summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--core/CHANGELOG.txt1060
-rw-r--r--core/COPYRIGHT.txt26
-rw-r--r--core/INSTALL.mysql.txt42
-rw-r--r--core/INSTALL.pgsql.txt44
-rw-r--r--core/INSTALL.sqlite.txt31
-rw-r--r--core/INSTALL.txt398
-rw-r--r--core/LICENSE.txt339
-rw-r--r--core/MAINTAINERS.txt288
-rw-r--r--core/UPGRADE.txt232
-rw-r--r--core/authorize.php177
-rw-r--r--core/cron.php29
-rw-r--r--core/includes/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php96
-rw-r--r--core/includes/Symfony/Component/ClassLoader/ClassCollectionLoader.php222
-rw-r--r--core/includes/Symfony/Component/ClassLoader/DebugUniversalClassLoader.php62
-rw-r--r--core/includes/Symfony/Component/ClassLoader/LICENSE19
-rw-r--r--core/includes/Symfony/Component/ClassLoader/MapClassLoader.php76
-rw-r--r--core/includes/Symfony/Component/ClassLoader/UniversalClassLoader.php265
-rw-r--r--core/includes/Symfony/Component/ClassLoader/composer.json22
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/ApacheRequest.php51
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/Cookie.php207
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/Exception/AccessDeniedException.php30
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/Exception/FileException.php21
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/Exception/FileNotFoundException.php30
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/Exception/UnexpectedTypeException.php20
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/Exception/UploadException.php21
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/File.php544
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/MimeType/ContentTypeMimeTypeGuesser.php62
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileBinaryMimeTypeGuesser.php71
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileinfoMimeTypeGuesser.php59
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesser.php125
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesserInterface.php30
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/File/UploadedFile.php225
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/FileBag.php157
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/HeaderBag.php306
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/LICENSE19
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/ParameterBag.php245
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/RedirectResponse.php60
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/Request.php1217
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/RequestMatcher.php177
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/RequestMatcherInterface.php33
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/Response.php891
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/ResponseHeaderBag.php238
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/ServerBag.php43
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/Session.php405
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/SessionStorage/ArraySessionStorage.php58
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/SessionStorage/FilesystemSessionStorage.php174
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/SessionStorage/NativeSessionStorage.php180
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/SessionStorage/PdoSessionStorage.php263
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/SessionStorage/SessionStorageInterface.php97
-rw-r--r--core/includes/Symfony/Component/HttpFoundation/composer.json22
-rw-r--r--core/includes/actions.inc383
-rw-r--r--core/includes/ajax.inc1206
-rw-r--r--core/includes/archiver.inc66
-rw-r--r--core/includes/authorize.inc324
-rw-r--r--core/includes/batch.inc534
-rw-r--r--core/includes/batch.queue.inc71
-rw-r--r--core/includes/bootstrap.inc3358
-rw-r--r--core/includes/cache-install.inc68
-rw-r--r--core/includes/cache.inc618
-rw-r--r--core/includes/common.inc7459
-rw-r--r--core/includes/database/database.inc3003
-rw-r--r--core/includes/database/log.inc159
-rw-r--r--core/includes/database/mysql/database.inc187
-rw-r--r--core/includes/database/mysql/install.inc33
-rw-r--r--core/includes/database/mysql/query.inc107
-rw-r--r--core/includes/database/mysql/schema.inc531
-rw-r--r--core/includes/database/pgsql/database.inc203
-rw-r--r--core/includes/database/pgsql/install.inc176
-rw-r--r--core/includes/database/pgsql/query.inc209
-rw-r--r--core/includes/database/pgsql/schema.inc617
-rw-r--r--core/includes/database/pgsql/select.inc108
-rw-r--r--core/includes/database/prefetch.inc507
-rw-r--r--core/includes/database/query.inc1953
-rw-r--r--core/includes/database/schema.inc723
-rw-r--r--core/includes/database/select.inc1630
-rw-r--r--core/includes/database/sqlite/database.inc511
-rw-r--r--core/includes/database/sqlite/install.inc51
-rw-r--r--core/includes/database/sqlite/query.inc160
-rw-r--r--core/includes/database/sqlite/schema.inc683
-rw-r--r--core/includes/database/sqlite/select.inc27
-rw-r--r--core/includes/date.inc196
-rw-r--r--core/includes/errors.inc293
-rw-r--r--core/includes/file.inc2451
-rw-r--r--core/includes/file.mimetypes.inc859
-rw-r--r--core/includes/filetransfer/filetransfer.inc418
-rw-r--r--core/includes/filetransfer/ftp.inc144
-rw-r--r--core/includes/filetransfer/local.inc76
-rw-r--r--core/includes/filetransfer/ssh.inc108
-rw-r--r--core/includes/form.inc4464
-rw-r--r--core/includes/gettext.inc1105
-rw-r--r--core/includes/graph.inc146
-rw-r--r--core/includes/image.inc442
-rw-r--r--core/includes/install.core.inc1859
-rw-r--r--core/includes/install.inc1307
-rw-r--r--core/includes/language.inc468
-rw-r--r--core/includes/locale.inc977
-rw-r--r--core/includes/lock.inc274
-rw-r--r--core/includes/mail.inc586
-rw-r--r--core/includes/menu.inc3798
-rw-r--r--core/includes/module.inc1007
-rw-r--r--core/includes/pager.inc658
-rw-r--r--core/includes/password.inc289
-rw-r--r--core/includes/path.inc581
-rw-r--r--core/includes/registry.inc186
-rw-r--r--core/includes/session.inc493
-rw-r--r--core/includes/standard.inc401
-rw-r--r--core/includes/stream_wrappers.inc836
-rw-r--r--core/includes/tablesort.inc252
-rw-r--r--core/includes/theme.inc2791
-rw-r--r--core/includes/theme.maintenance.inc211
-rw-r--r--core/includes/token.inc257
-rw-r--r--core/includes/unicode.entities.inc265
-rw-r--r--core/includes/unicode.inc601
-rw-r--r--core/includes/update.inc730
-rw-r--r--core/includes/updater.inc427
-rw-r--r--core/includes/utility.inc65
-rw-r--r--core/includes/uuid.inc154
-rw-r--r--core/includes/xmlrpc.inc625
-rw-r--r--core/includes/xmlrpcs.inc385
-rw-r--r--core/install.php32
-rw-r--r--core/misc/ajax.js622
-rw-r--r--core/misc/arrow-asc.pngbin0 -> 118 bytes
-rw-r--r--core/misc/arrow-desc.pngbin0 -> 118 bytes
-rw-r--r--core/misc/authorize.js28
-rw-r--r--core/misc/autocomplete.js321
-rw-r--r--core/misc/batch.js32
-rw-r--r--core/misc/collapse.js103
-rw-r--r--core/misc/configure.pngbin0 -> 248 bytes
-rw-r--r--core/misc/draggable.pngbin0 -> 268 bytes
-rw-r--r--core/misc/drupal.js412
-rw-r--r--core/misc/druplicon.pngbin0 -> 3905 bytes
-rw-r--r--core/misc/farbtastic/farbtastic.css36
-rw-r--r--core/misc/farbtastic/farbtastic.js8
-rw-r--r--core/misc/farbtastic/marker.pngbin0 -> 437 bytes
-rw-r--r--core/misc/farbtastic/mask.pngbin0 -> 2001 bytes
-rw-r--r--core/misc/farbtastic/wheel.pngbin0 -> 11589 bytes
-rw-r--r--core/misc/favicon.icobin0 -> 1150 bytes
-rw-r--r--core/misc/feed.pngbin0 -> 656 bytes
-rw-r--r--core/misc/form.js78
-rw-r--r--core/misc/forum-icons.pngbin0 -> 1765 bytes
-rw-r--r--core/misc/grippie.pngbin0 -> 106 bytes
-rw-r--r--core/misc/help.pngbin0 -> 294 bytes
-rw-r--r--core/misc/jquery.ba-bbq.js19
-rw-r--r--core/misc/jquery.cookie.js11
-rw-r--r--core/misc/jquery.form.js12
-rw-r--r--core/misc/jquery.js168
-rw-r--r--core/misc/jquery.once.js79
-rw-r--r--core/misc/machine-name.js119
-rw-r--r--core/misc/menu-collapsed-rtl.pngbin0 -> 107 bytes
-rw-r--r--core/misc/menu-collapsed.pngbin0 -> 105 bytes
-rw-r--r--core/misc/menu-expanded.pngbin0 -> 106 bytes
-rw-r--r--core/misc/menu-leaf.pngbin0 -> 126 bytes
-rw-r--r--core/misc/message-16-error.pngbin0 -> 519 bytes
-rw-r--r--core/misc/message-16-help.pngbin0 -> 668 bytes
-rw-r--r--core/misc/message-16-info.pngbin0 -> 733 bytes
-rw-r--r--core/misc/message-16-ok.pngbin0 -> 639 bytes
-rw-r--r--core/misc/message-16-warning.pngbin0 -> 442 bytes
-rw-r--r--core/misc/message-24-error.pngbin0 -> 733 bytes
-rw-r--r--core/misc/message-24-help.pngbin0 -> 1088 bytes
-rw-r--r--core/misc/message-24-info.pngbin0 -> 1011 bytes
-rw-r--r--core/misc/message-24-ok.pngbin0 -> 1058 bytes
-rw-r--r--core/misc/message-24-warning.pngbin0 -> 753 bytes
-rw-r--r--core/misc/permissions.pngbin0 -> 242 bytes
-rw-r--r--core/misc/powered-black-135x42.pngbin0 -> 2699 bytes
-rw-r--r--core/misc/powered-black-80x15.pngbin0 -> 1448 bytes
-rw-r--r--core/misc/powered-black-88x31.pngbin0 -> 2005 bytes
-rw-r--r--core/misc/powered-blue-135x42.pngbin0 -> 2879 bytes
-rw-r--r--core/misc/powered-blue-80x15.pngbin0 -> 943 bytes
-rw-r--r--core/misc/powered-blue-88x31.pngbin0 -> 2009 bytes
-rw-r--r--core/misc/powered-gray-135x42.pngbin0 -> 2594 bytes
-rw-r--r--core/misc/powered-gray-80x15.pngbin0 -> 698 bytes
-rw-r--r--core/misc/powered-gray-88x31.pngbin0 -> 1968 bytes
-rw-r--r--core/misc/print-rtl.css7
-rw-r--r--core/misc/print.css25
-rw-r--r--core/misc/progress.gifbin0 -> 5872 bytes
-rw-r--r--core/misc/progress.js106
-rw-r--r--core/misc/states.js423
-rw-r--r--core/misc/tabledrag.js1162
-rw-r--r--core/misc/tableheader.js111
-rw-r--r--core/misc/tableselect.js90
-rw-r--r--core/misc/textarea.js32
-rw-r--r--core/misc/throbber.gifbin0 -> 1336 bytes
-rw-r--r--core/misc/timezone.js66
-rw-r--r--core/misc/tree-bottom.pngbin0 -> 129 bytes
-rw-r--r--core/misc/tree.pngbin0 -> 130 bytes
-rw-r--r--core/misc/ui/images/ui-bg_flat_0_aaaaaa_40x100.pngbin0 -> 180 bytes
-rw-r--r--core/misc/ui/images/ui-bg_flat_75_ffffff_40x100.pngbin0 -> 178 bytes
-rw-r--r--core/misc/ui/images/ui-bg_glass_55_fbf9ee_1x400.pngbin0 -> 120 bytes
-rw-r--r--core/misc/ui/images/ui-bg_glass_65_ffffff_1x400.pngbin0 -> 105 bytes
-rw-r--r--core/misc/ui/images/ui-bg_glass_75_dadada_1x400.pngbin0 -> 111 bytes
-rw-r--r--core/misc/ui/images/ui-bg_glass_75_e6e6e6_1x400.pngbin0 -> 110 bytes
-rw-r--r--core/misc/ui/images/ui-bg_glass_95_fef1ec_1x400.pngbin0 -> 119 bytes
-rw-r--r--core/misc/ui/images/ui-bg_highlight-soft_75_cccccc_1x100.pngbin0 -> 101 bytes
-rw-r--r--core/misc/ui/images/ui-icons_222222_256x240.pngbin0 -> 4369 bytes
-rw-r--r--core/misc/ui/images/ui-icons_2e83ff_256x240.pngbin0 -> 4369 bytes
-rw-r--r--core/misc/ui/images/ui-icons_454545_256x240.pngbin0 -> 4369 bytes
-rw-r--r--core/misc/ui/images/ui-icons_888888_256x240.pngbin0 -> 4369 bytes
-rw-r--r--core/misc/ui/images/ui-icons_cd0a0a_256x240.pngbin0 -> 4369 bytes
-rw-r--r--core/misc/ui/jquery.effects.blind.min.js15
-rw-r--r--core/misc/ui/jquery.effects.bounce.min.js16
-rw-r--r--core/misc/ui/jquery.effects.clip.min.js15
-rw-r--r--core/misc/ui/jquery.effects.core.min.js31
-rw-r--r--core/misc/ui/jquery.effects.drop.min.js15
-rw-r--r--core/misc/ui/jquery.effects.explode.min.js16
-rw-r--r--core/misc/ui/jquery.effects.fade.min.js14
-rw-r--r--core/misc/ui/jquery.effects.fold.min.js15
-rw-r--r--core/misc/ui/jquery.effects.highlight.min.js15
-rw-r--r--core/misc/ui/jquery.effects.pulsate.min.js15
-rw-r--r--core/misc/ui/jquery.effects.scale.min.js21
-rw-r--r--core/misc/ui/jquery.effects.shake.min.js15
-rw-r--r--core/misc/ui/jquery.effects.slide.min.js15
-rw-r--r--core/misc/ui/jquery.effects.transfer.min.js15
-rw-r--r--core/misc/ui/jquery.ui.accordion.css20
-rw-r--r--core/misc/ui/jquery.ui.accordion.min.js31
-rw-r--r--core/misc/ui/jquery.ui.autocomplete.css54
-rw-r--r--core/misc/ui/jquery.ui.autocomplete.min.js32
-rw-r--r--core/misc/ui/jquery.ui.button.css39
-rw-r--r--core/misc/ui/jquery.ui.button.min.js26
-rw-r--r--core/misc/ui/jquery.ui.core.css42
-rw-r--r--core/misc/ui/jquery.ui.core.min.js18
-rw-r--r--core/misc/ui/jquery.ui.datepicker.css69
-rw-r--r--core/misc/ui/jquery.ui.datepicker.min.js82
-rw-r--r--core/misc/ui/jquery.ui.dialog.css22
-rw-r--r--core/misc/ui/jquery.ui.dialog.min.js41
-rw-r--r--core/misc/ui/jquery.ui.draggable.min.js51
-rw-r--r--core/misc/ui/jquery.ui.droppable.min.js27
-rw-r--r--core/misc/ui/jquery.ui.mouse.min.js18
-rw-r--r--core/misc/ui/jquery.ui.position.min.js17
-rw-r--r--core/misc/ui/jquery.ui.progressbar.css12
-rw-r--r--core/misc/ui/jquery.ui.progressbar.min.js17
-rw-r--r--core/misc/ui/jquery.ui.resizable.css21
-rw-r--r--core/misc/ui/jquery.ui.resizable.min.js48
-rw-r--r--core/misc/ui/jquery.ui.selectable.css11
-rw-r--r--core/misc/ui/jquery.ui.selectable.min.js23
-rw-r--r--core/misc/ui/jquery.ui.slider.css25
-rw-r--r--core/misc/ui/jquery.ui.slider.min.js34
-rw-r--r--core/misc/ui/jquery.ui.sortable.min.js61
-rw-r--r--core/misc/ui/jquery.ui.tabs.css19
-rw-r--r--core/misc/ui/jquery.ui.tabs.min.js36
-rw-r--r--core/misc/ui/jquery.ui.theme.css253
-rw-r--r--core/misc/ui/jquery.ui.widget.min.js16
-rw-r--r--core/misc/vertical-tabs-rtl.css14
-rw-r--r--core/misc/vertical-tabs.css72
-rw-r--r--core/misc/vertical-tabs.js205
-rw-r--r--core/misc/watchdog-error.pngbin0 -> 780 bytes
-rw-r--r--core/misc/watchdog-ok.pngbin0 -> 375 bytes
-rw-r--r--core/misc/watchdog-warning.pngbin0 -> 318 bytes
-rw-r--r--core/modules/README.txt9
-rw-r--r--core/modules/aggregator/aggregator-feed-source.tpl.php34
-rw-r--r--core/modules/aggregator/aggregator-item.tpl.php45
-rw-r--r--core/modules/aggregator/aggregator-summary-item.tpl.php23
-rw-r--r--core/modules/aggregator/aggregator-summary-items.tpl.php23
-rw-r--r--core/modules/aggregator/aggregator-wrapper.tpl.php18
-rw-r--r--core/modules/aggregator/aggregator.admin.inc597
-rw-r--r--core/modules/aggregator/aggregator.api.php231
-rw-r--r--core/modules/aggregator/aggregator.fetcher.inc61
-rw-r--r--core/modules/aggregator/aggregator.info8
-rw-r--r--core/modules/aggregator/aggregator.install280
-rw-r--r--core/modules/aggregator/aggregator.module767
-rw-r--r--core/modules/aggregator/aggregator.pages.inc531
-rw-r--r--core/modules/aggregator/aggregator.parser.inc321
-rw-r--r--core/modules/aggregator/aggregator.processor.inc203
-rw-r--r--core/modules/aggregator/aggregator.test859
-rw-r--r--core/modules/aggregator/aggregator.theme-rtl.css3
-rw-r--r--core/modules/aggregator/aggregator.theme.css4
-rw-r--r--core/modules/aggregator/tests/aggregator_test.info6
-rw-r--r--core/modules/aggregator/tests/aggregator_test.module58
-rw-r--r--core/modules/aggregator/tests/aggregator_test_atom.xml20
-rw-r--r--core/modules/aggregator/tests/aggregator_test_rss091.xml30
-rw-r--r--core/modules/block/block-admin-display-form.tpl.php67
-rw-r--r--core/modules/block/block.admin.css36
-rw-r--r--core/modules/block/block.admin.inc701
-rw-r--r--core/modules/block/block.api.php356
-rw-r--r--core/modules/block/block.info7
-rw-r--r--core/modules/block/block.install204
-rw-r--r--core/modules/block/block.js168
-rw-r--r--core/modules/block/block.module1003
-rw-r--r--core/modules/block/block.test757
-rw-r--r--core/modules/block/block.tpl.php55
-rw-r--r--core/modules/block/tests/block_test.info6
-rw-r--r--core/modules/block/tests/block_test.module28
-rw-r--r--core/modules/book/book-all-books-block.tpl.php18
-rw-r--r--core/modules/book/book-export-html.tpl.php50
-rw-r--r--core/modules/book/book-navigation.tpl.php51
-rw-r--r--core/modules/book/book-node-export-html.tpl.php24
-rw-r--r--core/modules/book/book-rtl.css11
-rw-r--r--core/modules/book/book.admin.inc264
-rw-r--r--core/modules/book/book.css51
-rw-r--r--core/modules/book/book.info8
-rw-r--r--core/modules/book/book.install92
-rw-r--r--core/modules/book/book.js22
-rw-r--r--core/modules/book/book.module1316
-rw-r--r--core/modules/book/book.pages.inc220
-rw-r--r--core/modules/book/book.test335
-rw-r--r--core/modules/color/color.admin-rtl.css44
-rw-r--r--core/modules/color/color.admin.css81
-rw-r--r--core/modules/color/color.info6
-rw-r--r--core/modules/color/color.install42
-rw-r--r--core/modules/color/color.js235
-rw-r--r--core/modules/color/color.module740
-rw-r--r--core/modules/color/color.test128
-rw-r--r--core/modules/color/images/hook-rtl.pngbin0 -> 116 bytes
-rw-r--r--core/modules/color/images/hook.pngbin0 -> 116 bytes
-rw-r--r--core/modules/color/images/lock.pngbin0 -> 230 bytes
-rw-r--r--core/modules/color/preview.html7
-rw-r--r--core/modules/color/preview.js34
-rw-r--r--core/modules/comment/comment-node-form.js32
-rw-r--r--core/modules/comment/comment-rtl.css5
-rw-r--r--core/modules/comment/comment-wrapper.tpl.php51
-rw-r--r--core/modules/comment/comment.admin.inc283
-rw-r--r--core/modules/comment/comment.api.php145
-rw-r--r--core/modules/comment/comment.css13
-rw-r--r--core/modules/comment/comment.info11
-rw-r--r--core/modules/comment/comment.install261
-rw-r--r--core/modules/comment/comment.module2719
-rw-r--r--core/modules/comment/comment.pages.inc119
-rw-r--r--core/modules/comment/comment.test1989
-rw-r--r--core/modules/comment/comment.tokens.inc243
-rw-r--r--core/modules/comment/comment.tpl.php90
-rw-r--r--core/modules/contact/contact.admin.inc206
-rw-r--r--core/modules/contact/contact.info7
-rw-r--r--core/modules/contact/contact.install89
-rw-r--r--core/modules/contact/contact.module257
-rw-r--r--core/modules/contact/contact.pages.inc291
-rw-r--r--core/modules/contact/contact.test416
-rw-r--r--core/modules/contextual/contextual-rtl.css16
-rw-r--r--core/modules/contextual/contextual.api.php40
-rw-r--r--core/modules/contextual/contextual.css99
-rw-r--r--core/modules/contextual/contextual.info5
-rw-r--r--core/modules/contextual/contextual.js43
-rw-r--r--core/modules/contextual/contextual.module168
-rw-r--r--core/modules/contextual/images/gear-select.pngbin0 -> 506 bytes
-rw-r--r--core/modules/dashboard/dashboard-rtl.css25
-rw-r--r--core/modules/dashboard/dashboard.api.php42
-rw-r--r--core/modules/dashboard/dashboard.css104
-rw-r--r--core/modules/dashboard/dashboard.info8
-rw-r--r--core/modules/dashboard/dashboard.install78
-rw-r--r--core/modules/dashboard/dashboard.js218
-rw-r--r--core/modules/dashboard/dashboard.module675
-rw-r--r--core/modules/dashboard/dashboard.test140
-rw-r--r--core/modules/dblog/dblog-rtl.css7
-rw-r--r--core/modules/dblog/dblog.admin.inc381
-rw-r--r--core/modules/dblog/dblog.css59
-rw-r--r--core/modules/dblog/dblog.info6
-rw-r--r--core/modules/dblog/dblog.install99
-rw-r--r--core/modules/dblog/dblog.module184
-rw-r--r--core/modules/dblog/dblog.test588
-rw-r--r--core/modules/entity/entity.api.php414
-rw-r--r--core/modules/entity/entity.controller.inc390
-rw-r--r--core/modules/entity/entity.info10
-rw-r--r--core/modules/entity/entity.module442
-rw-r--r--core/modules/entity/entity.query.inc953
-rw-r--r--core/modules/entity/tests/entity_cache_test.info7
-rw-r--r--core/modules/entity/tests/entity_cache_test.module27
-rw-r--r--core/modules/entity/tests/entity_cache_test_dependency.info6
-rw-r--r--core/modules/entity/tests/entity_cache_test_dependency.module17
-rw-r--r--core/modules/entity/tests/entity_crud_hook_test.info6
-rw-r--r--core/modules/entity/tests/entity_crud_hook_test.module266
-rw-r--r--core/modules/entity/tests/entity_crud_hook_test.test332
-rw-r--r--core/modules/entity/tests/entity_query.test1537
-rw-r--r--core/modules/field/field.api.php2635
-rw-r--r--core/modules/field/field.attach.inc1357
-rw-r--r--core/modules/field/field.crud.inc971
-rw-r--r--core/modules/field/field.default.inc268
-rw-r--r--core/modules/field/field.form.inc570
-rw-r--r--core/modules/field/field.info12
-rw-r--r--core/modules/field/field.info.inc900
-rw-r--r--core/modules/field/field.install377
-rw-r--r--core/modules/field/field.module1206
-rw-r--r--core/modules/field/field.multilingual.inc312
-rw-r--r--core/modules/field/modules/field_sql_storage/field_sql_storage.info8
-rw-r--r--core/modules/field/modules/field_sql_storage/field_sql_storage.install79
-rw-r--r--core/modules/field/modules/field_sql_storage/field_sql_storage.module743
-rw-r--r--core/modules/field/modules/field_sql_storage/field_sql_storage.test427
-rw-r--r--core/modules/field/modules/list/list.info8
-rw-r--r--core/modules/field/modules/list/list.install46
-rw-r--r--core/modules/field/modules/list/list.module471
-rw-r--r--core/modules/field/modules/list/tests/list.test374
-rw-r--r--core/modules/field/modules/list/tests/list_test.info6
-rw-r--r--core/modules/field/modules/list/tests/list_test.module23
-rw-r--r--core/modules/field/modules/number/number.info7
-rw-r--r--core/modules/field/modules/number/number.install45
-rw-r--r--core/modules/field/modules/number/number.module419
-rw-r--r--core/modules/field/modules/number/number.test133
-rw-r--r--core/modules/field/modules/options/options.api.php68
-rw-r--r--core/modules/field/modules/options/options.info7
-rw-r--r--core/modules/field/modules/options/options.module406
-rw-r--r--core/modules/field/modules/options/options.test518
-rw-r--r--core/modules/field/modules/text/text.info8
-rw-r--r--core/modules/field/modules/text/text.install67
-rw-r--r--core/modules/field/modules/text/text.js49
-rw-r--r--core/modules/field/modules/text/text.module611
-rw-r--r--core/modules/field/modules/text/text.test517
-rw-r--r--core/modules/field/tests/field.test3257
-rw-r--r--core/modules/field/tests/field_test.entity.inc494
-rw-r--r--core/modules/field/tests/field_test.field.inc379
-rw-r--r--core/modules/field/tests/field_test.info7
-rw-r--r--core/modules/field/tests/field_test.install150
-rw-r--r--core/modules/field/tests/field_test.module252
-rw-r--r--core/modules/field/tests/field_test.storage.inc473
-rw-r--r--core/modules/field/theme/field-rtl.css14
-rw-r--r--core/modules/field/theme/field.css28
-rw-r--r--core/modules/field/theme/field.tpl.php60
-rw-r--r--core/modules/field_ui/field_ui-rtl.css5
-rw-r--r--core/modules/field_ui/field_ui.admin.inc2077
-rw-r--r--core/modules/field_ui/field_ui.api.php204
-rw-r--r--core/modules/field_ui/field_ui.css59
-rw-r--r--core/modules/field_ui/field_ui.info7
-rw-r--r--core/modules/field_ui/field_ui.js330
-rw-r--r--core/modules/field_ui/field_ui.module369
-rw-r--r--core/modules/field_ui/field_ui.test645
-rw-r--r--core/modules/file/file.api.php66
-rw-r--r--core/modules/file/file.css35
-rw-r--r--core/modules/file/file.field.inc1003
-rw-r--r--core/modules/file/file.info7
-rw-r--r--core/modules/file/file.install98
-rw-r--r--core/modules/file/file.js149
-rw-r--r--core/modules/file/file.module996
-rw-r--r--core/modules/file/icons/application-octet-stream.pngbin0 -> 189 bytes
-rw-r--r--core/modules/file/icons/application-pdf.pngbin0 -> 346 bytes
-rw-r--r--core/modules/file/icons/application-x-executable.pngbin0 -> 189 bytes
-rw-r--r--core/modules/file/icons/audio-x-generic.pngbin0 -> 314 bytes
-rw-r--r--core/modules/file/icons/image-x-generic.pngbin0 -> 385 bytes
-rw-r--r--core/modules/file/icons/package-x-generic.pngbin0 -> 260 bytes
-rw-r--r--core/modules/file/icons/text-html.pngbin0 -> 265 bytes
-rw-r--r--core/modules/file/icons/text-plain.pngbin0 -> 220 bytes
-rw-r--r--core/modules/file/icons/text-x-generic.pngbin0 -> 220 bytes
-rw-r--r--core/modules/file/icons/text-x-script.pngbin0 -> 276 bytes
-rw-r--r--core/modules/file/icons/video-x-generic.pngbin0 -> 214 bytes
-rw-r--r--core/modules/file/icons/x-office-document.pngbin0 -> 196 bytes
-rw-r--r--core/modules/file/icons/x-office-presentation.pngbin0 -> 181 bytes
-rw-r--r--core/modules/file/icons/x-office-spreadsheet.pngbin0 -> 183 bytes
-rw-r--r--core/modules/file/tests/file.test1138
-rw-r--r--core/modules/file/tests/file_module_test.info6
-rw-r--r--core/modules/file/tests/file_module_test.module65
-rw-r--r--core/modules/filter/filter.admin.inc365
-rw-r--r--core/modules/filter/filter.admin.js44
-rw-r--r--core/modules/filter/filter.api.php323
-rw-r--r--core/modules/filter/filter.css53
-rw-r--r--core/modules/filter/filter.info8
-rw-r--r--core/modules/filter/filter.install149
-rw-r--r--core/modules/filter/filter.js20
-rw-r--r--core/modules/filter/filter.module1682
-rw-r--r--core/modules/filter/filter.pages.inc88
-rw-r--r--core/modules/filter/filter.test1752
-rw-r--r--core/modules/filter/tests/filter.url-input.txt36
-rw-r--r--core/modules/filter/tests/filter.url-output.txt36
-rw-r--r--core/modules/forum/forum-icon.tpl.php24
-rw-r--r--core/modules/forum/forum-list.tpl.php76
-rw-r--r--core/modules/forum/forum-rtl.css16
-rw-r--r--core/modules/forum/forum-submitted.tpl.php28
-rw-r--r--core/modules/forum/forum-topic-list.tpl.php68
-rw-r--r--core/modules/forum/forum.admin.inc313
-rw-r--r--core/modules/forum/forum.css50
-rw-r--r--core/modules/forum/forum.info10
-rw-r--r--core/modules/forum/forum.install246
-rw-r--r--core/modules/forum/forum.module1294
-rw-r--r--core/modules/forum/forum.pages.inc28
-rw-r--r--core/modules/forum/forum.test550
-rw-r--r--core/modules/forum/forums.tpl.php22
-rw-r--r--core/modules/help/help-rtl.css10
-rw-r--r--core/modules/help/help.admin.inc77
-rw-r--r--core/modules/help/help.api.php62
-rw-r--r--core/modules/help/help.css9
-rw-r--r--core/modules/help/help.info6
-rw-r--r--core/modules/help/help.module63
-rw-r--r--core/modules/help/help.test125
-rw-r--r--core/modules/image/image-rtl.css11
-rw-r--r--core/modules/image/image.admin.css60
-rw-r--r--core/modules/image/image.admin.inc907
-rw-r--r--core/modules/image/image.api.php199
-rw-r--r--core/modules/image/image.css14
-rw-r--r--core/modules/image/image.effects.inc316
-rw-r--r--core/modules/image/image.field.inc610
-rw-r--r--core/modules/image/image.info8
-rw-r--r--core/modules/image/image.install189
-rw-r--r--core/modules/image/image.module1224
-rw-r--r--core/modules/image/image.test1131
-rw-r--r--core/modules/image/sample.pngbin0 -> 168110 bytes
-rw-r--r--core/modules/image/tests/image_module_test.info7
-rw-r--r--core/modules/image/tests/image_module_test.module41
-rw-r--r--core/modules/locale/locale-rtl.css12
-rw-r--r--core/modules/locale/locale.admin.inc987
-rw-r--r--core/modules/locale/locale.api.php217
-rw-r--r--core/modules/locale/locale.bulk.inc247
-rw-r--r--core/modules/locale/locale.css32
-rw-r--r--core/modules/locale/locale.datepicker.js69
-rw-r--r--core/modules/locale/locale.info7
-rw-r--r--core/modules/locale/locale.install255
-rw-r--r--core/modules/locale/locale.module1174
-rw-r--r--core/modules/locale/locale.pages.inc431
-rw-r--r--core/modules/locale/locale.test2736
-rw-r--r--core/modules/locale/tests/locale_test.info6
-rw-r--r--core/modules/locale/tests/locale_test.js46
-rw-r--r--core/modules/locale/tests/locale_test.module105
-rw-r--r--core/modules/locale/tests/test.xx.po28
-rw-r--r--core/modules/menu/menu.admin.inc686
-rw-r--r--core/modules/menu/menu.admin.js47
-rw-r--r--core/modules/menu/menu.api.php87
-rw-r--r--core/modules/menu/menu.css12
-rw-r--r--core/modules/menu/menu.info7
-rw-r--r--core/modules/menu/menu.install71
-rw-r--r--core/modules/menu/menu.js66
-rw-r--r--core/modules/menu/menu.module777
-rw-r--r--core/modules/menu/menu.test722
-rw-r--r--core/modules/node/content_types.inc447
-rw-r--r--core/modules/node/content_types.js34
-rw-r--r--core/modules/node/node.admin.inc596
-rw-r--r--core/modules/node/node.api.php1275
-rw-r--r--core/modules/node/node.css10
-rw-r--r--core/modules/node/node.info11
-rw-r--r--core/modules/node/node.install471
-rw-r--r--core/modules/node/node.js43
-rw-r--r--core/modules/node/node.module3959
-rw-r--r--core/modules/node/node.pages.inc582
-rw-r--r--core/modules/node/node.test2293
-rw-r--r--core/modules/node/node.tokens.inc190
-rw-r--r--core/modules/node/node.tpl.php110
-rw-r--r--core/modules/node/tests/node_access_test.info6
-rw-r--r--core/modules/node/tests/node_access_test.install42
-rw-r--r--core/modules/node/tests/node_access_test.module218
-rw-r--r--core/modules/node/tests/node_test.info6
-rw-r--r--core/modules/node/tests/node_test.module151
-rw-r--r--core/modules/node/tests/node_test_exception.info6
-rw-r--r--core/modules/node/tests/node_test_exception.module16
-rw-r--r--core/modules/openid/login-bg.pngbin0 -> 205 bytes
-rw-r--r--core/modules/openid/openid-rtl.css18
-rw-r--r--core/modules/openid/openid.api.php116
-rw-r--r--core/modules/openid/openid.css46
-rw-r--r--core/modules/openid/openid.inc795
-rw-r--r--core/modules/openid/openid.info6
-rw-r--r--core/modules/openid/openid.install160
-rw-r--r--core/modules/openid/openid.js49
-rw-r--r--core/modules/openid/openid.module1007
-rw-r--r--core/modules/openid/openid.pages.inc115
-rw-r--r--core/modules/openid/openid.test648
-rw-r--r--core/modules/openid/tests/openid_test.info7
-rw-r--r--core/modules/openid/tests/openid_test.install17
-rw-r--r--core/modules/openid/tests/openid_test.module352
-rw-r--r--core/modules/overlay/images/background.pngbin0 -> 76 bytes
-rw-r--r--core/modules/overlay/images/close-rtl.pngbin0 -> 368 bytes
-rw-r--r--core/modules/overlay/images/close.pngbin0 -> 344 bytes
-rw-r--r--core/modules/overlay/overlay-child-rtl.css35
-rw-r--r--core/modules/overlay/overlay-child.css158
-rw-r--r--core/modules/overlay/overlay-child.js192
-rw-r--r--core/modules/overlay/overlay-parent.css50
-rw-r--r--core/modules/overlay/overlay-parent.js991
-rw-r--r--core/modules/overlay/overlay.api.php40
-rw-r--r--core/modules/overlay/overlay.info5
-rw-r--r--core/modules/overlay/overlay.install19
-rw-r--r--core/modules/overlay/overlay.module975
-rw-r--r--core/modules/overlay/overlay.tpl.php37
-rw-r--r--core/modules/path/path.admin.inc295
-rw-r--r--core/modules/path/path.api.php74
-rw-r--r--core/modules/path/path.info7
-rw-r--r--core/modules/path/path.js16
-rw-r--r--core/modules/path/path.module306
-rw-r--r--core/modules/path/path.test505
-rw-r--r--core/modules/php/php.info6
-rw-r--r--core/modules/php/php.install45
-rw-r--r--core/modules/php/php.module140
-rw-r--r--core/modules/php/php.test120
-rw-r--r--core/modules/poll/poll-bar--block.tpl.php26
-rw-r--r--core/modules/poll/poll-bar.tpl.php26
-rw-r--r--core/modules/poll/poll-results--block.tpl.php28
-rw-r--r--core/modules/poll/poll-results.tpl.php28
-rw-r--r--core/modules/poll/poll-rtl.css10
-rw-r--r--core/modules/poll/poll-vote.tpl.php29
-rw-r--r--core/modules/poll/poll.css51
-rw-r--r--core/modules/poll/poll.info7
-rw-r--r--core/modules/poll/poll.install149
-rw-r--r--core/modules/poll/poll.module1010
-rw-r--r--core/modules/poll/poll.pages.inc97
-rw-r--r--core/modules/poll/poll.test784
-rw-r--r--core/modules/poll/poll.tokens.inc107
-rw-r--r--core/modules/rdf/rdf.api.php115
-rw-r--r--core/modules/rdf/rdf.info6
-rw-r--r--core/modules/rdf/rdf.install49
-rw-r--r--core/modules/rdf/rdf.module882
-rw-r--r--core/modules/rdf/rdf.test760
-rw-r--r--core/modules/rdf/tests/rdf_test.info6
-rw-r--r--core/modules/rdf/tests/rdf_test.install25
-rw-r--r--core/modules/rdf/tests/rdf_test.module59
-rw-r--r--core/modules/search/search-result.tpl.php79
-rw-r--r--core/modules/search/search-results.tpl.php33
-rw-r--r--core/modules/search/search-rtl.css13
-rw-r--r--core/modules/search/search.admin.inc185
-rw-r--r--core/modules/search/search.api.php368
-rw-r--r--core/modules/search/search.css34
-rw-r--r--core/modules/search/search.extender.inc483
-rw-r--r--core/modules/search/search.info9
-rw-r--r--core/modules/search/search.install155
-rw-r--r--core/modules/search/search.module1328
-rw-r--r--core/modules/search/search.pages.inc160
-rw-r--r--core/modules/search/search.test1992
-rw-r--r--core/modules/search/tests/UnicodeTest.txt333
-rw-r--r--core/modules/search/tests/search_embedded_form.info6
-rw-r--r--core/modules/search/tests/search_embedded_form.module70
-rw-r--r--core/modules/search/tests/search_extra_type.info6
-rw-r--r--core/modules/search/tests/search_extra_type.module69
-rw-r--r--core/modules/shortcut/shortcut-rtl.css48
-rw-r--r--core/modules/shortcut/shortcut.admin.css8
-rw-r--r--core/modules/shortcut/shortcut.admin.inc779
-rw-r--r--core/modules/shortcut/shortcut.admin.js99
-rw-r--r--core/modules/shortcut/shortcut.api.php42
-rw-r--r--core/modules/shortcut/shortcut.css106
-rw-r--r--core/modules/shortcut/shortcut.info7
-rw-r--r--core/modules/shortcut/shortcut.install115
-rw-r--r--core/modules/shortcut/shortcut.module749
-rw-r--r--core/modules/shortcut/shortcut.pngbin0 -> 558 bytes
-rw-r--r--core/modules/shortcut/shortcut.test369
-rw-r--r--core/modules/simpletest/drupal_web_test_case.php3438
-rw-r--r--core/modules/simpletest/files/README.txt4
-rw-r--r--core/modules/simpletest/files/css_test_files/comment_hacks.css80
-rw-r--r--core/modules/simpletest/files/css_test_files/comment_hacks.css.optimized.css1
-rw-r--r--core/modules/simpletest/files/css_test_files/comment_hacks.css.unoptimized.css80
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_with_import.css30
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_with_import.css.optimized.css6
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_with_import.css.unoptimized.css30
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_without_import.css69
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_without_import.css.optimized.css4
-rw-r--r--core/modules/simpletest/files/css_test_files/css_input_without_import.css.unoptimized.css69
-rw-r--r--core/modules/simpletest/files/css_test_files/import1.css6
-rw-r--r--core/modules/simpletest/files/css_test_files/import2.css5
-rw-r--r--core/modules/simpletest/files/html-1.txt1
-rw-r--r--core/modules/simpletest/files/html-2.html1
-rw-r--r--core/modules/simpletest/files/image-1.pngbin0 -> 39325 bytes
-rw-r--r--core/modules/simpletest/files/image-2.jpgbin0 -> 1831 bytes
-rw-r--r--core/modules/simpletest/files/image-test.gifbin0 -> 183 bytes
-rw-r--r--core/modules/simpletest/files/image-test.jpgbin0 -> 1901 bytes
-rw-r--r--core/modules/simpletest/files/image-test.pngbin0 -> 125 bytes
-rw-r--r--core/modules/simpletest/files/javascript-1.txt3
-rw-r--r--core/modules/simpletest/files/javascript-2.script3
-rw-r--r--core/modules/simpletest/files/php-1.txt3
-rw-r--r--core/modules/simpletest/files/php-2.php2
-rw-r--r--core/modules/simpletest/files/sql-1.txt1
-rw-r--r--core/modules/simpletest/files/sql-2.sql1
-rw-r--r--core/modules/simpletest/simpletest.api.php60
-rw-r--r--core/modules/simpletest/simpletest.css89
-rw-r--r--core/modules/simpletest/simpletest.info41
-rw-r--r--core/modules/simpletest/simpletest.install182
-rw-r--r--core/modules/simpletest/simpletest.js103
-rw-r--r--core/modules/simpletest/simpletest.module506
-rw-r--r--core/modules/simpletest/simpletest.pages.inc510
-rw-r--r--core/modules/simpletest/simpletest.test505
-rw-r--r--core/modules/simpletest/tests/actions.test126
-rw-r--r--core/modules/simpletest/tests/actions_loop_test.info6
-rw-r--r--core/modules/simpletest/tests/actions_loop_test.install11
-rw-r--r--core/modules/simpletest/tests/actions_loop_test.module94
-rw-r--r--core/modules/simpletest/tests/ajax.test488
-rw-r--r--core/modules/simpletest/tests/ajax_forms_test.info6
-rw-r--r--core/modules/simpletest/tests/ajax_forms_test.module500
-rw-r--r--core/modules/simpletest/tests/ajax_test.info6
-rw-r--r--core/modules/simpletest/tests/ajax_test.module71
-rw-r--r--core/modules/simpletest/tests/batch.test405
-rw-r--r--core/modules/simpletest/tests/batch_test.callbacks.inc141
-rw-r--r--core/modules/simpletest/tests/batch_test.info6
-rw-r--r--core/modules/simpletest/tests/batch_test.module513
-rw-r--r--core/modules/simpletest/tests/bootstrap.test502
-rw-r--r--core/modules/simpletest/tests/cache.test375
-rw-r--r--core/modules/simpletest/tests/common.test2470
-rw-r--r--core/modules/simpletest/tests/common_test.css2
-rw-r--r--core/modules/simpletest/tests/common_test.info8
-rw-r--r--core/modules/simpletest/tests/common_test.module262
-rw-r--r--core/modules/simpletest/tests/common_test.print.css2
-rw-r--r--core/modules/simpletest/tests/common_test_cron_helper.info6
-rw-r--r--core/modules/simpletest/tests/common_test_cron_helper.module17
-rw-r--r--core/modules/simpletest/tests/common_test_info.txt9
-rw-r--r--core/modules/simpletest/tests/database_test.info6
-rw-r--r--core/modules/simpletest/tests/database_test.install217
-rw-r--r--core/modules/simpletest/tests/database_test.module241
-rw-r--r--core/modules/simpletest/tests/database_test.test3691
-rw-r--r--core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.info6
-rw-r--r--core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.module1
-rw-r--r--core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.info6
-rw-r--r--core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.module1
-rw-r--r--core/modules/simpletest/tests/error.test116
-rw-r--r--core/modules/simpletest/tests/error_test.info6
-rw-r--r--core/modules/simpletest/tests/error_test.module65
-rw-r--r--core/modules/simpletest/tests/file.test2748
-rw-r--r--core/modules/simpletest/tests/file_test.info7
-rw-r--r--core/modules/simpletest/tests/file_test.module461
-rw-r--r--core/modules/simpletest/tests/filetransfer.test168
-rw-r--r--core/modules/simpletest/tests/filter_test.info6
-rw-r--r--core/modules/simpletest/tests/filter_test.module62
-rw-r--r--core/modules/simpletest/tests/form.test1574
-rw-r--r--core/modules/simpletest/tests/form_test.file.inc48
-rw-r--r--core/modules/simpletest/tests/form_test.info6
-rw-r--r--core/modules/simpletest/tests/form_test.module1648
-rw-r--r--core/modules/simpletest/tests/graph.test195
-rw-r--r--core/modules/simpletest/tests/http.php32
-rw-r--r--core/modules/simpletest/tests/https.php31
-rw-r--r--core/modules/simpletest/tests/image.test460
-rw-r--r--core/modules/simpletest/tests/image_test.info6
-rw-r--r--core/modules/simpletest/tests/image_test.module138
-rw-r--r--core/modules/simpletest/tests/lock.test57
-rw-r--r--core/modules/simpletest/tests/mail.test418
-rw-r--r--core/modules/simpletest/tests/menu.test1621
-rw-r--r--core/modules/simpletest/tests/menu_test.info6
-rw-r--r--core/modules/simpletest/tests/menu_test.module527
-rw-r--r--core/modules/simpletest/tests/module.test304
-rw-r--r--core/modules/simpletest/tests/module_test.file.inc13
-rw-r--r--core/modules/simpletest/tests/module_test.info6
-rw-r--r--core/modules/simpletest/tests/module_test.install42
-rw-r--r--core/modules/simpletest/tests/module_test.module131
-rw-r--r--core/modules/simpletest/tests/password.test60
-rw-r--r--core/modules/simpletest/tests/path.test327
-rw-r--r--core/modules/simpletest/tests/registry.test142
-rw-r--r--core/modules/simpletest/tests/requirements1_test.info6
-rw-r--r--core/modules/simpletest/tests/requirements1_test.install21
-rw-r--r--core/modules/simpletest/tests/requirements1_test.module7
-rw-r--r--core/modules/simpletest/tests/requirements2_test.info8
-rw-r--r--core/modules/simpletest/tests/requirements2_test.module7
-rw-r--r--core/modules/simpletest/tests/schema.test384
-rw-r--r--core/modules/simpletest/tests/session.test536
-rw-r--r--core/modules/simpletest/tests/session_test.info6
-rw-r--r--core/modules/simpletest/tests/session_test.module192
-rw-r--r--core/modules/simpletest/tests/symfony.test31
-rw-r--r--core/modules/simpletest/tests/system.base.css6
-rw-r--r--core/modules/simpletest/tests/system_dependencies_test.info7
-rw-r--r--core/modules/simpletest/tests/system_dependencies_test.module1
-rw-r--r--core/modules/simpletest/tests/system_test.info7
-rw-r--r--core/modules/simpletest/tests/system_test.module399
-rw-r--r--core/modules/simpletest/tests/tablesort.test166
-rw-r--r--core/modules/simpletest/tests/taxonomy_test.info7
-rw-r--r--core/modules/simpletest/tests/taxonomy_test.install34
-rw-r--r--core/modules/simpletest/tests/taxonomy_test.module81
-rw-r--r--core/modules/simpletest/tests/theme.test490
-rw-r--r--core/modules/simpletest/tests/theme_test.info6
-rw-r--r--core/modules/simpletest/tests/theme_test.module137
-rw-r--r--core/modules/simpletest/tests/theme_test.template_test.tpl.php2
-rw-r--r--core/modules/simpletest/tests/unicode.test305
-rw-r--r--core/modules/simpletest/tests/update.test115
-rw-r--r--core/modules/simpletest/tests/update_script_test.info6
-rw-r--r--core/modules/simpletest/tests/update_script_test.install45
-rw-r--r--core/modules/simpletest/tests/update_script_test.module1
-rw-r--r--core/modules/simpletest/tests/update_test_1.info6
-rw-r--r--core/modules/simpletest/tests/update_test_1.install53
-rw-r--r--core/modules/simpletest/tests/update_test_1.module1
-rw-r--r--core/modules/simpletest/tests/update_test_2.info6
-rw-r--r--core/modules/simpletest/tests/update_test_2.install53
-rw-r--r--core/modules/simpletest/tests/update_test_2.module1
-rw-r--r--core/modules/simpletest/tests/update_test_3.info6
-rw-r--r--core/modules/simpletest/tests/update_test_3.install24
-rw-r--r--core/modules/simpletest/tests/update_test_3.module1
-rw-r--r--core/modules/simpletest/tests/upgrade/drupal-7.bare.database.php.gzbin0 -> 59294 bytes
-rw-r--r--core/modules/simpletest/tests/upgrade/drupal-7.filled.database.php.gzbin0 -> 77880 bytes
-rw-r--r--core/modules/simpletest/tests/upgrade/upgrade.test336
-rw-r--r--core/modules/simpletest/tests/upgrade/upgrade_bare.test31
-rw-r--r--core/modules/simpletest/tests/upgrade/upgrade_filled.test31
-rw-r--r--core/modules/simpletest/tests/url_alter_test.info6
-rw-r--r--core/modules/simpletest/tests/url_alter_test.install12
-rw-r--r--core/modules/simpletest/tests/url_alter_test.module67
-rw-r--r--core/modules/simpletest/tests/xmlrpc.test244
-rw-r--r--core/modules/simpletest/tests/xmlrpc_test.info6
-rw-r--r--core/modules/simpletest/tests/xmlrpc_test.module111
-rw-r--r--core/modules/statistics/statistics.admin.inc274
-rw-r--r--core/modules/statistics/statistics.info7
-rw-r--r--core/modules/statistics/statistics.install136
-rw-r--r--core/modules/statistics/statistics.module430
-rw-r--r--core/modules/statistics/statistics.pages.inc91
-rw-r--r--core/modules/statistics/statistics.test456
-rw-r--r--core/modules/statistics/statistics.tokens.inc63
-rw-r--r--core/modules/syslog/syslog.info6
-rw-r--r--core/modules/syslog/syslog.install15
-rw-r--r--core/modules/syslog/syslog.module109
-rw-r--r--core/modules/syslog/syslog.test41
-rw-r--r--core/modules/system/html.tpl.php58
-rw-r--r--core/modules/system/image.gd.inc367
-rw-r--r--core/modules/system/maintenance-page.tpl.php93
-rw-r--r--core/modules/system/page.tpl.php152
-rw-r--r--core/modules/system/region.tpl.php33
-rw-r--r--core/modules/system/system.admin-rtl.css86
-rw-r--r--core/modules/system/system.admin.css270
-rw-r--r--core/modules/system/system.admin.inc3173
-rw-r--r--core/modules/system/system.api.php4130
-rw-r--r--core/modules/system/system.archiver.inc139
-rw-r--r--core/modules/system/system.base-rtl.css54
-rw-r--r--core/modules/system/system.base.css281
-rw-r--r--core/modules/system/system.cron.js19
-rw-r--r--core/modules/system/system.info13
-rw-r--r--core/modules/system/system.install1647
-rw-r--r--core/modules/system/system.js137
-rw-r--r--core/modules/system/system.mail.inc112
-rw-r--r--core/modules/system/system.maintenance.css55
-rw-r--r--core/modules/system/system.menus-rtl.css37
-rw-r--r--core/modules/system/system.menus.css116
-rw-r--r--core/modules/system/system.messages-rtl.css13
-rw-r--r--core/modules/system/system.messages.css63
-rw-r--r--core/modules/system/system.module3988
-rw-r--r--core/modules/system/system.queue.inc371
-rw-r--r--core/modules/system/system.tar.inc1892
-rw-r--r--core/modules/system/system.test2564
-rw-r--r--core/modules/system/system.theme-rtl.css53
-rw-r--r--core/modules/system/system.theme.css239
-rw-r--r--core/modules/system/system.tokens.inc269
-rw-r--r--core/modules/system/system.updater.inc146
-rw-r--r--core/modules/system/theme.api.php230
-rw-r--r--core/modules/taxonomy/taxonomy-term.tpl.php51
-rw-r--r--core/modules/taxonomy/taxonomy.admin.inc982
-rw-r--r--core/modules/taxonomy/taxonomy.api.php214
-rw-r--r--core/modules/taxonomy/taxonomy.css13
-rw-r--r--core/modules/taxonomy/taxonomy.info10
-rw-r--r--core/modules/taxonomy/taxonomy.install248
-rw-r--r--core/modules/taxonomy/taxonomy.js40
-rw-r--r--core/modules/taxonomy/taxonomy.module1793
-rw-r--r--core/modules/taxonomy/taxonomy.pages.inc130
-rw-r--r--core/modules/taxonomy/taxonomy.test1286
-rw-r--r--core/modules/taxonomy/taxonomy.tokens.inc189
-rw-r--r--core/modules/toolbar/toolbar-rtl.css37
-rw-r--r--core/modules/toolbar/toolbar.css135
-rw-r--r--core/modules/toolbar/toolbar.info5
-rw-r--r--core/modules/toolbar/toolbar.js106
-rw-r--r--core/modules/toolbar/toolbar.module351
-rw-r--r--core/modules/toolbar/toolbar.pngbin0 -> 558 bytes
-rw-r--r--core/modules/toolbar/toolbar.tpl.php35
-rw-r--r--core/modules/tracker/tracker.css7
-rw-r--r--core/modules/tracker/tracker.info7
-rw-r--r--core/modules/tracker/tracker.install113
-rw-r--r--core/modules/tracker/tracker.module370
-rw-r--r--core/modules/tracker/tracker.pages.inc126
-rw-r--r--core/modules/tracker/tracker.test254
-rw-r--r--core/modules/translation/tests/translation_test.info6
-rw-r--r--core/modules/translation/tests/translation_test.module13
-rw-r--r--core/modules/translation/translation.info7
-rw-r--r--core/modules/translation/translation.module535
-rw-r--r--core/modules/translation/translation.pages.inc77
-rw-r--r--core/modules/translation/translation.test468
-rw-r--r--core/modules/trigger/tests/trigger_test.info5
-rw-r--r--core/modules/trigger/tests/trigger_test.module133
-rw-r--r--core/modules/trigger/trigger.admin.inc309
-rw-r--r--core/modules/trigger/trigger.api.php78
-rw-r--r--core/modules/trigger/trigger.info7
-rw-r--r--core/modules/trigger/trigger.install53
-rw-r--r--core/modules/trigger/trigger.module631
-rw-r--r--core/modules/trigger/trigger.test740
-rw-r--r--core/modules/update/tests/aaa_update_test.1_0.xml34
-rw-r--r--core/modules/update/tests/aaa_update_test.info5
-rw-r--r--core/modules/update/tests/aaa_update_test.module6
-rw-r--r--core/modules/update/tests/aaa_update_test.no-releases.xml2
-rw-r--r--core/modules/update/tests/aaa_update_test.tar.gzbin0 -> 384 bytes
-rw-r--r--core/modules/update/tests/bbb_update_test.1_0.xml34
-rw-r--r--core/modules/update/tests/bbb_update_test.info5
-rw-r--r--core/modules/update/tests/bbb_update_test.module6
-rw-r--r--core/modules/update/tests/ccc_update_test.1_0.xml34
-rw-r--r--core/modules/update/tests/ccc_update_test.info5
-rw-r--r--core/modules/update/tests/ccc_update_test.module6
-rw-r--r--core/modules/update/tests/drupal.0.xml34
-rw-r--r--core/modules/update/tests/drupal.1.xml51
-rw-r--r--core/modules/update/tests/drupal.2-sec.xml69
-rw-r--r--core/modules/update/tests/drupal.dev.xml50
-rw-r--r--core/modules/update/tests/update_test.info6
-rw-r--r--core/modules/update/tests/update_test.module164
-rw-r--r--core/modules/update/tests/update_test_basetheme.1_1-sec.xml52
-rw-r--r--core/modules/update/tests/update_test_subtheme.1_0.xml34
-rw-r--r--core/modules/update/update-rtl.css31
-rw-r--r--core/modules/update/update.api.php133
-rw-r--r--core/modules/update/update.authorize.inc314
-rw-r--r--core/modules/update/update.compare.inc789
-rw-r--r--core/modules/update/update.css131
-rw-r--r--core/modules/update/update.fetch.inc388
-rw-r--r--core/modules/update/update.info7
-rw-r--r--core/modules/update/update.install159
-rw-r--r--core/modules/update/update.manager.inc923
-rw-r--r--core/modules/update/update.module958
-rw-r--r--core/modules/update/update.report.inc324
-rw-r--r--core/modules/update/update.settings.inc120
-rw-r--r--core/modules/update/update.test699
-rw-r--r--core/modules/user/tests/user_form_test.info6
-rw-r--r--core/modules/user/tests/user_form_test.module64
-rw-r--r--core/modules/user/user-picture.tpl.php21
-rw-r--r--core/modules/user/user-profile-category.tpl.php35
-rw-r--r--core/modules/user/user-profile-item.tpl.php26
-rw-r--r--core/modules/user/user-profile.tpl.php37
-rw-r--r--core/modules/user/user-rtl.css34
-rw-r--r--core/modules/user/user.admin.inc1037
-rw-r--r--core/modules/user/user.api.php460
-rw-r--r--core/modules/user/user.css102
-rw-r--r--core/modules/user/user.entity.inc52
-rw-r--r--core/modules/user/user.info10
-rw-r--r--core/modules/user/user.install337
-rw-r--r--core/modules/user/user.js196
-rw-r--r--core/modules/user/user.module3897
-rw-r--r--core/modules/user/user.pages.inc553
-rw-r--r--core/modules/user/user.permissions.js69
-rw-r--r--core/modules/user/user.test2211
-rw-r--r--core/modules/user/user.tokens.inc131
-rw-r--r--core/scripts/cron-curl.sh3
-rw-r--r--core/scripts/cron-lynx.sh3
-rwxr-xr-xcore/scripts/drupal.sh144
-rw-r--r--core/scripts/dump-database-d6.sh101
-rw-r--r--core/scripts/dump-database-d7.sh90
-rw-r--r--core/scripts/generate-d6-content.sh206
-rw-r--r--core/scripts/generate-d7-content.sh308
-rwxr-xr-xcore/scripts/password-hash.sh91
-rwxr-xr-xcore/scripts/run-tests.sh672
-rw-r--r--core/themes/README.txt9
-rw-r--r--core/themes/bartik/bartik.info35
-rw-r--r--core/themes/bartik/color/base.pngbin0 -> 106 bytes
-rw-r--r--core/themes/bartik/color/color.inc132
-rw-r--r--core/themes/bartik/color/preview.css200
-rw-r--r--core/themes/bartik/color/preview.html65
-rw-r--r--core/themes/bartik/color/preview.js39
-rw-r--r--core/themes/bartik/color/preview.pngbin0 -> 106 bytes
-rw-r--r--core/themes/bartik/css/colors.css58
-rw-r--r--core/themes/bartik/css/ie-rtl.css48
-rw-r--r--core/themes/bartik/css/ie.css63
-rw-r--r--core/themes/bartik/css/layout-rtl.css22
-rw-r--r--core/themes/bartik/css/layout.css100
-rw-r--r--core/themes/bartik/css/maintenance-page.css67
-rw-r--r--core/themes/bartik/css/print.css46
-rw-r--r--core/themes/bartik/css/style-rtl.css271
-rw-r--r--core/themes/bartik/css/style.css1650
-rw-r--r--core/themes/bartik/images/add.pngbin0 -> 94 bytes
-rw-r--r--core/themes/bartik/images/buttons.pngbin0 -> 831 bytes
-rw-r--r--core/themes/bartik/images/comment-arrow-rtl.gifbin0 -> 97 bytes
-rw-r--r--core/themes/bartik/images/comment-arrow.gifbin0 -> 97 bytes
-rw-r--r--core/themes/bartik/images/search-button.pngbin0 -> 725 bytes
-rw-r--r--core/themes/bartik/images/tabs-border.pngbin0 -> 83 bytes
-rw-r--r--core/themes/bartik/logo.pngbin0 -> 3479 bytes
-rw-r--r--core/themes/bartik/screenshot.pngbin0 -> 19658 bytes
-rw-r--r--core/themes/bartik/template.php151
-rw-r--r--core/themes/bartik/templates/comment-wrapper.tpl.php51
-rw-r--r--core/themes/bartik/templates/comment.tpl.php105
-rw-r--r--core/themes/bartik/templates/maintenance-page.tpl.php66
-rw-r--r--core/themes/bartik/templates/node.tpl.php124
-rw-r--r--core/themes/bartik/templates/page.tpl.php246
-rw-r--r--core/themes/engines/phptemplate/phptemplate.engine25
-rw-r--r--core/themes/seven/ie.css18
-rw-r--r--core/themes/seven/ie7.css23
-rw-r--r--core/themes/seven/images/add.pngbin0 -> 160 bytes
-rw-r--r--core/themes/seven/images/arrow-asc.pngbin0 -> 88 bytes
-rw-r--r--core/themes/seven/images/arrow-desc.pngbin0 -> 95 bytes
-rw-r--r--core/themes/seven/images/arrow-next.pngbin0 -> 118 bytes
-rw-r--r--core/themes/seven/images/arrow-prev.pngbin0 -> 115 bytes
-rw-r--r--core/themes/seven/images/buttons.pngbin0 -> 786 bytes
-rw-r--r--core/themes/seven/images/fc-rtl.pngbin0 -> 76 bytes
-rw-r--r--core/themes/seven/images/fc.pngbin0 -> 82 bytes
-rw-r--r--core/themes/seven/images/list-item-rtl.pngbin0 -> 225 bytes
-rw-r--r--core/themes/seven/images/list-item.pngbin0 -> 195 bytes
-rw-r--r--core/themes/seven/images/task-check.pngbin0 -> 261 bytes
-rw-r--r--core/themes/seven/images/task-item-rtl.pngbin0 -> 178 bytes
-rw-r--r--core/themes/seven/images/task-item.pngbin0 -> 105 bytes
-rw-r--r--core/themes/seven/images/ui-icons-222222-256x240.pngbin0 -> 3702 bytes
-rw-r--r--core/themes/seven/images/ui-icons-454545-256x240.pngbin0 -> 3702 bytes
-rw-r--r--core/themes/seven/images/ui-icons-800000-256x240.pngbin0 -> 3702 bytes
-rw-r--r--core/themes/seven/images/ui-icons-888888-256x240.pngbin0 -> 3702 bytes
-rw-r--r--core/themes/seven/images/ui-icons-ffffff-256x240.pngbin0 -> 3702 bytes
-rw-r--r--core/themes/seven/jquery.ui.theme.css436
-rw-r--r--core/themes/seven/logo.pngbin0 -> 3905 bytes
-rw-r--r--core/themes/seven/maintenance-page.tpl.php47
-rw-r--r--core/themes/seven/page.tpl.php36
-rw-r--r--core/themes/seven/reset.css209
-rw-r--r--core/themes/seven/screenshot.pngbin0 -> 12298 bytes
-rw-r--r--core/themes/seven/seven.info14
-rw-r--r--core/themes/seven/style-rtl.css241
-rw-r--r--core/themes/seven/style.css998
-rw-r--r--core/themes/seven/template.php113
-rw-r--r--core/themes/seven/vertical-tabs-rtl.css21
-rw-r--r--core/themes/seven/vertical-tabs.css89
-rw-r--r--core/themes/stark/README.txt25
-rw-r--r--core/themes/stark/layout.css55
-rw-r--r--core/themes/stark/logo.pngbin0 -> 2326 bytes
-rw-r--r--core/themes/stark/screenshot.pngbin0 -> 11662 bytes
-rw-r--r--core/themes/stark/stark.info6
-rw-r--r--core/themes/tests/README.txt4
-rw-r--r--core/themes/tests/test_theme/template.php21
-rw-r--r--core/themes/tests/test_theme/test_theme.info16
-rw-r--r--core/themes/tests/test_theme/theme_test.template_test.tpl.php2
-rw-r--r--core/themes/tests/update_test_basetheme/update_test_basetheme.info4
-rw-r--r--core/themes/tests/update_test_subtheme/update_test_subtheme.info5
-rw-r--r--core/update.php474
-rw-r--r--core/xmlrpc.php21
973 files changed, 261361 insertions, 0 deletions
diff --git a/core/CHANGELOG.txt b/core/CHANGELOG.txt
new file mode 100644
index 000000000000..431574be1d23
--- /dev/null
+++ b/core/CHANGELOG.txt
@@ -0,0 +1,1060 @@
+
+Drupal 8.0, xxxx-xx-xx (development version)
+----------------------
+- Included the following Symfony2 components:
+ * ClassLoader - PSR-0-compatible autoload routines.
+ * HttpFoundation - Abstraction objects for HTTP requests and responses.
+- Removed modules from core
+ * The following modules have been removed from core, because contributed
+ modules with similar functionality are available:
+ * Blog
+ * Profile
+- Removed the Garland theme from core.
+- Universally Unique IDentifier (UUID):
+ * Support for generating and validating UUIDs.
+
+
+Drupal 7.0, 2011-01-05
+----------------------
+- Database:
+ * Fully rewritten database layer utilizing PHP 5's PDO abstraction layer.
+ * Drupal now requires MySQL >= 5.0.15 or PostgreSQL >= 8.3.
+ * Added query builders for INSERT, UPDATE, DELETE, MERGE, and SELECT queries.
+ * Support for master/slave replication, transactions, multi-insert queries,
+ and other features.
+ * Added support for the SQLite database engine.
+ * Default to InnoDB engine, rather than MyISAM, on MySQL when available.
+ This offers increased scalability and data integrity.
+- Security:
+ * Protected cron.php -- cron will only run if the proper key is provided.
+ * Implemented a pluggable password system and much stronger password hashes
+ that are compatible with the Portable PHP password hashing framework.
+ * Rate limited login attempts to prevent brute-force password guessing, and
+ improved the flood control API to allow variable time windows and
+ identifiers for limiting user access to resources.
+ * Transformed the "Update status" module into the "Update manager" which
+ can securely install or update modules and themes via a web interface.
+- Usability:
+ * Added contextual links (a.k.a. local tasks) to page elements, such as
+ blocks, nodes, or comments, which allows to perform the most common tasks
+ with a single click only.
+ * Improved installer requirements check.
+ * Improved support for integration of WYSIWYG editors.
+ * Implemented drag-and-drop positioning for input format listings.
+ * Implemented drag-and-drop positioning for language listing.
+ * Implemented drag-and-drop positioning for poll options.
+ * Provided descriptions and human-readable names for user permissions.
+ * Removed comment controls for users.
+ * Removed display order settings for comment module. Comment display
+ order can now be customized using the Views module.
+ * Removed the 'related terms' feature from taxonomy module since this can
+ now be achieved with Field API.
+ * Added additional features to the default install profile, and implemented
+ a "slimmed down" install profile designed for developers.
+ * Added a built-in, automated cron run feature, which is triggered by site
+ visitors.
+ * Added an administrator role which is assigned all permissions for
+ installed modules automatically.
+ * Image toolkits are now provided by modules (rather than requiring a
+ manual file copy to the includes directory).
+ * Added an edit tab to taxonomy term pages.
+ * Redesigned password strength validator.
+ * Redesigned the add content type screen.
+ * Highlight duplicate URL aliases.
+ * Renamed "input formats" to "text formats".
+ * Moved text format permissions to the main permissions page.
+ * Added configurable ability for users to cancel their own accounts.
+ * Added "vertical tabs", a reusable interface component that features
+ automatic summaries and increases usability.
+ * Replaced fieldsets on node edit and add pages with vertical tabs.
+- Performance:
+ * Improved performance on uncached page views by loading multiple core
+ objects in a single database query.
+ * Improved performance for logged-in users by reducing queries for path
+ alias lookups.
+ * Improved support for HTTP proxies (including reverse proxies), allowing
+ anonymous page views to be served entirely from the proxy.
+- Documentation:
+ * Hook API documentation now included in Drupal core.
+- News aggregator:
+ * Added OPML import functionality for RSS feeds.
+ * Optionally, RSS feeds may be configured to not automatically generate feed blocks.
+- Search:
+ * Added support for language-aware searches.
+- Aggregator:
+ * Introduced architecture that allows pluggable parsers and processors for
+ syndicating RSS and Atom feeds.
+ * Added options to suspend updating specific feeds and never discard feeds
+ items.
+- Testing:
+ * Added test framework and tests.
+- Improved time zone support:
+ * Drupal now uses PHP's time zone database when rendering dates in local
+ time. Site-wide and user-configured time zone offsets have been converted
+ to time zone names, e.g. Africa/Abidjan.
+ * In some cases the upgrade and install scripts do not choose the preferred
+ site default time zone. The automatically-selected time zone can be
+ corrected at admin/config/regional/settings.
+ * If your site is being upgraded from Drupal 6 and you do not have the
+ contributed date or event modules installed, user time zone settings will
+ fallback to the system time zone and will have to be reconfigured by each user.
+ * User-configured time zones now serve as the default time zone for PHP
+ date/time functions.
+- Filter system:
+ * Revamped the filter API and text format storage.
+ * Added support for default text formats to be assigned on a per-role basis.
+ * Refactored the HTML corrector to take advantage of PHP 5 features.
+- User system:
+ * Added clean API functions for creating, loading, updating, and deleting
+ user roles and permissions.
+ * Refactored the "access rules" component of user module: The user module
+ now provides a simple interface for blocking single IP addresses. The
+ previous functionality in the user module for restricting certain e-mail
+ addresses and usernames is now available as a contributed module. Further,
+ IP address range blocking is no longer supported and should be implemented
+ at the operating system level.
+ * Removed per-user themes: Contributed modules with similar functionality
+ are available.
+- OpenID:
+ * Added support for Gmail and Google Apps for Domain identifiers. Users can
+ now login with their user@example.com identifier when example.com is powered
+ by Google.
+ * Made the OpenID module more pluggable.
+- Added code registry:
+ * Using the registry, modules declare their includable files via their .info file,
+ allowing Drupal to lazy-load classes and interfaces as needed.
+- Theme system:
+ * Removed the Bluemarine, Chameleon and Pushbutton themes. These themes live
+ on as contributed themes (http://drupal.org/project/bluemarine,
+ http://drupal.org/project/chameleon and http://drupal.org/project/pushbutton).
+ * Added Stark theme to make analyzing Drupal's default HTML and CSS easier.
+ * Added Seven as the default administration theme.
+ * Variable preprocessing of theme hooks prior to template rendering now goes
+ through two phases: a 'preprocess' phase and a new 'process' phase. See
+ http://api.drupal.org/api/function/theme/7 for details.
+ * Theme hooks implemented as functions (rather than as templates) can now
+ also have preprocess (and process) functions. See
+ http://api.drupal.org/api/function/theme/7 for details.
+ * Added Bartik as the default theme.
+- File handling:
+ * Files are now first class Drupal objects with file_load(), file_save(),
+ and file_validate() functions and corresponding hooks.
+ * The file_move(), file_copy() and file_delete() functions now operate on
+ file objects and invoke file hooks so that modules are notified and can
+ respond to changes.
+ * For the occasions when only basic file manipulation are needed--such as
+ uploading a site logo--that don't require the overhead of databases and
+ hooks, the current unmanaged copy, move and delete operations have been
+ preserved but renamed to file_unmanaged_*().
+ * Rewrote file handling to use PHP stream wrappers to enable support for
+ both public and private files and to support pluggable storage mechanisms
+ and access to remote resources (e.g. S3 storage or Flickr photos).
+ * The mime_extension_mapping variable has been removed. Modules that need to
+ alter the default MIME type extension mappings should implement
+ hook_file_mimetype_mapping_alter().
+ * Added the hook_file_url_alter() hook, which makes it possible to serve
+ files from a CDN.
+ * Added a field specifically for uploading files, previously provided by
+ the contributed module FileField.
+- Image handling:
+ * Improved image handling, including better support for add-on image
+ libraries.
+ * Added API and interface for creating advanced image thumbnails.
+ * Inclusion of additional effects such as rotate and desaturate.
+ * Added a field specifically for uploading images, previously provided by
+ the contributed module ImageField.
+- Added aliased multi-site support:
+ * Added support for mapping domain names to sites directories.
+- Added RDF support:
+ * Modules can declare RDF namespaces which are serialized in the <html> tag
+ for RDFa support.
+ * Modules can specify how their data structure maps to RDF.
+ * Added support for RDFa export of nodes, comments, terms, users, etc. and
+ their fields.
+- Search engine optimization and web linking:
+ * Added a rel="canonical" link on node and comment pages to prevent
+ duplicate content indexing by search engines.
+ * Added a default rel="shortlink" link on node and comment pages that
+ advertises a short link as an alternative URL to third-party services.
+ * Meta information is now alterable by all modules before rendering.
+- Field API:
+ * Custom data fields may be attached to nodes, users, comments and taxonomy
+ terms.
+ * Node bodies and teasers are now Field API fields instead of
+ being a hard-coded property of node objects.
+ * In addition, any other object type may register with Field API
+ and allow custom data fields to be attached to itself.
+ * Provides most of the features of the former Content Construction
+ Kit (CCK) module.
+ * Taxonomy terms are now Field API fields that can be added to any fieldable
+ object.
+- Installer:
+ * Refactored the installer into an API that allows Drupal to be installed
+ via a command line script.
+- Page organization
+ * Made the help text area a full featured region with blocks.
+ * Site mission is replaced with the highlighted content block region and
+ separate RSS feed description settings.
+ * The footer message setting was removed in favor of custom blocks.
+ * Made the main page content a block which can be moved and ordered
+ with other blocks in the same region.
+ * Blocks can now return structured arrays for later rendering just
+ like page callbacks.
+- Translation system
+ * The translation system now supports message context (msgctxt).
+ * Added support for translatable fields to Field API.
+- JavaScript changes
+ * Upgraded the core JavaScript library to jQuery version 1.4.4.
+ * Upgraded the jQuery Forms library to 2.52.
+ * Added jQuery UI 1.8.7, which allows improvements to Drupal's user
+ experience.
+- Better module version support
+ * Modules now can specify which version of another module they depend on.
+- Removed modules from core
+ * The following modules have been removed from core, because contributed
+ modules with similar functionality are available:
+ * Blog API module
+ * Ping module
+ * Throttle module
+- Improved node access control system.
+ * All modules may now influence the access to a node at runtime, not just
+ the module that defined a node.
+ * Users may now be allowed to bypass node access restrictions without giving
+ them complete access to the site.
+ * Access control affects both published and unpublished nodes.
+ * Numerous other improvements to the node access system.
+- Actions system
+ * Simplified definitions of actions and triggers.
+ * Removed dependency on the combination of hooks and operations. Triggers
+ now directly map to module hooks.
+- Task handling
+ * Added a queue API to process many or long-running tasks.
+ * Added queue API support to cron API.
+ * Added a locking framework to coordinate long-running operations across
+ requests.
+
+Drupal 6.0, 2008-02-13
+----------------------
+- New, faster and better menu system.
+- New watchdog as a hook functionality.
+ * New hook_watchdog that can be implemented by any module to route log
+ messages to various destinations.
+ * Expands the severity levels from 3 (Error, Warning, Notice) to the 8
+ levels defined in RFC 3164.
+ * The watchdog module is now called dblog, and is optional, but enabled by
+ default in the default install profile.
+ * Extended the database log module so log messages can be filtered.
+ * Added syslog module: useful for monitoring large Drupal installations.
+- Added optional e-mail notifications when users are approved, blocked, or
+ deleted.
+- Drupal works with error reporting set to E_ALL.
+- Added scripts/drupal.sh to execute Drupal code from the command line. Useful
+ to use Drupal as a framework to build command-line tools.
+- Made signature support optional and made it possible to theme signatures.
+- Made it possible to filter the URL aliases on the URL alias administration
+ screen.
+- Language system improvements:
+ * Support for right to left languages.
+ * Language detection based on parts of the URL.
+ * Browser based language detection.
+ * Made it possible to specify a node's language.
+ * Support for translating posts on the site to different languages.
+ * Language dependent path aliases.
+ * Automatically import translations when adding a new language.
+ * JavaScript interface translation.
+ * Automatically import a module's translation upon enabling that module.
+- Moved "PHP input filter" to a standalone module so it can be deleted for
+ security reasons.
+- Usability:
+ * Improved handling of teasers in posts.
+ * Added sticky table headers.
+ * Check for clean URL support automatically with JavaScript.
+ * Removed default/settings.php. Instead the installer will create it from
+ default.settings.php.
+ * Made it possible to configure your own date formats.
+ * Remember anonymous comment posters.
+ * Only allow modules and themes to be enabled that have explicitly been
+ ported to the correct core API version.
+ * Can now specify the minimum PHP version required for a module within the
+ .info file.
+ * Drupal core no longer requires CREATE TEMPORARY TABLES or LOCK TABLES
+ database rights.
+ * Dynamically check password strength and confirmation.
+ * Refactored poll administration.
+ * Implemented drag-and-drop positioning for blocks, menu items, taxonomy
+ vocabularies and terms, forums, profile fields, and input format filters.
+- Theme system:
+ * Added .info files to themes and made it easier to specify regions and
+ features.
+ * Added theme registry: modules can directly provide .tpl.php files for
+ their themes without having to create theme_ functions.
+ * Used the Garland theme for the installation and maintenance pages.
+ * Added theme preprocess functions for themes that are templates.
+ * Added support for themeable functions in JavaScript.
+- Refactored update.php to a generic batch API to be able to run time-consuming
+ operations in multiple subsequent HTTP requests.
+- Installer:
+ * Themed the installer with the Garland theme.
+ * Added form to provide initial site information during installation.
+ * Added ability to provide extra installation steps programmatically.
+ * Made it possible to import interface translations at install time.
+- Added the HTML corrector filter:
+ * Fixes faulty and chopped off HTML in postings.
+ * Tags are now automatically closed at the end of the teaser.
+- Performance:
+ * Made it easier to conditionally load .include files and split up many core
+ modules.
+ * Added a JavaScript aggregator.
+ * Added block-level caching, improving performance for both authenticated
+ and anonymous users.
+ * Made Drupal work correctly when running behind a reverse proxy like
+ Squid or Pound.
+- File handling improvements:
+ * Entries in the files table are now keyed to a user instead of a node.
+ * Added reusable validation functions to check for uploaded file sizes,
+ extensions, and image resolution.
+ * Added ability to create and remove temporary files during a cron job.
+- Forum improvements:
+ * Any node type may now be posted in a forum.
+- Taxonomy improvements:
+ * Descriptions for terms are now shown on taxonomy/term pages as well
+ as RSS feeds.
+ * Added versioning support to categories by associating them with node
+ revisions.
+- Added support for OpenID.
+- Added support for triggering configurable actions.
+- Added the Update status module to automatically check for available updates
+ and warn sites if they are missing security updates or newer versions.
+ Sites deploying from CVS should use http://drupal.org/project/cvs_deploy.
+ Advanced settings provided by http://drupal.org/project/update_advanced.
+- Upgraded the core JavaScript library to jQuery version 1.2.3.
+- Added a new Schema API, which provides built-in support for core and
+ contributed modules to work with databases other than MySQL.
+- Removed drupal.module. The functionality lives on as the Site network
+ contributed module (http://drupal.org/project/site_network).
+- Removed old system updates. Updates from Drupal versions prior to 5.x will
+ require upgrading to 5.x before upgrading to 6.x.
+
+Drupal 5.7, 2008-01-28
+----------------------
+- fixed the input format configuration page.
+- fixed a variety of small bugs.
+
+Drupal 5.6, 2008-01-10
+----------------------
+- fixed a variety of small bugs.
+- fixed a security issue (Cross site request forgery), see SA-2008-005
+- fixed a security issue (Cross site scripting, UTF8), see SA-2008-006
+- fixed a security issue (Cross site scripting, register_globals), see SA-2008-007
+
+Drupal 5.5, 2007-12-06
+----------------------
+- fixed missing missing brackets in a query in the user module.
+- fixed taxonomy feed bug introduced by SA-2007-031
+
+Drupal 5.4, 2007-12-05
+----------------------
+- fixed a variety of small bugs.
+- fixed a security issue (SQL injection), see SA-2007-031
+
+Drupal 5.3, 2007-10-17
+----------------------
+- fixed a variety of small bugs.
+- fixed a security issue (HTTP response splitting), see SA-2007-024
+- fixed a security issue (Arbitrary code execution via installer), see SA-2007-025
+- fixed a security issue (Cross site scripting via uploads), see SA-2007-026
+- fixed a security issue (User deletion cross site request forgery), see SA-2007-029
+- fixed a security issue (API handling of unpublished comment), see SA-2007-030
+
+Drupal 5.2, 2007-07-26
+----------------------
+- changed hook_link() $teaser argument to match documentation.
+- fixed a variety of small bugs.
+- fixed a security issue (cross-site request forgery), see SA-2007-017
+- fixed a security issue (cross-site scripting), see SA-2007-018
+
+Drupal 5.1, 2007-01-29
+----------------------
+- fixed security issue (code execution), see SA-2007-005
+- fixed a variety of small bugs.
+
+Drupal 5.0, 2007-01-15
+----------------------
+- Completely retooled the administration page
+ * /Admin now contains an administration page which may be themed
+ * Reorganised administration menu items by task and by module
+ * Added a status report page with detailed PHP/MySQL/Drupal information
+- Added web-based installer which can:
+ * Check installation and run-time requirements
+ * Automatically generate the database configuration file
+ * Install pre-made 'install profiles' or distributions
+ * Import the database structure with automatic table prefixing
+ * Be localized
+- Added new default Garland theme
+- Added color module to change some themes' color schemes
+- Included the jQuery JavaScript library 1.0.4 and converted all core JavaScript to use it
+- Introduced the ability to alter mail sent from system
+- Module system:
+ * Added .info files for module meta-data
+ * Added support for module dependencies
+ * Improved module installation screen
+ * Moved core modules to their own directories
+ * Added support for module uninstalling
+- Added support for different cache backends
+- Added support for a generic "sites/all" directory.
+- Usability:
+ * Added support for auto-complete forms (AJAX) to user profiles.
+ * Made it possible to instantly assign roles to newly created user accounts.
+ * Improved configurability of the contact forms.
+ * Reorganized the settings pages.
+ * Made it easy to investigate popular search terms.
+ * Added a 'select all' checkbox and a range select feature to administration tables.
+ * Simplified the 'break' tag to split teasers from body.
+ * Use proper capitalization for titles, menu items and operations.
+- Integrated urlfilter.module into filter.module
+- Block system:
+ * Extended the block visibility settings with a role specific setting.
+ * Made it possible to customize all block titles.
+- Poll module:
+ * Optionally allow people to inspect all votes.
+ * Optionally allow people to cancel their vote.
+- Distributed authentication:
+ * Added default server option.
+- Added default robots.txt to control crawlers.
+- Database API:
+ * Added db_table_exists().
+- Blogapi module:
+ * 'Blogapi new' and 'blogapi edit' nodeapi operations.
+- User module:
+ * Added hook_profile_alter().
+ * E-mail verification is made optional.
+ * Added mass editing and filtering on admin/user/user.
+- PHP Template engine:
+ * Add the ability to look for a series of suggested templates.
+ * Look for page templates based upon the path.
+ * Look for block templates based upon the region, module, and delta.
+- Content system:
+ * Made it easier for node access modules to work well with each other.
+ * Added configurable content types.
+ * Changed node rendering to work with structured arrays.
+- Performance:
+ * Improved session handling: reduces database overhead.
+ * Improved access checking: reduces database overhead.
+ * Made it possible to do memcached based session management.
+ * Omit sidebars when serving a '404 - Page not found': saves CPU cycles and bandwidth.
+ * Added an 'aggressive' caching policy.
+ * Added a CSS aggregator and compressor (up to 40% faster page loads).
+- Removed the archive module.
+- Upgrade system:
+ * Created space for update branches.
+- Forms API:
+ * Made it possible to programmatically submit forms.
+ * Improved api for multistep forms.
+- Theme system:
+ * Split up and removed drupal.css.
+ * Added nested lists generation.
+ * Added a self-clearing block class.
+
+Drupal 4.7.11, 2008-01-10
+-------------------------
+- fixed a security issue (Cross site request forgery), see SA-2008-005
+- fixed a security issue (Cross site scripting, UTF8), see SA-2008-006
+- fixed a security issue (Cross site scripting, register_globals), see SA-2008-007
+
+Drupal 4.7.10, 2007-12-06
+-------------------------
+- fixed taxonomy feed bug introduced by SA-2007-031
+
+Drupal 4.7.9, 2007-12-05
+------------------------
+- fixed a security issue (SQL injection), see SA-2007-031
+
+Drupal 4.7.8, 2007-10-17
+----------------------
+- fixed a security issue (HTTP response splitting), see SA-2007-024
+- fixed a security issue (Cross site scripting via uploads), see SA-2007-026
+- fixed a security issue (API handling of unpublished comment), see SA-2007-030
+
+Drupal 4.7.7, 2007-07-26
+------------------------
+- fixed security issue (XSS), see SA-2007-018
+
+Drupal 4.7.6, 2007-01-29
+------------------------
+- fixed security issue (code execution), see SA-2007-005
+
+Drupal 4.7.5, 2007-01-05
+------------------------
+- Fixed security issue (XSS), see SA-2007-001
+- Fixed security issue (DoS), see SA-2007-002
+
+Drupal 4.7.4, 2006-10-18
+------------------------
+- Fixed security issue (XSS), see SA-2006-024
+- Fixed security issue (CSRF), see SA-2006-025
+- Fixed security issue (Form action attribute injection), see SA-2006-026
+
+Drupal 4.7.3, 2006-08-02
+------------------------
+- Fixed security issue (XSS), see SA-2006-011
+
+Drupal 4.7.2, 2006-06-01
+------------------------
+- Fixed critical upload issue, see SA-2006-007
+- Fixed taxonomy XSS issue, see SA-2006-008
+- Fixed a variety of small bugs.
+
+Drupal 4.7.1, 2006-05-24
+------------------------
+- Fixed critical SQL issue, see SA-2006-005
+- Fixed a serious upgrade related bug.
+- Fixed a variety of small bugs.
+
+Drupal 4.7.0, 2006-05-01
+------------------------
+- Added free tagging support.
+- Added a site-wide contact form.
+- Theme system:
+ * Added the PHPTemplate theme engine and removed the Xtemplate engine.
+ * Converted the bluemarine theme from XTemplate to PHPTemplate.
+ * Converted the pushbutton theme from XTemplate to PHPTemplate.
+- Usability:
+ * Reworked the 'request new password' functionality.
+ * Reworked the node and comment edit forms.
+ * Made it easy to add nodes to the navigation menu.
+ * Added site 'offline for maintenance' feature.
+ * Added support for auto-complete forms (AJAX).
+ * Added support for collapsible page sections (JS).
+ * Added support for resizable text fields (JS).
+ * Improved file upload functionality (AJAX).
+ * Reorganized some settings pages.
+ * Added friendly database error screens.
+ * Improved styling of update.php.
+- Refactored the forms API.
+ * Made it possible to alter, extend or theme forms.
+- Comment system:
+ * Added support for "mass comment operations" to ease repetitive tasks.
+ * Comment moderation has been removed.
+- Node system:
+ * Reworked the revision functionality.
+ * Removed the bookmarklet code. Third-party modules can now handle
+ This.
+- Upgrade system:
+ * Allows contributed modules to plug into the upgrade system.
+- Profiles:
+ * Added a block to display author information along with posts.
+ * Added support for private profile fields.
+- Statistics module:
+ * Added the ability to track page generation times.
+ * Made it possible to block certain IPs/hostnames.
+- Block system:
+ * Added support for theme-specific block regions.
+- Syndication:
+ * Made the aggregator module parse Atom feeds.
+ * Made the aggregator generate RSS feeds.
+ * Added RSS feed settings.
+- XML-RPC:
+ * Replaced the XML-RPC library by a better one.
+- Performance:
+ * Added 'loose caching' option for high-traffic sites.
+ * Improved performance of path aliasing.
+ * Added the ability to track page generation times.
+- Internationalization:
+ * Improved Unicode string handling API.
+ * Added support for PHP's multibyte string module.
+- Added support for PHP5's 'mysqli' extension.
+- Search module:
+ * Made indexer smarter and more robust.
+ * Added advanced search operators (e.g. phrase, node type, ...).
+ * Added customizable result ranking.
+- PostgreSQL support:
+ * Removed dependency on PL/pgSQL procedural language.
+- Menu system:
+ * Added support for external URLs.
+- Queue module:
+ * Removed from core.
+- HTTP handling:
+ * Added support for a tolerant Base URL.
+ * Output URIs relative to the root, without a base tag.
+
+Drupal 4.6.11, 2007-01-05
+-------------------------
+- Fixed security issue (XSS), see SA-2007-001
+- Fixed security issue (DoS), see SA-2007-002
+
+Drupal 4.6.10, 2006-10-18
+------------------------
+- Fixed security issue (XSS), see SA-2006-024
+- Fixed security issue (CSRF), see SA-2006-025
+- Fixed security issue (Form action attribute injection), see SA-2006-026
+
+Drupal 4.6.9, 2006-08-02
+------------------------
+- Fixed security issue (XSS), see SA-2006-011
+
+Drupal 4.6.8, 2006-06-01
+------------------------
+- Fixed critical upload issue, see SA-2006-007
+- Fixed taxonomy XSS issue, see SA-2006-008
+
+Drupal 4.6.7, 2006-05-24
+------------------------
+- Fixed critical SQL issue, see SA-2006-005
+
+Drupal 4.6.6, 2006-03-13
+------------------------
+- Fixed bugs, including 4 security vulnerabilities.
+
+Drupal 4.6.5, 2005-12-12
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.6.4, 2005-11-30
+------------------------
+- Fixed bugs, including 3 security vulnerabilities.
+
+Drupal 4.6.3, 2005-08-15
+------------------------
+- Fixed bugs, including a critical "arbitrary PHP code execution" bug.
+
+Drupal 4.6.2, 2005-06-29
+------------------------
+- Fixed bugs, including two critical "arbitrary PHP code execution" bugs.
+
+Drupal 4.6.1, 2005-06-01
+------------------------
+- Fixed bugs, including a critical input validation bug.
+
+Drupal 4.6.0, 2005-04-15
+------------------------
+- PHP5 compliance
+- Search:
+ * Added UTF-8 support to make it work with all languages.
+ * Improved search indexing algorithm.
+ * Improved search output.
+ * Impose a throttle on indexing of large sites.
+ * Added search block.
+- Syndication:
+ * Made the ping module ping pingomatic.com which, in turn, will ping all the major ping services.
+ * Made Drupal generate RSS 2.0 feeds.
+ * Made RSS feeds extensible.
+ * Added categories to RSS feeds.
+ * Added enclosures to RSS feeds.
+- Flood control mechanism:
+ * Added a mechanism to throttle certain operations.
+- Usability:
+ * Refactored the block configuration pages.
+ * Refactored the statistics pages.
+ * Refactored the watchdog pages.
+ * Refactored the throttle module configuration.
+ * Refactored the access rules page.
+ * Refactored the content administration page.
+ * Introduced forum configuration pages.
+ * Added a 'add child page' link to book pages.
+- Contact module:
+ * Added a simple contact module that allows users to contact each other using e-mail.
+- Multi-site configuration:
+ * Made it possible to run multiple sites from a single code base.
+- Added an image API: enables better image handling.
+- Block system:
+ * Extended the block visibility settings.
+- Theme system:
+ * Added new theme functions.
+- Database backend:
+ * The PEAR database backend is no longer supported.
+- Performance:
+ * Improved performance of the forum topics block.
+ * Improved performance of the tracker module.
+ * Improved performance of the node pages.
+- Documentation:
+ * Improved and extended PHPDoc/Doxygen comments.
+
+Drupal 4.5.8, 2006-03-13
+------------------------
+- Fixed bugs, including 3 security vulnerabilities.
+
+Drupal 4.5.7, 2005-12-12
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.5.6, 2005-11-30
+------------------------
+- Fixed bugs, including 3 security vulnerabilities.
+
+Drupal 4.5.5, 2005-08-15
+------------------------
+- Fixed bugs, including a critical "arbitrary PHP code execution" bug.
+
+Drupal 4.5.4, 2005-06-29
+------------------------
+- Fixed bugs, including two critical "arbitrary PHP code execution" bugs.
+
+Drupal 4.5.3, 2005-06-01
+------------------------
+- Fixed bugs, including a critical input validation bug.
+
+Drupal 4.5.2, 2005-01-15
+------------------------
+- Fixed bugs: a cross-site scripting (XSS) vulnerability has been fixed.
+
+Drupal 4.5.1, 2004-12-01
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.5.0, 2004-10-18
+------------------------
+- Navigation:
+ * Made it possible to add, delete, rename and move menu items.
+ * Introduced tabs and subtabs for local tasks.
+ * Reorganized the navigation menus.
+- User management:
+ * Added support for multiple roles per user.
+ * Made it possible to add custom profile fields.
+ * Made it possible to browse user profiles by field.
+- Node system:
+ * Added support for node-level permissions.
+- Comment module:
+ * Made it possible to leave contact information without having to register.
+- Upload module:
+ * Added support for uploading documents (includes images).
+- Forum module:
+ * Added support for sticky forum topics.
+ * Made it possible to track forum topics.
+- Syndication:
+ * Added support for RSS ping-notifications of http://technorati.com/.
+ * Refactored the categorization of syndicated news items.
+ * Added an URL alias for 'rss.xml'.
+ * Improved date parsing.
+- Database backend:
+ * Added support for multiple database connections.
+ * The PostgreSQL backend does no longer require PEAR.
+- Theme system:
+ * Changed all GIFs to PNGs.
+ * Reorganised the handling of themes, template engines, templates and styles.
+ * Unified and extended the available theme settings.
+ * Added theme screenshots.
+- Blocks:
+ * Added 'recent comments' block.
+ * Added 'categories' block.
+- Blogger API:
+ * Added support for auto-discovery of blogger API via RSD.
+- Performance:
+ * Added support for sending gzip compressed pages.
+ * Improved performance of the forum module.
+- Accessibility:
+ * Improved the accessibility of the archive module's calendar.
+ * Improved form handling and error reporting.
+ * Added HTTP redirects to prevent submitting twice when refreshing right after a form submission.
+- Refactored 403 (forbidden) handling and added support for custom 403 pages.
+- Documentation:
+ * Added PHPDoc/Doxygen comments.
+- Filter system:
+ * Added support for using multiple input formats on the site
+ * Expanded the embedded PHP-code feature so it can be used everywhere
+ * Added support for role-dependent filtering, through input formats
+- UI translation:
+ * Managing translations is now completely done through the administration interface
+ * Added support for importing/exporting gettext .po files
+
+Drupal 4.4.3, 2005-06-01
+------------------------
+- Fixed bugs, including a critical input validation bug.
+
+Drupal 4.4.2, 2004-07-04
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.4.1, 2004-05-01
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.4.0, 2004-04-01
+------------------------
+- Added support for the MetaWeblog API and MovableType extensions.
+- Added a file API: enables better document management.
+- Improved the watchdog and search module to log search keys.
+- News aggregator:
+ * Added support for conditional GET.
+ * Added OPML feed subscription list.
+ * Added support for <image>, <pubDate>, <dc:date>, <dcterms:created>, <dcterms:issued> and <dcterms:modified>.
+- Comment module:
+ * Made it possible to disable the "comment viewing controls".
+- Performance:
+ * Improved module loading when serving cached pages.
+ * Made it possible to automatically disable modules when under heavy load.
+ * Made it possible to automatically disable blocks when under heavy load.
+ * Improved performance and memory footprint of the locale module.
+- Theme system:
+ * Made all theme functions start with 'theme_'.
+ * Made all theme functions return their output.
+ * Migrated away from using the BaseTheme class.
+ * Added many new theme functions and refactored existing theme functions.
+ * Added avatar support to 'Xtemplate'.
+ * Replaced theme 'UnConeD' by 'Chameleon'.
+ * Replaced theme 'Marvin' by 'Pushbutton'.
+- Usability:
+ * Added breadcrumb navigation to all pages.
+ * Made it possible to add context-sensitive help to all pages.
+ * Replaced drop-down menus by radio buttons where appropriate.
+ * Removed the 'magic_quotes_gpc = 0' requirement.
+ * Added a 'book navigation' block.
+- Accessibility:
+ * Made themes degrade gracefully in absence of CSS.
+ * Grouped form elements using '<fieldset>' and '<legend>' tags.
+ * Added '<label>' tags to form elements.
+- Refactored 404 (file not found) handling and added support for custom 404 pages.
+- Improved the filter system to prevent conflicts between filters:
+ * Made it possible to change the order in which filters are applied.
+- Documentation:
+ * Added PHPDoc/Doxygen comments.
+
+Drupal 4.3.2, 2004-01-01
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.3.1, 2003-12-01
+------------------------
+- Fixed bugs: no critical bugs were identified.
+
+Drupal 4.3.0, 2003-11-01
+------------------------
+- Added support for configurable URLs.
+- Added support for sortable table columns.
+- Database backend:
+ * Added support for selective database table prefixing.
+- Performance:
+ * Optimized many SQL queries for speed by converting left joins to inner joins.
+- Comment module:
+ * Rewrote the comment housekeeping code to be much more efficient and scalable.
+ * Changed the comment module to use the standard pager.
+- User module:
+ * Added support for multiple sessions per user.
+ * Added support for anonymous user sessions.
+- Forum module:
+ * Improved the forum views and the themability thereof.
+- Book module:
+ * Improved integration of non-book nodes in the book outline.
+- Usability:
+ * Added support for "mass node operations" to ease repetitive tasks.
+ * Added support for breadcrumb navigation to several modules' user pages.
+ * Integrated the administration pages with the normal user pages.
+
+Drupal 4.2.0, 2003-08-01
+------------------------
+- Added support for clean URLs.
+- Added textarea hook and support for onload attributes: enables integration of WYSIWYG editors.
+- Rewrote the RSS/RDF parser:
+ * It will now use PHP's built-in XML parser to parse news feeds.
+- Rewrote the administration pages:
+ * Improved the navigational elements and added breadcrumb navigation.
+ * Improved the look and feel.
+ * Added context-sensitive help.
+- Database backend:
+ * Fixed numerous SQL queries to make Drupal ANSI compliant.
+ * Added MSSQL database scheme.
+- Search module:
+ * Changed the search module to use implicit AND'ing instead of implicit OR'ing.
+- Node system:
+ * Replaced the "post content" permission by more fine-grained permissions.
+ * Improved content submission:
+ + Improved teasers: teasers are now optional, teaser length can be configured, teaser and body are edited in a single textarea, users will no longer be bothered with teasers when the post is too short for one.
+ + Added the ability to preview both the short and the full version of your posts.
+ * Extended the node API which allows for better integration.
+ * Added default node settings to control the behavior for promotion, moderation and other options.
+- Themes:
+ * Replaced theme "Goofy" by "Xtemplate", a template driven theme.
+- Removed the 'register_globals = on' requirement.
+- Added better installation instructions.
+
+Drupal 4.1.0, 2003-02-01
+------------------------
+- Collaboratively revised and expanded the Drupal documentation.
+- Rewrote comment.module:
+ * Reintroduced comment rating/moderation.
+ * Added support for comment paging.
+ * Performance improvements: improved comment caching, faster SQL queries, etc.
+- Rewrote block.module:
+ * Performance improvements: blocks are no longer rendered when not displayed.
+- Rewrote forum.module:
+ * Added a lot of features one can find in stand-alone forum software including but not limited to support for topic paging, added support for icons, rewrote the statistics module, etc.
+- Rewrote statistics.module:
+ * Collects access counts for each node, referrer logs, number of users/guests.
+ * Export blocks displaying top viewed nodes over last 24 hour period, top viewed nodes over all time, last nodes viewed, how many users/guest online.
+- Added throttle.module:
+ * Auto-throttle congestion control mechanism: Drupal can adapt itself based on the server load.
+- Added profile.module:
+ * Enables to extend the user and registration page.
+- Added pager support to the main page.
+- Replaced weblogs.module by ping.module:
+ * Added support for normal and RSS notifications of http://blo.gs/.
+ * Added support for RSS ping-notifications of http://weblogs.com/.
+- Removed the rating module
+- Themes:
+ * Removed a significant portion of hard-coded mark-up.
+
+Drupal 4.0.0, 2002-06-15
+------------------------
+- Added tracker.module:
+ * Replaces the previous "your [site]" links (recent comments and nodes).
+- Added weblogs.module:
+ * This will ping weblogs.com when new content is promoted.
+- Added taxonomy module which replaces the meta module.
+ * Supports relations, hierarchies and synonyms.
+- Added a caching system:
+ * Speeds up pages for anonymous users and reduces system load.
+- Added support for external SMTP libraries.
+- Added an archive extension to the calendar.
+- Added support for the Blogger API.
+- Themes:
+ * Cleaned up the theme system.
+ * Moved themes that are not maintained to contributions CVS repository.
+- Database backend:
+ * Changed to PEAR database abstraction layer.
+ * Using ANSI SQL queries to be more portable.
+- Rewrote the user system:
+ * Added support for Drupal authentication through XML-RPC and through a Jabber server.
+ * Added support for modules to add more user data.
+ * Users may delete their own account.
+ * Added who's new and who's online blocks.
+- Changed block system:
+ * Various hard coded blocks are now dynamic.
+ * Blocks can now be enabled and/or be set by the user.
+ * Blocks can be set to only show up on some pages.
+ * Merged box module with block module.
+- Node system:
+ * Blogs can be updated.
+ * Teasers (abstracts) on all node types.
+ * Improved error checking.
+ * Content versioning support.
+ * Usability improvements.
+- Improved book module to support text, HTML and PHP pages.
+- Improved comment module to mark new comments.
+- Added a general outliner which will let any node type be linked to a book.
+- Added an update script that lets you upgrade from previous releases or on a day to day basis when using the development tree.
+- Search module:
+ * Improved the search system by making it context sensitive.
+ * Added indexing.
+- Various updates:
+ * Changed output to valid XHTML.
+ * Improved multiple sites using the same Drupal database support.
+ * Added support for session IDs in URLs instead of cookies.
+ * Made the type of content on the front page configurable.
+ * Made each cloud site have its own settings.
+ * Modules and themes can now be enabled/disabled using the administration pages.
+ * Added URL abstraction for links.
+ * Usability changes (renamed links, better UI, etc).
+- Collaboratively revised and expanded the Drupal documentation.
+
+Drupal 3.0.1, 2001-10-15
+------------------------
+- Various updates:
+ * Added missing translations
+ * Updated the themes: tidied up some HTML code and added new Drupal logos.
+
+Drupal 3.0.0, 2001-09-15
+------------------------
+- Major overhaul of the entire underlying design:
+ * Everything is based on nodes: nodes are a conceptual "black box" to couple and manage different types of content and that promotes reusing existing code, thus reducing the complexity and size of Drupal as well as improving long-term stability.
+- Rewrote submission/moderation queue and renamed it to queue.module.
+- Removed FAQ and documentation module and merged them into a "book module".
+- Removed ban module and integrated it into account.module as "access control":
+ * Access control is based on much more powerful regular expressions (regex) now rather than on MySQL pattern matching.
+- Rewrote watchdog and submission throttle:
+ * Improved watchdog messages and added watchdog filter.
+- Rewrote headline code and renamed it to import.module and export.module:
+ * Added various improvements, including a better parser, bundles and better control over individual feeds.
+- Rewrote section code and renamed it to meta.module:
+ * Supports unlimited amount of nested topics. Topics can be nested to create a multi-level hierarchy.
+- Rewrote configuration file resolving:
+ * Drupal tries to locate a configuration file that matches your domain name or uses conf.php if the former failed. Note also that the configuration files got renamed from .conf to .php for security's sake on mal-configured Drupal sites.
+- Added caching support which makes Drupal extremely scalable.
+- Added access.module:
+ * Allows you to set up 'roles' (groups) and to bind a set of permissions to each group.
+- Added blog.module.
+- Added poll.module.
+- Added system.module:
+ * Moved most of the configuration options from hostname.conf to the new administration section.
+ * Added support for custom "filters".
+- Added statistics.module
+- Added moderate.module:
+ * Allows to assign users editorial/moderator rights to certain nodes or topics.
+- Added page.module:
+ * Allows creation of static (and dynamic) pages through the administration interface.
+- Added help.module:
+ * Groups all available module documentation on a single page.
+- Added forum.module:
+ * Added an integrated forum.
+- Added cvs.module and cvs-to-sql.pl:
+ * Allows to display and mail CVS log messages as daily digests.
+- Added book.module:
+ * Allows collaborative handbook writing: primary used for Drupal documentation.
+- Removed cron.module and integrated it into conf.module.
+- Removed module.module as it was no longer needed.
+- Various updates:
+ * Added "auto-post new submissions" feature versus "moderate new submissions".
+ * Introduced links/Drupal tags: [[link]]
+ * Added preview functionality when submitting new content (such as a story) from the administration pages.
+ * Made the administration section only show those links a user has access to.
+ * Made all modules use specific form_* functions to guarantee a rock-solid forms and more consistent layout.
+ * Improved scheduler:
+ + Content can be scheduled to be 'posted', 'queued' and 'hidden'.
+ * Improved account module:
+ + Added "access control" to allow/deny certain usernames/e-mail addresses/hostnames.
+ * Improved locale module:
+ + Added new overview to easy the translation process.
+ * Improved comment module:
+ + Made it possible to permanently delete comments.
+ * Improved rating module
+ * Improved story module:
+ + Added preview functionality for administrators.
+ + Made it possible to permanently delete stories.
+ * Improved themes:
+ + W3C validation on a best effort basis.
+ + Removed $theme->control() from themes.
+ + Added theme "goofy".
+- Collaboratively revised and expanded the Drupal documentation.
+
+Drupal 2.0.0, 2001-03-15
+------------------------
+- Rewrote the comment/discussion code:
+ * Comment navigation should be less confusing now.
+ * Additional/alternative display and order methods have been added.
+ * Modules can be extended with a "comment system": modules can embed the existing comment system without having to write their own, duplicate comment system.
+- Added sections and section manager:
+ * Story sections can be maintained from the administration pages.
+ * Story sections make the open submission more adaptive in that you can set individual post, dump and expiration thresholds for each section according to the story type and urgency level: stories in certain sections do not "expire" and might stay interesting and active as time passes by, whereas news-related stories are only considered "hot" over a short period of time.
+- Multiple vhosts + multiple directories:
+ * You can set up multiple Drupal sites on top of the same physical source tree either by using vhosts or sub-directories.
+- Added "user ratings" similar to SlashCode's Karma or Scoop's Mojo:
+ * All rating logic is packed into a module to ease experimenting with different rating heuristics/algorithms.
+- Added "search infrastructure":
+ * Improved search page and integrated search functionality in the administration pages.
+- Added translation / localization / internationalization support:
+ * Because many people would love to see their website showing a lot less of English, and far more of their own language, Drupal provides a framework to set up a multi-lingual website or to overwrite the default English text in English.
+- Added fine-grained user permission (or group) system:
+ * Users can be granted access to specific administration sections. Example: a FAQ maintainer can be given access to maintain the FAQ and translators can be given access to the translation pages.
+- Added FAQ module
+- Changed the "open submission queue" into a (optional) module.
+- Various updates:
+ * Improved account module:
+ + User accounts can be deleted.
+ + Added fine-grained permission support.
+ * Improved block module
+ * Improved diary module:
+ + Diary entries can be deleted
+ * Improved headline module:
+ + Improved parser to support more "generic" RDF/RSS/XML backend.
+ * Improved module module
+ * Improved watchdog module
+ * Improved database abstraction layer
+ * Improved themes:
+ + W3C validation on a best effort basis
+ + Added theme "example" (alias "Stone Age")
+ * Added new scripts to directory "scripts"
+ * Added directory "misc"
+ * Added CREDITS file
+- Revised documentation
+
+Drupal 1.0.0, 2001-01-15
+------------------------
+- Initial release
diff --git a/core/COPYRIGHT.txt b/core/COPYRIGHT.txt
new file mode 100644
index 000000000000..18b074e754a4
--- /dev/null
+++ b/core/COPYRIGHT.txt
@@ -0,0 +1,26 @@
+
+All Drupal code is Copyright 2001 - 2010 by the original authors.
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or (at
+your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program as the file LICENSE.txt; if not, please see
+http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
+
+Drupal is a registered trademark of Dries Buytaert.
+
+Drupal includes works under other copyright notices and distributed
+according to the terms of the GNU General Public License or a compatible
+license, including:
+
+ jQuery - Copyright (c) 2008 - 2009 John Resig
+
+ Symfony2 - Copyright (c) 2004 - 2011 Fabien Potencier
diff --git a/core/INSTALL.mysql.txt b/core/INSTALL.mysql.txt
new file mode 100644
index 000000000000..a7c292e3171e
--- /dev/null
+++ b/core/INSTALL.mysql.txt
@@ -0,0 +1,42 @@
+
+CREATE THE MySQL DATABASE
+--------------------------
+
+This step is only necessary if you don't already have a database set up (e.g.,
+by your host). In the following examples, 'username' is an example MySQL user
+which has the CREATE and GRANT privileges. Use the appropriate user name for
+your system.
+
+First, you must create a new database for your Drupal site (here, 'databasename'
+is the name of the new database):
+
+ mysqladmin -u username -p create databasename
+
+MySQL will prompt for the 'username' database password and then create the
+initial database files. Next you must log in and set the access database rights:
+
+ mysql -u username -p
+
+Again, you will be asked for the 'username' database password. At the MySQL
+prompt, enter following command:
+
+ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER
+ ON databasename.*
+ TO 'username'@'localhost' IDENTIFIED BY 'password';
+
+where
+
+ 'databasename' is the name of your database
+ 'username@localhost' is the username of your MySQL account
+ 'password' is the password required for that username
+
+Note: Unless your database user has all of the privileges listed above, you will
+not be able to run Drupal.
+
+If successful, MySQL will reply with:
+
+ Query OK, 0 rows affected
+
+If the InnoDB storage engine is available, it will be used for all database
+tables. InnoDB provides features over MyISAM such as transaction support,
+row-level locks, and consistent non-locking reads.
diff --git a/core/INSTALL.pgsql.txt b/core/INSTALL.pgsql.txt
new file mode 100644
index 000000000000..8fe80433bd67
--- /dev/null
+++ b/core/INSTALL.pgsql.txt
@@ -0,0 +1,44 @@
+
+CREATE THE PostgreSQL DATABASE
+------------------------------
+
+Note that the database must be created with UTF-8 (Unicode) encoding.
+
+1. CREATE DATABASE USER
+
+ This step is only necessary if you don't already have a user set up (e.g., by
+ your host), or want to create a new user for use with Drupal only. The
+ following command creates a new user named 'username' and asks for a password
+ for that user:
+
+ createuser --pwprompt --encrypted --no-createrole --no-createdb username
+
+ If there are no errors, then the command was successful.
+
+2. CREATE DRUPAL DATABASE
+
+ This step is only necessary if you don't already have a database set up
+ (e.g., by your host) or want to create a new database for use with Drupal
+ only. The following command creates a new database named 'databasename',
+ which is owned by the previously created 'username':
+
+ createdb --encoding=UTF8 --owner=username databasename
+
+ If there are no errors, then the command was successful.
+
+3. CREATE SCHEMA OR SCHEMAS (Optional advanced step)
+
+ Drupal will run across different schemas within your database if you so wish.
+ By default, Drupal runs inside the 'public' schema but you can use $db_prefix
+ inside settings.php to define a schema for Drupal to run inside of, or
+ specify tables that are shared inside of a separate schema. Drupal will not
+ create schemas for you. In fact, the user that Drupal runs as should not be
+ allowed to do this. You'll need to execute the SQL below as a superuser,
+ replace 'username' with the username that Drupal uses to connect to
+ PostgreSQL, and replace 'schema_name' with a schema name you wish to use,
+ such as 'shared':
+
+ CREATE SCHEMA schema_name AUTHORIZATION username;
+
+ Do this for as many schemas as you need. See default.settings.php for
+ instructions on how to set which tables use which schemas.
diff --git a/core/INSTALL.sqlite.txt b/core/INSTALL.sqlite.txt
new file mode 100644
index 000000000000..b4bfcef472f8
--- /dev/null
+++ b/core/INSTALL.sqlite.txt
@@ -0,0 +1,31 @@
+
+SQLITE REQUIREMENTS
+-------------------
+
+To use SQLite with your Drupal installation, the following requirements must be
+met: Server has PHP 5.3.2 or later with PDO, and the PDO SQLite driver must be
+enabled.
+
+SQLITE DATABASE CREATION
+------------------------
+
+The Drupal installer will create the SQLite database for you. The only
+requirement is that the installer must have write permissions to the directory
+where the database file resides. This directory (not just the database file) also
+has to remain writeable by the web server going forward for SQLite to continue to
+be able to operate.
+
+On the "Database configuration" form in the "Database file" field, you must
+supply the exact path to where you wish your database file to reside. It is
+strongly suggested that you choose a path that is outside of the webroot, yet
+ensure that the directory is writeable by the web server.
+
+If you must place your database file in your webroot, you could try using the
+following in your "Database file" field:
+
+ sites/default/files/.ht.sqlite
+
+Note: The .ht in the name will tell Apache to prevent the database from being
+downloaded. Please check that the file is, indeed, protected by your webserver.
+If not, please consult the documentation of your webserver on how to protect a
+file from downloading.
diff --git a/core/INSTALL.txt b/core/INSTALL.txt
new file mode 100644
index 000000000000..245e934cb727
--- /dev/null
+++ b/core/INSTALL.txt
@@ -0,0 +1,398 @@
+
+CONTENTS OF THIS FILE
+---------------------
+
+ * Requirements and notes
+ * Optional server requirements
+ * Installation
+ * Building and customizing your site
+ * Multisite configuration
+ * More information
+
+REQUIREMENTS AND NOTES
+----------------------
+
+Drupal requires:
+
+- A web server. Apache (version 2.0 or greater) is recommended.
+- PHP 5.3.2 (or greater) (http://www.php.net/).
+- One of the following databases:
+ - MySQL 5.0.15 (or greater) (http://www.mysql.com/).
+ - MariaDB 5.1.44 (or greater) (http://mariadb.org/). MariaDB is a fully
+ compatible drop-in replacement for MySQL.
+ - PostgreSQL 8.3 (or greater) (http://www.postgresql.org/).
+ - SQLite 3.4.2 (or greater) (http://www.sqlite.org/).
+
+For more detailed information about Drupal requirements, including a list of
+PHP extensions and configurations that are required, see "System requirements"
+(http://drupal.org/requirements) in the Drupal.org online documentation.
+
+For detailed information on how to configure a test server environment using a
+variety of operating systems and web servers, see "Local server setup"
+(http://drupal.org/node/157602) in the Drupal.org online documentation.
+
+Note that all directories mentioned in this document are always relative to the
+directory of your Drupal installation, and commands are meant to be run from
+this directory (except for the initial commands that create that directory).
+
+OPTIONAL SERVER REQUIREMENTS
+----------------------------
+
+- If you want to use Drupal's "Clean URLs" feature on an Apache web server, you
+ will need the mod_rewrite module and the ability to use local .htaccess
+ files. For Clean URLs support on IIS, see "Clean URLs with IIS"
+ (http://drupal.org/node/3854) in the Drupal.org online documentation.
+
+- If you plan to use XML-based services such as RSS aggregation, you will need
+ PHP's XML extension. This extension is enabled by default on most PHP
+ installations.
+
+- To serve gzip compressed CSS and JS files on an Apache web server, you will
+ need the mod_headers module and the ability to use local .htaccess files.
+
+- Some Drupal functionality (e.g., checking whether Drupal and contributed
+ modules need updates, RSS aggregation, etc.) require that the web server be
+ able to go out to the web and download information. If you want to use this
+ functionality, you need to verify that your hosting provider or server
+ configuration allows the web server to initiate outbound connections. Most web
+ hosting setups allow this.
+
+INSTALLATION
+------------
+
+1. Download and extract Drupal.
+
+ You can obtain the latest Drupal release from http://drupal.org -- the files
+ are available in .tar.gz and .zip formats and can be extracted using most
+ compression tools.
+
+ To download and extract the files, on a typical Unix/Linux command line, use
+ the following commands (assuming you want version x.y of Drupal in .tar.gz
+ format):
+
+ wget http://drupal.org/files/projects/drupal-x.y.tar.gz
+ tar -zxvf drupal-x.y.tar.gz
+
+ This will create a new directory drupal-x.y/ containing all Drupal files and
+ directories. Then, to move the contents of that directory into a directory
+ within your web server's document root or your public HTML directory,
+ continue with this command:
+
+ mv drupal-x.y/* drupal-x.y/.htaccess /path/to/your/installation
+
+2. Optionally, download a translation.
+
+ By default, Drupal is installed in English, and further languages may be
+ installed later. If you prefer to install Drupal in another language
+ initially:
+
+ - Download a translation file for the correct Drupal version and language
+ from the translation server: http://localize.drupal.org/translate/downloads
+
+ - Place the file into your installation profile's translations
+ directory. For instance, if you are using the Standard install profile,
+ move the .po file into the directory:
+
+ profiles/standard/translations/
+
+ For detailed instructions, visit http://drupal.org/localize
+
+3. Create the Drupal database.
+
+ Because Drupal stores all site information in a database, you must create
+ this database in order to install Drupal, and grant Drupal certain database
+ privileges (such as the ability to create tables). For details, consult
+ INSTALL.mysql.txt, INSTALL.pgsql.txt, or INSTALL.sqlite.txt. You may also
+ need to consult your web hosting provider for instructions specific to your
+ web host.
+
+ Take note of the username, password, database name, and hostname as you
+ create the database. You will enter this information during the install.
+
+4. Run the install script.
+
+ To run the install script, point your browser to the base URL of your
+ website (e.g., http://www.example.com).
+
+ You will be guided through several screens to set up the database, add the
+ site maintenance account (the first user, also known as user/1), and provide
+ basic web site settings.
+
+ During installation, several files and directories need to be created, which
+ the install script will try to do automatically. However, on some hosting
+ environments, manual steps are required, and the install script will tell
+ you that it cannot proceed until you fix certain issues. This is normal and
+ does not indicate a problem with your server.
+
+ The most common steps you may need to perform are:
+
+ a. Missing files directory.
+
+ The install script will attempt to create a file storage directory in
+ the default location at sites/default/files (the location of the files
+ directory may be changed after Drupal is installed).
+
+ If auto-creation fails, you can make it work by changing permissions on
+ the sites/default directory so that the web server can create the files
+ directory within it for you. (If you are creating a multisite
+ installation, substitute the correct sites directory for sites/default;
+ see the Multisite Configuration section of this file, below.)
+
+ For example, on a Unix/Linux command line, you can grant everyone
+ (including the web server) permission to write to the sites/default
+ directory with this command:
+
+ chmod a+w sites/default
+
+ Be sure to set the permissions back after the installation is finished!
+ Sample command:
+
+ chmod go-w sites/default
+
+ Alternatively, instead of allowing the web server to create the files
+ directory for you as described above, you can create it yourself. Sample
+ commands from a Unix/Linux command line:
+
+ mkdir sites/default/files
+ chmod a+w sites/default/files
+
+ b. Missing settings file.
+
+ Drupal will try to automatically create a settings.php configuration file,
+ which is normally in the directory sites/default (to avoid problems when
+ upgrading, Drupal is not packaged with this file). If auto-creation fails,
+ you will need to create this file yourself, using the file
+ sites/default/default.settings.php as a template.
+
+ For example, on a Unix/Linux command line, you can make a copy of the
+ default.settings.php file with the command:
+
+ cp sites/default/default.settings.php sites/default/settings.php
+
+ Next, grant write privileges to the file to everyone (including the web
+ server) with the command:
+
+ chmod a+w sites/default/settings.php
+
+ Be sure to set the permissions back after the installation is finished!
+ Sample command:
+
+ chmod go-w sites/default/settings.php
+
+ c. Write permissions after install.
+
+ The install script will attempt to write-protect the settings.php file and
+ the sites/default directory after saving your configuration. If this
+ fails, you will be notified, and you can do it manually. Sample commands
+ from a Unix/Linux command line:
+
+ chmod go-w sites/default/settings.php
+ chmod go-w sites/default
+
+5. Verify that the site is working.
+
+ When the install script finishes, you will be logged in with the site
+ maintenance account on a "Welcome" page. If the default Drupal theme is not
+ displaying properly and links on the page result in "Page Not Found" errors,
+ you may be experiencing problems with clean URLs. Visit
+ http://drupal.org/getting-started/clean-urls to troubleshoot.
+
+6. Change file system storage settings (optional).
+
+ The files directory created in step 4 is the default file system path used to
+ store all uploaded files, as well as some temporary files created by
+ Drupal. After installation, you can modify the file system path to store
+ uploaded files in a different location.
+
+ It is not necessary to modify this path, but you may wish to change it if:
+
+ - Your site runs multiple Drupal installations from a single codebase (modify
+ the file system path of each installation to a different directory so that
+ uploads do not overlap between installations).
+
+ - Your site runs on a number of web servers behind a load balancer or reverse
+ proxy (modify the file system path on each server to point to a shared file
+ repository).
+
+ - You want to restrict access to uploaded files.
+
+ To modify the file system path:
+
+ a. Ensure that the new location for the path exists and is writable by the
+ web server. For example, to create a new directory named uploads and grant
+ write permissions, use the following commands on a Unix/Linux command
+ line:
+
+ mkdir uploads
+ chmod a+w uploads
+
+ b. Navigate to Administration > Configuration > Media > File system, and
+ enter the desired path. Note that if you want to use private file storage,
+ you need to first enter the path for private files and save the
+ configuration, and then change the "Default download method" setting and
+ save again.
+
+ Changing the file system path after files have been uploaded may cause
+ unexpected problems on an existing site. If you modify the file system path
+ on an existing site, remember to copy all files from the original location
+ to the new location.
+
+7. Revoke documentation file permissions (optional).
+
+ Some administrators suggest making the documentation files, especially
+ CHANGELOG.txt, non-readable so that the exact version of Drupal you are
+ running is slightly more difficult to determine. If you wish to implement
+ this optional security measure, from a Unix/Linux command line you can use
+ the following command:
+
+ chmod a-r CHANGELOG.txt
+
+ Note that the example only affects CHANGELOG.txt. To completely hide all
+ documentation files from public view, repeat this command for each of the
+ Drupal documentation files in the installation directory, substituting the
+ name of each file for CHANGELOG.txt in the example.
+
+ For more information on setting file permissions, see "Modifying Linux,
+ Unix, and Mac file permissions" (http://drupal.org/node/202483) or
+ "Modifying Windows file permissions" (http://drupal.org/node/202491) in the
+ Drupal.org online documentation.
+
+8. Set up independent "cron" maintenance jobs.
+
+ Many Drupal modules have tasks that must be run periodically, including the
+ Search module (building and updating the index used for keyword searching),
+ the Aggregator module (retrieving feeds from other sites), and the System
+ module (performing routine maintenance and pruning of database tables). These
+ tasks are known as "cron maintenance tasks", named after the Unix/Linux
+ "cron" utility.
+
+ When you install Drupal, its built-in cron feature is enabled, which
+ automatically runs the cron tasks periodically, triggered by people visiting
+ pages of your site. You can configure the built-in cron feature by navigating
+ to Administration > Configuration > System > Cron.
+
+ It is also possible to run the cron tasks independent of site visits; this is
+ recommended for most sites. To do this, you will need to set up an automated
+ process to visit the page cron.php on your site, which executes the cron
+ tasks.
+
+ The URL of the cron.php page requires a "cron key" to protect against
+ unauthorized access. Your site's cron key is automatically generated during
+ installation and is specific to your site. The full URL of the page, with the
+ cron key, is available in the "Cron maintenance tasks" section of the Status
+ report page at Administration > Reports > Status report.
+
+ As an example for how to set up this automated process, you can use the
+ crontab utility on Unix/Linux systems. The following crontab line uses the
+ wget command to visit the cron.php page, and runs each hour, on the hour:
+
+ 0 * * * * wget -O - -q -t 1 http://example.com/core/cron.php?cron_key=YOURKEY
+
+ Replace the text "http://example.com/core/cron.php?cron_key=YOURKEY" in the
+ example with the full URL displayed under "Cron maintenance tasks" on the
+ "Status report" page.
+
+ More information about cron maintenance tasks is available at
+ http://drupal.org/cron, and sample cron shell scripts can be found in the
+ scripts/ directory. (Note that these scripts must be customized like the
+ above example, to add your site-specific cron key and domain name.)
+
+BUILDING AND CUSTOMIZING YOUR SITE
+----------------------------------
+
+A new installation of Drupal defaults to a very basic configuration. To extend
+your site, you use "modules" and "themes". A module is a plugin that adds
+functionality to Drupal, while a theme changes the look of your site. The core
+of Drupal provides several optional modules and themes, and you can download
+more at http://drupal.org/project/modules and http://drupal.org/project/themes
+
+Do not mix downloaded or custom modules and themes with Drupal's core modules
+and themes. Drupal's modules and themes are located in the top-level modules and
+themes directories, while the modules and themes you add to Drupal are normally
+placed in the sites/all/modules and sites/all/themes directories. If you run a
+multisite installation, you can also place modules and themes in the
+site-specific directories -- see the Multisite Configuration section, below.
+
+Never edit Drupal's core modules and themes; instead, use the hooks available in
+the Drupal API. To modify the behavior of Drupal, develop a module as described
+at http://drupal.org/developing/modules. To modify the look of Drupal, create a
+subtheme as described at http://drupal.org/node/225125, or a completely new
+theme as described at http://drupal.org/documentation/theme
+
+MULTISITE CONFIGURATION
+-----------------------
+
+A single Drupal installation can host several Drupal-powered sites, each with
+its own individual configuration.
+
+Additional site configurations are created in subdirectories within the 'sites'
+directory. Each subdirectory must have a 'settings.php' file, which specifies
+the configuration settings. The easiest way to create additional sites is to
+copy the 'default' directory and modify the 'settings.php' file as appropriate.
+The new directory name is constructed from the site's URL. The configuration for
+www.example.com could be in 'sites/example.com/settings.php' (note that 'www.'
+should be omitted if users can access your site at http://example.com/).
+
+Sites do not have to have a different domain. You can also use subdomains and
+subdirectories for Drupal sites. For example, example.com, sub.example.com, and
+sub.example.com/site3 can all be defined as independent Drupal sites. The setup
+for a configuration such as this would look like the following:
+
+ sites/default/settings.php
+ sites/example.com/settings.php
+ sites/sub.example.com/settings.php
+ sites/sub.example.com.site3/settings.php
+
+When searching for a site configuration (for example www.sub.example.com/site3),
+Drupal will search for configuration files in the following order, using the
+first configuration it finds:
+
+ sites/www.sub.example.com.site3/settings.php
+ sites/sub.example.com.site3/settings.php
+ sites/example.com.site3/settings.php
+ sites/www.sub.example.com/settings.php
+ sites/sub.example.com/settings.php
+ sites/example.com/settings.php
+ sites/default/settings.php
+
+If you are installing on a non-standard port, the port number is treated as the
+deepest subdomain. For example: http://www.example.com:8080/ could be loaded
+from sites/8080.www.example.com/. The port number will be removed according to
+the pattern above if no port-specific configuration is found, just like a real
+subdomain.
+
+Each site configuration can have its own site-specific modules and themes in
+addition to those installed in the standard 'modules' and 'themes' directories.
+To use site-specific modules or themes, simply create a 'modules' or 'themes'
+directory within the site configuration directory. For example, if
+sub.example.com has a custom theme and a custom module that should not be
+accessible to other sites, the setup would look like this:
+
+ sites/sub.example.com/
+ settings.php
+ themes/custom_theme
+ modules/custom_module
+
+NOTE: for more information about multiple virtual hosts or the configuration
+settings, consult http://drupal.org/getting-started/6/install/multi-site
+
+For more information on configuring Drupal's file system path in a multisite
+configuration, see step 6 above.
+
+MORE INFORMATION
+----------------
+
+- See the Drupal.org online documentation:
+ http://drupal.org/documentation
+
+- For a list of security announcements, see the "Security advisories" page at
+ http://drupal.org/security (available as an RSS feed). This page also
+ describes how to subscribe to these announcements via e-mail.
+
+- For information about the Drupal security process, or to find out how to
+ report a potential security issue to the Drupal security team, see the
+ "Security team" page at http://drupal.org/security-team
+
+- For information about the wide range of available support options, visit
+ http://drupal.org and click on Community and Support in the top or bottom
+ navigation.
diff --git a/core/LICENSE.txt b/core/LICENSE.txt
new file mode 100644
index 000000000000..94fb84639c4b
--- /dev/null
+++ b/core/LICENSE.txt
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
new file mode 100644
index 000000000000..98df6c650d91
--- /dev/null
+++ b/core/MAINTAINERS.txt
@@ -0,0 +1,288 @@
+Drupal core is built and maintained by the Drupal project community. Everyone is
+encouraged to submit issues and changes (patches) to improve Drupal, and to
+contribute in other ways -- see http://drupal.org/contribute to find out how.
+
+Branch maintainers
+------------------
+
+The Drupal Core branch maintainers oversee the development of Drupal as a
+whole. The branch mainainers for Drupal 8 are:
+
+- Dries Buytaert 'dries' <http://drupal.org/user/1>
+- Nathaniel Catchpole 'catch' <http://drupal.org/user/35733>
+
+
+Component maintainers
+---------------------
+
+The Drupal Core component maintainers oversee the development of Drupal
+subsystems. See http://drupal.org/contribute/core-maintainers for more
+information on their responsibilities, and to find out how to become a component
+maintainer. Current component maintainers for Drupal 8:
+
+Ajax system
+- Alex Bronstein 'effulgentsia' <http://drupal.org/user/78040>
+- Randy Fay 'rfay' <http://drupal.org/user/30906>
+- Earl Miles 'merlinofchaos' <http://drupal.org/user/26979>
+
+Base system
+- Károly Négyesi 'chx' <http://drupal.org/user/9446>
+- Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+- Moshe Weitzman 'moshe weitzman' <http://drupal.org/user/23>
+
+Batch system
+- Yves Chedemois 'yched' <http://drupal.org/user/39567>
+
+Cache system
+- Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+- Nathaniel Catchpole 'catch' <http://drupal.org/user/35733>
+
+Cron system
+- Károly Négyesi 'chx' <http://drupal.org/user/9446>
+- Derek Wright 'dww' <http://drupal.org/user/46549>
+
+Database system
+- Larry Garfield 'Crell' <http://drupal.org/user/26398>
+
+ - MySQL driver
+ - Larry Garfield 'Crell' <http://drupal.org/user/26398>
+ - David Strauss 'David Strauss' <http://drupal.org/user/93254>
+
+ - PostgreSQL driver
+ - Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+ - Josh Waihi 'fiasco' <http://drupal.org/user/188162>
+
+ - Sqlite driver
+ - Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+ - Károly Négyesi 'chx' <http://drupal.org/user/9446>
+
+Database update system
+- ?
+
+Entity system
+- Wolfgang Ziegler 'fago' <http://drupal.org/user/16747>
+- Nathaniel Catchpole 'catch' <http://drupal.org/user/35733>
+- Franz Heinzmann 'Frando' <http://drupal.org/user/21850>
+
+File system
+- Andrew Morton 'drewish' <http://drupal.org/user/34869>
+- Aaron Winborn 'aaron' <http://drupal.org/user/33420>
+
+Form system
+- Károly Négyesi 'chx' <http://drupal.org/user/9446>
+- Alex Bronstein 'effulgentsia' <http://drupal.org/user/78040>
+- Wolfgang Ziegler 'fago' <http://drupal.org/user/16747>
+- Daniel F. Kudwien 'sun' <http://drupal.org/user/54136>
+- Franz Heinzmann 'Frando' <http://drupal.org/user/21850>
+
+Image system
+- Andrew Morton 'drewish' <http://drupal.org/user/34869>
+- Nathan Haug 'quicksketch' <http://drupal.org/user/35821>
+
+Install system
+- David Rothstein 'David_Rothstein' <http://drupal.org/user/124982>
+
+JavaScript
+- ?
+
+Language system
+- Francesco Placella 'plach' <http://drupal.org/user/183211>
+- Daniel F. Kudwien 'sun' <http://drupal.org/user/54136>
+
+Lock system
+- Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+
+Mail system
+- ?
+
+Markup
+- Jacine Luisi 'Jacine' <http://drupal.org/user/88931>
+- Daniel F. Kudwien 'sun' <http://drupal.org/user/54136>
+
+Menu system
+- Peter Wolanin 'pwolanin' <http://drupal.org/user/49851>
+- Károly Négyesi 'chx' <http://drupal.org/user/9446>
+
+Path system
+- Dave Reid 'davereid' <http://drupal.org/user/53892>
+- Nathaniel Catchpole 'catch' <http://drupal.org/user/35733>
+
+Render system
+- Moshe Weitzman 'moshe weitzman' <http://drupal.org/user/23>
+- Alex Bronstein 'effulgentsia' <http://drupal.org/user/78040>
+- Franz Heinzmann 'Frando' <http://drupal.org/user/21850>
+
+Theme system
+- Earl Miles 'merlinofchaos' <http://drupal.org/user/26979>
+- Alex Bronstein 'effulgentsia' <http://drupal.org/user/78040>
+- Joon Park 'dvessel' <http://drupal.org/user/56782>
+- John Albin Wilkins 'JohnAlbin' <http://drupal.org/user/32095>
+
+Token system
+- Dave Reid 'davereid' <http://drupal.org/user/53892>
+
+XML-RPC system
+- Frederic G. Marand 'fgm' <http://drupal.org/user/27985>
+
+
+Topic coordinators
+------------------
+
+Accessibility
+- Everett Zufelt 'Everett Zufelt' <http://drupal.org/user/406552>
+- Brandon Bowersox 'brandonojc' <http://drupal.org/user/186415>
+
+Documentation
+- Ariane Khachatourians 'arianek' <http://drupal.org/user/158886>
+- Jennifer Hodgdon 'jhodgdon' <http://drupal.org/user/155601>
+
+Security
+- Heine Deelstra 'Heine' <http://drupal.org/user/17943>
+
+Translations
+- Gerhard Killesreiter 'killes' <http://drupal.org/user/83>
+
+User experience and usability
+- Roy Scholten 'yoroy' <http://drupal.org/user/41502>
+- Bojhan Somers 'Bojhan' <http://drupal.org/user/87969>
+
+
+Module maintainers
+------------------
+
+Aggregator module
+- ?
+
+Block module
+- John Albin Wilkins 'JohnAlbin' <http://drupal.org/user/32095>
+
+Book module
+- Peter Wolanin 'pwolanin' <http://drupal.org/user/49851>
+
+Color module
+- ?
+
+Comment module
+- Dick Olsson 'dixon_' <http://drupal.org/user/239911>
+
+Contact module
+- Dave Reid 'davereid' <http://drupal.org/user/53892>
+
+Contextual module
+- Daniel F. Kudwien 'sun' <http://drupal.org/user/54136>
+
+Dashboard module
+- ?
+
+Database logging module
+- Khalid Baheyeldin 'kbahey' <http://drupal.org/user/4063>
+
+Field module
+- Yves Chedemois 'yched' <http://drupal.org/user/39567>
+- Barry Jaspan 'bjaspan' <http://drupal.org/user/46413>
+
+Field UI module
+- Yves Chedemois 'yched' <http://drupal.org/user/39567>
+
+File module
+- Aaron Winborn 'aaron' <http://drupal.org/user/33420>
+
+Filter module
+- Daniel F. Kudwien 'sun' <http://drupal.org/user/54136>
+
+Forum module
+- Lee Rowlands 'larowlan' <http://drupal.org/user/395439>
+
+Help module
+- ?
+
+Image module
+- Nathan Haug 'quicksketch' <http://drupal.org/user/35821>
+
+Locale module
+- Gábor Hojtsy 'Gábor Hojtsy' <http://drupal.org/user/4166>
+
+Menu module
+- ?
+
+Node module
+- Moshe Weitzman 'moshe weitzman' <http://drupal.org/user/23>
+- David Strauss 'David Strauss' <http://drupal.org/user/93254>
+
+OpenID module
+- Vojtech Kusy 'wojtha' <http://drupal.org/user/56154>
+- Heine Deelstra 'Heine' <http://drupal.org/user/17943>
+- Christian Schmidt 'c960657' <http://drupal.org/user/216078>
+- Damien Tournoud 'DamZ' <http://drupal.org/user/22211>
+
+Overlay module
+- Katherine Senzee 'ksenzee' <http://drupal.org/user/139855>
+
+Path module
+- Dave Reid 'davereid' <http://drupal.org/user/53892>
+
+PHP module
+- ?
+
+Poll module
+- ?
+
+RDF module
+- Stéphane Corlosquet 'scor' <http://drupal.org/user/52142>
+
+Search module
+- Doug Green 'douggreen' <http://drupal.org/user/29191>
+
+Shortcut module
+- David Rothstein 'David_Rothstein' <http://drupal.org/user/124982>
+- Kristof De Jaeger 'swentel' <http://drupal.org/user/107403>
+
+Simpletest module
+- Jimmy Berry 'boombatower' <http://drupal.org/user/214218>
+- Károly Négyesi 'chx' <http://drupal.org/user/9446>
+
+Statistics module
+- Dave Reid 'davereid' <http://drupal.org/user/53892>
+
+Syslog module
+- Khalid Baheyeldin 'kbahey' <http://drupal.org/user/4063>
+
+System module
+- ?
+
+Taxonomy module
+- Nathaniel Catchpole 'catch' <http://drupal.org/user/35733>
+- Benjamin Doherty 'bangpound' <http://drupal.org/user/100456>
+
+Toolbar module
+- ?
+
+Tracker module
+- David Strauss 'David Strauss' <http://drupal.org/user/93254>
+
+Translation module
+- Francesco Placella 'plach' <http://drupal.org/user/183211>
+
+Trigger module
+- ?
+
+Update module
+- Derek Wright 'dww' <http://drupal.org/user/46549>
+
+User module
+- Moshe Weitzman 'moshe weitzman' <http://drupal.org/user/23>
+- David Strauss 'David Strauss' <http://drupal.org/user/93254>
+
+
+Theme maintainers
+-----------------
+
+Bartik theme
+- Jen Simmons 'jensimmons' <http://drupal.org/user/140882>
+- Jeff Burns 'Jeff Burnz' <http://drupal.org/user/61393>
+
+Seven theme
+- Jeff Burns 'Jeff Burnz' <http://drupal.org/user/61393>
+
+Stark theme
+- John Albin Wilkins 'JohnAlbin' <http://drupal.org/user/32095>
diff --git a/core/UPGRADE.txt b/core/UPGRADE.txt
new file mode 100644
index 000000000000..fb665aaea59e
--- /dev/null
+++ b/core/UPGRADE.txt
@@ -0,0 +1,232 @@
+INTRODUCTION
+------------
+This document describes how to:
+
+ * Update your Drupal site from one minor 8.x version to another minor 8.x
+ version; for example, from 8.8 to 8.9, or from 8.6 to 8.10.
+
+ * Upgrade your Drupal site's major version from 7.x to 8.x.
+
+First steps and definitions:
+
+ * If you are upgrading to Drupal version x.y, then x is known as the major
+ version number, and y is known as the minor version number. The download
+ file will be named drupal-x.y.tar.gz (or drupal-x.y.zip).
+
+ * All directories mentioned in this document are relative to the directory of
+ your Drupal installation.
+
+ * Make a full backup of all files, directories, and your database(s) before
+ starting, and save it outside your Drupal installation directory.
+ Instructions may be found at http://drupal.org/upgrade/backing-up-the-db
+
+ * It is wise to try an update or upgrade on a test copy of your site before
+ applying it to your live site. Even minor updates can cause your site's
+ behavior to change.
+
+ * Each new release of Drupal has release notes, which explain the changes made
+ since the previous version and any special instructions needed to update or
+ upgrade to the new version. You can find a link to the release notes for the
+ version you are upgrading or updating to on the Drupal project page
+ (http://drupal.org/project/drupal).
+
+UPGRADE PROBLEMS
+----------------
+If you encounter errors during this process,
+
+ * Note any error messages you see.
+
+ * Restore your site to its previous state, using the file and database backups
+ you created before you started the upgrade process. Do not attempt to do
+ further upgrades on a site that had update problems.
+
+ * Consult one of the support options listed on http://drupal.org/support
+
+More in-depth information on upgrading can be found at http://drupal.org/upgrade
+
+MINOR VERSION UPDATES
+---------------------
+To update from one minor 8.x version of Drupal to any later 8.x version, after
+following the instructions in the INTRODUCTION section at the top of this file:
+
+1. Log in as a user with the permission "Administer software updates".
+
+2. Go to Administration > Configuration > Development > Maintenance mode.
+ Enable the "Put site into maintenance mode" checkbox and save the
+ configuration.
+
+3. Remove all old core files and directories, except for the 'sites' directory
+ and any custom files you added elsewhere.
+
+ If you made modifications to files like .htaccess or robots.txt, you will
+ need to re-apply them from your backup, after the new files are in place.
+
+ Sometimes an update includes changes to default.settings.php (this will be
+ noted in the release notes). If that's the case, follow these steps:
+
+ - Make a backup copy of your settings.php file, with a different file name.
+
+ - Make a copy of the new default.settings.php file, and name the copy
+ settings.php (overwriting your previous settings.php file).
+
+ - Copy the custom and site-specific entries from the backup you made into the
+ new settings.php file. You will definitely need the lines giving the
+ database information, and you will also want to copy in any other
+ customizations you have added.
+
+4. Download the latest Drupal 8.x release from http://drupal.org to a
+ directory outside of your web root. Extract the archive and copy the files
+ into your Drupal directory.
+
+ On a typical Unix/Linux command line, use the following commands to download
+ and extract:
+
+ wget http://drupal.org/files/projects/drupal-x.y.tar.gz
+ tar -zxvf drupal-x.y.tar.gz
+
+ This creates a new directory drupal-x.y/ containing all Drupal files and
+ directories. Copy the files into your Drupal installation directory:
+
+ cp -R drupal-x.y/* drupal-x.y/.htaccess /path/to/your/installation
+
+ If you do not have command line access to your server, download the archive
+ from http://drupal.org using your web browser, extract it, and then use an
+ FTP client to upload the files to your web root.
+
+5. Re-apply any modifications to files such as .htaccess or robots.txt.
+
+6. Run update.php by visiting http://www.example.com/core/update.php (replace
+ www.example.com with your domain name). This will update the core database
+ tables.
+
+ If you are unable to access update.php do the following:
+
+ - Open settings.php with a text editor.
+
+ - Find the line that says:
+ $update_free_access = FALSE;
+
+ - Change it into:
+ $update_free_access = TRUE;
+
+ - Once the upgrade is done, $update_free_access must be reverted to FALSE.
+
+7. Go to Administration > Reports > Status report. Verify that everything is
+ working as expected.
+
+8. Ensure that $update_free_access is FALSE in settings.php.
+
+9. Go to Administration > Configuration > Development > Maintenance mode.
+ Disable the "Put site into maintenance mode" checkbox and save the
+ configuration.
+
+MAJOR VERSION UPGRADE
+---------------------
+To upgrade from a previous major version of Drupal to Drupal 8.x, after
+following the instructions in the INTRODUCTION section at the top of this file:
+
+1. Check on the Drupal 8 status of your contributed and custom modules and
+ themes. See http://drupal.org/node/948216 for information on upgrading
+ contributed modules and themes. See http://drupal.org/node/895314 for a list
+ of modules that have been moved into core for Drupal 8, and instructions on
+ how to update them. See http://drupal.org/update/modules for information on
+ how to update your custom modules, and http://drupal.org/update/theme for
+ custom themes.
+
+ You may decide at this point that you cannot upgrade your site because
+ needed modules or themes are not ready for Drupal 8
+
+2. Update to the latest available version of Drupal 7.x (if your current version
+ is Drupal 6.x, you have to upgrade to 7.x first). If you need to update,
+ download Drupal 7.x and follow the instructions in its UPGRADE.txt. This
+ document only applies for upgrades from 7.x to 8.x.
+
+3. Log in as user ID 1 (the site maintenance user).
+
+4. Go to Administer > Site configuration > Site maintenance. Select
+ "Off-line" and save the configuration.
+
+5. Go to Administer > Site building > Themes. Enable "Bartik" and select it as
+ the default theme.
+
+6. Go to Administer > Site building > Modules. Disable all modules that are not
+ listed under "Core - required" or "Core - optional". It is possible that some
+ modules cannot be disabled because others depend on them. Repeat this step
+ until all non-core modules are disabled.
+
+ If you know that you will not re-enable some modules for Drupal 8.x and you
+ no longer need their data, then you can uninstall them under the Uninstall
+ tab after disabling them.
+
+7. On the command line or in your FTP client, remove the file
+
+ sites/default/default.settings.php
+
+8. Remove all old core files and directories, except for the 'sites' directory
+ and any custom files you added elsewhere.
+
+ If you made modifications to files like .htaccess or robots.txt, you will
+ need to re-apply them from your backup, after the new files are in place.
+
+9. If you uninstalled any modules, remove them from the sites/all/modules and
+ other sites/*/modules directories. Leave other modules in place, even though
+ they are incompatible with Drupal 8.x.
+
+10. Download the latest Drupal 8.x release from http://drupal.org to a
+ directory outside of your web root. Extract the archive and copy the files
+ into your Drupal directory.
+
+ On a typical Unix/Linux command line, use the following commands to download
+ and extract:
+
+ wget http://drupal.org/files/projects/drupal-x.y.tar.gz
+ tar -zxvf drupal-x.y.tar.gz
+
+ This creates a new directory drupal-x.y/ containing all Drupal files and
+ directories. Copy the files into your Drupal installation directory:
+
+ cp -R drupal-x.y/* drupal-x.y/.htaccess /path/to/your/installation
+
+ If you do not have command line access to your server, download the archive
+ from http://drupal.org using your web browser, extract it, and then use an
+ FTP client to upload the files to your web root.
+
+11. Re-apply any modifications to files such as .htaccess or robots.txt.
+
+12. Make your settings.php file writeable, so that the update process can
+ convert it to the format of Drupal 8.x. settings.php is usually located in
+
+ sites/default/settings.php
+
+13. Run update.php by visiting http://www.example.com/core/update.php (replace
+ www.example.com with your domain name). This will update the core database
+ tables.
+
+ If you are unable to access update.php do the following:
+
+ - Open settings.php with a text editor.
+
+ - Find the line that says:
+ $update_free_access = FALSE;
+
+ - Change it into:
+ $update_free_access = TRUE;
+
+ - Once the upgrade is done, $update_free_access must be reverted to FALSE.
+
+14. Backup your database after the core upgrade has run.
+
+15. Replace and update your non-core modules and themes, following the
+ procedures at http://drupal.org/node/948216
+
+16. Go to Administration > Reports > Status report. Verify that everything is
+ working as expected.
+
+17. Ensure that $update_free_access is FALSE in settings.php.
+
+18. Go to Administration > Configuration > Development > Maintenance mode.
+ Disable the "Put site into maintenance mode" checkbox and save the
+ configuration.
+
+To get started with Drupal 8 administration, visit
+http://drupal.org/getting-started/7/admin
diff --git a/core/authorize.php b/core/authorize.php
new file mode 100644
index 000000000000..26d310dda1a6
--- /dev/null
+++ b/core/authorize.php
@@ -0,0 +1,177 @@
+<?php
+
+/**
+ * @file
+ * Administrative script for running authorized file operations.
+ *
+ * Using this script, the site owner (the user actually owning the files on
+ * the webserver) can authorize certain file-related operations to proceed
+ * with elevated privileges, for example to deploy and upgrade modules or
+ * themes. Users should not visit this page directly, but instead use an
+ * administrative user interface which knows how to redirect the user to this
+ * script as part of a multistep process. This script actually performs the
+ * selected operations without loading all of Drupal, to be able to more
+ * gracefully recover from errors. Access to the script is controlled by a
+ * global killswitch in settings.php ('allow_authorize_operations') and via
+ * the 'administer software updates' permission.
+ *
+ * There are helper functions for setting up an operation to run via this
+ * system in modules/system/system.module. For more information, see:
+ * @link authorize Authorized operation helper functions @endlink
+ */
+
+// Change the directory to the Drupal root.
+chdir('..');
+
+/**
+ * Root directory of Drupal installation.
+ */
+define('DRUPAL_ROOT', getcwd());
+
+/**
+ * Global flag to identify update.php and authorize.php runs, and so
+ * avoid various unwanted operations, such as hook_init() and
+ * hook_exit() invokes, css/js preprocessing and translation, and
+ * solve some theming issues. This flag is checked on several places
+ * in Drupal code (not just authorize.php).
+ */
+define('MAINTENANCE_MODE', 'update');
+
+/**
+ * Renders a 403 access denied page for authorize.php.
+ */
+function authorize_access_denied_page() {
+ drupal_add_http_header('Status', '403 Forbidden');
+ watchdog('access denied', 'authorize.php', NULL, WATCHDOG_WARNING);
+ drupal_set_title('Access denied');
+ return t('You are not allowed to access this page.');
+}
+
+/**
+ * Determines if the current user is allowed to run authorize.php.
+ *
+ * The killswitch in settings.php overrides all else, otherwise, the user must
+ * have access to the 'administer software updates' permission.
+ *
+ * @return
+ * TRUE if the current user can run authorize.php, otherwise FALSE.
+ */
+function authorize_access_allowed() {
+ return variable_get('allow_authorize_operations', TRUE) && user_access('administer software updates');
+}
+
+// *** Real work of the script begins here. ***
+
+require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+require_once DRUPAL_ROOT . '/core/includes/session.inc';
+require_once DRUPAL_ROOT . '/core/includes/common.inc';
+require_once DRUPAL_ROOT . '/core/includes/file.inc';
+require_once DRUPAL_ROOT . '/core/includes/module.inc';
+require_once DRUPAL_ROOT . '/core/includes/ajax.inc';
+
+// We prepare only a minimal bootstrap. This includes the database and
+// variables, however, so we have access to the class autoloader registry.
+drupal_bootstrap(DRUPAL_BOOTSTRAP_SESSION);
+
+// This must go after drupal_bootstrap(), which unsets globals!
+global $conf;
+
+// We have to enable the user and system modules, even to check access and
+// display errors via the maintenance theme.
+$module_list['system']['filename'] = 'core/modules/system/system.module';
+$module_list['user']['filename'] = 'core/modules/user/user.module';
+module_list(TRUE, FALSE, FALSE, $module_list);
+drupal_load('module', 'system');
+drupal_load('module', 'user');
+
+// We also want to have the language system available, but we do *NOT* want to
+// actually call drupal_bootstrap(DRUPAL_BOOTSTRAP_LANGUAGE), since that would
+// also force us through the DRUPAL_BOOTSTRAP_PAGE_HEADER phase, which loads
+// all the modules, and that's exactly what we're trying to avoid.
+drupal_language_initialize();
+
+// Initialize the maintenance theme for this administrative script.
+drupal_maintenance_theme();
+
+$output = '';
+$show_messages = TRUE;
+
+if (authorize_access_allowed()) {
+ // Load both the Form API and Batch API.
+ require_once DRUPAL_ROOT . '/core/includes/form.inc';
+ require_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ // Load the code that drives the authorize process.
+ require_once DRUPAL_ROOT . '/core/includes/authorize.inc';
+
+ // For the sake of Batch API and a few other low-level functions, we need to
+ // initialize the URL path into $_GET['q']. However, we do not want to raise
+ // our bootstrap level, nor do we want to call drupal_initialize_path(),
+ // since that is assuming that modules are loaded and invoking hooks.
+ // However, all we really care is if we're in the middle of a batch, in which
+ // case $_GET['q'] will already be set, we just initialize it to an empty
+ // string if it's not already defined.
+ if (!isset($_GET['q'])) {
+ $_GET['q'] = '';
+ }
+
+ if (isset($_SESSION['authorize_operation']['page_title'])) {
+ drupal_set_title($_SESSION['authorize_operation']['page_title']);
+ }
+ else {
+ drupal_set_title(t('Authorize file system changes'));
+ }
+
+ // See if we've run the operation and need to display a report.
+ if (isset($_SESSION['authorize_results']) && $results = $_SESSION['authorize_results']) {
+
+ // Clear the session out.
+ unset($_SESSION['authorize_results']);
+ unset($_SESSION['authorize_operation']);
+ unset($_SESSION['authorize_filetransfer_info']);
+
+ if (!empty($results['page_title'])) {
+ drupal_set_title($results['page_title']);
+ }
+ if (!empty($results['page_message'])) {
+ drupal_set_message($results['page_message']['message'], $results['page_message']['type']);
+ }
+
+ $output = theme('authorize_report', array('messages' => $results['messages']));
+
+ $links = array();
+ if (is_array($results['tasks'])) {
+ $links += $results['tasks'];
+ }
+ else {
+ $links = array_merge($links, array(
+ l(t('Administration pages'), 'admin'),
+ l(t('Front page'), '<front>'),
+ ));
+ }
+
+ $output .= theme('item_list', array('items' => $links, 'title' => t('Next steps')));
+ }
+ // If a batch is running, let it run.
+ elseif (isset($_GET['batch'])) {
+ $output = _batch_page();
+ }
+ else {
+ if (empty($_SESSION['authorize_operation']) || empty($_SESSION['authorize_filetransfer_info'])) {
+ $output = t('It appears you have reached this page in error.');
+ }
+ elseif (!$batch = batch_get()) {
+ // We have a batch to process, show the filetransfer form.
+ $elements = drupal_get_form('authorize_filetransfer_form');
+ $output = drupal_render($elements);
+ }
+ }
+ // We defer the display of messages until all operations are done.
+ $show_messages = !(($batch = batch_get()) && isset($batch['running']));
+}
+else {
+ $output = authorize_access_denied_page();
+}
+
+if (!empty($output)) {
+ print theme('update_page', array('content' => $output, 'show_messages' => $show_messages));
+}
diff --git a/core/cron.php b/core/cron.php
new file mode 100644
index 000000000000..fa9aa14d4d40
--- /dev/null
+++ b/core/cron.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @file
+ * Handles incoming requests to fire off regularly-scheduled tasks (cron jobs).
+ */
+
+// Change the directory to the Drupal root.
+chdir('..');
+
+/**
+ * Root directory of Drupal installation.
+ */
+define('DRUPAL_ROOT', getcwd());
+
+include_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+if (!isset($_GET['cron_key']) || variable_get('cron_key', 'drupal') != $_GET['cron_key']) {
+ watchdog('cron', 'Cron could not run because an invalid key was used.', array(), WATCHDOG_NOTICE);
+ drupal_access_denied();
+}
+elseif (variable_get('maintenance_mode', 0)) {
+ watchdog('cron', 'Cron could not run because the site is in maintenance mode.', array(), WATCHDOG_NOTICE);
+ drupal_access_denied();
+}
+else {
+ drupal_cron_run();
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php b/core/includes/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php
new file mode 100644
index 000000000000..278f510e5861
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php
@@ -0,0 +1,96 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\ClassLoader;
+
+/**
+ * ApcUniversalClassLoader implements a "universal" autoloader cached in APC for PHP 5.3.
+ *
+ * It is able to load classes that use either:
+ *
+ * * The technical interoperability standards for PHP 5.3 namespaces and
+ * class names (http://groups.google.com/group/php-standards/web/psr-0-final-proposal);
+ *
+ * * The PEAR naming convention for classes (http://pear.php.net/).
+ *
+ * Classes from a sub-namespace or a sub-hierarchy of PEAR classes can be
+ * looked for in a list of locations to ease the vendoring of a sub-set of
+ * classes for large projects.
+ *
+ * Example usage:
+ *
+ * require 'vendor/symfony/src/Symfony/Component/ClassLoader/UniversalClassLoader.php';
+ * require 'vendor/symfony/src/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php';
+ *
+ * use Symfony\Component\ClassLoader\ApcUniversalClassLoader;
+ *
+ * $loader = new ApcUniversalClassLoader('apc.prefix.');
+ *
+ * // register classes with namespaces
+ * $loader->registerNamespaces(array(
+ * 'Symfony\Component' => __DIR__.'/component',
+ * 'Symfony' => __DIR__.'/framework',
+ * 'Sensio' => array(__DIR__.'/src', __DIR__.'/vendor'),
+ * ));
+ *
+ * // register a library using the PEAR naming convention
+ * $loader->registerPrefixes(array(
+ * 'Swift_' => __DIR__.'/Swift',
+ * ));
+ *
+ * // activate the autoloader
+ * $loader->register();
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Kris Wallsmith <kris@symfony.com>
+ *
+ * @api
+ */
+class ApcUniversalClassLoader extends UniversalClassLoader
+{
+ private $prefix;
+
+ /**
+ * Constructor.
+ *
+ * @param string $prefix A prefix to create a namespace in APC
+ *
+ * @api
+ */
+ public function __construct($prefix)
+ {
+ if (!extension_loaded('apc')) {
+ throw new \RuntimeException('Unable to use ApcUniversalClassLoader as APC is not enabled.');
+ }
+
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * Finds a file by class name while caching lookups to APC.
+ *
+ * @param string $class A class name to resolve to file
+ */
+ public function findFile($class)
+ {
+ if (false === $file = apc_fetch($this->prefix.$class)) {
+ apc_store($this->prefix.$class, $file = parent::findFile($class));
+ }
+
+ return $file;
+ }
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/ClassCollectionLoader.php b/core/includes/Symfony/Component/ClassLoader/ClassCollectionLoader.php
new file mode 100644
index 000000000000..da777f299166
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/ClassCollectionLoader.php
@@ -0,0 +1,222 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\ClassLoader;
+
+/**
+ * ClassCollectionLoader.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class ClassCollectionLoader
+{
+ static private $loaded;
+
+ /**
+ * Loads a list of classes and caches them in one big file.
+ *
+ * @param array $classes An array of classes to load
+ * @param string $cacheDir A cache directory
+ * @param string $name The cache name prefix
+ * @param Boolean $autoReload Whether to flush the cache when the cache is stale or not
+ * @param Boolean $adaptive Whether to remove already declared classes or not
+ * @param string $extension File extension of the resulting file
+ *
+ * @throws \InvalidArgumentException When class can't be loaded
+ */
+ static public function load($classes, $cacheDir, $name, $autoReload, $adaptive = false, $extension = '.php')
+ {
+ // each $name can only be loaded once per PHP process
+ if (isset(self::$loaded[$name])) {
+ return;
+ }
+
+ self::$loaded[$name] = true;
+
+ if ($adaptive) {
+ // don't include already declared classes
+ $classes = array_diff($classes, get_declared_classes(), get_declared_interfaces());
+
+ // the cache is different depending on which classes are already declared
+ $name = $name.'-'.substr(md5(implode('|', $classes)), 0, 5);
+ }
+
+ $cache = $cacheDir.'/'.$name.$extension;
+
+ // auto-reload
+ $reload = false;
+ if ($autoReload) {
+ $metadata = $cacheDir.'/'.$name.$extension.'.meta';
+ if (!file_exists($metadata) || !file_exists($cache)) {
+ $reload = true;
+ } else {
+ $time = filemtime($cache);
+ $meta = unserialize(file_get_contents($metadata));
+
+ if ($meta[1] != $classes) {
+ $reload = true;
+ } else {
+ foreach ($meta[0] as $resource) {
+ if (!file_exists($resource) || filemtime($resource) > $time) {
+ $reload = true;
+
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (!$reload && file_exists($cache)) {
+ require_once $cache;
+
+ return;
+ }
+
+ $files = array();
+ $content = '';
+ foreach ($classes as $class) {
+ if (!class_exists($class) && !interface_exists($class) && (!function_exists('trait_exists') || !trait_exists($class))) {
+ throw new \InvalidArgumentException(sprintf('Unable to load class "%s"', $class));
+ }
+
+ $r = new \ReflectionClass($class);
+ $files[] = $r->getFileName();
+
+ $c = preg_replace(array('/^\s*<\?php/', '/\?>\s*$/'), '', file_get_contents($r->getFileName()));
+
+ // add namespace declaration for global code
+ if (!$r->inNamespace()) {
+ $c = "\nnamespace\n{\n".self::stripComments($c)."\n}\n";
+ } else {
+ $c = self::fixNamespaceDeclarations('<?php '.$c);
+ $c = preg_replace('/^\s*<\?php/', '', $c);
+ }
+
+ $content .= $c;
+ }
+
+ // cache the core classes
+ if (!is_dir(dirname($cache))) {
+ mkdir(dirname($cache), 0777, true);
+ }
+ self::writeCacheFile($cache, '<?php '.$content);
+
+ if ($autoReload) {
+ // save the resources
+ self::writeCacheFile($metadata, serialize(array($files, $classes)));
+ }
+ }
+
+ /**
+ * Adds brackets around each namespace if it's not already the case.
+ *
+ * @param string $source Namespace string
+ *
+ * @return string Namespaces with brackets
+ */
+ static public function fixNamespaceDeclarations($source)
+ {
+ if (!function_exists('token_get_all')) {
+ return $source;
+ }
+
+ $output = '';
+ $inNamespace = false;
+ $tokens = token_get_all($source);
+
+ for ($i = 0, $max = count($tokens); $i < $max; $i++) {
+ $token = $tokens[$i];
+ if (is_string($token)) {
+ $output .= $token;
+ } elseif (in_array($token[0], array(T_COMMENT, T_DOC_COMMENT))) {
+ // strip comments
+ continue;
+ } elseif (T_NAMESPACE === $token[0]) {
+ if ($inNamespace) {
+ $output .= "}\n";
+ }
+ $output .= $token[1];
+
+ // namespace name and whitespaces
+ while (($t = $tokens[++$i]) && is_array($t) && in_array($t[0], array(T_WHITESPACE, T_NS_SEPARATOR, T_STRING))) {
+ $output .= $t[1];
+ }
+ if (is_string($t) && '{' === $t) {
+ $inNamespace = false;
+ --$i;
+ } else {
+ $output .= "\n{";
+ $inNamespace = true;
+ }
+ } else {
+ $output .= $token[1];
+ }
+ }
+
+ if ($inNamespace) {
+ $output .= "}\n";
+ }
+
+ return $output;
+ }
+
+ /**
+ * Writes a cache file.
+ *
+ * @param string $file Filename
+ * @param string $content Temporary file content
+ *
+ * @throws \RuntimeException when a cache file cannot be written
+ */
+ static private function writeCacheFile($file, $content)
+ {
+ $tmpFile = tempnam(dirname($file), basename($file));
+ if (false !== @file_put_contents($tmpFile, $content) && @rename($tmpFile, $file)) {
+ chmod($file, 0644);
+
+ return;
+ }
+
+ throw new \RuntimeException(sprintf('Failed to write cache file "%s".', $file));
+ }
+
+ /**
+ * Removes comments from a PHP source string.
+ *
+ * We don't use the PHP php_strip_whitespace() function
+ * as we want the content to be readable and well-formatted.
+ *
+ * @param string $source A PHP string
+ *
+ * @return string The PHP string with the comments removed
+ */
+ static private function stripComments($source)
+ {
+ if (!function_exists('token_get_all')) {
+ return $source;
+ }
+
+ $output = '';
+ foreach (token_get_all($source) as $token) {
+ if (is_string($token)) {
+ $output .= $token;
+ } elseif (!in_array($token[0], array(T_COMMENT, T_DOC_COMMENT))) {
+ $output .= $token[1];
+ }
+ }
+
+ // replace multiple new lines with a single newline
+ $output = preg_replace(array('/\s+$/Sm', '/\n+/S'), "\n", $output);
+
+ return $output;
+ }
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/DebugUniversalClassLoader.php b/core/includes/Symfony/Component/ClassLoader/DebugUniversalClassLoader.php
new file mode 100644
index 000000000000..8a958e01f96c
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/DebugUniversalClassLoader.php
@@ -0,0 +1,62 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\ClassLoader;
+
+/**
+ * Checks that the class is actually declared in the included file.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class DebugUniversalClassLoader extends UniversalClassLoader
+{
+ /**
+ * Replaces all regular UniversalClassLoader instances by a DebugUniversalClassLoader ones.
+ */
+ static public function enable()
+ {
+ if (!is_array($functions = spl_autoload_functions())) {
+ return;
+ }
+
+ foreach ($functions as $function) {
+ spl_autoload_unregister($function);
+ }
+
+ foreach ($functions as $function) {
+ if (is_array($function) && $function[0] instanceof UniversalClassLoader) {
+ $loader = new static();
+ $loader->registerNamespaceFallbacks($function[0]->getNamespaceFallbacks());
+ $loader->registerPrefixFallbacks($function[0]->getPrefixFallbacks());
+ $loader->registerNamespaces($function[0]->getNamespaces());
+ $loader->registerPrefixes($function[0]->getPrefixes());
+
+ $function[0] = $loader;
+ }
+
+ spl_autoload_register($function);
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->findFile($class)) {
+ require $file;
+
+ if (!class_exists($class, false) && !interface_exists($class, false) && (!function_exists('trait_exists') || !trait_exists($class, false))) {
+ throw new \RuntimeException(sprintf('The autoloader expected class "%s" to be defined in file "%s". The file was found but the class was not in it, the class name or namespace probably has a typo.', $class, $file));
+ }
+ }
+ }
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/LICENSE b/core/includes/Symfony/Component/ClassLoader/LICENSE
new file mode 100644
index 000000000000..89df4481b950
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2004-2011 Fabien Potencier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/core/includes/Symfony/Component/ClassLoader/MapClassLoader.php b/core/includes/Symfony/Component/ClassLoader/MapClassLoader.php
new file mode 100644
index 000000000000..cf17d42642cb
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/MapClassLoader.php
@@ -0,0 +1,76 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\ClassLoader;
+
+/**
+ * A class loader that uses a mapping file to look up paths.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class MapClassLoader
+{
+ private $map = array();
+
+ /**
+ * Constructor.
+ *
+ * @param array $map A map where keys are classes and values the absolute file path
+ */
+ public function __construct(array $map)
+ {
+ $this->map = $map;
+ }
+
+ /**
+ * Registers this instance as an autoloader.
+ *
+ * @param Boolean $prepend Whether to prepend the autoloader or not
+ */
+ public function register($prepend = false)
+ {
+ spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+ }
+
+ /**
+ * Loads the given class or interface.
+ *
+ * @param string $class The name of the class
+ */
+ public function loadClass($class)
+ {
+ if ('\\' === $class[0]) {
+ $class = substr($class, 1);
+ }
+
+ if (isset($this->map[$class])) {
+ require $this->map[$class];
+ }
+ }
+
+ /**
+ * Finds the path to the file where the class is defined.
+ *
+ * @param string $class The name of the class
+ *
+ * @return string|null The path, if found
+ */
+ public function findFile($class)
+ {
+ if ('\\' === $class[0]) {
+ $class = substr($class, 1);
+ }
+
+ if (isset($this->map[$class])) {
+ return $this->map[$class];
+ }
+ }
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/UniversalClassLoader.php b/core/includes/Symfony/Component/ClassLoader/UniversalClassLoader.php
new file mode 100644
index 000000000000..d296b94d5804
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/UniversalClassLoader.php
@@ -0,0 +1,265 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\ClassLoader;
+
+/**
+ * UniversalClassLoader implements a "universal" autoloader for PHP 5.3.
+ *
+ * It is able to load classes that use either:
+ *
+ * * The technical interoperability standards for PHP 5.3 namespaces and
+ * class names (http://groups.google.com/group/php-standards/web/psr-0-final-proposal);
+ *
+ * * The PEAR naming convention for classes (http://pear.php.net/).
+ *
+ * Classes from a sub-namespace or a sub-hierarchy of PEAR classes can be
+ * looked for in a list of locations to ease the vendoring of a sub-set of
+ * classes for large projects.
+ *
+ * Example usage:
+ *
+ * $loader = new UniversalClassLoader();
+ *
+ * // register classes with namespaces
+ * $loader->registerNamespaces(array(
+ * 'Symfony\Component' => __DIR__.'/component',
+ * 'Symfony' => __DIR__.'/framework',
+ * 'Sensio' => array(__DIR__.'/src', __DIR__.'/vendor'),
+ * ));
+ *
+ * // register a library using the PEAR naming convention
+ * $loader->registerPrefixes(array(
+ * 'Swift_' => __DIR__.'/Swift',
+ * ));
+ *
+ * // activate the autoloader
+ * $loader->register();
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class UniversalClassLoader
+{
+ private $namespaces = array();
+ private $prefixes = array();
+ private $namespaceFallbacks = array();
+ private $prefixFallbacks = array();
+
+ /**
+ * Gets the configured namespaces.
+ *
+ * @return array A hash with namespaces as keys and directories as values
+ */
+ public function getNamespaces()
+ {
+ return $this->namespaces;
+ }
+
+ /**
+ * Gets the configured class prefixes.
+ *
+ * @return array A hash with class prefixes as keys and directories as values
+ */
+ public function getPrefixes()
+ {
+ return $this->prefixes;
+ }
+
+ /**
+ * Gets the directory(ies) to use as a fallback for namespaces.
+ *
+ * @return array An array of directories
+ */
+ public function getNamespaceFallbacks()
+ {
+ return $this->namespaceFallbacks;
+ }
+
+ /**
+ * Gets the directory(ies) to use as a fallback for class prefixes.
+ *
+ * @return array An array of directories
+ */
+ public function getPrefixFallbacks()
+ {
+ return $this->prefixFallbacks;
+ }
+
+ /**
+ * Registers the directory to use as a fallback for namespaces.
+ *
+ * @param array $dirs An array of directories
+ *
+ * @api
+ */
+ public function registerNamespaceFallbacks(array $dirs)
+ {
+ $this->namespaceFallbacks = $dirs;
+ }
+
+ /**
+ * Registers the directory to use as a fallback for class prefixes.
+ *
+ * @param array $dirs An array of directories
+ *
+ * @api
+ */
+ public function registerPrefixFallbacks(array $dirs)
+ {
+ $this->prefixFallbacks = $dirs;
+ }
+
+ /**
+ * Registers an array of namespaces
+ *
+ * @param array $namespaces An array of namespaces (namespaces as keys and locations as values)
+ *
+ * @api
+ */
+ public function registerNamespaces(array $namespaces)
+ {
+ foreach ($namespaces as $namespace => $locations) {
+ $this->namespaces[$namespace] = (array) $locations;
+ }
+ }
+
+ /**
+ * Registers a namespace.
+ *
+ * @param string $namespace The namespace
+ * @param array|string $paths The location(s) of the namespace
+ *
+ * @api
+ */
+ public function registerNamespace($namespace, $paths)
+ {
+ $this->namespaces[$namespace] = (array) $paths;
+ }
+
+ /**
+ * Registers an array of classes using the PEAR naming convention.
+ *
+ * @param array $classes An array of classes (prefixes as keys and locations as values)
+ *
+ * @api
+ */
+ public function registerPrefixes(array $classes)
+ {
+ foreach ($classes as $prefix => $locations) {
+ $this->prefixes[$prefix] = (array) $locations;
+ }
+ }
+
+ /**
+ * Registers a set of classes using the PEAR naming convention.
+ *
+ * @param string $prefix The classes prefix
+ * @param array|string $paths The location(s) of the classes
+ *
+ * @api
+ */
+ public function registerPrefix($prefix, $paths)
+ {
+ $this->prefixes[$prefix] = (array) $paths;
+ }
+
+ /**
+ * Registers this instance as an autoloader.
+ *
+ * @param Boolean $prepend Whether to prepend the autoloader or not
+ *
+ * @api
+ */
+ public function register($prepend = false)
+ {
+ spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+ }
+
+ /**
+ * Loads the given class or interface.
+ *
+ * @param string $class The name of the class
+ */
+ public function loadClass($class)
+ {
+ if ($file = $this->findFile($class)) {
+ require $file;
+ }
+ }
+
+ /**
+ * Finds the path to the file where the class is defined.
+ *
+ * @param string $class The name of the class
+ *
+ * @return string|null The path, if found
+ */
+ public function findFile($class)
+ {
+ if ('\\' == $class[0]) {
+ $class = substr($class, 1);
+ }
+
+ if (false !== $pos = strrpos($class, '\\')) {
+ // namespaced class name
+ $namespace = substr($class, 0, $pos);
+ foreach ($this->namespaces as $ns => $dirs) {
+ if (0 !== strpos($namespace, $ns)) {
+ continue;
+ }
+
+ foreach ($dirs as $dir) {
+ $className = substr($class, $pos + 1);
+ $file = $dir.DIRECTORY_SEPARATOR.str_replace('\\', DIRECTORY_SEPARATOR, $namespace).DIRECTORY_SEPARATOR.str_replace('_', DIRECTORY_SEPARATOR, $className).'.php';
+ if (file_exists($file)) {
+ return $file;
+ }
+ }
+ }
+
+ foreach ($this->namespaceFallbacks as $dir) {
+ $file = $dir.DIRECTORY_SEPARATOR.str_replace('\\', DIRECTORY_SEPARATOR, $class).'.php';
+ if (file_exists($file)) {
+ return $file;
+ }
+ }
+ } else {
+ // PEAR-like class name
+ foreach ($this->prefixes as $prefix => $dirs) {
+ if (0 !== strpos($class, $prefix)) {
+ continue;
+ }
+
+ foreach ($dirs as $dir) {
+ $file = $dir.DIRECTORY_SEPARATOR.str_replace('_', DIRECTORY_SEPARATOR, $class).'.php';
+ if (file_exists($file)) {
+ return $file;
+ }
+ }
+ }
+
+ foreach ($this->prefixFallbacks as $dir) {
+ $file = $dir.DIRECTORY_SEPARATOR.str_replace('_', DIRECTORY_SEPARATOR, $class).'.php';
+ if (file_exists($file)) {
+ return $file;
+ }
+ }
+ }
+ }
+}
diff --git a/core/includes/Symfony/Component/ClassLoader/composer.json b/core/includes/Symfony/Component/ClassLoader/composer.json
new file mode 100644
index 000000000000..35b573e5bb82
--- /dev/null
+++ b/core/includes/Symfony/Component/ClassLoader/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "symfony/class-loader",
+ "type": "library",
+ "description": "Symfony ClassLoader Component",
+ "keywords": [],
+ "homepage": "http://symfony.com",
+ "version": "2.0.4",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ }
+ ],
+ "require": {
+ "php": ">=5.3.2"
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/ApacheRequest.php b/core/includes/Symfony/Component/HttpFoundation/ApacheRequest.php
new file mode 100644
index 000000000000..27215819e499
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/ApacheRequest.php
@@ -0,0 +1,51 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * Request represents an HTTP request from an Apache server.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ */
+class ApacheRequest extends Request
+{
+ /**
+ * {@inheritdoc}
+ */
+ protected function prepareRequestUri()
+ {
+ return $this->server->get('REQUEST_URI');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function prepareBaseUrl()
+ {
+ $baseUrl = $this->server->get('SCRIPT_NAME');
+
+ if (false === strpos($this->server->get('REQUEST_URI'), $baseUrl)) {
+ // assume mod_rewrite
+ return rtrim(dirname($baseUrl), '/\\');
+ }
+
+ return $baseUrl;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function preparePathInfo()
+ {
+ return $this->server->get('PATH_INFO');
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/Cookie.php b/core/includes/Symfony/Component/HttpFoundation/Cookie.php
new file mode 100644
index 000000000000..8392812ebe52
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/Cookie.php
@@ -0,0 +1,207 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * Represents a cookie
+ *
+ * @author Johannes M. Schmitt <schmittjoh@gmail.com>
+ *
+ * @api
+ */
+class Cookie
+{
+ protected $name;
+ protected $value;
+ protected $domain;
+ protected $expire;
+ protected $path;
+ protected $secure;
+ protected $httpOnly;
+
+ /**
+ * Constructor.
+ *
+ * @param string $name The name of the cookie
+ * @param string $value The value of the cookie
+ * @param integer|string|\DateTime $expire The time the cookie expires
+ * @param string $path The path on the server in which the cookie will be available on
+ * @param string $domain The domain that the cookie is available to
+ * @param Boolean $secure Whether the cookie should only be transmitted over a secure HTTPS connection from the client
+ * @param Boolean $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
+ *
+ * @api
+ */
+ public function __construct($name, $value = null, $expire = 0, $path = '/', $domain = null, $secure = false, $httpOnly = true)
+ {
+ // from PHP source code
+ if (preg_match("/[=,; \t\r\n\013\014]/", $name)) {
+ throw new \InvalidArgumentException(sprintf('The cookie name "%s" contains invalid characters.', $name));
+ }
+
+ if (preg_match("/[,; \t\r\n\013\014]/", $value)) {
+ throw new \InvalidArgumentException(sprintf('The cookie value "%s" contains invalid characters.', $value));
+ }
+
+ if (empty($name)) {
+ throw new \InvalidArgumentException('The cookie name cannot be empty.');
+ }
+
+ // convert expiration time to a Unix timestamp
+ if ($expire instanceof \DateTime) {
+ $expire = $expire->format('U');
+ } elseif (!is_numeric($expire)) {
+ $expire = strtotime($expire);
+
+ if (false === $expire || -1 === $expire) {
+ throw new \InvalidArgumentException('The cookie expiration time is not valid.');
+ }
+ }
+
+ $this->name = $name;
+ $this->value = $value;
+ $this->domain = $domain;
+ $this->expire = $expire;
+ $this->path = empty($path) ? '/' : $path;
+ $this->secure = (Boolean) $secure;
+ $this->httpOnly = (Boolean) $httpOnly;
+ }
+
+ public function __toString()
+ {
+ $str = urlencode($this->getName()).'=';
+
+ if ('' === (string) $this->getValue()) {
+ $str .= 'deleted; expires='.gmdate("D, d-M-Y H:i:s T", time() - 31536001);
+ } else {
+ $str .= urlencode($this->getValue());
+
+ if ($this->getExpiresTime() !== 0) {
+ $str .= '; expires='.gmdate("D, d-M-Y H:i:s T", $this->getExpiresTime());
+ }
+ }
+
+ if ('/' !== $this->path) {
+ $str .= '; path='.$this->path;
+ }
+
+ if (null !== $this->getDomain()) {
+ $str .= '; domain='.$this->getDomain();
+ }
+
+ if (true === $this->isSecure()) {
+ $str .= '; secure';
+ }
+
+ if (true === $this->isHttpOnly()) {
+ $str .= '; httponly';
+ }
+
+ return $str;
+ }
+
+ /**
+ * Gets the name of the cookie.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Gets the value of the cookie.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * Gets the domain that the cookie is available to.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getDomain()
+ {
+ return $this->domain;
+ }
+
+ /**
+ * Gets the time the cookie expires.
+ *
+ * @return integer
+ *
+ * @api
+ */
+ public function getExpiresTime()
+ {
+ return $this->expire;
+ }
+
+ /**
+ * Gets the path on the server in which the cookie will be available on.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getPath()
+ {
+ return $this->path;
+ }
+
+ /**
+ * Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
+ *
+ * @return Boolean
+ *
+ * @api
+ */
+ public function isSecure()
+ {
+ return $this->secure;
+ }
+
+ /**
+ * Checks whether the cookie will be made accessible only through the HTTP protocol.
+ *
+ * @return Boolean
+ *
+ * @api
+ */
+ public function isHttpOnly()
+ {
+ return $this->httpOnly;
+ }
+
+ /**
+ * Whether this cookie is about to be cleared
+ *
+ * @return Boolean
+ *
+ * @api
+ */
+ public function isCleared()
+ {
+ return $this->expire < time();
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/Exception/AccessDeniedException.php b/core/includes/Symfony/Component/HttpFoundation/File/Exception/AccessDeniedException.php
new file mode 100644
index 000000000000..9c7fe6812a31
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/Exception/AccessDeniedException.php
@@ -0,0 +1,30 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\Exception;
+
+/**
+ * Thrown when the access on a file was denied.
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class AccessDeniedException extends FileException
+{
+ /**
+ * Constructor.
+ *
+ * @param string $path The path to the accessed file
+ */
+ public function __construct($path)
+ {
+ parent::__construct(sprintf('The file %s could not be accessed', $path));
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileException.php b/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileException.php
new file mode 100644
index 000000000000..43c6cc8998c5
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileException.php
@@ -0,0 +1,21 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\Exception;
+
+/**
+ * Thrown when an error occurred in the component File
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class FileException extends \RuntimeException
+{
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileNotFoundException.php b/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileNotFoundException.php
new file mode 100644
index 000000000000..5b1aef8e2b29
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/Exception/FileNotFoundException.php
@@ -0,0 +1,30 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\Exception;
+
+/**
+ * Thrown when a file was not found
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class FileNotFoundException extends FileException
+{
+ /**
+ * Constructor.
+ *
+ * @param string $path The path to the file that was not found
+ */
+ public function __construct($path)
+ {
+ parent::__construct(sprintf('The file "%s" does not exist', $path));
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/Exception/UnexpectedTypeException.php b/core/includes/Symfony/Component/HttpFoundation/File/Exception/UnexpectedTypeException.php
new file mode 100644
index 000000000000..0444b8778218
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/Exception/UnexpectedTypeException.php
@@ -0,0 +1,20 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\Exception;
+
+class UnexpectedTypeException extends FileException
+{
+ public function __construct($value, $expectedType)
+ {
+ parent::__construct(sprintf('Expected argument of type %s, %s given', $expectedType, is_object($value) ? get_class($value) : gettype($value)));
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/Exception/UploadException.php b/core/includes/Symfony/Component/HttpFoundation/File/Exception/UploadException.php
new file mode 100644
index 000000000000..694e864d1c56
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/Exception/UploadException.php
@@ -0,0 +1,21 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\Exception;
+
+/**
+ * Thrown when an error occurred during file upload
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class UploadException extends FileException
+{
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/File.php b/core/includes/Symfony/Component/HttpFoundation/File/File.php
new file mode 100644
index 000000000000..3a900fdcef61
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/File.php
@@ -0,0 +1,544 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesser;
+
+/**
+ * A file in the file system.
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ *
+ * @api
+ */
+class File extends \SplFileInfo
+{
+ /**
+ * A map of mime types and their default extensions.
+ *
+ * @var array
+ */
+ static protected $defaultExtensions = array(
+ 'application/andrew-inset' => 'ez',
+ 'application/appledouble' => 'base64',
+ 'application/applefile' => 'base64',
+ 'application/commonground' => 'dp',
+ 'application/cprplayer' => 'pqi',
+ 'application/dsptype' => 'tsp',
+ 'application/excel' => 'xls',
+ 'application/font-tdpfr' => 'pfr',
+ 'application/futuresplash' => 'spl',
+ 'application/hstu' => 'stk',
+ 'application/hyperstudio' => 'stk',
+ 'application/javascript' => 'js',
+ 'application/mac-binhex40' => 'hqx',
+ 'application/mac-compactpro' => 'cpt',
+ 'application/mbed' => 'mbd',
+ 'application/mirage' => 'mfp',
+ 'application/msword' => 'doc',
+ 'application/ocsp-request' => 'orq',
+ 'application/ocsp-response' => 'ors',
+ 'application/octet-stream' => 'bin',
+ 'application/oda' => 'oda',
+ 'application/ogg' => 'ogg',
+ 'application/pdf' => 'pdf',
+ 'application/x-pdf' => 'pdf',
+ 'application/pgp-encrypted' => '7bit',
+ 'application/pgp-keys' => '7bit',
+ 'application/pgp-signature' => 'sig',
+ 'application/pkcs10' => 'p10',
+ 'application/pkcs7-mime' => 'p7m',
+ 'application/pkcs7-signature' => 'p7s',
+ 'application/pkix-cert' => 'cer',
+ 'application/pkix-crl' => 'crl',
+ 'application/pkix-pkipath' => 'pkipath',
+ 'application/pkixcmp' => 'pki',
+ 'application/postscript' => 'ps',
+ 'application/presentations' => 'shw',
+ 'application/prs.cww' => 'cw',
+ 'application/prs.nprend' => 'rnd',
+ 'application/quest' => 'qrt',
+ 'application/rtf' => 'rtf',
+ 'application/sgml-open-catalog' => 'soc',
+ 'application/sieve' => 'siv',
+ 'application/smil' => 'smi',
+ 'application/toolbook' => 'tbk',
+ 'application/vnd.3gpp.pic-bw-large' => 'plb',
+ 'application/vnd.3gpp.pic-bw-small' => 'psb',
+ 'application/vnd.3gpp.pic-bw-var' => 'pvb',
+ 'application/vnd.3gpp.sms' => 'sms',
+ 'application/vnd.acucorp' => 'atc',
+ 'application/vnd.adobe.xfdf' => 'xfdf',
+ 'application/vnd.amiga.amu' => 'ami',
+ 'application/vnd.blueice.multipass' => 'mpm',
+ 'application/vnd.cinderella' => 'cdy',
+ 'application/vnd.cosmocaller' => 'cmc',
+ 'application/vnd.criticaltools.wbs+xml' => 'wbs',
+ 'application/vnd.curl' => 'curl',
+ 'application/vnd.data-vision.rdz' => 'rdz',
+ 'application/vnd.dreamfactory' => 'dfac',
+ 'application/vnd.fsc.weblaunch' => 'fsc',
+ 'application/vnd.genomatix.tuxedo' => 'txd',
+ 'application/vnd.hbci' => 'hbci',
+ 'application/vnd.hhe.lesson-player' => 'les',
+ 'application/vnd.hp-hpgl' => 'plt',
+ 'application/vnd.ibm.electronic-media' => 'emm',
+ 'application/vnd.ibm.rights-management' => 'irm',
+ 'application/vnd.ibm.secure-container' => 'sc',
+ 'application/vnd.ipunplugged.rcprofile' => 'rcprofile',
+ 'application/vnd.irepository.package+xml' => 'irp',
+ 'application/vnd.jisp' => 'jisp',
+ 'application/vnd.kde.karbon' => 'karbon',
+ 'application/vnd.kde.kchart' => 'chrt',
+ 'application/vnd.kde.kformula' => 'kfo',
+ 'application/vnd.kde.kivio' => 'flw',
+ 'application/vnd.kde.kontour' => 'kon',
+ 'application/vnd.kde.kpresenter' => 'kpr',
+ 'application/vnd.kde.kspread' => 'ksp',
+ 'application/vnd.kde.kword' => 'kwd',
+ 'application/vnd.kenameapp' => 'htke',
+ 'application/vnd.kidspiration' => 'kia',
+ 'application/vnd.kinar' => 'kne',
+ 'application/vnd.llamagraphics.life-balance.desktop' => 'lbd',
+ 'application/vnd.llamagraphics.life-balance.exchange+xml' => 'lbe',
+ 'application/vnd.lotus-1-2-3' => 'wks',
+ 'application/vnd.mcd' => 'mcd',
+ 'application/vnd.mfmp' => 'mfm',
+ 'application/vnd.micrografx.flo' => 'flo',
+ 'application/vnd.micrografx.igx' => 'igx',
+ 'application/vnd.mif' => 'mif',
+ 'application/vnd.mophun.application' => 'mpn',
+ 'application/vnd.mophun.certificate' => 'mpc',
+ 'application/vnd.mozilla.xul+xml' => 'xul',
+ 'application/vnd.ms-artgalry' => 'cil',
+ 'application/vnd.ms-asf' => 'asf',
+ 'application/vnd.ms-excel' => 'xls',
+ 'application/vnd.ms-excel.sheet.macroenabled.12' => 'xlsm',
+ 'application/vnd.ms-lrm' => 'lrm',
+ 'application/vnd.ms-powerpoint' => 'ppt',
+ 'application/vnd.ms-project' => 'mpp',
+ 'application/vnd.ms-tnef' => 'base64',
+ 'application/vnd.ms-works' => 'base64',
+ 'application/vnd.ms-wpl' => 'wpl',
+ 'application/vnd.mseq' => 'mseq',
+ 'application/vnd.nervana' => 'ent',
+ 'application/vnd.nokia.radio-preset' => 'rpst',
+ 'application/vnd.nokia.radio-presets' => 'rpss',
+ 'application/vnd.oasis.opendocument.text' => 'odt',
+ 'application/vnd.oasis.opendocument.text-template' => 'ott',
+ 'application/vnd.oasis.opendocument.text-web' => 'oth',
+ 'application/vnd.oasis.opendocument.text-master' => 'odm',
+ 'application/vnd.oasis.opendocument.graphics' => 'odg',
+ 'application/vnd.oasis.opendocument.graphics-template' => 'otg',
+ 'application/vnd.oasis.opendocument.presentation' => 'odp',
+ 'application/vnd.oasis.opendocument.presentation-template' => 'otp',
+ 'application/vnd.oasis.opendocument.spreadsheet' => 'ods',
+ 'application/vnd.oasis.opendocument.spreadsheet-template' => 'ots',
+ 'application/vnd.oasis.opendocument.chart' => 'odc',
+ 'application/vnd.oasis.opendocument.formula' => 'odf',
+ 'application/vnd.oasis.opendocument.database' => 'odb',
+ 'application/vnd.oasis.opendocument.image' => 'odi',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.template' => 'dotx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
+ 'application/vnd.palm' => 'prc',
+ 'application/vnd.picsel' => 'efif',
+ 'application/vnd.pvi.ptid1' => 'pti',
+ 'application/vnd.quark.quarkxpress' => 'qxd',
+ 'application/vnd.sealed.doc' => 'sdoc',
+ 'application/vnd.sealed.eml' => 'seml',
+ 'application/vnd.sealed.mht' => 'smht',
+ 'application/vnd.sealed.ppt' => 'sppt',
+ 'application/vnd.sealed.xls' => 'sxls',
+ 'application/vnd.sealedmedia.softseal.html' => 'stml',
+ 'application/vnd.sealedmedia.softseal.pdf' => 'spdf',
+ 'application/vnd.seemail' => 'see',
+ 'application/vnd.smaf' => 'mmf',
+ 'application/vnd.sun.xml.calc' => 'sxc',
+ 'application/vnd.sun.xml.calc.template' => 'stc',
+ 'application/vnd.sun.xml.draw' => 'sxd',
+ 'application/vnd.sun.xml.draw.template' => 'std',
+ 'application/vnd.sun.xml.impress' => 'sxi',
+ 'application/vnd.sun.xml.impress.template' => 'sti',
+ 'application/vnd.sun.xml.math' => 'sxm',
+ 'application/vnd.sun.xml.writer' => 'sxw',
+ 'application/vnd.sun.xml.writer.global' => 'sxg',
+ 'application/vnd.sun.xml.writer.template' => 'stw',
+ 'application/vnd.sus-calendar' => 'sus',
+ 'application/vnd.vidsoft.vidconference' => 'vsc',
+ 'application/vnd.visio' => 'vsd',
+ 'application/vnd.visionary' => 'vis',
+ 'application/vnd.wap.sic' => 'sic',
+ 'application/vnd.wap.slc' => 'slc',
+ 'application/vnd.wap.wbxml' => 'wbxml',
+ 'application/vnd.wap.wmlc' => 'wmlc',
+ 'application/vnd.wap.wmlscriptc' => 'wmlsc',
+ 'application/vnd.webturbo' => 'wtb',
+ 'application/vnd.wordperfect' => 'wpd',
+ 'application/vnd.wqd' => 'wqd',
+ 'application/vnd.wv.csp+wbxml' => 'wv',
+ 'application/vnd.wv.csp+xml' => '8bit',
+ 'application/vnd.wv.ssp+xml' => '8bit',
+ 'application/vnd.yamaha.hv-dic' => 'hvd',
+ 'application/vnd.yamaha.hv-script' => 'hvs',
+ 'application/vnd.yamaha.hv-voice' => 'hvp',
+ 'application/vnd.yamaha.smaf-audio' => 'saf',
+ 'application/vnd.yamaha.smaf-phrase' => 'spf',
+ 'application/vocaltec-media-desc' => 'vmd',
+ 'application/vocaltec-media-file' => 'vmf',
+ 'application/vocaltec-talker' => 'vtk',
+ 'application/watcherinfo+xml' => 'wif',
+ 'application/wordperfect5.1' => 'wp5',
+ 'application/x-123' => 'wk',
+ 'application/x-7th_level_event' => '7ls',
+ 'application/x-authorware-bin' => 'aab',
+ 'application/x-authorware-map' => 'aam',
+ 'application/x-authorware-seg' => 'aas',
+ 'application/x-bcpio' => 'bcpio',
+ 'application/x-bleeper' => 'bleep',
+ 'application/x-bzip2' => 'bz2',
+ 'application/x-cdlink' => 'vcd',
+ 'application/x-chat' => 'chat',
+ 'application/x-chess-pgn' => 'pgn',
+ 'application/x-compress' => 'z',
+ 'application/x-cpio' => 'cpio',
+ 'application/x-cprplayer' => 'pqf',
+ 'application/x-csh' => 'csh',
+ 'application/x-cu-seeme' => 'csm',
+ 'application/x-cult3d-object' => 'co',
+ 'application/x-debian-package' => 'deb',
+ 'application/x-director' => 'dcr',
+ 'application/x-dvi' => 'dvi',
+ 'application/x-envoy' => 'evy',
+ 'application/x-futuresplash' => 'spl',
+ 'application/x-gtar' => 'gtar',
+ 'application/x-gzip' => 'gz',
+ 'application/x-hdf' => 'hdf',
+ 'application/x-hep' => 'hep',
+ 'application/x-html+ruby' => 'rhtml',
+ 'application/x-httpd-miva' => 'mv',
+ 'application/x-httpd-php' => 'phtml',
+ 'application/x-ica' => 'ica',
+ 'application/x-imagemap' => 'imagemap',
+ 'application/x-ipix' => 'ipx',
+ 'application/x-ipscript' => 'ips',
+ 'application/x-java-archive' => 'jar',
+ 'application/x-java-jnlp-file' => 'jnlp',
+ 'application/x-java-serialized-object' => 'ser',
+ 'application/x-java-vm' => 'class',
+ 'application/x-javascript' => 'js',
+ 'application/x-koan' => 'skp',
+ 'application/x-latex' => 'latex',
+ 'application/x-mac-compactpro' => 'cpt',
+ 'application/x-maker' => 'frm',
+ 'application/x-mathcad' => 'mcd',
+ 'application/x-midi' => 'mid',
+ 'application/x-mif' => 'mif',
+ 'application/x-msaccess' => 'mda',
+ 'application/x-msdos-program' => 'com',
+ 'application/x-msdownload' => 'base64',
+ 'application/x-msexcel' => 'xls',
+ 'application/x-msword' => 'doc',
+ 'application/x-netcdf' => 'nc',
+ 'application/x-ns-proxy-autoconfig' => 'pac',
+ 'application/x-pagemaker' => 'pm5',
+ 'application/x-perl' => 'pl',
+ 'application/x-pn-realmedia' => 'rp',
+ 'application/x-python' => 'py',
+ 'application/x-quicktimeplayer' => 'qtl',
+ 'application/x-rar-compressed' => 'rar',
+ 'application/x-ruby' => 'rb',
+ 'application/x-sh' => 'sh',
+ 'application/x-shar' => 'shar',
+ 'application/x-shockwave-flash' => 'swf',
+ 'application/x-sprite' => 'spr',
+ 'application/x-spss' => 'sav',
+ 'application/x-spt' => 'spt',
+ 'application/x-stuffit' => 'sit',
+ 'application/x-sv4cpio' => 'sv4cpio',
+ 'application/x-sv4crc' => 'sv4crc',
+ 'application/x-tar' => 'tar',
+ 'application/x-tcl' => 'tcl',
+ 'application/x-tex' => 'tex',
+ 'application/x-texinfo' => 'texinfo',
+ 'application/x-troff' => 't',
+ 'application/x-troff-man' => 'man',
+ 'application/x-troff-me' => 'me',
+ 'application/x-troff-ms' => 'ms',
+ 'application/x-twinvq' => 'vqf',
+ 'application/x-twinvq-plugin' => 'vqe',
+ 'application/x-ustar' => 'ustar',
+ 'application/x-vmsbackup' => 'bck',
+ 'application/x-wais-source' => 'src',
+ 'application/x-wingz' => 'wz',
+ 'application/x-word' => 'base64',
+ 'application/x-wordperfect6.1' => 'wp6',
+ 'application/x-x509-ca-cert' => 'crt',
+ 'application/x-zip-compressed' => 'zip',
+ 'application/xhtml+xml' => 'xhtml',
+ 'application/zip' => 'zip',
+ 'audio/3gpp' => '3gpp',
+ 'audio/amr' => 'amr',
+ 'audio/amr-wb' => 'awb',
+ 'audio/basic' => 'au',
+ 'audio/evrc' => 'evc',
+ 'audio/l16' => 'l16',
+ 'audio/midi' => 'mid',
+ 'audio/mpeg' => 'mp3',
+ 'audio/prs.sid' => 'sid',
+ 'audio/qcelp' => 'qcp',
+ 'audio/smv' => 'smv',
+ 'audio/vnd.audiokoz' => 'koz',
+ 'audio/vnd.digital-winds' => 'eol',
+ 'audio/vnd.everad.plj' => 'plj',
+ 'audio/vnd.lucent.voice' => 'lvp',
+ 'audio/vnd.nokia.mobile-xmf' => 'mxmf',
+ 'audio/vnd.nortel.vbk' => 'vbk',
+ 'audio/vnd.nuera.ecelp4800' => 'ecelp4800',
+ 'audio/vnd.nuera.ecelp7470' => 'ecelp7470',
+ 'audio/vnd.nuera.ecelp9600' => 'ecelp9600',
+ 'audio/vnd.sealedmedia.softseal.mpeg' => 'smp3',
+ 'audio/voxware' => 'vox',
+ 'audio/x-aiff' => 'aif',
+ 'audio/x-mid' => 'mid',
+ 'audio/x-midi' => 'mid',
+ 'audio/x-mpeg' => 'mp2',
+ 'audio/x-mpegurl' => 'mpu',
+ 'audio/x-pn-realaudio' => 'rm',
+ 'audio/x-pn-realaudio-plugin' => 'rpm',
+ 'audio/x-realaudio' => 'ra',
+ 'audio/x-wav' => 'wav',
+ 'chemical/x-csml' => 'csm',
+ 'chemical/x-embl-dl-nucleotide' => 'emb',
+ 'chemical/x-gaussian-cube' => 'cube',
+ 'chemical/x-gaussian-input' => 'gau',
+ 'chemical/x-jcamp-dx' => 'jdx',
+ 'chemical/x-mdl-molfile' => 'mol',
+ 'chemical/x-mdl-rxnfile' => 'rxn',
+ 'chemical/x-mdl-tgf' => 'tgf',
+ 'chemical/x-mopac-input' => 'mop',
+ 'chemical/x-pdb' => 'pdb',
+ 'chemical/x-rasmol' => 'scr',
+ 'chemical/x-xyz' => 'xyz',
+ 'drawing/dwf' => 'dwf',
+ 'drawing/x-dwf' => 'dwf',
+ 'i-world/i-vrml' => 'ivr',
+ 'image/bmp' => 'bmp',
+ 'image/cewavelet' => 'wif',
+ 'image/cis-cod' => 'cod',
+ 'image/fif' => 'fif',
+ 'image/gif' => 'gif',
+ 'image/ief' => 'ief',
+ 'image/jp2' => 'jp2',
+ 'image/jpeg' => 'jpg',
+ 'image/jpm' => 'jpm',
+ 'image/jpx' => 'jpf',
+ 'image/pict' => 'pic',
+ 'image/pjpeg' => 'jpg',
+ 'image/png' => 'png',
+ 'image/targa' => 'tga',
+ 'image/tiff' => 'tif',
+ 'image/vn-svf' => 'svf',
+ 'image/vnd.dgn' => 'dgn',
+ 'image/vnd.djvu' => 'djvu',
+ 'image/vnd.dwg' => 'dwg',
+ 'image/vnd.glocalgraphics.pgb' => 'pgb',
+ 'image/vnd.microsoft.icon' => 'ico',
+ 'image/vnd.ms-modi' => 'mdi',
+ 'image/vnd.sealed.png' => 'spng',
+ 'image/vnd.sealedmedia.softseal.gif' => 'sgif',
+ 'image/vnd.sealedmedia.softseal.jpg' => 'sjpg',
+ 'image/vnd.wap.wbmp' => 'wbmp',
+ 'image/x-bmp' => 'bmp',
+ 'image/x-cmu-raster' => 'ras',
+ 'image/x-freehand' => 'fh4',
+ 'image/x-png' => 'png',
+ 'image/x-portable-anymap' => 'pnm',
+ 'image/x-portable-bitmap' => 'pbm',
+ 'image/x-portable-graymap' => 'pgm',
+ 'image/x-portable-pixmap' => 'ppm',
+ 'image/x-rgb' => 'rgb',
+ 'image/x-xbitmap' => 'xbm',
+ 'image/x-xpixmap' => 'xpm',
+ 'image/x-xwindowdump' => 'xwd',
+ 'message/external-body' => '8bit',
+ 'message/news' => '8bit',
+ 'message/partial' => '8bit',
+ 'message/rfc822' => '8bit',
+ 'model/iges' => 'igs',
+ 'model/mesh' => 'msh',
+ 'model/vnd.parasolid.transmit.binary' => 'x_b',
+ 'model/vnd.parasolid.transmit.text' => 'x_t',
+ 'model/vrml' => 'wrl',
+ 'multipart/alternative' => '8bit',
+ 'multipart/appledouble' => '8bit',
+ 'multipart/digest' => '8bit',
+ 'multipart/mixed' => '8bit',
+ 'multipart/parallel' => '8bit',
+ 'text/comma-separated-values' => 'csv',
+ 'text/css' => 'css',
+ 'text/html' => 'html',
+ 'text/plain' => 'txt',
+ 'text/prs.fallenstein.rst' => 'rst',
+ 'text/richtext' => 'rtx',
+ 'text/rtf' => 'rtf',
+ 'text/sgml' => 'sgml',
+ 'text/tab-separated-values' => 'tsv',
+ 'text/vnd.net2phone.commcenter.command' => 'ccc',
+ 'text/vnd.sun.j2me.app-descriptor' => 'jad',
+ 'text/vnd.wap.si' => 'si',
+ 'text/vnd.wap.sl' => 'sl',
+ 'text/vnd.wap.wml' => 'wml',
+ 'text/vnd.wap.wmlscript' => 'wmls',
+ 'text/x-hdml' => 'hdml',
+ 'text/x-setext' => 'etx',
+ 'text/x-sgml' => 'sgml',
+ 'text/x-speech' => 'talk',
+ 'text/x-vcalendar' => 'vcs',
+ 'text/x-vcard' => 'vcf',
+ 'text/xml' => 'xml',
+ 'ulead/vrml' => 'uvr',
+ 'video/3gpp' => '3gp',
+ 'video/dl' => 'dl',
+ 'video/gl' => 'gl',
+ 'video/mj2' => 'mj2',
+ 'video/mpeg' => 'mpeg',
+ 'video/quicktime' => 'mov',
+ 'video/vdo' => 'vdo',
+ 'video/vivo' => 'viv',
+ 'video/vnd.fvt' => 'fvt',
+ 'video/vnd.mpegurl' => 'mxu',
+ 'video/vnd.nokia.interleaved-multimedia' => 'nim',
+ 'video/vnd.objectvideo' => 'mp4',
+ 'video/vnd.sealed.mpeg1' => 's11',
+ 'video/vnd.sealed.mpeg4' => 'smpg',
+ 'video/vnd.sealed.swf' => 'sswf',
+ 'video/vnd.sealedmedia.softseal.mov' => 'smov',
+ 'video/vnd.vivo' => 'vivo',
+ 'video/x-fli' => 'fli',
+ 'video/x-ms-asf' => 'asf',
+ 'video/x-ms-wmv' => 'wmv',
+ 'video/x-msvideo' => 'avi',
+ 'video/x-sgi-movie' => 'movie',
+ 'x-chemical/x-pdb' => 'pdb',
+ 'x-chemical/x-xyz' => 'xyz',
+ 'x-conference/x-cooltalk' => 'ice',
+ 'x-drawing/dwf' => 'dwf',
+ 'x-world/x-d96' => 'd',
+ 'x-world/x-svr' => 'svr',
+ 'x-world/x-vream' => 'vrw',
+ 'x-world/x-vrml' => 'wrl',
+ );
+
+ /**
+ * Constructs a new file from the given path.
+ *
+ * @param string $path The path to the file
+ *
+ * @throws FileNotFoundException If the given path is not a file
+ *
+ * @api
+ */
+ public function __construct($path)
+ {
+ if (!is_file($path)) {
+ throw new FileNotFoundException($path);
+ }
+
+ parent::__construct($path);
+ }
+
+ /**
+ * Returns the extension based on the mime type.
+ *
+ * If the mime type is unknown, returns null.
+ *
+ * @return string|null The guessed extension or null if it cannot be guessed
+ *
+ * @api
+ */
+ public function guessExtension()
+ {
+ $type = $this->getMimeType();
+
+ return isset(static::$defaultExtensions[$type]) ? static::$defaultExtensions[$type] : null;
+ }
+
+ /**
+ * Returns the mime type of the file.
+ *
+ * The mime type is guessed using the functions finfo(), mime_content_type()
+ * and the system binary "file" (in this order), depending on which of those
+ * is available on the current operating system.
+ *
+ * @return string|null The guessed mime type (i.e. "application/pdf")
+ *
+ * @api
+ */
+ public function getMimeType()
+ {
+ $guesser = MimeTypeGuesser::getInstance();
+
+ return $guesser->guess($this->getPathname());
+ }
+
+ /**
+ * Returns the extension of the file.
+ *
+ * \SplFileInfo::getExtension() is not available before PHP 5.3.6
+ *
+ * @return string The extension
+ *
+ * @api
+ */
+ public function getExtension()
+ {
+ return pathinfo($this->getBasename(), PATHINFO_EXTENSION);
+ }
+
+ /**
+ * Moves the file to a new location.
+ *
+ * @param string $directory The destination folder
+ * @param string $name The new file name
+ *
+ * @return File A File object representing the new file
+ *
+ * @throws FileException if the target file could not be created
+ *
+ * @api
+ */
+ public function move($directory, $name = null)
+ {
+ if (!is_dir($directory)) {
+ if (false === @mkdir($directory, 0777, true)) {
+ throw new FileException(sprintf('Unable to create the "%s" directory', $directory));
+ }
+ } elseif (!is_writable($directory)) {
+ throw new FileException(sprintf('Unable to write in the "%s" directory', $directory));
+ }
+
+ $target = $directory.DIRECTORY_SEPARATOR.(null === $name ? $this->getBasename() : basename($name));
+
+ if (!@rename($this->getPathname(), $target)) {
+ $error = error_get_last();
+ throw new FileException(sprintf('Could not move the file "%s" to "%s" (%s)', $this->getPathname(), $target, strip_tags($error['message'])));
+ }
+
+ chmod($target, 0666);
+
+ return new File($target);
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/MimeType/ContentTypeMimeTypeGuesser.php b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/ContentTypeMimeTypeGuesser.php
new file mode 100644
index 000000000000..fb900b2bc28d
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/ContentTypeMimeTypeGuesser.php
@@ -0,0 +1,62 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\MimeType;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+
+/**
+ * Guesses the mime type using the PHP function mime_content_type().
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class ContentTypeMimeTypeGuesser implements MimeTypeGuesserInterface
+{
+ /**
+ * Returns whether this guesser is supported on the current OS/PHP setup
+ *
+ * @return Boolean
+ */
+ static public function isSupported()
+ {
+ return function_exists('mime_content_type');
+ }
+
+ /**
+ * Guesses the mime type of the file with the given path
+ *
+ * @see MimeTypeGuesserInterface::guess()
+ */
+ public function guess($path)
+ {
+ if (!is_file($path)) {
+ throw new FileNotFoundException($path);
+ }
+
+ if (!is_readable($path)) {
+ throw new AccessDeniedException($path);
+ }
+
+ if (!self::isSupported()) {
+ return null;
+ }
+
+ $type = mime_content_type($path);
+
+ // remove charset (added as of PHP 5.3)
+ if (false !== $pos = strpos($type, ';')) {
+ $type = substr($type, 0, $pos);
+ }
+
+ return $type;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileBinaryMimeTypeGuesser.php b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileBinaryMimeTypeGuesser.php
new file mode 100644
index 000000000000..1b869f22cb3e
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileBinaryMimeTypeGuesser.php
@@ -0,0 +1,71 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\MimeType;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+
+/**
+ * Guesses the mime type with the binary "file" (only available on *nix)
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class FileBinaryMimeTypeGuesser implements MimeTypeGuesserInterface
+{
+ /**
+ * Returns whether this guesser is supported on the current OS
+ *
+ * @return Boolean
+ */
+ static public function isSupported()
+ {
+ return !strstr(PHP_OS, 'WIN');
+ }
+ /**
+ * Guesses the mime type of the file with the given path
+ *
+ * @see MimeTypeGuesserInterface::guess()
+ */
+ public function guess($path)
+ {
+ if (!is_file($path)) {
+ throw new FileNotFoundException($path);
+ }
+
+ if (!is_readable($path)) {
+ throw new AccessDeniedException($path);
+ }
+
+ if (!self::isSupported()) {
+ return null;
+ }
+
+ ob_start();
+
+ // need to use --mime instead of -i. see #6641
+ passthru(sprintf('file -b --mime %s 2>/dev/null', escapeshellarg($path)), $return);
+ if ($return > 0) {
+ ob_end_clean();
+
+ return null;
+ }
+
+ $type = trim(ob_get_clean());
+
+ if (!preg_match('#^([a-z0-9\-]+/[a-z0-9\-]+)#i', $type, $match)) {
+ // it's not a type, but an error message
+ return null;
+ }
+
+ return $match[1];
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileinfoMimeTypeGuesser.php b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileinfoMimeTypeGuesser.php
new file mode 100644
index 000000000000..45d5a086eda3
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/FileinfoMimeTypeGuesser.php
@@ -0,0 +1,59 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\MimeType;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+
+/**
+ * Guesses the mime type using the PECL extension FileInfo
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class FileinfoMimeTypeGuesser implements MimeTypeGuesserInterface
+{
+ /**
+ * Returns whether this guesser is supported on the current OS/PHP setup
+ *
+ * @return Boolean
+ */
+ static public function isSupported()
+ {
+ return function_exists('finfo_open');
+ }
+
+ /**
+ * Guesses the mime type of the file with the given path
+ *
+ * @see MimeTypeGuesserInterface::guess()
+ */
+ public function guess($path)
+ {
+ if (!is_file($path)) {
+ throw new FileNotFoundException($path);
+ }
+
+ if (!is_readable($path)) {
+ throw new AccessDeniedException($path);
+ }
+
+ if (!self::isSupported()) {
+ return null;
+ }
+
+ if (!$finfo = new \finfo(FILEINFO_MIME_TYPE)) {
+ return null;
+ }
+
+ return $finfo->file($path);
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesser.php b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesser.php
new file mode 100644
index 000000000000..23dd46324d7b
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesser.php
@@ -0,0 +1,125 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\MimeType;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+use Symfony\Component\HttpFoundation\File\Exception\AccessDeniedException;
+
+/**
+ * A singleton mime type guesser.
+ *
+ * By default, all mime type guessers provided by the framework are installed
+ * (if available on the current OS/PHP setup). You can register custom
+ * guessers by calling the register() method on the singleton instance.
+ *
+ * <code>
+ * $guesser = MimeTypeGuesser::getInstance();
+ * $guesser->register(new MyCustomMimeTypeGuesser());
+ * </code>
+ *
+ * The last registered guesser is preferred over previously registered ones.
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+class MimeTypeGuesser implements MimeTypeGuesserInterface
+{
+ /**
+ * The singleton instance
+ * @var MimeTypeGuesser
+ */
+ static private $instance = null;
+
+ /**
+ * All registered MimeTypeGuesserInterface instances
+ * @var array
+ */
+ protected $guessers = array();
+
+ /**
+ * Returns the singleton instance
+ *
+ * @return MimeTypeGuesser
+ */
+ static public function getInstance()
+ {
+ if (null === self::$instance) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Registers all natively provided mime type guessers
+ */
+ private function __construct()
+ {
+ if (FileBinaryMimeTypeGuesser::isSupported()) {
+ $this->register(new FileBinaryMimeTypeGuesser());
+ }
+
+ if (ContentTypeMimeTypeGuesser::isSupported()) {
+ $this->register(new ContentTypeMimeTypeGuesser());
+ }
+
+ if (FileinfoMimeTypeGuesser::isSupported()) {
+ $this->register(new FileinfoMimeTypeGuesser());
+ }
+ }
+
+ /**
+ * Registers a new mime type guesser
+ *
+ * When guessing, this guesser is preferred over previously registered ones.
+ *
+ * @param MimeTypeGuesserInterface $guesser
+ */
+ public function register(MimeTypeGuesserInterface $guesser)
+ {
+ array_unshift($this->guessers, $guesser);
+ }
+
+ /**
+ * Tries to guess the mime type of the given file
+ *
+ * The file is passed to each registered mime type guesser in reverse order
+ * of their registration (last registered is queried first). Once a guesser
+ * returns a value that is not NULL, this method terminates and returns the
+ * value.
+ *
+ * @param string $path The path to the file
+ * @return string The mime type or NULL, if none could be guessed
+ * @throws FileException If the file does not exist
+ */
+ public function guess($path)
+ {
+ if (!is_file($path)) {
+ throw new FileNotFoundException($path);
+ }
+
+ if (!is_readable($path)) {
+ throw new AccessDeniedException($path);
+ }
+
+ $mimeType = null;
+
+ foreach ($this->guessers as $guesser) {
+ $mimeType = $guesser->guess($path);
+
+ if (null !== $mimeType) {
+ break;
+ }
+ }
+
+ return $mimeType;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesserInterface.php b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesserInterface.php
new file mode 100644
index 000000000000..c11158396fe4
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/MimeType/MimeTypeGuesserInterface.php
@@ -0,0 +1,30 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File\MimeType;
+
+/**
+ * Guesses the mime type of a file
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ */
+interface MimeTypeGuesserInterface
+{
+ /**
+ * Guesses the mime type of the file with the given path
+ *
+ * @param string $path The path to the file
+ * @return string The mime type or NULL, if none could be guessed
+ * @throws FileNotFoundException If the file does not exist
+ * @throws AccessDeniedException If the file could not be read
+ */
+ function guess($path);
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/File/UploadedFile.php b/core/includes/Symfony/Component/HttpFoundation/File/UploadedFile.php
new file mode 100644
index 000000000000..936ed7057955
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/File/UploadedFile.php
@@ -0,0 +1,225 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\File;
+
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
+use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
+
+/**
+ * A file uploaded through a form.
+ *
+ * @author Bernhard Schussek <bernhard.schussek@symfony.com>
+ * @author Florian Eckerstorfer <florian@eckerstorfer.org>
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class UploadedFile extends File
+{
+ /**
+ * Whether the test mode is activated.
+ *
+ * Local files are used in test mode hence the code should not enforce HTTP uploads.
+ *
+ * @var Boolean
+ */
+ private $test = false;
+
+ /**
+ * The original name of the uploaded file.
+ *
+ * @var string
+ */
+ private $originalName;
+
+ /**
+ * The mime type provided by the uploader.
+ *
+ * @var string
+ */
+ private $mimeType;
+
+ /**
+ * The file size provided by the uploader.
+ *
+ * @var string
+ */
+ private $size;
+
+ /**
+ * The UPLOAD_ERR_XXX constant provided by the uploader.
+ *
+ * @var integer
+ */
+ private $error;
+
+ /**
+ * Accepts the information of the uploaded file as provided by the PHP global $_FILES.
+ *
+ * The file object is only created when the uploaded file is valid (i.e. when the
+ * isValid() method returns true). Otherwise the only methods that could be called
+ * on an UploadedFile instance are:
+ *
+ * * getClientOriginalName,
+ * * getClientMimeType,
+ * * isValid,
+ * * getError.
+ *
+ * Calling any other method on an non-valid instance will cause an unpredictable result.
+ *
+ * @param string $path The full temporary path to the file
+ * @param string $originalName The original file name
+ * @param string $mimeType The type of the file as provided by PHP
+ * @param integer $size The file size
+ * @param integer $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants)
+ * @param Boolean $test Whether the test mode is active
+ *
+ * @throws FileException If file_uploads is disabled
+ * @throws FileNotFoundException If the file does not exist
+ *
+ * @api
+ */
+ public function __construct($path, $originalName, $mimeType = null, $size = null, $error = null, $test = false)
+ {
+ if (!ini_get('file_uploads')) {
+ throw new FileException(sprintf('Unable to create UploadedFile because "file_uploads" is disabled in your php.ini file (%s)', get_cfg_var('cfg_file_path')));
+ }
+
+ $this->originalName = basename($originalName);
+ $this->mimeType = $mimeType ?: 'application/octet-stream';
+ $this->size = $size;
+ $this->error = $error ?: UPLOAD_ERR_OK;
+ $this->test = (Boolean) $test;
+
+ if (UPLOAD_ERR_OK === $this->error) {
+ parent::__construct($path);
+ }
+ }
+
+ /**
+ * Returns the original file name.
+ *
+ * It is extracted from the request from which the file has been uploaded.
+ * Then is should not be considered as a safe value.
+ *
+ * @return string|null The original name
+ *
+ * @api
+ */
+ public function getClientOriginalName()
+ {
+ return $this->originalName;
+ }
+
+ /**
+ * Returns the file mime type.
+ *
+ * It is extracted from the request from which the file has been uploaded.
+ * Then is should not be considered as a safe value.
+ *
+ * @return string|null The mime type
+ *
+ * @api
+ */
+ public function getClientMimeType()
+ {
+ return $this->mimeType;
+ }
+
+ /**
+ * Returns the file size.
+ *
+ * It is extracted from the request from which the file has been uploaded.
+ * Then is should not be considered as a safe value.
+ *
+ * @return integer|null The file size
+ *
+ * @api
+ */
+ public function getClientSize()
+ {
+ return $this->size;
+ }
+
+ /**
+ * Returns the upload error.
+ *
+ * If the upload was successful, the constant UPLOAD_ERR_OK is returned.
+ * Otherwise one of the other UPLOAD_ERR_XXX constants is returned.
+ *
+ * @return integer The upload error
+ *
+ * @api
+ */
+ public function getError()
+ {
+ return $this->error;
+ }
+
+ /**
+ * Returns whether the file was uploaded successfully.
+ *
+ * @return Boolean True if no error occurred during uploading
+ *
+ * @api
+ */
+ public function isValid()
+ {
+ return $this->error === UPLOAD_ERR_OK;
+ }
+
+ /**
+ * Moves the file to a new location.
+ *
+ * @param string $directory The destination folder
+ * @param string $name The new file name
+ *
+ * @return File A File object representing the new file
+ *
+ * @throws FileException if the file has not been uploaded via Http
+ *
+ * @api
+ */
+ public function move($directory, $name = null)
+ {
+ if ($this->isValid() && ($this->test || is_uploaded_file($this->getPathname()))) {
+ return parent::move($directory, $name);
+ }
+
+ throw new FileException(sprintf('The file "%s" has not been uploaded via Http', $this->getPathname()));
+ }
+
+ /**
+ * Returns the maximum size of an uploaded file as configured in php.ini
+ *
+ * @return type The maximum size of an uploaded file in bytes
+ */
+ static public function getMaxFilesize()
+ {
+ $max = trim(ini_get('upload_max_filesize'));
+
+ if ('' === $max) {
+ return PHP_INT_MAX;
+ }
+
+ switch (strtolower(substr($max, -1))) {
+ case 'g':
+ $max *= 1024;
+ case 'm':
+ $max *= 1024;
+ case 'k':
+ $max *= 1024;
+ }
+
+ return (integer) $max;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/FileBag.php b/core/includes/Symfony/Component/HttpFoundation/FileBag.php
new file mode 100644
index 000000000000..602cff2b3cec
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/FileBag.php
@@ -0,0 +1,157 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+/**
+ * FileBag is a container for HTTP headers.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Bulat Shakirzyanov <mallluhuct@gmail.com>
+ *
+ * @api
+ */
+class FileBag extends ParameterBag
+{
+ static private $fileKeys = array('error', 'name', 'size', 'tmp_name', 'type');
+
+ /**
+ * Constructor.
+ *
+ * @param array $parameters An array of HTTP files
+ *
+ * @api
+ */
+ public function __construct(array $parameters = array())
+ {
+ $this->replace($parameters);
+ }
+
+ /**
+ * (non-PHPdoc)
+ * @see Symfony\Component\HttpFoundation\ParameterBag::replace()
+ *
+ * @api
+ */
+ public function replace(array $files = array())
+ {
+ $this->parameters = array();
+ $this->add($files);
+ }
+
+ /**
+ * (non-PHPdoc)
+ * @see Symfony\Component\HttpFoundation\ParameterBag::set()
+ *
+ * @api
+ */
+ public function set($key, $value)
+ {
+ if (is_array($value) || $value instanceof UploadedFile) {
+ parent::set($key, $this->convertFileInformation($value));
+ } else {
+ throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.');
+ }
+ }
+
+ /**
+ * (non-PHPdoc)
+ * @see Symfony\Component\HttpFoundation\ParameterBag::add()
+ *
+ * @api
+ */
+ public function add(array $files = array())
+ {
+ foreach ($files as $key => $file) {
+ $this->set($key, $file);
+ }
+ }
+
+ /**
+ * Converts uploaded files to UploadedFile instances.
+ *
+ * @param array|UploadedFile $file A (multi-dimensional) array of uploaded file information
+ *
+ * @return array A (multi-dimensional) array of UploadedFile instances
+ */
+ protected function convertFileInformation($file)
+ {
+ if ($file instanceof UploadedFile) {
+ return $file;
+ }
+
+ $file = $this->fixPhpFilesArray($file);
+ if (is_array($file)) {
+ $keys = array_keys($file);
+ sort($keys);
+
+ if ($keys == self::$fileKeys) {
+ if (UPLOAD_ERR_NO_FILE == $file['error']) {
+ $file = null;
+ } else {
+ $file = new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['size'], $file['error']);
+ }
+ } else {
+ $file = array_map(array($this, 'convertFileInformation'), $file);
+ }
+ }
+
+ return $file;
+ }
+
+ /**
+ * Fixes a malformed PHP $_FILES array.
+ *
+ * PHP has a bug that the format of the $_FILES array differs, depending on
+ * whether the uploaded file fields had normal field names or array-like
+ * field names ("normal" vs. "parent[child]").
+ *
+ * This method fixes the array to look like the "normal" $_FILES array.
+ *
+ * It's safe to pass an already converted array, in which case this method
+ * just returns the original array unmodified.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function fixPhpFilesArray($data)
+ {
+ if (!is_array($data)) {
+ return $data;
+ }
+
+ $keys = array_keys($data);
+ sort($keys);
+
+ if (self::$fileKeys != $keys || !isset($data['name']) || !is_array($data['name'])) {
+ return $data;
+ }
+
+ $files = $data;
+ foreach (self::$fileKeys as $k) {
+ unset($files[$k]);
+ }
+
+ foreach (array_keys($data['name']) as $key) {
+ $files[$key] = $this->fixPhpFilesArray(array(
+ 'error' => $data['error'][$key],
+ 'name' => $data['name'][$key],
+ 'type' => $data['type'][$key],
+ 'tmp_name' => $data['tmp_name'][$key],
+ 'size' => $data['size'][$key]
+ ));
+ }
+
+ return $files;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/HeaderBag.php b/core/includes/Symfony/Component/HttpFoundation/HeaderBag.php
new file mode 100644
index 000000000000..f614b094fd26
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/HeaderBag.php
@@ -0,0 +1,306 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * HeaderBag is a container for HTTP headers.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class HeaderBag
+{
+ protected $headers;
+ protected $cacheControl;
+
+ /**
+ * Constructor.
+ *
+ * @param array $headers An array of HTTP headers
+ *
+ * @api
+ */
+ public function __construct(array $headers = array())
+ {
+ $this->cacheControl = array();
+ $this->headers = array();
+ foreach ($headers as $key => $values) {
+ $this->set($key, $values);
+ }
+ }
+
+ /**
+ * Returns the headers as a string.
+ *
+ * @return string The headers
+ */
+ public function __toString()
+ {
+ if (!$this->headers) {
+ return '';
+ }
+
+ $beautifier = function ($name) {
+ return preg_replace_callback('/\-(.)/', function ($match) { return '-'.strtoupper($match[1]); }, ucfirst($name));
+ };
+
+ $max = max(array_map('strlen', array_keys($this->headers))) + 1;
+ $content = '';
+ ksort($this->headers);
+ foreach ($this->headers as $name => $values) {
+ foreach ($values as $value) {
+ $content .= sprintf("%-{$max}s %s\r\n", $beautifier($name).':', $value);
+ }
+ }
+
+ return $content;
+ }
+
+ /**
+ * Returns the headers.
+ *
+ * @return array An array of headers
+ *
+ * @api
+ */
+ public function all()
+ {
+ return $this->headers;
+ }
+
+ /**
+ * Returns the parameter keys.
+ *
+ * @return array An array of parameter keys
+ *
+ * @api
+ */
+ public function keys()
+ {
+ return array_keys($this->headers);
+ }
+
+ /**
+ * Replaces the current HTTP headers by a new set.
+ *
+ * @param array $headers An array of HTTP headers
+ *
+ * @api
+ */
+ public function replace(array $headers = array())
+ {
+ $this->headers = array();
+ $this->add($headers);
+ }
+
+ /**
+ * Adds new headers the current HTTP headers set.
+ *
+ * @param array $headers An array of HTTP headers
+ *
+ * @api
+ */
+ public function add(array $headers)
+ {
+ foreach ($headers as $key => $values) {
+ $this->set($key, $values);
+ }
+ }
+
+ /**
+ * Returns a header value by name.
+ *
+ * @param string $key The header name
+ * @param mixed $default The default value
+ * @param Boolean $first Whether to return the first value or all header values
+ *
+ * @return string|array The first header value if $first is true, an array of values otherwise
+ *
+ * @api
+ */
+ public function get($key, $default = null, $first = true)
+ {
+ $key = strtr(strtolower($key), '_', '-');
+
+ if (!array_key_exists($key, $this->headers)) {
+ if (null === $default) {
+ return $first ? null : array();
+ }
+
+ return $first ? $default : array($default);
+ }
+
+ if ($first) {
+ return count($this->headers[$key]) ? $this->headers[$key][0] : $default;
+ }
+
+ return $this->headers[$key];
+ }
+
+ /**
+ * Sets a header by name.
+ *
+ * @param string $key The key
+ * @param string|array $values The value or an array of values
+ * @param Boolean $replace Whether to replace the actual value of not (true by default)
+ *
+ * @api
+ */
+ public function set($key, $values, $replace = true)
+ {
+ $key = strtr(strtolower($key), '_', '-');
+
+ $values = (array) $values;
+
+ if (true === $replace || !isset($this->headers[$key])) {
+ $this->headers[$key] = $values;
+ } else {
+ $this->headers[$key] = array_merge($this->headers[$key], $values);
+ }
+
+ if ('cache-control' === $key) {
+ $this->cacheControl = $this->parseCacheControl($values[0]);
+ }
+ }
+
+ /**
+ * Returns true if the HTTP header is defined.
+ *
+ * @param string $key The HTTP header
+ *
+ * @return Boolean true if the parameter exists, false otherwise
+ *
+ * @api
+ */
+ public function has($key)
+ {
+ return array_key_exists(strtr(strtolower($key), '_', '-'), $this->headers);
+ }
+
+ /**
+ * Returns true if the given HTTP header contains the given value.
+ *
+ * @param string $key The HTTP header name
+ * @param string $value The HTTP value
+ *
+ * @return Boolean true if the value is contained in the header, false otherwise
+ *
+ * @api
+ */
+ public function contains($key, $value)
+ {
+ return in_array($value, $this->get($key, null, false));
+ }
+
+ /**
+ * Removes a header.
+ *
+ * @param string $key The HTTP header name
+ *
+ * @api
+ */
+ public function remove($key)
+ {
+ $key = strtr(strtolower($key), '_', '-');
+
+ unset($this->headers[$key]);
+
+ if ('cache-control' === $key) {
+ $this->cacheControl = array();
+ }
+ }
+
+ /**
+ * Returns the HTTP header value converted to a date.
+ *
+ * @param string $key The parameter key
+ * @param \DateTime $default The default value
+ *
+ * @return \DateTime The filtered value
+ *
+ * @api
+ */
+ public function getDate($key, \DateTime $default = null)
+ {
+ if (null === $value = $this->get($key)) {
+ return $default;
+ }
+
+ if (false === $date = \DateTime::createFromFormat(DATE_RFC2822, $value)) {
+ throw new \RuntimeException(sprintf('The %s HTTP header is not parseable (%s).', $key, $value));
+ }
+
+ return $date;
+ }
+
+ public function addCacheControlDirective($key, $value = true)
+ {
+ $this->cacheControl[$key] = $value;
+
+ $this->set('Cache-Control', $this->getCacheControlHeader());
+ }
+
+ public function hasCacheControlDirective($key)
+ {
+ return array_key_exists($key, $this->cacheControl);
+ }
+
+ public function getCacheControlDirective($key)
+ {
+ return array_key_exists($key, $this->cacheControl) ? $this->cacheControl[$key] : null;
+ }
+
+ public function removeCacheControlDirective($key)
+ {
+ unset($this->cacheControl[$key]);
+
+ $this->set('Cache-Control', $this->getCacheControlHeader());
+ }
+
+ protected function getCacheControlHeader()
+ {
+ $parts = array();
+ ksort($this->cacheControl);
+ foreach ($this->cacheControl as $key => $value) {
+ if (true === $value) {
+ $parts[] = $key;
+ } else {
+ if (preg_match('#[^a-zA-Z0-9._-]#', $value)) {
+ $value = '"'.$value.'"';
+ }
+
+ $parts[] = "$key=$value";
+ }
+ }
+
+ return implode(', ', $parts);
+ }
+
+ /**
+ * Parses a Cache-Control HTTP header.
+ *
+ * @param string $header The value of the Cache-Control HTTP header
+ *
+ * @return array An array representing the attribute values
+ */
+ protected function parseCacheControl($header)
+ {
+ $cacheControl = array();
+ preg_match_all('#([a-zA-Z][a-zA-Z_-]*)\s*(?:=(?:"([^"]*)"|([^ \t",;]*)))?#', $header, $matches, PREG_SET_ORDER);
+ foreach ($matches as $match) {
+ $cacheControl[strtolower($match[1])] = isset($match[2]) && $match[2] ? $match[2] : (isset($match[3]) ? $match[3] : true);
+ }
+
+ return $cacheControl;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/LICENSE b/core/includes/Symfony/Component/HttpFoundation/LICENSE
new file mode 100644
index 000000000000..89df4481b950
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2004-2011 Fabien Potencier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/core/includes/Symfony/Component/HttpFoundation/ParameterBag.php b/core/includes/Symfony/Component/HttpFoundation/ParameterBag.php
new file mode 100644
index 000000000000..3a38b5fab334
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/ParameterBag.php
@@ -0,0 +1,245 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * ParameterBag is a container for key/value pairs.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class ParameterBag
+{
+ protected $parameters;
+
+ /**
+ * Constructor.
+ *
+ * @param array $parameters An array of parameters
+ *
+ * @api
+ */
+ public function __construct(array $parameters = array())
+ {
+ $this->parameters = $parameters;
+ }
+
+ /**
+ * Returns the parameters.
+ *
+ * @return array An array of parameters
+ *
+ * @api
+ */
+ public function all()
+ {
+ return $this->parameters;
+ }
+
+ /**
+ * Returns the parameter keys.
+ *
+ * @return array An array of parameter keys
+ *
+ * @api
+ */
+ public function keys()
+ {
+ return array_keys($this->parameters);
+ }
+
+ /**
+ * Replaces the current parameters by a new set.
+ *
+ * @param array $parameters An array of parameters
+ *
+ * @api
+ */
+ public function replace(array $parameters = array())
+ {
+ $this->parameters = $parameters;
+ }
+
+ /**
+ * Adds parameters.
+ *
+ * @param array $parameters An array of parameters
+ *
+ * @api
+ */
+ public function add(array $parameters = array())
+ {
+ $this->parameters = array_replace($this->parameters, $parameters);
+ }
+
+ /**
+ * Returns a parameter by name.
+ *
+ * @param string $path The key
+ * @param mixed $default The default value
+ * @param boolean $deep
+ *
+ * @api
+ */
+ public function get($path, $default = null, $deep = false)
+ {
+ if (!$deep || false === $pos = strpos($path, '[')) {
+ return array_key_exists($path, $this->parameters) ? $this->parameters[$path] : $default;
+ }
+
+ $root = substr($path, 0, $pos);
+ if (!array_key_exists($root, $this->parameters)) {
+ return $default;
+ }
+
+ $value = $this->parameters[$root];
+ $currentKey = null;
+ for ($i=$pos,$c=strlen($path); $i<$c; $i++) {
+ $char = $path[$i];
+
+ if ('[' === $char) {
+ if (null !== $currentKey) {
+ throw new \InvalidArgumentException(sprintf('Malformed path. Unexpected "[" at position %d.', $i));
+ }
+
+ $currentKey = '';
+ } else if (']' === $char) {
+ if (null === $currentKey) {
+ throw new \InvalidArgumentException(sprintf('Malformed path. Unexpected "]" at position %d.', $i));
+ }
+
+ if (!is_array($value) || !array_key_exists($currentKey, $value)) {
+ return $default;
+ }
+
+ $value = $value[$currentKey];
+ $currentKey = null;
+ } else {
+ if (null === $currentKey) {
+ throw new \InvalidArgumentException(sprintf('Malformed path. Unexpected "%s" at position %d.', $char, $i));
+ }
+
+ $currentKey .= $char;
+ }
+ }
+
+ if (null !== $currentKey) {
+ throw new \InvalidArgumentException(sprintf('Malformed path. Path must end with "]".'));
+ }
+
+ return $value;
+ }
+
+ /**
+ * Sets a parameter by name.
+ *
+ * @param string $key The key
+ * @param mixed $value The value
+ *
+ * @api
+ */
+ public function set($key, $value)
+ {
+ $this->parameters[$key] = $value;
+ }
+
+ /**
+ * Returns true if the parameter is defined.
+ *
+ * @param string $key The key
+ *
+ * @return Boolean true if the parameter exists, false otherwise
+ *
+ * @api
+ */
+ public function has($key)
+ {
+ return array_key_exists($key, $this->parameters);
+ }
+
+ /**
+ * Removes a parameter.
+ *
+ * @param string $key The key
+ *
+ * @api
+ */
+ public function remove($key)
+ {
+ unset($this->parameters[$key]);
+ }
+
+ /**
+ * Returns the alphabetic characters of the parameter value.
+ *
+ * @param string $key The parameter key
+ * @param mixed $default The default value
+ * @param boolean $deep
+ *
+ * @return string The filtered value
+ *
+ * @api
+ */
+ public function getAlpha($key, $default = '', $deep = false)
+ {
+ return preg_replace('/[^[:alpha:]]/', '', $this->get($key, $default, $deep));
+ }
+
+ /**
+ * Returns the alphabetic characters and digits of the parameter value.
+ *
+ * @param string $key The parameter key
+ * @param mixed $default The default value
+ * @param boolean $deep
+ *
+ * @return string The filtered value
+ *
+ * @api
+ */
+ public function getAlnum($key, $default = '', $deep = false)
+ {
+ return preg_replace('/[^[:alnum:]]/', '', $this->get($key, $default, $deep));
+ }
+
+ /**
+ * Returns the digits of the parameter value.
+ *
+ * @param string $key The parameter key
+ * @param mixed $default The default value
+ * @param boolean $deep
+ *
+ * @return string The filtered value
+ *
+ * @api
+ */
+ public function getDigits($key, $default = '', $deep = false)
+ {
+ return preg_replace('/[^[:digit:]]/', '', $this->get($key, $default, $deep));
+ }
+
+ /**
+ * Returns the parameter value converted to integer.
+ *
+ * @param string $key The parameter key
+ * @param mixed $default The default value
+ * @param boolean $deep
+ *
+ * @return string The filtered value
+ *
+ * @api
+ */
+ public function getInt($key, $default = 0, $deep = false)
+ {
+ return (int) $this->get($key, $default, $deep);
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/RedirectResponse.php b/core/includes/Symfony/Component/HttpFoundation/RedirectResponse.php
new file mode 100644
index 000000000000..3318b12bb389
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/RedirectResponse.php
@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * RedirectResponse represents an HTTP response doing a redirect.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class RedirectResponse extends Response
+{
+ /**
+ * Creates a redirect response so that it conforms to the rules defined for a redirect status code.
+ *
+ * @param string $url The URL to redirect to
+ * @param integer $status The status code (302 by default)
+ *
+ * @see http://tools.ietf.org/html/rfc2616#section-10.3
+ *
+ * @api
+ */
+ public function __construct($url, $status = 302)
+ {
+ if (empty($url)) {
+ throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
+ }
+
+ parent::__construct(
+ sprintf('<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta http-equiv="refresh" content="1;url=%1$s" />
+
+ <title>Redirecting to %1$s</title>
+ </head>
+ <body>
+ Redirecting to <a href="%1$s">%1$s</a>.
+ </body>
+</html>', htmlspecialchars($url, ENT_QUOTES, 'UTF-8')),
+ $status,
+ array('Location' => $url)
+ );
+
+ if (!$this->isRedirect()) {
+ throw new \InvalidArgumentException(sprintf('The HTTP status code is not a redirect ("%s" given).', $status));
+ }
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/Request.php b/core/includes/Symfony/Component/HttpFoundation/Request.php
new file mode 100644
index 000000000000..e66abb7484ea
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/Request.php
@@ -0,0 +1,1217 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * Request represents an HTTP request.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class Request
+{
+ static protected $trustProxy = false;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\ParameterBag
+ *
+ * @api
+ */
+ public $attributes;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\ParameterBag
+ *
+ * @api
+ */
+ public $request;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\ParameterBag
+ *
+ * @api
+ */
+ public $query;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\ServerBag
+ *
+ * @api
+ */
+ public $server;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\FileBag
+ *
+ * @api
+ */
+ public $files;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\ParameterBag
+ *
+ * @api
+ */
+ public $cookies;
+
+ /**
+ * @var \Symfony\Component\HttpFoundation\HeaderBag
+ *
+ * @api
+ */
+ public $headers;
+
+ protected $content;
+ protected $languages;
+ protected $charsets;
+ protected $acceptableContentTypes;
+ protected $pathInfo;
+ protected $requestUri;
+ protected $baseUrl;
+ protected $basePath;
+ protected $method;
+ protected $format;
+ protected $session;
+
+ static protected $formats;
+
+ /**
+ * Constructor.
+ *
+ * @param array $query The GET parameters
+ * @param array $request The POST parameters
+ * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
+ * @param array $cookies The COOKIE parameters
+ * @param array $files The FILES parameters
+ * @param array $server The SERVER parameters
+ * @param string $content The raw body data
+ *
+ * @api
+ */
+ public function __construct(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null)
+ {
+ $this->initialize($query, $request, $attributes, $cookies, $files, $server, $content);
+ }
+
+ /**
+ * Sets the parameters for this request.
+ *
+ * This method also re-initializes all properties.
+ *
+ * @param array $query The GET parameters
+ * @param array $request The POST parameters
+ * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
+ * @param array $cookies The COOKIE parameters
+ * @param array $files The FILES parameters
+ * @param array $server The SERVER parameters
+ * @param string $content The raw body data
+ *
+ * @api
+ */
+ public function initialize(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null)
+ {
+ $this->request = new ParameterBag($request);
+ $this->query = new ParameterBag($query);
+ $this->attributes = new ParameterBag($attributes);
+ $this->cookies = new ParameterBag($cookies);
+ $this->files = new FileBag($files);
+ $this->server = new ServerBag($server);
+ $this->headers = new HeaderBag($this->server->getHeaders());
+
+ $this->content = $content;
+ $this->languages = null;
+ $this->charsets = null;
+ $this->acceptableContentTypes = null;
+ $this->pathInfo = null;
+ $this->requestUri = null;
+ $this->baseUrl = null;
+ $this->basePath = null;
+ $this->method = null;
+ $this->format = null;
+ }
+
+ /**
+ * Creates a new request with values from PHP's super globals.
+ *
+ * @return Request A new request
+ *
+ * @api
+ */
+ static public function createFromGlobals()
+ {
+ $request = new static($_GET, $_POST, array(), $_COOKIE, $_FILES, $_SERVER);
+
+ if (0 === strpos($request->server->get('CONTENT_TYPE'), 'application/x-www-form-urlencoded')
+ && in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), array('PUT', 'DELETE'))
+ ) {
+ parse_str($request->getContent(), $data);
+ $request->request = new ParameterBag($data);
+ }
+
+ return $request;
+ }
+
+ /**
+ * Creates a Request based on a given URI and configuration.
+ *
+ * @param string $uri The URI
+ * @param string $method The HTTP method
+ * @param array $parameters The request (GET) or query (POST) parameters
+ * @param array $cookies The request cookies ($_COOKIE)
+ * @param array $files The request files ($_FILES)
+ * @param array $server The server parameters ($_SERVER)
+ * @param string $content The raw body data
+ *
+ * @return Request A Request instance
+ *
+ * @api
+ */
+ static public function create($uri, $method = 'GET', $parameters = array(), $cookies = array(), $files = array(), $server = array(), $content = null)
+ {
+ $defaults = array(
+ 'SERVER_NAME' => 'localhost',
+ 'SERVER_PORT' => 80,
+ 'HTTP_HOST' => 'localhost',
+ 'HTTP_USER_AGENT' => 'Symfony/2.X',
+ 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.5',
+ 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
+ 'REMOTE_ADDR' => '127.0.0.1',
+ 'SCRIPT_NAME' => '',
+ 'SCRIPT_FILENAME' => '',
+ 'SERVER_PROTOCOL' => 'HTTP/1.1',
+ 'REQUEST_TIME' => time(),
+ );
+
+ $components = parse_url($uri);
+ if (isset($components['host'])) {
+ $defaults['SERVER_NAME'] = $components['host'];
+ $defaults['HTTP_HOST'] = $components['host'];
+ }
+
+ if (isset($components['scheme'])) {
+ if ('https' === $components['scheme']) {
+ $defaults['HTTPS'] = 'on';
+ $defaults['SERVER_PORT'] = 443;
+ }
+ }
+
+ if (isset($components['port'])) {
+ $defaults['SERVER_PORT'] = $components['port'];
+ $defaults['HTTP_HOST'] = $defaults['HTTP_HOST'].':'.$components['port'];
+ }
+
+ if (!isset($components['path'])) {
+ $components['path'] = '';
+ }
+
+ if (in_array(strtoupper($method), array('POST', 'PUT', 'DELETE'))) {
+ $request = $parameters;
+ $query = array();
+ $defaults['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
+ } else {
+ $request = array();
+ $query = $parameters;
+ if (false !== $pos = strpos($uri, '?')) {
+ $qs = substr($uri, $pos + 1);
+ parse_str($qs, $params);
+
+ $query = array_merge($params, $query);
+ }
+ }
+
+ $queryString = isset($components['query']) ? html_entity_decode($components['query']) : '';
+ parse_str($queryString, $qs);
+ if (is_array($qs)) {
+ $query = array_replace($qs, $query);
+ }
+
+ $uri = $components['path'].($queryString ? '?'.$queryString : '');
+
+ $server = array_replace($defaults, $server, array(
+ 'REQUEST_METHOD' => strtoupper($method),
+ 'PATH_INFO' => '',
+ 'REQUEST_URI' => $uri,
+ 'QUERY_STRING' => $queryString,
+ ));
+
+ return new static($query, $request, array(), $cookies, $files, $server, $content);
+ }
+
+ /**
+ * Clones a request and overrides some of its parameters.
+ *
+ * @param array $query The GET parameters
+ * @param array $request The POST parameters
+ * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...)
+ * @param array $cookies The COOKIE parameters
+ * @param array $files The FILES parameters
+ * @param array $server The SERVER parameters
+ *
+ * @api
+ */
+ public function duplicate(array $query = null, array $request = null, array $attributes = null, array $cookies = null, array $files = null, array $server = null)
+ {
+ $dup = clone $this;
+ if ($query !== null) {
+ $dup->query = new ParameterBag($query);
+ }
+ if ($request !== null) {
+ $dup->request = new ParameterBag($request);
+ }
+ if ($attributes !== null) {
+ $dup->attributes = new ParameterBag($attributes);
+ }
+ if ($cookies !== null) {
+ $dup->cookies = new ParameterBag($cookies);
+ }
+ if ($files !== null) {
+ $dup->files = new FileBag($files);
+ }
+ if ($server !== null) {
+ $dup->server = new ServerBag($server);
+ $dup->headers = new HeaderBag($dup->server->getHeaders());
+ }
+ $dup->languages = null;
+ $dup->charsets = null;
+ $dup->acceptableContentTypes = null;
+ $dup->pathInfo = null;
+ $dup->requestUri = null;
+ $dup->baseUrl = null;
+ $dup->basePath = null;
+ $dup->method = null;
+ $dup->format = null;
+
+ return $dup;
+ }
+
+ /**
+ * Clones the current request.
+ *
+ * Note that the session is not cloned as duplicated requests
+ * are most of the time sub-requests of the main one.
+ */
+ public function __clone()
+ {
+ $this->query = clone $this->query;
+ $this->request = clone $this->request;
+ $this->attributes = clone $this->attributes;
+ $this->cookies = clone $this->cookies;
+ $this->files = clone $this->files;
+ $this->server = clone $this->server;
+ $this->headers = clone $this->headers;
+ }
+
+ /**
+ * Returns the request as a string.
+ *
+ * @return string The request
+ */
+ public function __toString()
+ {
+ return
+ sprintf('%s %s %s', $this->getMethod(), $this->getRequestUri(), $this->server->get('SERVER_PROTOCOL'))."\r\n".
+ $this->headers."\r\n".
+ $this->getContent();
+ }
+
+ /**
+ * Overrides the PHP global variables according to this request instance.
+ *
+ * It overrides $_GET, $_POST, $_REQUEST, $_SERVER, $_COOKIE, and $_FILES.
+ *
+ * @api
+ */
+ public function overrideGlobals()
+ {
+ $_GET = $this->query->all();
+ $_POST = $this->request->all();
+ $_SERVER = $this->server->all();
+ $_COOKIE = $this->cookies->all();
+ // FIXME: populate $_FILES
+
+ foreach ($this->headers->all() as $key => $value) {
+ $key = strtoupper(str_replace('-', '_', $key));
+ if (in_array($key, array('CONTENT_TYPE', 'CONTENT_LENGTH'))) {
+ $_SERVER[$key] = implode(', ', $value);
+ } else {
+ $_SERVER['HTTP_'.$key] = implode(', ', $value);
+ }
+ }
+
+ // FIXME: should read variables_order and request_order
+ // to know which globals to merge and in which order
+ $_REQUEST = array_merge($_GET, $_POST);
+ }
+
+ /**
+ * Trusts $_SERVER entries coming from proxies.
+ *
+ * You should only call this method if your application
+ * is hosted behind a reverse proxy that you manage.
+ *
+ * @api
+ */
+ static public function trustProxyData()
+ {
+ self::$trustProxy = true;
+ }
+
+ /**
+ * Gets a "parameter" value.
+ *
+ * This method is mainly useful for libraries that want to provide some flexibility.
+ *
+ * Order of precedence: GET, PATH, POST, COOKIE
+ * Avoid using this method in controllers:
+ * * slow
+ * * prefer to get from a "named" source
+ *
+ * @param string $key the key
+ * @param mixed $default the default value
+ * @param type $deep is parameter deep in multidimensional array
+ *
+ * @return mixed
+ */
+ public function get($key, $default = null, $deep = false)
+ {
+ return $this->query->get($key, $this->attributes->get($key, $this->request->get($key, $default, $deep), $deep), $deep);
+ }
+
+ /**
+ * Gets the Session.
+ *
+ * @return Session|null The session
+ *
+ * @api
+ */
+ public function getSession()
+ {
+ return $this->session;
+ }
+
+ /**
+ * Whether the request contains a Session which was started in one of the
+ * previous requests.
+ *
+ * @return boolean
+ *
+ * @api
+ */
+ public function hasPreviousSession()
+ {
+ // the check for $this->session avoids malicious users trying to fake a session cookie with proper name
+ return $this->cookies->has(session_name()) && null !== $this->session;
+ }
+
+ /**
+ * Whether the request contains a Session object.
+ *
+ * @return boolean
+ *
+ * @api
+ */
+ public function hasSession()
+ {
+ return null !== $this->session;
+ }
+
+ /**
+ * Sets the Session.
+ *
+ * @param Session $session The Session
+ *
+ * @api
+ */
+ public function setSession(Session $session)
+ {
+ $this->session = $session;
+ }
+
+ /**
+ * Returns the client IP address.
+ *
+ * @param Boolean $proxy Whether the current request has been made behind a proxy or not
+ *
+ * @return string The client IP address
+ *
+ * @api
+ */
+ public function getClientIp($proxy = false)
+ {
+ if ($proxy) {
+ if ($this->server->has('HTTP_CLIENT_IP')) {
+ return $this->server->get('HTTP_CLIENT_IP');
+ } elseif (self::$trustProxy && $this->server->has('HTTP_X_FORWARDED_FOR')) {
+ return $this->server->get('HTTP_X_FORWARDED_FOR');
+ }
+ }
+
+ return $this->server->get('REMOTE_ADDR');
+ }
+
+ /**
+ * Returns current script name.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getScriptName()
+ {
+ return $this->server->get('SCRIPT_NAME', $this->server->get('ORIG_SCRIPT_NAME', ''));
+ }
+
+ /**
+ * Returns the path being requested relative to the executed script.
+ *
+ * The path info always starts with a /.
+ *
+ * Suppose this request is instantiated from /mysite on localhost:
+ *
+ * * http://localhost/mysite returns an empty string
+ * * http://localhost/mysite/about returns '/about'
+ * * http://localhost/mysite/about?var=1 returns '/about'
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getPathInfo()
+ {
+ if (null === $this->pathInfo) {
+ $this->pathInfo = $this->preparePathInfo();
+ }
+
+ return $this->pathInfo;
+ }
+
+ /**
+ * Returns the root path from which this request is executed.
+ *
+ * Suppose that an index.php file instantiates this request object:
+ *
+ * * http://localhost/index.php returns an empty string
+ * * http://localhost/index.php/page returns an empty string
+ * * http://localhost/web/index.php return '/web'
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getBasePath()
+ {
+ if (null === $this->basePath) {
+ $this->basePath = $this->prepareBasePath();
+ }
+
+ return $this->basePath;
+ }
+
+ /**
+ * Returns the root url from which this request is executed.
+ *
+ * The base URL never ends with a /.
+ *
+ * This is similar to getBasePath(), except that it also includes the
+ * script filename (e.g. index.php) if one exists.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getBaseUrl()
+ {
+ if (null === $this->baseUrl) {
+ $this->baseUrl = $this->prepareBaseUrl();
+ }
+
+ return $this->baseUrl;
+ }
+
+ /**
+ * Gets the request's scheme.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getScheme()
+ {
+ return $this->isSecure() ? 'https' : 'http';
+ }
+
+ /**
+ * Returns the port on which the request is made.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getPort()
+ {
+ return $this->headers->get('X-Forwarded-Port') ?: $this->server->get('SERVER_PORT');
+ }
+
+ /**
+ * Returns the HTTP host being requested.
+ *
+ * The port name will be appended to the host if it's non-standard.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getHttpHost()
+ {
+ $scheme = $this->getScheme();
+ $port = $this->getPort();
+
+ if (('http' == $scheme && $port == 80) || ('https' == $scheme && $port == 443)) {
+ return $this->getHost();
+ }
+
+ return $this->getHost().':'.$port;
+ }
+
+ /**
+ * Returns the requested URI.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getRequestUri()
+ {
+ if (null === $this->requestUri) {
+ $this->requestUri = $this->prepareRequestUri();
+ }
+
+ return $this->requestUri;
+ }
+
+ /**
+ * Generates a normalized URI for the Request.
+ *
+ * @return string A normalized URI for the Request
+ *
+ * @see getQueryString()
+ *
+ * @api
+ */
+ public function getUri()
+ {
+ $qs = $this->getQueryString();
+ if (null !== $qs) {
+ $qs = '?'.$qs;
+ }
+
+ return $this->getScheme().'://'.$this->getHttpHost().$this->getBaseUrl().$this->getPathInfo().$qs;
+ }
+
+ /**
+ * Generates a normalized URI for the given path.
+ *
+ * @param string $path A path to use instead of the current one
+ *
+ * @return string The normalized URI for the path
+ *
+ * @api
+ */
+ public function getUriForPath($path)
+ {
+ return $this->getScheme().'://'.$this->getHttpHost().$this->getBaseUrl().$path;
+ }
+
+ /**
+ * Generates the normalized query string for the Request.
+ *
+ * It builds a normalized query string, where keys/value pairs are alphabetized
+ * and have consistent escaping.
+ *
+ * @return string A normalized query string for the Request
+ *
+ * @api
+ */
+ public function getQueryString()
+ {
+ if (!$qs = $this->server->get('QUERY_STRING')) {
+ return null;
+ }
+
+ $parts = array();
+ $order = array();
+
+ foreach (explode('&', $qs) as $segment) {
+ if (false === strpos($segment, '=')) {
+ $parts[] = $segment;
+ $order[] = $segment;
+ } else {
+ $tmp = explode('=', rawurldecode($segment), 2);
+ $parts[] = rawurlencode($tmp[0]).'='.rawurlencode($tmp[1]);
+ $order[] = $tmp[0];
+ }
+ }
+ array_multisort($order, SORT_ASC, $parts);
+
+ return implode('&', $parts);
+ }
+
+ /**
+ * Checks whether the request is secure or not.
+ *
+ * @return Boolean
+ *
+ * @api
+ */
+ public function isSecure()
+ {
+ return (
+ (strtolower($this->server->get('HTTPS')) == 'on' || $this->server->get('HTTPS') == 1)
+ ||
+ (self::$trustProxy && strtolower($this->headers->get('SSL_HTTPS')) == 'on' || $this->headers->get('SSL_HTTPS') == 1)
+ ||
+ (self::$trustProxy && strtolower($this->headers->get('X_FORWARDED_PROTO')) == 'https')
+ );
+ }
+
+ /**
+ * Returns the host name.
+ *
+ * @return string
+ *
+ * @api
+ */
+ public function getHost()
+ {
+ if (self::$trustProxy && $host = $this->headers->get('X_FORWARDED_HOST')) {
+ $elements = explode(',', $host);
+
+ $host = trim($elements[count($elements) - 1]);
+ } else {
+ if (!$host = $this->headers->get('HOST')) {
+ if (!$host = $this->server->get('SERVER_NAME')) {
+ $host = $this->server->get('SERVER_ADDR', '');
+ }
+ }
+ }
+
+ // Remove port number from host
+ $host = preg_replace('/:\d+$/', '', $host);
+
+ return trim($host);
+ }
+
+ /**
+ * Sets the request method.
+ *
+ * @param string $method
+ *
+ * @api
+ */
+ public function setMethod($method)
+ {
+ $this->method = null;
+ $this->server->set('REQUEST_METHOD', $method);
+ }
+
+ /**
+ * Gets the request method.
+ *
+ * The method is always an uppercased string.
+ *
+ * @return string The request method
+ *
+ * @api
+ */
+ public function getMethod()
+ {
+ if (null === $this->method) {
+ $this->method = strtoupper($this->server->get('REQUEST_METHOD', 'GET'));
+ if ('POST' === $this->method) {
+ $this->method = strtoupper($this->headers->get('X-HTTP-METHOD-OVERRIDE', $this->request->get('_method', 'POST')));
+ }
+ }
+
+ return $this->method;
+ }
+
+ /**
+ * Gets the mime type associated with the format.
+ *
+ * @param string $format The format
+ *
+ * @return string The associated mime type (null if not found)
+ *
+ * @api
+ */
+ public function getMimeType($format)
+ {
+ if (null === static::$formats) {
+ static::initializeFormats();
+ }
+
+ return isset(static::$formats[$format]) ? static::$formats[$format][0] : null;
+ }
+
+ /**
+ * Gets the format associated with the mime type.
+ *
+ * @param string $mimeType The associated mime type
+ *
+ * @return string The format (null if not found)
+ *
+ * @api
+ */
+ public function getFormat($mimeType)
+ {
+ if (false !== $pos = strpos($mimeType, ';')) {
+ $mimeType = substr($mimeType, 0, $pos);
+ }
+
+ if (null === static::$formats) {
+ static::initializeFormats();
+ }
+
+ foreach (static::$formats as $format => $mimeTypes) {
+ if (in_array($mimeType, (array) $mimeTypes)) {
+ return $format;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Associates a format with mime types.
+ *
+ * @param string $format The format
+ * @param string|array $mimeTypes The associated mime types (the preferred one must be the first as it will be used as the content type)
+ *
+ * @api
+ */
+ public function setFormat($format, $mimeTypes)
+ {
+ if (null === static::$formats) {
+ static::initializeFormats();
+ }
+
+ static::$formats[$format] = is_array($mimeTypes) ? $mimeTypes : array($mimeTypes);
+ }
+
+ /**
+ * Gets the request format.
+ *
+ * Here is the process to determine the format:
+ *
+ * * format defined by the user (with setRequestFormat())
+ * * _format request parameter
+ * * $default
+ *
+ * @param string $default The default format
+ *
+ * @return string The request format
+ *
+ * @api
+ */
+ public function getRequestFormat($default = 'html')
+ {
+ if (null === $this->format) {
+ $this->format = $this->get('_format', $default);
+ }
+
+ return $this->format;
+ }
+
+ /**
+ * Sets the request format.
+ *
+ * @param string $format The request format.
+ *
+ * @api
+ */
+ public function setRequestFormat($format)
+ {
+ $this->format = $format;
+ }
+
+ /**
+ * Checks whether the method is safe or not.
+ *
+ * @return Boolean
+ *
+ * @api
+ */
+ public function isMethodSafe()
+ {
+ return in_array($this->getMethod(), array('GET', 'HEAD'));
+ }
+
+ /**
+ * Returns the request body content.
+ *
+ * @param Boolean $asResource If true, a resource will be returned
+ *
+ * @return string|resource The request body content or a resource to read the body stream.
+ */
+ public function getContent($asResource = false)
+ {
+ if (false === $this->content || (true === $asResource && null !== $this->content)) {
+ throw new \LogicException('getContent() can only be called once when using the resource return type.');
+ }
+
+ if (true === $asResource) {
+ $this->content = false;
+
+ return fopen('php://input', 'rb');
+ }
+
+ if (null === $this->content) {
+ $this->content = file_get_contents('php://input');
+ }
+
+ return $this->content;
+ }
+
+ /**
+ * Gets the Etags.
+ *
+ * @return array The entity tags
+ */
+ public function getETags()
+ {
+ return preg_split('/\s*,\s*/', $this->headers->get('if_none_match'), null, PREG_SPLIT_NO_EMPTY);
+ }
+
+ public function isNoCache()
+ {
+ return $this->headers->hasCacheControlDirective('no-cache') || 'no-cache' == $this->headers->get('Pragma');
+ }
+
+ /**
+ * Returns the preferred language.
+ *
+ * @param array $locales An array of ordered available locales
+ *
+ * @return string The preferred locale
+ *
+ * @api
+ */
+ public function getPreferredLanguage(array $locales = null)
+ {
+ $preferredLanguages = $this->getLanguages();
+
+ if (null === $locales) {
+ return isset($preferredLanguages[0]) ? $preferredLanguages[0] : null;
+ }
+
+ if (!$preferredLanguages) {
+ return $locales[0];
+ }
+
+ $preferredLanguages = array_values(array_intersect($preferredLanguages, $locales));
+
+ return isset($preferredLanguages[0]) ? $preferredLanguages[0] : $locales[0];
+ }
+
+ /**
+ * Gets a list of languages acceptable by the client browser.
+ *
+ * @return array Languages ordered in the user browser preferences
+ *
+ * @api
+ */
+ public function getLanguages()
+ {
+ if (null !== $this->languages) {
+ return $this->languages;
+ }
+
+ $languages = $this->splitHttpAcceptHeader($this->headers->get('Accept-Language'));
+ $this->languages = array();
+ foreach ($languages as $lang => $q) {
+ if (strstr($lang, '-')) {
+ $codes = explode('-', $lang);
+ if ($codes[0] == 'i') {
+ // Language not listed in ISO 639 that are not variants
+ // of any listed language, which can be registered with the
+ // i-prefix, such as i-cherokee
+ if (count($codes) > 1) {
+ $lang = $codes[1];
+ }
+ } else {
+ for ($i = 0, $max = count($codes); $i < $max; $i++) {
+ if ($i == 0) {
+ $lang = strtolower($codes[0]);
+ } else {
+ $lang .= '_'.strtoupper($codes[$i]);
+ }
+ }
+ }
+ }
+
+ $this->languages[] = $lang;
+ }
+
+ return $this->languages;
+ }
+
+ /**
+ * Gets a list of charsets acceptable by the client browser.
+ *
+ * @return array List of charsets in preferable order
+ *
+ * @api
+ */
+ public function getCharsets()
+ {
+ if (null !== $this->charsets) {
+ return $this->charsets;
+ }
+
+ return $this->charsets = array_keys($this->splitHttpAcceptHeader($this->headers->get('Accept-Charset')));
+ }
+
+ /**
+ * Gets a list of content types acceptable by the client browser
+ *
+ * @return array List of content types in preferable order
+ *
+ * @api
+ */
+ public function getAcceptableContentTypes()
+ {
+ if (null !== $this->acceptableContentTypes) {
+ return $this->acceptableContentTypes;
+ }
+
+ return $this->acceptableContentTypes = array_keys($this->splitHttpAcceptHeader($this->headers->get('Accept')));
+ }
+
+ /**
+ * Returns true if the request is a XMLHttpRequest.
+ *
+ * It works if your JavaScript library set an X-Requested-With HTTP header.
+ * It is known to work with Prototype, Mootools, jQuery.
+ *
+ * @return Boolean true if the request is an XMLHttpRequest, false otherwise
+ *
+ * @api
+ */
+ public function isXmlHttpRequest()
+ {
+ return 'XMLHttpRequest' == $this->headers->get('X-Requested-With');
+ }
+
+ /**
+ * Splits an Accept-* HTTP header.
+ *
+ * @param string $header Header to split
+ */
+ public function splitHttpAcceptHeader($header)
+ {
+ if (!$header) {
+ return array();
+ }
+
+ $values = array();
+ foreach (array_filter(explode(',', $header)) as $value) {
+ // Cut off any q-value that might come after a semi-colon
+ if ($pos = strpos($value, ';')) {
+ $q = (float) trim(substr($value, strpos($value, '=') + 1));
+ $value = trim(substr($value, 0, $pos));
+ } else {
+ $q = 1;
+ }
+
+ if (0 < $q) {
+ $values[trim($value)] = $q;
+ }
+ }
+
+ arsort($values);
+ reset($values);
+
+ return $values;
+ }
+
+ /*
+ * The following methods are derived from code of the Zend Framework (1.10dev - 2010-01-24)
+ *
+ * Code subject to the new BSD license (http://framework.zend.com/license/new-bsd).
+ *
+ * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
+ */
+
+ protected function prepareRequestUri()
+ {
+ $requestUri = '';
+
+ if ($this->headers->has('X_REWRITE_URL')) {
+ // check this first so IIS will catch
+ $requestUri = $this->headers->get('X_REWRITE_URL');
+ } elseif ($this->server->get('IIS_WasUrlRewritten') == '1' && $this->server->get('UNENCODED_URL') != '') {
+ // IIS7 with URL Rewrite: make sure we get the unencoded url (double slash problem)
+ $requestUri = $this->server->get('UNENCODED_URL');
+ } elseif ($this->server->has('REQUEST_URI')) {
+ $requestUri = $this->server->get('REQUEST_URI');
+ // HTTP proxy reqs setup request uri with scheme and host [and port] + the url path, only use url path
+ $schemeAndHttpHost = $this->getScheme().'://'.$this->getHttpHost();
+ if (strpos($requestUri, $schemeAndHttpHost) === 0) {
+ $requestUri = substr($requestUri, strlen($schemeAndHttpHost));
+ }
+ } elseif ($this->server->has('ORIG_PATH_INFO')) {
+ // IIS 5.0, PHP as CGI
+ $requestUri = $this->server->get('ORIG_PATH_INFO');
+ if ($this->server->get('QUERY_STRING')) {
+ $requestUri .= '?'.$this->server->get('QUERY_STRING');
+ }
+ }
+
+ return $requestUri;
+ }
+
+ protected function prepareBaseUrl()
+ {
+ $filename = basename($this->server->get('SCRIPT_FILENAME'));
+
+ if (basename($this->server->get('SCRIPT_NAME')) === $filename) {
+ $baseUrl = $this->server->get('SCRIPT_NAME');
+ } elseif (basename($this->server->get('PHP_SELF')) === $filename) {
+ $baseUrl = $this->server->get('PHP_SELF');
+ } elseif (basename($this->server->get('ORIG_SCRIPT_NAME')) === $filename) {
+ $baseUrl = $this->server->get('ORIG_SCRIPT_NAME'); // 1and1 shared hosting compatibility
+ } else {
+ // Backtrack up the script_filename to find the portion matching
+ // php_self
+ $path = $this->server->get('PHP_SELF', '');
+ $file = $this->server->get('SCRIPT_FILENAME', '');
+ $segs = explode('/', trim($file, '/'));
+ $segs = array_reverse($segs);
+ $index = 0;
+ $last = count($segs);
+ $baseUrl = '';
+ do {
+ $seg = $segs[$index];
+ $baseUrl = '/'.$seg.$baseUrl;
+ ++$index;
+ } while (($last > $index) && (false !== ($pos = strpos($path, $baseUrl))) && (0 != $pos));
+ }
+
+ // Does the baseUrl have anything in common with the request_uri?
+ $requestUri = $this->getRequestUri();
+
+ if ($baseUrl && 0 === strpos($requestUri, $baseUrl)) {
+ // full $baseUrl matches
+ return $baseUrl;
+ }
+
+ if ($baseUrl && 0 === strpos($requestUri, dirname($baseUrl))) {
+ // directory portion of $baseUrl matches
+ return rtrim(dirname($baseUrl), '/');
+ }
+
+ $truncatedRequestUri = $requestUri;
+ if (($pos = strpos($requestUri, '?')) !== false) {
+ $truncatedRequestUri = substr($requestUri, 0, $pos);
+ }
+
+ $basename = basename($baseUrl);
+ if (empty($basename) || !strpos($truncatedRequestUri, $basename)) {
+ // no match whatsoever; set it blank
+ return '';
+ }
+
+ // If using mod_rewrite or ISAPI_Rewrite strip the script filename
+ // out of baseUrl. $pos !== 0 makes sure it is not matching a value
+ // from PATH_INFO or QUERY_STRING
+ if ((strlen($requestUri) >= strlen($baseUrl)) && ((false !== ($pos = strpos($requestUri, $baseUrl))) && ($pos !== 0))) {
+ $baseUrl = substr($requestUri, 0, $pos + strlen($baseUrl));
+ }
+
+ return rtrim($baseUrl, '/');
+ }
+
+ /**
+ * Prepares base path.
+ *
+ * @return string base path
+ */
+ protected function prepareBasePath()
+ {
+ $filename = basename($this->server->get('SCRIPT_FILENAME'));
+ $baseUrl = $this->getBaseUrl();
+ if (empty($baseUrl)) {
+ return '';
+ }
+
+ if (basename($baseUrl) === $filename) {
+ $basePath = dirname($baseUrl);
+ } else {
+ $basePath = $baseUrl;
+ }
+
+ if ('\\' === DIRECTORY_SEPARATOR) {
+ $basePath = str_replace('\\', '/', $basePath);
+ }
+
+ return rtrim($basePath, '/');
+ }
+
+ /**
+ * Prepares path info.
+ *
+ * @return string path info
+ */
+ protected function preparePathInfo()
+ {
+ $baseUrl = $this->getBaseUrl();
+
+ if (null === ($requestUri = $this->getRequestUri())) {
+ return '/';
+ }
+
+ $pathInfo = '/';
+
+ // Remove the query string from REQUEST_URI
+ if ($pos = strpos($requestUri, '?')) {
+ $requestUri = substr($requestUri, 0, $pos);
+ }
+
+ if ((null !== $baseUrl) && (false === ($pathInfo = substr(urldecode($requestUri), strlen(urldecode($baseUrl)))))) {
+ // If substr() returns false then PATH_INFO is set to an empty string
+ return '/';
+ } elseif (null === $baseUrl) {
+ return $requestUri;
+ }
+
+ return (string) $pathInfo;
+ }
+
+ /**
+ * Initializes HTTP request formats.
+ */
+ static protected function initializeFormats()
+ {
+ static::$formats = array(
+ 'html' => array('text/html', 'application/xhtml+xml'),
+ 'txt' => array('text/plain'),
+ 'js' => array('application/javascript', 'application/x-javascript', 'text/javascript'),
+ 'css' => array('text/css'),
+ 'json' => array('application/json', 'application/x-json'),
+ 'xml' => array('text/xml', 'application/xml', 'application/x-xml'),
+ 'rdf' => array('application/rdf+xml'),
+ 'atom' => array('application/atom+xml'),
+ );
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/RequestMatcher.php b/core/includes/Symfony/Component/HttpFoundation/RequestMatcher.php
new file mode 100644
index 000000000000..52ba0784bbca
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/RequestMatcher.php
@@ -0,0 +1,177 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * RequestMatcher compares a pre-defined set of checks against a Request instance.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class RequestMatcher implements RequestMatcherInterface
+{
+ private $path;
+ private $host;
+ private $methods;
+ private $ip;
+ private $attributes;
+
+ public function __construct($path = null, $host = null, $methods = null, $ip = null, array $attributes = array())
+ {
+ $this->path = $path;
+ $this->host = $host;
+ $this->methods = $methods;
+ $this->ip = $ip;
+ $this->attributes = $attributes;
+ }
+
+ /**
+ * Adds a check for the URL host name.
+ *
+ * @param string $regexp A Regexp
+ */
+ public function matchHost($regexp)
+ {
+ $this->host = $regexp;
+ }
+
+ /**
+ * Adds a check for the URL path info.
+ *
+ * @param string $regexp A Regexp
+ */
+ public function matchPath($regexp)
+ {
+ $this->path = $regexp;
+ }
+
+ /**
+ * Adds a check for the client IP.
+ *
+ * @param string $ip A specific IP address or a range specified using IP/netmask like 192.168.1.0/24
+ */
+ public function matchIp($ip)
+ {
+ $this->ip = $ip;
+ }
+
+ /**
+ * Adds a check for the HTTP method.
+ *
+ * @param string|array $method An HTTP method or an array of HTTP methods
+ */
+ public function matchMethod($method)
+ {
+ $this->methods = array_map('strtoupper', is_array($method) ? $method : array($method));
+ }
+
+ /**
+ * Adds a check for request attribute.
+ *
+ * @param string $key The request attribute name
+ * @param string $regexp A Regexp
+ */
+ public function matchAttribute($key, $regexp)
+ {
+ $this->attributes[$key] = $regexp;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @api
+ */
+ public function matches(Request $request)
+ {
+ if (null !== $this->methods && !in_array($request->getMethod(), $this->methods)) {
+ return false;
+ }
+
+ foreach ($this->attributes as $key => $pattern) {
+ if (!preg_match('#'.str_replace('#', '\\#', $pattern).'#', $request->attributes->get($key))) {
+ return false;
+ }
+ }
+
+ if (null !== $this->path) {
+ $path = str_replace('#', '\\#', $this->path);
+
+ if (!preg_match('#'.$path.'#', $request->getPathInfo())) {
+ return false;
+ }
+ }
+
+ if (null !== $this->host && !preg_match('#'.str_replace('#', '\\#', $this->host).'#', $request->getHost())) {
+ return false;
+ }
+
+ if (null !== $this->ip && !$this->checkIp($request->getClientIp(), $this->ip)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function checkIp($requestIp, $ip)
+ {
+ // IPv6 address
+ if (false !== strpos($requestIp, ':')) {
+ return $this->checkIp6($requestIp, $ip);
+ } else {
+ return $this->checkIp4($requestIp, $ip);
+ }
+ }
+
+ protected function checkIp4($requestIp, $ip)
+ {
+ if (false !== strpos($ip, '/')) {
+ list($address, $netmask) = explode('/', $ip);
+
+ if ($netmask < 1 || $netmask > 32) {
+ return false;
+ }
+ } else {
+ $address = $ip;
+ $netmask = 32;
+ }
+
+ return 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask);
+ }
+
+ /**
+ * @author David Soria Parra <dsp at php dot net>
+ * @see https://github.com/dsp/v6tools
+ */
+ protected function checkIp6($requestIp, $ip)
+ {
+ if (!defined('AF_INET6')) {
+ throw new \RuntimeException('Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".');
+ }
+
+ list($address, $netmask) = explode('/', $ip);
+
+ $bytes_addr = unpack("n*", inet_pton($address));
+ $bytes_test = unpack("n*", inet_pton($requestIp));
+
+ for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; $i++) {
+ $left = $netmask - 16 * ($i-1);
+ $left = ($left <= 16) ?: 16;
+ $mask = ~(0xffff >> $left) & 0xffff;
+ if (($bytes_addr[$i] & $mask) != ($bytes_test[$i] & $mask)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/RequestMatcherInterface.php b/core/includes/Symfony/Component/HttpFoundation/RequestMatcherInterface.php
new file mode 100644
index 000000000000..506ec7940153
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/RequestMatcherInterface.php
@@ -0,0 +1,33 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * RequestMatcherInterface is an interface for strategies to match a Request.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+interface RequestMatcherInterface
+{
+ /**
+ * Decides whether the rule(s) implemented by the strategy matches the supplied request.
+ *
+ * @param Request $request The request to check for a match
+ *
+ * @return Boolean true if the request matches, false otherwise
+ *
+ * @api
+ */
+ function matches(Request $request);
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/Response.php b/core/includes/Symfony/Component/HttpFoundation/Response.php
new file mode 100644
index 000000000000..e8a7d5624498
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/Response.php
@@ -0,0 +1,891 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * Response represents an HTTP response.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class Response
+{
+ /**
+ * @var \Symfony\Component\HttpFoundation\ResponseHeaderBag
+ */
+ public $headers;
+
+ protected $content;
+ protected $version;
+ protected $statusCode;
+ protected $statusText;
+ protected $charset;
+
+ static public $statusTexts = array(
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 307 => 'Temporary Redirect',
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Timeout',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Request Entity Too Large',
+ 414 => 'Request-URI Too Long',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Requested Range Not Satisfiable',
+ 417 => 'Expectation Failed',
+ 418 => 'I\'m a teapot',
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Timeout',
+ 505 => 'HTTP Version Not Supported',
+ );
+
+ /**
+ * Constructor.
+ *
+ * @param string $content The response content
+ * @param integer $status The response status code
+ * @param array $headers An array of response headers
+ *
+ * @api
+ */
+ public function __construct($content = '', $status = 200, $headers = array())
+ {
+ $this->headers = new ResponseHeaderBag($headers);
+ $this->setContent($content);
+ $this->setStatusCode($status);
+ $this->setProtocolVersion('1.0');
+ if (!$this->headers->has('Date')) {
+ $this->setDate(new \DateTime(null, new \DateTimeZone('UTC')));
+ }
+ }
+
+ /**
+ * Returns the response content as it will be sent (with the headers).
+ *
+ * @return string The response content
+ */
+ public function __toString()
+ {
+ $this->prepare();
+
+ return
+ sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n".
+ $this->headers."\r\n".
+ $this->getContent();
+ }
+
+ /**
+ * Clones the current Response instance.
+ */
+ public function __clone()
+ {
+ $this->headers = clone $this->headers;
+ }
+
+ /**
+ * Prepares the Response before it is sent to the client.
+ *
+ * This method tweaks the Response to ensure that it is
+ * compliant with RFC 2616.
+ */
+ public function prepare()
+ {
+ if ($this->isInformational() || in_array($this->statusCode, array(204, 304))) {
+ $this->setContent('');
+ }
+
+ // Fix Content-Type
+ $charset = $this->charset ?: 'UTF-8';
+ if (!$this->headers->has('Content-Type')) {
+ $this->headers->set('Content-Type', 'text/html; charset='.$charset);
+ } elseif ('text/' === substr($this->headers->get('Content-Type'), 0, 5) && false === strpos($this->headers->get('Content-Type'), 'charset')) {
+ // add the charset
+ $this->headers->set('Content-Type', $this->headers->get('Content-Type').'; charset='.$charset);
+ }
+
+ // Fix Content-Length
+ if ($this->headers->has('Transfer-Encoding')) {
+ $this->headers->remove('Content-Length');
+ }
+ }
+
+ /**
+ * Sends HTTP headers.
+ */
+ public function sendHeaders()
+ {
+ // headers have already been sent by the developer
+ if (headers_sent()) {
+ return;
+ }
+
+ $this->prepare();
+
+ // status
+ header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText));
+
+ // headers
+ foreach ($this->headers->all() as $name => $values) {
+ foreach ($values as $value) {
+ header($name.': '.$value, false);
+ }
+ }
+
+ // cookies
+ foreach ($this->headers->getCookies() as $cookie) {
+ setcookie($cookie->getName(), $cookie->getValue(), $cookie->getExpiresTime(), $cookie->getPath(), $cookie->getDomain(), $cookie->isSecure(), $cookie->isHttpOnly());
+ }
+ }
+
+ /**
+ * Sends content for the current web response.
+ */
+ public function sendContent()
+ {
+ echo $this->content;
+ }
+
+ /**
+ * Sends HTTP headers and content.
+ *
+ * @api
+ */
+ public function send()
+ {
+ $this->sendHeaders();
+ $this->sendContent();
+
+ if (function_exists('fastcgi_finish_request')) {
+ fastcgi_finish_request();
+ }
+ }
+
+ /**
+ * Sets the response content
+ *
+ * Valid types are strings, numbers, and objects that implement a __toString() method.
+ *
+ * @param mixed $content
+ *
+ * @api
+ */
+ public function setContent($content)
+ {
+ if (null !== $content && !is_string($content) && !is_numeric($content) && !is_callable(array($content, '__toString'))) {
+ throw new \UnexpectedValueException('The Response content must be a string or object implementing __toString(), "'.gettype($content).'" given.');
+ }
+
+ $this->content = (string) $content;
+ }
+
+ /**
+ * Gets the current response content
+ *
+ * @return string Content
+ *
+ * @api
+ */
+ public function getContent()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Sets the HTTP protocol version (1.0 or 1.1).
+ *
+ * @param string $version The HTTP protocol version
+ *
+ * @api
+ */
+ public function setProtocolVersion($version)
+ {
+ $this->version = $version;
+ }
+
+ /**
+ * Gets the HTTP protocol version.
+ *
+ * @return string The HTTP protocol version
+ *
+ * @api
+ */
+ public function getProtocolVersion()
+ {
+ return $this->version;
+ }
+
+ /**
+ * Sets response status code.
+ *
+ * @param integer $code HTTP status code
+ * @param string $text HTTP status text
+ *
+ * @throws \InvalidArgumentException When the HTTP status code is not valid
+ *
+ * @api
+ */
+ public function setStatusCode($code, $text = null)
+ {
+ $this->statusCode = (int) $code;
+ if ($this->isInvalid()) {
+ throw new \InvalidArgumentException(sprintf('The HTTP status code "%s" is not valid.', $code));
+ }
+
+ $this->statusText = false === $text ? '' : (null === $text ? self::$statusTexts[$this->statusCode] : $text);
+ }
+
+ /**
+ * Retrieves status code for the current web response.
+ *
+ * @return string Status code
+ *
+ * @api
+ */
+ public function getStatusCode()
+ {
+ return $this->statusCode;
+ }
+
+ /**
+ * Sets response charset.
+ *
+ * @param string $charset Character set
+ *
+ * @api
+ */
+ public function setCharset($charset)
+ {
+ $this->charset = $charset;
+ }
+
+ /**
+ * Retrieves the response charset.
+ *
+ * @return string Character set
+ *
+ * @api
+ */
+ public function getCharset()
+ {
+ return $this->charset;
+ }
+
+ /**
+ * Returns true if the response is worth caching under any circumstance.
+ *
+ * Responses marked "private" with an explicit Cache-Control directive are
+ * considered uncacheable.
+ *
+ * Responses with neither a freshness lifetime (Expires, max-age) nor cache
+ * validator (Last-Modified, ETag) are considered uncacheable.
+ *
+ * @return Boolean true if the response is worth caching, false otherwise
+ *
+ * @api
+ */
+ public function isCacheable()
+ {
+ if (!in_array($this->statusCode, array(200, 203, 300, 301, 302, 404, 410))) {
+ return false;
+ }
+
+ if ($this->headers->hasCacheControlDirective('no-store') || $this->headers->getCacheControlDirective('private')) {
+ return false;
+ }
+
+ return $this->isValidateable() || $this->isFresh();
+ }
+
+ /**
+ * Returns true if the response is "fresh".
+ *
+ * Fresh responses may be served from cache without any interaction with the
+ * origin. A response is considered fresh when it includes a Cache-Control/max-age
+ * indicator or Expiration header and the calculated age is less than the freshness lifetime.
+ *
+ * @return Boolean true if the response is fresh, false otherwise
+ *
+ * @api
+ */
+ public function isFresh()
+ {
+ return $this->getTtl() > 0;
+ }
+
+ /**
+ * Returns true if the response includes headers that can be used to validate
+ * the response with the origin server using a conditional GET request.
+ *
+ * @return Boolean true if the response is validateable, false otherwise
+ *
+ * @api
+ */
+ public function isValidateable()
+ {
+ return $this->headers->has('Last-Modified') || $this->headers->has('ETag');
+ }
+
+ /**
+ * Marks the response as "private".
+ *
+ * It makes the response ineligible for serving other clients.
+ *
+ * @api
+ */
+ public function setPrivate()
+ {
+ $this->headers->removeCacheControlDirective('public');
+ $this->headers->addCacheControlDirective('private');
+ }
+
+ /**
+ * Marks the response as "public".
+ *
+ * It makes the response eligible for serving other clients.
+ *
+ * @api
+ */
+ public function setPublic()
+ {
+ $this->headers->addCacheControlDirective('public');
+ $this->headers->removeCacheControlDirective('private');
+ }
+
+ /**
+ * Returns true if the response must be revalidated by caches.
+ *
+ * This method indicates that the response must not be served stale by a
+ * cache in any circumstance without first revalidating with the origin.
+ * When present, the TTL of the response should not be overridden to be
+ * greater than the value provided by the origin.
+ *
+ * @return Boolean true if the response must be revalidated by a cache, false otherwise
+ *
+ * @api
+ */
+ public function mustRevalidate()
+ {
+ return $this->headers->hasCacheControlDirective('must-revalidate') || $this->headers->has('must-proxy-revalidate');
+ }
+
+ /**
+ * Returns the Date header as a DateTime instance.
+ *
+ * @return \DateTime A \DateTime instance
+ *
+ * @throws \RuntimeException when the header is not parseable
+ *
+ * @api
+ */
+ public function getDate()
+ {
+ return $this->headers->getDate('Date');
+ }
+
+ /**
+ * Sets the Date header.
+ *
+ * @param \DateTime $date A \DateTime instance
+ *
+ * @api
+ */
+ public function setDate(\DateTime $date)
+ {
+ $date->setTimezone(new \DateTimeZone('UTC'));
+ $this->headers->set('Date', $date->format('D, d M Y H:i:s').' GMT');
+ }
+
+ /**
+ * Returns the age of the response.
+ *
+ * @return integer The age of the response in seconds
+ */
+ public function getAge()
+ {
+ if ($age = $this->headers->get('Age')) {
+ return $age;
+ }
+
+ return max(time() - $this->getDate()->format('U'), 0);
+ }
+
+ /**
+ * Marks the response stale by setting the Age header to be equal to the maximum age of the response.
+ *
+ * @api
+ */
+ public function expire()
+ {
+ if ($this->isFresh()) {
+ $this->headers->set('Age', $this->getMaxAge());
+ }
+ }
+
+ /**
+ * Returns the value of the Expires header as a DateTime instance.
+ *
+ * @return \DateTime A DateTime instance
+ *
+ * @api
+ */
+ public function getExpires()
+ {
+ return $this->headers->getDate('Expires');
+ }
+
+ /**
+ * Sets the Expires HTTP header with a \DateTime instance.
+ *
+ * If passed a null value, it removes the header.
+ *
+ * @param \DateTime $date A \DateTime instance
+ *
+ * @api
+ */
+ public function setExpires(\DateTime $date = null)
+ {
+ if (null === $date) {
+ $this->headers->remove('Expires');
+ } else {
+ $date = clone $date;
+ $date->setTimezone(new \DateTimeZone('UTC'));
+ $this->headers->set('Expires', $date->format('D, d M Y H:i:s').' GMT');
+ }
+ }
+
+ /**
+ * Sets the number of seconds after the time specified in the response's Date
+ * header when the the response should no longer be considered fresh.
+ *
+ * First, it checks for a s-maxage directive, then a max-age directive, and then it falls
+ * back on an expires header. It returns null when no maximum age can be established.
+ *
+ * @return integer|null Number of seconds
+ *
+ * @api
+ */
+ public function getMaxAge()
+ {
+ if ($age = $this->headers->getCacheControlDirective('s-maxage')) {
+ return $age;
+ }
+
+ if ($age = $this->headers->getCacheControlDirective('max-age')) {
+ return $age;
+ }
+
+ if (null !== $this->getExpires()) {
+ return $this->getExpires()->format('U') - $this->getDate()->format('U');
+ }
+
+ return null;
+ }
+
+ /**
+ * Sets the number of seconds after which the response should no longer be considered fresh.
+ *
+ * This methods sets the Cache-Control max-age directive.
+ *
+ * @param integer $value A number of seconds
+ *
+ * @api
+ */
+ public function setMaxAge($value)
+ {
+ $this->headers->addCacheControlDirective('max-age', $value);
+ }
+
+ /**
+ * Sets the number of seconds after which the response should no longer be considered fresh by shared caches.
+ *
+ * This methods sets the Cache-Control s-maxage directive.
+ *
+ * @param integer $value A number of seconds
+ *
+ * @api
+ */
+ public function setSharedMaxAge($value)
+ {
+ $this->setPublic();
+ $this->headers->addCacheControlDirective('s-maxage', $value);
+ }
+
+ /**
+ * Returns the response's time-to-live in seconds.
+ *
+ * It returns null when no freshness information is present in the response.
+ *
+ * When the responses TTL is <= 0, the response may not be served from cache without first
+ * revalidating with the origin.
+ *
+ * @return integer The TTL in seconds
+ *
+ * @api
+ */
+ public function getTtl()
+ {
+ if ($maxAge = $this->getMaxAge()) {
+ return $maxAge - $this->getAge();
+ }
+
+ return null;
+ }
+
+ /**
+ * Sets the response's time-to-live for shared caches.
+ *
+ * This method adjusts the Cache-Control/s-maxage directive.
+ *
+ * @param integer $seconds The number of seconds
+ *
+ * @api
+ */
+ public function setTtl($seconds)
+ {
+ $this->setSharedMaxAge($this->getAge() + $seconds);
+ }
+
+ /**
+ * Sets the response's time-to-live for private/client caches.
+ *
+ * This method adjusts the Cache-Control/max-age directive.
+ *
+ * @param integer $seconds The number of seconds
+ *
+ * @api
+ */
+ public function setClientTtl($seconds)
+ {
+ $this->setMaxAge($this->getAge() + $seconds);
+ }
+
+ /**
+ * Returns the Last-Modified HTTP header as a DateTime instance.
+ *
+ * @return \DateTime A DateTime instance
+ *
+ * @api
+ */
+ public function getLastModified()
+ {
+ return $this->headers->getDate('Last-Modified');
+ }
+
+ /**
+ * Sets the Last-Modified HTTP header with a \DateTime instance.
+ *
+ * If passed a null value, it removes the header.
+ *
+ * @param \DateTime $date A \DateTime instance
+ *
+ * @api
+ */
+ public function setLastModified(\DateTime $date = null)
+ {
+ if (null === $date) {
+ $this->headers->remove('Last-Modified');
+ } else {
+ $date = clone $date;
+ $date->setTimezone(new \DateTimeZone('UTC'));
+ $this->headers->set('Last-Modified', $date->format('D, d M Y H:i:s').' GMT');
+ }
+ }
+
+ /**
+ * Returns the literal value of ETag HTTP header.
+ *
+ * @return string The ETag HTTP header
+ *
+ * @api
+ */
+ public function getEtag()
+ {
+ return $this->headers->get('ETag');
+ }
+
+ /**
+ * Sets the ETag value.
+ *
+ * @param string $etag The ETag unique identifier
+ * @param Boolean $weak Whether you want a weak ETag or not
+ *
+ * @api
+ */
+ public function setEtag($etag = null, $weak = false)
+ {
+ if (null === $etag) {
+ $this->headers->remove('Etag');
+ } else {
+ if (0 !== strpos($etag, '"')) {
+ $etag = '"'.$etag.'"';
+ }
+
+ $this->headers->set('ETag', (true === $weak ? 'W/' : '').$etag);
+ }
+ }
+
+ /**
+ * Sets Response cache headers (validation and/or expiration).
+ *
+ * Available options are: etag, last_modified, max_age, s_maxage, private, and public.
+ *
+ * @param array $options An array of cache options
+ *
+ * @api
+ */
+ public function setCache(array $options)
+ {
+ if ($diff = array_diff(array_keys($options), array('etag', 'last_modified', 'max_age', 's_maxage', 'private', 'public'))) {
+ throw new \InvalidArgumentException(sprintf('Response does not support the following options: "%s".', implode('", "', array_keys($diff))));
+ }
+
+ if (isset($options['etag'])) {
+ $this->setEtag($options['etag']);
+ }
+
+ if (isset($options['last_modified'])) {
+ $this->setLastModified($options['last_modified']);
+ }
+
+ if (isset($options['max_age'])) {
+ $this->setMaxAge($options['max_age']);
+ }
+
+ if (isset($options['s_maxage'])) {
+ $this->setSharedMaxAge($options['s_maxage']);
+ }
+
+ if (isset($options['public'])) {
+ if ($options['public']) {
+ $this->setPublic();
+ } else {
+ $this->setPrivate();
+ }
+ }
+
+ if (isset($options['private'])) {
+ if ($options['private']) {
+ $this->setPrivate();
+ } else {
+ $this->setPublic();
+ }
+ }
+ }
+
+ /**
+ * Modifies the response so that it conforms to the rules defined for a 304 status code.
+ *
+ * This sets the status, removes the body, and discards any headers
+ * that MUST NOT be included in 304 responses.
+ *
+ * @see http://tools.ietf.org/html/rfc2616#section-10.3.5
+ *
+ * @api
+ */
+ public function setNotModified()
+ {
+ $this->setStatusCode(304);
+ $this->setContent(null);
+
+ // remove headers that MUST NOT be included with 304 Not Modified responses
+ foreach (array('Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified') as $header) {
+ $this->headers->remove($header);
+ }
+ }
+
+ /**
+ * Returns true if the response includes a Vary header.
+ *
+ * @return true if the response includes a Vary header, false otherwise
+ *
+ * @api
+ */
+ public function hasVary()
+ {
+ return (Boolean) $this->headers->get('Vary');
+ }
+
+ /**
+ * Returns an array of header names given in the Vary header.
+ *
+ * @return array An array of Vary names
+ *
+ * @api
+ */
+ public function getVary()
+ {
+ if (!$vary = $this->headers->get('Vary')) {
+ return array();
+ }
+
+ return is_array($vary) ? $vary : preg_split('/[\s,]+/', $vary);
+ }
+
+ /**
+ * Sets the Vary header.
+ *
+ * @param string|array $headers
+ * @param Boolean $replace Whether to replace the actual value of not (true by default)
+ *
+ * @api
+ */
+ public function setVary($headers, $replace = true)
+ {
+ $this->headers->set('Vary', $headers, $replace);
+ }
+
+ /**
+ * Determines if the Response validators (ETag, Last-Modified) matches
+ * a conditional value specified in the Request.
+ *
+ * If the Response is not modified, it sets the status code to 304 and
+ * remove the actual content by calling the setNotModified() method.
+ *
+ * @param Request $request A Request instance
+ *
+ * @return Boolean true if the Response validators matches the Request, false otherwise
+ *
+ * @api
+ */
+ public function isNotModified(Request $request)
+ {
+ $lastModified = $request->headers->get('If-Modified-Since');
+ $notModified = false;
+ if ($etags = $request->getEtags()) {
+ $notModified = (in_array($this->getEtag(), $etags) || in_array('*', $etags)) && (!$lastModified || $this->headers->get('Last-Modified') == $lastModified);
+ } elseif ($lastModified) {
+ $notModified = $lastModified == $this->headers->get('Last-Modified');
+ }
+
+ if ($notModified) {
+ $this->setNotModified();
+ }
+
+ return $notModified;
+ }
+
+ // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
+ /**
+ * @api
+ */
+ public function isInvalid()
+ {
+ return $this->statusCode < 100 || $this->statusCode >= 600;
+ }
+
+ /**
+ * @api
+ */
+ public function isInformational()
+ {
+ return $this->statusCode >= 100 && $this->statusCode < 200;
+ }
+
+ /**
+ * @api
+ */
+ public function isSuccessful()
+ {
+ return $this->statusCode >= 200 && $this->statusCode < 300;
+ }
+
+ /**
+ * @api
+ */
+ public function isRedirection()
+ {
+ return $this->statusCode >= 300 && $this->statusCode < 400;
+ }
+
+ /**
+ * @api
+ */
+ public function isClientError()
+ {
+ return $this->statusCode >= 400 && $this->statusCode < 500;
+ }
+
+ /**
+ * @api
+ */
+ public function isServerError()
+ {
+ return $this->statusCode >= 500 && $this->statusCode < 600;
+ }
+
+ /**
+ * @api
+ */
+ public function isOk()
+ {
+ return 200 === $this->statusCode;
+ }
+
+ /**
+ * @api
+ */
+ public function isForbidden()
+ {
+ return 403 === $this->statusCode;
+ }
+
+ /**
+ * @api
+ */
+ public function isNotFound()
+ {
+ return 404 === $this->statusCode;
+ }
+
+ /**
+ * @api
+ */
+ public function isRedirect($location = null)
+ {
+ return in_array($this->statusCode, array(201, 301, 302, 303, 307)) && (null === $location ?: $location == $this->headers->get('Location'));
+ }
+
+ /**
+ * @api
+ */
+ public function isEmpty()
+ {
+ return in_array($this->statusCode, array(201, 204, 304));
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/ResponseHeaderBag.php b/core/includes/Symfony/Component/HttpFoundation/ResponseHeaderBag.php
new file mode 100644
index 000000000000..f243dcc9f6bb
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/ResponseHeaderBag.php
@@ -0,0 +1,238 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * ResponseHeaderBag is a container for Response HTTP headers.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class ResponseHeaderBag extends HeaderBag
+{
+ const COOKIES_FLAT = 'flat';
+ const COOKIES_ARRAY = 'array';
+
+ protected $computedCacheControl = array();
+ protected $cookies = array();
+
+ /**
+ * Constructor.
+ *
+ * @param array $headers An array of HTTP headers
+ *
+ * @api
+ */
+ public function __construct(array $headers = array())
+ {
+ parent::__construct($headers);
+
+ if (!isset($this->headers['cache-control'])) {
+ $this->set('cache-control', '');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function __toString()
+ {
+ $cookies = '';
+ foreach ($this->getCookies() as $cookie) {
+ $cookies .= 'Set-Cookie: '.$cookie."\r\n";
+ }
+
+ return parent::__toString().$cookies;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @api
+ */
+ public function replace(array $headers = array())
+ {
+ parent::replace($headers);
+
+ if (!isset($this->headers['cache-control'])) {
+ $this->set('cache-control', '');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @api
+ */
+ public function set($key, $values, $replace = true)
+ {
+ parent::set($key, $values, $replace);
+
+ // ensure the cache-control header has sensible defaults
+ if (in_array(strtr(strtolower($key), '_', '-'), array('cache-control', 'etag', 'last-modified', 'expires'))) {
+ $computed = $this->computeCacheControlValue();
+ $this->headers['cache-control'] = array($computed);
+ $this->computedCacheControl = $this->parseCacheControl($computed);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @api
+ */
+ public function remove($key)
+ {
+ parent::remove($key);
+
+ if ('cache-control' === strtr(strtolower($key), '_', '-')) {
+ $this->computedCacheControl = array();
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function hasCacheControlDirective($key)
+ {
+ return array_key_exists($key, $this->computedCacheControl);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCacheControlDirective($key)
+ {
+ return array_key_exists($key, $this->computedCacheControl) ? $this->computedCacheControl[$key] : null;
+ }
+
+ /**
+ * Sets a cookie.
+ *
+ * @param Cookie $cookie
+ * @return void
+ *
+ * @api
+ */
+ public function setCookie(Cookie $cookie)
+ {
+ $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
+ }
+
+ /**
+ * Removes a cookie from the array, but does not unset it in the browser
+ *
+ * @param string $name
+ * @param string $path
+ * @param string $domain
+ * @return void
+ *
+ * @api
+ */
+ public function removeCookie($name, $path = '/', $domain = null)
+ {
+ if (null === $path) {
+ $path = '/';
+ }
+
+ unset($this->cookies[$domain][$path][$name]);
+
+ if (empty($this->cookies[$domain][$path])) {
+ unset($this->cookies[$domain][$path]);
+
+ if (empty($this->cookies[$domain])) {
+ unset($this->cookies[$domain]);
+ }
+ }
+ }
+
+ /**
+ * Returns an array with all cookies
+ *
+ * @param string $format
+ *
+ * @throws \InvalidArgumentException When the $format is invalid
+ *
+ * @return array
+ *
+ * @api
+ */
+ public function getCookies($format = self::COOKIES_FLAT)
+ {
+ if (!in_array($format, array(self::COOKIES_FLAT, self::COOKIES_ARRAY))) {
+ throw new \InvalidArgumentException(sprintf('Format "%s" invalid (%s).', $format, implode(', ', array(self::COOKIES_FLAT, self::COOKIES_ARRAY))));
+ }
+
+ if (self::COOKIES_ARRAY === $format) {
+ return $this->cookies;
+ }
+
+ $flattenedCookies = array();
+ foreach ($this->cookies as $path) {
+ foreach ($path as $cookies) {
+ foreach ($cookies as $cookie) {
+ $flattenedCookies[] = $cookie;
+ }
+ }
+ }
+
+ return $flattenedCookies;
+ }
+
+ /**
+ * Clears a cookie in the browser
+ *
+ * @param string $name
+ * @param string $path
+ * @param string $domain
+ * @return void
+ *
+ * @api
+ */
+ public function clearCookie($name, $path = '/', $domain = null)
+ {
+ $this->setCookie(new Cookie($name, null, 1, $path, $domain));
+ }
+
+ /**
+ * Returns the calculated value of the cache-control header.
+ *
+ * This considers several other headers and calculates or modifies the
+ * cache-control header to a sensible, conservative value.
+ *
+ * @return string
+ */
+ protected function computeCacheControlValue()
+ {
+ if (!$this->cacheControl && !$this->has('ETag') && !$this->has('Last-Modified') && !$this->has('Expires')) {
+ return 'no-cache';
+ }
+
+ if (!$this->cacheControl) {
+ // conservative by default
+ return 'private, must-revalidate';
+ }
+
+ $header = $this->getCacheControlHeader();
+ if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) {
+ return $header;
+ }
+
+ // public if s-maxage is defined, private otherwise
+ if (!isset($this->cacheControl['s-maxage'])) {
+ return $header.', private';
+ }
+
+ return $header;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/ServerBag.php b/core/includes/Symfony/Component/HttpFoundation/ServerBag.php
new file mode 100644
index 000000000000..02db3b181907
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/ServerBag.php
@@ -0,0 +1,43 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+/**
+ * ServerBag is a container for HTTP headers from the $_SERVER variable.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Bulat Shakirzyanov <mallluhuct@gmail.com>
+ */
+class ServerBag extends ParameterBag
+{
+ public function getHeaders()
+ {
+ $headers = array();
+ foreach ($this->parameters as $key => $value) {
+ if ('HTTP_' === substr($key, 0, 5)) {
+ $headers[substr($key, 5)] = $value;
+ }
+ // CONTENT_* are not prefixed with HTTP_
+ elseif (in_array($key, array('CONTENT_LENGTH', 'CONTENT_MD5', 'CONTENT_TYPE'))) {
+ $headers[$key] = $this->parameters[$key];
+ }
+ }
+
+ // PHP_AUTH_USER/PHP_AUTH_PW
+ if (isset($this->parameters['PHP_AUTH_USER'])) {
+ $pass = isset($this->parameters['PHP_AUTH_PW']) ? $this->parameters['PHP_AUTH_PW'] : '';
+ $headers['AUTHORIZATION'] = 'Basic '.base64_encode($this->parameters['PHP_AUTH_USER'].':'.$pass);
+ }
+
+ return $headers;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/Session.php b/core/includes/Symfony/Component/HttpFoundation/Session.php
new file mode 100644
index 000000000000..a835ef7da5e7
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/Session.php
@@ -0,0 +1,405 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation;
+
+use Symfony\Component\HttpFoundation\SessionStorage\SessionStorageInterface;
+
+/**
+ * Session.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class Session implements \Serializable
+{
+ protected $storage;
+ protected $started;
+ protected $attributes;
+ protected $flashes;
+ protected $oldFlashes;
+ protected $locale;
+ protected $defaultLocale;
+ protected $closed;
+
+ /**
+ * Constructor.
+ *
+ * @param SessionStorageInterface $storage A SessionStorageInterface instance
+ * @param string $defaultLocale The default locale
+ */
+ public function __construct(SessionStorageInterface $storage, $defaultLocale = 'en')
+ {
+ $this->storage = $storage;
+ $this->defaultLocale = $defaultLocale;
+ $this->locale = $defaultLocale;
+ $this->flashes = array();
+ $this->oldFlashes = array();
+ $this->attributes = array();
+ $this->setPhpDefaultLocale($this->defaultLocale);
+ $this->started = false;
+ $this->closed = false;
+ }
+
+ /**
+ * Starts the session storage.
+ *
+ * @api
+ */
+ public function start()
+ {
+ if (true === $this->started) {
+ return;
+ }
+
+ $this->storage->start();
+
+ $attributes = $this->storage->read('_symfony2');
+
+ if (isset($attributes['attributes'])) {
+ $this->attributes = $attributes['attributes'];
+ $this->flashes = $attributes['flashes'];
+ $this->locale = $attributes['locale'];
+ $this->setPhpDefaultLocale($this->locale);
+
+ // flag current flash messages to be removed at shutdown
+ $this->oldFlashes = $this->flashes;
+ }
+
+ $this->started = true;
+ }
+
+ /**
+ * Checks if an attribute is defined.
+ *
+ * @param string $name The attribute name
+ *
+ * @return Boolean true if the attribute is defined, false otherwise
+ *
+ * @api
+ */
+ public function has($name)
+ {
+ return array_key_exists($name, $this->attributes);
+ }
+
+ /**
+ * Returns an attribute.
+ *
+ * @param string $name The attribute name
+ * @param mixed $default The default value
+ *
+ * @return mixed
+ *
+ * @api
+ */
+ public function get($name, $default = null)
+ {
+ return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default;
+ }
+
+ /**
+ * Sets an attribute.
+ *
+ * @param string $name
+ * @param mixed $value
+ *
+ * @api
+ */
+ public function set($name, $value)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->attributes[$name] = $value;
+ }
+
+ /**
+ * Returns attributes.
+ *
+ * @return array Attributes
+ *
+ * @api
+ */
+ public function all()
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Sets attributes.
+ *
+ * @param array $attributes Attributes
+ *
+ * @api
+ */
+ public function replace(array $attributes)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->attributes = $attributes;
+ }
+
+ /**
+ * Removes an attribute.
+ *
+ * @param string $name
+ *
+ * @api
+ */
+ public function remove($name)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ if (array_key_exists($name, $this->attributes)) {
+ unset($this->attributes[$name]);
+ }
+ }
+
+ /**
+ * Clears all attributes.
+ *
+ * @api
+ */
+ public function clear()
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->attributes = array();
+ $this->flashes = array();
+ $this->setPhpDefaultLocale($this->locale = $this->defaultLocale);
+ }
+
+ /**
+ * Invalidates the current session.
+ *
+ * @api
+ */
+ public function invalidate()
+ {
+ $this->clear();
+ $this->storage->regenerate(true);
+ }
+
+ /**
+ * Migrates the current session to a new session id while maintaining all
+ * session attributes.
+ *
+ * @api
+ */
+ public function migrate()
+ {
+ $this->storage->regenerate();
+ }
+
+ /**
+ * Returns the session ID
+ *
+ * @return mixed The session ID
+ *
+ * @api
+ */
+ public function getId()
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ return $this->storage->getId();
+ }
+
+ /**
+ * Returns the locale
+ *
+ * @return string
+ */
+ public function getLocale()
+ {
+ return $this->locale;
+ }
+
+ /**
+ * Sets the locale.
+ *
+ * @param string $locale
+ */
+ public function setLocale($locale)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->setPhpDefaultLocale($this->locale = $locale);
+ }
+
+ /**
+ * Gets the flash messages.
+ *
+ * @return array
+ */
+ public function getFlashes()
+ {
+ return $this->flashes;
+ }
+
+ /**
+ * Sets the flash messages.
+ *
+ * @param array $values
+ */
+ public function setFlashes($values)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->flashes = $values;
+ $this->oldFlashes = array();
+ }
+
+ /**
+ * Gets a flash message.
+ *
+ * @param string $name
+ * @param string|null $default
+ *
+ * @return string
+ */
+ public function getFlash($name, $default = null)
+ {
+ return array_key_exists($name, $this->flashes) ? $this->flashes[$name] : $default;
+ }
+
+ /**
+ * Sets a flash message.
+ *
+ * @param string $name
+ * @param string $value
+ */
+ public function setFlash($name, $value)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->flashes[$name] = $value;
+ unset($this->oldFlashes[$name]);
+ }
+
+ /**
+ * Checks whether a flash message exists.
+ *
+ * @param string $name
+ *
+ * @return Boolean
+ */
+ public function hasFlash($name)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ return array_key_exists($name, $this->flashes);
+ }
+
+ /**
+ * Removes a flash message.
+ *
+ * @param string $name
+ */
+ public function removeFlash($name)
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ unset($this->flashes[$name]);
+ }
+
+ /**
+ * Removes the flash messages.
+ */
+ public function clearFlashes()
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->flashes = array();
+ $this->oldFlashes = array();
+ }
+
+ public function save()
+ {
+ if (false === $this->started) {
+ $this->start();
+ }
+
+ $this->flashes = array_diff_key($this->flashes, $this->oldFlashes);
+
+ $this->storage->write('_symfony2', array(
+ 'attributes' => $this->attributes,
+ 'flashes' => $this->flashes,
+ 'locale' => $this->locale,
+ ));
+ }
+
+ /**
+ * This method should be called when you don't want the session to be saved
+ * when the Session object is garbaged collected (useful for instance when
+ * you want to simulate the interaction of several users/sessions in a single
+ * PHP process).
+ */
+ public function close()
+ {
+ $this->closed = true;
+ }
+
+ public function __destruct()
+ {
+ if (true === $this->started && !$this->closed) {
+ $this->save();
+ }
+ }
+
+ public function serialize()
+ {
+ return serialize(array($this->storage, $this->defaultLocale));
+ }
+
+ public function unserialize($serialized)
+ {
+ list($this->storage, $this->defaultLocale) = unserialize($serialized);
+ $this->attributes = array();
+ $this->started = false;
+ }
+
+ private function setPhpDefaultLocale($locale)
+ {
+ // if either the class Locale doesn't exist, or an exception is thrown when
+ // setting the default locale, the intl module is not installed, and
+ // the call can be ignored:
+ try {
+ if (class_exists('Locale', false)) {
+ \Locale::setDefault($locale);
+ }
+ } catch (\Exception $e) {
+ }
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/SessionStorage/ArraySessionStorage.php b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/ArraySessionStorage.php
new file mode 100644
index 000000000000..62aac40982a3
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/ArraySessionStorage.php
@@ -0,0 +1,58 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\SessionStorage;
+
+/**
+ * ArraySessionStorage mocks the session for unit tests.
+ *
+ * When doing functional testing, you should use FilesystemSessionStorage instead.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Bulat Shakirzyanov <mallluhuct@gmail.com>
+ */
+
+class ArraySessionStorage implements SessionStorageInterface
+{
+ private $data = array();
+
+ public function read($key, $default = null)
+ {
+ return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
+ }
+
+ public function regenerate($destroy = false)
+ {
+ if ($destroy) {
+ $this->data = array();
+ }
+
+ return true;
+ }
+
+ public function remove($key)
+ {
+ unset($this->data[$key]);
+ }
+
+ public function start()
+ {
+ }
+
+ public function getId()
+ {
+ }
+
+ public function write($key, $data)
+ {
+ $this->data[$key] = $data;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/SessionStorage/FilesystemSessionStorage.php b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/FilesystemSessionStorage.php
new file mode 100644
index 000000000000..87abd01bcde2
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/FilesystemSessionStorage.php
@@ -0,0 +1,174 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\SessionStorage;
+
+/**
+ * FilesystemSessionStorage simulates sessions for functional tests.
+ *
+ * This storage does not start the session (session_start())
+ * as it is not "available" when running tests on the command line.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class FilesystemSessionStorage extends NativeSessionStorage
+{
+ private $path;
+ private $data;
+ private $started;
+
+ /**
+ * Constructor.
+ */
+ public function __construct($path, array $options = array())
+ {
+ $this->path = $path;
+ $this->started = false;
+
+ parent::__construct($options);
+ }
+
+ /**
+ * Starts the session.
+ *
+ * @api
+ */
+ public function start()
+ {
+ if ($this->started) {
+ return;
+ }
+
+ session_set_cookie_params(
+ $this->options['lifetime'],
+ $this->options['path'],
+ $this->options['domain'],
+ $this->options['secure'],
+ $this->options['httponly']
+ );
+
+ if (!ini_get('session.use_cookies') && isset($this->options['id']) && $this->options['id'] && $this->options['id'] != session_id()) {
+ session_id($this->options['id']);
+ }
+
+ if (!session_id()) {
+ session_id(hash('md5', uniqid(mt_rand(), true)));
+ }
+
+ $file = $this->path.'/'.session_id().'.session';
+
+ $this->data = file_exists($file) ? unserialize(file_get_contents($file)) : array();
+ $this->started = true;
+ }
+
+ /**
+ * Returns the session ID
+ *
+ * @return mixed The session ID
+ *
+ * @throws \RuntimeException If the session was not started yet
+ *
+ * @api
+ */
+ public function getId()
+ {
+ if (!$this->started) {
+ throw new \RuntimeException('The session must be started before reading its ID');
+ }
+
+ return session_id();
+ }
+
+ /**
+ * Reads data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ *
+ * @return mixed Data associated with the key
+ *
+ * @throws \RuntimeException If an error occurs while reading data from this storage
+ *
+ * @api
+ */
+ public function read($key, $default = null)
+ {
+ return array_key_exists($key, $this->data) ? $this->data[$key] : $default;
+ }
+
+ /**
+ * Removes data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ *
+ * @return mixed Data associated with the key
+ *
+ * @throws \RuntimeException If an error occurs while removing data from this storage
+ *
+ * @api
+ */
+ public function remove($key)
+ {
+ $retval = $this->data[$key];
+
+ unset($this->data[$key]);
+
+ return $retval;
+ }
+
+ /**
+ * Writes data to this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ * @param mixed $data Data associated with your key
+ *
+ * @throws \RuntimeException If an error occurs while writing to this storage
+ *
+ * @api
+ */
+ public function write($key, $data)
+ {
+ $this->data[$key] = $data;
+
+ if (!is_dir($this->path)) {
+ mkdir($this->path, 0777, true);
+ }
+
+ file_put_contents($this->path.'/'.session_id().'.session', serialize($this->data));
+ }
+
+ /**
+ * Regenerates id that represents this storage.
+ *
+ * @param Boolean $destroy Destroy session when regenerating?
+ *
+ * @return Boolean True if session regenerated, false if error
+ *
+ * @throws \RuntimeException If an error occurs while regenerating this storage
+ *
+ * @api
+ */
+ public function regenerate($destroy = false)
+ {
+ if ($destroy) {
+ $this->data = array();
+ }
+
+ return true;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/SessionStorage/NativeSessionStorage.php b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/NativeSessionStorage.php
new file mode 100644
index 000000000000..b759f7411a0a
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/NativeSessionStorage.php
@@ -0,0 +1,180 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\SessionStorage;
+
+/**
+ * NativeSessionStorage.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+class NativeSessionStorage implements SessionStorageInterface
+{
+ static protected $sessionIdRegenerated = false;
+ static protected $sessionStarted = false;
+
+ protected $options;
+
+ /**
+ * Available options:
+ *
+ * * name: The cookie name (null [omitted] by default)
+ * * id: The session id (null [omitted] by default)
+ * * lifetime: Cookie lifetime
+ * * path: Cookie path
+ * * domain: Cookie domain
+ * * secure: Cookie secure
+ * * httponly: Cookie http only
+ *
+ * The default values for most options are those returned by the session_get_cookie_params() function
+ *
+ * @param array $options An associative array of session options
+ */
+ public function __construct(array $options = array())
+ {
+ $cookieDefaults = session_get_cookie_params();
+
+ $this->options = array_merge(array(
+ 'lifetime' => $cookieDefaults['lifetime'],
+ 'path' => $cookieDefaults['path'],
+ 'domain' => $cookieDefaults['domain'],
+ 'secure' => $cookieDefaults['secure'],
+ 'httponly' => isset($cookieDefaults['httponly']) ? $cookieDefaults['httponly'] : false,
+ ), $options);
+
+ // Skip setting new session name if user don't want it
+ if (isset($this->options['name'])) {
+ session_name($this->options['name']);
+ }
+ }
+
+ /**
+ * Starts the session.
+ *
+ * @api
+ */
+ public function start()
+ {
+ if (self::$sessionStarted) {
+ return;
+ }
+
+ session_set_cookie_params(
+ $this->options['lifetime'],
+ $this->options['path'],
+ $this->options['domain'],
+ $this->options['secure'],
+ $this->options['httponly']
+ );
+
+ // disable native cache limiter as this is managed by HeaderBag directly
+ session_cache_limiter(false);
+
+ if (!ini_get('session.use_cookies') && isset($this->options['id']) && $this->options['id'] && $this->options['id'] != session_id()) {
+ session_id($this->options['id']);
+ }
+
+ session_start();
+
+ self::$sessionStarted = true;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @api
+ */
+ public function getId()
+ {
+ if (!self::$sessionStarted) {
+ throw new \RuntimeException('The session must be started before reading its ID');
+ }
+
+ return session_id();
+ }
+
+ /**
+ * Reads data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ * @param string $default Default value
+ *
+ * @return mixed Data associated with the key
+ *
+ * @api
+ */
+ public function read($key, $default = null)
+ {
+ return array_key_exists($key, $_SESSION) ? $_SESSION[$key] : $default;
+ }
+
+ /**
+ * Removes data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ *
+ * @return mixed Data associated with the key
+ *
+ * @api
+ */
+ public function remove($key)
+ {
+ $retval = null;
+
+ if (isset($_SESSION[$key])) {
+ $retval = $_SESSION[$key];
+ unset($_SESSION[$key]);
+ }
+
+ return $retval;
+ }
+
+ /**
+ * Writes data to this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ * @param mixed $data Data associated with your key
+ *
+ * @api
+ */
+ public function write($key, $data)
+ {
+ $_SESSION[$key] = $data;
+ }
+
+ /**
+ * Regenerates id that represents this storage.
+ *
+ * @param Boolean $destroy Destroy session when regenerating?
+ *
+ * @return Boolean True if session regenerated, false if error
+ *
+ * @api
+ */
+ public function regenerate($destroy = false)
+ {
+ if (self::$sessionIdRegenerated) {
+ return;
+ }
+
+ session_regenerate_id($destroy);
+
+ self::$sessionIdRegenerated = true;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/SessionStorage/PdoSessionStorage.php b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/PdoSessionStorage.php
new file mode 100644
index 000000000000..78f90b8ec956
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/PdoSessionStorage.php
@@ -0,0 +1,263 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\SessionStorage;
+
+/**
+ * PdoSessionStorage.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Michael Williams <michael.williams@funsational.com>
+ */
+class PdoSessionStorage extends NativeSessionStorage
+{
+ private $db;
+ private $dbOptions;
+
+ /**
+ * Constructor.
+ *
+ * @param \PDO $db A PDO instance
+ * @param array $options An associative array of session options
+ * @param array $dbOptions An associative array of DB options
+ *
+ * @throws \InvalidArgumentException When "db_table" option is not provided
+ *
+ * @see NativeSessionStorage::__construct()
+ */
+ public function __construct(\PDO $db, array $options = array(), array $dbOptions = array())
+ {
+ if (!array_key_exists('db_table', $dbOptions)) {
+ throw new \InvalidArgumentException('You must provide the "db_table" option for a PdoSessionStorage.');
+ }
+
+ $this->db = $db;
+ $this->dbOptions = array_merge(array(
+ 'db_id_col' => 'sess_id',
+ 'db_data_col' => 'sess_data',
+ 'db_time_col' => 'sess_time',
+ ), $dbOptions);
+
+ parent::__construct($options);
+ }
+
+ /**
+ * Starts the session.
+ */
+ public function start()
+ {
+ if (self::$sessionStarted) {
+ return;
+ }
+
+ // use this object as the session handler
+ session_set_save_handler(
+ array($this, 'sessionOpen'),
+ array($this, 'sessionClose'),
+ array($this, 'sessionRead'),
+ array($this, 'sessionWrite'),
+ array($this, 'sessionDestroy'),
+ array($this, 'sessionGC')
+ );
+
+ parent::start();
+ }
+
+ /**
+ * Opens a session.
+ *
+ * @param string $path (ignored)
+ * @param string $name (ignored)
+ *
+ * @return Boolean true, if the session was opened, otherwise an exception is thrown
+ */
+ public function sessionOpen($path = null, $name = null)
+ {
+ return true;
+ }
+
+ /**
+ * Closes a session.
+ *
+ * @return Boolean true, if the session was closed, otherwise false
+ */
+ public function sessionClose()
+ {
+ // do nothing
+ return true;
+ }
+
+ /**
+ * Destroys a session.
+ *
+ * @param string $id A session ID
+ *
+ * @return Boolean true, if the session was destroyed, otherwise an exception is thrown
+ *
+ * @throws \RuntimeException If the session cannot be destroyed
+ */
+ public function sessionDestroy($id)
+ {
+ // get table/column
+ $dbTable = $this->dbOptions['db_table'];
+ $dbIdCol = $this->dbOptions['db_id_col'];
+
+ // delete the record associated with this id
+ $sql = "DELETE FROM $dbTable WHERE $dbIdCol = :id";
+
+ try {
+ $stmt = $this->db->prepare($sql);
+ $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
+ $stmt->execute();
+ } catch (\PDOException $e) {
+ throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
+ }
+
+ return true;
+ }
+
+ /**
+ * Cleans up old sessions.
+ *
+ * @param int $lifetime The lifetime of a session
+ *
+ * @return Boolean true, if old sessions have been cleaned, otherwise an exception is thrown
+ *
+ * @throws \RuntimeException If any old sessions cannot be cleaned
+ */
+ public function sessionGC($lifetime)
+ {
+ // get table/column
+ $dbTable = $this->dbOptions['db_table'];
+ $dbTimeCol = $this->dbOptions['db_time_col'];
+
+ // delete the record associated with this id
+ $sql = "DELETE FROM $dbTable WHERE $dbTimeCol < (:time - $lifetime)";
+
+ try {
+ $this->db->query($sql);
+ $stmt = $this->db->prepare($sql);
+ $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
+ $stmt->execute();
+ } catch (\PDOException $e) {
+ throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
+ }
+
+ return true;
+ }
+
+ /**
+ * Reads a session.
+ *
+ * @param string $id A session ID
+ *
+ * @return string The session data if the session was read or created, otherwise an exception is thrown
+ *
+ * @throws \RuntimeException If the session cannot be read
+ */
+ public function sessionRead($id)
+ {
+ // get table/columns
+ $dbTable = $this->dbOptions['db_table'];
+ $dbDataCol = $this->dbOptions['db_data_col'];
+ $dbIdCol = $this->dbOptions['db_id_col'];
+
+ try {
+ $sql = "SELECT $dbDataCol FROM $dbTable WHERE $dbIdCol = :id";
+
+ $stmt = $this->db->prepare($sql);
+ $stmt->bindParam(':id', $id, \PDO::PARAM_STR, 255);
+
+ $stmt->execute();
+ // it is recommended to use fetchAll so that PDO can close the DB cursor
+ // we anyway expect either no rows, or one row with one column. fetchColumn, seems to be buggy #4777
+ $sessionRows = $stmt->fetchAll(\PDO::FETCH_NUM);
+
+ if (count($sessionRows) == 1) {
+ return $sessionRows[0][0];
+ }
+
+ // session does not exist, create it
+ $this->createNewSession($id);
+
+ return '';
+ } catch (\PDOException $e) {
+ throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
+ }
+ }
+
+ /**
+ * Writes session data.
+ *
+ * @param string $id A session ID
+ * @param string $data A serialized chunk of session data
+ *
+ * @return Boolean true, if the session was written, otherwise an exception is thrown
+ *
+ * @throws \RuntimeException If the session data cannot be written
+ */
+ public function sessionWrite($id, $data)
+ {
+ // get table/column
+ $dbTable = $this->dbOptions['db_table'];
+ $dbDataCol = $this->dbOptions['db_data_col'];
+ $dbIdCol = $this->dbOptions['db_id_col'];
+ $dbTimeCol = $this->dbOptions['db_time_col'];
+
+ $sql = ('mysql' === $this->db->getAttribute(\PDO::ATTR_DRIVER_NAME))
+ ? "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time) "
+ ."ON DUPLICATE KEY UPDATE $dbDataCol = VALUES($dbDataCol), $dbTimeCol = CASE WHEN $dbTimeCol = :time THEN (VALUES($dbTimeCol) + 1) ELSE VALUES($dbTimeCol) END"
+ : "UPDATE $dbTable SET $dbDataCol = :data, $dbTimeCol = :time WHERE $dbIdCol = :id";
+
+ try {
+ $stmt = $this->db->prepare($sql);
+ $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
+ $stmt->bindParam(':data', $data, \PDO::PARAM_STR);
+ $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
+ $stmt->execute();
+
+ if (!$stmt->rowCount()) {
+ // No session exists in the database to update. This happens when we have called
+ // session_regenerate_id()
+ $this->createNewSession($id, $data);
+ }
+ } catch (\PDOException $e) {
+ throw new \RuntimeException(sprintf('PDOException was thrown when trying to manipulate session data: %s', $e->getMessage()), 0, $e);
+ }
+
+ return true;
+ }
+
+ /**
+ * Creates a new session with the given $id and $data
+ *
+ * @param string $id
+ * @param string $data
+ */
+ private function createNewSession($id, $data = '')
+ {
+ // get table/column
+ $dbTable = $this->dbOptions['db_table'];
+ $dbDataCol = $this->dbOptions['db_data_col'];
+ $dbIdCol = $this->dbOptions['db_id_col'];
+ $dbTimeCol = $this->dbOptions['db_time_col'];
+
+ $sql = "INSERT INTO $dbTable ($dbIdCol, $dbDataCol, $dbTimeCol) VALUES (:id, :data, :time)";
+
+ $stmt = $this->db->prepare($sql);
+ $stmt->bindParam(':id', $id, \PDO::PARAM_STR);
+ $stmt->bindParam(':data', $data, \PDO::PARAM_STR);
+ $stmt->bindValue(':time', time(), \PDO::PARAM_INT);
+ $stmt->execute();
+
+ return true;
+ }
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/SessionStorage/SessionStorageInterface.php b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/SessionStorageInterface.php
new file mode 100644
index 000000000000..b61a2557b27c
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/SessionStorage/SessionStorageInterface.php
@@ -0,0 +1,97 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\HttpFoundation\SessionStorage;
+
+/**
+ * SessionStorageInterface.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ *
+ * @api
+ */
+interface SessionStorageInterface
+{
+ /**
+ * Starts the session.
+ *
+ * @api
+ */
+ function start();
+
+ /**
+ * Returns the session ID
+ *
+ * @return mixed The session ID
+ *
+ * @throws \RuntimeException If the session was not started yet
+ *
+ * @api
+ */
+ function getId();
+
+ /**
+ * Reads data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ *
+ * @return mixed Data associated with the key
+ *
+ * @throws \RuntimeException If an error occurs while reading data from this storage
+ *
+ * @api
+ */
+ function read($key);
+
+ /**
+ * Removes data from this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ *
+ * @return mixed Data associated with the key
+ *
+ * @throws \RuntimeException If an error occurs while removing data from this storage
+ *
+ * @api
+ */
+ function remove($key);
+
+ /**
+ * Writes data to this storage.
+ *
+ * The preferred format for a key is directory style so naming conflicts can be avoided.
+ *
+ * @param string $key A unique key identifying your data
+ * @param mixed $data Data associated with your key
+ *
+ * @throws \RuntimeException If an error occurs while writing to this storage
+ *
+ * @api
+ */
+ function write($key, $data);
+
+ /**
+ * Regenerates id that represents this storage.
+ *
+ * @param Boolean $destroy Destroy session when regenerating?
+ *
+ * @return Boolean True if session regenerated, false if error
+ *
+ * @throws \RuntimeException If an error occurs while regenerating this storage
+ *
+ * @api
+ */
+ function regenerate($destroy = false);
+}
diff --git a/core/includes/Symfony/Component/HttpFoundation/composer.json b/core/includes/Symfony/Component/HttpFoundation/composer.json
new file mode 100644
index 000000000000..b3cfbbd1a728
--- /dev/null
+++ b/core/includes/Symfony/Component/HttpFoundation/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "symfony/http-foundation",
+ "type": "library",
+ "description": "Symfony HttpFoundation Component",
+ "keywords": [],
+ "homepage": "http://symfony.com",
+ "version": "2.0.4",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "http://symfony.com/contributors"
+ }
+ ],
+ "require": {
+ "php": ">=5.3.2"
+ }
+}
diff --git a/core/includes/actions.inc b/core/includes/actions.inc
new file mode 100644
index 000000000000..760de8300b38
--- /dev/null
+++ b/core/includes/actions.inc
@@ -0,0 +1,383 @@
+<?php
+
+/**
+ * @file
+ * This is the actions engine for executing stored actions.
+ */
+
+/**
+ * @defgroup actions Actions
+ * @{
+ * Functions that perform an action on a certain system object.
+ *
+ * Action functions are declared by modules by implementing hook_action_info().
+ * Modules can cause action functions to run by calling actions_do(), and
+ * trigger.module provides a user interface that lets administrators define
+ * events that cause action functions to run.
+ *
+ * Each action function takes two to four arguments:
+ * - $entity: The object that the action acts on, such as a node, comment, or
+ * user.
+ * - $context: Array of additional information about what triggered the action.
+ * - $a1, $a2: Optional additional information, which can be passed into
+ * actions_do() and will be passed along to the action function.
+ *
+ * @} End of "defgroup actions".
+ */
+
+/**
+ * Performs a given list of actions by executing their callback functions.
+ *
+ * Given the IDs of actions to perform, this function finds out what the
+ * callback functions for the actions are by querying the database. Then
+ * it calls each callback using the function call $function($object, $context,
+ * $a1, $a2), passing the input arguments of this function (see below) to the
+ * action function.
+ *
+ * @param $action_ids
+ * The IDs of the actions to perform. Can be a single action ID or an array
+ * of IDs. IDs of configurable actions must be given as numeric action IDs;
+ * IDs of non-configurable actions may be given as action function names.
+ * @param $object
+ * The object that the action will act on: a node, user, or comment object.
+ * @param $context
+ * Associative array containing extra information about what triggered
+ * the action call, with $context['hook'] giving the name of the hook
+ * that resulted in this call to actions_do().
+ * @param $a1
+ * Passed along to the callback.
+ * @param $a2
+ * Passed along to the callback.
+ * @return
+ * An associative array containing the results of the functions that
+ * perform the actions, keyed on action ID.
+ *
+ * @ingroup actions
+ */
+function actions_do($action_ids, $object = NULL, $context = NULL, $a1 = NULL, $a2 = NULL) {
+ // $stack tracks the number of recursive calls.
+ static $stack;
+ $stack++;
+ if ($stack > variable_get('actions_max_stack', 35)) {
+ watchdog('actions', 'Stack overflow: too many calls to actions_do(). Aborting to prevent infinite recursion.', array(), WATCHDOG_ERROR);
+ return;
+ }
+ $actions = array();
+ $available_actions = actions_list();
+ $actions_result = array();
+ if (is_array($action_ids)) {
+ $conditions = array();
+ foreach ($action_ids as $action_id) {
+ if (is_numeric($action_id)) {
+ $conditions[] = $action_id;
+ }
+ elseif (isset($available_actions[$action_id])) {
+ $actions[$action_id] = $available_actions[$action_id];
+ }
+ }
+
+ // When we have action instances we must go to the database to retrieve
+ // instance data.
+ if (!empty($conditions)) {
+ $query = db_select('actions');
+ $query->addField('actions', 'aid');
+ $query->addField('actions', 'type');
+ $query->addField('actions', 'callback');
+ $query->addField('actions', 'parameters');
+ $query->condition('aid', $conditions, 'IN');
+ $result = $query->execute();
+ foreach ($result as $action) {
+ $actions[$action->aid] = $action->parameters ? unserialize($action->parameters) : array();
+ $actions[$action->aid]['callback'] = $action->callback;
+ $actions[$action->aid]['type'] = $action->type;
+ }
+ }
+
+ // Fire actions, in no particular order.
+ foreach ($actions as $action_id => $params) {
+ // Configurable actions need parameters.
+ if (is_numeric($action_id)) {
+ $function = $params['callback'];
+ if (function_exists($function)) {
+ $context = array_merge($context, $params);
+ $actions_result[$action_id] = $function($object, $context, $a1, $a2);
+ }
+ else {
+ $actions_result[$action_id] = FALSE;
+ }
+ }
+ // Singleton action; $action_id is the function name.
+ else {
+ $actions_result[$action_id] = $action_id($object, $context, $a1, $a2);
+ }
+ }
+ }
+ // Optimized execution of a single action.
+ else {
+ // If it's a configurable action, retrieve stored parameters.
+ if (is_numeric($action_ids)) {
+ $action = db_query("SELECT callback, parameters FROM {actions} WHERE aid = :aid", array(':aid' => $action_ids))->fetchObject();
+ $function = $action->callback;
+ if (function_exists($function)) {
+ $context = array_merge($context, unserialize($action->parameters));
+ $actions_result[$action_ids] = $function($object, $context, $a1, $a2);
+ }
+ else {
+ $actions_result[$action_ids] = FALSE;
+ }
+ }
+ // Singleton action; $action_ids is the function name.
+ else {
+ if (function_exists($action_ids)) {
+ $actions_result[$action_ids] = $action_ids($object, $context, $a1, $a2);
+ }
+ else {
+ // Set to avoid undefined index error messages later.
+ $actions_result[$action_ids] = FALSE;
+ }
+ }
+ }
+ $stack--;
+ return $actions_result;
+}
+
+/**
+ * Discovers all available actions by invoking hook_action_info().
+ *
+ * This function contrasts with actions_get_all_actions(); see the
+ * documentation of actions_get_all_actions() for an explanation.
+ *
+ * @param $reset
+ * Reset the action info static cache.
+ * @return
+ * An associative array keyed on action function name, with the same format
+ * as the return value of hook_action_info(), containing all
+ * modules' hook_action_info() return values as modified by any
+ * hook_action_info_alter() implementations.
+ *
+ * @see hook_action_info()
+ */
+function actions_list($reset = FALSE) {
+ $actions = &drupal_static(__FUNCTION__);
+ if (!isset($actions) || $reset) {
+ $actions = module_invoke_all('action_info');
+ drupal_alter('action_info', $actions);
+ }
+
+ // See module_implements() for an explanation of this cast.
+ return (array) $actions;
+}
+
+/**
+ * Retrieves all action instances from the database.
+ *
+ * This function differs from the actions_list() function, which gathers
+ * actions by invoking hook_action_info(). The actions returned by this
+ * function and the actions returned by actions_list() are partially
+ * synchronized. Non-configurable actions from hook_action_info()
+ * implementations are put into the database when actions_synchronize() is
+ * called, which happens when admin/config/system/actions is visited. Configurable
+ * actions are not added to the database until they are configured in the
+ * user interface, in which case a database row is created for each
+ * configuration of each action.
+ *
+ * @return
+ * Associative array keyed by numeric action ID. Each value is an associative
+ * array with keys 'callback', 'label', 'type' and 'configurable'.
+ */
+function actions_get_all_actions() {
+ $actions = db_query("SELECT aid, type, callback, parameters, label FROM {actions}")->fetchAllAssoc('aid', PDO::FETCH_ASSOC);
+ foreach ($actions as &$action) {
+ $action['configurable'] = (bool) $action['parameters'];
+ unset($action['parameters']);
+ unset($action['aid']);
+ }
+ return $actions;
+}
+
+/**
+ * Creates an associative array keyed by hashes of function names or IDs.
+ *
+ * Hashes are used to prevent actual function names from going out into HTML
+ * forms and coming back.
+ *
+ * @param $actions
+ * An associative array with function names or action IDs as keys
+ * and associative arrays with keys 'label', 'type', etc. as values.
+ * This is usually the output of actions_list() or actions_get_all_actions().
+ * @return
+ * An associative array whose keys are hashes of the input array keys, and
+ * whose corresponding values are associative arrays with components
+ * 'callback', 'label', 'type', and 'configurable' from the input array.
+ */
+function actions_actions_map($actions) {
+ $actions_map = array();
+ foreach ($actions as $callback => $array) {
+ $key = drupal_hash_base64($callback);
+ $actions_map[$key]['callback'] = isset($array['callback']) ? $array['callback'] : $callback;
+ $actions_map[$key]['label'] = $array['label'];
+ $actions_map[$key]['type'] = $array['type'];
+ $actions_map[$key]['configurable'] = $array['configurable'];
+ }
+ return $actions_map;
+}
+
+/**
+ * Given a hash of an action array key, returns the key (function or ID).
+ *
+ * Faster than actions_actions_map() when you only need the function name or ID.
+ *
+ * @param $hash
+ * Hash of a function name or action ID array key. The array key
+ * is a key into the return value of actions_list() (array key is the action
+ * function name) or actions_get_all_actions() (array key is the action ID).
+ * @return
+ * The corresponding array key, or FALSE if no match is found.
+ */
+function actions_function_lookup($hash) {
+ // Check for a function name match.
+ $actions_list = actions_list();
+ foreach ($actions_list as $function => $array) {
+ if (drupal_hash_base64($function) == $hash) {
+ return $function;
+ }
+ }
+ $aid = FALSE;
+ // Must be a configurable action; check database.
+ $result = db_query("SELECT aid FROM {actions} WHERE parameters <> ''")->fetchAll(PDO::FETCH_ASSOC);
+ foreach ($result as $row) {
+ if (drupal_hash_base64($row['aid']) == $hash) {
+ $aid = $row['aid'];
+ break;
+ }
+ }
+ return $aid;
+}
+
+/**
+ * Synchronizes actions that are provided by modules in hook_action_info().
+ *
+ * Actions provided by modules in hook_action_info() implementations are
+ * synchronized with actions that are stored in the actions database table.
+ * This is necessary so that actions that do not require configuration can
+ * receive action IDs.
+ *
+ * @param $delete_orphans
+ * If TRUE, any actions that exist in the database but are no longer
+ * found in the code (for example, because the module that provides them has
+ * been disabled) will be deleted.
+ */
+function actions_synchronize($delete_orphans = FALSE) {
+ $actions_in_code = actions_list(TRUE);
+ $actions_in_db = db_query("SELECT aid, callback, label FROM {actions} WHERE parameters = ''")->fetchAllAssoc('callback', PDO::FETCH_ASSOC);
+
+ // Go through all the actions provided by modules.
+ foreach ($actions_in_code as $callback => $array) {
+ // Ignore configurable actions since their instances get put in when the
+ // user adds the action.
+ if (!$array['configurable']) {
+ // If we already have an action ID for this action, no need to assign aid.
+ if (isset($actions_in_db[$callback])) {
+ unset($actions_in_db[$callback]);
+ }
+ else {
+ // This is a new singleton that we don't have an aid for; assign one.
+ db_insert('actions')
+ ->fields(array(
+ 'aid' => $callback,
+ 'type' => $array['type'],
+ 'callback' => $callback,
+ 'parameters' => '',
+ 'label' => $array['label'],
+ ))
+ ->execute();
+ watchdog('actions', "Action '%action' added.", array('%action' => $array['label']));
+ }
+ }
+ }
+
+ // Any actions that we have left in $actions_in_db are orphaned.
+ if ($actions_in_db) {
+ $orphaned = array_keys($actions_in_db);
+
+ if ($delete_orphans) {
+ $actions = db_query('SELECT aid, label FROM {actions} WHERE callback IN (:orphaned)', array(':orphaned' => $orphaned))->fetchAll();
+ foreach ($actions as $action) {
+ actions_delete($action->aid);
+ watchdog('actions', "Removed orphaned action '%action' from database.", array('%action' => $action->label));
+ }
+ }
+ else {
+ $link = l(t('Remove orphaned actions'), 'admin/config/system/actions/orphan');
+ $count = count($actions_in_db);
+ $orphans = implode(', ', $orphaned);
+ watchdog('actions', '@count orphaned actions (%orphans) exist in the actions table. !link', array('@count' => $count, '%orphans' => $orphans, '!link' => $link), WATCHDOG_INFO);
+ }
+ }
+}
+
+/**
+ * Saves an action and its user-supplied parameter values to the database.
+ *
+ * @param $function
+ * The name of the function to be called when this action is performed.
+ * @param $type
+ * The type of action, to describe grouping and/or context, e.g., 'node',
+ * 'user', 'comment', or 'system'.
+ * @param $params
+ * An associative array with parameter names as keys and parameter values as
+ * values.
+ * @param $label
+ * A user-supplied label of this particular action, e.g., 'Send e-mail
+ * to Jim'.
+ * @param $aid
+ * The ID of this action. If omitted, a new action is created.
+ * @return
+ * The ID of the action.
+ */
+function actions_save($function, $type, $params, $label, $aid = NULL) {
+ // aid is the callback for singleton actions so we need to keep a separate
+ // table for numeric aids.
+ if (!$aid) {
+ $aid = db_next_id();
+ }
+
+ db_merge('actions')
+ ->key(array('aid' => $aid))
+ ->fields(array(
+ 'callback' => $function,
+ 'type' => $type,
+ 'parameters' => serialize($params),
+ 'label' => $label,
+ ))
+ ->execute();
+
+ watchdog('actions', 'Action %action saved.', array('%action' => $label));
+ return $aid;
+}
+
+/**
+ * Retrieves a single action from the database.
+ *
+ * @param $aid
+ * The ID of the action to retrieve.
+ * @return
+ * The appropriate action row from the database as an object.
+ */
+function actions_load($aid) {
+ return db_query("SELECT aid, type, callback, parameters, label FROM {actions} WHERE aid = :aid", array(':aid' => $aid))->fetchObject();
+}
+
+/**
+ * Deletes a single action from the database.
+ *
+ * @param $aid
+ * The ID of the action to delete.
+ */
+function actions_delete($aid) {
+ db_delete('actions')
+ ->condition('aid', $aid)
+ ->execute();
+ module_invoke_all('actions_delete', $aid);
+}
+
diff --git a/core/includes/ajax.inc b/core/includes/ajax.inc
new file mode 100644
index 000000000000..cda55b424663
--- /dev/null
+++ b/core/includes/ajax.inc
@@ -0,0 +1,1206 @@
+<?php
+
+/**
+ * @file
+ * Functions for use with Drupal's Ajax framework.
+ */
+
+/**
+ * @defgroup ajax Ajax framework
+ * @{
+ * Functions for Drupal's Ajax framework.
+ *
+ * Drupal's Ajax framework is used to dynamically update parts of a page's HTML
+ * based on data from the server. Upon a specified event, such as a button
+ * click, a callback function is triggered which performs server-side logic and
+ * may return updated markup, which is then replaced on-the-fly with no page
+ * refresh necessary.
+ *
+ * This framework creates a PHP macro language that allows the server to
+ * instruct JavaScript to perform actions on the client browser. When using
+ * forms, it can be used with the #ajax property.
+ * The #ajax property can be used to bind events to the Ajax framework. By
+ * default, #ajax uses 'system/ajax' as its path for submission and thus calls
+ * ajax_form_callback() and a defined #ajax['callback'] function.
+ * However, you may optionally specify a different path to request or a
+ * different callback function to invoke, which can return updated HTML or can
+ * also return a richer set of @link ajax_commands Ajax framework commands @endlink.
+ *
+ * Standard form handling is as follows:
+ * - A form element has a #ajax property that includes #ajax['callback'] and
+ * omits #ajax['path']. See below about using #ajax['path'] to implement
+ * advanced use-cases that require something other than standard form
+ * handling.
+ * - On the specified element, Ajax processing is triggered by a change to
+ * that element.
+ * - The browser submits an HTTP POST request to the 'system/ajax' Drupal
+ * path.
+ * - The menu page callback for 'system/ajax', ajax_form_callback(), calls
+ * drupal_process_form() to process the form submission and rebuild the
+ * form if necessary. The form is processed in much the same way as if it
+ * were submitted without Ajax, with the same #process functions and
+ * validation and submission handlers called in either case, making it easy
+ * to create Ajax-enabled forms that degrade gracefully when JavaScript is
+ * disabled.
+ * - After form processing is complete, ajax_form_callback() calls the
+ * function named by #ajax['callback'], which returns the form element that
+ * has been updated and needs to be returned to the browser, or
+ * alternatively, an array of custom Ajax commands.
+ * - The page delivery callback for 'system/ajax', ajax_deliver(), renders the
+ * element returned by #ajax['callback'], and returns the JSON string
+ * created by ajax_render() to the browser.
+ * - The browser unserializes the returned JSON string into an array of
+ * command objects and executes each command, resulting in the old page
+ * content within and including the HTML element specified by
+ * #ajax['wrapper'] being replaced by the new content returned by
+ * #ajax['callback'], using a JavaScript animation effect specified by
+ * #ajax['effect'].
+ *
+ * A simple example of basic Ajax use from the
+ * @link http://drupal.org/project/examples Examples module @endlink follows:
+ * @code
+ * function main_page() {
+ * return drupal_get_form('ajax_example_simplest');
+ * }
+ *
+ * function ajax_example_simplest($form, &$form_state) {
+ * $form = array();
+ * $form['changethis'] = array(
+ * '#type' => 'select',
+ * '#options' => array(
+ * 'one' => 'one',
+ * 'two' => 'two',
+ * 'three' => 'three',
+ * ),
+ * '#ajax' => array(
+ * 'callback' => 'ajax_example_simplest_callback',
+ * 'wrapper' => 'replace_textfield_div',
+ * ),
+ * );
+
+ * // This entire form element will be replaced with an updated value.
+ * $form['replace_textfield'] = array(
+ * '#type' => 'textfield',
+ * '#title' => t("The default value will be changed"),
+ * '#description' => t("Say something about why you chose") . "'" .
+ * (!empty($form_state['values']['changethis'])
+ * ? $form_state['values']['changethis'] : t("Not changed yet")) . "'",
+ * '#prefix' => '<div id="replace_textfield_div">',
+ * '#suffix' => '</div>',
+ * );
+ * return $form;
+ * }
+ *
+ * function ajax_example_simplest_callback($form, $form_state) {
+ * // The form has already been submitted and updated. We can return the replaced
+ * // item as it is.
+ * return $form['replace_textfield'];
+ * }
+ * @endcode
+ *
+ * In the above example, the 'changethis' element is Ajax-enabled. The default
+ * #ajax['event'] is 'change', so when the 'changethis' element changes,
+ * an Ajax call is made. The form is submitted and reprocessed, and then the
+ * callback is called. In this case, the form has been automatically
+ * built changing $form['replace_textfield']['#description'], so the callback
+ * just returns that part of the form.
+ *
+ * To implement Ajax handling in a form, add '#ajax' to the form
+ * definition of a field. That field will trigger an Ajax event when it is
+ * clicked (or changed, depending on the kind of field). #ajax supports
+ * the following parameters (either 'path' or 'callback' is required at least):
+ * - #ajax['callback']: The callback to invoke to handle the server side of the
+ * Ajax event, which will receive a $form and $form_state as arguments, and
+ * returns a renderable array (most often a form or form fragment), an HTML
+ * string, or an array of Ajax commands. If returning a renderable array or
+ * a string, the value will replace the original element named in
+ * #ajax['wrapper'], and
+ * theme_status_messages()
+ * will be prepended to that
+ * element. (If the status messages are not wanted, return an array
+ * of Ajax commands instead.)
+ * #ajax['wrapper']. If an array of Ajax commands is returned, it will be
+ * executed by the calling code.
+ * - #ajax['path']: The menu path to use for the request. This is often omitted
+ * and the default is used. This path should map
+ * to a menu page callback that returns data using ajax_render(). Defaults to
+ * 'system/ajax', which invokes ajax_form_callback(), eventually calling
+ * the function named in #ajax['callback']. If you use a custom
+ * path, you must set up the menu entry and handle the entire callback in your
+ * own code.
+ * - #ajax['wrapper']: The CSS ID of the area to be replaced by the content
+ * returned by the #ajax['callback'] function. The content returned from
+ * the callback will replace the entire element named by #ajax['wrapper'].
+ * The wrapper is usually created using #prefix and #suffix properties in the
+ * form. Note that this is the wrapper ID, not a CSS selector. So to replace
+ * the element referred to by the CSS selector #some-selector on the page,
+ * use #ajax['wrapper'] = 'some-selector', not '#some-selector'.
+ * - #ajax['effect']: The jQuery effect to use when placing the new HTML.
+ * Defaults to no effect. Valid options are 'none', 'slide', or 'fade'.
+ * - #ajax['speed']: The effect speed to use. Defaults to 'slow'. May be
+ * 'slow', 'fast' or a number in milliseconds which represents the length
+ * of time the effect should run.
+ * - #ajax['event']: The JavaScript event to respond to. This is normally
+ * selected automatically for the type of form widget being used, and
+ * is only needed if you need to override the default behavior.
+ * - #ajax['prevent']: A JavaScript event to prevent when 'event' is triggered.
+ * Defaults to 'click' for #ajax on #type 'submit', 'button', and
+ * 'image_button'. Multiple events may be specified separated by spaces.
+ * For example, when binding #ajax behaviors to form buttons, pressing the
+ * ENTER key within a textfield triggers the 'click' event of the form's first
+ * submit button. Triggering Ajax in this situation leads to problems, like
+ * breaking autocomplete textfields. Because of that, Ajax behaviors are bound
+ * to the 'mousedown' event on form buttons by default. However, binding to
+ * 'mousedown' rather than 'click' means that it is possible to trigger a
+ * click by pressing the mouse, holding the mouse button down until the Ajax
+ * request is complete and the button is re-enabled, and then releasing the
+ * mouse button. For this case, 'prevent' can be set to 'click', so an
+ * additional event handler is bound to prevent such a click from triggering a
+ * non-Ajax form submission. This also prevents a textfield's ENTER press
+ * triggering a button's non-Ajax form submission behavior.
+ * - #ajax['method']: The jQuery method to use to place the new HTML.
+ * Defaults to 'replaceWith'. May be: 'replaceWith', 'append', 'prepend',
+ * 'before', 'after', or 'html'. See the
+ * @link http://api.jquery.com/category/manipulation/ jQuery manipulators documentation @endlink
+ * for more information on these methods.
+ * - #ajax['progress']: Choose either a throbber or progress bar that is
+ * displayed while awaiting a response from the callback, and add an optional
+ * message. Possible keys: 'type', 'message', 'url', 'interval'.
+ * More information is available in the
+ * @link http://api.drupal.org/api/drupal/developer--topics--forms_api_reference.html/7 Form API Reference @endlink
+ *
+ * In addition to using Form API for doing in-form modification, Ajax may be
+ * enabled by adding classes to buttons and links. By adding the 'use-ajax'
+ * class to a link, the link will be loaded via an Ajax call. When using this
+ * method, the href of the link can contain '/nojs/' as part of the path. When
+ * the Ajax framework makes the request, it will convert this to '/ajax/'.
+ * The server is then able to easily tell if this request was made through an
+ * actual Ajax request or in a degraded state, and respond appropriately.
+ *
+ * Similarly, submit buttons can be given the class 'use-ajax-submit'. The
+ * form will then be submitted via Ajax to the path specified in the #action.
+ * Like the ajax-submit class above, this path will have '/nojs/' replaced with
+ * '/ajax/' so that the submit handler can tell if the form was submitted
+ * in a degraded state or not.
+ *
+ * When responding to Ajax requests, the server should do what it needs to do
+ * for that request, then create a commands array. This commands array will
+ * be converted to a JSON object and returned to the client, which will then
+ * iterate over the array and process it like a macro language.
+ *
+ * Each command item is an associative array which will be converted to a command
+ * object on the JavaScript side. $command_item['command'] is the type of
+ * command, e.g. 'alert' or 'replace', and will correspond to a method in the
+ * Drupal.ajax[command] space. The command array may contain any other data
+ * that the command needs to process, e.g. 'method', 'selector', 'settings', etc.
+ *
+ * Commands are usually created with a couple of helper functions, so they
+ * look like this:
+ * @code
+ * $commands = array();
+ * // Replace the content of '#object-1' on the page with 'some html here'.
+ * $commands[] = ajax_command_replace('#object-1', 'some html here');
+ * // Add a visual "changed" marker to the '#object-1' element.
+ * $commands[] = ajax_command_changed('#object-1');
+ * // Menu 'page callback' and #ajax['callback'] functions are supposed to
+ * // return render arrays. If returning an Ajax commands array, it must be
+ * // encapsulated in a render array structure.
+ * return array('#type' => 'ajax', '#commands' => $commands);
+ * @endcode
+ *
+ * When returning an Ajax command array, it is often useful to have
+ * status messages rendered along with other tasks in the command array.
+ * In that case the the Ajax commands array may be constructed like this:
+ * @code
+ * $commands = array();
+ * $commands[] = ajax_command_replace(NULL, $output);
+ * $commands[] = ajax_command_prepend(NULL, theme('status_messages'));
+ * return array('#type' => 'ajax', '#commands' => $commands);
+ * @endcode
+ *
+ * See @link ajax_commands Ajax framework commands @endlink
+ */
+
+/**
+ * Render a commands array into JSON.
+ *
+ * @param $commands
+ * A list of macro commands generated by the use of ajax_command_*()
+ * functions.
+ */
+function ajax_render($commands = array()) {
+ // Ajax responses aren't rendered with html.tpl.php, so we have to call
+ // drupal_get_css() and drupal_get_js() here, in order to have new files added
+ // during this request to be loaded by the page. We only want to send back
+ // files that the page hasn't already loaded, so we implement simple diffing
+ // logic using array_diff_key().
+ foreach (array('css', 'js') as $type) {
+ // It is highly suspicious if $_POST['ajax_page_state'][$type] is empty,
+ // since the base page ought to have at least one JS file and one CSS file
+ // loaded. It probably indicates an error, and rather than making the page
+ // reload all of the files, instead we return no new files.
+ if (empty($_POST['ajax_page_state'][$type])) {
+ $items[$type] = array();
+ }
+ else {
+ $function = 'drupal_add_' . $type;
+ $items[$type] = $function();
+ drupal_alter($type, $items[$type]);
+ // @todo Inline CSS and JS items are indexed numerically. These can't be
+ // reliably diffed with array_diff_key(), since the number can change
+ // due to factors unrelated to the inline content, so for now, we strip
+ // the inline items from Ajax responses, and can add support for them
+ // when drupal_add_css() and drupal_add_js() are changed to using md5()
+ // or some other hash of the inline content.
+ foreach ($items[$type] as $key => $item) {
+ if (is_numeric($key)) {
+ unset($items[$type][$key]);
+ }
+ }
+ // Ensure that the page doesn't reload what it already has.
+ $items[$type] = array_diff_key($items[$type], $_POST['ajax_page_state'][$type]);
+ }
+ }
+
+ // Render the HTML to load these files, and add AJAX commands to insert this
+ // HTML in the page. We pass TRUE as the $skip_alter argument to prevent the
+ // data from being altered again, as we already altered it above. Settings are
+ // handled separately, afterwards.
+ if (isset($items['js']['settings'])) {
+ unset($items['js']['settings']);
+ }
+ $styles = drupal_get_css($items['css'], TRUE);
+ $scripts_footer = drupal_get_js('footer', $items['js'], TRUE);
+ $scripts_header = drupal_get_js('header', $items['js'], TRUE);
+
+ $extra_commands = array();
+ if (!empty($styles)) {
+ $extra_commands[] = ajax_command_prepend('head', $styles);
+ }
+ if (!empty($scripts_header)) {
+ $extra_commands[] = ajax_command_prepend('head', $scripts_header);
+ }
+ if (!empty($scripts_footer)) {
+ $extra_commands[] = ajax_command_append('body', $scripts_footer);
+ }
+ if (!empty($extra_commands)) {
+ $commands = array_merge($extra_commands, $commands);
+ }
+
+ // Now add a command to merge changes and additions to Drupal.settings.
+ $scripts = drupal_add_js();
+ if (!empty($scripts['settings'])) {
+ $settings = $scripts['settings'];
+ array_unshift($commands, ajax_command_settings(call_user_func_array('array_merge_recursive', $settings['data']), TRUE));
+ }
+
+ // Allow modules to alter any Ajax response.
+ drupal_alter('ajax_render', $commands);
+
+ return drupal_json_encode($commands);
+}
+
+/**
+ * Get a form submitted via #ajax during an Ajax callback.
+ *
+ * This will load a form from the form cache used during Ajax operations. It
+ * pulls the form info from $_POST.
+ *
+ * @return
+ * An array containing the $form and $form_state. Use the list() function
+ * to break these apart:
+ * @code
+ * list($form, $form_state, $form_id, $form_build_id) = ajax_get_form();
+ * @endcode
+ */
+function ajax_get_form() {
+ $form_state = form_state_defaults();
+
+ $form_build_id = $_POST['form_build_id'];
+
+ // Get the form from the cache.
+ $form = form_get_cache($form_build_id, $form_state);
+ if (!$form) {
+ // If $form cannot be loaded from the cache, the form_build_id in $_POST
+ // must be invalid, which means that someone performed a POST request onto
+ // system/ajax without actually viewing the concerned form in the browser.
+ // This is likely a hacking attempt as it never happens under normal
+ // circumstances, so we just do nothing.
+ watchdog('ajax', 'Invalid form POST data.', array(), WATCHDOG_WARNING);
+ drupal_exit();
+ }
+
+ // Since some of the submit handlers are run, redirects need to be disabled.
+ $form_state['no_redirect'] = TRUE;
+
+ // When a form is rebuilt after Ajax processing, its #build_id and #action
+ // should not change.
+ // @see drupal_rebuild_form()
+ $form_state['rebuild_info']['copy']['#build_id'] = TRUE;
+ $form_state['rebuild_info']['copy']['#action'] = TRUE;
+
+ // The form needs to be processed; prepare for that by setting a few internal
+ // variables.
+ $form_state['input'] = $_POST;
+ $form_id = $form['#form_id'];
+
+ return array($form, $form_state, $form_id, $form_build_id);
+}
+
+/**
+ * Menu callback; handles Ajax requests for the #ajax Form API property.
+ *
+ * This rebuilds the form from cache and invokes the defined #ajax['callback']
+ * to return an Ajax command structure for JavaScript. In case no 'callback' has
+ * been defined, nothing will happen.
+ *
+ * The Form API #ajax property can be set both for buttons and other input
+ * elements.
+ *
+ * This function is also the canonical example of how to implement
+ * #ajax['path']. If processing is required that cannot be accomplished with
+ * a callback, re-implement this function and set #ajax['path'] to the
+ * enhanced function.
+ */
+function ajax_form_callback() {
+ list($form, $form_state) = ajax_get_form();
+ drupal_process_form($form['#form_id'], $form, $form_state);
+
+ // We need to return the part of the form (or some other content) that needs
+ // to be re-rendered so the browser can update the page with changed content.
+ // Since this is the generic menu callback used by many Ajax elements, it is
+ // up to the #ajax['callback'] function of the element (may or may not be a
+ // button) that triggered the Ajax request to determine what needs to be
+ // rendered.
+ if (!empty($form_state['triggering_element'])) {
+ $callback = $form_state['triggering_element']['#ajax']['callback'];
+ }
+ if (!empty($callback) && function_exists($callback)) {
+ return $callback($form, $form_state);
+ }
+}
+
+/**
+ * Theme callback for Ajax requests.
+ *
+ * Many different pages can invoke an Ajax request to system/ajax or another
+ * generic Ajax path. It is almost always desired for an Ajax response to be
+ * rendered using the same theme as the base page, because most themes are built
+ * with the assumption that they control the entire page, so if the CSS for two
+ * themes are both loaded for a given page, they may conflict with each other.
+ * For example, Bartik is Drupal's default theme, and Seven is Drupal's default
+ * administration theme. Depending on whether the "Use the administration theme
+ * when editing or creating content" checkbox is checked, the node edit form may
+ * be displayed in either theme, but the Ajax response to the Field module's
+ * "Add another item" button should be rendered using the same theme as the rest
+ * of the page. Therefore, system_menu() sets the 'theme callback' for
+ * 'system/ajax' to this function, and it is recommended that modules
+ * implementing other generic Ajax paths do the same.
+ */
+function ajax_base_page_theme() {
+ if (!empty($_POST['ajax_page_state']['theme']) && !empty($_POST['ajax_page_state']['theme_token'])) {
+ $theme = $_POST['ajax_page_state']['theme'];
+ $token = $_POST['ajax_page_state']['theme_token'];
+
+ // Prevent a request forgery from giving a person access to a theme they
+ // shouldn't be otherwise allowed to see. However, since everyone is allowed
+ // to see the default theme, token validation isn't required for that, and
+ // bypassing it allows most use-cases to work even when accessed from the
+ // page cache.
+ if ($theme === variable_get('theme_default', 'bartik') || drupal_valid_token($token, $theme)) {
+ return $theme;
+ }
+ }
+}
+
+/**
+ * Package and send the result of a page callback to the browser as an Ajax response.
+ *
+ * This function is the equivalent of drupal_deliver_html_page(), but for Ajax
+ * requests. Like that function, it:
+ * - Adds needed HTTP headers.
+ * - Prints rendered output.
+ * - Performs end-of-request tasks.
+ *
+ * @param $page_callback_result
+ * The result of a page callback. Can be one of:
+ * - NULL: to indicate no content.
+ * - An integer menu status constant: to indicate an error condition.
+ * - A string of HTML content.
+ * - A renderable array of content.
+ *
+ * @see drupal_deliver_html_page()
+ */
+function ajax_deliver($page_callback_result) {
+ // Browsers do not allow JavaScript to read the contents of a user's local
+ // files. To work around that, the jQuery Form plugin submits forms containing
+ // a file input element to an IFRAME, instead of using XHR. Browsers do not
+ // normally expect JSON strings as content within an IFRAME, so the response
+ // must be customized accordingly.
+ // @see http://malsup.com/jquery/form/#file-upload
+ // @see Drupal.ajax.prototype.beforeSend()
+ $iframe_upload = !empty($_POST['ajax_iframe_upload']);
+
+ // Emit a Content-Type HTTP header if none has been added by the page callback
+ // or by a wrapping delivery callback.
+ if (is_null(drupal_get_http_header('Content-Type'))) {
+ if (!$iframe_upload) {
+ // Standard JSON can be returned to a browser's XHR object, and to
+ // non-browser user agents.
+ // @see http://www.ietf.org/rfc/rfc4627.txt?number=4627
+ drupal_add_http_header('Content-Type', 'application/json; charset=utf-8');
+ }
+ else {
+ // Browser IFRAMEs expect HTML. With most other content types, Internet
+ // Explorer presents the user with a download prompt.
+ drupal_add_http_header('Content-Type', 'text/html; charset=utf-8');
+ }
+ }
+
+ // Print the response.
+ $commands = ajax_prepare_response($page_callback_result);
+ $json = ajax_render($commands);
+ if (!$iframe_upload) {
+ // Standard JSON can be returned to a browser's XHR object, and to
+ // non-browser user agents.
+ print $json;
+ }
+ else {
+ // Browser IFRAMEs expect HTML. Browser extensions, such as Linkification
+ // and Skype's Browser Highlighter, convert URLs, phone numbers, etc. into
+ // links. This corrupts the JSON response. Protect the integrity of the
+ // JSON data by making it the value of a textarea.
+ // @see http://malsup.com/jquery/form/#file-upload
+ // @see http://drupal.org/node/1009382
+ print '<textarea>' . $json . '</textarea>';
+ }
+
+ // Perform end-of-request tasks.
+ ajax_footer();
+}
+
+/**
+ * Converts the return value of a page callback into an Ajax commands array.
+ *
+ * @param $page_callback_result
+ * The result of a page callback. Can be one of:
+ * - NULL: to indicate no content.
+ * - An integer menu status constant: to indicate an error condition.
+ * - A string of HTML content.
+ * - A renderable array of content.
+ *
+ * @return
+ * An Ajax commands array that can be passed to ajax_render().
+ */
+function ajax_prepare_response($page_callback_result) {
+ $commands = array();
+ if (!isset($page_callback_result)) {
+ // Simply delivering an empty commands array is sufficient. This results
+ // in the Ajax request being completed, but nothing being done to the page.
+ }
+ elseif (is_int($page_callback_result)) {
+ switch ($page_callback_result) {
+ case MENU_NOT_FOUND:
+ $commands[] = ajax_command_alert(t('The requested page could not be found.'));
+ break;
+
+ case MENU_ACCESS_DENIED:
+ $commands[] = ajax_command_alert(t('You are not authorized to access this page.'));
+ break;
+
+ case MENU_SITE_OFFLINE:
+ $commands[] = ajax_command_alert(filter_xss_admin(variable_get('maintenance_mode_message',
+ t('@site is currently under maintenance. We should be back shortly. Thank you for your patience.', array('@site' => variable_get('site_name', 'Drupal'))))));
+ break;
+ }
+ }
+ elseif (is_array($page_callback_result) && isset($page_callback_result['#type']) && ($page_callback_result['#type'] == 'ajax')) {
+ // Complex Ajax callbacks can return a result that contains an error message
+ // or a specific set of commands to send to the browser.
+ $page_callback_result += element_info('ajax');
+ $error = $page_callback_result['#error'];
+ if (isset($error) && $error !== FALSE) {
+ if ((empty($error) || $error === TRUE)) {
+ $error = t('An error occurred while handling the request: The server received invalid input.');
+ }
+ $commands[] = ajax_command_alert($error);
+ }
+ else {
+ $commands = $page_callback_result['#commands'];
+ }
+ }
+ else {
+ // Like normal page callbacks, simple Ajax callbacks can return HTML
+ // content, as a string or render array. This HTML is inserted in some
+ // relationship to #ajax['wrapper'], as determined by which jQuery DOM
+ // manipulation method is used. The method used is specified by
+ // #ajax['method']. The default method is 'replaceWith', which completely
+ // replaces the old wrapper element and its content with the new HTML.
+ $html = is_string($page_callback_result) ? $page_callback_result : drupal_render($page_callback_result);
+ $commands[] = ajax_command_insert(NULL, $html);
+ // Add the status messages inside the new content's wrapper element, so that
+ // on subsequent Ajax requests, it is treated as old content.
+ $commands[] = ajax_command_prepend(NULL, theme('status_messages'));
+ }
+
+ return $commands;
+}
+
+/**
+ * Perform end-of-Ajax-request tasks.
+ *
+ * This function is the equivalent of drupal_page_footer(), but for Ajax
+ * requests.
+ *
+ * @see drupal_page_footer()
+ */
+function ajax_footer() {
+ // Even for Ajax requests, invoke hook_exit() implementations. There may be
+ // modules that need very fast Ajax responses, and therefore, run Ajax
+ // requests with an early bootstrap.
+ if (drupal_get_bootstrap_phase() == DRUPAL_BOOTSTRAP_FULL && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update')) {
+ module_invoke_all('exit');
+ }
+
+ // Commit the user session. See above comment about the possibility of this
+ // function running without session.inc loaded.
+ if (function_exists('drupal_session_commit')) {
+ drupal_session_commit();
+ }
+}
+
+/**
+ * Form element process callback to handle #ajax.
+ *
+ * @param $element
+ * An associative array containing the properties of the element.
+ *
+ * @return
+ * The processed element.
+ *
+ * @see ajax_pre_render_element()
+ */
+function ajax_process_form($element, &$form_state) {
+ $element = ajax_pre_render_element($element);
+ if (!empty($element['#ajax_processed'])) {
+ $form_state['cache'] = TRUE;
+ }
+ return $element;
+}
+
+/**
+ * Add Ajax information about an element to the page to communicate with JavaScript.
+ *
+ * If #ajax['path'] is set on an element, this additional JavaScript is added
+ * to the page header to attach the Ajax behaviors. See ajax.js for more
+ * information.
+ *
+ * @param $element
+ * An associative array containing the properties of the element.
+ * Properties used:
+ * - #ajax['event']
+ * - #ajax['prevent']
+ * - #ajax['path']
+ * - #ajax['options']
+ * - #ajax['wrapper']
+ * - #ajax['parameters']
+ * - #ajax['effect']
+ *
+ * @return
+ * The processed element with the necessary JavaScript attached to it.
+ */
+function ajax_pre_render_element($element) {
+ // Skip already processed elements.
+ if (isset($element['#ajax_processed'])) {
+ return $element;
+ }
+ // Initialize #ajax_processed, so we do not process this element again.
+ $element['#ajax_processed'] = FALSE;
+
+ // Nothing to do if there is neither a callback nor a path.
+ if (!(isset($element['#ajax']['callback']) || isset($element['#ajax']['path']))) {
+ return $element;
+ }
+
+ // Add a reasonable default event handler if none was specified.
+ if (isset($element['#ajax']) && !isset($element['#ajax']['event'])) {
+ switch ($element['#type']) {
+ case 'submit':
+ case 'button':
+ case 'image_button':
+ // Pressing the ENTER key within a textfield triggers the click event of
+ // the form's first submit button. Triggering Ajax in this situation
+ // leads to problems, like breaking autocomplete textfields, so we bind
+ // to mousedown instead of click.
+ // @see http://drupal.org/node/216059
+ $element['#ajax']['event'] = 'mousedown';
+ // Retain keyboard accessibility by setting 'keypress'. This causes
+ // ajax.js to trigger 'event' when SPACE or ENTER are pressed while the
+ // button has focus.
+ $element['#ajax']['keypress'] = TRUE;
+ // Binding to mousedown rather than click means that it is possible to
+ // trigger a click by pressing the mouse, holding the mouse button down
+ // until the Ajax request is complete and the button is re-enabled, and
+ // then releasing the mouse button. Set 'prevent' so that ajax.js binds
+ // an additional handler to prevent such a click from triggering a
+ // non-Ajax form submission. This also prevents a textfield's ENTER
+ // press triggering this button's non-Ajax form submission behavior.
+ if (!isset($element['#ajax']['prevent'])) {
+ $element['#ajax']['prevent'] = 'click';
+ }
+ break;
+
+ case 'password':
+ case 'textfield':
+ case 'textarea':
+ $element['#ajax']['event'] = 'blur';
+ break;
+
+ case 'radio':
+ case 'checkbox':
+ case 'select':
+ $element['#ajax']['event'] = 'change';
+ break;
+
+ case 'link':
+ $element['#ajax']['event'] = 'click';
+ break;
+
+ default:
+ return $element;
+ }
+ }
+
+ // Attach JavaScript settings to the element.
+ if (isset($element['#ajax']['event'])) {
+ $element['#attached']['library'][] = array('system', 'jquery.form');
+ $element['#attached']['library'][] = array('system', 'drupal.ajax');
+
+ $settings = $element['#ajax'];
+
+ // Assign default settings.
+ $settings += array(
+ 'path' => 'system/ajax',
+ 'options' => array(),
+ );
+
+ // @todo Legacy support. Remove in Drupal 8.
+ if (isset($settings['method']) && $settings['method'] == 'replace') {
+ $settings['method'] = 'replaceWith';
+ }
+
+ // Change path to URL.
+ $settings['url'] = url($settings['path'], $settings['options']);
+ unset($settings['path'], $settings['options']);
+
+ // Add special data to $settings['submit'] so that when this element
+ // triggers an Ajax submission, Drupal's form processing can determine which
+ // element triggered it.
+ // @see _form_element_triggered_scripted_submission()
+ if (isset($settings['trigger_as'])) {
+ // An element can add a 'trigger_as' key within #ajax to make the element
+ // submit as though another one (for example, a non-button can use this
+ // to submit the form as though a button were clicked). When using this,
+ // the 'name' key is always required to identify the element to trigger
+ // as. The 'value' key is optional, and only needed when multiple elements
+ // share the same name, which is commonly the case for buttons.
+ $settings['submit']['_triggering_element_name'] = $settings['trigger_as']['name'];
+ if (isset($settings['trigger_as']['value'])) {
+ $settings['submit']['_triggering_element_value'] = $settings['trigger_as']['value'];
+ }
+ unset($settings['trigger_as']);
+ }
+ elseif (isset($element['#name'])) {
+ // Most of the time, elements can submit as themselves, in which case the
+ // 'trigger_as' key isn't needed, and the element's name is used.
+ $settings['submit']['_triggering_element_name'] = $element['#name'];
+ // If the element is a (non-image) button, its name may not identify it
+ // uniquely, in which case a match on value is also needed.
+ // @see _form_button_was_clicked()
+ if (isset($element['#button_type']) && empty($element['#has_garbage_value'])) {
+ $settings['submit']['_triggering_element_value'] = $element['#value'];
+ }
+ }
+
+ // Convert a simple #ajax['progress'] string into an array.
+ if (isset($settings['progress']) && is_string($settings['progress'])) {
+ $settings['progress'] = array('type' => $settings['progress']);
+ }
+ // Change progress path to a full URL.
+ if (isset($settings['progress']['path'])) {
+ $settings['progress']['url'] = url($settings['progress']['path']);
+ unset($settings['progress']['path']);
+ }
+
+ $element['#attached']['js'][] = array(
+ 'type' => 'setting',
+ 'data' => array('ajax' => array($element['#id'] => $settings)),
+ );
+
+ // Indicate that Ajax processing was successful.
+ $element['#ajax_processed'] = TRUE;
+ }
+ return $element;
+}
+
+/**
+ * @} End of "defgroup ajax".
+ */
+
+/**
+ * @defgroup ajax_commands Ajax framework commands
+ * @{
+ * Functions to create various Ajax commands.
+ *
+ * These functions can be used to create arrays for use with the
+ * ajax_render() function.
+ */
+
+/**
+ * Creates a Drupal Ajax 'alert' command.
+ *
+ * The 'alert' command instructs the client to display a JavaScript alert
+ * dialog box.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.alert()
+ * defined in misc/ajax.js.
+ *
+ * @param $text
+ * The message string to display to the user.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_alert($text) {
+ return array(
+ 'command' => 'alert',
+ 'text' => $text,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert' command using the method in #ajax['method'].
+ *
+ * This command instructs the client to insert the given HTML using whichever
+ * jQuery DOM manipulation method has been specified in the #ajax['method']
+ * variable of the element that triggered the request.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_insert($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => NULL,
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/replaceWith' command.
+ *
+ * The 'insert/replaceWith' command instructs the client to use jQuery's
+ * replaceWith() method to replace each element matched matched by the given
+ * selector with the given HTML.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery replaceWith() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * See @link http://docs.jquery.com/Manipulation/replaceWith#content jQuery replaceWith command @endlink
+ */
+function ajax_command_replace($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'replaceWith',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/html' command.
+ *
+ * The 'insert/html' command instructs the client to use jQuery's html()
+ * method to set the HTML content of each element matched by the given
+ * selector while leaving the outer tags intact.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery html() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Attributes/html#val
+ */
+function ajax_command_html($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'html',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/prepend' command.
+ *
+ * The 'insert/prepend' command instructs the client to use jQuery's prepend()
+ * method to prepend the given HTML content to the inside each element matched
+ * by the given selector.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery prepend() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Manipulation/prepend#content
+ */
+function ajax_command_prepend($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'prepend',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/append' command.
+ *
+ * The 'insert/append' command instructs the client to use jQuery's append()
+ * method to append the given HTML content to the inside of each element matched
+ * by the given selector.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery append() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Manipulation/append#content
+ */
+function ajax_command_append($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'append',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/after' command.
+ *
+ * The 'insert/after' command instructs the client to use jQuery's after()
+ * method to insert the given HTML content after each element matched by
+ * the given selector.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery after() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Manipulation/after#content
+ */
+function ajax_command_after($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'after',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'insert/before' command.
+ *
+ * The 'insert/before' command instructs the client to use jQuery's before()
+ * method to insert the given HTML content before each of elements matched by
+ * the given selector.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.insert()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $html
+ * The data to use with the jQuery before() method.
+ * @param $settings
+ * An optional array of settings that will be used for this command only.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Manipulation/before#content
+ */
+function ajax_command_before($selector, $html, $settings = NULL) {
+ return array(
+ 'command' => 'insert',
+ 'method' => 'before',
+ 'selector' => $selector,
+ 'data' => $html,
+ 'settings' => $settings,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'remove' command.
+ *
+ * The 'remove' command instructs the client to use jQuery's remove() method
+ * to remove each of elements matched by the given selector, and everything
+ * within them.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.remove()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Manipulation/remove#expr
+ */
+function ajax_command_remove($selector) {
+ return array(
+ 'command' => 'remove',
+ 'selector' => $selector,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'changed' command.
+ *
+ * This command instructs the client to mark each of the elements matched by the
+ * given selector as 'ajax-changed'.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.changed()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $asterisk
+ * An optional CSS selector which must be inside $selector. If specified,
+ * an asterisk will be appended to the HTML inside the $asterisk selector.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_changed($selector, $asterisk = '') {
+ return array(
+ 'command' => 'changed',
+ 'selector' => $selector,
+ 'asterisk' => $asterisk,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'css' command.
+ *
+ * The 'css' command will instruct the client to use the jQuery css() method
+ * to apply the CSS arguments to elements matched by the given selector.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.css()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $argument
+ * An array of key/value pairs to set in the CSS for the selector.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/CSS/css#properties
+ */
+function ajax_command_css($selector, $argument) {
+ return array(
+ 'command' => 'css',
+ 'selector' => $selector,
+ 'argument' => $argument,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'settings' command.
+ *
+ * The 'settings' command instructs the client either to use the given array as
+ * the settings for ajax-loaded content or to extend Drupal.settings with the
+ * given array, depending on the value of the $merge parameter.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.settings()
+ * defined in misc/ajax.js.
+ *
+ * @param $argument
+ * An array of key/value pairs to add to the settings. This will be utilized
+ * for all commands after this if they do not include their own settings
+ * array.
+ * @param $merge
+ * Whether or not the passed settings in $argument should be merged into the
+ * global Drupal.settings on the page. By default (FALSE), the settings that
+ * are passed to Drupal.attachBehaviors will not include the global
+ * Drupal.settings.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_settings($argument, $merge = FALSE) {
+ return array(
+ 'command' => 'settings',
+ 'settings' => $argument,
+ 'merge' => $merge,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'data' command.
+ *
+ * The 'data' command instructs the client to attach the name=value pair of
+ * data to the selector via jQuery's data cache.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.data()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $name
+ * The name or key (in the key value pair) of the data attached to this
+ * selector.
+ * @param $value
+ * The value of the data. Not just limited to strings can be any format.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ *
+ * @see http://docs.jquery.com/Core/data#namevalue
+ */
+function ajax_command_data($selector, $name, $value) {
+ return array(
+ 'command' => 'data',
+ 'selector' => $selector,
+ 'name' => $name,
+ 'value' => $value,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'invoke' command.
+ *
+ * The 'invoke' command will instruct the client to invoke the given jQuery
+ * method with the supplied arguments on the elements matched by the given
+ * selector. Intended for simple jQuery commands, such as attr(), addClass(),
+ * removeClass(), toggleClass(), etc.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.invoke()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string. If the command is a response to a request from
+ * an #ajax form element then this value can be NULL.
+ * @param $method
+ * The jQuery method to invoke.
+ * @param $arguments
+ * (optional) A list of arguments to the jQuery $method, if any.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_invoke($selector, $method, array $arguments = array()) {
+ return array(
+ 'command' => 'invoke',
+ 'selector' => $selector,
+ 'method' => $method,
+ 'arguments' => $arguments,
+ );
+}
+
+/**
+ * Creates a Drupal Ajax 'restripe' command.
+ *
+ * The 'restripe' command instructs the client to restripe a table. This is
+ * usually used after a table has been modified by a replace or append command.
+ *
+ * This command is implemented by Drupal.ajax.prototype.commands.restripe()
+ * defined in misc/ajax.js.
+ *
+ * @param $selector
+ * A jQuery selector string.
+ *
+ * @return
+ * An array suitable for use with the ajax_render() function.
+ */
+function ajax_command_restripe($selector) {
+ return array(
+ 'command' => 'restripe',
+ 'selector' => $selector,
+ );
+}
+
diff --git a/core/includes/archiver.inc b/core/includes/archiver.inc
new file mode 100644
index 000000000000..fec053be6233
--- /dev/null
+++ b/core/includes/archiver.inc
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * @file
+ * Shared classes and interfaces for the archiver system.
+ */
+
+/**
+ * Common interface for all Archiver classes.
+ */
+interface ArchiverInterface {
+
+ /**
+ * Constructor for a new archiver instance.
+ *
+ * @param $file_path
+ * The full system path of the archive to manipulate. Only local files
+ * are supported. If the file does not yet exist, it will be created if
+ * appropriate.
+ */
+ public function __construct($file_path);
+
+ /**
+ * Add the specified file or directory to the archive.
+ *
+ * @param $file_path
+ * The full system path of the file or directory to add. Only local files
+ * and directories are supported.
+ * @return ArchiverInterface
+ * The called object.
+ */
+ public function add($file_path);
+
+ /**
+ * Remove the specified file from the archive.
+ *
+ * @param $path
+ * The file name relative to the root of the archive to remove.
+ * @return ArchiverInterface
+ * The called object.
+ */
+ public function remove($path);
+
+ /**
+ * Extract multiple files in the archive to the specified path.
+ *
+ * @param $path
+ * A full system path of the directory to which to extract files.
+ * @param $files
+ * Optionally specify a list of files to be extracted. Files are
+ * relative to the root of the archive. If not specified, all files
+ * in the archive will be extracted
+ * @return ArchiverInterface
+ * The called object.
+ */
+ public function extract($path, Array $files = array());
+
+ /**
+ * List all files in the archive.
+ *
+ * @return
+ * An array of file names relative to the root of the archive.
+ */
+ public function listContents();
+}
+
diff --git a/core/includes/authorize.inc b/core/includes/authorize.inc
new file mode 100644
index 000000000000..862992f61317
--- /dev/null
+++ b/core/includes/authorize.inc
@@ -0,0 +1,324 @@
+<?php
+
+/**
+ * @file
+ * Helper functions and form handlers used for the authorize.php script.
+ */
+
+/**
+ * Build the form for choosing a FileTransfer type and supplying credentials.
+ */
+function authorize_filetransfer_form($form, &$form_state) {
+ global $base_url, $is_https;
+ $form = array();
+
+ // If possible, we want to post this form securely via https.
+ $form['#https'] = TRUE;
+
+ // CSS we depend on lives in modules/system/maintenance.css, which is loaded
+ // via the default maintenance theme.
+ $form['#attached']['js'][] = $base_url . '/misc/authorize.js';
+
+ // Get all the available ways to transfer files.
+ if (empty($_SESSION['authorize_filetransfer_info'])) {
+ drupal_set_message(t('Unable to continue, no available methods of file transfer'), 'error');
+ return array();
+ }
+ $available_backends = $_SESSION['authorize_filetransfer_info'];
+
+ if (!$is_https) {
+ $form['information']['https_warning'] = array(
+ '#prefix' => '<div class="messages error">',
+ '#markup' => t('WARNING: You are not using an encrypted connection, so your password will be sent in plain text. <a href="@https-link">Learn more</a>.', array('@https-link' => 'http://drupal.org/https-information')),
+ '#suffix' => '</div>',
+ );
+ }
+
+ // Decide on a default backend.
+ if (isset($form_state['values']['connection_settings']['authorize_filetransfer_default'])) {
+ $authorize_filetransfer_default = $form_state['values']['connection_settings']['authorize_filetransfer_default'];
+ }
+ elseif ($authorize_filetransfer_default = variable_get('authorize_filetransfer_default', NULL));
+ else {
+ $authorize_filetransfer_default = key($available_backends);
+ }
+
+ $form['information']['main_header'] = array(
+ '#prefix' => '<h3>',
+ '#markup' => t('To continue, provide your server connection details'),
+ '#suffix' => '</h3>',
+ );
+
+ $form['connection_settings']['#tree'] = TRUE;
+ $form['connection_settings']['authorize_filetransfer_default'] = array(
+ '#type' => 'select',
+ '#title' => t('Connection method'),
+ '#default_value' => $authorize_filetransfer_default,
+ '#weight' => -10,
+ );
+
+ /*
+ * Here we create two submit buttons. For a JS enabled client, they will
+ * only ever see submit_process. However, if a client doesn't have JS
+ * enabled, they will see submit_connection on the first form (when picking
+ * what filetransfer type to use, and submit_process on the second one (which
+ * leads to the actual operation).
+ */
+ $form['submit_connection'] = array(
+ '#prefix' => "<br style='clear:both'/>",
+ '#name' => 'enter_connection_settings',
+ '#type' => 'submit',
+ '#value' => t('Enter connection settings'),
+ '#weight' => 100,
+ );
+
+ $form['submit_process'] = array(
+ '#name' => 'process_updates',
+ '#type' => 'submit',
+ '#value' => t('Continue'),
+ '#weight' => 100,
+ '#attributes' => array('style' => 'display:none'),
+ );
+
+ // Build a container for each connection type.
+ foreach ($available_backends as $name => $backend) {
+ $form['connection_settings']['authorize_filetransfer_default']['#options'][$name] = $backend['title'];
+ $form['connection_settings'][$name] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array("filetransfer-$name", 'filetransfer')),
+ );
+ // We can't use #prefix on the container itself since then the header won't
+ // be hidden and shown when the containers are being manipulated via JS.
+ $form['connection_settings'][$name]['header'] = array(
+ '#markup' => '<h4>' . t('@backend connection settings', array('@backend' => $backend['title'])) . '</h4>',
+ );
+
+ $form['connection_settings'][$name] += _authorize_filetransfer_connection_settings($name);
+
+ // Start non-JS code.
+ if (isset($form_state['values']['connection_settings']['authorize_filetransfer_default']) && $form_state['values']['connection_settings']['authorize_filetransfer_default'] == $name) {
+
+ // If the user switches from JS to non-JS, Drupal (and Batch API) will
+ // barf. This is a known bug: http://drupal.org/node/229825.
+ setcookie('has_js', '', time() - 3600, '/');
+ unset($_COOKIE['has_js']);
+
+ // Change the submit button to the submit_process one.
+ $form['submit_process']['#attributes'] = array();
+ unset($form['submit_connection']);
+
+ // Activate the proper filetransfer settings form.
+ $form['connection_settings'][$name]['#attributes']['style'] = 'display:block';
+ // Disable the select box.
+ $form['connection_settings']['authorize_filetransfer_default']['#disabled'] = TRUE;
+
+ // Create a button for changing the type of connection.
+ $form['connection_settings']['change_connection_type'] = array(
+ '#name' => 'change_connection_type',
+ '#type' => 'submit',
+ '#value' => t('Change connection type'),
+ '#weight' => -5,
+ '#attributes' => array('class' => array('filetransfer-change-connection-type')),
+ );
+ }
+ // End non-JS code.
+ }
+ return $form;
+}
+
+/**
+ * Generate the Form API array for the settings for a given connection backend.
+ *
+ * @param $backend
+ * The name of the backend (e.g. 'ftp', 'ssh', etc).
+ * @return
+ * Form API array of connection settings for the given backend.
+ *
+ * @see hook_filetransfer_backends()
+ */
+function _authorize_filetransfer_connection_settings($backend) {
+ $defaults = variable_get('authorize_filetransfer_connection_settings_' . $backend, array());
+ $form = array();
+
+ // Create an instance of the file transfer class to get its settings form.
+ $filetransfer = authorize_get_filetransfer($backend);
+ if ($filetransfer) {
+ $form = $filetransfer->getSettingsForm();
+ }
+ // Fill in the defaults based on the saved settings, if any.
+ _authorize_filetransfer_connection_settings_set_defaults($form, NULL, $defaults);
+ return $form;
+}
+
+/**
+ * Recursively fill in the default settings on a file transfer connection form.
+ *
+ * The default settings for the file transfer connection forms are saved in
+ * the database. The settings are stored as a nested array in the case of a
+ * settings form that has fieldsets or otherwise uses a nested structure.
+ * Therefore, to properly add defaults, we need to walk through all the
+ * children form elements and process those defaults recursively.
+ *
+ * @param $element
+ * Reference to the Form API form element we're operating on.
+ * @param $key
+ * The key for our current form element, if any.
+ * @param array $defaults
+ * The default settings for the file transfer backend we're operating on.
+ * @return
+ * Nothing, this function just sets $element['#default_value'] if needed.
+ */
+function _authorize_filetransfer_connection_settings_set_defaults(&$element, $key, array $defaults) {
+ // If we're operating on a form element which isn't a fieldset, and we have
+ // a default setting saved, stash it in #default_value.
+ if (!empty($key) && isset($defaults[$key]) && isset($element['#type']) && $element['#type'] != 'fieldset') {
+ $element['#default_value'] = $defaults[$key];
+ }
+ // Now, we walk through all the child elements, and recursively invoke
+ // ourself on each one. Since the $defaults settings array can be nested
+ // (because of #tree, any values inside fieldsets will be nested), if
+ // there's a subarray of settings for the form key we're currently
+ // processing, pass in that subarray to the recursive call. Otherwise, just
+ // pass on the whole $defaults array.
+ foreach (element_children($element) as $child_key) {
+ _authorize_filetransfer_connection_settings_set_defaults($element[$child_key], $child_key, ((isset($defaults[$key]) && is_array($defaults[$key])) ? $defaults[$key] : $defaults));
+ }
+}
+
+/**
+ * Validate callback for the filetransfer authorization form.
+ *
+ * @see authorize_filetransfer_form()
+ */
+function authorize_filetransfer_form_validate($form, &$form_state) {
+ // Only validate the form if we have collected all of the user input and are
+ // ready to proceed with updating or installing.
+ if ($form_state['triggering_element']['#name'] != 'process_updates') {
+ return;
+ }
+
+ if (isset($form_state['values']['connection_settings'])) {
+ $backend = $form_state['values']['connection_settings']['authorize_filetransfer_default'];
+ $filetransfer = authorize_get_filetransfer($backend, $form_state['values']['connection_settings'][$backend]);
+ try {
+ if (!$filetransfer) {
+ throw new Exception(t('Error, this type of connection protocol (%backend) does not exist.', array('%backend' => $backend)));
+ }
+ $filetransfer->connect();
+ }
+ catch (Exception $e) {
+ // The format of this error message is similar to that used on the
+ // database connection form in the installer.
+ form_set_error('connection_settings', t('Failed to connect to the server. The server reports the following message: !message For more help installing or updating code on your server, see the <a href="@handbook_url">handbook</a>.', array(
+ '!message' => '<p class="error">' . $e->getMessage() . '</p>',
+ '@handbook_url' => 'http://drupal.org/documentation/install/modules-themes',
+ )));
+ }
+ }
+}
+
+/**
+ * Submit callback when a file transfer is being authorized.
+ *
+ * @see authorize_filetransfer_form()
+ */
+function authorize_filetransfer_form_submit($form, &$form_state) {
+ global $base_url;
+ switch ($form_state['triggering_element']['#name']) {
+ case 'process_updates':
+
+ // Save the connection settings to the DB.
+ $filetransfer_backend = $form_state['values']['connection_settings']['authorize_filetransfer_default'];
+
+ // If the database is available then try to save our settings. We have
+ // to make sure it is available since this code could potentially (will
+ // likely) be called during the installation process, before the
+ // database is set up.
+ try {
+ $connection_settings = array();
+ foreach ($form_state['values']['connection_settings'][$filetransfer_backend] as $key => $value) {
+ // We do *not* want to store passwords in the database, unless the
+ // backend explicitly says so via the magic #filetransfer_save form
+ // property. Otherwise, we store everything that's not explicitly
+ // marked with #filetransfer_save set to FALSE.
+ if (!isset($form['connection_settings'][$filetransfer_backend][$key]['#filetransfer_save'])) {
+ if ($form['connection_settings'][$filetransfer_backend][$key]['#type'] != 'password') {
+ $connection_settings[$key] = $value;
+ }
+ }
+ // The attribute is defined, so only save if set to TRUE.
+ elseif ($form['connection_settings'][$filetransfer_backend][$key]['#filetransfer_save']) {
+ $connection_settings[$key] = $value;
+ }
+ }
+ // Set this one as the default authorize method.
+ variable_set('authorize_filetransfer_default', $filetransfer_backend);
+ // Save the connection settings minus the password.
+ variable_set('authorize_filetransfer_connection_settings_' . $filetransfer_backend, $connection_settings);
+
+ $filetransfer = authorize_get_filetransfer($filetransfer_backend, $form_state['values']['connection_settings'][$filetransfer_backend]);
+
+ // Now run the operation.
+ authorize_run_operation($filetransfer);
+ }
+ catch (Exception $e) {
+ // If there is no database available, we don't care and just skip
+ // this part entirely.
+ }
+
+ break;
+
+ case 'enter_connection_settings':
+ $form_state['rebuild'] = TRUE;
+ break;
+
+ case 'change_connection_type':
+ $form_state['rebuild'] = TRUE;
+ unset($form_state['values']['connection_settings']['authorize_filetransfer_default']);
+ break;
+ }
+}
+
+/**
+ * Run the operation specified in $_SESSION['authorize_operation']
+ *
+ * @param $filetransfer
+ * The FileTransfer object to use for running the operation.
+ */
+function authorize_run_operation($filetransfer) {
+ $operation = $_SESSION['authorize_operation'];
+ unset($_SESSION['authorize_operation']);
+
+ if (!empty($operation['page_title'])) {
+ drupal_set_title($operation['page_title']);
+ }
+
+ require_once DRUPAL_ROOT . '/' . $operation['file'];
+ call_user_func_array($operation['callback'], array_merge(array($filetransfer), $operation['arguments']));
+}
+
+/**
+ * Get a FileTransfer class for a specific transfer method and settings.
+ *
+ * @param $backend
+ * The FileTransfer backend to get the class for.
+ * @param $settings
+ * Array of settings for the FileTransfer.
+ * @return
+ * An instantiated FileTransfer object for the requested method and settings,
+ * or FALSE if there was an error finding or instantiating it.
+ */
+function authorize_get_filetransfer($backend, $settings = array()) {
+ $filetransfer = FALSE;
+ if (!empty($_SESSION['authorize_filetransfer_info'][$backend])) {
+ $backend_info = $_SESSION['authorize_filetransfer_info'][$backend];
+ if (!empty($backend_info['file'])) {
+ $file = $backend_info['file path'] . '/' . $backend_info['file'];
+ require_once $file;
+ }
+ if (class_exists($backend_info['class'])) {
+ $filetransfer = $backend_info['class']::factory(DRUPAL_ROOT, $settings);
+ }
+ }
+ return $filetransfer;
+}
diff --git a/core/includes/batch.inc b/core/includes/batch.inc
new file mode 100644
index 000000000000..513a8f9ad0d4
--- /dev/null
+++ b/core/includes/batch.inc
@@ -0,0 +1,534 @@
+<?php
+
+
+/**
+ * @file
+ * Batch processing API for processes to run in multiple HTTP requests.
+ *
+ * Note that batches are usually invoked by form submissions, which is
+ * why the core interaction functions of the batch processing API live in
+ * form.inc.
+ *
+ * @see form.inc
+ * @see batch_set()
+ * @see batch_process()
+ * @see batch_get()
+ */
+
+/**
+ * Loads a batch from the database.
+ *
+ * @param $id
+ * The ID of the batch to load. When a progressive batch is being processed,
+ * the relevant ID is found in $_REQUEST['id'].
+ * @return
+ * An array representing the batch, or FALSE if no batch was found.
+ */
+function batch_load($id) {
+ $batch = db_query("SELECT batch FROM {batch} WHERE bid = :bid AND token = :token", array(
+ ':bid' => $id,
+ ':token' => drupal_get_token($id),
+ ))->fetchField();
+ if ($batch) {
+ return unserialize($batch);
+ }
+ return FALSE;
+}
+
+/**
+ * State-based dispatcher for the batch processing page.
+ *
+ * @see _batch_shutdown()
+ */
+function _batch_page() {
+ $batch = &batch_get();
+
+ if (!isset($_REQUEST['id'])) {
+ return FALSE;
+ }
+
+ // Retrieve the current state of the batch.
+ if (!$batch) {
+ $batch = batch_load($_REQUEST['id']);
+ if (!$batch) {
+ drupal_set_message(t('No active batch.'), 'error');
+ drupal_goto();
+ }
+ }
+
+ // Register database update for the end of processing.
+ drupal_register_shutdown_function('_batch_shutdown');
+
+ // Add batch-specific CSS.
+ foreach ($batch['sets'] as $batch_set) {
+ if (isset($batch_set['css'])) {
+ foreach ($batch_set['css'] as $css) {
+ drupal_add_css($css);
+ }
+ }
+ }
+
+ $op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
+ $output = NULL;
+ switch ($op) {
+ case 'start':
+ $output = _batch_start();
+ break;
+
+ case 'do':
+ // JavaScript-based progress page callback.
+ _batch_do();
+ break;
+
+ case 'do_nojs':
+ // Non-JavaScript-based progress page.
+ $output = _batch_progress_page_nojs();
+ break;
+
+ case 'finished':
+ $output = _batch_finished();
+ break;
+ }
+
+ return $output;
+}
+
+/**
+ * Initialize the batch processing.
+ *
+ * JavaScript-enabled clients are identified by the 'has_js' cookie set in
+ * drupal.js. If no JavaScript-enabled page has been visited during the current
+ * user's browser session, the non-JavaScript version is returned.
+ */
+function _batch_start() {
+ if (isset($_COOKIE['has_js']) && $_COOKIE['has_js']) {
+ return _batch_progress_page_js();
+ }
+ else {
+ return _batch_progress_page_nojs();
+ }
+}
+
+/**
+ * Output a batch processing page with JavaScript support.
+ *
+ * This initializes the batch and error messages. Note that in JavaScript-based
+ * processing, the batch processing page is displayed only once and updated via
+ * AHAH requests, so only the first batch set gets to define the page title.
+ * Titles specified by subsequent batch sets are not displayed.
+ *
+ * @see batch_set()
+ * @see _batch_do()
+ */
+function _batch_progress_page_js() {
+ $batch = batch_get();
+
+ $current_set = _batch_current_set();
+ drupal_set_title($current_set['title'], PASS_THROUGH);
+
+ // Merge required query parameters for batch processing into those provided by
+ // batch_set() or hook_batch_alter().
+ $batch['url_options']['query']['id'] = $batch['id'];
+
+ $js_setting = array(
+ 'batch' => array(
+ 'errorMessage' => $current_set['error_message'] . '<br />' . $batch['error_message'],
+ 'initMessage' => $current_set['init_message'],
+ 'uri' => url($batch['url'], $batch['url_options']),
+ ),
+ );
+ drupal_add_js($js_setting, 'setting');
+ drupal_add_library('system', 'drupal.batch');
+
+ return '<div id="progress"></div>';
+}
+
+/**
+ * Do one execution pass in JavaScript-mode and return progress to the browser.
+ *
+ * @see _batch_progress_page_js()
+ * @see _batch_process()
+ */
+function _batch_do() {
+ // HTTP POST required.
+ if ($_SERVER['REQUEST_METHOD'] != 'POST') {
+ drupal_set_message(t('HTTP POST is required.'), 'error');
+ drupal_set_title(t('Error'));
+ return '';
+ }
+
+ // Perform actual processing.
+ list($percentage, $message) = _batch_process();
+
+ drupal_json_output(array('status' => TRUE, 'percentage' => $percentage, 'message' => $message));
+}
+
+/**
+ * Output a batch processing page without JavaScript support.
+ *
+ * @see _batch_process()
+ */
+function _batch_progress_page_nojs() {
+ $batch = &batch_get();
+
+ $current_set = _batch_current_set();
+ drupal_set_title($current_set['title'], PASS_THROUGH);
+
+ $new_op = 'do_nojs';
+
+ if (!isset($batch['running'])) {
+ // This is the first page so we return some output immediately.
+ $percentage = 0;
+ $message = $current_set['init_message'];
+ $batch['running'] = TRUE;
+ }
+ else {
+ // This is one of the later requests; do some processing first.
+
+ // Error handling: if PHP dies due to a fatal error (e.g. a nonexistent
+ // function), it will output whatever is in the output buffer, followed by
+ // the error message.
+ ob_start();
+ $fallback = $current_set['error_message'] . '<br />' . $batch['error_message'];
+ $fallback = theme('maintenance_page', array('content' => $fallback, 'show_messages' => FALSE));
+
+ // We strip the end of the page using a marker in the template, so any
+ // additional HTML output by PHP shows up inside the page rather than below
+ // it. While this causes invalid HTML, the same would be true if we didn't,
+ // as content is not allowed to appear after </html> anyway.
+ list($fallback) = explode('<!--partial-->', $fallback);
+ print $fallback;
+
+ // Perform actual processing.
+ list($percentage, $message) = _batch_process($batch);
+ if ($percentage == 100) {
+ $new_op = 'finished';
+ }
+
+ // PHP did not die; remove the fallback output.
+ ob_end_clean();
+ }
+
+ // Merge required query parameters for batch processing into those provided by
+ // batch_set() or hook_batch_alter().
+ $batch['url_options']['query']['id'] = $batch['id'];
+ $batch['url_options']['query']['op'] = $new_op;
+
+ $url = url($batch['url'], $batch['url_options']);
+ $element = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'http-equiv' => 'Refresh',
+ 'content' => '0; URL=' . $url,
+ ),
+ );
+ drupal_add_html_head($element, 'batch_progress_meta_refresh');
+
+ return theme('progress_bar', array('percent' => $percentage, 'message' => $message));
+}
+
+/**
+ * Process sets in a batch.
+ *
+ * If the batch was marked for progressive execution (default), this executes as
+ * many operations in batch sets until an execution time of 1 second has been
+ * exceeded. It will continue with the next operation of the same batch set in
+ * the next request.
+ *
+ * @return
+ * An array containing a completion value (in percent) and a status message.
+ */
+function _batch_process() {
+ $batch = &batch_get();
+ $current_set = &_batch_current_set();
+ // Indicate that this batch set needs to be initialized.
+ $set_changed = TRUE;
+
+ // If this batch was marked for progressive execution (e.g. forms submitted by
+ // drupal_form_submit()), initialize a timer to determine whether we need to
+ // proceed with the same batch phase when a processing time of 1 second has
+ // been exceeded.
+ if ($batch['progressive']) {
+ timer_start('batch_processing');
+ }
+
+ if (empty($current_set['start'])) {
+ $current_set['start'] = microtime(TRUE);
+ }
+
+ $queue = _batch_queue($current_set);
+
+ while (!$current_set['success']) {
+ // If this is the first time we iterate this batch set in the current
+ // request, we check if it requires an additional file for functions
+ // definitions.
+ if ($set_changed && isset($current_set['file']) && is_file($current_set['file'])) {
+ include_once DRUPAL_ROOT . '/' . $current_set['file'];
+ }
+
+ $task_message = '';
+ // Assume a single pass operation and set the completion level to 1 by
+ // default.
+ $finished = 1;
+
+ if ($item = $queue->claimItem()) {
+ list($function, $args) = $item->data;
+
+ // Build the 'context' array and execute the function call.
+ $batch_context = array(
+ 'sandbox' => &$current_set['sandbox'],
+ 'results' => &$current_set['results'],
+ 'finished' => &$finished,
+ 'message' => &$task_message,
+ );
+ call_user_func_array($function, array_merge($args, array(&$batch_context)));
+
+ if ($finished >= 1) {
+ // Make sure this step is not counted twice when computing $current.
+ $finished = 0;
+ // Remove the processed operation and clear the sandbox.
+ $queue->deleteItem($item);
+ $current_set['count']--;
+ $current_set['sandbox'] = array();
+ }
+ }
+
+ // When all operations in the current batch set are completed, browse
+ // through the remaining sets, marking them 'successfully processed'
+ // along the way, until we find a set that contains operations.
+ // _batch_next_set() executes form submit handlers stored in 'control'
+ // sets (see form_execute_handlers()), which can in turn add new sets to
+ // the batch.
+ $set_changed = FALSE;
+ $old_set = $current_set;
+ while (empty($current_set['count']) && ($current_set['success'] = TRUE) && _batch_next_set()) {
+ $current_set = &_batch_current_set();
+ $current_set['start'] = microtime(TRUE);
+ $set_changed = TRUE;
+ }
+
+ // At this point, either $current_set contains operations that need to be
+ // processed or all sets have been completed.
+ $queue = _batch_queue($current_set);
+
+ // If we are in progressive mode, break processing after 1 second.
+ if ($batch['progressive'] && timer_read('batch_processing') > 1000) {
+ // Record elapsed wall clock time.
+ $current_set['elapsed'] = round((microtime(TRUE) - $current_set['start']) * 1000, 2);
+ break;
+ }
+ }
+
+ if ($batch['progressive']) {
+ // Gather progress information.
+
+ // Reporting 100% progress will cause the whole batch to be considered
+ // processed. If processing was paused right after moving to a new set,
+ // we have to use the info from the new (unprocessed) set.
+ if ($set_changed && isset($current_set['queue'])) {
+ // Processing will continue with a fresh batch set.
+ $remaining = $current_set['count'];
+ $total = $current_set['total'];
+ $progress_message = $current_set['init_message'];
+ $task_message = '';
+ }
+ else {
+ // Processing will continue with the current batch set.
+ $remaining = $old_set['count'];
+ $total = $old_set['total'];
+ $progress_message = $old_set['progress_message'];
+ }
+
+ // Total progress is the number of operations that have fully run plus the
+ // completion level of the current operation.
+ $current = $total - $remaining + $finished;
+ $percentage = _batch_api_percentage($total, $current);
+ $elapsed = isset($current_set['elapsed']) ? $current_set['elapsed'] : 0;
+ $values = array(
+ '@remaining' => $remaining,
+ '@total' => $total,
+ '@current' => floor($current),
+ '@percentage' => $percentage,
+ '@elapsed' => format_interval($elapsed / 1000),
+ // If possible, estimate remaining processing time.
+ '@estimate' => ($current > 0) ? format_interval(($elapsed * ($total - $current) / $current) / 1000) : '-',
+ );
+ $message = strtr($progress_message, $values);
+ if (!empty($message)) {
+ $message .= '<br />';
+ }
+ if (!empty($task_message)) {
+ $message .= $task_message;
+ }
+
+ return array($percentage, $message);
+ }
+ else {
+ // If we are not in progressive mode, the entire batch has been processed.
+ return _batch_finished();
+ }
+}
+
+/**
+ * Helper function for _batch_process(): returns the formatted percentage.
+ *
+ * @param $total
+ * The total number of operations.
+ * @param $current
+ * The number of the current operation. This may be a floating point number
+ * rather than an integer in the case of a multi-step operation that is not
+ * yet complete; in that case, the fractional part of $current represents the
+ * fraction of the operation that has been completed.
+ * @return
+ * The properly formatted percentage, as a string. We output percentages
+ * using the correct number of decimal places so that we never print "100%"
+ * until we are finished, but we also never print more decimal places than
+ * are meaningful.
+ */
+function _batch_api_percentage($total, $current) {
+ if (!$total || $total == $current) {
+ // If $total doesn't evaluate as true or is equal to the current set, then
+ // we're finished, and we can return "100".
+ $percentage = "100";
+ }
+ else {
+ // We add a new digit at 200, 2000, etc. (since, for example, 199/200
+ // would round up to 100% if we didn't).
+ $decimal_places = max(0, floor(log10($total / 2.0)) - 1);
+ do {
+ // Calculate the percentage to the specified number of decimal places.
+ $percentage = sprintf('%01.' . $decimal_places . 'f', round($current / $total * 100, $decimal_places));
+ // When $current is an integer, the above calculation will always be
+ // correct. However, if $current is a floating point number (in the case
+ // of a multi-step batch operation that is not yet complete), $percentage
+ // may be erroneously rounded up to 100%. To prevent that, we add one
+ // more decimal place and try again.
+ $decimal_places++;
+ } while ($percentage == '100');
+ }
+ return $percentage;
+}
+
+/**
+ * Return the batch set being currently processed.
+ */
+function &_batch_current_set() {
+ $batch = &batch_get();
+ return $batch['sets'][$batch['current_set']];
+}
+
+/**
+ * Retrieve the next set in a batch.
+ *
+ * If there is a subsequent set in this batch, assign it as the new set to
+ * process and execute its form submit handler (if defined), which may add
+ * further sets to this batch.
+ *
+ * @return
+ * TRUE if a subsequent set was found in the batch.
+ */
+function _batch_next_set() {
+ $batch = &batch_get();
+ if (isset($batch['sets'][$batch['current_set'] + 1])) {
+ $batch['current_set']++;
+ $current_set = &_batch_current_set();
+ if (isset($current_set['form_submit']) && ($function = $current_set['form_submit']) && function_exists($function)) {
+ // We use our stored copies of $form and $form_state to account for
+ // possible alterations by previous form submit handlers.
+ $function($batch['form_state']['complete_form'], $batch['form_state']);
+ }
+ return TRUE;
+ }
+}
+
+/**
+ * End the batch processing.
+ *
+ * Call the 'finished' callback of each batch set to allow custom handling of
+ * the results and resolve page redirection.
+ */
+function _batch_finished() {
+ $batch = &batch_get();
+
+ // Execute the 'finished' callbacks for each batch set, if defined.
+ foreach ($batch['sets'] as $batch_set) {
+ if (isset($batch_set['finished'])) {
+ // Check if the set requires an additional file for function definitions.
+ if (isset($batch_set['file']) && is_file($batch_set['file'])) {
+ include_once DRUPAL_ROOT . '/' . $batch_set['file'];
+ }
+ if (function_exists($batch_set['finished'])) {
+ $queue = _batch_queue($batch_set);
+ $operations = $queue->getAllItems();
+ $batch_set['finished']($batch_set['success'], $batch_set['results'], $operations, format_interval($batch_set['elapsed'] / 1000));
+ }
+ }
+ }
+
+ // Clean up the batch table and unset the static $batch variable.
+ if ($batch['progressive']) {
+ db_delete('batch')
+ ->condition('bid', $batch['id'])
+ ->execute();
+ foreach ($batch['sets'] as $batch_set) {
+ if ($queue = _batch_queue($batch_set)) {
+ $queue->deleteQueue();
+ }
+ }
+ }
+ $_batch = $batch;
+ $batch = NULL;
+
+ // Clean-up the session. Not needed for CLI updates.
+ if (isset($_SESSION)) {
+ unset($_SESSION['batches'][$batch['id']]);
+ if (empty($_SESSION['batches'])) {
+ unset($_SESSION['batches']);
+ }
+ }
+
+ // Redirect if needed.
+ if ($_batch['progressive']) {
+ // Revert the 'destination' that was saved in batch_process().
+ if (isset($_batch['destination'])) {
+ $_GET['destination'] = $_batch['destination'];
+ }
+
+ // Determine the target path to redirect to.
+ if (!isset($_batch['form_state']['redirect'])) {
+ if (isset($_batch['redirect'])) {
+ $_batch['form_state']['redirect'] = $_batch['redirect'];
+ }
+ else {
+ $_batch['form_state']['redirect'] = $_batch['source_url'];
+ }
+ }
+
+ // Use drupal_redirect_form() to handle the redirection logic.
+ drupal_redirect_form($_batch['form_state']);
+
+ // If no redirection happened, redirect to the originating page. In case the
+ // form needs to be rebuilt, save the final $form_state for
+ // drupal_build_form().
+ if (!empty($_batch['form_state']['rebuild'])) {
+ $_SESSION['batch_form_state'] = $_batch['form_state'];
+ }
+ $function = $_batch['redirect_callback'];
+ if (function_exists($function)) {
+ $function($_batch['source_url'], array('query' => array('op' => 'finish', 'id' => $_batch['id'])));
+ }
+ }
+}
+
+/**
+ * Shutdown function; store the current batch data for the next request.
+ */
+function _batch_shutdown() {
+ if ($batch = batch_get()) {
+ db_update('batch')
+ ->fields(array('batch' => serialize($batch)))
+ ->condition('bid', $batch['id'])
+ ->execute();
+ }
+}
+
diff --git a/core/includes/batch.queue.inc b/core/includes/batch.queue.inc
new file mode 100644
index 000000000000..8464836987b7
--- /dev/null
+++ b/core/includes/batch.queue.inc
@@ -0,0 +1,71 @@
+<?php
+
+
+/**
+ * @file
+ * Queue handlers used by the Batch API.
+ *
+ * Those implementations:
+ * - ensure FIFO ordering,
+ * - let an item be repeatedly claimed until it is actually deleted (no notion
+ * of lease time or 'expire' date), to allow multipass operations.
+ */
+
+/**
+ * Batch queue implementation.
+ *
+ * Stale items from failed batches are cleaned from the {queue} table on cron
+ * using the 'created' date.
+ */
+class BatchQueue extends SystemQueue {
+
+ public function claimItem($lease_time = 0) {
+ $item = db_query_range('SELECT data, item_id FROM {queue} q WHERE name = :name ORDER BY item_id ASC', 0, 1, array(':name' => $this->name))->fetchObject();
+ if ($item) {
+ $item->data = unserialize($item->data);
+ return $item;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Retrieve all remaining items in the queue.
+ *
+ * This is specific to Batch API and is not part of the DrupalQueueInterface,
+ */
+ public function getAllItems() {
+ $result = array();
+ $items = db_query('SELECT data FROM {queue} q WHERE name = :name ORDER BY item_id ASC', array(':name' => $this->name))->fetchAll();
+ foreach ($items as $item) {
+ $result[] = unserialize($item->data);
+ }
+ return $result;
+ }
+}
+
+/**
+ * Batch queue implementation used for non-progressive batches.
+ */
+class BatchMemoryQueue extends MemoryQueue {
+
+ public function claimItem($lease_time = 0) {
+ if (!empty($this->queue)) {
+ reset($this->queue);
+ return current($this->queue);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Retrieve all remaining items in the queue.
+ *
+ * This is specific to Batch API and is not part of the DrupalQueueInterface,
+ */
+ public function getAllItems() {
+ $result = array();
+ foreach ($this->queue as $item) {
+ $result[] = $item->data;
+ }
+ return $result;
+ }
+}
diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc
new file mode 100644
index 000000000000..9780ed242a2b
--- /dev/null
+++ b/core/includes/bootstrap.inc
@@ -0,0 +1,3358 @@
+<?php
+
+/**
+ * @file
+ * Functions that need to be loaded on every Drupal request.
+ */
+
+/**
+ * The current system version.
+ */
+define('VERSION', '8.0-dev');
+
+/**
+ * Core API compatibility.
+ */
+define('DRUPAL_CORE_COMPATIBILITY', '8.x');
+
+/**
+ * Minimum supported version of PHP.
+ */
+define('DRUPAL_MINIMUM_PHP', '5.3.2');
+
+/**
+ * Minimum recommended value of PHP memory_limit.
+ */
+define('DRUPAL_MINIMUM_PHP_MEMORY_LIMIT', '32M');
+
+/**
+ * Indicates that the item should never be removed unless explicitly selected.
+ *
+ * The item may be removed using cache()->delete() with a cache ID.
+ */
+define('CACHE_PERMANENT', 0);
+
+/**
+ * Indicates that the item should be removed at the next general cache wipe.
+ */
+define('CACHE_TEMPORARY', -1);
+
+/**
+ * @defgroup logging_severity_levels Logging severity levels
+ * @{
+ * Logging severity levels as defined in RFC 3164.
+ *
+ * The WATCHDOG_* constant definitions correspond to the logging severity levels
+ * defined in RFC 3164, section 4.1.1. PHP supplies predefined LOG_* constants
+ * for use in the syslog() function, but their values on Windows builds do not
+ * correspond to RFC 3164. The associated PHP bug report was closed with the
+ * comment, "And it's also not a bug, as Windows just have less log levels,"
+ * and "So the behavior you're seeing is perfectly normal."
+ *
+ * @see http://www.faqs.org/rfcs/rfc3164.html
+ * @see http://bugs.php.net/bug.php?id=18090
+ * @see http://php.net/manual/function.syslog.php
+ * @see http://php.net/manual/network.constants.php
+ * @see watchdog()
+ * @see watchdog_severity_levels()
+ */
+
+/**
+ * Log message severity -- Emergency: system is unusable.
+ */
+define('WATCHDOG_EMERGENCY', 0);
+
+/**
+ * Log message severity -- Alert: action must be taken immediately.
+ */
+define('WATCHDOG_ALERT', 1);
+
+/**
+ * Log message severity -- Critical: critical conditions.
+ */
+define('WATCHDOG_CRITICAL', 2);
+
+/**
+ * Log message severity -- Error: error conditions.
+ */
+define('WATCHDOG_ERROR', 3);
+
+/**
+ * Log message severity -- Warning: warning conditions.
+ */
+define('WATCHDOG_WARNING', 4);
+
+/**
+ * Log message severity -- Notice: normal but significant condition.
+ */
+define('WATCHDOG_NOTICE', 5);
+
+/**
+ * Log message severity -- Informational: informational messages.
+ */
+define('WATCHDOG_INFO', 6);
+
+/**
+ * Log message severity -- Debug: debug-level messages.
+ */
+define('WATCHDOG_DEBUG', 7);
+
+/**
+ * @} End of "defgroup logging_severity_levels".
+ */
+
+/**
+ * First bootstrap phase: initialize configuration.
+ */
+define('DRUPAL_BOOTSTRAP_CONFIGURATION', 0);
+
+/**
+ * Second bootstrap phase: try to serve a cached page.
+ */
+define('DRUPAL_BOOTSTRAP_PAGE_CACHE', 1);
+
+/**
+ * Third bootstrap phase: initialize database layer.
+ */
+define('DRUPAL_BOOTSTRAP_DATABASE', 2);
+
+/**
+ * Fourth bootstrap phase: initialize the variable system.
+ */
+define('DRUPAL_BOOTSTRAP_VARIABLES', 3);
+
+/**
+ * Fifth bootstrap phase: initialize session handling.
+ */
+define('DRUPAL_BOOTSTRAP_SESSION', 4);
+
+/**
+ * Sixth bootstrap phase: set up the page header.
+ */
+define('DRUPAL_BOOTSTRAP_PAGE_HEADER', 5);
+
+/**
+ * Seventh bootstrap phase: find out language of the page.
+ */
+define('DRUPAL_BOOTSTRAP_LANGUAGE', 6);
+
+/**
+ * Final bootstrap phase: Drupal is fully loaded; validate and fix
+ * input data.
+ */
+define('DRUPAL_BOOTSTRAP_FULL', 7);
+
+/**
+ * Role ID for anonymous users; should match what's in the "role" table.
+ */
+define('DRUPAL_ANONYMOUS_RID', 1);
+
+/**
+ * Role ID for authenticated users; should match what's in the "role" table.
+ */
+define('DRUPAL_AUTHENTICATED_RID', 2);
+
+/**
+ * The number of bytes in a kilobyte. For more information, visit
+ * http://en.wikipedia.org/wiki/Kilobyte.
+ */
+define('DRUPAL_KILOBYTE', 1024);
+
+/**
+ * System language (only applicable to UI).
+ *
+ * Refers to the language used in Drupal and module/theme source code.
+ */
+define('LANGUAGE_SYSTEM', 'system');
+
+/**
+ * The language code used when no language is explicitly assigned.
+ *
+ * Defined by ISO639-2 for "Undetermined".
+ */
+define('LANGUAGE_NONE', 'und');
+
+/**
+ * The type of language used to define the content language.
+ */
+define('LANGUAGE_TYPE_CONTENT', 'language_content');
+
+/**
+ * The type of language used to select the user interface.
+ */
+define('LANGUAGE_TYPE_INTERFACE', 'language');
+
+/**
+ * The type of language used for URLs.
+ */
+define('LANGUAGE_TYPE_URL', 'language_url');
+
+/**
+ * Language written left to right. Possible value of $language->direction.
+ */
+define('LANGUAGE_LTR', 0);
+
+/**
+ * Language written right to left. Possible value of $language->direction.
+ */
+define('LANGUAGE_RTL', 1);
+
+/**
+ * For convenience, define a short form of the request time global.
+ *
+ * REQUEST_TIME is a float with microseconds since PHP 5.4.0, but float
+ * timestamps confuses most of the PHP functions (including date_create()).
+ */
+define('REQUEST_TIME', (int) $_SERVER['REQUEST_TIME']);
+
+/**
+ * Flag for drupal_set_title(); text is not sanitized, so run check_plain().
+ */
+define('CHECK_PLAIN', 0);
+
+/**
+ * Flag for drupal_set_title(); text has already been sanitized.
+ */
+define('PASS_THROUGH', -1);
+
+/**
+ * Signals that the registry lookup cache should be reset.
+ */
+define('REGISTRY_RESET_LOOKUP_CACHE', 1);
+
+/**
+ * Signals that the registry lookup cache should be written to storage.
+ */
+define('REGISTRY_WRITE_LOOKUP_CACHE', 2);
+
+/**
+ * Regular expression to match PHP function names.
+ *
+ * @see http://php.net/manual/en/language.functions.php
+ */
+define('DRUPAL_PHP_FUNCTION_PATTERN', '[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*');
+
+/**
+ * Provides a caching wrapper to be used in place of large array structures.
+ *
+ * This class should be extended by systems that need to cache large amounts
+ * of data and have it represented as an array to calling functions. These
+ * arrays can become very large, so ArrayAccess is used to allow different
+ * strategies to be used for caching internally (lazy loading, building caches
+ * over time etc.). This can dramatically reduce the amount of data that needs
+ * to be loaded from cache backends on each request, and memory usage from
+ * static caches of that same data.
+ *
+ * Note that array_* functions do not work with ArrayAccess. Systems using
+ * DrupalCacheArray should use this only internally. If providing API functions
+ * that return the full array, this can be cached separately or returned
+ * directly. However since DrupalCacheArray holds partial content by design, it
+ * should be a normal PHP array or otherwise contain the full structure.
+ *
+ * Note also that due to limitations in PHP prior to 5.3.4, it is impossible to
+ * write directly to the contents of nested arrays contained in this object.
+ * Only writes to the top-level array elements are possible. So if you
+ * previously had set $object['foo'] = array(1, 2, 'bar' => 'baz'), but later
+ * want to change the value of 'bar' from 'baz' to 'foobar', you cannot do so
+ * a targeted write like $object['foo']['bar'] = 'foobar'. Instead, you must
+ * overwrite the entire top-level 'foo' array with the entire set of new
+ * values: $object['foo'] = array(1, 2, 'bar' => 'foobar'). Due to this same
+ * limitation, attempts to create references to any contained data, nested or
+ * otherwise, will fail silently. So $var = &$object['foo'] will not throw an
+ * error, and $var will be populated with the contents of $object['foo'], but
+ * that data will be passed by value, not reference. For more information on
+ * the PHP limitation, see the note in the official PHP documentation at·
+ * http://php.net/manual/en/arrayaccess.offsetget.php on
+ * ArrayAccess::offsetGet().
+ *
+ * By default, the class accounts for caches where calling functions might
+ * request keys in the array that won't exist even after a cache rebuild. This
+ * prevents situations where a cache rebuild would be triggered over and over
+ * due to a 'missing' item. These cases are stored internally as a value of
+ * NULL. This means that the offsetGet() and offsetExists() methods
+ * must be overridden if caching an array where the top level values can
+ * legitimately be NULL, and where $object->offsetExists() needs to correctly
+ * return (equivalent to array_key_exists() vs. isset()). This should not
+ * be necessary in the majority of cases.
+ *
+ * Classes extending this class must override at least the
+ * resolveCacheMiss() method to have a working implementation.
+ *
+ * offsetSet() is not overridden by this class by default. In practice this
+ * means that assigning an offset via arrayAccess will only apply while the
+ * object is in scope and will not be written back to the persistent cache.
+ * This follows a similar pattern to static vs. persistent caching in
+ * procedural code. Extending classes may wish to alter this behaviour, for
+ * example by overriding offsetSet() and adding an automatic call to persist().
+ *
+ * @see SchemaCache
+ */
+abstract class DrupalCacheArray implements ArrayAccess {
+
+ /**
+ * A cid to pass to cache()->set() and cache()->get().
+ */
+ private $cid;
+
+ /**
+ * A bin to pass to cache()->set() and cache()->get().
+ */
+ private $bin;
+
+ /**
+ * An array of keys to add to the cache at the end of the request.
+ */
+ protected $keysToPersist = array();
+
+ /**
+ * Storage for the data itself.
+ */
+ protected $storage = array();
+
+ /**
+ * Constructor.
+ *
+ * @param $cid
+ * The cid for the array being cached.
+ * @param $bin
+ * The bin to cache the array.
+ */
+ public function __construct($cid, $bin) {
+ $this->cid = $cid;
+ $this->bin = $bin;
+
+ if ($cached = cache($bin)->get($this->cid)) {
+ $this->storage = $cached->data;
+ }
+ }
+
+ public function offsetExists($offset) {
+ return $this->offsetGet($offset) !== NULL;
+ }
+
+ public function offsetGet($offset) {
+ if (isset($this->storage[$offset]) || array_key_exists($offset, $this->storage)) {
+ return $this->storage[$offset];
+ }
+ else {
+ return $this->resolveCacheMiss($offset);
+ }
+ }
+
+ public function offsetSet($offset, $value) {
+ $this->storage[$offset] = $value;
+ }
+
+ public function offsetUnset($offset) {
+ unset($this->storage[$offset]);
+ }
+
+ /**
+ * Flags an offset value to be written to the persistent cache.
+ *
+ * If a value is assigned to a cache object with offsetSet(), by default it
+ * will not be written to the persistent cache unless it is flagged with this
+ * method. This allows items to be cached for the duration of a request,
+ * without necessarily writing back to the persistent cache at the end.
+ *
+ * @param $offset
+ * The array offset that was request.
+ * @param $persist
+ * Optional boolean to specify whether the offset should be persisted or
+ * not, defaults to TRUE. When called with $persist = FALSE the offset will
+ * be unflagged so that it will not written at the end of the request.
+ */
+ protected function persist($offset, $persist = TRUE) {
+ $this->keysToPersist[$offset] = $persist;
+ }
+
+ /**
+ * Resolves a cache miss.
+ *
+ * When an offset is not found in the object, this is treated as a cache
+ * miss. This method allows classes implementing the interface to look up
+ * the actual value and allow it to be cached.
+ *
+ * @param $offset
+ * The offset that was requested.
+ *
+ * @return
+ * The value of the offset, or NULL if no value was found.
+ */
+ abstract protected function resolveCacheMiss($offset);
+
+ /**
+ * Immediately write a value to the persistent cache.
+ *
+ * @param $cid
+ * The cache ID.
+ * @param $bin
+ * The cache bin.
+ * @param $data
+ * The data to write to the persistent cache.
+ * @param $lock
+ * Whether to acquire a lock before writing to cache.
+ */
+ protected function set($cid, $data, $bin, $lock = TRUE) {
+ // Lock cache writes to help avoid stampedes.
+ // To implement locking for cache misses, override __construct().
+ $lock_name = $cid . ':' . $bin;
+ if (!$lock || lock_acquire($lock_name)) {
+ if ($cached = cache($bin)->get($cid)) {
+ $data = $cached->data + $data;
+ }
+ cache($bin)->set($cid, $data);
+ if ($lock) {
+ lock_release($lock_name);
+ }
+ }
+ }
+
+ public function __destruct() {
+ $data = array();
+ foreach ($this->keysToPersist as $offset => $persist) {
+ if ($persist) {
+ $data[$offset] = $this->storage[$offset];
+ }
+ }
+ if (!empty($data)) {
+ $this->set($this->cid, $data, $this->bin);
+ }
+ }
+}
+
+/**
+ * Start the timer with the specified name. If you start and stop the same
+ * timer multiple times, the measured intervals will be accumulated.
+ *
+ * @param $name
+ * The name of the timer.
+ */
+function timer_start($name) {
+ global $timers;
+
+ $timers[$name]['start'] = microtime(TRUE);
+ $timers[$name]['count'] = isset($timers[$name]['count']) ? ++$timers[$name]['count'] : 1;
+}
+
+/**
+ * Read the current timer value without stopping the timer.
+ *
+ * @param $name
+ * The name of the timer.
+ *
+ * @return
+ * The current timer value in ms.
+ */
+function timer_read($name) {
+ global $timers;
+
+ if (isset($timers[$name]['start'])) {
+ $stop = microtime(TRUE);
+ $diff = round(($stop - $timers[$name]['start']) * 1000, 2);
+
+ if (isset($timers[$name]['time'])) {
+ $diff += $timers[$name]['time'];
+ }
+ return $diff;
+ }
+ return $timers[$name]['time'];
+}
+
+/**
+ * Stop the timer with the specified name.
+ *
+ * @param $name
+ * The name of the timer.
+ *
+ * @return
+ * A timer array. The array contains the number of times the timer has been
+ * started and stopped (count) and the accumulated timer value in ms (time).
+ */
+function timer_stop($name) {
+ global $timers;
+
+ if (isset($timers[$name]['start'])) {
+ $stop = microtime(TRUE);
+ $diff = round(($stop - $timers[$name]['start']) * 1000, 2);
+ if (isset($timers[$name]['time'])) {
+ $timers[$name]['time'] += $diff;
+ }
+ else {
+ $timers[$name]['time'] = $diff;
+ }
+ unset($timers[$name]['start']);
+ }
+
+ return $timers[$name];
+}
+
+/**
+ * Finds the appropriate configuration directory.
+ *
+ * Finds a matching configuration directory by stripping the website's
+ * hostname from left to right and pathname from right to left. The first
+ * configuration file found will be used and the remaining ones will be ignored.
+ * If no configuration file is found, return a default value '$confdir/default'.
+ *
+ * With a site located at http://www.example.com:8080/mysite/test/, the file,
+ * settings.php, is searched for in the following directories:
+ *
+ * - $confdir/8080.www.example.com.mysite.test
+ * - $confdir/www.example.com.mysite.test
+ * - $confdir/example.com.mysite.test
+ * - $confdir/com.mysite.test
+ *
+ * - $confdir/8080.www.example.com.mysite
+ * - $confdir/www.example.com.mysite
+ * - $confdir/example.com.mysite
+ * - $confdir/com.mysite
+ *
+ * - $confdir/8080.www.example.com
+ * - $confdir/www.example.com
+ * - $confdir/example.com
+ * - $confdir/com
+ *
+ * - $confdir/default
+ *
+ * If a file named sites.php is present in the $confdir, it will be loaded
+ * prior to scanning for directories. It should define an associative array
+ * named $sites, which maps domains to directories. It should be in the form
+ * of:
+ * @code
+ * $sites = array(
+ * 'The url to alias' => 'A directory within the sites directory'
+ * );
+ * @endcode
+ * For example:
+ * @code
+ * $sites = array(
+ * 'devexample.com' => 'example.com',
+ * 'localhost.example' => 'example.com',
+ * );
+ * @endcode
+ * The above array will cause Drupal to look for a directory named
+ * "example.com" in the sites directory whenever a request comes from
+ * "example.com", "devexample.com", or "localhost/example". That is useful
+ * on development servers, where the domain name may not be the same as the
+ * domain of the live server. Since Drupal stores file paths into the database
+ * (files, system table, etc.) this will ensure the paths are correct while
+ * accessed on development servers.
+ *
+ * @param bool $require_settings
+ * Only configuration directories with an existing settings.php file
+ * will be recognized. Defaults to TRUE. During initial installation,
+ * this is set to FALSE so that Drupal can detect a matching directory,
+ * then create a new settings.php file in it.
+ * @param bool $reset
+ * Force a full search for matching directories even if one had been
+ * found previously. Defaults to FALSE.
+ *
+ * @return
+ * The path of the matching directory.
+ */
+function conf_path($require_settings = TRUE, $reset = FALSE) {
+ $conf = &drupal_static(__FUNCTION__, '');
+
+ if ($conf && !$reset) {
+ return $conf;
+ }
+
+ $script_name = $_SERVER['SCRIPT_NAME'];
+ if (!$script_name) {
+ $script_name = $_SERVER['SCRIPT_FILENAME'];
+ }
+ $http_host = $_SERVER['HTTP_HOST'];
+ $conf = find_conf_path($http_host, $script_name, $require_settings);
+ return $conf;
+}
+
+/**
+ * Finds the appropriate configuration directory for a given host and path.
+ *
+ * @param $http_host
+ * The hostname and optional port number, e.g. "www.example.com" or
+ * "www.example.com:8080".
+ * @param $script_name
+ * The part of the url following the hostname, including the leading slash.
+ *
+ * @return
+ * The path of the matching configuration directory.
+ *
+ * @see conf_path()
+ */
+function find_conf_path($http_host, $script_name, $require_settings = TRUE) {
+ $confdir = 'sites';
+
+ $sites = array();
+ if (file_exists(DRUPAL_ROOT . '/' . $confdir . '/sites.php')) {
+ // This will overwrite $sites with the desired mappings.
+ include(DRUPAL_ROOT . '/' . $confdir . '/sites.php');
+ }
+
+ $uri = explode('/', $script_name);
+ $server = explode('.', implode('.', array_reverse(explode(':', rtrim($http_host, '.')))));
+ for ($i = count($uri) - 1; $i > 0; $i--) {
+ for ($j = count($server); $j > 0; $j--) {
+ $dir = implode('.', array_slice($server, -$j)) . implode('.', array_slice($uri, 0, $i));
+ if (isset($sites[$dir]) && file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $sites[$dir])) {
+ $dir = $sites[$dir];
+ }
+ if (file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $dir . '/settings.php') || (!$require_settings && file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $dir))) {
+ $conf = "$confdir/$dir";
+ return $conf;
+ }
+ }
+ }
+ $conf = "$confdir/default";
+ return $conf;
+}
+
+/**
+ * Set appropriate server variables needed for command line scripts to work.
+ *
+ * This function can be called by command line scripts before bootstrapping
+ * Drupal, to ensure that the page loads with the desired server parameters.
+ * This is because many parts of Drupal assume that they are running in a web
+ * browser and therefore use information from the global PHP $_SERVER variable
+ * that does not get set when Drupal is run from the command line.
+ *
+ * In many cases, the default way in which this function populates the $_SERVER
+ * variable is sufficient, and it can therefore be called without passing in
+ * any input. However, command line scripts running on a multisite installation
+ * (or on any installation that has settings.php stored somewhere other than
+ * the sites/default folder) need to pass in the URL of the site to allow
+ * Drupal to detect the correct location of the settings.php file. Passing in
+ * the 'url' parameter is also required for functions like request_uri() to
+ * return the expected values.
+ *
+ * Most other parameters do not need to be passed in, but may be necessary in
+ * some cases; for example, if Drupal's ip_address() function needs to return
+ * anything but the standard localhost value ('127.0.0.1'), the command line
+ * script should pass in the desired value via the 'REMOTE_ADDR' key.
+ *
+ * @param $variables
+ * (optional) An associative array of variables within $_SERVER that should
+ * be replaced. If the special element 'url' is provided in this array, it
+ * will be used to populate some of the server defaults; it should be set to
+ * the URL of the current page request, excluding any $_GET request but
+ * including the script name (e.g., http://www.example.com/mysite/index.php).
+ *
+ * @see conf_path()
+ * @see request_uri()
+ * @see ip_address()
+ */
+function drupal_override_server_variables($variables = array()) {
+ // Allow the provided URL to override any existing values in $_SERVER.
+ if (isset($variables['url'])) {
+ $url = parse_url($variables['url']);
+ if (isset($url['host'])) {
+ $_SERVER['HTTP_HOST'] = $url['host'];
+ }
+ if (isset($url['path'])) {
+ $_SERVER['SCRIPT_NAME'] = $url['path'];
+ }
+ unset($variables['url']);
+ }
+ // Define default values for $_SERVER keys. These will be used if $_SERVER
+ // does not already define them and no other values are passed in to this
+ // function.
+ $defaults = array(
+ 'HTTP_HOST' => 'localhost',
+ 'SCRIPT_NAME' => NULL,
+ 'REMOTE_ADDR' => '127.0.0.1',
+ 'REQUEST_METHOD' => 'GET',
+ 'SERVER_NAME' => NULL,
+ 'SERVER_SOFTWARE' => NULL,
+ 'HTTP_USER_AGENT' => NULL,
+ );
+ // Replace elements of the $_SERVER array, as appropriate.
+ $_SERVER = $variables + $_SERVER + $defaults;
+}
+
+/**
+ * Initialize PHP environment.
+ */
+function drupal_environment_initialize() {
+ if (!isset($_SERVER['HTTP_REFERER'])) {
+ $_SERVER['HTTP_REFERER'] = '';
+ }
+ if (!isset($_SERVER['SERVER_PROTOCOL']) || ($_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.0' && $_SERVER['SERVER_PROTOCOL'] != 'HTTP/1.1')) {
+ $_SERVER['SERVER_PROTOCOL'] = 'HTTP/1.0';
+ }
+
+ if (isset($_SERVER['HTTP_HOST'])) {
+ // As HTTP_HOST is user input, ensure it only contains characters allowed
+ // in hostnames. See RFC 952 (and RFC 2181).
+ // $_SERVER['HTTP_HOST'] is lowercased here per specifications.
+ $_SERVER['HTTP_HOST'] = strtolower($_SERVER['HTTP_HOST']);
+ if (!drupal_valid_http_host($_SERVER['HTTP_HOST'])) {
+ // HTTP_HOST is invalid, e.g. if containing slashes it may be an attack.
+ header($_SERVER['SERVER_PROTOCOL'] . ' 400 Bad Request');
+ exit;
+ }
+ }
+ else {
+ // Some pre-HTTP/1.1 clients will not send a Host header. Ensure the key is
+ // defined for E_ALL compliance.
+ $_SERVER['HTTP_HOST'] = '';
+ }
+
+ // When clean URLs are enabled, emulate ?q=foo/bar using REQUEST_URI. It is
+ // not possible to append the query string using mod_rewrite without the B
+ // flag (this was added in Apache 2.2.8), because mod_rewrite unescapes the
+ // path before passing it on to PHP. This is a problem when the path contains
+ // e.g. "&" or "%" that have special meanings in URLs and must be encoded.
+ $_GET['q'] = request_path();
+
+ // Enforce E_STRICT, but allow users to set levels not part of E_STRICT.
+ error_reporting(E_STRICT | E_ALL | error_reporting());
+
+ // Override PHP settings required for Drupal to work properly.
+ // sites/default/default.settings.php contains more runtime settings.
+ // The .htaccess file contains settings that cannot be changed at runtime.
+
+ // Don't escape quotes when reading files from the database, disk, etc.
+ ini_set('magic_quotes_runtime', '0');
+ // Use session cookies, not transparent sessions that puts the session id in
+ // the query string.
+ ini_set('session.use_cookies', '1');
+ ini_set('session.use_only_cookies', '1');
+ ini_set('session.use_trans_sid', '0');
+ // Don't send HTTP headers using PHP's session handler.
+ ini_set('session.cache_limiter', 'none');
+ // Use httponly session cookies.
+ ini_set('session.cookie_httponly', '1');
+
+ // Set sane locale settings, to ensure consistent string, dates, times and
+ // numbers handling.
+ setlocale(LC_ALL, 'C');
+}
+
+/**
+ * Validate that a hostname (for example $_SERVER['HTTP_HOST']) is safe.
+ *
+ * @return
+ * TRUE if only containing valid characters, or FALSE otherwise.
+ */
+function drupal_valid_http_host($host) {
+ return preg_match('/^\[?(?:[a-zA-Z0-9-:\]_]+\.?)+$/', $host);
+}
+
+/**
+ * Loads the configuration and sets the base URL, cookie domain, and
+ * session name correctly.
+ */
+function drupal_settings_initialize() {
+ global $base_url, $base_path, $base_root;
+
+ // Export the following settings.php variables to the global namespace
+ global $databases, $cookie_domain, $conf, $installed_profile, $update_free_access, $db_url, $db_prefix, $drupal_hash_salt, $is_https, $base_secure_url, $base_insecure_url;
+ $conf = array();
+
+ if (file_exists(DRUPAL_ROOT . '/' . conf_path() . '/settings.php')) {
+ include_once DRUPAL_ROOT . '/' . conf_path() . '/settings.php';
+ }
+ $is_https = isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) == 'on';
+
+ if (isset($base_url)) {
+ // Parse fixed base URL from settings.php.
+ $parts = parse_url($base_url);
+ $http_protocol = $parts['scheme'];
+ if (!isset($parts['path'])) {
+ $parts['path'] = '';
+ }
+ $base_path = $parts['path'] . '/';
+ // Build $base_root (everything until first slash after "scheme://").
+ $base_root = substr($base_url, 0, strlen($base_url) - strlen($parts['path']));
+ }
+ else {
+ // Create base URL
+ $http_protocol = $is_https ? 'https' : 'http';
+ $base_root = $http_protocol . '://' . $_SERVER['HTTP_HOST'];
+
+ $base_url = $base_root;
+
+ // $_SERVER['SCRIPT_NAME'] can, in contrast to $_SERVER['PHP_SELF'], not
+ // be modified by a visitor.
+ if ($dir = rtrim(dirname($_SERVER['SCRIPT_NAME']), '\/')) {
+ // Remove "core" directory if present, allowing install.php, update.php,
+ // cron.php and others to auto-detect a base path.
+ $core_position = strrpos($dir, '/core');
+ if ($core_position !== FALSE && strlen($dir) - 5 == $core_position) {
+ $base_path = substr($dir, 0, $core_position);
+ }
+ else {
+ $base_path = $dir;
+ }
+ $base_url .= $base_path;
+ $base_path .= '/';
+ }
+ else {
+ $base_path = '/';
+ }
+ }
+ $base_secure_url = str_replace('http://', 'https://', $base_url);
+ $base_insecure_url = str_replace('https://', 'http://', $base_url);
+
+ if ($cookie_domain) {
+ // If the user specifies the cookie domain, also use it for session name.
+ $session_name = $cookie_domain;
+ }
+ else {
+ // Otherwise use $base_url as session name, without the protocol
+ // to use the same session identifiers across http and https.
+ list( , $session_name) = explode('://', $base_url, 2);
+ // HTTP_HOST can be modified by a visitor, but we already sanitized it
+ // in drupal_settings_initialize().
+ if (!empty($_SERVER['HTTP_HOST'])) {
+ $cookie_domain = $_SERVER['HTTP_HOST'];
+ // Strip leading periods, www., and port numbers from cookie domain.
+ $cookie_domain = ltrim($cookie_domain, '.');
+ if (strpos($cookie_domain, 'www.') === 0) {
+ $cookie_domain = substr($cookie_domain, 4);
+ }
+ $cookie_domain = explode(':', $cookie_domain);
+ $cookie_domain = '.' . $cookie_domain[0];
+ }
+ }
+ // Per RFC 2109, cookie domains must contain at least one dot other than the
+ // first. For hosts such as 'localhost' or IP Addresses we don't set a cookie domain.
+ if (count(explode('.', $cookie_domain)) > 2 && !is_numeric(str_replace('.', '', $cookie_domain))) {
+ ini_set('session.cookie_domain', $cookie_domain);
+ }
+ // To prevent session cookies from being hijacked, a user can configure the
+ // SSL version of their website to only transfer session cookies via SSL by
+ // using PHP's session.cookie_secure setting. The browser will then use two
+ // separate session cookies for the HTTPS and HTTP versions of the site. So we
+ // must use different session identifiers for HTTPS and HTTP to prevent a
+ // cookie collision.
+ if ($is_https) {
+ ini_set('session.cookie_secure', TRUE);
+ }
+ $prefix = ini_get('session.cookie_secure') ? 'SSESS' : 'SESS';
+ session_name($prefix . substr(hash('sha256', $session_name), 0, 32));
+}
+
+/**
+ * Returns and optionally sets the filename for a system item (module,
+ * theme, etc.). The filename, whether provided, cached, or retrieved
+ * from the database, is only returned if the file exists.
+ *
+ * This function plays a key role in allowing Drupal's resources (modules
+ * and themes) to be located in different places depending on a site's
+ * configuration. For example, a module 'foo' may legally be be located
+ * in any of these three places:
+ *
+ * modules/foo/foo.module
+ * sites/all/modules/foo/foo.module
+ * sites/example.com/modules/foo/foo.module
+ *
+ * Calling drupal_get_filename('module', 'foo') will give you one of
+ * the above, depending on where the module is located.
+ *
+ * @param $type
+ * The type of the item (i.e. theme, theme_engine, module, profile).
+ * @param $name
+ * The name of the item for which the filename is requested.
+ * @param $filename
+ * The filename of the item if it is to be set explicitly rather
+ * than by consulting the database.
+ *
+ * @return
+ * The filename of the requested item.
+ */
+function drupal_get_filename($type, $name, $filename = NULL) {
+ // The location of files will not change during the request, so do not use
+ // drupal_static().
+ static $files = array(), $dirs = array();
+
+ if (!isset($files[$type])) {
+ $files[$type] = array();
+ }
+
+ if (!empty($filename) && file_exists($filename)) {
+ $files[$type][$name] = $filename;
+ }
+ elseif (isset($files[$type][$name])) {
+ // nothing
+ }
+ // Verify that we have an active database connection, before querying
+ // the database. This is required because this function is called both
+ // before we have a database connection (i.e. during installation) and
+ // when a database connection fails.
+ else {
+ try {
+ if (function_exists('db_query')) {
+ $file = db_query("SELECT filename FROM {system} WHERE name = :name AND type = :type", array(':name' => $name, ':type' => $type))->fetchField();
+ if (file_exists(DRUPAL_ROOT . '/' . $file)) {
+ $files[$type][$name] = $file;
+ }
+ }
+ }
+ catch (Exception $e) {
+ // The database table may not exist because Drupal is not yet installed,
+ // or the database might be down. We have a fallback for this case so we
+ // hide the error completely.
+ }
+ // Fallback to searching the filesystem if the database could not find the
+ // file or the file returned by the database is not found.
+ if (!isset($files[$type][$name])) {
+ // We have a consistent directory naming: modules, themes...
+ $dir = $type . 's';
+ if ($type == 'theme_engine') {
+ $dir = 'themes/engines';
+ $extension = 'engine';
+ }
+ elseif ($type == 'theme') {
+ $extension = 'info';
+ }
+ else {
+ $extension = $type;
+ }
+
+ if (!isset($dirs[$dir][$extension])) {
+ $dirs[$dir][$extension] = TRUE;
+ if (!function_exists('drupal_system_listing')) {
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ }
+ // Scan the appropriate directories for all files with the requested
+ // extension, not just the file we are currently looking for. This
+ // prevents unnecessary scans from being repeated when this function is
+ // called more than once in the same page request.
+ $matches = drupal_system_listing("/^" . DRUPAL_PHP_FUNCTION_PATTERN . "\.$extension$/", $dir, 'name', 0);
+ foreach ($matches as $matched_name => $file) {
+ $files[$type][$matched_name] = $file->uri;
+ }
+ }
+ }
+ }
+
+ if (isset($files[$type][$name])) {
+ return $files[$type][$name];
+ }
+}
+
+/**
+ * Load the persistent variable table.
+ *
+ * The variable table is composed of values that have been saved in the table
+ * with variable_set() as well as those explicitly specified in the configuration
+ * file.
+ */
+function variable_initialize($conf = array()) {
+ // NOTE: caching the variables improves performance by 20% when serving
+ // cached pages.
+ if ($cached = cache('bootstrap')->get('variables')) {
+ $variables = $cached->data;
+ }
+ else {
+ // Cache miss. Avoid a stampede.
+ $name = 'variable_init';
+ if (!lock_acquire($name, 1)) {
+ // Another request is building the variable cache.
+ // Wait, then re-run this function.
+ lock_wait($name);
+ return variable_initialize($conf);
+ }
+ else {
+ // Proceed with variable rebuild.
+ $variables = array_map('unserialize', db_query('SELECT name, value FROM {variable}')->fetchAllKeyed());
+ cache('bootstrap')->set('variables', $variables);
+ lock_release($name);
+ }
+ }
+
+ foreach ($conf as $name => $value) {
+ $variables[$name] = $value;
+ }
+
+ return $variables;
+}
+
+/**
+ * Returns a persistent variable.
+ *
+ * Case-sensitivity of the variable_* functions depends on the database
+ * collation used. To avoid problems, always use lower case for persistent
+ * variable names.
+ *
+ * @param $name
+ * The name of the variable to return.
+ * @param $default
+ * The default value to use if this variable has never been set.
+ *
+ * @return
+ * The value of the variable.
+ *
+ * @see variable_del()
+ * @see variable_set()
+ */
+function variable_get($name, $default = NULL) {
+ global $conf;
+
+ return isset($conf[$name]) ? $conf[$name] : $default;
+}
+
+/**
+ * Sets a persistent variable.
+ *
+ * Case-sensitivity of the variable_* functions depends on the database
+ * collation used. To avoid problems, always use lower case for persistent
+ * variable names.
+ *
+ * @param $name
+ * The name of the variable to set.
+ * @param $value
+ * The value to set. This can be any PHP data type; these functions take care
+ * of serialization as necessary.
+ *
+ * @see variable_del()
+ * @see variable_get()
+ */
+function variable_set($name, $value) {
+ global $conf;
+
+ db_merge('variable')->key(array('name' => $name))->fields(array('value' => serialize($value)))->execute();
+
+ cache('bootstrap')->delete('variables');
+
+ $conf[$name] = $value;
+}
+
+/**
+ * Unsets a persistent variable.
+ *
+ * Case-sensitivity of the variable_* functions depends on the database
+ * collation used. To avoid problems, always use lower case for persistent
+ * variable names.
+ *
+ * @param $name
+ * The name of the variable to undefine.
+ *
+ * @see variable_get()
+ * @see variable_set()
+ */
+function variable_del($name) {
+ global $conf;
+
+ db_delete('variable')
+ ->condition('name', $name)
+ ->execute();
+ cache('bootstrap')->delete('variables');
+
+ unset($conf[$name]);
+}
+
+/**
+ * Retrieve the current page from the cache.
+ *
+ * Note: we do not serve cached pages to authenticated users, or to anonymous
+ * users when $_SESSION is non-empty. $_SESSION may contain status messages
+ * from a form submission, the contents of a shopping cart, or other user-
+ * specific content that should not be cached and displayed to other users.
+ *
+ * @param $check_only
+ * (optional) Set to TRUE to only return whether a previous call found a
+ * cache entry.
+ *
+ * @return
+ * The cache object, if the page was found in the cache, NULL otherwise.
+ */
+function drupal_page_get_cache($check_only = FALSE) {
+ global $base_root;
+ static $cache_hit = FALSE;
+
+ if ($check_only) {
+ return $cache_hit;
+ }
+
+ if (drupal_page_is_cacheable()) {
+ $cache = cache('page')->get($base_root . request_uri());
+ if ($cache !== FALSE) {
+ $cache_hit = TRUE;
+ }
+ return $cache;
+ }
+}
+
+/**
+ * Determine the cacheability of the current page.
+ *
+ * @param $allow_caching
+ * Set to FALSE if you want to prevent this page to get cached.
+ *
+ * @return
+ * TRUE if the current page can be cached, FALSE otherwise.
+ */
+function drupal_page_is_cacheable($allow_caching = NULL) {
+ $allow_caching_static = &drupal_static(__FUNCTION__, TRUE);
+ if (isset($allow_caching)) {
+ $allow_caching_static = $allow_caching;
+ }
+
+ return $allow_caching_static && ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD')
+ && !drupal_is_cli();
+}
+
+/**
+ * Invoke a bootstrap hook in all bootstrap modules that implement it.
+ *
+ * @param $hook
+ * The name of the bootstrap hook to invoke.
+ *
+ * @see bootstrap_hooks()
+ */
+function bootstrap_invoke_all($hook) {
+ // Bootstrap modules should have been loaded when this function is called, so
+ // we don't need to tell module_list() to reset its internal list (and we
+ // therefore leave the first parameter at its default value of FALSE). We
+ // still pass in TRUE for the second parameter, though; in case this is the
+ // first time during the bootstrap that module_list() is called, we want to
+ // make sure that its internal cache is primed with the bootstrap modules
+ // only.
+ foreach (module_list(FALSE, TRUE) as $module) {
+ drupal_load('module', $module);
+ module_invoke($module, $hook);
+ }
+}
+
+/**
+ * Includes a file with the provided type and name. This prevents
+ * including a theme, engine, module, etc., more than once.
+ *
+ * @param $type
+ * The type of item to load (i.e. theme, theme_engine, module).
+ * @param $name
+ * The name of the item to load.
+ *
+ * @return
+ * TRUE if the item is loaded or has already been loaded.
+ */
+function drupal_load($type, $name) {
+ // Once a file is included this can't be reversed during a request so do not
+ // use drupal_static() here.
+ static $files = array();
+
+ if (isset($files[$type][$name])) {
+ return TRUE;
+ }
+
+ $filename = drupal_get_filename($type, $name);
+
+ if ($filename) {
+ include_once DRUPAL_ROOT . '/' . $filename;
+ $files[$type][$name] = TRUE;
+
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Set an HTTP response header for the current page.
+ *
+ * Note: When sending a Content-Type header, always include a 'charset' type,
+ * too. This is necessary to avoid security bugs (e.g. UTF-7 XSS).
+ *
+ * @param $name
+ * The HTTP header name, or the special 'Status' header name.
+ * @param $value
+ * The HTTP header value; if equal to FALSE, the specified header is unset.
+ * If $name is 'Status', this is expected to be a status code followed by a
+ * reason phrase, e.g. "404 Not Found".
+ * @param $append
+ * Whether to append the value to an existing header or to replace it.
+ */
+function drupal_add_http_header($name, $value, $append = FALSE) {
+ // The headers as name/value pairs.
+ $headers = &drupal_static('drupal_http_headers', array());
+
+ $name_lower = strtolower($name);
+ _drupal_set_preferred_header_name($name);
+
+ if ($value === FALSE) {
+ $headers[$name_lower] = FALSE;
+ }
+ elseif (isset($headers[$name_lower]) && $append) {
+ // Multiple headers with identical names may be combined using comma (RFC
+ // 2616, section 4.2).
+ $headers[$name_lower] .= ',' . $value;
+ }
+ else {
+ $headers[$name_lower] = $value;
+ }
+ drupal_send_headers(array($name => $headers[$name_lower]), TRUE);
+}
+
+/**
+ * Get the HTTP response headers for the current page.
+ *
+ * @param $name
+ * An HTTP header name. If omitted, all headers are returned as name/value
+ * pairs. If an array value is FALSE, the header has been unset.
+ * @return
+ * A string containing the header value, or FALSE if the header has been set,
+ * or NULL if the header has not been set.
+ */
+function drupal_get_http_header($name = NULL) {
+ $headers = &drupal_static('drupal_http_headers', array());
+ if (isset($name)) {
+ $name = strtolower($name);
+ return isset($headers[$name]) ? $headers[$name] : NULL;
+ }
+ else {
+ return $headers;
+ }
+}
+
+/**
+ * Header names are case-insensitive, but for maximum compatibility they should
+ * follow "common form" (see RFC 2617, section 4.2).
+ */
+function _drupal_set_preferred_header_name($name = NULL) {
+ static $header_names = array();
+
+ if (!isset($name)) {
+ return $header_names;
+ }
+ $header_names[strtolower($name)] = $name;
+}
+
+/**
+ * Send the HTTP response headers previously set using drupal_add_http_header().
+ * Add default headers, unless they have been replaced or unset using
+ * drupal_add_http_header().
+ *
+ * @param $default_headers
+ * An array of headers as name/value pairs.
+ * @param $single
+ * If TRUE and headers have already be sent, send only the specified header.
+ */
+function drupal_send_headers($default_headers = array(), $only_default = FALSE) {
+ $headers_sent = &drupal_static(__FUNCTION__, FALSE);
+ $headers = drupal_get_http_header();
+ if ($only_default && $headers_sent) {
+ $headers = array();
+ }
+ $headers_sent = TRUE;
+
+ $header_names = _drupal_set_preferred_header_name();
+ foreach ($default_headers as $name => $value) {
+ $name_lower = strtolower($name);
+ if (!isset($headers[$name_lower])) {
+ $headers[$name_lower] = $value;
+ $header_names[$name_lower] = $name;
+ }
+ }
+ foreach ($headers as $name_lower => $value) {
+ if ($name_lower == 'status') {
+ header($_SERVER['SERVER_PROTOCOL'] . ' ' . $value);
+ }
+ // Skip headers that have been unset.
+ elseif ($value) {
+ header($header_names[$name_lower] . ': ' . $value);
+ }
+ }
+}
+
+/**
+ * Set HTTP headers in preparation for a page response.
+ *
+ * Authenticated users are always given a 'no-cache' header, and will fetch a
+ * fresh page on every request. This prevents authenticated users from seeing
+ * locally cached pages.
+ *
+ * Also give each page a unique ETag. This will force clients to include both
+ * an If-Modified-Since header and an If-None-Match header when doing
+ * conditional requests for the page (required by RFC 2616, section 13.3.4),
+ * making the validation more robust. This is a workaround for a bug in Mozilla
+ * Firefox that is triggered when Drupal's caching is enabled and the user
+ * accesses Drupal via an HTTP proxy (see
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=269303): When an authenticated
+ * user requests a page, and then logs out and requests the same page again,
+ * Firefox may send a conditional request based on the page that was cached
+ * locally when the user was logged in. If this page did not have an ETag
+ * header, the request only contains an If-Modified-Since header. The date will
+ * be recent, because with authenticated users the Last-Modified header always
+ * refers to the time of the request. If the user accesses Drupal via a proxy
+ * server, and the proxy already has a cached copy of the anonymous page with an
+ * older Last-Modified date, the proxy may respond with 304 Not Modified, making
+ * the client think that the anonymous and authenticated pageviews are
+ * identical.
+ *
+ * @see drupal_page_set_cache()
+ */
+function drupal_page_header() {
+ $headers_sent = &drupal_static(__FUNCTION__, FALSE);
+ if ($headers_sent) {
+ return TRUE;
+ }
+ $headers_sent = TRUE;
+
+ $default_headers = array(
+ 'Expires' => 'Sun, 19 Nov 1978 05:00:00 GMT',
+ 'Last-Modified' => gmdate(DATE_RFC1123, REQUEST_TIME),
+ 'Cache-Control' => 'no-cache, must-revalidate, post-check=0, pre-check=0',
+ 'ETag' => '"' . REQUEST_TIME . '"',
+ );
+ drupal_send_headers($default_headers);
+}
+
+/**
+ * Set HTTP headers in preparation for a cached page response.
+ *
+ * The headers allow as much as possible in proxies and browsers without any
+ * particular knowledge about the pages. Modules can override these headers
+ * using drupal_add_http_header().
+ *
+ * If the request is conditional (using If-Modified-Since and If-None-Match),
+ * and the conditions match those currently in the cache, a 304 Not Modified
+ * response is sent.
+ */
+function drupal_serve_page_from_cache(stdClass $cache) {
+ // Negotiate whether to use compression.
+ $page_compression = variable_get('page_compression', TRUE) && extension_loaded('zlib');
+ $return_compressed = $page_compression && isset($_SERVER['HTTP_ACCEPT_ENCODING']) && strpos($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip') !== FALSE;
+
+ // Get headers set in hook_boot(). Keys are lower-case.
+ $hook_boot_headers = drupal_get_http_header();
+
+ // Headers generated in this function, that may be replaced or unset using
+ // drupal_add_http_headers(). Keys are mixed-case.
+ $default_headers = array();
+
+ foreach ($cache->data['headers'] as $name => $value) {
+ // In the case of a 304 response, certain headers must be sent, and the
+ // remaining may not (see RFC 2616, section 10.3.5). Do not override
+ // headers set in hook_boot().
+ $name_lower = strtolower($name);
+ if (in_array($name_lower, array('content-location', 'expires', 'cache-control', 'vary')) && !isset($hook_boot_headers[$name_lower])) {
+ drupal_add_http_header($name, $value);
+ unset($cache->data['headers'][$name]);
+ }
+ }
+
+ // If the client sent a session cookie, a cached copy will only be served
+ // to that one particular client due to Vary: Cookie. Thus, do not set
+ // max-age > 0, allowing the page to be cached by external proxies, when a
+ // session cookie is present unless the Vary header has been replaced or
+ // unset in hook_boot().
+ $max_age = !isset($_COOKIE[session_name()]) || isset($hook_boot_headers['vary']) ? variable_get('page_cache_maximum_age', 0) : 0;
+ $default_headers['Cache-Control'] = 'public, max-age=' . $max_age;
+
+ // Entity tag should change if the output changes.
+ $etag = '"' . $cache->created . '-' . intval($return_compressed) . '"';
+ header('Etag: ' . $etag);
+
+ // See if the client has provided the required HTTP headers.
+ $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : FALSE;
+ $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : FALSE;
+
+ if ($if_modified_since && $if_none_match
+ && $if_none_match == $etag // etag must match
+ && $if_modified_since == $cache->created) { // if-modified-since must match
+ header($_SERVER['SERVER_PROTOCOL'] . ' 304 Not Modified');
+ drupal_send_headers($default_headers);
+ return;
+ }
+
+ // Send the remaining headers.
+ foreach ($cache->data['headers'] as $name => $value) {
+ drupal_add_http_header($name, $value);
+ }
+
+ $default_headers['Last-Modified'] = gmdate(DATE_RFC1123, $cache->created);
+
+ // HTTP/1.0 proxies does not support the Vary header, so prevent any caching
+ // by sending an Expires date in the past. HTTP/1.1 clients ignores the
+ // Expires header if a Cache-Control: max-age= directive is specified (see RFC
+ // 2616, section 14.9.3).
+ $default_headers['Expires'] = 'Sun, 19 Nov 1978 05:00:00 GMT';
+
+ drupal_send_headers($default_headers);
+
+ // Allow HTTP proxies to cache pages for anonymous users without a session
+ // cookie. The Vary header is used to indicates the set of request-header
+ // fields that fully determines whether a cache is permitted to use the
+ // response to reply to a subsequent request for a given URL without
+ // revalidation. If a Vary header has been set in hook_boot(), it is assumed
+ // that the module knows how to cache the page.
+ if (!isset($hook_boot_headers['vary']) && !variable_get('omit_vary_cookie')) {
+ header('Vary: Cookie');
+ }
+
+ if ($page_compression) {
+ header('Vary: Accept-Encoding', FALSE);
+ // If page_compression is enabled, the cache contains gzipped data.
+ if ($return_compressed) {
+ // $cache->data['body'] is already gzip'ed, so make sure
+ // zlib.output_compression does not compress it once more.
+ ini_set('zlib.output_compression', '0');
+ header('Content-Encoding: gzip');
+ }
+ else {
+ // The client does not support compression, so unzip the data in the
+ // cache. Strip the gzip header and run uncompress.
+ $cache->data['body'] = gzinflate(substr(substr($cache->data['body'], 10), 0, -8));
+ }
+ }
+
+ // Print the page.
+ print $cache->data['body'];
+}
+
+/**
+ * Define the critical hooks that force modules to always be loaded.
+ */
+function bootstrap_hooks() {
+ return array('boot', 'exit', 'watchdog', 'language_init');
+}
+
+/**
+ * Unserializes and appends elements from a serialized string.
+ *
+ * @param $obj
+ * The object to which the elements are appended.
+ * @param $field
+ * The attribute of $obj whose value should be unserialized.
+ */
+function drupal_unpack($obj, $field = 'data') {
+ if ($obj->$field && $data = unserialize($obj->$field)) {
+ foreach ($data as $key => $value) {
+ if (!empty($key) && !isset($obj->$key)) {
+ $obj->$key = $value;
+ }
+ }
+ }
+ return $obj;
+}
+
+/**
+ * Translates a string to the current language or to a given language.
+ *
+ * The t() function serves two purposes. First, at run-time it translates
+ * user-visible text into the appropriate language. Second, various mechanisms
+ * that figure out what text needs to be translated work off t() -- the text
+ * inside t() calls is added to the database of strings to be translated.
+ * These strings are expected to be in English, so the first argument should
+ * always be in English. To enable a fully-translatable site, it is important
+ * that all human-readable text that will be displayed on the site or sent to
+ * a user is passed through the t() function, or a related function. See the
+ * @link http://drupal.org/node/322729 Localization API @endlink pages for
+ * more information, including recommendations on how to break up or not
+ * break up strings for translation.
+ *
+ * You should never use t() to translate variables, such as calling
+ * @code t($text); @endcode, unless the text that the variable holds has been
+ * passed through t() elsewhere (e.g., $text is one of several translated
+ * literal strings in an array). It is especially important never to call
+ * @code t($user_text); @endcode, where $user_text is some text that a user
+ * entered - doing that can lead to cross-site scripting and other security
+ * problems. However, you can use variable substitution in your string, to put
+ * variable text such as user names or link URLs into translated text. Variable
+ * substitution looks like this:
+ * @code
+ * $text = t("@name's blog", array('@name' => format_username($account)));
+ * @endcode
+ * Basically, you can put variables like @name into your string, and t() will
+ * substitute their sanitized values at translation time (see $args below or
+ * the Localization API pages referenced above for details). Translators can
+ * then rearrange the string as necessary for the language (e.g., in Spanish,
+ * it might be "blog de @name").
+ *
+ * During the Drupal installation phase, some resources used by t() wil not be
+ * available to code that needs localization. See st() and get_t() for
+ * alternatives.
+ *
+ * @param $string
+ * A string containing the English string to translate.
+ * @param $args
+ * An associative array of replacements to make after translation.
+ * See format_string().
+ * @param $options
+ * An associative array of additional options, with the following elements:
+ * - 'langcode' (defaults to the current language): The language code to
+ * translate to a language other than what is used to display the page.
+ * - 'context' (defaults to the empty context): The context the source string
+ * belongs to.
+ *
+ * @return
+ * The translated string.
+ *
+ * @see st()
+ * @see get_t()
+ * @ingroup sanitization
+ */
+function t($string, array $args = array(), array $options = array()) {
+ global $language;
+ static $custom_strings;
+
+ // Merge in default.
+ if (empty($options['langcode'])) {
+ $options['langcode'] = isset($language->language) ? $language->language : LANGUAGE_SYSTEM;
+ }
+ if (empty($options['context'])) {
+ $options['context'] = '';
+ }
+
+ // First, check for an array of customized strings. If present, use the array
+ // *instead of* database lookups. This is a high performance way to provide a
+ // handful of string replacements. See settings.php for examples.
+ // Cache the $custom_strings variable to improve performance.
+ if (!isset($custom_strings[$options['langcode']])) {
+ $custom_strings[$options['langcode']] = variable_get('locale_custom_strings_' . $options['langcode'], array());
+ }
+ // Custom strings work for English too, even if locale module is disabled.
+ if (isset($custom_strings[$options['langcode']][$options['context']][$string])) {
+ $string = $custom_strings[$options['langcode']][$options['context']][$string];
+ }
+ // Translate with locale module if enabled.
+ elseif ($options['langcode'] != LANGUAGE_SYSTEM && ($options['langcode'] != 'en' || variable_get('locale_translate_english', FALSE)) && function_exists('locale')) {
+ $string = locale($string, $options['context'], $options['langcode']);
+ }
+ if (empty($args)) {
+ return $string;
+ }
+ else {
+ return format_string($string, $args);
+ }
+}
+
+/**
+ * Replace placeholders with sanitized values in a string.
+ *
+ * @param $string
+ * A string containing placeholders.
+ * @param $args
+ * An associative array of replacements to make. Occurrences in $string of
+ * any key in $args are replaced with the corresponding value, after
+ * sanitization. The sanitization function depends on the first character of
+ * the key:
+ * - !variable: Inserted as is. Use this for text that has already been
+ * sanitized.
+ * - @variable: Escaped to HTML using check_plain(). Use this for anything
+ * displayed on a page on the site.
+ * - %variable: Escaped as a placeholder for user-submitted content using
+ * drupal_placeholder(), which shows up as <em>emphasized</em> text.
+ *
+ * @see t()
+ * @ingroup sanitization
+ */
+function format_string($string, array $args = array()) {
+ // Transform arguments before inserting them.
+ foreach ($args as $key => $value) {
+ switch ($key[0]) {
+ case '@':
+ // Escaped only.
+ $args[$key] = check_plain($value);
+ break;
+
+ case '%':
+ default:
+ // Escaped and placeholder.
+ $args[$key] = drupal_placeholder($value);
+ break;
+
+ case '!':
+ // Pass-through.
+ }
+ }
+ return strtr($string, $args);
+}
+
+/**
+ * Encode special characters in a plain-text string for display as HTML.
+ *
+ * Also validates strings as UTF-8 to prevent cross site scripting attacks on
+ * Internet Explorer 6.
+ *
+ * @param $text
+ * The text to be checked or processed.
+ *
+ * @return
+ * An HTML safe version of $text, or an empty string if $text is not
+ * valid UTF-8.
+ *
+ * @see drupal_validate_utf8()
+ * @ingroup sanitization
+ */
+function check_plain($text) {
+ return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
+}
+
+/**
+ * Checks whether a string is valid UTF-8.
+ *
+ * All functions designed to filter input should use drupal_validate_utf8
+ * to ensure they operate on valid UTF-8 strings to prevent bypass of the
+ * filter.
+ *
+ * When text containing an invalid UTF-8 lead byte (0xC0 - 0xFF) is presented
+ * as UTF-8 to Internet Explorer 6, the program may misinterpret subsequent
+ * bytes. When these subsequent bytes are HTML control characters such as
+ * quotes or angle brackets, parts of the text that were deemed safe by filters
+ * end up in locations that are potentially unsafe; An onerror attribute that
+ * is outside of a tag, and thus deemed safe by a filter, can be interpreted
+ * by the browser as if it were inside the tag.
+ *
+ * The function does not return FALSE for strings containing character codes
+ * above U+10FFFF, even though these are prohibited by RFC 3629.
+ *
+ * @param $text
+ * The text to check.
+ * @return
+ * TRUE if the text is valid UTF-8, FALSE if not.
+ */
+function drupal_validate_utf8($text) {
+ if (strlen($text) == 0) {
+ return TRUE;
+ }
+ // With the PCRE_UTF8 modifier 'u', preg_match() fails silently on strings
+ // containing invalid UTF-8 byte sequences. It does not reject character
+ // codes above U+10FFFF (represented by 4 or more octets), though.
+ return (preg_match('/^./us', $text) == 1);
+}
+
+/**
+ * Returns the equivalent of Apache's $_SERVER['REQUEST_URI'] variable.
+ *
+ * Because $_SERVER['REQUEST_URI'] is only available on Apache, we generate an
+ * equivalent using other environment variables.
+ */
+function request_uri() {
+ if (isset($_SERVER['REQUEST_URI'])) {
+ $uri = $_SERVER['REQUEST_URI'];
+ }
+ else {
+ if (isset($_SERVER['argv'])) {
+ $uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['argv'][0];
+ }
+ elseif (isset($_SERVER['QUERY_STRING'])) {
+ $uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['QUERY_STRING'];
+ }
+ else {
+ $uri = $_SERVER['SCRIPT_NAME'];
+ }
+ }
+ // Prevent multiple slashes to avoid cross site requests via the Form API.
+ $uri = '/' . ltrim($uri, '/');
+
+ return $uri;
+}
+
+/**
+ * Log an exception.
+ *
+ * This is a wrapper function for watchdog() which automatically decodes an
+ * exception.
+ *
+ * @param $type
+ * The category to which this message belongs.
+ * @param $exception
+ * The exception that is going to be logged.
+ * @param $message
+ * The message to store in the log. If empty, a text that contains all useful
+ * information about the passed-in exception is used.
+ * @param $variables
+ * Array of variables to replace in the message on display. Defaults to the
+ * return value of drupal_decode_exception().
+ * @param $severity
+ * The severity of the message, as per RFC 3164.
+ * @param $link
+ * A link to associate with the message.
+ *
+ * @see watchdog()
+ * @see drupal_decode_exception()
+ */
+function watchdog_exception($type, Exception $exception, $message = NULL, $variables = array(), $severity = WATCHDOG_ERROR, $link = NULL) {
+
+ // Use a default value if $message is not set.
+ if (empty($message)) {
+ // The exception message is run through check_plain() by _drupal_decode_exception().
+ $message = '%type: !message in %function (line %line of %file).';
+ }
+ // $variables must be an array so that we can add the exception information.
+ if (!is_array($variables)) {
+ $variables = array();
+ }
+
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ $variables += _drupal_decode_exception($exception);
+ watchdog($type, $message, $variables, $severity, $link);
+}
+
+/**
+ * Log a system message.
+ *
+ * @param $type
+ * The category to which this message belongs. Can be any string, but the
+ * general practice is to use the name of the module calling watchdog().
+ * @param $message
+ * The message to store in the log. Keep $message translatable
+ * by not concatenating dynamic values into it! Variables in the
+ * message should be added by using placeholder strings alongside
+ * the variables argument to declare the value of the placeholders.
+ * See t() for documentation on how $message and $variables interact.
+ * @param $variables
+ * Array of variables to replace in the message on display or
+ * NULL if message is already translated or not possible to
+ * translate.
+ * @param $severity
+ * The severity of the message, as per RFC 3164. Possible values are
+ * WATCHDOG_ERROR, WATCHDOG_WARNING, etc.
+ * @param $link
+ * A link to associate with the message.
+ *
+ * @see watchdog_severity_levels()
+ * @see hook_watchdog()
+ */
+function watchdog($type, $message, $variables = array(), $severity = WATCHDOG_NOTICE, $link = NULL) {
+ global $user, $base_root;
+
+ static $in_error_state = FALSE;
+
+ // It is possible that the error handling will itself trigger an error. In that case, we could
+ // end up in an infinite loop. To avoid that, we implement a simple static semaphore.
+ if (!$in_error_state && function_exists('module_implements')) {
+ $in_error_state = TRUE;
+
+ // Prepare the fields to be logged
+ $log_entry = array(
+ 'type' => $type,
+ 'message' => $message,
+ 'variables' => $variables,
+ 'severity' => $severity,
+ 'link' => $link,
+ 'user' => $user,
+ 'request_uri' => $base_root . request_uri(),
+ 'referer' => isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '',
+ 'ip' => ip_address(),
+ 'timestamp' => REQUEST_TIME,
+ );
+
+ // Call the logging hooks to log/process the message
+ foreach (module_implements('watchdog') as $module) {
+ module_invoke($module, 'watchdog', $log_entry);
+ }
+
+ // It is critical that the semaphore is only cleared here, in the parent
+ // watchdog() call (not outside the loop), to prevent recursive execution.
+ $in_error_state = FALSE;
+ }
+}
+
+/**
+ * Set a message which reflects the status of the performed operation.
+ *
+ * If the function is called with no arguments, this function returns all set
+ * messages without clearing them.
+ *
+ * @param $message
+ * The message to be displayed to the user. For consistency with other
+ * messages, it should begin with a capital letter and end with a period.
+ * @param $type
+ * The type of the message. One of the following values are possible:
+ * - 'status'
+ * - 'warning'
+ * - 'error'
+ * @param $repeat
+ * If this is FALSE and the message is already set, then the message won't
+ * be repeated.
+ */
+function drupal_set_message($message = NULL, $type = 'status', $repeat = TRUE) {
+ if ($message) {
+ if (!isset($_SESSION['messages'][$type])) {
+ $_SESSION['messages'][$type] = array();
+ }
+
+ if ($repeat || !in_array($message, $_SESSION['messages'][$type])) {
+ $_SESSION['messages'][$type][] = $message;
+ }
+
+ // Mark this page as being uncacheable.
+ drupal_page_is_cacheable(FALSE);
+ }
+
+ // Messages not set when DB connection fails.
+ return isset($_SESSION['messages']) ? $_SESSION['messages'] : NULL;
+}
+
+/**
+ * Return all messages that have been set.
+ *
+ * @param $type
+ * (optional) Only return messages of this type.
+ * @param $clear_queue
+ * (optional) Set to FALSE if you do not want to clear the messages queue
+ * @return
+ * An associative array, the key is the message type, the value an array
+ * of messages. If the $type parameter is passed, you get only that type,
+ * or an empty array if there are no such messages. If $type is not passed,
+ * all message types are returned, or an empty array if none exist.
+ */
+function drupal_get_messages($type = NULL, $clear_queue = TRUE) {
+ if ($messages = drupal_set_message()) {
+ if ($type) {
+ if ($clear_queue) {
+ unset($_SESSION['messages'][$type]);
+ }
+ if (isset($messages[$type])) {
+ return array($type => $messages[$type]);
+ }
+ }
+ else {
+ if ($clear_queue) {
+ unset($_SESSION['messages']);
+ }
+ return $messages;
+ }
+ }
+ return array();
+}
+
+/**
+ * Get the title of the current page, for display on the page and in the title bar.
+ *
+ * @return
+ * The current page's title.
+ */
+function drupal_get_title() {
+ $title = drupal_set_title();
+
+ // During a bootstrap, menu.inc is not included and thus we cannot provide a title.
+ if (!isset($title) && function_exists('menu_get_active_title')) {
+ $title = check_plain(menu_get_active_title());
+ }
+
+ return $title;
+}
+
+/**
+ * Set the title of the current page, for display on the page and in the title bar.
+ *
+ * @param $title
+ * Optional string value to assign to the page title; or if set to NULL
+ * (default), leaves the current title unchanged.
+ * @param $output
+ * Optional flag - normally should be left as CHECK_PLAIN. Only set to
+ * PASS_THROUGH if you have already removed any possibly dangerous code
+ * from $title using a function like check_plain() or filter_xss(). With this
+ * flag the string will be passed through unchanged.
+ *
+ * @return
+ * The updated title of the current page.
+ */
+function drupal_set_title($title = NULL, $output = CHECK_PLAIN) {
+ $stored_title = &drupal_static(__FUNCTION__);
+
+ if (isset($title)) {
+ $stored_title = ($output == PASS_THROUGH) ? $title : check_plain($title);
+ }
+
+ return $stored_title;
+}
+
+/**
+ * Check to see if an IP address has been blocked.
+ *
+ * Blocked IP addresses are stored in the database by default. However for
+ * performance reasons we allow an override in settings.php. This allows us
+ * to avoid querying the database at this critical stage of the bootstrap if
+ * an administrative interface for IP address blocking is not required.
+ *
+ * @param $ip
+ * IP address to check.
+ * @return bool
+ * TRUE if access is denied, FALSE if access is allowed.
+ */
+function drupal_is_denied($ip) {
+ // Because this function is called on every page request, we first check
+ // for an array of IP addresses in settings.php before querying the
+ // database.
+ $blocked_ips = variable_get('blocked_ips');
+ $denied = FALSE;
+ if (isset($blocked_ips) && is_array($blocked_ips)) {
+ $denied = in_array($ip, $blocked_ips);
+ }
+ // Only check if database.inc is loaded already. If
+ // $conf['page_cache_without_database'] = TRUE; is set in settings.php,
+ // then the database won't be loaded here so the IPs in the database
+ // won't be denied. However the user asked explicitly not to use the
+ // database and also in this case it's quite likely that the user relies
+ // on higher performance solutions like a firewall.
+ elseif (class_exists('Database', FALSE)) {
+ $denied = (bool)db_query("SELECT 1 FROM {blocked_ips} WHERE ip = :ip", array(':ip' => $ip))->fetchField();
+ }
+ return $denied;
+}
+
+/**
+ * Handle denied users.
+ *
+ * @param $ip
+ * IP address to check. Prints a message and exits if access is denied.
+ */
+function drupal_block_denied($ip) {
+ // Deny access to blocked IP addresses - t() is not yet available.
+ if (drupal_is_denied($ip)) {
+ header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
+ print 'Sorry, ' . check_plain(ip_address()) . ' has been banned.';
+ exit();
+ }
+}
+
+/**
+ * Returns a string of highly randomized bytes (over the full 8-bit range).
+ *
+ * This function is better than simply calling mt_rand() or any other built-in
+ * PHP function because it can return a long string of bytes (compared to < 4
+ * bytes normally from mt_rand()) and uses the best available pseudo-random source.
+ *
+ * @param $count
+ * The number of characters (bytes) to return in the string.
+ */
+function drupal_random_bytes($count) {
+ // $random_state does not use drupal_static as it stores random bytes.
+ static $random_state, $bytes;
+ // Initialize on the first call. The contents of $_SERVER includes a mix of
+ // user-specific and system information that varies a little with each page.
+ if (!isset($random_state)) {
+ $random_state = print_r($_SERVER, TRUE);
+ if (function_exists('getmypid')) {
+ // Further initialize with the somewhat random PHP process ID.
+ $random_state .= getmypid();
+ }
+ $bytes = '';
+ }
+ if (strlen($bytes) < $count) {
+ // /dev/urandom is available on many *nix systems and is considered the
+ // best commonly available pseudo-random source.
+ if ($fh = @fopen('/dev/urandom', 'rb')) {
+ // PHP only performs buffered reads, so in reality it will always read
+ // at least 4096 bytes. Thus, it costs nothing extra to read and store
+ // that much so as to speed any additional invocations.
+ $bytes .= fread($fh, max(4096, $count));
+ fclose($fh);
+ }
+ // If /dev/urandom is not available or returns no bytes, this loop will
+ // generate a good set of pseudo-random bytes on any system.
+ // Note that it may be important that our $random_state is passed
+ // through hash() prior to being rolled into $output, that the two hash()
+ // invocations are different, and that the extra input into the first one -
+ // the microtime() - is prepended rather than appended. This is to avoid
+ // directly leaking $random_state via the $output stream, which could
+ // allow for trivial prediction of further "random" numbers.
+ while (strlen($bytes) < $count) {
+ $random_state = hash('sha256', microtime() . mt_rand() . $random_state);
+ $bytes .= hash('sha256', mt_rand() . $random_state, TRUE);
+ }
+ }
+ $output = substr($bytes, 0, $count);
+ $bytes = substr($bytes, $count);
+ return $output;
+}
+
+/**
+ * Calculate a base-64 encoded, URL-safe sha-256 hmac.
+ *
+ * @param $data
+ * String to be validated with the hmac.
+ * @param $key
+ * A secret string key.
+ *
+ * @return
+ * A base-64 encoded sha-256 hmac, with + replaced with -, / with _ and
+ * any = padding characters removed.
+ */
+function drupal_hmac_base64($data, $key) {
+ $hmac = base64_encode(hash_hmac('sha256', $data, $key, TRUE));
+ // Modify the hmac so it's safe to use in URLs.
+ return strtr($hmac, array('+' => '-', '/' => '_', '=' => ''));
+}
+
+/**
+ * Calculate a base-64 encoded, URL-safe sha-256 hash.
+ *
+ * @param $data
+ * String to be hashed.
+ *
+ * @return
+ * A base-64 encoded sha-256 hash, with + replaced with -, / with _ and
+ * any = padding characters removed.
+ */
+function drupal_hash_base64($data) {
+ $hash = base64_encode(hash('sha256', $data, TRUE));
+ // Modify the hash so it's safe to use in URLs.
+ return strtr($hash, array('+' => '-', '/' => '_', '=' => ''));
+}
+
+/**
+ * Merges multiple arrays, recursively, and returns the merged array.
+ *
+ * This function is similar to PHP's array_merge_recursive() function, but it
+ * handles non-array values differently. When merging values that are not both
+ * arrays, the latter value replaces the former rather than merging with it.
+ *
+ * Example:
+ * @code
+ * $link_options_1 = array('fragment' => 'x', 'attributes' => array('title' => t('X'), 'class' => array('a', 'b')));
+ * $link_options_2 = array('fragment' => 'y', 'attributes' => array('title' => t('Y'), 'class' => array('c', 'd')));
+ *
+ * // This results in array('fragment' => array('x', 'y'), 'attributes' => array('title' => array(t('X'), t('Y')), 'class' => array('a', 'b', 'c', 'd'))).
+ * $incorrect = array_merge_recursive($link_options_1, $link_options_2);
+ *
+ * // This results in array('fragment' => 'y', 'attributes' => array('title' => t('Y'), 'class' => array('a', 'b', 'c', 'd'))).
+ * $correct = drupal_array_merge_deep($link_options_1, $link_options_2);
+ * @endcode
+ *
+ * @param ...
+ * Arrays to merge.
+ *
+ * @return
+ * The merged array.
+ *
+ * @see drupal_array_merge_deep_array()
+ */
+function drupal_array_merge_deep() {
+ return drupal_array_merge_deep_array(func_get_args());
+}
+
+/**
+ * Merges multiple arrays, recursively, and returns the merged array.
+ *
+ * This function is equivalent to drupal_array_merge_deep(), except the
+ * input arrays are passed as a single array parameter rather than a variable
+ * parameter list.
+ *
+ * The following are equivalent:
+ * - drupal_array_merge_deep($a, $b);
+ * - drupal_array_merge_deep_array(array($a, $b));
+ *
+ * The following are also equivalent:
+ * - call_user_func_array('drupal_array_merge_deep', $arrays_to_merge);
+ * - drupal_array_merge_deep_array($arrays_to_merge);
+ *
+ * @see drupal_array_merge_deep()
+ */
+function drupal_array_merge_deep_array($arrays) {
+ $result = array();
+
+ foreach ($arrays as $array) {
+ foreach ($array as $key => $value) {
+ // Renumber integer keys as array_merge_recursive() does. Note that PHP
+ // automatically converts array keys that are integer strings (e.g., '1')
+ // to integers.
+ if (is_integer($key)) {
+ $result[] = $value;
+ }
+ // Recurse when both values are arrays.
+ elseif (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
+ $result[$key] = drupal_array_merge_deep_array(array($result[$key], $value));
+ }
+ // Otherwise, use the latter value, overriding any previous value.
+ else {
+ $result[$key] = $value;
+ }
+ }
+ }
+
+ return $result;
+}
+
+/**
+ * Generates a default anonymous $user object.
+ *
+ * @return Object - the user object.
+ */
+function drupal_anonymous_user() {
+ $user = new stdClass();
+ $user->uid = 0;
+ $user->hostname = ip_address();
+ $user->roles = array();
+ $user->roles[DRUPAL_ANONYMOUS_RID] = 'anonymous user';
+ $user->cache = 0;
+ return $user;
+}
+
+/**
+ * A string describing a phase of Drupal to load. Each phase adds to the
+ * previous one, so invoking a later phase automatically runs the earlier
+ * phases too. The most important usage is that if you want to access the
+ * Drupal database from a script without loading anything else, you can
+ * include bootstrap.inc, and call drupal_bootstrap(DRUPAL_BOOTSTRAP_DATABASE).
+ *
+ * @param $phase
+ * A constant. Allowed values are the DRUPAL_BOOTSTRAP_* constants.
+ * @param $new_phase
+ * A boolean, set to FALSE if calling drupal_bootstrap from inside a
+ * function called from drupal_bootstrap (recursion).
+ * @return
+ * The most recently completed phase.
+ *
+ */
+function drupal_bootstrap($phase = NULL, $new_phase = TRUE) {
+ // Not drupal_static(), because does not depend on any run-time information.
+ static $phases = array(
+ DRUPAL_BOOTSTRAP_CONFIGURATION,
+ DRUPAL_BOOTSTRAP_PAGE_CACHE,
+ DRUPAL_BOOTSTRAP_DATABASE,
+ DRUPAL_BOOTSTRAP_VARIABLES,
+ DRUPAL_BOOTSTRAP_SESSION,
+ DRUPAL_BOOTSTRAP_PAGE_HEADER,
+ DRUPAL_BOOTSTRAP_LANGUAGE,
+ DRUPAL_BOOTSTRAP_FULL,
+ );
+ // Not drupal_static(), because the only legitimate API to control this is to
+ // call drupal_bootstrap() with a new phase parameter.
+ static $final_phase;
+ // Not drupal_static(), because it's impossible to roll back to an earlier
+ // bootstrap state.
+ static $stored_phase = -1;
+
+ // When not recursing, store the phase name so it's not forgotten while
+ // recursing.
+ if ($new_phase) {
+ $final_phase = $phase;
+ }
+ if (isset($phase)) {
+ // Call a phase if it has not been called before and is below the requested
+ // phase.
+ while ($phases && $phase > $stored_phase && $final_phase > $stored_phase) {
+ $current_phase = array_shift($phases);
+
+ // This function is re-entrant. Only update the completed phase when the
+ // current call actually resulted in a progress in the bootstrap process.
+ if ($current_phase > $stored_phase) {
+ $stored_phase = $current_phase;
+ }
+
+ switch ($current_phase) {
+ case DRUPAL_BOOTSTRAP_CONFIGURATION:
+ _drupal_bootstrap_configuration();
+ break;
+
+ case DRUPAL_BOOTSTRAP_PAGE_CACHE:
+ _drupal_bootstrap_page_cache();
+ break;
+
+ case DRUPAL_BOOTSTRAP_DATABASE:
+ _drupal_bootstrap_database();
+ break;
+
+ case DRUPAL_BOOTSTRAP_VARIABLES:
+ _drupal_bootstrap_variables();
+ break;
+
+ case DRUPAL_BOOTSTRAP_SESSION:
+ require_once DRUPAL_ROOT . '/' . variable_get('session_inc', 'core/includes/session.inc');
+ drupal_session_initialize();
+ break;
+
+ case DRUPAL_BOOTSTRAP_PAGE_HEADER:
+ _drupal_bootstrap_page_header();
+ break;
+
+ case DRUPAL_BOOTSTRAP_LANGUAGE:
+ drupal_language_initialize();
+ break;
+
+ case DRUPAL_BOOTSTRAP_FULL:
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ _drupal_bootstrap_full();
+ break;
+ }
+ }
+ }
+ return $stored_phase;
+}
+
+/**
+ * Return the time zone of the current user.
+ */
+function drupal_get_user_timezone() {
+ global $user;
+ if (variable_get('configurable_timezones', 1) && $user->uid && $user->timezone) {
+ return $user->timezone;
+ }
+ else {
+ // Ignore PHP strict notice if time zone has not yet been set in the php.ini
+ // configuration.
+ return variable_get('date_default_timezone', @date_default_timezone_get());
+ }
+}
+
+/**
+ * Custom PHP error handler.
+ *
+ * @param $error_level
+ * The level of the error raised.
+ * @param $message
+ * The error message.
+ * @param $filename
+ * The filename that the error was raised in.
+ * @param $line
+ * The line number the error was raised at.
+ * @param $context
+ * An array that points to the active symbol table at the point the error occurred.
+ */
+function _drupal_error_handler($error_level, $message, $filename, $line, $context) {
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ _drupal_error_handler_real($error_level, $message, $filename, $line, $context);
+}
+
+/**
+ * Custom PHP exception handler.
+ *
+ * Uncaught exceptions are those not enclosed in a try/catch block. They are
+ * always fatal: the execution of the script will stop as soon as the exception
+ * handler exits.
+ *
+ * @param $exception
+ * The exception object that was thrown.
+ */
+function _drupal_exception_handler($exception) {
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+
+ try {
+ // Log the message to the watchdog and return an error page to the user.
+ _drupal_log_error(_drupal_decode_exception($exception), TRUE);
+ }
+ catch (Exception $exception2) {
+ // Another uncaught exception was thrown while handling the first one.
+ // If we are displaying errors, then do so with no possibility of a further uncaught exception being thrown.
+ if (error_displayable()) {
+ print '<h1>Additional uncaught exception thrown while handling exception.</h1>';
+ print '<h2>Original</h2><p>' . _drupal_render_exception_safe($exception) . '</p>';
+ print '<h2>Additional</h2><p>' . _drupal_render_exception_safe($exception2) . '</p><hr />';
+ }
+ }
+}
+
+/**
+ * Bootstrap configuration: Setup script environment and load settings.php.
+ */
+function _drupal_bootstrap_configuration() {
+ // Set the Drupal custom error handler.
+ set_error_handler('_drupal_error_handler');
+ set_exception_handler('_drupal_exception_handler');
+
+ drupal_environment_initialize();
+ // Start a page timer:
+ timer_start('page');
+ // Initialize the configuration, including variables from settings.php.
+ drupal_settings_initialize();
+
+ // Hook up the Symfony ClassLoader for loading PSR-0-compatible classes.
+ require_once(DRUPAL_ROOT . '/core/includes/Symfony/Component/ClassLoader/UniversalClassLoader.php');
+
+ // By default, use the UniversalClassLoader which is best for development,
+ // as it does not break when code is moved on the file system. It is slow,
+ // however, so for production the APC class loader should be used instead.
+ // @todo Switch to a cleaner way to switch autoloaders than variable_get().
+ switch (variable_get('autoloader_mode', 'default')) {
+ case 'apc':
+ if (function_exists('apc_store')) {
+ require_once(DRUPAL_ROOT . '/core/includes/Symfony/Component/ClassLoader/ApcUniversalClassLoader.php');
+ $loader = new \Symfony\Component\ClassLoader\ApcUniversalClassLoader('drupal.' . $GLOBALS['drupal_hash_salt']);
+ break;
+ }
+ // If APC was not loaded, fall through to the default loader so that
+ // the site does not fail completely.
+ case 'dev':
+ case 'default':
+ default:
+ $loader = new \Symfony\Component\ClassLoader\UniversalClassLoader();
+ break;
+ }
+
+ // Register classes with namespaces.
+ $loader->registerNamespaces(array(
+ // All Symfony-borrowed code lives in /core/includes/Symfony.
+ 'Symfony' => DRUPAL_ROOT . '/core/includes',
+ // All Drupal-namespaced code in core lives in /core/includes/Drupal.
+ 'Drupal' => DRUPAL_ROOT . '/core/includes',
+ ));
+
+ // Activate the autoloader.
+ $loader->register();
+}
+
+/**
+ * Bootstrap page cache: Try to serve a page from cache.
+ */
+function _drupal_bootstrap_page_cache() {
+ global $user;
+
+ // Allow specifying special cache handlers in settings.php, like
+ // using memcached or files for storing cache information.
+ require_once DRUPAL_ROOT . '/core/includes/cache.inc';
+ foreach (variable_get('cache_backends', array()) as $include) {
+ require_once DRUPAL_ROOT . '/' . $include;
+ }
+ // Check for a cache mode force from settings.php.
+ if (variable_get('page_cache_without_database')) {
+ $cache_enabled = TRUE;
+ }
+ else {
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_VARIABLES, FALSE);
+ $cache_enabled = variable_get('cache');
+ }
+ drupal_block_denied(ip_address());
+ // If there is no session cookie and cache is enabled (or forced), try
+ // to serve a cached page.
+ if (!isset($_COOKIE[session_name()]) && $cache_enabled) {
+ // Make sure there is a user object because its timestamp will be
+ // checked, hook_boot might check for anonymous user etc.
+ $user = drupal_anonymous_user();
+ // Get the page from the cache.
+ $cache = drupal_page_get_cache();
+ // If there is a cached page, display it.
+ if (is_object($cache)) {
+ header('X-Drupal-Cache: HIT');
+ // Restore the metadata cached with the page.
+ $_GET['q'] = $cache->data['path'];
+ drupal_set_title($cache->data['title'], PASS_THROUGH);
+ date_default_timezone_set(drupal_get_user_timezone());
+ // If the skipping of the bootstrap hooks is not enforced, call
+ // hook_boot.
+ if (variable_get('page_cache_invoke_hooks', TRUE)) {
+ bootstrap_invoke_all('boot');
+ }
+ drupal_serve_page_from_cache($cache);
+ // If the skipping of the bootstrap hooks is not enforced, call
+ // hook_exit.
+ if (variable_get('page_cache_invoke_hooks', TRUE)) {
+ bootstrap_invoke_all('exit');
+ }
+ // We are done.
+ exit;
+ }
+ else {
+ header('X-Drupal-Cache: MISS');
+ }
+ }
+}
+
+/**
+ * Bootstrap database: Initialize database system and register autoload functions.
+ */
+function _drupal_bootstrap_database() {
+ // Redirect the user to the installation script if Drupal has not been
+ // installed yet (i.e., if no $databases array has been defined in the
+ // settings.php file) and we are not already installing.
+ if (empty($GLOBALS['databases']) && !drupal_installation_attempted()) {
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+ install_goto('core/install.php');
+ }
+
+ // The user agent header is used to pass a database prefix in the request when
+ // running tests. However, for security reasons, it is imperative that we
+ // validate we ourselves made the request.
+ if ($test_prefix = drupal_valid_test_ua()) {
+ // Set the test run id for use in other parts of Drupal.
+ $test_info = &$GLOBALS['drupal_test_info'];
+ $test_info['test_run_id'] = $test_prefix;
+ $test_info['in_child_site'] = TRUE;
+
+ foreach ($GLOBALS['databases']['default'] as &$value) {
+ // Extract the current default database prefix.
+ if (!isset($value['prefix'])) {
+ $current_prefix = '';
+ }
+ elseif (is_array($value['prefix'])) {
+ $current_prefix = $value['prefix']['default'];
+ }
+ else {
+ $current_prefix = $value['prefix'];
+ }
+
+ // Remove the current database prefix and replace it by our own.
+ $value['prefix'] = array(
+ 'default' => $current_prefix . $test_prefix,
+ );
+ }
+ }
+
+ // Initialize the database system. Note that the connection
+ // won't be initialized until it is actually requested.
+ require_once DRUPAL_ROOT . '/core/includes/database/database.inc';
+
+ // Register autoload functions so that we can access classes and interfaces.
+ // The database autoload routine comes first so that we can load the database
+ // system without hitting the database. That is especially important during
+ // the install or upgrade process.
+ spl_autoload_register('drupal_autoload_class');
+ spl_autoload_register('drupal_autoload_interface');
+}
+
+/**
+ * Bootstrap variables: Load system variables and all enabled bootstrap modules.
+ */
+function _drupal_bootstrap_variables() {
+ global $conf;
+
+ // Initialize the lock system.
+ require_once DRUPAL_ROOT . '/' . variable_get('lock_inc', 'core/includes/lock.inc');
+ lock_initialize();
+
+ // Load variables from the database, but do not overwrite variables set in settings.php.
+ $conf = variable_initialize(isset($conf) ? $conf : array());
+ // Load bootstrap modules.
+ require_once DRUPAL_ROOT . '/core/includes/module.inc';
+ module_load_all(TRUE);
+}
+
+/**
+ * Bootstrap page header: Invoke hook_boot(), initialize locking system, and send default HTTP headers.
+ */
+function _drupal_bootstrap_page_header() {
+ bootstrap_invoke_all('boot');
+
+ if (!drupal_is_cli()) {
+ ob_start();
+ drupal_page_header();
+ }
+}
+
+/**
+ * Returns the current bootstrap phase for this Drupal process.
+ *
+ * The current phase is the one most recently completed by drupal_bootstrap().
+ *
+ * @see drupal_bootstrap()
+ */
+function drupal_get_bootstrap_phase() {
+ return drupal_bootstrap();
+}
+
+/**
+ * Checks the current User-Agent string to see if this is an internal request
+ * from SimpleTest. If so, returns the test prefix for this test.
+ *
+ * @return
+ * Either the simpletest prefix (the string "simpletest" followed by any
+ * number of digits) or FALSE if the user agent does not contain a valid
+ * HMAC and timestamp.
+ */
+function drupal_valid_test_ua() {
+ global $drupal_hash_salt;
+ // No reason to reset this.
+ static $test_prefix;
+
+ if (isset($test_prefix)) {
+ return $test_prefix;
+ }
+
+ if (isset($_SERVER['HTTP_USER_AGENT']) && preg_match("/^(simpletest\d+);(.+);(.+);(.+)$/", $_SERVER['HTTP_USER_AGENT'], $matches)) {
+ list(, $prefix, $time, $salt, $hmac) = $matches;
+ $check_string = $prefix . ';' . $time . ';' . $salt;
+ // We use the salt from settings.php to make the HMAC key, since
+ // the database is not yet initialized and we can't access any Drupal variables.
+ // The file properties add more entropy not easily accessible to others.
+ $key = $drupal_hash_salt . filectime(__FILE__) . fileinode(__FILE__);
+ $time_diff = REQUEST_TIME - $time;
+ // Since we are making a local request a 5 second time window is allowed,
+ // and the HMAC must match.
+ if ($time_diff >= 0 && $time_diff <= 5 && $hmac == drupal_hmac_base64($check_string, $key)) {
+ $test_prefix = $prefix;
+ return $test_prefix;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Generate a user agent string with a HMAC and timestamp for simpletest.
+ */
+function drupal_generate_test_ua($prefix) {
+ global $drupal_hash_salt;
+ static $key;
+
+ if (!isset($key)) {
+ // We use the salt from settings.php to make the HMAC key, since
+ // the database is not yet initialized and we can't access any Drupal variables.
+ // The file properties add more entropy not easily accessible to others.
+ $key = $drupal_hash_salt . filectime(__FILE__) . fileinode(__FILE__);
+ }
+ // Generate a moderately secure HMAC based on the database credentials.
+ $salt = uniqid('', TRUE);
+ $check_string = $prefix . ';' . time() . ';' . $salt;
+ return $check_string . ';' . drupal_hmac_base64($check_string, $key);
+}
+
+/**
+ * Enables use of the theme system without requiring database access.
+ *
+ * Loads and initializes the theme system for site installs, updates and when
+ * the site is in maintenance mode. This also applies when the database fails.
+ *
+ * @see _drupal_maintenance_theme()
+ */
+function drupal_maintenance_theme() {
+ require_once DRUPAL_ROOT . '/core/includes/theme.maintenance.inc';
+ _drupal_maintenance_theme();
+}
+
+/**
+ * Returns a simple 404 Not Found page.
+ *
+ * If fast 404 pages are enabled, and this is a matching page then print a
+ * simple 404 page and exit.
+ *
+ * This function is called from drupal_deliver_html_page() at the time when a
+ * a normal 404 page is generated, but it can also optionally be called directly
+ * from settings.php to prevent a Drupal bootstrap on these pages. See
+ * documentation in settings.php for the benefits and drawbacks of using this.
+ *
+ * Paths to dynamically-generated content, such as image styles, should also be
+ * accounted for in this function.
+ */
+function drupal_fast_404() {
+ $exclude_paths = variable_get('404_fast_paths_exclude', FALSE);
+ if ($exclude_paths && !preg_match($exclude_paths, $_GET['q'])) {
+ $fast_paths = variable_get('404_fast_paths', FALSE);
+ if ($fast_paths && preg_match($fast_paths, $_GET['q'])) {
+ drupal_add_http_header('Status', '404 Not Found');
+ $fast_404_html = variable_get('404_fast_html', '<html xmlns="http://www.w3.org/1999/xhtml"><head><title>404 Not Found</title></head><body><h1>Not Found</h1><p>The requested URL "@path" was not found on this server.</p></body></html>');
+ // Replace @path in the variable with the page path.
+ print strtr($fast_404_html, array('@path' => check_plain(request_uri())));
+ exit;
+ }
+ }
+}
+
+/**
+ * Return TRUE if a Drupal installation is currently being attempted.
+ */
+function drupal_installation_attempted() {
+ return defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'install';
+}
+
+/**
+ * Returns the name of the proper localization function.
+ *
+ * get_t() exists to support localization for code that might run during
+ * the installation phase, when some elements of the system might not have
+ * loaded.
+ *
+ * This would include implementations of hook_install(), which could run
+ * during the Drupal installation phase, and might also be run during
+ * non-installation time, such as while installing the module from the the
+ * module administration page.
+ *
+ * Example usage:
+ * @code
+ * $t = get_t();
+ * $translated = $t('translate this');
+ * @endcode
+ *
+ * Use t() if your code will never run during the Drupal installation phase.
+ * Use st() if your code will only run during installation and never any other
+ * time. Use get_t() if your code could run in either circumstance.
+ *
+ * @see t()
+ * @see st()
+ * @ingroup sanitization
+ */
+function get_t() {
+ static $t;
+ // This is not converted to drupal_static because there is no point in
+ // resetting this as it can not change in the course of a request.
+ if (!isset($t)) {
+ $t = drupal_installation_attempted() ? 'st' : 't';
+ }
+ return $t;
+}
+
+/**
+ * Initialize all the defined language types.
+ */
+function drupal_language_initialize() {
+ $types = language_types();
+
+ // Ensure the language is correctly returned, even without multilanguage
+ // support. Also make sure we have a $language fallback, in case a language
+ // negotiation callback needs to do a full bootstrap.
+ // Useful for eg. XML/HTML 'lang' attributes.
+ $default = language_default();
+ foreach ($types as $type) {
+ $GLOBALS[$type] = $default;
+ }
+ if (drupal_multilingual()) {
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+ foreach ($types as $type) {
+ $GLOBALS[$type] = language_initialize($type);
+ }
+ // Allow modules to react on language system initialization in multilingual
+ // environments.
+ bootstrap_invoke_all('language_init');
+ }
+}
+
+/**
+ * The built-in language types.
+ *
+ * @return
+ * An array of key-values pairs where the key is the language type and the
+ * value is its configurability.
+ */
+function drupal_language_types() {
+ return array(
+ LANGUAGE_TYPE_INTERFACE => TRUE,
+ LANGUAGE_TYPE_CONTENT => FALSE,
+ LANGUAGE_TYPE_URL => FALSE,
+ );
+}
+
+/**
+ * Return true if there is more than one language enabled.
+ */
+function drupal_multilingual() {
+ // The "language_count" variable stores the number of enabled languages to
+ // avoid unnecessarily querying the database when building the list of
+ // enabled languages on monolingual sites.
+ return variable_get('language_count', 1) > 1;
+}
+
+/**
+ * Return an array of the available language types.
+ */
+function language_types() {
+ return array_keys(variable_get('language_types', drupal_language_types()));
+}
+
+/**
+ * Returns a list of installed languages, indexed by the specified key.
+ *
+ * @param $field
+ * (optional) The field to index the list with.
+ *
+ * @return
+ * An associative array, keyed on the values of $field.
+ * - If $field is 'weight' or 'enabled', the array is nested, with the outer
+ * array's values each being associative arrays with language codes as
+ * keys and language objects as values.
+ * - For all other values of $field, the array is only one level deep, and
+ * the array's values are language objects.
+ */
+function language_list($field = 'language') {
+ $languages = &drupal_static(__FUNCTION__);
+ // Init language list
+ if (!isset($languages)) {
+ $default = language_default();
+ if (drupal_multilingual() || module_exists('locale')) {
+ $languages['language'] = db_query('SELECT * FROM {languages} ORDER BY weight ASC, name ASC')->fetchAllAssoc('language');
+ }
+ else {
+ // No locale module, so use the default language only.
+ $languages['language'][$default->language] = $default;
+ }
+
+ // Initialize default property so callers have an easy reference and
+ // can save the same object without data loss.
+ foreach ($languages['language'] as $langcode => $language) {
+ $languages['language'][$langcode]->default = ($langcode == $default->language);
+ }
+ }
+
+ // Return the array indexed by the right field
+ if (!isset($languages[$field])) {
+ $languages[$field] = array();
+ foreach ($languages['language'] as $lang) {
+ // Some values should be collected into an array
+ if (in_array($field, array('enabled', 'weight'))) {
+ $languages[$field][$lang->$field][$lang->language] = $lang;
+ }
+ else {
+ $languages[$field][$lang->$field] = $lang;
+ }
+ }
+ }
+ return $languages[$field];
+}
+
+/**
+ * Default language used on the site.
+ *
+ * @return
+ * A language object.
+ */
+function language_default() {
+ return variable_get(
+ 'language_default',
+ (object) array(
+ 'language' => 'en',
+ 'name' => 'English',
+ 'direction' => 0,
+ 'enabled' => 1,
+ 'plurals' => 0,
+ 'formula' => '',
+ 'domain' => '',
+ 'prefix' => '',
+ 'weight' => 0,
+ 'javascript' => ''
+ )
+ );
+}
+
+/**
+ * Returns the requested URL path of the page being viewed.
+ *
+ * Examples:
+ * - http://example.com/node/306 returns "node/306".
+ * - http://example.com/drupalfolder/node/306 returns "node/306" while
+ * base_path() returns "/drupalfolder/".
+ * - http://example.com/path/alias (which is a path alias for node/306) returns
+ * "path/alias" as opposed to the internal path.
+ * - http://example.com/index.php returns an empty string (meaning: front page).
+ * - http://example.com/index.php?page=1 returns an empty string.
+ *
+ * @return
+ * The requested Drupal URL path.
+ *
+ * @see current_path()
+ */
+function request_path() {
+ static $path;
+
+ if (isset($path)) {
+ return $path;
+ }
+
+ if (isset($_GET['q'])) {
+ // This is a request with a ?q=foo/bar query string. $_GET['q'] is
+ // overwritten in drupal_path_initialize(), but request_path() is called
+ // very early in the bootstrap process, so the original value is saved in
+ // $path and returned in later calls.
+ $path = $_GET['q'];
+ }
+ elseif (isset($_SERVER['REQUEST_URI'])) {
+ // This request is either a clean URL, or 'index.php', or nonsense.
+ // Extract the path from REQUEST_URI.
+ $request_path = strtok($_SERVER['REQUEST_URI'], '?');
+ $base_path_len = strlen(rtrim(dirname($_SERVER['SCRIPT_NAME']), '\/'));
+ // Unescape and strip $base_path prefix, leaving q without a leading slash.
+ $path = substr(urldecode($request_path), $base_path_len + 1);
+ // If the path equals the script filename, either because 'index.php' was
+ // explicitly provided in the URL, or because the server added it to
+ // $_SERVER['REQUEST_URI'] even when it wasn't provided in the URL (some
+ // versions of Microsoft IIS do this), the front page should be served.
+ if ($path == basename($_SERVER['PHP_SELF'])) {
+ $path = '';
+ }
+ }
+ else {
+ // This is the front page.
+ $path = '';
+ }
+
+ // Under certain conditions Apache's RewriteRule directive prepends the value
+ // assigned to $_GET['q'] with a slash. Moreover we can always have a trailing
+ // slash in place, hence we need to normalize $_GET['q'].
+ $path = trim($path, '/');
+
+ return $path;
+}
+
+/**
+ * Return a component of the current Drupal path.
+ *
+ * When viewing a page at the path "admin/structure/types", for example, arg(0)
+ * returns "admin", arg(1) returns "structure", and arg(2) returns "types".
+ *
+ * Avoid use of this function where possible, as resulting code is hard to read.
+ * In menu callback functions, attempt to use named arguments. See the explanation
+ * in menu.inc for how to construct callbacks that take arguments. When attempting
+ * to use this function to load an element from the current path, e.g. loading the
+ * node on a node page, please use menu_get_object() instead.
+ *
+ * @param $index
+ * The index of the component, where each component is separated by a '/'
+ * (forward-slash), and where the first component has an index of 0 (zero).
+ * @param $path
+ * A path to break into components. Defaults to the path of the current page.
+ *
+ * @return
+ * The component specified by $index, or NULL if the specified component was
+ * not found. If called without arguments, it returns an array containing all
+ * the components of the current path.
+ */
+function arg($index = NULL, $path = NULL) {
+ // Even though $arguments doesn't need to be resettable for any functional
+ // reasons (the result of explode() does not depend on any run-time
+ // information), it should be resettable anyway in case a module needs to
+ // free up the memory used by it.
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['arguments'] = &drupal_static(__FUNCTION__);
+ }
+ $arguments = &$drupal_static_fast['arguments'];
+
+ if (!isset($path)) {
+ $path = $_GET['q'];
+ }
+ if (!isset($arguments[$path])) {
+ $arguments[$path] = explode('/', $path);
+ }
+ if (!isset($index)) {
+ return $arguments[$path];
+ }
+ if (isset($arguments[$path][$index])) {
+ return $arguments[$path][$index];
+ }
+}
+
+/**
+ * If Drupal is behind a reverse proxy, we use the X-Forwarded-For header
+ * instead of $_SERVER['REMOTE_ADDR'], which would be the IP address of
+ * the proxy server, and not the client's. The actual header name can be
+ * configured by the reverse_proxy_header variable.
+ *
+ * @return
+ * IP address of client machine, adjusted for reverse proxy and/or cluster
+ * environments.
+ */
+function ip_address() {
+ $ip_address = &drupal_static(__FUNCTION__);
+
+ if (!isset($ip_address)) {
+ $ip_address = $_SERVER['REMOTE_ADDR'];
+
+ if (variable_get('reverse_proxy', 0)) {
+ $reverse_proxy_header = variable_get('reverse_proxy_header', 'HTTP_X_FORWARDED_FOR');
+ if (!empty($_SERVER[$reverse_proxy_header])) {
+ // If an array of known reverse proxy IPs is provided, then trust
+ // the XFF header if request really comes from one of them.
+ $reverse_proxy_addresses = variable_get('reverse_proxy_addresses', array());
+
+ // Turn XFF header into an array.
+ $forwarded = explode(',', $_SERVER[$reverse_proxy_header]);
+
+ // Trim the forwarded IPs; they may have been delimited by commas and spaces.
+ $forwarded = array_map('trim', $forwarded);
+
+ // Tack direct client IP onto end of forwarded array.
+ $forwarded[] = $ip_address;
+
+ // Eliminate all trusted IPs.
+ $untrusted = array_diff($forwarded, $reverse_proxy_addresses);
+
+ // The right-most IP is the most specific we can trust.
+ $ip_address = array_pop($untrusted);
+ }
+ }
+ }
+
+ return $ip_address;
+}
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+/**
+ * Get the schema definition of a table, or the whole database schema.
+ *
+ * The returned schema will include any modifications made by any
+ * module that implements hook_schema_alter().
+ *
+ * @param $table
+ * The name of the table. If not given, the schema of all tables is returned.
+ * @param $rebuild
+ * If true, the schema will be rebuilt instead of retrieved from the cache.
+ */
+function drupal_get_schema($table = NULL, $rebuild = FALSE) {
+ static $schema;
+
+ if ($rebuild || !isset($table)) {
+ $schema = drupal_get_complete_schema($rebuild);
+ }
+ elseif (!isset($schema)) {
+ $schema = new SchemaCache();
+ }
+
+ if (!isset($table)) {
+ return $schema;
+ }
+ if (isset($schema[$table])) {
+ return $schema[$table];
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Extends DrupalCacheArray to allow for dynamic building of the schema cache.
+ */
+class SchemaCache extends DrupalCacheArray {
+
+ public function __construct() {
+ // Cache by request method.
+ parent::__construct('schema:runtime:' . ($_SERVER['REQUEST_METHOD'] == 'GET'), 'cache');
+ }
+
+ protected function resolveCacheMiss($offset) {
+ $complete_schema = drupal_get_complete_schema();
+ $value = isset($complete_schema[$offset]) ? $complete_schema[$offset] : NULL;
+ $this->storage[$offset] = $value;
+ $this->persist($offset);
+ return $value;
+ }
+}
+
+/**
+ * Get the whole database schema.
+ *
+ * The returned schema will include any modifications made by any
+ * module that implements hook_schema_alter().
+ *
+ * @param $rebuild
+ * If true, the schema will be rebuilt instead of retrieved from the cache.
+ */
+function drupal_get_complete_schema($rebuild = FALSE) {
+ static $schema = array();
+
+ if (empty($schema) || $rebuild) {
+ // Try to load the schema from cache.
+ if (!$rebuild && $cached = cache()->get('schema')) {
+ $schema = $cached->data;
+ }
+ // Otherwise, rebuild the schema cache.
+ else {
+ $schema = array();
+ // Load the .install files to get hook_schema.
+ // On some databases this function may be called before bootstrap has
+ // been completed, so we force the functions we need to load just in case.
+ if (function_exists('module_load_all_includes')) {
+ // This function can be called very early in the bootstrap process, so
+ // we force the module_list() cache to be refreshed to ensure that it
+ // contains the complete list of modules before we go on to call
+ // module_load_all_includes().
+ module_list(TRUE);
+ module_load_all_includes('install');
+ }
+
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ // Invoke hook_schema for all modules.
+ foreach (module_implements('schema') as $module) {
+ // Cast the result of hook_schema() to an array, as a NULL return value
+ // would cause array_merge() to set the $schema variable to NULL as well.
+ // That would break modules which use $schema further down the line.
+ $current = (array) module_invoke($module, 'schema');
+ // Set 'module' and 'name' keys for each table, and remove descriptions,
+ // as they needlessly slow down cache()->get() for every single request.
+ _drupal_schema_initialize($current, $module);
+ $schema = array_merge($schema, $current);
+ }
+
+ drupal_alter('schema', $schema);
+ // If the schema is empty, avoid saving it: some database engines require
+ // the schema to perform queries, and this could lead to infinite loops.
+ if (!empty($schema) && (drupal_get_bootstrap_phase() == DRUPAL_BOOTSTRAP_FULL)) {
+ cache()->set('schema', $schema);
+ }
+ if ($rebuild) {
+ cache()->deletePrefix('schema:');
+ }
+ }
+ }
+
+ return $schema;
+}
+
+/**
+ * @} End of "ingroup schemaapi".
+ */
+
+
+/**
+ * @ingroup registry
+ * @{
+ */
+
+/**
+ * Confirm that an interface is available.
+ *
+ * This function is rarely called directly. Instead, it is registered as an
+ * spl_autoload() handler, and PHP calls it for us when necessary.
+ *
+ * @param $interface
+ * The name of the interface to check or load.
+ * @return
+ * TRUE if the interface is currently available, FALSE otherwise.
+ */
+function drupal_autoload_interface($interface) {
+ return _registry_check_code('interface', $interface);
+}
+
+/**
+ * Confirm that a class is available.
+ *
+ * This function is rarely called directly. Instead, it is registered as an
+ * spl_autoload() handler, and PHP calls it for us when necessary.
+ *
+ * @param $class
+ * The name of the class to check or load.
+ * @return
+ * TRUE if the class is currently available, FALSE otherwise.
+ */
+function drupal_autoload_class($class) {
+ return _registry_check_code('class', $class);
+}
+
+/**
+ * Helper to check for a resource in the registry.
+ *
+ * @param $type
+ * The type of resource we are looking up, or one of the constants
+ * REGISTRY_RESET_LOOKUP_CACHE or REGISTRY_WRITE_LOOKUP_CACHE, which
+ * signal that we should reset or write the cache, respectively.
+ * @param $name
+ * The name of the resource, or NULL if either of the REGISTRY_* constants
+ * is passed in.
+ * @return
+ * TRUE if the resource was found, FALSE if not.
+ * NULL if either of the REGISTRY_* constants is passed in as $type.
+ */
+function _registry_check_code($type, $name = NULL) {
+ static $lookup_cache, $cache_update_needed;
+
+ if ($type == 'class' && class_exists($name) || $type == 'interface' && interface_exists($name)) {
+ return TRUE;
+ }
+
+ if (!isset($lookup_cache)) {
+ $lookup_cache = array();
+ if ($cache = cache('bootstrap')->get('lookup_cache')) {
+ $lookup_cache = $cache->data;
+ }
+ }
+
+ // When we rebuild the registry, we need to reset this cache so
+ // we don't keep lookups for resources that changed during the rebuild.
+ if ($type == REGISTRY_RESET_LOOKUP_CACHE) {
+ $cache_update_needed = TRUE;
+ $lookup_cache = NULL;
+ return;
+ }
+
+ // Called from drupal_page_footer, we write to permanent storage if there
+ // changes to the lookup cache for this request.
+ if ($type == REGISTRY_WRITE_LOOKUP_CACHE) {
+ if ($cache_update_needed) {
+ cache('bootstrap')->set('lookup_cache', $lookup_cache);
+ }
+ return;
+ }
+
+ // $type is either 'interface' or 'class', so we only need the first letter to
+ // keep the cache key unique.
+ $cache_key = $type[0] . $name;
+ if (isset($lookup_cache[$cache_key])) {
+ if ($lookup_cache[$cache_key]) {
+ require_once DRUPAL_ROOT . '/' . $lookup_cache[$cache_key];
+ }
+ return (bool) $lookup_cache[$cache_key];
+ }
+
+ // This function may get called when the default database is not active, but
+ // there is no reason we'd ever want to not use the default database for
+ // this query.
+ $file = Database::getConnection('default', 'default')->query("SELECT filename FROM {registry} WHERE name = :name AND type = :type", array(
+ ':name' => $name,
+ ':type' => $type,
+ ))
+ ->fetchField();
+
+ // Flag that we've run a lookup query and need to update the cache.
+ $cache_update_needed = TRUE;
+
+ // Misses are valuable information worth caching, so cache even if
+ // $file is FALSE.
+ $lookup_cache[$cache_key] = $file;
+
+ if ($file) {
+ require_once DRUPAL_ROOT . '/' . $file;
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Rescan all enabled modules and rebuild the registry.
+ *
+ * Rescans all code in modules or includes directories, storing the location of
+ * each interface or class in the database.
+ */
+function registry_rebuild() {
+ system_rebuild_module_data();
+ registry_update();
+}
+
+/**
+ * Update the registry based on the latest files listed in the database.
+ *
+ * This function should be used when system_rebuild_module_data() does not need
+ * to be called, because it is already known that the list of files in the
+ * {system} table matches those in the file system.
+ *
+ * @see registry_rebuild()
+ */
+function registry_update() {
+ require_once DRUPAL_ROOT . '/core/includes/registry.inc';
+ _registry_update();
+}
+
+/**
+ * @} End of "ingroup registry".
+ */
+
+/**
+ * Central static variable storage.
+ *
+ * All functions requiring a static variable to persist or cache data within
+ * a single page request are encouraged to use this function unless it is
+ * absolutely certain that the static variable will not need to be reset during
+ * the page request. By centralizing static variable storage through this
+ * function, other functions can rely on a consistent API for resetting any
+ * other function's static variables.
+ *
+ * Example:
+ * @code
+ * function language_list($field = 'language') {
+ * $languages = &drupal_static(__FUNCTION__);
+ * if (!isset($languages)) {
+ * // If this function is being called for the first time after a reset,
+ * // query the database and execute any other code needed to retrieve
+ * // information about the supported languages.
+ * ...
+ * }
+ * if (!isset($languages[$field])) {
+ * // If this function is being called for the first time for a particular
+ * // index field, then execute code needed to index the information already
+ * // available in $languages by the desired field.
+ * ...
+ * }
+ * // Subsequent invocations of this function for a particular index field
+ * // skip the above two code blocks and quickly return the already indexed
+ * // information.
+ * return $languages[$field];
+ * }
+ * function locale_translate_overview_screen() {
+ * // When building the content for the translations overview page, make
+ * // sure to get completely fresh information about the supported languages.
+ * drupal_static_reset('language_list');
+ * ...
+ * }
+ * @endcode
+ *
+ * In a few cases, a function can have certainty that there is no legitimate
+ * use-case for resetting that function's static variable. This is rare,
+ * because when writing a function, it's hard to forecast all the situations in
+ * which it will be used. A guideline is that if a function's static variable
+ * does not depend on any information outside of the function that might change
+ * during a single page request, then it's ok to use the "static" keyword
+ * instead of the drupal_static() function.
+ *
+ * Example:
+ * @code
+ * function actions_do(...) {
+ * // $stack tracks the number of recursive calls.
+ * static $stack;
+ * $stack++;
+ * if ($stack > variable_get('actions_max_stack', 35)) {
+ * ...
+ * return;
+ * }
+ * ...
+ * $stack--;
+ * }
+ * @endcode
+ *
+ * In a few cases, a function needs a resettable static variable, but the
+ * function is called many times (100+) during a single page request, so
+ * every microsecond of execution time that can be removed from the function
+ * counts. These functions can use a more cumbersome, but faster variant of
+ * calling drupal_static(). It works by storing the reference returned by
+ * drupal_static() in the calling function's own static variable, thereby
+ * removing the need to call drupal_static() for each iteration of the function.
+ * Conceptually, it replaces:
+ * @code
+ * $foo = &drupal_static(__FUNCTION__);
+ * @endcode
+ * with:
+ * @code
+ * // Unfortunately, this does not work.
+ * static $foo = &drupal_static(__FUNCTION__);
+ * @endcode
+ * However, the above line of code does not work, because PHP only allows static
+ * variables to be initializied by literal values, and does not allow static
+ * variables to be assigned to references.
+ * - http://php.net/manual/en/language.variables.scope.php#language.variables.scope.static
+ * - http://php.net/manual/en/language.variables.scope.php#language.variables.scope.references
+ * The example below shows the syntax needed to work around both limitations.
+ * For benchmarks and more information, see http://drupal.org/node/619666.
+ *
+ * Example:
+ * @code
+ * function user_access($string, $account = NULL) {
+ * // Use the advanced drupal_static() pattern, since this is called very often.
+ * static $drupal_static_fast;
+ * if (!isset($drupal_static_fast)) {
+ * $drupal_static_fast['perm'] = &drupal_static(__FUNCTION__);
+ * }
+ * $perm = &$drupal_static_fast['perm'];
+ * ...
+ * }
+ * @endcode
+ *
+ * @param $name
+ * Globally unique name for the variable. For a function with only one static,
+ * variable, the function name (e.g. via the PHP magic __FUNCTION__ constant)
+ * is recommended. For a function with multiple static variables add a
+ * distinguishing suffix to the function name for each one.
+ * @param $default_value
+ * Optional default value.
+ * @param $reset
+ * TRUE to reset a specific named variable, or all variables if $name is NULL.
+ * Resetting every variable should only be used, for example, for running
+ * unit tests with a clean environment. Should be used only though via
+ * function drupal_static_reset() and the return value should not be used in
+ * this case.
+ *
+ * @return
+ * Returns a variable by reference.
+ *
+ * @see drupal_static_reset()
+ */
+function &drupal_static($name, $default_value = NULL, $reset = FALSE) {
+ static $data = array(), $default = array();
+ // First check if dealing with a previously defined static variable.
+ if (isset($data[$name]) || array_key_exists($name, $data)) {
+ // Non-NULL $name and both $data[$name] and $default[$name] statics exist.
+ if ($reset) {
+ // Reset pre-existing static variable to its default value.
+ $data[$name] = $default[$name];
+ }
+ return $data[$name];
+ }
+ // Neither $data[$name] nor $default[$name] static variables exist.
+ if (isset($name)) {
+ if ($reset) {
+ // Reset was called before a default is set and yet a variable must be
+ // returned.
+ return $data;
+ }
+ // First call with new non-NULL $name. Initialize a new static variable.
+ $default[$name] = $data[$name] = $default_value;
+ return $data[$name];
+ }
+ // Reset all: ($name == NULL). This needs to be done one at a time so that
+ // references returned by earlier invocations of drupal_static() also get
+ // reset.
+ foreach ($default as $name => $value) {
+ $data[$name] = $value;
+ }
+ // As the function returns a reference, the return should always be a
+ // variable.
+ return $data;
+}
+
+/**
+ * Reset one or all centrally stored static variable(s).
+ *
+ * @param $name
+ * Name of the static variable to reset. Omit to reset all variables.
+ */
+function drupal_static_reset($name = NULL) {
+ drupal_static($name, NULL, TRUE);
+}
+
+/**
+ * Detect whether the current script is running in a command-line environment.
+ */
+function drupal_is_cli() {
+ return (!isset($_SERVER['SERVER_SOFTWARE']) && (php_sapi_name() == 'cli' || (is_numeric($_SERVER['argc']) && $_SERVER['argc'] > 0)));
+}
+
+/**
+ * Formats text for emphasized display in a placeholder inside a sentence.
+ * Used automatically by t().
+ *
+ * @param $text
+ * The text to format (plain-text).
+ *
+ * @return
+ * The formatted text (html).
+ */
+function drupal_placeholder($text) {
+ return '<em class="placeholder">' . check_plain($text) . '</em>';
+}
+
+/**
+ * Register a function for execution on shutdown.
+ *
+ * Wrapper for register_shutdown_function() that catches thrown exceptions to
+ * avoid "Exception thrown without a stack frame in Unknown".
+ *
+ * @param $callback
+ * The shutdown function to register.
+ * @param ...
+ * Additional arguments to pass to the shutdown function.
+ *
+ * @return
+ * Array of shutdown functions to be executed.
+ *
+ * @see register_shutdown_function()
+ * @ingroup php_wrappers
+ */
+function &drupal_register_shutdown_function($callback = NULL) {
+ // We cannot use drupal_static() here because the static cache is reset during
+ // batch processing, which breaks batch handling.
+ static $callbacks = array();
+
+ if (isset($callback)) {
+ // Only register the internal shutdown function once.
+ if (empty($callbacks)) {
+ register_shutdown_function('_drupal_shutdown_function');
+ }
+ $args = func_get_args();
+ array_shift($args);
+ // Save callback and arguments
+ $callbacks[] = array('callback' => $callback, 'arguments' => $args);
+ }
+ return $callbacks;
+}
+
+/**
+ * Internal function used to execute registered shutdown functions.
+ */
+function _drupal_shutdown_function() {
+ $callbacks = &drupal_register_shutdown_function();
+
+ // Set the CWD to DRUPAL_ROOT as it is not guaranteed to be the same as it
+ // was in the normal context of execution.
+ chdir(DRUPAL_ROOT);
+
+ try {
+ while (list($key, $callback) = each($callbacks)) {
+ call_user_func_array($callback['callback'], $callback['arguments']);
+ }
+ }
+ catch (Exception $exception) {
+ // If we are displaying errors, then do so with no possibility of a further uncaught exception being thrown.
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ if (error_displayable()) {
+ print '<h1>Uncaught exception thrown in shutdown function.</h1>';
+ print '<p>' . _drupal_render_exception_safe($exception) . '</p><hr />';
+ }
+ }
+}
diff --git a/core/includes/cache-install.inc b/core/includes/cache-install.inc
new file mode 100644
index 000000000000..8bcf8b7b1c17
--- /dev/null
+++ b/core/includes/cache-install.inc
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Provides a stub cache implementation to be used during installation.
+ */
+
+/**
+ * A stub cache implementation to be used during the installation process.
+ *
+ * The stub implementation is needed when database access is not yet available.
+ * Because Drupal's caching system never requires that cached data be present,
+ * these stub functions can short-circuit the process and sidestep the need for
+ * any persistent storage. Obviously, using this cache implementation during
+ * normal operations would have a negative impact on performance.
+ */
+class DrupalFakeCache extends DrupalDatabaseCache implements DrupalCacheInterface {
+ function get($cid) {
+ return FALSE;
+ }
+
+ function getMultiple(&$cids) {
+ return array();
+ }
+
+ function set($cid, $data, $expire = CACHE_PERMANENT) {
+ }
+
+ function deletePrefix($cid) {
+ try {
+ if (class_exists('Database')) {
+ parent::deletePrefix($cid);
+ }
+ }
+ catch (Exception $e) {
+ }
+ }
+
+ function clear($cid = NULL, $wildcard = FALSE) {
+ // If there is a database cache, attempt to clear it whenever possible. The
+ // reason for doing this is that the database cache can accumulate data
+ // during installation due to any full bootstraps that may occur at the
+ // same time (for example, Ajax requests triggered by the installer). If we
+ // didn't try to clear it whenever this function is called, the data in the
+ // cache would become stale; for example, the installer sometimes calls
+ // variable_set(), which updates the {variable} table and then clears the
+ // cache to make sure that the next page request picks up the new value.
+ // Not actually clearing the cache here therefore leads old variables to be
+ // loaded on the first page requests after installation, which can cause
+ // subtle bugs, some of which would not be fixed unless the site
+ // administrator cleared the cache manually.
+ try {
+ if (class_exists('Database')) {
+ parent::clear($cid, $wildcard);
+ }
+ }
+ // If the attempt at clearing the cache causes an error, that means that
+ // either the database connection is not set up yet or the relevant cache
+ // table in the database has not yet been created, so we can safely do
+ // nothing here.
+ catch (Exception $e) {
+ }
+ }
+
+ function isEmpty() {
+ return TRUE;
+ }
+}
diff --git a/core/includes/cache.inc b/core/includes/cache.inc
new file mode 100644
index 000000000000..fcf3e5e5187f
--- /dev/null
+++ b/core/includes/cache.inc
@@ -0,0 +1,618 @@
+<?php
+
+/**
+ * Factory for instantiating and statically caching the correct class for a cache bin.
+ *
+ * By default, this returns an instance of the DrupalDatabaseCache class.
+ * Classes implementing DrupalCacheInterface can register themselves both as a
+ * default implementation and for specific bins.
+ *
+ * @see DrupalCacheInterface
+ *
+ * @param $bin
+ * The cache bin for which the cache object should be returned, defaults to
+ * 'cache'.
+ * @return DrupalCacheInterface
+ * The cache object associated with the specified bin.
+ */
+function cache($bin = 'cache') {
+ // Temporary backwards compatibiltiy layer, allow old style prefixed cache
+ // bin names to be passed as arguments.
+ $bin = str_replace('cache_', '', $bin);
+
+ // We do not use drupal_static() here because we do not want to change the
+ // storage of a cache bin mid-request.
+ static $cache_objects;
+ if (!isset($cache_objects[$bin])) {
+ $class = variable_get('cache_class_' . $bin);
+ if (!isset($class)) {
+ $class = variable_get('cache_default_class', 'DrupalDatabaseCache');
+ }
+ $cache_objects[$bin] = new $class($bin);
+ }
+ return $cache_objects[$bin];
+}
+
+/**
+ * Return data from the persistent cache
+ *
+ * Data may be stored as either plain text or as serialized data. cache_get
+ * will automatically return unserialized objects and arrays.
+ *
+ * @param $cid
+ * The cache ID of the data to retrieve.
+ * @param $bin
+ * The cache bin to store the data in. Valid core values are 'cache_block',
+ * 'cache_bootstrap', 'cache_field', 'cache_filter', 'cache_form',
+ * 'cache_menu', 'cache_page', 'cache_path', 'cache_update' or 'cache' for
+ * the default cache.
+ *
+ * @return
+ * The cache or FALSE on failure.
+ */
+function cache_get($cid, $bin = 'cache') {
+ return cache($bin)->get($cid);
+}
+
+/**
+ * Return data from the persistent cache when given an array of cache IDs.
+ *
+ * @param $cids
+ * An array of cache IDs for the data to retrieve. This is passed by
+ * reference, and will have the IDs successfully returned from cache removed.
+ * @param $bin
+ * The cache bin where the data is stored.
+ * @return
+ * An array of the items successfully returned from cache indexed by cid.
+ */
+function cache_get_multiple(array &$cids, $bin = 'cache') {
+ return cache($bin)->getMultiple($cids);
+}
+
+/**
+ * Store data in the persistent cache.
+ *
+ * The persistent cache is split up into several cache bins. In the default
+ * cache implementation, each cache bin corresponds to a database table by the
+ * same name. Other implementations might want to store several bins in data
+ * structures that get flushed together. While it is not a problem for most
+ * cache bins if the entries in them are flushed before their expire time, some
+ * might break functionality or are extremely expensive to recalculate. These
+ * will be marked with a (*). The other bins expired automatically by core.
+ * Contributed modules can add additional bins and get them expired
+ * automatically by implementing hook_flush_caches().
+ *
+ * - cache: Generic cache storage bin (used for variables, theme registry,
+ * locale date, list of simpletest tests etc).
+ *
+ * - cache_block: Stores the content of various blocks.
+ *
+ * - cache field: Stores the field data belonging to a given object.
+ *
+ * - cache_filter: Stores filtered pieces of content.
+ *
+ * - cache_form(*): Stores multistep forms. Flushing this bin means that some
+ * forms displayed to users lose their state and the data already submitted
+ * to them.
+ *
+ * - cache_menu: Stores the structure of visible navigation menus per page.
+ *
+ * - cache_page: Stores generated pages for anonymous users. It is flushed
+ * very often, whenever a page changes, at least for every ode and comment
+ * submission. This is the only bin affected by the page cache setting on
+ * the administrator panel.
+ *
+ * - cache path: Stores the system paths that have an alias.
+ *
+ * - cache update(*): Stores available releases. The update server (for
+ * example, drupal.org) needs to produce the relevant XML for every project
+ * installed on the current site. As this is different for (almost) every
+ * site, it's very expensive to recalculate for the update server.
+ *
+ * The reasons for having several bins are as follows:
+ *
+ * - smaller bins mean smaller database tables and allow for faster selects and
+ * inserts
+ * - we try to put fast changing cache items and rather static ones into
+ * different bins. The effect is that only the fast changing bins will need a
+ * lot of writes to disk. The more static bins will also be better cacheable
+ * with MySQL's query cache.
+ *
+ * @param $cid
+ * The cache ID of the data to store.
+ * @param $data
+ * The data to store in the cache. Complex data types will be automatically
+ * serialized before insertion.
+ * Strings will be stored as plain text and not serialized.
+ * @param $bin
+ * The cache bin to store the data in. Valid core values are 'cache_block',
+ * 'cache_bootstrap', 'cache_field', 'cache_filter', 'cache_form',
+ * 'cache_menu', 'cache_page', 'cache_update' or 'cache' for the default
+ * cache.
+ * @param $expire
+ * One of the following values:
+ * - CACHE_PERMANENT: Indicates that the item should never be removed unless
+ * explicitly told to using cache_clear_all() with a cache ID.
+ * - CACHE_TEMPORARY: Indicates that the item should be removed at the next
+ * general cache wipe.
+ * - A Unix timestamp: Indicates that the item should be kept at least until
+ * the given time, after which it behaves like CACHE_TEMPORARY.
+ */
+function cache_set($cid, $data, $bin = 'cache', $expire = CACHE_PERMANENT) {
+ return cache($bin)->set($cid, $data, $expire);
+}
+
+/**
+ * Expire data from the cache.
+ *
+ * If called without arguments, expirable entries will be cleared from the
+ * cache_page and cache_block bins.
+ *
+ * @param $cid
+ * If set, the cache ID to delete. Otherwise, all cache entries that can
+ * expire are deleted.
+ *
+ * @param $bin
+ * If set, the bin $bin to delete from. Mandatory
+ * argument if $cid is set.
+ *
+ * @param $wildcard
+ * If $wildcard is TRUE, cache IDs starting with $cid are deleted in
+ * addition to the exact cache ID specified by $cid. If $wildcard is
+ * TRUE and $cid is '*' then the entire bin $bin is emptied.
+ */
+function cache_clear_all($cid = NULL, $bin = NULL, $wildcard = FALSE) {
+ if (!isset($cid) && !isset($bin)) {
+ // Clear the block cache first, so stale data will
+ // not end up in the page cache.
+ if (module_exists('block')) {
+ cache('block')->expire();
+ }
+ cache('page')->expire();
+ return;
+ }
+ return cache($bin)->clear($cid, $wildcard);
+}
+
+/**
+ * Check if a cache bin is empty.
+ *
+ * A cache bin is considered empty if it does not contain any valid data for any
+ * cache ID.
+ *
+ * @param $bin
+ * The cache bin to check.
+ * @return
+ * TRUE if the cache bin specified is empty.
+ */
+function cache_is_empty($bin) {
+ return cache($bin)->isEmpty();
+}
+
+/**
+ * Interface for cache implementations.
+ *
+ * All cache implementations have to implement this interface.
+ * DrupalDatabaseCache provides the default implementation, which can be
+ * consulted as an example.
+ *
+ * To make Drupal use your implementation for a certain cache bin, you have to
+ * set a variable with the name of the cache bin as its key and the name of
+ * your class as its value. For example, if your implementation of
+ * DrupalCacheInterface was called MyCustomCache, the following line would make
+ * Drupal use it for the 'cache_page' bin:
+ * @code
+ * variable_set('cache_class_cache_page', 'MyCustomCache');
+ * @endcode
+ *
+ * Additionally, you can register your cache implementation to be used by
+ * default for all cache bins by setting the variable 'cache_default_class' to
+ * the name of your implementation of the DrupalCacheInterface, e.g.
+ * @code
+ * variable_set('cache_default_class', 'MyCustomCache');
+ * @endcode
+ *
+ * To implement a completely custom cache bin, use the same variable format:
+ * @code
+ * variable_set('cache_class_custom_bin', 'MyCustomCache');
+ * @endcode
+ * To access your custom cache bin, specify the name of the bin when storing
+ * or retrieving cached data:
+ * @code
+ * cache_set($cid, $data, 'custom_bin', $expire);
+ * cache_get($cid, 'custom_bin');
+ * @endcode
+ *
+ * @see cache()
+ * @see DrupalDatabaseCache
+ */
+interface DrupalCacheInterface {
+ /**
+ * Constructor.
+ *
+ * @param $bin
+ * The cache bin for which the object is created.
+ */
+ function __construct($bin);
+
+ /**
+ * Return data from the persistent cache. Data may be stored as either plain
+ * text or as serialized data. cache_get will automatically return
+ * unserialized objects and arrays.
+ *
+ * @param $cid
+ * The cache ID of the data to retrieve.
+ * @return
+ * The cache or FALSE on failure.
+ */
+ function get($cid);
+
+ /**
+ * Return data from the persistent cache when given an array of cache IDs.
+ *
+ * @param $cids
+ * An array of cache IDs for the data to retrieve. This is passed by
+ * reference, and will have the IDs successfully returned from cache
+ * removed.
+ * @return
+ * An array of the items successfully returned from cache indexed by cid.
+ */
+ function getMultiple(&$cids);
+
+ /**
+ * Store data in the persistent cache.
+ *
+ * @param $cid
+ * The cache ID of the data to store.
+ * @param $data
+ * The data to store in the cache. Complex data types will be automatically
+ * serialized before insertion.
+ * Strings will be stored as plain text and not serialized.
+ * @param $expire
+ * One of the following values:
+ * - CACHE_PERMANENT: Indicates that the item should never be removed unless
+ * explicitly told to using cache_clear_all() with a cache ID.
+ * - CACHE_TEMPORARY: Indicates that the item should be removed at the next
+ * general cache wipe.
+ * - A Unix timestamp: Indicates that the item should be kept at least until
+ * the given time, after which it behaves like CACHE_TEMPORARY.
+ */
+ function set($cid, $data, $expire = CACHE_PERMANENT);
+
+ /**
+ * Delete an item from the cache.
+ *
+ * @param $cid
+ * The cache ID to delete.
+ */
+ function delete($cid);
+
+ /**
+ * Delete multiple items from the cache.
+ *
+ * @param $cids
+ * An array of $cids to delete.
+ */
+ function deleteMultiple(Array $cids);
+
+ /**
+ * Delete items from the cache using a wildcard prefix.
+ *
+ * @param $prefix
+ * A wildcard prefix.
+ */
+ function deletePrefix($prefix);
+
+ /**
+ * Flush all cache items in a bin.
+ */
+ function flush();
+
+ /**
+ * Expire temporary items from cache.
+ */
+ function expire();
+
+ /**
+ * Perform garbage collection on a cache bin.
+ */
+ function garbageCollection();
+
+ /**
+ * Expire data from the cache. If called without arguments, expirable
+ * entries will be cleared from the cache_page and cache_block bins.
+ *
+ * @param $cid
+ * If set, the cache ID to delete. Otherwise, all cache entries that can
+ * expire are deleted.
+ * @param $wildcard
+ * If set to TRUE, the $cid is treated as a substring
+ * to match rather than a complete ID. The match is a right hand
+ * match. If '*' is given as $cid, the bin $bin will be emptied.
+ *
+ * @todo: this method is deprecated, as it's functionality is covered by
+ * more targeted methods in the interface.
+ */
+ function clear($cid = NULL, $wildcard = FALSE);
+
+ /**
+ * Check if a cache bin is empty.
+ *
+ * A cache bin is considered empty if it does not contain any valid data for
+ * any cache ID.
+ *
+ * @return
+ * TRUE if the cache bin specified is empty.
+ */
+ function isEmpty();
+}
+
+/**
+ * A stub cache implementation.
+ *
+ * The stub implementation is needed when database access is not yet available.
+ * Because Drupal's caching system never requires that cached data be present,
+ * these stub functions can short-circuit the process and sidestep the need for
+ * any persistent storage. Using this cache implementation during normal
+ * operations would have a negative impact on performance.
+ *
+ * This also can be used for testing purposes.
+ */
+class DrupalNullCache implements DrupalCacheInterface {
+
+ function __construct($bin) {}
+
+ function get($cid) {
+ return FALSE;
+ }
+
+ function getMultiple(&$cids) {
+ return array();
+ }
+
+ function set($cid, $data, $expire = CACHE_PERMANENT) {}
+
+ function delete($cid) {}
+
+ function deleteMultiple(array $cids) {}
+
+ function deletePrefix($prefix) {}
+
+ function flush() {}
+
+ function expire() {}
+
+ function garbageCollection() {}
+
+ function clear($cid = NULL, $wildcard = FALSE) {}
+
+ function isEmpty() {
+ return TRUE;
+ }
+}
+
+/**
+ * Default cache implementation.
+ *
+ * This is Drupal's default cache implementation. It uses the database to store
+ * cached data. Each cache bin corresponds to a database table by the same name.
+ */
+class DrupalDatabaseCache implements DrupalCacheInterface {
+ protected $bin;
+
+ function __construct($bin) {
+ // All cache tables should be prefixed with 'cache_', apart from the
+ // default 'cache' bin, which would look silly.
+ if ($bin != 'cache') {
+ $bin = 'cache_' . $bin;
+ }
+ $this->bin = $bin;
+ }
+
+ function get($cid) {
+ $cids = array($cid);
+ $cache = $this->getMultiple($cids);
+ return reset($cache);
+ }
+
+ function getMultiple(&$cids) {
+ try {
+ // Garbage collection necessary when enforcing a minimum cache lifetime.
+ $this->garbageCollection($this->bin);
+
+ // When serving cached pages, the overhead of using db_select() was found
+ // to add around 30% overhead to the request. Since $this->bin is a
+ // variable, this means the call to db_query() here uses a concatenated
+ // string. This is highly discouraged under any other circumstances, and
+ // is used here only due to the performance overhead we would incur
+ // otherwise. When serving an uncached page, the overhead of using
+ // db_select() is a much smaller proportion of the request.
+ $result = db_query('SELECT cid, data, created, expire, serialized FROM {' . db_escape_table($this->bin) . '} WHERE cid IN (:cids)', array(':cids' => $cids));
+ $cache = array();
+ foreach ($result as $item) {
+ $item = $this->prepareItem($item);
+ if ($item) {
+ $cache[$item->cid] = $item;
+ }
+ }
+ $cids = array_diff($cids, array_keys($cache));
+ return $cache;
+ }
+ catch (Exception $e) {
+ // If the database is never going to be available, cache requests should
+ // return FALSE in order to allow exception handling to occur.
+ return array();
+ }
+ }
+
+ /**
+ * Prepare a cached item.
+ *
+ * Checks that items are either permanent or did not expire, and unserializes
+ * data as appropriate.
+ *
+ * @param $cache
+ * An item loaded from cache_get() or cache_get_multiple().
+ * @return
+ * The item with data unserialized as appropriate or FALSE if there is no
+ * valid item to load.
+ */
+ protected function prepareItem($cache) {
+ global $user;
+
+ if (!isset($cache->data)) {
+ return FALSE;
+ }
+ // If enforcing a minimum cache lifetime, validate that the data is
+ // currently valid for this user before we return it by making sure the cache
+ // entry was created before the timestamp in the current session's cache
+ // timer. The cache variable is loaded into the $user object by _drupal_session_read()
+ // in session.inc. If the data is permanent or we're not enforcing a minimum
+ // cache lifetime always return the cached data.
+ if ($cache->expire != CACHE_PERMANENT && variable_get('cache_lifetime', 0) && $user->cache > $cache->created) {
+ // This cache data is too old and thus not valid for us, ignore it.
+ return FALSE;
+ }
+
+ if ($cache->serialized) {
+ $cache->data = unserialize($cache->data);
+ }
+
+ return $cache;
+ }
+
+ function set($cid, $data, $expire = CACHE_PERMANENT) {
+ $fields = array(
+ 'serialized' => 0,
+ 'created' => REQUEST_TIME,
+ 'expire' => $expire,
+ );
+ if (!is_string($data)) {
+ $fields['data'] = serialize($data);
+ $fields['serialized'] = 1;
+ }
+ else {
+ $fields['data'] = $data;
+ $fields['serialized'] = 0;
+ }
+
+ try {
+ db_merge($this->bin)
+ ->key(array('cid' => $cid))
+ ->fields($fields)
+ ->execute();
+ }
+ catch (Exception $e) {
+ // The database may not be available, so we'll ignore cache_set requests.
+ }
+ }
+
+ function delete($cid) {
+ db_delete($this->bin)
+ ->condition('cid', $cid)
+ ->execute();
+ }
+
+ function deleteMultiple(Array $cids) {
+ // Delete in chunks when a large array is passed.
+ do {
+ db_delete($this->bin)
+ ->condition('cid', array_splice($cids, 0, 1000), 'IN')
+ ->execute();
+ }
+ while (count($cids));
+ }
+
+ function deletePrefix($prefix) {
+ db_delete($this->bin)
+ ->condition('cid', db_like($prefix) . '%', 'LIKE')
+ ->execute();
+ }
+
+ function flush() {
+ db_truncate($this->bin)->execute();
+ }
+
+ function expire() {
+ if (variable_get('cache_lifetime', 0)) {
+ // We store the time in the current user's $user->cache variable which
+ // will be saved into the sessions bin by _drupal_session_write(). We then
+ // simulate that the cache was flushed for this user by not returning
+ // cached data that was cached before the timestamp.
+ $GLOBALS['user']->cache = REQUEST_TIME;
+
+ $cache_flush = variable_get('cache_flush_' . $this->bin, 0);
+ if ($cache_flush == 0) {
+ // This is the first request to clear the cache, start a timer.
+ variable_set('cache_flush_' . $this->bin, REQUEST_TIME);
+ }
+ elseif (REQUEST_TIME > ($cache_flush + variable_get('cache_lifetime', 0))) {
+ // Clear the cache for everyone, cache_lifetime seconds have
+ // passed since the first request to clear the cache.
+ db_delete($this->bin)
+ ->condition('expire', CACHE_PERMANENT, '<>')
+ ->condition('expire', REQUEST_TIME, '<')
+ ->execute();
+ variable_set('cache_flush_' . $this->bin, 0);
+ }
+ }
+ else {
+ // No minimum cache lifetime, flush all temporary cache entries now.
+ db_delete($this->bin)
+ ->condition('expire', CACHE_PERMANENT, '<>')
+ ->condition('expire', REQUEST_TIME, '<')
+ ->execute();
+ }
+ }
+
+ function garbageCollection() {
+ global $user;
+
+ // When cache lifetime is in force, avoid running garbage collection too
+ // often since this will remove temporary cache items indiscriminately.
+ $cache_flush = variable_get('cache_flush_' . $this->bin, 0);
+ if ($cache_flush && ($cache_flush + variable_get('cache_lifetime', 0) <= REQUEST_TIME)) {
+ // Reset the variable immediately to prevent a meltdown in heavy load situations.
+ variable_set('cache_flush_' . $this->bin, 0);
+ // Time to flush old cache data
+ db_delete($this->bin)
+ ->condition('expire', CACHE_PERMANENT, '<>')
+ ->condition('expire', $cache_flush, '<=')
+ ->execute();
+ }
+ }
+
+ function clear($cid = NULL, $wildcard = FALSE) {
+ global $user;
+
+ if (empty($cid)) {
+ $this->expire();
+ }
+ else {
+ if ($wildcard) {
+ if ($cid == '*') {
+ $this->flush();
+ }
+ else {
+ $this->deletePrefix($cid);
+ }
+ }
+ elseif (is_array($cid)) {
+ $this->deleteMultiple($cid);
+ }
+ else {
+ $this->delete($cid);
+ }
+ }
+ }
+
+ function isEmpty() {
+ $this->garbageCollection();
+ $query = db_select($this->bin);
+ $query->addExpression('1');
+ $result = $query->range(0, 1)
+ ->execute()
+ ->fetchField();
+ return empty($result);
+ }
+}
diff --git a/core/includes/common.inc b/core/includes/common.inc
new file mode 100644
index 000000000000..b2a080f3585f
--- /dev/null
+++ b/core/includes/common.inc
@@ -0,0 +1,7459 @@
+<?php
+
+/**
+ * @file
+ * Common functions that many Drupal modules will need to reference.
+ *
+ * The functions that are critical and need to be available even when serving
+ * a cached page are instead located in bootstrap.inc.
+ */
+
+/**
+ * @defgroup php_wrappers PHP wrapper functions
+ * @{
+ * Functions that are wrappers or custom implementations of PHP functions.
+ *
+ * Certain PHP functions should not be used in Drupal. Instead, Drupal's
+ * replacement functions should be used.
+ *
+ * For example, for improved or more secure UTF8-handling, or RFC-compliant
+ * handling of URLs in Drupal.
+ *
+ * For ease of use and memorizing, all these wrapper functions use the same name
+ * as the original PHP function, but prefixed with "drupal_". Beware, however,
+ * that not all wrapper functions support the same arguments as the original
+ * functions.
+ *
+ * You should always use these wrapper functions in your code.
+ *
+ * Wrong:
+ * @code
+ * $my_substring = substr($original_string, 0, 5);
+ * @endcode
+ *
+ * Correct:
+ * @code
+ * $my_substring = drupal_substr($original_string, 0, 5);
+ * @endcode
+ *
+ * @}
+ */
+
+/**
+ * Return status for saving which involved creating a new item.
+ */
+define('SAVED_NEW', 1);
+
+/**
+ * Return status for saving which involved an update to an existing item.
+ */
+define('SAVED_UPDATED', 2);
+
+/**
+ * Return status for saving which deleted an existing item.
+ */
+define('SAVED_DELETED', 3);
+
+/**
+ * The default group for system CSS files added to the page.
+ */
+define('CSS_SYSTEM', -100);
+
+/**
+ * The default group for module CSS files added to the page.
+ */
+define('CSS_DEFAULT', 0);
+
+/**
+ * The default group for theme CSS files added to the page.
+ */
+define('CSS_THEME', 100);
+
+/**
+ * The default group for JavaScript libraries, settings or jQuery plugins added
+ * to the page.
+ */
+define('JS_LIBRARY', -100);
+
+/**
+ * The default group for module JavaScript code added to the page.
+ */
+define('JS_DEFAULT', 0);
+
+/**
+ * The default group for theme JavaScript code added to the page.
+ */
+define('JS_THEME', 100);
+
+/**
+ * Error code indicating that the request made by drupal_http_request() exceeded
+ * the specified timeout.
+ */
+define('HTTP_REQUEST_TIMEOUT', -1);
+
+/**
+ * Constants defining cache granularity for blocks and renderable arrays.
+ *
+ * Modules specify the caching patterns for their blocks using binary
+ * combinations of these constants in their hook_block_info():
+ * $block[delta]['cache'] = DRUPAL_CACHE_PER_ROLE | DRUPAL_CACHE_PER_PAGE;
+ * DRUPAL_CACHE_PER_ROLE is used as a default when no caching pattern is
+ * specified. Use DRUPAL_CACHE_CUSTOM to disable standard block cache and
+ * implement
+ *
+ * The block cache is cleared in cache_clear_all(), and uses the same clearing
+ * policy than page cache (node, comment, user, taxonomy added or updated...).
+ * Blocks requiring more fine-grained clearing might consider disabling the
+ * built-in block cache (DRUPAL_NO_CACHE) and roll their own.
+ *
+ * Note that user 1 is excluded from block caching.
+ */
+
+/**
+ * The block should not get cached. This setting should be used:
+ * - for simple blocks (notably those that do not perform any db query),
+ * where querying the db cache would be more expensive than directly generating
+ * the content.
+ * - for blocks that change too frequently.
+ */
+define('DRUPAL_NO_CACHE', -1);
+
+/**
+ * The block is handling its own caching in its hook_block_view(). From the
+ * perspective of the block cache system, this is equivalent to DRUPAL_NO_CACHE.
+ * Useful when time based expiration is needed or a site uses a node access
+ * which invalidates standard block cache.
+ */
+define('DRUPAL_CACHE_CUSTOM', -2);
+
+/**
+ * The block or element can change depending on the roles the user viewing the
+ * page belongs to. This is the default setting for blocks, used when the block
+ * does not specify anything.
+ */
+define('DRUPAL_CACHE_PER_ROLE', 0x0001);
+
+/**
+ * The block or element can change depending on the user viewing the page.
+ * This setting can be resource-consuming for sites with large number of users,
+ * and thus should only be used when DRUPAL_CACHE_PER_ROLE is not sufficient.
+ */
+define('DRUPAL_CACHE_PER_USER', 0x0002);
+
+/**
+ * The block or element can change depending on the page being viewed.
+ */
+define('DRUPAL_CACHE_PER_PAGE', 0x0004);
+
+/**
+ * The block or element is the same for every user on every page where it is visible.
+ */
+define('DRUPAL_CACHE_GLOBAL', 0x0008);
+
+/**
+ * Add content to a specified region.
+ *
+ * @param $region
+ * Page region the content is added to.
+ * @param $data
+ * Content to be added.
+ */
+function drupal_add_region_content($region = NULL, $data = NULL) {
+ static $content = array();
+
+ if (isset($region) && isset($data)) {
+ $content[$region][] = $data;
+ }
+ return $content;
+}
+
+/**
+ * Get assigned content for a given region.
+ *
+ * @param $region
+ * A specified region to fetch content for. If NULL, all regions will be
+ * returned.
+ * @param $delimiter
+ * Content to be inserted between imploded array elements.
+ */
+function drupal_get_region_content($region = NULL, $delimiter = ' ') {
+ $content = drupal_add_region_content();
+ if (isset($region)) {
+ if (isset($content[$region]) && is_array($content[$region])) {
+ return implode($delimiter, $content[$region]);
+ }
+ }
+ else {
+ foreach (array_keys($content) as $region) {
+ if (is_array($content[$region])) {
+ $content[$region] = implode($delimiter, $content[$region]);
+ }
+ }
+ return $content;
+ }
+}
+
+/**
+ * Get the name of the currently active install profile.
+ *
+ * When this function is called during Drupal's initial installation process,
+ * the name of the profile that's about to be installed is stored in the global
+ * installation state. At all other times, the standard Drupal systems variable
+ * table contains the name of the current profile, and we can call variable_get()
+ * to determine what one is active.
+ *
+ * @return $profile
+ * The name of the install profile.
+ */
+function drupal_get_profile() {
+ global $install_state;
+
+ if (isset($install_state['parameters']['profile'])) {
+ $profile = $install_state['parameters']['profile'];
+ }
+ else {
+ $profile = variable_get('install_profile', 'standard');
+ }
+
+ return $profile;
+}
+
+
+/**
+ * Set the breadcrumb trail for the current page.
+ *
+ * @param $breadcrumb
+ * Array of links, starting with "home" and proceeding up to but not including
+ * the current page.
+ */
+function drupal_set_breadcrumb($breadcrumb = NULL) {
+ $stored_breadcrumb = &drupal_static(__FUNCTION__);
+
+ if (isset($breadcrumb)) {
+ $stored_breadcrumb = $breadcrumb;
+ }
+ return $stored_breadcrumb;
+}
+
+/**
+ * Get the breadcrumb trail for the current page.
+ */
+function drupal_get_breadcrumb() {
+ $breadcrumb = drupal_set_breadcrumb();
+
+ if (!isset($breadcrumb)) {
+ $breadcrumb = menu_get_active_breadcrumb();
+ }
+
+ return $breadcrumb;
+}
+
+/**
+ * Add output to the head tag of the HTML page.
+ *
+ * This function can be called as long as the headers aren't sent. Pass no
+ * arguments (or NULL for both) to retrieve the currently stored elements.
+ *
+ * @param $data
+ * A renderable array. If the '#type' key is not set then 'html_tag' will be
+ * added as the default '#type'.
+ * @param $key
+ * A unique string key to allow implementations of hook_html_head_alter() to
+ * identify the element in $data. Required if $data is not NULL.
+ *
+ * @return
+ * An array of all stored HEAD elements.
+ *
+ * @see theme_html_tag()
+ */
+function drupal_add_html_head($data = NULL, $key = NULL) {
+ $stored_head = &drupal_static(__FUNCTION__);
+
+ if (!isset($stored_head)) {
+ // Make sure the defaults, including Content-Type, come first.
+ $stored_head = _drupal_default_html_head();
+ }
+
+ if (isset($data) && isset($key)) {
+ if (!isset($data['#type'])) {
+ $data['#type'] = 'html_tag';
+ }
+ $stored_head[$key] = $data;
+ }
+ return $stored_head;
+}
+
+/**
+ * Returns elements that are always displayed in the HEAD tag of the HTML page.
+ */
+function _drupal_default_html_head() {
+ // Add default elements. Make sure the Content-Type comes first because the
+ // IE browser may be vulnerable to XSS via encoding attacks from any content
+ // that comes before this META tag, such as a TITLE tag.
+ $elements['system_meta_content_type'] = array(
+ '#type' => 'html_tag',
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'http-equiv' => 'Content-Type',
+ 'content' => 'text/html; charset=utf-8',
+ ),
+ // Security: This always has to be output first.
+ '#weight' => -1000,
+ );
+ // Show Drupal and the major version number in the META GENERATOR tag.
+ // Get the major version.
+ list($version, ) = explode('.', VERSION);
+ $elements['system_meta_generator'] = array(
+ '#type' => 'html_tag',
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'name' => 'Generator',
+ 'content' => 'Drupal ' . $version . ' (http://drupal.org)',
+ ),
+ );
+ // Also send the generator in the HTTP header.
+ $elements['system_meta_generator']['#attached']['drupal_add_http_header'][] = array('X-Generator', $elements['system_meta_generator']['#attributes']['content']);
+ return $elements;
+}
+
+/**
+ * Retrieve output to be displayed in the HEAD tag of the HTML page.
+ */
+function drupal_get_html_head() {
+ $elements = drupal_add_html_head();
+ drupal_alter('html_head', $elements);
+ return drupal_render($elements);
+}
+
+/**
+ * Add a feed URL for the current page.
+ *
+ * This function can be called as long the HTML header hasn't been sent.
+ *
+ * @param $url
+ * An internal system path or a fully qualified external URL of the feed.
+ * @param $title
+ * The title of the feed.
+ */
+function drupal_add_feed($url = NULL, $title = '') {
+ $stored_feed_links = &drupal_static(__FUNCTION__, array());
+
+ if (isset($url)) {
+ $stored_feed_links[$url] = theme('feed_icon', array('url' => $url, 'title' => $title));
+
+ drupal_add_html_head_link(array(
+ 'rel' => 'alternate',
+ 'type' => 'application/rss+xml',
+ 'title' => $title,
+ // Force the URL to be absolute, for consistency with other <link> tags
+ // output by Drupal.
+ 'href' => url($url, array('absolute' => TRUE)),
+ ));
+ }
+ return $stored_feed_links;
+}
+
+/**
+ * Get the feed URLs for the current page.
+ *
+ * @param $delimiter
+ * A delimiter to split feeds by.
+ */
+function drupal_get_feeds($delimiter = "\n") {
+ $feeds = drupal_add_feed();
+ return implode($feeds, $delimiter);
+}
+
+/**
+ * @defgroup http_handling HTTP handling
+ * @{
+ * Functions to properly handle HTTP responses.
+ */
+
+/**
+ * Process a URL query parameter array to remove unwanted elements.
+ *
+ * @param $query
+ * (optional) An array to be processed. Defaults to $_GET.
+ * @param $exclude
+ * (optional) A list of $query array keys to remove. Use "parent[child]" to
+ * exclude nested items. Defaults to array('q').
+ * @param $parent
+ * Internal use only. Used to build the $query array key for nested items.
+ *
+ * @return
+ * An array containing query parameters, which can be used for url().
+ */
+function drupal_get_query_parameters(array $query = NULL, array $exclude = array('q'), $parent = '') {
+ // Set defaults, if none given.
+ if (!isset($query)) {
+ $query = $_GET;
+ }
+ // If $exclude is empty, there is nothing to filter.
+ if (empty($exclude)) {
+ return $query;
+ }
+ elseif (!$parent) {
+ $exclude = array_flip($exclude);
+ }
+
+ $params = array();
+ foreach ($query as $key => $value) {
+ $string_key = ($parent ? $parent . '[' . $key . ']' : $key);
+ if (isset($exclude[$string_key])) {
+ continue;
+ }
+
+ if (is_array($value)) {
+ $params[$key] = drupal_get_query_parameters($value, $exclude, $string_key);
+ }
+ else {
+ $params[$key] = $value;
+ }
+ }
+
+ return $params;
+}
+
+/**
+ * Split an URL-encoded query string into an array.
+ *
+ * @param $query
+ * The query string to split.
+ *
+ * @return
+ * An array of url decoded couples $param_name => $value.
+ */
+function drupal_get_query_array($query) {
+ $result = array();
+ if (!empty($query)) {
+ foreach (explode('&', $query) as $param) {
+ $param = explode('=', $param);
+ $result[$param[0]] = isset($param[1]) ? rawurldecode($param[1]) : '';
+ }
+ }
+ return $result;
+}
+
+/**
+ * Parse an array into a valid, rawurlencoded query string.
+ *
+ * This differs from http_build_query() as we need to rawurlencode() (instead of
+ * urlencode()) all query parameters.
+ *
+ * @param $query
+ * The query parameter array to be processed, e.g. $_GET.
+ * @param $parent
+ * Internal use only. Used to build the $query array key for nested items.
+ *
+ * @return
+ * A rawurlencoded string which can be used as or appended to the URL query
+ * string.
+ *
+ * @see drupal_get_query_parameters()
+ * @ingroup php_wrappers
+ */
+function drupal_http_build_query(array $query, $parent = '') {
+ $params = array();
+
+ foreach ($query as $key => $value) {
+ $key = ($parent ? $parent . '[' . rawurlencode($key) . ']' : rawurlencode($key));
+
+ // Recurse into children.
+ if (is_array($value)) {
+ $params[] = drupal_http_build_query($value, $key);
+ }
+ // If a query parameter value is NULL, only append its key.
+ elseif (!isset($value)) {
+ $params[] = $key;
+ }
+ else {
+ // For better readability of paths in query strings, we decode slashes.
+ $params[] = $key . '=' . str_replace('%2F', '/', rawurlencode($value));
+ }
+ }
+
+ return implode('&', $params);
+}
+
+/**
+ * Prepare a 'destination' URL query parameter for use in combination with drupal_goto().
+ *
+ * Used to direct the user back to the referring page after completing a form.
+ * By default the current URL is returned. If a destination exists in the
+ * previous request, that destination is returned. As such, a destination can
+ * persist across multiple pages.
+ *
+ * @see drupal_goto()
+ */
+function drupal_get_destination() {
+ $destination = &drupal_static(__FUNCTION__);
+
+ if (isset($destination)) {
+ return $destination;
+ }
+
+ if (isset($_GET['destination'])) {
+ $destination = array('destination' => $_GET['destination']);
+ }
+ else {
+ $path = $_GET['q'];
+ $query = drupal_http_build_query(drupal_get_query_parameters());
+ if ($query != '') {
+ $path .= '?' . $query;
+ }
+ $destination = array('destination' => $path);
+ }
+ return $destination;
+}
+
+/**
+ * Wrapper around parse_url() to parse a system URL string into an associative array, suitable for url().
+ *
+ * This function should only be used for URLs that have been generated by the
+ * system, resp. url(). It should not be used for URLs that come from external
+ * sources, or URLs that link to external resources.
+ *
+ * The returned array contains a 'path' that may be passed separately to url().
+ * For example:
+ * @code
+ * $options = drupal_parse_url($_GET['destination']);
+ * $my_url = url($options['path'], $options);
+ * $my_link = l('Example link', $options['path'], $options);
+ * @endcode
+ *
+ * This is required, because url() does not support relative URLs containing a
+ * query string or fragment in its $path argument. Instead, any query string
+ * needs to be parsed into an associative query parameter array in
+ * $options['query'] and the fragment into $options['fragment'].
+ *
+ * @param $url
+ * The URL string to parse, f.e. $_GET['destination'].
+ *
+ * @return
+ * An associative array containing the keys:
+ * - 'path': The path of the URL. If the given $url is external, this includes
+ * the scheme and host.
+ * - 'query': An array of query parameters of $url, if existent.
+ * - 'fragment': The fragment of $url, if existent.
+ *
+ * @see url()
+ * @see drupal_goto()
+ * @ingroup php_wrappers
+ */
+function drupal_parse_url($url) {
+ $options = array(
+ 'path' => NULL,
+ 'query' => array(),
+ 'fragment' => '',
+ );
+
+ // External URLs: not using parse_url() here, so we do not have to rebuild
+ // the scheme, host, and path without having any use for it.
+ if (strpos($url, '://') !== FALSE) {
+ // Split off everything before the query string into 'path'.
+ $parts = explode('?', $url);
+ $options['path'] = $parts[0];
+ // If there is a query string, transform it into keyed query parameters.
+ if (isset($parts[1])) {
+ $query_parts = explode('#', $parts[1]);
+ parse_str($query_parts[0], $options['query']);
+ // Take over the fragment, if there is any.
+ if (isset($query_parts[1])) {
+ $options['fragment'] = $query_parts[1];
+ }
+ }
+ }
+ // Internal URLs.
+ else {
+ // parse_url() does not support relative URLs, so make it absolute. E.g. the
+ // relative URL "foo/bar:1" isn't properly parsed.
+ $parts = parse_url('http://example.com/' . $url);
+ // Strip the leading slash that was just added.
+ $options['path'] = substr($parts['path'], 1);
+ if (isset($parts['query'])) {
+ parse_str($parts['query'], $options['query']);
+ }
+ if (isset($parts['fragment'])) {
+ $options['fragment'] = $parts['fragment'];
+ }
+ }
+ // The 'q' parameter contains the path of the current page if clean URLs are
+ // disabled. It overrides the 'path' of the URL when present, even if clean
+ // URLs are enabled, due to how Apache rewriting rules work.
+ if (isset($options['query']['q'])) {
+ $options['path'] = $options['query']['q'];
+ unset($options['query']['q']);
+ }
+
+ return $options;
+}
+
+/**
+ * Encodes a Drupal path for use in a URL.
+ *
+ * For aesthetic reasons slashes are not escaped.
+ *
+ * Note that url() takes care of calling this function, so a path passed to that
+ * function should not be encoded in advance.
+ *
+ * @param $path
+ * The Drupal path to encode.
+ */
+function drupal_encode_path($path) {
+ return str_replace('%2F', '/', rawurlencode($path));
+}
+
+/**
+ * Send the user to a different Drupal page.
+ *
+ * This issues an on-site HTTP redirect. The function makes sure the redirected
+ * URL is formatted correctly.
+ *
+ * If a destination was specified in the current request's URI (i.e.,
+ * $_GET['destination']) then it will override the $path and $options values
+ * passed to this function. This provides the flexibility to build a link to
+ * user/login and override the default redirection so that the user is
+ * redirected to a specific path after logging in:
+ * @code
+ * $query = array('destination' => "node/$node->nid");
+ * $link = l(t('Log in'), 'user/login', array('query' => $query));
+ * @endcode
+ *
+ * Drupal will ensure that messages set by drupal_set_message() and other
+ * session data are written to the database before the user is redirected.
+ *
+ * This function ends the request; use it instead of a return in your menu
+ * callback.
+ *
+ * @param $path
+ * A Drupal path or a full URL.
+ * @param $options
+ * An associative array of additional URL options to pass to url().
+ * @param $http_response_code
+ * Valid values for an actual "goto" as per RFC 2616 section 10.3 are:
+ * - 301 Moved Permanently (the recommended value for most redirects)
+ * - 302 Found (default in Drupal and PHP, sometimes used for spamming search
+ * engines)
+ * - 303 See Other
+ * - 304 Not Modified
+ * - 305 Use Proxy
+ * - 307 Temporary Redirect (alternative to "503 Site Down for Maintenance")
+ * Note: Other values are defined by RFC 2616, but are rarely used and poorly
+ * supported.
+ *
+ * @see drupal_get_destination()
+ * @see url()
+ */
+function drupal_goto($path = '', array $options = array(), $http_response_code = 302) {
+ // A destination in $_GET always overrides the function arguments.
+ // We do not allow absolute URLs to be passed via $_GET, as this can be an attack vector.
+ if (isset($_GET['destination']) && !url_is_external($_GET['destination'])) {
+ $destination = drupal_parse_url($_GET['destination']);
+ $path = $destination['path'];
+ $options['query'] = $destination['query'];
+ $options['fragment'] = $destination['fragment'];
+ }
+
+ drupal_alter('drupal_goto', $path, $options, $http_response_code);
+
+ // The 'Location' HTTP header must be absolute.
+ $options['absolute'] = TRUE;
+
+ $url = url($path, $options);
+
+ header('Location: ' . $url, TRUE, $http_response_code);
+
+ // The "Location" header sends a redirect status code to the HTTP daemon. In
+ // some cases this can be wrong, so we make sure none of the code below the
+ // drupal_goto() call gets executed upon redirection.
+ drupal_exit($url);
+}
+
+/**
+ * Deliver a "site is under maintenance" message to the browser.
+ *
+ * Page callback functions wanting to report a "site offline" message should
+ * return MENU_SITE_OFFLINE instead of calling drupal_site_offline(). However,
+ * functions that are invoked in contexts where that return value might not
+ * bubble up to menu_execute_active_handler() should call drupal_site_offline().
+ */
+function drupal_site_offline() {
+ drupal_deliver_page(MENU_SITE_OFFLINE);
+}
+
+/**
+ * Deliver a "page not found" error to the browser.
+ *
+ * Page callback functions wanting to report a "page not found" message should
+ * return MENU_NOT_FOUND instead of calling drupal_not_found(). However,
+ * functions that are invoked in contexts where that return value might not
+ * bubble up to menu_execute_active_handler() should call drupal_not_found().
+ */
+function drupal_not_found() {
+ drupal_deliver_page(MENU_NOT_FOUND);
+}
+
+/**
+ * Deliver a "access denied" error to the browser.
+ *
+ * Page callback functions wanting to report an "access denied" message should
+ * return MENU_ACCESS_DENIED instead of calling drupal_access_denied(). However,
+ * functions that are invoked in contexts where that return value might not
+ * bubble up to menu_execute_active_handler() should call drupal_access_denied().
+ */
+function drupal_access_denied() {
+ drupal_deliver_page(MENU_ACCESS_DENIED);
+}
+
+/**
+ * Perform an HTTP request.
+ *
+ * This is a flexible and powerful HTTP client implementation. Correctly
+ * handles GET, POST, PUT or any other HTTP requests. Handles redirects.
+ *
+ * @param $url
+ * A string containing a fully qualified URI.
+ * @param array $options
+ * (optional) An array that can have one or more of the following elements:
+ * - headers: An array containing request headers to send as name/value pairs.
+ * - method: A string containing the request method. Defaults to 'GET'.
+ * - data: A string containing the request body, formatted as
+ * 'param=value&param=value&...'. Defaults to NULL.
+ * - max_redirects: An integer representing how many times a redirect
+ * may be followed. Defaults to 3.
+ * - timeout: A float representing the maximum number of seconds the function
+ * call may take. The default is 30 seconds. If a timeout occurs, the error
+ * code is set to the HTTP_REQUEST_TIMEOUT constant.
+ * - context: A context resource created with stream_context_create().
+ *
+ * @return object
+ * An object that can have one or more of the following components:
+ * - request: A string containing the request body that was sent.
+ * - code: An integer containing the response status code, or the error code
+ * if an error occurred.
+ * - protocol: The response protocol (e.g. HTTP/1.1 or HTTP/1.0).
+ * - status_message: The status message from the response, if a response was
+ * received.
+ * - redirect_code: If redirected, an integer containing the initial response
+ * status code.
+ * - redirect_url: If redirected, a string containing the URL of the redirect
+ * target.
+ * - error: If an error occurred, the error message. Otherwise not set.
+ * - headers: An array containing the response headers as name/value pairs.
+ * HTTP header names are case-insensitive (RFC 2616, section 4.2), so for
+ * easy access the array keys are returned in lower case.
+ * - data: A string containing the response body that was received.
+ */
+function drupal_http_request($url, array $options = array()) {
+ $result = new stdClass();
+
+ // Parse the URL and make sure we can handle the schema.
+ $uri = @parse_url($url);
+
+ if ($uri == FALSE) {
+ $result->error = 'unable to parse URL';
+ $result->code = -1001;
+ return $result;
+ }
+
+ if (!isset($uri['scheme'])) {
+ $result->error = 'missing schema';
+ $result->code = -1002;
+ return $result;
+ }
+
+ timer_start(__FUNCTION__);
+
+ // Merge the default options.
+ $options += array(
+ 'headers' => array(),
+ 'method' => 'GET',
+ 'data' => NULL,
+ 'max_redirects' => 3,
+ 'timeout' => 30.0,
+ 'context' => NULL,
+ );
+ // stream_socket_client() requires timeout to be a float.
+ $options['timeout'] = (float) $options['timeout'];
+
+ switch ($uri['scheme']) {
+ case 'http':
+ case 'feed':
+ $port = isset($uri['port']) ? $uri['port'] : 80;
+ $socket = 'tcp://' . $uri['host'] . ':' . $port;
+ // RFC 2616: "non-standard ports MUST, default ports MAY be included".
+ // We don't add the standard port to prevent from breaking rewrite rules
+ // checking the host that do not take into account the port number.
+ $options['headers']['Host'] = $uri['host'] . ($port != 80 ? ':' . $port : '');
+ break;
+ case 'https':
+ // Note: Only works when PHP is compiled with OpenSSL support.
+ $port = isset($uri['port']) ? $uri['port'] : 443;
+ $socket = 'ssl://' . $uri['host'] . ':' . $port;
+ $options['headers']['Host'] = $uri['host'] . ($port != 443 ? ':' . $port : '');
+ break;
+ default:
+ $result->error = 'invalid schema ' . $uri['scheme'];
+ $result->code = -1003;
+ return $result;
+ }
+
+ if (empty($options['context'])) {
+ $fp = @stream_socket_client($socket, $errno, $errstr, $options['timeout']);
+ }
+ else {
+ // Create a stream with context. Allows verification of a SSL certificate.
+ $fp = @stream_socket_client($socket, $errno, $errstr, $options['timeout'], STREAM_CLIENT_CONNECT, $options['context']);
+ }
+
+ // Make sure the socket opened properly.
+ if (!$fp) {
+ // When a network error occurs, we use a negative number so it does not
+ // clash with the HTTP status codes.
+ $result->code = -$errno;
+ $result->error = trim($errstr) ? trim($errstr) : t('Error opening socket @socket', array('@socket' => $socket));
+
+ // Mark that this request failed. This will trigger a check of the web
+ // server's ability to make outgoing HTTP requests the next time that
+ // requirements checking is performed.
+ // See system_requirements()
+ variable_set('drupal_http_request_fails', TRUE);
+
+ return $result;
+ }
+
+ // Construct the path to act on.
+ $path = isset($uri['path']) ? $uri['path'] : '/';
+ if (isset($uri['query'])) {
+ $path .= '?' . $uri['query'];
+ }
+
+ // Merge the default headers.
+ $options['headers'] += array(
+ 'User-Agent' => 'Drupal (+http://drupal.org/)',
+ );
+
+ // Only add Content-Length if we actually have any content or if it is a POST
+ // or PUT request. Some non-standard servers get confused by Content-Length in
+ // at least HEAD/GET requests, and Squid always requires Content-Length in
+ // POST/PUT requests.
+ $content_length = strlen($options['data']);
+ if ($content_length > 0 || $options['method'] == 'POST' || $options['method'] == 'PUT') {
+ $options['headers']['Content-Length'] = $content_length;
+ }
+
+ // If the server URL has a user then attempt to use basic authentication.
+ if (isset($uri['user'])) {
+ $options['headers']['Authorization'] = 'Basic ' . base64_encode($uri['user'] . (isset($uri['pass']) ? ':' . $uri['pass'] : ''));
+ }
+
+ // If the database prefix is being used by SimpleTest to run the tests in a copied
+ // database then set the user-agent header to the database prefix so that any
+ // calls to other Drupal pages will run the SimpleTest prefixed database. The
+ // user-agent is used to ensure that multiple testing sessions running at the
+ // same time won't interfere with each other as they would if the database
+ // prefix were stored statically in a file or database variable.
+ $test_info = &$GLOBALS['drupal_test_info'];
+ if (!empty($test_info['test_run_id'])) {
+ $options['headers']['User-Agent'] = drupal_generate_test_ua($test_info['test_run_id']);
+ }
+
+ $request = $options['method'] . ' ' . $path . " HTTP/1.0\r\n";
+ foreach ($options['headers'] as $name => $value) {
+ $request .= $name . ': ' . trim($value) . "\r\n";
+ }
+ $request .= "\r\n" . $options['data'];
+ $result->request = $request;
+ // Calculate how much time is left of the original timeout value.
+ $timeout = $options['timeout'] - timer_read(__FUNCTION__) / 1000;
+ if ($timeout > 0) {
+ stream_set_timeout($fp, floor($timeout), floor(1000000 * fmod($timeout, 1)));
+ fwrite($fp, $request);
+ }
+
+ // Fetch response. Due to PHP bugs like http://bugs.php.net/bug.php?id=43782
+ // and http://bugs.php.net/bug.php?id=46049 we can't rely on feof(), but
+ // instead must invoke stream_get_meta_data() each iteration.
+ $info = stream_get_meta_data($fp);
+ $alive = !$info['eof'] && !$info['timed_out'];
+ $response = '';
+
+ while ($alive) {
+ // Calculate how much time is left of the original timeout value.
+ $timeout = $options['timeout'] - timer_read(__FUNCTION__) / 1000;
+ if ($timeout <= 0) {
+ $info['timed_out'] = TRUE;
+ break;
+ }
+ stream_set_timeout($fp, floor($timeout), floor(1000000 * fmod($timeout, 1)));
+ $chunk = fread($fp, 1024);
+ $response .= $chunk;
+ $info = stream_get_meta_data($fp);
+ $alive = !$info['eof'] && !$info['timed_out'] && $chunk;
+ }
+ fclose($fp);
+
+ if ($info['timed_out']) {
+ $result->code = HTTP_REQUEST_TIMEOUT;
+ $result->error = 'request timed out';
+ return $result;
+ }
+ // Parse response headers from the response body.
+ // Be tolerant of malformed HTTP responses that separate header and body with
+ // \n\n or \r\r instead of \r\n\r\n.
+ list($response, $result->data) = preg_split("/\r\n\r\n|\n\n|\r\r/", $response, 2);
+ $response = preg_split("/\r\n|\n|\r/", $response);
+
+ // Parse the response status line.
+ list($protocol, $code, $status_message) = explode(' ', trim(array_shift($response)), 3);
+ $result->protocol = $protocol;
+ $result->status_message = $status_message;
+
+ $result->headers = array();
+
+ // Parse the response headers.
+ while ($line = trim(array_shift($response))) {
+ list($name, $value) = explode(':', $line, 2);
+ $name = strtolower($name);
+ if (isset($result->headers[$name]) && $name == 'set-cookie') {
+ // RFC 2109: the Set-Cookie response header comprises the token Set-
+ // Cookie:, followed by a comma-separated list of one or more cookies.
+ $result->headers[$name] .= ',' . trim($value);
+ }
+ else {
+ $result->headers[$name] = trim($value);
+ }
+ }
+
+ $responses = array(
+ 100 => 'Continue',
+ 101 => 'Switching Protocols',
+ 200 => 'OK',
+ 201 => 'Created',
+ 202 => 'Accepted',
+ 203 => 'Non-Authoritative Information',
+ 204 => 'No Content',
+ 205 => 'Reset Content',
+ 206 => 'Partial Content',
+ 300 => 'Multiple Choices',
+ 301 => 'Moved Permanently',
+ 302 => 'Found',
+ 303 => 'See Other',
+ 304 => 'Not Modified',
+ 305 => 'Use Proxy',
+ 307 => 'Temporary Redirect',
+ 400 => 'Bad Request',
+ 401 => 'Unauthorized',
+ 402 => 'Payment Required',
+ 403 => 'Forbidden',
+ 404 => 'Not Found',
+ 405 => 'Method Not Allowed',
+ 406 => 'Not Acceptable',
+ 407 => 'Proxy Authentication Required',
+ 408 => 'Request Time-out',
+ 409 => 'Conflict',
+ 410 => 'Gone',
+ 411 => 'Length Required',
+ 412 => 'Precondition Failed',
+ 413 => 'Request Entity Too Large',
+ 414 => 'Request-URI Too Large',
+ 415 => 'Unsupported Media Type',
+ 416 => 'Requested range not satisfiable',
+ 417 => 'Expectation Failed',
+ 500 => 'Internal Server Error',
+ 501 => 'Not Implemented',
+ 502 => 'Bad Gateway',
+ 503 => 'Service Unavailable',
+ 504 => 'Gateway Time-out',
+ 505 => 'HTTP Version not supported',
+ );
+ // RFC 2616 states that all unknown HTTP codes must be treated the same as the
+ // base code in their class.
+ if (!isset($responses[$code])) {
+ $code = floor($code / 100) * 100;
+ }
+ $result->code = $code;
+
+ switch ($code) {
+ case 200: // OK
+ case 304: // Not modified
+ break;
+ case 301: // Moved permanently
+ case 302: // Moved temporarily
+ case 307: // Moved temporarily
+ $location = $result->headers['location'];
+ $options['timeout'] -= timer_read(__FUNCTION__) / 1000;
+ if ($options['timeout'] <= 0) {
+ $result->code = HTTP_REQUEST_TIMEOUT;
+ $result->error = 'request timed out';
+ }
+ elseif ($options['max_redirects']) {
+ // Redirect to the new location.
+ $options['max_redirects']--;
+ $result = drupal_http_request($location, $options);
+ $result->redirect_code = $code;
+ }
+ if (!isset($result->redirect_url)) {
+ $result->redirect_url = $location;
+ }
+ break;
+ default:
+ $result->error = $status_message;
+ }
+
+ return $result;
+}
+/**
+ * @} End of "HTTP handling".
+ */
+
+function _fix_gpc_magic(&$item) {
+ if (is_array($item)) {
+ array_walk($item, '_fix_gpc_magic');
+ }
+ else {
+ $item = stripslashes($item);
+ }
+}
+
+/**
+ * Helper function to strip slashes from $_FILES skipping over the tmp_name keys
+ * since PHP generates single backslashes for file paths on Windows systems.
+ *
+ * tmp_name does not have backslashes added see
+ * http://php.net/manual/en/features.file-upload.php#42280
+ */
+function _fix_gpc_magic_files(&$item, $key) {
+ if ($key != 'tmp_name') {
+ if (is_array($item)) {
+ array_walk($item, '_fix_gpc_magic_files');
+ }
+ else {
+ $item = stripslashes($item);
+ }
+ }
+}
+
+/**
+ * Fix double-escaping problems caused by "magic quotes" in some PHP installations.
+ */
+function fix_gpc_magic() {
+ static $fixed = FALSE;
+ if (!$fixed && ini_get('magic_quotes_gpc')) {
+ array_walk($_GET, '_fix_gpc_magic');
+ array_walk($_POST, '_fix_gpc_magic');
+ array_walk($_COOKIE, '_fix_gpc_magic');
+ array_walk($_REQUEST, '_fix_gpc_magic');
+ array_walk($_FILES, '_fix_gpc_magic_files');
+ }
+ $fixed = TRUE;
+}
+
+/**
+ * @defgroup validation Input validation
+ * @{
+ * Functions to validate user input.
+ */
+
+/**
+ * Verify the syntax of the given e-mail address.
+ *
+ * Empty e-mail addresses are allowed. See RFC 2822 for details.
+ *
+ * @param $mail
+ * A string containing an e-mail address.
+ * @return
+ * TRUE if the address is in a valid format.
+ */
+function valid_email_address($mail) {
+ return (bool)filter_var($mail, FILTER_VALIDATE_EMAIL);
+}
+
+/**
+ * Verify the syntax of the given URL.
+ *
+ * This function should only be used on actual URLs. It should not be used for
+ * Drupal menu paths, which can contain arbitrary characters.
+ * Valid values per RFC 3986.
+ * @param $url
+ * The URL to verify.
+ * @param $absolute
+ * Whether the URL is absolute (beginning with a scheme such as "http:").
+ * @return
+ * TRUE if the URL is in a valid format.
+ */
+function valid_url($url, $absolute = FALSE) {
+ if ($absolute) {
+ return (bool)preg_match("
+ /^ # Start at the beginning of the text
+ (?:ftp|https?|feed):\/\/ # Look for ftp, http, https or feed schemes
+ (?: # Userinfo (optional) which is typically
+ (?:(?:[\w\.\-\+!$&'\(\)*\+,;=]|%[0-9a-f]{2})+:)* # a username or a username and password
+ (?:[\w\.\-\+%!$&'\(\)*\+,;=]|%[0-9a-f]{2})+@ # combination
+ )?
+ (?:
+ (?:[a-z0-9\-\.]|%[0-9a-f]{2})+ # A domain name or a IPv4 address
+ |(?:\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\]) # or a well formed IPv6 address
+ )
+ (?::[0-9]+)? # Server port number (optional)
+ (?:[\/|\?]
+ (?:[\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2}) # The path and query (optional)
+ *)?
+ $/xi", $url);
+ }
+ else {
+ return (bool)preg_match("/^(?:[\w#!:\.\?\+=&@$'~*,;\/\(\)\[\]\-]|%[0-9a-f]{2})+$/i", $url);
+ }
+}
+
+/**
+ * @} End of "defgroup validation".
+ */
+
+/**
+ * Register an event for the current visitor to the flood control mechanism.
+ *
+ * @param $name
+ * The name of an event.
+ * @param $window
+ * Optional number of seconds before this event expires. Defaults to 3600 (1
+ * hour). Typically uses the same value as the flood_is_allowed() $window
+ * parameter. Expired events are purged on cron run to prevent the flood table
+ * from growing indefinitely.
+ * @param $identifier
+ * Optional identifier (defaults to the current user's IP address).
+ */
+function flood_register_event($name, $window = 3600, $identifier = NULL) {
+ if (!isset($identifier)) {
+ $identifier = ip_address();
+ }
+ db_insert('flood')
+ ->fields(array(
+ 'event' => $name,
+ 'identifier' => $identifier,
+ 'timestamp' => REQUEST_TIME,
+ 'expiration' => REQUEST_TIME + $window,
+ ))
+ ->execute();
+}
+
+/**
+ * Make the flood control mechanism forget about an event for the current visitor.
+ *
+ * @param $name
+ * The name of an event.
+ * @param $identifier
+ * Optional identifier (defaults to the current user's IP address).
+ */
+function flood_clear_event($name, $identifier = NULL) {
+ if (!isset($identifier)) {
+ $identifier = ip_address();
+ }
+ db_delete('flood')
+ ->condition('event', $name)
+ ->condition('identifier', $identifier)
+ ->execute();
+}
+
+/**
+ * Checks whether user is allowed to proceed with the specified event.
+ *
+ * Events can have thresholds saying that each user can only do that event
+ * a certain number of times in a time window. This function verifies that the
+ * current user has not exceeded this threshold.
+ *
+ * @param $name
+ * The unique name of the event.
+ * @param $threshold
+ * The maximum number of times each user can do this event per time window.
+ * @param $window
+ * Number of seconds in the time window for this event (default is 3600
+ * seconds, or 1 hour).
+ * @param $identifier
+ * Unique identifier of the current user. Defaults to their IP address.
+ *
+ * @return
+ * TRUE if the user is allowed to proceed. FALSE if they have exceeded the
+ * threshold and should not be allowed to proceed.
+ */
+function flood_is_allowed($name, $threshold, $window = 3600, $identifier = NULL) {
+ if (!isset($identifier)) {
+ $identifier = ip_address();
+ }
+ $number = db_query("SELECT COUNT(*) FROM {flood} WHERE event = :event AND identifier = :identifier AND timestamp > :timestamp", array(
+ ':event' => $name,
+ ':identifier' => $identifier,
+ ':timestamp' => REQUEST_TIME - $window))
+ ->fetchField();
+ return ($number < $threshold);
+}
+
+/**
+ * @defgroup sanitization Sanitization functions
+ * @{
+ * Functions to sanitize values.
+ *
+ * See http://drupal.org/writing-secure-code for information
+ * on writing secure code.
+ */
+
+/**
+ * Strips dangerous protocols (e.g. 'javascript:') from a URI.
+ *
+ * This function must be called for all URIs within user-entered input prior
+ * to being output to an HTML attribute value. It is often called as part of
+ * check_url() or filter_xss(), but those functions return an HTML-encoded
+ * string, so this function can be called independently when the output needs to
+ * be a plain-text string for passing to t(), l(), drupal_attributes(), or
+ * another function that will call check_plain() separately.
+ *
+ * @param $uri
+ * A plain-text URI that might contain dangerous protocols.
+ *
+ * @return
+ * A plain-text URI stripped of dangerous protocols. As with all plain-text
+ * strings, this return value must not be output to an HTML page without
+ * check_plain() being called on it. However, it can be passed to functions
+ * expecting plain-text strings.
+ *
+ * @see check_url()
+ */
+function drupal_strip_dangerous_protocols($uri) {
+ static $allowed_protocols;
+
+ if (!isset($allowed_protocols)) {
+ $allowed_protocols = array_flip(variable_get('filter_allowed_protocols', array('ftp', 'http', 'https', 'irc', 'mailto', 'news', 'nntp', 'rtsp', 'sftp', 'ssh', 'tel', 'telnet', 'webcal')));
+ }
+
+ // Iteratively remove any invalid protocol found.
+ do {
+ $before = $uri;
+ $colonpos = strpos($uri, ':');
+ if ($colonpos > 0) {
+ // We found a colon, possibly a protocol. Verify.
+ $protocol = substr($uri, 0, $colonpos);
+ // If a colon is preceded by a slash, question mark or hash, it cannot
+ // possibly be part of the URL scheme. This must be a relative URL, which
+ // inherits the (safe) protocol of the base document.
+ if (preg_match('![/?#]!', $protocol)) {
+ break;
+ }
+ // Check if this is a disallowed protocol. Per RFC2616, section 3.2.3
+ // (URI Comparison) scheme comparison must be case-insensitive.
+ if (!isset($allowed_protocols[strtolower($protocol)])) {
+ $uri = substr($uri, $colonpos + 1);
+ }
+ }
+ } while ($before != $uri);
+
+ return $uri;
+}
+
+/**
+ * Strips dangerous protocols (e.g. 'javascript:') from a URI and encodes it for output to an HTML attribute value.
+ *
+ * @param $uri
+ * A plain-text URI that might contain dangerous protocols.
+ *
+ * @return
+ * A URI stripped of dangerous protocols and encoded for output to an HTML
+ * attribute value. Because it is already encoded, it should not be set as a
+ * value within a $attributes array passed to drupal_attributes(), because
+ * drupal_attributes() expects those values to be plain-text strings. To pass
+ * a filtered URI to drupal_attributes(), call
+ * drupal_strip_dangerous_protocols() instead.
+ *
+ * @see drupal_strip_dangerous_protocols()
+ */
+function check_url($uri) {
+ return check_plain(drupal_strip_dangerous_protocols($uri));
+}
+
+/**
+ * Very permissive XSS/HTML filter for admin-only use.
+ *
+ * Use only for fields where it is impractical to use the
+ * whole filter system, but where some (mainly inline) mark-up
+ * is desired (so check_plain() is not acceptable).
+ *
+ * Allows all tags that can be used inside an HTML body, save
+ * for scripts and styles.
+ */
+function filter_xss_admin($string) {
+ return filter_xss($string, array('a', 'abbr', 'acronym', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'command', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark', 'menu', 'meter', 'nav', 'ol', 'output', 'p', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr'));
+}
+
+/**
+ * Filters an HTML string to prevent cross-site-scripting (XSS) vulnerabilities.
+ *
+ * Based on kses by Ulf Harnhammar, see http://sourceforge.net/projects/kses.
+ * For examples of various XSS attacks, see: http://ha.ckers.org/xss.html.
+ *
+ * This code does four things:
+ * - Removes characters and constructs that can trick browsers.
+ * - Makes sure all HTML entities are well-formed.
+ * - Makes sure all HTML tags and attributes are well-formed.
+ * - Makes sure no HTML tags contain URLs with a disallowed protocol (e.g.
+ * javascript:).
+ *
+ * @param $string
+ * The string with raw HTML in it. It will be stripped of everything that can
+ * cause an XSS attack.
+ * @param $allowed_tags
+ * An array of allowed tags.
+ *
+ * @return
+ * An XSS safe version of $string, or an empty string if $string is not
+ * valid UTF-8.
+ *
+ * @see drupal_validate_utf8()
+ * @ingroup sanitization
+ */
+function filter_xss($string, $allowed_tags = array('a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd')) {
+ // Only operate on valid UTF-8 strings. This is necessary to prevent cross
+ // site scripting issues on Internet Explorer 6.
+ if (!drupal_validate_utf8($string)) {
+ return '';
+ }
+ // Store the text format
+ _filter_xss_split($allowed_tags, TRUE);
+ // Remove NULL characters (ignored by some browsers)
+ $string = str_replace(chr(0), '', $string);
+ // Remove Netscape 4 JS entities
+ $string = preg_replace('%&\s*\{[^}]*(\}\s*;?|$)%', '', $string);
+
+ // Defuse all HTML entities
+ $string = str_replace('&', '&amp;', $string);
+ // Change back only well-formed entities in our whitelist
+ // Decimal numeric entities
+ $string = preg_replace('/&amp;#([0-9]+;)/', '&#\1', $string);
+ // Hexadecimal numeric entities
+ $string = preg_replace('/&amp;#[Xx]0*((?:[0-9A-Fa-f]{2})+;)/', '&#x\1', $string);
+ // Named entities
+ $string = preg_replace('/&amp;([A-Za-z][A-Za-z0-9]*;)/', '&\1', $string);
+
+ return preg_replace_callback('%
+ (
+ <(?=[^a-zA-Z!/]) # a lone <
+ | # or
+ <!--.*?--> # a comment
+ | # or
+ <[^>]*(>|$) # a string that starts with a <, up until the > or the end of the string
+ | # or
+ > # just a >
+ )%x', '_filter_xss_split', $string);
+}
+
+/**
+ * Processes an HTML tag.
+ *
+ * @param $m
+ * An array with various meaning depending on the value of $store.
+ * If $store is TRUE then the array contains the allowed tags.
+ * If $store is FALSE then the array has one element, the HTML tag to process.
+ * @param $store
+ * Whether to store $m.
+ * @return
+ * If the element isn't allowed, an empty string. Otherwise, the cleaned up
+ * version of the HTML element.
+ */
+function _filter_xss_split($m, $store = FALSE) {
+ static $allowed_html;
+
+ if ($store) {
+ $allowed_html = array_flip($m);
+ return;
+ }
+
+ $string = $m[1];
+
+ if (substr($string, 0, 1) != '<') {
+ // We matched a lone ">" character
+ return '&gt;';
+ }
+ elseif (strlen($string) == 1) {
+ // We matched a lone "<" character
+ return '&lt;';
+ }
+
+ if (!preg_match('%^<\s*(/\s*)?([a-zA-Z0-9]+)([^>]*)>?|(<!--.*?-->)$%', $string, $matches)) {
+ // Seriously malformed
+ return '';
+ }
+
+ $slash = trim($matches[1]);
+ $elem = &$matches[2];
+ $attrlist = &$matches[3];
+ $comment = &$matches[4];
+
+ if ($comment) {
+ $elem = '!--';
+ }
+
+ if (!isset($allowed_html[strtolower($elem)])) {
+ // Disallowed HTML element
+ return '';
+ }
+
+ if ($comment) {
+ return $comment;
+ }
+
+ if ($slash != '') {
+ return "</$elem>";
+ }
+
+ // Is there a closing XHTML slash at the end of the attributes?
+ $attrlist = preg_replace('%(\s?)/\s*$%', '\1', $attrlist, -1, $count);
+ $xhtml_slash = $count ? ' /' : '';
+
+ // Clean up attributes
+ $attr2 = implode(' ', _filter_xss_attributes($attrlist));
+ $attr2 = preg_replace('/[<>]/', '', $attr2);
+ $attr2 = strlen($attr2) ? ' ' . $attr2 : '';
+
+ return "<$elem$attr2$xhtml_slash>";
+}
+
+/**
+ * Processes a string of HTML attributes.
+ *
+ * @return
+ * Cleaned up version of the HTML attributes.
+ */
+function _filter_xss_attributes($attr) {
+ $attrarr = array();
+ $mode = 0;
+ $attrname = '';
+
+ while (strlen($attr) != 0) {
+ // Was the last operation successful?
+ $working = 0;
+
+ switch ($mode) {
+ case 0:
+ // Attribute name, href for instance
+ if (preg_match('/^([-a-zA-Z]+)/', $attr, $match)) {
+ $attrname = strtolower($match[1]);
+ $skip = ($attrname == 'style' || substr($attrname, 0, 2) == 'on');
+ $working = $mode = 1;
+ $attr = preg_replace('/^[-a-zA-Z]+/', '', $attr);
+ }
+ break;
+
+ case 1:
+ // Equals sign or valueless ("selected")
+ if (preg_match('/^\s*=\s*/', $attr)) {
+ $working = 1; $mode = 2;
+ $attr = preg_replace('/^\s*=\s*/', '', $attr);
+ break;
+ }
+
+ if (preg_match('/^\s+/', $attr)) {
+ $working = 1; $mode = 0;
+ if (!$skip) {
+ $attrarr[] = $attrname;
+ }
+ $attr = preg_replace('/^\s+/', '', $attr);
+ }
+ break;
+
+ case 2:
+ // Attribute value, a URL after href= for instance
+ if (preg_match('/^"([^"]*)"(\s+|$)/', $attr, $match)) {
+ $thisval = filter_xss_bad_protocol($match[1]);
+
+ if (!$skip) {
+ $attrarr[] = "$attrname=\"$thisval\"";
+ }
+ $working = 1;
+ $mode = 0;
+ $attr = preg_replace('/^"[^"]*"(\s+|$)/', '', $attr);
+ break;
+ }
+
+ if (preg_match("/^'([^']*)'(\s+|$)/", $attr, $match)) {
+ $thisval = filter_xss_bad_protocol($match[1]);
+
+ if (!$skip) {
+ $attrarr[] = "$attrname='$thisval'";
+ }
+ $working = 1; $mode = 0;
+ $attr = preg_replace("/^'[^']*'(\s+|$)/", '', $attr);
+ break;
+ }
+
+ if (preg_match("%^([^\s\"']+)(\s+|$)%", $attr, $match)) {
+ $thisval = filter_xss_bad_protocol($match[1]);
+
+ if (!$skip) {
+ $attrarr[] = "$attrname=\"$thisval\"";
+ }
+ $working = 1; $mode = 0;
+ $attr = preg_replace("%^[^\s\"']+(\s+|$)%", '', $attr);
+ }
+ break;
+ }
+
+ if ($working == 0) {
+ // not well formed, remove and try again
+ $attr = preg_replace('/
+ ^
+ (
+ "[^"]*("|$) # - a string that starts with a double quote, up until the next double quote or the end of the string
+ | # or
+ \'[^\']*(\'|$)| # - a string that starts with a quote, up until the next quote or the end of the string
+ | # or
+ \S # - a non-whitespace character
+ )* # any number of the above three
+ \s* # any number of whitespaces
+ /x', '', $attr);
+ $mode = 0;
+ }
+ }
+
+ // The attribute list ends with a valueless attribute like "selected".
+ if ($mode == 1 && !$skip) {
+ $attrarr[] = $attrname;
+ }
+ return $attrarr;
+}
+
+/**
+ * Processes an HTML attribute value and ensures it does not contain an URL with a disallowed protocol (e.g. javascript:).
+ *
+ * @param $string
+ * The string with the attribute value.
+ * @param $decode
+ * (Deprecated) Whether to decode entities in the $string. Set to FALSE if the
+ * $string is in plain text, TRUE otherwise. Defaults to TRUE. This parameter
+ * is deprecated and will be removed in Drupal 8. To process a plain-text URI,
+ * call drupal_strip_dangerous_protocols() or check_url() instead.
+ * @return
+ * Cleaned up and HTML-escaped version of $string.
+ */
+function filter_xss_bad_protocol($string, $decode = TRUE) {
+ // Get the plain text representation of the attribute value (i.e. its meaning).
+ // @todo Remove the $decode parameter in Drupal 8, and always assume an HTML
+ // string that needs decoding.
+ if ($decode) {
+ if (!function_exists('decode_entities')) {
+ require_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+ }
+
+ $string = decode_entities($string);
+ }
+ return check_plain(drupal_strip_dangerous_protocols($string));
+}
+
+/**
+ * @} End of "defgroup sanitization".
+ */
+
+/**
+ * @defgroup format Formatting
+ * @{
+ * Functions to format numbers, strings, dates, etc.
+ */
+
+/**
+ * Formats an RSS channel.
+ *
+ * Arbitrary elements may be added using the $args associative array.
+ */
+function format_rss_channel($title, $link, $description, $items, $langcode = NULL, $args = array()) {
+ global $language_content;
+ $langcode = $langcode ? $langcode : $language_content->language;
+
+ $output = "<channel>\n";
+ $output .= ' <title>' . check_plain($title) . "</title>\n";
+ $output .= ' <link>' . check_url($link) . "</link>\n";
+
+ // The RSS 2.0 "spec" doesn't indicate HTML can be used in the description.
+ // We strip all HTML tags, but need to prevent double encoding from properly
+ // escaped source data (such as &amp becoming &amp;amp;).
+ $output .= ' <description>' . check_plain(decode_entities(strip_tags($description))) . "</description>\n";
+ $output .= ' <language>' . check_plain($langcode) . "</language>\n";
+ $output .= format_xml_elements($args);
+ $output .= $items;
+ $output .= "</channel>\n";
+
+ return $output;
+}
+
+/**
+ * Format a single RSS item.
+ *
+ * Arbitrary elements may be added using the $args associative array.
+ */
+function format_rss_item($title, $link, $description, $args = array()) {
+ $output = "<item>\n";
+ $output .= ' <title>' . check_plain($title) . "</title>\n";
+ $output .= ' <link>' . check_url($link) . "</link>\n";
+ $output .= ' <description>' . check_plain($description) . "</description>\n";
+ $output .= format_xml_elements($args);
+ $output .= "</item>\n";
+
+ return $output;
+}
+
+/**
+ * Format XML elements.
+ *
+ * @param $array
+ * An array where each item represents an element and is either a:
+ * - (key => value) pair (<key>value</key>)
+ * - Associative array with fields:
+ * - 'key': element name
+ * - 'value': element contents
+ * - 'attributes': associative array of element attributes
+ *
+ * In both cases, 'value' can be a simple string, or it can be another array
+ * with the same format as $array itself for nesting.
+ */
+function format_xml_elements($array) {
+ $output = '';
+ foreach ($array as $key => $value) {
+ if (is_numeric($key)) {
+ if ($value['key']) {
+ $output .= ' <' . $value['key'];
+ if (isset($value['attributes']) && is_array($value['attributes'])) {
+ $output .= drupal_attributes($value['attributes']);
+ }
+
+ if (isset($value['value']) && $value['value'] != '') {
+ $output .= '>' . (is_array($value['value']) ? format_xml_elements($value['value']) : check_plain($value['value'])) . '</' . $value['key'] . ">\n";
+ }
+ else {
+ $output .= " />\n";
+ }
+ }
+ }
+ else {
+ $output .= ' <' . $key . '>' . (is_array($value) ? format_xml_elements($value) : check_plain($value)) . "</$key>\n";
+ }
+ }
+ return $output;
+}
+
+/**
+ * Format a string containing a count of items.
+ *
+ * This function ensures that the string is pluralized correctly. Since t() is
+ * called by this function, make sure not to pass already-localized strings to
+ * it.
+ *
+ * For example:
+ * @code
+ * $output = format_plural($node->comment_count, '1 comment', '@count comments');
+ * @endcode
+ *
+ * Example with additional replacements:
+ * @code
+ * $output = format_plural($update_count,
+ * 'Changed the content type of 1 post from %old-type to %new-type.',
+ * 'Changed the content type of @count posts from %old-type to %new-type.',
+ * array('%old-type' => $info->old_type, '%new-type' => $info->new_type)));
+ * @endcode
+ *
+ * @param $count
+ * The item count to display.
+ * @param $singular
+ * The string for the singular case. Please make sure it is clear this is
+ * singular, to ease translation (e.g. use "1 new comment" instead of "1 new").
+ * Do not use @count in the singular string.
+ * @param $plural
+ * The string for the plural case. Please make sure it is clear this is plural,
+ * to ease translation. Use @count in place of the item count, as in "@count
+ * new comments".
+ * @param $args
+ * An associative array of replacements to make after translation. Incidences
+ * of any key in this array are replaced with the corresponding value.
+ * Based on the first character of the key, the value is escaped and/or themed:
+ * - !variable: inserted as is
+ * - @variable: escape plain text to HTML (check_plain)
+ * - %variable: escape text and theme as a placeholder for user-submitted
+ * content (check_plain + drupal_placeholder)
+ * Note that you do not need to include @count in this array.
+ * This replacement is done automatically for the plural case.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - 'langcode' (default to the current language) The language code to
+ * translate to a language other than what is used to display the page.
+ * - 'context' (default to the empty context) The context the source string
+ * belongs to.
+ * @return
+ * A translated string.
+ */
+function format_plural($count, $singular, $plural, array $args = array(), array $options = array()) {
+ $args['@count'] = $count;
+ if ($count == 1) {
+ return t($singular, $args, $options);
+ }
+
+ // Get the plural index through the gettext formula.
+ $index = (function_exists('locale_get_plural')) ? locale_get_plural($count, isset($options['langcode']) ? $options['langcode'] : NULL) : -1;
+ // Backwards compatibility.
+ if ($index < 0) {
+ return t($plural, $args, $options);
+ }
+ else {
+ switch ($index) {
+ case "0":
+ return t($singular, $args, $options);
+ case "1":
+ return t($plural, $args, $options);
+ default:
+ unset($args['@count']);
+ $args['@count[' . $index . ']'] = $count;
+ return t(strtr($plural, array('@count' => '@count[' . $index . ']')), $args, $options);
+ }
+ }
+}
+
+/**
+ * Parse a given byte count.
+ *
+ * @param $size
+ * A size expressed as a number of bytes with optional SI or IEC binary unit
+ * prefix (e.g. 2, 3K, 5MB, 10G, 6GiB, 8 bytes, 9mbytes).
+ * @return
+ * An integer representation of the size in bytes.
+ */
+function parse_size($size) {
+ $unit = preg_replace('/[^bkmgtpezy]/i', '', $size); // Remove the non-unit characters from the size.
+ $size = preg_replace('/[^0-9\.]/', '', $size); // Remove the non-numeric characters from the size.
+ if ($unit) {
+ // Find the position of the unit in the ordered string which is the power of magnitude to multiply a kilobyte by.
+ return round($size * pow(DRUPAL_KILOBYTE, stripos('bkmgtpezy', $unit[0])));
+ }
+ else {
+ return round($size);
+ }
+}
+
+/**
+ * Generate a string representation for the given byte count.
+ *
+ * @param $size
+ * A size in bytes.
+ * @param $langcode
+ * Optional language code to translate to a language other than what is used
+ * to display the page.
+ * @return
+ * A translated string representation of the size.
+ */
+function format_size($size, $langcode = NULL) {
+ if ($size < DRUPAL_KILOBYTE) {
+ return format_plural($size, '1 byte', '@count bytes', array(), array('langcode' => $langcode));
+ }
+ else {
+ $size = $size / DRUPAL_KILOBYTE; // Convert bytes to kilobytes.
+ $units = array(
+ t('@size KB', array(), array('langcode' => $langcode)),
+ t('@size MB', array(), array('langcode' => $langcode)),
+ t('@size GB', array(), array('langcode' => $langcode)),
+ t('@size TB', array(), array('langcode' => $langcode)),
+ t('@size PB', array(), array('langcode' => $langcode)),
+ t('@size EB', array(), array('langcode' => $langcode)),
+ t('@size ZB', array(), array('langcode' => $langcode)),
+ t('@size YB', array(), array('langcode' => $langcode)),
+ );
+ foreach ($units as $unit) {
+ if (round($size, 2) >= DRUPAL_KILOBYTE) {
+ $size = $size / DRUPAL_KILOBYTE;
+ }
+ else {
+ break;
+ }
+ }
+ return str_replace('@size', round($size, 2), $unit);
+ }
+}
+
+/**
+ * Format a time interval with the requested granularity.
+ *
+ * @param $timestamp
+ * The length of the interval in seconds.
+ * @param $granularity
+ * How many different units to display in the string.
+ * @param $langcode
+ * Optional language code to translate to a language other than
+ * what is used to display the page.
+ * @return
+ * A translated string representation of the interval.
+ */
+function format_interval($timestamp, $granularity = 2, $langcode = NULL) {
+ $units = array(
+ '1 year|@count years' => 31536000,
+ '1 month|@count months' => 2592000,
+ '1 week|@count weeks' => 604800,
+ '1 day|@count days' => 86400,
+ '1 hour|@count hours' => 3600,
+ '1 min|@count min' => 60,
+ '1 sec|@count sec' => 1
+ );
+ $output = '';
+ foreach ($units as $key => $value) {
+ $key = explode('|', $key);
+ if ($timestamp >= $value) {
+ $output .= ($output ? ' ' : '') . format_plural(floor($timestamp / $value), $key[0], $key[1], array(), array('langcode' => $langcode));
+ $timestamp %= $value;
+ $granularity--;
+ }
+
+ if ($granularity == 0) {
+ break;
+ }
+ }
+ return $output ? $output : t('0 sec', array(), array('langcode' => $langcode));
+}
+
+/**
+ * Formats a date, using a date type or a custom date format string.
+ *
+ * @param $timestamp
+ * A UNIX timestamp to format.
+ * @param $type
+ * (optional) The format to use, one of:
+ * - 'short', 'medium', or 'long' (the corresponding built-in date formats).
+ * - The name of a date type defined by a module in hook_date_format_types(),
+ * if it's been assigned a format.
+ * - The machine name of an administrator-defined date format.
+ * - 'custom', to use $format.
+ * Defaults to 'medium'.
+ * @param $format
+ * (optional) If $type is 'custom', a PHP date format string suitable for
+ * input to date(). Use a backslash to escape ordinary text, so it does not
+ * get interpreted as date format characters.
+ * @param $timezone
+ * (optional) Time zone identifier, as described at
+ * http://php.net/manual/en/timezones.php Defaults to the time zone used to
+ * display the page.
+ * @param $langcode
+ * (optional) Language code to translate to. Defaults to the language used to
+ * display the page.
+ *
+ * @return
+ * A translated date string in the requested format.
+ */
+function format_date($timestamp, $type = 'medium', $format = '', $timezone = NULL, $langcode = NULL) {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['timezones'] = &drupal_static(__FUNCTION__);
+ }
+ $timezones = &$drupal_static_fast['timezones'];
+
+ if (!isset($timezone)) {
+ $timezone = date_default_timezone_get();
+ }
+ // Store DateTimeZone objects in an array rather than repeatedly
+ // constructing identical objects over the life of a request.
+ if (!isset($timezones[$timezone])) {
+ $timezones[$timezone] = timezone_open($timezone);
+ }
+
+ // Use the default langcode if none is set.
+ global $language;
+ if (empty($langcode)) {
+ $langcode = isset($language->language) ? $language->language : LANGUAGE_SYSTEM;
+ }
+
+ switch ($type) {
+ case 'short':
+ $format = variable_get('date_format_short', 'm/d/Y - H:i');
+ break;
+
+ case 'long':
+ $format = variable_get('date_format_long', 'l, F j, Y - H:i');
+ break;
+
+ case 'custom':
+ // No change to format.
+ break;
+
+ case 'medium':
+ default:
+ // Retrieve the format of the custom $type passed.
+ if ($type != 'medium') {
+ $format = variable_get('date_format_' . $type, '');
+ }
+ // Fall back to 'medium'.
+ if ($format === '') {
+ $format = variable_get('date_format_medium', 'D, m/d/Y - H:i');
+ }
+ break;
+ }
+
+ // Create a DateTime object from the timestamp.
+ $date_time = date_create('@' . $timestamp);
+ // Set the time zone for the DateTime object.
+ date_timezone_set($date_time, $timezones[$timezone]);
+
+ // Encode markers that should be translated. 'A' becomes '\xEF\AA\xFF'.
+ // xEF and xFF are invalid UTF-8 sequences, and we assume they are not in the
+ // input string.
+ // Paired backslashes are isolated to prevent errors in read-ahead evaluation.
+ // The read-ahead expression ensures that A matches, but not \A.
+ $format = preg_replace(array('/\\\\\\\\/', '/(?<!\\\\)([AaeDlMTF])/'), array("\xEF\\\\\\\\\xFF", "\xEF\\\\\$1\$1\xFF"), $format);
+
+ // Call date_format().
+ $format = date_format($date_time, $format);
+
+ // Pass the langcode to _format_date_callback().
+ _format_date_callback(NULL, $langcode);
+
+ // Translate the marked sequences.
+ return preg_replace_callback('/\xEF([AaeDlMTF]?)(.*?)\xFF/', '_format_date_callback', $format);
+}
+
+/**
+ * Returns an ISO8601 formatted date based on the given date.
+ *
+ * Can be used as a callback for RDF mappings.
+ *
+ * @param $date
+ * A UNIX timestamp.
+ * @return string
+ * An ISO8601 formatted date.
+ */
+function date_iso8601($date) {
+ // The DATE_ISO8601 constant cannot be used here because it does not match
+ // date('c') and produces invalid RDF markup.
+ return date('c', $date);
+}
+
+/**
+ * Callback function for preg_replace_callback().
+ */
+function _format_date_callback(array $matches = NULL, $new_langcode = NULL) {
+ // We cache translations to avoid redundant and rather costly calls to t().
+ static $cache, $langcode;
+
+ if (!isset($matches)) {
+ $langcode = $new_langcode;
+ return;
+ }
+
+ $code = $matches[1];
+ $string = $matches[2];
+
+ if (!isset($cache[$langcode][$code][$string])) {
+ $options = array(
+ 'langcode' => $langcode,
+ );
+
+ if ($code == 'F') {
+ $options['context'] = 'Long month name';
+ }
+
+ if ($code == '') {
+ $cache[$langcode][$code][$string] = $string;
+ }
+ else {
+ $cache[$langcode][$code][$string] = t($string, array(), $options);
+ }
+ }
+ return $cache[$langcode][$code][$string];
+}
+
+/**
+ * Format a username.
+ *
+ * By default, the passed-in object's 'name' property is used if it exists, or
+ * else, the site-defined value for the 'anonymous' variable. However, a module
+ * may override this by implementing hook_username_alter(&$name, $account).
+ *
+ * @see hook_username_alter()
+ *
+ * @param $account
+ * The account object for the user whose name is to be formatted.
+ *
+ * @return
+ * An unsanitized string with the username to display. The code receiving
+ * this result must ensure that check_plain() is called on it before it is
+ * printed to the page.
+ */
+function format_username($account) {
+ $name = !empty($account->name) ? $account->name : variable_get('anonymous', t('Anonymous'));
+ drupal_alter('username', $name, $account);
+ return $name;
+}
+
+/**
+ * @} End of "defgroup format".
+ */
+
+/**
+ * Generates an internal or external URL.
+ *
+ * When creating links in modules, consider whether l() could be a better
+ * alternative than url().
+ *
+ * @param $path
+ * The internal path or external URL being linked to, such as "node/34" or
+ * "http://example.com/foo". A few notes:
+ * - If you provide a full URL, it will be considered an external URL.
+ * - If you provide only the path (e.g. "node/34"), it will be
+ * considered an internal link. In this case, it should be a system URL,
+ * and it will be replaced with the alias, if one exists. Additional query
+ * arguments for internal paths must be supplied in $options['query'], not
+ * included in $path.
+ * - If you provide an internal path and $options['alias'] is set to TRUE, the
+ * path is assumed already to be the correct path alias, and the alias is
+ * not looked up.
+ * - The special string '<front>' generates a link to the site's base URL.
+ * - If your external URL contains a query (e.g. http://example.com/foo?a=b),
+ * then you can either URL encode the query keys and values yourself and
+ * include them in $path, or use $options['query'] to let this function
+ * URL encode them.
+ * @param $options
+ * An associative array of additional options, with the following elements:
+ * - 'query': An array of query key/value-pairs (without any URL-encoding) to
+ * append to the URL.
+ * - 'fragment': A fragment identifier (named anchor) to append to the URL.
+ * Do not include the leading '#' character.
+ * - 'absolute': Defaults to FALSE. Whether to force the output to be an
+ * absolute link (beginning with http:). Useful for links that will be
+ * displayed outside the site, such as in an RSS feed.
+ * - 'alias': Defaults to FALSE. Whether the given path is a URL alias
+ * already.
+ * - 'external': Whether the given path is an external URL.
+ * - 'language': An optional language object. If the path being linked to is
+ * internal to the site, $options['language'] is used to look up the alias
+ * for the URL. If $options['language'] is omitted, the global $language_url
+ * will be used.
+ * - 'https': Whether this URL should point to a secure location. If not
+ * defined, the current scheme is used, so the user stays on http or https
+ * respectively. TRUE enforces HTTPS and FALSE enforces HTTP, but HTTPS can
+ * only be enforced when the variable 'https' is set to TRUE.
+ * - 'base_url': Only used internally, to modify the base URL when a language
+ * dependent URL requires so.
+ * - 'prefix': Only used internally, to modify the path when a language
+ * dependent URL requires so.
+ * - 'script': The script filename in Drupal's root directory to use when
+ * clean URLs are disabled, such as 'index.php'. Defaults to an empty
+ * string, as most modern web servers automatically find 'index.php'. If
+ * clean URLs are disabled, the value of $path is appended as query
+ * parameter 'q' to $options['script'] in the returned URL. When deploying
+ * Drupal on a web server that cannot be configured to automatically find
+ * index.php, then hook_url_outbound_alter() can be implemented to force
+ * this value to 'index.php'.
+ * - 'entity_type': The entity type of the object that called url(). Only set if
+ * url() is invoked by entity_uri().
+ * - 'entity': The entity object (such as a node) for which the URL is being
+ * generated. Only set if url() is invoked by entity_uri().
+ *
+ * @return
+ * A string containing a URL to the given path.
+ */
+function url($path = NULL, array $options = array()) {
+ // Merge in defaults.
+ $options += array(
+ 'fragment' => '',
+ 'query' => array(),
+ 'absolute' => FALSE,
+ 'alias' => FALSE,
+ 'prefix' => ''
+ );
+
+ if (!isset($options['external'])) {
+ // Return an external link if $path contains an allowed absolute URL. Only
+ // call the slow drupal_strip_dangerous_protocols() if $path contains a ':'
+ // before any / ? or #. Note: we could use url_is_external($path) here, but
+ // that would require another function call, and performance inside url() is
+ // critical.
+ $colonpos = strpos($path, ':');
+ $options['external'] = ($colonpos !== FALSE && !preg_match('![/?#]!', substr($path, 0, $colonpos)) && drupal_strip_dangerous_protocols($path) == $path);
+ }
+
+ // Preserve the original path before altering or aliasing.
+ $original_path = $path;
+
+ // Allow other modules to alter the outbound URL and options.
+ drupal_alter('url_outbound', $path, $options, $original_path);
+
+ if (isset($options['fragment']) && $options['fragment'] !== '') {
+ $options['fragment'] = '#' . $options['fragment'];
+ }
+
+ if ($options['external']) {
+ // Split off the fragment.
+ if (strpos($path, '#') !== FALSE) {
+ list($path, $old_fragment) = explode('#', $path, 2);
+ // If $options contains no fragment, take it over from the path.
+ if (isset($old_fragment) && !$options['fragment']) {
+ $options['fragment'] = '#' . $old_fragment;
+ }
+ }
+ // Append the query.
+ if ($options['query']) {
+ $path .= (strpos($path, '?') !== FALSE ? '&' : '?') . drupal_http_build_query($options['query']);
+ }
+ if (isset($options['https']) && variable_get('https', FALSE)) {
+ if ($options['https'] === TRUE) {
+ $path = str_replace('http://', 'https://', $path);
+ }
+ elseif ($options['https'] === FALSE) {
+ $path = str_replace('https://', 'http://', $path);
+ }
+ }
+ // Reassemble.
+ return $path . $options['fragment'];
+ }
+
+ global $base_url, $base_secure_url, $base_insecure_url;
+
+ // The base_url might be rewritten from the language rewrite in domain mode.
+ if (!isset($options['base_url'])) {
+ if (isset($options['https']) && variable_get('https', FALSE)) {
+ if ($options['https'] === TRUE) {
+ $options['base_url'] = $base_secure_url;
+ $options['absolute'] = TRUE;
+ }
+ elseif ($options['https'] === FALSE) {
+ $options['base_url'] = $base_insecure_url;
+ $options['absolute'] = TRUE;
+ }
+ }
+ else {
+ $options['base_url'] = $base_url;
+ }
+ }
+
+ // The special path '<front>' links to the default front page.
+ if ($path == '<front>') {
+ $path = '';
+ }
+ elseif (!empty($path) && !$options['alias']) {
+ $language = isset($options['language']) && isset($options['language']->language) ? $options['language']->language : '';
+ $alias = drupal_get_path_alias($original_path, $language);
+ if ($alias != $original_path) {
+ $path = $alias;
+ }
+ }
+
+ $base = $options['absolute'] ? $options['base_url'] . '/' : base_path();
+ $prefix = empty($path) ? rtrim($options['prefix'], '/') : $options['prefix'];
+
+ // With Clean URLs.
+ if (!empty($GLOBALS['conf']['clean_url'])) {
+ $path = drupal_encode_path($prefix . $path);
+ if ($options['query']) {
+ return $base . $path . '?' . drupal_http_build_query($options['query']) . $options['fragment'];
+ }
+ else {
+ return $base . $path . $options['fragment'];
+ }
+ }
+ // Without Clean URLs.
+ else {
+ $path = $prefix . $path;
+ $query = array();
+ if (!empty($path)) {
+ $query['q'] = $path;
+ }
+ if ($options['query']) {
+ // We do not use array_merge() here to prevent overriding $path via query
+ // parameters.
+ $query += $options['query'];
+ }
+ $query = $query ? ('?' . drupal_http_build_query($query)) : '';
+ $script = isset($options['script']) ? $options['script'] : '';
+ return $base . $script . $query . $options['fragment'];
+ }
+}
+
+/**
+ * Return TRUE if a path is external to Drupal (e.g. http://example.com).
+ *
+ * If a path cannot be assessed by Drupal's menu handler, then we must
+ * treat it as potentially insecure.
+ *
+ * @param $path
+ * The internal path or external URL being linked to, such as "node/34" or
+ * "http://example.com/foo".
+ * @return
+ * Boolean TRUE or FALSE, where TRUE indicates an external path.
+ */
+function url_is_external($path) {
+ $colonpos = strpos($path, ':');
+ // Avoid calling drupal_strip_dangerous_protocols() if there is any
+ // slash (/), hash (#) or question_mark (?) before the colon (:)
+ // occurrence - if any - as this would clearly mean it is not a URL.
+ return $colonpos !== FALSE && !preg_match('![/?#]!', substr($path, 0, $colonpos)) && drupal_strip_dangerous_protocols($path) == $path;
+}
+
+/**
+ * Format an attribute string for a HTTP header.
+ *
+ * @param $attributes
+ * An associative array of attributes such as 'rel'.
+ *
+ * @return
+ * A ; separated string ready for insertion in a HTTP header. No escaping is
+ * performed for HTML entities, so this string is not safe to be printed.
+ *
+ * @see drupal_add_http_header()
+ */
+function drupal_http_header_attributes(array $attributes = array()) {
+ foreach ($attributes as $attribute => &$data) {
+ if (is_array($data)) {
+ $data = implode(' ', $data);
+ }
+ $data = $attribute . '="' . $data . '"';
+ }
+ return $attributes ? ' ' . implode('; ', $attributes) : '';
+}
+
+/**
+ * Converts an associative array to an attribute string for use in XML/HTML tags.
+ *
+ * Each array key and its value will be formatted into an attribute string.
+ * If a value is itself an array, then its elements are concatenated to a single
+ * space-delimited string (for example, a class attribute with multiple values).
+ *
+ * Attribute values are sanitized by running them through check_plain().
+ * Attribute names are not automatically sanitized. When using user-supplied
+ * attribute names, it is strongly recommended to allow only white-listed names,
+ * since certain attributes carry security risks and can be abused.
+ *
+ * Examples of security aspects when using drupal_attributes:
+ * @code
+ * // By running the value in the following statement through check_plain,
+ * // the malicious script is neutralized.
+ * drupal_attributes(array('title' => t('<script>steal_cookie();</script>')));
+ *
+ * // The statement below demonstrates dangerous use of drupal_attributes, and
+ * // will return an onmouseout attribute with JavaScript code that, when used
+ * // as attribute in a tag, will cause users to be redirected to another site.
+ * //
+ * // In this case, the 'onmouseout' attribute should not be whitelisted --
+ * // you don't want users to have the ability to add this attribute or others
+ * // that take JavaScript commands.
+ * drupal_attributes(array('onmouseout' => 'window.location="http://malicious.com/";')));
+ * @endcode
+ *
+ * @param $attributes
+ * An associative array of key-value pairs to be converted to attributes.
+ *
+ * @return
+ * A string ready for insertion in a tag (starts with a space).
+ *
+ * @ingroup sanitization
+ */
+function drupal_attributes(array $attributes = array()) {
+ foreach ($attributes as $attribute => &$data) {
+ $data = implode(' ', (array) $data);
+ $data = $attribute . '="' . check_plain($data) . '"';
+ }
+ return $attributes ? ' ' . implode(' ', $attributes) : '';
+}
+
+/**
+ * Formats an internal or external URL link as an HTML anchor tag.
+ *
+ * This function correctly handles aliased paths, and adds an 'active' class
+ * attribute to links that point to the current page (for theming), so all
+ * internal links output by modules should be generated by this function if
+ * possible.
+ *
+ * @param $text
+ * The link text for the anchor tag.
+ * @param $path
+ * The internal path or external URL being linked to, such as "node/34" or
+ * "http://example.com/foo". After the url() function is called to construct
+ * the URL from $path and $options, the resulting URL is passed through
+ * check_plain() before it is inserted into the HTML anchor tag, to ensure
+ * well-formed HTML. See url() for more information and notes.
+ * @param array $options
+ * An associative array of additional options, with the following elements:
+ * - 'attributes': An associative array of HTML attributes to apply to the
+ * anchor tag. If element 'class' is included, it must be an array; 'title'
+ * must be a string; other elements are more flexible, as they just need
+ * to work in a call to drupal_attributes($options['attributes']).
+ * - 'html' (default FALSE): Whether $text is HTML or just plain-text. For
+ * example, to make an image tag into a link, this must be set to TRUE, or
+ * you will see the escaped HTML image tag. $text is not sanitized if
+ * 'html' is TRUE. The calling function must ensure that $text is already
+ * safe.
+ * - 'language': An optional language object. If the path being linked to is
+ * internal to the site, $options['language'] is used to determine whether
+ * the link is "active", or pointing to the current page (the language as
+ * well as the path must match). This element is also used by url().
+ * - Additional $options elements used by the url() function.
+ *
+ * @return
+ * An HTML string containing a link to the given path.
+ */
+function l($text, $path, array $options = array()) {
+ global $language_url;
+ static $use_theme = NULL;
+
+ // Merge in defaults.
+ $options += array(
+ 'attributes' => array(),
+ 'html' => FALSE,
+ );
+
+ // Append active class.
+ if (($path == $_GET['q'] || ($path == '<front>' && drupal_is_front_page())) &&
+ (empty($options['language']) || $options['language']->language == $language_url->language)) {
+ $options['attributes']['class'][] = 'active';
+ }
+
+ // Remove all HTML and PHP tags from a tooltip. For best performance, we act only
+ // if a quick strpos() pre-check gave a suspicion (because strip_tags() is expensive).
+ if (isset($options['attributes']['title']) && strpos($options['attributes']['title'], '<') !== FALSE) {
+ $options['attributes']['title'] = strip_tags($options['attributes']['title']);
+ }
+
+ // Determine if rendering of the link is to be done with a theme function
+ // or the inline default. Inline is faster, but if the theme system has been
+ // loaded and a module or theme implements a preprocess or process function
+ // or overrides the theme_link() function, then invoke theme(). Preliminary
+ // benchmarks indicate that invoking theme() can slow down the l() function
+ // by 20% or more, and that some of the link-heavy Drupal pages spend more
+ // than 10% of the total page request time in the l() function.
+ if (!isset($use_theme) && function_exists('theme')) {
+ // Allow edge cases to prevent theme initialization and force inline link
+ // rendering.
+ if (variable_get('theme_link', TRUE)) {
+ drupal_theme_initialize();
+ $registry = theme_get_registry();
+ // We don't want to duplicate functionality that's in theme(), so any
+ // hint of a module or theme doing anything at all special with the 'link'
+ // theme hook should simply result in theme() being called. This includes
+ // the overriding of theme_link() with an alternate function or template,
+ // the presence of preprocess or process functions, or the presence of
+ // include files.
+ $use_theme = !isset($registry['link']['function']) || ($registry['link']['function'] != 'theme_link');
+ $use_theme = $use_theme || !empty($registry['link']['preprocess functions']) || !empty($registry['link']['process functions']) || !empty($registry['link']['includes']);
+ }
+ else {
+ $use_theme = FALSE;
+ }
+ }
+ if ($use_theme) {
+ return theme('link', array('text' => $text, 'path' => $path, 'options' => $options));
+ }
+ // The result of url() is a plain-text URL. Because we are using it here
+ // in an HTML argument context, we need to encode it properly.
+ return '<a href="' . check_plain(url($path, $options)) . '"' . drupal_attributes($options['attributes']) . '>' . ($options['html'] ? $text : check_plain($text)) . '</a>';
+}
+
+/**
+ * Delivers a page callback result to the browser in the appropriate format.
+ *
+ * This function is most commonly called by menu_execute_active_handler(), but
+ * can also be called by error conditions such as drupal_not_found(),
+ * drupal_access_denied(), and drupal_site_offline().
+ *
+ * When a user requests a page, index.php calls menu_execute_active_handler(),
+ * which calls the 'page callback' function registered in hook_menu(). The page
+ * callback function can return one of:
+ * - NULL: to indicate no content.
+ * - An integer menu status constant: to indicate an error condition.
+ * - A string of HTML content.
+ * - A renderable array of content.
+ * Returning a renderable array rather than a string of HTML is preferred,
+ * because that provides modules with more flexibility in customizing the final
+ * result.
+ *
+ * When the page callback returns its constructed content to
+ * menu_execute_active_handler(), this function gets called. The purpose of
+ * this function is to determine the most appropriate 'delivery callback'
+ * function to route the content to. The delivery callback function then
+ * sends the content to the browser in the needed format. The default delivery
+ * callback is drupal_deliver_html_page(), which delivers the content as an HTML
+ * page, complete with blocks in addition to the content. This default can be
+ * overridden on a per menu router item basis by setting 'delivery callback' in
+ * hook_menu() or hook_menu_alter(), and can also be overridden on a per request
+ * basis in hook_page_delivery_callback_alter().
+ *
+ * For example, the same page callback function can be used for an HTML
+ * version of the page and an Ajax version of the page. The page callback
+ * function just needs to decide what content is to be returned and the
+ * delivery callback function will send it as an HTML page or an Ajax
+ * response, as appropriate.
+ *
+ * In order for page callbacks to be reusable in different delivery formats,
+ * they should not issue any "print" or "echo" statements, but instead just
+ * return content.
+ *
+ * Also note that this function does not perform access checks. The delivery
+ * callback function specified in hook_menu(), hook_menu_alter(), or
+ * hook_page_delivery_callback_alter() will be called even if the router item
+ * access checks fail. This is intentional (it is needed for JSON and other
+ * purposes), but it has security implications. Do not call this function
+ * directly unless you understand the security implications, and be careful in
+ * writing delivery callbacks, so that they do not violate security. See
+ * drupal_deliver_html_page() for an example of a delivery callback that
+ * respects security.
+ *
+ * @param $page_callback_result
+ * The result of a page callback. Can be one of:
+ * - NULL: to indicate no content.
+ * - An integer menu status constant: to indicate an error condition.
+ * - A string of HTML content.
+ * - A renderable array of content.
+ * @param $default_delivery_callback
+ * (Optional) If given, it is the name of a delivery function most likely
+ * to be appropriate for the page request as determined by the calling
+ * function (e.g., menu_execute_active_handler()). If not given, it is
+ * determined from the menu router information of the current page.
+ *
+ * @see menu_execute_active_handler()
+ * @see hook_menu()
+ * @see hook_menu_alter()
+ * @see hook_page_delivery_callback_alter()
+ */
+function drupal_deliver_page($page_callback_result, $default_delivery_callback = NULL) {
+ if (!isset($default_delivery_callback) && ($router_item = menu_get_item())) {
+ $default_delivery_callback = $router_item['delivery_callback'];
+ }
+ $delivery_callback = !empty($default_delivery_callback) ? $default_delivery_callback : 'drupal_deliver_html_page';
+ // Give modules a chance to alter the delivery callback used, based on
+ // request-time context (e.g., HTTP request headers).
+ drupal_alter('page_delivery_callback', $delivery_callback);
+ if (function_exists($delivery_callback)) {
+ $delivery_callback($page_callback_result);
+ }
+ else {
+ // If a delivery callback is specified, but doesn't exist as a function,
+ // something is wrong, but don't print anything, since it's not known
+ // what format the response needs to be in.
+ watchdog('delivery callback not found', 'callback %callback not found: %q.', array('%callback' => $delivery_callback, '%q' => $_GET['q']), WATCHDOG_ERROR);
+ }
+}
+
+/**
+ * Package and send the result of a page callback to the browser as HTML.
+ *
+ * @param $page_callback_result
+ * The result of a page callback. Can be one of:
+ * - NULL: to indicate no content.
+ * - An integer menu status constant: to indicate an error condition.
+ * - A string of HTML content.
+ * - A renderable array of content.
+ *
+ * @see drupal_deliver_page()
+ */
+function drupal_deliver_html_page($page_callback_result) {
+ // Emit the correct charset HTTP header, but not if the page callback
+ // result is NULL, since that likely indicates that it printed something
+ // in which case, no further headers may be sent, and not if code running
+ // for this page request has already set the content type header.
+ if (isset($page_callback_result) && is_null(drupal_get_http_header('Content-Type'))) {
+ drupal_add_http_header('Content-Type', 'text/html; charset=utf-8');
+ }
+
+ // Send appropriate HTTP-Header for browsers and search engines.
+ global $language;
+ drupal_add_http_header('Content-Language', $language->language);
+
+ // Menu status constants are integers; page content is a string or array.
+ if (is_int($page_callback_result)) {
+ // @todo: Break these up into separate functions?
+ switch ($page_callback_result) {
+ case MENU_NOT_FOUND:
+ // Print a 404 page.
+ drupal_add_http_header('Status', '404 Not Found');
+
+ watchdog('page not found', check_plain($_GET['q']), NULL, WATCHDOG_WARNING);
+
+ // Check for and return a fast 404 page if configured.
+ drupal_fast_404();
+
+ // Keep old path for reference, and to allow forms to redirect to it.
+ if (!isset($_GET['destination'])) {
+ $_GET['destination'] = $_GET['q'];
+ }
+
+ $path = drupal_get_normal_path(variable_get('site_404', ''));
+ if ($path && $path != $_GET['q']) {
+ // Custom 404 handler. Set the active item in case there are tabs to
+ // display, or other dependencies on the path.
+ menu_set_active_item($path);
+ $return = menu_execute_active_handler($path, FALSE);
+ }
+
+ if (empty($return) || $return == MENU_NOT_FOUND || $return == MENU_ACCESS_DENIED) {
+ // Standard 404 handler.
+ drupal_set_title(t('Page not found'));
+ $return = t('The requested page "@path" could not be found.', array('@path' => request_uri()));
+ }
+
+ drupal_set_page_content($return);
+ $page = element_info('page');
+ print drupal_render_page($page);
+ break;
+
+ case MENU_ACCESS_DENIED:
+ // Print a 403 page.
+ drupal_add_http_header('Status', '403 Forbidden');
+ watchdog('access denied', check_plain($_GET['q']), NULL, WATCHDOG_WARNING);
+
+ // Keep old path for reference, and to allow forms to redirect to it.
+ if (!isset($_GET['destination'])) {
+ $_GET['destination'] = $_GET['q'];
+ }
+
+ $path = drupal_get_normal_path(variable_get('site_403', ''));
+ if ($path && $path != $_GET['q']) {
+ // Custom 403 handler. Set the active item in case there are tabs to
+ // display or other dependencies on the path.
+ menu_set_active_item($path);
+ $return = menu_execute_active_handler($path, FALSE);
+ }
+
+ if (empty($return) || $return == MENU_NOT_FOUND || $return == MENU_ACCESS_DENIED) {
+ // Standard 403 handler.
+ drupal_set_title(t('Access denied'));
+ $return = t('You are not authorized to access this page.');
+ }
+
+ print drupal_render_page($return);
+ break;
+
+ case MENU_SITE_OFFLINE:
+ // Print a 503 page.
+ drupal_maintenance_theme();
+ drupal_add_http_header('Status', '503 Service unavailable');
+ drupal_set_title(t('Site under maintenance'));
+ print theme('maintenance_page', array('content' => filter_xss_admin(variable_get('maintenance_mode_message',
+ t('@site is currently under maintenance. We should be back shortly. Thank you for your patience.', array('@site' => variable_get('site_name', 'Drupal')))))));
+ break;
+ }
+ }
+ elseif (isset($page_callback_result)) {
+ // Print anything besides a menu constant, assuming it's not NULL or
+ // undefined.
+ print drupal_render_page($page_callback_result);
+ }
+
+ // Perform end-of-request tasks.
+ drupal_page_footer();
+}
+
+/**
+ * Perform end-of-request tasks.
+ *
+ * This function sets the page cache if appropriate, and allows modules to
+ * react to the closing of the page by calling hook_exit().
+ */
+function drupal_page_footer() {
+ global $user;
+
+ module_invoke_all('exit');
+
+ // Commit the user session, if needed.
+ drupal_session_commit();
+
+ if (variable_get('cache', 0) && ($cache = drupal_page_set_cache())) {
+ drupal_serve_page_from_cache($cache);
+ }
+ else {
+ ob_flush();
+ }
+
+ _registry_check_code(REGISTRY_WRITE_LOOKUP_CACHE);
+ drupal_cache_system_paths();
+ module_implements_write_cache();
+ system_run_automated_cron();
+}
+
+/**
+ * Perform end-of-request tasks.
+ *
+ * In some cases page requests need to end without calling drupal_page_footer().
+ * In these cases, call drupal_exit() instead. There should rarely be a reason
+ * to call exit instead of drupal_exit();
+ *
+ * @param $destination
+ * If this function is called from drupal_goto(), then this argument
+ * will be a fully-qualified URL that is the destination of the redirect.
+ * This should be passed along to hook_exit() implementations.
+ */
+function drupal_exit($destination = NULL) {
+ if (drupal_get_bootstrap_phase() == DRUPAL_BOOTSTRAP_FULL) {
+ if (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update') {
+ module_invoke_all('exit', $destination);
+ }
+ drupal_session_commit();
+ }
+ exit;
+}
+
+/**
+ * Form an associative array from a linear array.
+ *
+ * This function walks through the provided array and constructs an associative
+ * array out of it. The keys of the resulting array will be the values of the
+ * input array. The values will be the same as the keys unless a function is
+ * specified, in which case the output of the function is used for the values
+ * instead.
+ *
+ * @param $array
+ * A linear array.
+ * @param $function
+ * A name of a function to apply to all values before output.
+ *
+ * @return
+ * An associative array.
+ */
+function drupal_map_assoc($array, $function = NULL) {
+ // array_combine() fails with empty arrays:
+ // http://bugs.php.net/bug.php?id=34857.
+ $array = !empty($array) ? array_combine($array, $array) : array();
+ if (is_callable($function)) {
+ $array = array_map($function, $array);
+ }
+ return $array;
+}
+
+/**
+ * Attempts to set the PHP maximum execution time.
+ *
+ * This function is a wrapper around the PHP function set_time_limit().
+ * When called, set_time_limit() restarts the timeout counter from zero.
+ * In other words, if the timeout is the default 30 seconds, and 25 seconds
+ * into script execution a call such as set_time_limit(20) is made, the
+ * script will run for a total of 45 seconds before timing out.
+ *
+ * It also means that it is possible to decrease the total time limit if
+ * the sum of the new time limit and the current time spent running the
+ * script is inferior to the original time limit. It is inherent to the way
+ * set_time_limit() works, it should rather be called with an appropriate
+ * value every time you need to allocate a certain amount of time
+ * to execute a task than only once at the beginning of the script.
+ *
+ * Before calling set_time_limit(), we check if this function is available
+ * because it could be disabled by the server administrator. We also hide all
+ * the errors that could occur when calling set_time_limit(), because it is
+ * not possible to reliably ensure that PHP or a security extension will
+ * not issue a warning/error if they prevent the use of this function.
+ *
+ * @param $time_limit
+ * An integer specifying the new time limit, in seconds. A value of 0
+ * indicates unlimited execution time.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_set_time_limit($time_limit) {
+ if (function_exists('set_time_limit')) {
+ @set_time_limit($time_limit);
+ }
+}
+
+/**
+ * Returns the path to a system item (module, theme, etc.).
+ *
+ * @param $type
+ * The type of the item (i.e. theme, theme_engine, module, profile).
+ * @param $name
+ * The name of the item for which the path is requested.
+ *
+ * @return
+ * The path to the requested item.
+ */
+function drupal_get_path($type, $name) {
+ return dirname(drupal_get_filename($type, $name));
+}
+
+/**
+ * Return the base URL path (i.e., directory) of the Drupal installation.
+ *
+ * base_path() prefixes and suffixes a "/" onto the returned path if the path is
+ * not empty. At the very least, this will return "/".
+ *
+ * Examples:
+ * - http://example.com returns "/" because the path is empty.
+ * - http://example.com/drupal/folder returns "/drupal/folder/".
+ */
+function base_path() {
+ return $GLOBALS['base_path'];
+}
+
+/**
+ * Add a LINK tag with a distinct 'rel' attribute to the page's HEAD.
+ *
+ * This function can be called as long the HTML header hasn't been sent,
+ * which on normal pages is up through the preprocess step of theme('html').
+ * Adding a link will overwrite a prior link with the exact same 'rel' and
+ * 'href' attributes.
+ *
+ * @param $attributes
+ * Associative array of element attributes including 'href' and 'rel'.
+ * @param $header
+ * Optional flag to determine if a HTTP 'Link:' header should be sent.
+ */
+function drupal_add_html_head_link($attributes, $header = FALSE) {
+ $element = array(
+ '#tag' => 'link',
+ '#attributes' => $attributes,
+ );
+ $href = $attributes['href'];
+
+ if ($header) {
+ // Also add a HTTP header "Link:".
+ $href = '<' . check_plain($attributes['href']) . '>;';
+ unset($attributes['href']);
+ $element['#attached']['drupal_add_http_header'][] = array('Link', $href . drupal_http_header_attributes($attributes), TRUE);
+ }
+
+ drupal_add_html_head($element, 'drupal_add_html_head_link:' . $attributes['rel'] . ':' . $href);
+}
+
+/**
+ * Adds a cascading stylesheet to the stylesheet queue.
+ *
+ * Calling drupal_static_reset('drupal_add_css') will clear all cascading
+ * stylesheets added so far.
+ *
+ * If CSS aggregation/compression is enabled, all cascading style sheets added
+ * with $options['preprocess'] set to TRUE will be merged into one aggregate
+ * file and compressed by removing all extraneous white space.
+ * Preprocessed inline stylesheets will not be aggregated into this single file;
+ * instead, they are just compressed upon output on the page. Externally hosted
+ * stylesheets are never aggregated or compressed.
+ *
+ * The reason for aggregating the files is outlined quite thoroughly here:
+ * http://www.die.net/musings/page_load_time/ "Load fewer external objects. Due
+ * to request overhead, one bigger file just loads faster than two smaller ones
+ * half its size."
+ *
+ * $options['preprocess'] should be only set to TRUE when a file is required for
+ * all typical visitors and most pages of a site. It is critical that all
+ * preprocessed files are added unconditionally on every page, even if the
+ * files do not happen to be needed on a page. This is normally done by calling
+ * drupal_add_css() in a hook_init() implementation.
+ *
+ * Non-preprocessed files should only be added to the page when they are
+ * actually needed.
+ *
+ * @param $data
+ * (optional) The stylesheet data to be added, depending on what is passed
+ * through to the $options['type'] parameter:
+ * - 'file': The path to the CSS file relative to the base_path(), or a
+ * stream wrapper URI. For example: "modules/devel/devel.css" or
+ * "public://generated_css/stylesheet_1.css". Note that Modules should
+ * always prefix the names of their CSS files with the module name; for
+ * example, system-menus.css rather than simply menus.css. Themes can
+ * override module-supplied CSS files based on their filenames, and this
+ * prefixing helps prevent confusing name collisions for theme developers.
+ * See drupal_get_css() where the overrides are performed. Also, if the
+ * direction of the current language is right-to-left (Hebrew, Arabic,
+ * etc.), the function will also look for an RTL CSS file and append it to
+ * the list. The name of this file should have an '-rtl.css' suffix. For
+ * example a CSS file called 'mymodule-name.css' will have a
+ * 'mymodule-name-rtl.css' file added to the list, if exists in the same
+ * directory. This CSS file should contain overrides for properties which
+ * should be reversed or otherwise different in a right-to-left display.
+ * - 'inline': A string of CSS that should be placed in the given scope. Note
+ * that it is better practice to use 'file' stylesheets, rather than
+ * 'inline', as the CSS would then be aggregated and cached.
+ * - 'external': The absolute path to an external CSS file that is not hosted
+ * on the local server. These files will not be aggregated if CSS
+ * aggregation is enabled.
+ * @param $options
+ * (optional) A string defining the 'type' of CSS that is being added in the
+ * $data parameter ('file', 'inline', or 'external'), or an array which can
+ * have any or all of the following keys:
+ * - 'type': The type of stylesheet being added. Available options are 'file',
+ * 'inline' or 'external'. Defaults to 'file'.
+ * - 'basename': Force a basename for the file being added. Modules are
+ * expected to use stylesheets with unique filenames, but integration of
+ * external libraries may make this impossible. The basename of
+ * 'core/modules/node/node.css' is 'node.css'. If the external library
+ * "node.js" ships with a 'node.css', then a different, unique basename
+ * would be 'node.js.css'.
+ * - 'group': A number identifying the group in which to add the stylesheet.
+ * Available constants are:
+ * - CSS_SYSTEM: Any system-layer CSS.
+ * - CSS_DEFAULT: Any module-layer CSS.
+ * - CSS_THEME: Any theme-layer CSS.
+ * The group number serves as a weight: the markup for loading a stylesheet
+ * within a lower weight group is output to the page before the markup for
+ * loading a stylesheet within a higher weight group, so CSS within higher
+ * weight groups take precendence over CSS within lower weight groups.
+ * - 'every_page': For optimal front-end performance when aggregation is
+ * enabled, this should be set to TRUE if the stylesheet is present on every
+ * page of the website for users for whom it is present at all. This
+ * defaults to FALSE. It is set to TRUE for stylesheets added via module and
+ * theme .info files. Modules that add stylesheets within hook_init()
+ * implementations, or from other code that ensures that the stylesheet is
+ * added to all website pages, should also set this flag to TRUE. All
+ * stylesheets within the same group that have the 'every_page' flag set to
+ * TRUE and do not have 'preprocess' set to FALSE are aggregated together
+ * into a single aggregate file, and that aggregate file can be reused
+ * across a user's entire site visit, leading to faster navigation between
+ * pages. However, stylesheets that are only needed on pages less frequently
+ * visited, can be added by code that only runs for those particular pages,
+ * and that code should not set the 'every_page' flag. This minimizes the
+ * size of the aggregate file that the user needs to download when first
+ * visiting the website. Stylesheets without the 'every_page' flag are
+ * aggregated into a separate aggregate file. This other aggregate file is
+ * likely to change from page to page, and each new aggregate file needs to
+ * be downloaded when first encountered, so it should be kept relatively
+ * small by ensuring that most commonly needed stylesheets are added to
+ * every page.
+ * - 'weight': The weight of the stylesheet specifies the order in which the
+ * CSS will appear relative to other stylesheets with the same group and
+ * 'every_page' flag. The exact ordering of stylesheets is as follows:
+ * - First by group.
+ * - Then by the 'every_page' flag, with TRUE coming before FALSE.
+ * - Then by weight.
+ * - Then by the order in which the CSS was added. For example, all else
+ * being the same, a stylesheet added by a call to drupal_add_css() that
+ * happened later in the page request gets added to the page after one for
+ * which drupal_add_css() happened earlier in the page request.
+ * - 'media': The media type for the stylesheet, e.g., all, print, screen.
+ * Defaults to 'all'.
+ * - 'preprocess': If TRUE and CSS aggregation/compression is enabled, the
+ * styles will be aggregated and compressed. Defaults to TRUE.
+ * - 'browsers': An array containing information specifying which browsers
+ * should load the CSS item. See drupal_pre_render_conditional_comments()
+ * for details.
+ *
+ * @return
+ * An array of queued cascading stylesheets.
+ *
+ * @see drupal_get_css()
+ */
+function drupal_add_css($data = NULL, $options = NULL) {
+ $css = &drupal_static(__FUNCTION__, array());
+
+ // Construct the options, taking the defaults into consideration.
+ if (isset($options)) {
+ if (!is_array($options)) {
+ $options = array('type' => $options);
+ }
+ }
+ else {
+ $options = array();
+ }
+
+ // Create an array of CSS files for each media type first, since each type needs to be served
+ // to the browser differently.
+ if (isset($data)) {
+ $options += array(
+ 'type' => 'file',
+ 'group' => CSS_DEFAULT,
+ 'weight' => 0,
+ 'every_page' => FALSE,
+ 'media' => 'all',
+ 'preprocess' => TRUE,
+ 'data' => $data,
+ 'browsers' => array(),
+ );
+ $options['browsers'] += array(
+ 'IE' => TRUE,
+ '!IE' => TRUE,
+ );
+
+ // Files with a query string cannot be preprocessed.
+ if ($options['type'] === 'file' && $options['preprocess'] && strpos($options['data'], '?') !== FALSE) {
+ $options['preprocess'] = FALSE;
+ }
+
+ // Always add a tiny value to the weight, to conserve the insertion order.
+ $options['weight'] += count($css) / 1000;
+
+ // Add the data to the CSS array depending on the type.
+ switch ($options['type']) {
+ case 'inline':
+ // For inline stylesheets, we don't want to use the $data as the array
+ // key as $data could be a very long string of CSS.
+ $css[] = $options;
+ break;
+ default:
+ // Local and external files must keep their name as the associative key
+ // so the same CSS file is not be added twice.
+ $css[$data] = $options;
+ }
+ }
+
+ return $css;
+}
+
+/**
+ * Returns a themed representation of all stylesheets that should be attached to the page.
+ *
+ * It loads the CSS in order, with 'module' first, then 'theme' afterwards.
+ * This ensures proper cascading of styles so themes can easily override
+ * module styles through CSS selectors.
+ *
+ * Themes may replace module-defined CSS files by adding a stylesheet with the
+ * same filename. For example, themes/bartik/system-menus.css would replace
+ * modules/system/system-menus.css. This allows themes to override complete
+ * CSS files, rather than specific selectors, when necessary.
+ *
+ * If the original CSS file is being overridden by a theme, the theme is
+ * responsible for supplying an accompanying RTL CSS file to replace the
+ * module's.
+ *
+ * @param $css
+ * (optional) An array of CSS files. If no array is provided, the default
+ * stylesheets array is used instead.
+ * @param $skip_alter
+ * (optional) If set to TRUE, this function skips calling drupal_alter() on
+ * $css, useful when the calling function passes a $css array that has already
+ * been altered.
+ *
+ * @return
+ * A string of XHTML CSS tags.
+ *
+ * @see drupal_add_css()
+ */
+function drupal_get_css($css = NULL, $skip_alter = FALSE) {
+ if (!isset($css)) {
+ $css = drupal_add_css();
+ }
+
+ // Allow modules and themes to alter the CSS items.
+ if (!$skip_alter) {
+ drupal_alter('css', $css);
+ }
+
+ // Sort CSS items, so that they appear in the correct order.
+ uasort($css, 'drupal_sort_css_js');
+
+ // Remove the overridden CSS files. Later CSS files override former ones.
+ $previous_item = array();
+ foreach ($css as $key => $item) {
+ if ($item['type'] == 'file') {
+ // If defined, force a unique basename for this file.
+ $basename = isset($item['basename']) ? $item['basename'] : basename($item['data']);
+ if (isset($previous_item[$basename])) {
+ // Remove the previous item that shared the same base name.
+ unset($css[$previous_item[$basename]]);
+ }
+ $previous_item[$basename] = $key;
+ }
+ }
+
+ // Render the HTML needed to load the CSS.
+ $styles = array(
+ '#type' => 'styles',
+ '#items' => $css,
+ );
+
+ // Provide the page with information about the individual CSS files used,
+ // information not otherwise available when CSS aggregation is enabled.
+ $setting['ajaxPageState']['css'] = array_fill_keys(array_keys($css), 1);
+ $styles['#attached']['js'][] = array('type' => 'setting', 'data' => $setting);
+
+ return drupal_render($styles);
+}
+
+/**
+ * Function used by uasort to sort the array structures returned by drupal_add_css() and drupal_add_js().
+ *
+ * This sort order helps optimize front-end performance while providing modules
+ * and themes with the necessary control for ordering the CSS and JavaScript
+ * appearing on a page.
+ */
+function drupal_sort_css_js($a, $b) {
+ // First order by group, so that, for example, all items in the CSS_SYSTEM
+ // group appear before items in the CSS_DEFAULT group, which appear before
+ // all items in the CSS_THEME group. Modules may create additional groups by
+ // defining their own constants.
+ if ($a['group'] < $b['group']) {
+ return -1;
+ }
+ elseif ($a['group'] > $b['group']) {
+ return 1;
+ }
+ // Within a group, order all infrequently needed, page-specific files after
+ // common files needed throughout the website. Separating this way allows for
+ // the aggregate file generated for all of the common files to be reused
+ // across a site visit without being cut by a page using a less common file.
+ elseif ($a['every_page'] && !$b['every_page']) {
+ return -1;
+ }
+ elseif (!$a['every_page'] && $b['every_page']) {
+ return 1;
+ }
+ // Finally, order by weight.
+ elseif ($a['weight'] < $b['weight']) {
+ return -1;
+ }
+ elseif ($a['weight'] > $b['weight']) {
+ return 1;
+ }
+ else {
+ return 0;
+ }
+}
+
+/**
+ * Default callback to group CSS items.
+ *
+ * This function arranges the CSS items that are in the #items property of the
+ * styles element into groups. Arranging the CSS items into groups serves two
+ * purposes. When aggregation is enabled, files within a group are aggregated
+ * into a single file, significantly improving page loading performance by
+ * minimizing network traffic overhead. When aggregation is disabled, grouping
+ * allows multiple files to be loaded from a single STYLE tag, enabling sites
+ * with many modules enabled or a complex theme being used to stay within IE's
+ * 31 CSS inclusion tag limit: http://drupal.org/node/228818.
+ *
+ * This function puts multiple items into the same group if they are groupable
+ * and if they are for the same 'media' and 'browsers'. Items of the 'file' type
+ * are groupable if their 'preprocess' flag is TRUE, items of the 'inline' type
+ * are always groupable, and items of the 'external' type are never groupable.
+ * This function also ensures that the process of grouping items does not change
+ * their relative order. This requirement may result in multiple groups for the
+ * same type, media, and browsers, if needed to accommodate other items in
+ * between.
+ *
+ * @param $css
+ * An array of CSS items, as returned by drupal_add_css(), but after
+ * alteration performed by drupal_get_css().
+ *
+ * @return
+ * An array of CSS groups. Each group contains the same keys (e.g., 'media',
+ * 'data', etc.) as a CSS item from the $css parameter, with the value of
+ * each key applying to the group as a whole. Each group also contains an
+ * 'items' key, which is the subset of items from $css that are in the group.
+ *
+ * @see drupal_pre_render_styles()
+ */
+function drupal_group_css($css) {
+ $groups = array();
+ // If a group can contain multiple items, we track the information that must
+ // be the same for each item in the group, so that when we iterate the next
+ // item, we can determine if it can be put into the current group, or if a
+ // new group needs to be made for it.
+ $current_group_keys = NULL;
+ // When creating a new group, we pre-increment $i, so by initializing it to
+ // -1, the first group will have index 0.
+ $i = -1;
+ foreach ($css as $item) {
+ // The browsers for which the CSS item needs to be loaded is part of the
+ // information that determines when a new group is needed, but the order of
+ // keys in the array doesn't matter, and we don't want a new group if all
+ // that's different is that order.
+ ksort($item['browsers']);
+
+ // If the item can be grouped with other items, set $group_keys to an array
+ // of information that must be the same for all items in its group. If the
+ // item can't be grouped with other items, set $group_keys to FALSE. We
+ // put items into a group that can be aggregated together: whether they will
+ // be aggregated is up to the _drupal_css_aggregate() function or an
+ // override of that function specified in hook_css_alter(), but regardless
+ // of the details of that function, a group represents items that can be
+ // aggregated. Since a group may be rendered with a single HTML tag, all
+ // items in the group must share the same information that would need to be
+ // part of that HTML tag.
+ switch ($item['type']) {
+ case 'file':
+ // Group file items if their 'preprocess' flag is TRUE.
+ // Help ensure maximum reuse of aggregate files by only grouping
+ // together items that share the same 'group' value and 'every_page'
+ // flag. See drupal_add_css() for details about that.
+ $group_keys = $item['preprocess'] ? array($item['type'], $item['group'], $item['every_page'], $item['media'], $item['browsers']) : FALSE;
+ break;
+ case 'inline':
+ // Always group inline items.
+ $group_keys = array($item['type'], $item['media'], $item['browsers']);
+ break;
+ case 'external':
+ // Do not group external items.
+ $group_keys = FALSE;
+ break;
+ }
+
+ // If the group keys don't match the most recent group we're working with,
+ // then a new group must be made.
+ if ($group_keys !== $current_group_keys) {
+ $i++;
+ // Initialize the new group with the same properties as the first item
+ // being placed into it. The item's 'data' and 'weight' properties are
+ // unique to the item and should not be carried over to the group.
+ $groups[$i] = $item;
+ unset($groups[$i]['data'], $groups[$i]['weight']);
+ $groups[$i]['items'] = array();
+ $current_group_keys = $group_keys ? $group_keys : NULL;
+ }
+
+ // Add the item to the current group.
+ $groups[$i]['items'][] = $item;
+ }
+ return $groups;
+}
+
+/**
+ * Default callback to aggregate CSS files and inline content.
+ *
+ * Having the browser load fewer CSS files results in much faster page loads
+ * than when it loads many small files. This function aggregates files within
+ * the same group into a single file unless the site-wide setting to do so is
+ * disabled (commonly the case during site development). To optimize download,
+ * it also compresses the aggregate files by removing comments, whitespace, and
+ * other unnecessary content. Additionally, this functions aggregates inline
+ * content together, regardless of the site-wide aggregation setting.
+ *
+ * @param $css_groups
+ * An array of CSS groups as returned by drupal_group_css(). This function
+ * modifies the group's 'data' property for each group that is aggregated.
+ *
+ * @see drupal_group_css()
+ * @see drupal_pre_render_styles()
+ */
+function drupal_aggregate_css(&$css_groups) {
+ $preprocess_css = (variable_get('preprocess_css', FALSE) && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update'));
+
+ // For each group that needs aggregation, aggregate its items.
+ foreach ($css_groups as $key => $group) {
+ switch ($group['type']) {
+ // If a file group can be aggregated into a single file, do so, and set
+ // the group's data property to the file path of the aggregate file.
+ case 'file':
+ if ($group['preprocess'] && $preprocess_css) {
+ $css_groups[$key]['data'] = drupal_build_css_cache($group['items']);
+ }
+ break;
+ // Aggregate all inline CSS content into the group's data property.
+ case 'inline':
+ $css_groups[$key]['data'] = '';
+ foreach ($group['items'] as $item) {
+ $css_groups[$key]['data'] .= drupal_load_stylesheet_content($item['data'], $item['preprocess']);
+ }
+ break;
+ }
+ }
+}
+
+/**
+ * #pre_render callback to add the elements needed for CSS tags to be rendered.
+ *
+ * For production websites, LINK tags are preferable to STYLE tags with @import
+ * statements, because:
+ * - They are the standard tag intended for linking to a resource.
+ * - On Firefox 2 and perhaps other browsers, CSS files included with @import
+ * statements don't get saved when saving the complete web page for offline
+ * use: http://drupal.org/node/145218.
+ * - On IE, if only LINK tags and no @import statements are used, all the CSS
+ * files are downloaded in parallel, resulting in faster page load, but if
+ * @import statements are used and span across multiple STYLE tags, all the
+ * ones from one STYLE tag must be downloaded before downloading begins for
+ * the next STYLE tag. Furthermore, IE7 does not support media declaration on
+ * the @import statement, so multiple STYLE tags must be used when different
+ * files are for different media types. Non-IE browsers always download in
+ * parallel, so this is an IE-specific performance quirk:
+ * http://www.stevesouders.com/blog/2009/04/09/dont-use-import/.
+ *
+ * However, IE has an annoying limit of 31 total CSS inclusion tags
+ * (http://drupal.org/node/228818) and LINK tags are limited to one file per
+ * tag, whereas STYLE tags can contain multiple @import statements allowing
+ * multiple files to be loaded per tag. When CSS aggregation is disabled, a
+ * Drupal site can easily have more than 31 CSS files that need to be loaded, so
+ * using LINK tags exclusively would result in a site that would display
+ * incorrectly in IE. Depending on different needs, different strategies can be
+ * employed to decide when to use LINK tags and when to use STYLE tags.
+ *
+ * The strategy employed by this function is to use LINK tags for all aggregate
+ * files and for all files that cannot be aggregated (e.g., if 'preprocess' is
+ * set to FALSE or the type is 'external'), and to use STYLE tags for groups
+ * of files that could be aggregated together but aren't (e.g., if the site-wide
+ * aggregation setting is disabled). This results in all LINK tags when
+ * aggregation is enabled, a guarantee that as many or only slightly more tags
+ * are used with aggregation disabled than enabled (so that if the limit were to
+ * be crossed with aggregation enabled, the site developer would also notice the
+ * problem while aggregation is disabled), and an easy way for a developer to
+ * view HTML source while aggregation is disabled and know what files will be
+ * aggregated together when aggregation becomes enabled.
+ *
+ * This function evaluates the aggregation enabled/disabled condition on a group
+ * by group basis by testing whether an aggregate file has been made for the
+ * group rather than by testing the site-wide aggregation setting. This allows
+ * this function to work correctly even if modules have implemented custom
+ * logic for grouping and aggregating files.
+ *
+ * @param $element
+ * A render array containing:
+ * - '#items': The CSS items as returned by drupal_add_css() and altered by
+ * drupal_get_css().
+ * - '#group_callback': A function to call to group #items to enable the use
+ * of fewer tags by aggregating files and/or using multiple @import
+ * statements within a single tag.
+ * - '#aggregate_callback': A function to call to aggregate the items within
+ * the groups arranged by the #group_callback function.
+ *
+ * @return
+ * A render array that will render to a string of XHTML CSS tags.
+ *
+ * @see drupal_get_css()
+ */
+function drupal_pre_render_styles($elements) {
+ // Group and aggregate the items.
+ if (isset($elements['#group_callback'])) {
+ $elements['#groups'] = $elements['#group_callback']($elements['#items']);
+ }
+ if (isset($elements['#aggregate_callback'])) {
+ $elements['#aggregate_callback']($elements['#groups']);
+ }
+
+ // A dummy query-string is added to filenames, to gain control over
+ // browser-caching. The string changes on every update or full cache
+ // flush, forcing browsers to load a new copy of the files, as the
+ // URL changed.
+ $query_string = variable_get('css_js_query_string', '0');
+
+ // For inline CSS to validate as XHTML, all CSS containing XHTML needs to be
+ // wrapped in CDATA. To make that backwards compatible with HTML 4, we need to
+ // comment out the CDATA-tag.
+ $embed_prefix = "\n<!--/*--><![CDATA[/*><!--*/\n";
+ $embed_suffix = "\n/*]]>*/-->\n";
+
+ // Defaults for LINK and STYLE elements.
+ $link_element_defaults = array(
+ '#type' => 'html_tag',
+ '#tag' => 'link',
+ '#attributes' => array(
+ 'type' => 'text/css',
+ 'rel' => 'stylesheet',
+ ),
+ );
+ $style_element_defaults = array(
+ '#type' => 'html_tag',
+ '#tag' => 'style',
+ '#attributes' => array(
+ 'type' => 'text/css',
+ ),
+ );
+
+ // Loop through each group.
+ foreach ($elements['#groups'] as $group) {
+ switch ($group['type']) {
+ // For file items, there are three possibilites.
+ // - The group has been aggregated: in this case, output a LINK tag for
+ // the aggregate file.
+ // - The group can be aggregated but has not been (most likely because
+ // the site administrator disabled the site-wide setting): in this case,
+ // output as few STYLE tags for the group as possible, using @import
+ // statement for each file in the group. This enables us to stay within
+ // IE's limit of 31 total CSS inclusion tags.
+ // - The group contains items not eligible for aggregation (their
+ // 'preprocess' flag has been set to FALSE): in this case, output a LINK
+ // tag for each file.
+ case 'file':
+ // The group has been aggregated into a single file: output a LINK tag
+ // for the aggregate file.
+ if (isset($group['data'])) {
+ $element = $link_element_defaults;
+ $element['#attributes']['href'] = file_create_url($group['data']);
+ $element['#attributes']['media'] = $group['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ // The group can be aggregated, but hasn't been: combine multiple items
+ // into as few STYLE tags as possible.
+ elseif ($group['preprocess']) {
+ $import = array();
+ foreach ($group['items'] as $item) {
+ // A theme's .info file may have an entry for a file that doesn't
+ // exist as a way of overriding a module or base theme CSS file from
+ // being added to the page. Normally, file_exists() calls that need
+ // to run for every page request should be minimized, but this one
+ // is okay, because it only runs when CSS aggregation is disabled.
+ // On a server under heavy enough load that file_exists() calls need
+ // to be minimized, CSS aggregation should be enabled, in which case
+ // this code is not run. When aggregation is enabled,
+ // drupal_load_stylesheet() checks file_exists(), but only when
+ // building the aggregate file, which is then reused for many page
+ // requests.
+ if (file_exists($item['data'])) {
+ // The dummy query string needs to be added to the URL to control
+ // browser-caching. IE7 does not support a media type on the
+ // @import statement, so we instead specify the media for the
+ // group on the STYLE tag.
+ $import[] = '@import url("' . check_plain(file_create_url($item['data']) . '?' . $query_string) . '");';
+ }
+ }
+ // In addition to IE's limit of 31 total CSS inclusion tags, it also
+ // has a limit of 31 @import statements per STYLE tag.
+ while (!empty($import)) {
+ $import_batch = array_slice($import, 0, 31);
+ $import = array_slice($import, 31);
+ $element = $style_element_defaults;
+ $element['#value'] = implode("\n", $import_batch);
+ $element['#attributes']['media'] = $group['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ }
+ // The group contains items ineligible for aggregation: output a LINK
+ // tag for each file.
+ else {
+ foreach ($group['items'] as $item) {
+ $element = $link_element_defaults;
+ // We do not check file_exists() here, because this code runs for
+ // files whose 'preprocess' is set to FALSE, and therefore, even
+ // when aggregation is enabled, and we want to avoid needlessly
+ // taxing a server that may be under heavy load. The file_exists()
+ // performed above for files whose 'preprocess' is TRUE is done for
+ // the benefit of theme .info files, but code that deals with files
+ // whose 'preprocess' is FALSE is responsible for ensuring the file
+ // exists.
+ // The dummy query string needs to be added to the URL to control
+ // browser-caching.
+ $query_string_separator = (strpos($item['data'], '?') !== FALSE) ? '&' : '?';
+ $element['#attributes']['href'] = file_create_url($item['data']) . $query_string_separator . $query_string;
+ $element['#attributes']['media'] = $item['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ }
+ break;
+ // For inline content, the 'data' property contains the CSS content. If
+ // the group's 'data' property is set, then output it in a single STYLE
+ // tag. Otherwise, output a separate STYLE tag for each item.
+ case 'inline':
+ if (isset($group['data'])) {
+ $element = $style_element_defaults;
+ $element['#value'] = $group['data'];
+ $element['#value_prefix'] = $embed_prefix;
+ $element['#value_suffix'] = $embed_suffix;
+ $element['#attributes']['media'] = $group['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ else {
+ foreach ($group['items'] as $item) {
+ $element = $style_element_defaults;
+ $element['#value'] = $item['data'];
+ $element['#value_prefix'] = $embed_prefix;
+ $element['#value_suffix'] = $embed_suffix;
+ $element['#attributes']['media'] = $item['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ }
+ break;
+ // Output a LINK tag for each external item. The item's 'data' property
+ // contains the full URL.
+ case 'external':
+ foreach ($group['items'] as $item) {
+ $element = $link_element_defaults;
+ $element['#attributes']['href'] = $item['data'];
+ $element['#attributes']['media'] = $item['media'];
+ $element['#browsers'] = $group['browsers'];
+ $elements[] = $element;
+ }
+ break;
+ }
+ }
+
+ return $elements;
+}
+
+/**
+ * Aggregates and optimizes CSS files into a cache file in the files directory.
+ *
+ * The file name for the CSS cache file is generated from the hash of the
+ * aggregated contents of the files in $css. This forces proxies and browsers
+ * to download new CSS when the CSS changes.
+ *
+ * The cache file name is retrieved on a page load via a lookup variable that
+ * contains an associative array. The array key is the hash of the file names
+ * in $css while the value is the cache file name. The cache file is generated
+ * in two cases. First, if there is no file name value for the key, which will
+ * happen if a new file name has been added to $css or after the lookup
+ * variable is emptied to force a rebuild of the cache. Second, the cache
+ * file is generated if it is missing on disk. Old cache files are not deleted
+ * immediately when the lookup variable is emptied, but are deleted after a set
+ * period by drupal_delete_file_if_stale(). This ensures that files referenced
+ * by a cached page will still be available.
+ *
+ * @param $css
+ * An array of CSS files to aggregate and compress into one file.
+ *
+ * @return
+ * The URI of the CSS cache file, or FALSE if the file could not be saved.
+ */
+function drupal_build_css_cache($css) {
+ $data = '';
+ $uri = '';
+ $map = variable_get('drupal_css_cache_files', array());
+ $key = hash('sha256', serialize($css));
+ if (isset($map[$key])) {
+ $uri = $map[$key];
+ }
+
+ if (empty($uri) || !file_exists($uri)) {
+ // Build aggregate CSS file.
+ foreach ($css as $stylesheet) {
+ // Only 'file' stylesheets can be aggregated.
+ if ($stylesheet['type'] == 'file') {
+ $contents = drupal_load_stylesheet($stylesheet['data'], TRUE);
+
+ // Build the base URL of this CSS file: start with the full URL.
+ $css_base_url = file_create_url($stylesheet['data']);
+ // Move to the parent.
+ $css_base_url = substr($css_base_url, 0, strrpos($css_base_url, '/'));
+ // Simplify to a relative URL if the stylesheet URL starts with the
+ // base URL of the website.
+ if (substr($css_base_url, 0, strlen($GLOBALS['base_root'])) == $GLOBALS['base_root']) {
+ $css_base_url = substr($css_base_url, strlen($GLOBALS['base_root']));
+ }
+
+ _drupal_build_css_path(NULL, $css_base_url . '/');
+ // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths.
+ $data .= preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', '_drupal_build_css_path', $contents);
+ }
+ }
+
+ // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import,
+ // @import rules must proceed any other style, so we move those to the top.
+ $regexp = '/@import[^;]+;/i';
+ preg_match_all($regexp, $data, $matches);
+ $data = preg_replace($regexp, '', $data);
+ $data = implode('', $matches[0]) . $data;
+
+ // Prefix filename to prevent blocking by firewalls which reject files
+ // starting with "ad*".
+ $filename = 'css_' . drupal_hash_base64($data) . '.css';
+ // Create the css/ within the files folder.
+ $csspath = 'public://css';
+ $uri = $csspath . '/' . $filename;
+ // Create the CSS file.
+ file_prepare_directory($csspath, FILE_CREATE_DIRECTORY);
+ if (!file_exists($uri) && !file_unmanaged_save_data($data, $uri, FILE_EXISTS_REPLACE)) {
+ return FALSE;
+ }
+ // If CSS gzip compression is enabled, clean URLs are enabled (which means
+ // that rewrite rules are working) and the zlib extension is available then
+ // create a gzipped version of this file. This file is served conditionally
+ // to browsers that accept gzip using .htaccess rules.
+ if (variable_get('css_gzip_compression', TRUE) && variable_get('clean_url', 0) && extension_loaded('zlib')) {
+ if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($data, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) {
+ return FALSE;
+ }
+ }
+ // Save the updated map.
+ $map[$key] = $uri;
+ variable_set('drupal_css_cache_files', $map);
+ }
+ return $uri;
+}
+
+/**
+ * Helper function for drupal_build_css_cache().
+ *
+ * This function will prefix all paths within a CSS file.
+ */
+function _drupal_build_css_path($matches, $base = NULL) {
+ $_base = &drupal_static(__FUNCTION__);
+ // Store base path for preg_replace_callback.
+ if (isset($base)) {
+ $_base = $base;
+ }
+
+ // Prefix with base and remove '../' segments where possible.
+ $path = $_base . $matches[1];
+ $last = '';
+ while ($path != $last) {
+ $last = $path;
+ $path = preg_replace('`(^|/)(?!\.\./)([^/]+)/\.\./`', '$1', $path);
+ }
+ return 'url(' . $path . ')';
+}
+
+/**
+ * Loads the stylesheet and resolves all @import commands.
+ *
+ * Loads a stylesheet and replaces @import commands with the contents of the
+ * imported file. Use this instead of file_get_contents when processing
+ * stylesheets.
+ *
+ * The returned contents are compressed removing white space and comments only
+ * when CSS aggregation is enabled. This optimization will not apply for
+ * color.module enabled themes with CSS aggregation turned off.
+ *
+ * @param $file
+ * Name of the stylesheet to be processed.
+ * @param $optimize
+ * Defines if CSS contents should be compressed or not.
+ * @param $reset_basepath
+ * Used internally to facilitate recursive resolution of @import commands.
+ *
+ * @return
+ * Contents of the stylesheet, including any resolved @import commands.
+ */
+function drupal_load_stylesheet($file, $optimize = NULL, $reset_basepath = TRUE) {
+ // These statics are not cache variables, so we don't use drupal_static().
+ static $_optimize, $basepath;
+ if ($reset_basepath) {
+ $basepath = '';
+ }
+ // Store the value of $optimize for preg_replace_callback with nested
+ // @import loops.
+ if (isset($optimize)) {
+ $_optimize = $optimize;
+ }
+
+ // Stylesheets are relative one to each other. Start by adding a base path
+ // prefix provided by the parent stylesheet (if necessary).
+ if ($basepath && !file_uri_scheme($file)) {
+ $file = $basepath . '/' . $file;
+ }
+ $basepath = dirname($file);
+
+ // Load the CSS stylesheet. We suppress errors because themes may specify
+ // stylesheets in their .info file that don't exist in the theme's path,
+ // but are merely there to disable certain module CSS files.
+ if ($contents = @file_get_contents($file)) {
+ // Return the processed stylesheet.
+ return drupal_load_stylesheet_content($contents, $_optimize);
+ }
+
+ return '';
+}
+
+/**
+ * Process the contents of a stylesheet for aggregation.
+ *
+ * @param $contents
+ * The contents of the stylesheet.
+ * @param $optimize
+ * (optional) Boolean whether CSS contents should be minified. Defaults to
+ * FALSE.
+ * @return
+ * Contents of the stylesheet including the imported stylesheets.
+ */
+function drupal_load_stylesheet_content($contents, $optimize = FALSE) {
+ // Remove multiple charset declarations for standards compliance (and fixing Safari problems).
+ $contents = preg_replace('/^@charset\s+[\'"](\S*)\b[\'"];/i', '', $contents);
+
+ if ($optimize) {
+ // Perform some safe CSS optimizations.
+ // Regexp to match comment blocks.
+ $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/';
+ // Regexp to match double quoted strings.
+ $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"';
+ // Regexp to match single quoted strings.
+ $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'";
+ // Strip all comment blocks, but keep double/single quoted strings.
+ $contents = preg_replace(
+ "<($double_quot|$single_quot)|$comment>Ss",
+ "$1",
+ $contents
+ );
+ // Remove certain whitespace.
+ // There are different conditions for removing leading and trailing
+ // whitespace.
+ // @see http://php.net/manual/en/regexp.reference.subpatterns.php
+ $contents = preg_replace('<
+ # Strip leading and trailing whitespace.
+ \s*([@{};,])\s*
+ # Strip only leading whitespace from:
+ # - Closing parenthesis: Retain "@media (bar) and foo".
+ | \s+([\)])
+ # Strip only trailing whitespace from:
+ # - Opening parenthesis: Retain "@media (bar) and foo".
+ # - Colon: Retain :pseudo-selectors.
+ | ([\(:])\s+
+ >xS',
+ // Only one of the three capturing groups will match, so its reference
+ // will contain the wanted value and the references for the
+ // two non-matching groups will be replaced with empty strings.
+ '$1$2$3',
+ $contents
+ );
+ // End the file with a new line.
+ $contents = trim($contents);
+ $contents .= "\n";
+ }
+
+ // Replaces @import commands with the actual stylesheet content.
+ // This happens recursively but omits external files.
+ $contents = preg_replace_callback('/@import\s*(?:url\(\s*)?[\'"]?(?![a-z]+:)([^\'"\()]+)[\'"]?\s*\)?\s*;/', '_drupal_load_stylesheet', $contents);
+ return $contents;
+}
+
+/**
+ * Loads stylesheets recursively and returns contents with corrected paths.
+ *
+ * This function is used for recursive loading of stylesheets and
+ * returns the stylesheet content with all url() paths corrected.
+ */
+function _drupal_load_stylesheet($matches) {
+ $filename = $matches[1];
+ // Load the imported stylesheet and replace @import commands in there as well.
+ $file = drupal_load_stylesheet($filename, NULL, FALSE);
+
+ // Determine the file's directory.
+ $directory = dirname($filename);
+ // If the file is in the current directory, make sure '.' doesn't appear in
+ // the url() path.
+ $directory = $directory == '.' ? '' : $directory .'/';
+
+ // Alter all internal url() paths. Leave external paths alone. We don't need
+ // to normalize absolute paths here (i.e. remove folder/... segments) because
+ // that will be done later.
+ return preg_replace('/url\(\s*([\'"]?)(?![a-z]+:|\/+)/i', 'url(\1'. $directory, $file);
+}
+
+/**
+ * Deletes old cached CSS files.
+ */
+function drupal_clear_css_cache() {
+ variable_del('drupal_css_cache_files');
+ file_scan_directory('public://css', '/.*/', array('callback' => 'drupal_delete_file_if_stale'));
+}
+
+/**
+ * Callback to delete files modified more than a set time ago.
+ */
+function drupal_delete_file_if_stale($uri) {
+ // Default stale file threshold is 30 days.
+ if (REQUEST_TIME - filemtime($uri) > variable_get('drupal_stale_file_threshold', 2592000)) {
+ file_unmanaged_delete($uri);
+ }
+}
+
+/**
+ * Prepare a string for use as a valid CSS identifier (element, class or ID name).
+ *
+ * http://www.w3.org/TR/CSS21/syndata.html#characters shows the syntax for valid
+ * CSS identifiers (including element names, classes, and IDs in selectors.)
+ *
+ * @param $identifier
+ * The identifier to clean.
+ * @param $filter
+ * An array of string replacements to use on the identifier.
+ * @return
+ * The cleaned identifier.
+ */
+function drupal_clean_css_identifier($identifier, $filter = array(' ' => '-', '_' => '-', '/' => '-', '[' => '-', ']' => '')) {
+ // By default, we filter using Drupal's coding standards.
+ $identifier = strtr($identifier, $filter);
+
+ // Valid characters in a CSS identifier are:
+ // - the hyphen (U+002D)
+ // - a-z (U+0030 - U+0039)
+ // - A-Z (U+0041 - U+005A)
+ // - the underscore (U+005F)
+ // - 0-9 (U+0061 - U+007A)
+ // - ISO 10646 characters U+00A1 and higher
+ // We strip out any character not in the above list.
+ $identifier = preg_replace('/[^\x{002D}\x{0030}-\x{0039}\x{0041}-\x{005A}\x{005F}\x{0061}-\x{007A}\x{00A1}-\x{FFFF}]/u', '', $identifier);
+
+ return $identifier;
+}
+
+/**
+ * Prepare a string for use as a valid class name.
+ *
+ * Do not pass one string containing multiple classes as they will be
+ * incorrectly concatenated with dashes, i.e. "one two" will become "one-two".
+ *
+ * @param $class
+ * The class name to clean.
+ * @return
+ * The cleaned class name.
+ */
+function drupal_html_class($class) {
+ return drupal_clean_css_identifier(drupal_strtolower($class));
+}
+
+/**
+ * Prepare a string for use as a valid HTML ID and guarantee uniqueness.
+ *
+ * This function ensures that each passed HTML ID value only exists once on the
+ * page. By tracking the already returned ids, this function enables forms,
+ * blocks, and other content to be output multiple times on the same page,
+ * without breaking (X)HTML validation.
+ *
+ * For already existing IDs, a counter is appended to the ID string. Therefore,
+ * JavaScript and CSS code should not rely on any value that was generated by
+ * this function and instead should rely on manually added CSS classes or
+ * similarly reliable constructs.
+ *
+ * Two consecutive hyphens separate the counter from the original ID. To manage
+ * uniqueness across multiple Ajax requests on the same page, Ajax requests
+ * POST an array of all IDs currently present on the page, which are used to
+ * prime this function's cache upon first invocation.
+ *
+ * To allow reverse-parsing of IDs submitted via Ajax, any multiple consecutive
+ * hyphens in the originally passed $id are replaced with a single hyphen.
+ *
+ * @param $id
+ * The ID to clean.
+ *
+ * @return
+ * The cleaned ID.
+ */
+function drupal_html_id($id) {
+ // If this is an Ajax request, then content returned by this page request will
+ // be merged with content already on the base page. The HTML IDs must be
+ // unique for the fully merged content. Therefore, initialize $seen_ids to
+ // take into account IDs that are already in use on the base page.
+ $seen_ids_init = &drupal_static(__FUNCTION__ . ':init');
+ if (!isset($seen_ids_init)) {
+ // Ideally, Drupal would provide an API to persist state information about
+ // prior page requests in the database, and we'd be able to add this
+ // function's $seen_ids static variable to that state information in order
+ // to have it properly initialized for this page request. However, no such
+ // page state API exists, so instead, ajax.js adds all of the in-use HTML
+ // IDs to the POST data of Ajax submissions. Direct use of $_POST is
+ // normally not recommended as it could open up security risks, but because
+ // the raw POST data is cast to a number before being returned by this
+ // function, this usage is safe.
+ if (empty($_POST['ajax_html_ids'])) {
+ $seen_ids_init = array();
+ }
+ else {
+ // This function ensures uniqueness by appending a counter to the base id
+ // requested by the calling function after the first occurrence of that
+ // requested id. $_POST['ajax_html_ids'] contains the ids as they were
+ // returned by this function, potentially with the appended counter, so
+ // we parse that to reconstruct the $seen_ids array.
+ foreach ($_POST['ajax_html_ids'] as $seen_id) {
+ // We rely on '--' being used solely for separating a base id from the
+ // counter, which this function ensures when returning an id.
+ $parts = explode('--', $seen_id, 2);
+ if (!empty($parts[1]) && is_numeric($parts[1])) {
+ list($seen_id, $i) = $parts;
+ }
+ else {
+ $i = 1;
+ }
+ if (!isset($seen_ids_init[$seen_id]) || ($i > $seen_ids_init[$seen_id])) {
+ $seen_ids_init[$seen_id] = $i;
+ }
+ }
+ }
+ }
+ $seen_ids = &drupal_static(__FUNCTION__, $seen_ids_init);
+
+ $id = strtr(drupal_strtolower($id), array(' ' => '-', '_' => '-', '[' => '-', ']' => ''));
+
+ // As defined in http://www.w3.org/TR/html4/types.html#type-name, HTML IDs can
+ // only contain letters, digits ([0-9]), hyphens ("-"), underscores ("_"),
+ // colons (":"), and periods ("."). We strip out any character not in that
+ // list. Note that the CSS spec doesn't allow colons or periods in identifiers
+ // (http://www.w3.org/TR/CSS21/syndata.html#characters), so we strip those two
+ // characters as well.
+ $id = preg_replace('/[^A-Za-z0-9\-_]/', '', $id);
+
+ // Removing multiple consecutive hyphens.
+ $id = preg_replace('/\-+/', '-', $id);
+ // Ensure IDs are unique by appending a counter after the first occurrence.
+ // The counter needs to be appended with a delimiter that does not exist in
+ // the base ID. Requiring a unique delimiter helps ensure that we really do
+ // return unique IDs and also helps us re-create the $seen_ids array during
+ // Ajax requests.
+ if (isset($seen_ids[$id])) {
+ $id = $id . '--' . ++$seen_ids[$id];
+ }
+ else {
+ $seen_ids[$id] = 1;
+ }
+
+ return $id;
+}
+
+/**
+ * Provides a standard HTML class name that identifies a page region.
+ *
+ * It is recommended that template preprocess functions apply this class to any
+ * page region that is output by the theme (Drupal core already handles this in
+ * the standard template preprocess implementation). Standardizing the class
+ * names in this way allows modules to implement certain features, such as
+ * drag-and-drop or dynamic Ajax loading, in a theme-independent way.
+ *
+ * @param $region
+ * The name of the page region (for example, 'page_top' or 'content').
+ *
+ * @return
+ * An HTML class that identifies the region (for example, 'region-page-top'
+ * or 'region-content').
+ *
+ * @see template_preprocess_region()
+ */
+function drupal_region_class($region) {
+ return drupal_html_class("region-$region");
+}
+
+/**
+ * Adds a JavaScript file, setting, or inline code to the page.
+ *
+ * The behavior of this function depends on the parameters it is called with.
+ * Generally, it handles the addition of JavaScript to the page, either as
+ * reference to an existing file or as inline code. The following actions can be
+ * performed using this function:
+ * - Add a file ('file'): Adds a reference to a JavaScript file to the page.
+ * - Add inline JavaScript code ('inline'): Executes a piece of JavaScript code
+ * on the current page by placing the code directly in the page (for example,
+ * to tell the user that a new message arrived, by opening a pop up, alert
+ * box, etc.). This should only be used for JavaScript that cannot be executed
+ * from a file. When adding inline code, make sure that you are not relying on
+ * $() being the jQuery function. Wrap your code in
+ * @code (function ($) {... })(jQuery); @endcode
+ * or use jQuery() instead of $().
+ * - Add external JavaScript ('external'): Allows the inclusion of external
+ * JavaScript files that are not hosted on the local server. Note that these
+ * external JavaScript references do not get aggregated when preprocessing is
+ * on.
+ * - Add settings ('setting'): Adds settings to Drupal's global storage of
+ * JavaScript settings. Per-page settings are required by some modules to
+ * function properly. All settings will be accessible at Drupal.settings.
+ *
+ * Examples:
+ * @code
+ * drupal_add_js('core/misc/collapse.js');
+ * drupal_add_js('core/misc/collapse.js', 'file');
+ * drupal_add_js('jQuery(document).ready(function () { alert("Hello!"); });', 'inline');
+ * drupal_add_js('jQuery(document).ready(function () { alert("Hello!"); });',
+ * array('type' => 'inline', 'scope' => 'footer', 'weight' => 5)
+ * );
+ * drupal_add_js('http://example.com/example.js', 'external');
+ * drupal_add_js(array('myModule' => array('key' => 'value')), 'setting');
+ * @endcode
+ *
+ * Calling drupal_static_reset('drupal_add_js') will clear all JavaScript added
+ * so far.
+ *
+ * If JavaScript aggregation is enabled, all JavaScript files added with
+ * $options['preprocess'] set to TRUE will be merged into one aggregate file.
+ * Preprocessed inline JavaScript will not be aggregated into this single file.
+ * Externally hosted JavaScripts are never aggregated.
+ *
+ * The reason for aggregating the files is outlined quite thoroughly here:
+ * http://www.die.net/musings/page_load_time/ "Load fewer external objects. Due
+ * to request overhead, one bigger file just loads faster than two smaller ones
+ * half its size."
+ *
+ * $options['preprocess'] should be only set to TRUE when a file is required for
+ * all typical visitors and most pages of a site. It is critical that all
+ * preprocessed files are added unconditionally on every page, even if the
+ * files are not needed on a page. This is normally done by calling
+ * drupal_add_js() in a hook_init() implementation.
+ *
+ * Non-preprocessed files should only be added to the page when they are
+ * actually needed.
+ *
+ * @param $data
+ * (optional) If given, the value depends on the $options parameter:
+ * - 'file': Path to the file relative to base_path().
+ * - 'inline': The JavaScript code that should be placed in the given scope.
+ * - 'external': The absolute path to an external JavaScript file that is not
+ * hosted on the local server. These files will not be aggregated if
+ * JavaScript aggregation is enabled.
+ * - 'setting': An associative array with configuration options. The array is
+ * merged directly into Drupal.settings. All modules should wrap their
+ * actual configuration settings in another variable to prevent conflicts in
+ * the Drupal.settings namespace. Items added with a string key will replace
+ * existing settings with that key; items with numeric array keys will be
+ * added to the existing settings array.
+ * @param $options
+ * (optional) A string defining the type of JavaScript that is being added in
+ * the $data parameter ('file'/'setting'/'inline'/'external'), or an
+ * associative array. JavaScript settings should always pass the string
+ * 'setting' only. Other types can have the following elements in the array:
+ * - type: The type of JavaScript that is to be added to the page. Allowed
+ * values are 'file', 'inline', 'external' or 'setting'. Defaults
+ * to 'file'.
+ * - scope: The location in which you want to place the script. Possible
+ * values are 'header' or 'footer'. If your theme implements different
+ * regions, you can also use these. Defaults to 'header'.
+ * - group: A number identifying the group in which to add the JavaScript.
+ * Available constants are:
+ * - JS_LIBRARY: Any libraries, settings, or jQuery plugins.
+ * - JS_DEFAULT: Any module-layer JavaScript.
+ * - JS_THEME: Any theme-layer JavaScript.
+ * The group number serves as a weight: JavaScript within a lower weight
+ * group is presented on the page before JavaScript within a higher weight
+ * group.
+ * - every_page: For optimal front-end performance when aggregation is
+ * enabled, this should be set to TRUE if the JavaScript is present on every
+ * page of the website for users for whom it is present at all. This
+ * defaults to FALSE. It is set to TRUE for JavaScript files that are added
+ * via module and theme .info files. Modules that add JavaScript within
+ * hook_init() implementations, or from other code that ensures that the
+ * JavaScript is added to all website pages, should also set this flag to
+ * TRUE. All JavaScript files within the same group and that have the
+ * 'every_page' flag set to TRUE and do not have 'preprocess' set to FALSE
+ * are aggregated together into a single aggregate file, and that aggregate
+ * file can be reused across a user's entire site visit, leading to faster
+ * navigation between pages. However, JavaScript that is only needed on
+ * pages less frequently visited, can be added by code that only runs for
+ * those particular pages, and that code should not set the 'every_page'
+ * flag. This minimizes the size of the aggregate file that the user needs
+ * to download when first visiting the website. JavaScript without the
+ * 'every_page' flag is aggregated into a separate aggregate file. This
+ * other aggregate file is likely to change from page to page, and each new
+ * aggregate file needs to be downloaded when first encountered, so it
+ * should be kept relatively small by ensuring that most commonly needed
+ * JavaScript is added to every page.
+ * - weight: A number defining the order in which the JavaScript is added to
+ * the page relative to other JavaScript with the same 'scope', 'group',
+ * and 'every_page' value. In some cases, the order in which the JavaScript
+ * is presented on the page is very important. jQuery, for example, must be
+ * added to the page before any jQuery code is run, so jquery.js uses the
+ * JS_LIBRARY group and a weight of -20, jquery.once.js (a library drupal.js
+ * depends on) uses the JS_LIBRARY group and a weight of -19, drupal.js uses
+ * the JS_LIBRARY group and a weight of -1, other libraries use the
+ * JS_LIBRARY group and a weight of 0 or higher, and all other scripts use
+ * one of the other group constants. The exact ordering of JavaScript is as
+ * follows:
+ * - First by scope, with 'header' first, 'footer' last, and any other
+ * scopes provided by a custom theme coming in between, as determined by
+ * the theme.
+ * - Then by group.
+ * - Then by the 'every_page' flag, with TRUE coming before FALSE.
+ * - Then by weight.
+ * - Then by the order in which the JavaScript was added. For example, all
+ * else being the same, JavaScript added by a call to drupal_add_js() that
+ * happened later in the page request gets added to the page after one for
+ * which drupal_add_js() happened earlier in the page request.
+ * - defer: If set to TRUE, the defer attribute is set on the &lt;script&gt;
+ * tag. Defaults to FALSE.
+ * - cache: If set to FALSE, the JavaScript file is loaded anew on every page
+ * call; in other words, it is not cached. Used only when 'type' references
+ * a JavaScript file. Defaults to TRUE.
+ * - preprocess: If TRUE and JavaScript aggregation is enabled, the script
+ * file will be aggregated. Defaults to TRUE.
+ *
+ * @return
+ * The current array of JavaScript files, settings, and in-line code,
+ * including Drupal defaults, anything previously added with calls to
+ * drupal_add_js(), and this function call's additions.
+ *
+ * @see drupal_get_js()
+ */
+function drupal_add_js($data = NULL, $options = NULL) {
+ $javascript = &drupal_static(__FUNCTION__, array());
+
+ // Construct the options, taking the defaults into consideration.
+ if (isset($options)) {
+ if (!is_array($options)) {
+ $options = array('type' => $options);
+ }
+ }
+ else {
+ $options = array();
+ }
+ $options += drupal_js_defaults($data);
+
+ // Preprocess can only be set if caching is enabled.
+ $options['preprocess'] = $options['cache'] ? $options['preprocess'] : FALSE;
+
+ // Tweak the weight so that files of the same weight are included in the
+ // order of the calls to drupal_add_js().
+ $options['weight'] += count($javascript) / 1000;
+
+ if (isset($data)) {
+ // Add jquery.js and drupal.js, as well as the basePath setting, the
+ // first time a JavaScript file is added.
+ if (empty($javascript)) {
+ // url() generates the prefix using hook_url_outbound_alter(). Instead of
+ // running the hook_url_outbound_alter() again here, extract the prefix
+ // from url().
+ url('', array('prefix' => &$prefix));
+ $javascript = array(
+ 'settings' => array(
+ 'data' => array(
+ array('basePath' => base_path()),
+ array('pathPrefix' => empty($prefix) ? '' : $prefix),
+ ),
+ 'type' => 'setting',
+ 'scope' => 'header',
+ 'group' => JS_LIBRARY,
+ 'every_page' => TRUE,
+ 'weight' => 0,
+ ),
+ 'core/misc/drupal.js' => array(
+ 'data' => 'core/misc/drupal.js',
+ 'type' => 'file',
+ 'scope' => 'header',
+ 'group' => JS_LIBRARY,
+ 'every_page' => TRUE,
+ 'weight' => -1,
+ 'preprocess' => TRUE,
+ 'cache' => TRUE,
+ 'defer' => FALSE,
+ ),
+ );
+ // Register all required libraries.
+ drupal_add_library('system', 'jquery', TRUE);
+ drupal_add_library('system', 'jquery.once', TRUE);
+ }
+
+ switch ($options['type']) {
+ case 'setting':
+ // All JavaScript settings are placed in the header of the page with
+ // the library weight so that inline scripts appear afterwards.
+ $javascript['settings']['data'][] = $data;
+ break;
+
+ case 'inline':
+ $javascript[] = $options;
+ break;
+
+ default: // 'file' and 'external'
+ // Local and external files must keep their name as the associative key
+ // so the same JavaScript file is not added twice.
+ $javascript[$options['data']] = $options;
+ }
+ }
+ return $javascript;
+}
+
+/**
+ * Constructs an array of the defaults that are used for JavaScript items.
+ *
+ * @param $data
+ * (optional) The default data parameter for the JavaScript item array.
+ * @see drupal_get_js()
+ * @see drupal_add_js()
+ */
+function drupal_js_defaults($data = NULL) {
+ return array(
+ 'type' => 'file',
+ 'group' => JS_DEFAULT,
+ 'every_page' => FALSE,
+ 'weight' => 0,
+ 'scope' => 'header',
+ 'cache' => TRUE,
+ 'defer' => FALSE,
+ 'preprocess' => TRUE,
+ 'version' => NULL,
+ 'data' => $data,
+ );
+}
+
+/**
+ * Returns a themed presentation of all JavaScript code for the current page.
+ *
+ * References to JavaScript files are placed in a certain order: first, all
+ * 'core' files, then all 'module' and finally all 'theme' JavaScript files
+ * are added to the page. Then, all settings are output, followed by 'inline'
+ * JavaScript code. If running update.php, all preprocessing is disabled.
+ *
+ * Note that hook_js_alter(&$javascript) is called during this function call
+ * to allow alterations of the JavaScript during its presentation. Calls to
+ * drupal_add_js() from hook_js_alter() will not be added to the output
+ * presentation. The correct way to add JavaScript during hook_js_alter()
+ * is to add another element to the $javascript array, deriving from
+ * drupal_js_defaults(). See locale_js_alter() for an example of this.
+ *
+ * @param $scope
+ * (optional) The scope for which the JavaScript rules should be returned.
+ * Defaults to 'header'.
+ * @param $javascript
+ * (optional) An array with all JavaScript code. Defaults to the default
+ * JavaScript array for the given scope.
+ * @param $skip_alter
+ * (optional) If set to TRUE, this function skips calling drupal_alter() on
+ * $javascript, useful when the calling function passes a $javascript array
+ * that has already been altered.
+ * @return
+ * All JavaScript code segments and includes for the scope as HTML tags.
+ * @see drupal_add_js()
+ * @see locale_js_alter()
+ * @see drupal_js_defaults()
+ */
+function drupal_get_js($scope = 'header', $javascript = NULL, $skip_alter = FALSE) {
+ if (!isset($javascript)) {
+ $javascript = drupal_add_js();
+ }
+ if (empty($javascript)) {
+ return '';
+ }
+
+ // Allow modules to alter the JavaScript.
+ if (!$skip_alter) {
+ drupal_alter('js', $javascript);
+ }
+
+ // Filter out elements of the given scope.
+ $items = array();
+ foreach ($javascript as $key => $item) {
+ if ($item['scope'] == $scope) {
+ $items[$key] = $item;
+ }
+ }
+
+ $output = '';
+ // The index counter is used to keep aggregated and non-aggregated files in
+ // order by weight.
+ $index = 1;
+ $processed = array();
+ $files = array();
+ $preprocess_js = (variable_get('preprocess_js', FALSE) && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update'));
+
+ // A dummy query-string is added to filenames, to gain control over
+ // browser-caching. The string changes on every update or full cache
+ // flush, forcing browsers to load a new copy of the files, as the
+ // URL changed. Files that should not be cached (see drupal_add_js())
+ // get REQUEST_TIME as query-string instead, to enforce reload on every
+ // page request.
+ $default_query_string = variable_get('css_js_query_string', '0');
+
+ // For inline JavaScript to validate as XHTML, all JavaScript containing
+ // XHTML needs to be wrapped in CDATA. To make that backwards compatible
+ // with HTML 4, we need to comment out the CDATA-tag.
+ $embed_prefix = "\n<!--//--><![CDATA[//><!--\n";
+ $embed_suffix = "\n//--><!]]>\n";
+
+ // Since JavaScript may look for arguments in the URL and act on them, some
+ // third-party code might require the use of a different query string.
+ $js_version_string = variable_get('drupal_js_version_query_string', 'v=');
+
+ // Sort the JavaScript so that it appears in the correct order.
+ uasort($items, 'drupal_sort_css_js');
+
+ // Provide the page with information about the individual JavaScript files
+ // used, information not otherwise available when aggregation is enabled.
+ $setting['ajaxPageState']['js'] = array_fill_keys(array_keys($items), 1);
+ unset($setting['ajaxPageState']['js']['settings']);
+ drupal_add_js($setting, 'setting');
+
+ // If we're outputting the header scope, then this might be the final time
+ // that drupal_get_js() is running, so add the setting to this output as well
+ // as to the drupal_add_js() cache. If $items['settings'] doesn't exist, it's
+ // because drupal_get_js() was intentionally passed a $javascript argument
+ // stripped off settings, potentially in order to override how settings get
+ // output, so in this case, do not add the setting to this output.
+ if ($scope == 'header' && isset($items['settings'])) {
+ $items['settings']['data'][] = $setting;
+ }
+
+ // Loop through the JavaScript to construct the rendered output.
+ $element = array(
+ '#tag' => 'script',
+ '#value' => '',
+ '#attributes' => array(
+ 'type' => 'text/javascript',
+ ),
+ );
+ foreach ($items as $item) {
+ $query_string = empty($item['version']) ? $default_query_string : $js_version_string . $item['version'];
+
+ switch ($item['type']) {
+ case 'setting':
+ $js_element = $element;
+ $js_element['#value_prefix'] = $embed_prefix;
+ $js_element['#value'] = 'jQuery.extend(Drupal.settings, ' . drupal_json_encode(drupal_array_merge_deep_array($item['data'])) . ");";
+ $js_element['#value_suffix'] = $embed_suffix;
+ $output .= theme('html_tag', array('element' => $js_element));
+ break;
+
+ case 'inline':
+ $js_element = $element;
+ if ($item['defer']) {
+ $js_element['#attributes']['defer'] = 'defer';
+ }
+ $js_element['#value_prefix'] = $embed_prefix;
+ $js_element['#value'] = $item['data'];
+ $js_element['#value_suffix'] = $embed_suffix;
+ $processed[$index++] = theme('html_tag', array('element' => $js_element));
+ break;
+
+ case 'file':
+ $js_element = $element;
+ if (!$item['preprocess'] || !$preprocess_js) {
+ if ($item['defer']) {
+ $js_element['#attributes']['defer'] = 'defer';
+ }
+ $query_string_separator = (strpos($item['data'], '?') !== FALSE) ? '&' : '?';
+ $js_element['#attributes']['src'] = file_create_url($item['data']) . $query_string_separator . ($item['cache'] ? $query_string : REQUEST_TIME);
+ $processed[$index++] = theme('html_tag', array('element' => $js_element));
+ }
+ else {
+ // By increasing the index for each aggregated file, we maintain
+ // the relative ordering of JS by weight. We also set the key such
+ // that groups are split by items sharing the same 'group' value and
+ // 'every_page' flag. While this potentially results in more aggregate
+ // files, it helps make each one more reusable across a site visit,
+ // leading to better front-end performance of a website as a whole.
+ // See drupal_add_js() for details.
+ $key = 'aggregate_' . $item['group'] . '_' . $item['every_page'] . '_' . $index;
+ $processed[$key] = '';
+ $files[$key][$item['data']] = $item;
+ }
+ break;
+
+ case 'external':
+ $js_element = $element;
+ // Preprocessing for external JavaScript files is ignored.
+ if ($item['defer']) {
+ $js_element['#attributes']['defer'] = 'defer';
+ }
+ $js_element['#attributes']['src'] = $item['data'];
+ $processed[$index++] = theme('html_tag', array('element' => $js_element));
+ break;
+ }
+ }
+
+ // Aggregate any remaining JS files that haven't already been output.
+ if ($preprocess_js && count($files) > 0) {
+ foreach ($files as $key => $file_set) {
+ $uri = drupal_build_js_cache($file_set);
+ // Only include the file if was written successfully. Errors are logged
+ // using watchdog.
+ if ($uri) {
+ $preprocess_file = file_create_url($uri);
+ $js_element = $element;
+ $js_element['#attributes']['src'] = $preprocess_file;
+ $processed[$key] = theme('html_tag', array('element' => $js_element));
+ }
+ }
+ }
+
+ // Keep the order of JS files consistent as some are preprocessed and others are not.
+ // Make sure any inline or JS setting variables appear last after libraries have loaded.
+ return implode('', $processed) . $output;
+}
+
+/**
+ * Adds attachments to a render() structure.
+ *
+ * Libraries, JavaScript, CSS and other types of custom structures are attached
+ * to elements using the #attached property. The #attached property is an
+ * associative array, where the keys are the the attachment types and the values
+ * are the attached data. For example:
+ * @code
+ * $build['#attached'] = array(
+ * 'js' => array(drupal_get_path('module', 'taxonomy') . '/taxonomy.js'),
+ * 'css' => array(drupal_get_path('module', 'taxonomy') . '/taxonomy.css'),
+ * );
+ * @endcode
+ *
+ * 'js', 'css', and 'library' are types that get special handling. For any
+ * other kind of attached data, the array key must be the full name of the
+ * callback function and each value an array of arguments. For example:
+ * @code
+ * $build['#attached']['drupal_add_http_header'] = array(
+ * array('Content-Type', 'application/rss+xml; charset=utf-8'),
+ * );
+ * @endcode
+ *
+ * External 'js' and 'css' files can also be loaded. For example:
+ * @code
+ * $build['#attached']['js'] = array(
+ * 'http://code.jquery.com/jquery-1.4.2.min.js' => array(
+ * 'type' => 'external',
+ * ),
+ * );
+ * @endcode
+ *
+ * @param $elements
+ * The structured array describing the data being rendered.
+ * @param $group
+ * The default group of JavaScript and CSS being added. This is only applied
+ * to the stylesheets and JavaScript items that don't have an explicit group
+ * assigned to them.
+ * @param $dependency_check
+ * When TRUE, will exit if a given library's dependencies are missing. When
+ * set to FALSE, will continue to add the libraries, even though one or more
+ * dependencies are missing. Defaults to FALSE.
+ * @param $every_page
+ * Set to TRUE to indicate that the attachments are added to every page on the
+ * site. Only attachments with the every_page flag set to TRUE can participate
+ * in JavaScript/CSS aggregation.
+ *
+ * @return
+ * FALSE if there were any missing library dependencies; TRUE if all library
+ * dependencies were met.
+ *
+ * @see drupal_add_library()
+ * @see drupal_add_js()
+ * @see drupal_add_css()
+ * @see drupal_render()
+ */
+function drupal_process_attached($elements, $group = JS_DEFAULT, $dependency_check = FALSE, $every_page = NULL) {
+ // Add defaults to the special attached structures that should be processed differently.
+ $elements['#attached'] += array(
+ 'library' => array(),
+ 'js' => array(),
+ 'css' => array(),
+ );
+
+ // Add the libraries first.
+ $success = TRUE;
+ foreach ($elements['#attached']['library'] as $library) {
+ if (drupal_add_library($library[0], $library[1], $every_page) === FALSE) {
+ $success = FALSE;
+ // Exit if the dependency is missing.
+ if ($dependency_check) {
+ return $success;
+ }
+ }
+ }
+ unset($elements['#attached']['library']);
+
+ // Add both the JavaScript and the CSS.
+ // The parameters for drupal_add_js() and drupal_add_css() require special
+ // handling.
+ foreach (array('js', 'css') as $type) {
+ foreach ($elements['#attached'][$type] as $data => $options) {
+ // If the value is not an array, it's a filename and passed as first
+ // (and only) argument.
+ if (!is_array($options)) {
+ $data = $options;
+ $options = NULL;
+ }
+ // In some cases, the first parameter ($data) is an array. Arrays can't be
+ // passed as keys in PHP, so we have to get $data from the value array.
+ if (is_numeric($data)) {
+ $data = $options['data'];
+ unset($options['data']);
+ }
+ // Apply the default group if it isn't explicitly given.
+ if (!isset($options['group'])) {
+ $options['group'] = $group;
+ }
+ // Set the every_page flag if one was passed.
+ if (isset($every_page)) {
+ $options['every_page'] = $every_page;
+ }
+ call_user_func('drupal_add_' . $type, $data, $options);
+ }
+ unset($elements['#attached'][$type]);
+ }
+
+ // Add additional types of attachments specified in the render() structure.
+ // Libraries, JavaScript and CSS have been added already, as they require
+ // special handling.
+ foreach ($elements['#attached'] as $callback => $options) {
+ if (function_exists($callback)) {
+ foreach ($elements['#attached'][$callback] as $args) {
+ call_user_func_array($callback, $args);
+ }
+ }
+ }
+
+ return $success;
+}
+
+/**
+ * Adds JavaScript to change the state of an element based on another element.
+ *
+ * A "state" means a certain property on a DOM element, such as "visible" or
+ * "checked". A state can be applied to an element, depending on the state of
+ * another element on the page. In general, states depend on HTML attributes and
+ * DOM element properties, which change due to user interaction.
+ *
+ * Since states are driven by JavaScript only, it is important to understand
+ * that all states are applied on presentation only, none of the states force
+ * any server-side logic, and that they will not be applied for site visitors
+ * without JavaScript support. All modules implementing states have to make
+ * sure that the intended logic also works without JavaScript being enabled.
+ *
+ * #states is an associative array in the form of:
+ * @code
+ * array(
+ * STATE1 => CONDITIONS_ARRAY1,
+ * STATE2 => CONDITIONS_ARRAY2,
+ * ...
+ * )
+ * @endcode
+ * Each key is the name of a state to apply to the element, such as 'visible'.
+ * Each value is a list of conditions that denote when the state should be
+ * applied.
+ *
+ * Multiple different states may be specified to act on complex conditions:
+ * @code
+ * array(
+ * 'visible' => CONDITIONS,
+ * 'checked' => OTHER_CONDITIONS,
+ * )
+ * @endcode
+ *
+ * Every condition is a key/value pair, whose key is a jQuery selector that
+ * denotes another element on the page, and whose value is an array of
+ * conditions, which must bet met on that element:
+ * @code
+ * array(
+ * 'visible' => array(
+ * JQUERY_SELECTOR => REMOTE_CONDITIONS,
+ * JQUERY_SELECTOR => REMOTE_CONDITIONS,
+ * ...
+ * ),
+ * )
+ * @endcode
+ * All conditions must be met for the state to be applied.
+ *
+ * Each remote condition is a key/value pair specifying conditions on the other
+ * element that need to be met to apply the state to the element:
+ * @code
+ * array(
+ * 'visible' => array(
+ * ':input[name="remote_checkbox"]' => array('checked' => TRUE),
+ * ),
+ * )
+ * @endcode
+ *
+ * For example, to show a textfield only when a checkbox is checked:
+ * @code
+ * $form['toggle_me'] = array(
+ * '#type' => 'checkbox',
+ * '#title' => t('Tick this box to type'),
+ * );
+ * $form['settings'] = array(
+ * '#type' => 'textfield',
+ * '#states' => array(
+ * // Only show this field when the 'toggle_me' checkbox is enabled.
+ * 'visible' => array(
+ * ':input[name="toggle_me"]' => array('checked' => TRUE),
+ * ),
+ * ),
+ * );
+ * @endcode
+ *
+ * The following states may be applied to an element:
+ * - enabled
+ * - disabled
+ * - required
+ * - optional
+ * - visible
+ * - invisible
+ * - checked
+ * - unchecked
+ * - expanded
+ * - collapsed
+ *
+ * The following states may be used in remote conditions:
+ * - empty
+ * - filled
+ * - checked
+ * - unchecked
+ * - expanded
+ * - collapsed
+ * - value
+ *
+ * The following states exist for both elements and remote conditions, but are
+ * not fully implemented and may not change anything on the element:
+ * - relevant
+ * - irrelevant
+ * - valid
+ * - invalid
+ * - touched
+ * - untouched
+ * - readwrite
+ * - readonly
+ *
+ * When referencing select lists and radio buttons in remote conditions, a
+ * 'value' condition must be used:
+ * @code
+ * '#states' => array(
+ * // Show the settings if 'bar' has been selected for 'foo'.
+ * 'visible' => array(
+ * ':input[name="foo"]' => array('value' => 'bar'),
+ * ),
+ * ),
+ * @endcode
+ *
+ * @param $elements
+ * A renderable array element having a #states property as described above.
+ *
+ * @see form_example_states_form()
+ */
+function drupal_process_states(&$elements) {
+ $elements['#attached']['library'][] = array('system', 'drupal.states');
+ $elements['#attached']['js'][] = array(
+ 'type' => 'setting',
+ 'data' => array('states' => array('#' . $elements['#id'] => $elements['#states'])),
+ );
+}
+
+/**
+ * Adds multiple JavaScript or CSS files at the same time.
+ *
+ * A library defines a set of JavaScript and/or CSS files, optionally using
+ * settings, and optionally requiring another library. For example, a library
+ * can be a jQuery plugin, a JavaScript framework, or a CSS framework. This
+ * function allows modules to load a library defined/shipped by itself or a
+ * depending module, without having to add all files of the library separately.
+ * Each library is only loaded once.
+ *
+ * @param $module
+ * The name of the module that registered the library.
+ * @param $name
+ * The name of the library to add.
+ * @param $every_page
+ * Set to TRUE if this library is added to every page on the site. Only items
+ * with the every_page flag set to TRUE can participate in aggregation.
+ *
+ * @return
+ * TRUE if the library was successfully added; FALSE if the library or one of
+ * its dependencies could not be added.
+ *
+ * @see drupal_get_library()
+ * @see hook_library_info()
+ * @see hook_library_info_alter()
+ */
+function drupal_add_library($module, $name, $every_page = NULL) {
+ $added = &drupal_static(__FUNCTION__, array());
+
+ // Only process the library if it exists and it was not added already.
+ if (!isset($added[$module][$name])) {
+ if ($library = drupal_get_library($module, $name)) {
+ // Add all components within the library.
+ $elements['#attached'] = array(
+ 'library' => $library['dependencies'],
+ 'js' => $library['js'],
+ 'css' => $library['css'],
+ );
+ $added[$module][$name] = drupal_process_attached($elements, JS_LIBRARY, TRUE, $every_page);
+ }
+ else {
+ // Requested library does not exist.
+ $added[$module][$name] = FALSE;
+ }
+ }
+
+ return $added[$module][$name];
+}
+
+/**
+ * Retrieves information for a JavaScript/CSS library.
+ *
+ * Library information is statically cached. Libraries are keyed by module for
+ * several reasons:
+ * - Libraries are not unique. Multiple modules might ship with the same library
+ * in a different version or variant. This registry cannot (and does not
+ * attempt to) prevent library conflicts.
+ * - Modules implementing and thereby depending on a library that is registered
+ * by another module can only rely on that module's library.
+ * - Two (or more) modules can still register the same library and use it
+ * without conflicts in case the libraries are loaded on certain pages only.
+ *
+ * @param $module
+ * The name of a module that registered a library.
+ * @param $name
+ * (optional) The name of a registered library to retrieve. By default, all
+ * libraries registered by $module are returned.
+ *
+ * @return
+ * The definition of the requested library, if $name was passed and it exists,
+ * or FALSE if it does not exist. If no $name was passed, an associative array
+ * of libraries registered by $module is returned (which may be empty).
+ *
+ * @see drupal_add_library()
+ * @see hook_library_info()
+ * @see hook_library_info_alter()
+ *
+ * @todo The purpose of drupal_get_*() is completely different to other page
+ * requisite API functions; find and use a different name.
+ */
+function drupal_get_library($module, $name = NULL) {
+ $libraries = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($libraries[$module])) {
+ // Retrieve all libraries associated with the module.
+ $module_libraries = module_invoke($module, 'library_info');
+ if (empty($module_libraries)) {
+ $module_libraries = array();
+ }
+ // Allow modules to alter the module's registered libraries.
+ drupal_alter('library_info', $module_libraries, $module);
+
+ foreach ($module_libraries as $key => $data) {
+ if (is_array($data)) {
+ // Add default elements to allow for easier processing.
+ $module_libraries[$key] += array('dependencies' => array(), 'js' => array(), 'css' => array());
+ foreach ($module_libraries[$key]['js'] as $file => $options) {
+ $module_libraries[$key]['js'][$file]['version'] = $module_libraries[$key]['version'];
+ }
+ }
+ }
+ $libraries[$module] = $module_libraries;
+ }
+ if (isset($name)) {
+ if (!isset($libraries[$module][$name])) {
+ $libraries[$module][$name] = FALSE;
+ }
+ return $libraries[$module][$name];
+ }
+ return $libraries[$module];
+}
+
+/**
+ * Assist in adding the tableDrag JavaScript behavior to a themed table.
+ *
+ * Draggable tables should be used wherever an outline or list of sortable items
+ * needs to be arranged by an end-user. Draggable tables are very flexible and
+ * can manipulate the value of form elements placed within individual columns.
+ *
+ * To set up a table to use drag and drop in place of weight select-lists or
+ * in place of a form that contains parent relationships, the form must be
+ * themed into a table. The table must have an id attribute set. If using
+ * theme_table(), the id may be set as such:
+ * @code
+ * $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'my-module-table')));
+ * return $output;
+ * @endcode
+ *
+ * In the theme function for the form, a special class must be added to each
+ * form element within the same column, "grouping" them together.
+ *
+ * In a situation where a single weight column is being sorted in the table, the
+ * classes could be added like this (in the theme function):
+ * @code
+ * $form['my_elements'][$delta]['weight']['#attributes']['class'] = array('my-elements-weight');
+ * @endcode
+ *
+ * Each row of the table must also have a class of "draggable" in order to enable the
+ * drag handles:
+ * @code
+ * $row = array(...);
+ * $rows[] = array(
+ * 'data' => $row,
+ * 'class' => array('draggable'),
+ * );
+ * @endcode
+ *
+ * When tree relationships are present, the two additional classes
+ * 'tabledrag-leaf' and 'tabledrag-root' can be used to refine the behavior:
+ * - Rows with the 'tabledrag-leaf' class cannot have child rows.
+ * - Rows with the 'tabledrag-root' class cannot be nested under a parent row.
+ *
+ * Calling drupal_add_tabledrag() would then be written as such:
+ * @code
+ * drupal_add_tabledrag('my-module-table', 'order', 'sibling', 'my-elements-weight');
+ * @endcode
+ *
+ * In a more complex case where there are several groups in one column (such as
+ * the block regions on the admin/structure/block page), a separate subgroup class
+ * must also be added to differentiate the groups.
+ * @code
+ * $form['my_elements'][$region][$delta]['weight']['#attributes']['class'] = array('my-elements-weight', 'my-elements-weight-' . $region);
+ * @endcode
+ *
+ * $group is still 'my-element-weight', and the additional $subgroup variable
+ * will be passed in as 'my-elements-weight-' . $region. This also means that
+ * you'll need to call drupal_add_tabledrag() once for every region added.
+ *
+ * @code
+ * foreach ($regions as $region) {
+ * drupal_add_tabledrag('my-module-table', 'order', 'sibling', 'my-elements-weight', 'my-elements-weight-' . $region);
+ * }
+ * @endcode
+ *
+ * In a situation where tree relationships are present, adding multiple
+ * subgroups is not necessary, because the table will contain indentations that
+ * provide enough information about the sibling and parent relationships.
+ * See theme_menu_overview_form() for an example creating a table containing
+ * parent relationships.
+ *
+ * Please note that this function should be called from the theme layer, such as
+ * in a .tpl.php file, theme_ function, or in a template_preprocess function,
+ * not in a form declaration. Though the same JavaScript could be added to the
+ * page using drupal_add_js() directly, this function helps keep template files
+ * clean and readable. It also prevents tabledrag.js from being added twice
+ * accidentally.
+ *
+ * @param $table_id
+ * String containing the target table's id attribute. If the table does not
+ * have an id, one will need to be set, such as <table id="my-module-table">.
+ * @param $action
+ * String describing the action to be done on the form item. Either 'match'
+ * 'depth', or 'order'. Match is typically used for parent relationships.
+ * Order is typically used to set weights on other form elements with the same
+ * group. Depth updates the target element with the current indentation.
+ * @param $relationship
+ * String describing where the $action variable should be performed. Either
+ * 'parent', 'sibling', 'group', or 'self'. Parent will only look for fields
+ * up the tree. Sibling will look for fields in the same group in rows above
+ * and below it. Self affects the dragged row itself. Group affects the
+ * dragged row, plus any children below it (the entire dragged group).
+ * @param $group
+ * A class name applied on all related form elements for this action.
+ * @param $subgroup
+ * (optional) If the group has several subgroups within it, this string should
+ * contain the class name identifying fields in the same subgroup.
+ * @param $source
+ * (optional) If the $action is 'match', this string should contain the class
+ * name identifying what field will be used as the source value when matching
+ * the value in $subgroup.
+ * @param $hidden
+ * (optional) The column containing the field elements may be entirely hidden
+ * from view dynamically when the JavaScript is loaded. Set to FALSE if the
+ * column should not be hidden.
+ * @param $limit
+ * (optional) Limit the maximum amount of parenting in this table.
+ * @see block-admin-display-form.tpl.php
+ * @see theme_menu_overview_form()
+ */
+function drupal_add_tabledrag($table_id, $action, $relationship, $group, $subgroup = NULL, $source = NULL, $hidden = TRUE, $limit = 0) {
+ $js_added = &drupal_static(__FUNCTION__, FALSE);
+ if (!$js_added) {
+ // Add the table drag JavaScript to the page before the module JavaScript
+ // to ensure that table drag behaviors are registered before any module
+ // uses it.
+ drupal_add_library('system', 'jquery.cookie');
+ drupal_add_js('core/misc/tabledrag.js', array('weight' => -1));
+ $js_added = TRUE;
+ }
+
+ // If a subgroup or source isn't set, assume it is the same as the group.
+ $target = isset($subgroup) ? $subgroup : $group;
+ $source = isset($source) ? $source : $target;
+ $settings['tableDrag'][$table_id][$group][] = array(
+ 'target' => $target,
+ 'source' => $source,
+ 'relationship' => $relationship,
+ 'action' => $action,
+ 'hidden' => $hidden,
+ 'limit' => $limit,
+ );
+ drupal_add_js($settings, 'setting');
+}
+
+/**
+ * Aggregates JavaScript files into a cache file in the files directory.
+ *
+ * The file name for the JavaScript cache file is generated from the hash of
+ * the aggregated contents of the files in $files. This forces proxies and
+ * browsers to download new JavaScript when the JavaScript changes.
+ *
+ * The cache file name is retrieved on a page load via a lookup variable that
+ * contains an associative array. The array key is the hash of the names in
+ * $files while the value is the cache file name. The cache file is generated
+ * in two cases. First, if there is no file name value for the key, which will
+ * happen if a new file name has been added to $files or after the lookup
+ * variable is emptied to force a rebuild of the cache. Second, the cache
+ * file is generated if it is missing on disk. Old cache files are not deleted
+ * immediately when the lookup variable is emptied, but are deleted after a set
+ * period by drupal_delete_file_if_stale(). This ensures that files referenced
+ * by a cached page will still be available.
+ *
+ * @param $files
+ * An array of JavaScript files to aggregate and compress into one file.
+ *
+ * @return
+ * The URI of the cache file, or FALSE if the file could not be saved.
+ */
+function drupal_build_js_cache($files) {
+ $contents = '';
+ $uri = '';
+ $map = variable_get('drupal_js_cache_files', array());
+ $key = hash('sha256', serialize($files));
+ if (isset($map[$key])) {
+ $uri = $map[$key];
+ }
+
+ if (empty($uri) || !file_exists($uri)) {
+ // Build aggregate JS file.
+ foreach ($files as $path => $info) {
+ if ($info['preprocess']) {
+ // Append a ';' and a newline after each JS file to prevent them from running together.
+ $contents .= file_get_contents($path) . ";\n";
+ }
+ }
+ // Prefix filename to prevent blocking by firewalls which reject files
+ // starting with "ad*".
+ $filename = 'js_' . drupal_hash_base64($contents) . '.js';
+ // Create the js/ within the files folder.
+ $jspath = 'public://js';
+ $uri = $jspath . '/' . $filename;
+ // Create the JS file.
+ file_prepare_directory($jspath, FILE_CREATE_DIRECTORY);
+ if (!file_exists($uri) && !file_unmanaged_save_data($contents, $uri, FILE_EXISTS_REPLACE)) {
+ return FALSE;
+ }
+ // If JS gzip compression is enabled, clean URLs are enabled (which means
+ // that rewrite rules are working) and the zlib extension is available then
+ // create a gzipped version of this file. This file is served conditionally
+ // to browsers that accept gzip using .htaccess rules.
+ if (variable_get('js_gzip_compression', TRUE) && variable_get('clean_url', 0) && extension_loaded('zlib')) {
+ if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($contents, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) {
+ return FALSE;
+ }
+ }
+ $map[$key] = $uri;
+ variable_set('drupal_js_cache_files', $map);
+ }
+ return $uri;
+}
+
+/**
+ * Deletes old cached JavaScript files and variables.
+ */
+function drupal_clear_js_cache() {
+ variable_del('javascript_parsed');
+ variable_del('drupal_js_cache_files');
+ file_scan_directory('public://js', '/.*/', array('callback' => 'drupal_delete_file_if_stale'));
+}
+
+/**
+ * Converts a PHP variable into its JavaScript equivalent.
+ *
+ * We use HTML-safe strings, i.e. with <, > and & escaped.
+ *
+ * @see drupal_json_decode()
+ * @ingroup php_wrappers
+ */
+function drupal_json_encode($var) {
+ // json_encode() does not escape <, > and &, so we do it with str_replace().
+ return str_replace(array('<', '>', '&'), array('\u003c', '\u003e', '\u0026'), json_encode($var));
+}
+
+/**
+ * Converts an HTML-safe JSON string into its PHP equivalent.
+ *
+ * @see drupal_json_encode()
+ * @ingroup php_wrappers
+ */
+function drupal_json_decode($var) {
+ return json_decode($var, TRUE);
+}
+
+/**
+ * Return data in JSON format.
+ *
+ * This function should be used for JavaScript callback functions returning
+ * data in JSON format. It sets the header for JavaScript output.
+ *
+ * @param $var
+ * (optional) If set, the variable will be converted to JSON and output.
+ */
+function drupal_json_output($var = NULL) {
+ // We are returning JSON, so tell the browser.
+ drupal_add_http_header('Content-Type', 'application/json');
+
+ if (isset($var)) {
+ echo drupal_json_encode($var);
+ }
+}
+
+/**
+ * Get a salt useful for hardening against SQL injection.
+ *
+ * @return
+ * A salt based on information in settings.php, not in the database.
+ */
+function drupal_get_hash_salt() {
+ global $drupal_hash_salt, $databases;
+ // If the $drupal_hash_salt variable is empty, a hash of the serialized
+ // database credentials is used as a fallback salt.
+ return empty($drupal_hash_salt) ? hash('sha256', serialize($databases)) : $drupal_hash_salt;
+}
+
+/**
+ * Ensure the private key variable used to generate tokens is set.
+ *
+ * @return
+ * The private key.
+ */
+function drupal_get_private_key() {
+ if (!($key = variable_get('drupal_private_key', 0))) {
+ $key = drupal_hash_base64(drupal_random_bytes(55));
+ variable_set('drupal_private_key', $key);
+ }
+ return $key;
+}
+
+/**
+ * Generate a token based on $value, the current user session and private key.
+ *
+ * @param $value
+ * An additional value to base the token on.
+ */
+function drupal_get_token($value = '') {
+ return drupal_hmac_base64($value, session_id() . drupal_get_private_key() . drupal_get_hash_salt());
+}
+
+/**
+ * Validate a token based on $value, the current user session and private key.
+ *
+ * @param $token
+ * The token to be validated.
+ * @param $value
+ * An additional value to base the token on.
+ * @param $skip_anonymous
+ * Set to true to skip token validation for anonymous users.
+ * @return
+ * True for a valid token, false for an invalid token. When $skip_anonymous
+ * is true, the return value will always be true for anonymous users.
+ */
+function drupal_valid_token($token, $value = '', $skip_anonymous = FALSE) {
+ global $user;
+ return (($skip_anonymous && $user->uid == 0) || ($token == drupal_get_token($value)));
+}
+
+function _drupal_bootstrap_full() {
+ static $called = FALSE;
+
+ if ($called) {
+ return;
+ }
+ $called = TRUE;
+ require_once DRUPAL_ROOT . '/' . variable_get('path_inc', 'core/includes/path.inc');
+ require_once DRUPAL_ROOT . '/core/includes/theme.inc';
+ require_once DRUPAL_ROOT . '/core/includes/pager.inc';
+ require_once DRUPAL_ROOT . '/' . variable_get('menu_inc', 'core/includes/menu.inc');
+ require_once DRUPAL_ROOT . '/core/includes/tablesort.inc';
+ require_once DRUPAL_ROOT . '/core/includes/file.inc';
+ require_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+ require_once DRUPAL_ROOT . '/core/includes/image.inc';
+ require_once DRUPAL_ROOT . '/core/includes/form.inc';
+ require_once DRUPAL_ROOT . '/core/includes/mail.inc';
+ require_once DRUPAL_ROOT . '/core/includes/actions.inc';
+ require_once DRUPAL_ROOT . '/core/includes/ajax.inc';
+ require_once DRUPAL_ROOT . '/core/includes/token.inc';
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+
+ // Detect string handling method
+ unicode_check();
+ // Undo magic quotes
+ fix_gpc_magic();
+ // Load all enabled modules
+ module_load_all();
+ // Make sure all stream wrappers are registered.
+ file_get_stream_wrappers();
+
+ $test_info = &$GLOBALS['drupal_test_info'];
+ if (!empty($test_info['in_child_site'])) {
+ // Running inside the simpletest child site, log fatal errors to test
+ // specific file directory.
+ ini_set('log_errors', 1);
+ ini_set('error_log', 'public://error.log');
+ }
+
+ // Initialize $_GET['q'] prior to invoking hook_init().
+ drupal_path_initialize();
+
+ // Let all modules take action before the menu system handles the request.
+ // We do not want this while running update.php.
+ if (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update') {
+ // Prior to invoking hook_init(), initialize the theme (potentially a custom
+ // one for this page), so that:
+ // - Modules with hook_init() implementations that call theme() or
+ // theme_get_registry() don't initialize the incorrect theme.
+ // - The theme can have hook_*_alter() implementations affect page building
+ // (e.g., hook_form_alter(), hook_node_view_alter(), hook_page_alter()),
+ // ahead of when rendering starts.
+ menu_set_custom_theme();
+ drupal_theme_initialize();
+ module_invoke_all('init');
+ }
+}
+
+/**
+ * Store the current page in the cache.
+ *
+ * If page_compression is enabled, a gzipped version of the page is stored in
+ * the cache to avoid compressing the output on each request. The cache entry
+ * is unzipped in the relatively rare event that the page is requested by a
+ * client without gzip support.
+ *
+ * Page compression requires the PHP zlib extension
+ * (http://php.net/manual/en/ref.zlib.php).
+ *
+ * @see drupal_page_header()
+ */
+function drupal_page_set_cache() {
+ global $base_root;
+
+ if (drupal_page_is_cacheable()) {
+ $cache = (object) array(
+ 'cid' => $base_root . request_uri(),
+ 'data' => array(
+ 'path' => $_GET['q'],
+ 'body' => ob_get_clean(),
+ 'title' => drupal_get_title(),
+ 'headers' => array(),
+ ),
+ 'expire' => CACHE_TEMPORARY,
+ 'created' => REQUEST_TIME,
+ );
+
+ // Restore preferred header names based on the lower-case names returned
+ // by drupal_get_http_header().
+ $header_names = _drupal_set_preferred_header_name();
+ foreach (drupal_get_http_header() as $name_lower => $value) {
+ $cache->data['headers'][$header_names[$name_lower]] = $value;
+ if ($name_lower == 'expires') {
+ // Use the actual timestamp from an Expires header if available.
+ $cache->expire = strtotime($value);
+ }
+ }
+
+ if ($cache->data['body']) {
+ if (variable_get('page_compression', TRUE) && extension_loaded('zlib')) {
+ $cache->data['body'] = gzencode($cache->data['body'], 9, FORCE_GZIP);
+ }
+ cache('page')->set($cache->cid, $cache->data, $cache->expire);
+ }
+ return $cache;
+ }
+}
+
+/**
+ * Executes a cron run when called.
+ *
+ * Do not call this function from test, use $this->cronRun() instead.
+ *
+ * @return
+ * Returns TRUE if ran successfully
+ */
+function drupal_cron_run() {
+ // Allow execution to continue even if the request gets canceled.
+ @ignore_user_abort(TRUE);
+
+ // Prevent session information from being saved while cron is running.
+ drupal_save_session(FALSE);
+
+ // Force the current user to anonymous to ensure consistent permissions on
+ // cron runs.
+ $original_user = $GLOBALS['user'];
+ $GLOBALS['user'] = drupal_anonymous_user();
+
+ // Try to allocate enough time to run all the hook_cron implementations.
+ drupal_set_time_limit(240);
+
+ $return = FALSE;
+ // Grab the defined cron queues.
+ $queues = module_invoke_all('cron_queue_info');
+ drupal_alter('cron_queue_info', $queues);
+
+ // Try to acquire cron lock.
+ if (!lock_acquire('cron', 240.0)) {
+ // Cron is still running normally.
+ watchdog('cron', 'Attempting to re-run cron while it is already running.', array(), WATCHDOG_WARNING);
+ }
+ else {
+ // Make sure every queue exists. There is no harm in trying to recreate an
+ // existing queue.
+ foreach ($queues as $queue_name => $info) {
+ DrupalQueue::get($queue_name)->createQueue();
+ }
+ // Register shutdown callback
+ drupal_register_shutdown_function('drupal_cron_cleanup');
+
+ // Iterate through the modules calling their cron handlers (if any):
+ foreach (module_implements('cron') as $module) {
+ // Do not let an exception thrown by one module disturb another.
+ try {
+ module_invoke($module, 'cron');
+ }
+ catch (Exception $e) {
+ watchdog_exception('cron', $e);
+ }
+ }
+
+ // Record cron time
+ variable_set('cron_last', REQUEST_TIME);
+ watchdog('cron', 'Cron run completed.', array(), WATCHDOG_NOTICE);
+
+ // Release cron lock.
+ lock_release('cron');
+
+ // Return TRUE so other functions can check if it did run successfully
+ $return = TRUE;
+ }
+
+ foreach ($queues as $queue_name => $info) {
+ $function = $info['worker callback'];
+ $end = time() + (isset($info['time']) ? $info['time'] : 15);
+ $queue = DrupalQueue::get($queue_name);
+ while (time() < $end && ($item = $queue->claimItem())) {
+ $function($item->data);
+ $queue->deleteItem($item);
+ }
+ }
+ // Restore the user.
+ $GLOBALS['user'] = $original_user;
+ drupal_save_session(TRUE);
+
+ return $return;
+}
+
+/**
+ * Shutdown function for cron cleanup.
+ */
+function drupal_cron_cleanup() {
+ // See if the semaphore is still locked.
+ if (variable_get('cron_semaphore', FALSE)) {
+ watchdog('cron', 'Cron run exceeded the time limit and was aborted.', array(), WATCHDOG_WARNING);
+
+ // Release cron semaphore
+ variable_del('cron_semaphore');
+ }
+}
+
+/**
+ * Returns information about system object files (modules, themes, etc.).
+ *
+ * This function is used to find all or some system object files (module files,
+ * theme files, etc.) that exist on the site. It searches in several locations,
+ * depending on what type of object you are looking for. For instance, if you
+ * are looking for modules and call:
+ * @code
+ * drupal_system_listing("/\.module$/", "modules", 'name', 0);
+ * @endcode
+ * this function will search the site-wide modules directory (i.e., /modules/),
+ * your install profile's directory (i.e.,
+ * /profiles/your_site_profile/modules/), the all-sites directory (i.e.,
+ * /sites/all/modules/), and your site-specific directory (i.e.,
+ * /sites/your_site_dir/modules/), in that order, and return information about
+ * all of the files ending in .module in those directories.
+ *
+ * The information is returned in an associative array, which can be keyed on
+ * the file name ($key = 'filename'), the file name without the extension ($key
+ * = 'name'), or the full file stream URI ($key = 'uri'). If you use a key of
+ * 'filename' or 'name', files found later in the search will take precedence
+ * over files found earlier (unless they belong to a module or theme not
+ * compatible with Drupal core); if you choose a key of 'uri', you will get all
+ * files found.
+ *
+ * @param string $mask
+ * The preg_match() regular expression for the files to find.
+ * @param string $directory
+ * The subdirectory name in which the files are found. For example,
+ * 'core/modules' will search in sub-directories of the /core/modules
+ * directory, sub-directories of /sites/all/modules/, etc.
+ * @param string $key
+ * The key to be used for the associative array returned. Possible values are
+ * 'uri', for the file's URI; 'filename', for the basename of the file; and
+ * 'name' for the name of the file without the extension. If you choose 'name'
+ * or 'filename', only the highest-precedence file will be returned.
+ * @param int $min_depth
+ * Minimum depth of directories to return files from, relative to each
+ * directory searched. For instance, a minimum depth of 2 would find modules
+ * inside /core/modules/node/tests, but not modules directly in
+ * /core/modules/node.
+ *
+ * @return array
+ * An associative array of file objects, keyed on the chosen key. Each element
+ * in the array is an object containing file information, with properties:
+ * - 'uri': Full URI of the file.
+ * - 'filename': File name.
+ * - 'name': Name of file without the extension.
+ */
+function drupal_system_listing($mask, $directory, $key = 'name', $min_depth = 1) {
+ $config = conf_path();
+ $files = array();
+
+ // Search for the directory in core.
+ $searchdir = array('core/' . $directory);
+
+ // The 'profiles' directory contains pristine collections of modules and
+ // themes as provided by a distribution. It is pristine in the same way that
+ // the 'core/modules' directory is pristine for core; users should avoid
+ // any modification by using the sites/all or sites/<domain> directories.
+ $profile = drupal_get_profile();
+ if (file_exists("profiles/$profile/$directory")) {
+ $searchdir[] = "profiles/$profile/$directory";
+ }
+
+ // Always search sites/all/* as well as the global directories
+ $searchdir[] = 'sites/all/' . $directory;
+
+ if (file_exists("$config/$directory")) {
+ $searchdir[] = "$config/$directory";
+ }
+
+ // Get current list of items
+ if (!function_exists('file_scan_directory')) {
+ require_once DRUPAL_ROOT . '/core/includes/file.inc';
+ }
+ foreach ($searchdir as $dir) {
+ $files_to_add = file_scan_directory($dir, $mask, array('key' => $key, 'min_depth' => $min_depth));
+
+ // Duplicate files found in later search directories take precedence over
+ // earlier ones, so we want them to overwrite keys in our resulting
+ // $files array.
+ // The exception to this is if the later file is from a module or theme not
+ // compatible with Drupal core. This may occur during upgrades of Drupal
+ // core when new modules exist in core while older contrib modules with the
+ // same name exist in a directory such as sites/all/modules/.
+ foreach (array_intersect_key($files_to_add, $files) as $file_key => $file) {
+ // If it has no info file, then we just behave liberally and accept the
+ // new resource on the list for merging.
+ if (file_exists($info_file = dirname($file->uri) . '/' . $file->name . '.info')) {
+ // Get the .info file for the module or theme this file belongs to.
+ $info = drupal_parse_info_file($info_file);
+
+ // If the module or theme is incompatible with Drupal core, remove it
+ // from the array for the current search directory, so it is not
+ // overwritten when merged with the $files array.
+ if (isset($info['core']) && $info['core'] != DRUPAL_CORE_COMPATIBILITY) {
+ unset($files_to_add[$file_key]);
+ }
+ }
+ }
+ $files = array_merge($files, $files_to_add);
+ }
+
+ return $files;
+}
+
+/**
+ * Set the main page content value for later use.
+ *
+ * Given the nature of the Drupal page handling, this will be called once with
+ * a string or array. We store that and return it later as the block is being
+ * displayed.
+ *
+ * @param $content
+ * A string or renderable array representing the body of the page.
+ * @return
+ * If called without $content, a renderable array representing the body of
+ * the page.
+ */
+function drupal_set_page_content($content = NULL) {
+ $content_block = &drupal_static(__FUNCTION__, NULL);
+ $main_content_display = &drupal_static('system_main_content_added', FALSE);
+
+ if (!empty($content)) {
+ $content_block = (is_array($content) ? $content : array('main' => array('#markup' => $content)));
+ }
+ else {
+ // Indicate that the main content has been requested. We assume that
+ // the module requesting the content will be adding it to the page.
+ // A module can indicate that it does not handle the content by setting
+ // the static variable back to FALSE after calling this function.
+ $main_content_display = TRUE;
+ return $content_block;
+ }
+}
+
+/**
+ * #pre_render callback to render #browsers into #prefix and #suffix.
+ *
+ * @param $elements
+ * A render array with a '#browsers' property. The '#browsers' property can
+ * contain any or all of the following keys:
+ * - 'IE': If FALSE, the element is not rendered by Internet Explorer. If
+ * TRUE, the element is rendered by Internet Explorer. Can also be a string
+ * containing an expression for Internet Explorer to evaluate as part of a
+ * conditional comment. For example, this can be set to 'lt IE 7' for the
+ * element to be rendered in Internet Explorer 6, but not in Internet
+ * Explorer 7 or higher. Defaults to TRUE.
+ * - '!IE': If FALSE, the element is not rendered by browsers other than
+ * Internet Explorer. If TRUE, the element is rendered by those browsers.
+ * Defaults to TRUE.
+ * Examples:
+ * - To render an element in all browsers, '#browsers' can be left out or set
+ * to array('IE' => TRUE, '!IE' => TRUE).
+ * - To render an element in Internet Explorer only, '#browsers' can be set
+ * to array('!IE' => FALSE).
+ * - To render an element in Internet Explorer 6 only, '#browsers' can be set
+ * to array('IE' => 'lt IE 7', '!IE' => FALSE).
+ * - To render an element in Internet Explorer 8 and higher and in all other
+ * browsers, '#browsers' can be set to array('IE' => 'gte IE 8').
+ *
+ * @return
+ * The passed-in element with markup for conditional comments potentially
+ * added to '#prefix' and '#suffix'.
+ */
+function drupal_pre_render_conditional_comments($elements) {
+ $browsers = isset($elements['#browsers']) ? $elements['#browsers'] : array();
+ $browsers += array(
+ 'IE' => TRUE,
+ '!IE' => TRUE,
+ );
+
+ // If rendering in all browsers, no need for conditional comments.
+ if ($browsers['IE'] === TRUE && $browsers['!IE']) {
+ return $elements;
+ }
+
+ // Determine the conditional comment expression for Internet Explorer to
+ // evaluate.
+ if ($browsers['IE'] === TRUE) {
+ $expression = 'IE';
+ }
+ elseif ($browsers['IE'] === FALSE) {
+ $expression = '!IE';
+ }
+ else {
+ $expression = $browsers['IE'];
+ }
+
+ // Wrap the element's potentially existing #prefix and #suffix properties with
+ // conditional comment markup. The conditional comment expression is evaluated
+ // by Internet Explorer only. To control the rendering by other browsers,
+ // either the "downlevel-hidden" or "downlevel-revealed" technique must be
+ // used. See http://en.wikipedia.org/wiki/Conditional_comment for details.
+ $elements += array(
+ '#prefix' => '',
+ '#suffix' => '',
+ );
+ if (!$browsers['!IE']) {
+ // "downlevel-hidden".
+ $elements['#prefix'] = "\n<!--[if $expression]>\n" . $elements['#prefix'];
+ $elements['#suffix'] .= "<![endif]-->\n";
+ }
+ else {
+ // "downlevel-revealed".
+ $elements['#prefix'] = "\n<!--[if $expression]><!-->\n" . $elements['#prefix'];
+ $elements['#suffix'] .= "<!--<![endif]-->\n";
+ }
+
+ return $elements;
+}
+
+/**
+ * #pre_render callback to render a link into #markup.
+ *
+ * Doing so during pre_render gives modules a chance to alter the link parts.
+ *
+ * @param $elements
+ * A structured array whose keys form the arguments to l():
+ * - #title: The link text to pass as argument to l().
+ * - #href: The URL path component to pass as argument to l().
+ * - #options: (optional) An array of options to pass to l().
+ *
+ * @return
+ * The passed-in elements containing a rendered link in '#markup'.
+ */
+function drupal_pre_render_link($element) {
+ // By default, link options to pass to l() are normally set in #options.
+ $element += array('#options' => array());
+ // However, within the scope of renderable elements, #attributes is a valid
+ // way to specify attributes, too. Take them into account, but do not override
+ // attributes from #options.
+ if (isset($element['#attributes'])) {
+ $element['#options'] += array('attributes' => array());
+ $element['#options']['attributes'] += $element['#attributes'];
+ }
+
+ // This #pre_render callback can be invoked from inside or outside of a Form
+ // API context, and depending on that, a HTML ID may be already set in
+ // different locations. #options should have precedence over Form API's #id.
+ // #attributes have been taken over into #options above already.
+ if (isset($element['#options']['attributes']['id'])) {
+ $element['#id'] = $element['#options']['attributes']['id'];
+ }
+ elseif (isset($element['#id'])) {
+ $element['#options']['attributes']['id'] = $element['#id'];
+ }
+
+ // Conditionally invoke ajax_pre_render_element(), if #ajax is set.
+ if (isset($element['#ajax']) && !isset($element['#ajax_processed'])) {
+ // If no HTML ID was found above, automatically create one.
+ if (!isset($element['#id'])) {
+ $element['#id'] = $element['#options']['attributes']['id'] = drupal_html_id('ajax-link');
+ }
+ // If #ajax['path] was not specified, use the href as Ajax request URL.
+ if (!isset($element['#ajax']['path'])) {
+ $element['#ajax']['path'] = $element['#href'];
+ $element['#ajax']['options'] = $element['#options'];
+ }
+ $element = ajax_pre_render_element($element);
+ }
+
+ $element['#markup'] = l($element['#title'], $element['#href'], $element['#options']);
+ return $element;
+}
+
+/**
+ * #pre_render callback that collects child links into a single array.
+ *
+ * This function can be added as a pre_render callback for a renderable array,
+ * usually one which will be themed by theme_links(). It iterates through all
+ * unrendered children of the element, collects any #links properties it finds,
+ * merges them into the parent element's #links array, and prevents those
+ * children from being rendered separately.
+ *
+ * The purpose of this is to allow links to be logically grouped into related
+ * categories, so that each child group can be rendered as its own list of
+ * links if drupal_render() is called on it, but calling drupal_render() on the
+ * parent element will still produce a single list containing all the remaining
+ * links, regardless of what group they were in.
+ *
+ * A typical example comes from node links, which are stored in a renderable
+ * array similar to this:
+ * @code
+ * $node->content['links'] = array(
+ * '#theme' => 'links__node',
+ * '#pre_render' = array('drupal_pre_render_links'),
+ * 'comment' => array(
+ * '#theme' => 'links__node__comment',
+ * '#links' => array(
+ * // An array of links associated with node comments, suitable for
+ * // passing in to theme_links().
+ * ),
+ * ),
+ * 'statistics' => array(
+ * '#theme' => 'links__node__statistics',
+ * '#links' => array(
+ * // An array of links associated with node statistics, suitable for
+ * // passing in to theme_links().
+ * ),
+ * ),
+ * 'translation' => array(
+ * '#theme' => 'links__node__translation',
+ * '#links' => array(
+ * // An array of links associated with node translation, suitable for
+ * // passing in to theme_links().
+ * ),
+ * ),
+ * );
+ * @endcode
+ *
+ * In this example, the links are grouped by functionality, which can be
+ * helpful to themers who want to display certain kinds of links independently.
+ * For example, adding this code to node.tpl.php will result in the comment
+ * links being rendered as a single list:
+ * @code
+ * print render($content['links']['comment']);
+ * @endcode
+ *
+ * (where $node->content has been transformed into $content before handing
+ * control to the node.tpl.php template).
+ *
+ * The pre_render function defined here allows the above flexibility, but also
+ * allows the following code to be used to render all remaining links into a
+ * single list, regardless of their group:
+ * @code
+ * print render($content['links']);
+ * @endcode
+ *
+ * In the above example, this will result in the statistics and translation
+ * links being rendered together in a single list (but not the comment links,
+ * which were rendered previously on their own).
+ *
+ * Because of the way this function works, the individual properties of each
+ * group (for example, a group-specific #theme property such as
+ * 'links__node__comment' in the example above, or any other property such as
+ * #attributes or #pre_render that is attached to it) are only used when that
+ * group is rendered on its own. When the group is rendered together with other
+ * children, these child-specific properties are ignored, and only the overall
+ * properties of the parent are used.
+ */
+function drupal_pre_render_links($element) {
+ $element += array('#links' => array());
+ foreach (element_children($element) as $key) {
+ $child = &$element[$key];
+ // If the child has links which have not been printed yet and the user has
+ // access to it, merge its links in to the parent.
+ if (isset($child['#links']) && empty($child['#printed']) && (!isset($child['#access']) || $child['#access'])) {
+ $element['#links'] += $child['#links'];
+ // Mark the child as having been printed already (so that its links
+ // cannot be mistakenly rendered twice).
+ $child['#printed'] = TRUE;
+ }
+ }
+ return $element;
+}
+
+/**
+ * #pre_render callback to append contents in #markup to #children.
+ *
+ * This needs to be a #pre_render callback, because eventually assigned
+ * #theme_wrappers will expect the element's rendered content in #children.
+ * Note that if also a #theme is defined for the element, then the result of
+ * the theme callback will override #children.
+ *
+ * @see drupal_render()
+ *
+ * @param $elements
+ * A structured array using the #markup key.
+ *
+ * @return
+ * The passed-in elements, but #markup appended to #children.
+ */
+function drupal_pre_render_markup($elements) {
+ $elements['#children'] = $elements['#markup'];
+ return $elements;
+}
+
+/**
+ * Renders the page, including all theming.
+ *
+ * @param $page
+ * A string or array representing the content of a page. The array consists of
+ * the following keys:
+ * - #type: Value is always 'page'. This pushes the theming through page.tpl.php (required).
+ * - #show_messages: Suppress drupal_get_message() items. Used by Batch API (optional).
+ *
+ * @see hook_page_alter()
+ * @see element_info()
+ */
+function drupal_render_page($page) {
+ $main_content_display = &drupal_static('system_main_content_added', FALSE);
+
+ // Allow menu callbacks to return strings or arbitrary arrays to render.
+ // If the array returned is not of #type page directly, we need to fill
+ // in the page with defaults.
+ if (is_string($page) || (is_array($page) && (!isset($page['#type']) || ($page['#type'] != 'page')))) {
+ drupal_set_page_content($page);
+ $page = element_info('page');
+ }
+
+ // Modules can add elements to $page as needed in hook_page_build().
+ foreach (module_implements('page_build') as $module) {
+ $function = $module . '_page_build';
+ $function($page);
+ }
+ // Modules alter the $page as needed. Blocks are populated into regions like
+ // 'sidebar_first', 'footer', etc.
+ drupal_alter('page', $page);
+
+ // If no module has taken care of the main content, add it to the page now.
+ // This allows the site to still be usable even if no modules that
+ // control page regions (for example, the Block module) are enabled.
+ if (!$main_content_display) {
+ $page['content']['system_main'] = drupal_set_page_content();
+ }
+
+ return drupal_render($page);
+}
+
+/**
+ * Renders HTML given a structured array tree.
+ *
+ * Recursively iterates over each of the array elements, generating HTML code.
+ *
+ * Renderable arrays have two kinds of key/value pairs: properties and
+ * children. Properties have keys starting with '#' and their values influence
+ * how the array will be rendered. Children are all elements whose keys do not
+ * start with a '#'. Their values should be renderable arrays themselves,
+ * which will be rendered during the rendering of the parent array. The markup
+ * provided by the children is typically inserted into the markup generated by
+ * the parent array.
+ *
+ * HTML generation for a renderable array, and the treatment of any children,
+ * is controlled by two properties containing theme functions, #theme and
+ * #theme_wrappers.
+ *
+ * #theme is the theme function called first. If it is set and the element has
+ * any children, it is the responsibility of the theme function to render
+ * these children. For elements that are not allowed to have any children,
+ * e.g. buttons or textfields, the theme function can be used to render the
+ * element itself. If #theme is not present and the element has children, they
+ * are rendered and concatenated into a string by drupal_render_children().
+ *
+ * The #theme_wrappers property contains an array of theme functions which will
+ * be called, in order, after #theme has run. These can be used to add further
+ * markup around the rendered children; e.g., fieldsets add the required markup
+ * for a fieldset around their rendered child elements. All wrapper theme
+ * functions have to include the element's #children property in their output,
+ * as it contains the output of the previous theme functions and the rendered
+ * children.
+ *
+ * For example, for the form element type, by default only the #theme_wrappers
+ * property is set, which adds the form markup around the rendered child
+ * elements of the form. This allows you to set the #theme property on a
+ * specific form to a custom theme function, giving you complete control over
+ * the placement of the form's children while not at all having to deal with
+ * the form markup itself.
+ *
+ * drupal_render() can optionally cache the rendered output of elements to
+ * improve performance. To use drupal_render() caching, set the element's #cache
+ * property to an associative array with one or several of the following keys:
+ * - 'keys': An array of one or more keys that identify the element. If 'keys'
+ * is set, the cache ID is created automatically from these keys. See
+ * drupal_render_cid_create().
+ * - 'granularity' (optional): Define the cache granularity using binary
+ * combinations of the cache granularity constants, e.g. DRUPAL_CACHE_PER_USER
+ * to cache for each user separately or
+ * DRUPAL_CACHE_PER_PAGE | DRUPAL_CACHE_PER_ROLE to cache separately for each
+ * page and role. If not specified the element is cached globally for each
+ * theme and language.
+ * - 'cid': Specify the cache ID directly. Either 'keys' or 'cid' is required.
+ * If 'cid' is set, 'keys' and 'granularity' are ignored. Use only if you
+ * have special requirements.
+ * - 'expire': Set to one of the cache lifetime constants.
+ * - 'bin': Specify a cache bin to cache the element in. Defaults to 'cache'.
+ *
+ * This function is usually called from within another function, like
+ * drupal_get_form() or a theme function. Elements are sorted internally
+ * using uasort(). Since this is expensive, when passing already sorted
+ * elements to drupal_render(), for example from a database query, set
+ * $elements['#sorted'] = TRUE to avoid sorting them a second time.
+ *
+ * drupal_render() flags each element with a '#printed' status to indicate that
+ * the element has been rendered, which allows individual elements of a given
+ * array to be rendered independently and prevents them from being rendered
+ * more than once on subsequent calls to drupal_render() (e.g., as part of a
+ * larger array). If the same array or array element is passed more than once
+ * to drupal_render(), it simply returns a NULL value.
+ *
+ * @param $elements
+ * The structured array describing the data to be rendered.
+ * @return
+ * The rendered HTML.
+ */
+function drupal_render(&$elements) {
+ // Early-return nothing if user does not have access.
+ if (empty($elements) || (isset($elements['#access']) && !$elements['#access'])) {
+ return;
+ }
+
+ // Do not print elements twice.
+ if (!empty($elements['#printed'])) {
+ return;
+ }
+
+ // Try to fetch the element's markup from cache and return.
+ if (isset($elements['#cache']) && $cached_output = drupal_render_cache_get($elements)) {
+ return $cached_output;
+ }
+
+ // If #markup is set, ensure #type is set. This allows to specify just #markup
+ // on an element without setting #type.
+ if (isset($elements['#markup']) && !isset($elements['#type'])) {
+ $elements['#type'] = 'markup';
+ }
+
+ // If the default values for this element have not been loaded yet, populate
+ // them.
+ if (isset($elements['#type']) && empty($elements['#defaults_loaded'])) {
+ $elements += element_info($elements['#type']);
+ }
+
+ // Make any final changes to the element before it is rendered. This means
+ // that the $element or the children can be altered or corrected before the
+ // element is rendered into the final text.
+ if (isset($elements['#pre_render'])) {
+ foreach ($elements['#pre_render'] as $function) {
+ if (function_exists($function)) {
+ $elements = $function($elements);
+ }
+ }
+ }
+
+ // Allow #pre_render to abort rendering.
+ if (!empty($elements['#printed'])) {
+ return;
+ }
+
+ // Get the children of the element, sorted by weight.
+ $children = element_children($elements, TRUE);
+
+ // Initialize this element's #children, unless a #pre_render callback already
+ // preset #children.
+ if (!isset($elements['#children'])) {
+ $elements['#children'] = '';
+ }
+ // Call the element's #theme function if it is set. Then any children of the
+ // element have to be rendered there.
+ if (isset($elements['#theme'])) {
+ $elements['#children'] = theme($elements['#theme'], $elements);
+ }
+ // If #theme was not set and the element has children, render them now.
+ // This is the same process as drupal_render_children() but is inlined
+ // for speed.
+ if ($elements['#children'] == '') {
+ foreach ($children as $key) {
+ $elements['#children'] .= drupal_render($elements[$key]);
+ }
+ }
+
+ // Let the theme functions in #theme_wrappers add markup around the rendered
+ // children.
+ if (isset($elements['#theme_wrappers'])) {
+ foreach ($elements['#theme_wrappers'] as $theme_wrapper) {
+ $elements['#children'] = theme($theme_wrapper, $elements);
+ }
+ }
+
+ // Filter the outputted content and make any last changes before the
+ // content is sent to the browser. The changes are made on $content
+ // which allows the output'ed text to be filtered.
+ if (isset($elements['#post_render'])) {
+ foreach ($elements['#post_render'] as $function) {
+ if (function_exists($function)) {
+ $elements['#children'] = $function($elements['#children'], $elements);
+ }
+ }
+ }
+
+ // Add any JavaScript state information associated with the element.
+ if (!empty($elements['#states'])) {
+ drupal_process_states($elements);
+ }
+
+ // Add additional libraries, CSS, JavaScript an other custom
+ // attached data associated with this element.
+ if (!empty($elements['#attached'])) {
+ drupal_process_attached($elements);
+ }
+
+ $prefix = isset($elements['#prefix']) ? $elements['#prefix'] : '';
+ $suffix = isset($elements['#suffix']) ? $elements['#suffix'] : '';
+ $output = $prefix . $elements['#children'] . $suffix;
+
+ // Cache the processed element if #cache is set.
+ if (isset($elements['#cache'])) {
+ drupal_render_cache_set($output, $elements);
+ }
+
+ $elements['#printed'] = TRUE;
+ return $output;
+}
+
+/**
+ * Render children of an element and concatenate them.
+ *
+ * This renders all children of an element using drupal_render() and then
+ * joins them together into a single string.
+ *
+ * @param $element
+ * The structured array whose children shall be rendered.
+ * @param $children_keys
+ * If the keys of the element's children are already known, they can be passed
+ * in to save another run of element_children().
+ */
+function drupal_render_children(&$element, $children_keys = NULL) {
+ if ($children_keys === NULL) {
+ $children_keys = element_children($element);
+ }
+ $output = '';
+ foreach ($children_keys as $key) {
+ if (!empty($element[$key])) {
+ $output .= drupal_render($element[$key]);
+ }
+ }
+ return $output;
+}
+
+/**
+ * Render an element.
+ *
+ * This function renders an element using drupal_render(). The top level
+ * element is shown with show() before rendering, so it will always be rendered
+ * even if hide() had been previously used on it.
+ *
+ * @param $element
+ * The element to be rendered.
+ *
+ * @return
+ * The rendered element.
+ *
+ * @see drupal_render()
+ * @see show()
+ * @see hide()
+ */
+function render(&$element) {
+ if (is_array($element)) {
+ show($element);
+ return drupal_render($element);
+ }
+ else {
+ // Safe-guard for inappropriate use of render() on flat variables: return
+ // the variable as-is.
+ return $element;
+ }
+}
+
+/**
+ * Hide an element from later rendering.
+ *
+ * The first time render() or drupal_render() is called on an element tree,
+ * as each element in the tree is rendered, it is marked with a #printed flag
+ * and the rendered children of the element are cached. Subsequent calls to
+ * render() or drupal_render() will not traverse the child tree of this element
+ * again: they will just use the cached children. So if you want to hide an
+ * element, be sure to call hide() on the element before its parent tree is
+ * rendered for the first time, as it will have no effect on subsequent
+ * renderings of the parent tree.
+ *
+ * @param $element
+ * The element to be hidden.
+ *
+ * @return
+ * The element.
+ *
+ * @see render()
+ * @see show()
+ */
+function hide(&$element) {
+ $element['#printed'] = TRUE;
+ return $element;
+}
+
+/**
+ * Show a hidden element for later rendering.
+ *
+ * You can also use render($element), which shows the element while rendering
+ * it.
+ *
+ * The first time render() or drupal_render() is called on an element tree,
+ * as each element in the tree is rendered, it is marked with a #printed flag
+ * and the rendered children of the element are cached. Subsequent calls to
+ * render() or drupal_render() will not traverse the child tree of this element
+ * again: they will just use the cached children. So if you want to show an
+ * element, be sure to call show() on the element before its parent tree is
+ * rendered for the first time, as it will have no effect on subsequent
+ * renderings of the parent tree.
+ *
+ * @param $element
+ * The element to be shown.
+ *
+ * @return
+ * The element.
+ *
+ * @see render()
+ * @see hide()
+ */
+function show(&$element) {
+ $element['#printed'] = FALSE;
+ return $element;
+}
+
+/**
+ * Get the rendered output of a renderable element from cache.
+ *
+ * @see drupal_render()
+ * @see drupal_render_cache_set()
+ *
+ * @param $elements
+ * A renderable array.
+ * @return
+ * A markup string containing the rendered content of the element, or FALSE
+ * if no cached copy of the element is available.
+ */
+function drupal_render_cache_get($elements) {
+ if (!in_array($_SERVER['REQUEST_METHOD'], array('GET', 'HEAD')) || !$cid = drupal_render_cid_create($elements)) {
+ return FALSE;
+ }
+ $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'cache';
+
+ if (!empty($cid) && $cache = cache($bin)->get($cid)) {
+ // Add additional libraries, JavaScript, CSS and other data attached
+ // to this element.
+ if (isset($cache->data['#attached'])) {
+ drupal_process_attached($cache->data);
+ }
+ // Return the rendered output.
+ return $cache->data['#markup'];
+ }
+ return FALSE;
+}
+
+/**
+ * Cache the rendered output of a renderable element.
+ *
+ * This is called by drupal_render() if the #cache property is set on an element.
+ *
+ * @see drupal_render()
+ * @see drupal_render_cache_get()
+ *
+ * @param $markup
+ * The rendered output string of $elements.
+ * @param $elements
+ * A renderable array.
+ */
+function drupal_render_cache_set(&$markup, $elements) {
+ // Create the cache ID for the element.
+ if (!in_array($_SERVER['REQUEST_METHOD'], array('GET', 'HEAD')) || !$cid = drupal_render_cid_create($elements)) {
+ return FALSE;
+ }
+
+ // Cache implementations are allowed to modify the markup, to support
+ // replacing markup with edge-side include commands. The supporting cache
+ // backend will store the markup in some other key (like
+ // $data['#real-value']) and return an include command instead. When the
+ // ESI command is executed by the content accelerator, the real value can
+ // be retrieved and used.
+ $data['#markup'] = &$markup;
+ // Persist attached data associated with this element.
+ $attached = drupal_render_collect_attached($elements, TRUE);
+ if ($attached) {
+ $data['#attached'] = $attached;
+ }
+ $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'cache';
+ $expire = isset($elements['#cache']['expire']) ? $elements['#cache']['expire'] : CACHE_PERMANENT;
+ cache($bin)->set($cid, $data, $expire);
+}
+
+/**
+ * Collect #attached for an element and all child elements into a single array.
+ *
+ * When caching elements, it is necessary to collect all libraries, JavaScript
+ * and CSS into a single array, from both the element itself and all child
+ * elements. This allows drupal_render() to add these back to the page when the
+ * element is returned from cache.
+ *
+ * @param $elements
+ * The element to collect #attached from.
+ * @param $return
+ * Whether to return the attached elements and reset the internal static.
+ *
+ * @return
+ * The #attached array for this element and its descendants.
+ */
+function drupal_render_collect_attached($elements, $return = FALSE) {
+ $attached = &drupal_static(__FUNCTION__, array());
+
+ // Collect all #attached for this element.
+ if (isset($elements['#attached'])) {
+ foreach ($elements['#attached'] as $key => $value) {
+ if (!isset($attached[$key])) {
+ $attached[$key] = array();
+ }
+ $attached[$key] = array_merge($attached[$key], $value);
+ }
+ }
+ if ($children = element_children($elements)) {
+ foreach ($children as $child) {
+ drupal_render_collect_attached($elements[$child]);
+ }
+ }
+
+ // If this was the first call to the function, return all attached elements
+ // and reset the static cache.
+ if ($return) {
+ $return = $attached;
+ $attached = array();
+ return $return;
+ }
+}
+
+/**
+ * Prepare an element for caching based on a query. This smart caching strategy
+ * saves Drupal from querying and rendering to HTML when the underlying query is
+ * unchanged.
+ *
+ * Expensive queries should use the query builder to create the query and then
+ * call this function. Executing the query and formatting results should happen
+ * in a #pre_render callback.
+ *
+ * @param $query
+ * A select query object as returned by db_select().
+ * @param $function
+ * The name of the function doing this caching. A _pre_render suffix will be
+ * added to this string and is also part of the cache key in
+ * drupal_render_cache_set() and drupal_render_cache_get().
+ * @param $expire
+ * The cache expire time, passed eventually to cache_set().
+ * @param $granularity
+ * One or more granularity constants passed to drupal_render_cid_parts().
+ *
+ * @return
+ * A renderable array with the following keys and values:
+ * - #query: The passed-in $query.
+ * - #pre_render: $function with a _pre_render suffix.
+ * - #cache: An associative array prepared for drupal_render_cache_set().
+ */
+function drupal_render_cache_by_query($query, $function, $expire = CACHE_TEMPORARY, $granularity = NULL) {
+ $cache_keys = array_merge(array($function), drupal_render_cid_parts($granularity));
+ $query->preExecute();
+ $cache_keys[] = hash('sha256', serialize(array((string) $query, $query->getArguments())));
+ return array(
+ '#query' => $query,
+ '#pre_render' => array($function . '_pre_render'),
+ '#cache' => array(
+ 'keys' => $cache_keys,
+ 'expire' => $expire,
+ ),
+ );
+}
+
+/**
+ * Helper function for building cache ids.
+ *
+ * @param $granularity
+ * One or more cache granularity constants, e.g. DRUPAL_CACHE_PER_USER to cache
+ * for each user separately or DRUPAL_CACHE_PER_PAGE | DRUPAL_CACHE_PER_ROLE to
+ * cache separately for each page and role.
+ *
+ * @return
+ * An array of cache ID parts, always containing the active theme. If the
+ * locale module is enabled it also contains the active language. If
+ * $granularity was passed in, more parts are added.
+ */
+function drupal_render_cid_parts($granularity = NULL) {
+ global $theme, $base_root, $user;
+
+ $cid_parts[] = $theme;
+ // If Locale is enabled but we have only one language we do not need it as cid
+ // part.
+ if (drupal_multilingual()) {
+ foreach (language_types_configurable() as $language_type) {
+ $cid_parts[] = $GLOBALS[$language_type]->language;
+ }
+ }
+
+ if (!empty($granularity)) {
+ // 'PER_ROLE' and 'PER_USER' are mutually exclusive. 'PER_USER' can be a
+ // resource drag for sites with many users, so when a module is being
+ // equivocal, we favor the less expensive 'PER_ROLE' pattern.
+ if ($granularity & DRUPAL_CACHE_PER_ROLE) {
+ $cid_parts[] = 'r.' . implode(',', array_keys($user->roles));
+ }
+ elseif ($granularity & DRUPAL_CACHE_PER_USER) {
+ $cid_parts[] = "u.$user->uid";
+ }
+
+ if ($granularity & DRUPAL_CACHE_PER_PAGE) {
+ $cid_parts[] = $base_root . request_uri();
+ }
+ }
+
+ return $cid_parts;
+}
+
+/**
+ * Create the cache ID for a renderable element.
+ *
+ * This creates the cache ID string, either by returning the #cache['cid']
+ * property if present or by building the cache ID out of the #cache['keys']
+ * and, optionally, the #cache['granularity'] properties.
+ *
+ * @param $elements
+ * A renderable array.
+ *
+ * @return
+ * The cache ID string, or FALSE if the element may not be cached.
+ */
+function drupal_render_cid_create($elements) {
+ if (isset($elements['#cache']['cid'])) {
+ return $elements['#cache']['cid'];
+ }
+ elseif (isset($elements['#cache']['keys'])) {
+ $granularity = isset($elements['#cache']['granularity']) ? $elements['#cache']['granularity'] : NULL;
+ // Merge in additional cache ID parts based provided by drupal_render_cid_parts().
+ $cid_parts = array_merge($elements['#cache']['keys'], drupal_render_cid_parts($granularity));
+ return implode(':', $cid_parts);
+ }
+ return FALSE;
+}
+
+/**
+ * Function used by uasort to sort structured arrays by weight.
+ */
+function element_sort($a, $b) {
+ $a_weight = (is_array($a) && isset($a['#weight'])) ? $a['#weight'] : 0;
+ $b_weight = (is_array($b) && isset($b['#weight'])) ? $b['#weight'] : 0;
+ if ($a_weight == $b_weight) {
+ return 0;
+ }
+ return ($a_weight < $b_weight) ? -1 : 1;
+}
+
+/**
+ * Array sorting callback; sorts elements by title.
+ */
+function element_sort_by_title($a, $b) {
+ $a_title = (is_array($a) && isset($a['#title'])) ? $a['#title'] : '';
+ $b_title = (is_array($b) && isset($b['#title'])) ? $b['#title'] : '';
+ return strnatcasecmp($a_title, $b_title);
+}
+
+/**
+ * Retrieve the default properties for the defined element type.
+ *
+ * @param $type
+ * An element type as defined by hook_element_info().
+ */
+function element_info($type) {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['cache'] = &drupal_static(__FUNCTION__);
+ }
+ $cache = &$drupal_static_fast['cache'];
+
+ if (!isset($cache)) {
+ $cache = module_invoke_all('element_info');
+ foreach ($cache as $element_type => $info) {
+ $cache[$element_type]['#type'] = $element_type;
+ }
+ // Allow modules to alter the element type defaults.
+ drupal_alter('element_info', $cache);
+ }
+
+ return isset($cache[$type]) ? $cache[$type] : array();
+}
+
+/**
+ * Retrieve a single property for the defined element type.
+ *
+ * @param $type
+ * An element type as defined by hook_element_info().
+ * @param $property_name
+ * The property within the element type that should be returned.
+ * @param $default
+ * (Optional) The value to return if the element type does not specify a
+ * value for the property. Defaults to NULL.
+ */
+function element_info_property($type, $property_name, $default = NULL) {
+ return (($info = element_info($type)) && array_key_exists($property_name, $info)) ? $info[$property_name] : $default;
+}
+
+/**
+ * Function used by uasort to sort structured arrays by weight, without the property weight prefix.
+ */
+function drupal_sort_weight($a, $b) {
+ $a_weight = (is_array($a) && isset($a['weight'])) ? $a['weight'] : 0;
+ $b_weight = (is_array($b) && isset($b['weight'])) ? $b['weight'] : 0;
+ if ($a_weight == $b_weight) {
+ return 0;
+ }
+ return ($a_weight < $b_weight) ? -1 : 1;
+}
+
+/**
+ * Array sorting callback; sorts elements by 'title' key.
+ */
+function drupal_sort_title($a, $b) {
+ if (!isset($b['title'])) {
+ return -1;
+ }
+ if (!isset($a['title'])) {
+ return 1;
+ }
+ return strcasecmp($a['title'], $b['title']);
+}
+
+/**
+ * Check if the key is a property.
+ */
+function element_property($key) {
+ return $key[0] == '#';
+}
+
+/**
+ * Get properties of a structured array element. Properties begin with '#'.
+ */
+function element_properties($element) {
+ return array_filter(array_keys((array) $element), 'element_property');
+}
+
+/**
+ * Check if the key is a child.
+ */
+function element_child($key) {
+ return !isset($key[0]) || $key[0] != '#';
+}
+
+/**
+ * Identifies the children of an element array, optionally sorted by weight.
+ *
+ * The children of a element array are those key/value pairs whose key does
+ * not start with a '#'. See drupal_render() for details.
+ *
+ * @param $elements
+ * The element array whose children are to be identified.
+ * @param $sort
+ * Boolean to indicate whether the children should be sorted by weight.
+ * @return
+ * The array keys of the element's children.
+ */
+function element_children(&$elements, $sort = FALSE) {
+ // Do not attempt to sort elements which have already been sorted.
+ $sort = isset($elements['#sorted']) ? !$elements['#sorted'] : $sort;
+
+ // Filter out properties from the element, leaving only children.
+ $children = array();
+ $sortable = FALSE;
+ foreach ($elements as $key => $value) {
+ if ($key === '' || $key[0] !== '#') {
+ if (is_array($value)) {
+ $children[$key] = $value;
+ if (isset($value['#weight'])) {
+ $sortable = TRUE;
+ }
+ }
+ // Only trigger an error if the value is not null.
+ // @see http://drupal.org/node/1283892
+ elseif (isset($value)) {
+ trigger_error(t('"@key" is an invalid render array key', array('@key' => $key)), E_USER_ERROR);
+ }
+ }
+ }
+ // Sort the children if necessary.
+ if ($sort && $sortable) {
+ uasort($children, 'element_sort');
+ // Put the sorted children back into $elements in the correct order, to
+ // preserve sorting if the same element is passed through
+ // element_children() twice.
+ foreach ($children as $key => $child) {
+ unset($elements[$key]);
+ $elements[$key] = $child;
+ }
+ $elements['#sorted'] = TRUE;
+ }
+
+ return array_keys($children);
+}
+
+/**
+ * Returns the visible children of an element.
+ *
+ * @param $elements
+ * The parent element.
+ * @return
+ * The array keys of the element's visible children.
+ */
+function element_get_visible_children(array $elements) {
+ $visible_children = array();
+
+ foreach (element_children($elements) as $key) {
+ $child = $elements[$key];
+
+ // Skip un-accessible children.
+ if (isset($child['#access']) && !$child['#access']) {
+ continue;
+ }
+
+ // Skip value and hidden elements, since they are not rendered.
+ if (isset($child['#type']) && in_array($child['#type'], array('value', 'hidden'))) {
+ continue;
+ }
+
+ $visible_children[$key] = $child;
+ }
+
+ return array_keys($visible_children);
+}
+
+/**
+ * Sets HTML attributes based on element properties.
+ *
+ * @param $element
+ * The renderable element to process.
+ * @param $map
+ * An associative array whose keys are element property names and whose values
+ * are the HTML attribute names to set for corresponding the property; e.g.,
+ * array('#propertyname' => 'attributename'). If both names are identical
+ * except for the leading '#', then an attribute name value is sufficient and
+ * no property name needs to be specified.
+ */
+function element_set_attributes(array &$element, array $map) {
+ foreach ($map as $property => $attribute) {
+ // If the key is numeric, the attribute name needs to be taken over.
+ if (is_int($property)) {
+ $property = '#' . $attribute;
+ }
+ // Do not overwrite already existing attributes.
+ if (isset($element[$property]) && !isset($element['#attributes'][$attribute])) {
+ $element['#attributes'][$attribute] = $element[$property];
+ }
+ }
+}
+
+/**
+ * Sets a value in a nested array with variable depth.
+ *
+ * This helper function should be used when the depth of the array element you
+ * are changing may vary (that is, the number of parent keys is variable). It
+ * is primarily used for form structures and renderable arrays.
+ *
+ * Example:
+ * @code
+ * // Assume you have a 'signature' element somewhere in a form. It might be:
+ * $form['signature_settings']['signature'] = array(
+ * '#type' => 'text_format',
+ * '#title' => t('Signature'),
+ * );
+ * // Or, it might be further nested:
+ * $form['signature_settings']['user']['signature'] = array(
+ * '#type' => 'text_format',
+ * '#title' => t('Signature'),
+ * );
+ * @endcode
+ *
+ * To deal with the situation, the code needs to figure out the route to the
+ * element, given an array of parents that is either
+ * @code array('signature_settings', 'signature') @endcode in the first case or
+ * @code array('signature_settings', 'user', 'signature') @endcode in the second
+ * case.
+ *
+ * Without this helper function the only way to set the signature element in one
+ * line would be using eval(), which should be avoided:
+ * @code
+ * // Do not do this! Avoid eval().
+ * eval('$form[\'' . implode("']['", $parents) . '\'] = $element;');
+ * @endcode
+ *
+ * Instead, use this helper function:
+ * @code
+ * drupal_array_set_nested_value($form, $parents, $element);
+ * @endcode
+ *
+ * However if the number of array parent keys is static, the value should always
+ * be set directly rather than calling this function. For instance, for the
+ * first example we could just do:
+ * @code
+ * $form['signature_settings']['signature'] = $element;
+ * @endcode
+ *
+ * @param $array
+ * A reference to the array to modify.
+ * @param $parents
+ * An array of parent keys, starting with the outermost key.
+ * @param $value
+ * The value to set.
+ * @param $force
+ * (Optional) If TRUE, the value is forced into the structure even if it
+ * requires the deletion of an already existing non-array parent value. If
+ * FALSE, PHP throws an error if trying to add into a value that is not an
+ * array. Defaults to FALSE.
+ *
+ * @see drupal_array_get_nested_value()
+ */
+function drupal_array_set_nested_value(array &$array, array $parents, $value, $force = FALSE) {
+ $ref = &$array;
+ foreach ($parents as $parent) {
+ // PHP auto-creates container arrays and NULL entries without error if $ref
+ // is NULL, but throws an error if $ref is set, but not an array.
+ if ($force && isset($ref) && !is_array($ref)) {
+ $ref = array();
+ }
+ $ref = &$ref[$parent];
+ }
+ $ref = $value;
+}
+
+/**
+ * Retrieves a value from a nested array with variable depth.
+ *
+ * This helper function should be used when the depth of the array element being
+ * retrieved may vary (that is, the number of parent keys is variable). It is
+ * primarily used for form structures and renderable arrays.
+ *
+ * Without this helper function the only way to get a nested array value with
+ * variable depth in one line would be using eval(), which should be avoided:
+ * @code
+ * // Do not do this! Avoid eval().
+ * // May also throw a PHP notice, if the variable array keys do not exist.
+ * eval('$value = $array[\'' . implode("']['", $parents) . "'];");
+ * @endcode
+ *
+ * Instead, use this helper function:
+ * @code
+ * $value = drupal_array_get_nested_value($form, $parents);
+ * @endcode
+ *
+ * The return value will be NULL, regardless of whether the actual value is NULL
+ * or whether the requested key does not exist. If it is required to know
+ * whether the nested array key actually exists, pass a third argument that is
+ * altered by reference:
+ * @code
+ * $key_exists = NULL;
+ * $value = drupal_array_get_nested_value($form, $parents, $key_exists);
+ * if ($key_exists) {
+ * // ... do something with $value ...
+ * }
+ * @endcode
+ *
+ * However if the number of array parent keys is static, the value should always
+ * be retrieved directly rather than calling this function. For instance:
+ * @code
+ * $value = $form['signature_settings']['signature'];
+ * @endcode
+ *
+ * @param $array
+ * The array from which to get the value.
+ * @param $parents
+ * An array of parent keys of the value, starting with the outermost key.
+ * @param $key_exists
+ * (optional) If given, an already defined variable that is altered by
+ * reference.
+ *
+ * @return
+ * The requested nested value. Possibly NULL if the value is NULL or not all
+ * nested parent keys exist. $key_exists is altered by reference and is a
+ * Boolean that indicates whether all nested parent keys exist (TRUE) or not
+ * (FALSE). This allows to distinguish between the two possibilities when NULL
+ * is returned.
+ *
+ * @see drupal_array_set_nested_value()
+ */
+function drupal_array_get_nested_value(array &$array, array $parents, &$key_exists = NULL) {
+ $ref = &$array;
+ foreach ($parents as $parent) {
+ if (is_array($ref) && array_key_exists($parent, $ref)) {
+ $ref = &$ref[$parent];
+ }
+ else {
+ $key_exists = FALSE;
+ return NULL;
+ }
+ }
+ $key_exists = TRUE;
+ return $ref;
+}
+
+/**
+ * Determines whether a nested array with variable depth contains all of the requested keys.
+ *
+ * This helper function should be used when the depth of the array element to be
+ * checked may vary (that is, the number of parent keys is variable). See
+ * drupal_array_set_nested_value() for details. It is primarily used for form
+ * structures and renderable arrays.
+ *
+ * If it is required to also get the value of the checked nested key, use
+ * drupal_array_get_nested_value() instead.
+ *
+ * If the number of array parent keys is static, this helper function is
+ * unnecessary and the following code can be used instead:
+ * @code
+ * $value_exists = isset($form['signature_settings']['signature']);
+ * $key_exists = array_key_exists('signature', $form['signature_settings']);
+ * @endcode
+ *
+ * @param $array
+ * The array with the value to check for.
+ * @param $parents
+ * An array of parent keys of the value, starting with the outermost key.
+ *
+ * @return
+ * TRUE if all the parent keys exist, FALSE otherwise.
+ *
+ * @see drupal_array_get_nested_value()
+ */
+function drupal_array_nested_key_exists(array $array, array $parents) {
+ // Although this function is similar to PHP's array_key_exists(), its
+ // arguments should be consistent with drupal_array_get_nested_value().
+ $key_exists = NULL;
+ drupal_array_get_nested_value($array, $parents, $key_exists);
+ return $key_exists;
+}
+
+/**
+ * Provide theme registration for themes across .inc files.
+ */
+function drupal_common_theme() {
+ return array(
+ // theme.inc
+ 'html' => array(
+ 'render element' => 'page',
+ 'template' => 'html',
+ ),
+ 'page' => array(
+ 'render element' => 'page',
+ 'template' => 'page',
+ ),
+ 'region' => array(
+ 'render element' => 'elements',
+ 'template' => 'region',
+ ),
+ 'status_messages' => array(
+ 'variables' => array('display' => NULL),
+ ),
+ 'link' => array(
+ 'variables' => array('text' => NULL, 'path' => NULL, 'options' => array()),
+ ),
+ 'links' => array(
+ 'variables' => array('links' => NULL, 'attributes' => array('class' => array('links')), 'heading' => array()),
+ ),
+ 'image' => array(
+ // HTML 4 and XHTML 1.0 always require an alt attribute. The HTML 5 draft
+ // allows the alt attribute to be omitted in some cases. Therefore,
+ // default the alt attribute to an empty string, but allow code calling
+ // theme('image') to pass explicit NULL for it to be omitted. Usually,
+ // neither omission nor an empty string satisfies accessibility
+ // requirements, so it is strongly encouraged for code calling
+ // theme('image') to pass a meaningful value for the alt variable.
+ // - http://www.w3.org/TR/REC-html40/struct/objects.html#h-13.8
+ // - http://www.w3.org/TR/xhtml1/dtds.html
+ // - http://dev.w3.org/html5/spec/Overview.html#alt
+ // The title attribute is optional in all cases, so it is omitted by
+ // default.
+ 'variables' => array('path' => NULL, 'width' => NULL, 'height' => NULL, 'alt' => '', 'title' => NULL, 'attributes' => array()),
+ ),
+ 'breadcrumb' => array(
+ 'variables' => array('breadcrumb' => NULL),
+ ),
+ 'help' => array(
+ 'variables' => array(),
+ ),
+ 'table' => array(
+ 'variables' => array('header' => NULL, 'rows' => NULL, 'attributes' => array(), 'caption' => NULL, 'colgroups' => array(), 'sticky' => TRUE, 'empty' => ''),
+ ),
+ 'tablesort_indicator' => array(
+ 'variables' => array('style' => NULL),
+ ),
+ 'mark' => array(
+ 'variables' => array('type' => MARK_NEW),
+ ),
+ 'item_list' => array(
+ 'variables' => array('items' => array(), 'title' => '', 'type' => 'ul', 'attributes' => array()),
+ ),
+ 'more_help_link' => array(
+ 'variables' => array('url' => NULL),
+ ),
+ 'feed_icon' => array(
+ 'variables' => array('url' => NULL, 'title' => NULL),
+ ),
+ 'more_link' => array(
+ 'variables' => array('url' => NULL, 'title' => NULL)
+ ),
+ 'username' => array(
+ 'variables' => array('account' => NULL),
+ ),
+ 'progress_bar' => array(
+ 'variables' => array('percent' => NULL, 'message' => NULL),
+ ),
+ 'indentation' => array(
+ 'variables' => array('size' => 1),
+ ),
+ 'html_tag' => array(
+ 'render element' => 'element',
+ ),
+ // from theme.maintenance.inc
+ 'maintenance_page' => array(
+ 'variables' => array('content' => NULL, 'show_messages' => TRUE),
+ 'template' => 'maintenance-page',
+ ),
+ 'update_page' => array(
+ 'variables' => array('content' => NULL, 'show_messages' => TRUE),
+ ),
+ 'install_page' => array(
+ 'variables' => array('content' => NULL),
+ ),
+ 'task_list' => array(
+ 'variables' => array('items' => NULL, 'active' => NULL),
+ ),
+ 'authorize_message' => array(
+ 'variables' => array('message' => NULL, 'success' => TRUE),
+ ),
+ 'authorize_report' => array(
+ 'variables' => array('messages' => array()),
+ ),
+ // from pager.inc
+ 'pager' => array(
+ 'variables' => array('tags' => array(), 'element' => 0, 'parameters' => array(), 'quantity' => 9),
+ ),
+ 'pager_first' => array(
+ 'variables' => array('text' => NULL, 'element' => 0, 'parameters' => array()),
+ ),
+ 'pager_previous' => array(
+ 'variables' => array('text' => NULL, 'element' => 0, 'interval' => 1, 'parameters' => array()),
+ ),
+ 'pager_next' => array(
+ 'variables' => array('text' => NULL, 'element' => 0, 'interval' => 1, 'parameters' => array()),
+ ),
+ 'pager_last' => array(
+ 'variables' => array('text' => NULL, 'element' => 0, 'parameters' => array()),
+ ),
+ 'pager_link' => array(
+ 'variables' => array('text' => NULL, 'page_new' => NULL, 'element' => NULL, 'parameters' => array(), 'attributes' => array()),
+ ),
+ // from menu.inc
+ 'menu_link' => array(
+ 'render element' => 'element',
+ ),
+ 'menu_tree' => array(
+ 'render element' => 'tree',
+ ),
+ 'menu_local_task' => array(
+ 'render element' => 'element',
+ ),
+ 'menu_local_action' => array(
+ 'render element' => 'element',
+ ),
+ 'menu_local_tasks' => array(
+ 'variables' => array('primary' => array(), 'secondary' => array()),
+ ),
+ // from form.inc
+ 'select' => array(
+ 'render element' => 'element',
+ ),
+ 'fieldset' => array(
+ 'render element' => 'element',
+ ),
+ 'radio' => array(
+ 'render element' => 'element',
+ ),
+ 'radios' => array(
+ 'render element' => 'element',
+ ),
+ 'date' => array(
+ 'render element' => 'element',
+ ),
+ 'exposed_filters' => array(
+ 'render element' => 'form',
+ ),
+ 'checkbox' => array(
+ 'render element' => 'element',
+ ),
+ 'checkboxes' => array(
+ 'render element' => 'element',
+ ),
+ 'button' => array(
+ 'render element' => 'element',
+ ),
+ 'image_button' => array(
+ 'render element' => 'element',
+ ),
+ 'hidden' => array(
+ 'render element' => 'element',
+ ),
+ 'textfield' => array(
+ 'render element' => 'element',
+ ),
+ 'form' => array(
+ 'render element' => 'element',
+ ),
+ 'textarea' => array(
+ 'render element' => 'element',
+ ),
+ 'password' => array(
+ 'render element' => 'element',
+ ),
+ 'file' => array(
+ 'render element' => 'element',
+ ),
+ 'tableselect' => array(
+ 'render element' => 'element',
+ ),
+ 'form_element' => array(
+ 'render element' => 'element',
+ ),
+ 'form_required_marker' => array(
+ 'render element' => 'element',
+ ),
+ 'form_element_label' => array(
+ 'render element' => 'element',
+ ),
+ 'vertical_tabs' => array(
+ 'render element' => 'element',
+ ),
+ 'container' => array(
+ 'render element' => 'element',
+ ),
+ );
+}
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+/**
+ * Creates all tables in a module's hook_schema() implementation.
+ *
+ * Note: This function does not pass the module's schema through
+ * hook_schema_alter(). The module's tables will be created exactly as the
+ * module defines them.
+ *
+ * @param $module
+ * The module for which the tables will be created.
+ */
+function drupal_install_schema($module) {
+ $schema = drupal_get_schema_unprocessed($module);
+ _drupal_schema_initialize($schema, $module, FALSE);
+
+ foreach ($schema as $name => $table) {
+ db_create_table($name, $table);
+ }
+}
+
+/**
+ * Remove all tables that a module defines in its hook_schema().
+ *
+ * Note: This function does not pass the module's schema through
+ * hook_schema_alter(). The module's tables will be created exactly as the
+ * module defines them.
+ *
+ * @param $module
+ * The module for which the tables will be removed.
+ * @return
+ * An array of arrays with the following key/value pairs:
+ * - success: a boolean indicating whether the query succeeded.
+ * - query: the SQL query(s) executed, passed through check_plain().
+ */
+function drupal_uninstall_schema($module) {
+ $schema = drupal_get_schema_unprocessed($module);
+ _drupal_schema_initialize($schema, $module, FALSE);
+
+ foreach ($schema as $table) {
+ if (db_table_exists($table['name'])) {
+ db_drop_table($table['name']);
+ }
+ }
+}
+
+/**
+ * Returns the unprocessed and unaltered version of a module's schema.
+ *
+ * Use this function only if you explicitly need the original
+ * specification of a schema, as it was defined in a module's
+ * hook_schema(). No additional default values will be set,
+ * hook_schema_alter() is not invoked and these unprocessed
+ * definitions won't be cached.
+ *
+ * This function can be used to retrieve a schema specification in
+ * hook_schema(), so it allows you to derive your tables from existing
+ * specifications.
+ *
+ * It is also used by drupal_install_schema() and
+ * drupal_uninstall_schema() to ensure that a module's tables are
+ * created exactly as specified without any changes introduced by a
+ * module that implements hook_schema_alter().
+ *
+ * @param $module
+ * The module to which the table belongs.
+ * @param $table
+ * The name of the table. If not given, the module's complete schema
+ * is returned.
+ */
+function drupal_get_schema_unprocessed($module, $table = NULL) {
+ // Load the .install file to get hook_schema.
+ module_load_install($module);
+ $schema = module_invoke($module, 'schema');
+
+ if (isset($table) && isset($schema[$table])) {
+ return $schema[$table];
+ }
+ elseif (!empty($schema)) {
+ return $schema;
+ }
+ return array();
+}
+
+/**
+ * Fill in required default values for table definitions returned by hook_schema().
+ *
+ * @param $schema
+ * The schema definition array as it was returned by the module's
+ * hook_schema().
+ * @param $module
+ * The module for which hook_schema() was invoked.
+ * @param $remove_descriptions
+ * (optional) Whether to additionally remove 'description' keys of all tables
+ * and fields to improve performance of serialize() and unserialize().
+ * Defaults to TRUE.
+ */
+function _drupal_schema_initialize(&$schema, $module, $remove_descriptions = TRUE) {
+ // Set the name and module key for all tables.
+ foreach ($schema as $name => &$table) {
+ if (empty($table['module'])) {
+ $table['module'] = $module;
+ }
+ if (!isset($table['name'])) {
+ $table['name'] = $name;
+ }
+ if ($remove_descriptions) {
+ unset($table['description']);
+ foreach ($table['fields'] as &$field) {
+ unset($field['description']);
+ }
+ }
+ }
+}
+
+/**
+ * Retrieve a list of fields from a table schema. The list is suitable for use in a SQL query.
+ *
+ * @param $table
+ * The name of the table from which to retrieve fields.
+ * @param
+ * An optional prefix to to all fields.
+ *
+ * @return An array of fields.
+ **/
+function drupal_schema_fields_sql($table, $prefix = NULL) {
+ $schema = drupal_get_schema($table);
+ $fields = array_keys($schema['fields']);
+ if ($prefix) {
+ $columns = array();
+ foreach ($fields as $field) {
+ $columns[] = "$prefix.$field";
+ }
+ return $columns;
+ }
+ else {
+ return $fields;
+ }
+}
+
+/**
+ * Saves (inserts or updates) a record to the database based upon the schema.
+ *
+ * @param $table
+ * The name of the table; this must be defined by a hook_schema()
+ * implementation.
+ * @param $record
+ * An object or array representing the record to write, passed in by
+ * reference. If inserting a new record, values not provided in $record will
+ * be populated in $record and in the database with the default values from
+ * the schema, as well as a single serial (auto-increment) field (if present).
+ * If updating an existing record, only provided values are updated in the
+ * database, and $record is not modified.
+ * @param $primary_keys
+ * To indicate that this is a new record to be inserted, omit this argument.
+ * If this is an update, this argument specifies the primary keys' field
+ * names. If there is only 1 field in the key, you may pass in a string; if
+ * there are multiple fields in the key, pass in an array.
+ *
+ * @return
+ * If the record insert or update failed, returns FALSE. If it succeeded,
+ * returns SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
+ */
+function drupal_write_record($table, &$record, $primary_keys = array()) {
+ // Standardize $primary_keys to an array.
+ if (is_string($primary_keys)) {
+ $primary_keys = array($primary_keys);
+ }
+
+ $schema = drupal_get_schema($table);
+ if (empty($schema)) {
+ return FALSE;
+ }
+
+ $object = (object) $record;
+ $fields = array();
+
+ // Go through the schema to determine fields to write.
+ foreach ($schema['fields'] as $field => $info) {
+ if ($info['type'] == 'serial') {
+ // Skip serial types if we are updating.
+ if (!empty($primary_keys)) {
+ continue;
+ }
+ // Track serial field so we can helpfully populate them after the query.
+ // NOTE: Each table should come with one serial field only.
+ $serial = $field;
+ }
+
+ // Skip field if it is in $primary_keys as it is unnecessary to update a
+ // field to the value it is already set to.
+ if (in_array($field, $primary_keys)) {
+ continue;
+ }
+
+ if (!property_exists($object, $field)) {
+ // Skip fields that are not provided, default values are already known
+ // by the database.
+ continue;
+ }
+
+ // Build array of fields to update or insert.
+ if (empty($info['serialize'])) {
+ $fields[$field] = $object->$field;
+ }
+ else {
+ $fields[$field] = serialize($object->$field);
+ }
+
+ // Type cast to proper datatype, except when the value is NULL and the
+ // column allows this.
+ //
+ // MySQL PDO silently casts e.g. FALSE and '' to 0 when inserting the value
+ // into an integer column, but PostgreSQL PDO does not. Also type cast NULL
+ // when the column does not allow this.
+ if (isset($object->$field) || !empty($info['not null'])) {
+ if ($info['type'] == 'int' || $info['type'] == 'serial') {
+ $fields[$field] = (int) $fields[$field];
+ }
+ elseif ($info['type'] == 'float') {
+ $fields[$field] = (float) $fields[$field];
+ }
+ else {
+ $fields[$field] = (string) $fields[$field];
+ }
+ }
+ }
+
+ if (empty($fields)) {
+ return;
+ }
+
+ // Build the SQL.
+ if (empty($primary_keys)) {
+ // We are doing an insert.
+ $options = array('return' => Database::RETURN_INSERT_ID);
+ if (isset($serial) && isset($fields[$serial])) {
+ // If the serial column has been explicitly set with an ID, then we don't
+ // require the database to return the last insert id.
+ if ($fields[$serial]) {
+ $options['return'] = Database::RETURN_AFFECTED;
+ }
+ // If a serial column does exist with no value (i.e. 0) then remove it as
+ // the database will insert the correct value for us.
+ else {
+ unset($fields[$serial]);
+ }
+ }
+ $query = db_insert($table, $options)->fields($fields);
+ $return = SAVED_NEW;
+ }
+ else {
+ $query = db_update($table)->fields($fields);
+ foreach ($primary_keys as $key) {
+ $query->condition($key, $object->$key);
+ }
+ $return = SAVED_UPDATED;
+ }
+
+ // Execute the SQL.
+ if ($query_return = $query->execute()) {
+ if (isset($serial)) {
+ // If the database was not told to return the last insert id, it will be
+ // because we already know it.
+ if (isset($options) && $options['return'] != Database::RETURN_INSERT_ID) {
+ $object->$serial = $fields[$serial];
+ }
+ else {
+ $object->$serial = $query_return;
+ }
+ }
+ }
+ // If we have a single-field primary key but got no insert ID, the
+ // query failed. Note that we explicitly check for FALSE, because
+ // a valid update query which doesn't change any values will return
+ // zero (0) affected rows.
+ elseif ($query_return === FALSE && count($primary_keys) == 1) {
+ $return = FALSE;
+ }
+
+ // If we are inserting, populate empty fields with default values.
+ if (empty($primary_keys)) {
+ foreach ($schema['fields'] as $field => $info) {
+ if (isset($info['default']) && !property_exists($object, $field)) {
+ $object->$field = $info['default'];
+ }
+ }
+ }
+
+ // If we began with an array, convert back.
+ if (is_array($record)) {
+ $record = (array) $object;
+ }
+
+ return $return;
+}
+
+/**
+ * @} End of "ingroup schemaapi".
+ */
+
+/**
+ * Parses Drupal module and theme .info files.
+ *
+ * Info files are NOT for placing arbitrary theme and module-specific settings.
+ * Use variable_get() and variable_set() for that.
+ *
+ * Information stored in a module .info file:
+ * - name: The real name of the module for display purposes.
+ * - description: A brief description of the module.
+ * - dependencies: An array of shortnames of other modules this module requires.
+ * - package: The name of the package of modules this module belongs to.
+ *
+ * See forum.info for an example of a module .info file.
+ *
+ * Information stored in a theme .info file:
+ * - name: The real name of the theme for display purposes.
+ * - description: Brief description.
+ * - screenshot: Path to screenshot relative to the theme's .info file.
+ * - engine: Theme engine; typically phptemplate.
+ * - base: Name of a base theme, if applicable; e.g., base = zen.
+ * - regions: Listed regions; e.g., region[left] = Left sidebar.
+ * - features: Features available; e.g., features[] = logo.
+ * - stylesheets: Theme stylesheets; e.g., stylesheets[all][] = my-style.css.
+ * - scripts: Theme scripts; e.g., scripts[] = my-script.js.
+ *
+ * See bartik.info for an example of a theme .info file.
+ *
+ * @param $filename
+ * The file we are parsing. Accepts file with relative or absolute path.
+ *
+ * @return
+ * The info array.
+ *
+ * @see drupal_parse_info_format()
+ */
+function drupal_parse_info_file($filename) {
+ $info = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($info[$filename])) {
+ if (!file_exists($filename)) {
+ $info[$filename] = array();
+ }
+ else {
+ $data = file_get_contents($filename);
+ $info[$filename] = drupal_parse_info_format($data);
+ }
+ }
+ return $info[$filename];
+}
+
+/**
+ * Parse data in Drupal's .info format.
+ *
+ * Data should be in an .ini-like format to specify values. White-space
+ * generally doesn't matter, except inside values:
+ * @code
+ * key = value
+ * key = "value"
+ * key = 'value'
+ * key = "multi-line
+ * value"
+ * key = 'multi-line
+ * value'
+ * key
+ * =
+ * 'value'
+ * @endcode
+ *
+ * Arrays are created using a HTTP GET alike syntax:
+ * @code
+ * key[] = "numeric array"
+ * key[index] = "associative array"
+ * key[index][] = "nested numeric array"
+ * key[index][index] = "nested associative array"
+ * @endcode
+ *
+ * PHP constants are substituted in, but only when used as the entire value.
+ * Comments should start with a semi-colon at the beginning of a line.
+ *
+ * @param $data
+ * A string to parse.
+ * @return
+ * The info array.
+ *
+ * @see drupal_parse_info_file()
+ */
+function drupal_parse_info_format($data) {
+ $info = array();
+ $constants = get_defined_constants();
+
+ if (preg_match_all('
+ @^\s* # Start at the beginning of a line, ignoring leading whitespace
+ ((?:
+ [^=;\[\]]| # Key names cannot contain equal signs, semi-colons or square brackets,
+ \[[^\[\]]*\] # unless they are balanced and not nested
+ )+?)
+ \s*=\s* # Key/value pairs are separated by equal signs (ignoring white-space)
+ (?:
+ ("(?:[^"]|(?<=\\\\)")*")| # Double-quoted string, which may contain slash-escaped quotes/slashes
+ (\'(?:[^\']|(?<=\\\\)\')*\')| # Single-quoted string, which may contain slash-escaped quotes/slashes
+ ([^\r\n]*?) # Non-quoted string
+ )\s*$ # Stop at the next end of a line, ignoring trailing whitespace
+ @msx', $data, $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ // Fetch the key and value string
+ $i = 0;
+ foreach (array('key', 'value1', 'value2', 'value3') as $var) {
+ $$var = isset($match[++$i]) ? $match[$i] : '';
+ }
+ $value = stripslashes(substr($value1, 1, -1)) . stripslashes(substr($value2, 1, -1)) . $value3;
+
+ // Parse array syntax
+ $keys = preg_split('/\]?\[/', rtrim($key, ']'));
+ $last = array_pop($keys);
+ $parent = &$info;
+
+ // Create nested arrays
+ foreach ($keys as $key) {
+ if ($key == '') {
+ $key = count($parent);
+ }
+ if (!isset($parent[$key]) || !is_array($parent[$key])) {
+ $parent[$key] = array();
+ }
+ $parent = &$parent[$key];
+ }
+
+ // Handle PHP constants.
+ if (isset($constants[$value])) {
+ $value = $constants[$value];
+ }
+
+ // Insert actual value
+ if ($last == '') {
+ $last = count($parent);
+ }
+ $parent[$last] = $value;
+ }
+ }
+
+ return $info;
+}
+
+/**
+ * Severity levels, as defined in RFC 3164: http://www.ietf.org/rfc/rfc3164.txt.
+ *
+ * @return
+ * Array of the possible severity levels for log messages.
+ *
+ * @see watchdog()
+ * @ingroup logging_severity_levels
+ */
+function watchdog_severity_levels() {
+ return array(
+ WATCHDOG_EMERGENCY => t('emergency'),
+ WATCHDOG_ALERT => t('alert'),
+ WATCHDOG_CRITICAL => t('critical'),
+ WATCHDOG_ERROR => t('error'),
+ WATCHDOG_WARNING => t('warning'),
+ WATCHDOG_NOTICE => t('notice'),
+ WATCHDOG_INFO => t('info'),
+ WATCHDOG_DEBUG => t('debug'),
+ );
+}
+
+
+/**
+ * Explode a string of given tags into an array.
+ *
+ * @see drupal_implode_tags()
+ */
+function drupal_explode_tags($tags) {
+ // This regexp allows the following types of user input:
+ // this, "somecompany, llc", "and ""this"" w,o.rks", foo bar
+ $regexp = '%(?:^|,\ *)("(?>[^"]*)(?>""[^"]* )*"|(?: [^",]*))%x';
+ preg_match_all($regexp, $tags, $matches);
+ $typed_tags = array_unique($matches[1]);
+
+ $tags = array();
+ foreach ($typed_tags as $tag) {
+ // If a user has escaped a term (to demonstrate that it is a group,
+ // or includes a comma or quote character), we remove the escape
+ // formatting so to save the term into the database as the user intends.
+ $tag = trim(str_replace('""', '"', preg_replace('/^"(.*)"$/', '\1', $tag)));
+ if ($tag != "") {
+ $tags[] = $tag;
+ }
+ }
+
+ return $tags;
+}
+
+/**
+ * Implode an array of tags into a string.
+ *
+ * @see drupal_explode_tags()
+ */
+function drupal_implode_tags($tags) {
+ $encoded_tags = array();
+ foreach ($tags as $tag) {
+ // Commas and quotes in tag names are special cases, so encode them.
+ if (strpos($tag, ',') !== FALSE || strpos($tag, '"') !== FALSE) {
+ $tag = '"' . str_replace('"', '""', $tag) . '"';
+ }
+
+ $encoded_tags[] = $tag;
+ }
+ return implode(', ', $encoded_tags);
+}
+
+/**
+ * Flush all cached data on the site.
+ *
+ * Empties cache tables, rebuilds the menu cache and theme registries, and
+ * invokes a hook so that other modules' cache data can be cleared as well.
+ */
+function drupal_flush_all_caches() {
+ // Change query-strings on css/js files to enforce reload for all users.
+ _drupal_flush_css_js();
+
+ registry_rebuild();
+ drupal_clear_css_cache();
+ drupal_clear_js_cache();
+
+ // Rebuild the theme data. Note that the module data is rebuilt above, as
+ // part of registry_rebuild().
+ system_rebuild_theme_data();
+ drupal_theme_rebuild();
+
+ node_types_rebuild();
+ // node_menu() defines menu items based on node types so it needs to come
+ // after node types are rebuilt.
+ menu_rebuild();
+
+ // Synchronize to catch any actions that were added or removed.
+ actions_synchronize();
+
+ // Don't clear cache_form - in-progress form submissions may break.
+ // Ordered so clearing the page cache will always be the last action.
+ $core = array('cache', 'path', 'filter', 'bootstrap', 'page');
+ $cache_bins = array_merge(module_invoke_all('flush_caches'), $core);
+ foreach ($cache_bins as $bin) {
+ cache($bin)->flush();
+ }
+
+ // Rebuild the bootstrap module list. We do this here so that developers
+ // can get new hook_boot() implementations registered without having to
+ // write a hook_update_N() function.
+ _system_update_bootstrap_status();
+}
+
+/**
+ * Helper function to change query-strings on css/js files.
+ *
+ * Changes the character added to all css/js files as dummy query-string, so
+ * that all browsers are forced to reload fresh files.
+ */
+function _drupal_flush_css_js() {
+ // The timestamp is converted to base 36 in order to make it more compact.
+ variable_set('css_js_query_string', base_convert(REQUEST_TIME, 10, 36));
+}
+
+/**
+ * Debug function used for outputting debug information.
+ *
+ * The debug information is passed on to trigger_error() after being converted
+ * to a string using _drupal_debug_message().
+ *
+ * @param $data
+ * Data to be output.
+ * @param $label
+ * Label to prefix the data.
+ * @param $print_r
+ * Flag to switch between print_r() and var_export() for data conversion to
+ * string. Set $print_r to TRUE when dealing with a recursive data structure
+ * as var_export() will generate an error.
+ */
+function debug($data, $label = NULL, $print_r = FALSE) {
+ // Print $data contents to string.
+ $string = check_plain($print_r ? print_r($data, TRUE) : var_export($data, TRUE));
+
+ // Display values with pre-formatting to increase readability.
+ $string = '<pre>' . $string . '</pre>';
+
+ trigger_error(trim($label ? "$label: $string" : $string));
+}
+
+/**
+ * Parse a dependency for comparison by drupal_check_incompatibility().
+ *
+ * @param $dependency
+ * A dependency string, for example 'foo (>=8.x-4.5-beta5, 3.x)'.
+ * @return
+ * An associative array with three keys:
+ * - 'name' includes the name of the thing to depend on (e.g. 'foo').
+ * - 'original_version' contains the original version string (which can be
+ * used in the UI for reporting incompatibilities).
+ * - 'versions' is a list of associative arrays, each containing the keys
+ * 'op' and 'version'. 'op' can be one of: '=', '==', '!=', '<>', '<',
+ * '<=', '>', or '>='. 'version' is one piece like '4.5-beta3'.
+ * Callers should pass this structure to drupal_check_incompatibility().
+ *
+ * @see drupal_check_incompatibility()
+ */
+function drupal_parse_dependency($dependency) {
+ // We use named subpatterns and support every op that version_compare
+ // supports. Also, op is optional and defaults to equals.
+ $p_op = '(?P<operation>!=|==|=|<|<=|>|>=|<>)?';
+ // Core version is always optional: 8.x-2.x and 2.x is treated the same.
+ $p_core = '(?:' . preg_quote(DRUPAL_CORE_COMPATIBILITY) . '-)?';
+ $p_major = '(?P<major>\d+)';
+ // By setting the minor version to x, branches can be matched.
+ $p_minor = '(?P<minor>(?:\d+|x)(?:-[A-Za-z]+\d+)?)';
+ $value = array();
+ $parts = explode('(', $dependency, 2);
+ $value['name'] = trim($parts[0]);
+ if (isset($parts[1])) {
+ $value['original_version'] = ' (' . $parts[1];
+ foreach (explode(',', $parts[1]) as $version) {
+ if (preg_match("/^\s*$p_op\s*$p_core$p_major\.$p_minor/", $version, $matches)) {
+ $op = !empty($matches['operation']) ? $matches['operation'] : '=';
+ if ($matches['minor'] == 'x') {
+ // Drupal considers "2.x" to mean any version that begins with
+ // "2" (e.g. 2.0, 2.9 are all "2.x"). PHP's version_compare(),
+ // on the other hand, treats "x" as a string; so to
+ // version_compare(), "2.x" is considered less than 2.0. This
+ // means that >=2.x and <2.x are handled by version_compare()
+ // as we need, but > and <= are not.
+ if ($op == '>' || $op == '<=') {
+ $matches['major']++;
+ }
+ // Equivalence can be checked by adding two restrictions.
+ if ($op == '=' || $op == '==') {
+ $value['versions'][] = array('op' => '<', 'version' => ($matches['major'] + 1) . '.x');
+ $op = '>=';
+ }
+ }
+ $value['versions'][] = array('op' => $op, 'version' => $matches['major'] . '.' . $matches['minor']);
+ }
+ }
+ }
+ return $value;
+}
+
+/**
+ * Check whether a version is compatible with a given dependency.
+ *
+ * @param $v
+ * The parsed dependency structure from drupal_parse_dependency().
+ * @param $current_version
+ * The version to check against (like 4.2).
+ * @return
+ * NULL if compatible, otherwise the original dependency version string that
+ * caused the incompatibility.
+ *
+ * @see drupal_parse_dependency()
+ */
+function drupal_check_incompatibility($v, $current_version) {
+ if (!empty($v['versions'])) {
+ foreach ($v['versions'] as $required_version) {
+ if ((isset($required_version['op']) && !version_compare($current_version, $required_version['version'], $required_version['op']))) {
+ return $v['original_version'];
+ }
+ }
+ }
+}
+
+/**
+ * Performs one or more XML-RPC request(s).
+ *
+ * Usage example:
+ * @code
+ * $result = xmlrpc('http://example.com/xmlrpc.php', array(
+ * 'service.methodName' => array($parameter, $second, $third),
+ * ));
+ * @endcode
+ *
+ * @param $url
+ * An absolute URL of the XML-RPC endpoint.
+ * @param $args
+ * An associative array whose keys are the methods to call and whose values
+ * are the arguments to pass to the respective method. If multiple methods
+ * are specified, a system.multicall is performed.
+ * @param $options
+ * (optional) An array of options to pass along to drupal_http_request().
+ *
+ * @return
+ * For one request:
+ * Either the return value of the method on success, or FALSE.
+ * If FALSE is returned, see xmlrpc_errno() and xmlrpc_error_msg().
+ * For multiple requests:
+ * An array of results. Each result will either be the result
+ * returned by the method called, or an xmlrpc_error object if the call
+ * failed. See xmlrpc_error().
+ */
+function xmlrpc($url, $args, $options = array()) {
+ require_once DRUPAL_ROOT . '/core/includes/xmlrpc.inc';
+ return _xmlrpc($url, $args, $options);
+}
+
+/**
+ * Retrieves a list of all available archivers.
+ *
+ * @see hook_archiver_info()
+ * @see hook_archiver_info_alter()
+ */
+function archiver_get_info() {
+ $archiver_info = &drupal_static(__FUNCTION__, array());
+
+ if (empty($archiver_info)) {
+ $cache = cache()->get('archiver_info');
+ if ($cache === FALSE) {
+ // Rebuild the cache and save it.
+ $archiver_info = module_invoke_all('archiver_info');
+ drupal_alter('archiver_info', $archiver_info);
+ uasort($archiver_info, 'drupal_sort_weight');
+ cache()->set('archiver_info', $archiver_info);
+ }
+ else {
+ $archiver_info = $cache->data;
+ }
+ }
+
+ return $archiver_info;
+}
+
+/**
+ * Returns a string of supported archive extensions.
+ *
+ * @return
+ * A space-separated string of extensions suitable for use by the file
+ * validation system.
+ */
+function archiver_get_extensions() {
+ $valid_extensions = array();
+ foreach (archiver_get_info() as $archive) {
+ foreach ($archive['extensions'] as $extension) {
+ foreach (explode('.', $extension) as $part) {
+ if (!in_array($part, $valid_extensions)) {
+ $valid_extensions[] = $part;
+ }
+ }
+ }
+ }
+ return implode(' ', $valid_extensions);
+}
+
+/**
+ * Create the appropriate archiver for the specified file.
+ *
+ * @param $file
+ * The full path of the archive file. Note that stream wrapper
+ * paths are supported, but not remote ones.
+ * @return
+ * A newly created instance of the archiver class appropriate
+ * for the specified file, already bound to that file.
+ * If no appropriate archiver class was found, will return FALSE.
+ */
+function archiver_get_archiver($file) {
+ // Archivers can only work on local paths
+ $filepath = drupal_realpath($file);
+ if (!is_file($filepath)) {
+ throw new Exception(t('Archivers can only operate on local files: %file not supported', array('%file' => $file)));
+ }
+ $archiver_info = archiver_get_info();
+
+ foreach ($archiver_info as $implementation) {
+ foreach ($implementation['extensions'] as $extension) {
+ // Because extensions may be multi-part, such as .tar.gz,
+ // we cannot use simpler approaches like substr() or pathinfo().
+ // This method isn't quite as clean but gets the job done.
+ // Also note that the file may not yet exist, so we cannot rely
+ // on fileinfo() or other disk-level utilities.
+ if (strrpos($filepath, '.' . $extension) === strlen($filepath) - strlen('.' . $extension)) {
+ return new $implementation['class']($filepath);
+ }
+ }
+ }
+}
+
+/**
+ * Drupal Updater registry.
+ *
+ * An Updater is a class that knows how to update various parts of the Drupal
+ * file system, for example to update modules that have newer releases, or to
+ * install a new theme.
+ *
+ * @return
+ * Returns the Drupal Updater class registry.
+ *
+ * @see hook_updater_info()
+ * @see hook_updater_info_alter()
+ */
+function drupal_get_updaters() {
+ $updaters = &drupal_static(__FUNCTION__);
+ if (!isset($updaters)) {
+ $updaters = module_invoke_all('updater_info');
+ drupal_alter('updater_info', $updaters);
+ uasort($updaters, 'drupal_sort_weight');
+ }
+ return $updaters;
+}
+
+/**
+ * Drupal FileTransfer registry.
+ *
+ * @return
+ * Returns the Drupal FileTransfer class registry.
+ *
+ * @see FileTransfer
+ * @see hook_filetransfer_info()
+ * @see hook_filetransfer_info_alter()
+ */
+function drupal_get_filetransfer_info() {
+ $info = &drupal_static(__FUNCTION__);
+ if (!isset($info)) {
+ // Since we have to manually set the 'file path' default for each
+ // module separately, we can't use module_invoke_all().
+ $info = array();
+ foreach (module_implements('filetransfer_info') as $module) {
+ $function = $module . '_filetransfer_info';
+ if (function_exists($function)) {
+ $result = $function();
+ if (isset($result) && is_array($result)) {
+ foreach ($result as &$values) {
+ if (empty($values['file path'])) {
+ $values['file path'] = drupal_get_path('module', $module);
+ }
+ }
+ $info = array_merge_recursive($info, $result);
+ }
+ }
+ }
+ drupal_alter('filetransfer_info', $info);
+ uasort($info, 'drupal_sort_weight');
+ }
+ return $info;
+}
diff --git a/core/includes/database/database.inc b/core/includes/database/database.inc
new file mode 100644
index 000000000000..77584f90c4a1
--- /dev/null
+++ b/core/includes/database/database.inc
@@ -0,0 +1,3003 @@
+<?php
+
+/**
+ * @file
+ * Core systems for the database layer.
+ *
+ * Classes required for basic functioning of the database system should be
+ * placed in this file. All utility functions should also be placed in this
+ * file only, as they cannot auto-load the way classes can.
+ */
+
+/**
+ * @defgroup database Database abstraction layer
+ * @{
+ * Allow the use of different database servers using the same code base.
+ *
+ * Drupal provides a database abstraction layer to provide developers with
+ * the ability to support multiple database servers easily. The intent of
+ * this layer is to preserve the syntax and power of SQL as much as possible,
+ * but also allow developers a way to leverage more complex functionality in
+ * a unified way. It also provides a structured interface for dynamically
+ * constructing queries when appropriate, and enforcing security checks and
+ * similar good practices.
+ *
+ * The system is built atop PHP's PDO (PHP Data Objects) database API and
+ * inherits much of its syntax and semantics.
+ *
+ * Most Drupal database SELECT queries are performed by a call to db_query() or
+ * db_query_range(). Module authors should also consider using the PagerDefault
+ * Extender for queries that return results that need to be presented on
+ * multiple pages, and the Tablesort Extender for generating appropriate queries
+ * for sortable tables.
+ *
+ * For example, one might wish to return a list of the most recent 10 nodes
+ * authored by a given user. Instead of directly issuing the SQL query
+ * @code
+ * SELECT n.nid, n.title, n.created FROM node n WHERE n.uid = $uid LIMIT 0, 10;
+ * @endcode
+ * one would instead call the Drupal functions:
+ * @code
+ * $result = db_query_range('SELECT n.nid, n.title, n.created
+ * FROM {node} n WHERE n.uid = :uid', 0, 10, array(':uid' => $uid));
+ * foreach ($result as $record) {
+ * // Perform operations on $node->title, etc. here.
+ * }
+ * @endcode
+ * Curly braces are used around "node" to provide table prefixing via
+ * DatabaseConnection::prefixTables(). The explicit use of a user ID is pulled
+ * out into an argument passed to db_query() so that SQL injection attacks
+ * from user input can be caught and nullified. The LIMIT syntax varies between
+ * database servers, so that is abstracted into db_query_range() arguments.
+ * Finally, note the PDO-based ability to iterate over the result set using
+ * foreach ().
+ *
+ * All queries are passed as a prepared statement string. A
+ * prepared statement is a "template" of a query that omits literal or variable
+ * values in favor of placeholders. The values to place into those
+ * placeholders are passed separately, and the database driver handles
+ * inserting the values into the query in a secure fashion. That means you
+ * should never quote or string-escape a value to be inserted into the query.
+ *
+ * There are two formats for placeholders: named and unnamed. Named placeholders
+ * are strongly preferred in all cases as they are more flexible and
+ * self-documenting. Named placeholders should start with a colon ":" and can be
+ * followed by one or more letters, numbers or underscores.
+ *
+ * Named placeholders begin with a colon followed by a unique string. Example:
+ * @code
+ * SELECT nid, title FROM {node} WHERE uid=:uid;
+ * @endcode
+ *
+ * ":uid" is a placeholder that will be replaced with a literal value when
+ * the query is executed. A given placeholder label cannot be repeated in a
+ * given query, even if the value should be the same. When using named
+ * placeholders, the array of arguments to the query must be an associative
+ * array where keys are a placeholder label (e.g., :uid) and the value is the
+ * corresponding value to use. The array may be in any order.
+ *
+ * Unnamed placeholders are simply a question mark. Example:
+ * @code
+ * SELECT nid, title FROM {node} WHERE uid=?;
+ * @endcode
+ *
+ * In this case, the array of arguments must be an indexed array of values to
+ * use in the exact same order as the placeholders in the query.
+ *
+ * Note that placeholders should be a "complete" value. For example, when
+ * running a LIKE query the SQL wildcard character, %, should be part of the
+ * value, not the query itself. Thus, the following is incorrect:
+ * @code
+ * SELECT nid, title FROM {node} WHERE title LIKE :title%;
+ * @endcode
+ * It should instead read:
+ * @code
+ * SELECT nid, title FROM {node} WHERE title LIKE :title;
+ * @endcode
+ * and the value for :title should include a % as appropriate. Again, note the
+ * lack of quotation marks around :title. Because the value is not inserted
+ * into the query as one big string but as an explicitly separate value, the
+ * database server knows where the query ends and a value begins. That is
+ * considerably more secure against SQL injection than trying to remember
+ * which values need quotation marks and string escaping and which don't.
+ *
+ * INSERT, UPDATE, and DELETE queries need special care in order to behave
+ * consistently across all different databases. Therefore, they use a special
+ * object-oriented API for defining a query structurally. For example, rather
+ * than:
+ * @code
+ * INSERT INTO node (nid, title, body) VALUES (1, 'my title', 'my body');
+ * @endcode
+ * one would instead write:
+ * @code
+ * $fields = array('nid' => 1, 'title' => 'my title', 'body' => 'my body');
+ * db_insert('node')->fields($fields)->execute();
+ * @endcode
+ * This method allows databases that need special data type handling to do so,
+ * while also allowing optimizations such as multi-insert queries. UPDATE and
+ * DELETE queries have a similar pattern.
+ *
+ * Drupal also supports transactions, including a transparent fallback for
+ * databases that do not support transactions. To start a new transaction,
+ * simply call $txn = db_transaction(); in your own code. The transaction will
+ * remain open for as long as the variable $txn remains in scope. When $txn is
+ * destroyed, the transaction will be committed. If your transaction is nested
+ * inside of another then Drupal will track each transaction and only commit
+ * the outer-most transaction when the last transaction object goes out out of
+ * scope, that is, all relevant queries completed successfully.
+ *
+ * Example:
+ * @code
+ * function my_transaction_function() {
+ * // The transaction opens here.
+ * $txn = db_transaction();
+ *
+ * try {
+ * $id = db_insert('example')
+ * ->fields(array(
+ * 'field1' => 'mystring',
+ * 'field2' => 5,
+ * ))
+ * ->execute();
+ *
+ * my_other_function($id);
+ *
+ * return $id;
+ * }
+ * catch (Exception $e) {
+ * // Something went wrong somewhere, so roll back now.
+ * $txn->rollback();
+ * // Log the exception to watchdog.
+ * watchdog_exception('type', $e);
+ * }
+ *
+ * // $txn goes out of scope here. Unless the transaction was rolled back, it
+ * // gets automatically committed here.
+ * }
+ *
+ * function my_other_function($id) {
+ * // The transaction is still open here.
+ *
+ * if ($id % 2 == 0) {
+ * db_update('example')
+ * ->condition('id', $id)
+ * ->fields(array('field2' => 10))
+ * ->execute();
+ * }
+ * }
+ * @endcode
+ *
+ * @link http://drupal.org/developing/api/database
+ */
+
+
+/**
+ * Base Database API class.
+ *
+ * This class provides a Drupal-specific extension of the PDO database
+ * abstraction class in PHP. Every database driver implementation must provide a
+ * concrete implementation of it to support special handling required by that
+ * database.
+ *
+ * @see http://php.net/manual/en/book.pdo.php
+ */
+abstract class DatabaseConnection extends PDO {
+
+ /**
+ * The database target this connection is for.
+ *
+ * We need this information for later auditing and logging.
+ *
+ * @var string
+ */
+ protected $target = NULL;
+
+ /**
+ * The key representing this connection.
+ *
+ * The key is a unique string which identifies a database connection. A
+ * connection can be a single server or a cluster of master and slaves (use
+ * target to pick between master and slave).
+ *
+ * @var string
+ */
+ protected $key = NULL;
+
+ /**
+ * The current database logging object for this connection.
+ *
+ * @var DatabaseLog
+ */
+ protected $logger = NULL;
+
+ /**
+ * Tracks the number of "layers" of transactions currently active.
+ *
+ * On many databases transactions cannot nest. Instead, we track
+ * nested calls to transactions and collapse them into a single
+ * transaction.
+ *
+ * @var array
+ */
+ protected $transactionLayers = array();
+
+ /**
+ * Index of what driver-specific class to use for various operations.
+ *
+ * @var array
+ */
+ protected $driverClasses = array();
+
+ /**
+ * The name of the Statement class for this connection.
+ *
+ * @var string
+ */
+ protected $statementClass = 'DatabaseStatementBase';
+
+ /**
+ * Whether this database connection supports transactions.
+ *
+ * @var bool
+ */
+ protected $transactionSupport = TRUE;
+
+ /**
+ * Whether this database connection supports transactional DDL.
+ *
+ * Set to FALSE by default because few databases support this feature.
+ *
+ * @var bool
+ */
+ protected $transactionalDDLSupport = FALSE;
+
+ /**
+ * An index used to generate unique temporary table names.
+ *
+ * @var integer
+ */
+ protected $temporaryNameIndex = 0;
+
+ /**
+ * The connection information for this connection object.
+ *
+ * @var array
+ */
+ protected $connectionOptions = array();
+
+ /**
+ * The schema object for this connection.
+ *
+ * @var object
+ */
+ protected $schema = NULL;
+
+ /**
+ * The prefixes used by this database connection.
+ *
+ * @var array
+ */
+ protected $prefixes = array();
+
+ /**
+ * List of search values for use in prefixTables().
+ *
+ * @var array
+ */
+ protected $prefixSearch = array();
+
+ /**
+ * List of replacement values for use in prefixTables().
+ *
+ * @var array
+ */
+ protected $prefixReplace = array();
+
+ function __construct($dsn, $username, $password, $driver_options = array()) {
+ // Initialize and prepare the connection prefix.
+ $this->setPrefix(isset($this->connectionOptions['prefix']) ? $this->connectionOptions['prefix'] : '');
+
+ // Because the other methods don't seem to work right.
+ $driver_options[PDO::ATTR_ERRMODE] = PDO::ERRMODE_EXCEPTION;
+
+ // Call PDO::__construct and PDO::setAttribute.
+ parent::__construct($dsn, $username, $password, $driver_options);
+
+ // Set a specific PDOStatement class if the driver requires that.
+ if (!empty($this->statementClass)) {
+ $this->setAttribute(PDO::ATTR_STATEMENT_CLASS, array($this->statementClass, array($this)));
+ }
+ }
+
+ /**
+ * Returns the default query options for any given query.
+ *
+ * A given query can be customized with a number of option flags in an
+ * associative array:
+ * - target: The database "target" against which to execute a query. Valid
+ * values are "default" or "slave". The system will first try to open a
+ * connection to a database specified with the user-supplied key. If one
+ * is not available, it will silently fall back to the "default" target.
+ * If multiple databases connections are specified with the same target,
+ * one will be selected at random for the duration of the request.
+ * - fetch: This element controls how rows from a result set will be
+ * returned. Legal values include PDO::FETCH_ASSOC, PDO::FETCH_BOTH,
+ * PDO::FETCH_OBJ, PDO::FETCH_NUM, or a string representing the name of a
+ * class. If a string is specified, each record will be fetched into a new
+ * object of that class. The behavior of all other values is defined by PDO.
+ * See http://php.net/manual/pdostatement.fetch.php
+ * - return: Depending on the type of query, different return values may be
+ * meaningful. This directive instructs the system which type of return
+ * value is desired. The system will generally set the correct value
+ * automatically, so it is extremely rare that a module developer will ever
+ * need to specify this value. Setting it incorrectly will likely lead to
+ * unpredictable results or fatal errors. Legal values include:
+ * - Database::RETURN_STATEMENT: Return the prepared statement object for
+ * the query. This is usually only meaningful for SELECT queries, where
+ * the statement object is how one accesses the result set returned by the
+ * query.
+ * - Database::RETURN_AFFECTED: Return the number of rows affected by an
+ * UPDATE or DELETE query. Be aware that means the number of rows actually
+ * changed, not the number of rows matched by the WHERE clause.
+ * - Database::RETURN_INSERT_ID: Return the sequence ID (primary key)
+ * created by an INSERT statement on a table that contains a serial
+ * column.
+ * - Database::RETURN_NULL: Do not return anything, as there is no
+ * meaningful value to return. That is the case for INSERT queries on
+ * tables that do not contain a serial column.
+ * - throw_exception: By default, the database system will catch any errors
+ * on a query as an Exception, log it, and then rethrow it so that code
+ * further up the call chain can take an appropriate action. To suppress
+ * that behavior and simply return NULL on failure, set this option to
+ * FALSE.
+ *
+ * @return
+ * An array of default query options.
+ */
+ protected function defaultOptions() {
+ return array(
+ 'target' => 'default',
+ 'fetch' => PDO::FETCH_OBJ,
+ 'return' => Database::RETURN_STATEMENT,
+ 'throw_exception' => TRUE,
+ );
+ }
+
+ /**
+ * Returns the connection information for this connection object.
+ *
+ * Note that Database::getConnectionInfo() is for requesting information
+ * about an arbitrary database connection that is defined. This method
+ * is for requesting the connection information of this specific
+ * open connection object.
+ *
+ * @return
+ * An array of the connection information. The exact list of
+ * properties is driver-dependent.
+ */
+ public function getConnectionOptions() {
+ return $this->connectionOptions;
+ }
+
+ /**
+ * Set the list of prefixes used by this database connection.
+ *
+ * @param $prefix
+ * The prefixes, in any of the multiple forms documented in
+ * default.settings.php.
+ */
+ protected function setPrefix($prefix) {
+ if (is_array($prefix)) {
+ $this->prefixes = $prefix + array('default' => '');
+ }
+ else {
+ $this->prefixes = array('default' => $prefix);
+ }
+
+ // Set up variables for use in prefixTables(). Replace table-specific
+ // prefixes first.
+ $this->prefixSearch = array();
+ $this->prefixReplace = array();
+ foreach ($this->prefixes as $key => $val) {
+ if ($key != 'default') {
+ $this->prefixSearch[] = '{' . $key . '}';
+ $this->prefixReplace[] = $val . $key;
+ }
+ }
+ // Then replace remaining tables with the default prefix.
+ $this->prefixSearch[] = '{';
+ $this->prefixReplace[] = $this->prefixes['default'];
+ $this->prefixSearch[] = '}';
+ $this->prefixReplace[] = '';
+ }
+
+ /**
+ * Appends a database prefix to all tables in a query.
+ *
+ * Queries sent to Drupal should wrap all table names in curly brackets. This
+ * function searches for this syntax and adds Drupal's table prefix to all
+ * tables, allowing Drupal to coexist with other systems in the same database
+ * and/or schema if necessary.
+ *
+ * @param $sql
+ * A string containing a partial or entire SQL query.
+ *
+ * @return
+ * The properly-prefixed string.
+ */
+ public function prefixTables($sql) {
+ return str_replace($this->prefixSearch, $this->prefixReplace, $sql);
+ }
+
+ /**
+ * Find the prefix for a table.
+ *
+ * This function is for when you want to know the prefix of a table. This
+ * is not used in prefixTables due to performance reasons.
+ */
+ public function tablePrefix($table = 'default') {
+ if (isset($this->prefixes[$table])) {
+ return $this->prefixes[$table];
+ }
+ else {
+ return $this->prefixes['default'];
+ }
+ }
+
+ /**
+ * Prepares a query string and returns the prepared statement.
+ *
+ * This method caches prepared statements, reusing them when
+ * possible. It also prefixes tables names enclosed in curly-braces.
+ *
+ * @param $query
+ * The query string as SQL, with curly-braces surrounding the
+ * table names.
+ *
+ * @return DatabaseStatementInterface
+ * A PDO prepared statement ready for its execute() method.
+ */
+ public function prepareQuery($query) {
+ $query = $this->prefixTables($query);
+
+ // Call PDO::prepare.
+ return parent::prepare($query);
+ }
+
+ /**
+ * Tells this connection object what its target value is.
+ *
+ * This is needed for logging and auditing. It's sloppy to do in the
+ * constructor because the constructor for child classes has a different
+ * signature. We therefore also ensure that this function is only ever
+ * called once.
+ *
+ * @param $target
+ * The target this connection is for. Set to NULL (default) to disable
+ * logging entirely.
+ */
+ public function setTarget($target = NULL) {
+ if (!isset($this->target)) {
+ $this->target = $target;
+ }
+ }
+
+ /**
+ * Returns the target this connection is associated with.
+ *
+ * @return
+ * The target string of this connection.
+ */
+ public function getTarget() {
+ return $this->target;
+ }
+
+ /**
+ * Tells this connection object what its key is.
+ *
+ * @param $target
+ * The key this connection is for.
+ */
+ public function setKey($key) {
+ if (!isset($this->key)) {
+ $this->key = $key;
+ }
+ }
+
+ /**
+ * Returns the key this connection is associated with.
+ *
+ * @return
+ * The key of this connection.
+ */
+ public function getKey() {
+ return $this->key;
+ }
+
+ /**
+ * Associates a logging object with this connection.
+ *
+ * @param $logger
+ * The logging object we want to use.
+ */
+ public function setLogger(DatabaseLog $logger) {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Gets the current logging object for this connection.
+ *
+ * @return DatabaseLog
+ * The current logging object for this connection. If there isn't one,
+ * NULL is returned.
+ */
+ public function getLogger() {
+ return $this->logger;
+ }
+
+ /**
+ * Creates the appropriate sequence name for a given table and serial field.
+ *
+ * This information is exposed to all database drivers, although it is only
+ * useful on some of them. This method is table prefix-aware.
+ *
+ * @param $table
+ * The table name to use for the sequence.
+ * @param $field
+ * The field name to use for the sequence.
+ *
+ * @return
+ * A table prefix-parsed string for the sequence name.
+ */
+ public function makeSequenceName($table, $field) {
+ return $this->prefixTables('{' . $table . '}_' . $field . '_seq');
+ }
+
+ /**
+ * Flatten an array of query comments into a single comment string.
+ *
+ * The comment string will be sanitized to avoid SQL injection attacks.
+ *
+ * @param $comments
+ * An array of query comment strings.
+ *
+ * @return
+ * A sanitized comment string.
+ */
+ public function makeComment($comments) {
+ if (empty($comments))
+ return '';
+
+ // Flatten the array of comments.
+ $comment = implode('; ', $comments);
+
+ // Sanitize the comment string so as to avoid SQL injection attacks.
+ return '/* ' . $this->filterComment($comment) . ' */ ';
+ }
+
+ /**
+ * Sanitize a query comment string.
+ *
+ * Ensure a query comment does not include strings such as "* /" that might
+ * terminate the comment early. This avoids SQL injection attacks via the
+ * query comment. The comment strings in this example are separated by a
+ * space to avoid PHP parse errors.
+ *
+ * For example, the comment:
+ * @code
+ * db_update('example')
+ * ->condition('id', $id)
+ * ->fields(array('field2' => 10))
+ * ->comment('Exploit * / DROP TABLE node; --')
+ * ->execute()
+ * @endcode
+ *
+ * Would result in the following SQL statement being generated:
+ * @code
+ * "/ * Exploit * / DROP TABLE node; -- * / UPDATE example SET field2=..."
+ * @endcode
+ *
+ * Unless the comment is sanitised first, the SQL server would drop the
+ * node table and ignore the rest of the SQL statement.
+ *
+ * @param $comment
+ * A query comment string.
+ *
+ * @return
+ * A sanitized version of the query comment string.
+ */
+ protected function filterComment($comment = '') {
+ return preg_replace('/(\/\*\s*)|(\s*\*\/)/', '', $comment);
+ }
+
+ /**
+ * Executes a query string against the database.
+ *
+ * This method provides a central handler for the actual execution of every
+ * query. All queries executed by Drupal are executed as PDO prepared
+ * statements.
+ *
+ * @param $query
+ * The query to execute. In most cases this will be a string containing
+ * an SQL query with placeholders. An already-prepared instance of
+ * DatabaseStatementInterface may also be passed in order to allow calling
+ * code to manually bind variables to a query. If a
+ * DatabaseStatementInterface is passed, the $args array will be ignored.
+ * It is extremely rare that module code will need to pass a statement
+ * object to this method. It is used primarily for database drivers for
+ * databases that require special LOB field handling.
+ * @param $args
+ * An array of arguments for the prepared statement. If the prepared
+ * statement uses ? placeholders, this array must be an indexed array.
+ * If it contains named placeholders, it must be an associative array.
+ * @param $options
+ * An associative array of options to control how the query is run. See
+ * the documentation for DatabaseConnection::defaultOptions() for details.
+ *
+ * @return DatabaseStatementInterface
+ * This method will return one of: the executed statement, the number of
+ * rows affected by the query (not the number matched), or the generated
+ * insert IT of the last query, depending on the value of
+ * $options['return']. Typically that value will be set by default or a
+ * query builder and should not be set by a user. If there is an error,
+ * this method will return NULL and may throw an exception if
+ * $options['throw_exception'] is TRUE.
+ *
+ * @throws PDOException
+ */
+ public function query($query, array $args = array(), $options = array()) {
+
+ // Use default values if not already set.
+ $options += $this->defaultOptions();
+
+ try {
+ // We allow either a pre-bound statement object or a literal string.
+ // In either case, we want to end up with an executed statement object,
+ // which we pass to PDOStatement::execute.
+ if ($query instanceof DatabaseStatementInterface) {
+ $stmt = $query;
+ $stmt->execute(NULL, $options);
+ }
+ else {
+ $this->expandArguments($query, $args);
+ $stmt = $this->prepareQuery($query);
+ $stmt->execute($args, $options);
+ }
+
+ // Depending on the type of query we may need to return a different value.
+ // See DatabaseConnection::defaultOptions() for a description of each
+ // value.
+ switch ($options['return']) {
+ case Database::RETURN_STATEMENT:
+ return $stmt;
+ case Database::RETURN_AFFECTED:
+ return $stmt->rowCount();
+ case Database::RETURN_INSERT_ID:
+ return $this->lastInsertId();
+ case Database::RETURN_NULL:
+ return;
+ default:
+ throw new PDOException('Invalid return directive: ' . $options['return']);
+ }
+ }
+ catch (PDOException $e) {
+ if ($options['throw_exception']) {
+ // Add additional debug information.
+ if ($query instanceof DatabaseStatementInterface) {
+ $e->query_string = $stmt->getQueryString();
+ }
+ else {
+ $e->query_string = $query;
+ }
+ $e->args = $args;
+ throw $e;
+ }
+ return NULL;
+ }
+ }
+
+ /**
+ * Expands out shorthand placeholders.
+ *
+ * Drupal supports an alternate syntax for doing arrays of values. We
+ * therefore need to expand them out into a full, executable query string.
+ *
+ * @param $query
+ * The query string to modify.
+ * @param $args
+ * The arguments for the query.
+ *
+ * @return
+ * TRUE if the query was modified, FALSE otherwise.
+ */
+ protected function expandArguments(&$query, &$args) {
+ $modified = FALSE;
+
+ // If the placeholder value to insert is an array, assume that we need
+ // to expand it out into a comma-delimited set of placeholders.
+ foreach (array_filter($args, 'is_array') as $key => $data) {
+ $new_keys = array();
+ foreach ($data as $i => $value) {
+ // This assumes that there are no other placeholders that use the same
+ // name. For example, if the array placeholder is defined as :example
+ // and there is already an :example_2 placeholder, this will generate
+ // a duplicate key. We do not account for that as the calling code
+ // is already broken if that happens.
+ $new_keys[$key . '_' . $i] = $value;
+ }
+
+ // Update the query with the new placeholders.
+ // preg_replace is necessary to ensure the replacement does not affect
+ // placeholders that start with the same exact text. For example, if the
+ // query contains the placeholders :foo and :foobar, and :foo has an
+ // array of values, using str_replace would affect both placeholders,
+ // but using the following preg_replace would only affect :foo because
+ // it is followed by a non-word character.
+ $query = preg_replace('#' . $key . '\b#', implode(', ', array_keys($new_keys)), $query);
+
+ // Update the args array with the new placeholders.
+ unset($args[$key]);
+ $args += $new_keys;
+
+ $modified = TRUE;
+ }
+
+ return $modified;
+ }
+
+ /**
+ * Gets the driver-specific override class if any for the specified class.
+ *
+ * @param string $class
+ * The class for which we want the potentially driver-specific class.
+ * @param array $files
+ * The name of the files in which the driver-specific class can be.
+ * @param $use_autoload
+ * If TRUE, attempt to load classes using PHP's autoload capability
+ * as well as the manual approach here.
+ * @return string
+ * The name of the class that should be used for this driver.
+ */
+ public function getDriverClass($class, array $files = array(), $use_autoload = FALSE) {
+ if (empty($this->driverClasses[$class])) {
+ $driver = $this->driver();
+ $this->driverClasses[$class] = $class . '_' . $driver;
+ Database::loadDriverFile($driver, $files);
+ if (!class_exists($this->driverClasses[$class], $use_autoload)) {
+ $this->driverClasses[$class] = $class;
+ }
+ }
+ return $this->driverClasses[$class];
+ }
+
+ /**
+ * Prepares and returns a SELECT query object.
+ *
+ * @param $table
+ * The base table for this query, that is, the first table in the FROM
+ * clause. This table will also be used as the "base" table for query_alter
+ * hook implementations.
+ * @param $alias
+ * The alias of the base table of this query.
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return SelectQueryInterface
+ * An appropriate SelectQuery object for this database connection. Note that
+ * it may be a driver-specific subclass of SelectQuery, depending on the
+ * driver.
+ *
+ * @see SelectQuery
+ */
+ public function select($table, $alias = NULL, array $options = array()) {
+ $class = $this->getDriverClass('SelectQuery', array('query.inc', 'select.inc'));
+ return new $class($table, $alias, $this, $options);
+ }
+
+ /**
+ * Prepares and returns an INSERT query object.
+ *
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return InsertQuery
+ * A new InsertQuery object.
+ *
+ * @see InsertQuery
+ */
+ public function insert($table, array $options = array()) {
+ $class = $this->getDriverClass('InsertQuery', array('query.inc'));
+ return new $class($this, $table, $options);
+ }
+
+ /**
+ * Prepares and returns a MERGE query object.
+ *
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return MergeQuery
+ * A new MergeQuery object.
+ *
+ * @see MergeQuery
+ */
+ public function merge($table, array $options = array()) {
+ $class = $this->getDriverClass('MergeQuery', array('query.inc'));
+ return new $class($this, $table, $options);
+ }
+
+
+ /**
+ * Prepares and returns an UPDATE query object.
+ *
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return UpdateQuery
+ * A new UpdateQuery object.
+ *
+ * @see UpdateQuery
+ */
+ public function update($table, array $options = array()) {
+ $class = $this->getDriverClass('UpdateQuery', array('query.inc'));
+ return new $class($this, $table, $options);
+ }
+
+ /**
+ * Prepares and returns a DELETE query object.
+ *
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return DeleteQuery
+ * A new DeleteQuery object.
+ *
+ * @see DeleteQuery
+ */
+ public function delete($table, array $options = array()) {
+ $class = $this->getDriverClass('DeleteQuery', array('query.inc'));
+ return new $class($this, $table, $options);
+ }
+
+ /**
+ * Prepares and returns a TRUNCATE query object.
+ *
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return TruncateQuery
+ * A new TruncateQuery object.
+ *
+ * @see TruncateQuery
+ */
+ public function truncate($table, array $options = array()) {
+ $class = $this->getDriverClass('TruncateQuery', array('query.inc'));
+ return new $class($this, $table, $options);
+ }
+
+ /**
+ * Returns a DatabaseSchema object for manipulating the schema.
+ *
+ * This method will lazy-load the appropriate schema library file.
+ *
+ * @return DatabaseSchema
+ * The DatabaseSchema object for this connection.
+ */
+ public function schema() {
+ if (empty($this->schema)) {
+ $class = $this->getDriverClass('DatabaseSchema', array('schema.inc'));
+ if (class_exists($class)) {
+ $this->schema = new $class($this);
+ }
+ }
+ return $this->schema;
+ }
+
+ /**
+ * Escapes a table name string.
+ *
+ * Force all table names to be strictly alphanumeric-plus-underscore.
+ * For some database drivers, it may also wrap the table name in
+ * database-specific escape characters.
+ *
+ * @return
+ * The sanitized table name string.
+ */
+ public function escapeTable($table) {
+ return preg_replace('/[^A-Za-z0-9_.]+/', '', $table);
+ }
+
+ /**
+ * Escapes a field name string.
+ *
+ * Force all field names to be strictly alphanumeric-plus-underscore.
+ * For some database drivers, it may also wrap the field name in
+ * database-specific escape characters.
+ *
+ * @return
+ * The sanitized field name string.
+ */
+ public function escapeField($field) {
+ return preg_replace('/[^A-Za-z0-9_.]+/', '', $field);
+ }
+
+ /**
+ * Escapes an alias name string.
+ *
+ * Force all alias names to be strictly alphanumeric-plus-underscore. In
+ * contrast to DatabaseConnection::escapeField() /
+ * DatabaseConnection::escapeTable(), this doesn't allow the period (".")
+ * because that is not allowed in aliases.
+ *
+ * @return
+ * The sanitized field name string.
+ */
+ public function escapeAlias($field) {
+ return preg_replace('/[^A-Za-z0-9_]+/', '', $field);
+ }
+
+ /**
+ * Escapes characters that work as wildcard characters in a LIKE pattern.
+ *
+ * The wildcard characters "%" and "_" as well as backslash are prefixed with
+ * a backslash. Use this to do a search for a verbatim string without any
+ * wildcard behavior.
+ *
+ * For example, the following does a case-insensitive query for all rows whose
+ * name starts with $prefix:
+ * @code
+ * $result = db_query(
+ * 'SELECT * FROM person WHERE name LIKE :pattern',
+ * array(':pattern' => db_like($prefix) . '%')
+ * );
+ * @endcode
+ *
+ * Backslash is defined as escape character for LIKE patterns in
+ * DatabaseCondition::mapConditionOperator().
+ *
+ * @param $string
+ * The string to escape.
+ *
+ * @return
+ * The escaped string.
+ */
+ public function escapeLike($string) {
+ return addcslashes($string, '\%_');
+ }
+
+ /**
+ * Determines if there is an active transaction open.
+ *
+ * @return
+ * TRUE if we're currently in a transaction, FALSE otherwise.
+ */
+ public function inTransaction() {
+ return ($this->transactionDepth() > 0);
+ }
+
+ /**
+ * Determines current transaction depth.
+ */
+ public function transactionDepth() {
+ return count($this->transactionLayers);
+ }
+
+ /**
+ * Returns a new DatabaseTransaction object on this connection.
+ *
+ * @param $name
+ * Optional name of the savepoint.
+ *
+ * @see DatabaseTransaction
+ */
+ public function startTransaction($name = '') {
+ $class = $this->getDriverClass('DatabaseTransaction');
+ return new $class($this, $name);
+ }
+
+ /**
+ * Rolls back the transaction entirely or to a named savepoint.
+ *
+ * This method throws an exception if no transaction is active.
+ *
+ * @param $savepoint_name
+ * The name of the savepoint. The default, 'drupal_transaction', will roll
+ * the entire transaction back.
+ *
+ * @throws DatabaseTransactionNoActiveException
+ *
+ * @see DatabaseTransaction::rollback()
+ */
+ public function rollback($savepoint_name = 'drupal_transaction') {
+ if (!$this->supportsTransactions()) {
+ return;
+ }
+ if (!$this->inTransaction()) {
+ throw new DatabaseTransactionNoActiveException();
+ }
+ // A previous rollback to an earlier savepoint may mean that the savepoint
+ // in question has already been rolled back.
+ if (!in_array($savepoint_name, $this->transactionLayers)) {
+ return;
+ }
+
+ // We need to find the point we're rolling back to, all other savepoints
+ // before are no longer needed. If we rolled back other active savepoints,
+ // we need to throw an exception.
+ $rolled_back_other_active_savepoints = FALSE;
+ while ($savepoint = array_pop($this->transactionLayers)) {
+ if ($savepoint == $savepoint_name) {
+ // If it is the last the transaction in the stack, then it is not a
+ // savepoint, it is the transaction itself so we will need to roll back
+ // the transaction rather than a savepoint.
+ if (empty($this->transactionLayers)) {
+ break;
+ }
+ $this->query('ROLLBACK TO SAVEPOINT ' . $savepoint);
+ $this->popCommittableTransactions();
+ if ($rolled_back_other_active_savepoints) {
+ throw new DatabaseTransactionOutOfOrderException();
+ }
+ return;
+ }
+ else {
+ $rolled_back_other_active_savepoints = TRUE;
+ }
+ }
+ parent::rollBack();
+ if ($rolled_back_other_active_savepoints) {
+ throw new DatabaseTransactionOutOfOrderException();
+ }
+ }
+
+ /**
+ * Increases the depth of transaction nesting.
+ *
+ * If no transaction is already active, we begin a new transaction.
+ *
+ * @throws DatabaseTransactionNameNonUniqueException
+ *
+ * @see DatabaseTransaction
+ */
+ public function pushTransaction($name) {
+ if (!$this->supportsTransactions()) {
+ return;
+ }
+ if (isset($this->transactionLayers[$name])) {
+ throw new DatabaseTransactionNameNonUniqueException($name . " is already in use.");
+ }
+ // If we're already in a transaction then we want to create a savepoint
+ // rather than try to create another transaction.
+ if ($this->inTransaction()) {
+ $this->query('SAVEPOINT ' . $name);
+ }
+ else {
+ parent::beginTransaction();
+ }
+ $this->transactionLayers[$name] = $name;
+ }
+
+ /**
+ * Decreases the depth of transaction nesting.
+ *
+ * If we pop off the last transaction layer, then we either commit or roll
+ * back the transaction as necessary. If no transaction is active, we return
+ * because the transaction may have manually been rolled back.
+ *
+ * @param $name
+ * The name of the savepoint
+ *
+ * @throws DatabaseTransactionNoActiveException
+ * @throws DatabaseTransactionCommitFailedException
+ *
+ * @see DatabaseTransaction
+ */
+ public function popTransaction($name) {
+ if (!$this->supportsTransactions()) {
+ return;
+ }
+ if (!isset($this->transactionLayers[$name])) {
+ throw new DatabaseTransactionNoActiveException();
+ }
+
+ // Mark this layer as committable.
+ $this->transactionLayers[$name] = FALSE;
+ $this->popCommittableTransactions();
+ }
+
+ /**
+ * Internal function: commit all the transaction layers that can commit.
+ */
+ protected function popCommittableTransactions() {
+ // Commit all the committable layers.
+ foreach (array_reverse($this->transactionLayers) as $name => $active) {
+ // Stop once we found an active transaction.
+ if ($active) {
+ break;
+ }
+
+ // If there are no more layers left then we should commit.
+ unset($this->transactionLayers[$name]);
+ if (empty($this->transactionLayers)) {
+ if (!parent::commit()) {
+ throw new DatabaseTransactionCommitFailedException();
+ }
+ }
+ else {
+ $this->query('RELEASE SAVEPOINT ' . $name);
+ }
+ }
+ }
+
+ /**
+ * Runs a limited-range query on this database object.
+ *
+ * Use this as a substitute for ->query() when a subset of the query is to be
+ * returned. User-supplied arguments to the query should be passed in as
+ * separate parameters so that they can be properly escaped to avoid SQL
+ * injection attacks.
+ *
+ * @param $query
+ * A string containing an SQL query.
+ * @param $args
+ * An array of values to substitute into the query at placeholder markers.
+ * @param $from
+ * The first result row to return.
+ * @param $count
+ * The maximum number of result rows to return.
+ * @param $options
+ * An array of options on the query.
+ *
+ * @return DatabaseStatementInterface
+ * A database query result resource, or NULL if the query was not executed
+ * correctly.
+ */
+ abstract public function queryRange($query, $from, $count, array $args = array(), array $options = array());
+
+ /**
+ * Generates a temporary table name.
+ *
+ * @return
+ * A table name.
+ */
+ protected function generateTemporaryTableName() {
+ return "db_temporary_" . $this->temporaryNameIndex++;
+ }
+
+ /**
+ * Runs a SELECT query and stores its results in a temporary table.
+ *
+ * Use this as a substitute for ->query() when the results need to stored
+ * in a temporary table. Temporary tables exist for the duration of the page
+ * request. User-supplied arguments to the query should be passed in as
+ * separate parameters so that they can be properly escaped to avoid SQL
+ * injection attacks.
+ *
+ * Note that if you need to know how many results were returned, you should do
+ * a SELECT COUNT(*) on the temporary table afterwards.
+ *
+ * @param $query
+ * A string containing a normal SELECT SQL query.
+ * @param $args
+ * An array of values to substitute into the query at placeholder markers.
+ * @param $options
+ * An associative array of options to control how the query is run. See
+ * the documentation for DatabaseConnection::defaultOptions() for details.
+ *
+ * @return
+ * The name of the temporary table.
+ */
+ abstract function queryTemporary($query, array $args = array(), array $options = array());
+
+ /**
+ * Returns the type of database driver.
+ *
+ * This is not necessarily the same as the type of the database itself. For
+ * instance, there could be two MySQL drivers, mysql and mysql_mock. This
+ * function would return different values for each, but both would return
+ * "mysql" for databaseType().
+ */
+ abstract public function driver();
+
+ /**
+ * Returns the version of the database server.
+ */
+ public function version() {
+ return $this->getAttribute(PDO::ATTR_SERVER_VERSION);
+ }
+
+ /**
+ * Determines if this driver supports transactions.
+ *
+ * @return
+ * TRUE if this connection supports transactions, FALSE otherwise.
+ */
+ public function supportsTransactions() {
+ return $this->transactionSupport;
+ }
+
+ /**
+ * Determines if this driver supports transactional DDL.
+ *
+ * DDL queries are those that change the schema, such as ALTER queries.
+ *
+ * @return
+ * TRUE if this connection supports transactions for DDL queries, FALSE
+ * otherwise.
+ */
+ public function supportsTransactionalDDL() {
+ return $this->transactionalDDLSupport;
+ }
+
+ /**
+ * Returns the name of the PDO driver for this connection.
+ */
+ abstract public function databaseType();
+
+
+ /**
+ * Gets any special processing requirements for the condition operator.
+ *
+ * Some condition types require special processing, such as IN, because
+ * the value data they pass in is not a simple value. This is a simple
+ * overridable lookup function. Database connections should define only
+ * those operators they wish to be handled differently than the default.
+ *
+ * @param $operator
+ * The condition operator, such as "IN", "BETWEEN", etc. Case-sensitive.
+ *
+ * @return
+ * The extra handling directives for the specified operator, or NULL.
+ *
+ * @see DatabaseCondition::compile()
+ */
+ abstract public function mapConditionOperator($operator);
+
+ /**
+ * Throws an exception to deny direct access to transaction commits.
+ *
+ * We do not want to allow users to commit transactions at any time, only
+ * by destroying the transaction object or allowing it to go out of scope.
+ * A direct commit bypasses all of the safety checks we've built on top of
+ * PDO's transaction routines.
+ *
+ * @throws DatabaseTransactionExplicitCommitNotAllowedException
+ *
+ * @see DatabaseTransaction
+ */
+ public function commit() {
+ throw new DatabaseTransactionExplicitCommitNotAllowedException();
+ }
+
+ /**
+ * Retrieves an unique id from a given sequence.
+ *
+ * Use this function if for some reason you can't use a serial field. For
+ * example, MySQL has no ways of reading of the current value of a sequence
+ * and PostgreSQL can not advance the sequence to be larger than a given
+ * value. Or sometimes you just need a unique integer.
+ *
+ * @param $existing_id
+ * After a database import, it might be that the sequences table is behind,
+ * so by passing in the maximum existing id, it can be assured that we
+ * never issue the same id.
+ *
+ * @return
+ * An integer number larger than any number returned by earlier calls and
+ * also larger than the $existing_id if one was passed in.
+ */
+ abstract public function nextId($existing_id = 0);
+}
+
+/**
+ * Primary front-controller for the database system.
+ *
+ * This class is uninstantiatable and un-extendable. It acts to encapsulate
+ * all control and shepherding of database connections into a single location
+ * without the use of globals.
+ */
+abstract class Database {
+
+ /**
+ * Flag to indicate a query call should simply return NULL.
+ *
+ * This is used for queries that have no reasonable return value anyway, such
+ * as INSERT statements to a table without a serial primary key.
+ */
+ const RETURN_NULL = 0;
+
+ /**
+ * Flag to indicate a query call should return the prepared statement.
+ */
+ const RETURN_STATEMENT = 1;
+
+ /**
+ * Flag to indicate a query call should return the number of affected rows.
+ */
+ const RETURN_AFFECTED = 2;
+
+ /**
+ * Flag to indicate a query call should return the "last insert id".
+ */
+ const RETURN_INSERT_ID = 3;
+
+ /**
+ * An nested array of all active connections. It is keyed by database name
+ * and target.
+ *
+ * @var array
+ */
+ static protected $connections = array();
+
+ /**
+ * A processed copy of the database connection information from settings.php.
+ *
+ * @var array
+ */
+ static protected $databaseInfo = NULL;
+
+ /**
+ * A list of key/target credentials to simply ignore.
+ *
+ * @var array
+ */
+ static protected $ignoreTargets = array();
+
+ /**
+ * The key of the currently active database connection.
+ *
+ * @var string
+ */
+ static protected $activeKey = 'default';
+
+ /**
+ * An array of active query log objects.
+ *
+ * Every connection has one and only one logger object for all targets and
+ * logging keys.
+ *
+ * array(
+ * '$db_key' => DatabaseLog object.
+ * );
+ *
+ * @var array
+ */
+ static protected $logs = array();
+
+ /**
+ * Starts logging a given logging key on the specified connection.
+ *
+ * @param $logging_key
+ * The logging key to log.
+ * @param $key
+ * The database connection key for which we want to log.
+ *
+ * @return DatabaseLog
+ * The query log object. Note that the log object does support richer
+ * methods than the few exposed through the Database class, so in some
+ * cases it may be desirable to access it directly.
+ *
+ * @see DatabaseLog
+ */
+ final public static function startLog($logging_key, $key = 'default') {
+ if (empty(self::$logs[$key])) {
+ self::$logs[$key] = new DatabaseLog($key);
+
+ // Every target already active for this connection key needs to have the
+ // logging object associated with it.
+ if (!empty(self::$connections[$key])) {
+ foreach (self::$connections[$key] as $connection) {
+ $connection->setLogger(self::$logs[$key]);
+ }
+ }
+ }
+
+ self::$logs[$key]->start($logging_key);
+ return self::$logs[$key];
+ }
+
+ /**
+ * Retrieves the queries logged on for given logging key.
+ *
+ * This method also ends logging for the specified key. To get the query log
+ * to date without ending the logger request the logging object by starting
+ * it again (which does nothing to an open log key) and call methods on it as
+ * desired.
+ *
+ * @param $logging_key
+ * The logging key to log.
+ * @param $key
+ * The database connection key for which we want to log.
+ *
+ * @return array
+ * The query log for the specified logging key and connection.
+ *
+ * @see DatabaseLog
+ */
+ final public static function getLog($logging_key, $key = 'default') {
+ if (empty(self::$logs[$key])) {
+ return NULL;
+ }
+ $queries = self::$logs[$key]->get($logging_key);
+ self::$logs[$key]->end($logging_key);
+ return $queries;
+ }
+
+ /**
+ * Gets the connection object for the specified database key and target.
+ *
+ * @param $target
+ * The database target name.
+ * @param $key
+ * The database connection key. Defaults to NULL which means the active key.
+ *
+ * @return DatabaseConnection
+ * The corresponding connection object.
+ */
+ final public static function getConnection($target = 'default', $key = NULL) {
+ if (!isset($key)) {
+ // By default, we want the active connection, set in setActiveConnection.
+ $key = self::$activeKey;
+ }
+ // If the requested target does not exist, or if it is ignored, we fall back
+ // to the default target. The target is typically either "default" or
+ // "slave", indicating to use a slave SQL server if one is available. If
+ // it's not available, then the default/master server is the correct server
+ // to use.
+ if (!empty(self::$ignoreTargets[$key][$target]) || !isset(self::$databaseInfo[$key][$target])) {
+ $target = 'default';
+ }
+
+ if (!isset(self::$connections[$key][$target])) {
+ // If necessary, a new connection is opened.
+ self::$connections[$key][$target] = self::openConnection($key, $target);
+ }
+ return self::$connections[$key][$target];
+ }
+
+ /**
+ * Determines if there is an active connection.
+ *
+ * Note that this method will return FALSE if no connection has been
+ * established yet, even if one could be.
+ *
+ * @return
+ * TRUE if there is at least one database connection established, FALSE
+ * otherwise.
+ */
+ final public static function isActiveConnection() {
+ return !empty(self::$activeKey) && !empty(self::$connections) && !empty(self::$connections[self::$activeKey]);
+ }
+
+ /**
+ * Sets the active connection to the specified key.
+ *
+ * @return
+ * The previous database connection key.
+ */
+ final public static function setActiveConnection($key = 'default') {
+ if (empty(self::$databaseInfo)) {
+ self::parseConnectionInfo();
+ }
+
+ if (!empty(self::$databaseInfo[$key])) {
+ $old_key = self::$activeKey;
+ self::$activeKey = $key;
+ return $old_key;
+ }
+ }
+
+ /**
+ * Process the configuration file for database information.
+ */
+ final public static function parseConnectionInfo() {
+ global $databases;
+
+ $database_info = is_array($databases) ? $databases : array();
+ foreach ($database_info as $index => $info) {
+ foreach ($database_info[$index] as $target => $value) {
+ // If there is no "driver" property, then we assume it's an array of
+ // possible connections for this target. Pick one at random. That allows
+ // us to have, for example, multiple slave servers.
+ if (empty($value['driver'])) {
+ $database_info[$index][$target] = $database_info[$index][$target][mt_rand(0, count($database_info[$index][$target]) - 1)];
+ }
+
+ // Parse the prefix information.
+ if (!isset($database_info[$index][$target]['prefix'])) {
+ // Default to an empty prefix.
+ $database_info[$index][$target]['prefix'] = array(
+ 'default' => '',
+ );
+ }
+ elseif (!is_array($database_info[$index][$target]['prefix'])) {
+ // Transform the flat form into an array form.
+ $database_info[$index][$target]['prefix'] = array(
+ 'default' => $database_info[$index][$target]['prefix'],
+ );
+ }
+ }
+ }
+
+ if (!is_array(self::$databaseInfo)) {
+ self::$databaseInfo = $database_info;
+ }
+
+ // Merge the new $database_info into the existing.
+ // array_merge_recursive() cannot be used, as it would make multiple
+ // database, user, and password keys in the same database array.
+ else {
+ foreach ($database_info as $database_key => $database_values) {
+ foreach ($database_values as $target => $target_values) {
+ self::$databaseInfo[$database_key][$target] = $target_values;
+ }
+ }
+ }
+ }
+
+ /**
+ * Adds database connection information for a given key/target.
+ *
+ * This method allows the addition of new connection credentials at runtime.
+ * Under normal circumstances the preferred way to specify database
+ * credentials is via settings.php. However, this method allows them to be
+ * added at arbitrary times, such as during unit tests, when connecting to
+ * admin-defined third party databases, etc.
+ *
+ * If the given key/target pair already exists, this method will be ignored.
+ *
+ * @param $key
+ * The database key.
+ * @param $target
+ * The database target name.
+ * @param $info
+ * The database connection information, as it would be defined in
+ * settings.php. Note that the structure of this array will depend on the
+ * database driver it is connecting to.
+ */
+ public static function addConnectionInfo($key, $target, $info) {
+ if (empty(self::$databaseInfo[$key][$target])) {
+ self::$databaseInfo[$key][$target] = $info;
+ }
+ }
+
+ /**
+ * Gets information on the specified database connection.
+ *
+ * @param $connection
+ * The connection key for which we want information.
+ */
+ final public static function getConnectionInfo($key = 'default') {
+ if (empty(self::$databaseInfo)) {
+ self::parseConnectionInfo();
+ }
+
+ if (!empty(self::$databaseInfo[$key])) {
+ return self::$databaseInfo[$key];
+ }
+ }
+
+ /**
+ * Rename a connection and its corresponding connection information.
+ *
+ * @param $old_key
+ * The old connection key.
+ * @param $new_key
+ * The new connection key.
+ * @return
+ * TRUE in case of success, FALSE otherwise.
+ */
+ final public static function renameConnection($old_key, $new_key) {
+ if (empty(self::$databaseInfo)) {
+ self::parseConnectionInfo();
+ }
+
+ if (!empty(self::$databaseInfo[$old_key]) && empty(self::$databaseInfo[$new_key])) {
+ // Migrate the database connection information.
+ self::$databaseInfo[$new_key] = self::$databaseInfo[$old_key];
+ unset(self::$databaseInfo[$old_key]);
+
+ // Migrate over the DatabaseConnection object if it exists.
+ if (isset(self::$connections[$old_key])) {
+ self::$connections[$new_key] = self::$connections[$old_key];
+ unset(self::$connections[$old_key]);
+ }
+
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Remove a connection and its corresponding connection information.
+ *
+ * @param $key
+ * The connection key.
+ * @return
+ * TRUE in case of success, FALSE otherwise.
+ */
+ final public static function removeConnection($key) {
+ if (isset(self::$databaseInfo[$key])) {
+ unset(self::$databaseInfo[$key]);
+ unset(self::$connections[$key]);
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Opens a connection to the server specified by the given key and target.
+ *
+ * @param $key
+ * The database connection key, as specified in settings.php. The default is
+ * "default".
+ * @param $target
+ * The database target to open.
+ *
+ * @throws DatabaseConnectionNotDefinedException
+ * @throws DatabaseDriverNotSpecifiedException
+ */
+ final protected static function openConnection($key, $target) {
+ if (empty(self::$databaseInfo)) {
+ self::parseConnectionInfo();
+ }
+
+ // If the requested database does not exist then it is an unrecoverable
+ // error.
+ if (!isset(self::$databaseInfo[$key])) {
+ throw new DatabaseConnectionNotDefinedException('The specified database connection is not defined: ' . $key);
+ }
+
+ if (!$driver = self::$databaseInfo[$key][$target]['driver']) {
+ throw new DatabaseDriverNotSpecifiedException('Driver not specified for this database connection: ' . $key);
+ }
+
+ // We cannot rely on the registry yet, because the registry requires an
+ // open database connection.
+ $driver_class = 'DatabaseConnection_' . $driver;
+ require_once DRUPAL_ROOT . '/core/includes/database/' . $driver . '/database.inc';
+ $new_connection = new $driver_class(self::$databaseInfo[$key][$target]);
+ $new_connection->setTarget($target);
+ $new_connection->setKey($key);
+
+ // If we have any active logging objects for this connection key, we need
+ // to associate them with the connection we just opened.
+ if (!empty(self::$logs[$key])) {
+ $new_connection->setLogger(self::$logs[$key]);
+ }
+
+ return $new_connection;
+ }
+
+ /**
+ * Closes a connection to the server specified by the given key and target.
+ *
+ * @param $target
+ * The database target name. Defaults to NULL meaning that all target
+ * connections will be closed.
+ * @param $key
+ * The database connection key. Defaults to NULL which means the active key.
+ */
+ public static function closeConnection($target = NULL, $key = NULL) {
+ // Gets the active connection by default.
+ if (!isset($key)) {
+ $key = self::$activeKey;
+ }
+ // To close the connection, we need to unset the static variable.
+ if (isset($target)) {
+ unset(self::$connections[$key][$target]);
+ }
+ else {
+ unset(self::$connections[$key]);
+ }
+ }
+
+ /**
+ * Instructs the system to temporarily ignore a given key/target.
+ *
+ * At times we need to temporarily disable slave queries. To do so, call this
+ * method with the database key and the target to disable. That database key
+ * will then always fall back to 'default' for that key, even if it's defined.
+ *
+ * @param $key
+ * The database connection key.
+ * @param $target
+ * The target of the specified key to ignore.
+ */
+ public static function ignoreTarget($key, $target) {
+ self::$ignoreTargets[$key][$target] = TRUE;
+ }
+
+ /**
+ * Load a file for the database that might hold a class.
+ *
+ * @param $driver
+ * The name of the driver.
+ * @param array $files
+ * The name of the files the driver specific class can be.
+ */
+ public static function loadDriverFile($driver, array $files = array()) {
+ static $base_path;
+
+ if (empty($base_path)) {
+ $base_path = dirname(realpath(__FILE__));
+ }
+
+ $driver_base_path = "$base_path/$driver";
+ foreach ($files as $file) {
+ // Load the base file first so that classes extending base classes will
+ // have the base class loaded.
+ foreach (array("$base_path/$file", "$driver_base_path/$file") as $filename) {
+ // The OS caches file_exists() and PHP caches require_once(), so
+ // we'll let both of those take care of performance here.
+ if (file_exists($filename)) {
+ require_once $filename;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Exception for when popTransaction() is called with no active transaction.
+ */
+class DatabaseTransactionNoActiveException extends Exception { }
+
+/**
+ * Exception thrown when a savepoint or transaction name occurs twice.
+ */
+class DatabaseTransactionNameNonUniqueException extends Exception { }
+
+/**
+ * Exception thrown when a commit() function fails.
+ */
+class DatabaseTransactionCommitFailedException extends Exception { }
+
+/**
+ * Exception to deny attempts to explicitly manage transactions.
+ *
+ * This exception will be thrown when the PDO connection commit() is called.
+ * Code should never call this method directly.
+ */
+class DatabaseTransactionExplicitCommitNotAllowedException extends Exception { }
+
+/**
+ * Exception thrown when a rollback() resulted in other active transactions being rolled-back.
+ */
+class DatabaseTransactionOutOfOrderException extends Exception { }
+
+/**
+ * Exception thrown for merge queries that do not make semantic sense.
+ *
+ * There are many ways that a merge query could be malformed. They should all
+ * throw this exception and set an appropriately descriptive message.
+ */
+class InvalidMergeQueryException extends Exception {}
+
+/**
+ * Exception thrown if an insert query specifies a field twice.
+ *
+ * It is not allowed to specify a field as default and insert field, this
+ * exception is thrown if that is the case.
+ */
+class FieldsOverlapException extends Exception {}
+
+/**
+ * Exception thrown if an insert query doesn't specify insert or default fields.
+ */
+class NoFieldsException extends Exception {}
+
+/**
+ * Exception thrown if an undefined database connection is requested.
+ */
+class DatabaseConnectionNotDefinedException extends Exception {}
+
+/**
+ * Exception thrown if no driver is specified for a database connection.
+ */
+class DatabaseDriverNotSpecifiedException extends Exception {}
+
+
+/**
+ * A wrapper class for creating and managing database transactions.
+ *
+ * Not all databases or database configurations support transactions. For
+ * example, MySQL MyISAM tables do not. It is also easy to begin a transaction
+ * and then forget to commit it, which can lead to connection errors when
+ * another transaction is started.
+ *
+ * This class acts as a wrapper for transactions. To begin a transaction,
+ * simply instantiate it. When the object goes out of scope and is destroyed
+ * it will automatically commit. It also will check to see if the specified
+ * connection supports transactions. If not, it will simply skip any transaction
+ * commands, allowing user-space code to proceed normally. The only difference
+ * is that rollbacks won't actually do anything.
+ *
+ * In the vast majority of cases, you should not instantiate this class
+ * directly. Instead, call ->startTransaction(), from the appropriate connection
+ * object.
+ */
+class DatabaseTransaction {
+
+ /**
+ * The connection object for this transaction.
+ *
+ * @var DatabaseConnection
+ */
+ protected $connection;
+
+ /**
+ * A boolean value to indicate whether this transaction has been rolled back.
+ *
+ * @var Boolean
+ */
+ protected $rolledBack = FALSE;
+
+ /**
+ * The name of the transaction.
+ *
+ * This is used to label the transaction savepoint. It will be overridden to
+ * 'drupal_transaction' if there is no transaction depth.
+ */
+ protected $name;
+
+ public function __construct(DatabaseConnection &$connection, $name = NULL) {
+ $this->connection = &$connection;
+ // If there is no transaction depth, then no transaction has started. Name
+ // the transaction 'drupal_transaction'.
+ if (!$depth = $connection->transactionDepth()) {
+ $this->name = 'drupal_transaction';
+ }
+ // Within transactions, savepoints are used. Each savepoint requires a
+ // name. So if no name is present we need to create one.
+ elseif (!$name) {
+ $this->name = 'savepoint_' . $depth;
+ }
+ else {
+ $this->name = $name;
+ }
+ $this->connection->pushTransaction($this->name);
+ }
+
+ public function __destruct() {
+ // If we rolled back then the transaction would have already been popped.
+ if (!$this->rolledBack) {
+ $this->connection->popTransaction($this->name);
+ }
+ }
+
+ /**
+ * Retrieves the name of the transaction or savepoint.
+ */
+ public function name() {
+ return $this->name;
+ }
+
+ /**
+ * Rolls back the current transaction.
+ *
+ * This is just a wrapper method to rollback whatever transaction stack we are
+ * currently in, which is managed by the connection object itself. Note that
+ * logging (preferable with watchdog_exception()) needs to happen after a
+ * transaction has been rolled back or the log messages will be rolled back
+ * too.
+ *
+ * @see DatabaseConnection::rollback()
+ * @see watchdog_exception()
+ */
+ public function rollback() {
+ $this->rolledBack = TRUE;
+ $this->connection->rollback($this->name);
+ }
+}
+
+/**
+ * Represents a prepared statement.
+ *
+ * Some methods in that class are purposefully commented out. Due to a change in
+ * how PHP defines PDOStatement, we can't define a signature for those methods
+ * that will work the same way between versions older than 5.2.6 and later
+ * versions. See http://bugs.php.net/bug.php?id=42452 for more details.
+ *
+ * Child implementations should either extend PDOStatement:
+ * @code
+ * class DatabaseStatement_oracle extends PDOStatement implements DatabaseStatementInterface {}
+ * @endcode
+ * or define their own class. If defining their own class, they will also have
+ * to implement either the Iterator or IteratorAggregate interface before
+ * DatabaseStatementInterface:
+ * @code
+ * class DatabaseStatement_oracle implements Iterator, DatabaseStatementInterface {}
+ * @endcode
+ */
+interface DatabaseStatementInterface extends Traversable {
+
+ /**
+ * Executes a prepared statement
+ *
+ * @param $args
+ * An array of values with as many elements as there are bound parameters in
+ * the SQL statement being executed.
+ * @param $options
+ * An array of options for this query.
+ *
+ * @return
+ * TRUE on success, or FALSE on failure.
+ */
+ public function execute($args = array(), $options = array());
+
+ /**
+ * Gets the query string of this statement.
+ *
+ * @return
+ * The query string, in its form with placeholders.
+ */
+ public function getQueryString();
+
+ /**
+ * Returns the number of rows affected by the last SQL statement.
+ *
+ * @return
+ * The number of rows affected by the last DELETE, INSERT, or UPDATE
+ * statement executed.
+ */
+ public function rowCount();
+
+ /**
+ * Sets the default fetch mode for this statement.
+ *
+ * See http://php.net/manual/en/pdo.constants.php for the definition of the
+ * constants used.
+ *
+ * @param $mode
+ * One of the PDO::FETCH_* constants.
+ * @param $a1
+ * An option depending of the fetch mode specified by $mode:
+ * - for PDO::FETCH_COLUMN, the index of the column to fetch
+ * - for PDO::FETCH_CLASS, the name of the class to create
+ * - for PDO::FETCH_INTO, the object to add the data to
+ * @param $a2
+ * If $mode is PDO::FETCH_CLASS, the optional arguments to pass to the
+ * constructor.
+ */
+ // public function setFetchMode($mode, $a1 = NULL, $a2 = array());
+
+ /**
+ * Fetches the next row from a result set.
+ *
+ * See http://php.net/manual/en/pdo.constants.php for the definition of the
+ * constants used.
+ *
+ * @param $mode
+ * One of the PDO::FETCH_* constants.
+ * Default to what was specified by setFetchMode().
+ * @param $cursor_orientation
+ * Not implemented in all database drivers, don't use.
+ * @param $cursor_offset
+ * Not implemented in all database drivers, don't use.
+ *
+ * @return
+ * A result, formatted according to $mode.
+ */
+ // public function fetch($mode = NULL, $cursor_orientation = NULL, $cursor_offset = NULL);
+
+ /**
+ * Returns a single field from the next record of a result set.
+ *
+ * @param $index
+ * The numeric index of the field to return. Defaults to the first field.
+ *
+ * @return
+ * A single field from the next record, or FALSE if there is no next record.
+ */
+ public function fetchField($index = 0);
+
+ /**
+ * Fetches the next row and returns it as an object.
+ *
+ * The object will be of the class specified by DatabaseStatementInterface::setFetchMode()
+ * or stdClass if not specified.
+ */
+ // public function fetchObject();
+
+ /**
+ * Fetches the next row and returns it as an associative array.
+ *
+ * This method corresponds to PDOStatement::fetchObject(), but for associative
+ * arrays. For some reason PDOStatement does not have a corresponding array
+ * helper method, so one is added.
+ *
+ * @return
+ * An associative array, or FALSE if there is no next row.
+ */
+ public function fetchAssoc();
+
+ /**
+ * Returns an array containing all of the result set rows.
+ *
+ * @param $mode
+ * One of the PDO::FETCH_* constants.
+ * @param $column_index
+ * If $mode is PDO::FETCH_COLUMN, the index of the column to fetch.
+ * @param $constructor_arguments
+ * If $mode is PDO::FETCH_CLASS, the arguments to pass to the constructor.
+ *
+ * @return
+ * An array of results.
+ */
+ // function fetchAll($mode = NULL, $column_index = NULL, array $constructor_arguments);
+
+ /**
+ * Returns an entire single column of a result set as an indexed array.
+ *
+ * Note that this method will run the result set to the end.
+ *
+ * @param $index
+ * The index of the column number to fetch.
+ *
+ * @return
+ * An indexed array, or an empty array if there is no result set.
+ */
+ public function fetchCol($index = 0);
+
+ /**
+ * Returns the entire result set as a single associative array.
+ *
+ * This method is only useful for two-column result sets. It will return an
+ * associative array where the key is one column from the result set and the
+ * value is another field. In most cases, the default of the first two columns
+ * is appropriate.
+ *
+ * Note that this method will run the result set to the end.
+ *
+ * @param $key_index
+ * The numeric index of the field to use as the array key.
+ * @param $value_index
+ * The numeric index of the field to use as the array value.
+ *
+ * @return
+ * An associative array, or an empty array if there is no result set.
+ */
+ public function fetchAllKeyed($key_index = 0, $value_index = 1);
+
+ /**
+ * Returns the result set as an associative array keyed by the given field.
+ *
+ * If the given key appears multiple times, later records will overwrite
+ * earlier ones.
+ *
+ * @param $key
+ * The name of the field on which to index the array.
+ * @param $fetch
+ * The fetchmode to use. If set to PDO::FETCH_ASSOC, PDO::FETCH_NUM, or
+ * PDO::FETCH_BOTH the returned value with be an array of arrays. For any
+ * other value it will be an array of objects. By default, the fetch mode
+ * set for the query will be used.
+ *
+ * @return
+ * An associative array, or an empty array if there is no result set.
+ */
+ public function fetchAllAssoc($key, $fetch = NULL);
+}
+
+/**
+ * Default implementation of DatabaseStatementInterface.
+ *
+ * PDO allows us to extend the PDOStatement class to provide additional
+ * functionality beyond that offered by default. We do need extra
+ * functionality. By default, this class is not driver-specific. If a given
+ * driver needs to set a custom statement class, it may do so in its
+ * constructor.
+ *
+ * @see http://us.php.net/pdostatement
+ */
+class DatabaseStatementBase extends PDOStatement implements DatabaseStatementInterface {
+
+ /**
+ * Reference to the database connection object for this statement.
+ *
+ * The name $dbh is inherited from PDOStatement.
+ *
+ * @var DatabaseConnection
+ */
+ public $dbh;
+
+ protected function __construct($dbh) {
+ $this->dbh = $dbh;
+ $this->setFetchMode(PDO::FETCH_OBJ);
+ }
+
+ public function execute($args = array(), $options = array()) {
+ if (isset($options['fetch'])) {
+ if (is_string($options['fetch'])) {
+ // Default to an object. Note: db fields will be added to the object
+ // before the constructor is run. If you need to assign fields after
+ // the constructor is run, see http://drupal.org/node/315092.
+ $this->setFetchMode(PDO::FETCH_CLASS, $options['fetch']);
+ }
+ else {
+ $this->setFetchMode($options['fetch']);
+ }
+ }
+
+ $logger = $this->dbh->getLogger();
+ if (!empty($logger)) {
+ $query_start = microtime(TRUE);
+ }
+
+ $return = parent::execute($args);
+
+ if (!empty($logger)) {
+ $query_end = microtime(TRUE);
+ $logger->log($this, $args, $query_end - $query_start);
+ }
+
+ return $return;
+ }
+
+ public function getQueryString() {
+ return $this->queryString;
+ }
+
+ public function fetchCol($index = 0) {
+ return $this->fetchAll(PDO::FETCH_COLUMN, $index);
+ }
+
+ public function fetchAllAssoc($key, $fetch = NULL) {
+ $return = array();
+ if (isset($fetch)) {
+ if (is_string($fetch)) {
+ $this->setFetchMode(PDO::FETCH_CLASS, $fetch);
+ }
+ else {
+ $this->setFetchMode($fetch);
+ }
+ }
+
+ foreach ($this as $record) {
+ $record_key = is_object($record) ? $record->$key : $record[$key];
+ $return[$record_key] = $record;
+ }
+
+ return $return;
+ }
+
+ public function fetchAllKeyed($key_index = 0, $value_index = 1) {
+ $return = array();
+ $this->setFetchMode(PDO::FETCH_NUM);
+ foreach ($this as $record) {
+ $return[$record[$key_index]] = $record[$value_index];
+ }
+ return $return;
+ }
+
+ public function fetchField($index = 0) {
+ // Call PDOStatement::fetchColumn to fetch the field.
+ return $this->fetchColumn($index);
+ }
+
+ public function fetchAssoc() {
+ // Call PDOStatement::fetch to fetch the row.
+ return $this->fetch(PDO::FETCH_ASSOC);
+ }
+}
+
+/**
+ * Empty implementation of a database statement.
+ *
+ * This class satisfies the requirements of being a database statement/result
+ * object, but does not actually contain data. It is useful when developers
+ * need to safely return an "empty" result set without connecting to an actual
+ * database. Calling code can then treat it the same as if it were an actual
+ * result set that happens to contain no records.
+ *
+ * @see SearchQuery
+ */
+class DatabaseStatementEmpty implements Iterator, DatabaseStatementInterface {
+
+ public function execute($args = array(), $options = array()) {
+ return FALSE;
+ }
+
+ public function getQueryString() {
+ return '';
+ }
+
+ public function rowCount() {
+ return 0;
+ }
+
+ public function setFetchMode($mode, $a1 = NULL, $a2 = array()) {
+ return;
+ }
+
+ public function fetch($mode = NULL, $cursor_orientation = NULL, $cursor_offset = NULL) {
+ return NULL;
+ }
+
+ public function fetchField($index = 0) {
+ return NULL;
+ }
+
+ public function fetchObject() {
+ return NULL;
+ }
+
+ public function fetchAssoc() {
+ return NULL;
+ }
+
+ function fetchAll($mode = NULL, $column_index = NULL, array $constructor_arguments = array()) {
+ return array();
+ }
+
+ public function fetchCol($index = 0) {
+ return array();
+ }
+
+ public function fetchAllKeyed($key_index = 0, $value_index = 1) {
+ return array();
+ }
+
+ public function fetchAllAssoc($key, $fetch = NULL) {
+ return array();
+ }
+
+ /* Implementations of Iterator. */
+
+ public function current() {
+ return NULL;
+ }
+
+ public function key() {
+ return NULL;
+ }
+
+ public function rewind() {
+ // Nothing to do: our DatabaseStatement can't be rewound.
+ }
+
+ public function next() {
+ // Do nothing, since this is an always-empty implementation.
+ }
+
+ public function valid() {
+ return FALSE;
+ }
+}
+
+/**
+ * The following utility functions are simply convenience wrappers.
+ *
+ * They should never, ever have any database-specific code in them.
+ */
+
+/**
+ * Executes an arbitrary query string against the active database.
+ *
+ * Use this function for SELECT queries if it is just a simple query string.
+ * If the caller or other modules need to change the query, use db_select()
+ * instead.
+ *
+ * Do not use this function for INSERT, UPDATE, or DELETE queries. Those should
+ * be handled via db_insert(), db_update() and db_delete() respectively.
+ *
+ * @param $query
+ * The prepared statement query to run. Although it will accept both named and
+ * unnamed placeholders, named placeholders are strongly preferred as they are
+ * more self-documenting.
+ * @param $args
+ * An array of values to substitute into the query. If the query uses named
+ * placeholders, this is an associative array in any order. If the query uses
+ * unnamed placeholders (?), this is an indexed array and the order must match
+ * the order of placeholders in the query string.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return DatabaseStatementInterface
+ * A prepared statement object, already executed.
+ *
+ * @see DatabaseConnection::defaultOptions()
+ */
+function db_query($query, array $args = array(), array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = 'default';
+ }
+
+ return Database::getConnection($options['target'])->query($query, $args, $options);
+}
+
+/**
+ * Executes a query against the active database, restricted to a range.
+ *
+ * @param $query
+ * The prepared statement query to run. Although it will accept both named and
+ * unnamed placeholders, named placeholders are strongly preferred as they are
+ * more self-documenting.
+ * @param $from
+ * The first record from the result set to return.
+ * @param $count
+ * The number of records to return from the result set.
+ * @param $args
+ * An array of values to substitute into the query. If the query uses named
+ * placeholders, this is an associative array in any order. If the query uses
+ * unnamed placeholders (?), this is an indexed array and the order must match
+ * the order of placeholders in the query string.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return DatabaseStatementInterface
+ * A prepared statement object, already executed.
+ *
+ * @see DatabaseConnection::defaultOptions()
+ */
+function db_query_range($query, $from, $count, array $args = array(), array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = 'default';
+ }
+
+ return Database::getConnection($options['target'])->queryRange($query, $from, $count, $args, $options);
+}
+
+/**
+ * Executes a query string and saves the result set to a temporary table.
+ *
+ * The execution of the query string happens against the active database.
+ *
+ * @param $query
+ * The prepared statement query to run. Although it will accept both named and
+ * unnamed placeholders, named placeholders are strongly preferred as they are
+ * more self-documenting.
+ * @param $args
+ * An array of values to substitute into the query. If the query uses named
+ * placeholders, this is an associative array in any order. If the query uses
+ * unnamed placeholders (?), this is an indexed array and the order must match
+ * the order of placeholders in the query string.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return
+ * The name of the temporary table.
+ *
+ * @see DatabaseConnection::defaultOptions()
+ */
+function db_query_temporary($query, array $args = array(), array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = 'default';
+ }
+
+ return Database::getConnection($options['target'])->queryTemporary($query, $args, $options);
+}
+
+/**
+ * Returns a new InsertQuery object for the active database.
+ *
+ * @param $table
+ * The table into which to insert.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return InsertQuery
+ * A new InsertQuery object for this connection.
+ */
+function db_insert($table, array $options = array()) {
+ if (empty($options['target']) || $options['target'] == 'slave') {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->insert($table, $options);
+}
+
+/**
+ * Returns a new MergeQuery object for the active database.
+ *
+ * @param $table
+ * The table into which to merge.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return MergeQuery
+ * A new MergeQuery object for this connection.
+ */
+function db_merge($table, array $options = array()) {
+ if (empty($options['target']) || $options['target'] == 'slave') {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->merge($table, $options);
+}
+
+/**
+ * Returns a new UpdateQuery object for the active database.
+ *
+ * @param $table
+ * The table to update.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return UpdateQuery
+ * A new UpdateQuery object for this connection.
+ */
+function db_update($table, array $options = array()) {
+ if (empty($options['target']) || $options['target'] == 'slave') {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->update($table, $options);
+}
+
+/**
+ * Returns a new DeleteQuery object for the active database.
+ *
+ * @param $table
+ * The table from which to delete.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return DeleteQuery
+ * A new DeleteQuery object for this connection.
+ */
+function db_delete($table, array $options = array()) {
+ if (empty($options['target']) || $options['target'] == 'slave') {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->delete($table, $options);
+}
+
+/**
+ * Returns a new TruncateQuery object for the active database.
+ *
+ * @param $table
+ * The table from which to delete.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return TruncateQuery
+ * A new TruncateQuery object for this connection.
+ */
+function db_truncate($table, array $options = array()) {
+ if (empty($options['target']) || $options['target'] == 'slave') {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->truncate($table, $options);
+}
+
+/**
+ * Returns a new SelectQuery object for the active database.
+ *
+ * @param $table
+ * The base table for this query. May be a string or another SelectQuery
+ * object. If a query object is passed, it will be used as a subselect.
+ * @param $alias
+ * The alias for the base table of this query.
+ * @param $options
+ * An array of options to control how the query operates.
+ *
+ * @return SelectQuery
+ * A new SelectQuery object for this connection.
+ */
+function db_select($table, $alias = NULL, array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->select($table, $alias, $options);
+}
+
+/**
+ * Returns a new transaction object for the active database.
+ *
+ * @param string $name
+ * Optional name of the transaction.
+ * @param array $options
+ * An array of options to control how the transaction operates:
+ * - target: The database target name.
+ *
+ * @return DatabaseTransaction
+ * A new DatabaseTransaction object for this connection.
+ */
+function db_transaction($name = NULL, array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = 'default';
+ }
+ return Database::getConnection($options['target'])->startTransaction($name);
+}
+
+/**
+ * Sets a new active database.
+ *
+ * @param $key
+ * The key in the $databases array to set as the default database.
+ *
+ * @return
+ * The key of the formerly active database.
+ */
+function db_set_active($key = 'default') {
+ return Database::setActiveConnection($key);
+}
+
+/**
+ * Restricts a dynamic table name to safe characters.
+ *
+ * Only keeps alphanumeric and underscores.
+ *
+ * @param $table
+ * The table name to escape.
+ *
+ * @return
+ * The escaped table name as a string.
+ */
+function db_escape_table($table) {
+ return Database::getConnection()->escapeTable($table);
+}
+
+/**
+ * Restricts a dynamic column or constraint name to safe characters.
+ *
+ * Only keeps alphanumeric and underscores.
+ *
+ * @param $field
+ * The field name to escape.
+ *
+ * @return
+ * The escaped field name as a string.
+ */
+function db_escape_field($field) {
+ return Database::getConnection()->escapeField($field);
+}
+
+/**
+ * Escapes characters that work as wildcard characters in a LIKE pattern.
+ *
+ * The wildcard characters "%" and "_" as well as backslash are prefixed with
+ * a backslash. Use this to do a search for a verbatim string without any
+ * wildcard behavior.
+ *
+ * For example, the following does a case-insensitive query for all rows whose
+ * name starts with $prefix:
+ * @code
+ * $result = db_query(
+ * 'SELECT * FROM person WHERE name LIKE :pattern',
+ * array(':pattern' => db_like($prefix) . '%')
+ * );
+ * @endcode
+ *
+ * Backslash is defined as escape character for LIKE patterns in
+ * DatabaseCondition::mapConditionOperator().
+ *
+ * @param $string
+ * The string to escape.
+ *
+ * @return
+ * The escaped string.
+ */
+function db_like($string) {
+ return Database::getConnection()->escapeLike($string);
+}
+
+/**
+ * Retrieves the name of the currently active database driver.
+ *
+ * @return
+ * The name of the currently active database driver.
+ */
+function db_driver() {
+ return Database::getConnection()->driver();
+}
+
+/**
+ * Closes the active database connection.
+ *
+ * @param $options
+ * An array of options to control which connection is closed. Only the target
+ * key has any meaning in this case.
+ */
+function db_close(array $options = array()) {
+ if (empty($options['target'])) {
+ $options['target'] = NULL;
+ }
+ Database::closeConnection($options['target']);
+}
+
+/**
+ * Retrieves a unique id.
+ *
+ * Use this function if for some reason you can't use a serial field. Using a
+ * serial field is preferred, and InsertQuery::execute() returns the value of
+ * the last ID inserted.
+ *
+ * @param $existing_id
+ * After a database import, it might be that the sequences table is behind, so
+ * by passing in a minimum ID, it can be assured that we never issue the same
+ * ID.
+ *
+ * @return
+ * An integer number larger than any number returned before for this sequence.
+ */
+function db_next_id($existing_id = 0) {
+ return Database::getConnection()->nextId($existing_id);
+}
+
+/**
+ * Returns a new DatabaseCondition, set to "OR" all conditions together.
+ *
+ * @return DatabaseCondition
+ */
+function db_or() {
+ return new DatabaseCondition('OR');
+}
+
+/**
+ * Returns a new DatabaseCondition, set to "AND" all conditions together.
+ *
+ * @return DatabaseCondition
+ */
+function db_and() {
+ return new DatabaseCondition('AND');
+}
+
+/**
+ * Returns a new DatabaseCondition, set to "XOR" all conditions together.
+ *
+ * @return DatabaseCondition
+ */
+function db_xor() {
+ return new DatabaseCondition('XOR');
+}
+
+/**
+ * Returns a new DatabaseCondition, set to the specified conjunction.
+ *
+ * Internal API function call. The db_and(), db_or(), and db_xor()
+ * functions are preferred.
+ *
+ * @param $conjunction
+ * The conjunction to use for query conditions (AND, OR or XOR).
+ * @return DatabaseCondition
+ */
+function db_condition($conjunction) {
+ return new DatabaseCondition($conjunction);
+}
+
+/**
+ * @} End of "defgroup database".
+ */
+
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+/**
+ * Creates a new table from a Drupal table definition.
+ *
+ * @param $name
+ * The name of the table to create.
+ * @param $table
+ * A Schema API table definition array.
+ */
+function db_create_table($name, $table) {
+ return Database::getConnection()->schema()->createTable($name, $table);
+}
+
+/**
+ * Returns an array of field names from an array of key/index column specifiers.
+ *
+ * This is usually an identity function but if a key/index uses a column prefix
+ * specification, this function extracts just the name.
+ *
+ * @param $fields
+ * An array of key/index column specifiers.
+ *
+ * @return
+ * An array of field names.
+ */
+function db_field_names($fields) {
+ return Database::getConnection()->schema()->fieldNames($fields);
+}
+
+/**
+ * Checks if an index exists in the given table.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ * @param $name
+ * The name of the index in drupal (no prefixing).
+ *
+ * @return
+ * TRUE if the given index exists, otherwise FALSE.
+ */
+function db_index_exists($table, $name) {
+ return Database::getConnection()->schema()->indexExists($table, $name);
+}
+
+/**
+ * Checks if a table exists.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ *
+ * @return
+ * TRUE if the given table exists, otherwise FALSE.
+ */
+function db_table_exists($table) {
+ return Database::getConnection()->schema()->tableExists($table);
+}
+
+/**
+ * Checks if a column exists in the given table.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ * @param $field
+ * The name of the field.
+ *
+ * @return
+ * TRUE if the given column exists, otherwise FALSE.
+ */
+function db_field_exists($table, $field) {
+ return Database::getConnection()->schema()->fieldExists($table, $field);
+}
+
+/**
+ * Finds all tables that are like the specified base table name.
+ *
+ * @param $table_expression
+ * An SQL expression, for example "simpletest%" (without the quotes).
+ * BEWARE: this is not prefixed, the caller should take care of that.
+ *
+ * @return
+ * Array, both the keys and the values are the matching tables.
+ */
+function db_find_tables($table_expression) {
+ return Database::getConnection()->schema()->findTables($table_expression);
+}
+
+function _db_create_keys_sql($spec) {
+ return Database::getConnection()->schema()->createKeysSql($spec);
+}
+
+/**
+ * Renames a table.
+ *
+ * @param $table
+ * The table to be renamed.
+ * @param $new_name
+ * The new name for the table.
+ */
+function db_rename_table($table, $new_name) {
+ return Database::getConnection()->schema()->renameTable($table, $new_name);
+}
+
+/**
+ * Drops a table.
+ *
+ * @param $table
+ * The table to be dropped.
+ */
+function db_drop_table($table) {
+ return Database::getConnection()->schema()->dropTable($table);
+}
+
+/**
+ * Adds a new field to a table.
+ *
+ * @param $table
+ * Name of the table to be altered.
+ * @param $field
+ * Name of the field to be added.
+ * @param $spec
+ * The field specification array, as taken from a schema definition. The
+ * specification may also contain the key 'initial'; the newly-created field
+ * will be set to the value of the key in all rows. This is most useful for
+ * creating NOT NULL columns with no default value in existing tables.
+ * @param $keys_new
+ * Optional keys and indexes specification to be created on the table along
+ * with adding the field. The format is the same as a table specification, but
+ * without the 'fields' element. If you are adding a type 'serial' field, you
+ * MUST specify at least one key or index including it in this array. See
+ * db_change_field() for more explanation why.
+ *
+ * @see db_change_field()
+ */
+function db_add_field($table, $field, $spec, $keys_new = array()) {
+ return Database::getConnection()->schema()->addField($table, $field, $spec, $keys_new);
+}
+
+/**
+ * Drops a field.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be dropped.
+ */
+function db_drop_field($table, $field) {
+ return Database::getConnection()->schema()->dropField($table, $field);
+}
+
+/**
+ * Sets the default value for a field.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be altered.
+ * @param $default
+ * Default value to be set. NULL for 'default NULL'.
+ */
+function db_field_set_default($table, $field, $default) {
+ return Database::getConnection()->schema()->fieldSetDefault($table, $field, $default);
+}
+
+/**
+ * Sets a field to have no default value.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be altered.
+ */
+function db_field_set_no_default($table, $field) {
+ return Database::getConnection()->schema()->fieldSetNoDefault($table, $field);
+}
+
+/**
+ * Adds a primary key to a database table.
+ *
+ * @param $table
+ * Name of the table to be altered.
+ * @param $fields
+ * Array of fields for the primary key.
+ */
+function db_add_primary_key($table, $fields) {
+ return Database::getConnection()->schema()->addPrimaryKey($table, $fields);
+}
+
+/**
+ * Drops the primary key of a database table.
+ *
+ * @param $table
+ * Name of the table to be altered.
+ */
+function db_drop_primary_key($table) {
+ return Database::getConnection()->schema()->dropPrimaryKey($table);
+}
+
+/**
+ * Adds a unique key.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the key.
+ * @param $fields
+ * An array of field names.
+ */
+function db_add_unique_key($table, $name, $fields) {
+ return Database::getConnection()->schema()->addUniqueKey($table, $name, $fields);
+}
+
+/**
+ * Drops a unique key.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the key.
+ */
+function db_drop_unique_key($table, $name) {
+ return Database::getConnection()->schema()->dropUniqueKey($table, $name);
+}
+
+/**
+ * Adds an index.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the index.
+ * @param $fields
+ * An array of field names.
+ */
+function db_add_index($table, $name, $fields) {
+ return Database::getConnection()->schema()->addIndex($table, $name, $fields);
+}
+
+/**
+ * Drops an index.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the index.
+ */
+function db_drop_index($table, $name) {
+ return Database::getConnection()->schema()->dropIndex($table, $name);
+}
+
+/**
+ * Changes a field definition.
+ *
+ * IMPORTANT NOTE: To maintain database portability, you have to explicitly
+ * recreate all indices and primary keys that are using the changed field.
+ *
+ * That means that you have to drop all affected keys and indexes with
+ * db_drop_{primary_key,unique_key,index}() before calling db_change_field().
+ * To recreate the keys and indices, pass the key definitions as the optional
+ * $keys_new argument directly to db_change_field().
+ *
+ * For example, suppose you have:
+ * @code
+ * $schema['foo'] = array(
+ * 'fields' => array(
+ * 'bar' => array('type' => 'int', 'not null' => TRUE)
+ * ),
+ * 'primary key' => array('bar')
+ * );
+ * @endcode
+ * and you want to change foo.bar to be type serial, leaving it as the primary
+ * key. The correct sequence is:
+ * @code
+ * db_drop_primary_key('foo');
+ * db_change_field('foo', 'bar', 'bar',
+ * array('type' => 'serial', 'not null' => TRUE),
+ * array('primary key' => array('bar')));
+ * @endcode
+ *
+ * The reasons for this are due to the different database engines:
+ *
+ * On PostgreSQL, changing a field definition involves adding a new field and
+ * dropping an old one which causes any indices, primary keys and sequences
+ * (from serial-type fields) that use the changed field to be dropped.
+ *
+ * On MySQL, all type 'serial' fields must be part of at least one key or index
+ * as soon as they are created. You cannot use
+ * db_add_{primary_key,unique_key,index}() for this purpose because the ALTER
+ * TABLE command will fail to add the column without a key or index
+ * specification. The solution is to use the optional $keys_new argument to
+ * create the key or index at the same time as field.
+ *
+ * You could use db_add_{primary_key,unique_key,index}() in all cases unless you
+ * are converting a field to be type serial. You can use the $keys_new argument
+ * in all cases.
+ *
+ * @param $table
+ * Name of the table.
+ * @param $field
+ * Name of the field to change.
+ * @param $field_new
+ * New name for the field (set to the same as $field if you don't want to
+ * change the name).
+ * @param $spec
+ * The field specification for the new field.
+ * @param $keys_new
+ * Optional keys and indexes specification to be created on the table along
+ * with changing the field. The format is the same as a table specification
+ * but without the 'fields' element.
+ */
+function db_change_field($table, $field, $field_new, $spec, $keys_new = array()) {
+ return Database::getConnection()->schema()->changeField($table, $field, $field_new, $spec, $keys_new);
+}
+
+/**
+ * @} End of "ingroup schemaapi".
+ */
+
+/**
+ * Sets a session variable specifying the lag time for ignoring a slave server.
+ */
+function db_ignore_slave() {
+ $connection_info = Database::getConnectionInfo();
+ // Only set ignore_slave_server if there are slave servers being used, which
+ // is assumed if there are more than one.
+ if (count($connection_info) > 1) {
+ // Five minutes is long enough to allow the slave to break and resume
+ // interrupted replication without causing problems on the Drupal site from
+ // the old data.
+ $duration = variable_get('maximum_replication_lag', 300);
+ // Set session variable with amount of time to delay before using slave.
+ $_SESSION['ignore_slave_server'] = REQUEST_TIME + $duration;
+ }
+}
diff --git a/core/includes/database/log.inc b/core/includes/database/log.inc
new file mode 100644
index 000000000000..ec27ef8e6332
--- /dev/null
+++ b/core/includes/database/log.inc
@@ -0,0 +1,159 @@
+<?php
+
+/**
+ * @file
+ * Logging classes for the database layer.
+ */
+
+/**
+ * Database query logger.
+ *
+ * We log queries in a separate object rather than in the connection object
+ * because we want to be able to see all queries sent to a given database, not
+ * database target. If we logged the queries in each connection object we
+ * would not be able to track what queries went to which target.
+ *
+ * Every connection has one and only one logging object on it for all targets
+ * and logging keys.
+ */
+class DatabaseLog {
+
+ /**
+ * Cache of logged queries. This will only be used if the query logger is enabled.
+ *
+ * The structure for the logging array is as follows:
+ *
+ * array(
+ * $logging_key = array(
+ * array(query => '', args => array(), caller => '', target => '', time => 0),
+ * array(query => '', args => array(), caller => '', target => '', time => 0),
+ * ),
+ * );
+ *
+ * @var array
+ */
+ protected $queryLog = array();
+
+ /**
+ * The connection key for which this object is logging.
+ *
+ * @var string
+ */
+ protected $connectionKey = 'default';
+
+ /**
+ * Constructor.
+ *
+ * @param $key
+ * The database connection key for which to enable logging.
+ */
+ public function __construct($key = 'default') {
+ $this->connectionKey = $key;
+ }
+
+ /**
+ * Begin logging queries to the specified connection and logging key.
+ *
+ * If the specified logging key is already running this method does nothing.
+ *
+ * @param $logging_key
+ * The identification key for this log request. By specifying different
+ * logging keys we are able to start and stop multiple logging runs
+ * simultaneously without them colliding.
+ */
+ public function start($logging_key) {
+ if (empty($this->queryLog[$logging_key])) {
+ $this->clear($logging_key);
+ }
+ }
+
+ /**
+ * Retrieve the query log for the specified logging key so far.
+ *
+ * @param $logging_key
+ * The logging key to fetch.
+ * @return
+ * An indexed array of all query records for this logging key.
+ */
+ public function get($logging_key) {
+ return $this->queryLog[$logging_key];
+ }
+
+ /**
+ * Empty the query log for the specified logging key.
+ *
+ * This method does not stop logging, it simply clears the log. To stop
+ * logging, use the end() method.
+ *
+ * @param $logging_key
+ * The logging key to empty.
+ */
+ public function clear($logging_key) {
+ $this->queryLog[$logging_key] = array();
+ }
+
+ /**
+ * Stop logging for the specified logging key.
+ *
+ * @param $logging_key
+ * The logging key to stop.
+ */
+ public function end($logging_key) {
+ unset($this->queryLog[$logging_key]);
+ }
+
+ /**
+ * Log a query to all active logging keys.
+ *
+ * @param $statement
+ * The prepared statement object to log.
+ * @param $args
+ * The arguments passed to the statement object.
+ * @param $time
+ * The time in milliseconds the query took to execute.
+ */
+ public function log(DatabaseStatementInterface $statement, $args, $time) {
+ foreach (array_keys($this->queryLog) as $key) {
+ $this->queryLog[$key][] = array(
+ 'query' => $statement->getQueryString(),
+ 'args' => $args,
+ 'target' => $statement->dbh->getTarget(),
+ 'caller' => $this->findCaller(),
+ 'time' => $time,
+ );
+ }
+ }
+
+ /**
+ * Determine the routine that called this query.
+ *
+ * We define "the routine that called this query" as the first entry in
+ * the call stack that is not inside includes/database. That makes the
+ * climbing logic very simple, and handles the variable stack depth caused
+ * by the query builders.
+ *
+ * @link http://www.php.net/debug_backtrace
+ * @return
+ * This method returns a stack trace entry similar to that generated by
+ * debug_backtrace(). However, it flattens the trace entry and the trace
+ * entry before it so that we get the function and args of the function that
+ * called into the database system, not the function and args of the
+ * database call itself.
+ */
+ public function findCaller() {
+ $stack = debug_backtrace();
+ $stack_count = count($stack);
+ for ($i = 0; $i < $stack_count; ++$i) {
+ if (strpos($stack[$i]['file'], 'includes' . DIRECTORY_SEPARATOR . 'database') === FALSE) {
+ return array(
+ 'file' => $stack[$i]['file'],
+ 'line' => $stack[$i]['line'],
+ 'function' => $stack[$i + 1]['function'],
+ 'class' => isset($stack[$i + 1]['class']) ? $stack[$i + 1]['class'] : NULL,
+ 'type' => isset($stack[$i + 1]['type']) ? $stack[$i + 1]['type'] : NULL,
+ 'args' => $stack[$i + 1]['args'],
+ );
+ }
+ }
+ }
+}
diff --git a/core/includes/database/mysql/database.inc b/core/includes/database/mysql/database.inc
new file mode 100644
index 000000000000..7d5d85998dbd
--- /dev/null
+++ b/core/includes/database/mysql/database.inc
@@ -0,0 +1,187 @@
+<?php
+
+/**
+ * @file
+ * Database interface code for MySQL database servers.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+class DatabaseConnection_mysql extends DatabaseConnection {
+
+ /**
+ * Flag to indicate if we have registered the nextID cleanup function.
+ *
+ * @var boolean
+ */
+ protected $shutdownRegistered = FALSE;
+
+ public function __construct(array $connection_options = array()) {
+ // This driver defaults to transaction support, except if explicitly passed FALSE.
+ $this->transactionSupport = !isset($connection_options['transactions']) || ($connection_options['transactions'] !== FALSE);
+
+ // MySQL never supports transactional DDL.
+ $this->transactionalDDLSupport = FALSE;
+
+ $this->connectionOptions = $connection_options;
+
+ // The DSN should use either a socket or a host/port.
+ if (isset($connection_options['unix_socket'])) {
+ $dsn = 'mysql:unix_socket=' . $connection_options['unix_socket'];
+ }
+ else {
+ // Default to TCP connection on port 3306.
+ $dsn = 'mysql:host=' . $connection_options['host'] . ';port=' . (empty($connection_options['port']) ? 3306 : $connection_options['port']);
+ }
+ $dsn .= ';dbname=' . $connection_options['database'];
+ parent::__construct($dsn, $connection_options['username'], $connection_options['password'], array(
+ // So we don't have to mess around with cursors and unbuffered queries by default.
+ PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => TRUE,
+ // Because MySQL's prepared statements skip the query cache, because it's dumb.
+ PDO::ATTR_EMULATE_PREPARES => TRUE,
+ // Force column names to lower case.
+ PDO::ATTR_CASE => PDO::CASE_LOWER,
+ ));
+
+ // Force MySQL to use the UTF-8 character set. Also set the collation, if a
+ // certain one has been set; otherwise, MySQL defaults to 'utf8_general_ci'
+ // for UTF-8.
+ if (!empty($connection_options['collation'])) {
+ $this->exec('SET NAMES utf8 COLLATE ' . $connection_options['collation']);
+ }
+ else {
+ $this->exec('SET NAMES utf8');
+ }
+
+ // Force MySQL's behavior to conform more closely to SQL standards.
+ // This allows Drupal to run almost seamlessly on many different
+ // kinds of database systems. These settings force MySQL to behave
+ // the same as postgresql, or sqlite in regards to syntax interpretation
+ // and invalid data handling. See http://drupal.org/node/344575 for
+ // further discussion. Also, as MySQL 5.5 changed the meaning of
+ // TRADITIONAL we need to spell out the modes one by one.
+ $this->exec("SET sql_mode='ANSI,STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER'");
+ }
+
+ public function queryRange($query, $from, $count, array $args = array(), array $options = array()) {
+ return $this->query($query . ' LIMIT ' . (int) $from . ', ' . (int) $count, $args, $options);
+ }
+
+ public function queryTemporary($query, array $args = array(), array $options = array()) {
+ $tablename = $this->generateTemporaryTableName();
+ $this->query(preg_replace('/^SELECT/i', 'CREATE TEMPORARY TABLE {' . $tablename . '} Engine=MEMORY SELECT', $query), $args, $options);
+ return $tablename;
+ }
+
+ public function driver() {
+ return 'mysql';
+ }
+
+ public function databaseType() {
+ return 'mysql';
+ }
+
+ public function mapConditionOperator($operator) {
+ // We don't want to override any of the defaults.
+ return NULL;
+ }
+
+ public function nextId($existing_id = 0) {
+ $new_id = $this->query('INSERT INTO {sequences} () VALUES ()', array(), array('return' => Database::RETURN_INSERT_ID));
+ // This should only happen after an import or similar event.
+ if ($existing_id >= $new_id) {
+ // If we INSERT a value manually into the sequences table, on the next
+ // INSERT, MySQL will generate a larger value. However, there is no way
+ // of knowing whether this value already exists in the table. MySQL
+ // provides an INSERT IGNORE which would work, but that can mask problems
+ // other than duplicate keys. Instead, we use INSERT ... ON DUPLICATE KEY
+ // UPDATE in such a way that the UPDATE does not do anything. This way,
+ // duplicate keys do not generate errors but everything else does.
+ $this->query('INSERT INTO {sequences} (value) VALUES (:value) ON DUPLICATE KEY UPDATE value = value', array(':value' => $existing_id));
+ $new_id = $this->query('INSERT INTO {sequences} () VALUES ()', array(), array('return' => Database::RETURN_INSERT_ID));
+ }
+ if (!$this->shutdownRegistered) {
+ // Use register_shutdown_function() here to keep the database system
+ // independent of Drupal.
+ register_shutdown_function(array($this, 'nextIdDelete'));
+ $shutdownRegistered = TRUE;
+ }
+ return $new_id;
+ }
+
+ public function nextIdDelete() {
+ // While we want to clean up the table to keep it up from occupying too
+ // much storage and memory, we must keep the highest value in the table
+ // because InnoDB uses an in-memory auto-increment counter as long as the
+ // server runs. When the server is stopped and restarted, InnoDB
+ // reinitializes the counter for each table for the first INSERT to the
+ // table based solely on values from the table so deleting all values would
+ // be a problem in this case. Also, TRUNCATE resets the auto increment
+ // counter.
+ try {
+ $max_id = $this->query('SELECT MAX(value) FROM {sequences}')->fetchField();
+ // We know we are using MySQL here, no need for the slower db_delete().
+ $this->query('DELETE FROM {sequences} WHERE value < :value', array(':value' => $max_id));
+ }
+ // During testing, this function is called from shutdown with the
+ // simpletest prefix stored in $this->connection, and those tables are gone
+ // by the time shutdown is called so we need to ignore the database
+ // errors. There is no problem with completely ignoring errors here: if
+ // these queries fail, the sequence will work just fine, just use a bit
+ // more database storage and memory.
+ catch (PDOException $e) {
+ }
+ }
+
+ /**
+ * Overridden to work around issues to MySQL not supporting transactional DDL.
+ */
+ protected function popCommittableTransactions() {
+ // Commit all the committable layers.
+ foreach (array_reverse($this->transactionLayers) as $name => $active) {
+ // Stop once we found an active transaction.
+ if ($active) {
+ break;
+ }
+
+ // If there are no more layers left then we should commit.
+ unset($this->transactionLayers[$name]);
+ if (empty($this->transactionLayers)) {
+ if (!PDO::commit()) {
+ throw new DatabaseTransactionCommitFailedException();
+ }
+ }
+ else {
+ // Attempt to release this savepoint in the standard way.
+ try {
+ $this->query('RELEASE SAVEPOINT ' . $name);
+ }
+ catch (PDOException $e) {
+ // However, in MySQL (InnoDB), savepoints are automatically committed
+ // when tables are altered or created (DDL transactions are not
+ // supported). This can cause exceptions due to trying to release
+ // savepoints which no longer exist.
+ //
+ // To avoid exceptions when no actual error has occurred, we silently
+ // succeed for MySQL error code 1305 ("SAVEPOINT does not exist").
+ if ($e->errorInfo[1] == '1305') {
+ // If one SAVEPOINT was released automatically, then all were.
+ // Therefore, we keep just the topmost transaction.
+ $this->transactionLayers = array('drupal_transaction' => 'drupal_transaction');
+ }
+ else {
+ throw $e;
+ }
+ }
+ }
+ }
+ }
+}
+
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/mysql/install.inc b/core/includes/database/mysql/install.inc
new file mode 100644
index 000000000000..75f2ae390504
--- /dev/null
+++ b/core/includes/database/mysql/install.inc
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Installation code for MySQL embedded database engine.
+ */
+
+/**
+ * Specifies installation tasks for MySQL and equivalent databases.
+ */
+class DatabaseTasks_mysql extends DatabaseTasks {
+ /**
+ * The PDO driver name for MySQL and equivalent databases.
+ *
+ * @var string
+ */
+ protected $pdoDriver = 'mysql';
+
+ /**
+ * Returns a human-readable name string for MySQL and equivalent databases.
+ */
+ public function name() {
+ return st('MySQL, MariaDB, or equivalent');
+ }
+
+ /**
+ * Returns the minimum version for MySQL.
+ */
+ public function minimumVersion() {
+ return '5.0.15';
+ }
+}
+
diff --git a/core/includes/database/mysql/query.inc b/core/includes/database/mysql/query.inc
new file mode 100644
index 000000000000..888b6a5a450e
--- /dev/null
+++ b/core/includes/database/mysql/query.inc
@@ -0,0 +1,107 @@
+<?php
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * @file
+ * Query code for MySQL embedded database engine.
+ */
+
+
+class InsertQuery_mysql extends InsertQuery {
+
+ public function execute() {
+ if (!$this->preExecute()) {
+ return NULL;
+ }
+
+ // If we're selecting from a SelectQuery, finish building the query and
+ // pass it back, as any remaining options are irrelevant.
+ if (empty($this->fromQuery)) {
+ $max_placeholder = 0;
+ $values = array();
+ foreach ($this->insertValues as $insert_values) {
+ foreach ($insert_values as $value) {
+ $values[':db_insert_placeholder_' . $max_placeholder++] = $value;
+ }
+ }
+ }
+ else {
+ $values = $this->fromQuery->getArguments();
+ }
+
+ $last_insert_id = $this->connection->query((string) $this, $values, $this->queryOptions);
+
+ // Re-initialize the values array so that we can re-use this query.
+ $this->insertValues = array();
+
+ return $last_insert_id;
+ }
+
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // Default fields are always placed first for consistency.
+ $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+
+ // If we're selecting from a SelectQuery, finish building the query and
+ // pass it back, as any remaining options are irrelevant.
+ if (!empty($this->fromQuery)) {
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') ' . $this->fromQuery;
+ }
+
+ $query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
+
+ $max_placeholder = 0;
+ $values = array();
+ if (count($this->insertValues)) {
+ foreach ($this->insertValues as $insert_values) {
+ $placeholders = array();
+
+ // Default fields aren't really placeholders, but this is the most convenient
+ // way to handle them.
+ $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
+
+ $new_placeholder = $max_placeholder + count($insert_values);
+ for ($i = $max_placeholder; $i < $new_placeholder; ++$i) {
+ $placeholders[] = ':db_insert_placeholder_' . $i;
+ }
+ $max_placeholder = $new_placeholder;
+ $values[] = '(' . implode(', ', $placeholders) . ')';
+ }
+ }
+ else {
+ // If there are no values, then this is a default-only query. We still need to handle that.
+ $placeholders = array_fill(0, count($this->defaultFields), 'default');
+ $values[] = '(' . implode(', ', $placeholders) . ')';
+ }
+
+ $query .= implode(', ', $values);
+
+ return $query;
+ }
+}
+
+class TruncateQuery_mysql extends TruncateQuery {
+ public function __toString() {
+ // TRUNCATE is actually a DDL statement on MySQL, and DDL statements are
+ // not transactional, and result in an implicit COMMIT. When we are in a
+ // transaction, fallback to the slower, but transactional, DELETE.
+ if ($this->connection->inTransaction()) {
+ // Create a comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+ return $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '}';
+ }
+ else {
+ return parent::__toString();
+ }
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/mysql/schema.inc b/core/includes/database/mysql/schema.inc
new file mode 100644
index 000000000000..4e88fa169ebe
--- /dev/null
+++ b/core/includes/database/mysql/schema.inc
@@ -0,0 +1,531 @@
+<?php
+
+/**
+ * @file
+ * Database schema code for MySQL database servers.
+ */
+
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+class DatabaseSchema_mysql extends DatabaseSchema {
+
+ /**
+ * Maximum length of a table comment in MySQL.
+ */
+ const COMMENT_MAX_TABLE = 60;
+
+ /**
+ * Maximum length of a column comment in MySQL.
+ */
+ const COMMENT_MAX_COLUMN = 255;
+
+ /**
+ * Get information about the table and database name from the prefix.
+ *
+ * @return
+ * A keyed array with information about the database, table name and prefix.
+ */
+ protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
+ $info = array('prefix' => $this->connection->tablePrefix($table));
+ if ($add_prefix) {
+ $table = $info['prefix'] . $table;
+ }
+ if (($pos = strpos($table, '.')) !== FALSE) {
+ $info['database'] = substr($table, 0, $pos);
+ $info['table'] = substr($table, ++$pos);
+ }
+ else {
+ $db_info = Database::getConnectionInfo();
+ $info['database'] = $db_info['default']['database'];
+ $info['table'] = $table;
+ }
+ return $info;
+ }
+
+ /**
+ * Build a condition to match a table name against a standard information_schema.
+ *
+ * MySQL uses databases like schemas rather than catalogs so when we build
+ * a condition to query the information_schema.tables, we set the default
+ * database as the schema unless specified otherwise, and exclude table_catalog
+ * from the condition criteria.
+ */
+ protected function buildTableNameCondition($table_name, $operator = '=', $add_prefix = TRUE) {
+ $info = $this->connection->getConnectionOptions();
+
+ $table_info = $this->getPrefixInfo($table_name, $add_prefix);
+
+ $condition = new DatabaseCondition('AND');
+ $condition->condition('table_schema', $table_info['database']);
+ $condition->condition('table_name', $table_info['table'], $operator);
+ return $condition;
+ }
+
+ /**
+ * Generate SQL to create a new table from a Drupal schema definition.
+ *
+ * @param $name
+ * The name of the table to create.
+ * @param $table
+ * A Schema API table definition array.
+ * @return
+ * An array of SQL statements to create the table.
+ */
+ protected function createTableSql($name, $table) {
+ $info = $this->connection->getConnectionOptions();
+
+ // Provide defaults if needed.
+ $table += array(
+ 'mysql_engine' => 'InnoDB',
+ 'mysql_character_set' => 'utf8',
+ );
+
+ $sql = "CREATE TABLE {" . $name . "} (\n";
+
+ // Add the SQL statement for each field.
+ foreach ($table['fields'] as $field_name => $field) {
+ $sql .= $this->createFieldSql($field_name, $this->processField($field)) . ", \n";
+ }
+
+ // Process keys & indexes.
+ $keys = $this->createKeysSql($table);
+ if (count($keys)) {
+ $sql .= implode(", \n", $keys) . ", \n";
+ }
+
+ // Remove the last comma and space.
+ $sql = substr($sql, 0, -3) . "\n) ";
+
+ $sql .= 'ENGINE = ' . $table['mysql_engine'] . ' DEFAULT CHARACTER SET ' . $table['mysql_character_set'];
+ // By default, MySQL uses the default collation for new tables, which is
+ // 'utf8_general_ci' for utf8. If an alternate collation has been set, it
+ // needs to be explicitly specified.
+ // @see DatabaseConnection_mysql
+ if (!empty($info['collation'])) {
+ $sql .= ' COLLATE ' . $info['collation'];
+ }
+
+ // Add table comment.
+ if (!empty($table['description'])) {
+ $sql .= ' COMMENT ' . $this->prepareComment($table['description'], self::COMMENT_MAX_TABLE);
+ }
+
+ return array($sql);
+ }
+
+ /**
+ * Create an SQL string for a field to be used in table creation or alteration.
+ *
+ * Before passing a field out of a schema definition into this function it has
+ * to be processed by _db_process_field().
+ *
+ * @param $name
+ * Name of the field.
+ * @param $spec
+ * The field specification, as per the schema data structure format.
+ */
+ protected function createFieldSql($name, $spec) {
+ $sql = "`" . $name . "` " . $spec['mysql_type'];
+
+ if (in_array($spec['mysql_type'], array('VARCHAR', 'CHAR', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT', 'TEXT')) && isset($spec['length'])) {
+ $sql .= '(' . $spec['length'] . ')';
+ }
+ elseif (isset($spec['precision']) && isset($spec['scale'])) {
+ $sql .= '(' . $spec['precision'] . ', ' . $spec['scale'] . ')';
+ }
+
+ if (!empty($spec['unsigned'])) {
+ $sql .= ' unsigned';
+ }
+
+ if (isset($spec['not null'])) {
+ if ($spec['not null']) {
+ $sql .= ' NOT NULL';
+ }
+ else {
+ $sql .= ' NULL';
+ }
+ }
+
+ if (!empty($spec['auto_increment'])) {
+ $sql .= ' auto_increment';
+ }
+
+ // $spec['default'] can be NULL, so we explicitly check for the key here.
+ if (array_key_exists('default', $spec)) {
+ if (is_string($spec['default'])) {
+ $spec['default'] = "'" . $spec['default'] . "'";
+ }
+ elseif (!isset($spec['default'])) {
+ $spec['default'] = 'NULL';
+ }
+ $sql .= ' DEFAULT ' . $spec['default'];
+ }
+
+ if (empty($spec['not null']) && !isset($spec['default'])) {
+ $sql .= ' DEFAULT NULL';
+ }
+
+ // Add column comment.
+ if (!empty($spec['description'])) {
+ $sql .= ' COMMENT ' . $this->prepareComment($spec['description'], self::COMMENT_MAX_COLUMN);
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Set database-engine specific properties for a field.
+ *
+ * @param $field
+ * A field description array, as specified in the schema documentation.
+ */
+ protected function processField($field) {
+
+ if (!isset($field['size'])) {
+ $field['size'] = 'normal';
+ }
+
+ // Set the correct database-engine specific datatype.
+ // In case one is already provided, force it to uppercase.
+ if (isset($field['mysql_type'])) {
+ $field['mysql_type'] = drupal_strtoupper($field['mysql_type']);
+ }
+ else {
+ $map = $this->getFieldTypeMap();
+ $field['mysql_type'] = $map[$field['type'] . ':' . $field['size']];
+ }
+
+ if (isset($field['type']) && $field['type'] == 'serial') {
+ $field['auto_increment'] = TRUE;
+ }
+
+ return $field;
+ }
+
+ public function getFieldTypeMap() {
+ // Put :normal last so it gets preserved by array_flip. This makes
+ // it much easier for modules (such as schema.module) to map
+ // database types back into schema types.
+ // $map does not use drupal_static as its value never changes.
+ static $map = array(
+ 'varchar:normal' => 'VARCHAR',
+ 'char:normal' => 'CHAR',
+
+ 'text:tiny' => 'TINYTEXT',
+ 'text:small' => 'TINYTEXT',
+ 'text:medium' => 'MEDIUMTEXT',
+ 'text:big' => 'LONGTEXT',
+ 'text:normal' => 'TEXT',
+
+ 'serial:tiny' => 'TINYINT',
+ 'serial:small' => 'SMALLINT',
+ 'serial:medium' => 'MEDIUMINT',
+ 'serial:big' => 'BIGINT',
+ 'serial:normal' => 'INT',
+
+ 'int:tiny' => 'TINYINT',
+ 'int:small' => 'SMALLINT',
+ 'int:medium' => 'MEDIUMINT',
+ 'int:big' => 'BIGINT',
+ 'int:normal' => 'INT',
+
+ 'float:tiny' => 'FLOAT',
+ 'float:small' => 'FLOAT',
+ 'float:medium' => 'FLOAT',
+ 'float:big' => 'DOUBLE',
+ 'float:normal' => 'FLOAT',
+
+ 'numeric:normal' => 'DECIMAL',
+
+ 'blob:big' => 'LONGBLOB',
+ 'blob:normal' => 'BLOB',
+ );
+ return $map;
+ }
+
+ protected function createKeysSql($spec) {
+ $keys = array();
+
+ if (!empty($spec['primary key'])) {
+ $keys[] = 'PRIMARY KEY (' . $this->createKeysSqlHelper($spec['primary key']) . ')';
+ }
+ if (!empty($spec['unique keys'])) {
+ foreach ($spec['unique keys'] as $key => $fields) {
+ $keys[] = 'UNIQUE KEY `' . $key . '` (' . $this->createKeysSqlHelper($fields) . ')';
+ }
+ }
+ if (!empty($spec['indexes'])) {
+ foreach ($spec['indexes'] as $index => $fields) {
+ $keys[] = 'INDEX `' . $index . '` (' . $this->createKeysSqlHelper($fields) . ')';
+ }
+ }
+
+ return $keys;
+ }
+
+ protected function createKeySql($fields) {
+ $return = array();
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $return[] = '`' . $field[0] . '`(' . $field[1] . ')';
+ }
+ else {
+ $return[] = '`' . $field . '`';
+ }
+ }
+ return implode(', ', $return);
+ }
+
+ protected function createKeysSqlHelper($fields) {
+ $return = array();
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $return[] = '`' . $field[0] . '`(' . $field[1] . ')';
+ }
+ else {
+ $return[] = '`' . $field . '`';
+ }
+ }
+ return implode(', ', $return);
+ }
+
+ public function renameTable($table, $new_name) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot rename %table to %table_new: table %table doesn't exist.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+ if ($this->tableExists($new_name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename %table to %table_new: table %table_new already exists.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+
+ $info = $this->getPrefixInfo($new_name);
+ return $this->connection->query('ALTER TABLE {' . $table . '} RENAME TO `' . $info['table'] . '`');
+ }
+
+ public function dropTable($table) {
+ if (!$this->tableExists($table)) {
+ return FALSE;
+ }
+
+ $this->connection->query('DROP TABLE {' . $table . '}');
+ return TRUE;
+ }
+
+ public function addField($table, $field, $spec, $keys_new = array()) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add field %table.%field: table doesn't exist.", array('%field' => $field, '%table' => $table)));
+ }
+ if ($this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add field %table.%field: field already exists.", array('%field' => $field, '%table' => $table)));
+ }
+
+ $fixnull = FALSE;
+ if (!empty($spec['not null']) && !isset($spec['default'])) {
+ $fixnull = TRUE;
+ $spec['not null'] = FALSE;
+ }
+ $query = 'ALTER TABLE {' . $table . '} ADD ';
+ $query .= $this->createFieldSql($field, $this->processField($spec));
+ if ($keys_sql = $this->createKeysSql($keys_new)) {
+ $query .= ', ADD ' . implode(', ADD ', $keys_sql);
+ }
+ $this->connection->query($query);
+ if (isset($spec['initial'])) {
+ $this->connection->update($table)
+ ->fields(array($field => $spec['initial']))
+ ->execute();
+ }
+ if ($fixnull) {
+ $spec['not null'] = TRUE;
+ $this->changeField($table, $field, $field, $spec);
+ }
+ }
+
+ public function dropField($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP `' . $field . '`');
+ return TRUE;
+ }
+
+ public function fieldSetDefault($table, $field, $default) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot set default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ if (!isset($default)) {
+ $default = 'NULL';
+ }
+ else {
+ $default = is_string($default) ? "'$default'" : $default;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER COLUMN `' . $field . '` SET DEFAULT ' . $default);
+ }
+
+ public function fieldSetNoDefault($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot remove default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER COLUMN `' . $field . '` DROP DEFAULT');
+ }
+
+ public function indexExists($table, $name) {
+ // Returns one row for each column in the index. Result is string or FALSE.
+ // Details at http://dev.mysql.com/doc/refman/5.0/en/show-index.html
+ $row = $this->connection->query('SHOW INDEX FROM {' . $table . "} WHERE key_name = '$name'")->fetchAssoc();
+ return isset($row['key_name']);
+ }
+
+ public function addPrimaryKey($table, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add primary key to table %table: table doesn't exist.", array('%table' => $table)));
+ }
+ if ($this->indexExists($table, 'PRIMARY')) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add primary key to table %table: primary key already exists.", array('%table' => $table)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD PRIMARY KEY (' . $this->createKeySql($fields) . ')');
+ }
+
+ public function dropPrimaryKey($table) {
+ if (!$this->indexExists($table, 'PRIMARY')) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP PRIMARY KEY');
+ return TRUE;
+ }
+
+ public function addUniqueKey($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add unique key %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->indexExists($table, $name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add unique key %name to table %table: unique key already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD UNIQUE KEY `' . $name . '` (' . $this->createKeySql($fields) . ')');
+ }
+
+ public function dropUniqueKey($table, $name) {
+ if (!$this->indexExists($table, $name)) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP KEY `' . $name . '`');
+ return TRUE;
+ }
+
+ public function addIndex($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add index %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->indexExists($table, $name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add index %name to table %table: index already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD INDEX `' . $name . '` (' . $this->createKeySql($fields) . ')');
+ }
+
+ public function dropIndex($table, $name) {
+ if (!$this->indexExists($table, $name)) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP INDEX `' . $name . '`');
+ return TRUE;
+ }
+
+ public function changeField($table, $field, $field_new, $spec, $keys_new = array()) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot change the definition of field %table.%name: field doesn't exist.", array('%table' => $table, '%name' => $field)));
+ }
+ if (($field != $field_new) && $this->fieldExists($table, $field_new)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename field %table.%name to %name_new: target field already exists.", array('%table' => $table, '%name' => $field, '%name_new' => $field_new)));
+ }
+
+ $sql = 'ALTER TABLE {' . $table . '} CHANGE `' . $field . '` ' . $this->createFieldSql($field_new, $this->processField($spec));
+ if ($keys_sql = $this->createKeysSql($keys_new)) {
+ $sql .= ', ADD ' . implode(', ADD ', $keys_sql);
+ }
+ $this->connection->query($sql);
+ }
+
+ public function prepareComment($comment, $length = NULL) {
+ // Work around a bug in some versions of PDO, see http://bugs.php.net/bug.php?id=41125
+ $comment = str_replace("'", '’', $comment);
+
+ // Truncate comment to maximum comment length.
+ if (isset($length)) {
+ // Add table prefixes before truncating.
+ $comment = truncate_utf8($this->connection->prefixTables($comment), $length, TRUE, TRUE);
+ }
+
+ return $this->connection->quote($comment);
+ }
+
+ /**
+ * Retrieve a table or column comment.
+ */
+ public function getComment($table, $column = NULL) {
+ $condition = $this->buildTableNameCondition($table);
+ if (isset($column)) {
+ $condition->condition('column_name', $column);
+ $condition->compile($this->connection, $this);
+ // Don't use {} around information_schema.columns table.
+ return $this->connection->query("SELECT column_comment FROM information_schema.columns WHERE " . (string) $condition, $condition->arguments())->fetchField();
+ }
+ $condition->compile($this->connection, $this);
+ // Don't use {} around information_schema.tables table.
+ $comment = $this->connection->query("SELECT table_comment FROM information_schema.tables WHERE " . (string) $condition, $condition->arguments())->fetchField();
+ // Work-around for MySQL 5.0 bug http://bugs.mysql.com/bug.php?id=11379
+ return preg_replace('/; InnoDB free:.*$/', '', $comment);
+ }
+
+ public function tableExists($table) {
+ // The information_schema table is very slow to query under MySQL 5.0.
+ // Instead, we try to select from the table in question. If it fails,
+ // the most likely reason is that it does not exist. That is dramatically
+ // faster than using information_schema.
+ // @link http://bugs.mysql.com/bug.php?id=19588
+ // @todo: This override should be removed once we require a version of MySQL
+ // that has that bug fixed.
+ try {
+ $this->connection->queryRange("SELECT 1 FROM {" . $table . "}", 0, 1);
+ return TRUE;
+ }
+ catch (Exception $e) {
+ return FALSE;
+ }
+ }
+
+ public function fieldExists($table, $column) {
+ // The information_schema table is very slow to query under MySQL 5.0.
+ // Instead, we try to select from the table and field in question. If it
+ // fails, the most likely reason is that it does not exist. That is
+ // dramatically faster than using information_schema.
+ // @link http://bugs.mysql.com/bug.php?id=19588
+ // @todo: This override should be removed once we require a version of MySQL
+ // that has that bug fixed.
+ try {
+ $this->connection->queryRange("SELECT $column FROM {" . $table . "}", 0, 1);
+ return TRUE;
+ }
+ catch (Exception $e) {
+ return FALSE;
+ }
+ }
+
+}
+
+/**
+ * @} End of "ingroup schemaapi".
+ */
diff --git a/core/includes/database/pgsql/database.inc b/core/includes/database/pgsql/database.inc
new file mode 100644
index 000000000000..39b4e9b6960c
--- /dev/null
+++ b/core/includes/database/pgsql/database.inc
@@ -0,0 +1,203 @@
+<?php
+
+/**
+ * @file
+ * Database interface code for PostgreSQL database servers.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * The name by which to obtain a lock for retrive the next insert id.
+ */
+define('POSTGRESQL_NEXTID_LOCK', 1000);
+
+class DatabaseConnection_pgsql extends DatabaseConnection {
+
+ public function __construct(array $connection_options = array()) {
+ // This driver defaults to transaction support, except if explicitly passed FALSE.
+ $this->transactionSupport = !isset($connection_options['transactions']) || ($connection_options['transactions'] !== FALSE);
+
+ // Transactional DDL is always available in PostgreSQL,
+ // but we'll only enable it if standard transactions are.
+ $this->transactionalDDLSupport = $this->transactionSupport;
+
+ // Default to TCP connection on port 5432.
+ if (empty($connection_options['port'])) {
+ $connection_options['port'] = 5432;
+ }
+
+ // PostgreSQL in trust mode doesn't require a password to be supplied.
+ if (empty($connection_options['password'])) {
+ $connection_options['password'] = NULL;
+ }
+ // If the password contains a backslash it is treated as an escape character
+ // http://bugs.php.net/bug.php?id=53217
+ // so backslashes in the password need to be doubled up.
+ // The bug was reported against pdo_pgsql 1.0.2, backslashes in passwords
+ // will break on this doubling up when the bug is fixed, so check the version
+ //elseif (phpversion('pdo_pgsql') < 'version_this_was_fixed_in') {
+ else {
+ $connection_options['password'] = str_replace('\\', '\\\\', $connection_options['password']);
+ }
+
+ $this->connectionOptions = $connection_options;
+
+ $dsn = 'pgsql:host=' . $connection_options['host'] . ' dbname=' . $connection_options['database'] . ' port=' . $connection_options['port'];
+ parent::__construct($dsn, $connection_options['username'], $connection_options['password'], array(
+ // Prepared statements are most effective for performance when queries
+ // are recycled (used several times). However, if they are not re-used,
+ // prepared statements become ineffecient. Since most of Drupal's
+ // prepared queries are not re-used, it should be faster to emulate
+ // the preparation than to actually ready statements for re-use. If in
+ // doubt, reset to FALSE and measure performance.
+ PDO::ATTR_EMULATE_PREPARES => TRUE,
+ // Convert numeric values to strings when fetching.
+ PDO::ATTR_STRINGIFY_FETCHES => TRUE,
+ // Force column names to lower case.
+ PDO::ATTR_CASE => PDO::CASE_LOWER,
+ ));
+
+ // Force PostgreSQL to use the UTF-8 character set by default.
+ $this->exec("SET NAMES 'UTF8'");
+ }
+
+ public function query($query, array $args = array(), $options = array()) {
+
+ $options += $this->defaultOptions();
+
+ // The PDO PostgreSQL driver has a bug which
+ // doesn't type cast booleans correctly when
+ // parameters are bound using associative
+ // arrays.
+ // See http://bugs.php.net/bug.php?id=48383
+ foreach ($args as &$value) {
+ if (is_bool($value)) {
+ $value = (int) $value;
+ }
+ }
+
+ try {
+ if ($query instanceof DatabaseStatementInterface) {
+ $stmt = $query;
+ $stmt->execute(NULL, $options);
+ }
+ else {
+ $this->expandArguments($query, $args);
+ $stmt = $this->prepareQuery($query);
+ $stmt->execute($args, $options);
+ }
+
+ switch ($options['return']) {
+ case Database::RETURN_STATEMENT:
+ return $stmt;
+ case Database::RETURN_AFFECTED:
+ return $stmt->rowCount();
+ case Database::RETURN_INSERT_ID:
+ return $this->lastInsertId($options['sequence_name']);
+ case Database::RETURN_NULL:
+ return;
+ default:
+ throw new PDOException('Invalid return directive: ' . $options['return']);
+ }
+ }
+ catch (PDOException $e) {
+ if ($options['throw_exception']) {
+ // Add additional debug information.
+ if ($query instanceof DatabaseStatementInterface) {
+ $e->query_string = $stmt->getQueryString();
+ }
+ else {
+ $e->query_string = $query;
+ }
+ $e->args = $args;
+ throw $e;
+ }
+ return NULL;
+ }
+ }
+
+ public function queryRange($query, $from, $count, array $args = array(), array $options = array()) {
+ return $this->query($query . ' LIMIT ' . (int) $count . ' OFFSET ' . (int) $from, $args, $options);
+ }
+
+ public function queryTemporary($query, array $args = array(), array $options = array()) {
+ $tablename = $this->generateTemporaryTableName();
+ $this->query(preg_replace('/^SELECT/i', 'CREATE TEMPORARY TABLE {' . $tablename . '} AS SELECT', $query), $args, $options);
+ return $tablename;
+ }
+
+ public function driver() {
+ return 'pgsql';
+ }
+
+ public function databaseType() {
+ return 'pgsql';
+ }
+
+ public function mapConditionOperator($operator) {
+ static $specials;
+
+ // Function calls not allowed in static declarations, thus this method.
+ if (!isset($specials)) {
+ $specials = array(
+ // In PostgreSQL, 'LIKE' is case-sensitive. For case-insensitive LIKE
+ // statements, we need to use ILIKE instead.
+ 'LIKE' => array('operator' => 'ILIKE'),
+ 'NOT LIKE' => array('operator' => 'NOT ILIKE'),
+ );
+ }
+
+ return isset($specials[$operator]) ? $specials[$operator] : NULL;
+ }
+
+ /**
+ * Retrive a the next id in a sequence.
+ *
+ * PostgreSQL has built in sequences. We'll use these instead of inserting
+ * and updating a sequences table.
+ */
+ public function nextId($existing = 0) {
+
+ // Retrive the name of the sequence. This information cannot be cached
+ // because the prefix may change, for example, like it does in simpletests.
+ $sequence_name = $this->makeSequenceName('sequences', 'value');
+
+ // When PostgreSQL gets a value too small then it will lock the table,
+ // retry the INSERT and if it's still too small then alter the sequence.
+ $id = $this->query("SELECT nextval('" . $sequence_name . "')")->fetchField();
+ if ($id > $existing) {
+ return $id;
+ }
+
+ // PostgreSQL advisory locks are simply locks to be used by an
+ // application such as Drupal. This will prevent other Drupal proccesses
+ // from altering the sequence while we are.
+ $this->query("SELECT pg_advisory_lock(" . POSTGRESQL_NEXTID_LOCK . ")");
+
+ // While waiting to obtain the lock, the sequence may have been altered
+ // so lets try again to obtain an adequate value.
+ $id = $this->query("SELECT nextval('" . $sequence_name . "')")->fetchField();
+ if ($id > $existing) {
+ $this->query("SELECT pg_advisory_unlock(" . POSTGRESQL_NEXTID_LOCK . ")");
+ return $id;
+ }
+
+ // Reset the sequence to a higher value than the existing id.
+ $this->query("ALTER SEQUENCE " . $sequence_name . " RESTART WITH " . ($existing + 1));
+
+ // Retrive the next id. We know this will be as high as we want it.
+ $id = $this->query("SELECT nextval('" . $sequence_name . "')")->fetchField();
+
+ $this->query("SELECT pg_advisory_unlock(" . POSTGRESQL_NEXTID_LOCK . ")");
+
+ return $id;
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/pgsql/install.inc b/core/includes/database/pgsql/install.inc
new file mode 100644
index 000000000000..c350634ec400
--- /dev/null
+++ b/core/includes/database/pgsql/install.inc
@@ -0,0 +1,176 @@
+<?php
+
+/**
+ * @file
+ * Install functions for PostgreSQL embedded database engine.
+ */
+
+
+// PostgreSQL specific install functions
+
+class DatabaseTasks_pgsql extends DatabaseTasks {
+ protected $pdoDriver = 'pgsql';
+
+ public function __construct() {
+ $this->tasks[] = array(
+ 'function' => 'checkEncoding',
+ 'arguments' => array(),
+ );
+ $this->tasks[] = array(
+ 'function' => 'checkBinaryOutput',
+ 'arguments' => array(),
+ );
+ $this->tasks[] = array(
+ 'function' => 'initializeDatabase',
+ 'arguments' => array(),
+ );
+ }
+
+ public function name() {
+ return st('PostgreSQL');
+ }
+
+ public function minimumVersion() {
+ return '8.3';
+ }
+
+ /**
+ * Check encoding is UTF8.
+ */
+ protected function checkEncoding() {
+ try {
+ if (db_query('SHOW server_encoding')->fetchField() == 'UTF8') {
+ $this->pass(st('Database is encoded in UTF-8'));
+ }
+ else {
+ $replacements = array(
+ '%encoding' => 'UTF8',
+ '%driver' => $this->name(),
+ '!link' => '<a href="INSTALL.pgsql.txt">INSTALL.pgsql.txt</a>'
+ );
+ $text = 'The %driver database must use %encoding encoding to work with Drupal.';
+ $text .= 'Recreate the database with %encoding encoding. See !link for more details.';
+ $this->fail(st($text, $replacements));
+ }
+ }
+ catch (Exception $e) {
+ $this->fail(st('Drupal could not determine the encoding of the database was set to UTF-8'));
+ }
+ }
+
+ /**
+ * Check Binary Output.
+ *
+ * Unserializing does not work on Postgresql 9 when bytea_output is 'hex'.
+ */
+ function checkBinaryOutput() {
+ // PostgreSQL < 9 doesn't support bytea_output, so verify we are running
+ // at least PostgreSQL 9.
+ $database_connection = Database::getConnection();
+ if (version_compare($database_connection->version(), '9') >= 0) {
+ if (!$this->checkBinaryOutputSuccess()) {
+ // First try to alter the database. If it fails, raise an error telling
+ // the user to do it themselves.
+ $connection_options = $database_connection->getConnectionOptions();
+ // It is safe to include the database name directly here, because this
+ // code is only called when a connection to the database is already
+ // established, thus the database name is guaranteed to be a correct
+ // value.
+ $query = "ALTER DATABASE \"" . $connection_options['database'] . "\" SET bytea_output = 'escape';";
+ try {
+ db_query($query);
+ }
+ catch (Exception $e) {
+ // Ignore possible errors when the user doesn't have the necessary
+ // privileges to ALTER the database.
+ }
+
+ // Close the database connection so that the configuration parameter
+ // is applied to the current connection.
+ db_close();
+
+ // Recheck, if it fails, finally just rely on the end user to do the
+ // right thing.
+ if (!$this->checkBinaryOutputSuccess()) {
+ $replacements = array(
+ '%setting' => 'bytea_output',
+ '%current_value' => 'hex',
+ '%needed_value' => 'escape',
+ '!query' => "<code>" . $query . "</code>",
+ );
+ $this->fail(st("The %setting setting is currently set to '%current_value', but needs to be '%needed_value'. Change this by running the following query: !query", $replacements));
+ }
+ }
+ }
+ }
+
+ /**
+ * Verify that a binary data roundtrip returns the original string.
+ */
+ protected function checkBinaryOutputSuccess() {
+ $bytea_output = db_query("SELECT 'encoding'::bytea AS output")->fetchField();
+ return ($bytea_output == 'encoding');
+ }
+
+ /**
+ * Make PostgreSQL Drupal friendly.
+ */
+ function initializeDatabase() {
+ // We create some functions using global names instead of prefixing them
+ // like we do with table names. This is so that we don't double up if more
+ // than one instance of Drupal is running on a single database. We therefore
+ // avoid trying to create them again in that case.
+
+ try {
+ // Create functions.
+ db_query('CREATE OR REPLACE FUNCTION "greatest"(numeric, numeric) RETURNS numeric AS
+ \'SELECT CASE WHEN (($1 > $2) OR ($2 IS NULL)) THEN $1 ELSE $2 END;\'
+ LANGUAGE \'sql\''
+ );
+ db_query('CREATE OR REPLACE FUNCTION "greatest"(numeric, numeric, numeric) RETURNS numeric AS
+ \'SELECT greatest($1, greatest($2, $3));\'
+ LANGUAGE \'sql\''
+ );
+ // Don't use {} around pg_proc table.
+ if (!db_query("SELECT COUNT(*) FROM pg_proc WHERE proname = 'rand'")->fetchField()) {
+ db_query('CREATE OR REPLACE FUNCTION "rand"() RETURNS float AS
+ \'SELECT random();\'
+ LANGUAGE \'sql\''
+ );
+ }
+
+ db_query('CREATE OR REPLACE FUNCTION "substring_index"(text, text, integer) RETURNS text AS
+ \'SELECT array_to_string((string_to_array($1, $2)) [1:$3], $2);\'
+ LANGUAGE \'sql\''
+ );
+
+ // Using || to concatenate in Drupal is not recommeneded because there are
+ // database drivers for Drupal that do not support the syntax, however
+ // they do support CONCAT(item1, item2) which we can replicate in
+ // PostgreSQL. PostgreSQL requires the function to be defined for each
+ // different argument variation the function can handle.
+ db_query('CREATE OR REPLACE FUNCTION "concat"(anynonarray, anynonarray) RETURNS text AS
+ \'SELECT CAST($1 AS text) || CAST($2 AS text);\'
+ LANGUAGE \'sql\'
+ ');
+ db_query('CREATE OR REPLACE FUNCTION "concat"(text, anynonarray) RETURNS text AS
+ \'SELECT $1 || CAST($2 AS text);\'
+ LANGUAGE \'sql\'
+ ');
+ db_query('CREATE OR REPLACE FUNCTION "concat"(anynonarray, text) RETURNS text AS
+ \'SELECT CAST($1 AS text) || $2;\'
+ LANGUAGE \'sql\'
+ ');
+ db_query('CREATE OR REPLACE FUNCTION "concat"(text, text) RETURNS text AS
+ \'SELECT $1 || $2;\'
+ LANGUAGE \'sql\'
+ ');
+
+ $this->pass(st('PostgreSQL has initialized itself.'));
+ }
+ catch (Exception $e) {
+ $this->fail(st('Drupal could not be correctly setup with the existing database. Revise any errors.'));
+ }
+ }
+}
+
diff --git a/core/includes/database/pgsql/query.inc b/core/includes/database/pgsql/query.inc
new file mode 100644
index 000000000000..f3783a9ca8f9
--- /dev/null
+++ b/core/includes/database/pgsql/query.inc
@@ -0,0 +1,209 @@
+<?php
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * @file
+ * Query code for PostgreSQL embedded database engine.
+ */
+
+
+class InsertQuery_pgsql extends InsertQuery {
+
+ public function execute() {
+ if (!$this->preExecute()) {
+ return NULL;
+ }
+
+ $stmt = $this->connection->prepareQuery((string) $this);
+
+ // Fetch the list of blobs and sequences used on that table.
+ $table_information = $this->connection->schema()->queryTableInformation($this->table);
+
+ $max_placeholder = 0;
+ $blobs = array();
+ $blob_count = 0;
+ foreach ($this->insertValues as $insert_values) {
+ foreach ($this->insertFields as $idx => $field) {
+ if (isset($table_information->blob_fields[$field])) {
+ $blobs[$blob_count] = fopen('php://memory', 'a');
+ fwrite($blobs[$blob_count], $insert_values[$idx]);
+ rewind($blobs[$blob_count]);
+
+ $stmt->bindParam(':db_insert_placeholder_' . $max_placeholder++, $blobs[$blob_count], PDO::PARAM_LOB);
+
+ // Pre-increment is faster in PHP than increment.
+ ++$blob_count;
+ }
+ else {
+ $stmt->bindParam(':db_insert_placeholder_' . $max_placeholder++, $insert_values[$idx]);
+ }
+ }
+ // Check if values for a serial field has been passed.
+ if (!empty($table_information->serial_fields)) {
+ foreach ($table_information->serial_fields as $index => $serial_field) {
+ $serial_key = array_search($serial_field, $this->insertFields);
+ if ($serial_key !== FALSE) {
+ $serial_value = $insert_values[$serial_key];
+
+ // Force $last_insert_id to the specified value. This is only done
+ // if $index is 0.
+ if ($index == 0) {
+ $last_insert_id = $serial_value;
+ }
+ // Set the sequence to the bigger value of either the passed
+ // value or the max value of the column. It can happen that another
+ // thread calls nextval() which could lead to a serial number being
+ // used twice. However, trying to insert a value into a serial
+ // column should only be done in very rare cases and is not thread
+ // safe by definition.
+ $this->connection->query("SELECT setval('" . $table_information->sequences[$index] . "', GREATEST(MAX(" . $serial_field . "), :serial_value)) FROM {" . $this->table . "}", array(':serial_value' => (int)$serial_value));
+ }
+ }
+ }
+ }
+ if (!empty($this->fromQuery)) {
+ // bindParam stores only a reference to the variable that is followed when
+ // the statement is executed. We pass $arguments[$key] instead of $value
+ // because the second argument to bindParam is passed by reference and
+ // the foreach statement assigns the element to the existing reference.
+ $arguments = $this->fromQuery->getArguments();
+ foreach ($arguments as $key => $value) {
+ $stmt->bindParam($key, $arguments[$key]);
+ }
+ }
+
+ // PostgreSQL requires the table name to be specified explicitly
+ // when requesting the last insert ID, so we pass that in via
+ // the options array.
+ $options = $this->queryOptions;
+
+ if (!empty($table_information->sequences)) {
+ $options['sequence_name'] = $table_information->sequences[0];
+ }
+ // If there are no sequences then we can't get a last insert id.
+ elseif ($options['return'] == Database::RETURN_INSERT_ID) {
+ $options['return'] = Database::RETURN_NULL;
+ }
+ // Only use the returned last_insert_id if it is not already set.
+ if (!empty($last_insert_id)) {
+ $this->connection->query($stmt, array(), $options);
+ }
+ else {
+ $last_insert_id = $this->connection->query($stmt, array(), $options);
+ }
+
+ // Re-initialize the values array so that we can re-use this query.
+ $this->insertValues = array();
+
+ return $last_insert_id;
+ }
+
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // Default fields are always placed first for consistency.
+ $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+
+ // If we're selecting from a SelectQuery, finish building the query and
+ // pass it back, as any remaining options are irrelevant.
+ if (!empty($this->fromQuery)) {
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') ' . $this->fromQuery;
+ }
+
+ $query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
+
+ $max_placeholder = 0;
+ $values = array();
+ if (count($this->insertValues)) {
+ foreach ($this->insertValues as $insert_values) {
+ $placeholders = array();
+
+ // Default fields aren't really placeholders, but this is the most convenient
+ // way to handle them.
+ $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
+
+ $new_placeholder = $max_placeholder + count($insert_values);
+ for ($i = $max_placeholder; $i < $new_placeholder; ++$i) {
+ $placeholders[] = ':db_insert_placeholder_' . $i;
+ }
+ $max_placeholder = $new_placeholder;
+ $values[] = '(' . implode(', ', $placeholders) . ')';
+ }
+ }
+ else {
+ // If there are no values, then this is a default-only query. We still need to handle that.
+ $placeholders = array_fill(0, count($this->defaultFields), 'default');
+ $values[] = '(' . implode(', ', $placeholders) . ')';
+ }
+
+ $query .= implode(', ', $values);
+
+ return $query;
+ }
+}
+
+class UpdateQuery_pgsql extends UpdateQuery {
+ public function execute() {
+ $max_placeholder = 0;
+ $blobs = array();
+ $blob_count = 0;
+
+ // Because we filter $fields the same way here and in __toString(), the
+ // placeholders will all match up properly.
+ $stmt = $this->connection->prepareQuery((string) $this);
+
+ // Fetch the list of blobs and sequences used on that table.
+ $table_information = $this->connection->schema()->queryTableInformation($this->table);
+
+ // Expressions take priority over literal fields, so we process those first
+ // and remove any literal fields that conflict.
+ $fields = $this->fields;
+ $expression_fields = array();
+ foreach ($this->expressionFields as $field => $data) {
+ if (!empty($data['arguments'])) {
+ foreach ($data['arguments'] as $placeholder => $argument) {
+ // We assume that an expression will never happen on a BLOB field,
+ // which is a fairly safe assumption to make since in most cases
+ // it would be an invalid query anyway.
+ $stmt->bindParam($placeholder, $data['arguments'][$placeholder]);
+ }
+ }
+ unset($fields[$field]);
+ }
+
+ foreach ($fields as $field => $value) {
+ $placeholder = ':db_update_placeholder_' . ($max_placeholder++);
+
+ if (isset($table_information->blob_fields[$field])) {
+ $blobs[$blob_count] = fopen('php://memory', 'a');
+ fwrite($blobs[$blob_count], $value);
+ rewind($blobs[$blob_count]);
+ $stmt->bindParam($placeholder, $blobs[$blob_count], PDO::PARAM_LOB);
+ ++$blob_count;
+ }
+ else {
+ $stmt->bindParam($placeholder, $fields[$field]);
+ }
+ }
+
+ if (count($this->condition)) {
+ $this->condition->compile($this->connection, $this);
+
+ $arguments = $this->condition->arguments();
+ foreach ($arguments as $placeholder => $value) {
+ $stmt->bindParam($placeholder, $arguments[$placeholder]);
+ }
+ }
+
+ $options = $this->queryOptions;
+ $options['already_prepared'] = TRUE;
+ $this->connection->query($stmt, $options);
+
+ return $stmt->rowCount();
+ }
+}
diff --git a/core/includes/database/pgsql/schema.inc b/core/includes/database/pgsql/schema.inc
new file mode 100644
index 000000000000..9ed8a2620327
--- /dev/null
+++ b/core/includes/database/pgsql/schema.inc
@@ -0,0 +1,617 @@
+<?php
+
+/**
+ * @file
+ * Database schema code for PostgreSQL database servers.
+ */
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+class DatabaseSchema_pgsql extends DatabaseSchema {
+
+ /**
+ * A cache of information about blob columns and sequences of tables.
+ *
+ * This is collected by DatabaseConnection_pgsql->queryTableInformation(),
+ * by introspecting the database.
+ *
+ * @see DatabaseConnection_pgsql->queryTableInformation()
+ * @var array
+ */
+ protected $tableInformation = array();
+
+ /**
+ * Fetch the list of blobs and sequences used on a table.
+ *
+ * We introspect the database to collect the information required by insert
+ * and update queries.
+ *
+ * @param $table_name
+ * The non-prefixed name of the table.
+ * @return
+ * An object with two member variables:
+ * - 'blob_fields' that lists all the blob fields in the table.
+ * - 'sequences' that lists the sequences used in that table.
+ */
+ public function queryTableInformation($table) {
+ // Generate a key to reference this table's information on.
+ $key = $this->connection->prefixTables('{' . $table . '}');
+ if (!strpos($key, '.')) {
+ $key = 'public.' . $key;
+ }
+
+ if (!isset($this->tableInformation[$key])) {
+ // Split the key into schema and table for querying.
+ list($schema, $table_name) = explode('.', $key);
+ $table_information = (object) array(
+ 'blob_fields' => array(),
+ 'sequences' => array(),
+ );
+ // Don't use {} around information_schema.columns table.
+ $result = $this->connection->query("SELECT column_name, data_type, column_default FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table AND (data_type = 'bytea' OR (numeric_precision IS NOT NULL AND column_default LIKE :default))", array(
+ ':schema' => $schema,
+ ':table' => $table_name,
+ ':default' => '%nextval%',
+ ));
+ foreach ($result as $column) {
+ if ($column->data_type == 'bytea') {
+ $table_information->blob_fields[$column->column_name] = TRUE;
+ }
+ elseif (preg_match("/nextval\('([^']+)'/", $column->column_default, $matches)) {
+ // We must know of any sequences in the table structure to help us
+ // return the last insert id. If there is more than 1 sequences the
+ // first one (index 0 of the sequences array) will be used.
+ $table_information->sequences[] = $matches[1];
+ $table_information->serial_fields[] = $column->column_name;
+ }
+ }
+ $this->tableInformation[$key] = $table_information;
+ }
+ return $this->tableInformation[$key];
+ }
+
+ /**
+ * Fetch the list of CHECK constraints used on a field.
+ *
+ * We introspect the database to collect the information required by field
+ * alteration.
+ *
+ * @param $table
+ * The non-prefixed name of the table.
+ * @param $field
+ * The name of the field.
+ * @return
+ * An array of all the checks for the field.
+ */
+ public function queryFieldInformation($table, $field) {
+ $prefixInfo = $this->getPrefixInfo($table, TRUE);
+
+ // Split the key into schema and table for querying.
+ $schema = $prefixInfo['schema'];
+ $table_name = $prefixInfo['table'];
+
+ $field_information = (object) array(
+ 'checks' => array(),
+ );
+ $checks = $this->connection->query("SELECT conname FROM pg_class cl INNER JOIN pg_constraint co ON co.conrelid = cl.oid INNER JOIN pg_attribute attr ON attr.attrelid = cl.oid AND attr.attnum = ANY (co.conkey) INNER JOIN pg_namespace ns ON cl.relnamespace = ns.oid WHERE co.contype = 'c' AND ns.nspname = :schema AND cl.relname = :table AND attr.attname = :column", array(
+ ':schema' => $schema,
+ ':table' => $table_name,
+ ':column' => $field,
+ ));
+ $field_information = $checks->fetchCol();
+
+ return $field_information;
+ }
+
+ /**
+ * Generate SQL to create a new table from a Drupal schema definition.
+ *
+ * @param $name
+ * The name of the table to create.
+ * @param $table
+ * A Schema API table definition array.
+ * @return
+ * An array of SQL statements to create the table.
+ */
+ protected function createTableSql($name, $table) {
+ $sql_fields = array();
+ foreach ($table['fields'] as $field_name => $field) {
+ $sql_fields[] = $this->createFieldSql($field_name, $this->processField($field));
+ }
+
+ $sql_keys = array();
+ if (isset($table['primary key']) && is_array($table['primary key'])) {
+ $sql_keys[] = 'PRIMARY KEY (' . implode(', ', $table['primary key']) . ')';
+ }
+ if (isset($table['unique keys']) && is_array($table['unique keys'])) {
+ foreach ($table['unique keys'] as $key_name => $key) {
+ $sql_keys[] = 'CONSTRAINT ' . $this->prefixNonTable($name, $key_name, 'key') . ' UNIQUE (' . implode(', ', $key) . ')';
+ }
+ }
+
+ $sql = "CREATE TABLE {" . $name . "} (\n\t";
+ $sql .= implode(",\n\t", $sql_fields);
+ if (count($sql_keys) > 0) {
+ $sql .= ",\n\t";
+ }
+ $sql .= implode(",\n\t", $sql_keys);
+ $sql .= "\n)";
+ $statements[] = $sql;
+
+ if (isset($table['indexes']) && is_array($table['indexes'])) {
+ foreach ($table['indexes'] as $key_name => $key) {
+ $statements[] = $this->_createIndexSql($name, $key_name, $key);
+ }
+ }
+
+ // Add table comment.
+ if (!empty($table['description'])) {
+ $statements[] = 'COMMENT ON TABLE {' . $name . '} IS ' . $this->prepareComment($table['description']);
+ }
+
+ // Add column comments.
+ foreach ($table['fields'] as $field_name => $field) {
+ if (!empty($field['description'])) {
+ $statements[] = 'COMMENT ON COLUMN {' . $name . '}.' . $field_name . ' IS ' . $this->prepareComment($field['description']);
+ }
+ }
+
+ return $statements;
+ }
+
+ /**
+ * Create an SQL string for a field to be used in table creation or
+ * alteration.
+ *
+ * Before passing a field out of a schema definition into this
+ * function it has to be processed by _db_process_field().
+ *
+ * @param $name
+ * Name of the field.
+ * @param $spec
+ * The field specification, as per the schema data structure format.
+ */
+ protected function createFieldSql($name, $spec) {
+ $sql = $name . ' ' . $spec['pgsql_type'];
+
+ if (isset($spec['type']) && $spec['type'] == 'serial') {
+ unset($spec['not null']);
+ }
+
+ if (in_array($spec['pgsql_type'], array('varchar', 'character', 'text')) && isset($spec['length'])) {
+ $sql .= '(' . $spec['length'] . ')';
+ }
+ elseif (isset($spec['precision']) && isset($spec['scale'])) {
+ $sql .= '(' . $spec['precision'] . ', ' . $spec['scale'] . ')';
+ }
+
+ if (!empty($spec['unsigned'])) {
+ $sql .= " CHECK ($name >= 0)";
+ }
+
+ if (isset($spec['not null'])) {
+ if ($spec['not null']) {
+ $sql .= ' NOT NULL';
+ }
+ else {
+ $sql .= ' NULL';
+ }
+ }
+ if (isset($spec['default'])) {
+ $default = is_string($spec['default']) ? "'" . $spec['default'] . "'" : $spec['default'];
+ $sql .= " default $default";
+ }
+
+ return $sql;
+ }
+
+ /**
+ * Set database-engine specific properties for a field.
+ *
+ * @param $field
+ * A field description array, as specified in the schema documentation.
+ */
+ protected function processField($field) {
+ if (!isset($field['size'])) {
+ $field['size'] = 'normal';
+ }
+
+ // Set the correct database-engine specific datatype.
+ // In case one is already provided, force it to lowercase.
+ if (isset($field['pgsql_type'])) {
+ $field['pgsql_type'] = drupal_strtolower($field['pgsql_type']);
+ }
+ else {
+ $map = $this->getFieldTypeMap();
+ $field['pgsql_type'] = $map[$field['type'] . ':' . $field['size']];
+ }
+
+ if (!empty($field['unsigned'])) {
+ // Unsigned datatypes are not supported in PostgreSQL 8.3. In MySQL,
+ // they are used to ensure a positive number is inserted and it also
+ // doubles the maximum integer size that can be stored in a field.
+ // The PostgreSQL schema in Drupal creates a check constraint
+ // to ensure that a value inserted is >= 0. To provide the extra
+ // integer capacity, here, we bump up the column field size.
+ if (!isset($map)) {
+ $map = $this->getFieldTypeMap();
+ }
+ switch ($field['pgsql_type']) {
+ case 'smallint':
+ $field['pgsql_type'] = $map['int:medium'];
+ break;
+ case 'int' :
+ $field['pgsql_type'] = $map['int:big'];
+ break;
+ }
+ }
+ if (isset($field['type']) && $field['type'] == 'serial') {
+ unset($field['not null']);
+ }
+ return $field;
+ }
+
+ /**
+ * This maps a generic data type in combination with its data size
+ * to the engine-specific data type.
+ */
+ function getFieldTypeMap() {
+ // Put :normal last so it gets preserved by array_flip. This makes
+ // it much easier for modules (such as schema.module) to map
+ // database types back into schema types.
+ // $map does not use drupal_static as its value never changes.
+ static $map = array(
+ 'varchar:normal' => 'varchar',
+ 'char:normal' => 'character',
+
+ 'text:tiny' => 'text',
+ 'text:small' => 'text',
+ 'text:medium' => 'text',
+ 'text:big' => 'text',
+ 'text:normal' => 'text',
+
+ 'int:tiny' => 'smallint',
+ 'int:small' => 'smallint',
+ 'int:medium' => 'int',
+ 'int:big' => 'bigint',
+ 'int:normal' => 'int',
+
+ 'float:tiny' => 'real',
+ 'float:small' => 'real',
+ 'float:medium' => 'real',
+ 'float:big' => 'double precision',
+ 'float:normal' => 'real',
+
+ 'numeric:normal' => 'numeric',
+
+ 'blob:big' => 'bytea',
+ 'blob:normal' => 'bytea',
+
+ 'serial:tiny' => 'serial',
+ 'serial:small' => 'serial',
+ 'serial:medium' => 'serial',
+ 'serial:big' => 'bigserial',
+ 'serial:normal' => 'serial',
+ );
+ return $map;
+ }
+
+ protected function _createKeySql($fields) {
+ $return = array();
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $return[] = 'substr(' . $field[0] . ', 1, ' . $field[1] . ')';
+ }
+ else {
+ $return[] = '"' . $field . '"';
+ }
+ }
+ return implode(', ', $return);
+ }
+
+ function renameTable($table, $new_name) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot rename %table to %table_new: table %table doesn't exist.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+ if ($this->tableExists($new_name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename %table to %table_new: table %table_new already exists.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+
+ // Get the schema and tablename for the old table.
+ $old_full_name = $this->connection->prefixTables('{' . $table . '}');
+ list($old_schema, $old_table_name) = strpos($old_full_name, '.') ? explode('.', $old_full_name) : array('public', $old_full_name);
+
+ // Index names and constraint names are global in PostgreSQL, so we need to
+ // rename them when renaming the table.
+ $indexes = $this->connection->query('SELECT indexname FROM pg_indexes WHERE schemaname = :schema AND tablename = :table', array(':schema' => $old_schema, ':table' => $old_table_name));
+ foreach ($indexes as $index) {
+ if (preg_match('/^' . preg_quote($old_full_name) . '_(.*)_idx$/', $index->indexname, $matches)) {
+ $index_name = $matches[1];
+ $this->connection->query('ALTER INDEX ' . $index->indexname . ' RENAME TO {' . $new_name . '}_' . $index_name . '_idx');
+ }
+ }
+
+ // Now rename the table.
+ // Ensure the new table name does not include schema syntax.
+ $prefixInfo = $this->getPrefixInfo($new_name);
+ $this->connection->query('ALTER TABLE {' . $table . '} RENAME TO ' . $prefixInfo['table']);
+ }
+
+ public function dropTable($table) {
+ if (!$this->tableExists($table)) {
+ return FALSE;
+ }
+
+ $this->connection->query('DROP TABLE {' . $table . '}');
+ return TRUE;
+ }
+
+ public function addField($table, $field, $spec, $new_keys = array()) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add field %table.%field: table doesn't exist.", array('%field' => $field, '%table' => $table)));
+ }
+ if ($this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add field %table.%field: field already exists.", array('%field' => $field, '%table' => $table)));
+ }
+
+ $fixnull = FALSE;
+ if (!empty($spec['not null']) && !isset($spec['default'])) {
+ $fixnull = TRUE;
+ $spec['not null'] = FALSE;
+ }
+ $query = 'ALTER TABLE {' . $table . '} ADD COLUMN ';
+ $query .= $this->createFieldSql($field, $this->processField($spec));
+ $this->connection->query($query);
+ if (isset($spec['initial'])) {
+ $this->connection->update($table)
+ ->fields(array($field => $spec['initial']))
+ ->execute();
+ }
+ if ($fixnull) {
+ $this->connection->query("ALTER TABLE {" . $table . "} ALTER $field SET NOT NULL");
+ }
+ if (isset($new_keys)) {
+ $this->_createKeys($table, $new_keys);
+ }
+ // Add column comment.
+ if (!empty($spec['description'])) {
+ $this->connection->query('COMMENT ON COLUMN {' . $table . '}.' . $field . ' IS ' . $this->prepareComment($spec['description']));
+ }
+ }
+
+ public function dropField($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP COLUMN "' . $field . '"');
+ return TRUE;
+ }
+
+ public function fieldSetDefault($table, $field, $default) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot set default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ if (!isset($default)) {
+ $default = 'NULL';
+ }
+ else {
+ $default = is_string($default) ? "'$default'" : $default;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER COLUMN "' . $field . '" SET DEFAULT ' . $default);
+ }
+
+ public function fieldSetNoDefault($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot remove default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER COLUMN "' . $field . '" DROP DEFAULT');
+ }
+
+ public function indexExists($table, $name) {
+ // Details http://www.postgresql.org/docs/8.3/interactive/view-pg-indexes.html
+ $index_name = '{' . $table . '}_' . $name . '_idx';
+ return (bool) $this->connection->query("SELECT 1 FROM pg_indexes WHERE indexname = '$index_name'")->fetchField();
+ }
+
+ /**
+ * Helper function: check if a constraint (PK, FK, UK) exists.
+ *
+ * @param $table
+ * The name of the table.
+ * @param $name
+ * The name of the constraint (typically 'pkey' or '[constraint]_key').
+ */
+ protected function constraintExists($table, $name) {
+ $constraint_name = '{' . $table . '}_' . $name;
+ return (bool) $this->connection->query("SELECT 1 FROM pg_constraint WHERE conname = '$constraint_name'")->fetchField();
+ }
+
+ public function addPrimaryKey($table, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add primary key to table %table: table doesn't exist.", array('%table' => $table)));
+ }
+ if ($this->constraintExists($table, 'pkey')) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add primary key to table %table: primary key already exists.", array('%table' => $table)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD PRIMARY KEY (' . implode(',', $fields) . ')');
+ }
+
+ public function dropPrimaryKey($table) {
+ if (!$this->constraintExists($table, 'pkey')) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP CONSTRAINT ' . $this->prefixNonTable($table, 'pkey'));
+ return TRUE;
+ }
+
+ function addUniqueKey($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add unique key %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->constraintExists($table, $name . '_key')) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add unique key %name to table %table: unique key already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD CONSTRAINT "' . $this->prefixNonTable($table, $name, 'key') . '" UNIQUE (' . implode(',', $fields) . ')');
+ }
+
+ public function dropUniqueKey($table, $name) {
+ if (!$this->constraintExists($table, $name . '_key')) {
+ return FALSE;
+ }
+
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP CONSTRAINT "' . $this->prefixNonTable($table, $name, 'key') . '"');
+ return TRUE;
+ }
+
+ public function addIndex($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add index %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->indexExists($table, $name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add index %name to table %table: index already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $this->connection->query($this->_createIndexSql($table, $name, $fields));
+ }
+
+ public function dropIndex($table, $name) {
+ if (!$this->indexExists($table, $name)) {
+ return FALSE;
+ }
+
+ $this->connection->query('DROP INDEX ' . $this->prefixNonTable($table, $name, 'idx'));
+ return TRUE;
+ }
+
+ public function changeField($table, $field, $field_new, $spec, $new_keys = array()) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot change the definition of field %table.%name: field doesn't exist.", array('%table' => $table, '%name' => $field)));
+ }
+ if (($field != $field_new) && $this->fieldExists($table, $field_new)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename field %table.%name to %name_new: target field already exists.", array('%table' => $table, '%name' => $field, '%name_new' => $field_new)));
+ }
+
+ $spec = $this->processField($spec);
+
+ // We need to typecast the new column to best be able to transfer the data
+ // Schema_pgsql::getFieldTypeMap() will return possibilities that are not
+ // 'cast-able' such as 'serial' - so they need to be casted int instead.
+ if (in_array($spec['pgsql_type'], array('serial', 'bigserial', 'numeric'))) {
+ $typecast = 'int';
+ }
+ else {
+ $typecast = $spec['pgsql_type'];
+ }
+
+ if (in_array($spec['pgsql_type'], array('varchar', 'character', 'text')) && isset($spec['length'])) {
+ $typecast .= '(' . $spec['length'] . ')';
+ }
+ elseif (isset($spec['precision']) && isset($spec['scale'])) {
+ $typecast .= '(' . $spec['precision'] . ', ' . $spec['scale'] . ')';
+ }
+
+ // Remove old check constraints.
+ $field_info = $this->queryFieldInformation($table, $field);
+
+ foreach ($field_info as $check) {
+ $this->connection->query('ALTER TABLE {' . $table . '} DROP CONSTRAINT "' . $check . '"');
+ }
+
+ // Remove old default.
+ $this->fieldSetNoDefault($table, $field);
+
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER "' . $field . '" TYPE ' . $typecast . ' USING "' . $field . '"::' . $typecast);
+
+ if (isset($spec['not null'])) {
+ if ($spec['not null']) {
+ $nullaction = 'SET NOT NULL';
+ }
+ else {
+ $nullaction = 'DROP NOT NULL';
+ }
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER "' . $field . '" ' . $nullaction);
+ }
+
+ if (in_array($spec['pgsql_type'], array('serial', 'bigserial'))) {
+ // Type "serial" is known to PostgreSQL, but *only* during table creation,
+ // not when altering. Because of that, the sequence needs to be created
+ // and initialized by hand.
+ $seq = "{" . $table . "}_" . $field_new . "_seq";
+ $this->connection->query("CREATE SEQUENCE " . $seq);
+ // Set sequence to maximal field value to not conflict with existing
+ // entries.
+ $this->connection->query("SELECT setval('" . $seq . "', MAX(\"" . $field . '")) FROM {' . $table . "}");
+ $this->connection->query('ALTER TABLE {' . $table . '} ALTER "' . $field . '" SET DEFAULT nextval(\'' . $seq . '\')');
+ }
+
+ // Rename the column if necessary.
+ if ($field != $field_new) {
+ $this->connection->query('ALTER TABLE {' . $table . '} RENAME "' . $field . '" TO "' . $field_new . '"');
+ }
+
+ // Add unsigned check if necessary.
+ if (!empty($spec['unsigned'])) {
+ $this->connection->query('ALTER TABLE {' . $table . '} ADD CHECK ("' . $field_new . '" >= 0)');
+ }
+
+ // Add default if necessary.
+ if (isset($spec['default'])) {
+ $this->fieldSetDefault($table, $field_new, $spec['default']);
+ }
+
+ // Change description if necessary.
+ if (!empty($spec['description'])) {
+ $this->connection->query('COMMENT ON COLUMN {' . $table . '}."' . $field_new . '" IS ' . $this->prepareComment($spec['description']));
+ }
+
+ if (isset($new_keys)) {
+ $this->_createKeys($table, $new_keys);
+ }
+ }
+
+ protected function _createIndexSql($table, $name, $fields) {
+ $query = 'CREATE INDEX "' . $this->prefixNonTable($table, $name, 'idx') . '" ON {' . $table . '} (';
+ $query .= $this->_createKeySql($fields) . ')';
+ return $query;
+ }
+
+ protected function _createKeys($table, $new_keys) {
+ if (isset($new_keys['primary key'])) {
+ $this->addPrimaryKey($table, $new_keys['primary key']);
+ }
+ if (isset($new_keys['unique keys'])) {
+ foreach ($new_keys['unique keys'] as $name => $fields) {
+ $this->addUniqueKey($table, $name, $fields);
+ }
+ }
+ if (isset($new_keys['indexes'])) {
+ foreach ($new_keys['indexes'] as $name => $fields) {
+ $this->addIndex($table, $name, $fields);
+ }
+ }
+ }
+
+ /**
+ * Retrieve a table or column comment.
+ */
+ public function getComment($table, $column = NULL) {
+ $info = $this->getPrefixInfo($table);
+ // Don't use {} around pg_class, pg_attribute tables.
+ if (isset($column)) {
+ return $this->connection->query('SELECT col_description(oid, attnum) FROM pg_class, pg_attribute WHERE attrelid = oid AND relname = ? AND attname = ?', array($info['table'], $column))->fetchField();
+ }
+ else {
+ return $this->connection->query('SELECT obj_description(oid, ?) FROM pg_class WHERE relname = ?', array('pg_class', $info['table']))->fetchField();
+ }
+ }
+}
diff --git a/core/includes/database/pgsql/select.inc b/core/includes/database/pgsql/select.inc
new file mode 100644
index 000000000000..d1d83828118d
--- /dev/null
+++ b/core/includes/database/pgsql/select.inc
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * @file
+ * Select builder for PostgreSQL database engine.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+class SelectQuery_pgsql extends SelectQuery {
+
+ public function orderRandom() {
+ $alias = $this->addExpression('RANDOM()', 'random_field');
+ $this->orderBy($alias);
+ return $this;
+ }
+
+ /**
+ * Overrides SelectQuery::orderBy().
+ *
+ * PostgreSQL adheres strictly to the SQL-92 standard and requires that when
+ * using DISTINCT or GROUP BY conditions, fields and expressions that are
+ * ordered on also need to be selected. This is a best effort implementation
+ * to handle the cases that can be automated by adding the field if it is not
+ * yet selected.
+ *
+ * @code
+ * $query = db_select('node', 'n');
+ * $query->join('node_revision', 'nr', 'n.vid = nr.vid');
+ * $query
+ * ->distinct()
+ * ->fields('n')
+ * ->orderBy('timestamp');
+ * @endcode
+ *
+ * In this query, it is not possible (without relying on the schema) to know
+ * whether timestamp belongs to node_revisions and needs to be added or
+ * belongs to node and is already selected. Queries like this will need to be
+ * corrected in the original query by adding an explicit call to
+ * SelectQuery::addField() or SelectQuery::fields().
+ *
+ * Since this has a small performance impact, both by the additional
+ * processing in this function and in the database that needs to return the
+ * additional fields, this is done as an override instead of implementing it
+ * directly in SelectQuery::orderBy().
+ */
+ public function orderBy($field, $direction = 'ASC') {
+ // Call parent function to order on this.
+ $return = parent::orderBy($field, $direction);
+
+ // If there is a table alias specified, split it up.
+ if (strpos($field, '.') !== FALSE) {
+ list($table, $table_field) = explode('.', $field);
+ }
+ // Figure out if the field has already been added.
+ foreach ($this->fields as $existing_field) {
+ if (!empty($table)) {
+ // If table alias is given, check if field and table exists.
+ if ($existing_field['table'] == $table && $existing_field['field'] == $table_field) {
+ return $return;
+ }
+ }
+ else {
+ // If there is no table, simply check if the field exists as a field or
+ // an aliased field.
+ if ($existing_field['alias'] == $field) {
+ return $return;
+ }
+ }
+ }
+
+ // Also check expression aliases.
+ foreach ($this->expressions as $expression) {
+ if ($expression['alias'] == $field) {
+ return $return;
+ }
+ }
+
+ // If a table loads all fields, it can not be added again. It would
+ // result in an ambigious alias error because that field would be loaded
+ // twice: Once through table_alias.* and once directly. If the field
+ // actually belongs to a different table, it must be added manually.
+ foreach ($this->tables as $table) {
+ if (!empty($table['all_fields'])) {
+ return $return;
+ }
+ }
+
+ // If $field contains an characters which are not allowed in a field name
+ // it is considered an expression, these can't be handeld automatically
+ // either.
+ if ($this->connection->escapeField($field) != $field) {
+ return $return;
+ }
+
+ // This is a case that can be handled automatically, add the field.
+ $this->addField(NULL, $field);
+ return $return;
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
+
diff --git a/core/includes/database/prefetch.inc b/core/includes/database/prefetch.inc
new file mode 100644
index 000000000000..4f2b19d1f3d1
--- /dev/null
+++ b/core/includes/database/prefetch.inc
@@ -0,0 +1,507 @@
+<?php
+
+/**
+ * @file
+ * Database interface code for engines that need complete control over their
+ * result sets. For example, SQLite will prefix some column names by the name
+ * of the table. We post-process the data, by renaming the column names
+ * using the same convention as MySQL and PostgreSQL.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * An implementation of DatabaseStatementInterface that prefetches all data.
+ *
+ * This class behaves very similar to a PDOStatement but as it always fetches
+ * every row it is possible to manipulate those results.
+ */
+class DatabaseStatementPrefetch implements Iterator, DatabaseStatementInterface {
+
+ /**
+ * The query string.
+ *
+ * @var string
+ */
+ protected $queryString;
+
+ /**
+ * Driver-specific options. Can be used by child classes.
+ *
+ * @var Array
+ */
+ protected $driverOptions;
+
+ /**
+ * Reference to the database connection object for this statement.
+ *
+ * The name $dbh is inherited from PDOStatement.
+ *
+ * @var DatabaseConnection
+ */
+ public $dbh;
+
+ /**
+ * Main data store.
+ *
+ * @var Array
+ */
+ protected $data = array();
+
+ /**
+ * The current row, retrieved in PDO::FETCH_ASSOC format.
+ *
+ * @var Array
+ */
+ protected $currentRow = NULL;
+
+ /**
+ * The key of the current row.
+ *
+ * @var int
+ */
+ protected $currentKey = NULL;
+
+ /**
+ * The list of column names in this result set.
+ *
+ * @var Array
+ */
+ protected $columnNames = NULL;
+
+ /**
+ * The number of rows affected by the last query.
+ *
+ * @var int
+ */
+ protected $rowCount = NULL;
+
+ /**
+ * The number of rows in this result set.
+ *
+ * @var int
+ */
+ protected $resultRowCount = 0;
+
+ /**
+ * Holds the current fetch style (which will be used by the next fetch).
+ * @see PDOStatement::fetch()
+ *
+ * @var int
+ */
+ protected $fetchStyle = PDO::FETCH_OBJ;
+
+ /**
+ * Holds supplementary current fetch options (which will be used by the next fetch).
+ *
+ * @var Array
+ */
+ protected $fetchOptions = array(
+ 'class' => 'stdClass',
+ 'constructor_args' => array(),
+ 'object' => NULL,
+ 'column' => 0,
+ );
+
+ /**
+ * Holds the default fetch style.
+ *
+ * @var int
+ */
+ protected $defaultFetchStyle = PDO::FETCH_OBJ;
+
+ /**
+ * Holds supplementary default fetch options.
+ *
+ * @var Array
+ */
+ protected $defaultFetchOptions = array(
+ 'class' => 'stdClass',
+ 'constructor_args' => array(),
+ 'object' => NULL,
+ 'column' => 0,
+ );
+
+ public function __construct(DatabaseConnection $connection, $query, array $driver_options = array()) {
+ $this->dbh = $connection;
+ $this->queryString = $query;
+ $this->driverOptions = $driver_options;
+ }
+
+ /**
+ * Executes a prepared statement.
+ *
+ * @param $args
+ * An array of values with as many elements as there are bound parameters in the SQL statement being executed.
+ * @param $options
+ * An array of options for this query.
+ * @return
+ * TRUE on success, or FALSE on failure.
+ */
+ public function execute($args = array(), $options = array()) {
+ if (isset($options['fetch'])) {
+ if (is_string($options['fetch'])) {
+ // Default to an object. Note: db fields will be added to the object
+ // before the constructor is run. If you need to assign fields after
+ // the constructor is run, see http://drupal.org/node/315092.
+ $this->setFetchMode(PDO::FETCH_CLASS, $options['fetch']);
+ }
+ else {
+ $this->setFetchMode($options['fetch']);
+ }
+ }
+
+ $logger = $this->dbh->getLogger();
+ if (!empty($logger)) {
+ $query_start = microtime(TRUE);
+ }
+
+ // Prepare the query.
+ $statement = $this->getStatement($this->queryString, $args);
+ if (!$statement) {
+ $this->throwPDOException();
+ }
+
+ $return = $statement->execute($args);
+ if (!$return) {
+ $this->throwPDOException();
+ }
+
+ // Fetch all the data from the reply, in order to release any lock
+ // as soon as possible.
+ $this->rowCount = $statement->rowCount();
+ $this->data = $statement->fetchAll(PDO::FETCH_ASSOC);
+ // Destroy the statement as soon as possible. See
+ // DatabaseConnection_sqlite::PDOPrepare() for explanation.
+ unset($statement);
+
+ $this->resultRowCount = count($this->data);
+
+ if ($this->resultRowCount) {
+ $this->columnNames = array_keys($this->data[0]);
+ }
+ else {
+ $this->columnNames = array();
+ }
+
+ if (!empty($logger)) {
+ $query_end = microtime(TRUE);
+ $logger->log($this, $args, $query_end - $query_start);
+ }
+
+ // Initialize the first row in $this->currentRow.
+ $this->next();
+
+ return $return;
+ }
+
+ /**
+ * Throw a PDO Exception based on the last PDO error.
+ */
+ protected function throwPDOException() {
+ $error_info = $this->dbh->errorInfo();
+ // We rebuild a message formatted in the same way as PDO.
+ $exception = new PDOException("SQLSTATE[" . $error_info[0] . "]: General error " . $error_info[1] . ": " . $error_info[2]);
+ $exception->errorInfo = $error_info;
+ throw $exception;
+ }
+
+ /**
+ * Grab a PDOStatement object from a given query and its arguments.
+ *
+ * Some drivers (including SQLite) will need to perform some preparation
+ * themselves to get the statement right.
+ *
+ * @param $query
+ * The query.
+ * @param array $args
+ * An array of arguments.
+ * @return PDOStatement
+ * A PDOStatement object.
+ */
+ protected function getStatement($query, &$args = array()) {
+ return $this->dbh->prepare($query);
+ }
+
+ /**
+ * Return the object's SQL query string.
+ */
+ public function getQueryString() {
+ return $this->queryString;
+ }
+
+ /**
+ * @see PDOStatement::setFetchMode()
+ */
+ public function setFetchMode($fetchStyle, $a2 = NULL, $a3 = NULL) {
+ $this->defaultFetchStyle = $fetchStyle;
+ switch ($fetchStyle) {
+ case PDO::FETCH_CLASS:
+ $this->defaultFetchOptions['class'] = $a2;
+ if ($a3) {
+ $this->defaultFetchOptions['constructor_args'] = $a3;
+ }
+ break;
+ case PDO::FETCH_COLUMN:
+ $this->defaultFetchOptions['column'] = $a2;
+ break;
+ case PDO::FETCH_INTO:
+ $this->defaultFetchOptions['object'] = $a2;
+ break;
+ }
+
+ // Set the values for the next fetch.
+ $this->fetchStyle = $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ }
+
+ /**
+ * Return the current row formatted according to the current fetch style.
+ *
+ * This is the core method of this class. It grabs the value at the current
+ * array position in $this->data and format it according to $this->fetchStyle
+ * and $this->fetchMode.
+ *
+ * @return
+ * The current row formatted as requested.
+ */
+ public function current() {
+ if (isset($this->currentRow)) {
+ switch ($this->fetchStyle) {
+ case PDO::FETCH_ASSOC:
+ return $this->currentRow;
+ case PDO::FETCH_BOTH:
+ // PDO::FETCH_BOTH returns an array indexed by both the column name
+ // and the column number.
+ return $this->currentRow + array_values($this->currentRow);
+ case PDO::FETCH_NUM:
+ return array_values($this->currentRow);
+ case PDO::FETCH_LAZY:
+ // We do not do lazy as everything is fetched already. Fallback to
+ // PDO::FETCH_OBJ.
+ case PDO::FETCH_OBJ:
+ return (object) $this->currentRow;
+ case PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE:
+ $class_name = array_unshift($this->currentRow);
+ // Deliberate no break.
+ case PDO::FETCH_CLASS:
+ if (!isset($class_name)) {
+ $class_name = $this->fetchOptions['class'];
+ }
+ if (count($this->fetchOptions['constructor_args'])) {
+ $reflector = new ReflectionClass($class_name);
+ $result = $reflector->newInstanceArgs($this->fetchOptions['constructor_args']);
+ }
+ else {
+ $result = new $class_name();
+ }
+ foreach ($this->currentRow as $k => $v) {
+ $result->$k = $v;
+ }
+ return $result;
+ case PDO::FETCH_INTO:
+ foreach ($this->currentRow as $k => $v) {
+ $this->fetchOptions['object']->$k = $v;
+ }
+ return $this->fetchOptions['object'];
+ case PDO::FETCH_COLUMN:
+ if (isset($this->columnNames[$this->fetchOptions['column']])) {
+ return $this->currentRow[$k][$this->columnNames[$this->fetchOptions['column']]];
+ }
+ else {
+ return;
+ }
+ }
+ }
+ }
+
+ /* Implementations of Iterator. */
+
+ public function key() {
+ return $this->currentKey;
+ }
+
+ public function rewind() {
+ // Nothing to do: our DatabaseStatement can't be rewound.
+ }
+
+ public function next() {
+ if (!empty($this->data)) {
+ $this->currentRow = reset($this->data);
+ $this->currentKey = key($this->data);
+ unset($this->data[$this->currentKey]);
+ }
+ else {
+ $this->currentRow = NULL;
+ }
+ }
+
+ public function valid() {
+ return isset($this->currentRow);
+ }
+
+ /* Implementations of DatabaseStatementInterface. */
+
+ public function rowCount() {
+ return $this->rowCount;
+ }
+
+ public function fetch($fetch_style = NULL, $cursor_orientation = PDO::FETCH_ORI_NEXT, $cursor_offset = NULL) {
+ if (isset($this->currentRow)) {
+ // Set the fetch parameter.
+ $this->fetchStyle = isset($fetch_style) ? $fetch_style : $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+
+ // Grab the row in the format specified above.
+ $return = $this->current();
+ // Advance the cursor.
+ $this->next();
+
+ // Reset the fetch parameters to the value stored using setFetchMode().
+ $this->fetchStyle = $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ return $return;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ public function fetchColumn($index = 0) {
+ if (isset($this->currentRow) && isset($this->columnNames[$index])) {
+ // We grab the value directly from $this->data, and format it.
+ $return = $this->currentRow[$this->columnNames[$index]];
+ $this->next();
+ return $return;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ public function fetchField($index = 0) {
+ return $this->fetchColumn($index);
+ }
+
+ public function fetchObject($class_name = NULL, $constructor_args = array()) {
+ if (isset($this->currentRow)) {
+ if (!isset($class_name)) {
+ // Directly cast to an object to avoid a function call.
+ $result = (object) $this->currentRow;
+ }
+ else {
+ $this->fetchStyle = PDO::FETCH_CLASS;
+ $this->fetchOptions = array('constructor_args' => $constructor_args);
+ // Grab the row in the format specified above.
+ $result = $this->current();
+ // Reset the fetch parameters to the value stored using setFetchMode().
+ $this->fetchStyle = $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ }
+
+ $this->next();
+
+ return $result;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ public function fetchAssoc() {
+ if (isset($this->currentRow)) {
+ $result = $this->currentRow;
+ $this->next();
+ return $result;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ public function fetchAll($fetch_style = NULL, $fetch_column = NULL, $constructor_args = NULL) {
+ $this->fetchStyle = isset($fetch_style) ? $fetch_style : $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ if (isset($fetch_column)) {
+ $this->fetchOptions['column'] = $fetch_column;
+ }
+ if (isset($constructor_args)) {
+ $this->fetchOptions['constructor_args'] = $constructor_args;
+ }
+
+ $result = array();
+ // Traverse the array as PHP would have done.
+ while (isset($this->currentRow)) {
+ // Grab the row in the format specified above.
+ $result[] = $this->current();
+ $this->next();
+ }
+
+ // Reset the fetch parameters to the value stored using setFetchMode().
+ $this->fetchStyle = $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ return $result;
+ }
+
+ public function fetchCol($index = 0) {
+ if (isset($this->columnNames[$index])) {
+ $column = $this->columnNames[$index];
+ $result = array();
+ // Traverse the array as PHP would have done.
+ while (isset($this->currentRow)) {
+ $result[] = $this->currentRow[$this->columnNames[$index]];
+ $this->next();
+ }
+ return $result;
+ }
+ else {
+ return array();
+ }
+ }
+
+ public function fetchAllKeyed($key_index = 0, $value_index = 1) {
+ if (!isset($this->columnNames[$key_index]) || !isset($this->columnNames[$value_index]))
+ return array();
+
+ $key = $this->columnNames[$key_index];
+ $value = $this->columnNames[$value_index];
+
+ $result = array();
+ // Traverse the array as PHP would have done.
+ while (isset($this->currentRow)) {
+ $result[$this->currentRow[$key]] = $this->currentRow[$value];
+ $this->next();
+ }
+ return $result;
+ }
+
+ public function fetchAllAssoc($key, $fetch_style = NULL) {
+ $this->fetchStyle = isset($fetch_style) ? $fetch_style : $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+
+ $result = array();
+ // Traverse the array as PHP would have done.
+ while (isset($this->currentRow)) {
+ // Grab the row in its raw PDO::FETCH_ASSOC format.
+ $row = $this->currentRow;
+ // Grab the row in the format specified above.
+ $result_row = $this->current();
+ $result[$this->currentRow[$key]] = $result_row;
+ $this->next();
+ }
+
+ // Reset the fetch parameters to the value stored using setFetchMode().
+ $this->fetchStyle = $this->defaultFetchStyle;
+ $this->fetchOptions = $this->defaultFetchOptions;
+ return $result;
+ }
+
+}
+
+/**
+ * @} End of "ingroup database".
+ */
+
diff --git a/core/includes/database/query.inc b/core/includes/database/query.inc
new file mode 100644
index 000000000000..c779687679a7
--- /dev/null
+++ b/core/includes/database/query.inc
@@ -0,0 +1,1953 @@
+<?php
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * @file
+ * Non-specific Database query code. Used by all engines.
+ */
+
+/**
+ * Interface for a conditional clause in a query.
+ */
+interface QueryConditionInterface {
+
+ /**
+ * Helper function: builds the most common conditional clauses.
+ *
+ * This method can take a variable number of parameters. If called with two
+ * parameters, they are taken as $field and $value with $operator having a
+ * value of IN if $value is an array and = otherwise.
+ *
+ * @param $field
+ * The name of the field to check. If you would like to add a more complex
+ * condition involving operators or functions, use where().
+ * @param $value
+ * The value to test the field against. In most cases, this is a scalar.
+ * For more complex options, it is an array. The meaning of each element in
+ * the array is dependent on the $operator.
+ * @param $operator
+ * The comparison operator, such as =, <, or >=. It also accepts more
+ * complex options such as IN, LIKE, or BETWEEN. Defaults to IN if $value is
+ * an array, and = otherwise.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function condition($field, $value = NULL, $operator = NULL);
+
+ /**
+ * Adds an arbitrary WHERE clause to the query.
+ *
+ * @param $snippet
+ * A portion of a WHERE clause as a prepared statement. It must use named
+ * placeholders, not ? placeholders.
+ * @param $args
+ * An associative array of arguments.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function where($snippet, $args = array());
+
+ /**
+ * Sets a condition that the specified field be NULL.
+ *
+ * @param $field
+ * The name of the field to check.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function isNull($field);
+
+ /**
+ * Sets a condition that the specified field be NOT NULL.
+ *
+ * @param $field
+ * The name of the field to check.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function isNotNull($field);
+
+ /**
+ * Sets a condition that the specified subquery returns values.
+ *
+ * @param SelectQueryInterface $select
+ * The subquery that must contain results.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function exists(SelectQueryInterface $select);
+
+ /**
+ * Sets a condition that the specified subquery returns no values.
+ *
+ * @param SelectQueryInterface $select
+ * The subquery that must not contain results.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function notExists(SelectQueryInterface $select);
+
+ /**
+ * Gets a complete list of all conditions in this conditional clause.
+ *
+ * This method returns by reference. That allows alter hooks to access the
+ * data structure directly and manipulate it before it gets compiled.
+ *
+ * The data structure that is returned is an indexed array of entries, where
+ * each entry looks like the following:
+ * @code
+ * array(
+ * 'field' => $field,
+ * 'value' => $value,
+ * 'operator' => $operator,
+ * );
+ * @endcode
+ *
+ * In the special case that $operator is NULL, the $field is taken as a raw
+ * SQL snippet (possibly containing a function) and $value is an associative
+ * array of placeholders for the snippet.
+ *
+ * There will also be a single array entry of #conjunction, which is the
+ * conjunction that will be applied to the array, such as AND.
+ */
+ public function &conditions();
+
+ /**
+ * Gets a complete list of all values to insert into the prepared statement.
+ *
+ * @return
+ * An associative array of placeholders and values.
+ */
+ public function arguments();
+
+ /**
+ * Compiles the saved conditions for later retrieval.
+ *
+ * This method does not return anything, but simply prepares data to be
+ * retrieved via __toString() and arguments().
+ *
+ * @param $connection
+ * The database connection for which to compile the conditionals.
+ * @param $queryPlaceholder
+ * The query this condition belongs to. If not given, the current query is
+ * used.
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder);
+
+ /**
+ * Check whether a condition has been previously compiled.
+ *
+ * @return
+ * TRUE if the condition has been previously compiled.
+ */
+ public function compiled();
+}
+
+
+/**
+ * Interface for a query that can be manipulated via an alter hook.
+ */
+interface QueryAlterableInterface {
+
+ /**
+ * Adds a tag to a query.
+ *
+ * Tags are strings that identify a query. A query may have any number of
+ * tags. Tags are used to mark a query so that alter hooks may decide if they
+ * wish to take action. Tags should be all lower-case and contain only
+ * letters, numbers, and underscore, and start with a letter. That is, they
+ * should follow the same rules as PHP identifiers in general.
+ *
+ * @param $tag
+ * The tag to add.
+ *
+ * @return QueryAlterableInterface
+ * The called object.
+ */
+ public function addTag($tag);
+
+ /**
+ * Determines if a given query has a given tag.
+ *
+ * @param $tag
+ * The tag to check.
+ *
+ * @return
+ * TRUE if this query has been marked with this tag, FALSE otherwise.
+ */
+ public function hasTag($tag);
+
+ /**
+ * Determines if a given query has all specified tags.
+ *
+ * @param $tags
+ * A variable number of arguments, one for each tag to check.
+ *
+ * @return
+ * TRUE if this query has been marked with all specified tags, FALSE
+ * otherwise.
+ */
+ public function hasAllTags();
+
+ /**
+ * Determines if a given query has any specified tag.
+ *
+ * @param $tags
+ * A variable number of arguments, one for each tag to check.
+ *
+ * @return
+ * TRUE if this query has been marked with at least one of the specified
+ * tags, FALSE otherwise.
+ */
+ public function hasAnyTag();
+
+ /**
+ * Adds additional metadata to the query.
+ *
+ * Often, a query may need to provide additional contextual data to alter
+ * hooks. Alter hooks may then use that information to decide if and how
+ * to take action.
+ *
+ * @param $key
+ * The unique identifier for this piece of metadata. Must be a string that
+ * follows the same rules as any other PHP identifier.
+ * @param $object
+ * The additional data to add to the query. May be any valid PHP variable.
+ *
+ * @return QueryAlterableInterface
+ * The called object.
+ */
+ public function addMetaData($key, $object);
+
+ /**
+ * Retrieves a given piece of metadata.
+ *
+ * @param $key
+ * The unique identifier for the piece of metadata to retrieve.
+ *
+ * @return
+ * The previously attached metadata object, or NULL if one doesn't exist.
+ */
+ public function getMetaData($key);
+}
+
+/**
+ * Interface for a query that accepts placeholders.
+ */
+interface QueryPlaceholderInterface {
+
+ /**
+ * Returns a unique identifier for this object.
+ */
+ public function uniqueIdentifier();
+
+ /**
+ * Returns the next placeholder ID for the query.
+ *
+ * @return
+ * The next available placeholder ID as an integer.
+ */
+ public function nextPlaceholder();
+}
+
+/**
+ * Base class for query builders.
+ *
+ * Note that query builders use PHP's magic __toString() method to compile the
+ * query object into a prepared statement.
+ */
+abstract class Query implements QueryPlaceholderInterface {
+
+ /**
+ * The connection object on which to run this query.
+ *
+ * @var DatabaseConnection
+ */
+ protected $connection;
+
+ /**
+ * The target of the connection object.
+ *
+ * @var string
+ */
+ protected $connectionTarget;
+
+ /**
+ * The key of the connection object.
+ *
+ * @var string
+ */
+ protected $connectionKey;
+
+ /**
+ * The query options to pass on to the connection object.
+ *
+ * @var array
+ */
+ protected $queryOptions;
+
+ /**
+ * A unique identifier for this query object.
+ */
+ protected $uniqueIdentifier;
+
+ /**
+ * The placeholder counter.
+ */
+ protected $nextPlaceholder = 0;
+
+ /**
+ * An array of comments that can be prepended to a query.
+ *
+ * @var array
+ */
+ protected $comments = array();
+
+ /**
+ * Constructs a Query object.
+ *
+ * @param DatabaseConnection $connection
+ * Database connection object.
+ * @param array $options
+ * Array of query options.
+ */
+ public function __construct(DatabaseConnection $connection, $options) {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+
+ $this->connection = $connection;
+ $this->connectionKey = $this->connection->getKey();
+ $this->connectionTarget = $this->connection->getTarget();
+
+ $this->queryOptions = $options;
+ }
+
+ /**
+ * Implements the magic __sleep function to disconnect from the database.
+ */
+ public function __sleep() {
+ $keys = get_object_vars($this);
+ unset($keys['connection']);
+ return array_keys($keys);
+ }
+
+ /**
+ * Implements the magic __wakeup function to reconnect to the database.
+ */
+ public function __wakeup() {
+ $this->connection = Database::getConnection($this->connectionTarget, $this->connectionKey);
+ }
+
+ /**
+ * Implements the magic __clone function.
+ */
+ public function __clone() {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+ }
+
+ /**
+ * Runs the query against the database.
+ */
+ abstract protected function execute();
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * The toString operation is how we compile a query object to a prepared
+ * statement.
+ *
+ * @return
+ * A prepared statement query string for this object.
+ */
+ abstract public function __toString();
+
+ /**
+ * Returns a unique identifier for this object.
+ */
+ public function uniqueIdentifier() {
+ return $this->uniqueIdentifier;
+ }
+
+ /**
+ * Gets the next placeholder value for this query object.
+ *
+ * @return int
+ * Next placeholder value.
+ */
+ public function nextPlaceholder() {
+ return $this->nextPlaceholder++;
+ }
+
+ /**
+ * Adds a comment to the query.
+ *
+ * By adding a comment to a query, you can more easily find it in your
+ * query log or the list of active queries on an SQL server. This allows
+ * for easier debugging and allows you to more easily find where a query
+ * with a performance problem is being generated.
+ *
+ * The comment string will be sanitized to remove * / and other characters
+ * that may terminate the string early so as to avoid SQL injection attacks.
+ *
+ * @param $comment
+ * The comment string to be inserted into the query.
+ *
+ * @return Query
+ * The called object.
+ */
+ public function comment($comment) {
+ $this->comments[] = $comment;
+ return $this;
+ }
+
+ /**
+ * Returns a reference to the comments array for the query.
+ *
+ * Because this method returns by reference, alter hooks may edit the comments
+ * array directly to make their changes. If just adding comments, however, the
+ * use of comment() is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ * @code
+ * $comments =& $query->getComments();
+ * @endcode
+ *
+ * @return
+ * A reference to the comments array structure.
+ */
+ public function &getComments() {
+ return $this->comments;
+ }
+}
+
+/**
+ * General class for an abstracted INSERT query.
+ */
+class InsertQuery extends Query {
+
+ /**
+ * The table on which to insert.
+ *
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * An array of fields on which to insert.
+ *
+ * @var array
+ */
+ protected $insertFields = array();
+
+ /**
+ * An array of fields that should be set to their database-defined defaults.
+ *
+ * @var array
+ */
+ protected $defaultFields = array();
+
+ /**
+ * A nested array of values to insert.
+ *
+ * $insertValues is an array of arrays. Each sub-array is either an
+ * associative array whose keys are field names and whose values are field
+ * values to insert, or a non-associative array of values in the same order
+ * as $insertFields.
+ *
+ * Whether multiple insert sets will be run in a single query or multiple
+ * queries is left to individual drivers to implement in whatever manner is
+ * most appropriate. The order of values in each sub-array must match the
+ * order of fields in $insertFields.
+ *
+ * @var array
+ */
+ protected $insertValues = array();
+
+ /**
+ * A SelectQuery object to fetch the rows that should be inserted.
+ *
+ * @var SelectQueryInterface
+ */
+ protected $fromQuery;
+
+ /**
+ * Constructs an InsertQuery object.
+ *
+ * @param DatabaseConnection $connection
+ * A DatabaseConnection object.
+ * @param string $table
+ * Name of the table to associate with this query.
+ * @param array $options
+ * Array of database options.
+ */
+ public function __construct($connection, $table, array $options = array()) {
+ if (!isset($options['return'])) {
+ $options['return'] = Database::RETURN_INSERT_ID;
+ }
+ parent::__construct($connection, $options);
+ $this->table = $table;
+ }
+
+ /**
+ * Adds a set of field->value pairs to be inserted.
+ *
+ * This method may only be called once. Calling it a second time will be
+ * ignored. To queue up multiple sets of values to be inserted at once,
+ * use the values() method.
+ *
+ * @param $fields
+ * An array of fields on which to insert. This array may be indexed or
+ * associative. If indexed, the array is taken to be the list of fields.
+ * If associative, the keys of the array are taken to be the fields and
+ * the values are taken to be corresponding values to insert. If a
+ * $values argument is provided, $fields must be indexed.
+ * @param $values
+ * An array of fields to insert into the database. The values must be
+ * specified in the same order as the $fields array.
+ *
+ * @return InsertQuery
+ * The called object.
+ */
+ public function fields(array $fields, array $values = array()) {
+ if (empty($this->insertFields)) {
+ if (empty($values)) {
+ if (!is_numeric(key($fields))) {
+ $values = array_values($fields);
+ $fields = array_keys($fields);
+ }
+ }
+ $this->insertFields = $fields;
+ if (!empty($values)) {
+ $this->insertValues[] = $values;
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Adds another set of values to the query to be inserted.
+ *
+ * If $values is a numeric-keyed array, it will be assumed to be in the same
+ * order as the original fields() call. If it is associative, it may be
+ * in any order as long as the keys of the array match the names of the
+ * fields.
+ *
+ * @param $values
+ * An array of values to add to the query.
+ *
+ * @return InsertQuery
+ * The called object.
+ */
+ public function values(array $values) {
+ if (is_numeric(key($values))) {
+ $this->insertValues[] = $values;
+ }
+ else {
+ // Reorder the submitted values to match the fields array.
+ foreach ($this->insertFields as $key) {
+ $insert_values[$key] = $values[$key];
+ }
+ // For consistency, the values array is always numerically indexed.
+ $this->insertValues[] = array_values($insert_values);
+ }
+ return $this;
+ }
+
+ /**
+ * Specifies fields for which the database defaults should be used.
+ *
+ * If you want to force a given field to use the database-defined default,
+ * not NULL or undefined, use this method to instruct the database to use
+ * default values explicitly. In most cases this will not be necessary
+ * unless you are inserting a row that is all default values, as you cannot
+ * specify no values in an INSERT query.
+ *
+ * Specifying a field both in fields() and in useDefaults() is an error
+ * and will not execute.
+ *
+ * @param $fields
+ * An array of values for which to use the default values
+ * specified in the table definition.
+ *
+ * @return InsertQuery
+ * The called object.
+ */
+ public function useDefaults(array $fields) {
+ $this->defaultFields = $fields;
+ return $this;
+ }
+
+ /**
+ * Sets the fromQuery on this InsertQuery object.
+ *
+ * @param SelectQueryInterface $query
+ * The query to fetch the rows that should be inserted.
+ *
+ * @return InsertQuery
+ * The called object.
+ */
+ public function from(SelectQueryInterface $query) {
+ $this->fromQuery = $query;
+ return $this;
+ }
+
+ /**
+ * Executes the insert query.
+ *
+ * @return
+ * The last insert ID of the query, if one exists. If the query
+ * was given multiple sets of values to insert, the return value is
+ * undefined. If no fields are specified, this method will do nothing and
+ * return NULL. That makes it safe to use in multi-insert loops.
+ */
+ public function execute() {
+ // If validation fails, simply return NULL. Note that validation routines
+ // in preExecute() may throw exceptions instead.
+ if (!$this->preExecute()) {
+ return NULL;
+ }
+
+ // If we're selecting from a SelectQuery, finish building the query and
+ // pass it back, as any remaining options are irrelevant.
+ if (!empty($this->fromQuery)) {
+ $sql = (string) $this;
+ // The SelectQuery may contain arguments, load and pass them through.
+ return $this->connection->query($sql, $this->fromQuery->getArguments(), $this->queryOptions);
+ }
+
+ $last_insert_id = 0;
+
+ // Each insert happens in its own query in the degenerate case. However,
+ // we wrap it in a transaction so that it is atomic where possible. On many
+ // databases, such as SQLite, this is also a notable performance boost.
+ $transaction = $this->connection->startTransaction();
+
+ try {
+ $sql = (string) $this;
+ foreach ($this->insertValues as $insert_values) {
+ $last_insert_id = $this->connection->query($sql, $insert_values, $this->queryOptions);
+ }
+ }
+ catch (Exception $e) {
+ // One of the INSERTs failed, rollback the whole batch.
+ $transaction->rollback();
+ // Rethrow the exception for the calling code.
+ throw $e;
+ }
+
+ // Re-initialize the values array so that we can re-use this query.
+ $this->insertValues = array();
+
+ // Transaction commits here where $transaction looses scope.
+
+ return $last_insert_id;
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * @return string
+ * The prepared statement.
+ */
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // Default fields are always placed first for consistency.
+ $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+
+ if (!empty($this->fromQuery)) {
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') ' . $this->fromQuery;
+ }
+
+ // For simplicity, we will use the $placeholders array to inject
+ // default keywords even though they are not, strictly speaking,
+ // placeholders for prepared statements.
+ $placeholders = array();
+ $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
+ $placeholders = array_pad($placeholders, count($this->insertFields), '?');
+
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES (' . implode(', ', $placeholders) . ')';
+ }
+
+ /**
+ * Preprocesses and validates the query.
+ *
+ * @return
+ * TRUE if the validation was successful, FALSE if not.
+ *
+ * @throws FieldsOverlapException
+ * @throws NoFieldsException
+ */
+ public function preExecute() {
+ // Confirm that the user did not try to specify an identical
+ // field and default field.
+ if (array_intersect($this->insertFields, $this->defaultFields)) {
+ throw new FieldsOverlapException('You may not specify the same field to have a value and a schema-default value.');
+ }
+
+ if (!empty($this->fromQuery)) {
+ // We have to assume that the used aliases match the insert fields.
+ // Regular fields are added to the query before expressions, maintain the
+ // same order for the insert fields.
+ // This behavior can be overridden by calling fields() manually as only the
+ // first call to fields() does have an effect.
+ $this->fields(array_merge(array_keys($this->fromQuery->getFields()), array_keys($this->fromQuery->getExpressions())));
+ }
+
+ // Don't execute query without fields.
+ if (count($this->insertFields) + count($this->defaultFields) == 0) {
+ throw new NoFieldsException('There are no fields available to insert with.');
+ }
+
+ // If no values have been added, silently ignore this query. This can happen
+ // if values are added conditionally, so we don't want to throw an
+ // exception.
+ if (!isset($this->insertValues[0]) && count($this->insertFields) > 0 && empty($this->fromQuery)) {
+ return FALSE;
+ }
+ return TRUE;
+ }
+}
+
+/**
+ * General class for an abstracted DELETE operation.
+ */
+class DeleteQuery extends Query implements QueryConditionInterface {
+
+ /**
+ * The table from which to delete.
+ *
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * The condition object for this query.
+ *
+ * Condition handling is handled via composition.
+ *
+ * @var DatabaseCondition
+ */
+ protected $condition;
+
+ /**
+ * Constructs a DeleteQuery object.
+ *
+ * @param DatabaseConnection $connection
+ * A DatabaseConnection object.
+ * @param string $table
+ * Name of the table to associate with this query.
+ * @param array $options
+ * Array of database options.
+ */
+ public function __construct(DatabaseConnection $connection, $table, array $options = array()) {
+ $options['return'] = Database::RETURN_AFFECTED;
+ parent::__construct($connection, $options);
+ $this->table = $table;
+
+ $this->condition = new DatabaseCondition('AND');
+ }
+
+ /**
+ * Implements QueryConditionInterface::condition().
+ */
+ public function condition($field, $value = NULL, $operator = NULL) {
+ $this->condition->condition($field, $value, $operator);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNull().
+ */
+ public function isNull($field) {
+ $this->condition->isNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNotNull().
+ */
+ public function isNotNull($field) {
+ $this->condition->isNotNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::exists().
+ */
+ public function exists(SelectQueryInterface $select) {
+ $this->condition->exists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::notExists().
+ */
+ public function notExists(SelectQueryInterface $select) {
+ $this->condition->notExists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::conditions().
+ */
+ public function &conditions() {
+ return $this->condition->conditions();
+ }
+
+ /**
+ * Implements QueryConditionInterface::arguments().
+ */
+ public function arguments() {
+ return $this->condition->arguments();
+ }
+
+ /**
+ * Implements QueryConditionInterface::where().
+ */
+ public function where($snippet, $args = array()) {
+ $this->condition->where($snippet, $args);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::compile().
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ return $this->condition->compile($connection, $queryPlaceholder);
+ }
+
+ /**
+ * Implements QueryConditionInterface::compiled().
+ */
+ public function compiled() {
+ return $this->condition->compiled();
+ }
+
+ /**
+ * Executes the DELETE query.
+ *
+ * @return
+ * The return value is dependent on the database connection.
+ */
+ public function execute() {
+ $values = array();
+ if (count($this->condition)) {
+ $this->condition->compile($this->connection, $this);
+ $values = $this->condition->arguments();
+ }
+
+ return $this->connection->query((string) $this, $values, $this->queryOptions);
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * @return string
+ * The prepared statement.
+ */
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ $query = $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '} ';
+
+ if (count($this->condition)) {
+
+ $this->condition->compile($this->connection, $this);
+ $query .= "\nWHERE " . $this->condition;
+ }
+
+ return $query;
+ }
+}
+
+
+/**
+ * General class for an abstracted TRUNCATE operation.
+ */
+class TruncateQuery extends Query {
+
+ /**
+ * The table to truncate.
+ *
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * Constructs a TruncateQuery object.
+ *
+ * @param DatabaseConnection $connection
+ * A DatabaseConnection object.
+ * @param string $table
+ * Name of the table to associate with this query.
+ * @param array $options
+ * Array of database options.
+ */
+ public function __construct(DatabaseConnection $connection, $table, array $options = array()) {
+ $options['return'] = Database::RETURN_AFFECTED;
+ parent::__construct($connection, $options);
+ $this->table = $table;
+ }
+
+ /**
+ * Implements QueryConditionInterface::compile().
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ return $this->condition->compile($connection, $queryPlaceholder);
+ }
+
+ /**
+ * Implements QueryConditionInterface::compiled().
+ */
+ public function compiled() {
+ return $this->condition->compiled();
+ }
+
+ /**
+ * Executes the TRUNCATE query.
+ *
+ * @return
+ * Return value is dependent on the database type.
+ */
+ public function execute() {
+ return $this->connection->query((string) $this, array(), $this->queryOptions);
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * @return string
+ * The prepared statement.
+ */
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ return $comments . 'TRUNCATE {' . $this->connection->escapeTable($this->table) . '} ';
+ }
+}
+
+/**
+ * General class for an abstracted UPDATE operation.
+ */
+class UpdateQuery extends Query implements QueryConditionInterface {
+
+ /**
+ * The table to update.
+ *
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * An array of fields that will be updated.
+ *
+ * @var array
+ */
+ protected $fields = array();
+
+ /**
+ * An array of values to update to.
+ *
+ * @var array
+ */
+ protected $arguments = array();
+
+ /**
+ * The condition object for this query.
+ *
+ * Condition handling is handled via composition.
+ *
+ * @var DatabaseCondition
+ */
+ protected $condition;
+
+ /**
+ * Array of fields to update to an expression in case of a duplicate record.
+ *
+ * This variable is a nested array in the following format:
+ * @code
+ * <some field> => array(
+ * 'condition' => <condition to execute, as a string>,
+ * 'arguments' => <array of arguments for condition, or NULL for none>,
+ * );
+ * @endcode
+ *
+ * @var array
+ */
+ protected $expressionFields = array();
+
+ /**
+ * Constructs an UpdateQuery object.
+ *
+ * @param DatabaseConnection $connection
+ * A DatabaseConnection object.
+ * @param string $table
+ * Name of the table to associate with this query.
+ * @param array $options
+ * Array of database options.
+ */
+ public function __construct(DatabaseConnection $connection, $table, array $options = array()) {
+ $options['return'] = Database::RETURN_AFFECTED;
+ parent::__construct($connection, $options);
+ $this->table = $table;
+
+ $this->condition = new DatabaseCondition('AND');
+ }
+
+ /**
+ * Implements QueryConditionInterface::condition().
+ */
+ public function condition($field, $value = NULL, $operator = NULL) {
+ $this->condition->condition($field, $value, $operator);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNull().
+ */
+ public function isNull($field) {
+ $this->condition->isNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNotNull().
+ */
+ public function isNotNull($field) {
+ $this->condition->isNotNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::exists().
+ */
+ public function exists(SelectQueryInterface $select) {
+ $this->condition->exists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::notExists().
+ */
+ public function notExists(SelectQueryInterface $select) {
+ $this->condition->notExists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::conditions().
+ */
+ public function &conditions() {
+ return $this->condition->conditions();
+ }
+
+ /**
+ * Implements QueryConditionInterface::arguments().
+ */
+ public function arguments() {
+ return $this->condition->arguments();
+ }
+
+ /**
+ * Implements QueryConditionInterface::where().
+ */
+ public function where($snippet, $args = array()) {
+ $this->condition->where($snippet, $args);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::compile().
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ return $this->condition->compile($connection, $queryPlaceholder);
+ }
+
+ /**
+ * Implements QueryConditionInterface::compiled().
+ */
+ public function compiled() {
+ return $this->condition->compiled();
+ }
+
+ /**
+ * Adds a set of field->value pairs to be updated.
+ *
+ * @param $fields
+ * An associative array of fields to write into the database. The array keys
+ * are the field names and the values are the values to which to set them.
+ *
+ * @return UpdateQuery
+ * The called object.
+ */
+ public function fields(array $fields) {
+ $this->fields = $fields;
+ return $this;
+ }
+
+ /**
+ * Specifies fields to be updated as an expression.
+ *
+ * Expression fields are cases such as counter=counter+1. This method takes
+ * precedence over fields().
+ *
+ * @param $field
+ * The field to set.
+ * @param $expression
+ * The field will be set to the value of this expression. This parameter
+ * may include named placeholders.
+ * @param $arguments
+ * If specified, this is an array of key/value pairs for named placeholders
+ * corresponding to the expression.
+ *
+ * @return UpdateQuery
+ * The called object.
+ */
+ public function expression($field, $expression, array $arguments = NULL) {
+ $this->expressionFields[$field] = array(
+ 'expression' => $expression,
+ 'arguments' => $arguments,
+ );
+
+ return $this;
+ }
+
+ /**
+ * Executes the UPDATE query.
+ *
+ * @return
+ * The number of rows affected by the update.
+ */
+ public function execute() {
+
+ // Expressions take priority over literal fields, so we process those first
+ // and remove any literal fields that conflict.
+ $fields = $this->fields;
+ $update_values = array();
+ foreach ($this->expressionFields as $field => $data) {
+ if (!empty($data['arguments'])) {
+ $update_values += $data['arguments'];
+ }
+ unset($fields[$field]);
+ }
+
+ // Because we filter $fields the same way here and in __toString(), the
+ // placeholders will all match up properly.
+ $max_placeholder = 0;
+ foreach ($fields as $field => $value) {
+ $update_values[':db_update_placeholder_' . ($max_placeholder++)] = $value;
+ }
+
+ if (count($this->condition)) {
+ $this->condition->compile($this->connection, $this);
+ $update_values = array_merge($update_values, $this->condition->arguments());
+ }
+
+ return $this->connection->query((string) $this, $update_values, $this->queryOptions);
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * @return string
+ * The prepared statement.
+ */
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // Expressions take priority over literal fields, so we process those first
+ // and remove any literal fields that conflict.
+ $fields = $this->fields;
+ $update_fields = array();
+ foreach ($this->expressionFields as $field => $data) {
+ $update_fields[] = $field . '=' . $data['expression'];
+ unset($fields[$field]);
+ }
+
+ $max_placeholder = 0;
+ foreach ($fields as $field => $value) {
+ $update_fields[] = $field . '=:db_update_placeholder_' . ($max_placeholder++);
+ }
+
+ $query = $comments . 'UPDATE {' . $this->connection->escapeTable($this->table) . '} SET ' . implode(', ', $update_fields);
+
+ if (count($this->condition)) {
+ $this->condition->compile($this->connection, $this);
+ // There is an implicit string cast on $this->condition.
+ $query .= "\nWHERE " . $this->condition;
+ }
+
+ return $query;
+ }
+
+}
+
+/**
+ * General class for an abstracted MERGE query operation.
+ *
+ * An ANSI SQL:2003 compatible database would run the following query:
+ *
+ * @code
+ * MERGE INTO table_name_1 USING table_name_2 ON (condition)
+ * WHEN MATCHED THEN
+ * UPDATE SET column1 = value1 [, column2 = value2 ...]
+ * WHEN NOT MATCHED THEN
+ * INSERT (column1 [, column2 ...]) VALUES (value1 [, value2 ...
+ * @endcode
+ *
+ * Other databases (most notably MySQL, PostgreSQL and SQLite) will emulate
+ * this statement by running a SELECT and then INSERT or UPDATE.
+ *
+ * By default, the two table names are identical and they are passed into the
+ * the constructor. table_name_2 can be specified by the
+ * MergeQuery::conditionTable() method. It can be either a string or a
+ * subquery.
+ *
+ * The condition is built exactly like SelectQuery or UpdateQuery conditions,
+ * the UPDATE query part is built similarly like an UpdateQuery and finally the
+ * INSERT query part is built similarly like an InsertQuery. However, both
+ * UpdateQuery and InsertQuery has a fields method so
+ * MergeQuery::updateFields() and MergeQuery::insertFields() needs to be called
+ * instead. MergeQuery::fields() can also be called which calls both of these
+ * methods as the common case is to use the same column-value pairs for both
+ * INSERT and UPDATE. However, this is not mandatory. Another convinient
+ * wrapper is MergeQuery::key() which adds the same column-value pairs to the
+ * condition and the INSERT query part.
+ *
+ * Several methods (key(), fields(), insertFields()) can be called to set a
+ * key-value pair for the INSERT query part. Subsequent calls for the same
+ * fields override the earlier ones. The same is true for UPDATE and key(),
+ * fields() and updateFields().
+ */
+class MergeQuery extends Query implements QueryConditionInterface {
+ /**
+ * Returned by execute() if an INSERT query has been executed.
+ */
+ const STATUS_INSERT = 1;
+
+ /**
+ * Returned by execute() if an UPDATE query has been executed.
+ */
+ const STATUS_UPDATE = 2;
+
+ /**
+ * The table to be used for INSERT and UPDATE.
+ *
+ * @var string
+ */
+ protected $table;
+
+ /**
+ * The table or subquery to be used for the condition.
+ */
+ protected $conditionTable;
+
+ /**
+ * An array of fields on which to insert.
+ *
+ * @var array
+ */
+ protected $insertFields = array();
+
+ /**
+ * An array of fields which should be set to their database-defined defaults.
+ *
+ * Used on INSERT.
+ *
+ * @var array
+ */
+ protected $defaultFields = array();
+
+ /**
+ * An array of values to be inserted.
+ *
+ * @var string
+ */
+ protected $insertValues = array();
+
+ /**
+ * An array of fields that will be updated.
+ *
+ * @var array
+ */
+ protected $updateFields = array();
+
+ /**
+ * Array of fields to update to an expression in case of a duplicate record.
+ *
+ * This variable is a nested array in the following format:
+ * @code
+ * <some field> => array(
+ * 'condition' => <condition to execute, as a string>,
+ * 'arguments' => <array of arguments for condition, or NULL for none>,
+ * );
+ * @endcode
+ *
+ * @var array
+ */
+ protected $expressionFields = array();
+
+ /**
+ * Flag indicating whether an UPDATE is necessary.
+ *
+ * @var boolean
+ */
+ protected $needsUpdate = FALSE;
+
+ /**
+ * Constructs a MergeQuery object.
+ *
+ * @param DatabaseConnection $connection
+ * A DatabaseConnection object.
+ * @param string $table
+ * Name of the table to associate with this query.
+ * @param array $options
+ * Array of database options.
+ */
+ public function __construct(DatabaseConnection $connection, $table, array $options = array()) {
+ $options['return'] = Database::RETURN_AFFECTED;
+ parent::__construct($connection, $options);
+ $this->table = $table;
+ $this->conditionTable = $table;
+ $this->condition = new DatabaseCondition('AND');
+ }
+
+ /**
+ * Sets the table or subquery to be used for the condition.
+ *
+ * @param $table
+ * The table name or the subquery to be used. Use a SelectQuery object to
+ * pass in a subquery.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ protected function conditionTable($table) {
+ $this->conditionTable = $table;
+ return $this;
+ }
+
+ /**
+ * Adds a set of field->value pairs to be updated.
+ *
+ * @param $fields
+ * An associative array of fields to write into the database. The array keys
+ * are the field names and the values are the values to which to set them.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function updateFields(array $fields) {
+ $this->updateFields = $fields;
+ $this->needsUpdate = TRUE;
+ return $this;
+ }
+
+ /**
+ * Specifies fields to be updated as an expression.
+ *
+ * Expression fields are cases such as counter = counter + 1. This method
+ * takes precedence over MergeQuery::updateFields() and it's wrappers,
+ * MergeQuery::key() and MergeQuery::fields().
+ *
+ * @param $field
+ * The field to set.
+ * @param $expression
+ * The field will be set to the value of this expression. This parameter
+ * may include named placeholders.
+ * @param $arguments
+ * If specified, this is an array of key/value pairs for named placeholders
+ * corresponding to the expression.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function expression($field, $expression, array $arguments = NULL) {
+ $this->expressionFields[$field] = array(
+ 'expression' => $expression,
+ 'arguments' => $arguments,
+ );
+ $this->needsUpdate = TRUE;
+ return $this;
+ }
+
+ /**
+ * Adds a set of field->value pairs to be inserted.
+ *
+ * @param $fields
+ * An array of fields on which to insert. This array may be indexed or
+ * associative. If indexed, the array is taken to be the list of fields.
+ * If associative, the keys of the array are taken to be the fields and
+ * the values are taken to be corresponding values to insert. If a
+ * $values argument is provided, $fields must be indexed.
+ * @param $values
+ * An array of fields to insert into the database. The values must be
+ * specified in the same order as the $fields array.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function insertFields(array $fields, array $values = array()) {
+ if ($values) {
+ $fields = array_combine($fields, $values);
+ }
+ $this->insertFields = $fields;
+ return $this;
+ }
+
+ /**
+ * Specifies fields for which the database-defaults should be used.
+ *
+ * If you want to force a given field to use the database-defined default,
+ * not NULL or undefined, use this method to instruct the database to use
+ * default values explicitly. In most cases this will not be necessary
+ * unless you are inserting a row that is all default values, as you cannot
+ * specify no values in an INSERT query.
+ *
+ * Specifying a field both in fields() and in useDefaults() is an error
+ * and will not execute.
+ *
+ * @param $fields
+ * An array of values for which to use the default values
+ * specified in the table definition.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function useDefaults(array $fields) {
+ $this->defaultFields = $fields;
+ return $this;
+ }
+
+ /**
+ * Sets common field-value pairs in the INSERT and UPDATE query parts.
+ *
+ * This method should only be called once. It may be called either
+ * with a single associative array or two indexed arrays. If called
+ * with an associative array, the keys are taken to be the fields
+ * and the values are taken to be the corresponding values to set.
+ * If called with two arrays, the first array is taken as the fields
+ * and the second array is taken as the corresponding values.
+ *
+ * @param $fields
+ * An array of fields to insert, or an associative array of fields and
+ * values. The keys of the array are taken to be the fields and the values
+ * are taken to be corresponding values to insert.
+ * @param $values
+ * An array of values to set into the database. The values must be
+ * specified in the same order as the $fields array.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function fields(array $fields, array $values = array()) {
+ if ($values) {
+ $fields = array_combine($fields, $values);
+ }
+ foreach ($fields as $key => $value) {
+ $this->insertFields[$key] = $value;
+ $this->updateFields[$key] = $value;
+ }
+ $this->needsUpdate = TRUE;
+ return $this;
+ }
+
+ /**
+ * Sets the key field(s) to be used as conditions for this query.
+ *
+ * This method should only be called once. It may be called either
+ * with a single associative array or two indexed arrays. If called
+ * with an associative array, the keys are taken to be the fields
+ * and the values are taken to be the corresponding values to set.
+ * If called with two arrays, the first array is taken as the fields
+ * and the second array is taken as the corresponding values.
+ *
+ * The fields are copied to the condition of the query and the INSERT part.
+ * If no other method is called, the UPDATE will become a no-op.
+ *
+ * @param $fields
+ * An array of fields to set, or an associative array of fields and values.
+ * @param $values
+ * An array of values to set into the database. The values must be
+ * specified in the same order as the $fields array.
+ *
+ * @return MergeQuery
+ * The called object.
+ */
+ public function key(array $fields, array $values = array()) {
+ if ($values) {
+ $fields = array_combine($fields, $values);
+ }
+ foreach ($fields as $key => $value) {
+ $this->insertFields[$key] = $value;
+ $this->condition($key, $value);
+ }
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::condition().
+ */
+ public function condition($field, $value = NULL, $operator = NULL) {
+ $this->condition->condition($field, $value, $operator);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNull().
+ */
+ public function isNull($field) {
+ $this->condition->isNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNotNull().
+ */
+ public function isNotNull($field) {
+ $this->condition->isNotNull($field);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::exists().
+ */
+ public function exists(SelectQueryInterface $select) {
+ $this->condition->exists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::notExists().
+ */
+ public function notExists(SelectQueryInterface $select) {
+ $this->condition->notExists($select);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::conditions().
+ */
+ public function &conditions() {
+ return $this->condition->conditions();
+ }
+
+ /**
+ * Implements QueryConditionInterface::arguments().
+ */
+ public function arguments() {
+ return $this->condition->arguments();
+ }
+
+ /**
+ * Implements QueryConditionInterface::where().
+ */
+ public function where($snippet, $args = array()) {
+ $this->condition->where($snippet, $args);
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::compile().
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ return $this->condition->compile($connection, $queryPlaceholder);
+ }
+
+ /**
+ * Implements QueryConditionInterface::compiled().
+ */
+ public function compiled() {
+ return $this->condition->compiled();
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the query to a string.
+ *
+ * In the degenerate case, there is no string-able query as this operation
+ * is potentially two queries.
+ *
+ * @return string
+ * The prepared query statement.
+ */
+ public function __toString() {
+ }
+
+ public function execute() {
+ // Wrap multiple queries in a transaction, if the database supports it.
+ $transaction = $this->connection->startTransaction();
+ try {
+ if (!count($this->condition)) {
+ throw new InvalidMergeQueryException(t('Invalid merge query: no conditions'));
+ }
+ $select = $this->connection->select($this->conditionTable)
+ ->condition($this->condition)
+ ->forUpdate();
+ $select->addExpression('1');
+ if (!$select->execute()->fetchField()) {
+ try {
+ $insert = $this->connection->insert($this->table)->fields($this->insertFields);
+ if ($this->defaultFields) {
+ $insert->useDefaults($this->defaultFields);
+ }
+ $insert->execute();
+ return MergeQuery::STATUS_INSERT;
+ }
+ catch (Exception $e) {
+ // The insert query failed, maybe it's because a racing insert query
+ // beat us in inserting the same row. Retry the select query, if it
+ // returns a row, ignore the error and continue with the update
+ // query below.
+ if (!$select->execute()->fetchField()) {
+ throw $e;
+ }
+ }
+ }
+ if ($this->needsUpdate) {
+ $update = $this->connection->update($this->table)
+ ->fields($this->updateFields)
+ ->condition($this->condition);
+ if ($this->expressionFields) {
+ foreach ($this->expressionFields as $field => $data) {
+ $update->expression($field, $data['expression'], $data['arguments']);
+ }
+ }
+ $update->execute();
+ return MergeQuery::STATUS_UPDATE;
+ }
+ }
+ catch (Exception $e) {
+ // Something really wrong happened here, bubble up the exception to the
+ // caller.
+ $transaction->rollback();
+ throw $e;
+ }
+ // Transaction commits here where $transaction looses scope.
+ }
+}
+
+/**
+ * Generic class for a series of conditions in a query.
+ */
+class DatabaseCondition implements QueryConditionInterface, Countable {
+
+ /**
+ * Array of conditions.
+ *
+ * @var array
+ */
+ protected $conditions = array();
+
+ /**
+ * Array of arguments.
+ *
+ * @var array
+ */
+ protected $arguments = array();
+
+ /**
+ * Whether the conditions have been changed.
+ *
+ * TRUE if the condition has been changed since the last compile.
+ * FALSE if the condition has been compiled and not changed.
+ *
+ * @var bool
+ */
+ protected $changed = TRUE;
+
+ /**
+ * The identifier of the query placeholder this condition has been compiled against.
+ */
+ protected $queryPlaceholderIdentifier;
+
+ /**
+ * Constructs a DataBaseCondition object.
+ *
+ * @param string $conjunction
+ * The operator to use to combine conditions: 'AND' or 'OR'.
+ */
+ public function __construct($conjunction) {
+ $this->conditions['#conjunction'] = $conjunction;
+ }
+
+ /**
+ * Implements Countable::count().
+ *
+ * Returns the size of this conditional. The size of the conditional is the
+ * size of its conditional array minus one, because one element is the the
+ * conjunction.
+ */
+ public function count() {
+ return count($this->conditions) - 1;
+ }
+
+ /**
+ * Implements QueryConditionInterface::condition().
+ */
+ public function condition($field, $value = NULL, $operator = NULL) {
+ if (!isset($operator)) {
+ if (is_array($value)) {
+ $operator = 'IN';
+ }
+ elseif (!isset($value)) {
+ $operator = 'IS NULL';
+ }
+ else {
+ $operator = '=';
+ }
+ }
+ $this->conditions[] = array(
+ 'field' => $field,
+ 'value' => $value,
+ 'operator' => $operator,
+ );
+
+ $this->changed = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::where().
+ */
+ public function where($snippet, $args = array()) {
+ $this->conditions[] = array(
+ 'field' => $snippet,
+ 'value' => $args,
+ 'operator' => NULL,
+ );
+ $this->changed = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNull().
+ */
+ public function isNull($field) {
+ return $this->condition($field);
+ }
+
+ /**
+ * Implements QueryConditionInterface::isNotNull().
+ */
+ public function isNotNull($field) {
+ return $this->condition($field, NULL, 'IS NOT NULL');
+ }
+
+ /**
+ * Implements QueryConditionInterface::exists().
+ */
+ public function exists(SelectQueryInterface $select) {
+ return $this->condition('', $select, 'EXISTS');
+ }
+
+ /**
+ * Implements QueryConditionInterface::notExists().
+ */
+ public function notExists(SelectQueryInterface $select) {
+ return $this->condition('', $select, 'NOT EXISTS');
+ }
+
+ /**
+ * Implements QueryConditionInterface::conditions().
+ */
+ public function &conditions() {
+ return $this->conditions;
+ }
+
+ /**
+ * Implements QueryConditionInterface::arguments().
+ */
+ public function arguments() {
+ // If the caller forgot to call compile() first, refuse to run.
+ if ($this->changed) {
+ return NULL;
+ }
+ return $this->arguments;
+ }
+
+ /**
+ * Implements QueryConditionInterface::compile().
+ */
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ // Re-compile if this condition changed or if we are compiled against a
+ // different query placeholder object.
+ if ($this->changed || isset($this->queryPlaceholderIdentifier) && ($this->queryPlaceholderIdentifier != $queryPlaceholder->uniqueIdentifier())) {
+ $this->queryPlaceholderIdentifier = $queryPlaceholder->uniqueIdentifier();
+
+ $condition_fragments = array();
+ $arguments = array();
+
+ $conditions = $this->conditions;
+ $conjunction = $conditions['#conjunction'];
+ unset($conditions['#conjunction']);
+ foreach ($conditions as $condition) {
+ if (empty($condition['operator'])) {
+ // This condition is a literal string, so let it through as is.
+ $condition_fragments[] = ' (' . $condition['field'] . ') ';
+ $arguments += $condition['value'];
+ }
+ else {
+ // It's a structured condition, so parse it out accordingly.
+ // Note that $condition['field'] will only be an object for a dependent
+ // DatabaseCondition object, not for a dependent subquery.
+ if ($condition['field'] instanceof QueryConditionInterface) {
+ // Compile the sub-condition recursively and add it to the list.
+ $condition['field']->compile($connection, $queryPlaceholder);
+ $condition_fragments[] = '(' . (string) $condition['field'] . ')';
+ $arguments += $condition['field']->arguments();
+ }
+ else {
+ // For simplicity, we treat all operators as the same data structure.
+ // In the typical degenerate case, this won't get changed.
+ $operator_defaults = array(
+ 'prefix' => '',
+ 'postfix' => '',
+ 'delimiter' => '',
+ 'operator' => $condition['operator'],
+ 'use_value' => TRUE,
+ );
+ $operator = $connection->mapConditionOperator($condition['operator']);
+ if (!isset($operator)) {
+ $operator = $this->mapConditionOperator($condition['operator']);
+ }
+ $operator += $operator_defaults;
+
+ $placeholders = array();
+ if ($condition['value'] instanceof SelectQueryInterface) {
+ $condition['value']->compile($connection, $queryPlaceholder);
+ $placeholders[] = (string) $condition['value'];
+ $arguments += $condition['value']->arguments();
+ // Subqueries are the actual value of the operator, we don't
+ // need to add another below.
+ $operator['use_value'] = FALSE;
+ }
+ // We assume that if there is a delimiter, then the value is an
+ // array. If not, it is a scalar. For simplicity, we first convert
+ // up to an array so that we can build the placeholders in the same way.
+ elseif (!$operator['delimiter']) {
+ $condition['value'] = array($condition['value']);
+ }
+ if ($operator['use_value']) {
+ foreach ($condition['value'] as $value) {
+ $placeholder = ':db_condition_placeholder_' . $queryPlaceholder->nextPlaceholder();
+ $arguments[$placeholder] = $value;
+ $placeholders[] = $placeholder;
+ }
+ }
+ $condition_fragments[] = ' (' . $connection->escapeField($condition['field']) . ' ' . $operator['operator'] . ' ' . $operator['prefix'] . implode($operator['delimiter'], $placeholders) . $operator['postfix'] . ') ';
+ }
+ }
+ }
+
+ $this->changed = FALSE;
+ $this->stringVersion = implode($conjunction, $condition_fragments);
+ $this->arguments = $arguments;
+ }
+ }
+
+ /**
+ * Implements QueryConditionInterface::compiled().
+ */
+ public function compiled() {
+ return !$this->changed;
+ }
+
+ /**
+ * Implements PHP magic __toString method to convert the conditions to string.
+ *
+ * @return string
+ * A string version of the conditions.
+ */
+ public function __toString() {
+ // If the caller forgot to call compile() first, refuse to run.
+ if ($this->changed) {
+ return NULL;
+ }
+ return $this->stringVersion;
+ }
+
+ /**
+ * PHP magic __clone() method.
+ *
+ * Only copies fields that implement QueryConditionInterface. Also sets
+ * $this->changed to TRUE.
+ */
+ function __clone() {
+ $this->changed = TRUE;
+ foreach ($this->conditions as $key => $condition) {
+ if ($condition['field'] instanceOf QueryConditionInterface) {
+ $this->conditions[$key]['field'] = clone($condition['field']);
+ }
+ }
+ }
+
+ /**
+ * Gets any special processing requirements for the condition operator.
+ *
+ * Some condition types require special processing, such as IN, because
+ * the value data they pass in is not a simple value. This is a simple
+ * overridable lookup function.
+ *
+ * @param $operator
+ * The condition operator, such as "IN", "BETWEEN", etc. Case-sensitive.
+ *
+ * @return
+ * The extra handling directives for the specified operator, or NULL.
+ */
+ protected function mapConditionOperator($operator) {
+ // $specials does not use drupal_static as its value never changes.
+ static $specials = array(
+ 'BETWEEN' => array('delimiter' => ' AND '),
+ 'IN' => array('delimiter' => ', ', 'prefix' => ' (', 'postfix' => ')'),
+ 'NOT IN' => array('delimiter' => ', ', 'prefix' => ' (', 'postfix' => ')'),
+ 'EXISTS' => array('prefix' => ' (', 'postfix' => ')'),
+ 'NOT EXISTS' => array('prefix' => ' (', 'postfix' => ')'),
+ 'IS NULL' => array('use_value' => FALSE),
+ 'IS NOT NULL' => array('use_value' => FALSE),
+ // Use backslash for escaping wildcard characters.
+ 'LIKE' => array('postfix' => " ESCAPE '\\\\'"),
+ 'NOT LIKE' => array('postfix' => " ESCAPE '\\\\'"),
+ // These ones are here for performance reasons.
+ '=' => array(),
+ '<' => array(),
+ '>' => array(),
+ '>=' => array(),
+ '<=' => array(),
+ );
+ if (isset($specials[$operator])) {
+ $return = $specials[$operator];
+ }
+ else {
+ // We need to upper case because PHP index matches are case sensitive but
+ // do not need the more expensive drupal_strtoupper because SQL statements are ASCII.
+ $operator = strtoupper($operator);
+ $return = isset($specials[$operator]) ? $specials[$operator] : array();
+ }
+
+ $return += array('operator' => $operator);
+
+ return $return;
+ }
+
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/schema.inc b/core/includes/database/schema.inc
new file mode 100644
index 000000000000..27934dcdf041
--- /dev/null
+++ b/core/includes/database/schema.inc
@@ -0,0 +1,723 @@
+<?php
+
+/**
+ * @file
+ * Generic Database schema code.
+ */
+
+require_once __DIR__ . '/query.inc';
+
+/**
+ * @defgroup schemaapi Schema API
+ * @{
+ * API to handle database schemas.
+ *
+ * A Drupal schema definition is an array structure representing one or
+ * more tables and their related keys and indexes. A schema is defined by
+ * hook_schema(), which usually lives in a modulename.install file.
+ *
+ * By implementing hook_schema() and specifying the tables your module
+ * declares, you can easily create and drop these tables on all
+ * supported database engines. You don't have to deal with the
+ * different SQL dialects for table creation and alteration of the
+ * supported database engines.
+ *
+ * hook_schema() should return an array with a key for each table that
+ * the module defines.
+ *
+ * The following keys are defined:
+ * - 'description': A string in non-markup plain text describing this table
+ * and its purpose. References to other tables should be enclosed in
+ * curly-brackets. For example, the node_revisions table
+ * description field might contain "Stores per-revision title and
+ * body data for each {node}."
+ * - 'fields': An associative array ('fieldname' => specification)
+ * that describes the table's database columns. The specification
+ * is also an array. The following specification parameters are defined:
+ * - 'description': A string in non-markup plain text describing this field
+ * and its purpose. References to other tables should be enclosed in
+ * curly-brackets. For example, the node table vid field
+ * description might contain "Always holds the largest (most
+ * recent) {node_revision}.vid value for this nid."
+ * - 'type': The generic datatype: 'char', 'varchar', 'text', 'blob', 'int',
+ * 'float', 'numeric', or 'serial'. Most types just map to the according
+ * database engine specific datatypes. Use 'serial' for auto incrementing
+ * fields. This will expand to 'INT auto_increment' on MySQL.
+ * - 'mysql_type', 'pgsql_type', 'sqlite_type', etc.: If you need to
+ * use a record type not included in the officially supported list
+ * of types above, you can specify a type for each database
+ * backend. In this case, you can leave out the type parameter,
+ * but be advised that your schema will fail to load on backends that
+ * do not have a type specified. A possible solution can be to
+ * use the "text" type as a fallback.
+ * - 'serialize': A boolean indicating whether the field will be stored as
+ * a serialized string.
+ * - 'size': The data size: 'tiny', 'small', 'medium', 'normal',
+ * 'big'. This is a hint about the largest value the field will
+ * store and determines which of the database engine specific
+ * datatypes will be used (e.g. on MySQL, TINYINT vs. INT vs. BIGINT).
+ * 'normal', the default, selects the base type (e.g. on MySQL,
+ * INT, VARCHAR, BLOB, etc.).
+ * Not all sizes are available for all data types. See
+ * DatabaseSchema::getFieldTypeMap() for possible combinations.
+ * - 'not null': If true, no NULL values will be allowed in this
+ * database column. Defaults to false.
+ * - 'default': The field's default value. The PHP type of the
+ * value matters: '', '0', and 0 are all different. If you
+ * specify '0' as the default value for a type 'int' field it
+ * will not work because '0' is a string containing the
+ * character "zero", not an integer.
+ * - 'length': The maximal length of a type 'char', 'varchar' or 'text'
+ * field. Ignored for other field types.
+ * - 'unsigned': A boolean indicating whether a type 'int', 'float'
+ * and 'numeric' only is signed or unsigned. Defaults to
+ * FALSE. Ignored for other field types.
+ * - 'precision', 'scale': For type 'numeric' fields, indicates
+ * the precision (total number of significant digits) and scale
+ * (decimal digits right of the decimal point). Both values are
+ * mandatory. Ignored for other field types.
+ * All parameters apart from 'type' are optional except that type
+ * 'numeric' columns must specify 'precision' and 'scale'.
+ * - 'primary key': An array of one or more key column specifiers (see below)
+ * that form the primary key.
+ * - 'unique keys': An associative array of unique keys ('keyname' =>
+ * specification). Each specification is an array of one or more
+ * key column specifiers (see below) that form a unique key on the table.
+ * - 'foreign keys': An associative array of relations ('my_relation' =>
+ * specification). Each specification is an array containing the name of
+ * the referenced table ('table'), and an array of column mappings
+ * ('columns'). Column mappings are defined by key pairs ('source_column' =>
+ * 'referenced_column').
+ * - 'indexes': An associative array of indexes ('indexname' =>
+ * specification). Each specification is an array of one or more
+ * key column specifiers (see below) that form an index on the
+ * table.
+ *
+ * A key column specifier is either a string naming a column or an
+ * array of two elements, column name and length, specifying a prefix
+ * of the named column.
+ *
+ * As an example, here is a SUBSET of the schema definition for
+ * Drupal's 'node' table. It show four fields (nid, vid, type, and
+ * title), the primary key on field 'nid', a unique key named 'vid' on
+ * field 'vid', and two indexes, one named 'nid' on field 'nid' and
+ * one named 'node_title_type' on the field 'title' and the first four
+ * bytes of the field 'type':
+ *
+ * @code
+ * $schema['node'] = array(
+ * 'description' => 'The base table for nodes.',
+ * 'fields' => array(
+ * 'nid' => array('type' => 'serial', 'unsigned' => TRUE, 'not null' => TRUE),
+ * 'vid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE,'default' => 0),
+ * 'type' => array('type' => 'varchar','length' => 32,'not null' => TRUE, 'default' => ''),
+ * 'language' => array('type' => 'varchar','length' => 12,'not null' => TRUE,'default' => ''),
+ * 'title' => array('type' => 'varchar','length' => 255,'not null' => TRUE, 'default' => ''),
+ * 'uid' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'status' => array('type' => 'int', 'not null' => TRUE, 'default' => 1),
+ * 'created' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'changed' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'comment' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'promote' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'moderate' => array('type' => 'int', 'not null' => TRUE,'default' => 0),
+ * 'sticky' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * 'tnid' => array('type' => 'int', 'unsigned' => TRUE, 'not null' => TRUE, 'default' => 0),
+ * 'translate' => array('type' => 'int', 'not null' => TRUE, 'default' => 0),
+ * ),
+ * 'indexes' => array(
+ * 'node_changed' => array('changed'),
+ * 'node_created' => array('created'),
+ * 'node_moderate' => array('moderate'),
+ * 'node_frontpage' => array('promote', 'status', 'sticky', 'created'),
+ * 'node_status_type' => array('status', 'type', 'nid'),
+ * 'node_title_type' => array('title', array('type', 4)),
+ * 'node_type' => array(array('type', 4)),
+ * 'uid' => array('uid'),
+ * 'tnid' => array('tnid'),
+ * 'translate' => array('translate'),
+ * ),
+ * 'unique keys' => array(
+ * 'vid' => array('vid'),
+ * ),
+ * 'foreign keys' => array(
+ * 'node_revision' => array(
+ * 'table' => 'node_revision',
+ * 'columns' => array('vid' => 'vid'),
+ * ),
+ * 'node_author' => array(
+ * 'table' => 'users',
+ * 'columns' => array('uid' => 'uid'),
+ * ),
+ * ),
+ * 'primary key' => array('nid'),
+ * );
+ * @endcode
+ *
+ * @see drupal_install_schema()
+ */
+
+abstract class DatabaseSchema implements QueryPlaceholderInterface {
+
+ protected $connection;
+
+ /**
+ * The placeholder counter.
+ */
+ protected $placeholder = 0;
+
+ /**
+ * Definition of prefixInfo array structure.
+ *
+ * Rather than redefining DatabaseSchema::getPrefixInfo() for each driver,
+ * by defining the defaultSchema variable only MySQL has to re-write the
+ * method.
+ *
+ * @see DatabaseSchema::getPrefixInfo()
+ */
+ protected $defaultSchema = 'public';
+
+ /**
+ * A unique identifier for this query object.
+ */
+ protected $uniqueIdentifier;
+
+ public function __construct($connection) {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+ $this->connection = $connection;
+ }
+
+ /**
+ * Implements the magic __clone function.
+ */
+ public function __clone() {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+ }
+
+ /**
+ * Implements QueryPlaceHolderInterface::uniqueIdentifier().
+ */
+ public function uniqueIdentifier() {
+ return $this->uniqueIdentifier;
+ }
+
+ /**
+ * Implements QueryPlaceHolderInterface::nextPlaceholder().
+ */
+ public function nextPlaceholder() {
+ return $this->placeholder++;
+ }
+
+ /**
+ * Get information about the table name and schema from the prefix.
+ *
+ * @param
+ * Name of table to look prefix up for. Defaults to 'default' because thats
+ * default key for prefix.
+ * @param $add_prefix
+ * Boolean that indicates whether the given table name should be prefixed.
+ *
+ * @return
+ * A keyed array with information about the schema, table name and prefix.
+ */
+ protected function getPrefixInfo($table = 'default', $add_prefix = TRUE) {
+ $info = array(
+ 'schema' => $this->defaultSchema,
+ 'prefix' => $this->connection->tablePrefix($table),
+ );
+ if ($add_prefix) {
+ $table = $info['prefix'] . $table;
+ }
+ // If the prefix contains a period in it, then that means the prefix also
+ // contains a schema reference in which case we will change the schema key
+ // to the value before the period in the prefix. Everything after the dot
+ // will be prefixed onto the front of the table.
+ if (($pos = strpos($table, '.')) !== FALSE) {
+ // Grab everything before the period.
+ $info['schema'] = substr($table, 0, $pos);
+ // Grab everything after the dot.
+ $info['table'] = substr($table, ++$pos);
+ }
+ else {
+ $info['table'] = $table;
+ }
+ return $info;
+ }
+
+ /**
+ * Create names for indexes, primary keys and constraints.
+ *
+ * This prevents using {} around non-table names like indexes and keys.
+ */
+ function prefixNonTable($table) {
+ $args = func_get_args();
+ $info = $this->getPrefixInfo($table);
+ $args[0] = $info['table'];
+ return implode('_', $args);
+ }
+
+ /**
+ * Build a condition to match a table name against a standard information_schema.
+ *
+ * The information_schema is a SQL standard that provides information about the
+ * database server and the databases, schemas, tables, columns and users within
+ * it. This makes information_schema a useful tool to use across the drupal
+ * database drivers and is used by a few different functions. The function below
+ * describes the conditions to be meet when querying information_schema.tables
+ * for drupal tables or information associated with drupal tables. Even though
+ * this is the standard method, not all databases follow standards and so this
+ * method should be overwritten by a database driver if the database provider
+ * uses alternate methods. Because information_schema.tables is used in a few
+ * different functions, a database driver will only need to override this function
+ * to make all the others work. For example see
+ * core/includes/databases/mysql/schema.inc.
+ *
+ * @param $table_name
+ * The name of the table in question.
+ * @param $operator
+ * The operator to apply on the 'table' part of the condition.
+ * @param $add_prefix
+ * Boolean to indicate whether the table name needs to be prefixed.
+ *
+ * @return QueryConditionInterface
+ * A DatabaseCondition object.
+ */
+ protected function buildTableNameCondition($table_name, $operator = '=', $add_prefix = TRUE) {
+ $info = $this->connection->getConnectionOptions();
+
+ // Retrive the table name and schema
+ $table_info = $this->getPrefixInfo($table_name, $add_prefix);
+
+ $condition = new DatabaseCondition('AND');
+ $condition->condition('table_catalog', $info['database']);
+ $condition->condition('table_schema', $table_info['schema']);
+ $condition->condition('table_name', $table_info['table'], $operator);
+ return $condition;
+ }
+
+ /**
+ * Check if a table exists.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ *
+ * @return
+ * TRUE if the given table exists, otherwise FALSE.
+ */
+ public function tableExists($table) {
+ $condition = $this->buildTableNameCondition($table);
+ $condition->compile($this->connection, $this);
+ // Normally, we would heartily discourage the use of string
+ // concatenation for conditionals like this however, we
+ // couldn't use db_select() here because it would prefix
+ // information_schema.tables and the query would fail.
+ // Don't use {} around information_schema.tables table.
+ return (bool) $this->connection->query("SELECT 1 FROM information_schema.tables WHERE " . (string) $condition, $condition->arguments())->fetchField();
+ }
+
+ /**
+ * Find all tables that are like the specified base table name.
+ *
+ * @param $table_expression
+ * An SQL expression, for example "simpletest%" (without the quotes).
+ * BEWARE: this is not prefixed, the caller should take care of that.
+ *
+ * @return
+ * Array, both the keys and the values are the matching tables.
+ */
+ public function findTables($table_expression) {
+ $condition = $this->buildTableNameCondition($table_expression, 'LIKE', FALSE);
+
+ $condition->compile($this->connection, $this);
+ // Normally, we would heartily discourage the use of string
+ // concatenation for conditionals like this however, we
+ // couldn't use db_select() here because it would prefix
+ // information_schema.tables and the query would fail.
+ // Don't use {} around information_schema.tables table.
+ return $this->connection->query("SELECT table_name FROM information_schema.tables WHERE " . (string) $condition, $condition->arguments())->fetchAllKeyed(0, 0);
+ }
+
+ /**
+ * Check if a column exists in the given table.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ * @param $name
+ * The name of the column.
+ *
+ * @return
+ * TRUE if the given column exists, otherwise FALSE.
+ */
+ public function fieldExists($table, $column) {
+ $condition = $this->buildTableNameCondition($table);
+ $condition->condition('column_name', $column);
+ $condition->compile($this->connection, $this);
+ // Normally, we would heartily discourage the use of string
+ // concatenation for conditionals like this however, we
+ // couldn't use db_select() here because it would prefix
+ // information_schema.tables and the query would fail.
+ // Don't use {} around information_schema.columns table.
+ return (bool) $this->connection->query("SELECT 1 FROM information_schema.columns WHERE " . (string) $condition, $condition->arguments())->fetchField();
+ }
+
+ /**
+ * Returns a mapping of Drupal schema field names to DB-native field types.
+ *
+ * Because different field types do not map 1:1 between databases, Drupal has
+ * its own normalized field type names. This function returns a driver-specific
+ * mapping table from Drupal names to the native names for each database.
+ *
+ * @return array
+ * An array of Schema API field types to driver-specific field types.
+ */
+ abstract public function getFieldTypeMap();
+
+ /**
+ * Rename a table.
+ *
+ * @param $table
+ * The table to be renamed.
+ * @param $new_name
+ * The new name for the table.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If a table with the specified new name already exists.
+ */
+ abstract public function renameTable($table, $new_name);
+
+ /**
+ * Drop a table.
+ *
+ * @param $table
+ * The table to be dropped.
+ *
+ * @return
+ * TRUE if the table was successfully dropped, FALSE if there was no table
+ * by that name to begin with.
+ */
+ abstract public function dropTable($table);
+
+ /**
+ * Add a new field to a table.
+ *
+ * @param $table
+ * Name of the table to be altered.
+ * @param $field
+ * Name of the field to be added.
+ * @param $spec
+ * The field specification array, as taken from a schema definition.
+ * The specification may also contain the key 'initial', the newly
+ * created field will be set to the value of the key in all rows.
+ * This is most useful for creating NOT NULL columns with no default
+ * value in existing tables.
+ * @param $keys_new
+ * Optional keys and indexes specification to be created on the
+ * table along with adding the field. The format is the same as a
+ * table specification but without the 'fields' element. If you are
+ * adding a type 'serial' field, you MUST specify at least one key
+ * or index including it in this array. See db_change_field() for more
+ * explanation why.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified table already has a field by that name.
+ */
+ abstract public function addField($table, $field, $spec, $keys_new = array());
+
+ /**
+ * Drop a field.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be dropped.
+ *
+ * @return
+ * TRUE if the field was successfully dropped, FALSE if there was no field
+ * by that name to begin with.
+ */
+ abstract public function dropField($table, $field);
+
+ /**
+ * Set the default value for a field.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be altered.
+ * @param $default
+ * Default value to be set. NULL for 'default NULL'.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table or field doesn't exist.
+ */
+ abstract public function fieldSetDefault($table, $field, $default);
+
+ /**
+ * Set a field to have no default value.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $field
+ * The field to be altered.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table or field doesn't exist.
+ */
+ abstract public function fieldSetNoDefault($table, $field);
+
+ /**
+ * Checks if an index exists in the given table.
+ *
+ * @param $table
+ * The name of the table in drupal (no prefixing).
+ * @param $name
+ * The name of the index in drupal (no prefixing).
+ *
+ * @return
+ * TRUE if the given index exists, otherwise FALSE.
+ */
+ abstract public function indexExists($table, $name);
+
+ /**
+ * Add a primary key.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $fields
+ * Fields for the primary key.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified table already has a primary key.
+ */
+ abstract public function addPrimaryKey($table, $fields);
+
+ /**
+ * Drop the primary key.
+ *
+ * @param $table
+ * The table to be altered.
+ *
+ * @return
+ * TRUE if the primary key was successfully dropped, FALSE if there was no
+ * primary key on this table to begin with.
+ */
+ abstract public function dropPrimaryKey($table);
+
+ /**
+ * Add a unique key.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the key.
+ * @param $fields
+ * An array of field names.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified table already has a key by that name.
+ */
+ abstract public function addUniqueKey($table, $name, $fields);
+
+ /**
+ * Drop a unique key.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the key.
+ *
+ * @return
+ * TRUE if the key was successfully dropped, FALSE if there was no key by
+ * that name to begin with.
+ */
+ abstract public function dropUniqueKey($table, $name);
+
+ /**
+ * Add an index.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the index.
+ * @param $fields
+ * An array of field names.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified table already has an index by that name.
+ */
+ abstract public function addIndex($table, $name, $fields);
+
+ /**
+ * Drop an index.
+ *
+ * @param $table
+ * The table to be altered.
+ * @param $name
+ * The name of the index.
+ *
+ * @return
+ * TRUE if the index was successfully dropped, FALSE if there was no index
+ * by that name to begin with.
+ */
+ abstract public function dropIndex($table, $name);
+
+ /**
+ * Change a field definition.
+ *
+ * IMPORTANT NOTE: To maintain database portability, you have to explicitly
+ * recreate all indices and primary keys that are using the changed field.
+ *
+ * That means that you have to drop all affected keys and indexes with
+ * db_drop_{primary_key,unique_key,index}() before calling db_change_field().
+ * To recreate the keys and indices, pass the key definitions as the
+ * optional $keys_new argument directly to db_change_field().
+ *
+ * For example, suppose you have:
+ * @code
+ * $schema['foo'] = array(
+ * 'fields' => array(
+ * 'bar' => array('type' => 'int', 'not null' => TRUE)
+ * ),
+ * 'primary key' => array('bar')
+ * );
+ * @endcode
+ * and you want to change foo.bar to be type serial, leaving it as the
+ * primary key. The correct sequence is:
+ * @code
+ * db_drop_primary_key('foo');
+ * db_change_field('foo', 'bar', 'bar',
+ * array('type' => 'serial', 'not null' => TRUE),
+ * array('primary key' => array('bar')));
+ * @endcode
+ *
+ * The reasons for this are due to the different database engines:
+ *
+ * On PostgreSQL, changing a field definition involves adding a new field
+ * and dropping an old one which* causes any indices, primary keys and
+ * sequences (from serial-type fields) that use the changed field to be dropped.
+ *
+ * On MySQL, all type 'serial' fields must be part of at least one key
+ * or index as soon as they are created. You cannot use
+ * db_add_{primary_key,unique_key,index}() for this purpose because
+ * the ALTER TABLE command will fail to add the column without a key
+ * or index specification. The solution is to use the optional
+ * $keys_new argument to create the key or index at the same time as
+ * field.
+ *
+ * You could use db_add_{primary_key,unique_key,index}() in all cases
+ * unless you are converting a field to be type serial. You can use
+ * the $keys_new argument in all cases.
+ *
+ * @param $table
+ * Name of the table.
+ * @param $field
+ * Name of the field to change.
+ * @param $field_new
+ * New name for the field (set to the same as $field if you don't want to change the name).
+ * @param $spec
+ * The field specification for the new field.
+ * @param $keys_new
+ * Optional keys and indexes specification to be created on the
+ * table along with changing the field. The format is the same as a
+ * table specification but without the 'fields' element.
+ *
+ * @throws DatabaseSchemaObjectDoesNotExistException
+ * If the specified table or source field doesn't exist.
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified destination field already exists.
+ */
+ abstract public function changeField($table, $field, $field_new, $spec, $keys_new = array());
+
+ /**
+ * Create a new table from a Drupal table definition.
+ *
+ * @param $name
+ * The name of the table to create.
+ * @param $table
+ * A Schema API table definition array.
+ *
+ * @throws DatabaseSchemaObjectExistsException
+ * If the specified table already exists.
+ */
+ public function createTable($name, $table) {
+ if ($this->tableExists($name)) {
+ throw new DatabaseSchemaObjectExistsException(t('Table %name already exists.', array('%name' => $name)));
+ }
+ $statements = $this->createTableSql($name, $table);
+ foreach ($statements as $statement) {
+ $this->connection->query($statement);
+ }
+ }
+
+ /**
+ * Return an array of field names from an array of key/index column specifiers.
+ *
+ * This is usually an identity function but if a key/index uses a column prefix
+ * specification, this function extracts just the name.
+ *
+ * @param $fields
+ * An array of key/index column specifiers.
+ *
+ * @return
+ * An array of field names.
+ */
+ public function fieldNames($fields) {
+ $return = array();
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $return[] = $field[0];
+ }
+ else {
+ $return[] = $field;
+ }
+ }
+ return $return;
+ }
+
+ /**
+ * Prepare a table or column comment for database query.
+ *
+ * @param $comment
+ * The comment string to prepare.
+ * @param $length
+ * Optional upper limit on the returned string length.
+ *
+ * @return
+ * The prepared comment.
+ */
+ public function prepareComment($comment, $length = NULL) {
+ return $this->connection->quote($comment);
+ }
+}
+
+/**
+ * Exception thrown if an object being created already exists.
+ *
+ * For example, this exception should be thrown whenever there is an attempt to
+ * create a new database table, field, or index that already exists in the
+ * database schema.
+ */
+class DatabaseSchemaObjectExistsException extends Exception {}
+
+/**
+ * Exception thrown if an object being modified doesn't exist yet.
+ *
+ * For example, this exception should be thrown whenever there is an attempt to
+ * modify a database table, field, or index that does not currently exist in
+ * the database schema.
+ */
+class DatabaseSchemaObjectDoesNotExistException extends Exception {}
+
+/**
+ * @} End of "defgroup schemaapi".
+ */
+
diff --git a/core/includes/database/select.inc b/core/includes/database/select.inc
new file mode 100644
index 000000000000..750477854932
--- /dev/null
+++ b/core/includes/database/select.inc
@@ -0,0 +1,1630 @@
+<?php
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+require_once __DIR__ . '/query.inc';
+
+/**
+ * Interface for extendable query objects.
+ *
+ * "Extenders" follow the "Decorator" OOP design pattern. That is, they wrap
+ * and "decorate" another object. In our case, they implement the same interface
+ * as select queries and wrap a select query, to which they delegate almost all
+ * operations. Subclasses of this class may implement additional methods or
+ * override existing methods as appropriate. Extenders may also wrap other
+ * extender objects, allowing for arbitrarily complex "enhanced" queries.
+ */
+interface QueryExtendableInterface {
+
+ /**
+ * Enhance this object by wrapping it in an extender object.
+ *
+ * @param $extender_name
+ * The base name of the extending class. The base name will be checked
+ * against the current database connection to allow driver-specific subclasses
+ * as well, using the same logic as the query objects themselves. For example,
+ * PagerDefault_mysql is the MySQL-specific override for PagerDefault.
+ * @return QueryExtendableInterface
+ * The extender object, which now contains a reference to this object.
+ */
+ public function extend($extender_name);
+}
+
+/**
+ * Interface definition for a Select Query object.
+ */
+interface SelectQueryInterface extends QueryConditionInterface, QueryAlterableInterface, QueryExtendableInterface, QueryPlaceholderInterface {
+
+ /* Alter accessors to expose the query data to alter hooks. */
+
+ /**
+ * Returns a reference to the fields array for this query.
+ *
+ * Because this method returns by reference, alter hooks may edit the fields
+ * array directly to make their changes. If just adding fields, however, the
+ * use of addField() is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getFields();
+ * @endcode
+ *
+ * @return
+ * A reference to the fields array structure.
+ */
+ public function &getFields();
+
+ /**
+ * Returns a reference to the expressions array for this query.
+ *
+ * Because this method returns by reference, alter hooks may edit the expressions
+ * array directly to make their changes. If just adding expressions, however, the
+ * use of addExpression() is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getExpressions();
+ * @endcode
+ *
+ * @return
+ * A reference to the expression array structure.
+ */
+ public function &getExpressions();
+
+ /**
+ * Returns a reference to the order by array for this query.
+ *
+ * Because this method returns by reference, alter hooks may edit the order-by
+ * array directly to make their changes. If just adding additional ordering
+ * fields, however, the use of orderBy() is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getOrderBy();
+ * @endcode
+ *
+ * @return
+ * A reference to the expression array structure.
+ */
+ public function &getOrderBy();
+
+ /**
+ * Returns a reference to the group-by array for this query.
+ *
+ * Because this method returns by reference, alter hooks may edit the group-by
+ * array directly to make their changes. If just adding additional grouping
+ * fields, however, the use of groupBy() is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getGroupBy();
+ * @endcode
+ *
+ * @return
+ * A reference to the group-by array structure.
+ */
+ public function &getGroupBy();
+
+ /**
+ * Returns a reference to the tables array for this query.
+ *
+ * Because this method returns by reference, alter hooks may edit the tables
+ * array directly to make their changes. If just adding tables, however, the
+ * use of the join() methods is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getTables();
+ * @endcode
+ *
+ * @return
+ * A reference to the tables array structure.
+ */
+ public function &getTables();
+
+ /**
+ * Returns a reference to the union queries for this query. This include
+ * queries for UNION, UNION ALL, and UNION DISTINCT.
+ *
+ * Because this method returns by reference, alter hooks may edit the tables
+ * array directly to make their changes. If just adding union queries,
+ * however, the use of the union() method is preferred.
+ *
+ * Note that this method must be called by reference as well:
+ *
+ * @code
+ * $fields =& $query->getUnion();
+ * @endcode
+ *
+ * @return
+ * A reference to the union query array structure.
+ */
+ public function &getUnion();
+
+ /**
+ * Compiles and returns an associative array of the arguments for this prepared statement.
+ *
+ * @param $queryPlaceholder
+ * When collecting the arguments of a subquery, the main placeholder
+ * object should be passed as this parameter.
+ *
+ * @return
+ * An associative array of all placeholder arguments for this query.
+ */
+ public function getArguments(QueryPlaceholderInterface $queryPlaceholder = NULL);
+
+ /* Query building operations */
+
+ /**
+ * Sets this query to be DISTINCT.
+ *
+ * @param $distinct
+ * TRUE to flag this query DISTINCT, FALSE to disable it.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function distinct($distinct = TRUE);
+
+ /**
+ * Adds a field to the list to be SELECTed.
+ *
+ * @param $table_alias
+ * The name of the table from which the field comes, as an alias. Generally
+ * you will want to use the return value of join() here to ensure that it is
+ * valid.
+ * @param $field
+ * The name of the field.
+ * @param $alias
+ * The alias for this field. If not specified, one will be generated
+ * automatically based on the $table_alias and $field. The alias will be
+ * checked for uniqueness, so the requested alias may not be the alias
+ * that is assigned in all cases.
+ * @return
+ * The unique alias that was assigned for this field.
+ */
+ public function addField($table_alias, $field, $alias = NULL);
+
+ /**
+ * Add multiple fields from the same table to be SELECTed.
+ *
+ * This method does not return the aliases set for the passed fields. In the
+ * majority of cases that is not a problem, as the alias will be the field
+ * name. However, if you do need to know the alias you can call getFields()
+ * and examine the result to determine what alias was created. Alternatively,
+ * simply use addField() for the few fields you care about and this method for
+ * the rest.
+ *
+ * @param $table_alias
+ * The name of the table from which the field comes, as an alias. Generally
+ * you will want to use the return value of join() here to ensure that it is
+ * valid.
+ * @param $fields
+ * An indexed array of fields present in the specified table that should be
+ * included in this query. If not specified, $table_alias.* will be generated
+ * without any aliases.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function fields($table_alias, array $fields = array());
+
+ /**
+ * Adds an expression to the list of "fields" to be SELECTed.
+ *
+ * An expression can be any arbitrary string that is valid SQL. That includes
+ * various functions, which may in some cases be database-dependent. This
+ * method makes no effort to correct for database-specific functions.
+ *
+ * @param $expression
+ * The expression string. May contain placeholders.
+ * @param $alias
+ * The alias for this expression. If not specified, one will be generated
+ * automatically in the form "expression_#". The alias will be checked for
+ * uniqueness, so the requested alias may not be the alias that is assigned
+ * in all cases.
+ * @param $arguments
+ * Any placeholder arguments needed for this expression.
+ * @return
+ * The unique alias that was assigned for this expression.
+ */
+ public function addExpression($expression, $alias = NULL, $arguments = array());
+
+ /**
+ * Default Join against another table in the database.
+ *
+ * This method is a convenience method for innerJoin().
+ *
+ * @param $table
+ * The table against which to join.
+ * @param $alias
+ * The alias for the table. In most cases this should be the first letter
+ * of the table, or the first letter of each "word" in the table.
+ * @param $condition
+ * The condition on which to join this table. If the join requires values,
+ * this clause should use a named placeholder and the value or values to
+ * insert should be passed in the 4th parameter. For the first table joined
+ * on a query, this value is ignored as the first table is taken as the base
+ * table. The token %alias can be used in this string to be replaced with
+ * the actual alias. This is useful when $alias is modified by the database
+ * system, for example, when joining the same table more than once.
+ * @param $arguments
+ * An array of arguments to replace into the $condition of this join.
+ * @return
+ * The unique alias that was assigned for this table.
+ */
+ public function join($table, $alias = NULL, $condition = NULL, $arguments = array());
+
+ /**
+ * Inner Join against another table in the database.
+ *
+ * @param $table
+ * The table against which to join.
+ * @param $alias
+ * The alias for the table. In most cases this should be the first letter
+ * of the table, or the first letter of each "word" in the table.
+ * @param $condition
+ * The condition on which to join this table. If the join requires values,
+ * this clause should use a named placeholder and the value or values to
+ * insert should be passed in the 4th parameter. For the first table joined
+ * on a query, this value is ignored as the first table is taken as the base
+ * table. The token %alias can be used in this string to be replaced with
+ * the actual alias. This is useful when $alias is modified by the database
+ * system, for example, when joining the same table more than once.
+ * @param $arguments
+ * An array of arguments to replace into the $condition of this join.
+ * @return
+ * The unique alias that was assigned for this table.
+ */
+ public function innerJoin($table, $alias = NULL, $condition = NULL, $arguments = array());
+
+ /**
+ * Left Outer Join against another table in the database.
+ *
+ * @param $table
+ * The table against which to join.
+ * @param $alias
+ * The alias for the table. In most cases this should be the first letter
+ * of the table, or the first letter of each "word" in the table.
+ * @param $condition
+ * The condition on which to join this table. If the join requires values,
+ * this clause should use a named placeholder and the value or values to
+ * insert should be passed in the 4th parameter. For the first table joined
+ * on a query, this value is ignored as the first table is taken as the base
+ * table. The token %alias can be used in this string to be replaced with
+ * the actual alias. This is useful when $alias is modified by the database
+ * system, for example, when joining the same table more than once.
+ * @param $arguments
+ * An array of arguments to replace into the $condition of this join.
+ * @return
+ * The unique alias that was assigned for this table.
+ */
+ public function leftJoin($table, $alias = NULL, $condition = NULL, $arguments = array());
+
+ /**
+ * Right Outer Join against another table in the database.
+ *
+ * @param $table
+ * The table against which to join.
+ * @param $alias
+ * The alias for the table. In most cases this should be the first letter
+ * of the table, or the first letter of each "word" in the table.
+ * @param $condition
+ * The condition on which to join this table. If the join requires values,
+ * this clause should use a named placeholder and the value or values to
+ * insert should be passed in the 4th parameter. For the first table joined
+ * on a query, this value is ignored as the first table is taken as the base
+ * table. The token %alias can be used in this string to be replaced with
+ * the actual alias. This is useful when $alias is modified by the database
+ * system, for example, when joining the same table more than once.
+ * @param $arguments
+ * An array of arguments to replace into the $condition of this join.
+ * @return
+ * The unique alias that was assigned for this table.
+ */
+ public function rightJoin($table, $alias = NULL, $condition = NULL, $arguments = array());
+
+ /**
+ * Join against another table in the database.
+ *
+ * This method does the "hard" work of queuing up a table to be joined against.
+ * In some cases, that may include dipping into the Schema API to find the necessary
+ * fields on which to join.
+ *
+ * @param $type
+ * The type of join. Typically one one of INNER, LEFT OUTER, and RIGHT OUTER.
+ * @param $table
+ * The table against which to join. May be a string or another SelectQuery
+ * object. If a query object is passed, it will be used as a subselect.
+ * @param $alias
+ * The alias for the table. In most cases this should be the first letter
+ * of the table, or the first letter of each "word" in the table. If omitted,
+ * one will be dynamically generated.
+ * @param $condition
+ * The condition on which to join this table. If the join requires values,
+ * this clause should use a named placeholder and the value or values to
+ * insert should be passed in the 4th parameter. For the first table joined
+ * on a query, this value is ignored as the first table is taken as the base
+ * table. The token %alias can be used in this string to be replaced with
+ * the actual alias. This is useful when $alias is modified by the database
+ * system, for example, when joining the same table more than once.
+ * @param $arguments
+ * An array of arguments to replace into the $condition of this join.
+ * @return
+ * The unique alias that was assigned for this table.
+ */
+ public function addJoin($type, $table, $alias = NULL, $condition = NULL, $arguments = array());
+
+ /**
+ * Orders the result set by a given field.
+ *
+ * If called multiple times, the query will order by each specified field in the
+ * order this method is called.
+ *
+ * If the query uses DISTINCT or GROUP BY conditions, fields or expressions
+ * that are used for the order must be selected to be compatible with some
+ * databases like PostgreSQL. The PostgreSQL driver can handle simple cases
+ * automatically but it is suggested to explicitly specify them. Additionally,
+ * when ordering on an alias, the alias must be added before orderBy() is
+ * called.
+ *
+ * @param $field
+ * The field on which to order.
+ * @param $direction
+ * The direction to sort. Legal values are "ASC" and "DESC".
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function orderBy($field, $direction = 'ASC');
+
+ /**
+ * Orders the result set by a random value.
+ *
+ * This may be stacked with other orderBy() calls. If so, the query will order
+ * by each specified field, including this one, in the order called. Although
+ * this method may be called multiple times on the same query, doing so
+ * is not particularly useful.
+ *
+ * Note: The method used by most drivers may not scale to very large result
+ * sets. If you need to work with extremely large data sets, you may create
+ * your own database driver by subclassing off of an existing driver and
+ * implementing your own randomization mechanism. See
+ *
+ * http://jan.kneschke.de/projects/mysql/order-by-rand/
+ *
+ * for an example of such an alternate sorting mechanism.
+ *
+ * @return SelectQueryInterface
+ * The called object
+ */
+ public function orderRandom();
+
+ /**
+ * Restricts a query to a given range in the result set.
+ *
+ * If this method is called with no parameters, will remove any range
+ * directives that have been set.
+ *
+ * @param $start
+ * The first record from the result set to return. If NULL, removes any
+ * range directives that are set.
+ * @param $length
+ * The number of records to return from the result set.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function range($start = NULL, $length = NULL);
+
+ /**
+ * Add another Select query to UNION to this one.
+ *
+ * Union queries consist of two or more queries whose
+ * results are effectively concatenated together. Queries
+ * will be UNIONed in the order they are specified, with
+ * this object's query coming first. Duplicate columns will
+ * be discarded. All forms of UNION are supported, using
+ * the second '$type' argument.
+ *
+ * Note: All queries UNIONed together must have the same
+ * field structure, in the same order. It is up to the
+ * caller to ensure that they match properly. If they do
+ * not, an SQL syntax error will result.
+ *
+ * @param $query
+ * The query to UNION to this query.
+ * @param $type
+ * The type of UNION to add to the query. Defaults to plain
+ * UNION.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function union(SelectQueryInterface $query, $type = '');
+
+ /**
+ * Groups the result set by the specified field.
+ *
+ * @param $field
+ * The field on which to group. This should be the field as aliased.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function groupBy($field);
+
+ /**
+ * Get the equivalent COUNT query of this query as a new query object.
+ *
+ * @return SelectQueryInterface
+ * A new SelectQuery object with no fields or expressions besides COUNT(*).
+ */
+ public function countQuery();
+
+ /**
+ * Indicates if preExecute() has already been called on that object.
+ *
+ * @return
+ * TRUE is this query has already been prepared, FALSE otherwise.
+ */
+ public function isPrepared();
+
+ /**
+ * Generic preparation and validation for a SELECT query.
+ *
+ * @return
+ * TRUE if the validation was successful, FALSE if not.
+ */
+ public function preExecute(SelectQueryInterface $query = NULL);
+
+ /**
+ * Helper function to build most common HAVING conditional clauses.
+ *
+ * This method can take a variable number of parameters. If called with two
+ * parameters, they are taken as $field and $value with $operator having a value
+ * of IN if $value is an array and = otherwise.
+ *
+ * @param $field
+ * The name of the field to check. If you would like to add a more complex
+ * condition involving operators or functions, use having().
+ * @param $value
+ * The value to test the field against. In most cases, this is a scalar. For more
+ * complex options, it is an array. The meaning of each element in the array is
+ * dependent on the $operator.
+ * @param $operator
+ * The comparison operator, such as =, <, or >=. It also accepts more complex
+ * options such as IN, LIKE, or BETWEEN. Defaults to IN if $value is an array
+ * = otherwise.
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function havingCondition($field, $value = NULL, $operator = NULL);
+
+ /**
+ * Clone magic method.
+ *
+ * Select queries have dependent objects that must be deep-cloned. The
+ * connection object itself, however, should not be cloned as that would
+ * duplicate the connection itself.
+ */
+ public function __clone();
+
+ /**
+ * Add FOR UPDATE to the query.
+ *
+ * FOR UPDATE prevents the rows retrieved by the SELECT statement from being
+ * modified or deleted by other transactions until the current transaction
+ * ends. Other transactions that attempt UPDATE, DELETE, or SELECT FOR UPDATE
+ * of these rows will be blocked until the current transaction ends.
+ *
+ * @param $set
+ * IF TRUE, FOR UPDATE will be added to the query, if FALSE then it won't.
+ *
+ * @return QueryConditionInterface
+ * The called object.
+ */
+ public function forUpdate($set = TRUE);
+}
+
+/**
+ * The base extender class for Select queries.
+ */
+class SelectQueryExtender implements SelectQueryInterface {
+
+ /**
+ * The SelectQuery object we are extending/decorating.
+ *
+ * @var SelectQueryInterface
+ */
+ protected $query;
+
+ /**
+ * The connection object on which to run this query.
+ *
+ * @var DatabaseConnection
+ */
+ protected $connection;
+
+ /**
+ * A unique identifier for this query object.
+ */
+ protected $uniqueIdentifier;
+
+ /**
+ * The placeholder counter.
+ */
+ protected $placeholder = 0;
+
+ public function __construct(SelectQueryInterface $query, DatabaseConnection $connection) {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+ $this->query = $query;
+ $this->connection = $connection;
+ }
+
+ /**
+ * Implements QueryPlaceholderInterface::uniqueIdentifier().
+ */
+ public function uniqueIdentifier() {
+ return $this->uniqueIdentifier;
+ }
+
+ /**
+ * Implements QueryPlaceholderInterface::nextPlaceholder().
+ */
+ public function nextPlaceholder() {
+ return $this->placeholder++;
+ }
+
+ /* Implementations of QueryAlterableInterface. */
+
+ public function addTag($tag) {
+ $this->query->addTag($tag);
+ return $this;
+ }
+
+ public function hasTag($tag) {
+ return $this->query->hasTag($tag);
+ }
+
+ public function hasAllTags() {
+ return call_user_func_array(array($this->query, 'hasAllTags'), func_get_args());
+ }
+
+ public function hasAnyTag() {
+ return call_user_func_array(array($this->query, 'hasAnyTags'), func_get_args());
+ }
+
+ public function addMetaData($key, $object) {
+ $this->query->addMetaData($key, $object);
+ return $this;
+ }
+
+ public function getMetaData($key) {
+ return $this->query->getMetaData($key);
+ }
+
+ /* Implementations of QueryConditionInterface for the WHERE clause. */
+
+ public function condition($field, $value = NULL, $operator = NULL) {
+ $this->query->condition($field, $value, $operator);
+ return $this;
+ }
+
+ public function &conditions() {
+ return $this->query->conditions();
+ }
+
+ public function arguments() {
+ return $this->query->arguments();
+ }
+
+ public function where($snippet, $args = array()) {
+ $this->query->where($snippet, $args);
+ return $this;
+ }
+
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ return $this->query->compile($connection, $queryPlaceholder);
+ }
+
+ public function compiled() {
+ return $this->query->compiled();
+ }
+
+ /* Implementations of QueryConditionInterface for the HAVING clause. */
+
+ public function havingCondition($field, $value = NULL, $operator = '=') {
+ $this->query->condition($field, $value, $operator, $num_args);
+ return $this;
+ }
+
+ public function &havingConditions() {
+ return $this->having->conditions();
+ }
+
+ public function havingArguments() {
+ return $this->having->arguments();
+ }
+
+ public function having($snippet, $args = array()) {
+ $this->query->having($snippet, $args);
+ return $this;
+ }
+
+ public function havingCompile(DatabaseConnection $connection) {
+ return $this->query->havingCompile($connection);
+ }
+
+ /* Implementations of QueryExtendableInterface. */
+
+ public function extend($extender_name) {
+ // The extender can be anywhere so this needs to go to the registry, which
+ // is surely loaded by now.
+ $class = $this->connection->getDriverClass($extender_name, array(), TRUE);
+ return new $class($this, $this->connection);
+ }
+
+ /* Alter accessors to expose the query data to alter hooks. */
+
+ public function &getFields() {
+ return $this->query->getFields();
+ }
+
+ public function &getExpressions() {
+ return $this->query->getExpressions();
+ }
+
+ public function &getOrderBy() {
+ return $this->query->getOrderBy();
+ }
+
+ public function &getGroupBy() {
+ return $this->query->getGroupBy();
+ }
+
+ public function &getTables() {
+ return $this->query->getTables();
+ }
+
+ public function &getUnion() {
+ return $this->query->getUnion();
+ }
+
+ public function getArguments(QueryPlaceholderInterface $queryPlaceholder = NULL) {
+ return $this->query->getArguments($queryPlaceholder);
+ }
+
+ public function isPrepared() {
+ return $this->query->isPrepared();
+ }
+
+ public function preExecute(SelectQueryInterface $query = NULL) {
+ // If no query object is passed in, use $this.
+ if (!isset($query)) {
+ $query = $this;
+ }
+
+ return $this->query->preExecute($query);
+ }
+
+ public function execute() {
+ // By calling preExecute() here, we force it to preprocess the extender
+ // object rather than just the base query object. That means
+ // hook_query_alter() gets access to the extended object.
+ if (!$this->preExecute($this)) {
+ return NULL;
+ }
+
+ return $this->query->execute();
+ }
+
+ public function distinct($distinct = TRUE) {
+ $this->query->distinct($distinct);
+ return $this;
+ }
+
+ public function addField($table_alias, $field, $alias = NULL) {
+ return $this->query->addField($table_alias, $field, $alias);
+ }
+
+ public function fields($table_alias, array $fields = array()) {
+ $this->query->fields($table_alias, $fields);
+ return $this;
+ }
+
+ public function addExpression($expression, $alias = NULL, $arguments = array()) {
+ return $this->query->addExpression($expression, $alias, $arguments);
+ }
+
+ public function join($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->query->join($table, $alias, $condition, $arguments);
+ }
+
+ public function innerJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->query->innerJoin($table, $alias, $condition, $arguments);
+ }
+
+ public function leftJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->query->leftJoin($table, $alias, $condition, $arguments);
+ }
+
+ public function rightJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->query->rightJoin($table, $alias, $condition, $arguments);
+ }
+
+ public function addJoin($type, $table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->query->addJoin($type, $table, $alias, $condition, $arguments);
+ }
+
+ public function orderBy($field, $direction = 'ASC') {
+ $this->query->orderBy($field, $direction);
+ return $this;
+ }
+
+ public function orderRandom() {
+ $this->query->orderRandom();
+ return $this;
+ }
+
+ public function range($start = NULL, $length = NULL) {
+ $this->query->range($start, $length);
+ return $this;
+ }
+
+ public function union(SelectQueryInterface $query, $type = '') {
+ $this->query->union($query, $type);
+ return $this;
+ }
+
+ public function groupBy($field) {
+ $this->query->groupBy($field);
+ return $this;
+ }
+
+ public function forUpdate($set = TRUE) {
+ $this->query->forUpdate($set);
+ return $this;
+ }
+
+ public function countQuery() {
+ // Create our new query object that we will mutate into a count query.
+ $count = clone($this);
+
+ // Zero-out existing fields and expressions.
+ $fields =& $count->getFields();
+ $fields = array();
+ $expressions =& $count->getExpressions();
+ $expressions = array();
+
+ // Also remove 'all_fields' statements, which are expanded into tablename.*
+ // when the query is executed.
+ $tables = &$count->getTables();
+ foreach ($tables as $alias => &$table) {
+ unset($table['all_fields']);
+ }
+
+ // Ordering a count query is a waste of cycles, and breaks on some
+ // databases anyway.
+ $orders = &$count->getOrderBy();
+ $orders = array();
+
+ // COUNT() is an expression, so we add that back in.
+ $count->addExpression('COUNT(*)');
+
+ return $count;
+ }
+
+ function isNull($field) {
+ $this->query->isNull($field);
+ return $this;
+ }
+
+ function isNotNull($field) {
+ $this->query->isNotNull($field);
+ return $this;
+ }
+
+ public function exists(SelectQueryInterface $select) {
+ $this->query->exists($select);
+ return $this;
+ }
+
+ public function notExists(SelectQueryInterface $select) {
+ $this->query->notExists($select);
+ return $this;
+ }
+
+ public function __toString() {
+ return (string) $this->query;
+ }
+
+ public function __clone() {
+ $this->uniqueIdentifier = uniqid('', TRUE);
+
+ // We need to deep-clone the query we're wrapping, which in turn may
+ // deep-clone other objects. Exciting!
+ $this->query = clone($this->query);
+ }
+
+ /**
+ * Magic override for undefined methods.
+ *
+ * If one extender extends another extender, then methods in the inner extender
+ * will not be exposed on the outer extender. That's because we cannot know
+ * in advance what those methods will be, so we cannot provide wrapping
+ * implementations as we do above. Instead, we use this slower catch-all method
+ * to handle any additional methods.
+ */
+ public function __call($method, $args) {
+ $return = call_user_func_array(array($this->query, $method), $args);
+
+ // Some methods will return the called object as part of a fluent interface.
+ // Others will return some useful value. If it's a value, then the caller
+ // probably wants that value. If it's the called object, then we instead
+ // return this object. That way we don't "lose" an extender layer when
+ // chaining methods together.
+ if ($return instanceof SelectQueryInterface) {
+ return $this;
+ }
+ else {
+ return $return;
+ }
+ }
+}
+
+/**
+ * Query builder for SELECT statements.
+ */
+class SelectQuery extends Query implements SelectQueryInterface {
+
+ /**
+ * The fields to SELECT.
+ *
+ * @var array
+ */
+ protected $fields = array();
+
+ /**
+ * The expressions to SELECT as virtual fields.
+ *
+ * @var array
+ */
+ protected $expressions = array();
+
+ /**
+ * The tables against which to JOIN.
+ *
+ * This property is a nested array. Each entry is an array representing
+ * a single table against which to join. The structure of each entry is:
+ *
+ * array(
+ * 'type' => $join_type (one of INNER, LEFT OUTER, RIGHT OUTER),
+ * 'table' => $table,
+ * 'alias' => $alias_of_the_table,
+ * 'condition' => $condition_clause_on_which_to_join,
+ * 'arguments' => $array_of_arguments_for_placeholders_in_the condition.
+ * 'all_fields' => TRUE to SELECT $alias.*, FALSE or NULL otherwise.
+ * )
+ *
+ * If $table is a string, it is taken as the name of a table. If it is
+ * a SelectQuery object, it is taken as a subquery.
+ *
+ * @var array
+ */
+ protected $tables = array();
+
+ /**
+ * The fields by which to order this query.
+ *
+ * This is an associative array. The keys are the fields to order, and the value
+ * is the direction to order, either ASC or DESC.
+ *
+ * @var array
+ */
+ protected $order = array();
+
+ /**
+ * The fields by which to group.
+ *
+ * @var array
+ */
+ protected $group = array();
+
+ /**
+ * The conditional object for the WHERE clause.
+ *
+ * @var DatabaseCondition
+ */
+ protected $where;
+
+ /**
+ * The conditional object for the HAVING clause.
+ *
+ * @var DatabaseCondition
+ */
+ protected $having;
+
+ /**
+ * Whether or not this query should be DISTINCT
+ *
+ * @var boolean
+ */
+ protected $distinct = FALSE;
+
+ /**
+ * The range limiters for this query.
+ *
+ * @var array
+ */
+ protected $range;
+
+ /**
+ * An array whose elements specify a query to UNION, and the UNION type. The
+ * 'type' key may be '', 'ALL', or 'DISTINCT' to represent a 'UNION',
+ * 'UNION ALL', or 'UNION DISTINCT' statement, respectively.
+ *
+ * All entries in this array will be applied from front to back, with the
+ * first query to union on the right of the original query, the second union
+ * to the right of the first, etc.
+ *
+ * @var array
+ */
+ protected $union = array();
+
+ /**
+ * Indicates if preExecute() has already been called.
+ * @var boolean
+ */
+ protected $prepared = FALSE;
+
+ /**
+ * The FOR UPDATE status
+ */
+ protected $forUpdate = FALSE;
+
+ public function __construct($table, $alias = NULL, DatabaseConnection $connection, $options = array()) {
+ $options['return'] = Database::RETURN_STATEMENT;
+ parent::__construct($connection, $options);
+ $this->where = new DatabaseCondition('AND');
+ $this->having = new DatabaseCondition('AND');
+ $this->addJoin(NULL, $table, $alias);
+ }
+
+ /* Implementations of QueryAlterableInterface. */
+
+ public function addTag($tag) {
+ $this->alterTags[$tag] = 1;
+ return $this;
+ }
+
+ public function hasTag($tag) {
+ return isset($this->alterTags[$tag]);
+ }
+
+ public function hasAllTags() {
+ return !(boolean)array_diff(func_get_args(), array_keys($this->alterTags));
+ }
+
+ public function hasAnyTag() {
+ return (boolean)array_intersect(func_get_args(), array_keys($this->alterTags));
+ }
+
+ public function addMetaData($key, $object) {
+ $this->alterMetaData[$key] = $object;
+ return $this;
+ }
+
+ public function getMetaData($key) {
+ return isset($this->alterMetaData[$key]) ? $this->alterMetaData[$key] : NULL;
+ }
+
+ /* Implementations of QueryConditionInterface for the WHERE clause. */
+
+ public function condition($field, $value = NULL, $operator = NULL) {
+ $this->where->condition($field, $value, $operator);
+ return $this;
+ }
+
+ public function &conditions() {
+ return $this->where->conditions();
+ }
+
+ public function arguments() {
+ if (!$this->compiled()) {
+ return NULL;
+ }
+
+ $args = $this->where->arguments() + $this->having->arguments();
+
+ foreach ($this->tables as $table) {
+ if ($table['arguments']) {
+ $args += $table['arguments'];
+ }
+ // If this table is a subquery, grab its arguments recursively.
+ if ($table['table'] instanceof SelectQueryInterface) {
+ $args += $table['table']->arguments();
+ }
+ }
+
+ foreach ($this->expressions as $expression) {
+ if ($expression['arguments']) {
+ $args += $expression['arguments'];
+ }
+ }
+
+ // If there are any dependent queries to UNION,
+ // incorporate their arguments recursively.
+ foreach ($this->union as $union) {
+ $args += $union['query']->arguments();
+ }
+
+ return $args;
+ }
+
+ public function where($snippet, $args = array()) {
+ $this->where->where($snippet, $args);
+ return $this;
+ }
+
+ public function isNull($field) {
+ $this->where->isNull($field);
+ return $this;
+ }
+
+ public function isNotNull($field) {
+ $this->where->isNotNull($field);
+ return $this;
+ }
+
+ public function exists(SelectQueryInterface $select) {
+ $this->where->exists($select);
+ return $this;
+ }
+
+ public function notExists(SelectQueryInterface $select) {
+ $this->where->notExists($select);
+ return $this;
+ }
+
+ public function compile(DatabaseConnection $connection, QueryPlaceholderInterface $queryPlaceholder) {
+ $this->where->compile($connection, $queryPlaceholder);
+ $this->having->compile($connection, $queryPlaceholder);
+
+ foreach ($this->tables as $table) {
+ // If this table is a subquery, compile it recursively.
+ if ($table['table'] instanceof SelectQueryInterface) {
+ $table['table']->compile($connection, $queryPlaceholder);
+ }
+ }
+
+ // If there are any dependent queries to UNION, compile it recursively.
+ foreach ($this->union as $union) {
+ $union['query']->compile($connection, $queryPlaceholder);
+ }
+ }
+
+ public function compiled() {
+ if (!$this->where->compiled() || !$this->having->compiled()) {
+ return FALSE;
+ }
+
+ foreach ($this->tables as $table) {
+ // If this table is a subquery, check its status recursively.
+ if ($table['table'] instanceof SelectQueryInterface) {
+ if (!$table['table']->compiled()) {
+ return FALSE;
+ }
+ }
+ }
+
+ foreach ($this->union as $union) {
+ if (!$union['query']->compiled()) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+ }
+
+ /* Implementations of QueryConditionInterface for the HAVING clause. */
+
+ public function havingCondition($field, $value = NULL, $operator = NULL) {
+ $this->having->condition($field, $value, $operator);
+ return $this;
+ }
+
+ public function &havingConditions() {
+ return $this->having->conditions();
+ }
+
+ public function havingArguments() {
+ return $this->having->arguments();
+ }
+
+ public function having($snippet, $args = array()) {
+ $this->having->where($snippet, $args);
+ return $this;
+ }
+
+ public function havingCompile(DatabaseConnection $connection) {
+ return $this->having->compile($connection, $this);
+ }
+
+ /* Implementations of QueryExtendableInterface. */
+
+ public function extend($extender_name) {
+ $override_class = $extender_name . '_' . $this->connection->driver();
+ if (class_exists($override_class)) {
+ $extender_name = $override_class;
+ }
+ return new $extender_name($this, $this->connection);
+ }
+
+ public function havingIsNull($field) {
+ $this->having->isNull($field);
+ return $this;
+ }
+
+ public function havingIsNotNull($field) {
+ $this->having->isNotNull($field);
+ return $this;
+ }
+
+ public function havingExists(SelectQueryInterface $select) {
+ $this->having->exists($select);
+ return $this;
+ }
+
+ public function havingNotExists(SelectQueryInterface $select) {
+ $this->having->notExists($select);
+ return $this;
+ }
+
+ public function forUpdate($set = TRUE) {
+ if (isset($set)) {
+ $this->forUpdate = $set;
+ }
+ return $this;
+ }
+
+ /* Alter accessors to expose the query data to alter hooks. */
+
+ public function &getFields() {
+ return $this->fields;
+ }
+
+ public function &getExpressions() {
+ return $this->expressions;
+ }
+
+ public function &getOrderBy() {
+ return $this->order;
+ }
+
+ public function &getGroupBy() {
+ return $this->group;
+ }
+
+ public function &getTables() {
+ return $this->tables;
+ }
+
+ public function &getUnion() {
+ return $this->union;
+ }
+
+ public function getArguments(QueryPlaceholderInterface $queryPlaceholder = NULL) {
+ if (!isset($queryPlaceholder)) {
+ $queryPlaceholder = $this;
+ }
+ $this->compile($this->connection, $queryPlaceholder);
+ return $this->arguments();
+ }
+
+ /**
+ * Indicates if preExecute() has already been called on that object.
+ */
+ public function isPrepared() {
+ return $this->prepared;
+ }
+
+ /**
+ * Generic preparation and validation for a SELECT query.
+ *
+ * @return
+ * TRUE if the validation was successful, FALSE if not.
+ */
+ public function preExecute(SelectQueryInterface $query = NULL) {
+ // If no query object is passed in, use $this.
+ if (!isset($query)) {
+ $query = $this;
+ }
+
+ // Only execute this once.
+ if ($query->isPrepared()) {
+ return TRUE;
+ }
+
+ // Modules may alter all queries or only those having a particular tag.
+ if (isset($this->alterTags)) {
+ $hooks = array('query');
+ foreach ($this->alterTags as $tag => $value) {
+ $hooks[] = 'query_' . $tag;
+ }
+ drupal_alter($hooks, $query);
+ }
+
+ $this->prepared = TRUE;
+
+ // Now also prepare any sub-queries.
+ foreach ($this->tables as $table) {
+ if ($table['table'] instanceof SelectQueryInterface) {
+ $table['table']->preExecute();
+ }
+ }
+
+ foreach ($this->union as $union) {
+ $union['query']->preExecute();
+ }
+
+ return $this->prepared;
+ }
+
+ public function execute() {
+ // If validation fails, simply return NULL.
+ // Note that validation routines in preExecute() may throw exceptions instead.
+ if (!$this->preExecute()) {
+ return NULL;
+ }
+
+ $args = $this->getArguments();
+ return $this->connection->query((string) $this, $args, $this->queryOptions);
+ }
+
+ public function distinct($distinct = TRUE) {
+ $this->distinct = $distinct;
+ return $this;
+ }
+
+ public function addField($table_alias, $field, $alias = NULL) {
+ // If no alias is specified, first try the field name itself.
+ if (empty($alias)) {
+ $alias = $field;
+ }
+
+ // If that's already in use, try the table name and field name.
+ if (!empty($this->fields[$alias])) {
+ $alias = $table_alias . '_' . $field;
+ }
+
+ // If that is already used, just add a counter until we find an unused alias.
+ $alias_candidate = $alias;
+ $count = 2;
+ while (!empty($this->fields[$alias_candidate])) {
+ $alias_candidate = $alias . '_' . $count++;
+ }
+ $alias = $alias_candidate;
+
+ $this->fields[$alias] = array(
+ 'field' => $field,
+ 'table' => $table_alias,
+ 'alias' => $alias,
+ );
+
+ return $alias;
+ }
+
+ public function fields($table_alias, array $fields = array()) {
+
+ if ($fields) {
+ foreach ($fields as $field) {
+ // We don't care what alias was assigned.
+ $this->addField($table_alias, $field);
+ }
+ }
+ else {
+ // We want all fields from this table.
+ $this->tables[$table_alias]['all_fields'] = TRUE;
+ }
+
+ return $this;
+ }
+
+ public function addExpression($expression, $alias = NULL, $arguments = array()) {
+ if (empty($alias)) {
+ $alias = 'expression';
+ }
+
+ $alias_candidate = $alias;
+ $count = 2;
+ while (!empty($this->expressions[$alias_candidate])) {
+ $alias_candidate = $alias . '_' . $count++;
+ }
+ $alias = $alias_candidate;
+
+ $this->expressions[$alias] = array(
+ 'expression' => $expression,
+ 'alias' => $alias,
+ 'arguments' => $arguments,
+ );
+
+ return $alias;
+ }
+
+ public function join($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->addJoin('INNER', $table, $alias, $condition, $arguments);
+ }
+
+ public function innerJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->addJoin('INNER', $table, $alias, $condition, $arguments);
+ }
+
+ public function leftJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->addJoin('LEFT OUTER', $table, $alias, $condition, $arguments);
+ }
+
+ public function rightJoin($table, $alias = NULL, $condition = NULL, $arguments = array()) {
+ return $this->addJoin('RIGHT OUTER', $table, $alias, $condition, $arguments);
+ }
+
+ public function addJoin($type, $table, $alias = NULL, $condition = NULL, $arguments = array()) {
+
+ if (empty($alias)) {
+ if ($table instanceof SelectQueryInterface) {
+ $alias = 'subquery';
+ }
+ else {
+ $alias = $table;
+ }
+ }
+
+ $alias_candidate = $alias;
+ $count = 2;
+ while (!empty($this->tables[$alias_candidate])) {
+ $alias_candidate = $alias . '_' . $count++;
+ }
+ $alias = $alias_candidate;
+
+ if (is_string($condition)) {
+ $condition = str_replace('%alias', $alias, $condition);
+ }
+
+ $this->tables[$alias] = array(
+ 'join type' => $type,
+ 'table' => $table,
+ 'alias' => $alias,
+ 'condition' => $condition,
+ 'arguments' => $arguments,
+ );
+
+ return $alias;
+ }
+
+ public function orderBy($field, $direction = 'ASC') {
+ $this->order[$field] = $direction;
+ return $this;
+ }
+
+ public function orderRandom() {
+ $alias = $this->addExpression('RAND()', 'random_field');
+ $this->orderBy($alias);
+ return $this;
+ }
+
+ public function range($start = NULL, $length = NULL) {
+ $this->range = func_num_args() ? array('start' => $start, 'length' => $length) : array();
+ return $this;
+ }
+
+ public function union(SelectQueryInterface $query, $type = '') {
+ // Handle UNION aliasing.
+ switch ($type) {
+ // Fold UNION DISTINCT to UNION for better cross database support.
+ case 'DISTINCT':
+ case '':
+ $type = 'UNION';
+ break;
+
+ case 'ALL':
+ $type = 'UNION ALL';
+ default:
+ }
+
+ $this->union[] = array(
+ 'type' => $type,
+ 'query' => $query,
+ );
+
+ return $this;
+ }
+
+ public function groupBy($field) {
+ $this->group[$field] = $field;
+ return $this;
+ }
+
+ public function countQuery() {
+ // Create our new query object that we will mutate into a count query.
+ $count = clone($this);
+
+ $group_by = $count->getGroupBy();
+
+ if (!$count->distinct) {
+ // When not executing a distinct query, we can zero-out existing fields
+ // and expressions that are not used by a GROUP BY. Fields listed in
+ // the GROUP BY clause need to be present in the query.
+ $fields =& $count->getFields();
+ foreach (array_keys($fields) as $field) {
+ if (empty($group_by[$field])) {
+ unset($fields[$field]);
+ }
+ }
+ $expressions =& $count->getExpressions();
+ foreach (array_keys($expressions) as $field) {
+ if (empty($group_by[$field])) {
+ unset($expressions[$field]);
+ }
+ }
+
+ // Also remove 'all_fields' statements, which are expanded into tablename.*
+ // when the query is executed.
+ foreach ($count->tables as $alias => &$table) {
+ unset($table['all_fields']);
+ }
+ }
+
+ // If we've just removed all fields from the query, make sure there is at
+ // least one so that the query still runs.
+ $count->addExpression('1');
+
+ // Ordering a count query is a waste of cycles, and breaks on some
+ // databases anyway.
+ $orders = &$count->getOrderBy();
+ $orders = array();
+
+ if ($count->distinct && !empty($group_by)) {
+ // If the query is distinct and contains a GROUP BY, we need to remove the
+ // distinct because SQL99 does not support counting on distinct multiple fields.
+ $count->distinct = FALSE;
+ }
+
+ $query = $this->connection->select($count);
+ $query->addExpression('COUNT(*)');
+
+ return $query;
+ }
+
+ public function __toString() {
+ // For convenience, we compile the query ourselves if the caller forgot
+ // to do it. This allows constructs like "(string) $query" to work. When
+ // the query will be executed, it will be recompiled using the proper
+ // placeholder generator anyway.
+ if (!$this->compiled()) {
+ $this->compile($this->connection, $this);
+ }
+
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // SELECT
+ $query = $comments . 'SELECT ';
+ if ($this->distinct) {
+ $query .= 'DISTINCT ';
+ }
+
+ // FIELDS and EXPRESSIONS
+ $fields = array();
+ foreach ($this->tables as $alias => $table) {
+ if (!empty($table['all_fields'])) {
+ $fields[] = $this->connection->escapeTable($alias) . '.*';
+ }
+ }
+ foreach ($this->fields as $alias => $field) {
+ // Always use the AS keyword for field aliases, as some
+ // databases require it (e.g., PostgreSQL).
+ $fields[] = (isset($field['table']) ? $this->connection->escapeTable($field['table']) . '.' : '') . $this->connection->escapeField($field['field']) . ' AS ' . $this->connection->escapeAlias($field['alias']);
+ }
+ foreach ($this->expressions as $alias => $expression) {
+ $fields[] = $expression['expression'] . ' AS ' . $this->connection->escapeAlias($expression['alias']);
+ }
+ $query .= implode(', ', $fields);
+
+
+ // FROM - We presume all queries have a FROM, as any query that doesn't won't need the query builder anyway.
+ $query .= "\nFROM ";
+ foreach ($this->tables as $alias => $table) {
+ $query .= "\n";
+ if (isset($table['join type'])) {
+ $query .= $table['join type'] . ' JOIN ';
+ }
+
+ // If the table is a subquery, compile it and integrate it into this query.
+ if ($table['table'] instanceof SelectQueryInterface) {
+ // Run preparation steps on this sub-query before converting to string.
+ $subquery = $table['table'];
+ $subquery->preExecute();
+ $table_string = '(' . (string) $subquery . ')';
+ }
+ else {
+ $table_string = '{' . $this->connection->escapeTable($table['table']) . '}';
+ }
+
+ // Don't use the AS keyword for table aliases, as some
+ // databases don't support it (e.g., Oracle).
+ $query .= $table_string . ' ' . $this->connection->escapeTable($table['alias']);
+
+ if (!empty($table['condition'])) {
+ $query .= ' ON ' . $table['condition'];
+ }
+ }
+
+ // WHERE
+ if (count($this->where)) {
+ // There is an implicit string cast on $this->condition.
+ $query .= "\nWHERE " . $this->where;
+ }
+
+ // GROUP BY
+ if ($this->group) {
+ $query .= "\nGROUP BY " . implode(', ', $this->group);
+ }
+
+ // HAVING
+ if (count($this->having)) {
+ // There is an implicit string cast on $this->having.
+ $query .= "\nHAVING " . $this->having;
+ }
+
+ // ORDER BY
+ if ($this->order) {
+ $query .= "\nORDER BY ";
+ $fields = array();
+ foreach ($this->order as $field => $direction) {
+ $fields[] = $field . ' ' . $direction;
+ }
+ $query .= implode(', ', $fields);
+ }
+
+ // RANGE
+ // There is no universal SQL standard for handling range or limit clauses.
+ // Fortunately, all core-supported databases use the same range syntax.
+ // Databases that need a different syntax can override this method and
+ // do whatever alternate logic they need to.
+ if (!empty($this->range)) {
+ $query .= "\nLIMIT " . (int) $this->range['length'] . " OFFSET " . (int) $this->range['start'];
+ }
+
+ // UNION is a little odd, as the select queries to combine are passed into
+ // this query, but syntactically they all end up on the same level.
+ if ($this->union) {
+ foreach ($this->union as $union) {
+ $query .= ' ' . $union['type'] . ' ' . (string) $union['query'];
+ }
+ }
+
+ if ($this->forUpdate) {
+ $query .= ' FOR UPDATE';
+ }
+
+ return $query;
+ }
+
+ public function __clone() {
+ // On cloning, also clone the dependent objects. However, we do not
+ // want to clone the database connection object as that would duplicate the
+ // connection itself.
+
+ $this->where = clone($this->where);
+ $this->having = clone($this->having);
+ foreach ($this->union as $key => $aggregate) {
+ $this->union[$key]['query'] = clone($aggregate['query']);
+ }
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/sqlite/database.inc b/core/includes/database/sqlite/database.inc
new file mode 100644
index 000000000000..4cef16416736
--- /dev/null
+++ b/core/includes/database/sqlite/database.inc
@@ -0,0 +1,511 @@
+<?php
+
+/**
+ * @file
+ * Database interface code for SQLite embedded database engine.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+include_once DRUPAL_ROOT . '/core/includes/database/prefetch.inc';
+
+/**
+ * Specific SQLite implementation of DatabaseConnection.
+ */
+class DatabaseConnection_sqlite extends DatabaseConnection {
+
+ /**
+ * Whether this database connection supports savepoints.
+ *
+ * Version of sqlite lower then 3.6.8 can't use savepoints.
+ * See http://www.sqlite.org/releaselog/3_6_8.html
+ *
+ * @var boolean
+ */
+ protected $savepointSupport = FALSE;
+
+ /**
+ * Whether or not the active transaction (if any) will be rolled back.
+ *
+ * @var boolean
+ */
+ protected $willRollback;
+
+ /**
+ * All databases attached to the current database. This is used to allow
+ * prefixes to be safely handled without locking the table
+ *
+ * @var array
+ */
+ protected $attachedDatabases = array();
+
+ /**
+ * Whether or not a table has been dropped this request: the destructor will
+ * only try to get rid of unnecessary databases if there is potential of them
+ * being empty.
+ *
+ * This variable is set to public because DatabaseSchema_sqlite needs to
+ * access it. However, it should not be manually set.
+ *
+ * @var boolean
+ */
+ var $tableDropped = FALSE;
+
+ public function __construct(array $connection_options = array()) {
+ // We don't need a specific PDOStatement class here, we simulate it below.
+ $this->statementClass = NULL;
+
+ // This driver defaults to transaction support, except if explicitly passed FALSE.
+ $this->transactionSupport = !isset($connection_options['transactions']) || $connection_options['transactions'] !== FALSE;
+
+ $this->connectionOptions = $connection_options;
+
+ parent::__construct('sqlite:' . $connection_options['database'], '', '', array(
+ // Force column names to lower case.
+ PDO::ATTR_CASE => PDO::CASE_LOWER,
+ // Convert numeric values to strings when fetching.
+ PDO::ATTR_STRINGIFY_FETCHES => TRUE,
+ ));
+
+ // Attach one database for each registered prefix.
+ $prefixes = $this->prefixes;
+ foreach ($prefixes as $table => &$prefix) {
+ // Empty prefix means query the main database -- no need to attach anything.
+ if (!empty($prefix)) {
+ // Only attach the database once.
+ if (!isset($this->attachedDatabases[$prefix])) {
+ $this->attachedDatabases[$prefix] = $prefix;
+ $this->query('ATTACH DATABASE :database AS :prefix', array(':database' => $connection_options['database'] . '-' . $prefix, ':prefix' => $prefix));
+ }
+
+ // Add a ., so queries become prefix.table, which is proper syntax for
+ // querying an attached database.
+ $prefix .= '.';
+ }
+ }
+ // Regenerate the prefixes replacement table.
+ $this->setPrefix($prefixes);
+
+ // Detect support for SAVEPOINT.
+ $version = $this->query('SELECT sqlite_version()')->fetchField();
+ $this->savepointSupport = (version_compare($version, '3.6.8') >= 0);
+
+ // Create functions needed by SQLite.
+ $this->sqliteCreateFunction('if', array($this, 'sqlFunctionIf'));
+ $this->sqliteCreateFunction('greatest', array($this, 'sqlFunctionGreatest'));
+ $this->sqliteCreateFunction('pow', 'pow', 2);
+ $this->sqliteCreateFunction('length', 'strlen', 1);
+ $this->sqliteCreateFunction('md5', 'md5', 1);
+ $this->sqliteCreateFunction('concat', array($this, 'sqlFunctionConcat'));
+ $this->sqliteCreateFunction('substring', array($this, 'sqlFunctionSubstring'), 3);
+ $this->sqliteCreateFunction('substring_index', array($this, 'sqlFunctionSubstringIndex'), 3);
+ $this->sqliteCreateFunction('rand', array($this, 'sqlFunctionRand'));
+ }
+
+ /**
+ * Destructor for the SQLite connection.
+ *
+ * We prune empty databases on destruct, but only if tables have been
+ * dropped. This is especially needed when running the test suite, which
+ * creates and destroy databases several times in a row.
+ */
+ public function __destruct() {
+ if ($this->tableDropped && !empty($this->attachedDatabases)) {
+ foreach ($this->attachedDatabases as $prefix) {
+ // Check if the database is now empty, ignore the internal SQLite tables.
+ try {
+ $count = $this->query('SELECT COUNT(*) FROM ' . $prefix . '.sqlite_master WHERE type = :type AND name NOT LIKE :pattern', array(':type' => 'table', ':pattern' => 'sqlite_%'))->fetchField();
+
+ // We can prune the database file if it doesn't have any tables.
+ if ($count == 0) {
+ // Detach the database.
+ $this->query('DETACH DATABASE :schema', array(':schema' => $prefix));
+ // Destroy the database file.
+ unlink($this->connectionOptions['database'] . '-' . $prefix);
+ }
+ }
+ catch (Exception $e) {
+ // Ignore the exception and continue. There is nothing we can do here
+ // to report the error or fail safe.
+ }
+ }
+ }
+ }
+
+ /**
+ * SQLite compatibility implementation for the IF() SQL function.
+ */
+ public function sqlFunctionIf($condition, $expr1, $expr2 = NULL) {
+ return $condition ? $expr1 : $expr2;
+ }
+
+ /**
+ * SQLite compatibility implementation for the GREATEST() SQL function.
+ */
+ public function sqlFunctionGreatest() {
+ $args = func_get_args();
+ foreach ($args as $k => $v) {
+ if (!isset($v)) {
+ unset($args);
+ }
+ }
+ if (count($args)) {
+ return max($args);
+ }
+ else {
+ return NULL;
+ }
+ }
+
+ /**
+ * SQLite compatibility implementation for the CONCAT() SQL function.
+ */
+ public function sqlFunctionConcat() {
+ $args = func_get_args();
+ return implode('', $args);
+ }
+
+ /**
+ * SQLite compatibility implementation for the SUBSTRING() SQL function.
+ */
+ public function sqlFunctionSubstring($string, $from, $length) {
+ return substr($string, $from - 1, $length);
+ }
+
+ /**
+ * SQLite compatibility implementation for the SUBSTRING_INDEX() SQL function.
+ */
+ public function sqlFunctionSubstringIndex($string, $delimiter, $count) {
+ // If string is empty, simply return an empty string.
+ if (empty($string)) {
+ return '';
+ }
+ $end = 0;
+ for ($i = 0; $i < $count; $i++) {
+ $end = strpos($string, $delimiter, $end + 1);
+ if ($end === FALSE) {
+ $end = strlen($string);
+ }
+ }
+ return substr($string, 0, $end);
+ }
+
+ /**
+ * SQLite compatibility implementation for the RAND() SQL function.
+ */
+ public function sqlFunctionRand($seed = NULL) {
+ if (isset($seed)) {
+ mt_srand($seed);
+ }
+ return mt_rand() / mt_getrandmax();
+ }
+
+ /**
+ * SQLite-specific implementation of DatabaseConnection::prepare().
+ *
+ * We don't use prepared statements at all at this stage. We just create
+ * a DatabaseStatement_sqlite object, that will create a PDOStatement
+ * using the semi-private PDOPrepare() method below.
+ */
+ public function prepare($query, $options = array()) {
+ return new DatabaseStatement_sqlite($this, $query, $options);
+ }
+
+ /**
+ * NEVER CALL THIS FUNCTION: YOU MIGHT DEADLOCK YOUR PHP PROCESS.
+ *
+ * This is a wrapper around the parent PDO::prepare method. However, as
+ * the PDO SQLite driver only closes SELECT statements when the PDOStatement
+ * destructor is called and SQLite does not allow data change (INSERT,
+ * UPDATE etc) on a table which has open SELECT statements, you should never
+ * call this function and keep a PDOStatement object alive as that can lead
+ * to a deadlock. This really, really should be private, but as
+ * DatabaseStatement_sqlite needs to call it, we have no other choice but to
+ * expose this function to the world.
+ */
+ public function PDOPrepare($query, array $options = array()) {
+ return parent::prepare($query, $options);
+ }
+
+ public function queryRange($query, $from, $count, array $args = array(), array $options = array()) {
+ return $this->query($query . ' LIMIT ' . (int) $from . ', ' . (int) $count, $args, $options);
+ }
+
+ public function queryTemporary($query, array $args = array(), array $options = array()) {
+ // Generate a new temporary table name and protect it from prefixing.
+ // SQLite requires that temporary tables to be non-qualified.
+ $tablename = $this->generateTemporaryTableName();
+ $prefixes = $this->prefixes;
+ $prefixes[$tablename] = '';
+ $this->setPrefix($prefixes);
+
+ $this->query(preg_replace('/^SELECT/i', 'CREATE TEMPORARY TABLE ' . $tablename . ' AS SELECT', $query), $args, $options);
+ return $tablename;
+ }
+
+ public function driver() {
+ return 'sqlite';
+ }
+
+ public function databaseType() {
+ return 'sqlite';
+ }
+
+ public function mapConditionOperator($operator) {
+ // We don't want to override any of the defaults.
+ static $specials = array(
+ 'LIKE' => array('postfix' => " ESCAPE '\\'"),
+ 'NOT LIKE' => array('postfix' => " ESCAPE '\\'"),
+ );
+ return isset($specials[$operator]) ? $specials[$operator] : NULL;
+ }
+
+ public function prepareQuery($query) {
+ return $this->prepare($this->prefixTables($query));
+ }
+
+ public function nextId($existing_id = 0) {
+ $transaction = $this->startTransaction();
+ // We can safely use literal queries here instead of the slower query
+ // builder because if a given database breaks here then it can simply
+ // override nextId. However, this is unlikely as we deal with short strings
+ // and integers and no known databases require special handling for those
+ // simple cases. If another transaction wants to write the same row, it will
+ // wait until this transaction commits.
+ $stmt = $this->query('UPDATE {sequences} SET value = GREATEST(value, :existing_id) + 1', array(
+ ':existing_id' => $existing_id,
+ ));
+ if (!$stmt->rowCount()) {
+ $this->query('INSERT INTO {sequences} (value) VALUES (:existing_id + 1)', array(
+ ':existing_id' => $existing_id,
+ ));
+ }
+ // The transaction gets committed when the transaction object gets destroyed
+ // because it gets out of scope.
+ return $this->query('SELECT value FROM {sequences}')->fetchField();
+ }
+
+ public function rollback($savepoint_name = 'drupal_transaction') {
+ if ($this->savepointSupport) {
+ return parent::rollBack($savepoint_name);
+ }
+
+ if (!$this->inTransaction()) {
+ throw new DatabaseTransactionNoActiveException();
+ }
+ // A previous rollback to an earlier savepoint may mean that the savepoint
+ // in question has already been rolled back.
+ if (!in_array($savepoint_name, $this->transactionLayers)) {
+ return;
+ }
+
+ // We need to find the point we're rolling back to, all other savepoints
+ // before are no longer needed.
+ while ($savepoint = array_pop($this->transactionLayers)) {
+ if ($savepoint == $savepoint_name) {
+ // Mark whole stack of transactions as needed roll back.
+ $this->willRollback = TRUE;
+ // If it is the last the transaction in the stack, then it is not a
+ // savepoint, it is the transaction itself so we will need to roll back
+ // the transaction rather than a savepoint.
+ if (empty($this->transactionLayers)) {
+ break;
+ }
+ return;
+ }
+ }
+ if ($this->supportsTransactions()) {
+ PDO::rollBack();
+ }
+ }
+
+ public function pushTransaction($name) {
+ if ($this->savepointSupport) {
+ return parent::pushTransaction($name);
+ }
+ if (!$this->supportsTransactions()) {
+ return;
+ }
+ if (isset($this->transactionLayers[$name])) {
+ throw new DatabaseTransactionNameNonUniqueException($name . " is already in use.");
+ }
+ if (!$this->inTransaction()) {
+ PDO::beginTransaction();
+ }
+ $this->transactionLayers[$name] = $name;
+ }
+
+ public function popTransaction($name) {
+ if ($this->savepointSupport) {
+ return parent::popTransaction($name);
+ }
+ if (!$this->supportsTransactions()) {
+ return;
+ }
+ if (!$this->inTransaction()) {
+ throw new DatabaseTransactionNoActiveException();
+ }
+
+ // Commit everything since SAVEPOINT $name.
+ while($savepoint = array_pop($this->transactionLayers)) {
+ if ($savepoint != $name) continue;
+
+ // If there are no more layers left then we should commit or rollback.
+ if (empty($this->transactionLayers)) {
+ // If there was any rollback() we should roll back whole transaction.
+ if ($this->willRollback) {
+ $this->willRollback = FALSE;
+ PDO::rollBack();
+ }
+ elseif (!PDO::commit()) {
+ throw new DatabaseTransactionCommitFailedException();
+ }
+ }
+ else {
+ break;
+ }
+ }
+ }
+
+}
+
+/**
+ * Specific SQLite implementation of DatabaseConnection.
+ *
+ * See DatabaseConnection_sqlite::PDOPrepare() for reasons why we must prefetch
+ * the data instead of using PDOStatement.
+ *
+ * @see DatabaseConnection_sqlite::PDOPrepare()
+ */
+class DatabaseStatement_sqlite extends DatabaseStatementPrefetch implements Iterator, DatabaseStatementInterface {
+
+ /**
+ * SQLite specific implementation of getStatement().
+ *
+ * The PDO SQLite layer doesn't replace numeric placeholders in queries
+ * correctly, and this makes numeric expressions (such as COUNT(*) >= :count)
+ * fail. We replace numeric placeholders in the query ourselves to work
+ * around this bug.
+ *
+ * See http://bugs.php.net/bug.php?id=45259 for more details.
+ */
+ protected function getStatement($query, &$args = array()) {
+ if (count($args)) {
+ // Check if $args is a simple numeric array.
+ if (range(0, count($args) - 1) === array_keys($args)) {
+ // In that case, we have unnamed placeholders.
+ $count = 0;
+ $new_args = array();
+ foreach ($args as $value) {
+ if (is_float($value) || is_int($value)) {
+ if (is_float($value)) {
+ // Force the conversion to float so as not to loose precision
+ // in the automatic cast.
+ $value = sprintf('%F', $value);
+ }
+ $query = substr_replace($query, $value, strpos($query, '?'), 1);
+ }
+ else {
+ $placeholder = ':db_statement_placeholder_' . $count++;
+ $query = substr_replace($query, $placeholder, strpos($query, '?'), 1);
+ $new_args[$placeholder] = $value;
+ }
+ }
+ $args = $new_args;
+ }
+ else {
+ // Else, this is using named placeholders.
+ foreach ($args as $placeholder => $value) {
+ if (is_float($value) || is_int($value)) {
+ if (is_float($value)) {
+ // Force the conversion to float so as not to loose precision
+ // in the automatic cast.
+ $value = sprintf('%F', $value);
+ }
+
+ // We will remove this placeholder from the query as PDO throws an
+ // exception if the number of placeholders in the query and the
+ // arguments does not match.
+ unset($args[$placeholder]);
+ // PDO allows placeholders to not be prefixed by a colon. See
+ // http://marc.info/?l=php-internals&m=111234321827149&w=2 for
+ // more.
+ if ($placeholder[0] != ':') {
+ $placeholder = ":$placeholder";
+ }
+ // When replacing the placeholders, make sure we search for the
+ // exact placeholder. For example, if searching for
+ // ':db_placeholder_1', do not replace ':db_placeholder_11'.
+ $query = preg_replace('/' . preg_quote($placeholder) . '\b/', $value, $query);
+ }
+ }
+ }
+ }
+
+ return $this->dbh->PDOPrepare($query);
+ }
+
+ public function execute($args = array(), $options = array()) {
+ try {
+ $return = parent::execute($args, $options);
+ }
+ catch (PDOException $e) {
+ if (!empty($e->errorInfo[1]) && $e->errorInfo[1] === 17) {
+ // The schema has changed. SQLite specifies that we must resend the query.
+ $return = parent::execute($args, $options);
+ }
+ else {
+ // Rethrow the exception.
+ throw $e;
+ }
+ }
+
+ // In some weird cases, SQLite will prefix some column names by the name
+ // of the table. We post-process the data, by renaming the column names
+ // using the same convention as MySQL and PostgreSQL.
+ $rename_columns = array();
+ foreach ($this->columnNames as $k => $column) {
+ // In some SQLite versions, SELECT DISTINCT(field) will return "(field)"
+ // instead of "field".
+ if (preg_match("/^\((.*)\)$/", $column, $matches)) {
+ $rename_columns[$column] = $matches[1];
+ $this->columnNames[$k] = $matches[1];
+ $column = $matches[1];
+ }
+
+ // Remove "table." prefixes.
+ if (preg_match("/^.*\.(.*)$/", $column, $matches)) {
+ $rename_columns[$column] = $matches[1];
+ $this->columnNames[$k] = $matches[1];
+ }
+ }
+ if ($rename_columns) {
+ // DatabaseStatementPrefetch already extracted the first row,
+ // put it back into the result set.
+ if (isset($this->currentRow)) {
+ $this->data[0] = &$this->currentRow;
+ }
+
+ // Then rename all the columns across the result set.
+ foreach ($this->data as $k => $row) {
+ foreach ($rename_columns as $old_column => $new_column) {
+ $this->data[$k][$new_column] = $this->data[$k][$old_column];
+ unset($this->data[$k][$old_column]);
+ }
+ }
+
+ // Finally, extract the first row again.
+ $this->currentRow = $this->data[0];
+ unset($this->data[0]);
+ }
+
+ return $return;
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/sqlite/install.inc b/core/includes/database/sqlite/install.inc
new file mode 100644
index 000000000000..62cbac381f17
--- /dev/null
+++ b/core/includes/database/sqlite/install.inc
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * SQLite specific install functions
+ */
+
+class DatabaseTasks_sqlite extends DatabaseTasks {
+ protected $pdoDriver = 'sqlite';
+
+ public function name() {
+ return st('SQLite');
+ }
+
+ /**
+ * Minimum engine version.
+ *
+ * @todo: consider upping to 3.6.8 in Drupal 8 to get SAVEPOINT support.
+ */
+ public function minimumVersion() {
+ return '3.3.7';
+ }
+
+ public function getFormOptions($database) {
+ $form = parent::getFormOptions($database);
+
+ // Remove the options that only apply to client/server style databases.
+ unset($form['username'], $form['password'], $form['advanced_options']['host'], $form['advanced_options']['port']);
+
+ // Make the text more accurate for SQLite.
+ $form['database']['#title'] = st('Database file');
+ $form['database']['#description'] = st('The absolute path to the file where @drupal data will be stored. This must be writable by the web server and should exist outside of the web root.', array('@drupal' => drupal_install_profile_distribution_name()));
+ $default_database = conf_path(FALSE, TRUE) . '/files/.ht.sqlite';
+ $form['database']['#default_value'] = empty($database['database']) ? $default_database : $database['database'];
+ return $form;
+ }
+
+ public function validateDatabaseSettings($database) {
+ // Perform standard validation.
+ $errors = parent::validateDatabaseSettings($database);
+
+ // Verify the database is writable.
+ $db_directory = new SplFileInfo(dirname($database['database']));
+ if (!$db_directory->isWritable()) {
+ $errors[$database['driver'] . '][database'] = st('The directory you specified is not writable by the web server.');
+ }
+
+ return $errors;
+ }
+}
+
diff --git a/core/includes/database/sqlite/query.inc b/core/includes/database/sqlite/query.inc
new file mode 100644
index 000000000000..6b8a72f2ab46
--- /dev/null
+++ b/core/includes/database/sqlite/query.inc
@@ -0,0 +1,160 @@
+<?php
+
+/**
+ * @file
+ * Query code for SQLite embedded database engine.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * SQLite specific implementation of InsertQuery.
+ *
+ * We ignore all the default fields and use the clever SQLite syntax:
+ * INSERT INTO table DEFAULT VALUES
+ * for degenerated "default only" queries.
+ */
+class InsertQuery_sqlite extends InsertQuery {
+
+ public function execute() {
+ if (!$this->preExecute()) {
+ return NULL;
+ }
+ if (count($this->insertFields)) {
+ return parent::execute();
+ }
+ else {
+ return $this->connection->query('INSERT INTO {' . $this->table . '} DEFAULT VALUES', array(), $this->queryOptions);
+ }
+ }
+
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ // Produce as many generic placeholders as necessary.
+ $placeholders = array_fill(0, count($this->insertFields), '?');
+
+ // If we're selecting from a SelectQuery, finish building the query and
+ // pass it back, as any remaining options are irrelevant.
+ if (!empty($this->fromQuery)) {
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $this->insertFields) . ') ' . $this->fromQuery;
+ }
+
+ return $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $this->insertFields) . ') VALUES (' . implode(', ', $placeholders) . ')';
+ }
+
+}
+
+/**
+ * SQLite specific implementation of UpdateQuery.
+ *
+ * SQLite counts all the rows that match the conditions as modified, even if they
+ * will not be affected by the query. We workaround this by ensuring that
+ * we don't select those rows.
+ *
+ * A query like this one:
+ * UPDATE test SET name = 'newname' WHERE tid = 1
+ * will become:
+ * UPDATE test SET name = 'newname' WHERE tid = 1 AND name <> 'newname'
+ */
+class UpdateQuery_sqlite extends UpdateQuery {
+ /**
+ * Helper function that removes the fields that are already in a condition.
+ *
+ * @param $fields
+ * The fields.
+ * @param QueryConditionInterface $condition
+ * A database condition.
+ */
+ protected function removeFieldsInCondition(&$fields, QueryConditionInterface $condition) {
+ foreach ($condition->conditions() as $child_condition) {
+ if ($child_condition['field'] instanceof QueryConditionInterface) {
+ $this->removeFieldsInCondition($fields, $child_condition['field']);
+ }
+ else {
+ unset($fields[$child_condition['field']]);
+ }
+ }
+ }
+
+ public function execute() {
+ if (!empty($this->queryOptions['sqlite_return_matched_rows'])) {
+ return parent::execute();
+ }
+
+ // Get the fields used in the update query, and remove those that are already
+ // in the condition.
+ $fields = $this->expressionFields + $this->fields;
+ $this->removeFieldsInCondition($fields, $this->condition);
+
+ // Add the inverse of the fields to the condition.
+ $condition = new DatabaseCondition('OR');
+ foreach ($fields as $field => $data) {
+ if (is_array($data)) {
+ // The field is an expression.
+ $condition->where($field . ' <> ' . $data['expression']);
+ $condition->isNull($field);
+ }
+ elseif (!isset($data)) {
+ // The field will be set to NULL.
+ $condition->isNotNull($field);
+ }
+ else {
+ $condition->condition($field, $data, '<>');
+ $condition->isNull($field);
+ }
+ }
+ if (count($condition)) {
+ $condition->compile($this->connection, $this);
+ $this->condition->where((string) $condition, $condition->arguments());
+ }
+ return parent::execute();
+ }
+
+}
+
+/**
+ * SQLite specific implementation of DeleteQuery.
+ *
+ * When the WHERE is omitted from a DELETE statement and the table being deleted
+ * has no triggers, SQLite uses an optimization to erase the entire table content
+ * without having to visit each row of the table individually.
+ *
+ * Prior to SQLite 3.6.5, SQLite does not return the actual number of rows deleted
+ * by that optimized "truncate" optimization.
+ */
+class DeleteQuery_sqlite extends DeleteQuery {
+ public function execute() {
+ if (!count($this->condition)) {
+ $total_rows = $this->connection->query('SELECT COUNT(*) FROM {' . $this->connection->escapeTable($this->table) . '}')->fetchField();
+ parent::execute();
+ return $total_rows;
+ }
+ else {
+ return parent::execute();
+ }
+ }
+}
+
+/**
+ * SQLite specific implementation of TruncateQuery.
+ *
+ * SQLite doesn't support TRUNCATE, but a DELETE query with no condition has
+ * exactly the effect (it is implemented by DROPing the table).
+ */
+class TruncateQuery_sqlite extends TruncateQuery {
+ public function __toString() {
+ // Create a sanitized comment string to prepend to the query.
+ $comments = $this->connection->makeComment($this->comments);
+
+ return $comments . 'DELETE FROM {' . $this->connection->escapeTable($this->table) . '} ';
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
diff --git a/core/includes/database/sqlite/schema.inc b/core/includes/database/sqlite/schema.inc
new file mode 100644
index 000000000000..c5882f12715d
--- /dev/null
+++ b/core/includes/database/sqlite/schema.inc
@@ -0,0 +1,683 @@
+<?php
+
+/**
+ * @file
+ * Database schema code for SQLite databases.
+ */
+
+
+/**
+ * @ingroup schemaapi
+ * @{
+ */
+
+class DatabaseSchema_sqlite extends DatabaseSchema {
+
+ /**
+ * Override DatabaseSchema::$defaultSchema
+ */
+ protected $defaultSchema = 'main';
+
+ public function tableExists($table) {
+ $info = $this->getPrefixInfo($table);
+
+ // Don't use {} around sqlite_master table.
+ return (bool) $this->connection->query('SELECT 1 FROM ' . $info['schema'] . '.sqlite_master WHERE type = :type AND name = :name', array(':type' => 'table', ':name' => $info['table']))->fetchField();
+ }
+
+ public function fieldExists($table, $column) {
+ $schema = $this->introspectSchema($table);
+ return !empty($schema['fields'][$column]);
+ }
+
+ /**
+ * Generate SQL to create a new table from a Drupal schema definition.
+ *
+ * @param $name
+ * The name of the table to create.
+ * @param $table
+ * A Schema API table definition array.
+ * @return
+ * An array of SQL statements to create the table.
+ */
+ public function createTableSql($name, $table) {
+ $sql = array();
+ $sql[] = "CREATE TABLE {" . $name . "} (\n" . $this->createColumsSql($name, $table) . "\n);\n";
+ return array_merge($sql, $this->createIndexSql($name, $table));
+ }
+
+ /**
+ * Build the SQL expression for indexes.
+ */
+ protected function createIndexSql($tablename, $schema) {
+ $sql = array();
+ $info = $this->getPrefixInfo($tablename);
+ if (!empty($schema['unique keys'])) {
+ foreach ($schema['unique keys'] as $key => $fields) {
+ $sql[] = 'CREATE UNIQUE INDEX ' . $info['schema'] . '.' . $info['table'] . '_' . $key . ' ON ' . $info['table'] . ' (' . $this->createKeySql($fields) . "); \n";
+ }
+ }
+ if (!empty($schema['indexes'])) {
+ foreach ($schema['indexes'] as $key => $fields) {
+ $sql[] = 'CREATE INDEX ' . $info['schema'] . '.' . $info['table'] . '_' . $key . ' ON ' . $info['table'] . ' (' . $this->createKeySql($fields) . "); \n";
+ }
+ }
+ return $sql;
+ }
+
+ /**
+ * Build the SQL expression for creating columns.
+ */
+ protected function createColumsSql($tablename, $schema) {
+ $sql_array = array();
+
+ // Add the SQL statement for each field.
+ foreach ($schema['fields'] as $name => $field) {
+ if (isset($field['type']) && $field['type'] == 'serial') {
+ if (isset($schema['primary key']) && ($key = array_search($name, $schema['primary key'])) !== FALSE) {
+ unset($schema['primary key'][$key]);
+ }
+ }
+ $sql_array[] = $this->createFieldSql($name, $this->processField($field));
+ }
+
+ // Process keys.
+ if (!empty($schema['primary key'])) {
+ $sql_array[] = " PRIMARY KEY (" . $this->createKeySql($schema['primary key']) . ")";
+ }
+
+ return implode(", \n", $sql_array);
+ }
+
+ /**
+ * Build the SQL expression for keys.
+ */
+ protected function createKeySql($fields) {
+ $return = array();
+ foreach ($fields as $field) {
+ if (is_array($field)) {
+ $return[] = $field[0];
+ }
+ else {
+ $return[] = $field;
+ }
+ }
+ return implode(', ', $return);
+ }
+
+ /**
+ * Set database-engine specific properties for a field.
+ *
+ * @param $field
+ * A field description array, as specified in the schema documentation.
+ */
+ protected function processField($field) {
+ if (!isset($field['size'])) {
+ $field['size'] = 'normal';
+ }
+
+ // Set the correct database-engine specific datatype.
+ // In case one is already provided, force it to uppercase.
+ if (isset($field['sqlite_type'])) {
+ $field['sqlite_type'] = drupal_strtoupper($field['sqlite_type']);
+ }
+ else {
+ $map = $this->getFieldTypeMap();
+ $field['sqlite_type'] = $map[$field['type'] . ':' . $field['size']];
+ }
+
+ if (isset($field['type']) && $field['type'] == 'serial') {
+ $field['auto_increment'] = TRUE;
+ }
+
+ return $field;
+ }
+
+ /**
+ * Create an SQL string for a field to be used in table creation or alteration.
+ *
+ * Before passing a field out of a schema definition into this function it has
+ * to be processed by db_processField().
+ *
+ * @param $name
+ * Name of the field.
+ * @param $spec
+ * The field specification, as per the schema data structure format.
+ */
+ protected function createFieldSql($name, $spec) {
+ if (!empty($spec['auto_increment'])) {
+ $sql = $name . " INTEGER PRIMARY KEY AUTOINCREMENT";
+ if (!empty($spec['unsigned'])) {
+ $sql .= ' CHECK (' . $name . '>= 0)';
+ }
+ }
+ else {
+ $sql = $name . ' ' . $spec['sqlite_type'];
+
+ if (in_array($spec['sqlite_type'], array('VARCHAR', 'TEXT')) && isset($spec['length'])) {
+ $sql .= '(' . $spec['length'] . ')';
+ }
+
+ if (isset($spec['not null'])) {
+ if ($spec['not null']) {
+ $sql .= ' NOT NULL';
+ }
+ else {
+ $sql .= ' NULL';
+ }
+ }
+
+ if (!empty($spec['unsigned'])) {
+ $sql .= ' CHECK (' . $name . '>= 0)';
+ }
+
+ if (isset($spec['default'])) {
+ if (is_string($spec['default'])) {
+ $spec['default'] = "'" . $spec['default'] . "'";
+ }
+ $sql .= ' DEFAULT ' . $spec['default'];
+ }
+
+ if (empty($spec['not null']) && !isset($spec['default'])) {
+ $sql .= ' DEFAULT NULL';
+ }
+ }
+ return $sql;
+ }
+
+ /**
+ * This maps a generic data type in combination with its data size
+ * to the engine-specific data type.
+ */
+ public function getFieldTypeMap() {
+ // Put :normal last so it gets preserved by array_flip. This makes
+ // it much easier for modules (such as schema.module) to map
+ // database types back into schema types.
+ // $map does not use drupal_static as its value never changes.
+ static $map = array(
+ 'varchar:normal' => 'VARCHAR',
+ 'char:normal' => 'CHAR',
+
+ 'text:tiny' => 'TEXT',
+ 'text:small' => 'TEXT',
+ 'text:medium' => 'TEXT',
+ 'text:big' => 'TEXT',
+ 'text:normal' => 'TEXT',
+
+ 'serial:tiny' => 'INTEGER',
+ 'serial:small' => 'INTEGER',
+ 'serial:medium' => 'INTEGER',
+ 'serial:big' => 'INTEGER',
+ 'serial:normal' => 'INTEGER',
+
+ 'int:tiny' => 'INTEGER',
+ 'int:small' => 'INTEGER',
+ 'int:medium' => 'INTEGER',
+ 'int:big' => 'INTEGER',
+ 'int:normal' => 'INTEGER',
+
+ 'float:tiny' => 'FLOAT',
+ 'float:small' => 'FLOAT',
+ 'float:medium' => 'FLOAT',
+ 'float:big' => 'FLOAT',
+ 'float:normal' => 'FLOAT',
+
+ 'numeric:normal' => 'NUMERIC',
+
+ 'blob:big' => 'BLOB',
+ 'blob:normal' => 'BLOB',
+ );
+ return $map;
+ }
+
+ public function renameTable($table, $new_name) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot rename %table to %table_new: table %table doesn't exist.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+ if ($this->tableExists($new_name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename %table to %table_new: table %table_new already exists.", array('%table' => $table, '%table_new' => $new_name)));
+ }
+
+ $schema = $this->introspectSchema($table);
+
+ // SQLite doesn't allow you to rename tables outside of the current
+ // database. So the syntax '...RENAME TO database.table' would fail.
+ // So we must determine the full table name here rather than surrounding
+ // the table with curly braces incase the db_prefix contains a reference
+ // to a database outside of our existsing database.
+ $info = $this->getPrefixInfo($new_name);
+ $this->connection->query('ALTER TABLE {' . $table . '} RENAME TO ' . $info['table']);
+
+ // Drop the indexes, there is no RENAME INDEX command in SQLite.
+ if (!empty($schema['unique keys'])) {
+ foreach ($schema['unique keys'] as $key => $fields) {
+ $this->dropIndex($table, $key);
+ }
+ }
+ if (!empty($schema['indexes'])) {
+ foreach ($schema['indexes'] as $index => $fields) {
+ $this->dropIndex($table, $index);
+ }
+ }
+
+ // Recreate the indexes.
+ $statements = $this->createIndexSql($new_name, $schema);
+ foreach ($statements as $statement) {
+ $this->connection->query($statement);
+ }
+ }
+
+ public function dropTable($table) {
+ if (!$this->tableExists($table)) {
+ return FALSE;
+ }
+ $this->connection->tableDropped = TRUE;
+ $this->connection->query('DROP TABLE {' . $table . '}');
+ return TRUE;
+ }
+
+ public function addField($table, $field, $specification, $keys_new = array()) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add field %table.%field: table doesn't exist.", array('%field' => $field, '%table' => $table)));
+ }
+ if ($this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add field %table.%field: field already exists.", array('%field' => $field, '%table' => $table)));
+ }
+
+ // SQLite doesn't have a full-featured ALTER TABLE statement. It only
+ // supports adding new fields to a table, in some simple cases. In most
+ // cases, we have to create a new table and copy the data over.
+ if (empty($keys_new) && (empty($specification['not null']) || isset($specification['default']))) {
+ // When we don't have to create new keys and we are not creating a
+ // NOT NULL column without a default value, we can use the quicker version.
+ $query = 'ALTER TABLE {' . $table . '} ADD ' . $this->createFieldSql($field, $this->processField($specification));
+ $this->connection->query($query);
+
+ // Apply the initial value if set.
+ if (isset($specification['initial'])) {
+ $this->connection->update($table)
+ ->fields(array($field => $specification['initial']))
+ ->execute();
+ }
+ }
+ else {
+ // We cannot add the field directly. Use the slower table alteration
+ // method, starting from the old schema.
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ // Add the new field.
+ $new_schema['fields'][$field] = $specification;
+
+ // Build the mapping between the old fields and the new fields.
+ $mapping = array();
+ if (isset($specification['initial'])) {
+ // If we have a initial value, copy it over.
+ $mapping[$field] = array(
+ 'expression' => ':newfieldinitial',
+ 'arguments' => array(':newfieldinitial' => $specification['initial']),
+ );
+ }
+ else {
+ // Else use the default of the field.
+ $mapping[$field] = NULL;
+ }
+
+ // Add the new indexes.
+ $new_schema += $keys_new;
+
+ $this->alterTable($table, $old_schema, $new_schema, $mapping);
+ }
+ }
+
+ /**
+ * Create a table with a new schema containing the old content.
+ *
+ * As SQLite does not support ALTER TABLE (with a few exceptions) it is
+ * necessary to create a new table and copy over the old content.
+ *
+ * @param $table
+ * Name of the table to be altered.
+ * @param $old_schema
+ * The old schema array for the table.
+ * @param $new_schema
+ * The new schema array for the table.
+ * @param $mapping
+ * An optional mapping between the fields of the old specification and the
+ * fields of the new specification. An associative array, whose keys are
+ * the fields of the new table, and values can take two possible forms:
+ * - a simple string, which is interpreted as the name of a field of the
+ * old table,
+ * - an associative array with two keys 'expression' and 'arguments',
+ * that will be used as an expression field.
+ */
+ protected function alterTable($table, $old_schema, $new_schema, array $mapping = array()) {
+ $i = 0;
+ do {
+ $new_table = $table . '_' . $i++;
+ } while ($this->tableExists($new_table));
+
+ $this->createTable($new_table, $new_schema);
+
+ // Build a SQL query to migrate the data from the old table to the new.
+ $select = $this->connection->select($table);
+
+ // Complete the mapping.
+ $possible_keys = array_keys($new_schema['fields']);
+ $mapping += array_combine($possible_keys, $possible_keys);
+
+ // Now add the fields.
+ foreach ($mapping as $field_alias => $field_source) {
+ // Just ignore this field (ie. use it's default value).
+ if (!isset($field_source)) {
+ continue;
+ }
+
+ if (is_array($field_source)) {
+ $select->addExpression($field_source['expression'], $field_alias, $field_source['arguments']);
+ }
+ else {
+ $select->addField($table, $field_source, $field_alias);
+ }
+ }
+
+ // Execute the data migration query.
+ $this->connection->insert($new_table)
+ ->from($select)
+ ->execute();
+
+ $old_count = $this->connection->query('SELECT COUNT(*) FROM {' . $table . '}')->fetchField();
+ $new_count = $this->connection->query('SELECT COUNT(*) FROM {' . $new_table . '}')->fetchField();
+ if ($old_count == $new_count) {
+ $this->dropTable($table);
+ $this->renameTable($new_table, $table);
+ }
+ }
+
+ /**
+ * Find out the schema of a table.
+ *
+ * This function uses introspection methods provided by the database to
+ * create a schema array. This is useful, for example, during update when
+ * the old schema is not available.
+ *
+ * @param $table
+ * Name of the table.
+ * @return
+ * An array representing the schema, from drupal_get_schema().
+ * @see drupal_get_schema()
+ */
+ protected function introspectSchema($table) {
+ $mapped_fields = array_flip($this->getFieldTypeMap());
+ $schema = array(
+ 'fields' => array(),
+ 'primary key' => array(),
+ 'unique keys' => array(),
+ 'indexes' => array(),
+ );
+
+ $info = $this->getPrefixInfo($table);
+ $result = $this->connection->query('PRAGMA ' . $info['schema'] . '.table_info(' . $info['table'] . ')');
+ foreach ($result as $row) {
+ if (preg_match('/^([^(]+)\((.*)\)$/', $row->type, $matches)) {
+ $type = $matches[1];
+ $length = $matches[2];
+ }
+ else {
+ $type = $row->type;
+ $length = NULL;
+ }
+ if (isset($mapped_fields[$type])) {
+ list($type, $size) = explode(':', $mapped_fields[$type]);
+ $schema['fields'][$row->name] = array(
+ 'type' => $type,
+ 'size' => $size,
+ 'not null' => !empty($row->notnull),
+ 'default' => trim($row->dflt_value, "'"),
+ );
+ if ($length) {
+ $schema['fields'][$row->name]['length'] = $length;
+ }
+ if ($row->pk) {
+ $schema['primary key'][] = $row->name;
+ }
+ }
+ else {
+ new Exception("Unable to parse the column type " . $row->type);
+ }
+ }
+ $indexes = array();
+ $result = $this->connection->query('PRAGMA ' . $info['schema'] . '.index_list(' . $info['table'] . ')');
+ foreach ($result as $row) {
+ if (strpos($row->name, 'sqlite_autoindex_') !== 0) {
+ $indexes[] = array(
+ 'schema_key' => $row->unique ? 'unique keys' : 'indexes',
+ 'name' => $row->name,
+ );
+ }
+ }
+ foreach ($indexes as $index) {
+ $name = $index['name'];
+ // Get index name without prefix.
+ $index_name = substr($name, strlen($info['table']) + 1);
+ $result = $this->connection->query('PRAGMA ' . $info['schema'] . '.index_info(' . $name . ')');
+ foreach ($result as $row) {
+ $schema[$index['schema_key']][$index_name][] = $row->name;
+ }
+ }
+ return $schema;
+ }
+
+ public function dropField($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ return FALSE;
+ }
+
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ unset($new_schema['fields'][$field]);
+ foreach ($new_schema['indexes'] as $index => $fields) {
+ foreach ($fields as $key => $field_name) {
+ if ($field_name == $field) {
+ unset($new_schema['indexes'][$index][$key]);
+ }
+ }
+ // If this index has no more fields then remove it.
+ if (empty($new_schema['indexes'][$index])) {
+ unset($new_schema['indexes'][$index]);
+ }
+ }
+ $this->alterTable($table, $old_schema, $new_schema);
+ return TRUE;
+ }
+
+ public function changeField($table, $field, $field_new, $spec, $keys_new = array()) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot change the definition of field %table.%name: field doesn't exist.", array('%table' => $table, '%name' => $field)));
+ }
+ if (($field != $field_new) && $this->fieldExists($table, $field_new)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot rename field %table.%name to %name_new: target field already exists.", array('%table' => $table, '%name' => $field, '%name_new' => $field_new)));
+ }
+
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ // Map the old field to the new field.
+ if ($field != $field_new) {
+ $mapping[$field_new] = $field;
+ }
+ else {
+ $mapping = array();
+ }
+
+ // Remove the previous definition and swap in the new one.
+ unset($new_schema['fields'][$field]);
+ $new_schema['fields'][$field_new] = $spec;
+
+ // Map the former indexes to the new column name.
+ $new_schema['primary key'] = $this->mapKeyDefinition($new_schema['primary key'], $mapping);
+ foreach (array('unique keys', 'indexes') as $k) {
+ foreach ($new_schema[$k] as &$key_definition) {
+ $key_definition = $this->mapKeyDefinition($key_definition, $mapping);
+ }
+ }
+
+ // Add in the keys from $keys_new.
+ if (isset($keys_new['primary key'])) {
+ $new_schema['primary key'] = $keys_new['primary key'];
+ }
+ foreach (array('unique keys', 'indexes') as $k) {
+ if (!empty($keys_new[$k])) {
+ $new_schema[$k] = $keys_new[$k] + $new_schema[$k];
+ }
+ }
+
+ $this->alterTable($table, $old_schema, $new_schema, $mapping);
+ }
+
+ /**
+ * Utility method: rename columns in an index definition according to a new mapping.
+ *
+ * @param $key_definition
+ * The key definition.
+ * @param $mapping
+ * The new mapping.
+ */
+ protected function mapKeyDefinition(array $key_definition, array $mapping) {
+ foreach ($key_definition as &$field) {
+ // The key definition can be an array($field, $length).
+ if (is_array($field)) {
+ $field = &$field[0];
+ }
+ if (isset($mapping[$field])) {
+ $field = $mapping[$field];
+ }
+ }
+ return $key_definition;
+ }
+
+ public function addIndex($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add index %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->indexExists($table, $name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add index %name to table %table: index already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $schema['indexes'][$name] = $fields;
+ $statements = $this->createIndexSql($table, $schema);
+ foreach ($statements as $statement) {
+ $this->connection->query($statement);
+ }
+ }
+
+ public function indexExists($table, $name) {
+ $info = $this->getPrefixInfo($table);
+
+ return $this->connection->query('PRAGMA ' . $info['schema'] . '.index_info(' . $info['table'] . '_' . $name . ')')->fetchField() != '';
+ }
+
+ public function dropIndex($table, $name) {
+ if (!$this->indexExists($table, $name)) {
+ return FALSE;
+ }
+
+ $info = $this->getPrefixInfo($table);
+
+ $this->connection->query('DROP INDEX ' . $info['schema'] . '.' . $info['table'] . '_' . $name);
+ return TRUE;
+ }
+
+ public function addUniqueKey($table, $name, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add unique key %name to table %table: table doesn't exist.", array('%table' => $table, '%name' => $name)));
+ }
+ if ($this->indexExists($table, $name)) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add unique key %name to table %table: unique key already exists.", array('%table' => $table, '%name' => $name)));
+ }
+
+ $schema['unique keys'][$name] = $fields;
+ $statements = $this->createIndexSql($table, $schema);
+ foreach ($statements as $statement) {
+ $this->connection->query($statement);
+ }
+ }
+
+ public function dropUniqueKey($table, $name) {
+ if (!$this->indexExists($table, $name)) {
+ return FALSE;
+ }
+
+ $info = $this->getPrefixInfo($table);
+
+ $this->connection->query('DROP INDEX ' . $info['schema'] . '.' . $info['table'] . '_' . $name);
+ return TRUE;
+ }
+
+ public function addPrimaryKey($table, $fields) {
+ if (!$this->tableExists($table)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot add primary key to table %table: table doesn't exist.", array('%table' => $table)));
+ }
+
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ if (!empty($new_schema['primary key'])) {
+ throw new DatabaseSchemaObjectExistsException(t("Cannot add primary key to table %table: primary key already exists.", array('%table' => $table)));
+ }
+
+ $new_schema['primary key'] = $fields;
+ $this->alterTable($table, $old_schema, $new_schema);
+ }
+
+ public function dropPrimaryKey($table) {
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ if (empty($new_schema['primary key'])) {
+ return FALSE;
+ }
+
+ unset($new_schema['primary key']);
+ $this->alterTable($table, $old_schema, $new_schema);
+ return TRUE;
+ }
+
+ public function fieldSetDefault($table, $field, $default) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot set default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ $new_schema['fields'][$field]['default'] = $default;
+ $this->alterTable($table, $old_schema, $new_schema);
+ }
+
+ public function fieldSetNoDefault($table, $field) {
+ if (!$this->fieldExists($table, $field)) {
+ throw new DatabaseSchemaObjectDoesNotExistException(t("Cannot remove default value of field %table.%field: field doesn't exist.", array('%table' => $table, '%field' => $field)));
+ }
+
+ $old_schema = $this->introspectSchema($table);
+ $new_schema = $old_schema;
+
+ unset($new_schema['fields'][$field]['default']);
+ $this->alterTable($table, $old_schema, $new_schema);
+ }
+
+ public function findTables($table_expression) {
+ // Don't add the prefix, $table_expression already includes the prefix.
+ $info = $this->getPrefixInfo($table_expression, FALSE);
+
+ // Can't use query placeholders for the schema because the query would have
+ // to be :prefixsqlite_master, which does not work.
+ $result = db_query("SELECT name FROM " . $info['schema'] . ".sqlite_master WHERE type = :type AND name LIKE :table_name", array(
+ ':type' => 'table',
+ ':table_name' => $info['table'],
+ ));
+ return $result->fetchAllKeyed(0, 0);
+ }
+}
diff --git a/core/includes/database/sqlite/select.inc b/core/includes/database/sqlite/select.inc
new file mode 100644
index 000000000000..fb926ef04d31
--- /dev/null
+++ b/core/includes/database/sqlite/select.inc
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @file
+ * Select builder for SQLite embedded database engine.
+ */
+
+/**
+ * @ingroup database
+ * @{
+ */
+
+/**
+ * SQLite specific query builder for SELECT statements.
+ */
+class SelectQuery_sqlite extends SelectQuery {
+ public function forUpdate($set = TRUE) {
+ // SQLite does not support FOR UPDATE so nothing to do.
+ return $this;
+ }
+}
+
+/**
+ * @} End of "ingroup database".
+ */
+
+
diff --git a/core/includes/date.inc b/core/includes/date.inc
new file mode 100644
index 000000000000..27634f9e39ba
--- /dev/null
+++ b/core/includes/date.inc
@@ -0,0 +1,196 @@
+<?php
+
+/**
+ * @file
+ * Initialize the list of date formats and their locales.
+ */
+
+/**
+ * Provides a default system list of date formats for system_date_formats().
+ */
+function system_default_date_formats() {
+ $formats = array();
+
+ // Short date formats.
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'Y-m-d H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'm/d/Y - H:i',
+ 'locales' => array('en-us'),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'd/m/Y - H:i',
+ 'locales' => array('en-gb', 'en-hk', 'en-ie', 'el-gr', 'es-es', 'fr-be', 'fr-fr', 'fr-lu', 'it-it', 'nl-be', 'pt-pt'),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'Y/m/d - H:i',
+ 'locales' => array('en-ca', 'fr-ca', 'no-no', 'sv-se'),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'd.m.Y - H:i',
+ 'locales' => array('de-ch', 'de-de', 'de-lu', 'fi-fi', 'fr-ch', 'is-is', 'pl-pl', 'ro-ro', 'ru-ru'),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'm/d/Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'd/m/Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'Y/m/d - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'M j Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'j M Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'Y M j - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'M j Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'j M Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'short',
+ 'format' => 'Y M j - g:ia',
+ 'locales' => array(),
+ );
+
+ // Medium date formats.
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, Y-m-d H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, m/d/Y - H:i',
+ 'locales' => array('en-us'),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, d/m/Y - H:i',
+ 'locales' => array('en-gb', 'en-hk', 'en-ie', 'el-gr', 'es-es', 'fr-be', 'fr-fr', 'fr-lu', 'it-it', 'nl-be', 'pt-pt'),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, Y/m/d - H:i',
+ 'locales' => array('en-ca', 'fr-ca', 'no-no', 'sv-se'),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'F j, Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'j F, Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'Y, F j - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, m/d/Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, d/m/Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'D, Y/m/d - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'F j, Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'j F Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'Y, F j - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'medium',
+ 'format' => 'j. F Y - G:i',
+ 'locales' => array(),
+ );
+
+ // Long date formats.
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, F j, Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, j F, Y - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, Y, F j - H:i',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, F j, Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, j F Y - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, Y, F j - g:ia',
+ 'locales' => array(),
+ );
+ $formats[] = array(
+ 'type' => 'long',
+ 'format' => 'l, j. F Y - G:i',
+ 'locales' => array(),
+ );
+
+ return $formats;
+}
diff --git a/core/includes/errors.inc b/core/includes/errors.inc
new file mode 100644
index 000000000000..9d3b92a262a6
--- /dev/null
+++ b/core/includes/errors.inc
@@ -0,0 +1,293 @@
+<?php
+
+/**
+ * @file
+ * Functions for error handling
+ */
+
+/**
+ * Error reporting level: display no errors.
+ */
+define('ERROR_REPORTING_HIDE', 0);
+
+/**
+ * Error reporting level: display errors and warnings.
+ */
+define('ERROR_REPORTING_DISPLAY_SOME', 1);
+
+/**
+ * Error reporting level: display all messages.
+ */
+define('ERROR_REPORTING_DISPLAY_ALL', 2);
+
+/**
+ * Map PHP error constants to watchdog severity levels.
+ * The error constants are documented at
+ * http://php.net/manual/en/errorfunc.constants.php
+ *
+ * @ingroup logging_severity_levels
+ */
+function drupal_error_levels() {
+ $types = array(
+ E_ERROR => array('Error', WATCHDOG_ERROR),
+ E_WARNING => array('Warning', WATCHDOG_WARNING),
+ E_PARSE => array('Parse error', WATCHDOG_ERROR),
+ E_NOTICE => array('Notice', WATCHDOG_NOTICE),
+ E_CORE_ERROR => array('Core error', WATCHDOG_ERROR),
+ E_CORE_WARNING => array('Core warning', WATCHDOG_WARNING),
+ E_COMPILE_ERROR => array('Compile error', WATCHDOG_ERROR),
+ E_COMPILE_WARNING => array('Compile warning', WATCHDOG_WARNING),
+ E_USER_ERROR => array('User error', WATCHDOG_ERROR),
+ E_USER_WARNING => array('User warning', WATCHDOG_WARNING),
+ E_USER_NOTICE => array('User notice', WATCHDOG_NOTICE),
+ E_STRICT => array('Strict warning', WATCHDOG_DEBUG),
+ E_RECOVERABLE_ERROR => array('Recoverable fatal error', WATCHDOG_ERROR),
+ E_DEPRECATED => array('Deprecated function', WATCHDOG_DEBUG),
+ E_USER_DEPRECATED => array('User deprecated function', WATCHDOG_DEBUG),
+ );
+
+ return $types;
+}
+
+/**
+ * Custom PHP error handler.
+ *
+ * @param $error_level
+ * The level of the error raised.
+ * @param $message
+ * The error message.
+ * @param $filename
+ * The filename that the error was raised in.
+ * @param $line
+ * The line number the error was raised at.
+ * @param $context
+ * An array that points to the active symbol table at the point the error occurred.
+ */
+function _drupal_error_handler_real($error_level, $message, $filename, $line, $context) {
+ if ($error_level & error_reporting()) {
+ $types = drupal_error_levels();
+ list($severity_msg, $severity_level) = $types[$error_level];
+ $caller = _drupal_get_last_caller(debug_backtrace());
+
+ if (!function_exists('filter_xss_admin')) {
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ }
+
+ // We treat recoverable errors as fatal.
+ _drupal_log_error(array(
+ '%type' => isset($types[$error_level]) ? $severity_msg : 'Unknown error',
+ // The standard PHP error handler considers that the error messages
+ // are HTML. We mimick this behavior here.
+ '!message' => filter_xss_admin($message),
+ '%function' => $caller['function'],
+ '%file' => $caller['file'],
+ '%line' => $caller['line'],
+ 'severity_level' => $severity_level,
+ ), $error_level == E_RECOVERABLE_ERROR);
+ }
+}
+
+/**
+ * Decode an exception, especially to retrive the correct caller.
+ *
+ * @param $exception
+ * The exception object that was thrown.
+ * @return
+ * An error in the format expected by _drupal_log_error().
+ */
+function _drupal_decode_exception($exception) {
+ $message = $exception->getMessage();
+
+ $backtrace = $exception->getTrace();
+ // Add the line throwing the exception to the backtrace.
+ array_unshift($backtrace, array('line' => $exception->getLine(), 'file' => $exception->getFile()));
+
+ // For PDOException errors, we try to return the initial caller,
+ // skipping internal functions of the database layer.
+ if ($exception instanceof PDOException) {
+ // The first element in the stack is the call, the second element gives us the caller.
+ // We skip calls that occurred in one of the classes of the database layer
+ // or in one of its global functions.
+ $db_functions = array('db_query', 'db_query_range');
+ while (!empty($backtrace[1]) && ($caller = $backtrace[1]) &&
+ ((isset($caller['class']) && (strpos($caller['class'], 'Query') !== FALSE || strpos($caller['class'], 'Database') !== FALSE || strpos($caller['class'], 'PDO') !== FALSE)) ||
+ in_array($caller['function'], $db_functions))) {
+ // We remove that call.
+ array_shift($backtrace);
+ }
+ if (isset($exception->query_string, $exception->args)) {
+ $message .= ": " . $exception->query_string . "; " . print_r($exception->args, TRUE);
+ }
+ }
+ $caller = _drupal_get_last_caller($backtrace);
+
+ return array(
+ '%type' => get_class($exception),
+ // The standard PHP exception handler considers that the exception message
+ // is plain-text. We mimick this behavior here.
+ '!message' => check_plain($message),
+ '%function' => $caller['function'],
+ '%file' => $caller['file'],
+ '%line' => $caller['line'],
+ 'severity_level' => WATCHDOG_ERROR,
+ );
+}
+
+/**
+ * Render an error message for an exception without any possibility of a further exception occurring.
+ *
+ * @param $exception
+ * The exception object that was thrown.
+ * @return
+ * An error message.
+ */
+function _drupal_render_exception_safe($exception) {
+ return check_plain(strtr('%type: !message in %function (line %line of %file).', _drupal_decode_exception($exception)));
+}
+
+/**
+ * Determines whether an error should be displayed.
+ *
+ * When in maintenance mode or when error_level is ERROR_REPORTING_DISPLAY_ALL,
+ * all errors should be displayed. For ERROR_REPORTING_DISPLAY_SOME, $error
+ * will be examined to determine if it should be displayed.
+ *
+ * @param $error
+ * Optional error to examine for ERROR_REPORTING_DISPLAY_SOME.
+ *
+ * @return
+ * TRUE if an error should be displayed.
+ */
+function error_displayable($error = NULL) {
+ $error_level = variable_get('error_level', ERROR_REPORTING_DISPLAY_ALL);
+ $updating = (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'update');
+ $all_errors_displayed = ($error_level == ERROR_REPORTING_DISPLAY_ALL);
+ $error_needs_display = ($error_level == ERROR_REPORTING_DISPLAY_SOME &&
+ isset($error) && $error['%type'] != 'Notice' && $error['%type'] != 'Strict warning');
+
+ return ($updating || $all_errors_displayed || $error_needs_display);
+}
+
+/**
+ * Log a PHP error or exception, display an error page in fatal cases.
+ *
+ * @param $error
+ * An array with the following keys: %type, !message, %function, %file, %line
+ * and severity_level. All the parameters are plain-text, with the exception of
+ * !message, which needs to be a safe HTML string.
+ * @param $fatal
+ * TRUE if the error is fatal.
+ */
+function _drupal_log_error($error, $fatal = FALSE) {
+ // Initialize a maintenance theme if the boostrap was not complete.
+ // Do it early because drupal_set_message() triggers a drupal_theme_initialize().
+ if ($fatal && (drupal_get_bootstrap_phase() != DRUPAL_BOOTSTRAP_FULL)) {
+ unset($GLOBALS['theme']);
+ if (!defined('MAINTENANCE_MODE')) {
+ define('MAINTENANCE_MODE', 'error');
+ }
+ drupal_maintenance_theme();
+ }
+
+ // When running inside the testing framework, we relay the errors
+ // to the tested site by the way of HTTP headers.
+ $test_info = &$GLOBALS['drupal_test_info'];
+ if (!empty($test_info['in_child_site']) && !headers_sent() && (!defined('SIMPLETEST_COLLECT_ERRORS') || SIMPLETEST_COLLECT_ERRORS)) {
+ // $number does not use drupal_static as it should not be reset
+ // as it uniquely identifies each PHP error.
+ static $number = 0;
+ $assertion = array(
+ $error['!message'],
+ $error['%type'],
+ array(
+ 'function' => $error['%function'],
+ 'file' => $error['%file'],
+ 'line' => $error['%line'],
+ ),
+ );
+ header('X-Drupal-Assertion-' . $number . ': ' . rawurlencode(serialize($assertion)));
+ $number++;
+ }
+
+ watchdog('php', '%type: !message in %function (line %line of %file).', $error, $error['severity_level']);
+
+ if ($fatal) {
+ drupal_add_http_header('Status', '500 Service unavailable (with message)');
+ }
+
+ if (drupal_is_cli()) {
+ if ($fatal) {
+ // When called from CLI, simply output a plain text message.
+ print html_entity_decode(strip_tags(t('%type: !message in %function (line %line of %file).', $error))). "\n";
+ exit;
+ }
+ }
+
+ if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') {
+ if ($fatal) {
+ // When called from JavaScript, simply output the error message.
+ print t('%type: !message in %function (line %line of %file).', $error);
+ exit;
+ }
+ }
+ else {
+ // Display the message if the current error reporting level allows this type
+ // of message to be displayed, and unconditionnaly in update.php.
+ if (error_displayable($error)) {
+ $class = 'error';
+
+ // If error type is 'User notice' then treat it as debug information
+ // instead of an error message, see dd().
+ if ($error['%type'] == 'User notice') {
+ $error['%type'] = 'Debug';
+ $class = 'status';
+ }
+
+ drupal_set_message(t('%type: !message in %function (line %line of %file).', $error), $class);
+ }
+
+ if ($fatal) {
+ drupal_set_title(t('Error'));
+ // We fallback to a maintenance page at this point, because the page generation
+ // itself can generate errors.
+ print theme('maintenance_page', array('content' => t('The website encountered an unexpected error. Please try again later.')));
+ exit;
+ }
+ }
+}
+
+/**
+ * Gets the last caller from a backtrace.
+ *
+ * @param $backtrace
+ * A standard PHP backtrace.
+ * @return
+ * An associative array with keys 'file', 'line' and 'function'.
+ */
+function _drupal_get_last_caller($backtrace) {
+ // Errors that occur inside PHP internal functions do not generate
+ // information about file and line. Ignore black listed functions.
+ $blacklist = array('debug', '_drupal_error_handler', '_drupal_exception_handler');
+ while (($backtrace && !isset($backtrace[0]['line'])) ||
+ (isset($backtrace[1]['function']) && in_array($backtrace[1]['function'], $blacklist))) {
+ array_shift($backtrace);
+ }
+
+ // The first trace is the call itself.
+ // It gives us the line and the file of the last call.
+ $call = $backtrace[0];
+
+ // The second call give us the function where the call originated.
+ if (isset($backtrace[1])) {
+ if (isset($backtrace[1]['class'])) {
+ $call['function'] = $backtrace[1]['class'] . $backtrace[1]['type'] . $backtrace[1]['function'] . '()';
+ }
+ else {
+ $call['function'] = $backtrace[1]['function'] . '()';
+ }
+ }
+ else {
+ $call['function'] = 'main()';
+ }
+ return $call;
+}
diff --git a/core/includes/file.inc b/core/includes/file.inc
new file mode 100644
index 000000000000..6e6611f3acd3
--- /dev/null
+++ b/core/includes/file.inc
@@ -0,0 +1,2451 @@
+<?php
+
+/**
+ * @file
+ * API for handling file uploads and server file management.
+ */
+
+/**
+ * Manually include stream wrapper code.
+ *
+ * Stream wrapper code is included here because there are cases where
+ * File API is needed before a bootstrap, or in an alternate order (e.g.
+ * maintenance theme).
+ */
+require_once DRUPAL_ROOT . '/core/includes/stream_wrappers.inc';
+
+/**
+ * @defgroup file File interface
+ * @{
+ * Common file handling functions.
+ *
+ * Fields on the file object:
+ * - fid: File ID
+ * - uid: The {users}.uid of the user who is associated with the file.
+ * - filename: Name of the file with no path components. This may differ from
+ * the basename of the filepath if the file is renamed to avoid overwriting
+ * an existing file.
+ * - uri: URI of the file.
+ * - filemime: The file's MIME type.
+ * - filesize: The size of the file in bytes.
+ * - status: A bitmapped field indicating the status of the file. The first 8
+ * bits are reserved for Drupal core. The least significant bit indicates
+ * temporary (0) or permanent (1). Temporary files older than
+ * DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed during cron runs.
+ * - timestamp: UNIX timestamp for the date the file was added to the database.
+ */
+
+/**
+ * Flag used by file_prepare_directory() -- create directory if not present.
+ */
+define('FILE_CREATE_DIRECTORY', 1);
+
+/**
+ * Flag used by file_prepare_directory() -- file permissions may be changed.
+ */
+define('FILE_MODIFY_PERMISSIONS', 2);
+
+/**
+ * Flag for dealing with existing files: Appends number until name is unique.
+ */
+define('FILE_EXISTS_RENAME', 0);
+
+/**
+ * Flag for dealing with existing files: Replace the existing file.
+ */
+define('FILE_EXISTS_REPLACE', 1);
+
+/**
+ * Flag for dealing with existing files: Do nothing and return FALSE.
+ */
+define('FILE_EXISTS_ERROR', 2);
+
+/**
+ * Indicates that the file is permanent and should not be deleted.
+ *
+ * Temporary files older than DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed
+ * during cron runs, but permanent files will not be removed during the file
+ * garbage collection process.
+ */
+define('FILE_STATUS_PERMANENT', 1);
+
+/**
+ * Methods to manage a registry of stream wrappers.
+ */
+
+/**
+ * Drupal stream wrapper registry.
+ *
+ * A stream wrapper is an abstraction of a file system that allows Drupal to
+ * use the same set of methods to access both local files and remote resources.
+ *
+ * Provide a facility for managing and querying user-defined stream wrappers
+ * in PHP. PHP's internal stream_get_wrappers() doesn't return the class
+ * registered to handle a stream, which we need to be able to find the handler
+ * for class instantiation.
+ *
+ * If a module registers a scheme that is already registered with PHP, the
+ * existing scheme will be unregistered and replaced with the specified class.
+ *
+ * A stream is referenced as "scheme://target".
+ *
+ * The optional $filter parameter can be used to retrieve only the stream
+ * wrappers that are appropriate for particular usage. For example, this returns
+ * only stream wrappers that use local file storage:
+ * @code
+ * $local_stream_wrappers = file_get_stream_wrappers(STEAM_WRAPPERS_LOCAL);
+ * @endcode
+ *
+ * The $filter parameter can only filter to types containing a particular flag.
+ * In some cases, you may want to filter to types that do not contain a
+ * particular flag. For example, you may want to retrieve all stream wrappers
+ * that are not writable, or all stream wrappers that are not local. PHP's
+ * array_diff_key() function can be used to help with this. For example, this
+ * returns only stream wrappers that do not use local file storage:
+ * @code
+ * $remote_stream_wrappers = array_diff_key(file_get_stream_wrappers(STREAM_WRAPPERS_ALL), file_get_stream_wrappers(STEAM_WRAPPERS_LOCAL));
+ * @endcode
+ *
+ * @param $filter
+ * (Optional) Filters out all types except those with an on bit for each on
+ * bit in $filter. For example, if $filter is STREAM_WRAPPERS_WRITE_VISIBLE,
+ * which is equal to (STREAM_WRAPPERS_READ | STREAM_WRAPPERS_WRITE |
+ * STREAM_WRAPPERS_VISIBLE), then only stream wrappers with all three of these
+ * bits set are returned. Defaults to STREAM_WRAPPERS_ALL, which returns all
+ * registered stream wrappers.
+ *
+ * @return
+ * An array keyed by scheme, with values containing an array of information
+ * about the stream wrapper, as returned by hook_stream_wrappers(). If $filter
+ * is omitted or set to STREAM_WRAPPERS_ALL, the entire Drupal stream wrapper
+ * registry is returned. Otherwise only the stream wrappers whose 'type'
+ * bitmask has an on bit for each bit specified in $filter are returned.
+ *
+ * @see hook_stream_wrappers()
+ * @see hook_stream_wrappers_alter()
+ */
+function file_get_stream_wrappers($filter = STREAM_WRAPPERS_ALL) {
+ $wrappers_storage = &drupal_static(__FUNCTION__);
+
+ if (!isset($wrappers_storage)) {
+ $wrappers = module_invoke_all('stream_wrappers');
+ foreach ($wrappers as $scheme => $info) {
+ // Add defaults.
+ $wrappers[$scheme] += array('type' => STREAM_WRAPPERS_NORMAL);
+ }
+ drupal_alter('stream_wrappers', $wrappers);
+ $existing = stream_get_wrappers();
+ foreach ($wrappers as $scheme => $info) {
+ // We only register classes that implement our interface.
+ if (in_array('DrupalStreamWrapperInterface', class_implements($info['class']), TRUE)) {
+ // Record whether we are overriding an existing scheme.
+ if (in_array($scheme, $existing, TRUE)) {
+ $wrappers[$scheme]['override'] = TRUE;
+ stream_wrapper_unregister($scheme);
+ }
+ else {
+ $wrappers[$scheme]['override'] = FALSE;
+ }
+ if (($info['type'] & STREAM_WRAPPERS_LOCAL) == STREAM_WRAPPERS_LOCAL) {
+ stream_wrapper_register($scheme, $info['class']);
+ }
+ else {
+ stream_wrapper_register($scheme, $info['class'], STREAM_IS_URL);
+ }
+ }
+ // Pre-populate the static cache with the filters most typically used.
+ $wrappers_storage[STREAM_WRAPPERS_ALL][$scheme] = $wrappers[$scheme];
+ if (($info['type'] & STREAM_WRAPPERS_WRITE_VISIBLE) == STREAM_WRAPPERS_WRITE_VISIBLE) {
+ $wrappers_storage[STREAM_WRAPPERS_WRITE_VISIBLE][$scheme] = $wrappers[$scheme];
+ }
+ }
+ }
+
+ if (!isset($wrappers_storage[$filter])) {
+ $wrappers_storage[$filter] = array();
+ foreach ($wrappers_storage[STREAM_WRAPPERS_ALL] as $scheme => $info) {
+ // Bit-wise filter.
+ if (($info['type'] & $filter) == $filter) {
+ $wrappers_storage[$filter][$scheme] = $info;
+ }
+ }
+ }
+
+ return $wrappers_storage[$filter];
+}
+
+/**
+ * Returns the stream wrapper class name for a given scheme.
+ *
+ * @param $scheme
+ * Stream scheme.
+ *
+ * @return
+ * Return string if a scheme has a registered handler, or FALSE.
+ */
+function file_stream_wrapper_get_class($scheme) {
+ $wrappers = file_get_stream_wrappers();
+ return empty($wrappers[$scheme]) ? FALSE : $wrappers[$scheme]['class'];
+}
+
+/**
+ * Returns the scheme of a URI (e.g. a stream).
+ *
+ * @param $uri
+ * A stream, referenced as "scheme://target".
+ *
+ * @return
+ * A string containing the name of the scheme, or FALSE if none. For example,
+ * the URI "public://example.txt" would return "public".
+ *
+ * @see file_uri_target()
+ */
+function file_uri_scheme($uri) {
+ $position = strpos($uri, '://');
+ return $position ? substr($uri, 0, $position) : FALSE;
+}
+
+/**
+ * Check that the scheme of a stream URI is valid.
+ *
+ * Confirms that there is a registered stream handler for the provided scheme
+ * and that it is callable. This is useful if you want to confirm a valid
+ * scheme without creating a new instance of the registered handler.
+ *
+ * @param $scheme
+ * A URI scheme, a stream is referenced as "scheme://target".
+ *
+ * @return
+ * Returns TRUE if the string is the name of a validated stream,
+ * or FALSE if the scheme does not have a registered handler.
+ */
+function file_stream_wrapper_valid_scheme($scheme) {
+ // Does the scheme have a registered handler that is callable?
+ $class = file_stream_wrapper_get_class($scheme);
+ if (class_exists($class)) {
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+
+/**
+ * Returns the part of an URI after the schema.
+ *
+ * @param $uri
+ * A stream, referenced as "scheme://target".
+ *
+ * @return
+ * A string containing the target (path), or FALSE if none.
+ * For example, the URI "public://sample/test.txt" would return
+ * "sample/test.txt".
+ *
+ * @see file_uri_scheme()
+ */
+function file_uri_target($uri) {
+ $data = explode('://', $uri, 2);
+
+ // Remove erroneous leading or trailing, forward-slashes and backslashes.
+ return count($data) == 2 ? trim($data[1], '\/') : FALSE;
+}
+
+/**
+ * Get the default file stream implementation.
+ *
+ * @return
+ * 'public', 'private' or any other file scheme defined as the default.
+ */
+function file_default_scheme() {
+ return variable_get('file_default_scheme', 'public');
+}
+
+/**
+ * Normalizes a URI by making it syntactically correct.
+ *
+ * A stream is referenced as "scheme://target".
+ *
+ * The following actions are taken:
+ * - Remove trailing slashes from target
+ * - Trim erroneous leading slashes from target. e.g. ":///" becomes "://".
+ *
+ * @param $uri
+ * String reference containing the URI to normalize.
+ *
+ * @return
+ * The normalized URI.
+ */
+function file_stream_wrapper_uri_normalize($uri) {
+ $scheme = file_uri_scheme($uri);
+
+ if ($scheme && file_stream_wrapper_valid_scheme($scheme)) {
+ $target = file_uri_target($uri);
+
+ if ($target !== FALSE) {
+ $uri = $scheme . '://' . $target;
+ }
+ }
+ else {
+ // The default scheme is file://
+ $url = 'file://' . $uri;
+ }
+ return $uri;
+}
+
+/**
+ * Returns a reference to the stream wrapper class responsible for a given URI.
+ *
+ * The scheme determines the stream wrapper class that should be
+ * used by consulting the stream wrapper registry.
+ *
+ * @param $uri
+ * A stream, referenced as "scheme://target".
+ *
+ * @return
+ * Returns a new stream wrapper object appropriate for the given URI or FALSE
+ * if no registered handler could be found. For example, a URI of
+ * "private://example.txt" would return a new private stream wrapper object
+ * (DrupalPrivateStreamWrapper).
+ */
+function file_stream_wrapper_get_instance_by_uri($uri) {
+ $scheme = file_uri_scheme($uri);
+ $class = file_stream_wrapper_get_class($scheme);
+ if (class_exists($class)) {
+ $instance = new $class();
+ $instance->setUri($uri);
+ return $instance;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Returns a reference to the stream wrapper class responsible for a given scheme.
+ *
+ * This helper method returns a stream instance using a scheme. That is, the
+ * passed string does not contain a "://". For example, "public" is a scheme
+ * but "public://" is a URI (stream). This is because the later contains both
+ * a scheme and target despite target being empty.
+ *
+ * Note: the instance URI will be initialized to "scheme://" so that you can
+ * make the customary method calls as if you had retrieved an instance by URI.
+ *
+ * @param $scheme
+ * If the stream was "public://target", "public" would be the scheme.
+ *
+ * @return
+ * Returns a new stream wrapper object appropriate for the given $scheme.
+ * For example, for the public scheme a stream wrapper object
+ * (DrupalPublicStreamWrapper).
+ * FALSE is returned if no registered handler could be found.
+ */
+function file_stream_wrapper_get_instance_by_scheme($scheme) {
+ $class = file_stream_wrapper_get_class($scheme);
+ if (class_exists($class)) {
+ $instance = new $class();
+ $instance->setUri($scheme . '://');
+ return $instance;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Creates a web-accessible URL for a stream to an external or local file.
+ *
+ * Compatibility: normal paths and stream wrappers.
+ * @see http://drupal.org/node/515192
+ *
+ * There are two kinds of local files:
+ * - "managed files", i.e. those stored by a Drupal-compatible stream wrapper.
+ * These are files that have either been uploaded by users or were generated
+ * automatically (for example through CSS aggregation).
+ * - "shipped files", i.e. those outside of the files directory, which ship as
+ * part of Drupal core or contributed modules or themes.
+ *
+ * @param $uri
+ * The URI to a file for which we need an external URL, or the path to a
+ * shipped file.
+ *
+ * @return
+ * A string containing a URL that may be used to access the file.
+ * If the provided string already contains a preceding 'http', 'https', or
+ * '/', nothing is done and the same string is returned. If a stream wrapper
+ * could not be found to generate an external URL, then FALSE is returned.
+ */
+function file_create_url($uri) {
+ // Allow the URI to be altered, e.g. to serve a file from a CDN or static
+ // file server.
+ drupal_alter('file_url', $uri);
+
+ $scheme = file_uri_scheme($uri);
+
+ if (!$scheme) {
+ // Allow for:
+ // - root-relative URIs (e.g. /foo.jpg in http://example.com/foo.jpg)
+ // - protocol-relative URIs (e.g. //bar.jpg, which is expanded to
+ // http://example.com/bar.jpg by the browser when viewing a page over
+ // HTTP and to https://example.com/bar.jpg when viewing a HTTPS page)
+ // Both types of relative URIs are characterized by a leading slash, hence
+ // we can use a single check.
+ if (drupal_substr($uri, 0, 1) == '/') {
+ return $uri;
+ }
+ else {
+ // If this is not a properly formatted stream, then it is a shipped file.
+ // Therefore, return the urlencoded URI with the base URL prepended.
+ return $GLOBALS['base_url'] . '/' . drupal_encode_path($uri);
+ }
+ }
+ elseif ($scheme == 'http' || $scheme == 'https') {
+ // Check for http so that we don't have to implement getExternalUrl() for
+ // the http wrapper.
+ return $uri;
+ }
+ else {
+ // Attempt to return an external URL using the appropriate wrapper.
+ if ($wrapper = file_stream_wrapper_get_instance_by_uri($uri)) {
+ return $wrapper->getExternalUrl();
+ }
+ else {
+ return FALSE;
+ }
+ }
+}
+
+/**
+ * Check that the directory exists and is writable.
+ *
+ * Directories need to have execute permissions to be considered a directory by
+ * FTP servers, etc.
+ *
+ * @param $directory
+ * A string reference containing the name of a directory path or URI. A
+ * trailing slash will be trimmed from a path.
+ * @param $options
+ * A bitmask to indicate if the directory should be created if it does
+ * not exist (FILE_CREATE_DIRECTORY) or made writable if it is read-only
+ * (FILE_MODIFY_PERMISSIONS).
+ *
+ * @return
+ * TRUE if the directory exists (or was created) and is writable. FALSE
+ * otherwise.
+ */
+function file_prepare_directory(&$directory, $options = FILE_MODIFY_PERMISSIONS) {
+ if (!file_stream_wrapper_valid_scheme(file_uri_scheme($directory))) {
+ // Only trim if we're not dealing with a stream.
+ $directory = rtrim($directory, '/\\');
+ }
+
+ // Check if directory exists.
+ if (!is_dir($directory)) {
+ // Let mkdir() recursively create directories and use the default directory
+ // permissions.
+ if (($options & FILE_CREATE_DIRECTORY) && @drupal_mkdir($directory, NULL, TRUE)) {
+ return drupal_chmod($directory);
+ }
+ return FALSE;
+ }
+ // The directory exists, so check to see if it is writable.
+ $writable = is_writable($directory);
+ if (!$writable && ($options & FILE_MODIFY_PERMISSIONS)) {
+ return drupal_chmod($directory);
+ }
+
+ return $writable;
+}
+
+/**
+ * If missing, create a .htaccess file in each Drupal files directory.
+ */
+function file_ensure_htaccess() {
+ file_save_htaccess('public://', FALSE);
+ if (variable_get('file_private_path', FALSE)) {
+ file_save_htaccess('private://', TRUE);
+ }
+ file_save_htaccess('temporary://', TRUE);
+}
+
+/**
+ * Creates an .htaccess file in the given directory.
+ *
+ * @param $directory
+ * The directory.
+ * @param $private
+ * FALSE indicates that $directory should be an open and public directory.
+ * The default is TRUE which indicates a private and protected directory.
+ */
+function file_save_htaccess($directory, $private = TRUE) {
+ if (file_uri_scheme($directory)) {
+ $directory = file_stream_wrapper_uri_normalize($directory);
+ }
+ else {
+ $directory = rtrim($directory, '/\\');
+ }
+ $htaccess_path = $directory . '/.htaccess';
+
+ if (file_exists($htaccess_path)) {
+ // Short circuit if the .htaccess file already exists.
+ return;
+ }
+
+ if ($private) {
+ // Private .htaccess file.
+ $htaccess_lines = "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nDeny from all\nOptions None\nOptions +FollowSymLinks";
+ }
+ else {
+ // Public .htaccess file.
+ $htaccess_lines = "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nOptions None\nOptions +FollowSymLinks";
+ }
+
+ // Write the .htaccess file.
+ if (file_put_contents($htaccess_path, $htaccess_lines)) {
+ drupal_chmod($htaccess_path, 0444);
+ }
+ else {
+ $variables = array('%directory' => $directory, '!htaccess' => '<br />' . nl2br(check_plain($htaccess_lines)));
+ watchdog('security', "Security warning: Couldn't write .htaccess file. Please create a .htaccess file in your %directory directory which contains the following lines: <code>!htaccess</code>", $variables, WATCHDOG_ERROR);
+ }
+}
+
+/**
+ * Loads file objects from the database.
+ *
+ * @param $fids
+ * An array of file IDs.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the {file_managed}
+ * table, where the keys are the database fields and the values are the
+ * values those fields must have. Instead, it is preferable to use
+ * EntityFieldQuery to retrieve a list of entity IDs loadable by
+ * this function.
+ *
+ * @return
+ * An array of file objects, indexed by fid.
+ *
+ * @see hook_file_load()
+ * @see file_load()
+ * @see entity_load()
+ * @see EntityFieldQuery
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function file_load_multiple($fids = array(), $conditions = array()) {
+ return entity_load('file', $fids, $conditions);
+}
+
+/**
+ * Load a file object from the database.
+ *
+ * @param $fid
+ * A file ID.
+ *
+ * @return
+ * A file object.
+ *
+ * @see hook_file_load()
+ * @see file_load_multiple()
+ */
+function file_load($fid) {
+ $files = file_load_multiple(array($fid), array());
+ return reset($files);
+}
+
+/**
+ * Save a file object to the database.
+ *
+ * If the $file->fid is not set a new record will be added.
+ *
+ * @param $file
+ * A file object returned by file_load().
+ *
+ * @return
+ * The updated file object.
+ *
+ * @see hook_file_insert()
+ * @see hook_file_update()
+ */
+function file_save(stdClass $file) {
+ $file->timestamp = REQUEST_TIME;
+ $file->filesize = filesize($file->uri);
+
+ // Load the stored entity, if any.
+ if (!empty($file->fid) && !isset($file->original)) {
+ $file->original = entity_load_unchanged('file', $file->fid);
+ }
+
+ module_invoke_all('file_presave', $file);
+ module_invoke_all('entity_presave', $file, 'file');
+
+ if (empty($file->fid)) {
+ drupal_write_record('file_managed', $file);
+ // Inform modules about the newly added file.
+ module_invoke_all('file_insert', $file);
+ module_invoke_all('entity_insert', $file, 'file');
+ }
+ else {
+ drupal_write_record('file_managed', $file, 'fid');
+ // Inform modules that the file has been updated.
+ module_invoke_all('file_update', $file);
+ module_invoke_all('entity_update', $file, 'file');
+ }
+
+ unset($file->original);
+ return $file;
+}
+
+/**
+ * Determines where a file is used.
+ *
+ * @param $file
+ * A file object.
+ *
+ * @return
+ * A nested array with usage data. The first level is keyed by module name,
+ * the second by object type and the third by the object id. The value
+ * of the third level contains the usage count.
+ *
+ * @see file_usage_add()
+ * @see file_usage_delete()
+ */
+function file_usage_list(stdClass $file) {
+ $result = db_select('file_usage', 'f')
+ ->fields('f', array('module', 'type', 'id', 'count'))
+ ->condition('fid', $file->fid)
+ ->condition('count', 0, '>')
+ ->execute();
+ $references = array();
+ foreach ($result as $usage) {
+ $references[$usage->module][$usage->type][$usage->id] = $usage->count;
+ }
+ return $references;
+}
+
+/**
+ * Records that a module is using a file.
+ *
+ * This usage information will be queried during file_delete() to ensure that
+ * a file is not in use before it is physically removed from disk.
+ *
+ * Examples:
+ * - A module that associates files with nodes, so $type would be
+ * 'node' and $id would be the node's nid. Files for all revisions are stored
+ * within a single nid.
+ * - The User module associates an image with a user, so $type would be 'user'
+ * and the $id would be the user's uid.
+ *
+ * @param $file
+ * A file object.
+ * @param $module
+ * The name of the module using the file.
+ * @param $type
+ * The type of the object that contains the referenced file.
+ * @param $id
+ * The unique, numeric ID of the object containing the referenced file.
+ * @param $count
+ * (optional) The number of references to add to the object. Defaults to 1.
+ *
+ * @see file_usage_list()
+ * @see file_usage_delete()
+ */
+function file_usage_add(stdClass $file, $module, $type, $id, $count = 1) {
+ db_merge('file_usage')
+ ->key(array(
+ 'fid' => $file->fid,
+ 'module' => $module,
+ 'type' => $type,
+ 'id' => $id,
+ ))
+ ->fields(array('count' => $count))
+ ->expression('count', 'count + :count', array(':count' => $count))
+ ->execute();
+}
+
+/**
+ * Removes a record to indicate that a module is no longer using a file.
+ *
+ * The file_delete() function is typically called after removing a file usage
+ * to remove the record from the file_managed table and delete the file itself.
+ *
+ * @param $file
+ * A file object.
+ * @param $module
+ * The name of the module using the file.
+ * @param $type
+ * (optional) The type of the object that contains the referenced file. May
+ * be omitted if all module references to a file are being deleted.
+ * @param $id
+ * (optional) The unique, numeric ID of the object containing the referenced
+ * file. May be omitted if all module references to a file are being deleted.
+ * @param $count
+ * (optional) The number of references to delete from the object. Defaults to
+ * 1. 0 may be specified to delete all references to the file within a
+ * specific object.
+ *
+ * @see file_usage_add()
+ * @see file_usage_list()
+ * @see file_delete()
+ */
+function file_usage_delete(stdClass $file, $module, $type = NULL, $id = NULL, $count = 1) {
+ // Delete rows that have a exact or less value to prevent empty rows.
+ $query = db_delete('file_usage')
+ ->condition('module', $module)
+ ->condition('fid', $file->fid);
+ if ($type && $id) {
+ $query
+ ->condition('type', $type)
+ ->condition('id', $id);
+ }
+ if ($count) {
+ $query->condition('count', $count, '<=');
+ }
+ $result = $query->execute();
+
+ // If the row has more than the specified count decrement it by that number.
+ if (!$result && $count > 0) {
+ $query = db_update('file_usage')
+ ->condition('module', $module)
+ ->condition('fid', $file->fid);
+ if ($type && $id) {
+ $query
+ ->condition('type', $type)
+ ->condition('id', $id);
+ }
+ $query->expression('count', 'count - :count', array(':count' => $count));
+ $query->execute();
+ }
+}
+
+/**
+ * Copies a file to a new location and adds a file record to the database.
+ *
+ * This function should be used when manipulating files that have records
+ * stored in the database. This is a powerful function that in many ways
+ * performs like an advanced version of copy().
+ * - Checks if $source and $destination are valid and readable/writable.
+ * - Checks that $source is not equal to $destination; if they are an error
+ * is reported.
+ * - If file already exists in $destination either the call will error out,
+ * replace the file or rename the file based on the $replace parameter.
+ * - Adds the new file to the files database. If the source file is a
+ * temporary file, the resulting file will also be a temporary file. See
+ * file_save_upload() for details on temporary files.
+ *
+ * @param $source
+ * A file object.
+ * @param $destination
+ * A string containing the destination that $source should be copied to.
+ * This must be a stream wrapper URI.
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file. If a managed file with
+ * the destination name exists then its database entry will be updated. If
+ * no database entry is found then a new one will be created.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * File object if the copy is successful, or FALSE in the event of an error.
+ *
+ * @see file_unmanaged_copy()
+ * @see hook_file_copy()
+ */
+function file_copy(stdClass $source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ if (!file_valid_uri($destination)) {
+ if (($realpath = drupal_realpath($source->uri)) !== FALSE) {
+ watchdog('file', 'File %file (%realpath) could not be copied because the destination %destination is invalid. This is often caused by improper use of file_copy() or a missing stream wrapper.', array('%file' => $source->uri, '%realpath' => $realpath, '%destination' => $destination));
+ }
+ else {
+ watchdog('file', 'File %file could not be copied because the destination %destination is invalid. This is often caused by improper use of file_copy() or a missing stream wrapper.', array('%file' => $source->uri, '%destination' => $destination));
+ }
+ drupal_set_message(t('The specified file %file could not be copied because the destination is invalid. More information is available in the system log.', array('%file' => $source->uri)), 'error');
+ return FALSE;
+ }
+
+ if ($uri = file_unmanaged_copy($source->uri, $destination, $replace)) {
+ $file = clone $source;
+ $file->fid = NULL;
+ $file->uri = $uri;
+ $file->filename = basename($uri);
+ // If we are replacing an existing file re-use its database record.
+ if ($replace == FILE_EXISTS_REPLACE) {
+ $existing_files = file_load_multiple(array(), array('uri' => $uri));
+ if (count($existing_files)) {
+ $existing = reset($existing_files);
+ $file->fid = $existing->fid;
+ $file->filename = $existing->filename;
+ }
+ }
+ // If we are renaming around an existing file (rather than a directory),
+ // use its basename for the filename.
+ elseif ($replace == FILE_EXISTS_RENAME && is_file($destination)) {
+ $file->filename = basename($destination);
+ }
+
+ $file = file_save($file);
+
+ // Inform modules that the file has been copied.
+ module_invoke_all('file_copy', $file, $source);
+
+ return $file;
+ }
+ return FALSE;
+}
+
+/**
+ * Determine whether the URI has a valid scheme for file API operations.
+ *
+ * There must be a scheme and it must be a Drupal-provided scheme like
+ * 'public', 'private', 'temporary', or an extension provided with
+ * hook_stream_wrappers().
+ *
+ * @param $uri
+ * The URI to be tested.
+ *
+ * @return
+ * TRUE if the URI is allowed.
+ */
+function file_valid_uri($uri) {
+ // Assert that the URI has an allowed scheme. Barepaths are not allowed.
+ $uri_scheme = file_uri_scheme($uri);
+ if (empty($uri_scheme) || !file_stream_wrapper_valid_scheme($uri_scheme)) {
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Copies a file to a new location without invoking the file API.
+ *
+ * This is a powerful function that in many ways performs like an advanced
+ * version of copy().
+ * - Checks if $source and $destination are valid and readable/writable.
+ * - Checks that $source is not equal to $destination; if they are an error
+ * is reported.
+ * - If file already exists in $destination either the call will error out,
+ * replace the file or rename the file based on the $replace parameter.
+ *
+ * @param $source
+ * A string specifying the filepath or URI of the source file.
+ * @param $destination
+ * A URI containing the destination that $source should be copied to. The
+ * URI may be a bare filepath (without a scheme) and in that case the default
+ * scheme (file://) will be used. If this value is omitted, Drupal's default
+ * files scheme will be used, usually "public://".
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * The path to the new file, or FALSE in the event of an error.
+ *
+ * @see file_copy()
+ */
+function file_unmanaged_copy($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ $original_source = $source;
+ $original_destination = $destination;
+
+ // Assert that the source file actually exists.
+ if (!file_exists($source)) {
+ // @todo Replace drupal_set_message() calls with exceptions instead.
+ drupal_set_message(t('The specified file %file could not be copied because no file by that name exists. Please check that you supplied the correct filename.', array('%file' => $original_source)), 'error');
+ if (($realpath = drupal_realpath($original_source)) !== FALSE) {
+ watchdog('file', 'File %file (%realpath) could not be copied because it does not exist.', array('%file' => $original_source, '%realpath' => $realpath));
+ }
+ else {
+ watchdog('file', 'File %file could not be copied because it does not exist.', array('%file' => $original_source));
+ }
+ return FALSE;
+ }
+
+ // Build a destination URI if necessary.
+ if (!isset($destination)) {
+ $destination = file_build_uri(basename($source));
+ }
+
+
+ // Prepare the destination directory.
+ if (file_prepare_directory($destination)) {
+ // The destination is already a directory, so append the source basename.
+ $destination = file_stream_wrapper_uri_normalize($destination . '/' . basename($source));
+ }
+ else {
+ // Perhaps $destination is a dir/file?
+ $dirname = drupal_dirname($destination);
+ if (!file_prepare_directory($dirname)) {
+ // The destination is not valid.
+ watchdog('file', 'File %file could not be copied because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
+ drupal_set_message(t('The specified file %file could not be copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
+ return FALSE;
+ }
+ }
+
+ // Determine whether we can perform this operation based on overwrite rules.
+ $destination = file_destination($destination, $replace);
+ if ($destination === FALSE) {
+ drupal_set_message(t('The file %file could not be copied because a file by that name already exists in the destination directory.', array('%file' => $original_source)), 'error');
+ watchdog('file', 'File %file could not be copied because a file by that name already exists in the destination directory (%directory)', array('%file' => $original_source, '%destination' => $destination));
+ return FALSE;
+ }
+
+ // Assert that the source and destination filenames are not the same.
+ $real_source = drupal_realpath($source);
+ $real_destination = drupal_realpath($destination);
+ if ($source == $destination || ($real_source !== FALSE) && ($real_source == $real_destination)) {
+ drupal_set_message(t('The specified file %file was not copied because it would overwrite itself.', array('%file' => $source)), 'error');
+ watchdog('file', 'File %file could not be copied because it would overwrite itself.', array('%file' => $source));
+ return FALSE;
+ }
+ // Make sure the .htaccess files are present.
+ file_ensure_htaccess();
+ // Perform the copy operation.
+ if (!@copy($source, $destination)) {
+ watchdog('file', 'The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination), WATCHDOG_ERROR);
+ return FALSE;
+ }
+
+ // Set the permissions on the new file.
+ drupal_chmod($destination);
+
+ return $destination;
+}
+
+/**
+ * Given a relative path, construct a URI into Drupal's default files location.
+ */
+function file_build_uri($path) {
+ $uri = file_default_scheme() . '://' . $path;
+ return file_stream_wrapper_uri_normalize($uri);
+}
+
+/**
+ * Determines the destination path for a file depending on how replacement of
+ * existing files should be handled.
+ *
+ * @param $destination
+ * A string specifying the desired final URI or filepath.
+ * @param $replace
+ * Replace behavior when the destination file already exists.
+ * - FILE_EXISTS_REPLACE - Replace the existing file.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * The destination filepath, or FALSE if the file already exists
+ * and FILE_EXISTS_ERROR is specified.
+ */
+function file_destination($destination, $replace) {
+ if (file_exists($destination)) {
+ switch ($replace) {
+ case FILE_EXISTS_REPLACE:
+ // Do nothing here, we want to overwrite the existing file.
+ break;
+
+ case FILE_EXISTS_RENAME:
+ $basename = basename($destination);
+ $directory = drupal_dirname($destination);
+ $destination = file_create_filename($basename, $directory);
+ break;
+
+ case FILE_EXISTS_ERROR:
+ // Error reporting handled by calling function.
+ return FALSE;
+ }
+ }
+ return $destination;
+}
+
+/**
+ * Move a file to a new location and update the file's database entry.
+ *
+ * Moving a file is performed by copying the file to the new location and then
+ * deleting the original.
+ * - Checks if $source and $destination are valid and readable/writable.
+ * - Performs a file move if $source is not equal to $destination.
+ * - If file already exists in $destination either the call will error out,
+ * replace the file or rename the file based on the $replace parameter.
+ * - Adds the new file to the files database.
+ *
+ * @param $source
+ * A file object.
+ * @param $destination
+ * A string containing the destination that $source should be moved to.
+ * This must be a stream wrapper URI.
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file. If a managed file with
+ * the destination name exists then its database entry will be updated and
+ * file_delete() called on the source file after hook_file_move is called.
+ * If no database entry is found then the source files record will be
+ * updated.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * Resulting file object for success, or FALSE in the event of an error.
+ *
+ * @see file_unmanaged_move()
+ * @see hook_file_move()
+ */
+function file_move(stdClass $source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ if (!file_valid_uri($destination)) {
+ if (($realpath = drupal_realpath($source->uri)) !== FALSE) {
+ watchdog('file', 'File %file (%realpath) could not be moved because the destination %destination is invalid. This may be caused by improper use of file_move() or a missing stream wrapper.', array('%file' => $source->uri, '%realpath' => $realpath, '%destination' => $destination));
+ }
+ else {
+ watchdog('file', 'File %file could not be moved because the destination %destination is invalid. This may be caused by improper use of file_move() or a missing stream wrapper.', array('%file' => $source->uri, '%destination' => $destination));
+ }
+ drupal_set_message(t('The specified file %file could not be moved because the destination is invalid. More information is available in the system log.', array('%file' => $source->uri)), 'error');
+ return FALSE;
+ }
+
+ if ($uri = file_unmanaged_move($source->uri, $destination, $replace)) {
+ $delete_source = FALSE;
+
+ $file = clone $source;
+ $file->uri = $uri;
+ // If we are replacing an existing file re-use its database record.
+ if ($replace == FILE_EXISTS_REPLACE) {
+ $existing_files = file_load_multiple(array(), array('uri' => $uri));
+ if (count($existing_files)) {
+ $existing = reset($existing_files);
+ $delete_source = TRUE;
+ $file->fid = $existing->fid;
+ }
+ }
+ // If we are renaming around an existing file (rather than a directory),
+ // use its basename for the filename.
+ elseif ($replace == FILE_EXISTS_RENAME && is_file($destination)) {
+ $file->filename = basename($destination);
+ }
+
+ $file = file_save($file);
+
+ // Inform modules that the file has been moved.
+ module_invoke_all('file_move', $file, $source);
+
+ if ($delete_source) {
+ // Try a soft delete to remove original if it's not in use elsewhere.
+ file_delete($source);
+ }
+
+ return $file;
+ }
+ return FALSE;
+}
+
+/**
+ * Move a file to a new location without calling any hooks or making any
+ * changes to the database.
+ *
+ * @param $source
+ * A string specifying the filepath or URI of the original file.
+ * @param $destination
+ * A string containing the destination that $source should be moved to.
+ * This must be a stream wrapper URI. If this value is omitted, Drupal's
+ * default files scheme will be used, usually "public://".
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * The URI of the moved file, or FALSE in the event of an error.
+ *
+ * @see file_move()
+ */
+function file_unmanaged_move($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ $filepath = file_unmanaged_copy($source, $destination, $replace);
+ if ($filepath == FALSE || file_unmanaged_delete($source) == FALSE) {
+ return FALSE;
+ }
+ return $filepath;
+}
+
+/**
+ * Modify a filename as needed for security purposes.
+ *
+ * Munging a file name prevents unknown file extensions from masking exploit
+ * files. When web servers such as Apache decide how to process a URL request,
+ * they use the file extension. If the extension is not recognized, Apache
+ * skips that extension and uses the previous file extension. For example, if
+ * the file being requested is exploit.php.pps, and Apache does not recognize
+ * the '.pps' extension, it treats the file as PHP and executes it. To make
+ * this file name safe for Apache and prevent it from executing as PHP, the
+ * .php extension is "munged" into .php_, making the safe file name
+ * exploit.php_.pps.
+ *
+ * Specifically, this function adds an underscore to all extensions that are
+ * between 2 and 5 characters in length, internal to the file name, and not
+ * included in $extensions.
+ *
+ * Function behavior is also controlled by the Drupal variable
+ * 'allow_insecure_uploads'. If 'allow_insecure_uploads' evaluates to TRUE, no
+ * alterations will be made, if it evaluates to FALSE, the filename is 'munged'.
+ *
+ * @param $filename
+ * File name to modify.
+ * @param $extensions
+ * A space-separated list of extensions that should not be altered.
+ * @param $alerts
+ * If TRUE, drupal_set_message() will be called to display a message if the
+ * file name was changed.
+ *
+ * @return
+ * The potentially modified $filename.
+ */
+function file_munge_filename($filename, $extensions, $alerts = TRUE) {
+ $original = $filename;
+
+ // Allow potentially insecure uploads for very savvy users and admin
+ if (!variable_get('allow_insecure_uploads', 0)) {
+ $whitelist = array_unique(explode(' ', trim($extensions)));
+
+ // Split the filename up by periods. The first part becomes the basename
+ // the last part the final extension.
+ $filename_parts = explode('.', $filename);
+ $new_filename = array_shift($filename_parts); // Remove file basename.
+ $final_extension = array_pop($filename_parts); // Remove final extension.
+
+ // Loop through the middle parts of the name and add an underscore to the
+ // end of each section that could be a file extension but isn't in the list
+ // of allowed extensions.
+ foreach ($filename_parts as $filename_part) {
+ $new_filename .= '.' . $filename_part;
+ if (!in_array($filename_part, $whitelist) && preg_match("/^[a-zA-Z]{2,5}\d?$/", $filename_part)) {
+ $new_filename .= '_';
+ }
+ }
+ $filename = $new_filename . '.' . $final_extension;
+
+ if ($alerts && $original != $filename) {
+ drupal_set_message(t('For security reasons, your upload has been renamed to %filename.', array('%filename' => $filename)));
+ }
+ }
+
+ return $filename;
+}
+
+/**
+ * Undo the effect of upload_munge_filename().
+ *
+ * @param $filename
+ * String with the filename to be unmunged.
+ *
+ * @return
+ * An unmunged filename string.
+ */
+function file_unmunge_filename($filename) {
+ return str_replace('_.', '.', $filename);
+}
+
+/**
+ * Create a full file path from a directory and filename.
+ *
+ * If a file with the specified name already exists, an alternative will be
+ * used.
+ *
+ * @param $basename
+ * String filename
+ * @param $directory
+ * String containing the directory or parent URI.
+ *
+ * @return
+ * File path consisting of $directory and a unique filename based off
+ * of $basename.
+ */
+function file_create_filename($basename, $directory) {
+ // Strip control characters (ASCII value < 32). Though these are allowed in
+ // some filesystems, not many applications handle them well.
+ $basename = preg_replace('/[\x00-\x1F]/u', '_', $basename);
+ if (substr(PHP_OS, 0, 3) == 'WIN') {
+ // These characters are not allowed in Windows filenames
+ $basename = str_replace(array(':', '*', '?', '"', '<', '>', '|'), '_', $basename);
+ }
+
+ // A URI or path may already have a trailing slash or look like "public://".
+ if (substr($directory, -1) == '/') {
+ $separator = '';
+ }
+ else {
+ $separator = '/';
+ }
+
+ $destination = $directory . $separator . $basename;
+
+ if (file_exists($destination)) {
+ // Destination file already exists, generate an alternative.
+ $pos = strrpos($basename, '.');
+ if ($pos !== FALSE) {
+ $name = substr($basename, 0, $pos);
+ $ext = substr($basename, $pos);
+ }
+ else {
+ $name = $basename;
+ $ext = '';
+ }
+
+ $counter = 0;
+ do {
+ $destination = $directory . $separator . $name . '_' . $counter++ . $ext;
+ } while (file_exists($destination));
+ }
+
+ return $destination;
+}
+
+/**
+ * Delete a file and its database record.
+ *
+ * If the $force parameter is not TRUE, file_usage_list() will be called to
+ * determine if the file is being used by any modules. If the file is being
+ * used the delete will be canceled.
+ *
+ * @param $file
+ * A file object.
+ * @param $force
+ * Boolean indicating that the file should be deleted even if the file is
+ * reported as in use by the file_usage table.
+ *
+ * @return mixed
+ * TRUE for success, FALSE in the event of an error, or an array if the file
+ * is being used by any modules.
+ *
+ * @see file_unmanaged_delete()
+ * @see file_usage_list()
+ * @see file_usage_delete()
+ * @see hook_file_delete()
+ */
+function file_delete(stdClass $file, $force = FALSE) {
+ if (!file_valid_uri($file->uri)) {
+ if (($realpath = drupal_realpath($file->uri)) !== FALSE) {
+ watchdog('file', 'File %file (%realpath) could not be deleted because it is not a valid URI. This may be caused by improper use of file_delete() or a missing stream wrapper.', array('%file' => $file->uri, '%realpath' => $realpath));
+ }
+ else {
+ watchdog('file', 'File %file could not be deleted because it is not a valid URI. This may be caused by improper use of file_delete() or a missing stream wrapper.', array('%file' => $file->uri));
+ }
+ drupal_set_message(t('The specified file %file could not be deleted because it is not a valid URI. More information is available in the system log.', array('%file' => $file->uri)), 'error');
+ return FALSE;
+ }
+
+ // If any module still has a usage entry in the file_usage table, the file
+ // will not be deleted, but file_delete() will return a populated array
+ // that tests as TRUE.
+ if (!$force && ($references = file_usage_list($file))) {
+ return $references;
+ }
+
+ // Let other modules clean up any references to the deleted file.
+ module_invoke_all('file_delete', $file);
+ module_invoke_all('entity_delete', $file, 'file');
+
+ // Make sure the file is deleted before removing its row from the
+ // database, so UIs can still find the file in the database.
+ if (file_unmanaged_delete($file->uri)) {
+ db_delete('file_managed')->condition('fid', $file->fid)->execute();
+ db_delete('file_usage')->condition('fid', $file->fid)->execute();
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Delete a file without calling any hooks or making any changes to the
+ * database.
+ *
+ * This function should be used when the file to be deleted does not have an
+ * entry recorded in the files table.
+ *
+ * @param $path
+ * A string containing a file path or (streamwrapper) URI.
+ *
+ * @return
+ * TRUE for success or path does not exist, or FALSE in the event of an
+ * error.
+ *
+ * @see file_delete()
+ * @see file_unmanaged_delete_recursive()
+ */
+function file_unmanaged_delete($path) {
+ if (is_dir($path)) {
+ watchdog('file', '%path is a directory and cannot be removed using file_unmanaged_delete().', array('%path' => $path), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ if (is_file($path)) {
+ return drupal_unlink($path);
+ }
+ // Return TRUE for non-existent file, but log that nothing was actually
+ // deleted, as the current state is the intended result.
+ if (!file_exists($path)) {
+ watchdog('file', 'The file %path was not deleted because it does not exist.', array('%path' => $path), WATCHDOG_NOTICE);
+ return TRUE;
+ }
+ // We cannot handle anything other than files and directories. Log an error
+ // for everything else (sockets, symbolic links, etc).
+ watchdog('file', 'The file %path is not of a recognized type so it was not deleted.', array('%path' => $path), WATCHDOG_ERROR);
+ return FALSE;
+}
+
+/**
+ * Recursively delete all files and directories in the specified filepath.
+ *
+ * If the specified path is a directory then the function will call itself
+ * recursively to process the contents. Once the contents have been removed the
+ * directory will also be removed.
+ *
+ * If the specified path is a file then it will be passed to
+ * file_unmanaged_delete().
+ *
+ * Note that this only deletes visible files with write permission.
+ *
+ * @param $path
+ * A string containing either an URI or a file or directory path.
+ *
+ * @return
+ * TRUE for success or if path does not exist, FALSE in the event of an
+ * error.
+ *
+ * @see file_unmanaged_delete()
+ */
+function file_unmanaged_delete_recursive($path) {
+ if (is_dir($path)) {
+ $dir = dir($path);
+ while (($entry = $dir->read()) !== FALSE) {
+ if ($entry == '.' || $entry == '..') {
+ continue;
+ }
+ $entry_path = $path . '/' . $entry;
+ file_unmanaged_delete_recursive($entry_path);
+ }
+ $dir->close();
+
+ return drupal_rmdir($path);
+ }
+ return file_unmanaged_delete($path);
+}
+
+/**
+ * Determine total disk space used by a single user or the whole filesystem.
+ *
+ * @param $uid
+ * Optional. A user id, specifying NULL returns the total space used by all
+ * non-temporary files.
+ * @param $status
+ * Optional. The file status to consider. The default is to only
+ * consider files in status FILE_STATUS_PERMANENT.
+ *
+ * @return
+ * An integer containing the number of bytes used.
+ */
+function file_space_used($uid = NULL, $status = FILE_STATUS_PERMANENT) {
+ $query = db_select('file_managed', 'f');
+ $query->condition('f.status', $status);
+ $query->addExpression('SUM(f.filesize)', 'filesize');
+ if (isset($uid)) {
+ $query->condition('f.uid', $uid);
+ }
+ return $query->execute()->fetchField();
+}
+
+/**
+ * Saves a file upload to a new location.
+ *
+ * The file will be added to the {file_managed} table as a temporary file.
+ * Temporary files are periodically cleaned. To make the file a permanent file,
+ * assign the status and use file_save() to save the changes.
+ *
+ * @param $source
+ * A string specifying the filepath or URI of the uploaded file to save.
+ * @param $validators
+ * An optional, associative array of callback functions used to validate the
+ * file. See file_validate() for a full discussion of the array format.
+ * If no extension validator is provided it will default to a limited safe
+ * list of extensions which is as follows: "jpg jpeg gif png txt
+ * doc xls pdf ppt pps odt ods odp". To allow all extensions you must
+ * explicitly set the 'file_validate_extensions' validator to an empty array
+ * (Beware: this is not safe and should only be allowed for trusted users, if
+ * at all).
+ * @param $destination
+ * A string containing the URI $source should be copied to.
+ * This must be a stream wrapper URI. If this value is omitted, Drupal's
+ * temporary files scheme will be used ("temporary://").
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE: Replace the existing file.
+ * - FILE_EXISTS_RENAME: Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR: Do nothing and return FALSE.
+ *
+ * @return
+ * An object containing the file information if the upload succeeded, FALSE
+ * in the event of an error, or NULL if no file was uploaded. The
+ * documentation for the "File interface" group, which you can find under
+ * Related topics, or the header at the top of this file, documents the
+ * components of a file object. In addition to the standard components,
+ * this function adds:
+ * - source: Path to the file before it is moved.
+ * - destination: Path to the file after it is moved (same as 'uri').
+ */
+function file_save_upload($source, $validators = array(), $destination = FALSE, $replace = FILE_EXISTS_RENAME) {
+ global $user;
+ static $upload_cache;
+
+ // Return cached objects without processing since the file will have
+ // already been processed and the paths in _FILES will be invalid.
+ if (isset($upload_cache[$source])) {
+ return $upload_cache[$source];
+ }
+
+ // Make sure there's an upload to process.
+ if (empty($_FILES['files']['name'][$source])) {
+ return NULL;
+ }
+
+ // Check for file upload errors and return FALSE if a lower level system
+ // error occurred. For a complete list of errors:
+ // See http://php.net/manual/en/features.file-upload.errors.php.
+ switch ($_FILES['files']['error'][$source]) {
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ drupal_set_message(t('The file %file could not be saved because it exceeds %maxsize, the maximum allowed size for uploads.', array('%file' => $_FILES['files']['name'][$source], '%maxsize' => format_size(file_upload_max_size()))), 'error');
+ return FALSE;
+
+ case UPLOAD_ERR_PARTIAL:
+ case UPLOAD_ERR_NO_FILE:
+ drupal_set_message(t('The file %file could not be saved because the upload did not complete.', array('%file' => $_FILES['files']['name'][$source])), 'error');
+ return FALSE;
+
+ case UPLOAD_ERR_OK:
+ // Final check that this is a valid upload, if it isn't, use the
+ // default error handler.
+ if (is_uploaded_file($_FILES['files']['tmp_name'][$source])) {
+ break;
+ }
+
+ // Unknown error
+ default:
+ drupal_set_message(t('The file %file could not be saved. An unknown error has occurred.', array('%file' => $_FILES['files']['name'][$source])), 'error');
+ return FALSE;
+ }
+
+ // Begin building file object.
+ $file = new stdClass();
+ $file->uid = $user->uid;
+ $file->status = 0;
+ $file->filename = trim(basename($_FILES['files']['name'][$source]), '.');
+ $file->uri = $_FILES['files']['tmp_name'][$source];
+ $file->filemime = file_get_mimetype($file->filename);
+ $file->filesize = $_FILES['files']['size'][$source];
+
+ $extensions = '';
+ if (isset($validators['file_validate_extensions'])) {
+ if (isset($validators['file_validate_extensions'][0])) {
+ // Build the list of non-munged extensions if the caller provided them.
+ $extensions = $validators['file_validate_extensions'][0];
+ }
+ else {
+ // If 'file_validate_extensions' is set and the list is empty then the
+ // caller wants to allow any extension. In this case we have to remove the
+ // validator or else it will reject all extensions.
+ unset($validators['file_validate_extensions']);
+ }
+ }
+ else {
+ // No validator was provided, so add one using the default list.
+ // Build a default non-munged safe list for file_munge_filename().
+ $extensions = 'jpg jpeg gif png txt doc xls pdf ppt pps odt ods odp';
+ $validators['file_validate_extensions'] = array();
+ $validators['file_validate_extensions'][0] = $extensions;
+ }
+
+ if (!empty($extensions)) {
+ // Munge the filename to protect against possible malicious extension hiding
+ // within an unknown file type (ie: filename.html.foo).
+ $file->filename = file_munge_filename($file->filename, $extensions);
+ }
+
+ // Rename potentially executable files, to help prevent exploits (i.e. will
+ // rename filename.php.foo and filename.php to filename.php.foo.txt and
+ // filename.php.txt, respectively). Don't rename if 'allow_insecure_uploads'
+ // evaluates to TRUE.
+ if (!variable_get('allow_insecure_uploads', 0) && preg_match('/\.(php|pl|py|cgi|asp|js)(\.|$)/i', $file->filename) && (substr($file->filename, -4) != '.txt')) {
+ $file->filemime = 'text/plain';
+ $file->uri .= '.txt';
+ $file->filename .= '.txt';
+ // The .txt extension may not be in the allowed list of extensions. We have
+ // to add it here or else the file upload will fail.
+ if (!empty($extensions)) {
+ $validators['file_validate_extensions'][0] .= ' txt';
+ drupal_set_message(t('For security reasons, your upload has been renamed to %filename.', array('%filename' => $file->filename)));
+ }
+ }
+
+ // If the destination is not provided, use the temporary directory.
+ if (empty($destination)) {
+ $destination = 'temporary://';
+ }
+
+ // Assert that the destination contains a valid stream.
+ $destination_scheme = file_uri_scheme($destination);
+ if (!$destination_scheme || !file_stream_wrapper_valid_scheme($destination_scheme)) {
+ drupal_set_message(t('The file could not be uploaded because the destination %destination is invalid.', array('%destination' => $destination)), 'error');
+ return FALSE;
+ }
+
+ $file->source = $source;
+ // A URI may already have a trailing slash or look like "public://".
+ if (substr($destination, -1) != '/') {
+ $destination .= '/';
+ }
+ $file->destination = file_destination($destination . $file->filename, $replace);
+ // If file_destination() returns FALSE then $replace == FILE_EXISTS_ERROR and
+ // there's an existing file so we need to bail.
+ if ($file->destination === FALSE) {
+ drupal_set_message(t('The file %source could not be uploaded because a file by that name already exists in the destination %directory.', array('%source' => $source, '%directory' => $destination)), 'error');
+ return FALSE;
+ }
+
+ // Add in our check of the the file name length.
+ $validators['file_validate_name_length'] = array();
+
+ // Call the validation functions specified by this function's caller.
+ $errors = file_validate($file, $validators);
+
+ // Check for errors.
+ if (!empty($errors)) {
+ $message = t('The specified file %name could not be uploaded.', array('%name' => $file->filename));
+ if (count($errors) > 1) {
+ $message .= theme('item_list', array('items' => $errors));
+ }
+ else {
+ $message .= ' ' . array_pop($errors);
+ }
+ form_set_error($source, $message);
+ return FALSE;
+ }
+
+ // Move uploaded files from PHP's upload_tmp_dir to Drupal's temporary
+ // directory. This overcomes open_basedir restrictions for future file
+ // operations.
+ $file->uri = $file->destination;
+ if (!drupal_move_uploaded_file($_FILES['files']['tmp_name'][$source], $file->uri)) {
+ form_set_error($source, t('File upload error. Could not move uploaded file.'));
+ watchdog('file', 'Upload error. Could not move uploaded file %file to destination %destination.', array('%file' => $file->filename, '%destination' => $file->uri));
+ return FALSE;
+ }
+
+ // Set the permissions on the new file.
+ drupal_chmod($file->uri);
+
+ // If we are replacing an existing file re-use its database record.
+ if ($replace == FILE_EXISTS_REPLACE) {
+ $existing_files = file_load_multiple(array(), array('uri' => $file->uri));
+ if (count($existing_files)) {
+ $existing = reset($existing_files);
+ $file->fid = $existing->fid;
+ }
+ }
+
+ // If we made it this far it's safe to record this file in the database.
+ if ($file = file_save($file)) {
+ // Add file to the cache.
+ $upload_cache[$source] = $file;
+ return $file;
+ }
+ return FALSE;
+}
+
+/**
+ * Moves an uploaded file to a new location.
+ *
+ * PHP's move_uploaded_file() does not properly support streams if safe_mode
+ * or open_basedir are enabled, so this function fills that gap.
+ *
+ * Compatibility: normal paths and stream wrappers.
+ * @see http://drupal.org/node/515192
+ *
+ * @param $filename
+ * The filename of the uploaded file.
+ * @param $uri
+ * A string containing the destination URI of the file.
+ *
+ * @return
+ * TRUE on success, or FALSE on failure.
+ *
+ * @see move_uploaded_file()
+ * @ingroup php_wrappers
+ */
+function drupal_move_uploaded_file($filename, $uri) {
+ $result = @move_uploaded_file($filename, $uri);
+ // PHP's move_uploaded_file() does not properly support streams if safe_mode
+ // or open_basedir are enabled so if the move failed, try finding a real path
+ // and retry the move operation.
+ if (!$result) {
+ if ($realpath = drupal_realpath($uri)) {
+ $result = move_uploaded_file($filename, $realpath);
+ }
+ else {
+ $result = move_uploaded_file($filename, $uri);
+ }
+ }
+
+ return $result;
+}
+
+/**
+ * Check that a file meets the criteria specified by the validators.
+ *
+ * After executing the validator callbacks specified hook_file_validate() will
+ * also be called to allow other modules to report errors about the file.
+ *
+ * @param $file
+ * A Drupal file object.
+ * @param $validators
+ * An optional, associative array of callback functions used to validate the
+ * file. The keys are function names and the values arrays of callback
+ * parameters which will be passed in after the file object. The
+ * functions should return an array of error messages; an empty array
+ * indicates that the file passed validation. The functions will be called in
+ * the order specified.
+ *
+ * @return
+ * An array containing validation error messages.
+ *
+ * @see hook_file_validate()
+ */
+function file_validate(stdClass &$file, $validators = array()) {
+ // Call the validation functions specified by this function's caller.
+ $errors = array();
+ foreach ($validators as $function => $args) {
+ if (function_exists($function)) {
+ array_unshift($args, $file);
+ $errors = array_merge($errors, call_user_func_array($function, $args));
+ }
+ }
+
+ // Let other modules perform validation on the new file.
+ return array_merge($errors, module_invoke_all('file_validate', $file));
+}
+
+/**
+ * Check for files with names longer than we can store in the database.
+ *
+ * @param $file
+ * A Drupal file object.
+ * @return
+ * An array. If the file name is too long, it will contain an error message.
+ */
+function file_validate_name_length(stdClass $file) {
+ $errors = array();
+
+ if (empty($file->filename)) {
+ $errors[] = t("The file's name is empty. Please give a name to the file.");
+ }
+ if (strlen($file->filename) > 240) {
+ $errors[] = t("The file's name exceeds the 240 characters limit. Please rename the file and try again.");
+ }
+ return $errors;
+}
+
+/**
+ * Check that the filename ends with an allowed extension.
+ *
+ * @param $file
+ * A Drupal file object.
+ * @param $extensions
+ * A string with a space separated list of allowed extensions.
+ *
+ * @return
+ * An array. If the file extension is not allowed, it will contain an error
+ * message.
+ *
+ * @see hook_file_validate()
+ */
+function file_validate_extensions(stdClass $file, $extensions) {
+ $errors = array();
+
+ $regex = '/\.(' . preg_replace('/ +/', '|', preg_quote($extensions)) . ')$/i';
+ if (!preg_match($regex, $file->filename)) {
+ $errors[] = t('Only files with the following extensions are allowed: %files-allowed.', array('%files-allowed' => $extensions));
+ }
+ return $errors;
+}
+
+/**
+ * Check that the file's size is below certain limits.
+ *
+ * This check is not enforced for the user #1.
+ *
+ * @param $file
+ * A Drupal file object.
+ * @param $file_limit
+ * An integer specifying the maximum file size in bytes. Zero indicates that
+ * no limit should be enforced.
+ * @param $user_limit
+ * An integer specifying the maximum number of bytes the user is allowed.
+ * Zero indicates that no limit should be enforced.
+ *
+ * @return
+ * An array. If the file size exceeds limits, it will contain an error
+ * message.
+ *
+ * @see hook_file_validate()
+ */
+function file_validate_size(stdClass $file, $file_limit = 0, $user_limit = 0) {
+ global $user;
+
+ $errors = array();
+
+ // Bypass validation for uid = 1.
+ if ($user->uid != 1) {
+ if ($file_limit && $file->filesize > $file_limit) {
+ $errors[] = t('The file is %filesize exceeding the maximum file size of %maxsize.', array('%filesize' => format_size($file->filesize), '%maxsize' => format_size($file_limit)));
+ }
+
+ // Save a query by only calling file_space_used() when a limit is provided.
+ if ($user_limit && (file_space_used($user->uid) + $file->filesize) > $user_limit) {
+ $errors[] = t('The file is %filesize which would exceed your disk quota of %quota.', array('%filesize' => format_size($file->filesize), '%quota' => format_size($user_limit)));
+ }
+ }
+ return $errors;
+}
+
+/**
+ * Check that the file is recognized by image_get_info() as an image.
+ *
+ * @param $file
+ * A Drupal file object.
+ *
+ * @return
+ * An array. If the file is not an image, it will contain an error message.
+ *
+ * @see hook_file_validate()
+ */
+function file_validate_is_image(stdClass $file) {
+ $errors = array();
+
+ $info = image_get_info($file->uri);
+ if (!$info || empty($info['extension'])) {
+ $errors[] = t('Only JPEG, PNG and GIF images are allowed.');
+ }
+
+ return $errors;
+}
+
+/**
+ * Verify that image dimensions are within the specified maximum and minimum.
+ *
+ * Non-image files will be ignored. If a image toolkit is available the image
+ * will be scaled to fit within the desired maximum dimensions.
+ *
+ * @param $file
+ * A Drupal file object. This function may resize the file affecting its
+ * size.
+ * @param $maximum_dimensions
+ * An optional string in the form WIDTHxHEIGHT e.g. '640x480' or '85x85'. If
+ * an image toolkit is installed the image will be resized down to these
+ * dimensions. A value of 0 indicates no restriction on size, so resizing
+ * will be attempted.
+ * @param $minimum_dimensions
+ * An optional string in the form WIDTHxHEIGHT. This will check that the
+ * image meets a minimum size. A value of 0 indicates no restriction.
+ *
+ * @return
+ * An array. If the file is an image and did not meet the requirements, it
+ * will contain an error message.
+ *
+ * @see hook_file_validate()
+ */
+function file_validate_image_resolution(stdClass $file, $maximum_dimensions = 0, $minimum_dimensions = 0) {
+ $errors = array();
+
+ // Check first that the file is an image.
+ if ($info = image_get_info($file->uri)) {
+ if ($maximum_dimensions) {
+ // Check that it is smaller than the given dimensions.
+ list($width, $height) = explode('x', $maximum_dimensions);
+ if ($info['width'] > $width || $info['height'] > $height) {
+ // Try to resize the image to fit the dimensions.
+ if ($image = image_load($file->uri)) {
+ image_scale($image, $width, $height);
+ image_save($image);
+ $file->filesize = $image->info['file_size'];
+ drupal_set_message(t('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', array('%dimensions' => $maximum_dimensions)));
+ }
+ else {
+ $errors[] = t('The image is too large; the maximum dimensions are %dimensions pixels.', array('%dimensions' => $maximum_dimensions));
+ }
+ }
+ }
+
+ if ($minimum_dimensions) {
+ // Check that it is larger than the given dimensions.
+ list($width, $height) = explode('x', $minimum_dimensions);
+ if ($info['width'] < $width || $info['height'] < $height) {
+ $errors[] = t('The image is too small; the minimum dimensions are %dimensions pixels.', array('%dimensions' => $minimum_dimensions));
+ }
+ }
+ }
+
+ return $errors;
+}
+
+/**
+ * Save a string to the specified destination and create a database file entry.
+ *
+ * @param $data
+ * A string containing the contents of the file.
+ * @param $destination
+ * A string containing the destination URI. This must be a stream wrapper URI.
+ * If no value is provided, a randomized name will be generated and the file
+ * will be saved using Drupal's default files scheme, usually "public://".
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file. If a managed file with
+ * the destination name exists then its database entry will be updated. If
+ * no database entry is found then a new one will be created.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * A file object, or FALSE on error.
+ *
+ * @see file_unmanaged_save_data()
+ */
+function file_save_data($data, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ global $user;
+
+ if (empty($destination)) {
+ $destination = file_default_scheme() . '://';
+ }
+ if (!file_valid_uri($destination)) {
+ watchdog('file', 'The data could not be saved because the destination %destination is invalid. This may be caused by improper use of file_save_data() or a missing stream wrapper.', array('%destination' => $destination));
+ drupal_set_message(t('The data could not be saved because the destination is invalid. More information is available in the system log.'), 'error');
+ return FALSE;
+ }
+
+ if ($uri = file_unmanaged_save_data($data, $destination, $replace)) {
+ // Create a file object.
+ $file = new stdClass();
+ $file->fid = NULL;
+ $file->uri = $uri;
+ $file->filename = basename($uri);
+ $file->filemime = file_get_mimetype($file->uri);
+ $file->uid = $user->uid;
+ $file->status = FILE_STATUS_PERMANENT;
+ // If we are replacing an existing file re-use its database record.
+ if ($replace == FILE_EXISTS_REPLACE) {
+ $existing_files = file_load_multiple(array(), array('uri' => $uri));
+ if (count($existing_files)) {
+ $existing = reset($existing_files);
+ $file->fid = $existing->fid;
+ $file->filename = $existing->filename;
+ }
+ }
+ // If we are renaming around an existing file (rather than a directory),
+ // use its basename for the filename.
+ elseif ($replace == FILE_EXISTS_RENAME && is_file($destination)) {
+ $file->filename = basename($destination);
+ }
+
+ return file_save($file);
+ }
+ return FALSE;
+}
+
+/**
+ * Save a string to the specified destination without invoking file API.
+ *
+ * This function is identical to file_save_data() except the file will not be
+ * saved to the {file_managed} table and none of the file_* hooks will be
+ * called.
+ *
+ * @param $data
+ * A string containing the contents of the file.
+ * @param $destination
+ * A string containing the destination location. This must be a stream wrapper
+ * URI. If no value is provided, a randomized name will be generated and the
+ * file will be saved using Drupal's default files scheme, usually "public://".
+ * @param $replace
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE - Replace the existing file.
+ * - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR - Do nothing and return FALSE.
+ *
+ * @return
+ * A string with the path of the resulting file, or FALSE on error.
+ *
+ * @see file_save_data()
+ */
+function file_unmanaged_save_data($data, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
+ // Write the data to a temporary file.
+ $temp_name = drupal_tempnam('temporary://', 'file');
+ if (file_put_contents($temp_name, $data) === FALSE) {
+ drupal_set_message(t('The file could not be created.'), 'error');
+ return FALSE;
+ }
+
+ // Move the file to its final destination.
+ return file_unmanaged_move($temp_name, $destination, $replace);
+}
+
+/**
+ * Transfer file using HTTP to client.
+ *
+ * Pipes a file through Drupal to the client.
+ *
+ * @param $uri
+ * String specifying the file URI to transfer.
+ * @param $headers
+ * An array of HTTP headers to send along with file.
+ */
+function file_transfer($uri, $headers) {
+ if (ob_get_level()) {
+ ob_end_clean();
+ }
+
+ foreach ($headers as $name => $value) {
+ drupal_add_http_header($name, $value);
+ }
+ drupal_send_headers();
+ $scheme = file_uri_scheme($uri);
+ // Transfer file in 1024 byte chunks to save memory usage.
+ if ($scheme && file_stream_wrapper_valid_scheme($scheme) && $fd = fopen($uri, 'rb')) {
+ while (!feof($fd)) {
+ print fread($fd, 1024);
+ }
+ fclose($fd);
+ }
+ else {
+ drupal_not_found();
+ }
+ drupal_exit();
+}
+
+/**
+ * Menu handler for private file transfers.
+ *
+ * Call modules that implement hook_file_download() to find out if a file is
+ * accessible and what headers it should be transferred with. If one or more
+ * modules returned headers the download will start with the returned headers.
+ * If a module returns -1 drupal_access_denied() will be returned. If the file
+ * exists but no modules responded drupal_access_denied() will be returned.
+ * If the file does not exist drupal_not_found() will be returned.
+ *
+ * @see hook_file_download()
+ */
+function file_download() {
+ // Merge remainder of arguments from GET['q'], into relative file path.
+ $args = func_get_args();
+ $scheme = array_shift($args);
+ $target = implode('/', $args);
+ $uri = $scheme . '://' . $target;
+ if (file_stream_wrapper_valid_scheme($scheme) && file_exists($uri)) {
+ // Let other modules provide headers and controls access to the file.
+ // module_invoke_all() uses array_merge_recursive() which merges header
+ // values into a new array. To avoid that and allow modules to override
+ // headers instead, use array_merge() to merge the returned arrays.
+ $headers = array();
+ foreach (module_implements('file_download') as $module) {
+ $function = $module . '_file_download';
+ $result = $function($uri);
+ if ($result == -1) {
+ return drupal_access_denied();
+ }
+ if (isset($result) && is_array($result)) {
+ $headers = array_merge($headers, $result);
+ }
+ }
+ if (count($headers)) {
+ file_transfer($uri, $headers);
+ }
+ return drupal_access_denied();
+ }
+ return drupal_not_found();
+}
+
+
+/**
+ * Finds all files that match a given mask in a given directory.
+ *
+ * Directories and files beginning with a period are excluded; this
+ * prevents hidden files and directories (such as SVN working directories)
+ * from being scanned.
+ *
+ * @param $dir
+ * The base directory or URI to scan, without trailing slash.
+ * @param $mask
+ * The preg_match() regular expression of the files to find.
+ * @param $options
+ * An associative array of additional options, with the following elements:
+ * - 'nomask': The preg_match() regular expression of the files to ignore.
+ * Defaults to '/(\.\.?|CVS)$/'.
+ * - 'callback': The callback function to call for each match. There is no
+ * default callback.
+ * - 'recurse': When TRUE, the directory scan will recurse the entire tree
+ * starting at the provided directory. Defaults to TRUE.
+ * - 'key': The key to be used for the returned associative array of files.
+ * Possible values are 'uri', for the file's URI; 'filename', for the
+ * basename of the file; and 'name' for the name of the file without the
+ * extension. Defaults to 'uri'.
+ * - 'min_depth': Minimum depth of directories to return files from. Defaults
+ * to 0.
+ * @param $depth
+ * Current depth of recursion. This parameter is only used internally and
+ * should not be passed in.
+ *
+ * @return
+ * An associative array (keyed on the chosen key) of objects with 'uri',
+ * 'filename', and 'name' members corresponding to the matching files.
+ */
+function file_scan_directory($dir, $mask, $options = array(), $depth = 0) {
+ // Merge in defaults.
+ $options += array(
+ 'nomask' => '/(\.\.?|CVS)$/',
+ 'callback' => 0,
+ 'recurse' => TRUE,
+ 'key' => 'uri',
+ 'min_depth' => 0,
+ );
+
+ $options['key'] = in_array($options['key'], array('uri', 'filename', 'name')) ? $options['key'] : 'uri';
+ $files = array();
+ if (is_dir($dir) && $handle = opendir($dir)) {
+ while (FALSE !== ($filename = readdir($handle))) {
+ if (!preg_match($options['nomask'], $filename) && $filename[0] != '.') {
+ $uri = "$dir/$filename";
+ $uri = file_stream_wrapper_uri_normalize($uri);
+ if (is_dir($uri) && $options['recurse']) {
+ // Give priority to files in this folder by merging them in after any subdirectory files.
+ $files = array_merge(file_scan_directory($uri, $mask, $options, $depth + 1), $files);
+ }
+ elseif ($depth >= $options['min_depth'] && preg_match($mask, $filename)) {
+ // Always use this match over anything already set in $files with the
+ // same $$options['key'].
+ $file = new stdClass();
+ $file->uri = $uri;
+ $file->filename = $filename;
+ $file->name = pathinfo($filename, PATHINFO_FILENAME);
+ $key = $options['key'];
+ $files[$file->$key] = $file;
+ if ($options['callback']) {
+ $options['callback']($uri);
+ }
+ }
+ }
+ }
+
+ closedir($handle);
+ }
+
+ return $files;
+}
+
+/**
+ * Determine the maximum file upload size by querying the PHP settings.
+ *
+ * @return
+ * A file size limit in bytes based on the PHP upload_max_filesize and
+ * post_max_size
+ */
+function file_upload_max_size() {
+ static $max_size = -1;
+
+ if ($max_size < 0) {
+ // Start with post_max_size.
+ $max_size = parse_size(ini_get('post_max_size'));
+
+ // If upload_max_size is less, then reduce. Except if upload_max_size is
+ // zero, which indicates no limit.
+ $upload_max = parse_size(ini_get('upload_max_filesize'));
+ if ($upload_max > 0 && $upload_max < $max_size) {
+ $max_size = $upload_max;
+ }
+ }
+ return $max_size;
+}
+
+/**
+ * Determine an Internet Media Type, or MIME type from a filename.
+ *
+ * @param $uri
+ * A string containing the URI, path, or filename.
+ * @param $mapping
+ * An optional map of extensions to their mimetypes, in the form:
+ * - 'mimetypes': a list of mimetypes, keyed by an identifier,
+ * - 'extensions': the mapping itself, an associative array in which
+ * the key is the extension (lowercase) and the value is the mimetype
+ * identifier. If $mapping is NULL file_mimetype_mapping() is called.
+ *
+ * @return
+ * The internet media type registered for the extension or
+ * application/octet-stream for unknown extensions.
+ *
+ * @see file_default_mimetype_mapping()
+ */
+function file_get_mimetype($uri, $mapping = NULL) {
+ if ($wrapper = file_stream_wrapper_get_instance_by_uri($uri)) {
+ return $wrapper->getMimeType($uri, $mapping);
+ }
+ else {
+ // getMimeType() is not implementation specific, so we can directly
+ // call it without an instance.
+ return DrupalLocalStreamWrapper::getMimeType($uri, $mapping);
+ }
+}
+
+/**
+ * Set the permissions on a file or directory.
+ *
+ * This function will use the 'file_chmod_directory' and 'file_chmod_file'
+ * variables for the default modes for directories and uploaded/generated
+ * files. By default these will give everyone read access so that users
+ * accessing the files with a user account without the webserver group (e.g.
+ * via FTP) can read these files, and give group write permissions so webserver
+ * group members (e.g. a vhost account) can alter files uploaded and owned by
+ * the webserver.
+ *
+ * PHP's chmod does not support stream wrappers so we use our wrapper
+ * implementation which interfaces with chmod() by default. Contrib wrappers
+ * may override this behavior in their implementations as needed.
+ *
+ * @param $uri
+ * A string containing a URI file, or directory path.
+ * @param $mode
+ * Integer value for the permissions. Consult PHP chmod() documentation for
+ * more information.
+ *
+ * @return
+ * TRUE for success, FALSE in the event of an error.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_chmod($uri, $mode = NULL) {
+ if (!isset($mode)) {
+ if (is_dir($uri)) {
+ $mode = variable_get('file_chmod_directory', 0775);
+ }
+ else {
+ $mode = variable_get('file_chmod_file', 0664);
+ }
+ }
+
+ // If this URI is a stream, pass it off to the appropriate stream wrapper.
+ // Otherwise, attempt PHP's chmod. This allows use of drupal_chmod even
+ // for unmanaged files outside of the stream wrapper interface.
+ if ($wrapper = file_stream_wrapper_get_instance_by_uri($uri)) {
+ if ($wrapper->chmod($mode)) {
+ return TRUE;
+ }
+ }
+ else {
+ if (@chmod($uri, $mode)) {
+ return TRUE;
+ }
+ }
+
+ watchdog('file', 'The file permissions could not be set on %uri.', array('%uri' => $uri), WATCHDOG_ERROR);
+ return FALSE;
+}
+
+/**
+ * Deletes a file.
+ *
+ * PHP's unlink() is broken on Windows, as it can fail to remove a file
+ * when it has a read-only flag set.
+ *
+ * @param $uri
+ * A URI or pathname.
+ * @param $context
+ * Refer to http://php.net/manual/en/ref.stream.php
+ *
+ * @return
+ * Boolean TRUE on success, or FALSE on failure.
+ *
+ * @see unlink()
+ * @ingroup php_wrappers
+ */
+function drupal_unlink($uri, $context = NULL) {
+ $scheme = file_uri_scheme($uri);
+ if ((!$scheme || !file_stream_wrapper_valid_scheme($scheme)) && (substr(PHP_OS, 0, 3) == 'WIN')) {
+ chmod($uri, 0600);
+ }
+ if ($context) {
+ return unlink($uri, $context);
+ }
+ else {
+ return unlink($uri);
+ }
+}
+
+/**
+ * Returns the absolute local filesystem path of a stream URI.
+ *
+ * This function was originally written to ease the conversion of 6.x code to
+ * use 7.x stream wrappers. However, it assumes that every URI may be resolved
+ * to an absolute local filesystem path, and this assumption fails when stream
+ * wrappers are used to support remote file storage. Remote stream wrappers
+ * may implement the realpath method by always returning FALSE. The use of
+ * drupal_realpath() is discouraged, and is slowly being removed from core
+ * functions where possible.
+ *
+ * Only use this function if you know that the stream wrapper in the URI uses
+ * the local file system, and you need to pass an absolute path to a function
+ * that is incompatible with stream URIs.
+ *
+ * @param $uri
+ * A stream wrapper URI or a filesystem path, possibly including one or more
+ * symbolic links.
+ *
+ * @return
+ * The absolute local filesystem path (with no symbolic links), or FALSE on
+ * failure.
+ *
+ * @see DrupalStreamWrapperInterface::realpath()
+ * @see http://php.net/manual/function.realpath.php
+ * @ingroup php_wrappers
+ * @todo: This function is deprecated, and should be removed wherever possible.
+ */
+function drupal_realpath($uri) {
+ // If this URI is a stream, pass it off to the appropriate stream wrapper.
+ // Otherwise, attempt PHP's realpath. This allows use of drupal_realpath even
+ // for unmanaged files outside of the stream wrapper interface.
+ if ($wrapper = file_stream_wrapper_get_instance_by_uri($uri)) {
+ return $wrapper->realpath();
+ }
+
+ return realpath($uri);
+}
+
+/**
+ * Gets the name of the directory from a given path.
+ *
+ * PHP's dirname() does not properly pass streams, so this function fills
+ * that gap. It is backwards compatible with normal paths and will use
+ * PHP's dirname() as a fallback.
+ *
+ * Compatibility: normal paths and stream wrappers.
+ * @see http://drupal.org/node/515192
+ *
+ * @param $uri
+ * A URI or path.
+ *
+ * @return
+ * A string containing the directory name.
+ *
+ * @see dirname()
+ * @ingroup php_wrappers
+ */
+function drupal_dirname($uri) {
+ $scheme = file_uri_scheme($uri);
+
+ if ($scheme && file_stream_wrapper_valid_scheme($scheme)) {
+ return file_stream_wrapper_get_instance_by_scheme($scheme)->dirname($uri);
+ }
+ else {
+ return dirname($uri);
+ }
+}
+
+/**
+ * Creates a directory using Drupal's default mode.
+ *
+ * PHP's mkdir() does not respect Drupal's default permissions mode. If a mode
+ * is not provided, this function will make sure that Drupal's is used.
+ *
+ * Compatibility: normal paths and stream wrappers.
+ * @see http://drupal.org/node/515192
+ *
+ * @param $uri
+ * A URI or pathname.
+ * @param $mode
+ * By default the Drupal mode is used.
+ * @param $recursive
+ * Default to FALSE.
+ * @param $context
+ * Refer to http://php.net/manual/en/ref.stream.php
+ *
+ * @return
+ * Boolean TRUE on success, or FALSE on failure.
+ *
+ * @see mkdir()
+ * @ingroup php_wrappers
+ */
+function drupal_mkdir($uri, $mode = NULL, $recursive = FALSE, $context = NULL) {
+ if (!isset($mode)) {
+ $mode = variable_get('file_chmod_directory', 0775);
+ }
+
+ if (!isset($context)) {
+ return mkdir($uri, $mode, $recursive);
+ }
+ else {
+ return mkdir($uri, $mode, $recursive, $context);
+ }
+}
+
+/**
+ * Remove a directory.
+ *
+ * PHP's rmdir() is broken on Windows, as it can fail to remove a directory
+ * when it has a read-only flag set.
+ *
+ * @param $uri
+ * A URI or pathname.
+ * @param $context
+ * Refer to http://php.net/manual/en/ref.stream.php
+ *
+ * @return
+ * Boolean TRUE on success, or FALSE on failure.
+ *
+ * @see rmdir()
+ * @ingroup php_wrappers
+ */
+function drupal_rmdir($uri, $context = NULL) {
+ $scheme = file_uri_scheme($uri);
+ if ((!$scheme || !file_stream_wrapper_valid_scheme($scheme)) && (substr(PHP_OS, 0, 3) == 'WIN')) {
+ chmod($uri, 0700);
+ }
+ if ($context) {
+ return rmdir($uri, $context);
+ }
+ else {
+ return rmdir($uri);
+ }
+}
+
+/**
+ * Creates a file with a unique filename in the specified directory.
+ *
+ * PHP's tempnam() does not return a URI like we want. This function
+ * will return a URI if given a URI, or it will return a filepath if
+ * given a filepath.
+ *
+ * Compatibility: normal paths and stream wrappers.
+ * @see http://drupal.org/node/515192
+ *
+ * @param $directory
+ * The directory where the temporary filename will be created.
+ * @param $prefix
+ * The prefix of the generated temporary filename.
+ * Note: Windows uses only the first three characters of prefix.
+ *
+ * @return
+ * The new temporary filename, or FALSE on failure.
+ *
+ * @see tempnam()
+ * @ingroup php_wrappers
+ */
+function drupal_tempnam($directory, $prefix) {
+ $scheme = file_uri_scheme($directory);
+
+ if ($scheme && file_stream_wrapper_valid_scheme($scheme)) {
+ $wrapper = file_stream_wrapper_get_instance_by_scheme($scheme);
+
+ if ($filename = tempnam($wrapper->getDirectoryPath(), $prefix)) {
+ return $scheme . '://' . basename($filename);
+ }
+ else {
+ return FALSE;
+ }
+ }
+ else {
+ // Handle as a normal tempnam() call.
+ return tempnam($directory, $prefix);
+ }
+}
+
+/**
+ * Get the path of system-appropriate temporary directory.
+ */
+function file_directory_temp() {
+ $temporary_directory = variable_get('file_temporary_path', NULL);
+
+ if (empty($temporary_directory)) {
+ $directories = array();
+
+ // Has PHP been set with an upload_tmp_dir?
+ if (ini_get('upload_tmp_dir')) {
+ $directories[] = ini_get('upload_tmp_dir');
+ }
+
+ // Operating system specific dirs.
+ if (substr(PHP_OS, 0, 3) == 'WIN') {
+ $directories[] = 'c:\\windows\\temp';
+ $directories[] = 'c:\\winnt\\temp';
+ }
+ else {
+ $directories[] = '/tmp';
+ }
+ // PHP may be able to find an alternative tmp directory.
+ $directories[] = sys_get_temp_dir();
+
+ foreach ($directories as $directory) {
+ if (is_dir($directory) && is_writable($directory)) {
+ $temporary_directory = $directory;
+ break;
+ }
+ }
+
+ if (empty($temporary_directory)) {
+ // If no directory has been found default to 'files/tmp'.
+ $temporary_directory = variable_get('file_public_path', conf_path() . '/files') . '/tmp';
+
+ // Windows accepts paths with either slash (/) or backslash (\), but will
+ // not accept a path which contains both a slash and a backslash. Since
+ // the 'file_public_path' variable may have either format, we sanitize
+ // everything to use slash which is supported on all platforms.
+ $temporary_directory = str_replace('\\', '/', $temporary_directory);
+ }
+ // Save the path of the discovered directory.
+ variable_set('file_temporary_path', $temporary_directory);
+ }
+
+ return $temporary_directory;
+}
+
+/**
+ * Examines a file object and returns appropriate content headers for download.
+ *
+ * @param $file
+ * A file object.
+ * @return
+ * An associative array of headers, as expected by file_transfer().
+ */
+function file_get_content_headers($file) {
+ $name = mime_header_encode($file->filename);
+ $type = mime_header_encode($file->filemime);
+ // Serve images, text, and flash content for display rather than download.
+ $inline_types = variable_get('file_inline_types', array('^text/', '^image/', 'flash$'));
+ $disposition = 'attachment';
+ foreach ($inline_types as $inline_type) {
+ // Exclamation marks are used as delimiters to avoid escaping slashes.
+ if (preg_match('!' . $inline_type . '!', $file->filemime)) {
+ $disposition = 'inline';
+ }
+ }
+
+ return array(
+ 'Content-Type' => $type . '; name="' . $name . '"',
+ 'Content-Length' => $file->filesize,
+ 'Content-Disposition' => $disposition . '; filename="' . $name . '"',
+ 'Cache-Control' => 'private',
+ );
+}
+
+/**
+ * @} End of "defgroup file".
+ */
diff --git a/core/includes/file.mimetypes.inc b/core/includes/file.mimetypes.inc
new file mode 100644
index 000000000000..7468a60af420
--- /dev/null
+++ b/core/includes/file.mimetypes.inc
@@ -0,0 +1,859 @@
+<?php
+
+/**
+ * @file
+ * Provides mimetype mappings.
+ */
+
+/**
+ * Return an array of MIME extension mappings.
+ *
+ * Returns the mapping after modules have altered the default mapping.
+ *
+ * @return
+ * Array of mimetypes correlated to the extensions that relate to them.
+ *
+ * @see file_get_mimetype()
+ */
+function file_mimetype_mapping() {
+ $mapping = &drupal_static(__FUNCTION__);
+ if (!isset($mapping)) {
+ $mapping = file_default_mimetype_mapping();
+ // Allow modules to alter the default mapping.
+ drupal_alter('file_mimetype_mapping', $mapping);
+ }
+ return $mapping;
+}
+
+/**
+ * Default MIME extension mapping.
+ *
+ * @return
+ * Array of mimetypes correlated to the extensions that relate to them.
+ *
+ * @see file_get_mimetype()
+ */
+function file_default_mimetype_mapping() {
+ return array(
+ 'mimetypes' => array(
+ 0 => 'application/andrew-inset',
+ 1 => 'application/atom',
+ 2 => 'application/atomcat+xml',
+ 3 => 'application/atomserv+xml',
+ 4 => 'application/cap',
+ 5 => 'application/cu-seeme',
+ 6 => 'application/dsptype',
+ 7 => 'application/hta',
+ 8 => 'application/java-archive',
+ 9 => 'application/java-serialized-object',
+ 10 => 'application/java-vm',
+ 11 => 'application/mac-binhex40',
+ 12 => 'application/mathematica',
+ 13 => 'application/msaccess',
+ 14 => 'application/msword',
+ 15 => 'application/octet-stream',
+ 16 => 'application/oda',
+ 17 => 'application/ogg',
+ 18 => 'application/pdf',
+ 19 => 'application/pgp-keys',
+ 20 => 'application/pgp-signature',
+ 21 => 'application/pics-rules',
+ 22 => 'application/postscript',
+ 23 => 'application/rar',
+ 24 => 'application/rdf+xml',
+ 25 => 'application/rss+xml',
+ 26 => 'application/rtf',
+ 27 => 'application/smil',
+ 28 => 'application/vnd.cinderella',
+ 29 => 'application/vnd.google-earth.kml+xml',
+ 30 => 'application/vnd.google-earth.kmz',
+ 31 => 'application/vnd.mozilla.xul+xml',
+ 32 => 'application/vnd.ms-excel',
+ 33 => 'application/vnd.ms-excel.addin.macroEnabled.12',
+ 34 => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',
+ 35 => 'application/vnd.ms-excel.sheet.macroEnabled.12',
+ 36 => 'application/vnd.ms-excel.template.macroEnabled.12',
+ 37 => 'application/vnd.ms-pki.seccat',
+ 38 => 'application/vnd.ms-pki.stl',
+ 39 => 'application/vnd.ms-powerpoint',
+ 40 => 'application/vnd.ms-powerpoint.addin.macroEnabled.12',
+ 41 => 'application/vnd.ms-powerpoint.presentation.macroEnabled.12',
+ 42 => 'application/vnd.ms-powerpoint.slideshow.macroEnabled.12',
+ 43 => 'application/vnd.ms-powerpoint.template.macroEnabled.12',
+ 44 => 'application/vnd.ms-word.document.macroEnabled.12',
+ 45 => 'application/vnd.ms-word.template.macroEnabled.12',
+ 46 => 'application/vnd.ms-xpsdocument',
+ 47 => 'application/vnd.oasis.opendocument.chart',
+ 48 => 'application/vnd.oasis.opendocument.database',
+ 49 => 'application/vnd.oasis.opendocument.formula',
+ 50 => 'application/vnd.oasis.opendocument.graphics',
+ 51 => 'application/vnd.oasis.opendocument.graphics-template',
+ 52 => 'application/vnd.oasis.opendocument.image',
+ 53 => 'application/vnd.oasis.opendocument.presentation',
+ 54 => 'application/vnd.oasis.opendocument.presentation-template',
+ 55 => 'application/vnd.oasis.opendocument.spreadsheet',
+ 56 => 'application/vnd.oasis.opendocument.spreadsheet-template',
+ 57 => 'application/vnd.oasis.opendocument.text',
+ 58 => 'application/vnd.oasis.opendocument.text-master',
+ 59 => 'application/vnd.oasis.opendocument.text-template',
+ 60 => 'application/vnd.oasis.opendocument.text-web',
+ 61 => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 62 => 'application/vnd.openxmlformats-officedocument.presentationml.slideshow',
+ 63 => 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ 64 => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 65 => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ 66 => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 67 => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ 68 => 'application/vnd.rim.cod',
+ 69 => 'application/vnd.smaf',
+ 70 => 'application/vnd.stardivision.calc',
+ 71 => 'application/vnd.stardivision.chart',
+ 72 => 'application/vnd.stardivision.draw',
+ 73 => 'application/vnd.stardivision.impress',
+ 74 => 'application/vnd.stardivision.math',
+ 75 => 'application/vnd.stardivision.writer',
+ 76 => 'application/vnd.stardivision.writer-global',
+ 77 => 'application/vnd.sun.xml.calc',
+ 78 => 'application/vnd.sun.xml.calc.template',
+ 79 => 'application/vnd.sun.xml.draw',
+ 80 => 'application/vnd.sun.xml.draw.template',
+ 81 => 'application/vnd.sun.xml.impress',
+ 82 => 'application/vnd.sun.xml.impress.template',
+ 83 => 'application/vnd.sun.xml.math',
+ 84 => 'application/vnd.sun.xml.writer',
+ 85 => 'application/vnd.sun.xml.writer.global',
+ 86 => 'application/vnd.sun.xml.writer.template',
+ 87 => 'application/vnd.symbian.install',
+ 88 => 'application/vnd.visio',
+ 89 => 'application/vnd.wap.wbxml',
+ 90 => 'application/vnd.wap.wmlc',
+ 91 => 'application/vnd.wap.wmlscriptc',
+ 92 => 'application/wordperfect',
+ 93 => 'application/wordperfect5.1',
+ 94 => 'application/x-123',
+ 95 => 'application/x-7z-compressed',
+ 96 => 'application/x-abiword',
+ 97 => 'application/x-apple-diskimage',
+ 98 => 'application/x-bcpio',
+ 99 => 'application/x-bittorrent',
+ 100 => 'application/x-cab',
+ 101 => 'application/x-cbr',
+ 102 => 'application/x-cbz',
+ 103 => 'application/x-cdf',
+ 104 => 'application/x-cdlink',
+ 105 => 'application/x-chess-pgn',
+ 106 => 'application/x-cpio',
+ 107 => 'application/x-debian-package',
+ 108 => 'application/x-director',
+ 109 => 'application/x-dms',
+ 110 => 'application/x-doom',
+ 111 => 'application/x-dvi',
+ 112 => 'application/x-flac',
+ 113 => 'application/x-font',
+ 114 => 'application/x-freemind',
+ 115 => 'application/x-futuresplash',
+ 116 => 'application/x-gnumeric',
+ 117 => 'application/x-go-sgf',
+ 118 => 'application/x-graphing-calculator',
+ 119 => 'application/x-gtar',
+ 120 => 'application/x-hdf',
+ 121 => 'application/x-httpd-eruby',
+ 122 => 'application/x-httpd-php',
+ 123 => 'application/x-httpd-php-source',
+ 124 => 'application/x-httpd-php3',
+ 125 => 'application/x-httpd-php3-preprocessed',
+ 126 => 'application/x-httpd-php4',
+ 127 => 'application/x-ica',
+ 128 => 'application/x-internet-signup',
+ 129 => 'application/x-iphone',
+ 130 => 'application/x-iso9660-image',
+ 131 => 'application/x-java-jnlp-file',
+ 132 => 'application/x-javascript',
+ 133 => 'application/x-jmol',
+ 134 => 'application/x-kchart',
+ 135 => 'application/x-killustrator',
+ 136 => 'application/x-koan',
+ 137 => 'application/x-kpresenter',
+ 138 => 'application/x-kspread',
+ 139 => 'application/x-kword',
+ 140 => 'application/x-latex',
+ 141 => 'application/x-lha',
+ 142 => 'application/x-lyx',
+ 143 => 'application/x-lzh',
+ 144 => 'application/x-lzx',
+ 145 => 'application/x-maker',
+ 146 => 'application/x-mif',
+ 147 => 'application/x-ms-wmd',
+ 148 => 'application/x-ms-wmz',
+ 149 => 'application/x-msdos-program',
+ 150 => 'application/x-msi',
+ 151 => 'application/x-netcdf',
+ 152 => 'application/x-ns-proxy-autoconfig',
+ 153 => 'application/x-nwc',
+ 154 => 'application/x-object',
+ 155 => 'application/x-oz-application',
+ 156 => 'application/x-pkcs7-certreqresp',
+ 157 => 'application/x-pkcs7-crl',
+ 158 => 'application/x-python-code',
+ 159 => 'application/x-quicktimeplayer',
+ 160 => 'application/x-redhat-package-manager',
+ 161 => 'application/x-shar',
+ 162 => 'application/x-shockwave-flash',
+ 163 => 'application/x-stuffit',
+ 164 => 'application/x-sv4cpio',
+ 165 => 'application/x-sv4crc',
+ 166 => 'application/x-tar',
+ 167 => 'application/x-tcl',
+ 168 => 'application/x-tex-gf',
+ 169 => 'application/x-tex-pk',
+ 170 => 'application/x-texinfo',
+ 171 => 'application/x-trash',
+ 172 => 'application/x-troff',
+ 173 => 'application/x-troff-man',
+ 174 => 'application/x-troff-me',
+ 175 => 'application/x-troff-ms',
+ 176 => 'application/x-ustar',
+ 177 => 'application/x-wais-source',
+ 178 => 'application/x-wingz',
+ 179 => 'application/x-x509-ca-cert',
+ 180 => 'application/x-xcf',
+ 181 => 'application/x-xfig',
+ 182 => 'application/x-xpinstall',
+ 183 => 'application/xhtml+xml',
+ 184 => 'application/xml',
+ 185 => 'application/zip',
+ 186 => 'audio/basic',
+ 187 => 'audio/midi',
+ 346 => 'audio/mp4',
+ 188 => 'audio/mpeg',
+ 189 => 'audio/ogg',
+ 190 => 'audio/prs.sid',
+ 191 => 'audio/x-aiff',
+ 192 => 'audio/x-gsm',
+ 193 => 'audio/x-mpegurl',
+ 194 => 'audio/x-ms-wax',
+ 195 => 'audio/x-ms-wma',
+ 196 => 'audio/x-pn-realaudio',
+ 197 => 'audio/x-realaudio',
+ 198 => 'audio/x-scpls',
+ 199 => 'audio/x-sd2',
+ 200 => 'audio/x-wav',
+ 201 => 'chemical/x-alchemy',
+ 202 => 'chemical/x-cache',
+ 203 => 'chemical/x-cache-csf',
+ 204 => 'chemical/x-cactvs-binary',
+ 205 => 'chemical/x-cdx',
+ 206 => 'chemical/x-cerius',
+ 207 => 'chemical/x-chem3d',
+ 208 => 'chemical/x-chemdraw',
+ 209 => 'chemical/x-cif',
+ 210 => 'chemical/x-cmdf',
+ 211 => 'chemical/x-cml',
+ 212 => 'chemical/x-compass',
+ 213 => 'chemical/x-crossfire',
+ 214 => 'chemical/x-csml',
+ 215 => 'chemical/x-ctx',
+ 216 => 'chemical/x-cxf',
+ 217 => 'chemical/x-embl-dl-nucleotide',
+ 218 => 'chemical/x-galactic-spc',
+ 219 => 'chemical/x-gamess-input',
+ 220 => 'chemical/x-gaussian-checkpoint',
+ 221 => 'chemical/x-gaussian-cube',
+ 222 => 'chemical/x-gaussian-input',
+ 223 => 'chemical/x-gaussian-log',
+ 224 => 'chemical/x-gcg8-sequence',
+ 225 => 'chemical/x-genbank',
+ 226 => 'chemical/x-hin',
+ 227 => 'chemical/x-isostar',
+ 228 => 'chemical/x-jcamp-dx',
+ 229 => 'chemical/x-kinemage',
+ 230 => 'chemical/x-macmolecule',
+ 231 => 'chemical/x-macromodel-input',
+ 232 => 'chemical/x-mdl-molfile',
+ 233 => 'chemical/x-mdl-rdfile',
+ 234 => 'chemical/x-mdl-rxnfile',
+ 235 => 'chemical/x-mdl-sdfile',
+ 236 => 'chemical/x-mdl-tgf',
+ 237 => 'chemical/x-mmcif',
+ 238 => 'chemical/x-mol2',
+ 239 => 'chemical/x-molconn-Z',
+ 240 => 'chemical/x-mopac-graph',
+ 241 => 'chemical/x-mopac-input',
+ 242 => 'chemical/x-mopac-out',
+ 243 => 'chemical/x-mopac-vib',
+ 244 => 'chemical/x-ncbi-asn1-ascii',
+ 245 => 'chemical/x-ncbi-asn1-binary',
+ 246 => 'chemical/x-ncbi-asn1-spec',
+ 247 => 'chemical/x-pdb',
+ 248 => 'chemical/x-rosdal',
+ 249 => 'chemical/x-swissprot',
+ 250 => 'chemical/x-vamas-iso14976',
+ 251 => 'chemical/x-vmd',
+ 252 => 'chemical/x-xtel',
+ 253 => 'chemical/x-xyz',
+ 254 => 'image/gif',
+ 255 => 'image/ief',
+ 256 => 'image/jpeg',
+ 257 => 'image/pcx',
+ 258 => 'image/png',
+ 259 => 'image/svg+xml',
+ 260 => 'image/tiff',
+ 261 => 'image/vnd.djvu',
+ 262 => 'image/vnd.microsoft.icon',
+ 263 => 'image/vnd.wap.wbmp',
+ 264 => 'image/x-cmu-raster',
+ 265 => 'image/x-coreldraw',
+ 266 => 'image/x-coreldrawpattern',
+ 267 => 'image/x-coreldrawtemplate',
+ 268 => 'image/x-corelphotopaint',
+ 269 => 'image/x-jg',
+ 270 => 'image/x-jng',
+ 271 => 'image/x-ms-bmp',
+ 272 => 'image/x-photoshop',
+ 273 => 'image/x-portable-anymap',
+ 274 => 'image/x-portable-bitmap',
+ 275 => 'image/x-portable-graymap',
+ 276 => 'image/x-portable-pixmap',
+ 277 => 'image/x-rgb',
+ 278 => 'image/x-xbitmap',
+ 279 => 'image/x-xpixmap',
+ 280 => 'image/x-xwindowdump',
+ 281 => 'message/rfc822',
+ 282 => 'model/iges',
+ 283 => 'model/mesh',
+ 284 => 'model/vrml',
+ 285 => 'text/calendar',
+ 286 => 'text/css',
+ 287 => 'text/csv',
+ 288 => 'text/h323',
+ 289 => 'text/html',
+ 290 => 'text/iuls',
+ 291 => 'text/mathml',
+ 292 => 'text/plain',
+ 293 => 'text/richtext',
+ 294 => 'text/scriptlet',
+ 295 => 'text/tab-separated-values',
+ 296 => 'text/texmacs',
+ 297 => 'text/vnd.sun.j2me.app-descriptor',
+ 298 => 'text/vnd.wap.wml',
+ 299 => 'text/vnd.wap.wmlscript',
+ 300 => 'text/x-bibtex',
+ 301 => 'text/x-boo',
+ 302 => 'text/x-c++hdr',
+ 303 => 'text/x-c++src',
+ 304 => 'text/x-chdr',
+ 305 => 'text/x-component',
+ 306 => 'text/x-csh',
+ 307 => 'text/x-csrc',
+ 308 => 'text/x-diff',
+ 309 => 'text/x-dsrc',
+ 310 => 'text/x-haskell',
+ 311 => 'text/x-java',
+ 312 => 'text/x-literate-haskell',
+ 313 => 'text/x-moc',
+ 314 => 'text/x-pascal',
+ 315 => 'text/x-pcs-gcd',
+ 316 => 'text/x-perl',
+ 317 => 'text/x-python',
+ 318 => 'text/x-setext',
+ 319 => 'text/x-sh',
+ 320 => 'text/x-tcl',
+ 321 => 'text/x-tex',
+ 322 => 'text/x-vcalendar',
+ 323 => 'text/x-vcard',
+ 324 => 'video/3gpp',
+ 325 => 'video/dl',
+ 326 => 'video/dv',
+ 327 => 'video/fli',
+ 328 => 'video/gl',
+ 329 => 'video/mp4',
+ 330 => 'video/mpeg',
+ 331 => 'video/ogg',
+ 332 => 'video/quicktime',
+ 333 => 'video/vnd.mpegurl',
+ 347 => 'video/x-flv',
+ 334 => 'video/x-la-asf',
+ 348 => 'video/x-m4v',
+ 335 => 'video/x-mng',
+ 336 => 'video/x-ms-asf',
+ 337 => 'video/x-ms-wm',
+ 338 => 'video/x-ms-wmv',
+ 339 => 'video/x-ms-wmx',
+ 340 => 'video/x-ms-wvx',
+ 341 => 'video/x-msvideo',
+ 342 => 'video/x-sgi-movie',
+ 343 => 'x-conference/x-cooltalk',
+ 344 => 'x-epoc/x-sisx-app',
+ 345 => 'x-world/x-vrml',
+ ),
+
+ // Extensions added to this list MUST be lower-case.
+ 'extensions' => array(
+ 'ez' => 0,
+ 'atom' => 1,
+ 'atomcat' => 2,
+ 'atomsrv' => 3,
+ 'cap' => 4,
+ 'pcap' => 4,
+ 'cu' => 5,
+ 'tsp' => 6,
+ 'hta' => 7,
+ 'jar' => 8,
+ 'ser' => 9,
+ 'class' => 10,
+ 'hqx' => 11,
+ 'nb' => 12,
+ 'mdb' => 13,
+ 'dot' => 14,
+ 'doc' => 14,
+ 'bin' => 15,
+ 'oda' => 16,
+ 'ogx' => 17,
+ 'pdf' => 18,
+ 'key' => 19,
+ 'pgp' => 20,
+ 'prf' => 21,
+ 'eps' => 22,
+ 'ai' => 22,
+ 'ps' => 22,
+ 'rar' => 23,
+ 'rdf' => 24,
+ 'rss' => 25,
+ 'rtf' => 26,
+ 'smi' => 27,
+ 'smil' => 27,
+ 'cdy' => 28,
+ 'kml' => 29,
+ 'kmz' => 30,
+ 'xul' => 31,
+ 'xlb' => 32,
+ 'xlt' => 32,
+ 'xls' => 32,
+ 'xlam' => 33,
+ 'xlsb' => 34,
+ 'xlsm' => 35,
+ 'xltm' => 36,
+ 'cat' => 37,
+ 'stl' => 38,
+ 'pps' => 39,
+ 'ppt' => 39,
+ 'ppam' => 40,
+ 'pptm' => 41,
+ 'ppsm' => 42,
+ 'potm' => 43,
+ 'docm' => 44,
+ 'dotm' => 45,
+ 'xps' => 46,
+ 'odc' => 47,
+ 'odb' => 48,
+ 'odf' => 49,
+ 'odg' => 50,
+ 'otg' => 51,
+ 'odi' => 52,
+ 'odp' => 53,
+ 'otp' => 54,
+ 'ods' => 55,
+ 'ots' => 56,
+ 'odt' => 57,
+ 'odm' => 58,
+ 'ott' => 59,
+ 'oth' => 60,
+ 'pptx' => 61,
+ 'ppsx' => 62,
+ 'potx' => 63,
+ 'xlsx' => 64,
+ 'xltx' => 65,
+ 'docx' => 66,
+ 'dotx' => 67,
+ 'cod' => 68,
+ 'mmf' => 69,
+ 'sdc' => 70,
+ 'sds' => 71,
+ 'sda' => 72,
+ 'sdd' => 73,
+ 'sdw' => 75,
+ 'sgl' => 76,
+ 'sxc' => 77,
+ 'stc' => 78,
+ 'sxd' => 79,
+ 'std' => 80,
+ 'sxi' => 81,
+ 'sti' => 82,
+ 'sxm' => 83,
+ 'sxw' => 84,
+ 'sxg' => 85,
+ 'stw' => 86,
+ 'sis' => 87,
+ 'vsd' => 88,
+ 'wbxml' => 89,
+ 'wmlc' => 90,
+ 'wmlsc' => 91,
+ 'wpd' => 92,
+ 'wp5' => 93,
+ 'wk' => 94,
+ '7z' => 95,
+ 'abw' => 96,
+ 'dmg' => 97,
+ 'bcpio' => 98,
+ 'torrent' => 99,
+ 'cab' => 100,
+ 'cbr' => 101,
+ 'cbz' => 102,
+ 'cdf' => 103,
+ 'vcd' => 104,
+ 'pgn' => 105,
+ 'cpio' => 106,
+ 'udeb' => 107,
+ 'deb' => 107,
+ 'dir' => 108,
+ 'dxr' => 108,
+ 'dcr' => 108,
+ 'dms' => 109,
+ 'wad' => 110,
+ 'dvi' => 111,
+ 'flac' => 112,
+ 'pfa' => 113,
+ 'pfb' => 113,
+ 'pcf' => 113,
+ 'gsf' => 113,
+ 'pcf.z' => 113,
+ 'mm' => 114,
+ 'spl' => 115,
+ 'gnumeric' => 116,
+ 'sgf' => 117,
+ 'gcf' => 118,
+ 'taz' => 119,
+ 'gtar' => 119,
+ 'tgz' => 119,
+ 'hdf' => 120,
+ 'rhtml' => 121,
+ 'phtml' => 122,
+ 'pht' => 122,
+ 'php' => 122,
+ 'phps' => 123,
+ 'php3' => 124,
+ 'php3p' => 125,
+ 'php4' => 126,
+ 'ica' => 127,
+ 'ins' => 128,
+ 'isp' => 128,
+ 'iii' => 129,
+ 'iso' => 130,
+ 'jnlp' => 131,
+ 'js' => 132,
+ 'jmz' => 133,
+ 'chrt' => 134,
+ 'kil' => 135,
+ 'skp' => 136,
+ 'skd' => 136,
+ 'skm' => 136,
+ 'skt' => 136,
+ 'kpr' => 137,
+ 'kpt' => 137,
+ 'ksp' => 138,
+ 'kwd' => 139,
+ 'kwt' => 139,
+ 'latex' => 140,
+ 'lha' => 141,
+ 'lyx' => 142,
+ 'lzh' => 143,
+ 'lzx' => 144,
+ 'maker' => 145,
+ 'frm' => 145,
+ 'frame' => 145,
+ 'fm' => 145,
+ 'book' => 145,
+ 'fb' => 145,
+ 'fbdoc' => 145,
+ 'mif' => 146,
+ 'wmd' => 147,
+ 'wmz' => 148,
+ 'dll' => 149,
+ 'bat' => 149,
+ 'exe' => 149,
+ 'com' => 149,
+ 'msi' => 150,
+ 'nc' => 151,
+ 'pac' => 152,
+ 'nwc' => 153,
+ 'o' => 154,
+ 'oza' => 155,
+ 'p7r' => 156,
+ 'crl' => 157,
+ 'pyo' => 158,
+ 'pyc' => 158,
+ 'qtl' => 159,
+ 'rpm' => 160,
+ 'shar' => 161,
+ 'swf' => 162,
+ 'swfl' => 162,
+ 'sitx' => 163,
+ 'sit' => 163,
+ 'sv4cpio' => 164,
+ 'sv4crc' => 165,
+ 'tar' => 166,
+ 'gf' => 168,
+ 'pk' => 169,
+ 'texi' => 170,
+ 'texinfo' => 170,
+ 'sik' => 171,
+ '~' => 171,
+ 'bak' => 171,
+ '%' => 171,
+ 'old' => 171,
+ 't' => 172,
+ 'roff' => 172,
+ 'tr' => 172,
+ 'man' => 173,
+ 'me' => 174,
+ 'ms' => 175,
+ 'ustar' => 176,
+ 'src' => 177,
+ 'wz' => 178,
+ 'crt' => 179,
+ 'xcf' => 180,
+ 'fig' => 181,
+ 'xpi' => 182,
+ 'xht' => 183,
+ 'xhtml' => 183,
+ 'xml' => 184,
+ 'xsl' => 184,
+ 'zip' => 185,
+ 'au' => 186,
+ 'snd' => 186,
+ 'mid' => 187,
+ 'midi' => 187,
+ 'kar' => 187,
+ 'mpega' => 188,
+ 'mpga' => 188,
+ 'm4a' => 188,
+ 'mp3' => 188,
+ 'mp2' => 188,
+ 'ogg' => 189,
+ 'oga' => 189,
+ 'spx' => 189,
+ 'sid' => 190,
+ 'aif' => 191,
+ 'aiff' => 191,
+ 'aifc' => 191,
+ 'gsm' => 192,
+ 'm3u' => 193,
+ 'wax' => 194,
+ 'wma' => 195,
+ 'rm' => 196,
+ 'ram' => 196,
+ 'ra' => 197,
+ 'pls' => 198,
+ 'sd2' => 199,
+ 'wav' => 200,
+ 'alc' => 201,
+ 'cac' => 202,
+ 'cache' => 202,
+ 'csf' => 203,
+ 'cascii' => 204,
+ 'cbin' => 204,
+ 'ctab' => 204,
+ 'cdx' => 205,
+ 'cer' => 206,
+ 'c3d' => 207,
+ 'chm' => 208,
+ 'cif' => 209,
+ 'cmdf' => 210,
+ 'cml' => 211,
+ 'cpa' => 212,
+ 'bsd' => 213,
+ 'csml' => 214,
+ 'csm' => 214,
+ 'ctx' => 215,
+ 'cxf' => 216,
+ 'cef' => 216,
+ 'emb' => 217,
+ 'embl' => 217,
+ 'spc' => 218,
+ 'gam' => 219,
+ 'inp' => 219,
+ 'gamin' => 219,
+ 'fchk' => 220,
+ 'fch' => 220,
+ 'cub' => 221,
+ 'gau' => 222,
+ 'gjf' => 222,
+ 'gjc' => 222,
+ 'gal' => 223,
+ 'gcg' => 224,
+ 'gen' => 225,
+ 'hin' => 226,
+ 'istr' => 227,
+ 'ist' => 227,
+ 'dx' => 228,
+ 'jdx' => 228,
+ 'kin' => 229,
+ 'mcm' => 230,
+ 'mmd' => 231,
+ 'mmod' => 231,
+ 'mol' => 232,
+ 'rd' => 233,
+ 'rxn' => 234,
+ 'sdf' => 235,
+ 'sd' => 235,
+ 'tgf' => 236,
+ 'mcif' => 237,
+ 'mol2' => 238,
+ 'b' => 239,
+ 'gpt' => 240,
+ 'mopcrt' => 241,
+ 'zmt' => 241,
+ 'mpc' => 241,
+ 'dat' => 241,
+ 'mop' => 241,
+ 'moo' => 242,
+ 'mvb' => 243,
+ 'prt' => 244,
+ 'aso' => 245,
+ 'val' => 245,
+ 'asn' => 246,
+ 'ent' => 247,
+ 'pdb' => 247,
+ 'ros' => 248,
+ 'sw' => 249,
+ 'vms' => 250,
+ 'vmd' => 251,
+ 'xtel' => 252,
+ 'xyz' => 253,
+ 'gif' => 254,
+ 'ief' => 255,
+ 'jpeg' => 256,
+ 'jpe' => 256,
+ 'jpg' => 256,
+ 'pcx' => 257,
+ 'png' => 258,
+ 'svgz' => 259,
+ 'svg' => 259,
+ 'tif' => 260,
+ 'tiff' => 260,
+ 'djvu' => 261,
+ 'djv' => 261,
+ 'ico' => 262,
+ 'wbmp' => 263,
+ 'ras' => 264,
+ 'cdr' => 265,
+ 'pat' => 266,
+ 'cdt' => 267,
+ 'cpt' => 268,
+ 'art' => 269,
+ 'jng' => 270,
+ 'bmp' => 271,
+ 'psd' => 272,
+ 'pnm' => 273,
+ 'pbm' => 274,
+ 'pgm' => 275,
+ 'ppm' => 276,
+ 'rgb' => 277,
+ 'xbm' => 278,
+ 'xpm' => 279,
+ 'xwd' => 280,
+ 'eml' => 281,
+ 'igs' => 282,
+ 'iges' => 282,
+ 'silo' => 283,
+ 'msh' => 283,
+ 'mesh' => 283,
+ 'icz' => 285,
+ 'ics' => 285,
+ 'css' => 286,
+ 'csv' => 287,
+ '323' => 288,
+ 'html' => 289,
+ 'htm' => 289,
+ 'shtml' => 289,
+ 'uls' => 290,
+ 'mml' => 291,
+ 'txt' => 292,
+ 'pot' => 292,
+ 'text' => 292,
+ 'asc' => 292,
+ 'rtx' => 293,
+ 'wsc' => 294,
+ 'sct' => 294,
+ 'tsv' => 295,
+ 'ts' => 296,
+ 'tm' => 296,
+ 'jad' => 297,
+ 'wml' => 298,
+ 'wmls' => 299,
+ 'bib' => 300,
+ 'boo' => 301,
+ 'hpp' => 302,
+ 'hh' => 302,
+ 'h++' => 302,
+ 'hxx' => 302,
+ 'cxx' => 303,
+ 'cc' => 303,
+ 'cpp' => 303,
+ 'c++' => 303,
+ 'h' => 304,
+ 'htc' => 305,
+ 'csh' => 306,
+ 'c' => 307,
+ 'patch' => 308,
+ 'diff' => 308,
+ 'd' => 309,
+ 'hs' => 310,
+ 'java' => 311,
+ 'lhs' => 312,
+ 'moc' => 313,
+ 'pas' => 314,
+ 'p' => 314,
+ 'gcd' => 315,
+ 'pm' => 316,
+ 'pl' => 316,
+ 'py' => 317,
+ 'etx' => 318,
+ 'sh' => 319,
+ 'tk' => 320,
+ 'tcl' => 320,
+ 'cls' => 321,
+ 'ltx' => 321,
+ 'sty' => 321,
+ 'tex' => 321,
+ 'vcs' => 322,
+ 'vcf' => 323,
+ '3gp' => 324,
+ 'dl' => 325,
+ 'dif' => 326,
+ 'dv' => 326,
+ 'fli' => 327,
+ 'gl' => 328,
+ 'mp4' => 329,
+ 'f4v' => 329,
+ 'f4p' => 329,
+ 'mpe' => 330,
+ 'mpeg' => 330,
+ 'mpg' => 330,
+ 'ogv' => 331,
+ 'qt' => 332,
+ 'mov' => 332,
+ 'mxu' => 333,
+ 'lsf' => 334,
+ 'lsx' => 334,
+ 'mng' => 335,
+ 'asx' => 336,
+ 'asf' => 336,
+ 'wm' => 337,
+ 'wmv' => 338,
+ 'wmx' => 339,
+ 'wvx' => 340,
+ 'avi' => 341,
+ 'movie' => 342,
+ 'ice' => 343,
+ 'sisx' => 344,
+ 'wrl' => 345,
+ 'vrm' => 345,
+ 'vrml' => 345,
+ 'f4a' => 346,
+ 'f4b' => 346,
+ 'flv' => 347,
+ 'm4v' => 348,
+ ),
+ );
+}
diff --git a/core/includes/filetransfer/filetransfer.inc b/core/includes/filetransfer/filetransfer.inc
new file mode 100644
index 000000000000..aa7ebe470e2e
--- /dev/null
+++ b/core/includes/filetransfer/filetransfer.inc
@@ -0,0 +1,418 @@
+<?php
+
+/**
+ * @file
+ * Base FileTransfer class.
+ *
+ * Classes extending this class perform file operations on directories not
+ * writable by the webserver. To achieve this, the class should connect back
+ * to the server using some backend (for example FTP or SSH). To keep security,
+ * the password should always be asked from the user and never stored. For
+ * safety, all methods operate only inside a "jail", by default the Drupal root.
+ */
+abstract class FileTransfer {
+ protected $username;
+ protected $password;
+ protected $hostname = 'localhost';
+ protected $port;
+
+ /**
+ * The constructor for the UpdateConnection class. This method is also called
+ * from the classes that extend this class and override this method.
+ */
+ function __construct($jail) {
+ $this->jail = $jail;
+ }
+
+ /**
+ * Classes that extend this class must override the factory() static method.
+ *
+ * @param string $jail
+ * The full path where all file operations performed by this object will
+ * be restricted to. This prevents the FileTransfer classes from being
+ * able to touch other parts of the filesystem.
+ * @param array $settings
+ * An array of connection settings for the FileTransfer subclass. If the
+ * getSettingsForm() method uses any nested settings, the same structure
+ * will be assumed here.
+ * @return object
+ * New instance of the appropriate FileTransfer subclass.
+ */
+ static function factory($jail, $settings) {
+ throw new FileTransferException('FileTransfer::factory() static method not overridden by FileTransfer subclass.');
+ }
+
+ /**
+ * Implementation of the magic __get() method.
+ *
+ * If the connection isn't set to anything, this will call the connect() method
+ * and set it to and return the result; afterwards, the connection will be
+ * returned directly without using this method.
+ */
+ function __get($name) {
+ if ($name == 'connection') {
+ $this->connect();
+ return $this->connection;
+ }
+
+ if ($name == 'chroot') {
+ $this->setChroot();
+ return $this->chroot;
+ }
+ }
+
+ /**
+ * Connects to the server.
+ */
+ abstract protected function connect();
+
+ /**
+ * Copies a directory.
+ *
+ * @param $source
+ * The source path.
+ * @param $destination
+ * The destination path.
+ */
+ public final function copyDirectory($source, $destination) {
+ $source = $this->sanitizePath($source);
+ $destination = $this->fixRemotePath($destination);
+ $this->checkPath($destination);
+ $this->copyDirectoryJailed($source, $destination);
+ }
+
+ /**
+ * @see http://php.net/chmod
+ *
+ * @param string $path
+ * @param long $mode
+ * @param bool $recursive
+ */
+ public final function chmod($path, $mode, $recursive = FALSE) {
+ if (!in_array('FileTransferChmodInterface', class_implements(get_class($this)))) {
+ throw new FileTransferException('Unable to change file permissions');
+ }
+ $path = $this->sanitizePath($path);
+ $path = $this->fixRemotePath($path);
+ $this->checkPath($path);
+ $this->chmodJailed($path, $mode, $recursive);
+ }
+
+ /**
+ * Creates a directory.
+ *
+ * @param $directory
+ * The directory to be created.
+ */
+ public final function createDirectory($directory) {
+ $directory = $this->fixRemotePath($directory);
+ $this->checkPath($directory);
+ $this->createDirectoryJailed($directory);
+ }
+
+ /**
+ * Removes a directory.
+ *
+ * @param $directory
+ * The directory to be removed.
+ */
+ public final function removeDirectory($directory) {
+ $directory = $this->fixRemotePath($directory);
+ $this->checkPath($directory);
+ $this->removeDirectoryJailed($directory);
+ }
+
+ /**
+ * Copies a file.
+ *
+ * @param $source
+ * The source file.
+ * @param $destination
+ * The destination file.
+ */
+ public final function copyFile($source, $destination) {
+ $source = $this->sanitizePath($source);
+ $destination = $this->fixRemotePath($destination);
+ $this->checkPath($destination);
+ $this->copyFileJailed($source, $destination);
+ }
+
+ /**
+ * Removes a file.
+ *
+ * @param $destination
+ * The destination file to be removed.
+ */
+ public final function removeFile($destination) {
+ $destination = $this->fixRemotePath($destination);
+ $this->checkPath($destination);
+ $this->removeFileJailed($destination);
+ }
+
+ /**
+ * Checks that the path is inside the jail and throws an exception if not.
+ *
+ * @param $path
+ * A path to check against the jail.
+ */
+ protected final function checkPath($path) {
+ $full_jail = $this->chroot . $this->jail;
+ $full_path = drupal_realpath(substr($this->chroot . $path, 0, strlen($full_jail)));
+ $full_path = $this->fixRemotePath($full_path, FALSE);
+ if ($full_jail !== $full_path) {
+ throw new FileTransferException('@directory is outside of the @jail', NULL, array('@directory' => $path, '@jail' => $this->jail));
+ }
+ }
+
+ /**
+ * Returns a modified path suitable for passing to the server.
+ * If a path is a windows path, makes it POSIX compliant by removing the drive letter.
+ * If $this->chroot has a value, it is stripped from the path to allow for
+ * chroot'd filetransfer systems.
+ *
+ * @param $path
+ * @param $strip_chroot
+ *
+ * @return string
+ */
+ protected final function fixRemotePath($path, $strip_chroot = TRUE) {
+ $path = $this->sanitizePath($path);
+ $path = preg_replace('|^([a-z]{1}):|i', '', $path); // Strip out windows driveletter if its there.
+ if ($strip_chroot) {
+ if ($this->chroot && strpos($path, $this->chroot) === 0) {
+ $path = ($path == $this->chroot) ? '' : substr($path, strlen($this->chroot));
+ }
+ }
+ return $path;
+ }
+
+ /**
+ * Changes backslashes to slashes, also removes a trailing slash.
+ *
+ * @param string $path
+ * @return string
+ */
+ function sanitizePath($path) {
+ $path = str_replace('\\', '/', $path); // Windows path sanitization.
+ if (substr($path, -1) == '/') {
+ $path = substr($path, 0, -1);
+ }
+ return $path;
+ }
+
+ /**
+ * Copies a directory.
+ *
+ * We need a separate method to make the $destination is in the jail.
+ *
+ * @param $source
+ * The source path.
+ * @param $destination
+ * The destination path.
+ */
+ protected function copyDirectoryJailed($source, $destination) {
+ if ($this->isDirectory($destination)) {
+ $destination = $destination . '/' . basename($source);
+ }
+ $this->createDirectory($destination);
+ foreach (new RecursiveIteratorIterator(new SkipDotsRecursiveDirectoryIterator($source), RecursiveIteratorIterator::SELF_FIRST) as $filename => $file) {
+ $relative_path = substr($filename, strlen($source));
+ if ($file->isDir()) {
+ $this->createDirectory($destination . $relative_path);
+ }
+ else {
+ $this->copyFile($file->getPathName(), $destination . $relative_path);
+ }
+ }
+ }
+
+ /**
+ * Creates a directory.
+ *
+ * @param $directory
+ * The directory to be created.
+ */
+ abstract protected function createDirectoryJailed($directory);
+
+ /**
+ * Removes a directory.
+ *
+ * @param $directory
+ * The directory to be removed.
+ */
+ abstract protected function removeDirectoryJailed($directory);
+
+ /**
+ * Copies a file.
+ *
+ * @param $source
+ * The source file.
+ * @param $destination
+ * The destination file.
+ */
+ abstract protected function copyFileJailed($source, $destination);
+
+ /**
+ * Removes a file.
+ *
+ * @param $destination
+ * The destination file to be removed.
+ */
+ abstract protected function removeFileJailed($destination);
+
+ /**
+ * Checks if a particular path is a directory
+ *
+ * @param $path
+ * The path to check
+ *
+ * @return boolean
+ */
+ abstract public function isDirectory($path);
+
+ /**
+ * Checks if a particular path is a file (not a directory).
+ *
+ * @param $path
+ * The path to check
+ *
+ * @return boolean
+ */
+ abstract public function isFile($path);
+
+ /**
+ * Returns the chroot property for this connection.
+ *
+ * It does this by moving up the tree until it finds itself. If successful,
+ * it will return the chroot, otherwise FALSE.
+ *
+ * @return
+ * The chroot path for this connection or FALSE.
+ */
+ function findChroot() {
+ // If the file exists as is, there is no chroot.
+ $path = __FILE__;
+ $path = $this->fixRemotePath($path, FALSE);
+ if ($this->isFile($path)) {
+ return FALSE;
+ }
+
+ $path = __DIR__;
+ $path = $this->fixRemotePath($path, FALSE);
+ $parts = explode('/', $path);
+ $chroot = '';
+ while (count($parts)) {
+ $check = implode($parts, '/');
+ if ($this->isFile($check . '/' . basename(__FILE__))) {
+ // Remove the trailing slash.
+ return substr($chroot, 0, -1);
+ }
+ $chroot .= array_shift($parts) . '/';
+ }
+ return FALSE;
+ }
+
+ /**
+ * Sets the chroot and changes the jail to match the correct path scheme
+ *
+ */
+ function setChroot() {
+ $this->chroot = $this->findChroot();
+ $this->jail = $this->fixRemotePath($this->jail);
+ }
+
+ /**
+ * Returns a form to collect connection settings credentials.
+ *
+ * Implementing classes can either extend this form with fields collecting the
+ * specific information they need, or override it entirely.
+ */
+ public function getSettingsForm() {
+ $form['username'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Username'),
+ );
+ $form['password'] = array(
+ '#type' => 'password',
+ '#title' => t('Password'),
+ '#description' => t('Your password is not saved in the database and is only used to establish a connection.'),
+ );
+ $form['advanced'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Advanced settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ );
+ $form['advanced']['hostname'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Host'),
+ '#default_value' => 'localhost',
+ '#description' => t('The connection will be created between your web server and the machine hosting the web server files. In the vast majority of cases, this will be the same machine, and "localhost" is correct.'),
+ );
+ $form['advanced']['port'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Port'),
+ '#default_value' => NULL,
+ );
+ return $form;
+ }
+}
+
+/**
+ * FileTransferException class.
+ */
+class FileTransferException extends Exception {
+ public $arguments;
+
+ function __construct($message, $code = 0, $arguments = array()) {
+ parent::__construct($message, $code);
+ $this->arguments = $arguments;
+ }
+}
+
+
+/**
+ * A FileTransfer Class implementing this interface can be used to chmod files.
+ */
+interface FileTransferChmodInterface {
+
+ /**
+ * Changes the permissions of the file / directory specified in $path
+ *
+ * @param string $path
+ * Path to change permissions of.
+ * @param long $mode
+ * @see http://php.net/chmod
+ * @param boolean $recursive
+ * Pass TRUE to recursively chmod the entire directory specified in $path.
+ */
+ function chmodJailed($path, $mode, $recursive);
+}
+
+/**
+ * Provides an interface for iterating recursively over filesystem directories.
+ *
+ * Manually skips '.' and '..' directories, since no existing method is
+ * available in PHP 5.2.
+ *
+ * @todo Depreciate in favor of RecursiveDirectoryIterator::SKIP_DOTS once PHP
+ * 5.3 or later is required.
+ */
+class SkipDotsRecursiveDirectoryIterator extends RecursiveDirectoryIterator {
+ /**
+ * Constructs a SkipDotsRecursiveDirectoryIterator
+ *
+ * @param $path
+ * The path of the directory to be iterated over.
+ */
+ function __construct($path) {
+ parent::__construct($path);
+ }
+
+ function next() {
+ parent::next();
+ while ($this->isDot()) {
+ parent::next();
+ }
+ }
+}
diff --git a/core/includes/filetransfer/ftp.inc b/core/includes/filetransfer/ftp.inc
new file mode 100644
index 000000000000..838dc7c1e1e5
--- /dev/null
+++ b/core/includes/filetransfer/ftp.inc
@@ -0,0 +1,144 @@
+<?php
+
+/**
+ * Base class for FTP implementations.
+ */
+abstract class FileTransferFTP extends FileTransfer {
+
+ public function __construct($jail, $username, $password, $hostname, $port) {
+ $this->username = $username;
+ $this->password = $password;
+ $this->hostname = $hostname;
+ $this->port = $port;
+ parent::__construct($jail);
+ }
+
+ /**
+ * Return an object which can implement the FTP protocol.
+ *
+ * @param string $jail
+ * @param array $settings
+ * @return FileTransferFTP
+ * The appropriate FileTransferFTP subclass based on the available
+ * options. If the FTP PHP extension is available, use it.
+ */
+ static function factory($jail, $settings) {
+ $username = empty($settings['username']) ? '' : $settings['username'];
+ $password = empty($settings['password']) ? '' : $settings['password'];
+ $hostname = empty($settings['advanced']['hostname']) ? 'localhost' : $settings['advanced']['hostname'];
+ $port = empty($settings['advanced']['port']) ? 21 : $settings['advanced']['port'];
+
+ if (function_exists('ftp_connect')) {
+ $class = 'FileTransferFTPExtension';
+ }
+ else {
+ throw new FileTransferException('No FTP backend available.');
+ }
+
+ return new $class($jail, $username, $password, $hostname, $port);
+ }
+
+ /**
+ * Returns the form to configure the FileTransfer class for FTP.
+ */
+ public function getSettingsForm() {
+ $form = parent::getSettingsForm();
+ $form['advanced']['port']['#default_value'] = 21;
+ return $form;
+ }
+}
+
+class FileTransferFTPExtension extends FileTransferFTP implements FileTransferChmodInterface {
+
+ public function connect() {
+ $this->connection = ftp_connect($this->hostname, $this->port);
+
+ if (!$this->connection) {
+ throw new FileTransferException("Cannot connect to FTP Server, check settings");
+ }
+ if (!ftp_login($this->connection, $this->username, $this->password)) {
+ throw new FileTransferException("Cannot log in to FTP server. Check username and password");
+ }
+ }
+
+ protected function copyFileJailed($source, $destination) {
+ if (!@ftp_put($this->connection, $destination, $source, FTP_BINARY)) {
+ throw new FileTransferException("Cannot move @source to @destination", NULL, array("@source" => $source, "@destination" => $destination));
+ }
+ }
+
+ protected function createDirectoryJailed($directory) {
+ if (!ftp_mkdir($this->connection, $directory)) {
+ throw new FileTransferException("Cannot create directory @directory", NULL, array("@directory" => $directory));
+ }
+ }
+
+ protected function removeDirectoryJailed($directory) {
+ $pwd = ftp_pwd($this->connection);
+ if (!ftp_chdir($this->connection, $directory)) {
+ throw new FileTransferException("Unable to change to directory @directory", NULL, array('@directory' => $directory));
+ }
+ $list = @ftp_nlist($this->connection, '.');
+ if (!$list) {
+ $list = array();
+ }
+ foreach ($list as $item){
+ if ($item == '.' || $item == '..') {
+ continue;
+ }
+ if (@ftp_chdir($this->connection, $item)){
+ ftp_cdup($this->connection);
+ $this->removeDirectory(ftp_pwd($this->connection) . '/' . $item);
+ }
+ else {
+ $this->removeFile(ftp_pwd($this->connection) . '/' . $item);
+ }
+ }
+ ftp_chdir($this->connection, $pwd);
+ if (!ftp_rmdir($this->connection, $directory)) {
+ throw new FileTransferException("Unable to remove to directory @directory", NULL, array('@directory' => $directory));
+ }
+ }
+
+ protected function removeFileJailed($destination) {
+ if (!ftp_delete($this->connection, $destination)) {
+ throw new FileTransferException("Unable to remove to file @file", NULL, array('@file' => $destination));
+ }
+ }
+
+ public function isDirectory($path) {
+ $result = FALSE;
+ $curr = ftp_pwd($this->connection);
+ if (@ftp_chdir($this->connection, $path)) {
+ $result = TRUE;
+ }
+ ftp_chdir($this->connection, $curr);
+ return $result;
+ }
+
+ public function isFile($path) {
+ return ftp_size($this->connection, $path) != -1;
+ }
+
+ function chmodJailed($path, $mode, $recursive) {
+ if (!ftp_chmod($this->connection, $mode, $path)) {
+ throw new FileTransferException("Unable to set permissions on %file", NULL, array ('%file' => $path));
+ }
+ if ($this->isDirectory($path) && $recursive) {
+ $filelist = @ftp_nlist($this->connection, $path);
+ if (!$filelist) {
+ //empty directory - returns false
+ return;
+ }
+ foreach ($filelist as $file) {
+ $this->chmodJailed($file, $mode, $recursive);
+ }
+ }
+ }
+}
+
+if (!function_exists('ftp_chmod')) {
+ function ftp_chmod($ftp_stream, $mode, $filename) {
+ return ftp_site($ftp_stream, sprintf('CHMOD %o %s', $mode, $filename));
+ }
+}
diff --git a/core/includes/filetransfer/local.inc b/core/includes/filetransfer/local.inc
new file mode 100644
index 000000000000..b1259897331f
--- /dev/null
+++ b/core/includes/filetransfer/local.inc
@@ -0,0 +1,76 @@
+<?php
+
+/**
+ * The local connection class for copying files as the httpd user.
+ */
+class FileTransferLocal extends FileTransfer implements FileTransferChmodInterface {
+
+ function connect() {
+ // No-op
+ }
+
+ static function factory($jail, $settings) {
+ return new FileTransferLocal($jail);
+ }
+
+ protected function copyFileJailed($source, $destination) {
+ if (@!copy($source, $destination)) {
+ throw new FileTransferException('Cannot copy %source to %destination.', NULL, array('%source' => $source, '%destination' => $destination));
+ }
+ }
+
+ protected function createDirectoryJailed($directory) {
+ if (!is_dir($directory) && @!mkdir($directory, 0777, TRUE)) {
+ throw new FileTransferException('Cannot create directory %directory.', NULL, array('%directory' => $directory));
+ }
+ }
+
+ protected function removeDirectoryJailed($directory) {
+ if (!is_dir($directory)) {
+ // Programmer error assertion, not something we expect users to see.
+ throw new FileTransferException('removeDirectoryJailed() called with a path (%directory) that is not a directory.', NULL, array('%directory' => $directory));
+ }
+ foreach (new RecursiveIteratorIterator(new SkipDotsRecursiveDirectoryIterator($directory), RecursiveIteratorIterator::CHILD_FIRST) as $filename => $file) {
+ if ($file->isDir()) {
+ if (@!drupal_rmdir($filename)) {
+ throw new FileTransferException('Cannot remove directory %directory.', NULL, array('%directory' => $filename));
+ }
+ }
+ elseif ($file->isFile()) {
+ if (@!drupal_unlink($filename)) {
+ throw new FileTransferException('Cannot remove file %file.', NULL, array('%file' => $filename));
+ }
+ }
+ }
+ if (@!drupal_rmdir($directory)) {
+ throw new FileTransferException('Cannot remove directory %directory.', NULL, array('%directory' => $directory));
+ }
+ }
+
+ protected function removeFileJailed($file) {
+ if (@!drupal_unlink($file)) {
+ throw new FileTransferException('Cannot remove file %file.', NULL, array('%file' => $file));
+ }
+ }
+
+ public function isDirectory($path) {
+ return is_dir($path);
+ }
+
+ public function isFile($path) {
+ return is_file($path);
+ }
+
+ public function chmodJailed($path, $mode, $recursive) {
+ if ($recursive && is_dir($path)) {
+ foreach (new RecursiveIteratorIterator(new SkipDotsRecursiveDirectoryIterator($path), RecursiveIteratorIterator::SELF_FIRST) as $filename => $file) {
+ if (@!chmod($filename, $mode)) {
+ throw new FileTransferException('Cannot chmod %path.', NULL, array('%path' => $filename));
+ }
+ }
+ }
+ elseif (@!chmod($path, $mode)) {
+ throw new FileTransferException('Cannot chmod %path.', NULL, array('%path' => $path));
+ }
+ }
+}
diff --git a/core/includes/filetransfer/ssh.inc b/core/includes/filetransfer/ssh.inc
new file mode 100644
index 000000000000..43ec3249ef0c
--- /dev/null
+++ b/core/includes/filetransfer/ssh.inc
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * The SSH connection class for the update module.
+ */
+class FileTransferSSH extends FileTransfer implements FileTransferChmodInterface {
+
+ function __construct($jail, $username, $password, $hostname = "localhost", $port = 22) {
+ $this->username = $username;
+ $this->password = $password;
+ $this->hostname = $hostname;
+ $this->port = $port;
+ parent::__construct($jail);
+ }
+
+ function connect() {
+ $this->connection = @ssh2_connect($this->hostname, $this->port);
+ if (!$this->connection) {
+ throw new FileTransferException('SSH Connection failed to @host:@port', NULL, array('@host' => $this->hostname, '@port' => $this->port));
+ }
+ if (!@ssh2_auth_password($this->connection, $this->username, $this->password)) {
+ throw new FileTransferException('The supplied username/password combination was not accepted.');
+ }
+ }
+
+ static function factory($jail, $settings) {
+ $username = empty($settings['username']) ? '' : $settings['username'];
+ $password = empty($settings['password']) ? '' : $settings['password'];
+ $hostname = empty($settings['advanced']['hostname']) ? 'localhost' : $settings['advanced']['hostname'];
+ $port = empty($settings['advanced']['port']) ? 22 : $settings['advanced']['port'];
+ return new FileTransferSSH($jail, $username, $password, $hostname, $port);
+ }
+
+ protected function copyFileJailed($source, $destination) {
+ if (!@ssh2_scp_send($this->connection, $source, $destination)) {
+ throw new FileTransferException('Cannot copy @source_file to @destination_file.', NULL, array('@source' => $source, '@destination' => $destination));
+ }
+ }
+
+ protected function copyDirectoryJailed($source, $destination) {
+ if (@!ssh2_exec($this->connection, 'cp -Rp ' . escapeshellarg($source) . ' ' . escapeshellarg($destination))) {
+ throw new FileTransferException('Cannot copy directory @directory.', NULL, array('@directory' => $source));
+ }
+ }
+
+ protected function createDirectoryJailed($directory) {
+ if (@!ssh2_exec($this->connection, 'mkdir ' . escapeshellarg($directory))) {
+ throw new FileTransferException('Cannot create directory @directory.', NULL, array('@directory' => $directory));
+ }
+ }
+
+ protected function removeDirectoryJailed($directory) {
+ if (@!ssh2_exec($this->connection, 'rm -Rf ' . escapeshellarg($directory))) {
+ throw new FileTransferException('Cannot remove @directory.', NULL, array('@directory' => $directory));
+ }
+ }
+
+ protected function removeFileJailed($destination) {
+ if (!@ssh2_exec($this->connection, 'rm ' . escapeshellarg($destination))) {
+ throw new FileTransferException('Cannot remove @directory.', NULL, array('@directory' => $destination));
+ }
+ }
+
+ /**
+ * WARNING: This is untested. It is not currently used, but should do the trick.
+ */
+ public function isDirectory($path) {
+ $directory = escapeshellarg($path);
+ $cmd = "[ -d {$directory} ] && echo 'yes'";
+ if ($output = @ssh2_exec($this->connection, $cmd)) {
+ if ($output == 'yes') {
+ return TRUE;
+ }
+ return FALSE;
+ } else {
+ throw new FileTransferException('Cannot check @path.', NULL, array('@path' => $path));
+ }
+ }
+
+ public function isFile($path) {
+ $file = escapeshellarg($path);
+ $cmd = "[ -f {$file} ] && echo 'yes'";
+ if ($output = @ssh2_exec($this->connection, $cmd)) {
+ if ($output == 'yes') {
+ return TRUE;
+ }
+ return FALSE;
+ } else {
+ throw new FileTransferException('Cannot check @path.', NULL, array('@path' => $path));
+ }
+ }
+
+ function chmodJailed($path, $mode, $recursive) {
+ $cmd = sprintf("chmod %s%o %s", $recursive ? '-R ' : '', $mode, escapeshellarg($path));
+ if (@!ssh2_exec($this->connection, $cmd)) {
+ throw new FileTransferException('Cannot change permissions of @path.', NULL, array('@path' => $path));
+ }
+ }
+
+ /**
+ * Returns the form to configure the FileTransfer class for SSH.
+ */
+ public function getSettingsForm() {
+ $form = parent::getSettingsForm();
+ $form['advanced']['port']['#default_value'] = 22;
+ return $form;
+ }
+}
diff --git a/core/includes/form.inc b/core/includes/form.inc
new file mode 100644
index 000000000000..00f95330b6e9
--- /dev/null
+++ b/core/includes/form.inc
@@ -0,0 +1,4464 @@
+<?php
+
+/**
+ * @defgroup forms Form builder functions
+ * @{
+ * Functions that build an abstract representation of a HTML form.
+ *
+ * All modules should declare their form builder functions to be in this
+ * group and each builder function should reference its validate and submit
+ * functions using \@see. Conversely, validate and submit functions should
+ * reference the form builder function using \@see. For examples, of this see
+ * system_modules_uninstall() or user_pass(), the latter of which has the
+ * following in its doxygen documentation:
+ *
+ * \@ingroup forms
+ * \@see user_pass_validate().
+ * \@see user_pass_submit().
+ *
+ * @} End of "defgroup forms".
+ */
+
+/**
+ * @defgroup form_api Form generation
+ * @{
+ * Functions to enable the processing and display of HTML forms.
+ *
+ * Drupal uses these functions to achieve consistency in its form processing and
+ * presentation, while simplifying code and reducing the amount of HTML that
+ * must be explicitly generated by modules.
+ *
+ * The primary function used with forms is drupal_get_form(), which is
+ * used for forms presented interactively to a user. Forms can also be built and
+ * submitted programmatically without any user input using the
+ * drupal_form_submit() function.
+ *
+ * drupal_get_form() handles retrieving, processing, and displaying a rendered
+ * HTML form for modules automatically.
+ *
+ * Here is an example of how to use drupal_get_form() and a form builder
+ * function:
+ * @code
+ * $form = drupal_get_form('my_module_example_form');
+ * ...
+ * function my_module_example_form($form, &$form_state) {
+ * $form['submit'] = array(
+ * '#type' => 'submit',
+ * '#value' => t('Submit'),
+ * );
+ * return $form;
+ * }
+ * function my_module_example_form_validate($form, &$form_state) {
+ * // Validation logic.
+ * }
+ * function my_module_example_form_submit($form, &$form_state) {
+ * // Submission logic.
+ * }
+ * @endcode
+ *
+ * Or with any number of additional arguments:
+ * @code
+ * $extra = "extra";
+ * $form = drupal_get_form('my_module_example_form', $extra);
+ * ...
+ * function my_module_example_form($form, &$form_state, $extra) {
+ * $form['submit'] = array(
+ * '#type' => 'submit',
+ * '#value' => $extra,
+ * );
+ * return $form;
+ * }
+ * @endcode
+ *
+ * The $form argument to form-related functions is a structured array containing
+ * the elements and properties of the form. For information on the array
+ * components and format, and more detailed explanations of the Form API
+ * workflow, see the
+ * @link http://api.drupal.org/api/drupal/developer--topics--forms_api_reference.html Form API reference @endlink
+ * and the
+ * @link http://drupal.org/node/37775 Form API documentation section. @endlink
+ * In addition, there is a set of Form API tutorials in
+ * @link form_example_tutorial.inc the Form Example Tutorial @endlink which
+ * provide basics all the way up through multistep forms.
+ *
+ * In the form builder, validation, submission, and other form functions,
+ * $form_state is the primary influence on the processing of the form and is
+ * passed by reference to most functions, so they use it to communicate with
+ * the form system and each other.
+ *
+ * See drupal_build_form() for documentation of $form_state keys.
+ */
+
+/**
+ * Wrapper for drupal_build_form() for use when $form_state is not needed.
+ *
+ * @param $form_id
+ * The unique string identifying the desired form. If a function with that
+ * name exists, it is called to build the form array. Modules that need to
+ * generate the same form (or very similar forms) using different $form_ids
+ * can implement hook_forms(), which maps different $form_id values to the
+ * proper form constructor function. Examples may be found in node_forms(),
+ * search_forms(), and user_forms().
+ * @param ...
+ * Any additional arguments are passed on to the functions called by
+ * drupal_get_form(), including the unique form constructor function. For
+ * example, the node_edit form requires that a node object is passed in here
+ * when it is called. These are available to implementations of
+ * hook_form_alter() and hook_form_FORM_ID_alter() as the array
+ * $form_state['build_info']['args'].
+ *
+ * @return
+ * The form array.
+ *
+ * @see drupal_build_form()
+ */
+function drupal_get_form($form_id) {
+ $form_state = array();
+
+ $args = func_get_args();
+ // Remove $form_id from the arguments.
+ array_shift($args);
+ $form_state['build_info']['args'] = $args;
+
+ return drupal_build_form($form_id, $form_state);
+}
+
+/**
+ * Build and process a form based on a form id.
+ *
+ * The form may also be retrieved from the cache if the form was built in a
+ * previous page-load. The form is then passed on for processing, validation
+ * and submission if there is proper input.
+ *
+ * @param $form_id
+ * The unique string identifying the desired form. If a function with that
+ * name exists, it is called to build the form array. Modules that need to
+ * generate the same form (or very similar forms) using different $form_ids
+ * can implement hook_forms(), which maps different $form_id values to the
+ * proper form constructor function. Examples may be found in node_forms(),
+ * search_forms(), and user_forms().
+ * @param $form_state
+ * An array which stores information about the form. This is passed as a
+ * reference so that the caller can use it to examine what in the form changed
+ * when the form submission process is complete. Furthermore, it may be used
+ * to store information related to the processed data in the form, which will
+ * persist across page requests when the 'cache' or 'rebuild' flag is set.
+ * The following parameters may be set in $form_state to affect how the form
+ * is rendered:
+ * - build_info: Internal. An associative array of information stored by Form
+ * API that is necessary to build and rebuild the form from cache when the
+ * original context may no longer be available:
+ * - args: A list of arguments to pass to the form constructor.
+ * - files: An optional array defining include files that need to be loaded
+ * for building the form. Each array entry may be the path to a file or
+ * another array containing values for the parameters 'type', 'module' and
+ * 'name' as needed by module_load_include(). The files listed here are
+ * automatically loaded by form_get_cache(). By default the current menu
+ * router item's 'file' definition is added, if any. Use
+ * form_load_include() to add include files from a form constructor.
+ * - rebuild_info: Internal. Similar to 'build_info', but pertaining to
+ * drupal_rebuild_form().
+ * - rebuild: Normally, after the entire form processing is completed and
+ * submit handlers have run, a form is considered to be done and
+ * drupal_redirect_form() will redirect the user to a new page using a GET
+ * request (so a browser refresh does not re-submit the form). However, if
+ * 'rebuild' has been set to TRUE, then a new copy of the form is
+ * immediately built and sent to the browser, instead of a redirect. This is
+ * used for multi-step forms, such as wizards and confirmation forms.
+ * Normally, $form_state['rebuild'] is set by a submit handler, since it is
+ * usually logic within a submit handler that determines whether a form is
+ * done or requires another step. However, a validation handler may already
+ * set $form_state['rebuild'] to cause the form processing to bypass submit
+ * handlers and rebuild the form instead, even if there are no validation
+ * errors.
+ * - redirect: Used to redirect the form on submission. It may either be a
+ * string containing the destination URL, or an array of arguments
+ * compatible with drupal_goto(). See drupal_redirect_form() for complete
+ * information.
+ * - no_redirect: If set to TRUE the form will NOT perform a drupal_goto(),
+ * even if 'redirect' is set.
+ * - method: The HTTP form method to use for finding the input for this form.
+ * May be 'post' or 'get'. Defaults to 'post'. Note that 'get' method
+ * forms do not use form ids so are always considered to be submitted, which
+ * can have unexpected effects. The 'get' method should only be used on
+ * forms that do not change data, as that is exclusively the domain of
+ * 'post.'
+ * - cache: If set to TRUE the original, unprocessed form structure will be
+ * cached, which allows the entire form to be rebuilt from cache. A typical
+ * form workflow involves two page requests; first, a form is built and
+ * rendered for the user to fill in. Then, the user fills the form in and
+ * submits it, triggering a second page request in which the form must be
+ * built and processed. By default, $form and $form_state are built from
+ * scratch during each of these page requests. Often, it is necessary or
+ * desired to persist the $form and $form_state variables from the initial
+ * page request to the one that processes the submission. 'cache' can be set
+ * to TRUE to do this. A prominent example is an Ajax-enabled form, in which
+ * ajax_process_form() enables form caching for all forms that include an
+ * element with the #ajax property. (The Ajax handler has no way to build
+ * the form itself, so must rely on the cached version.) Note that the
+ * persistence of $form and $form_state happens automatically for
+ * (multi-step) forms having the 'rebuild' flag set, regardless of the value
+ * for 'cache'.
+ * - no_cache: If set to TRUE the form will NOT be cached, even if 'cache' is
+ * set.
+ * - values: An associative array of values submitted to the form. The
+ * validation functions and submit functions use this array for nearly all
+ * their decision making. (Note that
+ * @link http://api.drupal.org/api/drupal/developer--topics--forms_api_reference.html/7#tree #tree @endlink
+ * determines whether the values are a flat array or an array whose structure
+ * parallels the $form array.)
+ * - input: The array of values as they were submitted by the user. These are
+ * raw and unvalidated, so should not be used without a thorough
+ * understanding of security implications. In almost all cases, code should
+ * use the data in the 'values' array exclusively. The most common use of
+ * this key is for multi-step forms that need to clear some of the user
+ * input when setting 'rebuild'. The values correspond to $_POST or $_GET,
+ * depending on the 'method' chosen.
+ * - always_process: If TRUE and the method is GET, a form_id is not
+ * necessary. This should only be used on RESTful GET forms that do NOT
+ * write data, as this could lead to security issues. It is useful so that
+ * searches do not need to have a form_id in their query arguments to
+ * trigger the search.
+ * - must_validate: Ordinarily, a form is only validated once, but there are
+ * times when a form is resubmitted internally and should be validated
+ * again. Setting this to TRUE will force that to happen. This is most
+ * likely to occur during Ajax operations.
+ * - programmed: If TRUE, the form was submitted programmatically, usually
+ * invoked via drupal_form_submit(). Defaults to FALSE.
+ * - process_input: Boolean flag. TRUE signifies correct form submission.
+ * This is always TRUE for programmed forms coming from drupal_form_submit()
+ * (see 'programmed' key), or if the form_id coming from the $_POST data is
+ * set and matches the current form_id.
+ * - submitted: If TRUE, the form has been submitted. Defaults to FALSE.
+ * - executed: If TRUE, the form was submitted and has been processed and
+ * executed. Defaults to FALSE.
+ * - triggering_element: (read-only) The form element that triggered
+ * submission. This is the same as the deprecated
+ * $form_state['clicked_button']. It is the element that caused submission,
+ * which may or may not be a button (in the case of Ajax forms). This key is
+ * often used to distinguish between various buttons in a submit handler,
+ * and is also used in Ajax handlers.
+ * - has_file_element: Internal. If TRUE, there is a file element and Form API
+ * will set the appropriate 'enctype' HTML attribute on the form.
+ * - groups: Internal. An array containing references to fieldsets to render
+ * them within vertical tabs.
+ * - storage: $form_state['storage'] is not a special key, and no specific
+ * support is provided for it in the Form API. By tradition it was
+ * the location where application-specific data was stored for communication
+ * between the submit, validation, and form builder functions, especially
+ * in a multi-step-style form. Form implementations may use any key(s)
+ * within $form_state (other than the keys listed here and other reserved
+ * ones used by Form API internals) for this kind of storage. The
+ * recommended way to ensure that the chosen key doesn't conflict with ones
+ * used by the Form API or other modules is to use the module name as the
+ * key name or a prefix for the key name. For example, the Node module uses
+ * $form_state['node'] in node editing forms to store information about the
+ * node being edited, and this information stays available across successive
+ * clicks of the "Preview" button as well as when the "Save" button is
+ * finally clicked.
+ * - buttons: A list containing copies of all submit and button elements in
+ * the form.
+ * - complete_form: A reference to the $form variable containing the complete
+ * form structure. #process, #after_build, #element_validate, and other
+ * handlers being invoked on a form element may use this reference to access
+ * other information in the form the element is contained in.
+ * - temporary: An array holding temporary data accessible during the current
+ * page request only. All $form_state properties that are not reserved keys
+ * (see form_state_keys_no_cache()) persist throughout a multistep form
+ * sequence. Form API provides this key for modules to communicate
+ * information across form-related functions during a single page request.
+ * It may be used to temporarily save data that does not need to or should
+ * not be cached during the whole form workflow; e.g., data that needs to be
+ * accessed during the current form build process only. There is no use-case
+ * for this functionality in Drupal core.
+ * - wrapper_callback: Modules that wish to pre-populate certain forms with
+ * common elements, such as back/next/save buttons in multi-step form
+ * wizards, may define a form builder function name that returns a form
+ * structure, which is passed on to the actual form builder function.
+ * Such implementations may either define the 'wrapper_callback' via
+ * hook_forms() or have to invoke drupal_build_form() (instead of
+ * drupal_get_form()) on their own in a custom menu callback to prepare
+ * $form_state accordingly.
+ * Information on how certain $form_state properties control redirection
+ * behavior after form submission may be found in drupal_redirect_form().
+ *
+ * @return
+ * The rendered form. This function may also perform a redirect and hence may
+ * not return at all, depending upon the $form_state flags that were set.
+ *
+ * @see drupal_redirect_form()
+ */
+function drupal_build_form($form_id, &$form_state) {
+ // Ensure some defaults; if already set they will not be overridden.
+ $form_state += form_state_defaults();
+
+ if (!isset($form_state['input'])) {
+ $form_state['input'] = $form_state['method'] == 'get' ? $_GET : $_POST;
+ }
+
+ if (isset($_SESSION['batch_form_state'])) {
+ // We've been redirected here after a batch processing. The form has
+ // already been processed, but needs to be rebuilt. See _batch_finished().
+ $form_state = $_SESSION['batch_form_state'];
+ unset($_SESSION['batch_form_state']);
+ return drupal_rebuild_form($form_id, $form_state);
+ }
+
+ // If the incoming input contains a form_build_id, we'll check the cache for a
+ // copy of the form in question. If it's there, we don't have to rebuild the
+ // form to proceed. In addition, if there is stored form_state data from a
+ // previous step, we'll retrieve it so it can be passed on to the form
+ // processing code.
+ $check_cache = isset($form_state['input']['form_id']) && $form_state['input']['form_id'] == $form_id && !empty($form_state['input']['form_build_id']);
+ if ($check_cache) {
+ $form = form_get_cache($form_state['input']['form_build_id'], $form_state);
+ }
+
+ // If the previous bit of code didn't result in a populated $form object, we
+ // are hitting the form for the first time and we need to build it from
+ // scratch.
+ if (!isset($form)) {
+ // If we attempted to serve the form from cache, uncacheable $form_state
+ // keys need to be removed after retrieving and preparing the form, except
+ // any that were already set prior to retrieving the form.
+ if ($check_cache) {
+ $form_state_before_retrieval = $form_state;
+ }
+
+ $form = drupal_retrieve_form($form_id, $form_state);
+ drupal_prepare_form($form_id, $form, $form_state);
+
+ // form_set_cache() removes uncacheable $form_state keys defined in
+ // form_state_keys_no_cache() in order for multi-step forms to work
+ // properly. This means that form processing logic for single-step forms
+ // using $form_state['cache'] may depend on data stored in those keys
+ // during drupal_retrieve_form()/drupal_prepare_form(), but form
+ // processing should not depend on whether the form is cached or not, so
+ // $form_state is adjusted to match what it would be after a
+ // form_set_cache()/form_get_cache() sequence. These exceptions are
+ // allowed to survive here:
+ // - always_process: Does not make sense in conjunction with form caching
+ // in the first place, since passing form_build_id as a GET parameter is
+ // not desired.
+ // - temporary: Any assigned data is expected to survives within the same
+ // page request.
+ if ($check_cache) {
+ $uncacheable_keys = array_flip(array_diff(form_state_keys_no_cache(), array('always_process', 'temporary')));
+ $form_state = array_diff_key($form_state, $uncacheable_keys);
+ $form_state += $form_state_before_retrieval;
+ }
+ }
+
+ // Now that we have a constructed form, process it. This is where:
+ // - Element #process functions get called to further refine $form.
+ // - User input, if any, gets incorporated in the #value property of the
+ // corresponding elements and into $form_state['values'].
+ // - Validation and submission handlers are called.
+ // - If this submission is part of a multistep workflow, the form is rebuilt
+ // to contain the information of the next step.
+ // - If necessary, the form and form state are cached or re-cached, so that
+ // appropriate information persists to the next page request.
+ // All of the handlers in the pipeline receive $form_state by reference and
+ // can use it to know or update information about the state of the form.
+ drupal_process_form($form_id, $form, $form_state);
+
+ // If this was a successful submission of a single-step form or the last step
+ // of a multi-step form, then drupal_process_form() issued a redirect to
+ // another page, or back to this page, but as a new request. Therefore, if
+ // we're here, it means that this is either a form being viewed initially
+ // before any user input, or there was a validation error requiring the form
+ // to be re-displayed, or we're in a multi-step workflow and need to display
+ // the form's next step. In any case, we have what we need in $form, and can
+ // return it for rendering.
+ return $form;
+}
+
+/**
+ * Retrieve default values for the $form_state array.
+ */
+function form_state_defaults() {
+ return array(
+ 'rebuild' => FALSE,
+ 'rebuild_info' => array(),
+ 'redirect' => NULL,
+ // @todo 'args' is usually set, so no other default 'build_info' keys are
+ // appended via += form_state_defaults().
+ 'build_info' => array(
+ 'args' => array(),
+ 'files' => array(),
+ ),
+ 'temporary' => array(),
+ 'submitted' => FALSE,
+ 'executed' => FALSE,
+ 'programmed' => FALSE,
+ 'cache'=> FALSE,
+ 'method' => 'post',
+ 'groups' => array(),
+ 'buttons' => array(),
+ );
+}
+
+/**
+ * Constructs a new $form from the information in $form_state.
+ *
+ * This is the key function for making multi-step forms advance from step to
+ * step. It is called by drupal_process_form() when all user input processing,
+ * including calling validation and submission handlers, for the request is
+ * finished. If a validate or submit handler set $form_state['rebuild'] to TRUE,
+ * and if other conditions don't preempt a rebuild from happening, then this
+ * function is called to generate a new $form, the next step in the form
+ * workflow, to be returned for rendering.
+ *
+ * Ajax form submissions are almost always multi-step workflows, so that is one
+ * common use-case during which form rebuilding occurs. See ajax_form_callback()
+ * for more information about creating Ajax-enabled forms.
+ *
+ * @param $form_id
+ * The unique string identifying the desired form. If a function
+ * with that name exists, it is called to build the form array.
+ * Modules that need to generate the same form (or very similar forms)
+ * using different $form_ids can implement hook_forms(), which maps
+ * different $form_id values to the proper form constructor function. Examples
+ * may be found in node_forms(), search_forms(), and user_forms().
+ * @param $form_state
+ * A keyed array containing the current state of the form.
+ * @param $old_form
+ * (optional) A previously built $form. Used to retain the #build_id and
+ * #action properties in Ajax callbacks and similar partial form rebuilds. The
+ * only properties copied from $old_form are the ones which both exist in
+ * $old_form and for which $form_state['rebuild_info']['copy'][PROPERTY] is
+ * TRUE. If $old_form is not passed, the entire $form is rebuilt freshly.
+ * 'rebuild_info' needs to be a separate top-level property next to
+ * 'build_info', since the contained data must not be cached.
+ *
+ * @return
+ * The newly built form.
+ *
+ * @see drupal_process_form()
+ * @see ajax_form_callback()
+ */
+function drupal_rebuild_form($form_id, &$form_state, $old_form = NULL) {
+ $form = drupal_retrieve_form($form_id, $form_state);
+
+ // If only parts of the form will be returned to the browser (e.g., Ajax or
+ // RIA clients), re-use the old #build_id to not require client-side code to
+ // manually update the hidden 'build_id' input element.
+ // Otherwise, a new #build_id is generated, to not clobber the previous
+ // build's data in the form cache; also allowing the user to go back to an
+ // earlier build, make changes, and re-submit.
+ // @see drupal_prepare_form()
+ if (isset($old_form['#build_id']) && !empty($form_state['rebuild_info']['copy']['#build_id'])) {
+ $form['#build_id'] = $old_form['#build_id'];
+ }
+ else {
+ $form['#build_id'] = 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand());
+ }
+
+ // #action defaults to request_uri(), but in case of Ajax and other partial
+ // rebuilds, the form is submitted to an alternate URL, and the original
+ // #action needs to be retained.
+ if (isset($old_form['#action']) && !empty($form_state['rebuild_info']['copy']['#action'])) {
+ $form['#action'] = $old_form['#action'];
+ }
+
+ drupal_prepare_form($form_id, $form, $form_state);
+
+ // Caching is normally done in drupal_process_form(), but what needs to be
+ // cached is the $form structure before it passes through form_builder(),
+ // so we need to do it here.
+ // @todo For Drupal 8, find a way to avoid this code duplication.
+ if (empty($form_state['no_cache'])) {
+ form_set_cache($form['#build_id'], $form, $form_state);
+ }
+
+ // Clear out all group associations as these might be different when
+ // re-rendering the form.
+ $form_state['groups'] = array();
+
+ // Return a fully built form that is ready for rendering.
+ return form_builder($form_id, $form, $form_state);
+}
+
+/**
+ * Fetch a form from cache.
+ */
+function form_get_cache($form_build_id, &$form_state) {
+ if ($cached = cache('form')->get('form_' . $form_build_id)) {
+ $form = $cached->data;
+
+ global $user;
+ if ((isset($form['#cache_token']) && drupal_valid_token($form['#cache_token'])) || (!isset($form['#cache_token']) && !$user->uid)) {
+ if ($cached = cache('form')->get('form_state_' . $form_build_id)) {
+ // Re-populate $form_state for subsequent rebuilds.
+ $form_state = $cached->data + $form_state;
+
+ // If the original form is contained in include files, load the files.
+ // @see form_load_include()
+ $form_state['build_info'] += array('files' => array());
+ foreach ($form_state['build_info']['files'] as $file) {
+ if (is_array($file)) {
+ $file += array('type' => 'inc', 'name' => $file['module']);
+ module_load_include($file['type'], $file['module'], $file['name']);
+ }
+ elseif (file_exists($file)) {
+ require_once DRUPAL_ROOT . '/' . $file;
+ }
+ }
+ }
+ return $form;
+ }
+ }
+}
+
+/**
+ * Store a form in the cache.
+ */
+function form_set_cache($form_build_id, $form, $form_state) {
+ // 6 hours cache life time for forms should be plenty.
+ $expire = 21600;
+
+ // Cache form structure.
+ if (isset($form)) {
+ if ($GLOBALS['user']->uid) {
+ $form['#cache_token'] = drupal_get_token();
+ }
+ cache('form')->set('form_' . $form_build_id, $form, REQUEST_TIME + $expire);
+ }
+
+ // Cache form state.
+ if ($data = array_diff_key($form_state, array_flip(form_state_keys_no_cache()))) {
+ cache('form')->set('form_state_' . $form_build_id, $data, REQUEST_TIME + $expire);
+ }
+}
+
+/**
+ * Returns an array of $form_state keys that shouldn't be cached.
+ */
+function form_state_keys_no_cache() {
+ return array(
+ // Public properties defined by form constructors and form handlers.
+ 'always_process',
+ 'must_validate',
+ 'rebuild',
+ 'rebuild_info',
+ 'redirect',
+ 'no_redirect',
+ 'temporary',
+ // Internal properties defined by form processing.
+ 'buttons',
+ 'triggering_element',
+ 'clicked_button',
+ 'complete_form',
+ 'groups',
+ 'input',
+ 'method',
+ 'submit_handlers',
+ 'submitted',
+ 'executed',
+ 'validate_handlers',
+ 'values',
+ );
+}
+
+/**
+ * Loads an include file and makes sure it is loaded whenever the form is processed.
+ *
+ * Example:
+ * @code
+ * // Load node.admin.inc from Node module.
+ * form_load_include($form_state, 'inc', 'node', 'node.admin');
+ * @endcode
+ *
+ * Use this function instead of module_load_include() from inside a form
+ * constructor or any form processing logic as it ensures that the include file
+ * is loaded whenever the form is processed. In contrast to using
+ * module_load_include() directly, form_load_include() makes sure the include
+ * file is correctly loaded also if the form is cached.
+ *
+ * @param $form_state
+ * The current state of the form.
+ * @param $type
+ * The include file's type (file extension).
+ * @param $module
+ * The module to which the include file belongs.
+ * @param $name
+ * (optional) The base file name (without the $type extension). If omitted,
+ * $module is used; i.e., resulting in "$module.$type" by default.
+ *
+ * @return
+ * The filepath of the loaded include file, or FALSE if the include file was
+ * not found or has been loaded already.
+ *
+ * @see module_load_include()
+ */
+function form_load_include(&$form_state, $type, $module, $name = NULL) {
+ if (!isset($name)) {
+ $name = $module;
+ }
+ if (!isset($form_state['build_info']['files']["$module:$name.$type"])) {
+ // Only add successfully included files to the form state.
+ if ($result = module_load_include($type, $module, $name)) {
+ $form_state['build_info']['files']["$module:$name.$type"] = array(
+ 'type' => $type,
+ 'module' => $module,
+ 'name' => $name,
+ );
+ return $result;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Retrieves, populates, and processes a form.
+ *
+ * This function allows you to supply values for form elements and submit a
+ * form for processing. Compare to drupal_get_form(), which also builds and
+ * processes a form, but does not allow you to supply values.
+ *
+ * There is no return value, but you can check to see if there are errors
+ * by calling form_get_errors().
+ *
+ * @param $form_id
+ * The unique string identifying the desired form. If a function
+ * with that name exists, it is called to build the form array.
+ * Modules that need to generate the same form (or very similar forms)
+ * using different $form_ids can implement hook_forms(), which maps
+ * different $form_id values to the proper form constructor function. Examples
+ * may be found in node_forms(), search_forms(), and user_forms().
+ * @param $form_state
+ * A keyed array containing the current state of the form. Most important is
+ * the $form_state['values'] collection, a tree of data used to simulate the
+ * incoming $_POST information from a user's form submission. If a key is not
+ * filled in $form_state['values'], then the default value of the respective
+ * element is used. To submit an unchecked checkbox or other control that
+ * browsers submit by not having a $_POST entry, include the key, but set the
+ * value to NULL.
+ * @param ...
+ * Any additional arguments are passed on to the functions called by
+ * drupal_form_submit(), including the unique form constructor function.
+ * For example, the node_edit form requires that a node object be passed
+ * in here when it is called. Arguments that need to be passed by reference
+ * should not be included here, but rather placed directly in the $form_state
+ * build info array so that the reference can be preserved. For example, a
+ * form builder function with the following signature:
+ * @code
+ * function mymodule_form($form, &$form_state, &$object) {
+ * }
+ * @endcode
+ * would be called via drupal_form_submit() as follows:
+ * @code
+ * $form_state['values'] = $my_form_values;
+ * $form_state['build_info']['args'] = array(&$object);
+ * drupal_form_submit('mymodule_form', $form_state);
+ * @endcode
+ * For example:
+ * @code
+ * // register a new user
+ * $form_state = array();
+ * $form_state['values']['name'] = 'robo-user';
+ * $form_state['values']['mail'] = 'robouser@example.com';
+ * $form_state['values']['pass']['pass1'] = 'password';
+ * $form_state['values']['pass']['pass2'] = 'password';
+ * $form_state['values']['op'] = t('Create new account');
+ * drupal_form_submit('user_register_form', $form_state);
+ * @endcode
+ */
+function drupal_form_submit($form_id, &$form_state) {
+ if (!isset($form_state['build_info']['args'])) {
+ $args = func_get_args();
+ array_shift($args);
+ array_shift($args);
+ $form_state['build_info']['args'] = $args;
+ }
+ // Merge in default values.
+ $form_state += form_state_defaults();
+
+ // Populate $form_state['input'] with the submitted values before retrieving
+ // the form, to be consistent with what drupal_build_form() does for
+ // non-programmatic submissions (form builder functions may expect it to be
+ // there).
+ $form_state['input'] = $form_state['values'];
+
+ $form_state['programmed'] = TRUE;
+ $form = drupal_retrieve_form($form_id, $form_state);
+ // Programmed forms are always submitted.
+ $form_state['submitted'] = TRUE;
+
+ // Reset form validation.
+ $form_state['must_validate'] = TRUE;
+ form_clear_error();
+
+ drupal_prepare_form($form_id, $form, $form_state);
+ drupal_process_form($form_id, $form, $form_state);
+}
+
+/**
+ * Retrieves the structured array that defines a given form.
+ *
+ * @param $form_id
+ * The unique string identifying the desired form. If a function
+ * with that name exists, it is called to build the form array.
+ * Modules that need to generate the same form (or very similar forms)
+ * using different $form_ids can implement hook_forms(), which maps
+ * different $form_id values to the proper form constructor function.
+ * @param $form_state
+ * A keyed array containing the current state of the form, including the
+ * additional arguments to drupal_get_form() or drupal_form_submit() in the
+ * 'args' component of the array.
+ */
+function drupal_retrieve_form($form_id, &$form_state) {
+ $forms = &drupal_static(__FUNCTION__);
+
+ // Record the filepath of the include file containing the original form, so
+ // the form builder callbacks can be loaded when the form is being rebuilt
+ // from cache on a different path (such as 'system/ajax'). See
+ // form_get_cache().
+ // $menu_get_item() is not available at installation time.
+ if (!isset($form_state['build_info']['files']['menu']) && !defined('MAINTENANCE_MODE')) {
+ $item = menu_get_item();
+ if (!empty($item['include_file'])) {
+ // Do not use form_load_include() here, as the file is already loaded.
+ // Anyway, form_get_cache() is able to handle filepaths too.
+ $form_state['build_info']['files']['menu'] = $item['include_file'];
+ }
+ }
+
+ // We save two copies of the incoming arguments: one for modules to use
+ // when mapping form ids to constructor functions, and another to pass to
+ // the constructor function itself.
+ $args = $form_state['build_info']['args'];
+
+ // We first check to see if there's a function named after the $form_id.
+ // If there is, we simply pass the arguments on to it to get the form.
+ if (!function_exists($form_id)) {
+ // In cases where many form_ids need to share a central constructor function,
+ // such as the node editing form, modules can implement hook_forms(). It
+ // maps one or more form_ids to the correct constructor functions.
+ //
+ // We cache the results of that hook to save time, but that only works
+ // for modules that know all their form_ids in advance. (A module that
+ // adds a small 'rate this comment' form to each comment in a list
+ // would need a unique form_id for each one, for example.)
+ //
+ // So, we call the hook if $forms isn't yet populated, OR if it doesn't
+ // yet have an entry for the requested form_id.
+ if (!isset($forms) || !isset($forms[$form_id])) {
+ $forms = module_invoke_all('forms', $form_id, $args);
+ }
+ $form_definition = $forms[$form_id];
+ if (isset($form_definition['callback arguments'])) {
+ $args = array_merge($form_definition['callback arguments'], $args);
+ }
+ if (isset($form_definition['callback'])) {
+ $callback = $form_definition['callback'];
+ $form_state['build_info']['base_form_id'] = $callback;
+ }
+ // In case $form_state['wrapper_callback'] is not defined already, we also
+ // allow hook_forms() to define one.
+ if (!isset($form_state['wrapper_callback']) && isset($form_definition['wrapper_callback'])) {
+ $form_state['wrapper_callback'] = $form_definition['wrapper_callback'];
+ }
+ }
+
+ $form = array();
+ // Assign a default CSS class name based on $form_id.
+ // This happens here and not in drupal_prepare_form() in order to allow the
+ // form constructor function to override or remove the default class.
+ $form['#attributes']['class'][] = drupal_html_class($form_id);
+ // Same for the base form ID, if any.
+ if (isset($form_state['build_info']['base_form_id'])) {
+ $form['#attributes']['class'][] = drupal_html_class($form_state['build_info']['base_form_id']);
+ }
+
+ // We need to pass $form_state by reference in order for forms to modify it,
+ // since call_user_func_array() requires that referenced variables are passed
+ // explicitly.
+ $args = array_merge(array($form, &$form_state), $args);
+
+ // When the passed $form_state (not using drupal_get_form()) defines a
+ // 'wrapper_callback', then it requests to invoke a separate (wrapping) form
+ // builder function to pre-populate the $form array with form elements, which
+ // the actual form builder function ($callback) expects. This allows for
+ // pre-populating a form with common elements for certain forms, such as
+ // back/next/save buttons in multi-step form wizards. See drupal_build_form().
+ if (isset($form_state['wrapper_callback']) && function_exists($form_state['wrapper_callback'])) {
+ $form = call_user_func_array($form_state['wrapper_callback'], $args);
+ // Put the prepopulated $form into $args.
+ $args[0] = $form;
+ }
+
+ // If $callback was returned by a hook_forms() implementation, call it.
+ // Otherwise, call the function named after the form id.
+ $form = call_user_func_array(isset($callback) ? $callback : $form_id, $args);
+ $form['#form_id'] = $form_id;
+
+ return $form;
+}
+
+/**
+ * Processes a form submission.
+ *
+ * This function is the heart of form API. The form gets built, validated and in
+ * appropriate cases, submitted and rebuilt.
+ *
+ * @param $form_id
+ * The unique string identifying the current form.
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. This
+ * includes the current persistent storage data for the form, and
+ * any data passed along by earlier steps when displaying a
+ * multi-step form. Additional information, like the sanitized $_POST
+ * data, is also accumulated here.
+ */
+function drupal_process_form($form_id, &$form, &$form_state) {
+ $form_state['values'] = array();
+
+ // With $_GET, these forms are always submitted if requested.
+ if ($form_state['method'] == 'get' && !empty($form_state['always_process'])) {
+ if (!isset($form_state['input']['form_build_id'])) {
+ $form_state['input']['form_build_id'] = $form['#build_id'];
+ }
+ if (!isset($form_state['input']['form_id'])) {
+ $form_state['input']['form_id'] = $form_id;
+ }
+ if (!isset($form_state['input']['form_token']) && isset($form['#token'])) {
+ $form_state['input']['form_token'] = drupal_get_token($form['#token']);
+ }
+ }
+
+ // form_builder() finishes building the form by calling element #process
+ // functions and mapping user input, if any, to #value properties, and also
+ // storing the values in $form_state['values']. We need to retain the
+ // unprocessed $form in case it needs to be cached.
+ $unprocessed_form = $form;
+ $form = form_builder($form_id, $form, $form_state);
+
+ // Only process the input if we have a correct form submission.
+ if ($form_state['process_input']) {
+ drupal_validate_form($form_id, $form, $form_state);
+
+ // drupal_html_id() maintains a cache of element IDs it has seen,
+ // so it can prevent duplicates. We want to be sure we reset that
+ // cache when a form is processed, so scenarios that result in
+ // the form being built behind the scenes and again for the
+ // browser don't increment all the element IDs needlessly.
+ drupal_static_reset('drupal_html_id');
+
+ if ($form_state['submitted'] && !form_get_errors() && !$form_state['rebuild']) {
+ // Execute form submit handlers.
+ form_execute_handlers('submit', $form, $form_state);
+
+ // We'll clear out the cached copies of the form and its stored data
+ // here, as we've finished with them. The in-memory copies are still
+ // here, though.
+ if (!variable_get('cache', 0) && !empty($form_state['values']['form_build_id'])) {
+ cache('form')->delete('form_' . $form_state['values']['form_build_id']);
+ cache('form')->delete('form_state_' . $form_state['values']['form_build_id']);
+ }
+
+ // If batches were set in the submit handlers, we process them now,
+ // possibly ending execution. We make sure we do not react to the batch
+ // that is already being processed (if a batch operation performs a
+ // drupal_form_submit).
+ if ($batch =& batch_get() && !isset($batch['current_set'])) {
+ // Store $form_state information in the batch definition.
+ // We need the full $form_state when either:
+ // - Some submit handlers were saved to be called during batch
+ // processing. See form_execute_handlers().
+ // - The form is multistep.
+ // In other cases, we only need the information expected by
+ // drupal_redirect_form().
+ if ($batch['has_form_submits'] || !empty($form_state['rebuild'])) {
+ $batch['form_state'] = $form_state;
+ }
+ else {
+ $batch['form_state'] = array_intersect_key($form_state, array_flip(array('programmed', 'rebuild', 'storage', 'no_redirect', 'redirect')));
+ }
+
+ $batch['progressive'] = !$form_state['programmed'];
+ batch_process();
+
+ // Execution continues only for programmatic forms.
+ // For 'regular' forms, we get redirected to the batch processing
+ // page. Form redirection will be handled in _batch_finished(),
+ // after the batch is processed.
+ }
+
+ // Set a flag to indicate the the form has been processed and executed.
+ $form_state['executed'] = TRUE;
+
+ // Redirect the form based on values in $form_state.
+ drupal_redirect_form($form_state);
+ }
+
+ // Don't rebuild or cache form submissions invoked via drupal_form_submit().
+ if (!empty($form_state['programmed'])) {
+ return;
+ }
+
+ // If $form_state['rebuild'] has been set and input has been processed
+ // without validation errors, we are in a multi-step workflow that is not
+ // yet complete. A new $form needs to be constructed based on the changes
+ // made to $form_state during this request. Normally, a submit handler sets
+ // $form_state['rebuild'] if a fully executed form requires another step.
+ // However, for forms that have not been fully executed (e.g., Ajax
+ // submissions triggered by non-buttons), there is no submit handler to set
+ // $form_state['rebuild']. It would not make sense to redisplay the
+ // identical form without an error for the user to correct, so we also
+ // rebuild error-free non-executed forms, regardless of
+ // $form_state['rebuild'].
+ // @todo D8: Simplify this logic; considering Ajax and non-HTML front-ends,
+ // along with element-level #submit properties, it makes no sense to have
+ // divergent form execution based on whether the triggering element has
+ // #executes_submit_callback set to TRUE.
+ if (($form_state['rebuild'] || !$form_state['executed']) && !form_get_errors()) {
+ // Form building functions (e.g., _form_builder_handle_input_element())
+ // may use $form_state['rebuild'] to determine if they are running in the
+ // context of a rebuild, so ensure it is set.
+ $form_state['rebuild'] = TRUE;
+ $form = drupal_rebuild_form($form_id, $form_state, $form);
+ }
+ }
+
+ // After processing the form, the form builder or a #process callback may
+ // have set $form_state['cache'] to indicate that the form and form state
+ // shall be cached. But the form may only be cached if the 'no_cache' property
+ // is not set to TRUE. Only cache $form as it was prior to form_builder(),
+ // because form_builder() must run for each request to accommodate new user
+ // input. Rebuilt forms are not cached here, because drupal_rebuild_form()
+ // already takes care of that.
+ if (!$form_state['rebuild'] && $form_state['cache'] && empty($form_state['no_cache'])) {
+ form_set_cache($form['#build_id'], $unprocessed_form, $form_state);
+ }
+}
+
+/**
+ * Prepares a structured form array by adding required elements,
+ * executing any hook_form_alter functions, and optionally inserting
+ * a validation token to prevent tampering.
+ *
+ * @param $form_id
+ * A unique string identifying the form for validation, submission,
+ * theming, and hook_form_alter functions.
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. Passed
+ * in here so that hook_form_alter() calls can use it, as well.
+ */
+function drupal_prepare_form($form_id, &$form, &$form_state) {
+ global $user;
+
+ $form['#type'] = 'form';
+ $form_state['programmed'] = isset($form_state['programmed']) ? $form_state['programmed'] : FALSE;
+
+ // Fix the form method, if it is 'get' in $form_state, but not in $form.
+ if ($form_state['method'] == 'get' && !isset($form['#method'])) {
+ $form['#method'] = 'get';
+ }
+
+ // Generate a new #build_id for this form, if none has been set already. The
+ // form_build_id is used as key to cache a particular build of the form. For
+ // multi-step forms, this allows the user to go back to an earlier build, make
+ // changes, and re-submit.
+ // @see drupal_build_form()
+ // @see drupal_rebuild_form()
+ if (!isset($form['#build_id'])) {
+ $form['#build_id'] = 'form-' . drupal_hash_base64(uniqid(mt_rand(), TRUE) . mt_rand());
+ }
+ $form['form_build_id'] = array(
+ '#type' => 'hidden',
+ '#value' => $form['#build_id'],
+ '#id' => $form['#build_id'],
+ '#name' => 'form_build_id',
+ );
+
+ // Add a token, based on either #token or form_id, to any form displayed to
+ // authenticated users. This ensures that any submitted form was actually
+ // requested previously by the user and protects against cross site request
+ // forgeries.
+ // This does not apply to programmatically submitted forms. Furthermore, since
+ // tokens are session-bound and forms displayed to anonymous users are very
+ // likely cached, we cannot assign a token for them.
+ // During installation, there is no $user yet.
+ if (!empty($user->uid) && !$form_state['programmed']) {
+ // Form constructors may explicitly set #token to FALSE when cross site
+ // request forgery is irrelevant to the form, such as search forms.
+ if (isset($form['#token']) && $form['#token'] === FALSE) {
+ unset($form['#token']);
+ }
+ // Otherwise, generate a public token based on the form id.
+ else {
+ $form['#token'] = $form_id;
+ $form['form_token'] = array(
+ '#id' => drupal_html_id('edit-' . $form_id . '-form-token'),
+ '#type' => 'token',
+ '#default_value' => drupal_get_token($form['#token']),
+ );
+ }
+ }
+
+ if (isset($form_id)) {
+ $form['form_id'] = array(
+ '#type' => 'hidden',
+ '#value' => $form_id,
+ '#id' => drupal_html_id("edit-$form_id"),
+ );
+ }
+ if (!isset($form['#id'])) {
+ $form['#id'] = drupal_html_id($form_id);
+ }
+
+ $form += element_info('form');
+ $form += array('#tree' => FALSE, '#parents' => array());
+
+ if (!isset($form['#validate'])) {
+ // Ensure that modules can rely on #validate being set.
+ $form['#validate'] = array();
+ // Check for a handler specific to $form_id.
+ if (function_exists($form_id . '_validate')) {
+ $form['#validate'][] = $form_id . '_validate';
+ }
+ // Otherwise check whether this is a shared form and whether there is a
+ // handler for the shared $form_id.
+ elseif (isset($form_state['build_info']['base_form_id']) && function_exists($form_state['build_info']['base_form_id'] . '_validate')) {
+ $form['#validate'][] = $form_state['build_info']['base_form_id'] . '_validate';
+ }
+ }
+
+ if (!isset($form['#submit'])) {
+ // Ensure that modules can rely on #submit being set.
+ $form['#submit'] = array();
+ // Check for a handler specific to $form_id.
+ if (function_exists($form_id . '_submit')) {
+ $form['#submit'][] = $form_id . '_submit';
+ }
+ // Otherwise check whether this is a shared form and whether there is a
+ // handler for the shared $form_id.
+ elseif (isset($form_state['build_info']['base_form_id']) && function_exists($form_state['build_info']['base_form_id'] . '_submit')) {
+ $form['#submit'][] = $form_state['build_info']['base_form_id'] . '_submit';
+ }
+ }
+
+ // If no #theme has been set, automatically apply theme suggestions.
+ // theme_form() itself is in #theme_wrappers and not #theme. Therefore, the
+ // #theme function only has to care for rendering the inner form elements,
+ // not the form itself.
+ if (!isset($form['#theme'])) {
+ $form['#theme'] = array($form_id);
+ if (isset($form_state['build_info']['base_form_id'])) {
+ $form['#theme'][] = $form_state['build_info']['base_form_id'];
+ }
+ }
+
+ // Invoke hook_form_alter(), hook_form_BASE_FORM_ID_alter(), and
+ // hook_form_FORM_ID_alter() implementations.
+ $hooks = array('form');
+ if (isset($form_state['build_info']['base_form_id'])) {
+ $hooks[] = 'form_' . $form_state['build_info']['base_form_id'];
+ }
+ $hooks[] = 'form_' . $form_id;
+ drupal_alter($hooks, $form, $form_state, $form_id);
+}
+
+
+/**
+ * Validates user-submitted form data from the $form_state using
+ * the validate functions defined in a structured form array.
+ *
+ * @param $form_id
+ * A unique string identifying the form for validation, submission,
+ * theming, and hook_form_alter functions.
+ * @param $form
+ * An associative array containing the structure of the form, which is passed
+ * by reference. Form validation handlers are able to alter the form structure
+ * (like #process and #after_build callbacks during form building) in case of
+ * a validation error. If a validation handler alters the form structure, it
+ * is responsible for validating the values of changed form elements in
+ * $form_state['values'] to prevent form submit handlers from receiving
+ * unvalidated values.
+ * @param $form_state
+ * A keyed array containing the current state of the form. The current
+ * user-submitted data is stored in $form_state['values'], though
+ * form validation functions are passed an explicit copy of the
+ * values for the sake of simplicity. Validation handlers can also
+ * $form_state to pass information on to submit handlers. For example:
+ * $form_state['data_for_submission'] = $data;
+ * This technique is useful when validation requires file parsing,
+ * web service requests, or other expensive requests that should
+ * not be repeated in the submission step.
+ */
+function drupal_validate_form($form_id, &$form, &$form_state) {
+ $validated_forms = &drupal_static(__FUNCTION__, array());
+
+ if (isset($validated_forms[$form_id]) && empty($form_state['must_validate'])) {
+ return;
+ }
+
+ // If the session token was set by drupal_prepare_form(), ensure that it
+ // matches the current user's session.
+ if (isset($form['#token'])) {
+ if (!drupal_valid_token($form_state['values']['form_token'], $form['#token'])) {
+ $path = current_path();
+ $query = drupal_get_query_parameters();
+ $url = url($path, array('query' => $query));
+
+ // Setting this error will cause the form to fail validation.
+ form_set_error('form_token', t('The form has become outdated. Copy any unsaved work in the form below and then <a href="@link">reload this page</a>.', array('@link' => $url)));
+ }
+ }
+
+ _form_validate($form, $form_state, $form_id);
+ $validated_forms[$form_id] = TRUE;
+
+ // If validation errors are limited then remove any non validated form values,
+ // so that only values that passed validation are left for submit callbacks.
+ if (isset($form_state['triggering_element']['#limit_validation_errors']) && $form_state['triggering_element']['#limit_validation_errors'] !== FALSE) {
+ $values = array();
+ foreach ($form_state['triggering_element']['#limit_validation_errors'] as $section) {
+ // If the section exists within $form_state['values'], even if the value
+ // is NULL, copy it to $values.
+ $section_exists = NULL;
+ $value = drupal_array_get_nested_value($form_state['values'], $section, $section_exists);
+ if ($section_exists) {
+ drupal_array_set_nested_value($values, $section, $value);
+ }
+ }
+ // A button's #value does not require validation, so for convenience we
+ // allow the value of the clicked button to be retained in its normal
+ // $form_state['values'] locations, even if these locations are not included
+ // in #limit_validation_errors.
+ if (isset($form_state['triggering_element']['#button_type'])) {
+ $button_value = $form_state['triggering_element']['#value'];
+
+ // Like all input controls, the button value may be in the location
+ // dictated by #parents. If it is, copy it to $values, but do not override
+ // what may already be in $values.
+ $parents = $form_state['triggering_element']['#parents'];
+ if (!drupal_array_nested_key_exists($values, $parents) && drupal_array_get_nested_value($form_state['values'], $parents) === $button_value) {
+ drupal_array_set_nested_value($values, $parents, $button_value);
+ }
+
+ // Additionally, form_builder() places the button value in
+ // $form_state['values'][BUTTON_NAME]. If it's still there, after
+ // validation handlers have run, copy it to $values, but do not override
+ // what may already be in $values.
+ $name = $form_state['triggering_element']['#name'];
+ if (!isset($values[$name]) && isset($form_state['values'][$name]) && $form_state['values'][$name] === $button_value) {
+ $values[$name] = $button_value;
+ }
+ }
+ $form_state['values'] = $values;
+ }
+}
+
+/**
+ * Redirects the user to a URL after a form has been processed.
+ *
+ * After a form was executed, the data in $form_state controls whether the form
+ * is redirected. By default, we redirect to a new destination page. The path
+ * of the destination page can be set in $form_state['redirect'], as either a
+ * string containing the destination or an array of arguments compatible with
+ * drupal_goto(). If that is not set, the user is redirected to the current
+ * page to display a fresh, unpopulated copy of the form.
+ *
+ * For example, to redirect to 'node':
+ * @code
+ * $form_state['redirect'] = 'node';
+ * @endcode
+ * Or to redirect to 'node/123?foo=bar#baz':
+ * @code
+ * $form_state['redirect'] = array(
+ * 'node/123',
+ * array(
+ * 'query' => array(
+ * 'foo' => 'bar',
+ * ),
+ * 'fragment' => 'baz',
+ * ),
+ * );
+ * @endcode
+ *
+ * There are several triggers that may prevent a redirection though:
+ * - If $form_state['redirect'] is FALSE, a form builder function or form
+ * validation/submit handler does not want a user to be redirected, which
+ * means that drupal_goto() is not invoked. For most forms, the redirection
+ * logic will be the same regardless of whether $form_state['redirect'] is
+ * undefined or FALSE. However, in case it was not defined and the current
+ * request contains a 'destination' query string, drupal_goto() will redirect
+ * to that given destination instead. Only setting $form_state['redirect'] to
+ * FALSE will prevent any redirection.
+ * - If $form_state['no_redirect'] is TRUE, then the callback that originally
+ * built the form explicitly disallows any redirection, regardless of the
+ * redirection value in $form_state['redirect']. For example, ajax_get_form()
+ * defines $form_state['no_redirect'] when building a form in an Ajax
+ * callback to prevent any redirection. $form_state['no_redirect'] should NOT
+ * be altered by form builder functions or form validation/submit handlers.
+ * - If $form_state['programmed'] is TRUE, the form submission was usually
+ * invoked via drupal_form_submit(), so any redirection would break the script
+ * that invoked drupal_form_submit().
+ * - If $form_state['rebuild'] is TRUE, the form needs to be rebuilt without
+ * redirection.
+ *
+ * @param $form_state
+ * A keyed array containing the current state of the form.
+ *
+ * @see drupal_process_form()
+ * @see drupal_build_form()
+ */
+function drupal_redirect_form($form_state) {
+ // Skip redirection for form submissions invoked via drupal_form_submit().
+ if (!empty($form_state['programmed'])) {
+ return;
+ }
+ // Skip redirection if rebuild is activated.
+ if (!empty($form_state['rebuild'])) {
+ return;
+ }
+ // Skip redirection if it was explicitly disallowed.
+ if (!empty($form_state['no_redirect'])) {
+ return;
+ }
+ // Only invoke drupal_goto() if redirect value was not set to FALSE.
+ if (!isset($form_state['redirect']) || $form_state['redirect'] !== FALSE) {
+ if (isset($form_state['redirect'])) {
+ if (is_array($form_state['redirect'])) {
+ call_user_func_array('drupal_goto', $form_state['redirect']);
+ }
+ else {
+ // This function can be called from the installer, which guarantees
+ // that $redirect will always be a string, so catch that case here
+ // and use the appropriate redirect function.
+ $function = drupal_installation_attempted() ? 'install_goto' : 'drupal_goto';
+ $function($form_state['redirect']);
+ }
+ }
+ drupal_goto($_GET['q']);
+ }
+}
+
+/**
+ * Performs validation on form elements. First ensures required fields are
+ * completed, #maxlength is not exceeded, and selected options were in the
+ * list of options given to the user. Then calls user-defined validators.
+ *
+ * @param $elements
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. The current
+ * user-submitted data is stored in $form_state['values'], though
+ * form validation functions are passed an explicit copy of the
+ * values for the sake of simplicity. Validation handlers can also
+ * $form_state to pass information on to submit handlers. For example:
+ * $form_state['data_for_submission'] = $data;
+ * This technique is useful when validation requires file parsing,
+ * web service requests, or other expensive requests that should
+ * not be repeated in the submission step.
+ * @param $form_id
+ * A unique string identifying the form for validation, submission,
+ * theming, and hook_form_alter functions.
+ */
+function _form_validate(&$elements, &$form_state, $form_id = NULL) {
+ // Also used in the installer, pre-database setup.
+ $t = get_t();
+
+ // Recurse through all children.
+ foreach (element_children($elements) as $key) {
+ if (isset($elements[$key]) && $elements[$key]) {
+ _form_validate($elements[$key], $form_state);
+ }
+ }
+
+ // Validate the current input.
+ if (!isset($elements['#validated']) || !$elements['#validated']) {
+ // The following errors are always shown.
+ if (isset($elements['#needs_validation'])) {
+ // Verify that the value is not longer than #maxlength.
+ if (isset($elements['#maxlength']) && drupal_strlen($elements['#value']) > $elements['#maxlength']) {
+ form_error($elements, $t('!name cannot be longer than %max characters but is currently %length characters long.', array('!name' => empty($elements['#title']) ? $elements['#parents'][0] : $elements['#title'], '%max' => $elements['#maxlength'], '%length' => drupal_strlen($elements['#value']))));
+ }
+
+ if (isset($elements['#options']) && isset($elements['#value'])) {
+ if ($elements['#type'] == 'select') {
+ $options = form_options_flatten($elements['#options']);
+ }
+ else {
+ $options = $elements['#options'];
+ }
+ if (is_array($elements['#value'])) {
+ $value = in_array($elements['#type'], array('checkboxes', 'tableselect')) ? array_keys($elements['#value']) : $elements['#value'];
+ foreach ($value as $v) {
+ if (!isset($options[$v])) {
+ form_error($elements, $t('An illegal choice has been detected. Please contact the site administrator.'));
+ watchdog('form', 'Illegal choice %choice in !name element.', array('%choice' => $v, '!name' => empty($elements['#title']) ? $elements['#parents'][0] : $elements['#title']), WATCHDOG_ERROR);
+ }
+ }
+ }
+ // Non-multiple select fields always have a value in HTML. If the user
+ // does not change the form, it will be the value of the first option.
+ // Because of this, form validation for the field will almost always
+ // pass, even if the user did not select anything. To work around this
+ // browser behavior, required select fields without a #default_value get
+ // an additional, first empty option. In case the submitted value is
+ // identical to the empty option's value, we reset the element's value
+ // to NULL to trigger the regular #required handling below.
+ // @see form_process_select()
+ elseif ($elements['#type'] == 'select' && !$elements['#multiple'] && $elements['#required'] && !isset($elements['#default_value']) && $elements['#value'] === $elements['#empty_value']) {
+ $elements['#value'] = NULL;
+ form_set_value($elements, NULL, $form_state);
+ }
+ elseif (!isset($options[$elements['#value']])) {
+ form_error($elements, $t('An illegal choice has been detected. Please contact the site administrator.'));
+ watchdog('form', 'Illegal choice %choice in %name element.', array('%choice' => $elements['#value'], '%name' => empty($elements['#title']) ? $elements['#parents'][0] : $elements['#title']), WATCHDOG_ERROR);
+ }
+ }
+ }
+
+ // While this element is being validated, it may be desired that some calls
+ // to form_set_error() be suppressed and not result in a form error, so
+ // that a button that implements low-risk functionality (such as "Previous"
+ // or "Add more") that doesn't require all user input to be valid can still
+ // have its submit handlers triggered. The triggering element's
+ // #limit_validation_errors property contains the information for which
+ // errors are needed, and all other errors are to be suppressed. The
+ // #limit_validation_errors property is ignored if submit handlers will run,
+ // but the element doesn't have a #submit property, because it's too large a
+ // security risk to have any invalid user input when executing form-level
+ // submit handlers.
+ if (isset($form_state['triggering_element']['#limit_validation_errors']) && ($form_state['triggering_element']['#limit_validation_errors'] !== FALSE) && !($form_state['submitted'] && !isset($form_state['triggering_element']['#submit']))) {
+ form_set_error(NULL, '', $form_state['triggering_element']['#limit_validation_errors']);
+ }
+ // If submit handlers won't run (due to the submission having been triggered
+ // by an element whose #executes_submit_callback property isn't TRUE), then
+ // it's safe to suppress all validation errors, and we do so by default,
+ // which is particularly useful during an Ajax submission triggered by a
+ // non-button. An element can override this default by setting the
+ // #limit_validation_errors property. For button element types,
+ // #limit_validation_errors defaults to FALSE (via system_element_info()),
+ // so that full validation is their default behavior.
+ elseif (isset($form_state['triggering_element']) && !isset($form_state['triggering_element']['#limit_validation_errors']) && !$form_state['submitted']) {
+ form_set_error(NULL, '', array());
+ }
+ // As an extra security measure, explicitly turn off error suppression if
+ // one of the above conditions wasn't met. Since this is also done at the
+ // end of this function, doing it here is only to handle the rare edge case
+ // where a validate handler invokes form processing of another form.
+ else {
+ drupal_static_reset('form_set_error:limit_validation_errors');
+ }
+
+ // Make sure a value is passed when the field is required.
+ if (isset($elements['#needs_validation']) && $elements['#required']) {
+ // A simple call to empty() will not cut it here as some fields, like
+ // checkboxes, can return a valid value of '0'. Instead, check the
+ // length if it's a string, and the item count if it's an array.
+ // An unchecked checkbox has a #value of integer 0, different than string
+ // '0', which could be a valid value.
+ $is_empty_multiple = (!count($elements['#value']));
+ $is_empty_string = (is_string($elements['#value']) && drupal_strlen(trim($elements['#value'])) == 0);
+ $is_empty_value = ($elements['#value'] === 0);
+ if ($is_empty_multiple || $is_empty_string || $is_empty_value) {
+ // Although discouraged, a #title is not mandatory for form elements. In
+ // case there is no #title, we cannot set a form error message.
+ // Instead of setting no #title, form constructors are encouraged to set
+ // #title_display to 'invisible' to improve accessibility.
+ if (isset($elements['#title'])) {
+ form_error($elements, $t('!name field is required.', array('!name' => $elements['#title'])));
+ }
+ else {
+ form_error($elements);
+ }
+ }
+ }
+
+ // Call user-defined form level validators.
+ if (isset($form_id)) {
+ form_execute_handlers('validate', $elements, $form_state);
+ }
+ // Call any element-specific validators. These must act on the element
+ // #value data.
+ elseif (isset($elements['#element_validate'])) {
+ foreach ($elements['#element_validate'] as $function) {
+ $function($elements, $form_state, $form_state['complete_form']);
+ }
+ }
+ $elements['#validated'] = TRUE;
+ }
+
+ // Done validating this element, so turn off error suppression.
+ // _form_validate() turns it on again when starting on the next element, if
+ // it's still appropriate to do so.
+ drupal_static_reset('form_set_error:limit_validation_errors');
+}
+
+/**
+ * A helper function used to execute custom validation and submission
+ * handlers for a given form. Button-specific handlers are checked
+ * first. If none exist, the function falls back to form-level handlers.
+ *
+ * @param $type
+ * The type of handler to execute. 'validate' or 'submit' are the
+ * defaults used by Form API.
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. If the user
+ * submitted the form by clicking a button with custom handler functions
+ * defined, those handlers will be stored here.
+ */
+function form_execute_handlers($type, &$form, &$form_state) {
+ $return = FALSE;
+ // If there was a button pressed, use its handlers.
+ if (isset($form_state[$type . '_handlers'])) {
+ $handlers = $form_state[$type . '_handlers'];
+ }
+ // Otherwise, check for a form-level handler.
+ elseif (isset($form['#' . $type])) {
+ $handlers = $form['#' . $type];
+ }
+ else {
+ $handlers = array();
+ }
+
+ foreach ($handlers as $function) {
+ // Check if a previous _submit handler has set a batch, but make sure we
+ // do not react to a batch that is already being processed (for instance
+ // if a batch operation performs a drupal_form_submit()).
+ if ($type == 'submit' && ($batch =& batch_get()) && !isset($batch['id'])) {
+ // Some previous submit handler has set a batch. To ensure correct
+ // execution order, store the call in a special 'control' batch set.
+ // See _batch_next_set().
+ $batch['sets'][] = array('form_submit' => $function);
+ $batch['has_form_submits'] = TRUE;
+ }
+ else {
+ $function($form, $form_state);
+ }
+ $return = TRUE;
+ }
+ return $return;
+}
+
+/**
+ * Files an error against a form element.
+ *
+ * When a validation error is detected, the validator calls form_set_error() to
+ * indicate which element needs to be changed and provide an error message. This
+ * causes the Form API to not execute the form submit handlers, and instead to
+ * re-display the form to the user with the corresponding elements rendered with
+ * an 'error' CSS class (shown as red by default).
+ *
+ * The standard form_set_error() behavior can be changed if a button provides
+ * the #limit_validation_errors property. Multistep forms not wanting to
+ * validate the whole form can set #limit_validation_errors on buttons to
+ * limit validation errors to only certain elements. For example, pressing the
+ * "Previous" button in a multistep form should not fire validation errors just
+ * because the current step has invalid values. If #limit_validation_errors is
+ * set on a clicked button, the button must also define a #submit property
+ * (may be set to an empty array). Any #submit handlers will be executed even if
+ * there is invalid input, so extreme care should be taken with respect to any
+ * actions taken by them. This is typically not a problem with buttons like
+ * "Previous" or "Add more" that do not invoke persistent storage of the
+ * submitted form values. Do not use the #limit_validation_errors property on
+ * buttons that trigger saving of form values to the database.
+ *
+ * The #limit_validation_errors property is a list of "sections" within
+ * $form_state['values'] that must contain valid values. Each "section" is an
+ * array with the ordered set of keys needed to reach that part of
+ * $form_state['values'] (i.e., the #parents property of the element).
+ *
+ * Example 1: Allow the "Previous" button to function, regardless of whether any
+ * user input is valid.
+ *
+ * @code
+ * $form['actions']['previous'] = array(
+ * '#type' => 'submit',
+ * '#value' => t('Previous'),
+ * '#limit_validation_errors' => array(), // No validation.
+ * '#submit' => array('some_submit_function'), // #submit required.
+ * );
+ * @endcode
+ *
+ * Example 2: Require some, but not all, user input to be valid to process the
+ * submission of a "Previous" button.
+ *
+ * @code
+ * $form['actions']['previous'] = array(
+ * '#type' => 'submit',
+ * '#value' => t('Previous'),
+ * '#limit_validation_errors' => array(
+ * array('step1'), // Validate $form_state['values']['step1'].
+ * array('foo', 'bar'), // Validate $form_state['values']['foo']['bar'].
+ * ),
+ * '#submit' => array('some_submit_function'), // #submit required.
+ * );
+ * @endcode
+ *
+ * This will require $form_state['values']['step1'] and everything within it
+ * (for example, $form_state['values']['step1']['choice']) to be valid, so
+ * calls to form_set_error('step1', $message) or
+ * form_set_error('step1][choice', $message) will prevent the submit handlers
+ * from running, and result in the error message being displayed to the user.
+ * However, calls to form_set_error('step2', $message) and
+ * form_set_error('step2][groupX][choiceY', $message) will be suppressed,
+ * resulting in the message not being displayed to the user, and the submit
+ * handlers will run despite $form_state['values']['step2'] and
+ * $form_state['values']['step2']['groupX']['choiceY'] containing invalid
+ * values. Errors for an invalid $form_state['values']['foo'] will be
+ * suppressed, but errors flagging invalid values for
+ * $form_state['values']['foo']['bar'] and everything within it will be
+ * flagged and submission prevented.
+ *
+ * Partial form validation is implemented by suppressing errors rather than by
+ * skipping the input processing and validation steps entirely, because some
+ * forms have button-level submit handlers that call Drupal API functions that
+ * assume that certain data exists within $form_state['values'], and while not
+ * doing anything with that data that requires it to be valid, PHP errors
+ * would be triggered if the input processing and validation steps were fully
+ * skipped.
+ * @see http://drupal.org/node/370537
+ * @see http://drupal.org/node/763376
+ *
+ * @param $name
+ * The name of the form element. If the #parents property of your form
+ * element is array('foo', 'bar', 'baz') then you may set an error on 'foo'
+ * or 'foo][bar][baz'. Setting an error on 'foo' sets an error for every
+ * element where the #parents array starts with 'foo'.
+ * @param $message
+ * The error message to present to the user.
+ * @param $limit_validation_errors
+ * Internal use only. The #limit_validation_errors property of the clicked
+ * button, if it exists.
+ *
+ * @return
+ * Return value is for internal use only. To get a list of errors, use
+ * form_get_errors() or form_get_error().
+ */
+function form_set_error($name = NULL, $message = '', $limit_validation_errors = NULL) {
+ $form = &drupal_static(__FUNCTION__, array());
+ $sections = &drupal_static(__FUNCTION__ . ':limit_validation_errors');
+ if (isset($limit_validation_errors)) {
+ $sections = $limit_validation_errors;
+ }
+
+ if (isset($name) && !isset($form[$name])) {
+ $record = TRUE;
+ if (isset($sections)) {
+ // #limit_validation_errors is an array of "sections" within which user
+ // input must be valid. If the element is within one of these sections,
+ // the error must be recorded. Otherwise, it can be suppressed.
+ // #limit_validation_errors can be an empty array, in which case all
+ // errors are suppressed. For example, a "Previous" button might want its
+ // submit action to be triggered even if none of the submitted values are
+ // valid.
+ $record = FALSE;
+ foreach ($sections as $section) {
+ // Exploding by '][' reconstructs the element's #parents. If the
+ // reconstructed #parents begin with the same keys as the specified
+ // section, then the element's values are within the part of
+ // $form_state['values'] that the clicked button requires to be valid,
+ // so errors for this element must be recorded. As the exploded array
+ // will all be strings, we need to cast every value of the section
+ // array to string.
+ if (array_slice(explode('][', $name), 0, count($section)) === array_map('strval', $section)) {
+ $record = TRUE;
+ break;
+ }
+ }
+ }
+ if ($record) {
+ $form[$name] = $message;
+ if ($message) {
+ drupal_set_message($message, 'error');
+ }
+ }
+ }
+
+ return $form;
+}
+
+/**
+ * Clear all errors against all form elements made by form_set_error().
+ */
+function form_clear_error() {
+ drupal_static_reset('form_set_error');
+}
+
+/**
+ * Return an associative array of all errors.
+ */
+function form_get_errors() {
+ $form = form_set_error();
+ if (!empty($form)) {
+ return $form;
+ }
+}
+
+/**
+ * Returns the error message filed against the given form element.
+ *
+ * Form errors higher up in the form structure override deeper errors as well as
+ * errors on the element itself.
+ */
+function form_get_error($element) {
+ $form = form_set_error();
+ $parents = array();
+ foreach ($element['#parents'] as $parent) {
+ $parents[] = $parent;
+ $key = implode('][', $parents);
+ if (isset($form[$key])) {
+ return $form[$key];
+ }
+ }
+}
+
+/**
+ * Flag an element as having an error.
+ */
+function form_error(&$element, $message = '') {
+ form_set_error(implode('][', $element['#parents']), $message);
+}
+
+/**
+ * Walk through the structured form array, adding any required properties to
+ * each element and mapping the incoming input data to the proper elements.
+ * Also, execute any #process handlers attached to a specific element.
+ *
+ * This is one of the three primary functions that recursively iterates a form
+ * array. This one does it for completing the form building process. The other
+ * two are _form_validate() (invoked via drupal_validate_form() and used to
+ * invoke validation logic for each element) and drupal_render() (for rendering
+ * each element). Each of these three pipelines provides ample opportunity for
+ * modules to customize what happens. For example, during this function's life
+ * cycle, the following functions get called for each element:
+ * - $element['#value_callback']: A function that implements how user input is
+ * mapped to an element's #value property. This defaults to a function named
+ * 'form_type_TYPE_value' where TYPE is $element['#type'].
+ * - $element['#process']: An array of functions called after user input has
+ * been mapped to the element's #value property. These functions can be used
+ * to dynamically add child elements: for example, for the 'date' element
+ * type, one of the functions in this array is form_process_date(), which adds
+ * the individual 'year', 'month', 'day', etc. child elements. These functions
+ * can also be used to set additional properties or implement special logic
+ * other than adding child elements: for example, for the 'fieldset' element
+ * type, one of the functions in this array is form_process_fieldset(), which
+ * adds the attributes and JavaScript needed to make the fieldset collapsible
+ * if the #collapsible property is set. The #process functions are called in
+ * preorder traversal, meaning they are called for the parent element first,
+ * then for the child elements.
+ * - $element['#after_build']: An array of functions called after form_builder()
+ * is done with its processing of the element. These are called in postorder
+ * traversal, meaning they are called for the child elements first, then for
+ * the parent element.
+ * There are similar properties containing callback functions invoked by
+ * _form_validate() and drupal_render(), appropriate for those operations.
+ *
+ * Developers are strongly encouraged to integrate the functionality needed by
+ * their form or module within one of these three pipelines, using the
+ * appropriate callback property, rather than implementing their own recursive
+ * traversal of a form array. This facilitates proper integration between
+ * multiple modules. For example, module developers are familiar with the
+ * relative order in which hook_form_alter() implementations and #process
+ * functions run. A custom traversal function that affects the building of a
+ * form is likely to not integrate with hook_form_alter() and #process in the
+ * expected way. Also, deep recursion within PHP is both slow and memory
+ * intensive, so it is best to minimize how often it's done.
+ *
+ * As stated above, each element's #process functions are executed after its
+ * #value has been set. This enables those functions to execute conditional
+ * logic based on the current value. However, all of form_builder() runs before
+ * drupal_validate_form() is called, so during #process function execution, the
+ * element's #value has not yet been validated, so any code that requires
+ * validated values must reside within a submit handler.
+ *
+ * As a security measure, user input is used for an element's #value only if the
+ * element exists within $form, is not disabled (as per the #disabled property),
+ * and can be accessed (as per the #access property, except that forms submitted
+ * using drupal_form_submit() bypass #access restrictions). When user input is
+ * ignored due to #disabled and #access restrictions, the element's default
+ * value is used.
+ *
+ * Because of the preorder traversal, where #process functions of an element run
+ * before user input for its child elements is processed, and because of the
+ * Form API security of user input processing with respect to #access and
+ * #disabled described above, this generally means that #process functions
+ * should not use an element's (unvalidated) #value to affect the #disabled or
+ * #access of child elements. Use-cases where a developer may be tempted to
+ * implement such conditional logic usually fall into one of two categories:
+ * - Where user input from the current submission must affect the structure of a
+ * form, including properties like #access and #disabled that affect how the
+ * next submission needs to be processed, a multi-step workflow is needed.
+ * This is most commonly implemented with a submit handler setting persistent
+ * data within $form_state based on *validated* values in
+ * $form_state['values'] and setting $form_state['rebuild']. The form building
+ * functions must then be implemented to use the $form_state data to rebuild
+ * the form with the structure appropriate for the new state.
+ * - Where user input must affect the rendering of the form without affecting
+ * its structure, the necessary conditional rendering logic should reside
+ * within functions that run during the rendering phase (#pre_render, #theme,
+ * #theme_wrappers, and #post_render).
+ *
+ * @param $form_id
+ * A unique string identifying the form for validation, submission,
+ * theming, and hook_form_alter functions.
+ * @param $element
+ * An associative array containing the structure of the current element.
+ * @param $form_state
+ * A keyed array containing the current state of the form. In this
+ * context, it is used to accumulate information about which button
+ * was clicked when the form was submitted, as well as the sanitized
+ * $_POST data.
+ */
+function form_builder($form_id, &$element, &$form_state) {
+ // Initialize as unprocessed.
+ $element['#processed'] = FALSE;
+
+ // Use element defaults.
+ if (isset($element['#type']) && empty($element['#defaults_loaded']) && ($info = element_info($element['#type']))) {
+ // Overlay $info onto $element, retaining preexisting keys in $element.
+ $element += $info;
+ $element['#defaults_loaded'] = TRUE;
+ }
+ // Assign basic defaults common for all form elements.
+ $element += array(
+ '#required' => FALSE,
+ '#attributes' => array(),
+ '#title_display' => 'before',
+ );
+
+ // Special handling if we're on the top level form element.
+ if (isset($element['#type']) && $element['#type'] == 'form') {
+ if (!empty($element['#https']) && variable_get('https', FALSE) &&
+ !url_is_external($element['#action'])) {
+ global $base_root;
+
+ // Not an external URL so ensure that it is secure.
+ $element['#action'] = str_replace('http://', 'https://', $base_root) . $element['#action'];
+ }
+
+ // Store a reference to the complete form in $form_state prior to building
+ // the form. This allows advanced #process and #after_build callbacks to
+ // perform changes elsewhere in the form.
+ $form_state['complete_form'] = &$element;
+
+ // Set a flag if we have a correct form submission. This is always TRUE for
+ // programmed forms coming from drupal_form_submit(), or if the form_id coming
+ // from the POST data is set and matches the current form_id.
+ if ($form_state['programmed'] || (!empty($form_state['input']) && (isset($form_state['input']['form_id']) && ($form_state['input']['form_id'] == $form_id)))) {
+ $form_state['process_input'] = TRUE;
+ }
+ else {
+ $form_state['process_input'] = FALSE;
+ }
+
+ // All form elements should have an #array_parents property.
+ $element['#array_parents'] = array();
+ }
+
+ if (!isset($element['#id'])) {
+ $element['#id'] = drupal_html_id('edit-' . implode('-', $element['#parents']));
+ }
+ // Handle input elements.
+ if (!empty($element['#input'])) {
+ _form_builder_handle_input_element($form_id, $element, $form_state);
+ }
+ // Allow for elements to expand to multiple elements, e.g., radios,
+ // checkboxes and files.
+ if (isset($element['#process']) && !$element['#processed']) {
+ foreach ($element['#process'] as $process) {
+ $element = $process($element, $form_state, $form_state['complete_form']);
+ }
+ $element['#processed'] = TRUE;
+ }
+
+ // We start off assuming all form elements are in the correct order.
+ $element['#sorted'] = TRUE;
+
+ // Recurse through all child elements.
+ $count = 0;
+ foreach (element_children($element) as $key) {
+ // Prior to checking properties of child elements, their default properties
+ // need to be loaded.
+ if (isset($element[$key]['#type']) && empty($element[$key]['#defaults_loaded']) && ($info = element_info($element[$key]['#type']))) {
+ $element[$key] += $info;
+ $element[$key]['#defaults_loaded'] = TRUE;
+ }
+
+ // Don't squash an existing tree value.
+ if (!isset($element[$key]['#tree'])) {
+ $element[$key]['#tree'] = $element['#tree'];
+ }
+
+ // Deny access to child elements if parent is denied.
+ if (isset($element['#access']) && !$element['#access']) {
+ $element[$key]['#access'] = FALSE;
+ }
+
+ // Make child elements inherit their parent's #disabled and #allow_focus
+ // values unless they specify their own.
+ foreach (array('#disabled', '#allow_focus') as $property) {
+ if (isset($element[$property]) && !isset($element[$key][$property])) {
+ $element[$key][$property] = $element[$property];
+ }
+ }
+
+ // Don't squash existing parents value.
+ if (!isset($element[$key]['#parents'])) {
+ // Check to see if a tree of child elements is present. If so,
+ // continue down the tree if required.
+ $element[$key]['#parents'] = $element[$key]['#tree'] && $element['#tree'] ? array_merge($element['#parents'], array($key)) : array($key);
+ }
+ // Ensure #array_parents follows the actual form structure.
+ $array_parents = $element['#array_parents'];
+ $array_parents[] = $key;
+ $element[$key]['#array_parents'] = $array_parents;
+
+ // Assign a decimal placeholder weight to preserve original array order.
+ if (!isset($element[$key]['#weight'])) {
+ $element[$key]['#weight'] = $count/1000;
+ }
+ else {
+ // If one of the child elements has a weight then we will need to sort
+ // later.
+ unset($element['#sorted']);
+ }
+ $element[$key] = form_builder($form_id, $element[$key], $form_state);
+ $count++;
+ }
+
+ // The #after_build flag allows any piece of a form to be altered
+ // after normal input parsing has been completed.
+ if (isset($element['#after_build']) && !isset($element['#after_build_done'])) {
+ foreach ($element['#after_build'] as $function) {
+ $element = $function($element, $form_state);
+ }
+ $element['#after_build_done'] = TRUE;
+ }
+
+ // If there is a file element, we need to flip a flag so later the
+ // form encoding can be set.
+ if (isset($element['#type']) && $element['#type'] == 'file') {
+ $form_state['has_file_element'] = TRUE;
+ }
+
+ // Final tasks for the form element after form_builder() has run for all other
+ // elements.
+ if (isset($element['#type']) && $element['#type'] == 'form') {
+ // If there is a file element, we set the form encoding.
+ if (isset($form_state['has_file_element'])) {
+ $element['#attributes']['enctype'] = 'multipart/form-data';
+ }
+
+ // If a form contains a single textfield, and the ENTER key is pressed
+ // within it, Internet Explorer submits the form with no POST data
+ // identifying any submit button. Other browsers submit POST data as though
+ // the user clicked the first button. Therefore, to be as consistent as we
+ // can be across browsers, if no 'triggering_element' has been identified
+ // yet, default it to the first button.
+ if (!$form_state['programmed'] && !isset($form_state['triggering_element']) && !empty($form_state['buttons'])) {
+ $form_state['triggering_element'] = $form_state['buttons'][0];
+ }
+
+ // If the triggering element specifies "button-level" validation and submit
+ // handlers to run instead of the default form-level ones, then add those to
+ // the form state.
+ foreach (array('validate', 'submit') as $type) {
+ if (isset($form_state['triggering_element']['#' . $type])) {
+ $form_state[$type . '_handlers'] = $form_state['triggering_element']['#' . $type];
+ }
+ }
+
+ // If the triggering element executes submit handlers, then set the form
+ // state key that's needed for those handlers to run.
+ if (!empty($form_state['triggering_element']['#executes_submit_callback'])) {
+ $form_state['submitted'] = TRUE;
+ }
+
+ // Special processing if the triggering element is a button.
+ if (isset($form_state['triggering_element']['#button_type'])) {
+ // Because there are several ways in which the triggering element could
+ // have been determined (including from input variables set by JavaScript
+ // or fallback behavior implemented for IE), and because buttons often
+ // have their #name property not derived from their #parents property, we
+ // can't assume that input processing that's happened up until here has
+ // resulted in $form_state['values'][BUTTON_NAME] being set. But it's
+ // common for forms to have several buttons named 'op' and switch on
+ // $form_state['values']['op'] during submit handler execution.
+ $form_state['values'][$form_state['triggering_element']['#name']] = $form_state['triggering_element']['#value'];
+
+ // @todo Legacy support. Remove in Drupal 8.
+ $form_state['clicked_button'] = $form_state['triggering_element'];
+ }
+ }
+ return $element;
+}
+
+/**
+ * Populate the #value and #name properties of input elements so they
+ * can be processed and rendered.
+ */
+function _form_builder_handle_input_element($form_id, &$element, &$form_state) {
+ if (!isset($element['#name'])) {
+ $name = array_shift($element['#parents']);
+ $element['#name'] = $name;
+ if ($element['#type'] == 'file') {
+ // To make it easier to handle $_FILES in file.inc, we place all
+ // file fields in the 'files' array. Also, we do not support
+ // nested file names.
+ $element['#name'] = 'files[' . $element['#name'] . ']';
+ }
+ elseif (count($element['#parents'])) {
+ $element['#name'] .= '[' . implode('][', $element['#parents']) . ']';
+ }
+ array_unshift($element['#parents'], $name);
+ }
+
+ // Setting #disabled to TRUE results in user input being ignored, regardless
+ // of how the element is themed or whether JavaScript is used to change the
+ // control's attributes. However, it's good UI to let the user know that input
+ // is not wanted for the control. HTML supports two attributes for this:
+ // http://www.w3.org/TR/html401/interact/forms.html#h-17.12. If a form wants
+ // to start a control off with one of these attributes for UI purposes only,
+ // but still allow input to be processed if it's sumitted, it can set the
+ // desired attribute in #attributes directly rather than using #disabled.
+ // However, developers should think carefully about the accessibility
+ // implications of doing so: if the form expects input to be enterable under
+ // some condition triggered by JavaScript, how would someone who has
+ // JavaScript disabled trigger that condition? Instead, developers should
+ // consider whether a multi-step form would be more appropriate (#disabled can
+ // be changed from step to step). If one still decides to use JavaScript to
+ // affect when a control is enabled, then it is best for accessibility for the
+ // control to be enabled in the HTML, and disabled by JavaScript on document
+ // ready.
+ if (!empty($element['#disabled'])) {
+ if (!empty($element['#allow_focus'])) {
+ $element['#attributes']['readonly'] = 'readonly';
+ }
+ else {
+ $element['#attributes']['disabled'] = 'disabled';
+ }
+ }
+
+ // With JavaScript or other easy hacking, input can be submitted even for
+ // elements with #access=FALSE or #disabled=TRUE. For security, these must
+ // not be processed. Forms that set #disabled=TRUE on an element do not
+ // expect input for the element, and even forms submitted with
+ // drupal_form_submit() must not be able to get around this. Forms that set
+ // #access=FALSE on an element usually allow access for some users, so forms
+ // submitted with drupal_form_submit() may bypass access restriction and be
+ // treated as high-privilege users instead.
+ $process_input = empty($element['#disabled']) && ($form_state['programmed'] || ($form_state['process_input'] && (!isset($element['#access']) || $element['#access'])));
+
+ // Set the element's #value property.
+ if (!isset($element['#value']) && !array_key_exists('#value', $element)) {
+ $value_callback = !empty($element['#value_callback']) ? $element['#value_callback'] : 'form_type_' . $element['#type'] . '_value';
+ if ($process_input) {
+ // Get the input for the current element. NULL values in the input need to
+ // be explicitly distinguished from missing input. (see below)
+ $input_exists = NULL;
+ $input = drupal_array_get_nested_value($form_state['input'], $element['#parents'], $input_exists);
+ // For browser-submitted forms, the submitted values do not contain values
+ // for certain elements (empty multiple select, unchecked checkbox).
+ // During initial form processing, we add explicit NULL values for such
+ // elements in $form_state['input']. When rebuilding the form, we can
+ // distinguish elements having NULL input from elements that were not part
+ // of the initially submitted form and can therefore use default values
+ // for the latter, if required. Programmatically submitted forms can
+ // submit explicit NULL values when calling drupal_form_submit(), so we do
+ // not modify $form_state['input'] for them.
+ if (!$input_exists && !$form_state['rebuild'] && !$form_state['programmed']) {
+ // Add the necessary parent keys to $form_state['input'] and sets the
+ // element's input value to NULL.
+ drupal_array_set_nested_value($form_state['input'], $element['#parents'], NULL);
+ $input_exists = TRUE;
+ }
+ // If we have input for the current element, assign it to the #value
+ // property, optionally filtered through $value_callback.
+ if ($input_exists) {
+ if (function_exists($value_callback)) {
+ $element['#value'] = $value_callback($element, $input, $form_state);
+ }
+ if (!isset($element['#value']) && isset($input)) {
+ $element['#value'] = $input;
+ }
+ }
+ // Mark all posted values for validation.
+ if (isset($element['#value']) || (!empty($element['#required']))) {
+ $element['#needs_validation'] = TRUE;
+ }
+ }
+ // Load defaults.
+ if (!isset($element['#value'])) {
+ // Call #type_value without a second argument to request default_value handling.
+ if (function_exists($value_callback)) {
+ $element['#value'] = $value_callback($element, FALSE, $form_state);
+ }
+ // Final catch. If we haven't set a value yet, use the explicit default value.
+ // Avoid image buttons (which come with garbage value), so we only get value
+ // for the button actually clicked.
+ if (!isset($element['#value']) && empty($element['#has_garbage_value'])) {
+ $element['#value'] = isset($element['#default_value']) ? $element['#default_value'] : '';
+ }
+ }
+ }
+
+ // Determine which element (if any) triggered the submission of the form and
+ // keep track of all the clickable buttons in the form for
+ // form_state_values_clean(). Enforce the same input processing restrictions
+ // as above.
+ if ($process_input) {
+ // Detect if the element triggered the submission via Ajax.
+ if (_form_element_triggered_scripted_submission($element, $form_state)) {
+ $form_state['triggering_element'] = $element;
+ }
+
+ // If the form was submitted by the browser rather than via Ajax, then it
+ // can only have been triggered by a button, and we need to determine which
+ // button within the constraints of how browsers provide this information.
+ if (isset($element['#button_type'])) {
+ // All buttons in the form need to be tracked for
+ // form_state_values_clean() and for the form_builder() code that handles
+ // a form submission containing no button information in $_POST.
+ $form_state['buttons'][] = $element;
+ if (_form_button_was_clicked($element, $form_state)) {
+ $form_state['triggering_element'] = $element;
+ }
+ }
+ }
+
+ // Set the element's value in $form_state['values'], but only, if its key
+ // does not exist yet (a #value_callback may have already populated it).
+ if (!drupal_array_nested_key_exists($form_state['values'], $element['#parents'])) {
+ form_set_value($element, $element['#value'], $form_state);
+ }
+}
+
+/**
+ * Helper function to handle the convoluted logic of button click detection.
+ *
+ * This detects button or non-button controls that trigger a form submission via
+ * Ajax or some other scriptable environment. These environments can set the
+ * special input key '_triggering_element_name' to identify the triggering
+ * element. If the name alone doesn't identify the element uniquely, the input
+ * key '_triggering_element_value' may also be set to require a match on element
+ * value. An example where this is needed is if there are several buttons all
+ * named 'op', and only differing in their value.
+ */
+function _form_element_triggered_scripted_submission($element, &$form_state) {
+ if (!empty($form_state['input']['_triggering_element_name']) && $element['#name'] == $form_state['input']['_triggering_element_name']) {
+ if (empty($form_state['input']['_triggering_element_value']) || $form_state['input']['_triggering_element_value'] == $element['#value']) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Helper function to handle the convoluted logic of button click detection.
+ *
+ * This detects button controls that trigger a form submission by being clicked
+ * and having the click processed by the browser rather than being captured by
+ * JavaScript. Essentially, it detects if the button's name and value are part
+ * of the POST data, but with extra code to deal with the convoluted way in
+ * which browsers submit data for image button clicks.
+ *
+ * This does not detect button clicks processed by Ajax (that is done in
+ * _form_element_triggered_scripted_submission()) and it does not detect form
+ * submissions from Internet Explorer in response to an ENTER key pressed in a
+ * textfield (form_builder() has extra code for that).
+ *
+ * Because this function contains only part of the logic needed to determine
+ * $form_state['triggering_element'], it should not be called from anywhere
+ * other than within the Form API. Form validation and submit handlers needing
+ * to know which button was clicked should get that information from
+ * $form_state['triggering_element'].
+ */
+function _form_button_was_clicked($element, &$form_state) {
+ // First detect normal 'vanilla' button clicks. Traditionally, all
+ // standard buttons on a form share the same name (usually 'op'),
+ // and the specific return value is used to determine which was
+ // clicked. This ONLY works as long as $form['#name'] puts the
+ // value at the top level of the tree of $_POST data.
+ if (isset($form_state['input'][$element['#name']]) && $form_state['input'][$element['#name']] == $element['#value']) {
+ return TRUE;
+ }
+ // When image buttons are clicked, browsers do NOT pass the form element
+ // value in $_POST. Instead they pass an integer representing the
+ // coordinates of the click on the button image. This means that image
+ // buttons MUST have unique $form['#name'] values, but the details of
+ // their $_POST data should be ignored.
+ elseif (!empty($element['#has_garbage_value']) && isset($element['#value']) && $element['#value'] !== '') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Removes internal Form API elements and buttons from submitted form values.
+ *
+ * This function can be used when a module wants to store all submitted form
+ * values, for example, by serializing them into a single database column. In
+ * such cases, all internal Form API values and all form button elements should
+ * not be contained, and this function allows to remove them before the module
+ * proceeds to storage. Next to button elements, the following internal values
+ * are removed:
+ * - form_id
+ * - form_token
+ * - form_build_id
+ * - op
+ *
+ * @param $form_state
+ * A keyed array containing the current state of the form, including
+ * submitted form values; altered by reference.
+ */
+function form_state_values_clean(&$form_state) {
+ // Remove internal Form API values.
+ unset($form_state['values']['form_id'], $form_state['values']['form_token'], $form_state['values']['form_build_id'], $form_state['values']['op']);
+
+ // Remove button values.
+ // form_builder() collects all button elements in a form. We remove the button
+ // value separately for each button element.
+ foreach ($form_state['buttons'] as $button) {
+ // Remove this button's value from the submitted form values by finding
+ // the value corresponding to this button.
+ // We iterate over the #parents of this button and move a reference to
+ // each parent in $form_state['values']. For example, if #parents is:
+ // array('foo', 'bar', 'baz')
+ // then the corresponding $form_state['values'] part will look like this:
+ // array(
+ // 'foo' => array(
+ // 'bar' => array(
+ // 'baz' => 'button_value',
+ // ),
+ // ),
+ // )
+ // We start by (re)moving 'baz' to $last_parent, so we are able unset it
+ // at the end of the iteration. Initially, $values will contain a
+ // reference to $form_state['values'], but in the iteration we move the
+ // reference to $form_state['values']['foo'], and finally to
+ // $form_state['values']['foo']['bar'], which is the level where we can
+ // unset 'baz' (that is stored in $last_parent).
+ $parents = $button['#parents'];
+ $values = &$form_state['values'];
+ $last_parent = array_pop($parents);
+ foreach ($parents as $parent) {
+ $values = &$values[$parent];
+ }
+ unset($values[$last_parent]);
+ }
+}
+
+/**
+ * Helper function to determine the value for an image button form element.
+ *
+ * @param $form
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @param $form_state
+ * A keyed array containing the current state of the form.
+ * @return
+ * The data that will appear in the $form_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_image_button_value($form, $input, $form_state) {
+ if ($input !== FALSE) {
+ if (!empty($input)) {
+ // If we're dealing with Mozilla or Opera, we're lucky. It will
+ // return a proper value, and we can get on with things.
+ return $form['#return_value'];
+ }
+ else {
+ // Unfortunately, in IE we never get back a proper value for THIS
+ // form element. Instead, we get back two split values: one for the
+ // X and one for the Y coordinates on which the user clicked the
+ // button. We'll find this element in the #post data, and search
+ // in the same spot for its name, with '_x'.
+ $input = $form_state['input'];
+ foreach (explode('[', $form['#name']) as $element_name) {
+ // chop off the ] that may exist.
+ if (substr($element_name, -1) == ']') {
+ $element_name = substr($element_name, 0, -1);
+ }
+
+ if (!isset($input[$element_name])) {
+ if (isset($input[$element_name . '_x'])) {
+ return $form['#return_value'];
+ }
+ return NULL;
+ }
+ $input = $input[$element_name];
+ }
+ return $form['#return_value'];
+ }
+ }
+}
+
+/**
+ * Helper function to determine the value for a checkbox form element.
+ *
+ * @param $form
+ * The form element whose value is being populated.
+* @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_checkbox_value($element, $input = FALSE) {
+ if ($input === FALSE) {
+ // Use #default_value as the default value of a checkbox, except change
+ // NULL to 0, because _form_builder_handle_input_element() would otherwise
+ // replace NULL with empty string, but an empty string is a potentially
+ // valid value for a checked checkbox.
+ return isset($element['#default_value']) ? $element['#default_value'] : 0;
+ }
+ else {
+ // Checked checkboxes are submitted with a value (possibly '0' or ''):
+ // http://www.w3.org/TR/html401/interact/forms.html#successful-controls.
+ // For checked checkboxes, browsers submit the string version of
+ // #return_value, but we return the original #return_value. For unchecked
+ // checkboxes, browsers submit nothing at all, but
+ // _form_builder_handle_input_element() detects this, and calls this
+ // function with $input=NULL. Returning NULL from a value callback means to
+ // use the default value, which is not what is wanted when an unchecked
+ // checkbox is submitted, so we use integer 0 as the value indicating an
+ // unchecked checkbox. Therefore, modules must not use integer 0 as a
+ // #return_value, as doing so results in the checkbox always being treated
+ // as unchecked. The string '0' is allowed for #return_value. The most
+ // common use-case for setting #return_value to either 0 or '0' is for the
+ // first option within a 0-indexed array of checkboxes, and for this,
+ // form_process_checkboxes() uses the string rather than the integer.
+ return isset($input) ? $element['#return_value'] : 0;
+ }
+}
+
+/**
+ * Helper function to determine the value for a checkboxes form element.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_checkboxes_value($element, $input = FALSE) {
+ if ($input === FALSE) {
+ $value = array();
+ $element += array('#default_value' => array());
+ foreach ($element['#default_value'] as $key) {
+ $value[$key] = $key;
+ }
+ return $value;
+ }
+ elseif (is_array($input)) {
+ // Programmatic form submissions use NULL to indicate that a checkbox
+ // should be unchecked; see drupal_form_submit(). We therefore remove all
+ // NULL elements from the array before constructing the return value, to
+ // simulate the behavior of web browsers (which do not send unchecked
+ // checkboxes to the server at all). This will not affect non-programmatic
+ // form submissions, since all values in $_POST are strings.
+ foreach ($input as $key => $value) {
+ if (!isset($value)) {
+ unset($input[$key]);
+ }
+ }
+ return drupal_map_assoc($input);
+ }
+ else {
+ return array();
+ }
+}
+
+/**
+ * Helper function to determine the value for a tableselect form element.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_tableselect_value($element, $input = FALSE) {
+ // If $element['#multiple'] == FALSE, then radio buttons are displayed and
+ // the default value handling is used.
+ if (isset($element['#multiple']) && $element['#multiple']) {
+ // Checkboxes are being displayed with the default value coming from the
+ // keys of the #default_value property. This differs from the checkboxes
+ // element which uses the array values.
+ if ($input === FALSE) {
+ $value = array();
+ $element += array('#default_value' => array());
+ foreach ($element['#default_value'] as $key => $flag) {
+ if ($flag) {
+ $value[$key] = $key;
+ }
+ }
+ return $value;
+ }
+ else {
+ return is_array($input) ? drupal_map_assoc($input) : array();
+ }
+ }
+}
+
+/**
+ * Helper function to determine the value for a password_confirm form
+ * element.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_password_confirm_value($element, $input = FALSE) {
+ if ($input === FALSE) {
+ $element += array('#default_value' => array());
+ return $element['#default_value'] + array('pass1' => '', 'pass2' => '');
+ }
+}
+
+/**
+ * Helper function to determine the value for a select form element.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_select_value($element, $input = FALSE) {
+ if ($input !== FALSE) {
+ if (isset($element['#multiple']) && $element['#multiple']) {
+ // If an enabled multi-select submits NULL, it means all items are
+ // unselected. A disabled multi-select always submits NULL, and the
+ // default value should be used.
+ if (empty($element['#disabled'])) {
+ return (is_array($input)) ? drupal_map_assoc($input) : array();
+ }
+ else {
+ return (isset($element['#default_value']) && is_array($element['#default_value'])) ? $element['#default_value'] : array();
+ }
+ }
+ // Non-multiple select elements may have an empty option preprended to them
+ // (see form_process_select()). When this occurs, usually #empty_value is
+ // an empty string, but some forms set #empty_value to integer 0 or some
+ // other non-string constant. PHP receives all submitted form input as
+ // strings, but if the empty option is selected, set the value to match the
+ // empty value exactly.
+ elseif (isset($element['#empty_value']) && $input === (string) $element['#empty_value']) {
+ return $element['#empty_value'];
+ }
+ else {
+ return $input;
+ }
+ }
+}
+
+/**
+ * Helper function to determine the value for a textfield form element.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_textfield_value($element, $input = FALSE) {
+ if ($input !== FALSE && $input !== NULL) {
+ // Equate $input to the form value to ensure it's marked for
+ // validation.
+ return str_replace(array("\r", "\n"), '', $input);
+ }
+}
+
+/**
+ * Helper function to determine the value for form's token value.
+ *
+ * @param $element
+ * The form element whose value is being populated.
+ * @param $input
+ * The incoming input to populate the form element. If this is FALSE,
+ * the element's default value should be returned.
+ * @return
+ * The data that will appear in the $element_state['values'] collection
+ * for this element. Return nothing to use the default.
+ */
+function form_type_token_value($element, $input = FALSE) {
+ if ($input !== FALSE) {
+ return (string) $input;
+ }
+}
+
+/**
+ * Change submitted form values during form validation.
+ *
+ * Use this function to change the submitted value of a form element in a form
+ * validation function, so that the changed value persists in $form_state
+ * through to the submission handlers.
+ *
+ * Note that form validation functions are specified in the '#validate'
+ * component of the form array (the value of $form['#validate'] is an array of
+ * validation function names). If the form does not originate in your module,
+ * you can implement hook_form_FORM_ID_alter() to add a validation function
+ * to $form['#validate'].
+ *
+ * @param $element
+ * The form element that should have its value updated; in most cases you can
+ * just pass in the element from the $form array, although the only component
+ * that is actually used is '#parents'. If constructing yourself, set
+ * $element['#parents'] to be an array giving the path through the form
+ * array's keys to the element whose value you want to update. For instance,
+ * if you want to update the value of $form['elem1']['elem2'], which should be
+ * stored in $form_state['values']['elem1']['elem2'], you would set
+ * $element['#parents'] = array('elem1','elem2').
+ * @param $value
+ * The new value for the form element.
+ * @param $form_state
+ * Form state array where the value change should be recorded.
+ */
+function form_set_value($element, $value, &$form_state) {
+ drupal_array_set_nested_value($form_state['values'], $element['#parents'], $value, TRUE);
+}
+
+/**
+ * Allows PHP array processing of multiple select options with the same value.
+ *
+ * Used for form select elements which need to validate HTML option groups
+ * and multiple options which may return the same value. Associative PHP arrays
+ * cannot handle these structures, since they share a common key.
+ *
+ * @param $array
+ * The form options array to process.
+ *
+ * @return
+ * An array with all hierarchical elements flattened to a single array.
+ */
+function form_options_flatten($array) {
+ // Always reset static var when first entering the recursion.
+ drupal_static_reset('_form_options_flatten');
+ return _form_options_flatten($array);
+}
+
+/**
+ * Helper function for form_options_flatten().
+ *
+ * Iterates over arrays which may share common values and produces a flat
+ * array that has removed duplicate keys. Also handles cases where objects
+ * are passed as array values.
+ */
+function _form_options_flatten($array) {
+ $return = &drupal_static(__FUNCTION__);
+
+ foreach ($array as $key => $value) {
+ if (is_object($value)) {
+ _form_options_flatten($value->option);
+ }
+ elseif (is_array($value)) {
+ _form_options_flatten($value);
+ }
+ else {
+ $return[$key] = 1;
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * Processes a select list form element.
+ *
+ * This process callback is mandatory for select fields, since all user agents
+ * automatically preselect the first available option of single (non-multiple)
+ * select lists.
+ *
+ * @param $element
+ * The form element to process. Properties used:
+ * - #multiple: (optional) Indicates whether one or more options can be
+ * selected. Defaults to FALSE.
+ * - #default_value: Must be NULL or not set in case there is no value for the
+ * element yet, in which case a first default option is inserted by default.
+ * Whether this first option is a valid option depends on whether the field
+ * is #required or not.
+ * - #required: (optional) Whether the user needs to select an option (TRUE)
+ * or not (FALSE). Defaults to FALSE.
+ * - #empty_option: (optional) The label to show for the first default option.
+ * By default, the label is automatically set to "- Please select -" for a
+ * required field and "- None -" for an optional field.
+ * - #empty_value: (optional) The value for the first default option, which is
+ * used to determine whether the user submitted a value or not.
+ * - If #required is TRUE, this defaults to '' (an empty string).
+ * - If #required is not TRUE and this value isn't set, then no extra option
+ * is added to the select control, leaving the control in a slightly
+ * illogical state, because there's no way for the user to select nothing,
+ * since all user agents automatically preselect the first available
+ * option. But people are used to this being the behavior of select
+ * controls.
+ * @todo Address the above issue in Drupal 8.
+ * - If #required is not TRUE and this value is set (most commonly to an
+ * empty string), then an extra option (see #empty_option above)
+ * representing a "non-selection" is added with this as its value.
+ *
+ * @see _form_validate()
+ */
+function form_process_select($element) {
+ // #multiple select fields need a special #name.
+ if ($element['#multiple']) {
+ $element['#attributes']['multiple'] = 'multiple';
+ $element['#attributes']['name'] = $element['#name'] . '[]';
+ }
+ // A non-#multiple select needs special handling to prevent user agents from
+ // preselecting the first option without intention. #multiple select lists do
+ // not get an empty option, as it would not make sense, user interface-wise.
+ else {
+ $required = $element['#required'];
+ // If the element is required and there is no #default_value, then add an
+ // empty option that will fail validation, so that the user is required to
+ // make a choice. Also, if there's a value for #empty_value or
+ // #empty_option, then add an option that represents emptiness.
+ if (($required && !isset($element['#default_value'])) || isset($element['#empty_value']) || isset($element['#empty_option'])) {
+ $element += array(
+ '#empty_value' => '',
+ '#empty_option' => $required ? t('- Select -') : t('- None -'),
+ );
+ // The empty option is prepended to #options and purposively not merged
+ // to prevent another option in #options mistakenly using the same value
+ // as #empty_value.
+ $empty_option = array($element['#empty_value'] => $element['#empty_option']);
+ $element['#options'] = $empty_option + $element['#options'];
+ }
+ }
+ return $element;
+}
+
+/**
+ * Returns HTML for a select form element.
+ *
+ * It is possible to group options together; to do this, change the format of
+ * $options to an associative array in which the keys are group labels, and the
+ * values are associative arrays in the normal $options format.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #options, #description, #extra,
+ * #multiple, #required, #name, #attributes, #size.
+ *
+ * @ingroup themeable
+ */
+function theme_select($variables) {
+ $element = $variables['element'];
+ element_set_attributes($element, array('id', 'name', 'size'));
+ _form_set_class($element, array('form-select'));
+
+ return '<select' . drupal_attributes($element['#attributes']) . '>' . form_select_options($element) . '</select>';
+}
+
+/**
+ * Converts a select form element's options array into an HTML.
+ *
+ * @param $element
+ * An associative array containing the properties of the element.
+ * @param $choices
+ * Mixed: Either an associative array of items to list as choices, or an
+ * object with an 'option' member that is an associative array. This
+ * parameter is only used internally and should not be passed.
+ * @return
+ * An HTML string of options for the select form element.
+ */
+function form_select_options($element, $choices = NULL) {
+ if (!isset($choices)) {
+ $choices = $element['#options'];
+ }
+ // array_key_exists() accommodates the rare event where $element['#value'] is NULL.
+ // isset() fails in this situation.
+ $value_valid = isset($element['#value']) || array_key_exists('#value', $element);
+ $value_is_array = $value_valid && is_array($element['#value']);
+ $options = '';
+ foreach ($choices as $key => $choice) {
+ if (is_array($choice)) {
+ $options .= '<optgroup label="' . $key . '">';
+ $options .= form_select_options($element, $choice);
+ $options .= '</optgroup>';
+ }
+ elseif (is_object($choice)) {
+ $options .= form_select_options($element, $choice->option);
+ }
+ else {
+ $key = (string) $key;
+ if ($value_valid && (!$value_is_array && (string) $element['#value'] === $key || ($value_is_array && in_array($key, $element['#value'])))) {
+ $selected = ' selected="selected"';
+ }
+ else {
+ $selected = '';
+ }
+ $options .= '<option value="' . check_plain($key) . '"' . $selected . '>' . check_plain($choice) . '</option>';
+ }
+ }
+ return $options;
+}
+
+/**
+ * Traverses a select element's #option array looking for any values
+ * that hold the given key. Returns an array of indexes that match.
+ *
+ * This function is useful if you need to modify the options that are
+ * already in a form element; for example, to remove choices which are
+ * not valid because of additional filters imposed by another module.
+ * One example might be altering the choices in a taxonomy selector.
+ * To correctly handle the case of a multiple hierarchy taxonomy,
+ * #options arrays can now hold an array of objects, instead of a
+ * direct mapping of keys to labels, so that multiple choices in the
+ * selector can have the same key (and label). This makes it difficult
+ * to manipulate directly, which is why this helper function exists.
+ *
+ * This function does not support optgroups (when the elements of the
+ * #options array are themselves arrays), and will return FALSE if
+ * arrays are found. The caller must either flatten/restore or
+ * manually do their manipulations in this case, since returning the
+ * index is not sufficient, and supporting this would make the
+ * "helper" too complicated and cumbersome to be of any help.
+ *
+ * As usual with functions that can return array() or FALSE, do not
+ * forget to use === and !== if needed.
+ *
+ * @param $element
+ * The select element to search.
+ * @param $key
+ * The key to look for.
+ * @return
+ * An array of indexes that match the given $key. Array will be
+ * empty if no elements were found. FALSE if optgroups were found.
+ */
+function form_get_options($element, $key) {
+ $keys = array();
+ foreach ($element['#options'] as $index => $choice) {
+ if (is_array($choice)) {
+ return FALSE;
+ }
+ elseif (is_object($choice)) {
+ if (isset($choice->option[$key])) {
+ $keys[] = $index;
+ }
+ }
+ elseif ($index == $key) {
+ $keys[] = $index;
+ }
+ }
+ return $keys;
+}
+
+/**
+ * Returns HTML for a fieldset form element and its children.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #attributes, #children, #collapsed, #collapsible,
+ * #description, #id, #title, #value.
+ *
+ * @ingroup themeable
+ */
+function theme_fieldset($variables) {
+ $element = $variables['element'];
+ element_set_attributes($element, array('id'));
+ _form_set_class($element, array('form-wrapper'));
+
+ $output = '<fieldset' . drupal_attributes($element['#attributes']) . '>';
+ if (!empty($element['#title'])) {
+ // Always wrap fieldset legends in a SPAN for CSS positioning.
+ $output .= '<legend><span class="fieldset-legend">' . $element['#title'] . '</span></legend>';
+ }
+ $output .= '<div class="fieldset-wrapper">';
+ if (!empty($element['#description'])) {
+ $output .= '<div class="fieldset-description">' . $element['#description'] . '</div>';
+ }
+ $output .= $element['#children'];
+ if (isset($element['#value'])) {
+ $output .= $element['#value'];
+ }
+ $output .= '</div>';
+ $output .= "</fieldset>\n";
+ return $output;
+}
+
+/**
+ * Returns HTML for a radio button form element.
+ *
+ * Note: The input "name" attribute needs to be sanitized before output, which
+ * is currently done by passing all attributes to drupal_attributes().
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #required, #return_value, #value, #attributes, #title,
+ * #description
+ *
+ * @ingroup themeable
+ */
+function theme_radio($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'radio';
+ element_set_attributes($element, array('id', 'name', '#return_value' => 'value'));
+
+ if (isset($element['#return_value']) && $element['#value'] !== FALSE && $element['#value'] == $element['#return_value']) {
+ $element['#attributes']['checked'] = 'checked';
+ }
+ _form_set_class($element, array('form-radio'));
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Returns HTML for a set of radio button form elements.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #options, #description, #required,
+ * #attributes, #children.
+ *
+ * @ingroup themeable
+ */
+function theme_radios($variables) {
+ $element = $variables['element'];
+ $attributes = array();
+ if (isset($element['#id'])) {
+ $attributes['id'] = $element['#id'];
+ }
+ $attributes['class'] = 'form-radios';
+ if (!empty($element['#attributes']['class'])) {
+ $attributes['class'] .= ' ' . implode(' ', $element['#attributes']['class']);
+ }
+ return '<div' . drupal_attributes($attributes) . '>' . (!empty($element['#children']) ? $element['#children'] : '') . '</div>';
+}
+
+/**
+ * Expand a password_confirm field into two text boxes.
+ */
+function form_process_password_confirm($element) {
+ $element['pass1'] = array(
+ '#type' => 'password',
+ '#title' => t('Password'),
+ '#value' => empty($element['#value']) ? NULL : $element['#value']['pass1'],
+ '#required' => $element['#required'],
+ '#attributes' => array('class' => array('password-field')),
+ );
+ $element['pass2'] = array(
+ '#type' => 'password',
+ '#title' => t('Confirm password'),
+ '#value' => empty($element['#value']) ? NULL : $element['#value']['pass2'],
+ '#required' => $element['#required'],
+ '#attributes' => array('class' => array('password-confirm')),
+ );
+ $element['#element_validate'] = array('password_confirm_validate');
+ $element['#tree'] = TRUE;
+
+ if (isset($element['#size'])) {
+ $element['pass1']['#size'] = $element['pass2']['#size'] = $element['#size'];
+ }
+
+ return $element;
+}
+
+/**
+ * Validate password_confirm element.
+ */
+function password_confirm_validate($element, &$element_state) {
+ $pass1 = trim($element['pass1']['#value']);
+ $pass2 = trim($element['pass2']['#value']);
+ if (!empty($pass1) || !empty($pass2)) {
+ if (strcmp($pass1, $pass2)) {
+ form_error($element, t('The specified passwords do not match.'));
+ }
+ }
+ elseif ($element['#required'] && !empty($element_state['input'])) {
+ form_error($element, t('Password field is required.'));
+ }
+
+ // Password field must be converted from a two-element array into a single
+ // string regardless of validation results.
+ form_set_value($element['pass1'], NULL, $element_state);
+ form_set_value($element['pass2'], NULL, $element_state);
+ form_set_value($element, $pass1, $element_state);
+
+ return $element;
+
+}
+
+/**
+ * Returns HTML for a date selection form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #options, #description, #required,
+ * #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_date($variables) {
+ $element = $variables['element'];
+
+ $attributes = array();
+ if (isset($element['#id'])) {
+ $attributes['id'] = $element['#id'];
+ }
+ if (!empty($element['#attributes']['class'])) {
+ $attributes['class'] = (array) $element['#attributes']['class'];
+ }
+ $attributes['class'][] = 'container-inline';
+
+ return '<div' . drupal_attributes($attributes) . '>' . drupal_render_children($element) . '</div>';
+}
+
+/**
+ * Roll out a single date element.
+ */
+function form_process_date($element) {
+ // Default to current date
+ if (empty($element['#value'])) {
+ $element['#value'] = array(
+ 'day' => format_date(REQUEST_TIME, 'custom', 'j'),
+ 'month' => format_date(REQUEST_TIME, 'custom', 'n'),
+ 'year' => format_date(REQUEST_TIME, 'custom', 'Y'),
+ );
+ }
+
+ $element['#tree'] = TRUE;
+
+ // Determine the order of day, month, year in the site's chosen date format.
+ $format = variable_get('date_format_short', 'm/d/Y - H:i');
+ $sort = array();
+ $sort['day'] = max(strpos($format, 'd'), strpos($format, 'j'));
+ $sort['month'] = max(strpos($format, 'm'), strpos($format, 'M'));
+ $sort['year'] = strpos($format, 'Y');
+ asort($sort);
+ $order = array_keys($sort);
+
+ // Output multi-selector for date.
+ foreach ($order as $type) {
+ switch ($type) {
+ case 'day':
+ $options = drupal_map_assoc(range(1, 31));
+ $title = t('Day');
+ break;
+
+ case 'month':
+ $options = drupal_map_assoc(range(1, 12), 'map_month');
+ $title = t('Month');
+ break;
+
+ case 'year':
+ $options = drupal_map_assoc(range(1900, 2050));
+ $title = t('Year');
+ break;
+ }
+
+ $element[$type] = array(
+ '#type' => 'select',
+ '#title' => $title,
+ '#title_display' => 'invisible',
+ '#value' => $element['#value'][$type],
+ '#attributes' => $element['#attributes'],
+ '#options' => $options,
+ );
+ }
+
+ return $element;
+}
+
+/**
+ * Validates the date type to stop dates like February 30, 2006.
+ */
+function date_validate($element) {
+ if (!checkdate($element['#value']['month'], $element['#value']['day'], $element['#value']['year'])) {
+ form_error($element, t('The specified date is invalid.'));
+ }
+}
+
+/**
+ * Helper function for usage with drupal_map_assoc to display month names.
+ */
+function map_month($month) {
+ $months = &drupal_static(__FUNCTION__, array(
+ 1 => 'Jan',
+ 2 => 'Feb',
+ 3 => 'Mar',
+ 4 => 'Apr',
+ 5 => 'May',
+ 6 => 'Jun',
+ 7 => 'Jul',
+ 8 => 'Aug',
+ 9 => 'Sep',
+ 10 => 'Oct',
+ 11 => 'Nov',
+ 12 => 'Dec',
+ ));
+ return t($months[$month]);
+}
+
+/**
+ * If no default value is set for weight select boxes, use 0.
+ */
+function weight_value(&$form) {
+ if (isset($form['#default_value'])) {
+ $form['#value'] = $form['#default_value'];
+ }
+ else {
+ $form['#value'] = 0;
+ }
+}
+
+/**
+ * Roll out a single radios element to a list of radios,
+ * using the options array as index.
+ */
+function form_process_radios($element) {
+ if (count($element['#options']) > 0) {
+ $weight = 0;
+ foreach ($element['#options'] as $key => $choice) {
+ // Maintain order of options as defined in #options, in case the element
+ // defines custom option sub-elements, but does not define all option
+ // sub-elements.
+ $weight += 0.001;
+
+ $element += array($key => array());
+ // Generate the parents as the autogenerator does, so we will have a
+ // unique id for each radio button.
+ $parents_for_id = array_merge($element['#parents'], array($key));
+ $element[$key] += array(
+ '#type' => 'radio',
+ '#title' => $choice,
+ // The key is sanitized in drupal_attributes() during output from the
+ // theme function.
+ '#return_value' => $key,
+ '#default_value' => isset($element['#default_value']) ? $element['#default_value'] : NULL,
+ '#attributes' => $element['#attributes'],
+ '#parents' => $element['#parents'],
+ '#id' => drupal_html_id('edit-' . implode('-', $parents_for_id)),
+ '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+ '#weight' => $weight,
+ );
+ }
+ }
+ return $element;
+}
+
+/**
+ * Returns HTML for a checkbox form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #return_value, #description, #required,
+ * #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_checkbox($variables) {
+ $element = $variables['element'];
+ $t = get_t();
+ $element['#attributes']['type'] = 'checkbox';
+ element_set_attributes($element, array('id', 'name', '#return_value' => 'value'));
+
+ // Unchecked checkbox has #value of integer 0.
+ if (!empty($element['#checked'])) {
+ $element['#attributes']['checked'] = 'checked';
+ }
+ _form_set_class($element, array('form-checkbox'));
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Returns HTML for a set of checkbox form elements.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #children, #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_checkboxes($variables) {
+ $element = $variables['element'];
+ $attributes = array();
+ if (isset($element['#id'])) {
+ $attributes['id'] = $element['#id'];
+ }
+ $attributes['class'][] = 'form-checkboxes';
+ if (!empty($element['#attributes']['class'])) {
+ $attributes['class'] = array_merge($attributes['class'], $element['#attributes']['class']);
+ }
+ return '<div' . drupal_attributes($attributes) . '>' . (!empty($element['#children']) ? $element['#children'] : '') . '</div>';
+}
+
+/**
+ * Add form_element theming to an element if title or description is set.
+ *
+ * This is used as a pre render function for checkboxes and radios.
+ */
+function form_pre_render_conditional_form_element($element) {
+ // Set the element's title attribute to show #title as a tooltip, if needed.
+ if (isset($element['#title']) && $element['#title_display'] == 'attribute') {
+ $element['#attributes']['title'] = $element['#title'];
+ if (!empty($element['#required'])) {
+ // Append an indication that this field is required.
+ $element['#attributes']['title'] .= ' (' . $t('Required') . ')';
+ }
+ }
+
+ if (isset($element['#title']) || isset($element['#description'])) {
+ $element['#theme_wrappers'][] = 'form_element';
+ }
+ return $element;
+}
+
+/**
+ * Sets the #checked property of a checkbox element.
+ */
+function form_process_checkbox($element, $form_state) {
+ $value = $element['#value'];
+ $return_value = $element['#return_value'];
+ // On form submission, the #value of an available and enabled checked
+ // checkbox is #return_value, and the #value of an available and enabled
+ // unchecked checkbox is integer 0. On not submitted forms, and for
+ // checkboxes with #access=FALSE or #disabled=TRUE, the #value is
+ // #default_value (integer 0 if #default_value is NULL). Most of the time,
+ // a string comparison of #value and #return_value is sufficient for
+ // determining the "checked" state, but a value of TRUE always means checked
+ // (even if #return_value is 'foo'), and a value of FALSE or integer 0 always
+ // means unchecked (even if #return_value is '' or '0').
+ if ($value === TRUE || $value === FALSE || $value === 0) {
+ $element['#checked'] = (bool) $value;
+ }
+ else {
+ // Compare as strings, so that 15 is not considered equal to '15foo', but 1
+ // is considered equal to '1'. This cast does not imply that either #value
+ // or #return_value is expected to be a string.
+ $element['#checked'] = ((string) $value === (string) $return_value);
+ }
+ return $element;
+}
+
+function form_process_checkboxes($element) {
+ $value = is_array($element['#value']) ? $element['#value'] : array();
+ $element['#tree'] = TRUE;
+ if (count($element['#options']) > 0) {
+ if (!isset($element['#default_value']) || $element['#default_value'] == 0) {
+ $element['#default_value'] = array();
+ }
+ $weight = 0;
+ foreach ($element['#options'] as $key => $choice) {
+ // Integer 0 is not a valid #return_value, so use '0' instead.
+ // @see form_type_checkbox_value().
+ // @todo For Drupal 8, cast all integer keys to strings for consistency
+ // with form_process_radios().
+ if ($key === 0) {
+ $key = '0';
+ }
+ // Maintain order of options as defined in #options, in case the element
+ // defines custom option sub-elements, but does not define all option
+ // sub-elements.
+ $weight += 0.001;
+
+ $element += array($key => array());
+ $element[$key] += array(
+ '#type' => 'checkbox',
+ '#title' => $choice,
+ '#return_value' => $key,
+ '#default_value' => isset($value[$key]) ? $key : NULL,
+ '#attributes' => $element['#attributes'],
+ '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+ '#weight' => $weight,
+ );
+ }
+ }
+ return $element;
+}
+
+/**
+ * Processes a form actions container element.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * form actions container.
+ * @param $form_state
+ * The $form_state array for the form this element belongs to.
+ *
+ * @return
+ * The processed element.
+ */
+function form_process_actions($element, &$form_state) {
+ $element['#attributes']['class'][] = 'form-actions';
+ return $element;
+}
+
+/**
+ * Processes a container element.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * container.
+ * @param $form_state
+ * The $form_state array for the form this element belongs to.
+ * @return
+ * The processed element.
+ */
+function form_process_container($element, &$form_state) {
+ // Generate the ID of the element if it's not explicitly given.
+ if (!isset($element['#id'])) {
+ $element['#id'] = drupal_html_id(implode('-', $element['#parents']) . '-wrapper');
+ }
+ return $element;
+}
+
+/**
+ * Returns HTML to wrap child elements in a container.
+ *
+ * Used for grouped form items. Can also be used as a #theme_wrapper for any
+ * renderable element, to surround it with a <div> and add attributes such as
+ * classes or an HTML id.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #id, #attributes, #children.
+ *
+ * @ingroup themeable
+ */
+function theme_container($variables) {
+ $element = $variables['element'];
+
+ // Special handling for form elements.
+ if (isset($element['#array_parents'])) {
+ // Assign an html ID.
+ if (!isset($element['#attributes']['id'])) {
+ $element['#attributes']['id'] = $element['#id'];
+ }
+ // Add the 'form-wrapper' class.
+ $element['#attributes']['class'][] = 'form-wrapper';
+ }
+
+ return '<div' . drupal_attributes($element['#attributes']) . '>' . $element['#children'] . '</div>';
+}
+
+/**
+ * Returns HTML for a table with radio buttons or checkboxes.
+ *
+ * An example of per-row options:
+ * @code
+ * $options = array();
+ * $options[0]['title'] = "A red row"
+ * $options[0]['#attributes'] = array ('class' => array('red-row'));
+ * $options[1]['title'] = "A blue row"
+ * $options[1]['#attributes'] = array ('class' => array('blue-row'));
+ *
+ * $form['myselector'] = array (
+ * '#type' => 'tableselect',
+ * '#title' => 'My Selector'
+ * '#options' => $options,
+ * );
+ * @endcode
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties and children of
+ * the tableselect element. Properties used: #header, #options, #empty,
+ * and #js_select. The #options property is an array of selection options;
+ * each array element of #options is an array of properties. These
+ * properties can include #attributes, which is added to the
+ * table row's HTML attributes; see theme_table().
+ *
+ * @ingroup themeable
+ */
+function theme_tableselect($variables) {
+ $element = $variables['element'];
+ $rows = array();
+ $header = $element['#header'];
+ if (!empty($element['#options'])) {
+ // Generate a table row for each selectable item in #options.
+ foreach (element_children($element) as $key) {
+ $row = array();
+
+ $row['data'] = array();
+ if (isset($element['#options'][$key]['#attributes'])) {
+ $row += $element['#options'][$key]['#attributes'];
+ }
+ // Render the checkbox / radio element.
+ $row['data'][] = drupal_render($element[$key]);
+
+ // As theme_table only maps header and row columns by order, create the
+ // correct order by iterating over the header fields.
+ foreach ($element['#header'] as $fieldname => $title) {
+ $row['data'][] = $element['#options'][$key][$fieldname];
+ }
+ $rows[] = $row;
+ }
+ // Add an empty header or a "Select all" checkbox to provide room for the
+ // checkboxes/radios in the first table column.
+ if ($element['#js_select']) {
+ // Add a "Select all" checkbox.
+ drupal_add_js('core/misc/tableselect.js');
+ array_unshift($header, array('class' => array('select-all')));
+ }
+ else {
+ // Add an empty header when radio buttons are displayed or a "Select all"
+ // checkbox is not desired.
+ array_unshift($header, '');
+ }
+ }
+ return theme('table', array('header' => $header, 'rows' => $rows, 'empty' => $element['#empty'], 'attributes' => $element['#attributes']));
+}
+
+/**
+ * Create the correct amount of checkbox or radio elements to populate the table.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * tableselect element.
+ * @return
+ * The processed element.
+ */
+function form_process_tableselect($element) {
+
+ if ($element['#multiple']) {
+ $value = is_array($element['#value']) ? $element['#value'] : array();
+ }
+ else {
+ // Advanced selection behaviour make no sense for radios.
+ $element['#js_select'] = FALSE;
+ }
+
+ $element['#tree'] = TRUE;
+
+ if (count($element['#options']) > 0) {
+ if (!isset($element['#default_value']) || $element['#default_value'] === 0) {
+ $element['#default_value'] = array();
+ }
+
+ // Create a checkbox or radio for each item in #options in such a way that
+ // the value of the tableselect element behaves as if it had been of type
+ // checkboxes or radios.
+ foreach ($element['#options'] as $key => $choice) {
+ // Do not overwrite manually created children.
+ if (!isset($element[$key])) {
+ if ($element['#multiple']) {
+ $title = '';
+ if (!empty($element['#options'][$key]['title']['data']['#title'])) {
+ $title = t('Update @title', array(
+ '@title' => $element['#options'][$key]['title']['data']['#title'],
+ ));
+ }
+ $element[$key] = array(
+ '#type' => 'checkbox',
+ '#title' => $title,
+ '#title_display' => 'invisible',
+ '#return_value' => $key,
+ '#default_value' => isset($value[$key]) ? $key : NULL,
+ '#attributes' => $element['#attributes'],
+ );
+ }
+ else {
+ // Generate the parents as the autogenerator does, so we will have a
+ // unique id for each radio button.
+ $parents_for_id = array_merge($element['#parents'], array($key));
+ $element[$key] = array(
+ '#type' => 'radio',
+ '#title' => '',
+ '#return_value' => $key,
+ '#default_value' => ($element['#default_value'] == $key) ? $key : NULL,
+ '#attributes' => $element['#attributes'],
+ '#parents' => $element['#parents'],
+ '#id' => drupal_html_id('edit-' . implode('-', $parents_for_id)),
+ '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+ );
+ }
+ if (isset($element['#options'][$key]['#weight'])) {
+ $element[$key]['#weight'] = $element['#options'][$key]['#weight'];
+ }
+ }
+ }
+ }
+ else {
+ $element['#value'] = array();
+ }
+ return $element;
+}
+
+/**
+ * Processes a machine-readable name form element.
+ *
+ * @param $element
+ * The form element to process. Properties used:
+ * - #machine_name: An associative array containing:
+ * - exists: A function name to invoke for checking whether a submitted
+ * machine name value already exists. The submitted value is passed as
+ * argument. In most cases, an existing API or menu argument loader
+ * function can be re-used. The callback is only invoked, if the submitted
+ * value differs from the element's #default_value.
+ * - source: (optional) The #array_parents of the form element containing
+ * the human-readable name (i.e., as contained in the $form structure) to
+ * use as source for the machine name. Defaults to array('name').
+ * - label: (optional) A text to display as label for the machine name value
+ * after the human-readable name form element. Defaults to "Machine name".
+ * - replace_pattern: (optional) A regular expression (without delimiters)
+ * matching disallowed characters in the machine name. Defaults to
+ * '[^a-z0-9_]+'.
+ * - replace: (optional) A character to replace disallowed characters in the
+ * machine name via JavaScript. Defaults to '_' (underscore). When using a
+ * different character, 'replace_pattern' needs to be set accordingly.
+ * - error: (optional) A custom form error message string to show, if the
+ * machine name contains disallowed characters.
+ * - #maxlength: (optional) Should be set to the maximum allowed length of the
+ * machine name. Defaults to 64.
+ * - #disabled: (optional) Should be set to TRUE in case an existing machine
+ * name must not be changed after initial creation.
+ */
+function form_process_machine_name($element, &$form_state) {
+ // Apply default form element properties.
+ $element += array(
+ '#title' => t('Machine-readable name'),
+ '#description' => t('A unique machine-readable name. Can only contain lowercase letters, numbers, and underscores.'),
+ '#machine_name' => array(),
+ );
+ // A form element that only wants to set one #machine_name property (usually
+ // 'source' only) would leave all other properties undefined, if the defaults
+ // were defined in hook_element_info(). Therefore, we apply the defaults here.
+ $element['#machine_name'] += array(
+ 'source' => array('name'),
+ 'target' => '#' . $element['#id'],
+ 'label' => t('Machine name'),
+ 'replace_pattern' => '[^a-z0-9_]+',
+ 'replace' => '_',
+ );
+
+ // The source element defaults to array('name'), but may have been overidden.
+ if (empty($element['#machine_name']['source'])) {
+ return $element;
+ }
+
+ // Retrieve the form element containing the human-readable name from the
+ // complete form in $form_state. By reference, because we need to append
+ // a #field_suffix that will hold the live preview.
+ $key_exists = NULL;
+ $source = drupal_array_get_nested_value($form_state['complete_form'], $element['#machine_name']['source'], $key_exists);
+ if (!$key_exists) {
+ return $element;
+ }
+
+ // Append a field suffix to the source form element, which will contain
+ // the live preview of the machine name.
+ $suffix_id = $source['#id'] . '-machine-name-suffix';
+ $source += array('#field_suffix' => '');
+ $source['#field_suffix'] .= ' <small id="' . $suffix_id . '">&nbsp;</small>';
+
+ $parents = array_merge($element['#machine_name']['source'], array('#field_suffix'));
+ drupal_array_set_nested_value($form_state['complete_form'], $parents, $source['#field_suffix']);
+
+ $element['#machine_name']['suffix'] = '#' . $suffix_id;
+
+ $js_settings = array(
+ 'type' => 'setting',
+ 'data' => array(
+ 'machineName' => array(
+ '#' . $source['#id'] => $element['#machine_name'],
+ ),
+ ),
+ );
+ $element['#attached']['js'][] = 'core/misc/machine-name.js';
+ $element['#attached']['js'][] = $js_settings;
+
+ return $element;
+}
+
+/**
+ * Form element validation handler for #type 'machine_name'.
+ *
+ * Note that #maxlength is validated by _form_validate() already.
+ */
+function form_validate_machine_name(&$element, &$form_state) {
+ // Verify that the machine name not only consists of replacement tokens.
+ if (preg_match('@^' . $element['#machine_name']['replace'] . '+$@', $element['#value'])) {
+ form_error($element, t('The machine-readable name must contain unique characters.'));
+ }
+
+ // Verify that the machine name contains no disallowed characters.
+ if (preg_match('@' . $element['#machine_name']['replace_pattern'] . '@', $element['#value'])) {
+ if (!isset($element['#machine_name']['error'])) {
+ // Since a hyphen is the most common alternative replacement character,
+ // a corresponding validation error message is supported here.
+ if ($element['#machine_name']['replace'] == '-') {
+ form_error($element, t('The machine-readable name must contain only lowercase letters, numbers, and hyphens.'));
+ }
+ // Otherwise, we assume the default (underscore).
+ else {
+ form_error($element, t('The machine-readable name must contain only lowercase letters, numbers, and underscores.'));
+ }
+ }
+ else {
+ form_error($element, $element['#machine_name']['error']);
+ }
+ }
+
+ // Verify that the machine name is unique.
+ if ($element['#default_value'] !== $element['#value']) {
+ $function = $element['#machine_name']['exists'];
+ if ($function($element['#value'], $element, $form_state)) {
+ form_error($element, t('The machine-readable name is already in use. It must be unique.'));
+ }
+ }
+}
+
+/**
+ * Adds fieldsets to the specified group or adds group members to this
+ * fieldset.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * fieldset. Note that $element must be taken by reference here, so processed
+ * child elements are taken over into $form_state.
+ * @param $form_state
+ * The $form_state array for the form this fieldset belongs to.
+ * @return
+ * The processed element.
+ */
+function form_process_fieldset(&$element, &$form_state) {
+ $parents = implode('][', $element['#parents']);
+
+ // Each fieldset forms a new group. The #type 'vertical_tabs' basically only
+ // injects a new fieldset.
+ $form_state['groups'][$parents]['#group_exists'] = TRUE;
+ $element['#groups'] = &$form_state['groups'];
+
+ // Process vertical tabs group member fieldsets.
+ if (isset($element['#group'])) {
+ // Add this fieldset to the defined group (by reference).
+ $group = $element['#group'];
+ $form_state['groups'][$group][] = &$element;
+ }
+
+ // Contains form element summary functionalities.
+ $element['#attached']['library'][] = array('system', 'drupal.form');
+
+ // The .form-wrapper class is required for #states to treat fieldsets like
+ // containers.
+ if (!isset($element['#attributes']['class'])) {
+ $element['#attributes']['class'] = array();
+ }
+
+ // Collapsible fieldsets
+ if (!empty($element['#collapsible'])) {
+ $element['#attached']['library'][] = array('system', 'drupal.collapse');
+ $element['#attributes']['class'][] = 'collapsible';
+ if (!empty($element['#collapsed'])) {
+ $element['#attributes']['class'][] = 'collapsed';
+ }
+ }
+
+ return $element;
+}
+
+/**
+ * Adds members of this group as actual elements for rendering.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * fieldset.
+ *
+ * @return
+ * The modified element with all group members.
+ */
+function form_pre_render_fieldset($element) {
+ // Fieldsets may be rendered outside of a Form API context.
+ if (!isset($element['#parents']) || !isset($element['#groups'])) {
+ return $element;
+ }
+ // Inject group member elements belonging to this group.
+ $parents = implode('][', $element['#parents']);
+ $children = element_children($element['#groups'][$parents]);
+ if (!empty($children)) {
+ foreach ($children as $key) {
+ // Break references and indicate that the element should be rendered as
+ // group member.
+ $child = (array) $element['#groups'][$parents][$key];
+ $child['#group_fieldset'] = TRUE;
+ // Inject the element as new child element.
+ $element[] = $child;
+
+ $sort = TRUE;
+ }
+ // Re-sort the element's children if we injected group member elements.
+ if (isset($sort)) {
+ $element['#sorted'] = FALSE;
+ }
+ }
+
+ if (isset($element['#group'])) {
+ $group = $element['#group'];
+ // If this element belongs to a group, but the group-holding element does
+ // not exist, we need to render it (at its original location).
+ if (!isset($element['#groups'][$group]['#group_exists'])) {
+ // Intentionally empty to clarify the flow; we simply return $element.
+ }
+ // If we injected this element into the group, then we want to render it.
+ elseif (!empty($element['#group_fieldset'])) {
+ // Intentionally empty to clarify the flow; we simply return $element.
+ }
+ // Otherwise, this element belongs to a group and the group exists, so we do
+ // not render it.
+ elseif (element_children($element['#groups'][$group])) {
+ $element['#printed'] = TRUE;
+ }
+ }
+
+ return $element;
+}
+
+/**
+ * Creates a group formatted as vertical tabs.
+ *
+ * @param $element
+ * An associative array containing the properties and children of the
+ * fieldset.
+ * @param $form_state
+ * The $form_state array for the form this vertical tab widget belongs to.
+ * @return
+ * The processed element.
+ */
+function form_process_vertical_tabs($element, &$form_state) {
+ // Inject a new fieldset as child, so that form_process_fieldset() processes
+ // this fieldset like any other fieldset.
+ $element['group'] = array(
+ '#type' => 'fieldset',
+ '#theme_wrappers' => array(),
+ '#parents' => $element['#parents'],
+ );
+
+ // The JavaScript stores the currently selected tab in this hidden
+ // field so that the active tab can be restored the next time the
+ // form is rendered, e.g. on preview pages or when form validation
+ // fails.
+ $name = implode('__', $element['#parents']);
+ if (isset($form_state['values'][$name . '__active_tab'])) {
+ $element['#default_tab'] = $form_state['values'][$name . '__active_tab'];
+ }
+ $element[$name . '__active_tab'] = array(
+ '#type' => 'hidden',
+ '#default_value' => $element['#default_tab'],
+ '#attributes' => array('class' => array('vertical-tabs-active-tab')),
+ );
+
+ return $element;
+}
+
+/**
+ * Returns HTML for an element's children fieldsets as vertical tabs.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties and children of the
+ * fieldset. Properties used: #children.
+ *
+ * @ingroup themeable
+ */
+function theme_vertical_tabs($variables) {
+ $element = $variables['element'];
+ // Add required JavaScript and Stylesheet.
+ drupal_add_library('system', 'drupal.vertical-tabs');
+
+ $output = '<h2 class="element-invisible">' . t('Vertical Tabs') . '</h2>';
+ $output .= '<div class="vertical-tabs-panes">' . $element['#children'] . '</div>';
+ return $output;
+}
+
+/**
+ * Returns HTML for a submit button form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #attributes, #button_type, #name, #value.
+ *
+ * @ingroup themeable
+ */
+function theme_submit($variables) {
+ return theme('button', $variables['element']);
+}
+
+/**
+ * Returns HTML for a button form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #attributes, #button_type, #name, #value.
+ *
+ * @ingroup themeable
+ */
+function theme_button($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'submit';
+ element_set_attributes($element, array('id', 'name', 'value'));
+
+ $element['#attributes']['class'][] = 'form-' . $element['#button_type'];
+ if (!empty($element['#attributes']['disabled'])) {
+ $element['#attributes']['class'][] = 'form-button-disabled';
+ }
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Returns HTML for an image button form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #attributes, #button_type, #name, #value, #title, #src.
+ *
+ * @ingroup themeable
+ */
+function theme_image_button($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'image';
+ element_set_attributes($element, array('id', 'name', 'value'));
+
+ $element['#attributes']['src'] = file_create_url($element['#src']);
+ if (!empty($element['#title'])) {
+ $element['#attributes']['alt'] = $element['#title'];
+ $element['#attributes']['title'] = $element['#title'];
+ }
+
+ $element['#attributes']['class'][] = 'form-' . $element['#button_type'];
+ if (!empty($element['#attributes']['disabled'])) {
+ $element['#attributes']['class'][] = 'form-button-disabled';
+ }
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Returns HTML for a hidden form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #name, #value, #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_hidden($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'hidden';
+ element_set_attributes($element, array('name', 'value'));
+ return '<input' . drupal_attributes($element['#attributes']) . " />\n";
+}
+
+/**
+ * Returns HTML for a textfield form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #description, #size, #maxlength,
+ * #placeholder, #required, #attributes, #autocomplete_path.
+ *
+ * @ingroup themeable
+ */
+function theme_textfield($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'text';
+ element_set_attributes($element, array('id', 'name', 'value', 'size', 'maxlength', 'placeholder'));
+ _form_set_class($element, array('form-text'));
+
+ $extra = '';
+ if ($element['#autocomplete_path'] && drupal_valid_path($element['#autocomplete_path'])) {
+ drupal_add_library('system', 'drupal.autocomplete');
+ $element['#attributes']['class'][] = 'form-autocomplete';
+
+ $attributes = array();
+ $attributes['type'] = 'hidden';
+ $attributes['id'] = $element['#attributes']['id'] . '-autocomplete';
+ $attributes['value'] = url($element['#autocomplete_path'], array('absolute' => TRUE));
+ $attributes['disabled'] = 'disabled';
+ $attributes['class'][] = 'autocomplete';
+ $extra = '<input' . drupal_attributes($attributes) . ' />';
+ }
+
+ $output = '<input' . drupal_attributes($element['#attributes']) . ' />';
+
+ return $output . $extra;
+}
+
+/**
+ * Returns HTML for a form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #action, #method, #attributes, #children
+ *
+ * @ingroup themeable
+ */
+function theme_form($variables) {
+ $element = $variables['element'];
+ if (isset($element['#action'])) {
+ $element['#attributes']['action'] = drupal_strip_dangerous_protocols($element['#action']);
+ }
+ element_set_attributes($element, array('method', 'id'));
+ if (empty($element['#attributes']['accept-charset'])) {
+ $element['#attributes']['accept-charset'] = "UTF-8";
+ }
+ // Anonymous DIV to satisfy XHTML compliance.
+ return '<form' . drupal_attributes($element['#attributes']) . '><div>' . $element['#children'] . '</div></form>';
+}
+
+/**
+ * Returns HTML for a textarea form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #description, #rows, #cols,
+ * #placeholder, #required, #attributes
+ *
+ * @ingroup themeable
+ */
+function theme_textarea($variables) {
+ $element = $variables['element'];
+ element_set_attributes($element, array('id', 'name', 'rows', 'cols', 'placeholder'));
+ _form_set_class($element, array('form-textarea'));
+
+ $wrapper_attributes = array(
+ 'class' => array('form-textarea-wrapper'),
+ );
+
+ // Add resizable behavior.
+ if (!empty($element['#resizable'])) {
+ drupal_add_library('system', 'drupal.textarea');
+ $wrapper_attributes['class'][] = 'resizable';
+ }
+
+ $output = '<div' . drupal_attributes($wrapper_attributes) . '>';
+ $output .= '<textarea' . drupal_attributes($element['#attributes']) . '>' . check_plain($element['#value']) . '</textarea>';
+ $output .= '</div>';
+ return $output;
+}
+
+/**
+ * Returns HTML for a password form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #value, #description, #size, #maxlength,
+ * #placeholder, #required, #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_password($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'password';
+ element_set_attributes($element, array('id', 'name', 'size', 'maxlength', 'placeholder'));
+ _form_set_class($element, array('form-text'));
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Expand weight elements into selects.
+ */
+function form_process_weight($element) {
+ for ($n = (-1 * $element['#delta']); $n <= $element['#delta']; $n++) {
+ $weights[$n] = $n;
+ }
+ $element['#options'] = $weights;
+ $element['#type'] = 'select';
+ $element['#is_weight'] = TRUE;
+ $element += element_info('select');
+ return $element;
+}
+
+/**
+ * Returns HTML for a file upload form element.
+ *
+ * For assistance with handling the uploaded file correctly, see the API
+ * provided by file.inc.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #name, #size, #description, #required,
+ * #attributes.
+ *
+ * @ingroup themeable
+ */
+function theme_file($variables) {
+ $element = $variables['element'];
+ $element['#attributes']['type'] = 'file';
+ element_set_attributes($element, array('id', 'name', 'size'));
+ _form_set_class($element, array('form-file'));
+
+ return '<input' . drupal_attributes($element['#attributes']) . ' />';
+}
+
+/**
+ * Returns HTML for a form element.
+ *
+ * Each form element is wrapped in a DIV container having the following CSS
+ * classes:
+ * - form-item: Generic for all form elements.
+ * - form-type-#type: The internal element #type.
+ * - form-item-#name: The internal form element #name (usually derived from the
+ * $form structure and set via form_builder()).
+ * - form-disabled: Only set if the form element is #disabled.
+ *
+ * In addition to the element itself, the DIV contains a label for the element
+ * based on the optional #title_display property, and an optional #description.
+ *
+ * The optional #title_display property can have these values:
+ * - before: The label is output before the element. This is the default.
+ * The label includes the #title and the required marker, if #required.
+ * - after: The label is output after the element. For example, this is used
+ * for radio and checkbox #type elements as set in system_element_info().
+ * If the #title is empty but the field is #required, the label will
+ * contain only the required marker.
+ * - invisible: Labels are critical for screen readers to enable them to
+ * properly navigate through forms but can be visually distracting. This
+ * property hides the label for everyone except screen readers.
+ * - attribute: Set the title attribute on the element to create a tooltip
+ * but output no label element. This is supported only for checkboxes
+ * and radios in form_pre_render_conditional_form_element(). It is used
+ * where a visual label is not needed, such as a table of checkboxes where
+ * the row and column provide the context. The tooltip will include the
+ * title and required marker.
+ *
+ * If the #title property is not set, then the label and any required marker
+ * will not be output, regardless of the #title_display or #required values.
+ * This can be useful in cases such as the password_confirm element, which
+ * creates children elements that have their own labels and required markers,
+ * but the parent element should have neither. Use this carefully because a
+ * field without an associated label can cause accessibility challenges.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #title, #title_display, #description, #id, #required,
+ * #children, #type, #name.
+ *
+ * @ingroup themeable
+ */
+function theme_form_element($variables) {
+ $element = &$variables['element'];
+ // This is also used in the installer, pre-database setup.
+ $t = get_t();
+
+ // This function is invoked as theme wrapper, but the rendered form element
+ // may not necessarily have been processed by form_builder().
+ $element += array(
+ '#title_display' => 'before',
+ );
+
+ // Add element #id for #type 'item'.
+ if (isset($element['#markup']) && !empty($element['#id'])) {
+ $attributes['id'] = $element['#id'];
+ }
+ // Add element's #type and #name as class to aid with JS/CSS selectors.
+ $attributes['class'] = array('form-item');
+ if (!empty($element['#type'])) {
+ $attributes['class'][] = 'form-type-' . strtr($element['#type'], '_', '-');
+ }
+ if (!empty($element['#name'])) {
+ $attributes['class'][] = 'form-item-' . strtr($element['#name'], array(' ' => '-', '_' => '-', '[' => '-', ']' => ''));
+ }
+ // Add a class for disabled elements to facilitate cross-browser styling.
+ if (!empty($element['#attributes']['disabled'])) {
+ $attributes['class'][] = 'form-disabled';
+ }
+ $output = '<div' . drupal_attributes($attributes) . '>' . "\n";
+
+ // If #title is not set, we don't display any label or required marker.
+ if (!isset($element['#title'])) {
+ $element['#title_display'] = 'none';
+ }
+ $prefix = isset($element['#field_prefix']) ? '<span class="field-prefix">' . $element['#field_prefix'] . '</span> ' : '';
+ $suffix = isset($element['#field_suffix']) ? ' <span class="field-suffix">' . $element['#field_suffix'] . '</span>' : '';
+
+ switch ($element['#title_display']) {
+ case 'before':
+ case 'invisible':
+ $output .= ' ' . theme('form_element_label', $variables);
+ $output .= ' ' . $prefix . $element['#children'] . $suffix . "\n";
+ break;
+
+ case 'after':
+ $output .= ' ' . $prefix . $element['#children'] . $suffix;
+ $output .= ' ' . theme('form_element_label', $variables) . "\n";
+ break;
+
+ case 'none':
+ case 'attribute':
+ // Output no label and no required marker, only the children.
+ $output .= ' ' . $prefix . $element['#children'] . $suffix . "\n";
+ break;
+ }
+
+ if (!empty($element['#description'])) {
+ $output .= '<div class="description">' . $element['#description'] . "</div>\n";
+ }
+
+ $output .= "</div>\n";
+
+ return $output;
+}
+
+/**
+ * Returns HTML for a marker for required form elements.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ *
+ * @ingroup themeable
+ */
+function theme_form_required_marker($variables) {
+ // This is also used in the installer, pre-database setup.
+ $t = get_t();
+ $attributes = array(
+ 'class' => 'form-required',
+ 'title' => $t('This field is required.'),
+ );
+ return '<abbr' . drupal_attributes($attributes) . '>*</abbr>';
+}
+
+/**
+ * Returns HTML for a form element label and required marker.
+ *
+ * Form element labels include the #title and a #required marker. The label is
+ * associated with the element itself by the element #id. Labels may appear
+ * before or after elements, depending on theme_form_element() and #title_display.
+ *
+ * This function will not be called for elements with no labels, depending on
+ * #title_display. For elements that have an empty #title and are not required,
+ * this function will output no label (''). For required elements that have an
+ * empty #title, this will output the required marker alone within the label.
+ * The label will use the #id to associate the marker with the field that is
+ * required. That is especially important for screenreader users to know
+ * which field is required.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #required, #title, #id, #value, #description.
+ *
+ * @ingroup themeable
+ */
+function theme_form_element_label($variables) {
+ $element = $variables['element'];
+ // This is also used in the installer, pre-database setup.
+ $t = get_t();
+
+ // If title and required marker are both empty, output no label.
+ if (empty($element['#title']) && empty($element['#required'])) {
+ return '';
+ }
+
+ // If the element is required, a required marker is appended to the label.
+ $required = !empty($element['#required']) ? theme('form_required_marker', array('element' => $element)) : '';
+
+ $title = filter_xss_admin($element['#title']);
+
+ $attributes = array();
+ // Style the label as class option to display inline with the element.
+ if ($element['#title_display'] == 'after') {
+ $attributes['class'] = 'option';
+ }
+ // Show label only to screen readers to avoid disruption in visual flows.
+ elseif ($element['#title_display'] == 'invisible') {
+ $attributes['class'] = 'element-invisible';
+ }
+
+ if (!empty($element['#id'])) {
+ $attributes['for'] = $element['#id'];
+ }
+
+ // The leading whitespace helps visually separate fields from inline labels.
+ return ' <label' . drupal_attributes($attributes) . '>' . $t('!title !required', array('!title' => $title, '!required' => $required)) . "</label>\n";
+}
+
+/**
+ * Sets a form element's class attribute.
+ *
+ * Adds 'required' and 'error' classes as needed.
+ *
+ * @param $element
+ * The form element.
+ * @param $name
+ * Array of new class names to be added.
+ */
+function _form_set_class(&$element, $class = array()) {
+ if (!empty($class)) {
+ if (!isset($element['#attributes']['class'])) {
+ $element['#attributes']['class'] = array();
+ }
+ $element['#attributes']['class'] = array_merge($element['#attributes']['class'], $class);
+ }
+ // This function is invoked from form element theme functions, but the
+ // rendered form element may not necessarily have been processed by
+ // form_builder().
+ if (!empty($element['#required'])) {
+ $element['#attributes']['class'][] = 'required';
+ }
+ if (isset($element['#parents']) && form_get_error($element)) {
+ $element['#attributes']['class'][] = 'error';
+ }
+}
+
+/**
+ * Helper form element validator: integer.
+ */
+function element_validate_integer($element, &$form_state) {
+ $value = $element['#value'];
+ if ($value !== '' && (!is_numeric($value) || intval($value) != $value)) {
+ form_error($element, t('%name must be an integer.', array('%name' => $element['#title'])));
+ }
+}
+
+/**
+ * Helper form element validator: integer > 0.
+ */
+function element_validate_integer_positive($element, &$form_state) {
+ $value = $element['#value'];
+ if ($value !== '' && (!is_numeric($value) || intval($value) != $value || $value <= 0)) {
+ form_error($element, t('%name must be a positive integer.', array('%name' => $element['#title'])));
+ }
+}
+
+/**
+ * Helper form element validator: number.
+ */
+function element_validate_number($element, &$form_state) {
+ $value = $element['#value'];
+ if ($value != '' && !is_numeric($value)) {
+ form_error($element, t('%name must be a number.', array('%name' => $element['#title'])));
+ }
+}
+
+/**
+ * @} End of "defgroup form_api".
+ */
+
+/**
+ * @defgroup batch Batch operations
+ * @{
+ * Create and process batch operations.
+ *
+ * Functions allowing forms processing to be spread out over several page
+ * requests, thus ensuring that the processing does not get interrupted
+ * because of a PHP timeout, while allowing the user to receive feedback
+ * on the progress of the ongoing operations.
+ *
+ * The API is primarily designed to integrate nicely with the Form API
+ * workflow, but can also be used by non-Form API scripts (like update.php)
+ * or even simple page callbacks (which should probably be used sparingly).
+ *
+ * Example:
+ * @code
+ * $batch = array(
+ * 'title' => t('Exporting'),
+ * 'operations' => array(
+ * array('my_function_1', array($account->uid, 'story')),
+ * array('my_function_2', array()),
+ * ),
+ * 'finished' => 'my_finished_callback',
+ * 'file' => 'path_to_file_containing_myfunctions',
+ * );
+ * batch_set($batch);
+ * // Only needed if not inside a form _submit handler.
+ * // Setting redirect in batch_process.
+ * batch_process('node/1');
+ * @endcode
+ *
+ * Note: if the batch 'title', 'init_message', 'progress_message', or
+ * 'error_message' could contain any user input, it is the responsibility of
+ * the code calling batch_set() to sanitize them first with a function like
+ * check_plain() or filter_xss(). Furthermore, if the batch operation
+ * returns any user input in the 'results' or 'message' keys of $context,
+ * it must also sanitize them first.
+ *
+ * Sample batch operations:
+ * @code
+ * // Simple and artificial: load a node of a given type for a given user
+ * function my_function_1($uid, $type, &$context) {
+ * // The $context array gathers batch context information about the execution (read),
+ * // as well as 'return values' for the current operation (write)
+ * // The following keys are provided :
+ * // 'results' (read / write): The array of results gathered so far by
+ * // the batch processing, for the current operation to append its own.
+ * // 'message' (write): A text message displayed in the progress page.
+ * // The following keys allow for multi-step operations :
+ * // 'sandbox' (read / write): An array that can be freely used to
+ * // store persistent data between iterations. It is recommended to
+ * // use this instead of $_SESSION, which is unsafe if the user
+ * // continues browsing in a separate window while the batch is processing.
+ * // 'finished' (write): A float number between 0 and 1 informing
+ * // the processing engine of the completion level for the operation.
+ * // 1 (or no value explicitly set) means the operation is finished
+ * // and the batch processing can continue to the next operation.
+ *
+ * $node = node_load(array('uid' => $uid, 'type' => $type));
+ * $context['results'][] = $node->nid . ' : ' . check_plain($node->title);
+ * $context['message'] = check_plain($node->title);
+ * }
+ *
+ * // More advanced example: multi-step operation - load all nodes, five by five
+ * function my_function_2(&$context) {
+ * if (empty($context['sandbox'])) {
+ * $context['sandbox']['progress'] = 0;
+ * $context['sandbox']['current_node'] = 0;
+ * $context['sandbox']['max'] = db_query('SELECT COUNT(DISTINCT nid) FROM {node}')->fetchField();
+ * }
+ * $limit = 5;
+ * $result = db_select('node')
+ * ->fields('node', array('nid'))
+ * ->condition('nid', $context['sandbox']['current_node'], '>')
+ * ->orderBy('nid')
+ * ->range(0, $limit)
+ * ->execute();
+ * foreach ($result as $row) {
+ * $node = node_load($row->nid, NULL, TRUE);
+ * $context['results'][] = $node->nid . ' : ' . check_plain($node->title);
+ * $context['sandbox']['progress']++;
+ * $context['sandbox']['current_node'] = $node->nid;
+ * $context['message'] = check_plain($node->title);
+ * }
+ * if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
+ * $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
+ * }
+ * }
+ * @endcode
+ *
+ * Sample 'finished' callback:
+ * @code
+ * function batch_test_finished($success, $results, $operations) {
+ * // The 'success' parameter means no fatal PHP errors were detected. All
+ * // other error management should be handled using 'results'.
+ * if ($success) {
+ * $message = format_plural(count($results), 'One post processed.', '@count posts processed.');
+ * }
+ * else {
+ * $message = t('Finished with an error.');
+ * }
+ * drupal_set_message($message);
+ * // Providing data for the redirected page is done through $_SESSION.
+ * foreach ($results as $result) {
+ * $items[] = t('Loaded node %title.', array('%title' => $result));
+ * }
+ * $_SESSION['my_batch_results'] = $items;
+ * }
+ * @endcode
+ */
+
+/**
+ * Opens a new batch.
+ *
+ * @param $batch
+ * An array defining the batch. The following keys can be used -- only
+ * 'operations' is required, and batch_init() provides default values for
+ * the messages.
+ * - 'operations': Array of function calls to be performed.
+ * Example:
+ * @code
+ * array(
+ * array('my_function_1', array($arg1)),
+ * array('my_function_2', array($arg2_1, $arg2_2)),
+ * )
+ * @endcode
+ * - 'title': Title for the progress page. Only safe strings should be passed.
+ * Defaults to t('Processing').
+ * - 'init_message': Message displayed while the processing is initialized.
+ * Defaults to t('Initializing.').
+ * - 'progress_message': Message displayed while processing the batch.
+ * Available placeholders are @current, @remaining, @total, @percentage,
+ * @estimate and @elapsed. Defaults to t('Completed @current of @total.').
+ * - 'error_message': Message displayed if an error occurred while processing
+ * the batch. Defaults to t('An error has occurred.').
+ * - 'finished': Name of a function to be executed after the batch has
+ * completed. This should be used to perform any result massaging that
+ * may be needed, and possibly save data in $_SESSION for display after
+ * final page redirection.
+ * - 'file': Path to the file containing the definitions of the
+ * 'operations' and 'finished' functions, for instance if they don't
+ * reside in the main .module file. The path should be relative to
+ * base_path(), and thus should be built using drupal_get_path().
+ * - 'css': Array of paths to CSS files to be used on the progress page.
+ * - 'url_options': options passed to url() when constructing redirect
+ * URLs for the batch.
+ *
+ * Operations are added as new batch sets. Batch sets are used to ensure
+ * clean code independence, ensuring that several batches submitted by
+ * different parts of the code (core / contrib modules) can be processed
+ * correctly while not interfering or having to cope with each other. Each
+ * batch set gets to specify his own UI messages, operates on its own set
+ * of operations and results, and triggers its own 'finished' callback.
+ * Batch sets are processed sequentially, with the progress bar starting
+ * fresh for every new set.
+ */
+function batch_set($batch_definition) {
+ if ($batch_definition) {
+ $batch =& batch_get();
+
+ // Initialize the batch if needed.
+ if (empty($batch)) {
+ $batch = array(
+ 'sets' => array(),
+ 'has_form_submits' => FALSE,
+ );
+ }
+
+ // Base and default properties for the batch set.
+ // Use get_t() to allow batches at install time.
+ $t = get_t();
+ $init = array(
+ 'sandbox' => array(),
+ 'results' => array(),
+ 'success' => FALSE,
+ 'start' => 0,
+ 'elapsed' => 0,
+ );
+ $defaults = array(
+ 'title' => $t('Processing'),
+ 'init_message' => $t('Initializing.'),
+ 'progress_message' => $t('Completed @current of @total.'),
+ 'error_message' => $t('An error has occurred.'),
+ 'css' => array(),
+ );
+ $batch_set = $init + $batch_definition + $defaults;
+
+ // Tweak init_message to avoid the bottom of the page flickering down after
+ // init phase.
+ $batch_set['init_message'] .= '<br/>&nbsp;';
+
+ // The non-concurrent workflow of batch execution allows us to save
+ // numberOfItems() queries by handling our own counter.
+ $batch_set['total'] = count($batch_set['operations']);
+ $batch_set['count'] = $batch_set['total'];
+
+ // Add the set to the batch.
+ if (empty($batch['id'])) {
+ // The batch is not running yet. Simply add the new set.
+ $batch['sets'][] = $batch_set;
+ }
+ else {
+ // The set is being added while the batch is running. Insert the new set
+ // right after the current one to ensure execution order, and store its
+ // operations in a queue.
+ $index = $batch['current_set'] + 1;
+ $slice1 = array_slice($batch['sets'], 0, $index);
+ $slice2 = array_slice($batch['sets'], $index);
+ $batch['sets'] = array_merge($slice1, array($batch_set), $slice2);
+ _batch_populate_queue($batch, $index);
+ }
+ }
+}
+
+/**
+ * Processes the batch.
+ *
+ * Unless the batch has been marked with 'progressive' = FALSE, the function
+ * issues a drupal_goto and thus ends page execution.
+ *
+ * This function is generally not needed in form submit handlers;
+ * Form API takes care of batches that were set during form submission.
+ *
+ * @param $redirect
+ * (optional) Path to redirect to when the batch has finished processing.
+ * @param $url
+ * (optional - should only be used for separate scripts like update.php)
+ * URL of the batch processing page.
+ * @param $redirect_callback
+ * (optional) Specify a function to be called to redirect to the progressive
+ * processing page. By default drupal_goto() will be used to redirect to a
+ * page which will do the progressive page. Specifying another function will
+ * allow the progressive processing to be processed differently.
+ */
+function batch_process($redirect = NULL, $url = 'batch', $redirect_callback = 'drupal_goto') {
+ $batch =& batch_get();
+
+ drupal_theme_initialize();
+
+ if (isset($batch)) {
+ // Add process information
+ $process_info = array(
+ 'current_set' => 0,
+ 'progressive' => TRUE,
+ 'url' => $url,
+ 'url_options' => array(),
+ 'source_url' => $_GET['q'],
+ 'redirect' => $redirect,
+ 'theme' => $GLOBALS['theme_key'],
+ 'redirect_callback' => $redirect_callback,
+ );
+ $batch += $process_info;
+
+ // The batch is now completely built. Allow other modules to make changes
+ // to the batch so that it is easier to reuse batch processes in other
+ // environments.
+ drupal_alter('batch', $batch);
+
+ // Assign an arbitrary id: don't rely on a serial column in the 'batch'
+ // table, since non-progressive batches skip database storage completely.
+ $batch['id'] = db_next_id();
+
+ // Move operations to a job queue. Non-progressive batches will use a
+ // memory-based queue.
+ foreach ($batch['sets'] as $key => $batch_set) {
+ _batch_populate_queue($batch, $key);
+ }
+
+ // Initiate processing.
+ if ($batch['progressive']) {
+ // Now that we have a batch id, we can generate the redirection link in
+ // the generic error message.
+ $t = get_t();
+ $batch['error_message'] = $t('Please continue to <a href="@error_url">the error page</a>', array('@error_url' => url($url, array('query' => array('id' => $batch['id'], 'op' => 'finished')))));
+
+ // Clear the way for the drupal_goto() redirection to the batch processing
+ // page, by saving and unsetting the 'destination', if there is any.
+ if (isset($_GET['destination'])) {
+ $batch['destination'] = $_GET['destination'];
+ unset($_GET['destination']);
+ }
+
+ // Store the batch.
+ db_insert('batch')
+ ->fields(array(
+ 'bid' => $batch['id'],
+ 'timestamp' => REQUEST_TIME,
+ 'token' => drupal_get_token($batch['id']),
+ 'batch' => serialize($batch),
+ ))
+ ->execute();
+
+ // Set the batch number in the session to guarantee that it will stay alive.
+ $_SESSION['batches'][$batch['id']] = TRUE;
+
+ // Redirect for processing.
+ $function = $batch['redirect_callback'];
+ if (function_exists($function)) {
+ $function($batch['url'], array('query' => array('op' => 'start', 'id' => $batch['id'])));
+ }
+ }
+ else {
+ // Non-progressive execution: bypass the whole progressbar workflow
+ // and execute the batch in one pass.
+ require_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ _batch_process();
+ }
+ }
+}
+
+/**
+ * Retrieves the current batch.
+ */
+function &batch_get() {
+ // Not drupal_static(), because Batch API operates at a lower level than most
+ // use-cases for resetting static variables, and we specifically do not want a
+ // global drupal_static_reset() resetting the batch information. Functions
+ // that are part of the Batch API and need to reset the batch information may
+ // call batch_get() and manipulate the result by reference. Functions that are
+ // not part of the Batch API can also do this, but shouldn't.
+ static $batch = array();
+ return $batch;
+}
+
+/**
+ * Populates a job queue with the operations of a batch set.
+ *
+ * Depending on whether the batch is progressive or not, the BatchQueue or
+ * BatchMemoryQueue handler classes will be used.
+ *
+ * @param $batch
+ * The batch array.
+ * @param $set_id
+ * The id of the set to process.
+ * @return
+ * The name and class of the queue are added by reference to the batch set.
+ */
+function _batch_populate_queue(&$batch, $set_id) {
+ $batch_set = &$batch['sets'][$set_id];
+
+ if (isset($batch_set['operations'])) {
+ $batch_set += array(
+ 'queue' => array(
+ 'name' => 'drupal_batch:' . $batch['id'] . ':' . $set_id,
+ 'class' => $batch['progressive'] ? 'BatchQueue' : 'BatchMemoryQueue',
+ ),
+ );
+
+ $queue = _batch_queue($batch_set);
+ $queue->createQueue();
+ foreach ($batch_set['operations'] as $operation) {
+ $queue->createItem($operation);
+ }
+
+ unset($batch_set['operations']);
+ }
+}
+
+/**
+ * Returns a queue object for a batch set.
+ *
+ * @param $batch_set
+ * The batch set.
+ * @return
+ * The queue object.
+ */
+function _batch_queue($batch_set) {
+ static $queues;
+
+ // The class autoloader is not available when running update.php, so make
+ // sure the files are manually included.
+ if (!isset($queues)) {
+ $queues = array();
+ require_once DRUPAL_ROOT . '/core/modules/system/system.queue.inc';
+ require_once DRUPAL_ROOT . '/core/includes/batch.queue.inc';
+ }
+
+ if (isset($batch_set['queue'])) {
+ $name = $batch_set['queue']['name'];
+ $class = $batch_set['queue']['class'];
+
+ if (!isset($queues[$class][$name])) {
+ $queues[$class][$name] = new $class($name);
+ }
+ return $queues[$class][$name];
+ }
+}
+
+/**
+ * @} End of "defgroup batch".
+ */
diff --git a/core/includes/gettext.inc b/core/includes/gettext.inc
new file mode 100644
index 000000000000..4d2ed9ebc74d
--- /dev/null
+++ b/core/includes/gettext.inc
@@ -0,0 +1,1105 @@
+<?php
+
+/**
+ * @file
+ * Gettext parsing and generating API.
+ *
+ * @todo Decouple these functions from Locale API and put to gettext_ namespace.
+ */
+
+/**
+ * @defgroup locale-api-import-export Translation import/export API.
+ * @{
+ * Functions to import and export translations.
+ *
+ * These functions provide the ability to import translations from
+ * external files and to export translations and translation templates.
+ */
+
+/**
+ * Parses Gettext Portable Object file information and inserts into database
+ *
+ * @param $file
+ * Drupal file object corresponding to the PO file to import.
+ * @param $langcode
+ * Language code.
+ * @param $mode
+ * Should existing translations be replaced LOCALE_IMPORT_KEEP or
+ * LOCALE_IMPORT_OVERWRITE.
+ */
+function _locale_import_po($file, $langcode, $mode) {
+ // Try to allocate enough time to parse and import the data.
+ drupal_set_time_limit(240);
+
+ // Check if we have the language already in the database.
+ if (!language_load($langcode)) {
+ drupal_set_message(t('The language selected for import is not supported.'), 'error');
+ return FALSE;
+ }
+
+ // Get strings from file (returns on failure after a partial import, or on success)
+ $status = _locale_import_read_po('db-store', $file, $mode, $langcode);
+ if ($status === FALSE) {
+ // Error messages are set in _locale_import_read_po().
+ return FALSE;
+ }
+
+ // Get status information on import process.
+ list($header_done, $additions, $updates, $deletes, $skips) = _locale_import_one_string('db-report');
+
+ if (!$header_done) {
+ drupal_set_message(t('The translation file %filename appears to have a missing or malformed header.', array('%filename' => $file->filename)), 'error');
+ }
+
+ // Clear cache and force refresh of JavaScript translations.
+ _locale_invalidate_js($langcode);
+ cache()->deletePrefix('locale:');
+
+ // Rebuild the menu, strings may have changed.
+ menu_rebuild();
+
+ drupal_set_message(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => $additions, '%update' => $updates, '%delete' => $deletes)));
+ watchdog('locale', 'Imported %file into %locale: %number new strings added, %update updated and %delete removed.', array('%file' => $file->filename, '%locale' => $langcode, '%number' => $additions, '%update' => $updates, '%delete' => $deletes));
+ if ($skips) {
+ if (module_exists('dblog')) {
+ $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. <a href="@url">See the log</a> for details.', '@count translation strings were skipped because of disallowed or malformed HTML. <a href="@url">See the log</a> for details.', array('@url' => url('admin/reports/dblog')));
+ }
+ else {
+ $skip_message = format_plural($skips, 'A translation string was skipped because of disallowed or malformed HTML. See the log for details.', '@count translation strings were skipped because of disallowed or malformed HTML. See the log for details.');
+ }
+ drupal_set_message($skip_message, 'error');
+ watchdog('locale', '@count disallowed HTML string(s) in %file', array('@count' => $skips, '%file' => $file->uri), WATCHDOG_WARNING);
+ }
+ return TRUE;
+}
+
+/**
+ * Parses Gettext Portable Object file into an array
+ *
+ * @param $op
+ * Storage operation type: db-store or mem-store.
+ * @param $file
+ * Drupal file object corresponding to the PO file to import.
+ * @param $mode
+ * Should existing translations be replaced LOCALE_IMPORT_KEEP or
+ * LOCALE_IMPORT_OVERWRITE.
+ * @param $lang
+ * Language code.
+ */
+function _locale_import_read_po($op, $file, $mode = NULL, $lang = NULL) {
+
+ // The file will get closed by PHP on returning from this function.
+ $fd = fopen($file->uri, 'rb');
+ if (!$fd) {
+ _locale_import_message('The translation import failed because the file %filename could not be read.', $file);
+ return FALSE;
+ }
+
+ /*
+ * The parser context. Can be:
+ * - 'COMMENT' (#)
+ * - 'MSGID' (msgid)
+ * - 'MSGID_PLURAL' (msgid_plural)
+ * - 'MSGCTXT' (msgctxt)
+ * - 'MSGSTR' (msgstr or msgstr[])
+ * - 'MSGSTR_ARR' (msgstr_arg)
+ */
+ $context = 'COMMENT';
+
+ // Current entry being read.
+ $current = array();
+
+ // Current plurality for 'msgstr[]'.
+ $plural = 0;
+
+ // Current line.
+ $lineno = 0;
+
+ while (!feof($fd)) {
+ // A line should not be longer than 10 * 1024.
+ $line = fgets($fd, 10 * 1024);
+
+ if ($lineno == 0) {
+ // The first line might come with a UTF-8 BOM, which should be removed.
+ $line = str_replace("\xEF\xBB\xBF", '', $line);
+ }
+
+ $lineno++;
+
+ // Trim away the linefeed.
+ $line = trim(strtr($line, array("\\\n" => "")));
+
+ if (!strncmp('#', $line, 1)) {
+ // Lines starting with '#' are comments.
+
+ if ($context == 'COMMENT') {
+ // Already in comment token, insert the comment.
+ $current['#'][] = substr($line, 1);
+ }
+ elseif (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
+ // We are currently in string token, close it out.
+ _locale_import_one_string($op, $current, $mode, $lang, $file);
+
+ // Start a new entry for the comment.
+ $current = array();
+ $current['#'][] = substr($line, 1);
+
+ $context = 'COMMENT';
+ }
+ else {
+ // A comment following any other token is a syntax error.
+ _locale_import_message('The translation file %filename contains an error: "msgstr" was expected but not found on line %line.', $file, $lineno);
+ return FALSE;
+ }
+ }
+ elseif (!strncmp('msgid_plural', $line, 12)) {
+ // A plural form for the current message.
+
+ if ($context != 'MSGID') {
+ // A plural form cannot be added to anything else but the id directly.
+ _locale_import_message('The translation file %filename contains an error: "msgid_plural" was expected but not found on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Remove 'msgid_plural' and trim away whitespace.
+ $line = trim(substr($line, 12));
+ // At this point, $line should now contain only the plural form.
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The plural form must be wrapped in quotes.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Append the plural form to the current entry.
+ $current['msgid'] .= "\0" . $quoted;
+
+ $context = 'MSGID_PLURAL';
+ }
+ elseif (!strncmp('msgid', $line, 5)) {
+ // Starting a new message.
+
+ if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
+ // We are currently in a message string, close it out.
+ _locale_import_one_string($op, $current, $mode, $lang, $file);
+
+ // Start a new context for the id.
+ $current = array();
+ }
+ elseif ($context == 'MSGID') {
+ // We are currently already in the context, meaning we passed an id with no data.
+ _locale_import_message('The translation file %filename contains an error: "msgid" is unexpected on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Remove 'msgid' and trim away whitespace.
+ $line = trim(substr($line, 5));
+ // At this point, $line should now contain only the message id.
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The message id must be wrapped in quotes.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ $current['msgid'] = $quoted;
+ $context = 'MSGID';
+ }
+ elseif (!strncmp('msgctxt', $line, 7)) {
+ // Starting a new context.
+
+ if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
+ // We are currently in a message, start a new one.
+ _locale_import_one_string($op, $current, $mode, $lang, $file);
+ $current = array();
+ }
+ elseif (!empty($current['msgctxt'])) {
+ // A context cannot apply to another context.
+ _locale_import_message('The translation file %filename contains an error: "msgctxt" is unexpected on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Remove 'msgctxt' and trim away whitespaces.
+ $line = trim(substr($line, 7));
+ // At this point, $line should now contain the context.
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The context string must be quoted.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ $current['msgctxt'] = $quoted;
+
+ $context = 'MSGCTXT';
+ }
+ elseif (!strncmp('msgstr[', $line, 7)) {
+ // A message string for a specific plurality.
+
+ if (($context != 'MSGID') && ($context != 'MSGCTXT') && ($context != 'MSGID_PLURAL') && ($context != 'MSGSTR_ARR')) {
+ // Message strings must come after msgid, msgxtxt, msgid_plural, or other msgstr[] entries.
+ _locale_import_message('The translation file %filename contains an error: "msgstr[]" is unexpected on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Ensure the plurality is terminated.
+ if (strpos($line, ']') === FALSE) {
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Extract the plurality.
+ $frombracket = strstr($line, '[');
+ $plural = substr($frombracket, 1, strpos($frombracket, ']') - 1);
+
+ // Skip to the next whitespace and trim away any further whitespace, bringing $line to the message data.
+ $line = trim(strstr($line, " "));
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The string must be quoted.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ $current['msgstr'][$plural] = $quoted;
+
+ $context = 'MSGSTR_ARR';
+ }
+ elseif (!strncmp("msgstr", $line, 6)) {
+ // A string for the an id or context.
+
+ if (($context != 'MSGID') && ($context != 'MSGCTXT')) {
+ // Strings are only valid within an id or context scope.
+ _locale_import_message('The translation file %filename contains an error: "msgstr" is unexpected on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Remove 'msgstr' and trim away away whitespaces.
+ $line = trim(substr($line, 6));
+ // At this point, $line should now contain the message.
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The string must be quoted.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ $current['msgstr'] = $quoted;
+
+ $context = 'MSGSTR';
+ }
+ elseif ($line != '') {
+ // Anything that is not a token may be a continuation of a previous token.
+
+ $quoted = _locale_import_parse_quoted($line);
+ if ($quoted === FALSE) {
+ // The string must be quoted.
+ _locale_import_message('The translation file %filename contains a syntax error on line %line.', $file, $lineno);
+ return FALSE;
+ }
+
+ // Append the string to the current context.
+ if (($context == 'MSGID') || ($context == 'MSGID_PLURAL')) {
+ $current['msgid'] .= $quoted;
+ }
+ elseif ($context == 'MSGCTXT') {
+ $current['msgctxt'] .= $quoted;
+ }
+ elseif ($context == 'MSGSTR') {
+ $current['msgstr'] .= $quoted;
+ }
+ elseif ($context == 'MSGSTR_ARR') {
+ $current['msgstr'][$plural] .= $quoted;
+ }
+ else {
+ // No valid context to append to.
+ _locale_import_message('The translation file %filename contains an error: there is an unexpected string on line %line.', $file, $lineno);
+ return FALSE;
+ }
+ }
+ }
+
+ // End of PO file, closed out the last entry.
+ if (($context == 'MSGSTR') || ($context == 'MSGSTR_ARR')) {
+ _locale_import_one_string($op, $current, $mode, $lang, $file);
+ }
+ elseif ($context != 'COMMENT') {
+ _locale_import_message('The translation file %filename ended unexpectedly at line %line.', $file, $lineno);
+ return FALSE;
+ }
+}
+
+/**
+ * Sets an error message occurred during locale file parsing.
+ *
+ * @param $message
+ * The message to be translated.
+ * @param $file
+ * Drupal file object corresponding to the PO file to import.
+ * @param $lineno
+ * An optional line number argument.
+ */
+function _locale_import_message($message, $file, $lineno = NULL) {
+ $vars = array('%filename' => $file->filename);
+ if (isset($lineno)) {
+ $vars['%line'] = $lineno;
+ }
+ $t = get_t();
+ drupal_set_message($t($message, $vars), 'error');
+}
+
+/**
+ * Imports a string into the database
+ *
+ * @param $op
+ * Operation to perform: 'db-store', 'db-report', 'mem-store' or 'mem-report'.
+ * @param $value
+ * Details of the string stored.
+ * @param $mode
+ * Should existing translations be replaced LOCALE_IMPORT_KEEP or
+ * LOCALE_IMPORT_OVERWRITE.
+ * @param $lang
+ * Language to store the string in.
+ * @param $file
+ * Object representation of file being imported, only required when op is
+ * 'db-store'.
+ */
+function _locale_import_one_string($op, $value = NULL, $mode = NULL, $lang = NULL, $file = NULL) {
+ $report = &drupal_static(__FUNCTION__, array('additions' => 0, 'updates' => 0, 'deletes' => 0, 'skips' => 0));
+ $header_done = &drupal_static(__FUNCTION__ . ':header_done', FALSE);
+ $strings = &drupal_static(__FUNCTION__ . ':strings', array());
+
+ switch ($op) {
+ // Return stored strings
+ case 'mem-report':
+ return $strings;
+
+ // Store string in memory (only supports single strings)
+ case 'mem-store':
+ $strings[isset($value['msgctxt']) ? $value['msgctxt'] : ''][$value['msgid']] = $value['msgstr'];
+ return;
+
+ // Called at end of import to inform the user
+ case 'db-report':
+ return array($header_done, $report['additions'], $report['updates'], $report['deletes'], $report['skips']);
+
+ // Store the string we got in the database.
+ case 'db-store':
+ // We got header information.
+ if ($value['msgid'] == '') {
+ $languages = language_list();
+ if (($mode != LOCALE_IMPORT_KEEP) || empty($languages[$lang]->plurals)) {
+ // Since we only need to parse the header if we ought to update the
+ // plural formula, only run this if we don't need to keep existing
+ // data untouched or if we don't have an existing plural formula.
+ $header = _locale_import_parse_header($value['msgstr']);
+
+ // Get the plural formula and update in database.
+ if (isset($header["Plural-Forms"]) && $p = _locale_import_parse_plural_forms($header["Plural-Forms"], $file->uri)) {
+ list($nplurals, $plural) = $p;
+ db_update('languages')
+ ->fields(array(
+ 'plurals' => $nplurals,
+ 'formula' => $plural,
+ ))
+ ->condition('language', $lang)
+ ->execute();
+ }
+ else {
+ db_update('languages')
+ ->fields(array(
+ 'plurals' => 0,
+ 'formula' => '',
+ ))
+ ->condition('language', $lang)
+ ->execute();
+ }
+ }
+ $header_done = TRUE;
+ }
+
+ else {
+ // Some real string to import.
+ $comments = _locale_import_shorten_comments(empty($value['#']) ? array() : $value['#']);
+
+ if (strpos($value['msgid'], "\0")) {
+ // This string has plural versions.
+ $english = explode("\0", $value['msgid'], 2);
+ $entries = array_keys($value['msgstr']);
+ for ($i = 3; $i <= count($entries); $i++) {
+ $english[] = $english[1];
+ }
+ $translation = array_map('_locale_import_append_plural', $value['msgstr'], $entries);
+ $english = array_map('_locale_import_append_plural', $english, $entries);
+ foreach ($translation as $key => $trans) {
+ if ($key == 0) {
+ $plid = 0;
+ }
+ $plid = _locale_import_one_string_db($report, $lang, isset($value['msgctxt']) ? $value['msgctxt'] : '', $english[$key], $trans, $comments, $mode, $plid, $key);
+ }
+ }
+
+ else {
+ // A simple string to import.
+ $english = $value['msgid'];
+ $translation = $value['msgstr'];
+ _locale_import_one_string_db($report, $lang, isset($value['msgctxt']) ? $value['msgctxt'] : '', $english, $translation, $comments, $mode);
+ }
+ }
+ } // end of db-store operation
+}
+
+/**
+ * Import one string into the database.
+ *
+ * @param $report
+ * Report array summarizing the number of changes done in the form:
+ * array(inserts, updates, deletes).
+ * @param $langcode
+ * Language code to import string into.
+ * @param $context
+ * The context of this string.
+ * @param $source
+ * Source string.
+ * @param $translation
+ * Translation to language specified in $langcode.
+ * @param $location
+ * Location value to save with source string.
+ * @param $mode
+ * Import mode to use, LOCALE_IMPORT_KEEP or LOCALE_IMPORT_OVERWRITE.
+ * @param $plid
+ * Optional plural ID to use.
+ * @param $plural
+ * Optional plural value to use.
+ *
+ * @return
+ * The string ID of the existing string modified or the new string added.
+ */
+function _locale_import_one_string_db(&$report, $langcode, $context, $source, $translation, $location, $mode, $plid = 0, $plural = 0) {
+ $lid = db_query("SELECT lid FROM {locales_source} WHERE source = :source AND context = :context", array(':source' => $source, ':context' => $context))->fetchField();
+
+ if (!empty($translation)) {
+ // Skip this string unless it passes a check for dangerous code.
+ if (!locale_string_is_safe($translation)) {
+ watchdog('locale', 'Import of string "%string" was skipped because of disallowed or malformed HTML.', array('%string' => $translation), WATCHDOG_ERROR);
+ $report['skips']++;
+ $lid = 0;
+ }
+ elseif ($lid) {
+ // We have this source string saved already.
+ db_update('locales_source')
+ ->fields(array(
+ 'location' => $location,
+ ))
+ ->condition('lid', $lid)
+ ->execute();
+
+ $exists = db_query("SELECT COUNT(lid) FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $langcode))->fetchField();
+
+ if (!$exists) {
+ // No translation in this language.
+ db_insert('locales_target')
+ ->fields(array(
+ 'lid' => $lid,
+ 'language' => $langcode,
+ 'translation' => $translation,
+ 'plid' => $plid,
+ 'plural' => $plural,
+ ))
+ ->execute();
+
+ $report['additions']++;
+ }
+ elseif ($mode == LOCALE_IMPORT_OVERWRITE) {
+ // Translation exists, only overwrite if instructed.
+ db_update('locales_target')
+ ->fields(array(
+ 'translation' => $translation,
+ 'plid' => $plid,
+ 'plural' => $plural,
+ ))
+ ->condition('language', $langcode)
+ ->condition('lid', $lid)
+ ->execute();
+
+ $report['updates']++;
+ }
+ }
+ else {
+ // No such source string in the database yet.
+ $lid = db_insert('locales_source')
+ ->fields(array(
+ 'location' => $location,
+ 'source' => $source,
+ 'context' => (string) $context,
+ ))
+ ->execute();
+
+ db_insert('locales_target')
+ ->fields(array(
+ 'lid' => $lid,
+ 'language' => $langcode,
+ 'translation' => $translation,
+ 'plid' => $plid,
+ 'plural' => $plural
+ ))
+ ->execute();
+
+ $report['additions']++;
+ }
+ }
+ elseif ($mode == LOCALE_IMPORT_OVERWRITE) {
+ // Empty translation, remove existing if instructed.
+ db_delete('locales_target')
+ ->condition('language', $langcode)
+ ->condition('lid', $lid)
+ ->condition('plid', $plid)
+ ->condition('plural', $plural)
+ ->execute();
+
+ $report['deletes']++;
+ }
+
+ return $lid;
+}
+
+/**
+ * Parses a Gettext Portable Object file header
+ *
+ * @param $header
+ * A string containing the complete header.
+ *
+ * @return
+ * An associative array of key-value pairs.
+ */
+function _locale_import_parse_header($header) {
+ $header_parsed = array();
+ $lines = array_map('trim', explode("\n", $header));
+ foreach ($lines as $line) {
+ if ($line) {
+ list($tag, $contents) = explode(":", $line, 2);
+ $header_parsed[trim($tag)] = trim($contents);
+ }
+ }
+ return $header_parsed;
+}
+
+/**
+ * Parses a Plural-Forms entry from a Gettext Portable Object file header
+ *
+ * @param $pluralforms
+ * A string containing the Plural-Forms entry.
+ * @param $filepath
+ * A string containing the filepath.
+ *
+ * @return
+ * An array containing the number of plurals and a
+ * formula in PHP for computing the plural form.
+ */
+function _locale_import_parse_plural_forms($pluralforms, $filepath) {
+ // First, delete all whitespace
+ $pluralforms = strtr($pluralforms, array(" " => "", "\t" => ""));
+
+ // Select the parts that define nplurals and plural
+ $nplurals = strstr($pluralforms, "nplurals=");
+ if (strpos($nplurals, ";")) {
+ $nplurals = substr($nplurals, 9, strpos($nplurals, ";") - 9);
+ }
+ else {
+ return FALSE;
+ }
+ $plural = strstr($pluralforms, "plural=");
+ if (strpos($plural, ";")) {
+ $plural = substr($plural, 7, strpos($plural, ";") - 7);
+ }
+ else {
+ return FALSE;
+ }
+
+ // Get PHP version of the plural formula
+ $plural = _locale_import_parse_arithmetic($plural);
+
+ if ($plural !== FALSE) {
+ return array($nplurals, $plural);
+ }
+ else {
+ drupal_set_message(t('The translation file %filepath contains an error: the plural formula could not be parsed.', array('%filepath' => $filepath)), 'error');
+ return FALSE;
+ }
+}
+
+/**
+ * Parses and sanitizes an arithmetic formula into a PHP expression
+ *
+ * While parsing, we ensure, that the operators have the right
+ * precedence and associativity.
+ *
+ * @param $string
+ * A string containing the arithmetic formula.
+ *
+ * @return
+ * The PHP version of the formula.
+ */
+function _locale_import_parse_arithmetic($string) {
+ // Operator precedence table
+ $precedence = array("(" => -1, ")" => -1, "?" => 1, ":" => 1, "||" => 3, "&&" => 4, "==" => 5, "!=" => 5, "<" => 6, ">" => 6, "<=" => 6, ">=" => 6, "+" => 7, "-" => 7, "*" => 8, "/" => 8, "%" => 8);
+ // Right associativity
+ $right_associativity = array("?" => 1, ":" => 1);
+
+ $tokens = _locale_import_tokenize_formula($string);
+
+ // Parse by converting into infix notation then back into postfix
+ // Operator stack - holds math operators and symbols
+ $operator_stack = array();
+ // Element Stack - holds data to be operated on
+ $element_stack = array();
+
+ foreach ($tokens as $token) {
+ $current_token = $token;
+
+ // Numbers and the $n variable are simply pushed into $element_stack
+ if (is_numeric($token)) {
+ $element_stack[] = $current_token;
+ }
+ elseif ($current_token == "n") {
+ $element_stack[] = '$n';
+ }
+ elseif ($current_token == "(") {
+ $operator_stack[] = $current_token;
+ }
+ elseif ($current_token == ")") {
+ $topop = array_pop($operator_stack);
+ while (isset($topop) && ($topop != "(")) {
+ $element_stack[] = $topop;
+ $topop = array_pop($operator_stack);
+ }
+ }
+ elseif (!empty($precedence[$current_token])) {
+ // If it's an operator, then pop from $operator_stack into $element_stack until the
+ // precedence in $operator_stack is less than current, then push into $operator_stack
+ $topop = array_pop($operator_stack);
+ while (isset($topop) && ($precedence[$topop] >= $precedence[$current_token]) && !(($precedence[$topop] == $precedence[$current_token]) && !empty($right_associativity[$topop]) && !empty($right_associativity[$current_token]))) {
+ $element_stack[] = $topop;
+ $topop = array_pop($operator_stack);
+ }
+ if ($topop) {
+ $operator_stack[] = $topop; // Return element to top
+ }
+ $operator_stack[] = $current_token; // Parentheses are not needed
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ // Flush operator stack
+ $topop = array_pop($operator_stack);
+ while ($topop != NULL) {
+ $element_stack[] = $topop;
+ $topop = array_pop($operator_stack);
+ }
+
+ // Now extract formula from stack
+ $previous_size = count($element_stack) + 1;
+ while (count($element_stack) < $previous_size) {
+ $previous_size = count($element_stack);
+ for ($i = 2; $i < count($element_stack); $i++) {
+ $op = $element_stack[$i];
+ if (!empty($precedence[$op])) {
+ $f = "";
+ if ($op == ":") {
+ $f = $element_stack[$i - 2] . "):" . $element_stack[$i - 1] . ")";
+ }
+ elseif ($op == "?") {
+ $f = "(" . $element_stack[$i - 2] . "?(" . $element_stack[$i - 1];
+ }
+ else {
+ $f = "(" . $element_stack[$i - 2] . $op . $element_stack[$i - 1] . ")";
+ }
+ array_splice($element_stack, $i - 2, 3, $f);
+ break;
+ }
+ }
+ }
+
+ // If only one element is left, the number of operators is appropriate
+ if (count($element_stack) == 1) {
+ return $element_stack[0];
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Backward compatible implementation of token_get_all() for formula parsing
+ *
+ * @param $string
+ * A string containing the arithmetic formula.
+ *
+ * @return
+ * The PHP version of the formula.
+ */
+function _locale_import_tokenize_formula($formula) {
+ $formula = str_replace(" ", "", $formula);
+ $tokens = array();
+ for ($i = 0; $i < strlen($formula); $i++) {
+ if (is_numeric($formula[$i])) {
+ $num = $formula[$i];
+ $j = $i + 1;
+ while ($j < strlen($formula) && is_numeric($formula[$j])) {
+ $num .= $formula[$j];
+ $j++;
+ }
+ $i = $j - 1;
+ $tokens[] = $num;
+ }
+ elseif ($pos = strpos(" =<>!&|", $formula[$i])) { // We won't have a space
+ $next = $formula[$i + 1];
+ switch ($pos) {
+ case 1:
+ case 2:
+ case 3:
+ case 4:
+ if ($next == '=') {
+ $tokens[] = $formula[$i] . '=';
+ $i++;
+ }
+ else {
+ $tokens[] = $formula[$i];
+ }
+ break;
+ case 5:
+ if ($next == '&') {
+ $tokens[] = '&&';
+ $i++;
+ }
+ else {
+ $tokens[] = $formula[$i];
+ }
+ break;
+ case 6:
+ if ($next == '|') {
+ $tokens[] = '||';
+ $i++;
+ }
+ else {
+ $tokens[] = $formula[$i];
+ }
+ break;
+ }
+ }
+ else {
+ $tokens[] = $formula[$i];
+ }
+ }
+ return $tokens;
+}
+
+/**
+ * Modify a string to contain proper count indices
+ *
+ * This is a callback function used via array_map()
+ *
+ * @param $entry
+ * An array element.
+ * @param $key
+ * Index of the array element.
+ */
+function _locale_import_append_plural($entry, $key) {
+ // No modifications for 0, 1
+ if ($key == 0 || $key == 1) {
+ return $entry;
+ }
+
+ // First remove any possibly false indices, then add new ones
+ $entry = preg_replace('/(@count)\[[0-9]\]/', '\\1', $entry);
+ return preg_replace('/(@count)/', "\\1[$key]", $entry);
+}
+
+/**
+ * Generate a short, one string version of the passed comment array
+ *
+ * @param $comment
+ * An array of strings containing a comment.
+ *
+ * @return
+ * Short one string version of the comment.
+ */
+function _locale_import_shorten_comments($comment) {
+ $comm = '';
+ while (count($comment)) {
+ $test = $comm . substr(array_shift($comment), 1) . ', ';
+ if (strlen($comm) < 130) {
+ $comm = $test;
+ }
+ else {
+ break;
+ }
+ }
+ return trim(substr($comm, 0, -2));
+}
+
+/**
+ * Parses a string in quotes
+ *
+ * @param $string
+ * A string specified with enclosing quotes.
+ *
+ * @return
+ * The string parsed from inside the quotes.
+ */
+function _locale_import_parse_quoted($string) {
+ if (substr($string, 0, 1) != substr($string, -1, 1)) {
+ return FALSE; // Start and end quotes must be the same
+ }
+ $quote = substr($string, 0, 1);
+ $string = substr($string, 1, -1);
+ if ($quote == '"') { // Double quotes: strip slashes
+ return stripcslashes($string);
+ }
+ elseif ($quote == "'") { // Simple quote: return as-is
+ return $string;
+ }
+ else {
+ return FALSE; // Unrecognized quote
+ }
+}
+
+/**
+ * Generates a structured array of all strings with translations in
+ * $language, if given. This array can be used to generate an export
+ * of the string in the database.
+ *
+ * @param $language
+ * Language object to generate the output for, or NULL if generating
+ * translation template.
+ */
+function _locale_export_get_strings($language = NULL) {
+ if (isset($language)) {
+ $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.translation, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language ORDER BY t.plid, t.plural", array(':language' => $language->language));
+ }
+ else {
+ $result = db_query("SELECT s.lid, s.source, s.context, s.location, t.plid, t.plural FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid ORDER BY t.plid, t.plural");
+ }
+ $strings = array();
+ foreach ($result as $child) {
+ $string = array(
+ 'comment' => $child->location,
+ 'source' => $child->source,
+ 'context' => $child->context,
+ 'translation' => isset($child->translation) ? $child->translation : '',
+ );
+ if ($child->plid) {
+ // Has a parent lid. Since we process in the order of plids,
+ // we already have the parent in the array, so we can add the
+ // lid to the next plural version to it. This builds a linked
+ // list of plurals.
+ $string['child'] = TRUE;
+ $strings[$child->plid]['plural'] = $child->lid;
+ }
+ $strings[$child->lid] = $string;
+ }
+ return $strings;
+}
+
+/**
+ * Generates the PO(T) file contents for given strings.
+ *
+ * @param $language
+ * Language object to generate the output for, or NULL if generating
+ * translation template.
+ * @param $strings
+ * Array of strings to export. See _locale_export_get_strings()
+ * on how it should be formatted.
+ * @param $header
+ * The header portion to use for the output file. Defaults
+ * are provided for PO and POT files.
+ */
+function _locale_export_po_generate($language = NULL, $strings = array(), $header = NULL) {
+ global $user;
+
+ if (!isset($header)) {
+ if (isset($language)) {
+ $header = '# ' . $language->name . ' translation of ' . variable_get('site_name', 'Drupal') . "\n";
+ $header .= '# Generated by ' . $user->name . ' <' . $user->mail . ">\n";
+ $header .= "#\n";
+ $header .= "msgid \"\"\n";
+ $header .= "msgstr \"\"\n";
+ $header .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
+ $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
+ $header .= "\"PO-Revision-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
+ $header .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
+ $header .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
+ $header .= "\"MIME-Version: 1.0\\n\"\n";
+ $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
+ $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
+ if ($language->formula && $language->plurals) {
+ $header .= "\"Plural-Forms: nplurals=" . $language->plurals . "; plural=" . strtr($language->formula, array('$' => '')) . ";\\n\"\n";
+ }
+ }
+ else {
+ $header = "# LANGUAGE translation of PROJECT\n";
+ $header .= "# Copyright (c) YEAR NAME <EMAIL@ADDRESS>\n";
+ $header .= "#\n";
+ $header .= "msgid \"\"\n";
+ $header .= "msgstr \"\"\n";
+ $header .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n";
+ $header .= "\"POT-Creation-Date: " . date("Y-m-d H:iO") . "\\n\"\n";
+ $header .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n";
+ $header .= "\"Last-Translator: NAME <EMAIL@ADDRESS>\\n\"\n";
+ $header .= "\"Language-Team: LANGUAGE <EMAIL@ADDRESS>\\n\"\n";
+ $header .= "\"MIME-Version: 1.0\\n\"\n";
+ $header .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n";
+ $header .= "\"Content-Transfer-Encoding: 8bit\\n\"\n";
+ $header .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n";
+ }
+ }
+
+ $output = $header . "\n";
+
+ foreach ($strings as $lid => $string) {
+ // Only process non-children, children are output below their parent.
+ if (!isset($string['child'])) {
+ if ($string['comment']) {
+ $output .= '#: ' . $string['comment'] . "\n";
+ }
+ if (!empty($string['context'])) {
+ $output .= 'msgctxt ' . _locale_export_string($string['context']);
+ }
+ $output .= 'msgid ' . _locale_export_string($string['source']);
+ if (!empty($string['plural'])) {
+ $plural = $string['plural'];
+ $output .= 'msgid_plural ' . _locale_export_string($strings[$plural]['source']);
+ if (isset($language)) {
+ $translation = $string['translation'];
+ for ($i = 0; $i < $language->plurals; $i++) {
+ $output .= 'msgstr[' . $i . '] ' . _locale_export_string($translation);
+ if ($plural) {
+ $translation = _locale_export_remove_plural($strings[$plural]['translation']);
+ $plural = isset($strings[$plural]['plural']) ? $strings[$plural]['plural'] : 0;
+ }
+ else {
+ $translation = '';
+ }
+ }
+ }
+ else {
+ $output .= 'msgstr[0] ""' . "\n";
+ $output .= 'msgstr[1] ""' . "\n";
+ }
+ }
+ else {
+ $output .= 'msgstr ' . _locale_export_string($string['translation']);
+ }
+ $output .= "\n";
+ }
+ }
+ return $output;
+}
+
+/**
+ * Write a generated PO or POT file to the output.
+ *
+ * @param $language
+ * Language object to generate the output for, or NULL if generating
+ * translation template.
+ * @param $output
+ * The PO(T) file to output as a string. See _locale_export_generate_po()
+ * on how it can be generated.
+ */
+function _locale_export_po($language = NULL, $output = NULL) {
+ // Log the export event.
+ if (isset($language)) {
+ $filename = $language->language . '.po';
+ watchdog('locale', 'Exported %locale translation file: %filename.', array('%locale' => $language->name, '%filename' => $filename));
+ }
+ else {
+ $filename = 'drupal.pot';
+ watchdog('locale', 'Exported translation file: %filename.', array('%filename' => $filename));
+ }
+ // Download the file for the client.
+ header("Content-Disposition: attachment; filename=$filename");
+ header("Content-Type: text/plain; charset=utf-8");
+ print $output;
+ drupal_exit();
+}
+
+/**
+ * Print out a string on multiple lines
+ */
+function _locale_export_string($str) {
+ $stri = addcslashes($str, "\0..\37\\\"");
+ $parts = array();
+
+ // Cut text into several lines
+ while ($stri != "") {
+ $i = strpos($stri, "\\n");
+ if ($i === FALSE) {
+ $curstr = $stri;
+ $stri = "";
+ }
+ else {
+ $curstr = substr($stri, 0, $i + 2);
+ $stri = substr($stri, $i + 2);
+ }
+ $curparts = explode("\n", _locale_export_wrap($curstr, 70));
+ $parts = array_merge($parts, $curparts);
+ }
+
+ // Multiline string
+ if (count($parts) > 1) {
+ return "\"\"\n\"" . implode("\"\n\"", $parts) . "\"\n";
+ }
+ // Single line string
+ elseif (count($parts) == 1) {
+ return "\"$parts[0]\"\n";
+ }
+ // No translation
+ else {
+ return "\"\"\n";
+ }
+}
+
+/**
+ * Custom word wrapping for Portable Object (Template) files.
+ */
+function _locale_export_wrap($str, $len) {
+ $words = explode(' ', $str);
+ $return = array();
+
+ $cur = "";
+ $nstr = 1;
+ while (count($words)) {
+ $word = array_shift($words);
+ if ($nstr) {
+ $cur = $word;
+ $nstr = 0;
+ }
+ elseif (strlen("$cur $word") > $len) {
+ $return[] = $cur . " ";
+ $cur = $word;
+ }
+ else {
+ $cur = "$cur $word";
+ }
+ }
+ $return[] = $cur;
+
+ return implode("\n", $return);
+}
+
+/**
+ * Removes plural index information from a string
+ */
+function _locale_export_remove_plural($entry) {
+ return preg_replace('/(@count)\[[0-9]\]/', '\\1', $entry);
+}
+/**
+ * @} End of "locale-api-import-export"
+ */
diff --git a/core/includes/graph.inc b/core/includes/graph.inc
new file mode 100644
index 000000000000..416fad6df077
--- /dev/null
+++ b/core/includes/graph.inc
@@ -0,0 +1,146 @@
+<?php
+
+/**
+ * @file
+ * Directed acyclic graph functions.
+ */
+
+
+/**
+ * Perform a depth first sort on a directed acyclic graph.
+ *
+ * @param $graph
+ * A three dimensional associated array, with the first keys being the names
+ * of the vertices, these can be strings or numbers. The second key is
+ * 'edges' and the third one are again vertices, each such key representing
+ * an edge. Values of array elements are copied over.
+ *
+ * Example:
+ * @code
+ * $graph[1]['edges'][2] = 1;
+ * $graph[2]['edges'][3] = 1;
+ * $graph[2]['edges'][4] = 1;
+ * $graph[3]['edges'][4] = 1;
+ * @endcode
+ *
+ * On return you will also have:
+ * @code
+ * $graph[1]['paths'][2] = 1;
+ * $graph[1]['paths'][3] = 1;
+ * $graph[2]['reverse_paths'][1] = 1;
+ * $graph[3]['reverse_paths'][1] = 1;
+ * @endcode
+ *
+ * @return
+ * The passed-in $graph with more secondary keys filled in:
+ * - 'paths': Contains a list of vertices than can be reached on a path from
+ * this vertex.
+ * - 'reverse_paths': Contains a list of vertices that has a path from them
+ * to this vertex.
+ * - 'weight': If there is a path from a vertex to another then the weight of
+ * the latter is higher.
+ * - 'component': Vertices in the same component have the same component
+ * identifier.
+ *
+ * @see _drupal_depth_first_search()
+ */
+function drupal_depth_first_search(&$graph) {
+ $state = array(
+ // The order of last visit of the depth first search. This is the reverse
+ // of the topological order if the graph is acyclic.
+ 'last_visit_order' => array(),
+ // The components of the graph.
+ 'components' => array(),
+ );
+ // Perform the actual sort.
+ foreach ($graph as $start => $data) {
+ _drupal_depth_first_search($graph, $state, $start);
+ }
+
+ // We do such a numbering that every component starts with 0. This is useful
+ // for module installs as we can install every 0 weighted module in one
+ // request, and then every 1 weighted etc.
+ $component_weights = array();
+
+ foreach ($state['last_visit_order'] as $vertex) {
+ $component = $graph[$vertex]['component'];
+ if (!isset($component_weights[$component])) {
+ $component_weights[$component] = 0;
+ }
+ $graph[$vertex]['weight'] = $component_weights[$component]--;
+ }
+}
+
+/**
+ * Helper function to perform a depth first sort.
+ *
+ * @param $graph
+ * A three dimensional associated graph array.
+ * @param $state
+ * An associative array. The key 'last_visit_order' stores a list of the
+ * vertices visited. The key components stores list of vertices belonging
+ * to the same the component.
+ * @param $start
+ * An arbitrary vertex where we started traversing the graph.
+ * @param $component
+ * The component of the last vertex.
+ *
+ * @see drupal_depth_first_search()
+ */
+function _drupal_depth_first_search(&$graph, &$state, $start, &$component = NULL) {
+ // Assign new component for each new vertex, i.e. when not called recursively.
+ if (!isset($component)) {
+ $component = $start;
+ }
+ // Nothing to do, if we already visited this vertex.
+ if (isset($graph[$start]['paths'])) {
+ return;
+ }
+ // Mark $start as visited.
+ $graph[$start]['paths'] = array();
+
+ // Assign $start to the current component.
+ $graph[$start]['component'] = $component;
+ $state['components'][$component][] = $start;
+
+ // Visit edges of $start.
+ if (isset($graph[$start]['edges'])) {
+ foreach ($graph[$start]['edges'] as $end => $v) {
+ // Mark that $start can reach $end.
+ $graph[$start]['paths'][$end] = $v;
+
+ if (isset($graph[$end]['component']) && $component != $graph[$end]['component']) {
+ // This vertex already has a component, use that from now on and
+ // reassign all the previously explored vertices.
+ $new_component = $graph[$end]['component'];
+ foreach ($state['components'][$component] as $vertex) {
+ $graph[$vertex]['component'] = $new_component;
+ $state['components'][$new_component][] = $vertex;
+ }
+ unset($state['components'][$component]);
+ $component = $new_component;
+ }
+ // Only visit existing vertices.
+ if (isset($graph[$end])) {
+ // Visit the connected vertex.
+ _drupal_depth_first_search($graph, $state, $end, $component);
+
+ // All vertices reachable by $end are also reachable by $start.
+ $graph[$start]['paths'] += $graph[$end]['paths'];
+ }
+ }
+ }
+
+ // Now that any other subgraph has been explored, add $start to all reverse
+ // paths.
+ foreach ($graph[$start]['paths'] as $end => $v) {
+ if (isset($graph[$end])) {
+ $graph[$end]['reverse_paths'][$start] = $v;
+ }
+ }
+
+ // Record the order of the last visit. This is the reverse of the
+ // topological order if the graph is acyclic.
+ $state['last_visit_order'][] = $start;
+}
+
diff --git a/core/includes/image.inc b/core/includes/image.inc
new file mode 100644
index 000000000000..8dc36b995bc0
--- /dev/null
+++ b/core/includes/image.inc
@@ -0,0 +1,442 @@
+<?php
+
+/**
+ * @file
+ * API for manipulating images.
+ */
+
+/**
+ * @defgroup image Image toolkits
+ * @{
+ * Functions for image file manipulations.
+ *
+ * Drupal's image toolkits provide an abstraction layer for common image file
+ * manipulations like scaling, cropping, and rotating. The abstraction frees
+ * module authors from the need to support multiple image libraries, and it
+ * allows site administrators to choose the library that's best for them.
+ *
+ * PHP includes the GD library by default so a GD toolkit is installed with
+ * Drupal. Other toolkits like ImageMagick are available from contrib modules.
+ * GD works well for small images, but using it with larger files may cause PHP
+ * to run out of memory. In contrast the ImageMagick library does not suffer
+ * from this problem, but it requires the ISP to have installed additional
+ * software.
+ *
+ * Image toolkits are discovered based on the associated module's
+ * hook_image_toolkits. Additionally the image toolkit include file
+ * must be identified in the files array in the module.info file. The
+ * toolkit must then be enabled using the admin/config/media/image-toolkit
+ * form.
+ *
+ * Only one toolkit may be selected at a time. If a module author wishes to call
+ * a specific toolkit they can check that it is installed by calling
+ * image_get_available_toolkits(), and then calling its functions directly.
+ */
+
+/**
+ * Return a list of available toolkits.
+ *
+ * @return
+ * An array with the toolkit names as keys and the descriptions as values.
+ */
+function image_get_available_toolkits() {
+ // hook_image_toolkits returns an array of toolkit names.
+ $toolkits = module_invoke_all('image_toolkits');
+
+ $output = array();
+ foreach ($toolkits as $name => $info) {
+ // Only allow modules that aren't marked as unavailable.
+ if ($info['available']) {
+ $output[$name] = $info['title'];
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Retrieve the name of the currently used toolkit.
+ *
+ * @return
+ * String containing the name of the selected toolkit, or FALSE on error.
+ */
+function image_get_toolkit() {
+ static $toolkit;
+
+ if (!isset($toolkit)) {
+ $toolkits = image_get_available_toolkits();
+ $toolkit = variable_get('image_toolkit', 'gd');
+ if (!isset($toolkits[$toolkit]) || !function_exists('image_' . $toolkit . '_load')) {
+ // The selected toolkit isn't available so return the first one found. If
+ // none are available this will return FALSE.
+ reset($toolkits);
+ $toolkit = key($toolkits);
+ }
+ }
+
+ return $toolkit;
+}
+
+/**
+ * Invokes the given method using the currently selected toolkit.
+ *
+ * @param $method
+ * A string containing the method to invoke.
+ * @param $image
+ * An image object returned by image_load().
+ * @param $params
+ * An optional array of parameters to pass to the toolkit method.
+ *
+ * @return
+ * Mixed values (typically Boolean indicating successful operation).
+ */
+function image_toolkit_invoke($method, stdClass $image, array $params = array()) {
+ $function = 'image_' . $image->toolkit . '_' . $method;
+ if (function_exists($function)) {
+ array_unshift($params, $image);
+ return call_user_func_array($function, $params);
+ }
+ watchdog('image', 'The selected image handling toolkit %toolkit can not correctly process %function.', array('%toolkit' => $image->toolkit, '%function' => $function), WATCHDOG_ERROR);
+ return FALSE;
+}
+
+/**
+ * Get details about an image.
+ *
+ * Drupal supports GIF, JPG and PNG file formats when used with the GD
+ * toolkit, and may support others, depending on which toolkits are
+ * installed.
+ *
+ * @param $filepath
+ * String specifying the path of the image file.
+ * @param $toolkit
+ * An optional image toolkit name to override the default.
+ *
+ * @return
+ * FALSE, if the file could not be found or is not an image. Otherwise, a
+ * keyed array containing information about the image:
+ * - "width": Width, in pixels.
+ * - "height": Height, in pixels.
+ * - "extension": Commonly used file extension for the image.
+ * - "mime_type": MIME type ('image/jpeg', 'image/gif', 'image/png').
+ * - "file_size": File size in bytes.
+ */
+function image_get_info($filepath, $toolkit = FALSE) {
+ $details = FALSE;
+ if (!is_file($filepath) && !is_uploaded_file($filepath)) {
+ return $details;
+ }
+
+ if (!$toolkit) {
+ $toolkit = image_get_toolkit();
+ }
+ if ($toolkit) {
+ $image = new stdClass();
+ $image->source = $filepath;
+ $image->toolkit = $toolkit;
+ $details = image_toolkit_invoke('get_info', $image);
+ if (isset($details) && is_array($details)) {
+ $details['file_size'] = filesize($filepath);
+ }
+ }
+
+ return $details;
+}
+
+/**
+ * Scales an image to the exact width and height given.
+ *
+ * This function achieves the target aspect ratio by cropping the original image
+ * equally on both sides, or equally on the top and bottom. This function is
+ * useful to create uniform sized avatars from larger images.
+ *
+ * The resulting image always has the exact target dimensions.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $width
+ * The target width, in pixels.
+ * @param $height
+ * The target height, in pixels.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_resize()
+ * @see image_crop()
+ */
+function image_scale_and_crop(stdClass $image, $width, $height) {
+ $scale = max($width / $image->info['width'], $height / $image->info['height']);
+ $x = ($image->info['width'] * $scale - $width) / 2;
+ $y = ($image->info['height'] * $scale - $height) / 2;
+
+ if (image_resize($image, $image->info['width'] * $scale, $image->info['height'] * $scale)) {
+ return image_crop($image, $x, $y, $width, $height);
+ }
+ return FALSE;
+}
+
+/**
+ * Scales image dimensions while maintaining aspect ratio.
+ *
+ * The resulting dimensions can be smaller for one or both target dimensions.
+ *
+ * @param $dimensions
+ * Dimensions to be modified - an array with components width and height, in
+ * pixels.
+ * @param $width
+ * The target width, in pixels. This value is omitted then the scaling will
+ * based only on the height value.
+ * @param $height
+ * The target height, in pixels. This value is omitted then the scaling will
+ * based only on the width value.
+ * @param $upscale
+ * Boolean indicating that files smaller than the dimensions will be scaled
+ * up. This generally results in a low quality image.
+ *
+ * @return
+ * TRUE if $dimensions was modified, FALSE otherwise.
+ *
+ * @see image_scale()
+ */
+function image_dimensions_scale(array &$dimensions, $width = NULL, $height = NULL, $upscale = FALSE) {
+ $aspect = $dimensions['height'] / $dimensions['width'];
+
+ if ($upscale) {
+ // Set width/height according to aspect ratio if either is empty.
+ $width = !empty($width) ? $width : $height / $aspect;
+ $height = !empty($height) ? $height : $width / $aspect;
+ }
+ else {
+ // Set impossibly large values if the width and height aren't set.
+ $width = !empty($width) ? $width : 9999999;
+ $height = !empty($height) ? $height : 9999999;
+
+ // Don't scale up.
+ if (round($width) >= $dimensions['width'] && round($height) >= $dimensions['height']) {
+ return FALSE;
+ }
+ }
+
+ if ($aspect < $height / $width) {
+ $dimensions['width'] = $width;
+ $dimensions['height'] = (int) round($width * $aspect);
+ }
+ else {
+ $dimensions['width'] = (int) round($height / $aspect);
+ $dimensions['height'] = $height;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Scales an image while maintaining aspect ratio.
+ *
+ * The resulting image can be smaller for one or both target dimensions.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $width
+ * The target width, in pixels. This value is omitted then the scaling will
+ * based only on the height value.
+ * @param $height
+ * The target height, in pixels. This value is omitted then the scaling will
+ * based only on the width value.
+ * @param $upscale
+ * Boolean indicating that files smaller than the dimensions will be scaled
+ * up. This generally results in a low quality image.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_dimensions_scale()
+ * @see image_load()
+ * @see image_scale_and_crop()
+ */
+function image_scale(stdClass $image, $width = NULL, $height = NULL, $upscale = FALSE) {
+ $dimensions = $image->info;
+
+ // Scale the dimensions - if they don't change then just return success.
+ if (!image_dimensions_scale($dimensions, $width, $height, $upscale)) {
+ return TRUE;
+ }
+
+ return image_resize($image, $dimensions['width'], $dimensions['height']);
+}
+
+/**
+ * Resize an image to the given dimensions (ignoring aspect ratio).
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $width
+ * The target width, in pixels.
+ * @param $height
+ * The target height, in pixels.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_gd_resize()
+ */
+function image_resize(stdClass $image, $width, $height) {
+ $width = (int) round($width);
+ $height = (int) round($height);
+
+ return image_toolkit_invoke('resize', $image, array($width, $height));
+}
+
+/**
+ * Rotate an image by the given number of degrees.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $degrees
+ * The number of (clockwise) degrees to rotate the image.
+ * @param $background
+ * An hexadecimal integer specifying the background color to use for the
+ * uncovered area of the image after the rotation. E.g. 0x000000 for black,
+ * 0xff00ff for magenta, and 0xffffff for white. For images that support
+ * transparency, this will default to transparent. Otherwise it will
+ * be white.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_gd_rotate()
+ */
+function image_rotate(stdClass $image, $degrees, $background = NULL) {
+ return image_toolkit_invoke('rotate', $image, array($degrees, $background));
+}
+
+/**
+ * Crop an image to the rectangle specified by the given rectangle.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $x
+ * The top left coordinate, in pixels, of the crop area (x axis value).
+ * @param $y
+ * The top left coordinate, in pixels, of the crop area (y axis value).
+ * @param $width
+ * The target width, in pixels.
+ * @param $height
+ * The target height, in pixels.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_scale_and_crop()
+ * @see image_gd_crop()
+ */
+function image_crop(stdClass $image, $x, $y, $width, $height) {
+ $aspect = $image->info['height'] / $image->info['width'];
+ if (empty($height)) $height = $width / $aspect;
+ if (empty($width)) $width = $height * $aspect;
+
+ $width = (int) round($width);
+ $height = (int) round($height);
+
+ return image_toolkit_invoke('crop', $image, array($x, $y, $width, $height));
+}
+
+/**
+ * Convert an image to grayscale.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_gd_desaturate()
+ */
+function image_desaturate(stdClass $image) {
+ return image_toolkit_invoke('desaturate', $image);
+}
+
+
+/**
+ * Load an image file and return an image object.
+ *
+ * Any changes to the file are not saved until image_save() is called.
+ *
+ * @param $file
+ * Path to an image file.
+ * @param $toolkit
+ * An optional, image toolkit name to override the default.
+ *
+ * @return
+ * An image object or FALSE if there was a problem loading the file. The
+ * image object has the following properties:
+ * - 'source' - The original file path.
+ * - 'info' - The array of information returned by image_get_info()
+ * - 'toolkit' - The name of the image toolkit requested when the image was
+ * loaded.
+ * Image toolkits may add additional properties. The caller is advised not to
+ * monkey about with them.
+ *
+ * @see image_save()
+ * @see image_get_info()
+ * @see image_get_available_toolkits()
+ * @see image_gd_load()
+ */
+function image_load($file, $toolkit = FALSE) {
+ if (!$toolkit) {
+ $toolkit = image_get_toolkit();
+ }
+ if ($toolkit) {
+ $image = new stdClass();
+ $image->source = $file;
+ $image->info = image_get_info($file, $toolkit);
+ if (isset($image->info) && is_array($image->info)) {
+ $image->toolkit = $toolkit;
+ if (image_toolkit_invoke('load', $image)) {
+ return $image;
+ }
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Close the image and save the changes to a file.
+ *
+ * @param $image
+ * An image object returned by image_load(). The object's 'info' property
+ * will be updated if the file is saved successfully.
+ * @param $destination
+ * Destination path where the image should be saved. If it is empty the
+ * original image file will be overwritten.
+ *
+ * @return
+ * TRUE on success, FALSE on failure.
+ *
+ * @see image_load()
+ * @see image_gd_save()
+ */
+function image_save(stdClass $image, $destination = NULL) {
+ if (empty($destination)) {
+ $destination = $image->source;
+ }
+ if ($return = image_toolkit_invoke('save', $image, array($destination))) {
+ // Clear the cached file size and refresh the image information.
+ clearstatcache();
+ $image->info = image_get_info($destination, $image->toolkit);
+
+ if (drupal_chmod($destination)) {
+ return $return;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * @} End of "defgroup image".
+ */
diff --git a/core/includes/install.core.inc b/core/includes/install.core.inc
new file mode 100644
index 000000000000..66426fbf888b
--- /dev/null
+++ b/core/includes/install.core.inc
@@ -0,0 +1,1859 @@
+<?php
+
+/**
+ * @file
+ * API functions for installing Drupal.
+ */
+
+/**
+ * Global flag to indicate that a task should not be run during the current
+ * installation request.
+ *
+ * This can be used to skip running an installation task when certain
+ * conditions are met, even though the task may still show on the list of
+ * installation tasks presented to the user. For example, the Drupal installer
+ * uses this flag to skip over the database configuration form when valid
+ * database connection information is already available from settings.php. It
+ * also uses this flag to skip language import tasks when the installation is
+ * being performed in English.
+ */
+define('INSTALL_TASK_SKIP', 1);
+
+/**
+ * Global flag to indicate that a task should be run on each installation
+ * request that reaches it.
+ *
+ * This is primarily used by the Drupal installer for bootstrap-related tasks.
+ */
+define('INSTALL_TASK_RUN_IF_REACHED', 2);
+
+/**
+ * Global flag to indicate that a task should be run on each installation
+ * request that reaches it, until the database is set up and we are able to
+ * record the fact that it already ran.
+ *
+ * This is the default method for running tasks and should be used for most
+ * tasks that occur after the database is set up; these tasks will then run
+ * once and be marked complete once they are successfully finished. For
+ * example, the Drupal installer uses this flag for the batch installation of
+ * modules on the new site, and also for the configuration form that collects
+ * basic site information and sets up the site maintenance account.
+ */
+define('INSTALL_TASK_RUN_IF_NOT_COMPLETED', 3);
+
+/**
+ * Installs Drupal either interactively or via an array of passed-in settings.
+ *
+ * The Drupal installation happens in a series of steps, which may be spread
+ * out over multiple page requests. Each request begins by trying to determine
+ * the last completed installation step (also known as a "task"), if one is
+ * available from a previous request. Control is then passed to the task
+ * handler, which processes the remaining tasks that need to be run until (a)
+ * an error is thrown, (b) a new page needs to be displayed, or (c) the
+ * installation finishes (whichever happens first).
+ *
+ * @param $settings
+ * An optional array of installation settings. Leave this empty for a normal,
+ * interactive, browser-based installation intended to occur over multiple
+ * page requests. Alternatively, if an array of settings is passed in, the
+ * installer will attempt to use it to perform the installation in a single
+ * page request (optimized for the command line) and not send any output
+ * intended for the web browser. See install_state_defaults() for a list of
+ * elements that are allowed to appear in this array.
+ *
+ * @see install_state_defaults()
+ */
+function install_drupal($settings = array()) {
+ global $install_state;
+ // Initialize the installation state with the settings that were passed in,
+ // as well as a boolean indicating whether or not this is an interactive
+ // installation.
+ $interactive = empty($settings);
+ $install_state = $settings + array('interactive' => $interactive) + install_state_defaults();
+ try {
+ // Begin the page request. This adds information about the current state of
+ // the Drupal installation to the passed-in array.
+ install_begin_request($install_state);
+ // Based on the installation state, run the remaining tasks for this page
+ // request, and collect any output.
+ $output = install_run_tasks($install_state);
+ }
+ catch (Exception $e) {
+ // When an installation error occurs, either send the error to the web
+ // browser or pass on the exception so the calling script can use it.
+ if ($install_state['interactive']) {
+ install_display_output($e->getMessage(), $install_state);
+ }
+ else {
+ throw $e;
+ }
+ }
+ // All available tasks for this page request are now complete. Interactive
+ // installations can send output to the browser or redirect the user to the
+ // next page.
+ if ($install_state['interactive']) {
+ if ($install_state['parameters_changed']) {
+ // Redirect to the correct page if the URL parameters have changed.
+ install_goto(install_redirect_url($install_state));
+ }
+ elseif (isset($output)) {
+ // Display a page only if some output is available. Otherwise it is
+ // possible that we are printing a JSON page and theme output should
+ // not be shown.
+ install_display_output($output, $install_state);
+ }
+ }
+}
+
+/**
+ * Returns an array of default settings for the global installation state.
+ *
+ * The installation state is initialized with these settings at the beginning
+ * of each page request. They may evolve during the page request, but they are
+ * initialized again once the next request begins.
+ *
+ * Non-interactive Drupal installations can override some of these default
+ * settings by passing in an array to the installation script, most notably
+ * 'parameters' (which contains one-time parameters such as 'profile' and
+ * 'locale' that are normally passed in via the URL) and 'forms' (which can
+ * be used to programmatically submit forms during the installation; the keys
+ * of each element indicate the name of the installation task that the form
+ * submission is for, and the values are used as the $form_state['values']
+ * array that is passed on to the form submission via drupal_form_submit()).
+ *
+ * @see drupal_form_submit()
+ */
+function install_state_defaults() {
+ $defaults = array(
+ // The current task being processed.
+ 'active_task' => NULL,
+ // The last task that was completed during the previous installation
+ // request.
+ 'completed_task' => NULL,
+ // This becomes TRUE only when Drupal's system module is installed.
+ 'database_tables_exist' => FALSE,
+ // An array of forms to be programmatically submitted during the
+ // installation. The keys of each element indicate the name of the
+ // installation task that the form submission is for, and the values are
+ // used as the $form_state['values'] array that is passed on to the form
+ // submission via drupal_form_submit().
+ 'forms' => array(),
+ // This becomes TRUE only at the end of the installation process, after
+ // all available tasks have been completed and Drupal is fully installed.
+ // It is used by the installer to store correct information in the database
+ // about the completed installation, as well as to inform theme functions
+ // that all tasks are finished (so that the task list can be displayed
+ // correctly).
+ 'installation_finished' => FALSE,
+ // Whether or not this installation is interactive. By default this will
+ // be set to FALSE if settings are passed in to install_drupal().
+ 'interactive' => TRUE,
+ // An array of available languages for the installation.
+ 'locales' => array(),
+ // An array of parameters for the installation, pre-populated by the URL
+ // or by the settings passed in to install_drupal(). This is primarily
+ // used to store 'profile' (the name of the chosen installation profile)
+ // and 'locale' (the name of the chosen installation language), since
+ // these settings need to persist from page request to page request before
+ // the database is available for storage.
+ 'parameters' => array(),
+ // Whether or not the parameters have changed during the current page
+ // request. For interactive installations, this will trigger a page
+ // redirect.
+ 'parameters_changed' => FALSE,
+ // An array of information about the chosen installation profile. This will
+ // be filled in based on the profile's .info file.
+ 'profile_info' => array(),
+ // An array of available installation profiles.
+ 'profiles' => array(),
+ // An array of server variables that will be substituted into the global
+ // $_SERVER array via drupal_override_server_variables(). Used by
+ // non-interactive installations only.
+ 'server' => array(),
+ // This becomes TRUE only when a valid database connection can be
+ // established.
+ 'settings_verified' => FALSE,
+ // Installation tasks can set this to TRUE to force the page request to
+ // end (even if there is no themable output), in the case of an interactive
+ // installation. This is needed only rarely; for example, it would be used
+ // by an installation task that prints JSON output rather than returning a
+ // themed page. The most common example of this is during batch processing,
+ // but the Drupal installer automatically takes care of setting this
+ // parameter properly in that case, so that individual installation tasks
+ // which implement the batch API do not need to set it themselves.
+ 'stop_page_request' => FALSE,
+ // Installation tasks can set this to TRUE to indicate that the task should
+ // be run again, even if it normally wouldn't be. This can be used, for
+ // example, if a single task needs to be spread out over multiple page
+ // requests, or if it needs to perform some validation before allowing
+ // itself to be marked complete. The most common examples of this are batch
+ // processing and form submissions, but the Drupal installer automatically
+ // takes care of setting this parameter properly in those cases, so that
+ // individual installation tasks which implement the batch API or form API
+ // do not need to set it themselves.
+ 'task_not_complete' => FALSE,
+ // A list of installation tasks which have already been performed during
+ // the current page request.
+ 'tasks_performed' => array(),
+ );
+ return $defaults;
+}
+
+/**
+ * Begin an installation request, modifying the installation state as needed.
+ *
+ * This function performs commands that must run at the beginning of every page
+ * request. It throws an exception if the installation should not proceed.
+ *
+ * @param $install_state
+ * An array of information about the current installation state. This is
+ * modified with information gleaned from the beginning of the page request.
+ */
+function install_begin_request(&$install_state) {
+ // Add any installation parameters passed in via the URL.
+ $install_state['parameters'] += $_GET;
+
+ // Validate certain core settings that are used throughout the installation.
+ if (!empty($install_state['parameters']['profile'])) {
+ $install_state['parameters']['profile'] = preg_replace('/[^a-zA-Z_0-9]/', '', $install_state['parameters']['profile']);
+ }
+ if (!empty($install_state['parameters']['locale'])) {
+ $install_state['parameters']['locale'] = preg_replace('/[^a-zA-Z_0-9\-]/', '', $install_state['parameters']['locale']);
+ }
+
+ // Allow command line scripts to override server variables used by Drupal.
+ require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+ if (!$install_state['interactive']) {
+ drupal_override_server_variables($install_state['server']);
+ }
+
+ // The user agent header is used to pass a database prefix in the request when
+ // running tests. However, for security reasons, it is imperative that no
+ // installation be permitted using such a prefix.
+ if (isset($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], "simpletest") !== FALSE) {
+ header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
+ exit;
+ }
+
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_CONFIGURATION);
+
+ // This must go after drupal_bootstrap(), which unsets globals!
+ global $conf;
+
+ require_once DRUPAL_ROOT . '/core/modules/system/system.install';
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ require_once DRUPAL_ROOT . '/core/includes/file.inc';
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ require_once DRUPAL_ROOT . '/' . variable_get('path_inc', 'core/includes/path.inc');
+
+ // Load module basics (needed for hook invokes).
+ include_once DRUPAL_ROOT . '/core/includes/module.inc';
+ include_once DRUPAL_ROOT . '/core/includes/session.inc';
+
+ // Set up $language, so t() caller functions will still work.
+ drupal_language_initialize();
+
+ require_once DRUPAL_ROOT . '/core/includes/ajax.inc';
+ $module_list['system']['filename'] = 'core/modules/system/system.module';
+ $module_list['entity']['filename'] = 'core/modules/entity/entity.module';
+ $module_list['user']['filename'] = 'core/modules/user/user.module';
+ module_list(TRUE, FALSE, FALSE, $module_list);
+ drupal_load('module', 'system');
+ drupal_load('module', 'entity');
+ drupal_load('module', 'user');
+
+ // Load the cache infrastructure using a "fake" cache implementation that
+ // does not attempt to write to the database. We need this during the initial
+ // part of the installer because the database is not available yet. We
+ // continue to use it even when the database does become available, in order
+ // to preserve consistency between interactive and command-line installations
+ // (the latter complete in one page request and therefore are forced to
+ // continue using the cache implementation they started with) and also
+ // because any data put in the cache during the installer is inherently
+ // suspect, due to the fact that Drupal is not fully set up yet.
+ require_once DRUPAL_ROOT . '/core/includes/cache.inc';
+ require_once DRUPAL_ROOT . '/core/includes/cache-install.inc';
+ $conf['cache_default_class'] = 'DrupalFakeCache';
+
+ // Prepare for themed output. We need to run this at the beginning of the
+ // page request to avoid a different theme accidentally getting set. (We also
+ // need to run it even in the case of command-line installations, to prevent
+ // any code in the installer that happens to initialize the theme system from
+ // accessing the database before it is set up yet.)
+ drupal_maintenance_theme();
+
+ // Check existing settings.php.
+ $install_state['settings_verified'] = install_verify_settings();
+
+ if ($install_state['settings_verified']) {
+ // Initialize the database system. Note that the connection
+ // won't be initialized until it is actually requested.
+ require_once DRUPAL_ROOT . '/core/includes/database/database.inc';
+
+ // Verify the last completed task in the database, if there is one.
+ $task = install_verify_completed_task();
+ }
+ else {
+ $task = NULL;
+
+ // Since previous versions of Drupal stored database connection information
+ // in the 'db_url' variable, we should never let an installation proceed if
+ // this variable is defined and the settings file was not verified above
+ // (otherwise we risk installing over an existing site whose settings file
+ // has not yet been updated).
+ if (!empty($GLOBALS['db_url'])) {
+ throw new Exception(install_already_done_error());
+ }
+ }
+
+ // Modify the installation state as appropriate.
+ $install_state['completed_task'] = $task;
+ $install_state['database_tables_exist'] = !empty($task);
+}
+
+/**
+ * Runs all tasks for the current installation request.
+ *
+ * In the case of an interactive installation, all tasks will be attempted
+ * until one is reached that has output which needs to be displayed to the
+ * user, or until a page redirect is required. Otherwise, tasks will be
+ * attempted until the installation is finished.
+ *
+ * @param $install_state
+ * An array of information about the current installation state. This is
+ * passed along to each task, so it can be modified if necessary.
+ *
+ * @return
+ * HTML output from the last completed task.
+ */
+function install_run_tasks(&$install_state) {
+ do {
+ // Obtain a list of tasks to perform. The list of tasks itself can be
+ // dynamic (e.g., some might be defined by the installation profile,
+ // which is not necessarily known until the earlier tasks have run),
+ // so we regenerate the remaining tasks based on the installation state,
+ // each time through the loop.
+ $tasks_to_perform = install_tasks_to_perform($install_state);
+ // Run the first task on the list.
+ reset($tasks_to_perform);
+ $task_name = key($tasks_to_perform);
+ $task = array_shift($tasks_to_perform);
+ $install_state['active_task'] = $task_name;
+ $original_parameters = $install_state['parameters'];
+ $output = install_run_task($task, $install_state);
+ $install_state['parameters_changed'] = ($install_state['parameters'] != $original_parameters);
+ // Store this task as having been performed during the current request,
+ // and save it to the database as completed, if we need to and if the
+ // database is in a state that allows us to do so. Also mark the
+ // installation as 'done' when we have run out of tasks.
+ if (!$install_state['task_not_complete']) {
+ $install_state['tasks_performed'][] = $task_name;
+ $install_state['installation_finished'] = empty($tasks_to_perform);
+ if ($install_state['database_tables_exist'] && ($task['run'] == INSTALL_TASK_RUN_IF_NOT_COMPLETED || $install_state['installation_finished'])) {
+ variable_set('install_task', $install_state['installation_finished'] ? 'done' : $task_name);
+ }
+ }
+ // Stop when there are no tasks left. In the case of an interactive
+ // installation, also stop if we have some output to send to the browser,
+ // the URL parameters have changed, or an end to the page request was
+ // specifically called for.
+ $finished = empty($tasks_to_perform) || ($install_state['interactive'] && (isset($output) || $install_state['parameters_changed'] || $install_state['stop_page_request']));
+ } while (!$finished);
+ return $output;
+}
+
+/**
+ * Runs an individual installation task.
+ *
+ * @param $task
+ * An array of information about the task to be run.
+ * @param $install_state
+ * An array of information about the current installation state. This is
+ * passed in by reference so that it can be modified by the task.
+ *
+ * @return
+ * The output of the task function, if there is any.
+ */
+function install_run_task($task, &$install_state) {
+ $function = $task['function'];
+
+ if ($task['type'] == 'form') {
+ require_once DRUPAL_ROOT . '/core/includes/form.inc';
+ if ($install_state['interactive']) {
+ // For interactive forms, build the form and ensure that it will not
+ // redirect, since the installer handles its own redirection only after
+ // marking the form submission task complete.
+ $form_state = array(
+ // We need to pass $install_state by reference in order for forms to
+ // modify it, since the form API will use it in call_user_func_array(),
+ // which requires that referenced variables be passed explicitly.
+ 'build_info' => array('args' => array(&$install_state)),
+ 'no_redirect' => TRUE,
+ );
+ $form = drupal_build_form($function, $form_state);
+ // If a successful form submission did not occur, the form needs to be
+ // rendered, which means the task is not complete yet.
+ if (empty($form_state['executed'])) {
+ $install_state['task_not_complete'] = TRUE;
+ return drupal_render($form);
+ }
+ // Otherwise, return nothing so the next task will run in the same
+ // request.
+ return;
+ }
+ else {
+ // For non-interactive forms, submit the form programmatically with the
+ // values taken from the installation state. Throw an exception if any
+ // errors were encountered.
+ $form_state = array(
+ 'values' => !empty($install_state['forms'][$function]) ? $install_state['forms'][$function] : array(),
+ // We need to pass $install_state by reference in order for forms to
+ // modify it, since the form API will use it in call_user_func_array(),
+ // which requires that referenced variables be passed explicitly.
+ 'build_info' => array('args' => array(&$install_state)),
+ );
+ drupal_form_submit($function, $form_state);
+ $errors = form_get_errors();
+ if (!empty($errors)) {
+ throw new Exception(implode("\n", $errors));
+ }
+ }
+ }
+
+ elseif ($task['type'] == 'batch') {
+ // Start a new batch based on the task function, if one is not running
+ // already.
+ $current_batch = variable_get('install_current_batch');
+ if (!$install_state['interactive'] || !$current_batch) {
+ $batch = $function($install_state);
+ if (empty($batch)) {
+ // If the task did some processing and decided no batch was necessary,
+ // there is nothing more to do here.
+ return;
+ }
+ batch_set($batch);
+ // For interactive batches, we need to store the fact that this batch
+ // task is currently running. Otherwise, we need to make sure the batch
+ // will complete in one page request.
+ if ($install_state['interactive']) {
+ variable_set('install_current_batch', $function);
+ }
+ else {
+ $batch =& batch_get();
+ $batch['progressive'] = FALSE;
+ }
+ // Process the batch. For progressive batches, this will redirect.
+ // Otherwise, the batch will complete.
+ batch_process(install_redirect_url($install_state), install_full_redirect_url($install_state));
+ }
+ // If we are in the middle of processing this batch, keep sending back
+ // any output from the batch process, until the task is complete.
+ elseif ($current_batch == $function) {
+ include_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ $output = _batch_page();
+ // The task is complete when we try to access the batch page and receive
+ // FALSE in return, since this means we are at a URL where we are no
+ // longer requesting a batch ID.
+ if ($output === FALSE) {
+ // Return nothing so the next task will run in the same request.
+ variable_del('install_current_batch');
+ return;
+ }
+ else {
+ // We need to force the page request to end if the task is not
+ // complete, since the batch API sometimes prints JSON output
+ // rather than returning a themed page.
+ $install_state['task_not_complete'] = $install_state['stop_page_request'] = TRUE;
+ return $output;
+ }
+ }
+ }
+
+ else {
+ // For normal tasks, just return the function result, whatever it is.
+ return $function($install_state);
+ }
+}
+
+/**
+ * Returns a list of tasks to perform during the current installation request.
+ *
+ * Note that the list of tasks can change based on the installation state as
+ * the page request evolves (for example, if an installation profile hasn't
+ * been selected yet, we don't yet know which profile tasks need to be run).
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * A list of tasks to be performed, with associated metadata.
+ */
+function install_tasks_to_perform($install_state) {
+ // Start with a list of all currently available tasks.
+ $tasks = install_tasks($install_state);
+ foreach ($tasks as $name => $task) {
+ // Remove any tasks that were already performed or that never should run.
+ // Also, if we started this page request with an indication of the last
+ // task that was completed, skip that task and all those that come before
+ // it, unless they are marked as always needing to run.
+ if ($task['run'] == INSTALL_TASK_SKIP || in_array($name, $install_state['tasks_performed']) || (!empty($install_state['completed_task']) && empty($completed_task_found) && $task['run'] != INSTALL_TASK_RUN_IF_REACHED)) {
+ unset($tasks[$name]);
+ }
+ if (!empty($install_state['completed_task']) && $name == $install_state['completed_task']) {
+ $completed_task_found = TRUE;
+ }
+ }
+ return $tasks;
+}
+
+/**
+ * Returns a list of all tasks the installer currently knows about.
+ *
+ * This function will return tasks regardless of whether or not they are
+ * intended to run on the current page request. However, the list can change
+ * based on the installation state (for example, if an installation profile
+ * hasn't been selected yet, we don't yet know which profile tasks will be
+ * available).
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * A list of tasks, with associated metadata.
+ */
+function install_tasks($install_state) {
+ // Determine whether translation import tasks will need to be performed.
+ $needs_translations = count($install_state['locales']) > 1 && !empty($install_state['parameters']['locale']) && $install_state['parameters']['locale'] != 'en';
+
+ // Start with the core installation tasks that run before handing control
+ // to the install profile.
+ $tasks = array(
+ 'install_select_profile' => array(
+ 'display_name' => st('Choose profile'),
+ 'display' => count($install_state['profiles']) != 1,
+ 'run' => INSTALL_TASK_RUN_IF_REACHED,
+ ),
+ 'install_select_locale' => array(
+ 'display_name' => st('Choose language'),
+ 'run' => INSTALL_TASK_RUN_IF_REACHED,
+ ),
+ 'install_load_profile' => array(
+ 'run' => INSTALL_TASK_RUN_IF_REACHED,
+ ),
+ 'install_verify_requirements' => array(
+ 'display_name' => st('Verify requirements'),
+ ),
+ 'install_settings_form' => array(
+ 'display_name' => st('Set up database'),
+ 'type' => 'form',
+ 'run' => $install_state['settings_verified'] ? INSTALL_TASK_SKIP : INSTALL_TASK_RUN_IF_NOT_COMPLETED,
+ ),
+ 'install_system_module' => array(
+ ),
+ 'install_bootstrap_full' => array(
+ 'run' => INSTALL_TASK_RUN_IF_REACHED,
+ ),
+ 'install_profile_modules' => array(
+ 'display_name' => count($install_state['profiles']) == 1 ? st('Install site') : st('Install profile'),
+ 'type' => 'batch',
+ ),
+ 'install_import_locales' => array(
+ 'display_name' => st('Set up translations'),
+ 'display' => $needs_translations,
+ 'type' => 'batch',
+ 'run' => $needs_translations ? INSTALL_TASK_RUN_IF_NOT_COMPLETED : INSTALL_TASK_SKIP,
+ ),
+ 'install_configure_form' => array(
+ 'display_name' => st('Configure site'),
+ 'type' => 'form',
+ ),
+ );
+
+ // Now add any tasks defined by the installation profile.
+ if (!empty($install_state['parameters']['profile'])) {
+ $function = $install_state['parameters']['profile'] . '_install_tasks';
+ if (function_exists($function)) {
+ $result = $function($install_state);
+ if (is_array($result)) {
+ $tasks += $result;
+ }
+ }
+ }
+
+ // Finish by adding the remaining core tasks.
+ $tasks += array(
+ 'install_import_locales_remaining' => array(
+ 'display_name' => st('Finish translations'),
+ 'display' => $needs_translations,
+ 'type' => 'batch',
+ 'run' => $needs_translations ? INSTALL_TASK_RUN_IF_NOT_COMPLETED : INSTALL_TASK_SKIP,
+ ),
+ 'install_finished' => array(
+ 'display_name' => st('Finished'),
+ ),
+ );
+
+ // Allow the installation profile to modify the full list of tasks.
+ if (!empty($install_state['parameters']['profile'])) {
+ $profile_file = DRUPAL_ROOT . '/profiles/' . $install_state['parameters']['profile'] . '/' . $install_state['parameters']['profile'] . '.profile';
+ if (is_file($profile_file)) {
+ include_once $profile_file;
+ $function = $install_state['parameters']['profile'] . '_install_tasks_alter';
+ if (function_exists($function)) {
+ $function($tasks, $install_state);
+ }
+ }
+ }
+
+ // Fill in default parameters for each task before returning the list.
+ foreach ($tasks as $task_name => &$task) {
+ $task += array(
+ 'display_name' => NULL,
+ 'display' => !empty($task['display_name']),
+ 'type' => 'normal',
+ 'run' => INSTALL_TASK_RUN_IF_NOT_COMPLETED,
+ 'function' => $task_name,
+ );
+ }
+ return $tasks;
+}
+
+/**
+ * Returns a list of tasks that should be displayed to the end user.
+ *
+ * The output of this function is a list suitable for sending to
+ * theme_task_list().
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * A list of tasks, with keys equal to the machine-readable task name and
+ * values equal to the name that should be displayed.
+ *
+ * @see theme_task_list()
+ */
+function install_tasks_to_display($install_state) {
+ $displayed_tasks = array();
+ foreach (install_tasks($install_state) as $name => $task) {
+ if ($task['display']) {
+ $displayed_tasks[$name] = $task['display_name'];
+ }
+ }
+ return $displayed_tasks;
+}
+
+/**
+ * Returns the URL that should be redirected to during an installation request.
+ *
+ * The output of this function is suitable for sending to install_goto().
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The URL to redirect to.
+ *
+ * @see install_full_redirect_url()
+ */
+function install_redirect_url($install_state) {
+ return 'core/install.php?' . drupal_http_build_query($install_state['parameters']);
+}
+
+/**
+ * Returns the complete URL redirected to during an installation request.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The complete URL to redirect to.
+ *
+ * @see install_redirect_url()
+ */
+function install_full_redirect_url($install_state) {
+ global $base_url;
+ return $base_url . '/' . install_redirect_url($install_state);
+}
+
+/**
+ * Displays themed installer output and ends the page request.
+ *
+ * Installation tasks should use drupal_set_title() to set the desired page
+ * title, but otherwise this function takes care of theming the overall page
+ * output during every step of the installation.
+ *
+ * @param $output
+ * The content to display on the main part of the page.
+ * @param $install_state
+ * An array of information about the current installation state.
+ */
+function install_display_output($output, $install_state) {
+ drupal_page_header();
+ // Only show the task list if there is an active task; otherwise, the page
+ // request has ended before tasks have even been started, so there is nothing
+ // meaningful to show.
+ if (isset($install_state['active_task'])) {
+ // Let the theming function know when every step of the installation has
+ // been completed.
+ $active_task = $install_state['installation_finished'] ? NULL : $install_state['active_task'];
+ drupal_add_region_content('sidebar_first', theme('task_list', array('items' => install_tasks_to_display($install_state), 'active' => $active_task)));
+ }
+ print theme('install_page', array('content' => $output));
+ exit;
+}
+
+/**
+ * Installation task; verify the requirements for installing Drupal.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * A themed status report, or an exception if there are requirement errors.
+ * If there are only requirement warnings, a themed status report is shown
+ * initially, but the user is allowed to bypass it by providing 'continue=1'
+ * in the URL. Otherwise, no output is returned, so that the next task can be
+ * run in the same page request.
+ */
+function install_verify_requirements(&$install_state) {
+ // Check the installation requirements for Drupal and this profile.
+ $requirements = install_check_requirements($install_state);
+
+ // Verify existence of all required modules.
+ $requirements += drupal_verify_profile($install_state);
+
+ // Check the severity of the requirements reported.
+ $severity = drupal_requirements_severity($requirements);
+
+ // If there are errors, always display them. If there are only warnings, skip
+ // them if the user has provided a URL parameter acknowledging the warnings
+ // and indicating a desire to continue anyway. See drupal_requirements_url().
+ if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && empty($install_state['parameters']['continue']))) {
+ if ($install_state['interactive']) {
+ drupal_set_title(st('Requirements problem'));
+ $status_report = theme('status_report', array('requirements' => $requirements));
+ $status_report .= st('Check the messages and <a href="!url">proceed with the installation</a>.', array('!url' => check_url(drupal_requirements_url($severity))));
+ return $status_report;
+ }
+ else {
+ // Throw an exception showing any unmet requirements.
+ $failures = array();
+ foreach ($requirements as $requirement) {
+ // Skip warnings altogether for non-interactive installations; these
+ // proceed in a single request so there is no good opportunity (and no
+ // good method) to warn the user anyway.
+ if (isset($requirement['severity']) && $requirement['severity'] == REQUIREMENT_ERROR) {
+ $failures[] = $requirement['title'] . ': ' . $requirement['value'] . "\n\n" . $requirement['description'];
+ }
+ }
+ if (!empty($failures)) {
+ throw new Exception(implode("\n\n", $failures));
+ }
+ }
+ }
+}
+
+/**
+ * Installation task; install the Drupal system module.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ */
+function install_system_module(&$install_state) {
+ // Install system.module.
+ drupal_install_system();
+
+ // Enable the user module so that sessions can be recorded during the
+ // upcoming bootstrap step.
+ module_enable(array('user'), FALSE);
+
+ // Save the list of other modules to install for the upcoming tasks.
+ // variable_set() can be used now that system.module is installed.
+ $modules = $install_state['profile_info']['dependencies'];
+
+ // The install profile is also a module, which needs to be installed
+ // after all the dependencies have been installed.
+ $modules[] = drupal_get_profile();
+
+ variable_set('install_profile_modules', array_diff($modules, array('system')));
+ $install_state['database_tables_exist'] = TRUE;
+}
+
+/**
+ * Verify and return the last installation task that was completed.
+ *
+ * @return
+ * The last completed task, if there is one. An exception is thrown if Drupal
+ * is already installed.
+ */
+function install_verify_completed_task() {
+ try {
+ if ($result = db_query("SELECT value FROM {variable} WHERE name = :name", array('name' => 'install_task'))) {
+ $task = unserialize($result->fetchField());
+ }
+ }
+ // Do not trigger an error if the database query fails, since the database
+ // might not be set up yet.
+ catch (Exception $e) {
+ }
+ if (isset($task)) {
+ if ($task == 'done') {
+ throw new Exception(install_already_done_error());
+ }
+ return $task;
+ }
+}
+
+/**
+ * Verifies the existing settings in settings.php.
+ */
+function install_verify_settings() {
+ global $databases;
+
+ // Verify existing settings (if any).
+ if (!empty($databases) && install_verify_pdo()) {
+ $database = $databases['default']['default'];
+ drupal_static_reset('conf_path');
+ $settings_file = './' . conf_path(FALSE) . '/settings.php';
+ $errors = install_database_errors($database, $settings_file);
+ if (empty($errors)) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Verify PDO library.
+ */
+function install_verify_pdo() {
+ // PDO was moved to PHP core in 5.2.0, but the old extension (targeting 5.0
+ // and 5.1) is still available from PECL, and can still be built without
+ // errors. To verify that the correct version is in use, we check the
+ // PDO::ATTR_DEFAULT_FETCH_MODE constant, which is not available in the
+ // PECL extension.
+ return extension_loaded('pdo') && defined('PDO::ATTR_DEFAULT_FETCH_MODE');
+}
+
+/**
+ * Installation task; define a form to configure and rewrite settings.php.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The form API definition for the database configuration form.
+ */
+function install_settings_form($form, &$form_state, &$install_state) {
+ global $databases;
+ $profile = $install_state['parameters']['profile'];
+ $install_locale = $install_state['parameters']['locale'];
+
+ drupal_static_reset('conf_path');
+ $conf_path = './' . conf_path(FALSE);
+ $settings_file = $conf_path . '/settings.php';
+ $database = isset($databases['default']['default']) ? $databases['default']['default'] : array();
+
+ drupal_set_title(st('Database configuration'));
+
+ $drivers = drupal_get_database_types();
+ $drivers_keys = array_keys($drivers);
+
+ $form['driver'] = array(
+ '#type' => 'radios',
+ '#title' => st('Database type'),
+ '#required' => TRUE,
+ '#default_value' => !empty($database['driver']) ? $database['driver'] : current($drivers_keys),
+ '#description' => st('The type of database your @drupal data will be stored in.', array('@drupal' => drupal_install_profile_distribution_name())),
+ );
+ if (count($drivers) == 1) {
+ $form['driver']['#disabled'] = TRUE;
+ $form['driver']['#description'] .= ' ' . st('Your PHP configuration only supports a single database type, so it has been automatically selected.');
+ }
+
+ // Add driver specific configuration options.
+ foreach ($drivers as $key => $driver) {
+ $form['driver']['#options'][$key] = $driver->name();
+
+ $form['settings'][$key] = $driver->getFormOptions($database);
+ $form['settings'][$key]['#prefix'] = '<h2 class="js-hide">' . st('@driver_name settings', array('@driver_name' => $driver->name())) . '</h2>';
+ $form['settings'][$key]['#type'] = 'container';
+ $form['settings'][$key]['#tree'] = TRUE;
+ $form['settings'][$key]['advanced_options']['#parents'] = array($key);
+ $form['settings'][$key]['#states'] = array(
+ 'visible' => array(
+ ':input[name=driver]' => array('value' => $key),
+ )
+ );
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['save'] = array(
+ '#type' => 'submit',
+ '#value' => st('Save and continue'),
+ '#limit_validation_errors' => array(
+ array('driver'),
+ array(isset($form_state['input']['driver']) ? $form_state['input']['driver'] : current($drivers_keys)),
+ ),
+ '#submit' => array('install_settings_form_submit'),
+ );
+
+ $form['errors'] = array();
+ $form['settings_file'] = array('#type' => 'value', '#value' => $settings_file);
+
+ return $form;
+}
+
+/**
+ * Form API validate for install_settings form.
+ */
+function install_settings_form_validate($form, &$form_state) {
+ $driver = $form_state['values']['driver'];
+ $database = $form_state['values'][$driver];
+ $database['driver'] = $driver;
+
+ // TODO: remove when PIFR will be updated to use 'db_prefix' instead of
+ // 'prefix' in the database settings form.
+ $database['prefix'] = $database['db_prefix'];
+ unset($database['db_prefix']);
+
+ $form_state['storage']['database'] = $database;
+ $errors = install_database_errors($database, $form_state['values']['settings_file']);
+ foreach ($errors as $name => $message) {
+ form_set_error($name, $message);
+ }
+}
+
+/**
+ * Checks a database connection and returns any errors.
+ */
+function install_database_errors($database, $settings_file) {
+ global $databases;
+ $errors = array();
+
+ // Check database type.
+ $database_types = drupal_get_database_types();
+ $driver = $database['driver'];
+ if (!isset($database_types[$driver])) {
+ $errors['driver'] = st("In your %settings_file file you have configured @drupal to use a %driver server, however your PHP installation currently does not support this database type.", array('%settings_file' => $settings_file, '@drupal' => drupal_install_profile_distribution_name(), '%driver' => $driver));
+ }
+ else {
+ // Run driver specific validation
+ $errors += $database_types[$driver]->validateDatabaseSettings($database);
+
+ // Run tasks associated with the database type. Any errors are caught in the
+ // calling function.
+ $databases['default']['default'] = $database;
+ // Just changing the global doesn't get the new information processed.
+ // We tell tell the Database class to re-parse $databases.
+ Database::parseConnectionInfo();
+
+ try {
+ db_run_tasks($driver);
+ }
+ catch (DatabaseTaskException $e) {
+ // These are generic errors, so we do not have any specific key of the
+ // database connection array to attach them to; therefore, we just put
+ // them in the error array with standard numeric keys.
+ $errors[$driver . '][0'] = $e->getMessage();
+ }
+ }
+ return $errors;
+}
+
+/**
+ * Form API submit for install_settings form.
+ */
+function install_settings_form_submit($form, &$form_state) {
+ global $install_state;
+
+ // Update global settings array and save.
+ $settings['databases'] = array(
+ 'value' => array('default' => array('default' => $form_state['storage']['database'])),
+ 'required' => TRUE,
+ );
+ $settings['drupal_hash_salt'] = array(
+ 'value' => drupal_hash_base64(drupal_random_bytes(55)),
+ 'required' => TRUE,
+ );
+ drupal_rewrite_settings($settings);
+ // Indicate that the settings file has been verified, and check the database
+ // for the last completed task, now that we have a valid connection. This
+ // last step is important since we want to trigger an error if the new
+ // database already has Drupal installed.
+ $install_state['settings_verified'] = TRUE;
+ $install_state['completed_task'] = install_verify_completed_task();
+}
+
+/**
+ * Finds all .profile files.
+ */
+function install_find_profiles() {
+ return file_scan_directory('./profiles', '/\.profile$/', array('key' => 'name'));
+}
+
+/**
+ * Installation task; select which profile to install.
+ *
+ * @param $install_state
+ * An array of information about the current installation state. The chosen
+ * profile will be added here, if it was not already selected previously, as
+ * will a list of all available profiles.
+ *
+ * @return
+ * For interactive installations, a form allowing the profile to be selected,
+ * if the user has a choice that needs to be made. Otherwise, an exception is
+ * thrown if a profile cannot be chosen automatically.
+ */
+function install_select_profile(&$install_state) {
+ $install_state['profiles'] += install_find_profiles();
+ if (empty($install_state['parameters']['profile'])) {
+ // Try to find a profile.
+ $profile = _install_select_profile($install_state['profiles']);
+ if (empty($profile)) {
+ // We still don't have a profile, so display a form for selecting one.
+ // Only do this in the case of interactive installations, since this is
+ // not a real form with submit handlers (the database isn't even set up
+ // yet), rather just a convenience method for setting parameters in the
+ // URL.
+ if ($install_state['interactive']) {
+ include_once DRUPAL_ROOT . '/core/includes/form.inc';
+ drupal_set_title(st('Select an installation profile'));
+ $form = drupal_get_form('install_select_profile_form', $install_state['profiles']);
+ return drupal_render($form);
+ }
+ else {
+ throw new Exception(install_no_profile_error());
+ }
+ }
+ else {
+ $install_state['parameters']['profile'] = $profile;
+ }
+ }
+}
+
+/**
+ * Helper function for automatically selecting an installation profile from a
+ * list or from a selection passed in via $_POST.
+ */
+function _install_select_profile($profiles) {
+ if (sizeof($profiles) == 0) {
+ throw new Exception(install_no_profile_error());
+ }
+ // Don't need to choose profile if only one available.
+ if (sizeof($profiles) == 1) {
+ $profile = array_pop($profiles);
+ // TODO: is this right?
+ require_once DRUPAL_ROOT . '/' . $profile->uri;
+ return $profile->name;
+ }
+ else {
+ foreach ($profiles as $profile) {
+ if (!empty($_POST['profile']) && ($_POST['profile'] == $profile->name)) {
+ return $profile->name;
+ }
+ }
+ }
+}
+
+/**
+ * Form API array definition for the profile selection form.
+ *
+ * @param $form_state
+ * Array of metadata about state of form processing.
+ * @param $profile_files
+ * Array of .profile files, as returned from file_scan_directory().
+ */
+function install_select_profile_form($form, &$form_state, $profile_files) {
+ $profiles = array();
+ $names = array();
+
+ foreach ($profile_files as $profile) {
+ // TODO: is this right?
+ include_once DRUPAL_ROOT . '/' . $profile->uri;
+
+ $details = install_profile_info($profile->name);
+ // Don't show hidden profiles. This is used by to hide the testing profile,
+ // which only exists to speed up test runs.
+ if ($details['hidden'] === TRUE) {
+ continue;
+ }
+ $profiles[$profile->name] = $details;
+
+ // Determine the name of the profile; default to file name if defined name
+ // is unspecified.
+ $name = isset($details['name']) ? $details['name'] : $profile->name;
+ $names[$profile->name] = $name;
+ }
+
+ // Display radio buttons alphabetically by human-readable name, but always
+ // put the core profiles first (if they are present in the filesystem).
+ natcasesort($names);
+ if (isset($names['minimal'])) {
+ // If the expert ("Minimal") core profile is present, put it in front of
+ // any non-core profiles rather than including it with them alphabetically,
+ // since the other profiles might be intended to group together in a
+ // particular way.
+ $names = array('minimal' => $names['minimal']) + $names;
+ }
+ if (isset($names['standard'])) {
+ // If the default ("Standard") core profile is present, put it at the very
+ // top of the list. This profile will have its radio button pre-selected,
+ // so we want it to always appear at the top.
+ $names = array('standard' => $names['standard']) + $names;
+ }
+
+ foreach ($names as $profile => $name) {
+ $form['profile'][$name] = array(
+ '#type' => 'radio',
+ '#value' => 'standard',
+ '#return_value' => $profile,
+ '#title' => $name,
+ '#description' => isset($profiles[$profile]['description']) ? $profiles[$profile]['description'] : '',
+ '#parents' => array('profile'),
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => st('Save and continue'),
+ );
+ return $form;
+}
+
+/**
+ * Find all .po files useful for the installer.
+ */
+function install_find_locales() {
+ $files = install_find_locale_files();
+ // English does not need a translation file.
+ array_unshift($files, (object) array('name' => 'en'));
+ foreach ($files as $key => $file) {
+ // Strip off the file name component before the language code.
+ $files[$key]->langcode = preg_replace('!^(.+\.)?([^\.]+)$!', '\2', $file->name);
+ // Language codes cannot exceed 12 characters to fit into the {languages}
+ // table.
+ if (strlen($files[$key]->langcode) > 12) {
+ unset($files[$key]);
+ }
+ }
+ return $files;
+}
+
+/**
+ * Find installer translations either for a specific langcode or all languages.
+ */
+function install_find_locale_files($langcode = NULL) {
+ $directory = variable_get('locale_translate_file_directory', conf_path() . '/files/translations');
+ $files = file_scan_directory($directory, '!install\.' . (!empty($langcode) ? '\.' . preg_quote($langcode, '!') : '[^\.]+') . '\.po$!', array('recurse' => FALSE));
+ return $files;
+}
+
+/**
+ * Installation task; select which locale to use for the current profile.
+ *
+ * @param $install_state
+ * An array of information about the current installation state. The chosen
+ * locale will be added here, if it was not already selected previously, as
+ * will a list of all available locales.
+ *
+ * @return
+ * For interactive installations, a form or other page output allowing the
+ * locale to be selected or providing information about locale selection, if
+ * a locale has not been chosen. Otherwise, an exception is thrown if a
+ * locale cannot be chosen automatically.
+ */
+function install_select_locale(&$install_state) {
+ // Find all available locales.
+ $profilename = $install_state['parameters']['profile'];
+ $locales = install_find_locales($profilename);
+ $install_state['locales'] += $locales;
+
+ if (!empty($_POST['locale'])) {
+ foreach ($locales as $locale) {
+ if ($_POST['locale'] == $locale->langcode) {
+ $install_state['parameters']['locale'] = $locale->langcode;
+ return;
+ }
+ }
+ }
+
+ if (empty($install_state['parameters']['locale'])) {
+ // If only the built-in (English) language is available, and we are
+ // performing an interactive installation, inform the user that the
+ // installer can be localized. Otherwise we assume the user knows what he
+ // is doing.
+ if (count($locales) == 1) {
+ if ($install_state['interactive']) {
+ drupal_set_title(st('Choose language'));
+ if (!empty($install_state['parameters']['localize'])) {
+ $output = '<p>Follow these steps to translate Drupal into your language:</p>';
+ $output .= '<ol>';
+ $output .= '<li>Download a translation from the <a href="http://localize.drupal.org/download" target="_blank">translation server</a>.</li>';
+ $output .= '<li>Place it into the following directory:
+<pre>
+/profiles/' . $profilename . '/translations/
+</pre></li>';
+ $output .= '</ol>';
+ $output .= '<p>For more information on installing Drupal in different languages, visit the <a href="http://drupal.org/localize" target="_blank">drupal.org handbook page</a>.</p>';
+ $output .= '<p>How should the installation continue?</p>';
+ $output .= '<ul>';
+ $output .= '<li><a href="install.php?profile=' . $profilename . '">Reload the language selection page after adding translations</a></li>';
+ $output .= '<li><a href="install.php?profile=' . $profilename . '&amp;locale=en">Continue installation in English</a></li>';
+ $output .= '</ul>';
+ }
+ else {
+ include_once DRUPAL_ROOT . '/core/includes/form.inc';
+ $elements = drupal_get_form('install_select_locale_form', $locales, $profilename);
+ $output = drupal_render($elements);
+ }
+ return $output;
+ }
+ // One language, but not an interactive installation. Assume the user
+ // knows what he is doing.
+ $locale = current($locales);
+ $install_state['parameters']['locale'] = $locale->name;
+ return;
+ }
+ else {
+ // Allow profile to pre-select the language, skipping the selection.
+ $function = $profilename . '_profile_details';
+ if (function_exists($function)) {
+ $details = $function();
+ if (isset($details['language'])) {
+ foreach ($locales as $locale) {
+ if ($details['language'] == $locale->name) {
+ $install_state['parameters']['locale'] = $locale->name;
+ return;
+ }
+ }
+ }
+ }
+
+ // We still don't have a locale, so display a form for selecting one.
+ // Only do this in the case of interactive installations, since this is
+ // not a real form with submit handlers (the database isn't even set up
+ // yet), rather just a convenience method for setting parameters in the
+ // URL.
+ if ($install_state['interactive']) {
+ drupal_set_title(st('Choose language'));
+ include_once DRUPAL_ROOT . '/core/includes/form.inc';
+ $elements = drupal_get_form('install_select_locale_form', $locales, $profilename);
+ return drupal_render($elements);
+ }
+ else {
+ throw new Exception(st('Sorry, you must select a language to continue the installation.'));
+ }
+ }
+ }
+}
+
+/**
+ * Form API array definition for language selection.
+ */
+function install_select_locale_form($form, &$form_state, $locales, $profilename) {
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $languages = standard_language_list();
+ foreach ($locales as $locale) {
+ $name = $locale->langcode;
+ if (isset($languages[$name])) {
+ $name = $languages[$name][0] . (isset($languages[$name][1]) ? ' ' . st('(@language)', array('@language' => $languages[$name][1])) : '');
+ }
+ $form['locale'][$locale->langcode] = array(
+ '#type' => 'radio',
+ '#return_value' => $locale->langcode,
+ '#default_value' => $locale->langcode == 'en' ? 'en' : '',
+ '#title' => $name . ($locale->langcode == 'en' ? ' ' . st('(built-in)') : ''),
+ '#parents' => array('locale')
+ );
+ }
+ if (count($locales) == 1) {
+ $form['help'] = array(
+ '#markup' => '<p><a href="install.php?profile=' . $profilename . '&amp;localize=true">' . st('Learn how to install Drupal in other languages') . '</a></p>',
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => st('Save and continue'),
+ );
+ return $form;
+}
+
+/**
+ * Indicates that there are no profiles available.
+ */
+function install_no_profile_error() {
+ drupal_set_title(st('No profiles available'));
+ return st('We were unable to find any installation profiles. Installation profiles tell us what modules to enable and what schema to install in the database. A profile is necessary to continue with the installation process.');
+}
+
+/**
+ * Indicates that Drupal has already been installed.
+ */
+function install_already_done_error() {
+ global $base_url;
+
+ drupal_set_title(st('Drupal already installed'));
+ return st('<ul><li>To start over, you must empty your existing database.</li><li>To install to a different database, edit the appropriate <em>settings.php</em> file in the <em>sites</em> folder.</li><li>To upgrade an existing installation, proceed to the <a href="@base-url/update.php">update script</a>.</li><li>View your <a href="@base-url">existing site</a>.</li></ul>', array('@base-url' => $base_url));
+}
+
+/**
+ * Installation task; load information about the chosen profile.
+ *
+ * @param $install_state
+ * An array of information about the current installation state. The loaded
+ * profile information will be added here, or an exception will be thrown if
+ * the profile cannot be loaded.
+ */
+function install_load_profile(&$install_state) {
+ $profile_file = DRUPAL_ROOT . '/profiles/' . $install_state['parameters']['profile'] . '/' . $install_state['parameters']['profile'] . '.profile';
+ if (is_file($profile_file)) {
+ include_once $profile_file;
+ $install_state['profile_info'] = install_profile_info($install_state['parameters']['profile'], $install_state['parameters']['locale']);
+ }
+ else {
+ throw new Exception(st('Sorry, the profile you have chosen cannot be loaded.'));
+ }
+}
+
+/**
+ * Installation task; perform a full bootstrap of Drupal.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ */
+function install_bootstrap_full(&$install_state) {
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+}
+
+/**
+ * Installation task; install required modules via a batch process.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The batch definition.
+ */
+function install_profile_modules(&$install_state) {
+ $modules = variable_get('install_profile_modules', array());
+ $files = system_rebuild_module_data();
+ variable_del('install_profile_modules');
+
+ // Always install required modules first. Respect the dependencies between
+ // the modules.
+ $required = array();
+ $non_required = array();
+ // Although the profile module is marked as required, it needs to go after
+ // every dependency, including non-required ones. So clear its required
+ // flag for now to allow it to install late.
+ $files[$install_state['parameters']['profile']]->info['required'] = FALSE;
+ // Add modules that other modules depend on.
+ foreach ($modules as $module) {
+ if ($files[$module]->requires) {
+ $modules = array_merge($modules, array_keys($files[$module]->requires));
+ }
+ }
+ $modules = array_unique($modules);
+ foreach ($modules as $module) {
+ if (!empty($files[$module]->info['required'])) {
+ $required[$module] = $files[$module]->sort;
+ }
+ else {
+ $non_required[$module] = $files[$module]->sort;
+ }
+ }
+ arsort($required);
+ arsort($non_required);
+
+ $operations = array();
+ foreach ($required + $non_required as $module => $weight) {
+ $operations[] = array('_install_module_batch', array($module, $files[$module]->info['name']));
+ }
+ $batch = array(
+ 'operations' => $operations,
+ 'title' => st('Installing @drupal', array('@drupal' => drupal_install_profile_distribution_name())),
+ 'error_message' => st('The installation has encountered an error.'),
+ 'finished' => '_install_profile_modules_finished',
+ );
+ return $batch;
+}
+
+/**
+ * Installation task; import languages via a batch process.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The batch definition, if there are language files to import.
+ */
+function install_import_locales(&$install_state) {
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc';
+ $install_locale = $install_state['parameters']['locale'];
+
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $predefined = standard_language_list();
+ if (!isset($predefined[$install_locale])) {
+ // Drupal does not know about this language, so we prefill its values with
+ // our best guess. The user will be able to edit afterwards.
+ $language = (object) array(
+ 'language' => $install_locale,
+ 'name' => $install_locale,
+ 'default' => TRUE,
+ );
+ locale_language_save($language);
+ }
+ else {
+ // A known predefined language, details will be filled in properly.
+ $language = (object) array(
+ 'language' => $install_locale,
+ 'default' => TRUE,
+ );
+ locale_language_save($language);
+ }
+
+ // Collect files to import for this language.
+ $batch = locale_translate_batch_import_files($install_locale);
+ if (!empty($batch)) {
+ return $batch;
+ }
+}
+
+/**
+ * Installation task; configure settings for the new site.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The form API definition for the site configuration form.
+ */
+function install_configure_form($form, &$form_state, &$install_state) {
+ if (variable_get('site_name', FALSE) || variable_get('site_mail', FALSE)) {
+ // Site already configured: This should never happen, means re-running the
+ // installer, possibly by an attacker after the 'install_task' variable got
+ // accidentally blown somewhere. Stop it now.
+ throw new Exception(install_already_done_error());
+ }
+
+ drupal_set_title(st('Configure site'));
+
+ // Warn about settings.php permissions risk
+ $settings_dir = conf_path();
+ $settings_file = $settings_dir . '/settings.php';
+ // Check that $_POST is empty so we only show this message when the form is
+ // first displayed, not on the next page after it is submitted. (We do not
+ // want to repeat it multiple times because it is a general warning that is
+ // not related to the rest of the installation process; it would also be
+ // especially out of place on the last page of the installer, where it would
+ // distract from the message that the Drupal installation has completed
+ // successfully.)
+ if (empty($_POST) && (!drupal_verify_install_file(DRUPAL_ROOT . '/' . $settings_file, FILE_EXIST|FILE_READABLE|FILE_NOT_WRITABLE) || !drupal_verify_install_file(DRUPAL_ROOT . '/' . $settings_dir, FILE_NOT_WRITABLE, 'dir'))) {
+ drupal_set_message(st('All necessary changes to %dir and %file have been made, so you should remove write permissions to them now in order to avoid security risks. If you are unsure how to do so, consult the <a href="@handbook_url">online handbook</a>.', array('%dir' => $settings_dir, '%file' => $settings_file, '@handbook_url' => 'http://drupal.org/server-permissions')), 'warning');
+ }
+
+ drupal_add_js(drupal_get_path('module', 'system') . '/system.js');
+ // Add JavaScript time zone detection.
+ drupal_add_js('core/misc/timezone.js');
+ // We add these strings as settings because JavaScript translation does not
+ // work on install time.
+ drupal_add_js(array('copyFieldValue' => array('edit-site-mail' => array('edit-account-mail'))), 'setting');
+ drupal_add_js('jQuery(function () { Drupal.cleanURLsInstallCheck(); });', 'inline');
+ // Add JS to show / hide the 'Email administrator about site updates' elements
+ drupal_add_js('jQuery(function () { Drupal.hideEmailAdministratorCheckbox() });', 'inline');
+ // Build menu to allow clean URL check.
+ menu_rebuild();
+
+ // Cache a fully-built schema. This is necessary for any invocation of
+ // index.php because: (1) setting cache table entries requires schema
+ // information, (2) that occurs during bootstrap before any module are
+ // loaded, so (3) if there is no cached schema, drupal_get_schema() will
+ // try to generate one but with no loaded modules will return nothing.
+ //
+ // This logically could be done during the 'install_finished' task, but the
+ // clean URL check requires it now.
+ drupal_get_schema(NULL, TRUE);
+
+ // Return the form.
+ return _install_configure_form($form, $form_state, $install_state);
+}
+
+/**
+ * Installation task; finish importing files at end of installation.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * The batch definition, if there are language files to import.
+ *
+ * @todo
+ * This currently does the same as the first import step. Need to revisit
+ * once we have l10n_update functionality integrated. See
+ * http://drupal.org/node/1191488.
+ */
+function install_import_locales_remaining(&$install_state) {
+ include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc';
+ return locale_translate_batch_import_files($install_state['parameters']['locale']);
+}
+
+/**
+ * Installation task; perform final steps and display a 'finished' page.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ *
+ * @return
+ * A message informing the user that the installation is complete.
+ */
+function install_finished(&$install_state) {
+ drupal_set_title(st('@drupal installation complete', array('@drupal' => drupal_install_profile_distribution_name())), PASS_THROUGH);
+ $messages = drupal_set_message();
+ $output = '<p>' . st('Congratulations, you installed @drupal!', array('@drupal' => drupal_install_profile_distribution_name())) . '</p>';
+ $output .= '<p>' . (isset($messages['error']) ? st('Review the messages above before visiting <a href="@url">your new site</a>.', array('@url' => url(''))) : st('<a href="@url">Visit your new site</a>.', array('@url' => url('')))) . '</p>';
+
+ // Flush all caches to ensure that any full bootstraps during the installer
+ // do not leave stale cached data, and that any content types or other items
+ // registered by the install profile are registered correctly.
+ drupal_flush_all_caches();
+
+ // Remember the profile which was used.
+ variable_set('install_profile', drupal_get_profile());
+
+ // Install profiles are always loaded last
+ db_update('system')
+ ->fields(array('weight' => 1000))
+ ->condition('type', 'module')
+ ->condition('name', drupal_get_profile())
+ ->execute();
+
+ // Cache a fully-built schema.
+ drupal_get_schema(NULL, TRUE);
+
+ // Run cron to populate update status tables (if available) so that users
+ // will be warned if they've installed an out of date Drupal version.
+ // Will also trigger indexing of profile-supplied content or feeds.
+ drupal_cron_run();
+
+ return $output;
+}
+
+/**
+ * Batch callback for batch installation of modules.
+ */
+function _install_module_batch($module, $module_name, &$context) {
+ // Install and enable the module right away, so that the module will be
+ // loaded by drupal_bootstrap in subsequent batch requests, and other
+ // modules possibly depending on it can safely perform their installation
+ // steps.
+ module_enable(array($module), FALSE);
+ $context['results'][] = $module;
+ $context['message'] = st('Installed %module module.', array('%module' => $module_name));
+}
+
+/**
+ * 'Finished' callback for module installation batch.
+ */
+function _install_profile_modules_finished($success, $results, $operations) {
+ // Flush all caches to complete the module installation process. Subsequent
+ // installation tasks will now have full access to the profile's modules.
+ drupal_flush_all_caches();
+}
+
+/**
+ * Checks installation requirements and reports any errors.
+ */
+function install_check_requirements($install_state) {
+ $profile = $install_state['parameters']['profile'];
+
+ // Check the profile requirements.
+ $requirements = drupal_check_profile($profile);
+
+ // If Drupal is not set up already, we need to create a settings file.
+ if (!$install_state['settings_verified']) {
+ $writable = FALSE;
+ $conf_path = './' . conf_path(FALSE, TRUE);
+ $settings_file = $conf_path . '/settings.php';
+ $default_settings_file = './sites/default/default.settings.php';
+ $file = $conf_path;
+ $exists = FALSE;
+ // Verify that the directory exists.
+ if (drupal_verify_install_file($conf_path, FILE_EXIST, 'dir')) {
+ // Check if a settings.php file already exists.
+ $file = $settings_file;
+ if (drupal_verify_install_file($settings_file, FILE_EXIST)) {
+ // If it does, make sure it is writable.
+ $writable = drupal_verify_install_file($settings_file, FILE_READABLE|FILE_WRITABLE);
+ $exists = TRUE;
+ }
+ }
+
+ // If default.settings.php does not exist, or is not readable, throw an
+ // error.
+ if (!drupal_verify_install_file($default_settings_file, FILE_EXIST|FILE_READABLE)) {
+ $requirements['default settings file exists'] = array(
+ 'title' => st('Default settings file'),
+ 'value' => st('The default settings file does not exist.'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => st('The @drupal installer requires that the %default-file file not be modified in any way from the original download.', array('@drupal' => drupal_install_profile_distribution_name(), '%default-file' => $default_settings_file)),
+ );
+ }
+ // Otherwise, if settings.php does not exist yet, we can try to copy
+ // default.settings.php to create it.
+ elseif (!$exists) {
+ $copied = drupal_verify_install_file($conf_path, FILE_EXIST|FILE_WRITABLE, 'dir') && @copy($default_settings_file, $settings_file);
+ if ($copied) {
+ // If the new settings file has the same owner as default.settings.php,
+ // this means default.settings.php is owned by the webserver user.
+ // This is an inherent security weakness because it allows a malicious
+ // webserver process to append arbitrary PHP code and then execute it.
+ // However, it is also a common configuration on shared hosting, and
+ // there is nothing Drupal can do to prevent it. In this situation,
+ // having settings.php also owned by the webserver does not introduce
+ // any additional security risk, so we keep the file in place.
+ if (fileowner($default_settings_file) === fileowner($settings_file)) {
+ $writable = drupal_verify_install_file($settings_file, FILE_READABLE|FILE_WRITABLE);
+ $exists = TRUE;
+ }
+ // If settings.php and default.settings.php have different owners, this
+ // probably means the server is set up "securely" (with the webserver
+ // running as its own user, distinct from the user who owns all the
+ // Drupal PHP files), although with either a group or world writable
+ // sites directory. Keeping settings.php owned by the webserver would
+ // therefore introduce a security risk. It would also cause a usability
+ // problem, since site owners who do not have root access to the file
+ // system would be unable to edit their settings file later on. We
+ // therefore must delete the file we just created and force the
+ // administrator to log on to the server and create it manually.
+ else {
+ $deleted = @drupal_unlink($settings_file);
+ // We expect deleting the file to be successful (since we just
+ // created it ourselves above), but if it fails somehow, we set a
+ // variable so we can display a one-time error message to the
+ // administrator at the bottom of the requirements list. We also try
+ // to make the file writable, to eliminate any conflicting error
+ // messages in the requirements list.
+ $exists = !$deleted;
+ if ($exists) {
+ $settings_file_ownership_error = TRUE;
+ $writable = drupal_verify_install_file($settings_file, FILE_READABLE|FILE_WRITABLE);
+ }
+ }
+ }
+ }
+
+ // If settings.php does not exist, throw an error.
+ if (!$exists) {
+ $requirements['settings file exists'] = array(
+ 'title' => st('Settings file'),
+ 'value' => st('The settings file does not exist.'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => st('The @drupal installer requires that you create a settings file as part of the installation process. Copy the %default_file file to %file. More details about installing Drupal are available in <a href="@install_txt">INSTALL.txt</a>.', array('@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, '%default_file' => $default_settings_file, '@install_txt' => base_path() . 'core/INSTALL.txt')),
+ );
+ }
+ else {
+ $requirements['settings file exists'] = array(
+ 'title' => st('Settings file'),
+ 'value' => st('The %file file exists.', array('%file' => $file)),
+ );
+ // If settings.php is not writable, throw an error.
+ if (!$writable) {
+ $requirements['settings file writable'] = array(
+ 'title' => st('Settings file'),
+ 'value' => st('The settings file is not writable.'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => st('The @drupal installer requires write permissions to %file during the installation process. If you are unsure how to grant file permissions, consult the <a href="@handbook_url">online handbook</a>.', array('@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, '@handbook_url' => 'http://drupal.org/server-permissions')),
+ );
+ }
+ else {
+ $requirements['settings file'] = array(
+ 'title' => st('Settings file'),
+ 'value' => st('The settings file is writable.'),
+ );
+ }
+ if (!empty($settings_file_ownership_error)) {
+ $requirements['settings file ownership'] = array(
+ 'title' => st('Settings file'),
+ 'value' => st('The settings file is owned by the web server.'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => st('The @drupal installer failed to create a settings file with proper file ownership. Log on to your web server, remove the existing %file file, and create a new one by copying the %default_file file to %file. More details about installing Drupal are available in <a href="@install_txt">INSTALL.txt</a>. If you have problems with the file permissions on your server, consult the <a href="@handbook_url">online handbook</a>.', array('@drupal' => drupal_install_profile_distribution_name(), '%file' => $file, '%default_file' => $default_settings_file, '@install_txt' => base_path() . 'core/INSTALL.txt', '@handbook_url' => 'http://drupal.org/server-permissions')),
+ );
+ }
+ }
+ }
+ return $requirements;
+}
+
+/**
+ * Forms API array definition for site configuration.
+ */
+function _install_configure_form($form, &$form_state, &$install_state) {
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+
+ $form['site_information'] = array(
+ '#type' => 'fieldset',
+ '#title' => st('Site information'),
+ '#collapsible' => FALSE,
+ );
+ $form['site_information']['site_name'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Site name'),
+ '#required' => TRUE,
+ '#weight' => -20,
+ );
+ $form['site_information']['site_mail'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Site e-mail address'),
+ '#default_value' => ini_get('sendmail_from'),
+ '#description' => st("Automated e-mails, such as registration information, will be sent from this address. Use an address ending in your site's domain to help prevent these e-mails from being flagged as spam."),
+ '#required' => TRUE,
+ '#weight' => -15,
+ );
+ $form['admin_account'] = array(
+ '#type' => 'fieldset',
+ '#title' => st('Site maintenance account'),
+ '#collapsible' => FALSE,
+ );
+
+ $form['admin_account']['account']['#tree'] = TRUE;
+ $form['admin_account']['account']['name'] = array('#type' => 'textfield',
+ '#title' => st('Username'),
+ '#maxlength' => USERNAME_MAX_LENGTH,
+ '#description' => st('Spaces are allowed; punctuation is not allowed except for periods, hyphens, and underscores.'),
+ '#required' => TRUE,
+ '#weight' => -10,
+ '#attributes' => array('class' => array('username')),
+ );
+
+ $form['admin_account']['account']['mail'] = array('#type' => 'textfield',
+ '#title' => st('E-mail address'),
+ '#maxlength' => EMAIL_MAX_LENGTH,
+ '#required' => TRUE,
+ '#weight' => -5,
+ );
+ $form['admin_account']['account']['pass'] = array(
+ '#type' => 'password_confirm',
+ '#required' => TRUE,
+ '#size' => 25,
+ '#weight' => 0,
+ );
+
+ $form['server_settings'] = array(
+ '#type' => 'fieldset',
+ '#title' => st('Server settings'),
+ '#collapsible' => FALSE,
+ );
+
+ $countries = country_get_list();
+ $form['server_settings']['site_default_country'] = array(
+ '#type' => 'select',
+ '#title' => st('Default country'),
+ '#empty_value' => '',
+ '#default_value' => variable_get('site_default_country', NULL),
+ '#options' => $countries,
+ '#description' => st('Select the default country for the site.'),
+ '#weight' => 0,
+ );
+
+ $form['server_settings']['date_default_timezone'] = array(
+ '#type' => 'select',
+ '#title' => st('Default time zone'),
+ '#default_value' => date_default_timezone_get(),
+ '#options' => system_time_zones(),
+ '#description' => st('By default, dates in this site will be displayed in the chosen time zone.'),
+ '#weight' => 5,
+ '#attributes' => array('class' => array('timezone-detect')),
+ );
+
+ $form['server_settings']['clean_url'] = array(
+ '#type' => 'hidden',
+ '#default_value' => 0,
+ '#attributes' => array('id' => 'edit-clean-url', 'class' => array('install')),
+ );
+
+ $form['update_notifications'] = array(
+ '#type' => 'fieldset',
+ '#title' => st('Update notifications'),
+ '#collapsible' => FALSE,
+ );
+ $form['update_notifications']['update_status_module'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array(
+ 1 => st('Check for updates automatically'),
+ 2 => st('Receive e-mail notifications'),
+ ),
+ '#default_value' => array(1, 2),
+ '#description' => st('The system will notify you when updates and important security releases are available for installed components. Anonymous information about your site is sent to <a href="@drupal">Drupal.org</a>.', array('@drupal' => 'http://drupal.org')),
+ '#weight' => 15,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => st('Save and continue'),
+ '#weight' => 15,
+ );
+
+ return $form;
+}
+
+/**
+ * Forms API validate for the site configuration form.
+ */
+function install_configure_form_validate($form, &$form_state) {
+ if ($error = user_validate_name($form_state['values']['account']['name'])) {
+ form_error($form['admin_account']['account']['name'], $error);
+ }
+ if ($error = user_validate_mail($form_state['values']['account']['mail'])) {
+ form_error($form['admin_account']['account']['mail'], $error);
+ }
+ if ($error = user_validate_mail($form_state['values']['site_mail'])) {
+ form_error($form['site_information']['site_mail'], $error);
+ }
+}
+
+/**
+ * Forms API submit for the site configuration form.
+ */
+function install_configure_form_submit($form, &$form_state) {
+ global $user;
+
+ variable_set('site_name', $form_state['values']['site_name']);
+ variable_set('site_mail', $form_state['values']['site_mail']);
+ variable_set('date_default_timezone', $form_state['values']['date_default_timezone']);
+ variable_set('site_default_country', $form_state['values']['site_default_country']);
+
+ // Enable update.module if this option was selected.
+ if ($form_state['values']['update_status_module'][1]) {
+ module_enable(array('update'), FALSE);
+
+ // Add the site maintenance account's email address to the list of
+ // addresses to be notified when updates are available, if selected.
+ if ($form_state['values']['update_status_module'][2]) {
+ variable_set('update_notify_emails', array($form_state['values']['account']['mail']));
+ }
+ }
+
+ // We precreated user 1 with placeholder values. Let's save the real values.
+ $account = user_load(1);
+ $merge_data = array('init' => $form_state['values']['account']['mail'], 'roles' => !empty($account->roles) ? $account->roles : array(), 'status' => 1);
+ user_save($account, array_merge($form_state['values']['account'], $merge_data));
+ // Load global $user and perform final login tasks.
+ $user = user_load(1);
+ user_login_finalize();
+
+ if (isset($form_state['values']['clean_url'])) {
+ variable_set('clean_url', $form_state['values']['clean_url']);
+ }
+
+ // Record when this install ran.
+ variable_set('install_time', $_SERVER['REQUEST_TIME']);
+}
diff --git a/core/includes/install.inc b/core/includes/install.inc
new file mode 100644
index 000000000000..514d89c3554c
--- /dev/null
+++ b/core/includes/install.inc
@@ -0,0 +1,1307 @@
+<?php
+
+/**
+ * Indicates that a module has not been installed yet.
+ */
+define('SCHEMA_UNINSTALLED', -1);
+
+/**
+ * Indicates that a module has been installed.
+ */
+define('SCHEMA_INSTALLED', 0);
+
+/**
+ * Requirement severity -- Informational message only.
+ */
+define('REQUIREMENT_INFO', -1);
+
+/**
+ * Requirement severity -- Requirement successfully met.
+ */
+define('REQUIREMENT_OK', 0);
+
+/**
+ * Requirement severity -- Warning condition; proceed but flag warning.
+ */
+define('REQUIREMENT_WARNING', 1);
+
+/**
+ * Requirement severity -- Error condition; abort installation.
+ */
+define('REQUIREMENT_ERROR', 2);
+
+/**
+ * File permission check -- File exists.
+ */
+define('FILE_EXIST', 1);
+
+/**
+ * File permission check -- File is readable.
+ */
+define('FILE_READABLE', 2);
+
+/**
+ * File permission check -- File is writable.
+ */
+define('FILE_WRITABLE', 4);
+
+/**
+ * File permission check -- File is executable.
+ */
+define('FILE_EXECUTABLE', 8);
+
+/**
+ * File permission check -- File does not exist.
+ */
+define('FILE_NOT_EXIST', 16);
+
+/**
+ * File permission check -- File is not readable.
+ */
+define('FILE_NOT_READABLE', 32);
+
+/**
+ * File permission check -- File is not writable.
+ */
+define('FILE_NOT_WRITABLE', 64);
+
+/**
+ * File permission check -- File is not executable.
+ */
+define('FILE_NOT_EXECUTABLE', 128);
+
+/**
+ * Initialize the update system by loading all installed module's .install files.
+ */
+function drupal_load_updates() {
+ foreach (drupal_get_installed_schema_version(NULL, FALSE, TRUE) as $module => $schema_version) {
+ if ($schema_version > -1) {
+ module_load_install($module);
+ }
+ }
+}
+
+/**
+ * Returns an array of available schema versions for a module.
+ *
+ * @param $module
+ * A module name.
+ * @return
+ * If the module has updates, an array of available updates sorted by version.
+ * Otherwise, FALSE.
+ */
+function drupal_get_schema_versions($module) {
+ $updates = &drupal_static(__FUNCTION__, NULL);
+ if (!isset($updates[$module])) {
+ $updates = array();
+
+ foreach (module_list() as $loaded_module) {
+ $updates[$loaded_module] = array();
+ }
+
+ // Prepare regular expression to match all possible defined hook_update_N().
+ $regexp = '/^(?P<module>.+)_update_(?P<version>\d+)$/';
+ $functions = get_defined_functions();
+ // Narrow this down to functions ending with an integer, since all
+ // hook_update_N() functions end this way, and there are other
+ // possible functions which match '_update_'. We use preg_grep() here
+ // instead of foreaching through all defined functions, since the loop
+ // through all PHP functions can take significant page execution time
+ // and this function is called on every administrative page via
+ // system_requirements().
+ foreach (preg_grep('/_\d+$/', $functions['user']) as $function) {
+ // If this function is a module update function, add it to the list of
+ // module updates.
+ if (preg_match($regexp, $function, $matches)) {
+ $updates[$matches['module']][] = $matches['version'];
+ }
+ }
+ // Ensure that updates are applied in numerical order.
+ foreach ($updates as &$module_updates) {
+ sort($module_updates, SORT_NUMERIC);
+ }
+ }
+ return empty($updates[$module]) ? FALSE : $updates[$module];
+}
+
+/**
+ * Returns the currently installed schema version for a module.
+ *
+ * @param $module
+ * A module name.
+ * @param $reset
+ * Set to TRUE after modifying the system table.
+ * @param $array
+ * Set to TRUE if you want to get information about all modules in the
+ * system.
+ * @return
+ * The currently installed schema version, or SCHEMA_UNINSTALLED if the
+ * module is not installed.
+ */
+function drupal_get_installed_schema_version($module, $reset = FALSE, $array = FALSE) {
+ static $versions = array();
+
+ if ($reset) {
+ $versions = array();
+ }
+
+ if (!$versions) {
+ $versions = array();
+ $result = db_query("SELECT name, schema_version FROM {system} WHERE type = :type", array(':type' => 'module'));
+ foreach ($result as $row) {
+ $versions[$row->name] = $row->schema_version;
+ }
+ }
+
+ if ($array) {
+ return $versions;
+ }
+ else {
+ return isset($versions[$module]) ? $versions[$module] : SCHEMA_UNINSTALLED;
+ }
+}
+
+/**
+ * Update the installed version information for a module.
+ *
+ * @param $module
+ * A module name.
+ * @param $version
+ * The new schema version.
+ */
+function drupal_set_installed_schema_version($module, $version) {
+ db_update('system')
+ ->fields(array('schema_version' => $version))
+ ->condition('name', $module)
+ ->execute();
+
+ // Reset the static cache of module schema versions.
+ drupal_get_installed_schema_version(NULL, TRUE);
+}
+
+/**
+ * Loads the install profile, extracting its defined distribution name.
+ *
+ * @return
+ * The distribution name defined in the profile's .info file. Defaults to
+ * "Drupal" if none is explicitly provided by the install profile.
+ *
+ * @see install_profile_info()
+ */
+function drupal_install_profile_distribution_name() {
+ // During installation, the profile information is stored in the global
+ // installation state (it might not be saved anywhere yet).
+ if (drupal_installation_attempted()) {
+ global $install_state;
+ return $install_state['profile_info']['distribution_name'];
+ }
+ // At all other times, we load the profile via standard methods.
+ else {
+ $profile = drupal_get_profile();
+ $info = system_get_info('module', $profile);
+ return $info['distribution_name'];
+ }
+}
+
+/**
+ * Auto detect the base_url with PHP predefined variables.
+ *
+ * @param $file
+ * The name of the file calling this function so we can strip it out of
+ * the URI when generating the base_url.
+ * @return
+ * The auto-detected $base_url that should be configured in settings.php
+ */
+function drupal_detect_baseurl($file = 'core/install.php') {
+ $proto = $_SERVER['HTTPS'] ? 'https://' : 'http://';
+ $host = $_SERVER['SERVER_NAME'];
+ $port = ($_SERVER['SERVER_PORT'] == 80 ? '' : ':' . $_SERVER['SERVER_PORT']);
+ $uri = preg_replace("/\?.*/", '', $_SERVER['REQUEST_URI']);
+ $dir = str_replace("/$file", '', $uri);
+
+ return "$proto$host$port$dir";
+}
+
+/**
+ * Detect all supported databases that are compiled into PHP.
+ *
+ * @return
+ * An array of database types compiled into PHP.
+ */
+function drupal_detect_database_types() {
+ $databases = drupal_get_database_types();
+
+ foreach ($databases as $driver => $installer) {
+ $databases[$driver] = $installer->name();
+ }
+
+ return $databases;
+}
+
+/**
+ * Return all supported database installer objects that are compiled into PHP.
+ *
+ * @return
+ * An array of database installer objects compiled into PHP.
+ */
+function drupal_get_database_types() {
+ $databases = array();
+
+ // We define a driver as a directory in /core/includes/database that in turn
+ // contains a database.inc file. That allows us to drop in additional drivers
+ // without modifying the installer.
+ // Because we have no registry yet, we need to also include the install.inc
+ // file for the driver explicitly.
+ require_once DRUPAL_ROOT . '/core/includes/database/database.inc';
+ foreach (file_scan_directory(DRUPAL_ROOT . '/core/includes/database', '/^[a-z]*$/i', array('recurse' => FALSE)) as $file) {
+ if (file_exists($file->uri . '/database.inc') && file_exists($file->uri . '/install.inc')) {
+ $drivers[$file->filename] = $file->uri;
+ }
+ }
+
+ foreach ($drivers as $driver => $file) {
+ $installer = db_installer_object($driver);
+ if ($installer->installable()) {
+ $databases[$driver] = $installer;
+ }
+ }
+
+ // Usability: unconditionally put the MySQL driver on top.
+ if (isset($databases['mysql'])) {
+ $mysql_database = $databases['mysql'];
+ unset($databases['mysql']);
+ $databases = array('mysql' => $mysql_database) + $databases;
+ }
+
+ return $databases;
+}
+
+/**
+ * Database installer structure.
+ *
+ * Defines basic Drupal requirements for databases.
+ */
+abstract class DatabaseTasks {
+
+ /**
+ * Structure that describes each task to run.
+ *
+ * @var array
+ *
+ * Each value of the tasks array is an associative array defining the function
+ * to call (optional) and any arguments to be passed to the function.
+ */
+ protected $tasks = array(
+ array(
+ 'function' => 'checkEngineVersion',
+ 'arguments' => array(),
+ ),
+ array(
+ 'arguments' => array(
+ 'CREATE TABLE {drupal_install_test} (id int NULL)',
+ 'Drupal can use CREATE TABLE database commands.',
+ 'Failed to <strong>CREATE</strong> a test table on your database server with the command %query. The server reports the following message: %error.<p>Are you sure the configured username has the necessary permissions to create tables in the database?</p>',
+ TRUE,
+ ),
+ ),
+ array(
+ 'arguments' => array(
+ 'INSERT INTO {drupal_install_test} (id) VALUES (1)',
+ 'Drupal can use INSERT database commands.',
+ 'Failed to <strong>INSERT</strong> a value into a test table on your database server. We tried inserting a value with the command %query and the server reported the following error: %error.',
+ ),
+ ),
+ array(
+ 'arguments' => array(
+ 'UPDATE {drupal_install_test} SET id = 2',
+ 'Drupal can use UPDATE database commands.',
+ 'Failed to <strong>UPDATE</strong> a value in a test table on your database server. We tried updating a value with the command %query and the server reported the following error: %error.',
+ ),
+ ),
+ array(
+ 'arguments' => array(
+ 'DELETE FROM {drupal_install_test}',
+ 'Drupal can use DELETE database commands.',
+ 'Failed to <strong>DELETE</strong> a value from a test table on your database server. We tried deleting a value with the command %query and the server reported the following error: %error.',
+ ),
+ ),
+ array(
+ 'arguments' => array(
+ 'DROP TABLE {drupal_install_test}',
+ 'Drupal can use DROP TABLE database commands.',
+ 'Failed to <strong>DROP</strong> a test table from your database server. We tried dropping a table with the command %query and the server reported the following error %error.',
+ ),
+ ),
+ );
+
+ /**
+ * Results from tasks.
+ *
+ * @var array
+ */
+ protected $results = array();
+
+ /**
+ * Ensure the PDO driver is supported by the version of PHP in use.
+ */
+ protected function hasPdoDriver() {
+ return in_array($this->pdoDriver, PDO::getAvailableDrivers());
+ }
+
+ /**
+ * Assert test as failed.
+ */
+ protected function fail($message) {
+ $this->results[$message] = FALSE;
+ }
+
+ /**
+ * Assert test as a pass.
+ */
+ protected function pass($message) {
+ $this->results[$message] = TRUE;
+ }
+
+ /**
+ * Check whether Drupal is installable on the database.
+ */
+ public function installable() {
+ return $this->hasPdoDriver() && empty($this->error);
+ }
+
+ /**
+ * Return the human-readable name of the driver.
+ */
+ abstract public function name();
+
+ /**
+ * Return the minimum required version of the engine.
+ *
+ * @return
+ * A version string. If not NULL, it will be checked against the version
+ * reported by the Database engine using version_compare().
+ */
+ public function minimumVersion() {
+ return NULL;
+ }
+
+ /**
+ * Run database tasks and tests to see if Drupal can run on the database.
+ */
+ public function runTasks() {
+ // We need to establish a connection before we can run tests.
+ if ($this->connect()) {
+ foreach ($this->tasks as $task) {
+ if (!isset($task['function'])) {
+ $task['function'] = 'runTestQuery';
+ }
+ if (method_exists($this, $task['function'])) {
+ // Returning false is fatal. No other tasks can run.
+ if (FALSE === call_user_func_array(array($this, $task['function']), $task['arguments'])) {
+ break;
+ }
+ }
+ else {
+ throw new DatabaseTaskException(st("Failed to run all tasks against the database server. The task %task wasn't found.", array('%task' => $task['function'])));
+ }
+ }
+ }
+ // Check for failed results and compile message
+ $message = '';
+ foreach ($this->results as $result => $success) {
+ if (!$success) {
+ $message .= '<p class="error">' . $result . '</p>';
+ }
+ }
+ if (!empty($message)) {
+ $message = '<p>In order for Drupal to work, and to continue with the installation process, you must resolve all issues reported below. For more help with configuring your database server, see the <a href="http://drupal.org/getting-started/install">installation handbook</a>. If you are unsure what any of this means you should probably contact your hosting provider.</p>' . $message;
+ throw new DatabaseTaskException($message);
+ }
+ }
+
+ /**
+ * Check if we can connect to the database.
+ */
+ protected function connect() {
+ try {
+ // This doesn't actually test the connection.
+ db_set_active();
+ // Now actually do a check.
+ Database::getConnection();
+ $this->pass('Drupal can CONNECT to the database ok.');
+ }
+ catch (Exception $e) {
+ $this->fail(st('Failed to connect to your database server. The server reports the following message: %error.<ul><li>Is the database server running?</li><li>Does the database exist, and have you entered the correct database name?</li><li>Have you entered the correct username and password?</li><li>Have you entered the correct database hostname?</li></ul>', array('%error' => $e->getMessage())));
+ return FALSE;
+ }
+ return TRUE;
+ }
+
+ /**
+ * Run SQL tests to ensure the database can execute commands with the current user.
+ */
+ protected function runTestQuery($query, $pass, $fail, $fatal = FALSE) {
+ try {
+ db_query($query);
+ $this->pass(st($pass));
+ }
+ catch (Exception $e) {
+ $this->fail(st($fail, array('%query' => $query, '%error' => $e->getMessage(), '%name' => $this->name())));
+ return !$fatal;
+ }
+ }
+
+ /**
+ * Check the engine version.
+ */
+ protected function checkEngineVersion() {
+ if ($this->minimumVersion() && version_compare(Database::getConnection()->version(), $this->minimumVersion(), '<')) {
+ $this->fail(st("The database version %version is less than the minimum required version %minimum_version.", array('%version' => Database::getConnection()->version(), '%minimum_version' => $this->minimumVersion())));
+ }
+ }
+
+ /**
+ * Return driver specific configuration options.
+ *
+ * @param $database
+ * An array of driver specific configuration options.
+ *
+ * @return
+ * The options form array.
+ */
+ public function getFormOptions($database) {
+ $form['database'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Database name'),
+ '#default_value' => empty($database['database']) ? '' : $database['database'],
+ '#size' => 45,
+ '#required' => TRUE,
+ '#description' => st('The name of the database your @drupal data will be stored in. It must exist on your server before @drupal can be installed.', array('@drupal' => drupal_install_profile_distribution_name())),
+ );
+
+ $form['username'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Database username'),
+ '#default_value' => empty($database['username']) ? '' : $database['username'],
+ '#required' => TRUE,
+ '#size' => 45,
+ );
+
+ $form['password'] = array(
+ '#type' => 'password',
+ '#title' => st('Database password'),
+ '#default_value' => empty($database['password']) ? '' : $database['password'],
+ '#required' => FALSE,
+ '#size' => 45,
+ );
+
+ $form['advanced_options'] = array(
+ '#type' => 'fieldset',
+ '#title' => st('Advanced options'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => st("These options are only necessary for some sites. If you're not sure what you should enter here, leave the default settings or check with your hosting provider."),
+ '#weight' => 10,
+ );
+
+ $profile = drupal_get_profile();
+ $db_prefix = ($profile == 'standard') ? 'drupal_' : $profile . '_';
+ $form['advanced_options']['db_prefix'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Table prefix'),
+ '#default_value' => '',
+ '#size' => 45,
+ '#description' => st('If more than one application will be sharing this database, enter a table prefix such as %prefix for your @drupal site here.', array('@drupal' => drupal_install_profile_distribution_name(), '%prefix' => $db_prefix)),
+ '#weight' => 10,
+ );
+
+ $form['advanced_options']['host'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Database host'),
+ '#default_value' => empty($database['host']) ? 'localhost' : $database['host'],
+ '#size' => 45,
+ // Hostnames can be 255 characters long.
+ '#maxlength' => 255,
+ '#required' => TRUE,
+ '#description' => st('If your database is located on a different server, change this.'),
+ );
+
+ $form['advanced_options']['port'] = array(
+ '#type' => 'textfield',
+ '#title' => st('Database port'),
+ '#default_value' => empty($database['port']) ? '' : $database['port'],
+ '#size' => 45,
+ // The maximum port number is 65536, 5 digits.
+ '#maxlength' => 5,
+ '#description' => st('If your database server is listening to a non-standard port, enter its number.'),
+ );
+
+ return $form;
+ }
+
+ /**
+ * Validates driver specific configuration settings.
+ *
+ * Checks to ensure correct basic database settings and that a proper
+ * connection to the database can be established.
+ *
+ * @param $database
+ * An array of driver specific configuration options.
+ *
+ * @return
+ * An array of driver configuration errors, keyed by form element name.
+ */
+ public function validateDatabaseSettings($database) {
+ $errors = array();
+
+ // Verify the table prefix.
+ if (!empty($database['prefix']) && is_string($database['prefix']) && !preg_match('/^[A-Za-z0-9_.]+$/', $database['prefix'])) {
+ $errors[$database['driver'] . '][advanced_options][db_prefix'] = st('The database table prefix you have entered, %prefix, is invalid. The table prefix can only contain alphanumeric characters, periods, or underscores.', array('%prefix' => $database['prefix']));
+ }
+
+ // Verify the database port.
+ if (!empty($database['port']) && !is_numeric($database['port'])) {
+ $errors[$database['driver'] . '][advanced_options][port'] = st('Database port must be a number.');
+ }
+
+ return $errors;
+ }
+
+}
+
+/**
+ * Exception thrown if the database installer fails.
+ */
+class DatabaseTaskException extends Exception {
+}
+
+/**
+ * Replace values in settings.php with values in the submitted array.
+ *
+ * @param $settings
+ * An array of settings that need to be updated.
+ */
+function drupal_rewrite_settings($settings = array()) {
+ $default_settings = 'sites/default/default.settings.php';
+ drupal_static_reset('conf_path');
+ $settings_file = conf_path(FALSE) . '/settings.php';
+
+ // Build list of setting names and insert the values into the global namespace.
+ $keys = array();
+ foreach ($settings as $setting => $data) {
+ $GLOBALS[$setting] = $data['value'];
+ $keys[] = $setting;
+ }
+
+ $buffer = NULL;
+ $first = TRUE;
+ if ($fp = fopen(DRUPAL_ROOT . '/' . $default_settings, 'r')) {
+ // Step line by line through settings.php.
+ while (!feof($fp)) {
+ $line = fgets($fp);
+ if ($first && substr($line, 0, 5) != '<?php') {
+ $buffer = "<?php\n\n";
+ }
+ $first = FALSE;
+ // Check for constants.
+ if (substr($line, 0, 7) == 'define(') {
+ preg_match('/define\(\s*[\'"]([A-Z_-]+)[\'"]\s*,(.*?)\);/', $line, $variable);
+ if (in_array($variable[1], $keys)) {
+ $setting = $settings[$variable[1]];
+ $buffer .= str_replace($variable[2], " '" . $setting['value'] . "'", $line);
+ unset($settings[$variable[1]]);
+ unset($settings[$variable[2]]);
+ }
+ else {
+ $buffer .= $line;
+ }
+ }
+ // Check for variables.
+ elseif (substr($line, 0, 1) == '$') {
+ preg_match('/\$([^ ]*) /', $line, $variable);
+ if (in_array($variable[1], $keys)) {
+ // Write new value to settings.php in the following format:
+ // $'setting' = 'value'; // 'comment'
+ $setting = $settings[$variable[1]];
+ $buffer .= '$' . $variable[1] . " = " . var_export($setting['value'], TRUE) . ";" . (!empty($setting['comment']) ? ' // ' . $setting['comment'] . "\n" : "\n");
+ unset($settings[$variable[1]]);
+ }
+ else {
+ $buffer .= $line;
+ }
+ }
+ else {
+ $buffer .= $line;
+ }
+ }
+ fclose($fp);
+
+ // Add required settings that were missing from settings.php.
+ foreach ($settings as $setting => $data) {
+ if ($data['required']) {
+ $buffer .= "\$$setting = " . var_export($data['value'], TRUE) . ";\n";
+ }
+ }
+
+ $fp = fopen(DRUPAL_ROOT . '/' . $settings_file, 'w');
+ if ($fp && fwrite($fp, $buffer) === FALSE) {
+ throw new Exception(st('Failed to modify %settings. Verify the file permissions.', array('%settings' => $settings_file)));
+ }
+ }
+ else {
+ throw new Exception(st('Failed to open %settings. Verify the file permissions.', array('%settings' => $default_settings)));
+ }
+}
+
+/**
+ * Verify an install profile for installation.
+ *
+ * @param $install_state
+ * An array of information about the current installation state.
+ * @return
+ * The list of modules to install.
+ */
+function drupal_verify_profile($install_state) {
+ $profile = $install_state['parameters']['profile'];
+ $locale = $install_state['parameters']['locale'];
+
+ include_once DRUPAL_ROOT . '/core/includes/file.inc';
+ include_once DRUPAL_ROOT . '/core/includes/common.inc';
+
+ $profile_file = DRUPAL_ROOT . "/profiles/$profile/$profile.profile";
+
+ if (!isset($profile) || !file_exists($profile_file)) {
+ throw new Exception(install_no_profile_error());
+ }
+ $info = $install_state['profile_info'];
+
+ // Get a list of modules that exist in Drupal's assorted subdirectories.
+ $present_modules = array();
+ foreach (drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.module$/', 'modules', 'name', 0) as $present_module) {
+ $present_modules[] = $present_module->name;
+ }
+
+ // The install profile is also a module, which needs to be installed after all the other dependencies
+ // have been installed.
+ $present_modules[] = drupal_get_profile();
+
+ // Verify that all of the profile's required modules are present.
+ $missing_modules = array_diff($info['dependencies'], $present_modules);
+
+ $requirements = array();
+
+ if (count($missing_modules)) {
+ $modules = array();
+ foreach ($missing_modules as $module) {
+ $modules[] = '<span class="admin-missing">' . drupal_ucfirst($module) . '</span>';
+ }
+ $requirements['required_modules'] = array(
+ 'title' => st('Required modules'),
+ 'value' => st('Required modules not found.'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => st('The following modules are required but were not found. Move them into the appropriate modules subdirectory, such as <em>sites/all/modules</em>. Missing modules: !modules', array('!modules' => implode(', ', $modules))),
+ );
+ }
+ return $requirements;
+}
+
+/**
+ * Callback to install the system module.
+ *
+ * Separated from the installation of other modules so core system
+ * functions can be made available while other modules are installed.
+ */
+function drupal_install_system() {
+ $system_path = drupal_get_path('module', 'system');
+ require_once DRUPAL_ROOT . '/' . $system_path . '/system.install';
+ module_invoke('system', 'install');
+
+ $system_versions = drupal_get_schema_versions('system');
+ $system_version = $system_versions ? max($system_versions) : SCHEMA_INSTALLED;
+ db_insert('system')
+ ->fields(array('filename', 'name', 'type', 'owner', 'status', 'schema_version', 'bootstrap'))
+ ->values(array(
+ 'filename' => $system_path . '/system.module',
+ 'name' => 'system',
+ 'type' => 'module',
+ 'owner' => '',
+ 'status' => 1,
+ 'schema_version' => $system_version,
+ 'bootstrap' => 0,
+ ))
+ ->execute();
+ system_rebuild_module_data();
+}
+
+/**
+ * Uninstalls a given list of modules.
+ *
+ * @param $module_list
+ * The modules to uninstall.
+ * @param $uninstall_dependents
+ * If TRUE, the function will check that all modules which depend on the
+ * passed-in module list either are already uninstalled or contained in the
+ * list, and it will ensure that the modules are uninstalled in the correct
+ * order. This incurs a significant performance cost, so use FALSE if you
+ * know $module_list is already complete and in the correct order.
+ *
+ * @return
+ * FALSE if one or more dependent modules are missing from the list, TRUE
+ * otherwise.
+ */
+function drupal_uninstall_modules($module_list = array(), $uninstall_dependents = TRUE) {
+ if ($uninstall_dependents) {
+ // Get all module data so we can find dependents and sort.
+ $module_data = system_rebuild_module_data();
+ // Create an associative array with weights as values.
+ $module_list = array_flip(array_values($module_list));
+
+ $profile = drupal_get_profile();
+ while (list($module) = each($module_list)) {
+ if (!isset($module_data[$module]) || drupal_get_installed_schema_version($module) == SCHEMA_UNINSTALLED) {
+ // This module doesn't exist or is already uninstalled, skip it.
+ unset($module_list[$module]);
+ continue;
+ }
+ $module_list[$module] = $module_data[$module]->sort;
+
+ // If the module has any dependents which are not already uninstalled and
+ // not included in the passed-in list, abort. It is not safe to uninstall
+ // them automatically because uninstalling a module is a destructive
+ // operation.
+ foreach (array_keys($module_data[$module]->required_by) as $dependent) {
+ if (!isset($module_list[$dependent]) && drupal_get_installed_schema_version($dependent) != SCHEMA_UNINSTALLED && $dependent != $profile) {
+ return FALSE;
+ }
+ }
+ }
+
+ // Sort the module list by pre-calculated weights.
+ asort($module_list);
+ $module_list = array_keys($module_list);
+ }
+
+ foreach ($module_list as $module) {
+ // Uninstall the module.
+ module_load_install($module);
+ module_invoke($module, 'uninstall');
+ drupal_uninstall_schema($module);
+
+ watchdog('system', '%module module uninstalled.', array('%module' => $module), WATCHDOG_INFO);
+ drupal_set_installed_schema_version($module, SCHEMA_UNINSTALLED);
+ }
+
+ if (!empty($module_list)) {
+ // Call hook_module_uninstall to let other modules act
+ module_invoke_all('modules_uninstalled', $module_list);
+ }
+
+ return TRUE;
+}
+
+/**
+ * Verify the state of the specified file.
+ *
+ * @param $file
+ * The file to check for.
+ * @param $mask
+ * An optional bitmask created from various FILE_* constants.
+ * @param $type
+ * The type of file. Can be file (default), dir, or link.
+ * @return
+ * TRUE on success or FALSE on failure. A message is set for the latter.
+ */
+function drupal_verify_install_file($file, $mask = NULL, $type = 'file') {
+ $return = TRUE;
+ // Check for files that shouldn't be there.
+ if (isset($mask) && ($mask & FILE_NOT_EXIST) && file_exists($file)) {
+ return FALSE;
+ }
+ // Verify that the file is the type of file it is supposed to be.
+ if (isset($type) && file_exists($file)) {
+ $check = 'is_' . $type;
+ if (!function_exists($check) || !$check($file)) {
+ $return = FALSE;
+ }
+ }
+
+ // Verify file permissions.
+ if (isset($mask)) {
+ $masks = array(FILE_EXIST, FILE_READABLE, FILE_WRITABLE, FILE_EXECUTABLE, FILE_NOT_READABLE, FILE_NOT_WRITABLE, FILE_NOT_EXECUTABLE);
+ foreach ($masks as $current_mask) {
+ if ($mask & $current_mask) {
+ switch ($current_mask) {
+ case FILE_EXIST:
+ if (!file_exists($file)) {
+ if ($type == 'dir') {
+ drupal_install_mkdir($file, $mask);
+ }
+ if (!file_exists($file)) {
+ $return = FALSE;
+ }
+ }
+ break;
+ case FILE_READABLE:
+ if (!is_readable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ case FILE_WRITABLE:
+ if (!is_writable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ case FILE_EXECUTABLE:
+ if (!is_executable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ case FILE_NOT_READABLE:
+ if (is_readable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ case FILE_NOT_WRITABLE:
+ if (is_writable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ case FILE_NOT_EXECUTABLE:
+ if (is_executable($file) && !drupal_install_fix_file($file, $mask)) {
+ $return = FALSE;
+ }
+ break;
+ }
+ }
+ }
+ }
+ return $return;
+}
+
+/**
+ * Create a directory with specified permissions.
+ *
+ * @param $file
+ * The name of the directory to create;
+ * @param $mask
+ * The permissions of the directory to create.
+ * @param $message
+ * (optional) Whether to output messages. Defaults to TRUE.
+ * @return
+ * TRUE/FALSE whether or not the directory was successfully created.
+ */
+function drupal_install_mkdir($file, $mask, $message = TRUE) {
+ $mod = 0;
+ $masks = array(FILE_READABLE, FILE_WRITABLE, FILE_EXECUTABLE, FILE_NOT_READABLE, FILE_NOT_WRITABLE, FILE_NOT_EXECUTABLE);
+ foreach ($masks as $m) {
+ if ($mask & $m) {
+ switch ($m) {
+ case FILE_READABLE:
+ $mod |= 0444;
+ break;
+ case FILE_WRITABLE:
+ $mod |= 0222;
+ break;
+ case FILE_EXECUTABLE:
+ $mod |= 0111;
+ break;
+ }
+ }
+ }
+
+ if (@drupal_mkdir($file, $mod)) {
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Attempt to fix file permissions.
+ *
+ * The general approach here is that, because we do not know the security
+ * setup of the webserver, we apply our permission changes to all three
+ * digits of the file permission (i.e. user, group and all).
+ *
+ * To ensure that the values behave as expected (and numbers don't carry
+ * from one digit to the next) we do the calculation on the octal value
+ * using bitwise operations. This lets us remove, for example, 0222 from
+ * 0700 and get the correct value of 0500.
+ *
+ * @param $file
+ * The name of the file with permissions to fix.
+ * @param $mask
+ * The desired permissions for the file.
+ * @param $message
+ * (optional) Whether to output messages. Defaults to TRUE.
+ * @return
+ * TRUE/FALSE whether or not we were able to fix the file's permissions.
+ */
+function drupal_install_fix_file($file, $mask, $message = TRUE) {
+ // If $file does not exist, fileperms() issues a PHP warning.
+ if (!file_exists($file)) {
+ return FALSE;
+ }
+
+ $mod = fileperms($file) & 0777;
+ $masks = array(FILE_READABLE, FILE_WRITABLE, FILE_EXECUTABLE, FILE_NOT_READABLE, FILE_NOT_WRITABLE, FILE_NOT_EXECUTABLE);
+
+ // FILE_READABLE, FILE_WRITABLE, and FILE_EXECUTABLE permission strings
+ // can theoretically be 0400, 0200, and 0100 respectively, but to be safe
+ // we set all three access types in case the administrator intends to
+ // change the owner of settings.php after installation.
+ foreach ($masks as $m) {
+ if ($mask & $m) {
+ switch ($m) {
+ case FILE_READABLE:
+ if (!is_readable($file)) {
+ $mod |= 0444;
+ }
+ break;
+ case FILE_WRITABLE:
+ if (!is_writable($file)) {
+ $mod |= 0222;
+ }
+ break;
+ case FILE_EXECUTABLE:
+ if (!is_executable($file)) {
+ $mod |= 0111;
+ }
+ break;
+ case FILE_NOT_READABLE:
+ if (is_readable($file)) {
+ $mod &= ~0444;
+ }
+ break;
+ case FILE_NOT_WRITABLE:
+ if (is_writable($file)) {
+ $mod &= ~0222;
+ }
+ break;
+ case FILE_NOT_EXECUTABLE:
+ if (is_executable($file)) {
+ $mod &= ~0111;
+ }
+ break;
+ }
+ }
+ }
+
+ // chmod() will work if the web server is running as owner of the file.
+ // If PHP safe_mode is enabled the currently executing script must also
+ // have the same owner.
+ if (@chmod($file, $mod)) {
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Send the user to a different installer page.
+ *
+ * This issues an on-site HTTP redirect. Messages (and errors) are erased.
+ *
+ * @param $path
+ * An installer path.
+ */
+function install_goto($path) {
+ global $base_url;
+ include_once DRUPAL_ROOT . '/core/includes/common.inc';
+ header('Location: ' . $base_url . '/' . $path);
+ header('Cache-Control: no-cache'); // Not a permanent redirect.
+ drupal_exit();
+}
+
+/**
+ * Returns the URL of the current script, with modified query parameters.
+ *
+ * This function can be called by low-level scripts (such as install.php and
+ * update.php) and returns the URL of the current script. Existing query
+ * parameters are preserved by default, but new ones can optionally be merged
+ * in.
+ *
+ * This function is used when the script must maintain certain query parameters
+ * over multiple page requests in order to work correctly. In such cases (for
+ * example, update.php, which requires the 'continue=1' parameter to remain in
+ * the URL throughout the update process if there are any requirement warnings
+ * that need to be bypassed), using this function to generate the URL for links
+ * to the next steps of the script ensures that the links will work correctly.
+ *
+ * @param $query
+ * (optional) An array of query parameters to merge in to the existing ones.
+ *
+ * @return
+ * The URL of the current script, with query parameters modified by the
+ * passed-in $query. The URL is not sanitized, so it still needs to be run
+ * through check_url() if it will be used as an HTML attribute value.
+ *
+ * @see drupal_requirements_url()
+ */
+function drupal_current_script_url($query = array()) {
+ $uri = $_SERVER['SCRIPT_NAME'];
+ $query = array_merge(drupal_get_query_parameters(), $query);
+ if (!empty($query)) {
+ $uri .= '?' . drupal_http_build_query($query);
+ }
+ return $uri;
+}
+
+/**
+ * Returns a URL for proceeding to the next page after a requirements problem.
+ *
+ * This function can be called by low-level scripts (such as install.php and
+ * update.php) and returns a URL that can be used to attempt to proceed to the
+ * next step of the script.
+ *
+ * @param $severity
+ * The severity of the requirements problem, as returned by
+ * drupal_requirements_severity().
+ *
+ * @return
+ * A URL for attempting to proceed to the next step of the script. The URL is
+ * not sanitized, so it still needs to be run through check_url() if it will
+ * be used as an HTML attribute value.
+ *
+ * @see drupal_current_script_url()
+ */
+function drupal_requirements_url($severity) {
+ $query = array();
+ // If there are no errors, only warnings, append 'continue=1' to the URL so
+ // the user can bypass this screen on the next page load.
+ if ($severity == REQUIREMENT_WARNING) {
+ $query['continue'] = 1;
+ }
+ return drupal_current_script_url($query);
+}
+
+/**
+ * Functional equivalent of t(), used when some systems are not available.
+ *
+ * Used during the install process, when database, theme, and localization
+ * system is possibly not yet available.
+ *
+ * Use t() if your code will never run during the Drupal installation phase.
+ * Use st() if your code will only run during installation and never any other
+ * time. Use get_t() if your code could run in either circumstance.
+ *
+ * @see t()
+ * @see get_t()
+ * @ingroup sanitization
+ */
+function st($string, array $args = array(), array $options = array()) {
+ static $locale_strings = NULL;
+ global $install_state;
+
+ if (empty($options['context'])) {
+ $options['context'] = '';
+ }
+
+ if (!isset($locale_strings)) {
+ $locale_strings = array();
+ if (isset($install_state['parameters']['profile']) && isset($install_state['parameters']['locale'])) {
+ // If the given locale was selected, there should be at least one .po file
+ // with its name ending in install.{$install_state['parameters']['locale']}.po
+ // This might or might not be the entire filename. It is also possible
+ // that multiple files end with the same extension, even if unlikely.
+ $locale_files = install_find_locale_files($install_state['parameters']['locale']);
+ if (!empty($locale_files)) {
+ require_once DRUPAL_ROOT . '/core/includes/gettext.inc';
+ foreach ($locale_files as $locale_file) {
+ _locale_import_read_po('mem-store', $locale_file);
+ }
+ $locale_strings = _locale_import_one_string('mem-report');
+ }
+ }
+ }
+
+ require_once DRUPAL_ROOT . '/core/includes/theme.inc';
+ // Transform arguments before inserting them
+ foreach ($args as $key => $value) {
+ switch ($key[0]) {
+ // Escaped only
+ case '@':
+ $args[$key] = check_plain($value);
+ break;
+ // Escaped and placeholder
+ case '%':
+ default:
+ $args[$key] = '<em>' . check_plain($value) . '</em>';
+ break;
+ // Pass-through
+ case '!':
+ }
+ }
+ return strtr((!empty($locale_strings[$options['context']][$string]) ? $locale_strings[$options['context']][$string] : $string), $args);
+}
+
+/**
+ * Check an install profile's requirements.
+ *
+ * @param $profile
+ * Name of install profile to check.
+ * @return
+ * Array of the install profile's requirements.
+ */
+function drupal_check_profile($profile) {
+ include_once DRUPAL_ROOT . '/core/includes/file.inc';
+
+ $profile_file = DRUPAL_ROOT . "/profiles/$profile/$profile.profile";
+
+ if (!isset($profile) || !file_exists($profile_file)) {
+ throw new Exception(install_no_profile_error());
+ }
+
+ $info = install_profile_info($profile);
+
+ // Collect requirement testing results.
+ $requirements = array();
+ foreach ($info['dependencies'] as $module) {
+ module_load_install($module);
+ $function = $module . '_requirements';
+ if (function_exists($function)) {
+ $requirements = array_merge($requirements, $function('install'));
+ }
+ }
+ return $requirements;
+}
+
+/**
+ * Extract highest severity from requirements array.
+ *
+ * @param $requirements
+ * An array of requirements, in the same format as is returned by
+ * hook_requirements().
+ * @return
+ * The highest severity in the array.
+ */
+function drupal_requirements_severity(&$requirements) {
+ $severity = REQUIREMENT_OK;
+ foreach ($requirements as $requirement) {
+ if (isset($requirement['severity'])) {
+ $severity = max($severity, $requirement['severity']);
+ }
+ }
+ return $severity;
+}
+
+/**
+ * Check a module's requirements.
+ *
+ * @param $module
+ * Machine name of module to check.
+ * @return
+ * TRUE/FALSE depending on the requirements are in place.
+ */
+function drupal_check_module($module) {
+ module_load_install($module);
+ if (module_hook($module, 'requirements')) {
+ // Check requirements
+ $requirements = module_invoke($module, 'requirements', 'install');
+ if (is_array($requirements) && drupal_requirements_severity($requirements) == REQUIREMENT_ERROR) {
+ // Print any error messages
+ foreach ($requirements as $requirement) {
+ if (isset($requirement['severity']) && $requirement['severity'] == REQUIREMENT_ERROR) {
+ $message = $requirement['description'];
+ if (isset($requirement['value']) && $requirement['value']) {
+ $message .= ' (' . t('Currently using !item !version', array('!item' => $requirement['title'], '!version' => $requirement['value'])) . ')';
+ }
+ drupal_set_message($message, 'error');
+ }
+ }
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Retrieve info about an install profile from its .info file.
+ *
+ * The information stored in a profile .info file is similar to that stored in
+ * a normal Drupal module .info file. For example:
+ * - name: The real name of the install profile for display purposes.
+ * - description: A brief description of the profile.
+ * - dependencies: An array of shortnames of other modules this install profile requires.
+ *
+ * Additional, less commonly-used information that can appear in a profile.info
+ * file but not in a normal Drupal module .info file includes:
+ * - distribution_name: The name of the Drupal distribution that is being
+ * installed, to be shown throughout the installation process. Defaults to
+ * 'Drupal'.
+ *
+ * Note that this function does an expensive file system scan to get info file
+ * information for dependencies. If you only need information from the info
+ * file itself, use system_get_info().
+ *
+ * Example of .info file:
+ * @code
+ * name = Minimal
+ * description = Start fresh, with only a few modules enabled.
+ * dependencies[] = block
+ * dependencies[] = dblog
+ * @endcode
+ *
+ * @param $profile
+ * Name of profile.
+ * @param $locale
+ * Name of locale used (if any).
+ *
+ * @return
+ * The info array.
+ */
+function install_profile_info($profile, $locale = 'en') {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($cache[$profile])) {
+ // Set defaults for module info.
+ $defaults = array(
+ 'dependencies' => array(),
+ 'description' => '',
+ 'distribution_name' => 'Drupal',
+ 'version' => NULL,
+ 'hidden' => FALSE,
+ 'php' => DRUPAL_MINIMUM_PHP,
+ );
+ $info = drupal_parse_info_file("profiles/$profile/$profile.info") + $defaults;
+ $info['dependencies'] = array_unique(array_merge(
+ drupal_required_modules(),
+ $info['dependencies'],
+ ($locale != 'en' && !empty($locale) ? array('locale') : array()))
+ );
+
+ // drupal_required_modules() includes the current profile as a dependency.
+ // Since a module can't depend on itself we remove that element of the array.
+ array_shift($info['dependencies']);
+
+ $cache[$profile] = $info;
+ }
+ return $cache[$profile];
+}
+
+/**
+ * Ensures the environment for a Drupal database on a predefined connection.
+ *
+ * This will run tasks that check that Drupal can perform all of the functions
+ * on a database, that Drupal needs. Tasks include simple checks like CREATE
+ * TABLE to database specific functions like stored procedures and client
+ * encoding.
+ */
+function db_run_tasks($driver) {
+ db_installer_object($driver)->runTasks();
+ return TRUE;
+}
+
+/**
+ * Returns a database installer object.
+ *
+ * @param $driver
+ * The name of the driver.
+ */
+function db_installer_object($driver) {
+ Database::loadDriverFile($driver, array('install.inc'));
+ $task_class = 'DatabaseTasks_' . $driver;
+ return new $task_class();
+}
diff --git a/core/includes/language.inc b/core/includes/language.inc
new file mode 100644
index 000000000000..685a4d44a009
--- /dev/null
+++ b/core/includes/language.inc
@@ -0,0 +1,468 @@
+<?php
+
+/**
+ * @file
+ * Multiple language handling functionality.
+ */
+
+/**
+ * No language negotiation. The default language is used.
+ */
+define('LANGUAGE_NEGOTIATION_DEFAULT', 'language-default');
+
+/**
+ * Return all the defined language types.
+ *
+ * @return
+ * An array of language type names. The name will be used as the global
+ * variable name the language value will be stored in.
+ */
+function language_types_info() {
+ $language_types = &drupal_static(__FUNCTION__);
+
+ if (!isset($language_types)) {
+ $language_types = module_invoke_all('language_types_info');
+ // Let other modules alter the list of language types.
+ drupal_alter('language_types_info', $language_types);
+ }
+
+ return $language_types;
+}
+
+/**
+ * Return only the configurable language types.
+ *
+ * A language type maybe configurable or fixed. A fixed language type is a type
+ * whose negotiation values are unchangeable and defined while defining the
+ * language type itself.
+ *
+ * @param $stored
+ * Optional. By default retrieves values from the 'language_types' variable to
+ * avoid unnecessary hook invocations.
+ * If set to FALSE retrieves values from the actual language type definitions.
+ * This allows to react to alterations performed on the definitions by modules
+ * installed after the 'language_types' variable is set.
+ *
+ * @return
+ * An array of language type names.
+ */
+function language_types_configurable($stored = TRUE) {
+ $configurable = &drupal_static(__FUNCTION__);
+
+ if ($stored && !isset($configurable)) {
+ $types = variable_get('language_types', drupal_language_types());
+ $configurable = array_keys(array_filter($types));
+ }
+
+ if (!$stored) {
+ $result = array();
+ foreach (language_types_info() as $type => $info) {
+ if (!isset($info['fixed'])) {
+ $result[] = $type;
+ }
+ }
+ return $result;
+ }
+
+ return $configurable;
+}
+
+/**
+ * Disable the given language types.
+ *
+ * @param $types
+ * An array of language types.
+ */
+function language_types_disable($types) {
+ $enabled_types = variable_get('language_types', drupal_language_types());
+
+ foreach ($types as $type) {
+ unset($enabled_types[$type]);
+ }
+
+ variable_set('language_types', $enabled_types);
+}
+
+/**
+ * Updates the language type configuration.
+ */
+function language_types_set() {
+ // Ensure that we are getting the defined language negotiation information. An
+ // invocation of module_enable() or module_disable() could outdate the cached
+ // information.
+ drupal_static_reset('language_types_info');
+ drupal_static_reset('language_negotiation_info');
+
+ // Determine which language types are configurable and which not by checking
+ // whether the 'fixed' key is defined. Non-configurable (fixed) language types
+ // have their language negotiation settings stored there.
+ $defined_providers = language_negotiation_info();
+ foreach (language_types_info() as $type => $info) {
+ if (isset($info['fixed'])) {
+ $language_types[$type] = FALSE;
+ $negotiation = array();
+ foreach ($info['fixed'] as $weight => $id) {
+ if (isset($defined_providers[$id])) {
+ $negotiation[$id] = $weight;
+ }
+ }
+ language_negotiation_set($type, $negotiation);
+ }
+ else {
+ $language_types[$type] = TRUE;
+ }
+ }
+
+ // Save language types.
+ variable_set('language_types', $language_types);
+
+ // Ensure that subsequent calls of language_types_configurable() return the
+ // updated language type information.
+ drupal_static_reset('language_types_configurable');
+}
+
+/**
+ * Check if a language provider is enabled.
+ *
+ * This has two possible behaviors:
+ * - If $provider_id is given return its ID if enabled, FALSE otherwise.
+ * - If no ID is passed the first enabled language provider is returned.
+ *
+ * @param $type
+ * The language negotiation type.
+ * @param $provider_id
+ * The language provider ID.
+ *
+ * @return
+ * The provider ID if it is enabled, FALSE otherwise.
+ */
+function language_negotiation_get($type, $provider_id = NULL) {
+ $negotiation = variable_get("language_negotiation_$type", array());
+
+ if (empty($negotiation)) {
+ return empty($provider_id) ? LANGUAGE_NEGOTIATION_DEFAULT : FALSE;
+ }
+
+ if (empty($provider_id)) {
+ return key($negotiation);
+ }
+
+ if (isset($negotiation[$provider_id])) {
+ return $provider_id;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Check if the given language provider is enabled for any configurable language
+ * type.
+ *
+ * @param $provider_id
+ * The language provider ID.
+ *
+ * @return
+ * TRUE if there is at least one language type for which the give language
+ * provider is enabled, FALSE otherwise.
+ */
+function language_negotiation_get_any($provider_id) {
+ foreach (language_types_configurable() as $type) {
+ if (language_negotiation_get($type, $provider_id)) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Return the language switch links for the given language.
+ *
+ * @param $type
+ * The language negotiation type.
+ * @param $path
+ * The internal path the switch links will be relative to.
+ *
+ * @return
+ * A keyed array of links ready to be themed.
+ */
+function language_negotiation_get_switch_links($type, $path) {
+ $links = FALSE;
+ $negotiation = variable_get("language_negotiation_$type", array());
+
+ foreach ($negotiation as $id => $provider) {
+ if (isset($provider['callbacks']['switcher'])) {
+ if (isset($provider['file'])) {
+ require_once DRUPAL_ROOT . '/' . $provider['file'];
+ }
+
+ $callback = $provider['callbacks']['switcher'];
+ $result = $callback($type, $path);
+
+ if (!empty($result)) {
+ // Allow modules to provide translations for specific links.
+ drupal_alter('language_switch_links', $result, $type, $path);
+ $links = (object) array('links' => $result, 'provider' => $id);
+ break;
+ }
+ }
+ }
+
+ return $links;
+}
+
+/**
+ * Updates language configuration to remove any language provider that is no longer defined.
+ */
+function language_negotiation_purge() {
+ // Ensure that we are getting the defined language negotiation information. An
+ // invocation of module_enable() or module_disable() could outdate the cached
+ // information.
+ drupal_static_reset('language_negotiation_info');
+ drupal_static_reset('language_types_info');
+
+ $defined_providers = language_negotiation_info();
+ foreach (language_types_info() as $type => $type_info) {
+ $weight = 0;
+ $negotiation = array();
+ foreach (variable_get("language_negotiation_$type", array()) as $id => $provider) {
+ if (isset($defined_providers[$id])) {
+ $negotiation[$id] = $weight++;
+ }
+ }
+ language_negotiation_set($type, $negotiation);
+ }
+}
+
+/**
+ * Save a list of language providers.
+ *
+ * @param $type
+ * The language negotiation type.
+ * @param $language_providers
+ * An array of language provider weights keyed by id.
+ * @see language_provider_weight()
+ */
+function language_negotiation_set($type, $language_providers) {
+ // Save only the necessary fields.
+ $provider_fields = array('callbacks', 'file', 'cache');
+
+ $negotiation = array();
+ $providers_weight = array();
+ $defined_providers = language_negotiation_info();
+ $default_types = language_types_configurable(FALSE);
+
+ // Initialize the providers weight list.
+ foreach ($language_providers as $id => $provider) {
+ $providers_weight[$id] = language_provider_weight($provider);
+ }
+
+ // Order providers list by weight.
+ asort($providers_weight);
+
+ foreach ($providers_weight as $id => $weight) {
+ if (isset($defined_providers[$id])) {
+ $provider = $defined_providers[$id];
+ // If the provider does not express any preference about types, make it
+ // available for any configurable type.
+ $types = array_flip(isset($provider['types']) ? $provider['types'] : $default_types);
+ // Check if the provider is defined and has the right type.
+ if (isset($types[$type])) {
+ $provider_data = array();
+ foreach ($provider_fields as $field) {
+ if (isset($provider[$field])) {
+ $provider_data[$field] = $provider[$field];
+ }
+ }
+ $negotiation[$id] = $provider_data;
+ }
+ }
+ }
+
+ variable_set("language_negotiation_$type", $negotiation);
+}
+
+/**
+ * Return all the defined language providers.
+ *
+ * @return
+ * An array of language providers.
+ */
+function language_negotiation_info() {
+ $language_providers = &drupal_static(__FUNCTION__);
+
+ if (!isset($language_providers)) {
+ // Collect all the module-defined language negotiation providers.
+ $language_providers = module_invoke_all('language_negotiation_info');
+
+ // Add the default language provider.
+ $language_providers[LANGUAGE_NEGOTIATION_DEFAULT] = array(
+ 'callbacks' => array('language' => 'language_from_default'),
+ 'weight' => 10,
+ 'name' => t('Default language'),
+ 'description' => t('Use the default site language (@language_name).', array('@language_name' => language_default()->name)),
+ 'config' => 'admin/config/regional/language',
+ );
+
+ // Let other modules alter the list of language providers.
+ drupal_alter('language_negotiation_info', $language_providers);
+ }
+
+ return $language_providers;
+}
+
+/**
+ * Helper function used to cache the language providers results.
+ *
+ * @param $provider_id
+ * The language provider ID.
+ * @param $provider
+ * The language provider to be invoked. If not passed it will be explicitly
+ * loaded through language_negotiation_info().
+ *
+ * @return
+ * The language provider's return value.
+ */
+function language_provider_invoke($provider_id, $provider = NULL) {
+ $results = &drupal_static(__FUNCTION__);
+
+ if (!isset($results[$provider_id])) {
+ global $user;
+
+ // Get languages grouped by status and select only the enabled ones.
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+
+ if (!isset($provider)) {
+ $providers = language_negotiation_info();
+ $provider = $providers[$provider_id];
+ }
+
+ if (isset($provider['file'])) {
+ require_once DRUPAL_ROOT . '/' . $provider['file'];
+ }
+
+ // If the language provider has no cache preference or this is satisfied
+ // we can execute the callback.
+ $cache = !isset($provider['cache']) || $user->uid || $provider['cache'] == variable_get('cache', 0);
+ $callback = isset($provider['callbacks']['language']) ? $provider['callbacks']['language'] : FALSE;
+ $langcode = $cache && function_exists($callback) ? $callback($languages) : FALSE;
+ $results[$provider_id] = isset($languages[$langcode]) ? $languages[$langcode] : FALSE;
+ }
+
+ return $results[$provider_id];
+}
+
+/**
+ * Return the passed language provider weight or a default value.
+ *
+ * @param $provider
+ * A language provider data structure.
+ *
+ * @return
+ * A numeric weight.
+ */
+function language_provider_weight($provider) {
+ $default = is_numeric($provider) ? $provider : 0;
+ return isset($provider['weight']) && is_numeric($provider['weight']) ? $provider['weight'] : $default;
+}
+
+/**
+ * Choose a language for the given type based on language negotiation settings.
+ *
+ * @param $type
+ * The language type.
+ *
+ * @return
+ * The negotiated language object.
+ */
+function language_initialize($type) {
+ // Execute the language providers in the order they were set up and return the
+ // first valid language found.
+ $negotiation = variable_get("language_negotiation_$type", array());
+
+ foreach ($negotiation as $id => $provider) {
+ $language = language_provider_invoke($id, $provider);
+ if ($language) {
+ return $language;
+ }
+ }
+
+ // If no other language was found use the default one.
+ return language_default();
+}
+
+/**
+ * Default language provider.
+ *
+ * @return
+ * The default language code.
+ */
+function language_from_default() {
+ return language_default()->language;
+}
+
+/**
+ * Split the given path into prefix and actual path.
+ *
+ * Parse the given path and return the language object identified by the
+ * prefix and the actual path.
+ *
+ * @param $path
+ * The path to split.
+ * @param $languages
+ * An array of valid languages.
+ *
+ * @return
+ * An array composed of:
+ * - A language object corresponding to the identified prefix on success,
+ * FALSE otherwise.
+ * - The path without the prefix on success, the given path otherwise.
+ */
+function language_url_split_prefix($path, $languages) {
+ $args = empty($path) ? array() : explode('/', $path);
+ $prefix = array_shift($args);
+
+ // Search prefix within enabled languages.
+ foreach ($languages as $language) {
+ if (!empty($language->prefix) && $language->prefix == $prefix) {
+ // Rebuild $path with the language removed.
+ return array($language, implode('/', $args));
+ }
+ }
+
+ return array(FALSE, $path);
+}
+
+/**
+ * Return the possible fallback languages ordered by language weight.
+ *
+ * @param
+ * The language type.
+ *
+ * @return
+ * An array of language codes.
+ */
+function language_fallback_get_candidates($type = LANGUAGE_TYPE_CONTENT) {
+ $fallback_candidates = &drupal_static(__FUNCTION__);
+
+ if (!isset($fallback_candidates)) {
+ $fallback_candidates = array();
+
+ // Get languages ordered by weight.
+ // Use array keys to avoid duplicated entries.
+ foreach (language_list('weight') as $languages) {
+ foreach ($languages as $language) {
+ $fallback_candidates[$language->language] = NULL;
+ }
+ }
+
+ $fallback_candidates = array_keys($fallback_candidates);
+ $fallback_candidates[] = LANGUAGE_NONE;
+
+ // Let other modules hook in and add/change candidates.
+ drupal_alter('language_fallback_candidates', $fallback_candidates);
+ }
+
+ return $fallback_candidates;
+}
diff --git a/core/includes/locale.inc b/core/includes/locale.inc
new file mode 100644
index 000000000000..6222bda70b3e
--- /dev/null
+++ b/core/includes/locale.inc
@@ -0,0 +1,977 @@
+<?php
+
+/**
+ * @file
+ * Administration functions for locale.module.
+ */
+
+/**
+ * The language is determined using a URL language indicator:
+ * path prefix or domain according to the configuration.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_URL', 'locale-url');
+
+/**
+ * The language is set based on the browser language settings.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_BROWSER', 'locale-browser');
+
+/**
+ * The language is determined using the current interface language.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_INTERFACE', 'locale-interface');
+
+/**
+ * If no URL language is available language is determined using an already
+ * detected one.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_URL_FALLBACK', 'locale-url-fallback');
+
+/**
+ * The language is set based on the user language settings.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_USER', 'locale-user');
+
+/**
+ * The language is set based on the request/session parameters.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_SESSION', 'locale-session');
+
+/**
+ * Regular expression pattern used to localize JavaScript strings.
+ */
+define('LOCALE_JS_STRING', '(?:(?:\'(?:\\\\\'|[^\'])*\'|"(?:\\\\"|[^"])*")(?:\s*\+\s*)?)+');
+
+/**
+ * Regular expression pattern used to match simple JS object literal.
+ *
+ * This pattern matches a basic JS object, but will fail on an object with
+ * nested objects. Used in JS file parsing for string arg processing.
+ */
+define('LOCALE_JS_OBJECT', '\{.*?\}');
+
+/**
+ * Regular expression to match an object containing a key 'context'.
+ *
+ * Pattern to match a JS object containing a 'context key' with a string value,
+ * which is captured. Will fail if there are nested objects.
+ */
+define('LOCALE_JS_OBJECT_CONTEXT', '
+ \{ # match object literal start
+ .*? # match anything, non-greedy
+ (?: # match a form of "context"
+ \'context\'
+ |
+ "context"
+ |
+ context
+ )
+ \s*:\s* # match key-value separator ":"
+ (' . LOCALE_JS_STRING . ') # match context string
+ .*? # match anything, non-greedy
+ \} # match end of object literal
+');
+
+/**
+ * Translation import mode overwriting all existing translations
+ * if new translated version available.
+ */
+define('LOCALE_IMPORT_OVERWRITE', 0);
+
+/**
+ * Translation import mode keeping existing translations and only
+ * inserting new strings.
+ */
+define('LOCALE_IMPORT_KEEP', 1);
+
+/**
+ * URL language negotiation: use the path prefix as URL language
+ * indicator.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX', 0);
+
+/**
+ * URL language negotiation: use the domain as URL language
+ * indicator.
+ */
+define('LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN', 1);
+
+/**
+ * @defgroup locale-languages-negotiation Language negotiation options
+ * @{
+ * Functions for language negotiation.
+ *
+ * There are functions that provide the ability to identify the
+ * language. This behavior can be controlled by various options.
+ */
+
+/**
+ * Identifies the language from the current interface language.
+ *
+ * @return
+ * The current interface language code.
+ */
+function locale_language_from_interface() {
+ global $language;
+ return isset($language->language) ? $language->language : FALSE;
+}
+
+/**
+ * Identify language from the Accept-language HTTP header we got.
+ *
+ * We perform browser accept-language parsing only if page cache is disabled,
+ * otherwise we would cache a user-specific preference.
+ *
+ * @param $languages
+ * An array of language objects for enabled languages ordered by weight.
+ *
+ * @return
+ * A valid language code on success, FALSE otherwise.
+ */
+function locale_language_from_browser($languages) {
+ if (empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
+ return FALSE;
+ }
+
+ // The Accept-Language header contains information about the language
+ // preferences configured in the user's browser / operating system.
+ // RFC 2616 (section 14.4) defines the Accept-Language header as follows:
+ // Accept-Language = "Accept-Language" ":"
+ // 1#( language-range [ ";" "q" "=" qvalue ] )
+ // language-range = ( ( 1*8ALPHA *( "-" 1*8ALPHA ) ) | "*" )
+ // Samples: "hu, en-us;q=0.66, en;q=0.33", "hu,en-us;q=0.5"
+ $browser_langcodes = array();
+ if (preg_match_all('@([a-zA-Z-]+|\*)(?:;q=([0-9.]+))?(?:$|\s*,\s*)@', trim($_SERVER['HTTP_ACCEPT_LANGUAGE']), $matches, PREG_SET_ORDER)) {
+ foreach ($matches as $match) {
+ // We can safely use strtolower() here, tags are ASCII.
+ // RFC2616 mandates that the decimal part is no more than three digits,
+ // so we multiply the qvalue by 1000 to avoid floating point comparisons.
+ $langcode = strtolower($match[1]);
+ $qvalue = isset($match[2]) ? (float) $match[2] : 1;
+ $browser_langcodes[$langcode] = (int) ($qvalue * 1000);
+ }
+ }
+
+ // We should take pristine values from the HTTP headers, but Internet Explorer
+ // from version 7 sends only specific language tags (eg. fr-CA) without the
+ // corresponding generic tag (fr) unless explicitly configured. In that case,
+ // we assume that the lowest value of the specific tags is the value of the
+ // generic language to be as close to the HTTP 1.1 spec as possible.
+ // See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4 and
+ // http://blogs.msdn.com/b/ie/archive/2006/10/17/accept-language-header-for-internet-explorer-7.aspx
+ asort($browser_langcodes);
+ foreach ($browser_langcodes as $langcode => $qvalue) {
+ $generic_tag = strtok($langcode, '-');
+ if (!isset($browser_langcodes[$generic_tag])) {
+ $browser_langcodes[$generic_tag] = $qvalue;
+ }
+ }
+
+ // Find the enabled language with the greatest qvalue, following the rules
+ // of RFC 2616 (section 14.4). If several languages have the same qvalue,
+ // prefer the one with the greatest weight.
+ $best_match_langcode = FALSE;
+ $max_qvalue = 0;
+ foreach ($languages as $langcode => $language) {
+ // Language tags are case insensitive (RFC2616, sec 3.10).
+ $langcode = strtolower($langcode);
+
+ // If nothing matches below, the default qvalue is the one of the wildcard
+ // language, if set, or is 0 (which will never match).
+ $qvalue = isset($browser_langcodes['*']) ? $browser_langcodes['*'] : 0;
+
+ // Find the longest possible prefix of the browser-supplied language
+ // ('the language-range') that matches this site language ('the language tag').
+ $prefix = $langcode;
+ do {
+ if (isset($browser_langcodes[$prefix])) {
+ $qvalue = $browser_langcodes[$prefix];
+ break;
+ }
+ }
+ while ($prefix = substr($prefix, 0, strrpos($prefix, '-')));
+
+ // Find the best match.
+ if ($qvalue > $max_qvalue) {
+ $best_match_langcode = $language->language;
+ $max_qvalue = $qvalue;
+ }
+ }
+
+ return $best_match_langcode;
+}
+
+/**
+ * Identify language from the user preferences.
+ *
+ * @param $languages
+ * An array of valid language objects.
+ *
+ * @return
+ * A valid language code on success, FALSE otherwise.
+ */
+function locale_language_from_user($languages) {
+ // User preference (only for logged users).
+ global $user;
+
+ if ($user->uid) {
+ return $user->language;
+ }
+
+ // No language preference from the user.
+ return FALSE;
+}
+
+/**
+ * Identify language from a request/session parameter.
+ *
+ * @param $languages
+ * An array of valid language objects.
+ *
+ * @return
+ * A valid language code on success, FALSE otherwise.
+ */
+function locale_language_from_session($languages) {
+ $param = variable_get('locale_language_negotiation_session_param', 'language');
+
+ // Request parameter: we need to update the session parameter only if we have
+ // an authenticated user.
+ if (isset($_GET[$param]) && isset($languages[$langcode = $_GET[$param]])) {
+ global $user;
+ if ($user->uid) {
+ $_SESSION[$param] = $langcode;
+ }
+ return $langcode;
+ }
+
+ // Session parameter.
+ if (isset($_SESSION[$param])) {
+ return $_SESSION[$param];
+ }
+
+ return FALSE;
+}
+
+/**
+ * Identify language via URL prefix or domain.
+ *
+ * @param $languages
+ * An array of valid language objects.
+ *
+ * @return
+ * A valid language code on success, FALSE otherwise.
+ */
+function locale_language_from_url($languages) {
+ $language_url = FALSE;
+
+ if (!language_negotiation_get_any(LOCALE_LANGUAGE_NEGOTIATION_URL)) {
+ return $language_url;
+ }
+
+ switch (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX)) {
+ case LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX:
+ // $_GET['q'] might not be available at this time, because
+ // path initialization runs after the language bootstrap phase.
+ list($language, $_GET['q']) = language_url_split_prefix(isset($_GET['q']) ? $_GET['q'] : NULL, $languages);
+ if ($language !== FALSE) {
+ $language_url = $language->language;
+ }
+ break;
+
+ case LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN:
+ foreach ($languages as $language) {
+ // Skip check if the language doesn't have a domain.
+ if ($language->domain) {
+ // Only compare the domains not the protocols or ports.
+ // Remove protocol and add http:// so parse_url works
+ $host = 'http://' . str_replace(array('http://', 'https://'), '', $language->domain);
+ $host = parse_url($host, PHP_URL_HOST);
+ if ($_SERVER['HTTP_HOST'] == $host) {
+ $language_url = $language->language;
+ break;
+ }
+ }
+ }
+ break;
+ }
+
+ return $language_url;
+}
+
+/**
+ * Determines the language to be assigned to URLs when none is detected.
+ *
+ * The language negotiation process has a fallback chain that ends with the
+ * default language provider. Each built-in language type has a separate
+ * initialization:
+ * - Interface language, which is the only configurable one, always gets a valid
+ * value. If no request-specific language is detected, the default language
+ * will be used.
+ * - Content language merely inherits the interface language by default.
+ * - URL language is detected from the requested URL and will be used to rewrite
+ * URLs appearing in the page being rendered. If no language can be detected,
+ * there are two possibilities:
+ * - If the default language has no configured path prefix or domain, then the
+ * default language is used. This guarantees that (missing) URL prefixes are
+ * preserved when navigating through the site.
+ * - If the default language has a configured path prefix or domain, a
+ * requested URL having an empty prefix or domain is an anomaly that must be
+ * fixed. This is done by introducing a prefix or domain in the rendered
+ * page matching the detected interface language.
+ *
+ * @param $languages
+ * (optional) An array of valid language objects. This is passed by
+ * language_provider_invoke() to every language provider callback, but it is
+ * not actually needed here. Defaults to NULL.
+ * @param $language_type
+ * (optional) The language type to fall back to. Defaults to the interface
+ * language.
+ *
+ * @return
+ * A valid language code.
+ */
+function locale_language_url_fallback($language = NULL, $language_type = LANGUAGE_TYPE_INTERFACE) {
+ $default = language_default();
+ $prefix = (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX) == LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX);
+
+ // If the default language is not configured to convey language information,
+ // a missing URL language information indicates that URL language should be
+ // the default one, otherwise we fall back to an already detected language.
+ if (($prefix && empty($default->prefix)) || (!$prefix && empty($default->domain))) {
+ return $default->language;
+ }
+ else {
+ return $GLOBALS[$language_type]->language;
+ }
+}
+
+/**
+ * Return the URL language switcher block. Translation links may be provided by
+ * other modules.
+ */
+function locale_language_switcher_url($type, $path) {
+ $languages = language_list('enabled');
+ $links = array();
+
+ foreach ($languages[1] as $language) {
+ $links[$language->language] = array(
+ 'href' => $path,
+ 'title' => $language->name,
+ 'language' => $language,
+ 'attributes' => array('class' => array('language-link')),
+ );
+ }
+
+ return $links;
+}
+
+/**
+ * Return the session language switcher block.
+ */
+function locale_language_switcher_session($type, $path) {
+ drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css');
+
+ $param = variable_get('locale_language_negotiation_session_param', 'language');
+ $language_query = isset($_SESSION[$param]) ? $_SESSION[$param] : $GLOBALS[$type]->language;
+
+ $languages = language_list('enabled');
+ $links = array();
+
+ $query = $_GET;
+ unset($query['q']);
+
+ foreach ($languages[1] as $language) {
+ $langcode = $language->language;
+ $links[$langcode] = array(
+ 'href' => $path,
+ 'title' => $language->name,
+ 'attributes' => array('class' => array('language-link')),
+ 'query' => $query,
+ );
+ if ($language_query != $langcode) {
+ $links[$langcode]['query'][$param] = $langcode;
+ }
+ else {
+ $links[$langcode]['attributes']['class'][] = ' session-active';
+ }
+ }
+
+ return $links;
+}
+
+/**
+ * Rewrite URLs for the URL language provider.
+ */
+function locale_language_url_rewrite_url(&$path, &$options) {
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['languages'] = &drupal_static(__FUNCTION__);
+ }
+ $languages = &$drupal_static_fast['languages'];
+
+ if (!isset($languages)) {
+ $languages = language_list('enabled');
+ $languages = array_flip(array_keys($languages[1]));
+ }
+
+ // Language can be passed as an option, or we go for current URL language.
+ if (!isset($options['language'])) {
+ global $language_url;
+ $options['language'] = $language_url;
+ }
+ // We allow only enabled languages here.
+ elseif (!isset($languages[$options['language']->language])) {
+ unset($options['language']);
+ return;
+ }
+
+ if (isset($options['language'])) {
+ switch (variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX)) {
+ case LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN:
+ if ($options['language']->domain) {
+ // Ask for an absolute URL with our modified base_url.
+ $options['absolute'] = TRUE;
+ $options['base_url'] = $options['language']->domain;
+ }
+ break;
+
+ case LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX:
+ if (!empty($options['language']->prefix)) {
+ $options['prefix'] = $options['language']->prefix . '/';
+ }
+ break;
+ }
+ }
+}
+
+/**
+ * Rewrite URLs for the Session language provider.
+ */
+function locale_language_url_rewrite_session(&$path, &$options) {
+ static $query_rewrite, $query_param, $query_value;
+
+ // The following values are not supposed to change during a single page
+ // request processing.
+ if (!isset($query_rewrite)) {
+ global $user;
+ if (!$user->uid) {
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+ $query_param = check_plain(variable_get('locale_language_negotiation_session_param', 'language'));
+ $query_value = isset($_GET[$query_param]) ? check_plain($_GET[$query_param]) : NULL;
+ $query_rewrite = isset($languages[$query_value]) && language_negotiation_get_any(LOCALE_LANGUAGE_NEGOTIATION_SESSION);
+ }
+ else {
+ $query_rewrite = FALSE;
+ }
+ }
+
+ // If the user is anonymous, the user language provider is enabled, and the
+ // corresponding option has been set, we must preserve any explicit user
+ // language preference even with cookies disabled.
+ if ($query_rewrite) {
+ if (is_string($options['query'])) {
+ $options['query'] = drupal_get_query_array($options['query']);
+ }
+ if (!isset($options['query'][$query_param])) {
+ $options['query'][$query_param] = $query_value;
+ }
+ }
+}
+
+/**
+ * @} End of "locale-languages-negotiation"
+ */
+
+/**
+ * Check that a string is safe to be added or imported as a translation.
+ *
+ * This test can be used to detect possibly bad translation strings. It should
+ * not have any false positives. But it is only a test, not a transformation,
+ * as it destroys valid HTML. We cannot reliably filter translation strings
+ * on import because some strings are irreversibly corrupted. For example,
+ * a &amp; in the translation would get encoded to &amp;amp; by filter_xss()
+ * before being put in the database, and thus would be displayed incorrectly.
+ *
+ * The allowed tag list is like filter_xss_admin(), but omitting div and img as
+ * not needed for translation and likely to cause layout issues (div) or a
+ * possible attack vector (img).
+ */
+function locale_string_is_safe($string) {
+ return decode_entities($string) == decode_entities(filter_xss($string, array('a', 'abbr', 'acronym', 'address', 'b', 'bdo', 'big', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dl', 'dt', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'ul', 'var')));
+}
+
+/**
+ * API function to add or update a language.
+ *
+ * @param $language
+ * Language object with properties corresponding to 'languages' table columns.
+ */
+function locale_language_save($language) {
+ $language->is_new = !(bool) db_query_range('SELECT 1 FROM {languages} WHERE language = :language', 0, 1, array(':language' => $language->language))->fetchField();
+ // Default prefix on language code if not provided otherwise.
+ if (!isset($language->prefix)) {
+ $language->prefix = $language->language;
+ }
+
+ // If name was not set, we add a predefined language.
+ if (!isset($language->name)) {
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $predefined = standard_language_list();
+ $language->name = $predefined[$language->language][0];
+ $language->direction = isset($predefined[$language->language][2]) ? $predefined[$language->language][2] : LANGUAGE_LTR;
+ }
+
+ // Set to enabled for the default language and unless specified otherwise.
+ if (!empty($language->default) || !isset($language->enabled)) {
+ $language->enabled = TRUE;
+ }
+ // Let other modules modify $language before saved.
+ module_invoke_all('locale_language_presave', $language);
+
+ // Save the record and inform others about the change.
+ if ($language->is_new) {
+ drupal_write_record('languages', $language);
+ module_invoke_all('locale_language_insert', $language);
+ watchdog('locale', 'The %language (%langcode) language has been created.', array('%language' => $language->name, '%langcode' => $language->language));
+ }
+ else {
+ drupal_write_record('languages', $language, array('language'));
+ module_invoke_all('locale_language_update', $language);
+ watchdog('locale', 'The %language (%langcode) language has been updated.', array('%language' => $language->name, '%langcode' => $language->language));
+ }
+
+ if (!empty($language->default)) {
+ // Set the new version of this language as default in a variable.
+ $default_language = language_default();
+ variable_set('language_default', $language);
+ }
+
+ // Update language count based on enabled language count.
+ variable_set('language_count', db_query('SELECT COUNT(language) FROM {languages} WHERE enabled = 1')->fetchField());
+
+ // Kill the static cache in language_list().
+ drupal_static_reset('language_list');
+
+ // @todo move these two cache clears out. See http://drupal.org/node/1293252
+ // Changing the language settings impacts the interface.
+ cache_clear_all('*', 'cache_page', TRUE);
+ // Force JavaScript translation file re-creation for the modified language.
+ _locale_invalidate_js($language->language);
+
+ return $language;
+}
+
+/**
+ * Parses a JavaScript file, extracts strings wrapped in Drupal.t() and
+ * Drupal.formatPlural() and inserts them into the database.
+ */
+function _locale_parse_js_file($filepath) {
+ global $language;
+
+ // The file path might contain a query string, so make sure we only use the
+ // actual file.
+ $parsed_url = drupal_parse_url($filepath);
+ $filepath = $parsed_url['path'];
+ // Load the JavaScript file.
+ $file = file_get_contents($filepath);
+
+ // Match all calls to Drupal.t() in an array.
+ // Note: \s also matches newlines with the 's' modifier.
+ preg_match_all('~
+ [^\w]Drupal\s*\.\s*t\s* # match "Drupal.t" with whitespace
+ \(\s* # match "(" argument list start
+ (' . LOCALE_JS_STRING . ')\s* # capture string argument
+ (?:,\s*' . LOCALE_JS_OBJECT . '\s* # optionally capture str args
+ (?:,\s*' . LOCALE_JS_OBJECT_CONTEXT . '\s*) # optionally capture context
+ ?)? # close optional args
+ [,\)] # match ")" or "," to finish
+ ~sx', $file, $t_matches);
+
+ // Match all Drupal.formatPlural() calls in another array.
+ preg_match_all('~
+ [^\w]Drupal\s*\.\s*formatPlural\s* # match "Drupal.formatPlural" with whitespace
+ \( # match "(" argument list start
+ \s*.+?\s*,\s* # match count argument
+ (' . LOCALE_JS_STRING . ')\s*,\s* # match singular string argument
+ ( # capture plural string argument
+ (?: # non-capturing group to repeat string pieces
+ (?:
+ \' # match start of single-quoted string
+ (?:\\\\\'|[^\'])* # match any character except unescaped single-quote
+ @count # match "@count"
+ (?:\\\\\'|[^\'])* # match any character except unescaped single-quote
+ \' # match end of single-quoted string
+ |
+ " # match start of double-quoted string
+ (?:\\\\"|[^"])* # match any character except unescaped double-quote
+ @count # match "@count"
+ (?:\\\\"|[^"])* # match any character except unescaped double-quote
+ " # match end of double-quoted string
+ )
+ (?:\s*\+\s*)? # match "+" with possible whitespace, for str concat
+ )+ # match multiple because we supports concatenating strs
+ )\s* # end capturing of plural string argument
+ (?:,\s*' . LOCALE_JS_OBJECT . '\s* # optionally capture string args
+ (?:,\s*' . LOCALE_JS_OBJECT_CONTEXT . '\s*)? # optionally capture context
+ )?
+ [,\)]
+ ~sx', $file, $plural_matches);
+
+ $matches = array();
+
+ // Add strings from Drupal.t().
+ foreach ($t_matches[1] as $key => $string) {
+ $matches[] = array(
+ 'string' => $string,
+ 'context' => $t_matches[2][$key],
+ );
+ }
+
+ // Add string from Drupal.formatPlural().
+ foreach ($plural_matches[1] as $key => $string) {
+ $matches[] = array(
+ 'string' => $string,
+ 'context' => $plural_matches[3][$key],
+ );
+
+ // If there is also a plural version of this string, add it to the strings array.
+ if (isset($plural_matches[2][$key])) {
+ $matches[] = array(
+ 'string' => $plural_matches[2][$key],
+ 'context' => $plural_matches[3][$key],
+ );
+ }
+ }
+
+ // Loop through all matches and process them.
+ foreach ($matches as $key => $match) {
+
+ // Remove the quotes and string concatenations from the string and context.
+ $string = implode('', preg_split('~(?<!\\\\)[\'"]\s*\+\s*[\'"]~s', substr($match['string'], 1, -1)));
+ $context = implode('', preg_split('~(?<!\\\\)[\'"]\s*\+\s*[\'"]~s', substr($match['context'], 1, -1)));
+
+ $source = db_query("SELECT lid, location FROM {locales_source} WHERE source = :source AND context = :context", array(':source' => $string, ':context' => $context))->fetchObject();
+ if ($source) {
+ // We already have this source string and now have to add the location
+ // to the location column, if this file is not yet present in there.
+ $locations = preg_split('~\s*;\s*~', $source->location);
+
+ if (!in_array($filepath, $locations)) {
+ $locations[] = $filepath;
+ $locations = implode('; ', $locations);
+
+ // Save the new locations string to the database.
+ db_update('locales_source')
+ ->fields(array(
+ 'location' => $locations,
+ ))
+ ->condition('lid', $source->lid)
+ ->execute();
+ }
+ }
+ else {
+ // We don't have the source string yet, thus we insert it into the database.
+ db_insert('locales_source')
+ ->fields(array(
+ 'location' => $filepath,
+ 'source' => $string,
+ 'context' => $context,
+ ))
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Force the JavaScript translation file(s) to be refreshed.
+ *
+ * This function sets a refresh flag for a specified language, or all
+ * languages except English, if none specified. JavaScript translation
+ * files are rebuilt (with locale_update_js_files()) the next time a
+ * request is served in that language.
+ *
+ * @param $langcode
+ * The language code for which the file needs to be refreshed.
+ *
+ * @return
+ * New content of the 'javascript_parsed' variable.
+ */
+function _locale_invalidate_js($langcode = NULL) {
+ $parsed = variable_get('javascript_parsed', array());
+
+ if (empty($langcode)) {
+ // Invalidate all languages.
+ $languages = language_list();
+ if (!locale_translate_english()) {
+ unset($languages['en']);
+ }
+ foreach ($languages as $lcode => $data) {
+ $parsed['refresh:' . $lcode] = 'waiting';
+ }
+ }
+ else {
+ // Invalidate single language.
+ $parsed['refresh:' . $langcode] = 'waiting';
+ }
+
+ variable_set('javascript_parsed', $parsed);
+ return $parsed;
+}
+
+/**
+ * (Re-)Creates the JavaScript translation file for a language.
+ *
+ * @param $language
+ * The language, the translation file should be (re)created for.
+ */
+function _locale_rebuild_js($langcode = NULL) {
+ if (!isset($langcode)) {
+ global $language;
+ }
+ else {
+ // Get information about the locale.
+ $languages = language_list();
+ $language = $languages[$langcode];
+ }
+
+ // Construct the array for JavaScript translations.
+ // Only add strings with a translation to the translations array.
+ $result = db_query("SELECT s.lid, s.source, s.context, t.translation FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.location LIKE '%.js%'", array(':language' => $language->language));
+
+ $translations = array();
+ foreach ($result as $data) {
+ $translations[$data->context][$data->source] = $data->translation;
+ }
+
+ // Construct the JavaScript file, if there are translations.
+ $data_hash = NULL;
+ $data = $status = '';
+ if (!empty($translations)) {
+
+ $data = "Drupal.locale = { ";
+
+ if (!empty($language->formula)) {
+ $data .= "'pluralFormula': function (\$n) { return Number({$language->formula}); }, ";
+ }
+
+ $data .= "'strings': " . drupal_json_encode($translations) . " };";
+ $data_hash = drupal_hash_base64($data);
+ }
+
+ // Construct the filepath where JS translation files are stored.
+ // There is (on purpose) no front end to edit that variable.
+ $dir = 'public://' . variable_get('locale_js_directory', 'languages');
+
+ // Delete old file, if we have no translations anymore, or a different file to be saved.
+ $changed_hash = $language->javascript != $data_hash;
+ if (!empty($language->javascript) && (!$data || $changed_hash)) {
+ file_unmanaged_delete($dir . '/' . $language->language . '_' . $language->javascript . '.js');
+ $language->javascript = '';
+ $status = 'deleted';
+ }
+
+ // Only create a new file if the content has changed or the original file got
+ // lost.
+ $dest = $dir . '/' . $language->language . '_' . $data_hash . '.js';
+ if ($data && ($changed_hash || !file_exists($dest))) {
+ // Ensure that the directory exists and is writable, if possible.
+ file_prepare_directory($dir, FILE_CREATE_DIRECTORY);
+
+ // Save the file.
+ if (file_unmanaged_save_data($data, $dest)) {
+ $language->javascript = $data_hash;
+ // If we deleted a previous version of the file and we replace it with a
+ // new one we have an update.
+ if ($status == 'deleted') {
+ $status = 'updated';
+ }
+ // If the file did not exist previously and the data has changed we have
+ // a fresh creation.
+ elseif ($changed_hash) {
+ $status = 'created';
+ }
+ // If the data hash is unchanged the translation was lost and has to be
+ // rebuilt.
+ else {
+ $status = 'rebuilt';
+ }
+ }
+ else {
+ $language->javascript = '';
+ $status = 'error';
+ }
+ }
+
+ // Save the new JavaScript hash (or an empty value if the file just got
+ // deleted). Act only if some operation was executed that changed the hash
+ // code.
+ if ($status && $changed_hash) {
+ db_update('languages')
+ ->fields(array(
+ 'javascript' => $language->javascript,
+ ))
+ ->condition('language', $language->language)
+ ->execute();
+
+ // Update the default language variable if the default language has been altered.
+ // This is necessary to keep the variable consistent with the database
+ // version of the language and to prevent checking against an outdated hash.
+ $default_langcode = language_default()->language;
+ drupal_static_reset('language_list');
+ if ($default_langcode == $language->language) {
+ $default = language_load($default_langcode);
+ variable_set('language_default', $default);
+ }
+ }
+
+ // Log the operation and return success flag.
+ switch ($status) {
+ case 'updated':
+ watchdog('locale', 'Updated JavaScript translation file for the language %language.', array('%language' => $language->name));
+ return TRUE;
+ case 'rebuilt':
+ watchdog('locale', 'JavaScript translation file %file.js was lost.', array('%file' => $language->javascript), WATCHDOG_WARNING);
+ // Proceed to the 'created' case as the JavaScript translation file has
+ // been created again.
+ case 'created':
+ watchdog('locale', 'Created JavaScript translation file for the language %language.', array('%language' => $language->name));
+ return TRUE;
+ case 'deleted':
+ watchdog('locale', 'Removed JavaScript translation file for the language %language because no translations currently exist for that language.', array('%language' => $language->name));
+ return TRUE;
+ case 'error':
+ watchdog('locale', 'An error occurred during creation of the JavaScript translation file for the language %language.', array('%language' => $language->name), WATCHDOG_ERROR);
+ return FALSE;
+ default:
+ // No operation needed.
+ return TRUE;
+ }
+}
+
+/**
+ * @defgroup locale-api-predefined List of predefined languages
+ * @{
+ * API to provide a list of predefined languages.
+ */
+
+/**
+ * Prepares the language code list for a select form item with only the unsupported ones
+ */
+function _locale_prepare_predefined_list() {
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $languages = language_list();
+ $predefined = standard_language_list();
+ foreach ($predefined as $key => $value) {
+ if (isset($languages[$key])) {
+ unset($predefined[$key]);
+ continue;
+ }
+ $predefined[$key] = t($value[0]);
+ }
+ asort($predefined);
+ return $predefined;
+}
+
+/**
+ * @} End of "locale-api-languages-predefined"
+ */
+
+/**
+ * Get list of all predefined and custom countries.
+ *
+ * @return
+ * An array of all country code => country name pairs.
+ */
+function country_get_list() {
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $countries = standard_country_list();
+ // Allow other modules to modify the country list.
+ drupal_alter('countries', $countries);
+ return $countries;
+}
+
+/**
+ * Save locale specific date formats to the database.
+ *
+ * @param $langcode
+ * Language code, can be 2 characters, e.g. 'en' or 5 characters, e.g.
+ * 'en-CA'.
+ * @param $type
+ * Date format type, e.g. 'short', 'medium'.
+ * @param $format
+ * The date format string.
+ */
+function locale_date_format_save($langcode, $type, $format) {
+ $locale_format = array();
+ $locale_format['language'] = $langcode;
+ $locale_format['type'] = $type;
+ $locale_format['format'] = $format;
+
+ $is_existing = (bool) db_query_range('SELECT 1 FROM {date_format_locale} WHERE language = :langcode AND type = :type', 0, 1, array(':langcode' => $langcode, ':type' => $type))->fetchField();
+ if ($is_existing) {
+ $keys = array('type', 'language');
+ drupal_write_record('date_format_locale', $locale_format, $keys);
+ }
+ else {
+ drupal_write_record('date_format_locale', $locale_format);
+ }
+}
+
+/**
+ * Select locale date format details from database.
+ *
+ * @param $languages
+ * An array of language codes.
+ *
+ * @return
+ * An array of date formats.
+ */
+function locale_get_localized_date_format($languages) {
+ $formats = array();
+
+ // Get list of different format types.
+ $format_types = system_get_date_types();
+ $short_default = variable_get('date_format_short', 'm/d/Y - H:i');
+
+ // Loop through each language until we find one with some date formats
+ // configured.
+ foreach ($languages as $language) {
+ $date_formats = system_date_format_locale($language);
+ if (!empty($date_formats)) {
+ // We have locale-specific date formats, so check for their types. If
+ // we're missing a type, use the default setting instead.
+ foreach ($format_types as $type => $type_info) {
+ // If format exists for this language, use it.
+ if (!empty($date_formats[$type])) {
+ $formats['date_format_' . $type] = $date_formats[$type];
+ }
+ // Otherwise get default variable setting. If this is not set, default
+ // to the short format.
+ else {
+ $formats['date_format_' . $type] = variable_get('date_format_' . $type, $short_default);
+ }
+ }
+
+ // Return on the first match.
+ return $formats;
+ }
+ }
+
+ // No locale specific formats found, so use defaults.
+ $system_types = array('short', 'medium', 'long');
+ // Handle system types separately as they have defaults if no variable exists.
+ $formats['date_format_short'] = $short_default;
+ $formats['date_format_medium'] = variable_get('date_format_medium', 'D, m/d/Y - H:i');
+ $formats['date_format_long'] = variable_get('date_format_long', 'l, F j, Y - H:i');
+
+ // For non-system types, get the default setting, otherwise use the short
+ // format.
+ foreach ($format_types as $type => $type_info) {
+ if (!in_array($type, $system_types)) {
+ $formats['date_format_' . $type] = variable_get('date_format_' . $type, $short_default);
+ }
+ }
+
+ return $formats;
+}
diff --git a/core/includes/lock.inc b/core/includes/lock.inc
new file mode 100644
index 000000000000..7dd8db30a163
--- /dev/null
+++ b/core/includes/lock.inc
@@ -0,0 +1,274 @@
+<?php
+
+/**
+ * @file
+ * A database-mediated implementation of a locking mechanism.
+ */
+
+/**
+ * @defgroup lock Locking mechanisms
+ * @{
+ * Functions to coordinate long-running operations across requests.
+ *
+ * In most environments, multiple Drupal page requests (a.k.a. threads or
+ * processes) will execute in parallel. This leads to potential conflicts or
+ * race conditions when two requests execute the same code at the same time. A
+ * common example of this is a rebuild like menu_rebuild() where we invoke many
+ * hook implementations to get and process data from all active modules, and
+ * then delete the current data in the database to insert the new afterwards.
+ *
+ * This is a cooperative, advisory lock system. Any long-running operation
+ * that could potentially be attempted in parallel by multiple requests should
+ * try to acquire a lock before proceeding. By obtaining a lock, one request
+ * notifies any other requests that a specific operation is in progress which
+ * must not be executed in parallel.
+ *
+ * To use this API, pick a unique name for the lock. A sensible choice is the
+ * name of the function performing the operation. A very simple example use of
+ * this API:
+ * @code
+ * function mymodule_long_operation() {
+ * if (lock_acquire('mymodule_long_operation')) {
+ * // Do the long operation here.
+ * // ...
+ * lock_release('mymodule_long_operation');
+ * }
+ * }
+ * @endcode
+ *
+ * If a function acquires a lock it should always release it when the
+ * operation is complete by calling lock_release(), as in the example.
+ *
+ * A function that has acquired a lock may attempt to renew a lock (extend the
+ * duration of the lock) by calling lock_acquire() again during the operation.
+ * Failure to renew a lock is indicative that another request has acquired
+ * the lock, and that the current operation may need to be aborted.
+ *
+ * If a function fails to acquire a lock it may either immediately return, or
+ * it may call lock_wait() if the rest of the current page request requires
+ * that the operation in question be complete. After lock_wait() returns,
+ * the function may again attempt to acquire the lock, or may simply allow the
+ * page request to proceed on the assumption that a parallel request completed
+ * the operation.
+ *
+ * lock_acquire() and lock_wait() will automatically break (delete) a lock
+ * whose duration has exceeded the timeout specified when it was acquired.
+ *
+ * Alternative implementations of this API (such as APC) may be substituted
+ * by setting the 'lock_inc' variable to an alternate include filepath. Since
+ * this is an API intended to support alternative implementations, code using
+ * this API should never rely upon specific implementation details (for example
+ * no code should look for or directly modify a lock in the {semaphore} table).
+ */
+
+/**
+ * Initialize the locking system.
+ */
+function lock_initialize() {
+ global $locks;
+
+ $locks = array();
+}
+
+/**
+ * Helper function to get this request's unique id.
+ */
+function _lock_id() {
+ // Do not use drupal_static(). This identifier refers to the current
+ // client request, and must not be changed under any circumstances
+ // else the shutdown handler may fail to release our locks.
+ static $lock_id;
+
+ if (!isset($lock_id)) {
+ // Assign a unique id.
+ $lock_id = uniqid(mt_rand(), TRUE);
+ // We only register a shutdown function if a lock is used.
+ drupal_register_shutdown_function('lock_release_all', $lock_id);
+ }
+ return $lock_id;
+}
+
+/**
+ * Acquire (or renew) a lock, but do not block if it fails.
+ *
+ * @param $name
+ * The name of the lock.
+ * @param $timeout
+ * A number of seconds (float) before the lock expires (minimum of 0.001).
+ *
+ * @return
+ * TRUE if the lock was acquired, FALSE if it failed.
+ */
+function lock_acquire($name, $timeout = 30.0) {
+ global $locks;
+
+ // Insure that the timeout is at least 1 ms.
+ $timeout = max($timeout, 0.001);
+ $expire = microtime(TRUE) + $timeout;
+ if (isset($locks[$name])) {
+ // Try to extend the expiration of a lock we already acquired.
+ $success = (bool) db_update('semaphore')
+ ->fields(array('expire' => $expire))
+ ->condition('name', $name)
+ ->condition('value', _lock_id())
+ ->execute();
+ if (!$success) {
+ // The lock was broken.
+ unset($locks[$name]);
+ }
+ return $success;
+ }
+ else {
+ // Optimistically try to acquire the lock, then retry once if it fails.
+ // The first time through the loop cannot be a retry.
+ $retry = FALSE;
+ // We always want to do this code at least once.
+ do {
+ try {
+ db_insert('semaphore')
+ ->fields(array(
+ 'name' => $name,
+ 'value' => _lock_id(),
+ 'expire' => $expire,
+ ))
+ ->execute();
+ // We track all acquired locks in the global variable.
+ $locks[$name] = TRUE;
+ // We never need to try again.
+ $retry = FALSE;
+ }
+ catch (PDOException $e) {
+ // Suppress the error. If this is our first pass through the loop,
+ // then $retry is FALSE. In this case, the insert must have failed
+ // meaning some other request acquired the lock but did not release it.
+ // We decide whether to retry by checking lock_may_be_available()
+ // Since this will break the lock in case it is expired.
+ $retry = $retry ? FALSE : lock_may_be_available($name);
+ }
+ // We only retry in case the first attempt failed, but we then broke
+ // an expired lock.
+ } while ($retry);
+ }
+ return isset($locks[$name]);
+}
+
+/**
+ * Check if lock acquired by a different process may be available.
+ *
+ * If an existing lock has expired, it is removed.
+ *
+ * @param $name
+ * The name of the lock.
+ *
+ * @return
+ * TRUE if there is no lock or it was removed, FALSE otherwise.
+ */
+function lock_may_be_available($name) {
+ $lock = db_query('SELECT expire, value FROM {semaphore} WHERE name = :name', array(':name' => $name))->fetchAssoc();
+ if (!$lock) {
+ return TRUE;
+ }
+ $expire = (float) $lock['expire'];
+ $now = microtime(TRUE);
+ if ($now > $expire) {
+ // We check two conditions to prevent a race condition where another
+ // request acquired the lock and set a new expire time. We add a small
+ // number to $expire to avoid errors with float to string conversion.
+ return (bool) db_delete('semaphore')
+ ->condition('name', $name)
+ ->condition('value', $lock['value'])
+ ->condition('expire', 0.0001 + $expire, '<=')
+ ->execute();
+ }
+ return FALSE;
+}
+
+/**
+ * Wait for a lock to be available.
+ *
+ * This function may be called in a request that fails to acquire a desired
+ * lock. This will block further execution until the lock is available or the
+ * specified delay in seconds is reached. This should not be used with locks
+ * that are acquired very frequently, since the lock is likely to be acquired
+ * again by a different request while waiting.
+ *
+ * @param $name
+ * The name of the lock.
+ * @param $delay
+ * The maximum number of seconds to wait, as an integer.
+ *
+ * @return
+ * TRUE if the lock holds, FALSE if it is available.
+ */
+function lock_wait($name, $delay = 30) {
+ // Pause the process for short periods between calling
+ // lock_may_be_available(). This prevents hitting the database with constant
+ // database queries while waiting, which could lead to performance issues.
+ // However, if the wait period is too long, there is the potential for a
+ // large number of processes to be blocked waiting for a lock, especially
+ // if the item being rebuilt is commonly requested. To address both of these
+ // concerns, begin waiting for 25ms, then add 25ms to the wait period each
+ // time until it reaches 500ms. After this point polling will continue every
+ // 500ms until $delay is reached.
+
+ // $delay is passed in seconds, but we will be using usleep(), which takes
+ // microseconds as a parameter. Multiply it by 1 million so that all
+ // further numbers are equivalent.
+ $delay = (int) $delay * 1000000;
+
+ // Begin sleeping at 25ms.
+ $sleep = 25000;
+ while ($delay > 0) {
+ // This function should only be called by a request that failed to get a
+ // lock, so we sleep first to give the parallel request a chance to finish
+ // and release the lock.
+ usleep($sleep);
+ // After each sleep, increase the value of $sleep until it reaches
+ // 500ms, to reduce the potential for a lock stampede.
+ $delay = $delay - $sleep;
+ $sleep = min(500000, $sleep + 25000, $delay);
+ if (lock_may_be_available($name)) {
+ // No longer need to wait.
+ return FALSE;
+ }
+ }
+ // The caller must still wait longer to get the lock.
+ return TRUE;
+}
+
+/**
+ * Release a lock previously acquired by lock_acquire().
+ *
+ * This will release the named lock if it is still held by the current request.
+ *
+ * @param $name
+ * The name of the lock.
+ */
+function lock_release($name) {
+ global $locks;
+
+ unset($locks[$name]);
+ db_delete('semaphore')
+ ->condition('name', $name)
+ ->condition('value', _lock_id())
+ ->execute();
+}
+
+/**
+ * Release all previously acquired locks.
+ */
+function lock_release_all($lock_id = NULL) {
+ global $locks;
+
+ $locks = array();
+ if (empty($lock_id)) {
+ $lock_id = _lock_id();
+ }
+ db_delete('semaphore')
+ ->condition('value', $lock_id)
+ ->execute();
+}
+
+/**
+ * @} End of "defgroup lock".
+ */
diff --git a/core/includes/mail.inc b/core/includes/mail.inc
new file mode 100644
index 000000000000..7272df972e29
--- /dev/null
+++ b/core/includes/mail.inc
@@ -0,0 +1,586 @@
+<?php
+
+/**
+ * @file
+ * API functions for processing and sending e-mail.
+ */
+
+/**
+ * Auto-detect appropriate line endings for e-mails.
+ *
+ * $conf['mail_line_endings'] will override this setting.
+ */
+define('MAIL_LINE_ENDINGS', isset($_SERVER['WINDIR']) || strpos($_SERVER['SERVER_SOFTWARE'], 'Win32') !== FALSE ? "\r\n" : "\n");
+
+/**
+ * Compose and optionally send an e-mail message.
+ *
+ * Sending an e-mail works with defining an e-mail template (subject, text
+ * and possibly e-mail headers) and the replacement values to use in the
+ * appropriate places in the template. Processed e-mail templates are
+ * requested from hook_mail() from the module sending the e-mail. Any module
+ * can modify the composed e-mail message array using hook_mail_alter().
+ * Finally drupal_mail_system()->mail() sends the e-mail, which can
+ * be reused if the exact same composed e-mail is to be sent to multiple
+ * recipients.
+ *
+ * Finding out what language to send the e-mail with needs some consideration.
+ * If you send e-mail to a user, her preferred language should be fine, so
+ * use user_preferred_language(). If you send email based on form values
+ * filled on the page, there are two additional choices if you are not
+ * sending the e-mail to a user on the site. You can either use the language
+ * used to generate the page ($language global variable) or the site default
+ * language. See language_default(). The former is good if sending e-mail to
+ * the person filling the form, the later is good if you send e-mail to an
+ * address previously set up (like contact addresses in a contact form).
+ *
+ * Taking care of always using the proper language is even more important
+ * when sending e-mails in a row to multiple users. Hook_mail() abstracts
+ * whether the mail text comes from an administrator setting or is
+ * static in the source code. It should also deal with common mail tokens,
+ * only receiving $params which are unique to the actual e-mail at hand.
+ *
+ * An example:
+ *
+ * @code
+ * function example_notify($accounts) {
+ * foreach ($accounts as $account) {
+ * $params['account'] = $account;
+ * // example_mail() will be called based on the first drupal_mail() parameter.
+ * drupal_mail('example', 'notice', $account->mail, user_preferred_language($account), $params);
+ * }
+ * }
+ *
+ * function example_mail($key, &$message, $params) {
+ * $data['user'] = $params['account'];
+ * $options['language'] = $message['language'];
+ * user_mail_tokens($variables, $data, $options);
+ * switch($key) {
+ * case 'notice':
+ * $langcode = $message['language']->language;
+ * $message['subject'] = t('Notification from !site', $variables, array('langcode' => $langcode));
+ * $message['body'][] = t("Dear !username\n\nThere is new content available on the site.", $variables, array('langcode' => $langcode));
+ * break;
+ * }
+ * }
+ * @endcode
+ *
+ * @param $module
+ * A module name to invoke hook_mail() on. The {$module}_mail() hook will be
+ * called to complete the $message structure which will already contain common
+ * defaults.
+ * @param $key
+ * A key to identify the e-mail sent. The final e-mail id for e-mail altering
+ * will be {$module}_{$key}.
+ * @param $to
+ * The e-mail address or addresses where the message will be sent to. The
+ * formatting of this string must comply with RFC 2822. Some examples are:
+ * - user@example.com
+ * - user@example.com, anotheruser@example.com
+ * - User <user@example.com>
+ * - User <user@example.com>, Another User <anotheruser@example.com>
+ * @param $language
+ * Language object to use to compose the e-mail.
+ * @param $params
+ * Optional parameters to build the e-mail.
+ * @param $from
+ * Sets From to this value, if given.
+ * @param $send
+ * Send the message directly, without calling drupal_mail_system()->mail()
+ * manually.
+ *
+ * @return
+ * The $message array structure containing all details of the
+ * message. If already sent ($send = TRUE), then the 'result' element
+ * will contain the success indicator of the e-mail, failure being already
+ * written to the watchdog. (Success means nothing more than the message being
+ * accepted at php-level, which still doesn't guarantee it to be delivered.)
+ */
+function drupal_mail($module, $key, $to, $language, $params = array(), $from = NULL, $send = TRUE) {
+ $default_from = variable_get('site_mail', ini_get('sendmail_from'));
+
+ // Bundle up the variables into a structured array for altering.
+ $message = array(
+ 'id' => $module . '_' . $key,
+ 'module' => $module,
+ 'key' => $key,
+ 'to' => $to,
+ 'from' => isset($from) ? $from : $default_from,
+ 'language' => $language,
+ 'params' => $params,
+ 'subject' => '',
+ 'body' => array()
+ );
+
+ // Build the default headers
+ $headers = array(
+ 'MIME-Version' => '1.0',
+ 'Content-Type' => 'text/plain; charset=UTF-8; format=flowed; delsp=yes',
+ 'Content-Transfer-Encoding' => '8Bit',
+ 'X-Mailer' => 'Drupal'
+ );
+ if ($default_from) {
+ // To prevent e-mail from looking like spam, the addresses in the Sender and
+ // Return-Path headers should have a domain authorized to use the originating
+ // SMTP server.
+ $headers['From'] = $headers['Sender'] = $headers['Return-Path'] = $default_from;
+ }
+ if ($from) {
+ $headers['From'] = $from;
+ }
+ $message['headers'] = $headers;
+
+ // Build the e-mail (get subject and body, allow additional headers) by
+ // invoking hook_mail() on this module. We cannot use module_invoke() as
+ // we need to have $message by reference in hook_mail().
+ if (function_exists($function = $module . '_mail')) {
+ $function($key, $message, $params);
+ }
+
+ // Invoke hook_mail_alter() to allow all modules to alter the resulting e-mail.
+ drupal_alter('mail', $message);
+
+ // Retrieve the responsible implementation for this message.
+ $system = drupal_mail_system($module, $key);
+
+ // Format the message body.
+ $message = $system->format($message);
+
+ // Optionally send e-mail.
+ if ($send) {
+ $message['result'] = $system->mail($message);
+
+ // Log errors
+ if (!$message['result']) {
+ watchdog('mail', 'Error sending e-mail (from %from to %to).', array('%from' => $message['from'], '%to' => $message['to']), WATCHDOG_ERROR);
+ drupal_set_message(t('Unable to send e-mail. Contact the site administrator if the problem persists.'), 'error');
+ }
+ }
+
+ return $message;
+}
+
+/**
+ * Returns an object that implements the MailSystemInterface.
+ *
+ * Allows for one or more custom mail backends to format and send mail messages
+ * composed using drupal_mail().
+ *
+ * An implementation needs to implement the following methods:
+ * - format: Allows to preprocess, format, and postprocess a mail
+ * message before it is passed to the sending system. By default, all messages
+ * may contain HTML and are converted to plain-text by the DefaultMailSystem
+ * implementation. For example, an alternative implementation could override
+ * the default implementation and additionally sanitize the HTML for usage in
+ * a MIME-encoded e-mail, but still invoking the DefaultMailSystem
+ * implementation to generate an alternate plain-text version for sending.
+ * - mail: Sends a message through a custom mail sending engine.
+ * By default, all messages are sent via PHP's mail() function by the
+ * DefaultMailSystem implementation.
+ *
+ * The selection of a particular implementation is controlled via the variable
+ * 'mail_system', which is a keyed array. The default implementation
+ * is the class whose name is the value of 'default-system' key. A more specific
+ * match first to key and then to module will be used in preference to the
+ * default. To specificy a different class for all mail sent by one module, set
+ * the class name as the value for the key corresponding to the module name. To
+ * specificy a class for a particular message sent by one module, set the class
+ * name as the value for the array key that is the message id, which is
+ * "${module}_${key}".
+ *
+ * For example to debug all mail sent by the user module by logging it to a
+ * file, you might set the variable as something like:
+ *
+ * @code
+ * array(
+ * 'default-system' => 'DefaultMailSystem',
+ * 'user' => 'DevelMailLog',
+ * );
+ * @endcode
+ *
+ * Finally, a different system can be specified for a specific e-mail ID (see
+ * the $key param), such as one of the keys used by the contact module:
+ *
+ * @code
+ * array(
+ * 'default-system' => 'DefaultMailSystem',
+ * 'user' => 'DevelMailLog',
+ * 'contact_page_autoreply' => 'DrupalDevNullMailSend',
+ * );
+ * @endcode
+ *
+ * Other possible uses for system include a mail-sending class that actually
+ * sends (or duplicates) each message to SMS, Twitter, instant message, etc, or
+ * a class that queues up a large number of messages for more efficient bulk
+ * sending or for sending via a remote gateway so as to reduce the load
+ * on the local server.
+ *
+ * @param $module
+ * The module name which was used by drupal_mail() to invoke hook_mail().
+ * @param $key
+ * A key to identify the e-mail sent. The final e-mail ID for the e-mail
+ * alter hook in drupal_mail() would have been {$module}_{$key}.
+ *
+ * @return MailSystemInterface
+ */
+function drupal_mail_system($module, $key) {
+ $instances = &drupal_static(__FUNCTION__, array());
+
+ $id = $module . '_' . $key;
+
+ $configuration = variable_get('mail_system', array('default-system' => 'DefaultMailSystem'));
+
+ // Look for overrides for the default class, starting from the most specific
+ // id, and falling back to the module name.
+ if (isset($configuration[$id])) {
+ $class = $configuration[$id];
+ }
+ elseif (isset($configuration[$module])) {
+ $class = $configuration[$module];
+ }
+ else {
+ $class = $configuration['default-system'];
+ }
+
+ if (empty($instances[$class])) {
+ $interfaces = class_implements($class);
+ if (isset($interfaces['MailSystemInterface'])) {
+ $instances[$class] = new $class();
+ }
+ else {
+ throw new Exception(t('Class %class does not implement interface %interface', array('%class' => $class, '%interface' => 'MailSystemInterface')));
+ }
+ }
+ return $instances[$class];
+}
+
+/**
+ * An interface for pluggable mail back-ends.
+ */
+interface MailSystemInterface {
+ /**
+ * Format a message composed by drupal_mail() prior sending.
+ *
+ * @param $message
+ * A message array, as described in hook_mail_alter().
+ *
+ * @return
+ * The formatted $message.
+ */
+ public function format(array $message);
+
+ /**
+ * Send a message composed by drupal_mail().
+ *
+ * @param $message
+ * Message array with at least the following elements:
+ * - id: A unique identifier of the e-mail type. Examples: 'contact_user_copy',
+ * 'user_password_reset'.
+ * - to: The mail address or addresses where the message will be sent to.
+ * The formatting of this string must comply with RFC 2822. Some examples:
+ * - user@example.com
+ * - user@example.com, anotheruser@example.com
+ * - User <user@example.com>
+ * - User <user@example.com>, Another User <anotheruser@example.com>
+ * - subject: Subject of the e-mail to be sent. This must not contain any
+ * newline characters, or the mail may not be sent properly.
+ * - body: Message to be sent. Accepts both CRLF and LF line-endings.
+ * E-mail bodies must be wrapped. You can use drupal_wrap_mail() for
+ * smart plain text wrapping.
+ * - headers: Associative array containing all additional mail headers not
+ * defined by one of the other parameters. PHP's mail() looks for Cc
+ * and Bcc headers and sends the mail to addresses in these headers too.
+ *
+ * @return
+ * TRUE if the mail was successfully accepted for delivery, otherwise FALSE.
+ */
+ public function mail(array $message);
+}
+
+/**
+ * Perform format=flowed soft wrapping for mail (RFC 3676).
+ *
+ * We use delsp=yes wrapping, but only break non-spaced languages when
+ * absolutely necessary to avoid compatibility issues.
+ *
+ * We deliberately use LF rather than CRLF, see drupal_mail().
+ *
+ * @param $text
+ * The plain text to process.
+ * @param $indent (optional)
+ * A string to indent the text with. Only '>' characters are repeated on
+ * subsequent wrapped lines. Others are replaced by spaces.
+ */
+function drupal_wrap_mail($text, $indent = '') {
+ // Convert CRLF into LF.
+ $text = str_replace("\r", '', $text);
+ // See if soft-wrapping is allowed.
+ $clean_indent = _drupal_html_to_text_clean($indent);
+ $soft = strpos($clean_indent, ' ') === FALSE;
+ // Check if the string has line breaks.
+ if (strpos($text, "\n") !== FALSE) {
+ // Remove trailing spaces to make existing breaks hard.
+ $text = preg_replace('/ +\n/m', "\n", $text);
+ // Wrap each line at the needed width.
+ $lines = explode("\n", $text);
+ array_walk($lines, '_drupal_wrap_mail_line', array('soft' => $soft, 'length' => strlen($indent)));
+ $text = implode("\n", $lines);
+ }
+ else {
+ // Wrap this line.
+ _drupal_wrap_mail_line($text, 0, array('soft' => $soft, 'length' => strlen($indent)));
+ }
+ // Empty lines with nothing but spaces.
+ $text = preg_replace('/^ +\n/m', "\n", $text);
+ // Space-stuff special lines.
+ $text = preg_replace('/^(>| |From)/m', ' $1', $text);
+ // Apply indentation. We only include non-'>' indentation on the first line.
+ $text = $indent . substr(preg_replace('/^/m', $clean_indent, $text), strlen($indent));
+
+ return $text;
+}
+
+/**
+ * Transform an HTML string into plain text, preserving the structure of the
+ * markup. Useful for preparing the body of a node to be sent by e-mail.
+ *
+ * The output will be suitable for use as 'format=flowed; delsp=yes' text
+ * (RFC 3676) and can be passed directly to drupal_mail() for sending.
+ *
+ * We deliberately use LF rather than CRLF, see drupal_mail().
+ *
+ * This function provides suitable alternatives for the following tags:
+ * <a> <em> <i> <strong> <b> <br> <p> <blockquote> <ul> <ol> <li> <dl> <dt>
+ * <dd> <h1> <h2> <h3> <h4> <h5> <h6> <hr>
+ *
+ * @param $string
+ * The string to be transformed.
+ * @param $allowed_tags (optional)
+ * If supplied, a list of tags that will be transformed. If omitted, all
+ * all supported tags are transformed.
+ *
+ * @return
+ * The transformed string.
+ */
+function drupal_html_to_text($string, $allowed_tags = NULL) {
+ // Cache list of supported tags.
+ static $supported_tags;
+ if (empty($supported_tags)) {
+ $supported_tags = array('a', 'em', 'i', 'strong', 'b', 'br', 'p', 'blockquote', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr');
+ }
+
+ // Make sure only supported tags are kept.
+ $allowed_tags = isset($allowed_tags) ? array_intersect($supported_tags, $allowed_tags) : $supported_tags;
+
+ // Make sure tags, entities and attributes are well-formed and properly nested.
+ $string = _filter_htmlcorrector(filter_xss($string, $allowed_tags));
+
+ // Apply inline styles.
+ $string = preg_replace('!</?(em|i)((?> +)[^>]*)?>!i', '/', $string);
+ $string = preg_replace('!</?(strong|b)((?> +)[^>]*)?>!i', '*', $string);
+
+ // Replace inline <a> tags with the text of link and a footnote.
+ // 'See <a href="http://drupal.org">the Drupal site</a>' becomes
+ // 'See the Drupal site [1]' with the URL included as a footnote.
+ _drupal_html_to_mail_urls(NULL, TRUE);
+ $pattern = '@(<a[^>]+?href="([^"]*)"[^>]*?>(.+?)</a>)@i';
+ $string = preg_replace_callback($pattern, '_drupal_html_to_mail_urls', $string);
+ $urls = _drupal_html_to_mail_urls();
+ $footnotes = '';
+ if (count($urls)) {
+ $footnotes .= "\n";
+ for ($i = 0, $max = count($urls); $i < $max; $i++) {
+ $footnotes .= '[' . ($i + 1) . '] ' . $urls[$i] . "\n";
+ }
+ }
+
+ // Split tags from text.
+ $split = preg_split('/<([^>]+?)>/', $string, -1, PREG_SPLIT_DELIM_CAPTURE);
+ // Note: PHP ensures the array consists of alternating delimiters and literals
+ // and begins and ends with a literal (inserting $null as required).
+
+ $tag = FALSE; // Odd/even counter (tag or no tag)
+ $casing = NULL; // Case conversion function
+ $output = '';
+ $indent = array(); // All current indentation string chunks
+ $lists = array(); // Array of counters for opened lists
+ foreach ($split as $value) {
+ $chunk = NULL; // Holds a string ready to be formatted and output.
+
+ // Process HTML tags (but don't output any literally).
+ if ($tag) {
+ list($tagname) = explode(' ', strtolower($value), 2);
+ switch ($tagname) {
+ // List counters
+ case 'ul':
+ array_unshift($lists, '*');
+ break;
+ case 'ol':
+ array_unshift($lists, 1);
+ break;
+ case '/ul':
+ case '/ol':
+ array_shift($lists);
+ $chunk = ''; // Ensure blank new-line.
+ break;
+
+ // Quotation/list markers, non-fancy headers
+ case 'blockquote':
+ // Format=flowed indentation cannot be mixed with lists.
+ $indent[] = count($lists) ? ' "' : '>';
+ break;
+ case 'li':
+ $indent[] = isset($lists[0]) && is_numeric($lists[0]) ? ' ' . $lists[0]++ . ') ' : ' * ';
+ break;
+ case 'dd':
+ $indent[] = ' ';
+ break;
+ case 'h3':
+ $indent[] = '.... ';
+ break;
+ case 'h4':
+ $indent[] = '.. ';
+ break;
+ case '/blockquote':
+ if (count($lists)) {
+ // Append closing quote for inline quotes (immediately).
+ $output = rtrim($output, "> \n") . "\"\n";
+ $chunk = ''; // Ensure blank new-line.
+ }
+ // Fall-through
+ case '/li':
+ case '/dd':
+ array_pop($indent);
+ break;
+ case '/h3':
+ case '/h4':
+ array_pop($indent);
+ case '/h5':
+ case '/h6':
+ $chunk = ''; // Ensure blank new-line.
+ break;
+
+ // Fancy headers
+ case 'h1':
+ $indent[] = '======== ';
+ $casing = 'drupal_strtoupper';
+ break;
+ case 'h2':
+ $indent[] = '-------- ';
+ $casing = 'drupal_strtoupper';
+ break;
+ case '/h1':
+ case '/h2':
+ $casing = NULL;
+ // Pad the line with dashes.
+ $output = _drupal_html_to_text_pad($output, ($tagname == '/h1') ? '=' : '-', ' ');
+ array_pop($indent);
+ $chunk = ''; // Ensure blank new-line.
+ break;
+
+ // Horizontal rulers
+ case 'hr':
+ // Insert immediately.
+ $output .= drupal_wrap_mail('', implode('', $indent)) . "\n";
+ $output = _drupal_html_to_text_pad($output, '-');
+ break;
+
+ // Paragraphs and definition lists
+ case '/p':
+ case '/dl':
+ $chunk = ''; // Ensure blank new-line.
+ break;
+ }
+ }
+ // Process blocks of text.
+ else {
+ // Convert inline HTML text to plain text; not removing line-breaks or
+ // white-space, since that breaks newlines when sanitizing plain-text.
+ $value = trim(decode_entities($value));
+ if (drupal_strlen($value)) {
+ $chunk = $value;
+ }
+ }
+
+ // See if there is something waiting to be output.
+ if (isset($chunk)) {
+ // Apply any necessary case conversion.
+ if (isset($casing)) {
+ $chunk = $casing($chunk);
+ }
+ // Format it and apply the current indentation.
+ $output .= drupal_wrap_mail($chunk, implode('', $indent)) . MAIL_LINE_ENDINGS;
+ // Remove non-quotation markers from indentation.
+ $indent = array_map('_drupal_html_to_text_clean', $indent);
+ }
+
+ $tag = !$tag;
+ }
+
+ return $output . $footnotes;
+}
+
+/**
+ * Helper function for array_walk in drupal_wrap_mail().
+ *
+ * Wraps words on a single line.
+ */
+function _drupal_wrap_mail_line(&$line, $key, $values) {
+ // Use soft-breaks only for purely quoted or unindented text.
+ $line = wordwrap($line, 77 - $values['length'], $values['soft'] ? " \n" : "\n");
+ // Break really long words at the maximum width allowed.
+ $line = wordwrap($line, 996 - $values['length'], $values['soft'] ? " \n" : "\n");
+}
+
+/**
+ * Helper function for drupal_html_to_text().
+ *
+ * Keeps track of URLs and replaces them with placeholder tokens.
+ */
+function _drupal_html_to_mail_urls($match = NULL, $reset = FALSE) {
+ global $base_url, $base_path;
+ static $urls = array(), $regexp;
+
+ if ($reset) {
+ // Reset internal URL list.
+ $urls = array();
+ }
+ else {
+ if (empty($regexp)) {
+ $regexp = '@^' . preg_quote($base_path, '@') . '@';
+ }
+ if ($match) {
+ list(, , $url, $label) = $match;
+ // Ensure all URLs are absolute.
+ $urls[] = strpos($url, '://') ? $url : preg_replace($regexp, $base_url . '/', $url);
+ return $label . ' [' . count($urls) . ']';
+ }
+ }
+ return $urls;
+}
+
+/**
+ * Helper function for drupal_wrap_mail() and drupal_html_to_text().
+ *
+ * Replace all non-quotation markers from a given piece of indentation with spaces.
+ */
+function _drupal_html_to_text_clean($indent) {
+ return preg_replace('/[^>]/', ' ', $indent);
+}
+
+/**
+ * Helper function for drupal_html_to_text().
+ *
+ * Pad the last line with the given character.
+ */
+function _drupal_html_to_text_pad($text, $pad, $prefix = '') {
+ // Remove last line break.
+ $text = substr($text, 0, -1);
+ // Calculate needed padding space and add it.
+ if (($p = strrpos($text, "\n")) === FALSE) {
+ $p = -1;
+ }
+ $n = max(0, 79 - (strlen($text) - $p) - strlen($prefix));
+ // Add prefix and padding, and restore linebreak.
+ return $text . $prefix . str_repeat($pad, $n) . "\n";
+}
diff --git a/core/includes/menu.inc b/core/includes/menu.inc
new file mode 100644
index 000000000000..f23eb0d4dff2
--- /dev/null
+++ b/core/includes/menu.inc
@@ -0,0 +1,3798 @@
+<?php
+
+/**
+ * @file
+ * API for the Drupal menu system.
+ */
+
+/**
+ * @defgroup menu Menu system
+ * @{
+ * Define the navigation menus, and route page requests to code based on URLs.
+ *
+ * The Drupal menu system drives both the navigation system from a user
+ * perspective and the callback system that Drupal uses to respond to URLs
+ * passed from the browser. For this reason, a good understanding of the
+ * menu system is fundamental to the creation of complex modules. As a note,
+ * this is related to, but separate from menu.module, which allows menus
+ * (which in this context are hierarchical lists of links) to be customized from
+ * the Drupal administrative interface.
+ *
+ * Drupal's menu system follows a simple hierarchy defined by paths.
+ * Implementations of hook_menu() define menu items and assign them to
+ * paths (which should be unique). The menu system aggregates these items
+ * and determines the menu hierarchy from the paths. For example, if the
+ * paths defined were a, a/b, e, a/b/c/d, f/g, and a/b/h, the menu system
+ * would form the structure:
+ * - a
+ * - a/b
+ * - a/b/c/d
+ * - a/b/h
+ * - e
+ * - f/g
+ * Note that the number of elements in the path does not necessarily
+ * determine the depth of the menu item in the tree.
+ *
+ * When responding to a page request, the menu system looks to see if the
+ * path requested by the browser is registered as a menu item with a
+ * callback. If not, the system searches up the menu tree for the most
+ * complete match with a callback it can find. If the path a/b/i is
+ * requested in the tree above, the callback for a/b would be used.
+ *
+ * The found callback function is called with any arguments specified
+ * in the "page arguments" attribute of its menu item. The
+ * attribute must be an array. After these arguments, any remaining
+ * components of the path are appended as further arguments. In this
+ * way, the callback for a/b above could respond to a request for
+ * a/b/i differently than a request for a/b/j.
+ *
+ * For an illustration of this process, see page_example.module.
+ *
+ * Access to the callback functions is also protected by the menu system.
+ * The "access callback" with an optional "access arguments" of each menu
+ * item is called before the page callback proceeds. If this returns TRUE,
+ * then access is granted; if FALSE, then access is denied. Default local task
+ * menu items (see next paragraph) may omit this attribute to use the value
+ * provided by the parent item.
+ *
+ * In the default Drupal interface, you will notice many links rendered as
+ * tabs. These are known in the menu system as "local tasks", and they are
+ * rendered as tabs by default, though other presentations are possible.
+ * Local tasks function just as other menu items in most respects. It is
+ * convention that the names of these tasks should be short verbs if
+ * possible. In addition, a "default" local task should be provided for
+ * each set. When visiting a local task's parent menu item, the default
+ * local task will be rendered as if it is selected; this provides for a
+ * normal tab user experience. This default task is special in that it
+ * links not to its provided path, but to its parent item's path instead.
+ * The default task's path is only used to place it appropriately in the
+ * menu hierarchy.
+ *
+ * Everything described so far is stored in the menu_router table. The
+ * menu_links table holds the visible menu links. By default these are
+ * derived from the same hook_menu definitions, however you are free to
+ * add more with menu_link_save().
+ */
+
+/**
+ * @defgroup menu_flags Menu flags
+ * @{
+ * Flags for use in the "type" attribute of menu items.
+ */
+
+/**
+ * Internal menu flag -- menu item is the root of the menu tree.
+ */
+define('MENU_IS_ROOT', 0x0001);
+
+/**
+ * Internal menu flag -- menu item is visible in the menu tree.
+ */
+define('MENU_VISIBLE_IN_TREE', 0x0002);
+
+/**
+ * Internal menu flag -- menu item is visible in the breadcrumb.
+ */
+define('MENU_VISIBLE_IN_BREADCRUMB', 0x0004);
+
+/**
+ * Internal menu flag -- menu item links back to its parent.
+ */
+define('MENU_LINKS_TO_PARENT', 0x0008);
+
+/**
+ * Internal menu flag -- menu item can be modified by administrator.
+ */
+define('MENU_MODIFIED_BY_ADMIN', 0x0020);
+
+/**
+ * Internal menu flag -- menu item was created by administrator.
+ */
+define('MENU_CREATED_BY_ADMIN', 0x0040);
+
+/**
+ * Internal menu flag -- menu item is a local task.
+ */
+define('MENU_IS_LOCAL_TASK', 0x0080);
+
+/**
+ * Internal menu flag -- menu item is a local action.
+ */
+define('MENU_IS_LOCAL_ACTION', 0x0100);
+
+/**
+ * @} End of "Menu flags".
+ */
+
+/**
+ * @defgroup menu_item_types Menu item types
+ * @{
+ * Definitions for various menu item types.
+ *
+ * Menu item definitions provide one of these constants, which are shortcuts for
+ * combinations of @link menu_flags Menu flags @endlink.
+ */
+
+/**
+ * Menu type -- A "normal" menu item that's shown in menu and breadcrumbs.
+ *
+ * Normal menu items show up in the menu tree and can be moved/hidden by
+ * the administrator. Use this for most menu items. It is the default value if
+ * no menu item type is specified.
+ */
+define('MENU_NORMAL_ITEM', MENU_VISIBLE_IN_TREE | MENU_VISIBLE_IN_BREADCRUMB);
+
+/**
+ * Menu type -- A hidden, internal callback, typically used for API calls.
+ *
+ * Callbacks simply register a path so that the correct function is fired
+ * when the URL is accessed. They do not appear in menus or breadcrumbs.
+ */
+define('MENU_CALLBACK', 0x0000);
+
+/**
+ * Menu type -- A normal menu item, hidden until enabled by an administrator.
+ *
+ * Modules may "suggest" menu items that the administrator may enable. They act
+ * just as callbacks do until enabled, at which time they act like normal items.
+ * Note for the value: 0x0010 was a flag which is no longer used, but this way
+ * the values of MENU_CALLBACK and MENU_SUGGESTED_ITEM are separate.
+ */
+define('MENU_SUGGESTED_ITEM', MENU_VISIBLE_IN_BREADCRUMB | 0x0010);
+
+/**
+ * Menu type -- A task specific to the parent item, usually rendered as a tab.
+ *
+ * Local tasks are menu items that describe actions to be performed on their
+ * parent item. An example is the path "node/52/edit", which performs the
+ * "edit" task on "node/52".
+ */
+define('MENU_LOCAL_TASK', MENU_IS_LOCAL_TASK | MENU_VISIBLE_IN_BREADCRUMB);
+
+/**
+ * Menu type -- The "default" local task, which is initially active.
+ *
+ * Every set of local tasks should provide one "default" task, that links to the
+ * same path as its parent when clicked.
+ */
+define('MENU_DEFAULT_LOCAL_TASK', MENU_IS_LOCAL_TASK | MENU_LINKS_TO_PARENT | MENU_VISIBLE_IN_BREADCRUMB);
+
+/**
+ * Menu type -- An action specific to the parent, usually rendered as a link.
+ *
+ * Local actions are menu items that describe actions on the parent item such
+ * as adding a new user, taxonomy term, etc.
+ */
+define('MENU_LOCAL_ACTION', MENU_IS_LOCAL_TASK | MENU_IS_LOCAL_ACTION | MENU_VISIBLE_IN_BREADCRUMB);
+
+/**
+ * @} End of "Menu item types".
+ */
+
+/**
+ * @defgroup menu_context_types Menu context types
+ * @{
+ * Flags for use in the "context" attribute of menu router items.
+ */
+
+/**
+ * Internal menu flag: Invisible local task.
+ *
+ * This flag may be used for local tasks like "Delete", so custom modules and
+ * themes can alter the default context and expose the task by altering menu.
+ */
+define('MENU_CONTEXT_NONE', 0x0000);
+
+/**
+ * Internal menu flag: Local task should be displayed in page context.
+ */
+define('MENU_CONTEXT_PAGE', 0x0001);
+
+/**
+ * Internal menu flag: Local task should be displayed inline.
+ */
+define('MENU_CONTEXT_INLINE', 0x0002);
+
+/**
+ * @} End of "Menu context types".
+ */
+
+/**
+ * @defgroup menu_status_codes Menu status codes
+ * @{
+ * Status codes for menu callbacks.
+ */
+
+/**
+ * Internal menu status code -- Menu item was found.
+ */
+define('MENU_FOUND', 1);
+
+/**
+ * Internal menu status code -- Menu item was not found.
+ */
+define('MENU_NOT_FOUND', 2);
+
+/**
+ * Internal menu status code -- Menu item access is denied.
+ */
+define('MENU_ACCESS_DENIED', 3);
+
+/**
+ * Internal menu status code -- Menu item inaccessible because site is offline.
+ */
+define('MENU_SITE_OFFLINE', 4);
+
+/**
+ * Internal menu status code -- Everything is working fine.
+ */
+define('MENU_SITE_ONLINE', 5);
+
+/**
+ * @} End of "Menu status codes".
+ */
+
+/**
+ * @defgroup menu_tree_parameters Menu tree parameters
+ * @{
+ * Parameters for a menu tree.
+ */
+
+ /**
+ * The maximum number of path elements for a menu callback
+ */
+define('MENU_MAX_PARTS', 9);
+
+
+/**
+ * The maximum depth of a menu links tree - matches the number of p columns.
+ */
+define('MENU_MAX_DEPTH', 9);
+
+
+/**
+ * @} End of "Menu tree parameters".
+ */
+
+/**
+ * Returns the ancestors (and relevant placeholders) for any given path.
+ *
+ * For example, the ancestors of node/12345/edit are:
+ * - node/12345/edit
+ * - node/12345/%
+ * - node/%/edit
+ * - node/%/%
+ * - node/12345
+ * - node/%
+ * - node
+ *
+ * To generate these, we will use binary numbers. Each bit represents a
+ * part of the path. If the bit is 1, then it represents the original
+ * value while 0 means wildcard. If the path is node/12/edit/foo
+ * then the 1011 bitstring represents node/%/edit/foo where % means that
+ * any argument matches that part. We limit ourselves to using binary
+ * numbers that correspond the patterns of wildcards of router items that
+ * actually exists. This list of 'masks' is built in menu_rebuild().
+ *
+ * @param $parts
+ * An array of path parts, for the above example
+ * array('node', '12345', 'edit').
+ *
+ * @return
+ * An array which contains the ancestors and placeholders. Placeholders
+ * simply contain as many '%s' as the ancestors.
+ */
+function menu_get_ancestors($parts) {
+ $number_parts = count($parts);
+ $ancestors = array();
+ $length = $number_parts - 1;
+ $end = (1 << $number_parts) - 1;
+ $masks = variable_get('menu_masks', array());
+ // Only examine patterns that actually exist as router items (the masks).
+ foreach ($masks as $i) {
+ if ($i > $end) {
+ // Only look at masks that are not longer than the path of interest.
+ continue;
+ }
+ elseif ($i < (1 << $length)) {
+ // We have exhausted the masks of a given length, so decrease the length.
+ --$length;
+ }
+ $current = '';
+ for ($j = $length; $j >= 0; $j--) {
+ // Check the bit on the $j offset.
+ if ($i & (1 << $j)) {
+ // Bit one means the original value.
+ $current .= $parts[$length - $j];
+ }
+ else {
+ // Bit zero means means wildcard.
+ $current .= '%';
+ }
+ // Unless we are at offset 0, add a slash.
+ if ($j) {
+ $current .= '/';
+ }
+ }
+ $ancestors[] = $current;
+ }
+ return $ancestors;
+}
+
+/**
+ * Unserializes menu data, using a map to replace path elements.
+ *
+ * The menu system stores various path-related information (such as the 'page
+ * arguments' and 'access arguments' components of a menu item) in the database
+ * using serialized arrays, where integer values in the arrays represent
+ * arguments to be replaced by values from the path. This function first
+ * unserializes such menu information arrays, and then does the path
+ * replacement.
+ *
+ * The path replacement acts on each integer-valued element of the unserialized
+ * menu data array ($data) using a map array ($map, which is typically an array
+ * of path arguments) as a list of replacements. For instance, if there is an
+ * element of $data whose value is the number 2, then it is replaced in $data
+ * with $map[2]; non-integer values in $data are left alone.
+ *
+ * As an example, an unserialized $data array with elements ('node_load', 1)
+ * represents instructions for calling the node_load() function. Specifically,
+ * this instruction says to use the path component at index 1 as the input
+ * parameter to node_load(). If the path is 'node/123', then $map will be the
+ * array ('node', 123), and the returned array from this function will have
+ * elements ('node_load', 123), since $map[1] is 123. This return value will
+ * indicate specifically that node_load(123) is to be called to load the node
+ * whose ID is 123 for this menu item.
+ *
+ * @param $data
+ * A serialized array of menu data, as read from the database.
+ * @param $map
+ * A path argument array, used to replace integer values in $data; an integer
+ * value N in $data will be replaced by value $map[N]. Typically, the $map
+ * array is generated from a call to the arg() function.
+ *
+ * @return
+ * The unserialized $data array, with path arguments replaced.
+ */
+function menu_unserialize($data, $map) {
+ if ($data = unserialize($data)) {
+ foreach ($data as $k => $v) {
+ if (is_int($v)) {
+ $data[$k] = isset($map[$v]) ? $map[$v] : '';
+ }
+ }
+ return $data;
+ }
+ else {
+ return array();
+ }
+}
+
+
+
+/**
+ * Replaces the statically cached item for a given path.
+ *
+ * @param $path
+ * The path.
+ * @param $router_item
+ * The router item. Usually you take a router entry from menu_get_item and
+ * set it back either modified or to a different path. This lets you modify the
+ * navigation block, the page title, the breadcrumb and the page help in one
+ * call.
+ */
+function menu_set_item($path, $router_item) {
+ menu_get_item($path, $router_item);
+}
+
+/**
+ * Get a router item.
+ *
+ * @param $path
+ * The path, for example node/5. The function will find the corresponding
+ * node/% item and return that.
+ * @param $router_item
+ * Internal use only.
+ *
+ * @return
+ * The router item, an associate array corresponding to one row in the
+ * menu_router table. The value of key map holds the loaded objects. The
+ * value of key access is TRUE if the current user can access this page.
+ * The values for key title, page_arguments, access_arguments, and
+ * theme_arguments will be filled in based on the database values and the
+ * objects loaded.
+ */
+function menu_get_item($path = NULL, $router_item = NULL) {
+ $router_items = &drupal_static(__FUNCTION__);
+ if (!isset($path)) {
+ $path = $_GET['q'];
+ }
+ if (isset($router_item)) {
+ $router_items[$path] = $router_item;
+ }
+ if (!isset($router_items[$path])) {
+ // Rebuild if we know it's needed, or if the menu masks are missing which
+ // occurs rarely, likely due to a race condition of multiple rebuilds.
+ if (variable_get('menu_rebuild_needed', FALSE) || !variable_get('menu_masks', array())) {
+ menu_rebuild();
+ }
+ $original_map = arg(NULL, $path);
+
+ // Since there is no limit to the length of $path, use a hash to keep it
+ // short yet unique.
+ $cid = 'menu_item:' . hash('sha256', $path);
+ if ($cached = cache('menu')->get($cid)) {
+ $router_item = $cached->data;
+ }
+ else {
+ $parts = array_slice($original_map, 0, MENU_MAX_PARTS);
+ $ancestors = menu_get_ancestors($parts);
+ $router_item = db_query_range('SELECT * FROM {menu_router} WHERE path IN (:ancestors) ORDER BY fit DESC', 0, 1, array(':ancestors' => $ancestors))->fetchAssoc();
+ cache('menu')->set($cid, $router_item);
+ }
+ if ($router_item) {
+ // Allow modules to alter the router item before it is translated and
+ // checked for access.
+ drupal_alter('menu_get_item', $router_item, $path, $original_map);
+
+ $map = _menu_translate($router_item, $original_map);
+ $router_item['original_map'] = $original_map;
+ if ($map === FALSE) {
+ $router_items[$path] = FALSE;
+ return FALSE;
+ }
+ if ($router_item['access']) {
+ $router_item['map'] = $map;
+ $router_item['page_arguments'] = array_merge(menu_unserialize($router_item['page_arguments'], $map), array_slice($map, $router_item['number_parts']));
+ $router_item['theme_arguments'] = array_merge(menu_unserialize($router_item['theme_arguments'], $map), array_slice($map, $router_item['number_parts']));
+ }
+ }
+ $router_items[$path] = $router_item;
+ }
+ return $router_items[$path];
+}
+
+/**
+ * Execute the page callback associated with the current path.
+ *
+ * @param $path
+ * The drupal path whose handler is to be be executed. If set to NULL, then
+ * the current path is used.
+ * @param $deliver
+ * (optional) A boolean to indicate whether the content should be sent to the
+ * browser using the appropriate delivery callback (TRUE) or whether to return
+ * the result to the caller (FALSE).
+ */
+function menu_execute_active_handler($path = NULL, $deliver = TRUE) {
+ // Check if site is offline.
+ $page_callback_result = _menu_site_is_offline() ? MENU_SITE_OFFLINE : MENU_SITE_ONLINE;
+
+ // Allow other modules to change the site status but not the path because that
+ // would not change the global variable. hook_url_inbound_alter() can be used
+ // to change the path. Code later will not use the $read_only_path variable.
+ $read_only_path = !empty($path) ? $path : $_GET['q'];
+ drupal_alter('menu_site_status', $page_callback_result, $read_only_path);
+
+ // Only continue if the site status is not set.
+ if ($page_callback_result == MENU_SITE_ONLINE) {
+ if ($router_item = menu_get_item($path)) {
+ if ($router_item['access']) {
+ if ($router_item['include_file']) {
+ require_once DRUPAL_ROOT . '/' . $router_item['include_file'];
+ }
+ $page_callback_result = call_user_func_array($router_item['page_callback'], $router_item['page_arguments']);
+ }
+ else {
+ $page_callback_result = MENU_ACCESS_DENIED;
+ }
+ }
+ else {
+ $page_callback_result = MENU_NOT_FOUND;
+ }
+ }
+
+ // Deliver the result of the page callback to the browser, or if requested,
+ // return it raw, so calling code can do more processing.
+ if ($deliver) {
+ $default_delivery_callback = (isset($router_item) && $router_item) ? $router_item['delivery_callback'] : NULL;
+ drupal_deliver_page($page_callback_result, $default_delivery_callback);
+ }
+ else {
+ return $page_callback_result;
+ }
+}
+
+/**
+ * Loads objects into the map as defined in the $item['load_functions'].
+ *
+ * @param $item
+ * A menu router or menu link item
+ * @param $map
+ * An array of path arguments (ex: array('node', '5'))
+ *
+ * @return
+ * Returns TRUE for success, FALSE if an object cannot be loaded.
+ * Names of object loading functions are placed in $item['load_functions'].
+ * Loaded objects are placed in $map[]; keys are the same as keys in the
+ * $item['load_functions'] array.
+ * $item['access'] is set to FALSE if an object cannot be loaded.
+ */
+function _menu_load_objects(&$item, &$map) {
+ if ($load_functions = $item['load_functions']) {
+ // If someone calls this function twice, then unserialize will fail.
+ if (!is_array($load_functions)) {
+ $load_functions = unserialize($load_functions);
+ }
+ $path_map = $map;
+ foreach ($load_functions as $index => $function) {
+ if ($function) {
+ $value = isset($path_map[$index]) ? $path_map[$index] : '';
+ if (is_array($function)) {
+ // Set up arguments for the load function. These were pulled from
+ // 'load arguments' in the hook_menu() entry, but they need
+ // some processing. In this case the $function is the key to the
+ // load_function array, and the value is the list of arguments.
+ list($function, $args) = each($function);
+ $load_functions[$index] = $function;
+
+ // Some arguments are placeholders for dynamic items to process.
+ foreach ($args as $i => $arg) {
+ if ($arg === '%index') {
+ // Pass on argument index to the load function, so multiple
+ // occurrences of the same placeholder can be identified.
+ $args[$i] = $index;
+ }
+ if ($arg === '%map') {
+ // Pass on menu map by reference. The accepting function must
+ // also declare this as a reference if it wants to modify
+ // the map.
+ $args[$i] = &$map;
+ }
+ if (is_int($arg)) {
+ $args[$i] = isset($path_map[$arg]) ? $path_map[$arg] : '';
+ }
+ }
+ array_unshift($args, $value);
+ $return = call_user_func_array($function, $args);
+ }
+ else {
+ $return = $function($value);
+ }
+ // If callback returned an error or there is no callback, trigger 404.
+ if ($return === FALSE) {
+ $item['access'] = FALSE;
+ $map = FALSE;
+ return FALSE;
+ }
+ $map[$index] = $return;
+ }
+ }
+ $item['load_functions'] = $load_functions;
+ }
+ return TRUE;
+}
+
+/**
+ * Check access to a menu item using the access callback
+ *
+ * @param $item
+ * A menu router or menu link item
+ * @param $map
+ * An array of path arguments (ex: array('node', '5'))
+ *
+ * @return
+ * $item['access'] becomes TRUE if the item is accessible, FALSE otherwise.
+ */
+function _menu_check_access(&$item, $map) {
+ // Determine access callback, which will decide whether or not the current
+ // user has access to this path.
+ $callback = empty($item['access_callback']) ? 0 : trim($item['access_callback']);
+ // Check for a TRUE or FALSE value.
+ if (is_numeric($callback)) {
+ $item['access'] = (bool) $callback;
+ }
+ else {
+ $arguments = menu_unserialize($item['access_arguments'], $map);
+ // As call_user_func_array is quite slow and user_access is a very common
+ // callback, it is worth making a special case for it.
+ if ($callback == 'user_access') {
+ $item['access'] = (count($arguments) == 1) ? user_access($arguments[0]) : user_access($arguments[0], $arguments[1]);
+ }
+ elseif (function_exists($callback)) {
+ $item['access'] = call_user_func_array($callback, $arguments);
+ }
+ }
+}
+
+/**
+ * Localize the router item title using t() or another callback.
+ *
+ * Translate the title and description to allow storage of English title
+ * strings in the database, yet display of them in the language required
+ * by the current user.
+ *
+ * @param $item
+ * A menu router item or a menu link item.
+ * @param $map
+ * The path as an array with objects already replaced. E.g., for path
+ * node/123 $map would be array('node', $node) where $node is the node
+ * object for node 123.
+ * @param $link_translate
+ * TRUE if we are translating a menu link item; FALSE if we are
+ * translating a menu router item.
+ *
+ * @return
+ * No return value.
+ * $item['title'] is localized according to $item['title_callback'].
+ * If an item's callback is check_plain(), $item['options']['html'] becomes
+ * TRUE.
+ * $item['description'] is translated using t().
+ * When doing link translation and the $item['options']['attributes']['title']
+ * (link title attribute) matches the description, it is translated as well.
+ */
+function _menu_item_localize(&$item, $map, $link_translate = FALSE) {
+ $callback = $item['title_callback'];
+ $item['localized_options'] = $item['options'];
+ // All 'class' attributes are assumed to be an array during rendering, but
+ // links stored in the database may use an old string value.
+ // @todo In order to remove this code we need to implement a database update
+ // including unserializing all existing link options and running this code
+ // on them, as well as adding validation to menu_link_save().
+ if (isset($item['options']['attributes']['class']) && is_string($item['options']['attributes']['class'])) {
+ $item['localized_options']['attributes']['class'] = explode(' ', $item['options']['attributes']['class']);
+ }
+ // If we are translating the title of a menu link, and its title is the same
+ // as the corresponding router item, then we can use the title information
+ // from the router. If it's customized, then we need to use the link title
+ // itself; can't localize.
+ // If we are translating a router item (tabs, page, breadcrumb), then we
+ // can always use the information from the router item.
+ if (!$link_translate || ($item['title'] == $item['link_title'])) {
+ // t() is a special case. Since it is used very close to all the time,
+ // we handle it directly instead of using indirect, slower methods.
+ if ($callback == 't') {
+ if (empty($item['title_arguments'])) {
+ $item['title'] = t($item['title']);
+ }
+ else {
+ $item['title'] = t($item['title'], menu_unserialize($item['title_arguments'], $map));
+ }
+ }
+ elseif ($callback && function_exists($callback)) {
+ if (empty($item['title_arguments'])) {
+ $item['title'] = $callback($item['title']);
+ }
+ else {
+ $item['title'] = call_user_func_array($callback, menu_unserialize($item['title_arguments'], $map));
+ }
+ // Avoid calling check_plain again on l() function.
+ if ($callback == 'check_plain') {
+ $item['localized_options']['html'] = TRUE;
+ }
+ }
+ }
+ elseif ($link_translate) {
+ $item['title'] = $item['link_title'];
+ }
+
+ // Translate description, see the motivation above.
+ if (!empty($item['description'])) {
+ $original_description = $item['description'];
+ $item['description'] = t($item['description']);
+ if ($link_translate && isset($item['options']['attributes']['title']) && $item['options']['attributes']['title'] == $original_description) {
+ $item['localized_options']['attributes']['title'] = $item['description'];
+ }
+ }
+}
+
+/**
+ * Handles dynamic path translation and menu access control.
+ *
+ * When a user arrives on a page such as node/5, this function determines
+ * what "5" corresponds to, by inspecting the page's menu path definition,
+ * node/%node. This will call node_load(5) to load the corresponding node
+ * object.
+ *
+ * It also works in reverse, to allow the display of tabs and menu items which
+ * contain these dynamic arguments, translating node/%node to node/5.
+ *
+ * Translation of menu item titles and descriptions are done here to
+ * allow for storage of English strings in the database, and translation
+ * to the language required to generate the current page.
+ *
+ * @param $router_item
+ * A menu router item
+ * @param $map
+ * An array of path arguments (ex: array('node', '5'))
+ * @param $to_arg
+ * Execute $item['to_arg_functions'] or not. Use only if you want to render a
+ * path from the menu table, for example tabs.
+ *
+ * @return
+ * Returns the map with objects loaded as defined in the
+ * $item['load_functions']. $item['access'] becomes TRUE if the item is
+ * accessible, FALSE otherwise. $item['href'] is set according to the map.
+ * If an error occurs during calling the load_functions (like trying to load
+ * a non existing node) then this function return FALSE.
+ */
+function _menu_translate(&$router_item, $map, $to_arg = FALSE) {
+ if ($to_arg && !empty($router_item['to_arg_functions'])) {
+ // Fill in missing path elements, such as the current uid.
+ _menu_link_map_translate($map, $router_item['to_arg_functions']);
+ }
+ // The $path_map saves the pieces of the path as strings, while elements in
+ // $map may be replaced with loaded objects.
+ $path_map = $map;
+ if (!empty($router_item['load_functions']) && !_menu_load_objects($router_item, $map)) {
+ // An error occurred loading an object.
+ $router_item['access'] = FALSE;
+ return FALSE;
+ }
+
+ // Generate the link path for the page request or local tasks.
+ $link_map = explode('/', $router_item['path']);
+ if (isset($router_item['tab_root'])) {
+ $tab_root_map = explode('/', $router_item['tab_root']);
+ }
+ if (isset($router_item['tab_parent'])) {
+ $tab_parent_map = explode('/', $router_item['tab_parent']);
+ }
+ for ($i = 0; $i < $router_item['number_parts']; $i++) {
+ if ($link_map[$i] == '%') {
+ $link_map[$i] = $path_map[$i];
+ }
+ if (isset($tab_root_map[$i]) && $tab_root_map[$i] == '%') {
+ $tab_root_map[$i] = $path_map[$i];
+ }
+ if (isset($tab_parent_map[$i]) && $tab_parent_map[$i] == '%') {
+ $tab_parent_map[$i] = $path_map[$i];
+ }
+ }
+ $router_item['href'] = implode('/', $link_map);
+ $router_item['tab_root_href'] = implode('/', $tab_root_map);
+ $router_item['tab_parent_href'] = implode('/', $tab_parent_map);
+ $router_item['options'] = array();
+ _menu_check_access($router_item, $map);
+
+ // For performance, don't localize an item the user can't access.
+ if ($router_item['access']) {
+ _menu_item_localize($router_item, $map);
+ }
+
+ return $map;
+}
+
+/**
+ * This function translates the path elements in the map using any to_arg
+ * helper function. These functions take an argument and return an object.
+ * See http://drupal.org/node/109153 for more information.
+ *
+ * @param $map
+ * An array of path arguments (ex: array('node', '5'))
+ * @param $to_arg_functions
+ * An array of helper function (ex: array(2 => 'menu_tail_to_arg'))
+ */
+function _menu_link_map_translate(&$map, $to_arg_functions) {
+ $to_arg_functions = unserialize($to_arg_functions);
+ foreach ($to_arg_functions as $index => $function) {
+ // Translate place-holders into real values.
+ $arg = $function(!empty($map[$index]) ? $map[$index] : '', $map, $index);
+ if (!empty($map[$index]) || isset($arg)) {
+ $map[$index] = $arg;
+ }
+ else {
+ unset($map[$index]);
+ }
+ }
+}
+
+/**
+ * Returns path as one string from the argument we are currently at.
+ */
+function menu_tail_to_arg($arg, $map, $index) {
+ return implode('/', array_slice($map, $index));
+}
+
+/**
+ * Loads path as one string from the argument we are currently at.
+ *
+ * To use this load function, you must specify the load arguments
+ * in the router item as:
+ * @code
+ * $item['load arguments'] = array('%map', '%index');
+ * @endcode
+ *
+ * @see search_menu().
+ */
+function menu_tail_load($arg, &$map, $index) {
+ $arg = implode('/', array_slice($map, $index));
+ $map = array_slice($map, 0, $index);
+ return $arg;
+}
+
+/**
+ * This function is similar to _menu_translate() but does link-specific
+ * preparation such as always calling to_arg functions
+ *
+ * @param $item
+ * A menu link.
+ * @param $translate
+ * (optional) Whether to try to translate a link containing dynamic path
+ * argument placeholders (%) based on the menu router item of the current
+ * path. Defaults to FALSE. Internally used for breadcrumbs.
+ *
+ * @return
+ * Returns the map of path arguments with objects loaded as defined in the
+ * $item['load_functions'].
+ * $item['access'] becomes TRUE if the item is accessible, FALSE otherwise.
+ * $item['href'] is generated from link_path, possibly by to_arg functions.
+ * $item['title'] is generated from link_title, and may be localized.
+ * $item['options'] is unserialized; it is also changed within the call here
+ * to $item['localized_options'] by _menu_item_localize().
+ */
+function _menu_link_translate(&$item, $translate = FALSE) {
+ if (!is_array($item['options'])) {
+ $item['options'] = unserialize($item['options']);
+ }
+ if ($item['external']) {
+ $item['access'] = 1;
+ $map = array();
+ $item['href'] = $item['link_path'];
+ $item['title'] = $item['link_title'];
+ $item['localized_options'] = $item['options'];
+ }
+ else {
+ // Complete the path of the menu link with elements from the current path,
+ // if it contains dynamic placeholders (%).
+ $map = explode('/', $item['link_path']);
+ if (strpos($item['link_path'], '%') !== FALSE) {
+ // Invoke registered to_arg callbacks.
+ if (!empty($item['to_arg_functions'])) {
+ _menu_link_map_translate($map, $item['to_arg_functions']);
+ }
+ // Or try to derive the path argument map from the current router item,
+ // if this $item's path is within the router item's path. This means
+ // that if we are on the current path 'foo/%/bar/%/baz', then
+ // menu_get_item() will have translated the menu router item for the
+ // current path, and we can take over the argument map for a link like
+ // 'foo/%/bar'. This inheritance is only valid for breadcrumb links.
+ // @see _menu_tree_check_access()
+ // @see menu_get_active_breadcrumb()
+ elseif ($translate && ($current_router_item = menu_get_item())) {
+ // If $translate is TRUE, then this link is in the active trail.
+ // Only translate paths within the current path.
+ if (strpos($current_router_item['path'], $item['link_path']) === 0) {
+ $count = count($map);
+ $map = array_slice($current_router_item['original_map'], 0, $count);
+ $item['original_map'] = $map;
+ if (isset($current_router_item['map'])) {
+ $item['map'] = array_slice($current_router_item['map'], 0, $count);
+ }
+ // Reset access to check it (for the first time).
+ unset($item['access']);
+ }
+ }
+ }
+ $item['href'] = implode('/', $map);
+
+ // Skip links containing untranslated arguments.
+ if (strpos($item['href'], '%') !== FALSE) {
+ $item['access'] = FALSE;
+ return FALSE;
+ }
+ // menu_tree_check_access() may set this ahead of time for links to nodes.
+ if (!isset($item['access'])) {
+ if (!empty($item['load_functions']) && !_menu_load_objects($item, $map)) {
+ // An error occurred loading an object.
+ $item['access'] = FALSE;
+ return FALSE;
+ }
+ _menu_check_access($item, $map);
+ }
+ // For performance, don't localize a link the user can't access.
+ if ($item['access']) {
+ _menu_item_localize($item, $map, TRUE);
+ }
+ }
+
+ // Allow other customizations - e.g. adding a page-specific query string to the
+ // options array. For performance reasons we only invoke this hook if the link
+ // has the 'alter' flag set in the options array.
+ if (!empty($item['options']['alter'])) {
+ drupal_alter('translated_menu_link', $item, $map);
+ }
+
+ return $map;
+}
+
+/**
+ * Get a loaded object from a router item.
+ *
+ * menu_get_object() provides access to objects loaded by the current router
+ * item. For example, on the page node/%node, the router loads the %node object,
+ * and calling menu_get_object() will return that. Normally, it is necessary to
+ * specify the type of object referenced, however node is the default.
+ * The following example tests to see whether the node being displayed is of the
+ * "story" content type:
+ * @code
+ * $node = menu_get_object();
+ * $story = $node->type == 'story';
+ * @endcode
+ *
+ * @param $type
+ * Type of the object. These appear in hook_menu definitions as %type. Core
+ * provides aggregator_feed, aggregator_category, contact, filter_format,
+ * forum_term, menu, menu_link, node, taxonomy_vocabulary, user. See the
+ * relevant {$type}_load function for more on each. Defaults to node.
+ * @param $position
+ * The position of the object in the path, where the first path segment is 0.
+ * For node/%node, the position of %node is 1, but for comment/reply/%node,
+ * it's 2. Defaults to 1.
+ * @param $path
+ * See menu_get_item() for more on this. Defaults to the current path.
+ */
+function menu_get_object($type = 'node', $position = 1, $path = NULL) {
+ $router_item = menu_get_item($path);
+ if (isset($router_item['load_functions'][$position]) && !empty($router_item['map'][$position]) && $router_item['load_functions'][$position] == $type . '_load') {
+ return $router_item['map'][$position];
+ }
+}
+
+/**
+ * Renders a menu tree based on the current path.
+ *
+ * The tree is expanded based on the current path and dynamic paths are also
+ * changed according to the defined to_arg functions (for example the 'My
+ * account' link is changed from user/% to a link with the current user's uid).
+ *
+ * @param $menu_name
+ * The name of the menu.
+ *
+ * @return
+ * A structured array representing the specified menu on the current page, to
+ * be rendered by drupal_render().
+ */
+function menu_tree($menu_name) {
+ $menu_output = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($menu_output[$menu_name])) {
+ $tree = menu_tree_page_data($menu_name);
+ $menu_output[$menu_name] = menu_tree_output($tree);
+ }
+ return $menu_output[$menu_name];
+}
+
+/**
+ * Returns a rendered menu tree.
+ *
+ * The menu item's LI element is given one of the following classes:
+ * - expanded: The menu item is showing its submenu.
+ * - collapsed: The menu item has a submenu which is not shown.
+ * - leaf: The menu item has no submenu.
+ *
+ * @param $tree
+ * A data structure representing the tree as returned from menu_tree_data.
+ *
+ * @return
+ * A structured array to be rendered by drupal_render().
+ */
+function menu_tree_output($tree) {
+ $build = array();
+ $items = array();
+
+ // Pull out just the menu links we are going to render so that we
+ // get an accurate count for the first/last classes.
+ foreach ($tree as $data) {
+ if ($data['link']['access'] && !$data['link']['hidden']) {
+ $items[] = $data;
+ }
+ }
+
+ $router_item = menu_get_item();
+ $num_items = count($items);
+ foreach ($items as $i => $data) {
+ $class = array();
+ if ($i == 0) {
+ $class[] = 'first';
+ }
+ if ($i == $num_items - 1) {
+ $class[] = 'last';
+ }
+ // Set a class for the <li>-tag. Since $data['below'] may contain local
+ // tasks, only set 'expanded' class if the link also has children within
+ // the current menu.
+ if ($data['link']['has_children'] && $data['below']) {
+ $class[] = 'expanded';
+ }
+ elseif ($data['link']['has_children']) {
+ $class[] = 'collapsed';
+ }
+ else {
+ $class[] = 'leaf';
+ }
+ // Set a class if the link is in the active trail.
+ if ($data['link']['in_active_trail']) {
+ $class[] = 'active-trail';
+ $data['link']['localized_options']['attributes']['class'][] = 'active-trail';
+ }
+ // Normally, l() compares the href of every link with $_GET['q'] and sets
+ // the active class accordingly. But local tasks do not appear in menu
+ // trees, so if the current path is a local task, and this link is its
+ // tab root, then we have to set the class manually.
+ if ($data['link']['href'] == $router_item['tab_root_href'] && $data['link']['href'] != $_GET['q']) {
+ $data['link']['localized_options']['attributes']['class'][] = 'active';
+ }
+
+ // Allow menu-specific theme overrides.
+ $element['#theme'] = 'menu_link__' . strtr($data['link']['menu_name'], '-', '_');
+ $element['#attributes']['class'] = $class;
+ $element['#title'] = $data['link']['title'];
+ $element['#href'] = $data['link']['href'];
+ $element['#localized_options'] = !empty($data['link']['localized_options']) ? $data['link']['localized_options'] : array();
+ $element['#below'] = $data['below'] ? menu_tree_output($data['below']) : $data['below'];
+ $element['#original_link'] = $data['link'];
+ // Index using the link's unique mlid.
+ $build[$data['link']['mlid']] = $element;
+ }
+ if ($build) {
+ // Make sure drupal_render() does not re-order the links.
+ $build['#sorted'] = TRUE;
+ // Add the theme wrapper for outer markup.
+ // Allow menu-specific theme overrides.
+ $build['#theme_wrappers'][] = 'menu_tree__' . strtr($data['link']['menu_name'], '-', '_');
+ }
+
+ return $build;
+}
+
+/**
+ * Get the data structure representing a named menu tree.
+ *
+ * Since this can be the full tree including hidden items, the data returned
+ * may be used for generating an an admin interface or a select.
+ *
+ * @param $menu_name
+ * The named menu links to return
+ * @param $link
+ * A fully loaded menu link, or NULL. If a link is supplied, only the
+ * path to root will be included in the returned tree - as if this link
+ * represented the current page in a visible menu.
+ * @param $max_depth
+ * Optional maximum depth of links to retrieve. Typically useful if only one
+ * or two levels of a sub tree are needed in conjunction with a non-NULL
+ * $link, in which case $max_depth should be greater than $link['depth'].
+ *
+ * @return
+ * An tree of menu links in an array, in the order they should be rendered.
+ */
+function menu_tree_all_data($menu_name, $link = NULL, $max_depth = NULL) {
+ $tree = &drupal_static(__FUNCTION__, array());
+
+ // Use $mlid as a flag for whether the data being loaded is for the whole tree.
+ $mlid = isset($link['mlid']) ? $link['mlid'] : 0;
+ // Generate a cache ID (cid) specific for this $menu_name, $link, $language, and depth.
+ $cid = 'links:' . $menu_name . ':all:' . $mlid . ':' . $GLOBALS['language']->language . ':' . (int) $max_depth;
+
+ if (!isset($tree[$cid])) {
+ // If the static variable doesn't have the data, check {cache_menu}.
+ $cache = cache('menu')->get($cid);
+ if ($cache && isset($cache->data)) {
+ // If the cache entry exists, it contains the parameters for
+ // menu_build_tree().
+ $tree_parameters = $cache->data;
+ }
+ // If the tree data was not in the cache, build $tree_parameters.
+ if (!isset($tree_parameters)) {
+ $tree_parameters = array(
+ 'min_depth' => 1,
+ 'max_depth' => $max_depth,
+ );
+ if ($mlid) {
+ // The tree is for a single item, so we need to match the values in its
+ // p columns and 0 (the top level) with the plid values of other links.
+ $parents = array(0);
+ for ($i = 1; $i < MENU_MAX_DEPTH; $i++) {
+ if (!empty($link["p$i"])) {
+ $parents[] = $link["p$i"];
+ }
+ }
+ $tree_parameters['expanded'] = $parents;
+ $tree_parameters['active_trail'] = $parents;
+ $tree_parameters['active_trail'][] = $mlid;
+ }
+
+ // Cache the tree building parameters using the page-specific cid.
+ cache('menu')->set($cid, $tree_parameters);
+ }
+
+ // Build the tree using the parameters; the resulting tree will be cached
+ // by _menu_build_tree()).
+ $tree[$cid] = menu_build_tree($menu_name, $tree_parameters);
+ }
+
+ return $tree[$cid];
+}
+
+/**
+ * Set the path for determining the active trail of the specified menu tree.
+ *
+ * This path will also affect the breadcrumbs under some circumstances.
+ * Breadcrumbs are built using the preferred link returned by
+ * menu_link_get_preferred(). If the preferred link is inside one of the menus
+ * specified in calls to menu_tree_set_path(), the preferred link will be
+ * overridden by the corresponding path returned by menu_tree_get_path().
+ *
+ * Setting this path does not affect the main content; for that use
+ * menu_set_active_item() instead.
+ *
+ * @param $menu_name
+ * The name of the affected menu tree.
+ * @param $path
+ * The path to use when finding the active trail.
+ */
+function menu_tree_set_path($menu_name, $path = NULL) {
+ $paths = &drupal_static(__FUNCTION__);
+ if (isset($path)) {
+ $paths[$menu_name] = $path;
+ }
+ return isset($paths[$menu_name]) ? $paths[$menu_name] : NULL;
+}
+
+/**
+ * Get the path for determining the active trail of the specified menu tree.
+ *
+ * @param $menu_name
+ * The menu name of the requested tree.
+ *
+ * @return
+ * A string containing the path. If no path has been specified with
+ * menu_tree_set_path(), NULL is returned.
+ */
+function menu_tree_get_path($menu_name) {
+ return menu_tree_set_path($menu_name);
+}
+
+/**
+ * Get the data structure representing a named menu tree, based on the current page.
+ *
+ * The tree order is maintained by storing each parent in an individual
+ * field, see http://drupal.org/node/141866 for more.
+ *
+ * @param $menu_name
+ * The named menu links to return.
+ * @param $max_depth
+ * (optional) The maximum depth of links to retrieve.
+ * @param $only_active_trail
+ * (optional) Whether to only return the links in the active trail (TRUE)
+ * instead of all links on every level of the menu link tree (FALSE). Defaults
+ * to FALSE. Internally used for breadcrumbs only.
+ *
+ * @return
+ * An array of menu links, in the order they should be rendered. The array
+ * is a list of associative arrays -- these have two keys, link and below.
+ * link is a menu item, ready for theming as a link. Below represents the
+ * submenu below the link if there is one, and it is a subtree that has the
+ * same structure described for the top-level array.
+ */
+function menu_tree_page_data($menu_name, $max_depth = NULL, $only_active_trail = FALSE) {
+ $tree = &drupal_static(__FUNCTION__, array());
+
+ // Check if the active trail has been overridden for this menu tree.
+ $active_path = menu_tree_get_path($menu_name);
+ // Load the menu item corresponding to the current page.
+ if ($item = menu_get_item($active_path)) {
+ if (isset($max_depth)) {
+ $max_depth = min($max_depth, MENU_MAX_DEPTH);
+ }
+ // Generate a cache ID (cid) specific for this page.
+ $cid = 'links:' . $menu_name . ':page:' . $item['href'] . ':' . $GLOBALS['language']->language . ':' . (int) $item['access'] . ':' . (int) $max_depth;
+ // If we are asked for the active trail only, and $menu_name has not been
+ // built and cached for this page yet, then this likely means that it
+ // won't be built anymore, as this function is invoked from
+ // template_process_page(). So in order to not build a giant menu tree
+ // that needs to be checked for access on all levels, we simply check
+ // whether we have the menu already in cache, or otherwise, build a minimum
+ // tree containing the breadcrumb/active trail only.
+ // @see menu_set_active_trail()
+ if (!isset($tree[$cid]) && $only_active_trail) {
+ $cid .= ':trail';
+ }
+
+ if (!isset($tree[$cid])) {
+ // If the static variable doesn't have the data, check {cache_menu}.
+ $cache = cache('menu')->get($cid);
+ if ($cache && isset($cache->data)) {
+ // If the cache entry exists, it contains the parameters for
+ // menu_build_tree().
+ $tree_parameters = $cache->data;
+ }
+ // If the tree data was not in the cache, build $tree_parameters.
+ if (!isset($tree_parameters)) {
+ $tree_parameters = array(
+ 'min_depth' => 1,
+ 'max_depth' => $max_depth,
+ );
+ // Parent mlids; used both as key and value to ensure uniqueness.
+ // We always want all the top-level links with plid == 0.
+ $active_trail = array(0 => 0);
+
+ // If the item for the current page is accessible, build the tree
+ // parameters accordingly.
+ if ($item['access']) {
+ // Find a menu link corresponding to the current path. If $active_path
+ // is NULL, let menu_link_get_preferred() determine the path.
+ if ($active_link = menu_link_get_preferred($active_path)) {
+ // The active link may only be taken into account to build the
+ // active trail, if it resides in the requested menu. Otherwise,
+ // we'd needlessly re-run _menu_build_tree() queries for every menu
+ // on every page.
+ if ($active_link['menu_name'] == $menu_name) {
+ // Use all the coordinates, except the last one because there
+ // can be no child beyond the last column.
+ for ($i = 1; $i < MENU_MAX_DEPTH; $i++) {
+ if ($active_link['p' . $i]) {
+ $active_trail[$active_link['p' . $i]] = $active_link['p' . $i];
+ }
+ }
+ // If we are asked to build links for the active trail only, skip
+ // the entire 'expanded' handling.
+ if ($only_active_trail) {
+ $tree_parameters['only_active_trail'] = TRUE;
+ }
+ }
+ }
+ $parents = $active_trail;
+
+ $expanded = variable_get('menu_expanded', array());
+ // Check whether the current menu has any links set to be expanded.
+ if (!$only_active_trail && in_array($menu_name, $expanded)) {
+ // Collect all the links set to be expanded, and then add all of
+ // their children to the list as well.
+ do {
+ $result = db_select('menu_links', NULL, array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('menu_links', array('mlid'))
+ ->condition('menu_name', $menu_name)
+ ->condition('expanded', 1)
+ ->condition('has_children', 1)
+ ->condition('plid', $parents, 'IN')
+ ->condition('mlid', $parents, 'NOT IN')
+ ->execute();
+ $num_rows = FALSE;
+ foreach ($result as $item) {
+ $parents[$item['mlid']] = $item['mlid'];
+ $num_rows = TRUE;
+ }
+ } while ($num_rows);
+ }
+ $tree_parameters['expanded'] = $parents;
+ $tree_parameters['active_trail'] = $active_trail;
+ }
+ // If access is denied, we only show top-level links in menus.
+ else {
+ $tree_parameters['expanded'] = $active_trail;
+ $tree_parameters['active_trail'] = $active_trail;
+ }
+ // Cache the tree building parameters using the page-specific cid.
+ cache('menu')->set($cid, $tree_parameters);
+ }
+
+ // Build the tree using the parameters; the resulting tree will be cached
+ // by _menu_build_tree().
+ $tree[$cid] = menu_build_tree($menu_name, $tree_parameters);
+ }
+ return $tree[$cid];
+ }
+
+ return array();
+}
+
+/**
+ * Build a menu tree, translate links, and check access.
+ *
+ * @param $menu_name
+ * The name of the menu.
+ * @param $parameters
+ * (optional) An associative array of build parameters. Possible keys:
+ * - expanded: An array of parent link ids to return only menu links that are
+ * children of one of the plids in this list. If empty, the whole menu tree
+ * is built, unless 'only_active_trail' is TRUE.
+ * - active_trail: An array of mlids, representing the coordinates of the
+ * currently active menu link.
+ * - only_active_trail: Whether to only return links that are in the active
+ * trail. This option is ignored, if 'expanded' is non-empty. Internally
+ * used for breadcrumbs.
+ * - min_depth: The minimum depth of menu links in the resulting tree.
+ * Defaults to 1, which is the default to build a whole tree for a menu, i.e.
+ * excluding menu container itself.
+ * - max_depth: The maximum depth of menu links in the resulting tree.
+ *
+ * @return
+ * A fully built menu tree.
+ */
+function menu_build_tree($menu_name, array $parameters = array()) {
+ // Build the menu tree.
+ $data = _menu_build_tree($menu_name, $parameters);
+ // Check access for the current user to each item in the tree.
+ menu_tree_check_access($data['tree'], $data['node_links']);
+ return $data['tree'];
+}
+
+/**
+ * Build a menu tree.
+ *
+ * This function may be used build the data for a menu tree only, for example
+ * to further massage the data manually before further processing happens.
+ * menu_tree_check_access() needs to be invoked afterwards.
+ *
+ * @see menu_build_tree()
+ */
+function _menu_build_tree($menu_name, array $parameters = array()) {
+ // Static cache of already built menu trees.
+ $trees = &drupal_static(__FUNCTION__, array());
+
+ // Build the cache id; sort parents to prevent duplicate storage and remove
+ // default parameter values.
+ if (isset($parameters['expanded'])) {
+ sort($parameters['expanded']);
+ }
+ $tree_cid = 'links:' . $menu_name . ':tree-data:' . $GLOBALS['language']->language . ':' . hash('sha256', serialize($parameters));
+
+ // If we do not have this tree in the static cache, check {cache_menu}.
+ if (!isset($trees[$tree_cid])) {
+ $cache = cache('menu')->get($tree_cid);
+ if ($cache && isset($cache->data)) {
+ $trees[$tree_cid] = $cache->data;
+ }
+ }
+
+ if (!isset($trees[$tree_cid])) {
+ // Select the links from the table, and recursively build the tree. We
+ // LEFT JOIN since there is no match in {menu_router} for an external
+ // link.
+ $query = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC));
+ $query->addTag('translatable');
+ $query->leftJoin('menu_router', 'm', 'm.path = ml.router_path');
+ $query->fields('ml');
+ $query->fields('m', array(
+ 'load_functions',
+ 'to_arg_functions',
+ 'access_callback',
+ 'access_arguments',
+ 'page_callback',
+ 'page_arguments',
+ 'delivery_callback',
+ 'tab_parent',
+ 'tab_root',
+ 'title',
+ 'title_callback',
+ 'title_arguments',
+ 'theme_callback',
+ 'theme_arguments',
+ 'type',
+ 'description',
+ ));
+ for ($i = 1; $i <= MENU_MAX_DEPTH; $i++) {
+ $query->orderBy('p' . $i, 'ASC');
+ }
+ $query->condition('ml.menu_name', $menu_name);
+ if (!empty($parameters['expanded'])) {
+ $query->condition('ml.plid', $parameters['expanded'], 'IN');
+ }
+ elseif (!empty($parameters['only_active_trail'])) {
+ $query->condition('ml.mlid', $parameters['active_trail'], 'IN');
+ }
+ $min_depth = (isset($parameters['min_depth']) ? $parameters['min_depth'] : 1);
+ if ($min_depth != 1) {
+ $query->condition('ml.depth', $min_depth, '>=');
+ }
+ if (isset($parameters['max_depth'])) {
+ $query->condition('ml.depth', $parameters['max_depth'], '<=');
+ }
+
+ // Build an ordered array of links using the query result object.
+ $links = array();
+ foreach ($query->execute() as $item) {
+ $links[] = $item;
+ }
+ $active_trail = (isset($parameters['active_trail']) ? $parameters['active_trail'] : array());
+ $data['tree'] = menu_tree_data($links, $active_trail, $min_depth);
+ $data['node_links'] = array();
+ menu_tree_collect_node_links($data['tree'], $data['node_links']);
+
+ // Cache the data, if it is not already in the cache.
+ cache('menu')->set($tree_cid, $data);
+ $trees[$tree_cid] = $data;
+ }
+
+ return $trees[$tree_cid];
+}
+
+/**
+ * Recursive helper function - collect node links.
+ *
+ * @param $tree
+ * The menu tree you wish to collect node links from.
+ * @param $node_links
+ * An array in which to store the collected node links.
+ */
+function menu_tree_collect_node_links(&$tree, &$node_links) {
+ foreach ($tree as $key => $v) {
+ if ($tree[$key]['link']['router_path'] == 'node/%') {
+ $nid = substr($tree[$key]['link']['link_path'], 5);
+ if (is_numeric($nid)) {
+ $node_links[$nid][$tree[$key]['link']['mlid']] = &$tree[$key]['link'];
+ $tree[$key]['link']['access'] = FALSE;
+ }
+ }
+ if ($tree[$key]['below']) {
+ menu_tree_collect_node_links($tree[$key]['below'], $node_links);
+ }
+ }
+}
+
+/**
+ * Check access and perform other dynamic operations for each link in the tree.
+ *
+ * @param $tree
+ * The menu tree you wish to operate on.
+ * @param $node_links
+ * A collection of node link references generated from $tree by
+ * menu_tree_collect_node_links().
+ */
+function menu_tree_check_access(&$tree, $node_links = array()) {
+ if ($node_links) {
+ $nids = array_keys($node_links);
+ $select = db_select('node', 'n');
+ $select->addField('n', 'nid');
+ $select->condition('n.status', 1);
+ $select->condition('n.nid', $nids, 'IN');
+ $select->addTag('node_access');
+ $nids = $select->execute()->fetchCol();
+ foreach ($nids as $nid) {
+ foreach ($node_links[$nid] as $mlid => $link) {
+ $node_links[$nid][$mlid]['access'] = TRUE;
+ }
+ }
+ }
+ _menu_tree_check_access($tree);
+}
+
+/**
+ * Recursive helper function for menu_tree_check_access()
+ */
+function _menu_tree_check_access(&$tree) {
+ $new_tree = array();
+ foreach ($tree as $key => $v) {
+ $item = &$tree[$key]['link'];
+ _menu_link_translate($item);
+ if ($item['access'] || ($item['in_active_trail'] && strpos($item['href'], '%') !== FALSE)) {
+ if ($tree[$key]['below']) {
+ _menu_tree_check_access($tree[$key]['below']);
+ }
+ // The weights are made a uniform 5 digits by adding 50000 as an offset.
+ // After _menu_link_translate(), $item['title'] has the localized link title.
+ // Adding the mlid to the end of the index insures that it is unique.
+ $new_tree[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['mlid']] = $tree[$key];
+ }
+ }
+ // Sort siblings in the tree based on the weights and localized titles.
+ ksort($new_tree);
+ $tree = $new_tree;
+}
+
+/**
+ * Builds the data representing a menu tree.
+ *
+ * @param $links
+ * A flat array of menu links that are part of the menu. Each array element
+ * is an associative array of information about the menu link, containing the
+ * fields from the {menu_links} table, and optionally additional information
+ * from the {menu_router} table, if the menu item appears in both tables.
+ * This array must be ordered depth-first. See _menu_build_tree() for a sample
+ * query.
+ * @param $parents
+ * An array of the menu link ID values that are in the path from the current
+ * page to the root of the menu tree.
+ * @param $depth
+ * The minimum depth to include in the returned menu tree.
+ *
+ * @return
+ * An array of menu links in the form of a tree. Each item in the tree is an
+ * associative array containing:
+ * - link: The menu link item from $links, with additional element
+ * 'in_active_trail' (TRUE if the link ID was in $parents).
+ * - below: An array containing the sub-tree of this item, where each element
+ * is a tree item array with 'link' and 'below' elements. This array will be
+ * empty if the menu item has no items in its sub-tree having a depth
+ * greater than or equal to $depth.
+ */
+function menu_tree_data(array $links, array $parents = array(), $depth = 1) {
+ // Reverse the array so we can use the more efficient array_pop() function.
+ $links = array_reverse($links);
+ return _menu_tree_data($links, $parents, $depth);
+}
+
+/**
+ * Recursive helper function to build the data representing a menu tree.
+ *
+ * The function is a bit complex because the rendering of a link depends on
+ * the next menu link.
+ */
+function _menu_tree_data(&$links, $parents, $depth) {
+ $tree = array();
+ while ($item = array_pop($links)) {
+ // We need to determine if we're on the path to root so we can later build
+ // the correct active trail and breadcrumb.
+ $item['in_active_trail'] = in_array($item['mlid'], $parents);
+ // Add the current link to the tree.
+ $tree[$item['mlid']] = array(
+ 'link' => $item,
+ 'below' => array(),
+ );
+ // Look ahead to the next link, but leave it on the array so it's available
+ // to other recursive function calls if we return or build a sub-tree.
+ $next = end($links);
+ // Check whether the next link is the first in a new sub-tree.
+ if ($next && $next['depth'] > $depth) {
+ // Recursively call _menu_tree_data to build the sub-tree.
+ $tree[$item['mlid']]['below'] = _menu_tree_data($links, $parents, $next['depth']);
+ // Fetch next link after filling the sub-tree.
+ $next = end($links);
+ }
+ // Determine if we should exit the loop and return.
+ if (!$next || $next['depth'] < $depth) {
+ break;
+ }
+ }
+ return $tree;
+}
+
+/**
+ * Preprocesses the rendered tree for theme_menu_tree().
+ */
+function template_preprocess_menu_tree(&$variables) {
+ $variables['tree'] = $variables['tree']['#children'];
+}
+
+/**
+ * Returns HTML for a wrapper for a menu sub-tree.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - tree: An HTML string containing the tree's items.
+ *
+ * @see template_preprocess_menu_tree()
+ * @ingroup themeable
+ */
+function theme_menu_tree($variables) {
+ return '<ul class="menu">' . $variables['tree'] . '</ul>';
+}
+
+/**
+ * Returns HTML for a menu link and submenu.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: Structured array data for a menu link.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_link(array $variables) {
+ $element = $variables['element'];
+ $sub_menu = '';
+
+ if ($element['#below']) {
+ $sub_menu = drupal_render($element['#below']);
+ }
+ $output = l($element['#title'], $element['#href'], $element['#localized_options']);
+ return '<li' . drupal_attributes($element['#attributes']) . '>' . $output . $sub_menu . "</li>\n";
+}
+
+/**
+ * Returns HTML for a single local task link.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing:
+ * - #link: A menu link array with 'title', 'href', and 'localized_options'
+ * keys.
+ * - #active: A boolean indicating whether the local task is active.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_local_task($variables) {
+ $link = $variables['element']['#link'];
+ $link_text = $link['title'];
+
+ if (!empty($variables['element']['#active'])) {
+ // Add text to indicate active tab for non-visual users.
+ $active = '<span class="element-invisible">' . t('(active tab)') . '</span>';
+
+ // If the link does not contain HTML already, check_plain() it now.
+ // After we set 'html'=TRUE the link will not be sanitized by l().
+ if (empty($link['localized_options']['html'])) {
+ $link['title'] = check_plain($link['title']);
+ }
+ $link['localized_options']['html'] = TRUE;
+ $link_text = t('!local-task-title!active', array('!local-task-title' => $link['title'], '!active' => $active));
+ }
+
+ return '<li' . (!empty($variables['element']['#active']) ? ' class="active"' : '') . '>' . l($link_text, $link['href'], $link['localized_options']) . "</li>\n";
+}
+
+/**
+ * Returns HTML for a single local action link.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing:
+ * - #link: A menu link array with 'title', 'href', and 'localized_options'
+ * keys.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_local_action($variables) {
+ $link = $variables['element']['#link'];
+
+ $output = '<li>';
+ if (isset($link['href'])) {
+ $output .= l($link['title'], $link['href'], isset($link['localized_options']) ? $link['localized_options'] : array());
+ }
+ elseif (!empty($link['localized_options']['html'])) {
+ $output .= $link['title'];
+ }
+ else {
+ $output .= check_plain($link['title']);
+ }
+ $output .= "</li>\n";
+
+ return $output;
+}
+
+/**
+ * Generates elements for the $arg array in the help hook.
+ */
+function drupal_help_arg($arg = array()) {
+ // Note - the number of empty elements should be > MENU_MAX_PARTS.
+ return $arg + array('', '', '', '', '', '', '', '', '', '', '', '');
+}
+
+/**
+ * Returns the help associated with the active menu item.
+ */
+function menu_get_active_help() {
+ $output = '';
+ $router_path = menu_tab_root_path();
+ // We will always have a path unless we are on a 403 or 404.
+ if (!$router_path) {
+ return '';
+ }
+
+ $arg = drupal_help_arg(arg(NULL));
+
+ foreach (module_implements('help') as $module) {
+ $function = $module . '_help';
+ // Lookup help for this path.
+ if ($help = $function($router_path, $arg)) {
+ $output .= $help . "\n";
+ }
+ }
+ return $output;
+}
+
+/**
+ * Gets the custom theme for the current page, if there is one.
+ *
+ * @param $initialize
+ * This parameter should only be used internally; it is set to TRUE in order
+ * to force the custom theme to be initialized for the current page request.
+ *
+ * @return
+ * The machine-readable name of the custom theme, if there is one.
+ *
+ * @see menu_set_custom_theme()
+ */
+function menu_get_custom_theme($initialize = FALSE) {
+ $custom_theme = &drupal_static(__FUNCTION__);
+ // Skip this if the site is offline or being installed or updated, since the
+ // menu system may not be correctly initialized then.
+ if ($initialize && !_menu_site_is_offline(TRUE) && (!defined('MAINTENANCE_MODE') || (MAINTENANCE_MODE != 'update' && MAINTENANCE_MODE != 'install'))) {
+ // First allow modules to dynamically set a custom theme for the current
+ // page. Since we can only have one, the last module to return a valid
+ // theme takes precedence.
+ $custom_themes = array_filter(module_invoke_all('custom_theme'), 'drupal_theme_access');
+ if (!empty($custom_themes)) {
+ $custom_theme = array_pop($custom_themes);
+ }
+ // If there is a theme callback function for the current page, execute it.
+ // If this returns a valid theme, it will override any theme that was set
+ // by a hook_custom_theme() implementation above.
+ $router_item = menu_get_item();
+ if (!empty($router_item['access']) && !empty($router_item['theme_callback']) && function_exists($router_item['theme_callback'])) {
+ $theme_name = call_user_func_array($router_item['theme_callback'], $router_item['theme_arguments']);
+ if (drupal_theme_access($theme_name)) {
+ $custom_theme = $theme_name;
+ }
+ }
+ }
+ return $custom_theme;
+}
+
+/**
+ * Sets a custom theme for the current page, if there is one.
+ */
+function menu_set_custom_theme() {
+ menu_get_custom_theme(TRUE);
+}
+
+/**
+ * Return an array containing the names of system-defined (default) menus.
+ */
+function menu_list_system_menus() {
+ return array(
+ 'navigation' => 'Navigation',
+ 'management' => 'Management',
+ 'user-menu' => 'User menu',
+ 'main-menu' => 'Main menu',
+ );
+}
+
+/**
+ * Return an array of links to be rendered as the Main menu.
+ */
+function menu_main_menu() {
+ return menu_navigation_links(variable_get('menu_main_links_source', 'main-menu'));
+}
+
+/**
+ * Return an array of links to be rendered as the Secondary links.
+ */
+function menu_secondary_menu() {
+
+ // If the secondary menu source is set as the primary menu, we display the
+ // second level of the primary menu.
+ if (variable_get('menu_secondary_links_source', 'user-menu') == variable_get('menu_main_links_source', 'main-menu')) {
+ return menu_navigation_links(variable_get('menu_main_links_source', 'main-menu'), 1);
+ }
+ else {
+ return menu_navigation_links(variable_get('menu_secondary_links_source', 'user-menu'), 0);
+ }
+}
+
+/**
+ * Return an array of links for a navigation menu.
+ *
+ * @param $menu_name
+ * The name of the menu.
+ * @param $level
+ * Optional, the depth of the menu to be returned.
+ *
+ * @return
+ * An array of links of the specified menu and level.
+ */
+function menu_navigation_links($menu_name, $level = 0) {
+ // Don't even bother querying the menu table if no menu is specified.
+ if (empty($menu_name)) {
+ return array();
+ }
+
+ // Get the menu hierarchy for the current page.
+ $tree = menu_tree_page_data($menu_name, $level + 1);
+
+ // Go down the active trail until the right level is reached.
+ while ($level-- > 0 && $tree) {
+ // Loop through the current level's items until we find one that is in trail.
+ while ($item = array_shift($tree)) {
+ if ($item['link']['in_active_trail']) {
+ // If the item is in the active trail, we continue in the subtree.
+ $tree = empty($item['below']) ? array() : $item['below'];
+ break;
+ }
+ }
+ }
+
+ // Create a single level of links.
+ $router_item = menu_get_item();
+ $links = array();
+ foreach ($tree as $item) {
+ if (!$item['link']['hidden']) {
+ $class = '';
+ $l = $item['link']['localized_options'];
+ $l['href'] = $item['link']['href'];
+ $l['title'] = $item['link']['title'];
+ if ($item['link']['in_active_trail']) {
+ $class = ' active-trail';
+ $l['attributes']['class'][] = 'active-trail';
+ }
+ // Normally, l() compares the href of every link with $_GET['q'] and sets
+ // the active class accordingly. But local tasks do not appear in menu
+ // trees, so if the current path is a local task, and this link is its
+ // tab root, then we have to set the class manually.
+ if ($item['link']['href'] == $router_item['tab_root_href'] && $item['link']['href'] != $_GET['q']) {
+ $l['attributes']['class'][] = 'active';
+ }
+ // Keyed with the unique mlid to generate classes in theme_links().
+ $links['menu-' . $item['link']['mlid'] . $class] = $l;
+ }
+ }
+ return $links;
+}
+
+/**
+ * Collects the local tasks (tabs), action links, and the root path.
+ *
+ * @param $level
+ * The level of tasks you ask for. Primary tasks are 0, secondary are 1.
+ *
+ * @return
+ * An array containing
+ * - tabs: Local tasks for the requested level:
+ * - count: The number of local tasks.
+ * - output: The themed output of local tasks.
+ * - actions: Action links for the requested level:
+ * - count: The number of action links.
+ * - output: The themed output of action links.
+ * - root_path: The router path for the current page. If the current page is
+ * a default local task, then this corresponds to the parent tab.
+ */
+function menu_local_tasks($level = 0) {
+ $data = &drupal_static(__FUNCTION__);
+ $root_path = &drupal_static(__FUNCTION__ . ':root_path', '');
+ $empty = array(
+ 'tabs' => array('count' => 0, 'output' => array()),
+ 'actions' => array('count' => 0, 'output' => array()),
+ 'root_path' => &$root_path,
+ );
+
+ if (!isset($data)) {
+ $data = array();
+ // Set defaults in case there are no actions or tabs.
+ $actions = $empty['actions'];
+ $tabs = array();
+
+ $router_item = menu_get_item();
+
+ // If this router item points to its parent, start from the parents to
+ // compute tabs and actions.
+ if ($router_item && ($router_item['type'] & MENU_LINKS_TO_PARENT)) {
+ $router_item = menu_get_item($router_item['tab_parent_href']);
+ }
+
+ // If we failed to fetch a router item or the current user doesn't have
+ // access to it, don't bother computing the tabs.
+ if (!$router_item || !$router_item['access']) {
+ return $empty;
+ }
+
+ // Get all tabs (also known as local tasks) and the root page.
+ $result = db_select('menu_router', NULL, array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('menu_router')
+ ->condition('tab_root', $router_item['tab_root'])
+ ->condition('context', MENU_CONTEXT_INLINE, '<>')
+ ->orderBy('weight')
+ ->orderBy('title')
+ ->execute();
+ $map = $router_item['original_map'];
+ $children = array();
+ $tasks = array();
+ $root_path = $router_item['path'];
+
+ foreach ($result as $item) {
+ _menu_translate($item, $map, TRUE);
+ if ($item['tab_parent']) {
+ // All tabs, but not the root page.
+ $children[$item['tab_parent']][$item['path']] = $item;
+ }
+ // Store the translated item for later use.
+ $tasks[$item['path']] = $item;
+ }
+
+ // Find all tabs below the current path.
+ $path = $router_item['path'];
+ // Tab parenting may skip levels, so the number of parts in the path may not
+ // equal the depth. Thus we use the $depth counter (offset by 1000 for ksort).
+ $depth = 1001;
+ $actions['count'] = 0;
+ $actions['output'] = array();
+ while (isset($children[$path])) {
+ $tabs_current = array();
+ $actions_current = array();
+ $next_path = '';
+ $tab_count = 0;
+ $action_count = 0;
+ foreach ($children[$path] as $item) {
+ // Local tasks can be normal items too, so bitmask with
+ // MENU_IS_LOCAL_TASK before checking.
+ if (!($item['type'] & MENU_IS_LOCAL_TASK)) {
+ // This item is not a tab, skip it.
+ continue;
+ }
+ if ($item['access']) {
+ $link = $item;
+ // The default task is always active. As tabs can be normal items
+ // too, so bitmask with MENU_LINKS_TO_PARENT before checking.
+ if (($item['type'] & MENU_LINKS_TO_PARENT) == MENU_LINKS_TO_PARENT) {
+ // Find the first parent which is not a default local task or action.
+ for ($p = $item['tab_parent']; ($tasks[$p]['type'] & MENU_LINKS_TO_PARENT) == MENU_LINKS_TO_PARENT; $p = $tasks[$p]['tab_parent']);
+ // Use the path of the parent instead.
+ $link['href'] = $tasks[$p]['href'];
+ // Mark the link as active, if the current path happens to be the
+ // path of the default local task itself (i.e., instead of its
+ // tab_parent_href or tab_root_href). Normally, links for default
+ // local tasks link to their parent, but the path of default local
+ // tasks can still be accessed directly, in which case this link
+ // would not be marked as active, since l() only compares the href
+ // with $_GET['q'].
+ if ($link['href'] != $_GET['q']) {
+ $link['localized_options']['attributes']['class'][] = 'active';
+ }
+ $tabs_current[] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => $link,
+ '#active' => TRUE,
+ );
+ $next_path = $item['path'];
+ $tab_count++;
+ }
+ else {
+ // Actions can be normal items too, so bitmask with
+ // MENU_IS_LOCAL_ACTION before checking.
+ if (($item['type'] & MENU_IS_LOCAL_ACTION) == MENU_IS_LOCAL_ACTION) {
+ // The item is an action, display it as such.
+ $actions_current[] = array(
+ '#theme' => 'menu_local_action',
+ '#link' => $link,
+ );
+ $action_count++;
+ }
+ else {
+ // Otherwise, it's a normal tab.
+ $tabs_current[] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => $link,
+ );
+ $tab_count++;
+ }
+ }
+ }
+ }
+ $path = $next_path;
+ $tabs[$depth]['count'] = $tab_count;
+ $tabs[$depth]['output'] = $tabs_current;
+ $actions['count'] += $action_count;
+ $actions['output'] = array_merge($actions['output'], $actions_current);
+ $depth++;
+ }
+ $data['actions'] = $actions;
+ // Find all tabs at the same level or above the current one.
+ $parent = $router_item['tab_parent'];
+ $path = $router_item['path'];
+ $current = $router_item;
+ $depth = 1000;
+ while (isset($children[$parent])) {
+ $tabs_current = array();
+ $next_path = '';
+ $next_parent = '';
+ $count = 0;
+ foreach ($children[$parent] as $item) {
+ // Skip local actions.
+ if ($item['type'] & MENU_IS_LOCAL_ACTION) {
+ continue;
+ }
+ if ($item['access']) {
+ $count++;
+ $link = $item;
+ // Local tasks can be normal items too, so bitmask with
+ // MENU_LINKS_TO_PARENT before checking.
+ if (($item['type'] & MENU_LINKS_TO_PARENT) == MENU_LINKS_TO_PARENT) {
+ // Find the first parent which is not a default local task.
+ for ($p = $item['tab_parent']; ($tasks[$p]['type'] & MENU_LINKS_TO_PARENT) == MENU_LINKS_TO_PARENT; $p = $tasks[$p]['tab_parent']);
+ // Use the path of the parent instead.
+ $link['href'] = $tasks[$p]['href'];
+ if ($item['path'] == $router_item['path']) {
+ $root_path = $tasks[$p]['path'];
+ }
+ }
+ // We check for the active tab.
+ if ($item['path'] == $path) {
+ // Mark the link as active, if the current path is a (second-level)
+ // local task of a default local task. Since this default local task
+ // links to its parent, l() will not mark it as active, as it only
+ // compares the link's href to $_GET['q'].
+ if ($link['href'] != $_GET['q']) {
+ $link['localized_options']['attributes']['class'][] = 'active';
+ }
+ $tabs_current[] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => $link,
+ '#active' => TRUE,
+ );
+ $next_path = $item['tab_parent'];
+ if (isset($tasks[$next_path])) {
+ $next_parent = $tasks[$next_path]['tab_parent'];
+ }
+ }
+ else {
+ $tabs_current[] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => $link,
+ );
+ }
+ }
+ }
+ $path = $next_path;
+ $parent = $next_parent;
+ $tabs[$depth]['count'] = $count;
+ $tabs[$depth]['output'] = $tabs_current;
+ $depth--;
+ }
+ // Sort by depth.
+ ksort($tabs);
+ // Remove the depth, we are interested only in their relative placement.
+ $tabs = array_values($tabs);
+ $data['tabs'] = $tabs;
+
+ // Allow modules to alter local tasks or dynamically append further tasks.
+ drupal_alter('menu_local_tasks', $data, $router_item, $root_path);
+ }
+
+ if (isset($data['tabs'][$level])) {
+ return array(
+ 'tabs' => $data['tabs'][$level],
+ 'actions' => $data['actions'],
+ 'root_path' => $root_path,
+ );
+ }
+ // @todo If there are no tabs, then there still can be actions; for example,
+ // when added via hook_menu_local_tasks_alter().
+ elseif (!empty($data['actions']['output'])) {
+ return array('actions' => $data['actions']) + $empty;
+ }
+ return $empty;
+}
+
+/**
+ * Retrieve contextual links for a system object based on registered local tasks.
+ *
+ * This leverages the menu system to retrieve the first layer of registered
+ * local tasks for a given system path. All local tasks of the tab type
+ * MENU_CONTEXT_INLINE are taken into account.
+ *
+ * @see hook_menu()
+ *
+ * For example, when considering the following registered local tasks:
+ * - node/%node/view (default local task) with no 'context' defined
+ * - node/%node/edit with context: MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE
+ * - node/%node/revisions with context: MENU_CONTEXT_PAGE
+ * - node/%node/report-as-spam with context: MENU_CONTEXT_INLINE
+ *
+ * If the path "node/123" is passed to this function, then it will return the
+ * links for 'edit' and 'report-as-spam'.
+ *
+ * @param $module
+ * The name of the implementing module. This is used to prefix the key for
+ * each contextual link, which is transformed into a CSS class during
+ * rendering by theme_links(). For example, if $module is 'block' and the
+ * retrieved local task path argument is 'edit', then the resulting CSS class
+ * will be 'block-edit'.
+ * @param $parent_path
+ * The static menu router path of the object to retrieve local tasks for, for
+ * example 'node' or 'admin/structure/block/manage'.
+ * @param $args
+ * A list of dynamic path arguments to append to $parent_path to form the
+ * fully-qualified menu router path, for example array(123) for a certain
+ * node or array('system', 'navigation') for a certain block.
+ *
+ * @return
+ * A list of menu router items that are local tasks for the passed-in path.
+ *
+ * @see contextual_links_preprocess()
+ */
+function menu_contextual_links($module, $parent_path, $args) {
+ static $path_empty = array();
+
+ $links = array();
+ // Performance: In case a previous invocation for the same parent path did not
+ // return any links, we immediately return here.
+ if (isset($path_empty[$parent_path])) {
+ return $links;
+ }
+ // Construct the item-specific parent path.
+ $path = $parent_path . '/' . implode('/', $args);
+
+ // Get the router item for the given parent link path.
+ $router_item = menu_get_item($path);
+ if (!$router_item || !$router_item['access']) {
+ $path_empty[$parent_path] = TRUE;
+ return $links;
+ }
+ $data = &drupal_static(__FUNCTION__, array());
+ $root_path = $router_item['path'];
+
+ // Performance: For a single, normalized path (such as 'node/%') we only query
+ // available tasks once per request.
+ if (!isset($data[$root_path])) {
+ // Get all contextual links that are direct children of the router item and
+ // not of the tab type 'view'.
+ $data[$root_path] = db_select('menu_router', 'm')
+ ->fields('m')
+ ->condition('tab_parent', $router_item['tab_root'])
+ ->condition('context', MENU_CONTEXT_NONE, '<>')
+ ->condition('context', MENU_CONTEXT_PAGE, '<>')
+ ->orderBy('weight')
+ ->orderBy('title')
+ ->execute()
+ ->fetchAllAssoc('path', PDO::FETCH_ASSOC);
+ }
+ $parent_length = drupal_strlen($root_path) + 1;
+ $map = $router_item['original_map'];
+ foreach ($data[$root_path] as $item) {
+ // Extract the actual "task" string from the path argument.
+ $key = drupal_substr($item['path'], $parent_length);
+
+ // Denormalize and translate the contextual link.
+ _menu_translate($item, $map, TRUE);
+ if (!$item['access']) {
+ continue;
+ }
+ // All contextual links are keyed by the actual "task" path argument,
+ // prefixed with the name of the implementing module.
+ $links[$module . '-' . $key] = $item;
+ }
+
+ // Allow modules to alter contextual links.
+ drupal_alter('menu_contextual_links', $links, $router_item, $root_path);
+
+ // Performance: If the current user does not have access to any links for this
+ // router path and no other module added further links, we assign FALSE here
+ // to skip the entire process the next time the same router path is requested.
+ if (empty($links)) {
+ $path_empty[$parent_path] = TRUE;
+ }
+
+ return $links;
+}
+
+/**
+ * Returns the rendered local tasks at the top level.
+ */
+function menu_primary_local_tasks() {
+ $links = menu_local_tasks(0);
+ // Do not display single tabs.
+ return ($links['tabs']['count'] > 1 ? $links['tabs']['output'] : '');
+}
+
+/**
+ * Returns the rendered local tasks at the second level.
+ */
+function menu_secondary_local_tasks() {
+ $links = menu_local_tasks(1);
+ // Do not display single tabs.
+ return ($links['tabs']['count'] > 1 ? $links['tabs']['output'] : '');
+}
+
+/**
+ * Returns the rendered local actions at the current level.
+ */
+function menu_local_actions() {
+ $links = menu_local_tasks();
+ return $links['actions']['output'];
+}
+
+/**
+ * Returns the router path, or the path of the parent tab of a default local task.
+ */
+function menu_tab_root_path() {
+ $links = menu_local_tasks();
+ return $links['root_path'];
+}
+
+/**
+ * Returns a renderable element for the primary and secondary tabs.
+ */
+function menu_local_tabs() {
+ return array(
+ '#theme' => 'menu_local_tasks',
+ '#primary' => menu_primary_local_tasks(),
+ '#secondary' => menu_secondary_local_tasks(),
+ );
+}
+
+/**
+ * Returns HTML for primary and secondary local tasks.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_local_tasks(&$variables) {
+ $output = '';
+
+ if (!empty($variables['primary'])) {
+ $variables['primary']['#prefix'] = '<h2 class="element-invisible">' . t('Primary tabs') . '</h2>';
+ $variables['primary']['#prefix'] .= '<ul class="tabs primary">';
+ $variables['primary']['#suffix'] = '</ul>';
+ $output .= drupal_render($variables['primary']);
+ }
+ if (!empty($variables['secondary'])) {
+ $variables['secondary']['#prefix'] = '<h2 class="element-invisible">' . t('Secondary tabs') . '</h2>';
+ $variables['secondary']['#prefix'] .= '<ul class="tabs secondary">';
+ $variables['secondary']['#suffix'] = '</ul>';
+ $output .= drupal_render($variables['secondary']);
+ }
+
+ return $output;
+}
+
+/**
+ * Set (or get) the active menu for the current page - determines the active trail.
+ */
+function menu_set_active_menu_names($menu_names = NULL) {
+ $active = &drupal_static(__FUNCTION__);
+
+ if (isset($menu_names) && is_array($menu_names)) {
+ $active = $menu_names;
+ }
+ elseif (!isset($active)) {
+ $active = variable_get('menu_default_active_menus', array_keys(menu_list_system_menus()));
+ }
+ return $active;
+}
+
+/**
+ * Get the active menu for the current page - determines the active trail.
+ */
+function menu_get_active_menu_names() {
+ return menu_set_active_menu_names();
+}
+
+/**
+ * Set the active path, which determines which page is loaded.
+ *
+ * Note that this may not have the desired effect unless invoked very early
+ * in the page load, such as during hook_boot, or unless you call
+ * menu_execute_active_handler() to generate your page output.
+ *
+ * @param $path
+ * A Drupal path - not a path alias.
+ */
+function menu_set_active_item($path) {
+ $_GET['q'] = $path;
+}
+
+/**
+ * Sets the active trail (path to menu tree root) of the current page.
+ *
+ * Any trail set by this function will only be used for functionality that calls
+ * menu_get_active_trail(). Drupal core only uses trails set here for
+ * breadcrumbs and the page title and not for menu trees or page content.
+ * Additionally, breadcrumbs set by drupal_set_breadcrumb() will override any
+ * trail set here.
+ *
+ * To affect the trail used by menu trees, use menu_tree_set_path(). To affect
+ * the page content, use menu_set_active_item() instead.
+ *
+ * @param $new_trail
+ * Menu trail to set; the value is saved in a static variable and can be
+ * retrieved by menu_get_active_trail(). The format of this array should be
+ * the same as the return value of menu_get_active_trail().
+ *
+ * @return
+ * The active trail. See menu_get_active_trail() for details.
+ */
+function menu_set_active_trail($new_trail = NULL) {
+ $trail = &drupal_static(__FUNCTION__);
+
+ if (isset($new_trail)) {
+ $trail = $new_trail;
+ }
+ elseif (!isset($trail)) {
+ $trail = array();
+ $trail[] = array(
+ 'title' => t('Home'),
+ 'href' => '<front>',
+ 'link_path' => '',
+ 'localized_options' => array(),
+ 'type' => 0,
+ );
+
+ // Try to retrieve a menu link corresponding to the current path. If more
+ // than one exists, the link from the most preferred menu is returned.
+ $preferred_link = menu_link_get_preferred();
+ $current_item = menu_get_item();
+
+ // There is a link for the current path.
+ if ($preferred_link) {
+ // Pass TRUE for $only_active_trail to make menu_tree_page_data() build
+ // a stripped down menu tree containing the active trail only, in case
+ // the given menu has not been built in this request yet.
+ $tree = menu_tree_page_data($preferred_link['menu_name'], NULL, TRUE);
+ list($key, $curr) = each($tree);
+ }
+ // There is no link for the current path.
+ else {
+ $preferred_link = $current_item;
+ $curr = FALSE;
+ }
+
+ while ($curr) {
+ $link = $curr['link'];
+ if ($link['in_active_trail']) {
+ // Add the link to the trail, unless it links to its parent.
+ if (!($link['type'] & MENU_LINKS_TO_PARENT)) {
+ // The menu tree for the active trail may contain additional links
+ // that have not been translated yet, since they contain dynamic
+ // argument placeholders (%). Such links are not contained in regular
+ // menu trees, and have only been loaded for the additional
+ // translation that happens here, so as to be able to display them in
+ // the breadcumb for the current page.
+ // @see _menu_tree_check_access()
+ // @see _menu_link_translate()
+ if (strpos($link['href'], '%') !== FALSE) {
+ _menu_link_translate($link, TRUE);
+ }
+ if ($link['access']) {
+ $trail[] = $link;
+ }
+ }
+ $tree = $curr['below'] ? $curr['below'] : array();
+ }
+ list($key, $curr) = each($tree);
+ }
+ // Make sure the current page is in the trail to build the page title, by
+ // appending either the preferred link or the menu router item for the
+ // current page. Exclude it if we are on the front page.
+ $last = end($trail);
+ if ($last['href'] != $preferred_link['href'] && !drupal_is_front_page()) {
+ $trail[] = $preferred_link;
+ }
+ }
+ return $trail;
+}
+
+/**
+ * Lookup the preferred menu link for a given system path.
+ *
+ * @param $path
+ * The path, for example 'node/5'. The function will find the corresponding
+ * menu link ('node/5' if it exists, or fallback to 'node/%').
+ *
+ * @return
+ * A fully translated menu link, or NULL if no matching menu link was
+ * found. The most specific menu link ('node/5' preferred over 'node/%') in
+ * the most preferred menu (as defined by menu_get_active_menu_names()) is
+ * returned.
+ */
+function menu_link_get_preferred($path = NULL) {
+ $preferred_links = &drupal_static(__FUNCTION__);
+
+ if (!isset($path)) {
+ $path = $_GET['q'];
+ }
+
+ if (!isset($preferred_links[$path])) {
+ $preferred_links[$path] = FALSE;
+
+ // Look for the correct menu link by building a list of candidate paths,
+ // which are ordered by priority (translated hrefs are preferred over
+ // untranslated paths). Afterwards, the most relevant path is picked from
+ // the menus, ordered by menu preference.
+ $item = menu_get_item($path);
+ $path_candidates = array();
+ // 1. The current item href.
+ $path_candidates[$item['href']] = $item['href'];
+ // 2. The tab root href of the current item (if any).
+ if ($item['tab_parent'] && ($tab_root = menu_get_item($item['tab_root_href']))) {
+ $path_candidates[$tab_root['href']] = $tab_root['href'];
+ }
+ // 3. The current item path (with wildcards).
+ $path_candidates[$item['path']] = $item['path'];
+ // 4. The tab root path of the current item (if any).
+ if (!empty($tab_root)) {
+ $path_candidates[$tab_root['path']] = $tab_root['path'];
+ }
+
+ // Retrieve a list of menu names, ordered by preference.
+ $menu_names = menu_get_active_menu_names();
+
+ $query = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC));
+ $query->leftJoin('menu_router', 'm', 'm.path = ml.router_path');
+ $query->fields('ml');
+ // Weight must be taken from {menu_links}, not {menu_router}.
+ $query->addField('ml', 'weight', 'link_weight');
+ $query->fields('m');
+ $query->condition('ml.menu_name', $menu_names, 'IN');
+ $query->condition('ml.link_path', $path_candidates, 'IN');
+
+ // Sort candidates by link path and menu name.
+ $candidates = array();
+ foreach ($query->execute() as $candidate) {
+ $candidate['weight'] = $candidate['link_weight'];
+ $candidates[$candidate['link_path']][$candidate['menu_name']] = $candidate;
+ }
+
+ // Pick the most specific link, in the most preferred menu.
+ foreach ($path_candidates as $link_path) {
+ if (!isset($candidates[$link_path])) {
+ continue;
+ }
+ foreach ($menu_names as $menu_name) {
+ if (!isset($candidates[$link_path][$menu_name])) {
+ continue;
+ }
+ $candidate_item = $candidates[$link_path][$menu_name];
+ $map = explode('/', $path);
+ _menu_translate($candidate_item, $map);
+ if ($candidate_item['access']) {
+ $preferred_links[$path] = $candidate_item;
+ }
+ break 2;
+ }
+ }
+ }
+
+ return $preferred_links[$path];
+}
+
+/**
+ * Gets the active trail (path to root menu root) of the current page.
+ *
+ * If a trail is supplied to menu_set_active_trail(), that value is returned. If
+ * a trail is not supplied to menu_set_active_trail(), the path to the current
+ * page is calculated and returned. The calculated trail is also saved as a
+ * static value for use by subsequent calls to menu_get_active_trail().
+ *
+ * @return
+ * Path to menu root of the current page, as an array of menu link items,
+ * starting with the site's home page. Each link item is an associative array
+ * with the following components:
+ * - title: Title of the item.
+ * - href: Drupal path of the item.
+ * - localized_options: Options for passing into the l() function.
+ * - type: A menu type constant, such as MENU_DEFAULT_LOCAL_TASK, or 0 to
+ * indicate it's not really in the menu (used for the home page item).
+ */
+function menu_get_active_trail() {
+ return menu_set_active_trail();
+}
+
+/**
+ * Get the breadcrumb for the current page, as determined by the active trail.
+ *
+ * @see menu_set_active_trail()
+ */
+function menu_get_active_breadcrumb() {
+ $breadcrumb = array();
+
+ // No breadcrumb for the front page.
+ if (drupal_is_front_page()) {
+ return $breadcrumb;
+ }
+
+ $item = menu_get_item();
+ if (!empty($item['access'])) {
+ $active_trail = menu_get_active_trail();
+
+ // Allow modules to alter the breadcrumb, if possible, as that is much
+ // faster than rebuilding an entirely new active trail.
+ drupal_alter('menu_breadcrumb', $active_trail, $item);
+
+ // Don't show a link to the current page in the breadcrumb trail.
+ $end = end($active_trail);
+ if ($item['href'] == $end['href']) {
+ array_pop($active_trail);
+ }
+
+ // Remove the tab root (parent) if the current path links to its parent.
+ // Normally, the tab root link is included in the breadcrumb, as soon as we
+ // are on a local task or any other child link. However, if we are on a
+ // default local task (e.g., node/%/view), then we do not want the tab root
+ // link (e.g., node/%) to appear, as it would be identical to the current
+ // page. Since this behavior also needs to work recursively (i.e., on
+ // default local tasks of default local tasks), and since the last non-task
+ // link in the trail is used as page title (see menu_get_active_title()),
+ // this condition cannot be cleanly integrated into menu_get_active_trail().
+ // menu_get_active_trail() already skips all links that link to their parent
+ // (commonly MENU_DEFAULT_LOCAL_TASK). In order to also hide the parent link
+ // itself, we always remove the last link in the trail, if the current
+ // router item links to its parent.
+ if (($item['type'] & MENU_LINKS_TO_PARENT) == MENU_LINKS_TO_PARENT) {
+ array_pop($active_trail);
+ }
+
+ foreach ($active_trail as $parent) {
+ $breadcrumb[] = l($parent['title'], $parent['href'], $parent['localized_options']);
+ }
+ }
+ return $breadcrumb;
+}
+
+/**
+ * Get the title of the current page, as determined by the active trail.
+ */
+function menu_get_active_title() {
+ $active_trail = menu_get_active_trail();
+
+ foreach (array_reverse($active_trail) as $item) {
+ if (!(bool) ($item['type'] & MENU_IS_LOCAL_TASK)) {
+ return $item['title'];
+ }
+ }
+}
+
+/**
+ * Get a menu link by its mlid, access checked and link translated for rendering.
+ *
+ * This function should never be called from within node_load() or any other
+ * function used as a menu object load function since an infinite recursion may
+ * occur.
+ *
+ * @param $mlid
+ * The mlid of the menu item.
+ *
+ * @return
+ * A menu link, with $item['access'] filled and link translated for
+ * rendering.
+ */
+function menu_link_load($mlid) {
+ if (is_numeric($mlid)) {
+ $query = db_select('menu_links', 'ml');
+ $query->leftJoin('menu_router', 'm', 'm.path = ml.router_path');
+ $query->fields('ml');
+ // Weight should be taken from {menu_links}, not {menu_router}.
+ $query->addField('ml', 'weight', 'link_weight');
+ $query->fields('m');
+ $query->condition('ml.mlid', $mlid);
+ if ($item = $query->execute()->fetchAssoc()) {
+ $item['weight'] = $item['link_weight'];
+ _menu_link_translate($item);
+ return $item;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Clears the cached cached data for a single named menu.
+ */
+function menu_cache_clear($menu_name = 'navigation') {
+ $cache_cleared = &drupal_static(__FUNCTION__, array());
+
+ if (empty($cache_cleared[$menu_name])) {
+ cache('menu')->deletePrefix('links:' . $menu_name . ':');
+ $cache_cleared[$menu_name] = 1;
+ }
+ elseif ($cache_cleared[$menu_name] == 1) {
+ drupal_register_shutdown_function('cache_clear_all', 'links:' . $menu_name . ':', 'cache_menu', TRUE);
+ $cache_cleared[$menu_name] = 2;
+ }
+
+ // Also clear the menu system static caches.
+ menu_reset_static_cache();
+}
+
+/**
+ * Clears all cached menu data. This should be called any time broad changes
+ * might have been made to the router items or menu links.
+ */
+function menu_cache_clear_all() {
+ cache('menu')->flush();
+ menu_reset_static_cache();
+}
+
+/**
+ * Resets the menu system static cache.
+ */
+function menu_reset_static_cache() {
+ drupal_static_reset('_menu_build_tree');
+ drupal_static_reset('menu_tree');
+ drupal_static_reset('menu_tree_all_data');
+ drupal_static_reset('menu_tree_page_data');
+ drupal_static_reset('menu_load_all');
+ drupal_static_reset('menu_link_get_preferred');
+}
+
+/**
+ * (Re)populate the database tables used by various menu functions.
+ *
+ * This function will clear and populate the {menu_router} table, add entries
+ * to {menu_links} for new router items, then remove stale items from
+ * {menu_links}.
+ *
+ * @return
+ * TRUE if the menu was rebuilt, FALSE if another thread was rebuilding
+ * in parallel and the current thread just waited for completion.
+ */
+function menu_rebuild() {
+ if (!lock_acquire('menu_rebuild')) {
+ // Wait for another request that is already doing this work.
+ // We choose to block here since otherwise the router item may not
+ // be available in menu_execute_active_handler() resulting in a 404.
+ lock_wait('menu_rebuild');
+ return FALSE;
+ }
+
+ $transaction = db_transaction();
+
+ try {
+ list($menu, $masks) = menu_router_build();
+ _menu_router_save($menu, $masks);
+ _menu_navigation_links_rebuild($menu);
+ // Clear the menu, page and block caches.
+ menu_cache_clear_all();
+ _menu_clear_page_cache();
+ // Indicate that the menu has been successfully rebuilt.
+ variable_del('menu_rebuild_needed');
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('menu', $e);
+ }
+
+ lock_release('menu_rebuild');
+ return TRUE;
+}
+
+/**
+ * Collect and alter the menu definitions.
+ */
+function menu_router_build() {
+ // We need to manually call each module so that we can know which module
+ // a given item came from.
+ $callbacks = array();
+ foreach (module_implements('menu') as $module) {
+ $router_items = call_user_func($module . '_menu');
+ if (isset($router_items) && is_array($router_items)) {
+ foreach (array_keys($router_items) as $path) {
+ $router_items[$path]['module'] = $module;
+ }
+ $callbacks = array_merge($callbacks, $router_items);
+ }
+ }
+ // Alter the menu as defined in modules, keys are like user/%user.
+ drupal_alter('menu', $callbacks);
+ list($menu, $masks) = _menu_router_build($callbacks);
+ _menu_router_cache($menu);
+
+ return array($menu, $masks);
+}
+
+/**
+ * Helper function to store the menu router if we have it in memory.
+ */
+function _menu_router_cache($new_menu = NULL) {
+ $menu = &drupal_static(__FUNCTION__);
+
+ if (isset($new_menu)) {
+ $menu = $new_menu;
+ }
+ return $menu;
+}
+
+/**
+ * Get the menu router.
+ */
+function menu_get_router() {
+ // Check first if we have it in memory already.
+ $menu = _menu_router_cache();
+ if (empty($menu)) {
+ list($menu, $masks) = menu_router_build();
+ }
+ return $menu;
+}
+
+/**
+ * Builds a link from a router item.
+ */
+function _menu_link_build($item) {
+ // Suggested items are disabled by default.
+ if ($item['type'] == MENU_SUGGESTED_ITEM) {
+ $item['hidden'] = 1;
+ }
+ // Hide all items that are not visible in the tree.
+ elseif (!($item['type'] & MENU_VISIBLE_IN_TREE)) {
+ $item['hidden'] = -1;
+ }
+ // Note, we set this as 'system', so that we can be sure to distinguish all
+ // the menu links generated automatically from entries in {menu_router}.
+ $item['module'] = 'system';
+ $item += array(
+ 'menu_name' => 'navigation',
+ 'link_title' => $item['title'],
+ 'link_path' => $item['path'],
+ 'hidden' => 0,
+ 'options' => empty($item['description']) ? array() : array('attributes' => array('title' => $item['description'])),
+ );
+ return $item;
+}
+
+/**
+ * Helper function to build menu links for the items in the menu router.
+ */
+function _menu_navigation_links_rebuild($menu) {
+ // Add normal and suggested items as links.
+ $menu_links = array();
+ foreach ($menu as $path => $item) {
+ if ($item['_visible']) {
+ $menu_links[$path] = $item;
+ $sort[$path] = $item['_number_parts'];
+ }
+ }
+ if ($menu_links) {
+ // Keep an array of processed menu links, to allow menu_link_save() to
+ // check this for parents instead of querying the database.
+ $parent_candidates = array();
+ // Make sure no child comes before its parent.
+ array_multisort($sort, SORT_NUMERIC, $menu_links);
+
+ foreach ($menu_links as $key => $item) {
+ $existing_item = db_select('menu_links')
+ ->fields('menu_links')
+ ->condition('link_path', $item['path'])
+ ->condition('module', 'system')
+ ->execute()->fetchAssoc();
+ if ($existing_item) {
+ $item['mlid'] = $existing_item['mlid'];
+ // A change in hook_menu may move the link to a different menu
+ if (empty($item['menu_name']) || ($item['menu_name'] == $existing_item['menu_name'])) {
+ $item['menu_name'] = $existing_item['menu_name'];
+ $item['plid'] = $existing_item['plid'];
+ }
+ else {
+ // It moved to a new menu. Let menu_link_save() try to find a new
+ // parent based on the path.
+ unset($item['plid']);
+ }
+ $item['has_children'] = $existing_item['has_children'];
+ $item['updated'] = $existing_item['updated'];
+ }
+ if ($existing_item && $existing_item['customized']) {
+ $parent_candidates[$existing_item['mlid']] = $existing_item;
+ }
+ else {
+ $item = _menu_link_build($item);
+ menu_link_save($item, $existing_item, $parent_candidates);
+ $parent_candidates[$item['mlid']] = $item;
+ unset($menu_links[$key]);
+ }
+ }
+ }
+ $paths = array_keys($menu);
+ // Updated and customized items whose router paths are gone need new ones.
+ $result = db_select('menu_links', NULL, array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('menu_links', array(
+ 'link_path',
+ 'mlid',
+ 'router_path',
+ 'updated',
+ ))
+ ->condition(db_or()
+ ->condition('updated', 1)
+ ->condition(db_and()
+ ->condition('router_path', $paths, 'NOT IN')
+ ->condition('external', 0)
+ ->condition('customized', 1)
+ )
+ )
+ ->execute();
+ foreach ($result as $item) {
+ $router_path = _menu_find_router_path($item['link_path']);
+ if (!empty($router_path) && ($router_path != $item['router_path'] || $item['updated'])) {
+ // If the router path and the link path matches, it's surely a working
+ // item, so we clear the updated flag.
+ $updated = $item['updated'] && $router_path != $item['link_path'];
+ db_update('menu_links')
+ ->fields(array(
+ 'router_path' => $router_path,
+ 'updated' => (int) $updated,
+ ))
+ ->condition('mlid', $item['mlid'])
+ ->execute();
+ }
+ }
+ // Find any item whose router path does not exist any more.
+ $result = db_select('menu_links')
+ ->fields('menu_links')
+ ->condition('router_path', $paths, 'NOT IN')
+ ->condition('external', 0)
+ ->condition('updated', 0)
+ ->condition('customized', 0)
+ ->orderBy('depth', 'DESC')
+ ->execute();
+ // Remove all such items. Starting from those with the greatest depth will
+ // minimize the amount of re-parenting done by menu_link_delete().
+ foreach ($result as $item) {
+ _menu_delete_item($item, TRUE);
+ }
+}
+
+/**
+ * Clone an array of menu links.
+ *
+ * @param $links
+ * An array of menu links to clone.
+ * @param $menu_name
+ * (optional) The name of a menu that the links will be cloned for. If not
+ * set, the cloned links will be in the same menu as the original set of
+ * links that were passed in.
+ *
+ * @return
+ * An array of menu links with the same properties as the passed-in array,
+ * but with the link identifiers removed so that a new link will be created
+ * when any of them is passed in to menu_link_save().
+ *
+ * @see menu_link_save()
+ */
+function menu_links_clone($links, $menu_name = NULL) {
+ foreach ($links as &$link) {
+ unset($link['mlid']);
+ unset($link['plid']);
+ if (isset($menu_name)) {
+ $link['menu_name'] = $menu_name;
+ }
+ }
+ return $links;
+}
+
+/**
+ * Returns an array containing all links for a menu.
+ *
+ * @param $menu_name
+ * The name of the menu whose links should be returned.
+ *
+ * @return
+ * An array of menu links.
+ */
+function menu_load_links($menu_name) {
+ $links = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('ml')
+ ->condition('ml.menu_name', $menu_name)
+ // Order by weight so as to be helpful for menus that are only one level
+ // deep.
+ ->orderBy('weight')
+ ->execute()
+ ->fetchAll();
+
+ foreach ($links as &$link) {
+ $link['options'] = unserialize($link['options']);
+ }
+ return $links;
+}
+
+/**
+ * Deletes all links for a menu.
+ *
+ * @param $menu_name
+ * The name of the menu whose links will be deleted.
+ */
+function menu_delete_links($menu_name) {
+ $links = menu_load_links($menu_name);
+ foreach ($links as $link) {
+ // To speed up the deletion process, we reset some link properties that
+ // would trigger re-parenting logic in _menu_delete_item() and
+ // _menu_update_parental_status().
+ $link['has_children'] = FALSE;
+ $link['plid'] = 0;
+ _menu_delete_item($link);
+ }
+}
+
+/**
+ * Delete one or several menu links.
+ *
+ * @param $mlid
+ * A valid menu link mlid or NULL. If NULL, $path is used.
+ * @param $path
+ * The path to the menu items to be deleted. $mlid must be NULL.
+ */
+function menu_link_delete($mlid, $path = NULL) {
+ if (isset($mlid)) {
+ _menu_delete_item(db_query("SELECT * FROM {menu_links} WHERE mlid = :mlid", array(':mlid' => $mlid))->fetchAssoc());
+ }
+ else {
+ $result = db_query("SELECT * FROM {menu_links} WHERE link_path = :link_path", array(':link_path' => $path));
+ foreach ($result as $link) {
+ _menu_delete_item($link);
+ }
+ }
+}
+
+/**
+ * Helper function for menu_link_delete; deletes a single menu link.
+ *
+ * @param $item
+ * Item to be deleted.
+ * @param $force
+ * Forces deletion. Internal use only, setting to TRUE is discouraged.
+ */
+function _menu_delete_item($item, $force = FALSE) {
+ $item = is_object($item) ? get_object_vars($item) : $item;
+ if ($item && ($item['module'] != 'system' || $item['updated'] || $force)) {
+ // Children get re-attached to the item's parent.
+ if ($item['has_children']) {
+ $result = db_query("SELECT mlid FROM {menu_links} WHERE plid = :plid", array(':plid' => $item['mlid']));
+ foreach ($result as $m) {
+ $child = menu_link_load($m->mlid);
+ $child['plid'] = $item['plid'];
+ menu_link_save($child);
+ }
+ }
+
+ // Notify modules we are deleting the item.
+ module_invoke_all('menu_link_delete', $item);
+
+ db_delete('menu_links')->condition('mlid', $item['mlid'])->execute();
+
+ // Update the has_children status of the parent.
+ _menu_update_parental_status($item);
+ menu_cache_clear($item['menu_name']);
+ _menu_clear_page_cache();
+ }
+}
+
+/**
+ * Saves a menu link.
+ *
+ * After calling this function, rebuild the menu cache using
+ * menu_cache_clear_all().
+ *
+ * @param $item
+ * An associative array representing a menu link item, with elements:
+ * - link_path: (required) The path of the menu item, which should be
+ * normalized first by calling drupal_get_normal_path() on it.
+ * - link_title: (required) Title to appear in menu for the link.
+ * - menu_name: (optional) The machine name of the menu for the link.
+ * Defaults to 'navigation'.
+ * - weight: (optional) Integer to determine position in menu. Default is 0.
+ * - expanded: (optional) Boolean that determines if the item is expanded.
+ * - options: (optional) An array of options, see l() for more.
+ * - mlid: (optional) Menu link identifier, the primary integer key for each
+ * menu link. Can be set to an existing value, or to 0 or NULL
+ * to insert a new link.
+ * - plid: (optional) The mlid of the parent.
+ * - router_path: (optional) The path of the relevant router item.
+ * @param $existing_item
+ * Optional, the current record from the {menu_links} table as an array.
+ * @param $parent_candidates
+ * Optional array of menu links keyed by mlid. Used by
+ * _menu_navigation_links_rebuild() only.
+ *
+ * @return
+ * The mlid of the saved menu link, or FALSE if the menu link could not be
+ * saved.
+ */
+function menu_link_save(&$item, $existing_item = array(), $parent_candidates = array()) {
+ drupal_alter('menu_link', $item);
+
+ // This is the easiest way to handle the unique internal path '<front>',
+ // since a path marked as external does not need to match a router path.
+ $item['external'] = (url_is_external($item['link_path']) || $item['link_path'] == '<front>') ? 1 : 0;
+ // Load defaults.
+ $item += array(
+ 'menu_name' => 'navigation',
+ 'weight' => 0,
+ 'link_title' => '',
+ 'hidden' => 0,
+ 'has_children' => 0,
+ 'expanded' => 0,
+ 'options' => array(),
+ 'module' => 'menu',
+ 'customized' => 0,
+ 'updated' => 0,
+ );
+ if (isset($item['mlid'])) {
+ if (!$existing_item) {
+ $existing_item = db_query('SELECT * FROM {menu_links} WHERE mlid = :mlid', array('mlid' => $item['mlid']))->fetchAssoc();
+ }
+ if ($existing_item) {
+ $existing_item['options'] = unserialize($existing_item['options']);
+ }
+ }
+ else {
+ $existing_item = FALSE;
+ }
+
+ // Try to find a parent link. If found, assign it and derive its menu.
+ $parent = _menu_link_find_parent($item, $parent_candidates);
+ if (!empty($parent['mlid'])) {
+ $item['plid'] = $parent['mlid'];
+ $item['menu_name'] = $parent['menu_name'];
+ }
+ // If no corresponding parent link was found, move the link to the top-level.
+ else {
+ $item['plid'] = 0;
+ }
+ $menu_name = $item['menu_name'];
+
+ if (!$existing_item) {
+ $item['mlid'] = db_insert('menu_links')
+ ->fields(array(
+ 'menu_name' => $item['menu_name'],
+ 'plid' => $item['plid'],
+ 'link_path' => $item['link_path'],
+ 'hidden' => $item['hidden'],
+ 'external' => $item['external'],
+ 'has_children' => $item['has_children'],
+ 'expanded' => $item['expanded'],
+ 'weight' => $item['weight'],
+ 'module' => $item['module'],
+ 'link_title' => $item['link_title'],
+ 'options' => serialize($item['options']),
+ 'customized' => $item['customized'],
+ 'updated' => $item['updated'],
+ ))
+ ->execute();
+ }
+
+ // Directly fill parents for top-level links.
+ if ($item['plid'] == 0) {
+ $item['p1'] = $item['mlid'];
+ for ($i = 2; $i <= MENU_MAX_DEPTH; $i++) {
+ $item["p$i"] = 0;
+ }
+ $item['depth'] = 1;
+ }
+ // Otherwise, ensure that this link's depth is not beyond the maximum depth
+ // and fill parents based on the parent link.
+ else {
+ if ($item['has_children'] && $existing_item) {
+ $limit = MENU_MAX_DEPTH - menu_link_children_relative_depth($existing_item) - 1;
+ }
+ else {
+ $limit = MENU_MAX_DEPTH - 1;
+ }
+ if ($parent['depth'] > $limit) {
+ return FALSE;
+ }
+ $item['depth'] = $parent['depth'] + 1;
+ _menu_link_parents_set($item, $parent);
+ }
+ // Need to check both plid and menu_name, since plid can be 0 in any menu.
+ if ($existing_item && ($item['plid'] != $existing_item['plid'] || $menu_name != $existing_item['menu_name'])) {
+ _menu_link_move_children($item, $existing_item);
+ }
+ // Find the router_path.
+ if (empty($item['router_path']) || !$existing_item || ($existing_item['link_path'] != $item['link_path'])) {
+ if ($item['external']) {
+ $item['router_path'] = '';
+ }
+ else {
+ // Find the router path which will serve this path.
+ $item['parts'] = explode('/', $item['link_path'], MENU_MAX_PARTS);
+ $item['router_path'] = _menu_find_router_path($item['link_path']);
+ }
+ }
+ // If every value in $existing_item is the same in the $item, there is no
+ // reason to run the update queries or clear the caches. We use
+ // array_intersect_assoc() with the $item as the first parameter because
+ // $item may have additional keys left over from building a router entry.
+ // The intersect removes the extra keys, allowing a meaningful comparison.
+ if (!$existing_item || (array_intersect_assoc($item, $existing_item)) != $existing_item) {
+ db_update('menu_links')
+ ->fields(array(
+ 'menu_name' => $item['menu_name'],
+ 'plid' => $item['plid'],
+ 'link_path' => $item['link_path'],
+ 'router_path' => $item['router_path'],
+ 'hidden' => $item['hidden'],
+ 'external' => $item['external'],
+ 'has_children' => $item['has_children'],
+ 'expanded' => $item['expanded'],
+ 'weight' => $item['weight'],
+ 'depth' => $item['depth'],
+ 'p1' => $item['p1'],
+ 'p2' => $item['p2'],
+ 'p3' => $item['p3'],
+ 'p4' => $item['p4'],
+ 'p5' => $item['p5'],
+ 'p6' => $item['p6'],
+ 'p7' => $item['p7'],
+ 'p8' => $item['p8'],
+ 'p9' => $item['p9'],
+ 'module' => $item['module'],
+ 'link_title' => $item['link_title'],
+ 'options' => serialize($item['options']),
+ 'customized' => $item['customized'],
+ ))
+ ->condition('mlid', $item['mlid'])
+ ->execute();
+ // Check the has_children status of the parent.
+ _menu_update_parental_status($item);
+ menu_cache_clear($menu_name);
+ if ($existing_item && $menu_name != $existing_item['menu_name']) {
+ menu_cache_clear($existing_item['menu_name']);
+ }
+ // Notify modules we have acted on a menu item.
+ $hook = 'menu_link_insert';
+ if ($existing_item) {
+ $hook = 'menu_link_update';
+ }
+ module_invoke_all($hook, $item);
+ // Now clear the cache.
+ _menu_clear_page_cache();
+ }
+ return $item['mlid'];
+}
+
+/**
+ * Find a possible parent for a given menu link.
+ *
+ * Because the parent of a given link might not exist anymore in the database,
+ * we apply a set of heuristics to determine a proper parent:
+ *
+ * - use the passed parent link if specified and existing.
+ * - else, use the first existing link down the previous link hierarchy
+ * - else, for system menu links (derived from hook_menu()), reparent
+ * based on the path hierarchy.
+ *
+ * @param $menu_link
+ * A menu link.
+ * @param $parent_candidates
+ * An array of menu links keyed by mlid.
+ * @return
+ * A menu link structure of the possible parent or FALSE if no valid parent
+ * has been found.
+ */
+function _menu_link_find_parent($menu_link, $parent_candidates = array()) {
+ $parent = FALSE;
+
+ // This item is explicitely top-level, skip the rest of the parenting.
+ if (isset($menu_link['plid']) && empty($menu_link['plid'])) {
+ return $parent;
+ }
+
+ // If we have a parent link ID, try to use that.
+ $candidates = array();
+ if (isset($menu_link['plid'])) {
+ $candidates[] = $menu_link['plid'];
+ }
+
+ // Else, if we have a link hierarchy try to find a valid parent in there.
+ if (!empty($menu_link['depth']) && $menu_link['depth'] > 1) {
+ for ($depth = $menu_link['depth'] - 1; $depth >= 1; $depth--) {
+ $candidates[] = $menu_link['p' . $depth];
+ }
+ }
+
+ foreach ($candidates as $mlid) {
+ if (isset($parent_candidates[$mlid])) {
+ $parent = $parent_candidates[$mlid];
+ }
+ else {
+ $parent = db_query("SELECT * FROM {menu_links} WHERE mlid = :mlid", array(':mlid' => $mlid))->fetchAssoc();
+ }
+ if ($parent) {
+ return $parent;
+ }
+ }
+
+ // If everything else failed, try to derive the parent from the path
+ // hierarchy. This only makes sense for links derived from menu router
+ // items (ie. from hook_menu()).
+ if ($menu_link['module'] == 'system') {
+ $query = db_select('menu_links');
+ $query->condition('module', 'system');
+ // We always respect the link's 'menu_name'; inheritance for router items is
+ // ensured in _menu_router_build().
+ $query->condition('menu_name', $menu_link['menu_name']);
+
+ // Find the parent - it must be unique.
+ $parent_path = $menu_link['link_path'];
+ do {
+ $parent = FALSE;
+ $parent_path = substr($parent_path, 0, strrpos($parent_path, '/'));
+ $new_query = clone $query;
+ $new_query->condition('link_path', $parent_path);
+ // Only valid if we get a unique result.
+ if ($new_query->countQuery()->execute()->fetchField() == 1) {
+ $parent = $new_query->fields('menu_links')->execute()->fetchAssoc();
+ }
+ } while ($parent === FALSE && $parent_path);
+ }
+
+ return $parent;
+}
+
+/**
+ * Helper function to clear the page and block caches at most twice per page load.
+ */
+function _menu_clear_page_cache() {
+ $cache_cleared = &drupal_static(__FUNCTION__, 0);
+
+ // Clear the page and block caches, but at most twice, including at
+ // the end of the page load when there are multiple links saved or deleted.
+ if ($cache_cleared == 0) {
+ cache_clear_all();
+ // Keep track of which menus have expanded items.
+ _menu_set_expanded_menus();
+ $cache_cleared = 1;
+ }
+ elseif ($cache_cleared == 1) {
+ drupal_register_shutdown_function('cache_clear_all');
+ // Keep track of which menus have expanded items.
+ drupal_register_shutdown_function('_menu_set_expanded_menus');
+ $cache_cleared = 2;
+ }
+}
+
+/**
+ * Helper function to update a list of menus with expanded items
+ */
+function _menu_set_expanded_menus() {
+ $names = db_query("SELECT menu_name FROM {menu_links} WHERE expanded <> 0 GROUP BY menu_name")->fetchCol();
+ variable_set('menu_expanded', $names);
+}
+
+/**
+ * Find the router path which will serve this path.
+ *
+ * @param $link_path
+ * The path for we are looking up its router path.
+ *
+ * @return
+ * A path from $menu keys or empty if $link_path points to a nonexisting
+ * place.
+ */
+function _menu_find_router_path($link_path) {
+ // $menu will only have data during a menu rebuild.
+ $menu = _menu_router_cache();
+
+ $router_path = $link_path;
+ $parts = explode('/', $link_path, MENU_MAX_PARTS);
+ $ancestors = menu_get_ancestors($parts);
+
+ if (empty($menu)) {
+ // Not during a menu rebuild, so look up in the database.
+ $router_path = (string) db_select('menu_router')
+ ->fields('menu_router', array('path'))
+ ->condition('path', $ancestors, 'IN')
+ ->orderBy('fit', 'DESC')
+ ->range(0, 1)
+ ->execute()->fetchField();
+ }
+ elseif (!isset($menu[$router_path])) {
+ // Add an empty router path as a fallback.
+ $ancestors[] = '';
+ foreach ($ancestors as $key => $router_path) {
+ if (isset($menu[$router_path])) {
+ // Exit the loop leaving $router_path as the first match.
+ break;
+ }
+ }
+ // If we did not find the path, $router_path will be the empty string
+ // at the end of $ancestors.
+ }
+ return $router_path;
+}
+
+/**
+ * Insert, update or delete an uncustomized menu link related to a module.
+ *
+ * @param $module
+ * The name of the module.
+ * @param $op
+ * Operation to perform: insert, update or delete.
+ * @param $link_path
+ * The path this link points to.
+ * @param $link_title
+ * Title of the link to insert or new title to update the link to.
+ * Unused for delete.
+ *
+ * @return
+ * The insert op returns the mlid of the new item. Others op return NULL.
+ */
+function menu_link_maintain($module, $op, $link_path, $link_title) {
+ switch ($op) {
+ case 'insert':
+ $menu_link = array(
+ 'link_title' => $link_title,
+ 'link_path' => $link_path,
+ 'module' => $module,
+ );
+ return menu_link_save($menu_link);
+ break;
+ case 'update':
+ $result = db_query("SELECT * FROM {menu_links} WHERE link_path = :link_path AND module = :module AND customized = 0", array(':link_path' => $link_path, ':module' => $module))->fetchAll(PDO::FETCH_ASSOC);
+ foreach ($result as $link) {
+ $link['link_title'] = $link_title;
+ $link['options'] = unserialize($link['options']);
+ menu_link_save($link);
+ }
+ break;
+ case 'delete':
+ menu_link_delete(NULL, $link_path);
+ break;
+ }
+}
+
+/**
+ * Find the depth of an item's children relative to its depth.
+ *
+ * For example, if the item has a depth of 2, and the maximum of any child in
+ * the menu link tree is 5, the relative depth is 3.
+ *
+ * @param $item
+ * An array representing a menu link item.
+ *
+ * @return
+ * The relative depth, or zero.
+ *
+ */
+function menu_link_children_relative_depth($item) {
+ $query = db_select('menu_links');
+ $query->addField('menu_links', 'depth');
+ $query->condition('menu_name', $item['menu_name']);
+ $query->orderBy('depth', 'DESC');
+ $query->range(0, 1);
+
+ $i = 1;
+ $p = 'p1';
+ while ($i <= MENU_MAX_DEPTH && $item[$p]) {
+ $query->condition($p, $item[$p]);
+ $p = 'p' . ++$i;
+ }
+
+ $max_depth = $query->execute()->fetchField();
+
+ return ($max_depth > $item['depth']) ? $max_depth - $item['depth'] : 0;
+}
+
+/**
+ * Update the children of a menu link that's being moved.
+ *
+ * The menu name, parents (p1 - p6), and depth are updated for all children of
+ * the link, and the has_children status of the previous parent is updated.
+ */
+function _menu_link_move_children($item, $existing_item) {
+ $query = db_update('menu_links');
+
+ $query->fields(array('menu_name' => $item['menu_name']));
+
+ $p = 'p1';
+ $expressions = array();
+ for ($i = 1; $i <= $item['depth']; $p = 'p' . ++$i) {
+ $expressions[] = array($p, ":p_$i", array(":p_$i" => $item[$p]));
+ }
+ $j = $existing_item['depth'] + 1;
+ while ($i <= MENU_MAX_DEPTH && $j <= MENU_MAX_DEPTH) {
+ $expressions[] = array('p' . $i++, 'p' . $j++, array());
+ }
+ while ($i <= MENU_MAX_DEPTH) {
+ $expressions[] = array('p' . $i++, 0, array());
+ }
+
+ $shift = $item['depth'] - $existing_item['depth'];
+ if ($shift > 0) {
+ // The order of expressions must be reversed so the new values don't
+ // overwrite the old ones before they can be used because "Single-table
+ // UPDATE assignments are generally evaluated from left to right"
+ // see: http://dev.mysql.com/doc/refman/5.0/en/update.html
+ $expressions = array_reverse($expressions);
+ }
+ foreach ($expressions as $expression) {
+ $query->expression($expression[0], $expression[1], $expression[2]);
+ }
+
+ $query->expression('depth', 'depth + :depth', array(':depth' => $shift));
+ $query->condition('menu_name', $existing_item['menu_name']);
+ $p = 'p1';
+ for ($i = 1; $i <= MENU_MAX_DEPTH && $existing_item[$p]; $p = 'p' . ++$i) {
+ $query->condition($p, $existing_item[$p]);
+ }
+
+ $query->execute();
+
+ // Check the has_children status of the parent, while excluding this item.
+ _menu_update_parental_status($existing_item, TRUE);
+}
+
+/**
+ * Check and update the has_children status for the parent of a link.
+ */
+function _menu_update_parental_status($item, $exclude = FALSE) {
+ // If plid == 0, there is nothing to update.
+ if ($item['plid']) {
+ // Check if at least one visible child exists in the table.
+ $query = db_select('menu_links');
+ $query->addField('menu_links', 'mlid');
+ $query->condition('menu_name', $item['menu_name']);
+ $query->condition('hidden', 0);
+ $query->condition('plid', $item['plid']);
+ $query->range(0, 1);
+ if ($exclude) {
+ $query->condition('mlid', $item['mlid'], '<>');
+ }
+ $parent_has_children = ((bool) $query->execute()->fetchField()) ? 1 : 0;
+ db_update('menu_links')
+ ->fields(array('has_children' => $parent_has_children))
+ ->condition('mlid', $item['plid'])
+ ->execute();
+ }
+}
+
+/**
+ * Helper function that sets the p1..p9 values for a menu link being saved.
+ */
+function _menu_link_parents_set(&$item, $parent) {
+ $i = 1;
+ while ($i < $item['depth']) {
+ $p = 'p' . $i++;
+ $item[$p] = $parent[$p];
+ }
+ $p = 'p' . $i++;
+ // The parent (p1 - p9) corresponding to the depth always equals the mlid.
+ $item[$p] = $item['mlid'];
+ while ($i <= MENU_MAX_DEPTH) {
+ $p = 'p' . $i++;
+ $item[$p] = 0;
+ }
+}
+
+/**
+ * Helper function to build the router table based on the data from hook_menu.
+ */
+function _menu_router_build($callbacks) {
+ // First pass: separate callbacks from paths, making paths ready for
+ // matching. Calculate fitness, and fill some default values.
+ $menu = array();
+ $masks = array();
+ foreach ($callbacks as $path => $item) {
+ $load_functions = array();
+ $to_arg_functions = array();
+ $fit = 0;
+ $move = FALSE;
+
+ $parts = explode('/', $path, MENU_MAX_PARTS);
+ $number_parts = count($parts);
+ // We store the highest index of parts here to save some work in the fit
+ // calculation loop.
+ $slashes = $number_parts - 1;
+ // Extract load and to_arg functions.
+ foreach ($parts as $k => $part) {
+ $match = FALSE;
+ // Look for wildcards in the form allowed to be used in PHP functions,
+ // because we are using these to construct the load function names.
+ if (preg_match('/^%(|' . DRUPAL_PHP_FUNCTION_PATTERN . ')$/', $part, $matches)) {
+ if (empty($matches[1])) {
+ $match = TRUE;
+ $load_functions[$k] = NULL;
+ }
+ else {
+ if (function_exists($matches[1] . '_to_arg')) {
+ $to_arg_functions[$k] = $matches[1] . '_to_arg';
+ $load_functions[$k] = NULL;
+ $match = TRUE;
+ }
+ if (function_exists($matches[1] . '_load')) {
+ $function = $matches[1] . '_load';
+ // Create an array of arguments that will be passed to the _load
+ // function when this menu path is checked, if 'load arguments'
+ // exists.
+ $load_functions[$k] = isset($item['load arguments']) ? array($function => $item['load arguments']) : $function;
+ $match = TRUE;
+ }
+ }
+ }
+ if ($match) {
+ $parts[$k] = '%';
+ }
+ else {
+ $fit |= 1 << ($slashes - $k);
+ }
+ }
+ if ($fit) {
+ $move = TRUE;
+ }
+ else {
+ // If there is no %, it fits maximally.
+ $fit = (1 << $number_parts) - 1;
+ }
+ $masks[$fit] = 1;
+ $item['_load_functions'] = $load_functions;
+ $item['to_arg_functions'] = empty($to_arg_functions) ? '' : serialize($to_arg_functions);
+ $item += array(
+ 'title' => '',
+ 'weight' => 0,
+ 'type' => MENU_NORMAL_ITEM,
+ 'module' => '',
+ '_number_parts' => $number_parts,
+ '_parts' => $parts,
+ '_fit' => $fit,
+ );
+ $item += array(
+ '_visible' => (bool) ($item['type'] & MENU_VISIBLE_IN_BREADCRUMB),
+ '_tab' => (bool) ($item['type'] & MENU_IS_LOCAL_TASK),
+ );
+ if ($move) {
+ $new_path = implode('/', $item['_parts']);
+ $menu[$new_path] = $item;
+ $sort[$new_path] = $number_parts;
+ }
+ else {
+ $menu[$path] = $item;
+ $sort[$path] = $number_parts;
+ }
+ }
+ array_multisort($sort, SORT_NUMERIC, $menu);
+ // Apply inheritance rules.
+ foreach ($menu as $path => $v) {
+ $item = &$menu[$path];
+ if (!$item['_tab']) {
+ // Non-tab items.
+ $item['tab_parent'] = '';
+ $item['tab_root'] = $path;
+ }
+ // If not specified, assign the default tab type for local tasks.
+ elseif (!isset($item['context'])) {
+ $item['context'] = MENU_CONTEXT_PAGE;
+ }
+ for ($i = $item['_number_parts'] - 1; $i; $i--) {
+ $parent_path = implode('/', array_slice($item['_parts'], 0, $i));
+ if (isset($menu[$parent_path])) {
+
+ $parent = &$menu[$parent_path];
+
+ // If we have no menu name, try to inherit it from parent items.
+ if (!isset($item['menu_name'])) {
+ // If the parent item of this item does not define a menu name (and no
+ // previous iteration assigned one already), try to find the menu name
+ // of the parent item in the currently stored menu links.
+ if (!isset($parent['menu_name'])) {
+ $menu_name = db_query("SELECT menu_name FROM {menu_links} WHERE router_path = :router_path AND module = 'system'", array(':router_path' => $parent_path))->fetchField();
+ if ($menu_name) {
+ $parent['menu_name'] = $menu_name;
+ }
+ }
+ // If the parent item defines a menu name, inherit it.
+ if (!empty($parent['menu_name'])) {
+ $item['menu_name'] = $parent['menu_name'];
+ }
+ }
+ if (!isset($item['tab_parent'])) {
+ // Parent stores the parent of the path.
+ $item['tab_parent'] = $parent_path;
+ }
+ if (!isset($item['tab_root']) && !$parent['_tab']) {
+ $item['tab_root'] = $parent_path;
+ }
+ // If an access callback is not found for a default local task we use
+ // the callback from the parent, since we expect them to be identical.
+ // In all other cases, the access parameters must be specified.
+ if (($item['type'] == MENU_DEFAULT_LOCAL_TASK) && !isset($item['access callback']) && isset($parent['access callback'])) {
+ $item['access callback'] = $parent['access callback'];
+ if (!isset($item['access arguments']) && isset($parent['access arguments'])) {
+ $item['access arguments'] = $parent['access arguments'];
+ }
+ }
+ // Same for page callbacks.
+ if (!isset($item['page callback']) && isset($parent['page callback'])) {
+ $item['page callback'] = $parent['page callback'];
+ if (!isset($item['page arguments']) && isset($parent['page arguments'])) {
+ $item['page arguments'] = $parent['page arguments'];
+ }
+ if (!isset($item['file path']) && isset($parent['file path'])) {
+ $item['file path'] = $parent['file path'];
+ }
+ if (!isset($item['file']) && isset($parent['file'])) {
+ $item['file'] = $parent['file'];
+ if (empty($item['file path']) && isset($item['module']) && isset($parent['module']) && $item['module'] != $parent['module']) {
+ $item['file path'] = drupal_get_path('module', $parent['module']);
+ }
+ }
+ }
+ // Same for delivery callbacks.
+ if (!isset($item['delivery callback']) && isset($parent['delivery callback'])) {
+ $item['delivery callback'] = $parent['delivery callback'];
+ }
+ // Same for theme callbacks.
+ if (!isset($item['theme callback']) && isset($parent['theme callback'])) {
+ $item['theme callback'] = $parent['theme callback'];
+ if (!isset($item['theme arguments']) && isset($parent['theme arguments'])) {
+ $item['theme arguments'] = $parent['theme arguments'];
+ }
+ }
+ // Same for load arguments: if a loader doesn't have any explict
+ // arguments, try to find arguments in the parent.
+ if (!isset($item['load arguments'])) {
+ foreach ($item['_load_functions'] as $k => $function) {
+ // This loader doesn't have any explict arguments...
+ if (!is_array($function)) {
+ // ... check the parent for a loader at the same position
+ // using the same function name and defining arguments...
+ if (isset($parent['_load_functions'][$k]) && is_array($parent['_load_functions'][$k]) && key($parent['_load_functions'][$k]) === $function) {
+ // ... and inherit the arguments on the child.
+ $item['_load_functions'][$k] = $parent['_load_functions'][$k];
+ }
+ }
+ }
+ }
+ }
+ }
+ if (!isset($item['access callback']) && isset($item['access arguments'])) {
+ // Default callback.
+ $item['access callback'] = 'user_access';
+ }
+ if (!isset($item['access callback']) || empty($item['page callback'])) {
+ $item['access callback'] = 0;
+ }
+ if (is_bool($item['access callback'])) {
+ $item['access callback'] = intval($item['access callback']);
+ }
+
+ $item['load_functions'] = empty($item['_load_functions']) ? '' : serialize($item['_load_functions']);
+ $item += array(
+ 'access arguments' => array(),
+ 'access callback' => '',
+ 'page arguments' => array(),
+ 'page callback' => '',
+ 'delivery callback' => '',
+ 'title arguments' => array(),
+ 'title callback' => 't',
+ 'theme arguments' => array(),
+ 'theme callback' => '',
+ 'description' => '',
+ 'position' => '',
+ 'context' => 0,
+ 'tab_parent' => '',
+ 'tab_root' => $path,
+ 'path' => $path,
+ 'file' => '',
+ 'file path' => '',
+ 'include file' => '',
+ );
+
+ // Calculate out the file to be included for each callback, if any.
+ if ($item['file']) {
+ $file_path = $item['file path'] ? $item['file path'] : drupal_get_path('module', $item['module']);
+ $item['include file'] = $file_path . '/' . $item['file'];
+ }
+ }
+
+ // Sort the masks so they are in order of descending fit.
+ $masks = array_keys($masks);
+ rsort($masks);
+
+ return array($menu, $masks);
+}
+
+/**
+ * Helper function to save data from menu_router_build() to the router table.
+ */
+function _menu_router_save($menu, $masks) {
+ // Delete the existing router since we have some data to replace it.
+ db_truncate('menu_router')->execute();
+
+ // Prepare insert object.
+ $insert = db_insert('menu_router')
+ ->fields(array(
+ 'path',
+ 'load_functions',
+ 'to_arg_functions',
+ 'access_callback',
+ 'access_arguments',
+ 'page_callback',
+ 'page_arguments',
+ 'delivery_callback',
+ 'fit',
+ 'number_parts',
+ 'context',
+ 'tab_parent',
+ 'tab_root',
+ 'title',
+ 'title_callback',
+ 'title_arguments',
+ 'theme_callback',
+ 'theme_arguments',
+ 'type',
+ 'description',
+ 'position',
+ 'weight',
+ 'include_file',
+ ));
+
+ $num_records = 0;
+
+ foreach ($menu as $path => $item) {
+ // Fill in insert object values.
+ $insert->values(array(
+ 'path' => $item['path'],
+ 'load_functions' => $item['load_functions'],
+ 'to_arg_functions' => $item['to_arg_functions'],
+ 'access_callback' => $item['access callback'],
+ 'access_arguments' => serialize($item['access arguments']),
+ 'page_callback' => $item['page callback'],
+ 'page_arguments' => serialize($item['page arguments']),
+ 'delivery_callback' => $item['delivery callback'],
+ 'fit' => $item['_fit'],
+ 'number_parts' => $item['_number_parts'],
+ 'context' => $item['context'],
+ 'tab_parent' => $item['tab_parent'],
+ 'tab_root' => $item['tab_root'],
+ 'title' => $item['title'],
+ 'title_callback' => $item['title callback'],
+ 'title_arguments' => ($item['title arguments'] ? serialize($item['title arguments']) : ''),
+ 'theme_callback' => $item['theme callback'],
+ 'theme_arguments' => serialize($item['theme arguments']),
+ 'type' => $item['type'],
+ 'description' => $item['description'],
+ 'position' => $item['position'],
+ 'weight' => $item['weight'],
+ 'include_file' => $item['include file'],
+ ));
+
+ // Execute in batches to avoid the memory overhead of all of those records
+ // in the query object.
+ if (++$num_records == 20) {
+ $insert->execute();
+ $num_records = 0;
+ }
+ }
+ // Insert any remaining records.
+ $insert->execute();
+ // Store the masks.
+ variable_set('menu_masks', $masks);
+
+ return $menu;
+}
+
+/**
+ * Checks whether the site is in maintenance mode.
+ *
+ * This function will log the current user out and redirect to front page
+ * if the current user has no 'access site in maintenance mode' permission.
+ *
+ * @param $check_only
+ * If this is set to TRUE, the function will perform the access checks and
+ * return the site offline status, but not log the user out or display any
+ * messages.
+ *
+ * @return
+ * FALSE if the site is not in maintenance mode, the user login page is
+ * displayed, or the user has the 'access site in maintenance mode'
+ * permission. TRUE for anonymous users not being on the login page when the
+ * site is in maintenance mode.
+ */
+function _menu_site_is_offline($check_only = FALSE) {
+ // Check if site is in maintenance mode.
+ if (variable_get('maintenance_mode', 0)) {
+ if (user_access('access site in maintenance mode')) {
+ // Ensure that the maintenance mode message is displayed only once
+ // (allowing for page redirects) and specifically suppress its display on
+ // the maintenance mode settings page.
+ if (!$check_only && $_GET['q'] != 'admin/config/development/maintenance') {
+ if (user_access('administer site configuration')) {
+ drupal_set_message(t('Operating in maintenance mode. <a href="@url">Go online.</a>', array('@url' => url('admin/config/development/maintenance'))), 'status', FALSE);
+ }
+ else {
+ drupal_set_message(t('Operating in maintenance mode.'), 'status', FALSE);
+ }
+ }
+ }
+ else {
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * @} End of "defgroup menu".
+ */
diff --git a/core/includes/module.inc b/core/includes/module.inc
new file mode 100644
index 000000000000..cca2ef935658
--- /dev/null
+++ b/core/includes/module.inc
@@ -0,0 +1,1007 @@
+<?php
+
+/**
+ * @file
+ * API for loading and interacting with Drupal modules.
+ */
+
+/**
+ * Load all the modules that have been enabled in the system table.
+ *
+ * @param $bootstrap
+ * Whether to load only the reduced set of modules loaded in "bootstrap mode"
+ * for cached pages. See bootstrap.inc.
+ *
+ * @return
+ * If $bootstrap is NULL, return a boolean indicating whether all modules
+ * have been loaded.
+ */
+function module_load_all($bootstrap = FALSE) {
+ static $has_run = FALSE;
+
+ if (isset($bootstrap)) {
+ foreach (module_list(TRUE, $bootstrap) as $module) {
+ drupal_load('module', $module);
+ }
+ // $has_run will be TRUE if $bootstrap is FALSE.
+ $has_run = !$bootstrap;
+ }
+ return $has_run;
+}
+
+
+/**
+ * Returns a list of currently active modules.
+ *
+ * Usually, this returns a list of all enabled modules. When called early on in
+ * the bootstrap, it will return a list of vital modules only (those needed to
+ * generate cached pages).
+ *
+ * All parameters to this function are optional and should generally not be
+ * changed from their defaults.
+ *
+ * @param $refresh
+ * (optional) Whether to force the module list to be regenerated (such as
+ * after the administrator has changed the system settings). Defaults to
+ * FALSE.
+ * @param $bootstrap_refresh
+ * (optional) When $refresh is TRUE, setting $bootstrap_refresh to TRUE forces
+ * the module list to be regenerated using the reduced set of modules loaded
+ * in "bootstrap mode" for cached pages. Otherwise, setting $refresh to TRUE
+ * generates the complete list of enabled modules.
+ * @param $sort
+ * (optional) By default, modules are ordered by weight and module name. Set
+ * this option to TRUE to return a module list ordered only by module name.
+ * @param $fixed_list
+ * (optional) If an array of module names is provided, this will override the
+ * module list with the given set of modules. This will persist until the next
+ * call with $refresh set to TRUE or with a new $fixed_list passed in. This
+ * parameter is primarily intended for internal use (e.g., in install.php and
+ * update.php).
+ *
+ * @return
+ * An associative array whose keys and values are the names of the modules in
+ * the list.
+ */
+function module_list($refresh = FALSE, $bootstrap_refresh = FALSE, $sort = FALSE, $fixed_list = NULL) {
+ static $list = array(), $sorted_list;
+
+ if (empty($list) || $refresh || $fixed_list) {
+ $list = array();
+ $sorted_list = NULL;
+ if ($fixed_list) {
+ foreach ($fixed_list as $name => $module) {
+ drupal_get_filename('module', $name, $module['filename']);
+ $list[$name] = $name;
+ }
+ }
+ else {
+ if ($refresh) {
+ // For the $refresh case, make sure that system_list() returns fresh
+ // data.
+ drupal_static_reset('system_list');
+ }
+ if ($bootstrap_refresh) {
+ $list = system_list('bootstrap');
+ }
+ else {
+ // Not using drupal_map_assoc() here as that requires common.inc.
+ $list = array_keys(system_list('module_enabled'));
+ $list = (!empty($list) ? array_combine($list, $list) : array());
+ }
+ }
+ }
+ if ($sort) {
+ if (!isset($sorted_list)) {
+ $sorted_list = $list;
+ ksort($sorted_list);
+ }
+ return $sorted_list;
+ }
+ return $list;
+}
+
+/**
+ * Build a list of bootstrap modules and enabled modules and themes.
+ *
+ * @param $type
+ * The type of list to return:
+ * - module_enabled: All enabled modules.
+ * - bootstrap: All enabled modules required for bootstrap.
+ * - theme: All themes.
+ *
+ * @return
+ * An associative array of modules or themes, keyed by name. For $type
+ * 'bootstrap', the array values equal the keys. For $type 'module_enabled'
+ * or 'theme', the array values are objects representing the respective
+ * database row, with the 'info' property already unserialized.
+ *
+ * @see module_list()
+ * @see list_themes()
+ */
+function system_list($type) {
+ $lists = &drupal_static(__FUNCTION__);
+
+ // For bootstrap modules, attempt to fetch the list from cache if possible.
+ // if not fetch only the required information to fire bootstrap hooks
+ // in case we are going to serve the page from cache.
+ if ($type == 'bootstrap') {
+ if (isset($lists['bootstrap'])) {
+ return $lists['bootstrap'];
+ }
+ if ($cached = cache('bootstrap')->get('bootstrap_modules')) {
+ $bootstrap_list = $cached->data;
+ }
+ else {
+ $bootstrap_list = db_query("SELECT name, filename FROM {system} WHERE status = 1 AND bootstrap = 1 AND type = 'module' ORDER BY weight ASC, name ASC")->fetchAllAssoc('name');
+ cache('bootstrap')->set('bootstrap_modules', $bootstrap_list);
+ }
+ // To avoid a separate database lookup for the filepath, prime the
+ // drupal_get_filename() static cache for bootstrap modules only.
+ // The rest is stored separately to keep the bootstrap module cache small.
+ foreach ($bootstrap_list as $module) {
+ drupal_get_filename('module', $module->name, $module->filename);
+ }
+ // We only return the module names here since module_list() doesn't need
+ // the filename itself.
+ $lists['bootstrap'] = array_keys($bootstrap_list);
+ }
+ // Otherwise build the list for enabled modules and themes.
+ elseif (!isset($lists['module_enabled'])) {
+ if ($cached = cache('bootstrap')->get('system_list')) {
+ $lists = $cached->data;
+ }
+ else {
+ $lists = array(
+ 'module_enabled' => array(),
+ 'theme' => array(),
+ 'filepaths' => array(),
+ );
+ // The module name (rather than the filename) is used as the fallback
+ // weighting in order to guarantee consistent behavior across different
+ // Drupal installations, which might have modules installed in different
+ // locations in the file system. The ordering here must also be
+ // consistent with the one used in module_implements().
+ $result = db_query("SELECT * FROM {system} WHERE type = 'theme' OR (type = 'module' AND status = 1) ORDER BY weight ASC, name ASC");
+ foreach ($result as $record) {
+ $record->info = unserialize($record->info);
+ // Build a list of all enabled modules.
+ if ($record->type == 'module') {
+ $lists['module_enabled'][$record->name] = $record;
+ }
+ // Build a list of themes.
+ if ($record->type == 'theme') {
+ $lists['theme'][$record->name] = $record;
+ }
+ // Build a list of filenames so drupal_get_filename can use it.
+ if ($record->status) {
+ $lists['filepaths'][] = array('type' => $record->type, 'name' => $record->name, 'filepath' => $record->filename);
+ }
+ }
+ cache('bootstrap')->set('system_list', $lists);
+ }
+ // To avoid a separate database lookup for the filepath, prime the
+ // drupal_get_filename() static cache with all enabled modules and themes.
+ foreach ($lists['filepaths'] as $item) {
+ drupal_get_filename($item['type'], $item['name'], $item['filepath']);
+ }
+ }
+
+ return $lists[$type];
+}
+
+/**
+ * Reset all system_list() caches.
+ */
+function system_list_reset() {
+ drupal_static_reset('system_list');
+ drupal_static_reset('system_rebuild_module_data');
+ drupal_static_reset('list_themes');
+ cache('bootstrap')->deleteMultiple(array('bootstrap_modules', 'system_list'));
+}
+
+/**
+ * Find dependencies any level deep and fill in required by information too.
+ *
+ * @param $files
+ * The array of filesystem objects used to rebuild the cache.
+ *
+ * @return
+ * The same array with the new keys for each module:
+ * - requires: An array with the keys being the modules that this module
+ * requires.
+ * - required_by: An array with the keys being the modules that will not work
+ * without this module.
+ */
+function _module_build_dependencies($files) {
+ require_once DRUPAL_ROOT . '/core/includes/graph.inc';
+ foreach ($files as $filename => $file) {
+ $graph[$file->name]['edges'] = array();
+ if (isset($file->info['dependencies']) && is_array($file->info['dependencies'])) {
+ foreach ($file->info['dependencies'] as $dependency) {
+ $dependency_data = drupal_parse_dependency($dependency);
+ $graph[$file->name]['edges'][$dependency_data['name']] = $dependency_data;
+ }
+ }
+ }
+ drupal_depth_first_search($graph);
+ foreach ($graph as $module => $data) {
+ $files[$module]->required_by = isset($data['reverse_paths']) ? $data['reverse_paths'] : array();
+ $files[$module]->requires = isset($data['paths']) ? $data['paths'] : array();
+ $files[$module]->sort = $data['weight'];
+ }
+ return $files;
+}
+
+/**
+ * Determine whether a given module exists.
+ *
+ * @param $module
+ * The name of the module (without the .module extension).
+ *
+ * @return
+ * TRUE if the module is both installed and enabled.
+ */
+function module_exists($module) {
+ $list = module_list();
+ return isset($list[$module]);
+}
+
+/**
+ * Load a module's installation hooks.
+ *
+ * @param $module
+ * The name of the module (without the .module extension).
+ *
+ * @return
+ * The name of the module's install file, if successful; FALSE otherwise.
+ */
+function module_load_install($module) {
+ // Make sure the installation API is available
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+
+ return module_load_include('install', $module);
+}
+
+/**
+ * Load a module include file.
+ *
+ * Examples:
+ * @code
+ * // Load node.admin.inc from the node module.
+ * module_load_include('inc', 'node', 'node.admin');
+ * // Load content_types.inc from the node module.
+ * module_load_include('inc', 'node', 'content_types');
+ * @endcode
+ *
+ * Do not use this function to load an install file, use module_load_install()
+ * instead. Do not use this function in a global context since it requires
+ * Drupal to be fully bootstrapped, use require_once DRUPAL_ROOT . '/path/file'
+ * instead.
+ *
+ * @param $type
+ * The include file's type (file extension).
+ * @param $module
+ * The module to which the include file belongs.
+ * @param $name
+ * (optional) The base file name (without the $type extension). If omitted,
+ * $module is used; i.e., resulting in "$module.$type" by default.
+ *
+ * @return
+ * The name of the included file, if successful; FALSE otherwise.
+ */
+function module_load_include($type, $module, $name = NULL) {
+ if (!isset($name)) {
+ $name = $module;
+ }
+
+ if (function_exists('drupal_get_path')) {
+ $file = DRUPAL_ROOT . '/' . drupal_get_path('module', $module) . "/$name.$type";
+ if (is_file($file)) {
+ require_once $file;
+ return $file;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Load an include file for each of the modules that have been enabled in
+ * the system table.
+ */
+function module_load_all_includes($type, $name = NULL) {
+ $modules = module_list();
+ foreach ($modules as $module) {
+ module_load_include($type, $module, $name);
+ }
+}
+
+/**
+ * Enables or installs a given list of modules.
+ *
+ * Definitions:
+ * - "Enabling" is the process of activating a module for use by Drupal.
+ * - "Disabling" is the process of deactivating a module.
+ * - "Installing" is the process of enabling it for the first time or after it
+ * has been uninstalled.
+ * - "Uninstalling" is the process of removing all traces of a module.
+ *
+ * Order of events:
+ * - Gather and add module dependencies to $module_list (if applicable).
+ * - For each module that is being enabled:
+ * - Install module schema and update system registries and caches.
+ * - If the module is being enabled for the first time or had been
+ * uninstalled, invoke hook_install() and add it to the list of installed
+ * modules.
+ * - Invoke hook_enable().
+ * - Invoke hook_modules_installed().
+ * - Invoke hook_modules_enabled().
+ *
+ * @param $module_list
+ * An array of module names.
+ * @param $enable_dependencies
+ * If TRUE, dependencies will automatically be added and enabled in the
+ * correct order. This incurs a significant performance cost, so use FALSE
+ * if you know $module_list is already complete and in the correct order.
+ *
+ * @return
+ * FALSE if one or more dependencies are missing, TRUE otherwise.
+ *
+ * @see hook_install()
+ * @see hook_enable()
+ * @see hook_modules_installed()
+ * @see hook_modules_enabled()
+ */
+function module_enable($module_list, $enable_dependencies = TRUE) {
+ if ($enable_dependencies) {
+ // Get all module data so we can find dependencies and sort.
+ $module_data = system_rebuild_module_data();
+ // Create an associative array with weights as values.
+ $module_list = array_flip(array_values($module_list));
+
+ while (list($module) = each($module_list)) {
+ if (!isset($module_data[$module])) {
+ // This module is not found in the filesystem, abort.
+ return FALSE;
+ }
+ if ($module_data[$module]->status) {
+ // Skip already enabled modules.
+ unset($module_list[$module]);
+ continue;
+ }
+ $module_list[$module] = $module_data[$module]->sort;
+
+ // Add dependencies to the list, with a placeholder weight.
+ // The new modules will be processed as the while loop continues.
+ foreach (array_keys($module_data[$module]->requires) as $dependency) {
+ if (!isset($module_list[$dependency])) {
+ $module_list[$dependency] = 0;
+ }
+ }
+ }
+
+ if (!$module_list) {
+ // Nothing to do. All modules already enabled.
+ return TRUE;
+ }
+
+ // Sort the module list by pre-calculated weights.
+ arsort($module_list);
+ $module_list = array_keys($module_list);
+ }
+
+ // Required for module installation checks.
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+
+ $modules_installed = array();
+ $modules_enabled = array();
+ foreach ($module_list as $module) {
+ // Only process modules that are not already enabled.
+ $existing = db_query("SELECT status FROM {system} WHERE type = :type AND name = :name", array(
+ ':type' => 'module',
+ ':name' => $module))
+ ->fetchObject();
+ if ($existing->status == 0) {
+ // Load the module's code.
+ drupal_load('module', $module);
+ module_load_install($module);
+
+ // Update the database and module list to reflect the new module. This
+ // needs to be done first so that the module's hook implementations,
+ // hook_schema() in particular, can be called while it is being
+ // installed.
+ db_update('system')
+ ->fields(array('status' => 1))
+ ->condition('type', 'module')
+ ->condition('name', $module)
+ ->execute();
+ // Refresh the module list to include it.
+ system_list_reset();
+ module_list(TRUE);
+ module_implements_reset();
+ _system_update_bootstrap_status();
+ // Update the registry to include it.
+ registry_update();
+ // Refresh the schema to include it.
+ drupal_get_schema(NULL, TRUE);
+
+ // Allow modules to react prior to the installation of a module.
+ module_invoke_all('modules_preinstall', array($module));
+
+ // Now install the module if necessary.
+ if (drupal_get_installed_schema_version($module, TRUE) == SCHEMA_UNINSTALLED) {
+ drupal_install_schema($module);
+
+ // Set the schema version to the number of the last update provided
+ // by the module.
+ $versions = drupal_get_schema_versions($module);
+ $version = $versions ? max($versions) : SCHEMA_INSTALLED;
+
+ // If the module has no current updates, but has some that were
+ // previously removed, set the version to the value of
+ // hook_update_last_removed().
+ if ($last_removed = module_invoke($module, 'update_last_removed')) {
+ $version = max($version, $last_removed);
+ }
+ drupal_set_installed_schema_version($module, $version);
+ // Allow the module to perform install tasks.
+ module_invoke($module, 'install');
+ // Record the fact that it was installed.
+ $modules_installed[] = $module;
+ watchdog('system', '%module module installed.', array('%module' => $module), WATCHDOG_INFO);
+ }
+
+ // Allow modules to react prior to the enabling of a module.
+ module_invoke_all('modules_preenable', array($module));
+
+ // Enable the module.
+ module_invoke($module, 'enable');
+
+ // Record the fact that it was enabled.
+ $modules_enabled[] = $module;
+ watchdog('system', '%module module enabled.', array('%module' => $module), WATCHDOG_INFO);
+ }
+ }
+
+ // If any modules were newly installed, invoke hook_modules_installed().
+ if (!empty($modules_installed)) {
+ module_invoke_all('modules_installed', $modules_installed);
+ }
+
+ // If any modules were newly enabled, invoke hook_modules_enabled().
+ if (!empty($modules_enabled)) {
+ module_invoke_all('modules_enabled', $modules_enabled);
+ }
+
+ return TRUE;
+}
+
+/**
+ * Disable a given set of modules.
+ *
+ * @param $module_list
+ * An array of module names.
+ * @param $disable_dependents
+ * If TRUE, dependent modules will automatically be added and disabled in the
+ * correct order. This incurs a significant performance cost, so use FALSE
+ * if you know $module_list is already complete and in the correct order.
+ */
+function module_disable($module_list, $disable_dependents = TRUE) {
+ if ($disable_dependents) {
+ // Get all module data so we can find dependents and sort.
+ $module_data = system_rebuild_module_data();
+ // Create an associative array with weights as values.
+ $module_list = array_flip(array_values($module_list));
+
+ $profile = drupal_get_profile();
+ while (list($module) = each($module_list)) {
+ if (!isset($module_data[$module]) || !$module_data[$module]->status) {
+ // This module doesn't exist or is already disabled, skip it.
+ unset($module_list[$module]);
+ continue;
+ }
+ $module_list[$module] = $module_data[$module]->sort;
+
+ // Add dependent modules to the list, with a placeholder weight.
+ // The new modules will be processed as the while loop continues.
+ foreach ($module_data[$module]->required_by as $dependent => $dependent_data) {
+ if (!isset($module_list[$dependent]) && $dependent != $profile) {
+ $module_list[$dependent] = 0;
+ }
+ }
+ }
+
+ // Sort the module list by pre-calculated weights.
+ asort($module_list);
+ $module_list = array_keys($module_list);
+ }
+
+ $invoke_modules = array();
+
+ foreach ($module_list as $module) {
+ if (module_exists($module)) {
+ // Check if node_access table needs rebuilding.
+ if (!node_access_needs_rebuild() && module_hook($module, 'node_grants')) {
+ node_access_needs_rebuild(TRUE);
+ }
+
+ module_load_install($module);
+ module_invoke($module, 'disable');
+ db_update('system')
+ ->fields(array('status' => 0))
+ ->condition('type', 'module')
+ ->condition('name', $module)
+ ->execute();
+ $invoke_modules[] = $module;
+ watchdog('system', '%module module disabled.', array('%module' => $module), WATCHDOG_INFO);
+ }
+ }
+
+ if (!empty($invoke_modules)) {
+ // Refresh the module list to exclude the disabled modules.
+ system_list_reset();
+ module_list(TRUE);
+ module_implements_reset();
+ // Invoke hook_modules_disabled before disabling modules,
+ // so we can still call module hooks to get information.
+ module_invoke_all('modules_disabled', $invoke_modules);
+ // Update the registry to remove the newly-disabled module.
+ registry_update();
+ _system_update_bootstrap_status();
+ }
+
+ // If there remains no more node_access module, rebuilding will be
+ // straightforward, we can do it right now.
+ if (node_access_needs_rebuild() && count(module_implements('node_grants')) == 0) {
+ node_access_rebuild();
+ }
+}
+
+/**
+ * @defgroup hooks Hooks
+ * @{
+ * Allow modules to interact with the Drupal core.
+ *
+ * Drupal's module system is based on the concept of "hooks". A hook is a PHP
+ * function that is named foo_bar(), where "foo" is the name of the module
+ * (whose filename is thus foo.module) and "bar" is the name of the hook. Each
+ * hook has a defined set of parameters and a specified result type.
+ *
+ * To extend Drupal, a module need simply implement a hook. When Drupal wishes
+ * to allow intervention from modules, it determines which modules implement a
+ * hook and calls that hook in all enabled modules that implement it.
+ *
+ * The available hooks to implement are explained here in the Hooks section of
+ * the developer documentation. The string "hook" is used as a placeholder for
+ * the module name in the hook definitions. For example, if the module file is
+ * called example.module, then hook_help() as implemented by that module would
+ * be defined as example_help().
+ *
+ * The example functions included are not part of the Drupal core, they are
+ * just models that you can modify. Only the hooks implemented within modules
+ * are executed when running Drupal.
+ *
+ * See also @link themeable the themeable group page. @endlink
+ */
+
+/**
+ * Determine whether a module implements a hook.
+ *
+ * @param $module
+ * The name of the module (without the .module extension).
+ * @param $hook
+ * The name of the hook (e.g. "help" or "menu").
+ *
+ * @return
+ * TRUE if the module is both installed and enabled, and the hook is
+ * implemented in that module.
+ */
+function module_hook($module, $hook) {
+ $function = $module . '_' . $hook;
+ if (function_exists($function)) {
+ return TRUE;
+ }
+ // If the hook implementation does not exist, check whether it may live in an
+ // optional include file registered via hook_hook_info().
+ $hook_info = module_hook_info();
+ if (isset($hook_info[$hook]['group'])) {
+ module_load_include('inc', $module, $module . '.' . $hook_info[$hook]['group']);
+ if (function_exists($function)) {
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Determine which modules are implementing a hook.
+ *
+ * @param $hook
+ * The name of the hook (e.g. "help" or "menu").
+ * @param $sort
+ * By default, modules are ordered by weight and filename, settings this option
+ * to TRUE, module list will be ordered by module name.
+ *
+ * @return
+ * An array with the names of the modules which are implementing this hook.
+ *
+ * @see module_implements_write_cache()
+ */
+function module_implements($hook, $sort = FALSE) {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['implementations'] = &drupal_static(__FUNCTION__);
+ }
+ $implementations = &$drupal_static_fast['implementations'];
+
+ // Fetch implementations from cache.
+ if (empty($implementations)) {
+ $implementations = cache('bootstrap')->get('module_implements');
+ if ($implementations === FALSE) {
+ $implementations = array();
+ }
+ else {
+ $implementations = $implementations->data;
+ }
+ }
+
+ if (!isset($implementations[$hook])) {
+ // The hook is not cached, so ensure that whether or not it has
+ // implementations, that the cache is updated at the end of the request.
+ $implementations['#write_cache'] = TRUE;
+ $hook_info = module_hook_info();
+ $implementations[$hook] = array();
+ $list = module_list(FALSE, FALSE, $sort);
+ foreach ($list as $module) {
+ $include_file = isset($hook_info[$hook]['group']) && module_load_include('inc', $module, $module . '.' . $hook_info[$hook]['group']);
+ // Since module_hook() may needlessly try to load the include file again,
+ // function_exists() is used directly here.
+ if (function_exists($module . '_' . $hook)) {
+ $implementations[$hook][$module] = $include_file ? $hook_info[$hook]['group'] : FALSE;
+ }
+ }
+ // Allow modules to change the weight of specific implementations but avoid
+ // an infinite loop.
+ if ($hook != 'module_implements_alter') {
+ drupal_alter('module_implements', $implementations[$hook], $hook);
+ }
+ }
+ else {
+ foreach ($implementations[$hook] as $module => $group) {
+ // If this hook implementation is stored in a lazy-loaded file, so include
+ // that file first.
+ if ($group) {
+ module_load_include('inc', $module, "$module.$group");
+ }
+ // It is possible that a module removed a hook implementation without the
+ // implementations cache being rebuilt yet, so we check whether the
+ // function exists on each request to avoid undefined function errors.
+ // Since module_hook() may needlessly try to load the include file again,
+ // function_exists() is used directly here.
+ if (!function_exists($module . '_' . $hook)) {
+ // Clear out the stale implementation from the cache and force a cache
+ // refresh to forget about no longer existing hook implementations.
+ unset($implementations[$hook][$module]);
+ $implementations['#write_cache'] = TRUE;
+ }
+ }
+ }
+
+ return array_keys($implementations[$hook]);
+}
+
+/**
+ * Regenerate the stored list of hook implementations.
+ */
+function module_implements_reset() {
+ // We maintain a persistent cache of hook implementations in addition to the
+ // static cache to avoid looping through every module and every hook on each
+ // request. Benchmarks show that the benefit of this caching outweighs the
+ // additional database hit even when using the default database caching
+ // backend and only a small number of modules are enabled. The cost of the
+ // cache_get() is more or less constant and reduced further when non-database
+ // caching backends are used, so there will be more significant gains when a
+ // large number of modules are installed or hooks invoked, since this can
+ // quickly lead to module_hook() being called several thousand times
+ // per request.
+ drupal_static_reset('module_implements');
+ cache('bootstrap')->set('module_implements', array());
+ drupal_static_reset('module_hook_info');
+ drupal_static_reset('drupal_alter');
+ cache('bootstrap')->delete('hook_info');
+}
+
+/**
+ * Retrieve a list of what hooks are explicitly declared.
+ */
+function module_hook_info() {
+ // This function is indirectly invoked from bootstrap_invoke_all(), in which
+ // case common.inc, subsystems, and modules are not loaded yet, so it does not
+ // make sense to support hook groups resp. lazy-loaded include files prior to
+ // full bootstrap.
+ if (drupal_bootstrap(NULL, FALSE) != DRUPAL_BOOTSTRAP_FULL) {
+ return array();
+ }
+ $hook_info = &drupal_static(__FUNCTION__);
+
+ if (!isset($hook_info)) {
+ $hook_info = array();
+ $cache = cache('bootstrap')->get('hook_info');
+ if ($cache === FALSE) {
+ // Rebuild the cache and save it.
+ // We can't use module_invoke_all() here or it would cause an infinite
+ // loop.
+ foreach (module_list() as $module) {
+ $function = $module . '_hook_info';
+ if (function_exists($function)) {
+ $result = $function();
+ if (isset($result) && is_array($result)) {
+ $hook_info = array_merge_recursive($hook_info, $result);
+ }
+ }
+ }
+ // We can't use drupal_alter() for the same reason as above.
+ foreach (module_list() as $module) {
+ $function = $module . '_hook_info_alter';
+ if (function_exists($function)) {
+ $function($hook_info);
+ }
+ }
+ cache('bootstrap')->set('hook_info', $hook_info);
+ }
+ else {
+ $hook_info = $cache->data;
+ }
+ }
+
+ return $hook_info;
+}
+
+/**
+ * Writes the hook implementation cache.
+ *
+ * @see module_implements()
+ */
+function module_implements_write_cache() {
+ $implementations = &drupal_static('module_implements');
+ // Check whether we need to write the cache. We do not want to cache hooks
+ // which are only invoked on HTTP POST requests since these do not need to be
+ // optimized as tightly, and not doing so keeps the cache entry smaller.
+ if (isset($implementations['#write_cache']) && ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD')) {
+ unset($implementations['#write_cache']);
+ cache('bootstrap')->set('module_implements', $implementations);
+ }
+}
+
+/**
+ * Invoke a hook in a particular module.
+ *
+ * @param $module
+ * The name of the module (without the .module extension).
+ * @param $hook
+ * The name of the hook to invoke.
+ * @param ...
+ * Arguments to pass to the hook implementation.
+ *
+ * @return
+ * The return value of the hook implementation.
+ */
+function module_invoke($module, $hook) {
+ $args = func_get_args();
+ // Remove $module and $hook from the arguments.
+ unset($args[0], $args[1]);
+ if (module_hook($module, $hook)) {
+ return call_user_func_array($module . '_' . $hook, $args);
+ }
+}
+
+/**
+ * Invoke a hook in all enabled modules that implement it.
+ *
+ * @param $hook
+ * The name of the hook to invoke.
+ * @param ...
+ * Arguments to pass to the hook.
+ *
+ * @return
+ * An array of return values of the hook implementations. If modules return
+ * arrays from their implementations, those are merged into one array.
+ */
+function module_invoke_all($hook) {
+ $args = func_get_args();
+ // Remove $hook from the arguments.
+ unset($args[0]);
+ $return = array();
+ foreach (module_implements($hook) as $module) {
+ $function = $module . '_' . $hook;
+ if (function_exists($function)) {
+ $result = call_user_func_array($function, $args);
+ if (isset($result) && is_array($result)) {
+ $return = array_merge_recursive($return, $result);
+ }
+ elseif (isset($result)) {
+ $return[] = $result;
+ }
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * @} End of "defgroup hooks".
+ */
+
+/**
+ * Array of modules required by core.
+ */
+function drupal_required_modules() {
+ $files = drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.info$/', 'modules', 'name', 0);
+ $required = array();
+
+ // An install profile is required and one must always be loaded.
+ $required[] = drupal_get_profile();
+
+ foreach ($files as $name => $file) {
+ $info = drupal_parse_info_file($file->uri);
+ if (!empty($info) && !empty($info['required']) && $info['required']) {
+ $required[] = $name;
+ }
+ }
+
+ return $required;
+}
+
+/**
+ * Hands off alterable variables to type-specific *_alter implementations.
+ *
+ * This dispatch function hands off the passed-in variables to type-specific
+ * hook_TYPE_alter() implementations in modules. It ensures a consistent
+ * interface for all altering operations.
+ *
+ * A maximum of 2 alterable arguments is supported. In case more arguments need
+ * to be passed and alterable, modules provide additional variables assigned by
+ * reference in the last $context argument:
+ * @code
+ * $context = array(
+ * 'alterable' => &$alterable,
+ * 'unalterable' => $unalterable,
+ * 'foo' => 'bar',
+ * );
+ * drupal_alter('mymodule_data', $alterable1, $alterable2, $context);
+ * @endcode
+ *
+ * Note that objects are always passed by reference in PHP5. If it is absolutely
+ * required that no implementation alters a passed object in $context, then an
+ * object needs to be cloned:
+ * @code
+ * $context = array(
+ * 'unalterable_object' => clone $object,
+ * );
+ * drupal_alter('mymodule_data', $data, $context);
+ * @endcode
+ *
+ * @param $type
+ * A string describing the type of the alterable $data. 'form', 'links',
+ * 'node_content', and so on are several examples. Alternatively can be an
+ * array, in which case hook_TYPE_alter() is invoked for each value in the
+ * array, ordered first by module, and then for each module, in the order of
+ * values in $type. For example, when Form API is using drupal_alter() to
+ * execute both hook_form_alter() and hook_form_FORM_ID_alter()
+ * implementations, it passes array('form', 'form_' . $form_id) for $type.
+ * @param $data
+ * The variable that will be passed to hook_TYPE_alter() implementations to be
+ * altered. The type of this variable depends on the value of the $type
+ * argument. For example, when altering a 'form', $data will be a structured
+ * array. When altering a 'profile', $data will be an object.
+ * @param $context1
+ * (optional) An additional variable that is passed by reference.
+ * @param $context2
+ * (optional) An additional variable that is passed by reference. If more
+ * context needs to be provided to implementations, then this should be an
+ * associative array as described above.
+ */
+function drupal_alter($type, &$data, &$context1 = NULL, &$context2 = NULL) {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['functions'] = &drupal_static(__FUNCTION__);
+ }
+ $functions = &$drupal_static_fast['functions'];
+
+ // Most of the time, $type is passed as a string, so for performance,
+ // normalize it to that. When passed as an array, usually the first item in
+ // the array is a generic type, and additional items in the array are more
+ // specific variants of it, as in the case of array('form', 'form_FORM_ID').
+ if (is_array($type)) {
+ $cid = implode(',', $type);
+ $extra_types = $type;
+ $type = array_shift($extra_types);
+ // Allow if statements in this function to use the faster isset() rather
+ // than !empty() both when $type is passed as a string, or as an array with
+ // one item.
+ if (empty($extra_types)) {
+ unset($extra_types);
+ }
+ }
+ else {
+ $cid = $type;
+ }
+
+ // Some alter hooks are invoked many times per page request, so statically
+ // cache the list of functions to call, and on subsequent calls, iterate
+ // through them quickly.
+ if (!isset($functions[$cid])) {
+ $functions[$cid] = array();
+ $hook = $type . '_alter';
+ $modules = module_implements($hook);
+ if (!isset($extra_types)) {
+ // For the more common case of a single hook, we do not need to call
+ // function_exists(), since module_implements() returns only modules with
+ // implementations.
+ foreach ($modules as $module) {
+ $functions[$cid][] = $module . '_' . $hook;
+ }
+ }
+ else {
+ // For multiple hooks, we need $modules to contain every module that
+ // implements at least one of them.
+ $extra_modules = array();
+ foreach ($extra_types as $extra_type) {
+ $extra_modules = array_merge($extra_modules, module_implements($extra_type . '_alter'));
+ }
+ // If any modules implement one of the extra hooks that do not implement
+ // the primary hook, we need to add them to the $modules array in their
+ // appropriate order.
+ if (array_diff($extra_modules, $modules)) {
+ // Order the modules by the order returned by module_list().
+ $modules = array_intersect(module_list(), array_merge($modules, $extra_modules));
+ }
+ foreach ($modules as $module) {
+ // Since $modules is a merged array, for any given module, we do not
+ // know whether it has any particular implementation, so we need a
+ // function_exists().
+ $function = $module . '_' . $hook;
+ if (function_exists($function)) {
+ $functions[$cid][] = $function;
+ }
+ foreach ($extra_types as $extra_type) {
+ $function = $module . '_' . $extra_type . '_alter';
+ if (function_exists($function)) {
+ $functions[$cid][] = $function;
+ }
+ }
+ }
+ }
+ // Allow the theme to alter variables after the theme system has been
+ // initialized.
+ global $theme, $base_theme_info;
+ if (isset($theme)) {
+ $theme_keys = array();
+ foreach ($base_theme_info as $base) {
+ $theme_keys[] = $base->name;
+ }
+ $theme_keys[] = $theme;
+ foreach ($theme_keys as $theme_key) {
+ $function = $theme_key . '_' . $hook;
+ if (function_exists($function)) {
+ $functions[$cid][] = $function;
+ }
+ if (isset($extra_types)) {
+ foreach ($extra_types as $extra_type) {
+ $function = $theme_key . '_' . $extra_type . '_alter';
+ if (function_exists($function)) {
+ $functions[$cid][] = $function;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ foreach ($functions[$cid] as $function) {
+ $function($data, $context1, $context2);
+ }
+}
+
diff --git a/core/includes/pager.inc b/core/includes/pager.inc
new file mode 100644
index 000000000000..a5d3e6be03c9
--- /dev/null
+++ b/core/includes/pager.inc
@@ -0,0 +1,658 @@
+<?php
+
+/**
+ * @file
+ * Functions to aid in presenting database results as a set of pages.
+ */
+
+
+/**
+ * Query extender for pager queries.
+ *
+ * This is the "default" pager mechanism. It creates a paged query with a fixed
+ * number of entries per page.
+ */
+class PagerDefault extends SelectQueryExtender {
+
+ /**
+ * The highest element we've autogenerated so far.
+ *
+ * @var int
+ */
+ static $maxElement = 0;
+
+ /**
+ * The number of elements per page to allow.
+ *
+ * @var int
+ */
+ protected $limit = 10;
+
+ /**
+ * The unique ID of this pager on this page.
+ *
+ * @var int
+ */
+ protected $element = NULL;
+
+ /**
+ * The count query that will be used for this pager.
+ *
+ * @var SelectQueryInterface
+ */
+ protected $customCountQuery = FALSE;
+
+ public function __construct(SelectQueryInterface $query, DatabaseConnection $connection) {
+ parent::__construct($query, $connection);
+
+ // Add pager tag. Do this here to ensure that it is always added before
+ // preExecute() is called.
+ $this->addTag('pager');
+ }
+
+ /**
+ * Override the execute method.
+ *
+ * Before we run the query, we need to add pager-based range() instructions
+ * to it.
+ */
+ public function execute() {
+
+ // Add convenience tag to mark that this is an extended query. We have to
+ // do this in the constructor to ensure that it is set before preExecute()
+ // gets called.
+ if (!$this->preExecute($this)) {
+ return NULL;
+ }
+
+ // A NULL limit is the "kill switch" for pager queries.
+ if (empty($this->limit)) {
+ return;
+ }
+ $this->ensureElement();
+
+ $total_items = $this->getCountQuery()->execute()->fetchField();
+ $current_page = pager_default_initialize($total_items, $this->limit, $this->element);
+ $this->range($current_page * $this->limit, $this->limit);
+
+ // Now that we've added our pager-based range instructions, run the query normally.
+ return $this->query->execute();
+ }
+
+ /**
+ * Ensure that there is an element associated with this query.
+ * If an element was not specified previously, then the value of the
+ * $maxElement counter is taken, after which the counter is incremented.
+ *
+ * After running this method, access $this->element to get the element for this
+ * query.
+ */
+ protected function ensureElement() {
+ if (!isset($this->element)) {
+ $this->element = self::$maxElement++;
+ }
+ }
+
+ /**
+ * Specify the count query object to use for this pager.
+ *
+ * You will rarely need to specify a count query directly. If not specified,
+ * one is generated off of the pager query itself.
+ *
+ * @param SelectQueryInterface $query
+ * The count query object. It must return a single row with a single column,
+ * which is the total number of records.
+ */
+ public function setCountQuery(SelectQueryInterface $query) {
+ $this->customCountQuery = $query;
+ }
+
+ /**
+ * Retrieve the count query for this pager.
+ *
+ * The count query may be specified manually or, by default, taken from the
+ * query we are extending.
+ *
+ * @return SelectQueryInterface
+ * A count query object.
+ */
+ public function getCountQuery() {
+ if ($this->customCountQuery) {
+ return $this->customCountQuery;
+ }
+ else {
+ return $this->query->countQuery();
+ }
+ }
+
+ /**
+ * Specify the maximum number of elements per page for this query.
+ *
+ * The default if not specified is 10 items per page.
+ *
+ * @param $limit
+ * An integer specifying the number of elements per page. If passed a false
+ * value (FALSE, 0, NULL), the pager is disabled.
+ */
+ public function limit($limit = 10) {
+ $this->limit = $limit;
+ return $this;
+ }
+
+ /**
+ * Specify the element ID for this pager query.
+ *
+ * The element is used to differentiate different pager queries on the same
+ * page so that they may be operated independently. If you do not specify an
+ * element, every pager query on the page will get a unique element. If for
+ * whatever reason you want to explicitly define an element for a given query,
+ * you may do so here.
+ *
+ * Setting the element here also increments the static $maxElement counter,
+ * which is used for determining the $element when there's none specified.
+ *
+ * Note that no collision detection is done when setting an element ID
+ * explicitly, so it is possible for two pagers to end up using the same ID
+ * if both are set explicitly.
+ *
+ * @param $element
+ */
+ public function element($element) {
+ $this->element = $element;
+ if ($element >= self::$maxElement) {
+ self::$maxElement = $element + 1;
+ }
+ return $this;
+ }
+}
+
+/**
+ * Returns the current page being requested for display within a pager.
+ *
+ * @param $element
+ * An optional integer to distinguish between multiple pagers on one page.
+ *
+ * @return
+ * The number of the current requested page, within the pager represented by
+ * $element. This is determined from the URL query parameter $_GET['page'], or
+ * 0 by default. Note that this number may differ from the actual page being
+ * displayed. For example, if a search for "example text" brings up three
+ * pages of results, but a users visits search/node/example+text?page=10, this
+ * function will return 10, even though the default pager implementation
+ * adjusts for this and still displays the third page of search results at
+ * that URL.
+ *
+ * @see pager_default_initialize()
+ */
+function pager_find_page($element = 0) {
+ $page = isset($_GET['page']) ? $_GET['page'] : '';
+ $page_array = explode(',', $page);
+ if (!isset($page_array[$element])) {
+ $page_array[$element] = 0;
+ }
+ return (int) $page_array[$element];
+}
+
+/**
+ * Initializes a pager for theme('pager').
+ *
+ * This function sets up the necessary global variables so that future calls
+ * to theme('pager') will render a pager that correctly corresponds to the
+ * items being displayed.
+ *
+ * If the items being displayed result from a database query performed using
+ * Drupal's database API, and if you have control over the construction of the
+ * database query, you do not need to call this function directly; instead, you
+ * can simply extend the query object with the 'PagerDefault' extender before
+ * executing it. For example:
+ * @code
+ * $query = db_select('some_table')->extend('PagerDefault');
+ * @endcode
+ *
+ * However, if you are using a different method for generating the items to be
+ * paged through, then you should call this function in preparation.
+ *
+ * The following example shows how this function can be used in a page callback
+ * that invokes an external datastore with an SQL-like syntax:
+ * @code
+ * // First find the total number of items and initialize the pager.
+ * $where = "status = 1";
+ * $total = mymodule_select("SELECT COUNT(*) FROM data " . $where)->result();
+ * $num_per_page = variable_get('mymodule_num_per_page', 10);
+ * $page = pager_default_initialize($total, $num_per_page);
+ *
+ * // Next, retrieve and display the items for the current page.
+ * $offset = $num_per_page * $page;
+ * $result = mymodule_select("SELECT * FROM data " . $where . " LIMIT %d, %d", $offset, $num_per_page)->fetchAll();
+ * $output = theme('mymodule_results', array('result' => $result));
+ *
+ * // Finally, display the pager controls, and return.
+ * $output .= theme('pager');
+ * return $output;
+ * @endcode
+ *
+ * A second example involves a page callback that invokes an external search
+ * service where the total number of matching results is provided as part of
+ * the returned set (so that we do not need a separate query in order to obtain
+ * this information). Here, we call pager_find_page() to calculate the desired
+ * offset before the search is invoked:
+ * @code
+ * // Perform the query, using the requested offset from pager_find_page().
+ * // This comes from a URL parameter, so here we are assuming that the URL
+ * // parameter corresponds to an actual page of results that will exist
+ * // within the set.
+ * $page = pager_find_page();
+ * $num_per_page = variable_get('mymodule_num_per_page', 10);
+ * $offset = $num_per_page * $page;
+ * $result = mymodule_remote_search($keywords, $offset, $num_per_page);
+ *
+ * // Now that we have the total number of results, initialize the pager.
+ * pager_default_initialize($result->total, $num_per_page);
+ *
+ * // Display the search results.
+ * $output = theme('search_results', array('results' => $result->data, 'type' => 'remote'));
+ *
+ * // Finally, display the pager controls, and return.
+ * $output .= theme('pager');
+ * return $output;
+ * @endcode
+ *
+ * @param $total
+ * The total number of items to be paged.
+ * @param $limit
+ * The number of items the calling code will display per page.
+ * @param $element
+ * An optional integer to distinguish between multiple pagers on one page.
+ *
+ * @return
+ * The number of the current page, within the pager represented by $element.
+ * This is determined from the URL query parameter $_GET['page'], or 0 by
+ * default. However, if a page that does not correspond to the actual range
+ * of the result set was requested, this function will return the closest
+ * page actually within the result set.
+ */
+function pager_default_initialize($total, $limit, $element = 0) {
+ global $pager_page_array, $pager_total, $pager_total_items, $pager_limits;
+
+ $page = pager_find_page($element);
+
+ // We calculate the total of pages as ceil(items / limit).
+ $pager_total_items[$element] = $total;
+ $pager_total[$element] = ceil($pager_total_items[$element] / $limit);
+ $pager_page_array[$element] = max(0, min($page, ((int) $pager_total[$element]) - 1));
+ $pager_limits[$element] = $limit;
+ return $pager_page_array[$element];
+}
+
+/**
+ * Compose a URL query parameter array for pager links.
+ *
+ * @return
+ * A URL query parameter array that consists of all components of the current
+ * page request except for those pertaining to paging.
+ */
+function pager_get_query_parameters() {
+ $query = &drupal_static(__FUNCTION__);
+ if (!isset($query)) {
+ $query = drupal_get_query_parameters($_GET, array('q', 'page'));
+ }
+ return $query;
+}
+
+/**
+ * Returns HTML for a query pager.
+ *
+ * Menu callbacks that display paged query results should call theme('pager') to
+ * retrieve a pager control so that users can view other results. Format a list
+ * of nearby pages with additional query results.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - tags: An array of labels for the controls in the pager.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager links.
+ * - quantity: The number of pages in the list.
+ *
+ * @ingroup themeable
+ */
+function theme_pager($variables) {
+ $tags = $variables['tags'];
+ $element = $variables['element'];
+ $parameters = $variables['parameters'];
+ $quantity = $variables['quantity'];
+ global $pager_page_array, $pager_total;
+
+ // Calculate various markers within this pager piece:
+ // Middle is used to "center" pages around the current page.
+ $pager_middle = ceil($quantity / 2);
+ // current is the page we are currently paged to
+ $pager_current = $pager_page_array[$element] + 1;
+ // first is the first page listed by this pager piece (re quantity)
+ $pager_first = $pager_current - $pager_middle + 1;
+ // last is the last page listed by this pager piece (re quantity)
+ $pager_last = $pager_current + $quantity - $pager_middle;
+ // max is the maximum page number
+ $pager_max = $pager_total[$element];
+ // End of marker calculations.
+
+ // Prepare for generation loop.
+ $i = $pager_first;
+ if ($pager_last > $pager_max) {
+ // Adjust "center" if at end of query.
+ $i = $i + ($pager_max - $pager_last);
+ $pager_last = $pager_max;
+ }
+ if ($i <= 0) {
+ // Adjust "center" if at start of query.
+ $pager_last = $pager_last + (1 - $i);
+ $i = 1;
+ }
+ // End of generation loop preparation.
+
+ $li_first = theme('pager_first', array('text' => (isset($tags[0]) ? $tags[0] : t('« first')), 'element' => $element, 'parameters' => $parameters));
+ $li_previous = theme('pager_previous', array('text' => (isset($tags[1]) ? $tags[1] : t('‹ previous')), 'element' => $element, 'interval' => 1, 'parameters' => $parameters));
+ $li_next = theme('pager_next', array('text' => (isset($tags[3]) ? $tags[3] : t('next ›')), 'element' => $element, 'interval' => 1, 'parameters' => $parameters));
+ $li_last = theme('pager_last', array('text' => (isset($tags[4]) ? $tags[4] : t('last »')), 'element' => $element, 'parameters' => $parameters));
+
+ if ($pager_total[$element] > 1) {
+ if ($li_first) {
+ $items[] = array(
+ 'class' => array('pager-first'),
+ 'data' => $li_first,
+ );
+ }
+ if ($li_previous) {
+ $items[] = array(
+ 'class' => array('pager-previous'),
+ 'data' => $li_previous,
+ );
+ }
+
+ // When there is more than one page, create the pager list.
+ if ($i != $pager_max) {
+ if ($i > 1) {
+ $items[] = array(
+ 'class' => array('pager-ellipsis'),
+ 'data' => '…',
+ );
+ }
+ // Now generate the actual pager piece.
+ for (; $i <= $pager_last && $i <= $pager_max; $i++) {
+ if ($i < $pager_current) {
+ $items[] = array(
+ 'class' => array('pager-item'),
+ 'data' => theme('pager_previous', array('text' => $i, 'element' => $element, 'interval' => ($pager_current - $i), 'parameters' => $parameters)),
+ );
+ }
+ if ($i == $pager_current) {
+ $items[] = array(
+ 'class' => array('pager-current'),
+ 'data' => $i,
+ );
+ }
+ if ($i > $pager_current) {
+ $items[] = array(
+ 'class' => array('pager-item'),
+ 'data' => theme('pager_next', array('text' => $i, 'element' => $element, 'interval' => ($i - $pager_current), 'parameters' => $parameters)),
+ );
+ }
+ }
+ if ($i < $pager_max) {
+ $items[] = array(
+ 'class' => array('pager-ellipsis'),
+ 'data' => '…',
+ );
+ }
+ }
+ // End generation.
+ if ($li_next) {
+ $items[] = array(
+ 'class' => array('pager-next'),
+ 'data' => $li_next,
+ );
+ }
+ if ($li_last) {
+ $items[] = array(
+ 'class' => array('pager-last'),
+ 'data' => $li_last,
+ );
+ }
+ return '<h2 class="element-invisible">' . t('Pages') . '</h2>' . theme('item_list', array(
+ 'items' => $items,
+ 'attributes' => array('class' => array('pager')),
+ ));
+ }
+}
+
+
+/**
+ * @defgroup pagerpieces Pager pieces
+ * @{
+ * Theme functions for customizing pager elements.
+ *
+ * Note that you should NOT modify this file to customize your pager.
+ */
+
+/**
+ * Returns HTML for the "first page" link in a query pager.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - text: The name (or image) of the link.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager links.
+ *
+ * @ingroup themeable
+ */
+function theme_pager_first($variables) {
+ $text = $variables['text'];
+ $element = $variables['element'];
+ $parameters = $variables['parameters'];
+ global $pager_page_array;
+ $output = '';
+
+ // If we are anywhere but the first page
+ if ($pager_page_array[$element] > 0) {
+ $output = theme('pager_link', array('text' => $text, 'page_new' => pager_load_array(0, $element, $pager_page_array), 'element' => $element, 'parameters' => $parameters));
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the "previous page" link in a query pager.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - text: The name (or image) of the link.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - interval: The number of pages to move backward when the link is clicked.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager links.
+ *
+ * @ingroup themeable
+ */
+function theme_pager_previous($variables) {
+ $text = $variables['text'];
+ $element = $variables['element'];
+ $interval = $variables['interval'];
+ $parameters = $variables['parameters'];
+ global $pager_page_array;
+ $output = '';
+
+ // If we are anywhere but the first page
+ if ($pager_page_array[$element] > 0) {
+ $page_new = pager_load_array($pager_page_array[$element] - $interval, $element, $pager_page_array);
+
+ // If the previous page is the first page, mark the link as such.
+ if ($page_new[$element] == 0) {
+ $output = theme('pager_first', array('text' => $text, 'element' => $element, 'parameters' => $parameters));
+ }
+ // The previous page is not the first page.
+ else {
+ $output = theme('pager_link', array('text' => $text, 'page_new' => $page_new, 'element' => $element, 'parameters' => $parameters));
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the "next page" link in a query pager.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - text: The name (or image) of the link.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - interval: The number of pages to move forward when the link is clicked.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager links.
+ *
+ * @ingroup themeable
+ */
+function theme_pager_next($variables) {
+ $text = $variables['text'];
+ $element = $variables['element'];
+ $interval = $variables['interval'];
+ $parameters = $variables['parameters'];
+ global $pager_page_array, $pager_total;
+ $output = '';
+
+ // If we are anywhere but the last page
+ if ($pager_page_array[$element] < ($pager_total[$element] - 1)) {
+ $page_new = pager_load_array($pager_page_array[$element] + $interval, $element, $pager_page_array);
+ // If the next page is the last page, mark the link as such.
+ if ($page_new[$element] == ($pager_total[$element] - 1)) {
+ $output = theme('pager_last', array('text' => $text, 'element' => $element, 'parameters' => $parameters));
+ }
+ // The next page is not the last page.
+ else {
+ $output = theme('pager_link', array('text' => $text, 'page_new' => $page_new, 'element' => $element, 'parameters' => $parameters));
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the "last page" link in query pager.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - text: The name (or image) of the link.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager links.
+ *
+ * @ingroup themeable
+ */
+function theme_pager_last($variables) {
+ $text = $variables['text'];
+ $element = $variables['element'];
+ $parameters = $variables['parameters'];
+ global $pager_page_array, $pager_total;
+ $output = '';
+
+ // If we are anywhere but the last page
+ if ($pager_page_array[$element] < ($pager_total[$element] - 1)) {
+ $output = theme('pager_link', array('text' => $text, 'page_new' => pager_load_array($pager_total[$element] - 1, $element, $pager_page_array), 'element' => $element, 'parameters' => $parameters));
+ }
+
+ return $output;
+}
+
+
+/**
+ * Returns HTML for a link to a specific query result page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - text: The link text. Also used to figure out the title attribute of the
+ * link, if it is not provided in $variables['attributes']['title']; in
+ * this case, $variables['text'] must be one of the standard pager link
+ * text strings that would be generated by the pager theme functions, such
+ * as a number or t('« first').
+ * - page_new: The first result to display on the linked page.
+ * - element: An optional integer to distinguish between multiple pagers on
+ * one page.
+ * - parameters: An associative array of query string parameters to append to
+ * the pager link.
+ * - attributes: An associative array of HTML attributes to apply to the
+ * pager link.
+ *
+ * @see theme_pager()
+ *
+ * @ingroup themeable
+ */
+function theme_pager_link($variables) {
+ $text = $variables['text'];
+ $page_new = $variables['page_new'];
+ $element = $variables['element'];
+ $parameters = $variables['parameters'];
+ $attributes = $variables['attributes'];
+
+ $page = isset($_GET['page']) ? $_GET['page'] : '';
+ if ($new_page = implode(',', pager_load_array($page_new[$element], $element, explode(',', $page)))) {
+ $parameters['page'] = $new_page;
+ }
+
+ $query = array();
+ if (count($parameters)) {
+ $query = drupal_get_query_parameters($parameters, array());
+ }
+ if ($query_pager = pager_get_query_parameters()) {
+ $query = array_merge($query, $query_pager);
+ }
+
+ // Set each pager link title
+ if (!isset($attributes['title'])) {
+ static $titles = NULL;
+ if (!isset($titles)) {
+ $titles = array(
+ t('« first') => t('Go to first page'),
+ t('‹ previous') => t('Go to previous page'),
+ t('next ›') => t('Go to next page'),
+ t('last »') => t('Go to last page'),
+ );
+ }
+ if (isset($titles[$text])) {
+ $attributes['title'] = $titles[$text];
+ }
+ elseif (is_numeric($text)) {
+ $attributes['title'] = t('Go to page @number', array('@number' => $text));
+ }
+ }
+
+ return l($text, $_GET['q'], array('attributes' => $attributes, 'query' => $query));
+}
+
+/**
+ * @} End of "Pager pieces".
+ */
+
+/**
+ * Helper function
+ *
+ * Copies $old_array to $new_array and sets $new_array[$element] = $value
+ * Fills in $new_array[0 .. $element - 1] = 0
+ */
+function pager_load_array($value, $element, $old_array) {
+ $new_array = $old_array;
+ // Look for empty elements.
+ for ($i = 0; $i < $element; $i++) {
+ if (empty($new_array[$i])) {
+ // Load found empty element with 0.
+ $new_array[$i] = 0;
+ }
+ }
+ // Update the changed element.
+ $new_array[$element] = (int) $value;
+ return $new_array;
+}
diff --git a/core/includes/password.inc b/core/includes/password.inc
new file mode 100644
index 000000000000..a4b963362947
--- /dev/null
+++ b/core/includes/password.inc
@@ -0,0 +1,289 @@
+<?php
+
+/**
+ * @file
+ * Secure password hashing functions for user authentication.
+ *
+ * Based on the Portable PHP password hashing framework.
+ * @see http://www.openwall.com/phpass/
+ *
+ * An alternative or custom version of this password hashing API may be
+ * used by setting the variable password_inc to the name of the PHP file
+ * containing replacement user_hash_password(), user_check_password(), and
+ * user_needs_new_hash() functions.
+ */
+
+/**
+ * The standard log2 number of iterations for password stretching. This should
+ * increase by 1 every Drupal version in order to counteract increases in the
+ * speed and power of computers available to crack the hashes.
+ */
+define('DRUPAL_HASH_COUNT', 16);
+
+/**
+ * The minimum allowed log2 number of iterations for password stretching.
+ */
+define('DRUPAL_MIN_HASH_COUNT', 7);
+
+/**
+ * The maximum allowed log2 number of iterations for password stretching.
+ */
+define('DRUPAL_MAX_HASH_COUNT', 30);
+
+/**
+ * The expected (and maximum) number of characters in a hashed password.
+ */
+define('DRUPAL_HASH_LENGTH', 55);
+
+/**
+ * Returns a string for mapping an int to the corresponding base 64 character.
+ */
+function _password_itoa64() {
+ return './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
+}
+
+/**
+ * Encode bytes into printable base 64 using the *nix standard from crypt().
+ *
+ * @param $input
+ * The string containing bytes to encode.
+ * @param $count
+ * The number of characters (bytes) to encode.
+ *
+ * @return
+ * Encoded string
+ */
+function _password_base64_encode($input, $count) {
+ $output = '';
+ $i = 0;
+ $itoa64 = _password_itoa64();
+ do {
+ $value = ord($input[$i++]);
+ $output .= $itoa64[$value & 0x3f];
+ if ($i < $count) {
+ $value |= ord($input[$i]) << 8;
+ }
+ $output .= $itoa64[($value >> 6) & 0x3f];
+ if ($i++ >= $count) {
+ break;
+ }
+ if ($i < $count) {
+ $value |= ord($input[$i]) << 16;
+ }
+ $output .= $itoa64[($value >> 12) & 0x3f];
+ if ($i++ >= $count) {
+ break;
+ }
+ $output .= $itoa64[($value >> 18) & 0x3f];
+ } while ($i < $count);
+
+ return $output;
+}
+
+/**
+ * Generates a random base 64-encoded salt prefixed with settings for the hash.
+ *
+ * Proper use of salts may defeat a number of attacks, including:
+ * - The ability to try candidate passwords against multiple hashes at once.
+ * - The ability to use pre-hashed lists of candidate passwords.
+ * - The ability to determine whether two users have the same (or different)
+ * password without actually having to guess one of the passwords.
+ *
+ * @param $count_log2
+ * Integer that determines the number of iterations used in the hashing
+ * process. A larger value is more secure, but takes more time to complete.
+ *
+ * @return
+ * A 12 character string containing the iteration count and a random salt.
+ */
+function _password_generate_salt($count_log2) {
+ $output = '$S$';
+ // Ensure that $count_log2 is within set bounds.
+ $count_log2 = _password_enforce_log2_boundaries($count_log2);
+ // We encode the final log2 iteration count in base 64.
+ $itoa64 = _password_itoa64();
+ $output .= $itoa64[$count_log2];
+ // 6 bytes is the standard salt for a portable phpass hash.
+ $output .= _password_base64_encode(drupal_random_bytes(6), 6);
+ return $output;
+}
+
+/**
+ * Ensures that $count_log2 is within set bounds.
+ *
+ * @param $count_log2
+ * Integer that determines the number of iterations used in the hashing
+ * process. A larger value is more secure, but takes more time to complete.
+ *
+ * @return
+ * Integer within set bounds that is closest to $count_log2.
+ */
+function _password_enforce_log2_boundaries($count_log2) {
+ if ($count_log2 < DRUPAL_MIN_HASH_COUNT) {
+ return DRUPAL_MIN_HASH_COUNT;
+ }
+ elseif ($count_log2 > DRUPAL_MAX_HASH_COUNT) {
+ return DRUPAL_MAX_HASH_COUNT;
+ }
+
+ return (int) $count_log2;
+}
+
+/**
+ * Hash a password using a secure stretched hash.
+ *
+ * By using a salt and repeated hashing the password is "stretched". Its
+ * security is increased because it becomes much more computationally costly
+ * for an attacker to try to break the hash by brute-force computation of the
+ * hashes of a large number of plain-text words or strings to find a match.
+ *
+ * @param $algo
+ * The string name of a hashing algorithm usable by hash(), like 'sha256'.
+ * @param $password
+ * The plain-text password to hash.
+ * @param $setting
+ * An existing hash or the output of _password_generate_salt(). Must be
+ * at least 12 characters (the settings and salt).
+ *
+ * @return
+ * A string containing the hashed password (and salt) or FALSE on failure.
+ * The return string will be truncated at DRUPAL_HASH_LENGTH characters max.
+ */
+function _password_crypt($algo, $password, $setting) {
+ // The first 12 characters of an existing hash are its setting string.
+ $setting = substr($setting, 0, 12);
+
+ if ($setting[0] != '$' || $setting[2] != '$') {
+ return FALSE;
+ }
+ $count_log2 = _password_get_count_log2($setting);
+ // Hashes may be imported from elsewhere, so we allow != DRUPAL_HASH_COUNT
+ if ($count_log2 < DRUPAL_MIN_HASH_COUNT || $count_log2 > DRUPAL_MAX_HASH_COUNT) {
+ return FALSE;
+ }
+ $salt = substr($setting, 4, 8);
+ // Hashes must have an 8 character salt.
+ if (strlen($salt) != 8) {
+ return FALSE;
+ }
+
+ // Convert the base 2 logarithm into an integer.
+ $count = 1 << $count_log2;
+
+ // We rely on the hash() function being available in PHP 5.2+.
+ $hash = hash($algo, $salt . $password, TRUE);
+ do {
+ $hash = hash($algo, $hash . $password, TRUE);
+ } while (--$count);
+
+ $len = strlen($hash);
+ $output = $setting . _password_base64_encode($hash, $len);
+ // _password_base64_encode() of a 16 byte MD5 will always be 22 characters.
+ // _password_base64_encode() of a 64 byte sha512 will always be 86 characters.
+ $expected = 12 + ceil((8 * $len) / 6);
+ return (strlen($output) == $expected) ? substr($output, 0, DRUPAL_HASH_LENGTH) : FALSE;
+}
+
+/**
+ * Parse the log2 iteration count from a stored hash or setting string.
+ */
+function _password_get_count_log2($setting) {
+ $itoa64 = _password_itoa64();
+ return strpos($itoa64, $setting[3]);
+}
+
+/**
+ * Hash a password using a secure hash.
+ *
+ * @param $password
+ * A plain-text password.
+ * @param $count_log2
+ * Optional integer to specify the iteration count. Generally used only during
+ * mass operations where a value less than the default is needed for speed.
+ *
+ * @return
+ * A string containing the hashed password (and a salt), or FALSE on failure.
+ */
+function user_hash_password($password, $count_log2 = 0) {
+ if (empty($count_log2)) {
+ // Use the standard iteration count.
+ $count_log2 = variable_get('password_count_log2', DRUPAL_HASH_COUNT);
+ }
+ return _password_crypt('sha512', $password, _password_generate_salt($count_log2));
+}
+
+/**
+ * Check whether a plain text password matches a stored hashed password.
+ *
+ * Alternative implementations of this function may use other data in the
+ * $account object, for example the uid to look up the hash in a custom table
+ * or remote database.
+ *
+ * @param $password
+ * A plain-text password
+ * @param $account
+ * A user object with at least the fields from the {users} table.
+ *
+ * @return
+ * TRUE or FALSE.
+ */
+function user_check_password($password, $account) {
+ if (substr($account->pass, 0, 2) == 'U$') {
+ // This may be an updated password from user_update_7000(). Such hashes
+ // have 'U' added as the first character and need an extra md5() (see the
+ // Drupal 7 documentation).
+ $stored_hash = substr($account->pass, 1);
+ $password = md5($password);
+ }
+ else {
+ $stored_hash = $account->pass;
+ }
+
+ $type = substr($stored_hash, 0, 3);
+ switch ($type) {
+ case '$S$':
+ // A normal Drupal 7 password using sha512.
+ $hash = _password_crypt('sha512', $password, $stored_hash);
+ break;
+ case '$H$':
+ // phpBB3 uses "$H$" for the same thing as "$P$".
+ case '$P$':
+ // A phpass password generated using md5. This is an
+ // imported password or from an earlier Drupal version.
+ $hash = _password_crypt('md5', $password, $stored_hash);
+ break;
+ default:
+ return FALSE;
+ }
+ return ($hash && $stored_hash == $hash);
+}
+
+/**
+ * Check whether a user's hashed password needs to be replaced with a new hash.
+ *
+ * This is typically called during the login process when the plain text
+ * password is available. A new hash is needed when the desired iteration count
+ * has changed through a change in the variable password_count_log2 or
+ * DRUPAL_HASH_COUNT or if the user's password hash was generated in an update
+ * like user_update_7000() (see the Drupal 7 documentation).
+ *
+ * Alternative implementations of this function might use other criteria based
+ * on the fields in $account.
+ *
+ * @param $account
+ * A user object with at least the fields from the {users} table.
+ *
+ * @return
+ * TRUE or FALSE.
+ */
+function user_needs_new_hash($account) {
+ // Check whether this was an updated password.
+ if ((substr($account->pass, 0, 3) != '$S$') || (strlen($account->pass) != DRUPAL_HASH_LENGTH)) {
+ return TRUE;
+ }
+ // Ensure that $count_log2 is within set bounds.
+ $count_log2 = _password_enforce_log2_boundaries(variable_get('password_count_log2', DRUPAL_HASH_COUNT));
+ // Check whether the iteration count used differs from the standard number.
+ return (_password_get_count_log2($account->pass) !== $count_log2);
+}
+
diff --git a/core/includes/path.inc b/core/includes/path.inc
new file mode 100644
index 000000000000..630b34c4ce08
--- /dev/null
+++ b/core/includes/path.inc
@@ -0,0 +1,581 @@
+<?php
+
+/**
+ * @file
+ * Functions to handle paths in Drupal, including path aliasing.
+ *
+ * These functions are not loaded for cached pages, but modules that need
+ * to use them in hook_boot() or hook exit() can make them available, by
+ * executing "drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);".
+ */
+
+/**
+ * Initialize the $_GET['q'] variable to the proper normal path.
+ */
+function drupal_path_initialize() {
+ if (!empty($_GET['q'])) {
+ $_GET['q'] = drupal_get_normal_path($_GET['q']);
+ }
+ else {
+ $_GET['q'] = drupal_get_normal_path(variable_get('site_frontpage', 'node'));
+ }
+}
+
+/**
+ * Given an alias, return its Drupal system URL if one exists. Given a Drupal
+ * system URL return one of its aliases if such a one exists. Otherwise,
+ * return FALSE.
+ *
+ * @param $action
+ * One of the following values:
+ * - wipe: delete the alias cache.
+ * - alias: return an alias for a given Drupal system path (if one exists).
+ * - source: return the Drupal system URL for a path alias (if one exists).
+ * @param $path
+ * The path to investigate for corresponding aliases or system URLs.
+ * @param $path_language
+ * Optional language code to search the path with. Defaults to the page language.
+ * If there's no path defined for that language it will search paths without
+ * language.
+ *
+ * @return
+ * Either a Drupal system path, an aliased path, or FALSE if no path was
+ * found.
+ */
+function drupal_lookup_path($action, $path = '', $path_language = NULL) {
+ global $language_url;
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['cache'] = &drupal_static(__FUNCTION__);
+ }
+ $cache = &$drupal_static_fast['cache'];
+
+ if (!isset($cache)) {
+ $cache = array(
+ 'map' => array(),
+ 'no_source' => array(),
+ 'whitelist' => NULL,
+ 'system_paths' => array(),
+ 'no_aliases' => array(),
+ 'first_call' => TRUE,
+ );
+ }
+
+ // Retrieve the path alias whitelist.
+ if (!isset($cache['whitelist'])) {
+ $cache['whitelist'] = variable_get('path_alias_whitelist', NULL);
+ if (!isset($cache['whitelist'])) {
+ $cache['whitelist'] = drupal_path_alias_whitelist_rebuild();
+ }
+ }
+
+ // If no language is explicitly specified we default to the current URL
+ // language. If we used a language different from the one conveyed by the
+ // requested URL, we might end up being unable to check if there is a path
+ // alias matching the URL path.
+ $path_language = $path_language ? $path_language : $language_url->language;
+
+ if ($action == 'wipe') {
+ $cache = array();
+ $cache['whitelist'] = drupal_path_alias_whitelist_rebuild();
+ }
+ elseif ($cache['whitelist'] && $path != '') {
+ if ($action == 'alias') {
+ // During the first call to drupal_lookup_path() per language, load the
+ // expected system paths for the page from cache.
+ if (!empty($cache['first_call'])) {
+ $cache['first_call'] = FALSE;
+
+ $cache['map'][$path_language] = array();
+ // Load system paths from cache.
+ $cid = current_path();
+ if ($cached = cache('path')->get($cid)) {
+ $cache['system_paths'] = $cached->data;
+ // Now fetch the aliases corresponding to these system paths.
+ $args = array(
+ ':system' => $cache['system_paths'],
+ ':language' => $path_language,
+ ':language_none' => LANGUAGE_NONE,
+ );
+ // Always get the language-specific alias before the language-neutral
+ // one. For example 'de' is less than 'und' so the order needs to be
+ // ASC, while 'xx-lolspeak' is more than 'und' so the order needs to
+ // be DESC. We also order by pid ASC so that fetchAllKeyed() returns
+ // the most recently created alias for each source. Subsequent queries
+ // using fetchField() must use pid DESC to have the same effect.
+ // For performance reasons, the query builder is not used here.
+ if ($path_language == LANGUAGE_NONE) {
+ // Prevent PDO from complaining about a token the query doesn't use.
+ unset($args[':language']);
+ $result = db_query('SELECT source, alias FROM {url_alias} WHERE source IN (:system) AND language = :language_none ORDER BY pid ASC', $args);
+ }
+ elseif ($path_language < LANGUAGE_NONE) {
+ $result = db_query('SELECT source, alias FROM {url_alias} WHERE source IN (:system) AND language IN (:language, :language_none) ORDER BY language ASC, pid ASC', $args);
+ }
+ else {
+ $result = db_query('SELECT source, alias FROM {url_alias} WHERE source IN (:system) AND language IN (:language, :language_none) ORDER BY language DESC, pid ASC', $args);
+ }
+ $cache['map'][$path_language] = $result->fetchAllKeyed();
+ // Keep a record of paths with no alias to avoid querying twice.
+ $cache['no_aliases'][$path_language] = array_flip(array_diff_key($cache['system_paths'], array_keys($cache['map'][$path_language])));
+ }
+ }
+ // If the alias has already been loaded, return it.
+ if (isset($cache['map'][$path_language][$path])) {
+ return $cache['map'][$path_language][$path];
+ }
+ // Check the path whitelist, if the top_level part before the first /
+ // is not in the list, then there is no need to do anything further,
+ // it is not in the database.
+ elseif (!isset($cache['whitelist'][strtok($path, '/')])) {
+ return FALSE;
+ }
+ // For system paths which were not cached, query aliases individually.
+ elseif (!isset($cache['no_aliases'][$path_language][$path])) {
+ $args = array(
+ ':source' => $path,
+ ':language' => $path_language,
+ ':language_none' => LANGUAGE_NONE,
+ );
+ // See the queries above.
+ if ($path_language == LANGUAGE_NONE) {
+ unset($args[':language']);
+ $alias = db_query("SELECT alias FROM {url_alias} WHERE source = :source AND language = :language_none ORDER BY pid DESC", $args)->fetchField();
+ }
+ elseif ($path_language > LANGUAGE_NONE) {
+ $alias = db_query("SELECT alias FROM {url_alias} WHERE source = :source AND language IN (:language, :language_none) ORDER BY language DESC, pid DESC", $args)->fetchField();
+ }
+ else {
+ $alias = db_query("SELECT alias FROM {url_alias} WHERE source = :source AND language IN (:language, :language_none) ORDER BY language ASC, pid DESC", $args)->fetchField();
+ }
+ $cache['map'][$path_language][$path] = $alias;
+ return $alias;
+ }
+ }
+ // Check $no_source for this $path in case we've already determined that there
+ // isn't a path that has this alias
+ elseif ($action == 'source' && !isset($cache['no_source'][$path_language][$path])) {
+ // Look for the value $path within the cached $map
+ $source = FALSE;
+ if (!isset($cache['map'][$path_language]) || !($source = array_search($path, $cache['map'][$path_language]))) {
+ $args = array(
+ ':alias' => $path,
+ ':language' => $path_language,
+ ':language_none' => LANGUAGE_NONE,
+ );
+ // See the queries above.
+ if ($path_language == LANGUAGE_NONE) {
+ unset($args[':language']);
+ $result = db_query("SELECT source FROM {url_alias} WHERE alias = :alias AND language = :language_none ORDER BY pid DESC", $args);
+ }
+ elseif ($path_language > LANGUAGE_NONE) {
+ $result = db_query("SELECT source FROM {url_alias} WHERE alias = :alias AND language IN (:language, :language_none) ORDER BY language DESC, pid DESC", $args);
+ }
+ else {
+ $result = db_query("SELECT source FROM {url_alias} WHERE alias = :alias AND language IN (:language, :language_none) ORDER BY language ASC, pid DESC", $args);
+ }
+ if ($source = $result->fetchField()) {
+ $cache['map'][$path_language][$source] = $path;
+ }
+ else {
+ // We can't record anything into $map because we do not have a valid
+ // index and there is no need because we have not learned anything
+ // about any Drupal path. Thus cache to $no_source.
+ $cache['no_source'][$path_language][$path] = TRUE;
+ }
+ }
+ return $source;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Cache system paths for a page.
+ *
+ * Cache an array of the system paths available on each page. We assume
+ * that aliases will be needed for the majority of these paths during
+ * subsequent requests, and load them in a single query during
+ * drupal_lookup_path().
+ */
+function drupal_cache_system_paths() {
+ // Check if the system paths for this page were loaded from cache in this
+ // request to avoid writing to cache on every request.
+ $cache = &drupal_static('drupal_lookup_path', array());
+ if (empty($cache['system_paths']) && !empty($cache['map'])) {
+ // Generate a cache ID (cid) specifically for this page.
+ $cid = current_path();
+ // The static $map array used by drupal_lookup_path() includes all
+ // system paths for the page request.
+ if ($paths = current($cache['map'])) {
+ $data = array_keys($paths);
+ $expire = REQUEST_TIME + (60 * 60 * 24);
+ cache('path')->set($cid, $data, $expire);
+ }
+ }
+}
+
+/**
+ * Given an internal Drupal path, return the alias set by the administrator.
+ *
+ * If no path is provided, the function will return the alias of the current
+ * page.
+ *
+ * @param $path
+ * An internal Drupal path.
+ * @param $path_language
+ * An optional language code to look up the path in.
+ *
+ * @return
+ * An aliased path if one was found, or the original path if no alias was
+ * found.
+ */
+function drupal_get_path_alias($path = NULL, $path_language = NULL) {
+ // If no path is specified, use the current page's path.
+ if ($path == NULL) {
+ $path = $_GET['q'];
+ }
+ $result = $path;
+ if ($alias = drupal_lookup_path('alias', $path, $path_language)) {
+ $result = $alias;
+ }
+ return $result;
+}
+
+/**
+ * Given a path alias, return the internal path it represents.
+ *
+ * @param $path
+ * A Drupal path alias.
+ * @param $path_language
+ * An optional language code to look up the path in.
+ *
+ * @return
+ * The internal path represented by the alias, or the original alias if no
+ * internal path was found.
+ */
+function drupal_get_normal_path($path, $path_language = NULL) {
+ $original_path = $path;
+
+ // Lookup the path alias first.
+ if ($source = drupal_lookup_path('source', $path, $path_language)) {
+ $path = $source;
+ }
+
+ // Allow other modules to alter the inbound URL. We cannot use drupal_alter()
+ // here because we need to run hook_url_inbound_alter() in the reverse order
+ // of hook_url_outbound_alter().
+ foreach (array_reverse(module_implements('url_inbound_alter')) as $module) {
+ $function = $module . '_url_inbound_alter';
+ $function($path, $original_path, $path_language);
+ }
+
+ return $path;
+}
+
+/**
+ * Check if the current page is the front page.
+ *
+ * @return
+ * Boolean value: TRUE if the current page is the front page; FALSE if otherwise.
+ */
+function drupal_is_front_page() {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['is_front_page'] = &drupal_static(__FUNCTION__);
+ }
+ $is_front_page = &$drupal_static_fast['is_front_page'];
+
+ if (!isset($is_front_page)) {
+ // As drupal_path_initialize updates $_GET['q'] with the 'site_frontpage' path,
+ // we can check it against the 'site_frontpage' variable.
+ $is_front_page = ($_GET['q'] == variable_get('site_frontpage', 'node'));
+ }
+
+ return $is_front_page;
+}
+
+/**
+ * Check if a path matches any pattern in a set of patterns.
+ *
+ * @param $path
+ * The path to match.
+ * @param $patterns
+ * String containing a set of patterns separated by \n, \r or \r\n.
+ *
+ * @return
+ * Boolean value: TRUE if the path matches a pattern, FALSE otherwise.
+ */
+function drupal_match_path($path, $patterns) {
+ $regexps = &drupal_static(__FUNCTION__);
+
+ if (!isset($regexps[$patterns])) {
+ // Convert path settings to a regular expression.
+ // Therefore replace newlines with a logical or, /* with asterisks and the <front> with the frontpage.
+ $to_replace = array(
+ '/(\r\n?|\n)/', // newlines
+ '/\\\\\*/', // asterisks
+ '/(^|\|)\\\\<front\\\\>($|\|)/' // <front>
+ );
+ $replacements = array(
+ '|',
+ '.*',
+ '\1' . preg_quote(variable_get('site_frontpage', 'node'), '/') . '\2'
+ );
+ $patterns_quoted = preg_quote($patterns, '/');
+ $regexps[$patterns] = '/^(' . preg_replace($to_replace, $replacements, $patterns_quoted) . ')$/';
+ }
+ return (bool)preg_match($regexps[$patterns], $path);
+}
+
+/**
+ * Return the current URL path of the page being viewed.
+ *
+ * Examples:
+ * - http://example.com/node/306 returns "node/306".
+ * - http://example.com/drupalfolder/node/306 returns "node/306" while
+ * base_path() returns "/drupalfolder/".
+ * - http://example.com/path/alias (which is a path alias for node/306) returns
+ * "node/306" as opposed to the path alias.
+ *
+ * This function is not available in hook_boot() so use $_GET['q'] instead.
+ * However, be careful when doing that because in the case of Example #3
+ * $_GET['q'] will contain "path/alias". If "node/306" is needed, calling
+ * drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL) makes this function available.
+ *
+ * @return
+ * The current Drupal URL path.
+ *
+ * @see request_path()
+ */
+function current_path() {
+ return $_GET['q'];
+}
+
+/**
+ * Rebuild the path alias white list.
+ *
+ * @param $source
+ * An optional system path for which an alias is being inserted.
+ *
+ * @return
+ * An array containing a white list of path aliases.
+ */
+function drupal_path_alias_whitelist_rebuild($source = NULL) {
+ // When paths are inserted, only rebuild the whitelist if the system path
+ // has a top level component which is not already in the whitelist.
+ if (!empty($source)) {
+ $whitelist = variable_get('path_alias_whitelist', NULL);
+ if (isset($whitelist[strtok($source, '/')])) {
+ return $whitelist;
+ }
+ }
+ // For each alias in the database, get the top level component of the system
+ // path it corresponds to. This is the portion of the path before the first
+ // '/', if present, otherwise the whole path itself.
+ $whitelist = array();
+ $result = db_query("SELECT DISTINCT SUBSTRING_INDEX(source, '/', 1) AS path FROM {url_alias}");
+ foreach ($result as $row) {
+ $whitelist[$row->path] = TRUE;
+ }
+ variable_set('path_alias_whitelist', $whitelist);
+ return $whitelist;
+}
+
+/**
+ * Fetch a specific URL alias from the database.
+ *
+ * @param $conditions
+ * A string representing the source, a number representing the pid, or an
+ * array of query conditions.
+ *
+ * @return
+ * FALSE if no alias was found or an associative array containing the
+ * following keys:
+ * - source: The internal system path.
+ * - alias: The URL alias.
+ * - pid: Unique path alias identifier.
+ * - language: The language of the alias.
+ */
+function path_load($conditions) {
+ if (is_numeric($conditions)) {
+ $conditions = array('pid' => $conditions);
+ }
+ elseif (is_string($conditions)) {
+ $conditions = array('source' => $conditions);
+ }
+ elseif (!is_array($conditions)) {
+ return FALSE;
+ }
+ $select = db_select('url_alias');
+ foreach ($conditions as $field => $value) {
+ $select->condition($field, $value);
+ }
+ return $select
+ ->fields('url_alias')
+ ->execute()
+ ->fetchAssoc();
+}
+
+/**
+ * Save a path alias to the database.
+ *
+ * @param $path
+ * An associative array containing the following keys:
+ * - source: The internal system path.
+ * - alias: The URL alias.
+ * - pid: (optional) Unique path alias identifier.
+ * - language: (optional) The language of the alias.
+ */
+function path_save(&$path) {
+ $path += array('pid' => NULL, 'language' => LANGUAGE_NONE);
+
+ // Insert or update the alias.
+ $status = drupal_write_record('url_alias', $path, (!empty($path['pid']) ? 'pid' : array()));
+
+ // Verify that a record was written.
+ if ($status) {
+ if ($status === SAVED_NEW) {
+ module_invoke_all('path_insert', $path);
+ }
+ else {
+ module_invoke_all('path_update', $path);
+ }
+ drupal_clear_path_cache($path['source']);
+ }
+}
+
+/**
+ * Delete a URL alias.
+ *
+ * @param $criteria
+ * A number representing the pid or an array of criteria.
+ */
+function path_delete($criteria) {
+ if (!is_array($criteria)) {
+ $criteria = array('pid' => $criteria);
+ }
+ $path = path_load($criteria);
+ $query = db_delete('url_alias');
+ foreach ($criteria as $field => $value) {
+ $query->condition($field, $value);
+ }
+ $query->execute();
+ module_invoke_all('path_delete', $path);
+ drupal_clear_path_cache($path['source']);
+}
+
+/**
+ * Determine whether a path is in the administrative section of the site.
+ *
+ * By default, paths are considered to be non-administrative. If a path does not
+ * match any of the patterns in path_get_admin_paths(), or if it matches both
+ * administrative and non-administrative patterns, it is considered
+ * non-administrative.
+ *
+ * @param $path
+ * A Drupal path.
+ *
+ * @return
+ * TRUE if the path is administrative, FALSE otherwise.
+ *
+ * @see path_get_admin_paths()
+ * @see hook_admin_paths()
+ * @see hook_admin_paths_alter()
+ */
+function path_is_admin($path) {
+ $path_map = &drupal_static(__FUNCTION__);
+ if (!isset($path_map['admin'][$path])) {
+ $patterns = path_get_admin_paths();
+ $path_map['admin'][$path] = drupal_match_path($path, $patterns['admin']);
+ $path_map['non_admin'][$path] = drupal_match_path($path, $patterns['non_admin']);
+ }
+ return $path_map['admin'][$path] && !$path_map['non_admin'][$path];
+}
+
+/**
+ * Get a list of administrative and non-administrative paths.
+ *
+ * @return array
+ * An associative array containing the following keys:
+ * 'admin': An array of administrative paths and regular expressions
+ * in a format suitable for drupal_match_path().
+ * 'non_admin': An array of non-administrative paths and regular expressions.
+ *
+ * @see hook_admin_paths()
+ * @see hook_admin_paths_alter()
+ */
+function path_get_admin_paths() {
+ $patterns = &drupal_static(__FUNCTION__);
+ if (!isset($patterns)) {
+ $paths = module_invoke_all('admin_paths');
+ drupal_alter('admin_paths', $paths);
+ // Combine all admin paths into one array, and likewise for non-admin paths,
+ // for easier handling.
+ $patterns = array();
+ $patterns['admin'] = array();
+ $patterns['non_admin'] = array();
+ foreach ($paths as $path => $enabled) {
+ if ($enabled) {
+ $patterns['admin'][] = $path;
+ }
+ else {
+ $patterns['non_admin'][] = $path;
+ }
+ }
+ $patterns['admin'] = implode("\n", $patterns['admin']);
+ $patterns['non_admin'] = implode("\n", $patterns['non_admin']);
+ }
+ return $patterns;
+}
+
+/**
+ * Checks a path exists and the current user has access to it.
+ *
+ * @param $path
+ * The path to check.
+ * @param $dynamic_allowed
+ * Whether paths with menu wildcards (like user/%) should be allowed.
+ *
+ * @return
+ * TRUE if it is a valid path AND the current user has access permission,
+ * FALSE otherwise.
+ */
+function drupal_valid_path($path, $dynamic_allowed = FALSE) {
+ global $menu_admin;
+ // We indicate that a menu administrator is running the menu access check.
+ $menu_admin = TRUE;
+ if ($path == '<front>' || url_is_external($path)) {
+ $item = array('access' => TRUE);
+ }
+ elseif ($dynamic_allowed && preg_match('/\/\%/', $path)) {
+ // Path is dynamic (ie 'user/%'), so check directly against menu_router table.
+ if ($item = db_query("SELECT * FROM {menu_router} where path = :path", array(':path' => $path))->fetchAssoc()) {
+ $item['link_path'] = $form_item['link_path'];
+ $item['link_title'] = $form_item['link_title'];
+ $item['external'] = FALSE;
+ $item['options'] = '';
+ _menu_link_translate($item);
+ }
+ }
+ else {
+ $item = menu_get_item($path);
+ }
+ $menu_admin = FALSE;
+ return $item && $item['access'];
+}
+
+/**
+ * Clear the path cache.
+ *
+ * @param $source
+ * An optional system path for which an alias is being changed.
+ */
+function drupal_clear_path_cache($source = NULL) {
+ // Clear the drupal_lookup_path() static cache.
+ drupal_static_reset('drupal_lookup_path');
+ drupal_path_alias_whitelist_rebuild($source);
+}
diff --git a/core/includes/registry.inc b/core/includes/registry.inc
new file mode 100644
index 000000000000..8961f7a18b63
--- /dev/null
+++ b/core/includes/registry.inc
@@ -0,0 +1,186 @@
+<?php
+
+/**
+ * @file
+ * This file contains the code registry parser engine.
+ */
+
+/**
+ * @defgroup registry Code registry
+ * @{
+ * The code registry engine.
+ *
+ * Drupal maintains an internal registry of all functions or classes in the
+ * system, allowing it to lazy-load code files as needed (reducing the amount
+ * of code that must be parsed on each request).
+ */
+
+/**
+ * Does the work for registry_update().
+ */
+function _registry_update() {
+
+ // The registry serves as a central autoloader for all classes, including
+ // the database query builders. However, the registry rebuild process
+ // requires write ability to the database, which means having access to the
+ // query builders that require the registry in order to be loaded. That
+ // causes a fatal race condition. Therefore we manually include the
+ // appropriate query builders for the currently active database before the
+ // registry rebuild process runs.
+ $connection_info = Database::getConnectionInfo();
+ $driver = $connection_info['default']['driver'];
+ require_once DRUPAL_ROOT . '/core/includes/database/query.inc';
+ require_once DRUPAL_ROOT . '/core/includes/database/select.inc';
+ require_once DRUPAL_ROOT . '/core/includes/database/' . $driver . '/query.inc';
+
+ // Get current list of modules and their files.
+ $modules = db_query("SELECT * FROM {system} WHERE type = 'module'")->fetchAll();
+ // Get the list of files we are going to parse.
+ $files = array();
+ foreach ($modules as &$module) {
+ $module->info = unserialize($module->info);
+ $dir = dirname($module->filename);
+
+ // Store the module directory for use in hook_registry_files_alter().
+ $module->dir = $dir;
+
+ if ($module->status) {
+ // Add files for enabled modules to the registry.
+ foreach ($module->info['files'] as $file) {
+ $files["$dir/$file"] = array('module' => $module->name, 'weight' => $module->weight);
+ }
+ }
+ }
+ foreach (file_scan_directory('core/includes', '/\.inc$/') as $filename => $file) {
+ $files["$filename"] = array('module' => '', 'weight' => 0);
+ }
+
+ $transaction = db_transaction();
+ try {
+ // Allow modules to manually modify the list of files before the registry
+ // parses them. The $modules array provides the .info file information, which
+ // includes the list of files registered to each module. Any files in the
+ // list can then be added to the list of files that the registry will parse,
+ // or modify attributes of a file.
+ drupal_alter('registry_files', $files, $modules);
+ foreach (registry_get_parsed_files() as $filename => $file) {
+ // Add the hash for those files we have already parsed.
+ if (isset($files[$filename])) {
+ $files[$filename]['hash'] = $file['hash'];
+ }
+ else {
+ // Flush the registry of resources in files that are no longer on disc
+ // or are in files that no installed modules require to be parsed.
+ db_delete('registry')
+ ->condition('filename', $filename)
+ ->execute();
+ db_delete('registry_file')
+ ->condition('filename', $filename)
+ ->execute();
+ }
+ }
+ $parsed_files = _registry_parse_files($files);
+
+ $unchanged_resources = array();
+ $lookup_cache = array();
+ if ($cache = cache('bootstrap')->get('lookup_cache')) {
+ $lookup_cache = $cache->data;
+ }
+ foreach ($lookup_cache as $key => $file) {
+ // If the file for this cached resource is carried over unchanged from
+ // the last registry build, then we can safely re-cache it.
+ if ($file && in_array($file, array_keys($files)) && !in_array($file, $parsed_files)) {
+ $unchanged_resources[$key] = $file;
+ }
+ }
+ module_implements_reset();
+ _registry_check_code(REGISTRY_RESET_LOOKUP_CACHE);
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('registry', $e);
+ throw $e;
+ }
+
+ // We have some unchanged resources, warm up the cache - no need to pay
+ // for looking them up again.
+ if (count($unchanged_resources) > 0) {
+ cache('bootstrap')->set('lookup_cache', $unchanged_resources);
+ }
+}
+
+/**
+ * Return the list of files in registry_file
+ */
+function registry_get_parsed_files() {
+ $files = array();
+ // We want the result as a keyed array.
+ $files = db_query("SELECT * FROM {registry_file}")->fetchAllAssoc('filename', PDO::FETCH_ASSOC);
+ return $files;
+}
+
+/**
+ * Parse all files that have changed since the registry was last built, and save their function and class listings.
+ *
+ * @param $files
+ * The list of files to check and parse.
+ */
+function _registry_parse_files($files) {
+ $parsed_files = array();
+ foreach ($files as $filename => $file) {
+ if (file_exists($filename)) {
+ $hash = hash_file('sha256', $filename);
+ if (empty($file['hash']) || $file['hash'] != $hash) {
+ // Delete registry entries for this file, so we can insert the new resources.
+ db_delete('registry')
+ ->condition('filename', $filename)
+ ->execute();
+ $file['hash'] = $hash;
+ $parsed_files[$filename] = $file;
+ }
+ }
+ }
+ foreach ($parsed_files as $filename => $file) {
+ _registry_parse_file($filename, file_get_contents($filename), $file['module'], $file['weight']);
+ db_merge('registry_file')
+ ->key(array('filename' => $filename))
+ ->fields(array(
+ 'hash' => $file['hash'],
+ ))
+ ->execute();
+ }
+ return array_keys($parsed_files);
+}
+
+/**
+ * Parse a file and save its function and class listings.
+ *
+ * @param $filename
+ * Name of the file we are going to parse.
+ * @param $contents
+ * Contents of the file we are going to parse as a string.
+ * @param $module
+ * (optional) Name of the module this file belongs to.
+ * @param $weight
+ * (optional) Weight of the module.
+ */
+function _registry_parse_file($filename, $contents, $module = '', $weight = 0) {
+ if (preg_match_all('/^\s*(?:abstract|final)?\s*(class|interface)\s+([a-zA-Z0-9_]+)/m', $contents, $matches)) {
+ $query = db_insert('registry')->fields(array('name', 'type', 'filename', 'module', 'weight'));
+ foreach ($matches[2] as $key => $name) {
+ $query->values(array(
+ 'name' => $name,
+ 'type' => $matches[1][$key],
+ 'filename' => $filename,
+ 'module' => $module,
+ 'weight' => $weight,
+ ));
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * @} End of "defgroup registry".
+ */
+
diff --git a/core/includes/session.inc b/core/includes/session.inc
new file mode 100644
index 000000000000..df70f0e1ac25
--- /dev/null
+++ b/core/includes/session.inc
@@ -0,0 +1,493 @@
+<?php
+
+/**
+ * @file
+ * User session handling functions.
+ *
+ * The user-level session storage handlers:
+ * - _drupal_session_open()
+ * - _drupal_session_close()
+ * - _drupal_session_read()
+ * - _drupal_session_write()
+ * - _drupal_session_destroy()
+ * - _drupal_session_garbage_collection()
+ * are assigned by session_set_save_handler() in bootstrap.inc and are called
+ * automatically by PHP. These functions should not be called directly. Session
+ * data should instead be accessed via the $_SESSION superglobal.
+ */
+
+/**
+ * Session handler assigned by session_set_save_handler().
+ *
+ * This function is used to handle any initialization, such as file paths or
+ * database connections, that is needed before accessing session data. Drupal
+ * does not need to initialize anything in this function.
+ *
+ * This function should not be called directly.
+ *
+ * @return
+ * This function will always return TRUE.
+ */
+function _drupal_session_open() {
+ return TRUE;
+}
+
+/**
+ * Session handler assigned by session_set_save_handler().
+ *
+ * This function is used to close the current session. Because Drupal stores
+ * session data in the database immediately on write, this function does
+ * not need to do anything.
+ *
+ * This function should not be called directly.
+ *
+ * @return
+ * This function will always return TRUE.
+ */
+function _drupal_session_close() {
+ return TRUE;
+}
+
+/**
+ * Reads an entire session from the database (internal use only).
+ *
+ * Also initializes the $user object for the user associated with the session.
+ * This function is registered with session_set_save_handler() to support
+ * database-backed sessions. It is called on every page load when PHP sets
+ * up the $_SESSION superglobal.
+ *
+ * This function is an internal function and must not be called directly.
+ * Doing so may result in logging out the current user, corrupting session data
+ * or other unexpected behavior. Session data must always be accessed via the
+ * $_SESSION superglobal.
+ *
+ * @param $sid
+ * The session ID of the session to retrieve.
+ *
+ * @return
+ * The user's session, or an empty string if no session exists.
+ */
+function _drupal_session_read($sid) {
+ global $user, $is_https;
+
+ // Write and Close handlers are called after destructing objects
+ // since PHP 5.0.5.
+ // Thus destructors can use sessions but session handler can't use objects.
+ // So we are moving session closure before destructing objects.
+ drupal_register_shutdown_function('session_write_close');
+
+ // Handle the case of first time visitors and clients that don't store
+ // cookies (eg. web crawlers).
+ $insecure_session_name = substr(session_name(), 1);
+ if (!isset($_COOKIE[session_name()]) && !isset($_COOKIE[$insecure_session_name])) {
+ $user = drupal_anonymous_user();
+ return '';
+ }
+
+ // Otherwise, if the session is still active, we have a record of the
+ // client's session in the database. If it's HTTPS then we are either have
+ // a HTTPS session or we are about to log in so we check the sessions table
+ // for an anonymous session with the non-HTTPS-only cookie.
+ if ($is_https) {
+ $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.ssid = :ssid", array(':ssid' => $sid))->fetchObject();
+ if (!$user) {
+ if (isset($_COOKIE[$insecure_session_name])) {
+ $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid AND s.uid = 0", array(
+ ':sid' => $_COOKIE[$insecure_session_name]))
+ ->fetchObject();
+ }
+ }
+ }
+ else {
+ $user = db_query("SELECT u.*, s.* FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.sid = :sid", array(':sid' => $sid))->fetchObject();
+ }
+
+ // We found the client's session record and they are an authenticated,
+ // active user.
+ if ($user && $user->uid > 0 && $user->status == 1) {
+ // This is done to unserialize the data member of $user.
+ $user->data = unserialize($user->data);
+
+ // Add roles element to $user.
+ $user->roles = array();
+ $user->roles[DRUPAL_AUTHENTICATED_RID] = 'authenticated user';
+ $user->roles += db_query("SELECT r.rid, r.name FROM {role} r INNER JOIN {users_roles} ur ON ur.rid = r.rid WHERE ur.uid = :uid", array(':uid' => $user->uid))->fetchAllKeyed(0, 1);
+ }
+ elseif ($user) {
+ // The user is anonymous or blocked. Only preserve two fields from the
+ // {sessions} table.
+ $account = drupal_anonymous_user();
+ $account->session = $user->session;
+ $account->timestamp = $user->timestamp;
+ $user = $account;
+ }
+ else {
+ // The session has expired.
+ $user = drupal_anonymous_user();
+ $user->session = '';
+ }
+
+ // Store the session that was read for comparison in _drupal_session_write().
+ $last_read = &drupal_static('drupal_session_last_read');
+ $last_read = array(
+ 'sid' => $sid,
+ 'value' => $user->session,
+ );
+
+ return $user->session;
+}
+
+/**
+ * Writes an entire session to the database (internal use only).
+ *
+ * This function is registered with session_set_save_handler() to support
+ * database-backed sessions.
+ *
+ * This function is an internal function and must not be called directly.
+ * Doing so may result in corrupted session data or other unexpected behavior.
+ * Session data must always be accessed via the $_SESSION superglobal.
+ *
+ * @param $sid
+ * The session ID of the session to write to.
+ * @param $value
+ * Session data to write as a serialized string.
+ *
+ * @return
+ * Always returns TRUE.
+ */
+function _drupal_session_write($sid, $value) {
+ global $user, $is_https;
+
+ // The exception handler is not active at this point, so we need to do it
+ // manually.
+ try {
+ if (!drupal_save_session()) {
+ // We don't have anything to do if we are not allowed to save the session.
+ return;
+ }
+
+ // Check whether $_SESSION has been changed in this request.
+ $last_read = &drupal_static('drupal_session_last_read');
+ $is_changed = !isset($last_read) || $last_read['sid'] != $sid || $last_read['value'] !== $value;
+
+ // For performance reasons, do not update the sessions table, unless
+ // $_SESSION has changed or more than 180 has passed since the last update.
+ if ($is_changed || REQUEST_TIME - $user->timestamp > variable_get('session_write_interval', 180)) {
+ // Either ssid or sid or both will be added from $key below.
+ $fields = array(
+ 'uid' => $user->uid,
+ 'cache' => isset($user->cache) ? $user->cache : 0,
+ 'hostname' => ip_address(),
+ 'session' => $value,
+ 'timestamp' => REQUEST_TIME,
+ );
+
+ // Use the session ID as 'sid' and an empty string as 'ssid' by default.
+ // _drupal_session_read() does not allow empty strings so that's a safe
+ // default.
+ $key = array('sid' => $sid, 'ssid' => '');
+ // On HTTPS connections, use the session ID as both 'sid' and 'ssid'.
+ if ($is_https) {
+ $key['ssid'] = $sid;
+ // The "secure pages" setting allows a site to simultaneously use both
+ // secure and insecure session cookies. If enabled and both cookies are
+ // presented then use both keys.
+ if (variable_get('https', FALSE)) {
+ $insecure_session_name = substr(session_name(), 1);
+ if (isset($_COOKIE[$insecure_session_name])) {
+ $key['sid'] = $_COOKIE[$insecure_session_name];
+ }
+ }
+ }
+
+ db_merge('sessions')
+ ->key($key)
+ ->fields($fields)
+ ->execute();
+ }
+
+ // Likewise, do not update access time more than once per 180 seconds.
+ if ($user->uid && REQUEST_TIME - $user->access > variable_get('session_write_interval', 180)) {
+ db_update('users')
+ ->fields(array(
+ 'access' => REQUEST_TIME
+ ))
+ ->condition('uid', $user->uid)
+ ->execute();
+ }
+
+ return TRUE;
+ }
+ catch (Exception $exception) {
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ // If we are displaying errors, then do so with no possibility of a further
+ // uncaught exception being thrown.
+ if (error_displayable()) {
+ print '<h1>Uncaught exception thrown in session handler.</h1>';
+ print '<p>' . _drupal_render_exception_safe($exception) . '</p><hr />';
+ }
+ return FALSE;
+ }
+}
+
+/**
+ * Initializes the session handler, starting a session if needed.
+ */
+function drupal_session_initialize() {
+ global $user, $is_https;
+
+ session_set_save_handler('_drupal_session_open', '_drupal_session_close', '_drupal_session_read', '_drupal_session_write', '_drupal_session_destroy', '_drupal_session_garbage_collection');
+
+ // We use !empty() in the following check to ensure that blank session IDs
+ // are not valid.
+ if (!empty($_COOKIE[session_name()]) || ($is_https && variable_get('https', FALSE) && !empty($_COOKIE[substr(session_name(), 1)]))) {
+ // If a session cookie exists, initialize the session. Otherwise the
+ // session is only started on demand in drupal_session_commit(), making
+ // anonymous users not use a session cookie unless something is stored in
+ // $_SESSION. This allows HTTP proxies to cache anonymous pageviews.
+ drupal_session_start();
+ if (!empty($user->uid) || !empty($_SESSION)) {
+ drupal_page_is_cacheable(FALSE);
+ }
+ }
+ else {
+ // Set a session identifier for this request. This is necessary because
+ // we lazily start sessions at the end of this request, and some
+ // processes (like drupal_get_token()) needs to know the future
+ // session ID in advance.
+ $user = drupal_anonymous_user();
+ // Less random sessions (which are much faster to generate) are used for
+ // anonymous users than are generated in drupal_session_regenerate() when
+ // a user becomes authenticated.
+ session_id(drupal_hash_base64(uniqid(mt_rand(), TRUE)));
+ }
+ date_default_timezone_set(drupal_get_user_timezone());
+}
+
+/**
+ * Forcefully starts a session, preserving already set session data.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_session_start() {
+ // Command line clients do not support cookies nor sessions.
+ if (!drupal_session_started() && !drupal_is_cli()) {
+ // Save current session data before starting it, as PHP will destroy it.
+ $session_data = isset($_SESSION) ? $_SESSION : NULL;
+
+ session_start();
+ drupal_session_started(TRUE);
+
+ // Restore session data.
+ if (!empty($session_data)) {
+ $_SESSION += $session_data;
+ }
+ }
+}
+
+/**
+ * Commits the current session, if necessary.
+ *
+ * If an anonymous user already have an empty session, destroy it.
+ */
+function drupal_session_commit() {
+ global $user;
+
+ if (!drupal_save_session()) {
+ // We don't have anything to do if we are not allowed to save the session.
+ return;
+ }
+
+ if (empty($user->uid) && empty($_SESSION)) {
+ // There is no session data to store, destroy the session if it was
+ // previously started.
+ if (drupal_session_started()) {
+ session_destroy();
+ }
+ }
+ else {
+ // There is session data to store. Start the session if it is not already
+ // started.
+ if (!drupal_session_started()) {
+ drupal_session_start();
+ }
+ // Write the session data.
+ session_write_close();
+ }
+}
+
+/**
+ * Returns whether a session has been started.
+ */
+function drupal_session_started($set = NULL) {
+ static $session_started = FALSE;
+ if (isset($set)) {
+ $session_started = $set;
+ }
+ return $session_started && session_id();
+}
+
+/**
+ * Called when an anonymous user becomes authenticated or vice-versa.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_session_regenerate() {
+ global $user, $is_https;
+ if ($is_https && variable_get('https', FALSE)) {
+ $insecure_session_name = substr(session_name(), 1);
+ if (isset($_COOKIE[$insecure_session_name])) {
+ $old_insecure_session_id = $_COOKIE[$insecure_session_name];
+ }
+ $params = session_get_cookie_params();
+ $session_id = drupal_hash_base64(uniqid(mt_rand(), TRUE) . drupal_random_bytes(55));
+ // If a session cookie lifetime is set, the session will expire
+ // $params['lifetime'] seconds from the current request. If it is not set,
+ // it will expire when the browser is closed.
+ $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
+ setcookie($insecure_session_name, $session_id, $expire, $params['path'], $params['domain'], FALSE, $params['httponly']);
+ $_COOKIE[$insecure_session_name] = $session_id;
+ }
+
+ if (drupal_session_started()) {
+ $old_session_id = session_id();
+ }
+ session_id(drupal_hash_base64(uniqid(mt_rand(), TRUE) . drupal_random_bytes(55)));
+
+ if (isset($old_session_id)) {
+ $params = session_get_cookie_params();
+ $expire = $params['lifetime'] ? REQUEST_TIME + $params['lifetime'] : 0;
+ setcookie(session_name(), session_id(), $expire, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
+ $fields = array('sid' => session_id());
+ if ($is_https) {
+ $fields['ssid'] = session_id();
+ // If the "secure pages" setting is enabled, use the newly-created
+ // insecure session identifier as the regenerated sid.
+ if (variable_get('https', FALSE)) {
+ $fields['sid'] = $session_id;
+ }
+ }
+ db_update('sessions')
+ ->fields($fields)
+ ->condition($is_https ? 'ssid' : 'sid', $old_session_id)
+ ->execute();
+ }
+ elseif (isset($old_insecure_session_id)) {
+ // If logging in to the secure site, and there was no active session on the
+ // secure site but a session was active on the insecure site, update the
+ // insecure session with the new session identifiers.
+ db_update('sessions')
+ ->fields(array('sid' => $session_id, 'ssid' => session_id()))
+ ->condition('sid', $old_insecure_session_id)
+ ->execute();
+ }
+ else {
+ // Start the session when it doesn't exist yet.
+ // Preserve the logged in user, as it will be reset to anonymous
+ // by _drupal_session_read.
+ $account = $user;
+ drupal_session_start();
+ $user = $account;
+ }
+ date_default_timezone_set(drupal_get_user_timezone());
+}
+
+/**
+ * Session handler assigned by session_set_save_handler().
+ *
+ * Cleans up a specific session.
+ *
+ * @param $sid
+ * Session ID.
+ */
+function _drupal_session_destroy($sid) {
+ global $user, $is_https;
+
+ // Delete session data.
+ db_delete('sessions')
+ ->condition($is_https ? 'ssid' : 'sid', $sid)
+ ->execute();
+
+ // Reset $_SESSION and $user to prevent a new session from being started
+ // in drupal_session_commit().
+ $_SESSION = array();
+ $user = drupal_anonymous_user();
+
+ // Unset the session cookies.
+ _drupal_session_delete_cookie(session_name());
+ if ($is_https) {
+ _drupal_session_delete_cookie(substr(session_name(), 1), TRUE);
+ }
+}
+
+/**
+ * Deletes the session cookie.
+ *
+ * @param $name
+ * Name of session cookie to delete.
+ * @param $force_insecure
+ * Force cookie to be insecure.
+ */
+function _drupal_session_delete_cookie($name, $force_insecure = FALSE) {
+ if (isset($_COOKIE[$name])) {
+ $params = session_get_cookie_params();
+ setcookie($name, '', REQUEST_TIME - 3600, $params['path'], $params['domain'], !$force_insecure && $params['secure'], $params['httponly']);
+ unset($_COOKIE[$name]);
+ }
+}
+
+/**
+ * Ends a specific user's session(s).
+ *
+ * @param $uid
+ * User ID.
+ */
+function drupal_session_destroy_uid($uid) {
+ db_delete('sessions')
+ ->condition('uid', $uid)
+ ->execute();
+}
+
+/**
+ * Session handler assigned by session_set_save_handler().
+ *
+ * Cleans up stalled sessions.
+ *
+ * @param $lifetime
+ * The value of session.gc_maxlifetime, passed by PHP.
+ * Sessions not updated for more than $lifetime seconds will be removed.
+ */
+function _drupal_session_garbage_collection($lifetime) {
+ // Be sure to adjust 'php_value session.gc_maxlifetime' to a large enough
+ // value. For example, if you want user sessions to stay in your database
+ // for three weeks before deleting them, you need to set gc_maxlifetime
+ // to '1814400'. At that value, only after a user doesn't log in after
+ // three weeks (1814400 seconds) will his/her session be removed.
+ db_delete('sessions')
+ ->condition('timestamp', REQUEST_TIME - $lifetime, '<')
+ ->execute();
+ return TRUE;
+}
+
+/**
+ * Determines whether to save session data of the current request.
+ *
+ * This function allows the caller to temporarily disable writing of
+ * session data, should the request end while performing potentially
+ * dangerous operations, such as manipulating the global $user object.
+ * See http://drupal.org/node/218104 for usage.
+ *
+ * @param $status
+ * Disables writing of session data when FALSE, (re-)enables
+ * writing when TRUE.
+ *
+ * @return
+ * FALSE if writing session data has been disabled. Otherwise, TRUE.
+ */
+function drupal_save_session($status = NULL) {
+ $save_session = &drupal_static(__FUNCTION__, TRUE);
+ if (isset($status)) {
+ $save_session = $status;
+ }
+ return $save_session;
+}
diff --git a/core/includes/standard.inc b/core/includes/standard.inc
new file mode 100644
index 000000000000..38b5d153740c
--- /dev/null
+++ b/core/includes/standard.inc
@@ -0,0 +1,401 @@
+<?php
+
+/**
+ * @file
+ * Provides a list of countries and languages based on web standards.
+ */
+
+/**
+ * Get an array of all country code => country name pairs.
+ *
+ * Get an array of all country code => country name pairs as laid out
+ * in ISO 3166-1 alpha-2. Originally from the location project
+ * (http://drupal.org/project/location).
+ *
+ * @return
+ * An array of country code => country name pairs.
+ */
+function standard_country_list() {
+ static $countries;
+
+ if (isset($countries)) {
+ return $countries;
+ }
+ $t = get_t();
+
+ $countries = array(
+ 'AD' => $t('Andorra'),
+ 'AE' => $t('United Arab Emirates'),
+ 'AF' => $t('Afghanistan'),
+ 'AG' => $t('Antigua and Barbuda'),
+ 'AI' => $t('Anguilla'),
+ 'AL' => $t('Albania'),
+ 'AM' => $t('Armenia'),
+ 'AN' => $t('Netherlands Antilles'),
+ 'AO' => $t('Angola'),
+ 'AQ' => $t('Antarctica'),
+ 'AR' => $t('Argentina'),
+ 'AS' => $t('American Samoa'),
+ 'AT' => $t('Austria'),
+ 'AU' => $t('Australia'),
+ 'AW' => $t('Aruba'),
+ 'AX' => $t('Aland Islands'),
+ 'AZ' => $t('Azerbaijan'),
+ 'BA' => $t('Bosnia and Herzegovina'),
+ 'BB' => $t('Barbados'),
+ 'BD' => $t('Bangladesh'),
+ 'BE' => $t('Belgium'),
+ 'BF' => $t('Burkina Faso'),
+ 'BG' => $t('Bulgaria'),
+ 'BH' => $t('Bahrain'),
+ 'BI' => $t('Burundi'),
+ 'BJ' => $t('Benin'),
+ 'BL' => $t('Saint Barthélemy'),
+ 'BM' => $t('Bermuda'),
+ 'BN' => $t('Brunei'),
+ 'BO' => $t('Bolivia'),
+ 'BR' => $t('Brazil'),
+ 'BS' => $t('Bahamas'),
+ 'BT' => $t('Bhutan'),
+ 'BV' => $t('Bouvet Island'),
+ 'BW' => $t('Botswana'),
+ 'BY' => $t('Belarus'),
+ 'BZ' => $t('Belize'),
+ 'CA' => $t('Canada'),
+ 'CC' => $t('Cocos (Keeling) Islands'),
+ 'CD' => $t('Congo (Kinshasa)'),
+ 'CF' => $t('Central African Republic'),
+ 'CG' => $t('Congo (Brazzaville)'),
+ 'CH' => $t('Switzerland'),
+ 'CI' => $t('Ivory Coast'),
+ 'CK' => $t('Cook Islands'),
+ 'CL' => $t('Chile'),
+ 'CM' => $t('Cameroon'),
+ 'CN' => $t('China'),
+ 'CO' => $t('Colombia'),
+ 'CR' => $t('Costa Rica'),
+ 'CU' => $t('Cuba'),
+ 'CV' => $t('Cape Verde'),
+ 'CX' => $t('Christmas Island'),
+ 'CY' => $t('Cyprus'),
+ 'CZ' => $t('Czech Republic'),
+ 'DE' => $t('Germany'),
+ 'DJ' => $t('Djibouti'),
+ 'DK' => $t('Denmark'),
+ 'DM' => $t('Dominica'),
+ 'DO' => $t('Dominican Republic'),
+ 'DZ' => $t('Algeria'),
+ 'EC' => $t('Ecuador'),
+ 'EE' => $t('Estonia'),
+ 'EG' => $t('Egypt'),
+ 'EH' => $t('Western Sahara'),
+ 'ER' => $t('Eritrea'),
+ 'ES' => $t('Spain'),
+ 'ET' => $t('Ethiopia'),
+ 'FI' => $t('Finland'),
+ 'FJ' => $t('Fiji'),
+ 'FK' => $t('Falkland Islands'),
+ 'FM' => $t('Micronesia'),
+ 'FO' => $t('Faroe Islands'),
+ 'FR' => $t('France'),
+ 'GA' => $t('Gabon'),
+ 'GB' => $t('United Kingdom'),
+ 'GD' => $t('Grenada'),
+ 'GE' => $t('Georgia'),
+ 'GF' => $t('French Guiana'),
+ 'GG' => $t('Guernsey'),
+ 'GH' => $t('Ghana'),
+ 'GI' => $t('Gibraltar'),
+ 'GL' => $t('Greenland'),
+ 'GM' => $t('Gambia'),
+ 'GN' => $t('Guinea'),
+ 'GP' => $t('Guadeloupe'),
+ 'GQ' => $t('Equatorial Guinea'),
+ 'GR' => $t('Greece'),
+ 'GS' => $t('South Georgia and the South Sandwich Islands'),
+ 'GT' => $t('Guatemala'),
+ 'GU' => $t('Guam'),
+ 'GW' => $t('Guinea-Bissau'),
+ 'GY' => $t('Guyana'),
+ 'HK' => $t('Hong Kong S.A.R., China'),
+ 'HM' => $t('Heard Island and McDonald Islands'),
+ 'HN' => $t('Honduras'),
+ 'HR' => $t('Croatia'),
+ 'HT' => $t('Haiti'),
+ 'HU' => $t('Hungary'),
+ 'ID' => $t('Indonesia'),
+ 'IE' => $t('Ireland'),
+ 'IL' => $t('Israel'),
+ 'IM' => $t('Isle of Man'),
+ 'IN' => $t('India'),
+ 'IO' => $t('British Indian Ocean Territory'),
+ 'IQ' => $t('Iraq'),
+ 'IR' => $t('Iran'),
+ 'IS' => $t('Iceland'),
+ 'IT' => $t('Italy'),
+ 'JE' => $t('Jersey'),
+ 'JM' => $t('Jamaica'),
+ 'JO' => $t('Jordan'),
+ 'JP' => $t('Japan'),
+ 'KE' => $t('Kenya'),
+ 'KG' => $t('Kyrgyzstan'),
+ 'KH' => $t('Cambodia'),
+ 'KI' => $t('Kiribati'),
+ 'KM' => $t('Comoros'),
+ 'KN' => $t('Saint Kitts and Nevis'),
+ 'KP' => $t('North Korea'),
+ 'KR' => $t('South Korea'),
+ 'KW' => $t('Kuwait'),
+ 'KY' => $t('Cayman Islands'),
+ 'KZ' => $t('Kazakhstan'),
+ 'LA' => $t('Laos'),
+ 'LB' => $t('Lebanon'),
+ 'LC' => $t('Saint Lucia'),
+ 'LI' => $t('Liechtenstein'),
+ 'LK' => $t('Sri Lanka'),
+ 'LR' => $t('Liberia'),
+ 'LS' => $t('Lesotho'),
+ 'LT' => $t('Lithuania'),
+ 'LU' => $t('Luxembourg'),
+ 'LV' => $t('Latvia'),
+ 'LY' => $t('Libya'),
+ 'MA' => $t('Morocco'),
+ 'MC' => $t('Monaco'),
+ 'MD' => $t('Moldova'),
+ 'ME' => $t('Montenegro'),
+ 'MF' => $t('Saint Martin (French part)'),
+ 'MG' => $t('Madagascar'),
+ 'MH' => $t('Marshall Islands'),
+ 'MK' => $t('Macedonia'),
+ 'ML' => $t('Mali'),
+ 'MM' => $t('Myanmar'),
+ 'MN' => $t('Mongolia'),
+ 'MO' => $t('Macao S.A.R., China'),
+ 'MP' => $t('Northern Mariana Islands'),
+ 'MQ' => $t('Martinique'),
+ 'MR' => $t('Mauritania'),
+ 'MS' => $t('Montserrat'),
+ 'MT' => $t('Malta'),
+ 'MU' => $t('Mauritius'),
+ 'MV' => $t('Maldives'),
+ 'MW' => $t('Malawi'),
+ 'MX' => $t('Mexico'),
+ 'MY' => $t('Malaysia'),
+ 'MZ' => $t('Mozambique'),
+ 'NA' => $t('Namibia'),
+ 'NC' => $t('New Caledonia'),
+ 'NE' => $t('Niger'),
+ 'NF' => $t('Norfolk Island'),
+ 'NG' => $t('Nigeria'),
+ 'NI' => $t('Nicaragua'),
+ 'NL' => $t('Netherlands'),
+ 'NO' => $t('Norway'),
+ 'NP' => $t('Nepal'),
+ 'NR' => $t('Nauru'),
+ 'NU' => $t('Niue'),
+ 'NZ' => $t('New Zealand'),
+ 'OM' => $t('Oman'),
+ 'PA' => $t('Panama'),
+ 'PE' => $t('Peru'),
+ 'PF' => $t('French Polynesia'),
+ 'PG' => $t('Papua New Guinea'),
+ 'PH' => $t('Philippines'),
+ 'PK' => $t('Pakistan'),
+ 'PL' => $t('Poland'),
+ 'PM' => $t('Saint Pierre and Miquelon'),
+ 'PN' => $t('Pitcairn'),
+ 'PR' => $t('Puerto Rico'),
+ 'PS' => $t('Palestinian Territory'),
+ 'PT' => $t('Portugal'),
+ 'PW' => $t('Palau'),
+ 'PY' => $t('Paraguay'),
+ 'QA' => $t('Qatar'),
+ 'RE' => $t('Reunion'),
+ 'RO' => $t('Romania'),
+ 'RS' => $t('Serbia'),
+ 'RU' => $t('Russia'),
+ 'RW' => $t('Rwanda'),
+ 'SA' => $t('Saudi Arabia'),
+ 'SB' => $t('Solomon Islands'),
+ 'SC' => $t('Seychelles'),
+ 'SD' => $t('Sudan'),
+ 'SE' => $t('Sweden'),
+ 'SG' => $t('Singapore'),
+ 'SH' => $t('Saint Helena'),
+ 'SI' => $t('Slovenia'),
+ 'SJ' => $t('Svalbard and Jan Mayen'),
+ 'SK' => $t('Slovakia'),
+ 'SL' => $t('Sierra Leone'),
+ 'SM' => $t('San Marino'),
+ 'SN' => $t('Senegal'),
+ 'SO' => $t('Somalia'),
+ 'SR' => $t('Suriname'),
+ 'ST' => $t('Sao Tome and Principe'),
+ 'SV' => $t('El Salvador'),
+ 'SY' => $t('Syria'),
+ 'SZ' => $t('Swaziland'),
+ 'TC' => $t('Turks and Caicos Islands'),
+ 'TD' => $t('Chad'),
+ 'TF' => $t('French Southern Territories'),
+ 'TG' => $t('Togo'),
+ 'TH' => $t('Thailand'),
+ 'TJ' => $t('Tajikistan'),
+ 'TK' => $t('Tokelau'),
+ 'TL' => $t('Timor-Leste'),
+ 'TM' => $t('Turkmenistan'),
+ 'TN' => $t('Tunisia'),
+ 'TO' => $t('Tonga'),
+ 'TR' => $t('Turkey'),
+ 'TT' => $t('Trinidad and Tobago'),
+ 'TV' => $t('Tuvalu'),
+ 'TW' => $t('Taiwan'),
+ 'TZ' => $t('Tanzania'),
+ 'UA' => $t('Ukraine'),
+ 'UG' => $t('Uganda'),
+ 'UM' => $t('United States Minor Outlying Islands'),
+ 'US' => $t('United States'),
+ 'UY' => $t('Uruguay'),
+ 'UZ' => $t('Uzbekistan'),
+ 'VA' => $t('Vatican'),
+ 'VC' => $t('Saint Vincent and the Grenadines'),
+ 'VE' => $t('Venezuela'),
+ 'VG' => $t('British Virgin Islands'),
+ 'VI' => $t('U.S. Virgin Islands'),
+ 'VN' => $t('Vietnam'),
+ 'VU' => $t('Vanuatu'),
+ 'WF' => $t('Wallis and Futuna'),
+ 'WS' => $t('Samoa'),
+ 'YE' => $t('Yemen'),
+ 'YT' => $t('Mayotte'),
+ 'ZA' => $t('South Africa'),
+ 'ZM' => $t('Zambia'),
+ 'ZW' => $t('Zimbabwe'),
+ );
+
+ // Sort the list.
+ natcasesort($countries);
+
+ return $countries;
+}
+
+/**
+ * Some common languages with their English and native names.
+ *
+ * Language codes are defined by the W3C language tags document for
+ * interoperability. Language codes typically have a language and optionally,
+ * a script or regional variant name. See
+ * http://www.w3.org/International/articles/language-tags/ for more information.
+ *
+ * This list is based on languages available from localize.drupal.org. See
+ * http://localize.drupal.org/issues for information on how to add languages
+ * there.
+ *
+ * The "Left-to-right marker" comments and the enclosed UTF-8 markers are to
+ * make otherwise strange looking PHP syntax natural (to not be displayed in
+ * right to left). See http://drupal.org/node/128866#comment-528929.
+ *
+ * @return
+ * An array of language code to language name information.
+ * Language name information itself is an array of English and native names.
+ */
+function standard_language_list() {
+ return array(
+ 'af' => array('Afrikaans', 'Afrikaans'),
+ 'am' => array('Amharic', 'አማርኛ'),
+ 'ar' => array('Arabic', /* Left-to-right marker "‭" */ 'العربية', LANGUAGE_RTL),
+ 'ast' => array('Asturian', 'Asturianu'),
+ 'az' => array('Azerbaijani', 'Azərbaycanca'),
+ 'be' => array('Belarusian', 'Беларуская'),
+ 'bg' => array('Bulgarian', 'Български'),
+ 'bn' => array('Bengali', 'বাংলা'),
+ 'bo' => array('Tibetan', 'བོད་སྐད་'),
+ 'bs' => array('Bosnian', 'Bosanski'),
+ 'ca' => array('Catalan', 'Català'),
+ 'cs' => array('Czech', 'Čeština'),
+ 'cy' => array('Welsh', 'Cymraeg'),
+ 'da' => array('Danish', 'Dansk'),
+ 'de' => array('German', 'Deutsch'),
+ 'dz' => array('Dzongkha', 'རྫོང་ཁ'),
+ 'el' => array('Greek', 'Ελληνικά'),
+ 'en' => array('English', 'English'),
+ 'en-gb' => array('English, British', 'English, British'),
+ 'eo' => array('Esperanto', 'Esperanto'),
+ 'es' => array('Spanish', 'Español'),
+ 'et' => array('Estonian', 'Eesti'),
+ 'eu' => array('Basque', 'Euskera'),
+ 'fa' => array('Persian, Farsi', /* Left-to-right marker "‭" */ 'فارسی', LANGUAGE_RTL),
+ 'fi' => array('Finnish', 'Suomi'),
+ 'fil' => array('Filipino', 'Filipino'),
+ 'fo' => array('Faeroese', 'Føroyskt'),
+ 'fr' => array('French', 'Français'),
+ 'ga' => array('Irish', 'Gaeilge'),
+ 'gd' => array('Scots Gaelic', 'Gàidhlig'),
+ 'gl' => array('Galician', 'Galego'),
+ 'gsw-berne' => array('Swiss German', 'Schwyzerdütsch'),
+ 'gu' => array('Gujarati', 'ગુજરાતી'),
+ 'he' => array('Hebrew', /* Left-to-right marker "‭" */ 'עברית', LANGUAGE_RTL),
+ 'hi' => array('Hindi', 'हिन्दी'),
+ 'hr' => array('Croatian', 'Hrvatski'),
+ 'ht' => array('Haitian Creole', 'Kreyòl ayisyen'),
+ 'hu' => array('Hungarian', 'Magyar'),
+ 'hy' => array('Armenian', 'Հայերեն'),
+ 'id' => array('Indonesian', 'Bahasa Indonesia'),
+ 'is' => array('Icelandic', 'Íslenska'),
+ 'it' => array('Italian', 'Italiano'),
+ 'ja' => array('Japanese', '日本語'),
+ 'jv' => array('Javanese', 'Basa Java'),
+ 'ka' => array('Georgian', 'ქართული ენა'),
+ 'kk' => array('Kazakh', 'Қазақ'),
+ 'kn' => array('Kannada', 'ಕನ್ನಡ'),
+ 'ko' => array('Korean', '한국어'),
+ 'ku' => array('Kurdish', 'Kurdî'),
+ 'ky' => array('Kyrgyz', 'Кыргызча'),
+ 'lo' => array('Lao', 'ພາສາລາວ'),
+ 'lt' => array('Lithuanian', 'Lietuvių'),
+ 'lv' => array('Latvian', 'Latviešu'),
+ 'mfe' => array('Mauritian Creole', 'Kreol Morisyen'),
+ 'mg' => array('Malagasy', 'Malagasy'),
+ 'mi' => array('Maori', 'Māori'),
+ 'mk' => array('Macedonian', 'Македонски'),
+ 'ml' => array('Malayalam', 'മലയാളം'),
+ 'mn' => array('Mongolian', 'монгол'),
+ 'mr' => array('Marathi', 'मराठी'),
+ 'mt' => array('Maltese', 'Malti'),
+ 'my' => array('Burmese', 'ဗမာစကား'),
+ 'ne' => array('Nepali', 'नेपाली'),
+ 'nl' => array('Dutch', 'Nederlands'),
+ 'nb' => array('Norwegian Bokmål', 'Bokmål'),
+ 'nn' => array('Norwegian Nynorsk', 'Nynorsk'),
+ 'oc' => array('Occitan', 'Occitan'),
+ 'or' => array('Oriya', 'ଓଡ଼ିଆ'),
+ 'pa' => array('Punjabi', 'ਪੰਜਾਬੀ'),
+ 'pl' => array('Polish', 'Polski'),
+ 'pt' => array('Portuguese, International', 'Português, Internacional'),
+ 'pt-pt' => array('Portuguese, Portugal', 'Português, Portugal'),
+ 'pt-br' => array('Portuguese, Brazil', 'Português, Brasil'),
+ 'ro' => array('Romanian', 'Română'),
+ 'ru' => array('Russian', 'Русский'),
+ 'sco' => array('Scots', 'Scots'),
+ 'se' => array('Northern Sami', 'Sámi'),
+ 'si' => array('Sinhala', 'සිංහල'),
+ 'sk' => array('Slovak', 'Slovenčina'),
+ 'sl' => array('Slovenian', 'Slovenščina'),
+ 'sq' => array('Albanian', 'Shqip'),
+ 'sr' => array('Serbian', 'Српски'),
+ 'sv' => array('Swedish', 'Svenska'),
+ 'sw' => array('Swahili', 'Kiswahili'),
+ 'ta' => array('Tamil', 'தமிழ்'),
+ 'ta-lk' => array('Tamil, Sri Lanka', 'தமிழ், இலங்கை'),
+ 'te' => array('Telugu', 'తెలుగు'),
+ 'th' => array('Thai', 'ภาษาไทย'),
+ 'ti' => array('Tigrinya', 'ትግርኛ'),
+ 'tr' => array('Turkish', 'Türkçe'),
+ 'ug' => array('Uighur', 'Уйғур'),
+ 'uk' => array('Ukrainian', 'Українська'),
+ 'ur' => array('Urdu', /* Left-to-right marker "‭" */ 'اردو', LANGUAGE_RTL),
+ 'vi' => array('Vietnamese', 'Tiếng Việt'),
+ 'xx-lolspeak' => array('Lolspeak', 'Lolspeak'),
+ 'zh-hans' => array('Chinese, Simplified', '简体中文'),
+ 'zh-hant' => array('Chinese, Traditional', '繁體中文'),
+ );
+}
diff --git a/core/includes/stream_wrappers.inc b/core/includes/stream_wrappers.inc
new file mode 100644
index 000000000000..650d94db1ea7
--- /dev/null
+++ b/core/includes/stream_wrappers.inc
@@ -0,0 +1,836 @@
+<?php
+
+/**
+ * @file
+ * Drupal stream wrapper interface.
+ *
+ * Provides a Drupal interface and classes to implement PHP stream wrappers for
+ * public, private, and temporary files.
+ *
+ * A stream wrapper is an abstraction of a file system that allows Drupal to
+ * use the same set of methods to access both local files and remote resources.
+ *
+ * Note that PHP 5.2 fopen() only supports URIs of the form "scheme://target"
+ * despite the fact that according to RFC 3986 a URI's scheme component
+ * delimiter is in general just ":", not "://". Because of this PHP limitation
+ * and for consistency Drupal will only accept URIs of form "scheme://target".
+ *
+ * @see http://www.faqs.org/rfcs/rfc3986.html
+ * @see http://bugs.php.net/bug.php?id=47070
+ */
+
+/**
+ * Stream wrapper bit flags that are the basis for composite types.
+ *
+ * Note that 0x0002 is skipped, because it was the value of a constant that has
+ * since been removed.
+ */
+
+/**
+ * Stream wrapper bit flag -- a filter that matches all wrappers.
+ */
+define('STREAM_WRAPPERS_ALL', 0x0000);
+
+/**
+ * Stream wrapper bit flag -- refers to a local file system location.
+ */
+define('STREAM_WRAPPERS_LOCAL', 0x0001);
+
+/**
+ * Stream wrapper bit flag -- wrapper is readable (almost always true).
+ */
+define('STREAM_WRAPPERS_READ', 0x0004);
+
+/**
+ * Stream wrapper bit flag -- wrapper is writeable.
+ */
+define('STREAM_WRAPPERS_WRITE', 0x0008);
+
+/**
+ * Stream wrapper bit flag -- exposed in the UI and potentially web accessible.
+ */
+define('STREAM_WRAPPERS_VISIBLE', 0x0010);
+
+/**
+ * Composite stream wrapper bit flags that are usually used as the types.
+ */
+
+/**
+ * Stream wrapper type flag -- not visible in the UI or accessible via web,
+ * but readable and writable. E.g. the temporary directory for uploads.
+ */
+define('STREAM_WRAPPERS_HIDDEN', STREAM_WRAPPERS_READ | STREAM_WRAPPERS_WRITE);
+
+/**
+ * Stream wrapper type flag -- hidden, readable and writeable using local files.
+ */
+define('STREAM_WRAPPERS_LOCAL_HIDDEN', STREAM_WRAPPERS_LOCAL | STREAM_WRAPPERS_HIDDEN);
+
+/**
+ * Stream wrapper type flag -- visible, readable and writeable.
+ */
+define('STREAM_WRAPPERS_WRITE_VISIBLE', STREAM_WRAPPERS_READ | STREAM_WRAPPERS_WRITE | STREAM_WRAPPERS_VISIBLE);
+
+/**
+ * Stream wrapper type flag -- visible and read-only.
+ */
+define('STREAM_WRAPPERS_READ_VISIBLE', STREAM_WRAPPERS_READ | STREAM_WRAPPERS_VISIBLE);
+
+/**
+ * Stream wrapper type flag -- the default when 'type' is omitted from
+ * hook_stream_wrappers(). This does not include STREAM_WRAPPERS_LOCAL,
+ * because PHP grants a greater trust level to local files (for example, they
+ * can be used in an "include" statement, regardless of the "allow_url_include"
+ * setting), so stream wrappers need to explicitly opt-in to this.
+ */
+define('STREAM_WRAPPERS_NORMAL', STREAM_WRAPPERS_WRITE_VISIBLE);
+
+/**
+ * Stream wrapper type flag -- visible, readable and writeable using local files.
+ */
+define('STREAM_WRAPPERS_LOCAL_NORMAL', STREAM_WRAPPERS_LOCAL | STREAM_WRAPPERS_NORMAL);
+
+/**
+ * Generic PHP stream wrapper interface.
+ *
+ * @see http://www.php.net/manual/en/class.streamwrapper.php
+ */
+interface StreamWrapperInterface {
+ public function stream_open($uri, $mode, $options, &$opened_url);
+ public function stream_close();
+ public function stream_lock($operation);
+ public function stream_read($count);
+ public function stream_write($data);
+ public function stream_eof();
+ public function stream_seek($offset, $whence);
+ public function stream_flush();
+ public function stream_tell();
+ public function stream_stat();
+ public function unlink($uri);
+ public function rename($from_uri, $to_uri);
+ public function mkdir($uri, $mode, $options);
+ public function rmdir($uri, $options);
+ public function url_stat($uri, $flags);
+ public function dir_opendir($uri, $options);
+ public function dir_readdir();
+ public function dir_rewinddir();
+ public function dir_closedir();
+}
+
+/**
+ * Drupal stream wrapper extension.
+ *
+ * Extend the StreamWrapperInterface with methods expected by Drupal stream
+ * wrapper classes.
+ */
+interface DrupalStreamWrapperInterface extends StreamWrapperInterface {
+ /**
+ * Set the absolute stream resource URI.
+ *
+ * This allows you to set the URI. Generally is only called by the factory
+ * method.
+ *
+ * @param $uri
+ * A string containing the URI that should be used for this instance.
+ */
+ function setUri($uri);
+
+ /**
+ * Returns the stream resource URI.
+ *
+ * @return
+ * Returns the current URI of the instance.
+ */
+ public function getUri();
+
+ /**
+ * Returns a web accessible URL for the resource.
+ *
+ * This function should return a URL that can be embedded in a web page
+ * and accessed from a browser. For example, the external URL of
+ * "youtube://xIpLd0WQKCY" might be
+ * "http://www.youtube.com/watch?v=xIpLd0WQKCY".
+ *
+ * @return
+ * Returns a string containing a web accessible URL for the resource.
+ */
+ public function getExternalUrl();
+
+ /**
+ * Returns the MIME type of the resource.
+ *
+ * @param $uri
+ * The URI, path, or filename.
+ * @param $mapping
+ * An optional map of extensions to their mimetypes, in the form:
+ * - 'mimetypes': a list of mimetypes, keyed by an identifier,
+ * - 'extensions': the mapping itself, an associative array in which
+ * the key is the extension and the value is the mimetype identifier.
+ *
+ * @return
+ * Returns a string containing the MIME type of the resource.
+ */
+ public static function getMimeType($uri, $mapping = NULL);
+
+ /**
+ * Changes permissions of the resource.
+ *
+ * PHP lacks this functionality and it is not part of the official stream
+ * wrapper interface. This is a custom implementation for Drupal.
+ *
+ * @param $mode
+ * Integer value for the permissions. Consult PHP chmod() documentation
+ * for more information.
+ *
+ * @return
+ * Returns TRUE on success or FALSE on failure.
+ */
+ public function chmod($mode);
+
+ /**
+ * Returns canonical, absolute path of the resource.
+ *
+ * Implementation placeholder. PHP's realpath() does not support stream
+ * wrappers. We provide this as a default so that individual wrappers may
+ * implement their own solutions.
+ *
+ * @return
+ * Returns a string with absolute pathname on success (implemented
+ * by core wrappers), or FALSE on failure or if the registered
+ * wrapper does not provide an implementation.
+ */
+ public function realpath();
+
+ /**
+ * Gets the name of the directory from a given path.
+ *
+ * This method is usually accessed through drupal_dirname(), which wraps
+ * around the normal PHP dirname() function, which does not support stream
+ * wrappers.
+ *
+ * @param $uri
+ * An optional URI.
+ *
+ * @return
+ * A string containing the directory name, or FALSE if not applicable.
+ *
+ * @see drupal_dirname()
+ */
+ public function dirname($uri = NULL);
+}
+
+
+/**
+ * Drupal stream wrapper base class for local files.
+ *
+ * This class provides a complete stream wrapper implementation. URIs such as
+ * "public://example.txt" are expanded to a normal filesystem path such as
+ * "sites/default/files/example.txt" and then PHP filesystem functions are
+ * invoked.
+ *
+ * DrupalLocalStreamWrapper implementations need to implement at least the
+ * getDirectoryPath() and getExternalUrl() methods.
+ */
+abstract class DrupalLocalStreamWrapper implements DrupalStreamWrapperInterface {
+ /**
+ * Stream context resource.
+ *
+ * @var Resource
+ */
+ public $context;
+
+ /**
+ * A generic resource handle.
+ *
+ * @var Resource
+ */
+ public $handle = NULL;
+
+ /**
+ * Instance URI (stream).
+ *
+ * A stream is referenced as "scheme://target".
+ *
+ * @var String
+ */
+ protected $uri;
+
+ /**
+ * Gets the path that the wrapper is responsible for.
+ * @TODO: Review this method name in D8 per http://drupal.org/node/701358
+ *
+ * @return
+ * String specifying the path.
+ */
+ abstract function getDirectoryPath();
+
+ /**
+ * Base implementation of setUri().
+ */
+ function setUri($uri) {
+ $this->uri = $uri;
+ }
+
+ /**
+ * Base implementation of getUri().
+ */
+ function getUri() {
+ return $this->uri;
+ }
+
+ /**
+ * Returns the local writable target of the resource within the stream.
+ *
+ * This function should be used in place of calls to realpath() or similar
+ * functions when attempting to determine the location of a file. While
+ * functions like realpath() may return the location of a read-only file, this
+ * method may return a URI or path suitable for writing that is completely
+ * separate from the URI used for reading.
+ *
+ * @param $uri
+ * Optional URI.
+ *
+ * @return
+ * Returns a string representing a location suitable for writing of a file,
+ * or FALSE if unable to write to the file such as with read-only streams.
+ */
+ protected function getTarget($uri = NULL) {
+ if (!isset($uri)) {
+ $uri = $this->uri;
+ }
+
+ list($scheme, $target) = explode('://', $uri, 2);
+
+ // Remove erroneous leading or trailing, forward-slashes and backslashes.
+ return trim($target, '\/');
+ }
+
+ /**
+ * Base implementation of getMimeType().
+ */
+ static function getMimeType($uri, $mapping = NULL) {
+ if (!isset($mapping)) {
+ // The default file map, defined in file.mimetypes.inc is quite big.
+ // We only load it when necessary.
+ include_once DRUPAL_ROOT . '/core/includes/file.mimetypes.inc';
+ $mapping = file_mimetype_mapping();
+ }
+
+ $extension = '';
+ $file_parts = explode('.', basename($uri));
+
+ // Remove the first part: a full filename should not match an extension.
+ array_shift($file_parts);
+
+ // Iterate over the file parts, trying to find a match.
+ // For my.awesome.image.jpeg, we try:
+ // - jpeg
+ // - image.jpeg, and
+ // - awesome.image.jpeg
+ while ($additional_part = array_pop($file_parts)) {
+ $extension = strtolower($additional_part . ($extension ? '.' . $extension : ''));
+ if (isset($mapping['extensions'][$extension])) {
+ return $mapping['mimetypes'][$mapping['extensions'][$extension]];
+ }
+ }
+
+ return 'application/octet-stream';
+ }
+
+ /**
+ * Base implementation of chmod().
+ */
+ function chmod($mode) {
+ $output = @chmod($this->getLocalPath(), $mode);
+ // We are modifying the underlying file here, so we have to clear the stat
+ // cache so that PHP understands that URI has changed too.
+ clearstatcache();
+ return $output;
+ }
+
+ /**
+ * Base implementation of realpath().
+ */
+ function realpath() {
+ return $this->getLocalPath();
+ }
+
+ /**
+ * Returns the canonical absolute path of the URI, if possible.
+ *
+ * @param string $uri
+ * (optional) The stream wrapper URI to be converted to a canonical
+ * absolute path. This may point to a directory or another type of file.
+ *
+ * @return string|false
+ * If $uri is not set, returns the canonical absolute path of the URI
+ * previously set by the DrupalStreamWrapperInterface::setUri() function.
+ * If $uri is set and valid for this class, returns its canonical absolute
+ * path, as determined by the realpath() function. If $uri is set but not
+ * valid, returns FALSE.
+ */
+ protected function getLocalPath($uri = NULL) {
+ if (!isset($uri)) {
+ $uri = $this->uri;
+ }
+ $path = $this->getDirectoryPath() . '/' . $this->getTarget($uri);
+ $realpath = realpath($path);
+ if (!$realpath) {
+ // This file does not yet exist.
+ $realpath = realpath(dirname($path)) . '/' . basename($path);
+ }
+ $directory = realpath($this->getDirectoryPath());
+ if (!$realpath || !$directory || strpos($realpath, $directory) !== 0) {
+ return FALSE;
+ }
+ return $realpath;
+ }
+
+ /**
+ * Support for fopen(), file_get_contents(), file_put_contents() etc.
+ *
+ * @param $uri
+ * A string containing the URI to the file to open.
+ * @param $mode
+ * The file mode ("r", "wb" etc.).
+ * @param $options
+ * A bit mask of STREAM_USE_PATH and STREAM_REPORT_ERRORS.
+ * @param $opened_path
+ * A string containing the path actually opened.
+ *
+ * @return
+ * Returns TRUE if file was opened successfully.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-open.php
+ */
+ public function stream_open($uri, $mode, $options, &$opened_path) {
+ $this->uri = $uri;
+ $path = $this->getLocalPath();
+ $this->handle = ($options & STREAM_REPORT_ERRORS) ? fopen($path, $mode) : @fopen($path, $mode);
+
+ if ((bool) $this->handle && $options & STREAM_USE_PATH) {
+ $opened_path = $path;
+ }
+
+ return (bool) $this->handle;
+ }
+
+ /**
+ * Support for flock().
+ *
+ * @param $operation
+ * One of the following:
+ * - LOCK_SH to acquire a shared lock (reader).
+ * - LOCK_EX to acquire an exclusive lock (writer).
+ * - LOCK_UN to release a lock (shared or exclusive).
+ * - LOCK_NB if you don't want flock() to block while locking (not
+ * supported on Windows).
+ *
+ * @return
+ * Always returns TRUE at the present time.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-lock.php
+ */
+ public function stream_lock($operation) {
+ if (in_array($operation, array(LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB))) {
+ return flock($this->handle, $operation);
+ }
+
+ return TRUE;
+ }
+
+ /**
+ * Support for fread(), file_get_contents() etc.
+ *
+ * @param $count
+ * Maximum number of bytes to be read.
+ *
+ * @return
+ * The string that was read, or FALSE in case of an error.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-read.php
+ */
+ public function stream_read($count) {
+ return fread($this->handle, $count);
+ }
+
+ /**
+ * Support for fwrite(), file_put_contents() etc.
+ *
+ * @param $data
+ * The string to be written.
+ *
+ * @return
+ * The number of bytes written (integer).
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-write.php
+ */
+ public function stream_write($data) {
+ return fwrite($this->handle, $data);
+ }
+
+ /**
+ * Support for feof().
+ *
+ * @return
+ * TRUE if end-of-file has been reached.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-eof.php
+ */
+ public function stream_eof() {
+ return feof($this->handle);
+ }
+
+ /**
+ * Support for fseek().
+ *
+ * @param $offset
+ * The byte offset to got to.
+ * @param $whence
+ * SEEK_SET, SEEK_CUR, or SEEK_END.
+ *
+ * @return
+ * TRUE on success.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-seek.php
+ */
+ public function stream_seek($offset, $whence) {
+ // fseek returns 0 on success and -1 on a failure.
+ // stream_seek 1 on success and 0 on a failure.
+ return !fseek($this->handle, $offset, $whence);
+ }
+
+ /**
+ * Support for fflush().
+ *
+ * @return
+ * TRUE if data was successfully stored (or there was no data to store).
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-flush.php
+ */
+ public function stream_flush() {
+ return fflush($this->handle);
+ }
+
+ /**
+ * Support for ftell().
+ *
+ * @return
+ * The current offset in bytes from the beginning of file.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-tell.php
+ */
+ public function stream_tell() {
+ return ftell($this->handle);
+ }
+
+ /**
+ * Support for fstat().
+ *
+ * @return
+ * An array with file status, or FALSE in case of an error - see fstat()
+ * for a description of this array.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-stat.php
+ */
+ public function stream_stat() {
+ return fstat($this->handle);
+ }
+
+ /**
+ * Support for fclose().
+ *
+ * @return
+ * TRUE if stream was successfully closed.
+ *
+ * @see http://php.net/manual/en/streamwrapper.stream-close.php
+ */
+ public function stream_close() {
+ return fclose($this->handle);
+ }
+
+ /**
+ * Support for unlink().
+ *
+ * @param $uri
+ * A string containing the uri to the resource to delete.
+ *
+ * @return
+ * TRUE if resource was successfully deleted.
+ *
+ * @see http://php.net/manual/en/streamwrapper.unlink.php
+ */
+ public function unlink($uri) {
+ $this->uri = $uri;
+ return drupal_unlink($this->getLocalPath());
+ }
+
+ /**
+ * Support for rename().
+ *
+ * @param $from_uri,
+ * The uri to the file to rename.
+ * @param $to_uri
+ * The new uri for file.
+ *
+ * @return
+ * TRUE if file was successfully renamed.
+ *
+ * @see http://php.net/manual/en/streamwrapper.rename.php
+ */
+ public function rename($from_uri, $to_uri) {
+ return rename($this->getLocalPath($from_uri), $this->getLocalPath($to_uri));
+ }
+
+ /**
+ * Gets the name of the directory from a given path.
+ *
+ * This method is usually accessed through drupal_dirname(), which wraps
+ * around the PHP dirname() function because it does not support stream
+ * wrappers.
+ *
+ * @param $uri
+ * A URI or path.
+ *
+ * @return
+ * A string containing the directory name.
+ *
+ * @see drupal_dirname()
+ */
+ public function dirname($uri = NULL) {
+ list($scheme, $target) = explode('://', $uri, 2);
+ $target = $this->getTarget($uri);
+ $dirname = dirname($target);
+
+ if ($dirname == '.') {
+ $dirname = '';
+ }
+
+ return $scheme . '://' . $dirname;
+ }
+
+ /**
+ * Support for mkdir().
+ *
+ * @param $uri
+ * A string containing the URI to the directory to create.
+ * @param $mode
+ * Permission flags - see mkdir().
+ * @param $options
+ * A bit mask of STREAM_REPORT_ERRORS and STREAM_MKDIR_RECURSIVE.
+ *
+ * @return
+ * TRUE if directory was successfully created.
+ *
+ * @see http://php.net/manual/en/streamwrapper.mkdir.php
+ */
+ public function mkdir($uri, $mode, $options) {
+ $this->uri = $uri;
+ $recursive = (bool) ($options & STREAM_MKDIR_RECURSIVE);
+ if ($recursive) {
+ // $this->getLocalPath() fails if $uri has multiple levels of directories
+ // that do not yet exist.
+ $localpath = $this->getDirectoryPath() . '/' . $this->getTarget($uri);
+ }
+ else {
+ $localpath = $this->getLocalPath($uri);
+ }
+ if ($options & STREAM_REPORT_ERRORS) {
+ return mkdir($localpath, $mode, $recursive);
+ }
+ else {
+ return @mkdir($localpath, $mode, $recursive);
+ }
+ }
+
+ /**
+ * Support for rmdir().
+ *
+ * @param $uri
+ * A string containing the URI to the directory to delete.
+ * @param $options
+ * A bit mask of STREAM_REPORT_ERRORS.
+ *
+ * @return
+ * TRUE if directory was successfully removed.
+ *
+ * @see http://php.net/manual/en/streamwrapper.rmdir.php
+ */
+ public function rmdir($uri, $options) {
+ $this->uri = $uri;
+ if ($options & STREAM_REPORT_ERRORS) {
+ return drupal_rmdir($this->getLocalPath());
+ }
+ else {
+ return @drupal_rmdir($this->getLocalPath());
+ }
+ }
+
+ /**
+ * Support for stat().
+ *
+ * @param $uri
+ * A string containing the URI to get information about.
+ * @param $flags
+ * A bit mask of STREAM_URL_STAT_LINK and STREAM_URL_STAT_QUIET.
+ *
+ * @return
+ * An array with file status, or FALSE in case of an error - see fstat()
+ * for a description of this array.
+ *
+ * @see http://php.net/manual/en/streamwrapper.url-stat.php
+ */
+ public function url_stat($uri, $flags) {
+ $this->uri = $uri;
+ $path = $this->getLocalPath();
+ // Suppress warnings if requested or if the file or directory does not
+ // exist. This is consistent with PHP's plain filesystem stream wrapper.
+ if ($flags & STREAM_URL_STAT_QUIET || !file_exists($path)) {
+ return @stat($path);
+ }
+ else {
+ return stat($path);
+ }
+ }
+
+ /**
+ * Support for opendir().
+ *
+ * @param $uri
+ * A string containing the URI to the directory to open.
+ * @param $options
+ * Unknown (parameter is not documented in PHP Manual).
+ *
+ * @return
+ * TRUE on success.
+ *
+ * @see http://php.net/manual/en/streamwrapper.dir-opendir.php
+ */
+ public function dir_opendir($uri, $options) {
+ $this->uri = $uri;
+ $this->handle = opendir($this->getLocalPath());
+
+ return (bool) $this->handle;
+ }
+
+ /**
+ * Support for readdir().
+ *
+ * @return
+ * The next filename, or FALSE if there are no more files in the directory.
+ *
+ * @see http://php.net/manual/en/streamwrapper.dir-readdir.php
+ */
+ public function dir_readdir() {
+ return readdir($this->handle);
+ }
+
+ /**
+ * Support for rewinddir().
+ *
+ * @return
+ * TRUE on success.
+ *
+ * @see http://php.net/manual/en/streamwrapper.dir-rewinddir.php
+ */
+ public function dir_rewinddir() {
+ rewinddir($this->handle);
+ // We do not really have a way to signal a failure as rewinddir() does not
+ // have a return value and there is no way to read a directory handler
+ // without advancing to the next file.
+ return TRUE;
+ }
+
+ /**
+ * Support for closedir().
+ *
+ * @return
+ * TRUE on success.
+ *
+ * @see http://php.net/manual/en/streamwrapper.dir-closedir.php
+ */
+ public function dir_closedir() {
+ closedir($this->handle);
+ // We do not really have a way to signal a failure as closedir() does not
+ // have a return value.
+ return TRUE;
+ }
+}
+
+/**
+ * Drupal public (public://) stream wrapper class.
+ *
+ * Provides support for storing publicly accessible files with the Drupal file
+ * interface.
+ */
+class DrupalPublicStreamWrapper extends DrupalLocalStreamWrapper {
+ /**
+ * Implements abstract public function getDirectoryPath()
+ */
+ public function getDirectoryPath() {
+ return variable_get('file_public_path', conf_path() . '/files');
+ }
+
+ /**
+ * Overrides getExternalUrl().
+ *
+ * Return the HTML URI of a public file.
+ */
+ function getExternalUrl() {
+ $path = str_replace('\\', '/', $this->getTarget());
+ return $GLOBALS['base_url'] . '/' . self::getDirectoryPath() . '/' . drupal_encode_path($path);
+ }
+}
+
+
+/**
+ * Drupal private (private://) stream wrapper class.
+ *
+ * Provides support for storing privately accessible files with the Drupal file
+ * interface.
+ *
+ * Extends DrupalPublicStreamWrapper.
+ */
+class DrupalPrivateStreamWrapper extends DrupalLocalStreamWrapper {
+ /**
+ * Implements abstract public function getDirectoryPath()
+ */
+ public function getDirectoryPath() {
+ return variable_get('file_private_path', '');
+ }
+
+ /**
+ * Overrides getExternalUrl().
+ *
+ * Return the HTML URI of a private file.
+ */
+ function getExternalUrl() {
+ $path = str_replace('\\', '/', $this->getTarget());
+ return url('system/files/' . $path, array('absolute' => TRUE));
+ }
+}
+
+/**
+ * Drupal temporary (temporary://) stream wrapper class.
+ *
+ * Provides support for storing temporarily accessible files with the Drupal
+ * file interface.
+ *
+ * Extends DrupalPublicStreamWrapper.
+ */
+class DrupalTemporaryStreamWrapper extends DrupalLocalStreamWrapper {
+ /**
+ * Implements abstract public function getDirectoryPath()
+ */
+ public function getDirectoryPath() {
+ return variable_get('file_temporary_path', file_directory_temp());
+ }
+
+ /**
+ * Overrides getExternalUrl().
+ */
+ public function getExternalUrl() {
+ $path = str_replace('\\', '/', $this->getTarget());
+ return url('system/temporary/' . $path, array('absolute' => TRUE));
+ }
+}
diff --git a/core/includes/tablesort.inc b/core/includes/tablesort.inc
new file mode 100644
index 000000000000..121a1b909346
--- /dev/null
+++ b/core/includes/tablesort.inc
@@ -0,0 +1,252 @@
+<?php
+
+/**
+ * @file
+ * Functions to aid in the creation of sortable tables.
+ *
+ * All tables created with a call to theme('table') have the option of having
+ * column headers that the user can click on to sort the table by that column.
+ */
+
+
+/**
+ * Query extender class for tablesort queries.
+ */
+class TableSort extends SelectQueryExtender {
+
+ /**
+ * The array of fields that can be sorted by.
+ *
+ * @var array
+ */
+ protected $header = array();
+
+ public function __construct(SelectQueryInterface $query, DatabaseConnection $connection) {
+ parent::__construct($query, $connection);
+
+ // Add convenience tag to mark that this is an extended query. We have to
+ // do this in the constructor to ensure that it is set before preExecute()
+ // gets called.
+ $this->addTag('tablesort');
+ }
+
+ /**
+ * Order the query based on a header array.
+ *
+ * @see theme_table()
+ * @param $header
+ * Table header array.
+ * @return SelectQueryInterface
+ * The called object.
+ */
+ public function orderByHeader(Array $header) {
+ $this->header = $header;
+ $ts = $this->init();
+ if (!empty($ts['sql'])) {
+ // Based on code from db_escape_table(), but this can also contain a dot.
+ $field = preg_replace('/[^A-Za-z0-9_.]+/', '', $ts['sql']);
+
+ // Sort order can only be ASC or DESC.
+ $sort = drupal_strtoupper($ts['sort']);
+ $sort = in_array($sort, array('ASC', 'DESC')) ? $sort : '';
+ $this->orderBy($field, $sort);
+ }
+ return $this;
+ }
+
+ /**
+ * Initialize the table sort context.
+ */
+ protected function init() {
+ $ts = $this->order();
+ $ts['sort'] = $this->getSort();
+ $ts['query'] = $this->getQueryParameters();
+ return $ts;
+ }
+
+ /**
+ * Determine the current sort direction.
+ *
+ * @param $headers
+ * An array of column headers in the format described in theme_table().
+ * @return
+ * The current sort direction ("asc" or "desc").
+ */
+ protected function getSort() {
+ return tablesort_get_sort($this->header);
+ }
+
+ /**
+ * Compose a URL query parameter array to append to table sorting requests.
+ *
+ * @return
+ * A URL query parameter array that consists of all components of the current
+ * page request except for those pertaining to table sorting.
+ *
+ * @see tablesort_get_query_parameters()
+ */
+ protected function getQueryParameters() {
+ return tablesort_get_query_parameters();
+ }
+
+ /**
+ * Determine the current sort criterion.
+ *
+ * @param $headers
+ * An array of column headers in the format described in theme_table().
+ * @return
+ * An associative array describing the criterion, containing the keys:
+ * - "name": The localized title of the table column.
+ * - "sql": The name of the database field to sort on.
+ */
+ protected function order() {
+ return tablesort_get_order($this->header);
+ }
+}
+
+/**
+ * Initialize the table sort context.
+ */
+function tablesort_init($header) {
+ $ts = tablesort_get_order($header);
+ $ts['sort'] = tablesort_get_sort($header);
+ $ts['query'] = tablesort_get_query_parameters();
+ return $ts;
+}
+
+/**
+ * Format a column header.
+ *
+ * If the cell in question is the column header for the current sort criterion,
+ * it gets special formatting. All possible sort criteria become links.
+ *
+ * @param $cell
+ * The cell to format.
+ * @param $header
+ * An array of column headers in the format described in theme_table().
+ * @param $ts
+ * The current table sort context as returned from tablesort_init().
+ * @return
+ * A properly formatted cell, ready for _theme_table_cell().
+ */
+function tablesort_header($cell, $header, $ts) {
+ // Special formatting for the currently sorted column header.
+ if (is_array($cell) && isset($cell['field'])) {
+ $title = t('sort by @s', array('@s' => $cell['data']));
+ if ($cell['data'] == $ts['name']) {
+ $ts['sort'] = (($ts['sort'] == 'asc') ? 'desc' : 'asc');
+ $cell['class'][] = 'active';
+ $image = theme('tablesort_indicator', array('style' => $ts['sort']));
+ }
+ else {
+ // If the user clicks a different header, we want to sort ascending initially.
+ $ts['sort'] = 'asc';
+ $image = '';
+ }
+ $cell['data'] = l($cell['data'] . $image, $_GET['q'], array('attributes' => array('title' => $title), 'query' => array_merge($ts['query'], array('sort' => $ts['sort'], 'order' => $cell['data'])), 'html' => TRUE));
+
+ unset($cell['field'], $cell['sort']);
+ }
+ return $cell;
+}
+
+/**
+ * Format a table cell.
+ *
+ * Adds a class attribute to all cells in the currently active column.
+ *
+ * @param $cell
+ * The cell to format.
+ * @param $header
+ * An array of column headers in the format described in theme_table().
+ * @param $ts
+ * The current table sort context as returned from tablesort_init().
+ * @param $i
+ * The index of the cell's table column.
+ * @return
+ * A properly formatted cell, ready for _theme_table_cell().
+ */
+function tablesort_cell($cell, $header, $ts, $i) {
+ if (isset($header[$i]['data']) && $header[$i]['data'] == $ts['name'] && !empty($header[$i]['field'])) {
+ if (is_array($cell)) {
+ $cell['class'][] = 'active';
+ }
+ else {
+ $cell = array('data' => $cell, 'class' => array('active'));
+ }
+ }
+ return $cell;
+}
+
+/**
+ * Compose a URL query parameter array for table sorting links.
+ *
+ * @return
+ * A URL query parameter array that consists of all components of the current
+ * page request except for those pertaining to table sorting.
+ */
+function tablesort_get_query_parameters() {
+ return drupal_get_query_parameters($_GET, array('q', 'sort', 'order'));
+}
+
+/**
+ * Determine the current sort criterion.
+ *
+ * @param $headers
+ * An array of column headers in the format described in theme_table().
+ * @return
+ * An associative array describing the criterion, containing the keys:
+ * - "name": The localized title of the table column.
+ * - "sql": The name of the database field to sort on.
+ */
+function tablesort_get_order($headers) {
+ $order = isset($_GET['order']) ? $_GET['order'] : '';
+ foreach ($headers as $header) {
+ if (is_array($header)) {
+ if (isset($header['data']) && $order == $header['data']) {
+ $default = $header;
+ break;
+ }
+
+ if (empty($default) && isset($header['sort']) && ($header['sort'] == 'asc' || $header['sort'] == 'desc')) {
+ $default = $header;
+ }
+ }
+ }
+
+ if (!isset($default)) {
+ $default = reset($headers);
+ if (!is_array($default)) {
+ $default = array('data' => $default);
+ }
+ }
+
+ $default += array('data' => NULL, 'field' => NULL);
+ return array('name' => $default['data'], 'sql' => $default['field']);
+}
+
+/**
+ * Determine the current sort direction.
+ *
+ * @param $headers
+ * An array of column headers in the format described in theme_table().
+ * @return
+ * The current sort direction ("asc" or "desc").
+ */
+function tablesort_get_sort($headers) {
+ if (isset($_GET['sort'])) {
+ return (strtolower($_GET['sort']) == 'desc') ? 'desc' : 'asc';
+ }
+ // The user has not specified a sort. Use the default for the currently sorted
+ // header if specified; otherwise use "asc".
+ else {
+ // Find out which header is currently being sorted.
+ $ts = tablesort_get_order($headers);
+ foreach ($headers as $header) {
+ if (is_array($header) && isset($header['data']) && $header['data'] == $ts['name'] && isset($header['sort'])) {
+ return $header['sort'];
+ }
+ }
+ }
+ return 'asc';
+}
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
new file mode 100644
index 000000000000..5c38525b3eba
--- /dev/null
+++ b/core/includes/theme.inc
@@ -0,0 +1,2791 @@
+<?php
+
+/**
+ * @file
+ * The theme system, which controls the output of Drupal.
+ *
+ * The theme system allows for nearly all output of the Drupal system to be
+ * customized by user themes.
+ */
+
+/**
+ * @defgroup content_flags Content markers
+ * @{
+ * Markers used by theme_mark() and node_mark() to designate content.
+ * @see theme_mark(), node_mark()
+ */
+
+/**
+ * Mark content as read.
+ */
+define('MARK_READ', 0);
+
+/**
+ * Mark content as being new.
+ */
+define('MARK_NEW', 1);
+
+/**
+ * Mark content as being updated.
+ */
+define('MARK_UPDATED', 2);
+
+/**
+ * @} End of "Content markers".
+ */
+
+/**
+ * Determines if a theme is available to use.
+ *
+ * @param $theme
+ * Either the name of a theme or a full theme object.
+ *
+ * @return
+ * Boolean TRUE if the theme is enabled or is the site administration theme;
+ * FALSE otherwise.
+ */
+function drupal_theme_access($theme) {
+ if (is_object($theme)) {
+ return _drupal_theme_access($theme);
+ }
+ else {
+ $themes = list_themes();
+ return isset($themes[$theme]) && _drupal_theme_access($themes[$theme]);
+ }
+}
+
+/**
+ * Helper function for determining access to a theme.
+ *
+ * @see drupal_theme_access()
+ */
+function _drupal_theme_access($theme) {
+ $admin_theme = variable_get('admin_theme');
+ return !empty($theme->status) || ($admin_theme && $theme->name == $admin_theme);
+}
+
+/**
+ * Initialize the theme system by loading the theme.
+ */
+function drupal_theme_initialize() {
+ global $theme, $user, $theme_key;
+
+ // If $theme is already set, assume the others are set, too, and do nothing
+ if (isset($theme)) {
+ return;
+ }
+
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_DATABASE);
+ $themes = list_themes();
+
+ // Only select the user selected theme if it is available in the
+ // list of themes that can be accessed.
+ $theme = !empty($user->theme) && drupal_theme_access($user->theme) ? $user->theme : variable_get('theme_default', 'bartik');
+
+ // Allow modules to override the theme. Validation has already been performed
+ // inside menu_get_custom_theme(), so we do not need to check it again here.
+ $custom_theme = menu_get_custom_theme();
+ $theme = !empty($custom_theme) ? $custom_theme : $theme;
+
+ // Store the identifier for retrieving theme settings with.
+ $theme_key = $theme;
+
+ // Find all our ancestor themes and put them in an array.
+ $base_theme = array();
+ $ancestor = $theme;
+ while ($ancestor && isset($themes[$ancestor]->base_theme)) {
+ $ancestor = $themes[$ancestor]->base_theme;
+ $base_theme[] = $themes[$ancestor];
+ }
+ _drupal_theme_initialize($themes[$theme], array_reverse($base_theme));
+
+ // Themes can have alter functions, so reset the drupal_alter() cache.
+ drupal_static_reset('drupal_alter');
+
+ // Provide the page with information about the theme that's used, so that a
+ // later Ajax request can be rendered using the same theme.
+ // @see ajax_base_page_theme()
+ $setting['ajaxPageState'] = array(
+ 'theme' => $theme_key,
+ 'theme_token' => drupal_get_token($theme_key),
+ );
+ drupal_add_js($setting, 'setting');
+}
+
+/**
+ * Initialize the theme system given already loaded information. This
+ * function is useful to initialize a theme when no database is present.
+ *
+ * @param $theme
+ * An object with the following information:
+ * filename
+ * The .info file for this theme. The 'path' to
+ * the theme will be in this file's directory. (Required)
+ * owner
+ * The path to the .theme file or the .engine file to load for
+ * the theme. (Required)
+ * stylesheet
+ * The primary stylesheet for the theme. (Optional)
+ * engine
+ * The name of theme engine to use. (Optional)
+ * @param $base_theme
+ * An optional array of objects that represent the 'base theme' if the
+ * theme is meant to be derivative of another theme. It requires
+ * the same information as the $theme object. It should be in
+ * 'oldest first' order, meaning the top level of the chain will
+ * be first.
+ * @param $registry_callback
+ * The callback to invoke to set the theme registry.
+ */
+function _drupal_theme_initialize($theme, $base_theme = array(), $registry_callback = '_theme_load_registry') {
+ global $theme_info, $base_theme_info, $theme_engine, $theme_path;
+ $theme_info = $theme;
+ $base_theme_info = $base_theme;
+
+ $theme_path = dirname($theme->filename);
+
+ // Prepare stylesheets from this theme as well as all ancestor themes.
+ // We work it this way so that we can have child themes override parent
+ // theme stylesheets easily.
+ $final_stylesheets = array();
+
+ // Grab stylesheets from base theme
+ foreach ($base_theme as $base) {
+ if (!empty($base->stylesheets)) {
+ foreach ($base->stylesheets as $media => $stylesheets) {
+ foreach ($stylesheets as $name => $stylesheet) {
+ $final_stylesheets[$media][$name] = $stylesheet;
+ }
+ }
+ }
+ }
+
+ // Add stylesheets used by this theme.
+ if (!empty($theme->stylesheets)) {
+ foreach ($theme->stylesheets as $media => $stylesheets) {
+ foreach ($stylesheets as $name => $stylesheet) {
+ $final_stylesheets[$media][$name] = $stylesheet;
+ }
+ }
+ }
+
+ // And now add the stylesheets properly
+ foreach ($final_stylesheets as $media => $stylesheets) {
+ foreach ($stylesheets as $stylesheet) {
+ drupal_add_css($stylesheet, array('group' => CSS_THEME, 'every_page' => TRUE, 'media' => $media));
+ }
+ }
+
+ // Do basically the same as the above for scripts
+ $final_scripts = array();
+
+ // Grab scripts from base theme
+ foreach ($base_theme as $base) {
+ if (!empty($base->scripts)) {
+ foreach ($base->scripts as $name => $script) {
+ $final_scripts[$name] = $script;
+ }
+ }
+ }
+
+ // Add scripts used by this theme.
+ if (!empty($theme->scripts)) {
+ foreach ($theme->scripts as $name => $script) {
+ $final_scripts[$name] = $script;
+ }
+ }
+
+ // Add scripts used by this theme.
+ foreach ($final_scripts as $script) {
+ drupal_add_js($script, array('group' => JS_THEME, 'every_page' => TRUE));
+ }
+
+ $theme_engine = NULL;
+
+ // Initialize the theme.
+ if (isset($theme->engine)) {
+ // Include the engine.
+ include_once DRUPAL_ROOT . '/' . $theme->owner;
+
+ $theme_engine = $theme->engine;
+ if (function_exists($theme_engine . '_init')) {
+ foreach ($base_theme as $base) {
+ call_user_func($theme_engine . '_init', $base);
+ }
+ call_user_func($theme_engine . '_init', $theme);
+ }
+ }
+ else {
+ // include non-engine theme files
+ foreach ($base_theme as $base) {
+ // Include the theme file or the engine.
+ if (!empty($base->owner)) {
+ include_once DRUPAL_ROOT . '/' . $base->owner;
+ }
+ }
+ // and our theme gets one too.
+ if (!empty($theme->owner)) {
+ include_once DRUPAL_ROOT . '/' . $theme->owner;
+ }
+ }
+
+ if (isset($registry_callback)) {
+ _theme_registry_callback($registry_callback, array($theme, $base_theme, $theme_engine));
+ }
+}
+
+/**
+ * Get the theme registry.
+ *
+ * @param $complete
+ * Optional boolean to indicate whether to return the complete theme registry
+ * array or an instance of the ThemeRegistry class. If TRUE, the complete
+ * theme registry array will be returned. This is useful if you want to
+ * foreach over the whole registry, use array_* functions or inspect it in a
+ * debugger. If FALSE, an instance of the ThemeRegistry class will be
+ * returned, this provides an ArrayObject which allows it to be accessed
+ * with array syntax and isset(), and should be more lightweight
+ * than the full registry. Defaults to TRUE.
+ *
+ * @return
+ * The complete theme registry array, or an instance of the ThemeRegistry
+ * class.
+ */
+function theme_get_registry($complete = TRUE) {
+ static $theme_registry = array();
+ $key = (int) $complete;
+
+ if (!isset($theme_registry[$key])) {
+ list($callback, $arguments) = _theme_registry_callback();
+ if (!$complete) {
+ $arguments[] = FALSE;
+ }
+ $theme_registry[$key] = call_user_func_array($callback, $arguments);
+ }
+
+ return $theme_registry[$key];
+}
+
+/**
+ * Set the callback that will be used by theme_get_registry() to fetch the registry.
+ *
+ * @param $callback
+ * The name of the callback function.
+ * @param $arguments
+ * The arguments to pass to the function.
+ */
+function _theme_registry_callback($callback = NULL, array $arguments = array()) {
+ static $stored;
+ if (isset($callback)) {
+ $stored = array($callback, $arguments);
+ }
+ return $stored;
+}
+
+/**
+ * Get the theme_registry cache; if it doesn't exist, build it.
+ *
+ * @param $theme
+ * The loaded $theme object as returned by list_themes().
+ * @param $base_theme
+ * An array of loaded $theme objects representing the ancestor themes in
+ * oldest first order.
+ * @param $theme_engine
+ * The name of the theme engine.
+ * @param $complete
+ * Whether to load the complete theme registry or an instance of the
+ * ThemeRegistry class.
+ *
+ * @return
+ * The theme registry array, or an instance of the ThemeRegistry class.
+ */
+function _theme_load_registry($theme, $base_theme = NULL, $theme_engine = NULL, $complete = TRUE) {
+ if ($complete) {
+ // Check the theme registry cache; if it exists, use it.
+ $cached = cache()->get("theme_registry:$theme->name");
+ if (isset($cached->data)) {
+ $registry = $cached->data;
+ }
+ else {
+ // If not, build one and cache it.
+ $registry = _theme_build_registry($theme, $base_theme, $theme_engine);
+ // Only persist this registry if all modules are loaded. This assures a
+ // complete set of theme hooks.
+ if (module_load_all(NULL)) {
+ _theme_save_registry($theme, $registry);
+ }
+ }
+ return $registry;
+ }
+ else {
+ return new ThemeRegistry('theme_registry:runtime:' . $theme->name, 'cache');
+ }
+}
+
+/**
+ * Write the theme_registry cache into the database.
+ */
+function _theme_save_registry($theme, $registry) {
+ cache()->set("theme_registry:$theme->name", $registry);
+}
+
+/**
+ * Force the system to rebuild the theme registry; this should be called
+ * when modules are added to the system, or when a dynamic system needs
+ * to add more theme hooks.
+ */
+function drupal_theme_rebuild() {
+ cache()->deletePrefix('theme_registry');
+}
+
+/**
+ * Builds the run-time theme registry.
+ *
+ * Extends DrupalCacheArray to allow the theme registry to be accessed as a
+ * complete registry, while internally caching only the parts of the registry
+ * that are actually in use on the site. On cache misses the complete
+ * theme registry is loaded and used to update the run-time cache.
+ */
+class ThemeRegistry Extends DrupalCacheArray {
+
+ /**
+ * Whether the partial registry can be persisted to the cache.
+ *
+ * This is only allowed if all modules and the request method is GET. theme()
+ * should be very rarely called on POST requests and this avoids polluting
+ * the runtime cache.
+ */
+ protected $persistable;
+
+ /**
+ * The complete theme registry array.
+ */
+ protected $completeRegistry;
+
+ function __construct($cid, $bin) {
+ $this->cid = $cid;
+ $this->bin = $bin;
+ $this->persistable = module_load_all(NULL) && $_SERVER['REQUEST_METHOD'] == 'GET';
+
+ $data = array();
+ if ($this->persistable && $cached = cache($this->bin)->get($this->cid)) {
+ $data = $cached->data;
+ }
+ else {
+ $complete_registry = theme_get_registry();
+ if ($this->persistable) {
+ // If there is no runtime cache stored, fetch the full theme registry,
+ // but then initialize each value to NULL. This allows
+ // offsetExists() to function correctly on non-registered theme hooks
+ // without triggering a call to resolveCacheMiss().
+ $data = array_fill_keys(array_keys($complete_registry), NULL);
+ $this->set($this->cid, $data, $this->bin);
+ $this->completeRegistry = $complete_registry;
+ }
+ else {
+ $data = $complete_registry;
+ }
+ }
+ $this->storage = $data;
+ }
+
+ public function offsetExists($offset) {
+ // Since the theme registry allows for theme hooks to be requested that
+ // are not registered, just check the existence of the key in the registry.
+ // Use array_key_exists() here since a NULL value indicates that the theme
+ // hook exists but has not yet been requested.
+ return array_key_exists($offset, $this->storage);
+ }
+
+ public function offsetGet($offset) {
+ // If the offset is set but empty, it is a registered theme hook that has
+ // not yet been requested. Offsets that do not exist at all were not
+ // registered in hook_theme().
+ if (isset($this->storage[$offset])) {
+ return $this->storage[$offset];
+ }
+ elseif (array_key_exists($offset, $this->storage)) {
+ return $this->resolveCacheMiss($offset);
+ }
+ }
+
+ public function resolveCacheMiss($offset) {
+ if (!isset($this->completeRegistry)) {
+ $this->completeRegistry = theme_get_registry();
+ }
+ $this->storage[$offset] = $this->completeRegistry[$offset];
+ if ($this->persistable) {
+ $this->persist($offset);
+ }
+ return $this->storage[$offset];
+ }
+
+ public function set($cid, $data, $bin, $lock = TRUE) {
+ $lock_name = $cid . ':' . $bin;
+ if (!$lock || lock_acquire($lock_name)) {
+ if ($cached = cache($bin)->get($cid)) {
+ // Use array merge instead of union so that filled in values in $data
+ // overwrite empty values in the current cache.
+ $data = array_merge($cached->data, $data);
+ }
+ cache($bin)->set($cid, $data);
+ if ($lock) {
+ lock_release($lock_name);
+ }
+ }
+ }
+}
+
+/**
+ * Process a single implementation of hook_theme().
+ *
+ * @param $cache
+ * The theme registry that will eventually be cached; It is an associative
+ * array keyed by theme hooks, whose values are associative arrays describing
+ * the hook:
+ * - 'type': The passed-in $type.
+ * - 'theme path': The passed-in $path.
+ * - 'function': The name of the function generating output for this theme
+ * hook. Either defined explicitly in hook_theme() or, if neither 'function'
+ * nor 'template' is defined, then the default theme function name is used.
+ * The default theme function name is the theme hook prefixed by either
+ * 'theme_' for modules or '$name_' for everything else. If 'function' is
+ * defined, 'template' is not used.
+ * - 'template': The filename of the template generating output for this
+ * theme hook. The template is in the directory defined by the 'path' key of
+ * hook_theme() or defaults to $path.
+ * - 'variables': The variables for this theme hook as defined in
+ * hook_theme(). If there is more than one implementation and 'variables' is
+ * not specified in a later one, then the previous definition is kept.
+ * - 'render element': The renderable element for this theme hook as defined
+ * in hook_theme(). If there is more than one implementation and
+ * 'render element' is not specified in a later one, then the previous
+ * definition is kept.
+ * - 'preprocess functions': See theme() for detailed documentation.
+ * - 'process functions': See theme() for detailed documentation.
+ * @param $name
+ * The name of the module, theme engine, base theme engine, theme or base
+ * theme implementing hook_theme().
+ * @param $type
+ * One of 'module', 'theme_engine', 'base_theme_engine', 'theme', or
+ * 'base_theme'. Unlike regular hooks that can only be implemented by modules,
+ * each of these can implement hook_theme(). _theme_process_registry() is
+ * called in aforementioned order and new entries override older ones. For
+ * example, if a theme hook is both defined by a module and a theme, then the
+ * definition in the theme will be used.
+ * @param $theme
+ * The loaded $theme object as returned from list_themes().
+ * @param $path
+ * The directory where $name is. For example, modules/system or
+ * themes/bartik.
+ *
+ * @see theme()
+ * @see _theme_process_registry()
+ * @see hook_theme()
+ * @see list_themes()
+ */
+function _theme_process_registry(&$cache, $name, $type, $theme, $path) {
+ $result = array();
+
+ // Processor functions work in two distinct phases with the process
+ // functions always being executed after the preprocess functions.
+ $variable_process_phases = array(
+ 'preprocess functions' => 'preprocess',
+ 'process functions' => 'process',
+ );
+
+ $hook_defaults = array(
+ 'variables' => TRUE,
+ 'render element' => TRUE,
+ 'pattern' => TRUE,
+ 'base hook' => TRUE,
+ );
+
+ // Invoke the hook_theme() implementation, process what is returned, and
+ // merge it into $cache.
+ $function = $name . '_theme';
+ if (function_exists($function)) {
+ $result = $function($cache, $type, $theme, $path);
+ foreach ($result as $hook => $info) {
+ // When a theme or engine overrides a module's theme function
+ // $result[$hook] will only contain key/value pairs for information being
+ // overridden. Pull the rest of the information from what was defined by
+ // an earlier hook.
+
+ // Fill in the type and path of the module, theme, or engine that
+ // implements this theme function.
+ $result[$hook]['type'] = $type;
+ $result[$hook]['theme path'] = $path;
+
+ // If function and file are omitted, default to standard naming
+ // conventions.
+ if (!isset($info['template']) && !isset($info['function'])) {
+ $result[$hook]['function'] = ($type == 'module' ? 'theme_' : $name . '_') . $hook;
+ }
+
+ if (isset($cache[$hook]['includes'])) {
+ $result[$hook]['includes'] = $cache[$hook]['includes'];
+ }
+
+ // If the theme implementation defines a file, then also use the path
+ // that it defined. Otherwise use the default path. This allows
+ // system.module to declare theme functions on behalf of core .include
+ // files.
+ if (isset($info['file'])) {
+ $include_file = isset($info['path']) ? $info['path'] : $path;
+ $include_file .= '/' . $info['file'];
+ include_once DRUPAL_ROOT . '/' . $include_file;
+ $result[$hook]['includes'][] = $include_file;
+ }
+
+ // If the default keys are not set, use the default values registered
+ // by the module.
+ if (isset($cache[$hook])) {
+ $result[$hook] += array_intersect_key($cache[$hook], $hook_defaults);
+ }
+
+ // The following apply only to theming hooks implemented as templates.
+ if (isset($info['template'])) {
+ // Prepend the current theming path when none is set.
+ if (!isset($info['path'])) {
+ $result[$hook]['template'] = $path . '/' . $info['template'];
+ }
+ }
+
+ // Allow variable processors for all theming hooks, whether the hook is
+ // implemented as a template or as a function.
+ foreach ($variable_process_phases as $phase_key => $phase) {
+ // Check for existing variable processors. Ensure arrayness.
+ if (!isset($info[$phase_key]) || !is_array($info[$phase_key])) {
+ $info[$phase_key] = array();
+ $prefixes = array();
+ if ($type == 'module') {
+ // Default variable processor prefix.
+ $prefixes[] = 'template';
+ // Add all modules so they can intervene with their own variable
+ // processors. This allows them to provide variable processors even
+ // if they are not the owner of the current hook.
+ $prefixes += module_list();
+ }
+ elseif ($type == 'theme_engine' || $type == 'base_theme_engine') {
+ // Theme engines get an extra set that come before the normally
+ // named variable processors.
+ $prefixes[] = $name . '_engine';
+ // The theme engine registers on behalf of the theme using the
+ // theme's name.
+ $prefixes[] = $theme;
+ }
+ else {
+ // This applies when the theme manually registers their own variable
+ // processors.
+ $prefixes[] = $name;
+ }
+ foreach ($prefixes as $prefix) {
+ // Only use non-hook-specific variable processors for theming hooks
+ // implemented as templates. See theme().
+ if (isset($info['template']) && function_exists($prefix . '_' . $phase)) {
+ $info[$phase_key][] = $prefix . '_' . $phase;
+ }
+ if (function_exists($prefix . '_' . $phase . '_' . $hook)) {
+ $info[$phase_key][] = $prefix . '_' . $phase . '_' . $hook;
+ }
+ }
+ }
+ // Check for the override flag and prevent the cached variable
+ // processors from being used. This allows themes or theme engines to
+ // remove variable processors set earlier in the registry build.
+ if (!empty($info['override ' . $phase_key])) {
+ // Flag not needed inside the registry.
+ unset($result[$hook]['override ' . $phase_key]);
+ }
+ elseif (isset($cache[$hook][$phase_key]) && is_array($cache[$hook][$phase_key])) {
+ $info[$phase_key] = array_merge($cache[$hook][$phase_key], $info[$phase_key]);
+ }
+ $result[$hook][$phase_key] = $info[$phase_key];
+ }
+ }
+
+ // Merge the newly created theme hooks into the existing cache.
+ $cache = $result + $cache;
+ }
+
+ // Let themes have variable processors even if they didn't register a template.
+ if ($type == 'theme' || $type == 'base_theme') {
+ foreach ($cache as $hook => $info) {
+ // Check only if not registered by the theme or engine.
+ if (empty($result[$hook])) {
+ foreach ($variable_process_phases as $phase_key => $phase) {
+ if (!isset($info[$phase_key])) {
+ $cache[$hook][$phase_key] = array();
+ }
+ // Only use non-hook-specific variable processors for theming hooks
+ // implemented as templates. See theme().
+ if (isset($info['template']) && function_exists($name . '_' . $phase)) {
+ $cache[$hook][$phase_key][] = $name . '_' . $phase;
+ }
+ if (function_exists($name . '_' . $phase . '_' . $hook)) {
+ $cache[$hook][$phase_key][] = $name . '_' . $phase . '_' . $hook;
+ $cache[$hook]['theme path'] = $path;
+ }
+ // Ensure uniqueness.
+ $cache[$hook][$phase_key] = array_unique($cache[$hook][$phase_key]);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Build the theme registry cache.
+ *
+ * @param $theme
+ * The loaded $theme object as returned by list_themes().
+ * @param $base_theme
+ * An array of loaded $theme objects representing the ancestor themes in
+ * oldest first order.
+ * @param $theme_engine
+ * The name of the theme engine.
+ */
+function _theme_build_registry($theme, $base_theme, $theme_engine) {
+ $cache = array();
+ // First, process the theme hooks advertised by modules. This will
+ // serve as the basic registry. Since the list of enabled modules is the same
+ // regardless of the theme used, this is cached in its own entry to save
+ // building it for every theme.
+ if ($cached = cache_get('theme_registry:build:modules')) {
+ $cache = $cached->data;
+ }
+ else {
+ foreach (module_implements('theme') as $module) {
+ _theme_process_registry($cache, $module, 'module', $module, drupal_get_path('module', $module));
+ }
+ // Only cache this registry if all modules are loaded.
+ if (module_load_all(NULL)) {
+ cache_set('theme_registry:build:modules', $cache);
+ }
+ }
+
+ // Process each base theme.
+ foreach ($base_theme as $base) {
+ // If the base theme uses a theme engine, process its hooks.
+ $base_path = dirname($base->filename);
+ if ($theme_engine) {
+ _theme_process_registry($cache, $theme_engine, 'base_theme_engine', $base->name, $base_path);
+ }
+ _theme_process_registry($cache, $base->name, 'base_theme', $base->name, $base_path);
+ }
+
+ // And then the same thing, but for the theme.
+ if ($theme_engine) {
+ _theme_process_registry($cache, $theme_engine, 'theme_engine', $theme->name, dirname($theme->filename));
+ }
+
+ // Finally, hooks provided by the theme itself.
+ _theme_process_registry($cache, $theme->name, 'theme', $theme->name, dirname($theme->filename));
+
+ // Let modules alter the registry.
+ drupal_alter('theme_registry', $cache);
+
+ // Optimize the registry to not have empty arrays for functions.
+ foreach ($cache as $hook => $info) {
+ foreach (array('preprocess functions', 'process functions') as $phase) {
+ if (empty($info[$phase])) {
+ unset($cache[$hook][$phase]);
+ }
+ }
+ }
+ return $cache;
+}
+
+/**
+ * Return a list of all currently available themes.
+ *
+ * Retrieved from the database, if available and the site is not in maintenance
+ * mode; otherwise compiled freshly from the filesystem.
+ *
+ * @param $refresh
+ * Whether to reload the list of themes from the database. Defaults to FALSE.
+ *
+ * @return
+ * An associative array of the currently available themes. The keys are the
+ * names of the themes and the values are objects having the following
+ * properties:
+ * - 'filename': The name of the .info file.
+ * - 'name': The name of the theme.
+ * - 'status': 1 for enabled, 0 for disabled themes.
+ * - 'info': The contents of the .info file.
+ * - 'stylesheets': A two dimensional array, using the first key for the
+ * 'media' attribute (e.g. 'all'), the second for the name of the file
+ * (e.g. style.css). The value is a complete filepath
+ * (e.g. themes/bartik/style.css).
+ * - 'scripts': An associative array of JavaScripts, using the filename as key
+ * and the complete filepath as value.
+ * - 'engine': The name of the theme engine.
+ * - 'base theme': The name of the base theme.
+ */
+function list_themes($refresh = FALSE) {
+ $list = &drupal_static(__FUNCTION__, array());
+
+ if ($refresh) {
+ $list = array();
+ system_list_reset();
+ }
+
+ if (empty($list)) {
+ $list = array();
+ $themes = array();
+ // Extract from the database only when it is available.
+ // Also check that the site is not in the middle of an install or update.
+ if (!defined('MAINTENANCE_MODE')) {
+ try {
+ $themes = system_list('theme');
+ }
+ catch (Exception $e) {
+ // If the database is not available, rebuild the theme data.
+ $themes = _system_rebuild_theme_data();
+ }
+ }
+ else {
+ // Scan the installation when the database should not be read.
+ $themes = _system_rebuild_theme_data();
+ }
+
+ foreach ($themes as $theme) {
+ foreach ($theme->info['stylesheets'] as $media => $stylesheets) {
+ foreach ($stylesheets as $stylesheet => $path) {
+ $theme->stylesheets[$media][$stylesheet] = $path;
+ }
+ }
+ foreach ($theme->info['scripts'] as $script => $path) {
+ $theme->scripts[$script] = $path;
+ }
+ if (isset($theme->info['engine'])) {
+ $theme->engine = $theme->info['engine'];
+ }
+ if (isset($theme->info['base theme'])) {
+ $theme->base_theme = $theme->info['base theme'];
+ }
+ // Status is normally retrieved from the database. Add zero values when
+ // read from the installation directory to prevent notices.
+ if (!isset($theme->status)) {
+ $theme->status = 0;
+ }
+ $list[$theme->name] = $theme;
+ }
+ }
+
+ return $list;
+}
+
+/**
+ * Generates themed output.
+ *
+ * All requests for themed output must go through this function. It examines
+ * the request and routes it to the appropriate theme function or template, by
+ * checking the theme registry.
+ *
+ * The first argument to this function is the name of the theme hook. For
+ * instance, to theme a table, the theme hook name is 'table'. By default, this
+ * theme hook could be implemented by a function called 'theme_table' or a
+ * template file called 'table.tpl.php', but hook_theme() can override the
+ * default function or template name.
+ *
+ * If the implementation is a template file, several functions are called
+ * before the template file is invoked, to modify the $variables array. These
+ * fall into the "preprocessing" phase and the "processing" phase, and are
+ * executed (if they exist), in the following order (note that in the following
+ * list, HOOK indicates the theme hook name, MODULE indicates a module name,
+ * THEME indicates a theme name, and ENGINE indicates a theme engine name):
+ * - template_preprocess(&$variables, $hook): Creates a default set of variables
+ * for all theme hooks.
+ * - template_preprocess_HOOK(&$variables): Should be implemented by
+ * the module that registers the theme hook, to set up default variables.
+ * - MODULE_preprocess(&$variables, $hook): hook_preprocess() is invoked on all
+ * implementing modules.
+ * - MODULE_preprocess_HOOK(&$variables): hook_preprocess_HOOK() is invoked on
+ * all implementing modules, so that modules that didn't define the theme hook
+ * can alter the variables.
+ * - ENGINE_engine_preprocess(&$variables, $hook): Allows the theme engine to
+ * set necessary variables for all theme hooks.
+ * - ENGINE_engine_preprocess_HOOK(&$variables): Allows the theme engine to set
+ * necessary variables for the particular theme hook.
+ * - THEME_preprocess(&$variables, $hook): Allows the theme to set necessary
+ * variables for all theme hooks.
+ * - THEME_preprocess_HOOK(&$variables): Allows the theme to set necessary
+ * variables specific to the particular theme hook.
+ * - template_process(&$variables, $hook): Creates a default set of variables
+ * for all theme hooks.
+ * - template_process_HOOK(&$variables): This is the first processor specific
+ * to the theme hook; it should be implemented by the module that registers
+ * it.
+ * - MODULE_process(&$variables, $hook): hook_process() is invoked on all
+ * implementing modules.
+ * - MODULE_process_HOOK(&$variables): hook_process_HOOK() is invoked on
+ * on all implementing modules, so that modules that didn't define the theme
+ * hook can alter the variables.
+ * - ENGINE_engine_process(&$variables, $hook): Allows the theme engine to set
+ * necessary variables for all theme hooks.
+ * - ENGINE_engine_process_HOOK(&$variables): Allows the theme engine to set
+ * necessary variables for the particular theme hook.
+ * - ENGINE_process(&$variables, $hook): Allows the theme engine to process the
+ * variables.
+ * - ENGINE_process_HOOK(&$variables): Allows the theme engine to process the
+ * variables specific to the theme hook.
+ * - THEME_process(&$variables, $hook): Allows the theme to process the
+ * variables.
+ * - THEME_process_HOOK(&$variables): Allows the theme to process the
+ * variables specific to the theme hook.
+ *
+ * If the implementation is a function, only the theme-hook-specific preprocess
+ * and process functions (the ones ending in _HOOK) are called from the
+ * list above. This is because theme hooks with function implementations
+ * need to be fast, and calling the non-theme-hook-specific preprocess and
+ * process functions for them would incur a noticeable performance penalty.
+ *
+ * There are two special variables that these preprocess and process functions
+ * can set: 'theme_hook_suggestion' and 'theme_hook_suggestions'. These will be
+ * merged together to form a list of 'suggested' alternate theme hooks to use,
+ * in reverse order of priority. theme_hook_suggestion will always be a higher
+ * priority than items in theme_hook_suggestions. theme() will use the
+ * highest priority implementation that exists. If none exists, theme() will
+ * use the implementation for the theme hook it was called with. These
+ * suggestions are similar to and are used for similar reasons as calling
+ * theme() with an array as the $hook parameter (see below). The difference
+ * is whether the suggestions are determined by the code that calls theme() or
+ * by a preprocess or process function.
+ *
+ * @param $hook
+ * The name of the theme hook to call. If the name contains a
+ * double-underscore ('__') and there isn't an implementation for the full
+ * name, the part before the '__' is checked. This allows a fallback to a more
+ * generic implementation. For example, if theme('links__node', ...) is
+ * called, but there is no implementation of that theme hook, then the 'links'
+ * implementation is used. This process is iterative, so if
+ * theme('links__contextual__node', ...) is called, theme() checks for the
+ * following implementations, and uses the first one that exists:
+ * - links__contextual__node
+ * - links__contextual
+ * - links
+ * This allows themes to create specific theme implementations for named
+ * objects and contexts of otherwise generic theme hooks. The $hook parameter
+ * may also be an array, in which case the first theme hook that has an
+ * implementation is used. This allows for the code that calls theme() to
+ * explicitly specify the fallback order in a situation where using the '__'
+ * convention is not desired or is insufficient.
+ * @param $variables
+ * An associative array of variables to merge with defaults from the theme
+ * registry, pass to preprocess and process functions for modification, and
+ * finally, pass to the function or template implementing the theme hook.
+ * Alternatively, this can be a renderable array, in which case, its
+ * properties are mapped to variables expected by the theme hook
+ * implementations.
+ *
+ * @return
+ * An HTML string representing the themed output.
+ */
+function theme($hook, $variables = array()) {
+ static $hooks = NULL;
+
+ // If called before all modules are loaded, we do not necessarily have a full
+ // theme registry to work with, and therefore cannot process the theme
+ // request properly. See also _theme_load_registry().
+ if (!module_load_all(NULL) && !defined('MAINTENANCE_MODE')) {
+ throw new Exception(t('theme() may not be called until all modules are loaded.'));
+ }
+
+ if (!isset($hooks)) {
+ drupal_theme_initialize();
+ $hooks = theme_get_registry(FALSE);
+ }
+
+ // If an array of hook candidates were passed, use the first one that has an
+ // implementation.
+ if (is_array($hook)) {
+ foreach ($hook as $candidate) {
+ if (isset($hooks[$candidate])) {
+ break;
+ }
+ }
+ $hook = $candidate;
+ }
+
+ // If there's no implementation, check for more generic fallbacks. If there's
+ // still no implementation, log an error and return an empty string.
+ if (!isset($hooks[$hook])) {
+ // Iteratively strip everything after the last '__' delimiter, until an
+ // implementation is found.
+ while ($pos = strrpos($hook, '__')) {
+ $hook = substr($hook, 0, $pos);
+ if (isset($hooks[$hook])) {
+ break;
+ }
+ }
+ if (!isset($hooks[$hook])) {
+ // Only log a message when not trying theme suggestions ($hook being an
+ // array).
+ if (!isset($candidate)) {
+ watchdog('theme', 'Theme key "@key" not found.', array('@key' => $hook), WATCHDOG_WARNING);
+ }
+ return '';
+ }
+ }
+
+ $info = $hooks[$hook];
+ global $theme_path;
+ $temp = $theme_path;
+ // point path_to_theme() to the currently used theme path:
+ $theme_path = $info['theme path'];
+
+ // Include a file if the theme function or variable processor is held elsewhere.
+ if (!empty($info['includes'])) {
+ foreach ($info['includes'] as $include_file) {
+ include_once DRUPAL_ROOT . '/' . $include_file;
+ }
+ }
+
+ // If a renderable array is passed as $variables, then set $variables to
+ // the arguments expected by the theme function.
+ if (isset($variables['#theme']) || isset($variables['#theme_wrappers'])) {
+ $element = $variables;
+ $variables = array();
+ if (isset($info['variables'])) {
+ foreach (array_keys($info['variables']) as $name) {
+ if (isset($element["#$name"])) {
+ $variables[$name] = $element["#$name"];
+ }
+ }
+ }
+ else {
+ $variables[$info['render element']] = $element;
+ }
+ }
+
+ // Merge in argument defaults.
+ if (!empty($info['variables'])) {
+ $variables += $info['variables'];
+ }
+ elseif (!empty($info['render element'])) {
+ $variables += array($info['render element'] => array());
+ }
+
+ // Invoke the variable processors, if any. The processors may specify
+ // alternate suggestions for which hook's template/function to use. If the
+ // hook is a suggestion of a base hook, invoke the variable processors of
+ // the base hook, but retain the suggestion as a high priority suggestion to
+ // be used unless overridden by a variable processor function.
+ if (isset($info['base hook'])) {
+ $base_hook = $info['base hook'];
+ $base_hook_info = $hooks[$base_hook];
+ if (isset($base_hook_info['preprocess functions']) || isset($base_hook_info['process functions'])) {
+ $variables['theme_hook_suggestion'] = $hook;
+ $hook = $base_hook;
+ $info = $base_hook_info;
+ }
+ }
+ if (isset($info['preprocess functions']) || isset($info['process functions'])) {
+ $variables['theme_hook_suggestions'] = array();
+ foreach (array('preprocess functions', 'process functions') as $phase) {
+ if (!empty($info[$phase])) {
+ foreach ($info[$phase] as $processor_function) {
+ if (function_exists($processor_function)) {
+ // We don't want a poorly behaved process function changing $hook.
+ $hook_clone = $hook;
+ $processor_function($variables, $hook_clone);
+ }
+ }
+ }
+ }
+ // If the preprocess/process functions specified hook suggestions, and the
+ // suggestion exists in the theme registry, use it instead of the hook that
+ // theme() was called with. This allows the preprocess/process step to
+ // route to a more specific theme hook. For example, a function may call
+ // theme('node', ...), but a preprocess function can add 'node__article' as
+ // a suggestion, enabling a theme to have an alternate template file for
+ // article nodes. Suggestions are checked in the following order:
+ // - The 'theme_hook_suggestion' variable is checked first. It overrides
+ // all others.
+ // - The 'theme_hook_suggestions' variable is checked in FILO order, so the
+ // last suggestion added to the array takes precedence over suggestions
+ // added earlier.
+ $suggestions = array();
+ if (!empty($variables['theme_hook_suggestions'])) {
+ $suggestions = $variables['theme_hook_suggestions'];
+ }
+ if (!empty($variables['theme_hook_suggestion'])) {
+ $suggestions[] = $variables['theme_hook_suggestion'];
+ }
+ foreach (array_reverse($suggestions) as $suggestion) {
+ if (isset($hooks[$suggestion])) {
+ $info = $hooks[$suggestion];
+ break;
+ }
+ }
+ }
+
+ // Generate the output using either a function or a template.
+ $output = '';
+ if (isset($info['function'])) {
+ if (function_exists($info['function'])) {
+ $output = $info['function']($variables);
+ }
+ }
+ else {
+ // Default render function and extension.
+ $render_function = 'theme_render_template';
+ $extension = '.tpl.php';
+
+ // The theme engine may use a different extension and a different renderer.
+ global $theme_engine;
+ if (isset($theme_engine)) {
+ if ($info['type'] != 'module') {
+ if (function_exists($theme_engine . '_render_template')) {
+ $render_function = $theme_engine . '_render_template';
+ }
+ $extension_function = $theme_engine . '_extension';
+ if (function_exists($extension_function)) {
+ $extension = $extension_function();
+ }
+ }
+ }
+
+ // In some cases, a template implementation may not have had
+ // template_preprocess() run (for example, if the default implementation is
+ // a function, but a template overrides that default implementation). In
+ // these cases, a template should still be able to expect to have access to
+ // the variables provided by template_preprocess(), so we add them here if
+ // they don't already exist. We don't want to run template_preprocess()
+ // twice (it would be inefficient and mess up zebra striping), so we use the
+ // 'directory' variable to determine if it has already run, which while not
+ // completely intuitive, is reasonably safe, and allows us to save on the
+ // overhead of adding some new variable to track that.
+ if (!isset($variables['directory'])) {
+ $default_template_variables = array();
+ template_preprocess($default_template_variables, $hook);
+ $variables += $default_template_variables;
+ }
+
+ // Render the output using the template file.
+ $template_file = $info['template'] . $extension;
+ if (isset($info['path'])) {
+ $template_file = $info['path'] . '/' . $template_file;
+ }
+ $output = $render_function($template_file, $variables);
+ }
+
+ // restore path_to_theme()
+ $theme_path = $temp;
+ return $output;
+}
+
+/**
+ * Return the path to the current themed element.
+ *
+ * It can point to the active theme or the module handling a themed implementation.
+ * For example, when invoked within the scope of a theming call it will depend
+ * on where the theming function is handled. If implemented from a module, it
+ * will point to the module. If implemented from the active theme, it will point
+ * to the active theme. When called outside the scope of a theming call, it will
+ * always point to the active theme.
+ */
+function path_to_theme() {
+ global $theme_path;
+
+ if (!isset($theme_path)) {
+ drupal_theme_initialize();
+ }
+
+ return $theme_path;
+}
+
+/**
+ * Allow themes and/or theme engines to easily discover overridden theme functions.
+ *
+ * @param $cache
+ * The existing cache of theme hooks to test against.
+ * @param $prefixes
+ * An array of prefixes to test, in reverse order of importance.
+ *
+ * @return $implementations
+ * The functions found, suitable for returning from hook_theme;
+ */
+function drupal_find_theme_functions($cache, $prefixes) {
+ $implementations = array();
+ $functions = get_defined_functions();
+
+ foreach ($cache as $hook => $info) {
+ foreach ($prefixes as $prefix) {
+ // Find theme functions that implement possible "suggestion" variants of
+ // registered theme hooks and add those as new registered theme hooks.
+ // The 'pattern' key defines a common prefix that all suggestions must
+ // start with. The default is the name of the hook followed by '__'. An
+ // 'base hook' key is added to each entry made for a found suggestion,
+ // so that common functionality can be implemented for all suggestions of
+ // the same base hook. To keep things simple, deep hierarchy of
+ // suggestions is not supported: each suggestion's 'base hook' key
+ // refers to a base hook, not to another suggestion, and all suggestions
+ // are found using the base hook's pattern, not a pattern from an
+ // intermediary suggestion.
+ $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__');
+ if (!isset($info['base hook']) && !empty($pattern)) {
+ $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $functions['user']);
+ if ($matches) {
+ foreach ($matches as $match) {
+ $new_hook = substr($match, strlen($prefix) + 1);
+ $arg_name = isset($info['variables']) ? 'variables' : 'render element';
+ $implementations[$new_hook] = array(
+ 'function' => $match,
+ $arg_name => $info[$arg_name],
+ 'base hook' => $hook,
+ );
+ }
+ }
+ }
+ // Find theme functions that implement registered theme hooks and include
+ // that in what is returned so that the registry knows that the theme has
+ // this implementation.
+ if (function_exists($prefix . '_' . $hook)) {
+ $implementations[$hook] = array(
+ 'function' => $prefix . '_' . $hook,
+ );
+ }
+ }
+ }
+
+ return $implementations;
+}
+
+/**
+ * Allow themes and/or theme engines to easily discover overridden templates.
+ *
+ * @param $cache
+ * The existing cache of theme hooks to test against.
+ * @param $extension
+ * The extension that these templates will have.
+ * @param $path
+ * The path to search.
+ */
+function drupal_find_theme_templates($cache, $extension, $path) {
+ $implementations = array();
+
+ // Collect paths to all sub-themes grouped by base themes. These will be
+ // used for filtering. This allows base themes to have sub-themes in its
+ // folder hierarchy without affecting the base themes template discovery.
+ $theme_paths = array();
+ foreach (list_themes() as $theme_info) {
+ if (!empty($theme_info->base_theme)) {
+ $theme_paths[$theme_info->base_theme][$theme_info->name] = dirname($theme_info->filename);
+ }
+ }
+ foreach ($theme_paths as $basetheme => $subthemes) {
+ foreach ($subthemes as $subtheme => $subtheme_path) {
+ if (isset($theme_paths[$subtheme])) {
+ $theme_paths[$basetheme] = array_merge($theme_paths[$basetheme], $theme_paths[$subtheme]);
+ }
+ }
+ }
+ global $theme;
+ $subtheme_paths = isset($theme_paths[$theme]) ? $theme_paths[$theme] : array();
+
+ // Escape the periods in the extension.
+ $regex = '/' . str_replace('.', '\.', $extension) . '$/';
+ // Get a listing of all template files in the path to search.
+ $files = file_scan_directory($path, $regex, array('key' => 'name'));
+
+ // Find templates that implement registered theme hooks and include that in
+ // what is returned so that the registry knows that the theme has this
+ // implementation.
+ foreach ($files as $template => $file) {
+ // Ignore sub-theme templates for the current theme.
+ if (strpos($file->uri, str_replace($subtheme_paths, '', $file->uri)) !== 0) {
+ continue;
+ }
+ // Chop off the remaining '.tpl' extension. $template already has the
+ // rightmost extension removed, but there might still be more, such as with
+ // .tpl.php, which still has .tpl in $template at this point.
+ if (($pos = strpos($template, '.tpl')) !== FALSE) {
+ $template = substr($template, 0, $pos);
+ }
+ // Transform - in filenames to _ to match function naming scheme
+ // for the purposes of searching.
+ $hook = strtr($template, '-', '_');
+ if (isset($cache[$hook])) {
+ $implementations[$hook] = array(
+ 'template' => $template,
+ 'path' => dirname($file->uri),
+ );
+ }
+
+ // Match templates based on the 'template' filename.
+ foreach ($cache as $hook => $info) {
+ if (isset($info['template'])) {
+ $template_candidates = array($info['template'], str_replace($info['theme path'] . '/', '', $info['template']));
+ if (in_array($template, $template_candidates)) {
+ $implementations[$hook] = array(
+ 'template' => $template,
+ 'path' => dirname($file->uri),
+ );
+ }
+ }
+ }
+ }
+
+ // Find templates that implement possible "suggestion" variants of registered
+ // theme hooks and add those as new registered theme hooks. See
+ // drupal_find_theme_functions() for more information about suggestions and
+ // the use of 'pattern' and 'base hook'.
+ $patterns = array_keys($files);
+ foreach ($cache as $hook => $info) {
+ $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__');
+ if (!isset($info['base hook']) && !empty($pattern)) {
+ // Transform _ in pattern to - to match file naming scheme
+ // for the purposes of searching.
+ $pattern = strtr($pattern, '_', '-');
+
+ $matches = preg_grep('/^' . $pattern . '/', $patterns);
+ if ($matches) {
+ foreach ($matches as $match) {
+ $file = substr($match, 0, strpos($match, '.'));
+ // Put the underscores back in for the hook name and register this pattern.
+ $arg_name = isset($info['variables']) ? 'variables' : 'render element';
+ $implementations[strtr($file, '-', '_')] = array(
+ 'template' => $file,
+ 'path' => dirname($files[$match]->uri),
+ $arg_name => $info[$arg_name],
+ 'base hook' => $hook,
+ );
+ }
+ }
+ }
+ }
+ return $implementations;
+}
+
+/**
+ * Retrieve a setting for the current theme or for a given theme.
+ *
+ * The final setting is obtained from the last value found in the following
+ * sources:
+ * - the default global settings specified in this function
+ * - the default theme-specific settings defined in any base theme's .info file
+ * - the default theme-specific settings defined in the theme's .info file
+ * - the saved values from the global theme settings form
+ * - the saved values from the theme's settings form
+ * To only retrieve the default global theme setting, an empty string should be
+ * given for $theme.
+ *
+ * @param $setting_name
+ * The name of the setting to be retrieved.
+ * @param $theme
+ * The name of a given theme; defaults to the current theme.
+ *
+ * @return
+ * The value of the requested setting, NULL if the setting does not exist.
+ */
+function theme_get_setting($setting_name, $theme = NULL) {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ // If no key is given, use the current theme if we can determine it.
+ if (!isset($theme)) {
+ $theme = !empty($GLOBALS['theme_key']) ? $GLOBALS['theme_key'] : '';
+ }
+
+ if (empty($cache[$theme])) {
+ // Set the default values for each global setting.
+ // To add new global settings, add their default values below, and then
+ // add form elements to system_theme_settings() in system.admin.inc.
+ $cache[$theme] = array(
+ 'default_logo' => 1,
+ 'logo_path' => '',
+ 'default_favicon' => 1,
+ 'favicon_path' => '',
+ // Use the IANA-registered MIME type for ICO files as default.
+ 'favicon_mimetype' => 'image/vnd.microsoft.icon',
+ );
+ // Turn on all default features.
+ $features = _system_default_theme_features();
+ foreach ($features as $feature) {
+ $cache[$theme]['toggle_' . $feature] = 1;
+ }
+
+ // Get the values for the theme-specific settings from the .info files of
+ // the theme and all its base themes.
+ if ($theme) {
+ $themes = list_themes();
+ $theme_object = $themes[$theme];
+
+ // Create a list which includes the current theme and all its base themes.
+ if (isset($theme_object->base_themes)) {
+ $theme_keys = array_keys($theme_object->base_themes);
+ $theme_keys[] = $theme;
+ }
+ else {
+ $theme_keys = array($theme);
+ }
+ foreach ($theme_keys as $theme_key) {
+ if (!empty($themes[$theme_key]->info['settings'])) {
+ $cache[$theme] = array_merge($cache[$theme], $themes[$theme_key]->info['settings']);
+ }
+ }
+ }
+
+ // Get the saved global settings from the database.
+ $cache[$theme] = array_merge($cache[$theme], variable_get('theme_settings', array()));
+
+ if ($theme) {
+ // Get the saved theme-specific settings from the database.
+ $cache[$theme] = array_merge($cache[$theme], variable_get('theme_' . $theme . '_settings', array()));
+
+ // If the theme does not support a particular feature, override the global
+ // setting and set the value to NULL.
+ if (!empty($theme_object->info['features'])) {
+ foreach ($features as $feature) {
+ if (!in_array($feature, $theme_object->info['features'])) {
+ $cache[$theme]['toggle_' . $feature] = NULL;
+ }
+ }
+ }
+
+ // Generate the path to the logo image.
+ if ($cache[$theme]['toggle_logo']) {
+ if ($cache[$theme]['default_logo']) {
+ $cache[$theme]['logo'] = file_create_url(dirname($theme_object->filename) . '/logo.png');
+ }
+ elseif ($cache[$theme]['logo_path']) {
+ $cache[$theme]['logo'] = file_create_url($cache[$theme]['logo_path']);
+ }
+ }
+
+ // Generate the path to the favicon.
+ if ($cache[$theme]['toggle_favicon']) {
+ if ($cache[$theme]['default_favicon']) {
+ if (file_exists($favicon = dirname($theme_object->filename) . '/favicon.ico')) {
+ $cache[$theme]['favicon'] = file_create_url($favicon);
+ }
+ else {
+ $cache[$theme]['favicon'] = file_create_url('core/misc/favicon.ico');
+ }
+ }
+ elseif ($cache[$theme]['favicon_path']) {
+ $cache[$theme]['favicon'] = file_create_url($cache[$theme]['favicon_path']);
+ }
+ else {
+ $cache[$theme]['toggle_favicon'] = FALSE;
+ }
+ }
+ }
+ }
+
+ return isset($cache[$theme][$setting_name]) ? $cache[$theme][$setting_name] : NULL;
+}
+
+/**
+ * Render a system default template, which is essentially a PHP template.
+ *
+ * @param $template_file
+ * The filename of the template to render.
+ * @param $variables
+ * A keyed array of variables that will appear in the output.
+ *
+ * @return
+ * The output generated by the template.
+ */
+function theme_render_template($template_file, $variables) {
+ extract($variables, EXTR_SKIP); // Extract the variables to a local namespace
+ ob_start(); // Start output buffering
+ include DRUPAL_ROOT . '/' . $template_file; // Include the template file
+ return ob_get_clean(); // End buffering and return its contents
+}
+
+/**
+ * Enable a given list of themes.
+ *
+ * @param $theme_list
+ * An array of theme names.
+ */
+function theme_enable($theme_list) {
+ drupal_clear_css_cache();
+
+ foreach ($theme_list as $key) {
+ db_update('system')
+ ->fields(array('status' => 1))
+ ->condition('type', 'theme')
+ ->condition('name', $key)
+ ->execute();
+ }
+
+ list_themes(TRUE);
+ menu_rebuild();
+ drupal_theme_rebuild();
+
+ // Invoke hook_themes_enabled() after the themes have been enabled.
+ module_invoke_all('themes_enabled', $theme_list);
+}
+
+/**
+ * Disable a given list of themes.
+ *
+ * @param $theme_list
+ * An array of theme names.
+ */
+function theme_disable($theme_list) {
+ // Don't disable the default theme.
+ if ($pos = array_search(variable_get('theme_default', 'bartik'), $theme_list) !== FALSE) {
+ unset($theme_list[$pos]);
+ if (empty($theme_list)) {
+ return;
+ }
+ }
+
+ drupal_clear_css_cache();
+
+ foreach ($theme_list as $key) {
+ db_update('system')
+ ->fields(array('status' => 0))
+ ->condition('type', 'theme')
+ ->condition('name', $key)
+ ->execute();
+ }
+
+ list_themes(TRUE);
+ menu_rebuild();
+ drupal_theme_rebuild();
+
+ // Invoke hook_themes_disabled after the themes have been disabled.
+ module_invoke_all('themes_disabled', $theme_list);
+}
+
+/**
+ * @ingroup themeable
+ * @{
+ */
+
+/**
+ * Returns HTML for status and/or error messages, grouped by type.
+ *
+ * An invisible heading identifies the messages for assistive technology.
+ * Sighted users see a colored box. See http://www.w3.org/TR/WCAG-TECHS/H69.html
+ * for info.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - display: (optional) Set to 'status' or 'error' to display only messages
+ * of that type.
+ */
+function theme_status_messages($variables) {
+ $display = $variables['display'];
+ $output = '';
+
+ $status_heading = array(
+ 'status' => t('Status message'),
+ 'error' => t('Error message'),
+ 'warning' => t('Warning message'),
+ );
+ foreach (drupal_get_messages($display) as $type => $messages) {
+ $output .= "<div class=\"messages $type\">\n";
+ if (!empty($status_heading[$type])) {
+ $output .= '<h2 class="element-invisible">' . $status_heading[$type] . "</h2>\n";
+ }
+ if (count($messages) > 1) {
+ $output .= " <ul>\n";
+ foreach ($messages as $message) {
+ $output .= ' <li>' . $message . "</li>\n";
+ }
+ $output .= " </ul>\n";
+ }
+ else {
+ $output .= $messages[0];
+ }
+ $output .= "</div>\n";
+ }
+ return $output;
+}
+
+/**
+ * Returns HTML for a link.
+ *
+ * All Drupal code that outputs a link should call the l() function. That
+ * function performs some initial preprocessing, and then, if necessary, calls
+ * theme('link') for rendering the anchor tag.
+ *
+ * To optimize performance for sites that don't need custom theming of links,
+ * the l() function includes an inline copy of this function, and uses that copy
+ * if none of the enabled modules or the active theme implement any preprocess
+ * or process functions or override this theme implementation.
+ *
+ * @param $variables
+ * An associative array containing the keys 'text', 'path', and 'options'. See
+ * the l() function for information about these variables.
+ *
+ * @see l()
+ */
+function theme_link($variables) {
+ return '<a href="' . check_plain(url($variables['path'], $variables['options'])) . '"' . drupal_attributes($variables['options']['attributes']) . '>' . ($variables['options']['html'] ? $variables['text'] : check_plain($variables['text'])) . '</a>';
+}
+
+/**
+ * Returns HTML for a set of links.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - links: An associative array of links to be themed. The key for each link
+ * is used as its css class. Each link should be itself an array, with the
+ * following elements:
+ * - title: The link text.
+ * - href: The link URL. If omitted, the 'title' is shown as a plain text
+ * item in the links list.
+ * - html: (optional) Whether or not 'title' is HTML. If set, the title
+ * will not be passed through check_plain().
+ * - attributes: (optional) Attributes for the anchor, or for the <span> tag
+ * used in its place if no 'href' is supplied. If element 'class' is
+ * included, it must be an array of one or more class names.
+ * If the 'href' element is supplied, the entire link array is passed to l()
+ * as its $options parameter.
+ * - attributes: A keyed array of attributes for the UL containing the
+ * list of links.
+ * - heading: (optional) A heading to precede the links. May be an associative
+ * array or a string. If it's an array, it can have the following elements:
+ * - text: The heading text.
+ * - level: The heading level (e.g. 'h2', 'h3').
+ * - class: (optional) An array of the CSS classes for the heading.
+ * When using a string it will be used as the text of the heading and the
+ * level will default to 'h2'. Headings should be used on navigation menus
+ * and any list of links that consistently appears on multiple pages. To
+ * make the heading invisible use the 'element-invisible' CSS class. Do not
+ * use 'display:none', which removes it from screen-readers and assistive
+ * technology. Headings allow screen-reader and keyboard only users to
+ * navigate to or skip the links. See
+ * http://juicystudio.com/article/screen-readers-display-none.php and
+ * http://www.w3.org/TR/WCAG-TECHS/H42.html for more information.
+ */
+function theme_links($variables) {
+ $links = $variables['links'];
+ $attributes = $variables['attributes'];
+ $heading = $variables['heading'];
+ global $language_url;
+ $output = '';
+
+ if (count($links) > 0) {
+ $output = '';
+
+ // Treat the heading first if it is present to prepend it to the
+ // list of links.
+ if (!empty($heading)) {
+ if (is_string($heading)) {
+ // Prepare the array that will be used when the passed heading
+ // is a string.
+ $heading = array(
+ 'text' => $heading,
+ // Set the default level of the heading.
+ 'level' => 'h2',
+ );
+ }
+ $output .= '<' . $heading['level'];
+ if (!empty($heading['class'])) {
+ $output .= drupal_attributes(array('class' => $heading['class']));
+ }
+ $output .= '>' . check_plain($heading['text']) . '</' . $heading['level'] . '>';
+ }
+
+ $output .= '<ul' . drupal_attributes($attributes) . '>';
+
+ $num_links = count($links);
+ $i = 1;
+
+ foreach ($links as $key => $link) {
+ $class = array($key);
+
+ // Add first, last and active classes to the list of links to help out themers.
+ if ($i == 1) {
+ $class[] = 'first';
+ }
+ if ($i == $num_links) {
+ $class[] = 'last';
+ }
+ if (isset($link['href']) && ($link['href'] == $_GET['q'] || ($link['href'] == '<front>' && drupal_is_front_page()))
+ && (empty($link['language']) || $link['language']->language == $language_url->language)) {
+ $class[] = 'active';
+ }
+ $output .= '<li' . drupal_attributes(array('class' => $class)) . '>';
+
+ if (isset($link['href'])) {
+ // Pass in $link as $options, they share the same keys.
+ $output .= l($link['title'], $link['href'], $link);
+ }
+ elseif (!empty($link['title'])) {
+ // Some links are actually not links, but we wrap these in <span> for adding title and class attributes.
+ if (empty($link['html'])) {
+ $link['title'] = check_plain($link['title']);
+ }
+ $span_attributes = '';
+ if (isset($link['attributes'])) {
+ $span_attributes = drupal_attributes($link['attributes']);
+ }
+ $output .= '<span' . $span_attributes . '>' . $link['title'] . '</span>';
+ }
+
+ $i++;
+ $output .= "</li>\n";
+ }
+
+ $output .= '</ul>';
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for an image.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - path: Either the path of the image file (relative to base_path()) or a
+ * full URL.
+ * - width: The width of the image (if known).
+ * - height: The height of the image (if known).
+ * - alt: The alternative text for text-based browsers. HTML 4 and XHTML 1.0
+ * always require an alt attribute. The HTML 5 draft allows the alt
+ * attribute to be omitted in some cases. Therefore, this variable defaults
+ * to an empty string, but can be set to NULL for the attribute to be
+ * omitted. Usually, neither omission nor an empty string satisfies
+ * accessibility requirements, so it is strongly encouraged for code calling
+ * theme('image') to pass a meaningful value for this variable.
+ * - http://www.w3.org/TR/REC-html40/struct/objects.html#h-13.8
+ * - http://www.w3.org/TR/xhtml1/dtds.html
+ * - http://dev.w3.org/html5/spec/Overview.html#alt
+ * - title: The title text is displayed when the image is hovered in some
+ * popular browsers.
+ * - attributes: Associative array of attributes to be placed in the img tag.
+ */
+function theme_image($variables) {
+ $attributes = $variables['attributes'];
+ $attributes['src'] = file_create_url($variables['path']);
+
+ foreach (array('width', 'height', 'alt', 'title') as $key) {
+
+ if (isset($variables[$key])) {
+ $attributes[$key] = $variables[$key];
+ }
+ }
+
+ return '<img' . drupal_attributes($attributes) . ' />';
+}
+
+/**
+ * Returns HTML for a breadcrumb trail.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - breadcrumb: An array containing the breadcrumb links.
+ */
+function theme_breadcrumb($variables) {
+ $breadcrumb = $variables['breadcrumb'];
+
+ if (!empty($breadcrumb)) {
+ // Provide a navigational heading to give context for breadcrumb links to
+ // screen-reader users. Make the heading invisible with .element-invisible.
+ $output = '<h2 class="element-invisible">' . t('You are here') . '</h2>';
+
+ $output .= '<div class="breadcrumb">' . implode(' » ', $breadcrumb) . '</div>';
+ return $output;
+ }
+}
+
+/**
+ * Returns HTML for a table.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - header: An array containing the table headers. Each element of the array
+ * can be either a localized string or an associative array with the
+ * following keys:
+ * - "data": The localized title of the table column.
+ * - "field": The database field represented in the table column (required
+ * if user is to be able to sort on this column).
+ * - "sort": A default sort order for this column ("asc" or "desc").
+ * - Any HTML attributes, such as "colspan", to apply to the column header
+ * cell.
+ * - rows: An array of table rows. Every row is an array of cells, or an
+ * associative array with the following keys:
+ * - "data": an array of cells
+ * - Any HTML attributes, such as "class", to apply to the table row.
+ * - "no_striping": a boolean indicating that the row should receive no
+ * 'even / odd' styling. Defaults to FALSE.
+ * Each cell can be either a string or an associative array with the
+ * following keys:
+ * - "data": The string to display in the table cell.
+ * - "header": Indicates this cell is a header.
+ * - Any HTML attributes, such as "colspan", to apply to the table cell.
+ * Here's an example for $rows:
+ * @code
+ * $rows = array(
+ * // Simple row
+ * array(
+ * 'Cell 1', 'Cell 2', 'Cell 3'
+ * ),
+ * // Row with attributes on the row and some of its cells.
+ * array(
+ * 'data' => array('Cell 1', array('data' => 'Cell 2', 'colspan' => 2)), 'class' => array('funky')
+ * )
+ * );
+ * @endcode
+ * - attributes: An array of HTML attributes to apply to the table tag.
+ * - caption: A localized string to use for the <caption> tag.
+ * - colgroups: An array of column groups. Each element of the array can be
+ * either:
+ * - An array of columns, each of which is an associative array of HTML
+ * attributes applied to the COL element.
+ * - An array of attributes applied to the COLGROUP element, which must
+ * include a "data" attribute. To add attributes to COL elements, set the
+ * "data" attribute with an array of columns, each of which is an
+ * associative array of HTML attributes.
+ * Here's an example for $colgroup:
+ * @code
+ * $colgroup = array(
+ * // COLGROUP with one COL element.
+ * array(
+ * array(
+ * 'class' => array('funky'), // Attribute for the COL element.
+ * ),
+ * ),
+ * // Colgroup with attributes and inner COL elements.
+ * array(
+ * 'data' => array(
+ * array(
+ * 'class' => array('funky'), // Attribute for the COL element.
+ * ),
+ * ),
+ * 'class' => array('jazzy'), // Attribute for the COLGROUP element.
+ * ),
+ * );
+ * @endcode
+ * These optional tags are used to group and set properties on columns
+ * within a table. For example, one may easily group three columns and
+ * apply same background style to all.
+ * - sticky: Use a "sticky" table header.
+ * - empty: The message to display in an extra row if table does not have any
+ * rows.
+ */
+function theme_table($variables) {
+ $header = $variables['header'];
+ $rows = $variables['rows'];
+ $attributes = $variables['attributes'];
+ $caption = $variables['caption'];
+ $colgroups = $variables['colgroups'];
+ $sticky = $variables['sticky'];
+ $empty = $variables['empty'];
+
+ // Add sticky headers, if applicable.
+ if (count($header) && $sticky) {
+ drupal_add_js('core/misc/tableheader.js');
+ // Add 'sticky-enabled' class to the table to identify it for JS.
+ // This is needed to target tables constructed by this function.
+ $attributes['class'][] = 'sticky-enabled';
+ }
+
+ $output = '<table' . drupal_attributes($attributes) . ">\n";
+
+ if (isset($caption)) {
+ $output .= '<caption>' . $caption . "</caption>\n";
+ }
+
+ // Format the table columns:
+ if (count($colgroups)) {
+ foreach ($colgroups as $number => $colgroup) {
+ $attributes = array();
+
+ // Check if we're dealing with a simple or complex column
+ if (isset($colgroup['data'])) {
+ foreach ($colgroup as $key => $value) {
+ if ($key == 'data') {
+ $cols = $value;
+ }
+ else {
+ $attributes[$key] = $value;
+ }
+ }
+ }
+ else {
+ $cols = $colgroup;
+ }
+
+ // Build colgroup
+ if (is_array($cols) && count($cols)) {
+ $output .= ' <colgroup' . drupal_attributes($attributes) . '>';
+ $i = 0;
+ foreach ($cols as $col) {
+ $output .= ' <col' . drupal_attributes($col) . ' />';
+ }
+ $output .= " </colgroup>\n";
+ }
+ else {
+ $output .= ' <colgroup' . drupal_attributes($attributes) . " />\n";
+ }
+ }
+ }
+
+ // Add the 'empty' row message if available.
+ if (!count($rows) && $empty) {
+ $header_count = 0;
+ foreach ($header as $header_cell) {
+ if (is_array($header_cell)) {
+ $header_count += isset($header_cell['colspan']) ? $header_cell['colspan'] : 1;
+ }
+ else {
+ $header_count++;
+ }
+ }
+ $rows[] = array(array('data' => $empty, 'colspan' => $header_count, 'class' => array('empty', 'message')));
+ }
+
+ // Format the table header:
+ if (count($header)) {
+ $ts = tablesort_init($header);
+ // HTML requires that the thead tag has tr tags in it followed by tbody
+ // tags. Using ternary operator to check and see if we have any rows.
+ $output .= (count($rows) ? ' <thead><tr>' : ' <tr>');
+ foreach ($header as $cell) {
+ $cell = tablesort_header($cell, $header, $ts);
+ $output .= _theme_table_cell($cell, TRUE);
+ }
+ // Using ternary operator to close the tags based on whether or not there are rows
+ $output .= (count($rows) ? " </tr></thead>\n" : "</tr>\n");
+ }
+ else {
+ $ts = array();
+ }
+
+ // Format the table rows:
+ if (count($rows)) {
+ $output .= "<tbody>\n";
+ $flip = array('even' => 'odd', 'odd' => 'even');
+ $class = 'even';
+ foreach ($rows as $number => $row) {
+ $attributes = array();
+
+ // Check if we're dealing with a simple or complex row
+ if (isset($row['data'])) {
+ foreach ($row as $key => $value) {
+ if ($key == 'data') {
+ $cells = $value;
+ }
+ else {
+ $attributes[$key] = $value;
+ }
+ }
+ }
+ else {
+ $cells = $row;
+ }
+ if (count($cells)) {
+ // Add odd/even class
+ if (empty($row['no_striping'])) {
+ $class = $flip[$class];
+ $attributes['class'][] = $class;
+ }
+
+ // Build row
+ $output .= ' <tr' . drupal_attributes($attributes) . '>';
+ $i = 0;
+ foreach ($cells as $cell) {
+ $cell = tablesort_cell($cell, $header, $ts, $i++);
+ $output .= _theme_table_cell($cell);
+ }
+ $output .= " </tr>\n";
+ }
+ }
+ $output .= "</tbody>\n";
+ }
+
+ $output .= "</table>\n";
+ return $output;
+}
+
+/**
+ * Returns HTML for a sort icon.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - style: Set to either 'asc' or 'desc', this determines which icon to show.
+ */
+function theme_tablesort_indicator($variables) {
+ if ($variables['style'] == "asc") {
+ return theme('image', array('path' => 'core/misc/arrow-asc.png', 'width' => 13, 'height' => 13, 'alt' => t('sort ascending'), 'title' => t('sort ascending')));
+ }
+ else {
+ return theme('image', array('path' => 'core/misc/arrow-desc.png', 'width' => 13, 'height' => 13, 'alt' => t('sort descending'), 'title' => t('sort descending')));
+ }
+}
+
+/**
+ * Returns HTML for a marker for new or updated content.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - type: Number representing the marker type to display. See MARK_NEW,
+ * MARK_UPDATED, MARK_READ.
+ */
+function theme_mark($variables) {
+ $type = $variables['type'];
+ global $user;
+ if ($user->uid) {
+ if ($type == MARK_NEW) {
+ return ' <span class="marker">' . t('new') . '</span>';
+ }
+ elseif ($type == MARK_UPDATED) {
+ return ' <span class="marker">' . t('updated') . '</span>';
+ }
+ }
+}
+
+/**
+ * Returns HTML for a list or nested list of items.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - items: A list of items to render. String values are rendered as is. Each
+ * item can also be an associative array containing:
+ * - data: The string content of the list item.
+ * - children: A list of nested child items to render that behave
+ * identically to 'items', but any non-numeric string keys are treated as
+ * HTML attributes for the child list that wraps 'children'.
+ * Any other key/value pairs are used as HTML attributes for the list item
+ * in 'data'.
+ * - title: The title of the list.
+ * - type: The type of list to return (e.g. "ul", "ol").
+ * - attributes: The attributes applied to the list element.
+ */
+function theme_item_list($variables) {
+ $items = $variables['items'];
+ $title = $variables['title'];
+ $type = $variables['type'];
+ $list_attributes = $variables['attributes'];
+
+ $output = '';
+ if ($items) {
+ $output .= '<' . $type . drupal_attributes($list_attributes) . '>';
+
+ $num_items = count($items);
+ $i = 0;
+ foreach ($items as $key => $item) {
+ $i++;
+ $attributes = array();
+
+ if (is_array($item)) {
+ $value = '';
+ if (isset($item['data'])) {
+ $value .= $item['data'];
+ }
+ $attributes = array_diff_key($item, array('data' => 0, 'children' => 0));
+
+ // Append nested child list, if any.
+ if (isset($item['children'])) {
+ // HTML attributes for the outer list are defined in the 'attributes'
+ // theme variable, but not inherited by children. For nested lists,
+ // all non-numeric keys in 'children' are used as list attributes.
+ $child_list_attributes = array();
+ foreach ($item['children'] as $child_key => $child_item) {
+ if (is_string($child_key)) {
+ $child_list_attributes[$child_key] = $child_item;
+ unset($item['children'][$child_key]);
+ }
+ }
+ $value .= theme('item_list', array(
+ 'items' => $item['children'],
+ 'type' => $type,
+ 'attributes' => $child_list_attributes,
+ ));
+ }
+ }
+ else {
+ $value = $item;
+ }
+
+ $attributes['class'][] = ($i % 2 ? 'odd' : 'even');
+ if ($i == 1) {
+ $attributes['class'][] = 'first';
+ }
+ if ($i == $num_items) {
+ $attributes['class'][] = 'last';
+ }
+
+ $output .= '<li' . drupal_attributes($attributes) . '>' . $value . '</li>';
+ }
+ $output .= "</$type>";
+ }
+
+ // Only output the list container and title, if there are any list items.
+ if ($output !== '') {
+ if ($title !== '') {
+ $title = '<h3>' . $title . '</h3>';
+ }
+ $output = '<div class="item-list">' . $title . $output . '</div>';
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for a "more help" link.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - url: The url for the link.
+ */
+function theme_more_help_link($variables) {
+ return '<div class="more-help-link">' . l(t('More help'), $variables['url']) . '</div>';
+}
+
+/**
+ * Returns HTML for a feed icon.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - url: An internal system path or a fully qualified external URL of the
+ * feed.
+ * - title: A descriptive title of the feed.
+ */
+function theme_feed_icon($variables) {
+ $text = t('Subscribe to @feed-title', array('@feed-title' => $variables['title']));
+ if ($image = theme('image', array('path' => 'core/misc/feed.png', 'width' => 16, 'height' => 16, 'alt' => $text))) {
+ return l($image, $variables['url'], array('html' => TRUE, 'attributes' => array('class' => array('feed-icon'), 'title' => $text)));
+ }
+}
+
+/**
+ * Returns HTML for a generic HTML tag with attributes.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array describing the tag:
+ * - #tag: The tag name to output. Typical tags added to the HTML HEAD:
+ * - meta: To provide meta information, such as a page refresh.
+ * - link: To refer to stylesheets and other contextual information.
+ * - script: To load JavaScript.
+ * - #attributes: (optional) An array of HTML attributes to apply to the
+ * tag.
+ * - #value: (optional) A string containing tag content, such as inline CSS.
+ * - #value_prefix: (optional) A string to prepend to #value, e.g. a CDATA
+ * wrapper prefix.
+ * - #value_suffix: (optional) A string to append to #value, e.g. a CDATA
+ * wrapper suffix.
+ */
+function theme_html_tag($variables) {
+ $element = $variables['element'];
+ $attributes = isset($element['#attributes']) ? drupal_attributes($element['#attributes']) : '';
+ if (!isset($element['#value'])) {
+ return '<' . $element['#tag'] . $attributes . " />\n";
+ }
+ else {
+ $output = '<' . $element['#tag'] . $attributes . '>';
+ if (isset($element['#value_prefix'])) {
+ $output .= $element['#value_prefix'];
+ }
+ $output .= $element['#value'];
+ if (isset($element['#value_suffix'])) {
+ $output .= $element['#value_suffix'];
+ }
+ $output .= '</' . $element['#tag'] . ">\n";
+ return $output;
+ }
+}
+
+/**
+ * Returns HTML for a "more" link, like those used in blocks.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - url: The url of the main page.
+ * - title: A descriptive verb for the link, like 'Read more'.
+ */
+function theme_more_link($variables) {
+ return '<div class="more-link">' . l(t('More'), $variables['url'], array('attributes' => array('title' => $variables['title']))) . '</div>';
+}
+
+/**
+ * Returns HTML for a username, potentially linked to the user's page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - account: The user object to format.
+ * - name: The user's name, sanitized.
+ * - extra: Additional text to append to the user's name, sanitized.
+ * - link_path: The path or URL of the user's profile page, home page, or
+ * other desired page to link to for more information about the user.
+ * - link_options: An array of options to pass to the l() function's $options
+ * parameter if linking the user's name to the user's page.
+ * - attributes_array: An array of attributes to pass to the
+ * drupal_attributes() function if not linking to the user's page.
+ *
+ * @see template_preprocess_username()
+ * @see template_process_username()
+ */
+function theme_username($variables) {
+ if (isset($variables['link_path'])) {
+ // We have a link path, so we should generate a link using l().
+ // Additional classes may be added as array elements like
+ // $variables['link_options']['attributes']['class'][] = 'myclass';
+ $output = l($variables['name'] . $variables['extra'], $variables['link_path'], $variables['link_options']);
+ }
+ else {
+ // Modules may have added important attributes so they must be included
+ // in the output. Additional classes may be added as array elements like
+ // $variables['attributes_array']['class'][] = 'myclass';
+ $output = '<span' . drupal_attributes($variables['attributes_array']) . '>' . $variables['name'] . $variables['extra'] . '</span>';
+ }
+ return $output;
+}
+
+/**
+ * Returns HTML for a progress bar.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - percent: The percentage of the progress.
+ * - message: A string containing information to be displayed.
+ */
+function theme_progress_bar($variables) {
+ $output = '<div id="progress" class="progress">';
+ $output .= '<div class="bar"><div class="filled" style="width: ' . $variables['percent'] . '%"></div></div>';
+ $output .= '<div class="percentage">' . $variables['percent'] . '%</div>';
+ $output .= '<div class="message">' . $variables['message'] . '</div>';
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Returns HTML for an indentation div; used for drag and drop tables.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - size: Optional. The number of indentations to create.
+ */
+function theme_indentation($variables) {
+ $output = '';
+ for ($n = 0; $n < $variables['size']; $n++) {
+ $output .= '<div class="indentation">&nbsp;</div>';
+ }
+ return $output;
+}
+
+/**
+ * @} End of "ingroup themeable".
+ */
+
+/**
+ * Returns HTML output for a single table cell for theme_table().
+ *
+ * @param $cell
+ * Array of cell information, or string to display in cell.
+ * @param bool $header
+ * TRUE if this cell is a table header cell, FALSE if it is an ordinary
+ * table cell. If $cell is an array with element 'header' set to TRUE, that
+ * will override the $header parameter.
+ *
+ * @return
+ * HTML for the cell.
+ */
+function _theme_table_cell($cell, $header = FALSE) {
+ $attributes = '';
+
+ if (is_array($cell)) {
+ $data = isset($cell['data']) ? $cell['data'] : '';
+ // Cell's data property can be a string or a renderable array.
+ if (is_array($data)) {
+ $data = drupal_render($data);
+ }
+ $header |= isset($cell['header']);
+ unset($cell['data']);
+ unset($cell['header']);
+ $attributes = drupal_attributes($cell);
+ }
+ else {
+ $data = $cell;
+ }
+
+ if ($header) {
+ $output = "<th$attributes>$data</th>";
+ }
+ else {
+ $output = "<td$attributes>$data</td>";
+ }
+
+ return $output;
+}
+
+/**
+ * Adds a default set of helper variables for variable processors and templates.
+ * This comes in before any other preprocess function which makes it possible to
+ * be used in default theme implementations (non-overridden theme functions).
+ *
+ * For more detailed information, see theme().
+ *
+ */
+function template_preprocess(&$variables, $hook) {
+ global $user;
+ static $count = array();
+
+ // Track run count for each hook to provide zebra striping.
+ // See "template_preprocess_block()" which provides the same feature specific to blocks.
+ $count[$hook] = isset($count[$hook]) && is_int($count[$hook]) ? $count[$hook] : 1;
+ $variables['zebra'] = ($count[$hook] % 2) ? 'odd' : 'even';
+ $variables['id'] = $count[$hook]++;
+
+ // Tell all templates where they are located.
+ $variables['directory'] = path_to_theme();
+
+ // Initialize html class attribute for the current hook.
+ $variables['classes_array'] = array(drupal_html_class($hook));
+
+ // Merge in variables that don't depend on hook and don't change during a
+ // single page request.
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['default_variables'] = &drupal_static(__FUNCTION__);
+ }
+ $default_variables = &$drupal_static_fast['default_variables'];
+ // Global $user object shouldn't change during a page request once rendering
+ // has started, but if there's an edge case where it does, re-fetch the
+ // variables appropriate for the new user.
+ if (!isset($default_variables) || ($user !== $default_variables['user'])) {
+ $default_variables = _template_preprocess_default_variables();
+ }
+ $variables += $default_variables;
+}
+
+/**
+ * Returns hook-independent variables to template_preprocess().
+ */
+function _template_preprocess_default_variables() {
+ global $user;
+
+ // Variables that don't depend on a database connection.
+ $variables = array(
+ 'attributes_array' => array(),
+ 'title_attributes_array' => array(),
+ 'content_attributes_array' => array(),
+ 'title_prefix' => array(),
+ 'title_suffix' => array(),
+ 'user' => $user,
+ 'db_is_active' => !defined('MAINTENANCE_MODE'),
+ 'is_admin' => FALSE,
+ 'logged_in' => FALSE,
+ );
+
+ // The user object has no uid property when the database does not exist during
+ // install. The user_access() check deals with issues when in maintenance mode
+ // as uid is set but the user.module has not been included.
+ if (isset($user->uid) && function_exists('user_access')) {
+ $variables['is_admin'] = user_access('access administration pages');
+ $variables['logged_in'] = ($user->uid > 0);
+ }
+
+ // drupal_is_front_page() might throw an exception.
+ try {
+ $variables['is_front'] = drupal_is_front_page();
+ }
+ catch (Exception $e) {
+ // If the database is not yet available, set default values for these
+ // variables.
+ $variables['is_front'] = FALSE;
+ $variables['db_is_active'] = FALSE;
+ }
+
+ return $variables;
+}
+
+/**
+ * A default process function used to alter variables as late as possible.
+ *
+ * For more detailed information, see theme().
+ *
+ */
+function template_process(&$variables, $hook) {
+ // Flatten out classes.
+ $variables['classes'] = implode(' ', $variables['classes_array']);
+
+ // Flatten out attributes, title_attributes, and content_attributes.
+ // Because this function can be called very often, and often with empty
+ // attributes, optimize performance by only calling drupal_attributes() if
+ // necessary.
+ $variables['attributes'] = $variables['attributes_array'] ? drupal_attributes($variables['attributes_array']) : '';
+ $variables['title_attributes'] = $variables['title_attributes_array'] ? drupal_attributes($variables['title_attributes_array']) : '';
+ $variables['content_attributes'] = $variables['content_attributes_array'] ? drupal_attributes($variables['content_attributes_array']) : '';
+}
+
+/**
+ * Preprocess variables for html.tpl.php
+ *
+ * @see system_elements()
+ * @see html.tpl.php
+ */
+function template_preprocess_html(&$variables) {
+ // Compile a list of classes that are going to be applied to the body element.
+ // This allows advanced theming based on context (home page, node of certain type, etc.).
+ // Add a class that tells us whether we're on the front page or not.
+ $variables['classes_array'][] = $variables['is_front'] ? 'front' : 'not-front';
+ // Add a class that tells us whether the page is viewed by an authenticated user or not.
+ $variables['classes_array'][] = $variables['logged_in'] ? 'logged-in' : 'not-logged-in';
+
+ // Add information about the number of sidebars.
+ if (!empty($variables['page']['sidebar_first']) && !empty($variables['page']['sidebar_second'])) {
+ $variables['classes_array'][] = 'two-sidebars';
+ }
+ elseif (!empty($variables['page']['sidebar_first'])) {
+ $variables['classes_array'][] = 'one-sidebar sidebar-first';
+ }
+ elseif (!empty($variables['page']['sidebar_second'])) {
+ $variables['classes_array'][] = 'one-sidebar sidebar-second';
+ }
+ else {
+ $variables['classes_array'][] = 'no-sidebars';
+ }
+
+ // Populate the body classes.
+ if ($suggestions = theme_get_suggestions(arg(), 'page', '-')) {
+ foreach ($suggestions as $suggestion) {
+ if ($suggestion != 'page-front') {
+ // Add current suggestion to page classes to make it possible to theme
+ // the page depending on the current page type (e.g. node, admin, user,
+ // etc.) as well as more specific data like node-12 or node-edit.
+ $variables['classes_array'][] = drupal_html_class($suggestion);
+ }
+ }
+ }
+
+ // If on an individual node page, add the node type to body classes.
+ if ($node = menu_get_object()) {
+ $variables['classes_array'][] = drupal_html_class('node-type-' . $node->type);
+ }
+
+ // Initializes attributes which are specific to the html and body elements.
+ $variables['html_attributes_array'] = array();
+ $variables['body_attributes_array'] = array();
+
+ // HTML element attributes.
+ $variables['html_attributes_array']['xmlns'] = "http://www.w3.org/1999/xhtml";
+ $variables['html_attributes_array']['xml:lang'] = $GLOBALS['language']->language;
+ $variables['html_attributes_array']['dir'] = $GLOBALS['language']->direction ? 'rtl' : 'ltr';
+
+ // Add favicon.
+ if (theme_get_setting('toggle_favicon')) {
+ $favicon = theme_get_setting('favicon');
+ $type = theme_get_setting('favicon_mimetype');
+ drupal_add_html_head_link(array('rel' => 'shortcut icon', 'href' => drupal_strip_dangerous_protocols($favicon), 'type' => $type));
+ }
+
+ // Construct page title.
+ if (drupal_get_title()) {
+ $head_title = array(
+ 'title' => strip_tags(drupal_get_title()),
+ 'name' => check_plain(variable_get('site_name', 'Drupal')),
+ );
+ }
+ else {
+ $head_title = array('name' => check_plain(variable_get('site_name', 'Drupal')));
+ if (variable_get('site_slogan', '')) {
+ $head_title['slogan'] = filter_xss_admin(variable_get('site_slogan', ''));
+ }
+ }
+ $variables['head_title_array'] = $head_title;
+ $variables['head_title'] = implode(' | ', $head_title);
+
+ // Populate the page template suggestions.
+ if ($suggestions = theme_get_suggestions(arg(), 'html')) {
+ $variables['theme_hook_suggestions'] = $suggestions;
+ }
+}
+
+/**
+ * Preprocess variables for page.tpl.php
+ *
+ * Most themes utilize their own copy of page.tpl.php. The default is located
+ * inside "modules/system/page.tpl.php". Look in there for the full list of
+ * variables.
+ *
+ * Uses the arg() function to generate a series of page template suggestions
+ * based on the current path.
+ *
+ * Any changes to variables in this preprocessor should also be changed inside
+ * template_preprocess_maintenance_page() to keep all of them consistent.
+ *
+ * @see drupal_render_page()
+ * @see template_process_page()
+ * @see page.tpl.php
+ */
+function template_preprocess_page(&$variables) {
+ // Move some variables to the top level for themer convenience and template cleanliness.
+ $variables['show_messages'] = $variables['page']['#show_messages'];
+
+ foreach (system_region_list($GLOBALS['theme']) as $region_key => $region_name) {
+ if (!isset($variables['page'][$region_key])) {
+ $variables['page'][$region_key] = array();
+ }
+ }
+
+ // Set up layout variable.
+ $variables['layout'] = 'none';
+ if (!empty($variables['page']['sidebar_first'])) {
+ $variables['layout'] = 'first';
+ }
+ if (!empty($variables['page']['sidebar_second'])) {
+ $variables['layout'] = ($variables['layout'] == 'first') ? 'both' : 'second';
+ }
+
+ $variables['base_path'] = base_path();
+ $variables['front_page'] = url();
+ $variables['feed_icons'] = drupal_get_feeds();
+ $variables['language'] = $GLOBALS['language'];
+ $variables['language']->dir = $GLOBALS['language']->direction ? 'rtl' : 'ltr';
+ $variables['logo'] = theme_get_setting('logo');
+ $variables['main_menu'] = theme_get_setting('toggle_main_menu') ? menu_main_menu() : array();
+ $variables['secondary_menu'] = theme_get_setting('toggle_secondary_menu') ? menu_secondary_menu() : array();
+ $variables['action_links'] = menu_local_actions();
+ $variables['site_name'] = (theme_get_setting('toggle_name') ? filter_xss_admin(variable_get('site_name', 'Drupal')) : '');
+ $variables['site_slogan'] = (theme_get_setting('toggle_slogan') ? filter_xss_admin(variable_get('site_slogan', '')) : '');
+ $variables['tabs'] = menu_local_tabs();
+
+ if ($node = menu_get_object()) {
+ $variables['node'] = $node;
+ }
+
+ // Populate the page template suggestions.
+ if ($suggestions = theme_get_suggestions(arg(), 'page')) {
+ $variables['theme_hook_suggestions'] = $suggestions;
+ }
+}
+
+/**
+ * Process variables for page.tpl.php
+ *
+ * Perform final addition of variables before passing them into the template.
+ * To customize these variables, simply set them in an earlier step.
+ *
+ * @see template_preprocess_page()
+ * @see page.tpl.php
+ */
+function template_process_page(&$variables) {
+ if (!isset($variables['breadcrumb'])) {
+ // Build the breadcrumb last, so as to increase the chance of being able to
+ // re-use the cache of an already rendered menu containing the active link
+ // for the current page.
+ // @see menu_tree_page_data()
+ $variables['breadcrumb'] = theme('breadcrumb', array('breadcrumb' => drupal_get_breadcrumb()));
+ }
+ if (!isset($variables['title'])) {
+ $variables['title'] = drupal_get_title();
+ }
+
+ // Generate messages last in order to capture as many as possible for the
+ // current page.
+ if (!isset($variables['messages'])) {
+ $variables['messages'] = $variables['show_messages'] ? theme('status_messages') : '';
+ }
+}
+
+/**
+ * Process variables for html.tpl.php
+ *
+ * Perform final addition and modification of variables before passing into
+ * the template. To customize these variables, call drupal_render() on elements
+ * in $variables['page'] during THEME_preprocess_page().
+ *
+ * @see template_preprocess_html()
+ * @see html.tpl.php
+ */
+function template_process_html(&$variables) {
+ // Flatten out html_attributes and body_attributes.
+ $variables['html_attributes'] = drupal_attributes($variables['html_attributes_array']);
+ $variables['body_attributes'] = drupal_attributes($variables['body_attributes_array']);
+
+ // Render page_top and page_bottom into top level variables.
+ $variables['page_top'] = drupal_render($variables['page']['page_top']);
+ $variables['page_bottom'] = drupal_render($variables['page']['page_bottom']);
+ // Place the rendered HTML for the page body into a top level variable.
+ $variables['page'] = $variables['page']['#children'];
+ $variables['page_bottom'] .= drupal_get_js('footer');
+
+ $variables['head'] = drupal_get_html_head();
+ $variables['css'] = drupal_add_css();
+ $variables['styles'] = drupal_get_css();
+ $variables['scripts'] = drupal_get_js();
+}
+
+/**
+ * Generate an array of suggestions from path arguments.
+ *
+ * This is typically called for adding to the 'theme_hook_suggestions' or
+ * 'classes_array' variables from within preprocess functions, when wanting to
+ * base the additional suggestions on the path of the current page.
+ *
+ * @param $args
+ * An array of path arguments, such as from function arg().
+ * @param $base
+ * A string identifying the base 'thing' from which more specific suggestions
+ * are derived. For example, 'page' or 'html'.
+ * @param $delimiter
+ * The string used to delimit increasingly specific information. The default
+ * of '__' is appropriate for theme hook suggestions. '-' is appropriate for
+ * extra classes.
+ *
+ * @return
+ * An array of suggestions, suitable for adding to
+ * $variables['theme_hook_suggestions'] within a preprocess function or to
+ * $variables['classes_array'] if the suggestions represent extra CSS classes.
+ */
+function theme_get_suggestions($args, $base, $delimiter = '__') {
+
+ // Build a list of suggested theme hooks or body classes in order of
+ // specificity. One suggestion is made for every element of the current path,
+ // though numeric elements are not carried to subsequent suggestions. For
+ // example, for $base='page', http://www.example.com/node/1/edit would result
+ // in the following suggestions and body classes:
+ //
+ // page__node page-node
+ // page__node__% page-node-%
+ // page__node__1 page-node-1
+ // page__node__edit page-node-edit
+
+ $suggestions = array();
+ $prefix = $base;
+ foreach ($args as $arg) {
+ // Remove slashes or null per SA-CORE-2009-003 and change - (hyphen) to _
+ // (underscore).
+ //
+ // When we discover templates in @see drupal_find_theme_templates,
+ // hyphens (-) are converted to underscores (_) before the theme hook
+ // is registered. We do this because the hyphens used for delimiters
+ // in hook suggestions cannot be used in the function names of the
+ // associated preprocess functions. Any page templates designed to be used
+ // on paths that contain a hyphen are also registered with these hyphens
+ // converted to underscores so here we must convert any hyphens in path
+ // arguments to underscores here before fetching theme hook suggestions
+ // to ensure the templates are appropriately recognized.
+ $arg = str_replace(array("/", "\\", "\0", '-'), array('', '', '', '_'), $arg);
+ // The percent acts as a wildcard for numeric arguments since
+ // asterisks are not valid filename characters on many filesystems.
+ if (is_numeric($arg)) {
+ $suggestions[] = $prefix . $delimiter . '%';
+ }
+ $suggestions[] = $prefix . $delimiter . $arg;
+ if (!is_numeric($arg)) {
+ $prefix .= $delimiter . $arg;
+ }
+ }
+ if (drupal_is_front_page()) {
+ // Front templates should be based on root only, not prefixed arguments.
+ $suggestions[] = $base . $delimiter . 'front';
+ }
+
+ return $suggestions;
+}
+
+/**
+ * The variables array generated here is a mirror of template_preprocess_page().
+ * This preprocessor will run its course when theme_maintenance_page() is
+ * invoked.
+ *
+ * An alternate template file of "maintenance-page--offline.tpl.php" can be
+ * used when the database is offline to hide errors and completely replace the
+ * content.
+ *
+ * The $variables array contains the following arguments:
+ * - $content
+ *
+ * @see maintenance-page.tpl.php
+ */
+function template_preprocess_maintenance_page(&$variables) {
+ // Add favicon
+ if (theme_get_setting('toggle_favicon')) {
+ $favicon = theme_get_setting('favicon');
+ $type = theme_get_setting('favicon_mimetype');
+ drupal_add_html_head_link(array('rel' => 'shortcut icon', 'href' => drupal_strip_dangerous_protocols($favicon), 'type' => $type));
+ }
+
+ global $theme;
+ // Retrieve the theme data to list all available regions.
+ $theme_data = list_themes();
+ $regions = $theme_data[$theme]->info['regions'];
+
+ // Get all region content set with drupal_add_region_content().
+ foreach (array_keys($regions) as $region) {
+ // Assign region to a region variable.
+ $region_content = drupal_get_region_content($region);
+ isset($variables[$region]) ? $variables[$region] .= $region_content : $variables[$region] = $region_content;
+ }
+
+ // Setup layout variable.
+ $variables['layout'] = 'none';
+ if (!empty($variables['sidebar_first'])) {
+ $variables['layout'] = 'first';
+ }
+ if (!empty($variables['sidebar_second'])) {
+ $variables['layout'] = ($variables['layout'] == 'first') ? 'both' : 'second';
+ }
+
+ // Construct page title
+ if (drupal_get_title()) {
+ $head_title = array(
+ 'title' => strip_tags(drupal_get_title()),
+ 'name' => variable_get('site_name', 'Drupal'),
+ );
+ }
+ else {
+ $head_title = array('name' => variable_get('site_name', 'Drupal'));
+ if (variable_get('site_slogan', '')) {
+ $head_title['slogan'] = variable_get('site_slogan', '');
+ }
+ }
+
+ // set the default language if necessary
+ $language = isset($GLOBALS['language']) ? $GLOBALS['language'] : language_default();
+
+ $variables['head_title_array'] = $head_title;
+ $variables['head_title'] = implode(' | ', $head_title);
+ $variables['base_path'] = base_path();
+ $variables['front_page'] = url();
+ $variables['breadcrumb'] = '';
+ $variables['feed_icons'] = '';
+ $variables['help'] = '';
+ $variables['language'] = $language;
+ $variables['language']->dir = $language->direction ? 'rtl' : 'ltr';
+ $variables['logo'] = theme_get_setting('logo');
+ $variables['messages'] = $variables['show_messages'] ? theme('status_messages') : '';
+ $variables['main_menu'] = array();
+ $variables['secondary_menu'] = array();
+ $variables['site_name'] = (theme_get_setting('toggle_name') ? variable_get('site_name', 'Drupal') : '');
+ $variables['site_slogan'] = (theme_get_setting('toggle_slogan') ? variable_get('site_slogan', '') : '');
+ $variables['tabs'] = '';
+ $variables['title'] = drupal_get_title();
+
+ // Compile a list of classes that are going to be applied to the body element.
+ $variables['classes_array'][] = 'in-maintenance';
+ if (isset($variables['db_is_active']) && !$variables['db_is_active']) {
+ $variables['classes_array'][] = 'db-offline';
+ }
+ if ($variables['layout'] == 'both') {
+ $variables['classes_array'][] = 'two-sidebars';
+ }
+ elseif ($variables['layout'] == 'none') {
+ $variables['classes_array'][] = 'no-sidebars';
+ }
+ else {
+ $variables['classes_array'][] = 'one-sidebar sidebar-' . $variables['layout'];
+ }
+
+ // Dead databases will show error messages so supplying this template will
+ // allow themers to override the page and the content completely.
+ if (isset($variables['db_is_active']) && !$variables['db_is_active']) {
+ $variables['theme_hook_suggestion'] = 'maintenance_page__offline';
+ }
+}
+
+/**
+ * The variables array generated here is a mirror of template_process_html().
+ * This processor will run its course when theme_maintenance_page() is invoked.
+ *
+ * @see maintenance-page.tpl.php
+ */
+function template_process_maintenance_page(&$variables) {
+ $variables['head'] = drupal_get_html_head();
+ $variables['css'] = drupal_add_css();
+ $variables['styles'] = drupal_get_css();
+ $variables['scripts'] = drupal_get_js();
+}
+
+/**
+ * Preprocess variables for region.tpl.php
+ *
+ * Prepare the values passed to the theme_region function to be passed into a
+ * pluggable template engine. Uses the region name to generate a template file
+ * suggestions. If none are found, the default region.tpl.php is used.
+ *
+ * @see drupal_region_class()
+ * @see region.tpl.php
+ */
+function template_preprocess_region(&$variables) {
+ // Create the $content variable that templates expect.
+ $variables['content'] = $variables['elements']['#children'];
+ $variables['region'] = $variables['elements']['#region'];
+
+ $variables['classes_array'][] = drupal_region_class($variables['region']);
+ $variables['theme_hook_suggestions'][] = 'region__' . $variables['region'];
+}
+
+/**
+ * Preprocesses variables for theme_username().
+ *
+ * Modules that make any changes to variables like 'name' or 'extra' must insure
+ * that the final string is safe to include directly in the output by using
+ * check_plain() or filter_xss().
+ *
+ * @see template_process_username()
+ */
+function template_preprocess_username(&$variables) {
+ $account = $variables['account'];
+
+ $variables['extra'] = '';
+ if (empty($account->uid)) {
+ $variables['uid'] = 0;
+ if (theme_get_setting('toggle_comment_user_verification')) {
+ $variables['extra'] = ' (' . t('not verified') . ')';
+ }
+ }
+ else {
+ $variables['uid'] = (int) $account->uid;
+ }
+
+ // Set the name to a formatted name that is safe for printing and
+ // that won't break tables by being too long. Keep an unshortened,
+ // unsanitized version, in case other preprocess functions want to implement
+ // their own shortening logic or add markup. If they do so, they must ensure
+ // that $variables['name'] is safe for printing.
+ $name = $variables['name_raw'] = format_username($account);
+ if (drupal_strlen($name) > 20) {
+ $name = drupal_substr($name, 0, 15) . '...';
+ }
+ $variables['name'] = check_plain($name);
+
+ $variables['profile_access'] = user_access('access user profiles');
+ $variables['link_attributes'] = array();
+ // Populate link path and attributes if appropriate.
+ if ($variables['uid'] && $variables['profile_access']) {
+ // We are linking to a local user.
+ $variables['link_attributes'] = array('title' => t('View user profile.'));
+ $variables['link_path'] = 'user/' . $variables['uid'];
+ }
+ elseif (!empty($account->homepage)) {
+ // Like the 'class' attribute, the 'rel' attribute can hold a
+ // space-separated set of values, so initialize it as an array to make it
+ // easier for other preprocess functions to append to it.
+ $variables['link_attributes'] = array('rel' => array('nofollow'));
+ $variables['link_path'] = $account->homepage;
+ $variables['homepage'] = $account->homepage;
+ }
+ // We do not want the l() function to check_plain() a second time.
+ $variables['link_options']['html'] = TRUE;
+ // Set a default class.
+ $variables['attributes_array'] = array('class' => array('username'));
+}
+
+/**
+ * Processes variables for theme_username().
+ *
+ * @see template_preprocess_username()
+ */
+function template_process_username(&$variables) {
+ // Finalize the link_options array for passing to the l() function.
+ // This is done in the process phase so that attributes may be added by
+ // modules or the theme during the preprocess phase.
+ if (isset($variables['link_path'])) {
+ // $variables['attributes_array'] contains attributes that should be applied
+ // regardless of whether a link is being rendered or not.
+ // $variables['link_attributes'] contains attributes that should only be
+ // applied if a link is being rendered. Preprocess functions are encouraged
+ // to use the former unless they want to add attributes on the link only.
+ // If a link is being rendered, these need to be merged. Some attributes are
+ // themselves arrays, so the merging needs to be recursive.
+ $variables['link_options']['attributes'] = array_merge_recursive($variables['link_attributes'], $variables['attributes_array']);
+ }
+}
diff --git a/core/includes/theme.maintenance.inc b/core/includes/theme.maintenance.inc
new file mode 100644
index 000000000000..fcd87030cb82
--- /dev/null
+++ b/core/includes/theme.maintenance.inc
@@ -0,0 +1,211 @@
+<?php
+
+/**
+ * @file
+ * Theming for maintenance pages.
+ */
+
+/**
+ * Sets up the theming system for maintenance page.
+ *
+ * Used for site installs, updates and when the site is in maintenance mode.
+ * It also applies when the database is unavailable or bootstrap was not
+ * complete. Seven is always used for the initial install and update operations.
+ * In other cases, Bartik is used, but this can be overridden by setting a
+ * "maintenance_theme" key in the $conf variable in settings.php.
+ */
+function _drupal_maintenance_theme() {
+ global $theme, $theme_key, $conf;
+
+ // If $theme is already set, assume the others are set too, and do nothing.
+ if (isset($theme)) {
+ return;
+ }
+
+ require_once DRUPAL_ROOT . '/' . variable_get('path_inc', 'core/includes/path.inc');
+ require_once DRUPAL_ROOT . '/core/includes/theme.inc';
+ require_once DRUPAL_ROOT . '/core/includes/common.inc';
+ require_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+ require_once DRUPAL_ROOT . '/core/includes/file.inc';
+ require_once DRUPAL_ROOT . '/core/includes/module.inc';
+ unicode_check();
+
+ // Install and update pages are treated differently to prevent theming overrides.
+ if (defined('MAINTENANCE_MODE') && (MAINTENANCE_MODE == 'install' || MAINTENANCE_MODE == 'update')) {
+ $custom_theme = (isset($conf['maintenance_theme']) ? $conf['maintenance_theme'] : 'seven');
+ }
+ else {
+ // The bootstrap was not complete. So we are operating in a crippled
+ // environment, we need to bootstrap just enough to allow hook invocations
+ // to work. See _drupal_log_error().
+ if (!class_exists('Database', FALSE)) {
+ require_once DRUPAL_ROOT . '/core/includes/database/database.inc';
+ }
+
+ // We use the default theme as the maintenance theme. If a default theme
+ // isn't specified in the database or in settings.php, we use Bartik.
+ $custom_theme = variable_get('maintenance_theme', variable_get('theme_default', 'bartik'));
+ }
+
+ // Ensure that system.module is loaded.
+ if (!function_exists('_system_rebuild_theme_data')) {
+ $module_list['system']['filename'] = 'core/modules/system/system.module';
+ module_list(TRUE, FALSE, FALSE, $module_list);
+ drupal_load('module', 'system');
+ }
+
+ $themes = list_themes();
+
+ // list_themes() triggers a drupal_alter() in maintenance mode, but we can't
+ // let themes alter the .info data until we know a theme's base themes. So
+ // don't set global $theme until after list_themes() builds its cache.
+ $theme = $custom_theme;
+
+ // Store the identifier for retrieving theme settings with.
+ $theme_key = $theme;
+
+ // Find all our ancestor themes and put them in an array.
+ $base_theme = array();
+ $ancestor = $theme;
+ while ($ancestor && isset($themes[$ancestor]->base_theme)) {
+ $base_theme[] = $new_base_theme = $themes[$themes[$ancestor]->base_theme];
+ $ancestor = $themes[$ancestor]->base_theme;
+ }
+ _drupal_theme_initialize($themes[$theme], array_reverse($base_theme), '_theme_load_offline_registry');
+
+ // These are usually added from system_init() -except maintenance.css.
+ // When the database is inactive it's not called so we add it here.
+ $path = drupal_get_path('module', 'system');
+ drupal_add_css($path . '/system.base.css');
+ drupal_add_css($path . '/system.admin.css');
+ drupal_add_css($path . '/system.menus.css');
+ drupal_add_css($path . '/system.messages.css');
+ drupal_add_css($path . '/system.theme.css');
+ drupal_add_css($path . '/system.maintenance.css');
+}
+
+/**
+ * This builds the registry when the site needs to bypass any database calls.
+ */
+function _theme_load_offline_registry($theme, $base_theme = NULL, $theme_engine = NULL) {
+ return _theme_build_registry($theme, $base_theme, $theme_engine);
+}
+
+/**
+ * Returns HTML for a list of maintenance tasks to perform.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - items: An associative array of maintenance tasks.
+ * - active: The key for the currently active maintenance task.
+ *
+ * @ingroup themeable
+ */
+function theme_task_list($variables) {
+ $items = $variables['items'];
+ $active = $variables['active'];
+
+ $done = isset($items[$active]) || $active == NULL;
+ $output = '<h2 class="element-invisible">Installation tasks</h2>';
+ $output .= '<ol class="task-list">';
+
+ foreach ($items as $k => $item) {
+ if ($active == $k) {
+ $class = 'active';
+ $status = '(' . t('active') . ')';
+ $done = FALSE;
+ }
+ else {
+ $class = $done ? 'done' : '';
+ $status = $done ? '(' . t('done') . ')' : '';
+ }
+ $output .= '<li';
+ $output .= ($class ? ' class="' . $class . '"' : '') . '>';
+ $output .= $item;
+ $output .= ($status ? '<span class="element-invisible">' . $status . '</span>' : '');
+ $output .= '</li>';
+ }
+ $output .= '</ol>';
+ return $output;
+}
+
+/**
+ * Returns HTML for the installation page.
+ *
+ * Note: this function is not themeable.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - content: The page content to show.
+ */
+function theme_install_page($variables) {
+ drupal_add_http_header('Content-Type', 'text/html; charset=utf-8');
+ return theme('maintenance_page', $variables);
+}
+
+/**
+ * Returns HTML for the update page.
+ *
+ * Note: this function is not themeable.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - content: The page content to show.
+ * - show_messages: Whether to output status and error messages.
+ * FALSE can be useful to postpone the messages to a subsequent page.
+ */
+function theme_update_page($variables) {
+ drupal_add_http_header('Content-Type', 'text/html; charset=utf-8');
+ return theme('maintenance_page', $variables);
+}
+
+/**
+ * Returns HTML for a report of the results from an operation run via authorize.php.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - messages: An array of result messages.
+ *
+ * @ingroup themeable
+ */
+function theme_authorize_report($variables) {
+ $messages = $variables['messages'];
+ $output = '';
+ if (!empty($messages)) {
+ $output .= '<div id="authorize-results">';
+ foreach ($messages as $heading => $logs) {
+ $items = array();
+ foreach ($logs as $number => $log_message) {
+ if ($number === '#abort') {
+ continue;
+ }
+ $items[] = theme('authorize_message', array('message' => $log_message['message'], 'success' => $log_message['success']));
+ }
+ $output .= theme('item_list', array('items' => $items, 'title' => $heading));
+ }
+ $output .= '</div>';
+ }
+ return $output;
+}
+
+/**
+ * Returns HTML for a single log message from the authorize.php batch operation.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - message: The log message.
+ * - success: A boolean indicating failure or success.
+ *
+ * @ingroup themeable
+ */
+function theme_authorize_message($variables) {
+ $message = $variables['message'];
+ $success = $variables['success'];
+ if ($success) {
+ $item = array('data' => $message, 'class' => array('success'));
+ }
+ else {
+ $item = array('data' => '<strong>' . $message . '</strong>', 'class' => array('failure'));
+ }
+ return $item;
+}
diff --git a/core/includes/token.inc b/core/includes/token.inc
new file mode 100644
index 000000000000..7a5fea141c39
--- /dev/null
+++ b/core/includes/token.inc
@@ -0,0 +1,257 @@
+<?php
+
+/**
+ * @file
+ * Drupal placeholder/token replacement system.
+ *
+ * API functions for replacing placeholders in text with meaningful values.
+ *
+ * For example: When configuring automated emails, an administrator enters
+ * standard text for the email. Variables like the title of a node and the date
+ * the email was sent can be entered as placeholders like [node:title] and
+ * [date:short]. When a Drupal module prepares to send the email, it can call
+ * the token_replace() function, passing in the text. The token system will
+ * scan the text for placeholder tokens, give other modules an opportunity to
+ * replace them with meaningful text, then return the final product to the
+ * original module.
+ *
+ * Tokens follow the form: [$type:$name], where $type is a general class of
+ * tokens like 'node', 'user', or 'comment' and $name is the name of a given
+ * placeholder. For example, [node:title] or [node:created:since].
+ *
+ * In addition to raw text containing placeholders, modules may pass in an array
+ * of objects to be used when performing the replacement. The objects should be
+ * keyed by the token type they correspond to. For example:
+ *
+ * @code
+ * // Load a node and a user, then replace tokens in the text.
+ * $text = 'On [date:short], [user:name] read [node:title].';
+ * $node = node_load(1);
+ * $user = user_load(1);
+ *
+ * // [date:...] tokens use the current date automatically.
+ * $data = array('node' => $node, 'user' => $user);
+ * return token_replace($text, $data);
+ * @endcode
+ *
+ * Some tokens may be chained in the form of [$type:$pointer:$name], where $type
+ * is a normal token type, $pointer is a reference to another token type, and
+ * $name is the name of a given placeholder. For example, [node:author:mail]. In
+ * that example, 'author' is a pointer to the 'user' account that created the
+ * node, and 'mail' is a placeholder available for any 'user'.
+ *
+ * @see token_replace()
+ * @see hook_tokens()
+ * @see hook_token_info()
+ */
+
+/**
+ * Replaces all tokens in a given string with appropriate values.
+ *
+ * @param $text
+ * A string potentially containing replaceable tokens.
+ * @param $data
+ * (optional) An array of keyed objects. For simple replacement scenarios
+ * 'node', 'user', and others are common keys, with an accompanying node or
+ * user object being the value. Some token types, like 'site', do not require
+ * any explicit information from $data and can be replaced even if it is
+ * empty.
+ * @param $options
+ * (optional) A keyed array of settings and flags to control the token
+ * replacement process. Supported options are:
+ * - language: A language object to be used when generating locale-sensitive
+ * tokens.
+ * - callback: A callback function that will be used to post-process the array
+ * of token replacements after they are generated. For example, a module
+ * using tokens in a text-only email might provide a callback to strip HTML
+ * entities from token values before they are inserted into the final text.
+ * - clear: A boolean flag indicating that tokens should be removed from the
+ * final text if no replacement value can be generated.
+ * - sanitize: A boolean flag indicating that tokens should be sanitized for
+ * display to a web browser. Defaults to TRUE. Developers who set this
+ * option to FALSE assume responsibility for running filter_xss(),
+ * check_plain() or other appropriate scrubbing functions before displaying
+ * data to users.
+ *
+ * @return
+ * Text with tokens replaced.
+ */
+function token_replace($text, array $data = array(), array $options = array()) {
+ $replacements = array();
+ foreach (token_scan($text) as $type => $tokens) {
+ $replacements += token_generate($type, $tokens, $data, $options);
+ if (!empty($options['clear'])) {
+ $replacements += array_fill_keys($tokens, '');
+ }
+ }
+
+ // Optionally alter the list of replacement values.
+ if (!empty($options['callback']) && function_exists($options['callback'])) {
+ $function = $options['callback'];
+ $function($replacements, $data, $options);
+ }
+
+ $tokens = array_keys($replacements);
+ $values = array_values($replacements);
+
+ return str_replace($tokens, $values, $text);
+}
+
+/**
+ * Builds a list of all token-like patterns that appear in the text.
+ *
+ * @param $text
+ * The text to be scanned for possible tokens.
+ *
+ * @return
+ * An associative array of discovered tokens, grouped by type.
+ */
+function token_scan($text) {
+ // Matches tokens with the following pattern: [$type:$name]
+ // $type and $name may not contain [ ] or whitespace characters.
+ // $type may not contain : characters, but $name may.
+ preg_match_all('/
+ \[ # [ - pattern start
+ ([^\s\[\]:]*) # match $type not containing whitespace : [ or ]
+ : # : - separator
+ ([^\s\[\]]*) # match $name not containing whitespace [ or ]
+ \] # ] - pattern end
+ /x', $text, $matches);
+
+ $types = $matches[1];
+ $tokens = $matches[2];
+
+ // Iterate through the matches, building an associative array containing
+ // $tokens grouped by $types, pointing to the version of the token found in
+ // the source text. For example, $results['node']['title'] = '[node:title]';
+ $results = array();
+ for ($i = 0; $i < count($tokens); $i++) {
+ $results[$types[$i]][$tokens[$i]] = $matches[0][$i];
+ }
+
+ return $results;
+}
+
+/**
+ * Generates replacement values for a list of tokens.
+ *
+ * @param $type
+ * The type of token being replaced. 'node', 'user', and 'date' are common.
+ * @param $tokens
+ * An array of tokens to be replaced, keyed by the literal text of the token
+ * as it appeared in the source text.
+ * @param $data
+ * (optional) An array of keyed objects. For simple replacement scenarios
+ * 'node', 'user', and others are common keys, with an accompanying node or
+ * user object being the value. Some token types, like 'site', do not require
+ * any explicit information from $data and can be replaced even if it is
+ * empty.
+ * @param $options
+ * (optional) A keyed array of settings and flags to control the token
+ * replacement process. Supported options are:
+ * - language: A language object to be used when generating locale-sensitive
+ * tokens.
+ * - callback: A callback function that will be used to post-process the
+ * array of token replacements after they are generated. Can be used when
+ * modules require special formatting of token text, for example URL
+ * encoding or truncation to a specific length.
+ * - sanitize: A boolean flag indicating that tokens should be sanitized for
+ * display to a web browser. Developers who set this option to FALSE assume
+ * responsibility for running filter_xss(), check_plain() or other
+ * appropriate scrubbing functions before displaying data to users.
+ *
+ * @return
+ * An associative array of replacement values, keyed by the original 'raw'
+ * tokens that were found in the source text. For example:
+ * $results['[node:title]'] = 'My new node';
+ *
+ * @see hook_tokens()
+ * @see hook_tokens_alter()
+ */
+function token_generate($type, array $tokens, array $data = array(), array $options = array()) {
+ $options += array('sanitize' => TRUE);
+ $replacements = module_invoke_all('tokens', $type, $tokens, $data, $options);
+
+ // Allow other modules to alter the replacements.
+ $context = array(
+ 'type' => $type,
+ 'tokens' => $tokens,
+ 'data' => $data,
+ 'options' => $options,
+ );
+ drupal_alter('tokens', $replacements, $context);
+
+ return $replacements;
+}
+
+/**
+ * Given a list of tokens, returns those that begin with a specific prefix.
+ *
+ * Used to extract a group of 'chained' tokens (such as [node:author:name]) from
+ * the full list of tokens found in text. For example:
+ * @code
+ * $data = array(
+ * 'author:name' => '[node:author:name]',
+ * 'title' => '[node:title]',
+ * 'created' => '[node:created]',
+ * );
+ * $results = token_find_with_prefix($data, 'author');
+ * $results == array('name' => '[node:author:name]');
+ * @endcode
+ *
+ * @param $tokens
+ * A keyed array of tokens, and their original raw form in the source text.
+ * @param $prefix
+ * A textual string to be matched at the beginning of the token.
+ * @param $delimiter
+ * An optional string containing the character that separates the prefix from
+ * the rest of the token. Defaults to ':'.
+ *
+ * @return
+ * An associative array of discovered tokens, with the prefix and delimiter
+ * stripped from the key.
+ */
+function token_find_with_prefix(array $tokens, $prefix, $delimiter = ':') {
+ $results = array();
+ foreach ($tokens as $token => $raw) {
+ $parts = explode($delimiter, $token, 2);
+ if (count($parts) == 2 && $parts[0] == $prefix) {
+ $results[$parts[1]] = $raw;
+ }
+ }
+ return $results;
+}
+
+/**
+ * Returns metadata describing supported tokens.
+ *
+ * The metadata array contains token type, name, and description data as well as
+ * an optional pointer indicating that the token chains to another set of tokens.
+ * For example:
+ * @code
+ * $data['types']['node'] = array(
+ * 'name' => t('Nodes'),
+ * 'description' => t('Tokens related to node objects.'),
+ * );
+ * $data['tokens']['node']['title'] = array(
+ * 'name' => t('Title'),
+ * 'description' => t('The title of the current node.'),
+ * );
+ * $data['tokens']['node']['author'] = array(
+ * 'name' => t('Author'),
+ * 'description' => t('The author of the current node.'),
+ * 'type' => 'user',
+ * );
+ * @endcode
+ *
+ * @return
+ * An associative array of token information, grouped by token type.
+ */
+function token_info() {
+ $data = &drupal_static(__FUNCTION__);
+ if (!isset($data)) {
+ $data = module_invoke_all('token_info');
+ drupal_alter('token_info', $data);
+ }
+ return $data;
+}
diff --git a/core/includes/unicode.entities.inc b/core/includes/unicode.entities.inc
new file mode 100644
index 000000000000..3b1fbb691d76
--- /dev/null
+++ b/core/includes/unicode.entities.inc
@@ -0,0 +1,265 @@
+<?php
+
+/**
+ * @file
+ * (X)HTML entities, as defined in HTML 4.01.
+ *
+ * @see http://www.w3.org/TR/html401/sgml/entities.html
+ */
+
+$html_entities = array(
+ '&Aacute;' => 'Á',
+ '&aacute;' => 'á',
+ '&Acirc;' => 'Â',
+ '&acirc;' => 'â',
+ '&acute;' => '´',
+ '&AElig;' => 'Æ',
+ '&aelig;' => 'æ',
+ '&Agrave;' => 'À',
+ '&agrave;' => 'à',
+ '&alefsym;' => 'ℵ',
+ '&Alpha;' => 'Α',
+ '&alpha;' => 'α',
+ '&amp;' => '&',
+ '&and;' => '∧',
+ '&ang;' => '∠',
+ '&Aring;' => 'Å',
+ '&aring;' => 'å',
+ '&asymp;' => '≈',
+ '&Atilde;' => 'Ã',
+ '&atilde;' => 'ã',
+ '&Auml;' => 'Ä',
+ '&auml;' => 'ä',
+ '&bdquo;' => '„',
+ '&Beta;' => 'Β',
+ '&beta;' => 'β',
+ '&brvbar;' => '¦',
+ '&bull;' => '•',
+ '&cap;' => '∩',
+ '&Ccedil;' => 'Ç',
+ '&ccedil;' => 'ç',
+ '&cedil;' => '¸',
+ '&cent;' => '¢',
+ '&Chi;' => 'Χ',
+ '&chi;' => 'χ',
+ '&circ;' => 'ˆ',
+ '&clubs;' => '♣',
+ '&cong;' => '≅',
+ '&copy;' => '©',
+ '&crarr;' => '↵',
+ '&cup;' => '∪',
+ '&curren;' => '¤',
+ '&dagger;' => '†',
+ '&Dagger;' => '‡',
+ '&darr;' => '↓',
+ '&dArr;' => '⇓',
+ '&deg;' => '°',
+ '&Delta;' => 'Δ',
+ '&delta;' => 'δ',
+ '&diams;' => '♦',
+ '&divide;' => '÷',
+ '&Eacute;' => 'É',
+ '&eacute;' => 'é',
+ '&Ecirc;' => 'Ê',
+ '&ecirc;' => 'ê',
+ '&Egrave;' => 'È',
+ '&egrave;' => 'è',
+ '&empty;' => '∅',
+ '&emsp;' => ' ',
+ '&ensp;' => ' ',
+ '&Epsilon;' => 'Ε',
+ '&epsilon;' => 'ε',
+ '&equiv;' => '≡',
+ '&Eta;' => 'Η',
+ '&eta;' => 'η',
+ '&ETH;' => 'Ð',
+ '&eth;' => 'ð',
+ '&Euml;' => 'Ë',
+ '&euml;' => 'ë',
+ '&euro;' => '€',
+ '&exist;' => '∃',
+ '&fnof;' => 'ƒ',
+ '&forall;' => '∀',
+ '&frac12;' => '½',
+ '&frac14;' => '¼',
+ '&frac34;' => '¾',
+ '&frasl;' => '⁄',
+ '&Gamma;' => 'Γ',
+ '&gamma;' => 'γ',
+ '&ge;' => '≥',
+ '&harr;' => '↔',
+ '&hArr;' => '⇔',
+ '&hearts;' => '♥',
+ '&hellip;' => '…',
+ '&Iacute;' => 'Í',
+ '&iacute;' => 'í',
+ '&Icirc;' => 'Î',
+ '&icirc;' => 'î',
+ '&iexcl;' => '¡',
+ '&Igrave;' => 'Ì',
+ '&igrave;' => 'ì',
+ '&image;' => 'ℑ',
+ '&infin;' => '∞',
+ '&int;' => '∫',
+ '&Iota;' => 'Ι',
+ '&iota;' => 'ι',
+ '&iquest;' => '¿',
+ '&isin;' => '∈',
+ '&Iuml;' => 'Ï',
+ '&iuml;' => 'ï',
+ '&Kappa;' => 'Κ',
+ '&kappa;' => 'κ',
+ '&Lambda;' => 'Λ',
+ '&lambda;' => 'λ',
+ '&lang;' => '〈',
+ '&laquo;' => '«',
+ '&larr;' => '←',
+ '&lArr;' => '⇐',
+ '&lceil;' => '⌈',
+ '&ldquo;' => '“',
+ '&le;' => '≤',
+ '&lfloor;' => '⌊',
+ '&lowast;' => '∗',
+ '&loz;' => '◊',
+ '&lrm;' => '‎',
+ '&lsaquo;' => '‹',
+ '&lsquo;' => '‘',
+ '&macr;' => '¯',
+ '&mdash;' => '—',
+ '&micro;' => 'µ',
+ '&middot;' => '·',
+ '&minus;' => '−',
+ '&Mu;' => 'Μ',
+ '&mu;' => 'μ',
+ '&nabla;' => '∇',
+ '&nbsp;' => ' ',
+ '&ndash;' => '–',
+ '&ne;' => '≠',
+ '&ni;' => '∋',
+ '&not;' => '¬',
+ '&notin;' => '∉',
+ '&nsub;' => '⊄',
+ '&Ntilde;' => 'Ñ',
+ '&ntilde;' => 'ñ',
+ '&Nu;' => 'Ν',
+ '&nu;' => 'ν',
+ '&Oacute;' => 'Ó',
+ '&oacute;' => 'ó',
+ '&Ocirc;' => 'Ô',
+ '&ocirc;' => 'ô',
+ '&OElig;' => 'Œ',
+ '&oelig;' => 'œ',
+ '&Ograve;' => 'Ò',
+ '&ograve;' => 'ò',
+ '&oline;' => '‾',
+ '&Omega;' => 'Ω',
+ '&omega;' => 'ω',
+ '&Omicron;' => 'Ο',
+ '&omicron;' => 'ο',
+ '&oplus;' => '⊕',
+ '&or;' => '∨',
+ '&ordf;' => 'ª',
+ '&ordm;' => 'º',
+ '&Oslash;' => 'Ø',
+ '&oslash;' => 'ø',
+ '&Otilde;' => 'Õ',
+ '&otilde;' => 'õ',
+ '&otimes;' => '⊗',
+ '&Ouml;' => 'Ö',
+ '&ouml;' => 'ö',
+ '&para;' => '¶',
+ '&part;' => '∂',
+ '&permil;' => '‰',
+ '&perp;' => '⊥',
+ '&Phi;' => 'Φ',
+ '&phi;' => 'φ',
+ '&Pi;' => 'Π',
+ '&pi;' => 'π',
+ '&piv;' => 'ϖ',
+ '&plusmn;' => '±',
+ '&pound;' => '£',
+ '&prime;' => '′',
+ '&Prime;' => '″',
+ '&prod;' => '∏',
+ '&prop;' => '∝',
+ '&Psi;' => 'Ψ',
+ '&psi;' => 'ψ',
+ '&radic;' => '√',
+ '&rang;' => '〉',
+ '&raquo;' => '»',
+ '&rarr;' => '→',
+ '&rArr;' => '⇒',
+ '&rceil;' => '⌉',
+ '&rdquo;' => '”',
+ '&real;' => 'ℜ',
+ '&reg;' => '®',
+ '&rfloor;' => '⌋',
+ '&Rho;' => 'Ρ',
+ '&rho;' => 'ρ',
+ '&rlm;' => '‏',
+ '&rsaquo;' => '›',
+ '&rsquo;' => '’',
+ '&sbquo;' => '‚',
+ '&Scaron;' => 'Š',
+ '&scaron;' => 'š',
+ '&sdot;' => '⋅',
+ '&sect;' => '§',
+ '&shy;' => '­',
+ '&Sigma;' => 'Σ',
+ '&sigma;' => 'σ',
+ '&sigmaf;' => 'ς',
+ '&sim;' => '∼',
+ '&spades;' => '♠',
+ '&sub;' => '⊂',
+ '&sube;' => '⊆',
+ '&sum;' => '∑',
+ '&sup1;' => '¹',
+ '&sup2;' => '²',
+ '&sup3;' => '³',
+ '&sup;' => '⊃',
+ '&supe;' => '⊇',
+ '&szlig;' => 'ß',
+ '&Tau;' => 'Τ',
+ '&tau;' => 'τ',
+ '&there4;' => '∴',
+ '&Theta;' => 'Θ',
+ '&theta;' => 'θ',
+ '&thetasym;' => 'ϑ',
+ '&thinsp;' => ' ',
+ '&THORN;' => 'Þ',
+ '&thorn;' => 'þ',
+ '&tilde;' => '˜',
+ '&times;' => '×',
+ '&trade;' => '™',
+ '&Uacute;' => 'Ú',
+ '&uacute;' => 'ú',
+ '&uarr;' => '↑',
+ '&uArr;' => '⇑',
+ '&Ucirc;' => 'Û',
+ '&ucirc;' => 'û',
+ '&Ugrave;' => 'Ù',
+ '&ugrave;' => 'ù',
+ '&uml;' => '¨',
+ '&upsih;' => 'ϒ',
+ '&Upsilon;' => 'Υ',
+ '&upsilon;' => 'υ',
+ '&Uuml;' => 'Ü',
+ '&uuml;' => 'ü',
+ '&weierp;' => '℘',
+ '&Xi;' => 'Ξ',
+ '&xi;' => 'ξ',
+ '&Yacute;' => 'Ý',
+ '&yacute;' => 'ý',
+ '&yen;' => '¥',
+ '&yuml;' => 'ÿ',
+ '&Yuml;' => 'Ÿ',
+ '&Zeta;' => 'Ζ',
+ '&zeta;' => 'ζ',
+ '&zwj;' => '‍',
+ '&zwnj;' => '‌',
+ '&gt;' => '>',
+ '&lt;' => '<',
+ '&quot;' => '"',
+ // Add apostrophe (XML).
+ '&apos;' => "'",
+);
diff --git a/core/includes/unicode.inc b/core/includes/unicode.inc
new file mode 100644
index 000000000000..9dde2ca70cca
--- /dev/null
+++ b/core/includes/unicode.inc
@@ -0,0 +1,601 @@
+<?php
+
+/**
+ * Indicates an error during check for PHP unicode support.
+ */
+define('UNICODE_ERROR', -1);
+
+/**
+ * Indicates that standard PHP (emulated) unicode support is being used.
+ */
+define('UNICODE_SINGLEBYTE', 0);
+
+/**
+ * Indicates that full unicode support with the PHP mbstring extension is being
+ * used.
+ */
+define('UNICODE_MULTIBYTE', 1);
+
+/**
+ * Matches Unicode characters that are word boundaries.
+ *
+ * @see http://unicode.org/glossary
+ *
+ * Characters with the following General_category (gc) property values are used
+ * as word boundaries. While this does not fully conform to the Word Boundaries
+ * algorithm described in http://unicode.org/reports/tr29, as PCRE does not
+ * contain the Word_Break property table, this simpler algorithm has to do.
+ * - Cc, Cf, Cn, Co, Cs: Other.
+ * - Pc, Pd, Pe, Pf, Pi, Po, Ps: Punctuation.
+ * - Sc, Sk, Sm, So: Symbols.
+ * - Zl, Zp, Zs: Separators.
+ *
+ * Non-boundary characters include the following General_category (gc) property
+ * values:
+ * - Ll, Lm, Lo, Lt, Lu: Letters.
+ * - Mc, Me, Mn: Combining Marks.
+ * - Nd, Nl, No: Numbers.
+ *
+ * Note that the PCRE property matcher is not used because we wanted to be
+ * compatible with Unicode 5.2.0 regardless of the PCRE version used (and any
+ * bugs in PCRE property tables).
+ */
+define('PREG_CLASS_UNICODE_WORD_BOUNDARY',
+ '\x{0}-\x{2F}\x{3A}-\x{40}\x{5B}-\x{60}\x{7B}-\x{A9}\x{AB}-\x{B1}\x{B4}' .
+ '\x{B6}-\x{B8}\x{BB}\x{BF}\x{D7}\x{F7}\x{2C2}-\x{2C5}\x{2D2}-\x{2DF}' .
+ '\x{2E5}-\x{2EB}\x{2ED}\x{2EF}-\x{2FF}\x{375}\x{37E}-\x{385}\x{387}\x{3F6}' .
+ '\x{482}\x{55A}-\x{55F}\x{589}-\x{58A}\x{5BE}\x{5C0}\x{5C3}\x{5C6}' .
+ '\x{5F3}-\x{60F}\x{61B}-\x{61F}\x{66A}-\x{66D}\x{6D4}\x{6DD}\x{6E9}' .
+ '\x{6FD}-\x{6FE}\x{700}-\x{70F}\x{7F6}-\x{7F9}\x{830}-\x{83E}' .
+ '\x{964}-\x{965}\x{970}\x{9F2}-\x{9F3}\x{9FA}-\x{9FB}\x{AF1}\x{B70}' .
+ '\x{BF3}-\x{BFA}\x{C7F}\x{CF1}-\x{CF2}\x{D79}\x{DF4}\x{E3F}\x{E4F}' .
+ '\x{E5A}-\x{E5B}\x{F01}-\x{F17}\x{F1A}-\x{F1F}\x{F34}\x{F36}\x{F38}' .
+ '\x{F3A}-\x{F3D}\x{F85}\x{FBE}-\x{FC5}\x{FC7}-\x{FD8}\x{104A}-\x{104F}' .
+ '\x{109E}-\x{109F}\x{10FB}\x{1360}-\x{1368}\x{1390}-\x{1399}\x{1400}' .
+ '\x{166D}-\x{166E}\x{1680}\x{169B}-\x{169C}\x{16EB}-\x{16ED}' .
+ '\x{1735}-\x{1736}\x{17B4}-\x{17B5}\x{17D4}-\x{17D6}\x{17D8}-\x{17DB}' .
+ '\x{1800}-\x{180A}\x{180E}\x{1940}-\x{1945}\x{19DE}-\x{19FF}' .
+ '\x{1A1E}-\x{1A1F}\x{1AA0}-\x{1AA6}\x{1AA8}-\x{1AAD}\x{1B5A}-\x{1B6A}' .
+ '\x{1B74}-\x{1B7C}\x{1C3B}-\x{1C3F}\x{1C7E}-\x{1C7F}\x{1CD3}\x{1FBD}' .
+ '\x{1FBF}-\x{1FC1}\x{1FCD}-\x{1FCF}\x{1FDD}-\x{1FDF}\x{1FED}-\x{1FEF}' .
+ '\x{1FFD}-\x{206F}\x{207A}-\x{207E}\x{208A}-\x{208E}\x{20A0}-\x{20B8}' .
+ '\x{2100}-\x{2101}\x{2103}-\x{2106}\x{2108}-\x{2109}\x{2114}' .
+ '\x{2116}-\x{2118}\x{211E}-\x{2123}\x{2125}\x{2127}\x{2129}\x{212E}' .
+ '\x{213A}-\x{213B}\x{2140}-\x{2144}\x{214A}-\x{214D}\x{214F}' .
+ '\x{2190}-\x{244A}\x{249C}-\x{24E9}\x{2500}-\x{2775}\x{2794}-\x{2B59}' .
+ '\x{2CE5}-\x{2CEA}\x{2CF9}-\x{2CFC}\x{2CFE}-\x{2CFF}\x{2E00}-\x{2E2E}' .
+ '\x{2E30}-\x{3004}\x{3008}-\x{3020}\x{3030}\x{3036}-\x{3037}' .
+ '\x{303D}-\x{303F}\x{309B}-\x{309C}\x{30A0}\x{30FB}\x{3190}-\x{3191}' .
+ '\x{3196}-\x{319F}\x{31C0}-\x{31E3}\x{3200}-\x{321E}\x{322A}-\x{3250}' .
+ '\x{3260}-\x{327F}\x{328A}-\x{32B0}\x{32C0}-\x{33FF}\x{4DC0}-\x{4DFF}' .
+ '\x{A490}-\x{A4C6}\x{A4FE}-\x{A4FF}\x{A60D}-\x{A60F}\x{A673}\x{A67E}' .
+ '\x{A6F2}-\x{A716}\x{A720}-\x{A721}\x{A789}-\x{A78A}\x{A828}-\x{A82B}' .
+ '\x{A836}-\x{A839}\x{A874}-\x{A877}\x{A8CE}-\x{A8CF}\x{A8F8}-\x{A8FA}' .
+ '\x{A92E}-\x{A92F}\x{A95F}\x{A9C1}-\x{A9CD}\x{A9DE}-\x{A9DF}' .
+ '\x{AA5C}-\x{AA5F}\x{AA77}-\x{AA79}\x{AADE}-\x{AADF}\x{ABEB}' .
+ '\x{D800}-\x{F8FF}\x{FB29}\x{FD3E}-\x{FD3F}\x{FDFC}-\x{FDFD}' .
+ '\x{FE10}-\x{FE19}\x{FE30}-\x{FE6B}\x{FEFF}-\x{FF0F}\x{FF1A}-\x{FF20}' .
+ '\x{FF3B}-\x{FF40}\x{FF5B}-\x{FF65}\x{FFE0}-\x{FFFD}');
+
+/**
+ * Wrapper around _unicode_check().
+ */
+function unicode_check() {
+ list($GLOBALS['multibyte']) = _unicode_check();
+}
+
+/**
+ * Perform checks about Unicode support in PHP, and set the right settings if
+ * needed.
+ *
+ * Because Drupal needs to be able to handle text in various encodings, we do
+ * not support mbstring function overloading. HTTP input/output conversion must
+ * be disabled for similar reasons.
+ *
+ * @param $errors
+ * Whether to report any fatal errors with form_set_error().
+ */
+function _unicode_check() {
+ // Ensure translations don't break at install time
+ $t = get_t();
+
+ // Check for mbstring extension
+ if (!function_exists('mb_strlen')) {
+ return array(UNICODE_SINGLEBYTE, $t('Operations on Unicode strings are emulated on a best-effort basis. Install the <a href="@url">PHP mbstring extension</a> for improved Unicode support.', array('@url' => 'http://www.php.net/mbstring')));
+ }
+
+ // Check mbstring configuration
+ if (ini_get('mbstring.func_overload') != 0) {
+ return array(UNICODE_ERROR, $t('Multibyte string function overloading in PHP is active and must be disabled. Check the php.ini <em>mbstring.func_overload</em> setting. Please refer to the <a href="@url">PHP mbstring documentation</a> for more information.', array('@url' => 'http://www.php.net/mbstring')));
+ }
+ if (ini_get('mbstring.encoding_translation') != 0) {
+ return array(UNICODE_ERROR, $t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.encoding_translation</em> setting. Please refer to the <a href="@url">PHP mbstring documentation</a> for more information.', array('@url' => 'http://www.php.net/mbstring')));
+ }
+ if (ini_get('mbstring.http_input') != 'pass') {
+ return array(UNICODE_ERROR, $t('Multibyte string input conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.http_input</em> setting. Please refer to the <a href="@url">PHP mbstring documentation</a> for more information.', array('@url' => 'http://www.php.net/mbstring')));
+ }
+ if (ini_get('mbstring.http_output') != 'pass') {
+ return array(UNICODE_ERROR, $t('Multibyte string output conversion in PHP is active and must be disabled. Check the php.ini <em>mbstring.http_output</em> setting. Please refer to the <a href="@url">PHP mbstring documentation</a> for more information.', array('@url' => 'http://www.php.net/mbstring')));
+ }
+
+ // Set appropriate configuration
+ mb_internal_encoding('utf-8');
+ mb_language('uni');
+ return array(UNICODE_MULTIBYTE, '');
+}
+
+/**
+ * Return Unicode library status and errors.
+ */
+function unicode_requirements() {
+ // Ensure translations don't break at install time
+ $t = get_t();
+
+ $libraries = array(
+ UNICODE_SINGLEBYTE => $t('Standard PHP'),
+ UNICODE_MULTIBYTE => $t('PHP Mbstring Extension'),
+ UNICODE_ERROR => $t('Error'),
+ );
+ $severities = array(
+ UNICODE_SINGLEBYTE => REQUIREMENT_WARNING,
+ UNICODE_MULTIBYTE => REQUIREMENT_OK,
+ UNICODE_ERROR => REQUIREMENT_ERROR,
+ );
+ list($library, $description) = _unicode_check();
+
+ $requirements['unicode'] = array(
+ 'title' => $t('Unicode library'),
+ 'value' => $libraries[$library],
+ );
+ if ($description) {
+ $requirements['unicode']['description'] = $description;
+ }
+
+ $requirements['unicode']['severity'] = $severities[$library];
+
+ return $requirements;
+}
+
+/**
+ * Prepare a new XML parser.
+ *
+ * This is a wrapper around xml_parser_create() which extracts the encoding from
+ * the XML data first and sets the output encoding to UTF-8. This function should
+ * be used instead of xml_parser_create(), because PHP 4's XML parser doesn't
+ * check the input encoding itself. "Starting from PHP 5, the input encoding is
+ * automatically detected, so that the encoding parameter specifies only the
+ * output encoding."
+ *
+ * This is also where unsupported encodings will be converted. Callers should
+ * take this into account: $data might have been changed after the call.
+ *
+ * @param $data
+ * The XML data which will be parsed later.
+ *
+ * @return
+ * An XML parser object or FALSE on error.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_xml_parser_create(&$data) {
+ // Default XML encoding is UTF-8
+ $encoding = 'utf-8';
+ $bom = FALSE;
+
+ // Check for UTF-8 byte order mark (PHP5's XML parser doesn't handle it).
+ if (!strncmp($data, "\xEF\xBB\xBF", 3)) {
+ $bom = TRUE;
+ $data = substr($data, 3);
+ }
+
+ // Check for an encoding declaration in the XML prolog if no BOM was found.
+ if (!$bom && preg_match('/^<\?xml[^>]+encoding="(.+?)"/', $data, $match)) {
+ $encoding = $match[1];
+ }
+
+ // Unsupported encodings are converted here into UTF-8.
+ $php_supported = array('utf-8', 'iso-8859-1', 'us-ascii');
+ if (!in_array(strtolower($encoding), $php_supported)) {
+ $out = drupal_convert_to_utf8($data, $encoding);
+ if ($out !== FALSE) {
+ $encoding = 'utf-8';
+ $data = preg_replace('/^(<\?xml[^>]+encoding)="(.+?)"/', '\\1="utf-8"', $out);
+ }
+ else {
+ watchdog('php', 'Could not convert XML encoding %s to UTF-8.', array('%s' => $encoding), WATCHDOG_WARNING);
+ return FALSE;
+ }
+ }
+
+ $xml_parser = xml_parser_create($encoding);
+ xml_parser_set_option($xml_parser, XML_OPTION_TARGET_ENCODING, 'utf-8');
+ return $xml_parser;
+}
+
+/**
+ * Convert data to UTF-8
+ *
+ * Requires the iconv, GNU recode or mbstring PHP extension.
+ *
+ * @param $data
+ * The data to be converted.
+ * @param $encoding
+ * The encoding that the data is in.
+ *
+ * @return
+ * Converted data or FALSE.
+ */
+function drupal_convert_to_utf8($data, $encoding) {
+ if (function_exists('iconv')) {
+ $out = @iconv($encoding, 'utf-8', $data);
+ }
+ elseif (function_exists('mb_convert_encoding')) {
+ $out = @mb_convert_encoding($data, 'utf-8', $encoding);
+ }
+ elseif (function_exists('recode_string')) {
+ $out = @recode_string($encoding . '..utf-8', $data);
+ }
+ else {
+ watchdog('php', 'Unsupported encoding %s. Please install iconv, GNU recode or mbstring for PHP.', array('%s' => $encoding), WATCHDOG_ERROR);
+ return FALSE;
+ }
+
+ return $out;
+}
+
+/**
+ * Truncate a UTF-8-encoded string safely to a number of bytes.
+ *
+ * If the end position is in the middle of a UTF-8 sequence, it scans backwards
+ * until the beginning of the byte sequence.
+ *
+ * Use this function whenever you want to chop off a string at an unsure
+ * location. On the other hand, if you're sure that you're splitting on a
+ * character boundary (e.g. after using strpos() or similar), you can safely use
+ * substr() instead.
+ *
+ * @param $string
+ * The string to truncate.
+ * @param $len
+ * An upper limit on the returned string length.
+ *
+ * @return
+ * The truncated string.
+ */
+function drupal_truncate_bytes($string, $len) {
+ if (strlen($string) <= $len) {
+ return $string;
+ }
+ if ((ord($string[$len]) < 0x80) || (ord($string[$len]) >= 0xC0)) {
+ return substr($string, 0, $len);
+ }
+ // Scan backwards to beginning of the byte sequence.
+ while (--$len >= 0 && ord($string[$len]) >= 0x80 && ord($string[$len]) < 0xC0);
+
+ return substr($string, 0, $len);
+}
+
+/**
+ * Truncates a UTF-8-encoded string safely to a number of characters.
+ *
+ * @param $string
+ * The string to truncate.
+ * @param $max_length
+ * An upper limit on the returned string length, including trailing ellipsis
+ * if $add_ellipsis is TRUE.
+ * @param $wordsafe
+ * If TRUE, attempt to truncate on a word boundary. Word boundaries are
+ * spaces, punctuation, and Unicode characters used as word boundaries in
+ * non-Latin languages; see PREG_CLASS_UNICODE_WORD_BOUNDARY for more
+ * information. If a word boundary cannot be found that would make the length
+ * of the returned string fall within length guidelines (see parameters
+ * $max_length and $min_wordsafe_length), word boundaries are ignored.
+ * @param $add_ellipsis
+ * If TRUE, add t('...') to the end of the truncated string (defaults to
+ * FALSE). The string length will still fall within $max_length.
+ * @param $min_wordsafe_length
+ * If $wordsafe is TRUE, the minimum acceptable length for truncation (before
+ * adding an ellipsis, if $add_ellipsis is TRUE). Has no effect if $wordsafe
+ * is FALSE. This can be used to prevent having a very short resulting string
+ * that will not be understandable. For instance, if you are truncating the
+ * string "See myverylongurlexample.com for more information" to a word-safe
+ * return length of 20, the only available word boundary within 20 characters
+ * is after the word "See", which wouldn't leave a very informative string. If
+ * you had set $min_wordsafe_length to 10, though, the function would realise
+ * that "See" alone is too short, and would then just truncate ignoring word
+ * boundaries, giving you "See myverylongurl..." (assuming you had set
+ * $add_ellipses to TRUE).
+ *
+ * @return
+ * The truncated string.
+ */
+function truncate_utf8($string, $max_length, $wordsafe = FALSE, $add_ellipsis = FALSE, $min_wordsafe_length = 1) {
+ $ellipsis = '';
+ $max_length = max($max_length, 0);
+ $min_wordsafe_length = max($min_wordsafe_length, 0);
+
+ if (drupal_strlen($string) <= $max_length) {
+ // No truncation needed, so don't add ellipsis, just return.
+ return $string;
+ }
+
+ if ($add_ellipsis) {
+ // Truncate ellipsis in case $max_length is small.
+ $ellipsis = drupal_substr(t('...'), 0, $max_length);
+ $max_length -= drupal_strlen($ellipsis);
+ $max_length = max($max_length, 0);
+ }
+
+ if ($max_length <= $min_wordsafe_length) {
+ // Do not attempt word-safe if lengths are bad.
+ $wordsafe = FALSE;
+ }
+
+ if ($wordsafe) {
+ $matches = array();
+ // Find the last word boundary, if there is one within $min_wordsafe_length
+ // to $max_length characters. preg_match() is always greedy, so it will
+ // find the longest string possible.
+ $found = preg_match('/^(.{' . $min_wordsafe_length . ',' . $max_length . '})[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']/u', $string, $matches);
+ if ($found) {
+ $string = $matches[1];
+ }
+ else {
+ $string = drupal_substr($string, 0, $max_length);
+ }
+ }
+ else {
+ $string = drupal_substr($string, 0, $max_length);
+ }
+
+ if ($add_ellipsis) {
+ $string .= $ellipsis;
+ }
+
+ return $string;
+}
+
+/**
+ * Encodes MIME/HTTP header values that contain non-ASCII, UTF-8 encoded
+ * characters.
+ *
+ * For example, mime_header_encode('tést.txt') returns "=?UTF-8?B?dMOpc3QudHh0?=".
+ *
+ * See http://www.rfc-editor.org/rfc/rfc2047.txt for more information.
+ *
+ * Notes:
+ * - Only encode strings that contain non-ASCII characters.
+ * - We progressively cut-off a chunk with truncate_utf8(). This is to ensure
+ * each chunk starts and ends on a character boundary.
+ * - Using \n as the chunk separator may cause problems on some systems and may
+ * have to be changed to \r\n or \r.
+ */
+function mime_header_encode($string) {
+ if (preg_match('/[^\x20-\x7E]/', $string)) {
+ $chunk_size = 47; // floor((75 - strlen("=?UTF-8?B??=")) * 0.75);
+ $len = strlen($string);
+ $output = '';
+ while ($len > 0) {
+ $chunk = drupal_truncate_bytes($string, $chunk_size);
+ $output .= ' =?UTF-8?B?' . base64_encode($chunk) . "?=\n";
+ $c = strlen($chunk);
+ $string = substr($string, $c);
+ $len -= $c;
+ }
+ return trim($output);
+ }
+ return $string;
+}
+
+/**
+ * Complement to mime_header_encode
+ */
+function mime_header_decode($header) {
+ // First step: encoded chunks followed by other encoded chunks (need to collapse whitespace)
+ $header = preg_replace_callback('/=\?([^?]+)\?(Q|B)\?([^?]+|\?(?!=))\?=\s+(?==\?)/', '_mime_header_decode', $header);
+ // Second step: remaining chunks (do not collapse whitespace)
+ return preg_replace_callback('/=\?([^?]+)\?(Q|B)\?([^?]+|\?(?!=))\?=/', '_mime_header_decode', $header);
+}
+
+/**
+ * Helper function to mime_header_decode
+ */
+function _mime_header_decode($matches) {
+ // Regexp groups:
+ // 1: Character set name
+ // 2: Escaping method (Q or B)
+ // 3: Encoded data
+ $data = ($matches[2] == 'B') ? base64_decode($matches[3]) : str_replace('_', ' ', quoted_printable_decode($matches[3]));
+ if (strtolower($matches[1]) != 'utf-8') {
+ $data = drupal_convert_to_utf8($data, $matches[1]);
+ }
+ return $data;
+}
+
+/**
+ * Decodes all HTML entities (including numerical ones) to regular UTF-8 bytes.
+ *
+ * Double-escaped entities will only be decoded once ("&amp;lt;" becomes "&lt;",
+ * not "<"). Be careful when using this function, as decode_entities can revert
+ * previous sanitization efforts (&lt;script&gt; will become <script>).
+ *
+ * @param $text
+ * The text to decode entities in.
+ *
+ * @return
+ * The input $text, with all HTML entities decoded once.
+ */
+function decode_entities($text) {
+ return html_entity_decode($text, ENT_QUOTES, 'UTF-8');
+}
+
+/**
+ * Count the amount of characters in a UTF-8 string. This is less than or
+ * equal to the byte count.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_strlen($text) {
+ global $multibyte;
+ if ($multibyte == UNICODE_MULTIBYTE) {
+ return mb_strlen($text);
+ }
+ else {
+ // Do not count UTF-8 continuation bytes.
+ return strlen(preg_replace("/[\x80-\xBF]/", '', $text));
+ }
+}
+
+/**
+ * Uppercase a UTF-8 string.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_strtoupper($text) {
+ global $multibyte;
+ if ($multibyte == UNICODE_MULTIBYTE) {
+ return mb_strtoupper($text);
+ }
+ else {
+ // Use C-locale for ASCII-only uppercase
+ $text = strtoupper($text);
+ // Case flip Latin-1 accented letters
+ $text = preg_replace_callback('/\xC3[\xA0-\xB6\xB8-\xBE]/', '_unicode_caseflip', $text);
+ return $text;
+ }
+}
+
+/**
+ * Lowercase a UTF-8 string.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_strtolower($text) {
+ global $multibyte;
+ if ($multibyte == UNICODE_MULTIBYTE) {
+ return mb_strtolower($text);
+ }
+ else {
+ // Use C-locale for ASCII-only lowercase
+ $text = strtolower($text);
+ // Case flip Latin-1 accented letters
+ $text = preg_replace_callback('/\xC3[\x80-\x96\x98-\x9E]/', '_unicode_caseflip', $text);
+ return $text;
+ }
+}
+
+/**
+ * Helper function for case conversion of Latin-1.
+ * Used for flipping U+C0-U+DE to U+E0-U+FD and back.
+ */
+function _unicode_caseflip($matches) {
+ return $matches[0][0] . chr(ord($matches[0][1]) ^ 32);
+}
+
+/**
+ * Capitalize the first letter of a UTF-8 string.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_ucfirst($text) {
+ // Note: no mbstring equivalent!
+ return drupal_strtoupper(drupal_substr($text, 0, 1)) . drupal_substr($text, 1);
+}
+
+/**
+ * Cut off a piece of a string based on character indices and counts. Follows
+ * the same behavior as PHP's own substr() function.
+ *
+ * Note that for cutting off a string at a known character/substring
+ * location, the usage of PHP's normal strpos/substr is safe and
+ * much faster.
+ *
+ * @ingroup php_wrappers
+ */
+function drupal_substr($text, $start, $length = NULL) {
+ global $multibyte;
+ if ($multibyte == UNICODE_MULTIBYTE) {
+ return $length === NULL ? mb_substr($text, $start) : mb_substr($text, $start, $length);
+ }
+ else {
+ $strlen = strlen($text);
+ // Find the starting byte offset.
+ $bytes = 0;
+ if ($start > 0) {
+ // Count all the continuation bytes from the start until we have found
+ // $start characters or the end of the string.
+ $bytes = -1; $chars = -1;
+ while ($bytes < $strlen - 1 && $chars < $start) {
+ $bytes++;
+ $c = ord($text[$bytes]);
+ if ($c < 0x80 || $c >= 0xC0) {
+ $chars++;
+ }
+ }
+ }
+ elseif ($start < 0) {
+ // Count all the continuation bytes from the end until we have found
+ // abs($start) characters.
+ $start = abs($start);
+ $bytes = $strlen; $chars = 0;
+ while ($bytes > 0 && $chars < $start) {
+ $bytes--;
+ $c = ord($text[$bytes]);
+ if ($c < 0x80 || $c >= 0xC0) {
+ $chars++;
+ }
+ }
+ }
+ $istart = $bytes;
+
+ // Find the ending byte offset.
+ if ($length === NULL) {
+ $iend = $strlen;
+ }
+ elseif ($length > 0) {
+ // Count all the continuation bytes from the starting index until we have
+ // found $length characters or reached the end of the string, then
+ // backtrace one byte.
+ $iend = $istart - 1;
+ $chars = -1;
+ $last_real = FALSE;
+ while ($iend < $strlen - 1 && $chars < $length) {
+ $iend++;
+ $c = ord($text[$iend]);
+ $last_real = FALSE;
+ if ($c < 0x80 || $c >= 0xC0) {
+ $chars++;
+ $last_real = TRUE;
+ }
+ }
+ // Backtrace one byte if the last character we found was a real character
+ // and we don't need it.
+ if ($last_real && $chars >= $length) {
+ $iend--;
+ }
+ }
+ elseif ($length < 0) {
+ // Count all the continuation bytes from the end until we have found
+ // abs($start) characters, then backtrace one byte.
+ $length = abs($length);
+ $iend = $strlen; $chars = 0;
+ while ($iend > 0 && $chars < $length) {
+ $iend--;
+ $c = ord($text[$iend]);
+ if ($c < 0x80 || $c >= 0xC0) {
+ $chars++;
+ }
+ }
+ // Backtrace one byte if we are not at the beginning of the string.
+ if ($iend > 0) {
+ $iend--;
+ }
+ }
+ else {
+ // $length == 0, return an empty string.
+ return '';
+ }
+
+ return substr($text, $istart, max(0, $iend - $istart + 1));
+ }
+}
diff --git a/core/includes/update.inc b/core/includes/update.inc
new file mode 100644
index 000000000000..c60d0bc9911e
--- /dev/null
+++ b/core/includes/update.inc
@@ -0,0 +1,730 @@
+<?php
+
+/**
+ * @file
+ * Drupal database update API.
+ *
+ * This file contains functions to perform database updates for a Drupal
+ * installation. It is included and used extensively by update.php.
+ */
+
+/**
+ * Minimum schema version of Drupal 7 required for upgrade to Drupal 8.
+ *
+ * Upgrades from Drupal 7 to Drupal 8 require that Drupal 7 be running
+ * the most recent version, or the upgrade could fail. We can't easily
+ * check the Drupal 7 version once the update process has begun, so instead
+ * we check the schema version of system.module in the system table.
+ */
+define('REQUIRED_D7_SCHEMA_VERSION', '7069');
+
+/**
+ * Disable any items in the {system} table that are not core compatible.
+ */
+function update_fix_compatibility() {
+ $incompatible = array();
+ $result = db_query("SELECT name, type, status FROM {system} WHERE status = 1 AND type IN ('module','theme')");
+ foreach ($result as $row) {
+ if (update_check_incompatibility($row->name, $row->type)) {
+ $incompatible[] = $row->name;
+ }
+ }
+ if (!empty($incompatible)) {
+ db_update('system')
+ ->fields(array('status' => 0))
+ ->condition('name', $incompatible, 'IN')
+ ->execute();
+ }
+}
+
+/**
+ * Helper function to test compatibility of a module or theme.
+ */
+function update_check_incompatibility($name, $type = 'module') {
+ static $themes, $modules;
+
+ // Store values of expensive functions for future use.
+ if (empty($themes) || empty($modules)) {
+ // We need to do a full rebuild here to make sure the database reflects any
+ // code changes that were made in the filesystem before the update script
+ // was initiated.
+ $themes = system_rebuild_theme_data();
+ $modules = system_rebuild_module_data();
+ }
+
+ if ($type == 'module' && isset($modules[$name])) {
+ $file = $modules[$name];
+ }
+ elseif ($type == 'theme' && isset($themes[$name])) {
+ $file = $themes[$name];
+ }
+ if (!isset($file)
+ || !isset($file->info['core'])
+ || $file->info['core'] != DRUPAL_CORE_COMPATIBILITY
+ || version_compare(phpversion(), $file->info['php']) < 0) {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Performs extra steps required to bootstrap when using a Drupal 7 database.
+ *
+ * Users who still have a Drupal 7 database (and are in the process of
+ * updating to Drupal 8) need extra help before a full bootstrap can be
+ * achieved. This function does the necessary preliminary work that allows
+ * the bootstrap to be successful.
+ *
+ * No access check has been performed when this function is called, so no
+ * irreversible changes to the database are made here.
+ */
+function update_prepare_d8_bootstrap() {
+ // Allow the database system to work even if the registry has not been
+ // created yet.
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+ include_once DRUPAL_ROOT . '/core/modules/entity/entity.controller.inc';
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_DATABASE);
+
+ // If the site has not updated to Drupal 8 yet, check to make sure that it is
+ // running an up-to-date version of Drupal 7 before proceeding. Note this has
+ // to happen AFTER the database bootstraps because of
+ // drupal_get_installed_schema_version().
+ $system_schema = drupal_get_installed_schema_version('system');
+ if ($system_schema < 8000) {
+ $has_required_schema = $system_schema >= REQUIRED_D7_SCHEMA_VERSION;
+ $requirements = array(
+ 'drupal 7 version' => array(
+ 'title' => 'Drupal 7 version',
+ 'value' => $has_required_schema ? 'You are running a current version of Drupal 7.' : 'You are not running a current version of Drupal 7',
+ 'severity' => $has_required_schema ? REQUIREMENT_OK : REQUIREMENT_ERROR,
+ 'description' => $has_required_schema ? '' : 'Please update your Drupal 7 installation to the most recent version before attempting to upgrade to Drupal 8',
+ ),
+ );
+ }
+}
+
+/**
+ * Perform Drupal 7.x to 8.x updates that are required for update.php
+ * to function properly.
+ *
+ * This function runs when update.php is run the first time for 8.x,
+ * even before updates are selected or performed. It is important
+ * that if updates are not ultimately performed that no changes are
+ * made which make it impossible to continue using the prior version.
+ */
+function update_fix_d8_requirements() {
+ global $conf;
+
+ if (drupal_get_installed_schema_version('system') < 8000 && !variable_get('update_d8_requirements', FALSE)) {
+ // @todo: Make critical, first-run changes to the database here.
+ variable_set('update_d8_requirements', TRUE);
+ }
+}
+
+/**
+ * Helper function to install a new module in Drupal 8 via hook_update_N().
+ */
+function update_module_enable(array $modules) {
+ foreach ($modules as $module) {
+ // Check for initial schema and install it. The schema version of a newly
+ // installed module is always 0. Using 8000 here would be inconsistent
+ // since $module_update_8000() may involve a schema change, and we want
+ // to install the schema as it was before any updates were added.
+ $function = $module . '_schema_0';
+ if (function_exists($function)) {
+ $schema = $function();
+ foreach ($schema as $table => $spec) {
+ db_create_table($table, $spec);
+ }
+ }
+ // Change the schema version from SCHEMA_UNINSTALLED to 0, so any module
+ // updates since the module's inception are executed in a core upgrade.
+ db_update('system')
+ ->condition('type', 'module')
+ ->condition('name', $module)
+ ->fields(array('schema_version' => 0))
+ ->execute();
+
+ system_list_reset();
+ // @todo: figure out what to do about hook_install() and hook_enable().
+ }
+}
+
+/**
+ * Perform one update and store the results for display on finished page.
+ *
+ * If an update function completes successfully, it should return a message
+ * as a string indicating success, for example:
+ * @code
+ * return t('New index added successfully.');
+ * @endcode
+ *
+ * Alternatively, it may return nothing. In that case, no message
+ * will be displayed at all.
+ *
+ * If it fails for whatever reason, it should throw an instance of
+ * DrupalUpdateException with an appropriate error message, for example:
+ * @code
+ * throw new DrupalUpdateException(t('Description of what went wrong'));
+ * @endcode
+ *
+ * If an exception is thrown, the current update and all updates that depend on
+ * it will be aborted. The schema version will not be updated in this case, and
+ * all the aborted updates will continue to appear on update.php as updates
+ * that have not yet been run.
+ *
+ * If an update function needs to be re-run as part of a batch process, it
+ * should accept the $sandbox array by reference as its first parameter
+ * and set the #finished property to the percentage completed that it is, as a
+ * fraction of 1.
+ *
+ * @param $module
+ * The module whose update will be run.
+ * @param $number
+ * The update number to run.
+ * @param $dependency_map
+ * An array whose keys are the names of all update functions that will be
+ * performed during this batch process, and whose values are arrays of other
+ * update functions that each one depends on.
+ * @param $context
+ * The batch context array.
+ *
+ * @see update_resolve_dependencies()
+ */
+function update_do_one($module, $number, $dependency_map, &$context) {
+ $function = $module . '_update_' . $number;
+
+ // If this update was aborted in a previous step, or has a dependency that
+ // was aborted in a previous step, go no further.
+ if (!empty($context['results']['#abort']) && array_intersect($context['results']['#abort'], array_merge($dependency_map, array($function)))) {
+ return;
+ }
+
+ $ret = array();
+ if (function_exists($function)) {
+ try {
+ $ret['results']['query'] = $function($context['sandbox']);
+ $ret['results']['success'] = TRUE;
+ }
+ // @TODO We may want to do different error handling for different
+ // exception types, but for now we'll just log the exception and
+ // return the message for printing.
+ catch (Exception $e) {
+ watchdog_exception('update', $e);
+
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ $variables = _drupal_decode_exception($e);
+ // The exception message is run through check_plain() by _drupal_decode_exception().
+ $ret['#abort'] = array('success' => FALSE, 'query' => t('%type: !message in %function (line %line of %file).', $variables));
+ }
+ }
+
+ if (isset($context['sandbox']['#finished'])) {
+ $context['finished'] = $context['sandbox']['#finished'];
+ unset($context['sandbox']['#finished']);
+ }
+
+ if (!isset($context['results'][$module])) {
+ $context['results'][$module] = array();
+ }
+ if (!isset($context['results'][$module][$number])) {
+ $context['results'][$module][$number] = array();
+ }
+ $context['results'][$module][$number] = array_merge($context['results'][$module][$number], $ret);
+
+ if (!empty($ret['#abort'])) {
+ // Record this function in the list of updates that were aborted.
+ $context['results']['#abort'][] = $function;
+ }
+
+ // Record the schema update if it was completed successfully.
+ if ($context['finished'] == 1 && empty($ret['#abort'])) {
+ drupal_set_installed_schema_version($module, $number);
+ }
+
+ $context['message'] = 'Updating ' . check_plain($module) . ' module';
+}
+
+/**
+ * @class Exception class used to throw error if a module update fails.
+ */
+class DrupalUpdateException extends Exception { }
+
+/**
+ * Start the database update batch process.
+ *
+ * @param $start
+ * An array whose keys contain the names of modules to be updated during the
+ * current batch process, and whose values contain the number of the first
+ * requested update for that module. The actual updates that are run (and the
+ * order they are run in) will depend on the results of passing this data
+ * through the update dependency system.
+ * @param $redirect
+ * Path to redirect to when the batch has finished processing.
+ * @param $url
+ * URL of the batch processing page (should only be used for separate
+ * scripts like update.php).
+ * @param $batch
+ * Optional parameters to pass into the batch API.
+ * @param $redirect_callback
+ * (optional) Specify a function to be called to redirect to the progressive
+ * processing page.
+ *
+ * @see update_resolve_dependencies()
+ */
+function update_batch($start, $redirect = NULL, $url = NULL, $batch = array(), $redirect_callback = 'drupal_goto') {
+ // During the update, bring the site offline so that schema changes do not
+ // affect visiting users.
+ $_SESSION['maintenance_mode'] = variable_get('maintenance_mode', FALSE);
+ if ($_SESSION['maintenance_mode'] == FALSE) {
+ variable_set('maintenance_mode', TRUE);
+ }
+
+ // Resolve any update dependencies to determine the actual updates that will
+ // be run and the order they will be run in.
+ $updates = update_resolve_dependencies($start);
+
+ // Store the dependencies for each update function in an array which the
+ // batch API can pass in to the batch operation each time it is called. (We
+ // do not store the entire update dependency array here because it is
+ // potentially very large.)
+ $dependency_map = array();
+ foreach ($updates as $function => $update) {
+ $dependency_map[$function] = !empty($update['reverse_paths']) ? array_keys($update['reverse_paths']) : array();
+ }
+
+ $operations = array();
+ foreach ($updates as $update) {
+ if ($update['allowed']) {
+ // Set the installed version of each module so updates will start at the
+ // correct place. (The updates are already sorted, so we can simply base
+ // this on the first one we come across in the above foreach loop.)
+ if (isset($start[$update['module']])) {
+ drupal_set_installed_schema_version($update['module'], $update['number'] - 1);
+ unset($start[$update['module']]);
+ }
+ // Add this update function to the batch.
+ $function = $update['module'] . '_update_' . $update['number'];
+ $operations[] = array('update_do_one', array($update['module'], $update['number'], $dependency_map[$function]));
+ }
+ }
+ $batch['operations'] = $operations;
+ $batch += array(
+ 'title' => 'Updating',
+ 'init_message' => 'Starting updates',
+ 'error_message' => 'An unrecoverable error has occurred. You can find the error message below. It is advised to copy it to the clipboard for reference.',
+ 'finished' => 'update_finished',
+ 'file' => 'core/includes/update.inc',
+ );
+ batch_set($batch);
+ batch_process($redirect, $url, $redirect_callback);
+}
+
+/**
+ * Finish the update process and store results for eventual display.
+ *
+ * After the updates run, all caches are flushed. The update results are
+ * stored into the session (for example, to be displayed on the update results
+ * page in update.php). Additionally, if the site was off-line, now that the
+ * update process is completed, the site is set back online.
+ *
+ * @param $success
+ * Indicate that the batch API tasks were all completed successfully.
+ * @param $results
+ * An array of all the results that were updated in update_do_one().
+ * @param $operations
+ * A list of all the operations that had not been completed by the batch API.
+ *
+ * @see update_batch()
+ */
+function update_finished($success, $results, $operations) {
+ // Clear the caches in case the data has been updated.
+ drupal_flush_all_caches();
+
+ $_SESSION['update_results'] = $results;
+ $_SESSION['update_success'] = $success;
+ $_SESSION['updates_remaining'] = $operations;
+
+ // Now that the update is done, we can put the site back online if it was
+ // previously in maintenance mode.
+ if (isset($_SESSION['maintenance_mode']) && $_SESSION['maintenance_mode'] == FALSE) {
+ variable_set('maintenance_mode', FALSE);
+ unset($_SESSION['maintenance_mode']);
+ }
+}
+
+/**
+ * Return a list of all the pending database updates.
+ *
+ * @return
+ * An associative array keyed by module name which contains all information
+ * about database updates that need to be run, and any updates that are not
+ * going to proceed due to missing requirements. The system module will
+ * always be listed first.
+ *
+ * The subarray for each module can contain the following keys:
+ * - start: The starting update that is to be processed. If this does not
+ * exist then do not process any updates for this module as there are
+ * other requirements that need to be resolved.
+ * - warning: Any warnings about why this module can not be updated.
+ * - pending: An array of all the pending updates for the module including
+ * the update number and the description from source code comment for
+ * each update function. This array is keyed by the update number.
+ */
+function update_get_update_list() {
+ // Make sure that the system module is first in the list of updates.
+ $ret = array('system' => array());
+
+ $modules = drupal_get_installed_schema_version(NULL, FALSE, TRUE);
+ foreach ($modules as $module => $schema_version) {
+ // Skip uninstalled and incompatible modules.
+ if ($schema_version == SCHEMA_UNINSTALLED || update_check_incompatibility($module)) {
+ continue;
+ }
+ // Otherwise, get the list of updates defined by this module.
+ $updates = drupal_get_schema_versions($module);
+ if ($updates !== FALSE) {
+ // module_invoke returns NULL for nonexisting hooks, so if no updates
+ // are removed, it will == 0.
+ $last_removed = module_invoke($module, 'update_last_removed');
+ if ($schema_version < $last_removed) {
+ $ret[$module]['warning'] = '<em>' . $module . '</em> module can not be updated. Its schema version is ' . $schema_version . '. Updates up to and including ' . $last_removed . ' have been removed in this release. In order to update <em>' . $module . '</em> module, you will first <a href="http://drupal.org/upgrade">need to upgrade</a> to the last version in which these updates were available.';
+ continue;
+ }
+
+ $updates = drupal_map_assoc($updates);
+ foreach (array_keys($updates) as $update) {
+ if ($update > $schema_version) {
+ // The description for an update comes from its Doxygen.
+ $func = new ReflectionFunction($module . '_update_' . $update);
+ $description = str_replace(array("\n", '*', '/'), '', $func->getDocComment());
+ $ret[$module]['pending'][$update] = "$update - $description";
+ if (!isset($ret[$module]['start'])) {
+ $ret[$module]['start'] = $update;
+ }
+ }
+ }
+ if (!isset($ret[$module]['start']) && isset($ret[$module]['pending'])) {
+ $ret[$module]['start'] = $schema_version;
+ }
+ }
+ }
+
+ if (empty($ret['system'])) {
+ unset($ret['system']);
+ }
+ return $ret;
+}
+
+/**
+ * Resolves dependencies in a set of module updates, and orders them correctly.
+ *
+ * This function receives a list of requested module updates and determines an
+ * appropriate order to run them in such that all update dependencies are met.
+ * Any updates whose dependencies cannot be met are included in the returned
+ * array but have the key 'allowed' set to FALSE; the calling function should
+ * take responsibility for ensuring that these updates are ultimately not
+ * performed.
+ *
+ * In addition, the returned array also includes detailed information about the
+ * dependency chain for each update, as provided by the depth-first search
+ * algorithm in drupal_depth_first_search().
+ *
+ * @param $starting_updates
+ * An array whose keys contain the names of modules with updates to be run
+ * and whose values contain the number of the first requested update for that
+ * module.
+ *
+ * @return
+ * An array whose keys are the names of all update functions within the
+ * provided modules that would need to be run in order to fulfill the
+ * request, arranged in the order in which the update functions should be
+ * run. (This includes the provided starting update for each module and all
+ * subsequent updates that are available.) The values are themselves arrays
+ * containing all the keys provided by the drupal_depth_first_search()
+ * algorithm, which encode detailed information about the dependency chain
+ * for this update function (for example: 'paths', 'reverse_paths', 'weight',
+ * and 'component'), as well as the following additional keys:
+ * - 'allowed': A boolean which is TRUE when the update function's
+ * dependencies are met, and FALSE otherwise. Calling functions should
+ * inspect this value before running the update.
+ * - 'missing_dependencies': An array containing the names of any other
+ * update functions that are required by this one but that are unavailable
+ * to be run. This array will be empty when 'allowed' is TRUE.
+ * - 'module': The name of the module that this update function belongs to.
+ * - 'number': The number of this update function within that module.
+ *
+ * @see drupal_depth_first_search()
+ */
+function update_resolve_dependencies($starting_updates) {
+ // Obtain a dependency graph for the requested update functions.
+ $update_functions = update_get_update_function_list($starting_updates);
+ $graph = update_build_dependency_graph($update_functions);
+
+ // Perform the depth-first search and sort the results.
+ require_once DRUPAL_ROOT . '/core/includes/graph.inc';
+ drupal_depth_first_search($graph);
+ uasort($graph, 'drupal_sort_weight');
+
+ foreach ($graph as $function => &$data) {
+ $module = $data['module'];
+ $number = $data['number'];
+ // If the update function is missing and has not yet been performed, mark
+ // it and everything that ultimately depends on it as disallowed.
+ if (update_is_missing($module, $number, $update_functions) && !update_already_performed($module, $number)) {
+ $data['allowed'] = FALSE;
+ foreach (array_keys($data['paths']) as $dependent) {
+ $graph[$dependent]['allowed'] = FALSE;
+ $graph[$dependent]['missing_dependencies'][] = $function;
+ }
+ }
+ elseif (!isset($data['allowed'])) {
+ $data['allowed'] = TRUE;
+ $data['missing_dependencies'] = array();
+ }
+ // Now that we have finished processing this function, remove it from the
+ // graph if it was not part of the original list. This ensures that we
+ // never try to run any updates that were not specifically requested.
+ if (!isset($update_functions[$module][$number])) {
+ unset($graph[$function]);
+ }
+ }
+
+ return $graph;
+}
+
+/**
+ * Returns an organized list of update functions for a set of modules.
+ *
+ * @param $starting_updates
+ * An array whose keys contain the names of modules and whose values contain
+ * the number of the first requested update for that module.
+ *
+ * @return
+ * An array containing all the update functions that should be run for each
+ * module, including the provided starting update and all subsequent updates
+ * that are available. The keys of the array contain the module names, and
+ * each value is an ordered array of update functions, keyed by the update
+ * number.
+ *
+ * @see update_resolve_dependencies()
+ */
+function update_get_update_function_list($starting_updates) {
+ // Go through each module and find all updates that we need (including the
+ // first update that was requested and any updates that run after it).
+ $update_functions = array();
+ foreach ($starting_updates as $module => $version) {
+ $update_functions[$module] = array();
+ $updates = drupal_get_schema_versions($module);
+ if ($updates !== FALSE) {
+ $max_version = max($updates);
+ if ($version <= $max_version) {
+ foreach ($updates as $update) {
+ if ($update >= $version) {
+ $update_functions[$module][$update] = $module . '_update_' . $update;
+ }
+ }
+ }
+ }
+ }
+ return $update_functions;
+}
+
+/**
+ * Constructs a graph which encodes the dependencies between module updates.
+ *
+ * This function returns an associative array which contains a "directed graph"
+ * representation of the dependencies between a provided list of update
+ * functions, as well as any outside update functions that they directly depend
+ * on but that were not in the provided list. The vertices of the graph
+ * represent the update functions themselves, and each edge represents a
+ * requirement that the first update function needs to run before the second.
+ * For example, consider this graph:
+ *
+ * system_update_8000 ---> system_update_8001 ---> system_update_8002
+ *
+ * Visually, this indicates that system_update_8000() must run before
+ * system_update_8001(), which in turn must run before system_update_8002().
+ *
+ * The function takes into account standard dependencies within each module, as
+ * shown above (i.e., the fact that each module's updates must run in numerical
+ * order), but also finds any cross-module dependencies that are defined by
+ * modules which implement hook_update_dependencies(), and builds them into the
+ * graph as well.
+ *
+ * @param $update_functions
+ * An organized array of update functions, in the format returned by
+ * update_get_update_function_list().
+ *
+ * @return
+ * A multidimensional array representing the dependency graph, suitable for
+ * passing in to drupal_depth_first_search(), but with extra information
+ * about each update function also included. Each array key contains the name
+ * of an update function, including all update functions from the provided
+ * list as well as any outside update functions which they directly depend
+ * on. Each value is an associative array containing the following keys:
+ * - 'edges': A representation of any other update functions that immediately
+ * depend on this one. See drupal_depth_first_search() for more details on
+ * the format.
+ * - 'module': The name of the module that this update function belongs to.
+ * - 'number': The number of this update function within that module.
+ *
+ * @see drupal_depth_first_search()
+ * @see update_resolve_dependencies()
+ */
+function update_build_dependency_graph($update_functions) {
+ // Initialize an array that will define a directed graph representing the
+ // dependencies between update functions.
+ $graph = array();
+
+ // Go through each update function and build an initial list of dependencies.
+ foreach ($update_functions as $module => $functions) {
+ $previous_function = NULL;
+ foreach ($functions as $number => $function) {
+ // Add an edge to the directed graph representing the fact that each
+ // update function in a given module must run after the update that
+ // numerically precedes it.
+ if ($previous_function) {
+ $graph[$previous_function]['edges'][$function] = TRUE;
+ }
+ $previous_function = $function;
+
+ // Define the module and update number associated with this function.
+ $graph[$function]['module'] = $module;
+ $graph[$function]['number'] = $number;
+ }
+ }
+
+ // Now add any explicit update dependencies declared by modules.
+ $update_dependencies = update_retrieve_dependencies();
+ foreach ($graph as $function => $data) {
+ if (!empty($update_dependencies[$data['module']][$data['number']])) {
+ foreach ($update_dependencies[$data['module']][$data['number']] as $module => $number) {
+ $dependency = $module . '_update_' . $number;
+ $graph[$dependency]['edges'][$function] = TRUE;
+ $graph[$dependency]['module'] = $module;
+ $graph[$dependency]['number'] = $number;
+ }
+ }
+ }
+
+ return $graph;
+}
+
+/**
+ * Determines if a module update is missing or unavailable.
+ *
+ * @param $module
+ * The name of the module.
+ * @param $number
+ * The number of the update within that module.
+ * @param $update_functions
+ * An organized array of update functions, in the format returned by
+ * update_get_update_function_list(). This should represent all module
+ * updates that are requested to run at the time this function is called.
+ *
+ * @return
+ * TRUE if the provided module update is not installed or is not in the
+ * provided list of updates to run; FALSE otherwise.
+ */
+function update_is_missing($module, $number, $update_functions) {
+ return !isset($update_functions[$module][$number]) || !function_exists($update_functions[$module][$number]);
+}
+
+/**
+ * Determines if a module update has already been performed.
+ *
+ * @param $module
+ * The name of the module.
+ * @param $number
+ * The number of the update within that module.
+ *
+ * @return
+ * TRUE if the database schema indicates that the update has already been
+ * performed; FALSE otherwise.
+ */
+function update_already_performed($module, $number) {
+ return $number <= drupal_get_installed_schema_version($module);
+}
+
+/**
+ * Invoke hook_update_dependencies() in all installed modules.
+ *
+ * This function is similar to module_invoke_all(), with the main difference
+ * that it does not require that a module be enabled to invoke its hook, only
+ * that it be installed. This allows the update system to properly perform
+ * updates even on modules that are currently disabled.
+ *
+ * @return
+ * An array of return values obtained by merging the results of the
+ * hook_update_dependencies() implementations in all installed modules.
+ *
+ * @see module_invoke_all()
+ * @see hook_update_dependencies()
+ */
+function update_retrieve_dependencies() {
+ $return = array();
+ // Get a list of installed modules, arranged so that we invoke their hooks in
+ // the same order that module_invoke_all() does.
+ $modules = db_query("SELECT name FROM {system} WHERE type = 'module' AND schema_version <> :schema ORDER BY weight ASC, name ASC", array(':schema' => SCHEMA_UNINSTALLED))->fetchCol();
+ foreach ($modules as $module) {
+ $function = $module . '_update_dependencies';
+ if (function_exists($function)) {
+ $result = $function();
+ // Each implementation of hook_update_dependencies() returns a
+ // multidimensional, associative array containing some keys that
+ // represent module names (which are strings) and other keys that
+ // represent update function numbers (which are integers). We cannot use
+ // array_merge_recursive() to properly merge these results, since it
+ // treats strings and integers differently. Therefore, we have to
+ // explicitly loop through the expected array structure here and perform
+ // the merge manually.
+ if (isset($result) && is_array($result)) {
+ foreach ($result as $module => $module_data) {
+ foreach ($module_data as $update => $update_data) {
+ foreach ($update_data as $module_dependency => $update_dependency) {
+ // If there are redundant dependencies declared for the same
+ // update function (so that it is declared to depend on more than
+ // one update from a particular module), record the dependency on
+ // the highest numbered update here, since that automatically
+ // implies the previous ones. For example, if one module's
+ // implementation of hook_update_dependencies() required this
+ // ordering:
+ //
+ // system_update_8001 ---> user_update_8000
+ //
+ // but another module's implementation of the hook required this
+ // one:
+ //
+ // system_update_8002 ---> user_update_8000
+ //
+ // we record the second one, since system_update_8001() is always
+ // guaranteed to run before system_update_8002() anyway (within
+ // an individual module, updates are always run in numerical
+ // order).
+ if (!isset($return[$module][$update][$module_dependency]) || $update_dependency > $return[$module][$update][$module_dependency]) {
+ $return[$module][$update][$module_dependency] = $update_dependency;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * @defgroup update-api-7.x-to-8.x Update versions of API functions
+ * @{
+ * Functions similar to normal API function but not firing hooks.
+ *
+ * During update, it is impossible to judge the consequences of firing a hook
+ * as it might hit a module not yet updated. So simplified versions of some
+ * core APIs are provided.
+ */
+
+/**
+ * @} End of "defgroup update-api-7.x-to-8.x"
+ */
diff --git a/core/includes/updater.inc b/core/includes/updater.inc
new file mode 100644
index 000000000000..363c6ebffaae
--- /dev/null
+++ b/core/includes/updater.inc
@@ -0,0 +1,427 @@
+<?php
+
+/**
+ * @file
+ * Classes used for updating various files in the Drupal webroot. These
+ * classes use a FileTransfer object to actually perform the operations.
+ * Normally, the FileTransfer is provided when the site owner is redirected to
+ * authorize.php as part of a multistep process.
+ */
+
+/**
+ * Interface for a class which can update a Drupal project.
+ *
+ * An Updater currently serves the following purposes:
+ * - It can take a given directory, and determine if it can operate on it.
+ * - It can move the contents of that directory into the appropriate place
+ * on the system using FileTransfer classes.
+ * - It can return a list of "next steps" after an update or install.
+ * - In the future, it will most likely perform some of those steps as well.
+ */
+interface DrupalUpdaterInterface {
+
+ /**
+ * Checks if the project is installed.
+ *
+ * @return bool
+ */
+ public function isInstalled();
+
+ /**
+ * Returns the system name of the project.
+ *
+ * @param string $directory
+ * A directory containing a project.
+ */
+ public static function getProjectName($directory);
+
+ /**
+ * @return string
+ * An absolute path to the default install location.
+ */
+ public function getInstallDirectory();
+
+ /**
+ * Determine if the Updater can handle the project provided in $directory.
+ *
+ * @todo: Provide something more rational here, like a project spec file.
+ *
+ * @param string $directory
+ *
+ * @return bool
+ * TRUE if the project is installed, FALSE if not.
+ */
+ public static function canUpdateDirectory($directory);
+
+ /**
+ * Actions to run after an install has occurred.
+ */
+ public function postInstall();
+
+ /**
+ * Actions to run after an update has occurred.
+ */
+ public function postUpdate();
+}
+
+/**
+ * Base class for Updaters used in Drupal.
+ */
+class Updater {
+
+ /**
+ * @var string $source Directory to install from.
+ */
+ public $source;
+
+ public function __construct($source) {
+ $this->source = $source;
+ $this->name = self::getProjectName($source);
+ $this->title = self::getProjectTitle($source);
+ }
+
+ /**
+ * Return an Updater of the appropriate type depending on the source.
+ *
+ * If a directory is provided which contains a module, will return a
+ * ModuleUpdater.
+ *
+ * @param string $source
+ * Directory of a Drupal project.
+ *
+ * @return Updater
+ */
+ public static function factory($source) {
+ if (is_dir($source)) {
+ $updater = self::getUpdaterFromDirectory($source);
+ }
+ else {
+ throw new UpdaterException(t('Unable to determine the type of the source directory.'));
+ }
+ return new $updater($source);
+ }
+
+ /**
+ * Determine which Updater class can operate on the given directory.
+ *
+ * @param string $directory
+ * Extracted Drupal project.
+ *
+ * @return string
+ * The class name which can work with this project type.
+ */
+ public static function getUpdaterFromDirectory($directory) {
+ // Gets a list of possible implementing classes.
+ $updaters = drupal_get_updaters();
+ foreach ($updaters as $updater) {
+ $class = $updater['class'];
+ if (call_user_func(array($class, 'canUpdateDirectory'), $directory)) {
+ return $class;
+ }
+ }
+ throw new UpdaterException(t('Cannot determine the type of project.'));
+ }
+
+ /**
+ * Figure out what the most important (or only) info file is in a directory.
+ *
+ * Since there is no enforcement of which info file is the project's "main"
+ * info file, this will get one with the same name as the directory, or the
+ * first one it finds. Not ideal, but needs a larger solution.
+ *
+ * @param string $directory
+ * Directory to search in.
+ *
+ * @return string
+ * Path to the info file.
+ */
+ public static function findInfoFile($directory) {
+ $info_files = file_scan_directory($directory, '/.*\.info$/');
+ if (!$info_files) {
+ return FALSE;
+ }
+ foreach ($info_files as $info_file) {
+ if (drupal_substr($info_file->filename, 0, -5) == basename($directory)) {
+ // Info file Has the same name as the directory, return it.
+ return $info_file->uri;
+ }
+ }
+ // Otherwise, return the first one.
+ $info_file = array_shift($info_files);
+ return $info_file->uri;
+ }
+
+ /**
+ * Get the name of the project directory (basename).
+ *
+ * @todo: It would be nice, if projects contained an info file which could
+ * provide their canonical name.
+ *
+ * @param string $directory
+ *
+ * @return string
+ * The name of the project.
+ */
+ public static function getProjectName($directory) {
+ return basename($directory);
+ }
+
+ /**
+ * Return the project name from a Drupal info file.
+ *
+ * @param string $directory
+ * Directory to search for the info file.
+ *
+ * @return string
+ * The title of the project.
+ */
+ public static function getProjectTitle($directory) {
+ $info_file = self::findInfoFile($directory);
+ $info = drupal_parse_info_file($info_file);
+ if (empty($info)) {
+ throw new UpdaterException(t('Unable to parse info file: %info_file.', array('%info_file' => $info_file)));
+ }
+ if (empty($info['name'])) {
+ throw new UpdaterException(t("The info file (%info_file) does not define a 'name' attribute.", array('%info_file' => $info_file)));
+ }
+ return $info['name'];
+ }
+
+ /**
+ * Store the default parameters for the Updater.
+ *
+ * @param array $overrides
+ * An array of overrides for the default parameters.
+ *
+ * @return array
+ * An array of configuration parameters for an update or install operation.
+ */
+ protected function getInstallArgs($overrides = array()) {
+ $args = array(
+ 'make_backup' => FALSE,
+ 'install_dir' => $this->getInstallDirectory(),
+ 'backup_dir' => $this->getBackupDir(),
+ );
+ return array_merge($args, $overrides);
+ }
+
+ /**
+ * Updates a Drupal project, returns a list of next actions.
+ *
+ * @param FileTransfer $filetransfer
+ * Object that is a child of FileTransfer. Used for moving files
+ * to the server.
+ * @param array $overrides
+ * An array of settings to override defaults; see self::getInstallArgs().
+ *
+ * @return array
+ * An array of links which the user may need to complete the update
+ */
+ public function update(&$filetransfer, $overrides = array()) {
+ try {
+ // Establish arguments with possible overrides.
+ $args = $this->getInstallArgs($overrides);
+
+ // Take a Backup.
+ if ($args['make_backup']) {
+ $this->makeBackup($args['install_dir'], $args['backup_dir']);
+ }
+
+ if (!$this->name) {
+ // This is bad, don't want to delete the install directory.
+ throw new UpdaterException(t('Fatal error in update, cowardly refusing to wipe out the install directory.'));
+ }
+
+ // Make sure the installation parent directory exists and is writable.
+ $this->prepareInstallDirectory($filetransfer, $args['install_dir']);
+
+ // Note: If the project is installed in sites/all, it will not be
+ // deleted. It will be installed in sites/default as that will override
+ // the sites/all reference and not break other sites which are using it.
+ if (is_dir($args['install_dir'] . '/' . $this->name)) {
+ // Remove the existing installed file.
+ $filetransfer->removeDirectory($args['install_dir'] . '/' . $this->name);
+ }
+
+ // Copy the directory in place.
+ $filetransfer->copyDirectory($this->source, $args['install_dir']);
+
+ // Make sure what we just installed is readable by the web server.
+ $this->makeWorldReadable($filetransfer, $args['install_dir'] . '/' . $this->name);
+
+ // Run the updates.
+ // @TODO: decide if we want to implement this.
+ $this->postUpdate();
+
+ // For now, just return a list of links of things to do.
+ return $this->postUpdateTasks();
+ }
+ catch (FileTransferException $e) {
+ throw new UpdaterFileTransferException(t('File Transfer failed, reason: !reason', array('!reason' => strtr($e->getMessage(), $e->arguments))));
+ }
+ }
+
+ /**
+ * Installs a Drupal project, returns a list of next actions.
+ *
+ * @param FileTransfer $filetransfer
+ * Object that is a child of FileTransfer.
+ * @param array $overrides
+ * An array of settings to override defaults; see self::getInstallArgs().
+ *
+ * @return array
+ * An array of links which the user may need to complete the install.
+ */
+ public function install(&$filetransfer, $overrides = array()) {
+ try {
+ // Establish arguments with possible overrides.
+ $args = $this->getInstallArgs($overrides);
+
+ // Make sure the installation parent directory exists and is writable.
+ $this->prepareInstallDirectory($filetransfer, $args['install_dir']);
+
+ // Copy the directory in place.
+ $filetransfer->copyDirectory($this->source, $args['install_dir']);
+
+ // Make sure what we just installed is readable by the web server.
+ $this->makeWorldReadable($filetransfer, $args['install_dir'] . '/' . $this->name);
+
+ // Potentially enable something?
+ // @TODO: decide if we want to implement this.
+ $this->postInstall();
+ // For now, just return a list of links of things to do.
+ return $this->postInstallTasks();
+ }
+ catch (FileTransferException $e) {
+ throw new UpdaterFileTransferException(t('File Transfer failed, reason: !reason', array('!reason' => strtr($e->getMessage(), $e->arguments))));
+ }
+ }
+
+ /**
+ * Make sure the installation parent directory exists and is writable.
+ *
+ * @param FileTransfer $filetransfer
+ * Object which is a child of FileTransfer.
+ * @param string $directory
+ * The installation directory to prepare.
+ */
+ public function prepareInstallDirectory(&$filetransfer, $directory) {
+ // Make the parent dir writable if need be and create the dir.
+ if (!is_dir($directory)) {
+ $parent_dir = dirname($directory);
+ if (!is_writable($parent_dir)) {
+ @chmod($parent_dir, 0755);
+ // It is expected that this will fail if the directory is owned by the
+ // FTP user. If the FTP user == web server, it will succeed.
+ try {
+ $filetransfer->createDirectory($directory);
+ $this->makeWorldReadable($filetransfer, $directory);
+ }
+ catch (FileTransferException $e) {
+ // Probably still not writable. Try to chmod and do it again.
+ // @todo: Make a new exception class so we can catch it differently.
+ try {
+ $old_perms = substr(sprintf('%o', fileperms($parent_dir)), -4);
+ $filetransfer->chmod($parent_dir, 0755);
+ $filetransfer->createDirectory($directory);
+ $this->makeWorldReadable($filetransfer, $directory);
+ // Put the permissions back.
+ $filetransfer->chmod($parent_dir, intval($old_perms, 8));
+ }
+ catch (FileTransferException $e) {
+ $message = t($e->getMessage(), $e->arguments);
+ $throw_message = t('Unable to create %directory due to the following: %reason', array('%directory' => $install_location, '%reason' => $message));
+ throw new UpdaterException($throw_message);
+ }
+ }
+ // Put the parent directory back.
+ @chmod($parent_dir, 0555);
+ }
+ }
+ }
+
+ /**
+ * Ensure that a given directory is world readable.
+ *
+ * @param FileTransfer $filetransfer
+ * Object which is a child of FileTransfer.
+ * @param string $path
+ * The file path to make world readable.
+ * @param bool $recursive
+ * If the chmod should be applied recursively.
+ */
+ public function makeWorldReadable(&$filetransfer, $path, $recursive = TRUE) {
+ if (!is_executable($path)) {
+ // Set it to read + execute.
+ $new_perms = substr(sprintf('%o', fileperms($path)), -4, -1) . "5";
+ $filetransfer->chmod($path, intval($new_perms, 8), $recursive);
+ }
+ }
+
+ /**
+ * Perform a backup.
+ *
+ * @todo Not implemented.
+ */
+ public function makeBackup(&$filetransfer, $from, $to) {
+ }
+
+ /**
+ * Return the full path to a directory where backups should be written.
+ */
+ public function getBackupDir() {
+ return file_stream_wrapper_get_instance_by_scheme('temporary')->getDirectoryPath();
+ }
+
+ /**
+ * Perform actions after new code is updated.
+ */
+ public function postUpdate() {
+ }
+
+ /**
+ * Perform actions after installation.
+ */
+ public function postInstall() {
+ }
+
+ /**
+ * Return an array of links to pages that should be visited post operation.
+ *
+ * @return array
+ * Links which provide actions to take after the install is finished.
+ */
+ public function postInstallTasks() {
+ return array();
+ }
+
+ /**
+ * Return an array of links to pages that should be visited post operation.
+ *
+ * @return array
+ * Links which provide actions to take after the update is finished.
+ */
+ public function postUpdateTasks() {
+ return array();
+ }
+}
+
+/**
+ * Exception class for the Updater class hierarchy.
+ *
+ * This is identical to the base Exception class, we just give it a more
+ * specific name so that call sites that want to tell the difference can
+ * specifically catch these exceptions and treat them differently.
+ */
+class UpdaterException extends Exception {
+}
+
+/**
+ * Child class of UpdaterException that indicates a FileTransfer exception.
+ *
+ * We have to catch FileTransfer exceptions and wrap those in t(), since
+ * FileTransfer is so low-level that it doesn't use any Drupal APIs and none
+ * of the strings are translated.
+ */
+class UpdaterFileTransferException extends UpdaterException {
+}
diff --git a/core/includes/utility.inc b/core/includes/utility.inc
new file mode 100644
index 000000000000..7d82f3292e90
--- /dev/null
+++ b/core/includes/utility.inc
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @file
+ * Miscellaneous functions.
+ */
+
+/**
+ * Drupal-friendly var_export().
+ *
+ * @param $var
+ * The variable to export.
+ * @param $prefix
+ * A prefix that will be added at the beginning of every lines of the output.
+ * @return
+ * The variable exported in a way compatible to Drupal's coding standards.
+ */
+function drupal_var_export($var, $prefix = '') {
+ if (is_array($var)) {
+ if (empty($var)) {
+ $output = 'array()';
+ }
+ else {
+ $output = "array(\n";
+ // Don't export keys if the array is non associative.
+ $export_keys = array_values($var) != $var;
+ foreach ($var as $key => $value) {
+ $output .= ' ' . ($export_keys ? drupal_var_export($key) . ' => ' : '') . drupal_var_export($value, ' ', FALSE) . ",\n";
+ }
+ $output .= ')';
+ }
+ }
+ elseif (is_bool($var)) {
+ $output = $var ? 'TRUE' : 'FALSE';
+ }
+ elseif (is_string($var)) {
+ $line_safe_var = str_replace("\n", '\n', $var);
+ if (strpos($var, "\n") !== FALSE || strpos($var, "'") !== FALSE) {
+ // If the string contains a line break or a single quote, use the
+ // double quote export mode. Encode backslash and double quotes and
+ // transform some common control characters.
+ $var = str_replace(array('\\', '"', "\n", "\r", "\t"), array('\\\\', '\"', '\n', '\r', '\t'), $var);
+ $output = '"' . $var . '"';
+ }
+ else {
+ $output = "'" . $var . "'";
+ }
+ }
+ else if (is_object($var) && get_class($var) === 'stdClass') {
+ // var_export() will export stdClass objects using an undefined
+ // magic method __set_state() leaving the export broken. This
+ // workaround avoids this by casting the object as an array for
+ // export and casting it back to an object when evaluated.
+ $output .= '(object) ' . drupal_var_export((array) $var, $prefix);
+ }
+ else {
+ $output = var_export($var, TRUE);
+ }
+
+ if ($prefix) {
+ $output = str_replace("\n", "\n$prefix", $output);
+ }
+
+ return $output;
+}
diff --git a/core/includes/uuid.inc b/core/includes/uuid.inc
new file mode 100644
index 000000000000..57f2199d7b8f
--- /dev/null
+++ b/core/includes/uuid.inc
@@ -0,0 +1,154 @@
+<?php
+
+/**
+ * @file
+ * Handling of universally unique identifiers.
+ */
+
+/**
+ * Interface that defines a UUID backend.
+ */
+interface UuidInterface {
+
+ /**
+ * Generates a Universally Unique IDentifier (UUID).
+ *
+ * @return
+ * A 32 byte integer represented as a hex string formatted with 4 hypens.
+ */
+ public function generate();
+
+}
+
+/**
+ * Factory class for UUIDs.
+ *
+ * Determines which UUID implementation to use, and uses that to generate
+ * and validate UUIDs.
+ */
+class Uuid {
+
+ /**
+ * Holds the UUID implementation.
+ */
+ protected $plugin;
+
+ /**
+ * This constructor instantiates the correct UUID object.
+ */
+ public function __construct() {
+ $class = $this->determinePlugin();
+ $this->plugin = new $class();
+ }
+
+ /**
+ * Generates an universally unique identifier.
+ *
+ * @see UuidInterface::generate()
+ */
+ public function generate() {
+ return $this->plugin->generate();
+ }
+
+ /**
+ * Check that a string appears to be in the format of a UUID.
+ *
+ * Plugins should not implement validation, since UUIDs should be in a
+ * consistent format across all plugins.
+ *
+ * @param $uuid
+ * The string to test.
+ *
+ * @return
+ * TRUE if the string is well formed.
+ */
+ public function isValid($uuid) {
+ return preg_match("/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/", $uuid);
+ }
+
+ /**
+ * Determines the optimal implementation to use for generating UUIDs.
+ *
+ * The selection is made based on the enabled PHP extensions with the
+ * most performant available option chosen.
+ *
+ * @return
+ * The class name for the optimal UUID generator.
+ */
+ protected function determinePlugin() {
+ static $plugin;
+ if (!empty($plugin)) {
+ return $plugin;
+ }
+
+ $plugin = 'UuidPhp';
+
+ // Debian/Ubuntu uses the (broken) OSSP extension as their UUID
+ // implementation. The OSSP implementation is not compatible with the
+ // PECL functions.
+ if (function_exists('uuid_create') && !function_exists('uuid_make')) {
+ $plugin = 'UuidPecl';
+ }
+ // Try to use the COM implementation for Windows users.
+ elseif (function_exists('com_create_guid')) {
+ $plugin = 'UuidCom';
+ }
+ return $plugin;
+ }
+}
+
+/**
+ * UUID implementation using the PECL extension.
+ */
+class UuidPecl implements UuidInterface {
+ public function generate() {
+ return uuid_create(UUID_TYPE_DEFAULT);
+ }
+}
+
+/**
+ * UUID implementation using the Windows internal GUID extension.
+ *
+ * @see http://php.net/com_create_guid
+ */
+class UuidCom implements UuidInterface {
+ public function generate() {
+ // Remove {} wrapper and make lower case to keep result consistent.
+ return drupal_strtolower(trim(com_create_guid(), '{}'));
+ }
+}
+
+/**
+ * Generates an UUID v4 using PHP code.
+ *
+ * Loosely based on Ruby's UUIDTools generate_random logic.
+ *
+ * @see http://uuidtools.rubyforge.org/api/classes/UUIDTools/UUID.html
+ */
+class UuidPhp implements UuidInterface {
+ public function generate() {
+ $hex = substr(hash('sha256', drupal_random_bytes(16)), 0, 32);
+
+ // The field names refer to RFC 4122 section 4.1.2.
+ $time_low = substr($hex, 0, 8);
+ $time_mid = substr($hex, 8, 4);
+
+ $time_hi_and_version = base_convert(substr($hex, 12, 4), 16, 10);
+ $time_hi_and_version &= 0x0FFF;
+ $time_hi_and_version |= (4 << 12);
+
+ $clock_seq_hi_and_reserved = base_convert(substr($hex, 16, 4), 16, 10);
+ $clock_seq_hi_and_reserved &= 0x3F;
+ $clock_seq_hi_and_reserved |= 0x80;
+
+ $clock_seq_low = substr($hex, 20, 2);
+ $nodes = substr($hex, 20);
+
+ $uuid = sprintf('%s-%s-%04x-%02x%02x-%s',
+ $time_low, $time_mid,
+ $time_hi_and_version, $clock_seq_hi_and_reserved,
+ $clock_seq_low, $nodes);
+
+ return $uuid;
+ }
+}
diff --git a/core/includes/xmlrpc.inc b/core/includes/xmlrpc.inc
new file mode 100644
index 000000000000..92e5d14f0fc5
--- /dev/null
+++ b/core/includes/xmlrpc.inc
@@ -0,0 +1,625 @@
+<?php
+
+/**
+ * @file
+ * Drupal XML-RPC library.
+ *
+ * Based on the IXR - The Incutio XML-RPC Library - (c) Incutio Ltd 2002-2005
+ * Version 1.7 (beta) - Simon Willison, 23rd May 2005
+ * Site: http://scripts.incutio.com/xmlrpc/
+ * Manual: http://scripts.incutio.com/xmlrpc/manual.php
+ * This version is made available under the GNU GPL License
+ */
+
+/**
+ * Turns a data structure into objects with 'data' and 'type' attributes.
+ *
+ * @param $data
+ * The data structure.
+ * @param $type
+ * Optional type to assign to $data.
+ *
+ * @return object
+ * An XML-RPC data object containing the input $data.
+ */
+function xmlrpc_value($data, $type = FALSE) {
+ $xmlrpc_value = new stdClass();
+ $xmlrpc_value->data = $data;
+ if (!$type) {
+ $type = xmlrpc_value_calculate_type($xmlrpc_value);
+ }
+ $xmlrpc_value->type = $type;
+ if ($type == 'struct') {
+ // Turn all the values in the array into new xmlrpc_values
+ foreach ($xmlrpc_value->data as $key => $value) {
+ $xmlrpc_value->data[$key] = xmlrpc_value($value);
+ }
+ }
+ if ($type == 'array') {
+ for ($i = 0, $j = count($xmlrpc_value->data); $i < $j; $i++) {
+ $xmlrpc_value->data[$i] = xmlrpc_value($xmlrpc_value->data[$i]);
+ }
+ }
+ return $xmlrpc_value;
+}
+
+/**
+ * Maps a PHP type to an XML-RPC type.
+ *
+ * @param $xmlrpc_value
+ * Variable whose type should be mapped.
+ *
+ * @return string
+ * The corresponding XML-RPC type.
+ *
+ * @see http://www.xmlrpc.com/spec#scalars
+ */
+function xmlrpc_value_calculate_type($xmlrpc_value) {
+ // http://www.php.net/gettype: Never use gettype() to test for a certain type
+ // [...] Instead, use the is_* functions.
+ if (is_bool($xmlrpc_value->data)) {
+ return 'boolean';
+ }
+ if (is_double($xmlrpc_value->data)) {
+ return 'double';
+ }
+ if (is_int($xmlrpc_value->data)) {
+ return 'int';
+ }
+ if (is_array($xmlrpc_value->data)) {
+ // empty or integer-indexed arrays are 'array', string-indexed arrays 'struct'
+ return empty($xmlrpc_value->data) || range(0, count($xmlrpc_value->data) - 1) === array_keys($xmlrpc_value->data) ? 'array' : 'struct';
+ }
+ if (is_object($xmlrpc_value->data)) {
+ if (isset($xmlrpc_value->data->is_date)) {
+ return 'date';
+ }
+ if (isset($xmlrpc_value->data->is_base64)) {
+ return 'base64';
+ }
+ $xmlrpc_value->data = get_object_vars($xmlrpc_value->data);
+ return 'struct';
+ }
+ // default
+ return 'string';
+}
+
+/**
+ * Generates XML representing the given value.
+ *
+ * @param $xmlrpc_value
+ * A value to be represented in XML.
+ *
+ * @return
+ * XML representation of $xmlrpc_value.
+ */
+function xmlrpc_value_get_xml($xmlrpc_value) {
+ switch ($xmlrpc_value->type) {
+ case 'boolean':
+ return '<boolean>' . (($xmlrpc_value->data) ? '1' : '0') . '</boolean>';
+
+ case 'int':
+ return '<int>' . $xmlrpc_value->data . '</int>';
+
+ case 'double':
+ return '<double>' . $xmlrpc_value->data . '</double>';
+
+ case 'string':
+ // Note: we don't escape apostrophes because of the many blogging clients
+ // that don't support numerical entities (and XML in general) properly.
+ return '<string>' . htmlspecialchars($xmlrpc_value->data) . '</string>';
+
+ case 'array':
+ $return = '<array><data>' . "\n";
+ foreach ($xmlrpc_value->data as $item) {
+ $return .= ' <value>' . xmlrpc_value_get_xml($item) . "</value>\n";
+ }
+ $return .= '</data></array>';
+ return $return;
+
+ case 'struct':
+ $return = '<struct>' . "\n";
+ foreach ($xmlrpc_value->data as $name => $value) {
+ $return .= " <member><name>" . check_plain($name) . "</name><value>";
+ $return .= xmlrpc_value_get_xml($value) . "</value></member>\n";
+ }
+ $return .= '</struct>';
+ return $return;
+
+ case 'date':
+ return xmlrpc_date_get_xml($xmlrpc_value->data);
+
+ case 'base64':
+ return xmlrpc_base64_get_xml($xmlrpc_value->data);
+ }
+ return FALSE;
+}
+
+/**
+ * Constructs an object representing an XML-RPC message.
+ *
+ * @param $message
+ * A string containing an XML message.
+ *
+ * @return object
+ * An XML-RPC object containing the message.
+ *
+ * @see http://www.xmlrpc.com/spec
+ */
+function xmlrpc_message($message) {
+ $xmlrpc_message = new stdClass();
+ // The stack used to keep track of the current array/struct
+ $xmlrpc_message->array_structs = array();
+ // The stack used to keep track of if things are structs or array
+ $xmlrpc_message->array_structs_types = array();
+ // A stack as well
+ $xmlrpc_message->current_struct_name = array();
+ $xmlrpc_message->message = $message;
+ return $xmlrpc_message;
+}
+
+/**
+ * Parses an XML-RPC message.
+ *
+ * If parsing fails, the faultCode and faultString will be added to the message
+ * object.
+ *
+ * @param $xmlrpc_message
+ * An object generated by xmlrpc_message().
+ *
+ * @return
+ * TRUE if parsing succeeded; FALSE otherwise.
+ */
+function xmlrpc_message_parse($xmlrpc_message) {
+ $xmlrpc_message->_parser = xml_parser_create();
+ // Set XML parser to take the case of tags into account.
+ xml_parser_set_option($xmlrpc_message->_parser, XML_OPTION_CASE_FOLDING, FALSE);
+ // Set XML parser callback functions
+ xml_set_element_handler($xmlrpc_message->_parser, 'xmlrpc_message_tag_open', 'xmlrpc_message_tag_close');
+ xml_set_character_data_handler($xmlrpc_message->_parser, 'xmlrpc_message_cdata');
+ xmlrpc_message_set($xmlrpc_message);
+ if (!xml_parse($xmlrpc_message->_parser, $xmlrpc_message->message)) {
+ return FALSE;
+ }
+ xml_parser_free($xmlrpc_message->_parser);
+
+ // Grab the error messages, if any.
+ $xmlrpc_message = xmlrpc_message_get();
+ if (!isset($xmlrpc_message->messagetype)) {
+ return FALSE;
+ }
+ elseif ($xmlrpc_message->messagetype == 'fault') {
+ $xmlrpc_message->fault_code = $xmlrpc_message->params[0]['faultCode'];
+ $xmlrpc_message->fault_string = $xmlrpc_message->params[0]['faultString'];
+ }
+ return TRUE;
+}
+
+/**
+ * Stores a copy of the most recent XML-RPC message object temporarily.
+ *
+ * @param $value
+ * An XML-RPC message to store, or NULL to keep the last message.
+ *
+ * @return object
+ * The most recently stored message.
+ *
+ * @see xmlrpc_message_get()
+ */
+function xmlrpc_message_set($value = NULL) {
+ static $xmlrpc_message;
+ if ($value) {
+ $xmlrpc_message = $value;
+ }
+ return $xmlrpc_message;
+}
+
+/**
+ * Returns the most recently stored XML-RPC message object.
+ *
+ * @return object
+ * The most recently stored message.
+ *
+ * @see xmlrpc_message_set()
+ */
+function xmlrpc_message_get() {
+ return xmlrpc_message_set();
+}
+
+/**
+ * Handles opening tags for XML parsing in xmlrpc_message_parse().
+ */
+function xmlrpc_message_tag_open($parser, $tag, $attr) {
+ $xmlrpc_message = xmlrpc_message_get();
+ $xmlrpc_message->current_tag_contents = '';
+ $xmlrpc_message->last_open = $tag;
+ switch ($tag) {
+ case 'methodCall':
+ case 'methodResponse':
+ case 'fault':
+ $xmlrpc_message->messagetype = $tag;
+ break;
+
+ // Deal with stacks of arrays and structs
+ case 'data':
+ $xmlrpc_message->array_structs_types[] = 'array';
+ $xmlrpc_message->array_structs[] = array();
+ break;
+
+ case 'struct':
+ $xmlrpc_message->array_structs_types[] = 'struct';
+ $xmlrpc_message->array_structs[] = array();
+ break;
+ }
+ xmlrpc_message_set($xmlrpc_message);
+}
+
+/**
+ * Handles character data for XML parsing in xmlrpc_message_parse().
+ */
+function xmlrpc_message_cdata($parser, $cdata) {
+ $xmlrpc_message = xmlrpc_message_get();
+ $xmlrpc_message->current_tag_contents .= $cdata;
+ xmlrpc_message_set($xmlrpc_message);
+}
+
+/**
+ * Handles closing tags for XML parsing in xmlrpc_message_parse().
+ */
+function xmlrpc_message_tag_close($parser, $tag) {
+ $xmlrpc_message = xmlrpc_message_get();
+ $value_flag = FALSE;
+ switch ($tag) {
+ case 'int':
+ case 'i4':
+ $value = (int)trim($xmlrpc_message->current_tag_contents);
+ $value_flag = TRUE;
+ break;
+
+ case 'double':
+ $value = (double)trim($xmlrpc_message->current_tag_contents);
+ $value_flag = TRUE;
+ break;
+
+ case 'string':
+ $value = $xmlrpc_message->current_tag_contents;
+ $value_flag = TRUE;
+ break;
+
+ case 'dateTime.iso8601':
+ $value = xmlrpc_date(trim($xmlrpc_message->current_tag_contents));
+ // $value = $iso->getTimestamp();
+ $value_flag = TRUE;
+ break;
+
+ case 'value':
+ // If no type is indicated, the type is string
+ // We take special care for empty values
+ if (trim($xmlrpc_message->current_tag_contents) != '' || (isset($xmlrpc_message->last_open) && ($xmlrpc_message->last_open == 'value'))) {
+ $value = (string) $xmlrpc_message->current_tag_contents;
+ $value_flag = TRUE;
+ }
+ unset($xmlrpc_message->last_open);
+ break;
+
+ case 'boolean':
+ $value = (boolean)trim($xmlrpc_message->current_tag_contents);
+ $value_flag = TRUE;
+ break;
+
+ case 'base64':
+ $value = base64_decode(trim($xmlrpc_message->current_tag_contents));
+ $value_flag = TRUE;
+ break;
+
+ // Deal with stacks of arrays and structs
+ case 'data':
+ case 'struct':
+ $value = array_pop($xmlrpc_message->array_structs);
+ array_pop($xmlrpc_message->array_structs_types);
+ $value_flag = TRUE;
+ break;
+
+ case 'member':
+ array_pop($xmlrpc_message->current_struct_name);
+ break;
+
+ case 'name':
+ $xmlrpc_message->current_struct_name[] = trim($xmlrpc_message->current_tag_contents);
+ break;
+
+ case 'methodName':
+ $xmlrpc_message->methodname = trim($xmlrpc_message->current_tag_contents);
+ break;
+ }
+ if ($value_flag) {
+ if (count($xmlrpc_message->array_structs) > 0) {
+ // Add value to struct or array
+ if ($xmlrpc_message->array_structs_types[count($xmlrpc_message->array_structs_types) - 1] == 'struct') {
+ // Add to struct
+ $xmlrpc_message->array_structs[count($xmlrpc_message->array_structs) - 1][$xmlrpc_message->current_struct_name[count($xmlrpc_message->current_struct_name) - 1]] = $value;
+ }
+ else {
+ // Add to array
+ $xmlrpc_message->array_structs[count($xmlrpc_message->array_structs) - 1][] = $value;
+ }
+ }
+ else {
+ // Just add as a parameter
+ $xmlrpc_message->params[] = $value;
+ }
+ }
+ if (!in_array($tag, array("data", "struct", "member"))) {
+ $xmlrpc_message->current_tag_contents = '';
+ }
+ xmlrpc_message_set($xmlrpc_message);
+}
+
+/**
+ * Constructs an object representing an XML-RPC request.
+ *
+ * @param $method
+ * The name of the method to be called.
+ * @param $args
+ * An array of parameters to send with the method.
+ *
+ * @return object
+ * An XML-RPC object representing the request.
+ */
+function xmlrpc_request($method, $args) {
+ $xmlrpc_request = new stdClass();
+ $xmlrpc_request->method = $method;
+ $xmlrpc_request->args = $args;
+ $xmlrpc_request->xml = <<<EOD
+<?xml version="1.0"?>
+<methodCall>
+<methodName>{$xmlrpc_request->method}</methodName>
+<params>
+
+EOD;
+ foreach ($xmlrpc_request->args as $arg) {
+ $xmlrpc_request->xml .= '<param><value>';
+ $v = xmlrpc_value($arg);
+ $xmlrpc_request->xml .= xmlrpc_value_get_xml($v);
+ $xmlrpc_request->xml .= "</value></param>\n";
+ }
+ $xmlrpc_request->xml .= '</params></methodCall>';
+ return $xmlrpc_request;
+}
+
+/**
+ * Generates, temporarily saves, and returns an XML-RPC error object.
+ *
+ * @param $code
+ * The error code.
+ * @param $message
+ * The error message.
+ * @param $reset
+ * TRUE to empty the temporary error storage. Ignored if $code is supplied.
+ *
+ * @return object
+ * An XML-RPC error object representing $code and $message, or the most
+ * recently stored error object if omitted.
+ */
+function xmlrpc_error($code = NULL, $message = NULL, $reset = FALSE) {
+ static $xmlrpc_error;
+ if (isset($code)) {
+ $xmlrpc_error = new stdClass();
+ $xmlrpc_error->is_error = TRUE;
+ $xmlrpc_error->code = $code;
+ $xmlrpc_error->message = $message;
+ }
+ elseif ($reset) {
+ $xmlrpc_error = NULL;
+ }
+ return $xmlrpc_error;
+}
+
+/**
+ * Converts an XML-RPC error object into XML.
+ *
+ * @param $xmlrpc_error
+ * The XML-RPC error object.
+ *
+ * @return string
+ * An XML representation of the error as an XML methodResponse.
+ */
+function xmlrpc_error_get_xml($xmlrpc_error) {
+ return <<<EOD
+<methodResponse>
+ <fault>
+ <value>
+ <struct>
+ <member>
+ <name>faultCode</name>
+ <value><int>{$xmlrpc_error->code}</int></value>
+ </member>
+ <member>
+ <name>faultString</name>
+ <value><string>{$xmlrpc_error->message}</string></value>
+ </member>
+ </struct>
+ </value>
+ </fault>
+</methodResponse>
+
+EOD;
+}
+
+/**
+ * Converts a PHP or ISO date/time to an XML-RPC object.
+ *
+ * @param $time
+ * A PHP timestamp or an ISO date-time string.
+ *
+ * @return object
+ * An XML-RPC time/date object.
+ */
+function xmlrpc_date($time) {
+ $xmlrpc_date = new stdClass();
+ $xmlrpc_date->is_date = TRUE;
+ // $time can be a PHP timestamp or an ISO one
+ if (is_numeric($time)) {
+ $xmlrpc_date->year = gmdate('Y', $time);
+ $xmlrpc_date->month = gmdate('m', $time);
+ $xmlrpc_date->day = gmdate('d', $time);
+ $xmlrpc_date->hour = gmdate('H', $time);
+ $xmlrpc_date->minute = gmdate('i', $time);
+ $xmlrpc_date->second = gmdate('s', $time);
+ $xmlrpc_date->iso8601 = gmdate('Ymd\TH:i:s', $time);
+ }
+ else {
+ $xmlrpc_date->iso8601 = $time;
+ $time = str_replace(array('-', ':'), '', $time);
+ $xmlrpc_date->year = substr($time, 0, 4);
+ $xmlrpc_date->month = substr($time, 4, 2);
+ $xmlrpc_date->day = substr($time, 6, 2);
+ $xmlrpc_date->hour = substr($time, 9, 2);
+ $xmlrpc_date->minute = substr($time, 11, 2);
+ $xmlrpc_date->second = substr($time, 13, 2);
+ }
+ return $xmlrpc_date;
+}
+
+/**
+ * Converts an XML-RPC date-time object into XML.
+ *
+ * @param $xmlrpc_date
+ * The XML-RPC date-time object.
+ *
+ * @return string
+ * An XML representation of the date/time as XML.
+ */
+function xmlrpc_date_get_xml($xmlrpc_date) {
+ return '<dateTime.iso8601>' . $xmlrpc_date->year . $xmlrpc_date->month . $xmlrpc_date->day . 'T' . $xmlrpc_date->hour . ':' . $xmlrpc_date->minute . ':' . $xmlrpc_date->second . '</dateTime.iso8601>';
+}
+
+/**
+ * Returns an XML-RPC base 64 object.
+ *
+ * @param $data
+ * Base 64 data to store in returned object.
+ *
+ * @return object
+ * An XML-RPC base 64 object.
+ */
+function xmlrpc_base64($data) {
+ $xmlrpc_base64 = new stdClass();
+ $xmlrpc_base64->is_base64 = TRUE;
+ $xmlrpc_base64->data = $data;
+ return $xmlrpc_base64;
+}
+
+/**
+ * Converts an XML-RPC base 64 object into XML.
+ *
+ * @param $xmlrpc_base64
+ * The XML-RPC base 64 object.
+ *
+ * @return string
+ * An XML representation of the base 64 data as XML.
+ */
+function xmlrpc_base64_get_xml($xmlrpc_base64) {
+ return '<base64>' . base64_encode($xmlrpc_base64->data) . '</base64>';
+}
+
+/**
+ * Performs one or more XML-RPC requests.
+ *
+ * @param $url
+ * An absolute URL of the XML-RPC endpoint, e.g.,
+ * http://example.com/xmlrpc.php
+ * @param $args
+ * An associative array whose keys are the methods to call and whose values
+ * are the arguments to pass to the respective method. If multiple methods
+ * are specified, a system.multicall is performed.
+ * @param $options
+ * (optional) An array of options to pass along to drupal_http_request().
+ *
+ * @return
+ * A single response (single request) or an array of responses (multicall
+ * request). Each response is the return value of the method, just as if it
+ * has been a local function call, on success, or FALSE on failure. If FALSE
+ * is returned, see xmlrpc_errno() and xmlrpc_error_msg() to get more
+ * information.
+ */
+function _xmlrpc($url, $args, $options = array()) {
+ xmlrpc_clear_error();
+ if (count($args) > 1) {
+ $multicall_args = array();
+ foreach ($args as $method => $call) {
+ $multicall_args[] = array('methodName' => $method, 'params' => $call);
+ }
+ $method = 'system.multicall';
+ $args = array($multicall_args);
+ }
+ else {
+ $method = key($args);
+ $args = $args[$method];
+ }
+ $xmlrpc_request = xmlrpc_request($method, $args);
+ // Required options which will replace any that are passed in.
+ $options['method'] = 'POST';
+ $options['headers']['Content-Type'] = 'text/xml';
+ $options['data'] = $xmlrpc_request->xml;
+ $result = drupal_http_request($url, $options);
+ if ($result->code != 200) {
+ xmlrpc_error($result->code, $result->error);
+ return FALSE;
+ }
+ $message = xmlrpc_message($result->data);
+ // Now parse what we've got back
+ if (!xmlrpc_message_parse($message)) {
+ // XML error
+ xmlrpc_error(-32700, t('Parse error. Not well formed'));
+ return FALSE;
+ }
+ // Is the message a fault?
+ if ($message->messagetype == 'fault') {
+ xmlrpc_error($message->fault_code, $message->fault_string);
+ return FALSE;
+ }
+ // We now know that the message is well-formed and a non-fault result.
+ if ($method == 'system.multicall') {
+ // Return per-method results or error objects.
+ $return = array();
+ foreach ($message->params[0] as $result) {
+ if (array_keys($result) == array(0)) {
+ $return[] = $result[0];
+ }
+ else {
+ $return[] = xmlrpc_error($result['faultCode'], $result['faultString']);
+ }
+ }
+ }
+ else {
+ $return = $message->params[0];
+ }
+ return $return;
+}
+
+/**
+ * Returns the last XML-RPC client error number.
+ */
+function xmlrpc_errno() {
+ $error = xmlrpc_error();
+ return ($error != NULL ? $error->code : NULL);
+}
+
+/**
+ * Returns the last XML-RPC client error message.
+ */
+function xmlrpc_error_msg() {
+ $error = xmlrpc_error();
+ return ($error != NULL ? $error->message : NULL);
+}
+
+/**
+ * Clears any previously-saved errors.
+ *
+ * @see xmlrpc_error()
+ */
+function xmlrpc_clear_error() {
+ xmlrpc_error(NULL, NULL, TRUE);
+}
+
diff --git a/core/includes/xmlrpcs.inc b/core/includes/xmlrpcs.inc
new file mode 100644
index 000000000000..70c7cdac3b2e
--- /dev/null
+++ b/core/includes/xmlrpcs.inc
@@ -0,0 +1,385 @@
+<?php
+
+/**
+ * @file
+ * Provides API for defining and handling XML-RPC requests.
+ */
+
+/**
+ * Invokes XML-RPC methods on this server.
+ *
+ * @param array $callbacks
+ * Array of external XML-RPC method names with the callbacks they map to.
+ */
+function xmlrpc_server($callbacks) {
+ $xmlrpc_server = new stdClass();
+ // Define built-in XML-RPC method names
+ $defaults = array(
+ 'system.multicall' => 'xmlrpc_server_multicall',
+ array(
+ 'system.methodSignature',
+ 'xmlrpc_server_method_signature',
+ array('array', 'string'),
+ 'Returns an array describing the return type and required parameters of a method.',
+ ),
+ array(
+ 'system.getCapabilities',
+ 'xmlrpc_server_get_capabilities',
+ array('struct'),
+ 'Returns a struct describing the XML-RPC specifications supported by this server.',
+ ),
+ array(
+ 'system.listMethods',
+ 'xmlrpc_server_list_methods',
+ array('array'),
+ 'Returns an array of available methods on this server.',
+ ),
+ array(
+ 'system.methodHelp',
+ 'xmlrpc_server_method_help',
+ array('string', 'string'),
+ 'Returns a documentation string for the specified method.',
+ ),
+ );
+ // We build an array of all method names by combining the built-ins
+ // with those defined by modules implementing the _xmlrpc hook.
+ // Built-in methods are overridable.
+ $callbacks = array_merge($defaults, (array) $callbacks);
+ drupal_alter('xmlrpc', $callbacks);
+ foreach ($callbacks as $key => $callback) {
+ // we could check for is_array($callback)
+ if (is_int($key)) {
+ $method = $callback[0];
+ $xmlrpc_server->callbacks[$method] = $callback[1];
+ $xmlrpc_server->signatures[$method] = $callback[2];
+ $xmlrpc_server->help[$method] = $callback[3];
+ }
+ else {
+ $xmlrpc_server->callbacks[$key] = $callback;
+ $xmlrpc_server->signatures[$key] = '';
+ $xmlrpc_server->help[$key] = '';
+ }
+ }
+
+ $data = file_get_contents('php://input');
+ if (!$data) {
+ print 'XML-RPC server accepts POST requests only.';
+ drupal_exit();
+ }
+ $xmlrpc_server->message = xmlrpc_message($data);
+ if (!xmlrpc_message_parse($xmlrpc_server->message)) {
+ xmlrpc_server_error(-32700, t('Parse error. Request not well formed.'));
+ }
+ if ($xmlrpc_server->message->messagetype != 'methodCall') {
+ xmlrpc_server_error(-32600, t('Server error. Invalid XML-RPC. Request must be a methodCall.'));
+ }
+ if (!isset($xmlrpc_server->message->params)) {
+ $xmlrpc_server->message->params = array();
+ }
+ xmlrpc_server_set($xmlrpc_server);
+ $result = xmlrpc_server_call($xmlrpc_server, $xmlrpc_server->message->methodname, $xmlrpc_server->message->params);
+
+ if (is_object($result) && !empty($result->is_error)) {
+ xmlrpc_server_error($result);
+ }
+ // Encode the result
+ $r = xmlrpc_value($result);
+ // Create the XML
+ $xml = '
+<methodResponse>
+ <params>
+ <param>
+ <value>' . xmlrpc_value_get_xml($r) . '</value>
+ </param>
+ </params>
+</methodResponse>
+
+';
+ // Send it
+ xmlrpc_server_output($xml);
+}
+
+/**
+ * Throws an XML-RPC error.
+ *
+ * @param $error
+ * An error object or integer error code.
+ * @param $message
+ * (optional) The description of the error. Used only if an integer error
+ * code was passed in.
+ */
+function xmlrpc_server_error($error, $message = FALSE) {
+ if ($message && !is_object($error)) {
+ $error = xmlrpc_error($error, $message);
+ }
+ xmlrpc_server_output(xmlrpc_error_get_xml($error));
+}
+
+/**
+ * Sends XML-RPC output to the browser.
+ *
+ * @param string $xml
+ * XML to send to the browser.
+ */
+function xmlrpc_server_output($xml) {
+ $xml = '<?xml version="1.0"?>' . "\n" . $xml;
+ drupal_add_http_header('Content-Length', strlen($xml));
+ drupal_add_http_header('Content-Type', 'text/xml');
+ echo $xml;
+ drupal_exit();
+}
+
+/**
+ * Stores a copy of an XML-RPC request temporarily.
+ *
+ * @param object $xmlrpc_server
+ * (optional) Request object created by xmlrpc_server(). Omit to leave the
+ * previous server object saved.
+ *
+ * @return
+ * The latest stored request.
+ *
+ * @see xmlrpc_server_get()
+ */
+function xmlrpc_server_set($xmlrpc_server = NULL) {
+ static $server;
+ if (!isset($server)) {
+ $server = $xmlrpc_server;
+ }
+ return $server;
+}
+
+/**
+ * Retrieves the latest stored XML-RPC request.
+ *
+ * @return object
+ * The stored request.
+ *
+ * @see xmlrpc_server_set()
+ */
+function xmlrpc_server_get() {
+ return xmlrpc_server_set();
+}
+
+/**
+ * Dispatches an XML-RPC request and any parameters to the appropriate handler.
+ *
+ * @param object $xmlrpc_server
+ * Object containing information about this XML-RPC server, the methods it
+ * provides, their signatures, etc.
+ * @param string $methodname
+ * The external XML-RPC method name; e.g., 'system.methodHelp'.
+ * @param array $args
+ * Array containing any parameters that are to be sent along with the request.
+ *
+ * @return
+ * The results of the call.
+ */
+function xmlrpc_server_call($xmlrpc_server, $methodname, $args) {
+ // Make sure parameters are in an array
+ if ($args && !is_array($args)) {
+ $args = array($args);
+ }
+ // Has this method been mapped to a Drupal function by us or by modules?
+ if (!isset($xmlrpc_server->callbacks[$methodname])) {
+ return xmlrpc_error(-32601, t('Server error. Requested method @methodname not specified.', array("@methodname" => $xmlrpc_server->message->methodname)));
+ }
+ $method = $xmlrpc_server->callbacks[$methodname];
+ $signature = $xmlrpc_server->signatures[$methodname];
+
+ // If the method has a signature, validate the request against the signature
+ if (is_array($signature)) {
+ $ok = TRUE;
+ $return_type = array_shift($signature);
+ // Check the number of arguments
+ if (count($args) != count($signature)) {
+ return xmlrpc_error(-32602, t('Server error. Wrong number of method parameters.'));
+ }
+ // Check the argument types
+ foreach ($signature as $key => $type) {
+ $arg = $args[$key];
+ switch ($type) {
+ case 'int':
+ case 'i4':
+ if (is_array($arg) || !is_int($arg)) {
+ $ok = FALSE;
+ }
+ break;
+
+ case 'base64':
+ case 'string':
+ if (!is_string($arg)) {
+ $ok = FALSE;
+ }
+ break;
+
+ case 'boolean':
+ if ($arg !== FALSE && $arg !== TRUE) {
+ $ok = FALSE;
+ }
+ break;
+
+ case 'float':
+ case 'double':
+ if (!is_float($arg)) {
+ $ok = FALSE;
+ }
+ break;
+
+ case 'date':
+ case 'dateTime.iso8601':
+ if (!$arg->is_date) {
+ $ok = FALSE;
+ }
+ break;
+ }
+ if (!$ok) {
+ return xmlrpc_error(-32602, t('Server error. Invalid method parameters.'));
+ }
+ }
+ }
+
+ if (!function_exists($method)) {
+ return xmlrpc_error(-32601, t('Server error. Requested function @method does not exist.', array("@method" => $method)));
+ }
+ // Call the mapped function
+ return call_user_func_array($method, $args);
+}
+
+/**
+ * Dispatches multiple XML-RPC requests.
+ *
+ * @param array $methodcalls
+ * An array of XML-RPC requests to make. Each request is an array with the
+ * following elements:
+ * - methodName: Name of the method to invoke.
+ * - params: Parameters to pass to the method.
+ *
+ * @return
+ * An array of the results of each request.
+ *
+ * @see xmlrpc_server_call()
+ */
+function xmlrpc_server_multicall($methodcalls) {
+ // See http://www.xmlrpc.com/discuss/msgReader$1208
+ $return = array();
+ $xmlrpc_server = xmlrpc_server_get();
+ foreach ($methodcalls as $call) {
+ $ok = TRUE;
+ if (!isset($call['methodName']) || !isset($call['params'])) {
+ $result = xmlrpc_error(3, t('Invalid syntax for system.multicall.'));
+ $ok = FALSE;
+ }
+ $method = $call['methodName'];
+ $params = $call['params'];
+ if ($method == 'system.multicall') {
+ $result = xmlrpc_error(-32600, t('Recursive calls to system.multicall are forbidden.'));
+ }
+ elseif ($ok) {
+ $result = xmlrpc_server_call($xmlrpc_server, $method, $params);
+ }
+ if (is_object($result) && !empty($result->is_error)) {
+ $return[] = array(
+ 'faultCode' => $result->code,
+ 'faultString' => $result->message,
+ );
+ }
+ else {
+ $return[] = array($result);
+ }
+ }
+ return $return;
+}
+
+/**
+ * Lists the methods available on this XML-RPC server.
+ *
+ * XML-RPC method system.listMethods maps to this function.
+ *
+ * @return array
+ * Array of the names of methods available on this server.
+ */
+function xmlrpc_server_list_methods() {
+ $xmlrpc_server = xmlrpc_server_get();
+ return array_keys($xmlrpc_server->callbacks);
+}
+
+/**
+ * Returns a list of the capabilities of this server.
+ *
+ * XML-RPC method system.getCapabilities maps to this function.
+ *
+ * @return array
+ * Array of server capabilities.
+ *
+ * @see http://groups.yahoo.com/group/xml-rpc/message/2897
+ */
+function xmlrpc_server_get_capabilities() {
+ return array(
+ 'xmlrpc' => array(
+ 'specUrl' => 'http://www.xmlrpc.com/spec',
+ 'specVersion' => 1,
+ ),
+ 'faults_interop' => array(
+ 'specUrl' => 'http://xmlrpc-epi.sourceforge.net/specs/rfc.fault_codes.php',
+ 'specVersion' => 20010516,
+ ),
+ 'system.multicall' => array(
+ 'specUrl' => 'http://www.xmlrpc.com/discuss/msgReader$1208',
+ 'specVersion' => 1,
+ ),
+ 'introspection' => array(
+ 'specUrl' => 'http://scripts.incutio.com/xmlrpc/introspection.html',
+ 'specVersion' => 1,
+ ),
+ );
+}
+
+/**
+ * Returns one method signature for a function.
+ *
+ * This is the function mapped to the XML-RPC method system.methodSignature.
+ *
+ * A method signature is an array of the input and output types of a method. For
+ * instance, the method signature of this function is array('array', 'string'),
+ * because it takes an array and returns a string.
+ *
+ * @param string $methodname
+ * Name of method to return a method signature for.
+ *
+ * @return array
+ * An array of arrays of types, each of the arrays representing one method
+ * signature of the function that $methodname maps to.
+ */
+function xmlrpc_server_method_signature($methodname) {
+ $xmlrpc_server = xmlrpc_server_get();
+ if (!isset($xmlrpc_server->callbacks[$methodname])) {
+ return xmlrpc_error(-32601, t('Server error. Requested method @methodname not specified.', array("@methodname" => $methodname)));
+ }
+ if (!is_array($xmlrpc_server->signatures[$methodname])) {
+ return xmlrpc_error(-32601, t('Server error. Requested method @methodname signature not specified.', array("@methodname" => $methodname)));
+ }
+ // We array of types
+ $return = array();
+ foreach ($xmlrpc_server->signatures[$methodname] as $type) {
+ $return[] = $type;
+ }
+ return array($return);
+}
+
+/**
+ * Returns the help for an XML-RPC method.
+ *
+ * XML-RPC method system.methodHelp maps to this function.
+ *
+ * @param string $method
+ * Name of method for which we return a help string.
+ *
+ * @return string
+ * Help text for $method.
+ */
+function xmlrpc_server_method_help($method) {
+ $xmlrpc_server = xmlrpc_server_get();
+ return $xmlrpc_server->help[$method];
+}
+
diff --git a/core/install.php b/core/install.php
new file mode 100644
index 000000000000..01f54b762347
--- /dev/null
+++ b/core/install.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @file
+ * Initiates a browser-based installation of Drupal.
+ */
+
+// Change the directory to the Drupal root.
+chdir('..');
+
+/**
+ * Root directory of Drupal installation.
+ */
+define('DRUPAL_ROOT', getcwd());
+
+/**
+ * Global flag to indicate that site is in installation mode.
+ */
+define('MAINTENANCE_MODE', 'install');
+
+// Exit early if running an incompatible PHP version to avoid fatal errors.
+// The minimum version is specified explicitly, as DRUPAL_MINIMUM_PHP is not
+// yet available. It is defined in bootstrap.inc, but it is not possible to
+// load that file yet as it would cause a fatal error on older versions of PHP.
+if (version_compare(PHP_VERSION, '5.3.2') < 0) {
+ print 'Your PHP installation is too old. Drupal requires at least PHP 5.3.2. See the <a href="http://drupal.org/requirements">system requirements</a> page for more information.';
+ exit;
+}
+
+// Start the installer.
+require_once DRUPAL_ROOT . '/core/includes/install.core.inc';
+install_drupal();
diff --git a/core/misc/ajax.js b/core/misc/ajax.js
new file mode 100644
index 000000000000..92eefea952df
--- /dev/null
+++ b/core/misc/ajax.js
@@ -0,0 +1,622 @@
+(function ($) {
+
+/**
+ * Provides Ajax page updating via jQuery $.ajax (Asynchronous JavaScript and XML).
+ *
+ * Ajax is a method of making a request via JavaScript while viewing an HTML
+ * page. The request returns an array of commands encoded in JSON, which is
+ * then executed to make any changes that are necessary to the page.
+ *
+ * Drupal uses this file to enhance form elements with #ajax['path'] and
+ * #ajax['wrapper'] properties. If set, this file will automatically be included
+ * to provide Ajax capabilities.
+ */
+
+Drupal.ajax = Drupal.ajax || {};
+
+/**
+ * Attaches the Ajax behavior to each Ajax form element.
+ */
+Drupal.behaviors.AJAX = {
+ attach: function (context, settings) {
+ // Load all Ajax behaviors specified in the settings.
+ for (var base in settings.ajax) {
+ if (!$('#' + base + '.ajax-processed').length) {
+ var element_settings = settings.ajax[base];
+
+ if (typeof element_settings.selector == 'undefined') {
+ element_settings.selector = '#' + base;
+ }
+ $(element_settings.selector).each(function () {
+ element_settings.element = this;
+ Drupal.ajax[base] = new Drupal.ajax(base, this, element_settings);
+ });
+
+ $('#' + base).addClass('ajax-processed');
+ }
+ }
+
+ // Bind Ajax behaviors to all items showing the class.
+ $('.use-ajax:not(.ajax-processed)').addClass('ajax-processed').each(function () {
+ var element_settings = {};
+ // Clicked links look better with the throbber than the progress bar.
+ element_settings.progress = { 'type': 'throbber' };
+
+ // For anchor tags, these will go to the target of the anchor rather
+ // than the usual location.
+ if ($(this).attr('href')) {
+ element_settings.url = $(this).attr('href');
+ element_settings.event = 'click';
+ }
+ var base = $(this).attr('id');
+ Drupal.ajax[base] = new Drupal.ajax(base, this, element_settings);
+ });
+
+ // This class means to submit the form to the action using Ajax.
+ $('.use-ajax-submit:not(.ajax-processed)').addClass('ajax-processed').each(function () {
+ var element_settings = {};
+
+ // Ajax submits specified in this manner automatically submit to the
+ // normal form action.
+ element_settings.url = $(this.form).attr('action');
+ // Form submit button clicks need to tell the form what was clicked so
+ // it gets passed in the POST request.
+ element_settings.setClick = true;
+ // Form buttons use the 'click' event rather than mousedown.
+ element_settings.event = 'click';
+ // Clicked form buttons look better with the throbber than the progress bar.
+ element_settings.progress = { 'type': 'throbber' };
+
+ var base = $(this).attr('id');
+ Drupal.ajax[base] = new Drupal.ajax(base, this, element_settings);
+ });
+ }
+};
+
+/**
+ * Ajax object.
+ *
+ * All Ajax objects on a page are accessible through the global Drupal.ajax
+ * object and are keyed by the submit button's ID. You can access them from
+ * your module's JavaScript file to override properties or functions.
+ *
+ * For example, if your Ajax enabled button has the ID 'edit-submit', you can
+ * redefine the function that is called to insert the new content like this
+ * (inside a Drupal.behaviors attach block):
+ * @code
+ * Drupal.behaviors.myCustomAJAXStuff = {
+ * attach: function (context, settings) {
+ * Drupal.ajax['edit-submit'].commands.insert = function (ajax, response, status) {
+ * new_content = $(response.data);
+ * $('#my-wrapper').append(new_content);
+ * alert('New content was appended to #my-wrapper');
+ * }
+ * }
+ * };
+ * @endcode
+ */
+Drupal.ajax = function (base, element, element_settings) {
+ var defaults = {
+ url: 'system/ajax',
+ event: 'mousedown',
+ keypress: true,
+ selector: '#' + base,
+ effect: 'none',
+ speed: 'none',
+ method: 'replaceWith',
+ progress: {
+ type: 'throbber',
+ message: Drupal.t('Please wait...')
+ },
+ submit: {
+ 'js': true
+ }
+ };
+
+ $.extend(this, defaults, element_settings);
+
+ this.element = element;
+ this.element_settings = element_settings;
+
+ // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let
+ // the server detect when it needs to degrade gracefully.
+ // There are five scenarios to check for:
+ // 1. /nojs/
+ // 2. /nojs$ - The end of a URL string.
+ // 3. /nojs? - Followed by a query (with clean URLs enabled).
+ // E.g.: path/nojs?destination=foobar
+ // 4. /nojs& - Followed by a query (without clean URLs enabled).
+ // E.g.: ?q=path/nojs&destination=foobar
+ // 5. /nojs# - Followed by a fragment.
+ // E.g.: path/nojs#myfragment
+ this.url = element_settings.url.replace(/\/nojs(\/|$|\?|&|#)/g, '/ajax$1');
+ this.wrapper = '#' + element_settings.wrapper;
+
+ // If there isn't a form, jQuery.ajax() will be used instead, allowing us to
+ // bind Ajax to links as well.
+ if (this.element.form) {
+ this.form = $(this.element.form);
+ }
+
+ // Set the options for the ajaxSubmit function.
+ // The 'this' variable will not persist inside of the options object.
+ var ajax = this;
+ ajax.options = {
+ url: ajax.url,
+ data: ajax.submit,
+ beforeSerialize: function (element_settings, options) {
+ return ajax.beforeSerialize(element_settings, options);
+ },
+ beforeSubmit: function (form_values, element_settings, options) {
+ ajax.ajaxing = true;
+ return ajax.beforeSubmit(form_values, element_settings, options);
+ },
+ beforeSend: function (xmlhttprequest, options) {
+ ajax.ajaxing = true;
+ return ajax.beforeSend(xmlhttprequest, options);
+ },
+ success: function (response, status) {
+ // Sanity check for browser support (object expected).
+ // When using iFrame uploads, responses must be returned as a string.
+ if (typeof response == 'string') {
+ response = $.parseJSON(response);
+ }
+ return ajax.success(response, status);
+ },
+ complete: function (response, status) {
+ ajax.ajaxing = false;
+ if (status == 'error' || status == 'parsererror') {
+ return ajax.error(response, ajax.url);
+ }
+ },
+ dataType: 'json',
+ type: 'POST'
+ };
+
+ // Bind the ajaxSubmit function to the element event.
+ $(ajax.element).bind(element_settings.event, function (event) {
+ return ajax.eventResponse(this, event);
+ });
+
+ // If necessary, enable keyboard submission so that Ajax behaviors
+ // can be triggered through keyboard input as well as e.g. a mousedown
+ // action.
+ if (element_settings.keypress) {
+ $(ajax.element).keypress(function (event) {
+ return ajax.keypressResponse(this, event);
+ });
+ }
+
+ // If necessary, prevent the browser default action of an additional event.
+ // For example, prevent the browser default action of a click, even if the
+ // AJAX behavior binds to mousedown.
+ if (element_settings.prevent) {
+ $(ajax.element).bind(element_settings.prevent, false);
+ }
+};
+
+/**
+ * Handle a key press.
+ *
+ * The Ajax object will, if instructed, bind to a key press response. This
+ * will test to see if the key press is valid to trigger this event and
+ * if it is, trigger it for us and prevent other keypresses from triggering.
+ * In this case we're handling RETURN and SPACEBAR keypresses (event codes 13
+ * and 32. RETURN is often used to submit a form when in a textfield, and
+ * SPACE is often used to activate an element without submitting.
+ */
+Drupal.ajax.prototype.keypressResponse = function (element, event) {
+ // Create a synonym for this to reduce code confusion.
+ var ajax = this;
+
+ // Detect enter key and space bar and allow the standard response for them,
+ // except for form elements of type 'text' and 'textarea', where the
+ // spacebar activation causes inappropriate activation if #ajax['keypress'] is
+ // TRUE. On a text-type widget a space should always be a space.
+ if (event.which == 13 || (event.which == 32 && element.type != 'text' && element.type != 'textarea')) {
+ $(ajax.element_settings.element).trigger(ajax.element_settings.event);
+ return false;
+ }
+};
+
+/**
+ * Handle an event that triggers an Ajax response.
+ *
+ * When an event that triggers an Ajax response happens, this method will
+ * perform the actual Ajax call. It is bound to the event using
+ * bind() in the constructor, and it uses the options specified on the
+ * ajax object.
+ */
+Drupal.ajax.prototype.eventResponse = function (element, event) {
+ // Create a synonym for this to reduce code confusion.
+ var ajax = this;
+
+ // Do not perform another ajax command if one is already in progress.
+ if (ajax.ajaxing) {
+ return false;
+ }
+
+ try {
+ if (ajax.form) {
+ // If setClick is set, we must set this to ensure that the button's
+ // value is passed.
+ if (ajax.setClick) {
+ // Mark the clicked button. 'form.clk' is a special variable for
+ // ajaxSubmit that tells the system which element got clicked to
+ // trigger the submit. Without it there would be no 'op' or
+ // equivalent.
+ element.form.clk = element;
+ }
+
+ ajax.form.ajaxSubmit(ajax.options);
+ }
+ else {
+ ajax.beforeSerialize(ajax.element, ajax.options);
+ $.ajax(ajax.options);
+ }
+ }
+ catch (e) {
+ // Unset the ajax.ajaxing flag here because it won't be unset during
+ // the complete response.
+ ajax.ajaxing = false;
+ alert("An error occurred while attempting to process " + ajax.options.url + ": " + e.message);
+ }
+
+ // For radio/checkbox, allow the default event. On IE, this means letting
+ // it actually check the box.
+ if (typeof element.type != 'undefined' && (element.type == 'checkbox' || element.type == 'radio')) {
+ return true;
+ }
+ else {
+ return false;
+ }
+
+};
+
+/**
+ * Handler for the form serialization.
+ *
+ * Runs before the beforeSend() handler (see below), and unlike that one, runs
+ * before field data is collected.
+ */
+Drupal.ajax.prototype.beforeSerialize = function (element, options) {
+ // Allow detaching behaviors to update field values before collecting them.
+ // This is only needed when field values are added to the POST data, so only
+ // when there is a form such that this.form.ajaxSubmit() is used instead of
+ // $.ajax(). When there is no form and $.ajax() is used, beforeSerialize()
+ // isn't called, but don't rely on that: explicitly check this.form.
+ if (this.form) {
+ var settings = this.settings || Drupal.settings;
+ Drupal.detachBehaviors(this.form, settings, 'serialize');
+ }
+
+ // Prevent duplicate HTML ids in the returned markup.
+ // @see drupal_html_id()
+ options.data['ajax_html_ids[]'] = [];
+ $('[id]').each(function () {
+ options.data['ajax_html_ids[]'].push(this.id);
+ });
+
+ // Allow Drupal to return new JavaScript and CSS files to load without
+ // returning the ones already loaded.
+ // @see ajax_base_page_theme()
+ // @see drupal_get_css()
+ // @see drupal_get_js()
+ options.data['ajax_page_state[theme]'] = Drupal.settings.ajaxPageState.theme;
+ options.data['ajax_page_state[theme_token]'] = Drupal.settings.ajaxPageState.theme_token;
+ for (var key in Drupal.settings.ajaxPageState.css) {
+ options.data['ajax_page_state[css][' + key + ']'] = 1;
+ }
+ for (var key in Drupal.settings.ajaxPageState.js) {
+ options.data['ajax_page_state[js][' + key + ']'] = 1;
+ }
+};
+
+/**
+ * Modify form values prior to form submission.
+ */
+Drupal.ajax.prototype.beforeSubmit = function (form_values, element, options) {
+ // This function is left empty to make it simple to override for modules
+ // that wish to add functionality here.
+}
+
+/**
+ * Prepare the Ajax request before it is sent.
+ */
+Drupal.ajax.prototype.beforeSend = function (xmlhttprequest, options) {
+ // For forms without file inputs, the jQuery Form plugin serializes the form
+ // values, and then calls jQuery's $.ajax() function, which invokes this
+ // handler. In this circumstance, options.extraData is never used. For forms
+ // with file inputs, the jQuery Form plugin uses the browser's normal form
+ // submission mechanism, but captures the response in a hidden IFRAME. In this
+ // circumstance, it calls this handler first, and then appends hidden fields
+ // to the form to submit the values in options.extraData. There is no simple
+ // way to know which submission mechanism will be used, so we add to extraData
+ // regardless, and allow it to be ignored in the former case.
+ if (this.form) {
+ options.extraData = options.extraData || {};
+
+ // Let the server know when the IFRAME submission mechanism is used. The
+ // server can use this information to wrap the JSON response in a TEXTAREA,
+ // as per http://jquery.malsup.com/form/#file-upload.
+ options.extraData.ajax_iframe_upload = '1';
+
+ // The triggering element is about to be disabled (see below), but if it
+ // contains a value (e.g., a checkbox, textfield, select, etc.), ensure that
+ // value is included in the submission. As per above, submissions that use
+ // $.ajax() are already serialized prior to the element being disabled, so
+ // this is only needed for IFRAME submissions.
+ var v = $.fieldValue(this.element);
+ if (v !== null) {
+ options.extraData[this.element.name] = v;
+ }
+ }
+
+ // Disable the element that received the change to prevent user interface
+ // interaction while the Ajax request is in progress. ajax.ajaxing prevents
+ // the element from triggering a new request, but does not prevent the user
+ // from changing its value.
+ $(this.element).addClass('progress-disabled').attr('disabled', true);
+
+ // Insert progressbar or throbber.
+ if (this.progress.type == 'bar') {
+ var progressBar = new Drupal.progressBar('ajax-progress-' + this.element.id, eval(this.progress.update_callback), this.progress.method, eval(this.progress.error_callback));
+ if (this.progress.message) {
+ progressBar.setProgress(-1, this.progress.message);
+ }
+ if (this.progress.url) {
+ progressBar.startMonitoring(this.progress.url, this.progress.interval || 1500);
+ }
+ this.progress.element = $(progressBar.element).addClass('ajax-progress ajax-progress-bar');
+ this.progress.object = progressBar;
+ $(this.element).after(this.progress.element);
+ }
+ else if (this.progress.type == 'throbber') {
+ this.progress.element = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
+ if (this.progress.message) {
+ $('.throbber', this.progress.element).after('<div class="message">' + this.progress.message + '</div>');
+ }
+ $(this.element).after(this.progress.element);
+ }
+};
+
+/**
+ * Handler for the form redirection completion.
+ */
+Drupal.ajax.prototype.success = function (response, status) {
+ // Remove the progress element.
+ if (this.progress.element) {
+ $(this.progress.element).remove();
+ }
+ if (this.progress.object) {
+ this.progress.object.stopMonitoring();
+ }
+ $(this.element).removeClass('progress-disabled').removeAttr('disabled');
+
+ Drupal.freezeHeight();
+
+ for (var i in response) {
+ if (response[i]['command'] && this.commands[response[i]['command']]) {
+ this.commands[response[i]['command']](this, response[i], status);
+ }
+ }
+
+ // Reattach behaviors, if they were detached in beforeSerialize(). The
+ // attachBehaviors() called on the new content from processing the response
+ // commands is not sufficient, because behaviors from the entire form need
+ // to be reattached.
+ if (this.form) {
+ var settings = this.settings || Drupal.settings;
+ Drupal.attachBehaviors(this.form, settings);
+ }
+
+ Drupal.unfreezeHeight();
+
+ // Remove any response-specific settings so they don't get used on the next
+ // call by mistake.
+ this.settings = null;
+};
+
+/**
+ * Build an effect object which tells us how to apply the effect when adding new HTML.
+ */
+Drupal.ajax.prototype.getEffect = function (response) {
+ var type = response.effect || this.effect;
+ var speed = response.speed || this.speed;
+
+ var effect = {};
+ if (type == 'none') {
+ effect.showEffect = 'show';
+ effect.hideEffect = 'hide';
+ effect.showSpeed = '';
+ }
+ else if (type == 'fade') {
+ effect.showEffect = 'fadeIn';
+ effect.hideEffect = 'fadeOut';
+ effect.showSpeed = speed;
+ }
+ else {
+ effect.showEffect = type + 'Toggle';
+ effect.hideEffect = type + 'Toggle';
+ effect.showSpeed = speed;
+ }
+
+ return effect;
+};
+
+/**
+ * Handler for the form redirection error.
+ */
+Drupal.ajax.prototype.error = function (response, uri) {
+ alert(Drupal.ajaxError(response, uri));
+ // Remove the progress element.
+ if (this.progress.element) {
+ $(this.progress.element).remove();
+ }
+ if (this.progress.object) {
+ this.progress.object.stopMonitoring();
+ }
+ // Undo hide.
+ $(this.wrapper).show();
+ // Re-enable the element.
+ $(this.element).removeClass('progress-disabled').removeAttr('disabled');
+ // Reattach behaviors, if they were detached in beforeSerialize().
+ if (this.form) {
+ var settings = response.settings || this.settings || Drupal.settings;
+ Drupal.attachBehaviors(this.form, settings);
+ }
+};
+
+/**
+ * Provide a series of commands that the server can request the client perform.
+ */
+Drupal.ajax.prototype.commands = {
+ /**
+ * Command to insert new content into the DOM.
+ */
+ insert: function (ajax, response, status) {
+ // Get information from the response. If it is not there, default to
+ // our presets.
+ var wrapper = response.selector ? $(response.selector) : $(ajax.wrapper);
+ var method = response.method || ajax.method;
+ var effect = ajax.getEffect(response);
+
+ // We don't know what response.data contains: it might be a string of text
+ // without HTML, so don't rely on jQuery correctly iterpreting
+ // $(response.data) as new HTML rather than a CSS selector. Also, if
+ // response.data contains top-level text nodes, they get lost with either
+ // $(response.data) or $('<div></div>').replaceWith(response.data).
+ var new_content_wrapped = $('<div></div>').html(response.data);
+ var new_content = new_content_wrapped.contents();
+
+ // For legacy reasons, the effects processing code assumes that new_content
+ // consists of a single top-level element. Also, it has not been
+ // sufficiently tested whether attachBehaviors() can be successfully called
+ // with a context object that includes top-level text nodes. However, to
+ // give developers full control of the HTML appearing in the page, and to
+ // enable Ajax content to be inserted in places where DIV elements are not
+ // allowed (e.g., within TABLE, TR, and SPAN parents), we check if the new
+ // content satisfies the requirement of a single top-level element, and
+ // only use the container DIV created above when it doesn't. For more
+ // information, please see http://drupal.org/node/736066.
+ if (new_content.length != 1 || new_content.get(0).nodeType != 1) {
+ new_content = new_content_wrapped;
+ }
+
+ // If removing content from the wrapper, detach behaviors first.
+ switch (method) {
+ case 'html':
+ case 'replaceWith':
+ case 'replaceAll':
+ case 'empty':
+ case 'remove':
+ var settings = response.settings || ajax.settings || Drupal.settings;
+ Drupal.detachBehaviors(wrapper, settings);
+ }
+
+ // Add the new content to the page.
+ wrapper[method](new_content);
+
+ // Immediately hide the new content if we're using any effects.
+ if (effect.showEffect != 'show') {
+ new_content.hide();
+ }
+
+ // Determine which effect to use and what content will receive the
+ // effect, then show the new content.
+ if ($('.ajax-new-content', new_content).length > 0) {
+ $('.ajax-new-content', new_content).hide();
+ new_content.show();
+ $('.ajax-new-content', new_content)[effect.showEffect](effect.showSpeed);
+ }
+ else if (effect.showEffect != 'show') {
+ new_content[effect.showEffect](effect.showSpeed);
+ }
+
+ // Attach all JavaScript behaviors to the new content, if it was successfully
+ // added to the page, this if statement allows #ajax['wrapper'] to be
+ // optional.
+ if (new_content.parents('html').length > 0) {
+ // Apply any settings from the returned JSON if available.
+ var settings = response.settings || ajax.settings || Drupal.settings;
+ Drupal.attachBehaviors(new_content, settings);
+ }
+ },
+
+ /**
+ * Command to remove a chunk from the page.
+ */
+ remove: function (ajax, response, status) {
+ var settings = response.settings || ajax.settings || Drupal.settings;
+ Drupal.detachBehaviors($(response.selector), settings);
+ $(response.selector).remove();
+ },
+
+ /**
+ * Command to mark a chunk changed.
+ */
+ changed: function (ajax, response, status) {
+ if (!$(response.selector).hasClass('ajax-changed')) {
+ $(response.selector).addClass('ajax-changed');
+ if (response.asterisk) {
+ $(response.selector).find(response.asterisk).append(' <abbr class="ajax-changed" title="' + Drupal.t('Changed') + '">*</abbr> ');
+ }
+ }
+ },
+
+ /**
+ * Command to provide an alert.
+ */
+ alert: function (ajax, response, status) {
+ alert(response.text, response.title);
+ },
+
+ /**
+ * Command to provide the jQuery css() function.
+ */
+ css: function (ajax, response, status) {
+ $(response.selector).css(response.argument);
+ },
+
+ /**
+ * Command to set the settings that will be used for other commands in this response.
+ */
+ settings: function (ajax, response, status) {
+ if (response.merge) {
+ $.extend(true, Drupal.settings, response.settings);
+ }
+ else {
+ ajax.settings = response.settings;
+ }
+ },
+
+ /**
+ * Command to attach data using jQuery's data API.
+ */
+ data: function (ajax, response, status) {
+ $(response.selector).data(response.name, response.value);
+ },
+
+ /**
+ * Command to apply a jQuery method.
+ */
+ invoke: function (ajax, response, status) {
+ var $element = $(response.selector);
+ $element[response.method].apply($element, response.arguments);
+ },
+
+ /**
+ * Command to restripe a table.
+ */
+ restripe: function (ajax, response, status) {
+ // :even and :odd are reversed because jQuery counts from 0 and
+ // we count from 1, so we're out of sync.
+ // Match immediate children of the parent element to allow nesting.
+ $('> tbody > tr:visible, > tr:visible', $(response.selector))
+ .removeClass('odd even')
+ .filter(':even').addClass('odd').end()
+ .filter(':odd').addClass('even');
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/arrow-asc.png b/core/misc/arrow-asc.png
new file mode 100644
index 000000000000..2edbb17d7768
--- /dev/null
+++ b/core/misc/arrow-asc.png
Binary files differ
diff --git a/core/misc/arrow-desc.png b/core/misc/arrow-desc.png
new file mode 100644
index 000000000000..a3ccabc556ea
--- /dev/null
+++ b/core/misc/arrow-desc.png
Binary files differ
diff --git a/core/misc/authorize.js b/core/misc/authorize.js
new file mode 100644
index 000000000000..66b789791c80
--- /dev/null
+++ b/core/misc/authorize.js
@@ -0,0 +1,28 @@
+
+/**
+ * @file
+ * Conditionally hide or show the appropriate settings and saved defaults
+ * on the file transfer connection settings form used by authorize.php.
+ */
+
+(function ($) {
+
+Drupal.behaviors.authorizeFileTransferForm = {
+ attach: function(context) {
+ $('#edit-connection-settings-authorize-filetransfer-default').change(function() {
+ $('.filetransfer').hide().filter('.filetransfer-' + $(this).val()).show();
+ });
+ $('.filetransfer').hide().filter('.filetransfer-' + $('#edit-connection-settings-authorize-filetransfer-default').val()).show();
+
+ // Removes the float on the select box (used for non-JS interface).
+ if ($('.connection-settings-update-filetransfer-default-wrapper').length > 0) {
+ console.log($('.connection-settings-update-filetransfer-default-wrapper'));
+ $('.connection-settings-update-filetransfer-default-wrapper').css('float', 'none');
+ }
+ // Hides the submit button for non-js users.
+ $('#edit-submit-connection').hide();
+ $('#edit-submit-process').show();
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/autocomplete.js b/core/misc/autocomplete.js
new file mode 100644
index 000000000000..5e85be44fc5f
--- /dev/null
+++ b/core/misc/autocomplete.js
@@ -0,0 +1,321 @@
+(function ($) {
+
+/**
+ * Attaches the autocomplete behavior to all required fields.
+ */
+Drupal.behaviors.autocomplete = {
+ attach: function (context, settings) {
+ var acdb = [];
+ $('input.autocomplete', context).once('autocomplete', function () {
+ var uri = this.value;
+ if (!acdb[uri]) {
+ acdb[uri] = new Drupal.ACDB(uri);
+ }
+ var $input = $('#' + this.id.substr(0, this.id.length - 13))
+ .attr('autocomplete', 'OFF')
+ .attr('aria-autocomplete', 'list');
+ $($input[0].form).submit(Drupal.autocompleteSubmit);
+ $input.parent()
+ .attr('role', 'application')
+ .append($('<span class="element-invisible" aria-live="assertive"></span>')
+ .attr('id', $input.attr('id') + '-autocomplete-aria-live')
+ );
+ new Drupal.jsAC($input, acdb[uri]);
+ });
+ }
+};
+
+/**
+ * Prevents the form from submitting if the suggestions popup is open
+ * and closes the suggestions popup when doing so.
+ */
+Drupal.autocompleteSubmit = function () {
+ return $('#autocomplete').each(function () {
+ this.owner.hidePopup();
+ }).size() == 0;
+};
+
+/**
+ * An AutoComplete object.
+ */
+Drupal.jsAC = function ($input, db) {
+ var ac = this;
+ this.input = $input[0];
+ this.ariaLive = $('#' + $input.attr('id') + '-autocomplete-aria-live');
+ this.db = db;
+
+ $input
+ .keydown(function (event) { return ac.onkeydown(this, event); })
+ .keyup(function (event) { ac.onkeyup(this, event); })
+ .blur(function () { ac.hidePopup(); ac.db.cancel(); });
+
+};
+
+/**
+ * Handler for the "keydown" event.
+ */
+Drupal.jsAC.prototype.onkeydown = function (input, e) {
+ if (!e) {
+ e = window.event;
+ }
+ switch (e.keyCode) {
+ case 40: // down arrow.
+ this.selectDown();
+ return false;
+ case 38: // up arrow.
+ this.selectUp();
+ return false;
+ default: // All other keys.
+ return true;
+ }
+};
+
+/**
+ * Handler for the "keyup" event.
+ */
+Drupal.jsAC.prototype.onkeyup = function (input, e) {
+ if (!e) {
+ e = window.event;
+ }
+ switch (e.keyCode) {
+ case 16: // Shift.
+ case 17: // Ctrl.
+ case 18: // Alt.
+ case 20: // Caps lock.
+ case 33: // Page up.
+ case 34: // Page down.
+ case 35: // End.
+ case 36: // Home.
+ case 37: // Left arrow.
+ case 38: // Up arrow.
+ case 39: // Right arrow.
+ case 40: // Down arrow.
+ return true;
+
+ case 9: // Tab.
+ case 13: // Enter.
+ case 27: // Esc.
+ this.hidePopup(e.keyCode);
+ return true;
+
+ default: // All other keys.
+ if (input.value.length > 0)
+ this.populatePopup();
+ else
+ this.hidePopup(e.keyCode);
+ return true;
+ }
+};
+
+/**
+ * Puts the currently highlighted suggestion into the autocomplete field.
+ */
+Drupal.jsAC.prototype.select = function (node) {
+ this.input.value = $(node).data('autocompleteValue');
+};
+
+/**
+ * Highlights the next suggestion.
+ */
+Drupal.jsAC.prototype.selectDown = function () {
+ if (this.selected && this.selected.nextSibling) {
+ this.highlight(this.selected.nextSibling);
+ }
+ else if (this.popup) {
+ var lis = $('li', this.popup);
+ if (lis.size() > 0) {
+ this.highlight(lis.get(0));
+ }
+ }
+};
+
+/**
+ * Highlights the previous suggestion.
+ */
+Drupal.jsAC.prototype.selectUp = function () {
+ if (this.selected && this.selected.previousSibling) {
+ this.highlight(this.selected.previousSibling);
+ }
+};
+
+/**
+ * Highlights a suggestion.
+ */
+Drupal.jsAC.prototype.highlight = function (node) {
+ if (this.selected) {
+ $(this.selected).removeClass('selected');
+ }
+ $(node).addClass('selected');
+ this.selected = node;
+ $(this.ariaLive).html($(this.selected).html());
+};
+
+/**
+ * Unhighlights a suggestion.
+ */
+Drupal.jsAC.prototype.unhighlight = function (node) {
+ $(node).removeClass('selected');
+ this.selected = false;
+ $(this.ariaLive).empty();
+};
+
+/**
+ * Hides the autocomplete suggestions.
+ */
+Drupal.jsAC.prototype.hidePopup = function (keycode) {
+ // Select item if the right key or mousebutton was pressed.
+ if (this.selected && ((keycode && keycode != 46 && keycode != 8 && keycode != 27) || !keycode)) {
+ this.input.value = $(this.selected).data('autocompleteValue');
+ }
+ // Hide popup.
+ var popup = this.popup;
+ if (popup) {
+ this.popup = null;
+ $(popup).fadeOut('fast', function () { $(popup).remove(); });
+ }
+ this.selected = false;
+ $(this.ariaLive).empty();
+};
+
+/**
+ * Positions the suggestions popup and starts a search.
+ */
+Drupal.jsAC.prototype.populatePopup = function () {
+ var $input = $(this.input);
+ var position = $input.position();
+ // Show popup.
+ if (this.popup) {
+ $(this.popup).remove();
+ }
+ this.selected = false;
+ this.popup = $('<div id="autocomplete"></div>')[0];
+ this.popup.owner = this;
+ $(this.popup).css({
+ top: parseInt(position.top + this.input.offsetHeight, 10) + 'px',
+ left: parseInt(position.left, 10) + 'px',
+ width: $input.innerWidth() + 'px',
+ display: 'none'
+ });
+ $input.before(this.popup);
+
+ // Do search.
+ this.db.owner = this;
+ this.db.search(this.input.value);
+};
+
+/**
+ * Fills the suggestion popup with any matches received.
+ */
+Drupal.jsAC.prototype.found = function (matches) {
+ // If no value in the textfield, do not show the popup.
+ if (!this.input.value.length) {
+ return false;
+ }
+
+ // Prepare matches.
+ var ul = $('<ul></ul>');
+ var ac = this;
+ for (key in matches) {
+ $('<li></li>')
+ .html($('<div></div>').html(matches[key]))
+ .mousedown(function () { ac.select(this); })
+ .mouseover(function () { ac.highlight(this); })
+ .mouseout(function () { ac.unhighlight(this); })
+ .data('autocompleteValue', key)
+ .appendTo(ul);
+ }
+
+ // Show popup with matches, if any.
+ if (this.popup) {
+ if (ul.children().size()) {
+ $(this.popup).empty().append(ul).show();
+ $(this.ariaLive).html(Drupal.t('Autocomplete popup'));
+ }
+ else {
+ $(this.popup).css({ visibility: 'hidden' });
+ this.hidePopup();
+ }
+ }
+};
+
+Drupal.jsAC.prototype.setStatus = function (status) {
+ switch (status) {
+ case 'begin':
+ $(this.input).addClass('throbbing');
+ $(this.ariaLive).html(Drupal.t('Searching for matches...'));
+ break;
+ case 'cancel':
+ case 'error':
+ case 'found':
+ $(this.input).removeClass('throbbing');
+ break;
+ }
+};
+
+/**
+ * An AutoComplete DataBase object.
+ */
+Drupal.ACDB = function (uri) {
+ this.uri = uri;
+ this.delay = 300;
+ this.cache = {};
+};
+
+/**
+ * Performs a cached and delayed search.
+ */
+Drupal.ACDB.prototype.search = function (searchString) {
+ var db = this;
+ this.searchString = searchString;
+
+ // See if this string needs to be searched for anyway.
+ searchString = searchString.replace(/^\s+|\s+$/, '');
+ if (searchString.length <= 0 ||
+ searchString.charAt(searchString.length - 1) == ',') {
+ return;
+ }
+
+ // See if this key has been searched for before.
+ if (this.cache[searchString]) {
+ return this.owner.found(this.cache[searchString]);
+ }
+
+ // Initiate delayed search.
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+ this.timer = setTimeout(function () {
+ db.owner.setStatus('begin');
+
+ // Ajax GET request for autocompletion.
+ $.ajax({
+ type: 'GET',
+ url: db.uri + '/' + encodeURIComponent(searchString),
+ dataType: 'json',
+ success: function (matches) {
+ if (typeof matches.status == 'undefined' || matches.status != 0) {
+ db.cache[searchString] = matches;
+ // Verify if these are still the matches the user wants to see.
+ if (db.searchString == searchString) {
+ db.owner.found(matches);
+ }
+ db.owner.setStatus('found');
+ }
+ },
+ error: function (xmlhttp) {
+ alert(Drupal.ajaxError(xmlhttp, db.uri));
+ }
+ });
+ }, this.delay);
+};
+
+/**
+ * Cancels the current autocomplete request.
+ */
+Drupal.ACDB.prototype.cancel = function () {
+ if (this.owner) this.owner.setStatus('cancel');
+ if (this.timer) clearTimeout(this.timer);
+ this.searchString = '';
+};
+
+})(jQuery);
diff --git a/core/misc/batch.js b/core/misc/batch.js
new file mode 100644
index 000000000000..fee71a52fd13
--- /dev/null
+++ b/core/misc/batch.js
@@ -0,0 +1,32 @@
+(function ($) {
+
+/**
+ * Attaches the batch behavior to progress bars.
+ */
+Drupal.behaviors.batch = {
+ attach: function (context, settings) {
+ $('#progress', context).once('batch', function () {
+ var holder = $(this);
+
+ // Success: redirect to the summary.
+ var updateCallback = function (progress, status, pb) {
+ if (progress == 100) {
+ pb.stopMonitoring();
+ window.location = settings.batch.uri + '&op=finished';
+ }
+ };
+
+ var errorCallback = function (pb) {
+ holder.prepend($('<p class="error"></p>').html(settings.batch.errorMessage));
+ $('#wait').hide();
+ };
+
+ var progress = new Drupal.progressBar('updateprogress', updateCallback, 'POST', errorCallback);
+ progress.setProgress(-1, settings.batch.initMessage);
+ holder.append(progress.element);
+ progress.startMonitoring(settings.batch.uri + '&op=do', 10);
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/collapse.js b/core/misc/collapse.js
new file mode 100644
index 000000000000..1a98dc0e04cd
--- /dev/null
+++ b/core/misc/collapse.js
@@ -0,0 +1,103 @@
+(function ($) {
+
+/**
+ * Toggle the visibility of a fieldset using smooth animations.
+ */
+Drupal.toggleFieldset = function (fieldset) {
+ var $fieldset = $(fieldset);
+ if ($fieldset.is('.collapsed')) {
+ var $content = $('> .fieldset-wrapper', fieldset).hide();
+ $fieldset
+ .removeClass('collapsed')
+ .trigger({ type: 'collapsed', value: false })
+ .find('> legend span.fieldset-legend-prefix').html(Drupal.t('Hide'));
+ $content.slideDown({
+ duration: 'fast',
+ easing: 'linear',
+ complete: function () {
+ Drupal.collapseScrollIntoView(fieldset);
+ fieldset.animating = false;
+ },
+ step: function () {
+ // Scroll the fieldset into view.
+ Drupal.collapseScrollIntoView(fieldset);
+ }
+ });
+ }
+ else {
+ $fieldset.trigger({ type: 'collapsed', value: true });
+ $('> .fieldset-wrapper', fieldset).slideUp('fast', function () {
+ $fieldset
+ .addClass('collapsed')
+ .find('> legend span.fieldset-legend-prefix').html(Drupal.t('Show'));
+ fieldset.animating = false;
+ });
+ }
+};
+
+/**
+ * Scroll a given fieldset into view as much as possible.
+ */
+Drupal.collapseScrollIntoView = function (node) {
+ var h = document.documentElement.clientHeight || document.body.clientHeight || 0;
+ var offset = document.documentElement.scrollTop || document.body.scrollTop || 0;
+ var posY = $(node).offset().top;
+ var fudge = 55;
+ if (posY + node.offsetHeight + fudge > h + offset) {
+ if (node.offsetHeight > h) {
+ window.scrollTo(0, posY);
+ }
+ else {
+ window.scrollTo(0, posY + node.offsetHeight - h + fudge);
+ }
+ }
+};
+
+Drupal.behaviors.collapse = {
+ attach: function (context, settings) {
+ $('fieldset.collapsible', context).once('collapse', function () {
+ var $fieldset = $(this);
+ // Expand fieldset if there are errors inside, or if it contains an
+ // element that is targeted by the uri fragment identifier.
+ var anchor = location.hash && location.hash != '#' ? ', ' + location.hash : '';
+ if ($('.error' + anchor, $fieldset).length) {
+ $fieldset.removeClass('collapsed');
+ }
+
+ var summary = $('<span class="summary"></span>');
+ $fieldset.
+ bind('summaryUpdated', function () {
+ var text = $.trim($fieldset.drupalGetSummary());
+ summary.html(text ? ' (' + text + ')' : '');
+ })
+ .trigger('summaryUpdated');
+
+ // Turn the legend into a clickable link, but retain span.fieldset-legend
+ // for CSS positioning.
+ var $legend = $('> legend .fieldset-legend', this);
+
+ $('<span class="fieldset-legend-prefix element-invisible"></span>')
+ .append($fieldset.hasClass('collapsed') ? Drupal.t('Show') : Drupal.t('Hide'))
+ .prependTo($legend)
+ .after(' ');
+
+ // .wrapInner() does not retain bound events.
+ var $link = $('<a class="fieldset-title" href="#"></a>')
+ .prepend($legend.contents())
+ .appendTo($legend)
+ .click(function () {
+ var fieldset = $fieldset.get(0);
+ // Don't animate multiple times.
+ if (!fieldset.animating) {
+ fieldset.animating = true;
+ Drupal.toggleFieldset(fieldset);
+ }
+ return false;
+ });
+
+ $legend.append(summary);
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/configure.png b/core/misc/configure.png
new file mode 100644
index 000000000000..ff200dc1dc75
--- /dev/null
+++ b/core/misc/configure.png
Binary files differ
diff --git a/core/misc/draggable.png b/core/misc/draggable.png
new file mode 100644
index 000000000000..93d20d60d504
--- /dev/null
+++ b/core/misc/draggable.png
Binary files differ
diff --git a/core/misc/drupal.js b/core/misc/drupal.js
new file mode 100644
index 000000000000..7ae737c639bc
--- /dev/null
+++ b/core/misc/drupal.js
@@ -0,0 +1,412 @@
+
+var Drupal = Drupal || { 'settings': {}, 'behaviors': {}, 'locale': {} };
+
+// Allow other JavaScript libraries to use $.
+jQuery.noConflict();
+
+(function ($) {
+
+/**
+ * Attach all registered behaviors to a page element.
+ *
+ * Behaviors are event-triggered actions that attach to page elements, enhancing
+ * default non-JavaScript UIs. Behaviors are registered in the Drupal.behaviors
+ * object using the method 'attach' and optionally also 'detach' as follows:
+ * @code
+ * Drupal.behaviors.behaviorName = {
+ * attach: function (context, settings) {
+ * ...
+ * },
+ * detach: function (context, settings, trigger) {
+ * ...
+ * }
+ * };
+ * @endcode
+ *
+ * Drupal.attachBehaviors is added below to the jQuery ready event and so
+ * runs on initial page load. Developers implementing AHAH/Ajax in their
+ * solutions should also call this function after new page content has been
+ * loaded, feeding in an element to be processed, in order to attach all
+ * behaviors to the new content.
+ *
+ * Behaviors should use
+ * @code
+ * $(selector).once('behavior-name', function () {
+ * ...
+ * });
+ * @endcode
+ * to ensure the behavior is attached only once to a given element. (Doing so
+ * enables the reprocessing of given elements, which may be needed on occasion
+ * despite the ability to limit behavior attachment to a particular element.)
+ *
+ * @param context
+ * An element to attach behaviors to. If none is given, the document element
+ * is used.
+ * @param settings
+ * An object containing settings for the current context. If none given, the
+ * global Drupal.settings object is used.
+ */
+Drupal.attachBehaviors = function (context, settings) {
+ context = context || document;
+ settings = settings || Drupal.settings;
+ // Execute all of them.
+ $.each(Drupal.behaviors, function () {
+ if ($.isFunction(this.attach)) {
+ this.attach(context, settings);
+ }
+ });
+};
+
+/**
+ * Detach registered behaviors from a page element.
+ *
+ * Developers implementing AHAH/Ajax in their solutions should call this
+ * function before page content is about to be removed, feeding in an element
+ * to be processed, in order to allow special behaviors to detach from the
+ * content.
+ *
+ * Such implementations should look for the class name that was added in their
+ * corresponding Drupal.behaviors.behaviorName.attach implementation, i.e.
+ * behaviorName-processed, to ensure the behavior is detached only from
+ * previously processed elements.
+ *
+ * @param context
+ * An element to detach behaviors from. If none is given, the document element
+ * is used.
+ * @param settings
+ * An object containing settings for the current context. If none given, the
+ * global Drupal.settings object is used.
+ * @param trigger
+ * A string containing what's causing the behaviors to be detached. The
+ * possible triggers are:
+ * - unload: (default) The context element is being removed from the DOM.
+ * - move: The element is about to be moved within the DOM (for example,
+ * during a tabledrag row swap). After the move is completed,
+ * Drupal.attachBehaviors() is called, so that the behavior can undo
+ * whatever it did in response to the move. Many behaviors won't need to
+ * do anything simply in response to the element being moved, but because
+ * IFRAME elements reload their "src" when being moved within the DOM,
+ * behaviors bound to IFRAME elements (like WYSIWYG editors) may need to
+ * take some action.
+ * - serialize: When an Ajax form is submitted, this is called with the
+ * form as the context. This provides every behavior within the form an
+ * opportunity to ensure that the field elements have correct content
+ * in them before the form is serialized. The canonical use-case is so
+ * that WYSIWYG editors can update the hidden textarea to which they are
+ * bound.
+ *
+ * @see Drupal.attachBehaviors
+ */
+Drupal.detachBehaviors = function (context, settings, trigger) {
+ context = context || document;
+ settings = settings || Drupal.settings;
+ trigger = trigger || 'unload';
+ // Execute all of them.
+ $.each(Drupal.behaviors, function () {
+ if ($.isFunction(this.detach)) {
+ this.detach(context, settings, trigger);
+ }
+ });
+};
+
+/**
+ * Encode special characters in a plain-text string for display as HTML.
+ *
+ * @ingroup sanitization
+ */
+Drupal.checkPlain = function (str) {
+ var character, regex,
+ replace = { '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' };
+ str = String(str);
+ for (character in replace) {
+ if (replace.hasOwnProperty(character)) {
+ regex = new RegExp(character, 'g');
+ str = str.replace(regex, replace[character]);
+ }
+ }
+ return str;
+};
+
+/**
+ * Replace placeholders with sanitized values in a string.
+ *
+ * @param str
+ * A string with placeholders.
+ * @param args
+ * An object of replacements pairs to make. Incidences of any key in this
+ * array are replaced with the corresponding value. Based on the first
+ * character of the key, the value is escaped and/or themed:
+ * - !variable: inserted as is
+ * - @variable: escape plain text to HTML (Drupal.checkPlain)
+ * - %variable: escape text and theme as a placeholder for user-submitted
+ * content (checkPlain + Drupal.theme('placeholder'))
+ *
+ * @see Drupal.t()
+ * @ingroup sanitization
+ */
+Drupal.formatString = function(str, args) {
+ // Transform arguments before inserting them.
+ for (var key in args) {
+ switch (key.charAt(0)) {
+ // Escaped only.
+ case '@':
+ args[key] = Drupal.checkPlain(args[key]);
+ break;
+ // Pass-through.
+ case '!':
+ break;
+ // Escaped and placeholder.
+ case '%':
+ default:
+ args[key] = Drupal.theme('placeholder', args[key]);
+ break;
+ }
+ str = str.replace(key, args[key]);
+ }
+ return str;
+}
+
+/**
+ * Translate strings to the page language or a given language.
+ *
+ * See the documentation of the server-side t() function for further details.
+ *
+ * @param str
+ * A string containing the English string to translate.
+ * @param args
+ * An object of replacements pairs to make after translation. Incidences
+ * of any key in this array are replaced with the corresponding value.
+ * See Drupal.formatString().
+ *
+ * @param options
+ * - 'context' (defaults to the empty context): The context the source string
+ * belongs to.
+ *
+ * @return
+ * The translated string.
+ */
+Drupal.t = function (str, args, options) {
+ options = options || {};
+ options.context = options.context || '';
+
+ // Fetch the localized version of the string.
+ if (Drupal.locale.strings && Drupal.locale.strings[options.context] && Drupal.locale.strings[options.context][str]) {
+ str = Drupal.locale.strings[options.context][str];
+ }
+
+ if (args) {
+ str = Drupal.formatString(str, args);
+ }
+ return str;
+};
+
+/**
+ * Format a string containing a count of items.
+ *
+ * This function ensures that the string is pluralized correctly. Since Drupal.t() is
+ * called by this function, make sure not to pass already-localized strings to it.
+ *
+ * See the documentation of the server-side format_plural() function for further details.
+ *
+ * @param count
+ * The item count to display.
+ * @param singular
+ * The string for the singular case. Please make sure it is clear this is
+ * singular, to ease translation (e.g. use "1 new comment" instead of "1 new").
+ * Do not use @count in the singular string.
+ * @param plural
+ * The string for the plural case. Please make sure it is clear this is plural,
+ * to ease translation. Use @count in place of the item count, as in "@count
+ * new comments".
+ * @param args
+ * An object of replacements pairs to make after translation. Incidences
+ * of any key in this array are replaced with the corresponding value.
+ * See Drupal.formatString().
+ * Note that you do not need to include @count in this array.
+ * This replacement is done automatically for the plural case.
+ * @param options
+ * The options to pass to the Drupal.t() function.
+ * @return
+ * A translated string.
+ */
+Drupal.formatPlural = function (count, singular, plural, args, options) {
+ var args = args || {};
+ args['@count'] = count;
+ // Determine the index of the plural form.
+ var index = Drupal.locale.pluralFormula ? Drupal.locale.pluralFormula(args['@count']) : ((args['@count'] == 1) ? 0 : 1);
+
+ if (index == 0) {
+ return Drupal.t(singular, args, options);
+ }
+ else if (index == 1) {
+ return Drupal.t(plural, args, options);
+ }
+ else {
+ args['@count[' + index + ']'] = args['@count'];
+ delete args['@count'];
+ return Drupal.t(plural.replace('@count', '@count[' + index + ']'), args, options);
+ }
+};
+
+/**
+ * Generate the themed representation of a Drupal object.
+ *
+ * All requests for themed output must go through this function. It examines
+ * the request and routes it to the appropriate theme function. If the current
+ * theme does not provide an override function, the generic theme function is
+ * called.
+ *
+ * For example, to retrieve the HTML for text that should be emphasized and
+ * displayed as a placeholder inside a sentence, call
+ * Drupal.theme('placeholder', text).
+ *
+ * @param func
+ * The name of the theme function to call.
+ * @param ...
+ * Additional arguments to pass along to the theme function.
+ * @return
+ * Any data the theme function returns. This could be a plain HTML string,
+ * but also a complex object.
+ */
+Drupal.theme = function (func) {
+ var args = Array.prototype.slice.apply(arguments, [1]);
+
+ return (Drupal.theme[func] || Drupal.theme.prototype[func]).apply(this, args);
+};
+
+/**
+ * Freeze the current body height (as minimum height). Used to prevent
+ * unnecessary upwards scrolling when doing DOM manipulations.
+ */
+Drupal.freezeHeight = function () {
+ Drupal.unfreezeHeight();
+ $('<div id="freeze-height"></div>').css({
+ position: 'absolute',
+ top: '0px',
+ left: '0px',
+ width: '1px',
+ height: $('body').css('height')
+ }).appendTo('body');
+};
+
+/**
+ * Unfreeze the body height.
+ */
+Drupal.unfreezeHeight = function () {
+ $('#freeze-height').remove();
+};
+
+/**
+ * Encodes a Drupal path for use in a URL.
+ *
+ * For aesthetic reasons slashes are not escaped.
+ */
+Drupal.encodePath = function (item, uri) {
+ uri = uri || location.href;
+ return encodeURIComponent(item).replace(/%2F/g, '/');
+};
+
+/**
+ * Get the text selection in a textarea.
+ */
+Drupal.getSelection = function (element) {
+ if (typeof element.selectionStart != 'number' && document.selection) {
+ // The current selection.
+ var range1 = document.selection.createRange();
+ var range2 = range1.duplicate();
+ // Select all text.
+ range2.moveToElementText(element);
+ // Now move 'dummy' end point to end point of original range.
+ range2.setEndPoint('EndToEnd', range1);
+ // Now we can calculate start and end points.
+ var start = range2.text.length - range1.text.length;
+ var end = start + range1.text.length;
+ return { 'start': start, 'end': end };
+ }
+ return { 'start': element.selectionStart, 'end': element.selectionEnd };
+};
+
+/**
+ * Build an error message from an Ajax response.
+ */
+Drupal.ajaxError = function (xmlhttp, uri) {
+ var statusCode, statusText, pathText, responseText, readyStateText, message;
+ if (xmlhttp.status) {
+ statusCode = "\n" + Drupal.t("An AJAX HTTP error occurred.") + "\n" + Drupal.t("HTTP Result Code: !status", {'!status': xmlhttp.status});
+ }
+ else {
+ statusCode = "\n" + Drupal.t("An AJAX HTTP request terminated abnormally.");
+ }
+ statusCode += "\n" + Drupal.t("Debugging information follows.");
+ pathText = "\n" + Drupal.t("Path: !uri", {'!uri': uri} );
+ statusText = '';
+ // In some cases, when statusCode == 0, xmlhttp.statusText may not be defined.
+ // Unfortunately, testing for it with typeof, etc, doesn't seem to catch that
+ // and the test causes an exception. So we need to catch the exception here.
+ try {
+ statusText = "\n" + Drupal.t("StatusText: !statusText", {'!statusText': $.trim(xmlhttp.statusText)});
+ }
+ catch (e) {}
+
+ responseText = '';
+ // Again, we don't have a way to know for sure whether accessing
+ // xmlhttp.responseText is going to throw an exception. So we'll catch it.
+ try {
+ responseText = "\n" + Drupal.t("ResponseText: !responseText", {'!responseText': $.trim(xmlhttp.responseText) } );
+ } catch (e) {}
+
+ // Make the responseText more readable by stripping HTML tags and newlines.
+ responseText = responseText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/gi,"");
+ responseText = responseText.replace(/[\n]+\s+/g,"\n");
+
+ // We don't need readyState except for status == 0.
+ readyStateText = xmlhttp.status == 0 ? ("\n" + Drupal.t("ReadyState: !readyState", {'!readyState': xmlhttp.readyState})) : "";
+
+ message = statusCode + pathText + statusText + responseText + readyStateText;
+ return message;
+};
+
+// Class indicating that JS is enabled; used for styling purpose.
+$('html').addClass('js');
+
+// 'js enabled' cookie.
+document.cookie = 'has_js=1; path=/';
+
+/**
+ * Additions to jQuery.support.
+ */
+$(function () {
+ /**
+ * Boolean indicating whether or not position:fixed is supported.
+ */
+ if (jQuery.support.positionFixed === undefined) {
+ var el = $('<div style="position:fixed; top:10px" />').appendTo(document.body);
+ jQuery.support.positionFixed = el[0].offsetTop === 10;
+ el.remove();
+ }
+});
+
+//Attach all behaviors.
+$(function () {
+ Drupal.attachBehaviors(document, Drupal.settings);
+});
+
+/**
+ * The default themes.
+ */
+Drupal.theme.prototype = {
+
+ /**
+ * Formats text for emphasized display in a placeholder inside a sentence.
+ *
+ * @param str
+ * The text to format (plain-text).
+ * @return
+ * The formatted text (html).
+ */
+ placeholder: function (str) {
+ return '<em class="placeholder">' + Drupal.checkPlain(str) + '</em>';
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/druplicon.png b/core/misc/druplicon.png
new file mode 100644
index 000000000000..3b49a4ce78dc
--- /dev/null
+++ b/core/misc/druplicon.png
Binary files differ
diff --git a/core/misc/farbtastic/farbtastic.css b/core/misc/farbtastic/farbtastic.css
new file mode 100644
index 000000000000..25a68ebf7959
--- /dev/null
+++ b/core/misc/farbtastic/farbtastic.css
@@ -0,0 +1,36 @@
+
+.farbtastic {
+ position: relative;
+}
+.farbtastic * {
+ position: absolute;
+ cursor: crosshair;
+}
+.farbtastic,
+.farbtastic .wheel {
+ width: 195px;
+ height: 195px;
+}
+.farbtastic .color,
+.farbtastic .overlay {
+ top: 47px;
+ left: 47px;
+ width: 101px;
+ height: 101px;
+}
+.farbtastic .wheel {
+ background: url(wheel.png) no-repeat;
+ width: 195px;
+ height: 195px;
+}
+.farbtastic .overlay {
+ background: url(mask.png) no-repeat;
+}
+.farbtastic .marker {
+ width: 17px;
+ height: 17px;
+ margin: -8px 0 0 -8px;
+ overflow: hidden;
+ background: url(marker.png) no-repeat;
+}
+
diff --git a/core/misc/farbtastic/farbtastic.js b/core/misc/farbtastic/farbtastic.js
new file mode 100644
index 000000000000..10c9e7635d14
--- /dev/null
+++ b/core/misc/farbtastic/farbtastic.js
@@ -0,0 +1,8 @@
+(function(e){e.fn.farbtastic=function(f){e.farbtastic(this,f);return this};e.farbtastic=function(f,l){f=e(f).get(0);return f.farbtastic||(f.farbtastic=new e._farbtastic(f,l))};e._farbtastic=function(f,l){var a=this;e(f).html('<div class="farbtastic"><div class="color"></div><div class="wheel"></div><div class="overlay"></div><div class="h-marker marker"></div><div class="sl-marker marker"></div></div>');var k=e(".farbtastic",f);a.wheel=e(".wheel",f).get(0);a.radius=84;a.square=100;a.width=194;navigator.appVersion.match(/MSIE [0-6]\./)&&
+e("*",k).each(function(){if(this.currentStyle.backgroundImage!="none"){var b=this.currentStyle.backgroundImage;b=this.currentStyle.backgroundImage.substring(5,b.length-2);e(this).css({backgroundImage:"none",filter:"progid:DXImageTransform.Microsoft.AlphaImageLoader(enabled=true, sizingMethod=crop, src='"+b+"')"})}});a.linkTo=function(b){typeof a.callback=="object"&&e(a.callback).unbind("keyup",a.updateValue);a.color=null;if(typeof b=="function")a.callback=b;else if(typeof b=="object"||typeof b=="string"){a.callback=
+e(b);a.callback.bind("keyup",a.updateValue);a.callback.get(0).value&&a.setColor(a.callback.get(0).value)}return this};a.updateValue=function(){this.value&&this.value!=a.color&&a.setColor(this.value)};a.setColor=function(b){var c=a.unpack(b);if(a.color!=b&&c){a.color=b;a.rgb=c;a.hsl=a.RGBToHSL(a.rgb);a.updateDisplay()}return this};a.setHSL=function(b){a.hsl=b;a.rgb=a.HSLToRGB(b);a.color=a.pack(a.rgb);a.updateDisplay();return this};a.widgetCoords=function(b){var c=e(a.wheel).offset();return{x:b.pageX-
+c.left-a.width/2,y:b.pageY-c.top-a.width/2}};a.mousedown=function(b){if(!document.dragging){e(document).bind("mousemove",a.mousemove).bind("mouseup",a.mouseup);document.dragging=true}var c=a.widgetCoords(b);a.circleDrag=Math.max(Math.abs(c.x),Math.abs(c.y))*2>a.square;a.mousemove(b);return false};a.mousemove=function(b){var c=a.widgetCoords(b);if(a.circleDrag){b=Math.atan2(c.x,-c.y)/6.28;if(b<0)b+=1;a.setHSL([b,a.hsl[1],a.hsl[2]])}else{b=Math.max(0,Math.min(1,-(c.x/a.square)+0.5));c=Math.max(0,Math.min(1,
+-(c.y/a.square)+0.5));a.setHSL([a.hsl[0],b,c])}return false};a.mouseup=function(){e(document).unbind("mousemove",a.mousemove);e(document).unbind("mouseup",a.mouseup);document.dragging=false};a.updateDisplay=function(){var b=a.hsl[0]*6.28;e(".h-marker",k).css({left:Math.round(Math.sin(b)*a.radius+a.width/2)+"px",top:Math.round(-Math.cos(b)*a.radius+a.width/2)+"px"});e(".sl-marker",k).css({left:Math.round(a.square*(0.5-a.hsl[1])+a.width/2)+"px",top:Math.round(a.square*(0.5-a.hsl[2])+a.width/2)+"px"});
+e(".color",k).css("backgroundColor",a.pack(a.HSLToRGB([a.hsl[0],1,0.5])));if(typeof a.callback=="object"){e(a.callback).css({backgroundColor:a.color,color:a.hsl[2]>0.5?"#000":"#fff"});e(a.callback).each(function(){if(this.value&&this.value!=a.color)this.value=a.color})}else typeof a.callback=="function"&&a.callback.call(a,a.color)};a.pack=function(b){var c=Math.round(b[0]*255),d=Math.round(b[1]*255);b=Math.round(b[2]*255);return"#"+(c<16?"0":"")+c.toString(16)+(d<16?"0":"")+d.toString(16)+(b<16?"0":
+"")+b.toString(16)};a.unpack=function(b){if(b.length==7)return[parseInt("0x"+b.substring(1,3))/255,parseInt("0x"+b.substring(3,5))/255,parseInt("0x"+b.substring(5,7))/255];else if(b.length==4)return[parseInt("0x"+b.substring(1,2))/15,parseInt("0x"+b.substring(2,3))/15,parseInt("0x"+b.substring(3,4))/15]};a.HSLToRGB=function(b){var c,d=b[0];c=b[1];b=b[2];c=b<=0.5?b*(c+1):b+c-b*c;b=b*2-c;return[this.hueToRGB(b,c,d+0.33333),this.hueToRGB(b,c,d),this.hueToRGB(b,c,d-0.33333)]};a.hueToRGB=function(b,c,
+d){d=d<0?d+1:d>1?d-1:d;if(d*6<1)return b+(c-b)*d*6;if(d*2<1)return c;if(d*3<2)return b+(c-b)*(0.66666-d)*6;return b};a.RGBToHSL=function(b){var c,d,m,g,h=b[0],i=b[1],j=b[2];c=Math.min(h,Math.min(i,j));b=Math.max(h,Math.max(i,j));d=b-c;g=(c+b)/2;m=0;if(g>0&&g<1)m=d/(g<0.5?2*g:2-2*g);c=0;if(d>0){if(b==h&&b!=i)c+=(i-j)/d;if(b==i&&b!=j)c+=2+(j-h)/d;if(b==j&&b!=h)c+=4+(h-i)/d;c/=6}return[c,m,g]};e("*",k).mousedown(a.mousedown);a.setColor("#000000");l&&a.linkTo(l)}})(jQuery);
diff --git a/core/misc/farbtastic/marker.png b/core/misc/farbtastic/marker.png
new file mode 100644
index 000000000000..f9773d14f6ba
--- /dev/null
+++ b/core/misc/farbtastic/marker.png
Binary files differ
diff --git a/core/misc/farbtastic/mask.png b/core/misc/farbtastic/mask.png
new file mode 100644
index 000000000000..0fc9cbe63094
--- /dev/null
+++ b/core/misc/farbtastic/mask.png
Binary files differ
diff --git a/core/misc/farbtastic/wheel.png b/core/misc/farbtastic/wheel.png
new file mode 100644
index 000000000000..4a905e9fb52a
--- /dev/null
+++ b/core/misc/farbtastic/wheel.png
Binary files differ
diff --git a/core/misc/favicon.ico b/core/misc/favicon.ico
new file mode 100644
index 000000000000..3417ec244e8d
--- /dev/null
+++ b/core/misc/favicon.ico
Binary files differ
diff --git a/core/misc/feed.png b/core/misc/feed.png
new file mode 100644
index 000000000000..e3f5067739ac
--- /dev/null
+++ b/core/misc/feed.png
Binary files differ
diff --git a/core/misc/form.js b/core/misc/form.js
new file mode 100644
index 000000000000..259b84eb3f86
--- /dev/null
+++ b/core/misc/form.js
@@ -0,0 +1,78 @@
+(function ($) {
+
+/**
+ * Retrieves the summary for the first element.
+ */
+$.fn.drupalGetSummary = function () {
+ var callback = this.data('summaryCallback');
+ return (this[0] && callback) ? $.trim(callback(this[0])) : '';
+};
+
+/**
+ * Sets the summary for all matched elements.
+ *
+ * @param callback
+ * Either a function that will be called each time the summary is
+ * retrieved or a string (which is returned each time).
+ */
+$.fn.drupalSetSummary = function (callback) {
+ var self = this;
+
+ // To facilitate things, the callback should always be a function. If it's
+ // not, we wrap it into an anonymous function which just returns the value.
+ if (typeof callback != 'function') {
+ var val = callback;
+ callback = function () { return val; };
+ }
+
+ return this
+ .data('summaryCallback', callback)
+ // To prevent duplicate events, the handlers are first removed and then
+ // (re-)added.
+ .unbind('formUpdated.summary')
+ .bind('formUpdated.summary', function () {
+ self.trigger('summaryUpdated');
+ })
+ // The actual summaryUpdated handler doesn't fire when the callback is
+ // changed, so we have to do this manually.
+ .trigger('summaryUpdated');
+};
+
+/**
+ * Sends a 'formUpdated' event each time a form element is modified.
+ */
+Drupal.behaviors.formUpdated = {
+ attach: function (context) {
+ // These events are namespaced so that we can remove them later.
+ var events = 'change.formUpdated click.formUpdated blur.formUpdated keyup.formUpdated';
+ $(context)
+ // Since context could be an input element itself, it's added back to
+ // the jQuery object and filtered again.
+ .find(':input').andSelf().filter(':input')
+ // To prevent duplicate events, the handlers are first removed and then
+ // (re-)added.
+ .unbind(events).bind(events, function () {
+ $(this).trigger('formUpdated');
+ });
+ }
+};
+
+/**
+ * Prepopulate form fields with information from the visitor cookie.
+ */
+Drupal.behaviors.fillUserInfoFromCookie = {
+ attach: function (context, settings) {
+ $('form.user-info-from-cookie').once('user-info-from-cookie', function () {
+ var formContext = this;
+ $.each(['name', 'mail', 'homepage'], function () {
+ var $element = $('[name=' + this + ']', formContext);
+ var cookie = $.cookie('Drupal.visitor.' + this);
+ if ($element.length && cookie) {
+ $element.val(cookie);
+ }
+ });
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/forum-icons.png b/core/misc/forum-icons.png
new file mode 100644
index 000000000000..e291de672522
--- /dev/null
+++ b/core/misc/forum-icons.png
Binary files differ
diff --git a/core/misc/grippie.png b/core/misc/grippie.png
new file mode 100644
index 000000000000..fbd84316d8a3
--- /dev/null
+++ b/core/misc/grippie.png
Binary files differ
diff --git a/core/misc/help.png b/core/misc/help.png
new file mode 100644
index 000000000000..dcc5cac7956f
--- /dev/null
+++ b/core/misc/help.png
Binary files differ
diff --git a/core/misc/jquery.ba-bbq.js b/core/misc/jquery.ba-bbq.js
new file mode 100644
index 000000000000..deb9a2fa4bc3
--- /dev/null
+++ b/core/misc/jquery.ba-bbq.js
@@ -0,0 +1,19 @@
+
+/*
+ * jQuery BBQ: Back Button & Query Library - v1.2.1 - 2/17/2010
+ * http://benalman.com/projects/jquery-bbq-plugin/
+ *
+ * Copyright (c) 2010 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ */
+(function($,p){var i,m=Array.prototype.slice,r=decodeURIComponent,a=$.param,c,l,v,b=$.bbq=$.bbq||{},q,u,j,e=$.event.special,d="hashchange",A="querystring",D="fragment",y="elemUrlAttr",g="location",k="href",t="src",x=/^.*\?|#.*$/g,w=/^.*\#/,h,C={};function E(F){return typeof F==="string"}function B(G){var F=m.call(arguments,1);return function(){return G.apply(this,F.concat(m.call(arguments)))}}function n(F){return F.replace(/^[^#]*#?(.*)$/,"$1")}function o(F){return F.replace(/(?:^[^?#]*\?([^#]*).*$)?.*/,"$1")}function f(H,M,F,I,G){var O,L,K,N,J;if(I!==i){K=F.match(H?/^([^#]*)\#?(.*)$/:/^([^#?]*)\??([^#]*)(#?.*)/);J=K[3]||"";if(G===2&&E(I)){L=I.replace(H?w:x,"")}else{N=l(K[2]);I=E(I)?l[H?D:A](I):I;L=G===2?I:G===1?$.extend({},I,N):$.extend({},N,I);L=a(L);if(H){L=L.replace(h,r)}}O=K[1]+(H?"#":L||!K[1]?"?":"")+L+J}else{O=M(F!==i?F:p[g][k])}return O}a[A]=B(f,0,o);a[D]=c=B(f,1,n);c.noEscape=function(G){G=G||"";var F=$.map(G.split(""),encodeURIComponent);h=new RegExp(F.join("|"),"g")};c.noEscape(",/");$.deparam=l=function(I,F){var H={},G={"true":!0,"false":!1,"null":null};$.each(I.replace(/\+/g," ").split("&"),function(L,Q){var K=Q.split("="),P=r(K[0]),J,O=H,M=0,R=P.split("]["),N=R.length-1;if(/\[/.test(R[0])&&/\]$/.test(R[N])){R[N]=R[N].replace(/\]$/,"");R=R.shift().split("[").concat(R);N=R.length-1}else{N=0}if(K.length===2){J=r(K[1]);if(F){J=J&&!isNaN(J)?+J:J==="undefined"?i:G[J]!==i?G[J]:J}if(N){for(;M<=N;M++){P=R[M]===""?O.length:R[M];O=O[P]=M<N?O[P]||(R[M+1]&&isNaN(R[M+1])?{}:[]):J}}else{if($.isArray(H[P])){H[P].push(J)}else{if(H[P]!==i){H[P]=[H[P],J]}else{H[P]=J}}}}else{if(P){H[P]=F?i:""}}});return H};function z(H,F,G){if(F===i||typeof F==="boolean"){G=F;F=a[H?D:A]()}else{F=E(F)?F.replace(H?w:x,""):F}return l(F,G)}l[A]=B(z,0);l[D]=v=B(z,1);$[y]||($[y]=function(F){return $.extend(C,F)})({a:k,base:k,iframe:t,img:t,input:t,form:"action",link:k,script:t});j=$[y];function s(I,G,H,F){if(!E(H)&&typeof H!=="object"){F=H;H=G;G=i}return this.each(function(){var L=$(this),J=G||j()[(this.nodeName||"").toLowerCase()]||"",K=J&&L.attr(J)||"";L.attr(J,a[I](K,H,F))})}$.fn[A]=B(s,A);$.fn[D]=B(s,D);b.pushState=q=function(I,F){if(E(I)&&/^#/.test(I)&&F===i){F=2}var H=I!==i,G=c(p[g][k],H?I:{},H?F:2);p[g][k]=G+(/#/.test(G)?"":"#")};b.getState=u=function(F,G){return F===i||typeof F==="boolean"?v(F):v(G)[F]};b.removeState=function(F){var G={};if(F!==i){G=u();$.each($.isArray(F)?F:arguments,function(I,H){delete G[H]})}q(G,2)};e[d]=$.extend(e[d],{add:function(F){var H;function G(J){var I=J[D]=c();J.getState=function(K,L){return K===i||typeof K==="boolean"?l(I,K):l(I,L)[K]};H.apply(this,arguments)}if($.isFunction(F)){H=F;return G}else{H=F.handler;F.handler=G}}})})(jQuery,this);
+/*
+ * jQuery hashchange event - v1.2 - 2/11/2010
+ * http://benalman.com/projects/jquery-hashchange-plugin/
+ *
+ * Copyright (c) 2010 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ */
+(function($,i,b){var j,k=$.event.special,c="location",d="hashchange",l="href",f=$.browser,g=document.documentMode,h=f.msie&&(g===b||g<8),e="on"+d in i&&!h;function a(m){m=m||i[c][l];return m.replace(/^[^#]*#?(.*)$/,"$1")}$[d+"Delay"]=100;k[d]=$.extend(k[d],{setup:function(){if(e){return false}$(j.start)},teardown:function(){if(e){return false}$(j.stop)}});j=(function(){var m={},r,n,o,q;function p(){o=q=function(s){return s};if(h){n=$('<iframe src="javascript:0"/>').hide().insertAfter("body")[0].contentWindow;q=function(){return a(n.document[c][l])};o=function(u,s){if(u!==s){var t=n.document;t.open().close();t[c].hash="#"+u}};o(a())}}m.start=function(){if(r){return}var t=a();o||p();(function s(){var v=a(),u=q(t);if(v!==t){o(t=v,u);$(i).trigger(d)}else{if(u!==t){i[c][l]=i[c][l].replace(/#.*/,"")+"#"+u}}r=setTimeout(s,$[d+"Delay"])})()};m.stop=function(){if(!n){r&&clearTimeout(r);r=0}};return m})()})(jQuery,this);
diff --git a/core/misc/jquery.cookie.js b/core/misc/jquery.cookie.js
new file mode 100644
index 000000000000..79f514c2059e
--- /dev/null
+++ b/core/misc/jquery.cookie.js
@@ -0,0 +1,11 @@
+
+/**
+ * Cookie plugin 1.0
+ *
+ * Copyright (c) 2006 Klaus Hartl (stilbuero.de)
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ *
+ */
+jQuery.cookie=function(b,j,m){if(typeof j!="undefined"){m=m||{};if(j===null){j="";m.expires=-1}var e="";if(m.expires&&(typeof m.expires=="number"||m.expires.toUTCString)){var f;if(typeof m.expires=="number"){f=new Date();f.setTime(f.getTime()+(m.expires*24*60*60*1000))}else{f=m.expires}e="; expires="+f.toUTCString()}var l=m.path?"; path="+(m.path):"";var g=m.domain?"; domain="+(m.domain):"";var a=m.secure?"; secure":"";document.cookie=[b,"=",encodeURIComponent(j),e,l,g,a].join("")}else{var d=null;if(document.cookie&&document.cookie!=""){var k=document.cookie.split(";");for(var h=0;h<k.length;h++){var c=jQuery.trim(k[h]);if(c.substring(0,b.length+1)==(b+"=")){d=decodeURIComponent(c.substring(b.length+1));break}}}return d}};
diff --git a/core/misc/jquery.form.js b/core/misc/jquery.form.js
new file mode 100644
index 000000000000..7a6f1b29fe38
--- /dev/null
+++ b/core/misc/jquery.form.js
@@ -0,0 +1,12 @@
+
+/*!
+ * jQuery Form Plugin
+ * version: 2.52 (07-DEC-2010)
+ * @requires jQuery v1.3.2 or later
+ *
+ * Examples and documentation at: http://malsup.com/jquery/form/
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ */
+;(function(b){function q(){if(b.fn.ajaxSubmit.debug){var a="[jquery.form] "+Array.prototype.join.call(arguments,"");if(window.console&&window.console.log)window.console.log(a);else window.opera&&window.opera.postError&&window.opera.postError(a)}}b.fn.ajaxSubmit=function(a){function f(){function t(){var o=i.attr("target"),m=i.attr("action");l.setAttribute("target",u);l.getAttribute("method")!="POST"&&l.setAttribute("method","POST");l.getAttribute("action")!=e.url&&l.setAttribute("action",e.url);e.skipEncodingOverride|| i.attr({encoding:"multipart/form-data",enctype:"multipart/form-data"});e.timeout&&setTimeout(function(){F=true;s()},e.timeout);var v=[];try{if(e.extraData)for(var w in e.extraData)v.push(b('<input type="hidden" name="'+w+'" value="'+e.extraData[w]+'" />').appendTo(l)[0]);r.appendTo("body");r.data("form-plugin-onload",s);l.submit()}finally{l.setAttribute("action",m);o?l.setAttribute("target",o):i.removeAttr("target");b(v).remove()}}function s(){if(!G){r.removeData("form-plugin-onload");var o=true; try{if(F)throw"timeout";p=x.contentWindow?x.contentWindow.document:x.contentDocument?x.contentDocument:x.document;var m=e.dataType=="xml"||p.XMLDocument||b.isXMLDoc(p);q("isXml="+m);if(!m&&window.opera&&(p.body==null||p.body.innerHTML==""))if(--K){q("requeing onLoad callback, DOM not available");setTimeout(s,250);return}G=true;j.responseText=p.documentElement?p.documentElement.innerHTML:null;j.responseXML=p.XMLDocument?p.XMLDocument:p;j.getResponseHeader=function(L){return{"content-type":e.dataType}[L]}; var v=/(json|script)/.test(e.dataType);if(v||e.textarea){var w=p.getElementsByTagName("textarea")[0];if(w)j.responseText=w.value;else if(v){var H=p.getElementsByTagName("pre")[0],I=p.getElementsByTagName("body")[0];if(H)j.responseText=H.textContent;else if(I)j.responseText=I.innerHTML}}else if(e.dataType=="xml"&&!j.responseXML&&j.responseText!=null)j.responseXML=C(j.responseText);J=b.httpData(j,e.dataType)}catch(D){q("error caught:",D);o=false;j.error=D;b.handleError(e,j,"error",D)}if(j.aborted){q("upload aborted"); o=false}if(o){e.success.call(e.context,J,"success",j);y&&b.event.trigger("ajaxSuccess",[j,e])}y&&b.event.trigger("ajaxComplete",[j,e]);y&&!--b.active&&b.event.trigger("ajaxStop");if(e.complete)e.complete.call(e.context,j,o?"success":"error");setTimeout(function(){r.removeData("form-plugin-onload");r.remove();j.responseXML=null},100)}}function C(o,m){if(window.ActiveXObject){m=new ActiveXObject("Microsoft.XMLDOM");m.async="false";m.loadXML(o)}else m=(new DOMParser).parseFromString(o,"text/xml");return m&& m.documentElement&&m.documentElement.tagName!="parsererror"?m:null}var l=i[0];if(b(":input[name=submit],:input[id=submit]",l).length)alert('Error: Form elements must not have name or id of "submit".');else{var e=b.extend(true,{},b.ajaxSettings,a);e.context=e.context||e;var u="jqFormIO"+(new Date).getTime(),E="_"+u;window[E]=function(){var o=r.data("form-plugin-onload");if(o){o();window[E]=undefined;try{delete window[E]}catch(m){}}};var r=b('<iframe id="'+u+'" name="'+u+'" src="'+e.iframeSrc+'" onload="window[\'_\'+this.id]()" />'), x=r[0];r.css({position:"absolute",top:"-1000px",left:"-1000px"});var j={aborted:0,responseText:null,responseXML:null,status:0,statusText:"n/a",getAllResponseHeaders:function(){},getResponseHeader:function(){},setRequestHeader:function(){},abort:function(){this.aborted=1;r.attr("src",e.iframeSrc)}},y=e.global;y&&!b.active++&&b.event.trigger("ajaxStart");y&&b.event.trigger("ajaxSend",[j,e]);if(e.beforeSend&&e.beforeSend.call(e.context,j,e)===false)e.global&&b.active--;else if(!j.aborted){var G=false, F=0,z=l.clk;if(z){var A=z.name;if(A&&!z.disabled){e.extraData=e.extraData||{};e.extraData[A]=z.value;if(z.type=="image"){e.extraData[A+".x"]=l.clk_x;e.extraData[A+".y"]=l.clk_y}}}e.forceSync?t():setTimeout(t,10);var J,p,K=50}}}if(!this.length){q("ajaxSubmit: skipping submit process - no element selected");return this}if(typeof a=="function")a={success:a};var d=this.attr("action");if(d=typeof d==="string"?b.trim(d):"")d=(d.match(/^([^#]+)/)||[])[1];d=d||window.location.href||"";a=b.extend(true,{url:d, type:this.attr("method")||"GET",iframeSrc:/^https/i.test(window.location.href||"")?"javascript:false":"about:blank"},a);d={};this.trigger("form-pre-serialize",[this,a,d]);if(d.veto){q("ajaxSubmit: submit vetoed via form-pre-serialize trigger");return this}if(a.beforeSerialize&&a.beforeSerialize(this,a)===false){q("ajaxSubmit: submit aborted via beforeSerialize callback");return this}var c,h,g=this.formToArray(a.semantic);if(a.data){a.extraData=a.data;for(c in a.data)if(a.data[c]instanceof Array)for(var k in a.data[c])g.push({name:c, value:a.data[c][k]});else{h=a.data[c];h=b.isFunction(h)?h():h;g.push({name:c,value:h})}}if(a.beforeSubmit&&a.beforeSubmit(g,this,a)===false){q("ajaxSubmit: submit aborted via beforeSubmit callback");return this}this.trigger("form-submit-validate",[g,this,a,d]);if(d.veto){q("ajaxSubmit: submit vetoed via form-submit-validate trigger");return this}c=b.param(g);if(a.type.toUpperCase()=="GET"){a.url+=(a.url.indexOf("?")>=0?"&":"?")+c;a.data=null}else a.data=c;var i=this,n=[];a.resetForm&&n.push(function(){i.resetForm()}); a.clearForm&&n.push(function(){i.clearForm()});if(!a.dataType&&a.target){var B=a.success||function(){};n.push(function(t){var s=a.replaceTarget?"replaceWith":"html";b(a.target)[s](t).each(B,arguments)})}else a.success&&n.push(a.success);a.success=function(t,s,C){for(var l=a.context||a,e=0,u=n.length;e<u;e++)n[e].apply(l,[t,s,C||i,i])};c=b("input:file",this).length>0;k=i.attr("enctype")=="multipart/form-data"||i.attr("encoding")=="multipart/form-data";if(a.iframe!==false&&(c||a.iframe||k))a.closeKeepAlive? b.get(a.closeKeepAlive,f):f();else b.ajax(a);this.trigger("form-submit-notify",[this,a]);return this};b.fn.ajaxForm=function(a){if(this.length===0){var f={s:this.selector,c:this.context};if(!b.isReady&&f.s){q("DOM not ready, queuing ajaxForm");b(function(){b(f.s,f.c).ajaxForm(a)});return this}q("terminating; zero elements found by selector"+(b.isReady?"":" (DOM not ready)"));return this}return this.ajaxFormUnbind().bind("submit.form-plugin",function(d){if(!d.isDefaultPrevented()){d.preventDefault(); b(this).ajaxSubmit(a)}}).bind("click.form-plugin",function(d){var c=d.target,h=b(c);if(!h.is(":submit,input:image")){c=h.closest(":submit");if(c.length==0)return;c=c[0]}var g=this;g.clk=c;if(c.type=="image")if(d.offsetX!=undefined){g.clk_x=d.offsetX;g.clk_y=d.offsetY}else if(typeof b.fn.offset=="function"){h=h.offset();g.clk_x=d.pageX-h.left;g.clk_y=d.pageY-h.top}else{g.clk_x=d.pageX-c.offsetLeft;g.clk_y=d.pageY-c.offsetTop}setTimeout(function(){g.clk=g.clk_x=g.clk_y=null},100)})};b.fn.ajaxFormUnbind= function(){return this.unbind("submit.form-plugin click.form-plugin")};b.fn.formToArray=function(a){var f=[];if(this.length===0)return f;var d=this[0],c=a?d.getElementsByTagName("*"):d.elements;if(!c)return f;var h,g,k,i,n,B;h=0;for(n=c.length;h<n;h++){g=c[h];if(k=g.name)if(a&&d.clk&&g.type=="image"){if(!g.disabled&&d.clk==g){f.push({name:k,value:b(g).val()});f.push({name:k+".x",value:d.clk_x},{name:k+".y",value:d.clk_y})}}else if((i=b.fieldValue(g,true))&&i.constructor==Array){g=0;for(B=i.length;g< B;g++)f.push({name:k,value:i[g]})}else i!==null&&typeof i!="undefined"&&f.push({name:k,value:i})}if(!a&&d.clk){a=b(d.clk);c=a[0];if((k=c.name)&&!c.disabled&&c.type=="image"){f.push({name:k,value:a.val()});f.push({name:k+".x",value:d.clk_x},{name:k+".y",value:d.clk_y})}}return f};b.fn.formSerialize=function(a){return b.param(this.formToArray(a))};b.fn.fieldSerialize=function(a){var f=[];this.each(function(){var d=this.name;if(d){var c=b.fieldValue(this,a);if(c&&c.constructor==Array)for(var h=0,g=c.length;h< g;h++)f.push({name:d,value:c[h]});else c!==null&&typeof c!="undefined"&&f.push({name:this.name,value:c})}});return b.param(f)};b.fn.fieldValue=function(a){for(var f=[],d=0,c=this.length;d<c;d++){var h=b.fieldValue(this[d],a);h===null||typeof h=="undefined"||h.constructor==Array&&!h.length||(h.constructor==Array?b.merge(f,h):f.push(h))}return f};b.fieldValue=function(a,f){var d=a.name,c=a.type,h=a.tagName.toLowerCase();if(f===undefined)f=true;if(f&&(!d||a.disabled||c=="reset"||c=="button"||(c=="checkbox"|| c=="radio")&&!a.checked||(c=="submit"||c=="image")&&a.form&&a.form.clk!=a||h=="select"&&a.selectedIndex==-1))return null;if(h=="select"){var g=a.selectedIndex;if(g<0)return null;d=[];h=a.options;var k=(c=c=="select-one")?g+1:h.length;for(g=c?g:0;g<k;g++){var i=h[g];if(i.selected){var n=i.value;n||(n=i.attributes&&i.attributes.value&&!i.attributes.value.specified?i.text:i.value);if(c)return n;d.push(n)}}return d}return b(a).val()};b.fn.clearForm=function(){return this.each(function(){b("input,select,textarea", this).clearFields()})};b.fn.clearFields=b.fn.clearInputs=function(){return this.each(function(){var a=this.type,f=this.tagName.toLowerCase();if(a=="text"||a=="password"||f=="textarea")this.value="";else if(a=="checkbox"||a=="radio")this.checked=false;else if(f=="select")this.selectedIndex=-1})};b.fn.resetForm=function(){return this.each(function(){if(typeof this.reset=="function"||typeof this.reset=="object"&&!this.reset.nodeType)this.reset()})};b.fn.enable=function(a){if(a===undefined)a=true;return this.each(function(){this.disabled= !a})};b.fn.selected=function(a){if(a===undefined)a=true;return this.each(function(){var f=this.type;if(f=="checkbox"||f=="radio")this.checked=a;else if(this.tagName.toLowerCase()=="option"){f=b(this).parent("select");a&&f[0]&&f[0].type=="select-one"&&f.find("option").selected(false);this.selected=a}})}})(jQuery); \ No newline at end of file
diff --git a/core/misc/jquery.js b/core/misc/jquery.js
new file mode 100644
index 000000000000..e900c19a38f8
--- /dev/null
+++ b/core/misc/jquery.js
@@ -0,0 +1,168 @@
+
+/*!
+ * jQuery JavaScript Library v1.4.4
+ * http://jquery.com/
+ *
+ * Copyright 2010, John Resig
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * Includes Sizzle.js
+ * http://sizzlejs.com/
+ * Copyright 2010, The Dojo Foundation
+ * Released under the MIT, BSD, and GPL Licenses.
+ *
+ * Date: Thu Nov 11 19:04:53 2010 -0500
+ */
+(function(E,B){function ka(a,b,d){if(d===B&&a.nodeType===1){d=a.getAttribute("data-"+b);if(typeof d==="string"){try{d=d==="true"?true:d==="false"?false:d==="null"?null:!c.isNaN(d)?parseFloat(d):Ja.test(d)?c.parseJSON(d):d}catch(e){}c.data(a,b,d)}else d=B}return d}function U(){return false}function ca(){return true}function la(a,b,d){d[0].type=a;return c.event.handle.apply(b,d)}function Ka(a){var b,d,e,f,h,l,k,o,x,r,A,C=[];f=[];h=c.data(this,this.nodeType?"events":"__events__");if(typeof h==="function")h=
+h.events;if(!(a.liveFired===this||!h||!h.live||a.button&&a.type==="click")){if(a.namespace)A=RegExp("(^|\\.)"+a.namespace.split(".").join("\\.(?:.*\\.)?")+"(\\.|$)");a.liveFired=this;var J=h.live.slice(0);for(k=0;k<J.length;k++){h=J[k];h.origType.replace(X,"")===a.type?f.push(h.selector):J.splice(k--,1)}f=c(a.target).closest(f,a.currentTarget);o=0;for(x=f.length;o<x;o++){r=f[o];for(k=0;k<J.length;k++){h=J[k];if(r.selector===h.selector&&(!A||A.test(h.namespace))){l=r.elem;e=null;if(h.preType==="mouseenter"||
+h.preType==="mouseleave"){a.type=h.preType;e=c(a.relatedTarget).closest(h.selector)[0]}if(!e||e!==l)C.push({elem:l,handleObj:h,level:r.level})}}}o=0;for(x=C.length;o<x;o++){f=C[o];if(d&&f.level>d)break;a.currentTarget=f.elem;a.data=f.handleObj.data;a.handleObj=f.handleObj;A=f.handleObj.origHandler.apply(f.elem,arguments);if(A===false||a.isPropagationStopped()){d=f.level;if(A===false)b=false;if(a.isImmediatePropagationStopped())break}}return b}}function Y(a,b){return(a&&a!=="*"?a+".":"")+b.replace(La,
+"`").replace(Ma,"&")}function ma(a,b,d){if(c.isFunction(b))return c.grep(a,function(f,h){return!!b.call(f,h,f)===d});else if(b.nodeType)return c.grep(a,function(f){return f===b===d});else if(typeof b==="string"){var e=c.grep(a,function(f){return f.nodeType===1});if(Na.test(b))return c.filter(b,e,!d);else b=c.filter(b,e)}return c.grep(a,function(f){return c.inArray(f,b)>=0===d})}function na(a,b){var d=0;b.each(function(){if(this.nodeName===(a[d]&&a[d].nodeName)){var e=c.data(a[d++]),f=c.data(this,
+e);if(e=e&&e.events){delete f.handle;f.events={};for(var h in e)for(var l in e[h])c.event.add(this,h,e[h][l],e[h][l].data)}}})}function Oa(a,b){b.src?c.ajax({url:b.src,async:false,dataType:"script"}):c.globalEval(b.text||b.textContent||b.innerHTML||"");b.parentNode&&b.parentNode.removeChild(b)}function oa(a,b,d){var e=b==="width"?a.offsetWidth:a.offsetHeight;if(d==="border")return e;c.each(b==="width"?Pa:Qa,function(){d||(e-=parseFloat(c.css(a,"padding"+this))||0);if(d==="margin")e+=parseFloat(c.css(a,
+"margin"+this))||0;else e-=parseFloat(c.css(a,"border"+this+"Width"))||0});return e}function da(a,b,d,e){if(c.isArray(b)&&b.length)c.each(b,function(f,h){d||Ra.test(a)?e(a,h):da(a+"["+(typeof h==="object"||c.isArray(h)?f:"")+"]",h,d,e)});else if(!d&&b!=null&&typeof b==="object")c.isEmptyObject(b)?e(a,""):c.each(b,function(f,h){da(a+"["+f+"]",h,d,e)});else e(a,b)}function S(a,b){var d={};c.each(pa.concat.apply([],pa.slice(0,b)),function(){d[this]=a});return d}function qa(a){if(!ea[a]){var b=c("<"+
+a+">").appendTo("body"),d=b.css("display");b.remove();if(d==="none"||d==="")d="block";ea[a]=d}return ea[a]}function fa(a){return c.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:false}var t=E.document,c=function(){function a(){if(!b.isReady){try{t.documentElement.doScroll("left")}catch(j){setTimeout(a,1);return}b.ready()}}var b=function(j,s){return new b.fn.init(j,s)},d=E.jQuery,e=E.$,f,h=/^(?:[^<]*(<[\w\W]+>)[^>]*$|#([\w\-]+)$)/,l=/\S/,k=/^\s+/,o=/\s+$/,x=/\W/,r=/\d/,A=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,
+C=/^[\],:{}\s]*$/,J=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,w=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,I=/(?:^|:|,)(?:\s*\[)+/g,L=/(webkit)[ \/]([\w.]+)/,g=/(opera)(?:.*version)?[ \/]([\w.]+)/,i=/(msie) ([\w.]+)/,n=/(mozilla)(?:.*? rv:([\w.]+))?/,m=navigator.userAgent,p=false,q=[],u,y=Object.prototype.toString,F=Object.prototype.hasOwnProperty,M=Array.prototype.push,N=Array.prototype.slice,O=String.prototype.trim,D=Array.prototype.indexOf,R={};b.fn=b.prototype={init:function(j,
+s){var v,z,H;if(!j)return this;if(j.nodeType){this.context=this[0]=j;this.length=1;return this}if(j==="body"&&!s&&t.body){this.context=t;this[0]=t.body;this.selector="body";this.length=1;return this}if(typeof j==="string")if((v=h.exec(j))&&(v[1]||!s))if(v[1]){H=s?s.ownerDocument||s:t;if(z=A.exec(j))if(b.isPlainObject(s)){j=[t.createElement(z[1])];b.fn.attr.call(j,s,true)}else j=[H.createElement(z[1])];else{z=b.buildFragment([v[1]],[H]);j=(z.cacheable?z.fragment.cloneNode(true):z.fragment).childNodes}return b.merge(this,
+j)}else{if((z=t.getElementById(v[2]))&&z.parentNode){if(z.id!==v[2])return f.find(j);this.length=1;this[0]=z}this.context=t;this.selector=j;return this}else if(!s&&!x.test(j)){this.selector=j;this.context=t;j=t.getElementsByTagName(j);return b.merge(this,j)}else return!s||s.jquery?(s||f).find(j):b(s).find(j);else if(b.isFunction(j))return f.ready(j);if(j.selector!==B){this.selector=j.selector;this.context=j.context}return b.makeArray(j,this)},selector:"",jquery:"1.4.4",length:0,size:function(){return this.length},
+toArray:function(){return N.call(this,0)},get:function(j){return j==null?this.toArray():j<0?this.slice(j)[0]:this[j]},pushStack:function(j,s,v){var z=b();b.isArray(j)?M.apply(z,j):b.merge(z,j);z.prevObject=this;z.context=this.context;if(s==="find")z.selector=this.selector+(this.selector?" ":"")+v;else if(s)z.selector=this.selector+"."+s+"("+v+")";return z},each:function(j,s){return b.each(this,j,s)},ready:function(j){b.bindReady();if(b.isReady)j.call(t,b);else q&&q.push(j);return this},eq:function(j){return j===
+-1?this.slice(j):this.slice(j,+j+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(N.apply(this,arguments),"slice",N.call(arguments).join(","))},map:function(j){return this.pushStack(b.map(this,function(s,v){return j.call(s,v,s)}))},end:function(){return this.prevObject||b(null)},push:M,sort:[].sort,splice:[].splice};b.fn.init.prototype=b.fn;b.extend=b.fn.extend=function(){var j,s,v,z,H,G=arguments[0]||{},K=1,Q=arguments.length,ga=false;
+if(typeof G==="boolean"){ga=G;G=arguments[1]||{};K=2}if(typeof G!=="object"&&!b.isFunction(G))G={};if(Q===K){G=this;--K}for(;K<Q;K++)if((j=arguments[K])!=null)for(s in j){v=G[s];z=j[s];if(G!==z)if(ga&&z&&(b.isPlainObject(z)||(H=b.isArray(z)))){if(H){H=false;v=v&&b.isArray(v)?v:[]}else v=v&&b.isPlainObject(v)?v:{};G[s]=b.extend(ga,v,z)}else if(z!==B)G[s]=z}return G};b.extend({noConflict:function(j){E.$=e;if(j)E.jQuery=d;return b},isReady:false,readyWait:1,ready:function(j){j===true&&b.readyWait--;
+if(!b.readyWait||j!==true&&!b.isReady){if(!t.body)return setTimeout(b.ready,1);b.isReady=true;if(!(j!==true&&--b.readyWait>0))if(q){var s=0,v=q;for(q=null;j=v[s++];)j.call(t,b);b.fn.trigger&&b(t).trigger("ready").unbind("ready")}}},bindReady:function(){if(!p){p=true;if(t.readyState==="complete")return setTimeout(b.ready,1);if(t.addEventListener){t.addEventListener("DOMContentLoaded",u,false);E.addEventListener("load",b.ready,false)}else if(t.attachEvent){t.attachEvent("onreadystatechange",u);E.attachEvent("onload",
+b.ready);var j=false;try{j=E.frameElement==null}catch(s){}t.documentElement.doScroll&&j&&a()}}},isFunction:function(j){return b.type(j)==="function"},isArray:Array.isArray||function(j){return b.type(j)==="array"},isWindow:function(j){return j&&typeof j==="object"&&"setInterval"in j},isNaN:function(j){return j==null||!r.test(j)||isNaN(j)},type:function(j){return j==null?String(j):R[y.call(j)]||"object"},isPlainObject:function(j){if(!j||b.type(j)!=="object"||j.nodeType||b.isWindow(j))return false;if(j.constructor&&
+!F.call(j,"constructor")&&!F.call(j.constructor.prototype,"isPrototypeOf"))return false;for(var s in j);return s===B||F.call(j,s)},isEmptyObject:function(j){for(var s in j)return false;return true},error:function(j){throw j;},parseJSON:function(j){if(typeof j!=="string"||!j)return null;j=b.trim(j);if(C.test(j.replace(J,"@").replace(w,"]").replace(I,"")))return E.JSON&&E.JSON.parse?E.JSON.parse(j):(new Function("return "+j))();else b.error("Invalid JSON: "+j)},noop:function(){},globalEval:function(j){if(j&&
+l.test(j)){var s=t.getElementsByTagName("head")[0]||t.documentElement,v=t.createElement("script");v.type="text/javascript";if(b.support.scriptEval)v.appendChild(t.createTextNode(j));else v.text=j;s.insertBefore(v,s.firstChild);s.removeChild(v)}},nodeName:function(j,s){return j.nodeName&&j.nodeName.toUpperCase()===s.toUpperCase()},each:function(j,s,v){var z,H=0,G=j.length,K=G===B||b.isFunction(j);if(v)if(K)for(z in j){if(s.apply(j[z],v)===false)break}else for(;H<G;){if(s.apply(j[H++],v)===false)break}else if(K)for(z in j){if(s.call(j[z],
+z,j[z])===false)break}else for(v=j[0];H<G&&s.call(v,H,v)!==false;v=j[++H]);return j},trim:O?function(j){return j==null?"":O.call(j)}:function(j){return j==null?"":j.toString().replace(k,"").replace(o,"")},makeArray:function(j,s){var v=s||[];if(j!=null){var z=b.type(j);j.length==null||z==="string"||z==="function"||z==="regexp"||b.isWindow(j)?M.call(v,j):b.merge(v,j)}return v},inArray:function(j,s){if(s.indexOf)return s.indexOf(j);for(var v=0,z=s.length;v<z;v++)if(s[v]===j)return v;return-1},merge:function(j,
+s){var v=j.length,z=0;if(typeof s.length==="number")for(var H=s.length;z<H;z++)j[v++]=s[z];else for(;s[z]!==B;)j[v++]=s[z++];j.length=v;return j},grep:function(j,s,v){var z=[],H;v=!!v;for(var G=0,K=j.length;G<K;G++){H=!!s(j[G],G);v!==H&&z.push(j[G])}return z},map:function(j,s,v){for(var z=[],H,G=0,K=j.length;G<K;G++){H=s(j[G],G,v);if(H!=null)z[z.length]=H}return z.concat.apply([],z)},guid:1,proxy:function(j,s,v){if(arguments.length===2)if(typeof s==="string"){v=j;j=v[s];s=B}else if(s&&!b.isFunction(s)){v=
+s;s=B}if(!s&&j)s=function(){return j.apply(v||this,arguments)};if(j)s.guid=j.guid=j.guid||s.guid||b.guid++;return s},access:function(j,s,v,z,H,G){var K=j.length;if(typeof s==="object"){for(var Q in s)b.access(j,Q,s[Q],z,H,v);return j}if(v!==B){z=!G&&z&&b.isFunction(v);for(Q=0;Q<K;Q++)H(j[Q],s,z?v.call(j[Q],Q,H(j[Q],s)):v,G);return j}return K?H(j[0],s):B},now:function(){return(new Date).getTime()},uaMatch:function(j){j=j.toLowerCase();j=L.exec(j)||g.exec(j)||i.exec(j)||j.indexOf("compatible")<0&&n.exec(j)||
+[];return{browser:j[1]||"",version:j[2]||"0"}},browser:{}});b.each("Boolean Number String Function Array Date RegExp Object".split(" "),function(j,s){R["[object "+s+"]"]=s.toLowerCase()});m=b.uaMatch(m);if(m.browser){b.browser[m.browser]=true;b.browser.version=m.version}if(b.browser.webkit)b.browser.safari=true;if(D)b.inArray=function(j,s){return D.call(s,j)};if(!/\s/.test("\u00a0")){k=/^[\s\xA0]+/;o=/[\s\xA0]+$/}f=b(t);if(t.addEventListener)u=function(){t.removeEventListener("DOMContentLoaded",u,
+false);b.ready()};else if(t.attachEvent)u=function(){if(t.readyState==="complete"){t.detachEvent("onreadystatechange",u);b.ready()}};return E.jQuery=E.$=b}();(function(){c.support={};var a=t.documentElement,b=t.createElement("script"),d=t.createElement("div"),e="script"+c.now();d.style.display="none";d.innerHTML=" <link/><table></table><a href='/a' style='color:red;float:left;opacity:.55;'>a</a><input type='checkbox'/>";var f=d.getElementsByTagName("*"),h=d.getElementsByTagName("a")[0],l=t.createElement("select"),
+k=l.appendChild(t.createElement("option"));if(!(!f||!f.length||!h)){c.support={leadingWhitespace:d.firstChild.nodeType===3,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/red/.test(h.getAttribute("style")),hrefNormalized:h.getAttribute("href")==="/a",opacity:/^0.55$/.test(h.style.opacity),cssFloat:!!h.style.cssFloat,checkOn:d.getElementsByTagName("input")[0].value==="on",optSelected:k.selected,deleteExpando:true,optDisabled:false,checkClone:false,
+scriptEval:false,noCloneEvent:true,boxModel:null,inlineBlockNeedsLayout:false,shrinkWrapBlocks:false,reliableHiddenOffsets:true};l.disabled=true;c.support.optDisabled=!k.disabled;b.type="text/javascript";try{b.appendChild(t.createTextNode("window."+e+"=1;"))}catch(o){}a.insertBefore(b,a.firstChild);if(E[e]){c.support.scriptEval=true;delete E[e]}try{delete b.test}catch(x){c.support.deleteExpando=false}a.removeChild(b);if(d.attachEvent&&d.fireEvent){d.attachEvent("onclick",function r(){c.support.noCloneEvent=
+false;d.detachEvent("onclick",r)});d.cloneNode(true).fireEvent("onclick")}d=t.createElement("div");d.innerHTML="<input type='radio' name='radiotest' checked='checked'/>";a=t.createDocumentFragment();a.appendChild(d.firstChild);c.support.checkClone=a.cloneNode(true).cloneNode(true).lastChild.checked;c(function(){var r=t.createElement("div");r.style.width=r.style.paddingLeft="1px";t.body.appendChild(r);c.boxModel=c.support.boxModel=r.offsetWidth===2;if("zoom"in r.style){r.style.display="inline";r.style.zoom=
+1;c.support.inlineBlockNeedsLayout=r.offsetWidth===2;r.style.display="";r.innerHTML="<div style='width:4px;'></div>";c.support.shrinkWrapBlocks=r.offsetWidth!==2}r.innerHTML="<table><tr><td style='padding:0;display:none'></td><td>t</td></tr></table>";var A=r.getElementsByTagName("td");c.support.reliableHiddenOffsets=A[0].offsetHeight===0;A[0].style.display="";A[1].style.display="none";c.support.reliableHiddenOffsets=c.support.reliableHiddenOffsets&&A[0].offsetHeight===0;r.innerHTML="";t.body.removeChild(r).style.display=
+"none"});a=function(r){var A=t.createElement("div");r="on"+r;var C=r in A;if(!C){A.setAttribute(r,"return;");C=typeof A[r]==="function"}return C};c.support.submitBubbles=a("submit");c.support.changeBubbles=a("change");a=b=d=f=h=null}})();var ra={},Ja=/^(?:\{.*\}|\[.*\])$/;c.extend({cache:{},uuid:0,expando:"jQuery"+c.now(),noData:{embed:true,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:true},data:function(a,b,d){if(c.acceptData(a)){a=a==E?ra:a;var e=a.nodeType,f=e?a[c.expando]:null,h=
+c.cache;if(!(e&&!f&&typeof b==="string"&&d===B)){if(e)f||(a[c.expando]=f=++c.uuid);else h=a;if(typeof b==="object")if(e)h[f]=c.extend(h[f],b);else c.extend(h,b);else if(e&&!h[f])h[f]={};a=e?h[f]:h;if(d!==B)a[b]=d;return typeof b==="string"?a[b]:a}}},removeData:function(a,b){if(c.acceptData(a)){a=a==E?ra:a;var d=a.nodeType,e=d?a[c.expando]:a,f=c.cache,h=d?f[e]:e;if(b){if(h){delete h[b];d&&c.isEmptyObject(h)&&c.removeData(a)}}else if(d&&c.support.deleteExpando)delete a[c.expando];else if(a.removeAttribute)a.removeAttribute(c.expando);
+else if(d)delete f[e];else for(var l in a)delete a[l]}},acceptData:function(a){if(a.nodeName){var b=c.noData[a.nodeName.toLowerCase()];if(b)return!(b===true||a.getAttribute("classid")!==b)}return true}});c.fn.extend({data:function(a,b){var d=null;if(typeof a==="undefined"){if(this.length){var e=this[0].attributes,f;d=c.data(this[0]);for(var h=0,l=e.length;h<l;h++){f=e[h].name;if(f.indexOf("data-")===0){f=f.substr(5);ka(this[0],f,d[f])}}}return d}else if(typeof a==="object")return this.each(function(){c.data(this,
+a)});var k=a.split(".");k[1]=k[1]?"."+k[1]:"";if(b===B){d=this.triggerHandler("getData"+k[1]+"!",[k[0]]);if(d===B&&this.length){d=c.data(this[0],a);d=ka(this[0],a,d)}return d===B&&k[1]?this.data(k[0]):d}else return this.each(function(){var o=c(this),x=[k[0],b];o.triggerHandler("setData"+k[1]+"!",x);c.data(this,a,b);o.triggerHandler("changeData"+k[1]+"!",x)})},removeData:function(a){return this.each(function(){c.removeData(this,a)})}});c.extend({queue:function(a,b,d){if(a){b=(b||"fx")+"queue";var e=
+c.data(a,b);if(!d)return e||[];if(!e||c.isArray(d))e=c.data(a,b,c.makeArray(d));else e.push(d);return e}},dequeue:function(a,b){b=b||"fx";var d=c.queue(a,b),e=d.shift();if(e==="inprogress")e=d.shift();if(e){b==="fx"&&d.unshift("inprogress");e.call(a,function(){c.dequeue(a,b)})}}});c.fn.extend({queue:function(a,b){if(typeof a!=="string"){b=a;a="fx"}if(b===B)return c.queue(this[0],a);return this.each(function(){var d=c.queue(this,a,b);a==="fx"&&d[0]!=="inprogress"&&c.dequeue(this,a)})},dequeue:function(a){return this.each(function(){c.dequeue(this,
+a)})},delay:function(a,b){a=c.fx?c.fx.speeds[a]||a:a;b=b||"fx";return this.queue(b,function(){var d=this;setTimeout(function(){c.dequeue(d,b)},a)})},clearQueue:function(a){return this.queue(a||"fx",[])}});var sa=/[\n\t]/g,ha=/\s+/,Sa=/\r/g,Ta=/^(?:href|src|style)$/,Ua=/^(?:button|input)$/i,Va=/^(?:button|input|object|select|textarea)$/i,Wa=/^a(?:rea)?$/i,ta=/^(?:radio|checkbox)$/i;c.props={"for":"htmlFor","class":"className",readonly:"readOnly",maxlength:"maxLength",cellspacing:"cellSpacing",rowspan:"rowSpan",
+colspan:"colSpan",tabindex:"tabIndex",usemap:"useMap",frameborder:"frameBorder"};c.fn.extend({attr:function(a,b){return c.access(this,a,b,true,c.attr)},removeAttr:function(a){return this.each(function(){c.attr(this,a,"");this.nodeType===1&&this.removeAttribute(a)})},addClass:function(a){if(c.isFunction(a))return this.each(function(x){var r=c(this);r.addClass(a.call(this,x,r.attr("class")))});if(a&&typeof a==="string")for(var b=(a||"").split(ha),d=0,e=this.length;d<e;d++){var f=this[d];if(f.nodeType===
+1)if(f.className){for(var h=" "+f.className+" ",l=f.className,k=0,o=b.length;k<o;k++)if(h.indexOf(" "+b[k]+" ")<0)l+=" "+b[k];f.className=c.trim(l)}else f.className=a}return this},removeClass:function(a){if(c.isFunction(a))return this.each(function(o){var x=c(this);x.removeClass(a.call(this,o,x.attr("class")))});if(a&&typeof a==="string"||a===B)for(var b=(a||"").split(ha),d=0,e=this.length;d<e;d++){var f=this[d];if(f.nodeType===1&&f.className)if(a){for(var h=(" "+f.className+" ").replace(sa," "),
+l=0,k=b.length;l<k;l++)h=h.replace(" "+b[l]+" "," ");f.className=c.trim(h)}else f.className=""}return this},toggleClass:function(a,b){var d=typeof a,e=typeof b==="boolean";if(c.isFunction(a))return this.each(function(f){var h=c(this);h.toggleClass(a.call(this,f,h.attr("class"),b),b)});return this.each(function(){if(d==="string")for(var f,h=0,l=c(this),k=b,o=a.split(ha);f=o[h++];){k=e?k:!l.hasClass(f);l[k?"addClass":"removeClass"](f)}else if(d==="undefined"||d==="boolean"){this.className&&c.data(this,
+"__className__",this.className);this.className=this.className||a===false?"":c.data(this,"__className__")||""}})},hasClass:function(a){a=" "+a+" ";for(var b=0,d=this.length;b<d;b++)if((" "+this[b].className+" ").replace(sa," ").indexOf(a)>-1)return true;return false},val:function(a){if(!arguments.length){var b=this[0];if(b){if(c.nodeName(b,"option")){var d=b.attributes.value;return!d||d.specified?b.value:b.text}if(c.nodeName(b,"select")){var e=b.selectedIndex;d=[];var f=b.options;b=b.type==="select-one";
+if(e<0)return null;var h=b?e:0;for(e=b?e+1:f.length;h<e;h++){var l=f[h];if(l.selected&&(c.support.optDisabled?!l.disabled:l.getAttribute("disabled")===null)&&(!l.parentNode.disabled||!c.nodeName(l.parentNode,"optgroup"))){a=c(l).val();if(b)return a;d.push(a)}}return d}if(ta.test(b.type)&&!c.support.checkOn)return b.getAttribute("value")===null?"on":b.value;return(b.value||"").replace(Sa,"")}return B}var k=c.isFunction(a);return this.each(function(o){var x=c(this),r=a;if(this.nodeType===1){if(k)r=
+a.call(this,o,x.val());if(r==null)r="";else if(typeof r==="number")r+="";else if(c.isArray(r))r=c.map(r,function(C){return C==null?"":C+""});if(c.isArray(r)&&ta.test(this.type))this.checked=c.inArray(x.val(),r)>=0;else if(c.nodeName(this,"select")){var A=c.makeArray(r);c("option",this).each(function(){this.selected=c.inArray(c(this).val(),A)>=0});if(!A.length)this.selectedIndex=-1}else this.value=r}})}});c.extend({attrFn:{val:true,css:true,html:true,text:true,data:true,width:true,height:true,offset:true},
+attr:function(a,b,d,e){if(!a||a.nodeType===3||a.nodeType===8)return B;if(e&&b in c.attrFn)return c(a)[b](d);e=a.nodeType!==1||!c.isXMLDoc(a);var f=d!==B;b=e&&c.props[b]||b;var h=Ta.test(b);if((b in a||a[b]!==B)&&e&&!h){if(f){b==="type"&&Ua.test(a.nodeName)&&a.parentNode&&c.error("type property can't be changed");if(d===null)a.nodeType===1&&a.removeAttribute(b);else a[b]=d}if(c.nodeName(a,"form")&&a.getAttributeNode(b))return a.getAttributeNode(b).nodeValue;if(b==="tabIndex")return(b=a.getAttributeNode("tabIndex"))&&
+b.specified?b.value:Va.test(a.nodeName)||Wa.test(a.nodeName)&&a.href?0:B;return a[b]}if(!c.support.style&&e&&b==="style"){if(f)a.style.cssText=""+d;return a.style.cssText}f&&a.setAttribute(b,""+d);if(!a.attributes[b]&&a.hasAttribute&&!a.hasAttribute(b))return B;a=!c.support.hrefNormalized&&e&&h?a.getAttribute(b,2):a.getAttribute(b);return a===null?B:a}});var X=/\.(.*)$/,ia=/^(?:textarea|input|select)$/i,La=/\./g,Ma=/ /g,Xa=/[^\w\s.|`]/g,Ya=function(a){return a.replace(Xa,"\\$&")},ua={focusin:0,focusout:0};
+c.event={add:function(a,b,d,e){if(!(a.nodeType===3||a.nodeType===8)){if(c.isWindow(a)&&a!==E&&!a.frameElement)a=E;if(d===false)d=U;else if(!d)return;var f,h;if(d.handler){f=d;d=f.handler}if(!d.guid)d.guid=c.guid++;if(h=c.data(a)){var l=a.nodeType?"events":"__events__",k=h[l],o=h.handle;if(typeof k==="function"){o=k.handle;k=k.events}else if(!k){a.nodeType||(h[l]=h=function(){});h.events=k={}}if(!o)h.handle=o=function(){return typeof c!=="undefined"&&!c.event.triggered?c.event.handle.apply(o.elem,
+arguments):B};o.elem=a;b=b.split(" ");for(var x=0,r;l=b[x++];){h=f?c.extend({},f):{handler:d,data:e};if(l.indexOf(".")>-1){r=l.split(".");l=r.shift();h.namespace=r.slice(0).sort().join(".")}else{r=[];h.namespace=""}h.type=l;if(!h.guid)h.guid=d.guid;var A=k[l],C=c.event.special[l]||{};if(!A){A=k[l]=[];if(!C.setup||C.setup.call(a,e,r,o)===false)if(a.addEventListener)a.addEventListener(l,o,false);else a.attachEvent&&a.attachEvent("on"+l,o)}if(C.add){C.add.call(a,h);if(!h.handler.guid)h.handler.guid=
+d.guid}A.push(h);c.event.global[l]=true}a=null}}},global:{},remove:function(a,b,d,e){if(!(a.nodeType===3||a.nodeType===8)){if(d===false)d=U;var f,h,l=0,k,o,x,r,A,C,J=a.nodeType?"events":"__events__",w=c.data(a),I=w&&w[J];if(w&&I){if(typeof I==="function"){w=I;I=I.events}if(b&&b.type){d=b.handler;b=b.type}if(!b||typeof b==="string"&&b.charAt(0)==="."){b=b||"";for(f in I)c.event.remove(a,f+b)}else{for(b=b.split(" ");f=b[l++];){r=f;k=f.indexOf(".")<0;o=[];if(!k){o=f.split(".");f=o.shift();x=RegExp("(^|\\.)"+
+c.map(o.slice(0).sort(),Ya).join("\\.(?:.*\\.)?")+"(\\.|$)")}if(A=I[f])if(d){r=c.event.special[f]||{};for(h=e||0;h<A.length;h++){C=A[h];if(d.guid===C.guid){if(k||x.test(C.namespace)){e==null&&A.splice(h--,1);r.remove&&r.remove.call(a,C)}if(e!=null)break}}if(A.length===0||e!=null&&A.length===1){if(!r.teardown||r.teardown.call(a,o)===false)c.removeEvent(a,f,w.handle);delete I[f]}}else for(h=0;h<A.length;h++){C=A[h];if(k||x.test(C.namespace)){c.event.remove(a,r,C.handler,h);A.splice(h--,1)}}}if(c.isEmptyObject(I)){if(b=
+w.handle)b.elem=null;delete w.events;delete w.handle;if(typeof w==="function")c.removeData(a,J);else c.isEmptyObject(w)&&c.removeData(a)}}}}},trigger:function(a,b,d,e){var f=a.type||a;if(!e){a=typeof a==="object"?a[c.expando]?a:c.extend(c.Event(f),a):c.Event(f);if(f.indexOf("!")>=0){a.type=f=f.slice(0,-1);a.exclusive=true}if(!d){a.stopPropagation();c.event.global[f]&&c.each(c.cache,function(){this.events&&this.events[f]&&c.event.trigger(a,b,this.handle.elem)})}if(!d||d.nodeType===3||d.nodeType===
+8)return B;a.result=B;a.target=d;b=c.makeArray(b);b.unshift(a)}a.currentTarget=d;(e=d.nodeType?c.data(d,"handle"):(c.data(d,"__events__")||{}).handle)&&e.apply(d,b);e=d.parentNode||d.ownerDocument;try{if(!(d&&d.nodeName&&c.noData[d.nodeName.toLowerCase()]))if(d["on"+f]&&d["on"+f].apply(d,b)===false){a.result=false;a.preventDefault()}}catch(h){}if(!a.isPropagationStopped()&&e)c.event.trigger(a,b,e,true);else if(!a.isDefaultPrevented()){var l;e=a.target;var k=f.replace(X,""),o=c.nodeName(e,"a")&&k===
+"click",x=c.event.special[k]||{};if((!x._default||x._default.call(d,a)===false)&&!o&&!(e&&e.nodeName&&c.noData[e.nodeName.toLowerCase()])){try{if(e[k]){if(l=e["on"+k])e["on"+k]=null;c.event.triggered=true;e[k]()}}catch(r){}if(l)e["on"+k]=l;c.event.triggered=false}}},handle:function(a){var b,d,e,f;d=[];var h=c.makeArray(arguments);a=h[0]=c.event.fix(a||E.event);a.currentTarget=this;b=a.type.indexOf(".")<0&&!a.exclusive;if(!b){e=a.type.split(".");a.type=e.shift();d=e.slice(0).sort();e=RegExp("(^|\\.)"+
+d.join("\\.(?:.*\\.)?")+"(\\.|$)")}a.namespace=a.namespace||d.join(".");f=c.data(this,this.nodeType?"events":"__events__");if(typeof f==="function")f=f.events;d=(f||{})[a.type];if(f&&d){d=d.slice(0);f=0;for(var l=d.length;f<l;f++){var k=d[f];if(b||e.test(k.namespace)){a.handler=k.handler;a.data=k.data;a.handleObj=k;k=k.handler.apply(this,h);if(k!==B){a.result=k;if(k===false){a.preventDefault();a.stopPropagation()}}if(a.isImmediatePropagationStopped())break}}}return a.result},props:"altKey attrChange attrName bubbles button cancelable charCode clientX clientY ctrlKey currentTarget data detail eventPhase fromElement handler keyCode layerX layerY metaKey newValue offsetX offsetY pageX pageY prevValue relatedNode relatedTarget screenX screenY shiftKey srcElement target toElement view wheelDelta which".split(" "),
+fix:function(a){if(a[c.expando])return a;var b=a;a=c.Event(b);for(var d=this.props.length,e;d;){e=this.props[--d];a[e]=b[e]}if(!a.target)a.target=a.srcElement||t;if(a.target.nodeType===3)a.target=a.target.parentNode;if(!a.relatedTarget&&a.fromElement)a.relatedTarget=a.fromElement===a.target?a.toElement:a.fromElement;if(a.pageX==null&&a.clientX!=null){b=t.documentElement;d=t.body;a.pageX=a.clientX+(b&&b.scrollLeft||d&&d.scrollLeft||0)-(b&&b.clientLeft||d&&d.clientLeft||0);a.pageY=a.clientY+(b&&b.scrollTop||
+d&&d.scrollTop||0)-(b&&b.clientTop||d&&d.clientTop||0)}if(a.which==null&&(a.charCode!=null||a.keyCode!=null))a.which=a.charCode!=null?a.charCode:a.keyCode;if(!a.metaKey&&a.ctrlKey)a.metaKey=a.ctrlKey;if(!a.which&&a.button!==B)a.which=a.button&1?1:a.button&2?3:a.button&4?2:0;return a},guid:1E8,proxy:c.proxy,special:{ready:{setup:c.bindReady,teardown:c.noop},live:{add:function(a){c.event.add(this,Y(a.origType,a.selector),c.extend({},a,{handler:Ka,guid:a.handler.guid}))},remove:function(a){c.event.remove(this,
+Y(a.origType,a.selector),a)}},beforeunload:{setup:function(a,b,d){if(c.isWindow(this))this.onbeforeunload=d},teardown:function(a,b){if(this.onbeforeunload===b)this.onbeforeunload=null}}}};c.removeEvent=t.removeEventListener?function(a,b,d){a.removeEventListener&&a.removeEventListener(b,d,false)}:function(a,b,d){a.detachEvent&&a.detachEvent("on"+b,d)};c.Event=function(a){if(!this.preventDefault)return new c.Event(a);if(a&&a.type){this.originalEvent=a;this.type=a.type}else this.type=a;this.timeStamp=
+c.now();this[c.expando]=true};c.Event.prototype={preventDefault:function(){this.isDefaultPrevented=ca;var a=this.originalEvent;if(a)if(a.preventDefault)a.preventDefault();else a.returnValue=false},stopPropagation:function(){this.isPropagationStopped=ca;var a=this.originalEvent;if(a){a.stopPropagation&&a.stopPropagation();a.cancelBubble=true}},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=ca;this.stopPropagation()},isDefaultPrevented:U,isPropagationStopped:U,isImmediatePropagationStopped:U};
+var va=function(a){var b=a.relatedTarget;try{for(;b&&b!==this;)b=b.parentNode;if(b!==this){a.type=a.data;c.event.handle.apply(this,arguments)}}catch(d){}},wa=function(a){a.type=a.data;c.event.handle.apply(this,arguments)};c.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(a,b){c.event.special[a]={setup:function(d){c.event.add(this,b,d&&d.selector?wa:va,a)},teardown:function(d){c.event.remove(this,b,d&&d.selector?wa:va)}}});if(!c.support.submitBubbles)c.event.special.submit={setup:function(){if(this.nodeName.toLowerCase()!==
+"form"){c.event.add(this,"click.specialSubmit",function(a){var b=a.target,d=b.type;if((d==="submit"||d==="image")&&c(b).closest("form").length){a.liveFired=B;return la("submit",this,arguments)}});c.event.add(this,"keypress.specialSubmit",function(a){var b=a.target,d=b.type;if((d==="text"||d==="password")&&c(b).closest("form").length&&a.keyCode===13){a.liveFired=B;return la("submit",this,arguments)}})}else return false},teardown:function(){c.event.remove(this,".specialSubmit")}};if(!c.support.changeBubbles){var V,
+xa=function(a){var b=a.type,d=a.value;if(b==="radio"||b==="checkbox")d=a.checked;else if(b==="select-multiple")d=a.selectedIndex>-1?c.map(a.options,function(e){return e.selected}).join("-"):"";else if(a.nodeName.toLowerCase()==="select")d=a.selectedIndex;return d},Z=function(a,b){var d=a.target,e,f;if(!(!ia.test(d.nodeName)||d.readOnly)){e=c.data(d,"_change_data");f=xa(d);if(a.type!=="focusout"||d.type!=="radio")c.data(d,"_change_data",f);if(!(e===B||f===e))if(e!=null||f){a.type="change";a.liveFired=
+B;return c.event.trigger(a,b,d)}}};c.event.special.change={filters:{focusout:Z,beforedeactivate:Z,click:function(a){var b=a.target,d=b.type;if(d==="radio"||d==="checkbox"||b.nodeName.toLowerCase()==="select")return Z.call(this,a)},keydown:function(a){var b=a.target,d=b.type;if(a.keyCode===13&&b.nodeName.toLowerCase()!=="textarea"||a.keyCode===32&&(d==="checkbox"||d==="radio")||d==="select-multiple")return Z.call(this,a)},beforeactivate:function(a){a=a.target;c.data(a,"_change_data",xa(a))}},setup:function(){if(this.type===
+"file")return false;for(var a in V)c.event.add(this,a+".specialChange",V[a]);return ia.test(this.nodeName)},teardown:function(){c.event.remove(this,".specialChange");return ia.test(this.nodeName)}};V=c.event.special.change.filters;V.focus=V.beforeactivate}t.addEventListener&&c.each({focus:"focusin",blur:"focusout"},function(a,b){function d(e){e=c.event.fix(e);e.type=b;return c.event.trigger(e,null,e.target)}c.event.special[b]={setup:function(){ua[b]++===0&&t.addEventListener(a,d,true)},teardown:function(){--ua[b]===
+0&&t.removeEventListener(a,d,true)}}});c.each(["bind","one"],function(a,b){c.fn[b]=function(d,e,f){if(typeof d==="object"){for(var h in d)this[b](h,e,d[h],f);return this}if(c.isFunction(e)||e===false){f=e;e=B}var l=b==="one"?c.proxy(f,function(o){c(this).unbind(o,l);return f.apply(this,arguments)}):f;if(d==="unload"&&b!=="one")this.one(d,e,f);else{h=0;for(var k=this.length;h<k;h++)c.event.add(this[h],d,l,e)}return this}});c.fn.extend({unbind:function(a,b){if(typeof a==="object"&&!a.preventDefault)for(var d in a)this.unbind(d,
+a[d]);else{d=0;for(var e=this.length;d<e;d++)c.event.remove(this[d],a,b)}return this},delegate:function(a,b,d,e){return this.live(b,d,e,a)},undelegate:function(a,b,d){return arguments.length===0?this.unbind("live"):this.die(b,null,d,a)},trigger:function(a,b){return this.each(function(){c.event.trigger(a,b,this)})},triggerHandler:function(a,b){if(this[0]){var d=c.Event(a);d.preventDefault();d.stopPropagation();c.event.trigger(d,b,this[0]);return d.result}},toggle:function(a){for(var b=arguments,d=
+1;d<b.length;)c.proxy(a,b[d++]);return this.click(c.proxy(a,function(e){var f=(c.data(this,"lastToggle"+a.guid)||0)%d;c.data(this,"lastToggle"+a.guid,f+1);e.preventDefault();return b[f].apply(this,arguments)||false}))},hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}});var ya={focus:"focusin",blur:"focusout",mouseenter:"mouseover",mouseleave:"mouseout"};c.each(["live","die"],function(a,b){c.fn[b]=function(d,e,f,h){var l,k=0,o,x,r=h||this.selector;h=h?this:c(this.context);if(typeof d===
+"object"&&!d.preventDefault){for(l in d)h[b](l,e,d[l],r);return this}if(c.isFunction(e)){f=e;e=B}for(d=(d||"").split(" ");(l=d[k++])!=null;){o=X.exec(l);x="";if(o){x=o[0];l=l.replace(X,"")}if(l==="hover")d.push("mouseenter"+x,"mouseleave"+x);else{o=l;if(l==="focus"||l==="blur"){d.push(ya[l]+x);l+=x}else l=(ya[l]||l)+x;if(b==="live"){x=0;for(var A=h.length;x<A;x++)c.event.add(h[x],"live."+Y(l,r),{data:e,selector:r,handler:f,origType:l,origHandler:f,preType:o})}else h.unbind("live."+Y(l,r),f)}}return this}});
+c.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error".split(" "),function(a,b){c.fn[b]=function(d,e){if(e==null){e=d;d=null}return arguments.length>0?this.bind(b,d,e):this.trigger(b)};if(c.attrFn)c.attrFn[b]=true});E.attachEvent&&!E.addEventListener&&c(E).bind("unload",function(){for(var a in c.cache)if(c.cache[a].handle)try{c.event.remove(c.cache[a].handle.elem)}catch(b){}});
+(function(){function a(g,i,n,m,p,q){p=0;for(var u=m.length;p<u;p++){var y=m[p];if(y){var F=false;for(y=y[g];y;){if(y.sizcache===n){F=m[y.sizset];break}if(y.nodeType===1&&!q){y.sizcache=n;y.sizset=p}if(y.nodeName.toLowerCase()===i){F=y;break}y=y[g]}m[p]=F}}}function b(g,i,n,m,p,q){p=0;for(var u=m.length;p<u;p++){var y=m[p];if(y){var F=false;for(y=y[g];y;){if(y.sizcache===n){F=m[y.sizset];break}if(y.nodeType===1){if(!q){y.sizcache=n;y.sizset=p}if(typeof i!=="string"){if(y===i){F=true;break}}else if(k.filter(i,
+[y]).length>0){F=y;break}}y=y[g]}m[p]=F}}}var d=/((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[^\[\]'"]+)+\]|\\.|[^ >+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g,e=0,f=Object.prototype.toString,h=false,l=true;[0,0].sort(function(){l=false;return 0});var k=function(g,i,n,m){n=n||[];var p=i=i||t;if(i.nodeType!==1&&i.nodeType!==9)return[];if(!g||typeof g!=="string")return n;var q,u,y,F,M,N=true,O=k.isXML(i),D=[],R=g;do{d.exec("");if(q=d.exec(R)){R=q[3];D.push(q[1]);if(q[2]){F=q[3];
+break}}}while(q);if(D.length>1&&x.exec(g))if(D.length===2&&o.relative[D[0]])u=L(D[0]+D[1],i);else for(u=o.relative[D[0]]?[i]:k(D.shift(),i);D.length;){g=D.shift();if(o.relative[g])g+=D.shift();u=L(g,u)}else{if(!m&&D.length>1&&i.nodeType===9&&!O&&o.match.ID.test(D[0])&&!o.match.ID.test(D[D.length-1])){q=k.find(D.shift(),i,O);i=q.expr?k.filter(q.expr,q.set)[0]:q.set[0]}if(i){q=m?{expr:D.pop(),set:C(m)}:k.find(D.pop(),D.length===1&&(D[0]==="~"||D[0]==="+")&&i.parentNode?i.parentNode:i,O);u=q.expr?k.filter(q.expr,
+q.set):q.set;if(D.length>0)y=C(u);else N=false;for(;D.length;){q=M=D.pop();if(o.relative[M])q=D.pop();else M="";if(q==null)q=i;o.relative[M](y,q,O)}}else y=[]}y||(y=u);y||k.error(M||g);if(f.call(y)==="[object Array]")if(N)if(i&&i.nodeType===1)for(g=0;y[g]!=null;g++){if(y[g]&&(y[g]===true||y[g].nodeType===1&&k.contains(i,y[g])))n.push(u[g])}else for(g=0;y[g]!=null;g++)y[g]&&y[g].nodeType===1&&n.push(u[g]);else n.push.apply(n,y);else C(y,n);if(F){k(F,p,n,m);k.uniqueSort(n)}return n};k.uniqueSort=function(g){if(w){h=
+l;g.sort(w);if(h)for(var i=1;i<g.length;i++)g[i]===g[i-1]&&g.splice(i--,1)}return g};k.matches=function(g,i){return k(g,null,null,i)};k.matchesSelector=function(g,i){return k(i,null,null,[g]).length>0};k.find=function(g,i,n){var m;if(!g)return[];for(var p=0,q=o.order.length;p<q;p++){var u,y=o.order[p];if(u=o.leftMatch[y].exec(g)){var F=u[1];u.splice(1,1);if(F.substr(F.length-1)!=="\\"){u[1]=(u[1]||"").replace(/\\/g,"");m=o.find[y](u,i,n);if(m!=null){g=g.replace(o.match[y],"");break}}}}m||(m=i.getElementsByTagName("*"));
+return{set:m,expr:g}};k.filter=function(g,i,n,m){for(var p,q,u=g,y=[],F=i,M=i&&i[0]&&k.isXML(i[0]);g&&i.length;){for(var N in o.filter)if((p=o.leftMatch[N].exec(g))!=null&&p[2]){var O,D,R=o.filter[N];D=p[1];q=false;p.splice(1,1);if(D.substr(D.length-1)!=="\\"){if(F===y)y=[];if(o.preFilter[N])if(p=o.preFilter[N](p,F,n,y,m,M)){if(p===true)continue}else q=O=true;if(p)for(var j=0;(D=F[j])!=null;j++)if(D){O=R(D,p,j,F);var s=m^!!O;if(n&&O!=null)if(s)q=true;else F[j]=false;else if(s){y.push(D);q=true}}if(O!==
+B){n||(F=y);g=g.replace(o.match[N],"");if(!q)return[];break}}}if(g===u)if(q==null)k.error(g);else break;u=g}return F};k.error=function(g){throw"Syntax error, unrecognized expression: "+g;};var o=k.selectors={order:["ID","NAME","TAG"],match:{ID:/#((?:[\w\u00c0-\uFFFF\-]|\\.)+)/,CLASS:/\.((?:[\w\u00c0-\uFFFF\-]|\\.)+)/,NAME:/\[name=['"]*((?:[\w\u00c0-\uFFFF\-]|\\.)+)['"]*\]/,ATTR:/\[\s*((?:[\w\u00c0-\uFFFF\-]|\\.)+)\s*(?:(\S?=)\s*(['"]*)(.*?)\3|)\s*\]/,TAG:/^((?:[\w\u00c0-\uFFFF\*\-]|\\.)+)/,CHILD:/:(only|nth|last|first)-child(?:\((even|odd|[\dn+\-]*)\))?/,
+POS:/:(nth|eq|gt|lt|first|last|even|odd)(?:\((\d*)\))?(?=[^\-]|$)/,PSEUDO:/:((?:[\w\u00c0-\uFFFF\-]|\\.)+)(?:\((['"]?)((?:\([^\)]+\)|[^\(\)]*)+)\2\))?/},leftMatch:{},attrMap:{"class":"className","for":"htmlFor"},attrHandle:{href:function(g){return g.getAttribute("href")}},relative:{"+":function(g,i){var n=typeof i==="string",m=n&&!/\W/.test(i);n=n&&!m;if(m)i=i.toLowerCase();m=0;for(var p=g.length,q;m<p;m++)if(q=g[m]){for(;(q=q.previousSibling)&&q.nodeType!==1;);g[m]=n||q&&q.nodeName.toLowerCase()===
+i?q||false:q===i}n&&k.filter(i,g,true)},">":function(g,i){var n,m=typeof i==="string",p=0,q=g.length;if(m&&!/\W/.test(i))for(i=i.toLowerCase();p<q;p++){if(n=g[p]){n=n.parentNode;g[p]=n.nodeName.toLowerCase()===i?n:false}}else{for(;p<q;p++)if(n=g[p])g[p]=m?n.parentNode:n.parentNode===i;m&&k.filter(i,g,true)}},"":function(g,i,n){var m,p=e++,q=b;if(typeof i==="string"&&!/\W/.test(i)){m=i=i.toLowerCase();q=a}q("parentNode",i,p,g,m,n)},"~":function(g,i,n){var m,p=e++,q=b;if(typeof i==="string"&&!/\W/.test(i)){m=
+i=i.toLowerCase();q=a}q("previousSibling",i,p,g,m,n)}},find:{ID:function(g,i,n){if(typeof i.getElementById!=="undefined"&&!n)return(g=i.getElementById(g[1]))&&g.parentNode?[g]:[]},NAME:function(g,i){if(typeof i.getElementsByName!=="undefined"){for(var n=[],m=i.getElementsByName(g[1]),p=0,q=m.length;p<q;p++)m[p].getAttribute("name")===g[1]&&n.push(m[p]);return n.length===0?null:n}},TAG:function(g,i){return i.getElementsByTagName(g[1])}},preFilter:{CLASS:function(g,i,n,m,p,q){g=" "+g[1].replace(/\\/g,
+"")+" ";if(q)return g;q=0;for(var u;(u=i[q])!=null;q++)if(u)if(p^(u.className&&(" "+u.className+" ").replace(/[\t\n]/g," ").indexOf(g)>=0))n||m.push(u);else if(n)i[q]=false;return false},ID:function(g){return g[1].replace(/\\/g,"")},TAG:function(g){return g[1].toLowerCase()},CHILD:function(g){if(g[1]==="nth"){var i=/(-?)(\d*)n((?:\+|-)?\d*)/.exec(g[2]==="even"&&"2n"||g[2]==="odd"&&"2n+1"||!/\D/.test(g[2])&&"0n+"+g[2]||g[2]);g[2]=i[1]+(i[2]||1)-0;g[3]=i[3]-0}g[0]=e++;return g},ATTR:function(g,i,n,
+m,p,q){i=g[1].replace(/\\/g,"");if(!q&&o.attrMap[i])g[1]=o.attrMap[i];if(g[2]==="~=")g[4]=" "+g[4]+" ";return g},PSEUDO:function(g,i,n,m,p){if(g[1]==="not")if((d.exec(g[3])||"").length>1||/^\w/.test(g[3]))g[3]=k(g[3],null,null,i);else{g=k.filter(g[3],i,n,true^p);n||m.push.apply(m,g);return false}else if(o.match.POS.test(g[0])||o.match.CHILD.test(g[0]))return true;return g},POS:function(g){g.unshift(true);return g}},filters:{enabled:function(g){return g.disabled===false&&g.type!=="hidden"},disabled:function(g){return g.disabled===
+true},checked:function(g){return g.checked===true},selected:function(g){return g.selected===true},parent:function(g){return!!g.firstChild},empty:function(g){return!g.firstChild},has:function(g,i,n){return!!k(n[3],g).length},header:function(g){return/h\d/i.test(g.nodeName)},text:function(g){return"text"===g.type},radio:function(g){return"radio"===g.type},checkbox:function(g){return"checkbox"===g.type},file:function(g){return"file"===g.type},password:function(g){return"password"===g.type},submit:function(g){return"submit"===
+g.type},image:function(g){return"image"===g.type},reset:function(g){return"reset"===g.type},button:function(g){return"button"===g.type||g.nodeName.toLowerCase()==="button"},input:function(g){return/input|select|textarea|button/i.test(g.nodeName)}},setFilters:{first:function(g,i){return i===0},last:function(g,i,n,m){return i===m.length-1},even:function(g,i){return i%2===0},odd:function(g,i){return i%2===1},lt:function(g,i,n){return i<n[3]-0},gt:function(g,i,n){return i>n[3]-0},nth:function(g,i,n){return n[3]-
+0===i},eq:function(g,i,n){return n[3]-0===i}},filter:{PSEUDO:function(g,i,n,m){var p=i[1],q=o.filters[p];if(q)return q(g,n,i,m);else if(p==="contains")return(g.textContent||g.innerText||k.getText([g])||"").indexOf(i[3])>=0;else if(p==="not"){i=i[3];n=0;for(m=i.length;n<m;n++)if(i[n]===g)return false;return true}else k.error("Syntax error, unrecognized expression: "+p)},CHILD:function(g,i){var n=i[1],m=g;switch(n){case "only":case "first":for(;m=m.previousSibling;)if(m.nodeType===1)return false;if(n===
+"first")return true;m=g;case "last":for(;m=m.nextSibling;)if(m.nodeType===1)return false;return true;case "nth":n=i[2];var p=i[3];if(n===1&&p===0)return true;var q=i[0],u=g.parentNode;if(u&&(u.sizcache!==q||!g.nodeIndex)){var y=0;for(m=u.firstChild;m;m=m.nextSibling)if(m.nodeType===1)m.nodeIndex=++y;u.sizcache=q}m=g.nodeIndex-p;return n===0?m===0:m%n===0&&m/n>=0}},ID:function(g,i){return g.nodeType===1&&g.getAttribute("id")===i},TAG:function(g,i){return i==="*"&&g.nodeType===1||g.nodeName.toLowerCase()===
+i},CLASS:function(g,i){return(" "+(g.className||g.getAttribute("class"))+" ").indexOf(i)>-1},ATTR:function(g,i){var n=i[1];n=o.attrHandle[n]?o.attrHandle[n](g):g[n]!=null?g[n]:g.getAttribute(n);var m=n+"",p=i[2],q=i[4];return n==null?p==="!=":p==="="?m===q:p==="*="?m.indexOf(q)>=0:p==="~="?(" "+m+" ").indexOf(q)>=0:!q?m&&n!==false:p==="!="?m!==q:p==="^="?m.indexOf(q)===0:p==="$="?m.substr(m.length-q.length)===q:p==="|="?m===q||m.substr(0,q.length+1)===q+"-":false},POS:function(g,i,n,m){var p=o.setFilters[i[2]];
+if(p)return p(g,n,i,m)}}},x=o.match.POS,r=function(g,i){return"\\"+(i-0+1)},A;for(A in o.match){o.match[A]=RegExp(o.match[A].source+/(?![^\[]*\])(?![^\(]*\))/.source);o.leftMatch[A]=RegExp(/(^(?:.|\r|\n)*?)/.source+o.match[A].source.replace(/\\(\d+)/g,r))}var C=function(g,i){g=Array.prototype.slice.call(g,0);if(i){i.push.apply(i,g);return i}return g};try{Array.prototype.slice.call(t.documentElement.childNodes,0)}catch(J){C=function(g,i){var n=0,m=i||[];if(f.call(g)==="[object Array]")Array.prototype.push.apply(m,
+g);else if(typeof g.length==="number")for(var p=g.length;n<p;n++)m.push(g[n]);else for(;g[n];n++)m.push(g[n]);return m}}var w,I;if(t.documentElement.compareDocumentPosition)w=function(g,i){if(g===i){h=true;return 0}if(!g.compareDocumentPosition||!i.compareDocumentPosition)return g.compareDocumentPosition?-1:1;return g.compareDocumentPosition(i)&4?-1:1};else{w=function(g,i){var n,m,p=[],q=[];n=g.parentNode;m=i.parentNode;var u=n;if(g===i){h=true;return 0}else if(n===m)return I(g,i);else if(n){if(!m)return 1}else return-1;
+for(;u;){p.unshift(u);u=u.parentNode}for(u=m;u;){q.unshift(u);u=u.parentNode}n=p.length;m=q.length;for(u=0;u<n&&u<m;u++)if(p[u]!==q[u])return I(p[u],q[u]);return u===n?I(g,q[u],-1):I(p[u],i,1)};I=function(g,i,n){if(g===i)return n;for(g=g.nextSibling;g;){if(g===i)return-1;g=g.nextSibling}return 1}}k.getText=function(g){for(var i="",n,m=0;g[m];m++){n=g[m];if(n.nodeType===3||n.nodeType===4)i+=n.nodeValue;else if(n.nodeType!==8)i+=k.getText(n.childNodes)}return i};(function(){var g=t.createElement("div"),
+i="script"+(new Date).getTime(),n=t.documentElement;g.innerHTML="<a name='"+i+"'/>";n.insertBefore(g,n.firstChild);if(t.getElementById(i)){o.find.ID=function(m,p,q){if(typeof p.getElementById!=="undefined"&&!q)return(p=p.getElementById(m[1]))?p.id===m[1]||typeof p.getAttributeNode!=="undefined"&&p.getAttributeNode("id").nodeValue===m[1]?[p]:B:[]};o.filter.ID=function(m,p){var q=typeof m.getAttributeNode!=="undefined"&&m.getAttributeNode("id");return m.nodeType===1&&q&&q.nodeValue===p}}n.removeChild(g);
+n=g=null})();(function(){var g=t.createElement("div");g.appendChild(t.createComment(""));if(g.getElementsByTagName("*").length>0)o.find.TAG=function(i,n){var m=n.getElementsByTagName(i[1]);if(i[1]==="*"){for(var p=[],q=0;m[q];q++)m[q].nodeType===1&&p.push(m[q]);m=p}return m};g.innerHTML="<a href='#'></a>";if(g.firstChild&&typeof g.firstChild.getAttribute!=="undefined"&&g.firstChild.getAttribute("href")!=="#")o.attrHandle.href=function(i){return i.getAttribute("href",2)};g=null})();t.querySelectorAll&&
+function(){var g=k,i=t.createElement("div");i.innerHTML="<p class='TEST'></p>";if(!(i.querySelectorAll&&i.querySelectorAll(".TEST").length===0)){k=function(m,p,q,u){p=p||t;m=m.replace(/\=\s*([^'"\]]*)\s*\]/g,"='$1']");if(!u&&!k.isXML(p))if(p.nodeType===9)try{return C(p.querySelectorAll(m),q)}catch(y){}else if(p.nodeType===1&&p.nodeName.toLowerCase()!=="object"){var F=p.getAttribute("id"),M=F||"__sizzle__";F||p.setAttribute("id",M);try{return C(p.querySelectorAll("#"+M+" "+m),q)}catch(N){}finally{F||
+p.removeAttribute("id")}}return g(m,p,q,u)};for(var n in g)k[n]=g[n];i=null}}();(function(){var g=t.documentElement,i=g.matchesSelector||g.mozMatchesSelector||g.webkitMatchesSelector||g.msMatchesSelector,n=false;try{i.call(t.documentElement,"[test!='']:sizzle")}catch(m){n=true}if(i)k.matchesSelector=function(p,q){q=q.replace(/\=\s*([^'"\]]*)\s*\]/g,"='$1']");if(!k.isXML(p))try{if(n||!o.match.PSEUDO.test(q)&&!/!=/.test(q))return i.call(p,q)}catch(u){}return k(q,null,null,[p]).length>0}})();(function(){var g=
+t.createElement("div");g.innerHTML="<div class='test e'></div><div class='test'></div>";if(!(!g.getElementsByClassName||g.getElementsByClassName("e").length===0)){g.lastChild.className="e";if(g.getElementsByClassName("e").length!==1){o.order.splice(1,0,"CLASS");o.find.CLASS=function(i,n,m){if(typeof n.getElementsByClassName!=="undefined"&&!m)return n.getElementsByClassName(i[1])};g=null}}})();k.contains=t.documentElement.contains?function(g,i){return g!==i&&(g.contains?g.contains(i):true)}:t.documentElement.compareDocumentPosition?
+function(g,i){return!!(g.compareDocumentPosition(i)&16)}:function(){return false};k.isXML=function(g){return(g=(g?g.ownerDocument||g:0).documentElement)?g.nodeName!=="HTML":false};var L=function(g,i){for(var n,m=[],p="",q=i.nodeType?[i]:i;n=o.match.PSEUDO.exec(g);){p+=n[0];g=g.replace(o.match.PSEUDO,"")}g=o.relative[g]?g+"*":g;n=0;for(var u=q.length;n<u;n++)k(g,q[n],m);return k.filter(p,m)};c.find=k;c.expr=k.selectors;c.expr[":"]=c.expr.filters;c.unique=k.uniqueSort;c.text=k.getText;c.isXMLDoc=k.isXML;
+c.contains=k.contains})();var Za=/Until$/,$a=/^(?:parents|prevUntil|prevAll)/,ab=/,/,Na=/^.[^:#\[\.,]*$/,bb=Array.prototype.slice,cb=c.expr.match.POS;c.fn.extend({find:function(a){for(var b=this.pushStack("","find",a),d=0,e=0,f=this.length;e<f;e++){d=b.length;c.find(a,this[e],b);if(e>0)for(var h=d;h<b.length;h++)for(var l=0;l<d;l++)if(b[l]===b[h]){b.splice(h--,1);break}}return b},has:function(a){var b=c(a);return this.filter(function(){for(var d=0,e=b.length;d<e;d++)if(c.contains(this,b[d]))return true})},
+not:function(a){return this.pushStack(ma(this,a,false),"not",a)},filter:function(a){return this.pushStack(ma(this,a,true),"filter",a)},is:function(a){return!!a&&c.filter(a,this).length>0},closest:function(a,b){var d=[],e,f,h=this[0];if(c.isArray(a)){var l,k={},o=1;if(h&&a.length){e=0;for(f=a.length;e<f;e++){l=a[e];k[l]||(k[l]=c.expr.match.POS.test(l)?c(l,b||this.context):l)}for(;h&&h.ownerDocument&&h!==b;){for(l in k){e=k[l];if(e.jquery?e.index(h)>-1:c(h).is(e))d.push({selector:l,elem:h,level:o})}h=
+h.parentNode;o++}}return d}l=cb.test(a)?c(a,b||this.context):null;e=0;for(f=this.length;e<f;e++)for(h=this[e];h;)if(l?l.index(h)>-1:c.find.matchesSelector(h,a)){d.push(h);break}else{h=h.parentNode;if(!h||!h.ownerDocument||h===b)break}d=d.length>1?c.unique(d):d;return this.pushStack(d,"closest",a)},index:function(a){if(!a||typeof a==="string")return c.inArray(this[0],a?c(a):this.parent().children());return c.inArray(a.jquery?a[0]:a,this)},add:function(a,b){var d=typeof a==="string"?c(a,b||this.context):
+c.makeArray(a),e=c.merge(this.get(),d);return this.pushStack(!d[0]||!d[0].parentNode||d[0].parentNode.nodeType===11||!e[0]||!e[0].parentNode||e[0].parentNode.nodeType===11?e:c.unique(e))},andSelf:function(){return this.add(this.prevObject)}});c.each({parent:function(a){return(a=a.parentNode)&&a.nodeType!==11?a:null},parents:function(a){return c.dir(a,"parentNode")},parentsUntil:function(a,b,d){return c.dir(a,"parentNode",d)},next:function(a){return c.nth(a,2,"nextSibling")},prev:function(a){return c.nth(a,
+2,"previousSibling")},nextAll:function(a){return c.dir(a,"nextSibling")},prevAll:function(a){return c.dir(a,"previousSibling")},nextUntil:function(a,b,d){return c.dir(a,"nextSibling",d)},prevUntil:function(a,b,d){return c.dir(a,"previousSibling",d)},siblings:function(a){return c.sibling(a.parentNode.firstChild,a)},children:function(a){return c.sibling(a.firstChild)},contents:function(a){return c.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:c.makeArray(a.childNodes)}},function(a,
+b){c.fn[a]=function(d,e){var f=c.map(this,b,d);Za.test(a)||(e=d);if(e&&typeof e==="string")f=c.filter(e,f);f=this.length>1?c.unique(f):f;if((this.length>1||ab.test(e))&&$a.test(a))f=f.reverse();return this.pushStack(f,a,bb.call(arguments).join(","))}});c.extend({filter:function(a,b,d){if(d)a=":not("+a+")";return b.length===1?c.find.matchesSelector(b[0],a)?[b[0]]:[]:c.find.matches(a,b)},dir:function(a,b,d){var e=[];for(a=a[b];a&&a.nodeType!==9&&(d===B||a.nodeType!==1||!c(a).is(d));){a.nodeType===1&&
+e.push(a);a=a[b]}return e},nth:function(a,b,d){b=b||1;for(var e=0;a;a=a[d])if(a.nodeType===1&&++e===b)break;return a},sibling:function(a,b){for(var d=[];a;a=a.nextSibling)a.nodeType===1&&a!==b&&d.push(a);return d}});var za=/ jQuery\d+="(?:\d+|null)"/g,$=/^\s+/,Aa=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig,Ba=/<([\w:]+)/,db=/<tbody/i,eb=/<|&#?\w+;/,Ca=/<(?:script|object|embed|option|style)/i,Da=/checked\s*(?:[^=]|=\s*.checked.)/i,fb=/\=([^="'>\s]+\/)>/g,P={option:[1,
+"<select multiple='multiple'>","</select>"],legend:[1,"<fieldset>","</fieldset>"],thead:[1,"<table>","</table>"],tr:[2,"<table><tbody>","</tbody></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],area:[1,"<map>","</map>"],_default:[0,"",""]};P.optgroup=P.option;P.tbody=P.tfoot=P.colgroup=P.caption=P.thead;P.th=P.td;if(!c.support.htmlSerialize)P._default=[1,"div<div>","</div>"];c.fn.extend({text:function(a){if(c.isFunction(a))return this.each(function(b){var d=
+c(this);d.text(a.call(this,b,d.text()))});if(typeof a!=="object"&&a!==B)return this.empty().append((this[0]&&this[0].ownerDocument||t).createTextNode(a));return c.text(this)},wrapAll:function(a){if(c.isFunction(a))return this.each(function(d){c(this).wrapAll(a.call(this,d))});if(this[0]){var b=c(a,this[0].ownerDocument).eq(0).clone(true);this[0].parentNode&&b.insertBefore(this[0]);b.map(function(){for(var d=this;d.firstChild&&d.firstChild.nodeType===1;)d=d.firstChild;return d}).append(this)}return this},
+wrapInner:function(a){if(c.isFunction(a))return this.each(function(b){c(this).wrapInner(a.call(this,b))});return this.each(function(){var b=c(this),d=b.contents();d.length?d.wrapAll(a):b.append(a)})},wrap:function(a){return this.each(function(){c(this).wrapAll(a)})},unwrap:function(){return this.parent().each(function(){c.nodeName(this,"body")||c(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.appendChild(a)})},
+prepend:function(){return this.domManip(arguments,true,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,this)});else if(arguments.length){var a=c(arguments[0]);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,false,function(b){this.parentNode.insertBefore(b,
+this.nextSibling)});else if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,c(arguments[0]).toArray());return a}},remove:function(a,b){for(var d=0,e;(e=this[d])!=null;d++)if(!a||c.filter(a,[e]).length){if(!b&&e.nodeType===1){c.cleanData(e.getElementsByTagName("*"));c.cleanData([e])}e.parentNode&&e.parentNode.removeChild(e)}return this},empty:function(){for(var a=0,b;(b=this[a])!=null;a++)for(b.nodeType===1&&c.cleanData(b.getElementsByTagName("*"));b.firstChild;)b.removeChild(b.firstChild);
+return this},clone:function(a){var b=this.map(function(){if(!c.support.noCloneEvent&&!c.isXMLDoc(this)){var d=this.outerHTML,e=this.ownerDocument;if(!d){d=e.createElement("div");d.appendChild(this.cloneNode(true));d=d.innerHTML}return c.clean([d.replace(za,"").replace(fb,'="$1">').replace($,"")],e)[0]}else return this.cloneNode(true)});if(a===true){na(this,b);na(this.find("*"),b.find("*"))}return b},html:function(a){if(a===B)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(za,""):null;
+else if(typeof a==="string"&&!Ca.test(a)&&(c.support.leadingWhitespace||!$.test(a))&&!P[(Ba.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Aa,"<$1></$2>");try{for(var b=0,d=this.length;b<d;b++)if(this[b].nodeType===1){c.cleanData(this[b].getElementsByTagName("*"));this[b].innerHTML=a}}catch(e){this.empty().append(a)}}else c.isFunction(a)?this.each(function(f){var h=c(this);h.html(a.call(this,f,h.html()))}):this.empty().append(a);return this},replaceWith:function(a){if(this[0]&&this[0].parentNode){if(c.isFunction(a))return this.each(function(b){var d=
+c(this),e=d.html();d.replaceWith(a.call(this,b,e))});if(typeof a!=="string")a=c(a).detach();return this.each(function(){var b=this.nextSibling,d=this.parentNode;c(this).remove();b?c(b).before(a):c(d).append(a)})}else return this.pushStack(c(c.isFunction(a)?a():a),"replaceWith",a)},detach:function(a){return this.remove(a,true)},domManip:function(a,b,d){var e,f,h,l=a[0],k=[];if(!c.support.checkClone&&arguments.length===3&&typeof l==="string"&&Da.test(l))return this.each(function(){c(this).domManip(a,
+b,d,true)});if(c.isFunction(l))return this.each(function(x){var r=c(this);a[0]=l.call(this,x,b?r.html():B);r.domManip(a,b,d)});if(this[0]){e=l&&l.parentNode;e=c.support.parentNode&&e&&e.nodeType===11&&e.childNodes.length===this.length?{fragment:e}:c.buildFragment(a,this,k);h=e.fragment;if(f=h.childNodes.length===1?h=h.firstChild:h.firstChild){b=b&&c.nodeName(f,"tr");f=0;for(var o=this.length;f<o;f++)d.call(b?c.nodeName(this[f],"table")?this[f].getElementsByTagName("tbody")[0]||this[f].appendChild(this[f].ownerDocument.createElement("tbody")):
+this[f]:this[f],f>0||e.cacheable||this.length>1?h.cloneNode(true):h)}k.length&&c.each(k,Oa)}return this}});c.buildFragment=function(a,b,d){var e,f,h;b=b&&b[0]?b[0].ownerDocument||b[0]:t;if(a.length===1&&typeof a[0]==="string"&&a[0].length<512&&b===t&&!Ca.test(a[0])&&(c.support.checkClone||!Da.test(a[0]))){f=true;if(h=c.fragments[a[0]])if(h!==1)e=h}if(!e){e=b.createDocumentFragment();c.clean(a,b,e,d)}if(f)c.fragments[a[0]]=h?e:1;return{fragment:e,cacheable:f}};c.fragments={};c.each({appendTo:"append",
+prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){c.fn[a]=function(d){var e=[];d=c(d);var f=this.length===1&&this[0].parentNode;if(f&&f.nodeType===11&&f.childNodes.length===1&&d.length===1){d[b](this[0]);return this}else{f=0;for(var h=d.length;f<h;f++){var l=(f>0?this.clone(true):this).get();c(d[f])[b](l);e=e.concat(l)}return this.pushStack(e,a,d.selector)}}});c.extend({clean:function(a,b,d,e){b=b||t;if(typeof b.createElement==="undefined")b=b.ownerDocument||
+b[0]&&b[0].ownerDocument||t;for(var f=[],h=0,l;(l=a[h])!=null;h++){if(typeof l==="number")l+="";if(l){if(typeof l==="string"&&!eb.test(l))l=b.createTextNode(l);else if(typeof l==="string"){l=l.replace(Aa,"<$1></$2>");var k=(Ba.exec(l)||["",""])[1].toLowerCase(),o=P[k]||P._default,x=o[0],r=b.createElement("div");for(r.innerHTML=o[1]+l+o[2];x--;)r=r.lastChild;if(!c.support.tbody){x=db.test(l);k=k==="table"&&!x?r.firstChild&&r.firstChild.childNodes:o[1]==="<table>"&&!x?r.childNodes:[];for(o=k.length-
+1;o>=0;--o)c.nodeName(k[o],"tbody")&&!k[o].childNodes.length&&k[o].parentNode.removeChild(k[o])}!c.support.leadingWhitespace&&$.test(l)&&r.insertBefore(b.createTextNode($.exec(l)[0]),r.firstChild);l=r.childNodes}if(l.nodeType)f.push(l);else f=c.merge(f,l)}}if(d)for(h=0;f[h];h++)if(e&&c.nodeName(f[h],"script")&&(!f[h].type||f[h].type.toLowerCase()==="text/javascript"))e.push(f[h].parentNode?f[h].parentNode.removeChild(f[h]):f[h]);else{f[h].nodeType===1&&f.splice.apply(f,[h+1,0].concat(c.makeArray(f[h].getElementsByTagName("script"))));
+d.appendChild(f[h])}return f},cleanData:function(a){for(var b,d,e=c.cache,f=c.event.special,h=c.support.deleteExpando,l=0,k;(k=a[l])!=null;l++)if(!(k.nodeName&&c.noData[k.nodeName.toLowerCase()]))if(d=k[c.expando]){if((b=e[d])&&b.events)for(var o in b.events)f[o]?c.event.remove(k,o):c.removeEvent(k,o,b.handle);if(h)delete k[c.expando];else k.removeAttribute&&k.removeAttribute(c.expando);delete e[d]}}});var Ea=/alpha\([^)]*\)/i,gb=/opacity=([^)]*)/,hb=/-([a-z])/ig,ib=/([A-Z])/g,Fa=/^-?\d+(?:px)?$/i,
+jb=/^-?\d/,kb={position:"absolute",visibility:"hidden",display:"block"},Pa=["Left","Right"],Qa=["Top","Bottom"],W,Ga,aa,lb=function(a,b){return b.toUpperCase()};c.fn.css=function(a,b){if(arguments.length===2&&b===B)return this;return c.access(this,a,b,true,function(d,e,f){return f!==B?c.style(d,e,f):c.css(d,e)})};c.extend({cssHooks:{opacity:{get:function(a,b){if(b){var d=W(a,"opacity","opacity");return d===""?"1":d}else return a.style.opacity}}},cssNumber:{zIndex:true,fontWeight:true,opacity:true,
+zoom:true,lineHeight:true},cssProps:{"float":c.support.cssFloat?"cssFloat":"styleFloat"},style:function(a,b,d,e){if(!(!a||a.nodeType===3||a.nodeType===8||!a.style)){var f,h=c.camelCase(b),l=a.style,k=c.cssHooks[h];b=c.cssProps[h]||h;if(d!==B){if(!(typeof d==="number"&&isNaN(d)||d==null)){if(typeof d==="number"&&!c.cssNumber[h])d+="px";if(!k||!("set"in k)||(d=k.set(a,d))!==B)try{l[b]=d}catch(o){}}}else{if(k&&"get"in k&&(f=k.get(a,false,e))!==B)return f;return l[b]}}},css:function(a,b,d){var e,f=c.camelCase(b),
+h=c.cssHooks[f];b=c.cssProps[f]||f;if(h&&"get"in h&&(e=h.get(a,true,d))!==B)return e;else if(W)return W(a,b,f)},swap:function(a,b,d){var e={},f;for(f in b){e[f]=a.style[f];a.style[f]=b[f]}d.call(a);for(f in b)a.style[f]=e[f]},camelCase:function(a){return a.replace(hb,lb)}});c.curCSS=c.css;c.each(["height","width"],function(a,b){c.cssHooks[b]={get:function(d,e,f){var h;if(e){if(d.offsetWidth!==0)h=oa(d,b,f);else c.swap(d,kb,function(){h=oa(d,b,f)});if(h<=0){h=W(d,b,b);if(h==="0px"&&aa)h=aa(d,b,b);
+if(h!=null)return h===""||h==="auto"?"0px":h}if(h<0||h==null){h=d.style[b];return h===""||h==="auto"?"0px":h}return typeof h==="string"?h:h+"px"}},set:function(d,e){if(Fa.test(e)){e=parseFloat(e);if(e>=0)return e+"px"}else return e}}});if(!c.support.opacity)c.cssHooks.opacity={get:function(a,b){return gb.test((b&&a.currentStyle?a.currentStyle.filter:a.style.filter)||"")?parseFloat(RegExp.$1)/100+"":b?"1":""},set:function(a,b){var d=a.style;d.zoom=1;var e=c.isNaN(b)?"":"alpha(opacity="+b*100+")",f=
+d.filter||"";d.filter=Ea.test(f)?f.replace(Ea,e):d.filter+" "+e}};if(t.defaultView&&t.defaultView.getComputedStyle)Ga=function(a,b,d){var e;d=d.replace(ib,"-$1").toLowerCase();if(!(b=a.ownerDocument.defaultView))return B;if(b=b.getComputedStyle(a,null)){e=b.getPropertyValue(d);if(e===""&&!c.contains(a.ownerDocument.documentElement,a))e=c.style(a,d)}return e};if(t.documentElement.currentStyle)aa=function(a,b){var d,e,f=a.currentStyle&&a.currentStyle[b],h=a.style;if(!Fa.test(f)&&jb.test(f)){d=h.left;
+e=a.runtimeStyle.left;a.runtimeStyle.left=a.currentStyle.left;h.left=b==="fontSize"?"1em":f||0;f=h.pixelLeft+"px";h.left=d;a.runtimeStyle.left=e}return f===""?"auto":f};W=Ga||aa;if(c.expr&&c.expr.filters){c.expr.filters.hidden=function(a){var b=a.offsetHeight;return a.offsetWidth===0&&b===0||!c.support.reliableHiddenOffsets&&(a.style.display||c.css(a,"display"))==="none"};c.expr.filters.visible=function(a){return!c.expr.filters.hidden(a)}}var mb=c.now(),nb=/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
+ob=/^(?:select|textarea)/i,pb=/^(?:color|date|datetime|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i,qb=/^(?:GET|HEAD)$/,Ra=/\[\]$/,T=/\=\?(&|$)/,ja=/\?/,rb=/([?&])_=[^&]*/,sb=/^(\w+:)?\/\/([^\/?#]+)/,tb=/%20/g,ub=/#.*$/,Ha=c.fn.load;c.fn.extend({load:function(a,b,d){if(typeof a!=="string"&&Ha)return Ha.apply(this,arguments);else if(!this.length)return this;var e=a.indexOf(" ");if(e>=0){var f=a.slice(e,a.length);a=a.slice(0,e)}e="GET";if(b)if(c.isFunction(b)){d=b;b=null}else if(typeof b===
+"object"){b=c.param(b,c.ajaxSettings.traditional);e="POST"}var h=this;c.ajax({url:a,type:e,dataType:"html",data:b,complete:function(l,k){if(k==="success"||k==="notmodified")h.html(f?c("<div>").append(l.responseText.replace(nb,"")).find(f):l.responseText);d&&h.each(d,[l.responseText,k,l])}});return this},serialize:function(){return c.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?c.makeArray(this.elements):this}).filter(function(){return this.name&&
+!this.disabled&&(this.checked||ob.test(this.nodeName)||pb.test(this.type))}).map(function(a,b){var d=c(this).val();return d==null?null:c.isArray(d)?c.map(d,function(e){return{name:b.name,value:e}}):{name:b.name,value:d}}).get()}});c.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "),function(a,b){c.fn[b]=function(d){return this.bind(b,d)}});c.extend({get:function(a,b,d,e){if(c.isFunction(b)){e=e||d;d=b;b=null}return c.ajax({type:"GET",url:a,data:b,success:d,dataType:e})},
+getScript:function(a,b){return c.get(a,null,b,"script")},getJSON:function(a,b,d){return c.get(a,b,d,"json")},post:function(a,b,d,e){if(c.isFunction(b)){e=e||d;d=b;b={}}return c.ajax({type:"POST",url:a,data:b,success:d,dataType:e})},ajaxSetup:function(a){c.extend(c.ajaxSettings,a)},ajaxSettings:{url:location.href,global:true,type:"GET",contentType:"application/x-www-form-urlencoded",processData:true,async:true,xhr:function(){return new E.XMLHttpRequest},accepts:{xml:"application/xml, text/xml",html:"text/html",
+script:"text/javascript, application/javascript",json:"application/json, text/javascript",text:"text/plain",_default:"*/*"}},ajax:function(a){var b=c.extend(true,{},c.ajaxSettings,a),d,e,f,h=b.type.toUpperCase(),l=qb.test(h);b.url=b.url.replace(ub,"");b.context=a&&a.context!=null?a.context:b;if(b.data&&b.processData&&typeof b.data!=="string")b.data=c.param(b.data,b.traditional);if(b.dataType==="jsonp"){if(h==="GET")T.test(b.url)||(b.url+=(ja.test(b.url)?"&":"?")+(b.jsonp||"callback")+"=?");else if(!b.data||
+!T.test(b.data))b.data=(b.data?b.data+"&":"")+(b.jsonp||"callback")+"=?";b.dataType="json"}if(b.dataType==="json"&&(b.data&&T.test(b.data)||T.test(b.url))){d=b.jsonpCallback||"jsonp"+mb++;if(b.data)b.data=(b.data+"").replace(T,"="+d+"$1");b.url=b.url.replace(T,"="+d+"$1");b.dataType="script";var k=E[d];E[d]=function(m){if(c.isFunction(k))k(m);else{E[d]=B;try{delete E[d]}catch(p){}}f=m;c.handleSuccess(b,w,e,f);c.handleComplete(b,w,e,f);r&&r.removeChild(A)}}if(b.dataType==="script"&&b.cache===null)b.cache=
+false;if(b.cache===false&&l){var o=c.now(),x=b.url.replace(rb,"$1_="+o);b.url=x+(x===b.url?(ja.test(b.url)?"&":"?")+"_="+o:"")}if(b.data&&l)b.url+=(ja.test(b.url)?"&":"?")+b.data;b.global&&c.active++===0&&c.event.trigger("ajaxStart");o=(o=sb.exec(b.url))&&(o[1]&&o[1].toLowerCase()!==location.protocol||o[2].toLowerCase()!==location.host);if(b.dataType==="script"&&h==="GET"&&o){var r=t.getElementsByTagName("head")[0]||t.documentElement,A=t.createElement("script");if(b.scriptCharset)A.charset=b.scriptCharset;
+A.src=b.url;if(!d){var C=false;A.onload=A.onreadystatechange=function(){if(!C&&(!this.readyState||this.readyState==="loaded"||this.readyState==="complete")){C=true;c.handleSuccess(b,w,e,f);c.handleComplete(b,w,e,f);A.onload=A.onreadystatechange=null;r&&A.parentNode&&r.removeChild(A)}}}r.insertBefore(A,r.firstChild);return B}var J=false,w=b.xhr();if(w){b.username?w.open(h,b.url,b.async,b.username,b.password):w.open(h,b.url,b.async);try{if(b.data!=null&&!l||a&&a.contentType)w.setRequestHeader("Content-Type",
+b.contentType);if(b.ifModified){c.lastModified[b.url]&&w.setRequestHeader("If-Modified-Since",c.lastModified[b.url]);c.etag[b.url]&&w.setRequestHeader("If-None-Match",c.etag[b.url])}o||w.setRequestHeader("X-Requested-With","XMLHttpRequest");w.setRequestHeader("Accept",b.dataType&&b.accepts[b.dataType]?b.accepts[b.dataType]+", */*; q=0.01":b.accepts._default)}catch(I){}if(b.beforeSend&&b.beforeSend.call(b.context,w,b)===false){b.global&&c.active--===1&&c.event.trigger("ajaxStop");w.abort();return false}b.global&&
+c.triggerGlobal(b,"ajaxSend",[w,b]);var L=w.onreadystatechange=function(m){if(!w||w.readyState===0||m==="abort"){J||c.handleComplete(b,w,e,f);J=true;if(w)w.onreadystatechange=c.noop}else if(!J&&w&&(w.readyState===4||m==="timeout")){J=true;w.onreadystatechange=c.noop;e=m==="timeout"?"timeout":!c.httpSuccess(w)?"error":b.ifModified&&c.httpNotModified(w,b.url)?"notmodified":"success";var p;if(e==="success")try{f=c.httpData(w,b.dataType,b)}catch(q){e="parsererror";p=q}if(e==="success"||e==="notmodified")d||
+c.handleSuccess(b,w,e,f);else c.handleError(b,w,e,p);d||c.handleComplete(b,w,e,f);m==="timeout"&&w.abort();if(b.async)w=null}};try{var g=w.abort;w.abort=function(){w&&Function.prototype.call.call(g,w);L("abort")}}catch(i){}b.async&&b.timeout>0&&setTimeout(function(){w&&!J&&L("timeout")},b.timeout);try{w.send(l||b.data==null?null:b.data)}catch(n){c.handleError(b,w,null,n);c.handleComplete(b,w,e,f)}b.async||L();return w}},param:function(a,b){var d=[],e=function(h,l){l=c.isFunction(l)?l():l;d[d.length]=
+encodeURIComponent(h)+"="+encodeURIComponent(l)};if(b===B)b=c.ajaxSettings.traditional;if(c.isArray(a)||a.jquery)c.each(a,function(){e(this.name,this.value)});else for(var f in a)da(f,a[f],b,e);return d.join("&").replace(tb,"+")}});c.extend({active:0,lastModified:{},etag:{},handleError:function(a,b,d,e){a.error&&a.error.call(a.context,b,d,e);a.global&&c.triggerGlobal(a,"ajaxError",[b,a,e])},handleSuccess:function(a,b,d,e){a.success&&a.success.call(a.context,e,d,b);a.global&&c.triggerGlobal(a,"ajaxSuccess",
+[b,a])},handleComplete:function(a,b,d){a.complete&&a.complete.call(a.context,b,d);a.global&&c.triggerGlobal(a,"ajaxComplete",[b,a]);a.global&&c.active--===1&&c.event.trigger("ajaxStop")},triggerGlobal:function(a,b,d){(a.context&&a.context.url==null?c(a.context):c.event).trigger(b,d)},httpSuccess:function(a){try{return!a.status&&location.protocol==="file:"||a.status>=200&&a.status<300||a.status===304||a.status===1223}catch(b){}return false},httpNotModified:function(a,b){var d=a.getResponseHeader("Last-Modified"),
+e=a.getResponseHeader("Etag");if(d)c.lastModified[b]=d;if(e)c.etag[b]=e;return a.status===304},httpData:function(a,b,d){var e=a.getResponseHeader("content-type")||"",f=b==="xml"||!b&&e.indexOf("xml")>=0;a=f?a.responseXML:a.responseText;f&&a.documentElement.nodeName==="parsererror"&&c.error("parsererror");if(d&&d.dataFilter)a=d.dataFilter(a,b);if(typeof a==="string")if(b==="json"||!b&&e.indexOf("json")>=0)a=c.parseJSON(a);else if(b==="script"||!b&&e.indexOf("javascript")>=0)c.globalEval(a);return a}});
+if(E.ActiveXObject)c.ajaxSettings.xhr=function(){if(E.location.protocol!=="file:")try{return new E.XMLHttpRequest}catch(a){}try{return new E.ActiveXObject("Microsoft.XMLHTTP")}catch(b){}};c.support.ajax=!!c.ajaxSettings.xhr();var ea={},vb=/^(?:toggle|show|hide)$/,wb=/^([+\-]=)?([\d+.\-]+)(.*)$/,ba,pa=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]];c.fn.extend({show:function(a,b,d){if(a||a===0)return this.animate(S("show",
+3),a,b,d);else{d=0;for(var e=this.length;d<e;d++){a=this[d];b=a.style.display;if(!c.data(a,"olddisplay")&&b==="none")b=a.style.display="";b===""&&c.css(a,"display")==="none"&&c.data(a,"olddisplay",qa(a.nodeName))}for(d=0;d<e;d++){a=this[d];b=a.style.display;if(b===""||b==="none")a.style.display=c.data(a,"olddisplay")||""}return this}},hide:function(a,b,d){if(a||a===0)return this.animate(S("hide",3),a,b,d);else{a=0;for(b=this.length;a<b;a++){d=c.css(this[a],"display");d!=="none"&&c.data(this[a],"olddisplay",
+d)}for(a=0;a<b;a++)this[a].style.display="none";return this}},_toggle:c.fn.toggle,toggle:function(a,b,d){var e=typeof a==="boolean";if(c.isFunction(a)&&c.isFunction(b))this._toggle.apply(this,arguments);else a==null||e?this.each(function(){var f=e?a:c(this).is(":hidden");c(this)[f?"show":"hide"]()}):this.animate(S("toggle",3),a,b,d);return this},fadeTo:function(a,b,d,e){return this.filter(":hidden").css("opacity",0).show().end().animate({opacity:b},a,d,e)},animate:function(a,b,d,e){var f=c.speed(b,
+d,e);if(c.isEmptyObject(a))return this.each(f.complete);return this[f.queue===false?"each":"queue"](function(){var h=c.extend({},f),l,k=this.nodeType===1,o=k&&c(this).is(":hidden"),x=this;for(l in a){var r=c.camelCase(l);if(l!==r){a[r]=a[l];delete a[l];l=r}if(a[l]==="hide"&&o||a[l]==="show"&&!o)return h.complete.call(this);if(k&&(l==="height"||l==="width")){h.overflow=[this.style.overflow,this.style.overflowX,this.style.overflowY];if(c.css(this,"display")==="inline"&&c.css(this,"float")==="none")if(c.support.inlineBlockNeedsLayout)if(qa(this.nodeName)===
+"inline")this.style.display="inline-block";else{this.style.display="inline";this.style.zoom=1}else this.style.display="inline-block"}if(c.isArray(a[l])){(h.specialEasing=h.specialEasing||{})[l]=a[l][1];a[l]=a[l][0]}}if(h.overflow!=null)this.style.overflow="hidden";h.curAnim=c.extend({},a);c.each(a,function(A,C){var J=new c.fx(x,h,A);if(vb.test(C))J[C==="toggle"?o?"show":"hide":C](a);else{var w=wb.exec(C),I=J.cur()||0;if(w){var L=parseFloat(w[2]),g=w[3]||"px";if(g!=="px"){c.style(x,A,(L||1)+g);I=(L||
+1)/J.cur()*I;c.style(x,A,I+g)}if(w[1])L=(w[1]==="-="?-1:1)*L+I;J.custom(I,L,g)}else J.custom(I,C,"")}});return true})},stop:function(a,b){var d=c.timers;a&&this.queue([]);this.each(function(){for(var e=d.length-1;e>=0;e--)if(d[e].elem===this){b&&d[e](true);d.splice(e,1)}});b||this.dequeue();return this}});c.each({slideDown:S("show",1),slideUp:S("hide",1),slideToggle:S("toggle",1),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(a,b){c.fn[a]=function(d,e,f){return this.animate(b,
+d,e,f)}});c.extend({speed:function(a,b,d){var e=a&&typeof a==="object"?c.extend({},a):{complete:d||!d&&b||c.isFunction(a)&&a,duration:a,easing:d&&b||b&&!c.isFunction(b)&&b};e.duration=c.fx.off?0:typeof e.duration==="number"?e.duration:e.duration in c.fx.speeds?c.fx.speeds[e.duration]:c.fx.speeds._default;e.old=e.complete;e.complete=function(){e.queue!==false&&c(this).dequeue();c.isFunction(e.old)&&e.old.call(this)};return e},easing:{linear:function(a,b,d,e){return d+e*a},swing:function(a,b,d,e){return(-Math.cos(a*
+Math.PI)/2+0.5)*e+d}},timers:[],fx:function(a,b,d){this.options=b;this.elem=a;this.prop=d;if(!b.orig)b.orig={}}});c.fx.prototype={update:function(){this.options.step&&this.options.step.call(this.elem,this.now,this);(c.fx.step[this.prop]||c.fx.step._default)(this)},cur:function(){if(this.elem[this.prop]!=null&&(!this.elem.style||this.elem.style[this.prop]==null))return this.elem[this.prop];var a=parseFloat(c.css(this.elem,this.prop));return a&&a>-1E4?a:0},custom:function(a,b,d){function e(l){return f.step(l)}
+var f=this,h=c.fx;this.startTime=c.now();this.start=a;this.end=b;this.unit=d||this.unit||"px";this.now=this.start;this.pos=this.state=0;e.elem=this.elem;if(e()&&c.timers.push(e)&&!ba)ba=setInterval(h.tick,h.interval)},show:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.show=true;this.custom(this.prop==="width"||this.prop==="height"?1:0,this.cur());c(this.elem).show()},hide:function(){this.options.orig[this.prop]=c.style(this.elem,this.prop);this.options.hide=true;
+this.custom(this.cur(),0)},step:function(a){var b=c.now(),d=true;if(a||b>=this.options.duration+this.startTime){this.now=this.end;this.pos=this.state=1;this.update();this.options.curAnim[this.prop]=true;for(var e in this.options.curAnim)if(this.options.curAnim[e]!==true)d=false;if(d){if(this.options.overflow!=null&&!c.support.shrinkWrapBlocks){var f=this.elem,h=this.options;c.each(["","X","Y"],function(k,o){f.style["overflow"+o]=h.overflow[k]})}this.options.hide&&c(this.elem).hide();if(this.options.hide||
+this.options.show)for(var l in this.options.curAnim)c.style(this.elem,l,this.options.orig[l]);this.options.complete.call(this.elem)}return false}else{a=b-this.startTime;this.state=a/this.options.duration;b=this.options.easing||(c.easing.swing?"swing":"linear");this.pos=c.easing[this.options.specialEasing&&this.options.specialEasing[this.prop]||b](this.state,a,0,1,this.options.duration);this.now=this.start+(this.end-this.start)*this.pos;this.update()}return true}};c.extend(c.fx,{tick:function(){for(var a=
+c.timers,b=0;b<a.length;b++)a[b]()||a.splice(b--,1);a.length||c.fx.stop()},interval:13,stop:function(){clearInterval(ba);ba=null},speeds:{slow:600,fast:200,_default:400},step:{opacity:function(a){c.style(a.elem,"opacity",a.now)},_default:function(a){if(a.elem.style&&a.elem.style[a.prop]!=null)a.elem.style[a.prop]=(a.prop==="width"||a.prop==="height"?Math.max(0,a.now):a.now)+a.unit;else a.elem[a.prop]=a.now}}});if(c.expr&&c.expr.filters)c.expr.filters.animated=function(a){return c.grep(c.timers,function(b){return a===
+b.elem}).length};var xb=/^t(?:able|d|h)$/i,Ia=/^(?:body|html)$/i;c.fn.offset="getBoundingClientRect"in t.documentElement?function(a){var b=this[0],d;if(a)return this.each(function(l){c.offset.setOffset(this,a,l)});if(!b||!b.ownerDocument)return null;if(b===b.ownerDocument.body)return c.offset.bodyOffset(b);try{d=b.getBoundingClientRect()}catch(e){}var f=b.ownerDocument,h=f.documentElement;if(!d||!c.contains(h,b))return d||{top:0,left:0};b=f.body;f=fa(f);return{top:d.top+(f.pageYOffset||c.support.boxModel&&
+h.scrollTop||b.scrollTop)-(h.clientTop||b.clientTop||0),left:d.left+(f.pageXOffset||c.support.boxModel&&h.scrollLeft||b.scrollLeft)-(h.clientLeft||b.clientLeft||0)}}:function(a){var b=this[0];if(a)return this.each(function(x){c.offset.setOffset(this,a,x)});if(!b||!b.ownerDocument)return null;if(b===b.ownerDocument.body)return c.offset.bodyOffset(b);c.offset.initialize();var d,e=b.offsetParent,f=b.ownerDocument,h=f.documentElement,l=f.body;d=(f=f.defaultView)?f.getComputedStyle(b,null):b.currentStyle;
+for(var k=b.offsetTop,o=b.offsetLeft;(b=b.parentNode)&&b!==l&&b!==h;){if(c.offset.supportsFixedPosition&&d.position==="fixed")break;d=f?f.getComputedStyle(b,null):b.currentStyle;k-=b.scrollTop;o-=b.scrollLeft;if(b===e){k+=b.offsetTop;o+=b.offsetLeft;if(c.offset.doesNotAddBorder&&!(c.offset.doesAddBorderForTableAndCells&&xb.test(b.nodeName))){k+=parseFloat(d.borderTopWidth)||0;o+=parseFloat(d.borderLeftWidth)||0}e=b.offsetParent}if(c.offset.subtractsBorderForOverflowNotVisible&&d.overflow!=="visible"){k+=
+parseFloat(d.borderTopWidth)||0;o+=parseFloat(d.borderLeftWidth)||0}d=d}if(d.position==="relative"||d.position==="static"){k+=l.offsetTop;o+=l.offsetLeft}if(c.offset.supportsFixedPosition&&d.position==="fixed"){k+=Math.max(h.scrollTop,l.scrollTop);o+=Math.max(h.scrollLeft,l.scrollLeft)}return{top:k,left:o}};c.offset={initialize:function(){var a=t.body,b=t.createElement("div"),d,e,f,h=parseFloat(c.css(a,"marginTop"))||0;c.extend(b.style,{position:"absolute",top:0,left:0,margin:0,border:0,width:"1px",
+height:"1px",visibility:"hidden"});b.innerHTML="<div style='position:absolute;top:0;left:0;margin:0;border:5px solid #000;padding:0;width:1px;height:1px;'><div></div></div><table style='position:absolute;top:0;left:0;margin:0;border:5px solid #000;padding:0;width:1px;height:1px;' cellpadding='0' cellspacing='0'><tr><td></td></tr></table>";a.insertBefore(b,a.firstChild);d=b.firstChild;e=d.firstChild;f=d.nextSibling.firstChild.firstChild;this.doesNotAddBorder=e.offsetTop!==5;this.doesAddBorderForTableAndCells=
+f.offsetTop===5;e.style.position="fixed";e.style.top="20px";this.supportsFixedPosition=e.offsetTop===20||e.offsetTop===15;e.style.position=e.style.top="";d.style.overflow="hidden";d.style.position="relative";this.subtractsBorderForOverflowNotVisible=e.offsetTop===-5;this.doesNotIncludeMarginInBodyOffset=a.offsetTop!==h;a.removeChild(b);c.offset.initialize=c.noop},bodyOffset:function(a){var b=a.offsetTop,d=a.offsetLeft;c.offset.initialize();if(c.offset.doesNotIncludeMarginInBodyOffset){b+=parseFloat(c.css(a,
+"marginTop"))||0;d+=parseFloat(c.css(a,"marginLeft"))||0}return{top:b,left:d}},setOffset:function(a,b,d){var e=c.css(a,"position");if(e==="static")a.style.position="relative";var f=c(a),h=f.offset(),l=c.css(a,"top"),k=c.css(a,"left"),o=e==="absolute"&&c.inArray("auto",[l,k])>-1;e={};var x={};if(o)x=f.position();l=o?x.top:parseInt(l,10)||0;k=o?x.left:parseInt(k,10)||0;if(c.isFunction(b))b=b.call(a,d,h);if(b.top!=null)e.top=b.top-h.top+l;if(b.left!=null)e.left=b.left-h.left+k;"using"in b?b.using.call(a,
+e):f.css(e)}};c.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),d=this.offset(),e=Ia.test(b[0].nodeName)?{top:0,left:0}:b.offset();d.top-=parseFloat(c.css(a,"marginTop"))||0;d.left-=parseFloat(c.css(a,"marginLeft"))||0;e.top+=parseFloat(c.css(b[0],"borderTopWidth"))||0;e.left+=parseFloat(c.css(b[0],"borderLeftWidth"))||0;return{top:d.top-e.top,left:d.left-e.left}},offsetParent:function(){return this.map(function(){for(var a=this.offsetParent||t.body;a&&!Ia.test(a.nodeName)&&
+c.css(a,"position")==="static";)a=a.offsetParent;return a})}});c.each(["Left","Top"],function(a,b){var d="scroll"+b;c.fn[d]=function(e){var f=this[0],h;if(!f)return null;if(e!==B)return this.each(function(){if(h=fa(this))h.scrollTo(!a?e:c(h).scrollLeft(),a?e:c(h).scrollTop());else this[d]=e});else return(h=fa(f))?"pageXOffset"in h?h[a?"pageYOffset":"pageXOffset"]:c.support.boxModel&&h.document.documentElement[d]||h.document.body[d]:f[d]}});c.each(["Height","Width"],function(a,b){var d=b.toLowerCase();
+c.fn["inner"+b]=function(){return this[0]?parseFloat(c.css(this[0],d,"padding")):null};c.fn["outer"+b]=function(e){return this[0]?parseFloat(c.css(this[0],d,e?"margin":"border")):null};c.fn[d]=function(e){var f=this[0];if(!f)return e==null?null:this;if(c.isFunction(e))return this.each(function(l){var k=c(this);k[d](e.call(this,l,k[d]()))});if(c.isWindow(f))return f.document.compatMode==="CSS1Compat"&&f.document.documentElement["client"+b]||f.document.body["client"+b];else if(f.nodeType===9)return Math.max(f.documentElement["client"+
+b],f.body["scroll"+b],f.documentElement["scroll"+b],f.body["offset"+b],f.documentElement["offset"+b]);else if(e===B){f=c.css(f,d);var h=parseFloat(f);return c.isNaN(h)?f:h}else return this.css(d,typeof e==="string"?e:e+"px")}})})(window);
diff --git a/core/misc/jquery.once.js b/core/misc/jquery.once.js
new file mode 100644
index 000000000000..506fb4867f7c
--- /dev/null
+++ b/core/misc/jquery.once.js
@@ -0,0 +1,79 @@
+
+/**
+ * jQuery Once Plugin v1.2
+ * http://plugins.jquery.com/project/once
+ *
+ * Dual licensed under the MIT and GPL licenses:
+ * http://www.opensource.org/licenses/mit-license.php
+ * http://www.gnu.org/licenses/gpl.html
+ */
+
+(function ($) {
+ var cache = {}, uuid = 0;
+
+ /**
+ * Filters elements by whether they have not yet been processed.
+ *
+ * @param id
+ * (Optional) If this is a string, then it will be used as the CSS class
+ * name that is applied to the elements for determining whether it has
+ * already been processed. The elements will get a class in the form of
+ * "id-processed".
+ *
+ * If the id parameter is a function, it will be passed off to the fn
+ * parameter and the id will become a unique identifier, represented as a
+ * number.
+ *
+ * When the id is neither a string or a function, it becomes a unique
+ * identifier, depicted as a number. The element's class will then be
+ * represented in the form of "jquery-once-#-processed".
+ *
+ * Take note that the id must be valid for usage as an element's class name.
+ * @param fn
+ * (Optional) If given, this function will be called for each element that
+ * has not yet been processed. The function's return value follows the same
+ * logic as $.each(). Returning true will continue to the next matched
+ * element in the set, while returning false will entirely break the
+ * iteration.
+ */
+ $.fn.once = function (id, fn) {
+ if (typeof id != 'string') {
+ // Generate a numeric ID if the id passed can't be used as a CSS class.
+ if (!(id in cache)) {
+ cache[id] = ++uuid;
+ }
+ // When the fn parameter is not passed, we interpret it from the id.
+ if (!fn) {
+ fn = id;
+ }
+ id = 'jquery-once-' + cache[id];
+ }
+ // Remove elements from the set that have already been processed.
+ var name = id + '-processed';
+ var elements = this.not('.' + name).addClass(name);
+
+ return $.isFunction(fn) ? elements.each(fn) : elements;
+ };
+
+ /**
+ * Filters elements that have been processed once already.
+ *
+ * @param id
+ * A required string representing the name of the class which should be used
+ * when filtering the elements. This only filters elements that have already
+ * been processed by the once function. The id should be the same id that
+ * was originally passed to the once() function.
+ * @param fn
+ * (Optional) If given, this function will be called for each element that
+ * has not yet been processed. The function's return value follows the same
+ * logic as $.each(). Returning true will continue to the next matched
+ * element in the set, while returning false will entirely break the
+ * iteration.
+ */
+ $.fn.removeOnce = function (id, fn) {
+ var name = id + '-processed';
+ var elements = this.filter('.' + name).removeClass(name);
+
+ return $.isFunction(fn) ? elements.each(fn) : elements;
+ };
+})(jQuery);
diff --git a/core/misc/machine-name.js b/core/misc/machine-name.js
new file mode 100644
index 000000000000..2691c3b73a2c
--- /dev/null
+++ b/core/misc/machine-name.js
@@ -0,0 +1,119 @@
+(function ($) {
+
+/**
+ * Attach the machine-readable name form element behavior.
+ */
+Drupal.behaviors.machineName = {
+ /**
+ * Attaches the behavior.
+ *
+ * @param settings.machineName
+ * A list of elements to process, keyed by the HTML ID of the form element
+ * containing the human-readable value. Each element is an object defining
+ * the following properties:
+ * - target: The HTML ID of the machine name form element.
+ * - suffix: The HTML ID of a container to show the machine name preview in
+ * (usually a field suffix after the human-readable name form element).
+ * - label: The label to show for the machine name preview.
+ * - replace_pattern: A regular expression (without modifiers) matching
+ * disallowed characters in the machine name; e.g., '[^a-z0-9]+'.
+ * - replace: A character to replace disallowed characters with; e.g., '_'
+ * or '-'.
+ */
+ attach: function (context, settings) {
+ var self = this;
+ $.each(settings.machineName, function (source_id, options) {
+ var $source = $(source_id, context).addClass('machine-name-source');
+ var $target = $(options.target, context).addClass('machine-name-target');
+ var $suffix = $(options.suffix, context);
+ var $wrapper = $target.parents('.form-item:first');
+ // All elements have to exist.
+ if (!$source.length || !$target.length || !$suffix.length || !$wrapper.length) {
+ return;
+ }
+ // Skip processing upon a form validation error on the machine name.
+ if ($target.hasClass('error')) {
+ return;
+ }
+ // Figure out the maximum length for the machine name.
+ options.maxlength = $target.attr('maxlength');
+ // Hide the form item container of the machine name form element.
+ $wrapper.hide();
+ // Determine the initial machine name value. Unless the machine name form
+ // element is disabled or not empty, the initial default value is based on
+ // the human-readable form element value.
+ if ($target.is(':disabled') || $target.val() != '') {
+ var machine = $target.val();
+ }
+ else {
+ var machine = self.transliterate($source.val(), options);
+ }
+ // Append the machine name preview to the source field.
+ var $preview = $('<span class="machine-name-value">' + machine + '</span>');
+ $suffix.empty()
+ .append(' ').append('<span class="machine-name-label">' + options.label + ':</span>')
+ .append(' ').append($preview);
+
+ // If the machine name cannot be edited, stop further processing.
+ if ($target.is(':disabled')) {
+ return;
+ }
+
+ // If it is editable, append an edit link.
+ var $link = $('<span class="admin-link"><a href="#">' + Drupal.t('Edit') + '</a></span>')
+ .click(function () {
+ $wrapper.show();
+ $target.focus();
+ $suffix.hide();
+ $source.unbind('.machineName');
+ return false;
+ });
+ $suffix.append(' ').append($link);
+
+ // Preview the machine name in realtime when the human-readable name
+ // changes, but only if there is no machine name yet; i.e., only upon
+ // initial creation, not when editing.
+ if ($target.val() == '') {
+ $source.bind('keyup.machineName change.machineName', function () {
+ machine = self.transliterate($(this).val(), options);
+ // Set the machine name to the transliterated value.
+ if (machine != options.replace && machine != '') {
+ $target.val(machine);
+ $preview.text(machine);
+ $suffix.show();
+ }
+ else {
+ $suffix.hide();
+ $target.val(machine);
+ $preview.empty();
+ }
+ });
+ // Initialize machine name preview.
+ $source.keyup();
+ }
+ });
+ },
+
+ /**
+ * Transliterate a human-readable name to a machine name.
+ *
+ * @param source
+ * A string to transliterate.
+ * @param settings
+ * The machine name settings for the corresponding field, containing:
+ * - replace_pattern: A regular expression (without modifiers) matching
+ * disallowed characters in the machine name; e.g., '[^a-z0-9]+'.
+ * - replace: A character to replace disallowed characters with; e.g., '_'
+ * or '-'.
+ * - maxlength: The maximum length of the machine name.
+ *
+ * @return
+ * The transliterated source string.
+ */
+ transliterate: function (source, settings) {
+ var rx = new RegExp(settings.replace_pattern, 'g');
+ return source.toLowerCase().replace(rx, settings.replace).substr(0, settings.maxlength);
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/menu-collapsed-rtl.png b/core/misc/menu-collapsed-rtl.png
new file mode 100644
index 000000000000..dc8d0b8823a9
--- /dev/null
+++ b/core/misc/menu-collapsed-rtl.png
Binary files differ
diff --git a/core/misc/menu-collapsed.png b/core/misc/menu-collapsed.png
new file mode 100644
index 000000000000..91f3fd40ede0
--- /dev/null
+++ b/core/misc/menu-collapsed.png
Binary files differ
diff --git a/core/misc/menu-expanded.png b/core/misc/menu-expanded.png
new file mode 100644
index 000000000000..46f39ecb351c
--- /dev/null
+++ b/core/misc/menu-expanded.png
Binary files differ
diff --git a/core/misc/menu-leaf.png b/core/misc/menu-leaf.png
new file mode 100644
index 000000000000..6b2d63f98850
--- /dev/null
+++ b/core/misc/menu-leaf.png
Binary files differ
diff --git a/core/misc/message-16-error.png b/core/misc/message-16-error.png
new file mode 100644
index 000000000000..486390c9b0ae
--- /dev/null
+++ b/core/misc/message-16-error.png
Binary files differ
diff --git a/core/misc/message-16-help.png b/core/misc/message-16-help.png
new file mode 100644
index 000000000000..fc44326e46b7
--- /dev/null
+++ b/core/misc/message-16-help.png
Binary files differ
diff --git a/core/misc/message-16-info.png b/core/misc/message-16-info.png
new file mode 100644
index 000000000000..f47924fc8ab0
--- /dev/null
+++ b/core/misc/message-16-info.png
Binary files differ
diff --git a/core/misc/message-16-ok.png b/core/misc/message-16-ok.png
new file mode 100644
index 000000000000..9edebe6bd22f
--- /dev/null
+++ b/core/misc/message-16-ok.png
Binary files differ
diff --git a/core/misc/message-16-warning.png b/core/misc/message-16-warning.png
new file mode 100644
index 000000000000..ffc43177abdc
--- /dev/null
+++ b/core/misc/message-16-warning.png
Binary files differ
diff --git a/core/misc/message-24-error.png b/core/misc/message-24-error.png
new file mode 100644
index 000000000000..b09418060cee
--- /dev/null
+++ b/core/misc/message-24-error.png
Binary files differ
diff --git a/core/misc/message-24-help.png b/core/misc/message-24-help.png
new file mode 100644
index 000000000000..66b89cee3d46
--- /dev/null
+++ b/core/misc/message-24-help.png
Binary files differ
diff --git a/core/misc/message-24-info.png b/core/misc/message-24-info.png
new file mode 100644
index 000000000000..be599cabe49f
--- /dev/null
+++ b/core/misc/message-24-info.png
Binary files differ
diff --git a/core/misc/message-24-ok.png b/core/misc/message-24-ok.png
new file mode 100644
index 000000000000..bd3666934fb9
--- /dev/null
+++ b/core/misc/message-24-ok.png
Binary files differ
diff --git a/core/misc/message-24-warning.png b/core/misc/message-24-warning.png
new file mode 100644
index 000000000000..183297d8695d
--- /dev/null
+++ b/core/misc/message-24-warning.png
Binary files differ
diff --git a/core/misc/permissions.png b/core/misc/permissions.png
new file mode 100644
index 000000000000..621471135b41
--- /dev/null
+++ b/core/misc/permissions.png
Binary files differ
diff --git a/core/misc/powered-black-135x42.png b/core/misc/powered-black-135x42.png
new file mode 100644
index 000000000000..1ac2a8718611
--- /dev/null
+++ b/core/misc/powered-black-135x42.png
Binary files differ
diff --git a/core/misc/powered-black-80x15.png b/core/misc/powered-black-80x15.png
new file mode 100644
index 000000000000..e76fa0384805
--- /dev/null
+++ b/core/misc/powered-black-80x15.png
Binary files differ
diff --git a/core/misc/powered-black-88x31.png b/core/misc/powered-black-88x31.png
new file mode 100644
index 000000000000..3adaa4f446e7
--- /dev/null
+++ b/core/misc/powered-black-88x31.png
Binary files differ
diff --git a/core/misc/powered-blue-135x42.png b/core/misc/powered-blue-135x42.png
new file mode 100644
index 000000000000..21e052108568
--- /dev/null
+++ b/core/misc/powered-blue-135x42.png
Binary files differ
diff --git a/core/misc/powered-blue-80x15.png b/core/misc/powered-blue-80x15.png
new file mode 100644
index 000000000000..28f8bc58f42d
--- /dev/null
+++ b/core/misc/powered-blue-80x15.png
Binary files differ
diff --git a/core/misc/powered-blue-88x31.png b/core/misc/powered-blue-88x31.png
new file mode 100644
index 000000000000..e6e30865037c
--- /dev/null
+++ b/core/misc/powered-blue-88x31.png
Binary files differ
diff --git a/core/misc/powered-gray-135x42.png b/core/misc/powered-gray-135x42.png
new file mode 100644
index 000000000000..7bbfcc6b82f8
--- /dev/null
+++ b/core/misc/powered-gray-135x42.png
Binary files differ
diff --git a/core/misc/powered-gray-80x15.png b/core/misc/powered-gray-80x15.png
new file mode 100644
index 000000000000..80808ad50223
--- /dev/null
+++ b/core/misc/powered-gray-80x15.png
Binary files differ
diff --git a/core/misc/powered-gray-88x31.png b/core/misc/powered-gray-88x31.png
new file mode 100644
index 000000000000..a618d8303d84
--- /dev/null
+++ b/core/misc/powered-gray-88x31.png
Binary files differ
diff --git a/core/misc/print-rtl.css b/core/misc/print-rtl.css
new file mode 100644
index 000000000000..f99287a572cb
--- /dev/null
+++ b/core/misc/print-rtl.css
@@ -0,0 +1,7 @@
+
+body {
+ direction: rtl;
+}
+th {
+ text-align: right;
+}
diff --git a/core/misc/print.css b/core/misc/print.css
new file mode 100644
index 000000000000..0a56ef13b45e
--- /dev/null
+++ b/core/misc/print.css
@@ -0,0 +1,25 @@
+
+body {
+ margin: 1em;
+ background-color: #fff;
+}
+th {
+ text-align: left; /* LTR */
+ color: #006;
+ border-bottom: 1px solid #ccc;
+}
+tr.odd {
+ background-color: #ddd;
+}
+tr.even {
+ background-color: #fff;
+}
+td {
+ padding: 5px;
+}
+#menu {
+ visibility: hidden;
+}
+#main {
+ margin: 1em;
+}
diff --git a/core/misc/progress.gif b/core/misc/progress.gif
new file mode 100644
index 000000000000..f84a9de57677
--- /dev/null
+++ b/core/misc/progress.gif
Binary files differ
diff --git a/core/misc/progress.js b/core/misc/progress.js
new file mode 100644
index 000000000000..822666af4b7f
--- /dev/null
+++ b/core/misc/progress.js
@@ -0,0 +1,106 @@
+(function ($) {
+
+/**
+ * A progressbar object. Initialized with the given id. Must be inserted into
+ * the DOM afterwards through progressBar.element.
+ *
+ * method is the function which will perform the HTTP request to get the
+ * progress bar state. Either "GET" or "POST".
+ *
+ * e.g. pb = new progressBar('myProgressBar');
+ * some_element.appendChild(pb.element);
+ */
+Drupal.progressBar = function (id, updateCallback, method, errorCallback) {
+ var pb = this;
+ this.id = id;
+ this.method = method || 'GET';
+ this.updateCallback = updateCallback;
+ this.errorCallback = errorCallback;
+
+ // The WAI-ARIA setting aria-live="polite" will announce changes after users
+ // have completed their current activity and not interrupt the screen reader.
+ this.element = $('<div class="progress" aria-live="polite"></div>').attr('id', id);
+ this.element.html('<div class="bar"><div class="filled"></div></div>' +
+ '<div class="percentage"></div>' +
+ '<div class="message">&nbsp;</div>');
+};
+
+/**
+ * Set the percentage and status message for the progressbar.
+ */
+Drupal.progressBar.prototype.setProgress = function (percentage, message) {
+ if (percentage >= 0 && percentage <= 100) {
+ $('div.filled', this.element).css('width', percentage + '%');
+ $('div.percentage', this.element).html(percentage + '%');
+ }
+ $('div.message', this.element).html(message);
+ if (this.updateCallback) {
+ this.updateCallback(percentage, message, this);
+ }
+};
+
+/**
+ * Start monitoring progress via Ajax.
+ */
+Drupal.progressBar.prototype.startMonitoring = function (uri, delay) {
+ this.delay = delay;
+ this.uri = uri;
+ this.sendPing();
+};
+
+/**
+ * Stop monitoring progress via Ajax.
+ */
+Drupal.progressBar.prototype.stopMonitoring = function () {
+ clearTimeout(this.timer);
+ // This allows monitoring to be stopped from within the callback.
+ this.uri = null;
+};
+
+/**
+ * Request progress data from server.
+ */
+Drupal.progressBar.prototype.sendPing = function () {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ }
+ if (this.uri) {
+ var pb = this;
+ // When doing a post request, you need non-null data. Otherwise a
+ // HTTP 411 or HTTP 406 (with Apache mod_security) error may result.
+ $.ajax({
+ type: this.method,
+ url: this.uri,
+ data: '',
+ dataType: 'json',
+ success: function (progress) {
+ // Display errors.
+ if (progress.status == 0) {
+ pb.displayError(progress.data);
+ return;
+ }
+ // Update display.
+ pb.setProgress(progress.percentage, progress.message);
+ // Schedule next timer.
+ pb.timer = setTimeout(function () { pb.sendPing(); }, pb.delay);
+ },
+ error: function (xmlhttp) {
+ pb.displayError(Drupal.ajaxError(xmlhttp, pb.uri));
+ }
+ });
+ }
+};
+
+/**
+ * Display errors on the page.
+ */
+Drupal.progressBar.prototype.displayError = function (string) {
+ var error = $('<div class="messages error"></div>').html(string);
+ $(this.element).before(error).hide();
+
+ if (this.errorCallback) {
+ this.errorCallback(this);
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/states.js b/core/misc/states.js
new file mode 100644
index 000000000000..0b2616b055b4
--- /dev/null
+++ b/core/misc/states.js
@@ -0,0 +1,423 @@
+(function ($) {
+
+/**
+ * The base States namespace.
+ *
+ * Having the local states variable allows us to use the States namespace
+ * without having to always declare "Drupal.states".
+ */
+var states = Drupal.states = {
+ // An array of functions that should be postponed.
+ postponed: []
+};
+
+/**
+ * Attaches the states.
+ */
+Drupal.behaviors.states = {
+ attach: function (context, settings) {
+ for (var selector in settings.states) {
+ for (var state in settings.states[selector]) {
+ new states.Dependent({
+ element: $(selector),
+ state: states.State.sanitize(state),
+ dependees: settings.states[selector][state]
+ });
+ }
+ }
+
+ // Execute all postponed functions now.
+ while (states.postponed.length) {
+ (states.postponed.shift())();
+ }
+ }
+};
+
+/**
+ * Object representing an element that depends on other elements.
+ *
+ * @param args
+ * Object with the following keys (all of which are required):
+ * - element: A jQuery object of the dependent element
+ * - state: A State object describing the state that is dependent
+ * - dependees: An object with dependency specifications. Lists all elements
+ * that this element depends on.
+ */
+states.Dependent = function (args) {
+ $.extend(this, { values: {}, oldValue: undefined }, args);
+
+ for (var selector in this.dependees) {
+ this.initializeDependee(selector, this.dependees[selector]);
+ }
+};
+
+/**
+ * Comparison functions for comparing the value of an element with the
+ * specification from the dependency settings. If the object type can't be
+ * found in this list, the === operator is used by default.
+ */
+states.Dependent.comparisons = {
+ 'RegExp': function (reference, value) {
+ return reference.test(value);
+ },
+ 'Function': function (reference, value) {
+ // The "reference" variable is a comparison function.
+ return reference(value);
+ },
+ 'Number': function (reference, value) {
+ // If "reference" is a number and "value" is a string, then cast reference
+ // as a string before applying the strict comparison in compare(). Otherwise
+ // numeric keys in the form's #states array fail to match string values
+ // returned from jQuery's val().
+ return (value.constructor.name === 'String') ? compare(String(reference), value) : compare(reference, value);
+ }
+};
+
+states.Dependent.prototype = {
+ /**
+ * Initializes one of the elements this dependent depends on.
+ *
+ * @param selector
+ * The CSS selector describing the dependee.
+ * @param dependeeStates
+ * The list of states that have to be monitored for tracking the
+ * dependee's compliance status.
+ */
+ initializeDependee: function (selector, dependeeStates) {
+ var self = this;
+
+ // Cache for the states of this dependee.
+ self.values[selector] = {};
+
+ $.each(dependeeStates, function (state, value) {
+ state = states.State.sanitize(state);
+
+ // Initialize the value of this state.
+ self.values[selector][state.pristine] = undefined;
+
+ // Monitor state changes of the specified state for this dependee.
+ $(selector).bind('state:' + state, function (e) {
+ var complies = self.compare(value, e.value);
+ self.update(selector, state, complies);
+ });
+
+ // Make sure the event we just bound ourselves to is actually fired.
+ new states.Trigger({ selector: selector, state: state });
+ });
+ },
+
+ /**
+ * Compares a value with a reference value.
+ *
+ * @param reference
+ * The value used for reference.
+ * @param value
+ * The value to compare with the reference value.
+ * @return
+ * true, undefined or false.
+ */
+ compare: function (reference, value) {
+ if (reference.constructor.name in states.Dependent.comparisons) {
+ // Use a custom compare function for certain reference value types.
+ return states.Dependent.comparisons[reference.constructor.name](reference, value);
+ }
+ else {
+ // Do a plain comparison otherwise.
+ return compare(reference, value);
+ }
+ },
+
+ /**
+ * Update the value of a dependee's state.
+ *
+ * @param selector
+ * CSS selector describing the dependee.
+ * @param state
+ * A State object describing the dependee's updated state.
+ * @param value
+ * The new value for the dependee's updated state.
+ */
+ update: function (selector, state, value) {
+ // Only act when the 'new' value is actually new.
+ if (value !== this.values[selector][state.pristine]) {
+ this.values[selector][state.pristine] = value;
+ this.reevaluate();
+ }
+ },
+
+ /**
+ * Triggers change events in case a state changed.
+ */
+ reevaluate: function () {
+ var value = undefined;
+
+ // Merge all individual values to find out whether this dependee complies.
+ for (var selector in this.values) {
+ for (var state in this.values[selector]) {
+ state = states.State.sanitize(state);
+ var complies = this.values[selector][state.pristine];
+ value = ternary(value, invert(complies, state.invert));
+ }
+ }
+
+ // Only invoke a state change event when the value actually changed.
+ if (value !== this.oldValue) {
+ // Store the new value so that we can compare later whether the value
+ // actually changed.
+ this.oldValue = value;
+
+ // Normalize the value to match the normalized state name.
+ value = invert(value, this.state.invert);
+
+ // By adding "trigger: true", we ensure that state changes don't go into
+ // infinite loops.
+ this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true });
+ }
+ }
+};
+
+states.Trigger = function (args) {
+ $.extend(this, args);
+
+ if (this.state in states.Trigger.states) {
+ this.element = $(this.selector);
+
+ // Only call the trigger initializer when it wasn't yet attached to this
+ // element. Otherwise we'd end up with duplicate events.
+ if (!this.element.data('trigger:' + this.state)) {
+ this.initialize();
+ }
+ }
+};
+
+states.Trigger.prototype = {
+ initialize: function () {
+ var self = this;
+ var trigger = states.Trigger.states[this.state];
+
+ if (typeof trigger == 'function') {
+ // We have a custom trigger initialization function.
+ trigger.call(window, this.element);
+ }
+ else {
+ $.each(trigger, function (event, valueFn) {
+ self.defaultTrigger(event, valueFn);
+ });
+ }
+
+ // Mark this trigger as initialized for this element.
+ this.element.data('trigger:' + this.state, true);
+ },
+
+ defaultTrigger: function (event, valueFn) {
+ var self = this;
+ var oldValue = valueFn.call(this.element);
+
+ // Attach the event callback.
+ this.element.bind(event, function (e) {
+ var value = valueFn.call(self.element, e);
+ // Only trigger the event if the value has actually changed.
+ if (oldValue !== value) {
+ self.element.trigger({ type: 'state:' + self.state, value: value, oldValue: oldValue });
+ oldValue = value;
+ }
+ });
+
+ states.postponed.push(function () {
+ // Trigger the event once for initialization purposes.
+ self.element.trigger({ type: 'state:' + self.state, value: oldValue, oldValue: undefined });
+ });
+ }
+};
+
+/**
+ * This list of states contains functions that are used to monitor the state
+ * of an element. Whenever an element depends on the state of another element,
+ * one of these trigger functions is added to the dependee so that the
+ * dependent element can be updated.
+ */
+states.Trigger.states = {
+ // 'empty' describes the state to be monitored
+ empty: {
+ // 'keyup' is the (native DOM) event that we watch for.
+ 'keyup': function () {
+ // The function associated to that trigger returns the new value for the
+ // state.
+ return this.val() == '';
+ }
+ },
+
+ checked: {
+ 'change': function () {
+ return this.attr('checked');
+ }
+ },
+
+ // For radio buttons, only return the value if the radio button is selected.
+ value: {
+ 'keyup': function () {
+ // Radio buttons share the same :input[name="key"] selector.
+ if (this.length > 1) {
+ // Initial checked value of radios is undefined, so we return false.
+ return this.filter(':checked').val() || false;
+ }
+ return this.val();
+ },
+ 'change': function () {
+ // Radio buttons share the same :input[name="key"] selector.
+ if (this.length > 1) {
+ // Initial checked value of radios is undefined, so we return false.
+ return this.filter(':checked').val() || false;
+ }
+ return this.val();
+ }
+ },
+
+ collapsed: {
+ 'collapsed': function(e) {
+ return (e !== undefined && 'value' in e) ? e.value : this.is('.collapsed');
+ }
+ }
+};
+
+
+/**
+ * A state object is used for describing the state and performing aliasing.
+ */
+states.State = function(state) {
+ // We may need the original unresolved name later.
+ this.pristine = this.name = state;
+
+ // Normalize the state name.
+ while (true) {
+ // Iteratively remove exclamation marks and invert the value.
+ while (this.name.charAt(0) == '!') {
+ this.name = this.name.substring(1);
+ this.invert = !this.invert;
+ }
+
+ // Replace the state with its normalized name.
+ if (this.name in states.State.aliases) {
+ this.name = states.State.aliases[this.name];
+ }
+ else {
+ break;
+ }
+ }
+};
+
+/**
+ * Create a new State object by sanitizing the passed value.
+ */
+states.State.sanitize = function (state) {
+ if (state instanceof states.State) {
+ return state;
+ }
+ else {
+ return new states.State(state);
+ }
+};
+
+/**
+ * This list of aliases is used to normalize states and associates negated names
+ * with their respective inverse state.
+ */
+states.State.aliases = {
+ 'enabled': '!disabled',
+ 'invisible': '!visible',
+ 'invalid': '!valid',
+ 'untouched': '!touched',
+ 'optional': '!required',
+ 'filled': '!empty',
+ 'unchecked': '!checked',
+ 'irrelevant': '!relevant',
+ 'expanded': '!collapsed',
+ 'readwrite': '!readonly'
+};
+
+states.State.prototype = {
+ invert: false,
+
+ /**
+ * Ensures that just using the state object returns the name.
+ */
+ toString: function() {
+ return this.name;
+ }
+};
+
+/**
+ * Global state change handlers. These are bound to "document" to cover all
+ * elements whose state changes. Events sent to elements within the page
+ * bubble up to these handlers. We use this system so that themes and modules
+ * can override these state change handlers for particular parts of a page.
+ */
+{
+ $(document).bind('state:disabled', function(e) {
+ // Only act when this change was triggered by a dependency and not by the
+ // element monitoring itself.
+ if (e.trigger) {
+ $(e.target)
+ .attr('disabled', e.value)
+ .filter('.form-element')
+ .closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'addClass' : 'removeClass']('form-disabled');
+
+ // Note: WebKit nightlies don't reflect that change correctly.
+ // See https://bugs.webkit.org/show_bug.cgi?id=23789
+ }
+ });
+
+ $(document).bind('state:required', function(e) {
+ if (e.trigger) {
+ if (e.value) {
+ $(e.target).closest('.form-item, .form-wrapper').find('label').append('<abbr class="form-required" title="' + Drupal.t('This field is required.') + '">*</abbr>');
+ }
+ else {
+ $(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove();
+ }
+ }
+ });
+
+ $(document).bind('state:visible', function(e) {
+ if (e.trigger) {
+ $(e.target).closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'show' : 'hide']();
+ }
+ });
+
+ $(document).bind('state:checked', function(e) {
+ if (e.trigger) {
+ $(e.target).attr('checked', e.value);
+ }
+ });
+
+ $(document).bind('state:collapsed', function(e) {
+ if (e.trigger) {
+ if ($(e.target).is('.collapsed') !== e.value) {
+ $('> legend a', e.target).click();
+ }
+ }
+ });
+}
+
+/**
+ * These are helper functions implementing addition "operators" and don't
+ * implement any logic that is particular to states.
+ */
+{
+ // Bitwise AND with a third undefined state.
+ function ternary (a, b) {
+ return a === undefined ? b : (b === undefined ? a : a && b);
+ };
+
+ // Inverts a (if it's not undefined) when invert is true.
+ function invert (a, invert) {
+ return (invert && a !== undefined) ? !a : a;
+ };
+
+ // Compares two values while ignoring undefined values.
+ function compare (a, b) {
+ return (a === b) ? (a === undefined ? a : true) : (a === undefined || b === undefined);
+ }
+}
+
+})(jQuery);
diff --git a/core/misc/tabledrag.js b/core/misc/tabledrag.js
new file mode 100644
index 000000000000..b9b5822c8128
--- /dev/null
+++ b/core/misc/tabledrag.js
@@ -0,0 +1,1162 @@
+(function ($) {
+
+/**
+ * Drag and drop table rows with field manipulation.
+ *
+ * Using the drupal_add_tabledrag() function, any table with weights or parent
+ * relationships may be made into draggable tables. Columns containing a field
+ * may optionally be hidden, providing a better user experience.
+ *
+ * Created tableDrag instances may be modified with custom behaviors by
+ * overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods.
+ * See blocks.js for an example of adding additional functionality to tableDrag.
+ */
+Drupal.behaviors.tableDrag = {
+ attach: function (context, settings) {
+ for (var base in settings.tableDrag) {
+ $('#' + base, context).once('tabledrag', function () {
+ // Create the new tableDrag instance. Save in the Drupal variable
+ // to allow other scripts access to the object.
+ Drupal.tableDrag[base] = new Drupal.tableDrag(this, settings.tableDrag[base]);
+ });
+ }
+ }
+};
+
+/**
+ * Constructor for the tableDrag object. Provides table and field manipulation.
+ *
+ * @param table
+ * DOM object for the table to be made draggable.
+ * @param tableSettings
+ * Settings for the table added via drupal_add_dragtable().
+ */
+Drupal.tableDrag = function (table, tableSettings) {
+ var self = this;
+
+ // Required object variables.
+ this.table = table;
+ this.tableSettings = tableSettings;
+ this.dragObject = null; // Used to hold information about a current drag operation.
+ this.rowObject = null; // Provides operations for row manipulation.
+ this.oldRowElement = null; // Remember the previous element.
+ this.oldY = 0; // Used to determine up or down direction from last mouse move.
+ this.changed = false; // Whether anything in the entire table has changed.
+ this.maxDepth = 0; // Maximum amount of allowed parenting.
+ this.rtl = $(this.table).css('direction') == 'rtl' ? -1 : 1; // Direction of the table.
+
+ // Configure the scroll settings.
+ this.scrollSettings = { amount: 4, interval: 50, trigger: 70 };
+ this.scrollInterval = null;
+ this.scrollY = 0;
+ this.windowHeight = 0;
+
+ // Check this table's settings to see if there are parent relationships in
+ // this table. For efficiency, large sections of code can be skipped if we
+ // don't need to track horizontal movement and indentations.
+ this.indentEnabled = false;
+ for (var group in tableSettings) {
+ for (var n in tableSettings[group]) {
+ if (tableSettings[group][n].relationship == 'parent') {
+ this.indentEnabled = true;
+ }
+ if (tableSettings[group][n].limit > 0) {
+ this.maxDepth = tableSettings[group][n].limit;
+ }
+ }
+ }
+ if (this.indentEnabled) {
+ this.indentCount = 1; // Total width of indents, set in makeDraggable.
+ // Find the width of indentations to measure mouse movements against.
+ // Because the table doesn't need to start with any indentations, we
+ // manually append 2 indentations in the first draggable row, measure
+ // the offset, then remove.
+ var indent = Drupal.theme('tableDragIndentation');
+ var testRow = $('<tr/>').addClass('draggable').appendTo(table);
+ var testCell = $('<td/>').appendTo(testRow).prepend(indent).prepend(indent);
+ this.indentAmount = $('.indentation', testCell).get(1).offsetLeft - $('.indentation', testCell).get(0).offsetLeft;
+ testRow.remove();
+ }
+
+ // Make each applicable row draggable.
+ // Match immediate children of the parent element to allow nesting.
+ $('> tr.draggable, > tbody > tr.draggable', table).each(function () { self.makeDraggable(this); });
+
+ // Add a link before the table for users to show or hide weight columns.
+ $(table).before($('<a href="#" class="tabledrag-toggle-weight"></a>')
+ .attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.'))
+ .click(function () {
+ if ($.cookie('Drupal.tableDrag.showWeight') == 1) {
+ self.hideColumns();
+ }
+ else {
+ self.showColumns();
+ }
+ return false;
+ })
+ .wrap('<div class="tabledrag-toggle-weight-wrapper"></div>')
+ .parent()
+ );
+
+ // Initialize the specified columns (for example, weight or parent columns)
+ // to show or hide according to user preference. This aids accessibility
+ // so that, e.g., screen reader users can choose to enter weight values and
+ // manipulate form elements directly, rather than using drag-and-drop..
+ self.initColumns();
+
+ // Add mouse bindings to the document. The self variable is passed along
+ // as event handlers do not have direct access to the tableDrag object.
+ $(document).bind('mousemove', function (event) { return self.dragRow(event, self); });
+ $(document).bind('mouseup', function (event) { return self.dropRow(event, self); });
+};
+
+/**
+ * Initialize columns containing form elements to be hidden by default,
+ * according to the settings for this tableDrag instance.
+ *
+ * Identify and mark each cell with a CSS class so we can easily toggle
+ * show/hide it. Finally, hide columns if user does not have a
+ * 'Drupal.tableDrag.showWeight' cookie.
+ */
+Drupal.tableDrag.prototype.initColumns = function () {
+ for (var group in this.tableSettings) {
+ // Find the first field in this group.
+ for (var d in this.tableSettings[group]) {
+ var field = $('.' + this.tableSettings[group][d].target + ':first', this.table);
+ if (field.size() && this.tableSettings[group][d].hidden) {
+ var hidden = this.tableSettings[group][d].hidden;
+ var cell = field.parents('td:first');
+ break;
+ }
+ }
+
+ // Mark the column containing this field so it can be hidden.
+ if (hidden && cell[0]) {
+ // Add 1 to our indexes. The nth-child selector is 1 based, not 0 based.
+ // Match immediate children of the parent element to allow nesting.
+ var columnIndex = $('> td', cell.parent()).index(cell.get(0)) + 1;
+ $('> thead > tr, > tbody > tr, > tr', this.table).each(function () {
+ // Get the columnIndex and adjust for any colspans in this row.
+ var index = columnIndex;
+ var cells = $(this).children();
+ cells.each(function (n) {
+ if (n < index && this.colSpan && this.colSpan > 1) {
+ index -= this.colSpan - 1;
+ }
+ });
+ if (index > 0) {
+ cell = cells.filter(':nth-child(' + index + ')');
+ if (cell[0].colSpan && cell[0].colSpan > 1) {
+ // If this cell has a colspan, mark it so we can reduce the colspan.
+ cell.addClass('tabledrag-has-colspan');
+ }
+ else {
+ // Mark this cell so we can hide it.
+ cell.addClass('tabledrag-hide');
+ }
+ }
+ });
+ }
+ }
+
+ // Now hide cells and reduce colspans unless cookie indicates previous choice.
+ // Set a cookie if it is not already present.
+ if ($.cookie('Drupal.tableDrag.showWeight') === null) {
+ $.cookie('Drupal.tableDrag.showWeight', 0, {
+ path: Drupal.settings.basePath,
+ // The cookie expires in one year.
+ expires: 365
+ });
+ this.hideColumns();
+ }
+ // Check cookie value and show/hide weight columns accordingly.
+ else {
+ if ($.cookie('Drupal.tableDrag.showWeight') == 1) {
+ this.showColumns();
+ }
+ else {
+ this.hideColumns();
+ }
+ }
+};
+
+/**
+ * Hide the columns containing weight/parent form elements.
+ * Undo showColumns().
+ */
+Drupal.tableDrag.prototype.hideColumns = function () {
+ // Hide weight/parent cells and headers.
+ $('.tabledrag-hide', 'table.tabledrag-processed').css('display', 'none');
+ // Show TableDrag handles.
+ $('.tabledrag-handle', 'table.tabledrag-processed').css('display', '');
+ // Reduce the colspan of any effected multi-span columns.
+ $('.tabledrag-has-colspan', 'table.tabledrag-processed').each(function () {
+ this.colSpan = this.colSpan - 1;
+ });
+ // Change link text.
+ $('.tabledrag-toggle-weight').text(Drupal.t('Show row weights'));
+ // Change cookie.
+ $.cookie('Drupal.tableDrag.showWeight', 0, {
+ path: Drupal.settings.basePath,
+ // The cookie expires in one year.
+ expires: 365
+ });
+};
+
+/**
+ * Show the columns containing weight/parent form elements
+ * Undo hideColumns().
+ */
+Drupal.tableDrag.prototype.showColumns = function () {
+ // Show weight/parent cells and headers.
+ $('.tabledrag-hide', 'table.tabledrag-processed').css('display', '');
+ // Hide TableDrag handles.
+ $('.tabledrag-handle', 'table.tabledrag-processed').css('display', 'none');
+ // Increase the colspan for any columns where it was previously reduced.
+ $('.tabledrag-has-colspan', 'table.tabledrag-processed').each(function () {
+ this.colSpan = this.colSpan + 1;
+ });
+ // Change link text.
+ $('.tabledrag-toggle-weight').text(Drupal.t('Hide row weights'));
+ // Change cookie.
+ $.cookie('Drupal.tableDrag.showWeight', 1, {
+ path: Drupal.settings.basePath,
+ // The cookie expires in one year.
+ expires: 365
+ });
+};
+
+/**
+ * Find the target used within a particular row and group.
+ */
+Drupal.tableDrag.prototype.rowSettings = function (group, row) {
+ var field = $('.' + group, row);
+ for (var delta in this.tableSettings[group]) {
+ var targetClass = this.tableSettings[group][delta].target;
+ if (field.is('.' + targetClass)) {
+ // Return a copy of the row settings.
+ var rowSettings = {};
+ for (var n in this.tableSettings[group][delta]) {
+ rowSettings[n] = this.tableSettings[group][delta][n];
+ }
+ return rowSettings;
+ }
+ }
+};
+
+/**
+ * Take an item and add event handlers to make it become draggable.
+ */
+Drupal.tableDrag.prototype.makeDraggable = function (item) {
+ var self = this;
+
+ // Create the handle.
+ var handle = $('<a href="#" class="tabledrag-handle"><div class="handle">&nbsp;</div></a>').attr('title', Drupal.t('Drag to re-order'));
+ // Insert the handle after indentations (if any).
+ if ($('td:first .indentation:last', item).length) {
+ $('td:first .indentation:last', item).after(handle);
+ // Update the total width of indentation in this entire table.
+ self.indentCount = Math.max($('.indentation', item).size(), self.indentCount);
+ }
+ else {
+ $('td:first', item).prepend(handle);
+ }
+
+ // Add hover action for the handle.
+ handle.hover(function () {
+ self.dragObject == null ? $(this).addClass('tabledrag-handle-hover') : null;
+ }, function () {
+ self.dragObject == null ? $(this).removeClass('tabledrag-handle-hover') : null;
+ });
+
+ // Add the mousedown action for the handle.
+ handle.mousedown(function (event) {
+ // Create a new dragObject recording the event information.
+ self.dragObject = {};
+ self.dragObject.initMouseOffset = self.getMouseOffset(item, event);
+ self.dragObject.initMouseCoords = self.mouseCoords(event);
+ if (self.indentEnabled) {
+ self.dragObject.indentMousePos = self.dragObject.initMouseCoords;
+ }
+
+ // If there's a lingering row object from the keyboard, remove its focus.
+ if (self.rowObject) {
+ $('a.tabledrag-handle', self.rowObject.element).blur();
+ }
+
+ // Create a new rowObject for manipulation of this row.
+ self.rowObject = new self.row(item, 'mouse', self.indentEnabled, self.maxDepth, true);
+
+ // Save the position of the table.
+ self.table.topY = $(self.table).offset().top;
+ self.table.bottomY = self.table.topY + self.table.offsetHeight;
+
+ // Add classes to the handle and row.
+ $(this).addClass('tabledrag-handle-hover');
+ $(item).addClass('drag');
+
+ // Set the document to use the move cursor during drag.
+ $('body').addClass('drag');
+ if (self.oldRowElement) {
+ $(self.oldRowElement).removeClass('drag-previous');
+ }
+
+ // Hack for Konqueror, prevent the blur handler from firing.
+ // Konqueror always gives links focus, even after returning false on mousedown.
+ self.safeBlur = false;
+
+ // Call optional placeholder function.
+ self.onDrag();
+ return false;
+ });
+
+ // Prevent the anchor tag from jumping us to the top of the page.
+ handle.click(function () {
+ return false;
+ });
+
+ // Similar to the hover event, add a class when the handle is focused.
+ handle.focus(function () {
+ $(this).addClass('tabledrag-handle-hover');
+ self.safeBlur = true;
+ });
+
+ // Remove the handle class on blur and fire the same function as a mouseup.
+ handle.blur(function (event) {
+ $(this).removeClass('tabledrag-handle-hover');
+ if (self.rowObject && self.safeBlur) {
+ self.dropRow(event, self);
+ }
+ });
+
+ // Add arrow-key support to the handle.
+ handle.keydown(function (event) {
+ // If a rowObject doesn't yet exist and this isn't the tab key.
+ if (event.keyCode != 9 && !self.rowObject) {
+ self.rowObject = new self.row(item, 'keyboard', self.indentEnabled, self.maxDepth, true);
+ }
+
+ var keyChange = false;
+ switch (event.keyCode) {
+ case 37: // Left arrow.
+ case 63234: // Safari left arrow.
+ keyChange = true;
+ self.rowObject.indent(-1 * self.rtl);
+ break;
+ case 38: // Up arrow.
+ case 63232: // Safari up arrow.
+ var previousRow = $(self.rowObject.element).prev('tr').get(0);
+ while (previousRow && $(previousRow).is(':hidden')) {
+ previousRow = $(previousRow).prev('tr').get(0);
+ }
+ if (previousRow) {
+ self.safeBlur = false; // Do not allow the onBlur cleanup.
+ self.rowObject.direction = 'up';
+ keyChange = true;
+
+ if ($(item).is('.tabledrag-root')) {
+ // Swap with the previous top-level row.
+ var groupHeight = 0;
+ while (previousRow && $('.indentation', previousRow).size()) {
+ previousRow = $(previousRow).prev('tr').get(0);
+ groupHeight += $(previousRow).is(':hidden') ? 0 : previousRow.offsetHeight;
+ }
+ if (previousRow) {
+ self.rowObject.swap('before', previousRow);
+ // No need to check for indentation, 0 is the only valid one.
+ window.scrollBy(0, -groupHeight);
+ }
+ }
+ else if (self.table.tBodies[0].rows[0] != previousRow || $(previousRow).is('.draggable')) {
+ // Swap with the previous row (unless previous row is the first one
+ // and undraggable).
+ self.rowObject.swap('before', previousRow);
+ self.rowObject.interval = null;
+ self.rowObject.indent(0);
+ window.scrollBy(0, -parseInt(item.offsetHeight, 10));
+ }
+ handle.get(0).focus(); // Regain focus after the DOM manipulation.
+ }
+ break;
+ case 39: // Right arrow.
+ case 63235: // Safari right arrow.
+ keyChange = true;
+ self.rowObject.indent(1 * self.rtl);
+ break;
+ case 40: // Down arrow.
+ case 63233: // Safari down arrow.
+ var nextRow = $(self.rowObject.group).filter(':last').next('tr').get(0);
+ while (nextRow && $(nextRow).is(':hidden')) {
+ nextRow = $(nextRow).next('tr').get(0);
+ }
+ if (nextRow) {
+ self.safeBlur = false; // Do not allow the onBlur cleanup.
+ self.rowObject.direction = 'down';
+ keyChange = true;
+
+ if ($(item).is('.tabledrag-root')) {
+ // Swap with the next group (necessarily a top-level one).
+ var groupHeight = 0;
+ nextGroup = new self.row(nextRow, 'keyboard', self.indentEnabled, self.maxDepth, false);
+ if (nextGroup) {
+ $(nextGroup.group).each(function () {
+ groupHeight += $(this).is(':hidden') ? 0 : this.offsetHeight;
+ });
+ nextGroupRow = $(nextGroup.group).filter(':last').get(0);
+ self.rowObject.swap('after', nextGroupRow);
+ // No need to check for indentation, 0 is the only valid one.
+ window.scrollBy(0, parseInt(groupHeight, 10));
+ }
+ }
+ else {
+ // Swap with the next row.
+ self.rowObject.swap('after', nextRow);
+ self.rowObject.interval = null;
+ self.rowObject.indent(0);
+ window.scrollBy(0, parseInt(item.offsetHeight, 10));
+ }
+ handle.get(0).focus(); // Regain focus after the DOM manipulation.
+ }
+ break;
+ }
+
+ if (self.rowObject && self.rowObject.changed == true) {
+ $(item).addClass('drag');
+ if (self.oldRowElement) {
+ $(self.oldRowElement).removeClass('drag-previous');
+ }
+ self.oldRowElement = item;
+ self.restripeTable();
+ self.onDrag();
+ }
+
+ // Returning false if we have an arrow key to prevent scrolling.
+ if (keyChange) {
+ return false;
+ }
+ });
+
+ // Compatibility addition, return false on keypress to prevent unwanted scrolling.
+ // IE and Safari will suppress scrolling on keydown, but all other browsers
+ // need to return false on keypress. http://www.quirksmode.org/js/keys.html
+ handle.keypress(function (event) {
+ switch (event.keyCode) {
+ case 37: // Left arrow.
+ case 38: // Up arrow.
+ case 39: // Right arrow.
+ case 40: // Down arrow.
+ return false;
+ }
+ });
+};
+
+/**
+ * Mousemove event handler, bound to document.
+ */
+Drupal.tableDrag.prototype.dragRow = function (event, self) {
+ if (self.dragObject) {
+ self.currentMouseCoords = self.mouseCoords(event);
+
+ var y = self.currentMouseCoords.y - self.dragObject.initMouseOffset.y;
+ var x = self.currentMouseCoords.x - self.dragObject.initMouseOffset.x;
+
+ // Check for row swapping and vertical scrolling.
+ if (y != self.oldY) {
+ self.rowObject.direction = y > self.oldY ? 'down' : 'up';
+ self.oldY = y; // Update the old value.
+
+ // Check if the window should be scrolled (and how fast).
+ var scrollAmount = self.checkScroll(self.currentMouseCoords.y);
+ // Stop any current scrolling.
+ clearInterval(self.scrollInterval);
+ // Continue scrolling if the mouse has moved in the scroll direction.
+ if (scrollAmount > 0 && self.rowObject.direction == 'down' || scrollAmount < 0 && self.rowObject.direction == 'up') {
+ self.setScroll(scrollAmount);
+ }
+
+ // If we have a valid target, perform the swap and restripe the table.
+ var currentRow = self.findDropTargetRow(x, y);
+ if (currentRow) {
+ if (self.rowObject.direction == 'down') {
+ self.rowObject.swap('after', currentRow, self);
+ }
+ else {
+ self.rowObject.swap('before', currentRow, self);
+ }
+ self.restripeTable();
+ }
+ }
+
+ // Similar to row swapping, handle indentations.
+ if (self.indentEnabled) {
+ var xDiff = self.currentMouseCoords.x - self.dragObject.indentMousePos.x;
+ // Set the number of indentations the mouse has been moved left or right.
+ var indentDiff = Math.round(xDiff / self.indentAmount * self.rtl);
+ // Indent the row with our estimated diff, which may be further
+ // restricted according to the rows around this row.
+ var indentChange = self.rowObject.indent(indentDiff);
+ // Update table and mouse indentations.
+ self.dragObject.indentMousePos.x += self.indentAmount * indentChange * self.rtl;
+ self.indentCount = Math.max(self.indentCount, self.rowObject.indents);
+ }
+
+ return false;
+ }
+};
+
+/**
+ * Mouseup event handler, bound to document.
+ * Blur event handler, bound to drag handle for keyboard support.
+ */
+Drupal.tableDrag.prototype.dropRow = function (event, self) {
+ // Drop row functionality shared between mouseup and blur events.
+ if (self.rowObject != null) {
+ var droppedRow = self.rowObject.element;
+ // The row is already in the right place so we just release it.
+ if (self.rowObject.changed == true) {
+ // Update the fields in the dropped row.
+ self.updateFields(droppedRow);
+
+ // If a setting exists for affecting the entire group, update all the
+ // fields in the entire dragged group.
+ for (var group in self.tableSettings) {
+ var rowSettings = self.rowSettings(group, droppedRow);
+ if (rowSettings.relationship == 'group') {
+ for (var n in self.rowObject.children) {
+ self.updateField(self.rowObject.children[n], group);
+ }
+ }
+ }
+
+ self.rowObject.markChanged();
+ if (self.changed == false) {
+ $(Drupal.theme('tableDragChangedWarning')).insertBefore(self.table).hide().fadeIn('slow');
+ self.changed = true;
+ }
+ }
+
+ if (self.indentEnabled) {
+ self.rowObject.removeIndentClasses();
+ }
+ if (self.oldRowElement) {
+ $(self.oldRowElement).removeClass('drag-previous');
+ }
+ $(droppedRow).removeClass('drag').addClass('drag-previous');
+ self.oldRowElement = droppedRow;
+ self.onDrop();
+ self.rowObject = null;
+ }
+
+ // Functionality specific only to mouseup event.
+ if (self.dragObject != null) {
+ $('.tabledrag-handle', droppedRow).removeClass('tabledrag-handle-hover');
+
+ self.dragObject = null;
+ $('body').removeClass('drag');
+ clearInterval(self.scrollInterval);
+ }
+};
+
+/**
+ * Get the mouse coordinates from the event (allowing for browser differences).
+ */
+Drupal.tableDrag.prototype.mouseCoords = function (event) {
+ if (event.pageX || event.pageY) {
+ return { x: event.pageX, y: event.pageY };
+ }
+ return {
+ x: event.clientX + document.body.scrollLeft - document.body.clientLeft,
+ y: event.clientY + document.body.scrollTop - document.body.clientTop
+ };
+};
+
+/**
+ * Given a target element and a mouse event, get the mouse offset from that
+ * element. To do this we need the element's position and the mouse position.
+ */
+Drupal.tableDrag.prototype.getMouseOffset = function (target, event) {
+ var docPos = $(target).offset();
+ var mousePos = this.mouseCoords(event);
+ return { x: mousePos.x - docPos.left, y: mousePos.y - docPos.top };
+};
+
+/**
+ * Find the row the mouse is currently over. This row is then taken and swapped
+ * with the one being dragged.
+ *
+ * @param x
+ * The x coordinate of the mouse on the page (not the screen).
+ * @param y
+ * The y coordinate of the mouse on the page (not the screen).
+ */
+Drupal.tableDrag.prototype.findDropTargetRow = function (x, y) {
+ var rows = $(this.table.tBodies[0].rows).not(':hidden');
+ for (var n = 0; n < rows.length; n++) {
+ var row = rows[n];
+ var indentDiff = 0;
+ var rowY = $(row).offset().top;
+ // Because Safari does not report offsetHeight on table rows, but does on
+ // table cells, grab the firstChild of the row and use that instead.
+ // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari.
+ if (row.offsetHeight == 0) {
+ var rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2;
+ }
+ // Other browsers.
+ else {
+ var rowHeight = parseInt(row.offsetHeight, 10) / 2;
+ }
+
+ // Because we always insert before, we need to offset the height a bit.
+ if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) {
+ if (this.indentEnabled) {
+ // Check that this row is not a child of the row being dragged.
+ for (var n in this.rowObject.group) {
+ if (this.rowObject.group[n] == row) {
+ return null;
+ }
+ }
+ }
+ else {
+ // Do not allow a row to be swapped with itself.
+ if (row == this.rowObject.element) {
+ return null;
+ }
+ }
+
+ // Check that swapping with this row is allowed.
+ if (!this.rowObject.isValidSwap(row)) {
+ return null;
+ }
+
+ // We may have found the row the mouse just passed over, but it doesn't
+ // take into account hidden rows. Skip backwards until we find a draggable
+ // row.
+ while ($(row).is(':hidden') && $(row).prev('tr').is(':hidden')) {
+ row = $(row).prev('tr').get(0);
+ }
+ return row;
+ }
+ }
+ return null;
+};
+
+/**
+ * After the row is dropped, update the table fields according to the settings
+ * set for this table.
+ *
+ * @param changedRow
+ * DOM object for the row that was just dropped.
+ */
+Drupal.tableDrag.prototype.updateFields = function (changedRow) {
+ for (var group in this.tableSettings) {
+ // Each group may have a different setting for relationship, so we find
+ // the source rows for each separately.
+ this.updateField(changedRow, group);
+ }
+};
+
+/**
+ * After the row is dropped, update a single table field according to specific
+ * settings.
+ *
+ * @param changedRow
+ * DOM object for the row that was just dropped.
+ * @param group
+ * The settings group on which field updates will occur.
+ */
+Drupal.tableDrag.prototype.updateField = function (changedRow, group) {
+ var rowSettings = this.rowSettings(group, changedRow);
+
+ // Set the row as its own target.
+ if (rowSettings.relationship == 'self' || rowSettings.relationship == 'group') {
+ var sourceRow = changedRow;
+ }
+ // Siblings are easy, check previous and next rows.
+ else if (rowSettings.relationship == 'sibling') {
+ var previousRow = $(changedRow).prev('tr').get(0);
+ var nextRow = $(changedRow).next('tr').get(0);
+ var sourceRow = changedRow;
+ if ($(previousRow).is('.draggable') && $('.' + group, previousRow).length) {
+ if (this.indentEnabled) {
+ if ($('.indentations', previousRow).size() == $('.indentations', changedRow)) {
+ sourceRow = previousRow;
+ }
+ }
+ else {
+ sourceRow = previousRow;
+ }
+ }
+ else if ($(nextRow).is('.draggable') && $('.' + group, nextRow).length) {
+ if (this.indentEnabled) {
+ if ($('.indentations', nextRow).size() == $('.indentations', changedRow)) {
+ sourceRow = nextRow;
+ }
+ }
+ else {
+ sourceRow = nextRow;
+ }
+ }
+ }
+ // Parents, look up the tree until we find a field not in this group.
+ // Go up as many parents as indentations in the changed row.
+ else if (rowSettings.relationship == 'parent') {
+ var previousRow = $(changedRow).prev('tr');
+ while (previousRow.length && $('.indentation', previousRow).length >= this.rowObject.indents) {
+ previousRow = previousRow.prev('tr');
+ }
+ // If we found a row.
+ if (previousRow.length) {
+ sourceRow = previousRow[0];
+ }
+ // Otherwise we went all the way to the left of the table without finding
+ // a parent, meaning this item has been placed at the root level.
+ else {
+ // Use the first row in the table as source, because it's guaranteed to
+ // be at the root level. Find the first item, then compare this row
+ // against it as a sibling.
+ sourceRow = $(this.table).find('tr.draggable:first').get(0);
+ if (sourceRow == this.rowObject.element) {
+ sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0);
+ }
+ var useSibling = true;
+ }
+ }
+
+ // Because we may have moved the row from one category to another,
+ // take a look at our sibling and borrow its sources and targets.
+ this.copyDragClasses(sourceRow, changedRow, group);
+ rowSettings = this.rowSettings(group, changedRow);
+
+ // In the case that we're looking for a parent, but the row is at the top
+ // of the tree, copy our sibling's values.
+ if (useSibling) {
+ rowSettings.relationship = 'sibling';
+ rowSettings.source = rowSettings.target;
+ }
+
+ var targetClass = '.' + rowSettings.target;
+ var targetElement = $(targetClass, changedRow).get(0);
+
+ // Check if a target element exists in this row.
+ if (targetElement) {
+ var sourceClass = '.' + rowSettings.source;
+ var sourceElement = $(sourceClass, sourceRow).get(0);
+ switch (rowSettings.action) {
+ case 'depth':
+ // Get the depth of the target row.
+ targetElement.value = $('.indentation', $(sourceElement).parents('tr:first')).size();
+ break;
+ case 'match':
+ // Update the value.
+ targetElement.value = sourceElement.value;
+ break;
+ case 'order':
+ var siblings = this.rowObject.findSiblings(rowSettings);
+ if ($(targetElement).is('select')) {
+ // Get a list of acceptable values.
+ var values = [];
+ $('option', targetElement).each(function () {
+ values.push(this.value);
+ });
+ var maxVal = values[values.length - 1];
+ // Populate the values in the siblings.
+ $(targetClass, siblings).each(function () {
+ // If there are more items than possible values, assign the maximum value to the row.
+ if (values.length > 0) {
+ this.value = values.shift();
+ }
+ else {
+ this.value = maxVal;
+ }
+ });
+ }
+ else {
+ // Assume a numeric input field.
+ var weight = parseInt($(targetClass, siblings[0]).val(), 10) || 0;
+ $(targetClass, siblings).each(function () {
+ this.value = weight;
+ weight++;
+ });
+ }
+ break;
+ }
+ }
+};
+
+/**
+ * Copy all special tableDrag classes from one row's form elements to a
+ * different one, removing any special classes that the destination row
+ * may have had.
+ */
+Drupal.tableDrag.prototype.copyDragClasses = function (sourceRow, targetRow, group) {
+ var sourceElement = $('.' + group, sourceRow);
+ var targetElement = $('.' + group, targetRow);
+ if (sourceElement.length && targetElement.length) {
+ targetElement[0].className = sourceElement[0].className;
+ }
+};
+
+Drupal.tableDrag.prototype.checkScroll = function (cursorY) {
+ var de = document.documentElement;
+ var b = document.body;
+
+ var windowHeight = this.windowHeight = window.innerHeight || (de.clientHeight && de.clientWidth != 0 ? de.clientHeight : b.offsetHeight);
+ var scrollY = this.scrollY = (document.all ? (!de.scrollTop ? b.scrollTop : de.scrollTop) : (window.pageYOffset ? window.pageYOffset : window.scrollY));
+ var trigger = this.scrollSettings.trigger;
+ var delta = 0;
+
+ // Return a scroll speed relative to the edge of the screen.
+ if (cursorY - scrollY > windowHeight - trigger) {
+ delta = trigger / (windowHeight + scrollY - cursorY);
+ delta = (delta > 0 && delta < trigger) ? delta : trigger;
+ return delta * this.scrollSettings.amount;
+ }
+ else if (cursorY - scrollY < trigger) {
+ delta = trigger / (cursorY - scrollY);
+ delta = (delta > 0 && delta < trigger) ? delta : trigger;
+ return -delta * this.scrollSettings.amount;
+ }
+};
+
+Drupal.tableDrag.prototype.setScroll = function (scrollAmount) {
+ var self = this;
+
+ this.scrollInterval = setInterval(function () {
+ // Update the scroll values stored in the object.
+ self.checkScroll(self.currentMouseCoords.y);
+ var aboveTable = self.scrollY > self.table.topY;
+ var belowTable = self.scrollY + self.windowHeight < self.table.bottomY;
+ if (scrollAmount > 0 && belowTable || scrollAmount < 0 && aboveTable) {
+ window.scrollBy(0, scrollAmount);
+ }
+ }, this.scrollSettings.interval);
+};
+
+Drupal.tableDrag.prototype.restripeTable = function () {
+ // :even and :odd are reversed because jQuery counts from 0 and
+ // we count from 1, so we're out of sync.
+ // Match immediate children of the parent element to allow nesting.
+ $('> tbody > tr.draggable:visible, > tr.draggable:visible', this.table)
+ .removeClass('odd even')
+ .filter(':odd').addClass('even').end()
+ .filter(':even').addClass('odd');
+};
+
+/**
+ * Stub function. Allows a custom handler when a row begins dragging.
+ */
+Drupal.tableDrag.prototype.onDrag = function () {
+ return null;
+};
+
+/**
+ * Stub function. Allows a custom handler when a row is dropped.
+ */
+Drupal.tableDrag.prototype.onDrop = function () {
+ return null;
+};
+
+/**
+ * Constructor to make a new object to manipulate a table row.
+ *
+ * @param tableRow
+ * The DOM element for the table row we will be manipulating.
+ * @param method
+ * The method in which this row is being moved. Either 'keyboard' or 'mouse'.
+ * @param indentEnabled
+ * Whether the containing table uses indentations. Used for optimizations.
+ * @param maxDepth
+ * The maximum amount of indentations this row may contain.
+ * @param addClasses
+ * Whether we want to add classes to this row to indicate child relationships.
+ */
+Drupal.tableDrag.prototype.row = function (tableRow, method, indentEnabled, maxDepth, addClasses) {
+ this.element = tableRow;
+ this.method = method;
+ this.group = [tableRow];
+ this.groupDepth = $('.indentation', tableRow).size();
+ this.changed = false;
+ this.table = $(tableRow).parents('table:first').get(0);
+ this.indentEnabled = indentEnabled;
+ this.maxDepth = maxDepth;
+ this.direction = ''; // Direction the row is being moved.
+
+ if (this.indentEnabled) {
+ this.indents = $('.indentation', tableRow).size();
+ this.children = this.findChildren(addClasses);
+ this.group = $.merge(this.group, this.children);
+ // Find the depth of this entire group.
+ for (var n = 0; n < this.group.length; n++) {
+ this.groupDepth = Math.max($('.indentation', this.group[n]).size(), this.groupDepth);
+ }
+ }
+};
+
+/**
+ * Find all children of rowObject by indentation.
+ *
+ * @param addClasses
+ * Whether we want to add classes to this row to indicate child relationships.
+ */
+Drupal.tableDrag.prototype.row.prototype.findChildren = function (addClasses) {
+ var parentIndentation = this.indents;
+ var currentRow = $(this.element, this.table).next('tr.draggable');
+ var rows = [];
+ var child = 0;
+ while (currentRow.length) {
+ var rowIndentation = $('.indentation', currentRow).length;
+ // A greater indentation indicates this is a child.
+ if (rowIndentation > parentIndentation) {
+ child++;
+ rows.push(currentRow[0]);
+ if (addClasses) {
+ $('.indentation', currentRow).each(function (indentNum) {
+ if (child == 1 && (indentNum == parentIndentation)) {
+ $(this).addClass('tree-child-first');
+ }
+ if (indentNum == parentIndentation) {
+ $(this).addClass('tree-child');
+ }
+ else if (indentNum > parentIndentation) {
+ $(this).addClass('tree-child-horizontal');
+ }
+ });
+ }
+ }
+ else {
+ break;
+ }
+ currentRow = currentRow.next('tr.draggable');
+ }
+ if (addClasses && rows.length) {
+ $('.indentation:nth-child(' + (parentIndentation + 1) + ')', rows[rows.length - 1]).addClass('tree-child-last');
+ }
+ return rows;
+};
+
+/**
+ * Ensure that two rows are allowed to be swapped.
+ *
+ * @param row
+ * DOM object for the row being considered for swapping.
+ */
+Drupal.tableDrag.prototype.row.prototype.isValidSwap = function (row) {
+ if (this.indentEnabled) {
+ var prevRow, nextRow;
+ if (this.direction == 'down') {
+ prevRow = row;
+ nextRow = $(row).next('tr').get(0);
+ }
+ else {
+ prevRow = $(row).prev('tr').get(0);
+ nextRow = row;
+ }
+ this.interval = this.validIndentInterval(prevRow, nextRow);
+
+ // We have an invalid swap if the valid indentations interval is empty.
+ if (this.interval.min > this.interval.max) {
+ return false;
+ }
+ }
+
+ // Do not let an un-draggable first row have anything put before it.
+ if (this.table.tBodies[0].rows[0] == row && $(row).is(':not(.draggable)')) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Perform the swap between two rows.
+ *
+ * @param position
+ * Whether the swap will occur 'before' or 'after' the given row.
+ * @param row
+ * DOM element what will be swapped with the row group.
+ */
+Drupal.tableDrag.prototype.row.prototype.swap = function (position, row) {
+ Drupal.detachBehaviors(this.group, Drupal.settings, 'move');
+ $(row)[position](this.group);
+ Drupal.attachBehaviors(this.group, Drupal.settings);
+ this.changed = true;
+ this.onSwap(row);
+};
+
+/**
+ * Determine the valid indentations interval for the row at a given position
+ * in the table.
+ *
+ * @param prevRow
+ * DOM object for the row before the tested position
+ * (or null for first position in the table).
+ * @param nextRow
+ * DOM object for the row after the tested position
+ * (or null for last position in the table).
+ */
+Drupal.tableDrag.prototype.row.prototype.validIndentInterval = function (prevRow, nextRow) {
+ var minIndent, maxIndent;
+
+ // Minimum indentation:
+ // Do not orphan the next row.
+ minIndent = nextRow ? $('.indentation', nextRow).size() : 0;
+
+ // Maximum indentation:
+ if (!prevRow || $(prevRow).is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) {
+ // Do not indent:
+ // - the first row in the table,
+ // - rows dragged below a non-draggable row,
+ // - 'root' rows.
+ maxIndent = 0;
+ }
+ else {
+ // Do not go deeper than as a child of the previous row.
+ maxIndent = $('.indentation', prevRow).size() + ($(prevRow).is('.tabledrag-leaf') ? 0 : 1);
+ // Limit by the maximum allowed depth for the table.
+ if (this.maxDepth) {
+ maxIndent = Math.min(maxIndent, this.maxDepth - (this.groupDepth - this.indents));
+ }
+ }
+
+ return { 'min': minIndent, 'max': maxIndent };
+};
+
+/**
+ * Indent a row within the legal bounds of the table.
+ *
+ * @param indentDiff
+ * The number of additional indentations proposed for the row (can be
+ * positive or negative). This number will be adjusted to nearest valid
+ * indentation level for the row.
+ */
+Drupal.tableDrag.prototype.row.prototype.indent = function (indentDiff) {
+ // Determine the valid indentations interval if not available yet.
+ if (!this.interval) {
+ prevRow = $(this.element).prev('tr').get(0);
+ nextRow = $(this.group).filter(':last').next('tr').get(0);
+ this.interval = this.validIndentInterval(prevRow, nextRow);
+ }
+
+ // Adjust to the nearest valid indentation.
+ var indent = this.indents + indentDiff;
+ indent = Math.max(indent, this.interval.min);
+ indent = Math.min(indent, this.interval.max);
+ indentDiff = indent - this.indents;
+
+ for (var n = 1; n <= Math.abs(indentDiff); n++) {
+ // Add or remove indentations.
+ if (indentDiff < 0) {
+ $('.indentation:first', this.group).remove();
+ this.indents--;
+ }
+ else {
+ $('td:first', this.group).prepend(Drupal.theme('tableDragIndentation'));
+ this.indents++;
+ }
+ }
+ if (indentDiff) {
+ // Update indentation for this row.
+ this.changed = true;
+ this.groupDepth += indentDiff;
+ this.onIndent();
+ }
+
+ return indentDiff;
+};
+
+/**
+ * Find all siblings for a row, either according to its subgroup or indentation.
+ * Note that the passed-in row is included in the list of siblings.
+ *
+ * @param settings
+ * The field settings we're using to identify what constitutes a sibling.
+ */
+Drupal.tableDrag.prototype.row.prototype.findSiblings = function (rowSettings) {
+ var siblings = [];
+ var directions = ['prev', 'next'];
+ var rowIndentation = this.indents;
+ for (var d = 0; d < directions.length; d++) {
+ var checkRow = $(this.element)[directions[d]]();
+ while (checkRow.length) {
+ // Check that the sibling contains a similar target field.
+ if ($('.' + rowSettings.target, checkRow)) {
+ // Either add immediately if this is a flat table, or check to ensure
+ // that this row has the same level of indentation.
+ if (this.indentEnabled) {
+ var checkRowIndentation = $('.indentation', checkRow).length;
+ }
+
+ if (!(this.indentEnabled) || (checkRowIndentation == rowIndentation)) {
+ siblings.push(checkRow[0]);
+ }
+ else if (checkRowIndentation < rowIndentation) {
+ // No need to keep looking for siblings when we get to a parent.
+ break;
+ }
+ }
+ else {
+ break;
+ }
+ checkRow = $(checkRow)[directions[d]]();
+ }
+ // Since siblings are added in reverse order for previous, reverse the
+ // completed list of previous siblings. Add the current row and continue.
+ if (directions[d] == 'prev') {
+ siblings.reverse();
+ siblings.push(this.element);
+ }
+ }
+ return siblings;
+};
+
+/**
+ * Remove indentation helper classes from the current row group.
+ */
+Drupal.tableDrag.prototype.row.prototype.removeIndentClasses = function () {
+ for (var n in this.children) {
+ $('.indentation', this.children[n])
+ .removeClass('tree-child')
+ .removeClass('tree-child-first')
+ .removeClass('tree-child-last')
+ .removeClass('tree-child-horizontal');
+ }
+};
+
+/**
+ * Add an asterisk or other marker to the changed row.
+ */
+Drupal.tableDrag.prototype.row.prototype.markChanged = function () {
+ var marker = Drupal.theme('tableDragChangedMarker');
+ var cell = $('td:first', this.element);
+ if ($('abbr.tabledrag-changed', cell).length == 0) {
+ cell.append(marker);
+ }
+};
+
+/**
+ * Stub function. Allows a custom handler when a row is indented.
+ */
+Drupal.tableDrag.prototype.row.prototype.onIndent = function () {
+ return null;
+};
+
+/**
+ * Stub function. Allows a custom handler when a row is swapped.
+ */
+Drupal.tableDrag.prototype.row.prototype.onSwap = function (swappedRow) {
+ return null;
+};
+
+Drupal.theme.prototype.tableDragChangedMarker = function () {
+ return '<abbr class="warning tabledrag-changed" title="' + Drupal.t('Changed') + '">*</abbr>';
+};
+
+Drupal.theme.prototype.tableDragIndentation = function () {
+ return '<div class="indentation">&nbsp;</div>';
+};
+
+Drupal.theme.prototype.tableDragChangedWarning = function () {
+ return '<div class="tabledrag-changed-warning messages warning">' + Drupal.theme('tableDragChangedMarker') + ' ' + Drupal.t('Changes made in this table will not be saved until the form is submitted.') + '</div>';
+};
+
+})(jQuery);
diff --git a/core/misc/tableheader.js b/core/misc/tableheader.js
new file mode 100644
index 000000000000..949ef5212fc8
--- /dev/null
+++ b/core/misc/tableheader.js
@@ -0,0 +1,111 @@
+(function ($) {
+
+/**
+ * Attaches sticky table headers.
+ */
+Drupal.behaviors.tableHeader = {
+ attach: function (context, settings) {
+ if (!$.support.positionFixed) {
+ return;
+ }
+
+ $('table.sticky-enabled', context).once('tableheader', function () {
+ $(this).data("drupal-tableheader", new Drupal.tableHeader(this));
+ });
+ }
+};
+
+/**
+ * Constructor for the tableHeader object. Provides sticky table headers.
+ *
+ * @param table
+ * DOM object for the table to add a sticky header to.
+ */
+Drupal.tableHeader = function (table) {
+ var self = this;
+
+ this.originalTable = $(table);
+ this.originalHeader = $(table).children('thead');
+ this.originalHeaderCells = this.originalHeader.find('> tr > th');
+
+ // Clone the table header so it inherits original jQuery properties. Hide
+ // the table to avoid a flash of the header clone upon page load.
+ this.stickyTable = $('<table class="sticky-header"/>')
+ .insertBefore(this.originalTable)
+ .css({ position: 'fixed', top: '0px' });
+ this.stickyHeader = this.originalHeader.clone(true)
+ .hide()
+ .appendTo(this.stickyTable);
+ this.stickyHeaderCells = this.stickyHeader.find('> tr > th');
+
+ this.originalTable.addClass('sticky-table');
+ $(window)
+ .bind('scroll.drupal-tableheader', $.proxy(this, 'eventhandlerRecalculateStickyHeader'))
+ .bind('resize.drupal-tableheader', { calculateWidth: true }, $.proxy(this, 'eventhandlerRecalculateStickyHeader'))
+ // Make sure the anchor being scrolled into view is not hidden beneath the
+ // sticky table header. Adjust the scrollTop if it does.
+ .bind('drupalDisplaceAnchor.drupal-tableheader', function () {
+ window.scrollBy(0, -self.stickyTable.outerHeight());
+ })
+ // Make sure the element being focused is not hidden beneath the sticky
+ // table header. Adjust the scrollTop if it does.
+ .bind('drupalDisplaceFocus.drupal-tableheader', function (event) {
+ if (self.stickyVisible && event.clientY < (self.stickyOffsetTop + self.stickyTable.outerHeight()) && event.$target.closest('sticky-header').length === 0) {
+ window.scrollBy(0, -self.stickyTable.outerHeight());
+ }
+ })
+ .triggerHandler('resize.drupal-tableheader');
+
+ // We hid the header to avoid it showing up erroneously on page load;
+ // we need to unhide it now so that it will show up when expected.
+ this.stickyHeader.show();
+};
+
+/**
+ * Event handler: recalculates position of the sticky table header.
+ *
+ * @param event
+ * Event being triggered.
+ */
+Drupal.tableHeader.prototype.eventhandlerRecalculateStickyHeader = function (event) {
+ var self = this;
+ var calculateWidth = event.data && event.data.calculateWidth;
+
+ // Reset top position of sticky table headers to the current top offset.
+ this.stickyOffsetTop = Drupal.settings.tableHeaderOffset ? eval(Drupal.settings.tableHeaderOffset + '()') : 0;
+ this.stickyTable.css('top', this.stickyOffsetTop + 'px');
+
+ // Save positioning data.
+ var viewHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
+ if (calculateWidth || this.viewHeight !== viewHeight) {
+ this.viewHeight = viewHeight;
+ this.vPosition = this.originalTable.offset().top - 4 - this.stickyOffsetTop;
+ this.hPosition = this.originalTable.offset().left;
+ this.vLength = this.originalTable[0].clientHeight - 100;
+ calculateWidth = true;
+ }
+
+ // Track horizontal positioning relative to the viewport and set visibility.
+ var hScroll = document.documentElement.scrollLeft || document.body.scrollLeft;
+ var vOffset = (document.documentElement.scrollTop || document.body.scrollTop) - this.vPosition;
+ this.stickyVisible = vOffset > 0 && vOffset < this.vLength;
+ this.stickyTable.css({ left: (-hScroll + this.hPosition) + 'px', visibility: this.stickyVisible ? 'visible' : 'hidden' });
+
+ // Only perform expensive calculations if the sticky header is actually
+ // visible or when forced.
+ if (this.stickyVisible && (calculateWidth || !this.widthCalculated)) {
+ this.widthCalculated = true;
+ // Resize header and its cell widths.
+ this.stickyHeaderCells.each(function (index) {
+ var cellWidth = self.originalHeaderCells.eq(index).css('width');
+ // Exception for IE7.
+ if (cellWidth == 'auto') {
+ cellWidth = self.originalHeaderCells.get(index).clientWidth + 'px';
+ }
+ $(this).css('width', cellWidth);
+ });
+ this.stickyTable.css('width', this.originalTable.css('width'));
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/tableselect.js b/core/misc/tableselect.js
new file mode 100644
index 000000000000..1abda24d9517
--- /dev/null
+++ b/core/misc/tableselect.js
@@ -0,0 +1,90 @@
+(function ($) {
+
+Drupal.behaviors.tableSelect = {
+ attach: function (context, settings) {
+ $('table:has(th.select-all)', context).once('table-select', Drupal.tableSelect);
+ }
+};
+
+Drupal.tableSelect = function () {
+ // Do not add a "Select all" checkbox if there are no rows with checkboxes in the table
+ if ($('td input:checkbox', this).size() == 0) {
+ return;
+ }
+
+ // Keep track of the table, which checkbox is checked and alias the settings.
+ var table = this, checkboxes, lastChecked;
+ var strings = { 'selectAll': Drupal.t('Select all rows in this table'), 'selectNone': Drupal.t('Deselect all rows in this table') };
+ var updateSelectAll = function (state) {
+ $('th.select-all input:checkbox', table).each(function () {
+ $(this).attr('title', state ? strings.selectNone : strings.selectAll);
+ this.checked = state;
+ });
+ };
+
+ // Find all <th> with class select-all, and insert the check all checkbox.
+ $('th.select-all', table).prepend($('<input type="checkbox" class="form-checkbox" />').attr('title', strings.selectAll)).click(function (event) {
+ if ($(event.target).is('input:checkbox')) {
+ // Loop through all checkboxes and set their state to the select all checkbox' state.
+ checkboxes.each(function () {
+ this.checked = event.target.checked;
+ // Either add or remove the selected class based on the state of the check all checkbox.
+ $(this).parents('tr:first')[ this.checked ? 'addClass' : 'removeClass' ]('selected');
+ });
+ // Update the title and the state of the check all box.
+ updateSelectAll(event.target.checked);
+ }
+ });
+
+ // For each of the checkboxes within the table that are not disabled.
+ checkboxes = $('td input:checkbox:enabled', table).click(function (e) {
+ // Either add or remove the selected class based on the state of the check all checkbox.
+ $(this).parents('tr:first')[ this.checked ? 'addClass' : 'removeClass' ]('selected');
+
+ // If this is a shift click, we need to highlight everything in the range.
+ // Also make sure that we are actually checking checkboxes over a range and
+ // that a checkbox has been checked or unchecked before.
+ if (e.shiftKey && lastChecked && lastChecked != e.target) {
+ // We use the checkbox's parent TR to do our range searching.
+ Drupal.tableSelectRange($(e.target).parents('tr')[0], $(lastChecked).parents('tr')[0], e.target.checked);
+ }
+
+ // If all checkboxes are checked, make sure the select-all one is checked too, otherwise keep unchecked.
+ updateSelectAll((checkboxes.length == $(checkboxes).filter(':checked').length));
+
+ // Keep track of the last checked checkbox.
+ lastChecked = e.target;
+ });
+};
+
+Drupal.tableSelectRange = function (from, to, state) {
+ // We determine the looping mode based on the the order of from and to.
+ var mode = from.rowIndex > to.rowIndex ? 'previousSibling' : 'nextSibling';
+
+ // Traverse through the sibling nodes.
+ for (var i = from[mode]; i; i = i[mode]) {
+ // Make sure that we're only dealing with elements.
+ if (i.nodeType != 1) {
+ continue;
+ }
+
+ // Either add or remove the selected class based on the state of the target checkbox.
+ $(i)[ state ? 'addClass' : 'removeClass' ]('selected');
+ $('input:checkbox', i).each(function () {
+ this.checked = state;
+ });
+
+ if (to.nodeType) {
+ // If we are at the end of the range, stop.
+ if (i == to) {
+ break;
+ }
+ }
+ // A faster alternative to doing $(i).filter(to).length.
+ else if ($.filter(to, [i]).r.length) {
+ break;
+ }
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/textarea.js b/core/misc/textarea.js
new file mode 100644
index 000000000000..0ab5e71201f6
--- /dev/null
+++ b/core/misc/textarea.js
@@ -0,0 +1,32 @@
+(function ($) {
+
+Drupal.behaviors.textarea = {
+ attach: function (context, settings) {
+ $('.form-textarea-wrapper.resizable', context).once('textarea', function () {
+ var staticOffset = null;
+ var textarea = $(this).addClass('resizable-textarea').find('textarea');
+ var grippie = $('<div class="grippie"></div>').mousedown(startDrag);
+
+ grippie.insertAfter(textarea);
+
+ function startDrag(e) {
+ staticOffset = textarea.height() - e.pageY;
+ textarea.css('opacity', 0.25);
+ $(document).mousemove(performDrag).mouseup(endDrag);
+ return false;
+ }
+
+ function performDrag(e) {
+ textarea.height(Math.max(32, staticOffset + e.pageY) + 'px');
+ return false;
+ }
+
+ function endDrag(e) {
+ $(document).unbind('mousemove', performDrag).unbind('mouseup', endDrag);
+ textarea.css('opacity', 1);
+ }
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/throbber.gif b/core/misc/throbber.gif
new file mode 100644
index 000000000000..4352e64e859b
--- /dev/null
+++ b/core/misc/throbber.gif
Binary files differ
diff --git a/core/misc/timezone.js b/core/misc/timezone.js
new file mode 100644
index 000000000000..544973040360
--- /dev/null
+++ b/core/misc/timezone.js
@@ -0,0 +1,66 @@
+(function ($) {
+
+/**
+ * Set the client's system time zone as default values of form fields.
+ */
+Drupal.behaviors.setTimezone = {
+ attach: function (context, settings) {
+ $('select.timezone-detect', context).once('timezone', function () {
+ var dateString = Date();
+ // In some client environments, date strings include a time zone
+ // abbreviation, between 3 and 5 letters enclosed in parentheses,
+ // which can be interpreted by PHP.
+ var matches = dateString.match(/\(([A-Z]{3,5})\)/);
+ var abbreviation = matches ? matches[1] : 0;
+
+ // For all other client environments, the abbreviation is set to "0"
+ // and the current offset from UTC and daylight saving time status are
+ // used to guess the time zone.
+ var dateNow = new Date();
+ var offsetNow = dateNow.getTimezoneOffset() * -60;
+
+ // Use January 1 and July 1 as test dates for determining daylight
+ // saving time status by comparing their offsets.
+ var dateJan = new Date(dateNow.getFullYear(), 0, 1, 12, 0, 0, 0);
+ var dateJul = new Date(dateNow.getFullYear(), 6, 1, 12, 0, 0, 0);
+ var offsetJan = dateJan.getTimezoneOffset() * -60;
+ var offsetJul = dateJul.getTimezoneOffset() * -60;
+
+ var isDaylightSavingTime;
+ // If the offset from UTC is identical on January 1 and July 1,
+ // assume daylight saving time is not used in this time zone.
+ if (offsetJan == offsetJul) {
+ isDaylightSavingTime = '';
+ }
+ // If the maximum annual offset is equivalent to the current offset,
+ // assume daylight saving time is in effect.
+ else if (Math.max(offsetJan, offsetJul) == offsetNow) {
+ isDaylightSavingTime = 1;
+ }
+ // Otherwise, assume daylight saving time is not in effect.
+ else {
+ isDaylightSavingTime = 0;
+ }
+
+ // Submit request to the system/timezone callback and set the form field
+ // to the response time zone. The client date is passed to the callback
+ // for debugging purposes. Submit a synchronous request to avoid database
+ // errors associated with concurrent requests during install.
+ var path = 'system/timezone/' + abbreviation + '/' + offsetNow + '/' + isDaylightSavingTime;
+ var element = this;
+ $.ajax({
+ async: false,
+ url: settings.basePath,
+ data: { q: path, date: dateString },
+ dataType: 'json',
+ success: function (data) {
+ if (data) {
+ $(element).val(data);
+ }
+ }
+ });
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/misc/tree-bottom.png b/core/misc/tree-bottom.png
new file mode 100644
index 000000000000..a55804571e9c
--- /dev/null
+++ b/core/misc/tree-bottom.png
Binary files differ
diff --git a/core/misc/tree.png b/core/misc/tree.png
new file mode 100644
index 000000000000..89ea23501453
--- /dev/null
+++ b/core/misc/tree.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_flat_0_aaaaaa_40x100.png b/core/misc/ui/images/ui-bg_flat_0_aaaaaa_40x100.png
new file mode 100644
index 000000000000..5b5dab2ab7b1
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_flat_0_aaaaaa_40x100.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_flat_75_ffffff_40x100.png b/core/misc/ui/images/ui-bg_flat_75_ffffff_40x100.png
new file mode 100644
index 000000000000..ac8b229af950
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_flat_75_ffffff_40x100.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_glass_55_fbf9ee_1x400.png b/core/misc/ui/images/ui-bg_glass_55_fbf9ee_1x400.png
new file mode 100644
index 000000000000..ad3d6346e00f
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_glass_55_fbf9ee_1x400.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_glass_65_ffffff_1x400.png b/core/misc/ui/images/ui-bg_glass_65_ffffff_1x400.png
new file mode 100644
index 000000000000..42ccba269b6e
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_glass_65_ffffff_1x400.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_glass_75_dadada_1x400.png b/core/misc/ui/images/ui-bg_glass_75_dadada_1x400.png
new file mode 100644
index 000000000000..5a46b47cb166
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_glass_75_dadada_1x400.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_glass_75_e6e6e6_1x400.png b/core/misc/ui/images/ui-bg_glass_75_e6e6e6_1x400.png
new file mode 100644
index 000000000000..86c2baa655ea
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_glass_75_e6e6e6_1x400.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_glass_95_fef1ec_1x400.png b/core/misc/ui/images/ui-bg_glass_95_fef1ec_1x400.png
new file mode 100644
index 000000000000..4443fdc1a156
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_glass_95_fef1ec_1x400.png
Binary files differ
diff --git a/core/misc/ui/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/core/misc/ui/images/ui-bg_highlight-soft_75_cccccc_1x100.png
new file mode 100644
index 000000000000..7c9fa6c6edcf
--- /dev/null
+++ b/core/misc/ui/images/ui-bg_highlight-soft_75_cccccc_1x100.png
Binary files differ
diff --git a/core/misc/ui/images/ui-icons_222222_256x240.png b/core/misc/ui/images/ui-icons_222222_256x240.png
new file mode 100644
index 000000000000..ee039dc096a3
--- /dev/null
+++ b/core/misc/ui/images/ui-icons_222222_256x240.png
Binary files differ
diff --git a/core/misc/ui/images/ui-icons_2e83ff_256x240.png b/core/misc/ui/images/ui-icons_2e83ff_256x240.png
new file mode 100644
index 000000000000..45e8928e5284
--- /dev/null
+++ b/core/misc/ui/images/ui-icons_2e83ff_256x240.png
Binary files differ
diff --git a/core/misc/ui/images/ui-icons_454545_256x240.png b/core/misc/ui/images/ui-icons_454545_256x240.png
new file mode 100644
index 000000000000..7ec70d11bfb2
--- /dev/null
+++ b/core/misc/ui/images/ui-icons_454545_256x240.png
Binary files differ
diff --git a/core/misc/ui/images/ui-icons_888888_256x240.png b/core/misc/ui/images/ui-icons_888888_256x240.png
new file mode 100644
index 000000000000..5ba708c39172
--- /dev/null
+++ b/core/misc/ui/images/ui-icons_888888_256x240.png
Binary files differ
diff --git a/core/misc/ui/images/ui-icons_cd0a0a_256x240.png b/core/misc/ui/images/ui-icons_cd0a0a_256x240.png
new file mode 100644
index 000000000000..7930a558099b
--- /dev/null
+++ b/core/misc/ui/images/ui-icons_cd0a0a_256x240.png
Binary files differ
diff --git a/core/misc/ui/jquery.effects.blind.min.js b/core/misc/ui/jquery.effects.blind.min.js
new file mode 100644
index 000000000000..ed7c74f100b5
--- /dev/null
+++ b/core/misc/ui/jquery.effects.blind.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Blind 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Blind
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(b){b.effects.blind=function(c){return this.queue(function(){var a=b(this),g=["position","top","left"],f=b.effects.setMode(a,c.options.mode||"hide"),d=c.options.direction||"vertical";b.effects.save(a,g);a.show();var e=b.effects.createWrapper(a).css({overflow:"hidden"}),h=d=="vertical"?"height":"width";d=d=="vertical"?e.height():e.width();f=="show"&&e.css(h,0);var i={};i[h]=f=="show"?d:0;e.animate(i,c.duration,c.options.easing,function(){f=="hide"&&a.hide();b.effects.restore(a,g);b.effects.removeWrapper(a);
+c.callback&&c.callback.apply(a[0],arguments);a.dequeue()})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.bounce.min.js b/core/misc/ui/jquery.effects.bounce.min.js
new file mode 100644
index 000000000000..ca63813f7beb
--- /dev/null
+++ b/core/misc/ui/jquery.effects.bounce.min.js
@@ -0,0 +1,16 @@
+
+/*
+ * jQuery UI Effects Bounce 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Bounce
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(e){e.effects.bounce=function(b){return this.queue(function(){var a=e(this),l=["position","top","left"],h=e.effects.setMode(a,b.options.mode||"effect"),d=b.options.direction||"up",c=b.options.distance||20,m=b.options.times||5,i=b.duration||250;/show|hide/.test(h)&&l.push("opacity");e.effects.save(a,l);a.show();e.effects.createWrapper(a);var f=d=="up"||d=="down"?"top":"left";d=d=="up"||d=="left"?"pos":"neg";c=b.options.distance||(f=="top"?a.outerHeight({margin:true})/3:a.outerWidth({margin:true})/
+3);if(h=="show")a.css("opacity",0).css(f,d=="pos"?-c:c);if(h=="hide")c/=m*2;h!="hide"&&m--;if(h=="show"){var g={opacity:1};g[f]=(d=="pos"?"+=":"-=")+c;a.animate(g,i/2,b.options.easing);c/=2;m--}for(g=0;g<m;g++){var j={},k={};j[f]=(d=="pos"?"-=":"+=")+c;k[f]=(d=="pos"?"+=":"-=")+c;a.animate(j,i/2,b.options.easing).animate(k,i/2,b.options.easing);c=h=="hide"?c*2:c/2}if(h=="hide"){g={opacity:0};g[f]=(d=="pos"?"-=":"+=")+c;a.animate(g,i/2,b.options.easing,function(){a.hide();e.effects.restore(a,l);e.effects.removeWrapper(a);
+b.callback&&b.callback.apply(this,arguments)})}else{j={};k={};j[f]=(d=="pos"?"-=":"+=")+c;k[f]=(d=="pos"?"+=":"-=")+c;a.animate(j,i/2,b.options.easing).animate(k,i/2,b.options.easing,function(){e.effects.restore(a,l);e.effects.removeWrapper(a);b.callback&&b.callback.apply(this,arguments)})}a.queue("fx",function(){a.dequeue()});a.dequeue()})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.clip.min.js b/core/misc/ui/jquery.effects.clip.min.js
new file mode 100644
index 000000000000..75966ec4d1f6
--- /dev/null
+++ b/core/misc/ui/jquery.effects.clip.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Clip 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Clip
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(b){b.effects.clip=function(e){return this.queue(function(){var a=b(this),i=["position","top","left","height","width"],f=b.effects.setMode(a,e.options.mode||"hide"),c=e.options.direction||"vertical";b.effects.save(a,i);a.show();var d=b.effects.createWrapper(a).css({overflow:"hidden"});d=a[0].tagName=="IMG"?d:a;var g={size:c=="vertical"?"height":"width",position:c=="vertical"?"top":"left"};c=c=="vertical"?d.height():d.width();if(f=="show"){d.css(g.size,0);d.css(g.position,c/2)}var h={};h[g.size]=
+f=="show"?c:0;h[g.position]=f=="show"?0:c/2;d.animate(h,{queue:false,duration:e.duration,easing:e.options.easing,complete:function(){f=="hide"&&a.hide();b.effects.restore(a,i);b.effects.removeWrapper(a);e.callback&&e.callback.apply(a[0],arguments);a.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.core.min.js b/core/misc/ui/jquery.effects.core.min.js
new file mode 100644
index 000000000000..40c15504ad99
--- /dev/null
+++ b/core/misc/ui/jquery.effects.core.min.js
@@ -0,0 +1,31 @@
+
+/*
+ * jQuery UI Effects 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/
+ */
+jQuery.effects||function(f,j){function n(c){var a;if(c&&c.constructor==Array&&c.length==3)return c;if(a=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(c))return[parseInt(a[1],10),parseInt(a[2],10),parseInt(a[3],10)];if(a=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(c))return[parseFloat(a[1])*2.55,parseFloat(a[2])*2.55,parseFloat(a[3])*2.55];if(a=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(c))return[parseInt(a[1],
+16),parseInt(a[2],16),parseInt(a[3],16)];if(a=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(c))return[parseInt(a[1]+a[1],16),parseInt(a[2]+a[2],16),parseInt(a[3]+a[3],16)];if(/rgba\(0, 0, 0, 0\)/.exec(c))return o.transparent;return o[f.trim(c).toLowerCase()]}function s(c,a){var b;do{b=f.curCSS(c,a);if(b!=""&&b!="transparent"||f.nodeName(c,"body"))break;a="backgroundColor"}while(c=c.parentNode);return n(b)}function p(){var c=document.defaultView?document.defaultView.getComputedStyle(this,null):this.currentStyle,
+a={},b,d;if(c&&c.length&&c[0]&&c[c[0]])for(var e=c.length;e--;){b=c[e];if(typeof c[b]=="string"){d=b.replace(/\-(\w)/g,function(g,h){return h.toUpperCase()});a[d]=c[b]}}else for(b in c)if(typeof c[b]==="string")a[b]=c[b];return a}function q(c){var a,b;for(a in c){b=c[a];if(b==null||f.isFunction(b)||a in t||/scrollbar/.test(a)||!/color/i.test(a)&&isNaN(parseFloat(b)))delete c[a]}return c}function u(c,a){var b={_:0},d;for(d in a)if(c[d]!=a[d])b[d]=a[d];return b}function k(c,a,b,d){if(typeof c=="object"){d=
+a;b=null;a=c;c=a.effect}if(f.isFunction(a)){d=a;b=null;a={}}if(typeof a=="number"||f.fx.speeds[a]){d=b;b=a;a={}}if(f.isFunction(b)){d=b;b=null}a=a||{};b=b||a.duration;b=f.fx.off?0:typeof b=="number"?b:b in f.fx.speeds?f.fx.speeds[b]:f.fx.speeds._default;d=d||a.complete;return[c,a,b,d]}function m(c){if(!c||typeof c==="number"||f.fx.speeds[c])return true;if(typeof c==="string"&&!f.effects[c])return true;return false}f.effects={};f.each(["backgroundColor","borderBottomColor","borderLeftColor","borderRightColor",
+"borderTopColor","borderColor","color","outlineColor"],function(c,a){f.fx.step[a]=function(b){if(!b.colorInit){b.start=s(b.elem,a);b.end=n(b.end);b.colorInit=true}b.elem.style[a]="rgb("+Math.max(Math.min(parseInt(b.pos*(b.end[0]-b.start[0])+b.start[0],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[1]-b.start[1])+b.start[1],10),255),0)+","+Math.max(Math.min(parseInt(b.pos*(b.end[2]-b.start[2])+b.start[2],10),255),0)+")"}});var o={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,
+0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,
+211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0],transparent:[255,255,255]},r=["add","remove","toggle"],t={border:1,borderBottom:1,borderColor:1,borderLeft:1,borderRight:1,borderTop:1,borderWidth:1,margin:1,padding:1};f.effects.animateClass=function(c,a,b,
+d){if(f.isFunction(b)){d=b;b=null}return this.each(function(){f.queue(this,"fx",function(){var e=f(this),g=e.attr("style")||" ",h=q(p.call(this)),l,v=e.attr("className");f.each(r,function(w,i){c[i]&&e[i+"Class"](c[i])});l=q(p.call(this));e.attr("className",v);e.animate(u(h,l),a,b,function(){f.each(r,function(w,i){c[i]&&e[i+"Class"](c[i])});if(typeof e.attr("style")=="object"){e.attr("style").cssText="";e.attr("style").cssText=g}else e.attr("style",g);d&&d.apply(this,arguments)});h=f.queue(this);l=
+h.splice(h.length-1,1)[0];h.splice(1,0,l);f.dequeue(this)})})};f.fn.extend({_addClass:f.fn.addClass,addClass:function(c,a,b,d){return a?f.effects.animateClass.apply(this,[{add:c},a,b,d]):this._addClass(c)},_removeClass:f.fn.removeClass,removeClass:function(c,a,b,d){return a?f.effects.animateClass.apply(this,[{remove:c},a,b,d]):this._removeClass(c)},_toggleClass:f.fn.toggleClass,toggleClass:function(c,a,b,d,e){return typeof a=="boolean"||a===j?b?f.effects.animateClass.apply(this,[a?{add:c}:{remove:c},
+b,d,e]):this._toggleClass(c,a):f.effects.animateClass.apply(this,[{toggle:c},a,b,d])},switchClass:function(c,a,b,d,e){return f.effects.animateClass.apply(this,[{add:a,remove:c},b,d,e])}});f.extend(f.effects,{version:"1.8.7",save:function(c,a){for(var b=0;b<a.length;b++)a[b]!==null&&c.data("ec.storage."+a[b],c[0].style[a[b]])},restore:function(c,a){for(var b=0;b<a.length;b++)a[b]!==null&&c.css(a[b],c.data("ec.storage."+a[b]))},setMode:function(c,a){if(a=="toggle")a=c.is(":hidden")?"show":"hide";
+return a},getBaseline:function(c,a){var b;switch(c[0]){case "top":b=0;break;case "middle":b=0.5;break;case "bottom":b=1;break;default:b=c[0]/a.height}switch(c[1]){case "left":c=0;break;case "center":c=0.5;break;case "right":c=1;break;default:c=c[1]/a.width}return{x:c,y:b}},createWrapper:function(c){if(c.parent().is(".ui-effects-wrapper"))return c.parent();var a={width:c.outerWidth(true),height:c.outerHeight(true),"float":c.css("float")},b=f("<div></div>").addClass("ui-effects-wrapper").css({fontSize:"100%",
+background:"transparent",border:"none",margin:0,padding:0});c.wrap(b);b=c.parent();if(c.css("position")=="static"){b.css({position:"relative"});c.css({position:"relative"})}else{f.extend(a,{position:c.css("position"),zIndex:c.css("z-index")});f.each(["top","left","bottom","right"],function(d,e){a[e]=c.css(e);if(isNaN(parseInt(a[e],10)))a[e]="auto"});c.css({position:"relative",top:0,left:0})}return b.css(a).show()},removeWrapper:function(c){if(c.parent().is(".ui-effects-wrapper"))return c.parent().replaceWith(c);
+return c},setTransition:function(c,a,b,d){d=d||{};f.each(a,function(e,g){unit=c.cssUnit(g);if(unit[0]>0)d[g]=unit[0]*b+unit[1]});return d}});f.fn.extend({effect:function(c){var a=k.apply(this,arguments),b={options:a[1],duration:a[2],callback:a[3]};a=b.options.mode;var d=f.effects[c];if(f.fx.off||!d)return a?this[a](b.duration,b.callback):this.each(function(){b.callback&&b.callback.call(this)});return d.call(this,b)},_show:f.fn.show,show:function(c){if(m(c))return this._show.apply(this,arguments);
+else{var a=k.apply(this,arguments);a[1].mode="show";return this.effect.apply(this,a)}},_hide:f.fn.hide,hide:function(c){if(m(c))return this._hide.apply(this,arguments);else{var a=k.apply(this,arguments);a[1].mode="hide";return this.effect.apply(this,a)}},__toggle:f.fn.toggle,toggle:function(c){if(m(c)||typeof c==="boolean"||f.isFunction(c))return this.__toggle.apply(this,arguments);else{var a=k.apply(this,arguments);a[1].mode="toggle";return this.effect.apply(this,a)}},cssUnit:function(c){var a=this.css(c),
+b=[];f.each(["em","px","%","pt"],function(d,e){if(a.indexOf(e)>0)b=[parseFloat(a),e]});return b}});f.easing.jswing=f.easing.swing;f.extend(f.easing,{def:"easeOutQuad",swing:function(c,a,b,d,e){return f.easing[f.easing.def](c,a,b,d,e)},easeInQuad:function(c,a,b,d,e){return d*(a/=e)*a+b},easeOutQuad:function(c,a,b,d,e){return-d*(a/=e)*(a-2)+b},easeInOutQuad:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a+b;return-d/2*(--a*(a-2)-1)+b},easeInCubic:function(c,a,b,d,e){return d*(a/=e)*a*a+b},easeOutCubic:function(c,
+a,b,d,e){return d*((a=a/e-1)*a*a+1)+b},easeInOutCubic:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a+b;return d/2*((a-=2)*a*a+2)+b},easeInQuart:function(c,a,b,d,e){return d*(a/=e)*a*a*a+b},easeOutQuart:function(c,a,b,d,e){return-d*((a=a/e-1)*a*a*a-1)+b},easeInOutQuart:function(c,a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a+b;return-d/2*((a-=2)*a*a*a-2)+b},easeInQuint:function(c,a,b,d,e){return d*(a/=e)*a*a*a*a+b},easeOutQuint:function(c,a,b,d,e){return d*((a=a/e-1)*a*a*a*a+1)+b},easeInOutQuint:function(c,
+a,b,d,e){if((a/=e/2)<1)return d/2*a*a*a*a*a+b;return d/2*((a-=2)*a*a*a*a+2)+b},easeInSine:function(c,a,b,d,e){return-d*Math.cos(a/e*(Math.PI/2))+d+b},easeOutSine:function(c,a,b,d,e){return d*Math.sin(a/e*(Math.PI/2))+b},easeInOutSine:function(c,a,b,d,e){return-d/2*(Math.cos(Math.PI*a/e)-1)+b},easeInExpo:function(c,a,b,d,e){return a==0?b:d*Math.pow(2,10*(a/e-1))+b},easeOutExpo:function(c,a,b,d,e){return a==e?b+d:d*(-Math.pow(2,-10*a/e)+1)+b},easeInOutExpo:function(c,a,b,d,e){if(a==0)return b;if(a==
+e)return b+d;if((a/=e/2)<1)return d/2*Math.pow(2,10*(a-1))+b;return d/2*(-Math.pow(2,-10*--a)+2)+b},easeInCirc:function(c,a,b,d,e){return-d*(Math.sqrt(1-(a/=e)*a)-1)+b},easeOutCirc:function(c,a,b,d,e){return d*Math.sqrt(1-(a=a/e-1)*a)+b},easeInOutCirc:function(c,a,b,d,e){if((a/=e/2)<1)return-d/2*(Math.sqrt(1-a*a)-1)+b;return d/2*(Math.sqrt(1-(a-=2)*a)+1)+b},easeInElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e)==1)return b+d;g||(g=e*0.3);if(h<Math.abs(d)){h=d;c=g/4}else c=
+g/(2*Math.PI)*Math.asin(d/h);return-(h*Math.pow(2,10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g))+b},easeOutElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e)==1)return b+d;g||(g=e*0.3);if(h<Math.abs(d)){h=d;c=g/4}else c=g/(2*Math.PI)*Math.asin(d/h);return h*Math.pow(2,-10*a)*Math.sin((a*e-c)*2*Math.PI/g)+d+b},easeInOutElastic:function(c,a,b,d,e){c=1.70158;var g=0,h=d;if(a==0)return b;if((a/=e/2)==2)return b+d;g||(g=e*0.3*1.5);if(h<Math.abs(d)){h=d;c=g/4}else c=g/(2*Math.PI)*Math.asin(d/
+h);if(a<1)return-0.5*h*Math.pow(2,10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g)+b;return h*Math.pow(2,-10*(a-=1))*Math.sin((a*e-c)*2*Math.PI/g)*0.5+d+b},easeInBack:function(c,a,b,d,e,g){if(g==j)g=1.70158;return d*(a/=e)*a*((g+1)*a-g)+b},easeOutBack:function(c,a,b,d,e,g){if(g==j)g=1.70158;return d*((a=a/e-1)*a*((g+1)*a+g)+1)+b},easeInOutBack:function(c,a,b,d,e,g){if(g==j)g=1.70158;if((a/=e/2)<1)return d/2*a*a*(((g*=1.525)+1)*a-g)+b;return d/2*((a-=2)*a*(((g*=1.525)+1)*a+g)+2)+b},easeInBounce:function(c,
+a,b,d,e){return d-f.easing.easeOutBounce(c,e-a,0,d,e)+b},easeOutBounce:function(c,a,b,d,e){return(a/=e)<1/2.75?d*7.5625*a*a+b:a<2/2.75?d*(7.5625*(a-=1.5/2.75)*a+0.75)+b:a<2.5/2.75?d*(7.5625*(a-=2.25/2.75)*a+0.9375)+b:d*(7.5625*(a-=2.625/2.75)*a+0.984375)+b},easeInOutBounce:function(c,a,b,d,e){if(a<e/2)return f.easing.easeInBounce(c,a*2,0,d,e)*0.5+b;return f.easing.easeOutBounce(c,a*2-e,0,d,e)*0.5+d*0.5+b}})}(jQuery);
diff --git a/core/misc/ui/jquery.effects.drop.min.js b/core/misc/ui/jquery.effects.drop.min.js
new file mode 100644
index 000000000000..37a034d35906
--- /dev/null
+++ b/core/misc/ui/jquery.effects.drop.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Drop 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Drop
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(c){c.effects.drop=function(d){return this.queue(function(){var a=c(this),h=["position","top","left","opacity"],e=c.effects.setMode(a,d.options.mode||"hide"),b=d.options.direction||"left";c.effects.save(a,h);a.show();c.effects.createWrapper(a);var f=b=="up"||b=="down"?"top":"left";b=b=="up"||b=="left"?"pos":"neg";var g=d.options.distance||(f=="top"?a.outerHeight({margin:true})/2:a.outerWidth({margin:true})/2);if(e=="show")a.css("opacity",0).css(f,b=="pos"?-g:g);var i={opacity:e=="show"?1:
+0};i[f]=(e=="show"?b=="pos"?"+=":"-=":b=="pos"?"-=":"+=")+g;a.animate(i,{queue:false,duration:d.duration,easing:d.options.easing,complete:function(){e=="hide"&&a.hide();c.effects.restore(a,h);c.effects.removeWrapper(a);d.callback&&d.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.explode.min.js b/core/misc/ui/jquery.effects.explode.min.js
new file mode 100644
index 000000000000..726f3d5b8244
--- /dev/null
+++ b/core/misc/ui/jquery.effects.explode.min.js
@@ -0,0 +1,16 @@
+
+/*
+ * jQuery UI Effects Explode 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Explode
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(j){j.effects.explode=function(a){return this.queue(function(){var c=a.options.pieces?Math.round(Math.sqrt(a.options.pieces)):3,d=a.options.pieces?Math.round(Math.sqrt(a.options.pieces)):3;a.options.mode=a.options.mode=="toggle"?j(this).is(":visible")?"hide":"show":a.options.mode;var b=j(this).show().css("visibility","hidden"),g=b.offset();g.top-=parseInt(b.css("marginTop"),10)||0;g.left-=parseInt(b.css("marginLeft"),10)||0;for(var h=b.outerWidth(true),i=b.outerHeight(true),e=0;e<c;e++)for(var f=
+0;f<d;f++)b.clone().appendTo("body").wrap("<div></div>").css({position:"absolute",visibility:"visible",left:-f*(h/d),top:-e*(i/c)}).parent().addClass("ui-effects-explode").css({position:"absolute",overflow:"hidden",width:h/d,height:i/c,left:g.left+f*(h/d)+(a.options.mode=="show"?(f-Math.floor(d/2))*(h/d):0),top:g.top+e*(i/c)+(a.options.mode=="show"?(e-Math.floor(c/2))*(i/c):0),opacity:a.options.mode=="show"?0:1}).animate({left:g.left+f*(h/d)+(a.options.mode=="show"?0:(f-Math.floor(d/2))*(h/d)),top:g.top+
+e*(i/c)+(a.options.mode=="show"?0:(e-Math.floor(c/2))*(i/c)),opacity:a.options.mode=="show"?1:0},a.duration||500);setTimeout(function(){a.options.mode=="show"?b.css({visibility:"visible"}):b.css({visibility:"visible"}).hide();a.callback&&a.callback.apply(b[0]);b.dequeue();j("div.ui-effects-explode").remove()},a.duration||500)})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.fade.min.js b/core/misc/ui/jquery.effects.fade.min.js
new file mode 100644
index 000000000000..71127f9ad4bf
--- /dev/null
+++ b/core/misc/ui/jquery.effects.fade.min.js
@@ -0,0 +1,14 @@
+
+/*
+ * jQuery UI Effects Fade 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Fade
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(b){b.effects.fade=function(a){return this.queue(function(){var c=b(this),d=b.effects.setMode(c,a.options.mode||"hide");c.animate({opacity:d},{queue:false,duration:a.duration,easing:a.options.easing,complete:function(){a.callback&&a.callback.apply(this,arguments);c.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.fold.min.js b/core/misc/ui/jquery.effects.fold.min.js
new file mode 100644
index 000000000000..ccc6b2949141
--- /dev/null
+++ b/core/misc/ui/jquery.effects.fold.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Fold 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Fold
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(c){c.effects.fold=function(a){return this.queue(function(){var b=c(this),j=["position","top","left"],d=c.effects.setMode(b,a.options.mode||"hide"),g=a.options.size||15,h=!!a.options.horizFirst,k=a.duration?a.duration/2:c.fx.speeds._default/2;c.effects.save(b,j);b.show();var e=c.effects.createWrapper(b).css({overflow:"hidden"}),f=d=="show"!=h,l=f?["width","height"]:["height","width"];f=f?[e.width(),e.height()]:[e.height(),e.width()];var i=/([0-9]+)%/.exec(g);if(i)g=parseInt(i[1],10)/100*
+f[d=="hide"?0:1];if(d=="show")e.css(h?{height:0,width:g}:{height:g,width:0});h={};i={};h[l[0]]=d=="show"?f[0]:g;i[l[1]]=d=="show"?f[1]:0;e.animate(h,k,a.options.easing).animate(i,k,a.options.easing,function(){d=="hide"&&b.hide();c.effects.restore(b,j);c.effects.removeWrapper(b);a.callback&&a.callback.apply(b[0],arguments);b.dequeue()})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.highlight.min.js b/core/misc/ui/jquery.effects.highlight.min.js
new file mode 100644
index 000000000000..0ed3d89855a3
--- /dev/null
+++ b/core/misc/ui/jquery.effects.highlight.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Highlight 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Highlight
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(b){b.effects.highlight=function(c){return this.queue(function(){var a=b(this),e=["backgroundImage","backgroundColor","opacity"],d=b.effects.setMode(a,c.options.mode||"show"),f={backgroundColor:a.css("backgroundColor")};if(d=="hide")f.opacity=0;b.effects.save(a,e);a.show().css({backgroundImage:"none",backgroundColor:c.options.color||"#ffff99"}).animate(f,{queue:false,duration:c.duration,easing:c.options.easing,complete:function(){d=="hide"&&a.hide();b.effects.restore(a,e);d=="show"&&!b.support.opacity&&
+this.style.removeAttribute("filter");c.callback&&c.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.pulsate.min.js b/core/misc/ui/jquery.effects.pulsate.min.js
new file mode 100644
index 000000000000..658d8d03f77b
--- /dev/null
+++ b/core/misc/ui/jquery.effects.pulsate.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Pulsate 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Pulsate
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(d){d.effects.pulsate=function(a){return this.queue(function(){var b=d(this),c=d.effects.setMode(b,a.options.mode||"show");times=(a.options.times||5)*2-1;duration=a.duration?a.duration/2:d.fx.speeds._default/2;isVisible=b.is(":visible");animateTo=0;if(!isVisible){b.css("opacity",0).show();animateTo=1}if(c=="hide"&&isVisible||c=="show"&&!isVisible)times--;for(c=0;c<times;c++){b.animate({opacity:animateTo},duration,a.options.easing);animateTo=(animateTo+1)%2}b.animate({opacity:animateTo},duration,
+a.options.easing,function(){animateTo==0&&b.hide();a.callback&&a.callback.apply(this,arguments)});b.queue("fx",function(){b.dequeue()}).dequeue()})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.scale.min.js b/core/misc/ui/jquery.effects.scale.min.js
new file mode 100644
index 000000000000..206ef12f45b1
--- /dev/null
+++ b/core/misc/ui/jquery.effects.scale.min.js
@@ -0,0 +1,21 @@
+
+/*
+ * jQuery UI Effects Scale 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Scale
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(c){c.effects.puff=function(b){return this.queue(function(){var a=c(this),e=c.effects.setMode(a,b.options.mode||"hide"),g=parseInt(b.options.percent,10)||150,h=g/100,i={height:a.height(),width:a.width()};c.extend(b.options,{fade:true,mode:e,percent:e=="hide"?g:100,from:e=="hide"?i:{height:i.height*h,width:i.width*h}});a.effect("scale",b.options,b.duration,b.callback);a.dequeue()})};c.effects.scale=function(b){return this.queue(function(){var a=c(this),e=c.extend(true,{},b.options),g=c.effects.setMode(a,
+b.options.mode||"effect"),h=parseInt(b.options.percent,10)||(parseInt(b.options.percent,10)==0?0:g=="hide"?0:100),i=b.options.direction||"both",f=b.options.origin;if(g!="effect"){e.origin=f||["middle","center"];e.restore=true}f={height:a.height(),width:a.width()};a.from=b.options.from||(g=="show"?{height:0,width:0}:f);h={y:i!="horizontal"?h/100:1,x:i!="vertical"?h/100:1};a.to={height:f.height*h.y,width:f.width*h.x};if(b.options.fade){if(g=="show"){a.from.opacity=0;a.to.opacity=1}if(g=="hide"){a.from.opacity=
+1;a.to.opacity=0}}e.from=a.from;e.to=a.to;e.mode=g;a.effect("size",e,b.duration,b.callback);a.dequeue()})};c.effects.size=function(b){return this.queue(function(){var a=c(this),e=["position","top","left","width","height","overflow","opacity"],g=["position","top","left","overflow","opacity"],h=["width","height","overflow"],i=["fontSize"],f=["borderTopWidth","borderBottomWidth","paddingTop","paddingBottom"],k=["borderLeftWidth","borderRightWidth","paddingLeft","paddingRight"],p=c.effects.setMode(a,
+b.options.mode||"effect"),n=b.options.restore||false,m=b.options.scale||"both",l=b.options.origin,j={height:a.height(),width:a.width()};a.from=b.options.from||j;a.to=b.options.to||j;if(l){l=c.effects.getBaseline(l,j);a.from.top=(j.height-a.from.height)*l.y;a.from.left=(j.width-a.from.width)*l.x;a.to.top=(j.height-a.to.height)*l.y;a.to.left=(j.width-a.to.width)*l.x}var d={from:{y:a.from.height/j.height,x:a.from.width/j.width},to:{y:a.to.height/j.height,x:a.to.width/j.width}};if(m=="box"||m=="both"){if(d.from.y!=
+d.to.y){e=e.concat(f);a.from=c.effects.setTransition(a,f,d.from.y,a.from);a.to=c.effects.setTransition(a,f,d.to.y,a.to)}if(d.from.x!=d.to.x){e=e.concat(k);a.from=c.effects.setTransition(a,k,d.from.x,a.from);a.to=c.effects.setTransition(a,k,d.to.x,a.to)}}if(m=="content"||m=="both")if(d.from.y!=d.to.y){e=e.concat(i);a.from=c.effects.setTransition(a,i,d.from.y,a.from);a.to=c.effects.setTransition(a,i,d.to.y,a.to)}c.effects.save(a,n?e:g);a.show();c.effects.createWrapper(a);a.css("overflow","hidden").css(a.from);
+if(m=="content"||m=="both"){f=f.concat(["marginTop","marginBottom"]).concat(i);k=k.concat(["marginLeft","marginRight"]);h=e.concat(f).concat(k);a.find("*[width]").each(function(){child=c(this);n&&c.effects.save(child,h);var o={height:child.height(),width:child.width()};child.from={height:o.height*d.from.y,width:o.width*d.from.x};child.to={height:o.height*d.to.y,width:o.width*d.to.x};if(d.from.y!=d.to.y){child.from=c.effects.setTransition(child,f,d.from.y,child.from);child.to=c.effects.setTransition(child,
+f,d.to.y,child.to)}if(d.from.x!=d.to.x){child.from=c.effects.setTransition(child,k,d.from.x,child.from);child.to=c.effects.setTransition(child,k,d.to.x,child.to)}child.css(child.from);child.animate(child.to,b.duration,b.options.easing,function(){n&&c.effects.restore(child,h)})})}a.animate(a.to,{queue:false,duration:b.duration,easing:b.options.easing,complete:function(){a.to.opacity===0&&a.css("opacity",a.from.opacity);p=="hide"&&a.hide();c.effects.restore(a,n?e:g);c.effects.removeWrapper(a);b.callback&&
+b.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.shake.min.js b/core/misc/ui/jquery.effects.shake.min.js
new file mode 100644
index 000000000000..44542f32616f
--- /dev/null
+++ b/core/misc/ui/jquery.effects.shake.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Shake 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Shake
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(d){d.effects.shake=function(a){return this.queue(function(){var b=d(this),j=["position","top","left"];d.effects.setMode(b,a.options.mode||"effect");var c=a.options.direction||"left",e=a.options.distance||20,l=a.options.times||3,f=a.duration||a.options.duration||140;d.effects.save(b,j);b.show();d.effects.createWrapper(b);var g=c=="up"||c=="down"?"top":"left",h=c=="up"||c=="left"?"pos":"neg";c={};var i={},k={};c[g]=(h=="pos"?"-=":"+=")+e;i[g]=(h=="pos"?"+=":"-=")+e*2;k[g]=(h=="pos"?"-=":"+=")+
+e*2;b.animate(c,f,a.options.easing);for(e=1;e<l;e++)b.animate(i,f,a.options.easing).animate(k,f,a.options.easing);b.animate(i,f,a.options.easing).animate(c,f/2,a.options.easing,function(){d.effects.restore(b,j);d.effects.removeWrapper(b);a.callback&&a.callback.apply(this,arguments)});b.queue("fx",function(){b.dequeue()});b.dequeue()})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.slide.min.js b/core/misc/ui/jquery.effects.slide.min.js
new file mode 100644
index 000000000000..94f5906f3ad0
--- /dev/null
+++ b/core/misc/ui/jquery.effects.slide.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Slide 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Slide
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(c){c.effects.slide=function(d){return this.queue(function(){var a=c(this),h=["position","top","left"],f=c.effects.setMode(a,d.options.mode||"show"),b=d.options.direction||"left";c.effects.save(a,h);a.show();c.effects.createWrapper(a).css({overflow:"hidden"});var g=b=="up"||b=="down"?"top":"left";b=b=="up"||b=="left"?"pos":"neg";var e=d.options.distance||(g=="top"?a.outerHeight({margin:true}):a.outerWidth({margin:true}));if(f=="show")a.css(g,b=="pos"?isNaN(e)?"-"+e:-e:e);var i={};i[g]=(f==
+"show"?b=="pos"?"+=":"-=":b=="pos"?"-=":"+=")+e;a.animate(i,{queue:false,duration:d.duration,easing:d.options.easing,complete:function(){f=="hide"&&a.hide();c.effects.restore(a,h);c.effects.removeWrapper(a);d.callback&&d.callback.apply(this,arguments);a.dequeue()}})})}})(jQuery);
diff --git a/core/misc/ui/jquery.effects.transfer.min.js b/core/misc/ui/jquery.effects.transfer.min.js
new file mode 100644
index 000000000000..0addaa8bb210
--- /dev/null
+++ b/core/misc/ui/jquery.effects.transfer.min.js
@@ -0,0 +1,15 @@
+
+/*
+ * jQuery UI Effects Transfer 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Effects/Transfer
+ *
+ * Depends:
+ * jquery.effects.core.js
+ */
+(function(e){e.effects.transfer=function(a){return this.queue(function(){var b=e(this),c=e(a.options.to),d=c.offset();c={top:d.top,left:d.left,height:c.innerHeight(),width:c.innerWidth()};d=b.offset();var f=e('<div class="ui-effects-transfer"></div>').appendTo(document.body).addClass(a.options.className).css({top:d.top,left:d.left,height:b.innerHeight(),width:b.innerWidth(),position:"absolute"}).animate(c,a.duration,a.options.easing,function(){f.remove();a.callback&&a.callback.apply(b[0],arguments);
+b.dequeue()})})}})(jQuery);
diff --git a/core/misc/ui/jquery.ui.accordion.css b/core/misc/ui/jquery.ui.accordion.css
new file mode 100644
index 000000000000..fcd7c55dabda
--- /dev/null
+++ b/core/misc/ui/jquery.ui.accordion.css
@@ -0,0 +1,20 @@
+
+/*
+ * jQuery UI Accordion 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Accordion#theming
+ */
+/* IE/Win - Fix animation bug - #4615 */
+.ui-accordion { width: 100%; }
+.ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; }
+.ui-accordion .ui-accordion-li-fix { display: inline; }
+.ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; }
+.ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em .7em; }
+.ui-accordion-icons .ui-accordion-header a { padding-left: 2.2em; }
+.ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; }
+.ui-accordion .ui-accordion-content { padding: 1em 2.2em; border-top: 0; margin-top: -2px; position: relative; top: 1px; margin-bottom: 2px; overflow: auto; display: none; zoom: 1; }
+.ui-accordion .ui-accordion-content-active { display: block; }
diff --git a/core/misc/ui/jquery.ui.accordion.min.js b/core/misc/ui/jquery.ui.accordion.min.js
new file mode 100644
index 000000000000..0e0ee39afc3b
--- /dev/null
+++ b/core/misc/ui/jquery.ui.accordion.min.js
@@ -0,0 +1,31 @@
+
+/*
+ * jQuery UI Accordion 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Accordion
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ */
+(function(c){c.widget("ui.accordion",{options:{active:0,animated:"slide",autoHeight:true,clearStyle:false,collapsible:false,event:"click",fillSpace:false,header:"> li > :first-child,> :not(li):even",icons:{header:"ui-icon-triangle-1-e",headerSelected:"ui-icon-triangle-1-s"},navigation:false,navigationFilter:function(){return this.href.toLowerCase()===location.href.toLowerCase()}},_create:function(){var a=this,b=a.options;a.running=0;a.element.addClass("ui-accordion ui-widget ui-helper-reset").children("li").addClass("ui-accordion-li-fix");
+a.headers=a.element.find(b.header).addClass("ui-accordion-header ui-helper-reset ui-state-default ui-corner-all").bind("mouseenter.accordion",function(){b.disabled||c(this).addClass("ui-state-hover")}).bind("mouseleave.accordion",function(){b.disabled||c(this).removeClass("ui-state-hover")}).bind("focus.accordion",function(){b.disabled||c(this).addClass("ui-state-focus")}).bind("blur.accordion",function(){b.disabled||c(this).removeClass("ui-state-focus")});a.headers.next().addClass("ui-accordion-content ui-helper-reset ui-widget-content ui-corner-bottom");
+if(b.navigation){var d=a.element.find("a").filter(b.navigationFilter).eq(0);if(d.length){var f=d.closest(".ui-accordion-header");a.active=f.length?f:d.closest(".ui-accordion-content").prev()}}a.active=a._findActive(a.active||b.active).addClass("ui-state-default ui-state-active").toggleClass("ui-corner-all").toggleClass("ui-corner-top");a.active.next().addClass("ui-accordion-content-active");a._createIcons();a.resize();a.element.attr("role","tablist");a.headers.attr("role","tab").bind("keydown.accordion",
+function(g){return a._keydown(g)}).next().attr("role","tabpanel");a.headers.not(a.active||"").attr({"aria-expanded":"false",tabIndex:-1}).next().hide();a.active.length?a.active.attr({"aria-expanded":"true",tabIndex:0}):a.headers.eq(0).attr("tabIndex",0);c.browser.safari||a.headers.find("a").attr("tabIndex",-1);b.event&&a.headers.bind(b.event.split(" ").join(".accordion ")+".accordion",function(g){a._clickHandler.call(a,g,this);g.preventDefault()})},_createIcons:function(){var a=this.options;if(a.icons){c("<span></span>").addClass("ui-icon "+
+a.icons.header).prependTo(this.headers);this.active.children(".ui-icon").toggleClass(a.icons.header).toggleClass(a.icons.headerSelected);this.element.addClass("ui-accordion-icons")}},_destroyIcons:function(){this.headers.children(".ui-icon").remove();this.element.removeClass("ui-accordion-icons")},destroy:function(){var a=this.options;this.element.removeClass("ui-accordion ui-widget ui-helper-reset").removeAttr("role");this.headers.unbind(".accordion").removeClass("ui-accordion-header ui-accordion-disabled ui-helper-reset ui-state-default ui-corner-all ui-state-active ui-state-disabled ui-corner-top").removeAttr("role").removeAttr("aria-expanded").removeAttr("tabIndex");
+this.headers.find("a").removeAttr("tabIndex");this._destroyIcons();var b=this.headers.next().css("display","").removeAttr("role").removeClass("ui-helper-reset ui-widget-content ui-corner-bottom ui-accordion-content ui-accordion-content-active ui-accordion-disabled ui-state-disabled");if(a.autoHeight||a.fillHeight)b.css("height","");return c.Widget.prototype.destroy.call(this)},_setOption:function(a,b){c.Widget.prototype._setOption.apply(this,arguments);a=="active"&&this.activate(b);if(a=="icons"){this._destroyIcons();
+b&&this._createIcons()}if(a=="disabled")this.headers.add(this.headers.next())[b?"addClass":"removeClass"]("ui-accordion-disabled ui-state-disabled")},_keydown:function(a){if(!(this.options.disabled||a.altKey||a.ctrlKey)){var b=c.ui.keyCode,d=this.headers.length,f=this.headers.index(a.target),g=false;switch(a.keyCode){case b.RIGHT:case b.DOWN:g=this.headers[(f+1)%d];break;case b.LEFT:case b.UP:g=this.headers[(f-1+d)%d];break;case b.SPACE:case b.ENTER:this._clickHandler({target:a.target},a.target);
+a.preventDefault()}if(g){c(a.target).attr("tabIndex",-1);c(g).attr("tabIndex",0);g.focus();return false}return true}},resize:function(){var a=this.options,b;if(a.fillSpace){if(c.browser.msie){var d=this.element.parent().css("overflow");this.element.parent().css("overflow","hidden")}b=this.element.parent().height();c.browser.msie&&this.element.parent().css("overflow",d);this.headers.each(function(){b-=c(this).outerHeight(true)});this.headers.next().each(function(){c(this).height(Math.max(0,b-c(this).innerHeight()+
+c(this).height()))}).css("overflow","auto")}else if(a.autoHeight){b=0;this.headers.next().each(function(){b=Math.max(b,c(this).height("").height())}).height(b)}return this},activate:function(a){this.options.active=a;a=this._findActive(a)[0];this._clickHandler({target:a},a);return this},_findActive:function(a){return a?typeof a==="number"?this.headers.filter(":eq("+a+")"):this.headers.not(this.headers.not(a)):a===false?c([]):this.headers.filter(":eq(0)")},_clickHandler:function(a,b){var d=this.options;
+if(!d.disabled)if(a.target){a=c(a.currentTarget||b);b=a[0]===this.active[0];d.active=d.collapsible&&b?false:this.headers.index(a);if(!(this.running||!d.collapsible&&b)){this.active.removeClass("ui-state-active ui-corner-top").addClass("ui-state-default ui-corner-all").children(".ui-icon").removeClass(d.icons.headerSelected).addClass(d.icons.header);if(!b){a.removeClass("ui-state-default ui-corner-all").addClass("ui-state-active ui-corner-top").children(".ui-icon").removeClass(d.icons.header).addClass(d.icons.headerSelected);
+a.next().addClass("ui-accordion-content-active")}h=a.next();f=this.active.next();g={options:d,newHeader:b&&d.collapsible?c([]):a,oldHeader:this.active,newContent:b&&d.collapsible?c([]):h,oldContent:f};d=this.headers.index(this.active[0])>this.headers.index(a[0]);this.active=b?c([]):a;this._toggle(h,f,g,b,d)}}else if(d.collapsible){this.active.removeClass("ui-state-active ui-corner-top").addClass("ui-state-default ui-corner-all").children(".ui-icon").removeClass(d.icons.headerSelected).addClass(d.icons.header);
+this.active.next().addClass("ui-accordion-content-active");var f=this.active.next(),g={options:d,newHeader:c([]),oldHeader:d.active,newContent:c([]),oldContent:f},h=this.active=c([]);this._toggle(h,f,g)}},_toggle:function(a,b,d,f,g){var h=this,e=h.options;h.toShow=a;h.toHide=b;h.data=d;var j=function(){if(h)return h._completed.apply(h,arguments)};h._trigger("changestart",null,h.data);h.running=b.size()===0?a.size():b.size();if(e.animated){d={};d=e.collapsible&&f?{toShow:c([]),toHide:b,complete:j,
+down:g,autoHeight:e.autoHeight||e.fillSpace}:{toShow:a,toHide:b,complete:j,down:g,autoHeight:e.autoHeight||e.fillSpace};if(!e.proxied)e.proxied=e.animated;if(!e.proxiedDuration)e.proxiedDuration=e.duration;e.animated=c.isFunction(e.proxied)?e.proxied(d):e.proxied;e.duration=c.isFunction(e.proxiedDuration)?e.proxiedDuration(d):e.proxiedDuration;f=c.ui.accordion.animations;var i=e.duration,k=e.animated;if(k&&!f[k]&&!c.easing[k])k="slide";f[k]||(f[k]=function(l){this.slide(l,{easing:k,duration:i||700})});
+f[k](d)}else{if(e.collapsible&&f)a.toggle();else{b.hide();a.show()}j(true)}b.prev().attr({"aria-expanded":"false",tabIndex:-1}).blur();a.prev().attr({"aria-expanded":"true",tabIndex:0}).focus()},_completed:function(a){this.running=a?0:--this.running;if(!this.running){this.options.clearStyle&&this.toShow.add(this.toHide).css({height:"",overflow:""});this.toHide.removeClass("ui-accordion-content-active");this._trigger("change",null,this.data)}}});c.extend(c.ui.accordion,{version:"1.8.7",animations:{slide:function(a,
+b){a=c.extend({easing:"swing",duration:300},a,b);if(a.toHide.size())if(a.toShow.size()){var d=a.toShow.css("overflow"),f=0,g={},h={},e;b=a.toShow;e=b[0].style.width;b.width(parseInt(b.parent().width(),10)-parseInt(b.css("paddingLeft"),10)-parseInt(b.css("paddingRight"),10)-(parseInt(b.css("borderLeftWidth"),10)||0)-(parseInt(b.css("borderRightWidth"),10)||0));c.each(["height","paddingTop","paddingBottom"],function(j,i){h[i]="hide";j=(""+c.css(a.toShow[0],i)).match(/^([\d+-.]+)(.*)$/);g[i]={value:j[1],
+unit:j[2]||"px"}});a.toShow.css({height:0,overflow:"hidden"}).show();a.toHide.filter(":hidden").each(a.complete).end().filter(":visible").animate(h,{step:function(j,i){if(i.prop=="height")f=i.end-i.start===0?0:(i.now-i.start)/(i.end-i.start);a.toShow[0].style[i.prop]=f*g[i.prop].value+g[i.prop].unit},duration:a.duration,easing:a.easing,complete:function(){a.autoHeight||a.toShow.css("height","");a.toShow.css({width:e,overflow:d});a.complete()}})}else a.toHide.animate({height:"hide",paddingTop:"hide",
+paddingBottom:"hide"},a);else a.toShow.animate({height:"show",paddingTop:"show",paddingBottom:"show"},a)},bounceslide:function(a){this.slide(a,{easing:a.down?"easeOutBounce":"swing",duration:a.down?1E3:200})}}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.autocomplete.css b/core/misc/ui/jquery.ui.autocomplete.css
new file mode 100644
index 000000000000..80a5789eb465
--- /dev/null
+++ b/core/misc/ui/jquery.ui.autocomplete.css
@@ -0,0 +1,54 @@
+
+/*
+ * jQuery UI Autocomplete 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Autocomplete#theming
+ */
+.ui-autocomplete { position: absolute; cursor: default; }
+
+/* workarounds */
+* html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */
+
+/*
+ * jQuery UI Menu 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Menu#theming
+ */
+.ui-menu {
+ list-style:none;
+ padding: 2px;
+ margin: 0;
+ display:block;
+ float: left;
+}
+.ui-menu .ui-menu {
+ margin-top: -3px;
+}
+.ui-menu .ui-menu-item {
+ margin:0;
+ padding: 0;
+ zoom: 1;
+ float: left;
+ clear: left;
+ width: 100%;
+}
+.ui-menu .ui-menu-item a {
+ text-decoration:none;
+ display:block;
+ padding:.2em .4em;
+ line-height:1.5;
+ zoom:1;
+}
+.ui-menu .ui-menu-item a.ui-state-hover,
+.ui-menu .ui-menu-item a.ui-state-active {
+ font-weight: normal;
+ margin: -1px;
+}
diff --git a/core/misc/ui/jquery.ui.autocomplete.min.js b/core/misc/ui/jquery.ui.autocomplete.min.js
new file mode 100644
index 000000000000..9983ec770e10
--- /dev/null
+++ b/core/misc/ui/jquery.ui.autocomplete.min.js
@@ -0,0 +1,32 @@
+
+/*
+ * jQuery UI Autocomplete 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Autocomplete
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ * jquery.ui.position.js
+ */
+(function(d){d.widget("ui.autocomplete",{options:{appendTo:"body",delay:300,minLength:1,position:{my:"left top",at:"left bottom",collision:"none"},source:null},_create:function(){var a=this,b=this.element[0].ownerDocument,f;this.element.addClass("ui-autocomplete-input").attr("autocomplete","off").attr({role:"textbox","aria-autocomplete":"list","aria-haspopup":"true"}).bind("keydown.autocomplete",function(c){if(!(a.options.disabled||a.element.attr("readonly"))){f=false;var e=d.ui.keyCode;switch(c.keyCode){case e.PAGE_UP:a._move("previousPage",
+c);break;case e.PAGE_DOWN:a._move("nextPage",c);break;case e.UP:a._move("previous",c);c.preventDefault();break;case e.DOWN:a._move("next",c);c.preventDefault();break;case e.ENTER:case e.NUMPAD_ENTER:if(a.menu.active){f=true;c.preventDefault()}case e.TAB:if(!a.menu.active)return;a.menu.select(c);break;case e.ESCAPE:a.element.val(a.term);a.close(c);break;default:clearTimeout(a.searching);a.searching=setTimeout(function(){if(a.term!=a.element.val()){a.selectedItem=null;a.search(null,c)}},a.options.delay);
+break}}}).bind("keypress.autocomplete",function(c){if(f){f=false;c.preventDefault()}}).bind("focus.autocomplete",function(){if(!a.options.disabled){a.selectedItem=null;a.previous=a.element.val()}}).bind("blur.autocomplete",function(c){if(!a.options.disabled){clearTimeout(a.searching);a.closing=setTimeout(function(){a.close(c);a._change(c)},150)}});this._initSource();this.response=function(){return a._response.apply(a,arguments)};this.menu=d("<ul></ul>").addClass("ui-autocomplete").appendTo(d(this.options.appendTo||
+"body",b)[0]).mousedown(function(c){var e=a.menu.element[0];d(c.target).closest(".ui-menu-item").length||setTimeout(function(){d(document).one("mousedown",function(g){g.target!==a.element[0]&&g.target!==e&&!d.ui.contains(e,g.target)&&a.close()})},1);setTimeout(function(){clearTimeout(a.closing)},13)}).menu({focus:function(c,e){e=e.item.data("item.autocomplete");false!==a._trigger("focus",c,{item:e})&&/^key/.test(c.originalEvent.type)&&a.element.val(e.value)},selected:function(c,e){var g=e.item.data("item.autocomplete"),
+h=a.previous;if(a.element[0]!==b.activeElement){a.element.focus();a.previous=h;setTimeout(function(){a.previous=h;a.selectedItem=g},1)}false!==a._trigger("select",c,{item:g})&&a.element.val(g.value);a.term=a.element.val();a.close(c);a.selectedItem=g},blur:function(){a.menu.element.is(":visible")&&a.element.val()!==a.term&&a.element.val(a.term)}}).zIndex(this.element.zIndex()+1).css({top:0,left:0}).hide().data("menu");d.fn.bgiframe&&this.menu.element.bgiframe()},destroy:function(){this.element.removeClass("ui-autocomplete-input").removeAttr("autocomplete").removeAttr("role").removeAttr("aria-autocomplete").removeAttr("aria-haspopup");
+this.menu.element.remove();d.Widget.prototype.destroy.call(this)},_setOption:function(a,b){d.Widget.prototype._setOption.apply(this,arguments);a==="source"&&this._initSource();if(a==="appendTo")this.menu.element.appendTo(d(b||"body",this.element[0].ownerDocument)[0])},_initSource:function(){var a=this,b,f;if(d.isArray(this.options.source)){b=this.options.source;this.source=function(c,e){e(d.ui.autocomplete.filter(b,c.term))}}else if(typeof this.options.source==="string"){f=this.options.source;this.source=
+function(c,e){a.xhr&&a.xhr.abort();a.xhr=d.ajax({url:f,data:c,dataType:"json",success:function(g,h,i){i===a.xhr&&e(g);a.xhr=null},error:function(g){g===a.xhr&&e([]);a.xhr=null}})}}else this.source=this.options.source},search:function(a,b){a=a!=null?a:this.element.val();this.term=this.element.val();if(a.length<this.options.minLength)return this.close(b);clearTimeout(this.closing);if(this._trigger("search",b)!==false)return this._search(a)},_search:function(a){this.element.addClass("ui-autocomplete-loading");
+this.source({term:a},this.response)},_response:function(a){if(a&&a.length){a=this._normalize(a);this._suggest(a);this._trigger("open")}else this.close();this.element.removeClass("ui-autocomplete-loading")},close:function(a){clearTimeout(this.closing);if(this.menu.element.is(":visible")){this.menu.element.hide();this.menu.deactivate();this._trigger("close",a)}},_change:function(a){this.previous!==this.element.val()&&this._trigger("change",a,{item:this.selectedItem})},_normalize:function(a){if(a.length&&
+a[0].label&&a[0].value)return a;return d.map(a,function(b){if(typeof b==="string")return{label:b,value:b};return d.extend({label:b.label||b.value,value:b.value||b.label},b)})},_suggest:function(a){var b=this.menu.element.empty().zIndex(this.element.zIndex()+1);this._renderMenu(b,a);this.menu.deactivate();this.menu.refresh();b.show();this._resizeMenu();b.position(d.extend({of:this.element},this.options.position))},_resizeMenu:function(){var a=this.menu.element;a.outerWidth(Math.max(a.width("").outerWidth(),
+this.element.outerWidth()))},_renderMenu:function(a,b){var f=this;d.each(b,function(c,e){f._renderItem(a,e)})},_renderItem:function(a,b){return d("<li></li>").data("item.autocomplete",b).append(d("<a></a>").text(b.label)).appendTo(a)},_move:function(a,b){if(this.menu.element.is(":visible"))if(this.menu.first()&&/^previous/.test(a)||this.menu.last()&&/^next/.test(a)){this.element.val(this.term);this.menu.deactivate()}else this.menu[a](b);else this.search(null,b)},widget:function(){return this.menu.element}});
+d.extend(d.ui.autocomplete,{escapeRegex:function(a){return a.replace(/[-[\]{}()*+?.,\\^$|#\s]/g,"\\$&")},filter:function(a,b){var f=new RegExp(d.ui.autocomplete.escapeRegex(b),"i");return d.grep(a,function(c){return f.test(c.label||c.value||c)})}})})(jQuery);
+(function(d){d.widget("ui.menu",{_create:function(){var a=this;this.element.addClass("ui-menu ui-widget ui-widget-content ui-corner-all").attr({role:"listbox","aria-activedescendant":"ui-active-menuitem"}).click(function(b){if(d(b.target).closest(".ui-menu-item a").length){b.preventDefault();a.select(b)}});this.refresh()},refresh:function(){var a=this;this.element.children("li:not(.ui-menu-item):has(a)").addClass("ui-menu-item").attr("role","menuitem").children("a").addClass("ui-corner-all").attr("tabindex",
+-1).mouseenter(function(b){a.activate(b,d(this).parent())}).mouseleave(function(){a.deactivate()})},activate:function(a,b){this.deactivate();if(this.hasScroll()){var f=b.offset().top-this.element.offset().top,c=this.element.attr("scrollTop"),e=this.element.height();if(f<0)this.element.attr("scrollTop",c+f);else f>=e&&this.element.attr("scrollTop",c+f-e+b.height())}this.active=b.eq(0).children("a").addClass("ui-state-hover").attr("id","ui-active-menuitem").end();this._trigger("focus",a,{item:b})},
+deactivate:function(){if(this.active){this.active.children("a").removeClass("ui-state-hover").removeAttr("id");this._trigger("blur");this.active=null}},next:function(a){this.move("next",".ui-menu-item:first",a)},previous:function(a){this.move("prev",".ui-menu-item:last",a)},first:function(){return this.active&&!this.active.prevAll(".ui-menu-item").length},last:function(){return this.active&&!this.active.nextAll(".ui-menu-item").length},move:function(a,b,f){if(this.active){a=this.active[a+"All"](".ui-menu-item").eq(0);
+a.length?this.activate(f,a):this.activate(f,this.element.children(b))}else this.activate(f,this.element.children(b))},nextPage:function(a){if(this.hasScroll())if(!this.active||this.last())this.activate(a,this.element.children(".ui-menu-item:first"));else{var b=this.active.offset().top,f=this.element.height(),c=this.element.children(".ui-menu-item").filter(function(){var e=d(this).offset().top-b-f+d(this).height();return e<10&&e>-10});c.length||(c=this.element.children(".ui-menu-item:last"));this.activate(a,
+c)}else this.activate(a,this.element.children(".ui-menu-item").filter(!this.active||this.last()?":first":":last"))},previousPage:function(a){if(this.hasScroll())if(!this.active||this.first())this.activate(a,this.element.children(".ui-menu-item:last"));else{var b=this.active.offset().top,f=this.element.height();result=this.element.children(".ui-menu-item").filter(function(){var c=d(this).offset().top-b+f-d(this).height();return c<10&&c>-10});result.length||(result=this.element.children(".ui-menu-item:first"));
+this.activate(a,result)}else this.activate(a,this.element.children(".ui-menu-item").filter(!this.active||this.first()?":last":":first"))},hasScroll:function(){return this.element.height()<this.element.attr("scrollHeight")},select:function(a){this._trigger("selected",a,{item:this.active})}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.button.css b/core/misc/ui/jquery.ui.button.css
new file mode 100644
index 000000000000..973c314532ff
--- /dev/null
+++ b/core/misc/ui/jquery.ui.button.css
@@ -0,0 +1,39 @@
+
+/*
+ * jQuery UI Button 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Button#theming
+ */
+.ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */
+.ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */
+button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */
+.ui-button-icons-only { width: 3.4em; }
+button.ui-button-icons-only { width: 3.7em; }
+
+/*button text element */
+.ui-button .ui-button-text { display: block; line-height: 1.4; }
+.ui-button-text-only .ui-button-text { padding: .4em 1em; }
+.ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; }
+.ui-button-text-icon-primary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; }
+.ui-button-text-icon-secondary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 2.1em .4em 1em; }
+.ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; }
+/* no icon support for input elements, provide padding by default */
+input.ui-button { padding: .4em 1em; }
+
+/*button icon element(s) */
+.ui-button-icon-only .ui-icon, .ui-button-text-icon-primary .ui-icon, .ui-button-text-icon-secondary .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; }
+.ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; }
+.ui-button-text-icon-primary .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; }
+.ui-button-text-icon-secondary .ui-button-icon-secondary, .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; }
+.ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; }
+
+/*button sets*/
+.ui-buttonset { margin-right: 7px; }
+.ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; }
+
+/* workarounds */
+button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */
diff --git a/core/misc/ui/jquery.ui.button.min.js b/core/misc/ui/jquery.ui.button.min.js
new file mode 100644
index 000000000000..26366aa4b4c1
--- /dev/null
+++ b/core/misc/ui/jquery.ui.button.min.js
@@ -0,0 +1,26 @@
+
+/*
+ * jQuery UI Button 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Button
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ */
+(function(a){var g,i=function(b){a(":ui-button",b.target.form).each(function(){var c=a(this).data("button");setTimeout(function(){c.refresh()},1)})},h=function(b){var c=b.name,d=b.form,e=a([]);if(c)e=d?a(d).find("[name='"+c+"']"):a("[name='"+c+"']",b.ownerDocument).filter(function(){return!this.form});return e};a.widget("ui.button",{options:{disabled:null,text:true,label:null,icons:{primary:null,secondary:null}},_create:function(){this.element.closest("form").unbind("reset.button").bind("reset.button",
+i);if(typeof this.options.disabled!=="boolean")this.options.disabled=this.element.attr("disabled");this._determineButtonType();this.hasTitle=!!this.buttonElement.attr("title");var b=this,c=this.options,d=this.type==="checkbox"||this.type==="radio",e="ui-state-hover"+(!d?" ui-state-active":"");if(c.label===null)c.label=this.buttonElement.html();if(this.element.is(":disabled"))c.disabled=true;this.buttonElement.addClass("ui-button ui-widget ui-state-default ui-corner-all").attr("role","button").bind("mouseenter.button",
+function(){if(!c.disabled){a(this).addClass("ui-state-hover");this===g&&a(this).addClass("ui-state-active")}}).bind("mouseleave.button",function(){c.disabled||a(this).removeClass(e)}).bind("focus.button",function(){a(this).addClass("ui-state-focus")}).bind("blur.button",function(){a(this).removeClass("ui-state-focus")});d&&this.element.bind("change.button",function(){b.refresh()});if(this.type==="checkbox")this.buttonElement.bind("click.button",function(){if(c.disabled)return false;a(this).toggleClass("ui-state-active");
+b.buttonElement.attr("aria-pressed",b.element[0].checked)});else if(this.type==="radio")this.buttonElement.bind("click.button",function(){if(c.disabled)return false;a(this).addClass("ui-state-active");b.buttonElement.attr("aria-pressed",true);var f=b.element[0];h(f).not(f).map(function(){return a(this).button("widget")[0]}).removeClass("ui-state-active").attr("aria-pressed",false)});else{this.buttonElement.bind("mousedown.button",function(){if(c.disabled)return false;a(this).addClass("ui-state-active");
+g=this;a(document).one("mouseup",function(){g=null})}).bind("mouseup.button",function(){if(c.disabled)return false;a(this).removeClass("ui-state-active")}).bind("keydown.button",function(f){if(c.disabled)return false;if(f.keyCode==a.ui.keyCode.SPACE||f.keyCode==a.ui.keyCode.ENTER)a(this).addClass("ui-state-active")}).bind("keyup.button",function(){a(this).removeClass("ui-state-active")});this.buttonElement.is("a")&&this.buttonElement.keyup(function(f){f.keyCode===a.ui.keyCode.SPACE&&a(this).click()})}this._setOption("disabled",
+c.disabled)},_determineButtonType:function(){this.type=this.element.is(":checkbox")?"checkbox":this.element.is(":radio")?"radio":this.element.is("input")?"input":"button";if(this.type==="checkbox"||this.type==="radio"){this.buttonElement=this.element.parents().last().find("label[for="+this.element.attr("id")+"]");this.element.addClass("ui-helper-hidden-accessible");var b=this.element.is(":checked");b&&this.buttonElement.addClass("ui-state-active");this.buttonElement.attr("aria-pressed",b)}else this.buttonElement=
+this.element},widget:function(){return this.buttonElement},destroy:function(){this.element.removeClass("ui-helper-hidden-accessible");this.buttonElement.removeClass("ui-button ui-widget ui-state-default ui-corner-all ui-state-hover ui-state-active ui-button-icons-only ui-button-icon-only ui-button-text-icons ui-button-text-icon-primary ui-button-text-icon-secondary ui-button-text-only").removeAttr("role").removeAttr("aria-pressed").html(this.buttonElement.find(".ui-button-text").html());this.hasTitle||
+this.buttonElement.removeAttr("title");a.Widget.prototype.destroy.call(this)},_setOption:function(b,c){a.Widget.prototype._setOption.apply(this,arguments);if(b==="disabled")c?this.element.attr("disabled",true):this.element.removeAttr("disabled");this._resetButton()},refresh:function(){var b=this.element.is(":disabled");b!==this.options.disabled&&this._setOption("disabled",b);if(this.type==="radio")h(this.element[0]).each(function(){a(this).is(":checked")?a(this).button("widget").addClass("ui-state-active").attr("aria-pressed",
+true):a(this).button("widget").removeClass("ui-state-active").attr("aria-pressed",false)});else if(this.type==="checkbox")this.element.is(":checked")?this.buttonElement.addClass("ui-state-active").attr("aria-pressed",true):this.buttonElement.removeClass("ui-state-active").attr("aria-pressed",false)},_resetButton:function(){if(this.type==="input")this.options.label&&this.element.val(this.options.label);else{var b=this.buttonElement.removeClass("ui-button-icons-only ui-button-icon-only ui-button-text-icons ui-button-text-icon-primary ui-button-text-icon-secondary ui-button-text-only"),
+c=a("<span></span>").addClass("ui-button-text").html(this.options.label).appendTo(b.empty()).text(),d=this.options.icons,e=d.primary&&d.secondary;if(d.primary||d.secondary){b.addClass("ui-button-text-icon"+(e?"s":d.primary?"-primary":"-secondary"));d.primary&&b.prepend("<span class='ui-button-icon-primary ui-icon "+d.primary+"'></span>");d.secondary&&b.append("<span class='ui-button-icon-secondary ui-icon "+d.secondary+"'></span>");if(!this.options.text){b.addClass(e?"ui-button-icons-only":"ui-button-icon-only").removeClass("ui-button-text-icons ui-button-text-icon-primary ui-button-text-icon-secondary");
+this.hasTitle||b.attr("title",c)}}else b.addClass("ui-button-text-only")}}});a.widget("ui.buttonset",{options:{items:":button, :submit, :reset, :checkbox, :radio, a, :data(button)"},_create:function(){this.element.addClass("ui-buttonset")},_init:function(){this.refresh()},_setOption:function(b,c){b==="disabled"&&this.buttons.button("option",b,c);a.Widget.prototype._setOption.apply(this,arguments)},refresh:function(){this.buttons=this.element.find(this.options.items).filter(":ui-button").button("refresh").end().not(":ui-button").button().end().map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-all ui-corner-left ui-corner-right").filter(":first").addClass("ui-corner-left").end().filter(":last").addClass("ui-corner-right").end().end()},
+destroy:function(){this.element.removeClass("ui-buttonset");this.buttons.map(function(){return a(this).button("widget")[0]}).removeClass("ui-corner-left ui-corner-right").end().button("destroy");a.Widget.prototype.destroy.call(this)}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.core.css b/core/misc/ui/jquery.ui.core.css
new file mode 100644
index 000000000000..d436225e4143
--- /dev/null
+++ b/core/misc/ui/jquery.ui.core.css
@@ -0,0 +1,42 @@
+
+/*
+ * jQuery UI CSS Framework 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Theming/API
+ */
+
+/* Layout helpers
+----------------------------------*/
+.ui-helper-hidden { display: none; }
+.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); }
+.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }
+.ui-helper-clearfix:after { content: "."; display: block; height: 0; clear: both; visibility: hidden; }
+.ui-helper-clearfix { display: inline-block; }
+/* required comment for clearfix to work in Opera \*/
+* html .ui-helper-clearfix { height:1%; }
+.ui-helper-clearfix { display:block; }
+/* end clearfix */
+.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }
+
+
+/* Interaction Cues
+----------------------------------*/
+.ui-state-disabled { cursor: default !important; }
+
+
+/* Icons
+----------------------------------*/
+
+/* states and images */
+.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }
+
+
+/* Misc visuals
+----------------------------------*/
+
+/* Overlays */
+.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
diff --git a/core/misc/ui/jquery.ui.core.min.js b/core/misc/ui/jquery.ui.core.min.js
new file mode 100644
index 000000000000..976e056ff97e
--- /dev/null
+++ b/core/misc/ui/jquery.ui.core.min.js
@@ -0,0 +1,18 @@
+
+/*!
+ * jQuery UI 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI
+ */
+(function(c,j){function k(a){return!c(a).parents().andSelf().filter(function(){return c.curCSS(this,"visibility")==="hidden"||c.expr.filters.hidden(this)}).length}c.ui=c.ui||{};if(!c.ui.version){c.extend(c.ui,{version:"1.8.7",keyCode:{ALT:18,BACKSPACE:8,CAPS_LOCK:20,COMMA:188,COMMAND:91,COMMAND_LEFT:91,COMMAND_RIGHT:93,CONTROL:17,DELETE:46,DOWN:40,END:35,ENTER:13,ESCAPE:27,HOME:36,INSERT:45,LEFT:37,MENU:93,NUMPAD_ADD:107,NUMPAD_DECIMAL:110,NUMPAD_DIVIDE:111,NUMPAD_ENTER:108,NUMPAD_MULTIPLY:106,
+NUMPAD_SUBTRACT:109,PAGE_DOWN:34,PAGE_UP:33,PERIOD:190,RIGHT:39,SHIFT:16,SPACE:32,TAB:9,UP:38,WINDOWS:91}});c.fn.extend({_focus:c.fn.focus,focus:function(a,b){return typeof a==="number"?this.each(function(){var d=this;setTimeout(function(){c(d).focus();b&&b.call(d)},a)}):this._focus.apply(this,arguments)},scrollParent:function(){var a;a=c.browser.msie&&/(static|relative)/.test(this.css("position"))||/absolute/.test(this.css("position"))?this.parents().filter(function(){return/(relative|absolute|fixed)/.test(c.curCSS(this,
+"position",1))&&/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this,"overflow-y",1)+c.curCSS(this,"overflow-x",1))}).eq(0):this.parents().filter(function(){return/(auto|scroll)/.test(c.curCSS(this,"overflow",1)+c.curCSS(this,"overflow-y",1)+c.curCSS(this,"overflow-x",1))}).eq(0);return/fixed/.test(this.css("position"))||!a.length?c(document):a},zIndex:function(a){if(a!==j)return this.css("zIndex",a);if(this.length){a=c(this[0]);for(var b;a.length&&a[0]!==document;){b=a.css("position");
+if(b==="absolute"||b==="relative"||b==="fixed"){b=parseInt(a.css("zIndex"),10);if(!isNaN(b)&&b!==0)return b}a=a.parent()}}return 0},disableSelection:function(){return this.bind((c.support.selectstart?"selectstart":"mousedown")+".ui-disableSelection",function(a){a.preventDefault()})},enableSelection:function(){return this.unbind(".ui-disableSelection")}});c.each(["Width","Height"],function(a,b){function d(f,g,l,m){c.each(e,function(){g-=parseFloat(c.curCSS(f,"padding"+this,true))||0;if(l)g-=parseFloat(c.curCSS(f,
+"border"+this+"Width",true))||0;if(m)g-=parseFloat(c.curCSS(f,"margin"+this,true))||0});return g}var e=b==="Width"?["Left","Right"]:["Top","Bottom"],h=b.toLowerCase(),i={innerWidth:c.fn.innerWidth,innerHeight:c.fn.innerHeight,outerWidth:c.fn.outerWidth,outerHeight:c.fn.outerHeight};c.fn["inner"+b]=function(f){if(f===j)return i["inner"+b].call(this);return this.each(function(){c(this).css(h,d(this,f)+"px")})};c.fn["outer"+b]=function(f,g){if(typeof f!=="number")return i["outer"+b].call(this,f);return this.each(function(){c(this).css(h,
+d(this,f,true,g)+"px")})}});c.extend(c.expr[":"],{data:function(a,b,d){return!!c.data(a,d[3])},focusable:function(a){var b=a.nodeName.toLowerCase(),d=c.attr(a,"tabindex");if("area"===b){b=a.parentNode;d=b.name;if(!a.href||!d||b.nodeName.toLowerCase()!=="map")return false;a=c("img[usemap=#"+d+"]")[0];return!!a&&k(a)}return(/input|select|textarea|button|object/.test(b)?!a.disabled:"a"==b?a.href||!isNaN(d):!isNaN(d))&&k(a)},tabbable:function(a){var b=c.attr(a,"tabindex");return(isNaN(b)||b>=0)&&c(a).is(":focusable")}});
+c(function(){var a=document.body,b=a.appendChild(b=document.createElement("div"));c.extend(b.style,{minHeight:"100px",height:"auto",padding:0,borderWidth:0});c.support.minHeight=b.offsetHeight===100;c.support.selectstart="onselectstart"in b;a.removeChild(b).style.display="none"});c.extend(c.ui,{plugin:{add:function(a,b,d){a=c.ui[a].prototype;for(var e in d){a.plugins[e]=a.plugins[e]||[];a.plugins[e].push([b,d[e]])}},call:function(a,b,d){if((b=a.plugins[b])&&a.element[0].parentNode)for(var e=0;e<b.length;e++)a.options[b[e][0]]&&
+b[e][1].apply(a.element,d)}},contains:function(a,b){return document.compareDocumentPosition?a.compareDocumentPosition(b)&16:a!==b&&a.contains(b)},hasScroll:function(a,b){if(c(a).css("overflow")==="hidden")return false;b=b&&b==="left"?"scrollLeft":"scrollTop";var d=false;if(a[b]>0)return true;a[b]=1;d=a[b]>0;a[b]=0;return d},isOverAxis:function(a,b,d){return a>b&&a<b+d},isOver:function(a,b,d,e,h,i){return c.ui.isOverAxis(a,d,h)&&c.ui.isOverAxis(b,e,i)}})}})(jQuery);
diff --git a/core/misc/ui/jquery.ui.datepicker.css b/core/misc/ui/jquery.ui.datepicker.css
new file mode 100644
index 000000000000..a90488405f92
--- /dev/null
+++ b/core/misc/ui/jquery.ui.datepicker.css
@@ -0,0 +1,69 @@
+
+/*
+ * jQuery UI Datepicker 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Datepicker#theming
+ */
+.ui-datepicker { width: 17em; padding: .2em .2em 0; display: none; }
+.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; }
+.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; }
+.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; }
+.ui-datepicker .ui-datepicker-prev { left:2px; }
+.ui-datepicker .ui-datepicker-next { right:2px; }
+.ui-datepicker .ui-datepicker-prev-hover { left:1px; }
+.ui-datepicker .ui-datepicker-next-hover { right:1px; }
+.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px; }
+.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; }
+.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; }
+.ui-datepicker select.ui-datepicker-month-year {width: 100%;}
+.ui-datepicker select.ui-datepicker-month,
+.ui-datepicker select.ui-datepicker-year { width: 49%;}
+.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; }
+.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; }
+.ui-datepicker td { border: 0; padding: 1px; }
+.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; }
+.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; }
+.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
+.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; }
+
+/* with multiple calendars */
+.ui-datepicker.ui-datepicker-multi { width:auto; }
+.ui-datepicker-multi .ui-datepicker-group { float:left; }
+.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; }
+.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; }
+.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; }
+.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; }
+.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; }
+.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; }
+.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; }
+.ui-datepicker-row-break { clear:both; width:100%; }
+
+/* RTL support */
+.ui-datepicker-rtl { direction: rtl; }
+.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; }
+.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; }
+.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; }
+.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; }
+.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; }
+.ui-datepicker-rtl .ui-datepicker-group { float:right; }
+.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
+.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
+
+/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */
+.ui-datepicker-cover {
+ display: none; /*sorry for IE5*/
+ display/**/: block; /*sorry for IE5*/
+ position: absolute; /*must have*/
+ z-index: -1; /*must have*/
+ filter: mask(); /*must have*/
+ top: -4px; /*must have*/
+ left: -4px; /*must have*/
+ width: 200px; /*must have*/
+ height: 200px; /*must have*/
+}
diff --git a/core/misc/ui/jquery.ui.datepicker.min.js b/core/misc/ui/jquery.ui.datepicker.min.js
new file mode 100644
index 000000000000..11af4811541a
--- /dev/null
+++ b/core/misc/ui/jquery.ui.datepicker.min.js
@@ -0,0 +1,82 @@
+
+/*
+ * jQuery UI Datepicker 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Datepicker
+ *
+ * Depends:
+ * jquery.ui.core.js
+ */
+(function(d,G){function K(){this.debug=false;this._curInst=null;this._keyEvent=false;this._disabledInputs=[];this._inDialog=this._datepickerShowing=false;this._mainDivId="ui-datepicker-div";this._inlineClass="ui-datepicker-inline";this._appendClass="ui-datepicker-append";this._triggerClass="ui-datepicker-trigger";this._dialogClass="ui-datepicker-dialog";this._disableClass="ui-datepicker-disabled";this._unselectableClass="ui-datepicker-unselectable";this._currentClass="ui-datepicker-current-day";this._dayOverClass=
+"ui-datepicker-days-cell-over";this.regional=[];this.regional[""]={closeText:"Done",prevText:"Prev",nextText:"Next",currentText:"Today",monthNames:["January","February","March","April","May","June","July","August","September","October","November","December"],monthNamesShort:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayNames:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayNamesShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayNamesMin:["Su",
+"Mo","Tu","We","Th","Fr","Sa"],weekHeader:"Wk",dateFormat:"mm/dd/yy",firstDay:0,isRTL:false,showMonthAfterYear:false,yearSuffix:""};this._defaults={showOn:"focus",showAnim:"fadeIn",showOptions:{},defaultDate:null,appendText:"",buttonText:"...",buttonImage:"",buttonImageOnly:false,hideIfNoPrevNext:false,navigationAsDateFormat:false,gotoCurrent:false,changeMonth:false,changeYear:false,yearRange:"c-10:c+10",showOtherMonths:false,selectOtherMonths:false,showWeek:false,calculateWeek:this.iso8601Week,shortYearCutoff:"+10",
+minDate:null,maxDate:null,duration:"fast",beforeShowDay:null,beforeShow:null,onSelect:null,onChangeMonthYear:null,onClose:null,numberOfMonths:1,showCurrentAtPos:0,stepMonths:1,stepBigMonths:12,altField:"",altFormat:"",constrainInput:true,showButtonPanel:false,autoSize:false};d.extend(this._defaults,this.regional[""]);this.dpDiv=d('<div id="'+this._mainDivId+'" class="ui-datepicker ui-widget ui-widget-content ui-helper-clearfix ui-corner-all"></div>')}function E(a,b){d.extend(a,b);for(var c in b)if(b[c]==
+null||b[c]==G)a[c]=b[c];return a}d.extend(d.ui,{datepicker:{version:"1.8.7"}});var y=(new Date).getTime();d.extend(K.prototype,{markerClassName:"hasDatepicker",log:function(){this.debug&&console.log.apply("",arguments)},_widgetDatepicker:function(){return this.dpDiv},setDefaults:function(a){E(this._defaults,a||{});return this},_attachDatepicker:function(a,b){var c=null;for(var e in this._defaults){var f=a.getAttribute("date:"+e);if(f){c=c||{};try{c[e]=eval(f)}catch(h){c[e]=f}}}e=a.nodeName.toLowerCase();
+f=e=="div"||e=="span";if(!a.id){this.uuid+=1;a.id="dp"+this.uuid}var i=this._newInst(d(a),f);i.settings=d.extend({},b||{},c||{});if(e=="input")this._connectDatepicker(a,i);else f&&this._inlineDatepicker(a,i)},_newInst:function(a,b){return{id:a[0].id.replace(/([^A-Za-z0-9_-])/g,"\\\\$1"),input:a,selectedDay:0,selectedMonth:0,selectedYear:0,drawMonth:0,drawYear:0,inline:b,dpDiv:!b?this.dpDiv:d('<div class="'+this._inlineClass+' ui-datepicker ui-widget ui-widget-content ui-helper-clearfix ui-corner-all"></div>')}},
+_connectDatepicker:function(a,b){var c=d(a);b.append=d([]);b.trigger=d([]);if(!c.hasClass(this.markerClassName)){this._attachments(c,b);c.addClass(this.markerClassName).keydown(this._doKeyDown).keypress(this._doKeyPress).keyup(this._doKeyUp).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});this._autoSize(b);d.data(a,"datepicker",b)}},_attachments:function(a,b){var c=this._get(b,"appendText"),e=this._get(b,"isRTL");b.append&&
+b.append.remove();if(c){b.append=d('<span class="'+this._appendClass+'">'+c+"</span>");a[e?"before":"after"](b.append)}a.unbind("focus",this._showDatepicker);b.trigger&&b.trigger.remove();c=this._get(b,"showOn");if(c=="focus"||c=="both")a.focus(this._showDatepicker);if(c=="button"||c=="both"){c=this._get(b,"buttonText");var f=this._get(b,"buttonImage");b.trigger=d(this._get(b,"buttonImageOnly")?d("<img/>").addClass(this._triggerClass).attr({src:f,alt:c,title:c}):d('<button type="button"></button>').addClass(this._triggerClass).html(f==
+""?c:d("<img/>").attr({src:f,alt:c,title:c})));a[e?"before":"after"](b.trigger);b.trigger.click(function(){d.datepicker._datepickerShowing&&d.datepicker._lastInput==a[0]?d.datepicker._hideDatepicker():d.datepicker._showDatepicker(a[0]);return false})}},_autoSize:function(a){if(this._get(a,"autoSize")&&!a.inline){var b=new Date(2009,11,20),c=this._get(a,"dateFormat");if(c.match(/[DM]/)){var e=function(f){for(var h=0,i=0,g=0;g<f.length;g++)if(f[g].length>h){h=f[g].length;i=g}return i};b.setMonth(e(this._get(a,
+c.match(/MM/)?"monthNames":"monthNamesShort")));b.setDate(e(this._get(a,c.match(/DD/)?"dayNames":"dayNamesShort"))+20-b.getDay())}a.input.attr("size",this._formatDate(a,b).length)}},_inlineDatepicker:function(a,b){var c=d(a);if(!c.hasClass(this.markerClassName)){c.addClass(this.markerClassName).append(b.dpDiv).bind("setData.datepicker",function(e,f,h){b.settings[f]=h}).bind("getData.datepicker",function(e,f){return this._get(b,f)});d.data(a,"datepicker",b);this._setDate(b,this._getDefaultDate(b),
+true);this._updateDatepicker(b);this._updateAlternate(b);b.dpDiv.show()}},_dialogDatepicker:function(a,b,c,e,f){a=this._dialogInst;if(!a){this.uuid+=1;this._dialogInput=d('<input type="text" id="'+("dp"+this.uuid)+'" style="position: absolute; top: -100px; width: 0px; z-index: -10;"/>');this._dialogInput.keydown(this._doKeyDown);d("body").append(this._dialogInput);a=this._dialogInst=this._newInst(this._dialogInput,false);a.settings={};d.data(this._dialogInput[0],"datepicker",a)}E(a.settings,e||{});
+b=b&&b.constructor==Date?this._formatDate(a,b):b;this._dialogInput.val(b);this._pos=f?f.length?f:[f.pageX,f.pageY]:null;if(!this._pos)this._pos=[document.documentElement.clientWidth/2-100+(document.documentElement.scrollLeft||document.body.scrollLeft),document.documentElement.clientHeight/2-150+(document.documentElement.scrollTop||document.body.scrollTop)];this._dialogInput.css("left",this._pos[0]+20+"px").css("top",this._pos[1]+"px");a.settings.onSelect=c;this._inDialog=true;this.dpDiv.addClass(this._dialogClass);
+this._showDatepicker(this._dialogInput[0]);d.blockUI&&d.blockUI(this.dpDiv);d.data(this._dialogInput[0],"datepicker",a);return this},_destroyDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();d.removeData(a,"datepicker");if(e=="input"){c.append.remove();c.trigger.remove();b.removeClass(this.markerClassName).unbind("focus",this._showDatepicker).unbind("keydown",this._doKeyDown).unbind("keypress",this._doKeyPress).unbind("keyup",
+this._doKeyUp)}else if(e=="div"||e=="span")b.removeClass(this.markerClassName).empty()}},_enableDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=false;c.trigger.filter("button").each(function(){this.disabled=false}).end().filter("img").css({opacity:"1.0",cursor:""})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().removeClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,
+function(f){return f==a?null:f})}},_disableDatepicker:function(a){var b=d(a),c=d.data(a,"datepicker");if(b.hasClass(this.markerClassName)){var e=a.nodeName.toLowerCase();if(e=="input"){a.disabled=true;c.trigger.filter("button").each(function(){this.disabled=true}).end().filter("img").css({opacity:"0.5",cursor:"default"})}else if(e=="div"||e=="span")b.children("."+this._inlineClass).children().addClass("ui-state-disabled");this._disabledInputs=d.map(this._disabledInputs,function(f){return f==a?null:
+f});this._disabledInputs[this._disabledInputs.length]=a}},_isDisabledDatepicker:function(a){if(!a)return false;for(var b=0;b<this._disabledInputs.length;b++)if(this._disabledInputs[b]==a)return true;return false},_getInst:function(a){try{return d.data(a,"datepicker")}catch(b){throw"Missing instance data for this datepicker";}},_optionDatepicker:function(a,b,c){var e=this._getInst(a);if(arguments.length==2&&typeof b=="string")return b=="defaults"?d.extend({},d.datepicker._defaults):e?b=="all"?d.extend({},
+e.settings):this._get(e,b):null;var f=b||{};if(typeof b=="string"){f={};f[b]=c}if(e){this._curInst==e&&this._hideDatepicker();var h=this._getDateDatepicker(a,true);E(e.settings,f);this._attachments(d(a),e);this._autoSize(e);this._setDateDatepicker(a,h);this._updateDatepicker(e)}},_changeDatepicker:function(a,b,c){this._optionDatepicker(a,b,c)},_refreshDatepicker:function(a){(a=this._getInst(a))&&this._updateDatepicker(a)},_setDateDatepicker:function(a,b){if(a=this._getInst(a)){this._setDate(a,b);
+this._updateDatepicker(a);this._updateAlternate(a)}},_getDateDatepicker:function(a,b){(a=this._getInst(a))&&!a.inline&&this._setDateFromField(a,b);return a?this._getDate(a):null},_doKeyDown:function(a){var b=d.datepicker._getInst(a.target),c=true,e=b.dpDiv.is(".ui-datepicker-rtl");b._keyEvent=true;if(d.datepicker._datepickerShowing)switch(a.keyCode){case 9:d.datepicker._hideDatepicker();c=false;break;case 13:c=d("td."+d.datepicker._dayOverClass+":not(."+d.datepicker._currentClass+")",b.dpDiv);c[0]?
+d.datepicker._selectDay(a.target,b.selectedMonth,b.selectedYear,c[0]):d.datepicker._hideDatepicker();return false;case 27:d.datepicker._hideDatepicker();break;case 33:d.datepicker._adjustDate(a.target,a.ctrlKey?-d.datepicker._get(b,"stepBigMonths"):-d.datepicker._get(b,"stepMonths"),"M");break;case 34:d.datepicker._adjustDate(a.target,a.ctrlKey?+d.datepicker._get(b,"stepBigMonths"):+d.datepicker._get(b,"stepMonths"),"M");break;case 35:if(a.ctrlKey||a.metaKey)d.datepicker._clearDate(a.target);c=a.ctrlKey||
+a.metaKey;break;case 36:if(a.ctrlKey||a.metaKey)d.datepicker._gotoToday(a.target);c=a.ctrlKey||a.metaKey;break;case 37:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,e?+1:-1,"D");c=a.ctrlKey||a.metaKey;if(a.originalEvent.altKey)d.datepicker._adjustDate(a.target,a.ctrlKey?-d.datepicker._get(b,"stepBigMonths"):-d.datepicker._get(b,"stepMonths"),"M");break;case 38:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,-7,"D");c=a.ctrlKey||a.metaKey;break;case 39:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,
+e?-1:+1,"D");c=a.ctrlKey||a.metaKey;if(a.originalEvent.altKey)d.datepicker._adjustDate(a.target,a.ctrlKey?+d.datepicker._get(b,"stepBigMonths"):+d.datepicker._get(b,"stepMonths"),"M");break;case 40:if(a.ctrlKey||a.metaKey)d.datepicker._adjustDate(a.target,+7,"D");c=a.ctrlKey||a.metaKey;break;default:c=false}else if(a.keyCode==36&&a.ctrlKey)d.datepicker._showDatepicker(this);else c=false;if(c){a.preventDefault();a.stopPropagation()}},_doKeyPress:function(a){var b=d.datepicker._getInst(a.target);if(d.datepicker._get(b,
+"constrainInput")){b=d.datepicker._possibleChars(d.datepicker._get(b,"dateFormat"));var c=String.fromCharCode(a.charCode==G?a.keyCode:a.charCode);return a.ctrlKey||a.metaKey||c<" "||!b||b.indexOf(c)>-1}},_doKeyUp:function(a){a=d.datepicker._getInst(a.target);if(a.input.val()!=a.lastVal)try{if(d.datepicker.parseDate(d.datepicker._get(a,"dateFormat"),a.input?a.input.val():null,d.datepicker._getFormatConfig(a))){d.datepicker._setDateFromField(a);d.datepicker._updateAlternate(a);d.datepicker._updateDatepicker(a)}}catch(b){d.datepicker.log(b)}return true},
+_showDatepicker:function(a){a=a.target||a;if(a.nodeName.toLowerCase()!="input")a=d("input",a.parentNode)[0];if(!(d.datepicker._isDisabledDatepicker(a)||d.datepicker._lastInput==a)){var b=d.datepicker._getInst(a);d.datepicker._curInst&&d.datepicker._curInst!=b&&d.datepicker._curInst.dpDiv.stop(true,true);var c=d.datepicker._get(b,"beforeShow");E(b.settings,c?c.apply(a,[a,b]):{});b.lastVal=null;d.datepicker._lastInput=a;d.datepicker._setDateFromField(b);if(d.datepicker._inDialog)a.value="";if(!d.datepicker._pos){d.datepicker._pos=
+d.datepicker._findPos(a);d.datepicker._pos[1]+=a.offsetHeight}var e=false;d(a).parents().each(function(){e|=d(this).css("position")=="fixed";return!e});if(e&&d.browser.opera){d.datepicker._pos[0]-=document.documentElement.scrollLeft;d.datepicker._pos[1]-=document.documentElement.scrollTop}c={left:d.datepicker._pos[0],top:d.datepicker._pos[1]};d.datepicker._pos=null;b.dpDiv.empty();b.dpDiv.css({position:"absolute",display:"block",top:"-1000px"});d.datepicker._updateDatepicker(b);c=d.datepicker._checkOffset(b,
+c,e);b.dpDiv.css({position:d.datepicker._inDialog&&d.blockUI?"static":e?"fixed":"absolute",display:"none",left:c.left+"px",top:c.top+"px"});if(!b.inline){c=d.datepicker._get(b,"showAnim");var f=d.datepicker._get(b,"duration"),h=function(){d.datepicker._datepickerShowing=true;var i=b.dpDiv.find("iframe.ui-datepicker-cover");if(i.length){var g=d.datepicker._getBorders(b.dpDiv);i.css({left:-g[0],top:-g[1],width:b.dpDiv.outerWidth(),height:b.dpDiv.outerHeight()})}};b.dpDiv.zIndex(d(a).zIndex()+1);d.effects&&
+d.effects[c]?b.dpDiv.show(c,d.datepicker._get(b,"showOptions"),f,h):b.dpDiv[c||"show"](c?f:null,h);if(!c||!f)h();b.input.is(":visible")&&!b.input.is(":disabled")&&b.input.focus();d.datepicker._curInst=b}}},_updateDatepicker:function(a){var b=this,c=d.datepicker._getBorders(a.dpDiv);a.dpDiv.empty().append(this._generateHTML(a));var e=a.dpDiv.find("iframe.ui-datepicker-cover");e.length&&e.css({left:-c[0],top:-c[1],width:a.dpDiv.outerWidth(),height:a.dpDiv.outerHeight()});a.dpDiv.find("button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a").bind("mouseout",
+function(){d(this).removeClass("ui-state-hover");this.className.indexOf("ui-datepicker-prev")!=-1&&d(this).removeClass("ui-datepicker-prev-hover");this.className.indexOf("ui-datepicker-next")!=-1&&d(this).removeClass("ui-datepicker-next-hover")}).bind("mouseover",function(){if(!b._isDisabledDatepicker(a.inline?a.dpDiv.parent()[0]:a.input[0])){d(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover");d(this).addClass("ui-state-hover");this.className.indexOf("ui-datepicker-prev")!=
+-1&&d(this).addClass("ui-datepicker-prev-hover");this.className.indexOf("ui-datepicker-next")!=-1&&d(this).addClass("ui-datepicker-next-hover")}}).end().find("."+this._dayOverClass+" a").trigger("mouseover").end();c=this._getNumberOfMonths(a);e=c[1];e>1?a.dpDiv.addClass("ui-datepicker-multi-"+e).css("width",17*e+"em"):a.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width("");a.dpDiv[(c[0]!=1||c[1]!=1?"add":"remove")+"Class"]("ui-datepicker-multi");a.dpDiv[(this._get(a,
+"isRTL")?"add":"remove")+"Class"]("ui-datepicker-rtl");a==d.datepicker._curInst&&d.datepicker._datepickerShowing&&a.input&&a.input.is(":visible")&&!a.input.is(":disabled")&&a.input.focus();if(a.yearshtml){var f=a.yearshtml;setTimeout(function(){f===a.yearshtml&&a.dpDiv.find("select.ui-datepicker-year:first").replaceWith(a.yearshtml);f=a.yearshtml=null},0)}},_getBorders:function(a){var b=function(c){return{thin:1,medium:2,thick:3}[c]||c};return[parseFloat(b(a.css("border-left-width"))),parseFloat(b(a.css("border-top-width")))]},
+_checkOffset:function(a,b,c){var e=a.dpDiv.outerWidth(),f=a.dpDiv.outerHeight(),h=a.input?a.input.outerWidth():0,i=a.input?a.input.outerHeight():0,g=document.documentElement.clientWidth+d(document).scrollLeft(),j=document.documentElement.clientHeight+d(document).scrollTop();b.left-=this._get(a,"isRTL")?e-h:0;b.left-=c&&b.left==a.input.offset().left?d(document).scrollLeft():0;b.top-=c&&b.top==a.input.offset().top+i?d(document).scrollTop():0;b.left-=Math.min(b.left,b.left+e>g&&g>e?Math.abs(b.left+e-
+g):0);b.top-=Math.min(b.top,b.top+f>j&&j>f?Math.abs(f+i):0);return b},_findPos:function(a){for(var b=this._get(this._getInst(a),"isRTL");a&&(a.type=="hidden"||a.nodeType!=1);)a=a[b?"previousSibling":"nextSibling"];a=d(a).offset();return[a.left,a.top]},_hideDatepicker:function(a){var b=this._curInst;if(!(!b||a&&b!=d.data(a,"datepicker")))if(this._datepickerShowing){a=this._get(b,"showAnim");var c=this._get(b,"duration"),e=function(){d.datepicker._tidyDialog(b);this._curInst=null};d.effects&&d.effects[a]?
+b.dpDiv.hide(a,d.datepicker._get(b,"showOptions"),c,e):b.dpDiv[a=="slideDown"?"slideUp":a=="fadeIn"?"fadeOut":"hide"](a?c:null,e);a||e();if(a=this._get(b,"onClose"))a.apply(b.input?b.input[0]:null,[b.input?b.input.val():"",b]);this._datepickerShowing=false;this._lastInput=null;if(this._inDialog){this._dialogInput.css({position:"absolute",left:"0",top:"-100px"});if(d.blockUI){d.unblockUI();d("body").append(this.dpDiv)}}this._inDialog=false}},_tidyDialog:function(a){a.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar")},
+_checkExternalClick:function(a){if(d.datepicker._curInst){a=d(a.target);a[0].id!=d.datepicker._mainDivId&&a.parents("#"+d.datepicker._mainDivId).length==0&&!a.hasClass(d.datepicker.markerClassName)&&!a.hasClass(d.datepicker._triggerClass)&&d.datepicker._datepickerShowing&&!(d.datepicker._inDialog&&d.blockUI)&&d.datepicker._hideDatepicker()}},_adjustDate:function(a,b,c){a=d(a);var e=this._getInst(a[0]);if(!this._isDisabledDatepicker(a[0])){this._adjustInstDate(e,b+(c=="M"?this._get(e,"showCurrentAtPos"):
+0),c);this._updateDatepicker(e)}},_gotoToday:function(a){a=d(a);var b=this._getInst(a[0]);if(this._get(b,"gotoCurrent")&&b.currentDay){b.selectedDay=b.currentDay;b.drawMonth=b.selectedMonth=b.currentMonth;b.drawYear=b.selectedYear=b.currentYear}else{var c=new Date;b.selectedDay=c.getDate();b.drawMonth=b.selectedMonth=c.getMonth();b.drawYear=b.selectedYear=c.getFullYear()}this._notifyChange(b);this._adjustDate(a)},_selectMonthYear:function(a,b,c){a=d(a);var e=this._getInst(a[0]);e._selectingMonthYear=
+false;e["selected"+(c=="M"?"Month":"Year")]=e["draw"+(c=="M"?"Month":"Year")]=parseInt(b.options[b.selectedIndex].value,10);this._notifyChange(e);this._adjustDate(a)},_clickMonthYear:function(a){var b=this._getInst(d(a)[0]);b.input&&b._selectingMonthYear&&setTimeout(function(){b.input.focus()},0);b._selectingMonthYear=!b._selectingMonthYear},_selectDay:function(a,b,c,e){var f=d(a);if(!(d(e).hasClass(this._unselectableClass)||this._isDisabledDatepicker(f[0]))){f=this._getInst(f[0]);f.selectedDay=f.currentDay=
+d("a",e).html();f.selectedMonth=f.currentMonth=b;f.selectedYear=f.currentYear=c;this._selectDate(a,this._formatDate(f,f.currentDay,f.currentMonth,f.currentYear))}},_clearDate:function(a){a=d(a);this._getInst(a[0]);this._selectDate(a,"")},_selectDate:function(a,b){a=this._getInst(d(a)[0]);b=b!=null?b:this._formatDate(a);a.input&&a.input.val(b);this._updateAlternate(a);var c=this._get(a,"onSelect");if(c)c.apply(a.input?a.input[0]:null,[b,a]);else a.input&&a.input.trigger("change");if(a.inline)this._updateDatepicker(a);
+else{this._hideDatepicker();this._lastInput=a.input[0];typeof a.input[0]!="object"&&a.input.focus();this._lastInput=null}},_updateAlternate:function(a){var b=this._get(a,"altField");if(b){var c=this._get(a,"altFormat")||this._get(a,"dateFormat"),e=this._getDate(a),f=this.formatDate(c,e,this._getFormatConfig(a));d(b).each(function(){d(this).val(f)})}},noWeekends:function(a){a=a.getDay();return[a>0&&a<6,""]},iso8601Week:function(a){a=new Date(a.getTime());a.setDate(a.getDate()+4-(a.getDay()||7));var b=
+a.getTime();a.setMonth(0);a.setDate(1);return Math.floor(Math.round((b-a)/864E5)/7)+1},parseDate:function(a,b,c){if(a==null||b==null)throw"Invalid arguments";b=typeof b=="object"?b.toString():b+"";if(b=="")return null;for(var e=(c?c.shortYearCutoff:null)||this._defaults.shortYearCutoff,f=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,h=(c?c.dayNames:null)||this._defaults.dayNames,i=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort,g=(c?c.monthNames:null)||this._defaults.monthNames,
+j=c=-1,l=-1,u=-1,k=false,o=function(p){(p=z+1<a.length&&a.charAt(z+1)==p)&&z++;return p},m=function(p){var v=o(p);p=new RegExp("^\\d{1,"+(p=="@"?14:p=="!"?20:p=="y"&&v?4:p=="o"?3:2)+"}");p=b.substring(s).match(p);if(!p)throw"Missing number at position "+s;s+=p[0].length;return parseInt(p[0],10)},n=function(p,v,H){p=o(p)?H:v;for(v=0;v<p.length;v++)if(b.substr(s,p[v].length).toLowerCase()==p[v].toLowerCase()){s+=p[v].length;return v+1}throw"Unknown name at position "+s;},r=function(){if(b.charAt(s)!=
+a.charAt(z))throw"Unexpected literal at position "+s;s++},s=0,z=0;z<a.length;z++)if(k)if(a.charAt(z)=="'"&&!o("'"))k=false;else r();else switch(a.charAt(z)){case "d":l=m("d");break;case "D":n("D",f,h);break;case "o":u=m("o");break;case "m":j=m("m");break;case "M":j=n("M",i,g);break;case "y":c=m("y");break;case "@":var w=new Date(m("@"));c=w.getFullYear();j=w.getMonth()+1;l=w.getDate();break;case "!":w=new Date((m("!")-this._ticksTo1970)/1E4);c=w.getFullYear();j=w.getMonth()+1;l=w.getDate();break;
+case "'":if(o("'"))r();else k=true;break;default:r()}if(c==-1)c=(new Date).getFullYear();else if(c<100)c+=(new Date).getFullYear()-(new Date).getFullYear()%100+(c<=e?0:-100);if(u>-1){j=1;l=u;do{e=this._getDaysInMonth(c,j-1);if(l<=e)break;j++;l-=e}while(1)}w=this._daylightSavingAdjust(new Date(c,j-1,l));if(w.getFullYear()!=c||w.getMonth()+1!=j||w.getDate()!=l)throw"Invalid date";return w},ATOM:"yy-mm-dd",COOKIE:"D, dd M yy",ISO_8601:"yy-mm-dd",RFC_822:"D, d M y",RFC_850:"DD, dd-M-y",RFC_1036:"D, d M y",
+RFC_1123:"D, d M yy",RFC_2822:"D, d M yy",RSS:"D, d M y",TICKS:"!",TIMESTAMP:"@",W3C:"yy-mm-dd",_ticksTo1970:(718685+Math.floor(492.5)-Math.floor(19.7)+Math.floor(4.925))*24*60*60*1E7,formatDate:function(a,b,c){if(!b)return"";var e=(c?c.dayNamesShort:null)||this._defaults.dayNamesShort,f=(c?c.dayNames:null)||this._defaults.dayNames,h=(c?c.monthNamesShort:null)||this._defaults.monthNamesShort;c=(c?c.monthNames:null)||this._defaults.monthNames;var i=function(o){(o=k+1<a.length&&a.charAt(k+1)==o)&&k++;
+return o},g=function(o,m,n){m=""+m;if(i(o))for(;m.length<n;)m="0"+m;return m},j=function(o,m,n,r){return i(o)?r[m]:n[m]},l="",u=false;if(b)for(var k=0;k<a.length;k++)if(u)if(a.charAt(k)=="'"&&!i("'"))u=false;else l+=a.charAt(k);else switch(a.charAt(k)){case "d":l+=g("d",b.getDate(),2);break;case "D":l+=j("D",b.getDay(),e,f);break;case "o":l+=g("o",(b.getTime()-(new Date(b.getFullYear(),0,0)).getTime())/864E5,3);break;case "m":l+=g("m",b.getMonth()+1,2);break;case "M":l+=j("M",b.getMonth(),h,c);break;
+case "y":l+=i("y")?b.getFullYear():(b.getYear()%100<10?"0":"")+b.getYear()%100;break;case "@":l+=b.getTime();break;case "!":l+=b.getTime()*1E4+this._ticksTo1970;break;case "'":if(i("'"))l+="'";else u=true;break;default:l+=a.charAt(k)}return l},_possibleChars:function(a){for(var b="",c=false,e=function(h){(h=f+1<a.length&&a.charAt(f+1)==h)&&f++;return h},f=0;f<a.length;f++)if(c)if(a.charAt(f)=="'"&&!e("'"))c=false;else b+=a.charAt(f);else switch(a.charAt(f)){case "d":case "m":case "y":case "@":b+=
+"0123456789";break;case "D":case "M":return null;case "'":if(e("'"))b+="'";else c=true;break;default:b+=a.charAt(f)}return b},_get:function(a,b){return a.settings[b]!==G?a.settings[b]:this._defaults[b]},_setDateFromField:function(a,b){if(a.input.val()!=a.lastVal){var c=this._get(a,"dateFormat"),e=a.lastVal=a.input?a.input.val():null,f,h;f=h=this._getDefaultDate(a);var i=this._getFormatConfig(a);try{f=this.parseDate(c,e,i)||h}catch(g){this.log(g);e=b?"":e}a.selectedDay=f.getDate();a.drawMonth=a.selectedMonth=
+f.getMonth();a.drawYear=a.selectedYear=f.getFullYear();a.currentDay=e?f.getDate():0;a.currentMonth=e?f.getMonth():0;a.currentYear=e?f.getFullYear():0;this._adjustInstDate(a)}},_getDefaultDate:function(a){return this._restrictMinMax(a,this._determineDate(a,this._get(a,"defaultDate"),new Date))},_determineDate:function(a,b,c){var e=function(h){var i=new Date;i.setDate(i.getDate()+h);return i},f=function(h){try{return d.datepicker.parseDate(d.datepicker._get(a,"dateFormat"),h,d.datepicker._getFormatConfig(a))}catch(i){}var g=
+(h.toLowerCase().match(/^c/)?d.datepicker._getDate(a):null)||new Date,j=g.getFullYear(),l=g.getMonth();g=g.getDate();for(var u=/([+-]?[0-9]+)\s*(d|D|w|W|m|M|y|Y)?/g,k=u.exec(h);k;){switch(k[2]||"d"){case "d":case "D":g+=parseInt(k[1],10);break;case "w":case "W":g+=parseInt(k[1],10)*7;break;case "m":case "M":l+=parseInt(k[1],10);g=Math.min(g,d.datepicker._getDaysInMonth(j,l));break;case "y":case "Y":j+=parseInt(k[1],10);g=Math.min(g,d.datepicker._getDaysInMonth(j,l));break}k=u.exec(h)}return new Date(j,
+l,g)};if(b=(b=b==null||b===""?c:typeof b=="string"?f(b):typeof b=="number"?isNaN(b)?c:e(b):new Date(b.getTime()))&&b.toString()=="Invalid Date"?c:b){b.setHours(0);b.setMinutes(0);b.setSeconds(0);b.setMilliseconds(0)}return this._daylightSavingAdjust(b)},_daylightSavingAdjust:function(a){if(!a)return null;a.setHours(a.getHours()>12?a.getHours()+2:0);return a},_setDate:function(a,b,c){var e=!b,f=a.selectedMonth,h=a.selectedYear;b=this._restrictMinMax(a,this._determineDate(a,b,new Date));a.selectedDay=
+a.currentDay=b.getDate();a.drawMonth=a.selectedMonth=a.currentMonth=b.getMonth();a.drawYear=a.selectedYear=a.currentYear=b.getFullYear();if((f!=a.selectedMonth||h!=a.selectedYear)&&!c)this._notifyChange(a);this._adjustInstDate(a);if(a.input)a.input.val(e?"":this._formatDate(a))},_getDate:function(a){return!a.currentYear||a.input&&a.input.val()==""?null:this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay))},_generateHTML:function(a){var b=new Date;b=this._daylightSavingAdjust(new Date(b.getFullYear(),
+b.getMonth(),b.getDate()));var c=this._get(a,"isRTL"),e=this._get(a,"showButtonPanel"),f=this._get(a,"hideIfNoPrevNext"),h=this._get(a,"navigationAsDateFormat"),i=this._getNumberOfMonths(a),g=this._get(a,"showCurrentAtPos"),j=this._get(a,"stepMonths"),l=i[0]!=1||i[1]!=1,u=this._daylightSavingAdjust(!a.currentDay?new Date(9999,9,9):new Date(a.currentYear,a.currentMonth,a.currentDay)),k=this._getMinMaxDate(a,"min"),o=this._getMinMaxDate(a,"max");g=a.drawMonth-g;var m=a.drawYear;if(g<0){g+=12;m--}if(o){var n=
+this._daylightSavingAdjust(new Date(o.getFullYear(),o.getMonth()-i[0]*i[1]+1,o.getDate()));for(n=k&&n<k?k:n;this._daylightSavingAdjust(new Date(m,g,1))>n;){g--;if(g<0){g=11;m--}}}a.drawMonth=g;a.drawYear=m;n=this._get(a,"prevText");n=!h?n:this.formatDate(n,this._daylightSavingAdjust(new Date(m,g-j,1)),this._getFormatConfig(a));n=this._canAdjustMonth(a,-1,m,g)?'<a class="ui-datepicker-prev ui-corner-all" onclick="DP_jQuery_'+y+".datepicker._adjustDate('#"+a.id+"', -"+j+", 'M');\" title=\""+n+'"><span class="ui-icon ui-icon-circle-triangle-'+
+(c?"e":"w")+'">'+n+"</span></a>":f?"":'<a class="ui-datepicker-prev ui-corner-all ui-state-disabled" title="'+n+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"e":"w")+'">'+n+"</span></a>";var r=this._get(a,"nextText");r=!h?r:this.formatDate(r,this._daylightSavingAdjust(new Date(m,g+j,1)),this._getFormatConfig(a));f=this._canAdjustMonth(a,+1,m,g)?'<a class="ui-datepicker-next ui-corner-all" onclick="DP_jQuery_'+y+".datepicker._adjustDate('#"+a.id+"', +"+j+", 'M');\" title=\""+r+'"><span class="ui-icon ui-icon-circle-triangle-'+
+(c?"w":"e")+'">'+r+"</span></a>":f?"":'<a class="ui-datepicker-next ui-corner-all ui-state-disabled" title="'+r+'"><span class="ui-icon ui-icon-circle-triangle-'+(c?"w":"e")+'">'+r+"</span></a>";j=this._get(a,"currentText");r=this._get(a,"gotoCurrent")&&a.currentDay?u:b;j=!h?j:this.formatDate(j,r,this._getFormatConfig(a));h=!a.inline?'<button type="button" class="ui-datepicker-close ui-state-default ui-priority-primary ui-corner-all" onclick="DP_jQuery_'+y+'.datepicker._hideDatepicker();">'+this._get(a,
+"closeText")+"</button>":"";e=e?'<div class="ui-datepicker-buttonpane ui-widget-content">'+(c?h:"")+(this._isInRange(a,r)?'<button type="button" class="ui-datepicker-current ui-state-default ui-priority-secondary ui-corner-all" onclick="DP_jQuery_'+y+".datepicker._gotoToday('#"+a.id+"');\">"+j+"</button>":"")+(c?"":h)+"</div>":"";h=parseInt(this._get(a,"firstDay"),10);h=isNaN(h)?0:h;j=this._get(a,"showWeek");r=this._get(a,"dayNames");this._get(a,"dayNamesShort");var s=this._get(a,"dayNamesMin"),z=
+this._get(a,"monthNames"),w=this._get(a,"monthNamesShort"),p=this._get(a,"beforeShowDay"),v=this._get(a,"showOtherMonths"),H=this._get(a,"selectOtherMonths");this._get(a,"calculateWeek");for(var L=this._getDefaultDate(a),I="",C=0;C<i[0];C++){for(var M="",D=0;D<i[1];D++){var N=this._daylightSavingAdjust(new Date(m,g,a.selectedDay)),t=" ui-corner-all",x="";if(l){x+='<div class="ui-datepicker-group';if(i[1]>1)switch(D){case 0:x+=" ui-datepicker-group-first";t=" ui-corner-"+(c?"right":"left");break;case i[1]-
+1:x+=" ui-datepicker-group-last";t=" ui-corner-"+(c?"left":"right");break;default:x+=" ui-datepicker-group-middle";t="";break}x+='">'}x+='<div class="ui-datepicker-header ui-widget-header ui-helper-clearfix'+t+'">'+(/all|left/.test(t)&&C==0?c?f:n:"")+(/all|right/.test(t)&&C==0?c?n:f:"")+this._generateMonthYearHeader(a,g,m,k,o,C>0||D>0,z,w)+'</div><table class="ui-datepicker-calendar"><thead><tr>';var A=j?'<th class="ui-datepicker-week-col">'+this._get(a,"weekHeader")+"</th>":"";for(t=0;t<7;t++){var q=
+(t+h)%7;A+="<th"+((t+h+6)%7>=5?' class="ui-datepicker-week-end"':"")+'><span title="'+r[q]+'">'+s[q]+"</span></th>"}x+=A+"</tr></thead><tbody>";A=this._getDaysInMonth(m,g);if(m==a.selectedYear&&g==a.selectedMonth)a.selectedDay=Math.min(a.selectedDay,A);t=(this._getFirstDayOfMonth(m,g)-h+7)%7;A=l?6:Math.ceil((t+A)/7);q=this._daylightSavingAdjust(new Date(m,g,1-t));for(var O=0;O<A;O++){x+="<tr>";var P=!j?"":'<td class="ui-datepicker-week-col">'+this._get(a,"calculateWeek")(q)+"</td>";for(t=0;t<7;t++){var F=
+p?p.apply(a.input?a.input[0]:null,[q]):[true,""],B=q.getMonth()!=g,J=B&&!H||!F[0]||k&&q<k||o&&q>o;P+='<td class="'+((t+h+6)%7>=5?" ui-datepicker-week-end":"")+(B?" ui-datepicker-other-month":"")+(q.getTime()==N.getTime()&&g==a.selectedMonth&&a._keyEvent||L.getTime()==q.getTime()&&L.getTime()==N.getTime()?" "+this._dayOverClass:"")+(J?" "+this._unselectableClass+" ui-state-disabled":"")+(B&&!v?"":" "+F[1]+(q.getTime()==u.getTime()?" "+this._currentClass:"")+(q.getTime()==b.getTime()?" ui-datepicker-today":
+""))+'"'+((!B||v)&&F[2]?' title="'+F[2]+'"':"")+(J?"":' onclick="DP_jQuery_'+y+".datepicker._selectDay('#"+a.id+"',"+q.getMonth()+","+q.getFullYear()+', this);return false;"')+">"+(B&&!v?"&#xa0;":J?'<span class="ui-state-default">'+q.getDate()+"</span>":'<a class="ui-state-default'+(q.getTime()==b.getTime()?" ui-state-highlight":"")+(q.getTime()==u.getTime()?" ui-state-active":"")+(B?" ui-priority-secondary":"")+'" href="#">'+q.getDate()+"</a>")+"</td>";q.setDate(q.getDate()+1);q=this._daylightSavingAdjust(q)}x+=
+P+"</tr>"}g++;if(g>11){g=0;m++}x+="</tbody></table>"+(l?"</div>"+(i[0]>0&&D==i[1]-1?'<div class="ui-datepicker-row-break"></div>':""):"");M+=x}I+=M}I+=e+(d.browser.msie&&parseInt(d.browser.version,10)<7&&!a.inline?'<iframe src="javascript:false;" class="ui-datepicker-cover" frameborder="0"></iframe>':"");a._keyEvent=false;return I},_generateMonthYearHeader:function(a,b,c,e,f,h,i,g){var j=this._get(a,"changeMonth"),l=this._get(a,"changeYear"),u=this._get(a,"showMonthAfterYear"),k='<div class="ui-datepicker-title">',
+o="";if(h||!j)o+='<span class="ui-datepicker-month">'+i[b]+"</span>";else{i=e&&e.getFullYear()==c;var m=f&&f.getFullYear()==c;o+='<select class="ui-datepicker-month" onchange="DP_jQuery_'+y+".datepicker._selectMonthYear('#"+a.id+"', this, 'M');\" onclick=\"DP_jQuery_"+y+".datepicker._clickMonthYear('#"+a.id+"');\">";for(var n=0;n<12;n++)if((!i||n>=e.getMonth())&&(!m||n<=f.getMonth()))o+='<option value="'+n+'"'+(n==b?' selected="selected"':"")+">"+g[n]+"</option>";o+="</select>"}u||(k+=o+(h||!(j&&
+l)?"&#xa0;":""));a.yearshtml="";if(h||!l)k+='<span class="ui-datepicker-year">'+c+"</span>";else{g=this._get(a,"yearRange").split(":");var r=(new Date).getFullYear();i=function(s){s=s.match(/c[+-].*/)?c+parseInt(s.substring(1),10):s.match(/[+-].*/)?r+parseInt(s,10):parseInt(s,10);return isNaN(s)?r:s};b=i(g[0]);g=Math.max(b,i(g[1]||""));b=e?Math.max(b,e.getFullYear()):b;g=f?Math.min(g,f.getFullYear()):g;for(a.yearshtml+='<select class="ui-datepicker-year" onchange="DP_jQuery_'+y+".datepicker._selectMonthYear('#"+
+a.id+"', this, 'Y');\" onclick=\"DP_jQuery_"+y+".datepicker._clickMonthYear('#"+a.id+"');\">";b<=g;b++)a.yearshtml+='<option value="'+b+'"'+(b==c?' selected="selected"':"")+">"+b+"</option>";a.yearshtml+="</select>";if(d.browser.mozilla)k+='<select class="ui-datepicker-year"><option value="'+c+'" selected="selected">'+c+"</option></select>";else{k+=a.yearshtml;a.yearshtml=null}}k+=this._get(a,"yearSuffix");if(u)k+=(h||!(j&&l)?"&#xa0;":"")+o;k+="</div>";return k},_adjustInstDate:function(a,b,c){var e=
+a.drawYear+(c=="Y"?b:0),f=a.drawMonth+(c=="M"?b:0);b=Math.min(a.selectedDay,this._getDaysInMonth(e,f))+(c=="D"?b:0);e=this._restrictMinMax(a,this._daylightSavingAdjust(new Date(e,f,b)));a.selectedDay=e.getDate();a.drawMonth=a.selectedMonth=e.getMonth();a.drawYear=a.selectedYear=e.getFullYear();if(c=="M"||c=="Y")this._notifyChange(a)},_restrictMinMax:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");b=c&&b<c?c:b;return b=a&&b>a?a:b},_notifyChange:function(a){var b=this._get(a,
+"onChangeMonthYear");if(b)b.apply(a.input?a.input[0]:null,[a.selectedYear,a.selectedMonth+1,a])},_getNumberOfMonths:function(a){a=this._get(a,"numberOfMonths");return a==null?[1,1]:typeof a=="number"?[1,a]:a},_getMinMaxDate:function(a,b){return this._determineDate(a,this._get(a,b+"Date"),null)},_getDaysInMonth:function(a,b){return 32-(new Date(a,b,32)).getDate()},_getFirstDayOfMonth:function(a,b){return(new Date(a,b,1)).getDay()},_canAdjustMonth:function(a,b,c,e){var f=this._getNumberOfMonths(a);
+c=this._daylightSavingAdjust(new Date(c,e+(b<0?b:f[0]*f[1]),1));b<0&&c.setDate(this._getDaysInMonth(c.getFullYear(),c.getMonth()));return this._isInRange(a,c)},_isInRange:function(a,b){var c=this._getMinMaxDate(a,"min");a=this._getMinMaxDate(a,"max");return(!c||b.getTime()>=c.getTime())&&(!a||b.getTime()<=a.getTime())},_getFormatConfig:function(a){var b=this._get(a,"shortYearCutoff");b=typeof b!="string"?b:(new Date).getFullYear()%100+parseInt(b,10);return{shortYearCutoff:b,dayNamesShort:this._get(a,
+"dayNamesShort"),dayNames:this._get(a,"dayNames"),monthNamesShort:this._get(a,"monthNamesShort"),monthNames:this._get(a,"monthNames")}},_formatDate:function(a,b,c,e){if(!b){a.currentDay=a.selectedDay;a.currentMonth=a.selectedMonth;a.currentYear=a.selectedYear}b=b?typeof b=="object"?b:this._daylightSavingAdjust(new Date(e,c,b)):this._daylightSavingAdjust(new Date(a.currentYear,a.currentMonth,a.currentDay));return this.formatDate(this._get(a,"dateFormat"),b,this._getFormatConfig(a))}});d.fn.datepicker=
+function(a){if(!d.datepicker.initialized){d(document).mousedown(d.datepicker._checkExternalClick).find("body").append(d.datepicker.dpDiv);d.datepicker.initialized=true}var b=Array.prototype.slice.call(arguments,1);if(typeof a=="string"&&(a=="isDisabled"||a=="getDate"||a=="widget"))return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));if(a=="option"&&arguments.length==2&&typeof arguments[1]=="string")return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));
+return this.each(function(){typeof a=="string"?d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this].concat(b)):d.datepicker._attachDatepicker(this,a)})};d.datepicker=new K;d.datepicker.initialized=false;d.datepicker.uuid=(new Date).getTime();d.datepicker.version="1.8.7";window["DP_jQuery_"+y]=d})(jQuery);
diff --git a/core/misc/ui/jquery.ui.dialog.css b/core/misc/ui/jquery.ui.dialog.css
new file mode 100644
index 000000000000..156e03acfc00
--- /dev/null
+++ b/core/misc/ui/jquery.ui.dialog.css
@@ -0,0 +1,22 @@
+
+/*
+ * jQuery UI Dialog 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Dialog#theming
+ */
+.ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; }
+.ui-dialog .ui-dialog-titlebar { padding: .5em 1em .3em; position: relative; }
+.ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .2em 0; }
+.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; }
+.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; }
+.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; }
+.ui-dialog .ui-dialog-content { position: relative; border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; }
+.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; }
+.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset { float: right; }
+.ui-dialog .ui-dialog-buttonpane button { margin: .5em .4em .5em 0; cursor: pointer; }
+.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; }
+.ui-draggable .ui-dialog-titlebar { cursor: move; }
diff --git a/core/misc/ui/jquery.ui.dialog.min.js b/core/misc/ui/jquery.ui.dialog.min.js
new file mode 100644
index 000000000000..d60151c20382
--- /dev/null
+++ b/core/misc/ui/jquery.ui.dialog.min.js
@@ -0,0 +1,41 @@
+
+/*
+ * jQuery UI Dialog 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Dialog
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ * jquery.ui.button.js
+ * jquery.ui.draggable.js
+ * jquery.ui.mouse.js
+ * jquery.ui.position.js
+ * jquery.ui.resizable.js
+ */
+(function(c,j){var k={buttons:true,height:true,maxHeight:true,maxWidth:true,minHeight:true,minWidth:true,width:true},l={maxHeight:true,maxWidth:true,minHeight:true,minWidth:true};c.widget("ui.dialog",{options:{autoOpen:true,buttons:{},closeOnEscape:true,closeText:"close",dialogClass:"",draggable:true,hide:null,height:"auto",maxHeight:false,maxWidth:false,minHeight:150,minWidth:150,modal:false,position:{my:"center",at:"center",collision:"fit",using:function(a){var b=c(this).css(a).offset().top;b<0&&
+c(this).css("top",a.top-b)}},resizable:true,show:null,stack:true,title:"",width:300,zIndex:1E3},_create:function(){this.originalTitle=this.element.attr("title");if(typeof this.originalTitle!=="string")this.originalTitle="";this.options.title=this.options.title||this.originalTitle;var a=this,b=a.options,d=b.title||"&#160;",e=c.ui.dialog.getTitleId(a.element),g=(a.uiDialog=c("<div></div>")).appendTo(document.body).hide().addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+b.dialogClass).css({zIndex:b.zIndex}).attr("tabIndex",
+-1).css("outline",0).keydown(function(i){if(b.closeOnEscape&&i.keyCode&&i.keyCode===c.ui.keyCode.ESCAPE){a.close(i);i.preventDefault()}}).attr({role:"dialog","aria-labelledby":e}).mousedown(function(i){a.moveToTop(false,i)});a.element.show().removeAttr("title").addClass("ui-dialog-content ui-widget-content").appendTo(g);var f=(a.uiDialogTitlebar=c("<div></div>")).addClass("ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix").prependTo(g),h=c('<a href="#"></a>').addClass("ui-dialog-titlebar-close ui-corner-all").attr("role",
+"button").hover(function(){h.addClass("ui-state-hover")},function(){h.removeClass("ui-state-hover")}).focus(function(){h.addClass("ui-state-focus")}).blur(function(){h.removeClass("ui-state-focus")}).click(function(i){a.close(i);return false}).appendTo(f);(a.uiDialogTitlebarCloseText=c("<span></span>")).addClass("ui-icon ui-icon-closethick").text(b.closeText).appendTo(h);c("<span></span>").addClass("ui-dialog-title").attr("id",e).html(d).prependTo(f);if(c.isFunction(b.beforeclose)&&!c.isFunction(b.beforeClose))b.beforeClose=
+b.beforeclose;f.find("*").add(f).disableSelection();b.draggable&&c.fn.draggable&&a._makeDraggable();b.resizable&&c.fn.resizable&&a._makeResizable();a._createButtons(b.buttons);a._isOpen=false;c.fn.bgiframe&&g.bgiframe()},_init:function(){this.options.autoOpen&&this.open()},destroy:function(){var a=this;a.overlay&&a.overlay.destroy();a.uiDialog.hide();a.element.unbind(".dialog").removeData("dialog").removeClass("ui-dialog-content ui-widget-content").hide().appendTo("body");a.uiDialog.remove();a.originalTitle&&
+a.element.attr("title",a.originalTitle);return a},widget:function(){return this.uiDialog},close:function(a){var b=this,d,e;if(false!==b._trigger("beforeClose",a)){b.overlay&&b.overlay.destroy();b.uiDialog.unbind("keypress.ui-dialog");b._isOpen=false;if(b.options.hide)b.uiDialog.hide(b.options.hide,function(){b._trigger("close",a)});else{b.uiDialog.hide();b._trigger("close",a)}c.ui.dialog.overlay.resize();if(b.options.modal){d=0;c(".ui-dialog").each(function(){if(this!==b.uiDialog[0]){e=c(this).css("z-index");
+isNaN(e)||(d=Math.max(d,e))}});c.ui.dialog.maxZ=d}return b}},isOpen:function(){return this._isOpen},moveToTop:function(a,b){var d=this,e=d.options;if(e.modal&&!a||!e.stack&&!e.modal)return d._trigger("focus",b);if(e.zIndex>c.ui.dialog.maxZ)c.ui.dialog.maxZ=e.zIndex;if(d.overlay){c.ui.dialog.maxZ+=1;d.overlay.$el.css("z-index",c.ui.dialog.overlay.maxZ=c.ui.dialog.maxZ)}a={scrollTop:d.element.attr("scrollTop"),scrollLeft:d.element.attr("scrollLeft")};c.ui.dialog.maxZ+=1;d.uiDialog.css("z-index",c.ui.dialog.maxZ);
+d.element.attr(a);d._trigger("focus",b);return d},open:function(){if(!this._isOpen){var a=this,b=a.options,d=a.uiDialog;a.overlay=b.modal?new c.ui.dialog.overlay(a):null;a._size();a._position(b.position);d.show(b.show);a.moveToTop(true);b.modal&&d.bind("keypress.ui-dialog",function(e){if(e.keyCode===c.ui.keyCode.TAB){var g=c(":tabbable",this),f=g.filter(":first");g=g.filter(":last");if(e.target===g[0]&&!e.shiftKey){f.focus(1);return false}else if(e.target===f[0]&&e.shiftKey){g.focus(1);return false}}});
+c(a.element.find(":tabbable").get().concat(d.find(".ui-dialog-buttonpane :tabbable").get().concat(d.get()))).eq(0).focus();a._isOpen=true;a._trigger("open");return a}},_createButtons:function(a){var b=this,d=false,e=c("<div></div>").addClass("ui-dialog-buttonpane ui-widget-content ui-helper-clearfix"),g=c("<div></div>").addClass("ui-dialog-buttonset").appendTo(e);b.uiDialog.find(".ui-dialog-buttonpane").remove();typeof a==="object"&&a!==null&&c.each(a,function(){return!(d=true)});if(d){c.each(a,function(f,
+h){h=c.isFunction(h)?{click:h,text:f}:h;f=c('<button type="button"></button>').attr(h,true).unbind("click").click(function(){h.click.apply(b.element[0],arguments)}).appendTo(g);c.fn.button&&f.button()});e.appendTo(b.uiDialog)}},_makeDraggable:function(){function a(f){return{position:f.position,offset:f.offset}}var b=this,d=b.options,e=c(document),g;b.uiDialog.draggable({cancel:".ui-dialog-content, .ui-dialog-titlebar-close",handle:".ui-dialog-titlebar",containment:"document",start:function(f,h){g=
+d.height==="auto"?"auto":c(this).height();c(this).height(c(this).height()).addClass("ui-dialog-dragging");b._trigger("dragStart",f,a(h))},drag:function(f,h){b._trigger("drag",f,a(h))},stop:function(f,h){d.position=[h.position.left-e.scrollLeft(),h.position.top-e.scrollTop()];c(this).removeClass("ui-dialog-dragging").height(g);b._trigger("dragStop",f,a(h));c.ui.dialog.overlay.resize()}})},_makeResizable:function(a){function b(f){return{originalPosition:f.originalPosition,originalSize:f.originalSize,
+position:f.position,size:f.size}}a=a===j?this.options.resizable:a;var d=this,e=d.options,g=d.uiDialog.css("position");a=typeof a==="string"?a:"n,e,s,w,se,sw,ne,nw";d.uiDialog.resizable({cancel:".ui-dialog-content",containment:"document",alsoResize:d.element,maxWidth:e.maxWidth,maxHeight:e.maxHeight,minWidth:e.minWidth,minHeight:d._minHeight(),handles:a,start:function(f,h){c(this).addClass("ui-dialog-resizing");d._trigger("resizeStart",f,b(h))},resize:function(f,h){d._trigger("resize",f,b(h))},stop:function(f,
+h){c(this).removeClass("ui-dialog-resizing");e.height=c(this).height();e.width=c(this).width();d._trigger("resizeStop",f,b(h));c.ui.dialog.overlay.resize()}}).css("position",g).find(".ui-resizable-se").addClass("ui-icon ui-icon-grip-diagonal-se")},_minHeight:function(){var a=this.options;return a.height==="auto"?a.minHeight:Math.min(a.minHeight,a.height)},_position:function(a){var b=[],d=[0,0],e;if(a){if(typeof a==="string"||typeof a==="object"&&"0"in a){b=a.split?a.split(" "):[a[0],a[1]];if(b.length===
+1)b[1]=b[0];c.each(["left","top"],function(g,f){if(+b[g]===b[g]){d[g]=b[g];b[g]=f}});a={my:b.join(" "),at:b.join(" "),offset:d.join(" ")}}a=c.extend({},c.ui.dialog.prototype.options.position,a)}else a=c.ui.dialog.prototype.options.position;(e=this.uiDialog.is(":visible"))||this.uiDialog.show();this.uiDialog.css({top:0,left:0}).position(c.extend({of:window},a));e||this.uiDialog.hide()},_setOptions:function(a){var b=this,d={},e=false;c.each(a,function(g,f){b._setOption(g,f);if(g in k)e=true;if(g in
+l)d[g]=f});e&&this._size();this.uiDialog.is(":data(resizable)")&&this.uiDialog.resizable("option",d)},_setOption:function(a,b){var d=this,e=d.uiDialog;switch(a){case "beforeclose":a="beforeClose";break;case "buttons":d._createButtons(b);break;case "closeText":d.uiDialogTitlebarCloseText.text(""+b);break;case "dialogClass":e.removeClass(d.options.dialogClass).addClass("ui-dialog ui-widget ui-widget-content ui-corner-all "+b);break;case "disabled":b?e.addClass("ui-dialog-disabled"):e.removeClass("ui-dialog-disabled");
+break;case "draggable":var g=e.is(":data(draggable)");g&&!b&&e.draggable("destroy");!g&&b&&d._makeDraggable();break;case "position":d._position(b);break;case "resizable":(g=e.is(":data(resizable)"))&&!b&&e.resizable("destroy");g&&typeof b==="string"&&e.resizable("option","handles",b);!g&&b!==false&&d._makeResizable(b);break;case "title":c(".ui-dialog-title",d.uiDialogTitlebar).html(""+(b||"&#160;"));break}c.Widget.prototype._setOption.apply(d,arguments)},_size:function(){var a=this.options,b,d,e=
+this.uiDialog.is(":visible");this.element.show().css({width:"auto",minHeight:0,height:0});if(a.minWidth>a.width)a.width=a.minWidth;b=this.uiDialog.css({height:"auto",width:a.width}).height();d=Math.max(0,a.minHeight-b);if(a.height==="auto")if(c.support.minHeight)this.element.css({minHeight:d,height:"auto"});else{this.uiDialog.show();a=this.element.css("height","auto").height();e||this.uiDialog.hide();this.element.height(Math.max(a,d))}else this.element.height(Math.max(a.height-b,0));this.uiDialog.is(":data(resizable)")&&
+this.uiDialog.resizable("option","minHeight",this._minHeight())}});c.extend(c.ui.dialog,{version:"1.8.7",uuid:0,maxZ:0,getTitleId:function(a){a=a.attr("id");if(!a){this.uuid+=1;a=this.uuid}return"ui-dialog-title-"+a},overlay:function(a){this.$el=c.ui.dialog.overlay.create(a)}});c.extend(c.ui.dialog.overlay,{instances:[],oldInstances:[],maxZ:0,events:c.map("focus,mousedown,mouseup,keydown,keypress,click".split(","),function(a){return a+".dialog-overlay"}).join(" "),create:function(a){if(this.instances.length===
+0){setTimeout(function(){c.ui.dialog.overlay.instances.length&&c(document).bind(c.ui.dialog.overlay.events,function(d){if(c(d.target).zIndex()<c.ui.dialog.overlay.maxZ)return false})},1);c(document).bind("keydown.dialog-overlay",function(d){if(a.options.closeOnEscape&&d.keyCode&&d.keyCode===c.ui.keyCode.ESCAPE){a.close(d);d.preventDefault()}});c(window).bind("resize.dialog-overlay",c.ui.dialog.overlay.resize)}var b=(this.oldInstances.pop()||c("<div></div>").addClass("ui-widget-overlay")).appendTo(document.body).css({width:this.width(),
+height:this.height()});c.fn.bgiframe&&b.bgiframe();this.instances.push(b);return b},destroy:function(a){var b=c.inArray(a,this.instances);b!=-1&&this.oldInstances.push(this.instances.splice(b,1)[0]);this.instances.length===0&&c([document,window]).unbind(".dialog-overlay");a.remove();var d=0;c.each(this.instances,function(){d=Math.max(d,this.css("z-index"))});this.maxZ=d},height:function(){var a,b;if(c.browser.msie&&c.browser.version<7){a=Math.max(document.documentElement.scrollHeight,document.body.scrollHeight);
+b=Math.max(document.documentElement.offsetHeight,document.body.offsetHeight);return a<b?c(window).height()+"px":a+"px"}else return c(document).height()+"px"},width:function(){var a,b;if(c.browser.msie&&c.browser.version<7){a=Math.max(document.documentElement.scrollWidth,document.body.scrollWidth);b=Math.max(document.documentElement.offsetWidth,document.body.offsetWidth);return a<b?c(window).width()+"px":a+"px"}else return c(document).width()+"px"},resize:function(){var a=c([]);c.each(c.ui.dialog.overlay.instances,
+function(){a=a.add(this)});a.css({width:0,height:0}).css({width:c.ui.dialog.overlay.width(),height:c.ui.dialog.overlay.height()})}});c.extend(c.ui.dialog.overlay.prototype,{destroy:function(){c.ui.dialog.overlay.destroy(this.$el)}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.draggable.min.js b/core/misc/ui/jquery.ui.draggable.min.js
new file mode 100644
index 000000000000..59a74182577d
--- /dev/null
+++ b/core/misc/ui/jquery.ui.draggable.min.js
@@ -0,0 +1,51 @@
+
+/*
+ * jQuery UI Draggable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Draggables
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.mouse.js
+ * jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.draggable",d.ui.mouse,{widgetEventPrefix:"drag",options:{addClasses:true,appendTo:"parent",axis:false,connectToSortable:false,containment:false,cursor:"auto",cursorAt:false,grid:false,handle:false,helper:"original",iframeFix:false,opacity:false,refreshPositions:false,revert:false,revertDuration:500,scope:"default",scroll:true,scrollSensitivity:20,scrollSpeed:20,snap:false,snapMode:"both",snapTolerance:20,stack:false,zIndex:false},_create:function(){if(this.options.helper==
+"original"&&!/^(?:r|a|f)/.test(this.element.css("position")))this.element[0].style.position="relative";this.options.addClasses&&this.element.addClass("ui-draggable");this.options.disabled&&this.element.addClass("ui-draggable-disabled");this._mouseInit()},destroy:function(){if(this.element.data("draggable")){this.element.removeData("draggable").unbind(".draggable").removeClass("ui-draggable ui-draggable-dragging ui-draggable-disabled");this._mouseDestroy();return this}},_mouseCapture:function(a){var b=
+this.options;if(this.helper||b.disabled||d(a.target).is(".ui-resizable-handle"))return false;this.handle=this._getHandle(a);if(!this.handle)return false;return true},_mouseStart:function(a){var b=this.options;this.helper=this._createHelper(a);this._cacheHelperProportions();if(d.ui.ddmanager)d.ui.ddmanager.current=this;this._cacheMargins();this.cssPosition=this.helper.css("position");this.scrollParent=this.helper.scrollParent();this.offset=this.positionAbs=this.element.offset();this.offset={top:this.offset.top-
+this.margins.top,left:this.offset.left-this.margins.left};d.extend(this.offset,{click:{left:a.pageX-this.offset.left,top:a.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()});this.originalPosition=this.position=this._generatePosition(a);this.originalPageX=a.pageX;this.originalPageY=a.pageY;b.cursorAt&&this._adjustOffsetFromHelper(b.cursorAt);b.containment&&this._setContainment();if(this._trigger("start",a)===false){this._clear();return false}this._cacheHelperProportions();
+d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a);this.helper.addClass("ui-draggable-dragging");this._mouseDrag(a,true);return true},_mouseDrag:function(a,b){this.position=this._generatePosition(a);this.positionAbs=this._convertPositionTo("absolute");if(!b){b=this._uiHash();if(this._trigger("drag",a,b)===false){this._mouseUp({});return false}this.position=b.position}if(!this.options.axis||this.options.axis!="y")this.helper[0].style.left=this.position.left+"px";if(!this.options.axis||
+this.options.axis!="x")this.helper[0].style.top=this.position.top+"px";d.ui.ddmanager&&d.ui.ddmanager.drag(this,a);return false},_mouseStop:function(a){var b=false;if(d.ui.ddmanager&&!this.options.dropBehaviour)b=d.ui.ddmanager.drop(this,a);if(this.dropped){b=this.dropped;this.dropped=false}if(!this.element[0]||!this.element[0].parentNode)return false;if(this.options.revert=="invalid"&&!b||this.options.revert=="valid"&&b||this.options.revert===true||d.isFunction(this.options.revert)&&this.options.revert.call(this.element,
+b)){var c=this;d(this.helper).animate(this.originalPosition,parseInt(this.options.revertDuration,10),function(){c._trigger("stop",a)!==false&&c._clear()})}else this._trigger("stop",a)!==false&&this._clear();return false},cancel:function(){this.helper.is(".ui-draggable-dragging")?this._mouseUp({}):this._clear();return this},_getHandle:function(a){var b=!this.options.handle||!d(this.options.handle,this.element).length?true:false;d(this.options.handle,this.element).find("*").andSelf().each(function(){if(this==
+a.target)b=true});return b},_createHelper:function(a){var b=this.options;a=d.isFunction(b.helper)?d(b.helper.apply(this.element[0],[a])):b.helper=="clone"?this.element.clone():this.element;a.parents("body").length||a.appendTo(b.appendTo=="parent"?this.element[0].parentNode:b.appendTo);a[0]!=this.element[0]&&!/(fixed|absolute)/.test(a.css("position"))&&a.css("position","absolute");return a},_adjustOffsetFromHelper:function(a){if(typeof a=="string")a=a.split(" ");if(d.isArray(a))a={left:+a[0],top:+a[1]||
+0};if("left"in a)this.offset.click.left=a.left+this.margins.left;if("right"in a)this.offset.click.left=this.helperProportions.width-a.right+this.margins.left;if("top"in a)this.offset.click.top=a.top+this.margins.top;if("bottom"in a)this.offset.click.top=this.helperProportions.height-a.bottom+this.margins.top},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var a=this.offsetParent.offset();if(this.cssPosition=="absolute"&&this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],
+this.offsetParent[0])){a.left+=this.scrollParent.scrollLeft();a.top+=this.scrollParent.scrollTop()}if(this.offsetParent[0]==document.body||this.offsetParent[0].tagName&&this.offsetParent[0].tagName.toLowerCase()=="html"&&d.browser.msie)a={top:0,left:0};return{top:a.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:a.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if(this.cssPosition=="relative"){var a=this.element.position();return{top:a.top-
+(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:a.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}else return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.element.css("marginLeft"),10)||0,top:parseInt(this.element.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},_setContainment:function(){var a=this.options;if(a.containment==
+"parent")a.containment=this.helper[0].parentNode;if(a.containment=="document"||a.containment=="window")this.containment=[(a.containment=="document"?0:d(window).scrollLeft())-this.offset.relative.left-this.offset.parent.left,(a.containment=="document"?0:d(window).scrollTop())-this.offset.relative.top-this.offset.parent.top,(a.containment=="document"?0:d(window).scrollLeft())+d(a.containment=="document"?document:window).width()-this.helperProportions.width-this.margins.left,(a.containment=="document"?
+0:d(window).scrollTop())+(d(a.containment=="document"?document:window).height()||document.body.parentNode.scrollHeight)-this.helperProportions.height-this.margins.top];if(!/^(document|window|parent)$/.test(a.containment)&&a.containment.constructor!=Array){var b=d(a.containment)[0];if(b){a=d(a.containment).offset();var c=d(b).css("overflow")!="hidden";this.containment=[a.left+(parseInt(d(b).css("borderLeftWidth"),10)||0)+(parseInt(d(b).css("paddingLeft"),10)||0)-this.margins.left,a.top+(parseInt(d(b).css("borderTopWidth"),
+10)||0)+(parseInt(d(b).css("paddingTop"),10)||0)-this.margins.top,a.left+(c?Math.max(b.scrollWidth,b.offsetWidth):b.offsetWidth)-(parseInt(d(b).css("borderLeftWidth"),10)||0)-(parseInt(d(b).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,a.top+(c?Math.max(b.scrollHeight,b.offsetHeight):b.offsetHeight)-(parseInt(d(b).css("borderTopWidth"),10)||0)-(parseInt(d(b).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]}}else if(a.containment.constructor==
+Array)this.containment=a.containment},_convertPositionTo:function(a,b){if(!b)b=this.position;a=a=="absolute"?1:-1;var c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName);return{top:b.top+this.offset.relative.top*a+this.offset.parent.top*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollTop():
+f?0:c.scrollTop())*a),left:b.left+this.offset.relative.left*a+this.offset.parent.left*a-(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():f?0:c.scrollLeft())*a)}},_generatePosition:function(a){var b=this.options,c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,f=/(html|body)/i.test(c[0].tagName),e=a.pageX,g=a.pageY;
+if(this.originalPosition){if(this.containment){if(a.pageX-this.offset.click.left<this.containment[0])e=this.containment[0]+this.offset.click.left;if(a.pageY-this.offset.click.top<this.containment[1])g=this.containment[1]+this.offset.click.top;if(a.pageX-this.offset.click.left>this.containment[2])e=this.containment[2]+this.offset.click.left;if(a.pageY-this.offset.click.top>this.containment[3])g=this.containment[3]+this.offset.click.top}if(b.grid){g=this.originalPageY+Math.round((g-this.originalPageY)/
+b.grid[1])*b.grid[1];g=this.containment?!(g-this.offset.click.top<this.containment[1]||g-this.offset.click.top>this.containment[3])?g:!(g-this.offset.click.top<this.containment[1])?g-b.grid[1]:g+b.grid[1]:g;e=this.originalPageX+Math.round((e-this.originalPageX)/b.grid[0])*b.grid[0];e=this.containment?!(e-this.offset.click.left<this.containment[0]||e-this.offset.click.left>this.containment[2])?e:!(e-this.offset.click.left<this.containment[0])?e-b.grid[0]:e+b.grid[0]:e}}return{top:g-this.offset.click.top-
+this.offset.relative.top-this.offset.parent.top+(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollTop():f?0:c.scrollTop()),left:e-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+(d.browser.safari&&d.browser.version<526&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():f?0:c.scrollLeft())}},_clear:function(){this.helper.removeClass("ui-draggable-dragging");this.helper[0]!=
+this.element[0]&&!this.cancelHelperRemoval&&this.helper.remove();this.helper=null;this.cancelHelperRemoval=false},_trigger:function(a,b,c){c=c||this._uiHash();d.ui.plugin.call(this,a,[b,c]);if(a=="drag")this.positionAbs=this._convertPositionTo("absolute");return d.Widget.prototype._trigger.call(this,a,b,c)},plugins:{},_uiHash:function(){return{helper:this.helper,position:this.position,originalPosition:this.originalPosition,offset:this.positionAbs}}});d.extend(d.ui.draggable,{version:"1.8.7"});
+d.ui.plugin.add("draggable","connectToSortable",{start:function(a,b){var c=d(this).data("draggable"),f=c.options,e=d.extend({},b,{item:c.element});c.sortables=[];d(f.connectToSortable).each(function(){var g=d.data(this,"sortable");if(g&&!g.options.disabled){c.sortables.push({instance:g,shouldRevert:g.options.revert});g._refreshItems();g._trigger("activate",a,e)}})},stop:function(a,b){var c=d(this).data("draggable"),f=d.extend({},b,{item:c.element});d.each(c.sortables,function(){if(this.instance.isOver){this.instance.isOver=
+0;c.cancelHelperRemoval=true;this.instance.cancelHelperRemoval=false;if(this.shouldRevert)this.instance.options.revert=true;this.instance._mouseStop(a);this.instance.options.helper=this.instance.options._helper;c.options.helper=="original"&&this.instance.currentItem.css({top:"auto",left:"auto"})}else{this.instance.cancelHelperRemoval=false;this.instance._trigger("deactivate",a,f)}})},drag:function(a,b){var c=d(this).data("draggable"),f=this;d.each(c.sortables,function(){this.instance.positionAbs=
+c.positionAbs;this.instance.helperProportions=c.helperProportions;this.instance.offset.click=c.offset.click;if(this.instance._intersectsWith(this.instance.containerCache)){if(!this.instance.isOver){this.instance.isOver=1;this.instance.currentItem=d(f).clone().appendTo(this.instance.element).data("sortable-item",true);this.instance.options._helper=this.instance.options.helper;this.instance.options.helper=function(){return b.helper[0]};a.target=this.instance.currentItem[0];this.instance._mouseCapture(a,
+true);this.instance._mouseStart(a,true,true);this.instance.offset.click.top=c.offset.click.top;this.instance.offset.click.left=c.offset.click.left;this.instance.offset.parent.left-=c.offset.parent.left-this.instance.offset.parent.left;this.instance.offset.parent.top-=c.offset.parent.top-this.instance.offset.parent.top;c._trigger("toSortable",a);c.dropped=this.instance.element;c.currentItem=c.element;this.instance.fromOutside=c}this.instance.currentItem&&this.instance._mouseDrag(a)}else if(this.instance.isOver){this.instance.isOver=
+0;this.instance.cancelHelperRemoval=true;this.instance.options.revert=false;this.instance._trigger("out",a,this.instance._uiHash(this.instance));this.instance._mouseStop(a,true);this.instance.options.helper=this.instance.options._helper;this.instance.currentItem.remove();this.instance.placeholder&&this.instance.placeholder.remove();c._trigger("fromSortable",a);c.dropped=false}})}});d.ui.plugin.add("draggable","cursor",{start:function(){var a=d("body"),b=d(this).data("draggable").options;if(a.css("cursor"))b._cursor=
+a.css("cursor");a.css("cursor",b.cursor)},stop:function(){var a=d(this).data("draggable").options;a._cursor&&d("body").css("cursor",a._cursor)}});d.ui.plugin.add("draggable","iframeFix",{start:function(){var a=d(this).data("draggable").options;d(a.iframeFix===true?"iframe":a.iframeFix).each(function(){d('<div class="ui-draggable-iframeFix" style="background: #fff;"></div>').css({width:this.offsetWidth+"px",height:this.offsetHeight+"px",position:"absolute",opacity:"0.001",zIndex:1E3}).css(d(this).offset()).appendTo("body")})},
+stop:function(){d("div.ui-draggable-iframeFix").each(function(){this.parentNode.removeChild(this)})}});d.ui.plugin.add("draggable","opacity",{start:function(a,b){a=d(b.helper);b=d(this).data("draggable").options;if(a.css("opacity"))b._opacity=a.css("opacity");a.css("opacity",b.opacity)},stop:function(a,b){a=d(this).data("draggable").options;a._opacity&&d(b.helper).css("opacity",a._opacity)}});d.ui.plugin.add("draggable","scroll",{start:function(){var a=d(this).data("draggable");if(a.scrollParent[0]!=
+document&&a.scrollParent[0].tagName!="HTML")a.overflowOffset=a.scrollParent.offset()},drag:function(a){var b=d(this).data("draggable"),c=b.options,f=false;if(b.scrollParent[0]!=document&&b.scrollParent[0].tagName!="HTML"){if(!c.axis||c.axis!="x")if(b.overflowOffset.top+b.scrollParent[0].offsetHeight-a.pageY<c.scrollSensitivity)b.scrollParent[0].scrollTop=f=b.scrollParent[0].scrollTop+c.scrollSpeed;else if(a.pageY-b.overflowOffset.top<c.scrollSensitivity)b.scrollParent[0].scrollTop=f=b.scrollParent[0].scrollTop-
+c.scrollSpeed;if(!c.axis||c.axis!="y")if(b.overflowOffset.left+b.scrollParent[0].offsetWidth-a.pageX<c.scrollSensitivity)b.scrollParent[0].scrollLeft=f=b.scrollParent[0].scrollLeft+c.scrollSpeed;else if(a.pageX-b.overflowOffset.left<c.scrollSensitivity)b.scrollParent[0].scrollLeft=f=b.scrollParent[0].scrollLeft-c.scrollSpeed}else{if(!c.axis||c.axis!="x")if(a.pageY-d(document).scrollTop()<c.scrollSensitivity)f=d(document).scrollTop(d(document).scrollTop()-c.scrollSpeed);else if(d(window).height()-
+(a.pageY-d(document).scrollTop())<c.scrollSensitivity)f=d(document).scrollTop(d(document).scrollTop()+c.scrollSpeed);if(!c.axis||c.axis!="y")if(a.pageX-d(document).scrollLeft()<c.scrollSensitivity)f=d(document).scrollLeft(d(document).scrollLeft()-c.scrollSpeed);else if(d(window).width()-(a.pageX-d(document).scrollLeft())<c.scrollSensitivity)f=d(document).scrollLeft(d(document).scrollLeft()+c.scrollSpeed)}f!==false&&d.ui.ddmanager&&!c.dropBehaviour&&d.ui.ddmanager.prepareOffsets(b,a)}});d.ui.plugin.add("draggable",
+"snap",{start:function(){var a=d(this).data("draggable"),b=a.options;a.snapElements=[];d(b.snap.constructor!=String?b.snap.items||":data(draggable)":b.snap).each(function(){var c=d(this),f=c.offset();this!=a.element[0]&&a.snapElements.push({item:this,width:c.outerWidth(),height:c.outerHeight(),top:f.top,left:f.left})})},drag:function(a,b){for(var c=d(this).data("draggable"),f=c.options,e=f.snapTolerance,g=b.offset.left,n=g+c.helperProportions.width,m=b.offset.top,o=m+c.helperProportions.height,h=
+c.snapElements.length-1;h>=0;h--){var i=c.snapElements[h].left,k=i+c.snapElements[h].width,j=c.snapElements[h].top,l=j+c.snapElements[h].height;if(i-e<g&&g<k+e&&j-e<m&&m<l+e||i-e<g&&g<k+e&&j-e<o&&o<l+e||i-e<n&&n<k+e&&j-e<m&&m<l+e||i-e<n&&n<k+e&&j-e<o&&o<l+e){if(f.snapMode!="inner"){var p=Math.abs(j-o)<=e,q=Math.abs(l-m)<=e,r=Math.abs(i-n)<=e,s=Math.abs(k-g)<=e;if(p)b.position.top=c._convertPositionTo("relative",{top:j-c.helperProportions.height,left:0}).top-c.margins.top;if(q)b.position.top=c._convertPositionTo("relative",
+{top:l,left:0}).top-c.margins.top;if(r)b.position.left=c._convertPositionTo("relative",{top:0,left:i-c.helperProportions.width}).left-c.margins.left;if(s)b.position.left=c._convertPositionTo("relative",{top:0,left:k}).left-c.margins.left}var t=p||q||r||s;if(f.snapMode!="outer"){p=Math.abs(j-m)<=e;q=Math.abs(l-o)<=e;r=Math.abs(i-g)<=e;s=Math.abs(k-n)<=e;if(p)b.position.top=c._convertPositionTo("relative",{top:j,left:0}).top-c.margins.top;if(q)b.position.top=c._convertPositionTo("relative",{top:l-c.helperProportions.height,
+left:0}).top-c.margins.top;if(r)b.position.left=c._convertPositionTo("relative",{top:0,left:i}).left-c.margins.left;if(s)b.position.left=c._convertPositionTo("relative",{top:0,left:k-c.helperProportions.width}).left-c.margins.left}if(!c.snapElements[h].snapping&&(p||q||r||s||t))c.options.snap.snap&&c.options.snap.snap.call(c.element,a,d.extend(c._uiHash(),{snapItem:c.snapElements[h].item}));c.snapElements[h].snapping=p||q||r||s||t}else{c.snapElements[h].snapping&&c.options.snap.release&&c.options.snap.release.call(c.element,
+a,d.extend(c._uiHash(),{snapItem:c.snapElements[h].item}));c.snapElements[h].snapping=false}}}});d.ui.plugin.add("draggable","stack",{start:function(){var a=d(this).data("draggable").options;a=d.makeArray(d(a.stack)).sort(function(c,f){return(parseInt(d(c).css("zIndex"),10)||0)-(parseInt(d(f).css("zIndex"),10)||0)});if(a.length){var b=parseInt(a[0].style.zIndex)||0;d(a).each(function(c){this.style.zIndex=b+c});this[0].style.zIndex=b+a.length}}});d.ui.plugin.add("draggable","zIndex",{start:function(a,
+b){a=d(b.helper);b=d(this).data("draggable").options;if(a.css("zIndex"))b._zIndex=a.css("zIndex");a.css("zIndex",b.zIndex)},stop:function(a,b){a=d(this).data("draggable").options;a._zIndex&&d(b.helper).css("zIndex",a._zIndex)}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.droppable.min.js b/core/misc/ui/jquery.ui.droppable.min.js
new file mode 100644
index 000000000000..12efd10bc4e7
--- /dev/null
+++ b/core/misc/ui/jquery.ui.droppable.min.js
@@ -0,0 +1,27 @@
+
+/*
+ * jQuery UI Droppable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Droppables
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ * jquery.ui.mouse.js
+ * jquery.ui.draggable.js
+ */
+(function(d){d.widget("ui.droppable",{widgetEventPrefix:"drop",options:{accept:"*",activeClass:false,addClasses:true,greedy:false,hoverClass:false,scope:"default",tolerance:"intersect"},_create:function(){var a=this.options,b=a.accept;this.isover=0;this.isout=1;this.accept=d.isFunction(b)?b:function(c){return c.is(b)};this.proportions={width:this.element[0].offsetWidth,height:this.element[0].offsetHeight};d.ui.ddmanager.droppables[a.scope]=d.ui.ddmanager.droppables[a.scope]||[];d.ui.ddmanager.droppables[a.scope].push(this);
+a.addClasses&&this.element.addClass("ui-droppable")},destroy:function(){for(var a=d.ui.ddmanager.droppables[this.options.scope],b=0;b<a.length;b++)a[b]==this&&a.splice(b,1);this.element.removeClass("ui-droppable ui-droppable-disabled").removeData("droppable").unbind(".droppable");return this},_setOption:function(a,b){if(a=="accept")this.accept=d.isFunction(b)?b:function(c){return c.is(b)};d.Widget.prototype._setOption.apply(this,arguments)},_activate:function(a){var b=d.ui.ddmanager.current;this.options.activeClass&&
+this.element.addClass(this.options.activeClass);b&&this._trigger("activate",a,this.ui(b))},_deactivate:function(a){var b=d.ui.ddmanager.current;this.options.activeClass&&this.element.removeClass(this.options.activeClass);b&&this._trigger("deactivate",a,this.ui(b))},_over:function(a){var b=d.ui.ddmanager.current;if(!(!b||(b.currentItem||b.element)[0]==this.element[0]))if(this.accept.call(this.element[0],b.currentItem||b.element)){this.options.hoverClass&&this.element.addClass(this.options.hoverClass);
+this._trigger("over",a,this.ui(b))}},_out:function(a){var b=d.ui.ddmanager.current;if(!(!b||(b.currentItem||b.element)[0]==this.element[0]))if(this.accept.call(this.element[0],b.currentItem||b.element)){this.options.hoverClass&&this.element.removeClass(this.options.hoverClass);this._trigger("out",a,this.ui(b))}},_drop:function(a,b){var c=b||d.ui.ddmanager.current;if(!c||(c.currentItem||c.element)[0]==this.element[0])return false;var e=false;this.element.find(":data(droppable)").not(".ui-draggable-dragging").each(function(){var g=
+d.data(this,"droppable");if(g.options.greedy&&!g.options.disabled&&g.options.scope==c.options.scope&&g.accept.call(g.element[0],c.currentItem||c.element)&&d.ui.intersect(c,d.extend(g,{offset:g.element.offset()}),g.options.tolerance)){e=true;return false}});if(e)return false;if(this.accept.call(this.element[0],c.currentItem||c.element)){this.options.activeClass&&this.element.removeClass(this.options.activeClass);this.options.hoverClass&&this.element.removeClass(this.options.hoverClass);this._trigger("drop",
+a,this.ui(c));return this.element}return false},ui:function(a){return{draggable:a.currentItem||a.element,helper:a.helper,position:a.position,offset:a.positionAbs}}});d.extend(d.ui.droppable,{version:"1.8.7"});d.ui.intersect=function(a,b,c){if(!b.offset)return false;var e=(a.positionAbs||a.position.absolute).left,g=e+a.helperProportions.width,f=(a.positionAbs||a.position.absolute).top,h=f+a.helperProportions.height,i=b.offset.left,k=i+b.proportions.width,j=b.offset.top,l=j+b.proportions.height;
+switch(c){case "fit":return i<=e&&g<=k&&j<=f&&h<=l;case "intersect":return i<e+a.helperProportions.width/2&&g-a.helperProportions.width/2<k&&j<f+a.helperProportions.height/2&&h-a.helperProportions.height/2<l;case "pointer":return d.ui.isOver((a.positionAbs||a.position.absolute).top+(a.clickOffset||a.offset.click).top,(a.positionAbs||a.position.absolute).left+(a.clickOffset||a.offset.click).left,j,i,b.proportions.height,b.proportions.width);case "touch":return(f>=j&&f<=l||h>=j&&h<=l||f<j&&h>l)&&(e>=
+i&&e<=k||g>=i&&g<=k||e<i&&g>k);default:return false}};d.ui.ddmanager={current:null,droppables:{"default":[]},prepareOffsets:function(a,b){var c=d.ui.ddmanager.droppables[a.options.scope]||[],e=b?b.type:null,g=(a.currentItem||a.element).find(":data(droppable)").andSelf(),f=0;a:for(;f<c.length;f++)if(!(c[f].options.disabled||a&&!c[f].accept.call(c[f].element[0],a.currentItem||a.element))){for(var h=0;h<g.length;h++)if(g[h]==c[f].element[0]){c[f].proportions.height=0;continue a}c[f].visible=c[f].element.css("display")!=
+"none";if(c[f].visible){c[f].offset=c[f].element.offset();c[f].proportions={width:c[f].element[0].offsetWidth,height:c[f].element[0].offsetHeight};e=="mousedown"&&c[f]._activate.call(c[f],b)}}},drop:function(a,b){var c=false;d.each(d.ui.ddmanager.droppables[a.options.scope]||[],function(){if(this.options){if(!this.options.disabled&&this.visible&&d.ui.intersect(a,this,this.options.tolerance))c=c||this._drop.call(this,b);if(!this.options.disabled&&this.visible&&this.accept.call(this.element[0],a.currentItem||
+a.element)){this.isout=1;this.isover=0;this._deactivate.call(this,b)}}});return c},drag:function(a,b){a.options.refreshPositions&&d.ui.ddmanager.prepareOffsets(a,b);d.each(d.ui.ddmanager.droppables[a.options.scope]||[],function(){if(!(this.options.disabled||this.greedyChild||!this.visible)){var c=d.ui.intersect(a,this,this.options.tolerance);if(c=!c&&this.isover==1?"isout":c&&this.isover==0?"isover":null){var e;if(this.options.greedy){var g=this.element.parents(":data(droppable):eq(0)");if(g.length){e=
+d.data(g[0],"droppable");e.greedyChild=c=="isover"?1:0}}if(e&&c=="isover"){e.isover=0;e.isout=1;e._out.call(e,b)}this[c]=1;this[c=="isout"?"isover":"isout"]=0;this[c=="isover"?"_over":"_out"].call(this,b);if(e&&c=="isout"){e.isout=0;e.isover=1;e._over.call(e,b)}}}})}}})(jQuery);
diff --git a/core/misc/ui/jquery.ui.mouse.min.js b/core/misc/ui/jquery.ui.mouse.min.js
new file mode 100644
index 000000000000..18057ebd0843
--- /dev/null
+++ b/core/misc/ui/jquery.ui.mouse.min.js
@@ -0,0 +1,18 @@
+
+/*!
+ * jQuery UI Mouse 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Mouse
+ *
+ * Depends:
+ * jquery.ui.widget.js
+ */
+(function(c){c.widget("ui.mouse",{options:{cancel:":input,option",distance:1,delay:0},_mouseInit:function(){var a=this;this.element.bind("mousedown."+this.widgetName,function(b){return a._mouseDown(b)}).bind("click."+this.widgetName,function(b){if(true===c.data(b.target,a.widgetName+".preventClickEvent")){c.removeData(b.target,a.widgetName+".preventClickEvent");b.stopImmediatePropagation();return false}});this.started=false},_mouseDestroy:function(){this.element.unbind("."+this.widgetName)},_mouseDown:function(a){a.originalEvent=
+a.originalEvent||{};if(!a.originalEvent.mouseHandled){this._mouseStarted&&this._mouseUp(a);this._mouseDownEvent=a;var b=this,e=a.which==1,f=typeof this.options.cancel=="string"?c(a.target).parents().add(a.target).filter(this.options.cancel).length:false;if(!e||f||!this._mouseCapture(a))return true;this.mouseDelayMet=!this.options.delay;if(!this.mouseDelayMet)this._mouseDelayTimer=setTimeout(function(){b.mouseDelayMet=true},this.options.delay);if(this._mouseDistanceMet(a)&&this._mouseDelayMet(a)){this._mouseStarted=
+this._mouseStart(a)!==false;if(!this._mouseStarted){a.preventDefault();return true}}this._mouseMoveDelegate=function(d){return b._mouseMove(d)};this._mouseUpDelegate=function(d){return b._mouseUp(d)};c(document).bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate);a.preventDefault();return a.originalEvent.mouseHandled=true}},_mouseMove:function(a){if(c.browser.msie&&!(document.documentMode>=9)&&!a.button)return this._mouseUp(a);if(this._mouseStarted){this._mouseDrag(a);
+return a.preventDefault()}if(this._mouseDistanceMet(a)&&this._mouseDelayMet(a))(this._mouseStarted=this._mouseStart(this._mouseDownEvent,a)!==false)?this._mouseDrag(a):this._mouseUp(a);return!this._mouseStarted},_mouseUp:function(a){c(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate);if(this._mouseStarted){this._mouseStarted=false;a.target==this._mouseDownEvent.target&&c.data(a.target,this.widgetName+".preventClickEvent",
+true);this._mouseStop(a)}return false},_mouseDistanceMet:function(a){return Math.max(Math.abs(this._mouseDownEvent.pageX-a.pageX),Math.abs(this._mouseDownEvent.pageY-a.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return true}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.position.min.js b/core/misc/ui/jquery.ui.position.min.js
new file mode 100644
index 000000000000..2e1451efc103
--- /dev/null
+++ b/core/misc/ui/jquery.ui.position.min.js
@@ -0,0 +1,17 @@
+
+/*
+ * jQuery UI Position 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Position
+ */
+(function(c){c.ui=c.ui||{};var n=/left|center|right/,o=/top|center|bottom/,t=c.fn.position,u=c.fn.offset;c.fn.position=function(b){if(!b||!b.of)return t.apply(this,arguments);b=c.extend({},b);var a=c(b.of),d=a[0],g=(b.collision||"flip").split(" "),e=b.offset?b.offset.split(" "):[0,0],h,k,j;if(d.nodeType===9){h=a.width();k=a.height();j={top:0,left:0}}else if(d.setTimeout){h=a.width();k=a.height();j={top:a.scrollTop(),left:a.scrollLeft()}}else if(d.preventDefault){b.at="left top";h=k=0;j={top:b.of.pageY,
+left:b.of.pageX}}else{h=a.outerWidth();k=a.outerHeight();j=a.offset()}c.each(["my","at"],function(){var f=(b[this]||"").split(" ");if(f.length===1)f=n.test(f[0])?f.concat(["center"]):o.test(f[0])?["center"].concat(f):["center","center"];f[0]=n.test(f[0])?f[0]:"center";f[1]=o.test(f[1])?f[1]:"center";b[this]=f});if(g.length===1)g[1]=g[0];e[0]=parseInt(e[0],10)||0;if(e.length===1)e[1]=e[0];e[1]=parseInt(e[1],10)||0;if(b.at[0]==="right")j.left+=h;else if(b.at[0]==="center")j.left+=h/2;if(b.at[1]==="bottom")j.top+=
+k;else if(b.at[1]==="center")j.top+=k/2;j.left+=e[0];j.top+=e[1];return this.each(function(){var f=c(this),l=f.outerWidth(),m=f.outerHeight(),p=parseInt(c.curCSS(this,"marginLeft",true))||0,q=parseInt(c.curCSS(this,"marginTop",true))||0,v=l+p+parseInt(c.curCSS(this,"marginRight",true))||0,w=m+q+parseInt(c.curCSS(this,"marginBottom",true))||0,i=c.extend({},j),r;if(b.my[0]==="right")i.left-=l;else if(b.my[0]==="center")i.left-=l/2;if(b.my[1]==="bottom")i.top-=m;else if(b.my[1]==="center")i.top-=m/2;
+i.left=Math.round(i.left);i.top=Math.round(i.top);r={left:i.left-p,top:i.top-q};c.each(["left","top"],function(s,x){c.ui.position[g[s]]&&c.ui.position[g[s]][x](i,{targetWidth:h,targetHeight:k,elemWidth:l,elemHeight:m,collisionPosition:r,collisionWidth:v,collisionHeight:w,offset:e,my:b.my,at:b.at})});c.fn.bgiframe&&f.bgiframe();f.offset(c.extend(i,{using:b.using}))})};c.ui.position={fit:{left:function(b,a){var d=c(window);d=a.collisionPosition.left+a.collisionWidth-d.width()-d.scrollLeft();b.left=
+d>0?b.left-d:Math.max(b.left-a.collisionPosition.left,b.left)},top:function(b,a){var d=c(window);d=a.collisionPosition.top+a.collisionHeight-d.height()-d.scrollTop();b.top=d>0?b.top-d:Math.max(b.top-a.collisionPosition.top,b.top)}},flip:{left:function(b,a){if(a.at[0]!=="center"){var d=c(window);d=a.collisionPosition.left+a.collisionWidth-d.width()-d.scrollLeft();var g=a.my[0]==="left"?-a.elemWidth:a.my[0]==="right"?a.elemWidth:0,e=a.at[0]==="left"?a.targetWidth:-a.targetWidth,h=-2*a.offset[0];b.left+=
+a.collisionPosition.left<0?g+e+h:d>0?g+e+h:0}},top:function(b,a){if(a.at[1]!=="center"){var d=c(window);d=a.collisionPosition.top+a.collisionHeight-d.height()-d.scrollTop();var g=a.my[1]==="top"?-a.elemHeight:a.my[1]==="bottom"?a.elemHeight:0,e=a.at[1]==="top"?a.targetHeight:-a.targetHeight,h=-2*a.offset[1];b.top+=a.collisionPosition.top<0?g+e+h:d>0?g+e+h:0}}}};if(!c.offset.setOffset){c.offset.setOffset=function(b,a){if(/static/.test(c.curCSS(b,"position")))b.style.position="relative";var d=c(b),
+g=d.offset(),e=parseInt(c.curCSS(b,"top",true),10)||0,h=parseInt(c.curCSS(b,"left",true),10)||0;g={top:a.top-g.top+e,left:a.left-g.left+h};"using"in a?a.using.call(b,g):d.css(g)};c.fn.offset=function(b){var a=this[0];if(!a||!a.ownerDocument)return null;if(b)return this.each(function(){c.offset.setOffset(this,b)});return u.call(this)}}})(jQuery);
diff --git a/core/misc/ui/jquery.ui.progressbar.css b/core/misc/ui/jquery.ui.progressbar.css
new file mode 100644
index 000000000000..75610308b2a4
--- /dev/null
+++ b/core/misc/ui/jquery.ui.progressbar.css
@@ -0,0 +1,12 @@
+
+/*
+ * jQuery UI Progressbar 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Progressbar#theming
+ */
+.ui-progressbar { height:2em; text-align: left; }
+.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }
diff --git a/core/misc/ui/jquery.ui.progressbar.min.js b/core/misc/ui/jquery.ui.progressbar.min.js
new file mode 100644
index 000000000000..7a8f0b79002e
--- /dev/null
+++ b/core/misc/ui/jquery.ui.progressbar.min.js
@@ -0,0 +1,17 @@
+
+/*
+ * jQuery UI Progressbar 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Progressbar
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ */
+(function(b,d){b.widget("ui.progressbar",{options:{value:0,max:100},min:0,_create:function(){this.element.addClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").attr({role:"progressbar","aria-valuemin":this.min,"aria-valuemax":this.options.max,"aria-valuenow":this._value()});this.valueDiv=b("<div class='ui-progressbar-value ui-widget-header ui-corner-left'></div>").appendTo(this.element);this.oldValue=this._value();this._refreshValue()},destroy:function(){this.element.removeClass("ui-progressbar ui-widget ui-widget-content ui-corner-all").removeAttr("role").removeAttr("aria-valuemin").removeAttr("aria-valuemax").removeAttr("aria-valuenow");
+this.valueDiv.remove();b.Widget.prototype.destroy.apply(this,arguments)},value:function(a){if(a===d)return this._value();this._setOption("value",a);return this},_setOption:function(a,c){if(a==="value"){this.options.value=c;this._refreshValue();this._value()===this.options.max&&this._trigger("complete")}b.Widget.prototype._setOption.apply(this,arguments)},_value:function(){var a=this.options.value;if(typeof a!=="number")a=0;return Math.min(this.options.max,Math.max(this.min,a))},_percentage:function(){return 100*
+this._value()/this.options.max},_refreshValue:function(){var a=this.value(),c=this._percentage();if(this.oldValue!==a){this.oldValue=a;this._trigger("change")}this.valueDiv.toggleClass("ui-corner-right",a===this.options.max).width(c.toFixed(0)+"%");this.element.attr("aria-valuenow",a)}});b.extend(b.ui.progressbar,{version:"1.8.7"})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.resizable.css b/core/misc/ui/jquery.ui.resizable.css
new file mode 100644
index 000000000000..e0f15cc61c59
--- /dev/null
+++ b/core/misc/ui/jquery.ui.resizable.css
@@ -0,0 +1,21 @@
+
+/*
+ * jQuery UI Resizable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Resizable#theming
+ */
+.ui-resizable { position: relative;}
+.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block;}
+.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; }
+.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; }
+.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; }
+.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; }
+.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; }
+.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }
+.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; }
+.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; }
+.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}
diff --git a/core/misc/ui/jquery.ui.resizable.min.js b/core/misc/ui/jquery.ui.resizable.min.js
new file mode 100644
index 000000000000..4df6eb770b81
--- /dev/null
+++ b/core/misc/ui/jquery.ui.resizable.min.js
@@ -0,0 +1,48 @@
+
+/*
+ * jQuery UI Resizable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Resizables
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.mouse.js
+ * jquery.ui.widget.js
+ */
+(function(e){e.widget("ui.resizable",e.ui.mouse,{widgetEventPrefix:"resize",options:{alsoResize:false,animate:false,animateDuration:"slow",animateEasing:"swing",aspectRatio:false,autoHide:false,containment:false,ghost:false,grid:false,handles:"e,s,se",helper:false,maxHeight:null,maxWidth:null,minHeight:10,minWidth:10,zIndex:1E3},_create:function(){var b=this,a=this.options;this.element.addClass("ui-resizable");e.extend(this,{_aspectRatio:!!a.aspectRatio,aspectRatio:a.aspectRatio,originalElement:this.element,
+_proportionallyResizeElements:[],_helper:a.helper||a.ghost||a.animate?a.helper||"ui-resizable-helper":null});if(this.element[0].nodeName.match(/canvas|textarea|input|select|button|img/i)){/relative/.test(this.element.css("position"))&&e.browser.opera&&this.element.css({position:"relative",top:"auto",left:"auto"});this.element.wrap(e('<div class="ui-wrapper" style="overflow: hidden;"></div>').css({position:this.element.css("position"),width:this.element.outerWidth(),height:this.element.outerHeight(),
+top:this.element.css("top"),left:this.element.css("left")}));this.element=this.element.parent().data("resizable",this.element.data("resizable"));this.elementIsWrapper=true;this.element.css({marginLeft:this.originalElement.css("marginLeft"),marginTop:this.originalElement.css("marginTop"),marginRight:this.originalElement.css("marginRight"),marginBottom:this.originalElement.css("marginBottom")});this.originalElement.css({marginLeft:0,marginTop:0,marginRight:0,marginBottom:0});this.originalResizeStyle=
+this.originalElement.css("resize");this.originalElement.css("resize","none");this._proportionallyResizeElements.push(this.originalElement.css({position:"static",zoom:1,display:"block"}));this.originalElement.css({margin:this.originalElement.css("margin")});this._proportionallyResize()}this.handles=a.handles||(!e(".ui-resizable-handle",this.element).length?"e,s,se":{n:".ui-resizable-n",e:".ui-resizable-e",s:".ui-resizable-s",w:".ui-resizable-w",se:".ui-resizable-se",sw:".ui-resizable-sw",ne:".ui-resizable-ne",
+nw:".ui-resizable-nw"});if(this.handles.constructor==String){if(this.handles=="all")this.handles="n,e,s,w,se,sw,ne,nw";var c=this.handles.split(",");this.handles={};for(var d=0;d<c.length;d++){var f=e.trim(c[d]),g=e('<div class="ui-resizable-handle '+("ui-resizable-"+f)+'"></div>');/sw|se|ne|nw/.test(f)&&g.css({zIndex:++a.zIndex});"se"==f&&g.addClass("ui-icon ui-icon-gripsmall-diagonal-se");this.handles[f]=".ui-resizable-"+f;this.element.append(g)}}this._renderAxis=function(h){h=h||this.element;for(var i in this.handles){if(this.handles[i].constructor==
+String)this.handles[i]=e(this.handles[i],this.element).show();if(this.elementIsWrapper&&this.originalElement[0].nodeName.match(/textarea|input|select|button/i)){var j=e(this.handles[i],this.element),k=0;k=/sw|ne|nw|se|n|s/.test(i)?j.outerHeight():j.outerWidth();j=["padding",/ne|nw|n/.test(i)?"Top":/se|sw|s/.test(i)?"Bottom":/^e$/.test(i)?"Right":"Left"].join("");h.css(j,k);this._proportionallyResize()}e(this.handles[i])}};this._renderAxis(this.element);this._handles=e(".ui-resizable-handle",this.element).disableSelection();
+this._handles.mouseover(function(){if(!b.resizing){if(this.className)var h=this.className.match(/ui-resizable-(se|sw|ne|nw|n|e|s|w)/i);b.axis=h&&h[1]?h[1]:"se"}});if(a.autoHide){this._handles.hide();e(this.element).addClass("ui-resizable-autohide").hover(function(){e(this).removeClass("ui-resizable-autohide");b._handles.show()},function(){if(!b.resizing){e(this).addClass("ui-resizable-autohide");b._handles.hide()}})}this._mouseInit()},destroy:function(){this._mouseDestroy();var b=function(c){e(c).removeClass("ui-resizable ui-resizable-disabled ui-resizable-resizing").removeData("resizable").unbind(".resizable").find(".ui-resizable-handle").remove()};
+if(this.elementIsWrapper){b(this.element);var a=this.element;a.after(this.originalElement.css({position:a.css("position"),width:a.outerWidth(),height:a.outerHeight(),top:a.css("top"),left:a.css("left")})).remove()}this.originalElement.css("resize",this.originalResizeStyle);b(this.originalElement);return this},_mouseCapture:function(b){var a=false;for(var c in this.handles)if(e(this.handles[c])[0]==b.target)a=true;return!this.options.disabled&&a},_mouseStart:function(b){var a=this.options,c=this.element.position(),
+d=this.element;this.resizing=true;this.documentScroll={top:e(document).scrollTop(),left:e(document).scrollLeft()};if(d.is(".ui-draggable")||/absolute/.test(d.css("position")))d.css({position:"absolute",top:c.top,left:c.left});e.browser.opera&&/relative/.test(d.css("position"))&&d.css({position:"relative",top:"auto",left:"auto"});this._renderProxy();c=m(this.helper.css("left"));var f=m(this.helper.css("top"));if(a.containment){c+=e(a.containment).scrollLeft()||0;f+=e(a.containment).scrollTop()||0}this.offset=
+this.helper.offset();this.position={left:c,top:f};this.size=this._helper?{width:d.outerWidth(),height:d.outerHeight()}:{width:d.width(),height:d.height()};this.originalSize=this._helper?{width:d.outerWidth(),height:d.outerHeight()}:{width:d.width(),height:d.height()};this.originalPosition={left:c,top:f};this.sizeDiff={width:d.outerWidth()-d.width(),height:d.outerHeight()-d.height()};this.originalMousePosition={left:b.pageX,top:b.pageY};this.aspectRatio=typeof a.aspectRatio=="number"?a.aspectRatio:
+this.originalSize.width/this.originalSize.height||1;a=e(".ui-resizable-"+this.axis).css("cursor");e("body").css("cursor",a=="auto"?this.axis+"-resize":a);d.addClass("ui-resizable-resizing");this._propagate("start",b);return true},_mouseDrag:function(b){var a=this.helper,c=this.originalMousePosition,d=this._change[this.axis];if(!d)return false;c=d.apply(this,[b,b.pageX-c.left||0,b.pageY-c.top||0]);if(this._aspectRatio||b.shiftKey)c=this._updateRatio(c,b);c=this._respectSize(c,b);this._propagate("resize",
+b);a.css({top:this.position.top+"px",left:this.position.left+"px",width:this.size.width+"px",height:this.size.height+"px"});!this._helper&&this._proportionallyResizeElements.length&&this._proportionallyResize();this._updateCache(c);this._trigger("resize",b,this.ui());return false},_mouseStop:function(b){this.resizing=false;var a=this.options,c=this;if(this._helper){var d=this._proportionallyResizeElements,f=d.length&&/textarea/i.test(d[0].nodeName);d=f&&e.ui.hasScroll(d[0],"left")?0:c.sizeDiff.height;
+f={width:c.size.width-(f?0:c.sizeDiff.width),height:c.size.height-d};d=parseInt(c.element.css("left"),10)+(c.position.left-c.originalPosition.left)||null;var g=parseInt(c.element.css("top"),10)+(c.position.top-c.originalPosition.top)||null;a.animate||this.element.css(e.extend(f,{top:g,left:d}));c.helper.height(c.size.height);c.helper.width(c.size.width);this._helper&&!a.animate&&this._proportionallyResize()}e("body").css("cursor","auto");this.element.removeClass("ui-resizable-resizing");this._propagate("stop",
+b);this._helper&&this.helper.remove();return false},_updateCache:function(b){this.offset=this.helper.offset();if(l(b.left))this.position.left=b.left;if(l(b.top))this.position.top=b.top;if(l(b.height))this.size.height=b.height;if(l(b.width))this.size.width=b.width},_updateRatio:function(b){var a=this.position,c=this.size,d=this.axis;if(b.height)b.width=c.height*this.aspectRatio;else if(b.width)b.height=c.width/this.aspectRatio;if(d=="sw"){b.left=a.left+(c.width-b.width);b.top=null}if(d=="nw"){b.top=
+a.top+(c.height-b.height);b.left=a.left+(c.width-b.width)}return b},_respectSize:function(b){var a=this.options,c=this.axis,d=l(b.width)&&a.maxWidth&&a.maxWidth<b.width,f=l(b.height)&&a.maxHeight&&a.maxHeight<b.height,g=l(b.width)&&a.minWidth&&a.minWidth>b.width,h=l(b.height)&&a.minHeight&&a.minHeight>b.height;if(g)b.width=a.minWidth;if(h)b.height=a.minHeight;if(d)b.width=a.maxWidth;if(f)b.height=a.maxHeight;var i=this.originalPosition.left+this.originalSize.width,j=this.position.top+this.size.height,
+k=/sw|nw|w/.test(c);c=/nw|ne|n/.test(c);if(g&&k)b.left=i-a.minWidth;if(d&&k)b.left=i-a.maxWidth;if(h&&c)b.top=j-a.minHeight;if(f&&c)b.top=j-a.maxHeight;if((a=!b.width&&!b.height)&&!b.left&&b.top)b.top=null;else if(a&&!b.top&&b.left)b.left=null;return b},_proportionallyResize:function(){if(this._proportionallyResizeElements.length)for(var b=this.helper||this.element,a=0;a<this._proportionallyResizeElements.length;a++){var c=this._proportionallyResizeElements[a];if(!this.borderDif){var d=[c.css("borderTopWidth"),
+c.css("borderRightWidth"),c.css("borderBottomWidth"),c.css("borderLeftWidth")],f=[c.css("paddingTop"),c.css("paddingRight"),c.css("paddingBottom"),c.css("paddingLeft")];this.borderDif=e.map(d,function(g,h){g=parseInt(g,10)||0;h=parseInt(f[h],10)||0;return g+h})}e.browser.msie&&(e(b).is(":hidden")||e(b).parents(":hidden").length)||c.css({height:b.height()-this.borderDif[0]-this.borderDif[2]||0,width:b.width()-this.borderDif[1]-this.borderDif[3]||0})}},_renderProxy:function(){var b=this.options;this.elementOffset=
+this.element.offset();if(this._helper){this.helper=this.helper||e('<div style="overflow:hidden;"></div>');var a=e.browser.msie&&e.browser.version<7,c=a?1:0;a=a?2:-1;this.helper.addClass(this._helper).css({width:this.element.outerWidth()+a,height:this.element.outerHeight()+a,position:"absolute",left:this.elementOffset.left-c+"px",top:this.elementOffset.top-c+"px",zIndex:++b.zIndex});this.helper.appendTo("body").disableSelection()}else this.helper=this.element},_change:{e:function(b,a){return{width:this.originalSize.width+
+a}},w:function(b,a){return{left:this.originalPosition.left+a,width:this.originalSize.width-a}},n:function(b,a,c){return{top:this.originalPosition.top+c,height:this.originalSize.height-c}},s:function(b,a,c){return{height:this.originalSize.height+c}},se:function(b,a,c){return e.extend(this._change.s.apply(this,arguments),this._change.e.apply(this,[b,a,c]))},sw:function(b,a,c){return e.extend(this._change.s.apply(this,arguments),this._change.w.apply(this,[b,a,c]))},ne:function(b,a,c){return e.extend(this._change.n.apply(this,
+arguments),this._change.e.apply(this,[b,a,c]))},nw:function(b,a,c){return e.extend(this._change.n.apply(this,arguments),this._change.w.apply(this,[b,a,c]))}},_propagate:function(b,a){e.ui.plugin.call(this,b,[a,this.ui()]);b!="resize"&&this._trigger(b,a,this.ui())},plugins:{},ui:function(){return{originalElement:this.originalElement,element:this.element,helper:this.helper,position:this.position,size:this.size,originalSize:this.originalSize,originalPosition:this.originalPosition}}});e.extend(e.ui.resizable,
+{version:"1.8.7"});e.ui.plugin.add("resizable","alsoResize",{start:function(){var b=e(this).data("resizable").options,a=function(c){e(c).each(function(){var d=e(this);d.data("resizable-alsoresize",{width:parseInt(d.width(),10),height:parseInt(d.height(),10),left:parseInt(d.css("left"),10),top:parseInt(d.css("top"),10),position:d.css("position")})})};if(typeof b.alsoResize=="object"&&!b.alsoResize.parentNode)if(b.alsoResize.length){b.alsoResize=b.alsoResize[0];a(b.alsoResize)}else e.each(b.alsoResize,
+function(c){a(c)});else a(b.alsoResize)},resize:function(b,a){var c=e(this).data("resizable");b=c.options;var d=c.originalSize,f=c.originalPosition,g={height:c.size.height-d.height||0,width:c.size.width-d.width||0,top:c.position.top-f.top||0,left:c.position.left-f.left||0},h=function(i,j){e(i).each(function(){var k=e(this),q=e(this).data("resizable-alsoresize"),p={},r=j&&j.length?j:k.parents(a.originalElement[0]).length?["width","height"]:["width","height","top","left"];e.each(r,function(n,o){if((n=
+(q[o]||0)+(g[o]||0))&&n>=0)p[o]=n||null});if(e.browser.opera&&/relative/.test(k.css("position"))){c._revertToRelativePosition=true;k.css({position:"absolute",top:"auto",left:"auto"})}k.css(p)})};typeof b.alsoResize=="object"&&!b.alsoResize.nodeType?e.each(b.alsoResize,function(i,j){h(i,j)}):h(b.alsoResize)},stop:function(){var b=e(this).data("resizable"),a=b.options,c=function(d){e(d).each(function(){var f=e(this);f.css({position:f.data("resizable-alsoresize").position})})};if(b._revertToRelativePosition){b._revertToRelativePosition=
+false;typeof a.alsoResize=="object"&&!a.alsoResize.nodeType?e.each(a.alsoResize,function(d){c(d)}):c(a.alsoResize)}e(this).removeData("resizable-alsoresize")}});e.ui.plugin.add("resizable","animate",{stop:function(b){var a=e(this).data("resizable"),c=a.options,d=a._proportionallyResizeElements,f=d.length&&/textarea/i.test(d[0].nodeName),g=f&&e.ui.hasScroll(d[0],"left")?0:a.sizeDiff.height;f={width:a.size.width-(f?0:a.sizeDiff.width),height:a.size.height-g};g=parseInt(a.element.css("left"),10)+(a.position.left-
+a.originalPosition.left)||null;var h=parseInt(a.element.css("top"),10)+(a.position.top-a.originalPosition.top)||null;a.element.animate(e.extend(f,h&&g?{top:h,left:g}:{}),{duration:c.animateDuration,easing:c.animateEasing,step:function(){var i={width:parseInt(a.element.css("width"),10),height:parseInt(a.element.css("height"),10),top:parseInt(a.element.css("top"),10),left:parseInt(a.element.css("left"),10)};d&&d.length&&e(d[0]).css({width:i.width,height:i.height});a._updateCache(i);a._propagate("resize",
+b)}})}});e.ui.plugin.add("resizable","containment",{start:function(){var b=e(this).data("resizable"),a=b.element,c=b.options.containment;if(a=c instanceof e?c.get(0):/parent/.test(c)?a.parent().get(0):c){b.containerElement=e(a);if(/document/.test(c)||c==document){b.containerOffset={left:0,top:0};b.containerPosition={left:0,top:0};b.parentData={element:e(document),left:0,top:0,width:e(document).width(),height:e(document).height()||document.body.parentNode.scrollHeight}}else{var d=e(a),f=[];e(["Top",
+"Right","Left","Bottom"]).each(function(i,j){f[i]=m(d.css("padding"+j))});b.containerOffset=d.offset();b.containerPosition=d.position();b.containerSize={height:d.innerHeight()-f[3],width:d.innerWidth()-f[1]};c=b.containerOffset;var g=b.containerSize.height,h=b.containerSize.width;h=e.ui.hasScroll(a,"left")?a.scrollWidth:h;g=e.ui.hasScroll(a)?a.scrollHeight:g;b.parentData={element:a,left:c.left,top:c.top,width:h,height:g}}}},resize:function(b){var a=e(this).data("resizable"),c=a.options,d=a.containerOffset,
+f=a.position;b=a._aspectRatio||b.shiftKey;var g={top:0,left:0},h=a.containerElement;if(h[0]!=document&&/static/.test(h.css("position")))g=d;if(f.left<(a._helper?d.left:0)){a.size.width+=a._helper?a.position.left-d.left:a.position.left-g.left;if(b)a.size.height=a.size.width/c.aspectRatio;a.position.left=c.helper?d.left:0}if(f.top<(a._helper?d.top:0)){a.size.height+=a._helper?a.position.top-d.top:a.position.top;if(b)a.size.width=a.size.height*c.aspectRatio;a.position.top=a._helper?d.top:0}a.offset.left=
+a.parentData.left+a.position.left;a.offset.top=a.parentData.top+a.position.top;c=Math.abs((a._helper?a.offset.left-g.left:a.offset.left-g.left)+a.sizeDiff.width);d=Math.abs((a._helper?a.offset.top-g.top:a.offset.top-d.top)+a.sizeDiff.height);f=a.containerElement.get(0)==a.element.parent().get(0);g=/relative|absolute/.test(a.containerElement.css("position"));if(f&&g)c-=a.parentData.left;if(c+a.size.width>=a.parentData.width){a.size.width=a.parentData.width-c;if(b)a.size.height=a.size.width/a.aspectRatio}if(d+
+a.size.height>=a.parentData.height){a.size.height=a.parentData.height-d;if(b)a.size.width=a.size.height*a.aspectRatio}},stop:function(){var b=e(this).data("resizable"),a=b.options,c=b.containerOffset,d=b.containerPosition,f=b.containerElement,g=e(b.helper),h=g.offset(),i=g.outerWidth()-b.sizeDiff.width;g=g.outerHeight()-b.sizeDiff.height;b._helper&&!a.animate&&/relative/.test(f.css("position"))&&e(this).css({left:h.left-d.left-c.left,width:i,height:g});b._helper&&!a.animate&&/static/.test(f.css("position"))&&
+e(this).css({left:h.left-d.left-c.left,width:i,height:g})}});e.ui.plugin.add("resizable","ghost",{start:function(){var b=e(this).data("resizable"),a=b.options,c=b.size;b.ghost=b.originalElement.clone();b.ghost.css({opacity:0.25,display:"block",position:"relative",height:c.height,width:c.width,margin:0,left:0,top:0}).addClass("ui-resizable-ghost").addClass(typeof a.ghost=="string"?a.ghost:"");b.ghost.appendTo(b.helper)},resize:function(){var b=e(this).data("resizable");b.ghost&&b.ghost.css({position:"relative",
+height:b.size.height,width:b.size.width})},stop:function(){var b=e(this).data("resizable");b.ghost&&b.helper&&b.helper.get(0).removeChild(b.ghost.get(0))}});e.ui.plugin.add("resizable","grid",{resize:function(){var b=e(this).data("resizable"),a=b.options,c=b.size,d=b.originalSize,f=b.originalPosition,g=b.axis;a.grid=typeof a.grid=="number"?[a.grid,a.grid]:a.grid;var h=Math.round((c.width-d.width)/(a.grid[0]||1))*(a.grid[0]||1);a=Math.round((c.height-d.height)/(a.grid[1]||1))*(a.grid[1]||1);if(/^(se|s|e)$/.test(g)){b.size.width=
+d.width+h;b.size.height=d.height+a}else if(/^(ne)$/.test(g)){b.size.width=d.width+h;b.size.height=d.height+a;b.position.top=f.top-a}else{if(/^(sw)$/.test(g)){b.size.width=d.width+h;b.size.height=d.height+a}else{b.size.width=d.width+h;b.size.height=d.height+a;b.position.top=f.top-a}b.position.left=f.left-h}}});var m=function(b){return parseInt(b,10)||0},l=function(b){return!isNaN(parseInt(b,10))}})(jQuery);
diff --git a/core/misc/ui/jquery.ui.selectable.css b/core/misc/ui/jquery.ui.selectable.css
new file mode 100644
index 000000000000..1489dcf36c57
--- /dev/null
+++ b/core/misc/ui/jquery.ui.selectable.css
@@ -0,0 +1,11 @@
+
+/*
+ * jQuery UI Selectable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Selectable#theming
+ */
+.ui-selectable-helper { position: absolute; z-index: 100; border:1px dotted black; }
diff --git a/core/misc/ui/jquery.ui.selectable.min.js b/core/misc/ui/jquery.ui.selectable.min.js
new file mode 100644
index 000000000000..e2ec516e0b83
--- /dev/null
+++ b/core/misc/ui/jquery.ui.selectable.min.js
@@ -0,0 +1,23 @@
+
+/*
+ * jQuery UI Selectable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Selectables
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.mouse.js
+ * jquery.ui.widget.js
+ */
+(function(e){e.widget("ui.selectable",e.ui.mouse,{options:{appendTo:"body",autoRefresh:true,distance:0,filter:"*",tolerance:"touch"},_create:function(){var c=this;this.element.addClass("ui-selectable");this.dragged=false;var f;this.refresh=function(){f=e(c.options.filter,c.element[0]);f.each(function(){var d=e(this),b=d.offset();e.data(this,"selectable-item",{element:this,$element:d,left:b.left,top:b.top,right:b.left+d.outerWidth(),bottom:b.top+d.outerHeight(),startselected:false,selected:d.hasClass("ui-selected"),
+selecting:d.hasClass("ui-selecting"),unselecting:d.hasClass("ui-unselecting")})})};this.refresh();this.selectees=f.addClass("ui-selectee");this._mouseInit();this.helper=e("<div class='ui-selectable-helper'></div>")},destroy:function(){this.selectees.removeClass("ui-selectee").removeData("selectable-item");this.element.removeClass("ui-selectable ui-selectable-disabled").removeData("selectable").unbind(".selectable");this._mouseDestroy();return this},_mouseStart:function(c){var f=this;this.opos=[c.pageX,
+c.pageY];if(!this.options.disabled){var d=this.options;this.selectees=e(d.filter,this.element[0]);this._trigger("start",c);e(d.appendTo).append(this.helper);this.helper.css({left:c.clientX,top:c.clientY,width:0,height:0});d.autoRefresh&&this.refresh();this.selectees.filter(".ui-selected").each(function(){var b=e.data(this,"selectable-item");b.startselected=true;if(!c.metaKey){b.$element.removeClass("ui-selected");b.selected=false;b.$element.addClass("ui-unselecting");b.unselecting=true;f._trigger("unselecting",
+c,{unselecting:b.element})}});e(c.target).parents().andSelf().each(function(){var b=e.data(this,"selectable-item");if(b){var g=!c.metaKey||!b.$element.hasClass("ui-selected");b.$element.removeClass(g?"ui-unselecting":"ui-selected").addClass(g?"ui-selecting":"ui-unselecting");b.unselecting=!g;b.selecting=g;(b.selected=g)?f._trigger("selecting",c,{selecting:b.element}):f._trigger("unselecting",c,{unselecting:b.element});return false}})}},_mouseDrag:function(c){var f=this;this.dragged=true;if(!this.options.disabled){var d=
+this.options,b=this.opos[0],g=this.opos[1],h=c.pageX,i=c.pageY;if(b>h){var j=h;h=b;b=j}if(g>i){j=i;i=g;g=j}this.helper.css({left:b,top:g,width:h-b,height:i-g});this.selectees.each(function(){var a=e.data(this,"selectable-item");if(!(!a||a.element==f.element[0])){var k=false;if(d.tolerance=="touch")k=!(a.left>h||a.right<b||a.top>i||a.bottom<g);else if(d.tolerance=="fit")k=a.left>b&&a.right<h&&a.top>g&&a.bottom<i;if(k){if(a.selected){a.$element.removeClass("ui-selected");a.selected=false}if(a.unselecting){a.$element.removeClass("ui-unselecting");
+a.unselecting=false}if(!a.selecting){a.$element.addClass("ui-selecting");a.selecting=true;f._trigger("selecting",c,{selecting:a.element})}}else{if(a.selecting)if(c.metaKey&&a.startselected){a.$element.removeClass("ui-selecting");a.selecting=false;a.$element.addClass("ui-selected");a.selected=true}else{a.$element.removeClass("ui-selecting");a.selecting=false;if(a.startselected){a.$element.addClass("ui-unselecting");a.unselecting=true}f._trigger("unselecting",c,{unselecting:a.element})}if(a.selected)if(!c.metaKey&&
+!a.startselected){a.$element.removeClass("ui-selected");a.selected=false;a.$element.addClass("ui-unselecting");a.unselecting=true;f._trigger("unselecting",c,{unselecting:a.element})}}}});return false}},_mouseStop:function(c){var f=this;this.dragged=false;e(".ui-unselecting",this.element[0]).each(function(){var d=e.data(this,"selectable-item");d.$element.removeClass("ui-unselecting");d.unselecting=false;d.startselected=false;f._trigger("unselected",c,{unselected:d.element})});e(".ui-selecting",this.element[0]).each(function(){var d=
+e.data(this,"selectable-item");d.$element.removeClass("ui-selecting").addClass("ui-selected");d.selecting=false;d.selected=true;d.startselected=true;f._trigger("selected",c,{selected:d.element})});this._trigger("stop",c);this.helper.remove();return false}});e.extend(e.ui.selectable,{version:"1.8.7"})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.slider.css b/core/misc/ui/jquery.ui.slider.css
new file mode 100644
index 000000000000..a56a513849c1
--- /dev/null
+++ b/core/misc/ui/jquery.ui.slider.css
@@ -0,0 +1,25 @@
+
+/*
+ * jQuery UI Slider 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Slider#theming
+ */
+.ui-slider { position: relative; text-align: left; }
+.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }
+.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; }
+
+.ui-slider-horizontal { height: .8em; }
+.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }
+.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }
+.ui-slider-horizontal .ui-slider-range-min { left: 0; }
+.ui-slider-horizontal .ui-slider-range-max { right: 0; }
+
+.ui-slider-vertical { width: .8em; height: 100px; }
+.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }
+.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }
+.ui-slider-vertical .ui-slider-range-min { bottom: 0; }
+.ui-slider-vertical .ui-slider-range-max { top: 0; }
diff --git a/core/misc/ui/jquery.ui.slider.min.js b/core/misc/ui/jquery.ui.slider.min.js
new file mode 100644
index 000000000000..dc36f15fed04
--- /dev/null
+++ b/core/misc/ui/jquery.ui.slider.min.js
@@ -0,0 +1,34 @@
+
+/*
+ * jQuery UI Slider 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Slider
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.mouse.js
+ * jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.slider",d.ui.mouse,{widgetEventPrefix:"slide",options:{animate:false,distance:0,max:100,min:0,orientation:"horizontal",range:false,step:1,value:0,values:null},_create:function(){var b=this,a=this.options;this._mouseSliding=this._keySliding=false;this._animateOff=true;this._handleIndex=null;this._detectOrientation();this._mouseInit();this.element.addClass("ui-slider ui-slider-"+this.orientation+" ui-widget ui-widget-content ui-corner-all");a.disabled&&this.element.addClass("ui-slider-disabled ui-disabled");
+this.range=d([]);if(a.range){if(a.range===true){this.range=d("<div></div>");if(!a.values)a.values=[this._valueMin(),this._valueMin()];if(a.values.length&&a.values.length!==2)a.values=[a.values[0],a.values[0]]}else this.range=d("<div></div>");this.range.appendTo(this.element).addClass("ui-slider-range");if(a.range==="min"||a.range==="max")this.range.addClass("ui-slider-range-"+a.range);this.range.addClass("ui-widget-header")}d(".ui-slider-handle",this.element).length===0&&d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");
+if(a.values&&a.values.length)for(;d(".ui-slider-handle",this.element).length<a.values.length;)d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");this.handles=d(".ui-slider-handle",this.element).addClass("ui-state-default ui-corner-all");this.handle=this.handles.eq(0);this.handles.add(this.range).filter("a").click(function(c){c.preventDefault()}).hover(function(){a.disabled||d(this).addClass("ui-state-hover")},function(){d(this).removeClass("ui-state-hover")}).focus(function(){if(a.disabled)d(this).blur();
+else{d(".ui-slider .ui-state-focus").removeClass("ui-state-focus");d(this).addClass("ui-state-focus")}}).blur(function(){d(this).removeClass("ui-state-focus")});this.handles.each(function(c){d(this).data("index.ui-slider-handle",c)});this.handles.keydown(function(c){var e=true,f=d(this).data("index.ui-slider-handle"),h,g,i;if(!b.options.disabled){switch(c.keyCode){case d.ui.keyCode.HOME:case d.ui.keyCode.END:case d.ui.keyCode.PAGE_UP:case d.ui.keyCode.PAGE_DOWN:case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:e=
+false;if(!b._keySliding){b._keySliding=true;d(this).addClass("ui-state-active");h=b._start(c,f);if(h===false)return}break}i=b.options.step;h=b.options.values&&b.options.values.length?(g=b.values(f)):(g=b.value());switch(c.keyCode){case d.ui.keyCode.HOME:g=b._valueMin();break;case d.ui.keyCode.END:g=b._valueMax();break;case d.ui.keyCode.PAGE_UP:g=b._trimAlignValue(h+(b._valueMax()-b._valueMin())/5);break;case d.ui.keyCode.PAGE_DOWN:g=b._trimAlignValue(h-(b._valueMax()-b._valueMin())/5);break;case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:if(h===
+b._valueMax())return;g=b._trimAlignValue(h+i);break;case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:if(h===b._valueMin())return;g=b._trimAlignValue(h-i);break}b._slide(c,f,g);return e}}).keyup(function(c){var e=d(this).data("index.ui-slider-handle");if(b._keySliding){b._keySliding=false;b._stop(c,e);b._change(c,e);d(this).removeClass("ui-state-active")}});this._refreshValue();this._animateOff=false},destroy:function(){this.handles.remove();this.range.remove();this.element.removeClass("ui-slider ui-slider-horizontal ui-slider-vertical ui-slider-disabled ui-widget ui-widget-content ui-corner-all").removeData("slider").unbind(".slider");
+this._mouseDestroy();return this},_mouseCapture:function(b){var a=this.options,c,e,f,h,g;if(a.disabled)return false;this.elementSize={width:this.element.outerWidth(),height:this.element.outerHeight()};this.elementOffset=this.element.offset();c=this._normValueFromMouse({x:b.pageX,y:b.pageY});e=this._valueMax()-this._valueMin()+1;h=this;this.handles.each(function(i){var j=Math.abs(c-h.values(i));if(e>j){e=j;f=d(this);g=i}});if(a.range===true&&this.values(1)===a.min){g+=1;f=d(this.handles[g])}if(this._start(b,
+g)===false)return false;this._mouseSliding=true;h._handleIndex=g;f.addClass("ui-state-active").focus();a=f.offset();this._clickOffset=!d(b.target).parents().andSelf().is(".ui-slider-handle")?{left:0,top:0}:{left:b.pageX-a.left-f.width()/2,top:b.pageY-a.top-f.height()/2-(parseInt(f.css("borderTopWidth"),10)||0)-(parseInt(f.css("borderBottomWidth"),10)||0)+(parseInt(f.css("marginTop"),10)||0)};this.handles.hasClass("ui-state-hover")||this._slide(b,g,c);return this._animateOff=true},_mouseStart:function(){return true},
+_mouseDrag:function(b){var a=this._normValueFromMouse({x:b.pageX,y:b.pageY});this._slide(b,this._handleIndex,a);return false},_mouseStop:function(b){this.handles.removeClass("ui-state-active");this._mouseSliding=false;this._stop(b,this._handleIndex);this._change(b,this._handleIndex);this._clickOffset=this._handleIndex=null;return this._animateOff=false},_detectOrientation:function(){this.orientation=this.options.orientation==="vertical"?"vertical":"horizontal"},_normValueFromMouse:function(b){var a;
+if(this.orientation==="horizontal"){a=this.elementSize.width;b=b.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)}else{a=this.elementSize.height;b=b.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)}a=b/a;if(a>1)a=1;if(a<0)a=0;if(this.orientation==="vertical")a=1-a;b=this._valueMax()-this._valueMin();return this._trimAlignValue(this._valueMin()+a*b)},_start:function(b,a){var c={handle:this.handles[a],value:this.value()};if(this.options.values&&this.options.values.length){c.value=
+this.values(a);c.values=this.values()}return this._trigger("start",b,c)},_slide:function(b,a,c){var e;if(this.options.values&&this.options.values.length){e=this.values(a?0:1);if(this.options.values.length===2&&this.options.range===true&&(a===0&&c>e||a===1&&c<e))c=e;if(c!==this.values(a)){e=this.values();e[a]=c;b=this._trigger("slide",b,{handle:this.handles[a],value:c,values:e});this.values(a?0:1);b!==false&&this.values(a,c,true)}}else if(c!==this.value()){b=this._trigger("slide",b,{handle:this.handles[a],
+value:c});b!==false&&this.value(c)}},_stop:function(b,a){var c={handle:this.handles[a],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(a);c.values=this.values()}this._trigger("stop",b,c)},_change:function(b,a){if(!this._keySliding&&!this._mouseSliding){var c={handle:this.handles[a],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(a);c.values=this.values()}this._trigger("change",b,c)}},value:function(b){if(arguments.length){this.options.value=
+this._trimAlignValue(b);this._refreshValue();this._change(null,0)}return this._value()},values:function(b,a){var c,e,f;if(arguments.length>1){this.options.values[b]=this._trimAlignValue(a);this._refreshValue();this._change(null,b)}if(arguments.length)if(d.isArray(arguments[0])){c=this.options.values;e=arguments[0];for(f=0;f<c.length;f+=1){c[f]=this._trimAlignValue(e[f]);this._change(null,f)}this._refreshValue()}else return this.options.values&&this.options.values.length?this._values(b):this.value();
+else return this._values()},_setOption:function(b,a){var c,e=0;if(d.isArray(this.options.values))e=this.options.values.length;d.Widget.prototype._setOption.apply(this,arguments);switch(b){case "disabled":if(a){this.handles.filter(".ui-state-focus").blur();this.handles.removeClass("ui-state-hover");this.handles.attr("disabled","disabled");this.element.addClass("ui-disabled")}else{this.handles.removeAttr("disabled");this.element.removeClass("ui-disabled")}break;case "orientation":this._detectOrientation();
+this.element.removeClass("ui-slider-horizontal ui-slider-vertical").addClass("ui-slider-"+this.orientation);this._refreshValue();break;case "value":this._animateOff=true;this._refreshValue();this._change(null,0);this._animateOff=false;break;case "values":this._animateOff=true;this._refreshValue();for(c=0;c<e;c+=1)this._change(null,c);this._animateOff=false;break}},_value:function(){var b=this.options.value;return b=this._trimAlignValue(b)},_values:function(b){var a,c;if(arguments.length){a=this.options.values[b];
+return a=this._trimAlignValue(a)}else{a=this.options.values.slice();for(c=0;c<a.length;c+=1)a[c]=this._trimAlignValue(a[c]);return a}},_trimAlignValue:function(b){if(b<=this._valueMin())return this._valueMin();if(b>=this._valueMax())return this._valueMax();var a=this.options.step>0?this.options.step:1,c=(b-this._valueMin())%a;alignValue=b-c;if(Math.abs(c)*2>=a)alignValue+=c>0?a:-a;return parseFloat(alignValue.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},
+_refreshValue:function(){var b=this.options.range,a=this.options,c=this,e=!this._animateOff?a.animate:false,f,h={},g,i,j,l;if(this.options.values&&this.options.values.length)this.handles.each(function(k){f=(c.values(k)-c._valueMin())/(c._valueMax()-c._valueMin())*100;h[c.orientation==="horizontal"?"left":"bottom"]=f+"%";d(this).stop(1,1)[e?"animate":"css"](h,a.animate);if(c.options.range===true)if(c.orientation==="horizontal"){if(k===0)c.range.stop(1,1)[e?"animate":"css"]({left:f+"%"},a.animate);
+if(k===1)c.range[e?"animate":"css"]({width:f-g+"%"},{queue:false,duration:a.animate})}else{if(k===0)c.range.stop(1,1)[e?"animate":"css"]({bottom:f+"%"},a.animate);if(k===1)c.range[e?"animate":"css"]({height:f-g+"%"},{queue:false,duration:a.animate})}g=f});else{i=this.value();j=this._valueMin();l=this._valueMax();f=l!==j?(i-j)/(l-j)*100:0;h[c.orientation==="horizontal"?"left":"bottom"]=f+"%";this.handle.stop(1,1)[e?"animate":"css"](h,a.animate);if(b==="min"&&this.orientation==="horizontal")this.range.stop(1,
+1)[e?"animate":"css"]({width:f+"%"},a.animate);if(b==="max"&&this.orientation==="horizontal")this.range[e?"animate":"css"]({width:100-f+"%"},{queue:false,duration:a.animate});if(b==="min"&&this.orientation==="vertical")this.range.stop(1,1)[e?"animate":"css"]({height:f+"%"},a.animate);if(b==="max"&&this.orientation==="vertical")this.range[e?"animate":"css"]({height:100-f+"%"},{queue:false,duration:a.animate})}}});d.extend(d.ui.slider,{version:"1.8.7"})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.sortable.min.js b/core/misc/ui/jquery.ui.sortable.min.js
new file mode 100644
index 000000000000..2cb1eaa54f37
--- /dev/null
+++ b/core/misc/ui/jquery.ui.sortable.min.js
@@ -0,0 +1,61 @@
+
+/*
+ * jQuery UI Sortable 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Sortables
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.mouse.js
+ * jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.sortable",d.ui.mouse,{widgetEventPrefix:"sort",options:{appendTo:"parent",axis:false,connectWith:false,containment:false,cursor:"auto",cursorAt:false,dropOnEmpty:true,forcePlaceholderSize:false,forceHelperSize:false,grid:false,handle:false,helper:"original",items:"> *",opacity:false,placeholder:false,revert:false,scroll:true,scrollSensitivity:20,scrollSpeed:20,scope:"default",tolerance:"intersect",zIndex:1E3},_create:function(){this.containerCache={};this.element.addClass("ui-sortable");
+this.refresh();this.floating=this.items.length?/left|right/.test(this.items[0].item.css("float")):false;this.offset=this.element.offset();this._mouseInit()},destroy:function(){this.element.removeClass("ui-sortable ui-sortable-disabled").removeData("sortable").unbind(".sortable");this._mouseDestroy();for(var a=this.items.length-1;a>=0;a--)this.items[a].item.removeData("sortable-item");return this},_setOption:function(a,b){if(a==="disabled"){this.options[a]=b;this.widget()[b?"addClass":"removeClass"]("ui-sortable-disabled")}else d.Widget.prototype._setOption.apply(this,
+arguments)},_mouseCapture:function(a,b){if(this.reverting)return false;if(this.options.disabled||this.options.type=="static")return false;this._refreshItems(a);var c=null,e=this;d(a.target).parents().each(function(){if(d.data(this,"sortable-item")==e){c=d(this);return false}});if(d.data(a.target,"sortable-item")==e)c=d(a.target);if(!c)return false;if(this.options.handle&&!b){var f=false;d(this.options.handle,c).find("*").andSelf().each(function(){if(this==a.target)f=true});if(!f)return false}this.currentItem=
+c;this._removeCurrentsFromItems();return true},_mouseStart:function(a,b,c){b=this.options;var e=this;this.currentContainer=this;this.refreshPositions();this.helper=this._createHelper(a);this._cacheHelperProportions();this._cacheMargins();this.scrollParent=this.helper.scrollParent();this.offset=this.currentItem.offset();this.offset={top:this.offset.top-this.margins.top,left:this.offset.left-this.margins.left};this.helper.css("position","absolute");this.cssPosition=this.helper.css("position");d.extend(this.offset,
+{click:{left:a.pageX-this.offset.left,top:a.pageY-this.offset.top},parent:this._getParentOffset(),relative:this._getRelativeOffset()});this.originalPosition=this._generatePosition(a);this.originalPageX=a.pageX;this.originalPageY=a.pageY;b.cursorAt&&this._adjustOffsetFromHelper(b.cursorAt);this.domPosition={prev:this.currentItem.prev()[0],parent:this.currentItem.parent()[0]};this.helper[0]!=this.currentItem[0]&&this.currentItem.hide();this._createPlaceholder();b.containment&&this._setContainment();
+if(b.cursor){if(d("body").css("cursor"))this._storedCursor=d("body").css("cursor");d("body").css("cursor",b.cursor)}if(b.opacity){if(this.helper.css("opacity"))this._storedOpacity=this.helper.css("opacity");this.helper.css("opacity",b.opacity)}if(b.zIndex){if(this.helper.css("zIndex"))this._storedZIndex=this.helper.css("zIndex");this.helper.css("zIndex",b.zIndex)}if(this.scrollParent[0]!=document&&this.scrollParent[0].tagName!="HTML")this.overflowOffset=this.scrollParent.offset();this._trigger("start",
+a,this._uiHash());this._preserveHelperProportions||this._cacheHelperProportions();if(!c)for(c=this.containers.length-1;c>=0;c--)this.containers[c]._trigger("activate",a,e._uiHash(this));if(d.ui.ddmanager)d.ui.ddmanager.current=this;d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a);this.dragging=true;this.helper.addClass("ui-sortable-helper");this._mouseDrag(a);return true},_mouseDrag:function(a){this.position=this._generatePosition(a);this.positionAbs=this._convertPositionTo("absolute");
+if(!this.lastPositionAbs)this.lastPositionAbs=this.positionAbs;if(this.options.scroll){var b=this.options,c=false;if(this.scrollParent[0]!=document&&this.scrollParent[0].tagName!="HTML"){if(this.overflowOffset.top+this.scrollParent[0].offsetHeight-a.pageY<b.scrollSensitivity)this.scrollParent[0].scrollTop=c=this.scrollParent[0].scrollTop+b.scrollSpeed;else if(a.pageY-this.overflowOffset.top<b.scrollSensitivity)this.scrollParent[0].scrollTop=c=this.scrollParent[0].scrollTop-b.scrollSpeed;if(this.overflowOffset.left+
+this.scrollParent[0].offsetWidth-a.pageX<b.scrollSensitivity)this.scrollParent[0].scrollLeft=c=this.scrollParent[0].scrollLeft+b.scrollSpeed;else if(a.pageX-this.overflowOffset.left<b.scrollSensitivity)this.scrollParent[0].scrollLeft=c=this.scrollParent[0].scrollLeft-b.scrollSpeed}else{if(a.pageY-d(document).scrollTop()<b.scrollSensitivity)c=d(document).scrollTop(d(document).scrollTop()-b.scrollSpeed);else if(d(window).height()-(a.pageY-d(document).scrollTop())<b.scrollSensitivity)c=d(document).scrollTop(d(document).scrollTop()+
+b.scrollSpeed);if(a.pageX-d(document).scrollLeft()<b.scrollSensitivity)c=d(document).scrollLeft(d(document).scrollLeft()-b.scrollSpeed);else if(d(window).width()-(a.pageX-d(document).scrollLeft())<b.scrollSensitivity)c=d(document).scrollLeft(d(document).scrollLeft()+b.scrollSpeed)}c!==false&&d.ui.ddmanager&&!b.dropBehaviour&&d.ui.ddmanager.prepareOffsets(this,a)}this.positionAbs=this._convertPositionTo("absolute");if(!this.options.axis||this.options.axis!="y")this.helper[0].style.left=this.position.left+
+"px";if(!this.options.axis||this.options.axis!="x")this.helper[0].style.top=this.position.top+"px";for(b=this.items.length-1;b>=0;b--){c=this.items[b];var e=c.item[0],f=this._intersectsWithPointer(c);if(f)if(e!=this.currentItem[0]&&this.placeholder[f==1?"next":"prev"]()[0]!=e&&!d.ui.contains(this.placeholder[0],e)&&(this.options.type=="semi-dynamic"?!d.ui.contains(this.element[0],e):true)){this.direction=f==1?"down":"up";if(this.options.tolerance=="pointer"||this._intersectsWithSides(c))this._rearrange(a,
+c);else break;this._trigger("change",a,this._uiHash());break}}this._contactContainers(a);d.ui.ddmanager&&d.ui.ddmanager.drag(this,a);this._trigger("sort",a,this._uiHash());this.lastPositionAbs=this.positionAbs;return false},_mouseStop:function(a,b){if(a){d.ui.ddmanager&&!this.options.dropBehaviour&&d.ui.ddmanager.drop(this,a);if(this.options.revert){var c=this;b=c.placeholder.offset();c.reverting=true;d(this.helper).animate({left:b.left-this.offset.parent.left-c.margins.left+(this.offsetParent[0]==
+document.body?0:this.offsetParent[0].scrollLeft),top:b.top-this.offset.parent.top-c.margins.top+(this.offsetParent[0]==document.body?0:this.offsetParent[0].scrollTop)},parseInt(this.options.revert,10)||500,function(){c._clear(a)})}else this._clear(a,b);return false}},cancel:function(){var a=this;if(this.dragging){this._mouseUp();this.options.helper=="original"?this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper"):this.currentItem.show();for(var b=this.containers.length-1;b>=0;b--){this.containers[b]._trigger("deactivate",
+null,a._uiHash(this));if(this.containers[b].containerCache.over){this.containers[b]._trigger("out",null,a._uiHash(this));this.containers[b].containerCache.over=0}}}this.placeholder[0].parentNode&&this.placeholder[0].parentNode.removeChild(this.placeholder[0]);this.options.helper!="original"&&this.helper&&this.helper[0].parentNode&&this.helper.remove();d.extend(this,{helper:null,dragging:false,reverting:false,_noFinalSort:null});this.domPosition.prev?d(this.domPosition.prev).after(this.currentItem):
+d(this.domPosition.parent).prepend(this.currentItem);return this},serialize:function(a){var b=this._getItemsAsjQuery(a&&a.connected),c=[];a=a||{};d(b).each(function(){var e=(d(a.item||this).attr(a.attribute||"id")||"").match(a.expression||/(.+)[-=_](.+)/);if(e)c.push((a.key||e[1]+"[]")+"="+(a.key&&a.expression?e[1]:e[2]))});!c.length&&a.key&&c.push(a.key+"=");return c.join("&")},toArray:function(a){var b=this._getItemsAsjQuery(a&&a.connected),c=[];a=a||{};b.each(function(){c.push(d(a.item||this).attr(a.attribute||
+"id")||"")});return c},_intersectsWith:function(a){var b=this.positionAbs.left,c=b+this.helperProportions.width,e=this.positionAbs.top,f=e+this.helperProportions.height,g=a.left,h=g+a.width,i=a.top,k=i+a.height,j=this.offset.click.top,l=this.offset.click.left;j=e+j>i&&e+j<k&&b+l>g&&b+l<h;return this.options.tolerance=="pointer"||this.options.forcePointerForContainers||this.options.tolerance!="pointer"&&this.helperProportions[this.floating?"width":"height"]>a[this.floating?"width":"height"]?j:g<b+
+this.helperProportions.width/2&&c-this.helperProportions.width/2<h&&i<e+this.helperProportions.height/2&&f-this.helperProportions.height/2<k},_intersectsWithPointer:function(a){var b=d.ui.isOverAxis(this.positionAbs.top+this.offset.click.top,a.top,a.height);a=d.ui.isOverAxis(this.positionAbs.left+this.offset.click.left,a.left,a.width);b=b&&a;a=this._getDragVerticalDirection();var c=this._getDragHorizontalDirection();if(!b)return false;return this.floating?c&&c=="right"||a=="down"?2:1:a&&(a=="down"?
+2:1)},_intersectsWithSides:function(a){var b=d.ui.isOverAxis(this.positionAbs.top+this.offset.click.top,a.top+a.height/2,a.height);a=d.ui.isOverAxis(this.positionAbs.left+this.offset.click.left,a.left+a.width/2,a.width);var c=this._getDragVerticalDirection(),e=this._getDragHorizontalDirection();return this.floating&&e?e=="right"&&a||e=="left"&&!a:c&&(c=="down"&&b||c=="up"&&!b)},_getDragVerticalDirection:function(){var a=this.positionAbs.top-this.lastPositionAbs.top;return a!=0&&(a>0?"down":"up")},
+_getDragHorizontalDirection:function(){var a=this.positionAbs.left-this.lastPositionAbs.left;return a!=0&&(a>0?"right":"left")},refresh:function(a){this._refreshItems(a);this.refreshPositions();return this},_connectWith:function(){var a=this.options;return a.connectWith.constructor==String?[a.connectWith]:a.connectWith},_getItemsAsjQuery:function(a){var b=[],c=[],e=this._connectWith();if(e&&a)for(a=e.length-1;a>=0;a--)for(var f=d(e[a]),g=f.length-1;g>=0;g--){var h=d.data(f[g],"sortable");if(h&&h!=
+this&&!h.options.disabled)c.push([d.isFunction(h.options.items)?h.options.items.call(h.element):d(h.options.items,h.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),h])}c.push([d.isFunction(this.options.items)?this.options.items.call(this.element,null,{options:this.options,item:this.currentItem}):d(this.options.items,this.element).not(".ui-sortable-helper").not(".ui-sortable-placeholder"),this]);for(a=c.length-1;a>=0;a--)c[a][0].each(function(){b.push(this)});return d(b)},_removeCurrentsFromItems:function(){for(var a=
+this.currentItem.find(":data(sortable-item)"),b=0;b<this.items.length;b++)for(var c=0;c<a.length;c++)a[c]==this.items[b].item[0]&&this.items.splice(b,1)},_refreshItems:function(a){this.items=[];this.containers=[this];var b=this.items,c=[[d.isFunction(this.options.items)?this.options.items.call(this.element[0],a,{item:this.currentItem}):d(this.options.items,this.element),this]],e=this._connectWith();if(e)for(var f=e.length-1;f>=0;f--)for(var g=d(e[f]),h=g.length-1;h>=0;h--){var i=d.data(g[h],"sortable");
+if(i&&i!=this&&!i.options.disabled){c.push([d.isFunction(i.options.items)?i.options.items.call(i.element[0],a,{item:this.currentItem}):d(i.options.items,i.element),i]);this.containers.push(i)}}for(f=c.length-1;f>=0;f--){a=c[f][1];e=c[f][0];h=0;for(g=e.length;h<g;h++){i=d(e[h]);i.data("sortable-item",a);b.push({item:i,instance:a,width:0,height:0,left:0,top:0})}}},refreshPositions:function(a){if(this.offsetParent&&this.helper)this.offset.parent=this._getParentOffset();for(var b=this.items.length-1;b>=
+0;b--){var c=this.items[b],e=this.options.toleranceElement?d(this.options.toleranceElement,c.item):c.item;if(!a){c.width=e.outerWidth();c.height=e.outerHeight()}e=e.offset();c.left=e.left;c.top=e.top}if(this.options.custom&&this.options.custom.refreshContainers)this.options.custom.refreshContainers.call(this);else for(b=this.containers.length-1;b>=0;b--){e=this.containers[b].element.offset();this.containers[b].containerCache.left=e.left;this.containers[b].containerCache.top=e.top;this.containers[b].containerCache.width=
+this.containers[b].element.outerWidth();this.containers[b].containerCache.height=this.containers[b].element.outerHeight()}return this},_createPlaceholder:function(a){var b=a||this,c=b.options;if(!c.placeholder||c.placeholder.constructor==String){var e=c.placeholder;c.placeholder={element:function(){var f=d(document.createElement(b.currentItem[0].nodeName)).addClass(e||b.currentItem[0].className+" ui-sortable-placeholder").removeClass("ui-sortable-helper")[0];if(!e)f.style.visibility="hidden";return f},
+update:function(f,g){if(!(e&&!c.forcePlaceholderSize)){g.height()||g.height(b.currentItem.innerHeight()-parseInt(b.currentItem.css("paddingTop")||0,10)-parseInt(b.currentItem.css("paddingBottom")||0,10));g.width()||g.width(b.currentItem.innerWidth()-parseInt(b.currentItem.css("paddingLeft")||0,10)-parseInt(b.currentItem.css("paddingRight")||0,10))}}}}b.placeholder=d(c.placeholder.element.call(b.element,b.currentItem));b.currentItem.after(b.placeholder);c.placeholder.update(b,b.placeholder)},_contactContainers:function(a){for(var b=
+null,c=null,e=this.containers.length-1;e>=0;e--)if(!d.ui.contains(this.currentItem[0],this.containers[e].element[0]))if(this._intersectsWith(this.containers[e].containerCache)){if(!(b&&d.ui.contains(this.containers[e].element[0],b.element[0]))){b=this.containers[e];c=e}}else if(this.containers[e].containerCache.over){this.containers[e]._trigger("out",a,this._uiHash(this));this.containers[e].containerCache.over=0}if(b)if(this.containers.length===1){this.containers[c]._trigger("over",a,this._uiHash(this));
+this.containers[c].containerCache.over=1}else if(this.currentContainer!=this.containers[c]){b=1E4;e=null;for(var f=this.positionAbs[this.containers[c].floating?"left":"top"],g=this.items.length-1;g>=0;g--)if(d.ui.contains(this.containers[c].element[0],this.items[g].item[0])){var h=this.items[g][this.containers[c].floating?"left":"top"];if(Math.abs(h-f)<b){b=Math.abs(h-f);e=this.items[g]}}if(e||this.options.dropOnEmpty){this.currentContainer=this.containers[c];e?this._rearrange(a,e,null,true):this._rearrange(a,
+null,this.containers[c].element,true);this._trigger("change",a,this._uiHash());this.containers[c]._trigger("change",a,this._uiHash(this));this.options.placeholder.update(this.currentContainer,this.placeholder);this.containers[c]._trigger("over",a,this._uiHash(this));this.containers[c].containerCache.over=1}}},_createHelper:function(a){var b=this.options;a=d.isFunction(b.helper)?d(b.helper.apply(this.element[0],[a,this.currentItem])):b.helper=="clone"?this.currentItem.clone():this.currentItem;a.parents("body").length||
+d(b.appendTo!="parent"?b.appendTo:this.currentItem[0].parentNode)[0].appendChild(a[0]);if(a[0]==this.currentItem[0])this._storedCSS={width:this.currentItem[0].style.width,height:this.currentItem[0].style.height,position:this.currentItem.css("position"),top:this.currentItem.css("top"),left:this.currentItem.css("left")};if(a[0].style.width==""||b.forceHelperSize)a.width(this.currentItem.width());if(a[0].style.height==""||b.forceHelperSize)a.height(this.currentItem.height());return a},_adjustOffsetFromHelper:function(a){if(typeof a==
+"string")a=a.split(" ");if(d.isArray(a))a={left:+a[0],top:+a[1]||0};if("left"in a)this.offset.click.left=a.left+this.margins.left;if("right"in a)this.offset.click.left=this.helperProportions.width-a.right+this.margins.left;if("top"in a)this.offset.click.top=a.top+this.margins.top;if("bottom"in a)this.offset.click.top=this.helperProportions.height-a.bottom+this.margins.top},_getParentOffset:function(){this.offsetParent=this.helper.offsetParent();var a=this.offsetParent.offset();if(this.cssPosition==
+"absolute"&&this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0])){a.left+=this.scrollParent.scrollLeft();a.top+=this.scrollParent.scrollTop()}if(this.offsetParent[0]==document.body||this.offsetParent[0].tagName&&this.offsetParent[0].tagName.toLowerCase()=="html"&&d.browser.msie)a={top:0,left:0};return{top:a.top+(parseInt(this.offsetParent.css("borderTopWidth"),10)||0),left:a.left+(parseInt(this.offsetParent.css("borderLeftWidth"),10)||0)}},_getRelativeOffset:function(){if(this.cssPosition==
+"relative"){var a=this.currentItem.position();return{top:a.top-(parseInt(this.helper.css("top"),10)||0)+this.scrollParent.scrollTop(),left:a.left-(parseInt(this.helper.css("left"),10)||0)+this.scrollParent.scrollLeft()}}else return{top:0,left:0}},_cacheMargins:function(){this.margins={left:parseInt(this.currentItem.css("marginLeft"),10)||0,top:parseInt(this.currentItem.css("marginTop"),10)||0}},_cacheHelperProportions:function(){this.helperProportions={width:this.helper.outerWidth(),height:this.helper.outerHeight()}},
+_setContainment:function(){var a=this.options;if(a.containment=="parent")a.containment=this.helper[0].parentNode;if(a.containment=="document"||a.containment=="window")this.containment=[0-this.offset.relative.left-this.offset.parent.left,0-this.offset.relative.top-this.offset.parent.top,d(a.containment=="document"?document:window).width()-this.helperProportions.width-this.margins.left,(d(a.containment=="document"?document:window).height()||document.body.parentNode.scrollHeight)-this.helperProportions.height-
+this.margins.top];if(!/^(document|window|parent)$/.test(a.containment)){var b=d(a.containment)[0];a=d(a.containment).offset();var c=d(b).css("overflow")!="hidden";this.containment=[a.left+(parseInt(d(b).css("borderLeftWidth"),10)||0)+(parseInt(d(b).css("paddingLeft"),10)||0)-this.margins.left,a.top+(parseInt(d(b).css("borderTopWidth"),10)||0)+(parseInt(d(b).css("paddingTop"),10)||0)-this.margins.top,a.left+(c?Math.max(b.scrollWidth,b.offsetWidth):b.offsetWidth)-(parseInt(d(b).css("borderLeftWidth"),
+10)||0)-(parseInt(d(b).css("paddingRight"),10)||0)-this.helperProportions.width-this.margins.left,a.top+(c?Math.max(b.scrollHeight,b.offsetHeight):b.offsetHeight)-(parseInt(d(b).css("borderTopWidth"),10)||0)-(parseInt(d(b).css("paddingBottom"),10)||0)-this.helperProportions.height-this.margins.top]}},_convertPositionTo:function(a,b){if(!b)b=this.position;a=a=="absolute"?1:-1;var c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?
+this.offsetParent:this.scrollParent,e=/(html|body)/i.test(c[0].tagName);return{top:b.top+this.offset.relative.top*a+this.offset.parent.top*a-(d.browser.safari&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollTop():e?0:c.scrollTop())*a),left:b.left+this.offset.relative.left*a+this.offset.parent.left*a-(d.browser.safari&&this.cssPosition=="fixed"?0:(this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():e?0:c.scrollLeft())*a)}},_generatePosition:function(a){var b=
+this.options,c=this.cssPosition=="absolute"&&!(this.scrollParent[0]!=document&&d.ui.contains(this.scrollParent[0],this.offsetParent[0]))?this.offsetParent:this.scrollParent,e=/(html|body)/i.test(c[0].tagName);if(this.cssPosition=="relative"&&!(this.scrollParent[0]!=document&&this.scrollParent[0]!=this.offsetParent[0]))this.offset.relative=this._getRelativeOffset();var f=a.pageX,g=a.pageY;if(this.originalPosition){if(this.containment){if(a.pageX-this.offset.click.left<this.containment[0])f=this.containment[0]+
+this.offset.click.left;if(a.pageY-this.offset.click.top<this.containment[1])g=this.containment[1]+this.offset.click.top;if(a.pageX-this.offset.click.left>this.containment[2])f=this.containment[2]+this.offset.click.left;if(a.pageY-this.offset.click.top>this.containment[3])g=this.containment[3]+this.offset.click.top}if(b.grid){g=this.originalPageY+Math.round((g-this.originalPageY)/b.grid[1])*b.grid[1];g=this.containment?!(g-this.offset.click.top<this.containment[1]||g-this.offset.click.top>this.containment[3])?
+g:!(g-this.offset.click.top<this.containment[1])?g-b.grid[1]:g+b.grid[1]:g;f=this.originalPageX+Math.round((f-this.originalPageX)/b.grid[0])*b.grid[0];f=this.containment?!(f-this.offset.click.left<this.containment[0]||f-this.offset.click.left>this.containment[2])?f:!(f-this.offset.click.left<this.containment[0])?f-b.grid[0]:f+b.grid[0]:f}}return{top:g-this.offset.click.top-this.offset.relative.top-this.offset.parent.top+(d.browser.safari&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollTop():
+e?0:c.scrollTop()),left:f-this.offset.click.left-this.offset.relative.left-this.offset.parent.left+(d.browser.safari&&this.cssPosition=="fixed"?0:this.cssPosition=="fixed"?-this.scrollParent.scrollLeft():e?0:c.scrollLeft())}},_rearrange:function(a,b,c,e){c?c[0].appendChild(this.placeholder[0]):b.item[0].parentNode.insertBefore(this.placeholder[0],this.direction=="down"?b.item[0]:b.item[0].nextSibling);this.counter=this.counter?++this.counter:1;var f=this,g=this.counter;window.setTimeout(function(){g==
+f.counter&&f.refreshPositions(!e)},0)},_clear:function(a,b){this.reverting=false;var c=[];!this._noFinalSort&&this.currentItem[0].parentNode&&this.placeholder.before(this.currentItem);this._noFinalSort=null;if(this.helper[0]==this.currentItem[0]){for(var e in this._storedCSS)if(this._storedCSS[e]=="auto"||this._storedCSS[e]=="static")this._storedCSS[e]="";this.currentItem.css(this._storedCSS).removeClass("ui-sortable-helper")}else this.currentItem.show();this.fromOutside&&!b&&c.push(function(f){this._trigger("receive",
+f,this._uiHash(this.fromOutside))});if((this.fromOutside||this.domPosition.prev!=this.currentItem.prev().not(".ui-sortable-helper")[0]||this.domPosition.parent!=this.currentItem.parent()[0])&&!b)c.push(function(f){this._trigger("update",f,this._uiHash())});if(!d.ui.contains(this.element[0],this.currentItem[0])){b||c.push(function(f){this._trigger("remove",f,this._uiHash())});for(e=this.containers.length-1;e>=0;e--)if(d.ui.contains(this.containers[e].element[0],this.currentItem[0])&&!b){c.push(function(f){return function(g){f._trigger("receive",
+g,this._uiHash(this))}}.call(this,this.containers[e]));c.push(function(f){return function(g){f._trigger("update",g,this._uiHash(this))}}.call(this,this.containers[e]))}}for(e=this.containers.length-1;e>=0;e--){b||c.push(function(f){return function(g){f._trigger("deactivate",g,this._uiHash(this))}}.call(this,this.containers[e]));if(this.containers[e].containerCache.over){c.push(function(f){return function(g){f._trigger("out",g,this._uiHash(this))}}.call(this,this.containers[e]));this.containers[e].containerCache.over=
+0}}this._storedCursor&&d("body").css("cursor",this._storedCursor);this._storedOpacity&&this.helper.css("opacity",this._storedOpacity);if(this._storedZIndex)this.helper.css("zIndex",this._storedZIndex=="auto"?"":this._storedZIndex);this.dragging=false;if(this.cancelHelperRemoval){if(!b){this._trigger("beforeStop",a,this._uiHash());for(e=0;e<c.length;e++)c[e].call(this,a);this._trigger("stop",a,this._uiHash())}return false}b||this._trigger("beforeStop",a,this._uiHash());this.placeholder[0].parentNode.removeChild(this.placeholder[0]);
+this.helper[0]!=this.currentItem[0]&&this.helper.remove();this.helper=null;if(!b){for(e=0;e<c.length;e++)c[e].call(this,a);this._trigger("stop",a,this._uiHash())}this.fromOutside=false;return true},_trigger:function(){d.Widget.prototype._trigger.apply(this,arguments)===false&&this.cancel()},_uiHash:function(a){var b=a||this;return{helper:b.helper,placeholder:b.placeholder||d([]),position:b.position,originalPosition:b.originalPosition,offset:b.positionAbs,item:b.currentItem,sender:a?a.element:null}}});
+d.extend(d.ui.sortable,{version:"1.8.7"})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.tabs.css b/core/misc/ui/jquery.ui.tabs.css
new file mode 100644
index 000000000000..94420e185d90
--- /dev/null
+++ b/core/misc/ui/jquery.ui.tabs.css
@@ -0,0 +1,19 @@
+
+/*
+ * jQuery UI Tabs 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Tabs#theming
+ */
+.ui-tabs { position: relative; padding: .2em; zoom: 1; } /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as "fixed") */
+.ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; }
+.ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap; }
+.ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; }
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; }
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; }
+.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */
+.ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; }
+.ui-tabs .ui-tabs-hide { display: none !important; }
diff --git a/core/misc/ui/jquery.ui.tabs.min.js b/core/misc/ui/jquery.ui.tabs.min.js
new file mode 100644
index 000000000000..aeb42bb4bed9
--- /dev/null
+++ b/core/misc/ui/jquery.ui.tabs.min.js
@@ -0,0 +1,36 @@
+
+/*
+ * jQuery UI Tabs 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Tabs
+ *
+ * Depends:
+ * jquery.ui.core.js
+ * jquery.ui.widget.js
+ */
+(function(d,p){function u(){return++v}function w(){return++x}var v=0,x=0;d.widget("ui.tabs",{options:{add:null,ajaxOptions:null,cache:false,cookie:null,collapsible:false,disable:null,disabled:[],enable:null,event:"click",fx:null,idPrefix:"ui-tabs-",load:null,panelTemplate:"<div></div>",remove:null,select:null,show:null,spinner:"<em>Loading&#8230;</em>",tabTemplate:"<li><a href='#{href}'><span>#{label}</span></a></li>"},_create:function(){this._tabify(true)},_setOption:function(b,e){if(b=="selected")this.options.collapsible&&
+e==this.options.selected||this.select(e);else{this.options[b]=e;this._tabify()}},_tabId:function(b){return b.title&&b.title.replace(/\s/g,"_").replace(/[^\w\u00c0-\uFFFF-]/g,"")||this.options.idPrefix+u()},_sanitizeSelector:function(b){return b.replace(/:/g,"\\:")},_cookie:function(){var b=this.cookie||(this.cookie=this.options.cookie.name||"ui-tabs-"+w());return d.cookie.apply(null,[b].concat(d.makeArray(arguments)))},_ui:function(b,e){return{tab:b,panel:e,index:this.anchors.index(b)}},_cleanup:function(){this.lis.filter(".ui-state-processing").removeClass("ui-state-processing").find("span:data(label.tabs)").each(function(){var b=
+d(this);b.html(b.data("label.tabs")).removeData("label.tabs")})},_tabify:function(b){function e(g,f){g.css("display","");!d.support.opacity&&f.opacity&&g[0].style.removeAttribute("filter")}var a=this,c=this.options,h=/^#.+/;this.list=this.element.find("ol,ul").eq(0);this.lis=d(" > li:has(a[href])",this.list);this.anchors=this.lis.map(function(){return d("a",this)[0]});this.panels=d([]);this.anchors.each(function(g,f){var i=d(f).attr("href"),l=i.split("#")[0],q;if(l&&(l===location.toString().split("#")[0]||
+(q=d("base")[0])&&l===q.href)){i=f.hash;f.href=i}if(h.test(i))a.panels=a.panels.add(a.element.find(a._sanitizeSelector(i)));else if(i&&i!=="#"){d.data(f,"href.tabs",i);d.data(f,"load.tabs",i.replace(/#.*$/,""));i=a._tabId(f);f.href="#"+i;f=a.element.find("#"+i);if(!f.length){f=d(c.panelTemplate).attr("id",i).addClass("ui-tabs-panel ui-widget-content ui-corner-bottom").insertAfter(a.panels[g-1]||a.list);f.data("destroy.tabs",true)}a.panels=a.panels.add(f)}else c.disabled.push(g)});if(b){this.element.addClass("ui-tabs ui-widget ui-widget-content ui-corner-all");
+this.list.addClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.lis.addClass("ui-state-default ui-corner-top");this.panels.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom");if(c.selected===p){location.hash&&this.anchors.each(function(g,f){if(f.hash==location.hash){c.selected=g;return false}});if(typeof c.selected!=="number"&&c.cookie)c.selected=parseInt(a._cookie(),10);if(typeof c.selected!=="number"&&this.lis.filter(".ui-tabs-selected").length)c.selected=
+this.lis.index(this.lis.filter(".ui-tabs-selected"));c.selected=c.selected||(this.lis.length?0:-1)}else if(c.selected===null)c.selected=-1;c.selected=c.selected>=0&&this.anchors[c.selected]||c.selected<0?c.selected:0;c.disabled=d.unique(c.disabled.concat(d.map(this.lis.filter(".ui-state-disabled"),function(g){return a.lis.index(g)}))).sort();d.inArray(c.selected,c.disabled)!=-1&&c.disabled.splice(d.inArray(c.selected,c.disabled),1);this.panels.addClass("ui-tabs-hide");this.lis.removeClass("ui-tabs-selected ui-state-active");
+if(c.selected>=0&&this.anchors.length){a.element.find(a._sanitizeSelector(a.anchors[c.selected].hash)).removeClass("ui-tabs-hide");this.lis.eq(c.selected).addClass("ui-tabs-selected ui-state-active");a.element.queue("tabs",function(){a._trigger("show",null,a._ui(a.anchors[c.selected],a.element.find(a._sanitizeSelector(a.anchors[c.selected].hash))))});this.load(c.selected)}d(window).bind("unload",function(){a.lis.add(a.anchors).unbind(".tabs");a.lis=a.anchors=a.panels=null})}else c.selected=this.lis.index(this.lis.filter(".ui-tabs-selected"));
+this.element[c.collapsible?"addClass":"removeClass"]("ui-tabs-collapsible");c.cookie&&this._cookie(c.selected,c.cookie);b=0;for(var j;j=this.lis[b];b++)d(j)[d.inArray(b,c.disabled)!=-1&&!d(j).hasClass("ui-tabs-selected")?"addClass":"removeClass"]("ui-state-disabled");c.cache===false&&this.anchors.removeData("cache.tabs");this.lis.add(this.anchors).unbind(".tabs");if(c.event!=="mouseover"){var k=function(g,f){f.is(":not(.ui-state-disabled)")&&f.addClass("ui-state-"+g)},n=function(g,f){f.removeClass("ui-state-"+
+g)};this.lis.bind("mouseover.tabs",function(){k("hover",d(this))});this.lis.bind("mouseout.tabs",function(){n("hover",d(this))});this.anchors.bind("focus.tabs",function(){k("focus",d(this).closest("li"))});this.anchors.bind("blur.tabs",function(){n("focus",d(this).closest("li"))})}var m,o;if(c.fx)if(d.isArray(c.fx)){m=c.fx[0];o=c.fx[1]}else m=o=c.fx;var r=o?function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.hide().removeClass("ui-tabs-hide").animate(o,o.duration||"normal",
+function(){e(f,o);a._trigger("show",null,a._ui(g,f[0]))})}:function(g,f){d(g).closest("li").addClass("ui-tabs-selected ui-state-active");f.removeClass("ui-tabs-hide");a._trigger("show",null,a._ui(g,f[0]))},s=m?function(g,f){f.animate(m,m.duration||"normal",function(){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");e(f,m);a.element.dequeue("tabs")})}:function(g,f){a.lis.removeClass("ui-tabs-selected ui-state-active");f.addClass("ui-tabs-hide");a.element.dequeue("tabs")};
+this.anchors.bind(c.event+".tabs",function(){var g=this,f=d(g).closest("li"),i=a.panels.filter(":not(.ui-tabs-hide)"),l=a.element.find(a._sanitizeSelector(g.hash));if(f.hasClass("ui-tabs-selected")&&!c.collapsible||f.hasClass("ui-state-disabled")||f.hasClass("ui-state-processing")||a.panels.filter(":animated").length||a._trigger("select",null,a._ui(this,l[0]))===false){this.blur();return false}c.selected=a.anchors.index(this);a.abort();if(c.collapsible)if(f.hasClass("ui-tabs-selected")){c.selected=
+-1;c.cookie&&a._cookie(c.selected,c.cookie);a.element.queue("tabs",function(){s(g,i)}).dequeue("tabs");this.blur();return false}else if(!i.length){c.cookie&&a._cookie(c.selected,c.cookie);a.element.queue("tabs",function(){r(g,l)});a.load(a.anchors.index(this));this.blur();return false}c.cookie&&a._cookie(c.selected,c.cookie);if(l.length){i.length&&a.element.queue("tabs",function(){s(g,i)});a.element.queue("tabs",function(){r(g,l)});a.load(a.anchors.index(this))}else throw"jQuery UI Tabs: Mismatching fragment identifier.";
+d.browser.msie&&this.blur()});this.anchors.bind("click.tabs",function(){return false})},_getIndex:function(b){if(typeof b=="string")b=this.anchors.index(this.anchors.filter("[href$="+b+"]"));return b},destroy:function(){var b=this.options;this.abort();this.element.unbind(".tabs").removeClass("ui-tabs ui-widget ui-widget-content ui-corner-all ui-tabs-collapsible").removeData("tabs");this.list.removeClass("ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all");this.anchors.each(function(){var e=
+d.data(this,"href.tabs");if(e)this.href=e;var a=d(this).unbind(".tabs");d.each(["href","load","cache"],function(c,h){a.removeData(h+".tabs")})});this.lis.unbind(".tabs").add(this.panels).each(function(){d.data(this,"destroy.tabs")?d(this).remove():d(this).removeClass("ui-state-default ui-corner-top ui-tabs-selected ui-state-active ui-state-hover ui-state-focus ui-state-disabled ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide")});b.cookie&&this._cookie(null,b.cookie);return this},add:function(b,
+e,a){if(a===p)a=this.anchors.length;var c=this,h=this.options;e=d(h.tabTemplate.replace(/#\{href\}/g,b).replace(/#\{label\}/g,e));b=!b.indexOf("#")?b.replace("#",""):this._tabId(d("a",e)[0]);e.addClass("ui-state-default ui-corner-top").data("destroy.tabs",true);var j=c.element.find("#"+b);j.length||(j=d(h.panelTemplate).attr("id",b).data("destroy.tabs",true));j.addClass("ui-tabs-panel ui-widget-content ui-corner-bottom ui-tabs-hide");if(a>=this.lis.length){e.appendTo(this.list);j.appendTo(this.list[0].parentNode)}else{e.insertBefore(this.lis[a]);
+j.insertBefore(this.panels[a])}h.disabled=d.map(h.disabled,function(k){return k>=a?++k:k});this._tabify();if(this.anchors.length==1){h.selected=0;e.addClass("ui-tabs-selected ui-state-active");j.removeClass("ui-tabs-hide");this.element.queue("tabs",function(){c._trigger("show",null,c._ui(c.anchors[0],c.panels[0]))});this.load(0)}this._trigger("add",null,this._ui(this.anchors[a],this.panels[a]));return this},remove:function(b){b=this._getIndex(b);var e=this.options,a=this.lis.eq(b).remove(),c=this.panels.eq(b).remove();
+if(a.hasClass("ui-tabs-selected")&&this.anchors.length>1)this.select(b+(b+1<this.anchors.length?1:-1));e.disabled=d.map(d.grep(e.disabled,function(h){return h!=b}),function(h){return h>=b?--h:h});this._tabify();this._trigger("remove",null,this._ui(a.find("a")[0],c[0]));return this},enable:function(b){b=this._getIndex(b);var e=this.options;if(d.inArray(b,e.disabled)!=-1){this.lis.eq(b).removeClass("ui-state-disabled");e.disabled=d.grep(e.disabled,function(a){return a!=b});this._trigger("enable",null,
+this._ui(this.anchors[b],this.panels[b]));return this}},disable:function(b){b=this._getIndex(b);var e=this.options;if(b!=e.selected){this.lis.eq(b).addClass("ui-state-disabled");e.disabled.push(b);e.disabled.sort();this._trigger("disable",null,this._ui(this.anchors[b],this.panels[b]))}return this},select:function(b){b=this._getIndex(b);if(b==-1)if(this.options.collapsible&&this.options.selected!=-1)b=this.options.selected;else return this;this.anchors.eq(b).trigger(this.options.event+".tabs");return this},
+load:function(b){b=this._getIndex(b);var e=this,a=this.options,c=this.anchors.eq(b)[0],h=d.data(c,"load.tabs");this.abort();if(!h||this.element.queue("tabs").length!==0&&d.data(c,"cache.tabs"))this.element.dequeue("tabs");else{this.lis.eq(b).addClass("ui-state-processing");if(a.spinner){var j=d("span",c);j.data("label.tabs",j.html()).html(a.spinner)}this.xhr=d.ajax(d.extend({},a.ajaxOptions,{url:h,success:function(k,n){e.element.find(e._sanitizeSelector(c.hash)).html(k);e._cleanup();a.cache&&d.data(c,
+"cache.tabs",true);e._trigger("load",null,e._ui(e.anchors[b],e.panels[b]));try{a.ajaxOptions.success(k,n)}catch(m){}},error:function(k,n){e._cleanup();e._trigger("load",null,e._ui(e.anchors[b],e.panels[b]));try{a.ajaxOptions.error(k,n,b,c)}catch(m){}}}));e.element.dequeue("tabs");return this}},abort:function(){this.element.queue([]);this.panels.stop(false,true);this.element.queue("tabs",this.element.queue("tabs").splice(-2,2));if(this.xhr){this.xhr.abort();delete this.xhr}this._cleanup();return this},
+url:function(b,e){this.anchors.eq(b).removeData("cache.tabs").data("load.tabs",e);return this},length:function(){return this.anchors.length}});d.extend(d.ui.tabs,{version:"1.8.7"});d.extend(d.ui.tabs.prototype,{rotation:null,rotate:function(b,e){var a=this,c=this.options,h=a._rotate||(a._rotate=function(j){clearTimeout(a.rotation);a.rotation=setTimeout(function(){var k=c.selected;a.select(++k<a.anchors.length?k:0)},b);j&&j.stopPropagation()});e=a._unrotate||(a._unrotate=!e?function(j){j.clientX&&
+a.rotate(null)}:function(){t=c.selected;h()});if(b){this.element.bind("tabsshow",h);this.anchors.bind(c.event+".tabs",e);h()}else{clearTimeout(a.rotation);this.element.unbind("tabsshow",h);this.anchors.unbind(c.event+".tabs",e);delete this._rotate;delete this._unrotate}return this}})})(jQuery);
diff --git a/core/misc/ui/jquery.ui.theme.css b/core/misc/ui/jquery.ui.theme.css
new file mode 100644
index 000000000000..1e622b46a87b
--- /dev/null
+++ b/core/misc/ui/jquery.ui.theme.css
@@ -0,0 +1,253 @@
+
+/*
+ * jQuery UI CSS Framework 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Theming/API
+ *
+ * To view and modify this theme, visit http://jqueryui.com/themeroller/
+ */
+
+
+/* Component containers
+----------------------------------*/
+.ui-widget { font-family: Verdana,Arial,sans-serif/*{ffDefault}*/; font-size: 1.1em/*{fsDefault}*/; }
+.ui-widget .ui-widget { font-size: 1em; }
+.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif/*{ffDefault}*/; font-size: 1em; }
+.ui-widget-content { border: 1px solid #aaaaaa/*{borderColorContent}*/; background: #ffffff/*{bgColorContent}*/ url(images/ui-bg_flat_75_ffffff_40x100.png)/*{bgImgUrlContent}*/ 50%/*{bgContentXPos}*/ 50%/*{bgContentYPos}*/ repeat-x/*{bgContentRepeat}*/; color: #222222/*{fcContent}*/; }
+.ui-widget-content a { color: #222222/*{fcContent}*/; }
+.ui-widget-header { border: 1px solid #aaaaaa/*{borderColorHeader}*/; background: #cccccc/*{bgColorHeader}*/ url(images/ui-bg_highlight-soft_75_cccccc_1x100.png)/*{bgImgUrlHeader}*/ 50%/*{bgHeaderXPos}*/ 50%/*{bgHeaderYPos}*/ repeat-x/*{bgHeaderRepeat}*/; color: #222222/*{fcHeader}*/; font-weight: bold; }
+.ui-widget-header a { color: #222222/*{fcHeader}*/; }
+
+/* Interaction states
+----------------------------------*/
+.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3/*{borderColorDefault}*/; background: #e6e6e6/*{bgColorDefault}*/ url(images/ui-bg_glass_75_e6e6e6_1x400.png)/*{bgImgUrlDefault}*/ 50%/*{bgDefaultXPos}*/ 50%/*{bgDefaultYPos}*/ repeat-x/*{bgDefaultRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #555555/*{fcDefault}*/; }
+.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555/*{fcDefault}*/; text-decoration: none; }
+.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999/*{borderColorHover}*/; background: #dadada/*{bgColorHover}*/ url(images/ui-bg_glass_75_dadada_1x400.png)/*{bgImgUrlHover}*/ 50%/*{bgHoverXPos}*/ 50%/*{bgHoverYPos}*/ repeat-x/*{bgHoverRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #212121/*{fcHover}*/; }
+.ui-state-hover a, .ui-state-hover a:hover { color: #212121/*{fcHover}*/; text-decoration: none; }
+.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa/*{borderColorActive}*/; background: #ffffff/*{bgColorActive}*/ url(images/ui-bg_glass_65_ffffff_1x400.png)/*{bgImgUrlActive}*/ 50%/*{bgActiveXPos}*/ 50%/*{bgActiveYPos}*/ repeat-x/*{bgActiveRepeat}*/; font-weight: normal/*{fwDefault}*/; color: #212121/*{fcActive}*/; }
+.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121/*{fcActive}*/; text-decoration: none; }
+.ui-widget :active { outline: none; }
+
+/* Interaction Cues
+----------------------------------*/
+.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #fcefa1/*{borderColorHighlight}*/; background: #fbf9ee/*{bgColorHighlight}*/ url(images/ui-bg_glass_55_fbf9ee_1x400.png)/*{bgImgUrlHighlight}*/ 50%/*{bgHighlightXPos}*/ 50%/*{bgHighlightYPos}*/ repeat-x/*{bgHighlightRepeat}*/; color: #363636/*{fcHighlight}*/; }
+.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636/*{fcHighlight}*/; }
+.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a/*{borderColorError}*/; background: #fef1ec/*{bgColorError}*/ url(images/ui-bg_glass_95_fef1ec_1x400.png)/*{bgImgUrlError}*/ 50%/*{bgErrorXPos}*/ 50%/*{bgErrorYPos}*/ repeat-x/*{bgErrorRepeat}*/; color: #cd0a0a/*{fcError}*/; }
+.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a/*{fcError}*/; }
+.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a/*{fcError}*/; }
+.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }
+.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }
+.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }
+
+/* Icons
+----------------------------------*/
+
+/* states and images */
+.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png)/*{iconsContent}*/; }
+.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png)/*{iconsContent}*/; }
+.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png)/*{iconsHeader}*/; }
+.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png)/*{iconsDefault}*/; }
+.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png)/*{iconsHover}*/; }
+.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png)/*{iconsActive}*/; }
+.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png)/*{iconsHighlight}*/; }
+.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png)/*{iconsError}*/; }
+
+/* positioning */
+.ui-icon-carat-1-n { background-position: 0 0; }
+.ui-icon-carat-1-ne { background-position: -16px 0; }
+.ui-icon-carat-1-e { background-position: -32px 0; }
+.ui-icon-carat-1-se { background-position: -48px 0; }
+.ui-icon-carat-1-s { background-position: -64px 0; }
+.ui-icon-carat-1-sw { background-position: -80px 0; }
+.ui-icon-carat-1-w { background-position: -96px 0; }
+.ui-icon-carat-1-nw { background-position: -112px 0; }
+.ui-icon-carat-2-n-s { background-position: -128px 0; }
+.ui-icon-carat-2-e-w { background-position: -144px 0; }
+.ui-icon-triangle-1-n { background-position: 0 -16px; }
+.ui-icon-triangle-1-ne { background-position: -16px -16px; }
+.ui-icon-triangle-1-e { background-position: -32px -16px; }
+.ui-icon-triangle-1-se { background-position: -48px -16px; }
+.ui-icon-triangle-1-s { background-position: -64px -16px; }
+.ui-icon-triangle-1-sw { background-position: -80px -16px; }
+.ui-icon-triangle-1-w { background-position: -96px -16px; }
+.ui-icon-triangle-1-nw { background-position: -112px -16px; }
+.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
+.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
+.ui-icon-arrow-1-n { background-position: 0 -32px; }
+.ui-icon-arrow-1-ne { background-position: -16px -32px; }
+.ui-icon-arrow-1-e { background-position: -32px -32px; }
+.ui-icon-arrow-1-se { background-position: -48px -32px; }
+.ui-icon-arrow-1-s { background-position: -64px -32px; }
+.ui-icon-arrow-1-sw { background-position: -80px -32px; }
+.ui-icon-arrow-1-w { background-position: -96px -32px; }
+.ui-icon-arrow-1-nw { background-position: -112px -32px; }
+.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
+.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
+.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
+.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
+.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
+.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
+.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
+.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
+.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
+.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
+.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
+.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
+.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
+.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
+.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
+.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
+.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
+.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
+.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
+.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
+.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
+.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
+.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
+.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
+.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
+.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
+.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
+.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
+.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
+.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
+.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
+.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
+.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
+.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
+.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
+.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
+.ui-icon-arrow-4 { background-position: 0 -80px; }
+.ui-icon-arrow-4-diag { background-position: -16px -80px; }
+.ui-icon-extlink { background-position: -32px -80px; }
+.ui-icon-newwin { background-position: -48px -80px; }
+.ui-icon-refresh { background-position: -64px -80px; }
+.ui-icon-shuffle { background-position: -80px -80px; }
+.ui-icon-transfer-e-w { background-position: -96px -80px; }
+.ui-icon-transferthick-e-w { background-position: -112px -80px; }
+.ui-icon-folder-collapsed { background-position: 0 -96px; }
+.ui-icon-folder-open { background-position: -16px -96px; }
+.ui-icon-document { background-position: -32px -96px; }
+.ui-icon-document-b { background-position: -48px -96px; }
+.ui-icon-note { background-position: -64px -96px; }
+.ui-icon-mail-closed { background-position: -80px -96px; }
+.ui-icon-mail-open { background-position: -96px -96px; }
+.ui-icon-suitcase { background-position: -112px -96px; }
+.ui-icon-comment { background-position: -128px -96px; }
+.ui-icon-person { background-position: -144px -96px; }
+.ui-icon-print { background-position: -160px -96px; }
+.ui-icon-trash { background-position: -176px -96px; }
+.ui-icon-locked { background-position: -192px -96px; }
+.ui-icon-unlocked { background-position: -208px -96px; }
+.ui-icon-bookmark { background-position: -224px -96px; }
+.ui-icon-tag { background-position: -240px -96px; }
+.ui-icon-home { background-position: 0 -112px; }
+.ui-icon-flag { background-position: -16px -112px; }
+.ui-icon-calendar { background-position: -32px -112px; }
+.ui-icon-cart { background-position: -48px -112px; }
+.ui-icon-pencil { background-position: -64px -112px; }
+.ui-icon-clock { background-position: -80px -112px; }
+.ui-icon-disk { background-position: -96px -112px; }
+.ui-icon-calculator { background-position: -112px -112px; }
+.ui-icon-zoomin { background-position: -128px -112px; }
+.ui-icon-zoomout { background-position: -144px -112px; }
+.ui-icon-search { background-position: -160px -112px; }
+.ui-icon-wrench { background-position: -176px -112px; }
+.ui-icon-gear { background-position: -192px -112px; }
+.ui-icon-heart { background-position: -208px -112px; }
+.ui-icon-star { background-position: -224px -112px; }
+.ui-icon-link { background-position: -240px -112px; }
+.ui-icon-cancel { background-position: 0 -128px; }
+.ui-icon-plus { background-position: -16px -128px; }
+.ui-icon-plusthick { background-position: -32px -128px; }
+.ui-icon-minus { background-position: -48px -128px; }
+.ui-icon-minusthick { background-position: -64px -128px; }
+.ui-icon-close { background-position: -80px -128px; }
+.ui-icon-closethick { background-position: -96px -128px; }
+.ui-icon-key { background-position: -112px -128px; }
+.ui-icon-lightbulb { background-position: -128px -128px; }
+.ui-icon-scissors { background-position: -144px -128px; }
+.ui-icon-clipboard { background-position: -160px -128px; }
+.ui-icon-copy { background-position: -176px -128px; }
+.ui-icon-contact { background-position: -192px -128px; }
+.ui-icon-image { background-position: -208px -128px; }
+.ui-icon-video { background-position: -224px -128px; }
+.ui-icon-script { background-position: -240px -128px; }
+.ui-icon-alert { background-position: 0 -144px; }
+.ui-icon-info { background-position: -16px -144px; }
+.ui-icon-notice { background-position: -32px -144px; }
+.ui-icon-help { background-position: -48px -144px; }
+.ui-icon-check { background-position: -64px -144px; }
+.ui-icon-bullet { background-position: -80px -144px; }
+.ui-icon-radio-off { background-position: -96px -144px; }
+.ui-icon-radio-on { background-position: -112px -144px; }
+.ui-icon-pin-w { background-position: -128px -144px; }
+.ui-icon-pin-s { background-position: -144px -144px; }
+.ui-icon-play { background-position: 0 -160px; }
+.ui-icon-pause { background-position: -16px -160px; }
+.ui-icon-seek-next { background-position: -32px -160px; }
+.ui-icon-seek-prev { background-position: -48px -160px; }
+.ui-icon-seek-end { background-position: -64px -160px; }
+.ui-icon-seek-start { background-position: -80px -160px; }
+/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */
+.ui-icon-seek-first { background-position: -80px -160px; }
+.ui-icon-stop { background-position: -96px -160px; }
+.ui-icon-eject { background-position: -112px -160px; }
+.ui-icon-volume-off { background-position: -128px -160px; }
+.ui-icon-volume-on { background-position: -144px -160px; }
+.ui-icon-power { background-position: 0 -176px; }
+.ui-icon-signal-diag { background-position: -16px -176px; }
+.ui-icon-signal { background-position: -32px -176px; }
+.ui-icon-battery-0 { background-position: -48px -176px; }
+.ui-icon-battery-1 { background-position: -64px -176px; }
+.ui-icon-battery-2 { background-position: -80px -176px; }
+.ui-icon-battery-3 { background-position: -96px -176px; }
+.ui-icon-circle-plus { background-position: 0 -192px; }
+.ui-icon-circle-minus { background-position: -16px -192px; }
+.ui-icon-circle-close { background-position: -32px -192px; }
+.ui-icon-circle-triangle-e { background-position: -48px -192px; }
+.ui-icon-circle-triangle-s { background-position: -64px -192px; }
+.ui-icon-circle-triangle-w { background-position: -80px -192px; }
+.ui-icon-circle-triangle-n { background-position: -96px -192px; }
+.ui-icon-circle-arrow-e { background-position: -112px -192px; }
+.ui-icon-circle-arrow-s { background-position: -128px -192px; }
+.ui-icon-circle-arrow-w { background-position: -144px -192px; }
+.ui-icon-circle-arrow-n { background-position: -160px -192px; }
+.ui-icon-circle-zoomin { background-position: -176px -192px; }
+.ui-icon-circle-zoomout { background-position: -192px -192px; }
+.ui-icon-circle-check { background-position: -208px -192px; }
+.ui-icon-circlesmall-plus { background-position: 0 -208px; }
+.ui-icon-circlesmall-minus { background-position: -16px -208px; }
+.ui-icon-circlesmall-close { background-position: -32px -208px; }
+.ui-icon-squaresmall-plus { background-position: -48px -208px; }
+.ui-icon-squaresmall-minus { background-position: -64px -208px; }
+.ui-icon-squaresmall-close { background-position: -80px -208px; }
+.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
+.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
+.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
+.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
+.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
+.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
+
+
+/* Misc visuals
+----------------------------------*/
+
+/* Corner radius */
+.ui-corner-tl { -moz-border-radius-topleft: 4px/*{cornerRadius}*/; -webkit-border-top-left-radius: 4px/*{cornerRadius}*/; border-top-left-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-tr { -moz-border-radius-topright: 4px/*{cornerRadius}*/; -webkit-border-top-right-radius: 4px/*{cornerRadius}*/; border-top-right-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-bl { -moz-border-radius-bottomleft: 4px/*{cornerRadius}*/; -webkit-border-bottom-left-radius: 4px/*{cornerRadius}*/; border-bottom-left-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-br { -moz-border-radius-bottomright: 4px/*{cornerRadius}*/; -webkit-border-bottom-right-radius: 4px/*{cornerRadius}*/; border-bottom-right-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-top { -moz-border-radius-topleft: 4px/*{cornerRadius}*/; -webkit-border-top-left-radius: 4px/*{cornerRadius}*/; border-top-left-radius: 4px/*{cornerRadius}*/; -moz-border-radius-topright: 4px/*{cornerRadius}*/; -webkit-border-top-right-radius: 4px/*{cornerRadius}*/; border-top-right-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-bottom { -moz-border-radius-bottomleft: 4px/*{cornerRadius}*/; -webkit-border-bottom-left-radius: 4px/*{cornerRadius}*/; border-bottom-left-radius: 4px/*{cornerRadius}*/; -moz-border-radius-bottomright: 4px/*{cornerRadius}*/; -webkit-border-bottom-right-radius: 4px/*{cornerRadius}*/; border-bottom-right-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-right { -moz-border-radius-topright: 4px/*{cornerRadius}*/; -webkit-border-top-right-radius: 4px/*{cornerRadius}*/; border-top-right-radius: 4px/*{cornerRadius}*/; -moz-border-radius-bottomright: 4px/*{cornerRadius}*/; -webkit-border-bottom-right-radius: 4px/*{cornerRadius}*/; border-bottom-right-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-left { -moz-border-radius-topleft: 4px/*{cornerRadius}*/; -webkit-border-top-left-radius: 4px/*{cornerRadius}*/; border-top-left-radius: 4px/*{cornerRadius}*/; -moz-border-radius-bottomleft: 4px/*{cornerRadius}*/; -webkit-border-bottom-left-radius: 4px/*{cornerRadius}*/; border-bottom-left-radius: 4px/*{cornerRadius}*/; }
+.ui-corner-all { -moz-border-radius: 4px/*{cornerRadius}*/; -webkit-border-radius: 4px/*{cornerRadius}*/; border-radius: 4px/*{cornerRadius}*/; }
+
+/* Overlays */
+.ui-widget-overlay { background: #aaaaaa/*{bgColorOverlay}*/ url(images/ui-bg_flat_0_aaaaaa_40x100.png)/*{bgImgUrlOverlay}*/ 50%/*{bgOverlayXPos}*/ 50%/*{bgOverlayYPos}*/ repeat-x/*{bgOverlayRepeat}*/; opacity: .3;filter:Alpha(Opacity=30)/*{opacityOverlay}*/; }
+.ui-widget-shadow { margin: -8px/*{offsetTopShadow}*/ 0 0 -8px/*{offsetLeftShadow}*/; padding: 8px/*{thicknessShadow}*/; background: #aaaaaa/*{bgColorShadow}*/ url(images/ui-bg_flat_0_aaaaaa_40x100.png)/*{bgImgUrlShadow}*/ 50%/*{bgShadowXPos}*/ 50%/*{bgShadowYPos}*/ repeat-x/*{bgShadowRepeat}*/; opacity: .3;filter:Alpha(Opacity=30)/*{opacityShadow}*/; -moz-border-radius: 8px/*{cornerRadiusShadow}*/; -webkit-border-radius: 8px/*{cornerRadiusShadow}*/; border-radius: 8px/*{cornerRadiusShadow}*/; }
diff --git a/core/misc/ui/jquery.ui.widget.min.js b/core/misc/ui/jquery.ui.widget.min.js
new file mode 100644
index 000000000000..165a272b7886
--- /dev/null
+++ b/core/misc/ui/jquery.ui.widget.min.js
@@ -0,0 +1,16 @@
+
+/*!
+ * jQuery UI Widget 1.8.7
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Widget
+ */
+(function(b,j){if(b.cleanData){var k=b.cleanData;b.cleanData=function(a){for(var c=0,d;(d=a[c])!=null;c++)b(d).triggerHandler("remove");k(a)}}else{var l=b.fn.remove;b.fn.remove=function(a,c){return this.each(function(){if(!c)if(!a||b.filter(a,[this]).length)b("*",this).add([this]).each(function(){b(this).triggerHandler("remove")});return l.call(b(this),a,c)})}}b.widget=function(a,c,d){var e=a.split(".")[0],f;a=a.split(".")[1];f=e+"-"+a;if(!d){d=c;c=b.Widget}b.expr[":"][f]=function(h){return!!b.data(h,
+a)};b[e]=b[e]||{};b[e][a]=function(h,g){arguments.length&&this._createWidget(h,g)};c=new c;c.options=b.extend(true,{},c.options);b[e][a].prototype=b.extend(true,c,{namespace:e,widgetName:a,widgetEventPrefix:b[e][a].prototype.widgetEventPrefix||a,widgetBaseClass:f},d);b.widget.bridge(a,b[e][a])};b.widget.bridge=function(a,c){b.fn[a]=function(d){var e=typeof d==="string",f=Array.prototype.slice.call(arguments,1),h=this;d=!e&&f.length?b.extend.apply(null,[true,d].concat(f)):d;if(e&&d.charAt(0)==="_")return h;
+e?this.each(function(){var g=b.data(this,a),i=g&&b.isFunction(g[d])?g[d].apply(g,f):g;if(i!==g&&i!==j){h=i;return false}}):this.each(function(){var g=b.data(this,a);g?g.option(d||{})._init():b.data(this,a,new c(d,this))});return h}};b.Widget=function(a,c){arguments.length&&this._createWidget(a,c)};b.Widget.prototype={widgetName:"widget",widgetEventPrefix:"",options:{disabled:false},_createWidget:function(a,c){b.data(c,this.widgetName,this);this.element=b(c);this.options=b.extend(true,{},this.options,
+this._getCreateOptions(),a);var d=this;this.element.bind("remove."+this.widgetName,function(){d.destroy()});this._create();this._trigger("create");this._init()},_getCreateOptions:function(){return b.metadata&&b.metadata.get(this.element[0])[this.widgetName]},_create:function(){},_init:function(){},destroy:function(){this.element.unbind("."+this.widgetName).removeData(this.widgetName);this.widget().unbind("."+this.widgetName).removeAttr("aria-disabled").removeClass(this.widgetBaseClass+"-disabled ui-state-disabled")},
+widget:function(){return this.element},option:function(a,c){var d=a;if(arguments.length===0)return b.extend({},this.options);if(typeof a==="string"){if(c===j)return this.options[a];d={};d[a]=c}this._setOptions(d);return this},_setOptions:function(a){var c=this;b.each(a,function(d,e){c._setOption(d,e)});return this},_setOption:function(a,c){this.options[a]=c;if(a==="disabled")this.widget()[c?"addClass":"removeClass"](this.widgetBaseClass+"-disabled ui-state-disabled").attr("aria-disabled",c);return this},
+enable:function(){return this._setOption("disabled",false)},disable:function(){return this._setOption("disabled",true)},_trigger:function(a,c,d){var e=this.options[a];c=b.Event(c);c.type=(a===this.widgetEventPrefix?a:this.widgetEventPrefix+a).toLowerCase();d=d||{};if(c.originalEvent){a=b.event.props.length;for(var f;a;){f=b.event.props[--a];c[f]=c.originalEvent[f]}}this.element.trigger(c,d);return!(b.isFunction(e)&&e.call(this.element[0],c,d)===false||c.isDefaultPrevented())}}})(jQuery);
diff --git a/core/misc/vertical-tabs-rtl.css b/core/misc/vertical-tabs-rtl.css
new file mode 100644
index 000000000000..7fb0347d4614
--- /dev/null
+++ b/core/misc/vertical-tabs-rtl.css
@@ -0,0 +1,14 @@
+
+div.vertical-tabs {
+ margin-left: 0;
+ margin-right: 15em;
+}
+.vertical-tabs ul.vertical-tabs-list {
+ margin-left: 0;
+ margin-right: -15em;
+ float: right;
+}
+.vertical-tabs ul.vertical-tabs-list li.selected {
+ border-left-width: 0;
+ border-right-width: 1px;
+}
diff --git a/core/misc/vertical-tabs.css b/core/misc/vertical-tabs.css
new file mode 100644
index 000000000000..505ba1e58e29
--- /dev/null
+++ b/core/misc/vertical-tabs.css
@@ -0,0 +1,72 @@
+
+div.vertical-tabs {
+ margin: 1em 0 1em 15em; /* LTR */
+ border: 1px solid #ccc;
+ position: relative; /* IE7 */
+}
+.vertical-tabs ul.vertical-tabs-list {
+ width: 15em;
+ list-style: none;
+ list-style-image: none; /* IE7 */
+ border-top: 1px solid #ccc;
+ padding: 0;
+ margin: -1px 0 -1px -15em; /* LTR */
+ float: left; /* LTR */
+}
+.vertical-tabs fieldset.vertical-tabs-pane {
+ margin: 0 !important;
+ padding: 0 1em;
+ border: 0;
+}
+.vertical-tabs legend {
+ display: none;
+}
+
+/* Layout of each tab */
+.vertical-tabs ul.vertical-tabs-list li {
+ background: #eee;
+ border: 1px solid #ccc;
+ border-top: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0; /* IE7 */
+}
+.vertical-tabs ul.vertical-tabs-list li a {
+ display: block;
+ text-decoration: none;
+ padding: 0.5em 0.6em;
+}
+.vertical-tabs ul.vertical-tabs-list li a:focus strong,
+.vertical-tabs ul.vertical-tabs-list li a:active strong,
+.vertical-tabs ul.vertical-tabs-list li a:hover strong {
+ text-decoration: underline;
+}
+.vertical-tabs ul.vertical-tabs-list li a:hover {
+ outline: 1px dotted;
+}
+.vertical-tabs ul.vertical-tabs-list li.selected {
+ background-color: #fff;
+ border-right-width: 0; /* LTR */
+}
+.vertical-tabs ul.vertical-tabs-list .selected strong {
+ color: #000;
+}
+.vertical-tabs ul.vertical-tabs-list .summary {
+ display: block;
+}
+.vertical-tabs ul.vertical-tabs ul.vertical-tabs-list .summary {
+ line-height: normal;
+ margin-bottom: 0;
+}
+
+/**
+ * Prevent text inputs from overflowing when container is too narrow. "width" is
+ * applied to override hardcoded cols or size attributes and used in conjunction
+ * with "box-sizing" to prevent box model issues from occurring in most browsers.
+*/
+.vertical-tabs .form-type-textfield input {
+ width: 100%;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
diff --git a/core/misc/vertical-tabs.js b/core/misc/vertical-tabs.js
new file mode 100644
index 000000000000..82dcd2c62ed9
--- /dev/null
+++ b/core/misc/vertical-tabs.js
@@ -0,0 +1,205 @@
+
+(function ($) {
+
+/**
+ * This script transforms a set of fieldsets into a stack of vertical
+ * tabs. Another tab pane can be selected by clicking on the respective
+ * tab.
+ *
+ * Each tab may have a summary which can be updated by another
+ * script. For that to work, each fieldset has an associated
+ * 'verticalTabCallback' (with jQuery.data() attached to the fieldset),
+ * which is called every time the user performs an update to a form
+ * element inside the tab pane.
+ */
+Drupal.behaviors.verticalTabs = {
+ attach: function (context) {
+ $('.vertical-tabs-panes', context).once('vertical-tabs', function () {
+ var focusID = $(':hidden.vertical-tabs-active-tab', this).val();
+ var tab_focus;
+
+ // Check if there are some fieldsets that can be converted to vertical-tabs
+ var $fieldsets = $('> fieldset', this);
+ if ($fieldsets.length == 0) {
+ return;
+ }
+
+ // Create the tab column.
+ var tab_list = $('<ul class="vertical-tabs-list"></ul>');
+ $(this).wrap('<div class="vertical-tabs clearfix"></div>').before(tab_list);
+
+ // Transform each fieldset into a tab.
+ $fieldsets.each(function () {
+ var vertical_tab = new Drupal.verticalTab({
+ title: $('> legend', this).text(),
+ fieldset: $(this)
+ });
+ tab_list.append(vertical_tab.item);
+ $(this)
+ .removeClass('collapsible collapsed')
+ .addClass('vertical-tabs-pane')
+ .data('verticalTab', vertical_tab);
+ if (this.id == focusID) {
+ tab_focus = $(this);
+ }
+ });
+
+ $('> li:first', tab_list).addClass('first');
+ $('> li:last', tab_list).addClass('last');
+
+ if (!tab_focus) {
+ // If the current URL has a fragment and one of the tabs contains an
+ // element that matches the URL fragment, activate that tab.
+ if (window.location.hash && $(window.location.hash, this).length) {
+ tab_focus = $(window.location.hash, this).closest('.vertical-tabs-pane');
+ }
+ else {
+ tab_focus = $('> .vertical-tabs-pane:first', this);
+ }
+ }
+ if (tab_focus.length) {
+ tab_focus.data('verticalTab').focus();
+ }
+ });
+ }
+};
+
+/**
+ * The vertical tab object represents a single tab within a tab group.
+ *
+ * @param settings
+ * An object with the following keys:
+ * - title: The name of the tab.
+ * - fieldset: The jQuery object of the fieldset that is the tab pane.
+ */
+Drupal.verticalTab = function (settings) {
+ var self = this;
+ $.extend(this, settings, Drupal.theme('verticalTab', settings));
+
+ this.link.click(function () {
+ self.focus();
+ return false;
+ });
+
+ // Keyboard events added:
+ // Pressing the Enter key will open the tab pane.
+ this.link.keydown(function(event) {
+ if (event.keyCode == 13) {
+ self.focus();
+ // Set focus on the first input field of the visible fieldset/tab pane.
+ $("fieldset.vertical-tabs-pane :input:visible:enabled:first").focus();
+ return false;
+ }
+ });
+
+ // Pressing the Enter key lets you leave the tab again.
+ this.fieldset.keydown(function(event) {
+ // Enter key should not trigger inside <textarea> to allow for multi-line entries.
+ if (event.keyCode == 13 && event.target.nodeName != "TEXTAREA") {
+ // Set focus on the selected tab button again.
+ $(".vertical-tab-button.selected a").focus();
+ return false;
+ }
+ });
+
+ this.fieldset
+ .bind('summaryUpdated', function () {
+ self.updateSummary();
+ })
+ .trigger('summaryUpdated');
+};
+
+Drupal.verticalTab.prototype = {
+ /**
+ * Displays the tab's content pane.
+ */
+ focus: function () {
+ this.fieldset
+ .siblings('fieldset.vertical-tabs-pane')
+ .each(function () {
+ var tab = $(this).data('verticalTab');
+ tab.fieldset.hide();
+ tab.item.removeClass('selected');
+ })
+ .end()
+ .show()
+ .siblings(':hidden.vertical-tabs-active-tab')
+ .val(this.fieldset.attr('id'));
+ this.item.addClass('selected');
+ // Mark the active tab for screen readers.
+ $('#active-vertical-tab').remove();
+ this.link.append('<span id="active-vertical-tab" class="element-invisible">' + Drupal.t('(active tab)') + '</span>');
+ },
+
+ /**
+ * Updates the tab's summary.
+ */
+ updateSummary: function () {
+ this.summary.html(this.fieldset.drupalGetSummary());
+ },
+
+ /**
+ * Shows a vertical tab pane.
+ */
+ tabShow: function () {
+ // Display the tab.
+ this.item.show();
+ // Update .first marker for items. We need recurse from parent to retain the
+ // actual DOM element order as jQuery implements sortOrder, but not as public
+ // method.
+ this.item.parent().children('.vertical-tab-button').removeClass('first')
+ .filter(':visible:first').addClass('first');
+ // Display the fieldset.
+ this.fieldset.removeClass('vertical-tab-hidden').show();
+ // Focus this tab.
+ this.focus();
+ return this;
+ },
+
+ /**
+ * Hides a vertical tab pane.
+ */
+ tabHide: function () {
+ // Hide this tab.
+ this.item.hide();
+ // Update .first marker for items. We need recurse from parent to retain the
+ // actual DOM element order as jQuery implements sortOrder, but not as public
+ // method.
+ this.item.parent().children('.vertical-tab-button').removeClass('first')
+ .filter(':visible:first').addClass('first');
+ // Hide the fieldset.
+ this.fieldset.addClass('vertical-tab-hidden').hide();
+ // Focus the first visible tab (if there is one).
+ var $firstTab = this.fieldset.siblings('.vertical-tabs-pane:not(.vertical-tab-hidden):first');
+ if ($firstTab.length) {
+ $firstTab.data('verticalTab').focus();
+ }
+ return this;
+ }
+};
+
+/**
+ * Theme function for a vertical tab.
+ *
+ * @param settings
+ * An object with the following keys:
+ * - title: The name of the tab.
+ * @return
+ * This function has to return an object with at least these keys:
+ * - item: The root tab jQuery element
+ * - link: The anchor tag that acts as the clickable area of the tab
+ * (jQuery version)
+ * - summary: The jQuery element that contains the tab summary
+ */
+Drupal.theme.prototype.verticalTab = function (settings) {
+ var tab = {};
+ tab.item = $('<li class="vertical-tab-button" tabindex="-1"></li>')
+ .append(tab.link = $('<a href="#"></a>')
+ .append(tab.title = $('<strong></strong>').text(settings.title))
+ .append(tab.summary = $('<span class="summary"></span>')
+ )
+ );
+ return tab;
+};
+
+})(jQuery);
diff --git a/core/misc/watchdog-error.png b/core/misc/watchdog-error.png
new file mode 100644
index 000000000000..db05365aa51c
--- /dev/null
+++ b/core/misc/watchdog-error.png
Binary files differ
diff --git a/core/misc/watchdog-ok.png b/core/misc/watchdog-ok.png
new file mode 100644
index 000000000000..1d7baa06e2d0
--- /dev/null
+++ b/core/misc/watchdog-ok.png
Binary files differ
diff --git a/core/misc/watchdog-warning.png b/core/misc/watchdog-warning.png
new file mode 100644
index 000000000000..d8dced8890f2
--- /dev/null
+++ b/core/misc/watchdog-warning.png
Binary files differ
diff --git a/core/modules/README.txt b/core/modules/README.txt
new file mode 100644
index 000000000000..8928d8021ba8
--- /dev/null
+++ b/core/modules/README.txt
@@ -0,0 +1,9 @@
+
+This directory is reserved for core module files. Custom or contributed modules
+should be placed in their own subdirectory of the sites/all/modules directory.
+For multisite installations, they can also be placed in a subdirectory under
+/sites/{sitename}/modules/, where {sitename} is the name of your site (e.g.,
+www.example.com). This will allow you to more easily update Drupal core files.
+
+For more details, see: http://drupal.org/node/176043
+
diff --git a/core/modules/aggregator/aggregator-feed-source.tpl.php b/core/modules/aggregator/aggregator-feed-source.tpl.php
new file mode 100644
index 000000000000..6a684bdb7fae
--- /dev/null
+++ b/core/modules/aggregator/aggregator-feed-source.tpl.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present the source of the feed.
+ *
+ * The contents are rendered above feed listings when browsing source feeds.
+ * For example, "example.com/aggregator/sources/1".
+ *
+ * Available variables:
+ * - $source_icon: Feed icon linked to the source. Rendered through
+ * theme_feed_icon().
+ * - $source_image: Image set by the feed source.
+ * - $source_description: Description set by the feed source.
+ * - $source_url: URL to the feed source.
+ * - $last_checked: How long ago the feed was checked locally.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_aggregator_feed_source()
+ */
+?>
+<div class="feed-source">
+ <?php print $source_icon; ?>
+ <?php print $source_image; ?>
+ <div class="feed-description">
+ <?php print $source_description; ?>
+ </div>
+ <div class="feed-url">
+ <em><?php print t('URL:'); ?></em> <a href="<?php print $source_url; ?>"><?php print $source_url; ?></a>
+ </div>
+ <div class="feed-updated">
+ <em><?php print t('Updated:'); ?></em> <?php print $last_checked; ?>
+ </div>
+</div>
diff --git a/core/modules/aggregator/aggregator-item.tpl.php b/core/modules/aggregator/aggregator-item.tpl.php
new file mode 100644
index 000000000000..e9ad1e0d7560
--- /dev/null
+++ b/core/modules/aggregator/aggregator-item.tpl.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to format an individual feed item for display
+ * on the aggregator page.
+ *
+ * Available variables:
+ * - $feed_url: URL to the originating feed item.
+ * - $feed_title: Title of the feed item.
+ * - $source_url: Link to the local source section.
+ * - $source_title: Title of the remote source.
+ * - $source_date: Date the feed was posted on the remote source.
+ * - $content: Feed item content.
+ * - $categories: Linked categories assigned to the feed.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_aggregator_item()
+ */
+?>
+<div class="feed-item">
+ <h3 class="feed-item-title">
+ <a href="<?php print $feed_url; ?>"><?php print $feed_title; ?></a>
+ </h3>
+
+ <div class="feed-item-meta">
+ <?php if ($source_url): ?>
+ <a href="<?php print $source_url; ?>" class="feed-item-source"><?php print $source_title; ?></a> -
+ <?php endif; ?>
+ <span class="feed-item-date"><?php print $source_date; ?></span>
+ </div>
+
+<?php if ($content): ?>
+ <div class="feed-item-body">
+ <?php print $content; ?>
+ </div>
+<?php endif; ?>
+
+<?php if ($categories): ?>
+ <div class="feed-item-categories">
+ <?php print t('Categories'); ?>: <?php print implode(', ', $categories); ?>
+ </div>
+<?php endif ;?>
+
+</div>
diff --git a/core/modules/aggregator/aggregator-summary-item.tpl.php b/core/modules/aggregator/aggregator-summary-item.tpl.php
new file mode 100644
index 000000000000..fcd57c7a4629
--- /dev/null
+++ b/core/modules/aggregator/aggregator-summary-item.tpl.php
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present a linked feed item for summaries.
+ *
+ * Available variables:
+ * - $feed_url: Link to originating feed.
+ * - $feed_title: Title of feed.
+ * - $feed_age: Age of remote feed.
+ * - $source_url: Link to remote source.
+ * - $source_title: Locally set title for the source.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_aggregator_summary_item()
+ */
+?>
+<a href="<?php print $feed_url; ?>"><?php print $feed_title; ?></a>
+<span class="age"><?php print $feed_age; ?></span>
+
+<?php if ($source_url): ?>,
+ <span class="source"><a href="<?php print $source_url; ?>"><?php print $source_title; ?></a></span>
+<?php endif; ?>
diff --git a/core/modules/aggregator/aggregator-summary-items.tpl.php b/core/modules/aggregator/aggregator-summary-items.tpl.php
new file mode 100644
index 000000000000..0e2133a1e3b0
--- /dev/null
+++ b/core/modules/aggregator/aggregator-summary-items.tpl.php
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present feeds as list items.
+ *
+ * Each iteration generates a single feed source or category.
+ *
+ * Available variables:
+ * - $title: Title of the feed or category.
+ * - $summary_list: Unordered list of linked feed items generated through
+ * theme_item_list().
+ * - $source_url: URL to the local source or category.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_aggregator_summary_items()
+ */
+?>
+<h3><?php print $title; ?></h3>
+<?php print $summary_list; ?>
+<div class="links">
+ <a href="<?php print $source_url; ?>"><?php print t('More'); ?></a>
+</div>
diff --git a/core/modules/aggregator/aggregator-wrapper.tpl.php b/core/modules/aggregator/aggregator-wrapper.tpl.php
new file mode 100644
index 000000000000..0c2f774f55e5
--- /dev/null
+++ b/core/modules/aggregator/aggregator-wrapper.tpl.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to wrap aggregator content.
+ *
+ * Available variables:
+ * - $content: All aggregator content.
+ * - $page: Pager links rendered through theme_pager().
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_aggregator_wrapper()
+ */
+?>
+<div class="aggregator">
+ <?php print $content; ?>
+ <?php print $pager; ?>
+</div>
diff --git a/core/modules/aggregator/aggregator.admin.inc b/core/modules/aggregator/aggregator.admin.inc
new file mode 100644
index 000000000000..08087afb2b86
--- /dev/null
+++ b/core/modules/aggregator/aggregator.admin.inc
@@ -0,0 +1,597 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the aggregator module.
+ */
+
+/**
+ * Menu callback; displays the aggregator administration page.
+ */
+function aggregator_admin_overview() {
+ return aggregator_view();
+}
+
+/**
+ * Displays the aggregator administration page.
+ *
+ * @return
+ * The page HTML.
+ */
+function aggregator_view() {
+ $result = db_query('SELECT f.fid, f.title, f.url, f.refresh, f.checked, f.link, f.description, f.hash, f.etag, f.modified, f.image, f.block, COUNT(i.iid) AS items FROM {aggregator_feed} f LEFT JOIN {aggregator_item} i ON f.fid = i.fid GROUP BY f.fid, f.title, f.url, f.refresh, f.checked, f.link, f.description, f.hash, f.etag, f.modified, f.image, f.block ORDER BY f.title');
+
+ $output = '<h3>' . t('Feed overview') . '</h3>';
+
+ $header = array(t('Title'), t('Items'), t('Last update'), t('Next update'), array('data' => t('Operations'), 'colspan' => '3'));
+ $rows = array();
+ foreach ($result as $feed) {
+ $rows[] = array(
+ l($feed->title, "aggregator/sources/$feed->fid"),
+ format_plural($feed->items, '1 item', '@count items'),
+ ($feed->checked ? t('@time ago', array('@time' => format_interval(REQUEST_TIME - $feed->checked))) : t('never')),
+ ($feed->checked && $feed->refresh ? t('%time left', array('%time' => format_interval($feed->checked + $feed->refresh - REQUEST_TIME))) : t('never')),
+ l(t('edit'), "admin/config/services/aggregator/edit/feed/$feed->fid"),
+ l(t('remove items'), "admin/config/services/aggregator/remove/$feed->fid"),
+ l(t('update items'), "admin/config/services/aggregator/update/$feed->fid"),
+ );
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No feeds available. <a href="@link">Add feed</a>.', array('@link' => url('admin/config/services/aggregator/add/feed')))));
+
+ $result = db_query('SELECT c.cid, c.title, COUNT(ci.iid) as items FROM {aggregator_category} c LEFT JOIN {aggregator_category_item} ci ON c.cid = ci.cid GROUP BY c.cid, c.title ORDER BY title');
+
+ $output .= '<h3>' . t('Category overview') . '</h3>';
+
+ $header = array(t('Title'), t('Items'), t('Operations'));
+ $rows = array();
+ foreach ($result as $category) {
+ $rows[] = array(l($category->title, "aggregator/categories/$category->cid"), format_plural($category->items, '1 item', '@count items'), l(t('edit'), "admin/config/services/aggregator/edit/category/$category->cid"));
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No categories available. <a href="@link">Add category</a>.', array('@link' => url('admin/config/services/aggregator/add/category')))));
+
+ return $output;
+}
+
+/**
+ * Form builder; Generate a form to add/edit feed sources.
+ *
+ * @ingroup forms
+ * @see aggregator_form_feed_validate()
+ * @see aggregator_form_feed_submit()
+ */
+function aggregator_form_feed($form, &$form_state, stdClass $feed = NULL) {
+ $period = drupal_map_assoc(array(900, 1800, 3600, 7200, 10800, 21600, 32400, 43200, 64800, 86400, 172800, 259200, 604800, 1209600, 2419200), 'format_interval');
+ $period[AGGREGATOR_CLEAR_NEVER] = t('Never');
+
+ $form['title'] = array('#type' => 'textfield',
+ '#title' => t('Title'),
+ '#default_value' => isset($feed->title) ? $feed->title : '',
+ '#maxlength' => 255,
+ '#description' => t('The name of the feed (or the name of the website providing the feed).'),
+ '#required' => TRUE,
+ );
+ $form['url'] = array('#type' => 'textfield',
+ '#title' => t('URL'),
+ '#default_value' => isset($feed->url) ? $feed->url : '',
+ '#maxlength' => 255,
+ '#description' => t('The fully-qualified URL of the feed.'),
+ '#required' => TRUE,
+ );
+ $form['refresh'] = array('#type' => 'select',
+ '#title' => t('Update interval'),
+ '#default_value' => isset($feed->refresh) ? $feed->refresh : 3600,
+ '#options' => $period,
+ '#description' => t('The length of time between feed updates. Requires a correctly configured <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status'))),
+ );
+ $form['block'] = array('#type' => 'select',
+ '#title' => t('News items in block'),
+ '#default_value' => isset($feed->block) ? $feed->block : 5,
+ '#options' => drupal_map_assoc(array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)),
+ '#description' => t("Drupal can make a block with the most recent news items of this feed. You can <a href=\"@block-admin\">configure blocks</a> to be displayed in the sidebar of your page. This setting lets you configure the number of news items to show in this feed's block. If you choose '0' this feed's block will be disabled.", array('@block-admin' => url('admin/structure/block'))),
+ );
+
+ // Handling of categories.
+ $options = array();
+ $values = array();
+ $categories = db_query('SELECT c.cid, c.title, f.fid FROM {aggregator_category} c LEFT JOIN {aggregator_category_feed} f ON c.cid = f.cid AND f.fid = :fid ORDER BY title', array(':fid' => isset($feed->fid) ? $feed->fid : NULL));
+ foreach ($categories as $category) {
+ $options[$category->cid] = check_plain($category->title);
+ if ($category->fid) $values[] = $category->cid;
+ }
+
+ if ($options) {
+ $form['category'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Categorize news items'),
+ '#default_value' => $values,
+ '#options' => $options,
+ '#description' => t('New feed items are automatically filed in the checked categories.'),
+ );
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+ if (!empty($feed->fid)) {
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ );
+ $form['fid'] = array(
+ '#type' => 'hidden',
+ '#value' => $feed->fid,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Validate aggregator_form_feed() form submissions.
+ */
+function aggregator_form_feed_validate($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Save')) {
+ // Ensure URL is valid.
+ if (!valid_url($form_state['values']['url'], TRUE)) {
+ form_set_error('url', t('The URL %url is invalid. Enter a fully-qualified URL, such as http://www.example.com/feed.xml.', array('%url' => $form_state['values']['url'])));
+ }
+ // Check for duplicate titles.
+ if (isset($form_state['values']['fid'])) {
+ $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE (title = :title OR url = :url) AND fid <> :fid", array(':title' => $form_state['values']['title'], ':url' => $form_state['values']['url'], ':fid' => $form_state['values']['fid']));
+ }
+ else {
+ $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE title = :title OR url = :url", array(':title' => $form_state['values']['title'], ':url' => $form_state['values']['url']));
+ }
+ foreach ($result as $feed) {
+ if (strcasecmp($feed->title, $form_state['values']['title']) == 0) {
+ form_set_error('title', t('A feed named %feed already exists. Enter a unique title.', array('%feed' => $form_state['values']['title'])));
+ }
+ if (strcasecmp($feed->url, $form_state['values']['url']) == 0) {
+ form_set_error('url', t('A feed with this URL %url already exists. Enter a unique URL.', array('%url' => $form_state['values']['url'])));
+ }
+ }
+ }
+}
+
+/**
+ * Process aggregator_form_feed() form submissions.
+ *
+ * @todo Add delete confirmation dialog.
+ */
+function aggregator_form_feed_submit($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Delete')) {
+ $title = $form_state['values']['title'];
+ // Unset the title.
+ unset($form_state['values']['title']);
+ }
+ aggregator_save_feed($form_state['values']);
+ if (isset($form_state['values']['fid'])) {
+ if (isset($form_state['values']['title'])) {
+ drupal_set_message(t('The feed %feed has been updated.', array('%feed' => $form_state['values']['title'])));
+ if (arg(0) == 'admin') {
+ $form_state['redirect'] = 'admin/config/services/aggregator/';
+ return;
+ }
+ else {
+ $form_state['redirect'] = 'aggregator/sources/' . $form_state['values']['fid'];
+ return;
+ }
+ }
+ else {
+ watchdog('aggregator', 'Feed %feed deleted.', array('%feed' => $title));
+ drupal_set_message(t('The feed %feed has been deleted.', array('%feed' => $title)));
+ if (arg(0) == 'admin') {
+ $form_state['redirect'] = 'admin/config/services/aggregator/';
+ return;
+ }
+ else {
+ $form_state['redirect'] = 'aggregator/sources/';
+ return;
+ }
+ }
+ }
+ else {
+ watchdog('aggregator', 'Feed %feed added.', array('%feed' => $form_state['values']['title']), WATCHDOG_NOTICE, l(t('view'), 'admin/config/services/aggregator'));
+ drupal_set_message(t('The feed %feed has been added.', array('%feed' => $form_state['values']['title'])));
+ }
+}
+
+function aggregator_admin_remove_feed($form, $form_state, $feed) {
+ return confirm_form(
+ array(
+ 'feed' => array(
+ '#type' => 'value',
+ '#value' => $feed,
+ ),
+ ),
+ t('Are you sure you want to remove all items from the feed %feed?', array('%feed' => $feed->title)),
+ 'admin/config/services/aggregator',
+ t('This action cannot be undone.'),
+ t('Remove items'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Remove all items from a feed and redirect to the overview page.
+ *
+ * @param $feed
+ * An associative array describing the feed to be cleared.
+ */
+function aggregator_admin_remove_feed_submit($form, &$form_state) {
+ aggregator_remove($form_state['values']['feed']);
+ $form_state['redirect'] = 'admin/config/services/aggregator';
+}
+
+/**
+ * Form builder; Generate a form to import feeds from OPML.
+ *
+ * @ingroup forms
+ * @see aggregator_form_opml_validate()
+ * @see aggregator_form_opml_submit()
+ */
+function aggregator_form_opml($form, &$form_state) {
+ $period = drupal_map_assoc(array(900, 1800, 3600, 7200, 10800, 21600, 32400, 43200, 64800, 86400, 172800, 259200, 604800, 1209600, 2419200), 'format_interval');
+
+ $form['upload'] = array(
+ '#type' => 'file',
+ '#title' => t('OPML File'),
+ '#description' => t('Upload an OPML file containing a list of feeds to be imported.'),
+ );
+ $form['remote'] = array(
+ '#type' => 'textfield',
+ '#title' => t('OPML Remote URL'),
+ '#maxlength' => 1024,
+ '#description' => t('Enter the URL of an OPML file. This file will be downloaded and processed only once on submission of the form.'),
+ );
+ $form['refresh'] = array(
+ '#type' => 'select',
+ '#title' => t('Update interval'),
+ '#default_value' => 3600,
+ '#options' => $period,
+ '#description' => t('The length of time between feed updates. Requires a correctly configured <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status'))),
+ );
+ $form['block'] = array('#type' => 'select',
+ '#title' => t('News items in block'),
+ '#default_value' => 5,
+ '#options' => drupal_map_assoc(array(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)),
+ '#description' => t("Drupal can make a block with the most recent news items of a feed. You can <a href=\"@block-admin\">configure blocks</a> to be displayed in the sidebar of your page. This setting lets you configure the number of news items to show in a feed's block. If you choose '0' these feeds' blocks will be disabled.", array('@block-admin' => url('admin/structure/block'))),
+ );
+
+ // Handling of categories.
+ $options = array_map('check_plain', db_query("SELECT cid, title FROM {aggregator_category} ORDER BY title")->fetchAllKeyed());
+ if ($options) {
+ $form['category'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Categorize news items'),
+ '#options' => $options,
+ '#description' => t('New feed items are automatically filed in the checked categories.'),
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Import')
+ );
+
+ return $form;
+}
+
+/**
+ * Validate aggregator_form_opml form submissions.
+ */
+function aggregator_form_opml_validate($form, &$form_state) {
+ // If both fields are empty or filled, cancel.
+ if (empty($form_state['values']['remote']) == empty($_FILES['files']['name']['upload'])) {
+ form_set_error('remote', t('You must <em>either</em> upload a file or enter a URL.'));
+ }
+
+ // Validate the URL, if one was entered.
+ if (!empty($form_state['values']['remote']) && !valid_url($form_state['values']['remote'], TRUE)) {
+ form_set_error('remote', t('This URL is not valid.'));
+ }
+}
+
+/**
+ * Process aggregator_form_opml form submissions.
+ */
+function aggregator_form_opml_submit($form, &$form_state) {
+ $data = '';
+ $validators = array('file_validate_extensions' => array('opml xml'));
+ if ($file = file_save_upload('upload', $validators)) {
+ $data = file_get_contents($file->uri);
+ }
+ else {
+ $response = drupal_http_request($form_state['values']['remote']);
+ if (!isset($response->error)) {
+ $data = $response->data;
+ }
+ }
+
+ $feeds = _aggregator_parse_opml($data);
+ if (empty($feeds)) {
+ drupal_set_message(t('No new feed has been added.'));
+ return;
+ }
+
+ $form_state['values']['op'] = t('Save');
+
+ foreach ($feeds as $feed) {
+ // Ensure URL is valid.
+ if (!valid_url($feed['url'], TRUE)) {
+ drupal_set_message(t('The URL %url is invalid.', array('%url' => $feed['url'])), 'warning');
+ continue;
+ }
+
+ // Check for duplicate titles or URLs.
+ $result = db_query("SELECT title, url FROM {aggregator_feed} WHERE title = :title OR url = :url", array(':title' => $feed['title'], ':url' => $feed['url']));
+ foreach ($result as $old) {
+ if (strcasecmp($old->title, $feed['title']) == 0) {
+ drupal_set_message(t('A feed named %title already exists.', array('%title' => $old->title)), 'warning');
+ continue 2;
+ }
+ if (strcasecmp($old->url, $feed['url']) == 0) {
+ drupal_set_message(t('A feed with the URL %url already exists.', array('%url' => $old->url)), 'warning');
+ continue 2;
+ }
+ }
+
+ $form_state['values']['title'] = $feed['title'];
+ $form_state['values']['url'] = $feed['url'];
+ drupal_form_submit('aggregator_form_feed', $form_state);
+ }
+
+ $form_state['redirect'] = 'admin/config/services/aggregator';
+}
+
+/**
+ * Parse an OPML file.
+ *
+ * Feeds are recognized as <outline> elements with the attributes "text" and
+ * "xmlurl" set.
+ *
+ * @param $opml
+ * The complete contents of an OPML document.
+ *
+ * @return
+ * An array of feeds, each an associative array with a "title" and a "url"
+ * element, or NULL if the OPML document failed to be parsed. An empty
+ * array will be returned if the document is valid but contains no feeds, as
+ * some OPML documents do.
+ */
+function _aggregator_parse_opml($opml) {
+ $feeds = array();
+ $xml_parser = drupal_xml_parser_create($opml);
+ if (xml_parse_into_struct($xml_parser, $opml, $values)) {
+ foreach ($values as $entry) {
+ if ($entry['tag'] == 'OUTLINE' && isset($entry['attributes'])) {
+ $item = $entry['attributes'];
+ if (!empty($item['XMLURL']) && !empty($item['TEXT'])) {
+ $feeds[] = array('title' => $item['TEXT'], 'url' => $item['XMLURL']);
+ }
+ }
+ }
+ }
+ xml_parser_free($xml_parser);
+
+ return $feeds;
+}
+
+/**
+ * Menu callback; refreshes a feed, then redirects to the overview page.
+ *
+ * @param $feed
+ * An object describing the feed to be refreshed.
+ */
+function aggregator_admin_refresh_feed($feed) {
+ aggregator_refresh($feed);
+ drupal_goto('admin/config/services/aggregator');
+}
+
+/**
+ * Form builder; Configure the aggregator system.
+ *
+ * @ingroup forms
+ */
+function aggregator_admin_form($form, $form_state) {
+ // Global aggregator settings.
+ $form['aggregator_allowed_html_tags'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Allowed HTML tags'),
+ '#size' => 80,
+ '#maxlength' => 255,
+ '#default_value' => variable_get('aggregator_allowed_html_tags', '<a> <b> <br> <dd> <dl> <dt> <em> <i> <li> <ol> <p> <strong> <u> <ul>'),
+ '#description' => t('A space-separated list of HTML tags allowed in the content of feed items. Disallowed tags are stripped from the content.'),
+ );
+
+ // Make sure configuration is sane.
+ aggregator_sanitize_configuration();
+
+ // Get all available fetchers.
+ $fetchers = module_implements('aggregator_fetch');
+ foreach ($fetchers as $k => $module) {
+ if ($info = module_invoke($module, 'aggregator_fetch_info')) {
+ $label = $info['title'] . ' <span class="description">' . $info['description'] . '</span>';
+ }
+ else {
+ $label = $module;
+ }
+ unset($fetchers[$k]);
+ $fetchers[$module] = $label;
+ }
+
+ // Get all available parsers.
+ $parsers = module_implements('aggregator_parse');
+ foreach ($parsers as $k => $module) {
+ if ($info = module_invoke($module, 'aggregator_parse_info')) {
+ $label = $info['title'] . ' <span class="description">' . $info['description'] . '</span>';
+ }
+ else {
+ $label = $module;
+ }
+ unset($parsers[$k]);
+ $parsers[$module] = $label;
+ }
+
+ // Get all available processors.
+ $processors = module_implements('aggregator_process');
+ foreach ($processors as $k => $module) {
+ if ($info = module_invoke($module, 'aggregator_process_info')) {
+ $label = $info['title'] . ' <span class="description">' . $info['description'] . '</span>';
+ }
+ else {
+ $label = $module;
+ }
+ unset($processors[$k]);
+ $processors[$module] = $label;
+ }
+
+ // Only show basic configuration if there are actually options.
+ $basic_conf = array();
+ if (count($fetchers) > 1) {
+ $basic_conf['aggregator_fetcher'] = array(
+ '#type' => 'radios',
+ '#title' => t('Fetcher'),
+ '#description' => t('Fetchers download data from an external source. Choose a fetcher suitable for the external source you would like to download from.'),
+ '#options' => $fetchers,
+ '#default_value' => variable_get('aggregator_fetcher', 'aggregator'),
+ );
+ }
+ if (count($parsers) > 1) {
+ $basic_conf['aggregator_parser'] = array(
+ '#type' => 'radios',
+ '#title' => t('Parser'),
+ '#description' => t('Parsers transform downloaded data into standard structures. Choose a parser suitable for the type of feeds you would like to aggregate.'),
+ '#options' => $parsers,
+ '#default_value' => variable_get('aggregator_parser', 'aggregator'),
+ );
+ }
+ if (count($processors) > 1) {
+ $basic_conf['aggregator_processors'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Processors'),
+ '#description' => t('Processors act on parsed feed data, for example they store feed items. Choose the processors suitable for your task.'),
+ '#options' => $processors,
+ '#default_value' => variable_get('aggregator_processors', array('aggregator')),
+ );
+ }
+ if (count($basic_conf)) {
+ $form['basic_conf'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Basic configuration'),
+ '#description' => t('For most aggregation tasks, the default settings are fine.'),
+ '#collapsible' => TRUE,
+ '#collapsed' => FALSE,
+ );
+ $form['basic_conf'] += $basic_conf;
+ }
+
+ // Implementing modules will expect an array at $form['modules'].
+ $form['modules'] = array();
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+
+ return $form;
+}
+
+function aggregator_admin_form_submit($form, &$form_state) {
+ if (isset($form_state['values']['aggregator_processors'])) {
+ $form_state['values']['aggregator_processors'] = array_filter($form_state['values']['aggregator_processors']);
+ }
+ system_settings_form_submit($form, $form_state);
+}
+
+/**
+ * Form builder; Generate a form to add/edit/delete aggregator categories.
+ *
+ * @ingroup forms
+ * @see aggregator_form_category_validate()
+ * @see aggregator_form_category_submit()
+ */
+function aggregator_form_category($form, &$form_state, $edit = array('title' => '', 'description' => '', 'cid' => NULL)) {
+ $form['title'] = array('#type' => 'textfield',
+ '#title' => t('Title'),
+ '#default_value' => $edit['title'],
+ '#maxlength' => 64,
+ '#required' => TRUE,
+ );
+ $form['description'] = array('#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => $edit['description'],
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+ if ($edit['cid']) {
+ $form['actions']['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
+ $form['cid'] = array('#type' => 'hidden', '#value' => $edit['cid']);
+ }
+
+ return $form;
+}
+
+/**
+ * Validate aggregator_form_feed form submissions.
+ */
+function aggregator_form_category_validate($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Save')) {
+ // Check for duplicate titles
+ if (isset($form_state['values']['cid'])) {
+ $category = db_query("SELECT cid FROM {aggregator_category} WHERE title = :title AND cid <> :cid", array(':title' => $form_state['values']['title'], ':cid' => $form_state['values']['cid']))->fetchObject();
+ }
+ else {
+ $category = db_query("SELECT cid FROM {aggregator_category} WHERE title = :title", array(':title' => $form_state['values']['title']))->fetchObject();
+ }
+ if ($category) {
+ form_set_error('title', t('A category named %category already exists. Enter a unique title.', array('%category' => $form_state['values']['title'])));
+ }
+ }
+}
+
+/**
+ * Process aggregator_form_category form submissions.
+ *
+ * @todo Add delete confirmation dialog.
+ */
+function aggregator_form_category_submit($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Delete')) {
+ $title = $form_state['values']['title'];
+ // Unset the title.
+ unset($form_state['values']['title']);
+ }
+ aggregator_save_category($form_state['values']);
+ if (isset($form_state['values']['cid'])) {
+ if (isset($form_state['values']['title'])) {
+ drupal_set_message(t('The category %category has been updated.', array('%category' => $form_state['values']['title'])));
+ if (arg(0) == 'admin') {
+ $form_state['redirect'] = 'admin/config/services/aggregator/';
+ return;
+ }
+ else {
+ $form_state['redirect'] = 'aggregator/categories/' . $form_state['values']['cid'];
+ return;
+ }
+ }
+ else {
+ watchdog('aggregator', 'Category %category deleted.', array('%category' => $title));
+ drupal_set_message(t('The category %category has been deleted.', array('%category' => $title)));
+ if (arg(0) == 'admin') {
+ $form_state['redirect'] = 'admin/config/services/aggregator/';
+ return;
+ }
+ else {
+ $form_state['redirect'] = 'aggregator/categories/';
+ return;
+ }
+ }
+ }
+ else {
+ watchdog('aggregator', 'Category %category added.', array('%category' => $form_state['values']['title']), WATCHDOG_NOTICE, l(t('view'), 'admin/config/services/aggregator'));
+ drupal_set_message(t('The category %category has been added.', array('%category' => $form_state['values']['title'])));
+ }
+}
diff --git a/core/modules/aggregator/aggregator.api.php b/core/modules/aggregator/aggregator.api.php
new file mode 100644
index 000000000000..f31413c42707
--- /dev/null
+++ b/core/modules/aggregator/aggregator.api.php
@@ -0,0 +1,231 @@
+<?php
+
+/**
+ * @file
+ * Documentation for aggregator API.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Implement this hook to create an alternative fetcher for aggregator module.
+ *
+ * A fetcher downloads feed data to a Drupal site. The fetcher is called
+ * at the first of the three aggregation stages: data is downloaded by the
+ * active fetcher, it is converted to a common format by the active parser and
+ * finally, it is passed to all active processors which manipulate or store the
+ * data.
+ *
+ * Modules that define this hook can be set as active fetcher on
+ * admin/config/services/aggregator. Only one fetcher can be active at a time.
+ *
+ * @param $feed
+ * The $feed object that describes the resource to be downloaded.
+ * $feed->url contains the link to the feed. Download the data at the URL
+ * and expose it to other modules by attaching it to $feed->source_string.
+ *
+ * @return
+ * TRUE if fetching was successful, FALSE otherwise.
+ *
+ * @see hook_aggregator_fetch_info()
+ * @see hook_aggregator_parse()
+ * @see hook_aggregator_process()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_fetch($feed) {
+ $feed->source_string = mymodule_fetch($feed->url);
+}
+
+/**
+ * Implement this hook to expose the title and a short description of your
+ * fetcher.
+ *
+ * The title and the description provided are shown on
+ * admin/config/services/aggregator among other places. Use as title the human
+ * readable name of the fetcher and as description a brief (40 to 80 characters)
+ * explanation of the fetcher's functionality.
+ *
+ * This hook is only called if your module implements hook_aggregator_fetch().
+ * If this hook is not implemented aggregator will use your module's file name
+ * as title and there will be no description.
+ *
+ * @return
+ * An associative array defining a title and a description string.
+ *
+ * @see hook_aggregator_fetch()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_fetch_info() {
+ return array(
+ 'title' => t('Default fetcher'),
+ 'description' => t('Default fetcher for resources available by URL.'),
+ );
+}
+
+/**
+ * Implement this hook to create an alternative parser for aggregator module.
+ *
+ * A parser converts feed item data to a common format. The parser is called
+ * at the second of the three aggregation stages: data is downloaded by the
+ * active fetcher, it is converted to a common format by the active parser and
+ * finally, it is passed to all active processors which manipulate or store the
+ * data.
+ *
+ * Modules that define this hook can be set as active parser on
+ * admin/config/services/aggregator. Only one parser can be active at a time.
+ *
+ * @param $feed
+ * The $feed object that describes the resource to be parsed.
+ * $feed->source_string contains the raw feed data as a string. Parse data
+ * from $feed->source_string and expose it to other modules as an array of
+ * data items on $feed->items.
+ *
+ * Feed format:
+ * - $feed->description (string) - description of the feed
+ * - $feed->image (string) - image for the feed
+ * - $feed->etag (string) - value of feed's entity tag header field
+ * - $feed->modified (UNIX timestamp) - value of feed's last modified header
+ * field
+ * - $feed->items (Array) - array of feed items.
+ *
+ * By convention, the common format for a single feed item is:
+ * $item[key-name] = value;
+ *
+ * Recognized keys:
+ * TITLE (string) - the title of a feed item
+ * DESCRIPTION (string) - the description (body text) of a feed item
+ * TIMESTAMP (UNIX timestamp) - the feed item's published time as UNIX timestamp
+ * AUTHOR (string) - the feed item's author
+ * GUID (string) - RSS/Atom global unique identifier
+ * LINK (string) - the feed item's URL
+ *
+ * @return
+ * TRUE if parsing was successful, FALSE otherwise.
+ *
+ * @see hook_aggregator_parse_info()
+ * @see hook_aggregator_fetch()
+ * @see hook_aggregator_process()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_parse($feed) {
+ if ($items = mymodule_parse($feed->source_string)) {
+ $feed->items = $items;
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implement this hook to expose the title and a short description of your
+ * parser.
+ *
+ * The title and the description provided are shown on
+ * admin/config/services/aggregator among other places. Use as title the human
+ * readable name of the parser and as description a brief (40 to 80 characters)
+ * explanation of the parser's functionality.
+ *
+ * This hook is only called if your module implements hook_aggregator_parse().
+ * If this hook is not implemented aggregator will use your module's file name
+ * as title and there will be no description.
+ *
+ * @return
+ * An associative array defining a title and a description string.
+ *
+ * @see hook_aggregator_parse()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_parse_info() {
+ return array(
+ 'title' => t('Default parser'),
+ 'description' => t('Default parser for RSS, Atom and RDF feeds.'),
+ );
+}
+
+/**
+ * Implement this hook to create a processor for aggregator module.
+ *
+ * A processor acts on parsed feed data. Active processors are called at the
+ * third and last of the aggregation stages: data is downloaded by the active
+ * fetcher, it is converted to a common format by the active parser and
+ * finally, it is passed to all active processors which manipulate or store the
+ * data.
+ *
+ * Modules that define this hook can be activated as processor on
+ * admin/config/services/aggregator.
+ *
+ * @param $feed
+ * The $feed object that describes the resource to be processed. $feed->items
+ * contains an array of feed items downloaded and parsed at the parsing
+ * stage. See hook_aggregator_parse() for the basic format of a single item
+ * in the $feed->items array. For the exact format refer to the particular
+ * parser in use.
+ *
+ * @see hook_aggregator_process_info()
+ * @see hook_aggregator_fetch()
+ * @see hook_aggregator_parse()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_process($feed) {
+ foreach ($feed->items as $item) {
+ mymodule_save($item);
+ }
+}
+
+/**
+ * Implement this hook to expose the title and a short description of your
+ * processor.
+ *
+ * The title and the description provided are shown most importantly on
+ * admin/config/services/aggregator. Use as title the natural name of the
+ * processor and as description a brief (40 to 80 characters) explanation of
+ * the functionality.
+ *
+ * This hook is only called if your module implements
+ * hook_aggregator_process(). If this hook is not implemented aggregator
+ * will use your module's file name as title and there will be no description.
+ *
+ * @return
+ * An associative array defining a title and a description string.
+ *
+ * @see hook_aggregator_process()
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_process_info($feed) {
+ return array(
+ 'title' => t('Default processor'),
+ 'description' => t('Creates lightweight records of feed items.'),
+ );
+}
+
+/**
+ * Implement this hook to remove stored data if a feed is being deleted or a
+ * feed's items are being removed.
+ *
+ * Aggregator calls this hook if either a feed is deleted or a user clicks on
+ * "remove items".
+ *
+ * If your module stores feed items for example on hook_aggregator_process() it
+ * is recommended to implement this hook and to remove data related to $feed
+ * when called.
+ *
+ * @param $feed
+ * The $feed object whose items are being removed.
+ *
+ * @ingroup aggregator
+ */
+function hook_aggregator_remove($feed) {
+ mymodule_remove_items($feed->fid);
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/aggregator/aggregator.fetcher.inc b/core/modules/aggregator/aggregator.fetcher.inc
new file mode 100644
index 000000000000..831ea78d27f9
--- /dev/null
+++ b/core/modules/aggregator/aggregator.fetcher.inc
@@ -0,0 +1,61 @@
+<?php
+
+/**
+ * @file
+ * Fetcher functions for the aggregator module.
+ */
+
+/**
+ * Implements hook_aggregator_fetch_info().
+ */
+function aggregator_aggregator_fetch_info() {
+ return array(
+ 'title' => t('Default fetcher'),
+ 'description' => t('Downloads data from a URL using Drupal\'s HTTP request handler.'),
+ );
+}
+
+/**
+ * Implements hook_aggregator_fetch().
+ */
+function aggregator_aggregator_fetch($feed) {
+ $feed->source_string = FALSE;
+
+ // Generate conditional GET headers.
+ $headers = array();
+ if ($feed->etag) {
+ $headers['If-None-Match'] = $feed->etag;
+ }
+ if ($feed->modified) {
+ $headers['If-Modified-Since'] = gmdate(DATE_RFC1123, $feed->modified);
+ }
+
+ // Request feed.
+ $result = drupal_http_request($feed->url, array('headers' => $headers));
+
+ // Process HTTP response code.
+ switch ($result->code) {
+ case 304:
+ break;
+ case 301:
+ $feed->url = $result->redirect_url;
+ // Do not break here.
+ case 200:
+ case 302:
+ case 307:
+ if (!isset($result->data)) {
+ $result->data = '';
+ }
+ if (!isset($result->headers)) {
+ $result->headers = array();
+ }
+ $feed->source_string = $result->data;
+ $feed->http_headers = $result->headers;
+ break;
+ default:
+ watchdog('aggregator', 'The feed from %site seems to be broken due to "%error".', array('%site' => $feed->title, '%error' => $result->code . ' ' . $result->error), WATCHDOG_WARNING);
+ drupal_set_message(t('The feed from %site seems to be broken because of error "%error".', array('%site' => $feed->title, '%error' => $result->code . ' ' . $result->error)));
+ }
+
+ return $feed->source_string === FALSE ? FALSE : TRUE;
+}
diff --git a/core/modules/aggregator/aggregator.info b/core/modules/aggregator/aggregator.info
new file mode 100644
index 000000000000..91357ca6c873
--- /dev/null
+++ b/core/modules/aggregator/aggregator.info
@@ -0,0 +1,8 @@
+name = Aggregator
+description = "Aggregates syndicated content (RSS, RDF, and Atom feeds) from external sources."
+package = Core
+version = VERSION
+core = 8.x
+files[] = aggregator.test
+configure = admin/config/services/aggregator/settings
+stylesheets[all][] = aggregator.theme.css
diff --git a/core/modules/aggregator/aggregator.install b/core/modules/aggregator/aggregator.install
new file mode 100644
index 000000000000..eecd14fb27f1
--- /dev/null
+++ b/core/modules/aggregator/aggregator.install
@@ -0,0 +1,280 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the aggregator module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function aggregator_uninstall() {
+ variable_del('aggregator_allowed_html_tags');
+ variable_del('aggregator_summary_items');
+ variable_del('aggregator_clear');
+ variable_del('aggregator_category_selector');
+ variable_del('aggregator_fetcher');
+ variable_del('aggregator_parser');
+ variable_del('aggregator_processors');
+ variable_del('aggregator_teaser_length');
+}
+
+/**
+ * Implements hook_schema().
+ */
+function aggregator_schema() {
+ $schema['aggregator_category'] = array(
+ 'description' => 'Stores categories for aggregator feeds and feed items.',
+ 'fields' => array(
+ 'cid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique aggregator category ID.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Title of the category.',
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Description of the category',
+ ),
+ 'block' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'The number of recent items to show within the category block.',
+ )
+ ),
+ 'primary key' => array('cid'),
+ 'unique keys' => array(
+ 'title' => array('title'),
+ ),
+ );
+
+ $schema['aggregator_category_feed'] = array(
+ 'description' => 'Bridge table; maps feeds to categories.',
+ 'fields' => array(
+ 'fid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The feed's {aggregator_feed}.fid.",
+ ),
+ 'cid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {aggregator_category}.cid to which the feed is being assigned.',
+ )
+ ),
+ 'primary key' => array('cid', 'fid'),
+ 'indexes' => array(
+ 'fid' => array('fid'),
+ ),
+ 'foreign keys' => array(
+ 'aggregator_category' => array(
+ 'table' => 'aggregator_category',
+ 'columns' => array('cid' => 'cid'),
+ ),
+ ),
+ );
+
+ $schema['aggregator_category_item'] = array(
+ 'description' => 'Bridge table; maps feed items to categories.',
+ 'fields' => array(
+ 'iid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The feed item's {aggregator_item}.iid.",
+ ),
+ 'cid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {aggregator_category}.cid to which the feed item is being assigned.',
+ )
+ ),
+ 'primary key' => array('cid', 'iid'),
+ 'indexes' => array(
+ 'iid' => array('iid'),
+ ),
+ 'foreign keys' => array(
+ 'aggregator_category' => array(
+ 'table' => 'aggregator_category',
+ 'columns' => array('cid' => 'cid'),
+ ),
+ ),
+ );
+
+ $schema['aggregator_feed'] = array(
+ 'description' => 'Stores feeds to be parsed by the aggregator.',
+ 'fields' => array(
+ 'fid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique feed ID.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Title of the feed.',
+ ),
+ 'url' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'URL to the feed.',
+ ),
+ 'refresh' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'How often to check for new feed items, in seconds.',
+ ),
+ 'checked' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Last time feed was checked for new items, as Unix timestamp.',
+ ),
+ 'queued' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Time when this feed was queued for refresh, 0 if not queued.',
+ ),
+ 'link' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The parent website of the feed; comes from the <link> element in the feed.',
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => "The parent website's description; comes from the <description> element in the feed.",
+ ),
+ 'image' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'An image representing the feed.',
+ ),
+ 'hash' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Calculated hash of the feed data, used for validating cache.',
+ ),
+ 'etag' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Entity tag HTTP response header, used for validating cache.',
+ ),
+ 'modified' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'When the feed was last modified, as a Unix timestamp.',
+ ),
+ 'block' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => "Number of items to display in the feed's block.",
+ )
+ ),
+ 'primary key' => array('fid'),
+ 'unique keys' => array(
+ 'url' => array('url'),
+ 'title' => array('title'),
+ ),
+ 'indexes' => array(
+ 'queued' => array('queued'),
+ ),
+ );
+
+ $schema['aggregator_item'] = array(
+ 'description' => 'Stores the individual items imported from feeds.',
+ 'fields' => array(
+ 'iid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique ID for feed item.',
+ ),
+ 'fid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {aggregator_feed}.fid to which this item belongs.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Title of the feed item.',
+ ),
+ 'link' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Link to the feed item.',
+ ),
+ 'author' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Author of the feed item.',
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Body of the feed item.',
+ ),
+ 'timestamp' => array(
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'description' => 'Posted date of the feed item, as a Unix timestamp.',
+ ),
+ 'guid' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'Unique identifier for the feed item.',
+ )
+ ),
+ 'primary key' => array('iid'),
+ 'indexes' => array(
+ 'fid' => array('fid'),
+ ),
+ 'foreign keys' => array(
+ 'aggregator_feed' => array(
+ 'table' => 'aggregator_feed',
+ 'columns' => array('fid' => 'fid'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/aggregator/aggregator.module b/core/modules/aggregator/aggregator.module
new file mode 100644
index 000000000000..f247d26a33f1
--- /dev/null
+++ b/core/modules/aggregator/aggregator.module
@@ -0,0 +1,767 @@
+<?php
+
+/**
+ * @file
+ * Used to aggregate syndicated content (RSS, RDF, and Atom).
+ */
+
+/**
+ * Denotes that a feed's items should never expire.
+ */
+define('AGGREGATOR_CLEAR_NEVER', 0);
+
+/**
+ * Implements hook_help().
+ */
+function aggregator_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#aggregator':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Aggregator module is an on-site syndicator and news reader that gathers and displays fresh content from RSS-, RDF-, and Atom-based feeds made available across the web. Thousands of sites (particularly news sites and blogs) publish their latest headlines in feeds, using a number of standardized XML-based formats. For more information, see the online handbook entry for <a href="@aggregator-module">Aggregator module</a>.', array('@aggregator-module' => 'http://drupal.org/handbook/modules/aggregator', '@aggregator' => url('aggregator'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Viewing feeds') . '</dt>';
+ $output .= '<dd>' . t('Feeds contain published content, and may be grouped in categories, generally by topic. Users view feed content in the <a href="@aggregator">main aggregator display</a>, or by <a href="@aggregator-sources">their source</a> (usually via an RSS feed reader). The most recent content in a feed or category can be displayed as a block through the <a href="@admin-block">Blocks administration page</a>.', array('@aggregator' => url('aggregator'), '@aggregator-sources' => url('aggregator/sources'), '@admin-block' => url('admin/structure/block'))) . '</a></dd>';
+ $output .= '<dt>' . t('Adding, editing, and deleting feeds') . '</dt>';
+ $output .= '<dd>' . t('Administrators can add, edit, and delete feeds, and choose how often to check each feed for newly updated items on the <a href="@feededit">Feed aggregator administration page</a>.', array('@feededit' => url('admin/config/services/aggregator'))) . '</dd>';
+ $output .= '<dt>' . t('OPML integration') . '</dt>';
+ $output .= '<dd>' . t('A <a href="@aggregator-opml">machine-readable OPML file</a> of all feeds is available. OPML is an XML-based file format used to share outline-structured information such as a list of RSS feeds. Feeds can also be <a href="@import-opml">imported via an OPML file</a>.', array('@aggregator-opml' => url('aggregator/opml'), '@import-opml' => url('admin/config/services/aggregator'))) . '</dd>';
+ $output .= '<dt>' . t('Configuring cron') . '</dt>';
+ $output .= '<dd>' . t('A correctly configured <a href="@cron">cron maintenance task</a> is required to update feeds automatically.', array('@cron' => 'http://drupal.org/cron')) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/config/services/aggregator':
+ $output = '<p>' . t('Thousands of sites (particularly news sites and blogs) publish their latest headlines and posts in feeds, using a number of standardized XML-based formats. Formats supported by the aggregator include <a href="@rss">RSS</a>, <a href="@rdf">RDF</a>, and <a href="@atom">Atom</a>.', array('@rss' => 'http://cyber.law.harvard.edu/rss/', '@rdf' => 'http://www.w3.org/RDF/', '@atom' => 'http://www.atomenabled.org')) . '</p>';
+ $output .= '<p>' . t('Current feeds are listed below, and <a href="@addfeed">new feeds may be added</a>. For each feed or feed category, the <em>latest items</em> block may be enabled at the <a href="@block">blocks administration page</a>.', array('@addfeed' => url('admin/config/services/aggregator/add/feed'), '@block' => url('admin/structure/block'))) . '</p>';
+ return $output;
+ case 'admin/config/services/aggregator/add/feed':
+ return '<p>' . t('Add a feed in RSS, RDF or Atom format. A feed may only have one entry.') . '</p>';
+ case 'admin/config/services/aggregator/add/category':
+ return '<p>' . t('Categories allow feed items from different feeds to be grouped together. For example, several sport-related feeds may belong to a category named <em>Sports</em>. Feed items may be grouped automatically (by selecting a category when creating or editing a feed) or manually (via the <em>Categorize</em> page available from feed item listings). Each category provides its own feed page and block.') . '</p>';
+ case 'admin/config/services/aggregator/add/opml':
+ return '<p>' . t('<acronym title="Outline Processor Markup Language">OPML</acronym> is an XML format used to exchange multiple feeds between aggregators. A single OPML document may contain a collection of many feeds. Drupal can parse such a file and import all feeds at once, saving you the effort of adding them manually. You may either upload a local file from your computer or enter a URL where Drupal can download it.') . '</p>';
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function aggregator_theme() {
+ return array(
+ 'aggregator_wrapper' => array(
+ 'variables' => array('content' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ 'template' => 'aggregator-wrapper',
+ ),
+ 'aggregator_categorize_items' => array(
+ 'render element' => 'form',
+ 'file' => 'aggregator.pages.inc',
+ ),
+ 'aggregator_feed_source' => array(
+ 'variables' => array('feed' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ 'template' => 'aggregator-feed-source',
+ ),
+ 'aggregator_block_item' => array(
+ 'variables' => array('item' => NULL, 'feed' => 0),
+ ),
+ 'aggregator_summary_items' => array(
+ 'variables' => array('summary_items' => NULL, 'source' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ 'template' => 'aggregator-summary-items',
+ ),
+ 'aggregator_summary_item' => array(
+ 'variables' => array('item' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ 'template' => 'aggregator-summary-item',
+ ),
+ 'aggregator_item' => array(
+ 'variables' => array('item' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ 'template' => 'aggregator-item',
+ ),
+ 'aggregator_page_opml' => array(
+ 'variables' => array('feeds' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ ),
+ 'aggregator_page_rss' => array(
+ 'variables' => array('feeds' => NULL, 'category' => NULL),
+ 'file' => 'aggregator.pages.inc',
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function aggregator_menu() {
+ $items['admin/config/services/aggregator'] = array(
+ 'title' => 'Feed aggregator',
+ 'description' => "Configure which content your site aggregates from other sites, how often it polls them, and how they're categorized.",
+ 'page callback' => 'aggregator_admin_overview',
+ 'access arguments' => array('administer news feeds'),
+ 'weight' => 10,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/add/feed'] = array(
+ 'title' => 'Add feed',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_feed'),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/add/category'] = array(
+ 'title' => 'Add category',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_category'),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/add/opml'] = array(
+ 'title' => 'Import OPML',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_opml'),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/remove/%aggregator_feed'] = array(
+ 'title' => 'Remove items',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_admin_remove_feed', 5),
+ 'access arguments' => array('administer news feeds'),
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/update/%aggregator_feed'] = array(
+ 'title' => 'Update items',
+ 'page callback' => 'aggregator_admin_refresh_feed',
+ 'page arguments' => array(5),
+ 'access arguments' => array('administer news feeds'),
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/config/services/aggregator/settings'] = array(
+ 'title' => 'Settings',
+ 'description' => 'Configure the behavior of the feed aggregator, including when to discard feed items and how to present feed items and categories.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_admin_form'),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['aggregator'] = array(
+ 'title' => 'Feed aggregator',
+ 'page callback' => 'aggregator_page_last',
+ 'access arguments' => array('access news feeds'),
+ 'weight' => 5,
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/sources'] = array(
+ 'title' => 'Sources',
+ 'page callback' => 'aggregator_page_sources',
+ 'access arguments' => array('access news feeds'),
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/categories'] = array(
+ 'title' => 'Categories',
+ 'page callback' => 'aggregator_page_categories',
+ 'access callback' => '_aggregator_has_categories',
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/rss'] = array(
+ 'title' => 'RSS feed',
+ 'page callback' => 'aggregator_page_rss',
+ 'access arguments' => array('access news feeds'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/opml'] = array(
+ 'title' => 'OPML feed',
+ 'page callback' => 'aggregator_page_opml',
+ 'access arguments' => array('access news feeds'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/categories/%aggregator_category'] = array(
+ 'title callback' => '_aggregator_category_title',
+ 'title arguments' => array(2),
+ 'page callback' => 'aggregator_page_category',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access news feeds'),
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/categories/%aggregator_category/view'] = array(
+ 'title' => 'View',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['aggregator/categories/%aggregator_category/categorize'] = array(
+ 'title' => 'Categorize',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_page_category_form', 2),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/categories/%aggregator_category/configure'] = array(
+ 'title' => 'Configure',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_category', 2),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 1,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['aggregator/sources/%aggregator_feed'] = array(
+ 'page callback' => 'aggregator_page_source',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access news feeds'),
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/sources/%aggregator_feed/view'] = array(
+ 'title' => 'View',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['aggregator/sources/%aggregator_feed/categorize'] = array(
+ 'title' => 'Categorize',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_page_source_form', 2),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'aggregator.pages.inc',
+ );
+ $items['aggregator/sources/%aggregator_feed/configure'] = array(
+ 'title' => 'Configure',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_feed', 2),
+ 'access arguments' => array('administer news feeds'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 1,
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/edit/feed/%aggregator_feed'] = array(
+ 'title' => 'Edit feed',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_feed', 6),
+ 'access arguments' => array('administer news feeds'),
+ 'file' => 'aggregator.admin.inc',
+ );
+ $items['admin/config/services/aggregator/edit/category/%aggregator_category'] = array(
+ 'title' => 'Edit category',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('aggregator_form_category', 6),
+ 'access arguments' => array('administer news feeds'),
+ 'file' => 'aggregator.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Title callback for aggregatory category pages.
+ *
+ * @return
+ * An aggregator category title.
+ */
+function _aggregator_category_title($category) {
+ return $category['title'];
+}
+
+/**
+ * Find out whether there are any aggregator categories.
+ *
+ * @return
+ * TRUE if there is at least one category and the user has access to them, FALSE
+ * otherwise.
+ */
+function _aggregator_has_categories() {
+ return user_access('access news feeds') && (bool) db_query_range('SELECT 1 FROM {aggregator_category}', 0, 1)->fetchField();
+}
+
+/**
+ * Implements hook_permission().
+ */
+function aggregator_permission() {
+ return array(
+ 'administer news feeds' => array(
+ 'title' => t('Administer news feeds'),
+ ),
+ 'access news feeds' => array(
+ 'title' => t('View news feeds'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Queues news feeds for updates once their refresh interval has elapsed.
+ */
+function aggregator_cron() {
+ $result = db_query('SELECT * FROM {aggregator_feed} WHERE queued = 0 AND checked + refresh < :time AND refresh <> :never', array(
+ ':time' => REQUEST_TIME,
+ ':never' => AGGREGATOR_CLEAR_NEVER
+ ));
+ $queue = DrupalQueue::get('aggregator_feeds');
+ foreach ($result as $feed) {
+ if ($queue->createItem($feed)) {
+ // Add timestamp to avoid queueing item more than once.
+ db_update('aggregator_feed')
+ ->fields(array('queued' => REQUEST_TIME))
+ ->condition('fid', $feed->fid)
+ ->execute();
+ }
+ }
+
+ // Remove queued timestamp after 6 hours assuming the update has failed.
+ db_update('aggregator_feed')
+ ->fields(array('queued' => 0))
+ ->condition('queued', REQUEST_TIME - (3600 * 6), '<')
+ ->execute();
+}
+
+/**
+ * Implements hook_cron_queue_info().
+ */
+function aggregator_cron_queue_info() {
+ $queues['aggregator_feeds'] = array(
+ 'worker callback' => 'aggregator_refresh',
+ 'time' => 60,
+ );
+ return $queues;
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function aggregator_block_info() {
+ $blocks = array();
+ $result = db_query('SELECT cid, title FROM {aggregator_category} ORDER BY title');
+ foreach ($result as $category) {
+ $blocks['category-' . $category->cid]['info'] = t('!title category latest items', array('!title' => $category->title));
+ }
+ $result = db_query('SELECT fid, title FROM {aggregator_feed} WHERE block <> 0 ORDER BY fid');
+ foreach ($result as $feed) {
+ $blocks['feed-' . $feed->fid]['info'] = t('!title feed latest items', array('!title' => $feed->title));
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function aggregator_block_configure($delta = '') {
+ list($type, $id) = explode('-', $delta);
+ if ($type == 'category') {
+ $value = db_query('SELECT block FROM {aggregator_category} WHERE cid = :cid', array(':cid' => $id))->fetchField();
+ $form['block'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of news items in block'),
+ '#default_value' => $value,
+ '#options' => drupal_map_assoc(range(2, 20)),
+ );
+ return $form;
+ }
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function aggregator_block_save($delta = '', $edit = array()) {
+ list($type, $id) = explode('-', $delta);
+ if ($type == 'category') {
+ db_update('aggregator_category')
+ ->fields(array('block' => $edit['block']))
+ ->condition('cid', $id)
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generates blocks for the latest news items in each category and feed.
+ */
+function aggregator_block_view($delta = '') {
+ if (user_access('access news feeds')) {
+ $block = array();
+ list($type, $id) = explode('-', $delta);
+ switch ($type) {
+ case 'feed':
+ if ($feed = db_query('SELECT fid, title, block FROM {aggregator_feed} WHERE block <> 0 AND fid = :fid', array(':fid' => $id))->fetchObject()) {
+ $block['subject'] = check_plain($feed->title);
+ $result = db_query_range("SELECT * FROM {aggregator_item} WHERE fid = :fid ORDER BY timestamp DESC, iid DESC", 0, $feed->block, array(':fid' => $id));
+ $read_more = theme('more_link', array('url' => 'aggregator/sources/' . $feed->fid, 'title' => t("View this feed's recent news.")));
+ }
+ break;
+
+ case 'category':
+ if ($category = db_query('SELECT cid, title, block FROM {aggregator_category} WHERE cid = :cid', array(':cid' => $id))->fetchObject()) {
+ $block['subject'] = check_plain($category->title);
+ $result = db_query_range('SELECT i.* FROM {aggregator_category_item} ci LEFT JOIN {aggregator_item} i ON ci.iid = i.iid WHERE ci.cid = :cid ORDER BY i.timestamp DESC, i.iid DESC', 0, $category->block, array(':cid' => $category->cid));
+ $read_more = theme('more_link', array('url' => 'aggregator/categories/' . $category->cid, 'title' => t("View this category's recent news.")));
+ }
+ break;
+ }
+ $items = array();
+ foreach ($result as $item) {
+ $items[] = theme('aggregator_block_item', array('item' => $item));
+ }
+
+ // Only display the block if there are items to show.
+ if (count($items) > 0) {
+ $block['content'] = theme('item_list', array('items' => $items)) . $read_more;
+ }
+ return $block;
+ }
+}
+
+/**
+ * Add/edit/delete aggregator categories.
+ *
+ * @param $edit
+ * An associative array describing the category to be added/edited/deleted.
+ */
+function aggregator_save_category($edit) {
+ $link_path = 'aggregator/categories/';
+ if (!empty($edit['cid'])) {
+ $link_path .= $edit['cid'];
+ if (!empty($edit['title'])) {
+ db_merge('aggregator_category')
+ ->key(array('cid' => $edit['cid']))
+ ->fields(array(
+ 'title' => $edit['title'],
+ 'description' => $edit['description'],
+ ))
+ ->execute();
+ $op = 'update';
+ }
+ else {
+ db_delete('aggregator_category')
+ ->condition('cid', $edit['cid'])
+ ->execute();
+ // Make sure there is no active block for this category.
+ db_delete('block')
+ ->condition('module', 'aggregator')
+ ->condition('delta', 'category-' . $edit['cid'])
+ ->execute();
+ $edit['title'] = '';
+ $op = 'delete';
+ }
+ }
+ elseif (!empty($edit['title'])) {
+ // A single unique id for bundles and feeds, to use in blocks.
+ $link_path .= db_insert('aggregator_category')
+ ->fields(array(
+ 'title' => $edit['title'],
+ 'description' => $edit['description'],
+ 'block' => 5,
+ ))
+ ->execute();
+ $op = 'insert';
+ }
+ if (isset($op)) {
+ menu_link_maintain('aggregator', $op, $link_path, $edit['title']);
+ }
+}
+
+/**
+ * Add/edit/delete an aggregator feed.
+ *
+ * @param $edit
+ * An associative array describing the feed to be added/edited/deleted.
+ * @return
+ * The ID of the feed.
+ */
+function aggregator_save_feed($edit) {
+ if (!empty($edit['fid'])) {
+ // An existing feed is being modified, delete the category listings.
+ db_delete('aggregator_category_feed')
+ ->condition('fid', $edit['fid'])
+ ->execute();
+ }
+ if (!empty($edit['fid']) && !empty($edit['title'])) {
+ db_update('aggregator_feed')
+ ->condition('fid', $edit['fid'])
+ ->fields(array(
+ 'title' => $edit['title'],
+ 'url' => $edit['url'],
+ 'refresh' => $edit['refresh'],
+ 'block' => $edit['block'],
+ ))
+ ->execute();
+ }
+ elseif (!empty($edit['fid'])) {
+ $iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $edit['fid']))->fetchCol();
+ if ($iids) {
+ db_delete('aggregator_category_item')
+ ->condition('iid', $iids, 'IN')
+ ->execute();
+ }
+ db_delete('aggregator_feed')->
+ condition('fid', $edit['fid'])
+ ->execute();
+ db_delete('aggregator_item')
+ ->condition('fid', $edit['fid'])
+ ->execute();
+ // Make sure there is no active block for this feed.
+ db_delete('block')
+ ->condition('module', 'aggregator')
+ ->condition('delta', 'feed-' . $edit['fid'])
+ ->execute();
+ }
+ elseif (!empty($edit['title'])) {
+ $edit['fid'] = db_insert('aggregator_feed')
+ ->fields(array(
+ 'title' => $edit['title'],
+ 'url' => $edit['url'],
+ 'refresh' => $edit['refresh'],
+ 'block' => $edit['block'],
+ 'description' => '',
+ 'image' => '',
+ ))
+ ->execute();
+
+ }
+ if (!empty($edit['title'])) {
+ // The feed is being saved, save the categories as well.
+ if (!empty($edit['category'])) {
+ foreach ($edit['category'] as $cid => $value) {
+ if ($value) {
+ db_insert('aggregator_category_feed')
+ ->fields(array(
+ 'fid' => $edit['fid'],
+ 'cid' => $cid,
+ ))
+ ->execute();
+ }
+ }
+ }
+ }
+
+ return $edit['fid'];
+}
+
+/**
+ * Removes all items from a feed.
+ *
+ * @param $feed
+ * An object describing the feed to be cleared.
+ */
+function aggregator_remove($feed) {
+ _aggregator_get_variables();
+ // Call hook_aggregator_remove() on all modules.
+ module_invoke_all('aggregator_remove', $feed);
+ // Reset feed.
+ db_merge('aggregator_feed')
+ ->key(array('fid' => $feed->fid))
+ ->fields(array(
+ 'checked' => 0,
+ 'hash' => '',
+ 'etag' => '',
+ 'modified' => 0,
+ 'description' => $feed->description,
+ 'image' => $feed->image,
+ ))
+ ->execute();
+}
+
+function _aggregator_get_variables() {
+ // Fetch the feed.
+ $fetcher = variable_get('aggregator_fetcher', 'aggregator');
+ if ($fetcher == 'aggregator') {
+ include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/aggregator.fetcher.inc';
+ }
+ $parser = variable_get('aggregator_parser', 'aggregator');
+ if ($parser == 'aggregator') {
+ include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/aggregator.parser.inc';
+ }
+ $processors = variable_get('aggregator_processors', array('aggregator'));
+ if (in_array('aggregator', $processors)) {
+ include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/aggregator.processor.inc';
+ }
+ return array($fetcher, $parser, $processors);
+}
+
+/**
+ * Checks a news feed for new items.
+ *
+ * @param $feed
+ * An object describing the feed to be refreshed.
+ */
+function aggregator_refresh($feed) {
+ // Store feed URL to track changes.
+ $feed_url = $feed->url;
+
+ // Fetch the feed.
+ list($fetcher, $parser, $processors) = _aggregator_get_variables();
+ $success = module_invoke($fetcher, 'aggregator_fetch', $feed);
+
+ // We store the hash of feed data in the database. When refreshing a
+ // feed we compare stored hash and new hash calculated from downloaded
+ // data. If both are equal we say that feed is not updated.
+ $hash = hash('sha256', $feed->source_string);
+
+ if ($success && ($feed->hash != $hash)) {
+ // Parse the feed.
+ if (module_invoke($parser, 'aggregator_parse', $feed)) {
+ // Update feed with parsed data.
+ db_merge('aggregator_feed')
+ ->key(array('fid' => $feed->fid))
+ ->fields(array(
+ 'url' => $feed->url,
+ 'link' => empty($feed->link) ? $feed->url : $feed->link,
+ 'description' => empty($feed->description) ? '' : $feed->description,
+ 'image' => empty($feed->image) ? '' : $feed->image,
+ 'hash' => $hash,
+ 'etag' => empty($feed->etag) ? '' : $feed->etag,
+ 'modified' => empty($feed->modified) ? 0 : $feed->modified,
+ ))
+ ->execute();
+
+ // Log if feed URL has changed.
+ if ($feed->url != $feed_url) {
+ watchdog('aggregator', 'Updated URL for feed %title to %url.', array('%title' => $feed->title, '%url' => $feed->url));
+ }
+
+ watchdog('aggregator', 'There is new syndicated content from %site.', array('%site' => $feed->title));
+ drupal_set_message(t('There is new syndicated content from %site.', array('%site' => $feed->title)));
+
+ // If there are items on the feed, let all enabled processors do their work on it.
+ if (@count($feed->items)) {
+ foreach ($processors as $processor) {
+ module_invoke($processor, 'aggregator_process', $feed);
+ }
+ }
+ }
+ }
+ else {
+ drupal_set_message(t('There is no new syndicated content from %site.', array('%site' => $feed->title)));
+ }
+
+ // Regardless of successful or not, indicate that this feed has been checked.
+ db_update('aggregator_feed')
+ ->fields(array('checked' => REQUEST_TIME, 'queued' => 0))
+ ->condition('fid', $feed->fid)
+ ->execute();
+
+ // Expire old feed items.
+ if (function_exists('aggregator_expire')) {
+ aggregator_expire($feed);
+ }
+}
+
+/**
+ * Load an aggregator feed.
+ *
+ * @param $fid
+ * The feed id.
+ * @return
+ * An object describing the feed.
+ */
+function aggregator_feed_load($fid) {
+ $feeds = &drupal_static(__FUNCTION__);
+ if (!isset($feeds[$fid])) {
+ $feeds[$fid] = db_query('SELECT * FROM {aggregator_feed} WHERE fid = :fid', array(':fid' => $fid))->fetchObject();
+ }
+
+ return $feeds[$fid];
+}
+
+/**
+ * Load an aggregator category.
+ *
+ * @param $cid
+ * The category id.
+ * @return
+ * An associative array describing the category.
+ */
+function aggregator_category_load($cid) {
+ $categories = &drupal_static(__FUNCTION__);
+ if (!isset($categories[$cid])) {
+ $categories[$cid] = db_query('SELECT * FROM {aggregator_category} WHERE cid = :cid', array(':cid' => $cid))->fetchAssoc();
+ }
+
+ return $categories[$cid];
+}
+
+/**
+ * Returns HTML for an individual feed item for display in the block.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - item: The item to be displayed.
+ * - feed: Not used.
+ *
+ * @ingroup themeable
+ */
+function theme_aggregator_block_item($variables) {
+ // Display the external link to the item.
+ return '<a href="' . check_url($variables['item']->link) . '">' . check_plain($variables['item']->title) . "</a>\n";
+}
+
+/**
+ * Safely render HTML content, as allowed.
+ *
+ * @param $value
+ * The content to be filtered.
+ * @return
+ * The filtered content.
+ */
+function aggregator_filter_xss($value) {
+ return filter_xss($value, preg_split('/\s+|<|>/', variable_get('aggregator_allowed_html_tags', '<a> <b> <br> <dd> <dl> <dt> <em> <i> <li> <ol> <p> <strong> <u> <ul>'), -1, PREG_SPLIT_NO_EMPTY));
+}
+
+/**
+ * Check and sanitize aggregator configuration.
+ *
+ * Goes through all fetchers, parsers and processors and checks whether they are
+ * available.
+ * If one is missing resets to standard configuration.
+ *
+ * @return
+ * TRUE if this function reset the configuration FALSE if not.
+ */
+function aggregator_sanitize_configuration() {
+ $reset = FALSE;
+ list($fetcher, $parser, $processors) = _aggregator_get_variables();
+ if (!module_exists($fetcher)) {
+ $reset = TRUE;
+ }
+ if (!module_exists($parser)) {
+ $reset = TRUE;
+ }
+ foreach ($processors as $processor) {
+ if (!module_exists($processor)) {
+ $reset = TRUE;
+ break;
+ }
+ }
+ if ($reset) {
+ variable_del('aggregator_fetcher');
+ variable_del('aggregator_parser');
+ variable_del('aggregator_processors');
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Helper function for drupal_map_assoc.
+ *
+ * @param $count
+ * Items count.
+ * @return
+ * Plural-formatted "@count items"
+ */
+function _aggregator_items($count) {
+ return format_plural($count, '1 item', '@count items');
+}
diff --git a/core/modules/aggregator/aggregator.pages.inc b/core/modules/aggregator/aggregator.pages.inc
new file mode 100644
index 000000000000..228953b7a580
--- /dev/null
+++ b/core/modules/aggregator/aggregator.pages.inc
@@ -0,0 +1,531 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the aggregator module.
+ */
+
+/**
+ * Menu callback; displays the most recent items gathered from any feed.
+ *
+ * @return
+ * The items HTML.
+ */
+function aggregator_page_last() {
+ drupal_add_feed('aggregator/rss', variable_get('site_name', 'Drupal') . ' ' . t('aggregator'));
+
+ $items = aggregator_load_feed_items('sum');
+
+ return _aggregator_page_list($items, arg(1));
+}
+
+/**
+ * Menu callback; displays all the items captured from a particular feed.
+ *
+ * @param $feed
+ * The feed for which to display all items.
+ *
+ * @return
+ * The rendered list of items for a feed.
+ */
+function aggregator_page_source($feed) {
+ drupal_set_title($feed->title);
+ $feed_source = theme('aggregator_feed_source', array('feed' => $feed));
+
+ // It is safe to include the fid in the query because it's loaded from the
+ // database by aggregator_feed_load().
+ $items = aggregator_load_feed_items('source', $feed);
+
+ return _aggregator_page_list($items, arg(3), $feed_source);
+}
+
+/**
+ * Menu callback; displays a form with all items captured from a feed.
+ *
+ * @param $feed
+ * The feed for which to list all the aggregated items.
+ *
+ * @return
+ * The rendered list of items for a feed.
+ *
+ * @see aggregator_page_source()
+ */
+function aggregator_page_source_form($form, $form_state, $feed) {
+ return aggregator_page_source($feed);
+}
+
+/**
+ * Menu callback; displays all the items aggregated in a particular category.
+ *
+ * @param $category
+ * The category for which to list all the aggregated items.
+ *
+ * @return
+* The rendered list of items for a category.
+ */
+function aggregator_page_category($category) {
+ drupal_add_feed('aggregator/rss/' . $category['cid'], variable_get('site_name', 'Drupal') . ' ' . t('aggregator - @title', array('@title' => $category['title'])));
+
+ // It is safe to include the cid in the query because it's loaded from the
+ // database by aggregator_category_load().
+ $items = aggregator_load_feed_items('category', $category);
+
+ return _aggregator_page_list($items, arg(3));
+}
+
+/**
+ * Menu callback; displays a form containing items aggregated in a category.
+ *
+ * @param $category
+ * The category for which to list all the aggregated items.
+ *
+ * @return
+* The rendered list of items for a category.
+ *
+ * @see aggregator_page_category()
+ */
+function aggregator_page_category_form($form, $form_state, $category) {
+ return aggregator_page_category($category);
+}
+
+/**
+ * Load feed items
+ *
+ * @param $type
+ * The filter for the items. Possible values: 'sum', 'source', 'category'
+ * @param $data
+ * Feed or category data for filtering
+ * @return
+ * An array of the feed items.
+ */
+function aggregator_load_feed_items($type, $data = NULL) {
+ $items = array();
+ $range_limit = 20;
+ switch ($type) {
+ case 'sum':
+ $result = db_query_range('SELECT i.*, f.title AS ftitle, f.link AS flink FROM {aggregator_item} i INNER JOIN {aggregator_feed} f ON i.fid = f.fid ORDER BY i.timestamp DESC, i.iid DESC', 0, $range_limit);
+ break;
+ case 'source':
+ $result = db_query_range('SELECT * FROM {aggregator_item} WHERE fid = :fid ORDER BY timestamp DESC, iid DESC', 0, $range_limit, array(':fid' => $data->fid));
+ break;
+ case 'category':
+ $result = db_query_range('SELECT i.*, f.title AS ftitle, f.link AS flink FROM {aggregator_category_item} c LEFT JOIN {aggregator_item} i ON c.iid = i.iid LEFT JOIN {aggregator_feed} f ON i.fid = f.fid WHERE cid = :cid ORDER BY timestamp DESC, i.iid DESC', 0, $range_limit, array(':cid' => $data['cid']));
+ break;
+ }
+
+ foreach ($result as $item) {
+ $item->categories = db_query('SELECT c.title, c.cid FROM {aggregator_category_item} ci LEFT JOIN {aggregator_category} c ON ci.cid = c.cid WHERE ci.iid = :iid ORDER BY c.title', array(':iid' => $item->iid))->fetchAll();
+ $items[] = $item;
+ }
+
+ return $items;
+}
+
+/**
+ * Prints an aggregator page listing a number of feed items.
+ *
+ * Various menu callbacks use this function to print their feeds.
+ *
+ * @param $items
+ * The items to be listed.
+ * @param $op
+ * Which form should be added to the items. Only 'categorize' is now recognized.
+ * @param $feed_source
+ * The feed source URL.
+ * @return
+ * The items HTML.
+ */
+function _aggregator_page_list($items, $op, $feed_source = '') {
+ if (user_access('administer news feeds') && ($op == 'categorize')) {
+ // Get form data.
+ $output = aggregator_categorize_items($items, $feed_source);
+ }
+ else {
+ // Assemble themed output.
+ $output = $feed_source;
+ foreach ($items as $item) {
+ $output .= theme('aggregator_item', array('item' => $item));
+ }
+ $output = theme('aggregator_wrapper', array('content' => $output));
+ }
+
+ return $output;
+}
+
+/**
+ * Form builder; build the page list form.
+ *
+ * @param $items
+ * An array of the feed items.
+ * @param $feed_source
+ * The feed source URL.
+ * @return
+ * The form structure.
+ * @ingroup forms
+ * @see aggregator_categorize_items_submit()
+ */
+function aggregator_categorize_items($items, $feed_source = '') {
+ $form['#submit'][] = 'aggregator_categorize_items_submit';
+ $form['#theme'] = 'aggregator_categorize_items';
+ $form['feed_source'] = array(
+ '#value' => $feed_source,
+ );
+ $categories = array();
+ $done = FALSE;
+ $form['items'] = array();
+ $form['categories'] = array(
+ '#tree' => TRUE,
+ );
+ foreach ($items as $item) {
+ $form['items'][$item->iid] = array('#markup' => theme('aggregator_item', array('item' => $item)));
+ $form['categories'][$item->iid] = array();
+ $categories_result = db_query('SELECT c.cid, c.title, ci.iid FROM {aggregator_category} c LEFT JOIN {aggregator_category_item} ci ON c.cid = ci.cid AND ci.iid = :iid', array(':iid' => $item->iid));
+ $selected = array();
+ foreach ($categories_result as $category) {
+ if (!$done) {
+ $categories[$category->cid] = check_plain($category->title);
+ }
+ if ($category->iid) {
+ $selected[] = $category->cid;
+ }
+ }
+ $done = TRUE;
+ $form['categories'][$item->iid] = array(
+ '#type' => variable_get('aggregator_category_selector', 'checkboxes'),
+ '#default_value' => $selected,
+ '#options' => $categories,
+ '#size' => 10,
+ '#multiple' => TRUE
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save categories'));
+
+ return $form;
+}
+
+/**
+ * Process aggregator_categorize_items() form submissions.
+ */
+function aggregator_categorize_items_submit($form, &$form_state) {
+ if (!empty($form_state['values']['categories'])) {
+ foreach ($form_state['values']['categories'] as $iid => $selection) {
+ db_delete('aggregator_category_item')
+ ->condition('iid', $iid)
+ ->execute();
+ $insert = db_insert('aggregator_category_item')->fields(array('iid', 'cid'));
+ $has_values = FALSE;
+ foreach ($selection as $cid) {
+ if ($cid && $iid) {
+ $has_values = TRUE;
+ $insert->values(array(
+ 'iid' => $iid,
+ 'cid' => $cid,
+ ));
+ }
+ }
+ if ($has_values) {
+ $insert->execute();
+ }
+ }
+ }
+ drupal_set_message(t('The categories have been saved.'));
+}
+
+/**
+ * Returns HTML for the aggregator page list form for assigning categories.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_aggregator_categorize_items($variables) {
+ $form = $variables['form'];
+
+ $output = drupal_render($form['feed_source']);
+ $rows = array();
+ if (!empty($form['items'])) {
+ foreach (element_children($form['items']) as $key) {
+ $rows[] = array(
+ drupal_render($form['items'][$key]),
+ array('data' => drupal_render($form['categories'][$key]), 'class' => array('categorize-item')),
+ );
+ }
+ }
+ $output .= theme('table', array('header' => array('', t('Categorize')), 'rows' => $rows));
+ $output .= drupal_render($form['submit']);
+ $output .= drupal_render_children($form);
+
+ return theme('aggregator_wrapper', array('content' => $output));
+}
+
+/**
+ * Process variables for aggregator-wrapper.tpl.php.
+ *
+ * @see aggregator-wrapper.tpl.php
+ */
+function template_preprocess_aggregator_wrapper(&$variables) {
+ $variables['pager'] = theme('pager');
+}
+
+/**
+ * Process variables for aggregator-item.tpl.php.
+ *
+ * @see aggregator-item.tpl.php
+ */
+function template_preprocess_aggregator_item(&$variables) {
+ $item = $variables['item'];
+
+ $variables['feed_url'] = check_url($item->link);
+ $variables['feed_title'] = check_plain($item->title);
+ $variables['content'] = aggregator_filter_xss($item->description);
+
+ $variables['source_url'] = '';
+ $variables['source_title'] = '';
+ if (isset($item->ftitle) && isset($item->fid)) {
+ $variables['source_url'] = url("aggregator/sources/$item->fid");
+ $variables['source_title'] = check_plain($item->ftitle);
+ }
+ if (date('Ymd', $item->timestamp) == date('Ymd')) {
+ $variables['source_date'] = t('%ago ago', array('%ago' => format_interval(REQUEST_TIME - $item->timestamp)));
+ }
+ else {
+ $variables['source_date'] = format_date($item->timestamp, 'custom', variable_get('date_format_medium', 'D, m/d/Y - H:i'));
+ }
+
+ $variables['categories'] = array();
+ foreach ($item->categories as $category) {
+ $variables['categories'][$category->cid] = l($category->title, 'aggregator/categories/' . $category->cid);
+ }
+}
+
+/**
+ * Menu callback; displays all the feeds used by the aggregator.
+ */
+function aggregator_page_sources() {
+ $result = db_query('SELECT f.fid, f.title, f.description, f.image, MAX(i.timestamp) AS last FROM {aggregator_feed} f LEFT JOIN {aggregator_item} i ON f.fid = i.fid GROUP BY f.fid, f.title, f.description, f.image ORDER BY last DESC, f.title');
+
+ $output = '';
+ foreach ($result as $feed) {
+ // Most recent items:
+ $summary_items = array();
+ if (variable_get('aggregator_summary_items', 3)) {
+ $items = db_query_range('SELECT i.title, i.timestamp, i.link FROM {aggregator_item} i WHERE i.fid = :fid ORDER BY i.timestamp DESC', 0, variable_get('aggregator_summary_items', 3), array(':fid' => $feed->fid));
+ foreach ($items as $item) {
+ $summary_items[] = theme('aggregator_summary_item', array('item' => $item));
+ }
+ }
+ $feed->url = url('aggregator/sources/' . $feed->fid);
+ $output .= theme('aggregator_summary_items', array('summary_items' => $summary_items, 'source' => $feed));
+ }
+ $output .= theme('feed_icon', array('url' => 'aggregator/opml', 'title' => t('OPML feed')));
+
+ return theme('aggregator_wrapper', array('content' => $output));
+}
+
+/**
+ * Menu callback; displays all the categories used by the aggregator.
+ */
+function aggregator_page_categories() {
+ $result = db_query('SELECT c.cid, c.title, c.description FROM {aggregator_category} c LEFT JOIN {aggregator_category_item} ci ON c.cid = ci.cid LEFT JOIN {aggregator_item} i ON ci.iid = i.iid GROUP BY c.cid, c.title, c.description');
+
+ $output = '';
+ foreach ($result as $category) {
+ if (variable_get('aggregator_summary_items', 3)) {
+ $summary_items = array();
+ $items = db_query_range('SELECT i.title, i.timestamp, i.link, f.title as feed_title, f.link as feed_link FROM {aggregator_category_item} ci LEFT JOIN {aggregator_item} i ON i.iid = ci.iid LEFT JOIN {aggregator_feed} f ON i.fid = f.fid WHERE ci.cid = :cid ORDER BY i.timestamp DESC', 0, variable_get('aggregator_summary_items', 3), array(':cid' => $category->cid));
+ foreach ($items as $item) {
+ $summary_items[] = theme('aggregator_summary_item', array('item' => $item));
+ }
+ }
+ $category->url = url('aggregator/categories/' . $category->cid);
+ $output .= theme('aggregator_summary_items', array('summary_items' => $summary_items, 'source' => $category));
+ }
+
+ return theme('aggregator_wrapper', array('content' => $output));
+}
+
+/**
+ * Menu callback; generate an RSS 0.92 feed of aggregator items or categories.
+ */
+function aggregator_page_rss() {
+ $result = NULL;
+ // arg(2) is the passed cid, only select for that category.
+ if (arg(2)) {
+ $category = db_query('SELECT cid, title FROM {aggregator_category} WHERE cid = :cid', array(':cid' => arg(2)))->fetchObject();
+ $result = db_query_range('SELECT i.*, f.title AS ftitle, f.link AS flink FROM {aggregator_category_item} c LEFT JOIN {aggregator_item} i ON c.iid = i.iid LEFT JOIN {aggregator_feed} f ON i.fid = f.fid WHERE cid = :cid ORDER BY timestamp DESC, i.iid DESC', 0, variable_get('feed_default_items', 10), array(':cid' => $category->cid));
+ }
+ // Or, get the default aggregator items.
+ else {
+ $category = NULL;
+ $result = db_query_range('SELECT i.*, f.title AS ftitle, f.link AS flink FROM {aggregator_item} i INNER JOIN {aggregator_feed} f ON i.fid = f.fid ORDER BY i.timestamp DESC, i.iid DESC', 0, variable_get('feed_default_items', 10));
+ }
+
+ $feeds = $result->fetchAll();
+ return theme('aggregator_page_rss', array('feeds' => $feeds, 'category' => $category));
+}
+
+/**
+ * Prints the RSS page for a feed.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - feeds: An array of the feeds to theme.
+ * - category: A common category, if any, for all the feeds.
+ *
+ * @return void
+ *
+ * @ingroup themeable
+ */
+function theme_aggregator_page_rss($variables) {
+ $feeds = $variables['feeds'];
+ $category = $variables['category'];
+
+ drupal_add_http_header('Content-Type', 'application/rss+xml; charset=utf-8');
+
+ $items = '';
+ $feed_length = variable_get('feed_item_length', 'fulltext');
+ foreach ($feeds as $feed) {
+ switch ($feed_length) {
+ case 'teaser':
+ $summary = text_summary($feed->description, NULL, variable_get('aggregator_teaser_length', 600));
+ if ($summary != $feed->description) {
+ $summary .= '<p><a href="' . check_url($feed->link) . '">' . t('read more') . "</a></p>\n";
+ }
+ $feed->description = $summary;
+ break;
+ case 'title':
+ $feed->description = '';
+ break;
+ }
+ $items .= format_rss_item($feed->ftitle . ': ' . $feed->title, $feed->link, $feed->description, array('pubDate' => date('r', $feed->timestamp)));
+ }
+
+ $site_name = variable_get('site_name', 'Drupal');
+ $url = url((isset($category) ? 'aggregator/categories/' . $category->cid : 'aggregator'), array('absolute' => TRUE));
+ $description = isset($category) ? t('@site_name - aggregated feeds in category @title', array('@site_name' => $site_name, '@title' => $category->title)) : t('@site_name - aggregated feeds', array('@site_name' => $site_name));
+
+ $output = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
+ $output .= "<rss version=\"2.0\">\n";
+ $output .= format_rss_channel(t('@site_name aggregator', array('@site_name' => $site_name)), $url, $description, $items);
+ $output .= "</rss>\n";
+
+ print $output;
+}
+
+/**
+ * Menu callback; generates an OPML representation of all feeds.
+ *
+ * @param $cid
+ * If set, feeds are exported only from a category with this ID. Otherwise, all feeds are exported.
+ * @return
+ * The output XML.
+ */
+function aggregator_page_opml($cid = NULL) {
+ if ($cid) {
+ $result = db_query('SELECT f.title, f.url FROM {aggregator_feed} f LEFT JOIN {aggregator_category_feed} c on f.fid = c.fid WHERE c.cid = :cid ORDER BY title', array(':cid' => $cid));
+ }
+ else {
+ $result = db_query('SELECT * FROM {aggregator_feed} ORDER BY title');
+ }
+
+ $feeds = $result->fetchAll();
+ return theme('aggregator_page_opml', array('feeds' => $feeds));
+}
+
+/**
+ * Prints the OPML page for a feed.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - feeds: An array of the feeds to theme.
+ *
+ * @return void
+ *
+ * @ingroup themeable
+ */
+function theme_aggregator_page_opml($variables) {
+ $feeds = $variables['feeds'];
+
+ drupal_add_http_header('Content-Type', 'text/xml; charset=utf-8');
+
+ $output = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
+ $output .= "<opml version=\"1.1\">\n";
+ $output .= "<head>\n";
+ $output .= '<title>' . check_plain(variable_get('site_name', 'Drupal')) . "</title>\n";
+ $output .= '<dateModified>' . gmdate(DATE_RFC2822, REQUEST_TIME) . "</dateModified>\n";
+ $output .= "</head>\n";
+ $output .= "<body>\n";
+ foreach ($feeds as $feed) {
+ $output .= '<outline text="' . check_plain($feed->title) . '" xmlUrl="' . check_url($feed->url) . "\" />\n";
+ }
+ $output .= "</body>\n";
+ $output .= "</opml>\n";
+
+ print $output;
+}
+
+/**
+ * Process variables for aggregator-summary-items.tpl.php.
+ *
+ * @see aggregator-summary-items.tpl.php
+ */
+function template_preprocess_aggregator_summary_items(&$variables) {
+ $variables['title'] = check_plain($variables['source']->title);
+ $variables['summary_list'] = theme('item_list', array('items' => $variables['summary_items']));
+ $variables['source_url'] = $variables['source']->url;
+}
+
+/**
+ * Process variables for aggregator-summary-item.tpl.php.
+ *
+ * @see aggregator-summary-item.tpl.php
+ */
+function template_preprocess_aggregator_summary_item(&$variables) {
+ $item = $variables['item'];
+
+ $variables['feed_url'] = check_url($item->link);
+ $variables['feed_title'] = check_plain($item->title);
+ $variables['feed_age'] = t('%age old', array('%age' => format_interval(REQUEST_TIME - $item->timestamp)));
+
+ $variables['source_url'] = '';
+ $variables['source_title'] = '';
+ if (!empty($item->feed_link)) {
+ $variables['source_url'] = check_url($item->feed_link);
+ $variables['source_title'] = check_plain($item->feed_title);
+ }
+}
+
+/**
+ * Process variables for aggregator-feed-source.tpl.php.
+ *
+ * @see aggregator-feed-source.tpl.php
+ */
+function template_preprocess_aggregator_feed_source(&$variables) {
+ $feed = $variables['feed'];
+
+ $variables['source_icon'] = theme('feed_icon', array('url' => $feed->url, 'title' => t('!title feed', array('!title' => $feed->title))));
+
+ if (!empty($feed->image) && !empty($feed->title) && !empty($feed->link)) {
+ $variables['source_image'] = l(theme('image', array('path' => $feed->image, 'alt' => $feed->title)), $feed->link, array('html' => TRUE, 'attributes' => array('class' => 'feed-image')));
+ }
+ else {
+ $variables['source_image'] = '';
+ }
+
+ $variables['source_description'] = aggregator_filter_xss($feed->description);
+ $variables['source_url'] = check_url(url($feed->link, array('absolute' => TRUE)));
+
+ if ($feed->checked) {
+ $variables['last_checked'] = t('@time ago', array('@time' => format_interval(REQUEST_TIME - $feed->checked)));
+ }
+ else {
+ $variables['last_checked'] = t('never');
+ }
+
+ if (user_access('administer news feeds')) {
+ $variables['last_checked'] = l($variables['last_checked'], 'admin/config/services/aggregator');
+ }
+}
diff --git a/core/modules/aggregator/aggregator.parser.inc b/core/modules/aggregator/aggregator.parser.inc
new file mode 100644
index 000000000000..556f3d3bd80f
--- /dev/null
+++ b/core/modules/aggregator/aggregator.parser.inc
@@ -0,0 +1,321 @@
+<?php
+
+/**
+ * @file
+ * Parser functions for the aggregator module.
+ */
+
+/**
+ * Implements hook_aggregator_parse_info().
+ */
+function aggregator_aggregator_parse_info() {
+ return array(
+ 'title' => t('Default parser'),
+ 'description' => t('Parses RSS, Atom and RDF feeds.'),
+ );
+}
+
+/**
+ * Implements hook_aggregator_parse().
+ */
+function aggregator_aggregator_parse($feed) {
+ global $channel, $image;
+
+ // Filter the input data.
+ if (aggregator_parse_feed($feed->source_string, $feed)) {
+ $modified = empty($feed->http_headers['last-modified']) ? 0 : strtotime($feed->http_headers['last-modified']);
+
+ // Prepare the channel data.
+ foreach ($channel as $key => $value) {
+ $channel[$key] = trim($value);
+ }
+
+ // Prepare the image data (if any).
+ foreach ($image as $key => $value) {
+ $image[$key] = trim($value);
+ }
+
+ $etag = empty($feed->http_headers['etag']) ? '' : $feed->http_headers['etag'];
+
+ // Add parsed data to the feed object.
+ $feed->link = !empty($channel['link']) ? $channel['link'] : '';
+ $feed->description = !empty($channel['description']) ? $channel['description'] : '';
+ $feed->image = !empty($image['url']) ? $image['url'] : '';
+ $feed->etag = $etag;
+ $feed->modified = $modified;
+
+ // Clear the cache.
+ cache_clear_all();
+
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Parse a feed and store its items.
+ *
+ * @param $data
+ * The feed data.
+ * @param $feed
+ * An object describing the feed to be parsed.
+ * @return
+ * FALSE on error, TRUE otherwise.
+ */
+function aggregator_parse_feed(&$data, $feed) {
+ global $items, $image, $channel;
+
+ // Unset the global variables before we use them.
+ unset($GLOBALS['element'], $GLOBALS['item'], $GLOBALS['tag']);
+ $items = array();
+ $image = array();
+ $channel = array();
+
+ // Parse the data.
+ $xml_parser = drupal_xml_parser_create($data);
+ xml_set_element_handler($xml_parser, 'aggregator_element_start', 'aggregator_element_end');
+ xml_set_character_data_handler($xml_parser, 'aggregator_element_data');
+
+ if (!xml_parse($xml_parser, $data, 1)) {
+ watchdog('aggregator', 'The feed from %site seems to be broken due to an error "%error" on line %line.', array('%site' => $feed->title, '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser)), WATCHDOG_WARNING);
+ drupal_set_message(t('The feed from %site seems to be broken because of error "%error" on line %line.', array('%site' => $feed->title, '%error' => xml_error_string(xml_get_error_code($xml_parser)), '%line' => xml_get_current_line_number($xml_parser))), 'error');
+ return FALSE;
+ }
+ xml_parser_free($xml_parser);
+
+ // We reverse the array such that we store the first item last, and the last
+ // item first. In the database, the newest item should be at the top.
+ $items = array_reverse($items);
+
+ // Initialize items array.
+ $feed->items = array();
+ foreach ($items as $item) {
+
+ // Prepare the item:
+ foreach ($item as $key => $value) {
+ $item[$key] = trim($value);
+ }
+
+ // Resolve the item's title. If no title is found, we use up to 40
+ // characters of the description ending at a word boundary, but not
+ // splitting potential entities.
+ if (!empty($item['title'])) {
+ $item['title'] = $item['title'];
+ }
+ elseif (!empty($item['description'])) {
+ $item['title'] = preg_replace('/^(.*)[^\w;&].*?$/', "\\1", truncate_utf8($item['description'], 40));
+ }
+ else {
+ $item['title'] = '';
+ }
+
+ // Resolve the items link.
+ if (!empty($item['link'])) {
+ $item['link'] = $item['link'];
+ }
+ else {
+ $item['link'] = $feed->link;
+ }
+
+ // Atom feeds have an ID tag instead of a GUID tag.
+ if (!isset($item['guid'])) {
+ $item['guid'] = isset($item['id']) ? $item['id'] : '';
+ }
+
+ // Atom feeds have a content and/or summary tag instead of a description tag.
+ if (!empty($item['content:encoded'])) {
+ $item['description'] = $item['content:encoded'];
+ }
+ elseif (!empty($item['summary'])) {
+ $item['description'] = $item['summary'];
+ }
+ elseif (!empty($item['content'])) {
+ $item['description'] = $item['content'];
+ }
+
+ // Try to resolve and parse the item's publication date.
+ $date = '';
+ foreach (array('pubdate', 'dc:date', 'dcterms:issued', 'dcterms:created', 'dcterms:modified', 'issued', 'created', 'modified', 'published', 'updated') as $key) {
+ if (!empty($item[$key])) {
+ $date = $item[$key];
+ break;
+ }
+ }
+
+ $item['timestamp'] = strtotime($date);
+
+ if ($item['timestamp'] === FALSE) {
+ $item['timestamp'] = aggregator_parse_w3cdtf($date); // Aggregator_parse_w3cdtf() returns FALSE on failure.
+ }
+
+ // Resolve dc:creator tag as the item author if author tag is not set.
+ if (empty($item['author']) && !empty($item['dc:creator'])) {
+ $item['author'] = $item['dc:creator'];
+ }
+
+ $item += array('author' => '', 'description' => '');
+
+ // Store on $feed object. This is where processors will look for parsed items.
+ $feed->items[] = $item;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Callback function used by the XML parser.
+ */
+function aggregator_element_start($parser, $name, $attributes) {
+ global $item, $element, $tag, $items, $channel;
+
+ $name = strtolower($name);
+ switch ($name) {
+ case 'image':
+ case 'textinput':
+ case 'summary':
+ case 'tagline':
+ case 'subtitle':
+ case 'logo':
+ case 'info':
+ $element = $name;
+ break;
+ case 'id':
+ case 'content':
+ if ($element != 'item') {
+ $element = $name;
+ }
+ case 'link':
+ // According to RFC 4287, link elements in Atom feeds without a 'rel'
+ // attribute should be interpreted as though the relation type is
+ // "alternate".
+ if (!empty($attributes['HREF']) && (empty($attributes['REL']) || $attributes['REL'] == 'alternate')) {
+ if ($element == 'item') {
+ $items[$item]['link'] = $attributes['HREF'];
+ }
+ else {
+ $channel['link'] = $attributes['HREF'];
+ }
+ }
+ break;
+ case 'item':
+ $element = $name;
+ $item += 1;
+ break;
+ case 'entry':
+ $element = 'item';
+ $item += 1;
+ break;
+ }
+
+ $tag = $name;
+}
+
+/**
+ * Call-back function used by the XML parser.
+ */
+function aggregator_element_end($parser, $name) {
+ global $element;
+
+ switch ($name) {
+ case 'image':
+ case 'textinput':
+ case 'item':
+ case 'entry':
+ case 'info':
+ $element = '';
+ break;
+ case 'id':
+ case 'content':
+ if ($element == $name) {
+ $element = '';
+ }
+ }
+}
+
+/**
+ * Callback function used by the XML parser.
+ */
+function aggregator_element_data($parser, $data) {
+ global $channel, $element, $items, $item, $image, $tag;
+ $items += array($item => array());
+ switch ($element) {
+ case 'item':
+ $items[$item] += array($tag => '');
+ $items[$item][$tag] .= $data;
+ break;
+ case 'image':
+ case 'logo':
+ $image += array($tag => '');
+ $image[$tag] .= $data;
+ break;
+ case 'link':
+ if ($data) {
+ $items[$item] += array($tag => '');
+ $items[$item][$tag] .= $data;
+ }
+ break;
+ case 'content':
+ $items[$item] += array('content' => '');
+ $items[$item]['content'] .= $data;
+ break;
+ case 'summary':
+ $items[$item] += array('summary' => '');
+ $items[$item]['summary'] .= $data;
+ break;
+ case 'tagline':
+ case 'subtitle':
+ $channel += array('description' => '');
+ $channel['description'] .= $data;
+ break;
+ case 'info':
+ case 'id':
+ case 'textinput':
+ // The sub-element is not supported. However, we must recognize
+ // it or its contents will end up in the item array.
+ break;
+ default:
+ $channel += array($tag => '');
+ $channel[$tag] .= $data;
+ }
+}
+
+/**
+ * Parse the W3C date/time format, a subset of ISO 8601.
+ *
+ * PHP date parsing functions do not handle this format.
+ * See http://www.w3.org/TR/NOTE-datetime for more information.
+ * Originally from MagpieRSS (http://magpierss.sourceforge.net/).
+ *
+ * @param $date_str
+ * A string with a potentially W3C DTF date.
+ * @return
+ * A timestamp if parsed successfully or FALSE if not.
+ */
+function aggregator_parse_w3cdtf($date_str) {
+ if (preg_match('/(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(:(\d{2}))?(?:([-+])(\d{2}):?(\d{2})|(Z))?/', $date_str, $match)) {
+ list($year, $month, $day, $hours, $minutes, $seconds) = array($match[1], $match[2], $match[3], $match[4], $match[5], $match[6]);
+ // Calculate the epoch for current date assuming GMT.
+ $epoch = gmmktime($hours, $minutes, $seconds, $month, $day, $year);
+ if ($match[10] != 'Z') { // Z is zulu time, aka GMT
+ list($tz_mod, $tz_hour, $tz_min) = array($match[8], $match[9], $match[10]);
+ // Zero out the variables.
+ if (!$tz_hour) {
+ $tz_hour = 0;
+ }
+ if (!$tz_min) {
+ $tz_min = 0;
+ }
+ $offset_secs = (($tz_hour * 60) + $tz_min) * 60;
+ // Is timezone ahead of GMT? If yes, subtract offset.
+ if ($tz_mod == '+') {
+ $offset_secs *= -1;
+ }
+ $epoch += $offset_secs;
+ }
+ return $epoch;
+ }
+ else {
+ return FALSE;
+ }
+}
diff --git a/core/modules/aggregator/aggregator.processor.inc b/core/modules/aggregator/aggregator.processor.inc
new file mode 100644
index 000000000000..79261b6186b3
--- /dev/null
+++ b/core/modules/aggregator/aggregator.processor.inc
@@ -0,0 +1,203 @@
+<?php
+
+/**
+ * @file
+ * Processor functions for the aggregator module.
+ */
+
+/**
+ * Implements hook_aggregator_process_info().
+ */
+function aggregator_aggregator_process_info() {
+ return array(
+ 'title' => t('Default processor'),
+ 'description' => t('Creates lightweight records from feed items.'),
+ );
+}
+
+/**
+ * Implements hook_aggregator_process().
+ */
+function aggregator_aggregator_process($feed) {
+ if (is_object($feed)) {
+ if (is_array($feed->items)) {
+ foreach ($feed->items as $item) {
+ // Save this item. Try to avoid duplicate entries as much as possible. If
+ // we find a duplicate entry, we resolve it and pass along its ID is such
+ // that we can update it if needed.
+ if (!empty($item['guid'])) {
+ $entry = db_query("SELECT iid, timestamp FROM {aggregator_item} WHERE fid = :fid AND guid = :guid", array(':fid' => $feed->fid, ':guid' => $item['guid']))->fetchObject();
+ }
+ elseif ($item['link'] && $item['link'] != $feed->link && $item['link'] != $feed->url) {
+ $entry = db_query("SELECT iid, timestamp FROM {aggregator_item} WHERE fid = :fid AND link = :link", array(':fid' => $feed->fid, ':link' => $item['link']))->fetchObject();
+ }
+ else {
+ $entry = db_query("SELECT iid, timestamp FROM {aggregator_item} WHERE fid = :fid AND title = :title", array(':fid' => $feed->fid, ':title' => $item['title']))->fetchObject();
+ }
+ if (!$item['timestamp']) {
+ $item['timestamp'] = isset($entry->timestamp) ? $entry->timestamp : REQUEST_TIME;
+ }
+
+ // Make sure the item title fits in 255 varchar column.
+ $item['title'] = truncate_utf8($item['title'], 255, TRUE, TRUE);
+ aggregator_save_item(array('iid' => (isset($entry->iid) ? $entry->iid : ''), 'fid' => $feed->fid, 'timestamp' => $item['timestamp'], 'title' => $item['title'], 'link' => $item['link'], 'author' => $item['author'], 'description' => $item['description'], 'guid' => $item['guid']));
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_aggregator_remove().
+ */
+function aggregator_aggregator_remove($feed) {
+ $iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchCol();
+ if ($iids) {
+ db_delete('aggregator_category_item')
+ ->condition('iid', $iids, 'IN')
+ ->execute();
+ }
+ db_delete('aggregator_item')
+ ->condition('fid', $feed->fid)
+ ->execute();
+
+ drupal_set_message(t('The news items from %site have been removed.', array('%site' => $feed->title)));
+}
+
+/**
+ * Implements hook_form_aggregator_admin_form_alter().
+ *
+ * Form alter aggregator module's own form to keep processor functionality
+ * separate from aggregator API functionality.
+ */
+function aggregator_form_aggregator_admin_form_alter(&$form, $form_state) {
+ if (in_array('aggregator', variable_get('aggregator_processors', array('aggregator')))) {
+ $info = module_invoke('aggregator', 'aggregator_process', 'info');
+ $items = drupal_map_assoc(array(3, 5, 10, 15, 20, 25), '_aggregator_items');
+ $period = drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval');
+ $period[AGGREGATOR_CLEAR_NEVER] = t('Never');
+
+ // Only wrap into a collapsible fieldset if there is a basic configuration.
+ if (isset($form['basic_conf'])) {
+ $form['modules']['aggregator'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Default processor settings'),
+ '#description' => $info['description'],
+ '#collapsible' => TRUE,
+ '#collapsed' => !in_array('aggregator', variable_get('aggregator_processors', array('aggregator'))),
+ );
+ }
+ else {
+ $form['modules']['aggregator'] = array();
+ }
+
+ $form['modules']['aggregator']['aggregator_summary_items'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of items shown in listing pages'),
+ '#default_value' => variable_get('aggregator_summary_items', 3),
+ '#empty_value' => 0,
+ '#options' => $items,
+ );
+
+ $form['modules']['aggregator']['aggregator_clear'] = array(
+ '#type' => 'select',
+ '#title' => t('Discard items older than'),
+ '#default_value' => variable_get('aggregator_clear', 9676800),
+ '#options' => $period,
+ '#description' => t('Requires a correctly configured <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status'))),
+ );
+
+ $form['modules']['aggregator']['aggregator_category_selector'] = array(
+ '#type' => 'radios',
+ '#title' => t('Select categories using'),
+ '#default_value' => variable_get('aggregator_category_selector', 'checkboxes'),
+ '#options' => array('checkboxes' => t('checkboxes'),
+ 'select' => t('multiple selector')),
+ '#description' => t('For a small number of categories, checkboxes are easier to use, while a multiple selector works well with large numbers of categories.'),
+ );
+ $form['modules']['aggregator']['aggregator_teaser_length'] = array(
+ '#type' => 'select',
+ '#title' => t('Length of trimmed description'),
+ '#default_value' => 600,
+ '#options' => drupal_map_assoc(array(0, 200, 400, 600, 800, 1000, 1200, 1400, 1600, 1800, 2000), '_aggregator_characters'),
+ '#description' => t("The maximum number of characters used in the trimmed version of content.")
+ );
+
+ }
+}
+
+/**
+ * Helper function for teaser length choices.
+ */
+function _aggregator_characters($length) {
+ return ($length == 0) ? t('Unlimited') : format_plural($length, '1 character', '@count characters');
+}
+
+/**
+ * Add/edit/delete an aggregator item.
+ *
+ * @param $edit
+ * An associative array describing the item to be added/edited/deleted.
+ */
+function aggregator_save_item($edit) {
+ if ($edit['title'] && empty($edit['iid'])) {
+ $edit['iid'] = db_insert('aggregator_item')
+ ->fields(array(
+ 'title' => $edit['title'],
+ 'link' => $edit['link'],
+ 'author' => $edit['author'],
+ 'description' => $edit['description'],
+ 'guid' => $edit['guid'],
+ 'timestamp' => $edit['timestamp'],
+ 'fid' => $edit['fid'],
+ ))
+ ->execute();
+ }
+ if ($edit['iid'] && !$edit['title']) {
+ db_delete('aggregator_item')
+ ->condition('iid', $edit['iid'])
+ ->execute();
+ db_delete('aggregator_category_item')
+ ->condition('iid', $edit['iid'])
+ ->execute();
+ }
+ elseif ($edit['title'] && $edit['link']) {
+ // file the items in the categories indicated by the feed
+ $result = db_query('SELECT cid FROM {aggregator_category_feed} WHERE fid = :fid', array(':fid' => $edit['fid']));
+ foreach ($result as $category) {
+ db_merge('aggregator_category_item')
+ ->key(array(
+ 'iid' => $edit['iid'],
+ 'cid' => $category->cid,
+ ))
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Expire feed items on $feed that are older than aggregator_clear.
+ *
+ * @param $feed
+ * Object describing feed.
+ */
+function aggregator_expire($feed) {
+ $aggregator_clear = variable_get('aggregator_clear', 9676800);
+
+ if ($aggregator_clear != AGGREGATOR_CLEAR_NEVER) {
+ // Remove all items that are older than flush item timer.
+ $age = REQUEST_TIME - $aggregator_clear;
+ $iids = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid AND timestamp < :timestamp', array(
+ ':fid' => $feed->fid,
+ ':timestamp' => $age,
+ ))
+ ->fetchCol();
+ if ($iids) {
+ db_delete('aggregator_category_item')
+ ->condition('iid', $iids, 'IN')
+ ->execute();
+ db_delete('aggregator_item')
+ ->condition('iid', $iids, 'IN')
+ ->execute();
+ }
+ }
+}
diff --git a/core/modules/aggregator/aggregator.test b/core/modules/aggregator/aggregator.test
new file mode 100644
index 000000000000..c4f42a483ed1
--- /dev/null
+++ b/core/modules/aggregator/aggregator.test
@@ -0,0 +1,859 @@
+<?php
+
+/**
+ * @file
+ * Tests for aggregator.module.
+ */
+
+class AggregatorTestCase extends DrupalWebTestCase {
+ function setUp() {
+ parent::setUp('aggregator', 'aggregator_test');
+ $web_user = $this->drupalCreateUser(array('administer news feeds', 'access news feeds', 'create article content'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Create an aggregator feed (simulate form submission on admin/config/services/aggregator/add/feed).
+ *
+ * @param $feed_url
+ * If given, feed will be created with this URL, otherwise /rss.xml will be used.
+ * @return $feed
+ * Full feed object if possible.
+ *
+ * @see getFeedEditArray()
+ */
+ function createFeed($feed_url = NULL) {
+ $edit = $this->getFeedEditArray($feed_url);
+ $this->drupalPost('admin/config/services/aggregator/add/feed', $edit, t('Save'));
+ $this->assertRaw(t('The feed %name has been added.', array('%name' => $edit['title'])), t('The feed !name has been added.', array('!name' => $edit['title'])));
+
+ $feed = db_query("SELECT * FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $edit['title'], ':url' => $edit['url']))->fetch();
+ $this->assertTrue(!empty($feed), t('The feed found in database.'));
+ return $feed;
+ }
+
+ /**
+ * Delete an aggregator feed.
+ *
+ * @param $feed
+ * Feed object representing the feed.
+ */
+ function deleteFeed($feed) {
+ $this->drupalPost('admin/config/services/aggregator/edit/feed/' . $feed->fid, array(), t('Delete'));
+ $this->assertRaw(t('The feed %title has been deleted.', array('%title' => $feed->title)), t('Feed deleted successfully.'));
+ }
+
+ /**
+ * Return a randomly generated feed edit array.
+ *
+ * @param $feed_url
+ * If given, feed will be created with this URL, otherwise /rss.xml will be used.
+ * @return
+ * A feed array.
+ */
+ function getFeedEditArray($feed_url = NULL) {
+ $feed_name = $this->randomName(10);
+ if (!$feed_url) {
+ $feed_url = url('rss.xml', array(
+ 'query' => array('feed' => $feed_name),
+ 'absolute' => TRUE,
+ ));
+ }
+ $edit = array(
+ 'title' => $feed_name,
+ 'url' => $feed_url,
+ 'refresh' => '900',
+ );
+ return $edit;
+ }
+
+ /**
+ * Return the count of the randomly created feed array.
+ *
+ * @return
+ * Number of feed items on default feed created by createFeed().
+ */
+ function getDefaultFeedItemCount() {
+ // Our tests are based off of rss.xml, so let's find out how many elements should be related.
+ $feed_count = db_query_range('SELECT COUNT(*) FROM {node} n WHERE n.promote = 1 AND n.status = 1', 0, variable_get('feed_default_items', 10))->fetchField();
+ return $feed_count > 10 ? 10 : $feed_count;
+ }
+
+ /**
+ * Update feed items (simulate click to admin/config/services/aggregator/update/$fid).
+ *
+ * @param $feed
+ * Feed object representing the feed.
+ * @param $expected_count
+ * Expected number of feed items.
+ */
+ function updateFeedItems(&$feed, $expected_count) {
+ // First, let's ensure we can get to the rss xml.
+ $this->drupalGet($feed->url);
+ $this->assertResponse(200, t('!url is reachable.', array('!url' => $feed->url)));
+
+ // Refresh the feed (simulated link click).
+ $this->drupalGet('admin/config/services/aggregator/update/' . $feed->fid);
+
+ // Ensure we have the right number of items.
+ $result = db_query('SELECT iid FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid));
+ $items = array();
+ $feed->items = array();
+ foreach ($result as $item) {
+ $feed->items[] = $item->iid;
+ }
+ $feed->item_count = count($feed->items);
+ $this->assertEqual($expected_count, $feed->item_count, t('Total items in feed equal to the total items in database (!val1 != !val2)', array('!val1' => $expected_count, '!val2' => $feed->item_count)));
+ }
+
+ /**
+ * Confirm item removal from a feed.
+ *
+ * @param $feed
+ * Feed object representing the feed.
+ */
+ function removeFeedItems($feed) {
+ $this->drupalPost('admin/config/services/aggregator/remove/' . $feed->fid, array(), t('Remove items'));
+ $this->assertRaw(t('The news items from %title have been removed.', array('%title' => $feed->title)), t('Feed items removed.'));
+ }
+
+ /**
+ * Add and remove feed items and ensure that the count is zero.
+ *
+ * @param $feed
+ * Feed object representing the feed.
+ * @param $expected_count
+ * Expected number of feed items.
+ */
+ function updateAndRemove($feed, $expected_count) {
+ $this->updateFeedItems($feed, $expected_count);
+ $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField();
+ $this->assertTrue($count);
+ $this->removeFeedItems($feed);
+ $count = db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField();
+ $this->assertTrue($count == 0);
+ }
+
+ /**
+ * Pull feed categories from aggregator_category_feed table.
+ *
+ * @param $feed
+ * Feed object representing the feed.
+ */
+ function getFeedCategories($feed) {
+ // add the categories to the feed so we can use them
+ $result = db_query('SELECT cid FROM {aggregator_category_feed} WHERE fid = :fid', array(':fid' => $feed->fid));
+ foreach ($result as $category) {
+ $feed->categories[] = $category->cid;
+ }
+ }
+
+ /**
+ * Pull categories from aggregator_category table.
+ */
+ function getCategories() {
+ $categories = array();
+ $result = db_query('SELECT * FROM {aggregator_category}');
+ foreach ($result as $category) {
+ $categories[$category->cid] = $category;
+ }
+ return $categories;
+ }
+
+
+ /**
+ * Check if the feed name and url is unique.
+ *
+ * @param $feed_name
+ * String containing the feed name to check.
+ * @param $feed_url
+ * String containing the feed url to check.
+ * @return
+ * TRUE if feed is unique.
+ */
+ function uniqueFeed($feed_name, $feed_url) {
+ $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $feed_name, ':url' => $feed_url))->fetchField();
+ return (1 == $result);
+ }
+
+ /**
+ * Create a valid OPML file from an array of feeds.
+ *
+ * @param $feeds
+ * An array of feeds.
+ * @return
+ * Path to valid OPML file.
+ */
+ function getValidOpml($feeds) {
+ // Properly escape URLs so that XML parsers don't choke on them.
+ foreach ($feeds as &$feed) {
+ $feed['url'] = htmlspecialchars($feed['url']);
+ }
+ /**
+ * Does not have an XML declaration, must pass the parser.
+ */
+ $opml = <<<EOF
+<opml version="1.0">
+ <head></head>
+ <body>
+ <!-- First feed to be imported. -->
+ <outline text="{$feeds[0]['title']}" xmlurl="{$feeds[0]['url']}" />
+
+ <!-- Second feed. Test string delimitation and attribute order. -->
+ <outline xmlurl='{$feeds[1]['url']}' text='{$feeds[1]['title']}'/>
+
+ <!-- Test for duplicate URL and title. -->
+ <outline xmlurl="{$feeds[0]['url']}" text="Duplicate URL"/>
+ <outline xmlurl="http://duplicate.title" text="{$feeds[1]['title']}"/>
+
+ <!-- Test that feeds are only added with required attributes. -->
+ <outline text="{$feeds[2]['title']}" />
+ <outline xmlurl="{$feeds[2]['url']}" />
+ </body>
+</opml>
+EOF;
+
+ $path = 'public://valid-opml.xml';
+ return file_unmanaged_save_data($opml, $path);
+ }
+
+ /**
+ * Create an invalid OPML file.
+ *
+ * @return
+ * Path to invalid OPML file.
+ */
+ function getInvalidOpml() {
+ $opml = <<<EOF
+<opml>
+ <invalid>
+</opml>
+EOF;
+
+ $path = 'public://invalid-opml.xml';
+ return file_unmanaged_save_data($opml, $path);
+ }
+
+ /**
+ * Create a valid but empty OPML file.
+ *
+ * @return
+ * Path to empty OPML file.
+ */
+ function getEmptyOpml() {
+ $opml = <<<EOF
+<?xml version="1.0" encoding="utf-8"?>
+<opml version="1.0">
+ <head></head>
+ <body>
+ <outline text="Sample text" />
+ <outline text="Sample text" url="Sample URL" />
+ </body>
+</opml>
+EOF;
+
+ $path = 'public://empty-opml.xml';
+ return file_unmanaged_save_data($opml, $path);
+ }
+
+ function getRSS091Sample() {
+ return $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'aggregator') . '/tests/aggregator_test_rss091.xml';
+ }
+
+ function getAtomSample() {
+ // The content of this sample ATOM feed is based directly off of the
+ // example provided in RFC 4287.
+ return $GLOBALS['base_url'] . '/' . drupal_get_path('module', 'aggregator') . '/tests/aggregator_test_atom.xml';
+ }
+
+ function createSampleNodes() {
+ $langcode = LANGUAGE_NONE;
+ // Post 5 articles.
+ for ($i = 0; $i < 5; $i++) {
+ $edit = array();
+ $edit['title'] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ }
+ }
+}
+
+class AddFeedTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Add feed functionality',
+ 'description' => 'Add feed test.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Create a feed, ensure that it is unique, check the source, and delete the feed.
+ */
+ function testAddFeed() {
+ $feed = $this->createFeed();
+
+ // Check feed data.
+ $this->assertEqual($this->getUrl(), url('admin/config/services/aggregator/add/feed', array('absolute' => TRUE)), t('Directed to correct url.'));
+ $this->assertTrue($this->uniqueFeed($feed->title, $feed->url), t('The feed is unique.'));
+
+ // Check feed source.
+ $this->drupalGet('aggregator/sources/' . $feed->fid);
+ $this->assertResponse(200, t('Feed source exists.'));
+ $this->assertText($feed->title, t('Page title'));
+ $this->drupalGet('aggregator/sources/' . $feed->fid . '/categorize');
+ $this->assertResponse(200, t('Feed categorization page exists.'));
+
+ // Delete feed.
+ $this->deleteFeed($feed);
+ }
+}
+
+class CategorizeFeedTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Categorize feed functionality',
+ 'description' => 'Categorize feed test.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Create a feed and make sure you can add more than one category to it.
+ */
+ function testCategorizeFeed() {
+
+ // Create 2 categories.
+ $category_1 = array('title' => $this->randomName(10), 'description' => '');
+ $this->drupalPost('admin/config/services/aggregator/add/category', $category_1, t('Save'));
+ $this->assertRaw(t('The category %title has been added.', array('%title' => $category_1['title'])), t('The category %title has been added.', array('%title' => $category_1['title'])));
+
+ $category_2 = array('title' => $this->randomName(10), 'description' => '');
+ $this->drupalPost('admin/config/services/aggregator/add/category', $category_2, t('Save'));
+ $this->assertRaw(t('The category %title has been added.', array('%title' => $category_2['title'])), t('The category %title has been added.', array('%title' => $category_2['title'])));
+
+ // Get categories from database.
+ $categories = $this->getCategories();
+
+ // Create a feed and assign 2 categories to it.
+ $feed = $this->getFeedEditArray();
+ $feed['block'] = 5;
+ foreach ($categories as $cid => $category) {
+ $feed['category'][$cid] = $cid;
+ }
+
+ // Use aggregator_save_feed() function to save the feed.
+ aggregator_save_feed($feed);
+ $db_feed = db_query("SELECT * FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $feed['title'], ':url' => $feed['url']))->fetch();
+
+ // Assert the feed has two categories.
+ $this->getFeedCategories($db_feed);
+ $this->assertEqual(count($db_feed->categories), 2, t('Feed has 2 categories'));
+ }
+}
+
+class UpdateFeedTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update feed functionality',
+ 'description' => 'Update feed test.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Create a feed and attempt to update it.
+ */
+ function testUpdateFeed() {
+ $remamining_fields = array('title', 'url', '');
+ foreach ($remamining_fields as $same_field) {
+ $feed = $this->createFeed();
+
+ // Get new feed data array and modify newly created feed.
+ $edit = $this->getFeedEditArray();
+ $edit['refresh'] = 1800; // Change refresh value.
+ if (isset($feed->{$same_field})) {
+ $edit[$same_field] = $feed->{$same_field};
+ }
+ $this->drupalPost('admin/config/services/aggregator/edit/feed/' . $feed->fid, $edit, t('Save'));
+ $this->assertRaw(t('The feed %name has been updated.', array('%name' => $edit['title'])), t('The feed %name has been updated.', array('%name' => $edit['title'])));
+
+ // Check feed data.
+ $this->assertEqual($this->getUrl(), url('admin/config/services/aggregator/', array('absolute' => TRUE)));
+ $this->assertTrue($this->uniqueFeed($edit['title'], $edit['url']), t('The feed is unique.'));
+
+ // Check feed source.
+ $this->drupalGet('aggregator/sources/' . $feed->fid);
+ $this->assertResponse(200, t('Feed source exists.'));
+ $this->assertText($edit['title'], t('Page title'));
+
+ // Delete feed.
+ $feed->title = $edit['title']; // Set correct title so deleteFeed() will work.
+ $this->deleteFeed($feed);
+ }
+ }
+}
+
+class RemoveFeedTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Remove feed functionality',
+ 'description' => 'Remove feed test.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Remove a feed and ensure that all it services are removed.
+ */
+ function testRemoveFeed() {
+ $feed = $this->createFeed();
+
+ // Delete feed.
+ $this->deleteFeed($feed);
+
+ // Check feed source.
+ $this->drupalGet('aggregator/sources/' . $feed->fid);
+ $this->assertResponse(404, t('Deleted feed source does not exists.'));
+
+ // Check database for feed.
+ $result = db_query("SELECT COUNT(*) FROM {aggregator_feed} WHERE title = :title AND url = :url", array(':title' => $feed->title, ':url' => $feed->url))->fetchField();
+ $this->assertFalse($result, t('Feed not found in database'));
+ }
+}
+
+class UpdateFeedItemTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update feed item functionality',
+ 'description' => 'Update feed items from a feed.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Test running "update items" from the 'admin/config/services/aggregator' page.
+ */
+ function testUpdateFeedItem() {
+ $this->createSampleNodes();
+
+ // Create a feed and test updating feed items if possible.
+ $feed = $this->createFeed();
+ if (!empty($feed)) {
+ $this->updateFeedItems($feed, $this->getDefaultFeedItemCount());
+ $this->removeFeedItems($feed);
+ }
+
+ // Delete feed.
+ $this->deleteFeed($feed);
+
+ // Test updating feed items without valid timestamp information.
+ $edit = array(
+ 'title' => "Feed without publish timestamp",
+ 'url' => $this->getRSS091Sample(),
+ );
+
+ $this->drupalGet($edit['url']);
+ $this->assertResponse(array(200), t('URL !url is accessible', array('!url' => $edit['url'])));
+
+ $this->drupalPost('admin/config/services/aggregator/add/feed', $edit, t('Save'));
+ $this->assertRaw(t('The feed %name has been added.', array('%name' => $edit['title'])), t('The feed !name has been added.', array('!name' => $edit['title'])));
+
+ $feed = db_query("SELECT * FROM {aggregator_feed} WHERE url = :url", array(':url' => $edit['url']))->fetchObject();
+ $this->drupalGet('admin/config/services/aggregator/update/' . $feed->fid);
+
+ $before = db_query('SELECT timestamp FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField();
+
+ // Sleep for 3 second.
+ sleep(3);
+ db_update('aggregator_feed')
+ ->condition('fid', $feed->fid)
+ ->fields(array(
+ 'checked' => 0,
+ 'hash' => '',
+ 'etag' => '',
+ 'modified' => 0,
+ ))
+ ->execute();
+ $this->drupalGet('admin/config/services/aggregator/update/' . $feed->fid);
+
+ $after = db_query('SELECT timestamp FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField();
+
+ $this->assertTrue($before === $after, t('Publish timestamp of feed item was not updated (!before === !after)', array('!before' => $before, '!after' => $after)));
+ }
+}
+
+class RemoveFeedItemTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Remove feed item functionality',
+ 'description' => 'Remove feed items from a feed.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Test running "remove items" from the 'admin/config/services/aggregator' page.
+ */
+ function testRemoveFeedItem() {
+ // Create a bunch of test feeds.
+ $feed_urls = array();
+ // No last-modified, no etag.
+ $feed_urls[] = url('aggregator/test-feed', array('absolute' => TRUE));
+ // Last-modified, but no etag.
+ $feed_urls[] = url('aggregator/test-feed/1', array('absolute' => TRUE));
+ // No Last-modified, but etag.
+ $feed_urls[] = url('aggregator/test-feed/0/1', array('absolute' => TRUE));
+ // Last-modified and etag.
+ $feed_urls[] = url('aggregator/test-feed/1/1', array('absolute' => TRUE));
+
+ foreach ($feed_urls as $feed_url) {
+ $feed = $this->createFeed($feed_url);
+ // Update and remove items two times in a row to make sure that removal
+ // resets all 'modified' information (modified, etag, hash) and allows for
+ // immediate update.
+ $this->updateAndRemove($feed, 2);
+ $this->updateAndRemove($feed, 2);
+ $this->updateAndRemove($feed, 2);
+ // Delete feed.
+ $this->deleteFeed($feed);
+ }
+ }
+}
+
+class CategorizeFeedItemTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Categorize feed item functionality',
+ 'description' => 'Test feed item categorization.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * If a feed has a category, make sure that the children inherit that
+ * categorization.
+ */
+ function testCategorizeFeedItem() {
+ $this->createSampleNodes();
+
+ // Simulate form submission on "admin/config/services/aggregator/add/category".
+ $edit = array('title' => $this->randomName(10), 'description' => '');
+ $this->drupalPost('admin/config/services/aggregator/add/category', $edit, t('Save'));
+ $this->assertRaw(t('The category %title has been added.', array('%title' => $edit['title'])), t('The category %title has been added.', array('%title' => $edit['title'])));
+
+ $category = db_query("SELECT * FROM {aggregator_category} WHERE title = :title", array(':title' => $edit['title']))->fetch();
+ $this->assertTrue(!empty($category), t('The category found in database.'));
+
+ $link_path = 'aggregator/categories/' . $category->cid;
+ $menu_link = db_query("SELECT * FROM {menu_links} WHERE link_path = :link_path", array(':link_path' => $link_path))->fetch();
+ $this->assertTrue(!empty($menu_link), t('The menu link associated with the category found in database.'));
+
+ $feed = $this->createFeed();
+ db_insert('aggregator_category_feed')
+ ->fields(array(
+ 'cid' => $category->cid,
+ 'fid' => $feed->fid,
+ ))
+ ->execute();
+ $this->updateFeedItems($feed, $this->getDefaultFeedItemCount());
+ $this->getFeedCategories($feed);
+ $this->assertTrue(!empty($feed->categories), t('The category found in the feed.'));
+
+ // For each category of a feed, ensure feed items have that category, too.
+ if (!empty($feed->categories) && !empty($feed->items)) {
+ foreach ($feed->categories as $category) {
+ $categorized_count = db_select('aggregator_category_item')
+ ->condition('iid', $feed->items, 'IN')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+
+ $this->assertEqual($feed->item_count, $categorized_count, t('Total items in feed equal to the total categorized feed items in database'));
+ }
+ }
+
+ // Delete feed.
+ $this->deleteFeed($feed);
+ }
+}
+
+class ImportOPMLTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Import feeds from OPML functionality',
+ 'description' => 'Test OPML import.',
+ 'group' => 'Aggregator',
+ );
+ }
+
+ /**
+ * Open OPML import form.
+ */
+ function openImportForm() {
+ db_delete('aggregator_category')->execute();
+
+ $category = $this->randomName(10);
+ $cid = db_insert('aggregator_category')
+ ->fields(array(
+ 'title' => $category,
+ 'description' => '',
+ ))
+ ->execute();
+
+ $this->drupalGet('admin/config/services/aggregator/add/opml');
+ $this->assertText('A single OPML document may contain a collection of many feeds.', t('Found OPML help text.'));
+ $this->assertField('files[upload]', t('Found file upload field.'));
+ $this->assertField('remote', t('Found Remote URL field.'));
+ $this->assertField('refresh', '', t('Found Refresh field.'));
+ $this->assertFieldByName("category[$cid]", $cid, t('Found category field.'));
+ }
+
+ /**
+ * Submit form filled with invalid fields.
+ */
+ function validateImportFormFields() {
+ $before = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField();
+
+ $edit = array();
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $edit, t('Import'));
+ $this->assertRaw(t('You must <em>either</em> upload a file or enter a URL.'), t('Error if no fields are filled.'));
+
+ $path = $this->getEmptyOpml();
+ $edit = array(
+ 'files[upload]' => $path,
+ 'remote' => file_create_url($path),
+ );
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $edit, t('Import'));
+ $this->assertRaw(t('You must <em>either</em> upload a file or enter a URL.'), t('Error if both fields are filled.'));
+
+ $edit = array('remote' => 'invalidUrl://empty');
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $edit, t('Import'));
+ $this->assertText(t('This URL is not valid.'), t('Error if the URL is invalid.'));
+
+ $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField();
+ $this->assertEqual($before, $after, t('No feeds were added during the three last form submissions.'));
+ }
+
+ /**
+ * Submit form with invalid, empty and valid OPML files.
+ */
+ function submitImportForm() {
+ $before = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField();
+
+ $form['files[upload]'] = $this->getInvalidOpml();
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $form, t('Import'));
+ $this->assertText(t('No new feed has been added.'), t('Attempting to upload invalid XML.'));
+
+ $edit = array('remote' => file_create_url($this->getEmptyOpml()));
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $edit, t('Import'));
+ $this->assertText(t('No new feed has been added.'), t('Attempting to load empty OPML from remote URL.'));
+
+ $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField();
+ $this->assertEqual($before, $after, t('No feeds were added during the two last form submissions.'));
+
+ db_delete('aggregator_feed')->execute();
+ db_delete('aggregator_category')->execute();
+ db_delete('aggregator_category_feed')->execute();
+
+ $category = $this->randomName(10);
+ db_insert('aggregator_category')
+ ->fields(array(
+ 'cid' => 1,
+ 'title' => $category,
+ 'description' => '',
+ ))
+ ->execute();
+
+ $feeds[0] = $this->getFeedEditArray();
+ $feeds[1] = $this->getFeedEditArray();
+ $feeds[2] = $this->getFeedEditArray();
+ $edit = array(
+ 'files[upload]' => $this->getValidOpml($feeds),
+ 'refresh' => '900',
+ 'category[1]' => $category,
+ );
+ $this->drupalPost('admin/config/services/aggregator/add/opml', $edit, t('Import'));
+ $this->assertRaw(t('A feed with the URL %url already exists.', array('%url' => $feeds[0]['url'])), t('Verifying that a duplicate URL was identified'));
+ $this->assertRaw(t('A feed named %title already exists.', array('%title' => $feeds[1]['title'])), t('Verifying that a duplicate title was identified'));
+
+ $after = db_query('SELECT COUNT(*) FROM {aggregator_feed}')->fetchField();
+ $this->assertEqual($after, 2, t('Verifying that two distinct feeds were added.'));
+
+ $feeds_from_db = db_query("SELECT f.title, f.url, f.refresh, cf.cid FROM {aggregator_feed} f LEFT JOIN {aggregator_category_feed} cf ON f.fid = cf.fid");
+ $refresh = $category = TRUE;
+ foreach ($feeds_from_db as $feed) {
+ $title[$feed->url] = $feed->title;
+ $url[$feed->title] = $feed->url;
+ $category = $category && $feed->cid == 1;
+ $refresh = $refresh && $feed->refresh == 900;
+ }
+
+ $this->assertEqual($title[$feeds[0]['url']], $feeds[0]['title'], t('First feed was added correctly.'));
+ $this->assertEqual($url[$feeds[1]['title']], $feeds[1]['url'], t('Second feed was added correctly.'));
+ $this->assertTrue($refresh, t('Refresh times are correct.'));
+ $this->assertTrue($category, t('Categories are correct.'));
+ }
+
+ function testOPMLImport() {
+ $this->openImportForm();
+ $this->validateImportFormFields();
+ $this->submitImportForm();
+ }
+}
+
+class AggregatorCronTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update on cron functionality',
+ 'description' => 'Update feeds on cron.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Add feeds update them on cron.
+ */
+ public function testCron() {
+ // Create feed and test basic updating on cron.
+ global $base_url;
+ $key = variable_get('cron_key', 'drupal');
+ $this->createSampleNodes();
+ $feed = $this->createFeed();
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField(), 'Expected number of items in database.');
+ $this->removeFeedItems($feed);
+ $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField(), 'Expected number of items in database.');
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField(), 'Expected number of items in database.');
+
+ // Test feed locking when queued for update.
+ $this->removeFeedItems($feed);
+ db_update('aggregator_feed')
+ ->condition('fid', $feed->fid)
+ ->fields(array(
+ 'queued' => REQUEST_TIME,
+ ))
+ ->execute();
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField(), 'Expected number of items in database.');
+ db_update('aggregator_feed')
+ ->condition('fid', $feed->fid)
+ ->fields(array(
+ 'queued' => 0,
+ ))
+ ->execute();
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {aggregator_item} WHERE fid = :fid', array(':fid' => $feed->fid))->fetchField(), 'Expected number of items in database.');
+ }
+}
+
+class AggregatorRenderingTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Checks display of aggregator items',
+ 'description' => 'Checks display of aggregator items on the page.',
+ 'group' => 'Aggregator'
+ );
+ }
+
+ /**
+ * Add a feed block to the page and checks its links.
+ *
+ * TODO: Test the category block as well.
+ */
+ public function testBlockLinks() {
+ // Create feed.
+ $this->createSampleNodes();
+ $feed = $this->createFeed();
+ $this->updateFeedItems($feed, $this->getDefaultFeedItemCount());
+
+ // Place block on page (@see block.test:moveBlockToRegion())
+ // Need admin user to be able to access block admin.
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'administer blocks',
+ 'access administration pages',
+ 'administer news feeds',
+ 'access news feeds',
+ ));
+ $this->drupalLogin($this->admin_user);
+
+ // Prepare to use the block admin form.
+ $block = array(
+ 'module' => 'aggregator',
+ 'delta' => 'feed-' . $feed->fid,
+ 'title' => $feed->title,
+ );
+ $region = 'footer';
+ $edit = array();
+ $edit['blocks[' . $block['module'] . '_' . $block['delta'] . '][region]'] = $region;
+ // Check the feed block is available in the block list form.
+ $this->drupalGet('admin/structure/block');
+ $this->assertFieldByName('blocks[' . $block['module'] . '_' . $block['delta'] . '][region]', '', 'Aggregator feed block is available for positioning.');
+ // Position it.
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully moved to %region_name region.', array( '%region_name' => $region)));
+ // Confirm that the block is now being displayed on pages.
+ $this->drupalGet('node');
+ $this->assertText(t($block['title']), t('Feed block is displayed on the page.'));
+
+ // Find the expected read_more link.
+ $href = 'aggregator/sources/' . $feed->fid;
+ $links = $this->xpath('//a[@href = :href]', array(':href' => url($href)));
+ $this->assert(isset($links[0]), t('Link to href %href found.', array('%href' => $href)));
+
+ // Visit that page.
+ $this->drupalGet($href);
+ $correct_titles = $this->xpath('//h1[normalize-space(text())=:title]', array(':title' => $feed->title));
+ $this->assertFalse(empty($correct_titles), t('Aggregator feed page is available and has the correct title.'));
+ }
+}
+
+/**
+ * Tests for feed parsing.
+ */
+class FeedParserTestCase extends AggregatorTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Feed parser functionality',
+ 'description' => 'Test the built-in feed parser with valid feed samples.',
+ 'group' => 'Aggregator',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ // Do not remove old aggregator items during these tests, since our sample
+ // feeds have hardcoded dates in them (which may be expired when this test
+ // is run).
+ variable_set('aggregator_clear', AGGREGATOR_CLEAR_NEVER);
+ }
+
+ /**
+ * Test a feed that uses the RSS 0.91 format.
+ */
+ function testRSS091Sample() {
+ $feed = $this->createFeed($this->getRSS091Sample());
+ aggregator_refresh($feed);
+ $this->drupalGet('aggregator/sources/' . $feed->fid);
+ $this->assertResponse(200, t('Feed %name exists.', array('%name' => $feed->title)));
+ $this->assertText('First example feed item title');
+ $this->assertLinkByHref('http://example.com/example-turns-one');
+ $this->assertText('First example feed item description.');
+ }
+
+ /**
+ * Test a feed that uses the Atom format.
+ */
+ function testAtomSample() {
+ $feed = $this->createFeed($this->getAtomSample());
+ aggregator_refresh($feed);
+ $this->drupalGet('aggregator/sources/' . $feed->fid);
+ $this->assertResponse(200, t('Feed %name exists.', array('%name' => $feed->title)));
+ $this->assertText('Atom-Powered Robots Run Amok');
+ $this->assertLinkByHref('http://example.org/2003/12/13/atom03');
+ $this->assertText('Some text.');
+ $this->assertEqual('urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a', db_query('SELECT guid FROM {aggregator_item} WHERE link = :link', array(':link' => 'http://example.org/2003/12/13/atom03'))->fetchField(), 'Atom entry id element is parsed correctly.');
+ }
+}
+
diff --git a/core/modules/aggregator/aggregator.theme-rtl.css b/core/modules/aggregator/aggregator.theme-rtl.css
new file mode 100644
index 000000000000..f02ae91b2dfb
--- /dev/null
+++ b/core/modules/aggregator/aggregator.theme-rtl.css
@@ -0,0 +1,3 @@
+.aggregator .feed-icon {
+ float: left;
+}
diff --git a/core/modules/aggregator/aggregator.theme.css b/core/modules/aggregator/aggregator.theme.css
new file mode 100644
index 000000000000..e2182acf7c72
--- /dev/null
+++ b/core/modules/aggregator/aggregator.theme.css
@@ -0,0 +1,4 @@
+.aggregator .feed-icon {
+ float: right; /* LTR */
+ display: block;
+}
diff --git a/core/modules/aggregator/tests/aggregator_test.info b/core/modules/aggregator/tests/aggregator_test.info
new file mode 100644
index 000000000000..583a7fc743bb
--- /dev/null
+++ b/core/modules/aggregator/tests/aggregator_test.info
@@ -0,0 +1,6 @@
+name = "Aggregator module tests"
+description = "Support module for aggregator related testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/aggregator/tests/aggregator_test.module b/core/modules/aggregator/tests/aggregator_test.module
new file mode 100644
index 000000000000..2d26a5d9a743
--- /dev/null
+++ b/core/modules/aggregator/tests/aggregator_test.module
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function aggregator_test_menu() {
+ $items['aggregator/test-feed'] = array(
+ 'title' => 'Test feed static last modified date',
+ 'description' => "A cached test feed with a static last modified date.",
+ 'page callback' => 'aggregator_test_feed',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Page callback. Generates a test feed and simulates last-modified and etags.
+ *
+ * @param $use_last_modified
+ * Set TRUE to send a last modified header.
+ * @param $use_etag
+ * Set TRUE to send an etag.
+ */
+function aggregator_test_feed($use_last_modified = FALSE, $use_etag = FALSE) {
+ $last_modified = strtotime('Sun, 19 Nov 1978 05:00:00 GMT');
+ $etag = drupal_hash_base64($last_modified);
+
+ $if_modified_since = isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) : FALSE;
+ $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ? stripslashes($_SERVER['HTTP_IF_NONE_MATCH']) : FALSE;
+
+ // Send appropriate response. We respond with a 304 not modified on either
+ // etag or on last modified.
+ if ($use_last_modified) {
+ drupal_add_http_header('Last-Modified', gmdate(DATE_RFC1123, $last_modified));
+ }
+ if ($use_etag) {
+ drupal_add_http_header('ETag', $etag);
+ }
+ // Return 304 not modified if either last modified or etag match.
+ if ($last_modified == $if_modified_since || $etag == $if_none_match) {
+ drupal_add_http_header('Status', '304 Not Modified');
+ return;
+ }
+
+ // The following headers force validation of cache:
+ drupal_add_http_header('Expires', 'Sun, 19 Nov 1978 05:00:00 GMT');
+ drupal_add_http_header('Cache-Control', 'must-revalidate');
+ drupal_add_http_header('Content-Type', 'application/rss+xml; charset=utf-8');
+
+ // Read actual feed from file.
+ $file_name = DRUPAL_ROOT . '/' . drupal_get_path('module', 'aggregator') . '/tests/aggregator_test_rss091.xml';
+ $handle = fopen($file_name, 'r');
+ $feed = fread($handle, filesize($file_name));
+ fclose($handle);
+
+ print $feed;
+}
diff --git a/core/modules/aggregator/tests/aggregator_test_atom.xml b/core/modules/aggregator/tests/aggregator_test_atom.xml
new file mode 100644
index 000000000000..357b2e5a1565
--- /dev/null
+++ b/core/modules/aggregator/tests/aggregator_test_atom.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom">
+
+ <title>Example Feed</title>
+ <link href="http://example.org/" />
+ <updated>2003-12-13T18:30:02Z</updated>
+ <author>
+ <name>John Doe</name>
+ </author>
+ <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+
+ <entry>
+ <title>Atom-Powered Robots Run Amok</title>
+ <link href="http://example.org/2003/12/13/atom03" />
+ <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+ <updated>2003-12-13T18:30:02Z</updated>
+ <summary>Some text.</summary>
+ </entry>
+
+</feed>
diff --git a/core/modules/aggregator/tests/aggregator_test_rss091.xml b/core/modules/aggregator/tests/aggregator_test_rss091.xml
new file mode 100644
index 000000000000..f39a2732c034
--- /dev/null
+++ b/core/modules/aggregator/tests/aggregator_test_rss091.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="0.91">
+ <channel>
+ <title>Example</title>
+ <link>http://example.com</link>
+ <description>Example updates</description>
+ <language>en-us</language>
+ <copyright>Copyright 2000, Example team.</copyright>
+ <managingEditor>editor@example.com</managingEditor>
+ <webMaster>webmaster@example.com</webMaster>
+ <image>
+ <title>Example</title>
+ <url>http://example.com/images/druplicon.png</url>
+ <link>http://example.com</link>
+ <width>88</width>
+ <height>100</height>
+ <description>Example updates</description>
+ </image>
+ <item>
+ <title>First example feed item title</title>
+ <link>http://example.com/example-turns-one</link>
+ <description>First example feed item description.</description>
+ </item>
+ <item>
+ <title>Second example feed item title. This title is extremely long so that it exceeds the 255 character limit for titles in feed item storage. In fact it's so long that this sentence isn't long enough so I'm rambling a bit to make it longer, nearly there now. Ah now it's long enough so I'll shut up.</title>
+ <link>http://example.com/example-turns-two</link>
+ <description>Second example feed item description.</description>
+ </item>
+ </channel>
+</rss>
diff --git a/core/modules/block/block-admin-display-form.tpl.php b/core/modules/block/block-admin-display-form.tpl.php
new file mode 100644
index 000000000000..bb3d887e110d
--- /dev/null
+++ b/core/modules/block/block-admin-display-form.tpl.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to configure blocks.
+ *
+ * Available variables:
+ * - $block_regions: An array of regions. Keyed by name with the title as value.
+ * - $block_listing: An array of blocks keyed by region and then delta.
+ * - $form_submit: Form submit button.
+ *
+ * Each $block_listing[$region] contains an array of blocks for that region.
+ *
+ * Each $data in $block_listing[$region] contains:
+ * - $data->region_title: Region title for the listed block.
+ * - $data->block_title: Block title.
+ * - $data->region_select: Drop-down menu for assigning a region.
+ * - $data->weight_select: Drop-down menu for setting weights.
+ * - $data->configure_link: Block configuration link.
+ * - $data->delete_link: For deleting user added blocks.
+ *
+ * @see template_preprocess_block_admin_display_form()
+ * @see theme_block_admin_display()
+ */
+?>
+<?php
+ // Add table javascript.
+ drupal_add_js('core/misc/tableheader.js');
+ drupal_add_js(drupal_get_path('module', 'block') . '/block.js');
+ foreach ($block_regions as $region => $title) {
+ drupal_add_tabledrag('blocks', 'match', 'sibling', 'block-region-select', 'block-region-' . $region, NULL, FALSE);
+ drupal_add_tabledrag('blocks', 'order', 'sibling', 'block-weight', 'block-weight-' . $region);
+ }
+?>
+<table id="blocks" class="sticky-enabled">
+ <thead>
+ <tr>
+ <th><?php print t('Block'); ?></th>
+ <th><?php print t('Region'); ?></th>
+ <th><?php print t('Weight'); ?></th>
+ <th colspan="2"><?php print t('Operations'); ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php $row = 0; ?>
+ <?php foreach ($block_regions as $region => $title): ?>
+ <tr class="region-title region-title-<?php print $region?>">
+ <td colspan="5"><?php print $title; ?></td>
+ </tr>
+ <tr class="region-message region-<?php print $region?>-message <?php print empty($block_listing[$region]) ? 'region-empty' : 'region-populated'; ?>">
+ <td colspan="5"><em><?php print t('No blocks in this region'); ?></em></td>
+ </tr>
+ <?php foreach ($block_listing[$region] as $delta => $data): ?>
+ <tr class="draggable <?php print $row % 2 == 0 ? 'odd' : 'even'; ?><?php print $data->row_class ? ' ' . $data->row_class : ''; ?>">
+ <td class="block"><?php print $data->block_title; ?></td>
+ <td><?php print $data->region_select; ?></td>
+ <td><?php print $data->weight_select; ?></td>
+ <td><?php print $data->configure_link; ?></td>
+ <td><?php print $data->delete_link; ?></td>
+ </tr>
+ <?php $row++; ?>
+ <?php endforeach; ?>
+ <?php endforeach; ?>
+ </tbody>
+</table>
+
+<?php print $form_submit; ?>
diff --git a/core/modules/block/block.admin.css b/core/modules/block/block.admin.css
new file mode 100644
index 000000000000..214c8a25e0a0
--- /dev/null
+++ b/core/modules/block/block.admin.css
@@ -0,0 +1,36 @@
+
+#blocks tr.region-title td {
+ font-weight: bold;
+}
+#blocks tr.region-message {
+ font-weight: normal;
+ color: #999;
+}
+#blocks tr.region-populated {
+ display: none;
+}
+.block-region {
+ background-color: #ff6;
+ margin-top: 4px;
+ margin-bottom: 4px;
+ padding: 3px;
+}
+a.block-demo-backlink,
+a.block-demo-backlink:link,
+a.block-demo-backlink:visited {
+ background-color: #B4D7F0;
+ -moz-border-radius: 0 0 10px 10px;
+ -webkit-border-radius: 0 0 10px 10px;
+ border-radius: 0 0 10px 10px;
+ color: #000;
+ font-family: "Lucida Grande", Verdana, sans-serif;
+ font-size: small;
+ line-height: 20px;
+ left: 20px; /*LTR*/
+ padding: 5px 10px;
+ position: fixed;
+ z-index: 499;
+}
+a.block-demo-backlink:hover {
+ text-decoration: underline;
+}
diff --git a/core/modules/block/block.admin.inc b/core/modules/block/block.admin.inc
new file mode 100644
index 000000000000..7169190da8e7
--- /dev/null
+++ b/core/modules/block/block.admin.inc
@@ -0,0 +1,701 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the block module.
+ */
+
+/**
+ * Menu callback for admin/structure/block/demo.
+ */
+function block_admin_demo($theme = NULL) {
+ drupal_add_css(drupal_get_path('module', 'block') . '/block.admin.css');
+ return '';
+}
+
+/**
+ * Menu callback for admin/structure/block.
+ *
+ * @param $theme
+ * The theme to display the administration page for. If not provided, defaults
+ * to the currently used theme.
+ */
+function block_admin_display($theme = NULL) {
+ global $theme_key;
+
+ drupal_theme_initialize();
+
+ if (!isset($theme)) {
+ // If theme is not specifically set, rehash for the current theme.
+ $theme = $theme_key;
+ }
+
+ // Fetch and sort blocks.
+ $blocks = block_admin_display_prepare_blocks($theme);
+
+ return drupal_get_form('block_admin_display_form', $blocks, $theme);
+}
+
+/**
+ * Prepares a list of blocks for display on the blocks administration page.
+ *
+ * @param $theme
+ * The machine-readable name of the theme whose blocks should be returned.
+ *
+ * @return
+ * An array of blocks, as returned by _block_rehash(), sorted by region in
+ * preparation for display on the blocks administration page.
+ *
+ * @see block_admin_display_form()
+ */
+function block_admin_display_prepare_blocks($theme) {
+ $blocks = _block_rehash($theme);
+ $compare_theme = &drupal_static('_block_compare:theme');
+ $compare_theme = $theme;
+ usort($blocks, '_block_compare');
+ return $blocks;
+}
+
+/**
+ * Form builder for the main blocks administration form.
+ *
+ * @param $blocks
+ * An array of blocks, as returned by block_admin_display_prepare_blocks().
+ * @param $theme
+ * A string representing the name of the theme to edit blocks for.
+ * @param $block_regions
+ * (optional) An array of regions in which the blocks will be allowed to be
+ * placed. Defaults to all visible regions for the theme whose blocks are
+ * being configured. In all cases, a dummy region for disabled blocks will
+ * also be displayed.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see block_admin_display_form_submit()
+ */
+function block_admin_display_form($form, &$form_state, $blocks, $theme, $block_regions = NULL) {
+
+ $form['#attached']['css'] = array(drupal_get_path('module', 'block') . '/block.admin.css');
+
+ // Get a list of block regions if one was not provided.
+ if (!isset($block_regions)) {
+ $block_regions = system_region_list($theme, REGIONS_VISIBLE);
+ }
+
+ // Weights range from -delta to +delta, so delta should be at least half
+ // of the amount of blocks present. This makes sure all blocks in the same
+ // region get an unique weight.
+ $weight_delta = round(count($blocks) / 2);
+
+ // Build the form tree.
+ $form['edited_theme'] = array(
+ '#type' => 'value',
+ '#value' => $theme,
+ );
+ $form['block_regions'] = array(
+ '#type' => 'value',
+ // Add a last region for disabled blocks.
+ '#value' => $block_regions + array(BLOCK_REGION_NONE => BLOCK_REGION_NONE),
+ );
+ $form['blocks'] = array();
+ $form['#tree'] = TRUE;
+
+ foreach ($blocks as $i => $block) {
+ $key = $block['module'] . '_' . $block['delta'];
+ $form['blocks'][$key]['module'] = array(
+ '#type' => 'value',
+ '#value' => $block['module'],
+ );
+ $form['blocks'][$key]['delta'] = array(
+ '#type' => 'value',
+ '#value' => $block['delta'],
+ );
+ $form['blocks'][$key]['info'] = array(
+ '#markup' => check_plain($block['info']),
+ );
+ $form['blocks'][$key]['theme'] = array(
+ '#type' => 'hidden',
+ '#value' => $theme,
+ );
+ $form['blocks'][$key]['weight'] = array(
+ '#type' => 'weight',
+ '#default_value' => $block['weight'],
+ '#delta' => $weight_delta,
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for @block block', array('@block' => $block['info'])),
+ );
+ $form['blocks'][$key]['region'] = array(
+ '#type' => 'select',
+ '#default_value' => $block['region'] != BLOCK_REGION_NONE ? $block['region'] : NULL,
+ '#empty_value' => BLOCK_REGION_NONE,
+ '#title_display' => 'invisible',
+ '#title' => t('Region for @block block', array('@block' => $block['info'])),
+ '#options' => $block_regions,
+ );
+ $form['blocks'][$key]['configure'] = array(
+ '#type' => 'link',
+ '#title' => t('configure'),
+ '#href' => 'admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure',
+ );
+ if ($block['module'] == 'block') {
+ $form['blocks'][$key]['delete'] = array(
+ '#type' => 'link',
+ '#title' => t('delete'),
+ '#href' => 'admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/delete',
+ );
+ }
+ }
+ // Do not allow disabling the main system content block when it is present.
+ if (isset($form['blocks']['system_main']['region'])) {
+ $form['blocks']['system_main']['region']['#required'] = TRUE;
+ }
+
+ $form['actions'] = array(
+ '#tree' => FALSE,
+ '#type' => 'actions',
+ );
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save blocks'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form submission handler for the main blocks administration form.
+ *
+ * @see block_admin_display_form()
+ */
+function block_admin_display_form_submit($form, &$form_state) {
+ $transaction = db_transaction();
+ try {
+ foreach ($form_state['values']['blocks'] as $block) {
+ $block['status'] = (int) ($block['region'] != BLOCK_REGION_NONE);
+ $block['region'] = $block['status'] ? $block['region'] : '';
+ db_update('block')
+ ->fields(array(
+ 'status' => $block['status'],
+ 'weight' => $block['weight'],
+ 'region' => $block['region'],
+ ))
+ ->condition('module', $block['module'])
+ ->condition('delta', $block['delta'])
+ ->condition('theme', $block['theme'])
+ ->execute();
+ }
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('block', $e);
+ throw $e;
+ }
+ drupal_set_message(t('The block settings have been updated.'));
+ cache_clear_all();
+}
+
+/**
+ * Helper function for sorting blocks on admin/structure/block.
+ *
+ * Active blocks are sorted by region, then by weight.
+ * Disabled blocks are sorted by name.
+ */
+function _block_compare($a, $b) {
+ global $theme_key;
+
+ // Theme should be set before calling this function, or the current theme
+ // is being used.
+ $theme = &drupal_static(__FUNCTION__ . ':theme');
+ if (!isset($theme)) {
+ $theme = $theme_key;
+ }
+
+ $regions = &drupal_static(__FUNCTION__ . ':regions');
+ // We need the region list to correctly order by region.
+ if (!isset($regions)) {
+ $regions = array_flip(array_keys(system_region_list($theme)));
+ $regions[BLOCK_REGION_NONE] = count($regions);
+ }
+
+ // Separate enabled from disabled.
+ $status = $b['status'] - $a['status'];
+ if ($status) {
+ return $status;
+ }
+ // Sort by region (in the order defined by theme .info file).
+ if ((!empty($a['region']) && !empty($b['region'])) && ($place = ($regions[$a['region']] - $regions[$b['region']]))) {
+ return $place;
+ }
+ // Sort by weight, unless disabled.
+ if ($a['region'] != BLOCK_REGION_NONE) {
+ $weight = $a['weight'] - $b['weight'];
+ if ($weight) {
+ return $weight;
+ }
+ }
+ // Sort by title.
+ return strcmp($a['info'], $b['info']);
+}
+
+/**
+ * Form builder for the block configuration form.
+ *
+ * Also used by block_add_block_form() for adding a new custom block.
+ *
+ * @param $module
+ * Name of the module that implements the block to be configured.
+ * @param $delta
+ * Unique ID of the block within the context of $module.
+ *
+ * @see block_admin_configure_validate()
+ * @see block_admin_configure_submit()
+ * @ingroup forms
+ */
+function block_admin_configure($form, &$form_state, $module, $delta) {
+ $block = block_load($module, $delta);
+ $form['module'] = array(
+ '#type' => 'value',
+ '#value' => $block->module,
+ );
+ $form['delta'] = array(
+ '#type' => 'value',
+ '#value' => $block->delta,
+ );
+
+ // Get the block subject for the page title.
+ $info = module_invoke($block->module, 'block_info');
+ if (isset($info[$block->delta])) {
+ drupal_set_title(t("'%name' block", array('%name' => $info[$block->delta]['info'])), PASS_THROUGH);
+ }
+
+ $form['settings']['title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Block title'),
+ '#maxlength' => 64,
+ '#description' => $block->module == 'block' ? t('The title of the block as shown to the user.') : t('Override the default title for the block. Use <em>!placeholder</em> to display no title, or leave blank to use the default block title.', array('!placeholder' => '&lt;none&gt;')),
+ '#default_value' => isset($block->title) ? $block->title : '',
+ '#weight' => -19,
+ );
+
+ // Module-specific block configuration.
+ if ($settings = module_invoke($block->module, 'block_configure', $block->delta)) {
+ foreach ($settings as $k => $v) {
+ $form['settings'][$k] = $v;
+ }
+ }
+
+ // Region settings.
+ $form['regions'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Region settings'),
+ '#collapsible' => FALSE,
+ '#description' => t('Specify in which themes and regions this block is displayed.'),
+ '#tree' => TRUE,
+ );
+
+ $theme_default = variable_get('theme_default', 'bartik');
+ $admin_theme = variable_get('admin_theme');
+ foreach (list_themes() as $key => $theme) {
+ // Only display enabled themes
+ if ($theme->status) {
+ $region = db_query("SELECT region FROM {block} WHERE module = :module AND delta = :delta AND theme = :theme", array(
+ ':module' => $block->module,
+ ':delta' => $block->delta,
+ ':theme' => $key,
+ ))->fetchField();
+
+ // Use a meaningful title for the main site theme and administrative
+ // theme.
+ $theme_title = $theme->info['name'];
+ if ($key == $theme_default) {
+ $theme_title = t('!theme (default theme)', array('!theme' => $theme_title));
+ }
+ elseif ($admin_theme && $key == $admin_theme) {
+ $theme_title = t('!theme (administration theme)', array('!theme' => $theme_title));
+ }
+ $form['regions'][$key] = array(
+ '#type' => 'select',
+ '#title' => $theme_title,
+ '#default_value' => !empty($region) && $region != -1 ? $region : NULL,
+ '#empty_value' => BLOCK_REGION_NONE,
+ '#options' => system_region_list($key, REGIONS_VISIBLE),
+ '#weight' => ($key == $theme_default ? 9 : 10),
+ );
+ }
+ }
+
+ // Visibility settings.
+ $form['visibility_title'] = array(
+ '#type' => 'item',
+ '#title' => t('Visibility settings'),
+ );
+ $form['visibility'] = array(
+ '#type' => 'vertical_tabs',
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'block') . '/block.js'),
+ ),
+ );
+
+ // Per-path visibility.
+ $form['visibility']['path'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Pages'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'visibility',
+ '#weight' => 0,
+ );
+
+ $access = user_access('use PHP for settings');
+ if (isset($block->visibility) && $block->visibility == BLOCK_VISIBILITY_PHP && !$access) {
+ $form['visibility']['path']['visibility'] = array(
+ '#type' => 'value',
+ '#value' => BLOCK_VISIBILITY_PHP,
+ );
+ $form['visibility']['path']['pages'] = array(
+ '#type' => 'value',
+ '#value' => isset($block->pages) ? $block->pages : '',
+ );
+ }
+ else {
+ $options = array(
+ BLOCK_VISIBILITY_NOTLISTED => t('All pages except those listed'),
+ BLOCK_VISIBILITY_LISTED => t('Only the listed pages'),
+ );
+ $description = t("Specify pages by using their paths. Enter one path per line. The '*' character is a wildcard. Example paths are %user for the current user's page and %user-wildcard for every user page. %front is the front page.", array('%user' => 'user', '%user-wildcard' => 'user/*', '%front' => '<front>'));
+
+ if (module_exists('php') && $access) {
+ $options += array(BLOCK_VISIBILITY_PHP => t('Pages on which this PHP code returns <code>TRUE</code> (experts only)'));
+ $title = t('Pages or PHP code');
+ $description .= ' ' . t('If the PHP option is chosen, enter PHP code between %php. Note that executing incorrect PHP code can break your Drupal site.', array('%php' => '<?php ?>'));
+ }
+ else {
+ $title = t('Pages');
+ }
+ $form['visibility']['path']['visibility'] = array(
+ '#type' => 'radios',
+ '#title' => t('Show block on specific pages'),
+ '#options' => $options,
+ '#default_value' => isset($block->visibility) ? $block->visibility : BLOCK_VISIBILITY_NOTLISTED,
+ );
+ $form['visibility']['path']['pages'] = array(
+ '#type' => 'textarea',
+ '#title' => '<span class="element-invisible">' . $title . '</span>',
+ '#default_value' => isset($block->pages) ? $block->pages : '',
+ '#description' => $description,
+ );
+ }
+
+ // Per-role visibility.
+ $default_role_options = db_query("SELECT rid FROM {block_role} WHERE module = :module AND delta = :delta", array(
+ ':module' => $block->module,
+ ':delta' => $block->delta,
+ ))->fetchCol();
+ $role_options = array_map('check_plain', user_roles());
+ $form['visibility']['role'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Roles'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'visibility',
+ '#weight' => 10,
+ );
+ $form['visibility']['role']['roles'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Show block for specific roles'),
+ '#default_value' => $default_role_options,
+ '#options' => $role_options,
+ '#description' => t('Show this block only for the selected role(s). If you select no roles, the block will be visible to all users.'),
+ );
+
+ // Per-user visibility.
+ $form['visibility']['user'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Users'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'visibility',
+ '#weight' => 20,
+ );
+ $form['visibility']['user']['custom'] = array(
+ '#type' => 'radios',
+ '#title' => t('Customizable per user'),
+ '#options' => array(
+ BLOCK_CUSTOM_FIXED => t('Not customizable'),
+ BLOCK_CUSTOM_ENABLED => t('Customizable, visible by default'),
+ BLOCK_CUSTOM_DISABLED => t('Customizable, hidden by default'),
+ ),
+ '#description' => t('Allow individual users to customize the visibility of this block in their account settings.'),
+ '#default_value' => isset($block->custom) ? $block->custom : BLOCK_CUSTOM_FIXED,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save block'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for the block configuration form.
+ *
+ * @see block_admin_configure()
+ * @see block_admin_configure_submit()
+ */
+function block_admin_configure_validate($form, &$form_state) {
+ if ($form_state['values']['module'] == 'block') {
+ $custom_block_exists = (bool) db_query_range('SELECT 1 FROM {block_custom} WHERE bid <> :bid AND info = :info', 0, 1, array(
+ ':bid' => $form_state['values']['delta'],
+ ':info' => $form_state['values']['info'],
+ ))->fetchField();
+ if (empty($form_state['values']['info']) || $custom_block_exists) {
+ form_set_error('info', t('Ensure that each block description is unique.'));
+ }
+ }
+}
+
+/**
+ * Form submission handler for the block configuration form.
+ *
+ * @see block_admin_configure()
+ * @see block_admin_configure_validate()
+ */
+function block_admin_configure_submit($form, &$form_state) {
+ if (!form_get_errors()) {
+ $transaction = db_transaction();
+ try {
+ db_update('block')
+ ->fields(array(
+ 'visibility' => (int) $form_state['values']['visibility'],
+ 'pages' => trim($form_state['values']['pages']),
+ 'custom' => (int) $form_state['values']['custom'],
+ 'title' => $form_state['values']['title'],
+ ))
+ ->condition('module', $form_state['values']['module'])
+ ->condition('delta', $form_state['values']['delta'])
+ ->execute();
+
+ db_delete('block_role')
+ ->condition('module', $form_state['values']['module'])
+ ->condition('delta', $form_state['values']['delta'])
+ ->execute();
+ $query = db_insert('block_role')->fields(array('rid', 'module', 'delta'));
+ foreach (array_filter($form_state['values']['roles']) as $rid) {
+ $query->values(array(
+ 'rid' => $rid,
+ 'module' => $form_state['values']['module'],
+ 'delta' => $form_state['values']['delta'],
+ ));
+ }
+ $query->execute();
+
+ // Store regions per theme for this block
+ foreach ($form_state['values']['regions'] as $theme => $region) {
+ db_merge('block')
+ ->key(array('theme' => $theme, 'delta' => $form_state['values']['delta'], 'module' => $form_state['values']['module']))
+ ->fields(array(
+ 'region' => ($region == BLOCK_REGION_NONE ? '' : $region),
+ 'pages' => trim($form_state['values']['pages']),
+ 'status' => (int) ($region != BLOCK_REGION_NONE),
+ ))
+ ->execute();
+ }
+
+ module_invoke($form_state['values']['module'], 'block_save', $form_state['values']['delta'], $form_state['values']);
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('block', $e);
+ throw $e;
+ }
+ drupal_set_message(t('The block configuration has been saved.'));
+ cache_clear_all();
+ $form_state['redirect'] = 'admin/structure/block';
+ }
+}
+
+/**
+ * Form builder for the add block form.
+ *
+ * @see block_add_block_form_validate()
+ * @see block_add_block_form_submit()
+ * @ingroup forms
+ */
+function block_add_block_form($form, &$form_state) {
+ return block_admin_configure($form, $form_state, 'block', NULL);
+}
+
+/**
+ * Form validation handler for the add block form.
+ *
+ * @see block_add_block_form()
+ * @see block_add_block_form_submit()
+ */
+function block_add_block_form_validate($form, &$form_state) {
+ $custom_block_exists = (bool) db_query_range('SELECT 1 FROM {block_custom} WHERE info = :info', 0, 1, array(':info' => $form_state['values']['info']))->fetchField();
+
+ if (empty($form_state['values']['info']) || $custom_block_exists) {
+ form_set_error('info', t('Ensure that each block description is unique.'));
+ }
+}
+
+/**
+ * Form submission handler for the add block form.
+ *
+ * Saves the new custom block.
+ *
+ * @see block_add_block_form()
+ * @see block_add_block_form_validate()
+ */
+function block_add_block_form_submit($form, &$form_state) {
+ $delta = db_insert('block_custom')
+ ->fields(array(
+ 'body' => $form_state['values']['body']['value'],
+ 'info' => $form_state['values']['info'],
+ 'format' => $form_state['values']['body']['format'],
+ ))
+ ->execute();
+ // Store block delta to allow other modules to work with new block.
+ $form_state['values']['delta'] = $delta;
+
+ $query = db_insert('block')->fields(array('visibility', 'pages', 'custom', 'title', 'module', 'theme', 'status', 'weight', 'delta', 'cache'));
+ foreach (list_themes() as $key => $theme) {
+ if ($theme->status) {
+ $query->values(array(
+ 'visibility' => (int) $form_state['values']['visibility'],
+ 'pages' => trim($form_state['values']['pages']),
+ 'custom' => (int) $form_state['values']['custom'],
+ 'title' => $form_state['values']['title'],
+ 'module' => $form_state['values']['module'],
+ 'theme' => $theme->name,
+ 'status' => 0,
+ 'weight' => 0,
+ 'delta' => $delta,
+ 'cache' => DRUPAL_NO_CACHE,
+ ));
+ }
+ }
+ $query->execute();
+
+ $query = db_insert('block_role')->fields(array('rid', 'module', 'delta'));
+ foreach (array_filter($form_state['values']['roles']) as $rid) {
+ $query->values(array(
+ 'rid' => $rid,
+ 'module' => $form_state['values']['module'],
+ 'delta' => $delta,
+ ));
+ }
+ $query->execute();
+
+ // Store regions per theme for this block
+ foreach ($form_state['values']['regions'] as $theme => $region) {
+ db_merge('block')
+ ->key(array('theme' => $theme, 'delta' => $delta, 'module' => $form_state['values']['module']))
+ ->fields(array(
+ 'region' => ($region == BLOCK_REGION_NONE ? '' : $region),
+ 'pages' => trim($form_state['values']['pages']),
+ 'status' => (int) ($region != BLOCK_REGION_NONE),
+ ))
+ ->execute();
+ }
+
+ drupal_set_message(t('The block has been created.'));
+ cache_clear_all();
+ $form_state['redirect'] = 'admin/structure/block';
+}
+
+/**
+ * Form builder for the custom block deletion form.
+ *
+ * @param $module
+ * The name of the module that implements the block to be deleted. This should
+ * always equal 'block' since it only allows custom blocks to be deleted.
+ * @param $delta
+ * The unique ID of the block within the context of $module.
+ *
+ * @see block_custom_block_delete_submit()
+ */
+function block_custom_block_delete($form, &$form_state, $module, $delta) {
+ $block = block_load($module, $delta);
+ $custom_block = block_custom_block_get($block->delta);
+ $form['info'] = array('#type' => 'hidden', '#value' => $custom_block['info'] ? $custom_block['info'] : $custom_block['title']);
+ $form['bid'] = array('#type' => 'hidden', '#value' => $block->delta);
+
+ return confirm_form($form, t('Are you sure you want to delete the block %name?', array('%name' => $custom_block['info'])), 'admin/structure/block', '', t('Delete'), t('Cancel'));
+}
+
+/**
+ * Form submission handler for the custom block deletion form.
+ *
+ * @see block_custom_block_delete()
+ */
+function block_custom_block_delete_submit($form, &$form_state) {
+ db_delete('block_custom')
+ ->condition('bid', $form_state['values']['bid'])
+ ->execute();
+ db_delete('block')
+ ->condition('module', 'block')
+ ->condition('delta', $form_state['values']['bid'])
+ ->execute();
+ db_delete('block_role')
+ ->condition('module', 'block')
+ ->condition('delta', $form_state['values']['bid'])
+ ->execute();
+ drupal_set_message(t('The block %name has been removed.', array('%name' => $form_state['values']['info'])));
+ cache_clear_all();
+ $form_state['redirect'] = 'admin/structure/block';
+ return;
+}
+
+/**
+ * Processes variables for block-admin-display-form.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $form
+ *
+ * @see block-admin-display.tpl.php
+ * @see theme_block_admin_display()
+ */
+function template_preprocess_block_admin_display_form(&$variables) {
+ $variables['block_regions'] = $variables['form']['block_regions']['#value'];
+ if (isset($variables['block_regions'][BLOCK_REGION_NONE])) {
+ $variables['block_regions'][BLOCK_REGION_NONE] = t('Disabled');
+ }
+
+ foreach ($variables['block_regions'] as $key => $value) {
+ // Initialize an empty array for the region.
+ $variables['block_listing'][$key] = array();
+ }
+
+ // Initialize disabled blocks array.
+ $variables['block_listing'][BLOCK_REGION_NONE] = array();
+
+ // Add each block in the form to the appropriate place in the block listing.
+ foreach (element_children($variables['form']['blocks']) as $i) {
+ $block = &$variables['form']['blocks'][$i];
+
+ // Fetch the region for the current block.
+ $region = (isset($block['region']['#default_value']) ? $block['region']['#default_value'] : BLOCK_REGION_NONE);
+
+ // Set special classes needed for table drag and drop.
+ $block['region']['#attributes']['class'] = array('block-region-select', 'block-region-' . $region);
+ $block['weight']['#attributes']['class'] = array('block-weight', 'block-weight-' . $region);
+
+ $variables['block_listing'][$region][$i] = new stdClass();
+ $variables['block_listing'][$region][$i]->row_class = !empty($block['#attributes']['class']) ? implode(' ', $block['#attributes']['class']) : '';
+ $variables['block_listing'][$region][$i]->block_modified = !empty($block['#attributes']['class']) && in_array('block-modified', $block['#attributes']['class']);
+ $variables['block_listing'][$region][$i]->block_title = drupal_render($block['info']);
+ $variables['block_listing'][$region][$i]->region_select = drupal_render($block['region']) . drupal_render($block['theme']);
+ $variables['block_listing'][$region][$i]->weight_select = drupal_render($block['weight']);
+ $variables['block_listing'][$region][$i]->configure_link = drupal_render($block['configure']);
+ $variables['block_listing'][$region][$i]->delete_link = !empty($block['delete']) ? drupal_render($block['delete']) : '';
+ $variables['block_listing'][$region][$i]->printed = FALSE;
+ }
+
+ $variables['form_submit'] = drupal_render_children($variables['form']);
+}
+
diff --git a/core/modules/block/block.api.php b/core/modules/block/block.api.php
new file mode 100644
index 000000000000..d33f59425468
--- /dev/null
+++ b/core/modules/block/block.api.php
@@ -0,0 +1,356 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Block module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Define all blocks provided by the module.
+ *
+ * This hook declares to Drupal what blocks are provided by your module and can
+ * optionally specify initial block configuration settings.
+ *
+ * In hook_block_info(), each block your module provides is given a unique
+ * identifier referred to as "delta" (the array key in the return value). Delta
+ * values only need to be unique within your module, and they are used in the
+ * following ways:
+ * - Passed into the other block hooks in your module as an argument to
+ * identify the block being configured or viewed.
+ * - Used to construct the default HTML ID of "block-MODULE-DELTA" applied to
+ * each block when it is rendered (which can then be used for CSS styling or
+ * JavaScript programming).
+ * - Used to define a theming template suggestion of block__MODULE__DELTA, for
+ * advanced theming possibilities.
+ * - Used by other modules to identify your block in hook_block_info_alter() and
+ * other alter hooks.
+ * The values of delta can be strings or numbers, but because of the uses above
+ * it is preferable to use descriptive strings whenever possible, and only use a
+ * numeric identifier if you have to (for instance if your module allows users
+ * to create several similar blocks that you identify within your module code
+ * with numeric IDs). The maximum length for delta values is 32 bytes.
+ *
+ * @return
+ * An associative array whose keys define the delta for each block and whose
+ * values contain the block descriptions. Each block description is itself an
+ * associative array, with the following key-value pairs:
+ * - 'info': (required) The human-readable administrative name of the block.
+ * This is used to identify the block on administration screens, and
+ * is not displayed to non-administrative users.
+ * - 'cache': (optional) A bitmask describing what kind of caching is
+ * appropriate for the block. Drupal provides the following bitmask
+ * constants for defining cache granularity:
+ * - DRUPAL_CACHE_PER_ROLE (default): The block can change depending on the
+ * roles the user viewing the page belongs to.
+ * - DRUPAL_CACHE_PER_USER: The block can change depending on the user
+ * viewing the page. This setting can be resource-consuming for sites
+ * with large number of users, and should only be used when
+ * DRUPAL_CACHE_PER_ROLE is not sufficient.
+ * - DRUPAL_CACHE_PER_PAGE: The block can change depending on the page
+ * being viewed.
+ * - DRUPAL_CACHE_GLOBAL: The block is the same for every user on every
+ * page where it is visible.
+ * - DRUPAL_NO_CACHE: The block should not get cached.
+ * - 'properties': (optional) Array of additional metadata to add to the
+ * block. Common properties include:
+ * - 'administrative': Boolean which categorizes this block as usable in
+ * an administrative context. This might include blocks which help an
+ * administrator approve/deny comments, or view recently created
+ * user accounts.
+ * - 'weight': (optional) Initial value for the ordering weight of this block.
+ * Most modules do not provide an initial value, and any value provided can
+ * be modified by a user on the block configuration screen.
+ * - 'status': (optional) Initial value for block enabled status. (1 =
+ * enabled, 0 = disabled). Most modules do not provide an initial value,
+ * and any value provided can be modified by a user on the block
+ * configuration screen.
+ * - 'region': (optional) Initial value for theme region within which this
+ * block is set. Most modules do not provide an initial value, and
+ * any value provided can be modified by a user on the block configuration
+ * screen. Note: If you set a region that isn't available in the currently
+ * enabled theme, the block will be disabled.
+ * - 'visibility': (optional) Initial value for the visibility flag, which
+ * tells how to interpret the 'pages' value. Possible values are:
+ * - BLOCK_VISIBILITY_NOTLISTED: Show on all pages except listed pages.
+ * 'pages' lists the paths where the block should not be shown.
+ * - BLOCK_VISIBILITY_LISTED: Show only on listed pages. 'pages' lists the
+ * paths where the block should be shown.
+ * - BLOCK_VISIBILITY_PHP: Use custom PHP code to determine visibility.
+ * 'pages' gives the PHP code to use.
+ * Most modules do not provide an initial value for 'visibility' or 'pages',
+ * and any value provided can be modified by a user on the block
+ * configuration screen.
+ * - 'pages': (optional) See 'visibility' above.
+ *
+ * For a detailed usage example, see block_example.module.
+ *
+ * @see hook_block_configure()
+ * @see hook_block_save()
+ * @see hook_block_view()
+ * @see hook_block_info_alter()
+ */
+function hook_block_info() {
+ // This example comes from node.module.
+ $blocks['syndicate'] = array(
+ 'info' => t('Syndicate'),
+ 'cache' => DRUPAL_NO_CACHE
+ );
+
+ $blocks['recent'] = array(
+ 'info' => t('Recent content'),
+ // DRUPAL_CACHE_PER_ROLE will be assumed.
+ );
+
+ return $blocks;
+}
+
+/**
+ * Change block definition before saving to the database.
+ *
+ * @param $blocks
+ * A multidimensional array of blocks keyed by the defining module and delta;
+ * the values are blocks returned by hook_block_info(). This hook is fired
+ * after the blocks are collected from hook_block_info() and the database,
+ * right before saving back to the database.
+ * @param $theme
+ * The theme these blocks belong to.
+ * @param $code_blocks
+ * The blocks as defined in hook_block_info() before being overwritten by the
+ * database data.
+ *
+ * @see hook_block_info()
+ */
+function hook_block_info_alter(&$blocks, $theme, $code_blocks) {
+ // Disable the login block.
+ $blocks['user']['login']['status'] = 0;
+}
+
+/**
+ * Define a configuration form for a block.
+ *
+ * @param $delta
+ * Which block is being configured. This is a unique identifier for the block
+ * within the module, defined in hook_block_info().
+ *
+ * @return
+ * A configuration form, if one is needed for your block beyond the standard
+ * elements that the block module provides (block title, visibility, etc.).
+ *
+ * For a detailed usage example, see block_example.module.
+ *
+ * @see hook_block_info()
+ * @see hook_block_save()
+ */
+function hook_block_configure($delta = '') {
+ // This example comes from node.module.
+ $form = array();
+ if ($delta == 'recent') {
+ $form['node_recent_block_count'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of recent content items to display'),
+ '#default_value' => variable_get('node_recent_block_count', 10),
+ '#options' => drupal_map_assoc(array(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30)),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Save the configuration options from hook_block_configure().
+ *
+ * This hook allows you to save the block-specific configuration settings
+ * defined within your hook_block_configure().
+ *
+ * @param $delta
+ * Which block is being configured. This is a unique identifier for the block
+ * within the module, defined in hook_block_info().
+ * @param $edit
+ * The submitted form data from the configuration form.
+ *
+ * For a detailed usage example, see block_example.module.
+ *
+ * @see hook_block_configure()
+ * @see hook_block_info()
+ */
+function hook_block_save($delta = '', $edit = array()) {
+ // This example comes from node.module.
+ if ($delta == 'recent') {
+ variable_set('node_recent_block_count', $edit['node_recent_block_count']);
+ }
+}
+
+/**
+ * Return a rendered or renderable view of a block.
+ *
+ * @param $delta
+ * Which block to render. This is a unique identifier for the block
+ * within the module, defined in hook_block_info().
+ *
+ * @return
+ * An array containing the following elements:
+ * - subject: The default localized title of the block. If the block does not
+ * have a default title, this should be set to NULL.
+ * - content: The content of the block's body. This may be a renderable array
+ * (preferable) or a string containing rendered HTML content.
+ *
+ * For a detailed usage example, see block_example.module.
+ *
+ * @see hook_block_info()
+ * @see hook_block_view_alter()
+ * @see hook_block_view_MODULE_DELTA_alter()
+ */
+function hook_block_view($delta = '') {
+ // This example is adapted from node.module.
+ $block = array();
+
+ switch ($delta) {
+ case 'syndicate':
+ $block['subject'] = t('Syndicate');
+ $block['content'] = array(
+ '#theme' => 'feed_icon',
+ '#url' => 'rss.xml',
+ '#title' => t('Syndicate'),
+ );
+ break;
+
+ case 'recent':
+ if (user_access('access content')) {
+ $block['subject'] = t('Recent content');
+ if ($nodes = node_get_recent(variable_get('node_recent_block_count', 10))) {
+ $block['content'] = array(
+ '#theme' => 'node_recent_block',
+ '#nodes' => $nodes,
+ );
+ } else {
+ $block['content'] = t('No content available.');
+ }
+ }
+ break;
+ }
+ return $block;
+}
+
+/**
+ * Perform alterations to the content of a block.
+ *
+ * This hook allows you to modify any data returned by hook_block_view().
+ *
+ * Note that instead of hook_block_view_alter(), which is called for all
+ * blocks, you can also use hook_block_view_MODULE_DELTA_alter() to alter a
+ * specific block.
+ *
+ * @param $data
+ * An array of data, as returned from the hook_block_view() implementation of
+ * the module that defined the block:
+ * - subject: The default localized title of the block.
+ * - content: Either a string or a renderable array representing the content
+ * of the block. You should check that the content is an array before trying
+ * to modify parts of the renderable structure.
+ * @param $block
+ * The block object, as loaded from the database, having the main properties:
+ * - module: The name of the module that defined the block.
+ * - delta: The unique identifier for the block within that module, as defined
+ * in hook_block_info().
+ *
+ * @see hook_block_view_MODULE_DELTA_alter()
+ * @see hook_block_view()
+ */
+function hook_block_view_alter(&$data, $block) {
+ // Remove the contextual links on all blocks that provide them.
+ if (is_array($data['content']) && isset($data['content']['#contextual_links'])) {
+ unset($data['content']['#contextual_links']);
+ }
+ // Add a theme wrapper function defined by the current module to all blocks
+ // provided by the "somemodule" module.
+ if (is_array($data['content']) && $block->module == 'somemodule') {
+ $data['content']['#theme_wrappers'][] = 'mymodule_special_block';
+ }
+}
+
+/**
+ * Perform alterations to a specific block.
+ *
+ * Modules can implement hook_block_view_MODULE_DELTA_alter() to modify a
+ * specific block, rather than implementing hook_block_view_alter().
+ *
+ * @param $data
+ * An array of data, as returned from the hook_block_view() implementation of
+ * the module that defined the block:
+ * - subject: The localized title of the block.
+ * - content: Either a string or a renderable array representing the content
+ * of the block. You should check that the content is an array before trying
+ * to modify parts of the renderable structure.
+ * @param $block
+ * The block object, as loaded from the database, having the main properties:
+ * - module: The name of the module that defined the block.
+ * - delta: The unique identifier for the block within that module, as defined
+ * in hook_block_info().
+ *
+ * @see hook_block_view_alter()
+ * @see hook_block_view()
+ */
+function hook_block_view_MODULE_DELTA_alter(&$data, $block) {
+ // This code will only run for a specific block. For example, if MODULE_DELTA
+ // in the function definition above is set to "mymodule_somedelta", the code
+ // will only run on the "somedelta" block provided by the "mymodule" module.
+
+ // Change the title of the "somedelta" block provided by the "mymodule"
+ // module.
+ $data['subject'] = t('New title of the block');
+}
+
+/**
+ * Act on blocks prior to rendering.
+ *
+ * This hook allows you to add, remove or modify blocks in the block list. The
+ * block list contains the block definitions, not the rendered blocks. The
+ * blocks are rendered after the modules have had a chance to manipulate the
+ * block list.
+ *
+ * You can also set $block->content here, which will override the content of the
+ * block and prevent hook_block_view() from running.
+ *
+ * @param $blocks
+ * An array of $blocks, keyed by the block ID.
+ */
+function hook_block_list_alter(&$blocks) {
+ global $language, $theme_key;
+
+ // This example shows how to achieve language specific visibility setting for
+ // blocks.
+
+ $result = db_query('SELECT module, delta, language FROM {my_table}');
+ $block_languages = array();
+ foreach ($result as $record) {
+ $block_languages[$record->module][$record->delta][$record->language] = TRUE;
+ }
+
+ foreach ($blocks as $key => $block) {
+ // Any module using this alter should inspect the data before changing it,
+ // to ensure it is what they expect.
+ if (!isset($block->theme) || !isset($block->status) || $block->theme != $theme_key || $block->status != 1) {
+ // This block was added by a contrib module, leave it in the list.
+ continue;
+ }
+
+ if (!isset($block_languages[$block->module][$block->delta])) {
+ // No language setting for this block, leave it in the list.
+ continue;
+ }
+
+ if (!isset($block_languages[$block->module][$block->delta][$language->language])) {
+ // This block should not be displayed with the active language, remove
+ // from the list.
+ unset($blocks[$key]);
+ }
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/block/block.info b/core/modules/block/block.info
new file mode 100644
index 000000000000..6ad58ad661b2
--- /dev/null
+++ b/core/modules/block/block.info
@@ -0,0 +1,7 @@
+name = Block
+description = Controls the visual building blocks a page is constructed with. Blocks are boxes of content rendered into an area, or region, of a web page.
+package = Core
+version = VERSION
+core = 8.x
+files[] = block.test
+configure = admin/structure/block
diff --git a/core/modules/block/block.install b/core/modules/block/block.install
new file mode 100644
index 000000000000..c2d4185c12b0
--- /dev/null
+++ b/core/modules/block/block.install
@@ -0,0 +1,204 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the block module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function block_schema() {
+ $schema['block'] = array(
+ 'description' => 'Stores block settings, such as region and visibility settings.',
+ 'fields' => array(
+ 'bid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique block ID.',
+ ),
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "The module from which the block originates; for example, 'user' for the Who's Online block, and 'block' for any custom blocks.",
+ ),
+ 'delta' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '0',
+ 'description' => 'Unique ID for block within a module.',
+ ),
+ 'theme' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The theme under which the block settings apply.',
+ ),
+ 'status' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Block enabled status. (1 = enabled, 0 = disabled)',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Block weight within region.',
+ ),
+ 'region' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Theme region within which the block is set.',
+ ),
+ 'custom' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Flag to indicate how users may control visibility of the block. (0 = Users cannot control, 1 = On by default, but can be hidden, 2 = Hidden by default, but can be shown)',
+ ),
+ 'visibility' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Flag to indicate how to show blocks on pages. (0 = Show on all pages except listed pages, 1 = Show only on listed pages, 2 = Use custom PHP code to determine visibility)',
+ ),
+ 'pages' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'description' => 'Contents of the "Pages" block; contains either a list of paths on which to include/exclude the block or PHP code, depending on "visibility" setting.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Custom title for the block. (Empty string will use block default title, <none> will remove the title, text will cause block to use specified title.)',
+ 'translatable' => TRUE,
+ ),
+ 'cache' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 1,
+ 'size' => 'tiny',
+ 'description' => 'Binary flag to indicate block cache mode. (-2: Custom cache, -1: Do not cache, 1: Cache per role, 2: Cache per user, 4: Cache per page, 8: Block cache global) See DRUPAL_CACHE_* constants in ../includes/common.inc for more detailed information.',
+ ),
+ ),
+ 'primary key' => array('bid'),
+ 'unique keys' => array(
+ 'tmd' => array('theme', 'module', 'delta'),
+ ),
+ 'indexes' => array(
+ 'list' => array('theme', 'status', 'region', 'weight', 'module'),
+ ),
+ );
+
+ $schema['block_role'] = array(
+ 'description' => 'Sets up access permissions for blocks based on user roles',
+ 'fields' => array(
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'description' => "The block's origin module, from {block}.module.",
+ ),
+ 'delta' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'description' => "The block's unique delta within module, from {block}.delta.",
+ ),
+ 'rid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => "The user's role ID from {users_roles}.rid.",
+ ),
+ ),
+ 'primary key' => array('module', 'delta', 'rid'),
+ 'indexes' => array(
+ 'rid' => array('rid'),
+ ),
+ );
+
+ $schema['block_custom'] = array(
+ 'description' => 'Stores contents of custom-made blocks.',
+ 'fields' => array(
+ 'bid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => "The block's {block}.bid.",
+ ),
+ 'body' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'description' => 'Block contents.',
+ 'translatable' => TRUE,
+ ),
+ 'info' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Block description.',
+ ),
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'The {filter_format}.format of the block body.',
+ ),
+ ),
+ 'unique keys' => array(
+ 'info' => array('info'),
+ ),
+ 'primary key' => array('bid'),
+ );
+
+ $schema['cache_block'] = drupal_get_schema_unprocessed('system', 'cache');
+ $schema['cache_block']['description'] = 'Cache table for the Block module to store already built blocks, identified by module, delta, and various contexts which may change the block, such as theme, locale, and caching mode defined for the block.';
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function block_install() {
+
+ // Block should go first so that other modules can alter its output
+ // during hook_page_alter(). Almost everything on the page is a block,
+ // so before block module runs, there will not be much to alter.
+ db_update('system')
+ ->fields(array('weight' => -5))
+ ->condition('name', 'block')
+ ->execute();
+}
+
+/**
+ * @addtogroup updates-7.x-to-8.x
+ * @{
+ */
+
+/**
+ * Block cache is always enabled in 8.x.
+ */
+function block_update_8000() {
+ variable_del('block_cache');
+}
+
+/**
+ * @} End of "addtogroup updates-7.x-to-8.x"
+ * The next series of updates should start at 9000.
+ */
diff --git a/core/modules/block/block.js b/core/modules/block/block.js
new file mode 100644
index 000000000000..ce4995dcadf7
--- /dev/null
+++ b/core/modules/block/block.js
@@ -0,0 +1,168 @@
+(function ($) {
+
+/**
+ * Provide the summary information for the block settings vertical tabs.
+ */
+Drupal.behaviors.blockSettingsSummary = {
+ attach: function (context) {
+ // The drupalSetSummary method required for this behavior is not available
+ // on the Blocks administration page, so we need to make sure this
+ // behavior is processed only if drupalSetSummary is defined.
+ if (typeof jQuery.fn.drupalSetSummary == 'undefined') {
+ return;
+ }
+
+ $('fieldset#edit-path', context).drupalSetSummary(function (context) {
+ if (!$('textarea[name="pages"]', context).val()) {
+ return Drupal.t('Not restricted');
+ }
+ else {
+ return Drupal.t('Restricted to certain pages');
+ }
+ });
+
+ $('fieldset#edit-node-type', context).drupalSetSummary(function (context) {
+ var vals = [];
+ $('input[type="checkbox"]:checked', context).each(function () {
+ vals.push($.trim($(this).next('label').text()));
+ });
+ if (!vals.length) {
+ vals.push(Drupal.t('Not restricted'));
+ }
+ return vals.join(', ');
+ });
+
+ $('fieldset#edit-role', context).drupalSetSummary(function (context) {
+ var vals = [];
+ $('input[type="checkbox"]:checked', context).each(function () {
+ vals.push($.trim($(this).next('label').text()));
+ });
+ if (!vals.length) {
+ vals.push(Drupal.t('Not restricted'));
+ }
+ return vals.join(', ');
+ });
+
+ $('fieldset#edit-user', context).drupalSetSummary(function (context) {
+ var $radio = $('input[name="custom"]:checked', context);
+ if ($radio.val() == 0) {
+ return Drupal.t('Not customizable');
+ }
+ else {
+ return $radio.next('label').text();
+ }
+ });
+ }
+};
+
+/**
+ * Move a block in the blocks table from one region to another via select list.
+ *
+ * This behavior is dependent on the tableDrag behavior, since it uses the
+ * objects initialized in that behavior to update the row.
+ */
+Drupal.behaviors.blockDrag = {
+ attach: function (context, settings) {
+ // tableDrag is required and we should be on the blocks admin page.
+ if (typeof Drupal.tableDrag == 'undefined' || typeof Drupal.tableDrag.blocks == 'undefined') {
+ return;
+ }
+
+ var table = $('table#blocks');
+ var tableDrag = Drupal.tableDrag.blocks; // Get the blocks tableDrag object.
+
+ // Add a handler for when a row is swapped, update empty regions.
+ tableDrag.row.prototype.onSwap = function (swappedRow) {
+ checkEmptyRegions(table, this);
+ };
+
+ // A custom message for the blocks page specifically.
+ Drupal.theme.tableDragChangedWarning = function () {
+ return '<div class="messages warning">' + Drupal.theme('tableDragChangedMarker') + ' ' + Drupal.t('The changes to these blocks will not be saved until the <em>Save blocks</em> button is clicked.') + '</div>';
+ };
+
+ // Add a handler so when a row is dropped, update fields dropped into new regions.
+ tableDrag.onDrop = function () {
+ dragObject = this;
+ // Use "region-message" row instead of "region" row because
+ // "region-{region_name}-message" is less prone to regexp match errors.
+ var regionRow = $(dragObject.rowObject.element).prevAll('tr.region-message').get(0);
+ var regionName = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2');
+ var regionField = $('select.block-region-select', dragObject.rowObject.element);
+ // Check whether the newly picked region is available for this block.
+ if ($('option[value=' + regionName + ']', regionField).length == 0) {
+ // If not, alert the user and keep the block in its old region setting.
+ alert(Drupal.t('The block cannot be placed in this region.'));
+ // Simulate that there was a selected element change, so the row is put
+ // back to from where the user tried to drag it.
+ regionField.change();
+ }
+ else if ($(dragObject.rowObject.element).prev('tr').is('.region-message')) {
+ var weightField = $('select.block-weight', dragObject.rowObject.element);
+ var oldRegionName = weightField[0].className.replace(/([^ ]+[ ]+)*block-weight-([^ ]+)([ ]+[^ ]+)*/, '$2');
+
+ if (!regionField.is('.block-region-' + regionName)) {
+ regionField.removeClass('block-region-' + oldRegionName).addClass('block-region-' + regionName);
+ weightField.removeClass('block-weight-' + oldRegionName).addClass('block-weight-' + regionName);
+ regionField.val(regionName);
+ }
+ }
+ };
+
+ // Add the behavior to each region select list.
+ $('select.block-region-select', context).once('block-region-select', function () {
+ $(this).change(function (event) {
+ // Make our new row and select field.
+ var row = $(this).parents('tr:first');
+ var select = $(this);
+ tableDrag.rowObject = new tableDrag.row(row);
+
+ // Find the correct region and insert the row as the first in the region.
+ $('tr.region-message', table).each(function () {
+ if ($(this).is('.region-' + select[0].value + '-message')) {
+ // Add the new row and remove the old one.
+ $(this).after(row);
+ // Manually update weights and restripe.
+ tableDrag.updateFields(row.get(0));
+ tableDrag.rowObject.changed = true;
+ if (tableDrag.oldRowElement) {
+ $(tableDrag.oldRowElement).removeClass('drag-previous');
+ }
+ tableDrag.oldRowElement = row.get(0);
+ tableDrag.restripeTable();
+ tableDrag.rowObject.markChanged();
+ tableDrag.oldRowElement = row;
+ $(row).addClass('drag-previous');
+ }
+ });
+
+ // Modify empty regions with added or removed fields.
+ checkEmptyRegions(table, row);
+ // Remove focus from selectbox.
+ select.get(0).blur();
+ });
+ });
+
+ var checkEmptyRegions = function (table, rowObject) {
+ $('tr.region-message', table).each(function () {
+ // If the dragged row is in this region, but above the message row, swap it down one space.
+ if ($(this).prev('tr').get(0) == rowObject.element) {
+ // Prevent a recursion problem when using the keyboard to move rows up.
+ if ((rowObject.method != 'keyboard' || rowObject.direction == 'down')) {
+ rowObject.swap('after', this);
+ }
+ }
+ // This region has become empty.
+ if ($(this).next('tr').is(':not(.draggable)') || $(this).next('tr').size() == 0) {
+ $(this).removeClass('region-populated').addClass('region-empty');
+ }
+ // This region has become populated.
+ else if ($(this).is('.region-empty')) {
+ $(this).removeClass('region-empty').addClass('region-populated');
+ }
+ });
+ };
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/block/block.module b/core/modules/block/block.module
new file mode 100644
index 000000000000..920090f82241
--- /dev/null
+++ b/core/modules/block/block.module
@@ -0,0 +1,1003 @@
+<?php
+
+/**
+ * @file
+ * Controls the visual building blocks a page is constructed with.
+ */
+
+/**
+ * Denotes that a block is not enabled in any region and should not be shown.
+ */
+define('BLOCK_REGION_NONE', -1);
+
+/**
+ * Users cannot control whether or not they see this block.
+ */
+define('BLOCK_CUSTOM_FIXED', 0);
+
+/**
+ * Show this block by default, but let individual users hide it.
+ */
+define('BLOCK_CUSTOM_ENABLED', 1);
+
+/**
+ * Hide this block by default but let individual users show it.
+ */
+define('BLOCK_CUSTOM_DISABLED', 2);
+
+/**
+ * Show this block on every page except the listed pages.
+ */
+define('BLOCK_VISIBILITY_NOTLISTED', 0);
+
+/**
+ * Show this block on only the listed pages.
+ */
+define('BLOCK_VISIBILITY_LISTED', 1);
+
+/**
+ * Show this block if the associated PHP code returns TRUE.
+ */
+define('BLOCK_VISIBILITY_PHP', 2);
+
+/**
+ * Implements hook_help().
+ */
+function block_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#block':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Block module allows you to create boxes of content, which are rendered into an area, or region, of one or more pages of a website. The core Seven administration theme, for example, implements the regions "Content", "Help", "Dashboard main", and "Dashboard sidebar", and a block may appear in any one of these regions. The <a href="@blocks">Blocks administration page</a> provides a drag-and-drop interface for assigning a block to a region, and for controlling the order of blocks within regions. For more information, see the online handbook entry for <a href="@block">Block module</a>.', array('@block' => 'http://drupal.org/handbook/modules/block/', '@blocks' => url('admin/structure/block'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Positioning content') . '</dt>';
+ $output .= '<dd>' . t('When working with blocks, remember that all themes do <em>not</em> implement the same regions, or display regions in the same way. Blocks are positioned on a per-theme basis. Users with the <em>Administer blocks</em> permission can disable blocks. Disabled blocks are listed on the <a href="@blocks">Blocks administration page</a>, but are not displayed in any region.', array('@block' => 'http://drupal.org/handbook/modules/block/', '@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '<dt>' . t('Controlling visibility') . '</dt>';
+ $output .= '<dd>' . t('Blocks can be configured to be visible only on certain pages, only to users of certain roles, or only on pages displaying certain <a href="@content-type">content types</a>. Administrators can also allow specific blocks to be enabled or disabled by users when they edit their <a href="@user">My account</a> page. Some dynamic blocks, such as those generated by modules, will be displayed only on certain pages.', array('@content-type' => url('admin/structure/types'), '@user' => url('user'))) . '</dd>';
+ $output .= '<dt>' . t('Creating custom blocks') . '</dt>';
+ $output .= '<dd>' . t('Users with the <em>Administer blocks</em> permission can <a href="@block-add">add custom blocks</a>, which are then listed on the <a href="@blocks">Blocks administration page</a>. Once created, custom blocks behave just like default and module-generated blocks.', array('@blocks' => url('admin/structure/block'), '@block-add' => url('admin/structure/block/add'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/structure/block/add':
+ return '<p>' . t('Use this page to create a new custom block.') . '</p>';
+ }
+ if ($arg[0] == 'admin' && $arg[1] == 'structure' && $arg['2'] == 'block' && (empty($arg[3]) || $arg[3] == 'list')) {
+ $demo_theme = !empty($arg[4]) ? $arg[4] : variable_get('theme_default', 'bartik');
+ $themes = list_themes();
+ $output = '<p>' . t('This page provides a drag-and-drop interface for assigning a block to a region, and for controlling the order of blocks within regions. Since not all themes implement the same regions, or display regions in the same way, blocks are positioned on a per-theme basis. Remember that your changes will not be saved until you click the <em>Save blocks</em> button at the bottom of the page. Click the <em>configure</em> link next to each block to configure its specific title and visibility settings.') . '</p>';
+ $output .= '<p>' . l(t('Demonstrate block regions (@theme)', array('@theme' => $themes[$demo_theme]->info['name'])), 'admin/structure/block/demo/' . $demo_theme) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function block_theme() {
+ return array(
+ 'block' => array(
+ 'render element' => 'elements',
+ 'template' => 'block',
+ ),
+ 'block_admin_display_form' => array(
+ 'template' => 'block-admin-display-form',
+ 'file' => 'block.admin.inc',
+ 'render element' => 'form',
+ ),
+ );
+}
+
+/**
+ * Implements hook_permission().
+ */
+function block_permission() {
+ return array(
+ 'administer blocks' => array(
+ 'title' => t('Administer blocks'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function block_menu() {
+ $default_theme = variable_get('theme_default', 'bartik');
+ $items['admin/structure/block'] = array(
+ 'title' => 'Blocks',
+ 'description' => 'Configure what block content appears in your site\'s sidebars and other regions.',
+ 'page callback' => 'block_admin_display',
+ 'page arguments' => array($default_theme),
+ 'access arguments' => array('administer blocks'),
+ 'file' => 'block.admin.inc',
+ );
+ $items['admin/structure/block/manage/%/%'] = array(
+ 'title' => 'Configure block',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('block_admin_configure', 4, 5),
+ 'access arguments' => array('administer blocks'),
+ 'file' => 'block.admin.inc',
+ );
+ $items['admin/structure/block/manage/%/%/configure'] = array(
+ 'title' => 'Configure block',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ );
+ $items['admin/structure/block/manage/%/%/delete'] = array(
+ 'title' => 'Delete block',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('block_custom_block_delete', 4, 5),
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_NONE,
+ 'file' => 'block.admin.inc',
+ );
+ $items['admin/structure/block/add'] = array(
+ 'title' => 'Add block',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('block_add_block_form'),
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'block.admin.inc',
+ );
+ foreach (list_themes() as $key => $theme) {
+ $items['admin/structure/block/list/' . $key] = array(
+ 'title' => check_plain($theme->info['name']),
+ 'page arguments' => array($key),
+ 'type' => $key == $default_theme ? MENU_DEFAULT_LOCAL_TASK : MENU_LOCAL_TASK,
+ 'weight' => $key == $default_theme ? -10 : 0,
+ 'access callback' => '_block_themes_access',
+ 'access arguments' => array($key),
+ 'file' => 'block.admin.inc',
+ );
+ if ($key != $default_theme) {
+ $items['admin/structure/block/list/' . $key . '/add'] = array(
+ 'title' => 'Add block',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('block_add_block_form'),
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'block.admin.inc',
+ );
+ }
+ $items['admin/structure/block/demo/' . $key] = array(
+ 'title' => check_plain($theme->info['name']),
+ 'page callback' => 'block_admin_demo',
+ 'page arguments' => array($key),
+ 'type' => MENU_CALLBACK,
+ 'access callback' => '_block_themes_access',
+ 'access arguments' => array($key),
+ 'theme callback' => '_block_custom_theme',
+ 'theme arguments' => array($key),
+ 'file' => 'block.admin.inc',
+ );
+ }
+ return $items;
+}
+
+/**
+ * Menu item access callback - only admin or enabled themes can be accessed.
+ */
+function _block_themes_access($theme) {
+ return user_access('administer blocks') && drupal_theme_access($theme);
+}
+
+/**
+ * Theme callback for the block configuration pages.
+ *
+ * @param $theme
+ * The theme whose blocks are being configured. If not set, the default theme
+ * is assumed.
+ * @return
+ * The theme that should be used for the block configuration page, or NULL
+ * to indicate that the default theme should be used.
+ */
+function _block_custom_theme($theme = NULL) {
+ // We return exactly what was passed in, to guarantee that the page will
+ // always be displayed using the theme whose blocks are being configured.
+ return $theme;
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function block_block_info() {
+ $blocks = array();
+
+ $result = db_query('SELECT bid, info FROM {block_custom} ORDER BY info');
+ foreach ($result as $block) {
+ $blocks[$block->bid]['info'] = $block->info;
+ // Not worth caching.
+ $blocks[$block->bid]['cache'] = DRUPAL_NO_CACHE;
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function block_block_configure($delta = 0) {
+ if ($delta) {
+ $custom_block = block_custom_block_get($delta);
+ }
+ else {
+ $custom_block = array();
+ }
+ return block_custom_block_form($custom_block);
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function block_block_save($delta = 0, $edit = array()) {
+ block_custom_block_save($edit, $delta);
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generates the administrator-defined blocks for display.
+ */
+function block_block_view($delta = '') {
+ $block = db_query('SELECT body, format FROM {block_custom} WHERE bid = :bid', array(':bid' => $delta))->fetchObject();
+ $data['subject'] = NULL;
+ $data['content'] = check_markup($block->body, $block->format, '', TRUE);
+ return $data;
+}
+
+/**
+ * Implements hook_page_build().
+ *
+ * Render blocks into their regions.
+ */
+function block_page_build(&$page) {
+ global $theme;
+
+ // The theme system might not yet be initialized. We need $theme.
+ drupal_theme_initialize();
+
+ // Fetch a list of regions for the current theme.
+ $all_regions = system_region_list($theme);
+
+ $item = menu_get_item();
+ if ($item['path'] != 'admin/structure/block/demo/' . $theme) {
+ // Load all region content assigned via blocks.
+ foreach (array_keys($all_regions) as $region) {
+ // Assign blocks to region.
+ if ($blocks = block_get_blocks_by_region($region)) {
+ $page[$region] = $blocks;
+ }
+ }
+ // Once we've finished attaching all blocks to the page, clear the static
+ // cache to allow modules to alter the block list differently in different
+ // contexts. For example, any code that triggers hook_page_build() more
+ // than once in the same page request may need to alter the block list
+ // differently each time, so that only certain parts of the page are
+ // actually built. We do not clear the cache any earlier than this, though,
+ // because it is used each time block_get_blocks_by_region() gets called
+ // above.
+ drupal_static_reset('block_list');
+ }
+ else {
+ // Append region description if we are rendering the regions demo page.
+ $item = menu_get_item();
+ if ($item['path'] == 'admin/structure/block/demo/' . $theme) {
+ $visible_regions = array_keys(system_region_list($theme, REGIONS_VISIBLE));
+ foreach ($visible_regions as $region) {
+ $description = '<div class="block-region">' . $all_regions[$region] . '</div>';
+ $page[$region]['block_description'] = array(
+ '#markup' => $description,
+ '#weight' => 15,
+ );
+ }
+ $page['page_top']['backlink'] = array(
+ '#type' => 'link',
+ '#title' => t('Exit block region demonstration'),
+ '#href' => 'admin/structure/block' . (variable_get('theme_default', 'bartik') == $theme ? '' : '/list/' . $theme),
+ // Add the "overlay-restore" class to indicate this link should restore
+ // the context in which the region demonstration page was opened.
+ '#options' => array('attributes' => array('class' => array('block-demo-backlink', 'overlay-restore'))),
+ '#weight' => -10,
+ );
+ }
+ }
+}
+
+/**
+ * Get a renderable array of a region containing all enabled blocks.
+ *
+ * @param $region
+ * The requested region.
+ */
+function block_get_blocks_by_region($region) {
+ $build = array();
+ if ($list = block_list($region)) {
+ $build = _block_get_renderable_region($list);
+ }
+ return $build;
+}
+
+/**
+ * Get an array of blocks suitable for drupal_render().
+ *
+ * @param $list
+ * A list of blocks such as that returned by block_list().
+ * @return
+ * A renderable array.
+ */
+function _block_get_renderable_region($list = array()) {
+ $weight = 0;
+ $build = array();
+ foreach ($list as $key => $block) {
+ $build[$key] = array(
+ '#block' => $block,
+ '#weight' => ++$weight,
+ '#theme_wrappers' => array('block'),
+ );
+
+ // Block caching is not compatible with node_access modules. We also
+ // preserve the submission of forms in blocks, by fetching from cache
+ // only if the request method is 'GET' (or 'HEAD'). User 1 being out of
+ // the regular 'roles define permissions' schema, it brings too many
+ // chances of having unwanted output get in the cache and later be served
+ // to other users. We therefore exclude user 1 from block caching.
+ if (
+ $GLOBALS['user']->uid == 1 ||
+ count(module_implements('node_grants')) ||
+ !in_array($_SERVER['REQUEST_METHOD'], array('GET', 'HEAD')) ||
+ in_array($block->cache, array(DRUPAL_NO_CACHE, DRUPAL_CACHE_CUSTOM))
+ ) {
+ // Non-cached blocks get built immediately. Provides more content
+ // that can be easily manipulated during hook_page_alter().
+ $build[$key] = _block_get_renderable_block($build[$key]);
+ }
+ else {
+ $build[$key] += array(
+ '#pre_render' => array('_block_get_renderable_block'),
+ '#cache' => array(
+ 'keys' => array($block->module, $block->delta),
+ 'granularity' => $block->cache,
+ 'bin' => 'block',
+ 'expire' => CACHE_TEMPORARY,
+ ),
+ );
+ }
+
+ // Add contextual links for this block; skip the main content block, since
+ // contextual links are basically output as tabs/local tasks already. Also
+ // skip the help block, since we assume that most users do not need or want
+ // to perform contextual actions on the help block, and the links needlessly
+ // draw attention on it.
+ if ($key != 'system_main' && $key != 'system_help') {
+ $build[$key]['#contextual_links']['block'] = array('admin/structure/block/manage', array($block->module, $block->delta));
+ }
+ }
+ $build['#sorted'] = TRUE;
+ return $build;
+}
+
+/**
+ * Update the 'block' DB table with the blocks currently exported by modules.
+ *
+ * @param $theme
+ * The theme to rehash blocks for. If not provided, defaults to the currently
+ * used theme.
+ *
+ * @return
+ * Blocks currently exported by modules.
+ */
+function _block_rehash($theme = NULL) {
+ global $theme_key;
+
+ drupal_theme_initialize();
+ if (!isset($theme)) {
+ // If theme is not specifically set, rehash for the current theme.
+ $theme = $theme_key;
+ }
+ $regions = system_region_list($theme);
+
+ // These are the blocks the function will return.
+ $blocks = array();
+ // These are the blocks defined by code and modified by the database.
+ $current_blocks = array();
+ // These are {block}.bid values to be kept.
+ $bids = array();
+ $or = db_or();
+ // Gather the blocks defined by modules.
+ foreach (module_implements('block_info') as $module) {
+ $module_blocks = module_invoke($module, 'block_info');
+ foreach ($module_blocks as $delta => $block) {
+ // Compile a condition to retrieve this block from the database.
+ $condition = db_and()
+ ->condition('module', $module)
+ ->condition('delta', $delta);
+ $or->condition($condition);
+ // Add identifiers.
+ $block['module'] = $module;
+ $block['delta'] = $delta;
+ $block['theme'] = $theme;
+ $current_blocks[$module][$delta] = $block;
+ }
+ }
+ // Save the blocks defined in code for alter context.
+ $code_blocks = $current_blocks;
+ $database_blocks = db_select('block', 'b')
+ ->fields('b')
+ ->condition($or)
+ ->condition('theme', $theme)
+ ->execute();
+ foreach ($database_blocks as $block) {
+ // Preserve info which is not in the database.
+ $block->info = $current_blocks[$block->module][$block->delta]['info'];
+ // The cache mode can only by set from hook_block_info(), so that has
+ // precedence over the database's value.
+ if (isset($current_blocks[$block->module][$block->delta]['cache'])) {
+ $block->cache = $current_blocks[$block->module][$block->delta]['cache'];
+ }
+ // Blocks stored in the database override the blocks defined in code.
+ $current_blocks[$block->module][$block->delta] = get_object_vars($block);
+ // Preserve this block.
+ $bids[$block->bid] = $block->bid;
+ }
+ drupal_alter('block_info', $current_blocks, $theme, $code_blocks);
+ foreach ($current_blocks as $module => $module_blocks) {
+ foreach ($module_blocks as $delta => $block) {
+ if (!isset($block['pages'])) {
+ // {block}.pages is type 'text', so it cannot have a
+ // default value, and not null, so we need to provide
+ // value if the module did not.
+ $block['pages'] = '';
+ }
+ // Make sure weight is set.
+ if (!isset($block['weight'])) {
+ $block['weight'] = 0;
+ }
+ if (!empty($block['region']) && $block['region'] != BLOCK_REGION_NONE && !isset($regions[$block['region']])) {
+ drupal_set_message(t('The block %info was assigned to the invalid region %region and has been disabled.', array('%info' => $block['info'], '%region' => $block['region'])), 'warning');
+ // Disabled modules are moved into the BLOCK_REGION_NONE later so no
+ // need to move the bock to another region.
+ $block['status'] = 0;
+ }
+ // Set region to none if not enabled and make sure status is set.
+ if (empty($block['status'])) {
+ $block['status'] = 0;
+ $block['region'] = BLOCK_REGION_NONE;
+ }
+ // There is no point saving disabled blocks. Still, we need to save them
+ // because the 'title' attribute is saved to the {blocks} table.
+ if (isset($block['bid'])) {
+ // If the block has a bid property, it comes from the database and
+ // the record needs to be updated, so set the primary key to 'bid'
+ // before passing to drupal_write_record().
+ $primary_keys = array('bid');
+ // Remove a block from the list of blocks to keep if it became disabled.
+ unset($bids[$block['bid']]);
+ }
+ else {
+ $primary_keys = array();
+ }
+ drupal_write_record('block', $block, $primary_keys);
+ // Add to the list of blocks we return.
+ $blocks[] = $block;
+ }
+ }
+ if ($bids) {
+ // Remove disabled that are no longer defined by the code from the
+ // database.
+ db_delete('block')
+ ->condition('bid', $bids, 'NOT IN')
+ ->condition('theme', $theme)
+ ->execute();
+ }
+ return $blocks;
+}
+
+/**
+ * Returns information from database about a user-created (custom) block.
+ *
+ * @param $bid
+ * ID of the block to get information for.
+ * @return
+ * Associative array of information stored in the database for this block.
+ * Array keys:
+ * - bid: Block ID.
+ * - info: Block description.
+ * - body: Block contents.
+ * - format: Filter ID of the filter format for the body.
+ */
+function block_custom_block_get($bid) {
+ return db_query("SELECT * FROM {block_custom} WHERE bid = :bid", array(':bid' => $bid))->fetchAssoc();
+}
+
+/**
+ * Define the custom block form.
+ */
+function block_custom_block_form($edit = array()) {
+ $edit += array(
+ 'info' => '',
+ 'body' => '',
+ );
+ $form['info'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Block description'),
+ '#default_value' => $edit['info'],
+ '#maxlength' => 64,
+ '#description' => t('A brief description of your block. Used on the <a href="@overview">Blocks administration page</a>.', array('@overview' => url('admin/structure/block'))),
+ '#required' => TRUE,
+ '#weight' => -18,
+ );
+ $form['body_field']['#weight'] = -17;
+ $form['body_field']['body'] = array(
+ '#type' => 'text_format',
+ '#title' => t('Block body'),
+ '#default_value' => $edit['body'],
+ '#format' => isset($edit['format']) ? $edit['format'] : NULL,
+ '#rows' => 15,
+ '#description' => t('The content of the block as shown to the user.'),
+ '#required' => TRUE,
+ '#weight' => -17,
+ );
+
+ return $form;
+}
+
+/**
+ * Saves a user-created block in the database.
+ *
+ * @param $edit
+ * Associative array of fields to save. Array keys:
+ * - info: Block description.
+ * - body: Associative array of body value and format. Array keys:
+ * - value: Block contents.
+ * - format: Filter ID of the filter format for the body.
+ * @param $delta
+ * Block ID of the block to save.
+ * @return
+ * Always returns TRUE.
+ */
+function block_custom_block_save($edit, $delta) {
+ db_update('block_custom')
+ ->fields(array(
+ 'body' => $edit['body']['value'],
+ 'info' => $edit['info'],
+ 'format' => $edit['body']['format'],
+ ))
+ ->condition('bid', $delta)
+ ->execute();
+ return TRUE;
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function block_form_user_profile_form_alter(&$form, &$form_state) {
+ if ($form['#user_category'] == 'account') {
+ $account = $form['#user'];
+ $rids = array_keys($account->roles);
+ $result = db_query("SELECT DISTINCT b.* FROM {block} b LEFT JOIN {block_role} r ON b.module = r.module AND b.delta = r.delta WHERE b.status = 1 AND b.custom <> 0 AND (r.rid IN (:rids) OR r.rid IS NULL) ORDER BY b.weight, b.module", array(':rids' => $rids));
+
+ $blocks = array();
+ foreach ($result as $block) {
+ $data = module_invoke($block->module, 'block_info');
+ if ($data[$block->delta]['info']) {
+ $blocks[$block->module][$block->delta] = array(
+ '#type' => 'checkbox',
+ '#title' => check_plain($data[$block->delta]['info']),
+ '#default_value' => isset($account->data['block'][$block->module][$block->delta]) ? $account->data['block'][$block->module][$block->delta] : ($block->custom == 1),
+ );
+ }
+ }
+ // Only display the fieldset if there are any personalizable blocks.
+ if ($blocks) {
+ $form['block'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Personalize blocks'),
+ '#description' => t('Blocks consist of content or information that complements the main content of the page. Enable or disable optional blocks using the checkboxes below.'),
+ '#weight' => 3,
+ '#collapsible' => TRUE,
+ '#tree' => TRUE,
+ );
+ $form['block'] += $blocks;
+ }
+ }
+}
+
+/**
+ * Implements hook_user_presave().
+ */
+function block_user_presave(&$edit, $account, $category) {
+ if (isset($edit['block'])) {
+ $edit['data']['block'] = $edit['block'];
+ }
+}
+
+/**
+ * Initialize blocks for enabled themes.
+ */
+function block_themes_enabled($theme_list) {
+ foreach ($theme_list as $theme) {
+ block_theme_initialize($theme);
+ }
+}
+
+/**
+ * Assign an initial, default set of blocks for a theme.
+ *
+ * This function is called the first time a new theme is enabled. The new theme
+ * gets a copy of the default theme's blocks, with the difference that if a
+ * particular region isn't available in the new theme, the block is assigned
+ * to the new theme's default region.
+ *
+ * @param $theme
+ * The name of a theme.
+ */
+function block_theme_initialize($theme) {
+ // Initialize theme's blocks if none already registered.
+ $has_blocks = (bool) db_query_range('SELECT 1 FROM {block} WHERE theme = :theme', 0, 1, array(':theme' => $theme))->fetchField();
+ if (!$has_blocks) {
+ $default_theme = variable_get('theme_default', 'bartik');
+ // Apply only to new theme's visible regions.
+ $regions = system_region_list($theme, REGIONS_VISIBLE);
+ $result = db_query("SELECT * FROM {block} WHERE theme = :theme", array(':theme' => $default_theme), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $block) {
+ // If the region isn't supported by the theme, assign the block to the theme's default region.
+ if ($block['status'] && !isset($regions[$block['region']])) {
+ $block['region'] = system_default_region($theme);
+ }
+ $block['theme'] = $theme;
+ unset($block['bid']);
+ drupal_write_record('block', $block);
+ }
+ }
+}
+
+/**
+ * Return all blocks in the specified region for the current user.
+ *
+ * @param $region
+ * The name of a region.
+ *
+ * @return
+ * An array of block objects, indexed with the module name and block delta
+ * concatenated with an underscore, thus: MODULE_DELTA. If you are displaying
+ * your blocks in one or two sidebars, you may check whether this array is
+ * empty to see how many columns are going to be displayed.
+ *
+ * @todo
+ * Now that the blocks table has a primary key, we should use that as the
+ * array key instead of MODULE_DELTA.
+ */
+function block_list($region) {
+ $blocks = &drupal_static(__FUNCTION__);
+
+ if (!isset($blocks)) {
+ $blocks = _block_load_blocks();
+ }
+
+ // Create an empty array if there are no entries.
+ if (!isset($blocks[$region])) {
+ $blocks[$region] = array();
+ }
+
+ return $blocks[$region];
+}
+
+/**
+ * Load a block object from the database.
+ *
+ * @param $module
+ * Name of the module that implements the block to load.
+ * @param $delta
+ * Unique ID of the block within the context of $module. Pass NULL to return
+ * an empty block object for $module.
+ *
+ * @return
+ * A block object.
+ */
+function block_load($module, $delta) {
+ if (isset($delta)) {
+ $block = db_query('SELECT * FROM {block} WHERE module = :module AND delta = :delta', array(':module' => $module, ':delta' => $delta))->fetchObject();
+ }
+
+ // If the block does not exist in the database yet return a stub block
+ // object.
+ if (empty($block)) {
+ $block = new stdClass();
+ $block->module = $module;
+ $block->delta = $delta;
+ }
+
+ return $block;
+}
+
+/**
+ * Load blocks information from the database.
+ */
+function _block_load_blocks() {
+ global $theme_key;
+
+ $query = db_select('block', 'b');
+ $query->addField('b', 'title', 'subject');
+ $result = $query
+ ->fields('b')
+ ->condition('b.theme', $theme_key)
+ ->condition('b.status', 1)
+ ->orderBy('b.region')
+ ->orderBy('b.weight')
+ ->orderBy('b.module')
+ ->addTag('block_load')
+ ->addTag('translatable')
+ ->execute();
+
+ $block_info = $result->fetchAllAssoc('bid');
+ // Allow modules to modify the block list.
+ drupal_alter('block_list', $block_info);
+
+ $blocks = array();
+ foreach ($block_info as $block) {
+ $blocks[$block->region]["{$block->module}_{$block->delta}"] = $block;
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_list_alter().
+ *
+ * Check the page, user role and user specific visibilty settings.
+ * Remove the block if the visibility conditions are not met.
+ */
+function block_block_list_alter(&$blocks) {
+ global $user, $theme_key;
+
+ // Build an array of roles for each block.
+ $block_roles = array();
+ $result = db_query('SELECT module, delta, rid FROM {block_role}');
+ foreach ($result as $record) {
+ $block_roles[$record->module][$record->delta][] = $record->rid;
+ }
+
+ foreach ($blocks as $key => $block) {
+ if (!isset($block->theme) || !isset($block->status) || $block->theme != $theme_key || $block->status != 1) {
+ // This block was added by a contrib module, leave it in the list.
+ continue;
+ }
+
+ // If a block has no roles associated, it is displayed for every role.
+ // For blocks with roles associated, if none of the user's roles matches
+ // the settings from this block, remove it from the block list.
+ if (isset($block_roles[$block->module][$block->delta]) && !array_intersect($block_roles[$block->module][$block->delta], array_keys($user->roles))) {
+ // No match.
+ unset($blocks[$key]);
+ continue;
+ }
+
+ // Use the user's block visibility setting, if necessary.
+ if ($block->custom != BLOCK_CUSTOM_FIXED) {
+ if ($user->uid && isset($user->data['block'][$block->module][$block->delta])) {
+ $enabled = $user->data['block'][$block->module][$block->delta];
+ }
+ else {
+ $enabled = ($block->custom == BLOCK_CUSTOM_ENABLED);
+ }
+ }
+ else {
+ $enabled = TRUE;
+ }
+
+ // Limited visibility blocks must list at least one page.
+ if ($block->visibility == BLOCK_VISIBILITY_LISTED && empty($block->pages)) {
+ $enabled = FALSE;
+ }
+
+ if (!$enabled) {
+ unset($blocks[$key]);
+ continue;
+ }
+
+ // Match path if necessary.
+ if ($block->pages) {
+ // Convert path to lowercase. This allows comparison of the same path
+ // with different case. Ex: /Page, /page, /PAGE.
+ $pages = drupal_strtolower($block->pages);
+ if ($block->visibility < BLOCK_VISIBILITY_PHP) {
+ // Convert the Drupal path to lowercase
+ $path = drupal_strtolower(drupal_get_path_alias($_GET['q']));
+ // Compare the lowercase internal and lowercase path alias (if any).
+ $page_match = drupal_match_path($path, $pages);
+ if ($path != $_GET['q']) {
+ $page_match = $page_match || drupal_match_path($_GET['q'], $pages);
+ }
+ // When $block->visibility has a value of 0 (BLOCK_VISIBILITY_NOTLISTED),
+ // the block is displayed on all pages except those listed in $block->pages.
+ // When set to 1 (BLOCK_VISIBILITY_LISTED), it is displayed only on those
+ // pages listed in $block->pages.
+ $page_match = !($block->visibility xor $page_match);
+ }
+ elseif (module_exists('php')) {
+ $page_match = php_eval($block->pages);
+ }
+ else {
+ $page_match = FALSE;
+ }
+ }
+ else {
+ $page_match = TRUE;
+ }
+ if (!$page_match) {
+ unset($blocks[$key]);
+ }
+ }
+}
+
+/**
+ * Build the content and subject for a block. For cacheable blocks, this is
+ * called during #pre_render.
+ *
+ * @param $element
+ * A renderable array.
+ * @return
+ * A renderable array.
+ */
+function _block_get_renderable_block($element) {
+ $block = $element['#block'];
+
+ // Render the block content if it has not been created already.
+ if (!isset($block->content)) {
+ $array = module_invoke($block->module, 'block_view', $block->delta);
+
+ // Allow modules to modify the block before it is viewed, via either
+ // hook_block_view_alter() or hook_block_view_MODULE_DELTA_alter().
+ drupal_alter(array('block_view', "block_view_{$block->module}_{$block->delta}"), $array, $block);
+
+ if (empty($array['content'])) {
+ // Blocks without content should emit no markup at all.
+ $element += array(
+ '#access' => FALSE,
+ '#printed' => TRUE,
+ );
+ }
+ elseif (isset($array) && is_array($array)) {
+ foreach ($array as $k => $v) {
+ $block->$k = $v;
+ }
+ }
+ }
+
+ if (isset($block->content) && $block->content) {
+ // Normalize to the drupal_render() structure.
+ if (is_string($block->content)) {
+ $block->content = array('#markup' => $block->content);
+ }
+ // Override default block title if a custom display title is present.
+ if ($block->title) {
+ // Check plain here to allow module generated titles to keep any
+ // markup.
+ $block->subject = $block->title == '<none>' ? '' : check_plain($block->title);
+ }
+
+ // Add the content renderable array to the main element.
+ $element['content'] = $block->content;
+ unset($block->content);
+ $element['#block'] = $block;
+ }
+ return $element;
+}
+
+/**
+ * Implements hook_flush_caches().
+ */
+function block_flush_caches() {
+ // Rehash blocks for active themes. We don't use list_themes() here,
+ // because if MAINTENANCE_MODE is defined it skips reading the database,
+ // and we can't tell which themes are active.
+ $themes = db_query("SELECT name FROM {system} WHERE type = 'theme' AND status = 1");
+ foreach ($themes as $theme) {
+ _block_rehash($theme->name);
+ }
+
+ return array('block');
+}
+
+/**
+ * Process variables for block.tpl.php
+ *
+ * Prepare the values passed to the theme_block function to be passed
+ * into a pluggable template engine. Uses block properties to generate a
+ * series of template file suggestions. If none are found, the default
+ * block.tpl.php is used.
+ *
+ * Most themes utilize their own copy of block.tpl.php. The default is located
+ * inside "modules/block/block.tpl.php". Look in there for the full list of
+ * variables.
+ *
+ * The $variables array contains the following arguments:
+ * - $block
+ *
+ * @see block.tpl.php
+ */
+function template_preprocess_block(&$variables) {
+ $block_counter = &drupal_static(__FUNCTION__, array());
+ $variables['block'] = $variables['elements']['#block'];
+ // All blocks get an independent counter for each region.
+ if (!isset($block_counter[$variables['block']->region])) {
+ $block_counter[$variables['block']->region] = 1;
+ }
+ // Same with zebra striping.
+ $variables['block_zebra'] = ($block_counter[$variables['block']->region] % 2) ? 'odd' : 'even';
+ $variables['block_id'] = $block_counter[$variables['block']->region]++;
+
+ // Create the $content variable that templates expect.
+ $variables['content'] = $variables['elements']['#children'];
+
+ $variables['classes_array'][] = drupal_html_class('block-' . $variables['block']->module);
+
+ // Add default class for block content.
+ $variables['content_attributes_array']['class'][] = 'content';
+
+ $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->region;
+ $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->module;
+ // Hyphens (-) and underscores (_) play a special role in theme suggestions.
+ // Theme suggestions should only contain underscores, because within
+ // drupal_find_theme_templates(), underscores are converted to hyphens to
+ // match template file names, and then converted back to underscores to match
+ // pre-processing and other function names. So if your theme suggestion
+ // contains a hyphen, it will end up as an underscore after this conversion,
+ // and your function names won't be recognized. So, we need to convert
+ // hyphens to underscores in block deltas for the theme suggestions.
+ $variables['theme_hook_suggestions'][] = 'block__' . $variables['block']->module . '__' . strtr($variables['block']->delta, '-', '_');
+
+ // Create a valid HTML ID and make sure it is unique.
+ $variables['block_html_id'] = drupal_html_id('block-' . $variables['block']->module . '-' . $variables['block']->delta);
+}
+
+/**
+ * Implements hook_user_role_delete().
+ *
+ * Remove deleted role from blocks that use it.
+ */
+function block_user_role_delete($role) {
+ db_delete('block_role')
+ ->condition('rid', $role->rid)
+ ->execute();
+}
+
+/**
+ * Implements hook_menu_delete().
+ */
+function block_menu_delete($menu) {
+ db_delete('block')
+ ->condition('module', 'menu')
+ ->condition('delta', $menu['menu_name'])
+ ->execute();
+ db_delete('block_role')
+ ->condition('module', 'menu')
+ ->condition('delta', $menu['menu_name'])
+ ->execute();
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function block_admin_paths() {
+ $paths = array(
+ // Exclude the block demonstration page from admin (overlay) treatment.
+ // This allows us to present this page in its true form, full page.
+ 'admin/structure/block/demo/*' => FALSE,
+ );
+ return $paths;
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ *
+ * Cleanup {block} and {block_role} tables from modules' blocks.
+ */
+function block_modules_uninstalled($modules) {
+ db_delete('block')
+ ->condition('module', $modules, 'IN')
+ ->execute();
+ db_delete('block_role')
+ ->condition('module', $modules, 'IN')
+ ->execute();
+}
diff --git a/core/modules/block/block.test b/core/modules/block/block.test
new file mode 100644
index 000000000000..0b41be52ef39
--- /dev/null
+++ b/core/modules/block/block.test
@@ -0,0 +1,757 @@
+<?php
+
+/**
+ * @file
+ * Tests for block.module.
+ */
+
+class BlockTestCase extends DrupalWebTestCase {
+ protected $regions;
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block functionality',
+ 'description' => 'Add, edit and delete custom block. Configure and move a module-defined block.',
+ 'group' => 'Block',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create and log in an administrative user having access to the Full HTML
+ // text format.
+ $full_html_format = filter_format_load('full_html');
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'administer blocks',
+ filter_permission_name($full_html_format),
+ 'access administration pages',
+ ));
+ $this->drupalLogin($this->admin_user);
+
+ // Define the existing regions
+ $this->regions = array();
+ $this->regions[] = 'header';
+ $this->regions[] = 'sidebar_first';
+ $this->regions[] = 'content';
+ $this->regions[] = 'sidebar_second';
+ $this->regions[] = 'footer';
+ }
+
+ /**
+ * Test creating custom block, moving it to a specific region and then deleting it.
+ */
+ function testCustomBlock() {
+ // Confirm that the add block link appears on block overview pages.
+ $this->drupalGet('admin/structure/block');
+ $this->assertRaw(l('Add block', 'admin/structure/block/add'), t('Add block link is present on block overview page for default theme.'));
+ $this->drupalGet('admin/structure/block/list/seven');
+ $this->assertRaw(l('Add block', 'admin/structure/block/list/seven/add'), t('Add block link is present on block overview page for non-default theme.'));
+
+ // Confirm that hidden regions are not shown as options for block placement
+ // when adding a new block.
+ theme_enable(array('stark'));
+ $themes = list_themes();
+ $this->drupalGet('admin/structure/block/add');
+ foreach ($themes as $key => $theme) {
+ if ($theme->status) {
+ foreach ($theme->info['regions_hidden'] as $hidden_region) {
+ $elements = $this->xpath('//select[@id=:id]//option[@value=:value]', array(':id' => 'edit-regions-' . $key, ':value' => $hidden_region));
+ $this->assertFalse(isset($elements[0]), t('The hidden region @region is not available for @theme.', array('@region' => $hidden_region, '@theme' => $key)));
+ }
+ }
+ }
+
+ // Add a new custom block by filling out the input form on the admin/structure/block/add page.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $this->randomName(8);
+ $custom_block['body[value]'] = $this->randomName(32);
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ // Confirm that the custom block has been created, and then query the created bid.
+ $this->assertText(t('The block has been created.'), t('Custom block successfully created.'));
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+
+ // Check to see if the custom block was created by checking that it's in the database.
+ $this->assertNotNull($bid, t('Custom block found in database'));
+
+ // Check that block_block_view() returns the correct title and content.
+ $data = block_block_view($bid);
+ $format = db_query("SELECT format FROM {block_custom} WHERE bid = :bid", array(':bid' => $bid))->fetchField();
+ $this->assertTrue(array_key_exists('subject', $data) && empty($data['subject']), t('block_block_view() provides an empty block subject, since custom blocks do not have default titles.'));
+ $this->assertEqual(check_markup($custom_block['body[value]'], $format), $data['content'], t('block_block_view() provides correct block content.'));
+
+ // Check whether the block can be moved to all available regions.
+ $custom_block['module'] = 'block';
+ $custom_block['delta'] = $bid;
+ foreach ($this->regions as $region) {
+ $this->moveBlockToRegion($custom_block, $region);
+ }
+
+ // Verify presence of configure and delete links for custom block.
+ $this->drupalGet('admin/structure/block');
+ $this->assertLinkByHref('admin/structure/block/manage/block/' . $bid . '/configure', 0, t('Custom block configure link found.'));
+ $this->assertLinkByHref('admin/structure/block/manage/block/' . $bid . '/delete', 0, t('Custom block delete link found.'));
+
+ // Set visibility only for authenticated users, to verify delete functionality.
+ $edit = array();
+ $edit['roles[2]'] = TRUE;
+ $this->drupalPost('admin/structure/block/manage/block/' . $bid . '/configure', $edit, t('Save block'));
+
+ // Delete the created custom block & verify that it's been deleted and no longer appearing on the page.
+ $this->clickLink(t('delete'));
+ $this->drupalPost('admin/structure/block/manage/block/' . $bid . '/delete', array(), t('Delete'));
+ $this->assertRaw(t('The block %title has been removed.', array('%title' => $custom_block['info'])), t('Custom block successfully deleted.'));
+ $this->assertNoText(t($custom_block['title']), t('Custom block no longer appears on page.'));
+ $count = db_query("SELECT 1 FROM {block_role} WHERE module = :module AND delta = :delta", array(':module' => $custom_block['module'], ':delta' => $custom_block['delta']))->fetchField();
+ $this->assertFalse($count, t('Table block_role being cleaned.'));
+ }
+
+ /**
+ * Test creating custom block using Full HTML.
+ */
+ function testCustomBlockFormat() {
+ // Add a new custom block by filling out the input form on the admin/structure/block/add page.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $this->randomName(8);
+ $custom_block['body[value]'] = '<h1>Full HTML</h1>';
+ $full_html_format = filter_format_load('full_html');
+ $custom_block['body[format]'] = $full_html_format->format;
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ // Set the created custom block to a specific region.
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $edit = array();
+ $edit['blocks[block_' . $bid . '][region]'] = $this->regions[1];
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Confirm that the custom block is being displayed using configured text format.
+ $this->drupalGet('node');
+ $this->assertRaw('<h1>Full HTML</h1>', t('Custom block successfully being displayed using Full HTML.'));
+
+ // Confirm that a user without access to Full HTML can not see the body field,
+ // but can still submit the form without errors.
+ $block_admin = $this->drupalCreateUser(array('administer blocks'));
+ $this->drupalLogin($block_admin);
+ $this->drupalGet('admin/structure/block/manage/block/' . $bid . '/configure');
+ $this->assertFieldByXPath("//textarea[@name='body[value]' and @disabled='disabled']", t('This field has been disabled because you do not have sufficient permissions to edit it.'), t('Body field contains denied message'));
+ $this->drupalPost('admin/structure/block/manage/block/' . $bid . '/configure', array(), t('Save block'));
+ $this->assertNoText(t('Ensure that each block description is unique.'));
+
+ // Confirm that the custom block is still being displayed using configured text format.
+ $this->drupalGet('node');
+ $this->assertRaw('<h1>Full HTML</h1>', t('Custom block successfully being displayed using Full HTML.'));
+ }
+
+ /**
+ * Test block visibility.
+ */
+ function testBlockVisibility() {
+ $block = array();
+
+ // Create a random title for the block
+ $title = $this->randomName(8);
+
+ // Create the custom block
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $title;
+ $custom_block['body[value]'] = $this->randomName(32);
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $block['module'] = 'block';
+ $block['delta'] = $bid;
+ $block['title'] = $title;
+
+ // Set the block to be hidden on any user path, and to be shown only to
+ // authenticated users.
+ $edit = array();
+ $edit['pages'] = 'user*';
+ $edit['roles[2]'] = TRUE;
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', $edit, t('Save block'));
+
+ // Move block to the first sidebar.
+ $this->moveBlockToRegion($block, $this->regions[1]);
+
+ $this->drupalGet('');
+ $this->assertText($title, t('Block was displayed on the front page.'));
+
+ $this->drupalGet('user');
+ $this->assertNoText($title, t('Block was not displayed according to block visibility rules.'));
+
+ $this->drupalGet('USER/' . $this->admin_user->uid);
+ $this->assertNoText($title, t('Block was not displayed according to block visibility rules regardless of path case.'));
+
+ // Confirm that the block is not displayed to anonymous users.
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertNoText($title, t('Block was not displayed to anonymous users.'));
+
+ // Confirm that an empty block is not displayed.
+ $this->assertNoRaw('block-system-help', t('Empty block not displayed.'));
+ }
+
+ /**
+ * Test block visibility when using "pages" restriction but leaving
+ * "pages" textarea empty
+ */
+ function testBlockVisibilityListedEmpty() {
+ $block = array();
+
+ // Create a random title for the block
+ $title = $this->randomName(8);
+
+ // Create the custom block
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $title;
+ $custom_block['body[value]'] = $this->randomName(32);
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $block['module'] = 'block';
+ $block['delta'] = $bid;
+ $block['title'] = $title;
+
+ // Move block to the first sidebar.
+ $this->moveBlockToRegion($block, $this->regions[1]);
+
+ // Set the block to be hidden on any user path, and to be shown only to
+ // authenticated users.
+ $edit = array();
+ $edit['visibility'] = BLOCK_VISIBILITY_LISTED;
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', $edit, t('Save block'));
+
+ $this->drupalGet('');
+ $this->assertNoText($title, t('Block was not displayed according to block visibility rules.'));
+
+ $this->drupalGet('user');
+ $this->assertNoText($title, t('Block was not displayed according to block visibility rules regardless of path case.'));
+
+ // Confirm that the block is not displayed to anonymous users.
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertNoText($title, t('Block was not displayed to anonymous users.'));
+ }
+
+ /**
+ * Test user customization of block visibility.
+ */
+ function testBlockVisibilityPerUser() {
+ $block = array();
+
+ // Create a random title for the block.
+ $title = $this->randomName(8);
+
+ // Create our custom test block.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $title;
+ $custom_block['body[value]'] = $this->randomName(32);
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $block['module'] = 'block';
+ $block['delta'] = $bid;
+ $block['title'] = $title;
+
+ // Move block to the first sidebar.
+ $this->moveBlockToRegion($block, $this->regions[1]);
+
+ // Set the block to be customizable per user, visible by default.
+ $edit = array();
+ $edit['custom'] = BLOCK_CUSTOM_ENABLED;
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', $edit, t('Save block'));
+
+ // Disable block visibility for the admin user.
+ $edit = array();
+ $edit['block[' . $block['module'] . '][' . $block['delta'] . ']'] = FALSE;
+ $this->drupalPost('user/' . $this->admin_user->uid . '/edit', $edit, t('Save'));
+
+ $this->drupalGet('');
+ $this->assertNoText($block['title'], t('Block was not displayed according to per user block visibility setting.'));
+
+ // Set the block to be customizable per user, hidden by default.
+ $edit = array();
+ $edit['custom'] = BLOCK_CUSTOM_DISABLED;
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', $edit, t('Save block'));
+
+ // Enable block visibility for the admin user.
+ $edit = array();
+ $edit['block[' . $block['module'] . '][' . $block['delta'] . ']'] = TRUE;
+ $this->drupalPost('user/' . $this->admin_user->uid . '/edit', $edit, t('Save'));
+
+ $this->drupalGet('');
+ $this->assertText($block['title'], t('Block was displayed according to per user block visibility setting.'));
+ }
+
+ /**
+ * Test configuring and moving a module-define block to specific regions.
+ */
+ function testBlock() {
+ // Select the Navigation block to be configured and moved.
+ $block = array();
+ $block['module'] = 'system';
+ $block['delta'] = 'management';
+ $block['title'] = $this->randomName(8);
+
+ // Set block title to confirm that interface works and override any custom titles.
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', array('title' => $block['title']), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block title set.'));
+ $bid = db_query("SELECT bid FROM {block} WHERE module = :module AND delta = :delta", array(
+ ':module' => $block['module'],
+ ':delta' => $block['delta'],
+ ))->fetchField();
+
+ // Check to see if the block was created by checking that it's in the database.
+ $this->assertNotNull($bid, t('Block found in database'));
+
+ // Check whether the block can be moved to all available regions.
+ foreach ($this->regions as $region) {
+ $this->moveBlockToRegion($block, $region);
+ }
+
+ // Set the block to the disabled region.
+ $edit = array();
+ $edit['blocks[' . $block['module'] . '_' . $block['delta'] . '][region]'] = '-1';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Confirm that the block was moved to the proper region.
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to disabled region.'));
+ $this->assertNoText(t($block['title']), t('Block no longer appears on page.'));
+
+ // Confirm that the region's xpath is not available.
+ $xpath = $this->buildXPathQuery('//div[@id=:id]/*', array(':id' => 'block-block-' . $bid));
+ $this->assertNoFieldByXPath($xpath, FALSE, t('Custom block found in no regions.'));
+
+ // For convenience of developers, put the navigation block back.
+ $edit = array();
+ $edit['blocks[' . $block['module'] . '_' . $block['delta'] . '][region]'] = $this->regions[1];
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to first sidebar region.'));
+
+ $this->drupalPost('admin/structure/block/manage/' . $block['module'] . '/' . $block['delta'] . '/configure', array('title' => 'Navigation'), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block title set.'));
+ }
+
+ function moveBlockToRegion($block, $region) {
+ // Set the created block to a specific region.
+ $edit = array();
+ $edit['blocks[' . $block['module'] . '_' . $block['delta'] . '][region]'] = $region;
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Confirm that the block was moved to the proper region.
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully moved to %region_name region.', array( '%region_name' => $region)));
+
+ // Confirm that the block is being displayed.
+ $this->drupalGet('node');
+ $this->assertText(t($block['title']), t('Block successfully being displayed on the page.'));
+
+ // Confirm that the custom block was found at the proper region.
+ $xpath = $this->buildXPathQuery('//div[@class=:region-class]//div[@id=:block-id]/*', array(
+ ':region-class' => 'region region-' . str_replace('_', '-', $region),
+ ':block-id' => 'block-' . $block['module'] . '-' . $block['delta'],
+ ));
+ $this->assertFieldByXPath($xpath, NULL, t('Custom block found in %region_name region.', array('%region_name' => $region)));
+ }
+
+ /**
+ * Test _block_rehash().
+ */
+ function testBlockRehash() {
+ module_enable(array('block_test'));
+ $this->assertTrue(module_exists('block_test'), t('Test block module enabled.'));
+
+ // Our new block should be inserted in the database when we visit the
+ // block management page.
+ $this->drupalGet('admin/structure/block');
+ // Our test block's caching should default to DRUPAL_CACHE_PER_ROLE.
+ $current_caching = db_query("SELECT cache FROM {block} WHERE module = 'block_test' AND delta = 'test_cache'")->fetchField();
+ $this->assertEqual($current_caching, DRUPAL_CACHE_PER_ROLE, t('Test block cache mode defaults to DRUPAL_CACHE_PER_ROLE.'));
+
+ // Disable caching for this block.
+ variable_set('block_test_caching', DRUPAL_NO_CACHE);
+ // Flushing all caches should call _block_rehash().
+ drupal_flush_all_caches();
+ // Verify that the database is updated with the new caching mode.
+ $current_caching = db_query("SELECT cache FROM {block} WHERE module = 'block_test' AND delta = 'test_cache'")->fetchField();
+ $this->assertEqual($current_caching, DRUPAL_NO_CACHE, t("Test block's database entry updated to DRUPAL_NO_CACHE."));
+ }
+}
+
+class NonDefaultBlockAdmin extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Non default theme admin',
+ 'description' => 'Check the administer page for non default theme.',
+ 'group' => 'Block',
+ );
+ }
+
+ /**
+ * Test non-default theme admin.
+ */
+ function testNonDefaultBlockAdmin() {
+ $admin_user = $this->drupalCreateUser(array('administer blocks', 'administer themes'));
+ $this->drupalLogin($admin_user);
+ theme_enable(array('stark'));
+ $this->drupalGet('admin/structure/block/list/stark');
+ }
+}
+
+/**
+ * Test blocks correctly initialized when picking a new default theme.
+ */
+class NewDefaultThemeBlocks extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'New default theme blocks',
+ 'description' => 'Checks that the new default theme gets blocks.',
+ 'group' => 'Block',
+ );
+ }
+
+ /**
+ * Check the enabled Bartik blocks are correctly copied over.
+ */
+ function testNewDefaultThemeBlocks() {
+ // Create administrative user.
+ $admin_user = $this->drupalCreateUser(array('administer themes'));
+ $this->drupalLogin($admin_user);
+
+ // Ensure no other theme's blocks are in the block table yet.
+ $themes = array();
+ $themes['default'] = variable_get('theme_default', 'bartik');
+ if ($admin_theme = variable_get('admin_theme')) {
+ $themes['admin'] = $admin_theme;
+ }
+ $count = db_query_range('SELECT 1 FROM {block} WHERE theme NOT IN (:themes)', 0, 1, array(':themes' => $themes))->fetchField();
+ $this->assertFalse($count, t('Only the default theme and the admin theme have blocks.'));
+
+ // Populate list of all blocks for matching against new theme.
+ $blocks = array();
+ $result = db_query('SELECT * FROM {block} WHERE theme = :theme', array(':theme' => $themes['default']));
+ foreach ($result as $block) {
+ // $block->theme and $block->bid will not match, so remove them.
+ unset($block->theme, $block->bid);
+ $blocks[$block->module][$block->delta] = $block;
+ }
+
+ // Turn on the Stark theme and ensure that it contains all of the blocks
+ // the default theme had.
+ theme_enable(array('stark'));
+ variable_set('theme_default', 'stark');
+ $result = db_query('SELECT * FROM {block} WHERE theme = :theme', array(':theme' => 'stark'));
+ foreach ($result as $block) {
+ unset($block->theme, $block->bid);
+ $this->assertEqual($blocks[$block->module][$block->delta], $block, t('Block %name matched', array('%name' => $block->module . '-' . $block->delta)));
+ }
+ }
+}
+
+/**
+ * Test the block system with admin themes.
+ */
+class BlockAdminThemeTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Admin theme block admin accessibility',
+ 'description' => "Check whether the block administer page for a disabled theme accessible if and only if it's the admin theme.",
+ 'group' => 'Block',
+ );
+ }
+
+ /**
+ * Check for the accessibility of the admin theme on the block admin page.
+ */
+ function testAdminTheme() {
+ // Create administrative user.
+ $admin_user = $this->drupalCreateUser(array('administer blocks', 'administer themes'));
+ $this->drupalLogin($admin_user);
+
+ // Ensure that access to block admin page is denied when theme is disabled.
+ $this->drupalGet('admin/structure/block/list/stark');
+ $this->assertResponse(403, t('The block admin page for a disabled theme can not be accessed'));
+
+ // Enable admin theme and confirm that tab is accessible.
+ $edit['admin_theme'] = 'stark';
+ $this->drupalPost('admin/appearance', $edit, t('Save configuration'));
+ $this->drupalGet('admin/structure/block/list/stark');
+ $this->assertResponse(200, t('The block admin page for the admin theme can be accessed'));
+ }
+}
+
+/**
+ * Test block caching.
+ */
+class BlockCacheTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $normal_user;
+ protected $normal_user_alt;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block caching',
+ 'description' => 'Test block caching.',
+ 'group' => 'Block',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('block_test');
+
+ // Create an admin user, log in and enable test blocks.
+ $this->admin_user = $this->drupalCreateUser(array('administer blocks', 'access administration pages'));
+ $this->drupalLogin($this->admin_user);
+
+ // Create additional users to test caching modes.
+ $this->normal_user = $this->drupalCreateUser();
+ $this->normal_user_alt = $this->drupalCreateUser();
+ // Sync the roles, since drupalCreateUser() creates separate roles for
+ // the same permission sets.
+ user_save($this->normal_user_alt, array('roles' => $this->normal_user->roles));
+ $this->normal_user_alt->roles = $this->normal_user->roles;
+
+ // Enable our test block.
+ $edit['blocks[block_test_test_cache][region]'] = 'sidebar_first';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ }
+
+ /**
+ * Test DRUPAL_CACHE_PER_ROLE.
+ */
+ function testCachePerRole() {
+ $this->setCacheMode(DRUPAL_CACHE_PER_ROLE);
+
+ // Enable our test block. Set some content for it to display.
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ $this->drupalLogin($this->normal_user);
+ $this->drupalGet('');
+ $this->assertText($current_content, t('Block content displays.'));
+
+ // Change the content, but the cached copy should still be served.
+ $old_content = $current_content;
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ $this->drupalGet('');
+ $this->assertText($old_content, t('Block is served from the cache.'));
+
+ // Clear the cache and verify that the stale data is no longer there.
+ cache_clear_all();
+ $this->drupalGet('');
+ $this->assertNoText($old_content, t('Block cache clear removes stale cache data.'));
+ $this->assertText($current_content, t('Fresh block content is displayed after clearing the cache.'));
+
+ // Test whether the cached data is served for the correct users.
+ $old_content = $current_content;
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertNoText($old_content, t('Anonymous user does not see content cached per-role for normal user.'));
+
+ $this->drupalLogin($this->normal_user_alt);
+ $this->drupalGet('');
+ $this->assertText($old_content, t('User with the same roles sees per-role cached content.'));
+
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('');
+ $this->assertNoText($old_content, t('Admin user does not see content cached per-role for normal user.'));
+
+ $this->drupalLogin($this->normal_user);
+ $this->drupalGet('');
+ $this->assertText($old_content, t('Block is served from the per-role cache.'));
+ }
+
+ /**
+ * Test DRUPAL_CACHE_GLOBAL.
+ */
+ function testCacheGlobal() {
+ $this->setCacheMode(DRUPAL_CACHE_GLOBAL);
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ $this->drupalGet('');
+ $this->assertText($current_content, t('Block content displays.'));
+
+ $old_content = $current_content;
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ $this->drupalLogout();
+ $this->drupalGet('user');
+ $this->assertText($old_content, t('Block content served from global cache.'));
+ }
+
+ /**
+ * Test DRUPAL_NO_CACHE.
+ */
+ function testNoCache() {
+ $this->setCacheMode(DRUPAL_NO_CACHE);
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ // If DRUPAL_NO_CACHE has no effect, the next request would be cached.
+ $this->drupalGet('');
+ $this->assertText($current_content, t('Block content displays.'));
+
+ // A cached copy should not be served.
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ $this->drupalGet('');
+ $this->assertText($current_content, t('DRUPAL_NO_CACHE prevents blocks from being cached.'));
+ }
+
+ /**
+ * Test DRUPAL_CACHE_PER_USER.
+ */
+ function testCachePerUser() {
+ $this->setCacheMode(DRUPAL_CACHE_PER_USER);
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ $this->drupalLogin($this->normal_user);
+
+ $this->drupalGet('');
+ $this->assertText($current_content, t('Block content displays.'));
+
+ $old_content = $current_content;
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ $this->drupalGet('');
+ $this->assertText($old_content, t('Block is served from per-user cache.'));
+
+ $this->drupalLogin($this->normal_user_alt);
+ $this->drupalGet('');
+ $this->assertText($current_content, t('Per-user block cache is not served for other users.'));
+
+ $this->drupalLogin($this->normal_user);
+ $this->drupalGet('');
+ $this->assertText($old_content, t('Per-user block cache is persistent.'));
+ }
+
+ /**
+ * Test DRUPAL_CACHE_PER_PAGE.
+ */
+ function testCachePerPage() {
+ $this->setCacheMode(DRUPAL_CACHE_PER_PAGE);
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ $this->drupalGet('node');
+ $this->assertText($current_content, t('Block content displays on the node page.'));
+
+ $old_content = $current_content;
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+
+ $this->drupalGet('user');
+ $this->assertNoText($old_content, t('Block content cached for the node page does not show up for the user page.'));
+ $this->drupalGet('node');
+ $this->assertText($old_content, t('Block content cached for the node page.'));
+ }
+
+ /**
+ * Private helper method to set the test block's cache mode.
+ */
+ private function setCacheMode($cache_mode) {
+ db_update('block')
+ ->fields(array('cache' => $cache_mode))
+ ->condition('module', 'block_test')
+ ->execute();
+
+ $current_mode = db_query("SELECT cache FROM {block} WHERE module = 'block_test'")->fetchField();
+ if ($current_mode != $cache_mode) {
+ $this->fail(t('Unable to set cache mode to %mode. Current mode: %current_mode', array('%mode' => $cache_mode, '%current_mode' => $current_mode)));
+ }
+ }
+}
+
+/**
+ * Test block HTML id validity.
+ */
+class BlockHTMLIdTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block HTML id',
+ 'description' => 'Test block HTML id validity.',
+ 'group' => 'Block',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('block_test');
+
+ // Create an admin user, log in and enable test blocks.
+ $this->admin_user = $this->drupalCreateUser(array('administer blocks', 'access administration pages'));
+ $this->drupalLogin($this->admin_user);
+
+ // Enable our test block.
+ $edit['blocks[block_test_test_html_id][region]'] = 'sidebar_first';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Make sure the block has some content so it will appear
+ $current_content = $this->randomName();
+ variable_set('block_test_content', $current_content);
+ }
+
+ /**
+ * Test valid HTML id.
+ */
+ function testHTMLId() {
+ $this->drupalGet('');
+ $this->assertRaw('block-block-test-test-html-id', t('HTML id for test block is valid.'));
+ }
+}
+
+
+/**
+ * Unit tests for template_preprocess_block().
+ */
+class BlockTemplateSuggestionsUnitTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block template suggestions',
+ 'description' => 'Test the template_preprocess_block() function.',
+ 'group' => 'Block',
+ );
+ }
+
+ /**
+ * Test if template_preprocess_block() handles the suggestions right.
+ */
+ function testBlockThemeHookSuggestions() {
+ // Define block delta with underscore to be preprocessed
+ $block1 = new stdClass();
+ $block1->module = 'block';
+ $block1->delta = 'underscore_test';
+ $block1->region = 'footer';
+ $variables1 = array();
+ $variables1['elements']['#block'] = $block1;
+ $variables1['elements']['#children'] = '';
+ template_preprocess_block($variables1);
+ $this->assertEqual($variables1['theme_hook_suggestions'], array('block__footer', 'block__block', 'block__block__underscore_test'), t('Found expected block suggestions for delta with underscore'));
+
+ // Define block delta with hyphens to be preprocessed. Hyphens should be
+ // replaced with underscores.
+ $block2 = new stdClass();
+ $block2->module = 'block';
+ $block2->delta = 'hyphen-test';
+ $block2->region = 'footer';
+ $variables2 = array();
+ $variables2['elements']['#block'] = $block2;
+ $variables2['elements']['#children'] = '';
+ // Test adding a class to the block content.
+ $variables2['content_attributes_array']['class'][] = 'test-class';
+ template_preprocess_block($variables2);
+ $this->assertEqual($variables2['theme_hook_suggestions'], array('block__footer', 'block__block', 'block__block__hyphen_test'), t('Hyphens (-) in block delta were replaced by underscore (_)'));
+ // Test that the default class and added class are available.
+ $this->assertEqual($variables2['content_attributes_array']['class'], array('test-class', 'content'), t('Default .content class added to block content_attributes_array'));
+ }
+}
diff --git a/core/modules/block/block.tpl.php b/core/modules/block/block.tpl.php
new file mode 100644
index 000000000000..c1025c327352
--- /dev/null
+++ b/core/modules/block/block.tpl.php
@@ -0,0 +1,55 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a block.
+ *
+ * Available variables:
+ * - $block->subject: Block title.
+ * - $content: Block content.
+ * - $block->module: Module that generated the block.
+ * - $block->delta: An ID for the block, unique within each module.
+ * - $block->region: The block region embedding the current block.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the following:
+ * - block: The current template type, i.e., "theming hook".
+ * - block-[module]: The module generating the block. For example, the user module
+ * is responsible for handling the default user navigation block. In that case
+ * the class would be "block-user".
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * Helper variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $block_zebra: Outputs 'odd' and 'even' dependent on each block region.
+ * - $zebra: Same output as $block_zebra but independent of any block region.
+ * - $block_id: Counter dependent on each block region.
+ * - $id: Same output as $block_id but independent of any block region.
+ * - $is_front: Flags true when presented in the front page.
+ * - $logged_in: Flags true when the current user is a logged-in member.
+ * - $is_admin: Flags true when the current user is an administrator.
+ * - $block_html_id: A valid HTML ID and guaranteed unique.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_block()
+ * @see template_process()
+ */
+?>
+<div id="<?php print $block_html_id; ?>" class="<?php print $classes; ?>"<?php print $attributes; ?>>
+
+ <?php print render($title_prefix); ?>
+<?php if ($block->subject): ?>
+ <h2<?php print $title_attributes; ?>><?php print $block->subject ?></h2>
+<?php endif;?>
+ <?php print render($title_suffix); ?>
+
+ <div<?php print $content_attributes; ?>>
+ <?php print $content ?>
+ </div>
+</div>
diff --git a/core/modules/block/tests/block_test.info b/core/modules/block/tests/block_test.info
new file mode 100644
index 000000000000..7efb99ce14bf
--- /dev/null
+++ b/core/modules/block/tests/block_test.info
@@ -0,0 +1,6 @@
+name = Block test
+description = Provides test blocks.
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/block/tests/block_test.module b/core/modules/block/tests/block_test.module
new file mode 100644
index 000000000000..2abc433c9824
--- /dev/null
+++ b/core/modules/block/tests/block_test.module
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Provide test blocks.
+ */
+
+/**
+ * Implements hook_block_info().
+ */
+function block_test_block_info() {
+ $blocks['test_cache'] = array(
+ 'info' => t('Test block caching'),
+ 'cache' => variable_get('block_test_caching', DRUPAL_CACHE_PER_ROLE),
+ );
+
+ $blocks['test_html_id'] = array(
+ 'info' => t('Test block html id'),
+ );
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function block_test_block_view($delta = 0) {
+ return array('content' => variable_get('block_test_content', ''));
+}
diff --git a/core/modules/book/book-all-books-block.tpl.php b/core/modules/book/book-all-books-block.tpl.php
new file mode 100644
index 000000000000..626a5f26482a
--- /dev/null
+++ b/core/modules/book/book-all-books-block.tpl.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for rendering book outlines within a block.
+ * This template is used only when the block is configured to "show block on
+ * all pages" which presents Multiple independent books on all pages.
+ *
+ * Available variables:
+ * - $book_menus: Array of book outlines keyed to the parent book ID. Call
+ * render() on each to print it as an unordered list.
+ */
+?>
+<?php foreach ($book_menus as $book_id => $menu): ?>
+ <div id="book-block-menu-<?php print $book_id; ?>" class="book-block-menu">
+ <?php print render($menu); ?>
+ </div>
+<?php endforeach; ?>
diff --git a/core/modules/book/book-export-html.tpl.php b/core/modules/book/book-export-html.tpl.php
new file mode 100644
index 000000000000..4b25a766e18c
--- /dev/null
+++ b/core/modules/book/book-export-html.tpl.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for printed version of book outline.
+ *
+ * Available variables:
+ * - $title: Top level node title.
+ * - $head: Header tags.
+ * - $language: Language code. e.g. "en" for english.
+ * - $language_rtl: TRUE or FALSE depending on right to left language scripts.
+ * - $base_url: URL to home page.
+ * - $contents: Nodes within the current outline rendered through
+ * book-node-export-html.tpl.php.
+ *
+ * @see template_preprocess_book_export_html()
+ */
+?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" lang="<?php print $language->language; ?>" xml:lang="<?php print $language->language; ?>">
+ <head>
+ <title><?php print $title; ?></title>
+ <?php print $head; ?>
+ <base href="<?php print $base_url; ?>" />
+ <link type="text/css" rel="stylesheet" href="misc/print.css" />
+ <?php if ($language_rtl): ?>
+ <link type="text/css" rel="stylesheet" href="misc/print-rtl.css" />
+ <?php endif; ?>
+ </head>
+ <body>
+ <?php
+ /**
+ * The given node is /embedded to its absolute depth in a top level
+ * section/. For example, a child node with depth 2 in the hierarchy is
+ * contained in (otherwise empty) &lt;div&gt; elements corresponding to
+ * depth 0 and depth 1. This is intended to support WYSIWYG output - e.g.,
+ * level 3 sections always look like level 3 sections, no matter their
+ * depth relative to the node selected to be exported as printer-friendly
+ * HTML.
+ */
+ $div_close = '';
+ ?>
+ <?php for ($i = 1; $i < $depth; $i++): ?>
+ <div class="section-<?php print $i; ?>">
+ <?php $div_close .= '</div>'; ?>
+ <?php endfor; ?>
+ <?php print $contents; ?>
+ <?php print $div_close; ?>
+ </body>
+</html>
diff --git a/core/modules/book/book-navigation.tpl.php b/core/modules/book/book-navigation.tpl.php
new file mode 100644
index 000000000000..5d8e9aa7fcb6
--- /dev/null
+++ b/core/modules/book/book-navigation.tpl.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to navigate books. Presented under nodes that
+ * are a part of book outlines.
+ *
+ * Available variables:
+ * - $tree: The immediate children of the current node rendered as an
+ * unordered list.
+ * - $current_depth: Depth of the current node within the book outline.
+ * Provided for context.
+ * - $prev_url: URL to the previous node.
+ * - $prev_title: Title of the previous node.
+ * - $parent_url: URL to the parent node.
+ * - $parent_title: Title of the parent node. Not printed by default. Provided
+ * as an option.
+ * - $next_url: URL to the next node.
+ * - $next_title: Title of the next node.
+ * - $has_links: Flags TRUE whenever the previous, parent or next data has a
+ * value.
+ * - $book_id: The book ID of the current outline being viewed. Same as the
+ * node ID containing the entire outline. Provided for context.
+ * - $book_url: The book/node URL of the current outline being viewed.
+ * Provided as an option. Not used by default.
+ * - $book_title: The book/node title of the current outline being viewed.
+ * Provided as an option. Not used by default.
+ *
+ * @see template_preprocess_book_navigation()
+ */
+?>
+<?php if ($tree || $has_links): ?>
+ <div id="book-navigation-<?php print $book_id; ?>" class="book-navigation">
+ <?php print $tree; ?>
+
+ <?php if ($has_links): ?>
+ <div class="page-links clearfix">
+ <?php if ($prev_url): ?>
+ <a href="<?php print $prev_url; ?>" class="page-previous" title="<?php print t('Go to previous page'); ?>"><?php print t('‹ ') . $prev_title; ?></a>
+ <?php endif; ?>
+ <?php if ($parent_url): ?>
+ <a href="<?php print $parent_url; ?>" class="page-up" title="<?php print t('Go to parent page'); ?>"><?php print t('up'); ?></a>
+ <?php endif; ?>
+ <?php if ($next_url): ?>
+ <a href="<?php print $next_url; ?>" class="page-next" title="<?php print t('Go to next page'); ?>"><?php print $next_title . t(' ›'); ?></a>
+ <?php endif; ?>
+ </div>
+ <?php endif; ?>
+
+ </div>
+<?php endif; ?>
diff --git a/core/modules/book/book-node-export-html.tpl.php b/core/modules/book/book-node-export-html.tpl.php
new file mode 100644
index 000000000000..ef6c3224cfb5
--- /dev/null
+++ b/core/modules/book/book-node-export-html.tpl.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for rendering a single node in a printer
+ * friendly outline.
+ *
+ * @see book-node-export-html.tpl.php
+ * Where it is collected and printed out.
+ *
+ * Available variables:
+ * - $depth: Depth of the current node inside the outline.
+ * - $title: Node title.
+ * - $content: Node content.
+ * - $children: All the child nodes recursively rendered through this file.
+ *
+ * @see template_preprocess_book_node_export_html()
+ */
+?>
+<div id="node-<?php print $node->nid; ?>" class="section-<?php print $depth; ?>">
+ <h1 class="book-heading"><?php print $title; ?></h1>
+ <?php print $content; ?>
+ <?php print $children; ?>
+</div>
diff --git a/core/modules/book/book-rtl.css b/core/modules/book/book-rtl.css
new file mode 100644
index 000000000000..f3a84c20e32f
--- /dev/null
+++ b/core/modules/book/book-rtl.css
@@ -0,0 +1,11 @@
+
+.book-navigation .menu {
+ padding: 1em 3em 0 0;
+}
+
+.book-navigation .page-previous {
+ float: right;
+}
+.book-navigation .page-up {
+ float: right;
+}
diff --git a/core/modules/book/book.admin.inc b/core/modules/book/book.admin.inc
new file mode 100644
index 000000000000..7b9dea390f5b
--- /dev/null
+++ b/core/modules/book/book.admin.inc
@@ -0,0 +1,264 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the book module.
+ */
+
+/**
+ * Returns an administrative overview of all books.
+ */
+function book_admin_overview() {
+ $rows = array();
+
+ $headers = array(t('Book'), t('Operations'));
+
+ // Add any recognized books to the table list.
+ foreach (book_get_books() as $book) {
+ $rows[] = array(l($book['title'], $book['href'], $book['options']), l(t('edit order and titles'), 'admin/content/book/' . $book['nid']));
+ }
+
+ return theme('table', array('header' => $headers, 'rows' => $rows, 'empty' => t('No books available.')));
+}
+
+/**
+ * Builds and returns the book settings form.
+ *
+ * @see book_admin_settings_validate()
+ *
+ * @ingroup forms
+ */
+function book_admin_settings() {
+ $types = node_type_get_names();
+ $form['book_allowed_types'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Content types allowed in book outlines'),
+ '#default_value' => variable_get('book_allowed_types', array('book')),
+ '#options' => $types,
+ '#description' => t('Users with the %outline-perm permission can add all content types.', array('%outline-perm' => t('Administer book outlines'))),
+ '#required' => TRUE,
+ );
+ $form['book_child_type'] = array(
+ '#type' => 'radios',
+ '#title' => t('Content type for child pages'),
+ '#default_value' => variable_get('book_child_type', 'book'),
+ '#options' => $types,
+ '#required' => TRUE,
+ );
+ $form['array_filter'] = array('#type' => 'value', '#value' => TRUE);
+ $form['#validate'][] = 'book_admin_settings_validate';
+
+ return system_settings_form($form);
+}
+
+/**
+ * Validate the book settings form.
+ *
+ * @see book_admin_settings()
+ */
+function book_admin_settings_validate($form, &$form_state) {
+ $child_type = $form_state['values']['book_child_type'];
+ if (empty($form_state['values']['book_allowed_types'][$child_type])) {
+ form_set_error('book_child_type', t('The content type for the %add-child link must be one of those selected as an allowed book outline type.', array('%add-child' => t('Add child page'))));
+ }
+}
+
+/**
+ * Build the form to administrate the hierarchy of a single book.
+ *
+ * @see book_admin_edit_submit()
+ *
+ * @ingroup forms.
+ */
+function book_admin_edit($form, $form_state, $node) {
+ drupal_set_title($node->title);
+ $form['#node'] = $node;
+ _book_admin_table($node, $form);
+ $form['save'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save book pages'),
+ );
+
+ return $form;
+}
+
+/**
+ * Check that the book has not been changed while using the form.
+ *
+ * @see book_admin_edit()
+ */
+function book_admin_edit_validate($form, &$form_state) {
+ if ($form_state['values']['tree_hash'] != $form_state['values']['tree_current_hash']) {
+ form_set_error('', t('This book has been modified by another user, the changes could not be saved.'));
+ }
+}
+
+/**
+ * Handle submission of the book administrative page form.
+ *
+ * This function takes care to save parent menu items before their children.
+ * Saving menu items in the incorrect order can break the menu tree.
+ *
+ * @see book_admin_edit()
+ * @see menu_overview_form_submit()
+ */
+function book_admin_edit_submit($form, &$form_state) {
+ // Save elements in the same order as defined in post rather than the form.
+ // This ensures parents are updated before their children, preventing orphans.
+ $order = array_flip(array_keys($form_state['input']['table']));
+ $form['table'] = array_merge($order, $form['table']);
+
+ foreach (element_children($form['table']) as $key) {
+ if ($form['table'][$key]['#item']) {
+ $row = $form['table'][$key];
+ $values = $form_state['values']['table'][$key];
+
+ // Update menu item if moved.
+ if ($row['plid']['#default_value'] != $values['plid'] || $row['weight']['#default_value'] != $values['weight']) {
+ $row['#item']['plid'] = $values['plid'];
+ $row['#item']['weight'] = $values['weight'];
+ menu_link_save($row['#item']);
+ }
+
+ // Update the title if changed.
+ if ($row['title']['#default_value'] != $values['title']) {
+ $node = node_load($values['nid']);
+ $langcode = LANGUAGE_NONE;
+ $node->title = $values['title'];
+ $node->book['link_title'] = $values['title'];
+ $node->revision = 1;
+ $node->log = t('Title changed from %original to %current.', array('%original' => $node->title, '%current' => $values['title']));
+
+ node_save($node);
+ watchdog('content', 'book: updated %title.', array('%title' => $node->title), WATCHDOG_NOTICE, l(t('view'), 'node/' . $node->nid));
+ }
+ }
+ }
+
+ drupal_set_message(t('Updated book %title.', array('%title' => $form['#node']->title)));
+}
+
+/**
+ * Build the table portion of the form for the book administration page.
+ *
+ * @see book_admin_edit()
+ */
+function _book_admin_table($node, &$form) {
+ $form['table'] = array(
+ '#theme' => 'book_admin_table',
+ '#tree' => TRUE,
+ );
+
+ $tree = book_menu_subtree_data($node->book);
+ $tree = array_shift($tree); // Do not include the book item itself.
+ if ($tree['below']) {
+ $hash = drupal_hash_base64(serialize($tree['below']));
+ // Store the hash value as a hidden form element so that we can detect
+ // if another user changed the book hierarchy.
+ $form['tree_hash'] = array(
+ '#type' => 'hidden',
+ '#default_value' => $hash,
+ );
+ $form['tree_current_hash'] = array(
+ '#type' => 'value',
+ '#value' => $hash,
+ );
+ _book_admin_table_tree($tree['below'], $form['table']);
+ }
+
+}
+
+/**
+ * Recursive helper to build the main table in the book administration page form.
+ *
+ * @see book_admin_edit()
+ */
+function _book_admin_table_tree($tree, &$form) {
+ // The delta must be big enough to give each node a distinct value.
+ $count = count($tree);
+ $delta = ($count < 30) ? 15 : intval($count / 2) + 1;
+
+ foreach ($tree as $data) {
+ $form['book-admin-' . $data['link']['nid']] = array(
+ '#item' => $data['link'],
+ 'nid' => array('#type' => 'value', '#value' => $data['link']['nid']),
+ 'depth' => array('#type' => 'value', '#value' => $data['link']['depth']),
+ 'href' => array('#type' => 'value', '#value' => $data['link']['href']),
+ 'title' => array(
+ '#type' => 'textfield',
+ '#default_value' => $data['link']['link_title'],
+ '#maxlength' => 255,
+ '#size' => 40,
+ ),
+ 'weight' => array(
+ '#type' => 'weight',
+ '#default_value' => $data['link']['weight'],
+ '#delta' => max($delta, abs($data['link']['weight'])),
+ '#title' => t('Weight for @title', array('@title' => $data['link']['title'])),
+ '#title_display' => 'invisible',
+ ),
+ 'plid' => array(
+ '#type' => 'hidden',
+ '#default_value' => $data['link']['plid'],
+ ),
+ 'mlid' => array(
+ '#type' => 'hidden',
+ '#default_value' => $data['link']['mlid'],
+ ),
+ );
+ if ($data['below']) {
+ _book_admin_table_tree($data['below'], $form);
+ }
+ }
+
+ return $form;
+}
+
+/**
+ * Returns HTML for a book administration form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @see book_admin_table()
+ * @ingroup themeable
+ */
+function theme_book_admin_table($variables) {
+ $form = $variables['form'];
+
+ drupal_add_tabledrag('book-outline', 'match', 'parent', 'book-plid', 'book-plid', 'book-mlid', TRUE, MENU_MAX_DEPTH - 2);
+ drupal_add_tabledrag('book-outline', 'order', 'sibling', 'book-weight');
+
+ $header = array(t('Title'), t('Weight'), t('Parent'), array('data' => t('Operations'), 'colspan' => '3'));
+
+ $rows = array();
+ $destination = drupal_get_destination();
+ $access = user_access('administer nodes');
+ foreach (element_children($form) as $key) {
+ $nid = $form[$key]['nid']['#value'];
+ $href = $form[$key]['href']['#value'];
+
+ // Add special classes to be used with tabledrag.js.
+ $form[$key]['plid']['#attributes']['class'] = array('book-plid');
+ $form[$key]['mlid']['#attributes']['class'] = array('book-mlid');
+ $form[$key]['weight']['#attributes']['class'] = array('book-weight');
+
+ $data = array(
+ theme('indentation', array('size' => $form[$key]['depth']['#value'] - 2)) . drupal_render($form[$key]['title']),
+ drupal_render($form[$key]['weight']),
+ drupal_render($form[$key]['plid']) . drupal_render($form[$key]['mlid']),
+ l(t('view'), $href),
+ $access ? l(t('edit'), 'node/' . $nid . '/edit', array('query' => $destination)) : '&nbsp;',
+ $access ? l(t('delete'), 'node/' . $nid . '/delete', array('query' => $destination) ) : '&nbsp;',
+ );
+ $row = array('data' => $data);
+ if (isset($form[$key]['#attributes'])) {
+ $row = array_merge($row, $form[$key]['#attributes']);
+ }
+ $row['class'][] = 'draggable';
+ $rows[] = $row;
+ }
+
+ return theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'book-outline')));
+}
diff --git a/core/modules/book/book.css b/core/modules/book/book.css
new file mode 100644
index 000000000000..3348c0f5abbb
--- /dev/null
+++ b/core/modules/book/book.css
@@ -0,0 +1,51 @@
+
+.book-navigation .menu {
+ border-top: 1px solid #888;
+ padding: 1em 0 0 3em; /* LTR */
+}
+.book-navigation .page-links {
+ border-top: 1px solid #888;
+ border-bottom: 1px solid #888;
+ text-align: center;
+ padding: 0.5em;
+}
+.book-navigation .page-previous {
+ text-align: left;
+ width: 42%;
+ display: block;
+ float: left; /* LTR */
+}
+.book-navigation .page-up {
+ margin: 0 5%;
+ width: 4%;
+ display: block;
+ float: left; /* LTR */
+}
+.book-navigation .page-next {
+ text-align: right;
+ width: 42%;
+ display: block;
+ float: right;
+}
+.book-outline-form .form-item {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+html.js #edit-book-pick-book {
+ display: none;
+}
+.form-item-book-bid .description {
+ clear: both;
+}
+#book-admin-edit select {
+ margin-right: 24px;
+}
+#book-admin-edit select.progress-disabled {
+ margin-right: 0;
+}
+#book-admin-edit tr.ajax-new-content {
+ background-color: #ffd;
+}
+#book-admin-edit .form-item {
+ float: left;
+}
diff --git a/core/modules/book/book.info b/core/modules/book/book.info
new file mode 100644
index 000000000000..0f4d2b1db1ae
--- /dev/null
+++ b/core/modules/book/book.info
@@ -0,0 +1,8 @@
+name = Book
+description = Allows users to create and organize related content in an outline.
+package = Core
+version = VERSION
+core = 8.x
+files[] = book.test
+configure = admin/content/book/settings
+stylesheets[all][] = book.css
diff --git a/core/modules/book/book.install b/core/modules/book/book.install
new file mode 100644
index 000000000000..e92aca6e43ce
--- /dev/null
+++ b/core/modules/book/book.install
@@ -0,0 +1,92 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the book module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function book_install() {
+ // Add the node type.
+ _book_install_type_create();
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function book_uninstall() {
+ variable_del('book_allowed_types');
+ variable_del('book_child_type');
+ variable_del('book_block_mode');
+
+ // Delete menu links.
+ db_delete('menu_links')
+ ->condition('module', 'book')
+ ->execute();
+ menu_cache_clear_all();
+}
+
+function _book_install_type_create() {
+ // Create an additional node type.
+ $book_node_type = array(
+ 'type' => 'book',
+ 'name' => t('Book page'),
+ 'base' => 'node_content',
+ 'description' => t('<em>Books</em> have a built-in hierarchical navigation. Use for handbooks or tutorials.'),
+ 'custom' => 1,
+ 'modified' => 1,
+ 'locked' => 0,
+ );
+
+ $book_node_type = node_type_set_defaults($book_node_type);
+ node_type_save($book_node_type);
+ node_add_body_field($book_node_type);
+ // Default to not promoted.
+ variable_set('node_options_book', array('status'));
+ // Use this default type for adding content to books.
+ variable_set('book_allowed_types', array('book'));
+ variable_set('book_child_type', 'book');
+}
+
+/**
+ * Implements hook_schema().
+ */
+function book_schema() {
+ $schema['book'] = array(
+ 'description' => 'Stores book outline information. Uniquely connects each node in the outline to a link in {menu_links}',
+ 'fields' => array(
+ 'mlid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The book page's {menu_links}.mlid.",
+ ),
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The book page's {node}.nid.",
+ ),
+ 'bid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The book ID is the {book}.nid of the top-level page.",
+ ),
+ ),
+ 'primary key' => array('mlid'),
+ 'unique keys' => array(
+ 'nid' => array('nid'),
+ ),
+ 'indexes' => array(
+ 'bid' => array('bid'),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/book/book.js b/core/modules/book/book.js
new file mode 100644
index 000000000000..5b953f164ff7
--- /dev/null
+++ b/core/modules/book/book.js
@@ -0,0 +1,22 @@
+
+(function ($) {
+
+Drupal.behaviors.bookFieldsetSummaries = {
+ attach: function (context) {
+ $('fieldset.book-form', context).drupalSetSummary(function (context) {
+ var val = $('.form-item-book-bid select').val();
+
+ if (val === '0') {
+ return Drupal.t('Not in book');
+ }
+ else if (val === 'new') {
+ return Drupal.t('New book');
+ }
+ else {
+ return Drupal.checkPlain($('.form-item-book-bid select :selected').text());
+ }
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/book/book.module b/core/modules/book/book.module
new file mode 100644
index 000000000000..fee2467a4eec
--- /dev/null
+++ b/core/modules/book/book.module
@@ -0,0 +1,1316 @@
+<?php
+
+/**
+ * @file
+ * Allows users to create and organize related content in an outline.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function book_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#book':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Book module is used for creating structured, multi-page content, such as site resource guides, manuals, and wikis. It allows you to create content that has chapters, sections, subsections, or any similarly-tiered structure. For more information, see the online handbook entry for <a href="@book">Book module</a>.', array('@book' => 'http://drupal.org/handbook/modules/book/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Adding and managing book content') . '</dt>';
+ $output .= '<dd>' . t('You can assign separate permissions for <em>creating</em>, <em>editing</em>, and <em>deleting</em> book content, as well as <em>adding content to books</em>, and <em>creating new books</em>. Users with the <em>Administer book outlines</em> permission can add <em>any</em> type of content to a book by selecting the appropriate book outline while editing the content. They can also view a list of all books, and edit and rearrange section titles on the <a href="@admin-book">Book administration page</a>.', array('@admin-book' => url('admin/content/book'))) . '</dd>';
+ $output .= '<dt>' . t('Book navigation') . '</dt>';
+ $output .= '<dd>' . t("Book pages have a default book-specific navigation block. This navigation block contains links that lead to the previous and next pages in the book, and to the level above the current page in the book's structure. This block can be enabled on the <a href='@admin-block'>Blocks administration page</a>. For book pages to show up in the book navigation, they must be added to a book outline.", array('@admin-block' => url('admin/structure/block'))) . '</dd>';
+ $output .= '<dt>' . t('Collaboration') . '</dt>';
+ $output .= '<dd>' . t('Books can be created collaboratively, as they allow users with appropriate permissions to add pages into existing books, and add those pages to a custom table of contents menu.') . '</dd>';
+ $output .= '<dt>' . t('Printing books') . '</dt>';
+ $output .= '<dd>' . t("Users with the <em>View printer-friendly books</em> permission can select the <em>printer-friendly version</em> link visible at the bottom of a book page's content to generate a printer-friendly display of the page and all of its subsections.") . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/content/book':
+ return '<p>' . t('The book module offers a means to organize a collection of related content pages, collectively known as a book. When viewed, this content automatically displays links to adjacent book pages, providing a simple navigation system for creating and reviewing structured content.') . '</p>';
+ case 'node/%/outline':
+ return '<p>' . t('The outline feature allows you to include pages in the <a href="@book">Book hierarchy</a>, as well as move them within the hierarchy or to <a href="@book-admin">reorder an entire book</a>.', array('@book' => url('book'), '@book-admin' => url('admin/content/book'))) . '</p>';
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function book_theme() {
+ return array(
+ 'book_navigation' => array(
+ 'variables' => array('book_link' => NULL),
+ 'template' => 'book-navigation',
+ ),
+ 'book_export_html' => array(
+ 'variables' => array('title' => NULL, 'contents' => NULL, 'depth' => NULL),
+ 'template' => 'book-export-html',
+ ),
+ 'book_admin_table' => array(
+ 'render element' => 'form',
+ ),
+ 'book_title_link' => array(
+ 'variables' => array('link' => NULL),
+ ),
+ 'book_all_books_block' => array(
+ 'render element' => 'book_menus',
+ 'template' => 'book-all-books-block',
+ ),
+ 'book_node_export_html' => array(
+ 'variables' => array('node' => NULL, 'children' => NULL),
+ 'template' => 'book-node-export-html',
+ ),
+ );
+}
+
+/**
+ * Implements hook_permission().
+ */
+function book_permission() {
+ return array(
+ 'administer book outlines' => array(
+ 'title' => t('Administer book outlines'),
+ ),
+ 'create new books' => array(
+ 'title' => t('Create new books'),
+ ),
+ 'add content to books' => array(
+ 'title' => t('Add content and child pages to books'),
+ ),
+ 'access printer-friendly version' => array(
+ 'title' => t('View printer-friendly books'),
+ 'description' => t('View a book page and all of its sub-pages as a single document for ease of printing. Can be performance heavy.'),
+ ),
+ );
+}
+
+/**
+ * Inject links into $node as needed.
+ */
+function book_node_view_link($node, $view_mode) {
+ $links = array();
+
+ if (isset($node->book['depth'])) {
+ if ($view_mode == 'full' && node_is_page($node)) {
+ $child_type = variable_get('book_child_type', 'book');
+ if ((user_access('add content to books') || user_access('administer book outlines')) && node_access('create', $child_type) && $node->status == 1 && $node->book['depth'] < MENU_MAX_DEPTH) {
+ $links['book_add_child'] = array(
+ 'title' => t('Add child page'),
+ 'href' => 'node/add/' . str_replace('_', '-', $child_type),
+ 'query' => array('parent' => $node->book['mlid']),
+ );
+ }
+
+ if (user_access('access printer-friendly version')) {
+ $links['book_printer'] = array(
+ 'title' => t('Printer-friendly version'),
+ 'href' => 'book/export/html/' . $node->nid,
+ 'attributes' => array('title' => t('Show a printer-friendly version of this book page and its sub-pages.'))
+ );
+ }
+ }
+ }
+
+ if (!empty($links)) {
+ $node->content['links']['book'] = array(
+ '#theme' => 'links__node__book',
+ '#links' => $links,
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function book_menu() {
+ $items['admin/content/book'] = array(
+ 'title' => 'Books',
+ 'description' => "Manage your site's book outlines.",
+ 'page callback' => 'book_admin_overview',
+ 'access arguments' => array('administer book outlines'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'book.admin.inc',
+ );
+ $items['admin/content/book/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/content/book/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('book_admin_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 8,
+ 'file' => 'book.admin.inc',
+ );
+ $items['admin/content/book/%node'] = array(
+ 'title' => 'Re-order book pages and change titles',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('book_admin_edit', 3),
+ 'access callback' => '_book_outline_access',
+ 'access arguments' => array(3),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'book.admin.inc',
+ );
+ $items['book'] = array(
+ 'title' => 'Books',
+ 'page callback' => 'book_render',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ 'file' => 'book.pages.inc',
+ );
+ $items['book/export/%/%'] = array(
+ 'page callback' => 'book_export',
+ 'page arguments' => array(2, 3),
+ 'access arguments' => array('access printer-friendly version'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'book.pages.inc',
+ );
+ $items['node/%node/outline'] = array(
+ 'title' => 'Outline',
+ 'page callback' => 'book_outline',
+ 'page arguments' => array(1),
+ 'access callback' => '_book_outline_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ 'file' => 'book.pages.inc',
+ );
+ $items['node/%node/outline/remove'] = array(
+ 'title' => 'Remove from outline',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('book_remove_form', 1),
+ 'access callback' => '_book_outline_remove_access',
+ 'access arguments' => array(1),
+ 'file' => 'book.pages.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Menu item access callback - determine if the outline tab is accessible.
+ */
+function _book_outline_access($node) {
+ return user_access('administer book outlines') && node_access('view', $node);
+}
+
+/**
+ * Menu item access callback - determine if the user can remove nodes from the outline.
+ */
+function _book_outline_remove_access($node) {
+ return isset($node->book) && ($node->book['bid'] != $node->nid) && _book_outline_access($node);
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function book_admin_paths() {
+ if (variable_get('node_admin_theme')) {
+ $paths = array(
+ 'node/*/outline' => TRUE,
+ 'node/*/outline/remove' => TRUE,
+ );
+ return $paths;
+ }
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ */
+function book_entity_info_alter(&$info) {
+ // Add the 'Print' view mode for nodes.
+ $info['node']['view modes'] += array(
+ 'print' => array(
+ 'label' => t('Print'),
+ 'custom settings' => FALSE,
+ ),
+ );
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function book_block_info() {
+ $block = array();
+ $block['navigation']['info'] = t('Book navigation');
+ $block['navigation']['cache'] = DRUPAL_CACHE_PER_PAGE | DRUPAL_CACHE_PER_ROLE;
+
+ return $block;
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Displays the book table of contents in a block when the current page is a
+ * single-node view of a book node.
+ */
+function book_block_view($delta = '') {
+ $block = array();
+ $current_bid = 0;
+ if ($node = menu_get_object()) {
+ $current_bid = empty($node->book['bid']) ? 0 : $node->book['bid'];
+ }
+
+ if (variable_get('book_block_mode', 'all pages') == 'all pages') {
+ $block['subject'] = t('Book navigation');
+ $book_menus = array();
+ $pseudo_tree = array(0 => array('below' => FALSE));
+ foreach (book_get_books() as $book_id => $book) {
+ if ($book['bid'] == $current_bid) {
+ // If the current page is a node associated with a book, the menu
+ // needs to be retrieved.
+ $book_menus[$book_id] = menu_tree_output(menu_tree_all_data($node->book['menu_name'], $node->book));
+ }
+ else {
+ // Since we know we will only display a link to the top node, there
+ // is no reason to run an additional menu tree query for each book.
+ $book['in_active_trail'] = FALSE;
+ // Check whether user can access the book link.
+ $book_node = node_load($book['nid']);
+ $book['access'] = node_access('view', $book_node);
+ $pseudo_tree[0]['link'] = $book;
+ $book_menus[$book_id] = menu_tree_output($pseudo_tree);
+ }
+ }
+ if ($block['content'] = $book_menus) {
+ $book_menus['#theme'] = 'book_all_books_block';
+ }
+ }
+ elseif ($current_bid) {
+ // Only display this block when the user is browsing a book.
+ $select = db_select('node', 'n')
+ ->fields('n', array('title'))
+ ->condition('n.nid', $node->book['bid'])
+ ->addTag('node_access');
+ $title = $select->execute()->fetchField();
+ // Only show the block if the user has view access for the top-level node.
+ if ($title) {
+ $tree = menu_tree_all_data($node->book['menu_name'], $node->book);
+ // There should only be one element at the top level.
+ $data = array_shift($tree);
+ $block['subject'] = theme('book_title_link', array('link' => $data['link']));
+ $block['content'] = ($data['below']) ? menu_tree_output($data['below']) : '';
+ }
+ }
+
+ return $block;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function book_block_configure($delta = '') {
+ $block = array();
+ $options = array(
+ 'all pages' => t('Show block on all pages'),
+ 'book pages' => t('Show block only on book pages'),
+ );
+ $form['book_block_mode'] = array(
+ '#type' => 'radios',
+ '#title' => t('Book navigation block display'),
+ '#options' => $options,
+ '#default_value' => variable_get('book_block_mode', 'all pages'),
+ '#description' => t("If <em>Show block on all pages</em> is selected, the block will contain the automatically generated menus for all of the site's books. If <em>Show block only on book pages</em> is selected, the block will contain only the one menu corresponding to the current page's book. In this case, if the current page is not in a book, no block will be displayed. The <em>Page specific visibility settings</em> or other visibility settings can be used in addition to selectively display this block."),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function book_block_save($delta = '', $edit = array()) {
+ $block = array();
+ variable_set('book_block_mode', $edit['book_block_mode']);
+}
+
+/**
+ * Returns HTML for a link to a book title when used as a block title.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - link: An array containing title, href and options for the link.
+ *
+ * @ingroup themeable
+ */
+function theme_book_title_link($variables) {
+ $link = $variables['link'];
+
+ $link['options']['attributes']['class'] = array('book-title');
+
+ return l($link['title'], $link['href'], $link['options']);
+}
+
+/**
+ * Returns an array of all books.
+ *
+ * This list may be used for generating a list of all the books, or for building
+ * the options for a form select.
+ */
+function book_get_books() {
+ $all_books = &drupal_static(__FUNCTION__);
+
+ if (!isset($all_books)) {
+ $all_books = array();
+ $nids = db_query("SELECT DISTINCT(bid) FROM {book}")->fetchCol();
+
+ if ($nids) {
+ $query = db_select('book', 'b', array('fetch' => PDO::FETCH_ASSOC));
+ $query->join('node', 'n', 'b.nid = n.nid');
+ $query->join('menu_links', 'ml', 'b.mlid = ml.mlid');
+ $query->addField('n', 'type', 'type');
+ $query->addField('n', 'title', 'title');
+ $query->fields('b');
+ $query->fields('ml');
+ $query->condition('n.nid', $nids, 'IN');
+ $query->condition('n.status', 1);
+ $query->orderBy('ml.weight');
+ $query->orderBy('ml.link_title');
+ $query->addTag('node_access');
+ $result2 = $query->execute();
+ foreach ($result2 as $link) {
+ $link['href'] = $link['link_path'];
+ $link['options'] = unserialize($link['options']);
+ $all_books[$link['bid']] = $link;
+ }
+ }
+ }
+
+ return $all_books;
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ *
+ * Adds the book fieldset to the node form.
+ *
+ * @see book_pick_book_nojs_submit()
+ */
+function book_form_node_form_alter(&$form, &$form_state, $form_id) {
+ $node = $form['#node'];
+ $access = user_access('administer book outlines');
+ if (!$access) {
+ if (user_access('add content to books') && ((!empty($node->book['mlid']) && !empty($node->nid)) || book_type_is_allowed($node->type))) {
+ // Already in the book hierarchy, or this node type is allowed.
+ $access = TRUE;
+ }
+ }
+
+ if ($access) {
+ _book_add_form_elements($form, $form_state, $node);
+ // Since the "Book" dropdown can't trigger a form submission when
+ // JavaScript is disabled, add a submit button to do that. book.css hides
+ // this button when JavaScript is enabled.
+ $form['book']['pick-book'] = array(
+ '#type' => 'submit',
+ '#value' => t('Change book (update list of parents)'),
+ '#submit' => array('book_pick_book_nojs_submit'),
+ '#weight' => 20,
+ );
+ }
+}
+
+/**
+ * Submit handler to change a node's book.
+ *
+ * This handler is run when JavaScript is disabled. It triggers the form to
+ * rebuild so that the "Parent item" options are changed to reflect the newly
+ * selected book. When JavaScript is enabled, the submit button that triggers
+ * this handler is hidden, and the "Book" dropdown directly triggers the
+ * book_form_update() Ajax callback instead.
+ *
+ * @see book_form_update()
+ */
+function book_pick_book_nojs_submit($form, &$form_state) {
+ $form_state['node']->book = $form_state['values']['book'];
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Build the parent selection form element for the node form or outline tab.
+ *
+ * This function is also called when generating a new set of options during the
+ * Ajax callback, so an array is returned that can be used to replace an existing
+ * form element.
+ */
+function _book_parent_select($book_link) {
+ if (variable_get('menu_override_parent_selector', FALSE)) {
+ return array();
+ }
+ // Offer a message or a drop-down to choose a different parent page.
+ $form = array(
+ '#type' => 'hidden',
+ '#value' => -1,
+ '#prefix' => '<div id="edit-book-plid-wrapper">',
+ '#suffix' => '</div>',
+ );
+
+ if ($book_link['nid'] === $book_link['bid']) {
+ // This is a book - at the top level.
+ if ($book_link['original_bid'] === $book_link['bid']) {
+ $form['#prefix'] .= '<em>' . t('This is the top-level page in this book.') . '</em>';
+ }
+ else {
+ $form['#prefix'] .= '<em>' . t('This will be the top-level page in this book.') . '</em>';
+ }
+ }
+ elseif (!$book_link['bid']) {
+ $form['#prefix'] .= '<em>' . t('No book selected.') . '</em>';
+ }
+ else {
+ $form = array(
+ '#type' => 'select',
+ '#title' => t('Parent item'),
+ '#default_value' => $book_link['plid'],
+ '#description' => t('The parent page in the book. The maximum depth for a book and all child pages is !maxdepth. Some pages in the selected book may not be available as parents if selecting them would exceed this limit.', array('!maxdepth' => MENU_MAX_DEPTH)),
+ '#options' => book_toc($book_link['bid'], $book_link['parent_depth_limit'], array($book_link['mlid'])),
+ '#attributes' => array('class' => array('book-title-select')),
+ '#prefix' => '<div id="edit-book-plid-wrapper">',
+ '#suffix' => '</div>',
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Build the common elements of the book form for the node and outline forms.
+ */
+function _book_add_form_elements(&$form, &$form_state, $node) {
+ // If the form is being processed during the Ajax callback of our book bid
+ // dropdown, then $form_state will hold the value that was selected.
+ if (isset($form_state['values']['book'])) {
+ $node->book = $form_state['values']['book'];
+ }
+
+ $form['book'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Book outline'),
+ '#weight' => 10,
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('book-form'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'book') . '/book.js'),
+ ),
+ '#tree' => TRUE,
+ '#attributes' => array('class' => array('book-outline-form')),
+ );
+ foreach (array('menu_name', 'mlid', 'nid', 'router_path', 'has_children', 'options', 'module', 'original_bid', 'parent_depth_limit') as $key) {
+ $form['book'][$key] = array(
+ '#type' => 'value',
+ '#value' => $node->book[$key],
+ );
+ }
+
+ $form['book']['plid'] = _book_parent_select($node->book);
+
+ // @see _book_admin_table_tree(). The weight may be larger than 15.
+ $form['book']['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#default_value' => $node->book['weight'],
+ '#delta' => max(15, abs($node->book['weight'])),
+ '#weight' => 5,
+ '#description' => t('Pages at a given level are ordered first by weight and then by title.'),
+ );
+ $options = array();
+ $nid = isset($node->nid) ? $node->nid : 'new';
+
+ if (isset($node->nid) && ($nid == $node->book['original_bid']) && ($node->book['parent_depth_limit'] == 0)) {
+ // This is the top level node in a maximum depth book and thus cannot be moved.
+ $options[$node->nid] = $node->title;
+ }
+ else {
+ foreach (book_get_books() as $book) {
+ $options[$book['nid']] = $book['title'];
+ }
+ }
+
+ if (user_access('create new books') && ($nid == 'new' || ($nid != $node->book['original_bid']))) {
+ // The node can become a new book, if it is not one already.
+ $options = array($nid => '<' . t('create a new book') . '>') + $options;
+ }
+ if (!$node->book['mlid']) {
+ // The node is not currently in the hierarchy.
+ $options = array(0 => '<' . t('none') . '>') + $options;
+ }
+
+ // Add a drop-down to select the destination book.
+ $form['book']['bid'] = array(
+ '#type' => 'select',
+ '#title' => t('Book'),
+ '#default_value' => $node->book['bid'],
+ '#options' => $options,
+ '#access' => (bool) $options,
+ '#description' => t('Your page will be a part of the selected book.'),
+ '#weight' => -5,
+ '#attributes' => array('class' => array('book-title-select')),
+ '#ajax' => array(
+ 'callback' => 'book_form_update',
+ 'wrapper' => 'edit-book-plid-wrapper',
+ 'effect' => 'fade',
+ 'speed' => 'fast',
+ ),
+ );
+}
+
+/**
+ * Renders a new parent page select element when the book selection changes.
+ *
+ * This function is called via Ajax when the selected book is changed on a node
+ * or book outline form.
+ *
+ * @return
+ * The rendered parent page select element.
+ */
+function book_form_update($form, $form_state) {
+ return $form['book']['plid'];
+}
+
+/**
+ * Common helper function to handles additions and updates to the book outline.
+ *
+ * Performs all additions and updates to the book outline through node addition,
+ * node editing, node deletion, or the outline tab.
+ */
+function _book_update_outline($node) {
+ if (empty($node->book['bid'])) {
+ return FALSE;
+ }
+ $new = empty($node->book['mlid']);
+
+ $node->book['link_path'] = 'node/' . $node->nid;
+ $node->book['link_title'] = $node->title;
+ $node->book['parent_mismatch'] = FALSE; // The normal case.
+
+ if ($node->book['bid'] == $node->nid) {
+ $node->book['plid'] = 0;
+ $node->book['menu_name'] = book_menu_name($node->nid);
+ }
+ else {
+ // Check in case the parent is not is this book; the book takes precedence.
+ if (!empty($node->book['plid'])) {
+ $parent = db_query("SELECT * FROM {book} WHERE mlid = :mlid", array(
+ ':mlid' => $node->book['plid'],
+ ))->fetchAssoc();
+ }
+ if (empty($node->book['plid']) || !$parent || $parent['bid'] != $node->book['bid']) {
+ $node->book['plid'] = db_query("SELECT mlid FROM {book} WHERE nid = :nid", array(
+ ':nid' => $node->book['bid'],
+ ))->fetchField();
+ $node->book['parent_mismatch'] = TRUE; // Likely when JS is disabled.
+ }
+ }
+
+ if (menu_link_save($node->book)) {
+ if ($new) {
+ // Insert new.
+ db_insert('book')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'mlid' => $node->book['mlid'],
+ 'bid' => $node->book['bid'],
+ ))
+ ->execute();
+ // Reset the cache of stored books.
+ drupal_static_reset('book_get_books');
+ }
+ else {
+ if ($node->book['bid'] != db_query("SELECT bid FROM {book} WHERE nid = :nid", array(
+ ':nid' => $node->nid,
+ ))->fetchField()) {
+ // Update the bid for this page and all children.
+ book_update_bid($node->book);
+ // Reset the cache of stored books.
+ drupal_static_reset('book_get_books');
+ }
+ }
+
+ return TRUE;
+ }
+
+ // Failed to save the menu link.
+ return FALSE;
+}
+
+/**
+ * Update the bid for a page and its children when it is moved to a new book.
+ *
+ * @param $book_link
+ * A fully loaded menu link that is part of the book hierarchy.
+ */
+function book_update_bid($book_link) {
+ $query = db_select('menu_links');
+ $query->addField('menu_links', 'mlid');
+ for ($i = 1; $i <= MENU_MAX_DEPTH && $book_link["p$i"]; $i++) {
+ $query->condition("p$i", $book_link["p$i"]);
+ }
+ $mlids = $query->execute()->fetchCol();
+
+ if ($mlids) {
+ db_update('book')
+ ->fields(array('bid' => $book_link['bid']))
+ ->condition('mlid', $mlids, 'IN')
+ ->execute();
+ }
+}
+
+/**
+ * Get the book menu tree for a page, and return it as a linear array.
+ *
+ * @param $book_link
+ * A fully loaded menu link that is part of the book hierarchy.
+ * @return
+ * A linear array of menu links in the order that the links are shown in the
+ * menu, so the previous and next pages are the elements before and after the
+ * element corresponding to $node. The children of $node (if any) will come
+ * immediately after it in the array, and links will only be fetched as deep
+ * as one level deeper than $book_link.
+ */
+function book_get_flat_menu($book_link) {
+ $flat = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($flat[$book_link['mlid']])) {
+ // Call menu_tree_all_data() to take advantage of the menu system's caching.
+ $tree = menu_tree_all_data($book_link['menu_name'], $book_link, $book_link['depth'] + 1);
+ $flat[$book_link['mlid']] = array();
+ _book_flatten_menu($tree, $flat[$book_link['mlid']]);
+ }
+
+ return $flat[$book_link['mlid']];
+}
+
+/**
+ * Recursive helper function for book_get_flat_menu().
+ */
+function _book_flatten_menu($tree, &$flat) {
+ foreach ($tree as $data) {
+ if (!$data['link']['hidden']) {
+ $flat[$data['link']['mlid']] = $data['link'];
+ if ($data['below']) {
+ _book_flatten_menu($data['below'], $flat);
+ }
+ }
+ }
+}
+
+/**
+ * Fetches the menu link for the previous page of the book.
+ */
+function book_prev($book_link) {
+ // If the parent is zero, we are at the start of a book.
+ if ($book_link['plid'] == 0) {
+ return NULL;
+ }
+ $flat = book_get_flat_menu($book_link);
+ // Assigning the array to $flat resets the array pointer for use with each().
+ $curr = NULL;
+ do {
+ $prev = $curr;
+ list($key, $curr) = each($flat);
+ } while ($key && $key != $book_link['mlid']);
+
+ if ($key == $book_link['mlid']) {
+ // The previous page in the book may be a child of the previous visible link.
+ if ($prev['depth'] == $book_link['depth'] && $prev['has_children']) {
+ // The subtree will have only one link at the top level - get its data.
+ $tree = book_menu_subtree_data($prev);
+ $data = array_shift($tree);
+ // The link of interest is the last child - iterate to find the deepest one.
+ while ($data['below']) {
+ $data = end($data['below']);
+ }
+
+ return $data['link'];
+ }
+ else {
+ return $prev;
+ }
+ }
+}
+
+/**
+ * Fetches the menu link for the next page of the book.
+ */
+function book_next($book_link) {
+ $flat = book_get_flat_menu($book_link);
+ // Assigning the array to $flat resets the array pointer for use with each().
+ do {
+ list($key, $curr) = each($flat);
+ }
+ while ($key && $key != $book_link['mlid']);
+
+ if ($key == $book_link['mlid']) {
+ return current($flat);
+ }
+}
+
+/**
+ * Format the menu links for the child pages of the current page.
+ */
+function book_children($book_link) {
+ $flat = book_get_flat_menu($book_link);
+
+ $children = array();
+
+ if ($book_link['has_children']) {
+ // Walk through the array until we find the current page.
+ do {
+ $link = array_shift($flat);
+ }
+ while ($link && ($link['mlid'] != $book_link['mlid']));
+ // Continue though the array and collect the links whose parent is this page.
+ while (($link = array_shift($flat)) && $link['plid'] == $book_link['mlid']) {
+ $data['link'] = $link;
+ $data['below'] = '';
+ $children[] = $data;
+ }
+ }
+
+ if ($children) {
+ $elements = menu_tree_output($children);
+ return drupal_render($elements);
+ }
+ return '';
+}
+
+/**
+ * Generate the corresponding menu name from a book ID.
+ */
+function book_menu_name($bid) {
+ return 'book-toc-' . $bid;
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function book_node_load($nodes, $types) {
+ $result = db_query("SELECT * FROM {book} b INNER JOIN {menu_links} ml ON b.mlid = ml.mlid WHERE b.nid IN (:nids)", array(':nids' => array_keys($nodes)), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $record) {
+ $nodes[$record['nid']]->book = $record;
+ $nodes[$record['nid']]->book['href'] = $record['link_path'];
+ $nodes[$record['nid']]->book['title'] = $record['link_title'];
+ $nodes[$record['nid']]->book['options'] = unserialize($record['options']);
+ }
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function book_node_view($node, $view_mode) {
+ if ($view_mode == 'full') {
+ if (!empty($node->book['bid']) && empty($node->in_preview)) {
+ $node->content['book_navigation'] = array(
+ '#markup' => theme('book_navigation', array('book_link' => $node->book)),
+ '#weight' => 100,
+ );
+ }
+ }
+
+ if ($view_mode != 'rss') {
+ book_node_view_link($node, $view_mode);
+ }
+}
+
+/**
+ * Implements hook_page_alter().
+ *
+ * Add the book menu to the list of menus used to build the active trail when
+ * viewing a book page.
+ */
+function book_page_alter(&$page) {
+ if (($node = menu_get_object()) && !empty($node->book['bid'])) {
+ $active_menus = menu_get_active_menu_names();
+ $active_menus[] = $node->book['menu_name'];
+ menu_set_active_menu_names($active_menus);
+ }
+}
+
+/**
+ * Implements hook_node_presave().
+ */
+function book_node_presave($node) {
+ // Always save a revision for non-administrators.
+ if (!empty($node->book['bid']) && !user_access('administer nodes')) {
+ $node->revision = 1;
+ // The database schema requires a log message for every revision.
+ if (!isset($node->log)) {
+ $node->log = '';
+ }
+ }
+ // Make sure a new node gets a new menu link.
+ if (empty($node->nid)) {
+ $node->book['mlid'] = NULL;
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function book_node_insert($node) {
+ if (!empty($node->book['bid'])) {
+ if ($node->book['bid'] == 'new') {
+ // New nodes that are their own book.
+ $node->book['bid'] = $node->nid;
+ }
+ $node->book['nid'] = $node->nid;
+ $node->book['menu_name'] = book_menu_name($node->book['bid']);
+ _book_update_outline($node);
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function book_node_update($node) {
+ if (!empty($node->book['bid'])) {
+ if ($node->book['bid'] == 'new') {
+ // New nodes that are their own book.
+ $node->book['bid'] = $node->nid;
+ }
+ $node->book['nid'] = $node->nid;
+ $node->book['menu_name'] = book_menu_name($node->book['bid']);
+ _book_update_outline($node);
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function book_node_delete($node) {
+ if (!empty($node->book['bid'])) {
+ if ($node->nid == $node->book['bid']) {
+ // Handle deletion of a top-level post.
+ $result = db_query("SELECT b.nid FROM {menu_links} ml INNER JOIN {book} b on b.mlid = ml.mlid WHERE ml.plid = :plid", array(
+ ':plid' => $node->book['mlid']
+ ));
+ foreach ($result as $child) {
+ $child_node = node_load($child->nid);
+ $child_node->book['bid'] = $child_node->nid;
+ _book_update_outline($child_node);
+ }
+ }
+ menu_link_delete($node->book['mlid']);
+ db_delete('book')
+ ->condition('mlid', $node->book['mlid'])
+ ->execute();
+ drupal_static_reset('book_get_books');
+ }
+}
+
+/**
+ * Implements hook_node_prepare().
+ */
+function book_node_prepare($node) {
+ // Prepare defaults for the add/edit form.
+ if (empty($node->book) && (user_access('add content to books') || user_access('administer book outlines'))) {
+ $node->book = array();
+
+ if (empty($node->nid) && isset($_GET['parent']) && is_numeric($_GET['parent'])) {
+ // Handle "Add child page" links:
+ $parent = book_link_load($_GET['parent']);
+
+ if ($parent && $parent['access']) {
+ $node->book['bid'] = $parent['bid'];
+ $node->book['plid'] = $parent['mlid'];
+ $node->book['menu_name'] = $parent['menu_name'];
+ }
+ }
+ // Set defaults.
+ $node->book += _book_link_defaults(!empty($node->nid) ? $node->nid : 'new');
+ }
+ else {
+ if (isset($node->book['bid']) && !isset($node->book['original_bid'])) {
+ $node->book['original_bid'] = $node->book['bid'];
+ }
+ }
+ // Find the depth limit for the parent select.
+ if (isset($node->book['bid']) && !isset($node->book['parent_depth_limit'])) {
+ $node->book['parent_depth_limit'] = _book_parent_depth_limit($node->book);
+ }
+}
+
+/**
+ * Find the depth limit for items in the parent select.
+ */
+function _book_parent_depth_limit($book_link) {
+ return MENU_MAX_DEPTH - 1 - (($book_link['mlid'] && $book_link['has_children']) ? menu_link_children_relative_depth($book_link) : 0);
+}
+
+/**
+ * Form altering function for the confirm form for a single node deletion.
+ */
+function book_form_node_delete_confirm_alter(&$form, $form_state) {
+ $node = node_load($form['nid']['#value']);
+
+ if (isset($node->book) && $node->book['has_children']) {
+ $form['book_warning'] = array(
+ '#markup' => '<p>' . t('%title is part of a book outline, and has associated child pages. If you proceed with deletion, the child pages will be relocated automatically.', array('%title' => $node->title)) . '</p>',
+ '#weight' => -10,
+ );
+ }
+}
+
+/**
+ * Return an array with default values for a book link.
+ */
+function _book_link_defaults($nid) {
+ return array('original_bid' => 0, 'menu_name' => '', 'nid' => $nid, 'bid' => 0, 'router_path' => 'node/%', 'plid' => 0, 'mlid' => 0, 'has_children' => 0, 'weight' => 0, 'module' => 'book', 'options' => array());
+}
+
+/**
+ * Process variables for book-all-books-block.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $book_menus
+ *
+ * All non-renderable elements are removed so that the template has full
+ * access to the structured data but can also simply iterate over all
+ * elements and render them (as in the default template).
+ *
+ * @see book-navigation.tpl.php
+ */
+function template_preprocess_book_all_books_block(&$variables) {
+ // Remove all non-renderable elements.
+ $elements = $variables['book_menus'];
+ $variables['book_menus'] = array();
+ foreach (element_children($elements) as $index) {
+ $variables['book_menus'][$index] = $elements[$index];
+ }
+}
+
+/**
+ * Process variables for book-navigation.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $book_link
+ *
+ * @see book-navigation.tpl.php
+ */
+function template_preprocess_book_navigation(&$variables) {
+ $book_link = $variables['book_link'];
+
+ // Provide extra variables for themers. Not needed by default.
+ $variables['book_id'] = $book_link['bid'];
+ $variables['book_title'] = check_plain($book_link['link_title']);
+ $variables['book_url'] = 'node/' . $book_link['bid'];
+ $variables['current_depth'] = $book_link['depth'];
+ $variables['tree'] = '';
+
+ if ($book_link['mlid']) {
+ $variables['tree'] = book_children($book_link);
+
+ if ($prev = book_prev($book_link)) {
+ $prev_href = url($prev['href']);
+ drupal_add_html_head_link(array('rel' => 'prev', 'href' => $prev_href));
+ $variables['prev_url'] = $prev_href;
+ $variables['prev_title'] = check_plain($prev['title']);
+ }
+
+ if ($book_link['plid'] && $parent = book_link_load($book_link['plid'])) {
+ $parent_href = url($parent['href']);
+ drupal_add_html_head_link(array('rel' => 'up', 'href' => $parent_href));
+ $variables['parent_url'] = $parent_href;
+ $variables['parent_title'] = check_plain($parent['title']);
+ }
+
+ if ($next = book_next($book_link)) {
+ $next_href = url($next['href']);
+ drupal_add_html_head_link(array('rel' => 'next', 'href' => $next_href));
+ $variables['next_url'] = $next_href;
+ $variables['next_title'] = check_plain($next['title']);
+ }
+ }
+
+ $variables['has_links'] = FALSE;
+ // Link variables to filter for values and set state of the flag variable.
+ $links = array('prev_url', 'prev_title', 'parent_url', 'parent_title', 'next_url', 'next_title');
+ foreach ($links as $link) {
+ if (isset($variables[$link])) {
+ // Flag when there is a value.
+ $variables['has_links'] = TRUE;
+ }
+ else {
+ // Set empty to prevent notices.
+ $variables[$link] = '';
+ }
+ }
+}
+
+/**
+ * Recursively processes and formats menu items for book_toc().
+ *
+ * This helper function recursively modifies the $toc array for each item in
+ * $tree, ignoring items in the exclude array or at a depth greater than the
+ * limit. Truncates titles over thirty characters and appends an indentation
+ * string incremented by depth.
+ *
+ * @param $tree
+ * The data structure of the book's menu tree. Includes hidden links.
+ * @param $indent
+ * A string appended to each menu item title. Increments by '--' per depth
+ * level.
+ * @param $toc
+ * Reference to the table of contents array. This is modified in place, so the
+ * function does not have a return value.
+ * @param $exclude
+ * Optional array of mlid values. Any link whose mlid is in this array will be
+ * excluded (along with its children).
+ * @param $depth_limit
+ * Any link deeper than this value will be excluded (along with its children).
+ */
+function _book_toc_recurse($tree, $indent, &$toc, $exclude, $depth_limit) {
+ foreach ($tree as $data) {
+ if ($data['link']['depth'] > $depth_limit) {
+ // Don't iterate through any links on this level.
+ break;
+ }
+
+ if (!in_array($data['link']['mlid'], $exclude)) {
+ $toc[$data['link']['mlid']] = $indent . ' ' . truncate_utf8($data['link']['title'], 30, TRUE, TRUE);
+ if ($data['below']) {
+ _book_toc_recurse($data['below'], $indent . '--', $toc, $exclude, $depth_limit);
+ }
+ }
+ }
+}
+
+/**
+ * Returns an array of book pages in table of contents order.
+ *
+ * @param $bid
+ * The ID of the book whose pages are to be listed.
+ * @param $depth_limit
+ * Any link deeper than this value will be excluded (along with its children).
+ * @param $exclude
+ * Optional array of mlid values. Any link whose mlid is in this array
+ * will be excluded (along with its children).
+ * @return
+ * An array of mlid, title pairs for use as options for selecting a book page.
+ */
+function book_toc($bid, $depth_limit, $exclude = array()) {
+ $tree = menu_tree_all_data(book_menu_name($bid));
+ $toc = array();
+ _book_toc_recurse($tree, '', $toc, $exclude, $depth_limit);
+
+ return $toc;
+}
+
+/**
+ * Process variables for book-export-html.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $title
+ * - $contents
+ * - $depth
+ *
+ * @see book-export-html.tpl.php
+ */
+function template_preprocess_book_export_html(&$variables) {
+ global $base_url, $language;
+
+ $variables['title'] = check_plain($variables['title']);
+ $variables['base_url'] = $base_url;
+ $variables['language'] = $language;
+ $variables['language_rtl'] = ($language->direction == LANGUAGE_RTL);
+ $variables['head'] = drupal_get_html_head();
+}
+
+/**
+ * Traverse the book tree to build printable or exportable output.
+ *
+ * During the traversal, the $visit_func() callback is applied to each
+ * node, and is called recursively for each child of the node (in weight,
+ * title order).
+ *
+ * @param $tree
+ * A subtree of the book menu hierarchy, rooted at the current page.
+ * @param $visit_func
+ * A function callback to be called upon visiting a node in the tree.
+ * @return
+ * The output generated in visiting each node.
+ */
+function book_export_traverse($tree, $visit_func) {
+ $output = '';
+
+ foreach ($tree as $data) {
+ // Note- access checking is already performed when building the tree.
+ if ($node = node_load($data['link']['nid'], FALSE)) {
+ $children = '';
+
+ if ($data['below']) {
+ $children = book_export_traverse($data['below'], $visit_func);
+ }
+
+ if (function_exists($visit_func)) {
+ $output .= call_user_func($visit_func, $node, $children);
+ }
+ else {
+ // Use the default function.
+ $output .= book_node_export($node, $children);
+ }
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Generates printer-friendly HTML for a node.
+ *
+ * @see book_export_traverse()
+ *
+ * @param $node
+ * The node that will be output.
+ * @param $children
+ * All the rendered child nodes within the current node.
+ * @return
+ * The HTML generated for the given node.
+ */
+function book_node_export($node, $children = '') {
+ $build = node_view($node, 'print');
+ unset($build['#theme']);
+ // @todo Rendering should happen in the template using render().
+ $node->rendered = drupal_render($build);
+
+ return theme('book_node_export_html', array('node' => $node, 'children' => $children));
+}
+
+/**
+ * Process variables for book-node-export-html.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $node
+ * - $children
+ *
+ * @see book-node-export-html.tpl.php
+ */
+function template_preprocess_book_node_export_html(&$variables) {
+ $variables['depth'] = $variables['node']->book['depth'];
+ $variables['title'] = check_plain($variables['node']->title);
+ $variables['content'] = $variables['node']->rendered;
+}
+
+/**
+ * Determine if a given node type is in the list of types allowed for books.
+ */
+function book_type_is_allowed($type) {
+ return in_array($type, variable_get('book_allowed_types', array('book')));
+}
+
+/**
+ * Implements hook_node_type_update().
+ *
+ * Update book module's persistent variables if the machine-readable name of a
+ * node type is changed.
+ */
+function book_node_type_update($type) {
+ if (!empty($type->old_type) && $type->old_type != $type->type) {
+ // Update the list of node types that are allowed to be added to books.
+ $allowed_types = variable_get('book_allowed_types', array('book'));
+ $key = array_search($type->old_type, $allowed_types);
+
+ if ($key !== FALSE) {
+ $allowed_types[$type->type] = $allowed_types[$key] ? $type->type : 0;
+ unset($allowed_types[$key]);
+ variable_set('book_allowed_types', $allowed_types);
+ }
+
+ // Update the setting for the "Add child page" link.
+ if (variable_get('book_child_type', 'book') == $type->old_type) {
+ variable_set('book_child_type', $type->type);
+ }
+ }
+}
+
+/**
+ * Like menu_link_load(), but adds additional data from the {book} table.
+ *
+ * Do not call when loading a node, since this function may call node_load().
+ */
+function book_link_load($mlid) {
+ if ($item = db_query("SELECT * FROM {menu_links} ml INNER JOIN {book} b ON b.mlid = ml.mlid LEFT JOIN {menu_router} m ON m.path = ml.router_path WHERE ml.mlid = :mlid", array(
+ ':mlid' => $mlid,
+ ))->fetchAssoc()) {
+ _menu_link_translate($item);
+ return $item;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Get the data representing a subtree of the book hierarchy.
+ *
+ * The root of the subtree will be the link passed as a parameter, so the
+ * returned tree will contain this item and all its descendents in the menu tree.
+ *
+ * @param $link
+ * A fully loaded menu link.
+ * @return
+ * An subtree of menu links in an array, in the order they should be rendered.
+ */
+function book_menu_subtree_data($link) {
+ $tree = &drupal_static(__FUNCTION__, array());
+
+ // Generate a cache ID (cid) specific for this $menu_name and $link.
+ $cid = 'links:' . $link['menu_name'] . ':subtree-cid:' . $link['mlid'];
+
+ if (!isset($tree[$cid])) {
+ $cache = cache('menu')->get($cid);
+
+ if ($cache && isset($cache->data)) {
+ // If the cache entry exists, it will just be the cid for the actual data.
+ // This avoids duplication of large amounts of data.
+ $cache = cache('menu')->get($cache->data);
+
+ if ($cache && isset($cache->data)) {
+ $data = $cache->data;
+ }
+ }
+
+ // If the subtree data was not in the cache, $data will be NULL.
+ if (!isset($data)) {
+ $query = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC));
+ $query->join('menu_router', 'm', 'm.path = ml.router_path');
+ $query->join('book', 'b', 'ml.mlid = b.mlid');
+ $query->fields('b');
+ $query->fields('m', array('load_functions', 'to_arg_functions', 'access_callback', 'access_arguments', 'page_callback', 'page_arguments', 'delivery_callback', 'title', 'title_callback', 'title_arguments', 'type'));
+ $query->fields('ml');
+ $query->condition('menu_name', $link['menu_name']);
+ for ($i = 1; $i <= MENU_MAX_DEPTH && $link["p$i"]; ++$i) {
+ $query->condition("p$i", $link["p$i"]);
+ }
+ for ($i = 1; $i <= MENU_MAX_DEPTH; ++$i) {
+ $query->orderBy("p$i");
+ }
+ $links = array();
+ foreach ($query->execute() as $item) {
+ $links[] = $item;
+ }
+ $data['tree'] = menu_tree_data($links, array(), $link['depth']);
+ $data['node_links'] = array();
+ menu_tree_collect_node_links($data['tree'], $data['node_links']);
+ // Compute the real cid for book subtree data.
+ $tree_cid = 'links:' . $item['menu_name'] . ':subtree-data:' . hash('sha256', serialize($data));
+ // Cache the data, if it is not already in the cache.
+
+ if (!cache('menu')->get($tree_cid)) {
+ cache('menu')->set($tree_cid, $data);
+ }
+ // Cache the cid of the (shared) data using the menu and item-specific cid.
+ cache('menu')->set($cid, $tree_cid);
+ }
+ // Check access for the current user to each item in the tree.
+ menu_tree_check_access($data['tree'], $data['node_links']);
+ $tree[$cid] = $data['tree'];
+ }
+
+ return $tree[$cid];
+}
diff --git a/core/modules/book/book.pages.inc b/core/modules/book/book.pages.inc
new file mode 100644
index 000000000000..1617f0085039
--- /dev/null
+++ b/core/modules/book/book.pages.inc
@@ -0,0 +1,220 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the book module.
+ */
+
+/**
+ * Menu callback; prints a listing of all books.
+ */
+function book_render() {
+ $book_list = array();
+ foreach (book_get_books() as $book) {
+ $book_list[] = l($book['title'], $book['href'], $book['options']);
+ }
+
+ return theme('item_list', array('items' => $book_list));
+}
+
+/**
+ * Menu callback; Generates various representation of a book page and its children.
+ *
+ * The function delegates the generation of output to helper functions.
+ * The function name is derived by prepending 'book_export_' to the
+ * given output type. So, e.g., a type of 'html' results in a call to
+ * the function book_export_html().
+ *
+ * @param $type
+ * A string encoding the type of output requested. The following
+ * types are currently supported in book module:
+ *
+ * - html: HTML (printer friendly output)
+ *
+ * Other types may be supported in contributed modules.
+ * @param $nid
+ * An integer representing the node id (nid) of the node to export
+ * @return
+ * A string representing the node and its children in the book hierarchy
+ * in a format determined by the $type parameter.
+ */
+function book_export($type, $nid) {
+ $type = drupal_strtolower($type);
+
+ $export_function = 'book_export_' . $type;
+
+ if (function_exists($export_function)) {
+ print call_user_func($export_function, $nid);
+ }
+ else {
+ drupal_set_message(t('Unknown export format.'));
+ drupal_not_found();
+ }
+}
+
+/**
+ * This function is called by book_export() to generate HTML for export.
+ *
+ * The given node is /embedded to its absolute depth in a top level
+ * section/. For example, a child node with depth 2 in the hierarchy
+ * is contained in (otherwise empty) &lt;div&gt; elements
+ * corresponding to depth 0 and depth 1. This is intended to support
+ * WYSIWYG output - e.g., level 3 sections always look like level 3
+ * sections, no matter their depth relative to the node selected to be
+ * exported as printer-friendly HTML.
+ *
+ * @param $nid
+ * An integer representing the node id (nid) of the node to export.
+ * @return
+ * A string containing HTML representing the node and its children in
+ * the book hierarchy.
+ */
+function book_export_html($nid) {
+ if (user_access('access printer-friendly version')) {
+ $export_data = array();
+ $node = node_load($nid);
+ if (isset($node->book)) {
+ $tree = book_menu_subtree_data($node->book);
+ $contents = book_export_traverse($tree, 'book_node_export');
+ return theme('book_export_html', array('title' => $node->title, 'contents' => $contents, 'depth' => $node->book['depth']));
+ }
+ else {
+ drupal_not_found();
+ }
+ }
+ else {
+ drupal_access_denied();
+ }
+}
+
+/**
+ * Menu callback; show the outline form for a single node.
+ */
+function book_outline($node) {
+ drupal_set_title($node->title);
+ return drupal_get_form('book_outline_form', $node);
+}
+
+/**
+ * Build the form to handle all book outline operations via the outline tab.
+ *
+ * @see book_outline_form_submit()
+ * @see book_remove_button_submit()
+ *
+ * @ingroup forms
+ */
+function book_outline_form($form, &$form_state, $node) {
+ if (!isset($node->book)) {
+ // The node is not part of any book yet - set default options.
+ $node->book = _book_link_defaults($node->nid);
+ }
+ else {
+ $node->book['original_bid'] = $node->book['bid'];
+ }
+
+ // Find the depth limit for the parent select.
+ if (!isset($node->book['parent_depth_limit'])) {
+ $node->book['parent_depth_limit'] = _book_parent_depth_limit($node->book);
+ }
+ $form['#node'] = $node;
+ $form['#id'] = 'book-outline';
+ _book_add_form_elements($form, $form_state, $node);
+
+ $form['book']['#collapsible'] = FALSE;
+
+ $form['update'] = array(
+ '#type' => 'submit',
+ '#value' => $node->book['original_bid'] ? t('Update book outline') : t('Add to book outline'),
+ '#weight' => 15,
+ );
+
+ $form['remove'] = array(
+ '#type' => 'submit',
+ '#value' => t('Remove from book outline'),
+ '#access' => $node->nid != $node->book['bid'] && $node->book['bid'],
+ '#weight' => 20,
+ '#submit' => array('book_remove_button_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Button submit function to redirect to removal confirm form.
+ *
+ * @see book_outline_form()
+ */
+function book_remove_button_submit($form, &$form_state) {
+ $form_state['redirect'] = 'node/' . $form['#node']->nid . '/outline/remove';
+}
+
+/**
+ * Handles book outline form submissions from the outline tab.
+ *
+ * @see book_outline_form()
+ */
+function book_outline_form_submit($form, &$form_state) {
+ $node = $form['#node'];
+ $form_state['redirect'] = "node/" . $node->nid;
+ $book_link = $form_state['values']['book'];
+ if (!$book_link['bid']) {
+ drupal_set_message(t('No changes were made'));
+
+ return;
+ }
+
+ $book_link['menu_name'] = book_menu_name($book_link['bid']);
+ $node->book = $book_link;
+ if (_book_update_outline($node)) {
+ if ($node->book['parent_mismatch']) {
+ // This will usually only happen when JS is disabled.
+ drupal_set_message(t('The post has been added to the selected book. You may now position it relative to other pages.'));
+ $form_state['redirect'] = "node/" . $node->nid . "/outline";
+ }
+ else {
+ drupal_set_message(t('The book outline has been updated.'));
+ }
+ }
+ else {
+ drupal_set_message(t('There was an error adding the post to the book.'), 'error');
+ }
+}
+
+/**
+ * Menu callback; builds a form to confirm removal of a node from the book.
+ *
+ * @see book_remove_form_submit()
+ *
+ * @ingroup forms
+ */
+function book_remove_form($form, &$form_state, $node) {
+ $form['#node'] = $node;
+ $title = array('%title' => $node->title);
+
+ if ($node->book['has_children']) {
+ $description = t('%title has associated child pages, which will be relocated automatically to maintain their connection to the book. To recreate the hierarchy (as it was before removing this page), %title may be added again using the Outline tab, and each of its former child pages will need to be relocated manually.', $title);
+ }
+ else {
+ $description = t('%title may be added to hierarchy again using the Outline tab.', $title);
+ }
+
+ return confirm_form($form, t('Are you sure you want to remove %title from the book hierarchy?', $title), 'node/' . $node->nid, $description, t('Remove'));
+}
+
+/**
+ * Confirm form submit function to remove a node from the book.
+ *
+ * @see book_remove_form()
+ */
+function book_remove_form_submit($form, &$form_state) {
+ $node = $form['#node'];
+ if ($node->nid != $node->book['bid']) {
+ // Only allowed when this is not a book (top-level page).
+ menu_link_delete($node->book['mlid']);
+ db_delete('book')
+ ->condition('nid', $node->nid)
+ ->execute();
+ drupal_set_message(t('The post has been removed from the book.'));
+ }
+ $form_state['redirect'] = 'node/' . $node->nid;
+}
diff --git a/core/modules/book/book.test b/core/modules/book/book.test
new file mode 100644
index 000000000000..6c351b8ecf8f
--- /dev/null
+++ b/core/modules/book/book.test
@@ -0,0 +1,335 @@
+<?php
+
+/**
+ * @file
+ * Tests for book.module.
+ */
+
+class BookTestCase extends DrupalWebTestCase {
+ protected $book;
+ // $book_author is a user with permission to create and edit books.
+ protected $book_author;
+ // $web_user is a user with permission to view a book
+ // and access the printer-friendly version.
+ protected $web_user;
+ // $admin_user is a user with permission to create and edit books and to administer blocks.
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Book functionality',
+ 'description' => 'Create a book, add pages, and test book interface.',
+ 'group' => 'Book',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('book', 'node_access_test'));
+
+ // node_access_test requires a node_access_rebuild().
+ node_access_rebuild();
+
+ // Create users.
+ $this->book_author = $this->drupalCreateUser(array('create new books', 'create book content', 'edit own book content', 'add content to books'));
+ $this->web_user = $this->drupalCreateUser(array('access printer-friendly version', 'node test view'));
+ $this->admin_user = $this->drupalCreateUser(array('create new books', 'create book content', 'edit own book content', 'add content to books', 'administer blocks', 'administer permissions'));
+ }
+
+ /**
+ * Create a new book with a page hierarchy.
+ */
+ function createBook() {
+ // Create new book.
+ $this->drupalLogin($this->book_author);
+
+ $this->book = $this->createBookNode('new');
+ $book = $this->book;
+
+ /*
+ * Add page hierarchy to book.
+ * Book
+ * |- Node 0
+ * |- Node 1
+ * |- Node 2
+ * |- Node 3
+ * |- Node 4
+ */
+ $nodes = array();
+ $nodes[] = $this->createBookNode($book->nid); // Node 0.
+ $nodes[] = $this->createBookNode($book->nid, $nodes[0]->book['mlid']); // Node 1.
+ $nodes[] = $this->createBookNode($book->nid, $nodes[0]->book['mlid']); // Node 2.
+ $nodes[] = $this->createBookNode($book->nid); // Node 3.
+ $nodes[] = $this->createBookNode($book->nid); // Node 4.
+
+ $this->drupalLogout();
+
+ return $nodes;
+ }
+
+ /**
+ * Test book functionality through node interfaces.
+ */
+ function testBook() {
+ // Create new book.
+ $nodes = $this->createBook();
+ $book = $this->book;
+
+ $this->drupalLogin($this->web_user);
+
+ // Check that book pages display along with the correct outlines and
+ // previous/next links.
+ $this->checkBookNode($book, array($nodes[0], $nodes[3], $nodes[4]), FALSE, FALSE, $nodes[0], array());
+ $this->checkBookNode($nodes[0], array($nodes[1], $nodes[2]), $book, $book, $nodes[1], array($book));
+ $this->checkBookNode($nodes[1], NULL, $nodes[0], $nodes[0], $nodes[2], array($book, $nodes[0]));
+ $this->checkBookNode($nodes[2], NULL, $nodes[1], $nodes[0], $nodes[3], array($book, $nodes[0]));
+ $this->checkBookNode($nodes[3], NULL, $nodes[2], $book, $nodes[4], array($book));
+ $this->checkBookNode($nodes[4], NULL, $nodes[3], $book, FALSE, array($book));
+
+ $this->drupalLogout();
+
+ // Create a second book, and move an existing book page into it.
+ $this->drupalLogin($this->book_author);
+ $other_book = $this->createBookNode('new');
+ $node = $this->createBookNode($book->nid);
+ $edit = array('book[bid]' => $other_book->nid);
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ $this->drupalLogout();
+ $this->drupalLogin($this->web_user);
+
+ // Check that the nodes in the second book are displayed correctly.
+ // First we must set $this->book to the second book, so that the
+ // correct regex will be generated for testing the outline.
+ $this->book = $other_book;
+ $this->checkBookNode($other_book, array($node), FALSE, FALSE, $node, array());
+ $this->checkBookNode($node, NULL, $other_book, $other_book, FALSE, array($other_book));
+ }
+
+ /**
+ * Check the outline of sub-pages; previous, up, and next; and printer friendly version.
+ *
+ * @param $node
+ * Node to check.
+ * @param $nodes
+ * Nodes that should be in outline.
+ * @param $previous
+ * Previous link node.
+ * @param $up
+ * Up link node.
+ * @param $next
+ * Next link node.
+ * @param $breadcrumb
+ * The nodes that should be displayed in the breadcrumb.
+ */
+ function checkBookNode($node, $nodes, $previous = FALSE, $up = FALSE, $next = FALSE, array $breadcrumb) {
+ // $number does not use drupal_static as it should not be reset
+ // since it uniquely identifies each call to checkBookNode().
+ static $number = 0;
+ $this->drupalGet('node/' . $node->nid);
+
+ // Check outline structure.
+ if ($nodes !== NULL) {
+ $this->assertPattern($this->generateOutlinePattern($nodes), t('Node ' . $number . ' outline confirmed.'));
+ }
+ else {
+ $this->pass(t('Node ' . $number . ' doesn\'t have outline.'));
+ }
+
+ // Check previous, up, and next links.
+ if ($previous) {
+ $this->assertRaw(l('‹ ' . $previous->title, 'node/' . $previous->nid, array('attributes' => array('class' => array('page-previous'), 'title' => t('Go to previous page')))), t('Previous page link found.'));
+ }
+
+ if ($up) {
+ $this->assertRaw(l('up', 'node/' . $up->nid, array('attributes' => array('class' => array('page-up'), 'title' => t('Go to parent page')))), t('Up page link found.'));
+ }
+
+ if ($next) {
+ $this->assertRaw(l($next->title . ' ›', 'node/' . $next->nid, array('attributes' => array('class' => array('page-next'), 'title' => t('Go to next page')))), t('Next page link found.'));
+ }
+
+ // Compute the expected breadcrumb.
+ $expected_breadcrumb = array();
+ $expected_breadcrumb[] = url('');
+ foreach ($breadcrumb as $a_node) {
+ $expected_breadcrumb[] = url('node/' . $a_node->nid);
+ }
+
+ // Fetch links in the current breadcrumb.
+ $links = $this->xpath('//div[@class="breadcrumb"]/a');
+ $got_breadcrumb = array();
+ foreach ($links as $link) {
+ $got_breadcrumb[] = (string) $link['href'];
+ }
+
+ // Compare expected and got breadcrumbs.
+ $this->assertIdentical($expected_breadcrumb, $got_breadcrumb, t('The breadcrumb is correctly displayed on the page.'));
+
+ // Check printer friendly version.
+ $this->drupalGet('book/export/html/' . $node->nid);
+ $this->assertText($node->title, t('Printer friendly title found.'));
+ $this->assertRaw(check_markup($node->body[LANGUAGE_NONE][0]['value'], $node->body[LANGUAGE_NONE][0]['format']), t('Printer friendly body found.'));
+
+ $number++;
+ }
+
+ /**
+ * Create a regular expression to check for the sub-nodes in the outline.
+ *
+ * @param array $nodes Nodes to check in outline.
+ */
+ function generateOutlinePattern($nodes) {
+ $outline = '';
+ foreach ($nodes as $node) {
+ $outline .= '(node\/' . $node->nid . ')(.*?)(' . $node->title . ')(.*?)';
+ }
+
+ return '/<div id="book-navigation-' . $this->book->nid . '"(.*?)<ul(.*?)' . $outline . '<\/ul>/s';
+ }
+
+ /**
+ * Create book node.
+ *
+ * @param integer $book_nid Book node id or set to 'new' to create new book.
+ * @param integer $parent Parent book reference id.
+ */
+ function createBookNode($book_nid, $parent = NULL) {
+ // $number does not use drupal_static as it should not be reset
+ // since it uniquely identifies each call to createBookNode().
+ static $number = 0; // Used to ensure that when sorted nodes stay in same order.
+
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $number . ' - SimpleTest test node ' . $this->randomName(10);
+ $edit["body[$langcode][0][value]"] = 'SimpleTest test body ' . $this->randomName(32) . ' ' . $this->randomName(32);
+ $edit['book[bid]'] = $book_nid;
+
+ if ($parent !== NULL) {
+ $this->drupalPost('node/add/book', $edit, t('Change book (update list of parents)'));
+
+ $edit['book[plid]'] = $parent;
+ $this->drupalPost(NULL, $edit, t('Save'));
+ }
+ else {
+ $this->drupalPost('node/add/book', $edit, t('Save'));
+ }
+
+ // Check to make sure the book node was created.
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertNotNull(($node === FALSE ? NULL : $node), t('Book node found in database.'));
+ $number++;
+
+ return $node;
+ }
+
+ /**
+ * Tests book export ("printer-friendly version") functionality.
+ */
+ function testBookExport() {
+ // Create a book.
+ $nodes = $this->createBook();
+
+ // Login as web user and view printer-friendly version.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $this->book->nid);
+ $this->clickLink(t('Printer-friendly version'));
+
+ // Make sure each part of the book is there.
+ foreach ($nodes as $node) {
+ $this->assertText($node->title, t('Node title found in printer friendly version.'));
+ $this->assertRaw(check_markup($node->body[LANGUAGE_NONE][0]['value'], $node->body[LANGUAGE_NONE][0]['format']), t('Node body found in printer friendly version.'));
+ }
+
+ // Make sure we can't export an unsupported format.
+ $this->drupalGet('book/export/foobar/' . $this->book->nid);
+ $this->assertResponse('404', t('Unsupported export format returned "not found".'));
+
+ // Make sure we get a 404 on a not existing book node.
+ $this->drupalGet('book/export/html/123');
+ $this->assertResponse('404', t('Not existing book node returned "not found".'));
+
+ // Make sure an anonymous user cannot view printer-friendly version.
+ $this->drupalLogout();
+
+ // Load the book and verify there is no printer-friendly version link.
+ $this->drupalGet('node/' . $this->book->nid);
+ $this->assertNoLink(t('Printer-friendly version'), t('Anonymous user is not shown link to printer-friendly version.'));
+
+ // Try getting the URL directly, and verify it fails.
+ $this->drupalGet('book/export/html/' . $this->book->nid);
+ $this->assertResponse('403', t('Anonymous user properly forbidden.'));
+ }
+
+ /**
+ * Tests the functionality of the book navigation block.
+ */
+ function testBookNavigationBlock() {
+ $this->drupalLogin($this->admin_user);
+
+ // Set block title to confirm that the interface is available.
+ $block_title = $this->randomName(16);
+ $this->drupalPost('admin/structure/block/manage/book/navigation/configure', array('title' => $block_title), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array();
+ $edit['blocks[book_navigation][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.'));
+
+ // Give anonymous users the permission 'node test view'.
+ $edit = array();
+ $edit['1[node test view]'] = TRUE;
+ $this->drupalPost('admin/people/permissions/1', $edit, t('Save permissions'));
+ $this->assertText(t('The changes have been saved.'), t("Permission 'node test view' successfully assigned to anonymous users."));
+
+ // Test correct display of the block.
+ $nodes = $this->createBook();
+ $this->drupalGet('<front>');
+ $this->assertText($block_title, t('Book navigation block is displayed.'));
+ $this->assertText($this->book->title, t('Link to book root (@title) is displayed.', array('@title' => $nodes[0]->title)));
+ $this->assertNoText($nodes[0]->title, t('No links to individual book pages are displayed.'));
+ }
+
+ /**
+ * Test the book navigation block when an access module is enabled.
+ */
+ function testNavigationBlockOnAccessModuleEnabled() {
+ $this->drupalLogin($this->admin_user);
+ $edit = array();
+
+ // Set the block title.
+ $block_title = $this->randomName(16);
+ $edit['title'] = $block_title;
+
+ // Set block display to 'Show block only on book pages'.
+ $edit['book_block_mode'] = 'book pages';
+ $this->drupalPost('admin/structure/block/manage/book/navigation/configure', $edit, t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array();
+ $edit['blocks[book_navigation][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.'));
+
+ // Give anonymous users the permission 'node test view'.
+ $edit = array();
+ $edit['1[node test view]'] = TRUE;
+ $this->drupalPost('admin/people/permissions/1', $edit, t('Save permissions'));
+ $this->assertText(t('The changes have been saved.'), t('Permission \'node test view\' successfully assigned to anonymous users.'));
+
+ // Create a book.
+ $this->createBook();
+
+ // Test correct display of the block to registered users.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $this->book->nid);
+ $this->assertText($block_title, t('Book navigation block is displayed to registered users.'));
+ $this->drupalLogout();
+
+ // Test correct display of the block to anonymous users.
+ $this->drupalGet('node/' . $this->book->nid);
+ $this->assertText($block_title, t('Book navigation block is displayed to anonymous users.'));
+ }
+}
diff --git a/core/modules/color/color.admin-rtl.css b/core/modules/color/color.admin-rtl.css
new file mode 100644
index 000000000000..bfbcd499ff8f
--- /dev/null
+++ b/core/modules/color/color.admin-rtl.css
@@ -0,0 +1,44 @@
+
+#placeholder {
+ left: 0;
+ right: auto;
+}
+
+/* Palette */
+.color-form .form-item {
+ padding-left: 0;
+ padding-right: 1em;
+}
+.color-form label {
+ float: right;
+ clear: right;
+}
+.color-form .form-text,
+.color-form .form-select {
+ float: right;
+}
+.color-form .form-text {
+ margin-right: 0;
+ margin-left: 5px;
+}
+#palette .hook {
+ float: right;
+}
+#palette .down,
+#palette .up,
+#palette .both {
+ background: url(images/hook-rtl.png) no-repeat 0 0;
+}
+#palette .up {
+ background-position: 0 -27px;
+}
+#palette .both {
+ background-position: 0 -54px;
+}
+#palette .lock {
+ float: right;
+ right: -10px;
+}
+html.js #preview {
+ float: right;
+}
diff --git a/core/modules/color/color.admin.css b/core/modules/color/color.admin.css
new file mode 100644
index 000000000000..e513dadf5408
--- /dev/null
+++ b/core/modules/color/color.admin.css
@@ -0,0 +1,81 @@
+
+/* Farbtastic placement */
+.color-form {
+ max-width: 50em;
+ position: relative;
+}
+#placeholder {
+ position: absolute;
+ top: 0;
+ right: 0; /* LTR */
+}
+
+/* Palette */
+.color-form .form-item {
+ height: 2em;
+ line-height: 2em;
+ padding-left: 1em; /* LTR */
+ margin: 0.5em 0;
+}
+.color-form label {
+ float: left; /* LTR */
+ clear: left; /* LTR */
+ width: 10em;
+}
+.color-form .form-text,
+.color-form .form-select {
+ float: left; /* LTR */
+}
+.color-form .form-text {
+ text-align: center;
+ margin-right: 5px; /* LTR */
+ cursor: pointer;
+}
+
+#palette .hook {
+ float: left; /* LTR */
+ margin-top: 3px;
+ width: 16px;
+ height: 16px;
+}
+#palette .down,
+#palette .up,
+#palette .both {
+ background: url(images/hook.png) no-repeat 100% 0; /* LTR */
+}
+#palette .up {
+ background-position: 100% -27px; /* LTR */
+}
+#palette .both {
+ background-position: 100% -54px; /* LTR */
+}
+
+#palette .lock {
+ float: left; /* LTR */
+ position: relative;
+ top: -1.4em;
+ left: -10px; /* LTR */
+ width: 20px;
+ height: 25px;
+ background: url(images/lock.png) no-repeat 50% 2px;
+ cursor: pointer;
+}
+#palette .unlocked {
+ background-position: 50% -22px;
+}
+#palette .form-item {
+ width: 20em;
+}
+#palette .item-selected {
+ background: #eee;
+}
+
+/* Preview */
+#preview {
+ display: none;
+}
+html.js #preview {
+ display: block;
+ position: relative;
+ float: left; /* LTR */
+}
diff --git a/core/modules/color/color.info b/core/modules/color/color.info
new file mode 100644
index 000000000000..c25e1233d7c4
--- /dev/null
+++ b/core/modules/color/color.info
@@ -0,0 +1,6 @@
+name = Color
+description = Allows administrators to change the color scheme of compatible themes.
+package = Core
+version = VERSION
+core = 8.x
+files[] = color.test
diff --git a/core/modules/color/color.install b/core/modules/color/color.install
new file mode 100644
index 000000000000..a1879f9b5594
--- /dev/null
+++ b/core/modules/color/color.install
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the color module.
+ */
+
+/**
+ * Implements hook_requirements().
+ */
+function color_requirements($phase) {
+ $requirements = array();
+
+ if ($phase == 'runtime') {
+ // Check for the PHP GD library.
+ if (function_exists('imagegd2')) {
+ $info = gd_info();
+ $requirements['color_gd'] = array(
+ 'value' => $info['GD Version'],
+ );
+
+ // Check for PNG support.
+ if (function_exists('imagecreatefrompng')) {
+ $requirements['color_gd']['severity'] = REQUIREMENT_OK;
+ }
+ else {
+ $requirements['color_gd']['severity'] = REQUIREMENT_WARNING;
+ $requirements['color_gd']['description'] = t('The GD library for PHP is enabled, but was compiled without PNG support. Check the <a href="@url">PHP image documentation</a> for information on how to correct this.', array('@url' => 'http://www.php.net/manual/ref.image.php'));
+ }
+ }
+ else {
+ $requirements['color_gd'] = array(
+ 'value' => t('Not installed'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => t('The GD library for PHP is missing or outdated. Check the <a href="@url">PHP image documentation</a> for information on how to correct this.', array('@url' => 'http://www.php.net/manual/book.image.php')),
+ );
+ }
+ $requirements['color_gd']['title'] = t('GD library PNG support');
+ }
+
+ return $requirements;
+}
diff --git a/core/modules/color/color.js b/core/modules/color/color.js
new file mode 100644
index 000000000000..3e53ce115a78
--- /dev/null
+++ b/core/modules/color/color.js
@@ -0,0 +1,235 @@
+(function ($) {
+
+Drupal.behaviors.color = {
+ attach: function (context, settings) {
+ var i, j, colors, field_name;
+ // This behavior attaches by ID, so is only valid once on a page.
+ var form = $('#system-theme-settings .color-form', context).once('color');
+ if (form.length == 0) {
+ return;
+ }
+ var inputs = [];
+ var hooks = [];
+ var locks = [];
+ var focused = null;
+
+ // Add Farbtastic.
+ $(form).prepend('<div id="placeholder"></div>').addClass('color-processed');
+ var farb = $.farbtastic('#placeholder');
+
+ // Decode reference colors to HSL.
+ var reference = settings.color.reference;
+ for (i in reference) {
+ reference[i] = farb.RGBToHSL(farb.unpack(reference[i]));
+ }
+
+ // Build a preview.
+ var height = [];
+ var width = [];
+ // Loop through all defined gradients.
+ for (i in settings.gradients) {
+ // Add element to display the gradient.
+ $('#preview').once('color').append('<div id="gradient-' + i + '"></div>');
+ var gradient = $('#preview #gradient-' + i);
+ // Add height of current gradient to the list (divided by 10).
+ height.push(parseInt(gradient.css('height'), 10) / 10);
+ // Add width of current gradient to the list (divided by 10).
+ width.push(parseInt(gradient.css('width'), 10) / 10);
+ // Add rows (or columns for horizontal gradients).
+ // Each gradient line should have a height (or width for horizontal
+ // gradients) of 10px (because we divided the height/width by 10 above).
+ for (j = 0; j < (settings.gradients[i]['direction'] == 'vertical' ? height[i] : width[i]); ++j) {
+ gradient.append('<div class="gradient-line"></div>');
+ }
+ }
+
+ // Set up colorScheme selector.
+ $('#edit-scheme', form).change(function () {
+ var schemes = settings.color.schemes, colorScheme = this.options[this.selectedIndex].value;
+ if (colorScheme != '' && schemes[colorScheme]) {
+ // Get colors of active scheme.
+ colors = schemes[colorScheme];
+ for (field_name in colors) {
+ callback($('#edit-palette-' + field_name), colors[field_name], false, true);
+ }
+ preview();
+ }
+ });
+
+ /**
+ * Render the preview.
+ */
+ function preview() {
+ Drupal.color.callback(context, settings, form, farb, height, width);
+ }
+
+ /**
+ * Shift a given color, using a reference pair (ref in HSL).
+ *
+ * This algorithm ensures relative ordering on the saturation and luminance
+ * axes is preserved, and performs a simple hue shift.
+ *
+ * It is also symmetrical. If: shift_color(c, a, b) == d,
+ * then shift_color(d, b, a) == c.
+ */
+ function shift_color(given, ref1, ref2) {
+ // Convert to HSL.
+ given = farb.RGBToHSL(farb.unpack(given));
+
+ // Hue: apply delta.
+ given[0] += ref2[0] - ref1[0];
+
+ // Saturation: interpolate.
+ if (ref1[1] == 0 || ref2[1] == 0) {
+ given[1] = ref2[1];
+ }
+ else {
+ var d = ref1[1] / ref2[1];
+ if (d > 1) {
+ given[1] /= d;
+ }
+ else {
+ given[1] = 1 - (1 - given[1]) * d;
+ }
+ }
+
+ // Luminance: interpolate.
+ if (ref1[2] == 0 || ref2[2] == 0) {
+ given[2] = ref2[2];
+ }
+ else {
+ var d = ref1[2] / ref2[2];
+ if (d > 1) {
+ given[2] /= d;
+ }
+ else {
+ given[2] = 1 - (1 - given[2]) * d;
+ }
+ }
+
+ return farb.pack(farb.HSLToRGB(given));
+ }
+
+ /**
+ * Callback for Farbtastic when a new color is chosen.
+ */
+ function callback(input, color, propagate, colorScheme) {
+ var matched;
+ // Set background/foreground colors.
+ $(input).css({
+ backgroundColor: color,
+ 'color': farb.RGBToHSL(farb.unpack(color))[2] > 0.5 ? '#000' : '#fff'
+ });
+
+ // Change input value.
+ if ($(input).val() && $(input).val() != color) {
+ $(input).val(color);
+
+ // Update locked values.
+ if (propagate) {
+ i = input.i;
+ for (j = i + 1; ; ++j) {
+ if (!locks[j - 1] || $(locks[j - 1]).is('.unlocked')) break;
+ matched = shift_color(color, reference[input.key], reference[inputs[j].key]);
+ callback(inputs[j], matched, false);
+ }
+ for (j = i - 1; ; --j) {
+ if (!locks[j] || $(locks[j]).is('.unlocked')) break;
+ matched = shift_color(color, reference[input.key], reference[inputs[j].key]);
+ callback(inputs[j], matched, false);
+ }
+
+ // Update preview.
+ preview();
+ }
+
+ // Reset colorScheme selector.
+ if (!colorScheme) {
+ resetScheme();
+ }
+ }
+ }
+
+ /**
+ * Reset the color scheme selector.
+ */
+ function resetScheme() {
+ $('#edit-scheme', form).each(function () {
+ this.selectedIndex = this.options.length - 1;
+ });
+ }
+
+ // Focus the Farbtastic on a particular field.
+ function focus() {
+ var input = this;
+ // Remove old bindings.
+ focused && $(focused).unbind('keyup', farb.updateValue)
+ .unbind('keyup', preview).unbind('keyup', resetScheme)
+ .parent().removeClass('item-selected');
+
+ // Add new bindings.
+ focused = this;
+ farb.linkTo(function (color) { callback(input, color, true, false); });
+ farb.setColor(this.value);
+ $(focused).keyup(farb.updateValue).keyup(preview).keyup(resetScheme)
+ .parent().addClass('item-selected');
+ }
+
+ // Initialize color fields.
+ $('#palette input.form-text', form)
+ .each(function () {
+ // Extract palette field name
+ this.key = this.id.substring(13);
+
+ // Link to color picker temporarily to initialize.
+ farb.linkTo(function () {}).setColor('#000').linkTo(this);
+
+ // Add lock.
+ var i = inputs.length;
+ if (inputs.length) {
+ var lock = $('<div class="lock"></div>').toggle(
+ function () {
+ $(this).addClass('unlocked');
+ $(hooks[i - 1]).attr('class',
+ locks[i - 2] && $(locks[i - 2]).is(':not(.unlocked)') ? 'hook up' : 'hook'
+ );
+ $(hooks[i]).attr('class',
+ locks[i] && $(locks[i]).is(':not(.unlocked)') ? 'hook down' : 'hook'
+ );
+ },
+ function () {
+ $(this).removeClass('unlocked');
+ $(hooks[i - 1]).attr('class',
+ locks[i - 2] && $(locks[i - 2]).is(':not(.unlocked)') ? 'hook both' : 'hook down'
+ );
+ $(hooks[i]).attr('class',
+ locks[i] && $(locks[i]).is(':not(.unlocked)') ? 'hook both' : 'hook up'
+ );
+ }
+ );
+ $(this).after(lock);
+ locks.push(lock);
+ };
+
+ // Add hook.
+ var hook = $('<div class="hook"></div>');
+ $(this).after(hook);
+ hooks.push(hook);
+
+ $(this).parent().find('.lock').click();
+ this.i = i;
+ inputs.push(this);
+ })
+ .focus(focus);
+
+ $('#palette label', form);
+
+ // Focus first color.
+ focus.call(inputs[0]);
+
+ // Render preview.
+ preview();
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/color/color.module b/core/modules/color/color.module
new file mode 100644
index 000000000000..7665631ed4e8
--- /dev/null
+++ b/core/modules/color/color.module
@@ -0,0 +1,740 @@
+<?php
+
+/**
+ * Implements hook_help().
+ */
+function color_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#color':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Color module allows users with the <em>Administer site configuration</em> permission to quickly and easily change the color scheme of themes that have been built to be compatible with it. For more information, see the online handbook entry for <a href="@color">Color module</a>.', array('@color' => 'http://drupal.org/handbook/modules/color')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Changing colors') . '</dt>';
+ $output .= '<dd>' . t("Using the Color module allows you to easily change the color of links, backgrounds, text, and other theme elements. To change the color settings for a compatible theme, select the <em>Settings</em> link for your theme on the <a href='@configure'>Themes administration page</a>. If you don't see a color picker on that page, then your theme is not compatible with the color module. If you are sure that the theme does indeed support the color module, but the color picker does not appear, then <a href='@troubleshoot'>follow these troubleshooting procedures</a>.", array('@configure' => url('admin/appearance'), '@troubleshoot' => 'http://drupal.org/node/109457')) . '</dd>';
+ $output .= '<dd>' . t("The Color module saves a modified copy of the theme's specified stylesheets in the files directory. This means that if you make any manual changes to your theme's stylesheet, <em>you must save your color settings again, even if they haven't changed</em>. This step is required because the module stylesheets (in the files directory) need to be recreated to include your changes.") . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function color_theme() {
+ return array(
+ 'color_scheme_form' => array(
+ 'render element' => 'form',
+ ),
+ );
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function color_form_system_theme_settings_alter(&$form, &$form_state) {
+ if (isset($form_state['build_info']['args'][0]) && ($theme = $form_state['build_info']['args'][0]) && color_get_info($theme) && function_exists('gd_info')) {
+ $form['color'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Color scheme'),
+ '#weight' => -1,
+ '#attributes' => array('id' => 'color_scheme_form'),
+ '#theme' => 'color_scheme_form',
+ );
+ $form['color'] += color_scheme_form($form, $form_state, $theme);
+ $form['#validate'][] = 'color_scheme_form_validate';
+ $form['#submit'][] = 'color_scheme_form_submit';
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function color_form_system_themes_alter(&$form, &$form_state) {
+ _color_theme_select_form_alter($form, $form_state);
+}
+
+/**
+ * Helper for hook_form_FORM_ID_alter() implementations.
+ */
+function _color_theme_select_form_alter(&$form, &$form_state) {
+ // Use the generated screenshot in the theme list.
+ $themes = list_themes();
+ foreach (element_children($form) as $theme) {
+ if ($screenshot = variable_get('color_' . $theme . '_screenshot')) {
+ if (isset($form[$theme]['screenshot'])) {
+ $form[$theme]['screenshot']['#markup'] = theme('image', array('path' => $screenshot, 'title' => '', 'attributes' => array('class' => array('screenshot'))));
+ }
+ }
+ }
+}
+
+/**
+ * Callback for the theme to alter the resources used.
+ */
+function _color_html_alter(&$vars) {
+ global $theme_key;
+ $themes = list_themes();
+
+ // Override stylesheets.
+ $color_paths = variable_get('color_' . $theme_key . '_stylesheets', array());
+ if (!empty($color_paths)) {
+
+ foreach ($themes[$theme_key]->stylesheets['all'] as $base_filename => $old_path) {
+ // Loop over the path array with recolored CSS files to find matching
+ // paths which could replace the non-recolored paths.
+ foreach ($color_paths as $color_path) {
+ // Color module currently requires unique file names to be used,
+ // which allows us to compare different file paths.
+ if (basename($old_path) == basename($color_path)) {
+ // Replace the path to the new css file.
+ // This keeps the order of the stylesheets intact.
+ $vars['css'][$old_path]['data'] = $color_path;
+ }
+ }
+ }
+
+ $vars['styles'] = drupal_get_css($vars['css']);
+ }
+}
+
+/**
+ * Callback for the theme to alter the resources used.
+ */
+function _color_page_alter(&$vars) {
+ global $theme_key;
+
+ // Override logo.
+ $logo = variable_get('color_' . $theme_key . '_logo');
+ if ($logo && $vars['logo'] && preg_match('!' . $theme_key . '/logo.png$!', $vars['logo'])) {
+ $vars['logo'] = file_create_url($logo);
+ }
+}
+
+/**
+ * Retrieve the color.module info for a particular theme.
+ */
+function color_get_info($theme) {
+ static $theme_info = array();
+
+ if (isset($theme_info[$theme])) {
+ return $theme_info[$theme];
+ }
+
+ $path = drupal_get_path('theme', $theme);
+ $file = DRUPAL_ROOT . '/' . $path . '/color/color.inc';
+ if ($path && file_exists($file)) {
+ include $file;
+ $theme_info[$theme] = $info;
+ return $info;
+ }
+}
+
+/**
+ * Helper function to retrieve the color palette for a particular theme.
+ */
+function color_get_palette($theme, $default = FALSE) {
+ // Fetch and expand default palette.
+ $info = color_get_info($theme);
+ $palette = $info['schemes']['default']['colors'];
+
+ // Load variable.
+ return $default ? $palette : variable_get('color_' . $theme . '_palette', $palette);
+}
+
+/**
+ * Form callback. Returns the configuration form.
+ */
+function color_scheme_form($complete_form, &$form_state, $theme) {
+ $base = drupal_get_path('module', 'color');
+ $info = color_get_info($theme);
+
+ $info['schemes'][''] = array('title' => t('Custom'), 'colors' => array());
+ $color_sets = array();
+ $schemes = array();
+ foreach ($info['schemes'] as $key => $scheme) {
+ $color_sets[$key] = $scheme['title'];
+ $schemes[$key] = $scheme['colors'];
+ $schemes[$key] += $info['schemes']['default']['colors'];
+ }
+
+ // See if we're using a predefined scheme.
+ // Note: we use the original theme when the default scheme is chosen.
+ $current_scheme = variable_get('color_' . $theme . '_palette', array());
+ foreach ($schemes as $key => $scheme) {
+ if ($current_scheme == $scheme) {
+ $scheme_name = $key;
+ break;
+ }
+ }
+ if (empty($scheme_name)) {
+ if (empty($current_scheme)) {
+ $scheme_name = 'default';
+ }
+ else {
+ $scheme_name = '';
+ }
+ }
+
+ // Add scheme selector.
+ $form['scheme'] = array(
+ '#type' => 'select',
+ '#title' => t('Color set'),
+ '#options' => $color_sets,
+ '#default_value' => $scheme_name,
+ '#attached' => array(
+ // Add Farbtastic color picker.
+ 'library' => array(
+ array('system', 'farbtastic'),
+ ),
+ // Add custom CSS.
+ 'css' => array(
+ $base . '/color.admin.css' => array(),
+ ),
+ // Add custom JavaScript.
+ 'js' => array(
+ $base . '/color.js',
+ array(
+ 'data' => array(
+ 'color' => array(
+ 'reference' => color_get_palette($theme, TRUE),
+ 'schemes' => $schemes,
+ ),
+ 'gradients' => $info['gradients'],
+ ),
+ 'type' => 'setting',
+ ),
+ ),
+ ),
+ );
+
+ // Add palette fields.
+ $palette = color_get_palette($theme);
+ $names = $info['fields'];
+ $form['palette']['#tree'] = TRUE;
+ foreach ($palette as $name => $value) {
+ if (isset($names[$name])) {
+ $form['palette'][$name] = array(
+ '#type' => 'textfield',
+ '#title' => check_plain($names[$name]),
+ '#default_value' => $value,
+ '#size' => 8,
+ );
+ }
+ }
+ $form['theme'] = array('#type' => 'value', '#value' => $theme);
+ $form['info'] = array('#type' => 'value', '#value' => $info);
+
+ return $form;
+}
+
+/**
+ * Returns HTML for a theme's color form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_color_scheme_form($variables) {
+ $form = $variables['form'];
+
+ $theme = $form['theme']['#value'];
+ $info = $form['info']['#value'];
+ $path = drupal_get_path('theme', $theme) . '/';
+ drupal_add_css($path . $info['preview_css']);
+
+ $preview_js_path = isset($info['preview_js']) ? $path . $info['preview_js'] : drupal_get_path('module', 'color') . '/' . 'preview.js';
+ // Add the JS at a weight below color.js.
+ drupal_add_js($preview_js_path, array('weight' => -1));
+
+ $output = '';
+ $output .= '<div class="color-form clearfix">';
+ // Color schemes
+ $output .= drupal_render($form['scheme']);
+ // Palette
+ $output .= '<div id="palette" class="clearfix">';
+ foreach (element_children($form['palette']) as $name) {
+ $output .= drupal_render($form['palette'][$name]);
+ }
+ $output .= '</div>';
+ // Preview
+ $output .= drupal_render_children($form);
+ $output .= '<h2>' . t('Preview') . '</h2>';
+ // Attempt to load preview HTML if the theme provides it.
+ $preview_html_path = DRUPAL_ROOT . '/' . (isset($info['preview_html']) ? drupal_get_path('theme', $theme) . '/' . $info['preview_html'] : drupal_get_path('module', 'color') . '/preview.html');
+ $output .= file_get_contents($preview_html_path);
+ // Close the wrapper div.
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Validation handler for color change form.
+ */
+function color_scheme_form_validate($form, &$form_state) {
+ // Only accept hexadecimal CSS color strings to avoid XSS upon use.
+ foreach ($form_state['values']['palette'] as $key => $color) {
+ if (!preg_match('/^#([a-f0-9]{3}){1,2}$/iD', $color)) {
+ form_set_error('palette][' . $key, t('You must enter a valid hexadecimal color value for %name.', array('%name' => $form['color']['palette'][$key]['#title'])));
+ }
+ }
+}
+
+/**
+ * Submit handler for color change form.
+ */
+function color_scheme_form_submit($form, &$form_state) {
+ // Get theme coloring info.
+ if (!isset($form_state['values']['info'])) {
+ return;
+ }
+ $theme = $form_state['values']['theme'];
+ $info = $form_state['values']['info'];
+
+ // Resolve palette.
+ $palette = $form_state['values']['palette'];
+ if ($form_state['values']['scheme'] != '') {
+ foreach ($palette as $key => $color) {
+ if (isset($info['schemes'][$form_state['values']['scheme']]['colors'][$key])) {
+ $palette[$key] = $info['schemes'][$form_state['values']['scheme']]['colors'][$key];
+ }
+ }
+ $palette += $info['schemes']['default']['colors'];
+ }
+
+ // Make sure enough memory is available, if PHP's memory limit is compiled in.
+ if (function_exists('memory_get_usage')) {
+ // Fetch source image dimensions.
+ $source = drupal_get_path('theme', $theme) . '/' . $info['base_image'];
+ list($width, $height) = getimagesize($source);
+
+ // We need at least a copy of the source and a target buffer of the same
+ // size (both at 32bpp).
+ $required = $width * $height * 8;
+ // We intend to prevent color scheme changes if there isn't enough memory
+ // available. memory_get_usage(TRUE) returns a more accurate number than
+ // memory_get_usage(), therefore we won't inadvertently reject a color
+ // scheme change based on a faulty memory calculation.
+ $usage = memory_get_usage(TRUE);
+ $limit = parse_size(ini_get('memory_limit'));
+ if ($usage + $required > $limit) {
+ drupal_set_message(t('There is not enough memory available to PHP to change this theme\'s color scheme. You need at least %size more. Check the <a href="@url">PHP documentation</a> for more information.', array('%size' => format_size($usage + $required - $limit), '@url' => 'http://www.php.net/manual/ini.core.php#ini.sect.resource-limits')), 'error');
+ return;
+ }
+ }
+
+ // Delete old files.
+ foreach (variable_get('color_' . $theme . '_files', array()) as $file) {
+ @drupal_unlink($file);
+ }
+ if (isset($file) && $file = dirname($file)) {
+ @drupal_rmdir($file);
+ }
+
+ // Don't render the default colorscheme, use the standard theme instead.
+ if (implode(',', color_get_palette($theme, TRUE)) == implode(',', $palette)) {
+ variable_del('color_' . $theme . '_palette');
+ variable_del('color_' . $theme . '_stylesheets');
+ variable_del('color_' . $theme . '_logo');
+ variable_del('color_' . $theme . '_files');
+ variable_del('color_' . $theme . '_screenshot');
+ return;
+ }
+
+ // Prepare target locations for generated files.
+ $id = $theme . '-' . substr(hash('sha256', serialize($palette) . microtime()), 0, 8);
+ $paths['color'] = 'public://color';
+ $paths['target'] = $paths['color'] . '/' . $id;
+ foreach ($paths as $path) {
+ file_prepare_directory($path, FILE_CREATE_DIRECTORY);
+ }
+ $paths['target'] = $paths['target'] . '/';
+ $paths['id'] = $id;
+ $paths['source'] = drupal_get_path('theme', $theme) . '/';
+ $paths['files'] = $paths['map'] = array();
+
+ // Save palette and logo location.
+ variable_set('color_' . $theme . '_palette', $palette);
+ variable_set('color_' . $theme . '_logo', $paths['target'] . 'logo.png');
+
+ // Copy over neutral images.
+ foreach ($info['copy'] as $file) {
+ $base = basename($file);
+ $source = $paths['source'] . $file;
+ $filepath = file_unmanaged_copy($source, $paths['target'] . $base);
+ $paths['map'][$file] = $base;
+ $paths['files'][] = $filepath;
+ }
+
+ // Render new images, if image has been provided.
+ if ($info['base_image']) {
+ _color_render_images($theme, $info, $paths, $palette);
+ }
+
+ // Rewrite theme stylesheets.
+ $css = array();
+ foreach ($info['css'] as $stylesheet) {
+ // Build a temporary array with LTR and RTL files.
+ $files = array();
+ if (file_exists($paths['source'] . $stylesheet)) {
+ $files[] = $stylesheet;
+
+ $rtl_file = str_replace('.css', '-rtl.css', $stylesheet);
+ if (file_exists($paths['source'] . $rtl_file)) {
+ $files[] = $rtl_file;
+ }
+ }
+
+ foreach ($files as $file) {
+ // Aggregate @imports recursively for each configured top level CSS file
+ // without optimization. Aggregation and optimization will be
+ // handled by drupal_build_css_cache() only.
+ $style = drupal_load_stylesheet($paths['source'] . $file, FALSE);
+
+ // Return the path to where this CSS file originated from, stripping
+ // off the name of the file at the end of the path.
+ $base = base_path() . dirname($paths['source'] . $file) . '/';
+ _drupal_build_css_path(NULL, $base);
+
+ // Prefix all paths within this CSS file, ignoring absolute paths.
+ $style = preg_replace_callback('/url\([\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\)/i', '_drupal_build_css_path', $style);
+
+ // Rewrite stylesheet with new colors.
+ $style = _color_rewrite_stylesheet($theme, $info, $paths, $palette, $style);
+ $base_file = basename($file);
+ $css[] = $paths['target'] . $base_file;
+ _color_save_stylesheet($paths['target'] . $base_file, $style, $paths);
+ }
+ }
+
+ // Maintain list of files.
+ variable_set('color_' . $theme . '_stylesheets', $css);
+ variable_set('color_' . $theme . '_files', $paths['files']);
+}
+
+/**
+ * Rewrite the stylesheet to match the colors in the palette.
+ */
+function _color_rewrite_stylesheet($theme, &$info, &$paths, $palette, $style) {
+ $themes = list_themes();
+ // Prepare color conversion table.
+ $conversion = $palette;
+ foreach ($conversion as $k => $v) {
+ $conversion[$k] = drupal_strtolower($v);
+ }
+ $default = color_get_palette($theme, TRUE);
+
+ // Split off the "Don't touch" section of the stylesheet.
+ $split = "Color Module: Don't touch";
+ if (strpos($style, $split) !== FALSE) {
+ list($style, $fixed) = explode($split, $style);
+ }
+
+ // Find all colors in the stylesheet and the chunks in between.
+ $style = preg_split('/(#[0-9a-f]{6}|#[0-9a-f]{3})/i', $style, -1, PREG_SPLIT_DELIM_CAPTURE);
+ $is_color = FALSE;
+ $output = '';
+ $base = 'base';
+
+ // Iterate over all the parts.
+ foreach ($style as $chunk) {
+ if ($is_color) {
+ $chunk = drupal_strtolower($chunk);
+ // Check if this is one of the colors in the default palette.
+ if ($key = array_search($chunk, $default)) {
+ $chunk = $conversion[$key];
+ }
+ // Not a pre-set color. Extrapolate from the base.
+ else {
+ $chunk = _color_shift($palette[$base], $default[$base], $chunk, $info['blend_target']);
+ }
+ }
+ else {
+ // Determine the most suitable base color for the next color.
+
+ // 'a' declarations. Use link.
+ if (preg_match('@[^a-z0-9_-](a)[^a-z0-9_-][^/{]*{[^{]+$@i', $chunk)) {
+ $base = 'link';
+ }
+ // 'color:' styles. Use text.
+ elseif (preg_match('/(?<!-)color[^{:]*:[^{#]*$/i', $chunk)) {
+ $base = 'text';
+ }
+ // Reset back to base.
+ else {
+ $base = 'base';
+ }
+ }
+ $output .= $chunk;
+ $is_color = !$is_color;
+ }
+ // Append fixed colors segment.
+ if (isset($fixed)) {
+ $output .= $fixed;
+ }
+
+ // Replace paths to images.
+ foreach ($paths['map'] as $before => $after) {
+ $before = base_path() . $paths['source'] . $before;
+ $before = preg_replace('`(^|/)(?!../)([^/]+)/../`', '$1', $before);
+ $output = str_replace($before, $after, $output);
+ }
+
+ return $output;
+}
+
+/**
+ * Save the rewritten stylesheet to disk.
+ */
+function _color_save_stylesheet($file, $style, &$paths) {
+ $filepath = file_unmanaged_save_data($style, $file, FILE_EXISTS_REPLACE);
+ $paths['files'][] = $filepath;
+
+ // Set standard file permissions for webserver-generated files.
+ drupal_chmod($file);
+}
+
+/**
+ * Render images that match a given palette.
+ */
+function _color_render_images($theme, &$info, &$paths, $palette) {
+ // Prepare template image.
+ $source = $paths['source'] . '/' . $info['base_image'];
+ $source = imagecreatefrompng($source);
+ $width = imagesx($source);
+ $height = imagesy($source);
+
+ // Prepare target buffer.
+ $target = imagecreatetruecolor($width, $height);
+ imagealphablending($target, TRUE);
+
+ // Fill regions of solid color.
+ foreach ($info['fill'] as $color => $fill) {
+ imagefilledrectangle($target, $fill[0], $fill[1], $fill[0] + $fill[2], $fill[1] + $fill[3], _color_gd($target, $palette[$color]));
+ }
+
+ // Render gradients.
+ foreach ($info['gradients'] as $gradient) {
+ // Get direction of the gradient.
+ if (isset($gradient['direction']) && $gradient['direction'] == 'horizontal') {
+ // Horizontal gradient.
+ for ($x = 0; $x < $gradient['dimension'][2]; $x++) {
+ $color = _color_blend($target, $palette[$gradient['colors'][0]], $palette[$gradient['colors'][1]], $x / ($gradient['dimension'][2] - 1));
+ imagefilledrectangle($target, ($gradient['dimension'][0] + $x), $gradient['dimension'][1], ($gradient['dimension'][0] + $x + 1), ($gradient['dimension'][1] + $gradient['dimension'][3]), $color);
+ }
+ }
+ else {
+ // Vertical gradient.
+ for ($y = 0; $y < $gradient['dimension'][3]; $y++) {
+ $color = _color_blend($target, $palette[$gradient['colors'][0]], $palette[$gradient['colors'][1]], $y / ($gradient['dimension'][3] - 1));
+ imagefilledrectangle($target, $gradient['dimension'][0], $gradient['dimension'][1] + $y, $gradient['dimension'][0] + $gradient['dimension'][2], $gradient['dimension'][1] + $y + 1, $color);
+ }
+ }
+ }
+
+ // Blend over template.
+ imagecopy($target, $source, 0, 0, 0, 0, $width, $height);
+
+ // Clean up template image.
+ imagedestroy($source);
+
+ // Cut out slices.
+ foreach ($info['slices'] as $file => $coord) {
+ list($x, $y, $width, $height) = $coord;
+ $base = basename($file);
+ $image = drupal_realpath($paths['target'] . $base);
+
+ // Cut out slice.
+ if ($file == 'screenshot.png') {
+ $slice = imagecreatetruecolor(150, 90);
+ imagecopyresampled($slice, $target, 0, 0, $x, $y, 150, 90, $width, $height);
+ variable_set('color_' . $theme . '_screenshot', $image);
+ }
+ else {
+ $slice = imagecreatetruecolor($width, $height);
+ imagecopy($slice, $target, 0, 0, $x, $y, $width, $height);
+ }
+
+ // Save image.
+ imagepng($slice, $image);
+ imagedestroy($slice);
+ $paths['files'][] = $image;
+
+ // Set standard file permissions for webserver-generated files
+ drupal_chmod($image);
+
+ // Build before/after map of image paths.
+ $paths['map'][$file] = $base;
+ }
+
+ // Clean up target buffer.
+ imagedestroy($target);
+}
+
+/**
+ * Shift a given color, using a reference pair and a target blend color.
+ *
+ * Note: this function is significantly different from the JS version, as it
+ * is written to match the blended images perfectly.
+ *
+ * Constraint: if (ref2 == target + (ref1 - target) * delta) for some fraction delta
+ * then (return == target + (given - target) * delta)
+ *
+ * Loose constraint: Preserve relative positions in saturation and luminance
+ * space.
+ */
+function _color_shift($given, $ref1, $ref2, $target) {
+ // We assume that ref2 is a blend of ref1 and target and find
+ // delta based on the length of the difference vectors.
+
+ // delta = 1 - |ref2 - ref1| / |white - ref1|
+ $target = _color_unpack($target, TRUE);
+ $ref1 = _color_unpack($ref1, TRUE);
+ $ref2 = _color_unpack($ref2, TRUE);
+ $numerator = 0;
+ $denominator = 0;
+ for ($i = 0; $i < 3; ++$i) {
+ $numerator += ($ref2[$i] - $ref1[$i]) * ($ref2[$i] - $ref1[$i]);
+ $denominator += ($target[$i] - $ref1[$i]) * ($target[$i] - $ref1[$i]);
+ }
+ $delta = ($denominator > 0) ? (1 - sqrt($numerator / $denominator)) : 0;
+
+ // Calculate the color that ref2 would be if the assumption was true.
+ for ($i = 0; $i < 3; ++$i) {
+ $ref3[$i] = $target[$i] + ($ref1[$i] - $target[$i]) * $delta;
+ }
+
+ // If the assumption is not true, there is a difference between ref2 and ref3.
+ // We measure this in HSL space. Notation: x' = hsl(x).
+ $ref2 = _color_rgb2hsl($ref2);
+ $ref3 = _color_rgb2hsl($ref3);
+ for ($i = 0; $i < 3; ++$i) {
+ $shift[$i] = $ref2[$i] - $ref3[$i];
+ }
+
+ // Take the given color, and blend it towards the target.
+ $given = _color_unpack($given, TRUE);
+ for ($i = 0; $i < 3; ++$i) {
+ $result[$i] = $target[$i] + ($given[$i] - $target[$i]) * $delta;
+ }
+
+ // Finally, we apply the extra shift in HSL space.
+ // Note: if ref2 is a pure blend of ref1 and target, then |shift| = 0.
+ $result = _color_rgb2hsl($result);
+ for ($i = 0; $i < 3; ++$i) {
+ $result[$i] = min(1, max(0, $result[$i] + $shift[$i]));
+ }
+ $result = _color_hsl2rgb($result);
+
+ // Return hex color.
+ return _color_pack($result, TRUE);
+}
+
+/**
+ * Convert a hex triplet into a GD color.
+ */
+function _color_gd($img, $hex) {
+ $c = array_merge(array($img), _color_unpack($hex));
+ return call_user_func_array('imagecolorallocate', $c);
+}
+
+/**
+ * Blend two hex colors and return the GD color.
+ */
+function _color_blend($img, $hex1, $hex2, $alpha) {
+ $in1 = _color_unpack($hex1);
+ $in2 = _color_unpack($hex2);
+ $out = array($img);
+ for ($i = 0; $i < 3; ++$i) {
+ $out[] = $in1[$i] + ($in2[$i] - $in1[$i]) * $alpha;
+ }
+
+ return call_user_func_array('imagecolorallocate', $out);
+}
+
+/**
+ * Convert a hex color into an RGB triplet.
+ */
+function _color_unpack($hex, $normalize = FALSE) {
+ if (strlen($hex) == 4) {
+ $hex = $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];
+ }
+ $c = hexdec($hex);
+ for ($i = 16; $i >= 0; $i -= 8) {
+ $out[] = (($c >> $i) & 0xFF) / ($normalize ? 255 : 1);
+ }
+
+ return $out;
+}
+
+/**
+ * Convert an RGB triplet to a hex color.
+ */
+function _color_pack($rgb, $normalize = FALSE) {
+ $out = 0;
+ foreach ($rgb as $k => $v) {
+ $out |= (($v * ($normalize ? 255 : 1)) << (16 - $k * 8));
+ }
+
+ return '#' . str_pad(dechex($out), 6, 0, STR_PAD_LEFT);
+}
+
+/**
+ * Convert a HSL triplet into RGB.
+ */
+function _color_hsl2rgb($hsl) {
+ $h = $hsl[0];
+ $s = $hsl[1];
+ $l = $hsl[2];
+ $m2 = ($l <= 0.5) ? $l * ($s + 1) : $l + $s - $l*$s;
+ $m1 = $l * 2 - $m2;
+
+ return array(
+ _color_hue2rgb($m1, $m2, $h + 0.33333),
+ _color_hue2rgb($m1, $m2, $h),
+ _color_hue2rgb($m1, $m2, $h - 0.33333),
+ );
+}
+
+/**
+ * Helper function for _color_hsl2rgb().
+ */
+function _color_hue2rgb($m1, $m2, $h) {
+ $h = ($h < 0) ? $h + 1 : (($h > 1) ? $h - 1 : $h);
+ if ($h * 6 < 1) return $m1 + ($m2 - $m1) * $h * 6;
+ if ($h * 2 < 1) return $m2;
+ if ($h * 3 < 2) return $m1 + ($m2 - $m1) * (0.66666 - $h) * 6;
+
+ return $m1;
+}
+
+/**
+ * Convert an RGB triplet to HSL.
+ */
+function _color_rgb2hsl($rgb) {
+ $r = $rgb[0];
+ $g = $rgb[1];
+ $b = $rgb[2];
+ $min = min($r, min($g, $b));
+ $max = max($r, max($g, $b));
+ $delta = $max - $min;
+ $l = ($min + $max) / 2;
+ $s = 0;
+
+ if ($l > 0 && $l < 1) {
+ $s = $delta / ($l < 0.5 ? (2 * $l) : (2 - 2 * $l));
+ }
+
+ $h = 0;
+ if ($delta > 0) {
+ if ($max == $r && $max != $g) $h += ($g - $b) / $delta;
+ if ($max == $g && $max != $b) $h += (2 + ($b - $r) / $delta);
+ if ($max == $b && $max != $r) $h += (4 + ($r - $g) / $delta);
+ $h /= 6;
+ }
+
+ return array($h, $s, $l);
+}
diff --git a/core/modules/color/color.test b/core/modules/color/color.test
new file mode 100644
index 000000000000..36f31baad6e1
--- /dev/null
+++ b/core/modules/color/color.test
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * @file
+ * Tests for color module.
+ */
+
+/**
+ * Test color functionality.
+ */
+class ColorTestCase extends DrupalWebTestCase {
+ protected $big_user;
+ protected $themes;
+ protected $colorTests;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Color functionality',
+ 'description' => 'Modify the Bartik theme colors and make sure the changes are reflected on the frontend.',
+ 'group' => 'Color',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('color');
+
+ // Create users.
+ $this->big_user = $this->drupalCreateUser(array('administer themes'));
+
+ // This tests the color module in Bartik.
+ $this->themes = array(
+ 'bartik' => array(
+ 'palette_input' => 'palette[bg]',
+ 'scheme' => 'slate',
+ 'scheme_color' => '#3b3b3b',
+ ),
+ );
+ theme_enable(array_keys($this->themes));
+
+ // Array filled with valid and not valid color values
+ $this->colorTests = array(
+ '#000' => TRUE,
+ '#123456' => TRUE,
+ '#abcdef' => TRUE,
+ '#0' => FALSE,
+ '#00' => FALSE,
+ '#0000' => FALSE,
+ '#00000' => FALSE,
+ '123456' => FALSE,
+ '#00000g' => FALSE,
+ );
+ }
+
+ /**
+ * Test color module functionality.
+ */
+ function testColor() {
+ foreach ($this->themes as $theme => $test_values) {
+ $this->_testColor($theme, $test_values);
+ }
+ }
+
+ /**
+ * Tests color module functionality using the given theme.
+ */
+ function _testColor($theme, $test_values) {
+ variable_set('theme_default', $theme);
+ $settings_path = 'admin/appearance/settings/' . $theme;
+
+ $this->drupalLogin($this->big_user);
+ $this->drupalGet($settings_path);
+ $this->assertResponse(200);
+ $edit['scheme'] = '';
+ $edit[$test_values['palette_input']] = '#123456';
+ $this->drupalPost($settings_path, $edit, t('Save configuration'));
+
+ $this->drupalGet('<front>');
+ $stylesheets = variable_get('color_' . $theme . '_stylesheets', array());
+ $this->assertPattern('|' . file_create_url($stylesheets[0]) . '|', 'Make sure the color stylesheet is included in the content. (' . $theme . ')');
+
+ $stylesheet_content = join("\n", file($stylesheets[0]));
+ $this->assertTrue(strpos($stylesheet_content, 'color: #123456') !== FALSE, 'Make sure the color we changed is in the color stylesheet. (' . $theme . ')');
+
+ $this->drupalGet($settings_path);
+ $this->assertResponse(200);
+ $edit['scheme'] = $test_values['scheme'];
+ $this->drupalPost($settings_path, $edit, t('Save configuration'));
+
+ $this->drupalGet('<front>');
+ $stylesheets = variable_get('color_' . $theme . '_stylesheets', array());
+ $stylesheet_content = join("\n", file($stylesheets[0]));
+ $this->assertTrue(strpos($stylesheet_content, 'color: ' . $test_values['scheme_color']) !== FALSE, 'Make sure the color we changed is in the color stylesheet. (' . $theme . ')');
+
+ // Test with aggregated CSS turned on.
+ variable_set('preprocess_css', 1);
+ $this->drupalGet('<front>');
+ $stylesheets = variable_get('drupal_css_cache_files', array());
+ $stylesheet_content = '';
+ foreach ($stylesheets as $key => $uri) {
+ $stylesheet_content .= join("\n", file(drupal_realpath($uri)));
+ }
+ $this->assertTrue(strpos($stylesheet_content, 'public://') === FALSE, 'Make sure the color paths have been translated to local paths. (' . $theme . ')');
+ variable_set('preprocess_css', 0);
+ }
+
+ /**
+ * Test to see if the provided color is valid
+ */
+ function testValidColor() {
+ variable_set('theme_default', 'bartik');
+ $settings_path = 'admin/appearance/settings/bartik';
+
+ $this->drupalLogin($this->big_user);
+ $edit['scheme'] = '';
+
+ foreach ($this->colorTests as $color => $is_valid) {
+ $edit['palette[bg]'] = $color;
+ $this->drupalPost($settings_path, $edit, t('Save configuration'));
+
+ if($is_valid) {
+ $this->assertText('The configuration options have been saved.');
+ }
+ else {
+ $this->assertText('You must enter a valid hexadecimal color value for Main background.');
+ }
+ }
+ }
+}
diff --git a/core/modules/color/images/hook-rtl.png b/core/modules/color/images/hook-rtl.png
new file mode 100644
index 000000000000..a26b211e126c
--- /dev/null
+++ b/core/modules/color/images/hook-rtl.png
Binary files differ
diff --git a/core/modules/color/images/hook.png b/core/modules/color/images/hook.png
new file mode 100644
index 000000000000..dc1897370f92
--- /dev/null
+++ b/core/modules/color/images/hook.png
Binary files differ
diff --git a/core/modules/color/images/lock.png b/core/modules/color/images/lock.png
new file mode 100644
index 000000000000..9e1e00e5efd1
--- /dev/null
+++ b/core/modules/color/images/lock.png
Binary files differ
diff --git a/core/modules/color/preview.html b/core/modules/color/preview.html
new file mode 100644
index 000000000000..e25b7add7269
--- /dev/null
+++ b/core/modules/color/preview.html
@@ -0,0 +1,7 @@
+<div id="preview">
+ <div id="text">
+ <h2>Lorem ipsum dolor</h2>
+ <p>Sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud <a href="#">exercitation ullamco</a> laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
+ </div>
+ <div id="img"></div>
+</div> \ No newline at end of file
diff --git a/core/modules/color/preview.js b/core/modules/color/preview.js
new file mode 100644
index 000000000000..88ae95fb62c6
--- /dev/null
+++ b/core/modules/color/preview.js
@@ -0,0 +1,34 @@
+
+(function ($) {
+ Drupal.color = {
+ callback: function(context, settings, form, farb, height, width) {
+ // Solid background.
+ $('#preview', form).css('backgroundColor', $('#palette input[name="palette[base]"]', form).val());
+
+ // Text preview
+ $('#text', form).css('color', $('#palette input[name="palette[text]"]', form).val());
+ $('#text a, #text h2', form).css('color', $('#palette input[name="palette[link]"]', form).val());
+
+ // Set up gradients if there are some.
+ var color_start, color_end;
+ for (i in settings.gradients) {
+ color_start = farb.unpack($('#palette input[name="palette[' + settings.gradients[i]['colors'][0] + ']"]', form).val());
+ color_end = farb.unpack($('#palette input[name="palette[' + settings.gradients[i]['colors'][1] + ']"]', form).val());
+ if (color_start && color_end) {
+ var delta = [];
+ for (j in color_start) {
+ delta[j] = (color_end[j] - color_start[j]) / (settings.gradients[i]['vertical'] ? height[i] : width[i]);
+ }
+ var accum = color_start;
+ // Render gradient lines.
+ $('#gradient-' + i + ' > div', form).each(function () {
+ for (j in accum) {
+ accum[j] += delta[j];
+ }
+ this.style.backgroundColor = farb.pack(accum);
+ });
+ }
+ }
+ }
+ };
+})(jQuery);
diff --git a/core/modules/comment/comment-node-form.js b/core/modules/comment/comment-node-form.js
new file mode 100644
index 000000000000..76db2404ea17
--- /dev/null
+++ b/core/modules/comment/comment-node-form.js
@@ -0,0 +1,32 @@
+
+(function ($) {
+
+Drupal.behaviors.commentFieldsetSummaries = {
+ attach: function (context) {
+ $('fieldset.comment-node-settings-form', context).drupalSetSummary(function (context) {
+ return Drupal.checkPlain($('.form-item-comment input:checked', context).next('label').text());
+ });
+
+ // Provide the summary for the node type form.
+ $('fieldset.comment-node-type-settings-form', context).drupalSetSummary(function(context) {
+ var vals = [];
+
+ // Default comment setting.
+ vals.push($(".form-item-comment select option:selected", context).text());
+
+ // Threading.
+ var threading = $(".form-item-comment-default-mode input:checked", context).next('label').text();
+ if (threading) {
+ vals.push(threading);
+ }
+
+ // Comments per page.
+ var number = $(".form-item-comment-default-per-page select option:selected", context).val();
+ vals.push(Drupal.t('@number comments per page', {'@number': number}));
+
+ return Drupal.checkPlain(vals.join(', '));
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/comment/comment-rtl.css b/core/modules/comment/comment-rtl.css
new file mode 100644
index 000000000000..39c3929651d6
--- /dev/null
+++ b/core/modules/comment/comment-rtl.css
@@ -0,0 +1,5 @@
+
+.indented {
+ margin-left: 0;
+ margin-right: 25px;
+}
diff --git a/core/modules/comment/comment-wrapper.tpl.php b/core/modules/comment/comment-wrapper.tpl.php
new file mode 100644
index 000000000000..d855631a7480
--- /dev/null
+++ b/core/modules/comment/comment-wrapper.tpl.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to provide an HTML container for comments.
+ *
+ * Available variables:
+ * - $content: The array of content-related elements for the node. Use
+ * render($content) to print them all, or
+ * print a subset such as render($content['comment_form']).
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default value has the following:
+ * - comment-wrapper: The current template type, i.e., "theming hook".
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * The following variables are provided for contextual information.
+ * - $node: Node object the comments are attached to.
+ * The constants below the variables show the possible values and should be
+ * used for comparison.
+ * - $display_mode
+ * - COMMENT_MODE_FLAT
+ * - COMMENT_MODE_THREADED
+ *
+ * Other variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess_comment_wrapper()
+ * @see theme_comment_wrapper()
+ */
+?>
+<section id="comments" class="<?php print $classes; ?>"<?php print $attributes; ?>>
+ <?php if ($content['comments'] && $node->type != 'forum'): ?>
+ <?php print render($title_prefix); ?>
+ <h2 class="title"><?php print t('Comments'); ?></h2>
+ <?php print render($title_suffix); ?>
+ <?php endif; ?>
+
+ <?php print render($content['comments']); ?>
+
+ <?php if ($content['comment_form']): ?>
+ <h2 class="title comment-form"><?php print t('Add new comment'); ?></h2>
+ <?php print render($content['comment_form']); ?>
+ <?php endif; ?>
+</section>
diff --git a/core/modules/comment/comment.admin.inc b/core/modules/comment/comment.admin.inc
new file mode 100644
index 000000000000..4f3d35071536
--- /dev/null
+++ b/core/modules/comment/comment.admin.inc
@@ -0,0 +1,283 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the comment module.
+ */
+
+/**
+ * Menu callback; present an administrative comment listing.
+ */
+function comment_admin($type = 'new') {
+ $edit = $_POST;
+
+ if (isset($edit['operation']) && ($edit['operation'] == 'delete') && isset($edit['comments']) && $edit['comments']) {
+ return drupal_get_form('comment_multiple_delete_confirm');
+ }
+ else {
+ return drupal_get_form('comment_admin_overview', $type);
+ }
+}
+
+/**
+ * Form builder for the comment overview administration form.
+ *
+ * @param $arg
+ * Current path's fourth component: the type of overview form ('approval' or
+ * 'new').
+ *
+ * @ingroup forms
+ * @see comment_admin_overview_validate()
+ * @see comment_admin_overview_submit()
+ * @see theme_comment_admin_overview()
+ */
+function comment_admin_overview($form, &$form_state, $arg) {
+ // Build an 'Update options' form.
+ $form['options'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Update options'),
+ '#attributes' => array('class' => array('container-inline')),
+ );
+
+ if ($arg == 'approval') {
+ $options['publish'] = t('Publish the selected comments');
+ }
+ else {
+ $options['unpublish'] = t('Unpublish the selected comments');
+ }
+ $options['delete'] = t('Delete the selected comments');
+
+ $form['options']['operation'] = array(
+ '#type' => 'select',
+ '#title' => t('Operation'),
+ '#title_display' => 'invisible',
+ '#options' => $options,
+ '#default_value' => 'publish',
+ );
+ $form['options']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Update'),
+ );
+
+ // Load the comments that need to be displayed.
+ $status = ($arg == 'approval') ? COMMENT_NOT_PUBLISHED : COMMENT_PUBLISHED;
+ $header = array(
+ 'subject' => array('data' => t('Subject'), 'field' => 'subject'),
+ 'author' => array('data' => t('Author'), 'field' => 'name'),
+ 'posted_in' => array('data' => t('Posted in'), 'field' => 'node_title'),
+ 'changed' => array('data' => t('Updated'), 'field' => 'c.changed', 'sort' => 'desc'),
+ 'operations' => array('data' => t('Operations')),
+ );
+
+ $query = db_select('comment', 'c')->extend('PagerDefault')->extend('TableSort');
+ $query->join('node', 'n', 'n.nid = c.nid');
+ $query->addField('n', 'title', 'node_title');
+ $query->addTag('node_access');
+ $result = $query
+ ->fields('c', array('cid', 'subject', 'name', 'changed'))
+ ->condition('c.status', $status)
+ ->limit(50)
+ ->orderByHeader($header)
+ ->execute();
+
+ $cids = array();
+
+ // We collect a sorted list of node_titles during the query to attach to the
+ // comments later.
+ foreach ($result as $row) {
+ $cids[] = $row->cid;
+ $node_titles[] = $row->node_title;
+ }
+ $comments = comment_load_multiple($cids);
+
+ // Build a table listing the appropriate comments.
+ $options = array();
+ $destination = drupal_get_destination();
+
+ foreach ($comments as $comment) {
+ // Remove the first node title from the node_titles array and attach to
+ // the comment.
+ $comment->node_title = array_shift($node_titles);
+ $options[$comment->cid] = array(
+ 'subject' => array(
+ 'data' => array(
+ '#type' => 'link',
+ '#title' => $comment->subject,
+ '#href' => 'comment/' . $comment->cid,
+ '#options' => array('attributes' => array('title' => truncate_utf8($comment->comment_body[LANGUAGE_NONE][0]['value'], 128)), 'fragment' => 'comment-' . $comment->cid),
+ ),
+ ),
+ 'author' => theme('username', array('account' => $comment)),
+ 'posted_in' => array(
+ 'data' => array(
+ '#type' => 'link',
+ '#title' => $comment->node_title,
+ '#href' => 'node/' . $comment->nid,
+ ),
+ ),
+ 'changed' => format_date($comment->changed, 'short'),
+ 'operations' => array(
+ 'data' => array(
+ '#type' => 'link',
+ '#title' => t('edit'),
+ '#href' => 'comment/' . $comment->cid . '/edit',
+ '#options' => array('query' => $destination),
+ ),
+ ),
+ );
+ }
+
+ $form['comments'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#empty' => t('No comments available.'),
+ );
+
+ $form['pager'] = array('#theme' => 'pager');
+
+ return $form;
+}
+
+/**
+ * Validate comment_admin_overview form submissions.
+ */
+function comment_admin_overview_validate($form, &$form_state) {
+ $form_state['values']['comments'] = array_diff($form_state['values']['comments'], array(0));
+ // We can't execute any 'Update options' if no comments were selected.
+ if (count($form_state['values']['comments']) == 0) {
+ form_set_error('', t('Select one or more comments to perform the update on.'));
+ }
+}
+
+/**
+ * Process comment_admin_overview form submissions.
+ *
+ * Execute the chosen 'Update option' on the selected comments, such as
+ * publishing, unpublishing or deleting.
+ */
+function comment_admin_overview_submit($form, &$form_state) {
+ $operation = $form_state['values']['operation'];
+ $cids = $form_state['values']['comments'];
+
+ if ($operation == 'delete') {
+ comment_delete_multiple($cids);
+ }
+ else {
+ foreach ($cids as $cid => $value) {
+ $comment = comment_load($value);
+
+ if ($operation == 'unpublish') {
+ $comment->status = COMMENT_NOT_PUBLISHED;
+ }
+ elseif ($operation == 'publish') {
+ $comment->status = COMMENT_PUBLISHED;
+ }
+ comment_save($comment);
+ }
+ }
+ drupal_set_message(t('The update has been performed.'));
+ $form_state['redirect'] = 'admin/content/comment';
+ cache_clear_all();
+}
+
+/**
+ * List the selected comments and verify that the admin wants to delete them.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @return
+ * TRUE if the comments should be deleted, FALSE otherwise.
+ * @ingroup forms
+ * @see comment_multiple_delete_confirm_submit()
+ */
+function comment_multiple_delete_confirm($form, &$form_state) {
+ $edit = $form_state['input'];
+
+ $form['comments'] = array(
+ '#prefix' => '<ul>',
+ '#suffix' => '</ul>',
+ '#tree' => TRUE,
+ );
+ // array_filter() returns only elements with actual values.
+ $comment_counter = 0;
+ foreach (array_filter($edit['comments']) as $cid => $value) {
+ $comment = comment_load($cid);
+ if (is_object($comment) && is_numeric($comment->cid)) {
+ $subject = db_query('SELECT subject FROM {comment} WHERE cid = :cid', array(':cid' => $cid))->fetchField();
+ $form['comments'][$cid] = array('#type' => 'hidden', '#value' => $cid, '#prefix' => '<li>', '#suffix' => check_plain($subject) . '</li>');
+ $comment_counter++;
+ }
+ }
+ $form['operation'] = array('#type' => 'hidden', '#value' => 'delete');
+
+ if (!$comment_counter) {
+ drupal_set_message(t('There do not appear to be any comments to delete, or your selected comment was deleted by another administrator.'));
+ drupal_goto('admin/content/comment');
+ }
+ else {
+ return confirm_form($form,
+ t('Are you sure you want to delete these comments and all their children?'),
+ 'admin/content/comment', t('This action cannot be undone.'),
+ t('Delete comments'), t('Cancel'));
+ }
+}
+
+/**
+ * Process comment_multiple_delete_confirm form submissions.
+ */
+function comment_multiple_delete_confirm_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ comment_delete_multiple(array_keys($form_state['values']['comments']));
+ cache_clear_all();
+ $count = count($form_state['values']['comments']);
+ watchdog('content', 'Deleted @count comments.', array('@count' => $count));
+ drupal_set_message(format_plural($count, 'Deleted 1 comment.', 'Deleted @count comments.'));
+ }
+ $form_state['redirect'] = 'admin/content/comment';
+}
+
+/**
+ * Page callback for comment deletions.
+ */
+function comment_confirm_delete_page($cid) {
+ if ($comment = comment_load($cid)) {
+ return drupal_get_form('comment_confirm_delete', $comment);
+ }
+ return MENU_NOT_FOUND;
+}
+
+/**
+ * Form builder; Builds the confirmation form for deleting a single comment.
+ *
+ * @ingroup forms
+ * @see comment_confirm_delete_submit()
+ */
+function comment_confirm_delete($form, &$form_state, $comment) {
+ $form['#comment'] = $comment;
+ // Always provide entity id in the same form key as in the entity edit form.
+ $form['cid'] = array('#type' => 'value', '#value' => $comment->cid);
+ return confirm_form(
+ $form,
+ t('Are you sure you want to delete the comment %title?', array('%title' => $comment->subject)),
+ 'node/' . $comment->nid,
+ t('Any replies to this comment will be lost. This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel'),
+ 'comment_confirm_delete');
+}
+
+/**
+ * Process comment_confirm_delete form submissions.
+ */
+function comment_confirm_delete_submit($form, &$form_state) {
+ $comment = $form['#comment'];
+ // Delete the comment and its replies.
+ comment_delete($comment->cid);
+ drupal_set_message(t('The comment and all its replies have been deleted.'));
+ watchdog('content', 'Deleted comment @cid and its replies.', array('@cid' => $comment->cid));
+ // Clear the cache so an anonymous user sees that his comment was deleted.
+ cache_clear_all();
+
+ $form_state['redirect'] = "node/$comment->nid";
+}
diff --git a/core/modules/comment/comment.api.php b/core/modules/comment/comment.api.php
new file mode 100644
index 000000000000..05912655b1e6
--- /dev/null
+++ b/core/modules/comment/comment.api.php
@@ -0,0 +1,145 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Comment module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * The comment passed validation and is about to be saved.
+ *
+ * Modules may make changes to the comment before it is saved to the database.
+ *
+ * @param $comment
+ * The comment object.
+ */
+function hook_comment_presave($comment) {
+ // Remove leading & trailing spaces from the comment subject.
+ $comment->subject = trim($comment->subject);
+}
+
+/**
+ * The comment is being inserted.
+ *
+ * @param $comment
+ * The comment object.
+ */
+function hook_comment_insert($comment) {
+ // Reindex the node when comments are added.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * The comment is being updated.
+ *
+ * @param $comment
+ * The comment object.
+ */
+function hook_comment_update($comment) {
+ // Reindex the node when comments are updated.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Comments are being loaded from the database.
+ *
+ * @param $comments
+ * An array of comment objects indexed by cid.
+ */
+function hook_comment_load($comments) {
+ $result = db_query('SELECT cid, foo FROM {mytable} WHERE cid IN (:cids)', array(':cids' => array_keys($comments)));
+ foreach ($result as $record) {
+ $comments[$record->cid]->foo = $record->foo;
+ }
+}
+
+/**
+ * The comment is being viewed. This hook can be used to add additional data to the comment before theming.
+ *
+ * @param $comment
+ * Passes in the comment the action is being performed on.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * The language code used for rendering.
+ *
+ * @see hook_entity_view()
+ */
+function hook_comment_view($comment, $view_mode, $langcode) {
+ // how old is the comment
+ $comment->time_ago = time() - $comment->changed;
+}
+
+/**
+ * The comment was built; the module may modify the structured content.
+ *
+ * This hook is called after the content has been assembled in a structured array
+ * and may be used for doing processing which requires that the complete comment
+ * content structure has been built.
+ *
+ * If the module wishes to act on the rendered HTML of the comment rather than the
+ * structured content array, it may use this hook to add a #post_render callback.
+ * Alternatively, it could also implement hook_preprocess_comment(). See
+ * drupal_render() and theme() documentation respectively for details.
+ *
+ * @param $build
+ * A renderable array representing the comment.
+ *
+ * @see comment_view()
+ * @see hook_entity_view_alter()
+ */
+function hook_comment_view_alter(&$build) {
+ // Check for the existence of a field added by another module.
+ if ($build['#view_mode'] == 'full' && isset($build['an_additional_field'])) {
+ // Change its weight.
+ $build['an_additional_field']['#weight'] = -10;
+ }
+
+ // Add a #post_render callback to act on the rendered HTML of the comment.
+ $build['#post_render'][] = 'my_module_comment_post_render';
+}
+
+/**
+ * The comment is being published by the moderator.
+ *
+ * @param $comment
+ * Passes in the comment the action is being performed on.
+ * @return
+ * Nothing.
+ */
+function hook_comment_publish($comment) {
+ drupal_set_message(t('Comment: @subject has been published', array('@subject' => $comment->subject)));
+}
+
+/**
+ * The comment is being unpublished by the moderator.
+ *
+ * @param $comment
+ * Passes in the comment the action is being performed on.
+ * @return
+ * Nothing.
+ */
+function hook_comment_unpublish($comment) {
+ drupal_set_message(t('Comment: @subject has been unpublished', array('@subject' => $comment->subject)));
+}
+
+/**
+ * The comment is being deleted by the moderator.
+ *
+ * @param $comment
+ * Passes in the comment the action is being performed on.
+ * @return
+ * Nothing.
+ */
+function hook_comment_delete($comment) {
+ drupal_set_message(t('Comment: @subject has been deleted', array('@subject' => $comment->subject)));
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/comment/comment.css b/core/modules/comment/comment.css
new file mode 100644
index 000000000000..a55f527c8ad3
--- /dev/null
+++ b/core/modules/comment/comment.css
@@ -0,0 +1,13 @@
+
+#comments {
+ margin-top: 15px;
+}
+.indented {
+ margin-left: 25px; /* LTR */
+}
+.comment-unpublished {
+ background-color: #fff4f4;
+}
+.comment-preview {
+ background-color: #ffffea;
+}
diff --git a/core/modules/comment/comment.info b/core/modules/comment/comment.info
new file mode 100644
index 000000000000..949ffc2a830b
--- /dev/null
+++ b/core/modules/comment/comment.info
@@ -0,0 +1,11 @@
+name = Comment
+description = Allows users to comment on and discuss published content.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = text
+dependencies[] = entity
+files[] = comment.module
+files[] = comment.test
+configure = admin/content/comment
+stylesheets[all][] = comment.css
diff --git a/core/modules/comment/comment.install b/core/modules/comment/comment.install
new file mode 100644
index 000000000000..021380816917
--- /dev/null
+++ b/core/modules/comment/comment.install
@@ -0,0 +1,261 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the comment module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function comment_uninstall() {
+ // Delete comment_body field.
+ field_delete_field('comment_body');
+
+ // Remove variables.
+ variable_del('comment_block_count');
+ $node_types = array_keys(node_type_get_types());
+ foreach ($node_types as $node_type) {
+ field_attach_delete_bundle('comment', 'comment_node_' . $node_type);
+ variable_del('comment_' . $node_type);
+ variable_del('comment_anonymous_' . $node_type);
+ variable_del('comment_controls_' . $node_type);
+ variable_del('comment_default_mode_' . $node_type);
+ variable_del('comment_default_order_' . $node_type);
+ variable_del('comment_default_per_page_' . $node_type);
+ variable_del('comment_form_location_' . $node_type);
+ variable_del('comment_preview_' . $node_type);
+ variable_del('comment_subject_field_' . $node_type);
+ }
+}
+
+/**
+ * Implements hook_enable().
+ */
+function comment_enable() {
+ // Insert records into the node_comment_statistics for nodes that are missing.
+ $query = db_select('node', 'n');
+ $query->leftJoin('node_comment_statistics', 'ncs', 'ncs.nid = n.nid');
+ $query->addField('n', 'created', 'last_comment_timestamp');
+ $query->addField('n', 'uid', 'last_comment_uid');
+ $query->addField('n', 'nid');
+ $query->addExpression('0', 'comment_count');
+ $query->addExpression('NULL', 'last_comment_name');
+ $query->isNull('ncs.comment_count');
+
+ db_insert('node_comment_statistics')
+ ->from($query)
+ ->execute();
+}
+
+/**
+ * Implements hook_modules_enabled().
+ *
+ * Creates comment body fields for node types existing before the comment module
+ * is enabled. We use hook_modules_enabled() rather than hook_enable() so we can
+ * react to node types of existing modules, and those of modules being enabled
+ * both before and after comment module in the loop of module_enable().
+ *
+ * There is a separate comment bundle for each node type to allow for
+ * per-node-type customization of comment fields. Each one of these bundles
+ * needs a comment body field instance. A comment bundle is needed even for
+ * node types whose comments are disabled by default, because individual nodes
+ * may override that default.
+ *
+ * @see comment_node_type_insert()
+ */
+function comment_modules_enabled($modules) {
+ // Only react if comment module is one of the modules being enabled.
+ // hook_node_type_insert() is used to create body fields while the comment
+ // module is enabled.
+ if (in_array('comment', $modules)) {
+ // Ensure that the list of node types reflects newly enabled modules.
+ node_types_rebuild();
+
+ // Create comment body fields for each node type, if needed.
+ foreach (node_type_get_types() as $type => $info) {
+ _comment_body_field_create($info);
+ }
+ }
+}
+
+/**
+ * Implements hook_schema().
+ */
+function comment_schema() {
+ $schema['comment'] = array(
+ 'description' => 'Stores comments and associated data.',
+ 'fields' => array(
+ 'cid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique comment ID.',
+ ),
+ 'pid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {comment}.cid to which this comment is a reply. If set to 0, this comment is not a reply to an existing comment.',
+ ),
+ 'nid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {node}.nid to which this comment is a reply.',
+ ),
+ 'uid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {users}.uid who authored the comment. If set to 0, this comment was created by an anonymous user.',
+ ),
+ 'subject' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The comment title.',
+ ),
+ 'hostname' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "The author's host name.",
+ ),
+ 'created' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The time that the comment was created, as a Unix timestamp.',
+ ),
+ 'changed' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The time that the comment was last edited, as a Unix timestamp.',
+ ),
+ 'status' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 1,
+ 'size' => 'tiny',
+ 'description' => 'The published status of a comment. (0 = Not Published, 1 = Published)',
+ ),
+ 'thread' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'description' => "The alphadecimal representation of the comment's place in a thread, consisting of a base 36 string prefixed by an integer indicating its length.",
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 60,
+ 'not null' => FALSE,
+ 'description' => "The comment author's name. Uses {users}.name if the user is logged in, otherwise uses the value typed into the comment form.",
+ ),
+ 'mail' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => FALSE,
+ 'description' => "The comment author's e-mail address from the comment form, if user is anonymous, and the 'Anonymous users may/must leave their contact information' setting is turned on.",
+ ),
+ 'homepage' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => "The comment author's home page address from the comment form, if user is anonymous, and the 'Anonymous users may/must leave their contact information' setting is turned on.",
+ ),
+ 'language' => array(
+ 'description' => 'The {languages}.language of this comment.',
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'indexes' => array(
+ 'comment_status_pid' => array('pid', 'status'),
+ 'comment_num_new' => array('nid', 'status', 'created', 'cid', 'thread'),
+ 'comment_uid' => array('uid'),
+ 'comment_nid_language' => array('nid', 'language'),
+ 'comment_created' => array('created'),
+ ),
+ 'primary key' => array('cid'),
+ 'foreign keys' => array(
+ 'comment_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'comment_author' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['node_comment_statistics'] = array(
+ 'description' => 'Maintains statistics of node and comments posts to show "new" and "updated" flags.',
+ 'fields' => array(
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {node}.nid for which the statistics are compiled.',
+ ),
+ 'cid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {comment}.cid of the last comment.',
+ ),
+ 'last_comment_timestamp' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The Unix timestamp of the last comment that was posted within this node, from {comment}.changed.',
+ ),
+ 'last_comment_name' => array(
+ 'type' => 'varchar',
+ 'length' => 60,
+ 'not null' => FALSE,
+ 'description' => 'The name of the latest author to post a comment on this node, from {comment}.name.',
+ ),
+ 'last_comment_uid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The user ID of the latest author to post a comment on this node, from {comment}.uid.',
+ ),
+ 'comment_count' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The total number of comments on this node.',
+ ),
+ ),
+ 'primary key' => array('nid'),
+ 'indexes' => array(
+ 'node_comment_timestamp' => array('last_comment_timestamp'),
+ 'comment_count' => array('comment_count'),
+ 'last_comment_uid' => array('last_comment_uid'),
+ ),
+ 'foreign keys' => array(
+ 'statistics_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'last_comment_author' => array(
+ 'table' => 'users',
+ 'columns' => array(
+ 'last_comment_uid' => 'uid',
+ ),
+ ),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/comment/comment.module b/core/modules/comment/comment.module
new file mode 100644
index 000000000000..ae9278ce98f7
--- /dev/null
+++ b/core/modules/comment/comment.module
@@ -0,0 +1,2719 @@
+<?php
+
+/**
+ * @file
+ * Enables users to comment on published content.
+ *
+ * When enabled, the Drupal comment module creates a discussion
+ * board for each Drupal node. Users can post comments to discuss
+ * a forum topic, story, collaborative book page, etc.
+ */
+
+/**
+ * Comment is awaiting approval.
+ */
+define('COMMENT_NOT_PUBLISHED', 0);
+
+/**
+ * Comment is published.
+ */
+define('COMMENT_PUBLISHED', 1);
+
+/**
+ * Comments are displayed in a flat list - expanded.
+ */
+define('COMMENT_MODE_FLAT', 0);
+
+/**
+ * Comments are displayed as a threaded list - expanded.
+ */
+define('COMMENT_MODE_THREADED', 1);
+
+/**
+ * Anonymous posters cannot enter their contact information.
+ */
+define('COMMENT_ANONYMOUS_MAYNOT_CONTACT', 0);
+
+/**
+ * Anonymous posters may leave their contact information.
+ */
+define('COMMENT_ANONYMOUS_MAY_CONTACT', 1);
+
+/**
+ * Anonymous posters are required to leave their contact information.
+ */
+define('COMMENT_ANONYMOUS_MUST_CONTACT', 2);
+
+/**
+ * Comment form should be displayed on a separate page.
+ */
+define('COMMENT_FORM_SEPARATE_PAGE', 0);
+
+/**
+ * Comment form should be shown below post or list of comments.
+ */
+define('COMMENT_FORM_BELOW', 1);
+
+/**
+ * Comments for this node are hidden.
+ */
+define('COMMENT_NODE_HIDDEN', 0);
+
+/**
+ * Comments for this node are closed.
+ */
+define('COMMENT_NODE_CLOSED', 1);
+
+/**
+ * Comments for this node are open.
+ */
+define('COMMENT_NODE_OPEN', 2);
+
+/**
+ * Implements hook_help().
+ */
+function comment_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#comment':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Comment module allows users to comment on site content, set commenting defaults and permissions, and moderate comments. For more information, see the online handbook entry for <a href="@comment">Comment module</a>.', array('@comment' => 'http://drupal.org/handbook/modules/comment/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Default and custom settings') . '</dt>';
+ $output .= '<dd>' . t("Each <a href='@content-type'>content type</a> can have its own default comment settings configured as: <em>Open</em> to allow new comments, <em>Hidden</em> to hide existing comments and prevent new comments, or <em>Closed</em> to view existing comments, but prevent new comments. These defaults will apply to all new content created (changes to the settings on existing content must be done manually). Other comment settings can also be customized per content type, and can be overridden for any given item of content. When a comment has no replies, it remains editable by its author, as long as the author has a user account and is logged in.", array('@content-type' => url('admin/structure/types'))) . '</dd>';
+ $output .= '<dt>' . t('Comment approval') . '</dt>';
+ $output .= '<dd>' . t("Comments from users who have the <em>Skip comment approval</em> permission are published immediately. All other comments are placed in the <a href='@comment-approval'>Unapproved comments</a> queue, until a user who has permission to <em>Administer comments</em> publishes or deletes them. Published comments can be bulk managed on the <a href='@admin-comment'>Published comments</a> administration page.", array('@comment-approval' => url('admin/content/comment/approval'), '@admin-comment' => url('admin/content/comment'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function comment_entity_info() {
+ $return = array(
+ 'comment' => array(
+ 'label' => t('Comment'),
+ 'base table' => 'comment',
+ 'uri callback' => 'comment_uri',
+ 'fieldable' => TRUE,
+ 'controller class' => 'CommentController',
+ 'entity keys' => array(
+ 'id' => 'cid',
+ 'bundle' => 'node_type',
+ 'label' => 'subject',
+ ),
+ 'bundles' => array(),
+ 'view modes' => array(
+ 'full' => array(
+ 'label' => t('Full comment'),
+ 'custom settings' => FALSE,
+ ),
+ ),
+ 'static cache' => FALSE,
+ ),
+ );
+
+ foreach (node_type_get_names() as $type => $name) {
+ $return['comment']['bundles']['comment_node_' . $type] = array(
+ 'label' => t('@node_type comment', array('@node_type' => $name)),
+ // Provide the node type/bundle name for other modules, so it does not
+ // have to be extracted manually from the bundle name.
+ 'node bundle' => $type,
+ 'admin' => array(
+ // Place the Field UI paths for comments one level below the
+ // corresponding paths for nodes, so that they appear in the same set
+ // of local tasks. Note that the paths use a different placeholder name
+ // and thus a different menu loader callback, so that Field UI page
+ // callbacks get a comment bundle name from the node type in the URL.
+ // See comment_node_type_load() and comment_menu_alter().
+ 'path' => 'admin/structure/types/manage/%comment_node_type/comment',
+ 'bundle argument' => 4,
+ 'real path' => 'admin/structure/types/manage/' . str_replace('_', '-', $type) . '/comment',
+ 'access arguments' => array('administer content types'),
+ ),
+ );
+ }
+
+ return $return;
+}
+
+/**
+ * Menu loader callback for Field UI paths.
+ *
+ * Return a comment bundle name from a node type in the URL.
+ */
+function comment_node_type_load($name) {
+ if ($type = node_type_get_type(strtr($name, array('-' => '_')))) {
+ return 'comment_node_' . $type->type;
+ }
+}
+
+/**
+ * Entity uri callback.
+ */
+function comment_uri($comment) {
+ return array(
+ 'path' => 'comment/' . $comment->cid,
+ 'options' => array('fragment' => 'comment-' . $comment->cid),
+ );
+}
+
+/**
+ * Implements hook_field_extra_fields().
+ */
+function comment_field_extra_fields() {
+ $return = array();
+
+ foreach (node_type_get_types() as $type) {
+ if (variable_get('comment_subject_field_' . $type->type, 1) == 1) {
+ $return['comment']['comment_node_' . $type->type] = array(
+ 'form' => array(
+ 'author' => array(
+ 'label' => t('Author'),
+ 'description' => t('Author textfield'),
+ 'weight' => -2,
+ ),
+ 'subject' => array(
+ 'label' => t('Subject'),
+ 'description' => t('Subject textfield'),
+ 'weight' => -1,
+ ),
+ ),
+ );
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function comment_theme() {
+ return array(
+ 'comment_block' => array(
+ 'variables' => array(),
+ ),
+ 'comment_preview' => array(
+ 'variables' => array('comment' => NULL),
+ ),
+ 'comment' => array(
+ 'template' => 'comment',
+ 'render element' => 'elements',
+ ),
+ 'comment_post_forbidden' => array(
+ 'variables' => array('node' => NULL),
+ ),
+ 'comment_wrapper' => array(
+ 'template' => 'comment-wrapper',
+ 'render element' => 'content',
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function comment_menu() {
+ $items['admin/content/comment'] = array(
+ 'title' => 'Comments',
+ 'description' => 'List and edit site comments and the comment approval queue.',
+ 'page callback' => 'comment_admin',
+ 'access arguments' => array('administer comments'),
+ 'type' => MENU_LOCAL_TASK | MENU_NORMAL_ITEM,
+ 'file' => 'comment.admin.inc',
+ );
+ // Tabs begin here.
+ $items['admin/content/comment/new'] = array(
+ 'title' => 'Published comments',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/content/comment/approval'] = array(
+ 'title' => 'Unapproved comments',
+ 'title callback' => 'comment_count_unpublished',
+ 'page arguments' => array('approval'),
+ 'access arguments' => array('administer comments'),
+ 'type' => MENU_LOCAL_TASK,
+ );
+ $items['comment/%'] = array(
+ 'title' => 'Comment permalink',
+ 'page callback' => 'comment_permalink',
+ 'page arguments' => array(1),
+ 'access arguments' => array('access comments'),
+ );
+ $items['comment/%/view'] = array(
+ 'title' => 'View comment',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ // Every other comment path uses %, but this one loads the comment directly,
+ // so we don't end up loading it twice (in the page and access callback).
+ $items['comment/%comment/edit'] = array(
+ 'title' => 'Edit',
+ 'page callback' => 'comment_edit_page',
+ 'page arguments' => array(1),
+ 'access callback' => 'comment_access',
+ 'access arguments' => array('edit', 1),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 0,
+ );
+ $items['comment/%/approve'] = array(
+ 'title' => 'Approve',
+ 'page callback' => 'comment_approve',
+ 'page arguments' => array(1),
+ 'access arguments' => array('administer comments'),
+ 'file' => 'comment.pages.inc',
+ 'weight' => 1,
+ );
+ $items['comment/%/delete'] = array(
+ 'title' => 'Delete',
+ 'page callback' => 'comment_confirm_delete_page',
+ 'page arguments' => array(1),
+ 'access arguments' => array('administer comments'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'comment.admin.inc',
+ 'weight' => 2,
+ );
+ $items['comment/reply/%node'] = array(
+ 'title' => 'Add new comment',
+ 'page callback' => 'comment_reply',
+ 'page arguments' => array(2),
+ 'access callback' => 'node_access',
+ 'access arguments' => array('view', 2),
+ 'file' => 'comment.pages.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_menu_alter().
+ */
+function comment_menu_alter(&$items) {
+ // Add comments to the description for admin/content.
+ $items['admin/content']['description'] = 'Administer content and comments.';
+
+ // Adjust the Field UI tabs on admin/structure/types/manage/[node-type].
+ // See comment_entity_info().
+ $items['admin/structure/types/manage/%comment_node_type/comment/fields']['title'] = 'Comment fields';
+ $items['admin/structure/types/manage/%comment_node_type/comment/fields']['weight'] = 3;
+ $items['admin/structure/types/manage/%comment_node_type/comment/display']['title'] = 'Comment display';
+ $items['admin/structure/types/manage/%comment_node_type/comment/display']['weight'] = 4;
+}
+
+/**
+ * Returns a menu title which includes the number of unapproved comments.
+ */
+function comment_count_unpublished() {
+ $count = db_query('SELECT COUNT(cid) FROM {comment} WHERE status = :status', array(
+ ':status' => COMMENT_NOT_PUBLISHED,
+ ))->fetchField();
+ return t('Unapproved comments (@count)', array('@count' => $count));
+}
+
+/**
+ * Implements hook_node_type_insert().
+ *
+ * Creates a comment body field for a node type created while the comment module
+ * is enabled. For node types created before the comment module is enabled,
+ * hook_modules_enabled() serves to create the body fields.
+ *
+ * @see comment_modules_enabled()
+ */
+function comment_node_type_insert($info) {
+ _comment_body_field_create($info);
+}
+
+/**
+ * Implements hook_node_type_update().
+ */
+function comment_node_type_update($info) {
+ if (!empty($info->old_type) && $info->type != $info->old_type) {
+ field_attach_rename_bundle('comment', 'comment_node_' . $info->old_type, 'comment_node_' . $info->type);
+ }
+}
+
+/**
+ * Implements hook_node_type_delete().
+ */
+function comment_node_type_delete($info) {
+ field_attach_delete_bundle('comment', 'comment_node_' . $info->type);
+ $settings = array(
+ 'comment',
+ 'comment_default_mode',
+ 'comment_default_per_page',
+ 'comment_anonymous',
+ 'comment_subject_field',
+ 'comment_preview',
+ 'comment_form_location',
+ );
+ foreach ($settings as $setting) {
+ variable_del($setting . '_' . $info->type);
+ }
+}
+
+ /**
+ * Creates a comment_body field instance for a given node type.
+ */
+function _comment_body_field_create($info) {
+ // Create the field if needed.
+ if (!field_read_field('comment_body', array('include_inactive' => TRUE))) {
+ $field = array(
+ 'field_name' => 'comment_body',
+ 'type' => 'text_long',
+ 'entity_types' => array('comment'),
+ );
+ field_create_field($field);
+ }
+ // Create the instance if needed.
+ if (!field_read_instance('comment', 'comment_body', 'comment_node_' . $info->type, array('include_inactive' => TRUE))) {
+ field_attach_create_bundle('comment', 'comment_node_' . $info->type);
+ // Attaches the body field by default.
+ $instance = array(
+ 'field_name' => 'comment_body',
+ 'label' => 'Comment',
+ 'entity_type' => 'comment',
+ 'bundle' => 'comment_node_' . $info->type,
+ 'settings' => array('text_processing' => 1),
+ 'required' => TRUE,
+ 'display' => array(
+ 'default' => array(
+ 'label' => 'hidden',
+ 'type' => 'text_default',
+ 'weight' => 0,
+ ),
+ ),
+ );
+ field_create_instance($instance);
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function comment_permission() {
+ return array(
+ 'administer comments' => array(
+ 'title' => t('Administer comments and comment settings'),
+ ),
+ 'access comments' => array(
+ 'title' => t('View comments'),
+ ),
+ 'post comments' => array(
+ 'title' => t('Post comments'),
+ ),
+ 'skip comment approval' => array(
+ 'title' => t('Skip comment approval'),
+ ),
+ 'edit own comments' => array(
+ 'title' => t('Edit own comments'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function comment_block_info() {
+ $blocks['recent']['info'] = t('Recent comments');
+ $blocks['recent']['properties']['administrative'] = TRUE;
+
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function comment_block_configure($delta = '') {
+ $form['comment_block_count'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of recent comments'),
+ '#default_value' => variable_get('comment_block_count', 10),
+ '#options' => drupal_map_assoc(array(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30)),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function comment_block_save($delta = '', $edit = array()) {
+ variable_set('comment_block_count', (int) $edit['comment_block_count']);
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generates a block with the most recent comments.
+ */
+function comment_block_view($delta = '') {
+ if (user_access('access comments')) {
+ $block['subject'] = t('Recent comments');
+ $block['content'] = theme('comment_block');
+
+ return $block;
+ }
+}
+
+/**
+ * Redirects comment links to the correct page depending on comment settings.
+ *
+ * Since comments are paged there is no way to guarantee which page a comment
+ * appears on. Comment paging and threading settings may be changed at any time.
+ * With threaded comments, an individual comment may move between pages as
+ * comments can be added either before or after it in the overall discussion.
+ * Therefore we use a central routing function for comment links, which
+ * calculates the page number based on current comment settings and returns
+ * the full comment view with the pager set dynamically.
+ *
+ * @param $cid
+ * A comment identifier.
+ * @return
+ * The comment listing set to the page on which the comment appears.
+ */
+function comment_permalink($cid) {
+ if (($comment = comment_load($cid)) && ($node = node_load($comment->nid))) {
+
+ // Find the current display page for this comment.
+ $page = comment_get_display_page($comment->cid, $node->type);
+
+ // Set $_GET['q'] and $_GET['page'] ourselves so that the node callback
+ // behaves as it would when visiting the page directly.
+ $_GET['q'] = 'node/' . $node->nid;
+ $_GET['page'] = $page;
+
+ // Return the node view, this will show the correct comment in context.
+ return menu_execute_active_handler('node/' . $node->nid, FALSE);
+ }
+ drupal_not_found();
+}
+
+/**
+ * Find the most recent comments that are available to the current user.
+ *
+ * @param integer $number
+ * (optional) The maximum number of comments to find. Defaults to 10.
+ *
+ * @return
+ * An array of comment objects or an empty array if there are no recent
+ * comments visible to the current user.
+ */
+function comment_get_recent($number = 10) {
+ $query = db_select('comment', 'c');
+ $query->innerJoin('node', 'n', 'n.nid = c.nid');
+ $query->addTag('node_access');
+ $comments = $query
+ ->fields('c')
+ ->condition('c.status', COMMENT_PUBLISHED)
+ ->condition('n.status', NODE_PUBLISHED)
+ ->orderBy('c.created', 'DESC')
+ // Additionally order by cid to ensure that comments with the same timestamp
+ // are returned in the exact order posted.
+ ->orderBy('c.cid', 'DESC')
+ ->range(0, $number)
+ ->execute()
+ ->fetchAll();
+
+ return $comments ? $comments : array();
+}
+
+/**
+ * Calculate page number for first new comment.
+ *
+ * @param $num_comments
+ * Number of comments.
+ * @param $new_replies
+ * Number of new replies.
+ * @param $node
+ * The first new comment node.
+ * @return
+ * "page=X" if the page number is greater than zero; empty string otherwise.
+ */
+function comment_new_page_count($num_comments, $new_replies, $node) {
+ $mode = variable_get('comment_default_mode_' . $node->type, COMMENT_MODE_THREADED);
+ $comments_per_page = variable_get('comment_default_per_page_' . $node->type, 50);
+ $pagenum = NULL;
+ $flat = $mode == COMMENT_MODE_FLAT ? TRUE : FALSE;
+ if ($num_comments <= $comments_per_page) {
+ // Only one page of comments.
+ $pageno = 0;
+ }
+ elseif ($flat) {
+ // Flat comments.
+ $count = $num_comments - $new_replies;
+ $pageno = $count / $comments_per_page;
+ }
+ else {
+ // Threaded comments: we build a query with a subquery to find the first
+ // thread with a new comment.
+
+ // 1. Find all the threads with a new comment.
+ $unread_threads_query = db_select('comment')
+ ->fields('comment', array('thread'))
+ ->condition('nid', $node->nid)
+ ->condition('status', COMMENT_PUBLISHED)
+ ->orderBy('created', 'DESC')
+ ->orderBy('cid', 'DESC')
+ ->range(0, $new_replies);
+
+ // 2. Find the first thread.
+ $first_thread = db_select($unread_threads_query, 'thread')
+ ->fields('thread', array('thread'))
+ ->orderBy('SUBSTRING(thread, 1, (LENGTH(thread) - 1))')
+ ->range(0, 1)
+ ->execute()
+ ->fetchField();
+
+ // Remove the final '/'.
+ $first_thread = substr($first_thread, 0, -1);
+
+ // Find the number of the first comment of the first unread thread.
+ $count = db_query('SELECT COUNT(*) FROM {comment} WHERE nid = :nid AND status = :status AND SUBSTRING(thread, 1, (LENGTH(thread) - 1)) < :thread', array(
+ ':status' => COMMENT_PUBLISHED,
+ ':nid' => $node->nid,
+ ':thread' => $first_thread,
+ ))->fetchField();
+
+ $pageno = $count / $comments_per_page;
+ }
+
+ if ($pageno >= 1) {
+ $pagenum = array('page' => intval($pageno));
+ }
+
+ return $pagenum;
+}
+
+/**
+ * Returns HTML for a list of recent comments to be displayed in the comment block.
+ *
+ * @ingroup themeable
+ */
+function theme_comment_block() {
+ $items = array();
+ $number = variable_get('comment_block_count', 10);
+ foreach (comment_get_recent($number) as $comment) {
+ $items[] = l($comment->subject, 'comment/' . $comment->cid, array('fragment' => 'comment-' . $comment->cid)) . '&nbsp;<span>' . t('@time ago', array('@time' => format_interval(REQUEST_TIME - $comment->changed))) . '</span>';
+ }
+
+ if ($items) {
+ return theme('item_list', array('items' => $items));
+ }
+ else {
+ return t('No comments available.');
+ }
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function comment_node_view($node, $view_mode) {
+ $links = array();
+
+ if ($node->comment != COMMENT_NODE_HIDDEN) {
+ if ($view_mode == 'rss') {
+ // Add a comments RSS element which is a URL to the comments of this node.
+ $node->rss_elements[] = array(
+ 'key' => 'comments',
+ 'value' => url('node/' . $node->nid, array('fragment' => 'comments', 'absolute' => TRUE))
+ );
+ }
+ elseif ($view_mode == 'teaser') {
+ // Teaser view: display the number of comments that have been posted,
+ // or a link to add new comments if the user has permission, the node
+ // is open to new comments, and there currently are none.
+ if (user_access('access comments')) {
+ if (!empty($node->comment_count)) {
+ $links['comment-comments'] = array(
+ 'title' => format_plural($node->comment_count, '1 comment', '@count comments'),
+ 'href' => "node/$node->nid",
+ 'attributes' => array('title' => t('Jump to the first comment of this posting.')),
+ 'fragment' => 'comments',
+ 'html' => TRUE,
+ );
+ // Show a link to the first new comment.
+ if ($new = comment_num_new($node->nid)) {
+ $links['comment-new-comments'] = array(
+ 'title' => format_plural($new, '1 new comment', '@count new comments'),
+ 'href' => "node/$node->nid",
+ 'query' => comment_new_page_count($node->comment_count, $new, $node),
+ 'attributes' => array('title' => t('Jump to the first new comment of this posting.')),
+ 'fragment' => 'new',
+ 'html' => TRUE,
+ );
+ }
+ }
+ }
+ if ($node->comment == COMMENT_NODE_OPEN) {
+ if (user_access('post comments')) {
+ $links['comment-add'] = array(
+ 'title' => t('Add new comment'),
+ 'href' => "comment/reply/$node->nid",
+ 'attributes' => array('title' => t('Add a new comment to this page.')),
+ 'fragment' => 'comment-form',
+ );
+ }
+ else {
+ $links['comment-forbidden'] = array(
+ 'title' => theme('comment_post_forbidden', array('node' => $node)),
+ 'html' => TRUE,
+ );
+ }
+ }
+ }
+ elseif ($view_mode != 'search_index' && $view_mode != 'search_result') {
+ // Node in other view modes: add a "post comment" link if the user is
+ // allowed to post comments and if this node is allowing new comments.
+ // But we don't want this link if we're building the node for search
+ // indexing or constructing a search result excerpt.
+ if ($node->comment == COMMENT_NODE_OPEN) {
+ $comment_form_location = variable_get('comment_form_location_' . $node->type, COMMENT_FORM_BELOW);
+ if (user_access('post comments')) {
+ // Show the "post comment" link if the form is on another page, or
+ // if there are existing comments that the link will skip past.
+ if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE || (!empty($node->comment_count) && user_access('access comments'))) {
+ $links['comment-add'] = array(
+ 'title' => t('Add new comment'),
+ 'attributes' => array('title' => t('Share your thoughts and opinions related to this posting.')),
+ 'href' => "node/$node->nid",
+ 'fragment' => 'comment-form',
+ );
+ if ($comment_form_location == COMMENT_FORM_SEPARATE_PAGE) {
+ $links['comment-add']['href'] = "comment/reply/$node->nid";
+ }
+ }
+ }
+ else {
+ $links['comment-forbidden'] = array(
+ 'title' => theme('comment_post_forbidden', array('node' => $node)),
+ 'html' => TRUE,
+ );
+ }
+ }
+ }
+
+ $node->content['links']['comment'] = array(
+ '#theme' => 'links__node__comment',
+ '#links' => $links,
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+
+ // Only append comments when we are building a node on its own node detail
+ // page. We compare $node and $page_node to ensure that comments are not
+ // appended to other nodes shown on the page, for example a node_reference
+ // displayed in 'full' view mode within another node.
+ if ($node->comment && $view_mode == 'full' && node_is_page($node) && empty($node->in_preview)) {
+ $node->content['comments'] = comment_node_page_additions($node);
+ }
+ }
+}
+
+/**
+ * Build the comment-related elements for node detail pages.
+ *
+ * @param $node
+ * A node object.
+ */
+function comment_node_page_additions($node) {
+ $additions = array();
+
+ // Only attempt to render comments if the node has visible comments.
+ // Unpublished comments are not included in $node->comment_count, so show
+ // comments unconditionally if the user is an administrator.
+ if (($node->comment_count && user_access('access comments')) || user_access('administer comments')) {
+ $mode = variable_get('comment_default_mode_' . $node->type, COMMENT_MODE_THREADED);
+ $comments_per_page = variable_get('comment_default_per_page_' . $node->type, 50);
+ if ($cids = comment_get_thread($node, $mode, $comments_per_page)) {
+ $comments = comment_load_multiple($cids);
+ comment_prepare_thread($comments);
+ $build = comment_view_multiple($comments, $node);
+ $build['pager']['#theme'] = 'pager';
+ $additions['comments'] = $build;
+ }
+ }
+
+ // Append comment form if needed.
+ if (user_access('post comments') && $node->comment == COMMENT_NODE_OPEN && (variable_get('comment_form_location_' . $node->type, COMMENT_FORM_BELOW) == COMMENT_FORM_BELOW)) {
+ $build = drupal_get_form("comment_node_{$node->type}_form", (object) array('nid' => $node->nid));
+ $additions['comment_form'] = $build;
+ }
+
+ if ($additions) {
+ $additions += array(
+ '#theme' => 'comment_wrapper__node_' . $node->type,
+ '#node' => $node,
+ 'comments' => array(),
+ 'comment_form' => array(),
+ );
+ }
+
+ return $additions;
+}
+
+/**
+ * Retrieve comments for a thread.
+ *
+ * @param $node
+ * The node whose comment(s) needs rendering.
+ * @param $mode
+ * The comment display mode; COMMENT_MODE_FLAT or COMMENT_MODE_THREADED.
+ * @param $comments_per_page
+ * The amount of comments to display per page.
+ *
+ * To display threaded comments in the correct order we keep a 'thread' field
+ * and order by that value. This field keeps this data in
+ * a way which is easy to update and convenient to use.
+ *
+ * A "thread" value starts at "1". If we add a child (A) to this comment,
+ * we assign it a "thread" = "1.1". A child of (A) will have "1.1.1". Next
+ * brother of (A) will get "1.2". Next brother of the parent of (A) will get
+ * "2" and so on.
+ *
+ * First of all note that the thread field stores the depth of the comment:
+ * depth 0 will be "X", depth 1 "X.X", depth 2 "X.X.X", etc.
+ *
+ * Now to get the ordering right, consider this example:
+ *
+ * 1
+ * 1.1
+ * 1.1.1
+ * 1.2
+ * 2
+ *
+ * If we "ORDER BY thread ASC" we get the above result, and this is the
+ * natural order sorted by time. However, if we "ORDER BY thread DESC"
+ * we get:
+ *
+ * 2
+ * 1.2
+ * 1.1.1
+ * 1.1
+ * 1
+ *
+ * Clearly, this is not a natural way to see a thread, and users will get
+ * confused. The natural order to show a thread by time desc would be:
+ *
+ * 2
+ * 1
+ * 1.2
+ * 1.1
+ * 1.1.1
+ *
+ * which is what we already did before the standard pager patch. To achieve
+ * this we simply add a "/" at the end of each "thread" value. This way, the
+ * thread fields will look like this:
+ *
+ * 1/
+ * 1.1/
+ * 1.1.1/
+ * 1.2/
+ * 2/
+ *
+ * we add "/" since this char is, in ASCII, higher than every number, so if
+ * now we "ORDER BY thread DESC" we get the correct order. However this would
+ * spoil the reverse ordering, "ORDER BY thread ASC" -- here, we do not need
+ * to consider the trailing "/" so we use a substring only.
+ */
+function comment_get_thread($node, $mode, $comments_per_page) {
+ $query = db_select('comment', 'c')->extend('PagerDefault');
+ $query->addField('c', 'cid');
+ $query
+ ->condition('c.nid', $node->nid)
+ ->addTag('node_access')
+ ->addTag('comment_filter')
+ ->addMetaData('node', $node)
+ ->limit($comments_per_page);
+
+ $count_query = db_select('comment', 'c');
+ $count_query->addExpression('COUNT(*)');
+ $count_query
+ ->condition('c.nid', $node->nid)
+ ->addTag('node_access')
+ ->addTag('comment_filter')
+ ->addMetaData('node', $node);
+
+ if (!user_access('administer comments')) {
+ $query->condition('c.status', COMMENT_PUBLISHED);
+ $count_query->condition('c.status', COMMENT_PUBLISHED);
+ }
+ if ($mode === COMMENT_MODE_FLAT) {
+ $query->orderBy('c.cid', 'ASC');
+ }
+ else {
+ // See comment above. Analysis reveals that this doesn't cost too
+ // much. It scales much much better than having the whole comment
+ // structure.
+ $query->addExpression('SUBSTRING(c.thread, 1, (LENGTH(c.thread) - 1))', 'torder');
+ $query->orderBy('torder', 'ASC');
+ }
+
+ $query->setCountQuery($count_query);
+ $cids = $query->execute()->fetchCol();
+
+ return $cids;
+}
+
+/**
+ * Loop over comment thread, noting indentation level.
+ *
+ * @param array $comments
+ * An array of comment objects, keyed by cid.
+ * @return
+ * The $comments argument is altered by reference with indentation information.
+ */
+function comment_prepare_thread(&$comments) {
+ // A flag stating if we are still searching for first new comment on the thread.
+ $first_new = TRUE;
+
+ // A counter that helps track how indented we are.
+ $divs = 0;
+
+ foreach ($comments as $key => $comment) {
+ if ($first_new && $comment->new != MARK_READ) {
+ // Assign the anchor only for the first new comment. This avoids duplicate
+ // id attributes on a page.
+ $first_new = FALSE;
+ $comment->first_new = TRUE;
+ }
+
+ // The $divs element instructs #prefix whether to add an indent div or
+ // close existing divs (a negative value).
+ $comment->depth = count(explode('.', $comment->thread)) - 1;
+ if ($comment->depth > $divs) {
+ $comment->divs = 1;
+ $divs++;
+ }
+ else {
+ $comment->divs = $comment->depth - $divs;
+ while ($comment->depth < $divs) {
+ $divs--;
+ }
+ }
+ $comments[$key] = $comment;
+ }
+
+ // The final comment must close up some hanging divs
+ $comments[$key]->divs_final = $divs;
+}
+
+/**
+ * Generate an array for rendering the given comment.
+ *
+ * @param $comment
+ * A comment object.
+ * @param $node
+ * The node the comment is attached to.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return
+ * An array as expected by drupal_render().
+ */
+function comment_view($comment, $node, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Populate $comment->content with a render() array.
+ comment_build_content($comment, $node, $view_mode, $langcode);
+
+ $build = $comment->content;
+ // We don't need duplicate rendering info in comment->content.
+ unset($comment->content);
+
+ $build += array(
+ '#theme' => 'comment__node_' . $node->type,
+ '#comment' => $comment,
+ '#node' => $node,
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ );
+
+ if (empty($comment->in_preview)) {
+ $prefix = '';
+ $is_threaded = isset($comment->divs) && variable_get('comment_default_mode_' . $node->type, COMMENT_MODE_THREADED) == COMMENT_MODE_THREADED;
+
+ // Add 'new' anchor if needed.
+ if (!empty($comment->first_new)) {
+ $prefix .= "<a id=\"new\"></a>\n";
+ }
+
+ // Add indentation div or close open divs as needed.
+ if ($is_threaded) {
+ $prefix .= $comment->divs <= 0 ? str_repeat('</div>', abs($comment->divs)) : "\n" . '<div class="indented">';
+ }
+
+ // Add anchor for each comment.
+ $prefix .= "<a id=\"comment-$comment->cid\"></a>\n";
+ $build['#prefix'] = $prefix;
+
+ // Close all open divs.
+ if ($is_threaded && !empty($comment->divs_final)) {
+ $build['#suffix'] = str_repeat('</div>', $comment->divs_final);
+ }
+ }
+
+ // Allow modules to modify the structured comment.
+ $type = 'comment';
+ drupal_alter(array('comment_view', 'entity_view'), $build, $type);
+
+ return $build;
+}
+
+/**
+ * Builds a structured array representing the comment's content.
+ *
+ * The content built for the comment (field values, comments, file attachments or
+ * other comment components) will vary depending on the $view_mode parameter.
+ *
+ * @param $comment
+ * A comment object.
+ * @param $node
+ * The node the comment is attached to.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ */
+function comment_build_content($comment, $node, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Remove previously built content, if exists.
+ $comment->content = array();
+
+ // Build fields content.
+ field_attach_prepare_view('comment', array($comment->cid => $comment), $view_mode, $langcode);
+ entity_prepare_view('comment', array($comment->cid => $comment), $langcode);
+ $comment->content += field_attach_view('comment', $comment, $view_mode, $langcode);
+
+ $comment->content['links'] = array(
+ '#theme' => 'links__comment',
+ '#pre_render' => array('drupal_pre_render_links'),
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ if (empty($comment->in_preview)) {
+ $comment->content['links']['comment'] = array(
+ '#theme' => 'links__comment__comment',
+ '#links' => comment_links($comment, $node),
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ }
+
+ // Allow modules to make their own additions to the comment.
+ module_invoke_all('comment_view', $comment, $view_mode, $langcode);
+ module_invoke_all('entity_view', $comment, 'comment', $view_mode, $langcode);
+}
+
+/**
+ * Helper function, build links for an individual comment.
+ *
+ * Adds reply, edit, delete etc. depending on the current user permissions.
+ *
+ * @param $comment
+ * The comment object.
+ * @param $node
+ * The node the comment is attached to.
+ * @return
+ * A structured array of links.
+ */
+function comment_links($comment, $node) {
+ $links = array();
+ if ($node->comment == COMMENT_NODE_OPEN) {
+ if (user_access('administer comments') && user_access('post comments')) {
+ $links['comment-delete'] = array(
+ 'title' => t('delete'),
+ 'href' => "comment/$comment->cid/delete",
+ 'html' => TRUE,
+ );
+ $links['comment-edit'] = array(
+ 'title' => t('edit'),
+ 'href' => "comment/$comment->cid/edit",
+ 'html' => TRUE,
+ );
+ $links['comment-reply'] = array(
+ 'title' => t('reply'),
+ 'href' => "comment/reply/$comment->nid/$comment->cid",
+ 'html' => TRUE,
+ );
+ if ($comment->status == COMMENT_NOT_PUBLISHED) {
+ $links['comment-approve'] = array(
+ 'title' => t('approve'),
+ 'href' => "comment/$comment->cid/approve",
+ 'html' => TRUE,
+ 'query' => array('token' => drupal_get_token("comment/$comment->cid/approve")),
+ );
+ }
+ }
+ elseif (user_access('post comments')) {
+ if (comment_access('edit', $comment)) {
+ $links['comment-edit'] = array(
+ 'title' => t('edit'),
+ 'href' => "comment/$comment->cid/edit",
+ 'html' => TRUE,
+ );
+ }
+ $links['comment-reply'] = array(
+ 'title' => t('reply'),
+ 'href' => "comment/reply/$comment->nid/$comment->cid",
+ 'html' => TRUE,
+ );
+ }
+ else {
+ $links['comment-forbidden']['title'] = theme('comment_post_forbidden', array('node' => $node));
+ $links['comment-forbidden']['html'] = TRUE;
+ }
+ }
+ return $links;
+}
+
+/**
+ * Construct a drupal_render() style array from an array of loaded comments.
+ *
+ * @param $comments
+ * An array of comments as returned by comment_load_multiple().
+ * @param $node
+ * The node the comments are attached to.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $weight
+ * An integer representing the weight of the first comment in the list.
+ * @param $langcode
+ * A string indicating the language field values are to be shown in. If no
+ * language is provided the current content language is used.
+ *
+ * @return
+ * An array in the format expected by drupal_render().
+ */
+function comment_view_multiple($comments, $node, $view_mode = 'full', $weight = 0, $langcode = NULL) {
+ field_attach_prepare_view('comment', $comments, $view_mode, $langcode);
+ entity_prepare_view('comment', $comments, $langcode);
+
+ $build = array(
+ '#sorted' => TRUE,
+ );
+ foreach ($comments as $comment) {
+ $build[$comment->cid] = comment_view($comment, $node, $view_mode, $langcode);
+ $build[$comment->cid]['#weight'] = $weight;
+ $weight++;
+ }
+ return $build;
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function comment_form_node_type_form_alter(&$form, $form_state) {
+ if (isset($form['type'])) {
+ $form['comment'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Comment settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('comment-node-type-settings-form'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'comment') . '/comment-node-form.js'),
+ ),
+ );
+ // Unlike coment_form_node_form_alter(), all of these settings are applied
+ // as defaults to all new nodes. Therefore, it would be wrong to use #states
+ // to hide the other settings based on the primary comment setting.
+ $form['comment']['comment'] = array(
+ '#type' => 'select',
+ '#title' => t('Default comment setting for new content'),
+ '#default_value' => variable_get('comment_' . $form['#node_type']->type, COMMENT_NODE_OPEN),
+ '#options' => array(
+ COMMENT_NODE_OPEN => t('Open'),
+ COMMENT_NODE_CLOSED => t('Closed'),
+ COMMENT_NODE_HIDDEN => t('Hidden'),
+ ),
+ );
+ $form['comment']['comment_default_mode'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Threading'),
+ '#default_value' => variable_get('comment_default_mode_' . $form['#node_type']->type, COMMENT_MODE_THREADED),
+ '#description' => t('Show comment replies in a threaded list.'),
+ );
+ $form['comment']['comment_default_per_page'] = array(
+ '#type' => 'select',
+ '#title' => t('Comments per page'),
+ '#default_value' => variable_get('comment_default_per_page_' . $form['#node_type']->type, 50),
+ '#options' => _comment_per_page(),
+ );
+ $form['comment']['comment_anonymous'] = array(
+ '#type' => 'select',
+ '#title' => t('Anonymous commenting'),
+ '#default_value' => variable_get('comment_anonymous_' . $form['#node_type']->type, COMMENT_ANONYMOUS_MAYNOT_CONTACT),
+ '#options' => array(
+ COMMENT_ANONYMOUS_MAYNOT_CONTACT => t('Anonymous posters may not enter their contact information'),
+ COMMENT_ANONYMOUS_MAY_CONTACT => t('Anonymous posters may leave their contact information'),
+ COMMENT_ANONYMOUS_MUST_CONTACT => t('Anonymous posters must leave their contact information'),
+ ),
+ '#access' => user_access('post comments', drupal_anonymous_user()),
+ );
+ $form['comment']['comment_subject_field'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Allow comment title'),
+ '#default_value' => variable_get('comment_subject_field_' . $form['#node_type']->type, 1),
+ );
+ $form['comment']['comment_form_location'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Show reply form on the same page as comments'),
+ '#default_value' => variable_get('comment_form_location_' . $form['#node_type']->type, COMMENT_FORM_BELOW),
+ );
+ $form['comment']['comment_preview'] = array(
+ '#type' => 'radios',
+ '#title' => t('Preview comment'),
+ '#default_value' => variable_get('comment_preview_' . $form['#node_type']->type, DRUPAL_OPTIONAL),
+ '#options' => array(
+ DRUPAL_DISABLED => t('Disabled'),
+ DRUPAL_OPTIONAL => t('Optional'),
+ DRUPAL_REQUIRED => t('Required'),
+ ),
+ );
+ }
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ */
+function comment_form_node_form_alter(&$form, $form_state) {
+ $node = $form['#node'];
+ $form['comment_settings'] = array(
+ '#type' => 'fieldset',
+ '#access' => user_access('administer comments'),
+ '#title' => t('Comment settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('comment-node-settings-form'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'comment') . '/comment-node-form.js'),
+ ),
+ '#weight' => 30,
+ );
+ $comment_count = isset($node->nid) ? db_query('SELECT comment_count FROM {node_comment_statistics} WHERE nid = :nid', array(':nid' => $node->nid))->fetchField() : 0;
+ $comment_settings = ($node->comment == COMMENT_NODE_HIDDEN && empty($comment_count)) ? COMMENT_NODE_CLOSED : $node->comment;
+ $form['comment_settings']['comment'] = array(
+ '#type' => 'radios',
+ '#title' => t('Comments'),
+ '#title_display' => 'invisible',
+ '#parents' => array('comment'),
+ '#default_value' => $comment_settings,
+ '#options' => array(
+ COMMENT_NODE_OPEN => t('Open'),
+ COMMENT_NODE_CLOSED => t('Closed'),
+ COMMENT_NODE_HIDDEN => t('Hidden'),
+ ),
+ COMMENT_NODE_OPEN => array(
+ '#description' => t('Users with the "Post comments" permission can post comments.'),
+ ),
+ COMMENT_NODE_CLOSED => array(
+ '#description' => t('Users cannot post comments, but existing comments will be displayed.'),
+ ),
+ COMMENT_NODE_HIDDEN => array(
+ '#description' => t('Comments are hidden from view.'),
+ ),
+ );
+ // If the node doesn't have any comments, the "hidden" option makes no
+ // sense, so don't even bother presenting it to the user.
+ if (empty($comment_count)) {
+ unset($form['comment_settings']['comment']['#options'][COMMENT_NODE_HIDDEN]);
+ unset($form['comment_settings']['comment'][COMMENT_NODE_HIDDEN]);
+ $form['comment_settings']['comment'][COMMENT_NODE_CLOSED]['#description'] = t('Users cannot post comments.');
+ }
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function comment_node_load($nodes, $types) {
+ $comments_enabled = array();
+
+ // Check if comments are enabled for each node. If comments are disabled,
+ // assign values without hitting the database.
+ foreach ($nodes as $node) {
+ // Store whether comments are enabled for this node.
+ if ($node->comment != COMMENT_NODE_HIDDEN) {
+ $comments_enabled[] = $node->nid;
+ }
+ else {
+ $node->cid = 0;
+ $node->last_comment_timestamp = $node->created;
+ $node->last_comment_name = '';
+ $node->last_comment_uid = $node->uid;
+ $node->comment_count = 0;
+ }
+ }
+
+ // For nodes with comments enabled, fetch information from the database.
+ if (!empty($comments_enabled)) {
+ $result = db_query('SELECT nid, cid, last_comment_timestamp, last_comment_name, last_comment_uid, comment_count FROM {node_comment_statistics} WHERE nid IN (:comments_enabled)', array(':comments_enabled' => $comments_enabled));
+ foreach ($result as $record) {
+ $nodes[$record->nid]->cid = $record->cid;
+ $nodes[$record->nid]->last_comment_timestamp = $record->last_comment_timestamp;
+ $nodes[$record->nid]->last_comment_name = $record->last_comment_name;
+ $nodes[$record->nid]->last_comment_uid = $record->last_comment_uid;
+ $nodes[$record->nid]->comment_count = $record->comment_count;
+ }
+ }
+}
+
+/**
+ * Implements hook_node_prepare().
+ */
+function comment_node_prepare($node) {
+ if (!isset($node->comment)) {
+ $node->comment = variable_get("comment_$node->type", COMMENT_NODE_OPEN);
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function comment_node_insert($node) {
+ // Allow bulk updates and inserts to temporarily disable the
+ // maintenance of the {node_comment_statistics} table.
+ if (variable_get('comment_maintain_node_statistics', TRUE)) {
+ db_insert('node_comment_statistics')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'cid' => 0,
+ 'last_comment_timestamp' => $node->changed,
+ 'last_comment_name' => NULL,
+ 'last_comment_uid' => $node->uid,
+ 'comment_count' => 0,
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function comment_node_delete($node) {
+ $cids = db_query('SELECT cid FROM {comment} WHERE nid = :nid', array(':nid' => $node->nid))->fetchCol();
+ comment_delete_multiple($cids);
+ db_delete('node_comment_statistics')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Implements hook_node_update_index().
+ */
+function comment_node_update_index($node) {
+ $index_comments = &drupal_static(__FUNCTION__);
+
+ if ($index_comments === NULL) {
+ // Find and save roles that can 'access comments' or 'search content'.
+ $perms = array('access comments' => array(), 'search content' => array());
+ $result = db_query("SELECT rid, permission FROM {role_permission} WHERE permission IN ('access comments', 'search content')");
+ foreach ($result as $record) {
+ $perms[$record->permission][$record->rid] = $record->rid;
+ }
+
+ // Prevent indexing of comments if there are any roles that can search but
+ // not view comments.
+ $index_comments = TRUE;
+ foreach ($perms['search content'] as $rid) {
+ if (!isset($perms['access comments'][$rid]) && ($rid <= DRUPAL_AUTHENTICATED_RID || !isset($perms['access comments'][DRUPAL_AUTHENTICATED_RID]))) {
+ $index_comments = FALSE;
+ break;
+ }
+ }
+ }
+
+ if ($index_comments) {
+ $mode = variable_get('comment_default_mode_' . $node->type, COMMENT_MODE_THREADED);
+ $comments_per_page = variable_get('comment_default_per_page_' . $node->type, 50);
+ if ($node->comment && $cids = comment_get_thread($node, $mode, $comments_per_page)) {
+ $comments = comment_load_multiple($cids);
+ comment_prepare_thread($comments);
+ $build = comment_view_multiple($comments, $node);
+ return drupal_render($build);
+ }
+ }
+ return '';
+}
+
+/**
+ * Implements hook_update_index().
+ */
+function comment_update_index() {
+ // Store the maximum possible comments per thread (used for ranking by reply count)
+ variable_set('node_cron_comments_scale', 1.0 / max(1, db_query('SELECT MAX(comment_count) FROM {node_comment_statistics}')->fetchField()));
+}
+
+/**
+ * Implements hook_node_search_result().
+ *
+ * Formats a comment count string and returns it, for display with search
+ * results.
+ */
+function comment_node_search_result($node) {
+ // Do not make a string if comments are hidden.
+ if (user_access('access comments') && $node->comment != COMMENT_NODE_HIDDEN) {
+ $comments = db_query('SELECT comment_count FROM {node_comment_statistics} WHERE nid = :nid', array('nid' => $node->nid))->fetchField();
+ // Do not make a string if comments are closed and there are currently
+ // zero comments.
+ if ($node->comment != COMMENT_NODE_CLOSED || $comments > 0) {
+ return array('comment' => format_plural($comments, '1 comment', '@count comments'));
+ }
+ }
+}
+
+/**
+ * Implements hook_user_cancel().
+ */
+function comment_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_block_unpublish':
+ $comments = comment_load_multiple(array(), array('uid' => $account->uid));
+ foreach ($comments as $comment) {
+ $comment->status = 0;
+ comment_save($comment);
+ }
+ break;
+
+ case 'user_cancel_reassign':
+ $comments = comment_load_multiple(array(), array('uid' => $account->uid));
+ foreach ($comments as $comment) {
+ $comment->uid = 0;
+ comment_save($comment);
+ }
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function comment_user_delete($account) {
+ $cids = db_query('SELECT c.cid FROM {comment} c WHERE uid = :uid', array(':uid' => $account->uid))->fetchCol();
+ comment_delete_multiple($cids);
+}
+
+/**
+ * Determines whether the current user has access to a particular comment.
+ *
+ * Authenticated users can edit their comments as long they have not been
+ * replied to. This prevents people from changing or revising their statements
+ * based on the replies to their posts.
+ *
+ * @param $op
+ * The operation that is to be performed on the comment. Only 'edit' is
+ * recognized now.
+ * @param $comment
+ * The comment object.
+ * @return
+ * TRUE if the current user has acces to the comment, FALSE otherwise.
+ */
+function comment_access($op, $comment) {
+ global $user;
+
+ if ($op == 'edit') {
+ return ($user->uid && $user->uid == $comment->uid && $comment->status == COMMENT_PUBLISHED && user_access('edit own comments')) || user_access('administer comments');
+ }
+}
+
+/**
+ * Accepts a submission of new or changed comment content.
+ *
+ * @param $comment
+ * A comment object.
+ *
+ * @see comment_int_to_alphadecimal()
+ */
+function comment_save($comment) {
+ global $user;
+
+ $transaction = db_transaction();
+ try {
+ $defaults = array(
+ 'mail' => '',
+ 'homepage' => '',
+ 'name' => '',
+ 'status' => user_access('skip comment approval') ? COMMENT_PUBLISHED : COMMENT_NOT_PUBLISHED,
+ );
+ foreach ($defaults as $key => $default) {
+ if (!isset($comment->$key)) {
+ $comment->$key = $default;
+ }
+ }
+ // Make sure we have a bundle name.
+ if (!isset($comment->node_type)) {
+ $node = node_load($comment->nid);
+ $comment->node_type = 'comment_node_' . $node->type;
+ }
+
+ // Load the stored entity, if any.
+ if (!empty($comment->cid) && !isset($comment->original)) {
+ $comment->original = entity_load_unchanged('comment', $comment->cid);
+ }
+
+ field_attach_presave('comment', $comment);
+
+ // Allow modules to alter the comment before saving.
+ module_invoke_all('comment_presave', $comment);
+ module_invoke_all('entity_presave', $comment, 'comment');
+
+ if ($comment->cid) {
+
+ drupal_write_record('comment', $comment, 'cid');
+
+ // Ignore slave server temporarily to give time for the
+ // saved comment to be propagated to the slave.
+ db_ignore_slave();
+
+ // Update the {node_comment_statistics} table prior to executing hooks.
+ _comment_update_node_statistics($comment->nid);
+
+ field_attach_update('comment', $comment);
+ // Allow modules to respond to the updating of a comment.
+ module_invoke_all('comment_update', $comment);
+ module_invoke_all('entity_update', $comment, 'comment');
+ }
+ else {
+ // Add the comment to database. This next section builds the thread field.
+ // Also see the documentation for comment_view().
+ if (!empty($comment->thread)) {
+ // Allow calling code to set thread itself.
+ $thread = $comment->thread;
+ }
+ elseif ($comment->pid == 0) {
+ // This is a comment with no parent comment (depth 0): we start
+ // by retrieving the maximum thread level.
+ $max = db_query('SELECT MAX(thread) FROM {comment} WHERE nid = :nid', array(':nid' => $comment->nid))->fetchField();
+ // Strip the "/" from the end of the thread.
+ $max = rtrim($max, '/');
+ // Finally, build the thread field for this new comment.
+ $thread = comment_increment_alphadecimal($max) . '/';
+ }
+ else {
+ // This is a comment with a parent comment, so increase the part of the
+ // thread value at the proper depth.
+
+ // Get the parent comment:
+ $parent = comment_load($comment->pid);
+ // Strip the "/" from the end of the parent thread.
+ $parent->thread = (string) rtrim((string) $parent->thread, '/');
+ // Get the max value in *this* thread.
+ $max = db_query("SELECT MAX(thread) FROM {comment} WHERE thread LIKE :thread AND nid = :nid", array(
+ ':thread' => $parent->thread . '.%',
+ ':nid' => $comment->nid,
+ ))->fetchField();
+
+ if ($max == '') {
+ // First child of this parent.
+ $thread = $parent->thread . '.' . comment_int_to_alphadecimal(0) . '/';
+ }
+ else {
+ // Strip the "/" at the end of the thread.
+ $max = rtrim($max, '/');
+ // Get the value at the correct depth.
+ $parts = explode('.', $max);
+ $parent_depth = count(explode('.', $parent->thread));
+ $last = $parts[$parent_depth];
+ // Finally, build the thread field for this new comment.
+ $thread = $parent->thread . '.' . comment_increment_alphadecimal($last) . '/';
+ }
+ }
+
+ if (empty($comment->created)) {
+ $comment->created = REQUEST_TIME;
+ }
+
+ if (empty($comment->changed)) {
+ $comment->changed = $comment->created;
+ }
+
+ if ($comment->uid === $user->uid && isset($user->name)) { // '===' Need to modify anonymous users as well.
+ $comment->name = $user->name;
+ }
+
+ // Ensure the parent id (pid) has a value set.
+ if (empty($comment->pid)) {
+ $comment->pid = 0;
+ }
+
+ // Add the values which aren't passed into the function.
+ $comment->thread = $thread;
+ $comment->hostname = ip_address();
+
+ drupal_write_record('comment', $comment);
+
+ // Ignore slave server temporarily to give time for the
+ // created comment to be propagated to the slave.
+ db_ignore_slave();
+
+ // Update the {node_comment_statistics} table prior to executing hooks.
+ _comment_update_node_statistics($comment->nid);
+
+ field_attach_insert('comment', $comment);
+
+ // Tell the other modules a new comment has been submitted.
+ module_invoke_all('comment_insert', $comment);
+ module_invoke_all('entity_insert', $comment, 'comment');
+ }
+ if ($comment->status == COMMENT_PUBLISHED) {
+ module_invoke_all('comment_publish', $comment);
+ }
+ unset($comment->original);
+ }
+ catch (Exception $e) {
+ $transaction->rollback('comment');
+ watchdog_exception('comment', $e);
+ throw $e;
+ }
+
+}
+
+/**
+ * Delete a comment and all its replies.
+ *
+ * @param $cid
+ * The comment to delete.
+ */
+function comment_delete($cid) {
+ comment_delete_multiple(array($cid));
+}
+
+/**
+ * Delete comments and all their replies.
+ *
+ * @param $cids
+ * The comment to delete.
+ */
+function comment_delete_multiple($cids) {
+ $comments = comment_load_multiple($cids);
+ if ($comments) {
+ $transaction = db_transaction();
+ try {
+ // Delete the comments.
+ db_delete('comment')
+ ->condition('cid', array_keys($comments), 'IN')
+ ->execute();
+ foreach ($comments as $comment) {
+ field_attach_delete('comment', $comment);
+ module_invoke_all('comment_delete', $comment);
+ module_invoke_all('entity_delete', $comment, 'comment');
+
+ // Delete the comment's replies.
+ $child_cids = db_query('SELECT cid FROM {comment} WHERE pid = :cid', array(':cid' => $comment->cid))->fetchCol();
+ comment_delete_multiple($child_cids);
+ _comment_update_node_statistics($comment->nid);
+ }
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('comment', $e);
+ throw $e;
+ }
+ }
+}
+
+/**
+ * Load comments from the database.
+ *
+ * @param $cids
+ * An array of comment IDs.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the {comments}
+ * table, where the keys are the database fields and the values are the
+ * values those fields must have. Instead, it is preferable to use
+ * EntityFieldQuery to retrieve a list of entity IDs loadable by
+ * this function.
+ * @param $reset
+ * Whether to reset the internal static entity cache. Note that the static
+ * cache is disabled in comment_entity_info() by default.
+ *
+ * @return
+ * An array of comment objects, indexed by comment ID.
+ *
+ * @see entity_load()
+ * @see EntityFieldQuery
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function comment_load_multiple($cids = array(), $conditions = array(), $reset = FALSE) {
+ return entity_load('comment', $cids, $conditions, $reset);
+}
+
+/**
+ * Load the entire comment by cid.
+ *
+ * @param $cid
+ * The identifying comment id.
+ * @param $reset
+ * Whether to reset the internal static entity cache. Note that the static
+ * cache is disabled in comment_entity_info() by default.
+ *
+ * @return
+ * The comment object.
+ */
+function comment_load($cid, $reset = FALSE) {
+ $comment = comment_load_multiple(array($cid), array(), $reset);
+ return $comment ? $comment[$cid] : FALSE;
+}
+
+/**
+ * Controller class for comments.
+ *
+ * This extends the DrupalDefaultEntityController class, adding required
+ * special handling for comment objects.
+ */
+class CommentController extends DrupalDefaultEntityController {
+
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ $query = parent::buildQuery($ids, $conditions, $revision_id);
+ // Specify additional fields from the user and node tables.
+ $query->innerJoin('node', 'n', 'base.nid = n.nid');
+ $query->addField('n', 'type', 'node_type');
+ $query->innerJoin('users', 'u', 'base.uid = u.uid');
+ $query->addField('u', 'name', 'registered_name');
+ $query->fields('u', array('uid', 'signature', 'signature_format', 'picture'));
+ return $query;
+ }
+
+ protected function attachLoad(&$comments, $revision_id = FALSE) {
+ // Setup standard comment properties.
+ foreach ($comments as $key => $comment) {
+ $comment->name = $comment->uid ? $comment->registered_name : $comment->name;
+ $comment->new = node_mark($comment->nid, $comment->changed);
+ $comment->node_type = 'comment_node_' . $comment->node_type;
+ $comments[$key] = $comment;
+ }
+ parent::attachLoad($comments, $revision_id);
+ }
+}
+
+/**
+ * Get number of new comments for current user and specified node.
+ *
+ * @param $nid
+ * Node-id to count comments for.
+ * @param $timestamp
+ * Time to count from (defaults to time of last user access
+ * to node).
+ * @return The result or FALSE on error.
+ */
+function comment_num_new($nid, $timestamp = 0) {
+ global $user;
+
+ if ($user->uid) {
+ // Retrieve the timestamp at which the current user last viewed this node.
+ if (!$timestamp) {
+ $timestamp = node_last_viewed($nid);
+ }
+ $timestamp = ($timestamp > NODE_NEW_LIMIT ? $timestamp : NODE_NEW_LIMIT);
+
+ // Use the timestamp to retrieve the number of new comments.
+ return db_query('SELECT COUNT(cid) FROM {comment} WHERE nid = :nid AND created > :timestamp AND status = :status', array(
+ ':nid' => $nid,
+ ':timestamp' => $timestamp,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchField();
+ }
+ else {
+ return FALSE;
+ }
+
+}
+
+/**
+ * Get the display ordinal for a comment, starting from 0.
+ *
+ * Count the number of comments which appear before the comment we want to
+ * display, taking into account display settings and threading.
+ *
+ * @param $cid
+ * The comment ID.
+ * @param $node_type
+ * The node type of the comment's parent.
+ * @return
+ * The display ordinal for the comment.
+ * @see comment_get_display_page()
+ */
+function comment_get_display_ordinal($cid, $node_type) {
+ // Count how many comments (c1) are before $cid (c2) in display order. This is
+ // the 0-based display ordinal.
+ $query = db_select('comment', 'c1');
+ $query->innerJoin('comment', 'c2', 'c2.nid = c1.nid');
+ $query->addExpression('COUNT(*)', 'count');
+ $query->condition('c2.cid', $cid);
+ if (!user_access('administer comments')) {
+ $query->condition('c1.status', COMMENT_PUBLISHED);
+ }
+ $mode = variable_get('comment_default_mode_' . $node_type, COMMENT_MODE_THREADED);
+
+ if ($mode == COMMENT_MODE_FLAT) {
+ // For flat comments, cid is used for ordering comments due to
+ // unpredicatable behavior with timestamp, so we make the same assumption
+ // here.
+ $query->condition('c1.cid', $cid, '<');
+ }
+ else {
+ // For threaded comments, the c.thread column is used for ordering. We can
+ // use the sorting code for comparison, but must remove the trailing slash.
+ // See comment_view_multiple().
+ $query->where('SUBSTRING(c1.thread, 1, (LENGTH(c1.thread) -1)) < SUBSTRING(c2.thread, 1, (LENGTH(c2.thread) -1))');
+ }
+
+ return $query->execute()->fetchField();
+}
+
+/**
+ * Return the page number for a comment.
+ *
+ * Finds the correct page number for a comment taking into account display
+ * and paging settings.
+ *
+ * @param $cid
+ * The comment ID.
+ * @param $node_type
+ * The node type the comment is attached to.
+ * @return
+ * The page number.
+ */
+function comment_get_display_page($cid, $node_type) {
+ $ordinal = comment_get_display_ordinal($cid, $node_type);
+ $comments_per_page = variable_get('comment_default_per_page_' . $node_type, 50);
+ return floor($ordinal / $comments_per_page);
+}
+
+/**
+ * Page callback for comment editing.
+ */
+function comment_edit_page($comment) {
+ drupal_set_title(t('Edit comment %comment', array('%comment' => $comment->subject)), PASS_THROUGH);
+ $node = node_load($comment->nid);
+ return drupal_get_form("comment_node_{$node->type}_form", $comment);
+}
+
+/**
+ * Implements hook_forms().
+ */
+function comment_forms() {
+ $forms = array();
+ foreach (node_type_get_types() as $type) {
+ $forms["comment_node_{$type->type}_form"]['callback'] = 'comment_form';
+ }
+ return $forms;
+}
+
+/**
+ * Generate the basic commenting form, for appending to a node or display on a separate page.
+ *
+ * @see comment_form_validate()
+ * @see comment_form_submit()
+ *
+ * @ingroup forms
+ */
+function comment_form($form, &$form_state, $comment) {
+ global $user;
+
+ // During initial form build, add the comment entity to the form state for
+ // use during form building and processing. During a rebuild, use what is in
+ // the form state.
+ if (!isset($form_state['comment'])) {
+ $defaults = array(
+ 'name' => '',
+ 'mail' => '',
+ 'homepage' => '',
+ 'subject' => '',
+ 'comment' => '',
+ 'cid' => NULL,
+ 'pid' => NULL,
+ 'language' => LANGUAGE_NONE,
+ 'uid' => 0,
+ );
+ foreach ($defaults as $key => $value) {
+ if (!isset($comment->$key)) {
+ $comment->$key = $value;
+ }
+ }
+ $form_state['comment'] = $comment;
+ }
+ else {
+ $comment = $form_state['comment'];
+ }
+
+ $node = node_load($comment->nid);
+ $form['#node'] = $node;
+
+ // Use #comment-form as unique jump target, regardless of node type.
+ $form['#id'] = drupal_html_id('comment_form');
+ $form['#theme'] = array('comment_form__node_' . $node->type, 'comment_form');
+
+ $anonymous_contact = variable_get('comment_anonymous_' . $node->type, COMMENT_ANONYMOUS_MAYNOT_CONTACT);
+ $is_admin = (!empty($comment->cid) && user_access('administer comments'));
+
+ if (!$user->uid && $anonymous_contact != COMMENT_ANONYMOUS_MAYNOT_CONTACT) {
+ $form['#attached']['library'][] = array('system', 'jquery.cookie');
+ $form['#attributes']['class'][] = 'user-info-from-cookie';
+ }
+
+ // If not replying to a comment, use our dedicated page callback for new
+ // comments on nodes.
+ if (empty($comment->cid) && empty($comment->pid)) {
+ $form['#action'] = url('comment/reply/' . $comment->nid);
+ }
+
+ if (isset($form_state['comment_preview'])) {
+ $form += $form_state['comment_preview'];
+ }
+
+ // Display author information in a fieldset for comment moderators.
+ if ($is_admin) {
+ $form['author'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Administration'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#weight' => -2,
+ );
+ }
+ else {
+ // Sets the author form elements above the subject.
+ $form['author'] = array(
+ '#weight' => -2,
+ );
+ }
+
+ // Prepare default values for form elements.
+ if ($is_admin) {
+ $author = (!$comment->uid && $comment->name ? $comment->name : $comment->registered_name);
+ $status = (isset($comment->status) ? $comment->status : COMMENT_NOT_PUBLISHED);
+ $date = (!empty($comment->date) ? $comment->date : format_date($comment->created, 'custom', 'Y-m-d H:i O'));
+ }
+ else {
+ if ($user->uid) {
+ $author = $user->name;
+ }
+ else {
+ $author = ($comment->name ? $comment->name : '');
+ }
+ $status = (user_access('skip comment approval') ? COMMENT_PUBLISHED : COMMENT_NOT_PUBLISHED);
+ $date = '';
+ }
+
+ // Add the author name field depending on the current user.
+ if ($is_admin) {
+ $form['author']['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Authored by'),
+ '#default_value' => $author,
+ '#maxlength' => 60,
+ '#size' => 30,
+ '#description' => t('Leave blank for %anonymous.', array('%anonymous' => variable_get('anonymous', t('Anonymous')))),
+ '#autocomplete_path' => 'user/autocomplete',
+ );
+ }
+ elseif ($user->uid) {
+ $form['author']['_author'] = array(
+ '#type' => 'item',
+ '#title' => t('Your name'),
+ '#markup' => theme('username', array('account' => $user)),
+ );
+ $form['author']['name'] = array(
+ '#type' => 'value',
+ '#value' => $author,
+ );
+ }
+ else {
+ $form['author']['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your name'),
+ '#default_value' => $author,
+ '#required' => (!$user->uid && $anonymous_contact == COMMENT_ANONYMOUS_MUST_CONTACT),
+ '#maxlength' => 60,
+ '#size' => 30,
+ );
+ }
+
+ // Add author e-mail and homepage fields depending on the current user.
+ $form['author']['mail'] = array(
+ '#type' => 'textfield',
+ '#title' => t('E-mail'),
+ '#default_value' => $comment->mail,
+ '#required' => (!$user->uid && $anonymous_contact == COMMENT_ANONYMOUS_MUST_CONTACT),
+ '#maxlength' => 64,
+ '#size' => 30,
+ '#description' => t('The content of this field is kept private and will not be shown publicly.'),
+ '#access' => $is_admin || (!$user->uid && $anonymous_contact != COMMENT_ANONYMOUS_MAYNOT_CONTACT),
+ );
+ $form['author']['homepage'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Homepage'),
+ '#default_value' => $comment->homepage,
+ '#maxlength' => 255,
+ '#size' => 30,
+ '#access' => $is_admin || (!$user->uid && $anonymous_contact != COMMENT_ANONYMOUS_MAYNOT_CONTACT),
+ );
+
+ // Add administrative comment publishing options.
+ $form['author']['date'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Authored on'),
+ '#default_value' => $date,
+ '#maxlength' => 25,
+ '#size' => 20,
+ '#access' => $is_admin,
+ );
+ $form['author']['status'] = array(
+ '#type' => 'radios',
+ '#title' => t('Status'),
+ '#default_value' => $status,
+ '#options' => array(
+ COMMENT_PUBLISHED => t('Published'),
+ COMMENT_NOT_PUBLISHED => t('Not published'),
+ ),
+ '#access' => $is_admin,
+ );
+
+ $form['subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#maxlength' => 64,
+ '#default_value' => $comment->subject,
+ '#access' => variable_get('comment_subject_field_' . $node->type, 1) == 1,
+ '#weight' => -1,
+ );
+
+ // Used for conditional validation of author fields.
+ $form['is_anonymous'] = array(
+ '#type' => 'value',
+ '#value' => ($comment->cid ? !$comment->uid : !$user->uid),
+ );
+
+ // Add internal comment properties.
+ foreach (array('cid', 'pid', 'nid', 'language', 'uid') as $key) {
+ $form[$key] = array('#type' => 'value', '#value' => $comment->$key);
+ }
+ $form['node_type'] = array('#type' => 'value', '#value' => 'comment_node_' . $node->type);
+
+ // Only show the save button if comment previews are optional or if we are
+ // already previewing the submission.
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#access' => ($comment->cid && user_access('administer comments')) || variable_get('comment_preview_' . $node->type, DRUPAL_OPTIONAL) != DRUPAL_REQUIRED || isset($form_state['comment_preview']),
+ '#weight' => 19,
+ );
+ $form['actions']['preview'] = array(
+ '#type' => 'submit',
+ '#value' => t('Preview'),
+ '#access' => (variable_get('comment_preview_' . $node->type, DRUPAL_OPTIONAL) != DRUPAL_DISABLED),
+ '#weight' => 20,
+ '#submit' => array('comment_form_build_preview'),
+ );
+
+ // Attach fields.
+ $comment->node_type = 'comment_node_' . $node->type;
+ field_attach_form('comment', $comment, $form, $form_state);
+
+ return $form;
+}
+
+/**
+ * Build a preview from submitted form values.
+ */
+function comment_form_build_preview($form, &$form_state) {
+ $comment = comment_form_submit_build_comment($form, $form_state);
+ $form_state['comment_preview'] = comment_preview($comment);
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Generate a comment preview.
+ */
+function comment_preview($comment) {
+ global $user;
+
+ drupal_set_title(t('Preview comment'), PASS_THROUGH);
+
+ $node = node_load($comment->nid);
+
+ if (!form_get_errors()) {
+ $comment->format = $comment->comment_body[LANGUAGE_NONE][0]['format'];
+ // Attach the user and time information.
+ if (!empty($comment->name)) {
+ $account = user_load_by_name($comment->name);
+ }
+ elseif ($user->uid && empty($comment->is_anonymous)) {
+ $account = $user;
+ }
+
+ if (!empty($account->uid)) {
+ $comment->uid = $account->uid;
+ $comment->name = check_plain($account->name);
+ }
+ elseif (empty($comment->name)) {
+ $comment->name = variable_get('anonymous', t('Anonymous'));
+ }
+
+ $comment->created = !empty($comment->created) ? $comment->created : REQUEST_TIME;
+ $comment->changed = REQUEST_TIME;
+ $comment->in_preview = TRUE;
+ $comment_build = comment_view($comment, $node);
+ $comment_build['#weight'] = -100;
+
+ $form['comment_preview'] = $comment_build;
+ }
+
+ if ($comment->pid) {
+ $build = array();
+ if ($comments = comment_load_multiple(array($comment->pid), array('status' => COMMENT_PUBLISHED))) {
+ $parent_comment = $comments[$comment->pid];
+ $build = comment_view($parent_comment, $node);
+ }
+ }
+ else {
+ $build = node_view($node);
+ }
+
+ $form['comment_output_below'] = $build;
+ $form['comment_output_below']['#weight'] = 100;
+
+ return $form;
+}
+
+/**
+ * Validate comment form submissions.
+ */
+function comment_form_validate($form, &$form_state) {
+ global $user;
+
+ entity_form_field_validate('comment', $form, $form_state);
+
+ if (!empty($form_state['values']['cid'])) {
+ // Verify the name in case it is being changed from being anonymous.
+ $account = user_load_by_name($form_state['values']['name']);
+ $form_state['values']['uid'] = $account ? $account->uid : 0;
+
+ if ($form_state['values']['date'] && strtotime($form_state['values']['date']) === FALSE) {
+ form_set_error('date', t('You have to specify a valid date.'));
+ }
+ if ($form_state['values']['name'] && !$form_state['values']['is_anonymous'] && !$account) {
+ form_set_error('name', t('You have to specify a valid author.'));
+ }
+ }
+ elseif ($form_state['values']['is_anonymous']) {
+ // Validate anonymous comment author fields (if given). If the (original)
+ // author of this comment was an anonymous user, verify that no registered
+ // user with this name exists.
+ if ($form_state['values']['name']) {
+ $query = db_select('users', 'u');
+ $query->addField('u', 'uid', 'uid');
+ $taken = $query
+ ->condition('name', db_like($form_state['values']['name']), 'LIKE')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ if ($taken) {
+ form_set_error('name', t('The name you used belongs to a registered user.'));
+ }
+ }
+ }
+ if ($form_state['values']['mail'] && !valid_email_address($form_state['values']['mail'])) {
+ form_set_error('mail', t('The e-mail address you specified is not valid.'));
+ }
+ if ($form_state['values']['homepage'] && !valid_url($form_state['values']['homepage'], TRUE)) {
+ form_set_error('homepage', t('The URL of your homepage is not valid. Remember that it must be fully qualified, i.e. of the form <code>http://example.com/directory</code>.'));
+ }
+}
+
+/**
+ * Prepare a comment for submission.
+ */
+function comment_submit($comment) {
+ // @todo Legacy support. Remove in Drupal 8.
+ if (is_array($comment)) {
+ $comment += array('subject' => '');
+ $comment = (object) $comment;
+ }
+
+ if (empty($comment->date)) {
+ $comment->date = 'now';
+ }
+ $comment->created = strtotime($comment->date);
+ $comment->changed = REQUEST_TIME;
+
+ // If the comment was posted by a registered user, assign the author's ID.
+ // @todo Too fragile. Should be prepared and stored in comment_form() already.
+ if (!$comment->is_anonymous && !empty($comment->name) && ($account = user_load_by_name($comment->name))) {
+ $comment->uid = $account->uid;
+ }
+ // If the comment was posted by an anonymous user and no author name was
+ // required, use "Anonymous" by default.
+ if ($comment->is_anonymous && (!isset($comment->name) || $comment->name === '')) {
+ $comment->name = variable_get('anonymous', t('Anonymous'));
+ }
+
+ // Validate the comment's subject. If not specified, extract from comment body.
+ if (trim($comment->subject) == '') {
+ // The body may be in any format, so:
+ // 1) Filter it into HTML
+ // 2) Strip out all HTML tags
+ // 3) Convert entities back to plain-text.
+ $comment_body = $comment->comment_body[LANGUAGE_NONE][0];
+ if (isset($comment_body['format'])) {
+ $comment_text = check_markup($comment_body['value'], $comment_body['format']);
+ }
+ else {
+ $comment_text = check_plain($comment_body['value']);
+ }
+ $comment->subject = truncate_utf8(trim(decode_entities(strip_tags($comment_text))), 29, TRUE);
+ // Edge cases where the comment body is populated only by HTML tags will
+ // require a default subject.
+ if ($comment->subject == '') {
+ $comment->subject = t('(No subject)');
+ }
+ }
+ return $comment;
+}
+
+/**
+ * Updates the form state's comment entity by processing this submission's values.
+ *
+ * This is the default builder function for the comment form. It is called
+ * during the "Save" and "Preview" submit handlers to retrieve the entity to
+ * save or preview. This function can also be called by a "Next" button of a
+ * wizard to update the form state's entity with the current step's values
+ * before proceeding to the next step.
+ *
+ * @see comment_form()
+ */
+function comment_form_submit_build_comment($form, &$form_state) {
+ $comment = $form_state['comment'];
+ entity_form_submit_build_entity('comment', $comment, $form, $form_state);
+ comment_submit($comment);
+ return $comment;
+}
+
+/**
+ * Process comment form submissions; prepare the comment, store it, and set a redirection target.
+ */
+function comment_form_submit($form, &$form_state) {
+ $node = node_load($form_state['values']['nid']);
+ $comment = comment_form_submit_build_comment($form, $form_state);
+ if (user_access('post comments') && (user_access('administer comments') || $node->comment == COMMENT_NODE_OPEN)) {
+ // Save the anonymous user information to a cookie for reuse.
+ if (user_is_anonymous()) {
+ user_cookie_save(array_intersect_key($form_state['values'], array_flip(array('name', 'mail', 'homepage'))));
+ }
+
+ comment_save($comment);
+ $form_state['values']['cid'] = $comment->cid;
+
+ // Add an entry to the watchdog log.
+ watchdog('content', 'Comment posted: %subject.', array('%subject' => $comment->subject), WATCHDOG_NOTICE, l(t('view'), 'comment/' . $comment->cid, array('fragment' => 'comment-' . $comment->cid)));
+
+ // Explain the approval queue if necessary.
+ if ($comment->status == COMMENT_NOT_PUBLISHED) {
+ if (!user_access('administer comments')) {
+ drupal_set_message(t('Your comment has been queued for review by site administrators and will be published after approval.'));
+ }
+ }
+ else {
+ drupal_set_message(t('Your comment has been posted.'));
+ }
+ $query = array();
+ // Find the current display page for this comment.
+ $page = comment_get_display_page($comment->cid, $node->type);
+ if ($page > 0) {
+ $query['page'] = $page;
+ }
+ // Redirect to the newly posted comment.
+ $redirect = array('node/' . $node->nid, array('query' => $query, 'fragment' => 'comment-' . $comment->cid));
+ }
+ else {
+ watchdog('content', 'Comment: unauthorized comment submitted or comment submitted to a closed post %subject.', array('%subject' => $comment->subject), WATCHDOG_WARNING);
+ drupal_set_message(t('Comment: unauthorized comment submitted or comment submitted to a closed post %subject.', array('%subject' => $comment->subject)), 'error');
+ // Redirect the user to the node they are commenting on.
+ $redirect = 'node/' . $node->nid;
+ }
+ $form_state['redirect'] = $redirect;
+ // Clear the block and page caches so that anonymous users see the comment
+ // they have posted.
+ cache_clear_all();
+}
+
+/**
+ * Process variables for comment.tpl.php.
+ *
+ * @see comment.tpl.php
+ */
+function template_preprocess_comment(&$variables) {
+ $comment = $variables['elements']['#comment'];
+ $node = $variables['elements']['#node'];
+ $variables['comment'] = $comment;
+ $variables['node'] = $node;
+ $variables['author'] = theme('username', array('account' => $comment));
+ $variables['created'] = format_date($comment->created);
+ $variables['changed'] = format_date($comment->changed);
+
+ $variables['new'] = !empty($comment->new) ? t('new') : '';
+ $variables['picture'] = theme_get_setting('toggle_comment_user_picture') ? theme('user_picture', array('account' => $comment)) : '';
+ $variables['signature'] = $comment->signature;
+
+ $uri = entity_uri('comment', $comment);
+ $uri['options'] += array('attributes' => array('class' => 'permalink', 'rel' => 'bookmark'));
+
+ $variables['title'] = l($comment->subject, $uri['path'], $uri['options']);
+ $variables['permalink'] = l(t('Permalink'), $uri['path'], $uri['options']);
+ $variables['submitted'] = t('Submitted by !username on !datetime', array('!username' => $variables['author'], '!datetime' => $variables['created']));
+
+ // Preprocess fields.
+ field_attach_preprocess('comment', $comment, $variables['elements'], $variables);
+
+ // Helpful $content variable for templates.
+ foreach (element_children($variables['elements']) as $key) {
+ $variables['content'][$key] = $variables['elements'][$key];
+ }
+
+ // Set status to a string representation of comment->status.
+ if (isset($comment->in_preview)) {
+ $variables['status'] = 'comment-preview';
+ }
+ else {
+ $variables['status'] = ($comment->status == COMMENT_NOT_PUBLISHED) ? 'comment-unpublished' : 'comment-published';
+ }
+ // Gather comment classes.
+ if ($comment->uid == 0) {
+ $variables['classes_array'][] = 'comment-by-anonymous';
+ }
+ else {
+ // Published class is not needed. It is either 'comment-preview' or 'comment-unpublished'.
+ if ($variables['status'] != 'comment-published') {
+ $variables['classes_array'][] = $variables['status'];
+ }
+ if ($comment->uid === $variables['node']->uid) {
+ $variables['classes_array'][] = 'comment-by-node-author';
+ }
+ if ($comment->uid === $variables['user']->uid) {
+ $variables['classes_array'][] = 'comment-by-viewer';
+ }
+ if ($variables['new']) {
+ $variables['classes_array'][] = 'comment-new';
+ }
+ }
+}
+
+/**
+ * Returns HTML for a "you can't post comments" notice.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - node: The comment node.
+ *
+ * @ingroup themeable
+ */
+function theme_comment_post_forbidden($variables) {
+ $node = $variables['node'];
+ global $user;
+
+ // Since this is expensive to compute, we cache it so that a page with many
+ // comments only has to query the database once for all the links.
+ $authenticated_post_comments = &drupal_static(__FUNCTION__, NULL);
+
+ if (!$user->uid) {
+ if (!isset($authenticated_post_comments)) {
+ // We only output a link if we are certain that users will get permission
+ // to post comments by logging in.
+ $comment_roles = user_roles(TRUE, 'post comments');
+ $authenticated_post_comments = isset($comment_roles[DRUPAL_AUTHENTICATED_RID]);
+ }
+
+ if ($authenticated_post_comments) {
+ // We cannot use drupal_get_destination() because these links
+ // sometimes appear on /node and taxonomy listing pages.
+ if (variable_get('comment_form_location_' . $node->type, COMMENT_FORM_BELOW) == COMMENT_FORM_SEPARATE_PAGE) {
+ $destination = array('destination' => "comment/reply/$node->nid#comment-form");
+ }
+ else {
+ $destination = array('destination' => "node/$node->nid#comment-form");
+ }
+
+ if (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL)) {
+ // Users can register themselves.
+ return t('<a href="@login">Log in</a> or <a href="@register">register</a> to post comments', array('@login' => url('user/login', array('query' => $destination)), '@register' => url('user/register', array('query' => $destination))));
+ }
+ else {
+ // Only admins can add new users, no public registration.
+ return t('<a href="@login">Log in</a> to post comments', array('@login' => url('user/login', array('query' => $destination))));
+ }
+ }
+ }
+}
+
+/**
+ * Process variables for comment-wrapper.tpl.php.
+ *
+ * @see comment-wrapper.tpl.php
+ * @see theme_comment_wrapper()
+ */
+function template_preprocess_comment_wrapper(&$variables) {
+ // Provide contextual information.
+ $variables['node'] = $variables['content']['#node'];
+ $variables['display_mode'] = variable_get('comment_default_mode_' . $variables['node']->type, COMMENT_MODE_THREADED);
+ // The comment form is optional and may not exist.
+ $variables['content'] += array('comment_form' => array());
+}
+
+/**
+ * Return an array of viewing modes for comment listings.
+ *
+ * We can't use a global variable array because the locale system
+ * is not initialized yet when the comment module is loaded.
+ */
+function _comment_get_modes() {
+ return array(
+ COMMENT_MODE_FLAT => t('Flat list'),
+ COMMENT_MODE_THREADED => t('Threaded list')
+ );
+}
+
+/**
+ * Return an array of "comments per page" settings from which the user
+ * can choose.
+ */
+function _comment_per_page() {
+ return drupal_map_assoc(array(10, 30, 50, 70, 90, 150, 200, 250, 300));
+}
+
+/**
+ * Updates the comment statistics for a given node. This should be called any
+ * time a comment is added, deleted, or updated.
+ *
+ * The following fields are contained in the node_comment_statistics table.
+ * - last_comment_timestamp: the timestamp of the last comment for this node or the node create stamp if no comments exist for the node.
+ * - last_comment_name: the name of the anonymous poster for the last comment
+ * - last_comment_uid: the uid of the poster for the last comment for this node or the node authors uid if no comments exists for the node.
+ * - comment_count: the total number of approved/published comments on this node.
+ */
+function _comment_update_node_statistics($nid) {
+ // Allow bulk updates and inserts to temporarily disable the
+ // maintenance of the {node_comment_statistics} table.
+ if (!variable_get('comment_maintain_node_statistics', TRUE)) {
+ return;
+ }
+
+ $count = db_query('SELECT COUNT(cid) FROM {comment} WHERE nid = :nid AND status = :status', array(
+ ':nid' => $nid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchField();
+
+ if ($count > 0) {
+ // Comments exist.
+ $last_reply = db_query_range('SELECT cid, name, changed, uid FROM {comment} WHERE nid = :nid AND status = :status ORDER BY cid DESC', 0, 1, array(
+ ':nid' => $nid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchObject();
+ db_update('node_comment_statistics')
+ ->fields(array(
+ 'cid' => $last_reply->cid,
+ 'comment_count' => $count,
+ 'last_comment_timestamp' => $last_reply->changed,
+ 'last_comment_name' => $last_reply->uid ? '' : $last_reply->name,
+ 'last_comment_uid' => $last_reply->uid,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ }
+ else {
+ // Comments do not exist.
+ $node = db_query('SELECT uid, created FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetchObject();
+ db_update('node_comment_statistics')
+ ->fields(array(
+ 'cid' => 0,
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $node->created,
+ 'last_comment_name' => '',
+ 'last_comment_uid' => $node->uid,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ }
+}
+
+/**
+ * Generate sorting code.
+ *
+ * Consists of a leading character indicating length, followed by N digits
+ * with a numerical value in base 36 (alphadecimal). These codes can be sorted
+ * as strings without altering numerical order.
+ *
+ * It goes:
+ * 00, 01, 02, ..., 0y, 0z,
+ * 110, 111, ... , 1zy, 1zz,
+ * 2100, 2101, ..., 2zzy, 2zzz,
+ * 31000, 31001, ...
+ */
+function comment_int_to_alphadecimal($i = 0) {
+ $num = base_convert((int) $i, 10, 36);
+ $length = strlen($num);
+
+ return chr($length + ord('0') - 1) . $num;
+}
+
+/**
+ * Decode sorting code back to an integer.
+ *
+ * @see comment_int_to_alphadecimal()
+ */
+function comment_alphadecimal_to_int($c = '00') {
+ return base_convert(substr($c, 1), 36, 10);
+}
+
+/**
+ * Increment a sorting code to the next value.
+ *
+ * @see comment_int_to_alphadecimal()
+ */
+function comment_increment_alphadecimal($c = '00') {
+ return comment_int_to_alphadecimal(comment_alphadecimal_to_int($c) + 1);
+}
+
+/**
+ * Implements hook_action_info().
+ */
+function comment_action_info() {
+ return array(
+ 'comment_publish_action' => array(
+ 'label' => t('Publish comment'),
+ 'type' => 'comment',
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('comment_presave', 'comment_insert', 'comment_update'),
+ ),
+ 'comment_unpublish_action' => array(
+ 'label' => t('Unpublish comment'),
+ 'type' => 'comment',
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('comment_presave', 'comment_insert', 'comment_update'),
+ ),
+ 'comment_unpublish_by_keyword_action' => array(
+ 'label' => t('Unpublish comment containing keyword(s)'),
+ 'type' => 'comment',
+ 'configurable' => TRUE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('comment_presave', 'comment_insert', 'comment_update'),
+ ),
+ 'comment_save_action' => array(
+ 'label' => t('Save comment'),
+ 'type' => 'comment',
+ 'configurable' => FALSE,
+ 'triggers' => array('comment_insert', 'comment_update'),
+ ),
+ );
+}
+
+/**
+ * Publishes a comment.
+ *
+ * @param $comment
+ * An optional comment object.
+ * @param array $context
+ * Array with components:
+ * - 'cid': Comment ID. Required if $comment is not given.
+ *
+ * @ingroup actions
+ */
+function comment_publish_action($comment, $context = array()) {
+ if (isset($comment->subject)) {
+ $subject = $comment->subject;
+ $comment->status = COMMENT_PUBLISHED;
+ }
+ else {
+ $cid = $context['cid'];
+ $subject = db_query('SELECT subject FROM {comment} WHERE cid = :cid', array(':cid' => $cid))->fetchField();
+ db_update('comment')
+ ->fields(array('status' => COMMENT_PUBLISHED))
+ ->condition('cid', $cid)
+ ->execute();
+ }
+ watchdog('action', 'Published comment %subject.', array('%subject' => $subject));
+}
+
+/**
+ * Unpublishes a comment.
+ *
+ * @param $comment
+ * An optional comment object.
+ * @param array $context
+ * Array with components:
+ * - 'cid': Comment ID. Required if $comment is not given.
+ *
+ * @ingroup actions
+ */
+function comment_unpublish_action($comment, $context = array()) {
+ if (isset($comment->subject)) {
+ $subject = $comment->subject;
+ $comment->status = COMMENT_NOT_PUBLISHED;
+ }
+ else {
+ $cid = $context['cid'];
+ $subject = db_query('SELECT subject FROM {comment} WHERE cid = :cid', array(':cid' => $cid))->fetchField();
+ db_update('comment')
+ ->fields(array('status' => COMMENT_NOT_PUBLISHED))
+ ->condition('cid', $cid)
+ ->execute();
+ }
+ watchdog('action', 'Unpublished comment %subject.', array('%subject' => $subject));
+}
+
+/**
+ * Unpublishes a comment if it contains certain keywords.
+ *
+ * @param $comment
+ * Comment object to modify.
+ * @param array $context
+ * Array with components:
+ * - 'keywords': Keywords to look for. If the comment contains at least one
+ * of the keywords, it is unpublished.
+ *
+ * @ingroup actions
+ * @see comment_unpublish_by_keyword_action_form()
+ * @see comment_unpublish_by_keyword_action_submit()
+ */
+function comment_unpublish_by_keyword_action($comment, $context) {
+ foreach ($context['keywords'] as $keyword) {
+ $text = drupal_render($comment);
+ if (strpos($text, $keyword) !== FALSE) {
+ $comment->status = COMMENT_NOT_PUBLISHED;
+ watchdog('action', 'Unpublished comment %subject.', array('%subject' => $comment->subject));
+ break;
+ }
+ }
+}
+
+/**
+ * Form builder; Prepare a form for blacklisted keywords.
+ *
+ * @ingroup forms
+ * @see comment_unpublish_by_keyword_action()
+ * @see comment_unpublish_by_keyword_action_submit()
+ */
+function comment_unpublish_by_keyword_action_form($context) {
+ $form['keywords'] = array(
+ '#title' => t('Keywords'),
+ '#type' => 'textarea',
+ '#description' => t('The comment will be unpublished if it contains any of the phrases above. Use a case-sensitive, comma-separated list of phrases. Example: funny, bungee jumping, "Company, Inc."'),
+ '#default_value' => isset($context['keywords']) ? drupal_implode_tags($context['keywords']) : '',
+ );
+
+ return $form;
+}
+
+/**
+ * Process comment_unpublish_by_keyword_action_form form submissions.
+ *
+ * @see comment_unpublish_by_keyword_action()
+ */
+function comment_unpublish_by_keyword_action_submit($form, $form_state) {
+ return array('keywords' => drupal_explode_tags($form_state['values']['keywords']));
+}
+
+/**
+ * Saves a comment.
+ *
+ * @ingroup actions
+ */
+function comment_save_action($comment) {
+ comment_save($comment);
+ cache_clear_all();
+ watchdog('action', 'Saved comment %title', array('%title' => $comment->subject));
+}
+
+/**
+ * Implements hook_ranking().
+ */
+function comment_ranking() {
+ return array(
+ 'comments' => array(
+ 'title' => t('Number of comments'),
+ 'join' => array(
+ 'type' => 'LEFT',
+ 'table' => 'node_comment_statistics',
+ 'alias' => 'node_comment_statistics',
+ 'on' => 'node_comment_statistics.nid = i.sid',
+ ),
+ // Inverse law that maps the highest reply count on the site to 1 and 0 to 0.
+ 'score' => '2.0 - 2.0 / (1.0 + node_comment_statistics.comment_count * CAST(:scale AS DECIMAL))',
+ 'arguments' => array(':scale' => variable_get('node_cron_comments_scale', 0)),
+ ),
+ );
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function comment_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'comment',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post', 'sioct:Comment'),
+ 'title' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'created' => array(
+ 'predicates' => array('dc:date', 'dc:created'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'changed' => array(
+ 'predicates' => array('dc:modified'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'comment_body' => array(
+ 'predicates' => array('content:encoded'),
+ ),
+ 'pid' => array(
+ 'predicates' => array('sioc:reply_of'),
+ 'type' => 'rel',
+ ),
+ 'uid' => array(
+ 'predicates' => array('sioc:has_creator'),
+ 'type' => 'rel',
+ ),
+ 'name' => array(
+ 'predicates' => array('foaf:name'),
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_file_download_access().
+ */
+function comment_file_download_access($field, $entity_type, $entity) {
+ if ($entity_type == 'comment') {
+ if (user_access('access comments') && $entity->status == COMMENT_PUBLISHED || user_access('administer comments')) {
+ $node = node_load($entity->nid);
+ return node_access('view', $node);
+ }
+ return FALSE;
+ }
+}
diff --git a/core/modules/comment/comment.pages.inc b/core/modules/comment/comment.pages.inc
new file mode 100644
index 000000000000..7e88bffcb52d
--- /dev/null
+++ b/core/modules/comment/comment.pages.inc
@@ -0,0 +1,119 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the comment module.
+ */
+
+/**
+ * This function is responsible for generating a comment reply form.
+ * There are several cases that have to be handled, including:
+ * - replies to comments
+ * - replies to nodes
+ * - attempts to reply to nodes that can no longer accept comments
+ * - respecting access permissions ('access comments', 'post comments', etc.)
+ *
+ * The node or comment that is being replied to must appear above the comment
+ * form to provide the user context while authoring the comment.
+ *
+ * @param $node
+ * Every comment belongs to a node. This is that node.
+ *
+ * @param $pid
+ * Some comments are replies to other comments. In those cases, $pid is the parent
+ * comment's cid.
+ *
+ * @return
+ * The rendered parent node or comment plus the new comment form.
+ */
+function comment_reply($node, $pid = NULL) {
+ // Set the breadcrumb trail.
+ drupal_set_breadcrumb(array(l(t('Home'), NULL), l($node->title, 'node/' . $node->nid)));
+ $op = isset($_POST['op']) ? $_POST['op'] : '';
+ $build = array();
+
+ // The user is previewing a comment prior to submitting it.
+ if ($op == t('Preview')) {
+ if (user_access('post comments')) {
+ $build['comment_form'] = drupal_get_form("comment_node_{$node->type}_form", (object) array('pid' => $pid, 'nid' => $node->nid));
+ }
+ else {
+ drupal_set_message(t('You are not authorized to post comments.'), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ }
+ else {
+ // $pid indicates that this is a reply to a comment.
+ if ($pid) {
+ if (user_access('access comments')) {
+ // Load the comment whose cid = $pid
+ $comment = db_query('SELECT c.*, u.uid, u.name AS registered_name, u.signature, u.signature_format, u.picture, u.data FROM {comment} c INNER JOIN {users} u ON c.uid = u.uid WHERE c.cid = :cid AND c.status = :status', array(
+ ':cid' => $pid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchObject();
+ if ($comment) {
+ // If that comment exists, make sure that the current comment and the
+ // parent comment both belong to the same parent node.
+ if ($comment->nid != $node->nid) {
+ // Attempting to reply to a comment not belonging to the current nid.
+ drupal_set_message(t('The comment you are replying to does not exist.'), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ // Display the parent comment
+ $comment->node_type = 'comment_node_' . $node->type;
+ field_attach_load('comment', array($comment->cid => $comment));
+ $comment->name = $comment->uid ? $comment->registered_name : $comment->name;
+ $build['comment_parent'] = comment_view($comment, $node);
+ }
+ else {
+ drupal_set_message(t('The comment you are replying to does not exist.'), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ }
+ else {
+ drupal_set_message(t('You are not authorized to view comments.'), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ }
+ // This is the case where the comment is in response to a node. Display the node.
+ elseif (user_access('access content')) {
+ $build['comment_node'] = node_view($node);
+ }
+
+ // Should we show the reply box?
+ if ($node->comment != COMMENT_NODE_OPEN) {
+ drupal_set_message(t("This discussion is closed: you can't post new comments."), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ elseif (user_access('post comments')) {
+ $edit = array('nid' => $node->nid, 'pid' => $pid);
+ $build['comment_form'] = drupal_get_form("comment_node_{$node->type}_form", (object) $edit);
+ }
+ else {
+ drupal_set_message(t('You are not authorized to post comments.'), 'error');
+ drupal_goto("node/$node->nid");
+ }
+ }
+
+ return $build;
+}
+
+/**
+ * Menu callback; publish specified comment.
+ *
+ * @param $cid
+ * A comment identifier.
+ */
+function comment_approve($cid) {
+ if (!isset($_GET['token']) || !drupal_valid_token($_GET['token'], "comment/$cid/approve")) {
+ return MENU_ACCESS_DENIED;
+ }
+ if ($comment = comment_load($cid)) {
+ $comment->status = COMMENT_PUBLISHED;
+ comment_save($comment);
+
+ drupal_set_message(t('Comment approved.'));
+ drupal_goto('node/' . $comment->nid);
+ }
+ return MENU_NOT_FOUND;
+}
diff --git a/core/modules/comment/comment.test b/core/modules/comment/comment.test
new file mode 100644
index 000000000000..2e96ba3151cd
--- /dev/null
+++ b/core/modules/comment/comment.test
@@ -0,0 +1,1989 @@
+<?php
+
+/**
+ * @file
+ * Tests for comment.module.
+ */
+
+class CommentHelperCase extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $web_user;
+ protected $node;
+
+ function setUp() {
+ parent::setUp('comment', 'search');
+ // Create users and test node.
+ $this->admin_user = $this->drupalCreateUser(array('administer content types', 'administer comments', 'administer blocks'));
+ $this->web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'create article content', 'edit own comments'));
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'uid' => $this->web_user->uid));
+ }
+
+ /**
+ * Post comment.
+ *
+ * @param $node
+ * Node to post comment on.
+ * @param $comment
+ * Comment body.
+ * @param $subject
+ * Comment subject.
+ * @param $contact
+ * Set to NULL for no contact info, TRUE to ignore success checking, and
+ * array of values to set contact info.
+ */
+ function postComment($node, $comment, $subject = '', $contact = NULL) {
+ $langcode = LANGUAGE_NONE;
+ $edit = array();
+ $edit['comment_body[' . $langcode . '][0][value]'] = $comment;
+
+ $preview_mode = variable_get('comment_preview_article', DRUPAL_OPTIONAL);
+ $subject_mode = variable_get('comment_subject_field_article', 1);
+
+ // Must get the page before we test for fields.
+ if ($node !== NULL) {
+ $this->drupalGet('comment/reply/' . $node->nid);
+ }
+
+ if ($subject_mode == TRUE) {
+ $edit['subject'] = $subject;
+ }
+ else {
+ $this->assertNoFieldByName('subject', '', t('Subject field not found.'));
+ }
+
+ if ($contact !== NULL && is_array($contact)) {
+ $edit += $contact;
+ }
+ switch ($preview_mode) {
+ case DRUPAL_REQUIRED:
+ // Preview required so no save button should be found.
+ $this->assertNoFieldByName('op', t('Save'), t('Save button not found.'));
+ $this->drupalPost(NULL, $edit, t('Preview'));
+ // Don't break here so that we can test post-preview field presence and
+ // function below.
+ case DRUPAL_OPTIONAL:
+ $this->assertFieldByName('op', t('Preview'), t('Preview button found.'));
+ $this->assertFieldByName('op', t('Save'), t('Save button found.'));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ break;
+
+ case DRUPAL_DISABLED:
+ $this->assertNoFieldByName('op', t('Preview'), t('Preview button not found.'));
+ $this->assertFieldByName('op', t('Save'), t('Save button found.'));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ break;
+ }
+ $match = array();
+ // Get comment ID
+ preg_match('/#comment-([0-9]+)/', $this->getURL(), $match);
+
+ // Get comment.
+ if ($contact !== TRUE) { // If true then attempting to find error message.
+ if ($subject) {
+ $this->assertText($subject, 'Comment subject posted.');
+ }
+ $this->assertText($comment, 'Comment body posted.');
+ $this->assertTrue((!empty($match) && !empty($match[1])), t('Comment id found.'));
+ }
+
+ if (isset($match[1])) {
+ return (object) array('id' => $match[1], 'subject' => $subject, 'comment' => $comment);
+ }
+ }
+
+ /**
+ * Checks current page for specified comment.
+ *
+ * @param object $comment Comment object.
+ * @param boolean $reply The comment is a reply to another comment.
+ * @return boolean Comment found.
+ */
+ function commentExists($comment, $reply = FALSE) {
+ if ($comment && is_object($comment)) {
+ $regex = '/' . ($reply ? '<div class="indented">(.*?)' : '');
+ $regex .= '<a id="comment-' . $comment->id . '"(.*?)'; // Comment anchor.
+ $regex .= '<div(.*?)'; // Begin in comment div.
+ $regex .= $comment->subject . '(.*?)'; // Match subject.
+ $regex .= $comment->comment . '(.*?)'; // Match comment.
+ $regex .= '/s';
+
+ return (boolean)preg_match($regex, $this->drupalGetContent());
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Delete comment.
+ *
+ * @param object $comment
+ * Comment to delete.
+ */
+ function deleteComment($comment) {
+ $this->drupalPost('comment/' . $comment->id . '/delete', array(), t('Delete'));
+ $this->assertText(t('The comment and all its replies have been deleted.'), t('Comment deleted.'));
+ }
+
+ /**
+ * Set comment subject setting.
+ *
+ * @param boolean $enabled
+ * Subject value.
+ */
+ function setCommentSubject($enabled) {
+ $this->setCommentSettings('comment_subject_field', ($enabled ? '1' : '0'), 'Comment subject ' . ($enabled ? 'enabled' : 'disabled') . '.');
+ }
+
+ /**
+ * Set comment preview setting.
+ *
+ * @param int $mode
+ * Preview value.
+ */
+ function setCommentPreview($mode) {
+ switch ($mode) {
+ case DRUPAL_DISABLED:
+ $mode_text = 'disabled';
+ break;
+
+ case DRUPAL_OPTIONAL:
+ $mode_text = 'optional';
+ break;
+
+ case DRUPAL_REQUIRED:
+ $mode_text = 'required';
+ break;
+ }
+ $this->setCommentSettings('comment_preview', $mode, 'Comment preview ' . $mode_text . '.');
+ }
+
+ /**
+ * Set comment form location setting.
+ *
+ * @param boolean $enabled
+ * Form value.
+ */
+ function setCommentForm($enabled) {
+ $this->setCommentSettings('comment_form_location', ($enabled ? COMMENT_FORM_BELOW : COMMENT_FORM_SEPARATE_PAGE), 'Comment controls ' . ($enabled ? 'enabled' : 'disabled') . '.');
+ }
+
+ /**
+ * Set comment anonymous level setting.
+ *
+ * @param integer $level
+ * Anonymous level.
+ */
+ function setCommentAnonymous($level) {
+ $this->setCommentSettings('comment_anonymous', $level, 'Anonymous commenting set to level ' . $level . '.');
+ }
+
+ /**
+ * Set the default number of comments per page.
+ *
+ * @param integer $comments
+ * Comments per page value.
+ */
+ function setCommentsPerPage($number) {
+ $this->setCommentSettings('comment_default_per_page', $number, 'Number of comments per page set to ' . $number . '.');
+ }
+
+ /**
+ * Set comment setting for article content type.
+ *
+ * @param string $name
+ * Name of variable.
+ * @param string $value
+ * Value of variable.
+ * @param string $message
+ * Status message to display.
+ */
+ function setCommentSettings($name, $value, $message) {
+ variable_set($name . '_article', $value);
+ $this->assertTrue(TRUE, t($message)); // Display status message.
+ }
+
+ /**
+ * Check for contact info.
+ *
+ * @return boolean Contact info is available.
+ */
+ function commentContactInfoAvailable() {
+ return preg_match('/(input).*?(name="name").*?(input).*?(name="mail").*?(input).*?(name="homepage")/s', $this->drupalGetContent());
+ }
+
+ /**
+ * Perform the specified operation on the specified comment.
+ *
+ * @param object $comment
+ * Comment to perform operation on.
+ * @param string $operation
+ * Operation to perform.
+ * @param boolean $aproval
+ * Operation is found on approval page.
+ */
+ function performCommentOperation($comment, $operation, $approval = FALSE) {
+ $edit = array();
+ $edit['operation'] = $operation;
+ $edit['comments[' . $comment->id . ']'] = TRUE;
+ $this->drupalPost('admin/content/comment' . ($approval ? '/approval' : ''), $edit, t('Update'));
+
+ if ($operation == 'delete') {
+ $this->drupalPost(NULL, array(), t('Delete comments'));
+ $this->assertRaw(format_plural(1, 'Deleted 1 comment.', 'Deleted @count comments.'), t('Operation "' . $operation . '" was performed on comment.'));
+ }
+ else {
+ $this->assertText(t('The update has been performed.'), t('Operation "' . $operation . '" was performed on comment.'));
+ }
+ }
+
+ /**
+ * Get the comment ID for an unapproved comment.
+ *
+ * @param string $subject
+ * Comment subject to find.
+ * @return integer
+ * Comment id.
+ */
+ function getUnapprovedComment($subject) {
+ $this->drupalGet('admin/content/comment/approval');
+ preg_match('/href="(.*?)#comment-([^"]+)"(.*?)>(' . $subject . ')/', $this->drupalGetContent(), $match);
+
+ return $match[2];
+ }
+
+ /**
+ * Tests new comment marker.
+ */
+ public function testCommentNewCommentsIndicator() {
+ // Test if the right links are displayed when no comment is present for the
+ // node.
+ $this->drupalLogin($this->admin_user);
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'comment' => COMMENT_NODE_OPEN));
+ $this->drupalGet('node');
+ $this->assertNoLink(t('@count comments', array('@count' => 0)));
+ $this->assertNoLink(t('@count new comments', array('@count' => 0)));
+ $this->assertLink(t('Read more'));
+ $count = $this->xpath('//div[@id=:id]/div[@class=:class]/ul/li', array(':id' => 'node-' . $this->node->nid, ':class' => 'link-wrapper'));
+ $this->assertTrue(count($count) == 1, t('One child found'));
+
+ // Create a new comment. This helper function may be run with different
+ // comment settings so use comment_save() to avoid complex setup.
+ $comment = (object) array(
+ 'cid' => NULL,
+ 'nid' => $this->node->nid,
+ 'node_type' => $this->node->type,
+ 'pid' => 0,
+ 'uid' => $this->loggedInUser->uid,
+ 'status' => COMMENT_PUBLISHED,
+ 'subject' => $this->randomName(),
+ 'hostname' => ip_address(),
+ 'language' => LANGUAGE_NONE,
+ 'comment_body' => array(LANGUAGE_NONE => array($this->randomName())),
+ );
+ comment_save($comment);
+ $this->drupalLogout();
+
+ // Log in with 'web user' and check comment links.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node');
+ $this->assertLink(t('1 new comment'));
+ $this->clickLink(t('1 new comment'));
+ $this->assertRaw('<a id="new"></a>', t('Found "new" marker.'));
+ $this->assertTrue($this->xpath('//a[@id=:new]/following-sibling::a[1][@id=:comment_id]', array(':new' => 'new', ':comment_id' => 'comment-1')), t('The "new" anchor is positioned at the right comment.'));
+
+ // Test if "new comment" link is correctly removed.
+ $this->drupalGet('node');
+ $this->assertLink(t('1 comment'));
+ $this->assertLink(t('Read more'));
+ $this->assertNoLink(t('1 new comment'));
+ $this->assertNoLink(t('@count new comments', array('@count' => 0)));
+ $count = $this->xpath('//div[@id=:id]/div[@class=:class]/ul/li', array(':id' => 'node-' . $this->node->nid, ':class' => 'link-wrapper'));
+ $this->assertTrue(count($count) == 2, print_r($count, TRUE));
+ }
+}
+
+class CommentInterfaceTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment interface',
+ 'description' => 'Test comment user interfaces.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test comment interface.
+ */
+ function testCommentInterface() {
+ $langcode = LANGUAGE_NONE;
+ // Set comments to have subject and preview disabled.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(FALSE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+ $this->drupalLogout();
+
+ // Post comment #1 without subject or preview.
+ $this->drupalLogin($this->web_user);
+ $comment_text = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text);
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue($this->commentExists($comment), t('Comment found.'));
+ $by_viewer_class = $this->xpath('//a[@id=:comment_id]/following-sibling::div[1][contains(@class, "comment-by-viewer")]', array(':comment_id' => 'comment-' . $comment->id));
+ $this->assertTrue(!empty($by_viewer_class), t('HTML class for comments by viewer found.'));
+
+ // Set comments to have subject and preview to required.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_REQUIRED);
+ $this->drupalLogout();
+
+ // Create comment #2 that allows subject and requires preview.
+ $this->drupalLogin($this->web_user);
+ $subject_text = $this->randomName();
+ $comment_text = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text, $subject_text, TRUE);
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue($this->commentExists($comment), t('Comment found.'));
+
+ // Check comment display.
+ $this->drupalGet('node/' . $this->node->nid . '/' . $comment->id);
+ $this->assertText($subject_text, t('Individual comment subject found.'));
+ $this->assertText($comment_text, t('Individual comment body found.'));
+
+ // Set comments to have subject and preview to optional.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_OPTIONAL);
+
+ // Test changing the comment author to "Anonymous".
+ $this->drupalGet('comment/' . $comment->id . '/edit');
+ $comment = $this->postComment(NULL, $comment->comment, $comment->subject, array('name' => ''));
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue(empty($comment_loaded->name) && $comment_loaded->uid == 0, t('Comment author successfully changed to anonymous.'));
+
+ // Test changing the comment author to an unverified user.
+ $random_name = $this->randomName();
+ $this->drupalGet('comment/' . $comment->id . '/edit');
+ $comment = $this->postComment(NULL, $comment->comment, $comment->subject, array('name' => $random_name));
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertText($random_name . ' (' . t('not verified') . ')', t('Comment author successfully changed to an unverified user.'));
+
+ // Test changing the comment author to a verified user.
+ $this->drupalGet('comment/' . $comment->id . '/edit');
+ $comment = $this->postComment(NULL, $comment->comment, $comment->subject, array('name' => $this->web_user->name));
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue($comment_loaded->name == $this->web_user->name && $comment_loaded->uid == $this->web_user->uid, t('Comment author successfully changed to a registered user.'));
+
+ $this->drupalLogout();
+
+ // Reply to comment #2 creating comment #3 with optional preview and no
+ // subject though field enabled.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('comment/reply/' . $this->node->nid . '/' . $comment->id);
+ $this->assertText($subject_text, t('Individual comment-reply subject found.'));
+ $this->assertText($comment_text, t('Individual comment-reply body found.'));
+ $reply = $this->postComment(NULL, $this->randomName(), '', TRUE);
+ $reply_loaded = comment_load($reply->id);
+ $this->assertTrue($this->commentExists($reply, TRUE), t('Reply found.'));
+ $this->assertEqual($comment->id, $reply_loaded->pid, t('Pid of a reply to a comment is set correctly.'));
+ $this->assertEqual(rtrim($comment_loaded->thread, '/') . '.00/', $reply_loaded->thread, t('Thread of reply grows correctly.'));
+
+ // Second reply to comment #3 creating comment #4.
+ $this->drupalGet('comment/reply/' . $this->node->nid . '/' . $comment->id);
+ $this->assertText($subject_text, t('Individual comment-reply subject found.'));
+ $this->assertText($comment_text, t('Individual comment-reply body found.'));
+ $reply = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+ $reply_loaded = comment_load($reply->id);
+ $this->assertTrue($this->commentExists($reply, TRUE), t('Second reply found.'));
+ $this->assertEqual(rtrim($comment_loaded->thread, '/') . '.01/', $reply_loaded->thread, t('Thread of second reply grows correctly.'));
+
+ // Edit reply.
+ $this->drupalGet('comment/' . $reply->id . '/edit');
+ $reply = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+ $this->assertTrue($this->commentExists($reply, TRUE), t('Modified reply found.'));
+
+ // Correct link count
+ $this->drupalGet('node');
+ $this->assertRaw('4 comments', t('Link to the 4 comments exist.'));
+
+ // Confirm a new comment is posted to the correct page.
+ $this->setCommentsPerPage(2);
+ $comment_new_page = $this->postComment($this->node, $this->randomName(), $this->randomName(), TRUE);
+ $this->assertTrue($this->commentExists($comment_new_page), t('Page one exists. %s'));
+ $this->drupalGet('node/' . $this->node->nid, array('query' => array('page' => 1)));
+ $this->assertTrue($this->commentExists($reply, TRUE), t('Page two exists. %s'));
+ $this->setCommentsPerPage(50);
+
+ // Create comment #5 to assert HTML class.
+ $comment = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $by_node_author_class = $this->xpath('//a[@id=:comment_id]/following-sibling::div[1][contains(@class, "comment-by-node-author")]', array(':comment_id' => 'comment-' . $comment->id));
+ $this->assertTrue(!empty($by_node_author_class), t('HTML class for node author found.'));
+
+ // Attempt to post to node with comments disabled.
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'comment' => COMMENT_NODE_HIDDEN));
+ $this->assertTrue($this->node, t('Article node created.'));
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertText('This discussion is closed', t('Posting to node with comments disabled'));
+ $this->assertNoField('edit-comment', t('Comment body field found.'));
+
+ // Attempt to post to node with read-only comments.
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'comment' => COMMENT_NODE_CLOSED));
+ $this->assertTrue($this->node, t('Article node created.'));
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertText('This discussion is closed', t('Posting to node with comments read-only'));
+ $this->assertNoField('edit-comment', t('Comment body field found.'));
+
+ // Attempt to post to node with comments enabled (check field names etc).
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'comment' => COMMENT_NODE_OPEN));
+ $this->assertTrue($this->node, t('Article node created.'));
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertNoText('This discussion is closed', t('Posting to node with comments enabled'));
+ $this->assertField('edit-comment-body-' . $langcode . '-0-value', t('Comment body field found.'));
+
+ // Delete comment and make sure that reply is also removed.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $this->deleteComment($comment);
+ $this->deleteComment($comment_new_page);
+
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertFalse($this->commentExists($comment), t('Comment not found.'));
+ $this->assertFalse($this->commentExists($reply, TRUE), t('Reply not found.'));
+
+ // Enabled comment form on node page.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentForm(TRUE);
+ $this->drupalLogout();
+
+ // Submit comment through node form.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $this->node->nid);
+ $form_comment = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+ $this->assertTrue($this->commentExists($form_comment), t('Form comment found.'));
+
+ // Disable comment form on node page.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentForm(FALSE);
+ }
+
+ /**
+ * Tests the node comment statistics.
+ */
+ function testCommentNodeCommentStatistics() {
+ $langcode = LANGUAGE_NONE;
+ // Set comments to have subject and preview disabled.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(FALSE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+ $this->drupalLogout();
+
+ // Creates a second user to post comments.
+ $this->web_user2 = $this->drupalCreateUser(array('access comments', 'post comments', 'create article content', 'edit own comments'));
+
+ // Checks the initial values of node comment statistics with no comment.
+ $node = node_load($this->node->nid);
+ $this->assertEqual($node->last_comment_timestamp, $this->node->created, t('The initial value of node last_comment_timestamp is the node created date.'));
+ $this->assertEqual($node->last_comment_name, NULL, t('The initial value of node last_comment_name is NULL.'));
+ $this->assertEqual($node->last_comment_uid, $this->web_user->uid, t('The initial value of node last_comment_uid is the node uid.'));
+ $this->assertEqual($node->comment_count, 0, t('The initial value of node comment_count is zero.'));
+
+ // Post comment #1 as web_user2.
+ $this->drupalLogin($this->web_user2);
+ $comment_text = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text);
+ $comment_loaded = comment_load($comment->id);
+
+ // Checks the new values of node comment statistics with comment #1.
+ // The node needs to be reloaded with a node_load_multiple cache reset.
+ $node = node_load($this->node->nid, NULL, TRUE);
+ $this->assertEqual($node->last_comment_name, NULL, t('The value of node last_comment_name is NULL.'));
+ $this->assertEqual($node->last_comment_uid, $this->web_user2->uid, t('The value of node last_comment_uid is the comment #1 uid.'));
+ $this->assertEqual($node->comment_count, 1, t('The value of node comment_count is 1.'));
+
+ // Prepare for anonymous comment submission (comment approval enabled).
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ $this->drupalLogin($this->admin_user);
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => FALSE,
+ ));
+ // Ensure that the poster can leave some contact info.
+ $this->setCommentAnonymous('1');
+ $this->drupalLogout();
+
+ // Post comment #2 as anonymous (comment approval enabled).
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $anonymous_comment = $this->postComment($this->node, $this->randomName(), '', TRUE);
+ $comment_unpublished_loaded = comment_load($anonymous_comment->id);
+
+ // Checks the new values of node comment statistics with comment #2 and
+ // ensure they haven't changed since the comment has not been moderated.
+ // The node needs to be reloaded with a node_load_multiple cache reset.
+ $node = node_load($this->node->nid, NULL, TRUE);
+ $this->assertEqual($node->last_comment_name, NULL, t('The value of node last_comment_name is still NULL.'));
+ $this->assertEqual($node->last_comment_uid, $this->web_user2->uid, t('The value of node last_comment_uid is still the comment #1 uid.'));
+ $this->assertEqual($node->comment_count, 1, t('The value of node comment_count is still 1.'));
+
+ // Prepare for anonymous comment submission (no approval required).
+ $this->drupalLogin($this->admin_user);
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => TRUE,
+ ));
+ $this->drupalLogout();
+
+ // Post comment #3 as anonymous.
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $anonymous_comment = $this->postComment($this->node, $this->randomName(), '', array('name' => $this->randomName()));
+ $comment_loaded = comment_load($anonymous_comment->id);
+
+ // Checks the new values of node comment statistics with comment #3.
+ // The node needs to be reloaded with a node_load_multiple cache reset.
+ $node = node_load($this->node->nid, NULL, TRUE);
+ $this->assertEqual($node->last_comment_name, $comment_loaded->name, t('The value of node last_comment_name is the name of the anonymous user.'));
+ $this->assertEqual($node->last_comment_uid, 0, t('The value of node last_comment_uid is zero.'));
+ $this->assertEqual($node->comment_count, 2, t('The value of node comment_count is 2.'));
+ }
+
+ /**
+ * Tests comment links.
+ *
+ * The output of comment links depends on various environment conditions:
+ * - Various Comment module configuration settings, user registration
+ * settings, and user access permissions.
+ * - Whether the user is authenticated or not, and whether any comments exist.
+ *
+ * To account for all possible cases, this test creates permutations of all
+ * possible conditions and tests the expected appearance of comment links in
+ * each environment.
+ */
+ function testCommentLinks() {
+ // Bartik theme alters comment links, so use a different theme.
+ theme_enable(array('stark'));
+ variable_set('theme_default', 'stark');
+
+ // Remove additional user permissions from $this->web_user added by setUp(),
+ // since this test is limited to anonymous and authenticated roles only.
+ user_role_delete(key($this->web_user->roles));
+
+ // Matrix of possible environmental conditions and configuration settings.
+ // See setEnvironment() for details.
+ $conditions = array(
+ 'authenticated' => array(FALSE, TRUE),
+ 'comment count' => array(FALSE, TRUE),
+ 'access comments' => array(0, 1),
+ 'post comments' => array(0, 1),
+ 'form' => array(COMMENT_FORM_BELOW, COMMENT_FORM_SEPARATE_PAGE),
+ // USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL is irrelevant for this
+ // test; there is only a difference between open and closed registration.
+ 'user_register' => array(USER_REGISTER_VISITORS, USER_REGISTER_ADMINISTRATORS_ONLY),
+ // @todo Complete test coverage for:
+ //'comments' => array(COMMENT_NODE_OPEN, COMMENT_NODE_CLOSED, COMMENT_NODE_HIDDEN),
+ //// COMMENT_ANONYMOUS_MUST_CONTACT is irrelevant for this test.
+ //'contact ' => array(COMMENT_ANONYMOUS_MAY_CONTACT, COMMENT_ANONYMOUS_MAYNOT_CONTACT),
+ );
+
+ $environments = $this->generatePermutations($conditions);
+ foreach ($environments as $info) {
+ $this->assertCommentLinks($info);
+ }
+ }
+
+ /**
+ * Re-configures the environment, module settings, and user permissions.
+ *
+ * @param $info
+ * An associative array describing the environment to setup:
+ * - Environment conditions:
+ * - authenticated: Boolean whether to test with $this->web_user or
+ * anonymous.
+ * - comment count: Boolean whether to test with a new/unread comment on
+ * $this->node or no comments.
+ * - Configuration settings:
+ * - form: COMMENT_FORM_BELOW or COMMENT_FORM_SEPARATE_PAGE.
+ * - user_register: USER_REGISTER_ADMINISTRATORS_ONLY or
+ * USER_REGISTER_VISITORS.
+ * - contact: COMMENT_ANONYMOUS_MAY_CONTACT or
+ * COMMENT_ANONYMOUS_MAYNOT_CONTACT.
+ * - comments: COMMENT_NODE_OPEN, COMMENT_NODE_CLOSED, or
+ * COMMENT_NODE_HIDDEN.
+ * - User permissions:
+ * These are granted or revoked for the user, according to the
+ * 'authenticated' flag above. Pass 0 or 1 as parameter values. See
+ * user_role_change_permissions().
+ * - access comments
+ * - post comments
+ * - skip comment approval
+ * - edit own comments
+ */
+ function setEnvironment(array $info) {
+ static $current;
+
+ // Apply defaults to initial environment.
+ if (!isset($current)) {
+ $current = array(
+ 'authenticated' => FALSE,
+ 'comment count' => FALSE,
+ 'form' => COMMENT_FORM_BELOW,
+ 'user_register' => USER_REGISTER_VISITORS,
+ 'contact' => COMMENT_ANONYMOUS_MAY_CONTACT,
+ 'comments' => COMMENT_NODE_OPEN,
+ 'access comments' => 0,
+ 'post comments' => 0,
+ // Enabled by default, because it's irrelevant for this test.
+ 'skip comment approval' => 1,
+ 'edit own comments' => 0,
+ );
+ }
+ // Complete new environment with current environment.
+ $info = array_merge($current, $info);
+
+ // Change environment conditions.
+ if ($current['authenticated'] != $info['authenticated']) {
+ if ($this->loggedInUser) {
+ $this->drupalLogout();
+ }
+ else {
+ $this->drupalLogin($this->web_user);
+ }
+ }
+ if ($current['comment count'] != $info['comment count']) {
+ if ($info['comment count']) {
+ // Create a comment via CRUD API functionality, since
+ // $this->postComment() relies on actual user permissions.
+ $comment = (object) array(
+ 'cid' => NULL,
+ 'nid' => $this->node->nid,
+ 'node_type' => $this->node->type,
+ 'pid' => 0,
+ 'uid' => 0,
+ 'status' => COMMENT_PUBLISHED,
+ 'subject' => $this->randomName(),
+ 'hostname' => ip_address(),
+ 'language' => LANGUAGE_NONE,
+ 'comment_body' => array(LANGUAGE_NONE => array($this->randomName())),
+ );
+ comment_save($comment);
+ $this->comment = $comment;
+
+ // comment_num_new() relies on node_last_viewed(), so ensure that no one
+ // has seen the node of this comment.
+ db_delete('history')->condition('nid', $this->node->nid)->execute();
+ }
+ else {
+ $cids = db_query("SELECT cid FROM {comment}")->fetchCol();
+ comment_delete_multiple($cids);
+ unset($this->comment);
+ }
+ }
+
+ // Change comment settings.
+ variable_set('comment_form_location_' . $this->node->type, $info['form']);
+ variable_set('comment_anonymous_' . $this->node->type, $info['contact']);
+ if ($this->node->comment != $info['comments']) {
+ $this->node->comment = $info['comments'];
+ node_save($this->node);
+ }
+
+ // Change user settings.
+ variable_set('user_register', $info['user_register']);
+
+ // Change user permissions.
+ $rid = ($this->loggedInUser ? DRUPAL_AUTHENTICATED_RID : DRUPAL_ANONYMOUS_RID);
+ $perms = array_intersect_key($info, array('access comments' => 1, 'post comments' => 1, 'skip comment approval' => 1, 'edit own comments' => 1));
+ user_role_change_permissions($rid, $perms);
+
+ // Output verbose debugging information.
+ // @see DrupalTestCase::error()
+ $t_form = array(
+ COMMENT_FORM_BELOW => 'below',
+ COMMENT_FORM_SEPARATE_PAGE => 'separate page',
+ );
+ $t_contact = array(
+ COMMENT_ANONYMOUS_MAY_CONTACT => 'optional',
+ COMMENT_ANONYMOUS_MAYNOT_CONTACT => 'disabled',
+ COMMENT_ANONYMOUS_MUST_CONTACT => 'required',
+ );
+ $t_comments = array(
+ COMMENT_NODE_OPEN => 'open',
+ COMMENT_NODE_CLOSED => 'closed',
+ COMMENT_NODE_HIDDEN => 'hidden',
+ );
+ $verbose = $info;
+ $verbose['form'] = $t_form[$info['form']];
+ $verbose['contact'] = $t_contact[$info['contact']];
+ $verbose['comments'] = $t_comments[$info['comments']];
+ $message = t('Changed environment:<pre>@verbose</pre>', array(
+ '@verbose' => var_export($verbose, TRUE),
+ ));
+ $this->assert('debug', $message, 'Debug');
+
+ // Update current environment.
+ $current = $info;
+
+ return $info;
+ }
+
+ /**
+ * Asserts that comment links appear according to the passed environment setup.
+ *
+ * @param $info
+ * An associative array describing the environment to pass to
+ * setEnvironment().
+ */
+ function assertCommentLinks(array $info) {
+ $info = $this->setEnvironment($info);
+
+ $nid = $this->node->nid;
+
+ foreach (array('', "node/$nid") as $path) {
+ $this->drupalGet($path);
+
+ // User is allowed to view comments.
+ if ($info['access comments']) {
+ if ($path == '') {
+ // In teaser view, a link containing the comment count is always
+ // expected.
+ if ($info['comment count']) {
+ $this->assertLink(t('1 comment'));
+
+ // For logged in users, a link containing the amount of new/unread
+ // comments is expected.
+ // See important note about comment_num_new() below.
+ if ($this->loggedInUser && isset($this->comment) && !isset($this->comment->seen)) {
+ $this->assertLink(t('1 new comment'));
+ $this->comment->seen = TRUE;
+ }
+ }
+ }
+ }
+ else {
+ $this->assertNoLink(t('1 comment'));
+ $this->assertNoLink(t('1 new comment'));
+ }
+ // comment_num_new() is based on node views, so comments are marked as
+ // read when a node is viewed, regardless of whether we have access to
+ // comments.
+ if ($path == "node/$nid" && $this->loggedInUser && isset($this->comment)) {
+ $this->comment->seen = TRUE;
+ }
+
+ // User is not allowed to post comments.
+ if (!$info['post comments']) {
+ $this->assertNoLink('Add new comment');
+
+ // Anonymous users should see a note to log in or register in case
+ // authenticated users are allowed to post comments.
+ // @see theme_comment_post_forbidden()
+ if (!$this->loggedInUser) {
+ if (user_access('post comments', $this->web_user)) {
+ // The note depends on whether users are actually able to register.
+ if ($info['user_register']) {
+ $this->assertText('Log in or register to post comments');
+ }
+ else {
+ $this->assertText('Log in to post comments');
+ }
+ }
+ else {
+ $this->assertNoText('Log in or register to post comments');
+ $this->assertNoText('Log in to post comments');
+ }
+ }
+ }
+ // User is allowed to post comments.
+ else {
+ $this->assertNoText('Log in or register to post comments');
+
+ // "Add new comment" is always expected, except when there are no
+ // comments or if the user cannot see them.
+ if ($path == "node/$nid" && $info['form'] == COMMENT_FORM_BELOW && (!$info['comment count'] || !$info['access comments'])) {
+ $this->assertNoLink('Add new comment');
+ }
+ else {
+ $this->assertLink('Add new comment');
+ }
+
+ // Also verify that the comment form appears according to the configured
+ // location.
+ if ($path == "node/$nid") {
+ $elements = $this->xpath('//form[@id=:id]', array(':id' => 'comment-form'));
+ if ($info['form'] == COMMENT_FORM_BELOW) {
+ $this->assertTrue(count($elements), t('Comment form found below.'));
+ }
+ else {
+ $this->assertFalse(count($elements), t('Comment form not found below.'));
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Test previewing comments.
+ */
+class CommentPreviewTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment preview',
+ 'description' => 'Test comment preview.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test comment preview.
+ */
+ function testCommentPreview() {
+ $langcode = LANGUAGE_NONE;
+
+ // As admin user, configure comment settings.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentPreview(DRUPAL_OPTIONAL);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+ $this->drupalLogout();
+
+ // As web user, fill in node creation form and preview node.
+ $this->drupalLogin($this->web_user);
+ $edit = array();
+ $edit['subject'] = $this->randomName(8);
+ $edit['comment_body[' . $langcode . '][0][value]'] = $this->randomName(16);
+ $this->drupalPost('node/' . $this->node->nid, $edit, t('Preview'));
+
+ // Check that the preview is displaying the title and body.
+ $this->assertTitle(t('Preview comment | Drupal'), t('Page title is "Preview comment".'));
+ $this->assertText($edit['subject'], t('Subject displayed.'));
+ $this->assertText($edit['comment_body[' . $langcode . '][0][value]'], t('Comment displayed.'));
+
+ // Check that the title and body fields are displayed with the correct values.
+ $this->assertFieldByName('subject', $edit['subject'], t('Subject field displayed.'));
+ $this->assertFieldByName('comment_body[' . $langcode . '][0][value]', $edit['comment_body[' . $langcode . '][0][value]'], t('Comment field displayed.'));
+ }
+
+ /**
+ * Test comment edit, preview, and save.
+ */
+ function testCommentEditPreviewSave() {
+ $langcode = LANGUAGE_NONE;
+ $web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'skip comment approval'));
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentPreview(DRUPAL_OPTIONAL);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+
+ $edit = array();
+ $edit['subject'] = $this->randomName(8);
+ $edit['comment_body[' . $langcode . '][0][value]'] = $this->randomName(16);
+ $edit['name'] = $web_user->name;
+ $edit['date'] = '2008-03-02 17:23 +0300';
+ $raw_date = strtotime($edit['date']);
+ $expected_text_date = format_date($raw_date);
+ $expected_form_date = format_date($raw_date, 'custom', 'Y-m-d H:i O');
+ $comment = $this->postComment($this->node, $edit['subject'], $edit['comment_body[' . $langcode . '][0][value]'], TRUE);
+ $this->drupalPost('comment/' . $comment->id . '/edit', $edit, t('Preview'));
+
+ // Check that the preview is displaying the subject, comment, author and date correctly.
+ $this->assertTitle(t('Preview comment | Drupal'), t('Page title is "Preview comment".'));
+ $this->assertText($edit['subject'], t('Subject displayed.'));
+ $this->assertText($edit['comment_body[' . $langcode . '][0][value]'], t('Comment displayed.'));
+ $this->assertText($edit['name'], t('Author displayed.'));
+ $this->assertText($expected_text_date, t('Date displayed.'));
+
+ // Check that the subject, comment, author and date fields are displayed with the correct values.
+ $this->assertFieldByName('subject', $edit['subject'], t('Subject field displayed.'));
+ $this->assertFieldByName('comment_body[' . $langcode . '][0][value]', $edit['comment_body[' . $langcode . '][0][value]'], t('Comment field displayed.'));
+ $this->assertFieldByName('name', $edit['name'], t('Author field displayed.'));
+ $this->assertFieldByName('date', $edit['date'], t('Date field displayed.'));
+
+ // Check that saving a comment produces a success message.
+ $this->drupalPost('comment/' . $comment->id . '/edit', $edit, t('Save'));
+ $this->assertText(t('Your comment has been posted.'), t('Comment posted.'));
+
+ // Check that the comment fields are correct after loading the saved comment.
+ $this->drupalGet('comment/' . $comment->id . '/edit');
+ $this->assertFieldByName('subject', $edit['subject'], t('Subject field displayed.'));
+ $this->assertFieldByName('comment_body[' . $langcode . '][0][value]', $edit['comment_body[' . $langcode . '][0][value]'], t('Comment field displayed.'));
+ $this->assertFieldByName('name', $edit['name'], t('Author field displayed.'));
+ $this->assertFieldByName('date', $expected_form_date, t('Date field displayed.'));
+
+ // Submit the form using the displayed values.
+ $displayed = array();
+ $displayed['subject'] = (string) current($this->xpath("//input[@id='edit-subject']/@value"));
+ $displayed['comment_body[' . $langcode . '][0][value]'] = (string) current($this->xpath("//textarea[@id='edit-comment-body-" . $langcode . "-0-value']"));
+ $displayed['name'] = (string) current($this->xpath("//input[@id='edit-name']/@value"));
+ $displayed['date'] = (string) current($this->xpath("//input[@id='edit-date']/@value"));
+ $this->drupalPost('comment/' . $comment->id . '/edit', $displayed, t('Save'));
+
+ // Check that the saved comment is still correct.
+ $comment_loaded = comment_load($comment->id);
+ $this->assertEqual($comment_loaded->subject, $edit['subject'], t('Subject loaded.'));
+ $this->assertEqual($comment_loaded->comment_body[$langcode][0]['value'], $edit['comment_body[' . $langcode . '][0][value]'], t('Comment body loaded.'));
+ $this->assertEqual($comment_loaded->name, $edit['name'], t('Name loaded.'));
+ $this->assertEqual($comment_loaded->created, $raw_date, t('Date loaded.'));
+
+ }
+
+}
+
+class CommentAnonymous extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Anonymous comments',
+ 'description' => 'Test anonymous comments.',
+ 'group' => 'Comment',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ }
+
+ /**
+ * Test anonymous comment functionality.
+ */
+ function testAnonymous() {
+ $this->drupalLogin($this->admin_user);
+ // Enabled anonymous user comments.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => TRUE,
+ ));
+ $this->setCommentAnonymous('0'); // Ensure that doesn't require contact info.
+ $this->drupalLogout();
+
+ // Post anonymous comment without contact info.
+ $anonymous_comment1 = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $this->assertTrue($this->commentExists($anonymous_comment1), t('Anonymous comment without contact info found.'));
+ $anonymous_class = $this->xpath('//a[@id=:comment_id]/following-sibling::div[1][contains(@class, "comment-by-anonymous")]', array(':comment_id' => 'comment-' . $anonymous_comment1->id));
+ $this->assertTrue(!empty($anonymous_class), t('HTML class for anonymous comments found.'));
+
+ // Allow contact info.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentAnonymous('1');
+
+ // Attempt to edit anonymous comment.
+ $this->drupalGet('comment/' . $anonymous_comment1->id . '/edit');
+ $edited_comment = $this->postComment(NULL, $this->randomName(), $this->randomName());
+ $this->assertTrue($this->commentExists($edited_comment, FALSE), t('Modified reply found.'));
+ $this->drupalLogout();
+
+ // Post anonymous comment with contact info (optional).
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertTrue($this->commentContactInfoAvailable(), t('Contact information available.'));
+
+ $anonymous_comment2 = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $this->assertTrue($this->commentExists($anonymous_comment2), t('Anonymous comment with contact info (optional) found.'));
+
+ // Ensure anonymous users cannot post in the name of registered users.
+ $langcode = LANGUAGE_NONE;
+ $edit = array(
+ 'name' => $this->admin_user->name,
+ 'mail' => $this->randomName() . '@example.com',
+ 'subject' => $this->randomName(),
+ "comment_body[$langcode][0][value]" => $this->randomName(),
+ );
+ $this->drupalPost('comment/reply/' . $this->node->nid, $edit, t('Save'));
+ $this->assertText(t('The name you used belongs to a registered user.'));
+
+ // Require contact info.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentAnonymous('2');
+ $this->drupalLogout();
+
+ // Try to post comment with contact info (required).
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertTrue($this->commentContactInfoAvailable(), t('Contact information available.'));
+
+ $anonymous_comment3 = $this->postComment($this->node, $this->randomName(), $this->randomName(), TRUE);
+ $this->assertText(t('E-mail field is required.'), t('E-mail required.')); // Name should have 'Anonymous' for value by default.
+ $this->assertFalse($this->commentExists($anonymous_comment3), t('Anonymous comment with contact info (required) not found.'));
+
+ // Post comment with contact info (required).
+ $author_name = $this->randomName();
+ $author_mail = $this->randomName() . '@example.com';
+ $anonymous_comment3 = $this->postComment($this->node, $this->randomName(), $this->randomName(), array('name' => $author_name, 'mail' => $author_mail));
+ $this->assertTrue($this->commentExists($anonymous_comment3), t('Anonymous comment with contact info (required) found.'));
+
+ // Make sure the user data appears correctly when editing the comment.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('comment/' . $anonymous_comment3->id . '/edit');
+ $this->assertRaw($author_name, t("The anonymous user's name is correct when editing the comment."));
+ $this->assertRaw($author_mail, t("The anonymous user's e-mail address is correct when editing the comment."));
+
+ // Unpublish comment.
+ $this->performCommentOperation($anonymous_comment3, 'unpublish');
+
+ $this->drupalGet('admin/content/comment/approval');
+ $this->assertRaw('comments[' . $anonymous_comment3->id . ']', t('Comment was unpublished.'));
+
+ // Publish comment.
+ $this->performCommentOperation($anonymous_comment3, 'publish', TRUE);
+
+ $this->drupalGet('admin/content/comment');
+ $this->assertRaw('comments[' . $anonymous_comment3->id . ']', t('Comment was published.'));
+
+ // Delete comment.
+ $this->performCommentOperation($anonymous_comment3, 'delete');
+
+ $this->drupalGet('admin/content/comment');
+ $this->assertNoRaw('comments[' . $anonymous_comment3->id . ']', t('Comment was deleted.'));
+ $this->drupalLogout();
+
+ // Reset.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => FALSE,
+ 'post comments' => FALSE,
+ 'skip comment approval' => FALSE,
+ ));
+
+ // Attempt to view comments while disallowed.
+ // NOTE: if authenticated user has permission to post comments, then a
+ // "Login or register to post comments" type link may be shown.
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertNoPattern('@<h2[^>]*>Comments</h2>@', t('Comments were not displayed.'));
+ $this->assertNoLink('Add new comment', t('Link to add comment was found.'));
+
+ // Attempt to view node-comment form while disallowed.
+ $this->drupalGet('comment/reply/' . $this->node->nid);
+ $this->assertText('You are not authorized to post comments', t('Error attempting to post comment.'));
+ $this->assertNoFieldByName('subject', '', t('Subject field not found.'));
+ $this->assertNoFieldByName("comment_body[$langcode][0][value]", '', t('Comment field not found.'));
+
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => FALSE,
+ 'skip comment approval' => FALSE,
+ ));
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertPattern('@<h2[^>]*>Comments</h2>@', t('Comments were displayed.'));
+ $this->assertLink('Log in', 1, t('Link to log in was found.'));
+ $this->assertLink('register', 1, t('Link to register was found.'));
+
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => FALSE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => TRUE,
+ ));
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertNoPattern('@<h2[^>]*>Comments</h2>@', t('Comments were not displayed.'));
+ $this->assertFieldByName('subject', '', t('Subject field found.'));
+ $this->assertFieldByName("comment_body[$langcode][0][value]", '', t('Comment field found.'));
+
+ $this->drupalGet('comment/reply/' . $this->node->nid . '/' . $anonymous_comment3->id);
+ $this->assertText('You are not authorized to view comments', t('Error attempting to post reply.'));
+ $this->assertNoText($author_name, t('Comment not displayed.'));
+ }
+}
+
+/**
+ * Verify pagination of comments.
+ */
+class CommentPagerTest extends CommentHelperCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment paging settings',
+ 'description' => 'Test paging of comments and their settings.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Confirm comment paging works correctly with flat and threaded comments.
+ */
+ function testCommentPaging() {
+ $this->drupalLogin($this->admin_user);
+
+ // Set comment variables.
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+
+ // Create a node and three comments.
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $comments = array();
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_FLAT, t('Comment paging changed.'));
+
+ // Set comments to one per page so that we are able to test paging without
+ // needing to insert large numbers of comments.
+ $this->setCommentsPerPage(1);
+
+ // Check the first page of the node, and confirm the correct comments are
+ // shown.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw(t('next'), t('Paging links found.'));
+ $this->assertTrue($this->commentExists($comments[0]), t('Comment 1 appears on page 1.'));
+ $this->assertFalse($this->commentExists($comments[1]), t('Comment 2 does not appear on page 1.'));
+ $this->assertFalse($this->commentExists($comments[2]), t('Comment 3 does not appear on page 1.'));
+
+ // Check the second page.
+ $this->drupalGet('node/' . $node->nid, array('query' => array('page' => 1)));
+ $this->assertTrue($this->commentExists($comments[1]), t('Comment 2 appears on page 2.'));
+ $this->assertFalse($this->commentExists($comments[0]), t('Comment 1 does not appear on page 2.'));
+ $this->assertFalse($this->commentExists($comments[2]), t('Comment 3 does not appear on page 2.'));
+
+ // Check the third page.
+ $this->drupalGet('node/' . $node->nid, array('query' => array('page' => 2)));
+ $this->assertTrue($this->commentExists($comments[2]), t('Comment 3 appears on page 3.'));
+ $this->assertFalse($this->commentExists($comments[0]), t('Comment 1 does not appear on page 3.'));
+ $this->assertFalse($this->commentExists($comments[1]), t('Comment 2 does not appear on page 3.'));
+
+ // Post a reply to the oldest comment and test again.
+ $replies = array();
+ $oldest_comment = reset($comments);
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $oldest_comment->id);
+ $reply = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ $this->setCommentsPerPage(2);
+ // We are still in flat view - the replies should not be on the first page,
+ // even though they are replies to the oldest comment.
+ $this->drupalGet('node/' . $node->nid, array('query' => array('page' => 0)));
+ $this->assertFalse($this->commentExists($reply, TRUE), t('In flat mode, reply does not appear on page 1.'));
+
+ // If we switch to threaded mode, the replies on the oldest comment
+ // should be bumped to the first page and comment 6 should be bumped
+ // to the second page.
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Switched to threaded mode.'));
+ $this->drupalGet('node/' . $node->nid, array('query' => array('page' => 0)));
+ $this->assertTrue($this->commentExists($reply, TRUE), t('In threaded mode, reply appears on page 1.'));
+ $this->assertFalse($this->commentExists($comments[1]), t('In threaded mode, comment 2 has been bumped off of page 1.'));
+
+ // If (# replies > # comments per page) in threaded expanded view,
+ // the overage should be bumped.
+ $reply2 = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+ $this->drupalGet('node/' . $node->nid, array('query' => array('page' => 0)));
+ $this->assertFalse($this->commentExists($reply2, TRUE), t('In threaded mode where # replies > # comments per page, the newest reply does not appear on page 1.'));
+
+ $this->drupalLogout();
+ }
+
+ /**
+ * Test comment ordering and threading.
+ */
+ function testCommentOrderingThreading() {
+ $this->drupalLogin($this->admin_user);
+
+ // Set comment variables.
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+
+ // Display all the comments on the same page.
+ $this->setCommentsPerPage(1000);
+
+ // Create a node and three comments.
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $comments = array();
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the second comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[1]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the first comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[0]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the last comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[2]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the second comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[3]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // At this point, the comment tree is:
+ // - 0
+ // - 4
+ // - 1
+ // - 3
+ // - 6
+ // - 2
+ // - 5
+
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_FLAT, t('Comment paging changed.'));
+
+ $expected_order = array(
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ );
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertCommentOrder($comments, $expected_order);
+
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Switched to threaded mode.'));
+
+ $expected_order = array(
+ 0,
+ 4,
+ 1,
+ 3,
+ 6,
+ 2,
+ 5,
+ );
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertCommentOrder($comments, $expected_order);
+ }
+
+ /**
+ * Helper function: assert that the comments are displayed in the correct order.
+ *
+ * @param $comments
+ * And array of comments.
+ * @param $expected_order
+ * An array of keys from $comments describing the expected order.
+ */
+ function assertCommentOrder(array $comments, array $expected_order) {
+ $expected_cids = array();
+
+ // First, rekey the expected order by cid.
+ foreach ($expected_order as $key) {
+ $expected_cids[] = $comments[$key]->id;
+ }
+
+ $comment_anchors = $this->xpath('//a[starts-with(@id,"comment-")]');
+ $result_order = array();
+ foreach ($comment_anchors as $anchor) {
+ $result_order[] = substr($anchor['id'], 8);
+ }
+
+ return $this->assertIdentical($expected_cids, $result_order, t('Comment order: expected @expected, returned @returned.', array('@expected' => implode(',', $expected_cids), '@returned' => implode(',', $result_order))));
+ }
+
+ /**
+ * Test comment_new_page_count().
+ */
+ function testCommentNewPageIndicator() {
+ $this->drupalLogin($this->admin_user);
+
+ // Set comment variables.
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+
+ // Set comments to one per page so that we are able to test paging without
+ // needing to insert large numbers of comments.
+ $this->setCommentsPerPage(1);
+
+ // Create a node and three comments.
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $comments = array();
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the second comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[1]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the first comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[0]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the last comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $comments[2]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // At this point, the comment tree is:
+ // - 0
+ // - 4
+ // - 1
+ // - 3
+ // - 2
+ // - 5
+
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_FLAT, t('Comment paging changed.'));
+
+ $expected_pages = array(
+ 1 => 5, // Page of comment 5
+ 2 => 4, // Page of comment 4
+ 3 => 3, // Page of comment 3
+ 4 => 2, // Page of comment 2
+ 5 => 1, // Page of comment 1
+ 6 => 0, // Page of comment 0
+ );
+
+ $node = node_load($node->nid);
+ foreach ($expected_pages as $new_replies => $expected_page) {
+ $returned = comment_new_page_count($node->comment_count, $new_replies, $node);
+ $returned_page = is_array($returned) ? $returned['page'] : 0;
+ $this->assertIdentical($expected_page, $returned_page, t('Flat mode, @new replies: expected page @expected, returned page @returned.', array('@new' => $new_replies, '@expected' => $expected_page, '@returned' => $returned_page)));
+ }
+
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Switched to threaded mode.'));
+
+ $expected_pages = array(
+ 1 => 5, // Page of comment 5
+ 2 => 1, // Page of comment 4
+ 3 => 1, // Page of comment 4
+ 4 => 1, // Page of comment 4
+ 5 => 1, // Page of comment 4
+ 6 => 0, // Page of comment 0
+ );
+
+ $node = node_load($node->nid);
+ foreach ($expected_pages as $new_replies => $expected_page) {
+ $returned = comment_new_page_count($node->comment_count, $new_replies, $node);
+ $returned_page = is_array($returned) ? $returned['page'] : 0;
+ $this->assertEqual($expected_page, $returned_page, t('Threaded mode, @new replies: expected page @expected, returned page @returned.', array('@new' => $new_replies, '@expected' => $expected_page, '@returned' => $returned_page)));
+ }
+ }
+}
+
+/**
+ * Tests comments with node access.
+ *
+ * See http://drupal.org/node/886752 -- verify there is no PostgreSQL error when
+ * viewing a node with threaded comments (a comment and a reply), if a node
+ * access module is in use.
+ */
+class CommentNodeAccessTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment node access',
+ 'description' => 'Test comment viewing with node access.',
+ 'group' => 'Comment',
+ );
+ }
+
+ function setUp() {
+ DrupalWebTestCase::setUp('comment', 'search', 'node_access_test');
+ node_access_rebuild();
+
+ // Create users and test node.
+ $this->admin_user = $this->drupalCreateUser(array('administer content types', 'administer comments', 'administer blocks'));
+ $this->web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'create article content', 'edit own comments', 'node test view'));
+ $this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'uid' => $this->web_user->uid));
+ }
+
+ /**
+ * Test that threaded comments can be viewed.
+ */
+ function testThreadedCommentView() {
+ $langcode = LANGUAGE_NONE;
+ // Set comments to have subject required and preview disabled.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentPreview(DRUPAL_DISABLED);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+ $this->drupalLogout();
+
+ // Post comment.
+ $this->drupalLogin($this->web_user);
+ $comment_text = $this->randomName();
+ $comment_subject = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text, $comment_subject);
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue($this->commentExists($comment), t('Comment found.'));
+
+ // Check comment display.
+ $this->drupalGet('node/' . $this->node->nid . '/' . $comment->id);
+ $this->assertText($comment_subject, t('Individual comment subject found.'));
+ $this->assertText($comment_text, t('Individual comment body found.'));
+
+ // Reply to comment, creating second comment.
+ $this->drupalGet('comment/reply/' . $this->node->nid . '/' . $comment->id);
+ $reply_text = $this->randomName();
+ $reply_subject = $this->randomName();
+ $reply = $this->postComment(NULL, $reply_text, $reply_subject, TRUE);
+ $reply_loaded = comment_load($reply->id);
+ $this->assertTrue($this->commentExists($reply, TRUE), t('Reply found.'));
+
+ // Go to the node page and verify comment and reply are visible.
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertText($comment_text);
+ $this->assertText($comment_subject);
+ $this->assertText($reply_text);
+ $this->assertText($reply_subject);
+ }
+}
+
+class CommentApprovalTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment approval',
+ 'description' => 'Test comment approval functionality.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test comment approval functionality through admin/content/comment.
+ */
+ function testApprovalAdminInterface() {
+ // Set anonymous comments to require approval.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => FALSE,
+ ));
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentAnonymous('0'); // Ensure that doesn't require contact info.
+
+ // Test that the comments page loads correctly when there are no comments
+ $this->drupalGet('admin/content/comment');
+ $this->assertText(t('No comments available.'));
+
+ $this->drupalLogout();
+
+ // Post anonymous comment without contact info.
+ $subject = $this->randomName();
+ $body = $this->randomName();
+ $this->postComment($this->node, $body, $subject, TRUE); // Set $contact to true so that it won't check for id and message.
+ $this->assertText(t('Your comment has been queued for review by site administrators and will be published after approval.'), t('Comment requires approval.'));
+
+ // Get unapproved comment id.
+ $this->drupalLogin($this->admin_user);
+ $anonymous_comment4 = $this->getUnapprovedComment($subject);
+ $anonymous_comment4 = (object) array('id' => $anonymous_comment4, 'subject' => $subject, 'comment' => $body);
+ $this->drupalLogout();
+
+ $this->assertFalse($this->commentExists($anonymous_comment4), t('Anonymous comment was not published.'));
+
+ // Approve comment.
+ $this->drupalLogin($this->admin_user);
+ $this->performCommentOperation($anonymous_comment4, 'publish', TRUE);
+ $this->drupalLogout();
+
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertTrue($this->commentExists($anonymous_comment4), t('Anonymous comment visible.'));
+
+ // Post 2 anonymous comments without contact info.
+ $comments[] = $this->postComment($this->node, $this->randomName(), $this->randomName(), TRUE);
+ $comments[] = $this->postComment($this->node, $this->randomName(), $this->randomName(), TRUE);
+
+ // Publish multiple comments in one operation.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('admin/content/comment/approval');
+ $this->assertText(t('Unapproved comments (@count)', array('@count' => 2)), t('Two unapproved comments waiting for approval.'));
+ $edit = array(
+ "comments[{$comments[0]->id}]" => 1,
+ "comments[{$comments[1]->id}]" => 1,
+ );
+ $this->drupalPost(NULL, $edit, t('Update'));
+ $this->assertText(t('Unapproved comments (@count)', array('@count' => 0)), t('All comments were approved.'));
+
+ // Delete multiple comments in one operation.
+ $edit = array(
+ 'operation' => 'delete',
+ "comments[{$comments[0]->id}]" => 1,
+ "comments[{$comments[1]->id}]" => 1,
+ "comments[{$anonymous_comment4->id}]" => 1,
+ );
+ $this->drupalPost(NULL, $edit, t('Update'));
+ $this->assertText(t('Are you sure you want to delete these comments and all their children?'), t('Confirmation required.'));
+ $this->drupalPost(NULL, $edit, t('Delete comments'));
+ $this->assertText(t('No comments available.'), t('All comments were deleted.'));
+ }
+
+ /**
+ * Test comment approval functionality through node interface.
+ */
+ function testApprovalNodeInterface() {
+ // Set anonymous comments to require approval.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => FALSE,
+ ));
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentAnonymous('0'); // Ensure that doesn't require contact info.
+ $this->drupalLogout();
+
+ // Post anonymous comment without contact info.
+ $subject = $this->randomName();
+ $body = $this->randomName();
+ $this->postComment($this->node, $body, $subject, TRUE); // Set $contact to true so that it won't check for id and message.
+ $this->assertText(t('Your comment has been queued for review by site administrators and will be published after approval.'), t('Comment requires approval.'));
+
+ // Get unapproved comment id.
+ $this->drupalLogin($this->admin_user);
+ $anonymous_comment4 = $this->getUnapprovedComment($subject);
+ $anonymous_comment4 = (object) array('id' => $anonymous_comment4, 'subject' => $subject, 'comment' => $body);
+ $this->drupalLogout();
+
+ $this->assertFalse($this->commentExists($anonymous_comment4), t('Anonymous comment was not published.'));
+
+ // Approve comment.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('comment/1/approve');
+ $this->assertResponse(403, t('Forged comment approval was denied.'));
+ $this->drupalGet('comment/1/approve', array('query' => array('token' => 'forged')));
+ $this->assertResponse(403, t('Forged comment approval was denied.'));
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->clickLink(t('approve'));
+ $this->drupalLogout();
+
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertTrue($this->commentExists($anonymous_comment4), t('Anonymous comment visible.'));
+ }
+}
+
+/**
+ * Functional tests for the comment module blocks.
+ */
+class CommentBlockFunctionalTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment blocks',
+ 'description' => 'Test comment block functionality.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test the recent comments block.
+ */
+ function testRecentCommentBlock() {
+ $this->drupalLogin($this->admin_user);
+
+ // Set the block to a region to confirm block is available.
+ $edit = array(
+ 'blocks[comment_recent][region]' => 'sidebar_first',
+ );
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block saved to first sidebar region.'));
+
+ // Set block title and variables.
+ $block = array(
+ 'title' => $this->randomName(),
+ 'comment_block_count' => 2,
+ );
+ $this->drupalPost('admin/structure/block/manage/comment/recent/configure', $block, t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block saved.'));
+
+ // Add some test comments, one without a subject.
+ $comment1 = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $comment2 = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $comment3 = $this->postComment($this->node, $this->randomName());
+
+ // Test that a user without the 'access comments' permission cannot see the
+ // block.
+ $this->drupalLogout();
+ user_role_revoke_permissions(DRUPAL_ANONYMOUS_RID, array('access comments'));
+ // drupalCreateNode() does not automatically flush content caches unlike
+ // posting a node from a node form.
+ cache_clear_all();
+ $this->drupalGet('');
+ $this->assertNoText($block['title'], t('Block was not found.'));
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access comments'));
+
+ // Test that a user with the 'access comments' permission can see the
+ // block.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('');
+ $this->assertText($block['title'], t('Block was found.'));
+
+ // Test the only the 2 latest comments are shown and in the proper order.
+ $this->assertNoText($comment1->subject, t('Comment not found in block.'));
+ $this->assertText($comment2->subject, t('Comment found in block.'));
+ $this->assertText($comment3->comment, t('Comment found in block.'));
+ $this->assertTrue(strpos($this->drupalGetContent(), $comment3->comment) < strpos($this->drupalGetContent(), $comment2->subject), t('Comments were ordered correctly in block.'));
+
+ // Set the number of recent comments to show to 10.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $block = array(
+ 'comment_block_count' => 10,
+ );
+ $this->drupalPost('admin/structure/block/manage/comment/recent/configure', $block, t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block saved.'));
+
+ // Post an additional comment.
+ $comment4 = $this->postComment($this->node, $this->randomName(), $this->randomName());
+
+ // Test that all four comments are shown.
+ $this->assertText($comment1->subject, t('Comment found in block.'));
+ $this->assertText($comment2->subject, t('Comment found in block.'));
+ $this->assertText($comment3->comment, t('Comment found in block.'));
+ $this->assertText($comment4->subject, t('Comment found in block.'));
+
+ // Test that links to comments work when comments are across pages.
+ $this->setCommentsPerPage(1);
+ $this->drupalGet('');
+ $this->clickLink($comment1->subject);
+ $this->assertText($comment1->subject, t('Comment link goes to correct page.'));
+ $this->drupalGet('');
+ $this->clickLink($comment2->subject);
+ $this->assertText($comment2->subject, t('Comment link goes to correct page.'));
+ $this->clickLink($comment4->subject);
+ $this->assertText($comment4->subject, t('Comment link goes to correct page.'));
+ // Check that when viewing a comment page from a link to the comment, that
+ // rel="canonical" is added to the head of the document.
+ $this->assertRaw('<link rel="canonical"', t('Canonical URL was found in the HTML head'));
+ }
+}
+
+/**
+ * Unit tests for comment module integration with RSS feeds.
+ */
+class CommentRSSUnitTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment RSS',
+ 'description' => 'Test comments as part of an RSS feed.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test comments as part of an RSS feed.
+ */
+ function testCommentRSS() {
+ // Find comment in RSS feed.
+ $this->drupalLogin($this->web_user);
+ $comment = $this->postComment($this->node, $this->randomName(), $this->randomName());
+ $this->drupalGet('rss.xml');
+ $raw = '<comments>' . url('node/' . $this->node->nid, array('fragment' => 'comments', 'absolute' => TRUE)) . '</comments>';
+ $this->assertRaw($raw, t('Comments as part of RSS feed.'));
+
+ // Hide comments from RSS feed and check presence.
+ $this->node->comment = COMMENT_NODE_HIDDEN;
+ node_save($this->node);
+ $this->drupalGet('rss.xml');
+ $this->assertNoRaw($raw, t('Hidden comments is not a part of RSS feed.'));
+ }
+}
+
+
+/**
+ * Test to make sure comment content is rebuilt.
+ */
+class CommentContentRebuild extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment Rebuild',
+ 'description' => 'Test to make sure the comment content is rebuilt.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test to ensure that the comment's content array is rebuilt for every
+ * call to comment_view().
+ */
+ function testCommentRebuild() {
+ // Update the comment settings so preview isn't required.
+ $this->drupalLogin($this->admin_user);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentPreview(DRUPAL_OPTIONAL);
+ $this->drupalLogout();
+
+ // Log in as the web user and add the comment.
+ $this->drupalLogin($this->web_user);
+ $subject_text = $this->randomName();
+ $comment_text = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text, $subject_text, TRUE);
+ $comment_loaded = comment_load($comment->id);
+ $this->assertTrue($this->commentExists($comment), t('Comment found.'));
+
+ // Add the property to the content array and then see if it still exists on build.
+ $comment_loaded->content['test_property'] = array('#value' => $this->randomString());
+ $built_content = comment_view($comment_loaded, $this->node);
+
+ // This means that the content was rebuilt as the added test property no longer exists.
+ $this->assertFalse(isset($built_content['test_property']), t('Comment content was emptied before being built.'));
+ }
+}
+
+/**
+ * Test comment token replacement in strings.
+ */
+class CommentTokenReplaceTestCase extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check comment token replacement.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Creates a comment, then tests the tokens generated from it.
+ */
+ function testCommentTokenReplacement() {
+ global $language;
+ $url_options = array(
+ 'absolute' => TRUE,
+ 'language' => $language,
+ );
+
+ $this->drupalLogin($this->admin_user);
+
+ // Set comment variables.
+ $this->setCommentSubject(TRUE);
+
+ // Create a node and a comment.
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $parent_comment = $this->postComment($node, $this->randomName(), $this->randomName(), TRUE);
+
+ // Post a reply to the comment.
+ $this->drupalGet('comment/reply/' . $node->nid . '/' . $parent_comment->id);
+ $child_comment = $this->postComment(NULL, $this->randomName(), $this->randomName());
+ $comment = comment_load($child_comment->id);
+ $comment->homepage = 'http://example.org/';
+
+ // Add HTML to ensure that sanitation of some fields tested directly.
+ $comment->subject = '<blink>Blinking Comment</blink>';
+ $instance = field_info_instance('comment', 'body', 'comment_body');
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[comment:cid]'] = $comment->cid;
+ $tests['[comment:hostname]'] = check_plain($comment->hostname);
+ $tests['[comment:name]'] = filter_xss($comment->name);
+ $tests['[comment:mail]'] = check_plain($this->admin_user->mail);
+ $tests['[comment:homepage]'] = check_url($comment->homepage);
+ $tests['[comment:title]'] = filter_xss($comment->subject);
+ $tests['[comment:body]'] = _text_sanitize($instance, LANGUAGE_NONE, $comment->comment_body[LANGUAGE_NONE][0], 'value');
+ $tests['[comment:url]'] = url('comment/' . $comment->cid, $url_options + array('fragment' => 'comment-' . $comment->cid));
+ $tests['[comment:edit-url]'] = url('comment/' . $comment->cid . '/edit', $url_options);
+ $tests['[comment:created:since]'] = format_interval(REQUEST_TIME - $comment->created, 2, $language->language);
+ $tests['[comment:changed:since]'] = format_interval(REQUEST_TIME - $comment->changed, 2, $language->language);
+ $tests['[comment:parent:cid]'] = $comment->pid;
+ $tests['[comment:parent:title]'] = check_plain($parent_comment->subject);
+ $tests['[comment:node:nid]'] = $comment->nid;
+ $tests['[comment:node:title]'] = check_plain($node->title);
+ $tests['[comment:author:uid]'] = $comment->uid;
+ $tests['[comment:author:name]'] = check_plain($this->admin_user->name);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('comment' => $comment), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized comment token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[comment:hostname]'] = $comment->hostname;
+ $tests['[comment:name]'] = $comment->name;
+ $tests['[comment:mail]'] = $this->admin_user->mail;
+ $tests['[comment:homepage]'] = $comment->homepage;
+ $tests['[comment:title]'] = $comment->subject;
+ $tests['[comment:body]'] = $comment->comment_body[LANGUAGE_NONE][0]['value'];
+ $tests['[comment:parent:title]'] = $parent_comment->subject;
+ $tests['[comment:node:title]'] = $node->title;
+ $tests['[comment:author:name]'] = $this->admin_user->name;
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('comment' => $comment), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized comment token %token replaced.', array('%token' => $input)));
+ }
+
+ // Load node so comment_count gets computed.
+ $node = node_load($node->nid);
+
+ // Generate comment tokens for the node (it has 2 comments, both new).
+ $tests = array();
+ $tests['[node:comment-count]'] = 2;
+ $tests['[node:comment-count-new]'] = 2;
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $node), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Node comment token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+/**
+ * Test actions provided by the comment module.
+ */
+class CommentActionsTestCase extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment actions',
+ 'description' => 'Test actions provided by the comment module.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Test comment publish and unpublish actions.
+ */
+ function testCommentPublishUnpublishActions() {
+ $this->drupalLogin($this->web_user);
+ $comment_text = $this->randomName();
+ $subject = $this->randomName();
+ $comment = $this->postComment($this->node, $comment_text, $subject);
+ $comment = comment_load($comment->id);
+
+ // Unpublish a comment (direct form: doesn't actually save the comment).
+ comment_unpublish_action($comment);
+ $this->assertEqual($comment->status, COMMENT_NOT_PUBLISHED, t('Comment was unpublished'));
+ $this->assertWatchdogMessage('Unpublished comment %subject.', array('%subject' => $subject), t('Found watchdog message'));
+ $this->clearWatchdog();
+
+ // Unpublish a comment (indirect form: modify the comment in the database).
+ comment_unpublish_action(NULL, array('cid' => $comment->cid));
+ $this->assertEqual(comment_load($comment->cid)->status, COMMENT_NOT_PUBLISHED, t('Comment was unpublished'));
+ $this->assertWatchdogMessage('Unpublished comment %subject.', array('%subject' => $subject), t('Found watchdog message'));
+
+ // Publish a comment (direct form: doesn't actually save the comment).
+ comment_publish_action($comment);
+ $this->assertEqual($comment->status, COMMENT_PUBLISHED, t('Comment was published'));
+ $this->assertWatchdogMessage('Published comment %subject.', array('%subject' => $subject), t('Found watchdog message'));
+ $this->clearWatchdog();
+
+ // Publish a comment (indirect form: modify the comment in the database).
+ comment_publish_action(NULL, array('cid' => $comment->cid));
+ $this->assertEqual(comment_load($comment->cid)->status, COMMENT_PUBLISHED, t('Comment was published'));
+ $this->assertWatchdogMessage('Published comment %subject.', array('%subject' => $subject), t('Found watchdog message'));
+ $this->clearWatchdog();
+ }
+
+ /**
+ * Verify that a watchdog message has been entered.
+ *
+ * @param $watchdog_message
+ * The watchdog message.
+ * @param $variables
+ * The array of variables passed to watchdog().
+ * @param $message
+ * The assertion message.
+ */
+ function assertWatchdogMessage($watchdog_message, $variables, $message) {
+ $status = (bool) db_query_range("SELECT 1 FROM {watchdog} WHERE message = :message AND variables = :variables", 0, 1, array(':message' => $watchdog_message, ':variables' => serialize($variables)))->fetchField();
+ return $this->assert($status, $message);
+ }
+
+ /**
+ * Helper function: clear the watchdog.
+ */
+ function clearWatchdog() {
+ db_truncate('watchdog')->execute();
+ }
+}
+
+/**
+ * Test fields on comments.
+ */
+class CommentFieldsTest extends CommentHelperCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment fields',
+ 'description' => 'Tests fields on comments.',
+ 'group' => 'Comment',
+ );
+ }
+
+ /**
+ * Tests that the default 'comment_body' field is correctly added.
+ */
+ function testCommentDefaultFields() {
+ // Do not make assumptions on default node types created by the test
+ // install profile, and create our own.
+ $this->drupalCreateContentType(array('type' => 'test_node_type'));
+
+ // Check that the 'comment_body' field is present on all comment bundles.
+ $instances = field_info_instances('comment');
+ foreach (node_type_get_types() as $type_name => $info) {
+ $this->assertTrue(isset($instances['comment_node_' . $type_name]['comment_body']), t('The comment_body field is present for comments on type @type', array('@type' => $type_name)));
+
+ // Delete the instance along the way.
+ field_delete_instance($instances['comment_node_' . $type_name]['comment_body']);
+ }
+
+ // Check that the 'comment_body' field is deleted.
+ $field = field_info_field('comment_body');
+ $this->assertTrue(empty($field), t('The comment_body field was deleted'));
+
+ // Create a new content type.
+ $type_name = 'test_node_type_2';
+ $this->drupalCreateContentType(array('type' => $type_name));
+
+ // Check that the 'comment_body' field exists and has an instance on the
+ // new comment bundle.
+ $field = field_info_field('comment_body');
+ $this->assertTrue($field, t('The comment_body field exists'));
+ $instances = field_info_instances('comment');
+ $this->assertTrue(isset($instances['comment_node_' . $type_name]['comment_body']), t('The comment_body field is present for comments on type @type', array('@type' => $type_name)));
+ }
+
+ /**
+ * Test that comment module works when enabled after a content module.
+ */
+ function testCommentEnable() {
+ // Create a user to do module administration.
+ $this->admin_user = $this->drupalCreateUser(array('access administration pages', 'administer modules'));
+ $this->drupalLogin($this->admin_user);
+
+ // Disable the comment module.
+ $edit = array();
+ $edit['modules[Core][comment][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->resetAll();
+ $this->assertFalse(module_exists('comment'), t('Comment module disabled.'));
+
+ // Enable core content type modules (book, and poll).
+ $edit = array();
+ $edit['modules[Core][book][enable]'] = 'book';
+ $edit['modules[Core][poll][enable]'] = 'poll';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->resetAll();
+
+ // Now enable the comment module.
+ $edit = array();
+ $edit['modules[Core][comment][enable]'] = 'comment';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->resetAll();
+ $this->assertTrue(module_exists('comment'), t('Comment module enabled.'));
+
+ // Create nodes of each type.
+ $book_node = $this->drupalCreateNode(array('type' => 'book'));
+ $poll_node = $this->drupalCreateNode(array('type' => 'poll', 'active' => 1, 'runtime' => 0, 'choice' => array(array('chtext' => ''))));
+
+ $this->drupalLogout();
+
+ // Try to post a comment on each node. A failure will be triggered if the
+ // comment body is missing on one of these forms, due to postComment()
+ // asserting that the body is actually posted correctly.
+ $this->web_user = $this->drupalCreateUser(array('access content', 'access comments', 'post comments', 'skip comment approval'));
+ $this->drupalLogin($this->web_user);
+ $this->postComment($book_node, $this->randomName(), $this->randomName());
+ $this->postComment($poll_node, $this->randomName(), $this->randomName());
+ }
+
+ /**
+ * Test that comment module works correctly with plain text format.
+ */
+ function testCommentFormat() {
+ // Disable text processing for comments.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('instance[settings][text_processing]' => 0);
+ $this->drupalPost('admin/structure/types/manage/article/comment/fields/comment_body', $edit, t('Save settings'));
+
+ // Post a comment without an explicit subject.
+ $this->drupalLogin($this->web_user);
+ $edit = array('comment_body[und][0][value]' => $this->randomName(8));
+ $this->drupalPost('node/' . $this->node->nid, $edit, t('Save'));
+ }
+}
diff --git a/core/modules/comment/comment.tokens.inc b/core/modules/comment/comment.tokens.inc
new file mode 100644
index 000000000000..c495ec35d663
--- /dev/null
+++ b/core/modules/comment/comment.tokens.inc
@@ -0,0 +1,243 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for comment-related data.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function comment_token_info() {
+ $type = array(
+ 'name' => t('Comments'),
+ 'description' => t('Tokens for comments posted on the site.'),
+ 'needs-data' => 'comment',
+ );
+
+ // Comment-related tokens for nodes
+ $node['comment-count'] = array(
+ 'name' => t("Comment count"),
+ 'description' => t("The number of comments posted on a node."),
+ );
+ $node['comment-count-new'] = array(
+ 'name' => t("New comment count"),
+ 'description' => t("The number of comments posted on a node since the reader last viewed it."),
+ );
+
+ // Core comment tokens
+ $comment['cid'] = array(
+ 'name' => t("Comment ID"),
+ 'description' => t("The unique ID of the comment."),
+ );
+ $comment['hostname'] = array(
+ 'name' => t("IP Address"),
+ 'description' => t("The IP address of the computer the comment was posted from."),
+ );
+ $comment['name'] = array(
+ 'name' => t("Name"),
+ 'description' => t("The name left by the comment author."),
+ );
+ $comment['mail'] = array(
+ 'name' => t("Email address"),
+ 'description' => t("The email address left by the comment author."),
+ );
+ $comment['homepage'] = array(
+ 'name' => t("Home page"),
+ 'description' => t("The home page URL left by the comment author."),
+ );
+ $comment['title'] = array(
+ 'name' => t("Title"),
+ 'description' => t("The title of the comment."),
+ );
+ $comment['body'] = array(
+ 'name' => t("Content"),
+ 'description' => t("The formatted content of the comment itself."),
+ );
+ $comment['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The URL of the comment."),
+ );
+ $comment['edit-url'] = array(
+ 'name' => t("Edit URL"),
+ 'description' => t("The URL of the comment's edit page."),
+ );
+
+ // Chained tokens for comments
+ $comment['created'] = array(
+ 'name' => t("Date created"),
+ 'description' => t("The date the comment was posted."),
+ 'type' => 'date',
+ );
+ $comment['changed'] = array(
+ 'name' => t("Date changed"),
+ 'description' => t("The date the comment was most recently updated."),
+ 'type' => 'date',
+ );
+ $comment['parent'] = array(
+ 'name' => t("Parent"),
+ 'description' => t("The comment's parent, if comment threading is active."),
+ 'type' => 'comment',
+ );
+ $comment['node'] = array(
+ 'name' => t("Node"),
+ 'description' => t("The node the comment was posted to."),
+ 'type' => 'node',
+ );
+ $comment['author'] = array(
+ 'name' => t("Author"),
+ 'description' => t("The author of the comment, if they were logged in."),
+ 'type' => 'user',
+ );
+
+ return array(
+ 'types' => array('comment' => $type),
+ 'tokens' => array(
+ 'node' => $node,
+ 'comment' => $comment,
+ ),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function comment_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ $replacements = array();
+
+ if ($type == 'comment' && !empty($data['comment'])) {
+ $comment = $data['comment'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ // Simple key values on the comment.
+ case 'cid':
+ $replacements[$original] = $comment->cid;
+ break;
+
+ // Poster identity information for comments
+ case 'hostname':
+ $replacements[$original] = $sanitize ? check_plain($comment->hostname) : $comment->hostname;
+ break;
+
+ case 'name':
+ $name = ($comment->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $comment->name;
+ $replacements[$original] = $sanitize ? filter_xss($name) : $name;
+ break;
+
+ case 'mail':
+ if ($comment->uid != 0) {
+ $account = user_load($comment->uid);
+ $mail = $account->mail;
+ }
+ else {
+ $mail = $comment->mail;
+ }
+ $replacements[$original] = $sanitize ? check_plain($mail) : $mail;
+ break;
+
+ case 'homepage':
+ $replacements[$original] = $sanitize ? check_url($comment->homepage) : $comment->homepage;
+ break;
+
+ case 'title':
+ $replacements[$original] = $sanitize ? filter_xss($comment->subject) : $comment->subject;
+ break;
+
+ case 'body':
+ if ($items = field_get_items('comment', $comment, 'comment_body', $language_code)) {
+ $instance = field_info_instance('comment', 'body', 'comment_body');
+ $field_langcode = field_language('comment', $comment, 'comment_body', $language_code);
+ $replacements[$original] = $sanitize ? _text_sanitize($instance, $field_langcode, $items[0], 'value') : $items[0]['value'];
+ }
+ break;
+
+ // Comment related URLs.
+ case 'url':
+ $url_options['fragment'] = 'comment-' . $comment->cid;
+ $replacements[$original] = url('comment/' . $comment->cid, $url_options);
+ break;
+
+ case 'edit-url':
+ $url_options['fragment'] = NULL;
+ $replacements[$original] = url('comment/' . $comment->cid . '/edit', $url_options);
+ break;
+
+ // Default values for the chained tokens handled below.
+ case 'author':
+ $replacements[$original] = $sanitize ? filter_xss($comment->name) : $comment->name;
+ break;
+
+ case 'parent':
+ if (!empty($comment->pid)) {
+ $parent = comment_load($comment->pid);
+ $replacements[$original] = $sanitize ? filter_xss($parent->subject) : $parent->subject;
+ }
+ break;
+
+ case 'created':
+ $replacements[$original] = format_date($comment->created, 'medium', '', NULL, $language_code);
+ break;
+
+ case 'changed':
+ $replacements[$original] = format_date($comment->changed, 'medium', '', NULL, $language_code);
+ break;
+
+ case 'node':
+ $node = node_load($comment->nid);
+ $title = $node->title;
+ $replacements[$original] = $sanitize ? filter_xss($title) : $title;
+ break;
+ }
+ }
+
+ // Chained token relationships.
+ if ($node_tokens = token_find_with_prefix($tokens, 'node')) {
+ $node = node_load($comment->nid);
+ $replacements += token_generate('node', $node_tokens, array('node' => $node), $options);
+ }
+
+ if ($date_tokens = token_find_with_prefix($tokens, 'created')) {
+ $replacements += token_generate('date', $date_tokens, array('date' => $comment->created), $options);
+ }
+
+ if ($date_tokens = token_find_with_prefix($tokens, 'changed')) {
+ $replacements += token_generate('date', $date_tokens, array('date' => $comment->changed), $options);
+ }
+
+ if (($parent_tokens = token_find_with_prefix($tokens, 'parent')) && $parent = comment_load($comment->pid)) {
+ $replacements += token_generate('comment', $parent_tokens, array('comment' => $parent), $options);
+ }
+
+ if (($author_tokens = token_find_with_prefix($tokens, 'author')) && $account = user_load($comment->uid)) {
+ $replacements += token_generate('user', $author_tokens, array('user' => $account), $options);
+ }
+ }
+ elseif ($type == 'node' & !empty($data['node'])) {
+ $node = $data['node'];
+
+ foreach ($tokens as $name => $original) {
+ switch($name) {
+ case 'comment-count':
+ $replacements[$original] = $node->comment_count;
+ break;
+
+ case 'comment-count-new':
+ $replacements[$original] = comment_num_new($node->nid);
+ break;
+ }
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/comment/comment.tpl.php b/core/modules/comment/comment.tpl.php
new file mode 100644
index 000000000000..a483813d2361
--- /dev/null
+++ b/core/modules/comment/comment.tpl.php
@@ -0,0 +1,90 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for comments.
+ *
+ * Available variables:
+ * - $author: Comment author. Can be link or plain text.
+ * - $content: An array of comment items. Use render($content) to print them all, or
+ * print a subset such as render($content['field_example']). Use
+ * hide($content['field_example']) to temporarily suppress the printing of a
+ * given element.
+ * - $created: Formatted date and time for when the comment was created.
+ * Preprocess functions can reformat it by calling format_date() with the
+ * desired parameters on the $comment->created variable.
+ * - $changed: Formatted date and time for when the comment was last changed.
+ * Preprocess functions can reformat it by calling format_date() with the
+ * desired parameters on the $comment->changed variable.
+ * - $new: New comment marker.
+ * - $permalink: Comment permalink.
+ * - $submitted: Submission information created from $author and $created during
+ * template_preprocess_comment().
+ * - $picture: Authors picture.
+ * - $signature: Authors signature.
+ * - $status: Comment status. Possible values are:
+ * comment-unpublished, comment-published or comment-preview.
+ * - $title: Linked title.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the following:
+ * - comment: The current template type, i.e., "theming hook".
+ * - comment-by-anonymous: Comment by an unregistered user.
+ * - comment-by-node-author: Comment by the author of the parent node.
+ * - comment-preview: When previewing a new or edited comment.
+ * The following applies only to viewers who are registered users:
+ * - comment-unpublished: An unpublished comment visible only to administrators.
+ * - comment-by-viewer: Comment by the user currently viewing the page.
+ * - comment-new: New comment since last the visit.
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * These two variables are provided for context:
+ * - $comment: Full comment object.
+ * - $node: Node object the comments are attached to.
+ *
+ * Other variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_comment()
+ * @see template_process()
+ * @see theme_comment()
+ */
+?>
+<div class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
+ <?php print $picture ?>
+
+ <?php if ($new): ?>
+ <span class="new"><?php print $new ?></span>
+ <?php endif; ?>
+
+ <?php print render($title_prefix); ?>
+ <h3<?php print $title_attributes; ?>><?php print $title ?></h3>
+ <?php print render($title_suffix); ?>
+
+ <div class="submitted">
+ <?php print $permalink; ?>
+ <?php print $submitted; ?>
+ </div>
+
+ <div class="content"<?php print $content_attributes; ?>>
+ <?php
+ // We hide the comments and links now so that we can render them later.
+ hide($content['links']);
+ print render($content);
+ ?>
+ <?php if ($signature): ?>
+ <div class="user-signature clearfix">
+ <?php print $signature ?>
+ </div>
+ <?php endif; ?>
+ </div>
+
+ <?php print render($content['links']) ?>
+</div>
diff --git a/core/modules/contact/contact.admin.inc b/core/modules/contact/contact.admin.inc
new file mode 100644
index 000000000000..9fde037d3454
--- /dev/null
+++ b/core/modules/contact/contact.admin.inc
@@ -0,0 +1,206 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the contact module.
+ */
+
+/**
+ * Categories/list tab.
+ */
+function contact_category_list() {
+ $header = array(
+ t('Category'),
+ t('Recipients'),
+ t('Selected'),
+ array('data' => t('Operations'), 'colspan' => 2),
+ );
+ $rows = array();
+
+ // Get all the contact categories from the database.
+ $categories = db_select('contact', 'c')
+ ->addTag('translatable')
+ ->fields('c', array('cid', 'category', 'recipients', 'selected'))
+ ->orderBy('weight')
+ ->orderBy('category')
+ ->execute()
+ ->fetchAll();
+
+ // Loop through the categories and add them to the table.
+ foreach ($categories as $category) {
+ $rows[] = array(
+ check_plain($category->category),
+ check_plain($category->recipients),
+ ($category->selected ? t('Yes') : t('No')),
+ l(t('Edit'), 'admin/structure/contact/edit/' . $category->cid),
+ l(t('Delete'), 'admin/structure/contact/delete/' . $category->cid),
+ );
+ }
+
+ if (!$rows) {
+ $rows[] = array(array(
+ 'data' => t('No categories available.'),
+ 'colspan' => 5,
+ ));
+ }
+
+ $build['category_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ );
+ return $build;
+}
+
+/**
+ * Category edit page.
+ */
+function contact_category_edit_form($form, &$form_state, array $category = array()) {
+ // If this is a new category, add the default values.
+ $category += array(
+ 'category' => '',
+ 'recipients' => '',
+ 'reply' => '',
+ 'weight' => 0,
+ 'selected' => 0,
+ 'cid' => NULL,
+ );
+
+ $form['category'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Category'),
+ '#maxlength' => 255,
+ '#default_value' => $category['category'],
+ '#description' => t("Example: 'website feedback' or 'product information'."),
+ '#required' => TRUE,
+ );
+ $form['recipients'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Recipients'),
+ '#default_value' => $category['recipients'],
+ '#description' => t("Example: 'webmaster@example.com' or 'sales@example.com,support@example.com' . To specify multiple recipients, separate each e-mail address with a comma."),
+ '#required' => TRUE,
+ );
+ $form['reply'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Auto-reply'),
+ '#default_value' => $category['reply'],
+ '#description' => t('Optional auto-reply. Leave empty if you do not want to send the user an auto-reply message.'),
+ );
+ $form['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#default_value' => $category['weight'],
+ '#description' => t('When listing categories, those with lighter (smaller) weights get listed before categories with heavier (larger) weights. Categories with equal weights are sorted alphabetically.'),
+ );
+ $form['selected'] = array(
+ '#type' => 'select',
+ '#title' => t('Selected'),
+ '#options' => array(
+ 0 => t('No'),
+ 1 => t('Yes'),
+ ),
+ '#default_value' => $category['selected'],
+ '#description' => t('Set this to <em>Yes</em> if you would like this category to be selected by default.'),
+ );
+ $form['cid'] = array(
+ '#type' => 'value',
+ '#value' => $category['cid'],
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+
+ return $form;
+}
+
+/**
+ * Validate the contact category edit page form submission.
+ */
+function contact_category_edit_form_validate($form, &$form_state) {
+ // Validate and each e-mail recipient.
+ $recipients = explode(',', $form_state['values']['recipients']);
+
+ // When creating a new contact form, or renaming the category on an existing
+ // contact form, make sure that the given category is unique.
+ $category = $form_state['values']['category'];
+ $query = db_select('contact', 'c')->condition('c.category', $category, '=');
+ if (!empty($form_state['values']['cid'])) {
+ $query->condition('c.cid', $form_state['values']['cid'], '<>');
+ }
+ if ($query->countQuery()->execute()->fetchField()) {
+ form_set_error('category', t('A contact form with category %category already exists.', array('%category' => $category)));
+ }
+
+ foreach ($recipients as &$recipient) {
+ $recipient = trim($recipient);
+ if (!valid_email_address($recipient)) {
+ form_set_error('recipients', t('%recipient is an invalid e-mail address.', array('%recipient' => $recipient)));
+ }
+ }
+ $form_state['values']['recipients'] = implode(',', $recipients);
+}
+
+/**
+ * Process the contact category edit page form submission.
+ */
+function contact_category_edit_form_submit($form, &$form_state) {
+ if ($form_state['values']['selected']) {
+ // Unselect all other contact categories.
+ db_update('contact')
+ ->fields(array('selected' => '0'))
+ ->execute();
+ }
+
+ if (empty($form_state['values']['cid'])) {
+ drupal_write_record('contact', $form_state['values']);
+ }
+ else {
+ drupal_write_record('contact', $form_state['values'], array('cid'));
+ }
+
+ drupal_set_message(t('Category %category has been saved.', array('%category' => $form_state['values']['category'])));
+ watchdog('contact', 'Category %category has been saved.', array('%category' => $form_state['values']['category']), WATCHDOG_NOTICE, l(t('Edit'), 'admin/structure/contact/edit/' . $form_state['values']['cid']));
+ $form_state['redirect'] = 'admin/structure/contact';
+}
+
+/**
+ * Form builder for deleting a contact category.
+ *
+ * @see contact_category_delete_form_submit()
+ */
+function contact_category_delete_form($form, &$form_state, array $contact) {
+ $form['contact'] = array(
+ '#type' => 'value',
+ '#value' => $contact,
+ );
+
+ return confirm_form(
+ $form,
+ t('Are you sure you want to delete %category?', array('%category' => $contact['category'])),
+ 'admin/structure/contact',
+ t('This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Submit handler for the confirm delete category form.
+ *
+ * @see contact_category_delete_form()
+ */
+function contact_category_delete_form_submit($form, &$form_state) {
+ $contact = $form['contact']['#value'];
+
+ db_delete('contact')
+ ->condition('cid', $contact['cid'])
+ ->execute();
+
+ drupal_set_message(t('Category %category has been deleted.', array('%category' => $contact['category'])));
+ watchdog('contact', 'Category %category has been deleted.', array('%category' => $contact['category']), WATCHDOG_NOTICE);
+
+ $form_state['redirect'] = 'admin/structure/contact';
+}
diff --git a/core/modules/contact/contact.info b/core/modules/contact/contact.info
new file mode 100644
index 000000000000..dd2ac0f528e1
--- /dev/null
+++ b/core/modules/contact/contact.info
@@ -0,0 +1,7 @@
+name = Contact
+description = Enables the use of both personal and site-wide contact forms.
+package = Core
+version = VERSION
+core = 8.x
+files[] = contact.test
+configure = admin/structure/contact
diff --git a/core/modules/contact/contact.install b/core/modules/contact/contact.install
new file mode 100644
index 000000000000..7ddea4562665
--- /dev/null
+++ b/core/modules/contact/contact.install
@@ -0,0 +1,89 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the contact module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function contact_schema() {
+ $schema['contact'] = array(
+ 'description' => 'Contact form category settings.',
+ 'fields' => array(
+ 'cid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique category ID.',
+ ),
+ 'category' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Category name.',
+ 'translatable' => TRUE,
+ ),
+ 'recipients' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Comma-separated list of recipient e-mail addresses.',
+ ),
+ 'reply' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Text of the auto-reply message.',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The category's weight.",
+ ),
+ 'selected' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Flag to indicate whether or not category is selected by default. (1 = Yes, 0 = No)',
+ ),
+ ),
+ 'primary key' => array('cid'),
+ 'unique keys' => array(
+ 'category' => array('category'),
+ ),
+ 'indexes' => array(
+ 'list' => array('weight', 'category'),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function contact_install() {
+ // Insert a default contact category.
+ db_insert('contact')
+ ->fields(array(
+ 'category' => 'Website feedback',
+ 'recipients' => variable_get('site_mail', ini_get('sendmail_from')),
+ 'selected' => 1,
+ 'reply' => '',
+ ))
+ ->execute();
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function contact_uninstall() {
+ variable_del('contact_default_status');
+ variable_del('contact_threshold_limit');
+ variable_del('contact_threshold_window');
+}
diff --git a/core/modules/contact/contact.module b/core/modules/contact/contact.module
new file mode 100644
index 000000000000..eaae9c62c689
--- /dev/null
+++ b/core/modules/contact/contact.module
@@ -0,0 +1,257 @@
+<?php
+
+/**
+ * @file
+ * Enables the use of personal and site-wide contact forms.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function contact_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#contact':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Contact module allows visitors to contact site administrators and other users. Users specify a subject, write their message, and can have a copy of their message sent to their own e-mail address. For more information, see the online handbook entry for <a href="@contact">Contact module</a>.', array('@contact' => 'http://drupal.org/handbook/modules/contact/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('User contact forms') . '</dt>';
+ $output .= '<dd>' . t('Site users can be contacted with a user contact form that keeps their e-mail address private. Users may enable or disable their personal contact forms by editing their <em>My account</em> page. If enabled, a <em>Contact</em> tab leads to a personal contact form displayed on their user profile. Site administrators are still able to use the contact form, even if has been disabled. The <em>Contact</em> tab is not shown when you view your own profile.') . '</dd>';
+ $output .= '<dt>' . t('Site-wide contact forms') . '</dt>';
+ $output .= '<dd>' . t('The <a href="@contact">Contact page</a> provides a simple form for users with the <em>Use the site-wide contact form</em> permission to send comments, feedback, or other requests. You can create categories for directing the contact form messages to a set of defined recipients. Common categories for a business site, for example, might include "Website feedback" (messages are forwarded to website administrators) and "Product information" (messages are forwarded to members of the sales department). E-mail addresses defined within a category are not displayed publicly.', array('@contact' => url('contact'))) . '</p>';
+ $output .= '<dt>' . t('Navigation') . '</dt>';
+ $output .= '<dd>' . t("When the site-wide contact form is enabled, a link in the main <em>Navigation</em> menu is created, but the link is disabled by default. This menu link can be enabled on the <a href='@menu'>Menus administration page</a>.", array('@contact' => url('contact'), '@menu' => url('admin/structure/menu'))) . '</dd>';
+ $output .= '<dt>' . t('Customization') . '</dt>';
+ $output .= '<dd>' . t('If you would like additional text to appear on the site-wide or personal contact page, use a block. You can create and edit blocks on the <a href="@blocks">Blocks administration page</a>.', array('@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/structure/contact':
+ $output = '<p>' . t('Add one or more categories on this page to set up your site-wide <a href="@form">contact form</a>.', array('@form' => url('contact'))) . '</p>';
+ $output .= '<p>' . t('A <em>Contact</em> menu item (disabled by default) is added to the Navigation menu, which you can modify on the <a href="@menu-settings">Menus administration page</a>.', array('@menu-settings' => url('admin/structure/menu'))) . '</p>';
+ $output .= '<p>' . t('If you would like additional text to appear on the site-wide contact page, use a block. You can create and edit blocks on the <a href="@blocks">Blocks administration page</a>.', array('@blocks' => url('admin/structure/block'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function contact_permission() {
+ return array(
+ 'administer contact forms' => array(
+ 'title' => t('Administer contact forms and contact form settings'),
+ ),
+ 'access site-wide contact form' => array(
+ 'title' => t('Use the site-wide contact form'),
+ ),
+ 'access user contact forms' => array(
+ 'title' => t("Use users' personal contact forms"),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function contact_menu() {
+ $items['admin/structure/contact'] = array(
+ 'title' => 'Contact form',
+ 'description' => 'Create a system contact form and set up categories for the form to use.',
+ 'page callback' => 'contact_category_list',
+ 'access arguments' => array('administer contact forms'),
+ 'file' => 'contact.admin.inc',
+ );
+ $items['admin/structure/contact/add'] = array(
+ 'title' => 'Add category',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('contact_category_edit_form'),
+ 'access arguments' => array('administer contact forms'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'weight' => 1,
+ 'file' => 'contact.admin.inc',
+ );
+ $items['admin/structure/contact/edit/%contact'] = array(
+ 'title' => 'Edit contact category',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('contact_category_edit_form', 4),
+ 'access arguments' => array('administer contact forms'),
+ 'file' => 'contact.admin.inc',
+ );
+ $items['admin/structure/contact/delete/%contact'] = array(
+ 'title' => 'Delete contact',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('contact_category_delete_form', 4),
+ 'access arguments' => array('administer contact forms'),
+ 'file' => 'contact.admin.inc',
+ );
+ $items['contact'] = array(
+ 'title' => 'Contact',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('contact_site_form'),
+ 'access arguments' => array('access site-wide contact form'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ 'file' => 'contact.pages.inc',
+ );
+ $items['user/%user/contact'] = array(
+ 'title' => 'Contact',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('contact_personal_form', 1),
+ 'type' => MENU_LOCAL_TASK,
+ 'access callback' => '_contact_personal_tab_access',
+ 'access arguments' => array(1),
+ 'weight' => 2,
+ 'file' => 'contact.pages.inc',
+ );
+ return $items;
+}
+
+/**
+ * Menu access callback for a user's personal contact form.
+ *
+ * @param $account
+ * A user account object.
+ * @return
+ * TRUE if the current user has access to the requested user's contact form,
+ * or FALSE otherwise.
+ */
+function _contact_personal_tab_access($account) {
+ global $user;
+
+ // Anonymous users cannot have contact forms.
+ if (!$account->uid) {
+ return FALSE;
+ }
+
+ // User administrators should always have access to personal contact forms.
+ if (user_access('administer users')) {
+ return TRUE;
+ }
+
+ // Users may not contact themselves.
+ if ($user->uid == $account->uid) {
+ return FALSE;
+ }
+
+ // If the requested user has disabled their contact form, or this preference
+ // has not yet been saved, do not allow users to contact them.
+ if (empty($account->data['contact'])) {
+ return FALSE;
+ }
+
+ // If requested user has been blocked, do not allow users to contact them.
+ if (empty($account->status)) {
+ return FALSE;
+ }
+
+ return user_access('access user contact forms');
+}
+
+/**
+ * Load a contact category.
+ *
+ * @param $cid
+ * The contact category ID.
+ * @return
+ * An array with the contact category's data.
+ */
+function contact_load($cid) {
+ return db_select('contact', 'c')
+ ->addTag('translatable')
+ ->fields('c')
+ ->condition('cid', $cid)
+ ->execute()
+ ->fetchAssoc();
+}
+
+/**
+ * Implements hook_mail().
+ */
+function contact_mail($key, &$message, $params) {
+ $language = $message['language'];
+ $variables = array(
+ '!site-name' => variable_get('site_name', 'Drupal'),
+ '!subject' => $params['subject'],
+ '!category' => isset($params['category']['category']) ? $params['category']['category'] : '',
+ '!form-url' => url($_GET['q'], array('absolute' => TRUE, 'language' => $language)),
+ '!sender-name' => format_username($params['sender']),
+ '!sender-url' => $params['sender']->uid ? url('user/' . $params['sender']->uid, array('absolute' => TRUE, 'language' => $language)) : $params['sender']->mail,
+ );
+
+ switch ($key) {
+ case 'page_mail':
+ case 'page_copy':
+ $message['subject'] .= t('[!category] !subject', $variables, array('langcode' => $language->language));
+ $message['body'][] = t("!sender-name (!sender-url) sent a message using the contact form at !form-url.", $variables, array('langcode' => $language->language));
+ $message['body'][] = $params['message'];
+ break;
+
+ case 'page_autoreply':
+ $message['subject'] .= t('[!category] !subject', $variables, array('langcode' => $language->language));
+ $message['body'][] = $params['category']['reply'];
+ break;
+
+ case 'user_mail':
+ case 'user_copy':
+ $variables += array(
+ '!recipient-name' => format_username($params['recipient']),
+ '!recipient-edit-url' => url('user/' . $params['recipient']->uid . '/edit', array('absolute' => TRUE, 'language' => $language)),
+ );
+ $message['subject'] .= t('[!site-name] !subject', $variables, array('langcode' => $language->language));
+ $message['body'][] = t('Hello !recipient-name,', $variables, array('langcode' => $language->language));
+ $message['body'][] = t("!sender-name (!sender-url) has sent you a message via your contact form (!form-url) at !site-name.", $variables, array('langcode' => $language->language));
+ $message['body'][] = t("If you don't want to receive such e-mails, you can change your settings at !recipient-edit-url.", $variables, array('langcode' => $language->language));
+ $message['body'][] = t('Message:', array(), array('langcode' => $language->language));
+ $message['body'][] = $params['message'];
+ break;
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Add the enable personal contact form to an individual user's account page.
+ */
+function contact_form_user_profile_form_alter(&$form, &$form_state) {
+ if ($form['#user_category'] == 'account') {
+ $account = $form['#user'];
+ $form['contact'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Contact settings'),
+ '#weight' => 5,
+ '#collapsible' => TRUE,
+ );
+ $form['contact']['contact'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Personal contact form'),
+ '#default_value' => !empty($account->data['contact']) ? $account->data['contact'] : FALSE,
+ '#description' => t('Allow other users to contact you via a <a href="@url">personal contact form</a> which keeps your e-mail address hidden. Note that some privileged users such as site administrators are still able to contact you even if you choose to disable this feature.', array('@url' => url("user/$account->uid/contact"))),
+ );
+ }
+}
+
+/**
+ * Implements hook_user_presave().
+ */
+function contact_user_presave(&$edit, $account, $category) {
+ $edit['data']['contact'] = isset($edit['contact']) ? $edit['contact'] : variable_get('contact_default_status', 1);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Add the default personal contact setting on the user settings page.
+ */
+function contact_form_user_admin_settings_alter(&$form, &$form_state) {
+ $form['contact'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Contact settings'),
+ '#weight' => 0,
+ );
+ $form['contact']['contact_default_status'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable the personal contact form by default for new users.'),
+ '#description' => t('Changing this setting will not affect existing users.'),
+ '#default_value' => variable_get('contact_default_status', 1),
+ );
+}
diff --git a/core/modules/contact/contact.pages.inc b/core/modules/contact/contact.pages.inc
new file mode 100644
index 000000000000..30b2825045fc
--- /dev/null
+++ b/core/modules/contact/contact.pages.inc
@@ -0,0 +1,291 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the contact module.
+ */
+
+/**
+ * Form builder; the site-wide contact form.
+ *
+ * @see contact_site_form_validate()
+ * @see contact_site_form_submit()
+ */
+function contact_site_form($form, &$form_state) {
+ global $user;
+
+ // Check if flood control has been activated for sending e-mails.
+ $limit = variable_get('contact_threshold_limit', 5);
+ $window = variable_get('contact_threshold_window', 3600);
+ if (!flood_is_allowed('contact', $limit, $window) && !user_access('administer contact forms')) {
+ drupal_set_message(t("You cannot send more than %limit messages in @interval. Try again later.", array('%limit' => $limit, '@interval' => format_interval($window))), 'error');
+ drupal_access_denied();
+ drupal_exit();
+ }
+
+ // Get an array of the categories and the current default category.
+ $categories = db_select('contact', 'c')
+ ->addTag('translatable')
+ ->fields('c', array('cid', 'category'))
+ ->orderBy('weight')
+ ->orderBy('category')
+ ->execute()
+ ->fetchAllKeyed();
+ $default_category = db_query("SELECT cid FROM {contact} WHERE selected = 1")->fetchField();
+
+ // If there are no categories, do not display the form.
+ if (!$categories) {
+ if (user_access('administer contact forms')) {
+ drupal_set_message(t('The contact form has not been configured. <a href="@add">Add one or more categories</a> to the form.', array('@add' => url('admin/structure/contact/add'))), 'error');
+ }
+ else {
+ drupal_not_found();
+ drupal_exit();
+ }
+ }
+
+ // If there is more than one category available and no default category has
+ // been selected, prepend a default placeholder value.
+ if (!$default_category) {
+ if (count($categories) > 1) {
+ $categories = array(0 => t('- Please choose -')) + $categories;
+ }
+ else {
+ $default_category = key($categories);
+ }
+ }
+
+ if (!$user->uid) {
+ $form['#attached']['library'][] = array('system', 'jquery.cookie');
+ $form['#attributes']['class'][] = 'user-info-from-cookie';
+ }
+
+ $form['#attributes']['class'][] = 'contact-form';
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your name'),
+ '#maxlength' => 255,
+ '#default_value' => $user->uid ? format_username($user) : '',
+ '#required' => TRUE,
+ );
+ $form['mail'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your e-mail address'),
+ '#maxlength' => 255,
+ '#default_value' => $user->uid ? $user->mail : '',
+ '#required' => TRUE,
+ );
+ $form['subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#maxlength' => 255,
+ '#required' => TRUE,
+ );
+ $form['cid'] = array(
+ '#type' => 'select',
+ '#title' => t('Category'),
+ '#default_value' => $default_category,
+ '#options' => $categories,
+ '#required' => TRUE,
+ '#access' => count($categories) > 1,
+ );
+ $form['message'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Message'),
+ '#required' => TRUE,
+ );
+ // We do not allow anonymous users to send themselves a copy
+ // because it can be abused to spam people.
+ $form['copy'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Send yourself a copy.'),
+ '#access' => $user->uid,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Send message'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for contact_site_form().
+ */
+function contact_site_form_validate($form, &$form_state) {
+ if (!$form_state['values']['cid']) {
+ form_set_error('cid', t('You must select a valid category.'));
+ }
+ if (!valid_email_address($form_state['values']['mail'])) {
+ form_set_error('mail', t('You must enter a valid e-mail address.'));
+ }
+}
+
+/**
+ * Form submission handler for contact_site_form().
+ */
+function contact_site_form_submit($form, &$form_state) {
+ global $user, $language;
+
+ $values = $form_state['values'];
+ $values['sender'] = $user;
+ $values['sender']->name = $values['name'];
+ $values['sender']->mail = $values['mail'];
+ $values['category'] = contact_load($values['cid']);
+
+ // Save the anonymous user information to a cookie for reuse.
+ if (!$user->uid) {
+ user_cookie_save(array_intersect_key($values, array_flip(array('name', 'mail'))));
+ }
+
+ // Get the to and from e-mail addresses.
+ $to = $values['category']['recipients'];
+ $from = $values['sender']->mail;
+
+ // Send the e-mail to the recipients using the site default language.
+ drupal_mail('contact', 'page_mail', $to, language_default(), $values, $from);
+
+ // If the user requests it, send a copy using the current language.
+ if ($values['copy']) {
+ drupal_mail('contact', 'page_copy', $from, $language, $values, $from);
+ }
+
+ // Send an auto-reply if necessary using the current language.
+ if ($values['category']['reply']) {
+ drupal_mail('contact', 'page_autoreply', $from, $language, $values, $to);
+ }
+
+ flood_register_event('contact', variable_get('contact_threshold_window', 3600));
+ watchdog('mail', '%sender-name (@sender-from) sent an e-mail regarding %category.', array('%sender-name' => $values['name'], '@sender-from' => $from, '%category' => $values['category']['category']));
+
+ // Jump to home page rather than back to contact page to avoid
+ // contradictory messages if flood control has been activated.
+ drupal_set_message(t('Your message has been sent.'));
+ $form_state['redirect'] = '';
+}
+
+/**
+ * Form builder; the personal contact form.
+ *
+ * @see contact_personal_form_validate()
+ * @see contact_personal_form_submit()
+ */
+function contact_personal_form($form, &$form_state, $recipient) {
+ global $user;
+
+ // Check if flood control has been activated for sending e-mails.
+ $limit = variable_get('contact_threshold_limit', 5);
+ $window = variable_get('contact_threshold_window', 3600);
+ if (!flood_is_allowed('contact', $limit, $window) && !user_access('administer contact forms') && !user_access('administer users')) {
+ drupal_set_message(t("You cannot send more than %limit messages in @interval. Try again later.", array('%limit' => $limit, '@interval' => format_interval($window))), 'error');
+ drupal_access_denied();
+ drupal_exit();
+ }
+
+ drupal_set_title(t('Contact @username', array('@username' => format_username($recipient))), PASS_THROUGH);
+
+ if (!$user->uid) {
+ $form['#attached']['library'][] = array('system', 'jquery.cookie');
+ $form['#attributes']['class'][] = 'user-info-from-cookie';
+ }
+
+ $form['#attributes']['class'][] = 'contact-form';
+ $form['recipient'] = array(
+ '#type' => 'value',
+ '#value' => $recipient,
+ );
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your name'),
+ '#maxlength' => 255,
+ '#default_value' => $user->uid ? format_username($user) : '',
+ '#required' => TRUE,
+ );
+ $form['mail'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your e-mail address'),
+ '#maxlength' => 255,
+ '#default_value' => $user->uid ? $user->mail : '',
+ '#required' => TRUE,
+ );
+ $form['to'] = array(
+ '#type' => 'item',
+ '#title' => t('To'),
+ '#markup' => theme('username', array('account' => $recipient)),
+ );
+ $form['subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#maxlength' => 50,
+ '#required' => TRUE,
+ );
+ $form['message'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Message'),
+ '#rows' => 15,
+ '#required' => TRUE,
+ );
+ // We do not allow anonymous users to send themselves a copy
+ // because it can be abused to spam people.
+ $form['copy'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Send yourself a copy.'),
+ '#access' => $user->uid,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Send message'),
+ );
+ return $form;
+}
+
+/**
+ * Form validation handler for contact_personal_form().
+ *
+ * @see contact_personal_form()
+ */
+function contact_personal_form_validate($form, &$form_state) {
+ if (!valid_email_address($form_state['values']['mail'])) {
+ form_set_error('mail', t('You must enter a valid e-mail address.'));
+ }
+}
+
+/**
+ * Form submission handler for contact_personal_form().
+ *
+ * @see contact_personal_form()
+ */
+function contact_personal_form_submit($form, &$form_state) {
+ global $user, $language;
+
+ $values = $form_state['values'];
+ $values['sender'] = $user;
+ $values['sender']->name = $values['name'];
+ $values['sender']->mail = $values['mail'];
+
+ // Save the anonymous user information to a cookie for reuse.
+ if (!$user->uid) {
+ user_cookie_save(array_intersect_key($values, array_flip(array('name', 'mail'))));
+ }
+
+ // Get the to and from e-mail addresses.
+ $to = $values['recipient']->mail;
+ $from = $values['sender']->mail;
+
+ // Send the e-mail in the requested user language.
+ drupal_mail('contact', 'user_mail', $to, user_preferred_language($values['recipient']), $values, $from);
+
+ // Send a copy if requested, using current page language.
+ if ($values['copy']) {
+ drupal_mail('contact', 'user_copy', $from, $language, $values, $from);
+ }
+
+ flood_register_event('contact', variable_get('contact_threshold_window', 3600));
+ watchdog('mail', '%sender-name (@sender-from) sent %recipient-name an e-mail.', array('%sender-name' => $values['name'], '@sender-from' => $from, '%recipient-name' => $values['recipient']->name));
+
+ // Jump to the contacted user's profile page.
+ drupal_set_message(t('Your message has been sent.'));
+ $form_state['redirect'] = user_access('access user profiles') ? 'user/' . $values['recipient']->uid : '';
+}
diff --git a/core/modules/contact/contact.test b/core/modules/contact/contact.test
new file mode 100644
index 000000000000..129eb30ce343
--- /dev/null
+++ b/core/modules/contact/contact.test
@@ -0,0 +1,416 @@
+<?php
+
+/**
+ * @file
+ * Tests for contact.module.
+ */
+
+class ContactSitewideTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Site-wide contact form',
+ 'description' => 'Tests site-wide contact form functionality.',
+ 'group' => 'Contact',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('contact');
+ }
+
+ /**
+ * Test configuration options and site-wide contact form.
+ */
+ function testSiteWideContact() {
+ // Create and login administrative user.
+ $admin_user = $this->drupalCreateUser(array('access site-wide contact form', 'administer contact forms', 'administer users'));
+ $this->drupalLogin($admin_user);
+
+ $flood_limit = 3;
+ variable_set('contact_threshold_limit', $flood_limit);
+ variable_set('contact_threshold_window', 600);
+
+ // Set settings.
+ $edit = array();
+ $edit['contact_default_status'] = TRUE;
+ $this->drupalPost('admin/config/people/accounts', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Setting successfully saved.'));
+
+ // Delete old categories to ensure that new categories are used.
+ $this->deleteCategories();
+
+ // Ensure that the contact form won't be shown without categories.
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access site-wide contact form'));
+ $this->drupalLogout();
+ $this->drupalGet('contact');
+ $this->assertResponse(404);
+ $this->drupalLogin($admin_user);
+ $this->drupalGet('contact');
+ $this->assertResponse(200);
+ $this->assertText(t('The contact form has not been configured.'));
+
+ // Add categories.
+ // Test invalid recipients.
+ $invalid_recipients = array('invalid', 'invalid@', 'invalid@site.', '@site.', '@site.com');
+ foreach ($invalid_recipients as $invalid_recipient) {
+ $this->addCategory($this->randomName(16), $invalid_recipient, '', FALSE);
+ $this->assertRaw(t('%recipient is an invalid e-mail address.', array('%recipient' => $invalid_recipient)), t('Caught invalid recipient (' . $invalid_recipient . ').'));
+ }
+
+ // Test validation of empty category and recipients fields.
+ $this->addCategory($category = '', '', '', TRUE);
+ $this->assertText(t('Category field is required.'), t('Caught empty category field'));
+ $this->assertText(t('Recipients field is required.'), t('Caught empty recipients field.'));
+
+ // Create first valid category.
+ $recipients = array('simpletest@example.com', 'simpletest2@example.com', 'simpletest3@example.com');
+ $this->addCategory($category = $this->randomName(16), implode(',', array($recipients[0])), '', TRUE);
+ $this->assertRaw(t('Category %category has been saved.', array('%category' => $category)), t('Category successfully saved.'));
+
+ // Make sure the newly created category is included in the list of categories.
+ $this->assertNoUniqueText($category, t('New category included in categories list.'));
+
+ // Test update contact form category.
+ $categories = $this->getCategories();
+ $category_id = $this->updateCategory($categories, $category = $this->randomName(16), $recipients_str = implode(',', array($recipients[0], $recipients[1])), $reply = $this->randomName(30), FALSE);
+ $category_array = db_query("SELECT category, recipients, reply, selected FROM {contact} WHERE cid = :cid", array(':cid' => $category_id))->fetchAssoc();
+ $this->assertEqual($category_array['category'], $category);
+ $this->assertEqual($category_array['recipients'], $recipients_str);
+ $this->assertEqual($category_array['reply'], $reply);
+ $this->assertFalse($category_array['selected']);
+ $this->assertRaw(t('Category %category has been saved.', array('%category' => $category)), t('Category successfully saved.'));
+
+ // Ensure that the contact form is shown without a category selection input.
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access site-wide contact form'));
+ $this->drupalLogout();
+ $this->drupalGet('contact');
+ $this->assertText(t('Your e-mail address'), t('Contact form is shown when there is one category.'));
+ $this->assertNoText(t('Category'), t('When there is only one category, the category selection element is hidden.'));
+ $this->drupalLogin($admin_user);
+
+ // Add more categories.
+ $this->addCategory($category = $this->randomName(16), implode(',', array($recipients[0], $recipients[1])), '', FALSE);
+ $this->assertRaw(t('Category %category has been saved.', array('%category' => $category)), t('Category successfully saved.'));
+
+ $this->addCategory($category = $this->randomName(16), implode(',', array($recipients[0], $recipients[1], $recipients[2])), '', FALSE);
+ $this->assertRaw(t('Category %category has been saved.', array('%category' => $category)), t('Category successfully saved.'));
+
+ // Try adding a category that already exists.
+ $this->addCategory($category, '', '', FALSE);
+ $this->assertNoRaw(t('Category %category has been saved.', array('%category' => $category)), t('Category not saved.'));
+ $this->assertRaw(t('A contact form with category %category already exists.', array('%category' => $category)), t('Duplicate category error found.'));
+
+ // Clear flood table in preparation for flood test and allow other checks to complete.
+ db_delete('flood')->execute();
+ $num_records_after = db_query("SELECT COUNT(*) FROM {flood}")->fetchField();
+ $this->assertIdentical($num_records_after, '0', t('Flood table emptied.'));
+ $this->drupalLogout();
+
+ // Check to see that anonymous user cannot see contact page without permission.
+ user_role_revoke_permissions(DRUPAL_ANONYMOUS_RID, array('access site-wide contact form'));
+ $this->drupalGet('contact');
+ $this->assertResponse(403, t('Access denied to anonymous user without permission.'));
+
+ // Give anonymous user permission and see that page is viewable.
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access site-wide contact form'));
+ $this->drupalGet('contact');
+ $this->assertResponse(200, t('Access granted to anonymous user with permission.'));
+
+ // Submit contact form with invalid values.
+ $this->submitContact('', $recipients[0], $this->randomName(16), $categories[0], $this->randomName(64));
+ $this->assertText(t('Your name field is required.'), t('Name required.'));
+
+ $this->submitContact($this->randomName(16), '', $this->randomName(16), $categories[0], $this->randomName(64));
+ $this->assertText(t('Your e-mail address field is required.'), t('E-mail required.'));
+
+ $this->submitContact($this->randomName(16), $invalid_recipients[0], $this->randomName(16), $categories[0], $this->randomName(64));
+ $this->assertText(t('You must enter a valid e-mail address.'), t('Valid e-mail required.'));
+
+ $this->submitContact($this->randomName(16), $recipients[0], '', $categories[0], $this->randomName(64));
+ $this->assertText(t('Subject field is required.'), t('Subject required.'));
+
+ $this->submitContact($this->randomName(16), $recipients[0], $this->randomName(16), $categories[0], '');
+ $this->assertText(t('Message field is required.'), t('Message required.'));
+
+ // Test contact form with no default category selected.
+ db_update('contact')
+ ->fields(array('selected' => 0))
+ ->execute();
+ $this->drupalGet('contact');
+ $this->assertRaw(t('- Please choose -'), t('Without selected categories the visitor is asked to chose a category.'));
+
+ // Submit contact form with invalid category id (cid 0).
+ $this->submitContact($this->randomName(16), $recipients[0], $this->randomName(16), 0, '');
+ $this->assertText(t('You must select a valid category.'), t('Valid category required.'));
+
+ // Submit contact form with correct values and check flood interval.
+ for ($i = 0; $i < $flood_limit; $i++) {
+ $this->submitContact($this->randomName(16), $recipients[0], $this->randomName(16), $categories[0], $this->randomName(64));
+ $this->assertText(t('Your message has been sent.'), t('Message sent.'));
+ }
+ // Submit contact form one over limit.
+ $this->drupalGet('contact');
+ $this->assertResponse(403, t('Access denied to anonymous user after reaching message treshold.'));
+ $this->assertRaw(t('You cannot send more than %number messages in @interval. Try again later.', array('%number' => variable_get('contact_threshold_limit', 3), '@interval' => format_interval(600))), t('Message threshold reached.'));
+
+ // Delete created categories.
+ $this->drupalLogin($admin_user);
+ $this->deleteCategories();
+ }
+
+ /**
+ * Test auto-reply on the site-wide contact form.
+ */
+ function testAutoReply() {
+ // Create and login administrative user.
+ $admin_user = $this->drupalCreateUser(array('access site-wide contact form', 'administer contact forms', 'administer permissions', 'administer users'));
+ $this->drupalLogin($admin_user);
+
+ // Set up three categories, 2 with an auto-reply and one without.
+ $foo_autoreply = $this->randomName(40);
+ $bar_autoreply = $this->randomName(40);
+ $this->addCategory('foo', 'foo@example.com', $foo_autoreply, FALSE);
+ $this->addCategory('bar', 'bar@example.com', $bar_autoreply, FALSE);
+ $this->addCategory('no_autoreply', 'bar@example.com', '', FALSE);
+
+ // Test the auto-reply for category 'foo'.
+ $email = $this->randomName(32) . '@example.com';
+ $subject = $this->randomName(64);
+ $this->submitContact($this->randomName(16), $email, $subject, 2, $this->randomString(128));
+
+ // We are testing the auto-reply, so there should be one e-mail going to the sender.
+ $captured_emails = $this->drupalGetMails(array('id' => 'contact_page_autoreply', 'to' => $email, 'from' => 'foo@example.com'));
+ $this->assertEqual(count($captured_emails), 1, t('Auto-reply e-mail was sent to the sender for category "foo".'), t('Contact'));
+ $this->assertEqual($captured_emails[0]['body'], drupal_html_to_text($foo_autoreply), t('Auto-reply e-mail body is correct for category "foo".'), t('Contact'));
+
+ // Test the auto-reply for category 'bar'.
+ $email = $this->randomName(32) . '@example.com';
+ $this->submitContact($this->randomName(16), $email, $this->randomString(64), 3, $this->randomString(128));
+
+ // Auto-reply for category 'bar' should result in one auto-reply e-mail to the sender.
+ $captured_emails = $this->drupalGetMails(array('id' => 'contact_page_autoreply', 'to' => $email, 'from' => 'bar@example.com'));
+ $this->assertEqual(count($captured_emails), 1, t('Auto-reply e-mail was sent to the sender for category "bar".'), t('Contact'));
+ $this->assertEqual($captured_emails[0]['body'], drupal_html_to_text($bar_autoreply), t('Auto-reply e-mail body is correct for category "bar".'), t('Contact'));
+
+ // Verify that no auto-reply is sent when the auto-reply field is left blank.
+ $email = $this->randomName(32) . '@example.com';
+ $this->submitContact($this->randomName(16), $email, $this->randomString(64), 4, $this->randomString(128));
+ $captured_emails = $this->drupalGetMails(array('id' => 'contact_page_autoreply', 'to' => $email, 'from' => 'no_autoreply@example.com'));
+ $this->assertEqual(count($captured_emails), 0, t('No auto-reply e-mail was sent to the sender for category "no-autoreply".'), t('Contact'));
+ }
+
+ /**
+ * Add a category.
+ *
+ * @param string $category Name of category.
+ * @param string $recipients List of recipient e-mail addresses.
+ * @param string $reply Auto-reply text.
+ * @param boolean $selected Defautly selected.
+ */
+ function addCategory($category, $recipients, $reply, $selected) {
+ $edit = array();
+ $edit['category'] = $category;
+ $edit['recipients'] = $recipients;
+ $edit['reply'] = $reply;
+ $edit['selected'] = ($selected ? '1' : '0');
+ $this->drupalPost('admin/structure/contact/add', $edit, t('Save'));
+ }
+
+ /**
+ * Update a category.
+ *
+ * @param string $category Name of category.
+ * @param string $recipients List of recipient e-mail addresses.
+ * @param string $reply Auto-reply text.
+ * @param boolean $selected Defautly selected.
+ */
+ function updateCategory($categories, $category, $recipients, $reply, $selected) {
+ $category_id = $categories[array_rand($categories)];
+ $edit = array();
+ $edit['category'] = $category;
+ $edit['recipients'] = $recipients;
+ $edit['reply'] = $reply;
+ $edit['selected'] = ($selected ? '1' : '0');
+ $this->drupalPost('admin/structure/contact/edit/' . $category_id, $edit, t('Save'));
+ return ($category_id);
+ }
+
+ /**
+ * Submit contact form.
+ *
+ * @param string $name Name.
+ * @param string $mail E-mail address.
+ * @param string $subject Subject.
+ * @param integer $cid Category id.
+ * @param string $message Message.
+ */
+ function submitContact($name, $mail, $subject, $cid, $message) {
+ $edit = array();
+ $edit['name'] = $name;
+ $edit['mail'] = $mail;
+ $edit['subject'] = $subject;
+ $edit['cid'] = $cid;
+ $edit['message'] = $message;
+ $this->drupalPost('contact', $edit, t('Send message'));
+ }
+
+ /**
+ * Delete all categories.
+ */
+ function deleteCategories() {
+ $categories = $this->getCategories();
+ foreach ($categories as $category) {
+ $category_name = db_query("SELECT category FROM {contact} WHERE cid = :cid", array(':cid' => $category))->fetchField();
+ $this->drupalPost('admin/structure/contact/delete/' . $category, array(), t('Delete'));
+ $this->assertRaw(t('Category %category has been deleted.', array('%category' => $category_name)), t('Category deleted successfully.'));
+ }
+ }
+
+ /**
+ * Get list category ids.
+ *
+ * @return array Category ids.
+ */
+ function getCategories() {
+ $categories = db_query('SELECT cid FROM {contact}')->fetchCol();
+ return $categories;
+ }
+}
+
+/**
+ * Test the personal contact form.
+ */
+class ContactPersonalTestCase extends DrupalWebTestCase {
+ private $admin_user;
+ private $web_user;
+ private $contact_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Personal contact form',
+ 'description' => 'Tests personal contact form functionality.',
+ 'group' => 'Contact',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('contact');
+
+ // Create an admin user.
+ $this->admin_user = $this->drupalCreateUser(array('administer contact forms', 'administer users'));
+
+ // Create some normal users with their contact forms enabled by default.
+ variable_set('contact_default_status', TRUE);
+ $this->web_user = $this->drupalCreateUser(array('access user contact forms'));
+ $this->contact_user = $this->drupalCreateUser();
+ }
+
+ /**
+ * Test personal contact form access.
+ */
+ function testPersonalContactAccess() {
+ // Test allowed access to user with contact form enabled.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(200);
+
+ // Test denied access to the user's own contact form.
+ $this->drupalGet('user/' . $this->web_user->uid . '/contact');
+ $this->assertResponse(403);
+
+ // Test always denied access to the anonymous user contact form.
+ $this->drupalGet('user/0/contact');
+ $this->assertResponse(403);
+
+ // Test that anonymous users can access the contact form.
+ $this->drupalLogout();
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access user contact forms'));
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(200);
+
+ // Revoke the personal contact permission for the anonymous user.
+ user_role_revoke_permissions(DRUPAL_ANONYMOUS_RID, array('access user contact forms'));
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(403);
+
+ // Disable the personal contact form.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('contact_default_status' => FALSE);
+ $this->drupalPost('admin/config/people/accounts', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Setting successfully saved.'));
+ $this->drupalLogout();
+
+ // Re-create our contacted user with personal contact forms disabled by
+ // default.
+ $this->contact_user = $this->drupalCreateUser();
+
+ // Test denied access to a user with contact form disabled.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(403);
+
+ // Test allowed access for admin user to a user with contact form disabled.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(200);
+
+ // Re-create our contacted user as a blocked user.
+ $this->contact_user = $this->drupalCreateUser();
+ user_save($this->contact_user, array('status' => 0));
+
+ // Test that blocked users can still be contacted by admin.
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(200);
+
+ // Test that blocked users cannot be contacted by non-admins.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('user/' . $this->contact_user->uid . '/contact');
+ $this->assertResponse(403);
+ }
+
+ /**
+ * Test the personal contact form flood protection.
+ */
+ function testPersonalContactFlood() {
+ $flood_limit = 3;
+ variable_set('contact_threshold_limit', $flood_limit);
+
+ // Clear flood table in preparation for flood test and allow other checks to complete.
+ db_delete('flood')->execute();
+ $num_records_flood = db_query("SELECT COUNT(*) FROM {flood}")->fetchField();
+ $this->assertIdentical($num_records_flood, '0', 'Flood table emptied.');
+
+ $this->drupalLogin($this->web_user);
+
+ // Submit contact form with correct values and check flood interval.
+ for ($i = 0; $i < $flood_limit; $i++) {
+ $this->submitPersonalContact($this->contact_user);
+ $this->assertText(t('Your message has been sent.'), 'Message sent.');
+ }
+
+ // Submit contact form one over limit.
+ $this->drupalGet('user/' . $this->contact_user->uid. '/contact');
+ $this->assertRaw(t('You cannot send more than %number messages in @interval. Try again later.', array('%number' => $flood_limit, '@interval' => format_interval(variable_get('contact_threshold_window', 3600)))), 'Normal user denied access to flooded contact form.');
+
+ // Test that the admin user can still access the contact form even though
+ // the flood limit was reached.
+ $this->drupalLogin($this->admin_user);
+ $this->assertNoText('Try again later.', 'Admin user not denied access to flooded contact form.');
+ }
+
+ /**
+ * Fill out a user's personal contact form and submit.
+ *
+ * @param $account
+ * A user object of the user being contacted.
+ * @param $message
+ * An optional array with the form fields being used.
+ */
+ protected function submitPersonalContact($account, array $message = array()) {
+ $message += array(
+ 'subject' => $this->randomName(16),
+ 'message' => $this->randomName(64),
+ );
+ $this->drupalPost('user/' . $account->uid . '/contact', $message, t('Send message'));
+ }
+}
diff --git a/core/modules/contextual/contextual-rtl.css b/core/modules/contextual/contextual-rtl.css
new file mode 100644
index 000000000000..96ffef5cc239
--- /dev/null
+++ b/core/modules/contextual/contextual-rtl.css
@@ -0,0 +1,16 @@
+
+div.contextual-links-wrapper {
+ left: 5px;
+ right: auto;
+}
+div.contextual-links-wrapper ul.contextual-links {
+ -moz-border-radius: 0 4px 4px 4px;
+ -webkit-border-top-left-radius: 0;
+ -webkit-border-top-right-radius: 4px;
+ border-radius: 0 4px 4px 4px;
+ left: 0;
+ right: auto;
+}
+a.contextual-links-trigger {
+ text-indent: -90px;
+}
diff --git a/core/modules/contextual/contextual.api.php b/core/modules/contextual/contextual.api.php
new file mode 100644
index 000000000000..e8f33ee0b6c9
--- /dev/null
+++ b/core/modules/contextual/contextual.api.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by Contextual module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Alter a contextual links element before it is rendered.
+ *
+ * This hook is invoked by contextual_pre_render_links(). The renderable array
+ * of #type 'contextual_links', containing the entire contextual links data that
+ * is passed in by reference. Further links may be added or existing links can
+ * be altered.
+ *
+ * @param $element
+ * A renderable array representing the contextual links.
+ * @param $items
+ * An associative array containing the original contextual link items, as
+ * generated by menu_contextual_links(), which were used to build
+ * $element['#links'].
+ *
+ * @see hook_menu_contextual_links_alter()
+ * @see contextual_pre_render_links()
+ * @see contextual_element_info()
+ */
+function hook_contextual_links_view_alter(&$element, $items) {
+ // Add another class to all contextual link lists to facilitate custom
+ // styling.
+ $element['#attributes']['class'][] = 'custom-class';
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/contextual/contextual.css b/core/modules/contextual/contextual.css
new file mode 100644
index 000000000000..fd715147f2e3
--- /dev/null
+++ b/core/modules/contextual/contextual.css
@@ -0,0 +1,99 @@
+
+/**
+ * Contextual links regions.
+ */
+.contextual-links-region {
+ outline: none;
+ position: relative;
+}
+.contextual-links-region-active {
+ outline: #999 dashed 1px;
+}
+
+/**
+ * Contextual links.
+ */
+div.contextual-links-wrapper {
+ display: none;
+ font-size: 90%;
+ position: absolute;
+ right: 5px; /* LTR */
+ top: 2px;
+ z-index: 999;
+}
+html.js div.contextual-links-wrapper {
+ display: block;
+}
+a.contextual-links-trigger {
+ background: transparent url(images/gear-select.png) no-repeat 2px 0;
+ border: 1px solid transparent;
+ display: none;
+ height: 18px;
+ margin: 0;
+ padding: 0 2px;
+ outline: none;
+ text-indent: 34px; /* LTR */
+ width: 28px;
+ overflow: hidden;
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+a.contextual-links-trigger:hover,
+div.contextual-links-active a.contextual-links-trigger {
+ background-position: 2px -18px;
+}
+div.contextual-links-active a.contextual-links-trigger {
+ background-color: #fff;
+ border-color: #ccc;
+ border-bottom: none;
+ position: relative;
+ z-index: 1;
+ -moz-border-radius: 4px 4px 0 0;
+ -webkit-border-bottom-left-radius: 0;
+ -webkit-border-bottom-right-radius: 0;
+ border-radius: 4px 4px 0 0;
+}
+div.contextual-links-wrapper ul.contextual-links {
+ background-color: #fff;
+ border: 1px solid #ccc;
+ display: none;
+ margin: 0;
+ padding: 0.25em 0;
+ position: absolute;
+ right: 0;
+ text-align: left;
+ top: 18px;
+ white-space: nowrap;
+ -moz-border-radius: 4px 0 4px 4px; /* LTR */
+ -webkit-border-bottom-left-radius: 4px;
+ -webkit-border-bottom-right-radius: 4px;
+ -webkit-border-top-right-radius: 0; /* LTR */
+ -webkit-border-top-left-radius: 4px; /* LTR */
+ border-radius: 4px 0 4px 4px; /* LTR */
+}
+.contextual-links-region:hover a.contextual-links-trigger,
+div.contextual-links-active a.contextual-links-trigger,
+div.contextual-links-active ul.contextual-links {
+ display: block;
+}
+ul.contextual-links li {
+ line-height: 100%;
+ list-style: none;
+ list-style-image: none;
+ margin: 0;
+ padding: 0;
+}
+div.contextual-links-wrapper a {
+ text-decoration: none;
+}
+ul.contextual-links li a {
+ color: #333 !important;
+ display: block;
+ margin: 0.25em 0;
+ padding: 0.25em 1em 0.25em 0.5em;
+}
+ul.contextual-links li a:hover {
+ background-color: #bfdcee;
+}
diff --git a/core/modules/contextual/contextual.info b/core/modules/contextual/contextual.info
new file mode 100644
index 000000000000..a006377bcade
--- /dev/null
+++ b/core/modules/contextual/contextual.info
@@ -0,0 +1,5 @@
+name = Contextual links
+description = Provides contextual links to perform actions related to elements on a page.
+package = Core
+version = VERSION
+core = 8.x
diff --git a/core/modules/contextual/contextual.js b/core/modules/contextual/contextual.js
new file mode 100644
index 000000000000..ee5b7a0545a1
--- /dev/null
+++ b/core/modules/contextual/contextual.js
@@ -0,0 +1,43 @@
+(function ($) {
+
+Drupal.contextualLinks = Drupal.contextualLinks || {};
+
+/**
+ * Attach outline behavior for regions associated with contextual links.
+ */
+Drupal.behaviors.contextualLinks = {
+ attach: function (context) {
+ $('div.contextual-links-wrapper', context).once('contextual-links', function () {
+ var $wrapper = $(this);
+ var $region = $wrapper.closest('.contextual-links-region');
+ var $links = $wrapper.find('ul.contextual-links');
+ var $trigger = $('<a class="contextual-links-trigger" href="#" />').text(Drupal.t('Configure')).click(
+ function () {
+ $links.stop(true, true).slideToggle(100);
+ $wrapper.toggleClass('contextual-links-active');
+ return false;
+ }
+ );
+ // Attach hover behavior to trigger and ul.contextual-links.
+ $trigger.add($links).hover(
+ function () { $region.addClass('contextual-links-region-active'); },
+ function () { $region.removeClass('contextual-links-region-active'); }
+ );
+ // Hide the contextual links when user clicks a link or rolls out of the .contextual-links-region.
+ $region.bind('mouseleave click', Drupal.contextualLinks.mouseleave);
+ // Prepend the trigger.
+ $wrapper.prepend($trigger);
+ });
+ }
+};
+
+/**
+ * Disables outline for the region contextual links are associated with.
+ */
+Drupal.contextualLinks.mouseleave = function () {
+ $(this)
+ .find('.contextual-links-active').removeClass('contextual-links-active')
+ .find('ul.contextual-links').hide();
+};
+
+})(jQuery);
diff --git a/core/modules/contextual/contextual.module b/core/modules/contextual/contextual.module
new file mode 100644
index 000000000000..e3c0f8b01851
--- /dev/null
+++ b/core/modules/contextual/contextual.module
@@ -0,0 +1,168 @@
+<?php
+
+/**
+ * @file
+ * Adds contextual links to perform actions related to elements on a page.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function contextual_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#contextual':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Contextual links module displays links related to regions of pages on your site to users with <em>access contextual links</em> permission. For more information, see the online handbook entry for <a href="@contextual">Contextual links module</a>.', array('@contextual' => 'http://drupal.org/handbook/modules/contextual')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Displaying contextual links') . '</dt>';
+ $output .= '<dd>' . t('Contextual links are supplied by modules, to give you quick access to tasks associated with regions of pages on your site. For instance, if you have a custom menu block displayed in a sidebar of your site, the Blocks and Menus modules will supply links to configure the block and edit the menu. The Contextual links module collects these links into a list for display by your theme, and also adds JavaScript code to the page to hide the links initially, and display them when your mouse hovers over the block.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function contextual_permission() {
+ return array(
+ 'access contextual links' => array(
+ 'title' => t('Use contextual links'),
+ 'description' => t('Use contextual links to perform actions related to elements on a page.'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_library_info().
+ */
+function contextual_library_info() {
+ $path = drupal_get_path('module', 'contextual');
+ $libraries['contextual-links'] = array(
+ 'title' => 'Contextual links',
+ 'website' => 'http://drupal.org/node/473268',
+ 'version' => '1.0',
+ 'js' => array(
+ $path . '/contextual.js' => array(),
+ ),
+ 'css' => array(
+ $path . '/contextual.css' => array(),
+ ),
+ );
+ return $libraries;
+}
+
+/**
+ * Implements hook_element_info().
+ */
+function contextual_element_info() {
+ $types['contextual_links'] = array(
+ '#pre_render' => array('contextual_pre_render_links'),
+ '#theme' => 'links__contextual',
+ '#links' => array(),
+ '#prefix' => '<div class="contextual-links-wrapper">',
+ '#suffix' => '</div>',
+ '#attributes' => array(
+ 'class' => array('contextual-links'),
+ ),
+ '#attached' => array(
+ 'library' => array(
+ array('contextual', 'contextual-links'),
+ ),
+ ),
+ );
+ return $types;
+}
+
+/**
+ * Template variable preprocessor for contextual links.
+ *
+ * @see contextual_pre_render_links()
+ */
+function contextual_preprocess(&$variables, $hook) {
+ // Nothing to do here if the user is not permitted to access contextual links.
+ if (!user_access('access contextual links')) {
+ return;
+ }
+
+ $hooks = theme_get_registry(FALSE);
+
+ // Determine the primary theme function argument.
+ if (!empty($hooks[$hook]['variables'])) {
+ $keys = array_keys($hooks[$hook]['variables']);
+ $key = $keys[0];
+ }
+ elseif (!empty($hooks[$hook]['render element'])) {
+ $key = $hooks[$hook]['render element'];
+ }
+ if (!empty($key) && isset($variables[$key])) {
+ $element = $variables[$key];
+ }
+
+ if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) {
+ // Initialize the template variable as a renderable array.
+ $variables['title_suffix']['contextual_links'] = array(
+ '#type' => 'contextual_links',
+ '#contextual_links' => $element['#contextual_links'],
+ '#element' => $element,
+ );
+ // Mark this element as potentially having contextual links attached to it.
+ $variables['classes_array'][] = 'contextual-links-region';
+ }
+}
+
+/**
+ * Build a renderable array for contextual links.
+ *
+ * @param $element
+ * A renderable array containing a #contextual_links property, which is a
+ * keyed array. Each key is the name of the implementing module, and each
+ * value is an array that forms the function arguments for
+ * menu_contextual_links(). For example:
+ * @code
+ * array('#contextual_links' => array(
+ * 'block' => array('admin/structure/block/manage', array('system', 'navigation')),
+ * 'menu' => array('admin/structure/menu/manage', array('navigation')),
+ * ))
+ * @endcode
+ *
+ * @return
+ * A renderable array representing contextual links.
+ *
+ * @see menu_contextual_links()
+ */
+function contextual_pre_render_links($element) {
+ // Retrieve contextual menu links.
+ $items = array();
+ foreach ($element['#contextual_links'] as $module => $args) {
+ $items += menu_contextual_links($module, $args[0], $args[1]);
+ }
+
+ // Transform contextual links into parameters suitable for theme_link().
+ $links = array();
+ foreach ($items as $class => $item) {
+ $class = drupal_html_class($class);
+ $links[$class] = array(
+ 'title' => $item['title'],
+ 'href' => $item['href'],
+ );
+ // @todo theme_links() should *really* use the same parameters as l().
+ $item['localized_options'] += array('query' => array());
+ $item['localized_options']['query'] += drupal_get_destination();
+ $links[$class] += $item['localized_options'];
+ }
+ $element['#links'] = $links;
+
+ // Allow modules to alter the renderable contextual links element.
+ drupal_alter('contextual_links_view', $element, $items);
+
+ // If there are no links, tell drupal_render() to abort rendering.
+ if (empty($element['#links'])) {
+ $element['#printed'] = TRUE;
+ }
+
+ return $element;
+}
+
diff --git a/core/modules/contextual/images/gear-select.png b/core/modules/contextual/images/gear-select.png
new file mode 100644
index 000000000000..adf65822d442
--- /dev/null
+++ b/core/modules/contextual/images/gear-select.png
Binary files differ
diff --git a/core/modules/dashboard/dashboard-rtl.css b/core/modules/dashboard/dashboard-rtl.css
new file mode 100644
index 000000000000..cfccfa0310a9
--- /dev/null
+++ b/core/modules/dashboard/dashboard-rtl.css
@@ -0,0 +1,25 @@
+#dashboard div.dashboard-region {
+ float: right;
+}
+#dashboard #disabled-blocks .block, #dashboard .block-placeholder {
+ float: right;
+ margin: 3px 0 3px 3px;
+ padding: 6px 8px 6px 4px;
+}
+#dashboard .canvas-content a.button {
+ margin: 0 10px 0 0;
+}
+#dashboard .ui-sortable .block h2 {
+ background-position: right -39px;
+ padding: 0 19px;
+}
+#dashboard.customize-inactive #disabled-blocks .block:hover h2 {
+ background-position: right -39px;
+}
+#dashboard.customize-inactive .dashboard-region .ui-sortable .block:hover h2 {
+ background-position: right -36px;
+}
+#dashboard div#dashboard_main {
+ margin-left: 1%;
+ margin-right: 0;
+}
diff --git a/core/modules/dashboard/dashboard.api.php b/core/modules/dashboard/dashboard.api.php
new file mode 100644
index 000000000000..00bfde5a30af
--- /dev/null
+++ b/core/modules/dashboard/dashboard.api.php
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Dashboard module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Adds regions to the dashboard.
+ *
+ * @return
+ * An array whose keys are the names of the dashboard regions and whose
+ * values are the titles that will be displayed in the blocks administration
+ * interface. The keys are also used as theme wrapper functions.
+ */
+function hook_dashboard_regions() {
+ // Define a new dashboard region. Your module can also then define
+ // theme_mymodule_dashboard_region() as a theme wrapper function to control
+ // the region's appearance.
+ return array('mymodule_dashboard_region' => "My module's dashboard region");
+}
+
+/**
+ * Alter dashboard regions provided by modules.
+ *
+ * @param $regions
+ * An array containing all dashboard regions, in the format provided by
+ * hook_dashboard_regions().
+ */
+function hook_dashboard_regions_alter($regions) {
+ // Remove the sidebar region defined by the core dashboard module.
+ unset($regions['dashboard_sidebar']);
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/dashboard/dashboard.css b/core/modules/dashboard/dashboard.css
new file mode 100644
index 000000000000..9996ba9d77d2
--- /dev/null
+++ b/core/modules/dashboard/dashboard.css
@@ -0,0 +1,104 @@
+#dashboard div.dashboard-region {
+ float: left;
+ min-height: 1px;
+}
+#dashboard div#dashboard_main {
+ margin-right: 1%; /* LTR */
+ width: 65%;
+}
+#dashboard div#dashboard_sidebar {
+ width: 33%;
+}
+#dashboard div.block {
+ margin-bottom: 20px;
+}
+#dashboard .dashboard-region .block {
+ clear: both;
+}
+#dashboard .dashboard-region .block-placeholder {
+ display: block;
+ height: 1.6em;
+ margin: 0 0 20px 0;
+ padding: 0;
+ width: 100%;
+}
+#dashboard div.block h2 {
+ float: none;
+}
+#dashboard #disabled-blocks .block,
+#dashboard .block-placeholder {
+ background: #E2E1DC;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+ float: left; /* LTR */
+ margin: 3px 3px 3px 0; /* LTR */
+ padding: 6px 4px 6px 8px; /* LTR */
+}
+#dashboard .dashboard-add-other-blocks {
+ margin: 10px 0 0 0;
+}
+#dashboard .ui-sortable {
+ border: 2px dashed #CCC;
+ padding: 10px;
+}
+#dashboard .canvas-content {
+ padding: 10px;
+}
+#dashboard .canvas-content a.button {
+ color: #5A5A5A;
+ margin: 0 0 0 10px; /* LTR */
+ text-decoration: none;
+}
+#dashboard .region {
+ margin: 5px;
+}
+#dashboard #disabled-blocks {
+ padding: 5px 0;
+}
+#dashboard #disabled-blocks .ui-sortable {
+ background-color: #777;
+ border: 0;
+ padding: 0;
+}
+#dashboard #disabled-blocks .region {
+ background-color: #E0E0D8;
+ border: #CCC 1px solid;
+ padding: 10px;
+}
+#dashboard #disabled-blocks h2 {
+ display: inline;
+ font-weight: normal;
+ white-space: nowrap;
+}
+#dashboard #disabled-blocks .block {
+ background: #444;
+ color: #FFF;
+}
+#dashboard.customize-inactive #disabled-blocks .block:hover {
+ background: #0074BD;
+}
+#dashboard #disabled-blocks .block-placeholder {
+ height: 1.6em;
+ width: 30px;
+}
+#dashboard #disabled-blocks .block .content,
+#dashboard .ui-sortable-helper .content {
+ display: none;
+}
+#dashboard .ui-sortable .block {
+ cursor: move;
+ min-height: 1px;
+}
+#dashboard .ui-sortable .block h2 {
+ background: transparent url(../../misc/draggable.png) no-repeat 0px -39px;
+ padding: 0 17px;
+}
+#dashboard.customize-inactive #disabled-blocks .block:hover h2 {
+ background: #0074BD url(../../misc/draggable.png) no-repeat 0px -39px;
+ color: #FFF;
+}
+#dashboard.customize-inactive .dashboard-region .ui-sortable .block:hover h2 {
+ background: #0074BD url(../../misc/draggable.png) no-repeat 3px -36px;
+ color: #FFF;
+}
diff --git a/core/modules/dashboard/dashboard.info b/core/modules/dashboard/dashboard.info
new file mode 100644
index 000000000000..f98b235b930b
--- /dev/null
+++ b/core/modules/dashboard/dashboard.info
@@ -0,0 +1,8 @@
+name = Dashboard
+description = Provides a dashboard page in the administrative interface for organizing administrative tasks and tracking information within your site.
+core = 8.x
+package = Core
+version = VERSION
+files[] = dashboard.test
+dependencies[] = block
+configure = admin/dashboard/customize
diff --git a/core/modules/dashboard/dashboard.install b/core/modules/dashboard/dashboard.install
new file mode 100644
index 000000000000..502182625232
--- /dev/null
+++ b/core/modules/dashboard/dashboard.install
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the dashboard module.
+ */
+
+/**
+ * Implements hook_disable().
+ *
+ * Stash a list of blocks enabled on the dashboard, so they can be re-enabled
+ * if the dashboard is re-enabled. Then disable those blocks, since the
+ * dashboard regions will no longer be defined.
+ */
+function dashboard_disable() {
+ // Stash a list of currently enabled blocks.
+ $stashed_blocks = array();
+
+ $result = db_select('block', 'b')
+ ->fields('b', array('module', 'delta', 'region'))
+ ->condition('b.region', dashboard_regions(), 'IN')
+ ->execute();
+
+ foreach ($result as $block) {
+ $stashed_blocks[] = array(
+ 'module' => $block->module,
+ 'delta' => $block->delta,
+ 'region' => $block->region,
+ );
+ }
+ variable_set('dashboard_stashed_blocks', $stashed_blocks);
+
+ // Disable the dashboard blocks.
+ db_update('block')
+ ->fields(array(
+ 'status' => 0,
+ 'region' => BLOCK_REGION_NONE,
+ ))
+ ->condition('region', dashboard_regions(), 'IN')
+ ->execute();
+}
+
+/**
+ * Implements hook_enable().
+ *
+ * Restores blocks to the dashboard that were there when the dashboard module
+ * was disabled.
+ */
+function dashboard_enable() {
+ global $theme_key;
+ if (!$stashed_blocks = variable_get('dashboard_stashed_blocks')) {
+ return;
+ }
+ if (!$admin_theme = variable_get('admin_theme')) {
+ drupal_theme_initialize();
+ $admin_theme = $theme_key;
+ }
+ foreach ($stashed_blocks as $block) {
+ db_update('block')
+ ->fields(array(
+ 'status' => 1,
+ 'region' => $block['region']
+ ))
+ ->condition('module', $block['module'])
+ ->condition('delta', $block['delta'])
+ ->condition('theme', $admin_theme)
+ ->condition('status', 0)
+ ->execute();
+ }
+ variable_del('dashboard_stashed_blocks');
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function dashboard_uninstall() {
+ variable_del('dashboard_stashed_blocks');
+}
diff --git a/core/modules/dashboard/dashboard.js b/core/modules/dashboard/dashboard.js
new file mode 100644
index 000000000000..ebecbf65a445
--- /dev/null
+++ b/core/modules/dashboard/dashboard.js
@@ -0,0 +1,218 @@
+(function ($) {
+
+/**
+ * Implementation of Drupal.behaviors for dashboard.
+ */
+Drupal.behaviors.dashboard = {
+ attach: function (context, settings) {
+ $('#dashboard', context).once(function () {
+ $(this).prepend('<div class="customize clearfix"><ul class="action-links"><li><a href="#">' + Drupal.t('Customize dashboard') + '</a></li></ul><div class="canvas"></div></div>');
+ $('.customize .action-links a', this).click(Drupal.behaviors.dashboard.enterCustomizeMode);
+ });
+ Drupal.behaviors.dashboard.addPlaceholders();
+ if (Drupal.settings.dashboard.launchCustomize) {
+ Drupal.behaviors.dashboard.enterCustomizeMode();
+ }
+ },
+
+ addPlaceholders: function() {
+ $('#dashboard .dashboard-region .region').each(function () {
+ var empty_text = "";
+ // If the region is empty
+ if ($('.block', this).length == 0) {
+ // Check if we are in customize mode and grab the correct empty text
+ if ($('#dashboard').hasClass('customize-mode')) {
+ empty_text = Drupal.settings.dashboard.emptyRegionTextActive;
+ } else {
+ empty_text = Drupal.settings.dashboard.emptyRegionTextInactive;
+ }
+ // We need a placeholder.
+ if ($('.placeholder', this).length == 0) {
+ $(this).append('<div class="placeholder"></div>');
+ }
+ $('.placeholder', this).html(empty_text);
+ }
+ else {
+ $('.placeholder', this).remove();
+ }
+ });
+ },
+
+ /**
+ * Enter "customize" mode by displaying disabled blocks.
+ */
+ enterCustomizeMode: function () {
+ $('#dashboard').addClass('customize-mode customize-inactive');
+ Drupal.behaviors.dashboard.addPlaceholders();
+ // Hide the customize link
+ $('#dashboard .customize .action-links').hide();
+ // Load up the disabled blocks
+ $('div.customize .canvas').load(Drupal.settings.dashboard.drawer, Drupal.behaviors.dashboard.setupDrawer);
+ },
+
+ /**
+ * Exit "customize" mode by simply forcing a page refresh.
+ */
+ exitCustomizeMode: function () {
+ $('#dashboard').removeClass('customize-mode customize-inactive');
+ Drupal.behaviors.dashboard.addPlaceholders();
+ location.href = Drupal.settings.dashboard.dashboard;
+ },
+
+ /**
+ * Helper for enterCustomizeMode; sets up drag-and-drop and close button.
+ */
+ setupDrawer: function () {
+ $('div.customize .canvas-content input').click(Drupal.behaviors.dashboard.exitCustomizeMode);
+ $('div.customize .canvas-content').append('<a class="button" href="' + Drupal.settings.dashboard.dashboard + '">' + Drupal.t('Done') + '</a>');
+
+ // Initialize drag-and-drop.
+ var regions = $('#dashboard div.region');
+ regions.sortable({
+ connectWith: regions,
+ cursor: 'move',
+ cursorAt: {top:0},
+ dropOnEmpty: true,
+ items: '> div.block, > div.disabled-block',
+ placeholder: 'block-placeholder clearfix',
+ tolerance: 'pointer',
+ start: Drupal.behaviors.dashboard.start,
+ over: Drupal.behaviors.dashboard.over,
+ sort: Drupal.behaviors.dashboard.sort,
+ update: Drupal.behaviors.dashboard.update
+ });
+ },
+
+ /**
+ * While dragging, make the block appear as a disabled block
+ *
+ * This function is called on the jQuery UI Sortable "start" event.
+ *
+ * @param event
+ * The event that triggered this callback.
+ * @param ui
+ * An object containing information about the item that is being dragged.
+ */
+ start: function (event, ui) {
+ $('#dashboard').removeClass('customize-inactive');
+ var item = $(ui.item);
+
+ // If the block is already in disabled state, don't do anything.
+ if (!item.hasClass('disabled-block')) {
+ item.css({height: 'auto'});
+ }
+ },
+
+ /**
+ * While dragging, adapt block's width to the width of the region it is moved
+ * into.
+ *
+ * This function is called on the jQuery UI Sortable "over" event.
+ *
+ * @param event
+ * The event that triggered this callback.
+ * @param ui
+ * An object containing information about the item that is being dragged.
+ */
+ over: function (event, ui) {
+ var item = $(ui.item);
+
+ // If the block is in disabled state, remove width.
+ if ($(this).closest('#disabled-blocks').length) {
+ item.css('width', '');
+ }
+ else {
+ item.css('width', $(this).width());
+ }
+ },
+
+ /**
+ * While dragging, adapt block's position to stay connected with the position
+ * of the mouse pointer.
+ *
+ * This function is called on the jQuery UI Sortable "sort" event.
+ *
+ * @param event
+ * The event that triggered this callback.
+ * @param ui
+ * An object containing information about the item that is being dragged.
+ */
+ sort: function (event, ui) {
+ var item = $(ui.item);
+
+ if (event.pageX > ui.offset.left + item.width()) {
+ item.css('left', event.pageX);
+ }
+ },
+
+ /**
+ * Send block order to the server, and expand previously disabled blocks.
+ *
+ * This function is called on the jQuery UI Sortable "update" event.
+ *
+ * @param event
+ * The event that triggered this callback.
+ * @param ui
+ * An object containing information about the item that was just dropped.
+ */
+ update: function (event, ui) {
+ $('#dashboard').addClass('customize-inactive');
+ var item = $(ui.item);
+
+ // If the user dragged a disabled block, load the block contents.
+ if (item.hasClass('disabled-block')) {
+ var module, delta, itemClass;
+ itemClass = item.attr('class');
+ // Determine the block module and delta.
+ module = itemClass.match(/\bmodule-(\S+)\b/)[1];
+ delta = itemClass.match(/\bdelta-(\S+)\b/)[1];
+
+ // Load the newly enabled block's content.
+ $.get(Drupal.settings.dashboard.blockContent + '/' + module + '/' + delta, {},
+ function (block) {
+ if (block) {
+ item.html(block);
+ }
+
+ if (item.find('div.content').is(':empty')) {
+ item.find('div.content').html(Drupal.settings.dashboard.emptyBlockText);
+ }
+
+ Drupal.attachBehaviors(item);
+ },
+ 'html'
+ );
+ // Remove the "disabled-block" class, so we don't reload its content the
+ // next time it's dragged.
+ item.removeClass("disabled-block");
+ }
+
+ Drupal.behaviors.dashboard.addPlaceholders();
+
+ // Let the server know what the new block order is.
+ $.post(Drupal.settings.dashboard.updatePath, {
+ 'form_token': Drupal.settings.dashboard.formToken,
+ 'regions': Drupal.behaviors.dashboard.getOrder
+ }
+ );
+ },
+
+ /**
+ * Return the current order of the blocks in each of the sortable regions,
+ * in query string format.
+ */
+ getOrder: function () {
+ var order = [];
+ $('#dashboard div.region').each(function () {
+ var region = $(this).parent().attr('id').replace(/-/g, '_');
+ var blocks = $(this).sortable('toArray');
+ $.each(blocks, function() {
+ order.push(region + '[]=' + this);
+ });
+ });
+ order = order.join('&');
+ return order;
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/dashboard/dashboard.module b/core/modules/dashboard/dashboard.module
new file mode 100644
index 000000000000..1216cc00c117
--- /dev/null
+++ b/core/modules/dashboard/dashboard.module
@@ -0,0 +1,675 @@
+<?php
+
+/**
+ * Implements hook_help().
+ */
+function dashboard_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#dashboard':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Dashboard module provides a <a href="@dashboard">Dashboard page</a> in the administrative interface for organizing administrative tasks and navigation, and tracking information within your site. The Dashboard page contains blocks, which you can add to and arrange using the drag-and-drop interface that appears when you click on the <em>Customize dashboard</em> link. Within this interface, blocks that are not primarily used for site administration do not appear by default, but can be added via the <em>Add other blocks</em> link. For more information, see the online handbook entry for <a href="@handbook">Dashboard module</a>.', array('@handbook' => 'http://drupal.org/handbook/modules/dashboard', '@dashboard' => url('admin/dashboard'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Tracking user activity') . '</dt>';
+ $output .= '<dd>' . t("By enabling blocks such as <em>Who's online</em> and <em>Who's new</em>, site users can track who is logged in and new user signups at a centralized location.") . '</dd>';
+ $output .= '<dt>' . t('Tracking content activity') . '</dt>';
+ $output .= '<dd>' . t('By enabling blocks such as <em>New forum topics</em> and <em>Recent comments</em>, site users can view newly added site content at a glance.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/dashboard/configure':
+ // @todo This assumes the current page is being displayed using the same
+ // theme that the dashboard is displayed in.
+ $output = '<p>' . t('Rearrange blocks for display on the <a href="@dashboard-url">Dashboard page</a>. Blocks placed in the <em>Dashboard (inactive)</em> region are not displayed when viewing the Dashboard page, but are available within its <em>Customize dashboard</em> interface. Removing a block from active dashboard display makes it available on the main <a href="@blocks-url">blocks administration page</a>.', array('@dashboard-url' => url('admin/dashboard'), '@blocks-url' => url("admin/structure/block/list/{$GLOBALS['theme_key']}"))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function dashboard_menu() {
+ $items['admin/dashboard'] = array(
+ 'title' => 'Dashboard',
+ 'description' => 'View and customize your dashboard.',
+ 'page callback' => 'dashboard_admin',
+ 'access arguments' => array('access dashboard'),
+ // Make this appear first, so for example, in admin menus, it shows up on
+ // the top corner of the window as a convenient "home link".
+ 'weight' => -15,
+ );
+ $items['admin/dashboard/configure'] = array(
+ 'title' => 'Configure available dashboard blocks',
+ 'description' => 'Configure which blocks can be shown on the dashboard.',
+ 'page callback' => 'dashboard_admin_blocks',
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ );
+ $items['admin/dashboard/customize'] = array(
+ 'title' => 'Customize dashboard',
+ 'description' => 'Customize your dashboard.',
+ 'page callback' => 'dashboard_admin',
+ 'page arguments' => array(TRUE),
+ 'access arguments' => array('access dashboard'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ );
+ $items['admin/dashboard/drawer'] = array(
+ 'page callback' => 'dashboard_show_disabled',
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['admin/dashboard/block-content/%/%'] = array(
+ 'page callback' => 'dashboard_show_block_content',
+ 'page arguments' => array(3, 4),
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['admin/dashboard/update'] = array(
+ 'page callback' => 'dashboard_update',
+ 'access arguments' => array('administer blocks'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function dashboard_permission() {
+ return array(
+ 'access dashboard' => array(
+ 'title' => t('View the administrative dashboard'),
+ // Note: We translate the 'Administer blocks' permission string here with
+ // a separate t() call, to make sure it gets the same translation as when
+ // it's in block_permission().
+ 'description' => t('Customizing the dashboard requires the !permission-name permission.', array(
+ '!permission-name' => l(t('Administer blocks'), 'admin/people/permissions', array('fragment' => 'module-block')),
+ )),
+ ),
+ );
+}
+
+/**
+ * Implements hook_block_info_alter().
+ */
+function dashboard_block_info_alter(&$blocks, $theme, $code_blocks) {
+ $admin_theme = variable_get('admin_theme');
+ if (($admin_theme && $theme == $admin_theme) || (!$admin_theme && $theme == variable_get('theme_default', 'bartik'))) {
+ foreach ($blocks as $module => &$module_blocks) {
+ foreach ($module_blocks as $delta => &$block) {
+ // Make administrative blocks that are not already in use elsewhere
+ // available for the dashboard.
+ if (empty($block['status']) && (empty($block['region']) || $block['region'] == BLOCK_REGION_NONE) && !empty($code_blocks[$module][$delta]['properties']['administrative'])) {
+ $block['status'] = 1;
+ $block['region'] = 'dashboard_inactive';
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_block_list_alter().
+ *
+ * Skip rendering dashboard blocks when not on the dashboard page itself. This
+ * prevents expensive dashboard blocks from causing performance issues on pages
+ * where they will never be displayed.
+ */
+function dashboard_block_list_alter(&$blocks) {
+ if (!dashboard_is_visible()) {
+ foreach ($blocks as $key => $block) {
+ if (in_array($block->region, dashboard_regions())) {
+ unset($blocks[$key]);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_page_build().
+ *
+ * Display dashboard blocks in the main content region.
+ */
+function dashboard_page_build(&$page) {
+ global $theme_key;
+
+ if (dashboard_is_visible()) {
+ $block_info = array();
+
+ // Create a wrapper for the dashboard itself, then insert each dashboard
+ // region into it.
+ $page['content']['dashboard'] = array('#theme_wrappers' => array('dashboard'));
+ foreach (dashboard_regions() as $region) {
+ // Do not show dashboard blocks that are disabled.
+ if ($region == 'dashboard_inactive') {
+ continue;
+ }
+ // Insert regions even when they are empty, so that they will be
+ // displayed when the dashboard is being configured.
+ $page['content']['dashboard'][$region] = !empty($page[$region]) ? $page[$region] : array();
+ $page['content']['dashboard'][$region]['#dashboard_region'] = $region;
+ // Allow each dashboard region to be themed differently, or fall back on
+ // the generic theme wrapper function for dashboard regions.
+ $page['content']['dashboard'][$region]['#theme_wrappers'][] = array($region, 'dashboard_region');
+ unset($page[$region]);
+ $blocks_found = array();
+ foreach ($page['content']['dashboard'][$region] as $item) {
+ if (isset($item['#theme_wrappers']) && is_array($item['#theme_wrappers']) && in_array('block', $item['#theme_wrappers'])) {
+ // If this item is a block, ensure it has a subject.
+ if (empty($item['#block']->subject)) {
+ // Locally cache info data for the object for all blocks, in case
+ // we find a block similarly missing title from the same module.
+ if (!isset($block_info[$item['#block']->module])) {
+ $block_info[$item['#block']->module] = module_invoke($item['#block']->module, 'block_info');
+ }
+ $item['#block']->subject = $block_info[$item['#block']->module][$item['#block']->delta]['info'];
+ }
+ $blocks_found[$item['#block']->module . '_' . $item['#block']->delta] = TRUE;
+ }
+ }
+
+ // Find blocks which were not yet displayed on the page (were empty), and
+ // add placeholder items in their place for rendering.
+ $block_list = db_select('block')
+ ->condition('theme', $theme_key)
+ ->condition('status', 1)
+ ->condition('region', $region)
+ ->fields('block')
+ ->execute();
+ foreach ($block_list as $block) {
+ if (!isset($blocks_found[$block->module . '_' . $block->delta])) {
+ $block->enabled = $block->page_match = TRUE;
+ $block->content = array('#markup' => '<div class="dashboard-block-empty">(empty)</div>');
+ if (!isset($block_info[$block->module])) {
+ $block_info[$block->module] = module_invoke($block->module, 'block_info');
+ }
+ $block->subject = t('@title', array('@title' => $block_info[$block->module][$block->delta]['info']));
+ $block_render = array($block->module . '_' . $block->delta => $block);
+ $build = _block_get_renderable_region($block_render);
+ $page['content']['dashboard'][$block->region][] = $build;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Add regions to each theme to store the dashboard blocks.
+ */
+function dashboard_system_info_alter(&$info, $file, $type) {
+ if ($type == 'theme') {
+ // Add the dashboard regions (the "inactive" region should always appear
+ // last in the list, for usability reasons).
+ $dashboard_regions = dashboard_region_descriptions();
+ if (isset($dashboard_regions['dashboard_inactive'])) {
+ $inactive_region = $dashboard_regions['dashboard_inactive'];
+ unset($dashboard_regions['dashboard_inactive']);
+ $dashboard_regions['dashboard_inactive'] = $inactive_region;
+ }
+ $info['regions'] += $dashboard_regions;
+ // Indicate that these regions are intended to be displayed whenever the
+ // dashboard is displayed in an overlay. This information is provided for
+ // any module that might need to use it, not just the core Overlay module.
+ $info['overlay_regions'] = !empty($info['overlay_regions']) ? array_merge($info['overlay_regions'], dashboard_regions()) : dashboard_regions();
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function dashboard_theme() {
+ return array(
+ 'dashboard' => array(
+ 'render element' => 'element',
+ ),
+ 'dashboard_admin' => array(
+ 'render element' => 'element',
+ ),
+ 'dashboard_region' => array(
+ 'render element' => 'element',
+ ),
+ 'dashboard_disabled_blocks' => array(
+ 'variables' => array('blocks' => NULL),
+ ),
+ 'dashboard_disabled_block' => array(
+ 'variables' => array('block' => NULL),
+ ),
+ 'dashboard_admin_display_form' => array(
+ // When building the form for configuring dashboard blocks, reuse the
+ // Block module's template for the main block configuration form.
+ 'template' => 'block-admin-display-form',
+ 'path' => drupal_get_path('module', 'block'),
+ 'file' => 'block.admin.inc',
+ 'render element' => 'form',
+ ),
+ );
+}
+
+/**
+ * Implements hook_forms().
+ */
+function dashboard_forms() {
+ // Reroute the dashboard configuration form to the main blocks administration
+ // form. This allows us to distinguish them by form ID in hook_form_alter().
+ $forms['dashboard_admin_display_form'] = array(
+ 'callback' => 'block_admin_display_form',
+ );
+
+ return $forms;
+}
+
+/**
+ * Dashboard page callback.
+ *
+ * @param $launch_customize
+ * Whether to launch in customization mode right away. TRUE or FALSE.
+ */
+function dashboard_admin($launch_customize = FALSE) {
+ $js_settings = array(
+ 'dashboard' => array(
+ 'drawer' => url('admin/dashboard/drawer'),
+ 'blockContent' => url('admin/dashboard/block-content'),
+ 'updatePath' => url('admin/dashboard/update'),
+ 'formToken' => drupal_get_token('dashboard-update'),
+ 'launchCustomize' => $launch_customize,
+ 'dashboard' => url('admin/dashboard'),
+ 'emptyBlockText' => t('(empty)'),
+ 'emptyRegionTextInactive' => t('This dashboard region is empty. Click <em>Customize dashboard</em> to add blocks to it.'),
+ 'emptyRegionTextActive' => t('DRAG HERE'),
+ ),
+ );
+ $build = array(
+ '#theme' => 'dashboard_admin',
+ '#message' => t('To customize the dashboard page, move blocks to the dashboard regions on the <a href="@dashboard">Dashboard administration page</a>, or enable JavaScript on this page to use the drag-and-drop interface.', array('@dashboard' => url('admin/dashboard/configure'))),
+ '#access' => user_access('administer blocks'),
+ '#attached' => array(
+ 'js' => array(
+ drupal_get_path('module', 'dashboard') . '/dashboard.js',
+ array('data' => $js_settings, 'type' => 'setting'),
+ ),
+ 'library' => array(array('system', 'ui.sortable')),
+ ),
+ );
+ return $build;
+}
+
+/**
+ * Menu page callback: builds the page for administering dashboard blocks.
+ *
+ * This page reuses the Block module's administration form but limits editing
+ * to blocks that are available to appear on the dashboard.
+ *
+ * @see block_admin_display()
+ * @see block_admin_display_form()
+ * @see dashboard_form_dashboard_admin_display_form_alter()
+ * @see template_preprocess_dashboard_admin_display_form()
+ */
+function dashboard_admin_blocks() {
+ global $theme_key;
+ drupal_theme_initialize();
+ module_load_include('inc', 'block', 'block.admin');
+
+ // Prepare the blocks for the current theme, and remove those that are
+ // currently displayed in non-dashboard regions.
+ // @todo This assumes the current page is being displayed using the same
+ // theme that the dashboard is displayed in.
+ $blocks = block_admin_display_prepare_blocks($theme_key);
+ $dashboard_regions = dashboard_region_descriptions();
+ $regions_to_remove = array_diff_key(system_region_list($theme_key, REGIONS_VISIBLE), $dashboard_regions);
+ foreach ($blocks as $id => $block) {
+ if (isset($regions_to_remove[$block['region']])) {
+ unset($blocks[$id]);
+ }
+ }
+
+ // Pass in the above blocks and dashboard regions to the form, so that only
+ // dashboard-related regions will be displayed.
+ return drupal_get_form('dashboard_admin_display_form', $blocks, $theme_key, $dashboard_regions);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function dashboard_form_block_admin_display_form_alter(&$form, &$form_state, $form_id) {
+ // Hide dashboard regions (and any blocks placed within them) from the block
+ // administration form and from the options list on that form. This
+ // function is called for both the dashboard block configuration form and the
+ // standard block configuration form so that both forms can share the same
+ // constructor. As a result the form_id must be checked.
+ if ($form_id != 'dashboard_admin_display_form') {
+ $dashboard_regions = dashboard_region_descriptions();
+ $form['block_regions']['#value'] = array_diff_key($form['block_regions']['#value'], $dashboard_regions);
+ foreach (element_children($form['blocks']) as $i) {
+ $block = &$form['blocks'][$i];
+ if (isset($block['region']['#default_value']) && isset($dashboard_regions[$block['region']['#default_value']]) && $block['region']['#default_value'] != 'dashboard_inactive') {
+ $block['#access'] = FALSE;
+ }
+ elseif (isset($block['region']['#options'])) {
+ $block['region']['#options'] = array_diff_key($block['region']['#options'], $dashboard_regions);
+ }
+ // Show inactive dashboard blocks as disabled on the main block
+ // administration form, so that they are available to place in other
+ // regions of the theme. Note that when the form is submitted, any such
+ // blocks which still remain disabled will immediately be put back in the
+ // 'dashboard_inactive' region, because dashboard_block_info_alter() is
+ // called when the blocks are rehashed. Fortunately, this is the exact
+ // behavior we want.
+ if ($block['region']['#default_value'] == 'dashboard_inactive') {
+ // @todo These do not wind up in correct alphabetical order.
+ $block['region']['#default_value'] = NULL;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function dashboard_form_dashboard_admin_display_form_alter(&$form, &$form_state) {
+ // Redirect the 'configure' and 'delete' links on each block back to the
+ // dashboard blocks administration page.
+ foreach ($form['blocks'] as &$block) {
+ if (isset($block['configure']['#href'])) {
+ $block['configure']['#options']['query']['destination'] = 'admin/dashboard/configure';
+ }
+ if (isset($block['delete']['#href'])) {
+ $block['delete']['#options']['query']['destination'] = 'admin/dashboard/configure';
+ }
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function dashboard_form_block_admin_configure_alter(&$form, &$form_state) {
+ global $theme_key;
+ drupal_theme_initialize();
+ // Hide the dashboard regions from the region select list on the block
+ // configuration form, for all themes except the current theme (since the
+ // other themes do not display the dashboard).
+ // @todo This assumes the current page is being displayed using the same
+ // theme that the dashboard is displayed in.
+ $dashboard_regions = dashboard_region_descriptions();
+ foreach (element_children($form['regions']) as $region_name) {
+ $region = &$form['regions'][$region_name];
+ if ($region_name != $theme_key && isset($region['#options'])) {
+ $region['#options'] = array_diff_key($region['#options'], $dashboard_regions);
+ }
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function dashboard_form_block_add_block_form_alter(&$form, &$form_state) {
+ dashboard_form_block_admin_configure_alter($form, $form_state);
+}
+
+/**
+ * Preprocesses variables for block-admin-display-form.tpl.php.
+ */
+function template_preprocess_dashboard_admin_display_form(&$variables) {
+ template_preprocess_block_admin_display_form($variables);
+ if (isset($variables['block_regions'][BLOCK_REGION_NONE])) {
+ $variables['block_regions'][BLOCK_REGION_NONE] = t('Other blocks');
+ }
+}
+
+/**
+ * Determines if the dashboard should be displayed on the current page.
+ *
+ * This function checks if the user is currently viewing the dashboard and has
+ * access to see it. It is used by other functions in the dashboard module to
+ * decide whether or not the dashboard content should be displayed to the
+ * current user.
+ *
+ * Although the menu system normally handles the above tasks, it only does so
+ * for the main page content. However, the dashboard is not part of the main
+ * page content, but rather is displayed in special regions of the page (so it
+ * can interface with the Block module's method of managing page regions). We
+ * therefore need to maintain this separate function to check the menu item for
+ * us.
+ *
+ * @return
+ * TRUE if the dashboard should be visible on the current page, FALSE
+ * otherwise.
+ *
+ * @see dashboard_block_list_alter()
+ * @see dashboard_page_build()
+ */
+function dashboard_is_visible() {
+ static $is_visible;
+ if (!isset($is_visible)) {
+ // If the current menu item represents the page on which we want to display
+ // the dashboard, and if the current user has access to see it, return
+ // TRUE.
+ $menu_item = menu_get_item();
+ $is_visible = isset($menu_item['page_callback']) && $menu_item['page_callback'] == 'dashboard_admin' && !empty($menu_item['access']);
+ }
+ return $is_visible;
+}
+
+/**
+ * Return an array of dashboard region descriptions, keyed by region name.
+ */
+function dashboard_region_descriptions() {
+ $regions = module_invoke_all('dashboard_regions');
+ drupal_alter('dashboard_regions', $regions);
+ return $regions;
+}
+
+/**
+ * Return an array of dashboard region names.
+ */
+function dashboard_regions() {
+ $regions = &drupal_static(__FUNCTION__);
+ if (!isset($regions)) {
+ $regions = array_keys(dashboard_region_descriptions());
+ }
+ return $regions;
+}
+
+/**
+ * Implements hook_dashboard_regions().
+ */
+function dashboard_dashboard_regions() {
+ return array(
+ 'dashboard_main' => 'Dashboard (main)',
+ 'dashboard_sidebar' => 'Dashboard (sidebar)',
+ 'dashboard_inactive' => 'Dashboard (inactive)',
+ );
+}
+
+/**
+ * Ajax callback to show disabled blocks in the dashboard customization mode.
+ */
+function dashboard_show_disabled() {
+ global $theme_key;
+
+ // Blocks are not necessarily initialized at this point.
+ $blocks = _block_rehash();
+
+ // Limit the list to blocks that are marked as disabled for the dashboard.
+ foreach ($blocks as $key => $block) {
+ if ($block['theme'] != $theme_key || $block['region'] != 'dashboard_inactive') {
+ unset($blocks[$key]);
+ }
+ }
+
+ // Theme the output and end the page request.
+ print theme('dashboard_disabled_blocks', array('blocks' => $blocks));
+ drupal_exit();
+}
+
+/**
+ * Ajax callback to display the rendered contents of a specific block.
+ *
+ * @param $module
+ * The block's module name.
+ * @param $delta
+ * The block's delta.
+ */
+function dashboard_show_block_content($module, $delta) {
+ drupal_theme_initialize();
+ global $theme_key;
+
+ $blocks = array();
+ $block_object = db_query("SELECT * FROM {block} WHERE theme = :theme AND module = :module AND delta = :delta", array(
+ ":theme" => $theme_key,
+ ":module" => $module,
+ ":delta" => $delta,
+ ))
+ ->fetchObject();
+ $block_object->enabled = $block_object->page_match = TRUE;
+ $blocks[$module . "_" . $delta] = $block_object;
+ $build = _block_get_renderable_region($blocks);
+ $rendered_block = drupal_render($build);
+ print $rendered_block;
+ drupal_exit();
+}
+
+/**
+ * Set the new weight of each region according to the drag-and-drop order.
+ */
+function dashboard_update() {
+ drupal_theme_initialize();
+ global $theme_key;
+ // Check the form token to make sure we have a valid request.
+ if (!empty($_REQUEST['form_token']) && drupal_valid_token($_REQUEST['form_token'], 'dashboard-update')) {
+ parse_str($_REQUEST['regions'], $regions);
+ foreach ($regions as $region_name => $blocks) {
+ if ($region_name == 'disabled_blocks') {
+ $region_name = 'dashboard_inactive';
+ }
+ foreach ($blocks as $weight => $block_string) {
+ // Parse the query string to determine the block's module and delta.
+ preg_match('/block-([^-]+)-(.+)/', $block_string, $matches);
+ $block = new stdClass();
+ $block->module = $matches[1];
+ $block->delta = $matches[2];
+
+ $block->region = $region_name;
+ $block->weight = $weight;
+ $block->status = 1;
+
+ db_merge('block')
+ ->key(array(
+ 'module' => $block->module,
+ 'delta' => $block->delta,
+ 'theme' => $theme_key,
+ ))
+ ->fields(array(
+ 'status' => $block->status,
+ 'weight' => $block->weight,
+ 'region' => $block->region,
+ 'pages' => '',
+ ))
+ ->execute();
+ }
+ }
+ drupal_set_message(t('The configuration options have been saved.'), 'status', FALSE);
+ }
+ drupal_exit();
+}
+
+/**
+ * Returns HTML for the entire dashboard.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing the properties of the dashboard
+ * region element, #dashboard_region and #children.
+ *
+ * @ingroup themeable
+ */
+function theme_dashboard($variables) {
+ extract($variables);
+ drupal_add_css(drupal_get_path('module', 'dashboard') . '/dashboard.css');
+ return '<div id="dashboard" class="clearfix">' . $element['#children'] . '</div>';
+}
+
+/**
+ * Returns HTML for the non-customizable part of the dashboard page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing a #message.
+ *
+ * @ingroup themeable
+ */
+function theme_dashboard_admin($variables) {
+ // We only return a simple help message, since the actual content of the page
+ // will be populated via the dashboard regions in dashboard_page_build().
+ return '<div class="customize-dashboard js-hide">' . $variables['element']['#message'] . '</div>';
+}
+
+/**
+ * Returns HTML for a generic dashboard region.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing the properties of the dashboard
+ * region element, #dashboard_region and #children.
+ *
+ * @ingroup themeable
+ */
+function theme_dashboard_region($variables) {
+ extract($variables);
+ $output = '<div id="' . $element['#dashboard_region'] . '" class="dashboard-region">';
+ $output .= '<div class="region clearfix">';
+ $output .= $element['#children'];
+ // Closing div.region
+ $output .= '</div>';
+ // Closing div.dashboard-region
+ $output .= '</div>';
+ return $output;
+}
+
+/**
+ * Returns HTML for a set of disabled blocks, for display in dashboard customization mode.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - blocks: An array of block objects from _block_rehash().
+ *
+ * @ingroup themeable
+ */
+function theme_dashboard_disabled_blocks($variables) {
+ extract($variables);
+ $output = '<div class="canvas-content"><p>' . t('Drag and drop these blocks to the columns below. Changes are automatically saved. More options are available on the <a href="@dashboard-url">configuration page</a>.', array('@dashboard-url' => url('admin/dashboard/configure'))) . '</p>';
+ $output .= '<div id="disabled-blocks"><div class="region disabled-blocks clearfix">';
+ foreach ($blocks as $block) {
+ $output .= theme('dashboard_disabled_block', array('block' => $block));
+ }
+ $output .= '<div class="clearfix"></div>';
+ $output .= '<p class="dashboard-add-other-blocks">' . l(t('Add other blocks'), 'admin/dashboard/configure') . '</p>';
+ $output .= '</div></div></div>';
+ return $output;
+}
+
+/**
+ * Returns HTML for a disabled block, for display in dashboard customization mode.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - block: A block object from _block_rehash().
+ *
+ * @ingroup themeable
+ */
+function theme_dashboard_disabled_block($variables) {
+ extract($variables);
+ $output = "";
+ if (isset($block)) {
+ $output .= '<div id="block-' . $block['module'] . '-' . $block['delta']
+ . '" class="disabled-block block block-' . $block['module'] . '-' . $block['delta']
+ . ' module-' . $block['module'] . ' delta-' . $block['delta'] . '">'
+ . '<h2>' . (!empty($block['title']) && $block['title'] != '<none>' ? check_plain($block['title']) : check_plain($block['info'])) . '</h2>'
+ . '<div class="content"></div>'
+ . '</div>';
+ }
+ return $output;
+}
diff --git a/core/modules/dashboard/dashboard.test b/core/modules/dashboard/dashboard.test
new file mode 100644
index 000000000000..7cb93f9f17ff
--- /dev/null
+++ b/core/modules/dashboard/dashboard.test
@@ -0,0 +1,140 @@
+<?php
+
+/**
+ * @file
+ * Tests for dashboard.module.
+ */
+
+class DashboardBlocksTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Dashboard blocks',
+ 'description' => 'Test blocks as used by the dashboard.',
+ 'group' => 'Dashboard',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create and log in an administrative user having access to the dashboard.
+ $admin_user = $this->drupalCreateUser(array('access dashboard', 'administer blocks', 'access administration pages', 'administer modules'));
+ $this->drupalLogin($admin_user);
+
+ // Make sure that the dashboard is using the same theme as the rest of the
+ // site (and in particular, the same theme used on 403 pages). This forces
+ // the dashboard blocks to be the same for an administrator as for a
+ // regular user, and therefore lets us test that the dashboard blocks
+ // themselves are specifically removed for a user who does not have access
+ // to the dashboard page.
+ theme_enable(array('stark'));
+ variable_set('theme_default', 'stark');
+ variable_set('admin_theme', 'stark');
+ }
+
+ /**
+ * Test adding a block to the dashboard and checking access to it.
+ */
+ function testDashboardAccess() {
+ // Add a new custom block to a dashboard region.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $this->randomName(8);
+ $custom_block['body[value]'] = $this->randomName(32);
+ $custom_block['regions[stark]'] = 'dashboard_main';
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ // Ensure admin access.
+ $this->drupalGet('admin/dashboard');
+ $this->assertResponse(200, t('Admin has access to the dashboard.'));
+ $this->assertRaw($custom_block['title'], t('Admin has access to a dashboard block.'));
+
+ // Ensure non-admin access is denied.
+ $normal_user = $this->drupalCreateUser();
+ $this->drupalLogin($normal_user);
+ $this->drupalGet('admin/dashboard');
+ $this->assertResponse(403, t('Non-admin has no access to the dashboard.'));
+ $this->assertNoText($custom_block['title'], t('Non-admin has no access to a dashboard block.'));
+ }
+
+ /**
+ * Test that dashboard regions are displayed or hidden properly.
+ */
+ function testDashboardRegions() {
+ $dashboard_regions = dashboard_region_descriptions();
+
+ // Ensure blocks can be placed in dashboard regions.
+ $this->drupalGet('admin/dashboard/configure');
+ foreach ($dashboard_regions as $region => $description) {
+ $elements = $this->xpath('//option[@value=:region]', array(':region' => $region));
+ $this->assertTrue(!empty($elements), t('%region is an available choice on the dashboard block configuration page.', array('%region' => $region)));
+ }
+
+ // Ensure blocks cannot be placed in dashboard regions on the standard
+ // blocks configuration page.
+ $this->drupalGet('admin/structure/block');
+ foreach ($dashboard_regions as $region => $description) {
+ $elements = $this->xpath('//option[@value=:region]', array(':region' => $region));
+ $this->assertTrue(empty($elements), t('%region is not an available choice on the block configuration page.', array('%region' => $region)));
+ }
+ }
+
+ /**
+ * Test that the dashboard module can be disabled and enabled again,
+ * retaining its blocks.
+ */
+ function testDisableEnable() {
+ // Add a new custom block to a dashboard region.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $this->randomName(8);
+ $custom_block['body[value]'] = $this->randomName(32);
+ $custom_block['regions[stark]'] = 'dashboard_main';
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+ $this->drupalGet('admin/dashboard');
+ $this->assertRaw($custom_block['title'], t('Block appears on the dashboard.'));
+
+ $edit = array();
+ $edit['modules[Core][dashboard][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ $this->assertNoRaw('assigned to the invalid region', t('Dashboard blocks gracefully disabled.'));
+ module_list(TRUE);
+ $this->assertFalse(module_exists('dashboard'), t('Dashboard disabled.'));
+
+ $edit['modules[Core][dashboard][enable]'] = 'dashboard';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ module_list(TRUE);
+ $this->assertTrue(module_exists('dashboard'), t('Dashboard enabled.'));
+
+ $this->drupalGet('admin/dashboard');
+ $this->assertRaw($custom_block['title'], t('Block still appears on the dashboard.'));
+ }
+
+ /**
+ * Test that defining a block with ['properties']['administrative'] = TRUE
+ * adds it as an available block for the dashboard.
+ */
+ function testBlockAvailability() {
+ // Test "Recent comments", which should be available (defined as
+ // "administrative") but not enabled.
+ $this->drupalGet('admin/dashboard');
+ $this->assertNoText(t('Recent comments'), t('"Recent comments" not on dashboard.'));
+ $this->drupalGet('admin/dashboard/drawer');
+ $this->assertText(t('Recent comments'), t('Drawer of disabled blocks includes a block defined as "administrative".'));
+ $this->assertNoText(t('Syndicate'), t('Drawer of disabled blocks excludes a block not defined as "administrative".'));
+ $this->drupalGet('admin/dashboard/configure');
+ $elements = $this->xpath('//select[@id=:id]//option[@selected="selected"]', array(':id' => 'edit-blocks-comment-recent-region'));
+ $this->assertTrue($elements[0]['value'] == 'dashboard_inactive', t('A block defined as "administrative" defaults to dashboard_inactive.'));
+
+ // Now enable the block on the dashboard.
+ $values = array();
+ $values['blocks[comment_recent][region]'] = 'dashboard_main';
+ $this->drupalPost('admin/dashboard/configure', $values, t('Save blocks'));
+ $this->drupalGet('admin/dashboard');
+ $this->assertText(t('Recent comments'), t('"Recent comments" was placed on dashboard.'));
+ $this->drupalGet('admin/dashboard/drawer');
+ $this->assertNoText(t('Recent comments'), t('Drawer of disabled blocks excludes enabled blocks.'));
+ }
+}
diff --git a/core/modules/dblog/dblog-rtl.css b/core/modules/dblog/dblog-rtl.css
new file mode 100644
index 000000000000..282fe971ddc3
--- /dev/null
+++ b/core/modules/dblog/dblog-rtl.css
@@ -0,0 +1,7 @@
+
+.form-item-type,
+.form-item-severity {
+ float: right;
+ padding-right: 0;
+ padding-left: .8em;
+}
diff --git a/core/modules/dblog/dblog.admin.inc b/core/modules/dblog/dblog.admin.inc
new file mode 100644
index 000000000000..b2da7eddf2d3
--- /dev/null
+++ b/core/modules/dblog/dblog.admin.inc
@@ -0,0 +1,381 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for the dblog module.
+ */
+
+/**
+ * Menu callback; displays a listing of log messages.
+ *
+ * Messages are truncated at 56 chars. Full-length message could be viewed at
+ * the message details page.
+ */
+function dblog_overview() {
+ $filter = dblog_build_filter_query();
+ $rows = array();
+ $classes = array(
+ WATCHDOG_DEBUG => 'dblog-debug',
+ WATCHDOG_INFO => 'dblog-info',
+ WATCHDOG_NOTICE => 'dblog-notice',
+ WATCHDOG_WARNING => 'dblog-warning',
+ WATCHDOG_ERROR => 'dblog-error',
+ WATCHDOG_CRITICAL => 'dblog-critical',
+ WATCHDOG_ALERT => 'dblog-alert',
+ WATCHDOG_EMERGENCY => 'dblog-emerg',
+ );
+
+ $build['dblog_filter_form'] = drupal_get_form('dblog_filter_form');
+ $build['dblog_clear_log_form'] = drupal_get_form('dblog_clear_log_form');
+
+ $header = array(
+ '', // Icon column.
+ array('data' => t('Type'), 'field' => 'w.type'),
+ array('data' => t('Date'), 'field' => 'w.wid', 'sort' => 'desc'),
+ t('Message'),
+ array('data' => t('User'), 'field' => 'u.name'),
+ array('data' => t('Operations')),
+ );
+
+ $query = db_select('watchdog', 'w')->extend('PagerDefault')->extend('TableSort');
+ $query->leftJoin('users', 'u', 'w.uid = u.uid');
+ $query
+ ->fields('w', array('wid', 'uid', 'severity', 'type', 'timestamp', 'message', 'variables', 'link'))
+ ->addField('u', 'name');
+ if (!empty($filter['where'])) {
+ $query->where($filter['where'], $filter['args']);
+ }
+ $result = $query
+ ->limit(50)
+ ->orderByHeader($header)
+ ->execute();
+
+ foreach ($result as $dblog) {
+ $rows[] = array('data' =>
+ array(
+ // Cells
+ array('class' => 'icon'),
+ t($dblog->type),
+ format_date($dblog->timestamp, 'short'),
+ theme('dblog_message', array('event' => $dblog, 'link' => TRUE)),
+ theme('username', array('account' => $dblog)),
+ filter_xss($dblog->link),
+ ),
+ // Attributes for tr
+ 'class' => array(drupal_html_class('dblog-' . $dblog->type), $classes[$dblog->severity]),
+ );
+ }
+
+ $build['dblog_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#attributes' => array('id' => 'admin-dblog'),
+ '#empty' => t('No log messages available.'),
+ );
+ $build['dblog_pager'] = array('#theme' => 'pager');
+
+ return $build;
+}
+
+/**
+ * Menu callback; generic function to display a page of the most frequent events.
+ *
+ * Messages are not truncated because events from this page have no detail view.
+ *
+ * @param $type
+ * type of dblog events to display.
+ */
+function dblog_top($type) {
+
+ $header = array(
+ array('data' => t('Count'), 'field' => 'count', 'sort' => 'desc'),
+ array('data' => t('Message'), 'field' => 'message')
+ );
+ $count_query = db_select('watchdog');
+ $count_query->addExpression('COUNT(DISTINCT(message))');
+ $count_query->condition('type', $type);
+
+ $query = db_select('watchdog', 'w')->extend('PagerDefault')->extend('TableSort');
+ $query->addExpression('COUNT(wid)', 'count');
+ $query = $query
+ ->fields('w', array('message', 'variables'))
+ ->condition('w.type', $type)
+ ->groupBy('message')
+ ->groupBy('variables')
+ ->limit(30)
+ ->orderByHeader($header);
+ $query->setCountQuery($count_query);
+ $result = $query->execute();
+
+ $rows = array();
+ foreach ($result as $dblog) {
+ $rows[] = array($dblog->count, theme('dblog_message', array('event' => $dblog)));
+ }
+
+ $build['dblog_top_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No log messages available.'),
+ );
+ $build['dblog_top_pager'] = array('#theme' => 'pager');
+
+ return $build;
+}
+
+/**
+ * Menu callback; displays details about a log message.
+ */
+function dblog_event($id) {
+ $severity = watchdog_severity_levels();
+ $result = db_query('SELECT w.*, u.name, u.uid FROM {watchdog} w INNER JOIN {users} u ON w.uid = u.uid WHERE w.wid = :id', array(':id' => $id))->fetchObject();
+ if ($dblog = $result) {
+ $rows = array(
+ array(
+ array('data' => t('Type'), 'header' => TRUE),
+ t($dblog->type),
+ ),
+ array(
+ array('data' => t('Date'), 'header' => TRUE),
+ format_date($dblog->timestamp, 'long'),
+ ),
+ array(
+ array('data' => t('User'), 'header' => TRUE),
+ theme('username', array('account' => $dblog)),
+ ),
+ array(
+ array('data' => t('Location'), 'header' => TRUE),
+ l($dblog->location, $dblog->location),
+ ),
+ array(
+ array('data' => t('Referrer'), 'header' => TRUE),
+ l($dblog->referer, $dblog->referer),
+ ),
+ array(
+ array('data' => t('Message'), 'header' => TRUE),
+ theme('dblog_message', array('event' => $dblog)),
+ ),
+ array(
+ array('data' => t('Severity'), 'header' => TRUE),
+ $severity[$dblog->severity],
+ ),
+ array(
+ array('data' => t('Hostname'), 'header' => TRUE),
+ check_plain($dblog->hostname),
+ ),
+ array(
+ array('data' => t('Operations'), 'header' => TRUE),
+ $dblog->link,
+ ),
+ );
+ $build['dblog_table'] = array(
+ '#theme' => 'table',
+ '#rows' => $rows,
+ '#attributes' => array('class' => array('dblog-event')),
+ );
+ return $build;
+ }
+ else {
+ return '';
+ }
+}
+
+/**
+ * Build query for dblog administration filters based on session.
+ */
+function dblog_build_filter_query() {
+ if (empty($_SESSION['dblog_overview_filter'])) {
+ return;
+ }
+
+ $filters = dblog_filters();
+
+ // Build query
+ $where = $args = array();
+ foreach ($_SESSION['dblog_overview_filter'] as $key => $filter) {
+ $filter_where = array();
+ foreach ($filter as $value) {
+ $filter_where[] = $filters[$key]['where'];
+ $args[] = $value;
+ }
+ if (!empty($filter_where)) {
+ $where[] = '(' . implode(' OR ', $filter_where) . ')';
+ }
+ }
+ $where = !empty($where) ? implode(' AND ', $where) : '';
+
+ return array(
+ 'where' => $where,
+ 'args' => $args,
+ );
+}
+
+
+/**
+ * List dblog administration filters that can be applied.
+ */
+function dblog_filters() {
+ $filters = array();
+
+ foreach (_dblog_get_message_types() as $type) {
+ $types[$type] = t($type);
+ }
+
+ if (!empty($types)) {
+ $filters['type'] = array(
+ 'title' => t('Type'),
+ 'where' => "w.type = ?",
+ 'options' => $types,
+ );
+ }
+
+ $filters['severity'] = array(
+ 'title' => t('Severity'),
+ 'where' => 'w.severity = ?',
+ 'options' => watchdog_severity_levels(),
+ );
+
+ return $filters;
+}
+
+/**
+ * Returns HTML for a log message.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - event: An object with at least the message and variables properties.
+ * - link: (optional) Format message as link, event->wid is required.
+ *
+ * @ingroup themeable
+ */
+function theme_dblog_message($variables) {
+ $output = '';
+ $event = $variables['event'];
+ // Check for required properties.
+ if (isset($event->message) && isset($event->variables)) {
+ // Messages without variables or user specified text.
+ if ($event->variables === 'N;') {
+ $output = $event->message;
+ }
+ // Message to translate with injected variables.
+ else {
+ $output = t($event->message, unserialize($event->variables));
+ }
+ if ($variables['link'] && isset($event->wid)) {
+ // Truncate message to 56 chars.
+ $output = truncate_utf8(filter_xss($output, array()), 56, TRUE, TRUE);
+ $output = l($output, 'admin/reports/event/' . $event->wid, array('html' => TRUE));
+ }
+ }
+ return $output;
+}
+
+/**
+ * Return form for dblog administration filters.
+ *
+ * @ingroup forms
+ * @see dblog_filter_form_submit()
+ * @see dblog_filter_form_validate()
+ */
+function dblog_filter_form($form) {
+ $filters = dblog_filters();
+
+ $form['filters'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Filter log messages'),
+ '#collapsible' => TRUE,
+ '#collapsed' => empty($_SESSION['dblog_overview_filter']),
+ );
+ foreach ($filters as $key => $filter) {
+ $form['filters']['status'][$key] = array(
+ '#title' => $filter['title'],
+ '#type' => 'select',
+ '#multiple' => TRUE,
+ '#size' => 8,
+ '#options' => $filter['options'],
+ );
+ if (!empty($_SESSION['dblog_overview_filter'][$key])) {
+ $form['filters']['status'][$key]['#default_value'] = $_SESSION['dblog_overview_filter'][$key];
+ }
+ }
+
+ $form['filters']['actions'] = array(
+ '#type' => 'actions',
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['filters']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Filter'),
+ );
+ if (!empty($_SESSION['dblog_overview_filter'])) {
+ $form['filters']['actions']['reset'] = array(
+ '#type' => 'submit',
+ '#value' => t('Reset')
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Validate result from dblog administration filter form.
+ */
+function dblog_filter_form_validate($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Filter') && empty($form_state['values']['type']) && empty($form_state['values']['severity'])) {
+ form_set_error('type', t('You must select something to filter by.'));
+ }
+}
+
+/**
+ * Process result from dblog administration filter form.
+ */
+function dblog_filter_form_submit($form, &$form_state) {
+ $op = $form_state['values']['op'];
+ $filters = dblog_filters();
+ switch ($op) {
+ case t('Filter'):
+ foreach ($filters as $name => $filter) {
+ if (isset($form_state['values'][$name])) {
+ $_SESSION['dblog_overview_filter'][$name] = $form_state['values'][$name];
+ }
+ }
+ break;
+ case t('Reset'):
+ $_SESSION['dblog_overview_filter'] = array();
+ break;
+ }
+ return 'admin/reports/dblog';
+}
+
+/**
+ * Return form for dblog clear button.
+ *
+ * @ingroup forms
+ * @see dblog_clear_log_submit()
+ */
+function dblog_clear_log_form($form) {
+ $form['dblog_clear'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Clear log messages'),
+ '#description' => t('This will permanently remove the log messages from the database.'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ );
+ $form['dblog_clear']['clear'] = array(
+ '#type' => 'submit',
+ '#value' => t('Clear log messages'),
+ '#submit' => array('dblog_clear_log_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit callback: clear database with log messages.
+ */
+function dblog_clear_log_submit() {
+ $_SESSION['dblog_overview_filter'] = array();
+ db_delete('watchdog')->execute();
+ drupal_set_message(t('Database log cleared.'));
+}
diff --git a/core/modules/dblog/dblog.css b/core/modules/dblog/dblog.css
new file mode 100644
index 000000000000..88f4ba01b8ad
--- /dev/null
+++ b/core/modules/dblog/dblog.css
@@ -0,0 +1,59 @@
+.form-item-type,
+.form-item-severity {
+ float: left; /* LTR */
+ padding-right: .8em; /* LTR */
+ margin: 0.1em;
+ /**
+ * In Opera 9, DOM elements with the property of "overflow: auto"
+ * will partially hide its contents with unnecessary scrollbars when
+ * its immediate child is floated without an explicit width set.
+ */
+ width: 15em;
+}
+#dblog-filter-form .form-type-select select {
+ width: 100%;
+}
+#dblog-filter-form .form-actions {
+ float: left;
+ padding: 3ex 0 0 1em;
+}
+
+tr.dblog-user {
+ background: #ffd;
+}
+tr.dblog-user .active {
+ background: #eed;
+}
+tr.dblog-content {
+ background: #ddf;
+}
+tr.dblog-content .active {
+ background: #cce;
+}
+tr.dblog-page-not-found,
+tr.dblog-access-denied {
+ background: #dfd;
+}
+tr.dblog-page-not-found .active,
+tr.dblog-access-denied .active {
+ background: #cec;
+}
+tr.dblog-error {
+ background: #ffc9c9;
+}
+tr.dblog-error .active {
+ background: #eeb9b9;
+}
+table#admin-dblog td.icon {
+ background: no-repeat center;
+ width: 16px;
+}
+table#admin-dblog tr.dblog-warning td.icon {
+ background-image: url(../../misc/message-16-warning.png);
+}
+table#admin-dblog tr.dblog-error td.icon,
+table#admin-dblog tr.dblog-critical td.icon,
+table#admin-dblog tr.dblog-alert td.icon,
+table#admin-dblog tr.dblog-emerg td.icon {
+ background-image: url(../../misc/message-16-error.png);
+}
diff --git a/core/modules/dblog/dblog.info b/core/modules/dblog/dblog.info
new file mode 100644
index 000000000000..2aa61fc0e59c
--- /dev/null
+++ b/core/modules/dblog/dblog.info
@@ -0,0 +1,6 @@
+name = Database logging
+description = Logs and records system events to the database.
+package = Core
+version = VERSION
+core = 8.x
+files[] = dblog.test
diff --git a/core/modules/dblog/dblog.install b/core/modules/dblog/dblog.install
new file mode 100644
index 000000000000..23f85ba2594b
--- /dev/null
+++ b/core/modules/dblog/dblog.install
@@ -0,0 +1,99 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the dblog module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function dblog_schema() {
+ $schema['watchdog'] = array(
+ 'description' => 'Table that contains logs of all system events.',
+ 'fields' => array(
+ 'wid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique watchdog event ID.',
+ ),
+ 'uid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {users}.uid of the user who triggered the event.',
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Type of log message, for example "user" or "page not found."',
+ ),
+ 'message' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Text of log message to be passed into the t() function.',
+ ),
+ 'variables' => array(
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'Serialized array of variables that match the message string and that is passed into the t() function.',
+ ),
+ 'severity' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'The severity level of the event; ranges from 0 (Emergency) to 7 (Debug)',
+ ),
+ 'link' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'default' => '',
+ 'description' => 'Link to view the result of the event.',
+ ),
+ 'location' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'description' => 'URL of the origin of the event.',
+ ),
+ 'referer' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'description' => 'URL of referring page.',
+ ),
+ 'hostname' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Hostname of the user who triggered the event.',
+ ),
+ 'timestamp' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Unix timestamp of when event occurred.',
+ ),
+ ),
+ 'primary key' => array('wid'),
+ 'indexes' => array(
+ 'type' => array('type'),
+ 'uid' => array('uid'),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function dblog_uninstall() {
+ variable_del('dblog_row_limit');
+}
diff --git a/core/modules/dblog/dblog.module b/core/modules/dblog/dblog.module
new file mode 100644
index 000000000000..496a043a76a6
--- /dev/null
+++ b/core/modules/dblog/dblog.module
@@ -0,0 +1,184 @@
+<?php
+
+/**
+ * @file
+ * System monitoring and logging for administrators.
+ *
+ * The dblog module monitors your site and keeps a list of
+ * recorded events containing usage and performance data, errors,
+ * warnings, and similar operational information.
+ *
+ * @see watchdog()
+ */
+
+/**
+ * Implements hook_help().
+ */
+function dblog_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#dblog':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Database logging module logs system events in the Drupal database. For more information, see the online handbook entry for the <a href="@dblog">Database logging module</a>.', array('@dblog' => 'http://drupal.org/handbook/modules/dblog')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Monitoring your site') . '</dt>';
+ $output .= '<dd>' . t('The Database logging module allows you to view an event log on the <a href="@dblog">Recent log messages</a> page. The log is a chronological list of recorded events containing usage data, performance data, errors, warnings and operational information. Administrators should check the log on a regular basis to ensure their site is working properly.', array('@dblog' => url('admin/reports/dblog'))) . '</dd>';
+ $output .= '<dt>' . t('Debugging site problems') . '</dt>';
+ $output .= '<dd>' . t('In case of errors or problems with the site, the <a href="@dblog">Recent log messages</a> page can be useful for debugging, since it shows the sequence of events. The log messages include usage information, warnings, and errors.', array('@dblog' => url('admin/reports/dblog'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/reports/dblog':
+ return '<p>' . t('The Database logging module monitors your website, capturing system events in a log (shown here) to be reviewed by an authorized individual at a later time. This log is a list of recorded events containing usage data, performance data, errors, warnings and operational information. It is vital to check the Recent log messages report on a regular basis, as it is often the only way to tell what is going on.') . '</p>';
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function dblog_menu() {
+ $items['admin/reports/dblog'] = array(
+ 'title' => 'Recent log messages',
+ 'description' => 'View events that have recently been logged.',
+ 'page callback' => 'dblog_overview',
+ 'access arguments' => array('access site reports'),
+ 'weight' => -1,
+ 'file' => 'dblog.admin.inc',
+ );
+ $items['admin/reports/page-not-found'] = array(
+ 'title' => "Top 'page not found' errors",
+ 'description' => "View 'page not found' errors (404s).",
+ 'page callback' => 'dblog_top',
+ 'page arguments' => array('page not found'),
+ 'access arguments' => array('access site reports'),
+ 'file' => 'dblog.admin.inc',
+ );
+ $items['admin/reports/access-denied'] = array(
+ 'title' => "Top 'access denied' errors",
+ 'description' => "View 'access denied' errors (403s).",
+ 'page callback' => 'dblog_top',
+ 'page arguments' => array('access denied'),
+ 'access arguments' => array('access site reports'),
+ 'file' => 'dblog.admin.inc',
+ );
+ $items['admin/reports/event/%'] = array(
+ 'title' => 'Details',
+ 'page callback' => 'dblog_event',
+ 'page arguments' => array(3),
+ 'access arguments' => array('access site reports'),
+ 'file' => 'dblog.admin.inc',
+ );
+
+ if (module_exists('search')) {
+ $items['admin/reports/search'] = array(
+ 'title' => 'Top search phrases',
+ 'description' => 'View most popular search phrases.',
+ 'page callback' => 'dblog_top',
+ 'page arguments' => array('search'),
+ 'access arguments' => array('access site reports'),
+ 'file' => 'dblog.admin.inc',
+ );
+ }
+
+ return $items;
+}
+
+/**
+ * Implements hook_init().
+ */
+function dblog_init() {
+ if (arg(0) == 'admin' && arg(1) == 'reports') {
+ // Add the CSS for this module
+ drupal_add_css(drupal_get_path('module', 'dblog') . '/dblog.css');
+ }
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Remove expired log messages and flood control events.
+ */
+function dblog_cron() {
+ // Cleanup the watchdog table.
+ $row_limit = variable_get('dblog_row_limit', 1000);
+
+ // For row limit n, get the wid of the nth row in descending wid order.
+ // Counting the most recent n rows avoids issues with wid number sequences,
+ // e.g. auto_increment value > 1 or rows deleted directly from the table.
+ if ($row_limit > 0) {
+ $min_row = db_select('watchdog', 'w')
+ ->fields('w', array('wid'))
+ ->orderBy('wid', 'DESC')
+ ->range($row_limit - 1, 1)
+ ->execute()->fetchField();
+
+ // Delete all table entries older than the nth row, if nth row was found.
+ if ($min_row) {
+ db_delete('watchdog')
+ ->condition('wid', $min_row, '<')
+ ->execute();
+ }
+ }
+}
+
+function _dblog_get_message_types() {
+ $types = array();
+
+ $result = db_query('SELECT DISTINCT(type) FROM {watchdog} ORDER BY type');
+ foreach ($result as $object) {
+ $types[] = $object->type;
+ }
+
+ return $types;
+}
+
+/**
+ * Implements hook_watchdog().
+ *
+ * Note some values may be truncated for database column size restrictions.
+ */
+function dblog_watchdog(array $log_entry) {
+ // The user object may not exist in all conditions, so 0 is substituted if needed.
+ $user_uid = isset($log_entry['user']->uid) ? $log_entry['user']->uid : 0;
+
+ Database::getConnection('default', 'default')->insert('watchdog')
+ ->fields(array(
+ 'uid' => $user_uid,
+ 'type' => substr($log_entry['type'], 0, 64),
+ 'message' => $log_entry['message'],
+ 'variables' => serialize($log_entry['variables']),
+ 'severity' => $log_entry['severity'],
+ 'link' => substr($log_entry['link'], 0, 255),
+ 'location' => $log_entry['request_uri'],
+ 'referer' => $log_entry['referer'],
+ 'hostname' => substr($log_entry['ip'], 0, 128),
+ 'timestamp' => $log_entry['timestamp'],
+ ))
+ ->execute();
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function dblog_form_system_logging_settings_alter(&$form, $form_state) {
+ $form['dblog_row_limit'] = array(
+ '#type' => 'select',
+ '#title' => t('Database log messages to keep'),
+ '#default_value' => variable_get('dblog_row_limit', 1000),
+ '#options' => array(0 => t('All')) + drupal_map_assoc(array(100, 1000, 10000, 100000, 1000000)),
+ '#description' => t('The maximum number of messages to keep in the database log. Requires a <a href="@cron">cron maintenance task</a>.', array('@cron' => url('admin/reports/status')))
+ );
+ $form['actions']['#weight'] = 1;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function dblog_theme() {
+ return array(
+ 'dblog_message' => array(
+ 'variables' => array('event' => NULL, 'link' => FALSE),
+ 'file' => 'dblog.admin.inc',
+ ),
+ );
+}
diff --git a/core/modules/dblog/dblog.test b/core/modules/dblog/dblog.test
new file mode 100644
index 000000000000..ffd3e8a56533
--- /dev/null
+++ b/core/modules/dblog/dblog.test
@@ -0,0 +1,588 @@
+<?php
+
+/**
+ * @file
+ * Tests for dblog.module.
+ */
+
+class DBLogTestCase extends DrupalWebTestCase {
+ protected $big_user;
+ protected $any_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'DBLog functionality',
+ 'description' => 'Generate events and verify dblog entries; verify user access to log reports based on persmissions.',
+ 'group' => 'DBLog',
+ );
+ }
+
+ /**
+ * Enable modules and create users with specific permissions.
+ */
+ function setUp() {
+ parent::setUp('dblog', 'poll');
+ // Create users.
+ $this->big_user = $this->drupalCreateUser(array('administer site configuration', 'access administration pages', 'access site reports', 'administer users'));
+ $this->any_user = $this->drupalCreateUser(array());
+ }
+
+ /**
+ * Login users, create dblog events, and test dblog functionality through the admin and user interfaces.
+ */
+ function testDBLog() {
+ // Login the admin user.
+ $this->drupalLogin($this->big_user);
+
+ $row_limit = 100;
+ $this->verifyRowLimit($row_limit);
+ $this->verifyCron($row_limit);
+ $this->verifyEvents();
+ $this->verifyReports();
+
+ // Login the regular user.
+ $this->drupalLogin($this->any_user);
+ $this->verifyReports(403);
+ }
+
+ /**
+ * Verify setting of the dblog row limit.
+ *
+ * @param integer $count Log row limit.
+ */
+ private function verifyRowLimit($row_limit) {
+ // Change the dblog row limit.
+ $edit = array();
+ $edit['dblog_row_limit'] = $row_limit;
+ $this->drupalPost('admin/config/development/logging', $edit, t('Save configuration'));
+ $this->assertResponse(200);
+
+ // Check row limit variable.
+ $current_limit = variable_get('dblog_row_limit', 1000);
+ $this->assertTrue($current_limit == $row_limit, t('[Cache] Row limit variable of @count equals row limit of @limit', array('@count' => $current_limit, '@limit' => $row_limit)));
+ // Verify dblog row limit equals specified row limit.
+ $current_limit = unserialize(db_query("SELECT value FROM {variable} WHERE name = :dblog_limit", array(':dblog_limit' => 'dblog_row_limit'))->fetchField());
+ $this->assertTrue($current_limit == $row_limit, t('[Variable table] Row limit variable of @count equals row limit of @limit', array('@count' => $current_limit, '@limit' => $row_limit)));
+ }
+
+ /**
+ * Verify cron applies the dblog row limit.
+ *
+ * @param integer $count Log row limit.
+ */
+ private function verifyCron($row_limit) {
+ // Generate additional log entries.
+ $this->generateLogEntries($row_limit + 10);
+ // Verify dblog row count exceeds row limit.
+ $count = db_query('SELECT COUNT(wid) FROM {watchdog}')->fetchField();
+ $this->assertTrue($count > $row_limit, t('Dblog row count of @count exceeds row limit of @limit', array('@count' => $count, '@limit' => $row_limit)));
+
+ // Run cron job.
+ $this->cronRun();
+ // Verify dblog row count equals row limit plus one because cron adds a record after it runs.
+ $count = db_query('SELECT COUNT(wid) FROM {watchdog}')->fetchField();
+ $this->assertTrue($count == $row_limit + 1, t('Dblog row count of @count equals row limit of @limit plus one', array('@count' => $count, '@limit' => $row_limit)));
+ }
+
+ /**
+ * Generate dblog entries.
+ *
+ * @param integer $count
+ * Number of log entries to generate.
+ * @param $type
+ * The type of watchdog entry.
+ * @param $severity
+ * The severity of the watchdog entry.
+ */
+ private function generateLogEntries($count, $type = 'custom', $severity = WATCHDOG_NOTICE) {
+ global $base_root;
+
+ // Prepare the fields to be logged
+ $log = array(
+ 'type' => $type,
+ 'message' => 'Log entry added to test the dblog row limit.',
+ 'variables' => array(),
+ 'severity' => $severity,
+ 'link' => NULL,
+ 'user' => $this->big_user,
+ 'request_uri' => $base_root . request_uri(),
+ 'referer' => $_SERVER['HTTP_REFERER'],
+ 'ip' => ip_address(),
+ 'timestamp' => REQUEST_TIME,
+ );
+ $message = 'Log entry added to test the dblog row limit. Entry #';
+ for ($i = 0; $i < $count; $i++) {
+ $log['message'] = $message . $i;
+ dblog_watchdog($log);
+ }
+ }
+
+ /**
+ * Verify the logged in user has the desired access to the various dblog nodes.
+ *
+ * @param integer $response HTTP response code.
+ */
+ private function verifyReports($response = 200) {
+ $quote = '&#039;';
+
+ // View dblog help node.
+ $this->drupalGet('admin/help/dblog');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Database logging'), t('DBLog help was displayed'));
+ }
+
+ // View dblog report node.
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Recent log messages'), t('DBLog report was displayed'));
+ }
+
+ // View dblog page-not-found report node.
+ $this->drupalGet('admin/reports/page-not-found');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Top ' . $quote . 'page not found' . $quote . ' errors'), t('DBLog page-not-found report was displayed'));
+ }
+
+ // View dblog access-denied report node.
+ $this->drupalGet('admin/reports/access-denied');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Top ' . $quote . 'access denied' . $quote . ' errors'), t('DBLog access-denied report was displayed'));
+ }
+
+ // View dblog event node.
+ $this->drupalGet('admin/reports/event/1');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Details'), t('DBLog event node was displayed'));
+ }
+ }
+
+ /**
+ * Verify events.
+ */
+ private function verifyEvents() {
+ // Invoke events.
+ $this->doUser();
+ $this->doNode('article');
+ $this->doNode('page');
+ $this->doNode('poll');
+
+ // When a user account is canceled, any content they created remains but the
+ // uid = 0. Records in the watchdog table related to that user have the uid
+ // set to zero.
+ }
+
+ /**
+ * Generate and verify user events.
+ *
+ */
+ private function doUser() {
+ // Set user variables.
+ $name = $this->randomName();
+ $pass = user_password();
+ // Add user using form to generate add user event (which is not triggered by drupalCreateUser).
+ $edit = array();
+ $edit['name'] = $name;
+ $edit['mail'] = $name . '@example.com';
+ $edit['pass[pass1]'] = $pass;
+ $edit['pass[pass2]'] = $pass;
+ $edit['status'] = 1;
+ $this->drupalPost('admin/people/create', $edit, t('Create new account'));
+ $this->assertResponse(200);
+ // Retrieve user object.
+ $user = user_load_by_name($name);
+ $this->assertTrue($user != NULL, t('User @name was loaded', array('@name' => $name)));
+ $user->pass_raw = $pass; // Needed by drupalLogin.
+ // Login user.
+ $this->drupalLogin($user);
+ // Logout user.
+ $this->drupalLogout();
+ // Fetch row ids in watchdog that relate to the user.
+ $result = db_query('SELECT wid FROM {watchdog} WHERE uid = :uid', array(':uid' => $user->uid));
+ foreach ($result as $row) {
+ $ids[] = $row->wid;
+ }
+ $count_before = (isset($ids)) ? count($ids) : 0;
+ $this->assertTrue($count_before > 0, t('DBLog contains @count records for @name', array('@count' => $count_before, '@name' => $user->name)));
+
+ // Login the admin user.
+ $this->drupalLogin($this->big_user);
+ // Delete user.
+ // We need to POST here to invoke batch_process() in the internal browser.
+ $this->drupalPost('user/' . $user->uid . '/cancel', array('user_cancel_method' => 'user_cancel_reassign'), t('Cancel account'));
+
+ // View the dblog report.
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertResponse(200);
+
+ // Verify events were recorded.
+ // Add user.
+ // Default display includes name and email address; if too long then email is replaced by three periods.
+ $this->assertLogMessage(t('New user: %name (%email).', array('%name' => $name, '%email' => $user->mail)), t('DBLog event was recorded: [add user]'));
+ // Login user.
+ $this->assertLogMessage(t('Session opened for %name.', array('%name' => $name)), t('DBLog event was recorded: [login user]'));
+ // Logout user.
+ $this->assertLogMessage(t('Session closed for %name.', array('%name' => $name)), t('DBLog event was recorded: [logout user]'));
+ // Delete user.
+ $message = t('Deleted user: %name %email.', array('%name' => $name, '%email' => '<' . $user->mail . '>'));
+ $message_text = truncate_utf8(filter_xss($message, array()), 56, TRUE, TRUE);
+ // Verify full message on details page.
+ $link = FALSE;
+ if ($links = $this->xpath('//a[text()="' . html_entity_decode($message_text) . '"]')) {
+ // Found link with the message text.
+ $links = array_shift($links);
+ foreach ($links->attributes() as $attr => $value) {
+ if ($attr == 'href') {
+ // Extract link to details page.
+ $link = drupal_substr($value, strpos($value, 'admin/reports/event/'));
+ $this->drupalGet($link);
+ // Check for full message text on the details page.
+ $this->assertRaw($message, t('DBLog event details was found: [delete user]'));
+ break;
+ }
+ }
+ }
+ $this->assertTrue($link, t('DBLog event was recorded: [delete user]'));
+ // Visit random URL (to generate page not found event).
+ $not_found_url = $this->randomName(60);
+ $this->drupalGet($not_found_url);
+ $this->assertResponse(404);
+ // View dblog page-not-found report page.
+ $this->drupalGet('admin/reports/page-not-found');
+ $this->assertResponse(200);
+ // Check that full-length url displayed.
+ $this->assertText($not_found_url, t('DBLog event was recorded: [page not found]'));
+ }
+
+ /**
+ * Generate and verify node events.
+ *
+ * @param string $type Content type.
+ */
+ private function doNode($type) {
+ // Create user.
+ $perm = array('create ' . $type . ' content', 'edit own ' . $type . ' content', 'delete own ' . $type . ' content');
+ $user = $this->drupalCreateUser($perm);
+ // Login user.
+ $this->drupalLogin($user);
+
+ // Create node using form to generate add content event (which is not triggered by drupalCreateNode).
+ $edit = $this->getContent($type);
+ $langcode = LANGUAGE_NONE;
+ $title = $edit["title"];
+ $this->drupalPost('node/add/' . $type, $edit, t('Save'));
+ $this->assertResponse(200);
+ // Retrieve node object.
+ $node = $this->drupalGetNodeByTitle($title);
+ $this->assertTrue($node != NULL, t('Node @title was loaded', array('@title' => $title)));
+ // Edit node.
+ $edit = $this->getContentUpdate($type);
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertResponse(200);
+ // Delete node.
+ $this->drupalPost('node/' . $node->nid . '/delete', array(), t('Delete'));
+ $this->assertResponse(200);
+ // View node (to generate page not found event).
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertResponse(404);
+ // View the dblog report (to generate access denied event).
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertResponse(403);
+
+ // Login the admin user.
+ $this->drupalLogin($this->big_user);
+ // View the dblog report.
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertResponse(200);
+
+ // Verify events were recorded.
+ // Content added.
+ $this->assertLogMessage(t('@type: added %title.', array('@type' => $type, '%title' => $title)), t('DBLog event was recorded: [content added]'));
+ // Content updated.
+ $this->assertLogMessage(t('@type: updated %title.', array('@type' => $type, '%title' => $title)), t('DBLog event was recorded: [content updated]'));
+ // Content deleted.
+ $this->assertLogMessage(t('@type: deleted %title.', array('@type' => $type, '%title' => $title)), t('DBLog event was recorded: [content deleted]'));
+
+ // View dblog access-denied report node.
+ $this->drupalGet('admin/reports/access-denied');
+ $this->assertResponse(200);
+ // Access denied.
+ $this->assertText(t('admin/reports/dblog'), t('DBLog event was recorded: [access denied]'));
+
+ // View dblog page-not-found report node.
+ $this->drupalGet('admin/reports/page-not-found');
+ $this->assertResponse(200);
+ // Page not found.
+ $this->assertText(t('node/@nid', array('@nid' => $node->nid)), t('DBLog event was recorded: [page not found]'));
+ }
+
+ /**
+ * Create content based on content type.
+ *
+ * @param string $type Content type.
+ * @return array Content.
+ */
+ private function getContent($type) {
+ $langcode = LANGUAGE_NONE;
+ switch ($type) {
+ case 'poll':
+ $content = array(
+ "title" => $this->randomName(8),
+ 'choice[new:0][chtext]' => $this->randomName(32),
+ 'choice[new:1][chtext]' => $this->randomName(32),
+ );
+ break;
+
+ default:
+ $content = array(
+ "title" => $this->randomName(8),
+ "body[$langcode][0][value]" => $this->randomName(32),
+ );
+ break;
+ }
+ return $content;
+ }
+
+ /**
+ * Create content update based on content type.
+ *
+ * @param string $type Content type.
+ * @return array Content.
+ */
+ private function getContentUpdate($type) {
+ switch ($type) {
+ case 'poll':
+ $content = array(
+ 'choice[chid:1][chtext]' => $this->randomName(32),
+ 'choice[chid:2][chtext]' => $this->randomName(32),
+ );
+ break;
+
+ default:
+ $langcode = LANGUAGE_NONE;
+ $content = array(
+ "body[$langcode][0][value]" => $this->randomName(32),
+ );
+ break;
+ }
+ return $content;
+ }
+
+ /**
+ * Login an admin user, create dblog event, and test clearing dblog functionality through the admin interface.
+ */
+ protected function testDBLogAddAndClear() {
+ global $base_root;
+ // Get a count of how many watchdog entries there are.
+ $count = db_query('SELECT COUNT(*) FROM {watchdog}')->fetchField();
+ $log = array(
+ 'type' => 'custom',
+ 'message' => 'Log entry added to test the doClearTest clear down.',
+ 'variables' => array(),
+ 'severity' => WATCHDOG_NOTICE,
+ 'link' => NULL,
+ 'user' => $this->big_user,
+ 'request_uri' => $base_root . request_uri(),
+ 'referer' => $_SERVER['HTTP_REFERER'],
+ 'ip' => ip_address(),
+ 'timestamp' => REQUEST_TIME,
+ );
+ // Add a watchdog entry.
+ dblog_watchdog($log);
+ // Make sure the table count has actually incremented.
+ $this->assertEqual($count + 1, db_query('SELECT COUNT(*) FROM {watchdog}')->fetchField(), t('dblog_watchdog() added an entry to the dblog :count', array(':count' => $count)));
+ // Login the admin user.
+ $this->drupalLogin($this->big_user);
+ // Now post to clear the db table.
+ $this->drupalPost('admin/reports/dblog', array(), t('Clear log messages'));
+ // Count rows in watchdog that previously related to the deleted user.
+ $count = db_query('SELECT COUNT(*) FROM {watchdog}')->fetchField();
+ $this->assertEqual($count, 0, t('DBLog contains :count records after a clear.', array(':count' => $count)));
+ }
+
+ /**
+ * Test the dblog filter on admin/reports/dblog.
+ */
+ protected function testFilter() {
+ $this->drupalLogin($this->big_user);
+
+ // Clear log to ensure that only generated entries are found.
+ db_delete('watchdog')->execute();
+
+ // Generate watchdog entries.
+ $type_names = array();
+ $types = array();
+ for ($i = 0; $i < 3; $i++) {
+ $type_names[] = $type_name = $this->randomName();
+ $severity = WATCHDOG_EMERGENCY;
+ for ($j = 0; $j < 3; $j++) {
+ $types[] = $type = array(
+ 'count' => mt_rand(1, 5),
+ 'type' => $type_name,
+ 'severity' => $severity++,
+ );
+ $this->generateLogEntries($type['count'], $type['type'], $type['severity']);
+ }
+ }
+
+ // View the dblog.
+ $this->drupalGet('admin/reports/dblog');
+
+ // Confirm all the entries are displayed.
+ $count = $this->getTypeCount($types);
+ foreach ($types as $key => $type) {
+ $this->assertEqual($count[$key], $type['count'], 'Count matched');
+ }
+
+ // Filter by each type and confirm that entries with various severities are
+ // displayed.
+ foreach ($type_names as $type_name) {
+ $edit = array(
+ 'type[]' => array($type_name),
+ );
+ $this->drupalPost(NULL, $edit, t('Filter'));
+
+ // Count the number of entries of this type.
+ $type_count = 0;
+ foreach ($types as $type) {
+ if ($type['type'] == $type_name) {
+ $type_count += $type['count'];
+ }
+ }
+
+ $count = $this->getTypeCount($types);
+ $this->assertEqual(array_sum($count), $type_count, 'Count matched');
+ }
+
+ // Set filter to match each of the three type attributes and confirm the
+ // number of entries displayed.
+ foreach ($types as $key => $type) {
+ $edit = array(
+ 'type[]' => array($type['type']),
+ 'severity[]' => array($type['severity']),
+ );
+ $this->drupalPost(NULL, $edit, t('Filter'));
+
+ $count = $this->getTypeCount($types);
+ $this->assertEqual(array_sum($count), $type['count'], 'Count matched');
+ }
+
+ // Clear all logs and make sure the confirmation message is found.
+ $this->drupalPost('admin/reports/dblog', array(), t('Clear log messages'));
+ $this->assertText(t('Database log cleared.'), t('Confirmation message found'));
+ }
+
+ /**
+ * Get the log entry information form the page.
+ *
+ * @return
+ * List of entries and their information.
+ */
+ protected function getLogEntries() {
+ $entries = array();
+ if ($table = $this->xpath('.//table[@id="admin-dblog"]')) {
+ $table = array_shift($table);
+ foreach ($table->tbody->tr as $row) {
+ $entries[] = array(
+ 'severity' => $this->getSeverityConstant($row['class']),
+ 'type' => $this->asText($row->td[1]),
+ 'message' => $this->asText($row->td[3]),
+ 'user' => $this->asText($row->td[4]),
+ );
+ }
+ }
+ return $entries;
+ }
+
+ /**
+ * Get the count of entries per type.
+ *
+ * @param $types
+ * The type information to compare against.
+ * @return
+ * The count of each type keyed by the key of the $types array.
+ */
+ protected function getTypeCount(array $types) {
+ $entries = $this->getLogEntries();
+ $count = array_fill(0, count($types), 0);
+ foreach ($entries as $entry) {
+ foreach ($types as $key => $type) {
+ if ($entry['type'] == $type['type'] && $entry['severity'] == $type['severity']) {
+ $count[$key]++;
+ break;
+ }
+ }
+ }
+ return $count;
+ }
+
+ /**
+ * Get the watchdog severity constant corresponding to the CSS class.
+ *
+ * @param $class
+ * CSS class attribute.
+ * @return
+ * The watchdog severity constant or NULL if not found.
+ */
+ protected function getSeverityConstant($class) {
+ // Reversed array from dblog_overview().
+ $map = array(
+ 'dblog-debug' => WATCHDOG_DEBUG,
+ 'dblog-info' => WATCHDOG_INFO,
+ 'dblog-notice' => WATCHDOG_NOTICE,
+ 'dblog-warning' => WATCHDOG_WARNING,
+ 'dblog-error' => WATCHDOG_ERROR,
+ 'dblog-critical' => WATCHDOG_CRITICAL,
+ 'dblog-alert' => WATCHDOG_ALERT,
+ 'dblog-emerg' => WATCHDOG_EMERGENCY,
+ );
+
+ // Find the class that contains the severity.
+ $classes = explode(' ', $class);
+ foreach ($classes as $class) {
+ if (isset($map[$class])) {
+ return $map[$class];
+ }
+ }
+ return NULL;
+ }
+
+ /**
+ * Extract the text contained by the element.
+ *
+ * @param $element
+ * Element to extract text from.
+ * @return
+ * Extracted text.
+ */
+ protected function asText(SimpleXMLElement $element) {
+ if (!is_object($element)) {
+ return $this->fail('The element is not an element.');
+ }
+ return trim(html_entity_decode(strip_tags($element->asXML())));
+ }
+
+ /**
+ * Assert messages appear on the log overview screen.
+ *
+ * This function should be used only for admin/reports/dblog page, because it
+ * check for the message link text truncated to 56 characters. Other dblog
+ * pages have no detail links so contains a full message text.
+ *
+ * @param $log_message
+ * The message to check.
+ * @param $message
+ * The message to pass to simpletest.
+ */
+ protected function assertLogMessage($log_message, $message) {
+ $message_text = truncate_utf8(filter_xss($log_message, array()), 56, TRUE, TRUE);
+ // After filter_xss() HTML entities should be converted to their characters
+ // because assertLink() uses this string in xpath() to query DOM.
+ $this->assertLink(html_entity_decode($message_text), 0, $message);
+ }
+}
+
diff --git a/core/modules/entity/entity.api.php b/core/modules/entity/entity.api.php
new file mode 100644
index 000000000000..9b19477163bf
--- /dev/null
+++ b/core/modules/entity/entity.api.php
@@ -0,0 +1,414 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided the Entity module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Inform the base system and the Field API about one or more entity types.
+ *
+ * Inform the system about one or more entity types (i.e., object types that
+ * can be loaded via entity_load() and, optionally, to which fields can be
+ * attached).
+ *
+ * @return
+ * An array whose keys are entity type names and whose values identify
+ * properties of those types that the system needs to know about:
+ * - label: The human-readable name of the type.
+ * - controller class: The name of the class that is used to load the objects.
+ * The class has to implement the DrupalEntityControllerInterface interface.
+ * Leave blank to use the DrupalDefaultEntityController implementation.
+ * - base table: (used by DrupalDefaultEntityController) The name of the
+ * entity type's base table.
+ * - static cache: (used by DrupalDefaultEntityController) FALSE to disable
+ * static caching of entities during a page request. Defaults to TRUE.
+ * - field cache: (used by Field API loading and saving of field data) FALSE
+ * to disable Field API's persistent cache of field data. Only recommended
+ * if a higher level persistent cache is available for the entity type.
+ * Defaults to TRUE.
+ * - load hook: The name of the hook which should be invoked by
+ * DrupalDefaultEntityController:attachLoad(), for example 'node_load'.
+ * - uri callback: A function taking an entity as argument and returning the
+ * uri elements of the entity, e.g. 'path' and 'options'. The actual entity
+ * uri can be constructed by passing these elements to url().
+ * - label callback: (optional) A function taking an entity as argument and
+ * returning the label of the entity. The entity label is the main string
+ * associated with an entity; for example, the title of a node or the
+ * subject of a comment. If there is an entity object property that defines
+ * the label, use the 'label' element of the 'entity keys' return
+ * value component to provide this information (see below). If more complex
+ * logic is needed to determine the label of an entity, you can instead
+ * specify a callback function here, which will be called to determine the
+ * entity label. See also the entity_label() function, which implements this
+ * logic.
+ * - fieldable: Set to TRUE if you want your entity type to be fieldable.
+ * - translation: An associative array of modules registered as field
+ * translation handlers. Array keys are the module names, array values
+ * can be any data structure the module uses to provide field translation.
+ * Any empty value disallows the module to appear as a translation handler.
+ * - entity keys: An array describing how the Field API can extract the
+ * information it needs from the objects of the type. Elements:
+ * - id: The name of the property that contains the primary id of the
+ * entity. Every entity object passed to the Field API must have this
+ * property and its value must be numeric.
+ * - revision: The name of the property that contains the revision id of
+ * the entity. The Field API assumes that all revision ids are unique
+ * across all entities of a type. This entry can be omitted if the
+ * entities of this type are not versionable.
+ * - bundle: The name of the property that contains the bundle name for the
+ * entity. The bundle name defines which set of fields are attached to
+ * the entity (e.g. what nodes call "content type"). This entry can be
+ * omitted if this entity type exposes a single bundle (all entities have
+ * the same collection of fields). The name of this single bundle will be
+ * the same as the entity type.
+ * - label: The name of the property that contains the entity label. For
+ * example, if the entity's label is located in $entity->subject, then
+ * 'subject' should be specified here. If complex logic is required to
+ * build the label, a 'label callback' should be defined instead (see
+ * the 'label callback' section above for details).
+ * - bundle keys: An array describing how the Field API can extract the
+ * information it needs from the bundle objects for this type (e.g
+ * $vocabulary objects for terms; not applicable for nodes). This entry can
+ * be omitted if this type's bundles do not exist as standalone objects.
+ * Elements:
+ * - bundle: The name of the property that contains the name of the bundle
+ * object.
+ * - bundles: An array describing all bundles for this object type. Keys are
+ * bundles machine names, as found in the objects' 'bundle' property
+ * (defined in the 'entity keys' entry above). Elements:
+ * - label: The human-readable name of the bundle.
+ * - uri callback: Same as the 'uri callback' key documented above for the
+ * entity type, but for the bundle only. When determining the URI of an
+ * entity, if a 'uri callback' is defined for both the entity type and
+ * the bundle, the one for the bundle is used.
+ * - admin: An array of information that allows Field UI pages to attach
+ * themselves to the existing administration pages for the bundle.
+ * Elements:
+ * - path: the path of the bundle's main administration page, as defined
+ * in hook_menu(). If the path includes a placeholder for the bundle,
+ * the 'bundle argument', 'bundle helper' and 'real path' keys below
+ * are required.
+ * - bundle argument: The position of the placeholder in 'path', if any.
+ * - real path: The actual path (no placeholder) of the bundle's main
+ * administration page. This will be used to generate links.
+ * - access callback: As in hook_menu(). 'user_access' will be assumed if
+ * no value is provided.
+ * - access arguments: As in hook_menu().
+ * - view modes: An array describing the view modes for the entity type. View
+ * modes let entities be displayed differently depending on the context.
+ * For instance, a node can be displayed differently on its own page
+ * ('full' mode), on the home page or taxonomy listings ('teaser' mode), or
+ * in an RSS feed ('rss' mode). Modules taking part in the display of the
+ * entity (notably the Field API) can adjust their behavior depending on
+ * the requested view mode. An additional 'default' view mode is available
+ * for all entity types. This view mode is not intended for actual entity
+ * display, but holds default display settings. For each available view
+ * mode, administrators can configure whether it should use its own set of
+ * field display settings, or just replicate the settings of the 'default'
+ * view mode, thus reducing the amount of display configurations to keep
+ * track of. Keys of the array are view mode names. Each view mode is
+ * described by an array with the following key/value pairs:
+ * - label: The human-readable name of the view mode
+ * - custom settings: A boolean specifying whether the view mode should by
+ * default use its own custom field display settings. If FALSE, entities
+ * displayed in this view mode will reuse the 'default' display settings
+ * by default (e.g. right after the module exposing the view mode is
+ * enabled), but administrators can later use the Field UI to apply custom
+ * display settings specific to the view mode.
+ *
+ * @see entity_load()
+ * @see hook_entity_info_alter()
+ */
+function hook_entity_info() {
+ $return = array(
+ 'node' => array(
+ 'label' => t('Node'),
+ 'controller class' => 'NodeController',
+ 'base table' => 'node',
+ 'revision table' => 'node_revision',
+ 'uri callback' => 'node_uri',
+ 'fieldable' => TRUE,
+ 'translation' => array(
+ 'locale' => TRUE,
+ ),
+ 'entity keys' => array(
+ 'id' => 'nid',
+ 'revision' => 'vid',
+ 'bundle' => 'type',
+ ),
+ 'bundle keys' => array(
+ 'bundle' => 'type',
+ ),
+ 'bundles' => array(),
+ 'view modes' => array(
+ 'full' => array(
+ 'label' => t('Full content'),
+ 'custom settings' => FALSE,
+ ),
+ 'teaser' => array(
+ 'label' => t('Teaser'),
+ 'custom settings' => TRUE,
+ ),
+ 'rss' => array(
+ 'label' => t('RSS'),
+ 'custom settings' => FALSE,
+ ),
+ ),
+ ),
+ );
+
+ // Search integration is provided by node.module, so search-related
+ // view modes for nodes are defined here and not in search.module.
+ if (module_exists('search')) {
+ $return['node']['view modes'] += array(
+ 'search_index' => array(
+ 'label' => t('Search index'),
+ 'custom settings' => FALSE,
+ ),
+ 'search_result' => array(
+ 'label' => t('Search result'),
+ 'custom settings' => FALSE,
+ ),
+ );
+ }
+
+ // Bundles must provide a human readable name so we can create help and error
+ // messages, and the path to attach Field admin pages to.
+ foreach (node_type_get_names() as $type => $name) {
+ $return['node']['bundles'][$type] = array(
+ 'label' => $name,
+ 'admin' => array(
+ 'path' => 'admin/structure/types/manage/%node_type',
+ 'real path' => 'admin/structure/types/manage/' . str_replace('_', '-', $type),
+ 'bundle argument' => 4,
+ 'access arguments' => array('administer content types'),
+ ),
+ );
+ }
+
+ return $return;
+}
+
+/**
+ * Alter the entity info.
+ *
+ * Modules may implement this hook to alter the information that defines an
+ * entity. All properties that are available in hook_entity_info() can be
+ * altered here.
+ *
+ * @param $entity_info
+ * The entity info array, keyed by entity name.
+ *
+ * @see hook_entity_info()
+ */
+function hook_entity_info_alter(&$entity_info) {
+ // Set the controller class for nodes to an alternate implementation of the
+ // DrupalEntityController interface.
+ $entity_info['node']['controller class'] = 'MyCustomNodeController';
+}
+
+/**
+ * Act on entities when loaded.
+ *
+ * This is a generic load hook called for all entity types loaded via the
+ * entity API.
+ *
+ * @param $entities
+ * The entities keyed by entity ID.
+ * @param $type
+ * The type of entities being loaded (i.e. node, user, comment).
+ */
+function hook_entity_load($entities, $type) {
+ foreach ($entities as $entity) {
+ $entity->foo = mymodule_add_something($entity, $type);
+ }
+}
+
+/**
+ * Act on an entity before it is about to be created or updated.
+ *
+ * @param $entity
+ * The entity object.
+ * @param $type
+ * The type of entity being saved (i.e. node, user, comment).
+ */
+function hook_entity_presave($entity, $type) {
+ $entity->changed = REQUEST_TIME;
+}
+
+/**
+ * Act on entities when inserted.
+ *
+ * @param $entity
+ * The entity object.
+ * @param $type
+ * The type of entity being inserted (i.e. node, user, comment).
+ */
+function hook_entity_insert($entity, $type) {
+ // Insert the new entity into a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_insert('example_entity')
+ ->fields(array(
+ 'type' => $type,
+ 'id' => $id,
+ 'created' => REQUEST_TIME,
+ 'updated' => REQUEST_TIME,
+ ))
+ ->execute();
+}
+
+/**
+ * Act on entities when updated.
+ *
+ * @param $entity
+ * The entity object.
+ * @param $type
+ * The type of entity being updated (i.e. node, user, comment).
+ */
+function hook_entity_update($entity, $type) {
+ // Update the entity's entry in a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_update('example_entity')
+ ->fields(array(
+ 'updated' => REQUEST_TIME,
+ ))
+ ->condition('type', $type)
+ ->condition('id', $id)
+ ->execute();
+}
+
+/**
+ * Act on entities when deleted.
+ *
+ * @param $entity
+ * The entity object.
+ * @param $type
+ * The type of entity being deleted (i.e. node, user, comment).
+ */
+function hook_entity_delete($entity, $type) {
+ // Delete the entity's entry from a fictional table of all entities.
+ $info = entity_get_info($type);
+ list($id) = entity_extract_ids($type, $entity);
+ db_delete('example_entity')
+ ->condition('type', $type)
+ ->condition('id', $id)
+ ->execute();
+}
+
+/**
+ * Alter or execute an EntityFieldQuery.
+ *
+ * @param EntityFieldQuery $query
+ * An EntityFieldQuery. One of the most important properties to be changed is
+ * EntityFieldQuery::executeCallback. If this is set to an existing function,
+ * this function will get the query as its single argument and its result
+ * will be the returned as the result of EntityFieldQuery::execute(). This can
+ * be used to change the behavior of EntityFieldQuery entirely. For example,
+ * the default implementation can only deal with one field storage engine, but
+ * it is possible to write a module that can query across field storage
+ * engines. Also, the default implementation presumes entities are stored in
+ * SQL, but the execute callback could instead query any other entity storage,
+ * local or remote.
+ *
+ * Note the $query->altered attribute which is TRUE in case the query has
+ * already been altered once. This happens with cloned queries.
+ * If there is a pager, then such a cloned query will be executed to count
+ * all elements. This query can be detected by checking for
+ * ($query->pager && $query->count), allowing the driver to return 0 from
+ * the count query and disable the pager.
+ */
+function hook_entity_query_alter($query) {
+ $query->executeCallback = 'my_module_query_callback';
+}
+
+/**
+ * Act on entities being assembled before rendering.
+ *
+ * @param $entity
+ * The entity object.
+ * @param $type
+ * The type of entity being rendered (i.e. node, user, comment).
+ * @param $view_mode
+ * The view mode the entity is rendered in.
+ * @param $langcode
+ * The language code used for rendering.
+ *
+ * The module may add elements to $entity->content prior to rendering. The
+ * structure of $entity->content is a renderable array as expected by
+ * drupal_render().
+ *
+ * @see hook_entity_view_alter()
+ * @see hook_comment_view()
+ * @see hook_node_view()
+ * @see hook_user_view()
+ */
+function hook_entity_view($entity, $type, $view_mode, $langcode) {
+ $entity->content['my_additional_field'] = array(
+ '#markup' => $additional_field,
+ '#weight' => 10,
+ '#theme' => 'mymodule_my_additional_field',
+ );
+}
+
+/**
+ * Alter the results of ENTITY_view().
+ *
+ * This hook is called after the content has been assembled in a structured
+ * array and may be used for doing processing which requires that the complete
+ * entity content structure has been built.
+ *
+ * If a module wishes to act on the rendered HTML of the entity rather than the
+ * structured content array, it may use this hook to add a #post_render
+ * callback. Alternatively, it could also implement hook_preprocess_ENTITY().
+ * See drupal_render() and theme() for details.
+ *
+ * @param $build
+ * A renderable array representing the entity content.
+ * @param $type
+ * The type of entity being rendered (i.e. node, user, comment).
+ *
+ * @see hook_entity_view()
+ * @see hook_comment_view_alter()
+ * @see hook_node_view_alter()
+ * @see hook_taxonomy_term_view_alter()
+ * @see hook_user_view_alter()
+ */
+function hook_entity_view_alter(&$build, $type) {
+ if ($build['#view_mode'] == 'full' && isset($build['an_additional_field'])) {
+ // Change its weight.
+ $build['an_additional_field']['#weight'] = -10;
+
+ // Add a #post_render callback to act on the rendered HTML of the entity.
+ $build['#post_render'][] = 'my_module_node_post_render';
+ }
+}
+
+/**
+ * Act on entities as they are being prepared for view.
+ *
+ * Allows you to operate on multiple entities as they are being prepared for
+ * view. Only use this if attaching the data during the entity_load() phase
+ * is not appropriate, for example when attaching other 'entity' style objects.
+ *
+ * @param $entities
+ * The entities keyed by entity ID.
+ * @param $type
+ * The type of entities being loaded (i.e. node, user, comment).
+ */
+function hook_entity_prepare_view($entities, $type) {
+ // Load a specific node into the user object for later theming.
+ if ($type == 'user') {
+ $nodes = mymodule_get_user_nodes(array_keys($entities));
+ foreach ($entities as $uid => $entity) {
+ $entity->user_node = $nodes[$uid];
+ }
+ }
+}
diff --git a/core/modules/entity/entity.controller.inc b/core/modules/entity/entity.controller.inc
new file mode 100644
index 000000000000..8a342074a241
--- /dev/null
+++ b/core/modules/entity/entity.controller.inc
@@ -0,0 +1,390 @@
+<?php
+
+/**
+ * @file
+ * Entity API controller classes and interface.
+ */
+
+/**
+ * Interface for entity controller classes.
+ *
+ * All entity controller classes specified via the 'controller class' key
+ * returned by hook_entity_info() or hook_entity_info_alter() have to implement
+ * this interface.
+ *
+ * Most simple, SQL-based entity controllers will do better by extending
+ * DrupalDefaultEntityController instead of implementing this interface
+ * directly.
+ */
+interface DrupalEntityControllerInterface {
+
+ /**
+ * Constructor.
+ *
+ * @param $entityType
+ * The entity type for which the instance is created.
+ */
+ public function __construct($entityType);
+
+ /**
+ * Resets the internal, static entity cache.
+ *
+ * @param $ids
+ * (optional) If specified, the cache is reset for the entities with the
+ * given ids only.
+ */
+ public function resetCache(array $ids = NULL);
+
+ /**
+ * Loads one or more entities.
+ *
+ * @param $ids
+ * An array of entity IDs, or FALSE to load all entities.
+ * @param $conditions
+ * An array of conditions in the form 'field' => $value.
+ *
+ * @return
+ * An array of entity objects indexed by their ids.
+ */
+ public function load($ids = array(), $conditions = array());
+}
+
+/**
+ * Default implementation of DrupalEntityControllerInterface.
+ *
+ * This class can be used as-is by most simple entity types. Entity types
+ * requiring special handling can extend the class.
+ */
+class DrupalDefaultEntityController implements DrupalEntityControllerInterface {
+
+ /**
+ * Static cache of entities.
+ *
+ * @var array
+ */
+ protected $entityCache;
+
+ /**
+ * Entity type for this controller instance.
+ *
+ * @var string
+ */
+ protected $entityType;
+
+ /**
+ * Array of information about the entity.
+ *
+ * @var array
+ *
+ * @see entity_get_info()
+ */
+ protected $entityInfo;
+
+ /**
+ * Additional arguments to pass to hook_TYPE_load().
+ *
+ * Set before calling DrupalDefaultEntityController::attachLoad().
+ *
+ * @var array
+ */
+ protected $hookLoadArguments;
+
+ /**
+ * Name of the entity's ID field in the entity database table.
+ *
+ * @var string
+ */
+ protected $idKey;
+
+ /**
+ * Name of entity's revision database table field, if it supports revisions.
+ *
+ * Has the value FALSE if this entity does not use revisions.
+ *
+ * @var string
+ */
+ protected $revisionKey;
+
+ /**
+ * The table that stores revisions, if the entity supports revisions.
+ *
+ * @var string
+ */
+ protected $revisionTable;
+
+ /**
+ * Whether this entity type should use the static cache.
+ *
+ * Set by entity info.
+ *
+ * @var boolean
+ */
+ protected $cache;
+
+ /**
+ * Constructor: sets basic variables.
+ */
+ public function __construct($entityType) {
+ $this->entityType = $entityType;
+ $this->entityInfo = entity_get_info($entityType);
+ $this->entityCache = array();
+ $this->hookLoadArguments = array();
+ $this->idKey = $this->entityInfo['entity keys']['id'];
+
+ // Check if the entity type supports revisions.
+ if (!empty($this->entityInfo['entity keys']['revision'])) {
+ $this->revisionKey = $this->entityInfo['entity keys']['revision'];
+ $this->revisionTable = $this->entityInfo['revision table'];
+ }
+ else {
+ $this->revisionKey = FALSE;
+ }
+
+ // Check if the entity type supports static caching of loaded entities.
+ $this->cache = !empty($this->entityInfo['static cache']);
+ }
+
+ /**
+ * Implements DrupalEntityControllerInterface::resetCache().
+ */
+ public function resetCache(array $ids = NULL) {
+ if (isset($ids)) {
+ foreach ($ids as $id) {
+ unset($this->entityCache[$id]);
+ }
+ }
+ else {
+ $this->entityCache = array();
+ }
+ }
+
+ /**
+ * Implements DrupalEntityControllerInterface::load().
+ */
+ public function load($ids = array(), $conditions = array()) {
+ $entities = array();
+
+ // Revisions are not statically cached, and require a different query to
+ // other conditions, so separate the revision id into its own variable.
+ if ($this->revisionKey && isset($conditions[$this->revisionKey])) {
+ $revision_id = $conditions[$this->revisionKey];
+ unset($conditions[$this->revisionKey]);
+ }
+ else {
+ $revision_id = FALSE;
+ }
+
+ // Create a new variable which is either a prepared version of the $ids
+ // array for later comparison with the entity cache, or FALSE if no $ids
+ // were passed. The $ids array is reduced as items are loaded from cache,
+ // and we need to know if it's empty for this reason to avoid querying the
+ // database when all requested entities are loaded from cache.
+ $passed_ids = !empty($ids) ? array_flip($ids) : FALSE;
+ // Try to load entities from the static cache, if the entity type supports
+ // static caching.
+ if ($this->cache && !$revision_id) {
+ $entities += $this->cacheGet($ids, $conditions);
+ // If any entities were loaded, remove them from the ids still to load.
+ if ($passed_ids) {
+ $ids = array_keys(array_diff_key($passed_ids, $entities));
+ }
+ }
+
+ // Load any remaining entities from the database. This is the case if $ids
+ // is set to FALSE (so we load all entities), if there are any ids left to
+ // load, if loading a revision, or if $conditions was passed without $ids.
+ if ($ids === FALSE || $ids || $revision_id || ($conditions && !$passed_ids)) {
+ // Build the query.
+ $query = $this->buildQuery($ids, $conditions, $revision_id);
+ $queried_entities = $query
+ ->execute()
+ ->fetchAllAssoc($this->idKey);
+ }
+
+ // Pass all entities loaded from the database through $this->attachLoad(),
+ // which attaches fields (if supported by the entity type) and calls the
+ // entity type specific load callback, for example hook_node_load().
+ if (!empty($queried_entities)) {
+ $this->attachLoad($queried_entities, $revision_id);
+ $entities += $queried_entities;
+ }
+
+ if ($this->cache) {
+ // Add entities to the cache if we are not loading a revision.
+ if (!empty($queried_entities) && !$revision_id) {
+ $this->cacheSet($queried_entities);
+ }
+ }
+
+ // Ensure that the returned array is ordered the same as the original
+ // $ids array if this was passed in and remove any invalid ids.
+ if ($passed_ids) {
+ // Remove any invalid ids from the array.
+ $passed_ids = array_intersect_key($passed_ids, $entities);
+ foreach ($entities as $entity) {
+ $passed_ids[$entity->{$this->idKey}] = $entity;
+ }
+ $entities = $passed_ids;
+ }
+
+ return $entities;
+ }
+
+ /**
+ * Builds the query to load the entity.
+ *
+ * This has full revision support. For entities requiring special queries,
+ * the class can be extended, and the default query can be constructed by
+ * calling parent::buildQuery(). This is usually necessary when the object
+ * being loaded needs to be augmented with additional data from another
+ * table, such as loading node type into comments or vocabulary machine name
+ * into terms, however it can also support $conditions on different tables.
+ * See CommentController::buildQuery() or TaxonomyTermController::buildQuery()
+ * for examples.
+ *
+ * @param $ids
+ * An array of entity IDs, or FALSE to load all entities.
+ * @param $conditions
+ * An array of conditions in the form 'field' => $value.
+ * @param $revision_id
+ * The ID of the revision to load, or FALSE if this query is asking for the
+ * most current revision(s).
+ *
+ * @return SelectQuery
+ * A SelectQuery object for loading the entity.
+ */
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ $query = db_select($this->entityInfo['base table'], 'base');
+
+ $query->addTag($this->entityType . '_load_multiple');
+
+ if ($revision_id) {
+ $query->join($this->revisionTable, 'revision', "revision.{$this->idKey} = base.{$this->idKey} AND revision.{$this->revisionKey} = :revisionId", array(':revisionId' => $revision_id));
+ }
+ elseif ($this->revisionKey) {
+ $query->join($this->revisionTable, 'revision', "revision.{$this->revisionKey} = base.{$this->revisionKey}");
+ }
+
+ // Add fields from the {entity} table.
+ $entity_fields = $this->entityInfo['schema_fields_sql']['base table'];
+
+ if ($this->revisionKey) {
+ // Add all fields from the {entity_revision} table.
+ $entity_revision_fields = drupal_map_assoc($this->entityInfo['schema_fields_sql']['revision table']);
+ // The id field is provided by entity, so remove it.
+ unset($entity_revision_fields[$this->idKey]);
+
+ // Remove all fields from the base table that are also fields by the same
+ // name in the revision table.
+ $entity_field_keys = array_flip($entity_fields);
+ foreach ($entity_revision_fields as $key => $name) {
+ if (isset($entity_field_keys[$name])) {
+ unset($entity_fields[$entity_field_keys[$name]]);
+ }
+ }
+ $query->fields('revision', $entity_revision_fields);
+ }
+
+ $query->fields('base', $entity_fields);
+
+ if ($ids) {
+ $query->condition("base.{$this->idKey}", $ids, 'IN');
+ }
+ if ($conditions) {
+ foreach ($conditions as $field => $value) {
+ $query->condition('base.' . $field, $value);
+ }
+ }
+ return $query;
+ }
+
+ /**
+ * Attaches data to entities upon loading.
+ *
+ * This will attach fields, if the entity is fieldable. It calls
+ * hook_entity_load() for modules which need to add data to all entities.
+ * It also calls hook_TYPE_load() on the loaded entities. For example
+ * hook_node_load() or hook_user_load(). If your hook_TYPE_load()
+ * expects special parameters apart from the queried entities, you can set
+ * $this->hookLoadArguments prior to calling the method.
+ * See NodeController::attachLoad() for an example.
+ *
+ * @param $queried_entities
+ * Associative array of query results, keyed on the entity ID.
+ * @param $revision_id
+ * ID of the revision that was loaded, or FALSE if the most current revision
+ * was loaded.
+ */
+ protected function attachLoad(&$queried_entities, $revision_id = FALSE) {
+ // Attach fields.
+ if ($this->entityInfo['fieldable']) {
+ if ($revision_id) {
+ field_attach_load_revision($this->entityType, $queried_entities);
+ }
+ else {
+ field_attach_load($this->entityType, $queried_entities);
+ }
+ }
+
+ // Call hook_entity_load().
+ foreach (module_implements('entity_load') as $module) {
+ $function = $module . '_entity_load';
+ $function($queried_entities, $this->entityType);
+ }
+ // Call hook_TYPE_load(). The first argument for hook_TYPE_load() are
+ // always the queried entities, followed by additional arguments set in
+ // $this->hookLoadArguments.
+ $args = array_merge(array($queried_entities), $this->hookLoadArguments);
+ foreach (module_implements($this->entityInfo['load hook']) as $module) {
+ call_user_func_array($module . '_' . $this->entityInfo['load hook'], $args);
+ }
+ }
+
+ /**
+ * Gets entities from the static cache.
+ *
+ * @param $ids
+ * If not empty, return entities that match these IDs.
+ * @param $conditions
+ * If set, return entities that match all of these conditions.
+ *
+ * @return
+ * Array of entities from the entity cache.
+ */
+ protected function cacheGet($ids, $conditions = array()) {
+ $entities = array();
+ // Load any available entities from the internal cache.
+ if (!empty($this->entityCache)) {
+ if ($ids) {
+ $entities += array_intersect_key($this->entityCache, array_flip($ids));
+ }
+ // If loading entities only by conditions, fetch all available entities
+ // from the cache. Entities which don't match are removed later.
+ elseif ($conditions) {
+ $entities = $this->entityCache;
+ }
+ }
+
+ // Exclude any entities loaded from cache if they don't match $conditions.
+ // This ensures the same behavior whether loading from memory or database.
+ if ($conditions) {
+ foreach ($entities as $entity) {
+ $entity_values = (array) $entity;
+ if (array_diff_assoc($conditions, $entity_values)) {
+ unset($entities[$entity->{$this->idKey}]);
+ }
+ }
+ }
+ return $entities;
+ }
+
+ /**
+ * Stores entities in the static entity cache.
+ *
+ * @param $entities
+ * Entities to store in the cache.
+ */
+ protected function cacheSet($entities) {
+ $this->entityCache += $entities;
+ }
+}
diff --git a/core/modules/entity/entity.info b/core/modules/entity/entity.info
new file mode 100644
index 000000000000..a26079f15b49
--- /dev/null
+++ b/core/modules/entity/entity.info
@@ -0,0 +1,10 @@
+name = Entity
+description = API for managing entities like nodes and users.
+package = Core
+version = VERSION
+core = 8.x
+required = TRUE
+files[] = entity.query.inc
+files[] = entity.controller.inc
+files[] = tests/entity_crud_hook_test.test
+files[] = tests/entity_query.test
diff --git a/core/modules/entity/entity.module b/core/modules/entity/entity.module
new file mode 100644
index 000000000000..02611e3c4910
--- /dev/null
+++ b/core/modules/entity/entity.module
@@ -0,0 +1,442 @@
+<?php
+
+/**
+ * @file
+ * Entity API for handling entities like nodes or users.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function entity_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#entity':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Entity module provides an API for managing entities like nodes and users, i.e. an API for loading and identifying entities. For more information, see the online handbook entry for <a href="!url">Entity module</a>', array('!url' => 'http://drupal.org/handbook/modules/entity')) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_modules_preenable().
+ */
+function entity_modules_preenable() {
+ entity_info_cache_clear();
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function entity_modules_disabled() {
+ entity_info_cache_clear();
+}
+
+/**
+ * Gets the entity info array of an entity type.
+ *
+ * @see hook_entity_info()
+ * @see hook_entity_info_alter()
+ *
+ * @param $entity_type
+ * The entity type, e.g. node, for which the info shall be returned, or NULL
+ * to return an array with info about all types.
+ */
+function entity_get_info($entity_type = NULL) {
+ global $language;
+
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['entity_info'] = &drupal_static(__FUNCTION__);
+ }
+ $entity_info = &$drupal_static_fast['entity_info'];
+
+ // hook_entity_info() includes translated strings, so each language is cached
+ // separately.
+ $langcode = $language->language;
+
+ if (empty($entity_info)) {
+ if ($cache = cache()->get("entity_info:$langcode")) {
+ $entity_info = $cache->data;
+ }
+ else {
+ $entity_info = module_invoke_all('entity_info');
+ // Merge in default values.
+ foreach ($entity_info as $name => $data) {
+ $entity_info[$name] += array(
+ 'fieldable' => FALSE,
+ 'controller class' => 'DrupalDefaultEntityController',
+ 'static cache' => TRUE,
+ 'field cache' => TRUE,
+ 'load hook' => $name . '_load',
+ 'bundles' => array(),
+ 'view modes' => array(),
+ 'entity keys' => array(),
+ 'translation' => array(),
+ );
+ $entity_info[$name]['entity keys'] += array(
+ 'revision' => '',
+ 'bundle' => '',
+ );
+ foreach ($entity_info[$name]['view modes'] as $view_mode => $view_mode_info) {
+ $entity_info[$name]['view modes'][$view_mode] += array(
+ 'custom settings' => FALSE,
+ );
+ }
+ // If no bundle key is provided, assume a single bundle, named after
+ // the entity type.
+ if (empty($entity_info[$name]['entity keys']['bundle']) && empty($entity_info[$name]['bundles'])) {
+ $entity_info[$name]['bundles'] = array($name => array('label' => $entity_info[$name]['label']));
+ }
+ // Prepare entity schema fields SQL info for
+ // DrupalEntityControllerInterface::buildQuery().
+ if (isset($entity_info[$name]['base table'])) {
+ $entity_info[$name]['schema_fields_sql']['base table'] = drupal_schema_fields_sql($entity_info[$name]['base table']);
+ if (isset($entity_info[$name]['revision table'])) {
+ $entity_info[$name]['schema_fields_sql']['revision table'] = drupal_schema_fields_sql($entity_info[$name]['revision table']);
+ }
+ }
+ }
+ // Let other modules alter the entity info.
+ drupal_alter('entity_info', $entity_info);
+ cache()->set("entity_info:$langcode", $entity_info);
+ }
+ }
+
+ if (empty($entity_type)) {
+ return $entity_info;
+ }
+ elseif (isset($entity_info[$entity_type])) {
+ return $entity_info[$entity_type];
+ }
+}
+
+/**
+ * Resets the cached information about entity types.
+ */
+function entity_info_cache_clear() {
+ drupal_static_reset('entity_get_info');
+ // Clear all languages.
+ cache()->deletePrefix('entity_info:');
+}
+
+/**
+ * Helper function to extract id, vid, and bundle name from an entity.
+ *
+ * @param $entity_type
+ * The entity type; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity from which to extract values.
+ * @return
+ * A numerically indexed array (not a hash table) containing these
+ * elements:
+ * 0: primary id of the entity
+ * 1: revision id of the entity, or NULL if $entity_type is not versioned
+ * 2: bundle name of the entity
+ */
+function entity_extract_ids($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+
+ // Objects being created might not have id/vid yet.
+ $id = isset($entity->{$info['entity keys']['id']}) ? $entity->{$info['entity keys']['id']} : NULL;
+ $vid = ($info['entity keys']['revision'] && isset($entity->{$info['entity keys']['revision']})) ? $entity->{$info['entity keys']['revision']} : NULL;
+
+ if (!empty($info['entity keys']['bundle'])) {
+ // Explicitly fail for malformed entities missing the bundle property.
+ if (!isset($entity->{$info['entity keys']['bundle']}) || $entity->{$info['entity keys']['bundle']} === '') {
+ throw new EntityMalformedException(t('Missing bundle property on entity of type @entity_type.', array('@entity_type' => $entity_type)));
+ }
+ $bundle = $entity->{$info['entity keys']['bundle']};
+ }
+ else {
+ // The entity type provides no bundle key: assume a single bundle, named
+ // after the entity type.
+ $bundle = $entity_type;
+ }
+
+ return array($id, $vid, $bundle);
+}
+
+/**
+ * Helper function to assemble an object structure with initial ids.
+ *
+ * This function can be seen as reciprocal to entity_extract_ids().
+ *
+ * @param $entity_type
+ * The entity type; e.g. 'node' or 'user'.
+ * @param $ids
+ * A numerically indexed array, as returned by entity_extract_ids(),
+ * containing these elements:
+ * 0: primary id of the entity
+ * 1: revision id of the entity, or NULL if $entity_type is not versioned
+ * 2: bundle name of the entity, or NULL if $entity_type has no bundles
+ *
+ * @return
+ * An entity structure, initialized with the ids provided.
+ */
+function entity_create_stub_entity($entity_type, $ids) {
+ $entity = new stdClass();
+ $info = entity_get_info($entity_type);
+ $entity->{$info['entity keys']['id']} = $ids[0];
+ if (!empty($info['entity keys']['revision']) && isset($ids[1])) {
+ $entity->{$info['entity keys']['revision']} = $ids[1];
+ }
+ if (!empty($info['entity keys']['bundle']) && isset($ids[2])) {
+ $entity->{$info['entity keys']['bundle']} = $ids[2];
+ }
+ return $entity;
+}
+
+/**
+ * Loads entities from the database.
+ *
+ * This function should be used whenever you need to load more than one entity
+ * from the database. The entities are loaded into memory and will not require
+ * database access if loaded again during the same page request.
+ *
+ * The actual loading is done through a class that has to implement the
+ * DrupalEntityControllerInterface interface. By default,
+ * DrupalDefaultEntityController is used. Entity types can specify that a
+ * different class should be used by setting the 'controller class' key in
+ * hook_entity_info(). These classes can either implement the
+ * DrupalEntityControllerInterface interface, or, most commonly, extend the
+ * DrupalDefaultEntityController class. See node_entity_info() and the
+ * NodeController in node.module as an example.
+ *
+ * @see hook_entity_info()
+ * @see DrupalEntityControllerInterface
+ * @see DrupalDefaultEntityController
+ * @see EntityFieldQuery
+ *
+ * @param $entity_type
+ * The entity type to load, e.g. node or user.
+ * @param $ids
+ * An array of entity IDs, or FALSE to load all entities.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the base table, where
+ * the keys are the database fields and the values are the values those
+ * fields must have. Instead, it is preferable to use EntityFieldQuery to
+ * retrieve a list of entity IDs loadable by this function.
+ * @param $reset
+ * Whether to reset the internal cache for the requested entity type.
+ *
+ * @return
+ * An array of entity objects indexed by their ids.
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function entity_load($entity_type, $ids = FALSE, $conditions = array(), $reset = FALSE) {
+ if ($reset) {
+ entity_get_controller($entity_type)->resetCache();
+ }
+ return entity_get_controller($entity_type)->load($ids, $conditions);
+}
+
+/**
+ * Loads the unchanged, i.e. not modified, entity from the database.
+ *
+ * Unlike entity_load() this function ensures the entity is directly loaded from
+ * the database, thus bypassing any static cache. In particular, this function
+ * is useful to determine changes by comparing the entity being saved to the
+ * stored entity.
+ *
+ * @param $entity_type
+ * The entity type to load, e.g. node or user.
+ * @param $id
+ * The id of the entity to load.
+ *
+ * @return
+ * The unchanged entity, or FALSE if the entity cannot be loaded.
+ */
+function entity_load_unchanged($entity_type, $id) {
+ entity_get_controller($entity_type)->resetCache(array($id));
+ $result = entity_get_controller($entity_type)->load(array($id));
+ return reset($result);
+}
+
+/**
+ * Gets the entity controller class for an entity type.
+ */
+function entity_get_controller($entity_type) {
+ $controllers = &drupal_static(__FUNCTION__, array());
+ if (!isset($controllers[$entity_type])) {
+ $type_info = entity_get_info($entity_type);
+ $class = $type_info['controller class'];
+ $controllers[$entity_type] = new $class($entity_type);
+ }
+ return $controllers[$entity_type];
+}
+
+/**
+ * Invokes hook_entity_prepare_view().
+ *
+ * If adding a new entity similar to nodes, comments or users, you should
+ * invoke this function during the ENTITY_build_content() or
+ * ENTITY_view_multiple() phases of rendering to allow other modules to alter
+ * the objects during this phase. This is needed for situations where
+ * information needs to be loaded outside of ENTITY_load() - particularly
+ * when loading entities into one another - i.e. a user object into a node, due
+ * to the potential for unwanted side-effects such as caching and infinite
+ * recursion. By convention, entity_prepare_view() is called after
+ * field_attach_prepare_view() to allow entity level hooks to act on content
+ * loaded by field API.
+ *
+ * @see hook_entity_prepare_view()
+ *
+ * @param $entity_type
+ * The type of entity, i.e. 'node', 'user'.
+ * @param $entities
+ * The entity objects which are being prepared for view, keyed by object ID.
+ */
+function entity_prepare_view($entity_type, $entities) {
+ // To ensure hooks are only run once per entity, check for an
+ // entity_view_prepared flag and only process items without it.
+ // @todo: resolve this more generally for both entity and field level hooks.
+ $prepare = array();
+ foreach ($entities as $id => $entity) {
+ if (empty($entity->entity_view_prepared)) {
+ // Add this entity to the items to be prepared.
+ $prepare[$id] = $entity;
+
+ // Mark this item as prepared.
+ $entity->entity_view_prepared = TRUE;
+ }
+ }
+
+ if (!empty($prepare)) {
+ module_invoke_all('entity_prepare_view', $prepare, $entity_type);
+ }
+}
+
+/**
+ * Returns the uri elements of an entity.
+ *
+ * @param $entity_type
+ * The entity type; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity for which to generate a path.
+ *
+ * @return
+ * An array containing the 'path' and 'options' keys used to build the uri of
+ * the entity, and matching the signature of url(). NULL if the entity has no
+ * uri of its own.
+ */
+function entity_uri($entity_type, $entity) {
+ $info = entity_get_info($entity_type);
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // A bundle-specific callback takes precedence over the generic one for the
+ // entity type.
+ if (isset($info['bundles'][$bundle]['uri callback'])) {
+ $uri_callback = $info['bundles'][$bundle]['uri callback'];
+ }
+ elseif (isset($info['uri callback'])) {
+ $uri_callback = $info['uri callback'];
+ }
+ else {
+ return NULL;
+ }
+
+ // Invoke the callback to get the URI. If there is no callback, return NULL.
+ if (isset($uri_callback) && function_exists($uri_callback)) {
+ $uri = $uri_callback($entity);
+ // Pass the entity data to url() so that alter functions do not need to
+ // lookup this entity again.
+ $uri['options']['entity_type'] = $entity_type;
+ $uri['options']['entity'] = $entity;
+ return $uri;
+ }
+}
+
+/**
+ * Returns the label of an entity.
+ *
+ * See the 'label callback' component of the hook_entity_info() return value
+ * for more information.
+ *
+ * @param $entity_type
+ * The entity type; e.g., 'node' or 'user'.
+ * @param $entity
+ * The entity for which to generate the label.
+ *
+ * @return
+ * The entity label, or FALSE if not found.
+ */
+function entity_label($entity_type, $entity) {
+ $label = FALSE;
+ $info = entity_get_info($entity_type);
+ if (isset($info['label callback']) && function_exists($info['label callback'])) {
+ $label = $info['label callback']($entity_type, $entity);
+ }
+ elseif (!empty($info['entity keys']['label']) && isset($entity->{$info['entity keys']['label']})) {
+ $label = $entity->{$info['entity keys']['label']};
+ }
+
+ return $label;
+}
+
+/**
+ * Helper function for attaching field API validation to entity forms.
+ */
+function entity_form_field_validate($entity_type, $form, &$form_state) {
+ // All field attach API functions act on an entity object, but during form
+ // validation, we don't have one. $form_state contains the entity as it was
+ // prior to processing the current form submission, and we must not update it
+ // until we have fully validated the submitted input. Therefore, for
+ // validation, act on a pseudo entity created out of the form values.
+ $pseudo_entity = (object) $form_state['values'];
+ field_attach_form_validate($entity_type, $pseudo_entity, $form, $form_state);
+}
+
+/**
+ * Helper function for copying submitted values to entity properties for simple entity forms.
+ *
+ * During the submission handling of an entity form's "Save", "Preview", and
+ * possibly other buttons, the form state's entity needs to be updated with the
+ * submitted form values. Each entity form implements its own builder function
+ * for doing this, appropriate for the particular entity and form, whereas
+ * modules may specify additional builder functions in $form['#entity_builders']
+ * for copying the form values of added form elements to entity properties.
+ * Many of the main entity builder functions can call this helper function to
+ * re-use its logic of copying $form_state['values'][PROPERTY] values to
+ * $entity->PROPERTY for all entries in $form_state['values'] that are not field
+ * data, and calling field_attach_submit() to copy field data. Apart from that
+ * this helper invokes any additional builder functions that have been specified
+ * in $form['#entity_builders'].
+ *
+ * For some entity forms (e.g., forms with complex non-field data and forms that
+ * simultaneously edit multiple entities), this behavior may be inappropriate,
+ * so the builder function for such forms needs to implement the required
+ * functionality instead of calling this function.
+ */
+function entity_form_submit_build_entity($entity_type, $entity, $form, &$form_state) {
+ $info = entity_get_info($entity_type);
+ list(, , $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Copy top-level form values that are not for fields to entity properties,
+ // without changing existing entity properties that are not being edited by
+ // this form. Copying field values must be done using field_attach_submit().
+ $values_excluding_fields = $info['fieldable'] ? array_diff_key($form_state['values'], field_info_instances($entity_type, $bundle)) : $form_state['values'];
+ foreach ($values_excluding_fields as $key => $value) {
+ $entity->$key = $value;
+ }
+
+ // Invoke all specified builders for copying form values to entity properties.
+ if (isset($form['#entity_builders'])) {
+ foreach ($form['#entity_builders'] as $function) {
+ $function($entity_type, $entity, $form, $form_state);
+ }
+ }
+
+ // Copy field values to the entity.
+ if ($info['fieldable']) {
+ field_attach_submit($entity_type, $entity, $form, $form_state);
+ }
+}
+
+/**
+ * Exception thrown when a malformed entity is passed.
+ */
+class EntityMalformedException extends Exception { }
+
diff --git a/core/modules/entity/entity.query.inc b/core/modules/entity/entity.query.inc
new file mode 100644
index 000000000000..b4e91a2f9f40
--- /dev/null
+++ b/core/modules/entity/entity.query.inc
@@ -0,0 +1,953 @@
+<?php
+
+/**
+ * @file
+ * Entity query API.
+ */
+
+/**
+ * Exception thrown by EntityFieldQuery() on unsupported query syntax.
+ *
+ * Some storage modules might not support the full range of the syntax for
+ * conditions, and will raise an EntityFieldQueryException when an unsupported
+ * condition was specified.
+ */
+class EntityFieldQueryException extends Exception {}
+
+/**
+ * Retrieves entities matching a given set of conditions.
+ *
+ * This class allows finding entities based on entity properties (for example,
+ * node->changed), field values, and generic entity meta data (bundle,
+ * entity type, entity id, and revision ID). It is not possible to query across
+ * multiple entity types. For example, there is no facility to find published
+ * nodes written by users created in the last hour, as this would require
+ * querying both node->status and user->created.
+ *
+ * Normally we would not want to have public properties on the object, as that
+ * allows the object's state to become inconsistent too easily. However, this
+ * class's standard use case involves primarily code that does need to have
+ * direct access to the collected properties in order to handle alternate
+ * execution routines. We therefore use public properties for simplicity. Note
+ * that code that is simply creating and running a field query should still use
+ * the appropriate methods to add conditions on the query.
+ *
+ * Storage engines are not required to support every type of query. By default,
+ * an EntityFieldQueryException will be raised if an unsupported condition is
+ * specified or if the query has field conditions or sorts that are stored in
+ * different field storage engines. However, this logic can be overridden in
+ * hook_entity_query().
+ *
+ * Also note that this query does not automatically respect entity access
+ * restrictions. Node access control is performed by the SQL storage engine but
+ * other storage engines might not do this.
+ */
+class EntityFieldQuery {
+ /**
+ * Indicates that both deleted and non-deleted fields should be returned.
+ *
+ * @see EntityFieldQuery::deleted()
+ */
+ const RETURN_ALL = NULL;
+
+ /**
+ * TRUE if the query has already been altered, FALSE if it hasn't.
+ *
+ * Used in alter hooks to check for cloned queries that have already been
+ * altered prior to the clone (for example, the pager count query).
+ *
+ * @var boolean
+ */
+ public $altered = FALSE;
+
+ /**
+ * Associative array of entity-generic metadata conditions.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::entityCondition()
+ */
+ public $entityConditions = array();
+
+ /**
+ * List of field conditions.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::fieldCondition()
+ */
+ public $fieldConditions = array();
+
+ /**
+ * List of field meta conditions (language and delta).
+ *
+ * Field conditions operate on columns specified by hook_field_schema(),
+ * the meta conditions operate on columns added by the system: delta
+ * and language. These can not be mixed with the field conditions because
+ * field columns can have any name including delta and language.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::fieldLanguageCondition()
+ * @see EntityFieldQuery::fielDeltaCondition()
+ */
+ public $fieldMetaConditions = array();
+
+ /**
+ * List of property conditions.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::propertyCondition()
+ */
+ public $propertyConditions = array();
+
+ /**
+ * List of order clauses.
+ *
+ * @var array
+ */
+ public $order = array();
+
+ /**
+ * The query range.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::range()
+ */
+ public $range = array();
+
+ /**
+ * The query pager data.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::pager()
+ */
+ public $pager = array();
+
+ /**
+ * Query behavior for deleted data.
+ *
+ * TRUE to return only deleted data, FALSE to return only non-deleted data,
+ * EntityFieldQuery::RETURN_ALL to return everything.
+ *
+ * @see EntityFieldQuery::deleted()
+ */
+ public $deleted = FALSE;
+
+ /**
+ * A list of field arrays used.
+ *
+ * Field names passed to EntityFieldQuery::fieldCondition() and
+ * EntityFieldQuery::fieldOrderBy() are run through field_info_field() before
+ * stored in this array. This way, the elements of this array are field
+ * arrays.
+ *
+ * @var array
+ */
+ public $fields = array();
+
+ /**
+ * TRUE if this is a count query, FALSE if it isn't.
+ *
+ * @var boolean
+ */
+ public $count = FALSE;
+
+ /**
+ * Flag indicating whether this is querying current or all revisions.
+ *
+ * @var int
+ *
+ * @see EntityFieldQuery::age()
+ */
+ public $age = FIELD_LOAD_CURRENT;
+
+ /**
+ * A list of the tags added to this query.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::addTag()
+ */
+ public $tags = array();
+
+ /**
+ * A list of metadata added to this query.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::addMetaData()
+ */
+ public $metaData = array();
+
+ /**
+ * The ordered results.
+ *
+ * @var array
+ *
+ * @see EntityFieldQuery::execute().
+ */
+ public $orderedResults = array();
+
+ /**
+ * The method executing the query, if it is overriding the default.
+ *
+ * @var string
+ *
+ * @see EntityFieldQuery::execute().
+ */
+ public $executeCallback = '';
+
+ /**
+ * Adds a condition on entity-generic metadata.
+ *
+ * If the overall query contains only entity conditions or ordering, or if
+ * there are property conditions, then specifying the entity type is
+ * mandatory. If there are field conditions or ordering but no property
+ * conditions or ordering, then specifying an entity type is optional. While
+ * the field storage engine might support field conditions on more than one
+ * entity type, there is no way to query across multiple entity base tables by
+ * default. To specify the entity type, pass in 'entity_type' for $name,
+ * the type as a string for $value, and no $operator (it's disregarded).
+ *
+ * 'bundle', 'revision_id' and 'entity_id' have no such restrictions.
+ *
+ * Note: The "comment" and "taxonomy_term" entity types don't support bundle
+ * conditions. For "taxonomy_term", propertyCondition('vid') can be used
+ * instead.
+ *
+ * @param $name
+ * 'entity_type', 'bundle', 'revision_id' or 'entity_id'.
+ * @param $value
+ * The value for $name. In most cases, this is a scalar. For more complex
+ * options, it is an array. The meaning of each element in the array is
+ * dependent on $operator.
+ * @param $operator
+ * Possible values:
+ * - '=', '<>', '>', '>=', '<', '<=', 'STARTS_WITH', 'CONTAINS': These
+ * operators expect $value to be a literal of the same type as the
+ * column.
+ * - 'IN', 'NOT IN': These operators expect $value to be an array of
+ * literals of the same type as the column.
+ * - 'BETWEEN': This operator expects $value to be an array of two literals
+ * of the same type as the column.
+ * The operator can be omitted, and will default to 'IN' if the value is an
+ * array, or to '=' otherwise.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function entityCondition($name, $value, $operator = NULL) {
+ $this->entityConditions[$name] = array(
+ 'value' => $value,
+ 'operator' => $operator,
+ );
+ return $this;
+ }
+
+ /**
+ * Adds a condition on field values.
+ *
+ * @param $field
+ * Either a field name or a field array.
+ * @param $column
+ * The column that should hold the value to be matched.
+ * @param $value
+ * The value to test the column value against.
+ * @param $operator
+ * The operator to be used to test the given value.
+ * @param $delta_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $delta_group.
+ * @param $language_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $language_group.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ *
+ * @see EntityFieldQuery::addFieldCondition
+ * @see EntityFieldQuery::deleted
+ */
+ public function fieldCondition($field, $column = NULL, $value = NULL, $operator = NULL, $delta_group = NULL, $language_group = NULL) {
+ return $this->addFieldCondition($this->fieldConditions, $field, $column, $value, $operator, $delta_group, $language_group);
+ }
+
+ /**
+ * Adds a condition on the field language column.
+ *
+ * @param $field
+ * Either a field name or a field array.
+ * @param $value
+ * The value to test the column value against.
+ * @param $operator
+ * The operator to be used to test the given value.
+ * @param $delta_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $delta_group.
+ * @param $language_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $language_group.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ *
+ * @see EntityFieldQuery::addFieldCondition
+ * @see EntityFieldQuery::deleted
+ */
+ public function fieldLanguageCondition($field, $value = NULL, $operator = NULL, $delta_group = NULL, $language_group = NULL) {
+ return $this->addFieldCondition($this->fieldMetaConditions, $field, 'language', $value, $operator, $delta_group, $language_group);
+ }
+
+ /**
+ * Adds a condition on the field delta column.
+ *
+ * @param $field
+ * Either a field name or a field array.
+ * @param $value
+ * The value to test the column value against.
+ * @param $operator
+ * The operator to be used to test the given value.
+ * @param $delta_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $delta_group.
+ * @param $language_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $language_group.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ *
+ * @see EntityFieldQuery::addFieldCondition
+ * @see EntityFieldQuery::deleted
+ */
+ public function fieldDeltaCondition($field, $value = NULL, $operator = NULL, $delta_group = NULL, $language_group = NULL) {
+ return $this->addFieldCondition($this->fieldMetaConditions, $field, 'delta', $value, $operator, $delta_group, $language_group);
+ }
+
+ /**
+ * Adds the given condition to the proper condition array.
+ *
+ * @param $conditions
+ * A reference to an array of conditions.
+ * @param $field
+ * Either a field name or a field array.
+ * @param $column
+ * A column defined in the hook_field_schema() of this field. If this is
+ * omitted then the query will find only entities that have data in this
+ * field, using the entity and property conditions if there are any.
+ * @param $value
+ * The value to test the column value against. In most cases, this is a
+ * scalar. For more complex options, it is an array. The meaning of each
+ * element in the array is dependent on $operator.
+ * @param $operator
+ * Possible values:
+ * - '=', '<>', '>', '>=', '<', '<=', 'STARTS_WITH', 'CONTAINS': These
+ * operators expect $value to be a literal of the same type as the
+ * column.
+ * - 'IN', 'NOT IN': These operators expect $value to be an array of
+ * literals of the same type as the column.
+ * - 'BETWEEN': This operator expects $value to be an array of two literals
+ * of the same type as the column.
+ * The operator can be omitted, and will default to 'IN' if the value is an
+ * array, or to '=' otherwise.
+ * @param $delta_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $delta_group. For example, let's presume a multivalue field which has
+ * two columns, 'color' and 'shape', and for entity id 1, there are two
+ * values: red/square and blue/circle. Entity ID 1 does not have values
+ * corresponding to 'red circle', however if you pass 'red' and 'circle' as
+ * conditions, it will appear in the results - by default queries will run
+ * against any combination of deltas. By passing the conditions with the
+ * same $delta_group it will ensure that only values attached to the same
+ * delta are matched, and entity 1 would then be excluded from the results.
+ * @param $language_group
+ * An arbitrary identifier: conditions in the same group must have the same
+ * $language_group.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ protected function addFieldCondition(&$conditions, $field, $column = NULL, $value = NULL, $operator = NULL, $delta_group = NULL, $language_group = NULL) {
+ if (is_scalar($field)) {
+ $field_definition = field_info_field($field);
+ if (empty($field_definition)) {
+ throw new EntityFieldQueryException(t('Unknown field: @field_name', array('@field_name' => $field)));
+ }
+ $field = $field_definition;
+ }
+ // Ensure the same index is used for field conditions as for fields.
+ $index = count($this->fields);
+ $this->fields[$index] = $field;
+ if (isset($column)) {
+ $conditions[$index] = array(
+ 'field' => $field,
+ 'column' => $column,
+ 'value' => $value,
+ 'operator' => $operator,
+ 'delta_group' => $delta_group,
+ 'language_group' => $language_group,
+ );
+ }
+ return $this;
+ }
+
+ /**
+ * Adds a condition on an entity-specific property.
+ *
+ * An $entity_type must be specified by calling
+ * EntityFieldCondition::entityCondition('entity_type', $entity_type) before
+ * executing the query. Also, by default only entities stored in SQL are
+ * supported; however, EntityFieldQuery::executeCallback can be set to handle
+ * different entity storage.
+ *
+ * @param $column
+ * A column defined in the hook_schema() of the base table of the entity.
+ * @param $value
+ * The value to test the field against. In most cases, this is a scalar. For
+ * more complex options, it is an array. The meaning of each element in the
+ * array is dependent on $operator.
+ * @param $operator
+ * Possible values:
+ * - '=', '<>', '>', '>=', '<', '<=', 'STARTS_WITH', 'CONTAINS': These
+ * operators expect $value to be a literal of the same type as the
+ * column.
+ * - 'IN', 'NOT IN': These operators expect $value to be an array of
+ * literals of the same type as the column.
+ * - 'BETWEEN': This operator expects $value to be an array of two literals
+ * of the same type as the column.
+ * The operator can be omitted, and will default to 'IN' if the value is an
+ * array, or to '=' otherwise.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function propertyCondition($column, $value, $operator = NULL) {
+ $this->propertyConditions[] = array(
+ 'column' => $column,
+ 'value' => $value,
+ 'operator' => $operator,
+ );
+ return $this;
+ }
+
+ /**
+ * Orders the result set by entity-generic metadata.
+ *
+ * If called multiple times, the query will order by each specified column in
+ * the order this method is called.
+ *
+ * Note: The "comment" and "taxonomy_term" entity types don't support ordering
+ * by bundle. For "taxonomy_term", propertyOrderBy('vid') can be used instead.
+ *
+ * @param $name
+ * 'entity_type', 'bundle', 'revision_id' or 'entity_id'.
+ * @param $direction
+ * The direction to sort. Legal values are "ASC" and "DESC".
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function entityOrderBy($name, $direction = 'ASC') {
+ $this->order[] = array(
+ 'type' => 'entity',
+ 'specifier' => $name,
+ 'direction' => $direction,
+ );
+ return $this;
+ }
+
+ /**
+ * Orders the result set by a given field column.
+ *
+ * If called multiple times, the query will order by each specified column in
+ * the order this method is called.
+ *
+ * @param $field
+ * Either a field name or a field array.
+ * @param $column
+ * A column defined in the hook_field_schema() of this field. entity_id and
+ * bundle can also be used.
+ * @param $direction
+ * The direction to sort. Legal values are "ASC" and "DESC".
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function fieldOrderBy($field, $column, $direction = 'ASC') {
+ if (is_scalar($field)) {
+ $field_definition = field_info_field($field);
+ if (empty($field_definition)) {
+ throw new EntityFieldQueryException(t('Unknown field: @field_name', array('@field_name' => $field)));
+ }
+ $field = $field_definition;
+ }
+ // Save the index used for the new field, for later use in field storage.
+ $index = count($this->fields);
+ $this->fields[$index] = $field;
+ $this->order[] = array(
+ 'type' => 'field',
+ 'specifier' => array(
+ 'field' => $field,
+ 'index' => $index,
+ 'column' => $column,
+ ),
+ 'direction' => $direction,
+ );
+ return $this;
+ }
+
+ /**
+ * Orders the result set by an entity-specific property.
+ *
+ * An $entity_type must be specified by calling
+ * EntityFieldCondition::entityCondition('entity_type', $entity_type) before
+ * executing the query.
+ *
+ * If called multiple times, the query will order by each specified column in
+ * the order this method is called.
+ *
+ * @param $column
+ * The column on which to order.
+ * @param $direction
+ * The direction to sort. Legal values are "ASC" and "DESC".
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function propertyOrderBy($column, $direction = 'ASC') {
+ $this->order[] = array(
+ 'type' => 'property',
+ 'specifier' => $column,
+ 'direction' => $direction,
+ );
+ return $this;
+ }
+
+ /**
+ * Sets the query to be a count query only.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function count() {
+ $this->count = TRUE;
+ return $this;
+ }
+
+ /**
+ * Restricts a query to a given range in the result set.
+ *
+ * @param $start
+ * The first entity from the result set to return. If NULL, removes any
+ * range directives that are set.
+ * @param $length
+ * The number of entities to return from the result set.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function range($start = NULL, $length = NULL) {
+ $this->range = array(
+ 'start' => $start,
+ 'length' => $length,
+ );
+ return $this;
+ }
+
+ /**
+ * Enables a pager for the query.
+ *
+ * @param $limit
+ * An integer specifying the number of elements per page. If passed a false
+ * value (FALSE, 0, NULL), the pager is disabled.
+ * @param $element
+ * An optional integer to distinguish between multiple pagers on one page.
+ * If not provided, one is automatically calculated.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function pager($limit = 10, $element = NULL) {
+ if (!isset($element)) {
+ $element = PagerDefault::$maxElement++;
+ }
+ elseif ($element >= PagerDefault::$maxElement) {
+ PagerDefault::$maxElement = $element + 1;
+ }
+
+ $this->pager = array(
+ 'limit' => $limit,
+ 'element' => $element,
+ );
+ return $this;
+ }
+
+ /**
+ * Enables sortable tables for this query.
+ *
+ * @param $headers
+ * An EFQ Header array based on which the order clause is added to the query.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function tableSort(&$headers) {
+ // If 'field' is not initialized, the header columns aren't clickable
+ foreach ($headers as $key =>$header) {
+ if (is_array($header) && isset($header['specifier'])) {
+ $headers[$key]['field'] = '';
+ }
+ }
+
+ $order = tablesort_get_order($headers);
+ $direction = tablesort_get_sort($headers);
+ foreach ($headers as $header) {
+ if (is_array($header) && ($header['data'] == $order['name'])) {
+ if ($header['type'] == 'field') {
+ $this->fieldOrderBy($header['specifier']['field'], $header['specifier']['column'], $direction);
+ }
+ else {
+ $header['direction'] = $direction;
+ $this->order[] = $header;
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Filters on the data being deleted.
+ *
+ * @param $deleted
+ * TRUE to only return deleted data, FALSE to return non-deleted data,
+ * EntityFieldQuery::RETURN_ALL to return everything. Defaults to FALSE.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function deleted($deleted = TRUE) {
+ $this->deleted = $deleted;
+ return $this;
+ }
+
+ /**
+ * Queries the current or every revision.
+ *
+ * Note that this only affects field conditions. Property conditions always
+ * apply to the current revision.
+ * @TODO: Once revision tables have been cleaned up, revisit this.
+ *
+ * @param $age
+ * - FIELD_LOAD_CURRENT (default): Query the most recent revisions for all
+ * entities. The results will be keyed by entity type and entity ID.
+ * - FIELD_LOAD_REVISION: Query all revisions. The results will be keyed by
+ * entity type and entity revision ID.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function age($age) {
+ $this->age = $age;
+ return $this;
+ }
+
+ /**
+ * Adds a tag to the query.
+ *
+ * Tags are strings that mark a query so that hook_query_alter() and
+ * hook_query_TAG_alter() implementations may decide if they wish to alter
+ * the query. A query may have any number of tags, and they must be valid PHP
+ * identifiers (composed of letters, numbers, and underscores). For example,
+ * queries involving nodes that will be displayed for a user need to add the
+ * tag 'node_access', so that the node module can add access restrictions to
+ * the query.
+ *
+ * If an entity field query has tags, it must also have an entity type
+ * specified, because the alter hook will need the entity base table.
+ *
+ * @param string $tag
+ * The tag to add.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function addTag($tag) {
+ $this->tags[$tag] = $tag;
+ return $this;
+ }
+
+ /**
+ * Adds additional metadata to the query.
+ *
+ * Sometimes a query may need to provide additional contextual data for the
+ * alter hook. The alter hook implementations may then use that information
+ * to decide if and how to take action.
+ *
+ * @param $key
+ * The unique identifier for this piece of metadata. Must be a string that
+ * follows the same rules as any other PHP identifier.
+ * @param $object
+ * The additional data to add to the query. May be any valid PHP variable.
+ *
+ * @return EntityFieldQuery
+ * The called object.
+ */
+ public function addMetaData($key, $object) {
+ $this->metaData[$key] = $object;
+ return $this;
+ }
+
+ /**
+ * Executes the query.
+ *
+ * After executing the query, $this->orderedResults will contain a list of
+ * the same stub entities in the order returned by the query. This is only
+ * relevant if there are multiple entity types in the returned value and
+ * a field ordering was requested. In every other case, the returned value
+ * contains everything necessary for processing.
+ *
+ * @return
+ * Either a number if count() was called or an array of associative
+ * arrays of stub entities. The outer array keys are entity types, and the
+ * inner array keys are the relevant ID. (In most this cases this will be
+ * the entity ID. The only exception is when age=FIELD_LOAD_REVISION is used
+ * and field conditions or sorts are present -- in this case, the key will
+ * be the revision ID.) The inner array values are always stub entities, as
+ * returned by entity_create_stub_entity(). To traverse the returned array:
+ * @code
+ * foreach ($query->execute() as $entity_type => $entities) {
+ * foreach ($entities as $entity_id => $entity) {
+ * @endcode
+ * Note if the entity type is known, then the following snippet will load
+ * the entities found:
+ * @code
+ * $result = $query->execute();
+ * $entities = entity_load($my_type, array_keys($result[$my_type]));
+ * @endcode
+ */
+ public function execute() {
+ // Give a chance to other modules to alter the query.
+ drupal_alter('entity_query', $this);
+ $this->altered = TRUE;
+
+ // Initialize the pager.
+ $this->initializePager();
+
+ // Execute the query using the correct callback.
+ $result = call_user_func($this->queryCallback(), $this);
+
+ return $result;
+ }
+
+ /**
+ * Determines the query callback to use for this entity query.
+ *
+ * @return
+ * A callback that can be used with call_user_func().
+ */
+ public function queryCallback() {
+ // Use the override from $this->executeCallback. It can be set either
+ // while building the query, or using hook_entity_query_alter().
+ if (function_exists($this->executeCallback)) {
+ return $this->executeCallback;
+ }
+ // If there are no field conditions and sorts, and no execute callback
+ // then we default to querying entity tables in SQL.
+ if (empty($this->fields)) {
+ return array($this, 'propertyQuery');
+ }
+ // If no override, find the storage engine to be used.
+ foreach ($this->fields as $field) {
+ if (!isset($storage)) {
+ $storage = $field['storage']['module'];
+ }
+ elseif ($storage != $field['storage']['module']) {
+ throw new EntityFieldQueryException(t("Can't handle more than one field storage engine"));
+ }
+ }
+ if ($storage) {
+ // Use hook_field_storage_query() from the field storage.
+ return $storage . '_field_storage_query';
+ }
+ else {
+ throw new EntityFieldQueryException(t("Field storage engine not found."));
+ }
+ }
+
+ /**
+ * Queries entity tables in SQL for property conditions and sorts.
+ *
+ * This method is only used if there are no field conditions and sorts.
+ *
+ * @return
+ * See EntityFieldQuery::execute().
+ */
+ protected function propertyQuery() {
+ if (empty($this->entityConditions['entity_type'])) {
+ throw new EntityFieldQueryException(t('For this query an entity type must be specified.'));
+ }
+ $entity_type = $this->entityConditions['entity_type']['value'];
+ $entity_info = entity_get_info($entity_type);
+ if (empty($entity_info['base table'])) {
+ throw new EntityFieldQueryException(t('Entity %entity has no base table.', array('%entity' => $entity_type)));
+ }
+ $base_table = $entity_info['base table'];
+ $base_table_schema = drupal_get_schema($base_table);
+ $select_query = db_select($base_table);
+ $select_query->addExpression(':entity_type', 'entity_type', array(':entity_type' => $entity_type));
+ // Process the property conditions.
+ foreach ($this->propertyConditions as $property_condition) {
+ $this->addCondition($select_query, "$base_table." . $property_condition['column'], $property_condition);
+ }
+ // Process the four possible entity condition.
+ // The id field is always present in entity keys.
+ $sql_field = $entity_info['entity keys']['id'];
+ $id_map['entity_id'] = $sql_field;
+ $select_query->addField($base_table, $sql_field, 'entity_id');
+ if (isset($this->entityConditions['entity_id'])) {
+ $this->addCondition($select_query, $sql_field, $this->entityConditions['entity_id']);
+ }
+
+ // If there is a revision key defined, use it.
+ if (!empty($entity_info['entity keys']['revision'])) {
+ $sql_field = $entity_info['entity keys']['revision'];
+ $select_query->addField($base_table, $sql_field, 'revision_id');
+ if (isset($this->entityConditions['revision_id'])) {
+ $this->addCondition($select_query, $sql_field, $this->entityConditions['revision_id']);
+ }
+ }
+ else {
+ $sql_field = 'revision_id';
+ $select_query->addExpression('NULL', 'revision_id');
+ }
+ $id_map['revision_id'] = $sql_field;
+
+ // Handle bundles.
+ if (!empty($entity_info['entity keys']['bundle'])) {
+ $sql_field = $entity_info['entity keys']['bundle'];
+ $having = FALSE;
+
+ if (!empty($base_table_schema['fields'][$sql_field])) {
+ $select_query->addField($base_table, $sql_field, 'bundle');
+ }
+ }
+ else {
+ $sql_field = 'bundle';
+ $select_query->addExpression(':bundle', 'bundle', array(':bundle' => $entity_type));
+ $having = TRUE;
+ }
+ $id_map['bundle'] = $sql_field;
+ if (isset($this->entityConditions['bundle'])) {
+ $this->addCondition($select_query, $sql_field, $this->entityConditions['bundle'], $having);
+ }
+
+ // Order the query.
+ foreach ($this->order as $order) {
+ if ($order['type'] == 'entity') {
+ $key = $order['specifier'];
+ if (!isset($id_map[$key])) {
+ throw new EntityFieldQueryException(t('Do not know how to order on @key for @entity_type', array('@key' => $key, '@entity_type' => $entity_type)));
+ }
+ $select_query->orderBy($id_map[$key], $order['direction']);
+ }
+ elseif ($order['type'] == 'property') {
+ $select_query->orderBy("$base_table." . $order['specifier'], $order['direction']);
+ }
+ }
+
+ return $this->finishQuery($select_query);
+ }
+
+ /**
+ * Gets the total number of results and initialize a pager for the query.
+ *
+ * This query can be detected by checking for ($this->pager && $this->count),
+ * which allows a driver to return 0 from the count query and disable
+ * the pager.
+ */
+ function initializePager() {
+ if ($this->pager && !$this->count) {
+ $page = pager_find_page($this->pager['element']);
+ $count_query = clone $this;
+ $this->pager['total'] = $count_query->count()->execute();
+ $this->pager['start'] = $page * $this->pager['limit'];
+ pager_default_initialize($this->pager['total'], $this->pager['limit'], $this->pager['element']);
+ $this->range($this->pager['start'], $this->pager['limit']);
+ }
+ }
+
+ /**
+ * Finishes the query.
+ *
+ * Adds tags, metaData, range and returns the requested list or count.
+ *
+ * @param SelectQuery $select_query
+ * A SelectQuery which has entity_type, entity_id, revision_id and bundle
+ * fields added.
+ * @param $id_key
+ * Which field's values to use as the returned array keys.
+ *
+ * @return
+ * See EntityFieldQuery::execute().
+ */
+ function finishQuery($select_query, $id_key = 'entity_id') {
+ foreach ($this->tags as $tag) {
+ $select_query->addTag($tag);
+ }
+ foreach ($this->metaData as $key => $object) {
+ $select_query->addMetaData($key, $object);
+ }
+ $select_query->addMetaData('entity_field_query', $this);
+ if ($this->range) {
+ $select_query->range($this->range['start'], $this->range['length']);
+ }
+ if ($this->count) {
+ return $select_query->countQuery()->execute()->fetchField();
+ }
+ $return = array();
+ foreach ($select_query->execute() as $partial_entity) {
+ $bundle = isset($partial_entity->bundle) ? $partial_entity->bundle : NULL;
+ $entity = entity_create_stub_entity($partial_entity->entity_type, array($partial_entity->entity_id, $partial_entity->revision_id, $bundle));
+ $return[$partial_entity->entity_type][$partial_entity->$id_key] = $entity;
+ $this->ordered_results[] = $partial_entity;
+ }
+ return $return;
+ }
+
+ /**
+ * Adds a condition to an already built SelectQuery (internal function).
+ *
+ * This is a helper for hook_entity_query() and hook_field_storage_query().
+ *
+ * @param SelectQuery $select_query
+ * A SelectQuery object.
+ * @param $sql_field
+ * The name of the field.
+ * @param $condition
+ * A condition as described in EntityFieldQuery::fieldCondition() and
+ * EntityFieldQuery::entityCondition().
+ * @param $having
+ * HAVING or WHERE. This is necessary because SQL can't handle WHERE
+ * conditions on aliased columns.
+ */
+ public function addCondition(SelectQuery $select_query, $sql_field, $condition, $having = FALSE) {
+ $method = $having ? 'havingCondition' : 'condition';
+ $like_prefix = '';
+ switch ($condition['operator']) {
+ case 'CONTAINS':
+ $like_prefix = '%';
+ case 'STARTS_WITH':
+ $select_query->$method($sql_field, $like_prefix . db_like($condition['value']) . '%', 'LIKE');
+ break;
+ default:
+ $select_query->$method($sql_field, $condition['value'], $condition['operator']);
+ }
+ }
+
+}
+
diff --git a/core/modules/entity/tests/entity_cache_test.info b/core/modules/entity/tests/entity_cache_test.info
new file mode 100644
index 000000000000..c13496e84222
--- /dev/null
+++ b/core/modules/entity/tests/entity_cache_test.info
@@ -0,0 +1,7 @@
+name = "Entity cache test"
+description = "Support module for testing entity cache."
+package = Testing
+version = VERSION
+core = 8.x
+dependencies[] = entity_cache_test_dependency
+hidden = TRUE
diff --git a/core/modules/entity/tests/entity_cache_test.module b/core/modules/entity/tests/entity_cache_test.module
new file mode 100644
index 000000000000..5ae9eccb1e07
--- /dev/null
+++ b/core/modules/entity/tests/entity_cache_test.module
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @file
+ * Helper module for entity cache tests.
+ */
+
+/**
+ * Implements hook_watchdog().
+ *
+ * This hook is called during module_enable() and since this hook
+ * implementation is invoked, we have to expect that this module and dependent
+ * modules have been properly installed already. So we expect to be able to
+ * retrieve the entity information that has been registered by the required
+ * dependency module.
+ *
+ * @see EnableDisableTestCase::testEntityCache()
+ * @see entity_cache_test_dependency_entity_info()
+ */
+function entity_cache_test_watchdog($log_entry) {
+ if ($log_entry['type'] == 'system' && $log_entry['message'] == '%module module installed.') {
+ $info = entity_get_info('entity_cache_test');
+ // Store the information in a system variable to analyze it later in the
+ // test case.
+ variable_set('entity_cache_test', $info);
+ }
+}
diff --git a/core/modules/entity/tests/entity_cache_test_dependency.info b/core/modules/entity/tests/entity_cache_test_dependency.info
new file mode 100644
index 000000000000..17d551c1a608
--- /dev/null
+++ b/core/modules/entity/tests/entity_cache_test_dependency.info
@@ -0,0 +1,6 @@
+name = "Entity cache test dependency"
+description = "Support dependency module for testing entity cache."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/entity/tests/entity_cache_test_dependency.module b/core/modules/entity/tests/entity_cache_test_dependency.module
new file mode 100644
index 000000000000..73a11495f58f
--- /dev/null
+++ b/core/modules/entity/tests/entity_cache_test_dependency.module
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Helper module for entity cache tests.
+ */
+
+/**
+ * Implements hook_entity_info().
+ */
+function entity_cache_test_dependency_entity_info() {
+ return array(
+ 'entity_cache_test' => array(
+ 'label' => 'Entity Cache Test',
+ ),
+ );
+}
diff --git a/core/modules/entity/tests/entity_crud_hook_test.info b/core/modules/entity/tests/entity_crud_hook_test.info
new file mode 100644
index 000000000000..28ce1b5e35d0
--- /dev/null
+++ b/core/modules/entity/tests/entity_crud_hook_test.info
@@ -0,0 +1,6 @@
+name = "Entity CRUD Hooks Test"
+description = "Support module for CRUD hook tests."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/entity/tests/entity_crud_hook_test.module b/core/modules/entity/tests/entity_crud_hook_test.module
new file mode 100644
index 000000000000..873a162ced9c
--- /dev/null
+++ b/core/modules/entity/tests/entity_crud_hook_test.module
@@ -0,0 +1,266 @@
+<?php
+
+//
+// Presave hooks
+//
+
+/**
+ * Implements hook_entity_presave().
+ */
+function entity_crud_hook_test_entity_presave($entity, $type) {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called for type ' . $type);
+}
+
+/**
+ * Implements hook_comment_presave().
+ */
+function entity_crud_hook_test_comment_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_file_presave().
+ */
+function entity_crud_hook_test_file_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_node_presave().
+ */
+function entity_crud_hook_test_node_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_term_presave().
+ */
+function entity_crud_hook_test_taxonomy_term_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_presave().
+ */
+function entity_crud_hook_test_taxonomy_vocabulary_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_user_presave().
+ */
+function entity_crud_hook_test_user_presave() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+//
+// Insert hooks
+//
+
+/**
+ * Implements hook_entity_insert().
+ */
+function entity_crud_hook_test_entity_insert($entity, $type) {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called for type ' . $type);
+}
+
+/**
+ * Implements hook_comment_insert().
+ */
+function entity_crud_hook_test_comment_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_file_insert().
+ */
+function entity_crud_hook_test_file_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function entity_crud_hook_test_node_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_term_insert().
+ */
+function entity_crud_hook_test_taxonomy_term_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_insert().
+ */
+function entity_crud_hook_test_taxonomy_vocabulary_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_user_insert().
+ */
+function entity_crud_hook_test_user_insert() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+//
+// Load hooks
+//
+
+/**
+ * Implements hook_entity_load().
+ */
+function entity_crud_hook_test_entity_load(array $entities, $type) {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called for type ' . $type);
+}
+
+/**
+ * Implements hook_comment_load().
+ */
+function entity_crud_hook_test_comment_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_file_load().
+ */
+function entity_crud_hook_test_file_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function entity_crud_hook_test_node_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_term_load().
+ */
+function entity_crud_hook_test_taxonomy_term_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_load().
+ */
+function entity_crud_hook_test_taxonomy_vocabulary_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_user_load().
+ */
+function entity_crud_hook_test_user_load() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+//
+// Update hooks
+//
+
+/**
+ * Implements hook_entity_update().
+ */
+function entity_crud_hook_test_entity_update($entity, $type) {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called for type ' . $type);
+}
+
+/**
+ * Implements hook_comment_update().
+ */
+function entity_crud_hook_test_comment_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_file_update().
+ */
+function entity_crud_hook_test_file_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function entity_crud_hook_test_node_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_term_update().
+ */
+function entity_crud_hook_test_taxonomy_term_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_update().
+ */
+function entity_crud_hook_test_taxonomy_vocabulary_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_user_update().
+ */
+function entity_crud_hook_test_user_update() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+//
+// Delete hooks
+//
+
+/**
+ * Implements hook_entity_delete().
+ */
+function entity_crud_hook_test_entity_delete($entity, $type) {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called for type ' . $type);
+}
+
+/**
+ * Implements hook_comment_delete().
+ */
+function entity_crud_hook_test_comment_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_file_delete().
+ */
+function entity_crud_hook_test_file_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function entity_crud_hook_test_node_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function entity_crud_hook_test_taxonomy_term_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_delete().
+ */
+function entity_crud_hook_test_taxonomy_vocabulary_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function entity_crud_hook_test_user_delete() {
+ $_SESSION['entity_crud_hook_test'][] = (__FUNCTION__ . ' called');
+}
diff --git a/core/modules/entity/tests/entity_crud_hook_test.test b/core/modules/entity/tests/entity_crud_hook_test.test
new file mode 100644
index 000000000000..3f18fc855546
--- /dev/null
+++ b/core/modules/entity/tests/entity_crud_hook_test.test
@@ -0,0 +1,332 @@
+<?php
+
+/**
+ * Test invocation of hooks when inserting, loading, updating or deleting an
+ * entity. Tested hooks are:
+ * - hook_entity_insert()
+ * - hook_entity_load()
+ * - hook_entity_update()
+ * - hook_entity_delete()
+ * As well as all type-specific hooks, like hook_node_insert(),
+ * hook_comment_update(), etc.
+ */
+class EntityCrudHookTestCase extends DrupalWebTestCase {
+
+ protected $ids = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Entity CRUD hooks',
+ 'description' => 'Tests the invocation of hooks when inserting, loading, updating or deleting an entity.',
+ 'group' => 'Entity API',
+ );
+ }
+
+ public function setUp() {
+ parent::setUp('entity_crud_hook_test', 'taxonomy', 'comment');
+ }
+
+ /**
+ * Pass if the message $text was set by one of the CRUD hooks in
+ * entity_crud_hook_test.module, i.e., if the $text is an element of
+ * $_SESSION['entity_crud_hook_test'].
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertHookMessage($text, $message = NULL, $group = 'Other') {
+ if (!isset($message)) {
+ $message = $text;
+ }
+ return $this->assertTrue(array_search($text, $_SESSION['entity_crud_hook_test']) !== FALSE, $message, $group);
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on comments.
+ */
+ public function testCommentHooks() {
+ $node = (object) array(
+ 'uid' => 1,
+ 'type' => 'article',
+ 'title' => 'Test node',
+ 'status' => 1,
+ 'comment' => 2,
+ 'promote' => 0,
+ 'sticky' => 0,
+ 'language' => LANGUAGE_NONE,
+ 'created' => REQUEST_TIME,
+ 'changed' => REQUEST_TIME,
+ );
+ node_save($node);
+ $nid = $node->nid;
+
+ $comment = (object) array(
+ 'cid' => NULL,
+ 'pid' => 0,
+ 'nid' => $nid,
+ 'uid' => 1,
+ 'subject' => 'Test comment',
+ 'created' => REQUEST_TIME,
+ 'changed' => REQUEST_TIME,
+ 'status' => 1,
+ 'language' => LANGUAGE_NONE,
+ );
+ $_SESSION['entity_crud_hook_test'] = array();
+ comment_save($comment);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $comment = comment_load($comment->cid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $comment->subject = 'New subject';
+ comment_save($comment);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ comment_delete($comment->cid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type comment');
+ $this->assertHookMessage('entity_crud_hook_test_comment_delete called');
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on files.
+ */
+ public function testFileHooks() {
+ $url = 'public://entity_crud_hook_test.file';
+ file_put_contents($url, 'Test test test');
+ $file = (object) array(
+ 'fid' => NULL,
+ 'uid' => 1,
+ 'filename' => 'entity_crud_hook_test.file',
+ 'uri' => $url,
+ 'filemime' => 'text/plain',
+ 'filesize' => filesize($url),
+ 'status' => 1,
+ 'timestamp' => REQUEST_TIME,
+ );
+ $_SESSION['entity_crud_hook_test'] = array();
+ file_save($file);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $file = file_load($file->fid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $file->filename = 'new.entity_crud_hook_test.file';
+ file_save($file);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ file_delete($file);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type file');
+ $this->assertHookMessage('entity_crud_hook_test_file_delete called');
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on nodes.
+ */
+ public function testNodeHooks() {
+ $node = (object) array(
+ 'uid' => 1,
+ 'type' => 'article',
+ 'title' => 'Test node',
+ 'status' => 1,
+ 'comment' => 2,
+ 'promote' => 0,
+ 'sticky' => 0,
+ 'language' => LANGUAGE_NONE,
+ 'created' => REQUEST_TIME,
+ 'changed' => REQUEST_TIME,
+ );
+ $_SESSION['entity_crud_hook_test'] = array();
+ node_save($node);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $node = node_load($node->nid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $node->title = 'New title';
+ node_save($node);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ node_delete($node->nid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type node');
+ $this->assertHookMessage('entity_crud_hook_test_node_delete called');
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on taxonomy terms.
+ */
+ public function testTaxonomyTermHooks() {
+ $vocabulary = (object) array(
+ 'name' => 'Test vocabulary',
+ 'machine_name' => 'test',
+ 'description' => NULL,
+ 'module' => 'entity_crud_hook_test',
+ );
+ taxonomy_vocabulary_save($vocabulary);
+
+ $term = (object) array(
+ 'vid' => $vocabulary->vid,
+ 'name' => 'Test term',
+ 'description' => NULL,
+ 'format' => 1,
+ );
+ $_SESSION['entity_crud_hook_test'] = array();
+ taxonomy_term_save($term);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $term = taxonomy_term_load($term->tid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $term->name = 'New name';
+ taxonomy_term_save($term);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ taxonomy_term_delete($term->tid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type taxonomy_term');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_term_delete called');
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on taxonomy vocabularies.
+ */
+ public function testTaxonomyVocabularyHooks() {
+ $vocabulary = (object) array(
+ 'name' => 'Test vocabulary',
+ 'machine_name' => 'test',
+ 'description' => NULL,
+ 'module' => 'entity_crud_hook_test',
+ );
+ $_SESSION['entity_crud_hook_test'] = array();
+ taxonomy_vocabulary_save($vocabulary);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $vocabulary = taxonomy_vocabulary_load($vocabulary->vid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $vocabulary->name = 'New name';
+ taxonomy_vocabulary_save($vocabulary);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ taxonomy_vocabulary_delete($vocabulary->vid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type taxonomy_vocabulary');
+ $this->assertHookMessage('entity_crud_hook_test_taxonomy_vocabulary_delete called');
+ }
+
+ /**
+ * Test hook invocations for CRUD operations on users.
+ */
+ public function testUserHooks() {
+ $edit = array(
+ 'name' => 'Test user',
+ 'mail' => 'test@example.com',
+ 'created' => REQUEST_TIME,
+ 'status' => 1,
+ 'language' => 'en',
+ );
+ $account = (object) $edit;
+ $_SESSION['entity_crud_hook_test'] = array();
+ $account = user_save($account, $edit);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_insert called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_insert called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $account = user_load($account->uid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_load called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_load called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ $edit['name'] = 'New name';
+ $account = user_save($account, $edit);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_presave called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_presave called');
+ $this->assertHookMessage('entity_crud_hook_test_entity_update called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_update called');
+
+ $_SESSION['entity_crud_hook_test'] = array();
+ user_delete($account->uid);
+
+ $this->assertHookMessage('entity_crud_hook_test_entity_delete called for type user');
+ $this->assertHookMessage('entity_crud_hook_test_user_delete called');
+ }
+
+}
diff --git a/core/modules/entity/tests/entity_query.test b/core/modules/entity/tests/entity_query.test
new file mode 100644
index 000000000000..49cf7b8c0de6
--- /dev/null
+++ b/core/modules/entity/tests/entity_query.test
@@ -0,0 +1,1537 @@
+<?php
+
+/**
+ * @file
+ * Unit test file for the entity API.
+ */
+
+/**
+ * Tests EntityFieldQuery.
+ */
+class EntityFieldQueryTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Entity query',
+ 'description' => 'Test the EntityFieldQuery class.',
+ 'group' => 'Entity API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('field_test'));
+
+ field_attach_create_bundle('test_entity_bundle_key', 'bundle1');
+ field_attach_create_bundle('test_entity_bundle_key', 'bundle2');
+ field_attach_create_bundle('test_entity', 'test_bundles');
+ field_attach_create_bundle('test_entity_bundle', 'test_entity_bundle');
+
+ $instances = array();
+ $this->fields = array();
+ $this->field_names[0] = $field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $field = array('field_name' => $field_name, 'type' => 'test_field', 'cardinality' => 4);
+ $field = field_create_field($field);
+ $this->fields[0] = $field;
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => '',
+ 'bundle' => '',
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ 'settings' => array(
+ 'test_instance_setting' => $this->randomName(),
+ ),
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'label' => 'Test Field',
+ 'settings' => array(
+ 'test_widget_setting' => $this->randomName(),
+ )
+ )
+ );
+
+ $instances[0] = $instance;
+
+ // Add an instance to that bundle.
+ $instances[0]['bundle'] = 'bundle1';
+ $instances[0]['entity_type'] = 'test_entity_bundle_key';
+ field_create_instance($instances[0]);
+ $instances[0]['bundle'] = 'bundle2';
+ field_create_instance($instances[0]);
+ $instances[0]['bundle'] = $instances[0]['entity_type'] = 'test_entity_bundle';
+ field_create_instance($instances[0]);
+
+ $this->field_names[1] = $field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $field = array('field_name' => $field_name, 'type' => 'shape', 'cardinality' => 4);
+ $field = field_create_field($field);
+ $this->fields[1] = $field;
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => '',
+ 'bundle' => '',
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ 'settings' => array(
+ 'test_instance_setting' => $this->randomName(),
+ ),
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'label' => 'Test Field',
+ 'settings' => array(
+ 'test_widget_setting' => $this->randomName(),
+ )
+ )
+ );
+
+ $instances[1] = $instance;
+
+ // Add a field instance to the bundles.
+ $instances[1]['bundle'] = 'bundle1';
+ $instances[1]['entity_type'] = 'test_entity_bundle_key';
+ field_create_instance($instances[1]);
+ $instances[1]['bundle'] = $instances[1]['entity_type'] = 'test_entity_bundle';
+ field_create_instance($instances[1]);
+
+ $this->instances = $instances;
+ // Write entity base table if there is one.
+ $entities = array();
+
+ // Create entities which have a 'bundle key' defined.
+ for ($i = 1; $i < 7; $i++) {
+ $entity = new stdClass();
+ $entity->ftid = $i;
+ $entity->fttype = ($i < 5) ? 'bundle1' : 'bundle2';
+
+ $entity->{$this->field_names[0]}[LANGUAGE_NONE][0]['value'] = $i;
+ drupal_write_record('test_entity_bundle_key', $entity);
+ field_attach_insert('test_entity_bundle_key', $entity);
+ }
+
+ $entity = new stdClass();
+ $entity->ftid = 5;
+ $entity->fttype = 'test_entity_bundle';
+ $entity->{$this->field_names[1]}[LANGUAGE_NONE][0]['shape'] = 'square';
+ $entity->{$this->field_names[1]}[LANGUAGE_NONE][0]['color'] = 'red';
+ $entity->{$this->field_names[1]}[LANGUAGE_NONE][1]['shape'] = 'circle';
+ $entity->{$this->field_names[1]}[LANGUAGE_NONE][1]['color'] = 'blue';
+ drupal_write_record('test_entity_bundle', $entity);
+ field_attach_insert('test_entity_bundle', $entity);
+
+ $instances[2] = $instance;
+ $instances[2]['bundle'] = 'test_bundle';
+ $instances[2]['field_name'] = $this->field_names[0];
+ $instances[2]['entity_type'] = 'test_entity';
+ field_create_instance($instances[2]);
+
+ // Create entities with support for revisions.
+ for ($i = 1; $i < 5; $i++) {
+ $entity = new stdClass();
+ $entity->ftid = $i;
+ $entity->ftvid = $i;
+ $entity->fttype = 'test_bundle';
+ $entity->{$this->field_names[0]}[LANGUAGE_NONE][0]['value'] = $i;
+
+ drupal_write_record('test_entity', $entity);
+ field_attach_insert('test_entity', $entity);
+ drupal_write_record('test_entity_revision', $entity);
+ }
+
+ // Add two revisions to an entity.
+ for ($i = 100; $i < 102; $i++) {
+ $entity = new stdClass();
+ $entity->ftid = 4;
+ $entity->ftvid = $i;
+ $entity->fttype = 'test_bundle';
+ $entity->{$this->field_names[0]}[LANGUAGE_NONE][0]['value'] = $i;
+
+ drupal_write_record('test_entity', $entity, 'ftid');
+ drupal_write_record('test_entity_revision', $entity);
+
+ db_update('test_entity')
+ ->fields(array('ftvid' => $entity->ftvid))
+ ->condition('ftid', $entity->ftid)
+ ->execute();
+
+ field_attach_update('test_entity', $entity);
+ }
+ }
+
+ /**
+ * Tests EntityFieldQuery.
+ */
+ function testEntityFieldQuery() {
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle')
+ ->entityCondition('entity_id', '5');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 5),
+ ), t('Test query on an entity type with a generated bundle.'));
+
+ // Test entity_type condition.
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', 'test_entity_bundle_key');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test entity entity_type condition.'));
+
+ // Test entity_id condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityCondition('entity_id', '3');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ ), t('Test entity entity_id condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', '3');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ ), t('Test entity entity_id condition and entity_id property condition.'));
+
+ // Test bundle condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityCondition('bundle', 'bundle1');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test entity bundle condition: bundle1.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityCondition('bundle', 'bundle2');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test entity bundle condition: bundle2.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('fttype', 'bundle2');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test entity bundle condition and bundle property condition.'));
+
+ // Test revision_id condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->entityCondition('revision_id', '3');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 3),
+ ), t('Test entity revision_id condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->propertyCondition('ftvid', '3');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 3),
+ ), t('Test entity revision_id condition and revision_id property condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->fieldCondition($this->fields[0], 'value', 100, '>=')
+ ->age(FIELD_LOAD_REVISION);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 100),
+ array('test_entity', 101),
+ ), t('Test revision age.'));
+
+ // Test that fields attached to the non-revision supporting entity
+ // 'test_entity_bundle_key' are reachable in FIELD_LOAD_REVISION.
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($this->fields[0], 'value', 100, '<')
+ ->age(FIELD_LOAD_REVISION);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test that fields are reachable from FIELD_LOAD_REVISION even for non-revision entities.'));
+
+ // Test entity sort by entity_id.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('entity_id', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort entity entity_id in ascending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('entity_id', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort entity entity_id in descending order.'), TRUE);
+
+ // Test entity sort by entity_id, with a field condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->entityOrderBy('entity_id', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort entity entity_id in ascending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort entity entity_id property in descending order, with a field condition.'), TRUE);
+
+ // Test property sort by entity id.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort entity entity_id property in ascending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort entity entity_id property in descending order.'), TRUE);
+
+ // Test property sort by entity id, with a field condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort entity entity_id property in ascending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort entity entity_id property in descending order, with a field condition.'), TRUE);
+
+ // Test entity sort by bundle.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('bundle', 'ASC')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ ), t('Test sort entity bundle in ascending order, property in descending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('bundle', 'DESC')
+ ->propertyOrderBy('ftid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test sort entity bundle in descending order, property in ascending order.'), TRUE);
+
+ // Test entity sort by bundle, with a field condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->entityOrderBy('bundle', 'ASC')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ ), t('Test sort entity bundle in ascending order, property in descending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->entityOrderBy('bundle', 'DESC')
+ ->propertyOrderBy('ftid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test sort entity bundle in descending order, property in ascending order, with a field condition.'), TRUE);
+
+ // Test entity sort by bundle, field.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('bundle', 'ASC')
+ ->fieldOrderBy($this->fields[0], 'value', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ ), t('Test sort entity bundle in ascending order, field in descending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityOrderBy('bundle', 'DESC')
+ ->fieldOrderBy($this->fields[0], 'value', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test sort entity bundle in descending order, field in ascending order.'), TRUE);
+
+ // Test entity sort by revision_id.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->entityOrderBy('revision_id', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test sort entity revision_id in ascending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->entityOrderBy('revision_id', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 4),
+ array('test_entity', 3),
+ array('test_entity', 2),
+ array('test_entity', 1),
+ ), t('Test sort entity revision_id in descending order.'), TRUE);
+
+ // Test entity sort by revision_id, with a field condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->entityOrderBy('revision_id', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test sort entity revision_id in ascending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->entityOrderBy('revision_id', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 4),
+ array('test_entity', 3),
+ array('test_entity', 2),
+ array('test_entity', 1),
+ ), t('Test sort entity revision_id in descending order, with a field condition.'), TRUE);
+
+ // Test property sort by revision_id.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->propertyOrderBy('ftvid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test sort entity revision_id property in ascending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->propertyOrderBy('ftvid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 4),
+ array('test_entity', 3),
+ array('test_entity', 2),
+ array('test_entity', 1),
+ ), t('Test sort entity revision_id property in descending order.'), TRUE);
+
+ // Test property sort by revision_id, with a field condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftvid', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test sort entity revision_id property in ascending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftvid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 4),
+ array('test_entity', 3),
+ array('test_entity', 2),
+ array('test_entity', 1),
+ ), t('Test sort entity revision_id property in descending order, with a field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldOrderBy($this->fields[0], 'value', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort field in ascending order without field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldOrderBy($this->fields[0], 'value', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort field in descending order without field condition.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->fieldOrderBy($this->fields[0], 'value', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test sort field in ascending order.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->fieldOrderBy($this->fields[0], 'value', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test sort field in descending order.'), TRUE);
+
+ // Test "in" operation with entity entity_type condition and entity_id
+ // property condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', array(1, 3, 4), 'IN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test "in" operation with entity entity_type condition and entity_id property condition.'));
+
+ // Test "in" operation with entity entity_type condition and entity_id
+ // property condition. Sort in descending order by entity_id.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', array(1, 3, 4), 'IN')
+ ->propertyOrderBy('ftid', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 1),
+ ), t('Test "in" operation with entity entity_type condition and entity_id property condition. Sort entity_id in descending order.'), TRUE);
+
+ // Test query count
+ $query = new EntityFieldQuery();
+ $query_count = $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->count()
+ ->execute();
+ $this->assertEqual($query_count, 6, t('Test query count on entity condition.'));
+
+ $query = new EntityFieldQuery();
+ $query_count = $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', '1')
+ ->count()
+ ->execute();
+ $this->assertEqual($query_count, 1, t('Test query count on entity and property condition.'));
+
+ $query = new EntityFieldQuery();
+ $query_count = $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', '4', '>')
+ ->count()
+ ->execute();
+ $this->assertEqual($query_count, 2, t('Test query count on entity and property condition with operator.'));
+
+ $query = new EntityFieldQuery();
+ $query_count = $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 3, '=')
+ ->count()
+ ->execute();
+ $this->assertEqual($query_count, 1, t('Test query count on field condition.'));
+
+ // First, test without options.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('fttype', 'und', 'CONTAINS');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "contains" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[1], 'shape', 'uar', 'CONTAINS');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 5),
+ ), t('Test the "contains" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 1, '=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ ), t('Test the "equal to" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 3, '=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity', 3),
+ ), t('Test the "equal to" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 3, '<>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "not equal to" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 3, '<>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 4),
+ ), t('Test the "not equal to" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 2, '<');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ ), t('Test the "less than" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 2, '<');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity', 1),
+ ), t('Test the "less than" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 2, '<=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ ), t('Test the "less than or equal to" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 2, '<=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity', 1),
+ array('test_entity', 2),
+ ), t('Test the "less than or equal to" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 4, '>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "greater than" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 2, '>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test the "greater than" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 4, '>=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "greater than or equal to" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 3, '>=');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ ), t('Test the "greater than or equal to" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', array(3, 4), 'NOT IN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "not in" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', array(3, 4, 100, 101), 'NOT IN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 1),
+ array('test_entity', 2),
+ ), t('Test the "not in" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', array(3, 4), 'IN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test the "in" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', array(2, 3), 'IN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ ), t('Test the "in" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', array(1, 3), 'BETWEEN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ ), t('Test the "between" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', array(1, 3), 'BETWEEN');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity', 1),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ ), t('Test the "between" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('fttype', 'bun', 'STARTS_WITH');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test the "starts_with" operation on a property.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[1], 'shape', 'squ', 'STARTS_WITH');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 5),
+ ), t('Test the "starts_with" operation on a field.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 3);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity', 3),
+ ), t('Test omission of an operator with a single item.'));
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', array(2, 3));
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity', 2),
+ array('test_entity', 3),
+ ), t('Test omission of an operator with multiple items.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyCondition('ftid', 1, '>')
+ ->fieldCondition($this->fields[0], 'value', 4, '<');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ ), t('Test entity, property and field conditions.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->entityCondition('bundle', 'bundle', 'STARTS_WITH')
+ ->propertyCondition('ftid', 4)
+ ->fieldCondition($this->fields[0], 'value', 4);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ ), t('Test entity condition with "starts_with" operation, and property and field conditions.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->range(0, 2);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ ), t('Test limit on a property.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>=')
+ ->fieldOrderBy($this->fields[0], 'value', 'ASC')
+ ->range(0, 2);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ ), t('Test limit on a field.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->range(4, 6);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test offset on a property.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->fieldOrderBy($this->fields[0], 'value', 'ASC')
+ ->range(2, 4);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test offset on a field.'), TRUE);
+
+ for ($i = 6; $i < 10; $i++) {
+ $entity = new stdClass();
+ $entity->ftid = $i;
+ $entity->fttype = 'test_entity_bundle';
+ $entity->{$this->field_names[0]}[LANGUAGE_NONE][0]['value'] = $i - 5;
+ drupal_write_record('test_entity_bundle', $entity);
+ field_attach_insert('test_entity_bundle', $entity);
+ }
+
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', 2, '>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity', 3),
+ array('test_entity', 4),
+ array('test_entity_bundle', 8),
+ array('test_entity_bundle', 9),
+ ), t('Select a field across multiple entities.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($this->fields[1], 'shape', 'square')
+ ->fieldCondition($this->fields[1], 'color', 'blue');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 5),
+ ), t('Test without a delta group.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($this->fields[1], 'shape', 'square', '=', 'group')
+ ->fieldCondition($this->fields[1], 'color', 'blue', '=', 'group');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a delta group.'));
+
+ // Test query on a deleted field.
+ field_attach_delete_bundle('test_entity_bundle_key', 'bundle1');
+ field_attach_delete_bundle('test_entity', 'test_bundle');
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', '3');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 8),
+ ), t('Test query on a field after deleting field from some entities.'));
+
+ field_attach_delete_bundle('test_entity_bundle', 'test_entity_bundle');
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', '3');
+ $this->assertEntityFieldQuery($query, array(), t('Test query on a field after deleting field from all entities.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($this->fields[0], 'value', '3')
+ ->deleted(TRUE);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle', 8),
+ array('test_entity', 3),
+ ), t('Test query on a deleted field with deleted option set to TRUE.'));
+
+ $pass = FALSE;
+ $query = new EntityFieldQuery();
+ try {
+ $query->execute();
+ }
+ catch (EntityFieldQueryException $exception) {
+ $pass = ($exception->getMessage() == t('For this query an entity type must be specified.'));
+ }
+ $this->assertTrue($pass, t("Can't query the universe."));
+ }
+
+ /**
+ * Tests querying translatable fields.
+ */
+ function testEntityFieldQueryTranslatable() {
+
+ // Make a test field translatable AND cardinality one.
+ $this->fields[0]['translatable'] = TRUE;
+ $this->fields[0]['cardinality'] = 1;
+ field_update_field($this->fields[0]);
+ field_test_entity_info_translatable('test_entity', TRUE);
+ drupal_static_reset('field_available_languages');
+
+ // Create more items with different languages.
+ $entity = new stdClass();
+ $entity->ftid = 1;
+ $entity->ftvid = 1;
+ $entity->fttype = 'test_bundle';
+
+ // Set fields in two languages with one field value.
+ foreach (array(LANGUAGE_NONE, 'en') as $langcode) {
+ $entity->{$this->field_names[0]}[$langcode][0]['value'] = 1234;
+ }
+
+ field_attach_update('test_entity', $entity);
+
+ // Look up number of results when querying a single entity with multilingual
+ // field values.
+ $query = new EntityFieldQuery();
+ $query_count = $query
+ ->entityCondition('entity_type', 'test_entity')
+ ->entityCondition('bundle', 'test_bundle')
+ ->entityCondition('entity_id', '1')
+ ->fieldCondition($this->fields[0])
+ ->count()
+ ->execute();
+
+ $this->assertEqual($query_count, 1, t("Count on translatable cardinality one field is correct."));
+ }
+
+
+ /**
+ * Tests field meta conditions.
+ */
+ function testEntityFieldQueryMetaConditions() {
+ // Make a test field translatable.
+ $this->fields[0]['translatable'] = TRUE;
+ field_update_field($this->fields[0]);
+ field_test_entity_info_translatable('test_entity', TRUE);
+ drupal_static_reset('field_available_languages');
+
+ // Create more items with different languages.
+ $entity = new stdClass();
+ $entity->ftid = 1;
+ $entity->ftvid = 1;
+ $entity->fttype = 'test_bundle';
+ $j = 0;
+
+ foreach (array(LANGUAGE_NONE, 'en') as $langcode) {
+ for ($i = 0; $i < 4; $i++) {
+ $entity->{$this->field_names[0]}[$langcode][$i]['value'] = $i + $j;
+ }
+ $j += 4;
+ }
+
+ field_attach_update('test_entity', $entity);
+
+ // Test delta field meta condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldDeltaCondition($this->fields[0], 0, '>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ ), t('Test with a delta meta condition.'));
+
+ // Test language field meta condition.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldLanguageCondition($this->fields[0], LANGUAGE_NONE, '<>');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ ), t('Test with a language meta condition.'));
+
+ // Test delta grouping.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'group')
+ ->fieldDeltaCondition($this->fields[0], 1, '<', 'group');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ ), t('Test with a grouped delta meta condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'group')
+ ->fieldDeltaCondition($this->fields[0], 1, '>=', 'group');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a grouped delta meta condition (empty result set).'));
+
+ // Test language grouping.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', NULL, 'group')
+ ->fieldLanguageCondition($this->fields[0], 'en', '<>', NULL, 'group');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ ), t('Test with a grouped language meta condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', NULL, 'group')
+ ->fieldLanguageCondition($this->fields[0], LANGUAGE_NONE, '<>', NULL, 'group');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a grouped language meta condition (empty result set).'));
+
+ // Test delta and language grouping.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'delta', 'language')
+ ->fieldDeltaCondition($this->fields[0], 1, '<', 'delta', 'language')
+ ->fieldLanguageCondition($this->fields[0], 'en', '<>', 'delta', 'language');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity', 1),
+ ), t('Test with a grouped delta + language meta condition.'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'delta', 'language')
+ ->fieldDeltaCondition($this->fields[0], 1, '>=', 'delta', 'language')
+ ->fieldLanguageCondition($this->fields[0], 'en', '<>', 'delta', 'language');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a grouped delta + language meta condition (empty result set, delta condition unsatisifed).'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'delta', 'language')
+ ->fieldDeltaCondition($this->fields[0], 1, '<', 'delta', 'language')
+ ->fieldLanguageCondition($this->fields[0], LANGUAGE_NONE, '<>', 'delta', 'language');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a grouped delta + language meta condition (empty result set, language condition unsatisifed).'));
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity', '=')
+ ->fieldCondition($this->fields[0], 'value', 0, '=', 'delta', 'language')
+ ->fieldDeltaCondition($this->fields[0], 1, '>=', 'delta', 'language')
+ ->fieldLanguageCondition($this->fields[0], LANGUAGE_NONE, '<>', 'delta', 'language');
+ $this->assertEntityFieldQuery($query, array(), t('Test with a grouped delta + language meta condition (empty result set, both conditions unsatisifed).'));
+
+ // Test grouping with another field to ensure that grouping cache is reset
+ // properly.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle', '=')
+ ->fieldCondition($this->fields[1], 'shape', 'circle', '=', 'delta', 'language')
+ ->fieldCondition($this->fields[1], 'color', 'blue', '=', 'delta', 'language')
+ ->fieldDeltaCondition($this->fields[1], 1, '=', 'delta', 'language')
+ ->fieldLanguageCondition($this->fields[1], LANGUAGE_NONE, '=', 'delta', 'language');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle', 5),
+ ), t('Test grouping cache.'));
+ }
+
+ /**
+ * Tests the routing feature of EntityFieldQuery.
+ */
+ function testEntityFieldQueryRouting() {
+ // Entity-only query.
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', 'test_entity_bundle_key');
+ $this->assertIdentical($query->queryCallback(), array($query, 'propertyQuery'), t('Entity-only queries are handled by the propertyQuery handler.'));
+
+ // Field-only query.
+ $query = new EntityFieldQuery();
+ $query->fieldCondition($this->fields[0], 'value', '3');
+ $this->assertIdentical($query->queryCallback(), 'field_sql_storage_field_storage_query', t('Pure field queries are handled by the Field storage handler.'));
+
+ // Mixed entity and field query.
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', '3');
+ $this->assertIdentical($query->queryCallback(), 'field_sql_storage_field_storage_query', t('Mixed queries are handled by the Field storage handler.'));
+
+ // Overriding with $query->executeCallback.
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', 'test_entity_bundle_key');
+ $query->executeCallback = 'field_test_dummy_field_storage_query';
+ $this->assertEntityFieldQuery($query, array(
+ array('user', 1),
+ ), t('executeCallback can override the query handler.'));
+
+ // Overriding with $query->executeCallback via hook_entity_query_alter().
+ $query = new EntityFieldQuery();
+ $query->entityCondition('entity_type', 'test_entity_bundle_key');
+ // Add a flag that will be caught by field_test_entity_query_alter().
+ $query->alterMyExecuteCallbackPlease = TRUE;
+ $this->assertEntityFieldQuery($query, array(
+ array('user', 1),
+ ), t('executeCallback can override the query handler when set in a hook_entity_query_alter().'));
+
+ // Mixed-storage queries.
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($this->fields[0], 'value', '3')
+ ->fieldCondition($this->fields[1], 'shape', 'squ', 'STARTS_WITH');
+ // Alter the storage of the field.
+ $query->fields[1]['storage']['module'] = 'dummy_storage';
+ try {
+ $query->queryCallback();
+ }
+ catch (EntityFieldQueryException $exception) {
+ $pass = ($exception->getMessage() == t("Can't handle more than one field storage engine"));
+ }
+ $this->assertTrue($pass, t('Cannot query across field storage engines.'));
+ }
+
+ /**
+ * Tests the pager integration of EntityFieldQuery.
+ */
+ function testEntityFieldQueryPager() {
+ // Test pager in propertyQuery
+ $_GET['page'] = '0,1';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->pager(3, 0);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ ), t('Test pager integration in propertyQuery: page 1.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->pager(3, 1);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test pager integration in propertyQuery: page 2.'), TRUE);
+
+ // Test pager in field storage
+ $_GET['page'] = '0,1';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->pager(2, 0);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ ), t('Test pager integration in field storage: page 1.'), TRUE);
+
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->propertyOrderBy('ftid', 'ASC')
+ ->pager(2, 1);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test pager integration in field storage: page 2.'), TRUE);
+
+ unset($_GET['page']);
+ }
+
+ /**
+ * Tests the TableSort integration of EntityFieldQuery.
+ */
+ function testEntityFieldQueryTableSort() {
+ // Test TableSort in propertyQuery
+ $_GET['sort'] = 'asc';
+ $_GET['order'] = 'Id';
+ $header = array(
+ 'id' => array('data' => 'Id', 'type' => 'property', 'specifier' => 'ftid'),
+ 'type' => array('data' => 'Type', 'type' => 'entity', 'specifier' => 'bundle'),
+ );
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test TableSort by property: ftid ASC in propertyQuery.'), TRUE);
+
+ $_GET['sort'] = 'desc';
+ $_GET['order'] = 'Id';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test TableSort by property: ftid DESC in propertyQuery.'), TRUE);
+
+ $_GET['sort'] = 'asc';
+ $_GET['order'] = 'Type';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test TableSort by entity: bundle ASC in propertyQuery.'), TRUE);
+
+ $_GET['sort'] = 'desc';
+ $_GET['order'] = 'Type';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test TableSort by entity: bundle DESC in propertyQuery.'), TRUE);
+
+ // Test TableSort in field storage
+ $_GET['sort'] = 'asc';
+ $_GET['order'] = 'Id';
+ $header = array(
+ 'id' => array('data' => 'Id', 'type' => 'property', 'specifier' => 'ftid'),
+ 'type' => array('data' => 'Type', 'type' => 'entity', 'specifier' => 'bundle'),
+ 'field' => array('data' => 'Field', 'type' => 'field', 'specifier' => array('field' => $this->field_names[0], 'column' => 'value')),
+ );
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test TableSort by property: ftid ASC in field storage.'), TRUE);
+
+ $_GET['sort'] = 'desc';
+ $_GET['order'] = 'Id';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test TableSort by property: ftid DESC in field storage.'), TRUE);
+
+ $_GET['sort'] = 'asc';
+ $_GET['order'] = 'Type';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header)
+ ->entityOrderBy('entity_id', 'DESC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ ), t('Test TableSort by entity: bundle ASC in field storage.'), TRUE);
+
+ $_GET['sort'] = 'desc';
+ $_GET['order'] = 'Type';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header)
+ ->entityOrderBy('entity_id', 'ASC');
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ ), t('Test TableSort by entity: bundle DESC in field storage.'), TRUE);
+
+ $_GET['sort'] = 'asc';
+ $_GET['order'] = 'Field';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 1),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 6),
+ ), t('Test TableSort by field ASC.'), TRUE);
+
+ $_GET['sort'] = 'desc';
+ $_GET['order'] = 'Field';
+ $query = new EntityFieldQuery();
+ $query
+ ->entityCondition('entity_type', 'test_entity_bundle_key')
+ ->fieldCondition($this->fields[0], 'value', 0, '>')
+ ->tableSort($header);
+ $this->assertEntityFieldQuery($query, array(
+ array('test_entity_bundle_key', 6),
+ array('test_entity_bundle_key', 5),
+ array('test_entity_bundle_key', 4),
+ array('test_entity_bundle_key', 3),
+ array('test_entity_bundle_key', 2),
+ array('test_entity_bundle_key', 1),
+ ), t('Test TableSort by field DESC.'), TRUE);
+
+ unset($_GET['sort']);
+ unset($_GET['order']);
+ }
+
+ /**
+ * Fetches the results of an EntityFieldQuery and compares.
+ *
+ * @param $query
+ * An EntityFieldQuery to run.
+ * @param $intended_results
+ * A list of results, every entry is again a list, first being the entity
+ * type, the second being the entity_id.
+ * @param $message
+ * The message to be displayed as the result of this test.
+ * @param $ordered
+ * If FALSE then the result of EntityFieldQuery will match
+ * $intended_results even if the order is not the same. If TRUE then order
+ * should match too.
+ */
+ function assertEntityFieldQuery($query, $intended_results, $message, $ordered = FALSE) {
+ $results = array();
+ try {
+ foreach ($query->execute() as $entity_type => $entity_ids) {
+ foreach ($entity_ids as $entity_id => $stub_entity) {
+ $results[] = array($entity_type, $entity_id);
+ }
+ }
+ if (!isset($ordered) || !$ordered) {
+ sort($results);
+ sort($intended_results);
+ }
+ $this->assertEqual($results, $intended_results, $message);
+ }
+ catch (Exception $e) {
+ $this->fail('Exception thrown: '. $e->getMessage());
+ }
+ }
+}
diff --git a/core/modules/field/field.api.php b/core/modules/field/field.api.php
new file mode 100644
index 000000000000..74eae62ab50a
--- /dev/null
+++ b/core/modules/field/field.api.php
@@ -0,0 +1,2635 @@
+<?php
+
+/**
+ * @ingroup field_fieldable_type
+ * @{
+ */
+
+/**
+ * Exposes "pseudo-field" components on fieldable entities.
+ *
+ * Field UI's "Manage fields" and "Manage display" pages let users re-order
+ * fields, but also non-field components. For nodes, these include the title,
+ * poll choices, and other elements exposed by modules through hook_form() or
+ * hook_form_alter().
+ *
+ * Fieldable entities or modules that want to have their components supported
+ * should expose them using this hook. The user-defined settings (weight,
+ * visible) are automatically applied on rendered forms and displayed
+ * entities in a #pre_render callback added by field_attach_form() and
+ * field_attach_view().
+ *
+ * @see _field_extra_fields_pre_render()
+ * @see hook_field_extra_fields_alter()
+ *
+ * @return
+ * A nested array of 'pseudo-field' components. Each list is nested within
+ * the following keys: entity type, bundle name, context (either 'form' or
+ * 'display'). The keys are the name of the elements as appearing in the
+ * renderable array (either the entity form or the displayed entity). The
+ * value is an associative array:
+ * - label: The human readable name of the component.
+ * - description: A short description of the component contents.
+ * - weight: The default weight of the element.
+ */
+function hook_field_extra_fields() {
+ $extra['node']['poll'] = array(
+ 'form' => array(
+ 'choice_wrapper' => array(
+ 'label' => t('Poll choices'),
+ 'description' => t('Poll choices'),
+ 'weight' => -4,
+ ),
+ 'settings' => array(
+ 'label' => t('Poll settings'),
+ 'description' => t('Poll module settings'),
+ 'weight' => -3,
+ ),
+ ),
+ 'display' => array(
+ 'poll_view_voting' => array(
+ 'label' => t('Poll vote'),
+ 'description' => t('Poll vote'),
+ 'weight' => 0,
+ ),
+ 'poll_view_results' => array(
+ 'label' => t('Poll results'),
+ 'description' => t('Poll results'),
+ 'weight' => 0,
+ ),
+ )
+ );
+
+ return $extra;
+}
+
+/**
+ * Alter "pseudo-field" components on fieldable entities.
+ *
+ * @param $info
+ * The associative array of 'pseudo-field' components.
+ *
+ * @see hook_field_extra_fields()
+ */
+function hook_field_extra_fields_alter(&$info) {
+ // Force node title to always be at the top of the list by default.
+ foreach (node_type_get_types() as $bundle) {
+ if (isset($info['node'][$bundle->type]['title'])) {
+ $info['node'][$bundle->type]['title']['weight'] = -20;
+ }
+ }
+}
+
+/**
+ * @} End of "ingroup field_fieldable_type"
+ */
+
+/**
+ * @defgroup field_types Field Types API
+ * @{
+ * Define field types, widget types, display formatter types, storage types.
+ *
+ * The bulk of the Field Types API are related to field types. A field type
+ * represents a particular type of data (integer, string, date, etc.) that
+ * can be attached to a fieldable entity. hook_field_info() defines the basic
+ * properties of a field type, and a variety of other field hooks are called by
+ * the Field Attach API to perform field-type-specific actions.
+ *
+ * @see hook_field_info()
+ * @see hook_field_info_alter()
+ * @see hook_field_schema()
+ * @see hook_field_load()
+ * @see hook_field_validate()
+ * @see hook_field_presave()
+ * @see hook_field_insert()
+ * @see hook_field_update()
+ * @see hook_field_delete()
+ * @see hook_field_delete_revision()
+ * @see hook_field_prepare_view()
+ * @see hook_field_is_empty()
+ *
+ * The Field Types API also defines two kinds of pluggable handlers: widgets
+ * and formatters, which specify how the field appears in edit forms and in
+ * displayed entities. Widgets and formatters can be implemented by a field-type
+ * module for its own field types, or by a third-party module to extend the
+ * behavior of existing field types.
+ *
+ * @see hook_field_widget_info()
+ * @see hook_field_formatter_info()
+ *
+ * A third kind of pluggable handlers, storage backends, is defined by the
+ * @link field_storage Field Storage API @endlink.
+ */
+
+/**
+ * Define Field API field types.
+ *
+ * @return
+ * An array whose keys are field type names and whose values are arrays
+ * describing the field type, with the following key/value pairs:
+ * - label: The human-readable name of the field type.
+ * - description: A short description for the field type.
+ * - settings: An array whose keys are the names of the settings available
+ * for the field type, and whose values are the default values for those
+ * settings.
+ * - instance_settings: An array whose keys are the names of the settings
+ * available for instances of the field type, and whose values are the
+ * default values for those settings. Instance-level settings can have
+ * different values on each field instance, and thus allow greater
+ * flexibility than field-level settings. It is recommended to put settings
+ * at the instance level whenever possible. Notable exceptions: settings
+ * acting on the schema definition, or settings that Views needs to use
+ * across field instances (for example, the list of allowed values).
+ * - default_widget: The machine name of the default widget to be used by
+ * instances of this field type, when no widget is specified in the
+ * instance definition. This widget must be available whenever the field
+ * type is available (i.e. provided by the field type module, or by a module
+ * the field type module depends on).
+ * - default_formatter: The machine name of the default formatter to be used
+ * by instances of this field type, when no formatter is specified in the
+ * instance definition. This formatter must be available whenever the field
+ * type is available (i.e. provided by the field type module, or by a module
+ * the field type module depends on).
+ * - no_ui: (optional) A boolean specifying that users should not be allowed
+ * to create fields and instances of this field type through the UI. Such
+ * fields can only be created programmatically with field_create_field()
+ * and field_create_instance(). Defaults to FALSE.
+ *
+ * @see hook_field_info_alter()
+ */
+function hook_field_info() {
+ return array(
+ 'text' => array(
+ 'label' => t('Text'),
+ 'description' => t('This field stores varchar text in the database.'),
+ 'settings' => array('max_length' => 255),
+ 'instance_settings' => array('text_processing' => 0),
+ 'default_widget' => 'text_textfield',
+ 'default_formatter' => 'text_default',
+ ),
+ 'text_long' => array(
+ 'label' => t('Long text'),
+ 'description' => t('This field stores long text in the database.'),
+ 'settings' => array('max_length' => ''),
+ 'instance_settings' => array('text_processing' => 0),
+ 'default_widget' => 'text_textarea',
+ 'default_formatter' => 'text_default',
+ ),
+ 'text_with_summary' => array(
+ 'label' => t('Long text and summary'),
+ 'description' => t('This field stores long text in the database along with optional summary text.'),
+ 'settings' => array('max_length' => ''),
+ 'instance_settings' => array('text_processing' => 1, 'display_summary' => 0),
+ 'default_widget' => 'text_textarea_with_summary',
+ 'default_formatter' => 'text_summary_or_trimmed',
+ ),
+ );
+}
+
+/**
+ * Perform alterations on Field API field types.
+ *
+ * @param $info
+ * Array of information on field types exposed by hook_field_info()
+ * implementations.
+ */
+function hook_field_info_alter(&$info) {
+ // Add a setting to all field types.
+ foreach ($info as $field_type => $field_type_info) {
+ $info[$field_type]['settings'] += array(
+ 'mymodule_additional_setting' => 'default value',
+ );
+ }
+
+ // Change the default widget for fields of type 'foo'.
+ if (isset($info['foo'])) {
+ $info['foo']['default widget'] = 'mymodule_widget';
+ }
+}
+
+/**
+ * Define the Field API schema for a field structure.
+ *
+ * This hook MUST be defined in .install for it to be detected during
+ * installation and upgrade.
+ *
+ * @param $field
+ * A field structure.
+ *
+ * @return
+ * An associative array with the following keys:
+ * - columns: An array of Schema API column specifications, keyed by column
+ * name. This specifies what comprises a value for a given field. For
+ * example, a value for a number field is simply 'value', while a value for
+ * a formatted text field is the combination of 'value' and 'format'. It is
+ * recommended to avoid having the column definitions depend on field
+ * settings when possible. No assumptions should be made on how storage
+ * engines internally use the original column name to structure their
+ * storage.
+ * - indexes: (optional) An array of Schema API indexes definitions. Only
+ * columns that appear in the 'columns' array are allowed. Those indexes
+ * will be used as default indexes. Callers of field_create_field() can
+ * specify additional indexes, or, at their own risk, modify the default
+ * indexes specified by the field-type module. Some storage engines might
+ * not support indexes.
+ * - foreign keys: (optional) An array of Schema API foreign keys
+ * definitions.
+ */
+function hook_field_schema($field) {
+ if ($field['type'] == 'text_long') {
+ $columns = array(
+ 'value' => array(
+ 'type' => 'text',
+ 'size' => 'big',
+ 'not null' => FALSE,
+ ),
+ );
+ }
+ else {
+ $columns = array(
+ 'value' => array(
+ 'type' => 'varchar',
+ 'length' => $field['settings']['max_length'],
+ 'not null' => FALSE,
+ ),
+ );
+ }
+ $columns += array(
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ ),
+ );
+ return array(
+ 'columns' => $columns,
+ 'indexes' => array(
+ 'format' => array('format'),
+ ),
+ 'foreign keys' => array(
+ 'format' => array(
+ 'table' => 'filter_format',
+ 'columns' => array('format' => 'format'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Define custom load behavior for this module's field types.
+ *
+ * Unlike most other field hooks, this hook operates on multiple entities. The
+ * $entities, $instances and $items parameters are arrays keyed by entity ID.
+ * For performance reasons, information for all available entity should be
+ * loaded in a single query where possible.
+ *
+ * Note that the changes made to the field values get cached by the field cache
+ * for subsequent loads. You should never use this hook to load fieldable
+ * entities, since this is likely to cause infinite recursions when
+ * hook_field_load() is run on those as well. Use
+ * hook_field_formatter_prepare_view() instead.
+ *
+ * Make changes or additions to field values by altering the $items parameter by
+ * reference. There is no return value.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entities
+ * Array of entities being loaded, keyed by entity ID.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instances
+ * Array of instance structures for $field for each entity, keyed by entity
+ * ID.
+ * @param $langcode
+ * The language code associated with $items.
+ * @param $items
+ * Array of field values already loaded for the entities, keyed by entity ID.
+ * Store your changes in this parameter (passed by reference).
+ * @param $age
+ * FIELD_LOAD_CURRENT to load the most recent revision for all fields, or
+ * FIELD_LOAD_REVISION to load the version indicated by each entity.
+ */
+function hook_field_load($entity_type, $entities, $field, $instances, $langcode, &$items, $age) {
+ // Sample code from text.module: precompute sanitized strings so they are
+ // stored in the field cache.
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ // Only process items with a cacheable format, the rest will be handled
+ // by formatters if needed.
+ if (empty($instances[$id]['settings']['text_processing']) || filter_format_allowcache($item['format'])) {
+ $items[$id][$delta]['safe_value'] = isset($item['value']) ? _text_sanitize($instances[$id], $langcode, $item, 'value') : '';
+ if ($field['type'] == 'text_with_summary') {
+ $items[$id][$delta]['safe_summary'] = isset($item['summary']) ? _text_sanitize($instances[$id], $langcode, $item, 'summary') : '';
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Prepare field values prior to display.
+ *
+ * This hook is invoked before the field values are handed to formatters
+ * for display, and runs before the formatters' own
+ * hook_field_formatter_prepare_view().
+ *
+ * Unlike most other field hooks, this hook operates on multiple entities. The
+ * $entities, $instances and $items parameters are arrays keyed by entity ID.
+ * For performance reasons, information for all available entities should be
+ * loaded in a single query where possible.
+ *
+ * Make changes or additions to field values by altering the $items parameter by
+ * reference. There is no return value.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entities
+ * Array of entities being displayed, keyed by entity ID.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instances
+ * Array of instance structures for $field for each entity, keyed by entity
+ * ID.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * $entity->{$field['field_name']}, or an empty array if unset.
+ */
+function hook_field_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items) {
+ // Sample code from image.module: if there are no images specified at all,
+ // use the default image.
+ foreach ($entities as $id => $entity) {
+ if (empty($items[$id]) && $field['settings']['default_image']) {
+ if ($file = file_load($field['settings']['default_image'])) {
+ $items[$id][0] = (array) $file + array(
+ 'is_default' => TRUE,
+ 'alt' => '',
+ 'title' => '',
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Validate this module's field data.
+ *
+ * If there are validation problems, add to the $errors array (passed by
+ * reference). There is no return value.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ * @param $errors
+ * The array of errors (keyed by field name, language code, and delta) that
+ * have already been reported for the entity. The function should add its
+ * errors to this array. Each error is an associative array with the following
+ * keys and values:
+ * - error: An error code (should be a string prefixed with the module name).
+ * - message: The human readable message to be displayed.
+ */
+function hook_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ foreach ($items as $delta => $item) {
+ if (!empty($item['value'])) {
+ if (!empty($field['settings']['max_length']) && drupal_strlen($item['value']) > $field['settings']['max_length']) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'text_max_length',
+ 'message' => t('%name: the value may not be longer than %max characters.', array('%name' => $instance['label'], '%max' => $field['settings']['max_length'])),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Define custom presave behavior for this module's field types.
+ *
+ * Make changes or additions to field values by altering the $items parameter by
+ * reference. There is no return value.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ */
+function hook_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ if ($field['type'] == 'number_decimal') {
+ // Let PHP round the value to ensure consistent behavior across storage
+ // backends.
+ foreach ($items as $delta => $item) {
+ if (isset($item['value'])) {
+ $items[$delta]['value'] = round($item['value'], $field['settings']['scale']);
+ }
+ }
+ }
+}
+
+/**
+ * Define custom insert behavior for this module's field types.
+ *
+ * Invoked from field_attach_insert().
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ */
+function hook_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ if (variable_get('taxonomy_maintain_index_table', TRUE) && $field['storage']['type'] == 'field_sql_storage' && $entity_type == 'node' && $entity->status) {
+ $query = db_insert('taxonomy_index')->fields(array('nid', 'tid', 'sticky', 'created', ));
+ foreach ($items as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ ));
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Define custom update behavior for this module's field types.
+ *
+ * Invoked from field_attach_update().
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ */
+function hook_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ if (variable_get('taxonomy_maintain_index_table', TRUE) && $field['storage']['type'] == 'field_sql_storage' && $entity_type == 'node') {
+ $first_call = &drupal_static(__FUNCTION__, array());
+
+ // We don't maintain data for old revisions, so clear all previous values
+ // from the table. Since this hook runs once per field, per object, make
+ // sure we only wipe values once.
+ if (!isset($first_call[$entity->nid])) {
+ $first_call[$entity->nid] = FALSE;
+ db_delete('taxonomy_index')->condition('nid', $entity->nid)->execute();
+ }
+ // Only save data to the table if the node is published.
+ if ($entity->status) {
+ $query = db_insert('taxonomy_index')->fields(array('nid', 'tid', 'sticky', 'created'));
+ foreach ($items as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ ));
+ }
+ $query->execute();
+ }
+ }
+}
+
+/**
+ * Update the storage information for a field.
+ *
+ * This is invoked on the field's storage module from field_update_field(),
+ * before the new field information is saved to the database. The field storage
+ * module should update its storage tables to agree with the new field
+ * information. If there is a problem, the field storage module should throw an
+ * exception.
+ *
+ * @param $field
+ * The updated field structure to be saved.
+ * @param $prior_field
+ * The previously-saved field structure.
+ * @param $has_data
+ * TRUE if the field has data in storage currently.
+ */
+function hook_field_storage_update_field($field, $prior_field, $has_data) {
+ if (!$has_data) {
+ // There is no data. Re-create the tables completely.
+ $prior_schema = _field_sql_storage_schema($prior_field);
+ foreach ($prior_schema as $name => $table) {
+ db_drop_table($name, $table);
+ }
+ $schema = _field_sql_storage_schema($field);
+ foreach ($schema as $name => $table) {
+ db_create_table($name, $table);
+ }
+ }
+ else {
+ // There is data. See field_sql_storage_field_storage_update_field() for
+ // an example of what to do to modify the schema in place, preserving the
+ // old data as much as possible.
+ }
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Define custom delete behavior for this module's field types.
+ *
+ * This hook is invoked just before the data is deleted from field storage
+ * in field_attach_delete().
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ */
+function hook_field_delete($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ foreach ($items as $delta => $item) {
+ // For hook_file_references(), remember that this is being deleted.
+ $item['file_field_name'] = $field['field_name'];
+ // Pass in the ID of the object that is being removed so all references can
+ // be counted in hook_file_references().
+ $item['file_field_type'] = $entity_type;
+ $item['file_field_id'] = $id;
+ file_field_delete_file($item, $field);
+ }
+}
+
+/**
+ * Define custom revision delete behavior for this module's field types.
+ *
+ * This hook is invoked just before the data is deleted from field storage
+ * in field_attach_delete_revision(), and will only be called for fieldable
+ * types that are versioned.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ */
+function hook_field_delete_revision($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ foreach ($items as $delta => $item) {
+ // For hook_file_references, remember that this file is being deleted.
+ $item['file_field_name'] = $field['field_name'];
+ if (file_field_delete_file($item, $field)) {
+ $items[$delta] = NULL;
+ }
+ }
+}
+
+/**
+ * Define custom prepare_translation behavior for this module's field types.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ * @param $source_entity
+ * The source entity from which field values are being copied.
+ * @param $source_langcode
+ * The source language from which field values are being copied.
+ */
+function hook_field_prepare_translation($entity_type, $entity, $field, $instance, $langcode, &$items, $source_entity, $source_langcode) {
+ // If the translating user is not permitted to use the assigned text format,
+ // we must not expose the source values.
+ $field_name = $field['field_name'];
+ $formats = filter_formats();
+ $format_id = $source_entity->{$field_name}[$source_langcode][0]['format'];
+ if (!filter_access($formats[$format_id])) {
+ $items = array();
+ }
+}
+
+/**
+ * Define what constitutes an empty item for a field type.
+ *
+ * @param $item
+ * An item that may or may not be empty.
+ * @param $field
+ * The field to which $item belongs.
+ *
+ * @return
+ * TRUE if $field's type considers $item not to contain any data;
+ * FALSE otherwise.
+ */
+function hook_field_is_empty($item, $field) {
+ if (empty($item['value']) && (string) $item['value'] !== '0') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Expose Field API widget types.
+ *
+ * Widgets are Form API elements with additional processing capabilities.
+ * Widget hooks are typically called by the Field Attach API during the
+ * creation of the field form structure with field_attach_form().
+ *
+ * @return
+ * An array describing the widget types implemented by the module.
+ * The keys are widget type names. To avoid name clashes, widget type
+ * names should be prefixed with the name of the module that exposes them.
+ * The values are arrays describing the widget type, with the following
+ * key/value pairs:
+ * - label: The human-readable name of the widget type.
+ * - description: A short description for the widget type.
+ * - field types: An array of field types the widget supports.
+ * - settings: An array whose keys are the names of the settings available
+ * for the widget type, and whose values are the default values for those
+ * settings.
+ * - behaviors: (optional) An array describing behaviors of the widget, with
+ * the following elements:
+ * - multiple values: One of the following constants:
+ * - FIELD_BEHAVIOR_DEFAULT: (default) If the widget allows the input of
+ * one single field value (most common case). The widget will be
+ * repeated for each value input.
+ * - FIELD_BEHAVIOR_CUSTOM: If one single copy of the widget can receive
+ * several field values. Examples: checkboxes, multiple select,
+ * comma-separated textfield.
+ * - default value: One of the following constants:
+ * - FIELD_BEHAVIOR_DEFAULT: (default) If the widget accepts default
+ * values.
+ * - FIELD_BEHAVIOR_NONE: if the widget does not support default values.
+ *
+ * @see hook_field_widget_info_alter()
+ * @see hook_field_widget_form()
+ * @see hook_field_widget_form_alter()
+ * @see hook_field_widget_WIDGET_TYPE_form_alter()
+ * @see hook_field_widget_error()
+ */
+function hook_field_widget_info() {
+ return array(
+ 'text_textfield' => array(
+ 'label' => t('Text field'),
+ 'field types' => array('text'),
+ 'settings' => array('size' => 60),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_DEFAULT,
+ 'default value' => FIELD_BEHAVIOR_DEFAULT,
+ ),
+ ),
+ 'text_textarea' => array(
+ 'label' => t('Text area (multiple rows)'),
+ 'field types' => array('text_long'),
+ 'settings' => array('rows' => 5),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_DEFAULT,
+ 'default value' => FIELD_BEHAVIOR_DEFAULT,
+ ),
+ ),
+ 'text_textarea_with_summary' => array(
+ 'label' => t('Text area with a summary'),
+ 'field types' => array('text_with_summary'),
+ 'settings' => array('rows' => 20, 'summary_rows' => 5),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_DEFAULT,
+ 'default value' => FIELD_BEHAVIOR_DEFAULT,
+ ),
+ ),
+ );
+}
+
+/**
+ * Perform alterations on Field API widget types.
+ *
+ * @param $info
+ * Array of informations on widget types exposed by hook_field_widget_info()
+ * implementations.
+ */
+function hook_field_widget_info_alter(&$info) {
+ // Add a setting to a widget type.
+ $info['text_textfield']['settings'] += array(
+ 'mymodule_additional_setting' => 'default value',
+ );
+
+ // Let a new field type re-use an existing widget.
+ $info['options_select']['field types'][] = 'my_field_type';
+}
+
+/**
+ * Return the form for a single field widget.
+ *
+ * Field widget form elements should be based on the passed-in $element, which
+ * contains the base form element properties derived from the field
+ * configuration.
+ *
+ * Field API will set the weight, field name and delta values for each form
+ * element. If there are multiple values for this field, the Field API will
+ * invoke this hook as many times as needed.
+ *
+ * Note that, depending on the context in which the widget is being included
+ * (regular entity form, field configuration form, advanced search form...),
+ * the values for $field and $instance might be different from the "official"
+ * definitions returned by field_info_field() and field_info_instance().
+ * Examples: mono-value widget even if the field is multi-valued, non-required
+ * widget even if the field is 'required'...
+ *
+ * Therefore, the FAPI element callbacks (such as #process, #element_validate,
+ * #value_callback...) used by the widget cannot use the field_info_field()
+ * or field_info_instance() functions to retrieve the $field or $instance
+ * definitions they should operate on. The field_widget_field() and
+ * field_widget_instance() functions should be used instead to fetch the
+ * current working definitions from $form_state, where Field API stores them.
+ *
+ * Alternatively, hook_field_widget_form() can extract the needed specific
+ * properties from $field and $instance and set them as ad-hoc
+ * $element['#custom'] properties, for later use by its element callbacks.
+ *
+ * Other modules may alter the form element provided by this function using
+ * hook_field_widget_form_alter().
+ *
+ * @param $form
+ * The form structure where widgets are being attached to. This might be a
+ * full form structure, or a sub-element of a larger form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $field
+ * The field structure.
+ * @param $instance
+ * The field instance.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * Array of default values for this field.
+ * @param $delta
+ * The order of this item in the array of subelements (0, 1, 2, etc).
+ * @param $element
+ * A form element array containing basic properties for the widget:
+ * - #entity_type: The name of the entity the field is attached to.
+ * - #bundle: The name of the field bundle the field is contained in.
+ * - #field_name: The name of the field.
+ * - #language: The language the field is being edited in.
+ * - #field_parents: The 'parents' space for the field in the form. Most
+ * widgets can simply overlook this property. This identifies the
+ * location where the field values are placed within
+ * $form_state['values'], and is used to access processing information
+ * for the field through the field_form_get_state() and
+ * field_form_set_state() functions.
+ * - #columns: A list of field storage columns of the field.
+ * - #title: The sanitized element label for the field instance, ready for
+ * output.
+ * - #description: The sanitized element description for the field instance,
+ * ready for output.
+ * - #required: A Boolean indicating whether the element value is required;
+ * for required multiple value fields, only the first widget's values are
+ * required.
+ * - #delta: The order of this item in the array of subelements; see $delta
+ * above.
+ *
+ * @return
+ * The form elements for a single widget for this field.
+ *
+ * @see field_widget_field()
+ * @see field_widget_instance()
+ * @see hook_field_widget_form_alter()
+ * @see hook_field_widget_WIDGET_TYPE_form_alter()
+ */
+function hook_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ $element += array(
+ '#type' => $instance['widget']['type'],
+ '#default_value' => isset($items[$delta]) ? $items[$delta] : '',
+ );
+ return $element;
+}
+
+/**
+ * Alter forms for field widgets provided by other modules.
+ *
+ * @param $element
+ * The field widget form element as constructed by hook_field_widget_form().
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $context
+ * An associative array containing the following key-value pairs, matching the
+ * arguments received by hook_field_widget_form():
+ * - "form": The form structure where widgets are being attached to. This
+ * might be a full form structure, or a sub-element of a larger form.
+ * - "field": The field structure.
+ * - "instance": The field instance structure.
+ * - "langcode": The language associated with $items.
+ * - "items": Array of default values for this field.
+ * - "delta": The order of this item in the array of subelements (0, 1, 2,
+ * etc).
+ *
+ * @see hook_field_widget_form()
+ * @see hook_field_widget_WIDGET_TYPE_form_alter
+ */
+function hook_field_widget_form_alter(&$element, &$form_state, $context) {
+ // Add a css class to widget form elements for all fields of type mytype.
+ if ($context['field']['type'] == 'mytype') {
+ // Be sure not to overwrite existing attributes.
+ $element['#attributes']['class'][] = 'myclass';
+ }
+}
+
+/**
+ * Alter widget forms for a specific widget provided by another module.
+ *
+ * Modules can implement hook_field_widget_WIDGET_TYPE_form_alter() to modify a
+ * specific widget form, rather than using hook_field_widget_form_alter() and
+ * checking the widget type.
+ *
+ * @param $element
+ * The field widget form element as constructed by hook_field_widget_form().
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $context
+ * An associative array containing the following key-value pairs, matching the
+ * arguments received by hook_field_widget_form():
+ * - "form": The form structure where widgets are being attached to. This
+ * might be a full form structure, or a sub-element of a larger form.
+ * - "field": The field structure.
+ * - "instance": The field instance structure.
+ * - "langcode": The language associated with $items.
+ * - "items": Array of default values for this field.
+ * - "delta": The order of this item in the array of subelements (0, 1, 2,
+ * etc).
+ *
+ * @see hook_field_widget_form()
+ * @see hook_field_widget_form_alter()
+ */
+function hook_field_widget_WIDGET_TYPE_form_alter(&$element, &$form_state, $context) {
+ // Code here will only act on widgets of type WIDGET_TYPE. For example,
+ // hook_field_widget_mymodule_autocomplete_form_alter() will only act on
+ // widgets of type 'mymodule_autocomplete'.
+ $element['#autocomplete_path'] = 'mymodule/autocomplete_path';
+}
+
+/**
+ * Flag a field-level validation error.
+ *
+ * @param $element
+ * An array containing the form element for the widget. The error needs to be
+ * flagged on the right sub-element, according to the widget's internal
+ * structure.
+ * @param $error
+ * An associative array with the following key-value pairs, as returned by
+ * hook_field_validate():
+ * - error: the error code. Complex widgets might need to report different
+ * errors to different form elements inside the widget.
+ * - message: the human readable message to be displayed.
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-element of a larger form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ */
+function hook_field_widget_error($element, $error, $form, &$form_state) {
+ form_error($element['value'], $error['message']);
+}
+
+/**
+ * Expose Field API formatter types.
+ *
+ * Formatters handle the display of field values. Formatter hooks are typically
+ * called by the Field Attach API field_attach_prepare_view() and
+ * field_attach_view() functions.
+ *
+ * @return
+ * An array describing the formatter types implemented by the module.
+ * The keys are formatter type names. To avoid name clashes, formatter type
+ * names should be prefixed with the name of the module that exposes them.
+ * The values are arrays describing the formatter type, with the following
+ * key/value pairs:
+ * - label: The human-readable name of the formatter type.
+ * - description: A short description for the formatter type.
+ * - field types: An array of field types the formatter supports.
+ * - settings: An array whose keys are the names of the settings available
+ * for the formatter type, and whose values are the default values for
+ * those settings.
+ *
+ * @see hook_field_formatter_info_alter()
+ * @see hook_field_formatter_view()
+ * @see hook_field_formatter_prepare_view()
+ */
+function hook_field_formatter_info() {
+ return array(
+ 'text_default' => array(
+ 'label' => t('Default'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ ),
+ 'text_plain' => array(
+ 'label' => t('Plain text'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ ),
+
+ // The text_trimmed formatter displays the trimmed version of the
+ // full element of the field. It is intended to be used with text
+ // and text_long fields. It also works with text_with_summary
+ // fields though the text_summary_or_trimmed formatter makes more
+ // sense for that field type.
+ 'text_trimmed' => array(
+ 'label' => t('Trimmed'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ ),
+
+ // The 'summary or trimmed' field formatter for text_with_summary
+ // fields displays returns the summary element of the field or, if
+ // the summary is empty, the trimmed version of the full element
+ // of the field.
+ 'text_summary_or_trimmed' => array(
+ 'label' => t('Summary or trimmed'),
+ 'field types' => array('text_with_summary'),
+ ),
+ );
+}
+
+/**
+ * Perform alterations on Field API formatter types.
+ *
+ * @param $info
+ * Array of informations on formatter types exposed by
+ * hook_field_field_formatter_info() implementations.
+ */
+function hook_field_formatter_info_alter(&$info) {
+ // Add a setting to a formatter type.
+ $info['text_default']['settings'] += array(
+ 'mymodule_additional_setting' => 'default value',
+ );
+
+ // Let a new field type re-use an existing formatter.
+ $info['text_default']['field types'][] = 'my_field_type';
+}
+
+/**
+ * Allow formatters to load information for field values being displayed.
+ *
+ * This should be used when a formatter needs to load additional information
+ * from the database in order to render a field, for example a reference field
+ * which displays properties of the referenced entities such as name or type.
+ *
+ * This hook is called after the field type's own hook_field_prepare_view().
+ *
+ * Unlike most other field hooks, this hook operates on multiple entities. The
+ * $entities, $instances and $items parameters are arrays keyed by entity ID.
+ * For performance reasons, information for all available entities should be
+ * loaded in a single query where possible.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entities
+ * Array of entities being displayed, keyed by entity ID.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instances
+ * Array of instance structures for $field for each entity, keyed by entity
+ * ID.
+ * @param $langcode
+ * The language the field values are to be shown in. If no language is
+ * provided the current language is used.
+ * @param $items
+ * Array of field values for the entities, keyed by entity ID.
+ * @param $displays
+ * Array of display settings to use for each entity, keyed by entity ID.
+ *
+ * @return
+ * Changes or additions to field values are done by altering the $items
+ * parameter by reference.
+ */
+function hook_field_formatter_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $displays) {
+ $tids = array();
+
+ // Collect every possible term attached to any of the fieldable entities.
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ // Force the array key to prevent duplicates.
+ $tids[$item['tid']] = $item['tid'];
+ }
+ }
+
+ if ($tids) {
+ $terms = taxonomy_term_load_multiple($tids);
+
+ // Iterate through the fieldable entities again to attach the loaded term
+ // data.
+ foreach ($entities as $id => $entity) {
+ $rekey = FALSE;
+
+ foreach ($items[$id] as $delta => $item) {
+ // Check whether the taxonomy term field instance value could be loaded.
+ if (isset($terms[$item['tid']])) {
+ // Replace the instance value with the term data.
+ $items[$id][$delta]['taxonomy_term'] = $terms[$item['tid']];
+ }
+ // Otherwise, unset the instance value, since the term does not exist.
+ else {
+ unset($items[$id][$delta]);
+ $rekey = TRUE;
+ }
+ }
+
+ if ($rekey) {
+ // Rekey the items array.
+ $items[$id] = array_values($items[$id]);
+ }
+ }
+ }
+}
+
+/**
+ * Build a renderable array for a field value.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity being displayed.
+ * @param $field
+ * The field structure.
+ * @param $instance
+ * The field instance.
+ * @param $langcode
+ * The language associated with $items.
+ * @param $items
+ * Array of values for this field.
+ * @param $display
+ * The display settings to use, as found in the 'display' entry of instance
+ * definitions. The array notably contains the following keys and values;
+ * - type: The name of the formatter to use.
+ * - settings: The array of formatter settings.
+ *
+ * @return
+ * A renderable array for the $items, as an array of child elements keyed
+ * by numeric indexes starting from 0.
+ */
+function hook_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+ $settings = $display['settings'];
+
+ switch ($display['type']) {
+ case 'sample_field_formatter_simple':
+ // Common case: each value is displayed individually in a sub-element
+ // keyed by delta. The field.tpl.php template specifies the markup
+ // wrapping each value.
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => $settings['some_setting'] . $item['value']);
+ }
+ break;
+
+ case 'sample_field_formatter_themeable':
+ // More elaborate formatters can defer to a theme function for easier
+ // customization.
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array(
+ '#theme' => 'mymodule_theme_sample_field_formatter_themeable',
+ '#data' => $item['value'],
+ '#some_setting' => $settings['some_setting'],
+ );
+ }
+ break;
+
+ case 'sample_field_formatter_combined':
+ // Some formatters might need to display all values within a single piece
+ // of markup.
+ $rows = array();
+ foreach ($items as $delta => $item) {
+ $rows[] = array($delta, $item['value']);
+ }
+ $element[0] = array(
+ '#theme' => 'table',
+ '#header' => array(t('Delta'), t('Value')),
+ '#rows' => $rows,
+ );
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * @} End of "ingroup field_type"
+ */
+
+/**
+ * @ingroup field_attach
+ * @{
+ */
+
+/**
+ * Act on field_attach_form().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ * Implementing modules should alter the $form or $form_state parameters.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The entity for which an edit form is being built.
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-element of a larger form. The
+ * $form['#parents'] property can be used to identify the corresponding part
+ * of $form_state['values']. Hook implementations that need to act on the
+ * top-level properties of the global form (like #submit, #validate...) can
+ * add a #process callback to the array received in the $form parameter, and
+ * act on the $complete_form parameter in the process callback.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $langcode
+ * The language the field values are going to be entered in. If no language
+ * is provided the default site language will be used.
+ */
+function hook_field_attach_form($entity_type, $entity, &$form, &$form_state, $langcode) {
+ // Add a checkbox allowing a given field to be emptied.
+ // See hook_field_attach_submit() for the corresponding processing code.
+ $form['empty_field_foo'] = array(
+ '#type' => 'checkbox',
+ '#title' => t("Empty the 'field_foo' field"),
+ );
+}
+
+/**
+ * Act on field_attach_load().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * Unlike other field_attach hooks, this hook accounts for 'multiple loads'.
+ * Instead of the usual $entity parameter, it accepts an array of entities,
+ * indexed by entity ID. For performance reasons, information for all available
+ * entities should be loaded in a single query where possible.
+ *
+ * The changes made to the entities' field values get cached by the field cache
+ * for subsequent loads.
+ *
+ * See field_attach_load() for details and arguments.
+ */
+function hook_field_attach_load($entity_type, $entities, $age, $options) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_validate().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_validate() for details and arguments.
+ */
+function hook_field_attach_validate($entity_type, $entity, &$errors) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_submit().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The entity for which an edit form is being submitted. The incoming form
+ * values have been extracted as field values of the $entity object.
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-part of a larger form. The $form['#parents']
+ * property can be used to identify the corresponding part of
+ * $form_state['values'].
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ */
+function hook_field_attach_submit($entity_type, $entity, $form, &$form_state) {
+ // Sample case of an 'Empty the field' checkbox added on the form, allowing
+ // a given field to be emptied.
+ $values = drupal_array_get_nested_value($form_state['values'], $form['#parents']);
+ if (!empty($values['empty_field_foo'])) {
+ unset($entity->field_foo);
+ }
+}
+
+/**
+ * Act on field_attach_presave().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_presave() for details and arguments.
+ */
+function hook_field_attach_presave($entity_type, $entity) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_insert().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_insert() for details and arguments.
+ */
+function hook_field_attach_insert($entity_type, $entity) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_update().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_update() for details and arguments.
+ */
+function hook_field_attach_update($entity_type, $entity) {
+ // @todo Needs function body.
+}
+
+/**
+ * Alter field_attach_preprocess() variables.
+ *
+ * This hook is invoked while preprocessing the field.tpl.php template file
+ * in field_attach_preprocess().
+ *
+ * @param $variables
+ * The variables array is passed by reference and will be populated with field
+ * values.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The type of $entity; for example, 'node' or 'user'.
+ * - entity: The entity with fields to render.
+ * - element: The structured array containing the values ready for rendering.
+ */
+function hook_field_attach_preprocess_alter(&$variables, $context) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_delete().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_delete() for details and arguments.
+ */
+function hook_field_attach_delete($entity_type, $entity) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_attach_delete_revision().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_delete_revision() for details and arguments.
+ */
+function hook_field_attach_delete_revision($entity_type, $entity) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field_purge_data().
+ *
+ * This hook is invoked in field_purge_data() and allows modules to act on
+ * purging data from a single field pseudo-entity. For example, if a module
+ * relates data in the field with its own data, it may purge its own data
+ * during this process as well.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The pseudo-entity whose field data is being purged.
+ * @param $field
+ * The (possibly deleted) field whose data is being purged.
+ * @param $instance
+ * The deleted field instance whose data is being purged.
+ *
+ * @see @link field_purge Field API bulk data deletion @endlink
+ * @see field_purge_data()
+ */
+function hook_field_attach_purge($entity_type, $entity, $field, $instance) {
+ // find the corresponding data in mymodule and purge it
+ if ($entity_type == 'node' && $field->field_name == 'my_field_name') {
+ mymodule_remove_mydata($entity->nid);
+ }
+}
+
+/**
+ * Perform alterations on field_attach_view() or field_view_field().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * @param $output
+ * The structured content array tree for all of the entity's fields.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The type of $entity; for example, 'node' or 'user'.
+ * - entity: The entity with fields to render.
+ * - view_mode: View mode; for example, 'full' or 'teaser'.
+ * - display: Either a view mode string or an array of display settings. If
+ * this hook is being invoked from field_attach_view(), the 'display'
+ * element is set to the view mode string. If this hook is being invoked
+ * from field_view_field(), this element is set to the $display argument
+ * and the view_mode element is set to '_custom'. See field_view_field()
+ * for more information on what its $display argument contains.
+ * - language: The language code used for rendering.
+ */
+function hook_field_attach_view_alter(&$output, $context) {
+ // Append RDF term mappings on displayed taxonomy links.
+ foreach (element_children($output) as $field_name) {
+ $element = &$output[$field_name];
+ if ($element['#field_type'] == 'taxonomy_term_reference' && $element['#formatter'] == 'taxonomy_term_reference_link') {
+ foreach ($element['#items'] as $delta => $item) {
+ $term = $item['taxonomy_term'];
+ if (!empty($term->rdf_mapping['rdftype'])) {
+ $element[$delta]['#options']['attributes']['typeof'] = $term->rdf_mapping['rdftype'];
+ }
+ if (!empty($term->rdf_mapping['name']['predicates'])) {
+ $element[$delta]['#options']['attributes']['property'] = $term->rdf_mapping['name']['predicates'];
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Perform alterations on field_attach_prepare_translation().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * @param $entity
+ * The entity being prepared for translation.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The type of $entity; e.g. 'node' or 'user'.
+ * - langcode: The language the entity has to be translated in.
+ * - source_entity: The entity holding the field values to be translated.
+ * - source_langcode: The source language from which translate.
+ */
+function hook_field_attach_prepare_translation_alter(&$entity, $context) {
+ if ($context['entity_type'] == 'custom_entity_type') {
+ $entity->custom_field = $context['source_entity']->custom_field;
+ }
+}
+
+/**
+ * Perform alterations on field_language() values.
+ *
+ * This hook is invoked to alter the array of display languages for the given
+ * entity.
+ *
+ * @param $display_language
+ * A reference to an array of language codes keyed by field name.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The type of the entity to be displayed.
+ * - entity: The entity with fields to render.
+ * - langcode: The language code $entity has to be displayed in.
+ */
+function hook_field_language_alter(&$display_language, $context) {
+ // Do not apply core language fallback rules if they are disabled or if Locale
+ // is not registered as a translation handler.
+ if (variable_get('locale_field_language_fallback', TRUE) && field_has_translation_handler($context['entity_type'], 'locale')) {
+ locale_field_language_fallback($display_language, $context['entity'], $context['language']);
+ }
+}
+
+/**
+ * Alter field_available_languages() values.
+ *
+ * This hook is invoked from field_available_languages() to allow modules to
+ * alter the array of available languages for the given field.
+ *
+ * @param $languages
+ * A reference to an array of language codes to be made available.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The type of the entity the field is attached to.
+ * - field: A field data structure.
+ */
+function hook_field_available_languages_alter(&$languages, $context) {
+ // Add an unavailable language.
+ $languages[] = 'xx';
+
+ // Remove an available language.
+ $index = array_search('yy', $languages);
+ unset($languages[$index]);
+}
+
+/**
+ * Act on field_attach_create_bundle().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_create_bundle() for details and arguments.
+ */
+function hook_field_attach_create_bundle($entity_type, $bundle) {
+ // When a new bundle is created, the menu needs to be rebuilt to add the
+ // Field UI menu item tabs.
+ variable_set('menu_rebuild_needed', TRUE);
+}
+
+/**
+ * Act on field_attach_rename_bundle().
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * See field_attach_rename_bundle() for details and arguments.
+ */
+function hook_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ // Update the extra weights variable with new information.
+ if ($bundle_old !== $bundle_new) {
+ $extra_weights = variable_get('field_extra_weights', array());
+ if (isset($info[$entity_type][$bundle_old])) {
+ $extra_weights[$entity_type][$bundle_new] = $extra_weights[$entity_type][$bundle_old];
+ unset($extra_weights[$entity_type][$bundle_old]);
+ variable_set('field_extra_weights', $extra_weights);
+ }
+ }
+}
+
+/**
+ * Act on field_attach_delete_bundle.
+ *
+ * This hook is invoked after the field module has performed the operation.
+ *
+ * @param $entity_type
+ * The type of entity; for example, 'node' or 'user'.
+ * @param $bundle
+ * The bundle that was just deleted.
+ * @param $instances
+ * An array of all instances that existed for the bundle before it was
+ * deleted.
+ */
+function hook_field_attach_delete_bundle($entity_type, $bundle, $instances) {
+ // Remove the extra weights variable information for this bundle.
+ $extra_weights = variable_get('field_extra_weights', array());
+ if (isset($extra_weights[$entity_type][$bundle])) {
+ unset($extra_weights[$entity_type][$bundle]);
+ variable_set('field_extra_weights', $extra_weights);
+ }
+}
+
+/**
+ * @} End of "ingroup field_attach"
+ */
+
+/**********************************************************************
+ * Field Storage API
+ **********************************************************************/
+
+/**
+ * @ingroup field_storage
+ * @{
+ */
+
+/**
+ * Expose Field API storage backends.
+ *
+ * @return
+ * An array describing the storage backends implemented by the module.
+ * The keys are storage backend names. To avoid name clashes, storage backend
+ * names should be prefixed with the name of the module that exposes them.
+ * The values are arrays describing the storage backend, with the following
+ * key/value pairs:
+ * - label: The human-readable name of the storage backend.
+ * - description: A short description for the storage backend.
+ * - settings: An array whose keys are the names of the settings available
+ * for the storage backend, and whose values are the default values for
+ * those settings.
+ */
+function hook_field_storage_info() {
+ return array(
+ 'field_sql_storage' => array(
+ 'label' => t('Default SQL storage'),
+ 'description' => t('Stores fields in the local SQL database, using per-field tables.'),
+ 'settings' => array(),
+ ),
+ );
+}
+
+/**
+ * Perform alterations on Field API storage types.
+ *
+ * @param $info
+ * Array of informations on storage types exposed by
+ * hook_field_field_storage_info() implementations.
+ */
+function hook_field_storage_info_alter(&$info) {
+ // Add a setting to a storage type.
+ $info['field_sql_storage']['settings'] += array(
+ 'mymodule_additional_setting' => 'default value',
+ );
+}
+
+/**
+ * Reveal the internal details about the storage for a field.
+ *
+ * For example, an SQL storage module might return the Schema API structure for
+ * the table. A key/value storage module might return the server name,
+ * authentication credentials, and bin name.
+ *
+ * Field storage modules are not obligated to implement this hook. Modules
+ * that rely on these details must only use them for read operations.
+ *
+ * @param $field
+ * A field structure.
+ *
+ * @return
+ * An array of details.
+ * - The first dimension is a store type (sql, solr, etc).
+ * - The second dimension indicates the age of the values in the store
+ * FIELD_LOAD_CURRENT or FIELD_LOAD_REVISION.
+ * - Other dimensions are specific to the field storage module.
+ *
+ * @see hook_field_storage_details_alter()
+ */
+function hook_field_storage_details($field) {
+ $details = array();
+
+ // Add field columns.
+ foreach ((array) $field['columns'] as $column_name => $attributes) {
+ $real_name = _field_sql_storage_columnname($field['field_name'], $column_name);
+ $columns[$column_name] = $real_name;
+ }
+ return array(
+ 'sql' => array(
+ FIELD_LOAD_CURRENT => array(
+ _field_sql_storage_tablename($field) => $columns,
+ ),
+ FIELD_LOAD_REVISION => array(
+ _field_sql_storage_revision_tablename($field) => $columns,
+ ),
+ ),
+ );
+}
+
+/**
+ * Perform alterations on Field API storage details.
+ *
+ * @param $details
+ * An array of storage details for fields as exposed by
+ * hook_field_storage_details() implementations.
+ * @param $field
+ * A field structure.
+ *
+ * @see hook_field_storage_details()
+ */
+function hook_field_storage_details_alter(&$details, $field) {
+ if ($field['field_name'] == 'field_of_interest') {
+ $columns = array();
+ foreach ((array) $field['columns'] as $column_name => $attributes) {
+ $columns[$column_name] = $column_name;
+ }
+ $details['drupal_variables'] = array(
+ FIELD_LOAD_CURRENT => array(
+ 'moon' => $columns,
+ ),
+ FIELD_LOAD_REVISION => array(
+ 'mars' => $columns,
+ ),
+ );
+ }
+}
+
+/**
+ * Load field data for a set of entities.
+ *
+ * This hook is invoked from field_attach_load() to ask the field storage
+ * module to load field data.
+ *
+ * Modules implementing this hook should load field values and add them to
+ * objects in $entities. Fields with no values should be added as empty
+ * arrays.
+ *
+ * @param $entity_type
+ * The type of entity, such as 'node' or 'user'.
+ * @param $entities
+ * The array of entity objects to add fields to, keyed by entity ID.
+ * @param $age
+ * FIELD_LOAD_CURRENT to load the most recent revision for all fields, or
+ * FIELD_LOAD_REVISION to load the version indicated by each entity.
+ * @param $fields
+ * An array listing the fields to be loaded. The keys of the array are field
+ * IDs, and the values of the array are the entity IDs (or revision IDs,
+ * depending on the $age parameter) to add each field to.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - deleted: If TRUE, deleted fields should be loaded as well as
+ * non-deleted fields. If unset or FALSE, only non-deleted fields should be
+ * loaded.
+ */
+function hook_field_storage_load($entity_type, $entities, $age, $fields, $options) {
+ $field_info = field_info_field_by_ids();
+ $load_current = $age == FIELD_LOAD_CURRENT;
+
+ foreach ($fields as $field_id => $ids) {
+ $field = $field_info[$field_id];
+ $field_name = $field['field_name'];
+ $table = $load_current ? _field_sql_storage_tablename($field) : _field_sql_storage_revision_tablename($field);
+
+ $query = db_select($table, 't')
+ ->fields('t')
+ ->condition('entity_type', $entity_type)
+ ->condition($load_current ? 'entity_id' : 'revision_id', $ids, 'IN')
+ ->condition('language', field_available_languages($entity_type, $field), 'IN')
+ ->orderBy('delta');
+
+ if (empty($options['deleted'])) {
+ $query->condition('deleted', 0);
+ }
+
+ $results = $query->execute();
+
+ $delta_count = array();
+ foreach ($results as $row) {
+ if (!isset($delta_count[$row->entity_id][$row->language])) {
+ $delta_count[$row->entity_id][$row->language] = 0;
+ }
+
+ if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$row->language] < $field['cardinality']) {
+ $item = array();
+ // For each column declared by the field, populate the item
+ // from the prefixed database column.
+ foreach ($field['columns'] as $column => $attributes) {
+ $column_name = _field_sql_storage_columnname($field_name, $column);
+ $item[$column] = $row->$column_name;
+ }
+
+ // Add the item to the field values for the entity.
+ $entities[$row->entity_id]->{$field_name}[$row->language][] = $item;
+ $delta_count[$row->entity_id][$row->language]++;
+ }
+ }
+ }
+}
+
+/**
+ * Write field data for an entity.
+ *
+ * This hook is invoked from field_attach_insert() and field_attach_update(),
+ * to ask the field storage module to save field data.
+ *
+ * @param $entity_type
+ * The entity type of entity, such as 'node' or 'user'.
+ * @param $entity
+ * The entity on which to operate.
+ * @param $op
+ * FIELD_STORAGE_UPDATE when updating an existing entity,
+ * FIELD_STORAGE_INSERT when inserting a new entity.
+ * @param $fields
+ * An array listing the fields to be written. The keys and values of the
+ * array are field IDs.
+ */
+function hook_field_storage_write($entity_type, $entity, $op, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ if (!isset($vid)) {
+ $vid = $id;
+ }
+
+ foreach ($fields as $field_id) {
+ $field = field_info_field_by_id($field_id);
+ $field_name = $field['field_name'];
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+
+ $all_languages = field_available_languages($entity_type, $field);
+ $field_languages = array_intersect($all_languages, array_keys((array) $entity->$field_name));
+
+ // Delete and insert, rather than update, in case a value was added.
+ if ($op == FIELD_STORAGE_UPDATE) {
+ // Delete languages present in the incoming $entity->$field_name.
+ // Delete all languages if $entity->$field_name is empty.
+ $languages = !empty($entity->$field_name) ? $field_languages : $all_languages;
+ if ($languages) {
+ db_delete($table_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('language', $languages, 'IN')
+ ->execute();
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('revision_id', $vid)
+ ->condition('language', $languages, 'IN')
+ ->execute();
+ }
+ }
+
+ // Prepare the multi-insert query.
+ $do_insert = FALSE;
+ $columns = array('entity_type', 'entity_id', 'revision_id', 'bundle', 'delta', 'language');
+ foreach ($field['columns'] as $column => $attributes) {
+ $columns[] = _field_sql_storage_columnname($field_name, $column);
+ }
+ $query = db_insert($table_name)->fields($columns);
+ $revision_query = db_insert($revision_name)->fields($columns);
+
+ foreach ($field_languages as $langcode) {
+ $items = (array) $entity->{$field_name}[$langcode];
+ $delta_count = 0;
+ foreach ($items as $delta => $item) {
+ // We now know we have someting to insert.
+ $do_insert = TRUE;
+ $record = array(
+ 'entity_type' => $entity_type,
+ 'entity_id' => $id,
+ 'revision_id' => $vid,
+ 'bundle' => $bundle,
+ 'delta' => $delta,
+ 'language' => $langcode,
+ );
+ foreach ($field['columns'] as $column => $attributes) {
+ $record[_field_sql_storage_columnname($field_name, $column)] = isset($item[$column]) ? $item[$column] : NULL;
+ }
+ $query->values($record);
+ if (isset($vid)) {
+ $revision_query->values($record);
+ }
+
+ if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) {
+ break;
+ }
+ }
+ }
+
+ // Execute the query if we have values to insert.
+ if ($do_insert) {
+ $query->execute();
+ $revision_query->execute();
+ }
+ }
+}
+
+/**
+ * Delete all field data for an entity.
+ *
+ * This hook is invoked from field_attach_delete() to ask the field storage
+ * module to delete field data.
+ *
+ * @param $entity_type
+ * The entity type of entity, such as 'node' or 'user'.
+ * @param $entity
+ * The entity on which to operate.
+ * @param $fields
+ * An array listing the fields to delete. The keys and values of the
+ * array are field IDs.
+ */
+function hook_field_storage_delete($entity_type, $entity, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ if (isset($fields[$instance['field_id']])) {
+ $field = field_info_field_by_id($instance['field_id']);
+ field_sql_storage_field_storage_purge($entity_type, $entity, $field, $instance);
+ }
+ }
+}
+
+/**
+ * Delete a single revision of field data for an entity.
+ *
+ * This hook is invoked from field_attach_delete_revision() to ask the field
+ * storage module to delete field revision data.
+ *
+ * Deleting the current (most recently written) revision is not
+ * allowed as has undefined results.
+ *
+ * @param $entity_type
+ * The entity type of entity, such as 'node' or 'user'.
+ * @param $entity
+ * The entity on which to operate. The revision to delete is
+ * indicated by the entity's revision ID property, as identified by
+ * hook_fieldable_info() for $entity_type.
+ * @param $fields
+ * An array listing the fields to delete. The keys and values of the
+ * array are field IDs.
+ */
+function hook_field_storage_delete_revision($entity_type, $entity, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ if (isset($vid)) {
+ foreach ($fields as $field_id) {
+ $field = field_info_field_by_id($field_id);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('revision_id', $vid)
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Execute an EntityFieldQuery.
+ *
+ * This hook is called to find the entities having certain entity and field
+ * conditions and sort them in the given field order. If the field storage
+ * engine also handles property sorts and orders, it should unset those
+ * properties in the called object to signal that those have been handled.
+ *
+ * @param EntityFieldQuery $query
+ * An EntityFieldQuery.
+ *
+ * @return
+ * See EntityFieldQuery::execute() for the return values.
+ */
+function hook_field_storage_query($query) {
+ $groups = array();
+ if ($query->age == FIELD_LOAD_CURRENT) {
+ $tablename_function = '_field_sql_storage_tablename';
+ $id_key = 'entity_id';
+ }
+ else {
+ $tablename_function = '_field_sql_storage_revision_tablename';
+ $id_key = 'revision_id';
+ }
+ $table_aliases = array();
+ // Add tables for the fields used.
+ foreach ($query->fields as $key => $field) {
+ $tablename = $tablename_function($field);
+ // Every field needs a new table.
+ $table_alias = $tablename . $key;
+ $table_aliases[$key] = $table_alias;
+ if ($key) {
+ $select_query->join($tablename, $table_alias, "$table_alias.entity_type = $field_base_table.entity_type AND $table_alias.$id_key = $field_base_table.$id_key");
+ }
+ else {
+ $select_query = db_select($tablename, $table_alias);
+ $select_query->addTag('entity_field_access');
+ $select_query->addMetaData('base_table', $tablename);
+ $select_query->fields($table_alias, array('entity_type', 'entity_id', 'revision_id', 'bundle'));
+ $field_base_table = $table_alias;
+ }
+ if ($field['cardinality'] != 1) {
+ $select_query->distinct();
+ }
+ }
+
+ // Add field conditions.
+ foreach ($query->fieldConditions as $key => $condition) {
+ $table_alias = $table_aliases[$key];
+ $field = $condition['field'];
+ // Add the specified condition.
+ $sql_field = "$table_alias." . _field_sql_storage_columnname($field['field_name'], $condition['column']);
+ $query->addCondition($select_query, $sql_field, $condition);
+ // Add delta / language group conditions.
+ foreach (array('delta', 'language') as $column) {
+ if (isset($condition[$column . '_group'])) {
+ $group_name = $condition[$column . '_group'];
+ if (!isset($groups[$column][$group_name])) {
+ $groups[$column][$group_name] = $table_alias;
+ }
+ else {
+ $select_query->where("$table_alias.$column = " . $groups[$column][$group_name] . ".$column");
+ }
+ }
+ }
+ }
+
+ if (isset($query->deleted)) {
+ $select_query->condition("$field_base_table.deleted", (int) $query->deleted);
+ }
+
+ // Is there a need to sort the query by property?
+ $has_property_order = FALSE;
+ foreach ($query->order as $order) {
+ if ($order['type'] == 'property') {
+ $has_property_order = TRUE;
+ }
+ }
+
+ if ($query->propertyConditions || $has_property_order) {
+ if (empty($query->entityConditions['entity_type']['value'])) {
+ throw new EntityFieldQueryException('Property conditions and orders must have an entity type defined.');
+ }
+ $entity_type = $query->entityConditions['entity_type']['value'];
+ $entity_base_table = _field_sql_storage_query_join_entity($select_query, $entity_type, $field_base_table);
+ $query->entityConditions['entity_type']['operator'] = '=';
+ foreach ($query->propertyConditions as $property_condition) {
+ $query->addCondition($select_query, "$entity_base_table." . $property_condition['column'], $property_condition);
+ }
+ }
+ foreach ($query->entityConditions as $key => $condition) {
+ $query->addCondition($select_query, "$field_base_table.$key", $condition);
+ }
+
+ // Order the query.
+ foreach ($query->order as $order) {
+ if ($order['type'] == 'entity') {
+ $key = $order['specifier'];
+ $select_query->orderBy("$field_base_table.$key", $order['direction']);
+ }
+ elseif ($order['type'] == 'field') {
+ $specifier = $order['specifier'];
+ $field = $specifier['field'];
+ $table_alias = $table_aliases[$specifier['index']];
+ $sql_field = "$table_alias." . _field_sql_storage_columnname($field['field_name'], $specifier['column']);
+ $select_query->orderBy($sql_field, $order['direction']);
+ }
+ elseif ($order['type'] == 'property') {
+ $select_query->orderBy("$entity_base_table." . $order['specifier'], $order['direction']);
+ }
+ }
+
+ return $query->finishQuery($select_query, $id_key);
+}
+
+/**
+ * Act on creation of a new field.
+ *
+ * This hook is invoked from field_create_field() to ask the field storage
+ * module to save field information and prepare for storing field instances.
+ * If there is a problem, the field storage module should throw an exception.
+ *
+ * @param $field
+ * The field structure being created.
+ */
+function hook_field_storage_create_field($field) {
+ $schema = _field_sql_storage_schema($field);
+ foreach ($schema as $name => $table) {
+ db_create_table($name, $table);
+ }
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Act on deletion of a field.
+ *
+ * This hook is invoked from field_delete_field() to ask the field storage
+ * module to mark all information stored in the field for deletion.
+ *
+ * @param $field
+ * The field being deleted.
+ */
+function hook_field_storage_delete_field($field) {
+ // Mark all data associated with the field for deletion.
+ $field['deleted'] = 0;
+ $table = _field_sql_storage_tablename($field);
+ $revision_table = _field_sql_storage_revision_tablename($field);
+ db_update($table)
+ ->fields(array('deleted' => 1))
+ ->execute();
+
+ // Move the table to a unique name while the table contents are being deleted.
+ $field['deleted'] = 1;
+ $new_table = _field_sql_storage_tablename($field);
+ $revision_new_table = _field_sql_storage_revision_tablename($field);
+ db_rename_table($table, $new_table);
+ db_rename_table($revision_table, $revision_new_table);
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Act on deletion of a field instance.
+ *
+ * This hook is invoked from field_delete_instance() to ask the field storage
+ * module to mark all information stored for the field instance for deletion.
+ *
+ * @param $instance
+ * The instance being deleted.
+ */
+function hook_field_storage_delete_instance($instance) {
+ $field = field_info_field($instance['field_name']);
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_update($table_name)
+ ->fields(array('deleted' => 1))
+ ->condition('entity_type', $instance['entity_type'])
+ ->condition('bundle', $instance['bundle'])
+ ->execute();
+ db_update($revision_name)
+ ->fields(array('deleted' => 1))
+ ->condition('entity_type', $instance['entity_type'])
+ ->condition('bundle', $instance['bundle'])
+ ->execute();
+}
+
+/**
+ * Act before the storage backends load field data.
+ *
+ * This hook allows modules to load data before the Field Storage API,
+ * optionally preventing the field storage module from doing so.
+ *
+ * This lets 3rd party modules override, mirror, shard, or otherwise store a
+ * subset of fields in a different way than the current storage engine.
+ * Possible use cases include per-bundle storage, per-combo-field storage, etc.
+ *
+ * Modules implementing this hook should load field values and add them to
+ * objects in $entities. Fields with no values should be added as empty
+ * arrays. In addition, fields loaded should be added as keys to $skip_fields.
+ *
+ * @param $entity_type
+ * The type of entity, such as 'node' or 'user'.
+ * @param $entities
+ * The array of entity objects to add fields to, keyed by entity ID.
+ * @param $age
+ * FIELD_LOAD_CURRENT to load the most recent revision for all fields, or
+ * FIELD_LOAD_REVISION to load the version indicated by each entity.
+ * @param $skip_fields
+ * An array keyed by field IDs whose data has already been loaded and
+ * therefore should not be loaded again. Add a key to this array to indicate
+ * that your module has already loaded a field.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - field_id: The field ID that should be loaded. If unset, all fields
+ * should be loaded.
+ * - deleted: If TRUE, deleted fields should be loaded as well as
+ * non-deleted fields. If unset or FALSE, only non-deleted fields should be
+ * loaded.
+ */
+function hook_field_storage_pre_load($entity_type, $entities, $age, &$skip_fields, $options) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act before the storage backends insert field data.
+ *
+ * This hook allows modules to store data before the Field Storage API,
+ * optionally preventing the field storage module from doing so.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to save.
+ * @param $skip_fields
+ * An array keyed by field IDs whose data has already been written and
+ * therefore should not be written again. The values associated with these
+ * keys are not specified.
+ * @return
+ * Saved field IDs are set set as keys in $skip_fields.
+ */
+function hook_field_storage_pre_insert($entity_type, $entity, &$skip_fields) {
+ if ($entity_type == 'node' && $entity->status && _forum_node_check_node_type($entity)) {
+ $query = db_insert('forum_index')->fields(array('nid', 'title', 'tid', 'sticky', 'created', 'comment_count', 'last_comment_timestamp'));
+ foreach ($entity->taxonomy_forums as $language) {
+ foreach ($language as $delta) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'title' => $entity->title,
+ 'tid' => $delta['value'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $entity->created,
+ ));
+ }
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Act before the storage backends update field data.
+ *
+ * This hook allows modules to store data before the Field Storage API,
+ * optionally preventing the field storage module from doing so.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to save.
+ * @param $skip_fields
+ * An array keyed by field IDs whose data has already been written and
+ * therefore should not be written again. The values associated with these
+ * keys are not specified.
+ * @return
+ * Saved field IDs are set set as keys in $skip_fields.
+ */
+function hook_field_storage_pre_update($entity_type, $entity, &$skip_fields) {
+ $first_call = &drupal_static(__FUNCTION__, array());
+
+ if ($entity_type == 'node' && $entity->status && _forum_node_check_node_type($entity)) {
+ // We don't maintain data for old revisions, so clear all previous values
+ // from the table. Since this hook runs once per field, per entity, make
+ // sure we only wipe values once.
+ if (!isset($first_call[$entity->nid])) {
+ $first_call[$entity->nid] = FALSE;
+ db_delete('forum_index')->condition('nid', $entity->nid)->execute();
+ }
+ // Only save data to the table if the node is published.
+ if ($entity->status) {
+ $query = db_insert('forum_index')->fields(array('nid', 'title', 'tid', 'sticky', 'created', 'comment_count', 'last_comment_timestamp'));
+ foreach ($entity->taxonomy_forums as $language) {
+ foreach ($language as $delta) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'title' => $entity->title,
+ 'tid' => $delta['value'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $entity->created,
+ ));
+ }
+ }
+ $query->execute();
+ // The logic for determining last_comment_count is fairly complex, so
+ // call _forum_update_forum_index() too.
+ _forum_update_forum_index($entity->nid);
+ }
+ }
+}
+
+/**
+ * Returns the maximum weight for the entity components handled by the module.
+ *
+ * Field API takes care of fields and 'extra_fields'. This hook is intended for
+ * third-party modules adding other entity components (e.g. field_group).
+ *
+ * @param $entity_type
+ * The type of entity; e.g. 'node' or 'user'.
+ * @param $bundle
+ * The bundle name.
+ * @param $context
+ * The context for which the maximum weight is requested. Either 'form', or
+ * the name of a view mode.
+ * @return
+ * The maximum weight of the entity's components, or NULL if no components
+ * were found.
+ */
+function hook_field_info_max_weight($entity_type, $bundle, $context) {
+ $weights = array();
+
+ foreach (my_module_entity_additions($entity_type, $bundle, $context) as $addition) {
+ $weights[] = $addition['weight'];
+ }
+
+ return $weights ? max($weights) : NULL;
+}
+
+/**
+ * Alters the display settings of a field before it gets displayed.
+ *
+ * Note that instead of hook_field_display_alter(), which is called for all
+ * fields on all entity types, hook_field_display_ENTITY_TYPE_alter() may be
+ * used to alter display settings for fields on a specific entity type only.
+ *
+ * This hook is called once per field per displayed entity. If the result of the
+ * hook involves reading from the database, it is highly recommended to
+ * statically cache the information.
+ *
+ * @param $display
+ * The display settings that will be used to display the field values, as
+ * found in the 'display' key of $instance definitions.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The entity type; e.g., 'node' or 'user'.
+ * - field: The field being rendered.
+ * - instance: The instance being rendered.
+ * - entity: The entity being rendered.
+ * - view_mode: The view mode, e.g. 'full', 'teaser'...
+ *
+ * @see hook_field_display_ENTITY_TYPE_alter()
+ */
+function hook_field_display_alter(&$display, $context) {
+ // Leave field labels out of the search index.
+ // Note: The check against $context['entity_type'] == 'node' could be avoided
+ // by using hook_field_display_node_alter() instead of
+ // hook_field_display_alter(), resulting in less function calls when
+ // rendering non-node entities.
+ if ($context['entity_type'] == 'node' && $context['view_mode'] == 'search_index') {
+ $display['label'] = 'hidden';
+ }
+}
+
+/**
+ * Alters the display settings of a field on a given entity type before it gets displayed.
+ *
+ * Modules can implement hook_field_display_ENTITY_TYPE_alter() to alter display
+ * settings for fields on a specific entity type, rather than implementing
+ * hook_field_display_alter().
+ *
+ * This hook is called once per field per displayed entity. If the result of the
+ * hook involves reading from the database, it is highly recommended to
+ * statically cache the information.
+ *
+ * @param $display
+ * The display settings that will be used to display the field values, as
+ * found in the 'display' key of $instance definitions.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The entity type; e.g., 'node' or 'user'.
+ * - field: The field being rendered.
+ * - instance: The instance being rendered.
+ * - entity: The entity being rendered.
+ * - view_mode: The view mode, e.g. 'full', 'teaser'...
+ *
+ * @see hook_field_display_alter()
+ */
+function hook_field_display_ENTITY_TYPE_alter(&$display, $context) {
+ // Leave field labels out of the search index.
+ if ($context['view_mode'] == 'search_index') {
+ $display['label'] = 'hidden';
+ }
+}
+
+/**
+ * Alters the display settings of pseudo-fields before an entity is displayed.
+ *
+ * This hook is called once per displayed entity. If the result of the hook
+ * involves reading from the database, it is highly recommended to statically
+ * cache the information.
+ *
+ * @param $displays
+ * An array of display settings for the pseudo-fields in the entity, keyed
+ * by pseudo-field names.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The entity type; e.g., 'node' or 'user'.
+ * - bundle: The bundle name.
+ * - view_mode: The view mode, e.g. 'full', 'teaser'...
+ */
+function hook_field_extra_fields_display_alter(&$displays, $context) {
+ if ($context['entity_type'] == 'taxonomy_term' && $context['view_mode'] == 'full') {
+ $displays['description']['visible'] = FALSE;
+ }
+}
+
+/**
+ * Alters the widget properties of a field instance before it gets displayed.
+ *
+ * Note that instead of hook_field_widget_properties_alter(), which is called
+ * for all fields on all entity types,
+ * hook_field_widget_properties_ENTITY_TYPE_alter() may be used to alter widget
+ * properties for fields on a specific entity type only.
+ *
+ * This hook is called once per field per added or edit entity. If the result
+ * of the hook involves reading from the database, it is highly recommended to
+ * statically cache the information.
+ *
+ * @param $widget
+ * The instance's widget properties.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The entity type; e.g., 'node' or 'user'.
+ * - entity: The entity object.
+ * - field: The field that the widget belongs to.
+ * - instance: The instance of the field.
+ *
+ * @see hook_field_widget_properties_ENTITY_TYPE_alter()
+ */
+function hook_field_widget_properties_alter(&$widget, $context) {
+ // Change a widget's type according to the time of day.
+ $field = $context['field'];
+ if ($context['entity_type'] == 'node' && $field['field_name'] == 'field_foo') {
+ $time = date('H');
+ $widget['type'] = $time < 12 ? 'widget_am' : 'widget_pm';
+ }
+}
+
+/**
+ * Alters the widget properties of a field instance on a given entity type
+ * before it gets displayed.
+ *
+ * Modules can implement hook_field_widget_properties_ENTITY_TYPE_alter() to
+ * alter the widget properties for fields on a specific entity type, rather than
+ * implementing hook_field_widget_properties_alter().
+ *
+ * This hook is called once per field per displayed widget entity. If the result
+ * of the hook involves reading from the database, it is highly recommended to
+ * statically cache the information.
+ *
+ * @param $widget
+ * The instance's widget properties.
+ * @param $context
+ * An associative array containing:
+ * - entity_type: The entity type; e.g., 'node' or 'user'.
+ * - entity: The entity object.
+ * - field: The field that the widget belongs to.
+ * - instance: The instance of the field.
+ *
+ * @see hook_field_widget_properties_alter()
+ */
+function hook_field_widget_properties_ENTITY_TYPE_alter(&$widget, $context) {
+ // Change a widget's type according to the time of day.
+ $field = $context['field'];
+ if ($field['field_name'] == 'field_foo') {
+ $time = date('H');
+ $widget['type'] = $time < 12 ? 'widget_am' : 'widget_pm';
+ }
+}
+
+/**
+ * @} End of "ingroup field_storage"
+ */
+
+/**********************************************************************
+ * Field CRUD API
+ **********************************************************************/
+
+/**
+ * @ingroup field_crud
+ * @{
+ */
+
+/**
+ * Act on a field being created.
+ *
+ * This hook is invoked from field_create_field() after the field is created, to
+ * allow modules to act on field creation.
+ *
+ * @param $field
+ * The field just created.
+ */
+function hook_field_create_field($field) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on a field instance being created.
+ *
+ * This hook is invoked from field_create_instance() after the instance record
+ * is saved, so it cannot be used to modify the instance itself.
+ *
+ * @param $instance
+ * The instance just created.
+ */
+function hook_field_create_instance($instance) {
+ // @todo Needs function body.
+}
+
+/**
+ * Forbid a field update from occurring.
+ *
+ * Any module may forbid any update for any reason. For example, the
+ * field's storage module might forbid an update if it would change
+ * the storage schema while data for the field exists. A field type
+ * module might forbid an update if it would change existing data's
+ * semantics, or if there are external dependencies on field settings
+ * that cannot be updated.
+ *
+ * To forbid the update from occurring, throw a FieldUpdateForbiddenException.
+ *
+ * @param $field
+ * The field as it will be post-update.
+ * @param $prior_field
+ * The field as it is pre-update.
+ * @param $has_data
+ * Whether any data already exists for this field.
+ */
+function hook_field_update_forbid($field, $prior_field, $has_data) {
+ // A 'list' field stores integer keys mapped to display values. If
+ // the new field will have fewer values, and any data exists for the
+ // abandoned keys, the field will have no way to display them. So,
+ // forbid such an update.
+ if ($has_data && count($field['settings']['allowed_values']) < count($prior_field['settings']['allowed_values'])) {
+ // Identify the keys that will be lost.
+ $lost_keys = array_diff(array_keys($field['settings']['allowed_values']), array_keys($prior_field['settings']['allowed_values']));
+ // If any data exist for those keys, forbid the update.
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($prior_field['field_name'], 'value', $lost_keys)
+ ->range(0, 1)
+ ->execute();
+ if ($found) {
+ throw new FieldUpdateForbiddenException("Cannot update a list field not to include keys with existing data");
+ }
+ }
+}
+
+/**
+ * Act on a field being updated.
+ *
+ * This hook is invoked just after field is updated in field_update_field().
+ *
+ * @param $field
+ * The field as it is post-update.
+ * @param $prior_field
+ * The field as it was pre-update.
+ * @param $has_data
+ * Whether any data already exists for this field.
+ */
+function hook_field_update_field($field, $prior_field, $has_data) {
+ // Reset the static value that keeps track of allowed values for list fields.
+ drupal_static_reset('list_allowed_values');
+}
+
+/**
+ * Act on a field being deleted.
+ *
+ * This hook is invoked just after a field is deleted by field_delete_field().
+ *
+ * @param $field
+ * The field just deleted.
+ */
+function hook_field_delete_field($field) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on a field instance being updated.
+ *
+ * This hook is invoked from field_update_instance() after the instance record
+ * is saved, so it cannot be used by a module to modify the instance itself.
+ *
+ * @param $instance
+ * The instance as it is post-update.
+ * @param $prior_$instance
+ * The instance as it was pre-update.
+ */
+function hook_field_update_instance($instance, $prior_instance) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on a field instance being deleted.
+ *
+ * This hook is invoked from field_delete_instance() after the instance is
+ * deleted.
+ *
+ * @param $instance
+ * The instance just deleted.
+ */
+function hook_field_delete_instance($instance) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on field records being read from the database.
+ *
+ * This hook is invoked from field_read_fields() on each field being read.
+ *
+ * @param $field
+ * The field record just read from the database.
+ */
+function hook_field_read_field($field) {
+ // @todo Needs function body.
+}
+
+/**
+ * Act on a field record being read from the database.
+ *
+ * This hook is invoked from field_read_instances() on each instance being read.
+ *
+ * @param $instance
+ * The instance record just read from the database.
+ */
+function hook_field_read_instance($instance) {
+ // @todo Needs function body.
+}
+
+/**
+ * Acts when a field record is being purged.
+ *
+ * In field_purge_field(), after the field configuration has been
+ * removed from the database, the field storage module has had a chance to
+ * run its hook_field_storage_purge_field(), and the field info cache
+ * has been cleared, this hook is invoked on all modules to allow them to
+ * respond to the field being purged.
+ *
+ * @param $field
+ * The field being purged.
+ */
+function hook_field_purge_field($field) {
+ db_delete('my_module_field_info')
+ ->condition('id', $field['id'])
+ ->execute();
+}
+
+/**
+ * Acts when a field instance is being purged.
+ *
+ * In field_purge_instance(), after the field instance has been
+ * removed from the database, the field storage module has had a chance to
+ * run its hook_field_storage_purge_instance(), and the field info cache
+ * has been cleared, this hook is invoked on all modules to allow them to
+ * respond to the field instance being purged.
+ *
+ * @param $instance
+ * The instance being purged.
+ */
+function hook_field_purge_instance($instance) {
+ db_delete('my_module_field_instance_info')
+ ->condition('id', $instance['id'])
+ ->execute();
+}
+
+/**
+ * Remove field storage information when a field record is purged.
+ *
+ * Called from field_purge_field() to allow the field storage module
+ * to remove field information when a field is being purged.
+ *
+ * @param $field
+ * The field being purged.
+ */
+function hook_field_storage_purge_field($field) {
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_drop_table($table_name);
+ db_drop_table($revision_name);
+}
+
+/**
+ * Remove field storage information when a field instance is purged.
+ *
+ * Called from field_purge_instance() to allow the field storage module
+ * to remove field instance information when a field instance is being
+ * purged.
+ *
+ * @param $instance
+ * The instance being purged.
+ */
+function hook_field_storage_purge_field_instance($instance) {
+ db_delete('my_module_field_instance_info')
+ ->condition('id', $instance['id'])
+ ->execute();
+}
+
+/**
+ * Remove field storage information when field data is purged.
+ *
+ * Called from field_purge_data() to allow the field storage
+ * module to delete field data information.
+ *
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The pseudo-entity whose field data to delete.
+ * @param $field
+ * The (possibly deleted) field whose data is being purged.
+ * @param $instance
+ * The deleted field instance whose data is being purged.
+ */
+function hook_field_storage_purge($entity_type, $entity, $field, $instance) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_delete($table_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->execute();
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->execute();
+}
+
+/**
+ * @} End of "ingroup field_crud"
+ */
+
+/**********************************************************************
+ * TODO: I'm not sure where these belong yet.
+ **********************************************************************/
+
+/**
+ * Determine whether the user has access to a given field.
+ *
+ * This hook is invoked from field_access() to let modules block access to
+ * operations on fields. If no module returns FALSE, the operation is allowed.
+ *
+ * @param $op
+ * The operation to be performed. Possible values: 'edit', 'view'.
+ * @param $field
+ * The field on which the operation is to be performed.
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * (optional) The entity for the operation.
+ * @param $account
+ * (optional) The account to check; if not given use currently logged in user.
+ *
+ * @return
+ * TRUE if the operation is allowed, and FALSE if the operation is denied.
+ */
+function hook_field_access($op, $field, $entity_type, $entity, $account) {
+ if ($field['field_name'] == 'field_of_interest' && $op == 'edit') {
+ return user_access('edit field of interest', $account);
+ }
+ return TRUE;
+}
diff --git a/core/modules/field/field.attach.inc b/core/modules/field/field.attach.inc
new file mode 100644
index 000000000000..3f5520a57f5d
--- /dev/null
+++ b/core/modules/field/field.attach.inc
@@ -0,0 +1,1357 @@
+<?php
+
+/**
+ * @file
+ * Field attach API, allowing entities (nodes, users, ...) to be 'fieldable'.
+ */
+
+/**
+ * Exception thrown by field_attach_validate() on field validation errors.
+ */
+class FieldValidationException extends FieldException {
+ var $errors;
+
+ /**
+ * Constructor for FieldValidationException.
+ *
+ * @param $errors
+ * An array of field validation errors, keyed by field name and
+ * delta that contains two keys:
+ * - 'error': A machine-readable error code string, prefixed by
+ * the field module name. A field widget may use this code to decide
+ * how to report the error.
+ * - 'message': A human-readable error message such as to be
+ * passed to form_error() for the appropriate form element.
+ */
+ function __construct($errors) {
+ $this->errors = $errors;
+ parent::__construct(t('Field validation errors'));
+ }
+}
+
+/**
+ * @defgroup field_storage Field Storage API
+ * @{
+ * Implement a storage engine for Field API data.
+ *
+ * The Field Attach API uses the Field Storage API to perform all "database
+ * access". Each Field Storage API hook function defines a primitive database
+ * operation such as read, write, or delete. The default field storage module,
+ * field_sql_storage.module, uses the local SQL database to implement these
+ * operations, but alternative field storage backends can choose to represent
+ * the data in SQL differently or use a completely different storage mechanism
+ * such as a cloud-based database.
+ *
+ * Each field defines which storage backend it uses. The Drupal system variable
+ * 'field_storage_default' identifies the storage backend used by default.
+ */
+
+/**
+ * Argument for an update operation.
+ *
+ * This is used in hook_field_storage_write when updating an
+ * existing entity.
+ */
+define('FIELD_STORAGE_UPDATE', 'update');
+
+/**
+ * Argument for an insert operation.
+ *
+ * This is used in hook_field_storage_write when inserting a new entity.
+ */
+define('FIELD_STORAGE_INSERT', 'insert');
+
+/**
+ * @} End of "defgroup field_storage"
+ */
+
+/**
+ * @defgroup field_attach Field Attach API
+ * @{
+ * Operate on Field API data attached to Drupal entities.
+ *
+ * Field Attach API functions load, store, display, generate Form API
+ * structures, and perform a variety of other functions for field data attached
+ * to individual entities.
+ *
+ * Field Attach API functions generally take $entity_type and $entity arguments
+ * along with additional function-specific arguments. $entity_type is the type
+ * of the fieldable entity, such as 'node' or 'user', and $entity is the entity
+ * itself.
+ *
+ * hook_entity_info() is the central place for entity types to define if and
+ * how Field API should operate on their entity objects. Notably, the
+ * 'fieldable' property needs to be set to TRUE.
+ *
+ * The Field Attach API uses the concept of bundles: the set of fields for a
+ * given entity is defined on a per-bundle basis. The collection of bundles for
+ * an entity type is defined its hook_entity_info() implementation. For
+ * instance, node_entity_info() exposes each node type as its own bundle. This
+ * means that the set of fields of a node is determined by the node type. The
+ * Field API reads the bundle name for a given entity from a particular
+ * property of the entity object, and hook_entity_info() defines which property
+ * to use. For instance, node_entity_info() specifies:
+ * @code $info['entity keys']['bundle'] = 'type'@endcode
+ * This indicates that for a particular node object, the bundle name can be
+ * found in $node->type. This property can be omitted if the entity type only
+ * exposes a single bundle (all entities of this type have the same collection
+ * of fields). This is the case for the 'user' entity type.
+ *
+ * Most Field Attach API functions define a corresponding hook function that
+ * allows any module to act on Field Attach operations for any entity after the
+ * operation is complete, and access or modify all the field, form, or display
+ * data for that entity and operation. For example, field_attach_view() invokes
+ * hook_field_attach_view_alter(). These all-module hooks are distinct from
+ * those of the Field Types API, such as hook_field_load(), that are only
+ * invoked for the module that defines a specific field type.
+ *
+ * field_attach_load(), field_attach_insert(), and field_attach_update() also
+ * define pre-operation hooks, e.g. hook_field_attach_pre_load(). These hooks
+ * run before the corresponding Field Storage API and Field Type API
+ * operations. They allow modules to define additional storage locations (e.g.
+ * denormalizing, mirroring) for field data on a per-field basis. They also
+ * allow modules to take over field storage completely by instructing other
+ * implementations of the same hook and the Field Storage API itself not to
+ * operate on specified fields.
+ *
+ * The pre-operation hooks do not make the Field Storage API irrelevant. The
+ * Field Storage API is essentially the "fallback mechanism" for any fields
+ * that aren't being intercepted explicitly by pre-operation hooks.
+ */
+
+/**
+ * Invoke a field hook.
+ *
+ * @param $op
+ * Possible operations include:
+ * - form
+ * - validate
+ * - presave
+ * - insert
+ * - update
+ * - delete
+ * - delete revision
+ * - view
+ * - prepare translation
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The fully formed $entity_type entity.
+ * @param $a
+ * - The $form in the 'form' operation.
+ * - The value of $view_mode in the 'view' operation.
+ * - Otherwise NULL.
+ * @param $b
+ * - The $form_state in the 'submit' operation.
+ * - Otherwise NULL.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - 'field_name': The name of the field whose operation should be
+ * invoked. By default, the operation is invoked on all the fields
+ * in the entity's bundle. NOTE: This option is not compatible with
+ * the 'deleted' option; the 'field_id' option should be used
+ * instead.
+ * - 'field_id': The id of the field whose operation should be
+ * invoked. By default, the operation is invoked on all the fields
+ * in the entity's' bundles.
+ * - 'default': A boolean value, specifying which implementation of
+ * the operation should be invoked.
+ * - if FALSE (default), the field types implementation of the operation
+ * will be invoked (hook_field_[op])
+ * - If TRUE, the default field implementation of the field operation
+ * will be invoked (field_default_[op])
+ * Internal use only. Do not explicitely set to TRUE, but use
+ * _field_invoke_default() instead.
+ * - 'deleted': If TRUE, the function will operate on deleted fields
+ * as well as non-deleted fields. If unset or FALSE, only
+ * non-deleted fields are operated on.
+ * - 'language': A language code or an array of language codes keyed by field
+ * name. It will be used to narrow down to a single value the available
+ * languages to act on.
+ */
+function _field_invoke($op, $entity_type, $entity, &$a = NULL, &$b = NULL, $options = array()) {
+ // Merge default options.
+ $default_options = array(
+ 'default' => FALSE,
+ 'deleted' => FALSE,
+ 'language' => NULL,
+ );
+ $options += $default_options;
+
+ // Determine the list of instances to iterate on.
+ list(, , $bundle) = entity_extract_ids($entity_type, $entity);
+ $instances = _field_invoke_get_instances($entity_type, $bundle, $options);
+
+ // Iterate through the instances and collect results.
+ $return = array();
+ foreach ($instances as $instance) {
+ $field_name = $instance['field_name'];
+ $field = field_info_field($field_name);
+ $function = $options['default'] ? 'field_default_' . $op : $field['module'] . '_field_' . $op;
+ if (function_exists($function)) {
+ // Determine the list of languages to iterate on.
+ $available_languages = field_available_languages($entity_type, $field);
+ $languages = _field_language_suggestion($available_languages, $options['language'], $field_name);
+
+ foreach ($languages as $langcode) {
+ $items = isset($entity->{$field_name}[$langcode]) ? $entity->{$field_name}[$langcode] : array();
+ $result = $function($entity_type, $entity, $field, $instance, $langcode, $items, $a, $b);
+ if (isset($result)) {
+ // For hooks with array results, we merge results together.
+ // For hooks with scalar results, we collect results in an array.
+ if (is_array($result)) {
+ $return = array_merge($return, $result);
+ }
+ else {
+ $return[] = $result;
+ }
+ }
+
+ // Populate $items back in the field values, but avoid replacing missing
+ // fields with an empty array (those are not equivalent on update).
+ if ($items !== array() || isset($entity->{$field_name}[$langcode])) {
+ $entity->{$field_name}[$langcode] = $items;
+ }
+ }
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * Invoke a field hook across fields on multiple entities.
+ *
+ * @param $op
+ * Possible operations include:
+ * - load
+ * - prepare_view
+ * For all other operations, use _field_invoke() / field_invoke_default()
+ * instead.
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entities
+ * An array of entities, keyed by entity id.
+ * @param $a
+ * - The $age parameter in the 'load' operation.
+ * - Otherwise NULL.
+ * @param $b
+ * Currently always NULL.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - 'field_name': The name of the field whose operation should be
+ * invoked. By default, the operation is invoked on all the fields
+ * in the entity's bundle. NOTE: This option is not compatible with
+ * the 'deleted' option; the 'field_id' option should be used instead.
+ * - 'field_id': The id of the field whose operation should be
+ * invoked. By default, the operation is invoked on all the fields
+ * in the entity's' bundles.
+ * - 'default': A boolean value, specifying which implementation of
+ * the operation should be invoked.
+ * - if FALSE (default), the field types implementation of the operation
+ * will be invoked (hook_field_[op])
+ * - If TRUE, the default field implementation of the field operation
+ * will be invoked (field_default_[op])
+ * Internal use only. Do not explicitely set to TRUE, but use
+ * _field_invoke_multiple_default() instead.
+ * - 'deleted': If TRUE, the function will operate on deleted fields
+ * as well as non-deleted fields. If unset or FALSE, only
+ * non-deleted fields are operated on.
+ * - 'language': A language code or an array of arrays of language codes keyed
+ * by entity id and field name. It will be used to narrow down to a single
+ * value the available languages to act on.
+ *
+ * @return
+ * An array of returned values keyed by entity id.
+ */
+function _field_invoke_multiple($op, $entity_type, $entities, &$a = NULL, &$b = NULL, $options = array()) {
+ // Merge default options.
+ $default_options = array(
+ 'default' => FALSE,
+ 'deleted' => FALSE,
+ 'language' => NULL,
+ );
+ $options += $default_options;
+ $field_info = field_info_field_by_ids();
+
+ $fields = array();
+ $grouped_instances = array();
+ $grouped_entities = array();
+ $grouped_items = array();
+ $return = array();
+
+ // Go through the entities and collect the fields on which the hook should be
+ // invoked.
+ //
+ // We group fields by id, not by name, because this function can operate on
+ // deleted fields which may have non-unique names. However, entities can only
+ // contain data for a single field for each name, even if that field
+ // is deleted, so we reference field data via the
+ // $entity->$field_name property.
+ foreach ($entities as $entity) {
+ // Determine the list of instances to iterate on.
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ $instances = _field_invoke_get_instances($entity_type, $bundle, $options);
+
+ foreach ($instances as $instance) {
+ $field_id = $instance['field_id'];
+ $field_name = $instance['field_name'];
+ $field = $field_info[$field_id];
+ $function = $options['default'] ? 'field_default_' . $op : $field['module'] . '_field_' . $op;
+ if (function_exists($function)) {
+ // Add the field to the list of fields to invoke the hook on.
+ if (!isset($fields[$field_id])) {
+ $fields[$field_id] = $field;
+ }
+ // Extract the field values into a separate variable, easily accessed
+ // by hook implementations.
+ // Unless a language suggestion is provided we iterate on all the
+ // available languages.
+ $available_languages = field_available_languages($entity_type, $field);
+ $language = !empty($options['language'][$id]) ? $options['language'][$id] : $options['language'];
+ $languages = _field_language_suggestion($available_languages, $language, $field_name);
+ foreach ($languages as $langcode) {
+ $grouped_items[$field_id][$langcode][$id] = isset($entity->{$field_name}[$langcode]) ? $entity->{$field_name}[$langcode] : array();
+ // Group the instances and entities corresponding to the current
+ // field.
+ $grouped_instances[$field_id][$langcode][$id] = $instance;
+ $grouped_entities[$field_id][$langcode][$id] = $entities[$id];
+ }
+ }
+ }
+ // Initialize the return value for each entity.
+ $return[$id] = array();
+ }
+
+ // For each field, invoke the field hook and collect results.
+ foreach ($fields as $field_id => $field) {
+ $field_name = $field['field_name'];
+ $function = $options['default'] ? 'field_default_' . $op : $field['module'] . '_field_' . $op;
+ // Iterate over all the field translations.
+ foreach ($grouped_items[$field_id] as $langcode => &$items) {
+ $entities = $grouped_entities[$field_id][$langcode];
+ $instances = $grouped_instances[$field_id][$langcode];
+ $results = $function($entity_type, $entities, $field, $instances, $langcode, $items, $a, $b);
+ if (isset($results)) {
+ // Collect results by entity.
+ // For hooks with array results, we merge results together.
+ // For hooks with scalar results, we collect results in an array.
+ foreach ($results as $id => $result) {
+ if (is_array($result)) {
+ $return[$id] = array_merge($return[$id], $result);
+ }
+ else {
+ $return[$id][] = $result;
+ }
+ }
+ }
+ }
+
+ // Populate field values back in the entities, but avoid replacing missing
+ // fields with an empty array (those are not equivalent on update).
+ foreach ($grouped_entities[$field_id] as $langcode => $entities) {
+ foreach ($entities as $id => $entity) {
+ if ($grouped_items[$field_id][$langcode][$id] !== array() || isset($entity->{$field_name}[$langcode])) {
+ $entity->{$field_name}[$langcode] = $grouped_items[$field_id][$langcode][$id];
+ }
+ }
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * Invoke field.module's version of a field hook.
+ *
+ * This function invokes the field_default_[op]() function.
+ * Use _field_invoke() to invoke the field type implementation,
+ * hook_field_[op]().
+ *
+ * @see _field_invoke()
+ */
+function _field_invoke_default($op, $entity_type, $entity, &$a = NULL, &$b = NULL, $options = array()) {
+ $options['default'] = TRUE;
+ return _field_invoke($op, $entity_type, $entity, $a, $b, $options);
+}
+
+/**
+ * Invoke field.module's version of a field hook on multiple entities.
+ *
+ * This function invokes the field_default_[op]() function.
+ * Use _field_invoke_multiple() to invoke the field type implementation,
+ * hook_field_[op]().
+ *
+ * @see _field_invoke_multiple()
+ */
+function _field_invoke_multiple_default($op, $entity_type, $entities, &$a = NULL, &$b = NULL, $options = array()) {
+ $options['default'] = TRUE;
+ return _field_invoke_multiple($op, $entity_type, $entities, $a, $b, $options);
+}
+
+/**
+ * Helper for _field_invoke(): retrieves a list of instances to operate on.
+ *
+ * @param $entity_type
+ * The entity type.
+ * @param $bundle
+ * The bundle name.
+ * @param $options
+ * An associative array of options, as provided to _field_invoke(). Only the
+ * following keys are considered :
+ * - deleted
+ * - field_name
+ * - field_id
+ * See _field_invoke() for details.
+ *
+ * @return
+ * The array of selected instance definitions.
+ */
+function _field_invoke_get_instances($entity_type, $bundle, $options) {
+ if ($options['deleted']) {
+ // Deleted fields are not included in field_info_instances(), and need to
+ // be fetched from the database with field_read_instances().
+ $params = array('entity_type' => $entity_type, 'bundle' => $bundle);
+ if (isset($options['field_id'])) {
+ // Single-field mode by field id: field_read_instances() does the filtering.
+ // Single-field mode by field name is not compatible with the 'deleted'
+ // option.
+ $params['field_id'] = $options['field_id'];
+ }
+ $instances = field_read_instances($params, array('include_deleted' => TRUE));
+ }
+ elseif (isset($options['field_name'])) {
+ // Single-field mode by field name: field_info_instance() does the
+ // filtering.
+ $instances = array(field_info_instance($entity_type, $options['field_name'], $bundle));
+ }
+ else {
+ $instances = field_info_instances($entity_type, $bundle);
+ if (isset($options['field_id'])) {
+ // Single-field mode by field id: we need to loop on each instance to
+ // find the right one.
+ foreach ($instances as $instance) {
+ if ($instance['field_id'] == $options['field_id']) {
+ $instances = array($instance);
+ break;
+ }
+ }
+ }
+ }
+
+ return $instances;
+}
+
+/**
+ * Add form elements for all fields for an entity to a form structure.
+ *
+ * The form elements for the entity's fields are added by reference as direct
+ * children in the $form parameter. This parameter can be a full form structure
+ * (most common case for entity edit forms), or a sub-element of a larger form.
+ *
+ * By default, submitted field values appear at the top-level of
+ * $form_state['values']. A different location within $form_state['values'] can
+ * be specified by setting the '#parents' property on the incoming $form
+ * parameter. Because of name clashes, two instances of the same field cannot
+ * appear within the same $form element, or within the same '#parents' space.
+ *
+ * For each call to field_attach_form(), field values are processed by calling
+ * field_attach_form_validate() and field_attach_submit() on the same $form
+ * element.
+ *
+ * Sample resulting structure in $form:
+ * @code
+ * '#parents' => The location of field values in $form_state['values'],
+ * '#entity_type' => The name of the entity type,
+ * '#bundle' => The name of the bundle,
+ * // One sub-array per field appearing in the entity, keyed by field name.
+ * // The structure of the array differs slightly depending on whether the
+ * // widget is 'single-value' (provides the input for one field value,
+ * // most common case), and will therefore be repeated as many times as
+ * // needed, or 'multiple-values' (one single widget allows the input of
+ * // several values, e.g checkboxes, select box...).
+ * // The sub-array is nested into a $langcode key where $langcode has the
+ * // same value of the $langcode parameter above.
+ * // The '#language' key holds the same value of $langcode and it is used
+ * // to access the field sub-array when $langcode is unknown.
+ * 'field_foo' => array(
+ * '#tree' => TRUE,
+ * '#field_name' => The name of the field,
+ * '#language' => $langcode,
+ * $langcode => array(
+ * '#field_name' => The name of the field,
+ * '#language' => $langcode,
+ * '#field_parents' => The 'parents' space for the field in the form,
+ * equal to the #parents property of the $form parameter received by
+ * field_attach_form(),
+ * '#required' => Whether or not the field is required,
+ * '#title' => The label of the field instance,
+ * '#description' => The description text for the field instance,
+ *
+ * // Only for 'single' widgets:
+ * '#theme' => 'field_multiple_value_form',
+ * '#cardinality' => The field cardinality,
+ * // One sub-array per copy of the widget, keyed by delta.
+ * 0 => array(
+ * '#entity_type' => The name of the entity type,
+ * '#bundle' => The name of the bundle,
+ * '#field_name' => The name of the field,
+ * '#field_parents' => The 'parents' space for the field in the form,
+ * equal to the #parents property of the $form parameter received by
+ * field_attach_form(),
+ * '#title' => The title to be displayed by the widget,
+ * '#default_value' => The field value for delta 0,
+ * '#required' => Whether the widget should be marked required,
+ * '#delta' => 0,
+ * '#columns' => The array of field columns,
+ * // The remaining elements in the sub-array depend on the widget.
+ * '#type' => The type of the widget,
+ * ...
+ * ),
+ * 1 => array(
+ * ...
+ * ),
+ *
+ * // Only for multiple widgets:
+ * '#entity_type' => The name of the entity type,
+ * '#bundle' => $instance['bundle'],
+ * '#columns' => array_keys($field['columns']),
+ * // The remaining elements in the sub-array depend on the widget.
+ * '#type' => The type of the widget,
+ * ...
+ * ),
+ * ...
+ * ),
+ * )
+ * @endcode
+ *
+ * Additionally, some processing data is placed in $form_state, and can be
+ * accessed by field_form_get_state() and field_form_set_state().
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity for which to load form elements, used to initialize
+ * default form values.
+ * @param $form
+ * The form structure to fill in. This can be a full form structure, or a
+ * sub-element of a larger form. The #parents property can be set to control
+ * the location of submitted field values within $form_state['values']. If
+ * not specified, $form['#parents'] is set to an empty array, placing field
+ * values at the top-level of $form_state['values'].
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $langcode
+ * The language the field values are going to be entered, if no language
+ * is provided the default site language will be used.
+ *
+ * @see field_form_get_state()
+ * @see field_form_set_state()
+ */
+function field_attach_form($entity_type, $entity, &$form, &$form_state, $langcode = NULL) {
+ // Set #parents to 'top-level' by default.
+ $form += array('#parents' => array());
+
+ // If no language is provided use the default site language.
+ $options = array('language' => field_valid_language($langcode));
+ $form += (array) _field_invoke_default('form', $entity_type, $entity, $form, $form_state, $options);
+
+ // Add custom weight handling.
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ $form['#pre_render'][] = '_field_extra_fields_pre_render';
+ $form['#entity_type'] = $entity_type;
+ $form['#bundle'] = $bundle;
+
+ // Let other modules make changes to the form.
+ // Avoid module_invoke_all() to let parameters be taken by reference.
+ foreach (module_implements('field_attach_form') as $module) {
+ $function = $module . '_field_attach_form';
+ $function($entity_type, $entity, $form, $form_state, $langcode);
+ }
+}
+
+/**
+ * Loads fields for the current revisions of a group of entities.
+ *
+ * Loads all fields for each entity object in a group of a single entity type.
+ * The loaded field values are added directly to the entity objects.
+ *
+ * field_attach_load() is automatically called by the default entity controller
+ * class, and thus, in most cases, doesn't need to be explicitly called by the
+ * entity type module.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entities
+ * An array of entities for which to load fields, keyed by entity ID.
+ * Each entity needs to have its 'bundle', 'id' and (if applicable)
+ * 'revision' keys filled in. The function adds the loaded field data
+ * directly in the entity objects of the $entities array.
+ * @param $age
+ * FIELD_LOAD_CURRENT to load the most recent revision for all
+ * fields, or FIELD_LOAD_REVISION to load the version indicated by
+ * each entity. Defaults to FIELD_LOAD_CURRENT; use
+ * field_attach_load_revision() instead of passing FIELD_LOAD_REVISION.
+ * @param $options
+ * An associative array of additional options, with the following keys:
+ * - 'field_id': The field ID that should be loaded, instead of
+ * loading all fields, for each entity. Note that returned entities
+ * may contain data for other fields, for example if they are read
+ * from a cache.
+ * - 'deleted': If TRUE, the function will operate on deleted fields
+ * as well as non-deleted fields. If unset or FALSE, only
+ * non-deleted fields are operated on.
+ */
+function field_attach_load($entity_type, $entities, $age = FIELD_LOAD_CURRENT, $options = array()) {
+ $field_info = field_info_field_by_ids();
+ $load_current = $age == FIELD_LOAD_CURRENT;
+
+ // Merge default options.
+ $default_options = array(
+ 'deleted' => FALSE,
+ );
+ $options += $default_options;
+
+ $info = entity_get_info($entity_type);
+ // Only the most current revision of non-deleted fields for cacheable entity
+ // types can be cached.
+ $cache_read = $load_current && $info['field cache'] && empty($options['deleted']);
+ // In addition, do not write to the cache when loading a single field.
+ $cache_write = $cache_read && !isset($options['field_id']);
+
+ if (empty($entities)) {
+ return;
+ }
+
+ // Assume all entities will need to be queried. Entities found in the cache
+ // will be removed from the list.
+ $queried_entities = $entities;
+
+ // Fetch available entities from cache, if applicable.
+ if ($cache_read) {
+ // Build the list of cache entries to retrieve.
+ $cids = array();
+ foreach ($entities as $id => $entity) {
+ $cids[] = "field:$entity_type:$id";
+ }
+ $cache = cache_get_multiple($cids, 'cache_field');
+ // Put the cached field values back into the entities and remove them from
+ // the list of entities to query.
+ foreach ($entities as $id => $entity) {
+ $cid = "field:$entity_type:$id";
+ if (isset($cache[$cid])) {
+ unset($queried_entities[$id]);
+ foreach ($cache[$cid]->data as $field_name => $values) {
+ $entity->$field_name = $values;
+ }
+ }
+ }
+ }
+
+ // Fetch other entities from their storage location.
+ if ($queried_entities) {
+ // The invoke order is:
+ // - hook_field_storage_pre_load()
+ // - storage backend's hook_field_storage_load()
+ // - field-type module's hook_field_load()
+ // - hook_field_attach_load()
+
+ // Invoke hook_field_storage_pre_load(): let any module load field
+ // data before the storage engine, accumulating along the way.
+ $skip_fields = array();
+ foreach (module_implements('field_storage_pre_load') as $module) {
+ $function = $module . '_field_storage_pre_load';
+ $function($entity_type, $queried_entities, $age, $skip_fields, $options);
+ }
+
+ $instances = array();
+
+ // Collect the storage backends used by the remaining fields in the entities.
+ $storages = array();
+ foreach ($queried_entities as $entity) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ $instances = _field_invoke_get_instances($entity_type, $bundle, $options);
+
+ foreach ($instances as $instance) {
+ $field_name = $instance['field_name'];
+ $field_id = $instance['field_id'];
+ // Make sure all fields are present at least as empty arrays.
+ if (!isset($queried_entities[$id]->{$field_name})) {
+ $queried_entities[$id]->{$field_name} = array();
+ }
+ // Collect the storage backend if the field has not been loaded yet.
+ if (!isset($skip_fields[$field_id])) {
+ $field = $field_info[$field_id];
+ $storages[$field['storage']['type']][$field_id][] = $load_current ? $id : $vid;
+ }
+ }
+ }
+
+ // Invoke hook_field_storage_load() on the relevant storage backends.
+ foreach ($storages as $storage => $fields) {
+ $storage_info = field_info_storage_types($storage);
+ module_invoke($storage_info['module'], 'field_storage_load', $entity_type, $queried_entities, $age, $fields, $options);
+ }
+
+ // Invoke field-type module's hook_field_load().
+ _field_invoke_multiple('load', $entity_type, $queried_entities, $age, $options);
+
+ // Invoke hook_field_attach_load(): let other modules act on loading the
+ // entitiy.
+ module_invoke_all('field_attach_load', $entity_type, $queried_entities, $age, $options);
+
+ // Build cache data.
+ if ($cache_write) {
+ foreach ($queried_entities as $id => $entity) {
+ $data = array();
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ $instances = field_info_instances($entity_type, $bundle);
+ foreach ($instances as $instance) {
+ $data[$instance['field_name']] = $queried_entities[$id]->{$instance['field_name']};
+ }
+ $cid = "field:$entity_type:$id";
+ cache('field')->set($cid, $data);
+ }
+ }
+ }
+}
+
+/**
+ * Load all fields for previous versions of a group of entities.
+ *
+ * Loading different versions of the same entities is not supported, and should
+ * be done by separate calls to the function.
+ *
+ * field_attach_load_revision() is automatically called by the default entity
+ * controller class, and thus, in most cases, doesn't need to be explicitly
+ * called by the entity type module.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entities
+ * An array of entities for which to load fields, keyed by entity ID. Each
+ * entity needs to have its 'bundle', 'id' and (if applicable) 'revision'
+ * keys filled. The function adds the loaded field data directly in the
+ * entity objects of the $entities array.
+ * @param $options
+ * An associative array of additional options. See field_attach_load() for
+ * details.
+ */
+function field_attach_load_revision($entity_type, $entities, $options = array()) {
+ return field_attach_load($entity_type, $entities, FIELD_LOAD_REVISION, $options);
+}
+
+/**
+ * Perform field validation against the field data in an entity.
+ *
+ * This function does not perform field widget validation on form
+ * submissions. It is intended to be called during API save
+ * operations. Use field_attach_form_validate() to validate form
+ * submissions.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to validate.
+ * @throws FieldValidationException
+ * If validation errors are found, a FieldValidationException is thrown. The
+ * 'errors' property contains the array of errors, keyed by field name,
+ * language and delta.
+ */
+function field_attach_validate($entity_type, $entity) {
+ $errors = array();
+ // Check generic, field-type-agnostic errors first.
+ _field_invoke_default('validate', $entity_type, $entity, $errors);
+ // Check field-type specific errors.
+ _field_invoke('validate', $entity_type, $entity, $errors);
+
+ // Let other modules validate the entity.
+ // Avoid module_invoke_all() to let $errors be taken by reference.
+ foreach (module_implements('field_attach_validate') as $module) {
+ $function = $module . '_field_attach_validate';
+ $function($entity_type, $entity, $errors);
+ }
+
+ if ($errors) {
+ throw new FieldValidationException($errors);
+ }
+}
+
+/**
+ * Perform field validation against form-submitted field values.
+ *
+ * There are two levels of validation for fields in forms: widget
+ * validation, and field validation.
+ * - Widget validation steps are specific to a given widget's own form
+ * structure and UI metaphors. They are executed through FAPI's
+ * #element_validate property during normal form validation.
+ * - Field validation steps are common to a given field type, independently of
+ * the specific widget being used in a given form. They are defined in the
+ * field type's implementation of hook_field_validate().
+ *
+ * This function performs field validation in the context of a form
+ * submission. It converts field validation errors into form errors
+ * on the correct form elements. Fieldable entity types should call
+ * this function during their own form validation function.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity being submitted. The 'bundle', 'id' and (if applicable)
+ * 'revision' keys should be present. The actual field values will be read
+ * from $form_state['values'].
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-element of a larger form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ */
+function field_attach_form_validate($entity_type, $entity, $form, &$form_state) {
+ // Extract field values from submitted values.
+ _field_invoke_default('extract_form_values', $entity_type, $entity, $form, $form_state);
+
+ // Perform field_level validation.
+ try {
+ field_attach_validate($entity_type, $entity);
+ }
+ catch (FieldValidationException $e) {
+ // Pass field-level validation errors back to widgets for accurate error
+ // flagging.
+ foreach ($e->errors as $field_name => $field_errors) {
+ foreach ($field_errors as $langcode => $errors) {
+ $field_state = field_form_get_state($form['#parents'], $field_name, $langcode, $form_state);
+ $field_state['errors'] = $errors;
+ field_form_set_state($form['#parents'], $field_name, $langcode, $form_state, $field_state);
+ }
+ }
+ _field_invoke_default('form_errors', $entity_type, $entity, $form, $form_state);
+ }
+}
+
+/**
+ * Perform necessary operations on field data submitted by a form.
+ *
+ * Currently, this accounts for drag-and-drop reordering of
+ * field values, and filtering of empty values.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity being submitted. The 'bundle', 'id' and (if applicable)
+ * 'revision' keys should be present. The actual field values will be read
+ * from $form_state['values'].
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-element of a larger form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ */
+function field_attach_submit($entity_type, $entity, $form, &$form_state) {
+ // Extract field values from submitted values.
+ _field_invoke_default('extract_form_values', $entity_type, $entity, $form, $form_state);
+
+ _field_invoke_default('submit', $entity_type, $entity, $form, $form_state);
+
+ // Let other modules act on submitting the entity.
+ // Avoid module_invoke_all() to let $form_state be taken by reference.
+ foreach (module_implements('field_attach_submit') as $module) {
+ $function = $module . '_field_attach_submit';
+ $function($entity_type, $entity, $form, $form_state);
+ }
+}
+
+/**
+ * Perform necessary operations just before fields data get saved.
+ *
+ * We take no specific action here, we just give other
+ * modules the opportunity to act.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to process.
+ */
+function field_attach_presave($entity_type, $entity) {
+ _field_invoke('presave', $entity_type, $entity);
+
+ // Let other modules act on presaving the entity.
+ module_invoke_all('field_attach_presave', $entity_type, $entity);
+}
+
+/**
+ * Save field data for a new entity.
+ *
+ * The passed-in entity must already contain its id and (if applicable)
+ * revision id attributes.
+ * Default values (if any) will be saved for fields not present in the
+ * $entity.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to save.
+ * @return
+ * Default values (if any) will be added to the $entity parameter for fields
+ * it leaves unspecified.
+ */
+function field_attach_insert($entity_type, $entity) {
+ _field_invoke_default('insert', $entity_type, $entity);
+ _field_invoke('insert', $entity_type, $entity);
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Let any module insert field data before the storage engine, accumulating
+ // saved fields along the way.
+ $skip_fields = array();
+ foreach (module_implements('field_storage_pre_insert') as $module) {
+ $function = $module . '_field_storage_pre_insert';
+ $function($entity_type, $entity, $skip_fields);
+ }
+
+ // Collect the storage backends used by the remaining fields in the entities.
+ $storages = array();
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ $field_id = $field['id'];
+ $field_name = $field['field_name'];
+ if (!empty($entity->$field_name)) {
+ // Collect the storage backend if the field has not been written yet.
+ if (!isset($skip_fields[$field_id])) {
+ $storages[$field['storage']['type']][$field_id] = $field_id;
+ }
+ }
+ }
+
+ // Field storage backends save any remaining unsaved fields.
+ foreach ($storages as $storage => $fields) {
+ $storage_info = field_info_storage_types($storage);
+ module_invoke($storage_info['module'], 'field_storage_write', $entity_type, $entity, FIELD_STORAGE_INSERT, $fields);
+ }
+
+ // Let other modules act on inserting the entity.
+ module_invoke_all('field_attach_insert', $entity_type, $entity);
+
+ $entity_info = entity_get_info($entity_type);
+}
+
+/**
+ * Save field data for an existing entity.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to save.
+ */
+function field_attach_update($entity_type, $entity) {
+ _field_invoke('update', $entity_type, $entity);
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Let any module update field data before the storage engine, accumulating
+ // saved fields along the way.
+ $skip_fields = array();
+ foreach (module_implements('field_storage_pre_update') as $module) {
+ $function = $module . '_field_storage_pre_update';
+ $function($entity_type, $entity, $skip_fields);
+ }
+
+ // Collect the storage backends used by the remaining fields in the entities.
+ $storages = array();
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ $field_id = $field['id'];
+ $field_name = $field['field_name'];
+ // Leave the field untouched if $entity comes with no $field_name property,
+ // but empty the field if it comes as a NULL value or an empty array.
+ // Function property_exists() is slower, so we catch the more frequent
+ // cases where it's an empty array with the faster isset().
+ if (isset($entity->$field_name) || property_exists($entity, $field_name)) {
+ // Collect the storage backend if the field has not been written yet.
+ if (!isset($skip_fields[$field_id])) {
+ $storages[$field['storage']['type']][$field_id] = $field_id;
+ }
+ }
+ }
+
+ // Field storage backends save any remaining unsaved fields.
+ foreach ($storages as $storage => $fields) {
+ $storage_info = field_info_storage_types($storage);
+ module_invoke($storage_info['module'], 'field_storage_write', $entity_type, $entity, FIELD_STORAGE_UPDATE, $fields);
+ }
+
+ // Let other modules act on updating the entity.
+ module_invoke_all('field_attach_update', $entity_type, $entity);
+
+ $entity_info = entity_get_info($entity_type);
+ if ($entity_info['field cache']) {
+ cache('field')->delete("field:$entity_type:$id");
+ }
+}
+
+/**
+ * Delete field data for an existing entity. This deletes all
+ * revisions of field data for the entity.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity whose field data to delete.
+ */
+function field_attach_delete($entity_type, $entity) {
+ _field_invoke('delete', $entity_type, $entity);
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Collect the storage backends used by the fields in the entities.
+ $storages = array();
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ $field_id = $field['id'];
+ $storages[$field['storage']['type']][$field_id] = $field_id;
+ }
+
+ // Field storage backends delete their data.
+ foreach ($storages as $storage => $fields) {
+ $storage_info = field_info_storage_types($storage);
+ module_invoke($storage_info['module'], 'field_storage_delete', $entity_type, $entity, $fields);
+ }
+
+ // Let other modules act on deleting the entity.
+ module_invoke_all('field_attach_delete', $entity_type, $entity);
+
+ $entity_info = entity_get_info($entity_type);
+ if ($entity_info['field cache']) {
+ cache('field')->delete("field:$entity_type:$id");
+ }
+}
+
+/**
+ * Delete field data for a single revision of an existing entity. The
+ * passed entity must have a revision id attribute.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to save.
+ */
+function field_attach_delete_revision($entity_type, $entity) {
+ _field_invoke('delete_revision', $entity_type, $entity);
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Collect the storage backends used by the fields in the entities.
+ $storages = array();
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ $field_id = $field['id'];
+ $storages[$field['storage']['type']][$field_id] = $field_id;
+ }
+
+ // Field storage backends delete their data.
+ foreach ($storages as $storage => $fields) {
+ $storage_info = field_info_storage_types($storage);
+ module_invoke($storage_info['module'], 'field_storage_delete_revision', $entity_type, $entity, $fields);
+ }
+
+ // Let other modules act on deleting the revision.
+ module_invoke_all('field_attach_delete_revision', $entity_type, $entity);
+}
+
+/**
+ * Prepare field data prior to display.
+ *
+ * This function lets field types and formatters load additional data
+ * needed for display that is not automatically loaded during
+ * field_attach_load(). It accepts an array of entities to allow query
+ * optimisation when displaying lists of entities.
+ *
+ * field_attach_prepare_view() and field_attach_view() are two halves
+ * of the same operation. It is safe to call
+ * field_attach_prepare_view() multiple times on the same entity
+ * before calling field_attach_view() on it, but calling any Field
+ * API operation on an entity between passing that entity to these two
+ * functions may yield incorrect results.
+ *
+ * @param $entity_type
+ * The type of $entities; e.g. 'node' or 'user'.
+ * @param $entities
+ * An array of entities, keyed by entity id.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (Optional) The language the field values are to be shown in. If no language
+ * is provided the current language is used.
+ */
+function field_attach_prepare_view($entity_type, $entities, $view_mode, $langcode = NULL) {
+ $options = array('language' => array());
+
+ // To ensure hooks are only run once per entity, only process items without
+ // the _field_view_prepared flag.
+ // @todo: resolve this more generally for both entity and field level hooks.
+ $prepare = array();
+ foreach ($entities as $id => $entity) {
+ if (empty($entity->_field_view_prepared)) {
+ // Add this entity to the items to be prepared.
+ $prepare[$id] = $entity;
+
+ // Determine the actual language to display for each field, given the
+ // languages available in the field data.
+ $options['language'][$id] = field_language($entity_type, $entity, NULL, $langcode);
+
+ // Mark this item as prepared.
+ $entity->_field_view_prepared = TRUE;
+ }
+ }
+
+ $null = NULL;
+ // First let the field types do their preparation.
+ _field_invoke_multiple('prepare_view', $entity_type, $prepare, $null, $null, $options);
+ // Then let the formatters do their own specific massaging.
+ // field_default_prepare_view() takes care of dispatching to the correct
+ // formatters according to the display settings for the view mode.
+ _field_invoke_multiple_default('prepare_view', $entity_type, $prepare, $view_mode, $null, $options);
+}
+
+/**
+ * Returns a renderable array for the fields on an entity.
+ *
+ * Each field is displayed according to the display options specified in the
+ * $instance definition for the given $view_mode.
+ *
+ * field_attach_prepare_view() and field_attach_view() are two halves
+ * of the same operation. It is safe to call
+ * field_attach_prepare_view() multiple times on the same entity
+ * before calling field_attach_view() on it, but calling any Field
+ * API operation on an entity between passing that entity to these two
+ * functions may yield incorrect results.
+ *
+ * Sample structure:
+ * @code
+ * array(
+ * 'field_foo' => array(
+ * '#theme' => 'field',
+ * '#title' => the label of the field instance,
+ * '#label_display' => the label display mode,
+ * '#object' => the fieldable entity being displayed,
+ * '#entity_type' => the type of the entity being displayed,
+ * '#language' => the language of the field values being displayed,
+ * '#view_mode' => the view mode,
+ * '#field_name' => the name of the field,
+ * '#field_type' => the type of the field,
+ * '#formatter' => the name of the formatter,
+ * '#items' => the field values being displayed,
+ * // The element's children are the formatted values returned by
+ * // hook_field_formatter_view().
+ * ),
+ * );
+ * @endcode
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to render.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * The language the field values are to be shown in. If no language is
+ * provided the current language is used.
+ * @return
+ * A renderable array for the field values.
+ */
+function field_attach_view($entity_type, $entity, $view_mode, $langcode = NULL) {
+ // Determine the actual language to display for each field, given the
+ // languages available in the field data.
+ $display_language = field_language($entity_type, $entity, NULL, $langcode);
+ $options = array('language' => $display_language);
+
+ // Invoke field_default_view().
+ $null = NULL;
+ $output = _field_invoke_default('view', $entity_type, $entity, $view_mode, $null, $options);
+
+ // Add custom weight handling.
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ $output['#pre_render'][] = '_field_extra_fields_pre_render';
+ $output['#entity_type'] = $entity_type;
+ $output['#bundle'] = $bundle;
+
+ // Let other modules alter the renderable array.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'entity' => $entity,
+ 'view_mode' => $view_mode,
+ 'display' => $view_mode,
+ 'language' => $langcode,
+ );
+ drupal_alter('field_attach_view', $output, $context);
+
+ // Reset the _field_view_prepared flag set in field_attach_prepare_view(),
+ // in case the same entity is displayed with different settings later in
+ // the request.
+ unset($entity->_field_view_prepared);
+
+ return $output;
+}
+
+/**
+ * Populate the template variables with the field values available for rendering.
+ *
+ * The $variables array will be populated with all the field instance values
+ * associated with the given entity type, keyed by field name; in case of
+ * translatable fields the language currently chosen for display will be
+ * selected.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity with fields to render.
+ * @param $element
+ * The structured array containing the values ready for rendering.
+ * @param $variables
+ * The variables array is passed by reference and will be populated with field
+ * values.
+ */
+function field_attach_preprocess($entity_type, $entity, $element, &$variables) {
+ list(, , $bundle) = entity_extract_ids($entity_type, $entity);
+
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $field_name = $instance['field_name'];
+ if (isset($element[$field_name]['#language'])) {
+ $langcode = $element[$field_name]['#language'];
+ $variables[$field_name] = isset($entity->{$field_name}[$langcode]) ? $entity->{$field_name}[$langcode] : NULL;
+ }
+ }
+
+ // Let other modules make changes to the $variables array.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'entity' => $entity,
+ 'element' => $element,
+ );
+ drupal_alter('field_attach_preprocess', $variables, $context);
+}
+
+/**
+ * Prepares an entity for translation.
+ *
+ * This function is used to fill-in the form default values for Field API fields
+ * while performing entity translation. By default it copies all the source
+ * values in the given source language to the new entity and assigns them the
+ * target language.
+ *
+ * This is used as part of the 'per entity' translation pattern, which is
+ * implemented only for nodes by translation.module. Other entity types may be
+ * supported through contributed modules.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity to be prepared for translation.
+ * @param $langcode
+ * The language the entity has to be translated in.
+ * @param $source_entity
+ * The source entity holding the field values to be translated.
+ * @param $source_langcode
+ * The source language from which translate.
+ */
+function field_attach_prepare_translation($entity_type, $entity, $langcode, $source_entity, $source_langcode) {
+ $options = array('language' => $langcode);
+ // Copy source field values into the entity to be prepared.
+ _field_invoke_default('prepare_translation', $entity_type, $entity, $source_entity, $source_langcode, $options);
+ // Let field types handle their own advanced translation pattern if needed.
+ _field_invoke('prepare_translation', $entity_type, $entity, $source_entity, $source_langcode, $options);
+ // Let other modules alter the entity translation.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'langcode' => $langcode,
+ 'source_entity' => $source_entity,
+ 'source_langcode' => $source_langcode,
+ );
+ drupal_alter('field_attach_prepare_translation', $entity, $context);
+}
+
+/**
+ * Notify field.module that a new bundle was created.
+ *
+ * The default SQL-based storage doesn't need to do anything about it, but
+ * others might.
+ *
+ * @param $entity_type
+ * The entity type to which the bundle is bound.
+ * @param $bundle
+ * The name of the newly created bundle.
+ */
+function field_attach_create_bundle($entity_type, $bundle) {
+ // Clear the cache.
+ field_cache_clear();
+
+ // Let other modules act on creating the bundle.
+ module_invoke_all('field_attach_create_bundle', $entity_type, $bundle);
+}
+
+/**
+ * Notify field.module that a bundle was renamed.
+ *
+ * @param $entity_type
+ * The entity type to which the bundle is bound.
+ * @param $bundle_old
+ * The previous name of the bundle.
+ * @param $bundle_new
+ * The new name of the bundle.
+ */
+function field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ db_update('field_config_instance')
+ ->fields(array('bundle' => $bundle_new))
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle_old)
+ ->execute();
+
+ // Clear the cache.
+ field_cache_clear();
+
+ // Update bundle settings.
+ $settings = variable_get('field_bundle_settings_' . $entity_type . '__' . $bundle_old, array());
+ variable_set('field_bundle_settings_' . $entity_type . '__' . $bundle_new, $settings);
+ variable_del('field_bundle_settings_' . $entity_type . '__' . $bundle_old);
+
+ // Let other modules act on renaming the bundle.
+ module_invoke_all('field_attach_rename_bundle', $entity_type, $bundle_old, $bundle_new);
+}
+
+/**
+ * Notify field.module the a bundle was deleted.
+ *
+ * This deletes the data for the field instances as well as the field instances
+ * themselves. This function actually just marks the data and field instances
+ * and deleted, leaving the garbage collection for a separate process, because
+ * it is not always possible to delete this much data in a single page request
+ * (particularly since for some field types, the deletion is more than just a
+ * simple DELETE query).
+ *
+ * @param $entity_type
+ * The entity type to which the bundle is bound.
+ * @param $bundle
+ * The bundle to delete.
+ */
+function field_attach_delete_bundle($entity_type, $bundle) {
+ // First, delete the instances themselves. field_read_instances() must be
+ // used here since field_info_instances() does not return instances for
+ // disabled entity types or bundles.
+ $instances = field_read_instances(array('entity_type' => $entity_type, 'bundle' => $bundle), array('include_inactive' => 1));
+ foreach ($instances as $instance) {
+ field_delete_instance($instance);
+ }
+
+ // Clear the cache.
+ field_cache_clear();
+
+ // Clear bundle display settings.
+ variable_del('field_bundle_settings_' . $entity_type . '__' . $bundle);
+
+ // Let other modules act on deleting the bundle.
+ module_invoke_all('field_attach_delete_bundle', $entity_type, $bundle, $instances);
+}
+
+
+/**
+ * @} End of "defgroup field_attach"
+ */
diff --git a/core/modules/field/field.crud.inc b/core/modules/field/field.crud.inc
new file mode 100644
index 000000000000..e34c0c5282b2
--- /dev/null
+++ b/core/modules/field/field.crud.inc
@@ -0,0 +1,971 @@
+<?php
+
+/**
+ * @file
+ * Field CRUD API, handling field and field instance creation and deletion.
+ */
+
+/**
+ * @defgroup field_crud Field CRUD API
+ * @{
+ * Create, update, and delete Field API fields, bundles, and instances.
+ *
+ * Modules use this API, often in hook_install(), to create custom
+ * data structures. UI modules will use it to create a user interface.
+ *
+ * The Field CRUD API uses
+ * @link field Field API data structures @endlink.
+ */
+
+/**
+ * Creates a field.
+ *
+ * This function does not bind the field to any bundle; use
+ * field_create_instance() for that.
+ *
+ * @param $field
+ * A field definition array. The field_name and type properties are required.
+ * Other properties, if omitted, will be given the following default values:
+ * - cardinality: 1
+ * - locked: FALSE
+ * - indexes: the field-type indexes, specified by the field type's
+ * hook_field_schema(). The indexes specified in $field are added
+ * to those default indexes. It is possible to override the
+ * definition of a field-type index by providing an index with the
+ * same name, or to remove it by redefining it as an empty array
+ * of columns. Overriding field-type indexes should be done
+ * carefully, for it might seriously affect the site's performance.
+ * - settings: each omitted setting is given the default value defined in
+ * hook_field_info().
+ * - storage:
+ * - type: the storage backend specified in the 'field_storage_default'
+ * system variable.
+ * - settings: each omitted setting is given the default value specified in
+ * hook_field_storage_info().
+ * @return
+ * The $field array with the id property filled in.
+ * @throw
+ * FieldException
+ *
+ * See: @link field Field API data structures @endlink.
+ */
+function field_create_field($field) {
+ // Field name is required.
+ if (empty($field['field_name'])) {
+ throw new FieldException('Attempt to create an unnamed field.');
+ }
+ // Field type is required.
+ if (empty($field['type'])) {
+ throw new FieldException('Attempt to create a field with no type.');
+ }
+ // Field name cannot contain invalid characters.
+ if (!preg_match('/^[_a-z]+[_a-z0-9]*$/', $field['field_name'])) {
+ throw new FieldException('Attempt to create a field with invalid characters. Only lowercase alphanumeric characters and underscores are allowed, and only lowercase letters and underscore are allowed as the first character');
+ }
+
+ // Field name cannot be longer than 32 characters. We use drupal_strlen()
+ // because the DB layer assumes that column widths are given in characters,
+ // not bytes.
+ if (drupal_strlen($field['field_name']) > 32) {
+ throw new FieldException(t('Attempt to create a field with a name longer than 32 characters: %name',
+ array('%name' => $field['field_name'])));
+ }
+
+ // Ensure the field name is unique over active and disabled fields.
+ // We do not care about deleted fields.
+ $prior_field = field_read_field($field['field_name'], array('include_inactive' => TRUE));
+ if (!empty($prior_field)) {
+ $message = $prior_field['active']?
+ t('Attempt to create field name %name which already exists and is active.', array('%name' => $field['field_name'])):
+ t('Attempt to create field name %name which already exists, although it is inactive.', array('%name' => $field['field_name']));
+ throw new FieldException($message);
+ }
+
+ // Disallow reserved field names. This can't prevent all field name
+ // collisions with existing entity properties, but some is better
+ // than none.
+ foreach (entity_get_info() as $type => $info) {
+ if (in_array($field['field_name'], $info['entity keys'])) {
+ throw new FieldException(t('Attempt to create field name %name which is reserved by entity type %type.', array('%name' => $field['field_name'], '%type' => $type)));
+ }
+ }
+
+ $field += array(
+ 'entity_types' => array(),
+ 'cardinality' => 1,
+ 'translatable' => FALSE,
+ 'locked' => FALSE,
+ 'settings' => array(),
+ 'storage' => array(),
+ 'deleted' => 0,
+ );
+
+ // Check that the field type is known.
+ $field_type = field_info_field_types($field['type']);
+ if (!$field_type) {
+ throw new FieldException(t('Attempt to create a field of unknown type %type.', array('%type' => $field['type'])));
+ }
+ // Create all per-field-type properties (needed here as long as we have
+ // settings that impact column definitions).
+ $field['settings'] += field_info_field_settings($field['type']);
+ $field['module'] = $field_type['module'];
+ $field['active'] = 1;
+
+ // Provide default storage.
+ $field['storage'] += array(
+ 'type' => variable_get('field_storage_default', 'field_sql_storage'),
+ 'settings' => array(),
+ );
+ // Check that the storage type is known.
+ $storage_type = field_info_storage_types($field['storage']['type']);
+ if (!$storage_type) {
+ throw new FieldException(t('Attempt to create a field with unknown storage type %type.', array('%type' => $field['storage']['type'])));
+ }
+ // Provide default storage settings.
+ $field['storage']['settings'] += field_info_storage_settings($field['storage']['type']);
+ $field['storage']['module'] = $storage_type['module'];
+ $field['storage']['active'] = 1;
+ // Collect storage information.
+ module_load_install($field['module']);
+ $schema = (array) module_invoke($field['module'], 'field_schema', $field);
+ $schema += array('columns' => array(), 'indexes' => array(), 'foreign keys' => array());
+ // 'columns' are hardcoded in the field type.
+ $field['columns'] = $schema['columns'];
+ // 'foreign keys' are hardcoded in the field type.
+ $field['foreign keys'] = $schema['foreign keys'];
+ // 'indexes' can be both hardcoded in the field type, and specified in the
+ // incoming $field definition.
+ $field += array(
+ 'indexes' => array(),
+ );
+ $field['indexes'] += $schema['indexes'];
+
+ // The serialized 'data' column contains everything from $field that does not
+ // have its own column and is not automatically populated when the field is
+ // read.
+ $data = $field;
+ unset($data['columns'], $data['field_name'], $data['type'], $data['active'], $data['module'], $data['storage_type'], $data['storage_active'], $data['storage_module'], $data['locked'], $data['cardinality'], $data['deleted']);
+ // Additionally, do not save the 'bundles' property populated by
+ // field_info_field().
+ unset($data['bundles']);
+
+ $record = array(
+ 'field_name' => $field['field_name'],
+ 'type' => $field['type'],
+ 'module' => $field['module'],
+ 'active' => $field['active'],
+ 'storage_type' => $field['storage']['type'],
+ 'storage_module' => $field['storage']['module'],
+ 'storage_active' => $field['storage']['active'],
+ 'locked' => $field['locked'],
+ 'data' => $data,
+ 'cardinality' => $field['cardinality'],
+ 'translatable' => $field['translatable'],
+ 'deleted' => $field['deleted'],
+ );
+
+ // Store the field and get the id back.
+ drupal_write_record('field_config', $record);
+ $field['id'] = $record['id'];
+
+ // Invoke hook_field_storage_create_field after the field is
+ // complete (e.g. it has its id).
+ try {
+ // Invoke hook_field_storage_create_field after
+ // drupal_write_record() sets the field id.
+ module_invoke($storage_type['module'], 'field_storage_create_field', $field);
+ }
+ catch (Exception $e) {
+ // If storage creation failed, remove the field_config record before
+ // rethrowing the exception.
+ db_delete('field_config')
+ ->condition('id', $field['id'])
+ ->execute();
+ throw $e;
+ }
+
+ // Clear caches
+ field_cache_clear(TRUE);
+
+ // Invoke external hooks after the cache is cleared for API consistency.
+ module_invoke_all('field_create_field', $field);
+
+ return $field;
+}
+
+/**
+ * Updates a field.
+ *
+ * Any module may forbid any update for any reason. For example, the
+ * field's storage module might forbid an update if it would change
+ * the storage schema while data for the field exists. A field type
+ * module might forbid an update if it would change existing data's
+ * semantics, or if there are external dependencies on field settings
+ * that cannot be updated.
+ *
+ * @param $field
+ * A field structure. $field['field_name'] must provided; it
+ * identifies the field that will be updated to match this
+ * structure. Any other properties of the field that are not
+ * specified in $field will be left unchanged, so it is not
+ * necessary to pass in a fully populated $field structure.
+ * @return
+ * Throws a FieldException if the update cannot be performed.
+ * @see field_create_field()
+ */
+function field_update_field($field) {
+ // Check that the specified field exists.
+ $prior_field = field_read_field($field['field_name']);
+ if (empty($prior_field)) {
+ throw new FieldException('Attempt to update a non-existent field.');
+ }
+
+ // Use the prior field values for anything not specifically set by the new
+ // field to be sure that all values are set.
+ $field += $prior_field;
+ $field['settings'] += $prior_field['settings'];
+
+ // Some updates are always disallowed.
+ if ($field['type'] != $prior_field['type']) {
+ throw new FieldException("Cannot change an existing field's type.");
+ }
+ if ($field['entity_types'] != $prior_field['entity_types']) {
+ throw new FieldException("Cannot change an existing field's entity_types property.");
+ }
+ if ($field['storage']['type'] != $prior_field['storage']['type']) {
+ throw new FieldException("Cannot change an existing field's storage type.");
+ }
+
+ // Collect the new storage information, since what is in
+ // $prior_field may no longer be right.
+ module_load_install($field['module']);
+ $schema = (array) module_invoke($field['module'], 'field_schema', $field);
+ $schema += array('columns' => array(), 'indexes' => array());
+ // 'columns' are hardcoded in the field type.
+ $field['columns'] = $schema['columns'];
+ // 'indexes' can be both hardcoded in the field type, and specified in the
+ // incoming $field definition.
+ $field += array(
+ 'indexes' => array(),
+ );
+ $field['indexes'] += $schema['indexes'];
+
+ $has_data = field_has_data($field);
+
+ // See if any module forbids the update by throwing an exception.
+ foreach (module_implements('field_update_forbid') as $module) {
+ $function = $module . '_field_update_forbid';
+ $function($field, $prior_field, $has_data);
+ }
+
+ // Tell the storage engine to update the field. Do this before
+ // saving the new definition since it still might fail.
+ $storage_type = field_info_storage_types($field['storage']['type']);
+ module_invoke($storage_type['module'], 'field_storage_update_field', $field, $prior_field, $has_data);
+
+ // Save the new field definition. @todo: refactor with
+ // field_create_field.
+
+ // The serialized 'data' column contains everything from $field that does not
+ // have its own column and is not automatically populated when the field is
+ // read.
+ $data = $field;
+ unset($data['columns'], $data['field_name'], $data['type'], $data['locked'], $data['module'], $data['cardinality'], $data['active'], $data['deleted']);
+ // Additionally, do not save the 'bundles' property populated by
+ // field_info_field().
+ unset($data['bundles']);
+
+ $field['data'] = $data;
+
+ // Store the field and create the id.
+ $primary_key = array('id');
+ drupal_write_record('field_config', $field, $primary_key);
+
+ // Clear caches
+ field_cache_clear(TRUE);
+
+ // Invoke external hooks after the cache is cleared for API consistency.
+ module_invoke_all('field_update_field', $field, $prior_field, $has_data);
+}
+
+/**
+ * Reads a single field record directly from the database.
+ *
+ * Generally, you should use the field_info_field() instead.
+ *
+ * This function will not return deleted fields. Use
+ * field_read_fields() instead for this purpose.
+ *
+ * @param $field_name
+ * The field name to read.
+ * @param array $include_additional
+ * The default behavior of this function is to not return a field that
+ * is inactive. Setting
+ * $include_additional['include_inactive'] to TRUE will override this
+ * behavior.
+ * @return
+ * A field definition array, or FALSE.
+ */
+function field_read_field($field_name, $include_additional = array()) {
+ $fields = field_read_fields(array('field_name' => $field_name), $include_additional);
+ return $fields ? current($fields) : FALSE;
+}
+
+/**
+ * Reads in fields that match an array of conditions.
+ *
+ * @param array $params
+ * An array of conditions to match against.
+ * @param array $include_additional
+ * The default behavior of this function is to not return fields that
+ * are inactive or have been deleted. Setting
+ * $include_additional['include_inactive'] or
+ * $include_additional['include_deleted'] to TRUE will override this
+ * behavior.
+ * @return
+ * An array of fields matching $params. If
+ * $include_additional['include_deleted'] is TRUE, the array is keyed
+ * by field id, otherwise it is keyed by field name.
+ */
+function field_read_fields($params = array(), $include_additional = array()) {
+ $query = db_select('field_config', 'fc', array('fetch' => PDO::FETCH_ASSOC));
+ $query->fields('fc');
+
+ // Turn the conditions into a query.
+ foreach ($params as $key => $value) {
+ $query->condition($key, $value);
+ }
+ if (!isset($include_additional['include_inactive']) || !$include_additional['include_inactive']) {
+ $query
+ ->condition('fc.active', 1)
+ ->condition('fc.storage_active', 1);
+ }
+ $include_deleted = (isset($include_additional['include_deleted']) && $include_additional['include_deleted']);
+ if (!$include_deleted) {
+ $query->condition('fc.deleted', 0);
+ }
+
+ $fields = array();
+ $results = $query->execute();
+ foreach ($results as $record) {
+ $field = unserialize($record['data']);
+ $field['id'] = $record['id'];
+ $field['field_name'] = $record['field_name'];
+ $field['type'] = $record['type'];
+ $field['module'] = $record['module'];
+ $field['active'] = $record['active'];
+ $field['storage']['type'] = $record['storage_type'];
+ $field['storage']['module'] = $record['storage_module'];
+ $field['storage']['active'] = $record['storage_active'];
+ $field['locked'] = $record['locked'];
+ $field['cardinality'] = $record['cardinality'];
+ $field['translatable'] = $record['translatable'];
+ $field['deleted'] = $record['deleted'];
+
+ module_invoke_all('field_read_field', $field);
+
+ // Populate storage information.
+ module_load_install($field['module']);
+ $schema = (array) module_invoke($field['module'], 'field_schema', $field);
+ $schema += array('columns' => array(), 'indexes' => array());
+ $field['columns'] = $schema['columns'];
+
+ $field_name = $field['field_name'];
+ if ($include_deleted) {
+ $field_name = $field['id'];
+ }
+ $fields[$field_name] = $field;
+ }
+ return $fields;
+}
+
+/**
+ * Marks a field and its instances and data for deletion.
+ *
+ * @param $field_name
+ * The field name to delete.
+ */
+function field_delete_field($field_name) {
+ // Delete all non-deleted instances.
+ $field = field_info_field($field_name);
+ if (isset($field['bundles'])) {
+ foreach ($field['bundles'] as $entity_type => $bundles) {
+ foreach ($bundles as $bundle) {
+ $instance = field_info_instance($entity_type, $field_name, $bundle);
+ field_delete_instance($instance, FALSE);
+ }
+ }
+ }
+
+ // Mark field data for deletion.
+ module_invoke($field['storage']['module'], 'field_storage_delete_field', $field);
+
+ // Mark the field for deletion.
+ db_update('field_config')
+ ->fields(array('deleted' => 1))
+ ->condition('field_name', $field_name)
+ ->execute();
+
+ // Clear the cache.
+ field_cache_clear(TRUE);
+
+ module_invoke_all('field_delete_field', $field);
+}
+
+/**
+ * Creates an instance of a field, binding it to a bundle.
+ *
+ * @param $instance
+ * A field instance definition array. The field_name, entity_type and
+ * bundle properties are required. Other properties, if omitted,
+ * will be given the following default values:
+ * - label: the field name
+ * - description: empty string
+ * - required: FALSE
+ * - default_value_function: empty string
+ * - settings: each omitted setting is given the default value specified in
+ * hook_field_info().
+ * - widget:
+ * - type: the default widget specified in hook_field_info().
+ * - settings: each omitted setting is given the default value specified in
+ * hook_field_widget_info().
+ * - display:
+ * Settings for the 'default' view mode will be added if not present, and
+ * each view mode in the definition will be completed with the following
+ * default values:
+ * - label: 'above'
+ * - type: the default formatter specified in hook_field_info().
+ * - settings: each omitted setting is given the default value specified in
+ * hook_field_formatter_info().
+ * View modes not present in the definition are left empty, and the field
+ * will not be displayed in this mode.
+ *
+ * @return
+ * The $instance array with the id property filled in.
+ * @throw
+ * FieldException
+ *
+ * See: @link field Field API data structures @endlink.
+ */
+function field_create_instance($instance) {
+ $field = field_read_field($instance['field_name']);
+ if (empty($field)) {
+ throw new FieldException(t("Attempt to create an instance of a field @field_name that doesn't exist or is currently inactive.", array('@field_name' => $instance['field_name'])));
+ }
+ // Check that the required properties exists.
+ if (empty($instance['entity_type'])) {
+ throw new FieldException(t('Attempt to create an instance of field @field_name without an entity type.', array('@field_name' => $instance['field_name'])));
+ }
+ if (empty($instance['bundle'])) {
+ throw new FieldException(t('Attempt to create an instance of field @field_name without a bundle.', array('@field_name' => $instance['field_name'])));
+ }
+ // Check that the field can be attached to this entity type.
+ if (!empty($field['entity_types']) && !in_array($instance['entity_type'], $field['entity_types'])) {
+ throw new FieldException(t('Attempt to create an instance of field @field_name on forbidden entity type @entity_type.', array('@field_name' => $instance['field_name'], '@entity_type' => $instance['entity_type'])));
+ }
+
+ // Set the field id.
+ $instance['field_id'] = $field['id'];
+
+ // Note that we do *not* prevent creating a field on non-existing bundles,
+ // because that would break the 'Body as field' upgrade for contrib
+ // node types.
+
+ // TODO: Check that the widget type is known and can handle the field type ?
+ // TODO: Check that the formatters are known and can handle the field type ?
+ // TODO: Check that the display view modes are known for the entity type ?
+ // Those checks should probably happen in _field_write_instance() ?
+ // Problem : this would mean that a UI module cannot update an instance with a disabled formatter.
+
+ // Ensure the field instance is unique within the bundle.
+ // We only check for instances of active fields, since adding an instance of
+ // a disabled field is not supported.
+ $prior_instance = field_read_instance($instance['entity_type'], $instance['field_name'], $instance['bundle']);
+ if (!empty($prior_instance)) {
+ $message = t('Attempt to create an instance of field @field_name on bundle @bundle that already has an instance of that field.', array('@field_name' => $instance['field_name'], '@bundle' => $instance['bundle']));
+ throw new FieldException($message);
+ }
+
+ _field_write_instance($instance);
+
+ // Clear caches
+ field_cache_clear();
+
+ // Invoke external hooks after the cache is cleared for API consistency.
+ module_invoke_all('field_create_instance', $instance);
+
+ return $instance;
+}
+
+/**
+ * Updates an instance of a field.
+ *
+ * @param $instance
+ * An associative array representing an instance structure. The required
+ * keys and values are:
+ * - entity_type: The type of the entity the field is attached to.
+ * - bundle: The bundle this field belongs to.
+ * - field_name: The name of an existing field.
+ * Read-only_id properties are assigned automatically. Any other
+ * properties specified in $instance overwrite the existing values for
+ * the instance.
+ *
+ * @throw
+ * FieldException
+ *
+ * @see field_create_instance()
+ */
+function field_update_instance($instance) {
+ // Check that the specified field exists.
+ $field = field_read_field($instance['field_name']);
+ if (empty($field)) {
+ throw new FieldException(t('Attempt to update an instance of a nonexistent field @field.', array('@field' => $instance['field_name'])));
+ }
+
+ // Check that the field instance exists (even if it is inactive, since we
+ // want to be able to replace inactive widgets with new ones).
+ $prior_instance = field_read_instance($instance['entity_type'], $instance['field_name'], $instance['bundle'], array('include_inactive' => TRUE));
+ if (empty($prior_instance)) {
+ throw new FieldException(t("Attempt to update an instance of field @field on bundle @bundle that doesn't exist.", array('@field' => $instance['field_name'], '@bundle' => $instance['bundle'])));
+ }
+
+ $instance['id'] = $prior_instance['id'];
+ $instance['field_id'] = $prior_instance['field_id'];
+
+ _field_write_instance($instance, TRUE);
+
+ // Clear caches.
+ field_cache_clear();
+
+ module_invoke_all('field_update_instance', $instance, $prior_instance);
+}
+
+/**
+ * Stores an instance record in the field configuration database.
+ *
+ * @param $instance
+ * An instance structure.
+ * @param $update
+ * Whether this is a new or existing instance.
+ */
+function _field_write_instance($instance, $update = FALSE) {
+ $field = field_read_field($instance['field_name']);
+ $field_type = field_info_field_types($field['type']);
+
+ // Set defaults.
+ $instance += array(
+ 'settings' => array(),
+ 'display' => array(),
+ 'widget' => array(),
+ 'required' => FALSE,
+ 'label' => $instance['field_name'],
+ 'description' => '',
+ 'deleted' => 0,
+ );
+
+ // Set default instance settings.
+ $instance['settings'] += field_info_instance_settings($field['type']);
+
+ // Set default widget and settings.
+ $instance['widget'] += array(
+ // TODO: what if no 'default_widget' specified ?
+ 'type' => $field_type['default_widget'],
+ 'settings' => array(),
+ );
+ // If no weight specified, make sure the field sinks at the bottom.
+ if (!isset($instance['widget']['weight'])) {
+ $max_weight = field_info_max_weight($instance['entity_type'], $instance['bundle'], 'form');
+ $instance['widget']['weight'] = isset($max_weight) ? $max_weight + 1 : 0;
+ }
+ // Check widget module.
+ $widget_type = field_info_widget_types($instance['widget']['type']);
+ $instance['widget']['module'] = $widget_type['module'];
+ $instance['widget']['settings'] += field_info_widget_settings($instance['widget']['type']);
+
+ // Make sure there are at least display settings for the 'default' view mode,
+ // and fill in defaults for each view mode specified in the definition.
+ $instance['display'] += array(
+ 'default' => array(),
+ );
+ foreach ($instance['display'] as $view_mode => $display) {
+ $display += array(
+ 'label' => 'above',
+ 'type' => isset($field_type['default_formatter']) ? $field_type['default_formatter'] : 'hidden',
+ 'settings' => array(),
+ );
+ if ($display['type'] != 'hidden') {
+ $formatter_type = field_info_formatter_types($display['type']);
+ $display['module'] = $formatter_type['module'];
+ $display['settings'] += field_info_formatter_settings($display['type']);
+ }
+ // If no weight specified, make sure the field sinks at the bottom.
+ if (!isset($display['weight'])) {
+ $max_weight = field_info_max_weight($instance['entity_type'], $instance['bundle'], $view_mode);
+ $display['weight'] = isset($max_weight) ? $max_weight + 1 : 0;
+ }
+ $instance['display'][$view_mode] = $display;
+ }
+
+ // The serialized 'data' column contains everything from $instance that does
+ // not have its own column and is not automatically populated when the
+ // instance is read.
+ $data = $instance;
+ unset($data['id'], $data['field_id'], $data['field_name'], $data['entity_type'], $data['bundle'], $data['deleted']);
+
+ $record = array(
+ 'field_id' => $instance['field_id'],
+ 'field_name' => $instance['field_name'],
+ 'entity_type' => $instance['entity_type'],
+ 'bundle' => $instance['bundle'],
+ 'data' => $data,
+ 'deleted' => $instance['deleted'],
+ );
+ // We need to tell drupal_update_record() the primary keys to trigger an
+ // update.
+ if ($update) {
+ $record['id'] = $instance['id'];
+ $primary_key = array('id');
+ }
+ else {
+ $primary_key = array();
+ }
+ drupal_write_record('field_config_instance', $record, $primary_key);
+}
+
+/**
+ * Reads a single instance record from the database.
+ *
+ * Generally, you should use field_info_instance() instead, as it
+ * provides caching and allows other modules the opportunity to
+ * append additional formatters, widgets, and other information.
+ *
+ * @param $entity_type
+ * The type of entity to which the field is bound.
+ * @param $field_name
+ * The field name to read.
+ * @param $bundle
+ * The bundle to which the field is bound.
+ * @param array $include_additional
+ * The default behavior of this function is to not return an instance that
+ * has been deleted, or whose field is inactive. Setting
+ * $include_additional['include_inactive'] or
+ * $include_additional['include_deleted'] to TRUE will override this
+ * behavior.
+ * @return
+ * An instance structure, or FALSE.
+ */
+function field_read_instance($entity_type, $field_name, $bundle, $include_additional = array()) {
+ $instances = field_read_instances(array('entity_type' => $entity_type, 'field_name' => $field_name, 'bundle' => $bundle), $include_additional);
+ return $instances ? current($instances) : FALSE;
+}
+
+/**
+ * Reads in field instances that match an array of conditions.
+ *
+ * @param $param
+ * An array of properties to use in selecting a field
+ * instance. Valid keys include any column of the
+ * field_config_instance table. If NULL, all instances will be returned.
+ * @param $include_additional
+ * The default behavior of this function is to not return field
+ * instances that have been marked deleted, or whose field is inactive.
+ * Setting $include_additional['include_inactive'] or
+ * $include_additional['include_deleted'] to TRUE will override this
+ * behavior.
+ * @return
+ * An array of instances matching the arguments.
+ */
+function field_read_instances($params = array(), $include_additional = array()) {
+ $include_inactive = isset($include_additional['include_inactive']) && $include_additional['include_inactive'];
+ $include_deleted = isset($include_additional['include_deleted']) && $include_additional['include_deleted'];
+
+ $query = db_select('field_config_instance', 'fci', array('fetch' => PDO::FETCH_ASSOC));
+ $query->join('field_config', 'fc', 'fc.id = fci.field_id');
+ $query->fields('fci');
+
+ // Turn the conditions into a query.
+ foreach ($params as $key => $value) {
+ $query->condition('fci.' . $key, $value);
+ }
+ if (!$include_inactive) {
+ $query
+ ->condition('fc.active', 1)
+ ->condition('fc.storage_active', 1);
+ }
+ if (!$include_deleted) {
+ $query->condition('fc.deleted', 0);
+ $query->condition('fci.deleted', 0);
+ }
+
+ $instances = array();
+ $results = $query->execute();
+
+ foreach ($results as $record) {
+ // Filter out instances on unknown entity types (for instance because the
+ // module exposing them was disabled).
+ $entity_info = entity_get_info($record['entity_type']);
+ if ($include_inactive || $entity_info) {
+ $instance = unserialize($record['data']);
+ $instance['id'] = $record['id'];
+ $instance['field_id'] = $record['field_id'];
+ $instance['field_name'] = $record['field_name'];
+ $instance['entity_type'] = $record['entity_type'];
+ $instance['bundle'] = $record['bundle'];
+ $instance['deleted'] = $record['deleted'];
+
+ module_invoke_all('field_read_instance', $instance);
+ $instances[] = $instance;
+ }
+ }
+ return $instances;
+}
+
+/**
+ * Marks a field instance and its data for deletion.
+ *
+ * @param $instance
+ * An instance structure.
+ * @param $field_cleanup
+ * If TRUE, the field will be deleted as well if its last instance is being
+ * deleted. If FALSE, it is the caller's responsibility to handle the case of
+ * fields left without instances. Defaults to TRUE.
+ */
+function field_delete_instance($instance, $field_cleanup = TRUE) {
+ // Mark the field instance for deletion.
+ db_update('field_config_instance')
+ ->fields(array('deleted' => 1))
+ ->condition('field_name', $instance['field_name'])
+ ->condition('entity_type', $instance['entity_type'])
+ ->condition('bundle', $instance['bundle'])
+ ->execute();
+
+ // Clear the cache.
+ field_cache_clear();
+
+ // Mark instance data for deletion.
+ $field = field_info_field($instance['field_name']);
+ module_invoke($field['storage']['module'], 'field_storage_delete_instance', $instance);
+
+ // Let modules react to the deletion of the instance.
+ module_invoke_all('field_delete_instance', $instance);
+
+ // Delete the field itself if we just deleted its last instance.
+ if ($field_cleanup && count($field['bundles']) == 0) {
+ field_delete_field($field['field_name']);
+ }
+}
+
+/**
+ * @} End of "defgroup field_crud".
+ */
+
+/**
+ * @defgroup field_purge Field API bulk data deletion
+ * @{
+ * Clean up after Field API bulk deletion operations.
+ *
+ * Field API provides functions for deleting data attached to individual
+ * entities as well as deleting entire fields or field instances in a single
+ * operation.
+ *
+ * Deleting field data items for an entity with field_attach_delete() involves
+ * three separate operations:
+ * - Invoking the Field Type API hook_field_delete() for each field on the
+ * entity. The hook for each field type receives the entity and the specific
+ * field being deleted. A file field module might use this hook to delete
+ * uploaded files from the filesystem.
+ * - Invoking the Field Storage API hook_field_storage_delete() to remove
+ * data from the primary field storage. The hook implementation receives the
+ * entity being deleted and deletes data for all of the entity's bundle's
+ * fields.
+ * - Invoking the global Field Attach API hook_field_attach_delete() for all
+ * modules that implement it. Each hook implementation receives the entity
+ * being deleted and can operate on whichever subset of the entity's bundle's
+ * fields it chooses to.
+ *
+ * These hooks are invoked immediately when field_attach_delete() is
+ * called. Similar operations are performed for field_attach_delete_revision().
+ *
+ * When a field, bundle, or field instance is deleted, it is not practical to
+ * invoke these hooks immediately on every affected entity in a single page
+ * request; there could be thousands or millions of them. Instead, the
+ * appropriate field data items, instances, and/or fields are marked as deleted
+ * so that subsequent load or query operations will not return them. Later, a
+ * separate process cleans up, or "purges", the marked-as-deleted data by going
+ * through the three-step process described above and, finally, removing
+ * deleted field and instance records.
+ *
+ * Purging field data is made somewhat tricky by the fact that, while
+ * field_attach_delete() has a complete entity to pass to the various deletion
+ * hooks, the Field API purge process only has the field data it has previously
+ * stored. It cannot reconstruct complete original entities to pass to the
+ * deletion hooks. It is even possible that the original entity to which some
+ * Field API data was attached has been itself deleted before the field purge
+ * operation takes place.
+ *
+ * Field API resolves this problem by using "pseudo-entities" during purge
+ * operations. A pseudo-entity contains only the information from the original
+ * entity that Field API knows about: entity type, id, revision id, and
+ * bundle. It also contains the field data for whichever field instance is
+ * currently being purged. For example, suppose that the node type 'story' used
+ * to contain a field called 'subtitle' but the field was deleted. If node 37
+ * was a story with a subtitle, the pseudo-entity passed to the purge hooks
+ * would look something like this:
+ *
+ * @code
+ * $entity = stdClass Object(
+ * [nid] => 37,
+ * [vid] => 37,
+ * [type] => 'story',
+ * [subtitle] => array(
+ * [0] => array(
+ * 'value' => 'subtitle text',
+ * ),
+ * ),
+ * );
+ * @endcode
+ */
+
+/**
+ * Purges a batch of deleted Field API data, instances, or fields.
+ *
+ * This function will purge deleted field data on up to a specified maximum
+ * number of entities and then return. If a deleted field instance with no
+ * remaining data records is found, the instance itself will be purged.
+ * If a deleted field with no remaining field instances is found, the field
+ * itself will be purged.
+ *
+ * @param $batch_size
+ * The maximum number of field data records to purge before returning.
+ */
+function field_purge_batch($batch_size) {
+ // Retrieve all deleted field instances. We cannot use field_info_instances()
+ // because that function does not return deleted instances.
+ $instances = field_read_instances(array('deleted' => 1), array('include_deleted' => 1));
+
+ foreach ($instances as $instance) {
+ // field_purge_data() will need the field array.
+ $field = field_info_field_by_id($instance['field_id']);
+ // Retrieve some entities.
+ $query = new EntityFieldQuery();
+ $results = $query
+ ->fieldCondition($field)
+ ->entityCondition('bundle', $instance['bundle'])
+ ->deleted(TRUE)
+ ->range(0, $batch_size)
+ ->execute();
+
+ if ($results) {
+ foreach ($results as $entity_type => $stub_entities) {
+ field_attach_load($entity_type, $stub_entities, FIELD_LOAD_CURRENT, array('field_id' => $field['id'], 'deleted' => 1));
+ foreach ($stub_entities as $stub_entity) {
+ // Purge the data for the entity.
+ field_purge_data($entity_type, $stub_entity, $field, $instance);
+ }
+ }
+ }
+ else {
+ // No field data remains for the instance, so we can remove it.
+ field_purge_instance($instance);
+ }
+ }
+
+ // Retrieve all deleted fields. Any that have no instances can be purged.
+ $fields = field_read_fields(array('deleted' => 1), array('include_deleted' => 1));
+ foreach ($fields as $field) {
+ $instances = field_read_instances(array('field_id' => $field['id']), array('include_deleted' => 1));
+ if (empty($instances)) {
+ field_purge_field($field);
+ }
+ }
+}
+
+/**
+ * Purges the field data for a single field on a single pseudo-entity.
+ *
+ * This is basically the same as field_attach_delete() except it only applies
+ * to a single field. The entity itself is not being deleted, and it is quite
+ * possible that other field data will remain attached to it.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The pseudo-entity whose field data is being purged.
+ * @param $field
+ * The (possibly deleted) field whose data is being purged.
+ * @param $instance
+ * The deleted field instance whose data is being purged.
+ */
+function field_purge_data($entity_type, $entity, $field, $instance) {
+ // Each field type's hook_field_delete() only expects to operate on a single
+ // field at a time, so we can use it as-is for purging.
+ $options = array('field_id' => $instance['field_id'], 'deleted' => TRUE);
+ _field_invoke('delete', $entity_type, $entity, $dummy, $dummy, $options);
+
+ // Tell the field storage system to purge the data.
+ module_invoke($field['storage']['module'], 'field_storage_purge', $entity_type, $entity, $field, $instance);
+
+ // Let other modules act on purging the data.
+ foreach (module_implements('field_attach_purge') as $module) {
+ $function = $module . '_field_attach_purge';
+ $function($entity_type, $entity, $field, $instance);
+ }
+}
+
+/**
+ * Purges a field instance record from the database.
+ *
+ * This function assumes all data for the instance has already been purged, and
+ * should only be called by field_purge_batch().
+ *
+ * @param $instance
+ * The instance record to purge.
+ */
+function field_purge_instance($instance) {
+ db_delete('field_config_instance')
+ ->condition('id', $instance['id'])
+ ->execute();
+
+ // Notify the storage engine.
+ $field = field_info_field_by_id($instance['field_id']);
+ module_invoke($field['storage']['module'], 'field_storage_purge_instance', $instance);
+
+ // Clear the cache.
+ field_info_cache_clear();
+
+ // Invoke external hooks after the cache is cleared for API consistency.
+ module_invoke_all('field_purge_instance', $instance);
+}
+
+/**
+ * Purges a field record from the database.
+ *
+ * This function assumes all instances for the field has already been purged,
+ * and should only be called by field_purge_batch().
+ *
+ * @param $field
+ * The field record to purge.
+ */
+function field_purge_field($field) {
+ $instances = field_read_instances(array('field_id' => $field['id']), array('include_deleted' => 1));
+ if (count($instances) > 0) {
+ throw new FieldException(t('Attempt to purge a field @field_name that still has instances.', array('@field_name' => $field['field_name'])));
+ }
+
+ db_delete('field_config')
+ ->condition('id', $field['id'])
+ ->execute();
+
+ // Notify the storage engine.
+ module_invoke($field['storage']['module'], 'field_storage_purge_field', $field);
+
+ // Clear the cache.
+ field_info_cache_clear();
+
+ // Invoke external hooks after the cache is cleared for API consistency.
+ module_invoke_all('field_purge_field', $field);
+}
+
+/**
+ * @} End of "defgroup field_purge".
+ */
diff --git a/core/modules/field/field.default.inc b/core/modules/field/field.default.inc
new file mode 100644
index 000000000000..cb49bdb85efb
--- /dev/null
+++ b/core/modules/field/field.default.inc
@@ -0,0 +1,268 @@
+<?php
+
+/**
+ * @file
+ * Default 'implementations' of hook_field_*(): common field housekeeping.
+ *
+ * Those implementations are special, as field.module does not define any field
+ * types. Those functions take care of default stuff common to all field types.
+ * They are called through the _field_invoke_default() iterator, generally in
+ * the corresponding field_attach_[operation]() function.
+ */
+
+/**
+ * Extracts field values from submitted form values.
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * The field values. This parameter is altered by reference to receive the
+ * incoming form values.
+ * @param $form
+ * The form structure where field elements are attached to. This might be a
+ * full form structure, or a sub-element of a larger form.
+ * @param $form_state
+ * The form state.
+ */
+function field_default_extract_form_values($entity_type, $entity, $field, $instance, $langcode, &$items, $form, &$form_state) {
+ $path = array_merge($form['#parents'], array($field['field_name'], $langcode));
+ $key_exists = NULL;
+ $values = drupal_array_get_nested_value($form_state['values'], $path, $key_exists);
+ if ($key_exists) {
+ // Remove the 'value' of the 'add more' button.
+ unset($values['add_more']);
+ $items = $values;
+ }
+}
+
+/**
+ * Generic field validation handler.
+ *
+ * Possible error codes:
+ * - 'field_cardinality': The number of values exceeds the field cardinality.
+ *
+ * @see _hook_field_validate()
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ * @param $errors
+ * The array of errors, keyed by field name and by value delta, that have
+ * already been reported for the entity. The function should add its errors
+ * to this array. Each error is an associative array, with the following
+ * keys and values:
+ * - 'error': an error code (should be a string, prefixed with the module name)
+ * - 'message': the human readable message to be displayed.
+ */
+function field_default_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ // Filter out empty values.
+ $items = _field_filter_items($field, $items);
+
+ // Check that the number of values doesn't exceed the field cardinality.
+ // For form submitted values, this can only happen with 'multiple value'
+ // widgets.
+ if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && count($items) > $field['cardinality']) {
+ $errors[$field['field_name']][$langcode][0][] = array(
+ 'error' => 'field_cardinality',
+ 'message' => t('%name: this field cannot hold more than @count values.', array('%name' => $instance['label'], '@count' => $field['cardinality'])),
+ );
+ }
+}
+
+function field_default_submit($entity_type, $entity, $field, $instance, $langcode, &$items, $form, &$form_state) {
+ // Filter out empty values.
+ $items = _field_filter_items($field, $items);
+ // Reorder items to account for drag-n-drop reordering.
+ $items = _field_sort_items($field, $items);
+}
+
+/**
+ * Default field 'insert' operation.
+ *
+ * Insert default value if no $entity->$field_name entry was provided.
+ * This can happen with programmatic saves, or on form-based creation where
+ * the current user doesn't have 'edit' permission for the field.
+ */
+function field_default_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ // _field_invoke() populates $items with an empty array if the $entity has no
+ // entry for the field, so we check on the $entity itself.
+ // We also check that the current field translation is actually defined before
+ // assigning it a default value. This way we ensure that only the intended
+ // languages get a default value. Otherwise we could have default values for
+ // not yet open languages.
+ if (empty($entity) || !property_exists($entity, $field['field_name']) ||
+ (isset($entity->{$field['field_name']}[$langcode]) && count($entity->{$field['field_name']}[$langcode]) == 0)) {
+ $items = field_get_default_value($entity_type, $entity, $field, $instance, $langcode);
+ }
+}
+
+/**
+ * Invokes hook_field_formatter_prepare_view() on the relevant formatters.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entities
+ * An array of entities being displayed, keyed by entity id.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instances
+ * Array of instance structures for $field for each entity, keyed by entity
+ * id.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * Array of field values already loaded for the entities, keyed by entity id.
+ * @param $display
+ * Can be either:
+ * - the name of a view mode
+ * - or an array of display settings to use for display, as found in the
+ * 'display' entry of $instance definitions.
+ */
+function field_default_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $display) {
+ // Group entities, instances and items by formatter module.
+ $modules = array();
+ foreach ($instances as $id => $instance) {
+ if (is_string($display)) {
+ $view_mode = $display;
+ $instance_display = field_get_display($instance, $view_mode, $entities[$id]);
+ }
+ else {
+ $instance_display = $display;
+ }
+
+ if ($instance_display['type'] !== 'hidden') {
+ $module = $instance_display['module'];
+ $modules[$module] = $module;
+ $grouped_entities[$module][$id] = $entities[$id];
+ $grouped_instances[$module][$id] = $instance;
+ $grouped_displays[$module][$id] = $instance_display;
+ // hook_field_formatter_prepare_view() alters $items by reference.
+ $grouped_items[$module][$id] = &$items[$id];
+ }
+ }
+
+ foreach ($modules as $module) {
+ // Invoke hook_field_formatter_prepare_view().
+ $function = $module . '_field_formatter_prepare_view';
+ if (function_exists($function)) {
+ $function($entity_type, $grouped_entities[$module], $field, $grouped_instances[$module], $langcode, $grouped_items[$module], $grouped_displays[$module]);
+ }
+ }
+}
+
+/**
+ * Builds a renderable array for one field on one entity instance.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * A single object of type $entity_type.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * An array containing each field on $entity's bundle.
+ * @param $langcode
+ * The language associated to $items.
+ * @param $items
+ * Array of field values already loaded for the entities, keyed by entity id.
+ * @param $display
+ * Can be either:
+ * - the name of a view mode;
+ * - or an array of custom display settings, as found in the 'display' entry
+ * of $instance definitions.
+ */
+function field_default_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ $addition = array();
+
+ // Prepare incoming display specifications.
+ if (is_string($display)) {
+ $view_mode = $display;
+ $display = field_get_display($instance, $view_mode, $entity);
+ }
+ else {
+ $view_mode = '_custom_display';
+ }
+
+ if ($display['type'] !== 'hidden') {
+ // Calling the formatter function through module_invoke() can have a
+ // performance impact on pages with many fields and values.
+ $function = $display['module'] . '_field_formatter_view';
+ if (function_exists($function)) {
+ $elements = $function($entity_type, $entity, $field, $instance, $langcode, $items, $display);
+
+ if ($elements) {
+ $info = array(
+ '#theme' => 'field',
+ '#weight' => $display['weight'],
+ '#title' => $instance['label'],
+ '#access' => field_access('view', $field, $entity_type, $entity),
+ '#label_display' => $display['label'],
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ '#field_name' => $field['field_name'],
+ '#field_type' => $field['type'],
+ '#field_translatable' => $field['translatable'],
+ '#entity_type' => $entity_type,
+ '#bundle' => $bundle,
+ '#object' => $entity,
+ '#items' => $items,
+ '#formatter' => $display['type']
+ );
+
+ $addition[$field['field_name']] = array_merge($info, $elements);
+ }
+ }
+ }
+
+ return $addition;
+}
+
+/**
+ * Copies source field values into the entity to be prepared.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g. 'node' or 'user'.
+ * @param $entity
+ * The entity to be prepared for translation.
+ * @param $field
+ * The field structure for the operation.
+ * @param $instance
+ * The instance structure for $field on $entity's bundle.
+ * @param $langcode
+ * The language the entity has to be translated in.
+ * @param $items
+ * $entity->{$field['field_name']}[$langcode], or an empty array if unset.
+ * @param $source_entity
+ * The source entity holding the field values to be translated.
+ * @param $source_langcode
+ * The source language from which translate.
+ */
+function field_default_prepare_translation($entity_type, $entity, $field, $instance, $langcode, &$items, $source_entity, $source_langcode) {
+ $field_name = $field['field_name'];
+ // If the field is untranslatable keep using LANGUAGE_NONE.
+ if ($langcode == LANGUAGE_NONE) {
+ $source_langcode = LANGUAGE_NONE;
+ }
+ if (isset($source_entity->{$field_name}[$source_langcode])) {
+ $items = $source_entity->{$field_name}[$source_langcode];
+ }
+}
diff --git a/core/modules/field/field.form.inc b/core/modules/field/field.form.inc
new file mode 100644
index 000000000000..be3685d875ee
--- /dev/null
+++ b/core/modules/field/field.form.inc
@@ -0,0 +1,570 @@
+<?php
+
+/**
+ * @file
+ * Field forms management.
+ */
+
+/**
+ * Create a separate form element for each field.
+ */
+function field_default_form($entity_type, $entity, $field, $instance, $langcode, $items, &$form, &$form_state, $get_delta = NULL) {
+ // This could be called with no entity, as when a UI module creates a
+ // dummy form to set default values.
+ if ($entity) {
+ list($id, , ) = entity_extract_ids($entity_type, $entity);
+ }
+
+ $parents = $form['#parents'];
+
+ $addition = array();
+ $field_name = $field['field_name'];
+ $addition[$field_name] = array();
+
+ // Populate widgets with default values when creating a new entity.
+ if (empty($items) && empty($id)) {
+ $items = field_get_default_value($entity_type, $entity, $field, $instance, $langcode);
+ }
+
+ // Let modules alter the widget properties.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'entity' => $entity,
+ 'field' => $field,
+ 'instance' => $instance,
+ );
+ drupal_alter(array('field_widget_properties', 'field_widget_properties_' . $entity_type), $instance['widget'], $context);
+
+ // Collect widget elements.
+ $elements = array();
+ if (field_access('edit', $field, $entity_type, $entity)) {
+ // Store field information in $form_state.
+ if (!field_form_get_state($parents, $field_name, $langcode, $form_state)) {
+ $field_state = array(
+ 'field' => $field,
+ 'instance' => $instance,
+ 'items_count' => count($items),
+ 'array_parents' => array(),
+ 'errors' => array(),
+ );
+ field_form_set_state($parents, $field_name, $langcode, $form_state, $field_state);
+ }
+
+ // If field module handles multiple values for this form element, and we
+ // are displaying an individual element, process the multiple value form.
+ if (!isset($get_delta) && field_behaviors_widget('multiple values', $instance) == FIELD_BEHAVIOR_DEFAULT) {
+ $elements = field_multiple_value_form($field, $instance, $langcode, $items, $form, $form_state);
+ }
+ // If the widget is handling multiple values (e.g Options), or if we are
+ // displaying an individual element, just get a single form element and
+ // make it the $delta value.
+ else {
+ $delta = isset($get_delta) ? $get_delta : 0;
+ $function = $instance['widget']['module'] . '_field_widget_form';
+ if (function_exists($function)) {
+ $element = array(
+ '#entity_type' => $instance['entity_type'],
+ '#bundle' => $instance['bundle'],
+ '#field_name' => $field_name,
+ '#language' => $langcode,
+ '#field_parents' => $parents,
+ '#columns' => array_keys($field['columns']),
+ '#title' => check_plain($instance['label']),
+ '#description' => field_filter_xss($instance['description']),
+ // Only the first widget should be required.
+ '#required' => $delta == 0 && $instance['required'],
+ '#delta' => $delta,
+ );
+ if ($element = $function($form, $form_state, $field, $instance, $langcode, $items, $delta, $element)) {
+ // Allow modules to alter the field widget form element.
+ $context = array(
+ 'form' => $form,
+ 'field' => $field,
+ 'instance' => $instance,
+ 'langcode' => $langcode,
+ 'items' => $items,
+ 'delta' => $delta,
+ );
+ drupal_alter(array('field_widget_form', 'field_widget_' . $instance['widget']['type'] . '_form'), $element, $form_state, $context);
+
+ // If we're processing a specific delta value for a field where the
+ // field module handles multiples, set the delta in the result.
+ // For fields that handle their own processing, we can't make
+ // assumptions about how the field is structured, just merge in the
+ // returned element.
+ if (field_behaviors_widget('multiple values', $instance) == FIELD_BEHAVIOR_DEFAULT) {
+ $elements[$delta] = $element;
+ }
+ else {
+ $elements = $element;
+ }
+ }
+ }
+ }
+ }
+
+ if ($elements) {
+ // Also aid in theming of field widgets by rendering a classified
+ // container.
+ $addition[$field_name] = array(
+ '#type' => 'container',
+ '#attributes' => array(
+ 'class' => array(
+ 'field-type-' . drupal_html_class($field['type']),
+ 'field-name-' . drupal_html_class($field_name),
+ 'field-widget-' . drupal_html_class($instance['widget']['type']),
+ ),
+ ),
+ '#weight' => $instance['widget']['weight'],
+ );
+ }
+
+ // Populate the 'array_parents' information in $form_state['field'] after
+ // the form is built, so that we catch changes in the form structure performed
+ // in alter() hooks.
+ $elements['#after_build'][] = 'field_form_element_after_build';
+ $elements['#field_name'] = $field_name;
+ $elements['#language'] = $langcode;
+ $elements['#field_parents'] = $parents;
+
+ $addition[$field_name] += array(
+ '#tree' => TRUE,
+ // The '#language' key can be used to access the field's form element
+ // when $langcode is unknown.
+ '#language' => $langcode,
+ $langcode => $elements,
+ );
+
+ return $addition;
+}
+
+/**
+ * Special handling to create form elements for multiple values.
+ *
+ * Handles generic features for multiple fields:
+ * - number of widgets
+ * - AHAH-'add more' button
+ * - drag-n-drop value reordering
+ */
+function field_multiple_value_form($field, $instance, $langcode, $items, &$form, &$form_state) {
+ $field_name = $field['field_name'];
+ $parents = $form['#parents'];
+
+ // Determine the number of widgets to display.
+ switch ($field['cardinality']) {
+ case FIELD_CARDINALITY_UNLIMITED:
+ $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state);
+ $max = $field_state['items_count'];
+ break;
+
+ default:
+ $max = $field['cardinality'] - 1;
+ break;
+ }
+
+ $title = check_plain($instance['label']);
+ $description = field_filter_xss($instance['description']);
+
+ $id_prefix = implode('-', array_merge($parents, array($field_name)));
+ $wrapper_id = drupal_html_id($id_prefix . '-add-more-wrapper');
+
+ $field_elements = array();
+
+ $function = $instance['widget']['module'] . '_field_widget_form';
+ if (function_exists($function)) {
+ for ($delta = 0; $delta <= $max; $delta++) {
+ $multiple = $field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED;
+ $element = array(
+ '#entity_type' => $instance['entity_type'],
+ '#bundle' => $instance['bundle'],
+ '#field_name' => $field_name,
+ '#language' => $langcode,
+ '#field_parents' => $parents,
+ '#columns' => array_keys($field['columns']),
+ // For multiple fields, title and description are handled by the wrapping table.
+ '#title' => $multiple ? '' : $title,
+ '#description' => $multiple ? '' : $description,
+ // Only the first widget should be required.
+ '#required' => $delta == 0 && $instance['required'],
+ '#delta' => $delta,
+ '#weight' => $delta,
+ );
+ if ($element = $function($form, $form_state, $field, $instance, $langcode, $items, $delta, $element)) {
+ // Input field for the delta (drag-n-drop reordering).
+ if ($multiple) {
+ // We name the element '_weight' to avoid clashing with elements
+ // defined by widget.
+ $element['_weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for row @number', array('@number' => $delta + 1)),
+ '#title_display' => 'invisible',
+ // Note: this 'delta' is the FAPI 'weight' element's property.
+ '#delta' => $max,
+ '#default_value' => isset($items[$delta]['_weight']) ? $items[$delta]['_weight'] : $delta,
+ '#weight' => 100,
+ );
+ }
+
+ // Allow modules to alter the field widget form element.
+ $context = array(
+ 'form' => $form,
+ 'field' => $field,
+ 'instance' => $instance,
+ 'langcode' => $langcode,
+ 'items' => $items,
+ 'delta' => $delta,
+ );
+ drupal_alter(array('field_widget_form', 'field_widget_' . $instance['widget']['type'] . '_form'), $element, $form_state, $context);
+
+ $field_elements[$delta] = $element;
+ }
+ }
+
+ if ($field_elements) {
+ $field_elements += array(
+ '#theme' => 'field_multiple_value_form',
+ '#field_name' => $field['field_name'],
+ '#cardinality' => $field['cardinality'],
+ '#title' => $title,
+ '#required' => $instance['required'],
+ '#description' => $description,
+ '#prefix' => '<div id="' . $wrapper_id . '">',
+ '#suffix' => '</div>',
+ '#max_delta' => $max,
+ );
+ // Add 'add more' button, if not working with a programmed form.
+ if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED && empty($form_state['programmed'])) {
+ $field_elements['add_more'] = array(
+ '#type' => 'submit',
+ '#name' => strtr($id_prefix, '-', '_') . '_add_more',
+ '#value' => t('Add another item'),
+ '#attributes' => array('class' => array('field-add-more-submit')),
+ '#limit_validation_errors' => array(array_merge($parents, array($field_name, $langcode))),
+ '#submit' => array('field_add_more_submit'),
+ '#ajax' => array(
+ 'callback' => 'field_add_more_js',
+ 'wrapper' => $wrapper_id,
+ 'effect' => 'fade',
+ ),
+ );
+ }
+ }
+ }
+
+ return $field_elements;
+}
+
+/**
+ * Returns HTML for an individual form element.
+ *
+ * Combine multiple values into a table with drag-n-drop reordering.
+ * TODO : convert to a template.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the form element.
+ *
+ * @ingroup themeable
+ */
+function theme_field_multiple_value_form($variables) {
+ $element = $variables['element'];
+ $output = '';
+
+ if ($element['#cardinality'] > 1 || $element['#cardinality'] == FIELD_CARDINALITY_UNLIMITED) {
+ $table_id = drupal_html_id($element['#field_name'] . '_values');
+ $order_class = $element['#field_name'] . '-delta-order';
+ $required = !empty($element['#required']) ? theme('form_required_marker', $variables) : '';
+
+ $header = array(
+ array(
+ 'data' => '<label>' . t('!title: !required', array('!title' => $element['#title'], '!required' => $required)) . "</label>",
+ 'colspan' => 2,
+ 'class' => array('field-label'),
+ ),
+ t('Order'),
+ );
+ $rows = array();
+
+ // Sort items according to '_weight' (needed when the form comes back after
+ // preview or failed validation)
+ $items = array();
+ foreach (element_children($element) as $key) {
+ if ($key === 'add_more') {
+ $add_more_button = &$element[$key];
+ }
+ else {
+ $items[] = &$element[$key];
+ }
+ }
+ usort($items, '_field_sort_items_value_helper');
+
+ // Add the items as table rows.
+ foreach ($items as $key => $item) {
+ $item['_weight']['#attributes']['class'] = array($order_class);
+ $delta_element = drupal_render($item['_weight']);
+ $cells = array(
+ array('data' => '', 'class' => array('field-multiple-drag')),
+ drupal_render($item),
+ array('data' => $delta_element, 'class' => array('delta-order')),
+ );
+ $rows[] = array(
+ 'data' => $cells,
+ 'class' => array('draggable'),
+ );
+ }
+
+ $output = '<div class="form-item">';
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => $table_id, 'class' => array('field-multiple-table'))));
+ $output .= $element['#description'] ? '<div class="description">' . $element['#description'] . '</div>' : '';
+ $output .= '<div class="clearfix">' . drupal_render($add_more_button) . '</div>';
+ $output .= '</div>';
+
+ drupal_add_tabledrag($table_id, 'order', 'sibling', $order_class);
+ }
+ else {
+ foreach (element_children($element) as $key) {
+ $output .= drupal_render($element[$key]);
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * #after_build callback for field elements in a form.
+ *
+ * This stores the final location of the field within the form structure so
+ * that field_default_form_errors() can assign validation errors to the right
+ * form element.
+ *
+ * @see field_default_form_errors()
+ */
+function field_form_element_after_build($element, &$form_state) {
+ $parents = $element['#field_parents'];
+ $field_name = $element['#field_name'];
+ $langcode = $element['#language'];
+
+ $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state);
+ $field_state['array_parents'] = $element['#array_parents'];
+ field_form_set_state($parents, $field_name, $langcode, $form_state, $field_state);
+
+ return $element;
+}
+
+/**
+ * Transfer field-level validation errors to widgets.
+ */
+function field_default_form_errors($entity_type, $entity, $field, $instance, $langcode, $items, $form, &$form_state) {
+ $field_state = field_form_get_state($form['#parents'], $field['field_name'], $langcode, $form_state);
+
+ if (!empty($field_state['errors'])) {
+ $function = $instance['widget']['module'] . '_field_widget_error';
+ $function_exists = function_exists($function);
+
+ // Locate the correct element in the the form.
+ $element = drupal_array_get_nested_value($form_state['complete_form'], $field_state['array_parents']);
+
+ $multiple_widget = field_behaviors_widget('multiple values', $instance) != FIELD_BEHAVIOR_DEFAULT;
+ foreach ($field_state['errors'] as $delta => $delta_errors) {
+ // For multiple single-value widgets, pass errors by delta.
+ // For a multiple-value widget, all errors are passed to the main widget.
+ $error_element = $multiple_widget ? $element : $element[$delta];
+ foreach ($delta_errors as $error) {
+ if ($function_exists) {
+ $function($error_element, $error, $form, $form_state);
+ }
+ else {
+ // Make sure that errors are reported (even incorrectly flagged) if
+ // the widget module fails to implement hook_field_widget_error().
+ form_error($error_element, $error['error']);
+ }
+ }
+ }
+ // Reinitialize the errors list for the next submit.
+ $field_state['errors'] = array();
+ field_form_set_state($form['#parents'], $field['field_name'], $langcode, $form_state, $field_state);
+ }
+}
+
+/**
+ * Submit handler for the "Add another item" button of a field form.
+ *
+ * This handler is run regardless of whether JS is enabled or not. It makes
+ * changes to the form state. If the button was clicked with JS disabled, then
+ * the page is reloaded with the complete rebuilt form. If the button was
+ * clicked with JS enabled, then ajax_form_callback() calls field_add_more_js()
+ * to return just the changed part of the form.
+ */
+function field_add_more_submit($form, &$form_state) {
+ $button = $form_state['triggering_element'];
+
+ // Go one level up in the form, to the widgets container.
+ $element = drupal_array_get_nested_value($form, array_slice($button['#array_parents'], 0, -1));
+ $field_name = $element['#field_name'];
+ $langcode = $element['#language'];
+ $parents = $element['#field_parents'];
+
+ // Increment the items count.
+ $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state);
+ $field_state['items_count']++;
+ field_form_set_state($parents, $field_name, $langcode, $form_state, $field_state);
+
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Ajax callback in response to a new empty widget being added to the form.
+ *
+ * This returns the new page content to replace the page content made obsolete
+ * by the form submission.
+ *
+ * @see field_add_more_submit()
+ */
+function field_add_more_js($form, $form_state) {
+ $button = $form_state['triggering_element'];
+
+ // Go one level up in the form, to the widgets container.
+ $element = drupal_array_get_nested_value($form, array_slice($button['#array_parents'], 0, -1));
+ $field_name = $element['#field_name'];
+ $langcode = $element['#language'];
+ $parents = $element['#field_parents'];
+
+ $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state);
+
+ $field = $field_state['field'];
+ if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED) {
+ return;
+ }
+
+ // Add a DIV around the delta receiving the Ajax effect.
+ $delta = $element['#max_delta'];
+ $element[$delta]['#prefix'] = '<div class="ajax-new-content">' . (isset($element[$delta]['#prefix']) ? $element[$delta]['#prefix'] : '');
+ $element[$delta]['#suffix'] = (isset($element[$delta]['#suffix']) ? $element[$delta]['#suffix'] : '') . '</div>';
+
+ return $element;
+}
+
+/**
+ * Retrieves processing information about a field from $form_state.
+ *
+ * @param $parents
+ * The array of #parents where the field lives in the form.
+ * @param $field_name
+ * The field name.
+ * @param $langcode
+ * The language in which the field values are entered.
+ * @param $form_state
+ * The form state.
+ *
+ * @return
+ * An array with the following key/data pairs:
+ * - field: the field definition array,
+ * - instance: the field instance definition array,
+ * - items_count: the number of widgets to display for the field,
+ * - array_parents: the location of the field's widgets within the $form
+ * structure. This entry is populated at '#after_build' time.
+ * - errors: the array of field validation errors reported on the field. This
+ * entry is populated at field_attach_form_validate() time.
+ *
+ * @see field_form_set_state()
+ */
+function field_form_get_state($parents, $field_name, $langcode, &$form_state) {
+ $form_state_parents = _field_form_state_parents($parents, $field_name, $langcode);
+ return drupal_array_get_nested_value($form_state, $form_state_parents);
+}
+
+/**
+ * Stores processing information about a field in $form_state.
+ *
+ * @param $parents
+ * The array of #parents where the field lives in the form.
+ * @param $field_name
+ * The field name.
+ * @param $langcode
+ * The language in which the field values are entered.
+ * @param $form_state
+ * The form state.
+ * @param $field_state
+ * The array of data to store. See field_form_get_state() for the structure
+ * and content of the array.
+ *
+ * @see field_form_get_state()
+ */
+function field_form_set_state($parents, $field_name, $langcode, &$form_state, $field_state) {
+ $form_state_parents = _field_form_state_parents($parents, $field_name, $langcode);
+ drupal_array_set_nested_value($form_state, $form_state_parents, $field_state);
+}
+
+/**
+ * Returns the location of processing information within $form_state.
+ */
+function _field_form_state_parents($parents, $field_name, $langcode) {
+ // To ensure backwards compatibility on regular entity forms for widgets that
+ // still access $form_state['field'][$field_name] directly,
+ // - top-level fields (empty $parents) are placed directly under
+ // $form_state['fields'][$field_name].
+ // - Other fields are placed under
+ // $form_state['field']['#parents'][...$parents...]['#fields'][$field_name]
+ // to avoid clashes between field names and $parents parts.
+ // @todo Remove backwards compatibility in Drupal 8, and use a unique
+ // $form_state['field'][...$parents...]['#fields'][$field_name] structure.
+ if (!empty($parents)) {
+ $form_state_parents = array_merge(array('#parents'), $parents, array('#fields'));
+ }
+ else {
+ $form_state_parents = array();
+ }
+ $form_state_parents = array_merge(array('field'), $form_state_parents, array($field_name, $langcode));
+
+ return $form_state_parents;
+}
+
+/**
+ * Retrieves the field definition for a widget's helper callbacks.
+ *
+ * Widgets helper element callbacks (such as #process, #element_validate,
+ * #value_callback, ...) should use field_widget_field() and
+ * field_widget_instance() instead of field_info_field() and
+ * field_info_instance() when they need to access field or instance properties.
+ * See hook_field_widget_form() for more details.
+ *
+ * @param $element
+ * The structured array for the widget.
+ * @param $form_state
+ * The form state.
+ *
+ * @return
+ * The $field definition array for the current widget.
+ *
+ * @see field_widget_instance()
+ * @see hook_field_widget_form()
+ */
+function field_widget_field($element, $form_state) {
+ $field_state = field_form_get_state($element['#field_parents'], $element['#field_name'], $element['#language'], $form_state);
+ return $field_state['field'];
+}
+
+/**
+ * Retrieves the instance definition array for a widget's helper callbacks.
+ *
+ * Widgets helper element callbacks (such as #process, #element_validate,
+ * #value_callback, ...) should use field_widget_field() and
+ * field_widget_instance() instead of field_info_field() and
+ * field_info_instance() when they need to access field or instance properties.
+ * See hook_field_widget_form() for more details.
+ *
+ * @param $element
+ * The structured array for the widget.
+ * @param $form_state
+ * The form state.
+ *
+ * @return
+ * The $instance definition array for the current widget.
+ *
+ * @see field_widget_field()
+ * @see hook_field_widget_form()
+ */
+function field_widget_instance($element, $form_state) {
+ $field_state = field_form_get_state($element['#field_parents'], $element['#field_name'], $element['#language'], $form_state);
+ return $field_state['instance'];
+}
diff --git a/core/modules/field/field.info b/core/modules/field/field.info
new file mode 100644
index 000000000000..c61c501beb88
--- /dev/null
+++ b/core/modules/field/field.info
@@ -0,0 +1,12 @@
+name = Field
+description = Field API to add fields to entities like nodes and users.
+package = Core
+version = VERSION
+core = 8.x
+files[] = field.module
+files[] = field.attach.inc
+files[] = tests/field.test
+dependencies[] = field_sql_storage
+dependencies[] = entity
+required = TRUE
+stylesheets[all][] = theme/field.css
diff --git a/core/modules/field/field.info.inc b/core/modules/field/field.info.inc
new file mode 100644
index 000000000000..69fefca4f433
--- /dev/null
+++ b/core/modules/field/field.info.inc
@@ -0,0 +1,900 @@
+<?php
+
+/**
+ * @file
+ * Field Info API, providing information about available fields and field types.
+ */
+
+/**
+ * @defgroup field_info Field Info API
+ * @{
+ * Obtain information about Field API configuration.
+ *
+ * The Field Info API exposes information about field types, fields,
+ * instances, bundles, widget types, display formatters, behaviors,
+ * and settings defined by or with the Field API.
+ */
+
+/**
+ * Clears the field info cache without clearing the field data cache.
+ *
+ * This is useful when deleted fields or instances are purged. We
+ * need to remove the purged records, but no actual field data items
+ * are affected.
+ */
+function field_info_cache_clear() {
+ drupal_static_reset('field_view_mode_settings');
+
+ // @todo: Remove this when field_attach_*_bundle() bundle management
+ // functions are moved to the entity API.
+ entity_info_cache_clear();
+
+ _field_info_collate_types_reset();
+ _field_info_collate_fields_reset();
+}
+
+/**
+ * Collates all information on field types, widget types and related structures.
+ *
+ * @return
+ * An associative array containing:
+ * - 'field types': Array of hook_field_info() results, keyed by field_type.
+ * Each element has the following components: label, description, settings,
+ * instance_settings, default_widget, default_formatter, and behaviors
+ * from hook_field_info(), as well as module, giving the module that exposes
+ * the field type.
+ * - 'widget types': Array of hook_field_widget_info() results, keyed by
+ * widget_type. Each element has the following components: label, field
+ * types, settings, and behaviors from hook_field_widget_info(), as well
+ * as module, giving the module that exposes the widget type.
+ * - 'formatter types': Array of hook_field_formatter_info() results, keyed by
+ * formatter_type. Each element has the following components: label, field
+ * types, and behaviors from hook_field_formatter_info(), as well as
+ * module, giving the module that exposes the formatter type.
+ * - 'storage types': Array of hook_field_storage_info() results, keyed by
+ * storage type names. Each element has the following components: label,
+ * description, and settings from hook_field_storage_info(), as well as
+ * module, giving the module that exposes the storage type.
+ * - 'fieldable types': Array of hook_entity_info() results, keyed by
+ * entity_type. Each element has the following components: name, id key,
+ * revision key, bundle key, cacheable, and bundles from hook_entity_info(),
+ * as well as module, giving the module that exposes the entity type.
+ *
+ * @see _field_info_collate_types_reset()
+ */
+function _field_info_collate_types() {
+ global $language;
+
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['field_info_collate_types'] = &drupal_static(__FUNCTION__);
+ }
+ $info = &$drupal_static_fast['field_info_collate_types'];
+
+ // The _info() hooks invoked below include translated strings, so each
+ // language is cached separately.
+ $langcode = $language->language;
+
+ if (!isset($info)) {
+ if ($cached = cache('field')->get("field_info_types:$langcode")) {
+ $info = $cached->data;
+ }
+ else {
+ $info = array(
+ 'field types' => array(),
+ 'widget types' => array(),
+ 'formatter types' => array(),
+ 'storage types' => array(),
+ );
+
+ // Populate field types.
+ foreach (module_implements('field_info') as $module) {
+ $field_types = (array) module_invoke($module, 'field_info');
+ foreach ($field_types as $name => $field_info) {
+ // Provide defaults.
+ $field_info += array(
+ 'settings' => array(),
+ 'instance_settings' => array(),
+ );
+ $info['field types'][$name] = $field_info;
+ $info['field types'][$name]['module'] = $module;
+ }
+ }
+ drupal_alter('field_info', $info['field types']);
+
+ // Populate widget types.
+ foreach (module_implements('field_widget_info') as $module) {
+ $widget_types = (array) module_invoke($module, 'field_widget_info');
+ foreach ($widget_types as $name => $widget_info) {
+ // Provide defaults.
+ $widget_info += array(
+ 'settings' => array(),
+ );
+ $info['widget types'][$name] = $widget_info;
+ $info['widget types'][$name]['module'] = $module;
+ }
+ }
+ drupal_alter('field_widget_info', $info['widget types']);
+
+ // Populate formatter types.
+ foreach (module_implements('field_formatter_info') as $module) {
+ $formatter_types = (array) module_invoke($module, 'field_formatter_info');
+ foreach ($formatter_types as $name => $formatter_info) {
+ // Provide defaults.
+ $formatter_info += array(
+ 'settings' => array(),
+ );
+ $info['formatter types'][$name] = $formatter_info;
+ $info['formatter types'][$name]['module'] = $module;
+ }
+ }
+ drupal_alter('field_formatter_info', $info['formatter types']);
+
+ // Populate storage types.
+ foreach (module_implements('field_storage_info') as $module) {
+ $storage_types = (array) module_invoke($module, 'field_storage_info');
+ foreach ($storage_types as $name => $storage_info) {
+ // Provide defaults.
+ $storage_info += array(
+ 'settings' => array(),
+ );
+ $info['storage types'][$name] = $storage_info;
+ $info['storage types'][$name]['module'] = $module;
+ }
+ }
+ drupal_alter('field_storage_info', $info['storage types']);
+
+ cache('field')->set("field_info_types:$langcode", $info);
+ }
+ }
+
+ return $info;
+}
+
+/**
+ * Clear collated information on field and widget types and related structures.
+ */
+function _field_info_collate_types_reset() {
+ drupal_static_reset('_field_info_collate_types');
+ // Clear all languages.
+ cache('field')->deletePrefix('field_info_types:');
+}
+
+/**
+ * Collates all information on existing fields and instances.
+ *
+ * @return
+ * An associative array containing:
+ * - fields: Array of existing fields, keyed by field ID. This element
+ * lists deleted and non-deleted fields, but not inactive ones.
+ * Each field has an additional element, 'bundles', which is an array
+ * of all non-deleted instances of that field.
+ * - field_ids: Array of field IDs, keyed by field name. This element
+ * only lists non-deleted, active fields.
+ * - instances: Array of existing instances, keyed by entity type, bundle
+ * name and field name. This element only lists non-deleted instances
+ * whose field is active.
+ *
+ * @see _field_info_collate_fields_reset()
+ */
+function _field_info_collate_fields() {
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['field_info_collate_fields'] = &drupal_static(__FUNCTION__);
+ }
+ $info = &$drupal_static_fast['field_info_collate_fields'];
+
+ if (!isset($info)) {
+ if ($cached = cache('field')->get('field_info_fields')) {
+ $info = $cached->data;
+ }
+ else {
+ $definitions = array(
+ 'field_ids' => field_read_fields(array(), array('include_deleted' => 1)),
+ 'instances' => field_read_instances(),
+ );
+
+ // Populate 'fields' with all fields, keyed by ID.
+ $info['fields'] = array();
+ foreach ($definitions['field_ids'] as $key => $field) {
+ $info['fields'][$key] = $definitions['field_ids'][$key] = _field_info_prepare_field($field);
+ }
+
+ // Build an array of field IDs for non-deleted fields, keyed by name.
+ $info['field_ids'] = array();
+ foreach ($info['fields'] as $key => $field) {
+ if (!$field['deleted']) {
+ $info['field_ids'][$field['field_name']] = $key;
+ }
+ }
+
+ // Populate 'instances'. Only non-deleted instances are considered.
+ $info['instances'] = array();
+ foreach (field_info_bundles() as $entity_type => $bundles) {
+ foreach ($bundles as $bundle => $bundle_info) {
+ $info['instances'][$entity_type][$bundle] = array();
+ }
+ }
+ foreach ($definitions['instances'] as $instance) {
+ $field = $info['fields'][$instance['field_id']];
+ $instance = _field_info_prepare_instance($instance, $field);
+ $info['instances'][$instance['entity_type']][$instance['bundle']][$instance['field_name']] = $instance;
+ // Enrich field definitions with the list of bundles where they have
+ // instances. NOTE: Deleted fields in $info['field_ids'] are not
+ // enriched because all of their instances are deleted, too, and
+ // are thus not in $definitions['instances'].
+ $info['fields'][$instance['field_id']]['bundles'][$instance['entity_type']][] = $instance['bundle'];
+ }
+
+ // Populate 'extra_fields'.
+ $extra = module_invoke_all('field_extra_fields');
+ drupal_alter('field_extra_fields', $extra);
+ // Merge in saved settings.
+ foreach ($extra as $entity_type => $bundles) {
+ foreach ($bundles as $bundle => $extra_fields) {
+ $extra_fields = _field_info_prepare_extra_fields($extra_fields, $entity_type, $bundle);
+ $info['extra_fields'][$entity_type][$bundle] = $extra_fields;
+ }
+ }
+
+ cache('field')->set('field_info_fields', $info);
+ }
+ }
+
+ return $info;
+}
+
+/**
+ * Clear collated information on existing fields and instances.
+ */
+function _field_info_collate_fields_reset() {
+ drupal_static_reset('_field_info_collate_fields');
+ cache('field')->delete('field_info_fields');
+}
+
+/**
+ * Prepares a field definition for the current run-time context.
+ *
+ * Since the field was last saved or updated, new field settings can be
+ * expected.
+ *
+ * @param $field
+ * The raw field structure as read from the database.
+ */
+function _field_info_prepare_field($field) {
+ // Make sure all expected field settings are present.
+ $field['settings'] += field_info_field_settings($field['type']);
+ $field['storage']['settings'] += field_info_storage_settings($field['storage']['type']);
+
+ // Add storage details.
+ $details = (array) module_invoke($field['storage']['module'], 'field_storage_details', $field);
+ drupal_alter('field_storage_details', $details, $field, $instance);
+ $field['storage']['details'] = $details;
+
+ // Initialize the 'bundles' list.
+ $field['bundles'] = array();
+
+ return $field;
+}
+
+/**
+ * Prepares an instance definition for the current run-time context.
+ *
+ * Since the instance was last saved or updated, a number of things might have
+ * changed: widgets or formatters disabled, new settings expected, new view
+ * modes added...
+ *
+ * @param $instance
+ * The raw instance structure as read from the database.
+ * @param $field
+ * The field structure for the instance.
+ *
+ * @return
+ * Field instance array.
+ */
+function _field_info_prepare_instance($instance, $field) {
+ // Make sure all expected instance settings are present.
+ $instance['settings'] += field_info_instance_settings($field['type']);
+
+ // Set a default value for the instance.
+ if (field_behaviors_widget('default value', $instance) == FIELD_BEHAVIOR_DEFAULT && !isset($instance['default_value'])) {
+ $instance['default_value'] = NULL;
+ }
+
+ $instance['widget'] = _field_info_prepare_instance_widget($field, $instance['widget']);
+
+ foreach ($instance['display'] as $view_mode => $display) {
+ $instance['display'][$view_mode] = _field_info_prepare_instance_display($field, $display);
+ }
+
+ // Fallback to 'hidden' for view modes configured to use custom display
+ // settings, and for which the instance has no explicit settings.
+ $entity_info = entity_get_info($instance['entity_type']);
+ $view_modes = array_merge(array('default'), array_keys($entity_info['view modes']));
+ $view_mode_settings = field_view_mode_settings($instance['entity_type'], $instance['bundle']);
+ foreach ($view_modes as $view_mode) {
+ if ($view_mode == 'default' || !empty($view_mode_settings[$view_mode]['custom_settings'])) {
+ if (!isset($instance['display'][$view_mode])) {
+ $instance['display'][$view_mode] = array(
+ 'type' => 'hidden',
+ 'label' => 'above',
+ 'settings' => array(),
+ 'weight' => 0,
+ );
+ }
+ }
+ }
+
+ return $instance;
+}
+
+/**
+ * Adapts display specifications to the current run-time context.
+ *
+ * @param $field
+ * The field structure for the instance.
+ * @param $display
+ * Display specifications as found in
+ * $instance['display']['some_view_mode'].
+ */
+function _field_info_prepare_instance_display($field, $display) {
+ $field_type = field_info_field_types($field['type']);
+
+ // Fill in default values.
+ $display += array(
+ 'label' => 'above',
+ 'type' => $field_type['default_formatter'],
+ 'settings' => array(),
+ 'weight' => 0,
+ );
+ if ($display['type'] != 'hidden') {
+ $formatter_type = field_info_formatter_types($display['type']);
+ // Fallback to default formatter if formatter type is not available.
+ if (!$formatter_type) {
+ $display['type'] = $field_type['default_formatter'];
+ $formatter_type = field_info_formatter_types($display['type']);
+ }
+ $display['module'] = $formatter_type['module'];
+ // Fill in default settings for the formatter.
+ $display['settings'] += field_info_formatter_settings($display['type']);
+ }
+
+ return $display;
+}
+
+/**
+ * Prepares widget specifications for the current run-time context.
+ *
+ * @param $field
+ * The field structure for the instance.
+ * @param $widget
+ * Widget specifications as found in $instance['widget'].
+ */
+function _field_info_prepare_instance_widget($field, $widget) {
+ $field_type = field_info_field_types($field['type']);
+
+ // Fill in default values.
+ $widget += array(
+ 'type' => $field_type['default_widget'],
+ 'settings' => array(),
+ 'weight' => 0,
+ );
+
+ $widget_type = field_info_widget_types($widget['type']);
+ // Fallback to default formatter if formatter type is not available.
+ if (!$widget_type) {
+ $widget['type'] = $field_type['default_widget'];
+ $widget_type = field_info_widget_types($widget['type']);
+ }
+ $widget['module'] = $widget_type['module'];
+ // Fill in default settings for the widget.
+ $widget['settings'] += field_info_widget_settings($widget['type']);
+
+ return $widget;
+}
+
+/**
+ * Prepares 'extra fields' for the current run-time context.
+ *
+ * @param $extra_fields
+ * The array of extra fields, as collected in hook_field_extra_fields().
+ * @param $entity_type
+ * The entity type.
+ * @param $bundle
+ * The bundle name.
+ */
+function _field_info_prepare_extra_fields($extra_fields, $entity_type, $bundle) {
+ $entity_type_info = entity_get_info($entity_type);
+ $bundle_settings = field_bundle_settings($entity_type, $bundle);
+ $extra_fields += array('form' => array(), 'display' => array());
+
+ $result = array();
+ // Extra fields in forms.
+ foreach ($extra_fields['form'] as $name => $field_data) {
+ $settings = isset($bundle_settings['extra_fields']['form'][$name]) ? $bundle_settings['extra_fields']['form'][$name] : array();
+ if (isset($settings['weight'])) {
+ $field_data['weight'] = $settings['weight'];
+ }
+ $result['form'][$name] = $field_data;
+ }
+
+ // Extra fields in displayed entities.
+ $data = $extra_fields['display'];
+ foreach ($extra_fields['display'] as $name => $field_data) {
+ $settings = isset($bundle_settings['extra_fields']['display'][$name]) ? $bundle_settings['extra_fields']['display'][$name] : array();
+ $view_modes = array_merge(array('default'), array_keys($entity_type_info['view modes']));
+ foreach ($view_modes as $view_mode) {
+ if (isset($settings[$view_mode])) {
+ $field_data['display'][$view_mode] = $settings[$view_mode];
+ }
+ else {
+ $field_data['display'][$view_mode] = array(
+ 'weight' => $field_data['weight'],
+ 'visible' => TRUE,
+ );
+ }
+ }
+ unset($field_data['weight']);
+ $result['display'][$name] = $field_data;
+ }
+
+ return $result;
+}
+
+/**
+ * Determines the behavior of a widget with respect to an operation.
+ *
+ * @param $op
+ * The name of the operation. Currently supported: 'default value',
+ * 'multiple values'.
+ * @param $instance
+ * The field instance array.
+ *
+ * @return
+ * One of these values:
+ * - FIELD_BEHAVIOR_NONE: Do nothing for this operation.
+ * - FIELD_BEHAVIOR_CUSTOM: Use the widget's callback function.
+ * - FIELD_BEHAVIOR_DEFAULT: Use field.module default behavior.
+ */
+function field_behaviors_widget($op, $instance) {
+ $info = field_info_widget_types($instance['widget']['type']);
+ return isset($info['behaviors'][$op]) ? $info['behaviors'][$op] : FIELD_BEHAVIOR_DEFAULT;
+}
+
+/**
+ * Returns information about field types from hook_field_info().
+ *
+ * @param $field_type
+ * (optional) A field type name. If omitted, all field types will be
+ * returned.
+ *
+ * @return
+ * Either a field type description, as provided by hook_field_info(), or an
+ * array of all existing field types, keyed by field type name.
+ */
+function field_info_field_types($field_type = NULL) {
+ $info = _field_info_collate_types();
+ $field_types = $info['field types'];
+ if ($field_type) {
+ if (isset($field_types[$field_type])) {
+ return $field_types[$field_type];
+ }
+ }
+ else {
+ return $field_types;
+ }
+}
+
+/**
+ * Returns information about field widgets from hook_field_widget_info().
+ *
+ * @param $widget_type
+ * (optional) A widget type name. If omitted, all widget types will be
+ * returned.
+ *
+ * @return
+ * Either a single widget type description, as provided by
+ * hook_field_widget_info(), or an array of all existing widget types, keyed
+ * by widget type name.
+ */
+function field_info_widget_types($widget_type = NULL) {
+ $info = _field_info_collate_types();
+ $widget_types = $info['widget types'];
+ if ($widget_type) {
+ if (isset($widget_types[$widget_type])) {
+ return $widget_types[$widget_type];
+ }
+ }
+ else {
+ return $widget_types;
+ }
+}
+
+/**
+ * Returns information about field formatters from hook_field_formatter_info().
+ *
+ * @param $formatter_type
+ * (optional) A formatter type name. If omitted, all formatter types will be
+ * returned.
+ *
+ * @return
+ * Either a single formatter type description, as provided by
+ * hook_field_formatter_info(), or an array of all existing formatter types,
+ * keyed by formatter type name.
+ */
+function field_info_formatter_types($formatter_type = NULL) {
+ $info = _field_info_collate_types();
+ $formatter_types = $info['formatter types'];
+ if ($formatter_type) {
+ if (isset($formatter_types[$formatter_type])) {
+ return $formatter_types[$formatter_type];
+ }
+ }
+ else {
+ return $formatter_types;
+ }
+}
+
+/**
+ * Returns information about field storage from hook_field_storage_info().
+ *
+ * @param $storage_type
+ * (optional) A storage type name. If omitted, all storage types will be
+ * returned.
+ *
+ * @return
+ * Either a storage type description, as provided by
+ * hook_field_storage_info(), or an array of all existing storage types,
+ * keyed by storage type name.
+ */
+function field_info_storage_types($storage_type = NULL) {
+ $info = _field_info_collate_types();
+ $storage_types = $info['storage types'];
+ if ($storage_type) {
+ if (isset($storage_types[$storage_type])) {
+ return $storage_types[$storage_type];
+ }
+ }
+ else {
+ return $storage_types;
+ }
+}
+
+/**
+ * Returns information about existing bundles.
+ *
+ * @param $entity_type
+ * The type of entity; e.g. 'node' or 'user'.
+ *
+ * @return
+ * An array of bundles for the $entity_type keyed by bundle name,
+ * or, if no $entity_type was provided, the array of all existing bundles,
+ * keyed by entity type.
+ */
+function field_info_bundles($entity_type = NULL) {
+ $info = entity_get_info();
+
+ if ($entity_type) {
+ return isset($info[$entity_type]['bundles']) ? $info[$entity_type]['bundles'] : array();
+ }
+
+ $bundles = array();
+ foreach ($info as $type => $entity_info) {
+ $bundles[$type] = $entity_info['bundles'];
+ }
+ return $bundles;
+}
+
+/**
+ * Returns all field definitions.
+ *
+ * @return
+ * An array of field definitions, keyed by field name. Each field has an
+ * additional property, 'bundles', which is an array of all the bundles to
+ * which this field belongs keyed by entity type.
+ */
+function field_info_fields() {
+ $fields = array();
+ $info = _field_info_collate_fields();
+ foreach ($info['fields'] as $key => $field) {
+ if (!$field['deleted']) {
+ $fields[$field['field_name']] = $field;
+ }
+ }
+ return $fields;
+}
+
+/**
+ * Returns data about an individual field, given a field name.
+ *
+ * @param $field_name
+ * The name of the field to retrieve. $field_name can only refer to a
+ * non-deleted, active field. Use field_read_fields() to retrieve information
+ * on deleted or inactive fields.
+ *
+ * @return
+ * The field array, as returned by field_read_fields(), with an
+ * additional element 'bundles', whose value is an array of all the bundles
+ * this field belongs to keyed by entity type.
+ *
+ * @see field_info_field_by_id()
+ */
+function field_info_field($field_name) {
+ $info = _field_info_collate_fields();
+ if (isset($info['field_ids'][$field_name])) {
+ return $info['fields'][$info['field_ids'][$field_name]];
+ }
+}
+
+/**
+ * Returns data about an individual field, given a field ID.
+ *
+ * @param $field_id
+ * The id of the field to retrieve. $field_id can refer to a
+ * deleted field.
+ *
+ * @return
+ * The field array, as returned by field_read_fields(), with an
+ * additional element 'bundles', whose value is an array of all the bundles
+ * this field belongs to.
+ *
+ * @see field_info_field()
+ */
+function field_info_field_by_id($field_id) {
+ $info = _field_info_collate_fields();
+ if (isset($info['fields'][$field_id])) {
+ return $info['fields'][$field_id];
+ }
+}
+
+/**
+ * Returns the same data as field_info_field_by_id() for every field.
+ *
+ * This function is typically used when handling all fields of some entities
+ * to avoid thousands of calls to field_info_field_by_id().
+ *
+ * @return
+ * An array, each key is a field ID and the values are field arrays as
+ * returned by field_read_fields(), with an additional element 'bundles',
+ * whose value is an array of all the bundle this field belongs to.
+ *
+ * @see field_info_field()
+ * @see field_info_field_by_id()
+ */
+function field_info_field_by_ids() {
+ $info = _field_info_collate_fields();
+ return $info['fields'];
+}
+
+/**
+ * Retrieves information about field instances.
+ *
+ * @param $entity_type
+ * The entity type for which to return instances.
+ * @param $bundle_name
+ * The bundle name for which to return instances.
+ *
+ * @return
+ * If $entity_type is not set, return all instances keyed by entity type and
+ * bundle name. If $entity_type is set, return all instances for that entity
+ * type, keyed by bundle name. If $entity_type and $bundle_name are set, return
+ * all instances for that bundle.
+ */
+function field_info_instances($entity_type = NULL, $bundle_name = NULL) {
+ $info = _field_info_collate_fields();
+ if (!isset($entity_type)) {
+ return $info['instances'];
+ }
+ if (!isset($bundle_name)) {
+ return $info['instances'][$entity_type];
+ }
+ if (isset($info['instances'][$entity_type][$bundle_name])) {
+ return $info['instances'][$entity_type][$bundle_name];
+ }
+ return array();
+}
+
+/**
+ * Returns an array of instance data for a specific field and bundle.
+ *
+ * @param $entity_type
+ * The entity type for the instance.
+ * @param $field_name
+ * The field name for the instance.
+ * @param $bundle_name
+ * The bundle name for the instance.
+ */
+function field_info_instance($entity_type, $field_name, $bundle_name) {
+ $info = _field_info_collate_fields();
+ if (isset($info['instances'][$entity_type][$bundle_name][$field_name])) {
+ return $info['instances'][$entity_type][$bundle_name][$field_name];
+ }
+}
+
+/**
+ * Returns a list and settings of pseudo-field elements in a given bundle.
+ *
+ * If $context is 'form', an array with the following structure:
+ * @code
+ * array(
+ * 'name_of_pseudo_field_component' => array(
+ * 'label' => The human readable name of the component,
+ * 'description' => A short description of the component content,
+ * 'weight' => The weight of the component in edit forms,
+ * ),
+ * 'name_of_other_pseudo_field_component' => array(
+ * // ...
+ * ),
+ * );
+ * @endcode
+ *
+ * If $context is 'display', an array with the following structure:
+ * @code
+ * array(
+ * 'name_of_pseudo_field_component' => array(
+ * 'label' => The human readable name of the component,
+ * 'description' => A short description of the component content,
+ * // One entry per view mode, including the 'default' mode:
+ * 'display' => array(
+ * 'default' => array(
+ * 'weight' => The weight of the component in displayed entities in
+ * this view mode,
+ * 'visible' => TRUE if the component is visible, FALSE if hidden, in
+ * displayed entities in this view mode,
+ * ),
+ * 'teaser' => array(
+ * // ...
+ * ),
+ * ),
+ * ),
+ * 'name_of_other_pseudo_field_component' => array(
+ * // ...
+ * ),
+ * );
+ * @endcode
+ *
+ * @param $entity_type
+ * The type of entity; e.g. 'node' or 'user'.
+ * @param $bundle
+ * The bundle name.
+ * @param $context
+ * The context for which the list of pseudo-fields is requested. Either
+ * 'form' or 'display'.
+ *
+ * @return
+ * The array of pseudo-field elements in the bundle.
+ */
+function field_info_extra_fields($entity_type, $bundle, $context) {
+ $info = _field_info_collate_fields();
+ if (isset($info['extra_fields'][$entity_type][$bundle][$context])) {
+ return $info['extra_fields'][$entity_type][$bundle][$context];
+ }
+ return array();
+}
+
+/**
+ * Returns the maximum weight of all the components in an entity.
+ *
+ * This includes fields, 'extra_fields', and other components added by
+ * third-party modules (e.g. field_group).
+ *
+ * @param $entity_type
+ * The type of entity; e.g. 'node' or 'user'.
+ * @param $bundle
+ * The bundle name.
+ * @param $context
+ * The context for which the maximum weight is requested. Either 'form', or
+ * the name of a view mode.
+ * @return
+ * The maximum weight of the entity's components, or NULL if no components
+ * were found.
+ */
+function field_info_max_weight($entity_type, $bundle, $context) {
+ $weights = array();
+
+ // Collect weights for fields.
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ if ($context == 'form') {
+ $weights[] = $instance['widget']['weight'];
+ }
+ elseif (isset($instance['display'][$context]['weight'])) {
+ $weights[] = $instance['display'][$context]['weight'];
+ }
+ }
+ // Collect weights for extra fields.
+ foreach (field_info_extra_fields($entity_type, $bundle, $context) as $extra) {
+ $weights[] = $extra['weight'];
+ }
+
+ // Let other modules feedback about their own additions.
+ $weights = array_merge($weights, module_invoke_all('field_info_max_weight', $entity_type, $bundle, $context));
+ $max_weight = $weights ? max($weights) : NULL;
+
+ return $max_weight;
+}
+
+/**
+ * Returns a field type's default settings.
+ *
+ * @param $type
+ * A field type name.
+ *
+ * @return
+ * The field type's default settings, as provided by hook_field_info(), or an
+ * empty array if type or settings are not defined.
+ */
+function field_info_field_settings($type) {
+ $info = field_info_field_types($type);
+ return isset($info['settings']) ? $info['settings'] : array();
+}
+
+/**
+ * Returns a field type's default instance settings.
+ *
+ * @param $type
+ * A field type name.
+ *
+ * @return
+ * The field type's default instance settings, as provided by
+ * hook_field_info(), or an empty array if type or settings are not defined.
+ */
+function field_info_instance_settings($type) {
+ $info = field_info_field_types($type);
+ return isset($info['instance_settings']) ? $info['instance_settings'] : array();
+}
+
+/**
+ * Returns a field widget's default settings.
+ *
+ * @param $type
+ * A widget type name.
+ *
+ * @return
+ * The widget type's default settings, as provided by
+ * hook_field_widget_info(), or an empty array if type or settings are
+ * undefined.
+ */
+function field_info_widget_settings($type) {
+ $info = field_info_widget_types($type);
+ return isset($info['settings']) ? $info['settings'] : array();
+}
+
+/**
+ * Returns a field formatter's default settings.
+ *
+ * @param $type
+ * A field formatter type name.
+ *
+ * @return
+ * The formatter type's default settings, as provided by
+ * hook_field_formatter_info(), or an empty array if type or settings are
+ * undefined.
+ */
+function field_info_formatter_settings($type) {
+ $info = field_info_formatter_types($type);
+ return isset($info['settings']) ? $info['settings'] : array();
+}
+
+/**
+ * Returns a field storage type's default settings.
+ *
+ * @param $type
+ * A field storage type name.
+ *
+ * @return
+ * The storage type's default settings, as provided by
+ * hook_field_storage_info(), or an empty array if type or settings are
+ * undefined.
+ */
+function field_info_storage_settings($type) {
+ $info = field_info_storage_types($type);
+ return isset($info['settings']) ? $info['settings'] : array();
+}
+
+/**
+ * @} End of "defgroup field_info"
+ */
diff --git a/core/modules/field/field.install b/core/modules/field/field.install
new file mode 100644
index 000000000000..16b09e1c9251
--- /dev/null
+++ b/core/modules/field/field.install
@@ -0,0 +1,377 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the field module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function field_schema() {
+ // Static (meta) tables.
+ $schema['field_config'] = array(
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'The primary identifier for a field',
+ ),
+ 'field_name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'description' => 'The name of this field. Non-deleted field names are unique, but multiple deleted fields can have the same name.',
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'description' => 'The type of this field.',
+ ),
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The module that implements the field type.',
+ ),
+ 'active' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Boolean indicating whether the module that implements the field type is enabled.',
+ ),
+ 'storage_type' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'description' => 'The storage backend for the field.',
+ ),
+ 'storage_module' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The module that implements the storage backend.',
+ ),
+ 'storage_active' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Boolean indicating whether the module that implements the storage backend is enabled.',
+ ),
+ 'locked' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => '@TODO',
+ ),
+ 'data' => array(
+ 'type' => 'blob',
+ 'size' => 'big',
+ 'not null' => TRUE,
+ 'serialize' => TRUE,
+ 'description' => 'Serialized data containing the field properties that do not warrant a dedicated column.',
+ ),
+ 'cardinality' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'translatable' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'deleted' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('id'),
+ 'indexes' => array(
+ 'field_name' => array('field_name'),
+ // Used by field_read_fields().
+ 'active' => array('active'),
+ 'storage_active' => array('storage_active'),
+ 'deleted' => array('deleted'),
+ // Used by field_modules_disabled().
+ 'module' => array('module'),
+ 'storage_module' => array('storage_module'),
+ // Used by field_associate_fields().
+ 'type' => array('type'),
+ 'storage_type' => array('storage_type'),
+ ),
+ );
+ $schema['field_config_instance'] = array(
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'The primary identifier for a field instance',
+ ),
+ 'field_id' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'description' => 'The identifier of the field attached by this instance',
+ ),
+ 'field_name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'entity_type' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'bundle' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'data' => array(
+ 'type' => 'blob',
+ 'size' => 'big',
+ 'not null' => TRUE,
+ 'serialize' => TRUE,
+ ),
+ 'deleted' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('id'),
+ 'indexes' => array(
+ // Used by field_delete_instance().
+ 'field_name_bundle' => array('field_name', 'entity_type', 'bundle'),
+ // Used by field_read_instances().
+ 'deleted' => array('deleted'),
+ ),
+ );
+ $schema['cache_field'] = drupal_get_schema_unprocessed('system', 'cache');
+
+ return $schema;
+}
+
+/**
+ * Utility function: create a field by writing directly to the database.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_create_field(&$field) {
+ // Merge in default values.`
+ $field += array(
+ 'entity_types' => array(),
+ 'cardinality' => 1,
+ 'translatable' => FALSE,
+ 'locked' => FALSE,
+ 'settings' => array(),
+ 'indexes' => array(),
+ 'deleted' => 0,
+ 'active' => 1,
+ );
+
+ // Set storage.
+ $field['storage'] = array(
+ 'type' => 'field_sql_storage',
+ 'settings' => array(),
+ 'module' => 'field_sql_storage',
+ 'active' => 1,
+ );
+
+ // Fetch the field schema to initialize columns and indexes. The field module
+ // is not guaranteed to be loaded at this point.
+ module_load_install($field['module']);
+ $schema = (array) module_invoke($field['module'], 'field_schema', $field);
+ $schema += array('columns' => array(), 'indexes' => array());
+ // 'columns' are hardcoded in the field type.
+ $field['columns'] = $schema['columns'];
+ // 'indexes' can be both hardcoded in the field type, and specified in the
+ // incoming $field definition.
+ $field['indexes'] += $schema['indexes'];
+
+ // The serialized 'data' column contains everything from $field that does not
+ // have its own column and is not automatically populated when the field is
+ // read.
+ $data = $field;
+ unset($data['columns'], $data['field_name'], $data['type'], $data['active'], $data['module'], $data['storage_type'], $data['storage_active'], $data['storage_module'], $data['locked'], $data['cardinality'], $data['deleted']);
+ // Additionally, do not save the 'bundles' property populated by
+ // field_info_field().
+ unset($data['bundles']);
+
+ // Write the field to the database.
+ $record = array(
+ 'field_name' => $field['field_name'],
+ 'type' => $field['type'],
+ 'module' => $field['module'],
+ 'active' => (int) $field['active'],
+ 'storage_type' => $field['storage']['type'],
+ 'storage_module' => $field['storage']['module'],
+ 'storage_active' => (int) $field['storage']['active'],
+ 'locked' => (int) $field['locked'],
+ 'data' => serialize($data),
+ 'cardinality' => $field['cardinality'],
+ 'translatable' => (int) $field['translatable'],
+ 'deleted' => (int) $field['deleted'],
+ );
+ // We don't use drupal_write_record() here because it depends on the schema.
+ $field['id'] = db_insert('field_config')
+ ->fields($record)
+ ->execute();
+
+ // Create storage for the field.
+ field_sql_storage_field_storage_create_field($field);
+}
+
+/**
+ * Utility function: delete a field stored in SQL storage directly from the database.
+ *
+ * To protect user data, this function can only be used to delete fields once
+ * all information it stored is gone. Delete all data from the
+ * field_data_$field_name table before calling by either manually issuing
+ * delete queries against it or using _update_7000_field_delete_instance().
+ *
+ * @param $field_name
+ * The field name to delete.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_delete_field($field_name) {
+ $table_name = 'field_data_' . $field_name;
+ if (db_select($table_name)->range(0, 1)->countQuery()->execute()->fetchField()) {
+ $t = get_t();
+ throw new Exception($t('This function can only be used to delete fields without data'));
+ }
+ // Delete all instances.
+ db_delete('field_config_instance')
+ ->condition('field_name', $field_name)
+ ->execute();
+
+ // Nuke field data and revision tables.
+ db_drop_table($table_name);
+ db_drop_table('field_revision_' . $field_name);
+
+ // Delete the field.
+ db_delete('field_config')
+ ->condition('field_name', $field_name)
+ ->execute();
+}
+
+
+/**
+ * Utility function: delete an instance and all its data of a field stored in SQL Storage.
+ *
+ * BEWARE: this function deletes user data from the field storage tables.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_delete_instance($field_name, $entity_type, $bundle) {
+ // Delete field instance configuration data.
+ db_delete('field_config_instance')
+ ->condition('field_name', $field_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle)
+ ->execute();
+
+ // Nuke data.
+ db_delete('field_data_' . $field_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle)
+ ->execute();
+ db_delete('field_revision_' . $field_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle)
+ ->execute();
+}
+
+/**
+ * Utility function: fetch all the field definitions from the database.
+ *
+ * Warning: unlike the field_read_fields() API function, this function returns
+ * all fields by default, including deleted and inactive fields, unless
+ * specified otherwise in the $conditions parameter.
+ *
+ * @param $conditions
+ * An array of conditions to limit the select query to.
+ * @param $key
+ * The name of the field property the return array is indexed by. Using
+ * anything else than 'id' might cause incomplete results if the $conditions
+ * do not filter out deleted fields.
+ *
+ * @return
+ * An array of fields matching $conditions, keyed by the property specified
+ * by the $key parameter.
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_read_fields(array $conditions = array(), $key = 'id') {
+ $fields = array();
+ $query = db_select('field_config', 'fc', array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('fc');
+ foreach ($conditions as $column => $value) {
+ $query->condition($column, $value);
+ }
+ foreach ($query->execute() as $record) {
+ $field = unserialize($record['data']);
+ $field['id'] = $record['id'];
+ $field['field_name'] = $record['field_name'];
+ $field['type'] = $record['type'];
+ $field['module'] = $record['module'];
+ $field['active'] = $record['active'];
+ $field['storage']['type'] = $record['storage_type'];
+ $field['storage']['module'] = $record['storage_module'];
+ $field['storage']['active'] = $record['storage_active'];
+ $field['locked'] = $record['locked'];
+ $field['cardinality'] = $record['cardinality'];
+ $field['translatable'] = $record['translatable'];
+ $field['deleted'] = $record['deleted'];
+
+ $fields[$field[$key]] = $field;
+ }
+ return $fields;
+}
+
+/**
+ * Utility function: write a field instance directly to the database.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_create_instance($field, &$instance) {
+ // Merge in defaults.
+ $instance += array(
+ 'field_id' => $field['id'],
+ 'field_name' => $field['field_name'],
+ 'deleted' => 0,
+ );
+
+ // The serialized 'data' column contains everything from $instance that does
+ // not have its own column and is not automatically populated when the
+ // instance is read.
+ $data = $instance;
+ unset($data['id'], $data['field_id'], $data['field_name'], $data['entity_type'], $data['bundle'], $data['deleted']);
+
+ $record = array(
+ 'field_id' => $instance['field_id'],
+ 'field_name' => $instance['field_name'],
+ 'entity_type' => $instance['entity_type'],
+ 'bundle' => $instance['bundle'],
+ 'data' => serialize($data),
+ 'deleted' => (int) $instance['deleted'],
+ );
+ $instance['id'] = db_insert('field_config_instance')
+ ->fields($record)
+ ->execute();
+}
diff --git a/core/modules/field/field.module b/core/modules/field/field.module
new file mode 100644
index 000000000000..70b17f52465b
--- /dev/null
+++ b/core/modules/field/field.module
@@ -0,0 +1,1206 @@
+<?php
+/**
+ * @file
+ * Attach custom data fields to Drupal entities.
+ */
+
+/**
+ * Base class for all exceptions thrown by Field API functions.
+ *
+ * This class has no functionality of its own other than allowing all
+ * Field API exceptions to be caught by a single catch block.
+ */
+class FieldException extends Exception {}
+
+/*
+ * Load all public Field API functions. Drupal currently has no
+ * mechanism for auto-loading core APIs, so we have to load them on
+ * every page request.
+ */
+require_once DRUPAL_ROOT . '/core/modules/field/field.crud.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/field.default.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/field.info.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/field.multilingual.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/field.attach.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/field.form.inc';
+
+/**
+ * @defgroup field Field API
+ * @{
+ * Attach custom data fields to Drupal entities.
+ *
+ * The Field API allows custom data fields to be attached to Drupal
+ * entities and takes care of storing, loading, editing, and rendering
+ * field data. Any entity type (node, user, etc.) can use the Field
+ * API to make itself "fieldable" and thus allow fields to be attached
+ * to it. Other modules can provide a user interface for managing custom
+ * fields via a web browser as well as a wide and flexible variety of
+ * data type, form element, and display format capabilities.
+ *
+ * The Field API defines two primary data structures, Field and
+ * Instance, and the concept of a Bundle. A Field defines a
+ * particular type of data that can be attached to entities. A Field
+ * Instance is a Field attached to a single Bundle. A Bundle is a set
+ * of fields that are treated as a group by the Field Attach API and
+ * is related to a single fieldable entity type.
+ *
+ * For example, suppose a site administrator wants Article nodes to
+ * have a subtitle and photo. Using the Field API or Field UI module,
+ * the administrator creates a field named 'subtitle' of type 'text'
+ * and a field named 'photo' of type 'image'. The administrator
+ * (again, via a UI) creates two Field Instances, one attaching the
+ * field 'subtitle' to the 'node' bundle 'article' and one attaching
+ * the field 'photo' to the 'node' bundle 'article'. When the node
+ * system uses the Field Attach API to load all fields for an Article
+ * node, it passes the node's entity type (which is 'node') and
+ * content type (which is 'article') as the node's bundle.
+ * field_attach_load() then loads the 'subtitle' and 'photo' fields
+ * because they are both attached to the 'node' bundle 'article'.
+ *
+ * Field definitions are represented as an array of key/value pairs.
+ *
+ * array $field:
+ * - id (integer, read-only)
+ * The primary identifier of the field. It is assigned automatically
+ * by field_create_field().
+ * - field_name (string)
+ * The name of the field. Each field name is unique within Field API.
+ * When a field is attached to an entity, the field's data is stored
+ * in $entity->$field_name. Maximum length is 32 characters.
+ * - type (string)
+ * The type of the field, such as 'text' or 'image'. Field types
+ * are defined by modules that implement hook_field_info().
+ * - entity_types (array)
+ * The array of entity types that can hold instances of this field. If
+ * empty or not specified, the field can have instances in any entity type.
+ * - cardinality (integer)
+ * The number of values the field can hold. Legal values are any
+ * positive integer or FIELD_CARDINALITY_UNLIMITED.
+ * - translatable (integer)
+ * Whether the field is translatable.
+ * - locked (integer)
+ * Whether or not the field is available for editing. If TRUE, users can't
+ * change field settings or create new instances of the field in the UI.
+ * Defaults to FALSE.
+ * - module (string, read-only)
+ * The name of the module that implements the field type.
+ * - active (integer, read-only)
+ * TRUE if the module that implements the field type is currently
+ * enabled, FALSE otherwise.
+ * - deleted (integer, read-only)
+ * TRUE if this field has been deleted, FALSE otherwise. Deleted
+ * fields are ignored by the Field Attach API. This property exists
+ * because fields can be marked for deletion but only actually
+ * destroyed by a separate garbage-collection process.
+ * - columns (array, read-only).
+ * An array of the Field API columns used to store each value of
+ * this field. The column list may depend on field settings; it is
+ * not constant per field type. Field API column specifications are
+ * exactly like Schema API column specifications but, depending on
+ * the field storage module in use, the name of the column may not
+ * represent an actual column in an SQL database.
+ * - indexes (array).
+ * An array of indexes on data columns, using the same definition format
+ * as Schema API index specifications. Only columns that appear in the
+ * 'columns' setting are allowed. Note that field types can specify
+ * default indexes, which can be modified or added to when
+ * creating a field.
+ * - foreign keys: (optional) An associative array of relations, using the same
+ * structure as the 'foreign keys' definition of hook_schema(). Note, however,
+ * that the field data is not necessarily stored in SQL. Also, the possible
+ * usage is limited, as you cannot specify another field as related, only
+ * existing SQL tables, such as filter formats.
+ * - settings (array)
+ * A sub-array of key/value pairs of field-type-specific settings. Each
+ * field type module defines and documents its own field settings.
+ * - storage (array)
+ * A sub-array of key/value pairs identifying the storage backend to use for
+ * the for the field.
+ * - type (string)
+ * The storage backend used by the field. Storage backends are defined
+ * by modules that implement hook_field_storage_info().
+ * - module (string, read-only)
+ * The name of the module that implements the storage backend.
+ * - active (integer, read-only)
+ * TRUE if the module that implements the storage backend is currently
+ * enabled, FALSE otherwise.
+ * - settings (array)
+ * A sub-array of key/value pairs of settings. Each storage backend
+ * defines and documents its own settings.
+ *
+ * Field instance definitions are represented as an array of key/value pairs.
+ *
+ * array $instance:
+ * - id (integer, read-only)
+ * The primary identifier of this field instance. It is assigned
+ * automatically by field_create_instance().
+ * - field_id (integer, read-only)
+ * The foreign key of the field attached to the bundle by this instance.
+ * It is populated automatically by field_create_instance().
+ * - field_name (string)
+ * The name of the field attached to the bundle by this instance.
+ * - entity_type (string)
+ * The name of the entity type the instance is attached to.
+ * - bundle (string)
+ * The name of the bundle that the field is attached to.
+ * - label (string)
+ * A human-readable label for the field when used with this
+ * bundle. For example, the label will be the title of Form API
+ * elements for this instance.
+ * - description (string)
+ * A human-readable description for the field when used with this
+ * bundle. For example, the description will be the help text of
+ * Form API elements for this instance.
+ * - required (integer)
+ * TRUE if a value for this field is required when used with this
+ * bundle, FALSE otherwise. Currently, required-ness is only enforced
+ * during Form API operations, not by field_attach_load(),
+ * field_attach_insert(), or field_attach_update().
+ * - default_value_function (string)
+ * The name of the function, if any, that will provide a default value.
+ * - default_value (array)
+ * If default_value_function is not set, then fixed values can be provided.
+ * - deleted (integer, read-only)
+ * TRUE if this instance has been deleted, FALSE otherwise.
+ * Deleted instances are ignored by the Field Attach API.
+ * This property exists because instances can be marked for deletion but
+ * only actually destroyed by a separate garbage-collection process.
+ * - settings (array)
+ * A sub-array of key/value pairs of field-type-specific instance
+ * settings. Each field type module defines and documents its own
+ * instance settings.
+ * - widget (array)
+ * A sub-array of key/value pairs identifying the Form API input widget
+ * for the field when used by this bundle.
+ * - type (string)
+ * The type of the widget, such as text_textfield. Widget types
+ * are defined by modules that implement hook_field_widget_info().
+ * - settings (array)
+ * A sub-array of key/value pairs of widget-type-specific settings.
+ * Each field widget type module defines and documents its own
+ * widget settings.
+ * - weight (float)
+ * The weight of the widget relative to the other elements in entity
+ * edit forms.
+ * - module (string, read-only)
+ * The name of the module that implements the widget type.
+ * - display (array)
+ * A sub-array of key/value pairs identifying the way field values should
+ * be displayed in each of the entity type's view modes, plus the 'default'
+ * mode. For each view mode, Field UI lets site administrators define
+ * whether they want to use a dedicated set of display options or the
+ * 'default' options to reduce the number of displays to maintain as they
+ * add new fields. For nodes, on a fresh install, only the 'teaser' view
+ * mode is configured to use custom display options, all other view modes
+ * defined use the 'default' options by default. When programmatically
+ * adding field instances on nodes, it is therefore recommended to at least
+ * specify display options for 'default' and 'teaser'.
+ * - default (array)
+ * A sub-array of key/value pairs describing the display options to be
+ * used when the field is being displayed in view modes that are not
+ * configured to use dedicated display options.
+ * - label (string)
+ * Position of the label. 'inline', 'above' and 'hidden' are the
+ * values recognized by the default 'field' theme implementation.
+ * - type (string)
+ * The type of the display formatter, or 'hidden' for no display.
+ * - settings (array)
+ * A sub-array of key/value pairs of display options specific to
+ * the formatter.
+ * - weight (float)
+ * The weight of the field relative to the other entity components
+ * displayed in this view mode.
+ * - module (string, read-only)
+ * The name of the module which implements the display formatter.
+ * - some_mode
+ * A sub-array of key/value pairs describing the display options to be
+ * used when the field is being displayed in the 'some_mode' view mode.
+ * Those options will only be actually applied at run time if the view
+ * mode is not configured to use default settings for this bundle.
+ * - ...
+ * - other_mode
+ * - ...
+ *
+ * The (default) render arrays produced for field instances are documented at
+ * field_attach_view().
+ *
+ * Bundles are represented by two strings, an entity type and a bundle name.
+ *
+ * - @link field_types Field Types API @endlink. Defines field types,
+ * widget types, and display formatters. Field modules use this API
+ * to provide field types like Text and Node Reference along with the
+ * associated form elements and display formatters.
+ *
+ * - @link field_crud Field CRUD API @endlink. Create, updates, and
+ * deletes fields, bundles (a.k.a. "content types"), and instances.
+ * Modules use this API, often in hook_install(), to create
+ * custom data structures.
+ *
+ * - @link field_attach Field Attach API @endlink. Connects entity
+ * types to the Field API. Field Attach API functions load, store,
+ * generate Form API structures, display, and perform a variety of
+ * other functions for field data connected to individual entities.
+ * Fieldable entity types like node and user use this API to make
+ * themselves fieldable.
+ *
+ * - @link field_info Field Info API @endlink. Exposes information
+ * about all fields, instances, widgets, and related information
+ * defined by or with the Field API.
+ *
+ * - @link field_storage Field Storage API @endlink. Provides a
+ * pluggable back-end storage system for actual field data. The
+ * default implementation, field_sql_storage.module, stores field data
+ * in the local SQL database.
+ *
+ * - @link field_purge Field API bulk data deletion @endlink. Cleans
+ * up after bulk deletion operations such as field_delete_field()
+ * and field_delete_instance().
+ *
+ * - @link field_language Field language API @endlink. Provides native
+ * multilingual support for the Field API.
+ */
+
+/**
+ * Value for field API indicating a field accepts an unlimited number of values.
+ */
+define('FIELD_CARDINALITY_UNLIMITED', -1);
+
+/**
+ * Value for field API indicating a widget doesn't accept default values.
+ *
+ * @see hook_field_widget_info()
+ */
+define('FIELD_BEHAVIOR_NONE', 0x0001);
+
+/**
+ * Value for field API concerning widget default and multiple value settings.
+ *
+ * @see hook_field_widget_info()
+ *
+ * When used in a widget default context, indicates the widget accepts default
+ * values. When used in a multiple value context for a widget that allows the
+ * input of one single field value, indicates that the widget will be repeated
+ * for each value input.
+ */
+define('FIELD_BEHAVIOR_DEFAULT', 0x0002);
+
+/**
+ * Value for field API indicating a widget can receive several field values.
+ *
+ * @see hook_field_widget_info()
+ */
+define('FIELD_BEHAVIOR_CUSTOM', 0x0004);
+
+/**
+ * Age argument for loading the most recent version of an entity's
+ * field data with field_attach_load().
+ */
+define('FIELD_LOAD_CURRENT', 'FIELD_LOAD_CURRENT');
+
+/**
+ * Age argument for loading the version of an entity's field data
+ * specified in the entity with field_attach_load().
+ */
+define('FIELD_LOAD_REVISION', 'FIELD_LOAD_REVISION');
+
+/**
+ * Exception class thrown by hook_field_update_forbid().
+ */
+class FieldUpdateForbiddenException extends FieldException {}
+
+/**
+ * Implements hook_help().
+ */
+function field_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#field':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Field module allows custom data fields to be defined for <em>entity</em> types (entities include content items, comments, user accounts, and taxonomy terms). The Field module takes care of storing, loading, editing, and rendering field data. Most users will not interact with the Field module directly, but will instead use the <a href="@field-ui-help">Field UI module</a> user interface. Module developers can use the Field API to make new entity types "fieldable" and thus allow fields to be attached to them. For more information, see the online handbook entry for <a href="@field">Field module</a>.', array('@field-ui-help' => url('admin/help/field_ui'), '@field' => 'http://drupal.org/handbook/modules/field')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Enabling field types') . '</dt>';
+ $output .= '<dd>' . t('The Field module provides the infrastructure for fields and field attachment; the field types and input widgets themselves are provided by additional modules. Some of the modules are required; the optional modules can be enabled from the <a href="@modules">Modules administration page</a>. Drupal core includes the following field type modules: Number (required), Text (required), List (required), Taxonomy (optional), Image (optional), and File (optional); the required Options module provides input widgets for other field modules. Additional fields and widgets may be provided by contributed modules, which you can find in the <a href="@contrib">contributed module section of Drupal.org</a>. Currently enabled field and input widget modules:', array('@modules' => url('admin/modules'), '@contrib' => 'http://drupal.org/project/modules', '@options' => url('admin/help/options')));
+
+ // Make a list of all widget and field modules currently enabled, in
+ // order by displayed module name (module names are not translated).
+ $items = array();
+ $info = system_get_info('module');
+ $modules = array_merge(module_implements('field_info'), module_implements('field_widget_info'));
+ $modules = array_unique($modules);
+ sort($modules);
+ foreach ($modules as $module) {
+ $display = $info[$module]['name'];
+ if (module_hook($module, 'help')) {
+ $items['items'][] = l($display, 'admin/help/' . $module);
+ }
+ else {
+ $items['items'][] = $display;
+ }
+ }
+ $output .= theme('item_list', $items) . '</dd>';
+ $output .= '<dt>' . t('Managing field data storage') . '</dt>';
+ $output .= '<dd>' . t('Developers of field modules can either use the default <a href="@sql-store">Field SQL storage module</a> to store data for their fields, or a contributed or custom module developed using the <a href="@storage-api">field storage API</a>.', array('@storage-api' => 'http://api.drupal.org/api/group/field_storage/7', '@sql-store' => url('admin/help/field_sql_storage'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function field_theme() {
+ return array(
+ 'field' => array(
+ 'render element' => 'element',
+ ),
+ 'field_multiple_value_form' => array(
+ 'render element' => 'element',
+ ),
+ );
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Purges some deleted Field API data, if any exists.
+ */
+function field_cron() {
+ field_sync_field_status();
+ $limit = variable_get('field_purge_batch_size', 10);
+ field_purge_batch($limit);
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function field_modules_uninstalled($modules) {
+ module_load_include('inc', 'field', 'field.crud');
+ foreach ($modules as $module) {
+ // TODO D7: field_module_delete is not yet implemented
+ // field_module_delete($module);
+ }
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Goes through a list of all modules that provide a field type, and makes them
+ * required if there are any active fields of that type.
+ */
+function field_system_info_alter(&$info, $file, $type) {
+ if ($type == 'module' && module_hook($file->name, 'field_info')) {
+ $fields = field_read_fields(array('module' => $file->name), array('include_deleted' => TRUE));
+ if ($fields) {
+ $info['required'] = TRUE;
+
+ // Provide an explanation message (only mention pending deletions if there
+ // remains no actual, non-deleted fields)
+ $non_deleted = FALSE;
+ foreach ($fields as $field) {
+ if (empty($field['deleted'])) {
+ $non_deleted = TRUE;
+ break;
+ }
+ }
+ if ($non_deleted) {
+ if (module_exists('field_ui')) {
+ $explanation = t('Field type(s) in use - see !link', array('!link' => l(t('Field list'), 'admin/reports/fields')));
+ }
+ else {
+ $explanation = t('Fields type(s) in use');
+ }
+ }
+ else {
+ $explanation = t('Fields pending deletion');
+ }
+ $info['explanation'] = $explanation;
+ }
+ }
+}
+
+/**
+ * Implements hook_flush_caches().
+ */
+function field_flush_caches() {
+ field_sync_field_status();
+ return array('field');
+}
+
+/**
+ * Refreshes the 'active' and 'storage_active' columns for fields.
+ */
+function field_sync_field_status() {
+ // Refresh the 'active' and 'storage_active' columns according to the current
+ // set of enabled modules.
+ $all_modules = system_rebuild_module_data();
+ $modules = array();
+ foreach ($all_modules as $module_name => $module) {
+ if ($module->status) {
+ $modules[] = $module_name;
+ field_associate_fields($module_name);
+ }
+ }
+ db_update('field_config')
+ ->fields(array('active' => 0))
+ ->condition('module', $modules, 'NOT IN')
+ ->execute();
+ db_update('field_config')
+ ->fields(array('storage_active' => 0))
+ ->condition('storage_module', $modules, 'NOT IN')
+ ->execute();
+}
+
+/**
+ * Allows a module to update the database for fields and columns it controls.
+ *
+ * @param $module
+ * The name of the module to update on.
+ */
+function field_associate_fields($module) {
+ // Associate field types.
+ $field_types = (array) module_invoke($module, 'field_info');
+ foreach ($field_types as $name => $field_info) {
+ db_update('field_config')
+ ->fields(array('module' => $module, 'active' => 1))
+ ->condition('type', $name)
+ ->execute();
+ }
+ // Associate storage backends.
+ $storage_types = (array) module_invoke($module, 'field_storage_info');
+ foreach ($storage_types as $name => $storage_info) {
+ db_update('field_config')
+ ->fields(array('storage_module' => $module, 'storage_active' => 1))
+ ->condition('storage_type', $name)
+ ->execute();
+ }
+}
+
+/**
+ * Helper function to get the default value for a field on an entity.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entity
+ * The entity for the operation.
+ * @param $field
+ * The field structure.
+ * @param $instance
+ * The instance structure.
+ * @param $langcode
+ * The field language to fill-in with the default value.
+ */
+function field_get_default_value($entity_type, $entity, $field, $instance, $langcode = NULL) {
+ $items = array();
+ if (!empty($instance['default_value_function'])) {
+ $function = $instance['default_value_function'];
+ if (function_exists($function)) {
+ $items = $function($entity_type, $entity, $field, $instance, $langcode);
+ }
+ }
+ elseif (!empty($instance['default_value'])) {
+ $items = $instance['default_value'];
+ }
+ return $items;
+}
+
+/**
+ * Helper function to filter out empty field values.
+ *
+ * @param $field
+ * The field definition.
+ * @param $items
+ * The field values to filter.
+ *
+ * @return
+ * The array of items without empty field values. The function also renumbers
+ * the array keys to ensure sequential deltas.
+ */
+function _field_filter_items($field, $items) {
+ $function = $field['module'] . '_field_is_empty';
+ foreach ((array) $items as $delta => $item) {
+ // Explicitly break if the function is undefined.
+ if ($function($item, $field)) {
+ unset($items[$delta]);
+ }
+ }
+ return array_values($items);
+}
+
+/**
+ * Helper function to sort items in a field according to
+ * user drag-n-drop reordering.
+ */
+function _field_sort_items($field, $items) {
+ if (($field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED) && isset($items[0]['_weight'])) {
+ usort($items, '_field_sort_items_helper');
+ foreach ($items as $delta => $item) {
+ if (is_array($items[$delta])) {
+ unset($items[$delta]['_weight']);
+ }
+ }
+ }
+ return $items;
+}
+
+/**
+ * Sort function for items order.
+ * (copied form element_sort(), which acts on #weight keys)
+ */
+function _field_sort_items_helper($a, $b) {
+ $a_weight = (is_array($a) ? $a['_weight'] : 0);
+ $b_weight = (is_array($b) ? $b['_weight'] : 0);
+ return $a_weight - $b_weight;
+}
+
+/**
+ * Same as above, using ['_weight']['#value']
+ */
+function _field_sort_items_value_helper($a, $b) {
+ $a_weight = (is_array($a) && isset($a['_weight']['#value']) ? $a['_weight']['#value'] : 0);
+ $b_weight = (is_array($b) && isset($b['_weight']['#value']) ? $b['_weight']['#value'] : 0);
+ return $a_weight - $b_weight;
+}
+
+/**
+ * Gets or sets administratively defined bundle settings.
+ *
+ * For each bundle, settings are provided as a nested array with the following
+ * structure:
+ * @code
+ * array(
+ * 'view_modes' => array(
+ * // One sub-array per view mode for the entity type:
+ * 'full' => array(
+ * 'custom_display' => Whether the view mode uses custom display
+ * settings or settings of the 'default' mode,
+ * ),
+ * 'teaser' => ...
+ * ),
+ * 'extra_fields' => array(
+ * 'form' => array(
+ * // One sub-array per pseudo-field in displayed entities:
+ * 'extra_field_1' => array(
+ * 'weight' => The weight of the pseudo-field,
+ * ),
+ * 'extra_field_2' => ...
+ * ),
+ * 'display' => array(
+ * // One sub-array per pseudo-field in displayed entities:
+ * 'extra_field_1' => array(
+ * // One sub-array per view mode for the entity type, including
+ * // the 'default' mode:
+ * 'default' => array(
+ * 'weight' => The weight of the pseudo-field,
+ * 'visible' => TRUE if the pseudo-field is visible, FALSE if hidden,
+ * ),
+ * 'full' => ...
+ * ),
+ * 'extra_field_2' => ...
+ * ),
+ * ),
+ * );
+ * @endcode
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $bundle
+ * The bundle name.
+ * @param $settings
+ * (optional) The settings to store.
+ *
+ * @return
+ * If no $settings are passed, the current settings are returned.
+ */
+function field_bundle_settings($entity_type, $bundle, $settings = NULL) {
+ if (isset($settings)) {
+ variable_set('field_bundle_settings_' . $entity_type . '__' . $bundle, $settings);
+ field_info_cache_clear();
+ }
+ else {
+ $settings = variable_get('field_bundle_settings_' . $entity_type . '__' . $bundle, array());
+ $settings += array(
+ 'view_modes' => array(),
+ 'extra_fields' => array(),
+ );
+ $settings['extra_fields'] += array(
+ 'form' => array(),
+ 'display' => array(),
+ );
+
+ return $settings;
+ }
+}
+
+/**
+ * Returns view mode settings in a given bundle.
+ *
+ * @param $entity_type
+ * The type of entity; e.g. 'node' or 'user'.
+ * @param $bundle
+ * The bundle name to return view mode settings for.
+ *
+ * @return
+ * An array keyed by view mode, with the following key/value pairs:
+ * - custom_settings: Boolean specifying whether the view mode uses a
+ * dedicated set of display options (TRUE), or the 'default' options
+ * (FALSE). Defaults to FALSE.
+ */
+function field_view_mode_settings($entity_type, $bundle) {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($cache[$entity_type][$bundle])) {
+ $bundle_settings = field_bundle_settings($entity_type, $bundle);
+ $settings = $bundle_settings['view_modes'];
+ // Include view modes for which nothing has been stored yet, but whose
+ // definition in hook_entity_info() specify they should use custom settings
+ // by default.
+ $entity_info = entity_get_info($entity_type);
+ foreach ($entity_info['view modes'] as $view_mode => $view_mode_info) {
+ if (!isset($settings[$view_mode]['custom_settings']) && $view_mode_info['custom settings']) {
+ $settings[$view_mode]['custom_settings'] = TRUE;
+ }
+ }
+ $cache[$entity_type][$bundle] = $settings;
+ }
+
+ return $cache[$entity_type][$bundle];
+}
+
+/**
+ * Returns the display settings to use for an instance in a given view mode.
+ *
+ * @param $instance
+ * The field instance being displayed.
+ * @param $view_mode
+ * The view mode.
+ * @param $entity
+ * The entity being displayed.
+ *
+ * @return
+ * The display settings to be used when displaying the field values.
+ */
+function field_get_display($instance, $view_mode, $entity) {
+ // Check whether the view mode uses custom display settings or the 'default'
+ // mode.
+ $view_mode_settings = field_view_mode_settings($instance['entity_type'], $instance['bundle']);
+ $actual_mode = (!empty($view_mode_settings[$view_mode]['custom_settings']) ? $view_mode : 'default');
+ $display = $instance['display'][$actual_mode];
+
+ // Let modules alter the display settings.
+ $context = array(
+ 'entity_type' => $instance['entity_type'],
+ 'field' => field_info_field($instance['field_name']),
+ 'instance' => $instance,
+ 'entity' => $entity,
+ 'view_mode' => $view_mode,
+ );
+ drupal_alter(array('field_display', 'field_display_' . $instance['entity_type']), $display, $context);
+
+ return $display;
+}
+
+/**
+ * Returns the display settings to use for pseudo-fields in a given view mode.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $bundle
+ * The bundle name.
+ * @param $view_mode
+ * The view mode.
+ *
+ * @return
+ * The display settings to be used when viewing the bundle's pseudo-fields.
+ */
+function field_extra_fields_get_display($entity_type, $bundle, $view_mode) {
+ // Check whether the view mode uses custom display settings or the 'default'
+ // mode.
+ $view_mode_settings = field_view_mode_settings($entity_type, $bundle);
+ $actual_mode = (!empty($view_mode_settings[$view_mode]['custom_settings'])) ? $view_mode : 'default';
+ $extra_fields = field_info_extra_fields($entity_type, $bundle, 'display');
+
+ $displays = array();
+ foreach ($extra_fields as $name => $value) {
+ $displays[$name] = $extra_fields[$name]['display'][$actual_mode];
+ }
+
+ // Let modules alter the display settings.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'bundle' => $bundle,
+ 'view_mode' => $view_mode,
+ );
+ drupal_alter('field_extra_fields_display', $displays, $context);
+
+ return $displays;
+}
+
+/**
+ * Pre-render callback to adjust weights and visibility of non-field elements.
+ */
+function _field_extra_fields_pre_render($elements) {
+ $entity_type = $elements['#entity_type'];
+ $bundle = $elements['#bundle'];
+
+ if (isset($elements['#type']) && $elements['#type'] == 'form') {
+ $extra_fields = field_info_extra_fields($entity_type, $bundle, 'form');
+ foreach ($extra_fields as $name => $settings) {
+ if (isset($elements[$name])) {
+ $elements[$name]['#weight'] = $settings['weight'];
+ }
+ }
+ }
+ elseif (isset($elements['#view_mode'])) {
+ $view_mode = $elements['#view_mode'];
+ $extra_fields = field_extra_fields_get_display($entity_type, $bundle, $view_mode);
+ foreach ($extra_fields as $name => $settings) {
+ if (isset($elements[$name])) {
+ $elements[$name]['#weight'] = $settings['weight'];
+ // Visibility: make sure we do not accidentally show a hidden element.
+ $elements[$name]['#access'] = isset($elements[$name]['#access']) ? ($elements[$name]['#access'] && $settings['visible']) : $settings['visible'];
+ }
+ }
+ }
+
+ return $elements;
+}
+
+/**
+ * Clear the field info and field data caches.
+ */
+function field_cache_clear() {
+ cache('field')->flush();
+ field_info_cache_clear();
+}
+
+/**
+ * Like filter_xss_admin(), but with a shorter list of allowed tags.
+ *
+ * Used for items entered by administrators, like field descriptions,
+ * allowed values, where some (mainly inline) mark-up may be desired
+ * (so check_plain() is not acceptable).
+ */
+function field_filter_xss($string) {
+ return filter_xss($string, _field_filter_xss_allowed_tags());
+}
+
+/**
+ * List of tags allowed by field_filter_xss().
+ */
+function _field_filter_xss_allowed_tags() {
+ return array('a', 'b', 'big', 'code', 'del', 'em', 'i', 'ins', 'pre', 'q', 'small', 'span', 'strong', 'sub', 'sup', 'tt', 'ol', 'ul', 'li', 'p', 'br', 'img');
+}
+
+/**
+ * Human-readable list of allowed tags, for display in help texts.
+ */
+function _field_filter_xss_display_allowed_tags() {
+ return '<' . implode('> <', _field_filter_xss_allowed_tags()) . '>';
+}
+
+/**
+ * Returns a renderable array for a single field value.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entity
+ * The entity containing the field to display. Must at least contain the id
+ * key and the field data to display.
+ * @param $field_name
+ * The name of the field to display.
+ * @param $item
+ * The field value to display, as found in
+ * $entity->field_name[$langcode][$delta].
+ * @param $display
+ * Can be either the name of a view mode, or an array of display settings.
+ * See field_view_field() for more information.
+ * @param $langcode
+ * (Optional) The language of the value in $item. If not provided, the
+ * current language will be assumed.
+ * @return
+ * A renderable array for the field value.
+ */
+function field_view_value($entity_type, $entity, $field_name, $item, $display = array(), $langcode = NULL) {
+ $output = array();
+
+ if ($field = field_info_field($field_name)) {
+ // Determine the langcode that will be used by language fallback.
+ $langcode = field_language($entity_type, $entity, $field_name, $langcode);
+
+ // Push the item as the single value for the field, and defer to
+ // field_view_field() to build the render array for the whole field.
+ $clone = clone $entity;
+ $clone->{$field_name}[$langcode] = array($item);
+ $elements = field_view_field($entity_type, $clone, $field_name, $display, $langcode);
+
+ // Extract the part of the render array we need.
+ $output = isset($elements[0]) ? $elements[0] : array();
+ if (isset($elements['#access'])) {
+ $output['#access'] = $elements['#access'];
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns a renderable array for the value of a single field in an entity.
+ *
+ * The resulting output is a fully themed field with label and multiple values.
+ *
+ * This function can be used by third-party modules that need to output an
+ * isolated field.
+ * - Do not use inside node (or other entities) templates, use
+ * render($content[FIELD_NAME]) instead.
+ * - Do not use to display all fields in an entity, use
+ * field_attach_prepare_view() and field_attach_view() instead.
+ * - The field_view_value() function can be used to output a single formatted
+ * field value, without label or wrapping field markup.
+ *
+ * The function takes care of invoking the prepare_view steps. It also respects
+ * field access permissions.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entity
+ * The entity containing the field to display. Must at least contain the id
+ * key and the field data to display.
+ * @param $field_name
+ * The name of the field to display.
+ * @param $display
+ * Can be either:
+ * - The name of a view mode. The field will be displayed according to the
+ * display settings specified for this view mode in the $instance
+ * definition for the field in the entity's bundle.
+ * If no display settings are found for the view mode, the settings for
+ * the 'default' view mode will be used.
+ * - An array of display settings, as found in the 'display' entry of
+ * $instance definitions. The following key/value pairs are allowed:
+ * - label: (string) Position of the label. The default 'field' theme
+ * implementation supports the values 'inline', 'above' and 'hidden'.
+ * Defaults to 'above'.
+ * - type: (string) The formatter to use. Defaults to the
+ * 'default_formatter' for the field type, specified in
+ * hook_field_info(). The default formatter will also be used if the
+ * requested formatter is not available.
+ * - settings: (array) Settings specific to the formatter. Defaults to the
+ * formatter's default settings, specified in
+ * hook_field_formatter_info().
+ * - weight: (float) The weight to assign to the renderable element.
+ * Defaults to 0.
+ * @param $langcode
+ * (Optional) The language the field values are to be shown in. The site's
+ * current language fallback logic will be applied no values are available
+ * for the language. If no language is provided the current language will be
+ * used.
+ * @return
+ * A renderable array for the field value.
+ *
+ * @see field_view_value()
+ */
+function field_view_field($entity_type, $entity, $field_name, $display = array(), $langcode = NULL) {
+ $output = array();
+
+ if ($field = field_info_field($field_name)) {
+ if (is_array($display)) {
+ // When using custom display settings, fill in default values.
+ $display = _field_info_prepare_instance_display($field, $display);
+ }
+
+ // Hook invocations are done through the _field_invoke() functions in
+ // 'single field' mode, to reuse the language fallback logic.
+ // Determine the actual language to display for the field, given the
+ // languages available in the field data.
+ $display_language = field_language($entity_type, $entity, $field_name, $langcode);
+ $options = array('field_name' => $field_name, 'language' => $display_language);
+ $null = NULL;
+
+ // Invoke prepare_view steps if needed.
+ if (empty($entity->_field_view_prepared)) {
+ list($id) = entity_extract_ids($entity_type, $entity);
+
+ // First let the field types do their preparation.
+ _field_invoke_multiple('prepare_view', $entity_type, array($id => $entity), $display, $null, $options);
+ // Then let the formatters do their own specific massaging.
+ _field_invoke_multiple_default('prepare_view', $entity_type, array($id => $entity), $display, $null, $options);
+ }
+
+ // Build the renderable array.
+ $result = _field_invoke_default('view', $entity_type, $entity, $display, $null, $options);
+
+ // Invoke hook_field_attach_view_alter() to let other modules alter the
+ // renderable array, as in a full field_attach_view() execution.
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'entity' => $entity,
+ 'view_mode' => '_custom',
+ 'display' => $display,
+ );
+ drupal_alter('field_attach_view', $result, $context);
+
+ if (isset($result[$field_name])) {
+ $output = $result[$field_name];
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns the field items in the language they currently would be displayed.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entity
+ * The entity containing the data to be displayed.
+ * @param $field_name
+ * The field to be displayed.
+ * @param $langcode
+ * (optional) The language code $entity->{$field_name} has to be displayed in.
+ * Defaults to the current language.
+ *
+ * @return
+ * An array of field items keyed by delta if available, FALSE otherwise.
+ */
+function field_get_items($entity_type, $entity, $field_name, $langcode = NULL) {
+ $langcode = field_language($entity_type, $entity, $field_name, $langcode);
+ return isset($entity->{$field_name}[$langcode]) ? $entity->{$field_name}[$langcode] : FALSE;
+}
+
+/**
+ * Determine whether a field has any data.
+ *
+ * @param $field
+ * A field structure.
+ * @return
+ * TRUE if the field has data for any entity; FALSE otherwise.
+ */
+function field_has_data($field) {
+ $query = new EntityFieldQuery();
+ return (bool) $query
+ ->fieldCondition($field)
+ ->range(0, 1)
+ ->count()
+ ->execute();
+}
+
+/**
+ * Determine whether the user has access to a given field.
+ *
+ * @param $op
+ * The operation to be performed. Possible values:
+ * - "edit"
+ * - "view"
+ * @param $field
+ * The field on which the operation is to be performed.
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $entity
+ * (optional) The entity for the operation.
+ * @param $account
+ * (optional) The account to check, if not given use currently logged in user.
+ * @return
+ * TRUE if the operation is allowed;
+ * FALSE if the operation is denied.
+ */
+function field_access($op, $field, $entity_type, $entity = NULL, $account = NULL) {
+ global $user;
+
+ if (!isset($account)) {
+ $account = $user;
+ }
+
+ foreach (module_implements('field_access') as $module) {
+ $function = $module . '_field_access';
+ $access = $function($op, $field, $entity_type, $entity, $account);
+ if ($access === FALSE) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Helper function to extract the bundle name of from a bundle object.
+ *
+ * @param $entity_type
+ * The type of $entity; e.g., 'node' or 'user'.
+ * @param $bundle
+ * The bundle object (or string if bundles for this entity type do not exist
+ * as standalone objects).
+ * @return
+ * The bundle name.
+ */
+function field_extract_bundle($entity_type, $bundle) {
+ if (is_string($bundle)) {
+ return $bundle;
+ }
+
+ $info = entity_get_info($entity_type);
+ if (is_object($bundle) && isset($info['bundle keys']['bundle']) && isset($bundle->{$info['bundle keys']['bundle']})) {
+ return $bundle->{$info['bundle keys']['bundle']};
+ }
+}
+
+/**
+ * Theme preprocess function for theme_field() and field.tpl.php.
+ *
+ * @see theme_field()
+ * @see field.tpl.php
+ */
+function template_preprocess_field(&$variables, $hook) {
+ $element = $variables['element'];
+
+ // There's some overhead in calling check_plain() so only call it if the label
+ // variable is being displayed. Otherwise, set it to NULL to avoid PHP
+ // warnings if a theme implementation accesses the variable even when it's
+ // supposed to be hidden. If a theme implementation needs to print a hidden
+ // label, it needs to supply a preprocess function that sets it to the
+ // sanitized element title or whatever else is wanted in its place.
+ $variables['label_hidden'] = ($element['#label_display'] == 'hidden');
+ $variables['label'] = $variables['label_hidden'] ? NULL : check_plain($element['#title']);
+
+ // We want other preprocess functions and the theme implementation to have
+ // fast access to the field item render arrays. The item render array keys
+ // (deltas) should always be a subset of the keys in #items, and looping on
+ // those keys is faster than calling element_children() or looping on all keys
+ // within $element, since that requires traversal of all element properties.
+ $variables['items'] = array();
+ foreach ($element['#items'] as $delta => $item) {
+ if (!empty($element[$delta])) {
+ $variables['items'][$delta] = $element[$delta];
+ }
+ }
+
+ // Add default CSS classes. Since there can be many fields rendered on a page,
+ // save some overhead by calling strtr() directly instead of
+ // drupal_html_class().
+ $variables['field_name_css'] = strtr($element['#field_name'], '_', '-');
+ $variables['field_type_css'] = strtr($element['#field_type'], '_', '-');
+ $variables['classes_array'] = array(
+ 'field',
+ 'field-name-' . $variables['field_name_css'],
+ 'field-type-' . $variables['field_type_css'],
+ 'field-label-' . $element['#label_display'],
+ );
+ // Add a "clearfix" class to the wrapper since we float the label and the
+ // field items in field.css if the label is inline.
+ if ($element['#label_display'] == 'inline') {
+ $variables['classes_array'][] = 'clearfix';
+ }
+
+ // Add specific suggestions that can override the default implementation.
+ $variables['theme_hook_suggestions'] = array(
+ 'field__' . $element['#field_type'],
+ 'field__' . $element['#field_name'],
+ 'field__' . $element['#bundle'],
+ 'field__' . $element['#field_name'] . '__' . $element['#bundle'],
+ );
+}
+
+/**
+ * Theme process function for theme_field() and field.tpl.php.
+ *
+ * @see theme_field()
+ * @see field.tpl.php
+ */
+function template_process_field(&$variables, $hook) {
+ // The default theme implementation is a function, so template_process() does
+ // not automatically run, so we need to flatten the classes and attributes
+ // here. For best performance, only call drupal_attributes() when needed, and
+ // note that template_preprocess_field() does not initialize the
+ // *_attributes_array variables.
+ $variables['classes'] = implode(' ', $variables['classes_array']);
+ $variables['attributes'] = empty($variables['attributes_array']) ? '' : drupal_attributes($variables['attributes_array']);
+ $variables['title_attributes'] = empty($variables['title_attributes_array']) ? '' : drupal_attributes($variables['title_attributes_array']);
+ $variables['content_attributes'] = empty($variables['content_attributes_array']) ? '' : drupal_attributes($variables['content_attributes_array']);
+ foreach ($variables['items'] as $delta => $item) {
+ $variables['item_attributes'][$delta] = empty($variables['item_attributes_array'][$delta]) ? '' : drupal_attributes($variables['item_attributes_array'][$delta]);
+ }
+}
+/**
+ * @} End of "defgroup field"
+ */
+
+/**
+ * Returns HTML for a field.
+ *
+ * This is the default theme implementation to display the value of a field.
+ * Theme developers who are comfortable with overriding theme functions may do
+ * so in order to customize this markup. This function can be overridden with
+ * varying levels of specificity. For example, for a field named 'body'
+ * displayed on the 'article' content type, any of the following functions will
+ * override this default implementation. The first of these functions that
+ * exists is used:
+ * - THEMENAME_field__body__article()
+ * - THEMENAME_field__article()
+ * - THEMENAME_field__body()
+ * - THEMENAME_field()
+ *
+ * Theme developers who prefer to customize templates instead of overriding
+ * functions may copy the "field.tpl.php" from the "modules/field/theme" folder
+ * of the Drupal installation to somewhere within the theme's folder and
+ * customize it, just like customizing other Drupal templates such as
+ * page.tpl.php or node.tpl.php. However, it takes longer for the server to
+ * process templates than to call a function, so for websites with many fields
+ * displayed on a page, this can result in a noticeable slowdown of the website.
+ * For these websites, developers are discouraged from placing a field.tpl.php
+ * file into the theme's folder, but may customize templates for specific
+ * fields. For example, for a field named 'body' displayed on the 'article'
+ * content type, any of the following templates will override this default
+ * implementation. The first of these templates that exists is used:
+ * - field--body--article.tpl.php
+ * - field--article.tpl.php
+ * - field--body.tpl.php
+ * - field.tpl.php
+ * So, if the body field on the article content type needs customization, a
+ * field--body--article.tpl.php file can be added within the theme's folder.
+ * Because it's a template, it will result in slightly more time needed to
+ * display that field, but it will not impact other fields, and therefore,
+ * is unlikely to cause a noticeable change in website performance. A very rough
+ * guideline is that if a page is being displayed with more than 100 fields and
+ * they are all themed with a template instead of a function, it can add up to
+ * 5% to the time it takes to display that page. This is a guideline only and
+ * the exact performance impact depends on the server configuration and the
+ * details of the website.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - label_hidden: A boolean indicating to show or hide the field label.
+ * - title_attributes: A string containing the attributes for the title.
+ * - label: The label for the field.
+ * - content_attributes: A string containing the attributes for the content's
+ * div.
+ * - items: An array of field items.
+ * - item_attributes: An array of attributes for each item.
+ * - classes: A string containing the classes for the wrapping div.
+ * - attributes: A string containing the attributes for the wrapping div.
+ *
+ * @see template_preprocess_field()
+ * @see template_process_field()
+ * @see field.tpl.php
+ *
+ * @ingroup themeable
+ */
+function theme_field($variables) {
+ $output = '';
+
+ // Render the label, if it's not hidden.
+ if (!$variables['label_hidden']) {
+ $output .= '<div class="field-label"' . $variables['title_attributes'] . '>' . $variables['label'] . ':&nbsp;</div>';
+ }
+
+ // Render the items.
+ $output .= '<div class="field-items"' . $variables['content_attributes'] . '>';
+ foreach ($variables['items'] as $delta => $item) {
+ $classes = 'field-item ' . ($delta % 2 ? 'odd' : 'even');
+ $output .= '<div class="' . $classes . '"' . $variables['item_attributes'][$delta] . '>' . drupal_render($item) . '</div>';
+ }
+ $output .= '</div>';
+
+ // Render the top-level DIV.
+ $output = '<div class="' . $variables['classes'] . '"' . $variables['attributes'] . '>' . $output . '</div>';
+
+ return $output;
+}
diff --git a/core/modules/field/field.multilingual.inc b/core/modules/field/field.multilingual.inc
new file mode 100644
index 000000000000..44970741e985
--- /dev/null
+++ b/core/modules/field/field.multilingual.inc
@@ -0,0 +1,312 @@
+<?php
+
+/**
+ * @file
+ * Functions implementing Field API multilingual support.
+ */
+
+/**
+ * @defgroup field_language Field Language API
+ * @{
+ * Handling of multilingual fields.
+ *
+ * Fields natively implement multilingual support, and all fields use the
+ * following structure:
+ * @code
+ * $entity->{$field_name}[$langcode][$delta][$column_name]
+ * @endcode
+ * Every field can hold a single or multiple value for each language belonging
+ * to the available languages set:
+ * - For untranslatable fields this set only contains LANGUAGE_NONE.
+ * - For translatable fields this set can contain any language code. By default
+ * it is the list returned by field_content_languages(), which contains all
+ * installed languages with the addition of LANGUAGE_NONE. This default can be
+ * altered by modules implementing hook_field_available_languages_alter().
+ *
+ * The available languages for a particular field are returned by
+ * field_available_languages(). Whether a field is translatable is determined by
+ * calling field_is_translatable(), which checks the $field['translatable']
+ * property returned by field_info_field(), and whether there is at least one
+ * translation handler available for the field. A translation handler is a
+ * module registering itself via hook_entity_info() to handle field
+ * translations.
+ *
+ * By default, _field_invoke() and _field_invoke_multiple() are processing a
+ * field in all available languages, unless they are given a language
+ * suggestion. Based on that suggestion, _field_language_suggestion() determines
+ * the languages to act on.
+ *
+ * Most field_attach_*() functions act on all available languages, except for
+ * the following:
+ * - field_attach_form() only takes a single language code, specifying which
+ * language the field values will be submitted in.
+ * - field_attach_view() requires the language the entity will be displayed in.
+ * Since it is unknown whether a field translation exists for the requested
+ * language, the translation handler is responsible for performing one of the
+ * following actions:
+ * - Ignore missing translations, i.e. do not show any field values for the
+ * requested language. For example, see locale_field_language_alter().
+ * - Provide a value in a different language as fallback. By default, the
+ * fallback logic is applied separately to each field to ensure that there
+ * is a value for each field to display.
+ * The field language fallback logic relies on the global language fallback
+ * configuration. Therefore, the displayed field values can be in the
+ * requested language, but may be different if no values for the requested
+ * language are available. The default language fallback rules inspect all the
+ * enabled languages ordered by their weight. This behavior can be altered or
+ * even disabled by modules implementing hook_field_language_alter(), making
+ * it possible to choose the first approach. The display language for each
+ * field is returned by field_language().
+ */
+
+/**
+ * Implements hook_locale_language_insert().
+ */
+function field_locale_language_insert() {
+ field_info_cache_clear();
+}
+
+/**
+ * Implements hook_locale_language_update().
+ */
+function field_locale_language_update() {
+ field_info_cache_clear();
+}
+
+/**
+ * Implements hook_locale_language_delete().
+ */
+function field_locale_language_delete() {
+ field_info_cache_clear();
+}
+
+/**
+ * Collects the available languages for the given entity type and field.
+ *
+ * If the given field has language support enabled, an array of available
+ * languages will be returned, otherwise only LANGUAGE_NONE will be returned.
+ * Since the default value for a 'translatable' entity property is FALSE, we
+ * ensure that only entities that are able to handle translations actually get
+ * translatable fields.
+ *
+ * @param $entity_type
+ * The type of the entity the field is attached to, e.g. 'node' or 'user'.
+ * @param $field
+ * A field structure.
+ *
+ * @return
+ * An array of valid language codes.
+ */
+function field_available_languages($entity_type, $field) {
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['field_languages'] = &drupal_static(__FUNCTION__);
+ }
+ $field_languages = &$drupal_static_fast['field_languages'];
+ $field_name = $field['field_name'];
+
+ if (!isset($field_languages[$entity_type][$field_name])) {
+ // If the field has language support enabled we retrieve an (alterable) list
+ // of enabled languages, otherwise we return just LANGUAGE_NONE.
+ if (field_is_translatable($entity_type, $field)) {
+ $languages = field_content_languages();
+ // Let other modules alter the available languages.
+ $context = array('entity_type' => $entity_type, 'field' => $field);
+ drupal_alter('field_available_languages', $languages, $context);
+ $field_languages[$entity_type][$field_name] = $languages;
+ }
+ else {
+ $field_languages[$entity_type][$field_name] = array(LANGUAGE_NONE);
+ }
+ }
+
+ return $field_languages[$entity_type][$field_name];
+}
+
+/**
+ * Process the given language suggestion based on the available languages.
+ *
+ * If a non-empty language suggestion is provided it must appear among the
+ * available languages, otherwise it will be ignored.
+ *
+ * @param $available_languages
+ * An array of valid language codes.
+ * @param $language_suggestion
+ * A language code or an array of language codes keyed by field name.
+ * @param $field_name
+ * The name of the field being processed.
+ *
+ * @return
+ * An array of valid language codes.
+ */
+function _field_language_suggestion($available_languages, $language_suggestion, $field_name) {
+ // Handle possible language suggestions.
+ if (!empty($language_suggestion)) {
+ // We might have an array of language suggestions keyed by field name.
+ if (is_array($language_suggestion) && isset($language_suggestion[$field_name])) {
+ $language_suggestion = $language_suggestion[$field_name];
+ }
+
+ // If we have a language suggestion and the suggested language is available,
+ // we return only it.
+ if (in_array($language_suggestion, $available_languages)) {
+ $available_languages = array($language_suggestion);
+ }
+ }
+
+ return $available_languages;
+}
+
+/**
+ * Returns available content languages.
+ *
+ * The languages that may be associated to fields include LANGUAGE_NONE.
+ *
+ * @return
+ * An array of language codes.
+ */
+function field_content_languages() {
+ return array_keys(language_list() + array(LANGUAGE_NONE => NULL));
+}
+
+/**
+ * Checks whether a field has language support.
+ *
+ * A field has language support enabled if its 'translatable' property is set to
+ * TRUE, and its entity type has at least one translation handler registered.
+ *
+ * @param $entity_type
+ * The type of the entity the field is attached to.
+ * @param $field
+ * A field data structure.
+ *
+ * @return
+ * TRUE if the field can be translated.
+ */
+function field_is_translatable($entity_type, $field) {
+ return $field['translatable'] && field_has_translation_handler($entity_type);
+}
+
+/**
+ * Checks if a module is registered as a translation handler for a given entity.
+ *
+ * If no handler is passed, simply check if there is any translation handler
+ * enabled for the given entity type.
+ *
+ * @param $entity_type
+ * The type of the entity whose fields are to be translated.
+ * @param $handler
+ * (optional) The name of the handler to be checked. Defaults to NULL.
+ *
+ * @return
+ * TRUE, if the given handler is allowed to manage field translations. If no
+ * handler is passed, TRUE means there is at least one registered translation
+ * handler.
+ */
+function field_has_translation_handler($entity_type, $handler = NULL) {
+ $entity_info = entity_get_info($entity_type);
+
+ if (isset($handler)) {
+ return !empty($entity_info['translation'][$handler]);
+ }
+ elseif (isset($entity_info['translation'])) {
+ foreach ($entity_info['translation'] as $handler_info) {
+ // The translation handler must use a non-empty data structure.
+ if (!empty($handler_info)) {
+ return TRUE;
+ }
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Ensures that a given language code is valid.
+ *
+ * Checks whether the given language is one of the enabled languages. Otherwise,
+ * it returns the current, global language; or the site's default language, if
+ * the additional parameter $default is TRUE.
+ *
+ * @param $langcode
+ * The language code to validate.
+ * @param $default
+ * Whether to return the default language code or the current language code in
+ * case $langcode is invalid.
+ * @return
+ * A valid language code.
+ */
+function field_valid_language($langcode, $default = TRUE) {
+ $enabled_languages = field_content_languages();
+ if (in_array($langcode, $enabled_languages)) {
+ return $langcode;
+ }
+ global $language_content;
+ return $default ? language_default()->language : $language_content->language;
+}
+
+/**
+ * Returns the display language for the fields attached to the given entity.
+ *
+ * The actual language for each given field is determined based on the requested
+ * language and the actual data available in the fields themselves.
+ * If there is no registered translation handler for the given entity type, the
+ * display language to be used is just LANGUAGE_NONE, as no other language code
+ * is allowed by field_available_languages().
+ * If translation handlers are found, we let modules provide alternative display
+ * languages for fields not having the requested language available.
+ * Core language fallback rules are provided by locale_field_language_fallback()
+ * which is called by locale_field_language_alter().
+ *
+ * @param $entity_type
+ * The type of $entity.
+ * @param $entity
+ * The entity to be displayed.
+ * @param $field_name
+ * (optional) The name of the field to be displayed. Defaults to NULL. If
+ * no value is specified, the display languages for every field attached to
+ * the given entity will be returned.
+ * @param $langcode
+ * (optional) The language code $entity has to be displayed in. Defaults to
+ * NULL. If no value is given the current language will be used.
+ *
+ * @return
+ * A language code if a field name is specified, an array of language codes
+ * keyed by field name otherwise.
+ */
+function field_language($entity_type, $entity, $field_name = NULL, $langcode = NULL) {
+ $display_languages = &drupal_static(__FUNCTION__, array());
+ list($id, , $bundle) = entity_extract_ids($entity_type, $entity);
+ $langcode = field_valid_language($langcode, FALSE);
+
+ if (!isset($display_languages[$entity_type][$id][$langcode])) {
+ $display_language = array();
+
+ // By default display language is set to LANGUAGE_NONE if the field
+ // translation is not available. It is up to translation handlers to
+ // implement language fallback rules.
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ $display_language[$instance['field_name']] = isset($entity->{$instance['field_name']}[$langcode]) ? $langcode : LANGUAGE_NONE;
+ }
+
+ if (field_has_translation_handler($entity_type)) {
+ $context = array(
+ 'entity_type' => $entity_type,
+ 'entity' => $entity,
+ 'language' => $langcode,
+ );
+ drupal_alter('field_language', $display_language, $context);
+ }
+
+ $display_languages[$entity_type][$id][$langcode] = $display_language;
+ }
+
+ $display_language = $display_languages[$entity_type][$id][$langcode];
+
+ // Single-field mode.
+ if (isset($field_name)) {
+ return isset($display_language[$field_name]) ? $display_language[$field_name] : FALSE;
+ }
+
+ return $display_language;
+}
diff --git a/core/modules/field/modules/field_sql_storage/field_sql_storage.info b/core/modules/field/modules/field_sql_storage/field_sql_storage.info
new file mode 100644
index 000000000000..ee1ae571267b
--- /dev/null
+++ b/core/modules/field/modules/field_sql_storage/field_sql_storage.info
@@ -0,0 +1,8 @@
+name = Field SQL storage
+description = Stores field data in an SQL database.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = field_sql_storage.test
+required = TRUE
diff --git a/core/modules/field/modules/field_sql_storage/field_sql_storage.install b/core/modules/field/modules/field_sql_storage/field_sql_storage.install
new file mode 100644
index 000000000000..4a3a00e577f0
--- /dev/null
+++ b/core/modules/field/modules/field_sql_storage/field_sql_storage.install
@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the field_sql_storage module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function field_sql_storage_schema() {
+ $schema = array();
+
+ // Dynamic (data) tables.
+ if (db_table_exists('field_config')) {
+ $fields = field_read_fields(array(), array('include_deleted' => TRUE, 'include_inactive' => TRUE));
+ drupal_load('module', 'field_sql_storage');
+ foreach ($fields as $field) {
+ if ($field['storage']['type'] == 'field_sql_storage') {
+ $schema += _field_sql_storage_schema($field);
+ }
+ }
+ }
+ return $schema;
+}
+
+/**
+ * Utility function: write field data directly to SQL storage.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_field_sql_storage_write($entity_type, $bundle, $entity_id, $revision_id, $field_name, $data) {
+ $table_name = "field_data_{$field_name}";
+ $revision_name = "field_revision_{$field_name}";
+
+ db_delete($table_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $entity_id)
+ ->execute();
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $entity_id)
+ ->condition('revision_id', $revision_id)
+ ->execute();
+
+ $columns = array();
+ foreach ($data as $langcode => $items) {
+ foreach ($items as $delta => $item) {
+ $record = array(
+ 'entity_type' => $entity_type,
+ 'entity_id' => $entity_id,
+ 'revision_id' => $revision_id,
+ 'bundle' => $bundle,
+ 'delta' => $delta,
+ 'language' => $langcode,
+ );
+ foreach ($item as $column => $value) {
+ $record[_field_sql_storage_columnname($field_name, $column)] = $value;
+ }
+
+ $records[] = $record;
+ // Record the columns used.
+ $columns += $record;
+ }
+ }
+
+ if ($columns) {
+ $query = db_insert($table_name)->fields(array_keys($columns));
+ $revision_query = db_insert($revision_name)->fields(array_keys($columns));
+ foreach ($records as $record) {
+ $query->values($record);
+ if ($revision_id) {
+ $revision_query->values($record);
+ }
+ }
+ $query->execute();
+ $revision_query->execute();
+ }
+}
diff --git a/core/modules/field/modules/field_sql_storage/field_sql_storage.module b/core/modules/field/modules/field_sql_storage/field_sql_storage.module
new file mode 100644
index 000000000000..92d244a9fbac
--- /dev/null
+++ b/core/modules/field/modules/field_sql_storage/field_sql_storage.module
@@ -0,0 +1,743 @@
+<?php
+
+/**
+ * @file
+ * Default implementation of the field storage API.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function field_sql_storage_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#field_sql_storage':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Field SQL storage module stores field data in the database. It is the default field storage module; other field storage mechanisms may be available as contributed modules. See the <a href="@field-help">Field module help page</a> for more information about fields.', array('@field-help' => url('admin/help/field'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_field_storage_info().
+ */
+function field_sql_storage_field_storage_info() {
+ return array(
+ 'field_sql_storage' => array(
+ 'label' => t('Default SQL storage'),
+ 'description' => t('Stores fields in the local SQL database, using per-field tables.'),
+ ),
+ );
+}
+
+/**
+ * Generate a table name for a field data table.
+ *
+ * @param $field
+ * The field structure.
+ * @return
+ * A string containing the generated name for the database table
+ */
+function _field_sql_storage_tablename($field) {
+ if ($field['deleted']) {
+ return "field_deleted_data_{$field['id']}";
+ }
+ else {
+ return "field_data_{$field['field_name']}";
+ }
+}
+
+/**
+ * Generate a table name for a field revision archive table.
+ *
+ * @param $name
+ * The field structure.
+ * @return
+ * A string containing the generated name for the database table
+ */
+function _field_sql_storage_revision_tablename($field) {
+ if ($field['deleted']) {
+ return "field_deleted_revision_{$field['id']}";
+ }
+ else {
+ return "field_revision_{$field['field_name']}";
+ }
+}
+
+/**
+ * Generate a column name for a field data table.
+ *
+ * @param $name
+ * The name of the field
+ * @param $column
+ * The name of the column
+ * @return
+ * A string containing a generated column name for a field data
+ * table that is unique among all other fields.
+ */
+function _field_sql_storage_columnname($name, $column) {
+ return $name . '_' . $column;
+}
+
+/**
+ * Generate an index name for a field data table.
+ *
+ * @param $name
+ * The name of the field
+ * @param $column
+ * The name of the index
+ * @return
+ * A string containing a generated index name for a field data
+ * table that is unique among all other fields.
+ */
+function _field_sql_storage_indexname($name, $index) {
+ return $name . '_' . $index;
+}
+
+/**
+ * Return the database schema for a field. This may contain one or
+ * more tables. Each table will contain the columns relevant for the
+ * specified field. Leave the $field's 'columns' and 'indexes' keys
+ * empty to get only the base schema.
+ *
+ * @param $field
+ * The field structure for which to generate a database schema.
+ * @return
+ * One or more tables representing the schema for the field.
+ */
+function _field_sql_storage_schema($field) {
+ $deleted = $field['deleted'] ? 'deleted ' : '';
+ $current = array(
+ 'description' => "Data storage for {$deleted}field {$field['id']} ({$field['field_name']})",
+ 'fields' => array(
+ 'entity_type' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The entity type this data is attached to',
+ ),
+ 'bundle' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The field instance bundle to which this row belongs, used when deleting a field instance',
+ ),
+ 'deleted' => array(
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'A boolean indicating whether this data item has been deleted'
+ ),
+ 'entity_id' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'The entity id this data is attached to',
+ ),
+ 'revision_id' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => FALSE,
+ 'description' => 'The entity revision id this data is attached to, or NULL if the entity type is not versioned',
+ ),
+ // @todo Consider storing language as integer.
+ 'language' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The language for this data item.',
+ ),
+ 'delta' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'The sequence number for this data item, used for multi-value fields',
+ ),
+ ),
+ 'primary key' => array('entity_type', 'entity_id', 'deleted', 'delta', 'language'),
+ 'indexes' => array(
+ 'entity_type' => array('entity_type'),
+ 'bundle' => array('bundle'),
+ 'deleted' => array('deleted'),
+ 'entity_id' => array('entity_id'),
+ 'revision_id' => array('revision_id'),
+ 'language' => array('language'),
+ ),
+ );
+
+ $field += array('columns' => array(), 'indexes' => array(), 'foreign keys' => array());
+ // Add field columns.
+ foreach ($field['columns'] as $column_name => $attributes) {
+ $real_name = _field_sql_storage_columnname($field['field_name'], $column_name);
+ $current['fields'][$real_name] = $attributes;
+ }
+
+ // Add indexes.
+ foreach ($field['indexes'] as $index_name => $columns) {
+ $real_name = _field_sql_storage_indexname($field['field_name'], $index_name);
+ foreach ($columns as $column_name) {
+ $current['indexes'][$real_name][] = _field_sql_storage_columnname($field['field_name'], $column_name);
+ }
+ }
+
+ // Add foreign keys.
+ foreach ($field['foreign keys'] as $specifier => $specification) {
+ $real_name = _field_sql_storage_indexname($field['field_name'], $specifier);
+ $current['foreign keys'][$real_name]['table'] = $specification['table'];
+ foreach ($specification['columns'] as $column => $referenced) {
+ $sql_storage_column = _field_sql_storage_columnname($field['field_name'], $column_name);
+ $current['foreign keys'][$real_name]['columns'][$sql_storage_column] = $referenced;
+ }
+ }
+
+ // Construct the revision table.
+ $revision = $current;
+ $revision['description'] = "Revision archive storage for {$deleted}field {$field['id']} ({$field['field_name']})";
+ $revision['primary key'] = array('entity_type', 'entity_id', 'revision_id', 'deleted', 'delta', 'language');
+ $revision['fields']['revision_id']['not null'] = TRUE;
+ $revision['fields']['revision_id']['description'] = 'The entity revision id this data is attached to';
+
+ return array(
+ _field_sql_storage_tablename($field) => $current,
+ _field_sql_storage_revision_tablename($field) => $revision,
+ );
+}
+
+/**
+ * Implements hook_field_storage_create_field().
+ */
+function field_sql_storage_field_storage_create_field($field) {
+ $schema = _field_sql_storage_schema($field);
+ foreach ($schema as $name => $table) {
+ db_create_table($name, $table);
+ }
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Implements hook_field_update_forbid().
+ *
+ * Forbid any field update that changes column definitions if there is
+ * any data.
+ */
+function field_sql_storage_field_update_forbid($field, $prior_field, $has_data) {
+ if ($has_data && $field['columns'] != $prior_field['columns']) {
+ throw new FieldUpdateForbiddenException("field_sql_storage cannot change the schema for an existing field with data.");
+ }
+}
+
+/**
+ * Implements hook_field_storage_update_field().
+ */
+function field_sql_storage_field_storage_update_field($field, $prior_field, $has_data) {
+ if (! $has_data) {
+ // There is no data. Re-create the tables completely.
+
+ if (Database::getConnection()->supportsTransactionalDDL()) {
+ // If the database supports transactional DDL, we can go ahead and rely
+ // on it. If not, we will have to rollback manually if something fails.
+ $transaction = db_transaction();
+ }
+
+ try {
+ $prior_schema = _field_sql_storage_schema($prior_field);
+ foreach ($prior_schema as $name => $table) {
+ db_drop_table($name, $table);
+ }
+ $schema = _field_sql_storage_schema($field);
+ foreach ($schema as $name => $table) {
+ db_create_table($name, $table);
+ }
+ }
+ catch (Exception $e) {
+ if (Database::getConnection()->supportsTransactionalDDL()) {
+ $transaction->rollback();
+ }
+ else {
+ // Recreate tables.
+ $prior_schema = _field_sql_storage_schema($prior_field);
+ foreach ($prior_schema as $name => $table) {
+ if (!db_table_exists($name)) {
+ db_create_table($name, $table);
+ }
+ }
+ }
+ throw $e;
+ }
+ }
+ else {
+ // There is data, so there are no column changes. Drop all the
+ // prior indexes and create all the new ones, except for all the
+ // priors that exist unchanged.
+ $table = _field_sql_storage_tablename($prior_field);
+ $revision_table = _field_sql_storage_revision_tablename($prior_field);
+ foreach ($prior_field['indexes'] as $name => $columns) {
+ if (!isset($field['indexes'][$name]) || $columns != $field['indexes'][$name]) {
+ $real_name = _field_sql_storage_indexname($field['field_name'], $name);
+ db_drop_index($table, $real_name);
+ db_drop_index($revision_table, $real_name);
+ }
+ }
+ $table = _field_sql_storage_tablename($field);
+ $revision_table = _field_sql_storage_revision_tablename($field);
+ foreach ($field['indexes'] as $name => $columns) {
+ if (!isset($prior_field['indexes'][$name]) || $columns != $prior_field['indexes'][$name]) {
+ $real_name = _field_sql_storage_indexname($field['field_name'], $name);
+ $real_columns = array();
+ foreach ($columns as $column_name) {
+ $real_columns[] = _field_sql_storage_columnname($field['field_name'], $column_name);
+ }
+ db_add_index($table, $real_name, $real_columns);
+ db_add_index($revision_table, $real_name, $real_columns);
+ }
+ }
+ }
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Implements hook_field_storage_delete_field().
+ */
+function field_sql_storage_field_storage_delete_field($field) {
+ // Mark all data associated with the field for deletion.
+ $field['deleted'] = 0;
+ $table = _field_sql_storage_tablename($field);
+ $revision_table = _field_sql_storage_revision_tablename($field);
+ db_update($table)
+ ->fields(array('deleted' => 1))
+ ->execute();
+
+ // Move the table to a unique name while the table contents are being deleted.
+ $field['deleted'] = 1;
+ $new_table = _field_sql_storage_tablename($field);
+ $revision_new_table = _field_sql_storage_revision_tablename($field);
+ db_rename_table($table, $new_table);
+ db_rename_table($revision_table, $revision_new_table);
+ drupal_get_schema(NULL, TRUE);
+}
+
+/**
+ * Implements hook_field_storage_load().
+ */
+function field_sql_storage_field_storage_load($entity_type, $entities, $age, $fields, $options) {
+ $field_info = field_info_field_by_ids();
+ $load_current = $age == FIELD_LOAD_CURRENT;
+
+ foreach ($fields as $field_id => $ids) {
+ $field = $field_info[$field_id];
+ $field_name = $field['field_name'];
+ $table = $load_current ? _field_sql_storage_tablename($field) : _field_sql_storage_revision_tablename($field);
+
+ $query = db_select($table, 't')
+ ->fields('t')
+ ->condition('entity_type', $entity_type)
+ ->condition($load_current ? 'entity_id' : 'revision_id', $ids, 'IN')
+ ->condition('language', field_available_languages($entity_type, $field), 'IN')
+ ->orderBy('delta');
+
+ if (empty($options['deleted'])) {
+ $query->condition('deleted', 0);
+ }
+
+ $results = $query->execute();
+
+ $delta_count = array();
+ foreach ($results as $row) {
+ if (!isset($delta_count[$row->entity_id][$row->language])) {
+ $delta_count[$row->entity_id][$row->language] = 0;
+ }
+
+ if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$row->language] < $field['cardinality']) {
+ $item = array();
+ // For each column declared by the field, populate the item
+ // from the prefixed database column.
+ foreach ($field['columns'] as $column => $attributes) {
+ $column_name = _field_sql_storage_columnname($field_name, $column);
+ $item[$column] = $row->$column_name;
+ }
+
+ // Add the item to the field values for the entity.
+ $entities[$row->entity_id]->{$field_name}[$row->language][] = $item;
+ $delta_count[$row->entity_id][$row->language]++;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_write().
+ */
+function field_sql_storage_field_storage_write($entity_type, $entity, $op, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ if (!isset($vid)) {
+ $vid = $id;
+ }
+
+ foreach ($fields as $field_id) {
+ $field = field_info_field_by_id($field_id);
+ $field_name = $field['field_name'];
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+
+ $all_languages = field_available_languages($entity_type, $field);
+ $field_languages = array_intersect($all_languages, array_keys((array) $entity->$field_name));
+
+ // Delete and insert, rather than update, in case a value was added.
+ if ($op == FIELD_STORAGE_UPDATE) {
+ // Delete languages present in the incoming $entity->$field_name.
+ // Delete all languages if $entity->$field_name is empty.
+ $languages = !empty($entity->$field_name) ? $field_languages : $all_languages;
+ if ($languages) {
+ db_delete($table_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('language', $languages, 'IN')
+ ->execute();
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('revision_id', $vid)
+ ->condition('language', $languages, 'IN')
+ ->execute();
+ }
+ }
+
+ // Prepare the multi-insert query.
+ $do_insert = FALSE;
+ $columns = array('entity_type', 'entity_id', 'revision_id', 'bundle', 'delta', 'language');
+ foreach ($field['columns'] as $column => $attributes) {
+ $columns[] = _field_sql_storage_columnname($field_name, $column);
+ }
+ $query = db_insert($table_name)->fields($columns);
+ $revision_query = db_insert($revision_name)->fields($columns);
+
+ foreach ($field_languages as $langcode) {
+ $items = (array) $entity->{$field_name}[$langcode];
+ $delta_count = 0;
+ foreach ($items as $delta => $item) {
+ // We now know we have someting to insert.
+ $do_insert = TRUE;
+ $record = array(
+ 'entity_type' => $entity_type,
+ 'entity_id' => $id,
+ 'revision_id' => $vid,
+ 'bundle' => $bundle,
+ 'delta' => $delta,
+ 'language' => $langcode,
+ );
+ foreach ($field['columns'] as $column => $attributes) {
+ $record[_field_sql_storage_columnname($field_name, $column)] = isset($item[$column]) ? $item[$column] : NULL;
+ }
+ $query->values($record);
+ if (isset($vid)) {
+ $revision_query->values($record);
+ }
+
+ if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) {
+ break;
+ }
+ }
+ }
+
+ // Execute the query if we have values to insert.
+ if ($do_insert) {
+ $query->execute();
+ $revision_query->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_delete().
+ *
+ * This function deletes data for all fields for an entity from the database.
+ */
+function field_sql_storage_field_storage_delete($entity_type, $entity, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ foreach (field_info_instances($entity_type, $bundle) as $instance) {
+ if (isset($fields[$instance['field_id']])) {
+ $field = field_info_field_by_id($instance['field_id']);
+ field_sql_storage_field_storage_purge($entity_type, $entity, $field, $instance);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_purge().
+ *
+ * This function deletes data from the database for a single field on
+ * an entity.
+ */
+function field_sql_storage_field_storage_purge($entity_type, $entity, $field, $instance) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_delete($table_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->execute();
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->execute();
+}
+
+/**
+ * Implements hook_field_storage_query().
+ */
+function field_sql_storage_field_storage_query(EntityFieldQuery $query) {
+ if ($query->age == FIELD_LOAD_CURRENT) {
+ $tablename_function = '_field_sql_storage_tablename';
+ $id_key = 'entity_id';
+ }
+ else {
+ $tablename_function = '_field_sql_storage_revision_tablename';
+ $id_key = 'revision_id';
+ }
+ $table_aliases = array();
+ // Add tables for the fields used.
+ foreach ($query->fields as $key => $field) {
+ $tablename = $tablename_function($field);
+ // Every field needs a new table.
+ $table_alias = $tablename . $key;
+ $table_aliases[$key] = $table_alias;
+ if ($key) {
+ $select_query->join($tablename, $table_alias, "$table_alias.entity_type = $field_base_table.entity_type AND $table_alias.$id_key = $field_base_table.$id_key");
+ }
+ else {
+ $select_query = db_select($tablename, $table_alias);
+ $select_query->addTag('entity_field_access');
+ $select_query->addMetaData('base_table', $tablename);
+ $select_query->fields($table_alias, array('entity_type', 'entity_id', 'revision_id', 'bundle'));
+ $field_base_table = $table_alias;
+ }
+ if ($field['cardinality'] != 1 || $field['translatable']) {
+ $select_query->distinct();
+ }
+ }
+
+ // Add field conditions. We need a fresh grouping cache.
+ drupal_static_reset('_field_sql_storage_query_field_conditions');
+ _field_sql_storage_query_field_conditions($query, $select_query, $query->fieldConditions, $table_aliases, '_field_sql_storage_columnname');
+
+ // Add field meta conditions.
+ _field_sql_storage_query_field_conditions($query, $select_query, $query->fieldMetaConditions, $table_aliases, function ($field_name, $column) { return $column; });
+
+ if (isset($query->deleted)) {
+ $select_query->condition("$field_base_table.deleted", (int) $query->deleted);
+ }
+
+ // Is there a need to sort the query by property?
+ $has_property_order = FALSE;
+ foreach ($query->order as $order) {
+ if ($order['type'] == 'property') {
+ $has_property_order = TRUE;
+ }
+ }
+
+ if ($query->propertyConditions || $has_property_order) {
+ if (empty($query->entityConditions['entity_type']['value'])) {
+ throw new EntityFieldQueryException('Property conditions and orders must have an entity type defined.');
+ }
+ $entity_type = $query->entityConditions['entity_type']['value'];
+ $entity_base_table = _field_sql_storage_query_join_entity($select_query, $entity_type, $field_base_table);
+ $query->entityConditions['entity_type']['operator'] = '=';
+ foreach ($query->propertyConditions as $property_condition) {
+ $query->addCondition($select_query, "$entity_base_table." . $property_condition['column'], $property_condition);
+ }
+ }
+ foreach ($query->entityConditions as $key => $condition) {
+ $query->addCondition($select_query, "$field_base_table.$key", $condition);
+ }
+
+ // Order the query.
+ foreach ($query->order as $order) {
+ if ($order['type'] == 'entity') {
+ $key = $order['specifier'];
+ $select_query->orderBy("$field_base_table.$key", $order['direction']);
+ }
+ elseif ($order['type'] == 'field') {
+ $specifier = $order['specifier'];
+ $field = $specifier['field'];
+ $table_alias = $table_aliases[$specifier['index']];
+ $sql_field = "$table_alias." . _field_sql_storage_columnname($field['field_name'], $specifier['column']);
+ $select_query->orderBy($sql_field, $order['direction']);
+ }
+ elseif ($order['type'] == 'property') {
+ $select_query->orderBy("$entity_base_table." . $order['specifier'], $order['direction']);
+ }
+ }
+
+ return $query->finishQuery($select_query, $id_key);
+}
+
+/**
+ * Adds the base entity table to a field query object.
+ *
+ * @param SelectQuery $select_query
+ * A SelectQuery containing at least one table as specified by
+ * _field_sql_storage_tablename().
+ * @param $entity_type
+ * The entity type for which the base table should be joined.
+ * @param $field_base_table
+ * Name of a table in $select_query. As only INNER JOINs are used, it does
+ * not matter which.
+ *
+ * @return
+ * The name of the entity base table joined in.
+ */
+function _field_sql_storage_query_join_entity(SelectQuery $select_query, $entity_type, $field_base_table) {
+ $entity_info = entity_get_info($entity_type);
+ $entity_base_table = $entity_info['base table'];
+ $entity_field = $entity_info['entity keys']['id'];
+ $select_query->join($entity_base_table, $entity_base_table, "$entity_base_table.$entity_field = $field_base_table.entity_id");
+ return $entity_base_table;
+}
+
+/**
+ * Adds field (meta) conditions to the given query objects respecting groupings.
+ *
+ * @param EntityFieldQuery $query
+ * The field query object to be processed.
+ * @param SelectQuery $select_query
+ * The SelectQuery that should get grouping conditions.
+ * @param condtions
+ * The conditions to be added.
+ * @param $table_aliases
+ * An associative array of table aliases keyed by field index.
+ * @param $column_callback
+ * A callback that should return the column name to be used for the field
+ * conditions. Accepts a field name and a field column name as parameters.
+ */
+function _field_sql_storage_query_field_conditions(EntityFieldQuery $query, SelectQuery $select_query, $conditions, $table_aliases, $column_callback) {
+ $groups = &drupal_static(__FUNCTION__, array());
+ foreach ($conditions as $key => $condition) {
+ $table_alias = $table_aliases[$key];
+ $field = $condition['field'];
+ // Add the specified condition.
+ $sql_field = "$table_alias." . $column_callback($field['field_name'], $condition['column']);
+ $query->addCondition($select_query, $sql_field, $condition);
+ // Add delta / language group conditions.
+ foreach (array('delta', 'language') as $column) {
+ if (isset($condition[$column . '_group'])) {
+ $group_name = $condition[$column . '_group'];
+ if (!isset($groups[$column][$group_name])) {
+ $groups[$column][$group_name] = $table_alias;
+ }
+ else {
+ $select_query->where("$table_alias.$column = " . $groups[$column][$group_name] . ".$column");
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_delete_revision().
+ *
+ * This function actually deletes the data from the database.
+ */
+function field_sql_storage_field_storage_delete_revision($entity_type, $entity, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ if (isset($vid)) {
+ foreach ($fields as $field_id) {
+ $field = field_info_field_by_id($field_id);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_delete($revision_name)
+ ->condition('entity_type', $entity_type)
+ ->condition('entity_id', $id)
+ ->condition('revision_id', $vid)
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_delete_instance().
+ *
+ * This function simply marks for deletion all data associated with the field.
+ */
+function field_sql_storage_field_storage_delete_instance($instance) {
+ $field = field_info_field($instance['field_name']);
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_update($table_name)
+ ->fields(array('deleted' => 1))
+ ->condition('entity_type', $instance['entity_type'])
+ ->condition('bundle', $instance['bundle'])
+ ->execute();
+ db_update($revision_name)
+ ->fields(array('deleted' => 1))
+ ->condition('entity_type', $instance['entity_type'])
+ ->condition('bundle', $instance['bundle'])
+ ->execute();
+}
+
+/**
+ * Implements hook_field_attach_rename_bundle().
+ */
+function field_sql_storage_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ // We need to account for deleted or inactive fields and instances.
+ $instances = field_read_instances(array('entity_type' => $entity_type, 'bundle' => $bundle_new), array('include_deleted' => TRUE, 'include_inactive' => TRUE));
+ foreach ($instances as $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ if ($field['storage']['type'] == 'field_sql_storage') {
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_update($table_name)
+ ->fields(array('bundle' => $bundle_new))
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle_old)
+ ->execute();
+ db_update($revision_name)
+ ->fields(array('bundle' => $bundle_new))
+ ->condition('entity_type', $entity_type)
+ ->condition('bundle', $bundle_old)
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_purge_field().
+ *
+ * All field data items and instances have already been purged, so all
+ * that is left is to delete the table.
+ */
+function field_sql_storage_field_storage_purge_field($field) {
+ $table_name = _field_sql_storage_tablename($field);
+ $revision_name = _field_sql_storage_revision_tablename($field);
+ db_drop_table($table_name);
+ db_drop_table($revision_name);
+}
+
+/**
+ * Implements hook_field_storage_details().
+ */
+function field_sql_storage_field_storage_details($field) {
+ $details = array();
+ if (!empty($field['columns'])) {
+ // Add field columns.
+ foreach ($field['columns'] as $column_name => $attributes) {
+ $real_name = _field_sql_storage_columnname($field['field_name'], $column_name);
+ $columns[$column_name] = $real_name;
+ }
+ return array(
+ 'sql' => array(
+ FIELD_LOAD_CURRENT => array(
+ _field_sql_storage_tablename($field) => $columns,
+ ),
+ FIELD_LOAD_REVISION => array(
+ _field_sql_storage_revision_tablename($field) => $columns,
+ ),
+ ),
+ );
+ }
+}
diff --git a/core/modules/field/modules/field_sql_storage/field_sql_storage.test b/core/modules/field/modules/field_sql_storage/field_sql_storage.test
new file mode 100644
index 000000000000..773de3d072dc
--- /dev/null
+++ b/core/modules/field/modules/field_sql_storage/field_sql_storage.test
@@ -0,0 +1,427 @@
+<?php
+
+/**
+ * @file
+ * Tests for field_sql_storage.module.
+ *
+ * Field_sql_storage.module implements the default back-end storage plugin
+ * for the Field Strage API.
+ */
+
+/**
+ * Tests field storage.
+ */
+class FieldSqlStorageTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field SQL storage tests',
+ 'description' => "Test field SQL storage module.",
+ 'group' => 'Field API'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_sql_storage', 'field', 'field_test', 'text');
+ $this->field_name = strtolower($this->randomName());
+ $this->field = array('field_name' => $this->field_name, 'type' => 'test_field', 'cardinality' => 4);
+ $this->field = field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle'
+ );
+ $this->instance = field_create_instance($this->instance);
+ $this->table = _field_sql_storage_tablename($this->field);
+ $this->revision_table = _field_sql_storage_revision_tablename($this->field);
+
+ }
+
+ /**
+ * Uses the mysql tables and records to verify
+ * field_load_revision works correctly.
+ */
+ function testFieldAttachLoad() {
+ $entity_type = 'test_entity';
+ $eid = 0;
+ $langcode = LANGUAGE_NONE;
+
+ $columns = array('entity_type', 'entity_id', 'revision_id', 'delta', 'language', $this->field_name . '_value');
+
+ // Insert data for four revisions to the field revisions table
+ $query = db_insert($this->revision_table)->fields($columns);
+ for ($evid = 0; $evid < 4; ++$evid) {
+ $values[$evid] = array();
+ // Note: we insert one extra value ('<=' instead of '<').
+ for ($delta = 0; $delta <= $this->field['cardinality']; $delta++) {
+ $value = mt_rand(1, 127);
+ $values[$evid][] = $value;
+ $query->values(array($entity_type, $eid, $evid, $delta, $langcode, $value));
+ }
+ }
+ $query->execute();
+
+ // Insert data for the "most current revision" into the field table
+ $query = db_insert($this->table)->fields($columns);
+ foreach ($values[0] as $delta => $value) {
+ $query->values(array($entity_type, $eid, 0, $delta, $langcode, $value));
+ }
+ $query->execute();
+
+ // Load the "most current revision"
+ $entity = field_test_create_stub_entity($eid, 0, $this->instance['bundle']);
+ field_attach_load($entity_type, array($eid => $entity));
+ foreach ($values[0] as $delta => $value) {
+ if ($delta < $this->field['cardinality']) {
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['value'], $value, "Value $delta is loaded correctly for current revision");
+ }
+ else {
+ $this->assertFalse(array_key_exists($delta, $entity->{$this->field_name}[$langcode]), "No extraneous value gets loaded for current revision.");
+ }
+ }
+
+ // Load every revision
+ for ($evid = 0; $evid < 4; ++$evid) {
+ $entity = field_test_create_stub_entity($eid, $evid, $this->instance['bundle']);
+ field_attach_load_revision($entity_type, array($eid => $entity));
+ foreach ($values[$evid] as $delta => $value) {
+ if ($delta < $this->field['cardinality']) {
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['value'], $value, "Value $delta for revision $evid is loaded correctly");
+ }
+ else {
+ $this->assertFalse(array_key_exists($delta, $entity->{$this->field_name}[$langcode]), "No extraneous value gets loaded for revision $evid.");
+ }
+ }
+ }
+
+ // Add a translation in an unavailable language and verify it is not loaded.
+ $eid = $evid = 1;
+ $unavailable_language = 'xx';
+ $entity = field_test_create_stub_entity($eid, $evid, $this->instance['bundle']);
+ $values = array($entity_type, $eid, $evid, 0, $unavailable_language, mt_rand(1, 127));
+ db_insert($this->table)->fields($columns)->values($values)->execute();
+ db_insert($this->revision_table)->fields($columns)->values($values)->execute();
+ field_attach_load($entity_type, array($eid => $entity));
+ $this->assertFalse(array_key_exists($unavailable_language, $entity->{$this->field_name}), 'Field translation in an unavailable language ignored');
+ }
+
+ /**
+ * Reads mysql to verify correct data is
+ * written when using insert and update.
+ */
+ function testFieldAttachInsertAndUpdate() {
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+
+ // Test insert.
+ $values = array();
+ // Note: we try to insert one extra value ('<=' instead of '<').
+ // TODO : test empty values filtering and "compression" (store consecutive deltas).
+ for ($delta = 0; $delta <= $this->field['cardinality']; $delta++) {
+ $values[$delta]['value'] = mt_rand(1, 127);
+ }
+ $entity->{$this->field_name}[$langcode] = $rev_values[0] = $values;
+ field_attach_insert($entity_type, $entity);
+
+ $rows = db_select($this->table, 't')->fields('t')->execute()->fetchAllAssoc('delta', PDO::FETCH_ASSOC);
+ foreach ($values as $delta => $value) {
+ if ($delta < $this->field['cardinality']) {
+ $this->assertEqual($rows[$delta][$this->field_name . '_value'], $value['value'], t("Value $delta is inserted correctly"));
+ }
+ else {
+ $this->assertFalse(array_key_exists($delta, $rows), "No extraneous value gets inserted.");
+ }
+ }
+
+ // Test update.
+ $entity = field_test_create_stub_entity(0, 1, $this->instance['bundle']);
+ $values = array();
+ // Note: we try to update one extra value ('<=' instead of '<').
+ for ($delta = 0; $delta <= $this->field['cardinality']; $delta++) {
+ $values[$delta]['value'] = mt_rand(1, 127);
+ }
+ $entity->{$this->field_name}[$langcode] = $rev_values[1] = $values;
+ field_attach_update($entity_type, $entity);
+ $rows = db_select($this->table, 't')->fields('t')->execute()->fetchAllAssoc('delta', PDO::FETCH_ASSOC);
+ foreach ($values as $delta => $value) {
+ if ($delta < $this->field['cardinality']) {
+ $this->assertEqual($rows[$delta][$this->field_name . '_value'], $value['value'], t("Value $delta is updated correctly"));
+ }
+ else {
+ $this->assertFalse(array_key_exists($delta, $rows), "No extraneous value gets updated.");
+ }
+ }
+
+ // Check that data for both revisions are in the revision table.
+ // We make sure each value is stored correctly, then unset it.
+ // When an entire revision's values are unset (remembering that we
+ // put one extra value in $values per revision), unset the entire
+ // revision. Then, if $rev_values is empty at the end, all
+ // revision data was found.
+ $results = db_select($this->revision_table, 't')->fields('t')->execute();
+ foreach ($results as $row) {
+ $this->assertEqual($row->{$this->field_name . '_value'}, $rev_values[$row->revision_id][$row->delta]['value'], "Value {$row->delta} for revision {$row->revision_id} stored correctly");
+ unset($rev_values[$row->revision_id][$row->delta]);
+ if (count($rev_values[$row->revision_id]) == 1) {
+ unset($rev_values[$row->revision_id]);
+ }
+ }
+ $this->assertTrue(empty($rev_values), "All values for all revisions are stored in revision table {$this->revision_table}");
+
+ // Check that update leaves the field data untouched if
+ // $entity->{$field_name} is absent.
+ unset($entity->{$this->field_name});
+ field_attach_update($entity_type, $entity);
+ $rows = db_select($this->table, 't')->fields('t')->execute()->fetchAllAssoc('delta', PDO::FETCH_ASSOC);
+ foreach ($values as $delta => $value) {
+ if ($delta < $this->field['cardinality']) {
+ $this->assertEqual($rows[$delta][$this->field_name . '_value'], $value['value'], t("Update with no field_name entry leaves value $delta untouched"));
+ }
+ }
+
+ // Check that update with an empty $entity->$field_name empties the field.
+ $entity->{$this->field_name} = NULL;
+ field_attach_update($entity_type, $entity);
+ $rows = db_select($this->table, 't')->fields('t')->execute()->fetchAllAssoc('delta', PDO::FETCH_ASSOC);
+ $this->assertEqual(count($rows), 0, t("Update with an empty field_name entry empties the field."));
+ }
+
+ /**
+ * Tests insert and update with missing or NULL fields.
+ */
+ function testFieldAttachSaveMissingData() {
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+
+ // Insert: Field is missing
+ field_attach_insert($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 0, 'Missing field results in no inserts');
+
+ // Insert: Field is NULL
+ $entity->{$this->field_name} = NULL;
+ field_attach_insert($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 0, 'NULL field results in no inserts');
+
+ // Add some real data
+ $entity->{$this->field_name}[$langcode] = array(0 => array('value' => 1));
+ field_attach_insert($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 1, 'Field data saved');
+
+ // Update: Field is missing. Data should survive.
+ unset($entity->{$this->field_name});
+ field_attach_update($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 1, 'Missing field leaves data in table');
+
+ // Update: Field is NULL. Data should be wiped.
+ $entity->{$this->field_name} = NULL;
+ field_attach_update($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 0, 'NULL field leaves no data in table');
+
+ // Add a translation in an unavailable language.
+ $unavailable_language = 'xx';
+ db_insert($this->table)
+ ->fields(array('entity_type', 'bundle', 'deleted', 'entity_id', 'revision_id', 'delta', 'language'))
+ ->values(array($entity_type, $this->instance['bundle'], 0, 0, 0, 0, $unavailable_language))
+ ->execute();
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 1, 'Field translation in an unavailable language saved.');
+
+ // Again add some real data.
+ $entity->{$this->field_name}[$langcode] = array(0 => array('value' => 1));
+ field_attach_insert($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 2, 'Field data saved.');
+
+ // Update: Field translation is missing but field is not empty. Translation
+ // data should survive.
+ $entity->{$this->field_name}[$unavailable_language] = array(mt_rand(1, 127));
+ unset($entity->{$this->field_name}[$langcode]);
+ field_attach_update($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 2, 'Missing field translation leaves data in table.');
+
+ // Update: Field translation is NULL but field is not empty. Translation
+ // data should be wiped.
+ $entity->{$this->field_name}[$langcode] = NULL;
+ field_attach_update($entity_type, $entity);
+ $count = db_select($this->table)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 1, 'NULL field translation is wiped.');
+ }
+
+ /**
+ * Test trying to update a field with data.
+ */
+ function testUpdateFieldSchemaWithData() {
+ // Create a decimal 5.2 field and add some data.
+ $field = array('field_name' => 'decimal52', 'type' => 'number_decimal', 'settings' => array('precision' => 5, 'scale' => 2));
+ $field = field_create_field($field);
+ $instance = array('field_name' => 'decimal52', 'entity_type' => 'test_entity', 'bundle' => 'test_bundle');
+ $instance = field_create_instance($instance);
+ $entity = field_test_create_stub_entity(0, 0, $instance['bundle']);
+ $entity->decimal52[LANGUAGE_NONE][0]['value'] = '1.235';
+ field_attach_insert('test_entity', $entity);
+
+ // Attempt to update the field in a way that would work without data.
+ $field['settings']['scale'] = 3;
+ try {
+ field_update_field($field);
+ $this->fail(t('Cannot update field schema with data.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot update field schema with data.'));
+ }
+ }
+
+ /**
+ * Test that failure to create fields is handled gracefully.
+ */
+ function testFieldUpdateFailure() {
+ // Create a text field.
+ $field = array('field_name' => 'test_text', 'type' => 'text', 'settings' => array('max_length' => 255));
+ $field = field_create_field($field);
+
+ // Attempt to update the field in a way that would break the storage.
+ $prior_field = $field;
+ $field['settings']['max_length'] = -1;
+ try {
+ field_update_field($field);
+ $this->fail(t('Update succeeded.'));
+ }
+ catch (Exception $e) {
+ $this->pass(t('Update properly failed.'));
+ }
+
+ // Ensure that the field tables are still there.
+ foreach (_field_sql_storage_schema($prior_field) as $table_name => $table_info) {
+ $this->assertTrue(db_table_exists($table_name), t('Table %table exists.', array('%table' => $table_name)));
+ }
+ }
+
+ /**
+ * Test adding and removing indexes while data is present.
+ */
+ function testFieldUpdateIndexesWithData() {
+
+ // Create a decimal field.
+ $field_name = 'testfield';
+ $field = array('field_name' => $field_name, 'type' => 'text');
+ $field = field_create_field($field);
+ $instance = array('field_name' => $field_name, 'entity_type' => 'test_entity', 'bundle' => 'test_bundle');
+ $instance = field_create_instance($instance);
+ $tables = array(_field_sql_storage_tablename($field), _field_sql_storage_revision_tablename($field));
+
+ // Verify the indexes we will create do not exist yet.
+ foreach ($tables as $table) {
+ $this->assertFalse(Database::getConnection()->schema()->indexExists($table, 'value'), t("No index named value exists in $table"));
+ $this->assertFalse(Database::getConnection()->schema()->indexExists($table, 'value_format'), t("No index named value_format exists in $table"));
+ }
+
+ // Add data so the table cannot be dropped.
+ $entity = field_test_create_stub_entity(0, 0, $instance['bundle']);
+ $entity->{$field_name}[LANGUAGE_NONE][0]['value'] = 'field data';
+ field_attach_insert('test_entity', $entity);
+
+ // Add an index
+ $field = array('field_name' => $field_name, 'indexes' => array('value' => array('value')));
+ field_update_field($field);
+ foreach ($tables as $table) {
+ $this->assertTrue(Database::getConnection()->schema()->indexExists($table, "{$field_name}_value"), t("Index on value created in $table"));
+ }
+
+ // Add a different index, removing the existing custom one.
+ $field = array('field_name' => $field_name, 'indexes' => array('value_format' => array('value', 'format')));
+ field_update_field($field);
+ foreach ($tables as $table) {
+ $this->assertTrue(Database::getConnection()->schema()->indexExists($table, "{$field_name}_value_format"), t("Index on value_format created in $table"));
+ $this->assertFalse(Database::getConnection()->schema()->indexExists($table, "{$field_name}_value"), t("Index on value removed in $table"));
+ }
+
+ // Verify that the tables were not dropped.
+ $entity = field_test_create_stub_entity(0, 0, $instance['bundle']);
+ field_attach_load('test_entity', array(0 => $entity));
+ $this->assertEqual($entity->{$field_name}[LANGUAGE_NONE][0]['value'], 'field data', t("Index changes performed without dropping the tables"));
+ }
+
+ /**
+ * Test the storage details.
+ */
+ function testFieldStorageDetails() {
+ $current = _field_sql_storage_tablename($this->field);
+ $revision = _field_sql_storage_revision_tablename($this->field);
+
+ // Retrieve the field and instance with field_info so the storage details are attached.
+ $field = field_info_field($this->field['field_name']);
+ $instance = field_info_instance($this->instance['entity_type'], $this->instance['field_name'], $this->instance['bundle']);
+
+ // The storage details are indexed by a storage engine type.
+ $this->assertTrue(array_key_exists('sql', $field['storage']['details']), t('The storage type is SQL.'));
+
+ // The SQL details are indexed by table name.
+ $details = $field['storage']['details']['sql'];
+ $this->assertTrue(array_key_exists($current, $details[FIELD_LOAD_CURRENT]), t('Table name is available in the instance array.'));
+ $this->assertTrue(array_key_exists($revision, $details[FIELD_LOAD_REVISION]), t('Revision table name is available in the instance array.'));
+
+ // Test current and revision storage details together because the columns
+ // are the same.
+ foreach ((array) $this->field['columns'] as $column_name => $attributes) {
+ $storage_column_name = _field_sql_storage_columnname($this->field['field_name'], $column_name);
+ $this->assertEqual($details[FIELD_LOAD_CURRENT][$current][$column_name], $storage_column_name, t('Column name %value matches the definition in %bin.', array('%value' => $column_name, '%bin' => $current)));
+ $this->assertEqual($details[FIELD_LOAD_REVISION][$revision][$column_name], $storage_column_name, t('Column name %value matches the definition in %bin.', array('%value' => $column_name, '%bin' => $revision)));
+ }
+ }
+
+ /**
+ * Test foreign key support.
+ */
+ function testFieldSqlStorageForeignKeys() {
+ // Create a decimal field.
+ $field_name = 'testfield';
+ $field = array('field_name' => $field_name, 'type' => 'text');
+ $field = field_create_field($field);
+ // Retrieve the field and instance with field_info and verify the foreign
+ // keys are in place.
+ $field = field_info_field($field_name);
+ $this->assertEqual($field['foreign keys']['format']['table'], 'filter_format', t('Foreign key table name preserved through CRUD'));
+ $this->assertEqual($field['foreign keys']['format']['columns']['format'], 'format', t('Foreign key column name preserved through CRUD'));
+ // Now grab the SQL schema and verify that too.
+ $schema = drupal_get_schema(_field_sql_storage_tablename($field));
+ $this->assertEqual(count($schema['foreign keys']), 1, t("There is 1 foreign key in the schema"));
+ $foreign_key = reset($schema['foreign keys']);
+ $filter_column = _field_sql_storage_columnname($field['field_name'], 'format');
+ $this->assertEqual($foreign_key['table'], 'filter_format', t('Foreign key table name preserved in the schema'));
+ $this->assertEqual($foreign_key['columns'][$filter_column], 'format', t('Foreign key column name preserved in the schema'));
+ }
+}
diff --git a/core/modules/field/modules/list/list.info b/core/modules/field/modules/list/list.info
new file mode 100644
index 000000000000..6eb711799b79
--- /dev/null
+++ b/core/modules/field/modules/list/list.info
@@ -0,0 +1,8 @@
+name = List
+description = Defines list field types. Use with Options to create selection lists.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+dependencies[] = options
+files[] = tests/list.test
diff --git a/core/modules/field/modules/list/list.install b/core/modules/field/modules/list/list.install
new file mode 100644
index 000000000000..c86a21919c13
--- /dev/null
+++ b/core/modules/field/modules/list/list.install
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the list module.
+ */
+
+/**
+ * Implements hook_field_schema().
+ */
+function list_field_schema($field) {
+ switch ($field['type']) {
+ case 'list_text':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+ case 'list_float':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'float',
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+ case 'list_integer':
+ case 'list_boolean':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'int',
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+ }
+ return array(
+ 'columns' => $columns,
+ 'indexes' => array(
+ 'value' => array('value'),
+ ),
+ );
+}
diff --git a/core/modules/field/modules/list/list.module b/core/modules/field/modules/list/list.module
new file mode 100644
index 000000000000..652355142788
--- /dev/null
+++ b/core/modules/field/modules/list/list.module
@@ -0,0 +1,471 @@
+<?php
+
+/**
+ * @file
+ * Defines list field types that can be used with the Options module.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function list_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#list':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The List module defines various fields for storing a list of items, for use with the Field module. Usually these items are entered through a select list, checkboxes, or radio buttons. See the <a href="@field-help">Field module help page</a> for more information about fields.', array('@field-help' => url('admin/help/field'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_field_info().
+ */
+function list_field_info() {
+ return array(
+ 'list_integer' => array(
+ 'label' => t('List (integer)'),
+ 'description' => t("This field stores integer values from a list of allowed 'value => label' pairs, i.e. 'Lifetime in days': 1 => 1 day, 7 => 1 week, 31 => 1 month."),
+ 'settings' => array('allowed_values' => array(), 'allowed_values_function' => ''),
+ 'default_widget' => 'options_select',
+ 'default_formatter' => 'list_default',
+ ),
+ 'list_float' => array(
+ 'label' => t('List (float)'),
+ 'description' => t("This field stores float values from a list of allowed 'value => label' pairs, i.e. 'Fraction': 0 => 0, .25 => 1/4, .75 => 3/4, 1 => 1."),
+ 'settings' => array('allowed_values' => array(), 'allowed_values_function' => ''),
+ 'default_widget' => 'options_select',
+ 'default_formatter' => 'list_default',
+ ),
+ 'list_text' => array(
+ 'label' => t('List (text)'),
+ 'description' => t("This field stores text values from a list of allowed 'value => label' pairs, i.e. 'US States': IL => Illinois, IA => Iowa, IN => Indiana."),
+ 'settings' => array('allowed_values' => array(), 'allowed_values_function' => ''),
+ 'default_widget' => 'options_select',
+ 'default_formatter' => 'list_default',
+ ),
+ 'list_boolean' => array(
+ 'label' => t('Boolean'),
+ 'description' => t('This field stores simple on/off or yes/no options.'),
+ 'settings' => array('allowed_values' => array(), 'allowed_values_function' => ''),
+ 'default_widget' => 'options_buttons',
+ 'default_formatter' => 'list_default',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function list_field_settings_form($field, $instance, $has_data) {
+ $settings = $field['settings'];
+
+ switch ($field['type']) {
+ case 'list_integer':
+ case 'list_float':
+ case 'list_text':
+ $form['allowed_values'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Allowed values list'),
+ '#default_value' => list_allowed_values_string($settings['allowed_values']),
+ '#rows' => 10,
+ '#element_validate' => array('list_allowed_values_setting_validate'),
+ '#field_has_data' => $has_data,
+ '#field' => $field,
+ '#field_type' => $field['type'],
+ '#access' => empty($settings['allowed_values_function']),
+ );
+
+ $description = '<p>' . t('The possible values this field can contain. Enter one value per line, in the format key|label.');
+ if ($field['type'] == 'list_integer' || $field['type'] == 'list_float') {
+ $description .= '<br/>' . t('The key is the stored value, and must be numeric. The label will be used in displayed values and edit forms.');
+ $description .= '<br/>' . t('The label is optional: if a line contains a single number, it will be used as key and label.');
+ $description .= '<br/>' . t('Lists of labels are also accepted (one label per line), only if the field does not hold any values yet. Numeric keys will be automatically generated from the positions in the list.');
+ }
+ else {
+ $description .= '<br/>' . t('The key is the stored value. The label will be used in displayed values and edit forms.');
+ $description .= '<br/>' . t('The label is optional: if a line contains a single string, it will be used as key and label.');
+ }
+ $description .= '</p>';
+ $form['allowed_values']['#description'] = $description;
+
+ break;
+
+ case 'list_boolean':
+ $values = $settings['allowed_values'];
+ $off_value = array_shift($values);
+ $on_value = array_shift($values);
+
+ $form['allowed_values'] = array(
+ '#type' => 'value',
+ '#description' => '',
+ '#value_callback' => 'list_boolean_allowed_values_callback',
+ '#access' => empty($settings['allowed_values_function']),
+ );
+ $form['allowed_values']['on'] = array(
+ '#type' => 'textfield',
+ '#title' => t('On value'),
+ '#default_value' => $on_value,
+ '#required' => FALSE,
+ '#description' => t('If left empty, "1" will be used.'),
+ // Change #parents to make sure the element is not saved into field
+ // settings.
+ '#parents' => array('on'),
+ );
+ $form['allowed_values']['off'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Off value'),
+ '#default_value' => $off_value,
+ '#required' => FALSE,
+ '#description' => t('If left empty, "0" will be used.'),
+ // Change #parents to make sure the element is not saved into field
+ // settings.
+ '#parents' => array('off'),
+ );
+
+ // Link the allowed value to the on / off elements to prepare for the rare
+ // case of an alter changing #parents.
+ $form['allowed_values']['#on_parents'] = &$form['allowed_values']['on']['#parents'];
+ $form['allowed_values']['#off_parents'] = &$form['allowed_values']['off']['#parents'];
+
+ break;
+ }
+
+ // Alter the description for allowed values depending on the widget type.
+ if ($instance['widget']['type'] == 'options_onoff') {
+ $form['allowed_values']['#description'] .= '<p>' . t("For a 'single on/off checkbox' widget, define the 'off' value first, then the 'on' value in the <strong>Allowed values</strong> section. Note that the checkbox will be labeled with the label of the 'on' value.") . '</p>';
+ }
+ elseif ($instance['widget']['type'] == 'options_buttons') {
+ $form['allowed_values']['#description'] .= '<p>' . t("The 'checkboxes/radio buttons' widget will display checkboxes if the <em>Number of values</em> option is greater than 1 for this field, otherwise radios will be displayed.") . '</p>';
+ }
+ $form['allowed_values']['#description'] .= '<p>' . t('Allowed HTML tags in labels: @tags', array('@tags' => _field_filter_xss_display_allowed_tags())) . '</p>';
+
+ $form['allowed_values_function'] = array(
+ '#type' => 'value',
+ '#value' => $settings['allowed_values_function'],
+ );
+ $form['allowed_values_function_display'] = array(
+ '#type' => 'item',
+ '#title' => t('Allowed values list'),
+ '#markup' => t('The value of this field is being determined by the %function function and may not be changed.', array('%function' => $settings['allowed_values_function'])),
+ '#access' => !empty($settings['allowed_values_function']),
+ );
+
+ return $form;
+}
+
+/**
+ * Element validate callback; check that the entered values are valid.
+ */
+function list_allowed_values_setting_validate($element, &$form_state) {
+ $field = $element['#field'];
+ $has_data = $element['#field_has_data'];
+ $field_type = $field['type'];
+ $generate_keys = ($field_type == 'list_integer' || $field_type == 'list_float') && !$has_data;
+
+ $values = list_extract_allowed_values($element['#value'], $field['type'], $generate_keys);
+
+ if (!is_array($values)) {
+ form_error($element, t('Allowed values list: invalid input.'));
+ }
+ else {
+ // Check that keys are valid for the field type.
+ foreach ($values as $key => $value) {
+ if ($field_type == 'list_integer' && !preg_match('/^-?\d+$/', $key)) {
+ form_error($element, t('Allowed values list: keys must be integers.'));
+ break;
+ }
+ if ($field_type == 'list_float' && !is_numeric($key)) {
+ form_error($element, t('Allowed values list: each key must be a valid integer or decimal.'));
+ break;
+ }
+ elseif ($field_type == 'list_text' && drupal_strlen($key) > 255) {
+ form_error($element, t('Allowed values list: each key must be a string at most 255 characters long.'));
+ break;
+ }
+ }
+
+ // Prevent removing values currently in use.
+ if ($has_data) {
+ $lost_keys = array_diff(array_keys($field['settings']['allowed_values']), array_keys($values));
+ if (_list_values_in_use($field, $lost_keys)) {
+ form_error($element, t('Allowed values list: some values are being removed while currently in use.'));
+ }
+ }
+
+ form_set_value($element, $values, $form_state);
+ }
+}
+
+/**
+* Form element #value_callback: assembles the allowed values for 'boolean' fields.
+*/
+function list_boolean_allowed_values_callback($element, $input, $form_state) {
+ $on = drupal_array_get_nested_value($form_state['input'], $element['#on_parents']);
+ $off = drupal_array_get_nested_value($form_state['input'], $element['#off_parents']);
+ return array($off, $on);
+}
+
+/**
+ * Implements hook_field_update_field().
+ */
+function list_field_update_field($field, $prior_field, $has_data) {
+ drupal_static_reset('list_allowed_values');
+}
+
+/**
+ * Returns the array of allowed values for a list field.
+ *
+ * The strings are not safe for output. Keys and values of the array should be
+ * sanitized through field_filter_xss() before being displayed.
+ *
+ * @param $field
+ * The field definition.
+ *
+ * @return
+ * The array of allowed values. Keys of the array are the raw stored values
+ * (number or text), values of the array are the display labels.
+ */
+function list_allowed_values($field) {
+ $allowed_values = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($allowed_values[$field['id']])) {
+ $function = $field['settings']['allowed_values_function'];
+ if (!empty($function) && function_exists($function)) {
+ $values = $function($field);
+ }
+ else {
+ $values = $field['settings']['allowed_values'];
+ }
+
+ $allowed_values[$field['id']] = $values;
+ }
+
+ return $allowed_values[$field['id']];
+}
+
+/**
+ * Parses a string of 'allowed values' into an array.
+ *
+ * @param $string
+ * The list of allowed values in string format described in
+ * list_allowed_values_string().
+ * @param $field_type
+ * The field type. Either 'list_number' or 'list_text'.
+ * @param $generate_keys
+ * Boolean value indicating whether to generate keys based on the position of
+ * the value if a key is not manually specified, and if the value cannot be
+ * used as a key. This should only be TRUE for fields of type 'list_number'.
+ *
+ * @return
+ * The array of extracted key/value pairs, or NULL if the string is invalid.
+ *
+ * @see list_allowed_values_string()
+ */
+function list_extract_allowed_values($string, $field_type, $generate_keys) {
+ $values = array();
+
+ $list = explode("\n", $string);
+ $list = array_map('trim', $list);
+ $list = array_filter($list, 'strlen');
+
+ $generated_keys = $explicit_keys = FALSE;
+ foreach ($list as $position => $text) {
+ $value = $key = FALSE;
+
+ // Check for an explicit key.
+ $matches = array();
+ if (preg_match('/(.*)\|(.*)/', $text, $matches)) {
+ $key = $matches[1];
+ $value = $matches[2];
+ $explicit_keys = TRUE;
+ }
+ // Otherwise see if we can use the value as the key. Detecting true integer
+ // strings takes a little trick.
+ elseif ($field_type == 'list_text'
+ || ($field_type == 'list_float' && is_numeric($text))
+ || ($field_type == 'list_integer' && is_numeric($text) && (float) $text == intval($text))) {
+ $key = $value = $text;
+ $explicit_keys = TRUE;
+ }
+ // Otherwise see if we can generate a key from the position.
+ elseif ($generate_keys) {
+ $key = (string) $position;
+ $value = $text;
+ $generated_keys = TRUE;
+ }
+ else {
+ return;
+ }
+
+ // Float keys are represented as strings and need to be disambiguated
+ // ('.5' is '0.5').
+ if ($field_type == 'list_float' && is_numeric($key)) {
+ $key = (string) (float) $key;
+ }
+
+ $values[$key] = $value;
+ }
+
+ // We generate keys only if the list contains no explicit key at all.
+ if ($explicit_keys && $generated_keys) {
+ return;
+ }
+
+ return $values;
+}
+
+/**
+ * Generates a string representation of an array of 'allowed values'.
+ *
+ * This string format is suitable for edition in a textarea.
+ *
+ * @param $values
+ * An array of values, where array keys are values and array values are
+ * labels.
+ *
+ * @return
+ * The string representation of the $values array:
+ * - Values are separated by a carriage return.
+ * - Each value is in the format "value|label" or "value".
+ */
+function list_allowed_values_string($values) {
+ $lines = array();
+ foreach ($values as $key => $value) {
+ $lines[] = "$key|$value";
+ }
+ return implode("\n", $lines);
+}
+
+/**
+ * Implements hook_field_update_forbid().
+ */
+function list_field_update_forbid($field, $prior_field, $has_data) {
+ if ($field['module'] == 'list' && $has_data) {
+ // Forbid any update that removes allowed values with actual data.
+ $lost_keys = array_diff(array_keys($prior_field['settings']['allowed_values']), array_keys($field['settings']['allowed_values']));
+ if (_list_values_in_use($field, $lost_keys)) {
+ throw new FieldUpdateForbiddenException(t('Cannot update a list field to not include keys with existing data.'));
+ }
+ }
+}
+
+/**
+ * Checks if a list of values are being used in actual field values.
+ */
+function _list_values_in_use($field, $values) {
+ if ($values) {
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($field['field_name'], 'value', $values)
+ ->range(0, 1)
+ ->execute();
+ return !empty($found);
+ }
+
+ return FALSE;
+}
+
+/**
+ * Implements hook_field_validate().
+ *
+ * Possible error codes:
+ * - 'list_illegal_value': The value is not part of the list of allowed values.
+ */
+function list_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ $allowed_values = list_allowed_values($field);
+ foreach ($items as $delta => $item) {
+ if (!empty($item['value'])) {
+ if (!empty($allowed_values) && !isset($allowed_values[$item['value']])) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'list_illegal_value',
+ 'message' => t('%name: illegal value.', array('%name' => $instance['label'])),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function list_field_is_empty($item, $field) {
+ if (empty($item['value']) && (string) $item['value'] !== '0') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_field_widget_info_alter().
+ *
+ * The List module does not implement widgets of its own, but reuses the
+ * widgets defined in options.module.
+ *
+ * @see list_options_list()
+ */
+function list_field_widget_info_alter(&$info) {
+ $widgets = array(
+ 'options_select' => array('list_integer', 'list_float', 'list_text'),
+ 'options_buttons' => array('list_integer', 'list_float', 'list_text', 'list_boolean'),
+ 'options_onoff' => array('list_boolean'),
+ );
+
+ foreach ($widgets as $widget => $field_types) {
+ $info[$widget]['field types'] = array_merge($info[$widget]['field types'], $field_types);
+ }
+}
+
+/**
+ * Implements hook_options_list().
+ */
+function list_options_list($field, $instance) {
+ return list_allowed_values($field);
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function list_field_formatter_info() {
+ return array(
+ 'list_default' => array(
+ 'label' => t('Default'),
+ 'field types' => array('list_integer', 'list_float', 'list_text', 'list_boolean'),
+ ),
+ 'list_key' => array(
+ 'label' => t('Key'),
+ 'field types' => array('list_integer', 'list_float', 'list_text', 'list_boolean'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function list_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+
+ switch ($display['type']) {
+ case 'list_default':
+ $allowed_values = list_allowed_values($field);
+ foreach ($items as $delta => $item) {
+ if (isset($allowed_values[$item['value']])) {
+ $output = field_filter_xss($allowed_values[$item['value']]);
+ }
+ else {
+ // If no match was found in allowed values, fall back to the key.
+ $output = field_filter_xss($item['value']);
+ }
+ $element[$delta] = array('#markup' => $output);
+ }
+ break;
+
+ case 'list_key':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => field_filter_xss($item['value']));
+ }
+ break;
+ }
+
+ return $element;
+}
diff --git a/core/modules/field/modules/list/tests/list.test b/core/modules/field/modules/list/tests/list.test
new file mode 100644
index 000000000000..765901a84993
--- /dev/null
+++ b/core/modules/field/modules/list/tests/list.test
@@ -0,0 +1,374 @@
+<?php
+
+/**
+ * @file
+ * Tests for list.module.
+ */
+
+/**
+ * Tests for the 'List' field types.
+ */
+class ListFieldTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'List field',
+ 'description' => 'Test the List field type.',
+ 'group' => 'Field types',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ $this->field_name = 'test_list';
+ $this->field = array(
+ 'field_name' => $this->field_name,
+ 'type' => 'list_integer',
+ 'cardinality' => 1,
+ 'settings' => array(
+ 'allowed_values' => array(1 => 'One', 2 => 'Two', 3 => 'Three'),
+ ),
+ );
+ $this->field = field_create_field($this->field);
+
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_buttons',
+ ),
+ );
+ $this->instance = field_create_instance($this->instance);
+ }
+
+ /**
+ * Test that allowed values can be updated.
+ */
+ function testUpdateAllowedValues() {
+ $langcode = LANGUAGE_NONE;
+
+ // All three options appear.
+ $entity = field_test_create_stub_entity();
+ $form = drupal_get_form('field_test_entity_form', $entity);
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists'));
+
+ // Use one of the values in an actual entity, and check that this value
+ // cannot be removed from the list.
+ $entity = field_test_create_stub_entity();
+ $entity->{$this->field_name}[$langcode][0] = array('value' => 1);
+ field_test_entity_save($entity);
+ $this->field['settings']['allowed_values'] = array(2 => 'Two');
+ try {
+ field_update_field($this->field);
+ $this->fail(t('Cannot update a list field to not include keys with existing data.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot update a list field to not include keys with existing data.'));
+ }
+ // Empty the value, so that we can actually remove the option.
+ $entity->{$this->field_name}[$langcode] = array();
+ field_test_entity_save($entity);
+
+ // Removed options do not appear.
+ $this->field['settings']['allowed_values'] = array(2 => 'Two');
+ field_update_field($this->field);
+ $entity = field_test_create_stub_entity();
+ $form = drupal_get_form('field_test_entity_form', $entity);
+ $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+ $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist'));
+
+ // Completely new options appear.
+ $this->field['settings']['allowed_values'] = array(10 => 'Update', 20 => 'Twenty');
+ field_update_field($this->field);
+ $form = drupal_get_form('field_test_entity_form', $entity);
+ $this->assertTrue(empty($form[$this->field_name][$langcode][1]), t('Option 1 does not exist'));
+ $this->assertTrue(empty($form[$this->field_name][$langcode][2]), t('Option 2 does not exist'));
+ $this->assertTrue(empty($form[$this->field_name][$langcode][3]), t('Option 3 does not exist'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][10]), t('Option 10 exists'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][20]), t('Option 20 exists'));
+
+ // Options are reset when a new field with the same name is created.
+ field_delete_field($this->field_name);
+ unset($this->field['id']);
+ $this->field['settings']['allowed_values'] = array(1 => 'One', 2 => 'Two', 3 => 'Three');
+ $this->field = field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_buttons',
+ ),
+ );
+ $this->instance = field_create_instance($this->instance);
+ $entity = field_test_create_stub_entity();
+ $form = drupal_get_form('field_test_entity_form', $entity);
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][1]), t('Option 1 exists'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][2]), t('Option 2 exists'));
+ $this->assertTrue(!empty($form[$this->field_name][$langcode][3]), t('Option 3 exists'));
+ }
+}
+
+/**
+ * List module UI tests.
+ */
+class ListFieldUITestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'List field UI',
+ 'description' => 'Test the List field UI functionality.',
+ 'group' => 'Field types',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test', 'field_ui');
+
+ // Create test user.
+ $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer taxonomy'));
+ $this->drupalLogin($admin_user);
+
+ // Create content type, with underscores.
+ $type_name = 'test_' . strtolower($this->randomName());
+ $type = $this->drupalCreateContentType(array('name' => $type_name, 'type' => $type_name));
+ $this->type = $type->type;
+ // Store a valid URL name, with hyphens instead of underscores.
+ $this->hyphen_type = str_replace('_', '-', $this->type);
+ }
+
+ /**
+ * List (integer) : test 'allowed values' input.
+ */
+ function testListAllowedValuesInteger() {
+ $this->field_name = 'field_list_integer';
+ $this->createListField('list_integer');
+
+ // Flat list of textual values.
+ $string = "Zero\nOne";
+ $array = array('0' => 'Zero', '1' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Unkeyed lists are accepted.'));
+ // Explicit integer keys.
+ $string = "0|Zero\n2|Two";
+ $array = array('0' => 'Zero', '2' => 'Two');
+ $this->assertAllowedValuesInput($string, $array, t('Integer keys are accepted.'));
+ // Check that values can be added and removed.
+ $string = "0|Zero\n1|One";
+ $array = array('0' => 'Zero', '1' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added and removed.'));
+ // Non-integer keys.
+ $this->assertAllowedValuesInput("1.1|One", 'keys must be integers', t('Non integer keys are rejected.'));
+ $this->assertAllowedValuesInput("abc|abc", 'keys must be integers', t('Non integer keys are rejected.'));
+ // Mixed list of keyed and unkeyed values.
+ $this->assertAllowedValuesInput("Zero\n1|One", 'invalid input', t('Mixed lists are rejected.'));
+
+ // Create a node with actual data for the field.
+ $settings = array(
+ 'type' => $this->type,
+ $this->field_name => array(LANGUAGE_NONE => array(array('value' => 1))),
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Check that a flat list of values is rejected once the field has data.
+ $this->assertAllowedValuesInput( "Zero\nOne", 'invalid input', t('Unkeyed lists are rejected once the field has data.'));
+
+ // Check that values can be added but values in use cannot be removed.
+ $string = "0|Zero\n1|One\n2|Two";
+ $array = array('0' => 'Zero', '1' => 'One', '2' => 'Two');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added.'));
+ $string = "0|Zero\n1|One";
+ $array = array('0' => 'Zero', '1' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ $this->assertAllowedValuesInput("0|Zero", 'some values are being removed while currently in use', t('Values in use cannot be removed.'));
+
+ // Delete the node, remove the value.
+ node_delete($node->nid);
+ $string = "0|Zero";
+ $array = array('0' => 'Zero');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ }
+
+ /**
+ * List (float) : test 'allowed values' input.
+ */
+ function testListAllowedValuesFloat() {
+ $this->field_name = 'field_list_float';
+ $this->createListField('list_float');
+
+ // Flat list of textual values.
+ $string = "Zero\nOne";
+ $array = array('0' => 'Zero', '1' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Unkeyed lists are accepted.'));
+ // Explicit numeric keys.
+ $string = "0|Zero\n.5|Point five";
+ $array = array('0' => 'Zero', '0.5' => 'Point five');
+ $this->assertAllowedValuesInput($string, $array, t('Integer keys are accepted.'));
+ // Check that values can be added and removed.
+ $string = "0|Zero\n.5|Point five\n1.0|One";
+ $array = array('0' => 'Zero', '0.5' => 'Point five', '1' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added and removed.'));
+ // Non-numeric keys.
+ $this->assertAllowedValuesInput("abc|abc\n", 'each key must be a valid integer or decimal', t('Non numeric keys are rejected.'));
+ // Mixed list of keyed and unkeyed values.
+ $this->assertAllowedValuesInput("Zero\n1|One\n", 'invalid input', t('Mixed lists are rejected.'));
+
+ // Create a node with actual data for the field.
+ $settings = array(
+ 'type' => $this->type,
+ $this->field_name => array(LANGUAGE_NONE => array(array('value' => .5))),
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Check that a flat list of values is rejected once the field has data.
+ $this->assertAllowedValuesInput("Zero\nOne", 'invalid input', t('Unkeyed lists are rejected once the field has data.'));
+
+ // Check that values can be added but values in use cannot be removed.
+ $string = "0|Zero\n.5|Point five\n2|Two";
+ $array = array('0' => 'Zero', '0.5' => 'Point five', '2' => 'Two');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added.'));
+ $string = "0|Zero\n.5|Point five";
+ $array = array('0' => 'Zero', '0.5' => 'Point five');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ $this->assertAllowedValuesInput("0|Zero", 'some values are being removed while currently in use', t('Values in use cannot be removed.'));
+
+ // Delete the node, remove the value.
+ node_delete($node->nid);
+ $string = "0|Zero";
+ $array = array('0' => 'Zero');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ }
+
+ /**
+ * List (text) : test 'allowed values' input.
+ */
+ function testListAllowedValuesText() {
+ $this->field_name = 'field_list_text';
+ $this->createListField('list_text');
+
+ // Flat list of textual values.
+ $string = "Zero\nOne";
+ $array = array('Zero' => 'Zero', 'One' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Unkeyed lists are accepted.'));
+ // Explicit keys.
+ $string = "zero|Zero\none|One";
+ $array = array('zero' => 'Zero', 'one' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Explicit keys are accepted.'));
+ // Check that values can be added and removed.
+ $string = "zero|Zero\ntwo|Two";
+ $array = array('zero' => 'Zero', 'two' => 'Two');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added and removed.'));
+ // Mixed list of keyed and unkeyed values.
+ $string = "zero|Zero\nOne\n";
+ $array = array('zero' => 'Zero', 'One' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Mixed lists are accepted.'));
+ // Overly long keys.
+ $this->assertAllowedValuesInput("zero|Zero\n" . $this->randomName(256) . "|One", 'each key must be a string at most 255 characters long', t('Overly long keys are rejected.'));
+
+ // Create a node with actual data for the field.
+ $settings = array(
+ 'type' => $this->type,
+ $this->field_name => array(LANGUAGE_NONE => array(array('value' => 'One'))),
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Check that flat lists of values are still accepted once the field has
+ // data.
+ $string = "Zero\nOne";
+ $array = array('Zero' => 'Zero', 'One' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Unkeyed lists are still accepted once the field has data.'));
+
+ // Check that values can be added but values in use cannot be removed.
+ $string = "Zero\nOne\nTwo";
+ $array = array('Zero' => 'Zero', 'One' => 'One', 'Two' => 'Two');
+ $this->assertAllowedValuesInput($string, $array, t('Values can be added.'));
+ $string = "Zero\nOne";
+ $array = array('Zero' => 'Zero', 'One' => 'One');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ $this->assertAllowedValuesInput("Zero", 'some values are being removed while currently in use', t('Values in use cannot be removed.'));
+
+ // Delete the node, remove the value.
+ node_delete($node->nid);
+ $string = "Zero";
+ $array = array('Zero' => 'Zero');
+ $this->assertAllowedValuesInput($string, $array, t('Values not in use can be removed.'));
+ }
+
+ /**
+ * List (boolen) : test 'On/Off' values input.
+ */
+ function testListAllowedValuesBoolean() {
+ $this->field_name = 'field_list_boolean';
+ $this->createListField('list_boolean');
+
+ // Check that the separate 'On' and 'Off' form fields work.
+ $on = $this->randomName();
+ $off = $this->randomName();
+ $allowed_values = array(1 => $on, 0 => $off);
+ $edit = array(
+ 'on' => $on,
+ 'off' => $off,
+ );
+ $this->drupalPost($this->admin_path, $edit, t('Save settings'));
+ $this->assertText("Saved field_list_boolean configuration.", t("The 'On' and 'Off' form fields work for boolean fields."));
+ // Test the allowed_values on the field settings form.
+ $this->drupalGet($this->admin_path);
+ $this->assertFieldByName('on', $on, t("The 'On' value is stored correctly."));
+ $this->assertFieldByName('off', $off, t("The 'Off' value is stored correctly."));
+ $field = field_info_field($this->field_name);
+ $this->assertEqual($field['settings']['allowed_values'], $allowed_values, t('The allowed value is correct'));
+ $this->assertFalse(isset($field['settings']['on']), t('The on value is not saved into settings'));
+ $this->assertFalse(isset($field['settings']['off']), t('The off value is not saved into settings'));
+ }
+
+ /**
+ * Helper function to create list field of a given type.
+ *
+ * @param string $type
+ * 'list_integer', 'list_float', 'list_text' or 'list_boolean'
+ */
+ protected function createListField($type) {
+ // Create a test field and instance.
+ $field = array(
+ 'field_name' => $this->field_name,
+ 'type' => $type,
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'node',
+ 'bundle' => $this->type,
+ );
+ field_create_instance($instance);
+
+ $this->admin_path = 'admin/structure/types/manage/' . $this->hyphen_type . '/fields/' . $this->field_name;
+ }
+
+ /**
+ * Tests a string input for the 'allowed values' form element.
+ *
+ * @param $input_string
+ * The input string, in the pipe-linefeed format expected by the form
+ * element.
+ * @param $result
+ * Either an expected resulting array in
+ * $field['settings']['allowed_values'], or an expected error message.
+ * @param $message
+ * Message to display.
+ */
+ function assertAllowedValuesInput($input_string, $result, $message) {
+ $edit = array('field[settings][allowed_values]' => $input_string);
+ $this->drupalPost($this->admin_path, $edit, t('Save settings'));
+
+ if (is_string($result)) {
+ $this->assertText($result, $message);
+ }
+ else {
+ field_info_cache_clear();
+ $field = field_info_field($this->field_name);
+ $this->assertIdentical($field['settings']['allowed_values'], $result, $message);
+ }
+ }
+}
diff --git a/core/modules/field/modules/list/tests/list_test.info b/core/modules/field/modules/list/tests/list_test.info
new file mode 100644
index 000000000000..32c1d694f020
--- /dev/null
+++ b/core/modules/field/modules/list/tests/list_test.info
@@ -0,0 +1,6 @@
+name = "List test"
+description = "Support module for the List module tests."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/field/modules/list/tests/list_test.module b/core/modules/field/modules/list/tests/list_test.module
new file mode 100644
index 000000000000..8d5340412dac
--- /dev/null
+++ b/core/modules/field/modules/list/tests/list_test.module
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the List module tests.
+ */
+
+/**
+ * Allowed values callback.
+ */
+function list_test_allowed_values_callback($field) {
+ $values = array(
+ 'Group 1' => array(
+ 0 => 'Zero',
+ ),
+ 1 => 'One',
+ 'Group 2' => array(
+ 2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>',
+ ),
+ );
+
+ return $values;
+}
diff --git a/core/modules/field/modules/number/number.info b/core/modules/field/modules/number/number.info
new file mode 100644
index 000000000000..f38cbb4be77c
--- /dev/null
+++ b/core/modules/field/modules/number/number.info
@@ -0,0 +1,7 @@
+name = Number
+description = Defines numeric field types.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = number.test
diff --git a/core/modules/field/modules/number/number.install b/core/modules/field/modules/number/number.install
new file mode 100644
index 000000000000..02c7a305750a
--- /dev/null
+++ b/core/modules/field/modules/number/number.install
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the number module.
+ */
+
+/**
+ * Implements hook_field_schema().
+ */
+function number_field_schema($field) {
+ switch ($field['type']) {
+ case 'number_integer' :
+ $columns = array(
+ 'value' => array(
+ 'type' => 'int',
+ 'not null' => FALSE
+ ),
+ );
+ break;
+
+ case 'number_float' :
+ $columns = array(
+ 'value' => array(
+ 'type' => 'float',
+ 'not null' => FALSE
+ ),
+ );
+ break;
+
+ case 'number_decimal' :
+ $columns = array(
+ 'value' => array(
+ 'type' => 'numeric',
+ 'precision' => $field['settings']['precision'],
+ 'scale' => $field['settings']['scale'],
+ 'not null' => FALSE
+ ),
+ );
+ break;
+ }
+ return array(
+ 'columns' => $columns,
+ );
+}
diff --git a/core/modules/field/modules/number/number.module b/core/modules/field/modules/number/number.module
new file mode 100644
index 000000000000..87e2d3a93131
--- /dev/null
+++ b/core/modules/field/modules/number/number.module
@@ -0,0 +1,419 @@
+<?php
+
+/**
+ * @file
+ * Defines numeric field types.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function number_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#number':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Number module defines various numeric field types for the Field module. Numbers can be in integer, decimal, or floating-point form, and they can be formatted when displayed. Number fields can be limited to a specific set of input values or to a range of values. See the <a href="@field-help">Field module help page</a> for more information about fields.', array('@field-help' => url('admin/help/field'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_field_info().
+ */
+function number_field_info() {
+ return array(
+ 'number_integer' => array(
+ 'label' => t('Integer'),
+ 'description' => t('This field stores a number in the database as an integer.'),
+ 'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
+ 'default_widget' => 'number',
+ 'default_formatter' => 'number_integer',
+ ),
+ 'number_decimal' => array(
+ 'label' => t('Decimal'),
+ 'description' => t('This field stores a number in the database in a fixed decimal format.'),
+ 'settings' => array('precision' => 10, 'scale' => 2, 'decimal_separator' => '.'),
+ 'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
+ 'default_widget' => 'number',
+ 'default_formatter' => 'number_decimal',
+ ),
+ 'number_float' => array(
+ 'label' => t('Float'),
+ 'description' => t('This field stores a number in the database in a floating point format.'),
+ 'settings' => array('decimal_separator' => '.'),
+ 'instance_settings' => array('min' => '', 'max' => '', 'prefix' => '', 'suffix' => ''),
+ 'default_widget' => 'number',
+ 'default_formatter' => 'number_decimal',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function number_field_settings_form($field, $instance, $has_data) {
+ $settings = $field['settings'];
+ $form = array();
+
+ if ($field['type'] == 'number_decimal') {
+ $form['precision'] = array(
+ '#type' => 'select',
+ '#title' => t('Precision'),
+ '#options' => drupal_map_assoc(range(10, 32)),
+ '#default_value' => $settings['precision'],
+ '#description' => t('The total number of digits to store in the database, including those to the right of the decimal.'),
+ '#disabled' => $has_data,
+ );
+ $form['scale'] = array(
+ '#type' => 'select',
+ '#title' => t('Scale'),
+ '#options' => drupal_map_assoc(range(0, 10)),
+ '#default_value' => $settings['scale'],
+ '#description' => t('The number of digits to the right of the decimal.'),
+ '#disabled' => $has_data,
+ );
+ }
+ if ($field['type'] == 'number_decimal' || $field['type'] == 'number_float') {
+ $form['decimal_separator'] = array(
+ '#type' => 'select',
+ '#title' => t('Decimal marker'),
+ '#options' => array('.' => t('Decimal point'), ',' => t('Comma')),
+ '#default_value' => $settings['decimal_separator'],
+ '#description' => t('The character users will input to mark the decimal point in forms.'),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_instance_settings_form().
+ */
+function number_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ $form['min'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Minimum'),
+ '#default_value' => $settings['min'],
+ '#description' => t('The minimum value that should be allowed in this field. Leave blank for no minimum.'),
+ '#element_validate' => array('element_validate_number'),
+ );
+ $form['max'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum'),
+ '#default_value' => $settings['max'],
+ '#description' => t('The maximum value that should be allowed in this field. Leave blank for no maximum.'),
+ '#element_validate' => array('element_validate_number'),
+ );
+ $form['prefix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Prefix'),
+ '#default_value' => $settings['prefix'],
+ '#size' => 60,
+ '#description' => t("Define a string that should be prefixed to the value, like '$ ' or '&euro; '. Leave blank for none. Separate singular and plural values with a pipe ('pound|pounds')."),
+ );
+ $form['suffix'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Suffix'),
+ '#default_value' => $settings['suffix'],
+ '#size' => 60,
+ '#description' => t("Define a string that should be suffixed to the value, like ' m', ' kb/s'. Leave blank for none. Separate singular and plural values with a pipe ('pound|pounds')."),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_validate().
+ *
+ * Possible error codes:
+ * - 'number_min': The value is less than the allowed minimum value.
+ * - 'number_max': The value is greater than the allowed maximum value.
+ */
+function number_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ foreach ($items as $delta => $item) {
+ if ($item['value'] != '') {
+ if (is_numeric($instance['settings']['min']) && $item['value'] < $instance['settings']['min']) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'number_min',
+ 'message' => t('%name: the value may be no less than %min.', array('%name' => $instance['label'], '%min' => $instance['settings']['min'])),
+ );
+ }
+ if (is_numeric($instance['settings']['max']) && $item['value'] > $instance['settings']['max']) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'number_max',
+ 'message' => t('%name: the value may be no greater than %max.', array('%name' => $instance['label'], '%max' => $instance['settings']['max'])),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_presave().
+ */
+function number_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ if ($field['type'] == 'number_decimal') {
+ // Let PHP round the value to ensure consistent behavior across storage
+ // backends.
+ foreach ($items as $delta => $item) {
+ if (isset($item['value'])) {
+ $items[$delta]['value'] = round($item['value'], $field['settings']['scale']);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function number_field_is_empty($item, $field) {
+ if (empty($item['value']) && (string) $item['value'] !== '0') {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function number_field_formatter_info() {
+ return array(
+ // The 'Default' formatter is different for integer fields on the one hand,
+ // and for decimal and float fields on the other hand, in order to be able
+ // to use different default values for the settings.
+ 'number_integer' => array(
+ 'label' => t('Default'),
+ 'field types' => array('number_integer'),
+ 'settings' => array(
+ 'thousand_separator' => '',
+ // The 'decimal_separator' and 'scale' settings are not configurable
+ // through the UI, and will therefore keep their default values. They
+ // are only present so that the 'number_integer' and 'number_decimal'
+ // formatters can use the same code.
+ 'decimal_separator' => '.',
+ 'scale' => 0,
+ 'prefix_suffix' => TRUE,
+ ),
+ ),
+ 'number_decimal' => array(
+ 'label' => t('Default'),
+ 'field types' => array('number_decimal', 'number_float'),
+ 'settings' => array(
+ 'thousand_separator' => '',
+ 'decimal_separator' => '.',
+ 'scale' => 2,
+ 'prefix_suffix' => TRUE,
+ ),
+ ),
+ 'number_unformatted' => array(
+ 'label' => t('Unformatted'),
+ 'field types' => array('number_integer', 'number_decimal', 'number_float'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_formatter_settings_form().
+ */
+function number_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ if ($display['type'] == 'number_decimal' || $display['type'] == 'number_integer') {
+ $options = array(
+ '' => t('<none>'),
+ '.' => t('Decimal point'),
+ ',' => t('Comma'),
+ ' ' => t('Space'),
+ );
+ $element['thousand_separator'] = array(
+ '#type' => 'select',
+ '#title' => t('Thousand marker'),
+ '#options' => $options,
+ '#default_value' => $settings['thousand_separator'],
+ );
+
+ if ($display['type'] == 'number_decimal') {
+ $element['decimal_separator'] = array(
+ '#type' => 'select',
+ '#title' => t('Decimal marker'),
+ '#options' => array('.' => t('Decimal point'), ',' => t('Comma')),
+ '#default_value' => $settings['decimal_separator'],
+ );
+ $element['scale'] = array(
+ '#type' => 'select',
+ '#title' => t('Scale'),
+ '#options' => drupal_map_assoc(range(0, 10)),
+ '#default_value' => $settings['scale'],
+ '#description' => t('The number of digits to the right of the decimal.'),
+ );
+ }
+
+ $element['prefix_suffix'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display prefix and suffix.'),
+ '#default_value' => $settings['prefix_suffix'],
+ );
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_formatter_settings_summary().
+ */
+function number_field_formatter_settings_summary($field, $instance, $view_mode) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $summary = array();
+ if ($display['type'] == 'number_decimal' || $display['type'] == 'number_integer') {
+ $summary[] = number_format(1234.1234567890, $settings['scale'], $settings['decimal_separator'], $settings['thousand_separator']);
+ if ($settings['prefix_suffix']) {
+ $summary[] = t('Display with prefix and suffix.');
+ }
+ }
+
+ return implode('<br />', $summary);
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function number_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+ $settings = $display['settings'];
+
+ switch ($display['type']) {
+ case 'number_integer':
+ case 'number_decimal':
+ foreach ($items as $delta => $item) {
+ $output = number_format($item['value'], $settings['scale'], $settings['decimal_separator'], $settings['thousand_separator']);
+ if ($settings['prefix_suffix']) {
+ $prefixes = isset($instance['settings']['prefix']) ? array_map('field_filter_xss', explode('|', $instance['settings']['prefix'])) : array('');
+ $suffixes = isset($instance['settings']['suffix']) ? array_map('field_filter_xss', explode('|', $instance['settings']['suffix'])) : array('');
+ $prefix = (count($prefixes) > 1) ? format_plural($item['value'], $prefixes[0], $prefixes[1]) : $prefixes[0];
+ $suffix = (count($suffixes) > 1) ? format_plural($item['value'], $suffixes[0], $suffixes[1]) : $suffixes[0];
+ $output = $prefix . $output . $suffix;
+ }
+ $element[$delta] = array('#markup' => $output);
+ }
+ break;
+
+ case 'number_unformatted':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => $item['value']);
+ }
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function number_field_widget_info() {
+ return array(
+ 'number' => array(
+ 'label' => t('Text field'),
+ 'field types' => array('number_integer', 'number_decimal', 'number_float'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function number_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ $value = isset($items[$delta]['value']) ? $items[$delta]['value'] : '';
+ // Substitute the decimal separator.
+ if ($field['type'] == 'number_decimal' || $field['type'] == 'number_float') {
+ $value = strtr($value, '.', $field['settings']['decimal_separator']);
+ }
+
+ $element += array(
+ '#type' => 'textfield',
+ '#default_value' => $value,
+ // Allow a slightly larger size that the field length to allow for some
+ // configurations where all characters won't fit in input field.
+ '#size' => $field['type'] == 'number_decimal' ? $field['settings']['precision'] + 4 : 12,
+ // Allow two extra characters for signed values and decimal separator.
+ '#maxlength' => $field['type'] == 'number_decimal' ? $field['settings']['precision'] + 2 : 10,
+ // Extract the number type from the field type name for easier validation.
+ '#number_type' => str_replace('number_', '', $field['type']),
+ );
+
+ // Add prefix and suffix.
+ if (!empty($instance['settings']['prefix'])) {
+ $prefixes = explode('|', $instance['settings']['prefix']);
+ $element['#field_prefix'] = field_filter_xss(array_pop($prefixes));
+ }
+ if (!empty($instance['settings']['suffix'])) {
+ $suffixes = explode('|', $instance['settings']['suffix']);
+ $element['#field_suffix'] = field_filter_xss(array_pop($suffixes));
+ }
+
+ $element['#element_validate'][] = 'number_field_widget_validate';
+
+ return array('value' => $element);
+}
+
+/**
+ * FAPI validation of an individual number element.
+ */
+function number_field_widget_validate($element, &$form_state) {
+ $field = field_widget_field($element, $form_state);
+ $instance = field_widget_instance($element, $form_state);
+
+ $type = $element['#number_type'];
+ $value = $element['#value'];
+
+ // Reject invalid characters.
+ if (!empty($value)) {
+ switch ($type) {
+ case 'float':
+ case 'decimal':
+ $regexp = '@[^-0-9\\' . $field['settings']['decimal_separator'] . ']@';
+ $message = t('Only numbers and the decimal separator (@separator) allowed in %field.', array('%field' => $instance['label'], '@separator' => $field['settings']['decimal_separator']));
+ break;
+
+ case 'integer':
+ $regexp = '@[^-0-9]@';
+ $message = t('Only numbers are allowed in %field.', array('%field' => $instance['label']));
+ break;
+ }
+ if ($value != preg_replace($regexp, '', $value)) {
+ form_error($element, $message);
+ }
+ else {
+ if ($type == 'decimal' || $type == 'float') {
+ // Verify that only one decimal separator exists in the field.
+ if (substr_count($value, $field['settings']['decimal_separator']) > 1) {
+ $message = t('%field: There should only be one decimal separator (@separator).',
+ array(
+ '%field' => t($instance['label']),
+ '@separator' => $field['settings']['decimal_separator'],
+ )
+ );
+ form_error($element, $message);
+ }
+ else {
+ // Substitute the decimal separator; things should be fine.
+ $value = strtr($value, $field['settings']['decimal_separator'], '.');
+ }
+ }
+ form_set_value($element, $value, $form_state);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_widget_error().
+ */
+function number_field_widget_error($element, $error, $form, &$form_state) {
+ form_error($element['value'], $error['message']);
+}
diff --git a/core/modules/field/modules/number/number.test b/core/modules/field/modules/number/number.test
new file mode 100644
index 000000000000..e96be42a70e0
--- /dev/null
+++ b/core/modules/field/modules/number/number.test
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * @file
+ * Tests for number.module.
+ */
+
+/**
+ * Tests for number field types.
+ */
+class NumberFieldTestCase extends DrupalWebTestCase {
+ protected $field;
+ protected $instance;
+ protected $web_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Number field',
+ 'description' => 'Test the creation of number fields.',
+ 'group' => 'Field types'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+ $this->web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content', 'administer content types'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Test number_decimal field.
+ */
+ function testNumberDecimalField() {
+ // Create a field with settings to validate.
+ $this->field = array(
+ 'field_name' => drupal_strtolower($this->randomName()),
+ 'type' => 'number_decimal',
+ 'settings' => array(
+ 'precision' => 8, 'scale' => 4, 'decimal_separator' => '.',
+ )
+ );
+ field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'number',
+ ),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'number_decimal',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+
+ // Display creation form.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $langcode = LANGUAGE_NONE;
+ $this->assertFieldByName("{$this->field['field_name']}[$langcode][0][value]", '', t('Widget is displayed'));
+
+ // Submit a signed decimal value within the allowed precision and scale.
+ $value = '-1234.5678';
+ $edit = array(
+ "{$this->field['field_name']}[$langcode][0][value]" => $value,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), t('Entity was created'));
+ $this->assertRaw(round($value, 2), t('Value is displayed.'));
+
+ // Try to create entries with more than one decimal separator; assert fail.
+ $wrong_entries = array(
+ '3.14.159',
+ '0..45469',
+ '..4589',
+ '6.459.52',
+ '6.3..25',
+ );
+
+ foreach ($wrong_entries as $wrong_entry) {
+ $this->drupalGet('test-entity/add/test-bundle');
+ $edit = array(
+ "{$this->field['field_name']}[$langcode][0][value]" => $wrong_entry,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertText(
+ t('There should only be one decimal separator (@separator)',
+ array('@separator' => $this->field['settings']['decimal_separator'])),
+ t('Correctly failed to save decimal value with more than one decimal point.')
+ );
+ }
+ }
+
+ /**
+ * Test number_integer field.
+ */
+ function testNumberIntegerField() {
+ // Display the "Add content type" form.
+ $this->drupalGet('admin/structure/types/add');
+
+ // Add a content type.
+ $name = $this->randomName();
+ $type = drupal_strtolower($name);
+ $edit = array('name' => $name, 'type' => $type);
+ $this->drupalPost(NULL, $edit, t('Save and add fields'));
+
+ // Add an integer field to the newly-created type.
+ $label = $this->randomName();
+ $field_name = drupal_strtolower($label);
+ $edit = array(
+ 'fields[_add_new_field][label]'=> $label,
+ 'fields[_add_new_field][field_name]' => $field_name,
+ 'fields[_add_new_field][type]' => 'number_integer',
+ 'fields[_add_new_field][widget_type]' => 'number',
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Set the formatter to "number_integer" and to "unformatted", and just
+ // check that the settings summary does not generate warnings.
+ $this->drupalGet("admin/structure/types/manage/$type/display");
+ $edit = array(
+ "fields[field_$field_name][type]" => 'number_integer',
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $edit = array(
+ "fields[field_$field_name][type]" => 'number_unformatted',
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ }
+}
diff --git a/core/modules/field/modules/options/options.api.php b/core/modules/field/modules/options/options.api.php
new file mode 100644
index 000000000000..d1ac0db1e4ef
--- /dev/null
+++ b/core/modules/field/modules/options/options.api.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Options module.
+ */
+
+/**
+ * Returns the list of options to be displayed for a field.
+ *
+ * Field types willing to enable one or several of the widgets defined in
+ * options.module (select, radios/checkboxes, on/off checkbox) need to
+ * implement this hook to specify the list of options to display in the
+ * widgets.
+ *
+ * @param $field
+ * The field definition.
+ * @param $instance
+ * The instance definition. It is recommended to only use instance level
+ * properties to filter out values from a list defined by field level
+ * properties.
+ *
+ * @return
+ * The array of options for the field. Array keys are the values to be
+ * stored, and should be of the data type (string, number...) expected by
+ * the first 'column' for the field type. Array values are the labels to
+ * display within the widgets. The labels should NOT be sanitized,
+ * options.module takes care of sanitation according to the needs of each
+ * widget. The HTML tags defined in _field_filter_xss_allowed_tags() are
+ * allowed, other tags will be filtered.
+ */
+function hook_options_list($field, $instance) {
+ // Sample structure.
+ $options = array(
+ 0 => t('Zero'),
+ 1 => t('One'),
+ 2 => t('Two'),
+ 3 => t('Three'),
+ );
+
+ // Sample structure with groups. Only one level of nesting is allowed. This
+ // is only supported by the 'options_select' widget. Other widgets will
+ // flatten the array.
+ $options = array(
+ t('First group') => array(
+ 0 => t('Zero'),
+ ),
+ t('Second group') => array(
+ 1 => t('One'),
+ 2 => t('Two'),
+ ),
+ 3 => t('Three'),
+ );
+
+ // In actual implementations, the array of options will most probably depend
+ // on properties of the field. Example from taxonomy.module:
+ $options = array();
+ foreach ($field['settings']['allowed_values'] as $tree) {
+ $terms = taxonomy_get_tree($tree['vid'], $tree['parent']);
+ if ($terms) {
+ foreach ($terms as $term) {
+ $options[$term->tid] = str_repeat('-', $term->depth) . $term->name;
+ }
+ }
+ }
+
+ return $options;
+}
diff --git a/core/modules/field/modules/options/options.info b/core/modules/field/modules/options/options.info
new file mode 100644
index 000000000000..1cc6faf814fd
--- /dev/null
+++ b/core/modules/field/modules/options/options.info
@@ -0,0 +1,7 @@
+name = Options
+description = Defines selection, check box and radio button widgets for text and numeric fields.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = options.test
diff --git a/core/modules/field/modules/options/options.module b/core/modules/field/modules/options/options.module
new file mode 100644
index 000000000000..d4d05eca2948
--- /dev/null
+++ b/core/modules/field/modules/options/options.module
@@ -0,0 +1,406 @@
+<?php
+
+/**
+ * @file
+ * Defines selection, check box and radio button widgets for text and numeric fields.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function options_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#options':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Options module defines checkbox, selection, and other input widgets for the Field module. See the <a href="@field-help">Field module help page</a> for more information about fields.', array('@field-help' => url('admin/help/field'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function options_theme() {
+ return array(
+ 'options_none' => array(
+ 'variables' => array('instance' => NULL, 'option' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_info().
+ *
+ * Field type modules willing to use those widgets should:
+ * - Use hook_field_widget_info_alter() to append their field own types to the
+ * list of types supported by the widgets,
+ * - Implement hook_options_list() to provide the list of options.
+ * See list.module.
+ */
+function options_field_widget_info() {
+ return array(
+ 'options_select' => array(
+ 'label' => t('Select list'),
+ 'field types' => array(),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ ),
+ ),
+ 'options_buttons' => array(
+ 'label' => t('Check boxes/radio buttons'),
+ 'field types' => array(),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ ),
+ ),
+ 'options_onoff' => array(
+ 'label' => t('Single on/off checkbox'),
+ 'field types' => array(),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ ),
+ 'settings' => array('display_label' => 0),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function options_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ // Abstract over the actual field columns, to allow different field types to
+ // reuse those widgets.
+ $value_key = key($field['columns']);
+
+ $type = str_replace('options_', '', $instance['widget']['type']);
+ $multiple = $field['cardinality'] > 1 || $field['cardinality'] == FIELD_CARDINALITY_UNLIMITED;
+ $required = $element['#required'];
+ $has_value = isset($items[0][$value_key]);
+ $properties = _options_properties($type, $multiple, $required, $has_value);
+
+ // Prepare the list of options.
+ $options = _options_get_options($field, $instance, $properties);
+
+ // Put current field values in shape.
+ $default_value = _options_storage_to_form($items, $options, $value_key, $properties);
+
+ switch ($type) {
+ case 'select':
+ $element += array(
+ '#type' => 'select',
+ '#default_value' => $default_value,
+ // Do not display a 'multiple' select box if there is only one option.
+ '#multiple' => $multiple && count($options) > 1,
+ '#options' => $options,
+ );
+ break;
+
+ case 'buttons':
+ // If required and there is one single option, preselect it.
+ if ($required && count($options) == 1) {
+ reset($options);
+ $default_value = array(key($options));
+ }
+ $element += array(
+ '#type' => $multiple ? 'checkboxes' : 'radios',
+ // Radio buttons need a scalar value.
+ '#default_value' => $multiple ? $default_value : reset($default_value),
+ '#options' => $options,
+ );
+ break;
+
+ case 'onoff':
+ $keys = array_keys($options);
+ $off_value = array_shift($keys);
+ $on_value = array_shift($keys);
+ $element += array(
+ '#type' => 'checkbox',
+ '#default_value' => (isset($default_value[0]) && $default_value[0] == $on_value) ? 1 : 0,
+ '#on_value' => $on_value,
+ '#off_value' => $off_value,
+ );
+ // Override the title from the incoming $element.
+ $element['#title'] = isset($options[$on_value]) ? $options[$on_value] : '';
+
+ if ($instance['widget']['settings']['display_label']) {
+ $element['#title'] = $instance['label'];
+ }
+ break;
+ }
+
+ $element += array(
+ '#value_key' => $value_key,
+ '#element_validate' => array('options_field_widget_validate'),
+ '#properties' => $properties,
+ );
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_widget_settings_form().
+ */
+function options_field_widget_settings_form($field, $instance) {
+ $form = array();
+ if ($instance['widget']['type'] == 'options_onoff') {
+ $form['display_label'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Use field label instead of the "On value" as label'),
+ '#default_value' => $instance['widget']['settings']['display_label'],
+ '#weight' => -1,
+ );
+ }
+ return $form;
+}
+
+/**
+ * Form element validation handler for options element.
+ */
+function options_field_widget_validate($element, &$form_state) {
+ if ($element['#required'] && $element['#value'] == '_none') {
+ form_error($element, t('!name field is required.', array('!name' => $element['#title'])));
+ }
+ // Transpose selections from field => delta to delta => field, turning
+ // multiple selected options into multiple parent elements.
+ $items = _options_form_to_storage($element);
+ form_set_value($element, $items, $form_state);
+}
+
+/**
+ * Describes the preparation steps required by each widget.
+ */
+function _options_properties($type, $multiple, $required, $has_value) {
+ $base = array(
+ 'filter_xss' => FALSE,
+ 'strip_tags' => FALSE,
+ 'empty_option' => FALSE,
+ 'optgroups' => FALSE,
+ );
+
+ $properties = array();
+
+ switch ($type) {
+ case 'select':
+ $properties = array(
+ // Select boxes do not support any HTML tag.
+ 'strip_tags' => TRUE,
+ 'optgroups' => TRUE,
+ );
+ if ($multiple) {
+ // Multiple select: add a 'none' option for non-required fields.
+ if (!$required) {
+ $properties['empty_option'] = 'option_none';
+ }
+ }
+ else {
+ // Single select: add a 'none' option for non-required fields,
+ // and a 'select a value' option for required fields that do not come
+ // with a value selected.
+ if (!$required) {
+ $properties['empty_option'] = 'option_none';
+ }
+ else if (!$has_value) {
+ $properties['empty_option'] = 'option_select';
+ }
+ }
+ break;
+
+ case 'buttons':
+ $properties = array(
+ 'filter_xss' => TRUE,
+ );
+ // Add a 'none' option for non-required radio buttons.
+ if (!$required && !$multiple) {
+ $properties['empty_option'] = 'option_none';
+ }
+ break;
+
+ case 'onoff':
+ $properties = array(
+ 'filter_xss' => TRUE,
+ );
+ break;
+ }
+
+ return $properties + $base;
+}
+
+/**
+ * Collects the options for a field.
+ */
+function _options_get_options($field, $instance, $properties) {
+ // Get the list of options.
+ $options = (array) module_invoke($field['module'], 'options_list', $field, $instance);
+
+ // Sanitize the options.
+ _options_prepare_options($options, $properties);
+
+ if (!$properties['optgroups']) {
+ $options = options_array_flatten($options);
+ }
+
+ if ($properties['empty_option']) {
+ $label = theme('options_none', array('instance' => $instance, 'option' => $properties['empty_option']));
+ $options = array('_none' => $label) + $options;
+ }
+
+ return $options;
+}
+
+/**
+ * Sanitizes the options.
+ *
+ * The function is recursive to support optgroups.
+ */
+function _options_prepare_options(&$options, $properties) {
+ foreach ($options as $value => $label) {
+ // Recurse for optgroups.
+ if (is_array($label)) {
+ _options_prepare_options($options[$value], $properties);
+ }
+ else {
+ if ($properties['strip_tags']) {
+ $options[$value] = strip_tags($label);
+ }
+ if ($properties['filter_xss']) {
+ $options[$value] = field_filter_xss($label);
+ }
+ }
+ }
+}
+
+/**
+ * Transforms stored field values into the format the widgets need.
+ */
+function _options_storage_to_form($items, $options, $column, $properties) {
+ $items_transposed = options_array_transpose($items);
+ $values = (isset($items_transposed[$column]) && is_array($items_transposed[$column])) ? $items_transposed[$column] : array();
+
+ // Discard values that are not in the current list of options. Flatten the
+ // array if needed.
+ if ($properties['optgroups']) {
+ $options = options_array_flatten($options);
+ }
+ $values = array_values(array_intersect($values, array_keys($options)));
+ return $values;
+}
+
+/**
+ * Transforms submitted form values into field storage format.
+ */
+function _options_form_to_storage($element) {
+ $values = array_values((array) $element['#value']);
+ $properties = $element['#properties'];
+
+ // On/off checkbox: transform '0 / 1' into the 'on / off' values.
+ if ($element['#type'] == 'checkbox') {
+ $values = array($values[0] ? $element['#on_value'] : $element['#off_value']);
+ }
+
+ // Filter out the 'none' option. Use a strict comparison, because
+ // 0 == 'any string'.
+ if ($properties['empty_option']) {
+ $index = array_search('_none', $values, TRUE);
+ if ($index !== FALSE) {
+ unset($values[$index]);
+ }
+ }
+
+ // Make sure we populate at least an empty value.
+ if (empty($values)) {
+ $values = array(NULL);
+ }
+
+ $result = options_array_transpose(array($element['#value_key'] => $values));
+ return $result;
+}
+
+/**
+ * Manipulates a 2D array to reverse rows and columns.
+ *
+ * The default data storage for fields is delta first, column names second.
+ * This is sometimes inconvenient for field modules, so this function can be
+ * used to present the data in an alternate format.
+ *
+ * @param $array
+ * The array to be transposed. It must be at least two-dimensional, and
+ * the subarrays must all have the same keys or behavior is undefined.
+ * @return
+ * The transposed array.
+ */
+function options_array_transpose($array) {
+ $result = array();
+ if (is_array($array)) {
+ foreach ($array as $key1 => $value1) {
+ if (is_array($value1)) {
+ foreach ($value1 as $key2 => $value2) {
+ if (!isset($result[$key2])) {
+ $result[$key2] = array();
+ }
+ $result[$key2][$key1] = $value2;
+ }
+ }
+ }
+ }
+ return $result;
+}
+
+/**
+ * Flattens an array of allowed values.
+ *
+ * @param $array
+ * A single or multidimensional array.
+ * @return
+ * A flattened array.
+ */
+function options_array_flatten($array) {
+ $result = array();
+ if (is_array($array)) {
+ foreach ($array as $key => $value) {
+ if (is_array($value)) {
+ $result += options_array_flatten($value);
+ }
+ else {
+ $result[$key] = $value;
+ }
+ }
+ }
+ return $result;
+}
+
+/**
+ * Implements hook_field_widget_error().
+ */
+function options_field_widget_error($element, $error, $form, &$form_state) {
+ form_error($element, $error['message']);
+}
+
+/**
+ * Returns HTML for the label for the empty value for options that are not required.
+ *
+ * The default theme will display N/A for a radio list and '- None -' for a select.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - instance: An array representing the widget requesting the options.
+ *
+ * @ingroup themeable
+ */
+function theme_options_none($variables) {
+ $instance = $variables['instance'];
+ $option = $variables['option'];
+
+ $output = '';
+ switch ($instance['widget']['type']) {
+ case 'options_buttons':
+ $output = t('N/A');
+ break;
+
+ case 'options_select':
+ $output = ($option == 'option_none' ? t('- None -') : t('- Select a value -'));
+ break;
+ }
+
+ return $output;
+}
diff --git a/core/modules/field/modules/options/options.test b/core/modules/field/modules/options/options.test
new file mode 100644
index 000000000000..ea58f27ff2ea
--- /dev/null
+++ b/core/modules/field/modules/options/options.test
@@ -0,0 +1,518 @@
+<?php
+
+/**
+ * @file
+ * Tests for options.module.
+ */
+
+class OptionsWidgetsTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Options widgets',
+ 'description' => "Test the Options widgets.",
+ 'group' => 'Field types'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test', 'list_test');
+
+ // Field with cardinality 1.
+ $this->card_1 = array(
+ 'field_name' => 'card_1',
+ 'type' => 'list_integer',
+ 'cardinality' => 1,
+ 'settings' => array(
+ // Make sure that 0 works as an option.
+ 'allowed_values' => array(0 => 'Zero', 1 => 'One', 2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>'),
+ ),
+ );
+ $this->card_1 = field_create_field($this->card_1);
+
+ // Field with cardinality 2.
+ $this->card_2 = array(
+ 'field_name' => 'card_2',
+ 'type' => 'list_integer',
+ 'cardinality' => 2,
+ 'settings' => array(
+ // Make sure that 0 works as an option.
+ 'allowed_values' => array(0 => 'Zero', 1 => 'One', 2 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>'),
+ ),
+ );
+ $this->card_2 = field_create_field($this->card_2);
+
+ // Boolean field.
+ $this->bool = array(
+ 'field_name' => 'bool',
+ 'type' => 'list_boolean',
+ 'cardinality' => 1,
+ 'settings' => array(
+ // Make sure that 0 works as a 'on' value'.
+ 'allowed_values' => array(1 => 'Zero', 0 => 'Some <script>dangerous</script> & unescaped <strong>markup</strong>'),
+ ),
+ );
+ $this->bool = field_create_field($this->bool);
+
+ // Create a web user.
+ $this->web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Tests the 'options_buttons' widget (single select).
+ */
+ function testRadioButtons() {
+ // Create an instance of the 'single value' field.
+ $instance = array(
+ 'field_name' => $this->card_1['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_buttons',
+ ),
+ );
+ $instance = field_create_instance($instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Create an entity.
+ $entity_init = field_test_create_stub_entity();
+ $entity = clone $entity_init;
+ $entity->is_new = TRUE;
+ field_test_entity_save($entity);
+
+ // With no field data, no buttons are checked.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoFieldChecked("edit-card-1-$langcode-0");
+ $this->assertNoFieldChecked("edit-card-1-$langcode-1");
+ $this->assertNoFieldChecked("edit-card-1-$langcode-2");
+ $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
+
+ // Select first option.
+ $edit = array("card_1[$langcode]" => 0);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array(0));
+
+ // Check that the selected button is checked.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-card-1-$langcode-0");
+ $this->assertNoFieldChecked("edit-card-1-$langcode-1");
+ $this->assertNoFieldChecked("edit-card-1-$langcode-2");
+
+ // Unselect option.
+ $edit = array("card_1[$langcode]" => '_none');
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array());
+
+ // Check that required radios with one option is auto-selected.
+ $this->card_1['settings']['allowed_values'] = array(99 => 'Only allowed value');
+ field_update_field($this->card_1);
+ $instance['required'] = TRUE;
+ field_update_instance($instance);
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-card-1-$langcode-99");
+ }
+
+ /**
+ * Tests the 'options_buttons' widget (multiple select).
+ */
+ function testCheckBoxes() {
+ // Create an instance of the 'multiple values' field.
+ $instance = array(
+ 'field_name' => $this->card_2['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_buttons',
+ ),
+ );
+ $instance = field_create_instance($instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Create an entity.
+ $entity_init = field_test_create_stub_entity();
+ $entity = clone $entity_init;
+ $entity->is_new = TRUE;
+ field_test_entity_save($entity);
+
+ // Display form: with no field data, nothing is checked.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoFieldChecked("edit-card-2-$langcode-0");
+ $this->assertNoFieldChecked("edit-card-2-$langcode-1");
+ $this->assertNoFieldChecked("edit-card-2-$langcode-2");
+ $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
+
+ // Submit form: select first and third options.
+ $edit = array(
+ "card_2[$langcode][0]" => TRUE,
+ "card_2[$langcode][1]" => FALSE,
+ "card_2[$langcode][2]" => TRUE,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0, 2));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-card-2-$langcode-0");
+ $this->assertNoFieldChecked("edit-card-2-$langcode-1");
+ $this->assertFieldChecked("edit-card-2-$langcode-2");
+
+ // Submit form: select only first option.
+ $edit = array(
+ "card_2[$langcode][0]" => TRUE,
+ "card_2[$langcode][1]" => FALSE,
+ "card_2[$langcode][2]" => FALSE,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-card-2-$langcode-0");
+ $this->assertNoFieldChecked("edit-card-2-$langcode-1");
+ $this->assertNoFieldChecked("edit-card-2-$langcode-2");
+
+ // Submit form: select the three options while the field accepts only 2.
+ $edit = array(
+ "card_2[$langcode][0]" => TRUE,
+ "card_2[$langcode][1]" => TRUE,
+ "card_2[$langcode][2]" => TRUE,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertText('this field cannot hold more than 2 values', t('Validation error was displayed.'));
+
+ // Submit form: uncheck all options.
+ $edit = array(
+ "card_2[$langcode][0]" => FALSE,
+ "card_2[$langcode][1]" => FALSE,
+ "card_2[$langcode][2]" => FALSE,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ // Check that the value was saved.
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array());
+
+ // Required checkbox with one option is auto-selected.
+ $this->card_2['settings']['allowed_values'] = array(99 => 'Only allowed value');
+ field_update_field($this->card_2);
+ $instance['required'] = TRUE;
+ field_update_instance($instance);
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-card-2-$langcode-99");
+ }
+
+ /**
+ * Tests the 'options_select' widget (single select).
+ */
+ function testSelectListSingle() {
+ // Create an instance of the 'single value' field.
+ $instance = array(
+ 'field_name' => $this->card_1['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'required' => TRUE,
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ );
+ $instance = field_create_instance($instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Create an entity.
+ $entity_init = field_test_create_stub_entity();
+ $entity = clone $entity_init;
+ $entity->is_new = TRUE;
+ field_test_entity_save($entity);
+
+ // Display form.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ // A required field without any value has a "none" option.
+ $this->assertTrue($this->xpath('//select[@id=:id]//option[@value="_none" and text()=:label]', array(':id' => 'edit-card-1-' . $langcode, ':label' => t('- Select a value -'))), t('A required select list has a "Select a value" choice.'));
+
+ // With no field data, nothing is selected.
+ $this->assertNoOptionSelected("edit-card-1-$langcode", '_none');
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+ $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+
+ // Submit form: select invalid 'none' option.
+ $edit = array("card_1[$langcode]" => '_none');
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('!title field is required.', array('!title' => $instance['field_name'])), t('Cannot save a required field when selecting "none" from the select list.'));
+
+ // Submit form: select first option.
+ $edit = array("card_1[$langcode]" => 0);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ // A required field with a value has no 'none' option.
+ $this->assertFalse($this->xpath('//select[@id=:id]//option[@value="_none"]', array(':id' => 'edit-card-1-' . $langcode)), t('A required select list with an actual value has no "none" choice.'));
+ $this->assertOptionSelected("edit-card-1-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+
+ // Make the field non required.
+ $instance['required'] = FALSE;
+ field_update_instance($instance);
+
+ // Display form.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ // A non-required field has a 'none' option.
+ $this->assertTrue($this->xpath('//select[@id=:id]//option[@value="_none" and text()=:label]', array(':id' => 'edit-card-1-' . $langcode, ':label' => t('- None -'))), t('A non-required select list has a "None" choice.'));
+ // Submit form: Unselect the option.
+ $edit = array("card_1[$langcode]" => '_none');
+ $this->drupalPost('test-entity/manage/' . $entity->ftid . '/edit', $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array());
+
+ // Test optgroups.
+
+ $this->card_1['settings']['allowed_values'] = array();
+ $this->card_1['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+ field_update_field($this->card_1);
+
+ // Display form: with no field data, nothing is selected
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+ $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+ $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+ // Submit form: select first option.
+ $edit = array("card_1[$langcode]" => 0);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertOptionSelected("edit-card-1-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-1-$langcode", 2);
+
+ // Submit form: Unselect the option.
+ $edit = array("card_1[$langcode]" => '_none');
+ $this->drupalPost('test-entity/manage/' . $entity->ftid . '/edit', $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_1', $langcode, array());
+ }
+
+ /**
+ * Tests the 'options_select' widget (multiple select).
+ */
+ function testSelectListMultiple() {
+ // Create an instance of the 'multiple values' field.
+ $instance = array(
+ 'field_name' => $this->card_2['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ );
+ $instance = field_create_instance($instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Create an entity.
+ $entity_init = field_test_create_stub_entity();
+ $entity = clone $entity_init;
+ $entity->is_new = TRUE;
+ field_test_entity_save($entity);
+
+ // Display form: with no field data, nothing is selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+ $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+
+ // Submit form: select first and third options.
+ $edit = array("card_2[$langcode][]" => array(0 => 0, 2 => 2));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0, 2));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertOptionSelected("edit-card-2-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+ $this->assertOptionSelected("edit-card-2-$langcode", 2);
+
+ // Submit form: select only first option.
+ $edit = array("card_2[$langcode][]" => array(0 => 0));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertOptionSelected("edit-card-2-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+
+ // Submit form: select the three options while the field accepts only 2.
+ $edit = array("card_2[$langcode][]" => array(0 => 0, 1 => 1, 2 => 2));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertText('this field cannot hold more than 2 values', t('Validation error was displayed.'));
+
+ // Submit form: uncheck all options.
+ $edit = array("card_2[$langcode][]" => array());
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array());
+
+ // Test the 'None' option.
+
+ // Check that the 'none' option has no efect if actual options are selected
+ // as well.
+ $edit = array("card_2[$langcode][]" => array('_none' => '_none', 0 => 0));
+ $this->drupalPost('test-entity/manage/' . $entity->ftid . '/edit', $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0));
+
+ // Check that selecting the 'none' option empties the field.
+ $edit = array("card_2[$langcode][]" => array('_none' => '_none'));
+ $this->drupalPost('test-entity/manage/' . $entity->ftid . '/edit', $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array());
+
+ // A required select list does not have an empty key.
+ $instance['required'] = TRUE;
+ field_update_instance($instance);
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFalse($this->xpath('//select[@id=:id]//option[@value=""]', array(':id' => 'edit-card-2-' . $langcode)), t('A required select list does not have an empty key.'));
+
+ // We do not have to test that a required select list with one option is
+ // auto-selected because the browser does it for us.
+
+ // Test optgroups.
+
+ // Use a callback function defining optgroups.
+ $this->card_2['settings']['allowed_values'] = array();
+ $this->card_2['settings']['allowed_values_function'] = 'list_test_allowed_values_callback';
+ field_update_field($this->card_2);
+ $instance['required'] = FALSE;
+ field_update_instance($instance);
+
+ // Display form: with no field data, nothing is selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+ $this->assertRaw('Some dangerous &amp; unescaped markup', t('Option text was properly filtered.'));
+ $this->assertRaw('Group 1', t('Option groups are displayed.'));
+
+ // Submit form: select first option.
+ $edit = array("card_2[$langcode][]" => array(0 => 0));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertOptionSelected("edit-card-2-$langcode", 0);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 1);
+ $this->assertNoOptionSelected("edit-card-2-$langcode", 2);
+
+ // Submit form: Unselect the option.
+ $edit = array("card_2[$langcode][]" => array('_none' => '_none'));
+ $this->drupalPost('test-entity/manage/' . $entity->ftid . '/edit', $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'card_2', $langcode, array());
+ }
+
+ /**
+ * Tests the 'options_onoff' widget.
+ */
+ function testOnOffCheckbox() {
+ // Create an instance of the 'boolean' field.
+ $instance = array(
+ 'field_name' => $this->bool['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_onoff',
+ ),
+ );
+ $instance = field_create_instance($instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Create an entity.
+ $entity_init = field_test_create_stub_entity();
+ $entity = clone $entity_init;
+ $entity->is_new = TRUE;
+ field_test_entity_save($entity);
+
+ // Display form: with no field data, option is unchecked.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoFieldChecked("edit-bool-$langcode");
+ $this->assertRaw('Some dangerous &amp; unescaped <strong>markup</strong>', t('Option text was properly filtered.'));
+
+ // Submit form: check the option.
+ $edit = array("bool[$langcode]" => TRUE);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'bool', $langcode, array(0));
+
+ // Display form: check that the right options are selected.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertFieldChecked("edit-bool-$langcode");
+
+ // Submit form: uncheck the option.
+ $edit = array("bool[$langcode]" => FALSE);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertFieldValues($entity_init, 'bool', $langcode, array(1));
+
+ // Display form: with 'off' value, option is unchecked.
+ $this->drupalGet('test-entity/manage/' . $entity->ftid . '/edit');
+ $this->assertNoFieldChecked("edit-bool-$langcode");
+
+ // Create admin user.
+ $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer taxonomy'));
+ $this->drupalLogin($admin_user);
+
+ // Create a test field instance.
+ $fieldUpdate = $this->bool;
+ $fieldUpdate['settings']['allowed_values'] = array(0 => 0, 1 => 'MyOnValue');
+ field_update_field($fieldUpdate);
+ $instance = array(
+ 'field_name' => $this->bool['field_name'],
+ 'entity_type' => 'node',
+ 'bundle' => 'page',
+ 'widget' => array(
+ 'type' => 'options_onoff',
+ 'module' => 'options',
+ ),
+ );
+ field_create_instance($instance);
+
+ // Go to the edit page and check if the default settings works as expected
+ $fieldEditUrl = 'admin/structure/types/manage/page/fields/bool';
+ $this->drupalGet($fieldEditUrl);
+
+ $this->assertText(
+ 'Use field label instead of the "On value" as label ',
+ t('Display setting checkbox available.')
+ );
+
+ $this->assertFieldByXPath(
+ '*//label[@for="edit-' . $this->bool['field_name'] . '-und" and text()="MyOnValue "]',
+ TRUE,
+ t('Default case shows "On value"')
+ );
+
+ // Enable setting
+ $edit = array('instance[widget][settings][display_label]' => 1);
+ // Save the new Settings
+ $this->drupalPost($fieldEditUrl, $edit, t('Save settings'));
+
+ // Go again to the edit page and check if the setting
+ // is stored and has the expected effect
+ $this->drupalGet($fieldEditUrl);
+ $this->assertText(
+ 'Use field label instead of the "On value" as label ',
+ t('Display setting checkbox is available')
+ );
+ $this->assertFieldChecked(
+ 'edit-instance-widget-settings-display-label',
+ t('Display settings checkbox checked')
+ );
+ $this->assertFieldByXPath(
+ '*//label[@for="edit-' . $this->bool['field_name'] . '-und" and text()="' . $this->bool['field_name'] . ' "]',
+ TRUE,
+ t('Display label changes label of the checkbox')
+ );
+ }
+}
+
diff --git a/core/modules/field/modules/text/text.info b/core/modules/field/modules/text/text.info
new file mode 100644
index 000000000000..b424d2d452f0
--- /dev/null
+++ b/core/modules/field/modules/text/text.info
@@ -0,0 +1,8 @@
+name = Text
+description = Defines simple text field types.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = text.test
+required = TRUE
diff --git a/core/modules/field/modules/text/text.install b/core/modules/field/modules/text/text.install
new file mode 100644
index 000000000000..1b8d00b1e6e3
--- /dev/null
+++ b/core/modules/field/modules/text/text.install
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the text module.
+ */
+
+/**
+ * Implements hook_field_schema().
+ */
+function text_field_schema($field) {
+ switch ($field['type']) {
+ case 'text':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'varchar',
+ 'length' => $field['settings']['max_length'],
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+
+ case 'text_long':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'text',
+ 'size' => 'big',
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+
+ case 'text_with_summary':
+ $columns = array(
+ 'value' => array(
+ 'type' => 'text',
+ 'size' => 'big',
+ 'not null' => FALSE,
+ ),
+ 'summary' => array(
+ 'type' => 'text',
+ 'size' => 'big',
+ 'not null' => FALSE,
+ ),
+ );
+ break;
+ }
+ $columns += array(
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ ),
+ );
+ return array(
+ 'columns' => $columns,
+ 'indexes' => array(
+ 'format' => array('format'),
+ ),
+ 'foreign keys' => array(
+ 'format' => array(
+ 'table' => 'filter_format',
+ 'columns' => array('format' => 'format'),
+ ),
+ ),
+ );
+}
diff --git a/core/modules/field/modules/text/text.js b/core/modules/field/modules/text/text.js
new file mode 100644
index 000000000000..f3ae89430cd3
--- /dev/null
+++ b/core/modules/field/modules/text/text.js
@@ -0,0 +1,49 @@
+
+(function ($) {
+
+/**
+ * Auto-hide summary textarea if empty and show hide and unhide links.
+ */
+Drupal.behaviors.textSummary = {
+ attach: function (context, settings) {
+ $('.text-summary', context).once('text-summary', function () {
+ var $widget = $(this).closest('div.field-type-text-with-summary');
+ var $summaries = $widget.find('div.text-summary-wrapper');
+
+ $summaries.once('text-summary-wrapper').each(function(index) {
+ var $summary = $(this);
+ var $summaryLabel = $summary.find('label');
+ var $full = $widget.find('.text-full').eq(index).closest('.form-item');
+ var $fullLabel = $full.find('label');
+
+ // Create a placeholder label when the field cardinality is
+ // unlimited or greater than 1.
+ if ($fullLabel.length == 0) {
+ $fullLabel = $('<label></label>').prependTo($full);
+ }
+
+ // Setup the edit/hide summary link.
+ var $link = $('<span class="field-edit-link">(<a class="link-edit-summary" href="#">' + Drupal.t('Hide summary') + '</a>)</span>').toggle(
+ function () {
+ $summary.hide();
+ $(this).find('a').html(Drupal.t('Edit summary')).end().appendTo($fullLabel);
+ return false;
+ },
+ function () {
+ $summary.show();
+ $(this).find('a').html(Drupal.t('Hide summary')).end().appendTo($summaryLabel);
+ return false;
+ }
+ ).appendTo($summaryLabel);
+
+ // If no summary is set, hide the summary field.
+ if ($(this).find('.text-summary').val() == '') {
+ $link.click();
+ }
+ return;
+ });
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/field/modules/text/text.module b/core/modules/field/modules/text/text.module
new file mode 100644
index 000000000000..d73814faaafa
--- /dev/null
+++ b/core/modules/field/modules/text/text.module
@@ -0,0 +1,611 @@
+<?php
+
+/**
+ * @file
+ * Defines simple text field types.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function text_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#text':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t("The Text module defines various text field types for the Field module. A text field may contain plain text only, or optionally, may use Drupal's <a href='@filter-help'>text filters</a> to securely manage HTML output. Text input fields may be either a single line (text field), multiple lines (text area), or for greater input control, a select box, checkbox, or radio buttons. If desired, the field can be validated, so that it is limited to a set of allowed values. See the <a href='@field-help'>Field module help page</a> for more information about fields.", array('@field-help' => url('admin/help/field'), '@filter-help' => url('admin/help/filter'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_field_info().
+ *
+ * Field settings:
+ * - max_length: the maximum length for a varchar field.
+ * Instance settings:
+ * - text_processing: whether text input filters should be used.
+ * - display_summary: whether the summary field should be displayed.
+ * When empty and not displayed the summary will take its value from the
+ * trimmed value of the main text field.
+ */
+function text_field_info() {
+ return array(
+ 'text' => array(
+ 'label' => t('Text'),
+ 'description' => t('This field stores varchar text in the database.'),
+ 'settings' => array('max_length' => 255),
+ 'instance_settings' => array('text_processing' => 0),
+ 'default_widget' => 'text_textfield',
+ 'default_formatter' => 'text_default',
+ ),
+ 'text_long' => array(
+ 'label' => t('Long text'),
+ 'description' => t('This field stores long text in the database.'),
+ 'instance_settings' => array('text_processing' => 0),
+ 'default_widget' => 'text_textarea',
+ 'default_formatter' => 'text_default',
+ ),
+ 'text_with_summary' => array(
+ 'label' => t('Long text and summary'),
+ 'description' => t('This field stores long text in the database along with optional summary text.'),
+ 'instance_settings' => array('text_processing' => 1, 'display_summary' => 0),
+ 'default_widget' => 'text_textarea_with_summary',
+ 'default_formatter' => 'text_default',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function text_field_settings_form($field, $instance, $has_data) {
+ $settings = $field['settings'];
+
+ $form = array();
+
+ if ($field['type'] == 'text') {
+ $form['max_length'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum length'),
+ '#default_value' => $settings['max_length'],
+ '#required' => TRUE,
+ '#description' => t('The maximum length of the field in characters.'),
+ '#element_validate' => array('element_validate_integer_positive'),
+ // @todo: If $has_data, add a validate handler that only allows
+ // max_length to increase.
+ '#disabled' => $has_data,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_instance_settings_form().
+ */
+function text_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ $form['text_processing'] = array(
+ '#type' => 'radios',
+ '#title' => t('Text processing'),
+ '#default_value' => $settings['text_processing'],
+ '#options' => array(
+ t('Plain text'),
+ t('Filtered text (user selects text format)'),
+ ),
+ );
+ if ($field['type'] == 'text_with_summary') {
+ $form['display_summary'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Summary input'),
+ '#default_value' => $settings['display_summary'],
+ '#description' => t('This allows authors to input an explicit summary, to be displayed instead of the automatically trimmed text when using the "Summary or trimmed" display type.'),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_validate().
+ *
+ * Possible error codes:
+ * - 'text_value_max_length': The value exceeds the maximum length.
+ * - 'text_summary_max_length': The summary exceeds the maximum length.
+ */
+function text_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ foreach ($items as $delta => $item) {
+ // @todo Length is counted separately for summary and value, so the maximum
+ // length can be exceeded very easily.
+ foreach (array('value', 'summary') as $column) {
+ if (!empty($item[$column])) {
+ if (!empty($field['settings']['max_length']) && drupal_strlen($item[$column]) > $field['settings']['max_length']) {
+ switch ($column) {
+ case 'value':
+ $message = t('%name: the text may not be longer than %max characters.', array('%name' => $instance['label'], '%max' => $field['settings']['max_length']));
+ break;
+
+ case 'summary':
+ $message = t('%name: the summary may not be longer than %max characters.', array('%name' => $instance['label'], '%max' => $field['settings']['max_length']));
+ break;
+ }
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => "text_{$column}_length",
+ 'message' => $message,
+ );
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_load().
+ *
+ * Where possible, generate the sanitized version of each field early so that
+ * it is cached in the field cache. This avoids looking up from the filter cache
+ * separately.
+ *
+ * @see text_field_formatter_view()
+ */
+function text_field_load($entity_type, $entities, $field, $instances, $langcode, &$items) {
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ // Only process items with a cacheable format, the rest will be handled
+ // by formatters if needed.
+ if (empty($instances[$id]['settings']['text_processing']) || filter_format_allowcache($item['format'])) {
+ $items[$id][$delta]['safe_value'] = isset($item['value']) ? _text_sanitize($instances[$id], $langcode, $item, 'value') : '';
+ if ($field['type'] == 'text_with_summary') {
+ $items[$id][$delta]['safe_summary'] = isset($item['summary']) ? _text_sanitize($instances[$id], $langcode, $item, 'summary') : '';
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function text_field_is_empty($item, $field) {
+ if (!isset($item['value']) || $item['value'] === '') {
+ return !isset($item['summary']) || $item['summary'] === '';
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function text_field_formatter_info() {
+ return array(
+ 'text_default' => array(
+ 'label' => t('Default'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ ),
+ 'text_plain' => array(
+ 'label' => t('Plain text'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ ),
+
+ // The text_trimmed formatter displays the trimmed version of the
+ // full element of the field. It is intended to be used with text
+ // and text_long fields. It also works with text_with_summary
+ // fields though the text_summary_or_trimmed formatter makes more
+ // sense for that field type.
+ 'text_trimmed' => array(
+ 'label' => t('Trimmed'),
+ 'field types' => array('text', 'text_long', 'text_with_summary'),
+ 'settings' => array('trim_length' => 600),
+ ),
+
+ // The 'summary or trimmed' field formatter for text_with_summary
+ // fields displays returns the summary element of the field or, if
+ // the summary is empty, the trimmed version of the full element
+ // of the field.
+ 'text_summary_or_trimmed' => array(
+ 'label' => t('Summary or trimmed'),
+ 'field types' => array('text_with_summary'),
+ 'settings' => array('trim_length' => 600),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_formatter_settings_form().
+ */
+function text_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $element = array();
+
+ if (strpos($display['type'], '_trimmed') !== FALSE) {
+ $element['trim_length'] = array(
+ '#title' => t('Trim length'),
+ '#type' => 'textfield',
+ '#size' => 10,
+ '#default_value' => $settings['trim_length'],
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#required' => TRUE,
+ );
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_formatter_settings_summary().
+ */
+function text_field_formatter_settings_summary($field, $instance, $view_mode) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $summary = '';
+
+ if (strpos($display['type'], '_trimmed') !== FALSE) {
+ $summary = t('Trim length') . ': ' . $settings['trim_length'];
+ }
+
+ return $summary;
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function text_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+
+ switch ($display['type']) {
+ case 'text_default':
+ case 'text_trimmed':
+ foreach ($items as $delta => $item) {
+ $output = _text_sanitize($instance, $langcode, $item, 'value');
+ if ($display['type'] == 'text_trimmed') {
+ $output = text_summary($output, $instance['settings']['text_processing'] ? $item['format'] : NULL, $display['settings']['trim_length']);
+ }
+ $element[$delta] = array('#markup' => $output);
+ }
+ break;
+
+ case 'text_summary_or_trimmed':
+ foreach ($items as $delta => $item) {
+ if (!empty($item['summary'])) {
+ $output = _text_sanitize($instance, $langcode, $item, 'summary');
+ }
+ else {
+ $output = _text_sanitize($instance, $langcode, $item, 'value');
+ $output = text_summary($output, $instance['settings']['text_processing'] ? $item['format'] : NULL, $display['settings']['trim_length']);
+ }
+ $element[$delta] = array('#markup' => $output);
+ }
+ break;
+
+ case 'text_plain':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => strip_tags($item['value']));
+ }
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * Sanitizes the 'value' or 'summary' data of a text value.
+ *
+ * Depending on whether the field instance uses text processing, data is run
+ * through check_plain() or check_markup().
+ *
+ * @param $instance
+ * The instance definition.
+ * @param $langcode
+ * The language associated to $item.
+ * @param $item
+ * The field value to sanitize.
+ * @param $column
+ * The column to sanitize (either 'value' or 'summary').
+ *
+ * @return
+ * The sanitized string.
+ */
+function _text_sanitize($instance, $langcode, $item, $column) {
+ // If the value uses a cacheable text format, text_field_load() precomputes
+ // the sanitized string.
+ if (isset($item["safe_$column"])) {
+ return $item["safe_$column"];
+ }
+ return $instance['settings']['text_processing'] ? check_markup($item[$column], $item['format'], $langcode) : check_plain($item[$column]);
+}
+
+/**
+ * Generate a trimmed, formatted version of a text field value.
+ *
+ * If the end of the summary is not indicated using the <!--break--> delimiter
+ * then we generate the summary automatically, trying to end it at a sensible
+ * place such as the end of a paragraph, a line break, or the end of a
+ * sentence (in that order of preference).
+ *
+ * @param $text
+ * The content for which a summary will be generated.
+ * @param $format
+ * The format of the content.
+ * If the PHP filter is present and $text contains PHP code, we do not
+ * split it up to prevent parse errors.
+ * If the line break filter is present then we treat newlines embedded in
+ * $text as line breaks.
+ * If the htmlcorrector filter is present, it will be run on the generated
+ * summary (if different from the incoming $text).
+ * @param $size
+ * The desired character length of the summary. If omitted, the default
+ * value will be used. Ignored if the special delimiter is present
+ * in $text.
+ * @return
+ * The generated summary.
+ */
+function text_summary($text, $format = NULL, $size = NULL) {
+
+ if (!isset($size)) {
+ // What used to be called 'teaser' is now called 'summary', but
+ // the variable 'teaser_length' is preserved for backwards compatibility.
+ $size = variable_get('teaser_length', 600);
+ }
+
+ // Find where the delimiter is in the body
+ $delimiter = strpos($text, '<!--break-->');
+
+ // If the size is zero, and there is no delimiter, the entire body is the summary.
+ if ($size == 0 && $delimiter === FALSE) {
+ return $text;
+ }
+
+ // If a valid delimiter has been specified, use it to chop off the summary.
+ if ($delimiter !== FALSE) {
+ return substr($text, 0, $delimiter);
+ }
+
+ // We check for the presence of the PHP evaluator filter in the current
+ // format. If the body contains PHP code, we do not split it up to prevent
+ // parse errors.
+ if (isset($format)) {
+ $filters = filter_list_format($format);
+ if (isset($filters['php_code']) && $filters['php_code']->status && strpos($text, '<?') !== FALSE) {
+ return $text;
+ }
+ }
+
+ // If we have a short body, the entire body is the summary.
+ if (drupal_strlen($text) <= $size) {
+ return $text;
+ }
+
+ // If the delimiter has not been specified, try to split at paragraph or
+ // sentence boundaries.
+
+ // The summary may not be longer than maximum length specified. Initial slice.
+ $summary = truncate_utf8($text, $size);
+
+ // Store the actual length of the UTF8 string -- which might not be the same
+ // as $size.
+ $max_rpos = strlen($summary);
+
+ // How much to cut off the end of the summary so that it doesn't end in the
+ // middle of a paragraph, sentence, or word.
+ // Initialize it to maximum in order to find the minimum.
+ $min_rpos = $max_rpos;
+
+ // Store the reverse of the summary. We use strpos on the reversed needle and
+ // haystack for speed and convenience.
+ $reversed = strrev($summary);
+
+ // Build an array of arrays of break points grouped by preference.
+ $break_points = array();
+
+ // A paragraph near the end of sliced summary is most preferable.
+ $break_points[] = array('</p>' => 0);
+
+ // If no complete paragraph then treat line breaks as paragraphs.
+ $line_breaks = array('<br />' => 6, '<br>' => 4);
+ // Newline only indicates a line break if line break converter
+ // filter is present.
+ if (isset($filters['filter_autop'])) {
+ $line_breaks["\n"] = 1;
+ }
+ $break_points[] = $line_breaks;
+
+ // If the first paragraph is too long, split at the end of a sentence.
+ $break_points[] = array('. ' => 1, '! ' => 1, '? ' => 1, '。' => 0, '؟ ' => 1);
+
+ // Iterate over the groups of break points until a break point is found.
+ foreach ($break_points as $points) {
+ // Look for each break point, starting at the end of the summary.
+ foreach ($points as $point => $offset) {
+ // The summary is already reversed, but the break point isn't.
+ $rpos = strpos($reversed, strrev($point));
+ if ($rpos !== FALSE) {
+ $min_rpos = min($rpos + $offset, $min_rpos);
+ }
+ }
+
+ // If a break point was found in this group, slice and stop searching.
+ if ($min_rpos !== $max_rpos) {
+ // Don't slice with length 0. Length must be <0 to slice from RHS.
+ $summary = ($min_rpos === 0) ? $summary : substr($summary, 0, 0 - $min_rpos);
+ break;
+ }
+ }
+
+ // If the htmlcorrector filter is present, apply it to the generated summary.
+ if (isset($filters['filter_htmlcorrector'])) {
+ $summary = _filter_htmlcorrector($summary);
+ }
+
+ return $summary;
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function text_field_widget_info() {
+ return array(
+ 'text_textfield' => array(
+ 'label' => t('Text field'),
+ 'field types' => array('text'),
+ 'settings' => array('size' => 60),
+ ),
+ 'text_textarea' => array(
+ 'label' => t('Text area (multiple rows)'),
+ 'field types' => array('text_long'),
+ 'settings' => array('rows' => 5),
+ ),
+ 'text_textarea_with_summary' => array(
+ 'label' => t('Text area with a summary'),
+ 'field types' => array('text_with_summary'),
+ 'settings' => array('rows' => 20, 'summary_rows' => 5),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_settings_form().
+ */
+function text_field_widget_settings_form($field, $instance) {
+ $widget = $instance['widget'];
+ $settings = $widget['settings'];
+
+ if ($widget['type'] == 'text_textfield') {
+ $form['size'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Size of textfield'),
+ '#default_value' => $settings['size'],
+ '#required' => TRUE,
+ '#element_validate' => array('element_validate_integer_positive'),
+ );
+ }
+ else {
+ $form['rows'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Rows'),
+ '#default_value' => $settings['rows'],
+ '#required' => TRUE,
+ '#element_validate' => array('element_validate_integer_positive'),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function text_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ $summary_widget = array();
+ $main_widget = array();
+
+ switch ($instance['widget']['type']) {
+ case 'text_textfield':
+ $main_widget = $element + array(
+ '#type' => 'textfield',
+ '#default_value' => isset($items[$delta]['value']) ? $items[$delta]['value'] : NULL,
+ '#size' => $instance['widget']['settings']['size'],
+ '#maxlength' => $field['settings']['max_length'],
+ '#attributes' => array('class' => array('text-full')),
+ );
+ break;
+
+ case 'text_textarea_with_summary':
+ $display = !empty($items[$delta]['summary']) || !empty($instance['settings']['display_summary']);
+ $summary_widget = array(
+ '#type' => $display ? 'textarea' : 'value',
+ '#default_value' => isset($items[$delta]['summary']) ? $items[$delta]['summary'] : NULL,
+ '#title' => t('Summary'),
+ '#rows' => $instance['widget']['settings']['summary_rows'],
+ '#description' => t('Leave blank to use trimmed value of full text as the summary.'),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'text') . '/text.js'),
+ ),
+ '#attributes' => array('class' => array('text-summary')),
+ '#prefix' => '<div class="text-summary-wrapper">',
+ '#suffix' => '</div>',
+ '#weight' => -10,
+ );
+ // Fall through to the next case.
+
+ case 'text_textarea':
+ $main_widget = $element + array(
+ '#type' => 'textarea',
+ '#default_value' => isset($items[$delta]['value']) ? $items[$delta]['value'] : NULL,
+ '#rows' => $instance['widget']['settings']['rows'],
+ '#attributes' => array('class' => array('text-full')),
+ );
+ break;
+ }
+
+ if ($main_widget) {
+ // Conditionally alter the form element's type if text processing is enabled.
+ if ($instance['settings']['text_processing']) {
+ $element = $main_widget;
+ $element['#type'] = 'text_format';
+ $element['#format'] = isset($items[$delta]['format']) ? $items[$delta]['format'] : NULL;
+ $element['#base_type'] = $main_widget['#type'];
+ }
+ else {
+ $element['value'] = $main_widget;
+ }
+ }
+ if ($summary_widget) {
+ $element['summary'] = $summary_widget;
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_widget_error().
+ */
+function text_field_widget_error($element, $error, $form, &$form_state) {
+ switch ($error['error']) {
+ case 'text_summary_max_length':
+ $error_element = $element[$element['#columns'][1]];
+ break;
+
+ default:
+ $error_element = $element[$element['#columns'][0]];
+ break;
+ }
+
+ form_error($error_element, $error['message']);
+}
+
+/**
+ * Implements hook_field_prepare_translation().
+ */
+function text_field_prepare_translation($entity_type, $entity, $field, $instance, $langcode, &$items, $source_entity, $source_langcode) {
+ // If the translating user is not permitted to use the assigned text format,
+ // we must not expose the source values.
+ $field_name = $field['field_name'];
+ if (!empty($source_entity->{$field_name}[$source_langcode])) {
+ $formats = filter_formats();
+ foreach ($source_entity->{$field_name}[$source_langcode] as $delta => $item) {
+ $format_id = $item['format'];
+ if (!empty($format_id) && !filter_access($formats[$format_id])) {
+ unset($items[$delta]);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_filter_format_update().
+ */
+function text_filter_format_update($format) {
+ field_cache_clear();
+}
+
+/**
+ * Implements hook_filter_format_disable().
+ */
+function text_filter_format_disable($format) {
+ field_cache_clear();
+}
diff --git a/core/modules/field/modules/text/text.test b/core/modules/field/modules/text/text.test
new file mode 100644
index 000000000000..59369370efcc
--- /dev/null
+++ b/core/modules/field/modules/text/text.test
@@ -0,0 +1,517 @@
+<?php
+
+/**
+ * @file
+ * Tests for text.module.
+ */
+
+class TextFieldTestCase extends DrupalWebTestCase {
+ protected $instance;
+ protected $admin_user;
+ protected $web_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Text field',
+ 'description' => "Test the creation of text fields.",
+ 'group' => 'Field types'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ $this->admin_user = $this->drupalCreateUser(array('administer filters'));
+ $this->web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ // Test fields.
+
+ /**
+ * Test text field validation.
+ */
+ function testTextFieldValidation() {
+ // Create a field with settings to validate.
+ $max_length = 3;
+ $this->field = array(
+ 'field_name' => drupal_strtolower($this->randomName()),
+ 'type' => 'text',
+ 'settings' => array(
+ 'max_length' => $max_length,
+ )
+ );
+ field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'text_textfield',
+ ),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'text_default',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ // Test valid and invalid values with field_attach_validate().
+ $entity = field_test_create_stub_entity();
+ $langcode = LANGUAGE_NONE;
+ for ($i = 0; $i <= $max_length + 2; $i++) {
+ $entity->{$this->field['field_name']}[$langcode][0]['value'] = str_repeat('x', $i);
+ try {
+ field_attach_validate('test_entity', $entity);
+ $this->assertTrue($i <= $max_length, "Length $i does not cause validation error when max_length is $max_length");
+ }
+ catch (FieldValidationException $e) {
+ $this->assertTrue($i > $max_length, "Length $i causes validation error when max_length is $max_length");
+ }
+ }
+ }
+
+ /**
+ * Test widgets.
+ */
+ function testTextfieldWidgets() {
+ $this->_testTextfieldWidgets('text', 'text_textfield');
+ $this->_testTextfieldWidgets('text_long', 'text_textarea');
+ }
+
+ /**
+ * Helper function for testTextfieldWidgets().
+ */
+ function _testTextfieldWidgets($field_type, $widget_type) {
+ // Setup a field and instance
+ $entity_type = 'test_entity';
+ $this->field_name = drupal_strtolower($this->randomName());
+ $this->field = array('field_name' => $this->field_name, 'type' => $field_type);
+ field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->randomName() . '_label',
+ 'settings' => array(
+ 'text_processing' => TRUE,
+ ),
+ 'widget' => array(
+ 'type' => $widget_type,
+ ),
+ 'display' => array(
+ 'full' => array(
+ 'type' => 'text_default',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", '', t('Widget is displayed'));
+ $this->assertNoFieldByName("{$this->field_name}[$langcode][0][format]", '1', t('Format selector is not displayed'));
+
+ // Submit with some value.
+ $value = $this->randomName();
+ $edit = array(
+ "{$this->field_name}[$langcode][0][value]" => $value,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), t('Entity was created'));
+
+ // Display the entity.
+ $entity = field_test_entity_test_load($id);
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $this->content = drupal_render($entity->content);
+ $this->assertText($value, 'Filtered tags are not displayed');
+ }
+
+ /**
+ * Test widgets + 'formatted_text' setting.
+ */
+ function testTextfieldWidgetsFormatted() {
+ $this->_testTextfieldWidgetsFormatted('text', 'text_textfield');
+ $this->_testTextfieldWidgetsFormatted('text_long', 'text_textarea');
+ }
+
+ /**
+ * Helper function for testTextfieldWidgetsFormatted().
+ */
+ function _testTextfieldWidgetsFormatted($field_type, $widget_type) {
+ // Setup a field and instance
+ $entity_type = 'test_entity';
+ $this->field_name = drupal_strtolower($this->randomName());
+ $this->field = array('field_name' => $this->field_name, 'type' => $field_type);
+ field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->randomName() . '_label',
+ 'settings' => array(
+ 'text_processing' => TRUE,
+ ),
+ 'widget' => array(
+ 'type' => $widget_type,
+ ),
+ 'display' => array(
+ 'full' => array(
+ 'type' => 'text_default',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Disable all text formats besides the plain text fallback format.
+ $this->drupalLogin($this->admin_user);
+ foreach (filter_formats() as $format) {
+ if ($format->format != filter_fallback_format()) {
+ $this->drupalPost('admin/config/content/formats/' . $format->format . '/disable', array(), t('Disable'));
+ }
+ }
+ $this->drupalLogin($this->web_user);
+
+ // Display the creation form. Since the user only has access to one format,
+ // no format selector will be displayed.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", '', t('Widget is displayed'));
+ $this->assertNoFieldByName("{$this->field_name}[$langcode][0][format]", '', t('Format selector is not displayed'));
+
+ // Submit with data that should be filtered.
+ $value = '<em>' . $this->randomName() . '</em>';
+ $edit = array(
+ "{$this->field_name}[$langcode][0][value]" => $value,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), t('Entity was created'));
+
+ // Display the entity.
+ $entity = field_test_entity_test_load($id);
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $this->content = drupal_render($entity->content);
+ $this->assertNoRaw($value, t('HTML tags are not displayed.'));
+ $this->assertRaw(check_plain($value), t('Escaped HTML is displayed correctly.'));
+
+ // Create a new text format that does not escape HTML, and grant the user
+ // access to it.
+ $this->drupalLogin($this->admin_user);
+ $edit = array(
+ 'format' => drupal_strtolower($this->randomName()),
+ 'name' => $this->randomName(),
+ );
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ filter_formats_reset();
+ $this->checkPermissions(array(), TRUE);
+ $format = filter_format_load($edit['format']);
+ $format_id = $format->format;
+ $permission = filter_permission_name($format);
+ $rid = max(array_keys($this->web_user->roles));
+ user_role_grant_permissions($rid, array($permission));
+ $this->drupalLogin($this->web_user);
+
+ // Display edition form.
+ // We should now have a 'text format' selector.
+ $this->drupalGet('test-entity/manage/' . $id . '/edit');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", NULL, t('Widget is displayed'));
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][format]", NULL, t('Format selector is displayed'));
+
+ // Edit and change the text format to the new one that was created.
+ $edit = array(
+ "{$this->field_name}[$langcode][0][format]" => $format_id,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('test_entity @id has been updated.', array('@id' => $id)), t('Entity was updated'));
+
+ // Display the entity.
+ $entity = field_test_entity_test_load($id);
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $this->content = drupal_render($entity->content);
+ $this->assertRaw($value, t('Value is displayed unfiltered'));
+ }
+}
+
+class TextSummaryTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Text summary',
+ 'description' => 'Test text_summary() with different strings and lengths.',
+ 'group' => 'Field types',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->article_creator = $this->drupalCreateUser(array('create article content', 'edit own article content'));
+ }
+
+ /**
+ * Tests an edge case where the first sentence is a question and
+ * subsequent sentences are not. This edge case is documented at
+ * http://drupal.org/node/180425.
+ */
+ function testFirstSentenceQuestion() {
+ $text = 'A question? A sentence. Another sentence.';
+ $expected = 'A question? A sentence.';
+ $this->callTextSummary($text, $expected, NULL, 30);
+ }
+
+ /**
+ * Test summary with long example.
+ */
+ function testLongSentence() {
+ $text = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ' . // 125
+ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ' . // 108
+ 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. ' . // 103
+ 'Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'; // 110
+ $expected = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ' .
+ 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ' .
+ 'Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.';
+ // First three sentences add up to: 336, so add one for space and then 3 to get half-way into next word.
+ $this->callTextSummary($text, $expected, NULL, 340);
+ }
+
+ /**
+ * Test various summary length edge cases.
+ */
+ function testLength() {
+ // This string tests a number of edge cases.
+ $text = "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>";
+
+ // The summaries we expect text_summary() to return when $size is the index
+ // of each array item.
+ // Using no text format:
+ $expected = array(
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "<",
+ "<p",
+ "<p>",
+ "<p>\n",
+ "<p>\nH",
+ "<p>\nHi",
+ "<p>\nHi\n",
+ "<p>\nHi\n<",
+ "<p>\nHi\n</",
+ "<p>\nHi\n</p",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ );
+
+ // And using a text format WITH the line-break and htmlcorrector filters.
+ $expected_lb = array(
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "",
+ "<p></p>",
+ "<p></p>",
+ "<p></p>",
+ "<p></p>",
+ "<p></p>",
+ "<p>\nHi</p>",
+ "<p>\nHi</p>",
+ "<p>\nHi</p>",
+ "<p>\nHi</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ "<p>\nHi\n</p>\n<p>\nfolks\n<br />\n!\n</p>",
+ );
+
+ // Test text_summary() for different sizes.
+ for ($i = 0; $i <= 37; $i++) {
+ $this->callTextSummary($text, $expected[$i], NULL, $i);
+ $this->callTextSummary($text, $expected_lb[$i], 'plain_text', $i);
+ $this->callTextSummary($text, $expected_lb[$i], 'filtered_html', $i);
+ }
+ }
+
+ /**
+ * Calls text_summary() and asserts that the expected teaser is returned.
+ */
+ function callTextSummary($text, $expected, $format = NULL, $size = NULL) {
+ $summary = text_summary($text, $format, $size);
+ $this->assertIdentical($summary, $expected, t('Generated summary "@summary" matches expected "@expected".', array('@summary' => $summary, '@expected' => $expected)));
+ }
+
+ /**
+ * Test sending only summary.
+ */
+ function testOnlyTextSummary() {
+ // Login as article creator.
+ $this->drupalLogin($this->article_creator);
+ // Create article with summary but empty body.
+ $summary = $this->randomName();
+ $edit = array(
+ "title" => $this->randomName(),
+ "body[und][0][summary]" => $summary,
+ );
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+
+ $this->assertIdentical($node->body['und'][0]['summary'], $summary, t('Article with with summary and no body has been submitted.'));
+ }
+}
+
+class TextTranslationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Text translation',
+ 'description' => 'Check if the text field is correctly prepared for translation.',
+ 'group' => 'Field types',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'translation');
+
+ $full_html_format = filter_format_load('full_html');
+ $this->format = $full_html_format->format;
+ $this->admin = $this->drupalCreateUser(array(
+ 'administer languages',
+ 'administer content types',
+ 'access administration pages',
+ 'bypass node access',
+ filter_permission_name($full_html_format),
+ ));
+ $this->translator = $this->drupalCreateUser(array('create article content', 'edit own article content', 'translate content'));
+
+ // Enable an additional language.
+ $this->drupalLogin($this->admin);
+ $edit = array('langcode' => 'fr');
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Set "Article" content type to use multilingual support with translation.
+ $edit = array('language_content_type' => 2);
+ $this->drupalPost('admin/structure/types/manage/article', $edit, t('Save content type'));
+ $this->assertRaw(t('The content type %type has been updated.', array('%type' => 'Article')), t('Article content type has been updated.'));
+ }
+
+ /**
+ * Test that a plaintext textfield widget is correctly populated.
+ */
+ function testTextField() {
+ // Disable text processing for body.
+ $edit = array('instance[settings][text_processing]' => 0);
+ $this->drupalPost('admin/structure/types/manage/article/fields/body', $edit, t('Save settings'));
+
+ // Login as translator.
+ $this->drupalLogin($this->translator);
+
+ // Create content.
+ $langcode = LANGUAGE_NONE;
+ $body = $this->randomName();
+ $edit = array(
+ "title" => $this->randomName(),
+ "language" => 'en',
+ "body[$langcode][0][value]" => $body,
+ );
+
+ // Translate the article in french.
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->drupalGet("node/$node->nid/translate");
+ $this->clickLink(t('add translation'));
+ $this->assertFieldByXPath("//textarea[@name='body[$langcode][0][value]']", $body, t('The textfield widget is populated.'));
+ }
+
+ /**
+ * Check that user that does not have access the field format cannot see the
+ * source value when creating a translation.
+ */
+ function testTextFieldFormatted() {
+ // Make node body multiple.
+ $edit = array('field[cardinality]' => -1);
+ $this->drupalPost('admin/structure/types/manage/article/fields/body', $edit, t('Save settings'));
+ $this->drupalGet('node/add/article');
+ $this->assertFieldByXPath("//input[@name='body_add_more']", t('Add another item'), t('Body field cardinality set to multiple.'));
+
+ $body = array(
+ $this->randomName(),
+ $this->randomName(),
+ );
+
+ // Create an article with the first body input format set to "Full HTML".
+ $title = $this->randomName();
+ $edit = array(
+ 'title' => $title,
+ 'language' => 'en',
+ );
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+
+ // Populate the body field: the first item gets the "Full HTML" input
+ // format, the second one "Filtered HTML".
+ $formats = array('full_html', 'filtered_html');
+ $langcode = LANGUAGE_NONE;
+ foreach ($body as $delta => $value) {
+ $edit = array(
+ "body[$langcode][$delta][value]" => $value,
+ "body[$langcode][$delta][format]" => array_shift($formats),
+ );
+ $this->drupalPost('node/1/edit', $edit, t('Save'));
+ $this->assertText($body[$delta], t('The body field with delta @delta has been saved.', array('@delta' => $delta)));
+ }
+
+ // Login as translator.
+ $this->drupalLogin($this->translator);
+
+ // Translate the article in french.
+ $node = $this->drupalGetNodeByTitle($title);
+ $this->drupalGet("node/$node->nid/translate");
+ $this->clickLink(t('add translation'));
+ $this->assertNoText($body[0], t('The body field with delta @delta is hidden.', array('@delta' => 0)));
+ $this->assertText($body[1], t('The body field with delta @delta is shown.', array('@delta' => 1)));
+ }
+}
diff --git a/core/modules/field/tests/field.test b/core/modules/field/tests/field.test
new file mode 100644
index 000000000000..214eab4127cf
--- /dev/null
+++ b/core/modules/field/tests/field.test
@@ -0,0 +1,3257 @@
+<?php
+
+/**
+ * @file
+ * Tests for field.module.
+ */
+
+/**
+ * Parent class for Field API tests.
+ */
+class FieldTestCase extends DrupalWebTestCase {
+ var $default_storage = 'field_sql_storage';
+
+ /**
+ * Set the default field storage backend for fields created during tests.
+ */
+ function setUp() {
+ // Since this is a base class for many test cases, support the same
+ // flexibility that DrupalWebTestCase::setUp() has for the modules to be
+ // passed in as either an array or a variable number of string arguments.
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ parent::setUp($modules);
+ // Set default storage backend.
+ variable_set('field_storage_default', $this->default_storage);
+ }
+
+ /**
+ * Generate random values for a field_test field.
+ *
+ * @param $cardinality
+ * Number of values to generate.
+ * @return
+ * An array of random values, in the format expected for field values.
+ */
+ function _generateTestFieldValues($cardinality) {
+ $values = array();
+ for ($i = 0; $i < $cardinality; $i++) {
+ // field_test fields treat 0 as 'empty value'.
+ $values[$i]['value'] = mt_rand(1, 127);
+ }
+ return $values;
+ }
+
+ /**
+ * Assert that a field has the expected values in an entity.
+ *
+ * This function only checks a single column in the field values.
+ *
+ * @param $entity
+ * The entity to test.
+ * @param $field_name
+ * The name of the field to test
+ * @param $langcode
+ * The language code for the values.
+ * @param $expected_values
+ * The array of expected values.
+ * @param $column
+ * (Optional) the name of the column to check.
+ */
+ function assertFieldValues($entity, $field_name, $langcode, $expected_values, $column = 'value') {
+ $e = clone $entity;
+ field_attach_load('test_entity', array($e->ftid => $e));
+ $values = isset($e->{$field_name}[$langcode]) ? $e->{$field_name}[$langcode] : array();
+ $this->assertEqual(count($values), count($expected_values), t('Expected number of values were saved.'));
+ foreach ($expected_values as $key => $value) {
+ $this->assertEqual($values[$key][$column], $value, t('Value @value was saved correctly.', array('@value' => $value)));
+ }
+ }
+}
+
+class FieldAttachTestCase extends FieldTestCase {
+ function setUp($modules = array()) {
+ // Since this is a base class for many test cases, support the same
+ // flexibility that DrupalWebTestCase::setUp() has for the modules to be
+ // passed in as either an array or a variable number of string arguments.
+ if (!is_array($modules)) {
+ $modules = func_get_args();
+ }
+ if (!in_array('field_test', $modules)) {
+ $modules[] = 'field_test';
+ }
+ parent::setUp($modules);
+
+ $this->field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $this->field = array('field_name' => $this->field_name, 'type' => 'test_field', 'cardinality' => 4);
+ $this->field = field_create_field($this->field);
+ $this->field_id = $this->field['id'];
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ 'settings' => array(
+ 'test_instance_setting' => $this->randomName(),
+ ),
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'label' => 'Test Field',
+ 'settings' => array(
+ 'test_widget_setting' => $this->randomName(),
+ )
+ )
+ );
+ field_create_instance($this->instance);
+ }
+}
+
+/**
+ * Unit test class for storage-related field_attach_* functions.
+ *
+ * All field_attach_* test work with all field_storage plugins and
+ * all hook_field_attach_pre_{load,insert,update}() hooks.
+ */
+class FieldAttachStorageTestCase extends FieldAttachTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field attach tests (storage-related)',
+ 'description' => 'Test storage-related Field Attach API functions.',
+ 'group' => 'Field API',
+ );
+ }
+
+ /**
+ * Check field values insert, update and load.
+ *
+ * Works independently of the underlying field storage backend. Inserts or
+ * updates random field data and then loads and verifies the data.
+ */
+ function testFieldAttachSaveLoad() {
+ // Configure the instance so that we test hook_field_load() (see
+ // field_test_field_load() in field_test.module).
+ $this->instance['settings']['test_hook_field_load'] = TRUE;
+ field_update_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ $entity_type = 'test_entity';
+ $values = array();
+
+ // TODO : test empty values filtering and "compression" (store consecutive deltas).
+
+ // Preparation: create three revisions and store them in $revision array.
+ for ($revision_id = 0; $revision_id < 3; $revision_id++) {
+ $revision[$revision_id] = field_test_create_stub_entity(0, $revision_id, $this->instance['bundle']);
+ // Note: we try to insert one extra value.
+ $values[$revision_id] = $this->_generateTestFieldValues($this->field['cardinality'] + 1);
+ $current_revision = $revision_id;
+ // If this is the first revision do an insert.
+ if (!$revision_id) {
+ $revision[$revision_id]->{$this->field_name}[$langcode] = $values[$revision_id];
+ field_attach_insert($entity_type, $revision[$revision_id]);
+ }
+ else {
+ // Otherwise do an update.
+ $revision[$revision_id]->{$this->field_name}[$langcode] = $values[$revision_id];
+ field_attach_update($entity_type, $revision[$revision_id]);
+ }
+ }
+
+ // Confirm current revision loads the correct data.
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ field_attach_load($entity_type, array(0 => $entity));
+ // Number of values per field loaded equals the field cardinality.
+ $this->assertEqual(count($entity->{$this->field_name}[$langcode]), $this->field['cardinality'], t('Current revision: expected number of values'));
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ // The field value loaded matches the one inserted or updated.
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['value'] , $values[$current_revision][$delta]['value'], t('Current revision: expected value %delta was found.', array('%delta' => $delta)));
+ // The value added in hook_field_load() is found.
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['additional_key'], 'additional_value', t('Current revision: extra information for value %delta was found', array('%delta' => $delta)));
+ }
+
+ // Confirm each revision loads the correct data.
+ foreach (array_keys($revision) as $revision_id) {
+ $entity = field_test_create_stub_entity(0, $revision_id, $this->instance['bundle']);
+ field_attach_load_revision($entity_type, array(0 => $entity));
+ // Number of values per field loaded equals the field cardinality.
+ $this->assertEqual(count($entity->{$this->field_name}[$langcode]), $this->field['cardinality'], t('Revision %revision_id: expected number of values.', array('%revision_id' => $revision_id)));
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ // The field value loaded matches the one inserted or updated.
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['value'], $values[$revision_id][$delta]['value'], t('Revision %revision_id: expected value %delta was found.', array('%revision_id' => $revision_id, '%delta' => $delta)));
+ // The value added in hook_field_load() is found.
+ $this->assertEqual($entity->{$this->field_name}[$langcode][$delta]['additional_key'], 'additional_value', t('Revision %revision_id: extra information for value %delta was found', array('%revision_id' => $revision_id, '%delta' => $delta)));
+ }
+ }
+ }
+
+ /**
+ * Test the 'multiple' load feature.
+ */
+ function testFieldAttachLoadMultiple() {
+ $entity_type = 'test_entity';
+ $langcode = LANGUAGE_NONE;
+
+ // Define 2 bundles.
+ $bundles = array(
+ 1 => 'test_bundle_1',
+ 2 => 'test_bundle_2',
+ );
+ field_test_create_bundle($bundles[1]);
+ field_test_create_bundle($bundles[2]);
+ // Define 3 fields:
+ // - field_1 is in bundle_1 and bundle_2,
+ // - field_2 is in bundle_1,
+ // - field_3 is in bundle_2.
+ $field_bundles_map = array(
+ 1 => array(1, 2),
+ 2 => array(1),
+ 3 => array(2),
+ );
+ for ($i = 1; $i <= 3; $i++) {
+ $field_names[$i] = 'field_' . $i;
+ $field = array('field_name' => $field_names[$i], 'type' => 'test_field');
+ $field = field_create_field($field);
+ $field_ids[$i] = $field['id'];
+ foreach ($field_bundles_map[$i] as $bundle) {
+ $instance = array(
+ 'field_name' => $field_names[$i],
+ 'entity_type' => 'test_entity',
+ 'bundle' => $bundles[$bundle],
+ 'settings' => array(
+ // Configure the instance so that we test hook_field_load()
+ // (see field_test_field_load() in field_test.module).
+ 'test_hook_field_load' => TRUE,
+ ),
+ );
+ field_create_instance($instance);
+ }
+ }
+
+ // Create one test entity per bundle, with random values.
+ foreach ($bundles as $index => $bundle) {
+ $entities[$index] = field_test_create_stub_entity($index, $index, $bundle);
+ $entity = clone($entities[$index]);
+ $instances = field_info_instances('test_entity', $bundle);
+ foreach ($instances as $field_name => $instance) {
+ $values[$index][$field_name] = mt_rand(1, 127);
+ $entity->$field_name = array($langcode => array(array('value' => $values[$index][$field_name])));
+ }
+ field_attach_insert($entity_type, $entity);
+ }
+
+ // Check that a single load correctly loads field values for both entities.
+ field_attach_load($entity_type, $entities);
+ foreach ($entities as $index => $entity) {
+ $instances = field_info_instances($entity_type, $bundles[$index]);
+ foreach ($instances as $field_name => $instance) {
+ // The field value loaded matches the one inserted.
+ $this->assertEqual($entity->{$field_name}[$langcode][0]['value'], $values[$index][$field_name], t('Entity %index: expected value was found.', array('%index' => $index)));
+ // The value added in hook_field_load() is found.
+ $this->assertEqual($entity->{$field_name}[$langcode][0]['additional_key'], 'additional_value', t('Entity %index: extra information was found', array('%index' => $index)));
+ }
+ }
+
+ // Check that the single-field load option works.
+ $entity = field_test_create_stub_entity(1, 1, $bundles[1]);
+ field_attach_load($entity_type, array(1 => $entity), FIELD_LOAD_CURRENT, array('field_id' => $field_ids[1]));
+ $this->assertEqual($entity->{$field_names[1]}[$langcode][0]['value'], $values[1][$field_names[1]], t('Entity %index: expected value was found.', array('%index' => 1)));
+ $this->assertEqual($entity->{$field_names[1]}[$langcode][0]['additional_key'], 'additional_value', t('Entity %index: extra information was found', array('%index' => 1)));
+ $this->assert(!isset($entity->{$field_names[2]}), t('Entity %index: field %field_name is not loaded.', array('%index' => 2, '%field_name' => $field_names[2])));
+ $this->assert(!isset($entity->{$field_names[3]}), t('Entity %index: field %field_name is not loaded.', array('%index' => 3, '%field_name' => $field_names[3])));
+ }
+
+ /**
+ * Test saving and loading fields using different storage backends.
+ */
+ function testFieldAttachSaveLoadDifferentStorage() {
+ $entity_type = 'test_entity';
+ $langcode = LANGUAGE_NONE;
+
+ // Create two fields using different storage backends, and their instances.
+ $fields = array(
+ array(
+ 'field_name' => 'field_1',
+ 'type' => 'test_field',
+ 'cardinality' => 4,
+ 'storage' => array('type' => 'field_sql_storage')
+ ),
+ array(
+ 'field_name' => 'field_2',
+ 'type' => 'test_field',
+ 'cardinality' => 4,
+ 'storage' => array('type' => 'field_test_storage')
+ ),
+ );
+ foreach ($fields as $field) {
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ );
+ field_create_instance($instance);
+ }
+
+ $entity_init = field_test_create_stub_entity();
+
+ // Create entity and insert random values.
+ $entity = clone($entity_init);
+ $values = array();
+ foreach ($fields as $field) {
+ $values[$field['field_name']] = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity->{$field['field_name']}[$langcode] = $values[$field['field_name']];
+ }
+ field_attach_insert($entity_type, $entity);
+
+ // Check that values are loaded as expected.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ foreach ($fields as $field) {
+ $this->assertEqual($values[$field['field_name']], $entity->{$field['field_name']}[$langcode], t('%storage storage: expected values were found.', array('%storage' => $field['storage']['type'])));
+ }
+ }
+
+ /**
+ * Test storage details alteration.
+ *
+ * @see field_test_storage_details_alter()
+ */
+ function testFieldStorageDetailsAlter() {
+ $field_name = 'field_test_change_my_details';
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'test_field',
+ 'cardinality' => 4,
+ 'storage' => array('type' => 'field_test_storage'),
+ );
+ $field = field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ );
+ field_create_instance($instance);
+
+ $field = field_info_field($instance['field_name']);
+ $instance = field_info_instance($instance['entity_type'], $instance['field_name'], $instance['bundle']);
+
+ // The storage details are indexed by a storage engine type.
+ $this->assertTrue(array_key_exists('drupal_variables', $field['storage']['details']), t('The storage type is Drupal variables.'));
+
+ $details = $field['storage']['details']['drupal_variables'];
+
+ // The field_test storage details are indexed by variable name. The details
+ // are altered, so moon and mars are correct for this test.
+ $this->assertTrue(array_key_exists('moon', $details[FIELD_LOAD_CURRENT]), t('Moon is available in the instance array.'));
+ $this->assertTrue(array_key_exists('mars', $details[FIELD_LOAD_REVISION]), t('Mars is available in the instance array.'));
+
+ // Test current and revision storage details together because the columns
+ // are the same.
+ foreach ((array) $field['columns'] as $column_name => $attributes) {
+ $this->assertEqual($details[FIELD_LOAD_CURRENT]['moon'][$column_name], $column_name, t('Column name %value matches the definition in %bin.', array('%value' => $column_name, '%bin' => 'moon[FIELD_LOAD_CURRENT]')));
+ $this->assertEqual($details[FIELD_LOAD_REVISION]['mars'][$column_name], $column_name, t('Column name %value matches the definition in %bin.', array('%value' => $column_name, '%bin' => 'mars[FIELD_LOAD_REVISION]')));
+ }
+ }
+
+ /**
+ * Tests insert and update with missing or NULL fields.
+ */
+ function testFieldAttachSaveMissingData() {
+ $entity_type = 'test_entity';
+ $entity_init = field_test_create_stub_entity();
+ $langcode = LANGUAGE_NONE;
+
+ // Insert: Field is missing.
+ $entity = clone($entity_init);
+ field_attach_insert($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertTrue(empty($entity->{$this->field_name}), t('Insert: missing field results in no value saved'));
+
+ // Insert: Field is NULL.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ $entity->{$this->field_name} = NULL;
+ field_attach_insert($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertTrue(empty($entity->{$this->field_name}), t('Insert: NULL field results in no value saved'));
+
+ // Add some real data.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ $values = $this->_generateTestFieldValues(1);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_insert($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertEqual($entity->{$this->field_name}[$langcode], $values, t('Field data saved'));
+
+ // Update: Field is missing. Data should survive.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ field_attach_update($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertEqual($entity->{$this->field_name}[$langcode], $values, t('Update: missing field leaves existing values in place'));
+
+ // Update: Field is NULL. Data should be wiped.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ $entity->{$this->field_name} = NULL;
+ field_attach_update($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertTrue(empty($entity->{$this->field_name}), t('Update: NULL field removes existing values'));
+
+ // Re-add some data.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ $values = $this->_generateTestFieldValues(1);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_update($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertEqual($entity->{$this->field_name}[$langcode], $values, t('Field data saved'));
+
+ // Update: Field is empty array. Data should be wiped.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ $entity->{$this->field_name} = array();
+ field_attach_update($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertTrue(empty($entity->{$this->field_name}), t('Update: empty array removes existing values'));
+ }
+
+ /**
+ * Test insert with missing or NULL fields, with default value.
+ */
+ function testFieldAttachSaveMissingDataDefaultValue() {
+ // Add a default value function.
+ $this->instance['default_value_function'] = 'field_test_default_value';
+ field_update_instance($this->instance);
+
+ $entity_type = 'test_entity';
+ $entity_init = field_test_create_stub_entity();
+ $langcode = LANGUAGE_NONE;
+
+ // Insert: Field is NULL.
+ $entity = clone($entity_init);
+ $entity->{$this->field_name}[$langcode] = NULL;
+ field_attach_insert($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertTrue(empty($entity->{$this->field_name}[$langcode]), t('Insert: NULL field results in no value saved'));
+
+ // Insert: Field is missing.
+ field_cache_clear();
+ $entity = clone($entity_init);
+ field_attach_insert($entity_type, $entity);
+
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $values = field_test_default_value($entity_type, $entity, $this->field, $this->instance);
+ $this->assertEqual($entity->{$this->field_name}[$langcode], $values, t('Insert: missing field results in default value saved'));
+ }
+
+ /**
+ * Test field_attach_delete().
+ */
+ function testFieldAttachDelete() {
+ $entity_type = 'test_entity';
+ $langcode = LANGUAGE_NONE;
+ $rev[0] = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+
+ // Create revision 0
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $rev[0]->{$this->field_name}[$langcode] = $values;
+ field_attach_insert($entity_type, $rev[0]);
+
+ // Create revision 1
+ $rev[1] = field_test_create_stub_entity(0, 1, $this->instance['bundle']);
+ $rev[1]->{$this->field_name}[$langcode] = $values;
+ field_attach_update($entity_type, $rev[1]);
+
+ // Create revision 2
+ $rev[2] = field_test_create_stub_entity(0, 2, $this->instance['bundle']);
+ $rev[2]->{$this->field_name}[$langcode] = $values;
+ field_attach_update($entity_type, $rev[2]);
+
+ // Confirm each revision loads
+ foreach (array_keys($rev) as $vid) {
+ $read = field_test_create_stub_entity(0, $vid, $this->instance['bundle']);
+ field_attach_load_revision($entity_type, array(0 => $read));
+ $this->assertEqual(count($read->{$this->field_name}[$langcode]), $this->field['cardinality'], "The test entity revision $vid has {$this->field['cardinality']} values.");
+ }
+
+ // Delete revision 1, confirm the other two still load.
+ field_attach_delete_revision($entity_type, $rev[1]);
+ foreach (array(0, 2) as $vid) {
+ $read = field_test_create_stub_entity(0, $vid, $this->instance['bundle']);
+ field_attach_load_revision($entity_type, array(0 => $read));
+ $this->assertEqual(count($read->{$this->field_name}[$langcode]), $this->field['cardinality'], "The test entity revision $vid has {$this->field['cardinality']} values.");
+ }
+
+ // Confirm the current revision still loads
+ $read = field_test_create_stub_entity(0, 2, $this->instance['bundle']);
+ field_attach_load($entity_type, array(0 => $read));
+ $this->assertEqual(count($read->{$this->field_name}[$langcode]), $this->field['cardinality'], "The test entity current revision has {$this->field['cardinality']} values.");
+
+ // Delete all field data, confirm nothing loads
+ field_attach_delete($entity_type, $rev[2]);
+ foreach (array(0, 1, 2) as $vid) {
+ $read = field_test_create_stub_entity(0, $vid, $this->instance['bundle']);
+ field_attach_load_revision($entity_type, array(0 => $read));
+ $this->assertIdentical($read->{$this->field_name}, array(), "The test entity revision $vid is deleted.");
+ }
+ $read = field_test_create_stub_entity(0, 2, $this->instance['bundle']);
+ field_attach_load($entity_type, array(0 => $read));
+ $this->assertIdentical($read->{$this->field_name}, array(), t('The test entity current revision is deleted.'));
+ }
+
+ /**
+ * Test field_attach_create_bundle() and field_attach_rename_bundle().
+ */
+ function testFieldAttachCreateRenameBundle() {
+ // Create a new bundle. This has to be initiated by the module so that its
+ // hook_entity_info() is consistent.
+ $new_bundle = 'test_bundle_' . drupal_strtolower($this->randomName());
+ field_test_create_bundle($new_bundle);
+
+ // Add an instance to that bundle.
+ $this->instance['bundle'] = $new_bundle;
+ field_create_instance($this->instance);
+
+ // Save an entity with data in the field.
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity->{$this->field_name}[$langcode] = $values;
+ $entity_type = 'test_entity';
+ field_attach_insert($entity_type, $entity);
+
+ // Verify the field data is present on load.
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ field_attach_load($entity_type, array(0 => $entity));
+ $this->assertEqual(count($entity->{$this->field_name}[$langcode]), $this->field['cardinality'], "Data is retrieved for the new bundle");
+
+ // Rename the bundle. This has to be initiated by the module so that its
+ // hook_entity_info() is consistent.
+ $new_bundle = 'test_bundle_' . drupal_strtolower($this->randomName());
+ field_test_rename_bundle($this->instance['bundle'], $new_bundle);
+
+ // Check that the instance definition has been updated.
+ $this->instance = field_info_instance($entity_type, $this->field_name, $new_bundle);
+ $this->assertIdentical($this->instance['bundle'], $new_bundle, "Bundle name has been updated in the instance.");
+
+ // Verify the field data is present on load.
+ $entity = field_test_create_stub_entity(0, 0, $new_bundle);
+ field_attach_load($entity_type, array(0 => $entity));
+ $this->assertEqual(count($entity->{$this->field_name}[$langcode]), $this->field['cardinality'], "Bundle name has been updated in the field storage");
+ }
+
+ /**
+ * Test field_attach_delete_bundle().
+ */
+ function testFieldAttachDeleteBundle() {
+ // Create a new bundle. This has to be initiated by the module so that its
+ // hook_entity_info() is consistent.
+ $new_bundle = 'test_bundle_' . drupal_strtolower($this->randomName());
+ field_test_create_bundle($new_bundle);
+
+ // Add an instance to that bundle.
+ $this->instance['bundle'] = $new_bundle;
+ field_create_instance($this->instance);
+
+ // Create a second field for the test bundle
+ $field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $field = array('field_name' => $field_name, 'type' => 'test_field', 'cardinality' => 1);
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => $this->instance['bundle'],
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ // test_field has no instance settings
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'settings' => array(
+ 'size' => mt_rand(0, 255))));
+ field_create_instance($instance);
+
+ // Save an entity with data for both fields
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity->{$this->field_name}[$langcode] = $values;
+ $entity->{$field_name}[$langcode] = $this->_generateTestFieldValues(1);
+ field_attach_insert('test_entity', $entity);
+
+ // Verify the fields are present on load
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ field_attach_load('test_entity', array(0 => $entity));
+ $this->assertEqual(count($entity->{$this->field_name}[$langcode]), 4, 'First field got loaded');
+ $this->assertEqual(count($entity->{$field_name}[$langcode]), 1, 'Second field got loaded');
+
+ // Delete the bundle. This has to be initiated by the module so that its
+ // hook_entity_info() is consistent.
+ field_test_delete_bundle($this->instance['bundle']);
+
+ // Verify no data gets loaded
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ field_attach_load('test_entity', array(0 => $entity));
+ $this->assertFalse(isset($entity->{$this->field_name}[$langcode]), 'No data for first field');
+ $this->assertFalse(isset($entity->{$field_name}[$langcode]), 'No data for second field');
+
+ // Verify that the instances are gone
+ $this->assertFalse(field_read_instance('test_entity', $this->field_name, $this->instance['bundle']), "First field is deleted");
+ $this->assertFalse(field_read_instance('test_entity', $field_name, $instance['bundle']), "Second field is deleted");
+ }
+}
+
+/**
+ * Unit test class for non-storage related field_attach_* functions.
+ */
+class FieldAttachOtherTestCase extends FieldAttachTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field attach tests (other)',
+ 'description' => 'Test other Field Attach API functions.',
+ 'group' => 'Field API',
+ );
+ }
+
+ /**
+ * Test field_attach_view() and field_attach_prepare_view().
+ */
+ function testFieldAttachView() {
+ $entity_type = 'test_entity';
+ $entity_init = field_test_create_stub_entity();
+ $langcode = LANGUAGE_NONE;
+
+ // Populate values to be displayed.
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity_init->{$this->field_name}[$langcode] = $values;
+
+ // Simple formatter, label displayed.
+ $entity = clone($entity_init);
+ $formatter_setting = $this->randomName();
+ $this->instance['display'] = array(
+ 'full' => array(
+ 'label' => 'above',
+ 'type' => 'field_test_default',
+ 'settings' => array(
+ 'test_formatter_setting' => $formatter_setting,
+ )
+ ),
+ );
+ field_update_instance($this->instance);
+ field_attach_prepare_view($entity_type, array($entity->ftid => $entity), 'full');
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $output = drupal_render($entity->content);
+ $this->content = $output;
+ $this->assertRaw($this->instance['label'], "Label is displayed.");
+ foreach ($values as $delta => $value) {
+ $this->content = $output;
+ $this->assertRaw("$formatter_setting|{$value['value']}", "Value $delta is displayed, formatter settings are applied.");
+ }
+
+ // Label hidden.
+ $entity = clone($entity_init);
+ $this->instance['display']['full']['label'] = 'hidden';
+ field_update_instance($this->instance);
+ field_attach_prepare_view($entity_type, array($entity->ftid => $entity), 'full');
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $output = drupal_render($entity->content);
+ $this->content = $output;
+ $this->assertNoRaw($this->instance['label'], "Hidden label: label is not displayed.");
+
+ // Field hidden.
+ $entity = clone($entity_init);
+ $this->instance['display'] = array(
+ 'full' => array(
+ 'label' => 'above',
+ 'type' => 'hidden',
+ ),
+ );
+ field_update_instance($this->instance);
+ field_attach_prepare_view($entity_type, array($entity->ftid => $entity), 'full');
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $output = drupal_render($entity->content);
+ $this->content = $output;
+ $this->assertNoRaw($this->instance['label'], "Hidden field: label is not displayed.");
+ foreach ($values as $delta => $value) {
+ $this->assertNoRaw($value['value'], "Hidden field: value $delta is not displayed.");
+ }
+
+ // Multiple formatter.
+ $entity = clone($entity_init);
+ $formatter_setting = $this->randomName();
+ $this->instance['display'] = array(
+ 'full' => array(
+ 'label' => 'above',
+ 'type' => 'field_test_multiple',
+ 'settings' => array(
+ 'test_formatter_setting_multiple' => $formatter_setting,
+ )
+ ),
+ );
+ field_update_instance($this->instance);
+ field_attach_prepare_view($entity_type, array($entity->ftid => $entity), 'full');
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $output = drupal_render($entity->content);
+ $display = $formatter_setting;
+ foreach ($values as $delta => $value) {
+ $display .= "|$delta:{$value['value']}";
+ }
+ $this->content = $output;
+ $this->assertRaw($display, "Multiple formatter: all values are displayed, formatter settings are applied.");
+
+ // Test a formatter that uses hook_field_formatter_prepare_view().
+ $entity = clone($entity_init);
+ $formatter_setting = $this->randomName();
+ $this->instance['display'] = array(
+ 'full' => array(
+ 'label' => 'above',
+ 'type' => 'field_test_with_prepare_view',
+ 'settings' => array(
+ 'test_formatter_setting_additional' => $formatter_setting,
+ )
+ ),
+ );
+ field_update_instance($this->instance);
+ field_attach_prepare_view($entity_type, array($entity->ftid => $entity), 'full');
+ $entity->content = field_attach_view($entity_type, $entity, 'full');
+ $output = drupal_render($entity->content);
+ $this->content = $output;
+ foreach ($values as $delta => $value) {
+ $this->content = $output;
+ $expected = $formatter_setting . '|' . $value['value'] . '|' . ($value['value'] + 1);
+ $this->assertRaw($expected, "Value $delta is displayed, formatter settings are applied.");
+ }
+
+ // TODO:
+ // - check display order with several fields
+
+ // Preprocess template.
+ $variables = array();
+ field_attach_preprocess($entity_type, $entity, $entity->content, $variables);
+ $result = TRUE;
+ foreach ($values as $delta => $item) {
+ if ($variables[$this->field_name][$delta]['value'] !== $item['value']) {
+ $result = FALSE;
+ break;
+ }
+ }
+ $this->assertTrue($result, t('Variable $@field_name correctly populated.', array('@field_name' => $this->field_name)));
+ }
+
+ /**
+ * Tests the 'multiple entity' behavior of field_attach_prepare_view().
+ */
+ function testFieldAttachPrepareViewMultiple() {
+ $entity_type = 'test_entity';
+ $langcode = LANGUAGE_NONE;
+
+ // Set the instance to be hidden.
+ $this->instance['display']['full']['type'] = 'hidden';
+ field_update_instance($this->instance);
+
+ // Set up a second instance on another bundle, with a formatter that uses
+ // hook_field_formatter_prepare_view().
+ field_test_create_bundle('test_bundle_2');
+ $formatter_setting = $this->randomName();
+ $this->instance2 = $this->instance;
+ $this->instance2['bundle'] = 'test_bundle_2';
+ $this->instance2['display']['full'] = array(
+ 'type' => 'field_test_with_prepare_view',
+ 'settings' => array(
+ 'test_formatter_setting_additional' => $formatter_setting,
+ )
+ );
+ field_create_instance($this->instance2);
+
+ // Create one entity in each bundle.
+ $entity1_init = field_test_create_stub_entity(1, 1, 'test_bundle');
+ $values1 = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity1_init->{$this->field_name}[$langcode] = $values1;
+
+ $entity2_init = field_test_create_stub_entity(2, 2, 'test_bundle_2');
+ $values2 = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity2_init->{$this->field_name}[$langcode] = $values2;
+
+ // Run prepare_view, and check that the entities come out as expected.
+ $entity1 = clone($entity1_init);
+ $entity2 = clone($entity2_init);
+ field_attach_prepare_view($entity_type, array($entity1->ftid => $entity1, $entity2->ftid => $entity2), 'full');
+ $this->assertFalse(isset($entity1->{$this->field_name}[$langcode][0]['additional_formatter_value']), 'Entity 1 did not run through the prepare_view hook.');
+ $this->assertTrue(isset($entity2->{$this->field_name}[$langcode][0]['additional_formatter_value']), 'Entity 2 ran through the prepare_view hook.');
+
+ // Same thing, reversed order.
+ $entity1 = clone($entity1_init);
+ $entity2 = clone($entity2_init);
+ field_attach_prepare_view($entity_type, array($entity2->ftid => $entity2, $entity1->ftid => $entity1), 'full');
+ $this->assertFalse(isset($entity1->{$this->field_name}[$langcode][0]['additional_formatter_value']), 'Entity 1 did not run through the prepare_view hook.');
+ $this->assertTrue(isset($entity2->{$this->field_name}[$langcode][0]['additional_formatter_value']), 'Entity 2 ran through the prepare_view hook.');
+ }
+
+ /**
+ * Test field cache.
+ */
+ function testFieldAttachCache() {
+ // Initialize random values and a test entity.
+ $entity_init = field_test_create_stub_entity(1, 1, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+
+ // Non-cacheable entity type.
+ $entity_type = 'test_entity';
+ $cid = "field:$entity_type:{$entity_init->ftid}";
+
+ // Check that no initial cache entry is present.
+ $this->assertFalse(cache('field')->get($cid), t('Non-cached: no initial cache entry'));
+
+ // Save, and check that no cache entry is present.
+ $entity = clone($entity_init);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_insert($entity_type, $entity);
+ $this->assertFalse(cache('field')->get($cid), t('Non-cached: no cache entry on insert'));
+
+ // Load, and check that no cache entry is present.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $this->assertFalse(cache('field')->get($cid), t('Non-cached: no cache entry on load'));
+
+
+ // Cacheable entity type.
+ $entity_type = 'test_cacheable_entity';
+ $cid = "field:$entity_type:{$entity_init->ftid}";
+ $instance = $this->instance;
+ $instance['entity_type'] = $entity_type;
+ field_create_instance($instance);
+
+ // Check that no initial cache entry is present.
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no initial cache entry'));
+
+ // Save, and check that no cache entry is present.
+ $entity = clone($entity_init);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_insert($entity_type, $entity);
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no cache entry on insert'));
+
+ // Load a single field, and check that no cache entry is present.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity), FIELD_LOAD_CURRENT, array('field_id' => $this->field_id));
+ $cache = cache('field')->get($cid);
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no cache entry on loading a single field'));
+
+ // Load, and check that a cache entry is present with the expected values.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $cache = cache('field')->get($cid);
+ $this->assertEqual($cache->data[$this->field_name][$langcode], $values, t('Cached: correct cache entry on load'));
+
+ // Update with different values, and check that the cache entry is wiped.
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity = clone($entity_init);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_update($entity_type, $entity);
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no cache entry on update'));
+
+ // Load, and check that a cache entry is present with the expected values.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $cache = cache('field')->get($cid);
+ $this->assertEqual($cache->data[$this->field_name][$langcode], $values, t('Cached: correct cache entry on load'));
+
+ // Create a new revision, and check that the cache entry is wiped.
+ $entity_init = field_test_create_stub_entity(1, 2, $this->instance['bundle']);
+ $values = $this->_generateTestFieldValues($this->field['cardinality']);
+ $entity = clone($entity_init);
+ $entity->{$this->field_name}[$langcode] = $values;
+ field_attach_update($entity_type, $entity);
+ $cache = cache('field')->get($cid);
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no cache entry on new revision creation'));
+
+ // Load, and check that a cache entry is present with the expected values.
+ $entity = clone($entity_init);
+ field_attach_load($entity_type, array($entity->ftid => $entity));
+ $cache = cache('field')->get($cid);
+ $this->assertEqual($cache->data[$this->field_name][$langcode], $values, t('Cached: correct cache entry on load'));
+
+ // Delete, and check that the cache entry is wiped.
+ field_attach_delete($entity_type, $entity);
+ $this->assertFalse(cache('field')->get($cid), t('Cached: no cache entry after delete'));
+ }
+
+ /**
+ * Test field_attach_validate().
+ *
+ * Verify that field_attach_validate() invokes the correct
+ * hook_field_validate.
+ */
+ function testFieldAttachValidate() {
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+
+ // Set up values to generate errors
+ $values = array();
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ $values[$delta]['value'] = -1;
+ }
+ // Arrange for item 1 not to generate an error
+ $values[1]['value'] = 1;
+ $entity->{$this->field_name}[$langcode] = $values;
+
+ try {
+ field_attach_validate($entity_type, $entity);
+ }
+ catch (FieldValidationException $e) {
+ $errors = $e->errors;
+ }
+
+ foreach ($values as $delta => $value) {
+ if ($value['value'] != 1) {
+ $this->assertIdentical($errors[$this->field_name][$langcode][$delta][0]['error'], 'field_test_invalid', "Error set on value $delta");
+ $this->assertEqual(count($errors[$this->field_name][$langcode][$delta]), 1, "Only one error set on value $delta");
+ unset($errors[$this->field_name][$langcode][$delta]);
+ }
+ else {
+ $this->assertFalse(isset($errors[$this->field_name][$langcode][$delta]), "No error set on value $delta");
+ }
+ }
+ $this->assertEqual(count($errors[$this->field_name][$langcode]), 0, 'No extraneous errors set');
+
+ // Check that cardinality is validated.
+ $entity->{$this->field_name}[$langcode] = $this->_generateTestFieldValues($this->field['cardinality'] + 1);
+ try {
+ field_attach_validate($entity_type, $entity);
+ }
+ catch (FieldValidationException $e) {
+ $errors = $e->errors;
+ }
+ $this->assertEqual($errors[$this->field_name][$langcode][0][0]['error'], 'field_cardinality', t('Cardinality validation failed.'));
+
+ }
+
+ /**
+ * Test field_attach_form().
+ *
+ * This could be much more thorough, but it does verify that the correct
+ * widgets show up.
+ */
+ function testFieldAttachForm() {
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+
+ $form = array();
+ $form_state = form_state_defaults();
+ field_attach_form($entity_type, $entity, $form, $form_state);
+
+ $langcode = LANGUAGE_NONE;
+ $this->assertEqual($form[$this->field_name][$langcode]['#title'], $this->instance['label'], "Form title is {$this->instance['label']}");
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ // field_test_widget uses 'textfield'
+ $this->assertEqual($form[$this->field_name][$langcode][$delta]['value']['#type'], 'textfield', "Form delta $delta widget is textfield");
+ }
+ }
+
+ /**
+ * Test field_attach_submit().
+ */
+ function testFieldAttachSubmit() {
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+
+ // Build the form.
+ $form = array();
+ $form_state = form_state_defaults();
+ field_attach_form($entity_type, $entity, $form, $form_state);
+
+ // Simulate incoming values.
+ $values = array();
+ $weights = array();
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ $values[$delta]['value'] = mt_rand(1, 127);
+ // Assign random weight.
+ do {
+ $weight = mt_rand(0, $this->field['cardinality']);
+ } while (in_array($weight, $weights));
+ $weights[$delta] = $weight;
+ $values[$delta]['_weight'] = $weight;
+ }
+ // Leave an empty value. 'field_test' fields are empty if empty().
+ $values[1]['value'] = 0;
+
+ $langcode = LANGUAGE_NONE;
+ // Pretend the form has been built.
+ drupal_prepare_form('field_test_entity_form', $form, $form_state);
+ drupal_process_form('field_test_entity_form', $form, $form_state);
+ $form_state['values'][$this->field_name][$langcode] = $values;
+ field_attach_submit($entity_type, $entity, $form, $form_state);
+
+ asort($weights);
+ $expected_values = array();
+ foreach ($weights as $key => $value) {
+ if ($key != 1) {
+ $expected_values[] = array('value' => $values[$key]['value']);
+ }
+ }
+ $this->assertIdentical($entity->{$this->field_name}[$langcode], $expected_values, 'Submit filters empty values');
+ }
+}
+
+class FieldInfoTestCase extends FieldTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field info tests',
+ 'description' => 'Get information about existing fields, instances and bundles.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+ }
+
+ /**
+ * Test that field types and field definitions are correcly cached.
+ */
+ function testFieldInfo() {
+ // Test that field_test module's fields, widgets, and formatters show up.
+
+ $field_test_info = field_test_field_info();
+ // We need to account for the existence of user_field_info_alter().
+ foreach (array_keys($field_test_info) as $name) {
+ $field_test_info[$name]['instance_settings']['user_register_form'] = FALSE;
+ }
+ $info = field_info_field_types();
+ foreach ($field_test_info as $t_key => $field_type) {
+ foreach ($field_type as $key => $val) {
+ $this->assertEqual($info[$t_key][$key], $val, t("Field type $t_key key $key is $val"));
+ }
+ $this->assertEqual($info[$t_key]['module'], 'field_test', t("Field type field_test module appears"));
+ }
+
+ $formatter_info = field_test_field_formatter_info();
+ $info = field_info_formatter_types();
+ foreach ($formatter_info as $f_key => $formatter) {
+ foreach ($formatter as $key => $val) {
+ $this->assertEqual($info[$f_key][$key], $val, t("Formatter type $f_key key $key is $val"));
+ }
+ $this->assertEqual($info[$f_key]['module'], 'field_test', t("Formatter type field_test module appears"));
+ }
+
+ $widget_info = field_test_field_widget_info();
+ $info = field_info_widget_types();
+ foreach ($widget_info as $w_key => $widget) {
+ foreach ($widget as $key => $val) {
+ $this->assertEqual($info[$w_key][$key], $val, t("Widget type $w_key key $key is $val"));
+ }
+ $this->assertEqual($info[$w_key]['module'], 'field_test', t("Widget type field_test module appears"));
+ }
+
+ $storage_info = field_test_field_storage_info();
+ $info = field_info_storage_types();
+ foreach ($storage_info as $s_key => $storage) {
+ foreach ($storage as $key => $val) {
+ $this->assertEqual($info[$s_key][$key], $val, t("Storage type $s_key key $key is $val"));
+ }
+ $this->assertEqual($info[$s_key]['module'], 'field_test', t("Storage type field_test module appears"));
+ }
+
+ // Verify that no unexpected instances exist.
+ $core_fields = field_info_fields();
+ $instances = field_info_instances('test_entity', 'test_bundle');
+ $this->assertTrue(empty($instances), t('With no instances, info bundles is empty.'));
+
+ // Create a field, verify it shows up.
+ $field = array(
+ 'field_name' => drupal_strtolower($this->randomName()),
+ 'type' => 'test_field',
+ );
+ field_create_field($field);
+ $fields = field_info_fields();
+ $this->assertEqual(count($fields), count($core_fields) + 1, t('One new field exists'));
+ $this->assertEqual($fields[$field['field_name']]['field_name'], $field['field_name'], t('info fields contains field name'));
+ $this->assertEqual($fields[$field['field_name']]['type'], $field['type'], t('info fields contains field type'));
+ $this->assertEqual($fields[$field['field_name']]['module'], 'field_test', t('info fields contains field module'));
+ $settings = array('test_field_setting' => 'dummy test string');
+ foreach ($settings as $key => $val) {
+ $this->assertEqual($fields[$field['field_name']]['settings'][$key], $val, t("Field setting $key has correct default value $val"));
+ }
+ $this->assertEqual($fields[$field['field_name']]['cardinality'], 1, t('info fields contains cardinality 1'));
+ $this->assertEqual($fields[$field['field_name']]['active'], 1, t('info fields contains active 1'));
+
+ // Create an instance, verify that it shows up
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->randomName(),
+ 'description' => $this->randomName(),
+ 'weight' => mt_rand(0, 127),
+ // test_field has no instance settings
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'settings' => array(
+ 'test_setting' => 999)));
+ field_create_instance($instance);
+
+ $instances = field_info_instances('test_entity', $instance['bundle']);
+ $this->assertEqual(count($instances), 1, t('One instance shows up in info when attached to a bundle.'));
+ $this->assertTrue($instance < $instances[$instance['field_name']], t('Instance appears in info correctly'));
+ }
+
+ /**
+ * Test that cached field definitions are ready for current runtime context.
+ */
+ function testFieldPrepare() {
+ $field_definition = array(
+ 'field_name' => 'field',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+
+ // Simulate a stored field definition missing a field setting (e.g. a
+ // third-party module adding a new field setting has been enabled, and
+ // existing fields do not know the setting yet).
+ $data = db_query('SELECT data FROM {field_config} WHERE field_name = :field_name', array(':field_name' => $field_definition['field_name']))->fetchField();
+ $data = unserialize($data);
+ $data['settings'] = array();
+ db_update('field_config')
+ ->fields(array('data' => serialize($data)))
+ ->condition('field_name', $field_definition['field_name'])
+ ->execute();
+
+ field_cache_clear();
+
+ // Read the field back.
+ $field = field_info_field($field_definition['field_name']);
+
+ // Check that all expected settings are in place.
+ $field_type = field_info_field_types($field_definition['type']);
+ $this->assertIdentical($field['settings'], $field_type['settings'], t('All expected default field settings are present.'));
+ }
+
+ /**
+ * Test that cached instance definitions are ready for current runtime context.
+ */
+ function testInstancePrepare() {
+ $field_definition = array(
+ 'field_name' => 'field',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $instance_definition = array(
+ 'field_name' => $field_definition['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ );
+ field_create_instance($instance_definition);
+
+ // Simulate a stored instance definition missing various settings (e.g. a
+ // third-party module adding instance, widget or display settings has been
+ // enabled, but existing instances do not know the new settings).
+ $data = db_query('SELECT data FROM {field_config_instance} WHERE field_name = :field_name AND bundle = :bundle', array(':field_name' => $instance_definition['field_name'], ':bundle' => $instance_definition['bundle']))->fetchField();
+ $data = unserialize($data);
+ $data['settings'] = array();
+ $data['widget']['settings'] = 'unavailable_widget';
+ $data['widget']['settings'] = array();
+ $data['display']['default']['type'] = 'unavailable_formatter';
+ $data['display']['default']['settings'] = array();
+ db_update('field_config_instance')
+ ->fields(array('data' => serialize($data)))
+ ->condition('field_name', $instance_definition['field_name'])
+ ->condition('bundle', $instance_definition['bundle'])
+ ->execute();
+
+ field_cache_clear();
+
+ // Read the instance back.
+ $instance = field_info_instance($instance_definition['entity_type'], $instance_definition['field_name'], $instance_definition['bundle']);
+
+ // Check that all expected instance settings are in place.
+ $field_type = field_info_field_types($field_definition['type']);
+ $this->assertIdentical($instance['settings'], $field_type['instance_settings'] , t('All expected instance settings are present.'));
+
+ // Check that the default widget is used and expected settings are in place.
+ $this->assertIdentical($instance['widget']['type'], $field_type['default_widget'], t('Unavailable widget replaced with default widget.'));
+ $widget_type = field_info_widget_types($instance['widget']['type']);
+ $this->assertIdentical($instance['widget']['settings'], $widget_type['settings'] , t('All expected widget settings are present.'));
+
+ // Check that display settings are set for the 'default' mode.
+ $display = $instance['display']['default'];
+ $this->assertIdentical($display['type'], $field_type['default_formatter'], t("Formatter is set for the 'default' view mode"));
+ $formatter_type = field_info_formatter_types($display['type']);
+ $this->assertIdentical($display['settings'], $formatter_type['settings'] , t("Formatter settings are set for the 'default' view mode"));
+ }
+
+ /**
+ * Test that instances on disabled entity types are filtered out.
+ */
+ function testInstanceDisabledEntityType() {
+ // For this test the field type and the entity type must be exposed by
+ // different modules.
+ $field_definition = array(
+ 'field_name' => 'field',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $instance_definition = array(
+ 'field_name' => 'field',
+ 'entity_type' => 'comment',
+ 'bundle' => 'comment_node_article',
+ );
+ field_create_instance($instance_definition);
+
+ // Disable coment module. This clears field_info cache.
+ module_disable(array('comment'));
+ $this->assertNull(field_info_instance('comment', 'field', 'comment_node_article'), t('No instances are returned on disabled entity types.'));
+ }
+
+ /**
+ * Test that the field_info settings convenience functions work.
+ */
+ function testSettingsInfo() {
+ $info = field_test_field_info();
+ // We need to account for the existence of user_field_info_alter().
+ foreach (array_keys($info) as $name) {
+ $info[$name]['instance_settings']['user_register_form'] = FALSE;
+ }
+ foreach ($info as $type => $data) {
+ $this->assertIdentical(field_info_field_settings($type), $data['settings'], "field_info_field_settings returns {$type}'s field settings");
+ $this->assertIdentical(field_info_instance_settings($type), $data['instance_settings'], "field_info_field_settings returns {$type}'s field instance settings");
+ }
+
+ $info = field_test_field_widget_info();
+ foreach ($info as $type => $data) {
+ $this->assertIdentical(field_info_widget_settings($type), $data['settings'], "field_info_widget_settings returns {$type}'s widget settings");
+ }
+
+ $info = field_test_field_formatter_info();
+ foreach ($info as $type => $data) {
+ $this->assertIdentical(field_info_formatter_settings($type), $data['settings'], "field_info_formatter_settings returns {$type}'s formatter settings");
+ }
+ }
+}
+
+class FieldFormTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field form tests',
+ 'description' => 'Test Field form handling.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ $web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content'));
+ $this->drupalLogin($web_user);
+
+ $this->field_single = array('field_name' => 'field_single', 'type' => 'test_field');
+ $this->field_multiple = array('field_name' => 'field_multiple', 'type' => 'test_field', 'cardinality' => 4);
+ $this->field_unlimited = array('field_name' => 'field_unlimited', 'type' => 'test_field', 'cardinality' => FIELD_CARDINALITY_UNLIMITED);
+
+ $this->instance = array(
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->randomName() . '_label',
+ 'description' => $this->randomName() . '_description',
+ 'weight' => mt_rand(0, 127),
+ 'settings' => array(
+ 'test_instance_setting' => $this->randomName(),
+ ),
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ 'label' => 'Test Field',
+ 'settings' => array(
+ 'test_widget_setting' => $this->randomName(),
+ )
+ )
+ );
+ }
+
+ function testFieldFormSingle() {
+ $this->field = $this->field_single;
+ $this->field_name = $this->field['field_name'];
+ $this->instance['field_name'] = $this->field_name;
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", '', 'Widget is displayed');
+ $this->assertNoField("{$this->field_name}[$langcode][1][value]", 'No extraneous widget is displayed');
+ // TODO : check that the widget is populated with default value ?
+
+ // Submit with invalid value (field-level validation).
+ $edit = array("{$this->field_name}[$langcode][0][value]" => -1);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('%name does not accept the value -1.', array('%name' => $this->instance['label'])), 'Field validation fails with invalid input.');
+ // TODO : check that the correct field is flagged for error.
+
+ // Create an entity
+ $value = mt_rand(1, 127);
+ $edit = array("{$this->field_name}[$langcode][0][value]" => $value);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), 'Entity was created');
+ $entity = field_test_entity_test_load($id);
+ $this->assertEqual($entity->{$this->field_name}[$langcode][0]['value'], $value, 'Field value was saved');
+
+ // Display edit form.
+ $this->drupalGet('test-entity/manage/' . $id . '/edit');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", $value, 'Widget is displayed with the correct default value');
+ $this->assertNoField("{$this->field_name}[$langcode][1][value]", 'No extraneous widget is displayed');
+
+ // Update the entity.
+ $value = mt_rand(1, 127);
+ $edit = array("{$this->field_name}[$langcode][0][value]" => $value);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('test_entity @id has been updated.', array('@id' => $id)), 'Entity was updated');
+ $entity = field_test_entity_test_load($id);
+ $this->assertEqual($entity->{$this->field_name}[$langcode][0]['value'], $value, 'Field value was updated');
+
+ // Empty the field.
+ $value = '';
+ $edit = array("{$this->field_name}[$langcode][0][value]" => $value);
+ $this->drupalPost('test-entity/manage/' . $id . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('test_entity @id has been updated.', array('@id' => $id)), 'Entity was updated');
+ $entity = field_test_entity_test_load($id);
+ $this->assertIdentical($entity->{$this->field_name}, array(), 'Field was emptied');
+
+ }
+
+ function testFieldFormSingleRequired() {
+ $this->field = $this->field_single;
+ $this->field_name = $this->field['field_name'];
+ $this->instance['field_name'] = $this->field_name;
+ $this->instance['required'] = TRUE;
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Submit with missing required value.
+ $edit = array();
+ $this->drupalPost('test-entity/add/test-bundle', $edit, t('Save'));
+ $this->assertRaw(t('!name field is required.', array('!name' => $this->instance['label'])), 'Required field with no value fails validation');
+
+ // Create an entity
+ $value = mt_rand(1, 127);
+ $edit = array("{$this->field_name}[$langcode][0][value]" => $value);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), 'Entity was created');
+ $entity = field_test_entity_test_load($id);
+ $this->assertEqual($entity->{$this->field_name}[$langcode][0]['value'], $value, 'Field value was saved');
+
+ // Edit with missing required value.
+ $value = '';
+ $edit = array("{$this->field_name}[$langcode][0][value]" => $value);
+ $this->drupalPost('test-entity/manage/' . $id . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('!name field is required.', array('!name' => $this->instance['label'])), 'Required field with no value fails validation');
+ }
+
+// function testFieldFormMultiple() {
+// $this->field = $this->field_multiple;
+// $this->field_name = $this->field['field_name'];
+// $this->instance['field_name'] = $this->field_name;
+// field_create_field($this->field);
+// field_create_instance($this->instance);
+// }
+
+ function testFieldFormUnlimited() {
+ $this->field = $this->field_unlimited;
+ $this->field_name = $this->field['field_name'];
+ $this->instance['field_name'] = $this->field_name;
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form -> 1 widget.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", '', 'Widget 1 is displayed');
+ $this->assertNoField("{$this->field_name}[$langcode][1][value]", 'No extraneous widget is displayed');
+
+ // Press 'add more' button -> 2 widgets.
+ $this->drupalPost(NULL, array(), t('Add another item'));
+ $this->assertFieldByName("{$this->field_name}[$langcode][0][value]", '', 'Widget 1 is displayed');
+ $this->assertFieldByName("{$this->field_name}[$langcode][1][value]", '', 'New widget is displayed');
+ $this->assertNoField("{$this->field_name}[$langcode][2][value]", 'No extraneous widget is displayed');
+ // TODO : check that non-field inpurs are preserved ('title')...
+
+ // Yet another time so that we can play with more values -> 3 widgets.
+ $this->drupalPost(NULL, array(), t('Add another item'));
+
+ // Prepare values and weights.
+ $count = 3;
+ $delta_range = $count - 1;
+ $values = $weights = $pattern = $expected_values = $edit = array();
+ for ($delta = 0; $delta <= $delta_range; $delta++) {
+ // Assign unique random values and weights.
+ do {
+ $value = mt_rand(1, 127);
+ } while (in_array($value, $values));
+ do {
+ $weight = mt_rand(-$delta_range, $delta_range);
+ } while (in_array($weight, $weights));
+ $edit["$this->field_name[$langcode][$delta][value]"] = $value;
+ $edit["$this->field_name[$langcode][$delta][_weight]"] = $weight;
+ // We'll need three slightly different formats to check the values.
+ $values[$delta] = $value;
+ $weights[$delta] = $weight;
+ $field_values[$weight]['value'] = (string) $value;
+ $pattern[$weight] = "<input [^>]*value=\"$value\" [^>]*";
+ }
+
+ // Press 'add more' button -> 4 widgets
+ $this->drupalPost(NULL, $edit, t('Add another item'));
+ for ($delta = 0; $delta <= $delta_range; $delta++) {
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][value]", $values[$delta], "Widget $delta is displayed and has the right value");
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][_weight]", $weights[$delta], "Widget $delta has the right weight");
+ }
+ ksort($pattern);
+ $pattern = implode('.*', array_values($pattern));
+ $this->assertPattern("|$pattern|s", 'Widgets are displayed in the correct order');
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][value]", '', "New widget is displayed");
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][_weight]", $delta, "New widget has the right weight");
+ $this->assertNoField("$this->field_name[$langcode][" . ($delta + 1) . '][value]', 'No extraneous widget is displayed');
+
+ // Submit the form and create the entity.
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), 'Entity was created');
+ $entity = field_test_entity_test_load($id);
+ ksort($field_values);
+ $field_values = array_values($field_values);
+ $this->assertIdentical($entity->{$this->field_name}[$langcode], $field_values, 'Field values were saved in the correct order');
+
+ // Display edit form: check that the expected number of widgets is
+ // displayed, with correct values change values, reorder, leave an empty
+ // value in the middle.
+ // Submit: check that the entity is updated with correct values
+ // Re-submit: check that the field can be emptied.
+
+ // Test with several multiple fields in a form
+ }
+
+ function testFieldFormJSAddMore() {
+ $this->field = $this->field_unlimited;
+ $this->field_name = $this->field['field_name'];
+ $this->instance['field_name'] = $this->field_name;
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form -> 1 widget.
+ $this->drupalGet('test-entity/add/test-bundle');
+
+ // Press 'add more' button a couple times -> 3 widgets.
+ // drupalPostAJAX() will not work iteratively, so we add those through
+ // non-JS submission.
+ $this->drupalPost(NULL, array(), t('Add another item'));
+ $this->drupalPost(NULL, array(), t('Add another item'));
+
+ // Prepare values and weights.
+ $count = 3;
+ $delta_range = $count - 1;
+ $values = $weights = $pattern = $expected_values = $edit = array();
+ for ($delta = 0; $delta <= $delta_range; $delta++) {
+ // Assign unique random values and weights.
+ do {
+ $value = mt_rand(1, 127);
+ } while (in_array($value, $values));
+ do {
+ $weight = mt_rand(-$delta_range, $delta_range);
+ } while (in_array($weight, $weights));
+ $edit["$this->field_name[$langcode][$delta][value]"] = $value;
+ $edit["$this->field_name[$langcode][$delta][_weight]"] = $weight;
+ // We'll need three slightly different formats to check the values.
+ $values[$delta] = $value;
+ $weights[$delta] = $weight;
+ $field_values[$weight]['value'] = (string) $value;
+ $pattern[$weight] = "<input [^>]*value=\"$value\" [^>]*";
+ }
+ // Press 'add more' button through Ajax, and place the expected HTML result
+ // as the tested content.
+ $commands = $this->drupalPostAJAX(NULL, $edit, $this->field_name . '_add_more');
+ $this->content = $commands[1]['data'];
+
+ for ($delta = 0; $delta <= $delta_range; $delta++) {
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][value]", $values[$delta], "Widget $delta is displayed and has the right value");
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][_weight]", $weights[$delta], "Widget $delta has the right weight");
+ }
+ ksort($pattern);
+ $pattern = implode('.*', array_values($pattern));
+ $this->assertPattern("|$pattern|s", 'Widgets are displayed in the correct order');
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][value]", '', "New widget is displayed");
+ $this->assertFieldByName("$this->field_name[$langcode][$delta][_weight]", $delta, "New widget has the right weight");
+ $this->assertNoField("$this->field_name[$langcode][" . ($delta + 1) . '][value]', 'No extraneous widget is displayed');
+ }
+
+ /**
+ * Tests widgets handling multiple values.
+ */
+ function testFieldFormMultipleWidget() {
+ // Create a field with fixed cardinality and an instance using a multiple
+ // widget.
+ $this->field = $this->field_multiple;
+ $this->field_name = $this->field['field_name'];
+ $this->instance['field_name'] = $this->field_name;
+ $this->instance['widget']['type'] = 'test_field_widget_multiple';
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode]", '', t('Widget is displayed.'));
+
+ // Create entity with three values.
+ $edit = array("{$this->field_name}[$langcode]" => '1, 2, 3');
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+
+ // Check that the values were saved.
+ $entity_init = field_test_create_stub_entity($id);
+ $this->assertFieldValues($entity_init, $this->field_name, $langcode, array(1, 2, 3));
+
+ // Display the form, check that the values are correctly filled in.
+ $this->drupalGet('test-entity/manage/' . $id . '/edit');
+ $this->assertFieldByName("{$this->field_name}[$langcode]", '1, 2, 3', t('Widget is displayed.'));
+
+ // Submit the form with more values than the field accepts.
+ $edit = array("{$this->field_name}[$langcode]" => '1, 2, 3, 4, 5');
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw('this field cannot hold more than 4 values', t('Form validation failed.'));
+ // Check that the field values were not submitted.
+ $this->assertFieldValues($entity_init, $this->field_name, $langcode, array(1, 2, 3));
+ }
+
+ /**
+ * Tests fields with no 'edit' access.
+ */
+ function testFieldFormAccess() {
+ // Create a "regular" field.
+ $field = $this->field_single;
+ $field_name = $field['field_name'];
+ $instance = $this->instance;
+ $instance['field_name'] = $field_name;
+ field_create_field($field);
+ field_create_instance($instance);
+
+ // Create a field with no edit access - see field_test_field_access().
+ $field_no_access = array(
+ 'field_name' => 'field_no_edit_access',
+ 'type' => 'test_field',
+ );
+ $field_name_no_access = $field_no_access['field_name'];
+ $instance_no_access = array(
+ 'field_name' => $field_name_no_access,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'default_value' => array(0 => array('value' => 99)),
+ );
+ field_create_field($field_no_access);
+ field_create_instance($instance_no_access);
+
+ $langcode = LANGUAGE_NONE;
+
+ // Display creation form.
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertNoFieldByName("{$field_name_no_access}[$langcode][0][value]", '', t('Widget is not displayed if field access is denied.'));
+
+ // Create entity.
+ $edit = array("{$field_name}[$langcode][0][value]" => 1);
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+
+ // Check that the default value was saved.
+ $entity = field_test_entity_test_load($id);
+ $this->assertEqual($entity->{$field_name_no_access}[$langcode][0]['value'], 99, t('Default value was saved for the field with no edit access.'));
+ $this->assertEqual($entity->{$field_name}[$langcode][0]['value'], 1, t('Entered value vas saved for the field with edit access.'));
+
+ // Create a new revision.
+ $edit = array("{$field_name}[$langcode][0][value]" => 2, 'revision' => TRUE);
+ $this->drupalPost('test-entity/manage/' . $id . '/edit', $edit, t('Save'));
+
+ // Check that the new revision has the expected values.
+ $entity = field_test_entity_test_load($id);
+ $this->assertEqual($entity->{$field_name_no_access}[$langcode][0]['value'], 99, t('New revision has the expected value for the field with no edit access.'));
+ $this->assertEqual($entity->{$field_name}[$langcode][0]['value'], 2, t('New revision has the expected value for the field with edit access.'));
+
+ // Check that the revision is also saved in the revisions table.
+ $entity = field_test_entity_test_load($id, $entity->ftvid);
+ $this->assertEqual($entity->{$field_name_no_access}[$langcode][0]['value'], 99, t('New revision has the expected value for the field with no edit access.'));
+ $this->assertEqual($entity->{$field_name}[$langcode][0]['value'], 2, t('New revision has the expected value for the field with edit access.'));
+ }
+
+ /**
+ * Tests Field API form integration within a subform.
+ */
+ function testNestedFieldForm() {
+ // Add two instances on the 'test_bundle'
+ field_create_field($this->field_single);
+ field_create_field($this->field_unlimited);
+ $this->instance['field_name'] = 'field_single';
+ $this->instance['label'] = 'Single field';
+ field_create_instance($this->instance);
+ $this->instance['field_name'] = 'field_unlimited';
+ $this->instance['label'] = 'Unlimited field';
+ field_create_instance($this->instance);
+
+ // Create two entities.
+ $entity_1 = field_test_create_stub_entity(1, 1);
+ $entity_1->is_new = TRUE;
+ $entity_1->field_single[LANGUAGE_NONE][] = array('value' => 0);
+ $entity_1->field_unlimited[LANGUAGE_NONE][] = array('value' => 1);
+ field_test_entity_save($entity_1);
+
+ $entity_2 = field_test_create_stub_entity(2, 2);
+ $entity_2->is_new = TRUE;
+ $entity_2->field_single[LANGUAGE_NONE][] = array('value' => 10);
+ $entity_2->field_unlimited[LANGUAGE_NONE][] = array('value' => 11);
+ field_test_entity_save($entity_2);
+
+ // Display the 'combined form'.
+ $this->drupalGet('test-entity/nested/1/2');
+ $this->assertFieldByName('field_single[und][0][value]', 0, t('Entity 1: field_single value appears correctly is the form.'));
+ $this->assertFieldByName('field_unlimited[und][0][value]', 1, t('Entity 1: field_unlimited value 0 appears correctly is the form.'));
+ $this->assertFieldByName('entity_2[field_single][und][0][value]', 10, t('Entity 2: field_single value appears correctly is the form.'));
+ $this->assertFieldByName('entity_2[field_unlimited][und][0][value]', 11, t('Entity 2: field_unlimited value 0 appears correctly is the form.'));
+
+ // Submit the form and check that the entities are updated accordingly.
+ $edit = array(
+ 'field_single[und][0][value]' => 1,
+ 'field_unlimited[und][0][value]' => 2,
+ 'field_unlimited[und][1][value]' => 3,
+ 'entity_2[field_single][und][0][value]' => 11,
+ 'entity_2[field_unlimited][und][0][value]' => 12,
+ 'entity_2[field_unlimited][und][1][value]' => 13,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ field_cache_clear();
+ $entity_1 = field_test_create_stub_entity(1);
+ $entity_2 = field_test_create_stub_entity(2);
+ $this->assertFieldValues($entity_1, 'field_single', LANGUAGE_NONE, array(1));
+ $this->assertFieldValues($entity_1, 'field_unlimited', LANGUAGE_NONE, array(2, 3));
+ $this->assertFieldValues($entity_2, 'field_single', LANGUAGE_NONE, array(11));
+ $this->assertFieldValues($entity_2, 'field_unlimited', LANGUAGE_NONE, array(12, 13));
+
+ // Submit invalid values and check that errors are reported on the
+ // correct widgets.
+ $edit = array(
+ 'field_unlimited[und][1][value]' => -1,
+ );
+ $this->drupalPost('test-entity/nested/1/2', $edit, t('Save'));
+ $this->assertRaw(t('%label does not accept the value -1', array('%label' => 'Unlimited field')), t('Entity 1: the field validation error was reported.'));
+ $error_field = $this->xpath('//input[@id=:id and contains(@class, "error")]', array(':id' => 'edit-field-unlimited-und-1-value'));
+ $this->assertTrue($error_field, t('Entity 1: the error was flagged on the correct element.'));
+ $edit = array(
+ 'entity_2[field_unlimited][und][1][value]' => -1,
+ );
+ $this->drupalPost('test-entity/nested/1/2', $edit, t('Save'));
+ $this->assertRaw(t('%label does not accept the value -1', array('%label' => 'Unlimited field')), t('Entity 2: the field validation error was reported.'));
+ $error_field = $this->xpath('//input[@id=:id and contains(@class, "error")]', array(':id' => 'edit-entity-2-field-unlimited-und-1-value'));
+ $this->assertTrue($error_field, t('Entity 2: the error was flagged on the correct element.'));
+
+ // Test that reordering works on both entities.
+ $edit = array(
+ 'field_unlimited[und][0][_weight]' => 0,
+ 'field_unlimited[und][1][_weight]' => -1,
+ 'entity_2[field_unlimited][und][0][_weight]' => 0,
+ 'entity_2[field_unlimited][und][1][_weight]' => -1,
+ );
+ $this->drupalPost('test-entity/nested/1/2', $edit, t('Save'));
+ field_cache_clear();
+ $this->assertFieldValues($entity_1, 'field_unlimited', LANGUAGE_NONE, array(3, 2));
+ $this->assertFieldValues($entity_2, 'field_unlimited', LANGUAGE_NONE, array(13, 12));
+
+ // Test the 'add more' buttons. Only Ajax submission is tested, because
+ // the two 'add more' buttons present in the form have the same #value,
+ // which confuses drupalPost().
+ // 'Add more' button in the first entity:
+ $this->drupalGet('test-entity/nested/1/2');
+ $this->drupalPostAJAX(NULL, array(), 'field_unlimited_add_more');
+ $this->assertFieldByName('field_unlimited[und][0][value]', 3, t('Entity 1: field_unlimited value 0 appears correctly is the form.'));
+ $this->assertFieldByName('field_unlimited[und][1][value]', 2, t('Entity 1: field_unlimited value 1 appears correctly is the form.'));
+ $this->assertFieldByName('field_unlimited[und][2][value]', '', t('Entity 1: field_unlimited value 2 appears correctly is the form.'));
+ $this->assertFieldByName('field_unlimited[und][3][value]', '', t('Entity 1: an empty widget was added for field_unlimited value 3.'));
+ // 'Add more' button in the first entity (changing field values):
+ $edit = array(
+ 'entity_2[field_unlimited][und][0][value]' => 13,
+ 'entity_2[field_unlimited][und][1][value]' => 14,
+ 'entity_2[field_unlimited][und][2][value]' => 15,
+ );
+ $this->drupalPostAJAX(NULL, $edit, 'entity_2_field_unlimited_add_more');
+ $this->assertFieldByName('entity_2[field_unlimited][und][0][value]', 13, t('Entity 2: field_unlimited value 0 appears correctly is the form.'));
+ $this->assertFieldByName('entity_2[field_unlimited][und][1][value]', 14, t('Entity 2: field_unlimited value 1 appears correctly is the form.'));
+ $this->assertFieldByName('entity_2[field_unlimited][und][2][value]', 15, t('Entity 2: field_unlimited value 2 appears correctly is the form.'));
+ $this->assertFieldByName('entity_2[field_unlimited][und][3][value]', '', t('Entity 2: an empty widget was added for field_unlimited value 3.'));
+ // Save the form and check values are saved correclty.
+ $this->drupalPost(NULL, array(), t('Save'));
+ field_cache_clear();
+ $this->assertFieldValues($entity_1, 'field_unlimited', LANGUAGE_NONE, array(3, 2));
+ $this->assertFieldValues($entity_2, 'field_unlimited', LANGUAGE_NONE, array(13, 14, 15));
+ }
+}
+
+class FieldDisplayAPITestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field Display API tests',
+ 'description' => 'Test the display API.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ // Create a field and instance.
+ $this->field_name = 'test_field';
+ $this->label = $this->randomName();
+ $this->cardinality = 4;
+
+ $this->field = array(
+ 'field_name' => $this->field_name,
+ 'type' => 'test_field',
+ 'cardinality' => $this->cardinality,
+ );
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'label' => $this->label,
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'field_test_default',
+ 'settings' => array(
+ 'test_formatter_setting' => $this->randomName(),
+ ),
+ ),
+ 'teaser' => array(
+ 'type' => 'field_test_default',
+ 'settings' => array(
+ 'test_formatter_setting' => $this->randomName(),
+ ),
+ ),
+ ),
+ );
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+
+ // Create an entity with values.
+ $this->values = $this->_generateTestFieldValues($this->cardinality);
+ $this->entity = field_test_create_stub_entity();
+ $this->is_new = TRUE;
+ $this->entity->{$this->field_name}[LANGUAGE_NONE] = $this->values;
+ field_test_entity_save($this->entity);
+ }
+
+ /**
+ * Test the field_view_field() function.
+ */
+ function testFieldViewField() {
+ // No display settings: check that default display settings are used.
+ $output = field_view_field('test_entity', $this->entity, $this->field_name);
+ $this->drupalSetContent(drupal_render($output));
+ $settings = field_info_formatter_settings('field_test_default');
+ $setting = $settings['test_formatter_setting'];
+ $this->assertText($this->label, t('Label was displayed.'));
+ foreach ($this->values as $delta => $value) {
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // Check that explicit display settings are used.
+ $display = array(
+ 'label' => 'hidden',
+ 'type' => 'field_test_multiple',
+ 'settings' => array(
+ 'test_formatter_setting_multiple' => $this->randomName(),
+ 'alter' => TRUE,
+ ),
+ );
+ $output = field_view_field('test_entity', $this->entity, $this->field_name, $display);
+ $this->drupalSetContent(drupal_render($output));
+ $setting = $display['settings']['test_formatter_setting_multiple'];
+ $this->assertNoText($this->label, t('Label was not displayed.'));
+ $this->assertText('field_test_field_attach_view_alter', t('Alter fired, display passed.'));
+ $array = array();
+ foreach ($this->values as $delta => $value) {
+ $array[] = $delta . ':' . $value['value'];
+ }
+ $this->assertText($setting . '|' . implode('|', $array), t('Values were displayed with expected setting.'));
+
+ // Check the prepare_view steps are invoked.
+ $display = array(
+ 'label' => 'hidden',
+ 'type' => 'field_test_with_prepare_view',
+ 'settings' => array(
+ 'test_formatter_setting_additional' => $this->randomName(),
+ ),
+ );
+ $output = field_view_field('test_entity', $this->entity, $this->field_name, $display);
+ $view = drupal_render($output);
+ $this->drupalSetContent($view);
+ $setting = $display['settings']['test_formatter_setting_additional'];
+ $this->assertNoText($this->label, t('Label was not displayed.'));
+ $this->assertNoText('field_test_field_attach_view_alter', t('Alter not fired.'));
+ foreach ($this->values as $delta => $value) {
+ $this->assertText($setting . '|' . $value['value'] . '|' . ($value['value'] + 1), t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // View mode: check that display settings specified in the instance are
+ // used.
+ $output = field_view_field('test_entity', $this->entity, $this->field_name, 'teaser');
+ $this->drupalSetContent(drupal_render($output));
+ $setting = $this->instance['display']['teaser']['settings']['test_formatter_setting'];
+ $this->assertText($this->label, t('Label was displayed.'));
+ foreach ($this->values as $delta => $value) {
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // Unknown view mode: check that display settings for 'default' view mode
+ // are used.
+ $output = field_view_field('test_entity', $this->entity, $this->field_name, 'unknown_view_mode');
+ $this->drupalSetContent(drupal_render($output));
+ $setting = $this->instance['display']['default']['settings']['test_formatter_setting'];
+ $this->assertText($this->label, t('Label was displayed.'));
+ foreach ($this->values as $delta => $value) {
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+ }
+
+ /**
+ * Test the field_view_value() function.
+ */
+ function testFieldViewValue() {
+ // No display settings: check that default display settings are used.
+ $settings = field_info_formatter_settings('field_test_default');
+ $setting = $settings['test_formatter_setting'];
+ foreach ($this->values as $delta => $value) {
+ $item = $this->entity->{$this->field_name}[LANGUAGE_NONE][$delta];
+ $output = field_view_value('test_entity', $this->entity, $this->field_name, $item);
+ $this->drupalSetContent(drupal_render($output));
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // Check that explicit display settings are used.
+ $display = array(
+ 'type' => 'field_test_multiple',
+ 'settings' => array(
+ 'test_formatter_setting_multiple' => $this->randomName(),
+ ),
+ );
+ $setting = $display['settings']['test_formatter_setting_multiple'];
+ $array = array();
+ foreach ($this->values as $delta => $value) {
+ $item = $this->entity->{$this->field_name}[LANGUAGE_NONE][$delta];
+ $output = field_view_value('test_entity', $this->entity, $this->field_name, $item, $display);
+ $this->drupalSetContent(drupal_render($output));
+ $this->assertText($setting . '|0:' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // Check that prepare_view steps are invoked.
+ $display = array(
+ 'type' => 'field_test_with_prepare_view',
+ 'settings' => array(
+ 'test_formatter_setting_additional' => $this->randomName(),
+ ),
+ );
+ $setting = $display['settings']['test_formatter_setting_additional'];
+ $array = array();
+ foreach ($this->values as $delta => $value) {
+ $item = $this->entity->{$this->field_name}[LANGUAGE_NONE][$delta];
+ $output = field_view_value('test_entity', $this->entity, $this->field_name, $item, $display);
+ $this->drupalSetContent(drupal_render($output));
+ $this->assertText($setting . '|' . $value['value'] . '|' . ($value['value'] + 1), t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // View mode: check that display settings specified in the instance are
+ // used.
+ $setting = $this->instance['display']['teaser']['settings']['test_formatter_setting'];
+ foreach ($this->values as $delta => $value) {
+ $item = $this->entity->{$this->field_name}[LANGUAGE_NONE][$delta];
+ $output = field_view_value('test_entity', $this->entity, $this->field_name, $item, 'teaser');
+ $this->drupalSetContent(drupal_render($output));
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+
+ // Unknown view mode: check that display settings for 'default' view mode
+ // are used.
+ $setting = $this->instance['display']['default']['settings']['test_formatter_setting'];
+ foreach ($this->values as $delta => $value) {
+ $item = $this->entity->{$this->field_name}[LANGUAGE_NONE][$delta];
+ $output = field_view_value('test_entity', $this->entity, $this->field_name, $item, 'unknown_view_mode');
+ $this->drupalSetContent(drupal_render($output));
+ $this->assertText($setting . '|' . $value['value'], t('Value @delta was displayed with expected setting.', array('@delta' => $delta)));
+ }
+ }
+}
+
+class FieldCrudTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field CRUD tests',
+ 'description' => 'Test field create, read, update, and delete.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ // field_update_field() tests use number.module
+ parent::setUp('field_test', 'number');
+ }
+
+ // TODO : test creation with
+ // - a full fledged $field structure, check that all the values are there
+ // - a minimal $field structure, check all default values are set
+ // defer actual $field comparison to a helper function, used for the two cases above
+
+ /**
+ * Test the creation of a field.
+ */
+ function testCreateField() {
+ $field_definition = array(
+ 'field_name' => 'field_2',
+ 'type' => 'test_field',
+ );
+ field_test_memorize();
+ $field_definition = field_create_field($field_definition);
+ $mem = field_test_memorize();
+ $this->assertIdentical($mem['field_test_field_create_field'][0][0], $field_definition, 'hook_field_create_field() called with correct arguments.');
+
+ // Read the raw record from the {field_config_instance} table.
+ $result = db_query('SELECT * FROM {field_config} WHERE field_name = :field_name', array(':field_name' => $field_definition['field_name']));
+ $record = $result->fetchAssoc();
+ $record['data'] = unserialize($record['data']);
+
+ // Ensure that basic properties are preserved.
+ $this->assertEqual($record['field_name'], $field_definition['field_name'], t('The field name is properly saved.'));
+ $this->assertEqual($record['type'], $field_definition['type'], t('The field type is properly saved.'));
+
+ // Ensure that cardinality defaults to 1.
+ $this->assertEqual($record['cardinality'], 1, t('Cardinality defaults to 1.'));
+
+ // Ensure that default settings are present.
+ $field_type = field_info_field_types($field_definition['type']);
+ $this->assertIdentical($record['data']['settings'], $field_type['settings'], t('Default field settings have been written.'));
+
+ // Ensure that default storage was set.
+ $this->assertEqual($record['storage_type'], variable_get('field_storage_default'), t('The field type is properly saved.'));
+
+ // Guarantee that the name is unique.
+ try {
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create two fields with the same name.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create two fields with the same name.'));
+ }
+
+ // Check that field type is required.
+ try {
+ $field_definition = array(
+ 'field_name' => 'field_1',
+ );
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create a field with no type.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create a field with no type.'));
+ }
+
+ // Check that field name is required.
+ try {
+ $field_definition = array(
+ 'type' => 'test_field'
+ );
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create an unnamed field.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create an unnamed field.'));
+ }
+
+ // Check that field name must start with a letter or _.
+ try {
+ $field_definition = array(
+ 'field_name' => '2field_2',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create a field with a name starting with a digit.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create a field with a name starting with a digit.'));
+ }
+
+ // Check that field name must only contain lowercase alphanumeric or _.
+ try {
+ $field_definition = array(
+ 'field_name' => 'field#_3',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create a field with a name containing an illegal character.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create a field with a name containing an illegal character.'));
+ }
+
+ // Check that field name cannot be longer than 32 characters long.
+ try {
+ $field_definition = array(
+ 'field_name' => '_12345678901234567890123456789012',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $this->fail(t('Cannot create a field with a name longer than 32 characters.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create a field with a name longer than 32 characters.'));
+ }
+
+ // Check that field name can not be an entity key.
+ // "ftvid" is known as an entity key from the "test_entity" type.
+ try {
+ $field_definition = array(
+ 'type' => 'test_field',
+ 'field_name' => 'ftvid',
+ );
+ $field = field_create_field($field_definition);
+ $this->fail(t('Cannot create a field bearing the name of an entity key.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create a field bearing the name of an entity key.'));
+ }
+ }
+
+ /**
+ * Test failure to create a field.
+ */
+ function testCreateFieldFail() {
+ $field_name = 'duplicate';
+ $field_definition = array('field_name' => $field_name, 'type' => 'test_field', 'storage' => array('type' => 'field_test_storage_failure'));
+ $query = db_select('field_config')->condition('field_name', $field_name)->countQuery();
+
+ // The field does not appear in field_config.
+ $count = $query->execute()->fetchField();
+ $this->assertEqual($count, 0, 'A field_config row for the field does not exist.');
+
+ // Try to create the field.
+ try {
+ $field = field_create_field($field_definition);
+ $this->assertTrue(FALSE, 'Field creation (correctly) fails.');
+ }
+ catch (Exception $e) {
+ $this->assertTrue(TRUE, 'Field creation (correctly) fails.');
+ }
+
+ // The field does not appear in field_config.
+ $count = $query->execute()->fetchField();
+ $this->assertEqual($count, 0, 'A field_config row for the field does not exist.');
+ }
+
+ /**
+ * Test reading back a field definition.
+ */
+ function testReadField() {
+ $field_definition = array(
+ 'field_name' => 'field_1',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+
+ // Read the field back.
+ $field = field_read_field($field_definition['field_name']);
+ $this->assertTrue($field_definition < $field, t('The field was properly read.'));
+ }
+
+ /**
+ * Test creation of indexes on data column.
+ */
+ function testFieldIndexes() {
+ // Check that indexes specified by the field type are used by default.
+ $field_definition = array(
+ 'field_name' => 'field_1',
+ 'type' => 'test_field',
+ );
+ field_create_field($field_definition);
+ $field = field_read_field($field_definition['field_name']);
+ $expected_indexes = array('value' => array('value'));
+ $this->assertEqual($field['indexes'], $expected_indexes, t('Field type indexes saved by default'));
+
+ // Check that indexes specified by the field definition override the field
+ // type indexes.
+ $field_definition = array(
+ 'field_name' => 'field_2',
+ 'type' => 'test_field',
+ 'indexes' => array(
+ 'value' => array(),
+ ),
+ );
+ field_create_field($field_definition);
+ $field = field_read_field($field_definition['field_name']);
+ $expected_indexes = array('value' => array());
+ $this->assertEqual($field['indexes'], $expected_indexes, t('Field definition indexes override field type indexes'));
+
+ // Check that indexes specified by the field definition add to the field
+ // type indexes.
+ $field_definition = array(
+ 'field_name' => 'field_3',
+ 'type' => 'test_field',
+ 'indexes' => array(
+ 'value_2' => array('value'),
+ ),
+ );
+ field_create_field($field_definition);
+ $field = field_read_field($field_definition['field_name']);
+ $expected_indexes = array('value' => array('value'), 'value_2' => array('value'));
+ $this->assertEqual($field['indexes'], $expected_indexes, t('Field definition indexes are merged with field type indexes'));
+ }
+
+ /**
+ * Test the deletion of a field.
+ */
+ function testDeleteField() {
+ // TODO: Also test deletion of the data stored in the field ?
+
+ // Create two fields (so we can test that only one is deleted).
+ $this->field = array('field_name' => 'field_1', 'type' => 'test_field');
+ field_create_field($this->field);
+ $this->another_field = array('field_name' => 'field_2', 'type' => 'test_field');
+ field_create_field($this->another_field);
+
+ // Create instances for each.
+ $this->instance_definition = array(
+ 'field_name' => $this->field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ ),
+ );
+ field_create_instance($this->instance_definition);
+ $this->another_instance_definition = $this->instance_definition;
+ $this->another_instance_definition['field_name'] = $this->another_field['field_name'];
+ field_create_instance($this->another_instance_definition);
+
+ // Test that the first field is not deleted, and then delete it.
+ $field = field_read_field($this->field['field_name'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($field) && empty($field['deleted']), t('A new field is not marked for deletion.'));
+ field_delete_field($this->field['field_name']);
+
+ // Make sure that the field is marked as deleted when it is specifically
+ // loaded.
+ $field = field_read_field($this->field['field_name'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($field['deleted']), t('A deleted field is marked for deletion.'));
+
+ // Make sure that this field's instance is marked as deleted when it is
+ // specifically loaded.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($instance['deleted']), t('An instance for a deleted field is marked for deletion.'));
+
+ // Try to load the field normally and make sure it does not show up.
+ $field = field_read_field($this->field['field_name']);
+ $this->assertTrue(empty($field), t('A deleted field is not loaded by default.'));
+
+ // Try to load the instance normally and make sure it does not show up.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertTrue(empty($instance), t('An instance for a deleted field is not loaded by default.'));
+
+ // Make sure the other field (and its field instance) are not deleted.
+ $another_field = field_read_field($this->another_field['field_name']);
+ $this->assertTrue(!empty($another_field) && empty($another_field['deleted']), t('A non-deleted field is not marked for deletion.'));
+ $another_instance = field_read_instance('test_entity', $this->another_instance_definition['field_name'], $this->another_instance_definition['bundle']);
+ $this->assertTrue(!empty($another_instance) && empty($another_instance['deleted']), t('An instance of a non-deleted field is not marked for deletion.'));
+
+ // Try to create a new field the same name as a deleted field and
+ // write data into it.
+ field_create_field($this->field);
+ field_create_instance($this->instance_definition);
+ $field = field_read_field($this->field['field_name']);
+ $this->assertTrue(!empty($field) && empty($field['deleted']), t('A new field with a previously used name is created.'));
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertTrue(!empty($instance) && empty($instance['deleted']), t('A new instance for a previously used field name is created.'));
+
+ // Save an entity with data for the field
+ $entity = field_test_create_stub_entity(0, 0, $instance['bundle']);
+ $langcode = LANGUAGE_NONE;
+ $values[0]['value'] = mt_rand(1, 127);
+ $entity->{$field['field_name']}[$langcode] = $values;
+ $entity_type = 'test_entity';
+ field_attach_insert('test_entity', $entity);
+
+ // Verify the field is present on load
+ $entity = field_test_create_stub_entity(0, 0, $this->instance_definition['bundle']);
+ field_attach_load($entity_type, array(0 => $entity));
+ $this->assertIdentical(count($entity->{$field['field_name']}[$langcode]), count($values), "Data in previously deleted field saves and loads correctly");
+ foreach ($values as $delta => $value) {
+ $this->assertEqual($entity->{$field['field_name']}[$langcode][$delta]['value'], $values[$delta]['value'], "Data in previously deleted field saves and loads correctly");
+ }
+ }
+
+ function testUpdateNonExistentField() {
+ $test_field = array('field_name' => 'does_not_exist', 'type' => 'number_decimal');
+ try {
+ field_update_field($test_field);
+ $this->fail(t('Cannot update a field that does not exist.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot update a field that does not exist.'));
+ }
+ }
+
+ function testUpdateFieldType() {
+ $field = array('field_name' => 'field_type', 'type' => 'number_decimal');
+ $field = field_create_field($field);
+
+ $test_field = array('field_name' => 'field_type', 'type' => 'number_integer');
+ try {
+ field_update_field($test_field);
+ $this->fail(t('Cannot update a field to a different type.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot update a field to a different type.'));
+ }
+ }
+
+ /**
+ * Test updating a field.
+ */
+ function testUpdateField() {
+ // Create a field with a defined cardinality, so that we can ensure it's
+ // respected. Since cardinality enforcement is consistent across database
+ // systems, it makes a good test case.
+ $cardinality = 4;
+ $field_definition = array(
+ 'field_name' => 'field_update',
+ 'type' => 'test_field',
+ 'cardinality' => $cardinality,
+ );
+ $field_definition = field_create_field($field_definition);
+ $instance = array(
+ 'field_name' => 'field_update',
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ );
+ $instance = field_create_instance($instance);
+
+ do {
+ // We need a unique ID for our entity. $cardinality will do.
+ $id = $cardinality;
+ $entity = field_test_create_stub_entity($id, $id, $instance['bundle']);
+ // Fill in the entity with more values than $cardinality.
+ for ($i = 0; $i < 20; $i++) {
+ $entity->field_update[LANGUAGE_NONE][$i]['value'] = $i;
+ }
+ // Save the entity.
+ field_attach_insert('test_entity', $entity);
+ // Load back and assert there are $cardinality number of values.
+ $entity = field_test_create_stub_entity($id, $id, $instance['bundle']);
+ field_attach_load('test_entity', array($id => $entity));
+ $this->assertEqual(count($entity->field_update[LANGUAGE_NONE]), $field_definition['cardinality'], 'Cardinality is kept');
+ // Now check the values themselves.
+ for ($delta = 0; $delta < $cardinality; $delta++) {
+ $this->assertEqual($entity->field_update[LANGUAGE_NONE][$delta]['value'], $delta, 'Value is kept');
+ }
+ // Increase $cardinality and set the field cardinality to the new value.
+ $field_definition['cardinality'] = ++$cardinality;
+ field_update_field($field_definition);
+ } while ($cardinality < 6);
+ }
+
+ /**
+ * Test field type modules forbidding an update.
+ */
+ function testUpdateFieldForbid() {
+ $field = array('field_name' => 'forbidden', 'type' => 'test_field', 'settings' => array('changeable' => 0, 'unchangeable' => 0));
+ $field = field_create_field($field);
+ $field['settings']['changeable']++;
+ try {
+ field_update_field($field);
+ $this->pass(t("A changeable setting can be updated."));
+ }
+ catch (FieldException $e) {
+ $this->fail(t("An unchangeable setting cannot be updated."));
+ }
+ $field['settings']['unchangeable']++;
+ try {
+ field_update_field($field);
+ $this->fail(t("An unchangeable setting can be updated."));
+ }
+ catch (FieldException $e) {
+ $this->pass(t("An unchangeable setting cannot be updated."));
+ }
+ }
+
+ /**
+ * Test that fields are properly marked active or inactive.
+ */
+ function testActive() {
+ $field_definition = array(
+ 'field_name' => 'field_1',
+ 'type' => 'test_field',
+ // For this test, we need a storage backend provided by a different
+ // module than field_test.module.
+ 'storage' => array(
+ 'type' => 'field_sql_storage',
+ ),
+ );
+ field_create_field($field_definition);
+
+ // Test disabling and enabling:
+ // - the field type module,
+ // - the storage module,
+ // - both.
+ $this->_testActiveHelper($field_definition, array('field_test'));
+ $this->_testActiveHelper($field_definition, array('field_sql_storage'));
+ $this->_testActiveHelper($field_definition, array('field_test', 'field_sql_storage'));
+ }
+
+ /**
+ * Helper function for testActive().
+ *
+ * Test dependency between a field and a set of modules.
+ *
+ * @param $field_definition
+ * A field definition.
+ * @param $modules
+ * An aray of module names. The field will be tested to be inactive as long
+ * as any of those modules is disabled.
+ */
+ function _testActiveHelper($field_definition, $modules) {
+ $field_name = $field_definition['field_name'];
+
+ // Read the field.
+ $field = field_read_field($field_name);
+ $this->assertTrue($field_definition <= $field, t('The field was properly read.'));
+
+ module_disable($modules, FALSE);
+ drupal_flush_all_caches();
+
+ $fields = field_read_fields(array('field_name' => $field_name), array('include_inactive' => TRUE));
+ $this->assertTrue(isset($fields[$field_name]) && $field_definition < $field, t('The field is properly read when explicitly fetching inactive fields.'));
+
+ // Re-enable modules one by one, and check that the field is still inactive
+ // while some modules remain disabled.
+ while ($modules) {
+ $field = field_read_field($field_name);
+ $this->assertTrue(empty($field), t('%modules disabled. The field is marked inactive.', array('%modules' => implode(', ', $modules))));
+
+ $module = array_shift($modules);
+ module_enable(array($module), FALSE);
+ drupal_flush_all_caches();
+ }
+
+ // Check that the field is active again after all modules have been
+ // enabled.
+ $field = field_read_field($field_name);
+ $this->assertTrue($field_definition <= $field, t('The field was was marked active.'));
+ }
+}
+
+class FieldInstanceCrudTestCase extends FieldTestCase {
+ protected $field;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field instance CRUD tests',
+ 'description' => 'Create field entities by attaching fields to entities.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ $this->field = array(
+ 'field_name' => drupal_strtolower($this->randomName()),
+ 'type' => 'test_field',
+ );
+ field_create_field($this->field);
+ $this->instance_definition = array(
+ 'field_name' => $this->field['field_name'],
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ );
+ }
+
+ // TODO : test creation with
+ // - a full fledged $instance structure, check that all the values are there
+ // - a minimal $instance structure, check all default values are set
+ // defer actual $instance comparison to a helper function, used for the two cases above,
+ // and for testUpdateFieldInstance
+
+ /**
+ * Test the creation of a field instance.
+ */
+ function testCreateFieldInstance() {
+ field_create_instance($this->instance_definition);
+
+ // Read the raw record from the {field_config_instance} table.
+ $result = db_query('SELECT * FROM {field_config_instance} WHERE field_name = :field_name AND bundle = :bundle', array(':field_name' => $this->instance_definition['field_name'], ':bundle' => $this->instance_definition['bundle']));
+ $record = $result->fetchAssoc();
+ $record['data'] = unserialize($record['data']);
+
+ $field_type = field_info_field_types($this->field['type']);
+ $widget_type = field_info_widget_types($field_type['default_widget']);
+ $formatter_type = field_info_formatter_types($field_type['default_formatter']);
+
+ // Check that default values are set.
+ $this->assertIdentical($record['data']['required'], FALSE, t('Required defaults to false.'));
+ $this->assertIdentical($record['data']['label'], $this->instance_definition['field_name'], t('Label defaults to field name.'));
+ $this->assertIdentical($record['data']['description'], '', t('Description defaults to empty string.'));
+ $this->assertIdentical($record['data']['widget']['type'], $field_type['default_widget'], t('Default widget has been written.'));
+ $this->assertTrue(isset($record['data']['display']['default']), t('Display for "full" view_mode has been written.'));
+ $this->assertIdentical($record['data']['display']['default']['type'], $field_type['default_formatter'], t('Default formatter for "full" view_mode has been written.'));
+
+ // Check that default settings are set.
+ $this->assertIdentical($record['data']['settings'], $field_type['instance_settings'] , t('Default instance settings have been written.'));
+ $this->assertIdentical($record['data']['widget']['settings'], $widget_type['settings'] , t('Default widget settings have been written.'));
+ $this->assertIdentical($record['data']['display']['default']['settings'], $formatter_type['settings'], t('Default formatter settings for "full" view_mode have been written.'));
+
+ // Guarantee that the field/bundle combination is unique.
+ try {
+ field_create_instance($this->instance_definition);
+ $this->fail(t('Cannot create two instances with the same field / bundle combination.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create two instances with the same field / bundle combination.'));
+ }
+
+ // Check that the specified field exists.
+ try {
+ $this->instance_definition['field_name'] = $this->randomName();
+ field_create_instance($this->instance_definition);
+ $this->fail(t('Cannot create an instance of a non-existing field.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create an instance of a non-existing field.'));
+ }
+
+ // Create a field restricted to a specific entity type.
+ $field_restricted = array(
+ 'field_name' => drupal_strtolower($this->randomName()),
+ 'type' => 'test_field',
+ 'entity_types' => array('test_cacheable_entity'),
+ );
+ field_create_field($field_restricted);
+
+ // Check that an instance can be added to an entity type allowed
+ // by the field.
+ try {
+ $instance = $this->instance_definition;
+ $instance['field_name'] = $field_restricted['field_name'];
+ $instance['entity_type'] = 'test_cacheable_entity';
+ field_create_instance($instance);
+ $this->pass(t('Can create an instance on an entity type allowed by the field.'));
+ }
+ catch (FieldException $e) {
+ $this->fail(t('Can create an instance on an entity type allowed by the field.'));
+ }
+
+ // Check that an instance cannot be added to an entity type
+ // forbidden by the field.
+ try {
+ $instance = $this->instance_definition;
+ $instance['field_name'] = $field_restricted['field_name'];
+ field_create_instance($instance);
+ $this->fail(t('Cannot create an instance on an entity type forbidden by the field.'));
+ }
+ catch (FieldException $e) {
+ $this->pass(t('Cannot create an instance on an entity type forbidden by the field.'));
+ }
+
+ // TODO: test other failures.
+ }
+
+ /**
+ * Test reading back an instance definition.
+ */
+ function testReadFieldInstance() {
+ field_create_instance($this->instance_definition);
+
+ // Read the instance back.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertTrue($this->instance_definition < $instance, t('The field was properly read.'));
+ }
+
+ /**
+ * Test the update of a field instance.
+ */
+ function testUpdateFieldInstance() {
+ field_create_instance($this->instance_definition);
+ $field_type = field_info_field_types($this->field['type']);
+
+ // Check that basic changes are saved.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $instance['required'] = !$instance['required'];
+ $instance['label'] = $this->randomName();
+ $instance['description'] = $this->randomName();
+ $instance['settings']['test_instance_setting'] = $this->randomName();
+ $instance['widget']['settings']['test_widget_setting'] =$this->randomName();
+ $instance['widget']['weight']++;
+ $instance['display']['default']['settings']['test_formatter_setting'] = $this->randomName();
+ $instance['display']['default']['weight']++;
+ field_update_instance($instance);
+
+ $instance_new = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertEqual($instance['required'], $instance_new['required'], t('"required" change is saved'));
+ $this->assertEqual($instance['label'], $instance_new['label'], t('"label" change is saved'));
+ $this->assertEqual($instance['description'], $instance_new['description'], t('"description" change is saved'));
+ $this->assertEqual($instance['widget']['settings']['test_widget_setting'], $instance_new['widget']['settings']['test_widget_setting'], t('Widget setting change is saved'));
+ $this->assertEqual($instance['widget']['weight'], $instance_new['widget']['weight'], t('Widget weight change is saved'));
+ $this->assertEqual($instance['display']['default']['settings']['test_formatter_setting'], $instance_new['display']['default']['settings']['test_formatter_setting'], t('Formatter setting change is saved'));
+ $this->assertEqual($instance['display']['default']['weight'], $instance_new['display']['default']['weight'], t('Widget weight change is saved'));
+
+ // Check that changing widget and formatter types updates the default settings.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $instance['widget']['type'] = 'test_field_widget_multiple';
+ $instance['display']['default']['type'] = 'field_test_multiple';
+ field_update_instance($instance);
+
+ $instance_new = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertEqual($instance['widget']['type'], $instance_new['widget']['type'] , t('Widget type change is saved.'));
+ $settings = field_info_widget_settings($instance_new['widget']['type']);
+ $this->assertIdentical($settings, array_intersect_key($instance_new['widget']['settings'], $settings) , t('Widget type change updates default settings.'));
+ $this->assertEqual($instance['display']['default']['type'], $instance_new['display']['default']['type'] , t('Formatter type change is saved.'));
+ $info = field_info_formatter_types($instance_new['display']['default']['type']);
+ $settings = $info['settings'];
+ $this->assertIdentical($settings, array_intersect_key($instance_new['display']['default']['settings'], $settings) , t('Changing formatter type updates default settings.'));
+
+ // Check that adding a new view mode is saved and gets default settings.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $instance['display']['teaser'] = array();
+ field_update_instance($instance);
+
+ $instance_new = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertTrue(isset($instance_new['display']['teaser']), t('Display for the new view_mode has been written.'));
+ $this->assertIdentical($instance_new['display']['teaser']['type'], $field_type['default_formatter'], t('Default formatter for the new view_mode has been written.'));
+ $info = field_info_formatter_types($instance_new['display']['teaser']['type']);
+ $settings = $info['settings'];
+ $this->assertIdentical($settings, $instance_new['display']['teaser']['settings'] , t('Default formatter settings for the new view_mode have been written.'));
+
+ // TODO: test failures.
+ }
+
+ /**
+ * Test the deletion of a field instance.
+ */
+ function testDeleteFieldInstance() {
+ // TODO: Test deletion of the data stored in the field also.
+ // Need to check that data for a 'deleted' field / instance doesn't get loaded
+ // Need to check data marked deleted is cleaned on cron (not implemented yet...)
+
+ // Create two instances for the same field so we can test that only one
+ // is deleted.
+ field_create_instance($this->instance_definition);
+ $this->another_instance_definition = $this->instance_definition;
+ $this->another_instance_definition['bundle'] .= '_another_bundle';
+ $instance = field_create_instance($this->another_instance_definition);
+
+ // Test that the first instance is not deleted, and then delete it.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($instance) && empty($instance['deleted']), t('A new field instance is not marked for deletion.'));
+ field_delete_instance($instance);
+
+ // Make sure the instance is marked as deleted when the instance is
+ // specifically loaded.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($instance['deleted']), t('A deleted field instance is marked for deletion.'));
+
+ // Try to load the instance normally and make sure it does not show up.
+ $instance = field_read_instance('test_entity', $this->instance_definition['field_name'], $this->instance_definition['bundle']);
+ $this->assertTrue(empty($instance), t('A deleted field instance is not loaded by default.'));
+
+ // Make sure the other field instance is not deleted.
+ $another_instance = field_read_instance('test_entity', $this->another_instance_definition['field_name'], $this->another_instance_definition['bundle']);
+ $this->assertTrue(!empty($another_instance) && empty($another_instance['deleted']), t('A non-deleted field instance is not marked for deletion.'));
+
+ // Make sure the field is deleted when its last instance is deleted.
+ field_delete_instance($another_instance);
+ $field = field_read_field($another_instance['field_name'], array('include_deleted' => TRUE));
+ $this->assertTrue(!empty($field['deleted']), t('A deleted field is marked for deletion after all its instances have been marked for deletion.'));
+ }
+}
+
+/**
+ * Unit test class for the multilanguage fields logic.
+ *
+ * The following tests will check the multilanguage logic of _field_invoke() and
+ * that only the correct values are returned by field_available_languages().
+ */
+class FieldTranslationsTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field translations tests',
+ 'description' => 'Test multilanguage fields logic.',
+ 'group' => 'Field API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'field_test');
+
+ $this->field_name = drupal_strtolower($this->randomName() . '_field_name');
+
+ $this->entity_type = 'test_entity';
+
+ $field = array(
+ 'field_name' => $this->field_name,
+ 'type' => 'test_field',
+ 'cardinality' => 4,
+ 'translatable' => TRUE,
+ );
+ field_create_field($field);
+ $this->field = field_read_field($this->field_name);
+
+ $instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => $this->entity_type,
+ 'bundle' => 'test_bundle',
+ );
+ field_create_instance($instance);
+ $this->instance = field_read_instance('test_entity', $this->field_name, 'test_bundle');
+
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ for ($i = 0; $i < 3; ++$i) {
+ $language = (object) array(
+ 'language' => 'l' . $i,
+ 'name' => $this->randomString(),
+ );
+ locale_language_save($language);
+ }
+ }
+
+ /**
+ * Ensures that only valid values are returned by field_available_languages().
+ */
+ function testFieldAvailableLanguages() {
+ // Test 'translatable' fieldable info.
+ field_test_entity_info_translatable('test_entity', FALSE);
+ $field = $this->field;
+ $field['field_name'] .= '_untranslatable';
+
+ // Enable field translations for the entity.
+ field_test_entity_info_translatable('test_entity', TRUE);
+
+ // Test hook_field_languages() invocation on a translatable field.
+ variable_set('field_test_field_available_languages_alter', TRUE);
+ $enabled_languages = field_content_languages();
+ $available_languages = field_available_languages($this->entity_type, $this->field);
+ foreach ($available_languages as $delta => $langcode) {
+ if ($langcode != 'xx' && $langcode != 'en') {
+ $this->assertTrue(in_array($langcode, $enabled_languages), t('%language is an enabled language.', array('%language' => $langcode)));
+ }
+ }
+ $this->assertTrue(in_array('xx', $available_languages), t('%language was made available.', array('%language' => 'xx')));
+ $this->assertFalse(in_array('en', $available_languages), t('%language was made unavailable.', array('%language' => 'en')));
+
+ // Test field_available_languages() behavior for untranslatable fields.
+ $this->field['translatable'] = FALSE;
+ $this->field_name = $this->field['field_name'] = $this->instance['field_name'] = drupal_strtolower($this->randomName() . '_field_name');
+ $available_languages = field_available_languages($this->entity_type, $this->field);
+ $this->assertTrue(count($available_languages) == 1 && $available_languages[0] === LANGUAGE_NONE, t('For untranslatable fields only LANGUAGE_NONE is available.'));
+ }
+
+ /**
+ * Test the multilanguage logic of _field_invoke().
+ */
+ function testFieldInvoke() {
+ // Enable field translations for the entity.
+ field_test_entity_info_translatable('test_entity', TRUE);
+
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity(0, 0, $this->instance['bundle']);
+
+ // Populate some extra languages to check if _field_invoke() correctly uses
+ // the result of field_available_languages().
+ $values = array();
+ $extra_languages = mt_rand(1, 4);
+ $languages = $available_languages = field_available_languages($this->entity_type, $this->field);
+ for ($i = 0; $i < $extra_languages; ++$i) {
+ $languages[] = $this->randomName(2);
+ }
+
+ // For each given language provide some random values.
+ foreach ($languages as $langcode) {
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ $values[$langcode][$delta]['value'] = mt_rand(1, 127);
+ }
+ }
+ $entity->{$this->field_name} = $values;
+
+ $results = _field_invoke('test_op', $entity_type, $entity);
+ foreach ($results as $langcode => $result) {
+ $hash = hash('sha256', serialize(array($entity_type, $entity, $this->field_name, $langcode, $values[$langcode])));
+ // Check whether the parameters passed to _field_invoke() were correctly
+ // forwarded to the callback function.
+ $this->assertEqual($hash, $result, t('The result for %language is correctly stored.', array('%language' => $langcode)));
+ }
+
+ $this->assertEqual(count($results), count($available_languages), t('No unavailable language has been processed.'));
+ }
+
+ /**
+ * Test the multilanguage logic of _field_invoke_multiple().
+ */
+ function testFieldInvokeMultiple() {
+ // Enable field translations for the entity.
+ field_test_entity_info_translatable('test_entity', TRUE);
+
+ $values = array();
+ $options = array();
+ $entities = array();
+ $entity_type = 'test_entity';
+ $entity_count = mt_rand(2, 5);
+ $available_languages = field_available_languages($this->entity_type, $this->field);
+
+ for ($id = 1; $id <= $entity_count; ++$id) {
+ $entity = field_test_create_stub_entity($id, $id, $this->instance['bundle']);
+ $languages = $available_languages;
+
+ // Populate some extra languages to check whether _field_invoke()
+ // correctly uses the result of field_available_languages().
+ $extra_languages = mt_rand(1, 4);
+ for ($i = 0; $i < $extra_languages; ++$i) {
+ $languages[] = $this->randomName(2);
+ }
+
+ // For each given language provide some random values.
+ $language_count = count($languages);
+ for ($i = 0; $i < $language_count; ++$i) {
+ $langcode = $languages[$i];
+ // Avoid to populate at least one field translation to check that
+ // per-entity language suggestions work even when available field values
+ // are different for each language.
+ if ($i !== $id) {
+ for ($delta = 0; $delta < $this->field['cardinality']; $delta++) {
+ $values[$id][$langcode][$delta]['value'] = mt_rand(1, 127);
+ }
+ }
+ // Ensure that a language for which there is no field translation is
+ // used as display language to prepare per-entity language suggestions.
+ elseif (!isset($display_language)) {
+ $display_language = $langcode;
+ }
+ }
+
+ $entity->{$this->field_name} = $values[$id];
+ $entities[$id] = $entity;
+
+ // Store per-entity language suggestions.
+ $options['language'][$id] = field_language($entity_type, $entity, NULL, $display_language);
+ }
+
+ $grouped_results = _field_invoke_multiple('test_op_multiple', $entity_type, $entities);
+ foreach ($grouped_results as $id => $results) {
+ foreach ($results as $langcode => $result) {
+ if (isset($values[$id][$langcode])) {
+ $hash = hash('sha256', serialize(array($entity_type, $entities[$id], $this->field_name, $langcode, $values[$id][$langcode])));
+ // Check whether the parameters passed to _field_invoke_multiple()
+ // were correctly forwarded to the callback function.
+ $this->assertEqual($hash, $result, t('The result for entity %id/%language is correctly stored.', array('%id' => $id, '%language' => $langcode)));
+ }
+ }
+ $this->assertEqual(count($results), count($available_languages), t('No unavailable language has been processed for entity %id.', array('%id' => $id)));
+ }
+
+ $null = NULL;
+ $grouped_results = _field_invoke_multiple('test_op_multiple', $entity_type, $entities, $null, $null, $options);
+ foreach ($grouped_results as $id => $results) {
+ foreach ($results as $langcode => $result) {
+ $this->assertTrue(isset($options['language'][$id]), t('The result language %language for entity %id was correctly suggested (display language: %display_language).', array('%id' => $id, '%language' => $langcode, '%display_language' => $display_language)));
+ }
+ }
+ }
+
+ /**
+ * Test translatable fields storage/retrieval.
+ */
+ function testTranslatableFieldSaveLoad() {
+ // Enable field translations for nodes.
+ field_test_entity_info_translatable('node', TRUE);
+ $entity_info = entity_get_info('node');
+ $this->assertTrue(count($entity_info['translation']), t('Nodes are translatable.'));
+
+ // Prepare the field translations.
+ field_test_entity_info_translatable('test_entity', TRUE);
+ $eid = $evid = 1;
+ $entity_type = 'test_entity';
+ $entity = field_test_create_stub_entity($eid, $evid, $this->instance['bundle']);
+ $field_translations = array();
+ $available_languages = field_available_languages($entity_type, $this->field);
+ $this->assertTrue(count($available_languages) > 1, t('Field is translatable.'));
+ foreach ($available_languages as $langcode) {
+ $field_translations[$langcode] = $this->_generateTestFieldValues($this->field['cardinality']);
+ }
+
+ // Save and reload the field translations.
+ $entity->{$this->field_name} = $field_translations;
+ field_attach_insert($entity_type, $entity);
+ unset($entity->{$this->field_name});
+ field_attach_load($entity_type, array($eid => $entity));
+
+ // Check if the correct values were saved/loaded.
+ foreach ($field_translations as $langcode => $items) {
+ $result = TRUE;
+ foreach ($items as $delta => $item) {
+ $result = $result && $item['value'] == $entity->{$this->field_name}[$langcode][$delta]['value'];
+ }
+ $this->assertTrue($result, t('%language translation correctly handled.', array('%language' => $langcode)));
+ }
+ }
+
+ /**
+ * Tests display language logic for translatable fields.
+ */
+ function testFieldDisplayLanguage() {
+ $field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $entity_type = 'test_entity';
+
+ // We need an additional field here to properly test display language
+ // suggestions.
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'test_field',
+ 'cardinality' => 2,
+ 'translatable' => TRUE,
+ );
+ field_create_field($field);
+
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => $entity_type,
+ 'bundle' => 'test_bundle',
+ );
+ field_create_instance($instance);
+
+ $entity = field_test_create_stub_entity(1, 1, $this->instance['bundle']);
+ $instances = field_info_instances($entity_type, $this->instance['bundle']);
+
+ $enabled_languages = field_content_languages();
+ $languages = array();
+
+ // Generate field translations for languages different from the first
+ // enabled.
+ foreach ($instances as $instance) {
+ $field_name = $instance['field_name'];
+ $field = field_info_field($field_name);
+ do {
+ // Index 0 is reserved for the requested language, this way we ensure
+ // that no field is actually populated with it.
+ $langcode = $enabled_languages[mt_rand(1, count($enabled_languages) - 1)];
+ }
+ while (isset($languages[$langcode]));
+ $languages[$langcode] = TRUE;
+ $entity->{$field_name}[$langcode] = $this->_generateTestFieldValues($field['cardinality']);
+ }
+
+ // Test multiple-fields display languages for untranslatable entities.
+ field_test_entity_info_translatable($entity_type, FALSE);
+ drupal_static_reset('field_language');
+ $requested_language = $enabled_languages[0];
+ $display_language = field_language($entity_type, $entity, NULL, $requested_language);
+ foreach ($instances as $instance) {
+ $field_name = $instance['field_name'];
+ $this->assertTrue($display_language[$field_name] == LANGUAGE_NONE, t('The display language for field %field_name is %language.', array('%field_name' => $field_name, '%language' => LANGUAGE_NONE)));
+ }
+
+ // Test multiple-fields display languages for translatable entities.
+ field_test_entity_info_translatable($entity_type, TRUE);
+ drupal_static_reset('field_language');
+ $display_language = field_language($entity_type, $entity, NULL, $requested_language);
+
+ foreach ($instances as $instance) {
+ $field_name = $instance['field_name'];
+ $langcode = $display_language[$field_name];
+ // As the requested language was not assinged to any field, if the
+ // returned language is defined for the current field, core fallback rules
+ // were successfully applied.
+ $this->assertTrue(isset($entity->{$field_name}[$langcode]) && $langcode != $requested_language, t('The display language for the field %field_name is %language.', array('%field_name' => $field_name, '%language' => $langcode)));
+ }
+
+ // Test single-field display language.
+ drupal_static_reset('field_language');
+ $langcode = field_language($entity_type, $entity, $this->field_name, $requested_language);
+ $this->assertTrue(isset($entity->{$this->field_name}[$langcode]) && $langcode != $requested_language, t('The display language for the (single) field %field_name is %language.', array('%field_name' => $field_name, '%language' => $langcode)));
+
+ // Test field_language() basic behavior without language fallback.
+ variable_set('field_test_language_fallback', FALSE);
+ $entity->{$this->field_name}[$requested_language] = mt_rand(1, 127);
+ drupal_static_reset('field_language');
+ $display_language = field_language($entity_type, $entity, $this->field_name, $requested_language);
+ $this->assertEqual($display_language, $requested_language, t('Display language behave correctly when language fallback is disabled'));
+ }
+
+ /**
+ * Tests field translations when creating a new revision.
+ */
+ function testFieldFormTranslationRevisions() {
+ $web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content'));
+ $this->drupalLogin($web_user);
+
+ // Prepare the field translations.
+ field_test_entity_info_translatable($this->entity_type, TRUE);
+ $eid = 1;
+ $entity = field_test_create_stub_entity($eid, $eid, $this->instance['bundle']);
+ $available_languages = array_flip(field_available_languages($this->entity_type, $this->field));
+ unset($available_languages[LANGUAGE_NONE]);
+ $field_name = $this->field['field_name'];
+
+ // Store the field translations.
+ $entity->is_new = TRUE;
+ foreach ($available_languages as $langcode => $value) {
+ $entity->{$field_name}[$langcode][0]['value'] = $value + 1;
+ }
+ field_test_entity_save($entity);
+
+ // Create a new revision.
+ $langcode = field_valid_language(NULL);
+ $edit = array("{$field_name}[$langcode][0][value]" => $entity->{$field_name}[$langcode][0]['value'], 'revision' => TRUE);
+ $this->drupalPost('test-entity/manage/' . $eid . '/edit', $edit, t('Save'));
+
+ // Check translation revisions.
+ $this->checkTranslationRevisions($eid, $eid, $available_languages);
+ $this->checkTranslationRevisions($eid, $eid + 1, $available_languages);
+ }
+
+ /**
+ * Check if the field translation attached to the entity revision identified
+ * by the passed arguments were correctly stored.
+ */
+ private function checkTranslationRevisions($eid, $evid, $available_languages) {
+ $field_name = $this->field['field_name'];
+ $entity = field_test_entity_test_load($eid, $evid);
+ foreach ($available_languages as $langcode => $value) {
+ $passed = isset($entity->{$field_name}[$langcode]) && $entity->{$field_name}[$langcode][0]['value'] == $value + 1;
+ $this->assertTrue($passed, t('The @language translation for revision @revision was correctly stored', array('@language' => $langcode, '@revision' => $entity->ftvid)));
+ }
+ }
+}
+
+/**
+ * Unit test class for field bulk delete and batch purge functionality.
+ */
+class FieldBulkDeleteTestCase extends FieldTestCase {
+ protected $field;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Field bulk delete tests',
+ 'description' => 'Bulk delete fields and instances, and clean up afterwards.',
+ 'group' => 'Field API',
+ );
+ }
+
+ /**
+ * Convenience function for Field API tests.
+ *
+ * Given an array of potentially fully-populated entities and an
+ * optional field name, generate an array of stub entities of the
+ * same fieldable type which contains the data for the field name
+ * (if given).
+ *
+ * @param $entity_type
+ * The entity type of $entities.
+ * @param $entities
+ * An array of entities of type $entity_type.
+ * @param $field_name
+ * Optional; a field name whose data should be copied from
+ * $entities into the returned stub entities.
+ * @return
+ * An array of stub entities corresponding to $entities.
+ */
+ function _generateStubEntities($entity_type, $entities, $field_name = NULL) {
+ $stubs = array();
+ foreach ($entities as $entity) {
+ $stub = entity_create_stub_entity($entity_type, entity_extract_ids($entity_type, $entity));
+ if (isset($field_name)) {
+ $stub->{$field_name} = $entity->{$field_name};
+ }
+ $stubs[] = $stub;
+ }
+ return $stubs;
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ // Clean up data from previous test cases.
+ $this->fields = array();
+ $this->instances = array();
+
+ // Create two bundles.
+ $this->bundles = array('bb_1' => 'bb_1', 'bb_2' => 'bb_2');
+ foreach ($this->bundles as $name => $desc) {
+ field_test_create_bundle($name, $desc);
+ }
+
+ // Create two fields.
+ $field = array('field_name' => 'bf_1', 'type' => 'test_field', 'cardinality' => 1);
+ $this->fields[] = field_create_field($field);
+ $field = array('field_name' => 'bf_2', 'type' => 'test_field', 'cardinality' => 4);
+ $this->fields[] = field_create_field($field);
+
+ // For each bundle, create an instance of each field, and 10
+ // entities with values for each field.
+ $id = 0;
+ $this->entity_type = 'test_entity';
+ foreach ($this->bundles as $bundle) {
+ foreach ($this->fields as $field) {
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => $this->entity_type,
+ 'bundle' => $bundle,
+ 'widget' => array(
+ 'type' => 'test_field_widget',
+ )
+ );
+ $this->instances[] = field_create_instance($instance);
+ }
+
+ for ($i = 0; $i < 10; $i++) {
+ $entity = field_test_create_stub_entity($id, $id, $bundle);
+ foreach ($this->fields as $field) {
+ $entity->{$field['field_name']}[LANGUAGE_NONE] = $this->_generateTestFieldValues($field['cardinality']);
+ }
+ $this->entities[$id] = $entity;
+ field_attach_insert($this->entity_type, $entity);
+ $id++;
+ }
+ }
+ }
+
+ /**
+ * Verify that deleting an instance leaves the field data items in
+ * the database and that the appropriate Field API functions can
+ * operate on the deleted data and instance.
+ *
+ * This tests how EntityFieldQuery interacts with
+ * field_delete_instance() and could be moved to FieldCrudTestCase,
+ * but depends on this class's setUp().
+ */
+ function testDeleteFieldInstance() {
+ $bundle = reset($this->bundles);
+ $field = reset($this->fields);
+
+ // There are 10 entities of this bundle.
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($field)
+ ->entityCondition('bundle', $bundle)
+ ->execute();
+ $this->assertEqual(count($found['test_entity']), 10, 'Correct number of entities found before deleting');
+
+ // Delete the instance.
+ $instance = field_info_instance($this->entity_type, $field['field_name'], $bundle);
+ field_delete_instance($instance);
+
+ // The instance still exists, deleted.
+ $instances = field_read_instances(array('field_id' => $field['id'], 'deleted' => 1), array('include_deleted' => 1, 'include_inactive' => 1));
+ $this->assertEqual(count($instances), 1, 'There is one deleted instance');
+ $this->assertEqual($instances[0]['bundle'], $bundle, 'The deleted instance is for the correct bundle');
+
+ // There are 0 entities of this bundle with non-deleted data.
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($field)
+ ->entityCondition('bundle', $bundle)
+ ->execute();
+ $this->assertTrue(!isset($found['test_entity']), 'No entities found after deleting');
+
+ // There are 10 entities of this bundle when deleted fields are allowed, and
+ // their values are correct.
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($field)
+ ->entityCondition('bundle', $bundle)
+ ->deleted(TRUE)
+ ->execute();
+ field_attach_load($this->entity_type, $found[$this->entity_type], FIELD_LOAD_CURRENT, array('field_id' => $field['id'], 'deleted' => 1));
+ $this->assertEqual(count($found['test_entity']), 10, 'Correct number of entities found after deleting');
+ foreach ($found['test_entity'] as $id => $entity) {
+ $this->assertEqual($this->entities[$id]->{$field['field_name']}, $entity->{$field['field_name']}, "Entity $id with deleted data loaded correctly");
+ }
+ }
+
+ /**
+ * Verify that field data items and instances are purged when an
+ * instance is deleted.
+ */
+ function testPurgeInstance() {
+ field_test_memorize();
+
+ $bundle = reset($this->bundles);
+ $field = reset($this->fields);
+
+ // Delete the instance.
+ $instance = field_info_instance($this->entity_type, $field['field_name'], $bundle);
+ field_delete_instance($instance);
+
+ // No field hooks were called.
+ $mem = field_test_memorize();
+ $this->assertEqual(count($mem), 0, 'No field hooks were called');
+
+ $batch_size = 2;
+ for ($count = 8; $count >= 0; $count -= 2) {
+ // Purge two entities.
+ field_purge_batch($batch_size);
+
+ // There are $count deleted entities left.
+ $query = new EntityFieldQuery();
+ $found = $query
+ ->fieldCondition($field)
+ ->entityCondition('bundle', $bundle)
+ ->deleted(TRUE)
+ ->execute();
+ $this->assertEqual($count ? count($found['test_entity']) : count($found), $count, 'Correct number of entities found after purging 2');
+ }
+
+ // hook_field_delete() was called on a pseudo-entity for each entity. Each
+ // pseudo entity has a $field property that matches the original entity,
+ // but no others.
+ $mem = field_test_memorize();
+ $this->assertEqual(count($mem['field_test_field_delete']), 10, 'hook_field_delete was called for the right number of entities');
+ $stubs = $this->_generateStubEntities($this->entity_type, $this->entities, $field['field_name']);
+ $count = count($stubs);
+ foreach ($mem['field_test_field_delete'] as $args) {
+ $entity = $args[1];
+ $this->assertEqual($stubs[$entity->ftid], $entity, 'hook_field_delete() called with the correct stub');
+ unset($stubs[$entity->ftid]);
+ }
+ $this->assertEqual(count($stubs), $count-10, 'hook_field_delete was called with each entity once');
+
+ // The instance still exists, deleted.
+ $instances = field_read_instances(array('field_id' => $field['id'], 'deleted' => 1), array('include_deleted' => 1, 'include_inactive' => 1));
+ $this->assertEqual(count($instances), 1, 'There is one deleted instance');
+
+ // Purge the instance.
+ field_purge_batch($batch_size);
+
+ // The instance is gone.
+ $instances = field_read_instances(array('field_id' => $field['id'], 'deleted' => 1), array('include_deleted' => 1, 'include_inactive' => 1));
+ $this->assertEqual(count($instances), 0, 'The instance is gone');
+
+ // The field still exists, not deleted, because it has a second instance.
+ $fields = field_read_fields(array('id' => $field['id']), array('include_deleted' => 1, 'include_inactive' => 1));
+ $this->assertTrue(isset($fields[$field['id']]), 'The field exists and is not deleted');
+ }
+
+ /**
+ * Verify that fields are preserved and purged correctly as multiple
+ * instances are deleted and purged.
+ */
+ function testPurgeField() {
+ $field = reset($this->fields);
+
+ // Delete the first instance.
+ $instance = field_info_instance($this->entity_type, $field['field_name'], 'bb_1');
+ field_delete_instance($instance);
+
+ // Purge the data.
+ field_purge_batch(10);
+
+ // Purge again to purge the instance.
+ field_purge_batch(0);
+
+ // The field still exists, not deleted.
+ $fields = field_read_fields(array('id' => $field['id']), array('include_deleted' => 1));
+ $this->assertTrue(isset($fields[$field['id']]) && !$fields[$field['id']]['deleted'], 'The field exists and is not deleted');
+
+ // Delete the second instance.
+ $instance = field_info_instance($this->entity_type, $field['field_name'], 'bb_2');
+ field_delete_instance($instance);
+
+ // Purge the data.
+ field_purge_batch(10);
+
+ // The field still exists, deleted.
+ $fields = field_read_fields(array('id' => $field['id']), array('include_deleted' => 1));
+ $this->assertTrue(isset($fields[$field['id']]) && $fields[$field['id']]['deleted'], 'The field exists and is deleted');
+
+ // Purge again to purge the instance and the field.
+ field_purge_batch(0);
+
+ // The field is gone.
+ $fields = field_read_fields(array('id' => $field['id']), array('include_deleted' => 1, 'include_inactive' => 1));
+ $this->assertEqual(count($fields), 0, 'The field is purged.');
+ }
+}
+
+/**
+ * Tests entity properties.
+ */
+class EntityPropertiesTestCase extends FieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Entity properties',
+ 'description' => 'Tests entity properties.',
+ 'group' => 'Entity API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+ }
+
+ /**
+ * Tests label key and label callback of an entity.
+ */
+ function testEntityLabel() {
+ $entity_types = array(
+ 'test_entity_no_label',
+ 'test_entity_label',
+ 'test_entity_label_callback',
+ );
+
+ $entity = field_test_create_stub_entity();
+
+ foreach ($entity_types as $entity_type) {
+ $label = entity_label($entity_type, $entity);
+
+ switch ($entity_type) {
+ case 'test_entity_no_label':
+ $this->assertFalse($label, 'Entity with no label property or callback returned FALSE.');
+ break;
+
+ case 'test_entity_label':
+ $this->assertEqual($label, $entity->ftlabel, 'Entity with label key returned correct label.');
+ break;
+
+ case 'test_entity_label_callback':
+ $this->assertEqual($label, 'label callback ' . $entity->ftlabel, 'Entity with label callback returned correct label.');
+ break;
+ }
+ }
+ }
+}
diff --git a/core/modules/field/tests/field_test.entity.inc b/core/modules/field/tests/field_test.entity.inc
new file mode 100644
index 000000000000..b7c70a677115
--- /dev/null
+++ b/core/modules/field/tests/field_test.entity.inc
@@ -0,0 +1,494 @@
+<?php
+
+/**
+ * @file
+ * Defines an entity type.
+ */
+
+/**
+ * Implements hook_entity_info().
+ */
+function field_test_entity_info() {
+ $bundles = variable_get('field_test_bundles', array('test_bundle' => array('label' => 'Test Bundle')));
+ $test_entity_modes = array(
+ 'full' => array(
+ 'label' => t('Full object'),
+ 'custom settings' => TRUE,
+ ),
+ 'teaser' => array(
+ 'label' => t('Teaser'),
+ 'custom settings' => TRUE,
+ ),
+ );
+
+ return array(
+ 'test_entity' => array(
+ 'name' => t('Test Entity'),
+ 'fieldable' => TRUE,
+ 'field cache' => FALSE,
+ 'base table' => 'test_entity',
+ 'revision table' => 'test_entity_revision',
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'revision' => 'ftvid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => $bundles,
+ 'view modes' => $test_entity_modes,
+ ),
+ // This entity type doesn't get form handling for now...
+ 'test_cacheable_entity' => array(
+ 'name' => t('Test Entity, cacheable'),
+ 'fieldable' => TRUE,
+ 'field cache' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'revision' => 'ftvid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => $bundles,
+ 'view modes' => $test_entity_modes,
+ ),
+ 'test_entity_bundle_key' => array(
+ 'name' => t('Test Entity with a bundle key.'),
+ 'base table' => 'test_entity_bundle_key',
+ 'fieldable' => TRUE,
+ 'field cache' => FALSE,
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => array('bundle1' => array('label' => 'Bundle1'), 'bundle2' => array('label' => 'Bundle2')),
+ 'view modes' => $test_entity_modes,
+ ),
+ // In this case, the bundle key is not stored in the database.
+ 'test_entity_bundle' => array(
+ 'name' => t('Test Entity with a specified bundle.'),
+ 'base table' => 'test_entity_bundle',
+ 'fieldable' => TRUE,
+ 'controller class' => 'TestEntityBundleController',
+ 'field cache' => FALSE,
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => array('test_entity_2' => array('label' => 'Test entity 2')),
+ 'view modes' => $test_entity_modes,
+ ),
+ // @see EntityPropertiesTestCase::testEntityLabel()
+ 'test_entity_no_label' => array(
+ 'name' => t('Test entity without label'),
+ 'fieldable' => TRUE,
+ 'field cache' => FALSE,
+ 'base table' => 'test_entity',
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'revision' => 'ftvid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => $bundles,
+ 'view modes' => $test_entity_modes,
+ ),
+ 'test_entity_label' => array(
+ 'name' => t('Test entity label'),
+ 'fieldable' => TRUE,
+ 'field cache' => FALSE,
+ 'base table' => 'test_entity',
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'revision' => 'ftvid',
+ 'bundle' => 'fttype',
+ 'label' => 'ftlabel',
+ ),
+ 'bundles' => $bundles,
+ 'view modes' => $test_entity_modes,
+ ),
+ 'test_entity_label_callback' => array(
+ 'name' => t('Test entity label callback'),
+ 'fieldable' => TRUE,
+ 'field cache' => FALSE,
+ 'base table' => 'test_entity',
+ 'label callback' => 'field_test_entity_label_callback',
+ 'entity keys' => array(
+ 'id' => 'ftid',
+ 'revision' => 'ftvid',
+ 'bundle' => 'fttype',
+ ),
+ 'bundles' => $bundles,
+ 'view modes' => $test_entity_modes,
+ ),
+ );
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ */
+function field_test_entity_info_alter(&$entity_info) {
+ // Enable/disable field_test as a translation handler.
+ foreach (field_test_entity_info_translatable() as $entity_type => $translatable) {
+ $entity_info[$entity_type]['translation']['field_test'] = $translatable;
+ }
+ // Disable locale as a translation handler.
+ foreach ($entity_info as $entity_type => $info) {
+ $entity_info[$entity_type]['translation']['locale'] = FALSE;
+ }
+}
+
+/**
+ * Helper function to enable entity translations.
+ */
+function field_test_entity_info_translatable($entity_type = NULL, $translatable = NULL) {
+ drupal_static_reset('field_has_translation_handler');
+ $stored_value = &drupal_static(__FUNCTION__, array());
+ if (isset($entity_type)) {
+ $stored_value[$entity_type] = $translatable;
+ entity_info_cache_clear();
+ }
+ return $stored_value;
+}
+
+/**
+ * Creates a new bundle for test_entity entities.
+ *
+ * @param $bundle
+ * The machine-readable name of the bundle.
+ * @param $text
+ * The human-readable name of the bundle. If none is provided, the machine
+ * name will be used.
+ */
+function field_test_create_bundle($bundle, $text = NULL) {
+ $bundles = variable_get('field_test_bundles', array('test_bundle' => array('label' => 'Test Bundle')));
+ $bundles += array($bundle => array('label' => $text ? $text : $bundle));
+ variable_set('field_test_bundles', $bundles);
+
+ $info = field_test_entity_info();
+ foreach ($info as $type => $type_info) {
+ field_attach_create_bundle($type, $bundle);
+ }
+}
+
+/**
+ * Renames a bundle for test_entity entities.
+ *
+ * @param $bundle_old
+ * The machine-readable name of the bundle to rename.
+ * @param $bundle_new
+ * The new machine-readable name of the bundle.
+ */
+function field_test_rename_bundle($bundle_old, $bundle_new) {
+ $bundles = variable_get('field_test_bundles', array('test_bundle' => array('label' => 'Test Bundle')));
+ $bundles[$bundle_new] = $bundles[$bundle_old];
+ unset($bundles[$bundle_old]);
+ variable_set('field_test_bundles', $bundles);
+
+ $info = field_test_entity_info();
+ foreach ($info as $type => $type_info) {
+ field_attach_rename_bundle($type, $bundle_old, $bundle_new);
+ }
+}
+
+/**
+ * Deletes a bundle for test_entity objects.
+ *
+ * @param $bundle
+ * The machine-readable name of the bundle to delete.
+ */
+function field_test_delete_bundle($bundle) {
+ $bundles = variable_get('field_test_bundles', array('test_bundle' => array('label' => 'Test Bundle')));
+ unset($bundles[$bundle]);
+ variable_set('field_test_bundles', $bundles);
+
+ $info = field_test_entity_info();
+ foreach ($info as $type => $type_info) {
+ field_attach_delete_bundle($type, $bundle);
+ }
+}
+
+/**
+ * Creates a basic test_entity entity.
+ */
+function field_test_create_stub_entity($id = 1, $vid = 1, $bundle = 'test_bundle', $label = '') {
+ $entity = new stdClass();
+ // Only set id and vid properties if they don't come as NULL (creation form).
+ if (isset($id)) {
+ $entity->ftid = $id;
+ }
+ if (isset($vid)) {
+ $entity->ftvid = $vid;
+ }
+ $entity->fttype = $bundle;
+
+ $label = !empty($label) ? $label : $bundle . ' label';
+ $entity->ftlabel = $label;
+
+ return $entity;
+}
+
+/**
+ * Loads a test_entity.
+ *
+ * @param $ftid
+ * The id of the entity to load.
+ * @param $ftvid
+ * (Optional) The revision id of the entity to load. If not specified, the
+ * current revision will be used.
+ * @return
+ * The loaded entity.
+ */
+function field_test_entity_test_load($ftid, $ftvid = NULL) {
+ // Load basic strucure.
+ $query = db_select('test_entity', 'fte', array())
+ ->condition('fte.ftid', $ftid);
+
+ if ($ftvid) {
+ $query->join('test_entity_revision', 'fter', 'fte.ftid = fter.ftid');
+ $query->addField('fte', 'ftid');
+ $query->addField('fte', 'fttype');
+ $query->addField('fter', 'ftvid');
+ $query->condition('fter.ftvid', $ftvid);
+ }
+ else {
+ $query->fields('fte');
+ }
+
+ $entities = $query->execute()->fetchAllAssoc('ftid');
+
+ // Attach fields.
+ if ($ftvid) {
+ field_attach_load_revision('test_entity', $entities);
+ }
+ else {
+ field_attach_load('test_entity', $entities);
+ }
+
+ return $entities[$ftid];
+}
+
+/**
+ * Saves a test_entity.
+ *
+ * A new entity is created if $entity->ftid and $entity->is_new are both empty.
+ * A new revision is created if $entity->revision is not empty.
+ *
+ * @param $entity
+ * The entity to save.
+ */
+function field_test_entity_save(&$entity) {
+ field_attach_presave('test_entity', $entity);
+
+ if (!isset($entity->is_new)) {
+ $entity->is_new = empty($entity->ftid);
+ }
+
+ if (!$entity->is_new && !empty($entity->revision)) {
+ $entity->old_ftvid = $entity->ftvid;
+ unset($entity->ftvid);
+ }
+
+ $update_entity = TRUE;
+ if ($entity->is_new) {
+ drupal_write_record('test_entity', $entity);
+ drupal_write_record('test_entity_revision', $entity);
+ $op = 'insert';
+ }
+ else {
+ drupal_write_record('test_entity', $entity, 'ftid');
+ if (!empty($entity->revision)) {
+ drupal_write_record('test_entity_revision', $entity);
+ }
+ else {
+ drupal_write_record('test_entity_revision', $entity, 'ftvid');
+ $update_entity = FALSE;
+ }
+ $op = 'update';
+ }
+ if ($update_entity) {
+ db_update('test_entity')
+ ->fields(array('ftvid' => $entity->ftvid))
+ ->condition('ftid', $entity->ftid)
+ ->execute();
+ }
+
+ // Save fields.
+ $function = "field_attach_$op";
+ $function('test_entity', $entity);
+}
+
+/**
+ * Menu callback: displays the 'Add new test_entity' form.
+ */
+function field_test_entity_add($fttype) {
+ $fttype = str_replace('-', '_', $fttype);
+ $entity = (object)array('fttype' => $fttype);
+ drupal_set_title(t('Create test_entity @bundle', array('@bundle' => $fttype)), PASS_THROUGH);
+ return drupal_get_form('field_test_entity_form', $entity, TRUE);
+}
+
+/**
+ * Menu callback: displays the 'Edit exiisting test_entity' form.
+ */
+function field_test_entity_edit($entity) {
+ drupal_set_title(t('test_entity @ftid revision @ftvid', array('@ftid' => $entity->ftid, '@ftvid' => $entity->ftvid)), PASS_THROUGH);
+ return drupal_get_form('field_test_entity_form', $entity);
+}
+
+/**
+ * Test_entity form.
+ */
+function field_test_entity_form($form, &$form_state, $entity, $add = FALSE) {
+ // During initial form build, add the entity to the form state for use during
+ // form building and processing. During a rebuild, use what is in the form
+ // state.
+ if (!isset($form_state['test_entity'])) {
+ $form_state['test_entity'] = $entity;
+ }
+ else {
+ $entity = $form_state['test_entity'];
+ }
+
+ foreach (array('ftid', 'ftvid', 'fttype') as $key) {
+ $form[$key] = array(
+ '#type' => 'value',
+ '#value' => isset($entity->$key) ? $entity->$key : NULL,
+ );
+ }
+
+ // Add field widgets.
+ field_attach_form('test_entity', $entity, $form, $form_state);
+
+ if (!$add) {
+ $form['revision'] = array(
+ '#access' => user_access('administer field_test content'),
+ '#type' => 'checkbox',
+ '#title' => t('Create new revision'),
+ '#default_value' => FALSE,
+ '#weight' => 100,
+ );
+ }
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#weight' => 101,
+ );
+
+ return $form;
+}
+
+/**
+ * Validate handler for field_test_entity_form().
+ */
+function field_test_entity_form_validate($form, &$form_state) {
+ entity_form_field_validate('test_entity', $form, $form_state);
+}
+
+/**
+ * Submit handler for field_test_entity_form().
+ */
+function field_test_entity_form_submit($form, &$form_state) {
+ $entity = field_test_entity_form_submit_build_test_entity($form, $form_state);
+ $insert = empty($entity->ftid);
+ field_test_entity_save($entity);
+
+ $message = $insert ? t('test_entity @id has been created.', array('@id' => $entity->ftid)) : t('test_entity @id has been updated.', array('@id' => $entity->ftid));
+ drupal_set_message($message);
+
+ if ($entity->ftid) {
+ $form_state['redirect'] = 'test-entity/manage/' . $entity->ftid . '/edit';
+ }
+ else {
+ // Error on save.
+ drupal_set_message(t('The entity could not be saved.'), 'error');
+ $form_state['rebuild'] = TRUE;
+ }
+}
+
+/**
+ * Updates the form state's entity by processing this submission's values.
+ */
+function field_test_entity_form_submit_build_test_entity($form, &$form_state) {
+ $entity = $form_state['test_entity'];
+ entity_form_submit_build_entity('test_entity', $entity, $form, $form_state);
+ return $entity;
+}
+
+/**
+ * Form combining two separate entities.
+ */
+function field_test_entity_nested_form($form, &$form_state, $entity_1, $entity_2) {
+ // First entity.
+ foreach (array('ftid', 'ftvid', 'fttype') as $key) {
+ $form[$key] = array(
+ '#type' => 'value',
+ '#value' => $entity_1->$key,
+ );
+ }
+ field_attach_form('test_entity', $entity_1, $form, $form_state);
+
+ // Second entity.
+ $form['entity_2'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Second entity'),
+ '#tree' => TRUE,
+ '#parents' => array('entity_2'),
+ '#weight' => 50,
+ );
+ foreach (array('ftid', 'ftvid', 'fttype') as $key) {
+ $form['entity_2'][$key] = array(
+ '#type' => 'value',
+ '#value' => $entity_2->$key,
+ );
+ }
+ field_attach_form('test_entity', $entity_2, $form['entity_2'], $form_state);
+
+ $form['save'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#weight' => 100,
+ );
+
+ return $form;
+}
+
+/**
+ * Validate handler for field_test_entity_nested_form().
+ */
+function field_test_entity_nested_form_validate($form, &$form_state) {
+ $entity_1 = (object) $form_state['values'];
+ field_attach_form_validate('test_entity', $entity_1, $form, $form_state);
+
+ $entity_2 = (object) $form_state['values']['entity_2'];
+ field_attach_form_validate('test_entity', $entity_2, $form['entity_2'], $form_state);
+}
+
+/**
+ * Submit handler for field_test_entity_nested_form().
+ */
+function field_test_entity_nested_form_submit($form, &$form_state) {
+ $entity_1 = (object) $form_state['values'];
+ field_attach_submit('test_entity', $entity_1, $form, $form_state);
+ field_test_entity_save($entity_1);
+
+ $entity_2 = (object) $form_state['values']['entity_2'];
+ field_attach_submit('test_entity', $entity_2, $form['entity_2'], $form_state);
+ field_test_entity_save($entity_2);
+
+ drupal_set_message(t('test_entities @id_1 and @id_2 have been updated.', array('@id_1' => $entity_1->ftid, '@id_2' => $entity_2->ftid)));
+}
+
+/**
+ * Controller class for the test_entity_bundle entity type.
+ *
+ * This extends the DrupalDefaultEntityController class, adding required
+ * special handling for bundles (since they are not stored in the database).
+ */
+class TestEntityBundleController extends DrupalDefaultEntityController {
+
+ protected function attachLoad(&$entities, $revision_id = FALSE) {
+ // Add bundle information.
+ foreach ($entities as $key => $entity) {
+ $entity->fttype = 'test_entity_bundle';
+ $entities[$key] = $entity;
+ }
+ parent::attachLoad($entities, $revision_id);
+ }
+}
diff --git a/core/modules/field/tests/field_test.field.inc b/core/modules/field/tests/field_test.field.inc
new file mode 100644
index 000000000000..b8a2939d64ad
--- /dev/null
+++ b/core/modules/field/tests/field_test.field.inc
@@ -0,0 +1,379 @@
+<?php
+
+/**
+ * @file
+ * Defines a field type and its formatters and widgets.
+ */
+
+/**
+ * Implements hook_field_info().
+ */
+function field_test_field_info() {
+ return array(
+ 'test_field' => array(
+ 'label' => t('Test field'),
+ 'description' => t('Dummy field type used for tests.'),
+ 'settings' => array(
+ 'test_field_setting' => 'dummy test string',
+ 'changeable' => 'a changeable field setting',
+ 'unchangeable' => 'an unchangeable field setting',
+ ),
+ 'instance_settings' => array(
+ 'test_instance_setting' => 'dummy test string',
+ 'test_hook_field_load' => FALSE,
+ ),
+ 'default_widget' => 'test_field_widget',
+ 'default_formatter' => 'field_test_default',
+ ),
+ 'shape' => array(
+ 'label' => t('Shape'),
+ 'description' => t('Another dummy field type.'),
+ 'settings' => array(),
+ 'instance_settings' => array(),
+ 'default_widget' => 'test_field_widget',
+ 'default_formatter' => 'field_test_default',
+ ),
+ 'hidden_test_field' => array(
+ 'no_ui' => TRUE,
+ 'label' => t('Hidden from UI test field'),
+ 'description' => t('Dummy hidden field type used for tests.'),
+ 'settings' => array(),
+ 'instance_settings' => array(),
+ 'default_widget' => 'test_field_widget',
+ 'default_formatter' => 'field_test_default',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_update_forbid().
+ */
+function field_test_field_update_forbid($field, $prior_field, $has_data) {
+ if ($field['type'] == 'test_field' && $field['settings']['unchangeable'] != $prior_field['settings']['unchangeable']) {
+ throw new FieldException("field_test 'unchangeable' setting cannot be changed'");
+ }
+}
+
+/**
+ * Implements hook_field_load().
+ */
+function field_test_field_load($entity_type, $entities, $field, $instances, $langcode, &$items, $age) {
+ foreach ($items as $id => $item) {
+ // To keep the test non-intrusive, only act for instances with the
+ // test_hook_field_load setting explicitly set to TRUE.
+ if ($instances[$id]['settings']['test_hook_field_load']) {
+ foreach ($item as $delta => $value) {
+ // Don't add anything on empty values.
+ if ($value) {
+ $items[$id][$delta]['additional_key'] = 'additional_value';
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_validate().
+ *
+ * Possible error codes:
+ * - 'field_test_invalid': The value is invalid.
+ */
+function field_test_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ foreach ($items as $delta => $item) {
+ if ($item['value'] == -1) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'field_test_invalid',
+ 'message' => t('%name does not accept the value -1.', array('%name' => $instance['label'])),
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function field_test_field_is_empty($item, $field) {
+ return empty($item['value']);
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function field_test_field_settings_form($field, $instance, $has_data) {
+ $settings = $field['settings'];
+
+ $form['test_field_setting'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Field test field setting'),
+ '#default_value' => $settings['test_field_setting'],
+ '#required' => FALSE,
+ '#description' => t('A dummy form element to simulate field setting.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_instance_settings_form().
+ */
+function field_test_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ $form['test_instance_setting'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Field test field instance setting'),
+ '#default_value' => $settings['test_instance_setting'],
+ '#required' => FALSE,
+ '#description' => t('A dummy form element to simulate field instance setting.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function field_test_field_widget_info() {
+ return array(
+ 'test_field_widget' => array(
+ 'label' => t('Test field'),
+ 'field types' => array('test_field', 'hidden_test_field'),
+ 'settings' => array('test_widget_setting' => 'dummy test string'),
+ ),
+ 'test_field_widget_multiple' => array(
+ 'label' => t('Test field 1'),
+ 'field types' => array('test_field'),
+ 'settings' => array('test_widget_setting_multiple' => 'dummy test string'),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function field_test_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ switch ($instance['widget']['type']) {
+ case 'test_field_widget':
+ $element += array(
+ '#type' => 'textfield',
+ '#default_value' => isset($items[$delta]['value']) ? $items[$delta]['value'] : '',
+ );
+ return array('value' => $element);
+
+ case 'test_field_widget_multiple':
+ $values = array();
+ foreach ($items as $delta => $value) {
+ $values[] = $value['value'];
+ }
+ $element += array(
+ '#type' => 'textfield',
+ '#default_value' => implode(', ', $values),
+ '#element_validate' => array('field_test_widget_multiple_validate'),
+ );
+ return $element;
+ }
+}
+
+/**
+ * Form element validation handler for 'test_field_widget_multiple' widget.
+ */
+function field_test_widget_multiple_validate($element, &$form_state) {
+ $values = array_map('trim', explode(',', $element['#value']));
+ $items = array();
+ foreach ($values as $value) {
+ $items[] = array('value' => $value);
+ }
+ form_set_value($element, $items, $form_state);
+}
+
+/**
+ * Implements hook_field_widget_error().
+ */
+function field_test_field_widget_error($element, $error, $form, &$form_state) {
+ // @todo No easy way to differenciate widget types, we should receive it as a
+ // parameter.
+ if (isset($element['value'])) {
+ // Widget is test_field_widget.
+ $error_element = $element['value'];
+ }
+ else {
+ // Widget is test_field_widget_multiple.
+ $error_element = $element;
+ }
+
+ form_error($error_element, $error['message']);
+}
+
+/**
+ * Implements hook_field_widget_settings_form().
+ */
+function field_test_field_widget_settings_form($field, $instance) {
+ $widget = $instance['widget'];
+ $settings = $widget['settings'];
+
+ $form['test_widget_setting'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Field test field widget setting'),
+ '#default_value' => $settings['test_widget_setting'],
+ '#required' => FALSE,
+ '#description' => t('A dummy form element to simulate field widget setting.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function field_test_field_formatter_info() {
+ return array(
+ 'field_test_default' => array(
+ 'label' => t('Default'),
+ 'description' => t('Default formatter'),
+ 'field types' => array('test_field'),
+ 'settings' => array(
+ 'test_formatter_setting' => 'dummy test string',
+ ),
+ ),
+ 'field_test_multiple' => array(
+ 'label' => t('Multiple'),
+ 'description' => t('Multiple formatter'),
+ 'field types' => array('test_field'),
+ 'settings' => array(
+ 'test_formatter_setting_multiple' => 'dummy test string',
+ ),
+ ),
+ 'field_test_with_prepare_view' => array(
+ 'label' => t('Tests hook_field_formatter_prepare_view()'),
+ 'field types' => array('test_field'),
+ 'settings' => array(
+ 'test_formatter_setting_additional' => 'dummy test string',
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_formatter_settings_form().
+ */
+function field_test_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $element = array();
+
+ // The name of the setting depends on the formatter type.
+ $map = array(
+ 'field_test_default' => 'test_formatter_setting',
+ 'field_test_multiple' => 'test_formatter_setting_multiple',
+ 'field_test_with_prepare_view' => 'test_formatter_setting_additional',
+ );
+
+ if (isset($map[$display['type']])) {
+ $name = $map[$display['type']];
+
+ $element[$name] = array(
+ '#title' => t('Setting'),
+ '#type' => 'textfield',
+ '#size' => 20,
+ '#default_value' => $settings[$name],
+ '#required' => TRUE,
+ );
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_formatter_settings_summary().
+ */
+function field_test_field_formatter_settings_summary($field, $instance, $view_mode) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $summary = '';
+
+ // The name of the setting depends on the formatter type.
+ $map = array(
+ 'field_test_default' => 'test_formatter_setting',
+ 'field_test_multiple' => 'test_formatter_setting_multiple',
+ 'field_test_with_prepare_view' => 'test_formatter_setting_additional',
+ );
+
+ if (isset($map[$display['type']])) {
+ $name = $map[$display['type']];
+ $summary = t('@setting: @value', array('@setting' => $name, '@value' => $settings[$name]));
+ }
+
+ return $summary;
+}
+
+/**
+ * Implements hook_field_formatter_prepare_view().
+ */
+function field_test_field_formatter_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $displays) {
+ foreach ($items as $id => $item) {
+ // To keep the test non-intrusive, only act on the
+ // 'field_test_with_prepare_view' formatter.
+ if ($displays[$id]['type'] == 'field_test_with_prepare_view') {
+ foreach ($item as $delta => $value) {
+ // Don't add anything on empty values.
+ if ($value) {
+ $items[$id][$delta]['additional_formatter_value'] = $value['value'] + 1;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function field_test_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+ $settings = $display['settings'];
+
+ switch ($display['type']) {
+ case 'field_test_default':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => $settings['test_formatter_setting'] . '|' . $item['value']);
+ }
+ break;
+
+ case 'field_test_with_prepare_view':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => $settings['test_formatter_setting_additional'] . '|' . $item['value'] . '|' . $item['additional_formatter_value']);
+ }
+ break;
+
+ case 'field_test_multiple':
+ $array = array();
+ foreach ($items as $delta => $item) {
+ $array[] = $delta . ':' . $item['value'];
+ }
+ $element[0] = array('#markup' => $settings['test_formatter_setting_multiple'] . '|' . implode('|', $array));
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * Sample 'default value' callback.
+ */
+function field_test_default_value($entity_type, $entity, $field, $instance) {
+ return array(array('value' => 99));
+}
+
+/**
+ * Implements hook_field_access().
+ */
+function field_test_field_access($op, $field, $entity_type, $entity, $account) {
+ if ($field['field_name'] == "field_no_{$op}_access") {
+ return FALSE;
+ }
+ return TRUE;
+}
diff --git a/core/modules/field/tests/field_test.info b/core/modules/field/tests/field_test.info
new file mode 100644
index 000000000000..5fc9b27d862f
--- /dev/null
+++ b/core/modules/field/tests/field_test.info
@@ -0,0 +1,7 @@
+name = "Field API Test"
+description = "Support module for the Field API tests."
+core = 8.x
+package = Testing
+files[] = field_test.entity.inc
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/field/tests/field_test.install b/core/modules/field/tests/field_test.install
new file mode 100644
index 000000000000..59575611033c
--- /dev/null
+++ b/core/modules/field/tests/field_test.install
@@ -0,0 +1,150 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the field_test module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function field_test_install() {
+ // hook_entity_info_alter() needs to be executed as last.
+ db_update('system')
+ ->fields(array('weight' => 1))
+ ->condition('name', 'field_test')
+ ->execute();
+}
+
+/**
+ * Implements hook_schema().
+ */
+function field_test_schema() {
+ $schema['test_entity'] = array(
+ 'description' => 'The base table for test_entities.',
+ 'fields' => array(
+ 'ftid' => array(
+ 'description' => 'The primary identifier for a test_entity.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'ftvid' => array(
+ 'description' => 'The current {test_entity_revision}.ftvid version identifier.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'fttype' => array(
+ 'description' => 'The type of this test_entity.',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'ftlabel' => array(
+ 'description' => 'The label of this test_entity.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'unique keys' => array(
+ 'ftvid' => array('ftvid'),
+ ),
+ 'primary key' => array('ftid'),
+ );
+ $schema['test_entity_bundle_key'] = array(
+ 'description' => 'The base table for test entities with a bundle key.',
+ 'fields' => array(
+ 'ftid' => array(
+ 'description' => 'The primary indentifier for a test_entity_bundle_key.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'fttype' => array(
+ 'description' => 'The type of this test_entity.',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => FALSE,
+ 'default' => '',
+ ),
+ ),
+ );
+ $schema['test_entity_bundle'] = array(
+ 'description' => 'The base table for test entities with a bundle.',
+ 'fields' => array(
+ 'ftid' => array(
+ 'description' => 'The primary indentifier for a test_entity_bundle.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ );
+ $schema['test_entity_revision'] = array(
+ 'description' => 'Stores information about each saved version of a {test_entity}.',
+ 'fields' => array(
+ 'ftid' => array(
+ 'description' => 'The {test_entity} this version belongs to.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'ftvid' => array(
+ 'description' => 'The primary identifier for this version.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'indexes' => array(
+ 'nid' => array('ftid'),
+ ),
+ 'primary key' => array('ftvid'),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_field_schema().
+ */
+function field_test_field_schema($field) {
+ if ($field['type'] == 'test_field') {
+ return array(
+ 'columns' => array(
+ 'value' => array(
+ 'type' => 'int',
+ 'size' => 'medium',
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'value' => array('value'),
+ ),
+ );
+ }
+ else {
+ return array(
+ 'columns' => array(
+ 'shape' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => FALSE,
+ ),
+ 'color' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => FALSE,
+ ),
+ ),
+ );
+ }
+}
diff --git a/core/modules/field/tests/field_test.module b/core/modules/field/tests/field_test.module
new file mode 100644
index 000000000000..ef2fedbcda46
--- /dev/null
+++ b/core/modules/field/tests/field_test.module
@@ -0,0 +1,252 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the Field API tests.
+ *
+ * The module defines
+ * - an entity type (field_test.entity.inc)
+ * - a field type and its formatters and widgets (field_test.field.inc)
+ * - a field storage backend (field_test.storage.inc)
+ *
+ * The main field_test.module file implements generic hooks and provides some
+ * test helper functions
+ */
+
+require_once DRUPAL_ROOT . '/core/modules/field/tests/field_test.entity.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/tests/field_test.field.inc';
+require_once DRUPAL_ROOT . '/core/modules/field/tests/field_test.storage.inc';
+
+/**
+ * Implements hook_permission().
+ */
+function field_test_permission() {
+ $perms = array(
+ 'access field_test content' => array(
+ 'title' => t('Access field_test content'),
+ 'description' => t('View published field_test content.'),
+ ),
+ 'administer field_test content' => array(
+ 'title' => t('Administer field_test content'),
+ 'description' => t('Manage field_test content'),
+ ),
+ );
+ return $perms;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function field_test_menu() {
+ $items = array();
+ $bundles = field_info_bundles('test_entity');
+
+ foreach ($bundles as $bundle_name => $bundle_info) {
+ $bundle_url_str = str_replace('_', '-', $bundle_name);
+ $items['test-entity/add/' . $bundle_url_str] = array(
+ 'title' => t('Add %bundle test_entity', array('%bundle' => $bundle_info['label'])),
+ 'page callback' => 'field_test_entity_add',
+ 'page arguments' => array(2),
+ 'access arguments' => array('administer field_test content'),
+ 'type' => MENU_NORMAL_ITEM,
+ );
+ }
+ $items['test-entity/manage/%field_test_entity_test/edit'] = array(
+ 'title' => 'Edit test entity',
+ 'page callback' => 'field_test_entity_edit',
+ 'page arguments' => array(2),
+ 'access arguments' => array('administer field_test content'),
+ 'type' => MENU_NORMAL_ITEM,
+ );
+
+ $items['test-entity/nested/%field_test_entity_test/%field_test_entity_test'] = array(
+ 'title' => 'Nested entity form',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_test_entity_nested_form', 2, 3),
+ 'access arguments' => array('administer field_test content'),
+ 'type' => MENU_NORMAL_ITEM,
+ );
+
+ return $items;
+}
+
+/**
+ * Generic op to test _field_invoke behavior.
+ *
+ * This simulates a field operation callback to be invoked by _field_invoke().
+ */
+function field_test_field_test_op($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ return array($langcode => hash('sha256', serialize(array($entity_type, $entity, $field['field_name'], $langcode, $items))));
+}
+
+/**
+ * Generic op to test _field_invoke_multiple behavior.
+ *
+ * This simulates a multiple field operation callback to be invoked by
+ * _field_invoke_multiple().
+ */
+function field_test_field_test_op_multiple($entity_type, $entities, $field, $instances, $langcode, &$items) {
+ $result = array();
+ foreach ($entities as $id => $entity) {
+ // Entities, instances and items are assumed to be consistently grouped by
+ // language. To verify this we try to access all the passed data structures
+ // by entity id. If they are grouped correctly, one entity, one instance and
+ // one array of items should be available for each entity id.
+ $field_name = $instances[$id]['field_name'];
+ $result[$id] = array($langcode => hash('sha256', serialize(array($entity_type, $entity, $field_name, $langcode, $items[$id]))));
+ }
+ return $result;
+}
+
+/**
+ * Implements hook_field_available_languages_alter().
+ */
+function field_test_field_available_languages_alter(&$languages, $context) {
+ if (variable_get('field_test_field_available_languages_alter', FALSE)) {
+ // Add an unavailable language.
+ $languages[] = 'xx';
+ // Remove an available language.
+ $index = array_search('en', $languages);
+ unset($languages[$index]);
+ }
+}
+
+/**
+ * Implements hook_field_language_alter().
+ */
+function field_test_field_language_alter(&$display_language, $context) {
+ if (variable_get('field_test_language_fallback', TRUE)) {
+ locale_field_language_fallback($display_language, $context['entity'], $context['language']);
+ }
+}
+
+/**
+ * Store and retrieve keyed data for later verification by unit tests.
+ *
+ * This function is a simple in-memory key-value store with the
+ * distinction that it stores all values for a given key instead of
+ * just the most recently set value. field_test module hooks call
+ * this function to record their arguments, keyed by hook name. The
+ * unit tests later call this function to verify that the correct
+ * hooks were called and were passed the correct arguments.
+ *
+ * This function ignores all calls until the first time it is called
+ * with $key of NULL. Each time it is called with $key of NULL, it
+ * erases all previously stored data from its internal cache, but also
+ * returns the previously stored data to the caller. A typical usage
+ * scenario is:
+ *
+ * @code
+ * // calls to field_test_memorize() here are ignored
+ *
+ * // turn on memorization
+ * field_test_memorize();
+ *
+ * // call some Field API functions that invoke field_test hooks
+ * $field = field_create_field(...);
+ *
+ * // retrieve and reset the memorized hook call data
+ * $mem = field_test_memorize();
+ *
+ * // make sure hook_field_create_field() is invoked correctly
+ * assertEqual(count($mem['field_test_field_create_field']), 1);
+ * assertEqual($mem['field_test_field_create_field'][0], array($field));
+ * @endcode
+ *
+ * @param $key
+ * The key under which to store to $value, or NULL as described above.
+ * @param $value
+ * A value to store for $key.
+ * @return
+ * An array mapping each $key to an array of each $value passed in
+ * for that key.
+ */
+function field_test_memorize($key = NULL, $value = NULL) {
+ $memorize = &drupal_static(__FUNCTION__, NULL);
+
+ if (!isset($key)) {
+ $return = $memorize;
+ $memorize = array();
+ return $return;
+ }
+ if (is_array($memorize)) {
+ $memorize[$key][] = $value;
+ }
+}
+
+/**
+ * Memorize calls to hook_field_create_field().
+ */
+function field_test_field_create_field($field) {
+ $args = func_get_args();
+ field_test_memorize(__FUNCTION__, $args);
+}
+
+/**
+ * Memorize calls to hook_field_insert().
+ */
+function field_test_field_insert($entity_type, $entity, $field, $instance, $items) {
+ $args = func_get_args();
+ field_test_memorize(__FUNCTION__, $args);
+}
+
+/**
+ * Memorize calls to hook_field_update().
+ */
+function field_test_field_update($entity_type, $entity, $field, $instance, $items) {
+ $args = func_get_args();
+ field_test_memorize(__FUNCTION__, $args);
+}
+
+/**
+ * Memorize calls to hook_field_delete().
+ */
+function field_test_field_delete($entity_type, $entity, $field, $instance, $items) {
+ $args = func_get_args();
+ field_test_memorize(__FUNCTION__, $args);
+}
+
+/**
+ * Implements hook_entity_query_alter().
+ */
+function field_test_entity_query_alter(&$query) {
+ if (!empty($query->alterMyExecuteCallbackPlease)) {
+ $query->executeCallback = 'field_test_dummy_field_storage_query';
+ }
+}
+
+/**
+ * Pseudo-implements hook_field_storage_query().
+ */
+function field_test_dummy_field_storage_query(EntityFieldQuery $query) {
+ // Return dummy values that will be checked by the test.
+ return array(
+ 'user' => array(
+ 1 => entity_create_stub_entity('user', array(1, NULL, NULL)),
+ ),
+ );
+}
+
+/**
+ * Entity label callback.
+ *
+ * @param $entity_type
+ * The entity type.
+ * @param $entity
+ * The entity object.
+ *
+ * @return
+ * The label of the entity prefixed with "label callback".
+ */
+function field_test_entity_label_callback($entity_type, $entity) {
+ return 'label callback ' . $entity->ftlabel;
+}
+
+/**
+ * Implements hook_field_attach_view_alter().
+ */
+function field_test_field_attach_view_alter(&$output, $context) {
+ if (!empty($context['display']['settings']['alter'])) {
+ $output['test_field'][] = array('#markup' => 'field_test_field_attach_view_alter');
+ }
+}
diff --git a/core/modules/field/tests/field_test.storage.inc b/core/modules/field/tests/field_test.storage.inc
new file mode 100644
index 000000000000..a26af1765525
--- /dev/null
+++ b/core/modules/field/tests/field_test.storage.inc
@@ -0,0 +1,473 @@
+<?php
+
+/**
+ * @file
+ * Defines a field storage backend.
+ */
+
+
+/**
+ * Implements hook_field_storage_info().
+ */
+function field_test_field_storage_info() {
+ return array(
+ 'field_test_storage' => array(
+ 'label' => t('Test storage'),
+ 'description' => t('Dummy test storage backend. Stores field values in the variable table.'),
+ ),
+ 'field_test_storage_failure' => array(
+ 'label' => t('Test storage failure'),
+ 'description' => t('Dummy test storage backend. Always fails to create fields.'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_storage_details().
+ */
+function field_test_field_storage_details($field) {
+ $details = array();
+
+ // Add field columns.
+ $columns = array();
+ foreach ((array) $field['columns'] as $column_name => $attributes) {
+ $columns[$column_name] = $column_name;
+ }
+ return array(
+ 'drupal_variables' => array(
+ 'field_test_storage_data[FIELD_LOAD_CURRENT]' => $columns,
+ 'field_test_storage_data[FIELD_LOAD_REVISION]' => $columns,
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_storage_details_alter().
+ *
+ * @see FieldAttachStorageTestCase::testFieldStorageDetailsAlter()
+ */
+function field_test_field_storage_details_alter(&$details, $field) {
+
+ // For testing, storage details are changed only because of the field name.
+ if ($field['field_name'] == 'field_test_change_my_details') {
+ $columns = array();
+ foreach ((array) $field['columns'] as $column_name => $attributes) {
+ $columns[$column_name] = $column_name;
+ }
+ $details['drupal_variables'] = array(
+ FIELD_LOAD_CURRENT => array(
+ 'moon' => $columns,
+ ),
+ FIELD_LOAD_REVISION => array(
+ 'mars' => $columns,
+ ),
+ );
+ }
+}
+
+/**
+ * Helper function: stores or retrieves data from the 'storage backend'.
+ */
+function _field_test_storage_data($data = NULL) {
+ if (!isset($data)) {
+ return variable_get('field_test_storage_data', array());
+ }
+ else {
+ variable_set('field_test_storage_data', $data);
+ }
+}
+
+/**
+ * Implements hook_field_storage_load().
+ */
+function field_test_field_storage_load($entity_type, $entities, $age, $fields, $options) {
+ $data = _field_test_storage_data();
+
+ $load_current = $age == FIELD_LOAD_CURRENT;
+
+ foreach ($fields as $field_id => $ids) {
+ $field = field_info_field_by_id($field_id);
+ $field_name = $field['field_name'];
+ $field_data = $data[$field['id']];
+ $sub_table = $load_current ? 'current' : 'revisions';
+ $delta_count = array();
+ foreach ($field_data[$sub_table] as $row) {
+ if ($row->type == $entity_type && (!$row->deleted || $options['deleted'])) {
+ if (($load_current && in_array($row->entity_id, $ids)) || (!$load_current && in_array($row->revision_id, $ids))) {
+ if (in_array($row->language, field_available_languages($entity_type, $field))) {
+ if (!isset($delta_count[$row->entity_id][$row->language])) {
+ $delta_count[$row->entity_id][$row->language] = 0;
+ }
+ if ($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta_count[$row->entity_id][$row->language] < $field['cardinality']) {
+ $item = array();
+ foreach ($field['columns'] as $column => $attributes) {
+ $item[$column] = $row->{$column};
+ }
+ $entities[$row->entity_id]->{$field_name}[$row->language][] = $item;
+ $delta_count[$row->entity_id][$row->language]++;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_write().
+ */
+function field_test_field_storage_write($entity_type, $entity, $op, $fields) {
+ $data = _field_test_storage_data();
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ foreach ($fields as $field_id) {
+ $field = field_info_field_by_id($field_id);
+ $field_name = $field['field_name'];
+ $field_data = &$data[$field_id];
+
+ $all_languages = field_available_languages($entity_type, $field);
+ $field_languages = array_intersect($all_languages, array_keys((array) $entity->$field_name));
+
+ // Delete and insert, rather than update, in case a value was added.
+ if ($op == FIELD_STORAGE_UPDATE) {
+ // Delete languages present in the incoming $entity->$field_name.
+ // Delete all languages if $entity->$field_name is empty.
+ $languages = !empty($entity->$field_name) ? $field_languages : $all_languages;
+ if ($languages) {
+ foreach ($field_data['current'] as $key => $row) {
+ if ($row->type == $entity_type && $row->entity_id == $id && in_array($row->language, $languages)) {
+ unset($field_data['current'][$key]);
+ }
+ }
+ if (isset($vid)) {
+ foreach ($field_data['revisions'] as $key => $row) {
+ if ($row->type == $entity_type && $row->revision_id == $vid) {
+ unset($field_data['revisions'][$key]);
+ }
+ }
+ }
+ }
+ }
+
+ foreach ($field_languages as $langcode) {
+ $items = (array) $entity->{$field_name}[$langcode];
+ $delta_count = 0;
+ foreach ($items as $delta => $item) {
+ $row = (object) array(
+ 'field_id' => $field_id,
+ 'type' => $entity_type,
+ 'entity_id' => $id,
+ 'revision_id' => $vid,
+ 'bundle' => $bundle,
+ 'delta' => $delta,
+ 'deleted' => FALSE,
+ 'language' => $langcode,
+ );
+ foreach ($field['columns'] as $column => $attributes) {
+ $row->{$column} = isset($item[$column]) ? $item[$column] : NULL;
+ }
+
+ $field_data['current'][] = $row;
+ if (isset($vid)) {
+ $field_data['revisions'][] = $row;
+ }
+
+ if ($field['cardinality'] != FIELD_CARDINALITY_UNLIMITED && ++$delta_count == $field['cardinality']) {
+ break;
+ }
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_storage_delete().
+ */
+function field_test_field_storage_delete($entity_type, $entity, $fields) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Note: reusing field_test_storage_purge(), like field_sql_storage.module
+ // does, is highly inefficient in our case...
+ foreach (field_info_instances($bundle) as $instance) {
+ if (isset($fields[$instance['field_id']])) {
+ $field = field_info_field_by_id($instance['field_id']);
+ field_test_field_storage_purge($entity_type, $entity, $field, $instance);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_storage_purge().
+ */
+function field_test_field_storage_purge($entity_type, $entity, $field, $instance) {
+ $data = _field_test_storage_data();
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ $field_data = &$data[$field['id']];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as $key => $row) {
+ if ($row->type == $entity_type && $row->entity_id == $id) {
+ unset($field_data[$sub_table][$key]);
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_storage_delete_revision().
+ */
+function field_test_field_storage_delete_revision($entity_type, $entity, $fields) {
+ $data = _field_test_storage_data();
+
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ foreach ($fields as $field_id) {
+ $field_data = &$data[$field_id];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as $key => $row) {
+ if ($row->type == $entity_type && $row->entity_id == $id && $row->revision_id == $vid) {
+ unset($field_data[$sub_table][$key]);
+ }
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_storage_query().
+ */
+function field_test_field_storage_query($field_id, $conditions, $count, &$cursor = NULL, $age) {
+ $data = _field_test_storage_data();
+
+ $load_current = $age == FIELD_LOAD_CURRENT;
+
+ $field = field_info_field_by_id($field_id);
+ $field_columns = array_keys($field['columns']);
+
+ $field_data = $data[$field['id']];
+ $sub_table = $load_current ? 'current' : 'revisions';
+ // We need to sort records by entity type and entity id.
+ usort($field_data[$sub_table], '_field_test_field_storage_query_sort_helper');
+
+ // Initialize results array.
+ $return = array();
+ $entity_count = 0;
+ $rows_count = 0;
+ $rows_total = count($field_data[$sub_table]);
+ $skip = $cursor;
+ $skipped = 0;
+
+ foreach ($field_data[$sub_table] as $row) {
+ if ($count != FIELD_QUERY_NO_LIMIT && $entity_count >= $count) {
+ break;
+ }
+
+ if ($row->field_id == $field['id']) {
+ $match = TRUE;
+ $condition_deleted = FALSE;
+ // Add conditions.
+ foreach ($conditions as $condition) {
+ @list($column, $value, $operator) = $condition;
+ if (empty($operator)) {
+ $operator = is_array($value) ? 'IN' : '=';
+ }
+ switch ($operator) {
+ case '=':
+ $match = $match && $row->{$column} == $value;
+ break;
+ case '<>':
+ case '<':
+ case '<=':
+ case '>':
+ case '>=':
+ eval('$match = $match && ' . $row->{$column} . ' ' . $operator . ' '. $value);
+ break;
+ case 'IN':
+ $match = $match && in_array($row->{$column}, $value);
+ break;
+ case 'NOT IN':
+ $match = $match && !in_array($row->{$column}, $value);
+ break;
+ case 'BETWEEN':
+ $match = $match && $row->{$column} >= $value[0] && $row->{$column} <= $value[1];
+ break;
+ case 'STARTS_WITH':
+ case 'ENDS_WITH':
+ case 'CONTAINS':
+ // Not supported.
+ $match = FALSE;
+ break;
+ }
+ // Track condition on 'deleted'.
+ if ($column == 'deleted') {
+ $condition_deleted = TRUE;
+ }
+ }
+
+ // Exclude deleted data unless we have a condition on it.
+ if (!$condition_deleted && $row->deleted) {
+ $match = FALSE;
+ }
+
+ if ($match) {
+ if (!isset($skip) || $skipped >= $skip) {
+ $cursor++;
+ // If querying all revisions and the entity type has revisions, we need
+ // to key the results by revision_ids.
+ $entity_type = entity_get_info($row->type);
+ $id = ($load_current || empty($entity_type['entity keys']['revision'])) ? $row->entity_id : $row->revision_id;
+
+ if (!isset($return[$row->type][$id])) {
+ $return[$row->type][$id] = entity_create_stub_entity($row->type, array($row->entity_id, $row->revision_id, $row->bundle));
+ $entity_count++;
+ }
+ }
+ else {
+ $skipped++;
+ }
+ }
+ }
+ $rows_count++;
+
+ // The query is complete if we walked the whole array.
+ if ($count != FIELD_QUERY_NO_LIMIT && $rows_count >= $rows_total) {
+ $cursor = FIELD_QUERY_COMPLETE;
+ }
+ }
+
+ return $return;
+}
+
+/**
+ * Sort helper for field_test_field_storage_query().
+ *
+ * Sorts by entity type and entity id.
+ */
+function _field_test_field_storage_query_sort_helper($a, $b) {
+ if ($a->type == $b->type) {
+ if ($a->entity_id == $b->entity_id) {
+ return 0;
+ }
+ else {
+ return $a->entity_id < $b->entity_id ? -1 : 1;
+ }
+ }
+ else {
+ return $a->type < $b->type ? -1 : 1;
+ }
+}
+
+/**
+ * Implements hook_field_storage_create_field().
+ */
+function field_test_field_storage_create_field($field) {
+ if ($field['storage']['type'] == 'field_test_storage_failure') {
+ throw new Exception('field_test_storage_failure engine always fails to create fields');
+ }
+
+ $data = _field_test_storage_data();
+
+ $data[$field['id']] = array(
+ 'current' => array(),
+ 'revisions' => array(),
+ );
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_storage_delete_field().
+ */
+function field_test_field_storage_delete_field($field) {
+ $data = _field_test_storage_data();
+
+ $field_data = &$data[$field['id']];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as &$row) {
+ $row->deleted = TRUE;
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_storage_delete_instance().
+ */
+function field_test_field_storage_delete_instance($instance) {
+ $data = _field_test_storage_data();
+
+ $field = field_info_field($instance['field_name']);
+ $field_data = &$data[$field['id']];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as &$row) {
+ if ($row->bundle == $instance['bundle']) {
+ $row->deleted = TRUE;
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_attach_create_bundle().
+ */
+function field_test_field_attach_create_bundle($bundle) {
+ // We don't need to do anything here.
+}
+
+/**
+ * Implements hook_field_attach_rename_bundle().
+ */
+function field_test_field_attach_rename_bundle($bundle_old, $bundle_new) {
+ $data = _field_test_storage_data();
+
+ // We need to account for deleted or inactive fields and instances.
+ $instances = field_read_instances(array('bundle' => $bundle_new), array('include_deleted' => TRUE, 'include_inactive' => TRUE));
+ foreach ($instances as $field_name => $instance) {
+ $field = field_info_field_by_id($instance['field_id']);
+ if ($field['storage']['type'] == 'field_test_storage') {
+ $field_data = &$data[$field['id']];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as &$row) {
+ if ($row->bundle == $bundle_old) {
+ $row->bundle = $bundle_new;
+ }
+ }
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
+
+/**
+ * Implements hook_field_attach_delete_bundle().
+ */
+function field_test_field_attach_delete_bundle($entity_type, $bundle, $instances) {
+ $data = _field_test_storage_data();
+
+ foreach ($instances as $field_name => $instance) {
+ $field = field_info_field($field_name);
+ if ($field['storage']['type'] == 'field_test_storage') {
+ $field_data = &$data[$field['id']];
+ foreach (array('current', 'revisions') as $sub_table) {
+ foreach ($field_data[$sub_table] as &$row) {
+ if ($row->bundle == $bundle_old) {
+ $row->deleted = TRUE;
+ }
+ }
+ }
+ }
+ }
+
+ _field_test_storage_data($data);
+}
diff --git a/core/modules/field/theme/field-rtl.css b/core/modules/field/theme/field-rtl.css
new file mode 100644
index 000000000000..5d35a86a11cb
--- /dev/null
+++ b/core/modules/field/theme/field-rtl.css
@@ -0,0 +1,14 @@
+
+form .field-multiple-table th.field-label {
+ padding-right: 0;
+}
+form .field-multiple-table td.field-multiple-drag {
+ padding-left: 0;
+}
+form .field-multiple-table td.field-multiple-drag a.tabledrag-handle{
+ padding-left: .5em;
+}
+.field-label-inline .field-label,
+.field-label-inline .field-items {
+ float: right;
+}
diff --git a/core/modules/field/theme/field.css b/core/modules/field/theme/field.css
new file mode 100644
index 000000000000..9eba32f0b7f5
--- /dev/null
+++ b/core/modules/field/theme/field.css
@@ -0,0 +1,28 @@
+
+/* Field display */
+.field .field-label {
+ font-weight: bold;
+}
+.field-label-inline .field-label,
+.field-label-inline .field-items {
+ float:left; /*LTR*/
+}
+
+/* Form display */
+form .field-multiple-table {
+ margin: 0;
+}
+form .field-multiple-table th.field-label {
+ padding-left: 0; /*LTR*/
+}
+form .field-multiple-table td.field-multiple-drag {
+ width: 30px;
+ padding-right: 0; /*LTR*/
+}
+form .field-multiple-table td.field-multiple-drag a.tabledrag-handle {
+ padding-right: .5em; /*LTR*/
+}
+
+form .field-add-more-submit {
+ margin: .5em 0 0;
+}
diff --git a/core/modules/field/theme/field.tpl.php b/core/modules/field/theme/field.tpl.php
new file mode 100644
index 000000000000..9e76e3b9c12f
--- /dev/null
+++ b/core/modules/field/theme/field.tpl.php
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @file field.tpl.php
+ * Default template implementation to display the value of a field.
+ *
+ * This file is not used and is here as a starting point for customization only.
+ * @see theme_field()
+ *
+ * Available variables:
+ * - $items: An array of field values. Use render() to output them.
+ * - $label: The item label.
+ * - $label_hidden: Whether the label display is set to 'hidden'.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the
+ * following:
+ * - field: The current template type, i.e., "theming hook".
+ * - field-name-[field_name]: The current field name. For example, if the
+ * field name is "field_description" it would result in
+ * "field-name-field-description".
+ * - field-type-[field_type]: The current field type. For example, if the
+ * field type is "text" it would result in "field-type-text".
+ * - field-label-[label_display]: The current label position. For example, if
+ * the label position is "above" it would result in "field-label-above".
+ *
+ * Other variables:
+ * - $element['#object']: The entity to which the field is attached.
+ * - $element['#view_mode']: View mode, e.g. 'full', 'teaser'...
+ * - $element['#field_name']: The field name.
+ * - $element['#field_type']: The field type.
+ * - $element['#field_language']: The field language.
+ * - $element['#field_translatable']: Whether the field is translatable or not.
+ * - $element['#label_display']: Position of label display, inline, above, or
+ * hidden.
+ * - $field_name_css: The css-compatible field name.
+ * - $field_type_css: The css-compatible field type.
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess_field()
+ * @see theme_field()
+ */
+?>
+<!--
+THIS FILE IS NOT USED AND IS HERE AS A STARTING POINT FOR CUSTOMIZATION ONLY.
+See http://api.drupal.org/api/function/theme_field/7 for details.
+After copying this file to your theme's folder and customizing it, remove this
+HTML comment.
+-->
+<div class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
+ <?php if (!$label_hidden): ?>
+ <div class="field-label"<?php print $title_attributes; ?>><?php print $label ?>:&nbsp;</div>
+ <?php endif; ?>
+ <div class="field-items"<?php print $content_attributes; ?>>
+ <?php foreach ($items as $delta => $item): ?>
+ <div class="field-item <?php print $delta % 2 ? 'odd' : 'even'; ?>"<?php print $item_attributes[$delta]; ?>><?php print render($item); ?></div>
+ <?php endforeach; ?>
+ </div>
+</div>
diff --git a/core/modules/field_ui/field_ui-rtl.css b/core/modules/field_ui/field_ui-rtl.css
new file mode 100644
index 000000000000..123a840bf8f3
--- /dev/null
+++ b/core/modules/field_ui/field_ui-rtl.css
@@ -0,0 +1,5 @@
+
+/* 'Manage fields' overview */
+table.field-ui-overview tr.add-new .label-input {
+ float: right;
+}
diff --git a/core/modules/field_ui/field_ui.admin.inc b/core/modules/field_ui/field_ui.admin.inc
new file mode 100644
index 000000000000..6fd55b33c2dd
--- /dev/null
+++ b/core/modules/field_ui/field_ui.admin.inc
@@ -0,0 +1,2077 @@
+<?php
+
+/**
+ * @file
+ * Administrative interface for custom field type creation.
+ */
+
+/**
+ * Menu callback; lists all defined fields for quick reference.
+ */
+function field_ui_fields_list() {
+ $instances = field_info_instances();
+ $field_types = field_info_field_types();
+ $bundles = field_info_bundles();
+
+ $modules = system_rebuild_module_data();
+
+ $header = array(t('Field name'), t('Field type'), t('Used in'));
+ $rows = array();
+ foreach ($instances as $entity_type => $type_bundles) {
+ foreach ($type_bundles as $bundle => $bundle_instances) {
+ foreach ($bundle_instances as $field_name => $instance) {
+ $field = field_info_field($field_name);
+
+ // Initialize the row if we encounter the field for the first time.
+ if (!isset($rows[$field_name])) {
+ $rows[$field_name]['class'] = $field['locked'] ? array('menu-disabled') : array('');
+ $rows[$field_name]['data'][0] = $field['locked'] ? t('@field_name (Locked)', array('@field_name' => $field_name)) : $field_name;
+ $module_name = $field_types[$field['type']]['module'];
+ $rows[$field_name]['data'][1] = $field_types[$field['type']]['label'] . ' ' . t('(module: !module)', array('!module' => $modules[$module_name]->info['name']));
+ }
+
+ // Add the current instance.
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+ $rows[$field_name]['data'][2][] = $admin_path ? l($bundles[$entity_type][$bundle]['label'], $admin_path . '/fields') : $bundles[$entity_type][$bundle]['label'];
+ }
+ }
+ }
+ foreach ($rows as $field_name => $cell) {
+ $rows[$field_name]['data'][2] = implode(', ', $cell['data'][2]);
+ }
+ if (empty($rows)) {
+ $output = t('No fields have been defined yet.');
+ }
+ else {
+ // Sort rows by field name.
+ ksort($rows);
+ $output = theme('table', array('header' => $header, 'rows' => $rows));
+ }
+ return $output;
+}
+
+/**
+ * Helper function to display a message about inactive fields.
+ */
+function field_ui_inactive_message($entity_type, $bundle) {
+ $inactive_instances = field_ui_inactive_instances($entity_type, $bundle);
+ if (!empty($inactive_instances)) {
+ $field_types = field_info_field_types();
+ $widget_types = field_info_widget_types();
+
+ foreach ($inactive_instances as $field_name => $instance) {
+ $list[] = t('%field (@field_name) field requires the %widget_type widget provided by %widget_module module', array(
+ '%field' => $instance['label'],
+ '@field_name' => $instance['field_name'],
+ '%widget_type' => isset($widget_types[$instance['widget']['type']]) ? $widget_types[$instance['widget']['type']]['label'] : $instance['widget']['type'],
+ '%widget_module' => $instance['widget']['module'],
+ ));
+ }
+ drupal_set_message(t('Inactive fields are not shown unless their providing modules are enabled. The following fields are not enabled: !list', array('!list' => theme('item_list', array('items' => $list)))), 'error');
+ }
+}
+
+/**
+ * Helper function: determines the rendering order of a tree array.
+ *
+ * This is intended as a callback for array_reduce().
+ */
+function _field_ui_reduce_order($array, $a) {
+ $array = !isset($array) ? array() : $array;
+ if ($a['name']) {
+ $array[] = $a['name'];
+ }
+ if (!empty($a['children'])) {
+ uasort($a['children'], 'drupal_sort_weight');
+ $array = array_merge($array, array_reduce($a['children'], '_field_ui_reduce_order'));
+ }
+ return $array;
+}
+
+/**
+ * Returns the region to which a row in the 'Manage fields' screen belongs.
+ *
+ * This function is used as a #row_callback in field_ui_field_overview_form(),
+ * and is called during field_ui_table_pre_render().
+ */
+function field_ui_field_overview_row_region($row) {
+ switch ($row['#row_type']) {
+ case 'field':
+ case 'extra_field':
+ return 'main';
+ case 'add_new_field':
+ // If no input in 'label', assume the row has not been dragged out of the
+ // 'add new' section.
+ return (!empty($row['label']['#value']) ? 'main' : 'add_new');
+ }
+}
+
+/**
+ * Returns the region to which a row in the 'Manage display' screen belongs.
+ *
+ * This function is used as a #row_callback in field_ui_field_overview_form(),
+ * and is called during field_ui_table_pre_render().
+ */
+function field_ui_display_overview_row_region($row) {
+ switch ($row['#row_type']) {
+ case 'field':
+ case 'extra_field':
+ return ($row['format']['type']['#value'] == 'hidden' ? 'hidden' : 'visible');
+ }
+}
+
+/**
+ * Pre-render callback for field_ui_table elements.
+ */
+function field_ui_table_pre_render($elements) {
+ $js_settings = array();
+
+ // For each region, build the tree structure from the weight and parenting
+ // data contained in the flat form structure, to determine row order and
+ // indentation.
+ $regions = $elements['#regions'];
+ $tree = array('' => array('name' => '', 'children' => array()));
+ $trees = array_fill_keys(array_keys($regions), $tree);
+
+ $parents = array();
+ $list = drupal_map_assoc(element_children($elements));
+
+ // Iterate on rows until we can build a known tree path for all of them.
+ while ($list) {
+ foreach ($list as $name) {
+ $row = &$elements[$name];
+ $parent = $row['parent_wrapper']['parent']['#value'];
+ // Proceed if parent is known.
+ if (empty($parent) || isset($parents[$parent])) {
+ // Grab parent, and remove the row from the next iteration.
+ $parents[$name] = $parent ? array_merge($parents[$parent], array($parent)) : array();
+ unset($list[$name]);
+
+ // Determine the region for the row.
+ $function = $row['#region_callback'];
+ $region_name = $function($row);
+
+ // Add the element in the tree.
+ $target = &$trees[$region_name][''];
+ foreach ($parents[$name] as $key) {
+ $target = &$target['children'][$key];
+ }
+ $target['children'][$name] = array('name' => $name, 'weight' => $row['weight']['#value']);
+
+ // Add tabledrag indentation to the first row cell.
+ if ($depth = count($parents[$name])) {
+ $cell = current(element_children($row));
+ $row[$cell]['#prefix'] = theme('indentation', array('size' => $depth)) . (isset($row[$cell]['#prefix']) ? $row[$cell]['#prefix'] : '');
+ }
+
+ // Add row id and associate JS settings.
+ $id = drupal_html_class($name);
+ $row['#attributes']['id'] = $id;
+ if (isset($row['#js_settings'])) {
+ $row['#js_settings'] += array(
+ 'rowHandler' => $row['#row_type'],
+ 'name' => $name,
+ 'region' => $region_name,
+ );
+ $js_settings[$id] = $row['#js_settings'];
+ }
+ }
+ }
+ }
+ // Determine rendering order from the tree structure.
+ foreach ($regions as $region_name => $region) {
+ $elements['#regions'][$region_name]['rows_order'] = array_reduce($trees[$region_name], '_field_ui_reduce_order');
+ }
+
+ $elements['#attached']['js'][] = array(
+ 'type' => 'setting',
+ 'data' => array('fieldUIRowsData' => $js_settings),
+ );
+
+ return $elements;
+}
+
+/**
+ * Returns HTML for Field UI overview tables.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - elements: An associative array containing a Form API structure to be
+ * rendered as a table.
+ *
+ * @ingroup themeable
+ */
+function theme_field_ui_table($variables) {
+ $elements = $variables['elements'];
+ $table = array();
+ $js_settings = array();
+
+ // Add table headers and attributes.
+ foreach (array('header', 'attributes') as $key) {
+ if (isset($elements["#$key"])) {
+ $table[$key] = $elements["#$key"];
+ }
+ }
+
+ // Determine the colspan to use for region rows, by checking the number of
+ // columns in the headers.
+ $colums_count = 0;
+ foreach ($table['header'] as $header) {
+ $colums_count += (is_array($header) && isset($header['colspan']) ? $header['colspan'] : 1);
+ }
+
+ // Render rows, region by region.
+ foreach ($elements['#regions'] as $region_name => $region) {
+ $region_name_class = drupal_html_class($region_name);
+
+ // Add region rows.
+ if (isset($region['title'])) {
+ $table['rows'][] = array(
+ 'class' => array('region-title', 'region-' . $region_name_class . '-title'),
+ 'no_striping' => TRUE,
+ 'data' => array(
+ array('data' => $region['title'], 'colspan' => $colums_count),
+ ),
+ );
+ }
+ if (isset($region['message'])) {
+ $class = (empty($region['rows_order']) ? 'region-empty' : 'region-populated');
+ $table['rows'][] = array(
+ 'class' => array('region-message', 'region-' . $region_name_class . '-message', $class),
+ 'no_striping' => TRUE,
+ 'data' => array(
+ array('data' => $region['message'], 'colspan' => $colums_count),
+ ),
+ );
+ }
+
+ // Add form rows, in the order determined at pre-render time.
+ foreach ($region['rows_order'] as $name) {
+ $element = $elements[$name];
+
+ $row = array('data' => array());
+ if (isset($element['#attributes'])) {
+ $row += $element['#attributes'];
+ }
+
+ // Render children as table cells.
+ foreach (element_children($element) as $cell_key) {
+ $child = &$element[$cell_key];
+ // Do not render a cell for children of #type 'value'.
+ if (!(isset($child['#type']) && $child['#type'] == 'value')) {
+ $cell = array('data' => drupal_render($child));
+ if (isset($child['#cell_attributes'])) {
+ $cell += $child['#cell_attributes'];
+ }
+ $row['data'][] = $cell;
+ }
+ }
+ $table['rows'][] = $row;
+ }
+ }
+
+ return theme('table', $table);
+}
+
+/**
+ * Menu callback; listing of fields for a bundle.
+ *
+ * Allows fields and pseudo-fields to be re-ordered.
+ */
+function field_ui_field_overview_form($form, &$form_state, $entity_type, $bundle) {
+ $bundle = field_extract_bundle($entity_type, $bundle);
+
+ field_ui_inactive_message($entity_type, $bundle);
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+
+ // When displaying the form, make sure the list of fields is up-to-date.
+ if (empty($form_state['post'])) {
+ field_info_cache_clear();
+ }
+
+ // Gather bundle information.
+ $instances = field_info_instances($entity_type, $bundle);
+ $field_types = field_info_field_types();
+ $widget_types = field_info_widget_types();
+
+ $extra_fields = field_info_extra_fields($entity_type, $bundle, 'form');
+
+ $form += array(
+ '#entity_type' => $entity_type,
+ '#bundle' => $bundle,
+ '#fields' => array_keys($instances),
+ '#extra' => array_keys($extra_fields),
+ );
+
+ $table = array(
+ '#type' => 'field_ui_table',
+ '#tree' => TRUE,
+ '#header' => array(
+ t('Label'),
+ t('Weight'),
+ t('Parent'),
+ t('Name'),
+ t('Field'),
+ t('Widget'),
+ array('data' => t('Operations'), 'colspan' => 2),
+ ),
+ '#parent_options' => array(),
+ '#regions' => array(
+ 'main' => array('message' => t('No fields are present yet.')),
+ 'add_new' => array('title' => '&nbsp;'),
+ ),
+ '#attributes' => array(
+ 'class' => array('field-ui-overview'),
+ 'id' => 'field-overview',
+ ),
+ );
+
+ // Fields.
+ foreach ($instances as $name => $instance) {
+ $field = field_info_field($instance['field_name']);
+ $admin_field_path = $admin_path . '/fields/' . $instance['field_name'];
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf')),
+ '#row_type' => 'field',
+ '#region_callback' => 'field_ui_field_overview_row_region',
+ 'label' => array(
+ '#markup' => check_plain($instance['label']),
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#title' => t('Weight for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#default_value' => $instance['widget']['weight'],
+ '#size' => 3,
+ '#attributes' => array('class' => array('field-weight')),
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Parent for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'field_name' => array(
+ '#markup' => $instance['field_name'],
+ ),
+ 'type' => array(
+ '#type' => 'link',
+ '#title' => t($field_types[$field['type']]['label']),
+ '#href' => $admin_field_path . '/field-settings',
+ '#options' => array('attributes' => array('title' => t('Edit field settings.'))),
+ ),
+ 'widget_type' => array(
+ '#type' => 'link',
+ '#title' => t($widget_types[$instance['widget']['type']]['label']),
+ '#href' => $admin_field_path . '/widget-type',
+ '#options' => array('attributes' => array('title' => t('Change widget type.'))),
+ ),
+ 'edit' => array(
+ '#type' => 'link',
+ '#title' => t('edit'),
+ '#href' => $admin_field_path,
+ '#options' => array('attributes' => array('title' => t('Edit instance settings.'))),
+ ),
+ 'delete' => array(
+ '#type' => 'link',
+ '#title' => t('delete'),
+ '#href' => $admin_field_path . '/delete',
+ '#options' => array('attributes' => array('title' => t('Delete instance.'))),
+ ),
+ );
+
+ if (!empty($instance['locked'])) {
+ $table[$name]['edit'] = array('#value' => t('Locked'));
+ $table[$name]['delete'] = array();
+ $table[$name]['#attributes']['class'][] = 'menu-disabled';
+ }
+ }
+
+ // Non-field elements.
+ foreach ($extra_fields as $name => $extra_field) {
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf')),
+ '#row_type' => 'extra_field',
+ '#region_callback' => 'field_ui_field_overview_row_region',
+ 'label' => array(
+ '#markup' => check_plain($extra_field['label']),
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#default_value' => $extra_field['weight'],
+ '#size' => 3,
+ '#attributes' => array('class' => array('field-weight')),
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for @title', array('@title' => $extra_field['label'])),
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Parent for @title', array('@title' => $extra_field['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'field_name' => array(
+ '#markup' => $name,
+ ),
+ 'type' => array(
+ '#markup' => isset($extra_field['description']) ? $extra_field['description'] : '',
+ '#cell_attributes' => array('colspan' => 2),
+ ),
+ 'edit' => array(
+ '#markup' => isset($extra_field['edit']) ? $extra_field['edit'] : '',
+ ),
+ 'delete' => array(
+ '#markup' => isset($extra_field['delete']) ? $extra_field['delete'] : '',
+ ),
+ );
+ }
+
+ // Additional row: add new field.
+ $max_weight = field_info_max_weight($entity_type, $bundle, 'form');
+ $field_type_options = field_ui_field_type_options();
+ $widget_type_options = field_ui_widget_type_options(NULL, TRUE);
+ if ($field_type_options && $widget_type_options) {
+ $name = '_add_new_field';
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf', 'add-new')),
+ '#row_type' => 'add_new_field',
+ '#region_callback' => 'field_ui_field_overview_row_region',
+ 'label' => array(
+ '#type' => 'textfield',
+ '#title' => t('New field label'),
+ '#title_display' => 'invisible',
+ '#size' => 15,
+ '#description' => t('Label'),
+ '#prefix' => '<div class="label-input"><div class="add-new-placeholder">' . t('Add new field') .'</div>',
+ '#suffix' => '</div>',
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#default_value' => $max_weight + 1,
+ '#size' => 3,
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for new field'),
+ '#attributes' => array('class' => array('field-weight')),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Parent for new field'),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'field_name' => array(
+ '#type' => 'textfield',
+ '#title' => t('New field name'),
+ '#title_display' => 'invisible',
+ // This field should stay LTR even for RTL languages.
+ '#field_prefix' => '<span dir="ltr">field_',
+ '#field_suffix' => '</span>&lrm;',
+ '#attributes' => array('dir'=>'ltr'),
+ '#size' => 10,
+ '#description' => t('Field name (a-z, 0-9, _)'),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ 'type' => array(
+ '#type' => 'select',
+ '#title' => t('Type of new field'),
+ '#title_display' => 'invisible',
+ '#options' => $field_type_options,
+ '#empty_option' => t('- Select a field type -'),
+ '#description' => t('Type of data to store.'),
+ '#attributes' => array('class' => array('field-type-select')),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ 'widget_type' => array(
+ '#type' => 'select',
+ '#title' => t('Widget for new field'),
+ '#title_display' => 'invisible',
+ '#options' => $widget_type_options,
+ '#empty_option' => t('- Select a widget -'),
+ '#description' => t('Form element to edit the data.'),
+ '#attributes' => array('class' => array('widget-type-select')),
+ '#cell_attributes' => array('colspan' => 3),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ // Place the 'translatable' property as an explicit value so that contrib
+ // modules can form_alter() the value for newly created fields.
+ 'translatable' => array(
+ '#type' => 'value',
+ '#value' => FALSE,
+ ),
+ );
+ }
+
+ // Additional row: add existing field.
+ $existing_field_options = field_ui_existing_field_options($entity_type, $bundle);
+ if ($existing_field_options && $widget_type_options) {
+ $name = '_add_existing_field';
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf', 'add-new')),
+ '#row_type' => 'add_new_field',
+ '#region_callback' => 'field_ui_field_overview_row_region',
+ 'label' => array(
+ '#type' => 'textfield',
+ '#title' => t('Existing field label'),
+ '#title_display' => 'invisible',
+ '#size' => 15,
+ '#description' => t('Label'),
+ '#attributes' => array('class' => array('label-textfield')),
+ '#prefix' => '<div class="label-input"><div class="add-new-placeholder">' . t('Add existing field') .'</div>',
+ '#suffix' => '</div>',
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#default_value' => $max_weight + 2,
+ '#size' => 3,
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for added field'),
+ '#attributes' => array('class' => array('field-weight')),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Parent for existing field'),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'field_name' => array(
+ '#type' => 'select',
+ '#title' => t('Existing field to share'),
+ '#title_display' => 'invisible',
+ '#options' => $existing_field_options,
+ '#empty_option' => t('- Select an existing field -'),
+ '#description' => t('Field to share'),
+ '#attributes' => array('class' => array('field-select')),
+ '#cell_attributes' => array('colspan' => 2),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ 'widget_type' => array(
+ '#type' => 'select',
+ '#title' => t('Widget for existing field'),
+ '#title_display' => 'invisible',
+ '#options' => $widget_type_options,
+ '#empty_option' => t('- Select a widget -'),
+ '#description' => t('Form element to edit the data.'),
+ '#attributes' => array('class' => array('widget-type-select')),
+ '#cell_attributes' => array('colspan' => 3),
+ '#prefix' => '<div class="add-new-placeholder">&nbsp;</div>',
+ ),
+ );
+ }
+ $form['fields'] = $table;
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+
+ $form['#attached']['css'][] = drupal_get_path('module', 'field_ui') . '/field_ui.css';
+ $form['#attached']['js'][] = drupal_get_path('module', 'field_ui') . '/field_ui.js';
+
+ // Add settings for the update selects behavior.
+ $js_fields = array();
+ foreach ($existing_field_options as $field_name => $fields) {
+ $field = field_info_field($field_name);
+ $instance = field_info_instance($form['#entity_type'], $field_name, $form['#bundle']);
+ $js_fields[$field_name] = array('label' => $instance['label'], 'type' => $field['type'], 'widget' => $instance['widget']['type']);
+ }
+
+ $form['#attached']['js'][] = array(
+ 'type' => 'setting',
+ 'data' => array('fields' => $js_fields, 'fieldWidgetTypes' => field_ui_widget_type_options()),
+ );
+
+ // Add tabledrag behavior.
+ $form['#attached']['drupal_add_tabledrag'][] = array('field-overview', 'order', 'sibling', 'field-weight');
+ $form['#attached']['drupal_add_tabledrag'][] = array('field-overview', 'match', 'parent', 'field-parent', 'field-parent', 'field-name');
+
+ return $form;
+}
+
+/**
+ * Validate handler for the field overview form.
+ */
+function field_ui_field_overview_form_validate($form, &$form_state) {
+ _field_ui_field_overview_form_validate_add_new($form, $form_state);
+ _field_ui_field_overview_form_validate_add_existing($form, $form_state);
+}
+
+/**
+ * Helper function for field_ui_field_overview_form_validate.
+ *
+ * Validate the 'add new field' row.
+ */
+function _field_ui_field_overview_form_validate_add_new($form, &$form_state) {
+ $field = $form_state['values']['fields']['_add_new_field'];
+
+ // Validate if any information was provided in the 'add new field' row.
+ if (array_filter(array($field['label'], $field['field_name'], $field['type'], $field['widget_type']))) {
+ // Missing label.
+ if (!$field['label']) {
+ form_set_error('fields][_add_new_field][label', t('Add new field: you need to provide a label.'));
+ }
+
+ // Missing field name.
+ if (!$field['field_name']) {
+ form_set_error('fields][_add_new_field][field_name', t('Add new field: you need to provide a field name.'));
+ }
+ // Field name validation.
+ else {
+ $field_name = $field['field_name'];
+
+ // Add the 'field_' prefix.
+ if (substr($field_name, 0, 6) != 'field_') {
+ $field_name = 'field_' . $field_name;
+ form_set_value($form['fields']['_add_new_field']['field_name'], $field_name, $form_state);
+ }
+
+ // Invalid field name.
+ if (!preg_match('!^field_[a-z0-9_]+$!', $field_name)) {
+ form_set_error('fields][_add_new_field][field_name', t('Add new field: the field name %field_name is invalid. The name must include only lowercase unaccentuated letters, numbers, and underscores.', array('%field_name' => $field_name)));
+ }
+ if (strlen($field_name) > 32) {
+ form_set_error('fields][_add_new_field][field_name', t("Add new field: the field name %field_name is too long. The name is limited to 32 characters, including the 'field_' prefix.", array('%field_name' => $field_name)));
+ }
+
+ // Field name already exists. We need to check inactive fields as well, so
+ // we can't use field_info_fields().
+ $fields = field_read_fields(array('field_name' => $field_name), array('include_inactive' => TRUE));
+ if ($fields) {
+ form_set_error('fields][_add_new_field][field_name', t('Add new field: the field name %field_name already exists.', array('%field_name' => $field_name)));
+ }
+ }
+
+ // Missing field type.
+ if (!$field['type']) {
+ form_set_error('fields][_add_new_field][type', t('Add new field: you need to select a field type.'));
+ }
+
+ // Missing widget type.
+ if (!$field['widget_type']) {
+ form_set_error('fields][_add_new_field][widget_type', t('Add new field: you need to select a widget.'));
+ }
+ // Wrong widget type.
+ elseif ($field['type']) {
+ $widget_types = field_ui_widget_type_options($field['type']);
+ if (!isset($widget_types[$field['widget_type']])) {
+ form_set_error('fields][_add_new_field][widget_type', t('Add new field: invalid widget.'));
+ }
+ }
+ }
+}
+
+/**
+ * Helper function for field_ui_field_overview_form_validate.
+ *
+ * Validate the 'add existing field' row.
+ */
+function _field_ui_field_overview_form_validate_add_existing($form, &$form_state) {
+ // The form element might be absent if no existing fields can be added to
+ // this bundle.
+ if (isset($form_state['values']['fields']['_add_existing_field'])) {
+ $field = $form_state['values']['fields']['_add_existing_field'];
+
+ // Validate if any information was provided in the 'add existing field' row.
+ if (array_filter(array($field['label'], $field['field_name'], $field['widget_type']))) {
+ // Missing label.
+ if (!$field['label']) {
+ form_set_error('fields][_add_existing_field][label', t('Add existing field: you need to provide a label.'));
+ }
+
+ // Missing existing field name.
+ if (!$field['field_name']) {
+ form_set_error('fields][_add_existing_field][field_name', t('Add existing field: you need to select a field.'));
+ }
+
+ // Missing widget type.
+ if (!$field['widget_type']) {
+ form_set_error('fields][_add_existing_field][widget_type', t('Add existing field: you need to select a widget.'));
+ }
+ // Wrong widget type.
+ elseif ($field['field_name'] && ($existing_field = field_info_field($field['field_name']))) {
+ $widget_types = field_ui_widget_type_options($existing_field['type']);
+ if (!isset($widget_types[$field['widget_type']])) {
+ form_set_error('fields][_add_existing_field][widget_type', t('Add existing field: invalid widget.'));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Submit handler for the field overview form.
+ */
+function field_ui_field_overview_form_submit($form, &$form_state) {
+ $form_values = $form_state['values']['fields'];
+ $entity_type = $form['#entity_type'];
+ $bundle = $form['#bundle'];
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+
+ $bundle_settings = field_bundle_settings($entity_type, $bundle);
+
+ // Update field weights.
+ foreach ($form_values as $key => $values) {
+ if (in_array($key, $form['#fields'])) {
+ $instance = field_read_instance($entity_type, $key, $bundle);
+ $instance['widget']['weight'] = $values['weight'];
+ field_update_instance($instance);
+ }
+ elseif (in_array($key, $form['#extra'])) {
+ $bundle_settings['extra_fields']['form'][$key]['weight'] = $values['weight'];
+ }
+ }
+
+ field_bundle_settings($entity_type, $bundle, $bundle_settings);
+
+ $destinations = array();
+
+ // Create new field.
+ $field = array();
+ if (!empty($form_values['_add_new_field']['field_name'])) {
+ $values = $form_values['_add_new_field'];
+
+ $field = array(
+ 'field_name' => $values['field_name'],
+ 'type' => $values['type'],
+ 'translatable' => $values['translatable'],
+ );
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => $entity_type,
+ 'bundle' => $bundle,
+ 'label' => $values['label'],
+ 'widget' => array(
+ 'type' => $values['widget_type'],
+ 'weight' => $values['weight'],
+ ),
+ );
+
+ // Create the field and instance.
+ try {
+ field_create_field($field);
+ field_create_instance($instance);
+
+ $destinations[] = $admin_path . '/fields/' . $field['field_name'] . '/field-settings';
+ $destinations[] = $admin_path . '/fields/' . $field['field_name'];
+
+ // Store new field information for any additional submit handlers.
+ $form_state['fields_added']['_add_new_field'] = $field['field_name'];
+ }
+ catch (Exception $e) {
+ drupal_set_message(t('There was a problem creating field %label: @message.', array('%label' => $instance['label'], '@message' => $e->getMessage())), 'error');
+ }
+ }
+
+ // Add existing field.
+ if (!empty($form_values['_add_existing_field']['field_name'])) {
+ $values = $form_values['_add_existing_field'];
+ $field = field_info_field($values['field_name']);
+ if (!empty($field['locked'])) {
+ drupal_set_message(t('The field %label cannot be added because it is locked.', array('%label' => $values['label'])));
+ }
+ else {
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => $entity_type,
+ 'bundle' => $bundle,
+ 'label' => $values['label'],
+ 'widget' => array(
+ 'type' => $values['widget_type'],
+ 'weight' => $values['weight'],
+ ),
+ );
+
+ try {
+ field_create_instance($instance);
+ $destinations[] = $admin_path . '/fields/' . $instance['field_name'] . '/edit';
+ // Store new field information for any additional submit handlers.
+ $form_state['fields_added']['_add_existing_field'] = $instance['field_name'];
+ }
+ catch (Exception $e) {
+ drupal_set_message(t('There was a problem creating field instance %label: @message.', array('%label' => $instance['label'], '@message' => $e->getMessage())), 'error');
+ }
+ }
+ }
+
+ if ($destinations) {
+ $destination = drupal_get_destination();
+ $destinations[] = $destination['destination'];
+ unset($_GET['destination']);
+ $form_state['redirect'] = field_ui_get_destinations($destinations);
+ }
+ else {
+ drupal_set_message(t('Your settings have been saved.'));
+ }
+}
+
+/**
+ * Menu callback; presents field display settings for a given view mode.
+ */
+function field_ui_display_overview_form($form, &$form_state, $entity_type, $bundle, $view_mode) {
+ $bundle = field_extract_bundle($entity_type, $bundle);
+
+ field_ui_inactive_message($entity_type, $bundle);
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+
+ // Gather type information.
+ $instances = field_info_instances($entity_type, $bundle);
+ $field_types = field_info_field_types();
+ $extra_fields = field_info_extra_fields($entity_type, $bundle, 'display');
+
+ $form_state += array(
+ 'formatter_settings_edit' => NULL,
+ );
+
+ $form += array(
+ '#entity_type' => $entity_type,
+ '#bundle' => $bundle,
+ '#view_mode' => $view_mode,
+ '#fields' => array_keys($instances),
+ '#extra' => array_keys($extra_fields),
+ );
+
+ if (empty($instances) && empty($extra_fields)) {
+ drupal_set_message(t('There are no fields yet added. You can add new fields on the <a href="@link">Manage fields</a> page.', array('@link' => url($admin_path . '/fields'))), 'warning');
+ return $form;
+ }
+
+ $table = array(
+ '#type' => 'field_ui_table',
+ '#tree' => TRUE,
+ '#header' => array(
+ t('Field'),
+ t('Weight'),
+ t('Parent'),
+ t('Label'),
+ array('data' => t('Format'), 'colspan' => 3),
+ ),
+ '#regions' => array(
+ 'visible' => array('message' => t('No field is displayed.')),
+ 'hidden' => array('title' => t('Hidden'), 'message' => t('No field is hidden.')),
+ ),
+ '#parent_options' => array(),
+ '#attributes' => array(
+ 'class' => array('field-ui-overview'),
+ 'id' => 'field-display-overview',
+ ),
+ // Add Ajax wrapper.
+ '#prefix' => '<div id="field-display-overview-wrapper">',
+ '#suffix' => '</div>',
+ );
+
+ $field_label_options = array(
+ 'above' => t('Above'),
+ 'inline' => t('Inline'),
+ 'hidden' => t('<Hidden>'),
+ );
+ $extra_visibility_options = array(
+ 'visible' => t('Visible'),
+ 'hidden' => t('Hidden'),
+ );
+
+ // Field rows.
+ foreach ($instances as $name => $instance) {
+ $field = field_info_field($instance['field_name']);
+ $display = $instance['display'][$view_mode];
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf')),
+ '#row_type' => 'field',
+ '#region_callback' => 'field_ui_display_overview_row_region',
+ '#js_settings' => array(
+ 'rowHandler' => 'field',
+ 'defaultFormatter' => $field_types[$field['type']]['default_formatter'],
+ ),
+ 'human_name' => array(
+ '#markup' => check_plain($instance['label']),
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#title' => t('Weight for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#default_value' => $display['weight'],
+ '#size' => 3,
+ '#attributes' => array('class' => array('field-weight')),
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Label display for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'label' => array(
+ '#type' => 'select',
+ '#title' => t('Label display for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $field_label_options,
+ '#default_value' => $display['label'],
+ ),
+ );
+
+ $formatter_options = field_ui_formatter_options($field['type']);
+ $formatter_options['hidden'] = t('<Hidden>');
+ $table[$name]['format'] = array(
+ 'type' => array(
+ '#type' => 'select',
+ '#title' => t('Formatter for @title', array('@title' => $instance['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $formatter_options,
+ '#default_value' => $display['type'],
+ '#parents' => array('fields', $name, 'type'),
+ '#attributes' => array('class' => array('field-formatter-type')),
+ ),
+ 'settings_edit_form' => array(),
+ );
+
+ // Formatter settings.
+
+ // Check the currently selected formatter, and merge persisted values for
+ // formatter settings.
+ if (isset($form_state['values']['fields'][$name]['type'])) {
+ $formatter_type = $form_state['values']['fields'][$name]['type'];
+ }
+ else {
+ $formatter_type = $display['type'];
+ }
+ if (isset($form_state['formatter_settings'][$name])) {
+ $settings = $form_state['formatter_settings'][$name];
+ }
+ else {
+ $settings = $display['settings'];
+ }
+ $settings += field_info_formatter_settings($formatter_type);
+
+ $instance['display'][$view_mode]['type'] = $formatter_type;
+ $formatter = field_info_formatter_types($formatter_type);
+ $instance['display'][$view_mode]['module'] = $formatter['module'];
+ $instance['display'][$view_mode]['settings'] = $settings;
+
+ // Base button element for the various formatter settings actions.
+ $base_button = array(
+ '#submit' => array('field_ui_display_overview_multistep_submit'),
+ '#ajax' => array(
+ 'callback' => 'field_ui_display_overview_multistep_js',
+ 'wrapper' => 'field-display-overview-wrapper',
+ 'effect' => 'fade',
+ ),
+ '#field_name' => $name,
+ );
+
+ if ($form_state['formatter_settings_edit'] == $name) {
+ // We are currently editing this field's formatter settings. Display the
+ // settings form and submit buttons.
+ $table[$name]['format']['settings_edit_form'] = array();
+
+ $settings_form = array();
+ $function = $formatter['module'] . '_field_formatter_settings_form';
+ if (function_exists($function)) {
+ $settings_form = $function($field, $instance, $view_mode, $form, $form_state);
+ }
+
+ if ($settings_form) {
+ $table[$name]['format']['#cell_attributes'] = array('colspan' => 3);
+ $table[$name]['format']['settings_edit_form'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('field-formatter-settings-edit-form')),
+ '#parents' => array('fields', $name, 'settings_edit_form'),
+ 'label' => array(
+ '#markup' => t('Format settings:') . ' <span class="formatter-name">' . $formatter['label'] . '</span>',
+ ),
+ 'settings' => $settings_form,
+ 'actions' => array(
+ '#type' => 'actions',
+ 'save_settings' => $base_button + array(
+ '#type' => 'submit',
+ '#name' => $name . '_formatter_settings_update',
+ '#value' => t('Update'),
+ '#op' => 'update',
+ ),
+ 'cancel_settings' => $base_button + array(
+ '#type' => 'submit',
+ '#name' => $name . '_formatter_settings_cancel',
+ '#value' => t('Cancel'),
+ '#op' => 'cancel',
+ // Do not check errors for the 'Cancel' button, but make sure we
+ // get the value of the 'formatter type' select.
+ '#limit_validation_errors' => array(array('fields', $name, 'type')),
+ ),
+ ),
+ );
+ $table[$name]['#attributes']['class'][] = 'field-formatter-settings-editing';
+ }
+ }
+ else {
+ // Display a summary of the current formatter settings.
+ $summary = module_invoke($formatter['module'], 'field_formatter_settings_summary', $field, $instance, $view_mode);
+ $table[$name]['settings_summary'] = array();
+ $table[$name]['settings_edit'] = array();
+ if ($summary) {
+ $table[$name]['settings_summary'] = array(
+ '#markup' => '<div class="field-formatter-summary">' . $summary . '</div>',
+ '#cell_attributes' => array('class' => array('field-formatter-summary-cell')),
+ );
+ $table[$name]['settings_edit'] = $base_button + array(
+ '#type' => 'image_button',
+ '#name' => $name . '_formatter_settings_edit',
+ '#src' => 'core/misc/configure.png',
+ '#attributes' => array('class' => array('field-formatter-settings-edit'), 'alt' => t('Edit')),
+ '#op' => 'edit',
+ // Do not check errors for the 'Edit' button, but make sure we get
+ // the value of the 'formatter type' select.
+ '#limit_validation_errors' => array(array('fields', $name, 'type')),
+ '#prefix' => '<div class="field-formatter-settings-edit-wrapper">',
+ '#suffix' => '</div>',
+ );
+ }
+ }
+ }
+
+ // Non-field elements.
+ foreach ($extra_fields as $name => $extra_field) {
+ $display = $extra_field['display'][$view_mode];
+ $table[$name] = array(
+ '#attributes' => array('class' => array('draggable', 'tabledrag-leaf')),
+ '#row_type' => 'extra_field',
+ '#region_callback' => 'field_ui_display_overview_row_region',
+ '#js_settings' => array('rowHandler' => 'field'),
+ 'human_name' => array(
+ '#markup' => check_plain($extra_field['label']),
+ ),
+ 'weight' => array(
+ '#type' => 'textfield',
+ '#title' => t('Weight for @title', array('@title' => $extra_field['label'])),
+ '#title_display' => 'invisible',
+ '#default_value' => $display['weight'],
+ '#size' => 3,
+ '#attributes' => array('class' => array('field-weight')),
+ ),
+ 'parent_wrapper' => array(
+ 'parent' => array(
+ '#type' => 'select',
+ '#title' => t('Parents for @title', array('@title' => $extra_field['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $table['#parent_options'],
+ '#empty_value' => '',
+ '#attributes' => array('class' => array('field-parent')),
+ '#parents' => array('fields', $name, 'parent'),
+ ),
+ 'hidden_name' => array(
+ '#type' => 'hidden',
+ '#default_value' => $name,
+ '#attributes' => array('class' => array('field-name')),
+ ),
+ ),
+ 'empty_cell' => array(
+ '#markup' => '&nbsp;',
+ ),
+ 'format' => array(
+ 'type' => array(
+ '#type' => 'select',
+ '#title' => t('Visibility for @title', array('@title' => $extra_field['label'])),
+ '#title_display' => 'invisible',
+ '#options' => $extra_visibility_options,
+ '#default_value' => $display['visible'] ? 'visible' : 'hidden',
+ '#parents' => array('fields', $name, 'type'),
+ '#attributes' => array('class' => array('field-formatter-type')),
+ ),
+ ),
+ 'settings_summary' => array(),
+ 'settings_edit' => array(),
+ );
+ }
+
+ $form['fields'] = $table;
+
+ // Custom display settings.
+ if ($view_mode == 'default') {
+ $form['modes'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Custom display settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ );
+ // Collect options and default values for the 'Custom display settings'
+ // checkboxes.
+ $options = array();
+ $default = array();
+ $entity_info = entity_get_info($entity_type);
+ $view_modes = $entity_info['view modes'];
+ $view_mode_settings = field_view_mode_settings($entity_type, $bundle);
+ foreach ($view_modes as $view_mode_name => $view_mode_info) {
+ $options[$view_mode_name] = $view_mode_info['label'];
+ if (!empty($view_mode_settings[$view_mode_name]['custom_settings'])) {
+ $default[] = $view_mode_name;
+ }
+ }
+ $form['modes']['view_modes_custom'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Use custom display settings for the following view modes'),
+ '#options' => $options,
+ '#default_value' => $default,
+ );
+ }
+
+ // In overviews involving nested rows from contributed modules (i.e
+ // field_group), the 'format type' selects can trigger a series of changes in
+ // child rows. The #ajax behavior is therefore not attached directly to the
+ // selects, but triggered by the client-side script through a hidden #ajax
+ // 'Refresh' button. A hidden 'refresh_rows' input tracks the name of
+ // affected rows.
+ $form['refresh_rows'] = array('#type' => 'hidden');
+ $form['refresh'] = array(
+ '#type' => 'submit',
+ '#value' => t('Refresh'),
+ '#op' => 'refresh_table',
+ '#submit' => array('field_ui_display_overview_multistep_submit'),
+ '#ajax' => array(
+ 'callback' => 'field_ui_display_overview_multistep_js',
+ 'wrapper' => 'field-display-overview-wrapper',
+ 'effect' => 'fade',
+ // The button stays hidden, so we hide the Ajax spinner too. Ad-hoc
+ // spinners will be added manually by the client-side script.
+ 'progress' => 'none',
+ ),
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+
+ $form['#attached']['js'][] = drupal_get_path('module', 'field_ui') . '/field_ui.js';
+ $form['#attached']['css'][] = drupal_get_path('module', 'field_ui') . '/field_ui.css';
+
+ // Add tabledrag behavior.
+ $form['#attached']['drupal_add_tabledrag'][] = array('field-display-overview', 'order', 'sibling', 'field-weight');
+ $form['#attached']['drupal_add_tabledrag'][] = array('field-display-overview', 'match', 'parent', 'field-parent', 'field-parent', 'field-name');
+
+ return $form;
+}
+
+
+/**
+ * Form submit handler for multistep buttons on the 'Manage display' screen.
+ */
+function field_ui_display_overview_multistep_submit($form, &$form_state) {
+ $trigger = $form_state['triggering_element'];
+ $op = $trigger['#op'];
+
+ switch ($op) {
+ case 'edit':
+ // Store the field whose settings are currently being edited.
+ $field_name = $trigger['#field_name'];
+ $form_state['formatter_settings_edit'] = $field_name;
+ break;
+
+ case 'update':
+ // Store the saved settings, and set the field back to 'non edit' mode.
+ $field_name = $trigger['#field_name'];
+ $values = $form_state['values']['fields'][$field_name]['settings_edit_form']['settings'];
+ $form_state['formatter_settings'][$field_name] = $values;
+ unset($form_state['formatter_settings_edit']);
+ break;
+
+ case 'cancel':
+ // Set the field back to 'non edit' mode.
+ unset($form_state['formatter_settings_edit']);
+ break;
+
+ case 'refresh_table':
+ // If the currently edited field is one of the rows to be refreshed, set
+ // it back to 'non edit' mode.
+ $updated_rows = explode(' ', $form_state['values']['refresh_rows']);
+ if (isset($form_state['formatter_settings_edit']) && in_array($form_state['formatter_settings_edit'], $updated_rows)) {
+ unset($form_state['formatter_settings_edit']);
+ }
+ break;
+ }
+
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Ajax handler for multistep buttons on the 'Manage display' screen.
+ */
+function field_ui_display_overview_multistep_js($form, &$form_state) {
+ $trigger = $form_state['triggering_element'];
+ $op = $trigger['#op'];
+
+ // Pick the elements that need ro receive the ajax-new-content effect.
+ switch ($op) {
+ case 'edit':
+ $updated_rows = array($trigger['#field_name']);
+ $updated_columns = array('format');
+ break;
+
+ case 'update':
+ case 'cancel':
+ $updated_rows = array($trigger['#field_name']);
+ $updated_columns = array('format', 'settings_summary', 'settings_edit');
+ break;
+
+ case 'refresh_table':
+ $updated_rows = array_values(explode(' ', $form_state['values']['refresh_rows']));
+ $updated_columns = array('settings_summary', 'settings_edit');
+ break;
+ }
+
+ foreach ($updated_rows as $name) {
+ foreach ($updated_columns as $key) {
+ $element = &$form['fields'][$name][$key];
+ $element['#prefix'] = '<div class="ajax-new-content">' . (isset($element['#prefix']) ? $element['#prefix'] : '');
+ $element['#suffix'] = (isset($element['#suffix']) ? $element['#suffix'] : '') . '</div>';
+ }
+ }
+
+ // Return the whole table.
+ return $form['fields'];
+}
+
+/**
+ * Submit handler for the display overview form.
+ */
+function field_ui_display_overview_form_submit($form, &$form_state) {
+ $form_values = $form_state['values'];
+ $entity_type = $form['#entity_type'];
+ $bundle = $form['#bundle'];
+ $view_mode = $form['#view_mode'];
+
+ // Save data for 'regular' fields.
+ foreach ($form['#fields'] as $field_name) {
+ // Retrieve the stored instance settings to merge with the incoming values.
+ $instance = field_read_instance($entity_type, $field_name, $bundle);
+ $values = $form_values['fields'][$field_name];
+ // Get formatter settings. They lie either directly in submitted form
+ // values (if the whole form was submitted while some formatter
+ // settings were being edited), or have been persisted in
+ // $form_state.
+ $settings = array();
+ if (isset($values['settings_edit_form']['settings'])) {
+ $settings = $values['settings_edit_form']['settings'];
+ }
+ elseif (isset($form_state['formatter_settings'][$field_name])) {
+ $settings = $form_state['formatter_settings'][$field_name];
+ }
+ elseif (isset($instance['display'][$view_mode]['settings'])) {
+ $settings = $instance['display'][$view_mode]['settings'];
+ }
+
+ // Only save settings actually used by the selected formatter.
+ $default_settings = field_info_formatter_settings($values['type']);
+ $settings = array_intersect_key($settings, $default_settings);
+
+ $instance['display'][$view_mode] = array(
+ 'label' => $values['label'],
+ 'type' => $values['type'],
+ 'weight' => $values['weight'],
+ 'settings' => $settings,
+ );
+ field_update_instance($instance);
+ }
+
+ // Get current bundle settings.
+ $bundle_settings = field_bundle_settings($entity_type, $bundle);
+
+ // Save data for 'extra' fields.
+ foreach ($form['#extra'] as $name) {
+ $bundle_settings['extra_fields']['display'][$name][$view_mode] = array(
+ 'weight' => $form_values['fields'][$name]['weight'],
+ 'visible' => $form_values['fields'][$name]['type'] == 'visible',
+ );
+ }
+
+ // Save view modes data.
+ if ($view_mode == 'default') {
+ $entity_info = entity_get_info($entity_type);
+ foreach ($form_values['view_modes_custom'] as $view_mode_name => $value) {
+ // Display a message for each view mode newly configured to use custom
+ // settings.
+ $view_mode_settings = field_view_mode_settings($entity_type, $bundle);
+ if (!empty($value) && empty($view_mode_settings[$view_mode_name]['custom_settings'])) {
+ $view_mode_label = $entity_info['view modes'][$view_mode_name]['label'];
+ $path = _field_ui_bundle_admin_path($entity_type, $bundle) . "/display/$view_mode_name";
+ drupal_set_message(t('The %view_mode mode now uses custom display settings. You might want to <a href="@url">configure them</a>.', array('%view_mode' => $view_mode_label, '@url' => url($path))));
+ // Initialize the newly customized view mode with the display settings
+ // from the default view mode.
+ _field_ui_add_default_view_mode_settings($entity_type, $bundle, $view_mode_name, $bundle_settings);
+ }
+ $bundle_settings['view_modes'][$view_mode_name]['custom_settings'] = !empty($value);
+ }
+ }
+
+ // Save updated bundle settings.
+ field_bundle_settings($entity_type, $bundle, $bundle_settings);
+
+ drupal_set_message(t('Your settings have been saved.'));
+}
+
+/**
+ * Helper function for field_ui_display_overview_form_submit().
+ *
+ * When an administrator decides to use custom display settings for a view mode,
+ * that view mode needs to be initialized with the display settings for the
+ * 'default' view mode, which it was previously using. This helper function
+ * adds the new custom display settings to this bundle's instances, and saves
+ * them. It also modifies the passed-in $settings array, which the caller can
+ * then save using field_bundle_settings().
+ *
+ * @see field_bundle_settings()
+ *
+ * @param $entity_type
+ * The bundle's entity type.
+ * @param $bundle
+ * The bundle whose view mode is being customized.
+ * @param $view_mode
+ * The view mode that the administrator has set to use custom settings.
+ * @param $settings
+ * An associative array of bundle settings, as expected by
+ * field_bundle_settings().
+ */
+function _field_ui_add_default_view_mode_settings($entity_type, $bundle, $view_mode, &$settings) {
+ // Update display settings for field instances.
+ $instances = field_read_instances(array('entity_type' => $entity_type, 'bundle' => $bundle));
+ foreach ($instances as $instance) {
+ // If this field instance has display settings defined for this view mode,
+ // respect those settings.
+ if (!isset($instance['display'][$view_mode])) {
+ // The instance doesn't specify anything for this view mode, so use the
+ // default display settings.
+ $instance['display'][$view_mode] = $instance['display']['default'];
+ field_update_instance($instance);
+ }
+ }
+
+ // Update display settings for 'extra fields'.
+ foreach (array_keys($settings['extra_fields']['display']) as $name) {
+ if (!isset($settings['extra_fields']['display'][$name][$view_mode])) {
+ $settings['extra_fields']['display'][$name][$view_mode] = $settings['extra_fields']['display'][$name]['default'];
+ }
+ }
+}
+
+/**
+ * Return an array of field_type options.
+ */
+function field_ui_field_type_options() {
+ $options = &drupal_static(__FUNCTION__);
+
+ if (!isset($options)) {
+ $options = array();
+ $field_types = field_info_field_types();
+ $field_type_options = array();
+ foreach ($field_types as $name => $field_type) {
+ // Skip field types which have no widget types, or should not be add via
+ // uesr interface.
+ if (field_ui_widget_type_options($name) && empty($field_type['no_ui'])) {
+ $options[$name] = $field_type['label'];
+ }
+ }
+ asort($options);
+ }
+ return $options;
+}
+
+/**
+ * Return an array of widget type options for a field type.
+ *
+ * If no field type is provided, returns a nested array of all widget types,
+ * keyed by field type human name.
+ */
+function field_ui_widget_type_options($field_type = NULL, $by_label = FALSE) {
+ $options = &drupal_static(__FUNCTION__);
+
+ if (!isset($options)) {
+ $options = array();
+ $field_types = field_info_field_types();
+ foreach (field_info_widget_types() as $name => $widget_type) {
+ foreach ($widget_type['field types'] as $widget_field_type) {
+ // Check that the field type exists.
+ if (isset($field_types[$widget_field_type])) {
+ $options[$widget_field_type][$name] = $widget_type['label'];
+ }
+ }
+ }
+ }
+
+ if (isset($field_type)) {
+ return !empty($options[$field_type]) ? $options[$field_type] : array();
+ }
+ if ($by_label) {
+ $field_types = field_info_field_types();
+ $options_by_label = array();
+ foreach ($options as $field_type => $widgets) {
+ $options_by_label[$field_types[$field_type]['label']] = $widgets;
+ }
+ return $options_by_label;
+ }
+ return $options;
+}
+
+/**
+ * Return an array of formatter options for a field type.
+ *
+ * If no field type is provided, returns a nested array of all formatters, keyed
+ * by field type.
+ */
+function field_ui_formatter_options($field_type = NULL) {
+ $options = &drupal_static(__FUNCTION__);
+
+ if (!isset($options)) {
+ $field_types = field_info_field_types();
+ $options = array();
+ foreach (field_info_formatter_types() as $name => $formatter) {
+ foreach ($formatter['field types'] as $formatter_field_type) {
+ // Check that the field type exists.
+ if (isset($field_types[$formatter_field_type])) {
+ $options[$formatter_field_type][$name] = $formatter['label'];
+ }
+ }
+ }
+ }
+
+ if ($field_type) {
+ return !empty($options[$field_type]) ? $options[$field_type] : array();
+ }
+ return $options;
+}
+
+/**
+ * Return an array of existing field to be added to a bundle.
+ */
+function field_ui_existing_field_options($entity_type, $bundle) {
+ $options = array();
+ $field_types = field_info_field_types();
+
+ foreach (field_info_instances() as $existing_entity_type => $bundles) {
+ foreach ($bundles as $existing_bundle => $instances) {
+ // No need to look in the current bundle.
+ if (!($existing_bundle == $bundle && $existing_entity_type == $entity_type)) {
+ foreach ($instances as $instance) {
+ $field = field_info_field($instance['field_name']);
+ // Don't show
+ // - locked fields,
+ // - fields already in the current bundle,
+ // - fields that cannot be added to the entity type,
+ // - fields that that shoud not be added via user interface.
+
+ if (empty($field['locked'])
+ && !field_info_instance($entity_type, $field['field_name'], $bundle)
+ && (empty($field['entity_types']) || in_array($entity_type, $field['entity_types']))
+ && empty($field_types[$field['type']]['no_ui'])) {
+ $text = t('@type: @field (@label)', array(
+ '@type' => $field_types[$field['type']]['label'],
+ '@label' => t($instance['label']), '@field' => $instance['field_name'],
+ ));
+ $options[$instance['field_name']] = (drupal_strlen($text) > 80 ? truncate_utf8($text, 77) . '...' : $text);
+ }
+ }
+ }
+ }
+ }
+ // Sort the list by field name.
+ asort($options);
+ return $options;
+}
+
+/**
+ * Menu callback; presents the field settings edit page.
+ */
+function field_ui_field_settings_form($form, &$form_state, $instance) {
+ $bundle = $instance['bundle'];
+ $entity_type = $instance['entity_type'];
+ $field = field_info_field($instance['field_name']);
+
+ drupal_set_title($instance['label']);
+
+ $description = '<p>' . t('These settings apply to the %field field everywhere it is used. These settings impact the way that data is stored in the database and cannot be changed once data has been created.', array('%field' => $instance['label'])) . '</p>';
+
+ // Create a form structure for the field values.
+ $form['field'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Field settings'),
+ '#description' => $description,
+ '#tree' => TRUE,
+ );
+
+ // See if data already exists for this field.
+ // If so, prevent changes to the field settings.
+ $has_data = field_has_data($field);
+ if ($has_data) {
+ $form['field']['#description'] = '<div class="messages error">' . t('There is data for this field in the database. The field settings can no longer be changed.') . '</div>' . $form['field']['#description'];
+ }
+
+ // Build the non-configurable field values.
+ $form['field']['field_name'] = array('#type' => 'value', '#value' => $field['field_name']);
+ $form['field']['type'] = array('#type' => 'value', '#value' => $field['type']);
+ $form['field']['module'] = array('#type' => 'value', '#value' => $field['module']);
+ $form['field']['active'] = array('#type' => 'value', '#value' => $field['active']);
+
+ // Add settings provided by the field module. The field module is
+ // responsible for not returning settings that cannot be changed if
+ // the field already has data.
+ $form['field']['settings'] = array();
+ $additions = module_invoke($field['module'], 'field_settings_form', $field, $instance, $has_data);
+ if (is_array($additions)) {
+ $form['field']['settings'] = $additions;
+ }
+ if (empty($form['field']['settings'])) {
+ $form['field']['settings'] = array(
+ '#markup' => t('%field has no field settings.', array('%field' => $instance['label'])),
+ );
+ }
+ $form['#entity_type'] = $entity_type;
+ $form['#bundle'] = $bundle;
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save field settings'));
+ return $form;
+}
+
+/**
+ * Save a field's settings after editing.
+ */
+function field_ui_field_settings_form_submit($form, &$form_state) {
+ $form_values = $form_state['values'];
+ $field_values = $form_values['field'];
+
+ // Merge incoming form values into the existing field.
+ $field = field_info_field($field_values['field_name']);
+
+ $entity_type = $form['#entity_type'];
+ $bundle = $form['#bundle'];
+ $instance = field_info_instance($entity_type, $field['field_name'], $bundle);
+
+ // Update the field.
+ $field = array_merge($field, $field_values);
+
+ try {
+ field_update_field($field);
+ drupal_set_message(t('Updated field %label field settings.', array('%label' => $instance['label'])));
+ $form_state['redirect'] = field_ui_next_destination($entity_type, $bundle);
+ }
+ catch (Exception $e) {
+ drupal_set_message(t('Attempt to update field %label failed: %message.', array('%label' => $instance['label'], '%message' => $e->getMessage())), 'error');
+ }
+}
+
+/**
+ * Menu callback; select a widget for the field.
+ */
+function field_ui_widget_type_form($form, &$form_state, $instance) {
+ drupal_set_title($instance['label']);
+
+ $bundle = $instance['bundle'];
+ $entity_type = $instance['entity_type'];
+ $field_name = $instance['field_name'];
+
+ $field = field_info_field($field_name);
+ $field_type = field_info_field_types($field['type']);
+ $widget_type = field_info_widget_types($instance['widget']['type']);
+ $bundles = field_info_bundles();
+ $bundle_label = $bundles[$entity_type][$bundle]['label'];
+
+ $form = array(
+ '#bundle' => $bundle,
+ '#entity_type' => $entity_type,
+ '#field_name' => $field_name,
+ );
+
+ $form['basic'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Change widget'),
+ );
+ $form['basic']['widget_type'] = array(
+ '#type' => 'select',
+ '#title' => t('Widget type'),
+ '#required' => TRUE,
+ '#options' => field_ui_widget_type_options($field['type']),
+ '#default_value' => $instance['widget']['type'],
+ '#description' => t('The type of form element you would like to present to the user when creating this field in the %type type.', array('%type' => $bundle_label)),
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Continue'));
+
+ $form['#validate'] = array();
+ $form['#submit'] = array('field_ui_widget_type_form_submit');
+
+ return $form;
+}
+
+/**
+ * Submit the change in widget type.
+ */
+function field_ui_widget_type_form_submit($form, &$form_state) {
+ $form_values = $form_state['values'];
+ $bundle = $form['#bundle'];
+ $entity_type = $form['#entity_type'];
+ $field_name = $form['#field_name'];
+
+ // Retrieve the stored instance settings to merge with the incoming values.
+ $instance = field_read_instance($entity_type, $field_name, $bundle);
+
+ // Set the right module information.
+ $widget_type = field_info_widget_types($form_values['widget_type']);
+ $widget_module = $widget_type['module'];
+
+ $instance['widget']['type'] = $form_values['widget_type'];
+ $instance['widget']['module'] = $widget_module;
+
+ try {
+ field_update_instance($instance);
+ drupal_set_message(t('Changed the widget for field %label.', array('%label' => $instance['label'])));
+ }
+ catch (Exception $e) {
+ drupal_set_message(t('There was a problem changing the widget for field %label.', array('%label' => $instance['label'])));
+ }
+
+ $form_state['redirect'] = field_ui_next_destination($entity_type, $bundle);
+}
+
+/**
+ * Menu callback; present a form for removing a field instance from a bundle.
+ */
+function field_ui_field_delete_form($form, &$form_state, $instance) {
+ $bundle = $instance['bundle'];
+ $entity_type = $instance['entity_type'];
+ $field = field_info_field($instance['field_name']);
+
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+
+ $form['entity_type'] = array('#type' => 'value', '#value' => $entity_type);
+ $form['bundle'] = array('#type' => 'value', '#value' => $bundle);
+ $form['field_name'] = array('#type' => 'value', '#value' => $field['field_name']);
+
+ $output = confirm_form($form,
+ t('Are you sure you want to delete the field %field?', array('%field' => $instance['label'])),
+ $admin_path . '/fields',
+ t('If you have any content left in this field, it will be lost. This action cannot be undone.'),
+ t('Delete'), t('Cancel'),
+ 'confirm'
+ );
+
+ if ($field['locked']) {
+ unset($output['actions']['submit']);
+ $output['description']['#markup'] = t('This field is <strong>locked</strong> and cannot be deleted.');
+ }
+
+ return $output;
+}
+
+/**
+ * Removes a field instance from a bundle.
+ *
+ * If the field has no more instances, it will be marked as deleted too.
+ */
+function field_ui_field_delete_form_submit($form, &$form_state) {
+ $form_values = $form_state['values'];
+ $field_name = $form_values['field_name'];
+ $bundle = $form_values['bundle'];
+ $entity_type = $form_values['entity_type'];
+
+ $field = field_info_field($field_name);
+ $instance = field_info_instance($entity_type, $field_name, $bundle);
+ $bundles = field_info_bundles();
+ $bundle_label = $bundles[$entity_type][$bundle]['label'];
+
+ if (!empty($bundle) && $field && !$field['locked'] && $form_values['confirm']) {
+ field_delete_instance($instance);
+ drupal_set_message(t('The field %field has been deleted from the %type content type.', array('%field' => $instance['label'], '%type' => $bundle_label)));
+ }
+ else {
+ drupal_set_message(t('There was a problem removing the %field from the %type content type.', array('%field' => $instance['label'], '%type' => $bundle_label)));
+ }
+
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+ $form_state['redirect'] = field_ui_get_destinations(array($admin_path . '/fields'));
+
+ // Fields are purged on cron. However field module prevents disabling modules
+ // when field types they provided are used in a field until it is fully
+ // purged. In the case that a field has minimal or no content, a single call
+ // to field_purge_batch() will remove it from the system. Call this with a
+ // low batch limit to avoid administrators having to wait for cron runs when
+ // removing instances that meet this criteria.
+ field_purge_batch(10);
+}
+
+/**
+ * Menu callback; presents the field instance edit page.
+ */
+function field_ui_field_edit_form($form, &$form_state, $instance) {
+ $bundle = $instance['bundle'];
+ $entity_type = $instance['entity_type'];
+ $field = field_info_field($instance['field_name']);
+
+ drupal_set_title($instance['label']);
+
+ $form['#field'] = $field;
+ $form['#instance'] = $instance;
+
+ if (!empty($field['locked'])) {
+ $form['locked'] = array(
+ '#markup' => t('The field %field is locked and cannot be edited.', array('%field' => $instance['label'])),
+ );
+ return $form;
+ }
+
+ $field_type = field_info_field_types($field['type']);
+ $widget_type = field_info_widget_types($instance['widget']['type']);
+ $bundles = field_info_bundles();
+
+ // Create a form structure for the instance values.
+ $form['instance'] = array(
+ '#tree' => TRUE,
+ '#type' => 'fieldset',
+ '#title' => t('%type settings', array('%type' => $bundles[$entity_type][$bundle]['label'])),
+ '#description' => t('These settings apply only to the %field field when used in the %type type.', array(
+ '%field' => $instance['label'],
+ '%type' => $bundles[$entity_type][$bundle]['label'],
+ )),
+ // Ensure field_ui_field_edit_instance_pre_render() gets called in addition
+ // to, not instead of, the #pre_render function(s) needed by all fieldsets.
+ '#pre_render' => array_merge(array('field_ui_field_edit_instance_pre_render'), element_info_property('fieldset', '#pre_render', array())),
+ );
+
+ // Build the non-configurable instance values.
+ $form['instance']['field_name'] = array(
+ '#type' => 'value',
+ '#value' => $instance['field_name'],
+ );
+ $form['instance']['entity_type'] = array(
+ '#type' => 'value',
+ '#value' => $entity_type,
+ );
+ $form['instance']['bundle'] = array(
+ '#type' => 'value',
+ '#value' => $bundle,
+ );
+ $form['instance']['widget']['weight'] = array(
+ '#type' => 'value',
+ '#value' => !empty($instance['widget']['weight']) ? $instance['widget']['weight'] : 0,
+ );
+
+ // Build the configurable instance values.
+ $form['instance']['label'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Label'),
+ '#default_value' => !empty($instance['label']) ? $instance['label'] : $field['field_name'],
+ '#required' => TRUE,
+ '#weight' => -20,
+ );
+
+ $form['instance']['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Help text'),
+ '#default_value' => !empty($instance['description']) ? $instance['description'] : '',
+ '#rows' => 5,
+ '#description' => t('Instructions to present to the user below this field on the editing form.<br />Allowed HTML tags: @tags', array('@tags' => _field_filter_xss_display_allowed_tags())),
+ '#weight' => -10,
+ );
+
+ $form['instance']['required'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Required field'),
+ '#default_value' => !empty($instance['required']),
+ '#weight' => -5,
+ );
+
+ // Build the widget component of the instance.
+ $form['instance']['widget']['type'] = array(
+ '#type' => 'value',
+ '#value' => $instance['widget']['type'],
+ );
+ $form['instance']['widget']['module'] = array(
+ '#type' => 'value',
+ '#value' => $widget_type['module'],
+ );
+ $form['instance']['widget']['active'] = array(
+ '#type' => 'value',
+ '#value' => !empty($field['instance']['widget']['active']) ? 1 : 0,
+ );
+
+ // Add additional field instance settings from the field module.
+ $additions = module_invoke($field['module'], 'field_instance_settings_form', $field, $instance);
+ if (is_array($additions)) {
+ $form['instance']['settings'] = $additions;
+ }
+
+ // Add additional widget settings from the widget module.
+ $additions = module_invoke($widget_type['module'], 'field_widget_settings_form', $field, $instance);
+ if (is_array($additions)) {
+ $form['instance']['widget']['settings'] = $additions;
+ $form['instance']['widget']['active']['#value'] = 1;
+ }
+
+ // Add handling for default value if not provided by any other module.
+ if (field_behaviors_widget('default value', $instance) == FIELD_BEHAVIOR_DEFAULT && empty($instance['default_value_function'])) {
+ $form['instance']['default_value_widget'] = field_ui_default_value_widget($field, $instance, $form, $form_state);
+ }
+
+ $has_data = field_has_data($field);
+ if ($has_data) {
+ $description = '<p>' . t('These settings apply to the %field field everywhere it is used. Because the field already has data, some settings can no longer be changed.', array('%field' => $instance['label'])) . '</p>';
+ }
+ else {
+ $description = '<p>' . t('These settings apply to the %field field everywhere it is used.', array('%field' => $instance['label'])) . '</p>';
+ }
+
+ // Create a form structure for the field values.
+ $form['field'] = array(
+ '#type' => 'fieldset',
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#title' => t('Global settings'),
+ '#description' => $description,
+ '#tree' => TRUE,
+ );
+
+ // Build the configurable field values.
+ $description = t('Maximum number of values users can enter for this field.');
+ if (field_behaviors_widget('multiple values', $instance) == FIELD_BEHAVIOR_DEFAULT) {
+ $description .= '<br/>' . t("'Unlimited' will provide an 'Add more' button so the users can add as many values as they like.");
+ }
+ $form['field']['cardinality'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of values'),
+ '#options' => array(FIELD_CARDINALITY_UNLIMITED => t('Unlimited')) + drupal_map_assoc(range(1, 10)),
+ '#default_value' => $field['cardinality'],
+ '#description' => $description,
+ );
+
+ // Add additional field type settings. The field type module is
+ // responsible for not returning settings that cannot be changed if
+ // the field already has data.
+ $additions = module_invoke($field['module'], 'field_settings_form', $field, $instance, $has_data);
+ if (is_array($additions)) {
+ $form['field']['settings'] = $additions;
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save settings')
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete field'),
+ '#submit' => array('field_ui_field_edit_form_delete_submit'),
+ );
+ return $form;
+}
+
+/**
+ * Handle the 'Delete' button on the field instance edit form.
+ */
+function field_ui_field_edit_form_delete_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ }
+ $instance = $form['#instance'];
+ $form_state['redirect'] = array('admin/structure/types/manage/' . $instance['bundle'] . '/fields/' . $instance['field_name'] . '/delete', array('query' => $destination));
+}
+
+/**
+ * Pre-render function for field instance settings.
+ *
+ * Combines the instance, widget, and other settings into a single fieldset so
+ * that elements within each group can be shown at different weights as if they
+ * all had the same parent.
+ */
+function field_ui_field_edit_instance_pre_render($element) {
+ // Merge the widget settings into the main form.
+ if (isset($element['widget']['settings'])) {
+ foreach (element_children($element['widget']['settings']) as $key) {
+ $element['widget_' . $key] = $element['widget']['settings'][$key];
+ }
+ unset($element['widget']['settings']);
+ }
+
+ // Merge the instance settings into the main form.
+ if (isset($element['settings'])) {
+ foreach (element_children($element['settings']) as $key) {
+ $element['instance_' . $key] = $element['settings'][$key];
+ }
+ unset($element['settings']);
+ }
+
+ return $element;
+}
+
+/**
+ * Build default value fieldset.
+ */
+function field_ui_default_value_widget($field, $instance, &$form, &$form_state) {
+ $field_name = $field['field_name'];
+
+ $element = array(
+ '#type' => 'fieldset',
+ '#title' => t('Default value'),
+ '#collapsible' => FALSE,
+ '#tree' => TRUE,
+ '#description' => t('The default value for this field, used when creating new content.'),
+ // Stick to an empty 'parents' on this form in order not to breaks widgets
+ // that do not use field_widget_[field|instance]() and still access
+ // $form_state['field'] directly.
+ '#parents' => array(),
+ );
+
+ // Insert the widget.
+ $items = $instance['default_value'];
+ $instance['required'] = FALSE;
+ $instance['description'] = '';
+
+ // @todo Allow multiple values (requires more work on 'add more' JS handler).
+ $element += field_default_form(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $element, $form_state, 0);
+
+ return $element;
+}
+
+/**
+ * Form validation handler for field instance settings form.
+ */
+function field_ui_field_edit_form_validate($form, &$form_state) {
+ // Take the incoming values as the $instance definition, so that the 'default
+ // value' gets validated using the instance settings being submitted.
+ $instance = $form_state['values']['instance'];
+ $field_name = $instance['field_name'];
+
+ if (isset($form['instance']['default_value_widget'])) {
+ $element = $form['instance']['default_value_widget'];
+
+ $field_state = field_form_get_state($element['#parents'], $field_name, LANGUAGE_NONE, $form_state);
+ $field = $field_state['field'];
+
+ // Extract the 'default value'.
+ $items = array();
+ field_default_extract_form_values(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $element, $form_state);
+
+ // Validate the value and report errors.
+ $errors = array();
+ $function = $field['module'] . '_field_validate';
+ if (function_exists($function)) {
+ $function(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $errors);
+ }
+ if (isset($errors[$field_name][LANGUAGE_NONE])) {
+ // Store reported errors in $form_state.
+ $field_state['errors'] = $errors[$field_name][LANGUAGE_NONE];
+ field_form_set_state($element['#parents'], $field_name, LANGUAGE_NONE, $form_state, $field_state);
+ // Assign reported errors to the correct form element.
+ field_default_form_errors(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $element, $form_state);
+ }
+ }
+}
+
+/**
+ * Form submit handler for field instance settings form.
+ */
+function field_ui_field_edit_form_submit($form, &$form_state) {
+ $instance = $form_state['values']['instance'];
+ $field = $form_state['values']['field'];
+
+ // Update any field settings that have changed.
+ $field_source = field_info_field($instance['field_name']);
+ $field = array_merge($field_source, $field);
+ try {
+ field_update_field($field);
+ }
+ catch (Exception $e) {
+ drupal_set_message(t('Attempt to update field %label failed: %message.', array('%label' => $instance['label'], '%message' => $e->getMessage())), 'error');
+ return;
+ }
+
+ // Handle the default value.
+ if (isset($form['instance']['default_value_widget'])) {
+ $element = $form['instance']['default_value_widget'];
+
+ // Extract field values.
+ $items = array();
+ field_default_extract_form_values(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $element, $form_state);
+ field_default_submit(NULL, NULL, $field, $instance, LANGUAGE_NONE, $items, $element, $form_state);
+
+ $instance['default_value'] = $items ? $items : NULL;
+ }
+
+ // Retrieve the stored instance settings to merge with the incoming values.
+ $instance_source = field_read_instance($instance['entity_type'], $instance['field_name'], $instance['bundle']);
+ $instance = array_merge($instance_source, $instance);
+ field_update_instance($instance);
+
+ drupal_set_message(t('Saved %label configuration.', array('%label' => $instance['label'])));
+
+ $form_state['redirect'] = field_ui_next_destination($instance['entity_type'], $instance['bundle']);
+}
+
+/**
+ * Helper functions to handle multipage redirects.
+ */
+function field_ui_get_destinations($destinations) {
+ $path = array_shift($destinations);
+ $options = drupal_parse_url($path);
+ if ($destinations) {
+ $options['query']['destinations'] = $destinations;
+ }
+ return array($options['path'], $options);
+}
+
+/**
+ * Return the next redirect path in a multipage sequence.
+ */
+function field_ui_next_destination($entity_type, $bundle) {
+ $destinations = !empty($_REQUEST['destinations']) ? $_REQUEST['destinations'] : array();
+ if (!empty($destinations)) {
+ unset($_REQUEST['destinations']);
+ return field_ui_get_destinations($destinations);
+ }
+ $admin_path = _field_ui_bundle_admin_path($entity_type, $bundle);
+ return $admin_path . '/fields';
+}
diff --git a/core/modules/field_ui/field_ui.api.php b/core/modules/field_ui/field_ui.api.php
new file mode 100644
index 000000000000..2340125122b2
--- /dev/null
+++ b/core/modules/field_ui/field_ui.api.php
@@ -0,0 +1,204 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Field UI module.
+ */
+
+/**
+ * @ingroup field_ui_field_type
+ * @{
+ */
+
+/**
+ * Add settings to a field settings form.
+ *
+ * Invoked from field_ui_field_settings_form() to allow the module defining the
+ * field to add global settings (i.e. settings that do not depend on the bundle
+ * or instance) to the field settings form. If the field already has data, only
+ * include settings that are safe to change.
+ *
+ * @todo: Only the field type module knows which settings will affect the
+ * field's schema, but only the field storage module knows what schema
+ * changes are permitted once a field already has data. Probably we need an
+ * easy way for a field type module to ask whether an update to a new schema
+ * will be allowed without having to build up a fake $prior_field structure
+ * for hook_field_update_forbid().
+ *
+ * @param $field
+ * The field structure being configured.
+ * @param $instance
+ * The instance structure being configured.
+ * @param $has_data
+ * TRUE if the field already has data, FALSE if not.
+ *
+ * @return
+ * The form definition for the field settings.
+ */
+function hook_field_settings_form($field, $instance, $has_data) {
+ $settings = $field['settings'];
+ $form['max_length'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum length'),
+ '#default_value' => $settings['max_length'],
+ '#required' => FALSE,
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#description' => t('The maximum length of the field in characters. Leave blank for an unlimited size.'),
+ );
+ return $form;
+}
+
+/**
+ * Add settings to an instance field settings form.
+ *
+ * Invoked from field_ui_field_edit_form() to allow the module defining the
+ * field to add settings for a field instance.
+ *
+ * @param $field
+ * The field structure being configured.
+ * @param $instance
+ * The instance structure being configured.
+ *
+ * @return
+ * The form definition for the field instance settings.
+ */
+function hook_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ $form['text_processing'] = array(
+ '#type' => 'radios',
+ '#title' => t('Text processing'),
+ '#default_value' => $settings['text_processing'],
+ '#options' => array(
+ t('Plain text'),
+ t('Filtered text (user selects text format)'),
+ ),
+ );
+ if ($field['type'] == 'text_with_summary') {
+ $form['display_summary'] = array(
+ '#type' => 'select',
+ '#title' => t('Display summary'),
+ '#options' => array(
+ t('No'),
+ t('Yes'),
+ ),
+ '#description' => t('Display the summary to allow the user to input a summary value. Hide the summary to automatically fill it with a trimmed portion from the main post.'),
+ '#default_value' => !empty($settings['display_summary']) ? $settings['display_summary'] : 0,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Add settings to a widget settings form.
+ *
+ * Invoked from field_ui_field_edit_form() to allow the module defining the
+ * widget to add settings for a widget instance.
+ *
+ * @param $field
+ * The field structure being configured.
+ * @param $instance
+ * The instance structure being configured.
+ *
+ * @return
+ * The form definition for the widget settings.
+ */
+function hook_field_widget_settings_form($field, $instance) {
+ $widget = $instance['widget'];
+ $settings = $widget['settings'];
+
+ if ($widget['type'] == 'text_textfield') {
+ $form['size'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Size of textfield'),
+ '#default_value' => $settings['size'],
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#required' => TRUE,
+ );
+ }
+ else {
+ $form['rows'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Rows'),
+ '#default_value' => $settings['rows'],
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#required' => TRUE,
+ );
+ }
+
+ return $form;
+}
+
+
+/**
+ * Returns form elements for a formatter's settings.
+ *
+ * @param $field
+ * The field structure being configured.
+ * @param $instance
+ * The instance structure being configured.
+ * @param $view_mode
+ * The view mode being configured.
+ * @param $form
+ * The (entire) configuration form array, which will usually have no use here.
+ * @param $form_state
+ * The form state of the (entire) configuration form.
+ *
+ * @return
+ * The form elements for the formatter settings.
+ */
+function hook_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $element = array();
+
+ if ($display['type'] == 'text_trimmed' || $display['type'] == 'text_summary_or_trimmed') {
+ $element['trim_length'] = array(
+ '#title' => t('Length'),
+ '#type' => 'textfield',
+ '#size' => 20,
+ '#default_value' => $settings['trim_length'],
+ '#element_validate' => array('element_validate_integer_positive'),
+ '#required' => TRUE,
+ );
+ }
+
+ return $element;
+
+}
+
+/**
+ * Returns a short summary for the current formatter settings of an instance.
+ *
+ * If an empty result is returned, the formatter is assumed to have no
+ * configurable settings, and no UI will be provided to display a settings
+ * form.
+ *
+ * @param $field
+ * The field structure.
+ * @param $instance
+ * The instance structure.
+ * @param $view_mode
+ * The view mode for which a settings summary is requested.
+ *
+ * @return
+ * A string containing a short summary of the formatter settings.
+ */
+function hook_field_formatter_settings_summary($field, $instance, $view_mode) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $summary = '';
+
+ if ($display['type'] == 'text_trimmed' || $display['type'] == 'text_summary_or_trimmed') {
+ $summary = t('Length: @chars chars', array('@chars' => $settings['trim_length']));
+ }
+
+ return $summary;
+}
+
+/**
+ * @} End of "ingroup field_ui_field_type"
+ */
diff --git a/core/modules/field_ui/field_ui.css b/core/modules/field_ui/field_ui.css
new file mode 100644
index 000000000000..c25d2a436c8d
--- /dev/null
+++ b/core/modules/field_ui/field_ui.css
@@ -0,0 +1,59 @@
+
+/* 'Manage fields' and 'Manage display' overviews */
+table.field-ui-overview tr.add-new .label-input {
+ float: left; /* LTR */
+}
+table.field-ui-overview tr.add-new .tabledrag-changed {
+ display: none;
+}
+table.field-ui-overview tr.add-new .description {
+ margin-bottom: 0;
+}
+table.field-ui-overview tr.add-new .add-new-placeholder {
+ font-weight: bold;
+ padding-bottom: .5em;
+}
+table.field-ui-overview tr.region-title td {
+ font-weight: bold;
+}
+table.field-ui-overview tr.region-message td {
+ font-style: italic;
+}
+table.field-ui-overview tr.region-populated {
+ display: none;
+}
+table.field-ui-overview tr.region-add-new-title {
+ display: none;
+}
+
+/* 'Manage display' overview */
+#field-display-overview .field-formatter-summary-cell {
+ line-height: 1em;
+}
+#field-display-overview .field-formatter-summary {
+ float: left;
+ font-size: 0.9em;
+}
+#field-display-overview td.field-formatter-summary-cell span.warning {
+ display: block;
+ float: left;
+ margin-right: .5em;
+}
+#field-display-overview .field-formatter-settings-edit-wrapper {
+ float: right;
+}
+#field-display-overview .field-formatter-settings-edit {
+ float: right;
+}
+#field-display-overview tr.field-formatter-settings-editing td {
+ vertical-align: top;
+}
+#field-display-overview tr.field-formatter-settings-editing .field-formatter-type {
+ display: none;
+}
+#field-display-overview .field-formatter-settings-edit-form .formatter-name{
+ font-weight: bold;
+}
+#field-ui-display-overview-form #edit-refresh {
+ display:none;
+}
diff --git a/core/modules/field_ui/field_ui.info b/core/modules/field_ui/field_ui.info
new file mode 100644
index 000000000000..87bab94f2f8e
--- /dev/null
+++ b/core/modules/field_ui/field_ui.info
@@ -0,0 +1,7 @@
+name = Field UI
+description = User interface for the Field API.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = field_ui.test
diff --git a/core/modules/field_ui/field_ui.js b/core/modules/field_ui/field_ui.js
new file mode 100644
index 000000000000..b63399f3d28d
--- /dev/null
+++ b/core/modules/field_ui/field_ui.js
@@ -0,0 +1,330 @@
+
+(function($) {
+
+Drupal.behaviors.fieldUIFieldOverview = {
+ attach: function (context, settings) {
+ $('table#field-overview', context).once('field-overview', function () {
+ Drupal.fieldUIFieldOverview.attachUpdateSelects(this, settings);
+ });
+ }
+};
+
+Drupal.fieldUIFieldOverview = {
+ /**
+ * Implements dependent select dropdowns on the 'Manage fields' screen.
+ */
+ attachUpdateSelects: function(table, settings) {
+ var widgetTypes = settings.fieldWidgetTypes;
+ var fields = settings.fields;
+
+ // Store the default text of widget selects.
+ $('.widget-type-select', table).each(function () {
+ this.initialValue = this.options[0].text;
+ });
+
+ // 'Field type' select updates its 'Widget' select.
+ $('.field-type-select', table).each(function () {
+ this.targetSelect = $('.widget-type-select', $(this).parents('tr').eq(0));
+
+ $(this).bind('change keyup', function () {
+ var selectedFieldType = this.options[this.selectedIndex].value;
+ var options = (selectedFieldType in widgetTypes ? widgetTypes[selectedFieldType] : []);
+ this.targetSelect.fieldUIPopulateOptions(options);
+ });
+
+ // Trigger change on initial pageload to get the right widget options
+ // when field type comes pre-selected (on failed validation).
+ $(this).trigger('change', false);
+ });
+
+ // 'Existing field' select updates its 'Widget' select and 'Label' textfield.
+ $('.field-select', table).each(function () {
+ this.targetSelect = $('.widget-type-select', $(this).parents('tr').eq(0));
+ this.targetTextfield = $('.label-textfield', $(this).parents('tr').eq(0));
+
+ $(this).bind('change keyup', function (e, updateText) {
+ var updateText = (typeof updateText == 'undefined' ? true : updateText);
+ var selectedField = this.options[this.selectedIndex].value;
+ var selectedFieldType = (selectedField in fields ? fields[selectedField].type : null);
+ var selectedFieldWidget = (selectedField in fields ? fields[selectedField].widget : null);
+ var options = (selectedFieldType && (selectedFieldType in widgetTypes) ? widgetTypes[selectedFieldType] : []);
+ this.targetSelect.fieldUIPopulateOptions(options, selectedFieldWidget);
+
+ if (updateText) {
+ $(this.targetTextfield).attr('value', (selectedField in fields ? fields[selectedField].label : ''));
+ }
+ });
+
+ // Trigger change on initial pageload to get the right widget options
+ // and label when field type comes pre-selected (on failed validation).
+ $(this).trigger('change', false);
+ });
+ }
+};
+
+/**
+ * Populates options in a select input.
+ */
+jQuery.fn.fieldUIPopulateOptions = function (options, selected) {
+ return this.each(function () {
+ var disabled = false;
+ if (options.length == 0) {
+ options = [this.initialValue];
+ disabled = true;
+ }
+
+ // If possible, keep the same widget selected when changing field type.
+ // This is based on textual value, since the internal value might be
+ // different (options_buttons vs. node_reference_buttons).
+ var previousSelectedText = this.options[this.selectedIndex].text;
+
+ var html = '';
+ jQuery.each(options, function (value, text) {
+ // Figure out which value should be selected. The 'selected' param
+ // takes precedence.
+ var is_selected = ((typeof selected != 'undefined' && value == selected) || (typeof selected == 'undefined' && text == previousSelectedText));
+ html += '<option value="' + value + '"' + (is_selected ? ' selected="selected"' : '') + '>' + text + '</option>';
+ });
+
+ $(this).html(html).attr('disabled', disabled ? 'disabled' : '');
+ });
+};
+
+Drupal.behaviors.fieldUIDisplayOverview = {
+ attach: function (context, settings) {
+ $('table#field-display-overview', context).once('field-display-overview', function() {
+ Drupal.fieldUIOverview.attach(this, settings.fieldUIRowsData, Drupal.fieldUIDisplayOverview);
+ });
+ }
+};
+
+Drupal.fieldUIOverview = {
+ /**
+ * Attaches the fieldUIOverview behavior.
+ */
+ attach: function (table, rowsData, rowHandlers) {
+ var tableDrag = Drupal.tableDrag[table.id];
+
+ // Add custom tabledrag callbacks.
+ tableDrag.onDrop = this.onDrop;
+ tableDrag.row.prototype.onSwap = this.onSwap;
+
+ // Create row handlers.
+ $('tr.draggable', table).each(function () {
+ // Extract server-side data for the row.
+ var row = this;
+ if (row.id in rowsData) {
+ var data = rowsData[row.id];
+ data.tableDrag = tableDrag;
+
+ // Create the row handler, make it accessible from the DOM row element.
+ var rowHandler = eval('new rowHandlers.' + data.rowHandler + '(row, data);');
+ $(row).data('fieldUIRowHandler', rowHandler);
+ }
+ });
+ },
+
+ /**
+ * Event handler to be attached to form inputs triggering a region change.
+ */
+ onChange: function () {
+ var $trigger = $(this);
+ var row = $trigger.parents('tr:first').get(0);
+ var rowHandler = $(row).data('fieldUIRowHandler');
+
+ var refreshRows = {};
+ refreshRows[rowHandler.name] = $trigger.get(0);
+
+ // Handle region change.
+ var region = rowHandler.getRegion();
+ if (region != rowHandler.region) {
+ // Remove parenting.
+ $('select.field-parent', row).val('');
+ // Let the row handler deal with the region change.
+ $.extend(refreshRows, rowHandler.regionChange(region));
+ // Update the row region.
+ rowHandler.region = region;
+ }
+
+ // Ajax-update the rows.
+ Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows);
+ },
+
+ /**
+ * Lets row handlers react when a row is dropped into a new region.
+ */
+ onDrop: function () {
+ var dragObject = this;
+ var row = dragObject.rowObject.element;
+ var rowHandler = $(row).data('fieldUIRowHandler');
+ if (rowHandler !== undefined) {
+ var regionRow = $(row).prevAll('tr.region-message').get(0);
+ var region = regionRow.className.replace(/([^ ]+[ ]+)*region-([^ ]+)-message([ ]+[^ ]+)*/, '$2');
+
+ if (region != rowHandler.region) {
+ // Let the row handler deal with the region change.
+ refreshRows = rowHandler.regionChange(region);
+ // Update the row region.
+ rowHandler.region = region;
+ // Ajax-update the rows.
+ Drupal.fieldUIOverview.AJAXRefreshRows(refreshRows);
+ }
+ }
+ },
+
+ /**
+ * Refreshes placeholder rows in empty regions while a row is being dragged.
+ *
+ * Copied from block.js.
+ *
+ * @param table
+ * The table DOM element.
+ * @param rowObject
+ * The tableDrag rowObject for the row being dragged.
+ */
+ onSwap: function (draggedRow) {
+ var rowObject = this;
+ $('tr.region-message', rowObject.table).each(function () {
+ // If the dragged row is in this region, but above the message row, swap
+ // it down one space.
+ if ($(this).prev('tr').get(0) == rowObject.group[rowObject.group.length - 1]) {
+ // Prevent a recursion problem when using the keyboard to move rows up.
+ if ((rowObject.method != 'keyboard' || rowObject.direction == 'down')) {
+ rowObject.swap('after', this);
+ }
+ }
+ // This region has become empty.
+ if ($(this).next('tr').is(':not(.draggable)') || $(this).next('tr').length == 0) {
+ $(this).removeClass('region-populated').addClass('region-empty');
+ }
+ // This region has become populated.
+ else if ($(this).is('.region-empty')) {
+ $(this).removeClass('region-empty').addClass('region-populated');
+ }
+ });
+ },
+
+ /**
+ * Triggers Ajax refresh of selected rows.
+ *
+ * The 'format type' selects can trigger a series of changes in child rows.
+ * The #ajax behavior is therefore not attached directly to the selects, but
+ * triggered manually through a hidden #ajax 'Refresh' button.
+ *
+ * @param rows
+ * A hash object, whose keys are the names of the rows to refresh (they
+ * will receive the 'ajax-new-content' effect on the server side), and
+ * whose values are the DOM element in the row that should get an Ajax
+ * throbber.
+ */
+ AJAXRefreshRows: function (rows) {
+ // Separate keys and values.
+ var rowNames = [];
+ var ajaxElements = [];
+ $.each(rows, function (rowName, ajaxElement) {
+ rowNames.push(rowName);
+ ajaxElements.push(ajaxElement);
+ });
+
+ if (rowNames.length) {
+ // Add a throbber next each of the ajaxElements.
+ var $throbber = $('<div class="ajax-progress ajax-progress-throbber"><div class="throbber">&nbsp;</div></div>');
+ $(ajaxElements)
+ .addClass('progress-disabled')
+ .after($throbber);
+
+ // Fire the Ajax update.
+ $('input[name=refresh_rows]').val(rowNames.join(' '));
+ $('input#edit-refresh').mousedown();
+
+ // Disabled elements do not appear in POST ajax data, so we mark the
+ // elements disabled only after firing the request.
+ $(ajaxElements).attr('disabled', true);
+ }
+ }
+};
+
+
+/**
+ * Row handlers for the 'Manage display' screen.
+ */
+Drupal.fieldUIDisplayOverview = {};
+
+/**
+ * Constructor for a 'field' row handler.
+ *
+ * This handler is used for both fields and 'extra fields' rows.
+ *
+ * @param row
+ * The row DOM element.
+ * @param data
+ * Additional data to be populated in the constructed object.
+ */
+Drupal.fieldUIDisplayOverview.field = function (row, data) {
+ this.row = row;
+ this.name = data.name;
+ this.region = data.region;
+ this.tableDrag = data.tableDrag;
+
+ // Attach change listener to the 'formatter type' select.
+ this.$formatSelect = $('select.field-formatter-type', row);
+ this.$formatSelect.change(Drupal.fieldUIOverview.onChange);
+
+ return this;
+};
+
+Drupal.fieldUIDisplayOverview.field.prototype = {
+ /**
+ * Returns the region corresponding to the current form values of the row.
+ */
+ getRegion: function () {
+ return (this.$formatSelect.val() == 'hidden') ? 'hidden' : 'visible';
+ },
+
+ /**
+ * Reacts to a row being changed regions.
+ *
+ * This function is called when the row is moved to a different region, as a
+ * result of either :
+ * - a drag-and-drop action (the row's form elements then probably need to be
+ * updated accordingly)
+ * - user input in one of the form elements watched by the
+ * Drupal.fieldUIOverview.onChange change listener.
+ *
+ * @param region
+ * The name of the new region for the row.
+ * @return
+ * A hash object indicating which rows should be Ajax-updated as a result
+ * of the change, in the format expected by
+ * Drupal.displayOverview.AJAXRefreshRows().
+ */
+ regionChange: function (region) {
+
+ // When triggered by a row drag, the 'format' select needs to be adjusted
+ // to the new region.
+ var currentValue = this.$formatSelect.val();
+ switch (region) {
+ case 'visible':
+ if (currentValue == 'hidden') {
+ // Restore the formatter back to the default formatter. Pseudo-fields do
+ // not have default formatters, we just return to 'visible' for those.
+ var value = (this.defaultFormatter != undefined) ? this.defaultFormatter : 'visible';
+ }
+ break;
+
+ default:
+ var value = 'hidden';
+ break;
+ }
+ if (value != undefined) {
+ this.$formatSelect.val(value);
+ }
+
+ var refreshRows = {};
+ refreshRows[this.name] = this.$formatSelect.get(0);
+
+ return refreshRows;
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/field_ui/field_ui.module b/core/modules/field_ui/field_ui.module
new file mode 100644
index 000000000000..90559cff22a8
--- /dev/null
+++ b/core/modules/field_ui/field_ui.module
@@ -0,0 +1,369 @@
+<?php
+
+/**
+ * @file
+ * Allows administrators to associate custom fields to fieldable types.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function field_ui_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#field_ui':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Field UI module provides an administrative user interface (UI) for attaching and managing fields. Fields can be defined at the content-type level for content items and comments, at the vocabulary level for taxonomy terms, and at the site level for user accounts. Other modules may also enable fields to be defined for their data. Field types (text, image, number, etc.) are defined by modules, and collected and managed by the <a href="@field">Field module</a>. For more information, see the online handbook entry for <a href="@field_ui" target="_blank">Field UI module</a>.', array('@field' => url('admin/help/field'), '@field_ui' => 'http://drupal.org/handbook/modules/field-ui')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Planning fields') . '</dt>';
+ $output .= '<dd>' . t('There are several decisions you will need to make before defining a field for content, comments, etc.:') . '<dl>';
+ $output .= '<dt>' . t('What the field will be called') . '</dt>';
+ $output .= '<dd>' . t('A field has a <em>label</em> (the name displayed in the user interface) and a <em>machine name</em> (the name used internally). The label can be changed after you create the field, if needed, but the machine name cannot be changed after you have created the field.') . '</li>';
+ $output .= '<dt>' . t('What type of data the field will store') . '</dt>';
+ $output .= '<dd>' . t('Each field can store one type of data (text, number, file, etc.). When you define a field, you choose a particular <em>field type</em>, which corresponds to the type of data you want to store. The field type cannot be changed after you have created the field.') . '</dd>';
+ $output .= '<dt>' . t('How the data will be input and displayed') . '</dt>';
+ $output .= '<dd>' . t('Each field type has one or more available <em>widgets</em> associated with it; each widget provides a mechanism for data input when you are editing (text box, select list, file upload, etc.). Each field type also has one or more display options, which determine how the field is displayed to site visitors. The widget and display options can be changed after you have created the field.') . '</dd>';
+ $output .= '<dt>' . t('How many values the field will store') . '</dt>';
+ $output .= '<dd>' . t('You can store one value, a specific maximum number of values, or an unlimited number of values in each field. For example, an employee identification number field might store a single number, whereas a phone number field might store multiple phone numbers. This setting can be changed after you have created the field, but if you reduce the maximum number of values, you may lose information.') . '</dd>';
+ $output .= '</dl>';
+ $output .= '<dt>' . t('Reusing fields') . '</dt>';
+ $output .= '<dd>' . t('Once you have defined a field, you can reuse it. For example, if you define a custom image field for one content type, and you need to have an image field with the same parameters on another content type, you can add the same field to the second content type, in the <em>Add existing field</em> area of the user interface. You could also add this field to a taxonomy vocabulary, comments, user accounts, etc.') . '</dd>';
+ $output .= '<dd>' . t('Some settings of a reused field are unique to each use of the field; others are shared across all places you use the field. For example, the label of a text field is unique to each use, while the setting for the number of values is shared.') . '</dd>';
+ $output .= '<dd>' . t('There are two main reasons for reusing fields. First, reusing fields can save you time over defining new fields. Second, reusing fields also allows you to display, filter, group, and sort content together by field across content types. For example, the contributed Views module allows you to create lists and tables of content. So if you use the same field on multiple content types, you can create a View containing all of those content types together displaying that field, sorted by that field, and/or filtered by that field.') . '</dd>';
+ $output .= '<dt>' . t('Fields on content items') . '</dt>';
+ $output .= '<dd>' . t('Fields on content items are defined at the content-type level, on the <em>Manage fields</em> tab of the content type edit page (which you can reach from the <a href="@types">Content types page</a>). When you define a field for a content type, each content item of that type will have that field added to it. Some fields, such as the Title and Body, are provided for you when you create a content type, or are provided on content types created by your installation profile.', array('@types' => url('admin/structure/types'))) . '</dd>';
+ $output .= '<dt>' . t('Fields on taxonomy terms') . '</dt>';
+ $output .= '<dd>' . t('Fields on taxonomy terms are defined at the taxonomy vocabulary level, on the <em>Manage fields</em> tab of the vocabulary edit page (which you can reach from the <a href="@taxonomy">Taxonomy page</a>). When you define a field for a vocabulary, each term in that vocabulary will have that field added to it. For example, you could define an image field for a vocabulary to store an icon with each term.', array('@taxonomy' => url('admin/structure/taxonomy'))) . '</dd>';
+ $output .= '<dt>' . t('Fields on user accounts') . '</dt>';
+ $output .= '<dd>' . t('Fields on user accounts are defined on a site-wide basis on the <a href="@fields">Manage fields tab</a> of the <a href="@accounts">Account settings</a> page. When you define a field for user accounts, each user account will have that field added to it. For example, you could add a long text field to allow users to include a biography.', array('@fields' => url('admin/config/people/accounts/fields'), '@accounts' => url('admin/config/people/accounts'))) . '</dd>';
+ $output .= '<dt>' . t('Fields on comments') . '</dt>';
+ $output .= '<dd>' . t('Fields on comments are defined at the content-type level, on the <em>Comment fields</em> tab of the content type edit page (which you can reach from the <a href="@types">Content types page</a>). When you add a field for comments, each comment on a content item of that type will have that field added to it. For example, you could add a website field to the comments on forum posts, to allow forum commenters to add a link to their website.', array('@types' => url('admin/structure/types'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/reports/fields':
+ return '<p>' . t('This list shows all fields currently in use for easy reference.') . '</p>';
+ }
+}
+
+/**
+ * Implements hook_field_attach_rename_bundle().
+ */
+function field_ui_field_attach_rename_bundle($entity_type, $bundle_old, $bundle_new) {
+ // The Field UI relies on entity_get_info() to build menu items for entity
+ // field administration pages. Clear the entity info cache and ensure that
+ // the menu is rebuilt.
+ entity_info_cache_clear();
+ menu_rebuild();
+}
+
+/**
+ * Implements hook_menu().
+ */
+function field_ui_menu() {
+ $items['admin/reports/fields'] = array(
+ 'title' => 'Field list',
+ 'description' => 'Overview of fields on all entity types.',
+ 'page callback' => 'field_ui_fields_list',
+ 'access arguments' => array('administer content types'),
+ 'type' => MENU_NORMAL_ITEM,
+ 'file' => 'field_ui.admin.inc',
+ );
+
+ // Create tabs for all possible bundles.
+ foreach (entity_get_info() as $entity_type => $entity_info) {
+ if ($entity_info['fieldable']) {
+ foreach ($entity_info['bundles'] as $bundle_name => $bundle_info) {
+ if (isset($bundle_info['admin'])) {
+ // Extract path information from the bundle.
+ $path = $bundle_info['admin']['path'];
+ // Different bundles can appear on the same path (e.g. %node_type and
+ // %comment_node_type). To allow field_ui_menu_load() to extract the
+ // actual bundle object from the translated menu router path
+ // arguments, we need to identify the argument position of the bundle
+ // name string ('bundle argument') and pass that position to the menu
+ // loader. The position needs to be casted into a string; otherwise it
+ // would be replaced with the bundle name string.
+ if (isset($bundle_info['admin']['bundle argument'])) {
+ $bundle_arg = $bundle_info['admin']['bundle argument'];
+ $bundle_pos = (string) $bundle_arg;
+ }
+ else {
+ $bundle_arg = $bundle_name;
+ $bundle_pos = '0';
+ }
+ // This is the position of the %field_ui_menu placeholder in the
+ // items below.
+ $field_position = count(explode('/', $path)) + 1;
+
+ // Extract access information, providing defaults.
+ $access = array_intersect_key($bundle_info['admin'], drupal_map_assoc(array('access callback', 'access arguments')));
+ $access += array(
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer site configuration'),
+ );
+
+ $items["$path/fields"] = array(
+ 'title' => 'Manage fields',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_field_overview_form', $entity_type, $bundle_arg),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 1,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+ $items["$path/fields/%field_ui_menu"] = array(
+ 'load arguments' => array($entity_type, $bundle_arg, $bundle_pos, '%map'),
+ 'title callback' => 'field_ui_menu_title',
+ 'title arguments' => array($field_position),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_field_edit_form', $field_position),
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+ $items["$path/fields/%field_ui_menu/edit"] = array(
+ 'load arguments' => array($entity_type, $bundle_arg, $bundle_pos, '%map'),
+ 'title' => 'Edit',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_field_edit_form', $field_position),
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+ $items["$path/fields/%field_ui_menu/field-settings"] = array(
+ 'load arguments' => array($entity_type, $bundle_arg, $bundle_pos, '%map'),
+ 'title' => 'Field settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_field_settings_form', $field_position),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+ $items["$path/fields/%field_ui_menu/widget-type"] = array(
+ 'load arguments' => array($entity_type, $bundle_arg, $bundle_pos, '%map'),
+ 'title' => 'Widget type',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_widget_type_form', $field_position),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+ $items["$path/fields/%field_ui_menu/delete"] = array(
+ 'load arguments' => array($entity_type, $bundle_arg, $bundle_pos, '%map'),
+ 'title' => 'Delete',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_field_delete_form', $field_position),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ 'weight' => 10,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+
+ // 'Manage display' tab.
+ $items["$path/display"] = array(
+ 'title' => 'Manage display',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('field_ui_display_overview_form', $entity_type, $bundle_arg, 'default'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ 'file' => 'field_ui.admin.inc',
+ ) + $access;
+
+ // View modes secondary tabs.
+ // The same base $path for the menu item (with a placeholder) can be
+ // used for all bundles of a given entity type; but depending on
+ // administrator settings, each bundle has a different set of view
+ // modes available for customisation. So we define menu items for all
+ // view modes, and use an access callback to determine which ones are
+ // actually visible for a given bundle.
+ $weight = 0;
+ $view_modes = array('default' => array('label' => t('Default'))) + $entity_info['view modes'];
+ foreach ($view_modes as $view_mode => $view_mode_info) {
+ $items["$path/display/$view_mode"] = array(
+ 'title' => $view_mode_info['label'],
+ 'page arguments' => array('field_ui_display_overview_form', $entity_type, $bundle_arg, $view_mode),
+ // The access callback needs to check both the current 'custom
+ // display' setting for the view mode, and the overall access
+ // rules for the bundle admin pages.
+ 'access callback' => '_field_ui_view_mode_menu_access',
+ 'access arguments' => array_merge(array($entity_type, $bundle_arg, $view_mode, $access['access callback']), $access['access arguments']),
+ 'type' => ($view_mode == 'default' ? MENU_DEFAULT_LOCAL_TASK : MENU_LOCAL_TASK),
+ 'weight' => ($view_mode == 'default' ? -10 : $weight++),
+ 'file' => 'field_ui.admin.inc',
+ );
+ }
+ }
+ }
+ }
+ }
+ return $items;
+}
+
+/**
+ * Menu loader; Load a field instance based on field and bundle name.
+ *
+ * @param $field_name
+ * The name of the field, as contained in the path.
+ * @param $entity_type
+ * The name of the entity.
+ * @param $bundle_name
+ * The name of the bundle, as contained in the path.
+ * @param $bundle_pos
+ * The position of $bundle_name in $map.
+ * @param $map
+ * The translated menu router path argument map.
+ */
+function field_ui_menu_load($field_name, $entity_type, $bundle_name, $bundle_pos, $map) {
+ // Extract the actual bundle name from the translated argument map.
+ // The menu router path to manage fields of an entity can be shared among
+ // multiple bundles. For example:
+ // - admin/structure/types/manage/%node_type/fields/%field_ui_menu
+ // - admin/structure/types/manage/%comment_node_type/fields/%field_ui_menu
+ // The menu system will automatically load the correct bundle depending on the
+ // actual path arguments, but this menu loader function only receives the node
+ // type string as $bundle_name, which is not the bundle name for comments.
+ // We therefore leverage the dynamically translated $map provided by the menu
+ // system to retrieve the actual bundle and bundle name for the current path.
+ if ($bundle_pos > 0) {
+ $bundle = $map[$bundle_pos];
+ $bundle_name = field_extract_bundle($entity_type, $bundle);
+ }
+ // Check whether the field exists at all.
+ if ($field = field_info_field($field_name)) {
+ // Only return the field if a field instance exists for the given entity
+ // type and bundle.
+ if ($instance = field_info_instance($entity_type, $field_name, $bundle_name)) {
+ return $instance;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Menu title callback.
+ */
+function field_ui_menu_title($instance) {
+ return $instance['label'];
+}
+
+/**
+ * Menu access callback for the 'view mode display settings' pages.
+ */
+function _field_ui_view_mode_menu_access($entity_type, $bundle, $view_mode, $access_callback) {
+ // First, determine visibility according to the 'use custom display'
+ // setting for the view mode.
+ $bundle = field_extract_bundle($entity_type, $bundle);
+ $view_mode_settings = field_view_mode_settings($entity_type, $bundle);
+ $visibility = ($view_mode == 'default') || !empty($view_mode_settings[$view_mode]['custom_settings']);
+
+ // Then, determine access according to the $access parameter. This duplicates
+ // part of _menu_check_access().
+ if ($visibility) {
+ // Grab the variable 'access arguments' part.
+ $args = array_slice(func_get_args(), 4);
+ $callback = empty($access_callback) ? 0 : trim($access_callback);
+ if (is_numeric($callback)) {
+ return (bool) $callback;
+ }
+ else {
+ // As call_user_func_array() is quite slow and user_access is a very
+ // common callback, it is worth making a special case for it.
+ if ($access_callback == 'user_access') {
+ return (count($args) == 1) ? user_access($args[0]) : user_access($args[0], $args[1]);
+ }
+ elseif (function_exists($access_callback)) {
+ return call_user_func_array($access_callback, $args);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function field_ui_theme() {
+ return array(
+ 'field_ui_table' => array(
+ 'render element' => 'elements',
+ ),
+ );
+}
+
+/**
+ * Implements hook_element_info().
+ */
+function field_ui_element_info() {
+ return array(
+ 'field_ui_table' => array(
+ '#theme' => 'field_ui_table',
+ '#pre_render' => array('field_ui_table_pre_render'),
+ '#regions' => array('' => array()),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_attach_create_bundle().
+ */
+function field_ui_field_attach_create_bundle($entity_type, $bundle) {
+ // When a new bundle is created, the menu needs to be rebuilt to add our
+ // menu item tabs.
+ variable_set('menu_rebuild_needed', TRUE);
+}
+
+/**
+ * Helper function to create the right administration path for a bundle.
+ */
+function _field_ui_bundle_admin_path($entity_type, $bundle_name) {
+ $bundles = field_info_bundles($entity_type);
+ $bundle_info = $bundles[$bundle_name];
+ if (isset($bundle_info['admin'])) {
+ return isset($bundle_info['admin']['real path']) ? $bundle_info['admin']['real path'] : $bundle_info['admin']['path'];
+ }
+}
+
+/**
+ * Helper function to identify inactive fields within a bundle.
+ */
+function field_ui_inactive_instances($entity_type, $bundle_name = NULL) {
+ if (!empty($bundle_name)) {
+ $inactive = array($bundle_name => array());
+ $params = array('bundle' => $bundle_name);
+ }
+ else {
+ $inactive = array();
+ $params = array();
+ }
+ $params['entity_type'] = $entity_type;
+
+ $active_instances = field_info_instances($entity_type);
+ $all_instances = field_read_instances($params, array('include_inactive' => TRUE));
+ foreach ($all_instances as $instance) {
+ if (!isset($active_instances[$instance['bundle']][$instance['field_name']])) {
+ $inactive[$instance['bundle']][$instance['field_name']] = $instance;
+ }
+ }
+ if (!empty($bundle_name)) {
+ return $inactive[$bundle_name];
+ }
+ return $inactive;
+}
+
+/**
+ * Add a button Save and add fields to Create content type form.
+ */
+function field_ui_form_node_type_form_alter(&$form, $form_state) {
+ // We want to display the button only on add page.
+ if (empty($form['#node_type']->type)) {
+ $form['actions']['save_continue'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save and add fields'),
+ '#weight' => 45,
+ );
+ $form['#submit'][] = 'field_ui_form_node_type_form_submit';
+ }
+}
+
+/**
+ * Redirect to manage fields form.
+ */
+function field_ui_form_node_type_form_submit($form, &$form_state) {
+ if ($form_state['triggering_element']['#parents'][0] === 'save_continue') {
+ $form_state['redirect'] = _field_ui_bundle_admin_path('node', $form_state['values']['type']) .'/fields';
+ }
+}
diff --git a/core/modules/field_ui/field_ui.test b/core/modules/field_ui/field_ui.test
new file mode 100644
index 000000000000..56bfbbef87af
--- /dev/null
+++ b/core/modules/field_ui/field_ui.test
@@ -0,0 +1,645 @@
+<?php
+
+/**
+ * @file
+ * Tests for field_ui.module.
+ */
+
+/**
+ * Helper class for Field UI test classes.
+ */
+class FieldUITestCase extends DrupalWebTestCase {
+
+ function setUp() {
+ // Since this is a base class for many test cases, support the same
+ // flexibility that DrupalWebTestCase::setUp() has for the modules to be
+ // passed in as either an array or a variable number of string arguments.
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ $modules[] = 'field_test';
+ parent::setUp($modules);
+
+ // Create test user.
+ $admin_user = $this->drupalCreateUser(array('access content', 'administer content types', 'administer taxonomy'));
+ $this->drupalLogin($admin_user);
+
+ // Create content type, with underscores.
+ $type_name = strtolower($this->randomName(8)) . '_test';
+ $type = $this->drupalCreateContentType(array('name' => $type_name, 'type' => $type_name));
+ $this->type = $type->type;
+ // Store a valid URL name, with hyphens instead of underscores.
+ $this->hyphen_type = str_replace('_', '-', $this->type);
+ }
+
+ /**
+ * Create a new field through the Field UI.
+ *
+ * @param $bundle_path
+ * Path of the 'Manage fields' page for the bundle.
+ * @param $initial_edit
+ * $edit parameter for drupalPost() on the first step ('Manage fields'
+ * screen).
+ * @param $field_edit
+ * $edit parameter for drupalPost() on the second step ('Field settings'
+ * form).
+ * @param $instance_edit
+ * $edit parameter for drupalPost() on the third step ('Instance settings'
+ * form).
+ */
+ function fieldUIAddNewField($bundle_path, $initial_edit, $field_edit = array(), $instance_edit = array()) {
+ // Use 'test_field' field type by default.
+ $initial_edit += array(
+ 'fields[_add_new_field][type]' => 'test_field',
+ 'fields[_add_new_field][widget_type]' => 'test_field_widget',
+ );
+ $label = $initial_edit['fields[_add_new_field][label]'];
+ $field_name = $initial_edit['fields[_add_new_field][field_name]'];
+
+ // First step : 'Add new field' on the 'Manage fields' page.
+ $this->drupalPost("$bundle_path/fields", $initial_edit, t('Save'));
+ $this->assertRaw(t('These settings apply to the %label field everywhere it is used.', array('%label' => $label)), t('Field settings page was displayed.'));
+
+ // Second step : 'Field settings' form.
+ $this->drupalPost(NULL, $field_edit, t('Save field settings'));
+ $this->assertRaw(t('Updated field %label field settings.', array('%label' => $label)), t('Redirected to instance and widget settings page.'));
+
+ // Third step : 'Instance settings' form.
+ $this->drupalPost(NULL, $instance_edit, t('Save settings'));
+ $this->assertRaw(t('Saved %label configuration.', array('%label' => $label)), t('Redirected to "Manage fields" page.'));
+
+ // Check that the field appears in the overview form.
+ $this->assertFieldByXPath('//table[@id="field-overview"]//td[1]', $label, t('Field was created and appears in the overview page.'));
+ }
+
+ /**
+ * Add an existing field through the Field UI.
+ *
+ * @param $bundle_path
+ * Path of the 'Manage fields' page for the bundle.
+ * @param $initial_edit
+ * $edit parameter for drupalPost() on the first step ('Manage fields'
+ * screen).
+ * @param $instance_edit
+ * $edit parameter for drupalPost() on the second step ('Instance settings'
+ * form).
+ */
+ function fieldUIAddExistingField($bundle_path, $initial_edit, $instance_edit = array()) {
+ // Use 'test_field_widget' by default.
+ $initial_edit += array(
+ 'fields[_add_existing_field][widget_type]' => 'test_field_widget',
+ );
+ $label = $initial_edit['fields[_add_existing_field][label]'];
+ $field_name = $initial_edit['fields[_add_existing_field][field_name]'];
+
+ // First step : 'Add existing field' on the 'Manage fields' page.
+ $this->drupalPost("$bundle_path/fields", $initial_edit, t('Save'));
+
+ // Second step : 'Instance settings' form.
+ $this->drupalPost(NULL, $instance_edit, t('Save settings'));
+ $this->assertRaw(t('Saved %label configuration.', array('%label' => $label)), t('Redirected to "Manage fields" page.'));
+
+ // Check that the field appears in the overview form.
+ $this->assertFieldByXPath('//table[@id="field-overview"]//td[1]', $label, t('Field was created and appears in the overview page.'));
+ }
+
+ /**
+ * Delete a field instance through the Field UI.
+ *
+ * @param $bundle_path
+ * Path of the 'Manage fields' page for the bundle.
+ * @param $field_name
+ * The name of the field.
+ * @param $label
+ * The label of the field.
+ * @param $bundle_label
+ * The label of the bundle.
+ */
+ function fieldUIDeleteField($bundle_path, $field_name, $label, $bundle_label) {
+ // Display confirmation form.
+ $this->drupalGet("$bundle_path/fields/$field_name/delete");
+ $this->assertRaw(t('Are you sure you want to delete the field %label', array('%label' => $label)), t('Delete confirmation was found.'));
+
+ // Submit confirmation form.
+ $this->drupalPost(NULL, array(), t('Delete'));
+ $this->assertRaw(t('The field %label has been deleted from the %type content type.', array('%label' => $label, '%type' => $bundle_label)), t('Delete message was found.'));
+
+ // Check that the field does not appear in the overview form.
+ $this->assertNoFieldByXPath('//table[@id="field-overview"]//span[@class="label-field"]', $label, t('Field does not appear in the overview page.'));
+ }
+}
+
+/**
+ * Field UI tests for the 'Manage fields' screen.
+ */
+class FieldUIManageFieldsTestCase extends FieldUITestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Manage fields',
+ 'description' => 'Test the Field UI "Manage fields" screen.',
+ 'group' => 'Field UI',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create random field name.
+ $this->field_label = $this->randomName(8);
+ $this->field_name_input = strtolower($this->randomName(8));
+ $this->field_name = 'field_'. $this->field_name_input;
+ }
+
+ /**
+ * Main entry point for the field CRUD tests.
+ *
+ * In order to act on the same fields, and not create the fields over and over
+ * again the following tests create, update and delete the same fields.
+ */
+ function testCRUDFields() {
+ $this->manageFieldsPage();
+ $this->createField();
+ $this->updateField();
+ $this->addExistingField();
+ }
+
+ /**
+ * Test the manage fields page.
+ */
+ function manageFieldsPage() {
+ $this->drupalGet('admin/structure/types/manage/' . $this->hyphen_type . '/fields');
+ // Check all table columns.
+ $table_headers = array(
+ t('Label'),
+ t('Name'),
+ t('Field'),
+ t('Widget'),
+ t('Operations'),
+ );
+ foreach ($table_headers as $table_header) {
+ // We check that the label appear in the table headings.
+ $this->assertRaw($table_header . '</th>', t('%table_header table header was found.', array('%table_header' => $table_header)));
+ }
+
+ // "Add new field" and "Add existing field" aren't a table heading so just
+ // test the text.
+ foreach (array('Add new field', 'Add existing field') as $element) {
+ $this->assertText($element, t('"@element" was found.', array('@element' => $element)));
+ }
+ }
+
+ /**
+ * Test adding a new field.
+ *
+ * @todo Assert properties can bet set in the form and read back in $field and
+ * $instances.
+ */
+ function createField() {
+ // Create a test field.
+ $edit = array(
+ 'fields[_add_new_field][label]' => $this->field_label,
+ 'fields[_add_new_field][field_name]' => $this->field_name_input,
+ );
+ $this->fieldUIAddNewField('admin/structure/types/manage/' . $this->hyphen_type, $edit);
+
+ // Assert the field appears in the "add existing field" section for
+ // different entity types; e.g. if a field was added in a node entity, it
+ // should also appear in the 'taxonomy term' entity.
+ $vocabulary = taxonomy_vocabulary_load(1);
+ $this->drupalGet('admin/structure/taxonomy/' . $vocabulary->machine_name . '/fields');
+ $this->assertTrue($this->xpath('//select[@name="fields[_add_existing_field][field_name]"]//option[@value="' . $this->field_name . '"]'), t('Existing field was found in account settings.'));
+ }
+
+ /**
+ * Test editing an existing field.
+ */
+ function updateField() {
+ // Go to the field edit page.
+ $this->drupalGet('admin/structure/types/manage/' . $this->hyphen_type . '/fields/' . $this->field_name);
+
+ // Populate the field settings with new settings.
+ $string = 'updated dummy test string';
+ $edit = array(
+ 'field[settings][test_field_setting]' => $string,
+ 'instance[settings][test_instance_setting]' => $string,
+ 'instance[widget][settings][test_widget_setting]' => $string,
+ );
+ $this->drupalPost(NULL, $edit, t('Save settings'));
+
+ // Assert the field settings are correct.
+ $this->assertFieldSettings($this->type, $this->field_name, $string);
+
+ // Assert redirection back to the "manage fields" page.
+ $this->assertText(t('Saved @label configuration.', array('@label' => $this->field_label)), t('Redirected to "Manage fields" page.'));
+ }
+
+ /**
+ * Test adding an existing field in another content type.
+ */
+ function addExistingField() {
+ // Check "Add existing field" appears.
+ $this->drupalGet('admin/structure/types/manage/page/fields');
+ $this->assertRaw(t('Add existing field'), t('"Add existing field" was found.'));
+
+ // Check that the list of options respects entity type restrictions on
+ // fields. The 'comment' field is restricted to the 'comment' entity type
+ // and should not appear in the list.
+ $this->assertFalse($this->xpath('//select[@id="edit-add-existing-field-field-name"]//option[@value="comment"]'), t('The list of options respects entity type restrictions.'));
+
+ // Add a new field based on an existing field.
+ $edit = array(
+ 'fields[_add_existing_field][label]' => $this->field_label . '_2',
+ 'fields[_add_existing_field][field_name]' => $this->field_name,
+ );
+ $this->fieldUIAddExistingField("admin/structure/types/manage/page", $edit);
+ }
+
+ /**
+ * Assert the field settings.
+ *
+ * @param $bundle
+ * The bundle name for the instance.
+ * @param $field_name
+ * The field name for the instance.
+ * @param $string
+ * The settings text.
+ * @param $entity_type
+ * The entity type for the instance.
+ */
+ function assertFieldSettings($bundle, $field_name, $string = 'dummy test string', $entity_type = 'node') {
+ // Reset the fields info.
+ _field_info_collate_fields_reset();
+ // Assert field settings.
+ $field = field_info_field($field_name);
+ $this->assertTrue($field['settings']['test_field_setting'] == $string, t('Field settings were found.'));
+
+ // Assert instance and widget settings.
+ $instance = field_info_instance($entity_type, $field_name, $bundle);
+ $this->assertTrue($instance['settings']['test_instance_setting'] == $string, t('Field instance settings were found.'));
+ $this->assertTrue($instance['widget']['settings']['test_widget_setting'] == $string, t('Field widget settings were found.'));
+ }
+
+ /**
+ * Tests that default value is correctly validated and saved.
+ */
+ function testDefaultValue() {
+ // Create a test field and instance.
+ $field_name = 'test';
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'test_field'
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'node',
+ 'bundle' => $this->type,
+ );
+ field_create_instance($instance);
+
+ $langcode = LANGUAGE_NONE;
+ $admin_path = 'admin/structure/types/manage/' . $this->hyphen_type . '/fields/' . $field_name;
+ $element_id = "edit-$field_name-$langcode-0-value";
+ $element_name = "{$field_name}[$langcode][0][value]";
+ $this->drupalGet($admin_path);
+ $this->assertFieldById($element_id, '', t('The default value widget was empty.'));
+
+ // Check that invalid default values are rejected.
+ $edit = array($element_name => '-1');
+ $this->drupalPost($admin_path, $edit, t('Save settings'));
+ $this->assertText("$field_name does not accept the value -1", t('Form vaildation failed.'));
+
+ // Check that the default value is saved.
+ $edit = array($element_name => '1');
+ $this->drupalPost($admin_path, $edit, t('Save settings'));
+ $this->assertText("Saved $field_name configuration", t('The form was successfully submitted.'));
+ $instance = field_info_instance('node', $field_name, $this->type);
+ $this->assertEqual($instance['default_value'], array(array('value' => 1)), t('The default value was correctly saved.'));
+
+ // Check that the default value shows up in the form
+ $this->drupalGet($admin_path);
+ $this->assertFieldById($element_id, '1', t('The default value widget was displayed with the correct value.'));
+
+ // Check that the default value can be emptied.
+ $edit = array($element_name => '');
+ $this->drupalPost(NULL, $edit, t('Save settings'));
+ $this->assertText("Saved $field_name configuration", t('The form was successfully submitted.'));
+ field_info_cache_clear();
+ $instance = field_info_instance('node', $field_name, $this->type);
+ $this->assertEqual($instance['default_value'], NULL, t('The default value was correctly saved.'));
+ }
+
+ /**
+ * Tests that deletion removes fields and instances as expected.
+ */
+ function testDeleteField() {
+ // Create a new field.
+ $bundle_path1 = 'admin/structure/types/manage/' . $this->hyphen_type;
+ $edit1 = array(
+ 'fields[_add_new_field][label]' => $this->field_label,
+ 'fields[_add_new_field][field_name]' => $this->field_name,
+ );
+ $this->fieldUIAddNewField($bundle_path1, $edit1);
+
+ // Create an additional node type.
+ $type_name2 = strtolower($this->randomName(8)) . '_test';
+ $type2 = $this->drupalCreateContentType(array('name' => $type_name2, 'type' => $type_name2));
+ $type_name2 = $type2->type;
+ $hyphen_type2 = str_replace('_', '-', $type_name2);
+
+ // Add an instance to the second node type.
+ $bundle_path2 = 'admin/structure/types/manage/' . $hyphen_type2;
+ $edit2 = array(
+ 'fields[_add_existing_field][label]' => $this->field_label,
+ 'fields[_add_existing_field][field_name]' => $this->field_name,
+ );
+ $this->fieldUIAddExistingField($bundle_path2, $edit2);
+
+ // Delete the first instance.
+ $this->fieldUIDeleteField($bundle_path1, $this->field_name, $this->field_label, $this->type);
+
+ // Reset the fields info.
+ _field_info_collate_fields_reset();
+ // Check that the field instance was deleted.
+ $this->assertNull(field_info_instance('node', $this->field_name, $this->type), t('Field instance was deleted.'));
+ // Check that the field was not deleted
+ $this->assertNotNull(field_info_field($this->field_name), t('Field was not deleted.'));
+
+ // Delete the second instance.
+ $this->fieldUIDeleteField($bundle_path2, $this->field_name, $this->field_label, $type_name2);
+
+ // Reset the fields info.
+ _field_info_collate_fields_reset();
+ // Check that the field instance was deleted.
+ $this->assertNull(field_info_instance('node', $this->field_name, $type_name2), t('Field instance was deleted.'));
+ // Check that the field was deleted too.
+ $this->assertNull(field_info_field($this->field_name), t('Field was deleted.'));
+ }
+
+ /**
+ * Test that Field UI respects the 'no_ui' option in hook_field_info().
+ */
+ function testHiddenFields() {
+ $bundle_path = 'admin/structure/types/manage/' . $this->hyphen_type . '/fields/';
+
+ // Check that the field type is not available in the 'add new field' row.
+ $this->drupalGet($bundle_path);
+ $this->assertFalse($this->xpath('//select[@id="edit-add-new-field-type"]//option[@value="hidden_test_field"]'), t("The 'add new field' select respects field types 'no_ui' property."));
+
+ // Create a field and an instance programmatically.
+ $field_name = 'hidden_test_field';
+ field_create_field(array('field_name' => $field_name, 'type' => $field_name));
+ $instance = array(
+ 'field_name' => $field_name,
+ 'bundle' => $this->type,
+ 'entity_type' => 'node',
+ 'label' => t('Hidden field'),
+ 'widget' => array('type' => 'test_field_widget'),
+ );
+ field_create_instance($instance);
+ $this->assertTrue(field_read_instance('node', $field_name, $this->type), t('An instance of the field %field was created programmatically.', array('%field' => $field_name)));
+
+ // Check that the newly added instance appears on the 'Manage Fields'
+ // screen.
+ $this->drupalGet($bundle_path);
+ $this->assertFieldByXPath('//table[@id="field-overview"]//td[1]', $instance['label'], t('Field was created and appears in the overview page.'));
+
+ // Check that the instance does not appear in the 'add existing field' row
+ // on other bundles.
+ $bundle_path = 'admin/structure/types/manage/article/fields/';
+ $this->drupalGet($bundle_path);
+ $this->assertFalse($this->xpath('//select[@id="edit-add-existing-field-field-name"]//option[@value=:field_name]', array(':field_name' => $field_name)), t("The 'add existing field' select respects field types 'no_ui' property."));
+ }
+
+ /**
+ * Tests renaming a bundle.
+ */
+ function testRenameBundle() {
+ $type2 = strtolower($this->randomName(8)) . '_' .'test';
+ $hyphen_type2 = str_replace('_', '-', $type2);
+
+ $options = array(
+ 'type' => $type2,
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type, $options, t('Save content type'));
+
+ $this->drupalGet('admin/structure/types/manage/' . $hyphen_type2 . '/fields');
+ }
+}
+
+/**
+ * Field UI tests for the 'Manage display' screens.
+ */
+class FieldUIManageDisplayTestCase extends FieldUITestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Manage display',
+ 'description' => 'Test the Field UI "Manage display" screens.',
+ 'group' => 'Field UI',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('search'));
+ }
+
+ /**
+ * Test formatter formatter settings.
+ */
+ function testFormatterUI() {
+ $manage_fields = 'admin/structure/types/manage/' . $this->hyphen_type;
+ $manage_display = $manage_fields . '/display';
+
+ // Create a field, and a node with some data for the field.
+ $edit = array(
+ 'fields[_add_new_field][label]' => 'Test field',
+ 'fields[_add_new_field][field_name]' => 'field_test',
+ );
+ $this->fieldUIAddNewField($manage_fields, $edit);
+
+ // Clear the test-side cache and get the saved field instance.
+ field_info_cache_clear();
+ $instance = field_info_instance('node', 'field_test', $this->type);
+ $format = $instance['display']['default']['type'];
+ $default_settings = field_info_formatter_settings($format);
+ $setting_name = key($default_settings);
+ $setting_value = $instance['display']['default']['settings'][$setting_name];
+
+ // Display the "Manage display" screen and check that the expected formatter is
+ // selected.
+ $this->drupalGet($manage_display);
+ $this->assertFieldByName('fields[field_test][type]', $format, t('The expected formatter is selected.'));
+ $this->assertText("$setting_name: $setting_value", t('The expected summary is displayed.'));
+
+ // Change the formatter and check that the summary is updated.
+ $edit = array('fields[field_test][type]' => 'field_test_multiple', 'refresh_rows' => 'field_test');
+ $this->drupalPostAJAX(NULL, $edit, array('op' => t('Refresh')));
+ $format = 'field_test_multiple';
+ $default_settings = field_info_formatter_settings($format);
+ $setting_name = key($default_settings);
+ $setting_value = $default_settings[$setting_name];
+ $this->assertFieldByName('fields[field_test][type]', $format, t('The expected formatter is selected.'));
+ $this->assertText("$setting_name: $setting_value", t('The expected summary is displayed.'));
+
+ // Submit the form and check that the instance is updated.
+ $this->drupalPost(NULL, array(), t('Save'));
+ field_info_cache_clear();
+ $instance = field_info_instance('node', 'field_test', $this->type);
+ $current_format = $instance['display']['default']['type'];
+ $current_setting_value = $instance['display']['default']['settings'][$setting_name];
+ $this->assertEqual($current_format, $format, t('The formatter was updated.'));
+ $this->assertEqual($current_setting_value, $setting_value, t('The setting was updated.'));
+ }
+
+ /**
+ * Test switching view modes to use custom or 'default' settings'.
+ */
+ function testViewModeCustom() {
+ // Create a field, and a node with some data for the field.
+ $edit = array(
+ 'fields[_add_new_field][label]' => 'Test field',
+ 'fields[_add_new_field][field_name]' => 'field_test',
+ );
+ $this->fieldUIAddNewField('admin/structure/types/manage/' . $this->hyphen_type, $edit);
+ // For this test, use a formatter setting value that is an integer unlikely
+ // to appear in a rendered node other than as part of the field being tested
+ // (for example, unlikely to be part of the "Submitted by ... on ..." line).
+ $value = 12345;
+ $settings = array(
+ 'type' => $this->type,
+ 'field_test' => array(LANGUAGE_NONE => array(array('value' => $value))),
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Gather expected output values with the various formatters.
+ $formatters = field_info_formatter_types();
+ $output = array(
+ 'field_test_default' => $formatters['field_test_default']['settings']['test_formatter_setting'] . '|' . $value,
+ 'field_test_with_prepare_view' => $formatters['field_test_with_prepare_view']['settings']['test_formatter_setting_additional'] . '|' . $value. '|' . ($value + 1),
+ );
+
+ // Check that the field is displayed with the default formatter in 'rss'
+ // mode (uses 'default'), and hidden in 'teaser' mode (uses custom settings).
+ $this->assertNodeViewText($node, 'rss', $output['field_test_default'], t("The field is displayed as expected in view modes that use 'default' settings."));
+ $this->assertNodeViewNoText($node, 'teaser', $value, t("The field is hidden in view modes that use custom settings."));
+
+ // Change fomatter for 'default' mode, check that the field is displayed
+ // accordingly in 'rss' mode.
+ $edit = array(
+ 'fields[field_test][type]' => 'field_test_with_prepare_view',
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type . '/display', $edit, t('Save'));
+ $this->assertNodeViewText($node, 'rss', $output['field_test_with_prepare_view'], t("The field is displayed as expected in view modes that use 'default' settings."));
+
+ // Specialize the 'rss' mode, check that the field is displayed the same.
+ $edit = array(
+ "view_modes_custom[rss]" => TRUE,
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type . '/display', $edit, t('Save'));
+ $this->assertNodeViewText($node, 'rss', $output['field_test_with_prepare_view'], t("The field is displayed as expected in newly specialized 'rss' mode."));
+
+ // Set the field to 'hidden' in the view mode, check that the field is
+ // hidden.
+ $edit = array(
+ 'fields[field_test][type]' => 'hidden',
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type . '/display/rss', $edit, t('Save'));
+ $this->assertNodeViewNoText($node, 'rss', $value, t("The field is hidden in 'rss' mode."));
+
+ // Set the view mode back to 'default', check that the field is displayed
+ // accordingly.
+ $edit = array(
+ "view_modes_custom[rss]" => FALSE,
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type . '/display', $edit, t('Save'));
+ $this->assertNodeViewText($node, 'rss', $output['field_test_with_prepare_view'], t("The field is displayed as expected when 'rss' mode is set back to 'default' settings."));
+
+ // Specialize the view mode again.
+ $edit = array(
+ "view_modes_custom[rss]" => TRUE,
+ );
+ $this->drupalPost('admin/structure/types/manage/' . $this->hyphen_type . '/display', $edit, t('Save'));
+ // Check that the previous settings for the view mode have been kept.
+ $this->assertNodeViewNoText($node, 'rss', $value, t("The previous settings are kept when 'rss' mode is specialized again."));
+ }
+
+ /**
+ * Pass if the text is found in the rendered node in a given view mode.
+ *
+ * @param $node
+ * The node.
+ * @param $view_mode
+ * The view mode in which the node should be displayed.
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertNodeViewText($node, $view_mode, $text, $message) {
+ return $this->assertNodeViewTextHelper($node, $view_mode, $text, $message, FALSE);
+ }
+
+ /**
+ * Pass if the text is node found in the rendered node in a given view mode.
+ *
+ * @param $node
+ * The node.
+ * @param $view_mode
+ * The view mode in which the node should be displayed.
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertNodeViewNoText($node, $view_mode, $text, $message) {
+ return $this->assertNodeViewTextHelper($node, $view_mode, $text, $message, TRUE);
+ }
+
+ /**
+ * Helper for assertNodeViewText and assertNodeViewNoText.
+ *
+ * @param $node
+ * The node.
+ * @param $view_mode
+ * The view mode in which the node should be displayed.
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $not_exists
+ * TRUE if this text should not exist, FALSE if it should.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertNodeViewTextHelper($node, $view_mode, $text, $message, $not_exists) {
+ // Make sure caches on the tester side are refreshed after changes
+ // submitted on the tested side.
+ field_info_cache_clear();
+
+ // Save current content so that we can restore it when we're done.
+ $old_content = $this->drupalGetContent();
+
+ // Render a cloned node, so that we do not alter the original.
+ $clone = clone $node;
+ $element = node_view($clone, $view_mode);
+ $output = drupal_render($element);
+ $this->verbose(t('Rendered node - view mode: @view_mode', array('@view_mode' => $view_mode)) . '<hr />'. $output);
+
+ // Assign content so that DrupalWebTestCase functions can be used.
+ $this->drupalSetContent($output);
+ $method = ($not_exists ? 'assertNoText' : 'assertText');
+ $return = $this->{$method}((string) $text, $message);
+
+ // Restore previous content.
+ $this->drupalSetContent($old_content);
+
+ return $return;
+ }
+}
diff --git a/core/modules/file/file.api.php b/core/modules/file/file.api.php
new file mode 100644
index 000000000000..76fb9861070a
--- /dev/null
+++ b/core/modules/file/file.api.php
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * @file
+ * Hooks for file module.
+ */
+
+/**
+ * Control download access to files.
+ *
+ * The hook is typically implemented to limit access based on the entity the
+ * file is referenced, e.g., only users with access to a node should be allowed
+ * to download files attached to that node.
+ *
+ * @param $field
+ * The field to which the file belongs.
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The $entity to which $file is referenced.
+ *
+ * @return
+ * TRUE is access should be allowed by this entity or FALSE if denied. Note
+ * that denial may be overridden by another entity controller, making this
+ * grant permissive rather than restrictive.
+ *
+ * @see hook_field_access().
+ */
+function hook_file_download_access($field, $entity_type, $entity) {
+ if ($entity_type == 'node') {
+ return node_access('view', $entity);
+ }
+}
+
+/**
+ * Alter the access rules applied to a file download.
+ *
+ * Entities that implement file management set the access rules for their
+ * individual files. Module may use this hook to create custom access rules
+ * for file downloads.
+ *
+ * @see hook_file_download_access().
+ *
+ * @param $grants
+ * An array of grants gathered by hook_file_download_access(). The array is
+ * keyed by the module that defines the entity type's access control; the
+ * values are Boolean grant responses for each module.
+ * @param $field
+ * The field to which the file belongs.
+ * @param $entity_type
+ * The type of $entity; for example, 'node' or 'user'.
+ * @param $entity
+ * The $entity to which $file is referenced.
+ *
+ * @return
+ * An array of grants, keyed by module name, each with a Boolean grant value.
+ * Return an empty array to assert FALSE. You may choose to return your own
+ * module's value in addition to other grants or to overwrite the values set by
+ * other modules.
+ */
+function hook_file_download_access_alter(&$grants, $field, $entity_type, $entity) {
+ // For our example module, we always enforce the rules set by node module.
+ if (isset($grants['node'])) {
+ $grants = array('node' => $grants['node']);
+ }
+}
diff --git a/core/modules/file/file.css b/core/modules/file/file.css
new file mode 100644
index 000000000000..aed1a9d34cef
--- /dev/null
+++ b/core/modules/file/file.css
@@ -0,0 +1,35 @@
+
+/**
+ * Managed file element styles.
+ */
+.form-managed-file .form-file,
+.form-managed-file .form-submit {
+ margin: 0;
+}
+
+.form-managed-file input.progress-disabled {
+ float: none;
+ display: inline;
+}
+
+.form-managed-file div.ajax-progress,
+.form-managed-file div.throbber {
+ display: inline;
+ float: none;
+ padding: 1px 5px 2px 5px;
+}
+
+.form-managed-file div.ajax-progress div {
+ display: inline;
+}
+
+.form-managed-file div.ajax-progress-bar {
+ display: none;
+ margin-top: 4px;
+ width: 28em;
+ padding: 0;
+}
+
+.form-managed-file div.ajax-progress-bar div.bar {
+ margin: 0;
+}
diff --git a/core/modules/file/file.field.inc b/core/modules/file/file.field.inc
new file mode 100644
index 000000000000..35696dda62c0
--- /dev/null
+++ b/core/modules/file/file.field.inc
@@ -0,0 +1,1003 @@
+<?php
+
+/**
+ * @file
+ * Field module functionality for the File module.
+ */
+
+/**
+ * Implements hook_field_info().
+ */
+function file_field_info() {
+ return array(
+ 'file' => array(
+ 'label' => t('File'),
+ 'description' => t('This field stores the ID of a file as an integer value.'),
+ 'settings' => array(
+ 'display_field' => 0,
+ 'display_default' => 0,
+ 'uri_scheme' => variable_get('file_default_scheme', 'public'),
+ ),
+ 'instance_settings' => array(
+ 'file_extensions' => 'txt',
+ 'file_directory' => '',
+ 'max_filesize' => '',
+ 'description_field' => 0,
+ ),
+ 'default_widget' => 'file_generic',
+ 'default_formatter' => 'file_default',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function file_field_settings_form($field, $instance, $has_data) {
+ $defaults = field_info_field_settings($field['type']);
+ $settings = array_merge($defaults, $field['settings']);
+
+ $form['#attached']['js'][] = drupal_get_path('module', 'file') . '/file.js';
+
+ $form['display_field'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable <em>Display</em> field'),
+ '#default_value' => $settings['display_field'],
+ '#description' => t('The display option allows users to choose if a file should be shown when viewing the content.'),
+ );
+ $form['display_default'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Files displayed by default'),
+ '#default_value' => $settings['display_default'],
+ '#description' => t('This setting only has an effect if the display option is enabled.'),
+ );
+
+ $scheme_options = array();
+ foreach (file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE) as $scheme => $stream_wrapper) {
+ $scheme_options[$scheme] = $stream_wrapper['name'];
+ }
+ $form['uri_scheme'] = array(
+ '#type' => 'radios',
+ '#title' => t('Upload destination'),
+ '#options' => $scheme_options,
+ '#default_value' => $settings['uri_scheme'],
+ '#description' => t('Select where the final files should be stored. Private file storage has significantly more overhead than public files, but allows restricted access to files within this field.'),
+ '#disabled' => $has_data,
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_instance_settings_form().
+ */
+function file_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ $form['file_directory'] = array(
+ '#type' => 'textfield',
+ '#title' => t('File directory'),
+ '#default_value' => $settings['file_directory'],
+ '#description' => t('Optional subdirectory within the upload destination where files will be stored. Do not include preceding or trailing slashes.'),
+ '#element_validate' => array('_file_generic_settings_file_directory_validate'),
+ '#weight' => 3,
+ );
+
+ // Make the extension list a little more human-friendly by comma-separation.
+ $extensions = str_replace(' ', ', ', $settings['file_extensions']);
+ $form['file_extensions'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Allowed file extensions'),
+ '#default_value' => $extensions,
+ '#description' => t('Separate extensions with a space or comma and do not include the leading dot.'),
+ '#element_validate' => array('_file_generic_settings_extensions'),
+ '#weight' => 1,
+ // By making this field required, we prevent a potential security issue
+ // that would allow files of any type to be uploaded.
+ '#required' => TRUE,
+ );
+
+ $form['max_filesize'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum upload size'),
+ '#default_value' => $settings['max_filesize'],
+ '#description' => t('Enter a value like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes) in order to restrict the allowed file size. If left empty the file sizes will be limited only by PHP\'s maximum post and file upload sizes (current limit <strong>%limit</strong>).', array('%limit' => format_size(file_upload_max_size()))),
+ '#size' => 10,
+ '#element_validate' => array('_file_generic_settings_max_filesize'),
+ '#weight' => 5,
+ );
+
+ $form['description_field'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable <em>Description</em> field'),
+ '#default_value' => isset($settings['description_field']) ? $settings['description_field'] : '',
+ '#description' => t('The description field allows users to enter a description about the uploaded file.'),
+ '#parents' => array('instance', 'settings', 'description_field'),
+ '#weight' => 11,
+ );
+
+ return $form;
+}
+
+/**
+ * Element validate callback for the maximum upload size field.
+ *
+ * Ensure a size that can be parsed by parse_size() has been entered.
+ */
+function _file_generic_settings_max_filesize($element, &$form_state) {
+ if (!empty($element['#value']) && !is_numeric(parse_size($element['#value']))) {
+ form_error($element, t('The "!name" option must contain a valid value. You may either leave the text field empty or enter a string like "512" (bytes), "80 KB" (kilobytes) or "50 MB" (megabytes).', array('!name' => t($element['title']))));
+ }
+}
+
+/**
+ * Element validate callback for the allowed file extensions field.
+ *
+ * This doubles as a convenience clean-up function and a validation routine.
+ * Commas are allowed by the end-user, but ultimately the value will be stored
+ * as a space-separated list for compatibility with file_validate_extensions().
+ */
+function _file_generic_settings_extensions($element, &$form_state) {
+ if (!empty($element['#value'])) {
+ $extensions = preg_replace('/([, ]+\.?)/', ' ', trim(strtolower($element['#value'])));
+ $extensions = array_filter(explode(' ', $extensions));
+ $extensions = implode(' ', array_unique($extensions));
+ if (!preg_match('/^([a-z0-9]+([.][a-z0-9])* ?)+$/', $extensions)) {
+ form_error($element, t('The list of allowed extensions is not valid, be sure to exclude leading dots and to separate extensions with a comma or space.'));
+ }
+ else {
+ form_set_value($element, $extensions, $form_state);
+ }
+ }
+}
+
+/**
+ * Element validate callback for the file destination field.
+ *
+ * Remove slashes from the beginning and end of the destination value and ensure
+ * that the file directory path is not included at the beginning of the value.
+ */
+function _file_generic_settings_file_directory_validate($element, &$form_state) {
+ // Strip slashes from the beginning and end of $widget['file_directory'].
+ $value = trim($element['#value'], '\\/');
+ form_set_value($element, $value, $form_state);
+}
+
+/**
+ * Implements hook_field_load().
+ */
+function file_field_load($entity_type, $entities, $field, $instances, $langcode, &$items, $age) {
+
+ $fids = array();
+ foreach ($entities as $id => $entity) {
+ // Load the files from the files table.
+ foreach ($items[$id] as $delta => $item) {
+ if (!empty($item['fid'])) {
+ $fids[] = $item['fid'];
+ }
+ }
+ }
+ $files = file_load_multiple($fids);
+
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ // If the file does not exist, mark the entire item as empty.
+ if (empty($item['fid']) || !isset($files[$item['fid']])) {
+ $items[$id][$delta] = NULL;
+ }
+ else {
+ $items[$id][$delta] = array_merge($item, (array) $files[$item['fid']]);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_prepare_view().
+ */
+function file_field_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items) {
+ // Remove files specified to not be displayed.
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ if (!file_field_displayed($item, $field)) {
+ unset($items[$id][$delta]);
+ }
+ }
+ // Ensure consecutive deltas.
+ $items[$id] = array_values($items[$id]);
+ }
+}
+
+/**
+ * Implements hook_field_presave().
+ */
+function file_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ // Make sure that each file which will be saved with this object has a
+ // permanent status, so that it will not be removed when temporary files are
+ // cleaned up.
+ foreach ($items as $item) {
+ $file = file_load($item['fid']);
+ if (!$file->status) {
+ $file->status = FILE_STATUS_PERMANENT;
+ file_save($file);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_insert().
+ */
+function file_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Add a new usage of each uploaded file.
+ foreach ($items as $item) {
+ $file = (object) $item;
+ file_usage_add($file, 'file', $entity_type, $id);
+ }
+}
+
+/**
+ * Implements hook_field_update().
+ *
+ * Checks for files that have been removed from the object.
+ */
+function file_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // On new revisions, all files are considered to be a new usage and no
+ // deletion of previous file usages are necessary.
+ if (!empty($entity->revision)) {
+ foreach ($items as $item) {
+ $file = (object) $item;
+ file_usage_add($file, 'file', $entity_type, $id);
+ }
+ return;
+ }
+
+ // Build a display of the current FIDs.
+ $current_fids = array();
+ foreach ($items as $item) {
+ $current_fids[] = $item['fid'];
+ }
+
+ // Create a bare-bones entity so that we can load its previous values.
+ $original = entity_create_stub_entity($entity_type, array($id, $vid, $bundle));
+ field_attach_load($entity_type, array($id => $original), FIELD_LOAD_CURRENT, array('field_id' => $field['id']));
+
+ // Compare the original field values with the ones that are being saved.
+ $original_fids = array();
+ if (!empty($original->{$field['field_name']}[$langcode])) {
+ foreach ($original->{$field['field_name']}[$langcode] as $original_item) {
+ $original_fids[] = $original_item['fid'];
+ if (isset($original_item['fid']) && !in_array($original_item['fid'], $current_fids)) {
+ // Decrement the file usage count by 1 and delete the file if possible.
+ file_field_delete_file($original_item, $field, $entity_type, $id);
+ }
+ }
+ }
+
+ // Add new usage entries for newly added files.
+ foreach ($items as $item) {
+ if (!in_array($item['fid'], $original_fids)) {
+ $file = (object) $item;
+ file_usage_add($file, 'file', $entity_type, $id);
+ }
+ }
+}
+
+/**
+ * Implements hook_field_delete().
+ */
+function file_field_delete($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+
+ // Delete all file usages within this entity.
+ foreach ($items as $delta => $item) {
+ file_field_delete_file($item, $field, $entity_type, $id, 0);
+ }
+}
+
+/**
+ * Implements hook_field_delete_revision().
+ */
+function file_field_delete_revision($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ list($id, $vid, $bundle) = entity_extract_ids($entity_type, $entity);
+ foreach ($items as $delta => $item) {
+ // Decrement the file usage count by 1 and delete the file if possible.
+ if (file_field_delete_file($item, $field, $entity_type, $id)) {
+ $items[$delta] = NULL;
+ }
+ }
+}
+
+/**
+ * Decrements a file usage count and attempts to delete it.
+ *
+ * This function only has an effect if the file being deleted is used only by
+ * File module.
+ *
+ * @param $item
+ * The field item that contains a file array.
+ * @param $field
+ * The field structure for the operation.
+ * @param $entity_type
+ * The type of $entity.
+ * @param $id
+ * The entity ID which contains the file being deleted.
+ * @param $count
+ * (optional) The number of references to decrement from the object
+ * containing the file. Defaults to 1.
+ *
+ * @return
+ * Boolean TRUE if the file was deleted, or an array of remaining references
+ * if the file is still in use by other modules. Boolean FALSE if an error
+ * was encountered.
+ */
+function file_field_delete_file($item, $field, $entity_type, $id, $count = 1) {
+ // To prevent the file field from deleting files it doesn't know about, check
+ // the file reference count. Temporary files can be deleted because they
+ // are not yet associated with any content at all.
+ $file = (object) $item;
+ $file_usage = file_usage_list($file);
+ if ($file->status == 0 || !empty($file_usage['file'])) {
+ file_usage_delete($file, 'file', $entity_type, $id, $count);
+ return file_delete($file);
+ }
+
+ // Even if the file is not deleted, return TRUE to indicate the file field
+ // record can be removed from the field database tables.
+ return TRUE;
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function file_field_is_empty($item, $field) {
+ return empty($item['fid']);
+}
+
+/**
+ * Determine whether a file should be displayed when outputting field content.
+ *
+ * @param $item
+ * A field item array.
+ * @param $field
+ * A field array.
+ * @return
+ * Boolean TRUE if the file will be displayed, FALSE if the file is hidden.
+ */
+function file_field_displayed($item, $field) {
+ if (!empty($field['settings']['display_field'])) {
+ return (bool) $item['display'];
+ }
+ return TRUE;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function file_field_formatter_info() {
+ return array(
+ 'file_default' => array(
+ 'label' => t('Generic file'),
+ 'field types' => array('file'),
+ ),
+ 'file_table' => array(
+ 'label' => t('Table of files'),
+ 'field types' => array('file'),
+ ),
+ 'file_url_plain' => array(
+ 'label' => t('URL to file'),
+ 'field types' => array('file'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function file_field_widget_info() {
+ return array(
+ 'file_generic' => array(
+ 'label' => t('File'),
+ 'field types' => array('file'),
+ 'settings' => array(
+ 'progress_indicator' => 'throbber',
+ ),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ 'default value' => FIELD_BEHAVIOR_NONE,
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_settings_form().
+ */
+function file_field_widget_settings_form($field, $instance) {
+ $widget = $instance['widget'];
+ $settings = $widget['settings'];
+
+ $form['progress_indicator'] = array(
+ '#type' => 'radios',
+ '#title' => t('Progress indicator'),
+ '#options' => array(
+ 'throbber' => t('Throbber'),
+ 'bar' => t('Bar with progress meter'),
+ ),
+ '#default_value' => $settings['progress_indicator'],
+ '#description' => t('The throbber display does not show the status of uploads but takes up less space. The progress bar is helpful for monitoring progress on large uploads.'),
+ '#weight' => 16,
+ '#access' => file_progress_implementation(),
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function file_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+
+ $defaults = array(
+ 'fid' => 0,
+ 'display' => !empty($field['settings']['display_default']),
+ 'description' => '',
+ );
+
+ // Load the items for form rebuilds from the field state as they might not be
+ // in $form_state['values'] because of validation limitations. Also, they are
+ // only passed in as $items when editing existing entities.
+ $field_state = field_form_get_state($element['#field_parents'], $field['field_name'], $langcode, $form_state);
+ if (isset($field_state['items'])) {
+ $items = $field_state['items'];
+ }
+
+ // Essentially we use the managed_file type, extended with some enhancements.
+ $element_info = element_info('managed_file');
+ $element += array(
+ '#type' => 'managed_file',
+ '#upload_location' => file_field_widget_uri($field, $instance),
+ '#upload_validators' => file_field_widget_upload_validators($field, $instance),
+ '#value_callback' => 'file_field_widget_value',
+ '#process' => array_merge($element_info['#process'], array('file_field_widget_process')),
+ // Allows this field to return an array instead of a single value.
+ '#extended' => TRUE,
+ );
+
+ if ($field['cardinality'] == 1) {
+ // Set the default value.
+ $element['#default_value'] = !empty($items) ? $items[0] : $defaults;
+ // If there's only one field, return it as delta 0.
+ if (empty($element['#default_value']['fid'])) {
+ $element['#description'] = theme('file_upload_help', array('description' => $element['#description'], 'upload_validators' => $element['#upload_validators']));
+ }
+ $elements = array($element);
+ }
+ else {
+ // If there are multiple values, add an element for each existing one.
+ foreach ($items as $item) {
+ $elements[$delta] = $element;
+ $elements[$delta]['#default_value'] = $item;
+ $elements[$delta]['#weight'] = $delta;
+ $delta++;
+ }
+ // And then add one more empty row for new uploads except when this is a
+ // programmed form as it is not necessary.
+ if (($field['cardinality'] == FIELD_CARDINALITY_UNLIMITED || $delta < $field['cardinality']) && empty($form_state['programmed'])) {
+ $elements[$delta] = $element;
+ $elements[$delta]['#default_value'] = $defaults;
+ $elements[$delta]['#weight'] = $delta;
+ $elements[$delta]['#required'] = ($element['#required'] && $delta == 0);
+ }
+ // The group of elements all-together need some extra functionality
+ // after building up the full list (like draggable table rows).
+ $elements['#file_upload_delta'] = $delta;
+ $elements['#theme'] = 'file_widget_multiple';
+ $elements['#theme_wrappers'] = array('fieldset');
+ $elements['#process'] = array('file_field_widget_process_multiple');
+ $elements['#title'] = $element['#title'];
+ $elements['#description'] = $element['#description'];
+ $elements['#field_name'] = $element['#field_name'];
+ $elements['#language'] = $element['#language'];
+ $elements['#display_field'] = $field['settings']['display_field'];
+
+ // Add some properties that will eventually be added to the file upload
+ // field. These are added here so that they may be referenced easily through
+ // a hook_form_alter().
+ $elements['#file_upload_title'] = t('Add a new file');
+ $elements['#file_upload_description'] = theme('file_upload_help', array('description' => '', 'upload_validators' => $elements[0]['#upload_validators']));
+ }
+
+ return $elements;
+}
+
+/**
+ * Get the upload validators for a file field.
+ *
+ * @param $field
+ * A field array.
+ * @return
+ * An array suitable for passing to file_save_upload() or the file field
+ * element's '#upload_validators' property.
+ */
+function file_field_widget_upload_validators($field, $instance) {
+ // Cap the upload size according to the PHP limit.
+ $max_filesize = parse_size(file_upload_max_size());
+ if (!empty($instance['settings']['max_filesize']) && parse_size($instance['settings']['max_filesize']) < $max_filesize) {
+ $max_filesize = parse_size($instance['settings']['max_filesize']);
+ }
+
+ $validators = array();
+
+ // There is always a file size limit due to the PHP server limit.
+ $validators['file_validate_size'] = array($max_filesize);
+
+ // Add the extension check if necessary.
+ if (!empty($instance['settings']['file_extensions'])) {
+ $validators['file_validate_extensions'] = array($instance['settings']['file_extensions']);
+ }
+
+ return $validators;
+}
+
+/**
+ * Determine the URI for a file field instance.
+ *
+ * @param $field
+ * A field array.
+ * @param $instance
+ * A field instance array.
+ * @param $data
+ * An array of token objects to pass to token_replace().
+ * @return
+ * A file directory URI with tokens replaced.
+ *
+ * @see token_replace()
+ */
+function file_field_widget_uri($field, $instance, $data = array()) {
+ $destination = trim($instance['settings']['file_directory'], '/');
+
+ // Replace tokens.
+ $destination = token_replace($destination, $data);
+
+ return $field['settings']['uri_scheme'] . '://' . $destination;
+}
+
+/**
+ * The #value_callback for the file_generic field element.
+ */
+function file_field_widget_value($element, $input = FALSE, $form_state) {
+ if ($input) {
+ // Checkboxes lose their value when empty.
+ // If the display field is present make sure its unchecked value is saved.
+ $field = field_widget_field($element, $form_state);
+ if (empty($input['display'])) {
+ $input['display'] = $field['settings']['display_field'] ? 0 : 1;
+ }
+ }
+
+ // We depend on the managed file element to handle uploads.
+ $return = file_managed_file_value($element, $input, $form_state);
+
+ // Ensure that all the required properties are returned even if empty.
+ $return += array(
+ 'fid' => 0,
+ 'display' => 1,
+ 'description' => '',
+ );
+
+ return $return;
+}
+
+/**
+ * An element #process callback for the file_generic field type.
+ *
+ * Expands the file_generic type to include the description and display fields.
+ */
+function file_field_widget_process($element, &$form_state, $form) {
+ $item = $element['#value'];
+ $item['fid'] = $element['fid']['#value'];
+
+ $field = field_widget_field($element, $form_state);
+ $instance = field_widget_instance($element, $form_state);
+ $settings = $instance['widget']['settings'];
+
+ $element['#theme'] = 'file_widget';
+
+ // Add the display field if enabled.
+ if (!empty($field['settings']['display_field']) && $item['fid']) {
+ $element['display'] = array(
+ '#type' => empty($item['fid']) ? 'hidden' : 'checkbox',
+ '#title' => t('Include file in display'),
+ '#value' => isset($item['display']) ? $item['display'] : $field['settings']['display_default'],
+ '#attributes' => array('class' => array('file-display')),
+ );
+ }
+ else {
+ $element['display'] = array(
+ '#type' => 'hidden',
+ '#value' => '1',
+ );
+ }
+
+ // Add the description field if enabled.
+ if (!empty($instance['settings']['description_field']) && $item['fid']) {
+ $element['description'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Description'),
+ '#value' => isset($item['description']) ? $item['description'] : '',
+ '#type' => variable_get('file_description_type', 'textfield'),
+ '#maxlength' => variable_get('file_description_length', 128),
+ '#description' => t('The description may be used as the label of the link to the file.'),
+ );
+ }
+
+ // Adjust the Ajax settings so that on upload and remove of any individual
+ // file, the entire group of file fields is updated together.
+ if ($field['cardinality'] != 1) {
+ $parents = array_slice($element['#array_parents'], 0, -1);
+ $new_path = 'file/ajax/' . implode('/', $parents) . '/' . $form['form_build_id']['#value'];
+ $field_element = drupal_array_get_nested_value($form, $parents);
+ $new_wrapper = $field_element['#id'] . '-ajax-wrapper';
+ foreach (element_children($element) as $key) {
+ if (isset($element[$key]['#ajax'])) {
+ $element[$key]['#ajax']['path'] = $new_path;
+ $element[$key]['#ajax']['wrapper'] = $new_wrapper;
+ }
+ }
+ unset($element['#prefix'], $element['#suffix']);
+ }
+
+ // Add another submit handler to the upload and remove buttons, to implement
+ // functionality needed by the field widget. This submit handler, along with
+ // the rebuild logic in file_field_widget_form() requires the entire field,
+ // not just the individual item, to be valid.
+ foreach (array('upload_button', 'remove_button') as $key) {
+ $element[$key]['#submit'][] = 'file_field_widget_submit';
+ $element[$key]['#limit_validation_errors'] = array(array_slice($element['#parents'], 0, -1));
+ }
+
+ return $element;
+}
+
+/**
+ * An element #process callback for a group of file_generic fields.
+ *
+ * Adds the weight field to each row so it can be ordered and adds a new Ajax
+ * wrapper around the entire group so it can be replaced all at once.
+ */
+function file_field_widget_process_multiple($element, &$form_state, $form) {
+ $element_children = element_children($element, TRUE);
+ $count = count($element_children);
+
+ foreach ($element_children as $delta => $key) {
+ if ($key != $element['#file_upload_delta']) {
+ $description = _file_field_get_description_from_element($element[$key]);
+ $element[$key]['_weight'] = array(
+ '#type' => 'weight',
+ '#title' => $description ? t('Weight for @title', array('@title' => $description)) : t('Weight for new file'),
+ '#title_display' => 'invisible',
+ '#delta' => $count,
+ '#default_value' => $delta,
+ );
+ }
+ else {
+ // The title needs to be assigned to the upload field so that validation
+ // errors include the correct widget label.
+ $element[$key]['#title'] = $element['#title'];
+ $element[$key]['_weight'] = array(
+ '#type' => 'hidden',
+ '#default_value' => $delta,
+ );
+ }
+ }
+
+ // Add a new wrapper around all the elements for Ajax replacement.
+ $element['#prefix'] = '<div id="' . $element['#id'] . '-ajax-wrapper">';
+ $element['#suffix'] = '</div>';
+
+ return $element;
+}
+
+/**
+ * Helper function for file_field_widget_process_multiple().
+ *
+ * @param $element
+ * The element being processed.
+ * @return
+ * A description of the file suitable for use in the administrative interface.
+ */
+function _file_field_get_description_from_element($element) {
+ // Use the actual file description, if it's available.
+ if (!empty($element['#default_value']['description'])) {
+ return $element['#default_value']['description'];
+ }
+ // Otherwise, fall back to the filename.
+ if (!empty($element['#default_value']['filename'])) {
+ return $element['#default_value']['filename'];
+ }
+ // This is probably a newly uploaded file; no description is available.
+ return FALSE;
+}
+
+/**
+ * Submit handler for upload and remove buttons of file_generic fields.
+ *
+ * This runs in addition to and after file_managed_file_submit().
+ *
+ * @see file_managed_file_submit()
+ * @see file_field_widget_form()
+ * @see file_field_widget_process()
+ */
+function file_field_widget_submit($form, &$form_state) {
+ // During the form rebuild, file_field_widget_form() will create field item
+ // widget elements using re-indexed deltas, so clear out $form_state['input']
+ // to avoid a mismatch between old and new deltas. The rebuilt elements will
+ // have #default_value set appropriately for the current state of the field,
+ // so nothing is lost in doing this.
+ $parents = array_slice($form_state['triggering_element']['#parents'], 0, -2);
+ drupal_array_set_nested_value($form_state['input'], $parents, NULL);
+
+ $button = $form_state['triggering_element'];
+
+ // Go one level up in the form, to the widgets container.
+ $element = drupal_array_get_nested_value($form, array_slice($button['#array_parents'], 0, -1));
+ $field_name = $element['#field_name'];
+ $langcode = $element['#language'];
+ $parents = $element['#field_parents'];
+
+ $submitted_values = drupal_array_get_nested_value($form_state['values'], array_slice($button['#array_parents'], 0, -2));
+ foreach ($submitted_values as $delta => $submitted_value) {
+ if (!$submitted_value['fid']) {
+ unset($submitted_values[$delta]);
+ }
+ }
+
+ // Re-index deltas after removing empty items.
+ $submitted_values = array_values($submitted_values);
+
+ // Update form_state values.
+ drupal_array_set_nested_value($form_state['values'], array_slice($button['#array_parents'], 0, -2), $submitted_values);
+
+ // Update items.
+ $field_state = field_form_get_state($parents, $field_name, $langcode, $form_state);
+ $field_state['items'] = $submitted_values;
+ field_form_set_state($parents, $field_name, $langcode, $form_state, $field_state);
+}
+
+/**
+ * Returns HTML for an individual file upload widget.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the widget.
+ *
+ * @ingroup themeable
+ */
+function theme_file_widget($variables) {
+ $element = $variables['element'];
+ $output = '';
+
+ // The "form-managed-file" class is required for proper Ajax functionality.
+ $output .= '<div class="file-widget form-managed-file clearfix">';
+ if ($element['fid']['#value'] != 0) {
+ // Add the file size after the file name.
+ $element['filename']['#markup'] .= ' <span class="file-size">(' . format_size($element['#file']->filesize) . ')</span> ';
+ }
+ $output .= drupal_render_children($element);
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Returns HTML for a group of file upload widgets.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the widgets.
+ *
+ * @ingroup themeable
+ */
+function theme_file_widget_multiple($variables) {
+ $element = $variables['element'];
+
+ // Special ID and classes for draggable tables.
+ $weight_class = $element['#id'] . '-weight';
+ $table_id = $element['#id'] . '-table';
+
+ // Build up a table of applicable fields.
+ $headers = array();
+ $headers[] = t('File information');
+ if ($element['#display_field']) {
+ $headers[] = array(
+ 'data' => t('Display'),
+ 'class' => array('checkbox'),
+ );
+ }
+ $headers[] = t('Weight');
+ $headers[] = t('Operations');
+
+ // Get our list of widgets in order (needed when the form comes back after
+ // preview or failed validation).
+ $widgets = array();
+ foreach (element_children($element) as $key) {
+ $widgets[] = &$element[$key];
+ }
+ usort($widgets, '_field_sort_items_value_helper');
+
+ $rows = array();
+ foreach ($widgets as $key => &$widget) {
+ // Save the uploading row for last.
+ if ($widget['#file'] == FALSE) {
+ $widget['#title'] = $element['#file_upload_title'];
+ $widget['#description'] = $element['#file_upload_description'];
+ continue;
+ }
+
+ // Delay rendering of the buttons, so that they can be rendered later in the
+ // "operations" column.
+ $operations_elements = array();
+ foreach (element_children($widget) as $sub_key) {
+ if (isset($widget[$sub_key]['#type']) && $widget[$sub_key]['#type'] == 'submit') {
+ hide($widget[$sub_key]);
+ $operations_elements[] = &$widget[$sub_key];
+ }
+ }
+
+ // Delay rendering of the "Display" option and the weight selector, so that
+ // each can be rendered later in its own column.
+ if ($element['#display_field']) {
+ hide($widget['display']);
+ }
+ hide($widget['_weight']);
+
+ // Render everything else together in a column, without the normal wrappers.
+ $widget['#theme_wrappers'] = array();
+ $information = drupal_render($widget);
+
+ // Render the previously hidden elements, using render() instead of
+ // drupal_render(), to undo the earlier hide().
+ $operations = '';
+ foreach ($operations_elements as $operation_element) {
+ $operations .= render($operation_element);
+ }
+ $display = '';
+ if ($element['#display_field']) {
+ unset($widget['display']['#title']);
+ $display = array(
+ 'data' => render($widget['display']),
+ 'class' => array('checkbox'),
+ );
+ }
+ $widget['_weight']['#attributes']['class'] = array($weight_class);
+ $weight = render($widget['_weight']);
+
+ // Arrange the row with all of the rendered columns.
+ $row = array();
+ $row[] = $information;
+ if ($element['#display_field']) {
+ $row[] = $display;
+ }
+ $row[] = $weight;
+ $row[] = $operations;
+ $rows[] = array(
+ 'data' => $row,
+ 'class' => isset($widget['#attributes']['class']) ? array_merge($widget['#attributes']['class'], array('draggable')) : array('draggable'),
+ );
+ }
+
+ drupal_add_tabledrag($table_id, 'order', 'sibling', $weight_class);
+
+ $output = '';
+ $output = empty($rows) ? '' : theme('table', array('header' => $headers, 'rows' => $rows, 'attributes' => array('id' => $table_id)));
+ $output .= drupal_render_children($element);
+ return $output;
+}
+
+
+/**
+ * Returns HTML for help text based on file upload validators.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - description: The normal description for this field, specified by the
+ * user.
+ * - upload_validators: An array of upload validators as used in
+ * $element['#upload_validators'].
+ *
+ * @ingroup themeable
+ */
+function theme_file_upload_help($variables) {
+ $description = $variables['description'];
+ $upload_validators = $variables['upload_validators'];
+
+ $descriptions = array();
+
+ if (strlen($description)) {
+ $descriptions[] = $description;
+ }
+ if (isset($upload_validators['file_validate_size'])) {
+ $descriptions[] = t('Files must be less than !size.', array('!size' => '<strong>' . format_size($upload_validators['file_validate_size'][0]) . '</strong>'));
+ }
+ if (isset($upload_validators['file_validate_extensions'])) {
+ $descriptions[] = t('Allowed file types: !extensions.', array('!extensions' => '<strong>' . check_plain($upload_validators['file_validate_extensions'][0]) . '</strong>'));
+ }
+ if (isset($upload_validators['file_validate_image_resolution'])) {
+ $max = $upload_validators['file_validate_image_resolution'][0];
+ $min = $upload_validators['file_validate_image_resolution'][1];
+ if ($min && $max && $min == $max) {
+ $descriptions[] = t('Images must be exactly !size pixels.', array('!size' => '<strong>' . $max . '</strong>'));
+ }
+ elseif ($min && $max) {
+ $descriptions[] = t('Images must be between !min and !max pixels.', array('!min' => '<strong>' . $min . '</strong>', '!max' => '<strong>' . $max . '</strong>'));
+ }
+ elseif ($min) {
+ $descriptions[] = t('Images must be larger than !min pixels.', array('!min' => '<strong>' . $min . '</strong>'));
+ }
+ elseif ($max) {
+ $descriptions[] = t('Images must be smaller than !max pixels.', array('!max' => '<strong>' . $max . '</strong>'));
+ }
+ }
+
+ return implode('<br />', $descriptions);
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function file_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+
+ switch ($display['type']) {
+ case 'file_default':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array(
+ '#theme' => 'file_link',
+ '#file' => (object) $item,
+ );
+ }
+ break;
+
+ case 'file_url_plain':
+ foreach ($items as $delta => $item) {
+ $element[$delta] = array('#markup' => empty($item['uri']) ? '' : file_create_url($item['uri']));
+ }
+ break;
+
+ case 'file_table':
+ // Display all values in a single element..
+ $element[0] = array(
+ '#theme' => 'file_formatter_table',
+ '#items' => $items,
+ );
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * Returns HTML for a file attachments table.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - items: An array of file attachments.
+ *
+ * @ingroup themeable
+ */
+function theme_file_formatter_table($variables) {
+ $header = array(t('Attachment'), t('Size'));
+ $rows = array();
+ foreach ($variables['items'] as $delta => $item) {
+ $rows[] = array(
+ theme('file_link', array('file' => (object) $item)),
+ format_size($item['filesize']),
+ );
+ }
+
+ return empty($rows) ? '' : theme('table', array('header' => $header, 'rows' => $rows));
+}
diff --git a/core/modules/file/file.info b/core/modules/file/file.info
new file mode 100644
index 000000000000..39daffca0bbd
--- /dev/null
+++ b/core/modules/file/file.info
@@ -0,0 +1,7 @@
+name = File
+description = Defines a file field type.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = field
+files[] = tests/file.test
diff --git a/core/modules/file/file.install b/core/modules/file/file.install
new file mode 100644
index 000000000000..47ee4fd0014b
--- /dev/null
+++ b/core/modules/file/file.install
@@ -0,0 +1,98 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for File module.
+ */
+
+/**
+ * Implements hook_field_schema().
+ */
+function file_field_schema($field) {
+ return array(
+ 'columns' => array(
+ 'fid' => array(
+ 'description' => 'The {file_managed}.fid being referenced in this field.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'unsigned' => TRUE,
+ ),
+ 'display' => array(
+ 'description' => 'Flag to control whether this file should be displayed when viewing content.',
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ 'description' => array(
+ 'description' => 'A description of the file.',
+ 'type' => 'text',
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'fid' => array('fid'),
+ ),
+ 'foreign keys' => array(
+ 'fid' => array(
+ 'table' => 'file_managed',
+ 'columns' => array('fid' => 'fid'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_requirements().
+ *
+ * Display information about getting upload progress bars working.
+ */
+function file_requirements($phase) {
+ $requirements = array();
+
+ // Check the server's ability to indicate upload progress.
+ if ($phase == 'runtime') {
+ $implementation = file_progress_implementation();
+ $apache = strpos($_SERVER['SERVER_SOFTWARE'], 'Apache') !== FALSE;
+ $fastcgi = strpos($_SERVER['SERVER_SOFTWARE'], 'mod_fastcgi') !== FALSE || strpos($_SERVER["SERVER_SOFTWARE"], 'mod_fcgi') !== FALSE;
+ $description = NULL;
+ if (!$apache) {
+ $value = t('Not enabled');
+ $description = t('Your server is not capable of displaying file upload progress. File upload progress requires an Apache server running PHP with mod_php.');
+ $severity = REQUIREMENT_INFO;
+ }
+ elseif ($fastcgi) {
+ $value = t('Not enabled');
+ $description = t('Your server is not capable of displaying file upload progress. File upload progress requires PHP be run with mod_php and not as FastCGI.');
+ $severity = REQUIREMENT_INFO;
+ }
+ elseif (!$implementation && extension_loaded('apc')) {
+ $value = t('Not enabled');
+ $description = t('Your server is capable of displaying file upload progress through APC, but it is not enabled. Add <code>apc.rfc1867 = 1</code> to your php.ini configuration. Alternatively, it is recommended to use <a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress</a>, which supports more than one simultaneous upload.');
+ $severity = REQUIREMENT_INFO;
+ }
+ elseif (!$implementation) {
+ $value = t('Not enabled');
+ $description = t('Your server is capable of displaying file upload progress, but does not have the required libraries. It is recommended to install the <a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress library</a> (preferred) or to install <a href="http://us2.php.net/apc">APC</a>.');
+ $severity = REQUIREMENT_INFO;
+ }
+ elseif ($implementation == 'apc') {
+ $value = t('Enabled (<a href="http://php.net/manual/en/apc.configuration.php#ini.apc.rfc1867">APC RFC1867</a>)');
+ $description = t('Your server is capable of displaying file upload progress using APC RFC1867. Note that only one upload at a time is supported. It is recommended to use the <a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress library</a> if possible.');
+ $severity = REQUIREMENT_OK;
+ }
+ elseif ($implementation == 'uploadprogress') {
+ $value = t('Enabled (<a href="http://pecl.php.net/package/uploadprogress">PECL uploadprogress</a>)');
+ $severity = REQUIREMENT_OK;
+ }
+ $requirements['file_progress'] = array(
+ 'title' => t('Upload progress'),
+ 'value' => $value,
+ 'severity' => $severity,
+ 'description' => $description,
+ );
+ }
+
+ return $requirements;
+}
diff --git a/core/modules/file/file.js b/core/modules/file/file.js
new file mode 100644
index 000000000000..1071384f8a18
--- /dev/null
+++ b/core/modules/file/file.js
@@ -0,0 +1,149 @@
+
+/**
+ * @file
+ * Provides JavaScript additions to the managed file field type.
+ *
+ * This file provides progress bar support (if available), popup windows for
+ * file previews, and disabling of other file fields during Ajax uploads (which
+ * prevents separate file fields from accidentally uploading files).
+ */
+
+(function ($) {
+
+/**
+ * Attach behaviors to managed file element upload fields.
+ */
+Drupal.behaviors.fileValidateAutoAttach = {
+ attach: function (context, settings) {
+ if (settings.file && settings.file.elements) {
+ $.each(settings.file.elements, function(selector) {
+ var extensions = settings.file.elements[selector];
+ $(selector, context).bind('change', {extensions: extensions}, Drupal.file.validateExtension);
+ });
+ }
+ },
+ detach: function (context, settings) {
+ if (settings.file && settings.file.elements) {
+ $.each(settings.file.elements, function(selector) {
+ $(selector, context).unbind('change', Drupal.file.validateExtension);
+ });
+ }
+ }
+};
+
+/**
+ * Attach behaviors to the file upload and remove buttons.
+ */
+Drupal.behaviors.fileButtons = {
+ attach: function (context) {
+ $('input.form-submit', context).bind('mousedown', Drupal.file.disableFields);
+ $('div.form-managed-file input.form-submit', context).bind('mousedown', Drupal.file.progressBar);
+ },
+ detach: function (context) {
+ $('input.form-submit', context).unbind('mousedown', Drupal.file.disableFields);
+ $('div.form-managed-file input.form-submit', context).unbind('mousedown', Drupal.file.progressBar);
+ }
+};
+
+/**
+ * Attach behaviors to links within managed file elements.
+ */
+Drupal.behaviors.filePreviewLinks = {
+ attach: function (context) {
+ $('div.form-managed-file .file a, .file-widget .file a', context).bind('click',Drupal.file.openInNewWindow);
+ },
+ detach: function (context){
+ $('div.form-managed-file .file a, .file-widget .file a', context).unbind('click', Drupal.file.openInNewWindow);
+ }
+};
+
+/**
+ * File upload utility functions.
+ */
+Drupal.file = Drupal.file || {
+ /**
+ * Client-side file input validation of file extensions.
+ */
+ validateExtension: function (event) {
+ // Remove any previous errors.
+ $('.file-upload-js-error').remove();
+
+ // Add client side validation for the input[type=file].
+ var extensionPattern = event.data.extensions.replace(/,\s*/g, '|');
+ if (extensionPattern.length > 1 && this.value.length > 0) {
+ var acceptableMatch = new RegExp('\\.(' + extensionPattern + ')$', 'gi');
+ if (!acceptableMatch.test(this.value)) {
+ var error = Drupal.t("The selected file %filename cannot be uploaded. Only files with the following extensions are allowed: %extensions.", {
+ '%filename': this.value,
+ '%extensions': extensionPattern.replace(/\|/g, ', ')
+ });
+ $(this).parents('div.form-managed-file').prepend('<div class="messages error file-upload-js-error">' + error + '</div>');
+ this.value = '';
+ return false;
+ }
+ }
+ },
+ /**
+ * Prevent file uploads when using buttons not intended to upload.
+ */
+ disableFields: function (event){
+ var clickedButton = this;
+
+ // Only disable upload fields for Ajax buttons.
+ if (!$(clickedButton).hasClass('ajax-processed')) {
+ return;
+ }
+
+ // Check if we're working with an "Upload" button.
+ var $enabledFields = [];
+ if ($(this).parents('div.form-managed-file').size() > 0) {
+ $enabledFields = $(this).parents('div.form-managed-file').find('input.form-file');
+ }
+
+ // Temporarily disable upload fields other than the one we're currently
+ // working with. Filter out fields that are already disabled so that they
+ // do not get enabled when we re-enable these fields at the end of behavior
+ // processing. Re-enable in a setTimeout set to a relatively short amount
+ // of time (1 second). All the other mousedown handlers (like Drupal's Ajax
+ // behaviors) are excuted before any timeout functions are called, so we
+ // don't have to worry about the fields being re-enabled too soon.
+ // @todo If the previous sentence is true, why not set the timeout to 0?
+ var $fieldsToTemporarilyDisable = $('div.form-managed-file input.form-file').not($enabledFields).not(':disabled');
+ $fieldsToTemporarilyDisable.attr('disabled', 'disabled');
+ setTimeout(function (){
+ $fieldsToTemporarilyDisable.attr('disabled', '');
+ }, 1000);
+ },
+ /**
+ * Add progress bar support if possible.
+ */
+ progressBar: function (event) {
+ var clickedButton = this;
+ var $progressId = $(clickedButton).parents('div.form-managed-file').find('input.file-progress');
+ if ($progressId.size()) {
+ var originalName = $progressId.attr('name');
+
+ // Replace the name with the required identifier.
+ $progressId.attr('name', originalName.match(/APC_UPLOAD_PROGRESS|UPLOAD_IDENTIFIER/)[0]);
+
+ // Restore the original name after the upload begins.
+ setTimeout(function () {
+ $progressId.attr('name', originalName);
+ }, 1000);
+ }
+ // Show the progress bar if the upload takes longer than half a second.
+ setTimeout(function () {
+ $(clickedButton).parents('div.form-managed-file').find('div.ajax-progress-bar').slideDown();
+ }, 500);
+ },
+ /**
+ * Open links to files within forms in a new window.
+ */
+ openInNewWindow: function (event) {
+ $(this).attr('target', '_blank');
+ window.open(this.href, 'filePreview', 'toolbar=0,scrollbars=1,location=1,statusbar=1,menubar=0,resizable=1,width=500,height=550');
+ return false;
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/file/file.module b/core/modules/file/file.module
new file mode 100644
index 000000000000..b7d2f3c8842d
--- /dev/null
+++ b/core/modules/file/file.module
@@ -0,0 +1,996 @@
+<?php
+
+/**
+ * @file
+ * Defines a "managed_file" Form API field and a "file" field for Field module.
+ */
+
+// Load all Field module hooks for File.
+require_once DRUPAL_ROOT . '/core/modules/file/file.field.inc';
+
+/**
+ * Implements hook_help().
+ */
+function file_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#file':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The File module defines a <em>File</em> field type for the Field module, which lets you manage and validate uploaded files attached to content on your site (see the <a href="@field-help">Field module help page</a> for more information about fields). For more information, see the online handbook entry for <a href="@file">File module</a>.', array('@field-help' => url('admin/help/field'), '@file' => 'http://drupal.org/handbook/modules/file')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Attaching files to content') . '</dt>';
+ $output .= '<dd>' . t('The File module allows users to attach files to content (e.g., PDF files, spreadsheets, etc.), when a <em>File</em> field is added to a given content type using the <a href="@fieldui-help">Field UI module</a>. You can add validation options to your File field, such as specifying a maximum file size and allowed file extensions.', array('@fieldui-help' => url('admin/help/field_ui'))) . '</dd>';
+ $output .= '<dt>' . t('Managing attachment display') . '</dt>';
+ $output .= '<dd>' . t('When you attach a file to content, you can specify whether it is <em>listed</em> or not. Listed files are displayed automatically in a section at the bottom of your content; non-listed files are available for embedding in your content, but are not included in the list at the bottom.') . '</dd>';
+ $output .= '<dt>' . t('Managing file locations') . '</dt>';
+ $output .= '<dd>' . t("When you create a File field, you can specify a directory where the files will be stored, which can be within either the <em>public</em> or <em>private</em> files directory. Files in the public directory can be accessed directly through the web server; when public files are listed, direct links to the files are used, and anyone who knows a file's URL can download the file. Files in the private directory are not accessible directly through the web server; when private files are listed, the links are Drupal path requests. This adds to server load and download time, since Drupal must start up and resolve the path for each file download request, but allows for access restrictions.") . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function file_menu() {
+ $items = array();
+
+ $items['file/ajax'] = array(
+ 'page callback' => 'file_ajax_upload',
+ 'delivery callback' => 'ajax_deliver',
+ 'access arguments' => array('access content'),
+ 'theme callback' => 'ajax_base_page_theme',
+ 'type' => MENU_CALLBACK,
+ );
+ $items['file/progress'] = array(
+ 'page callback' => 'file_ajax_progress',
+ 'delivery callback' => 'ajax_deliver',
+ 'access arguments' => array('access content'),
+ 'theme callback' => 'ajax_base_page_theme',
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_element_info().
+ *
+ * The managed file element may be used independently anywhere in Drupal.
+ */
+function file_element_info() {
+ $file_path = drupal_get_path('module', 'file');
+ $types['managed_file'] = array(
+ '#input' => TRUE,
+ '#process' => array('file_managed_file_process'),
+ '#value_callback' => 'file_managed_file_value',
+ '#element_validate' => array('file_managed_file_validate'),
+ '#pre_render' => array('file_managed_file_pre_render'),
+ '#theme' => 'file_managed_file',
+ '#theme_wrappers' => array('form_element'),
+ '#progress_indicator' => 'throbber',
+ '#progress_message' => NULL,
+ '#upload_validators' => array(),
+ '#upload_location' => NULL,
+ '#extended' => FALSE,
+ '#attached' => array(
+ 'css' => array($file_path . '/file.css'),
+ 'js' => array($file_path . '/file.js'),
+ ),
+ );
+ return $types;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function file_theme() {
+ return array(
+ // file.module.
+ 'file_link' => array(
+ 'variables' => array('file' => NULL, 'icon_directory' => NULL),
+ ),
+ 'file_icon' => array(
+ 'variables' => array('file' => NULL, 'icon_directory' => NULL),
+ ),
+ 'file_managed_file' => array(
+ 'render element' => 'element',
+ ),
+
+ // file.field.inc.
+ 'file_widget' => array(
+ 'render element' => 'element',
+ ),
+ 'file_widget_multiple' => array(
+ 'render element' => 'element',
+ ),
+ 'file_formatter_table' => array(
+ 'variables' => array('items' => NULL),
+ ),
+ 'file_upload_help' => array(
+ 'variables' => array('description' => NULL, 'upload_validators' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_file_download().
+ *
+ * This function takes an extra parameter $field_type so that it may
+ * be re-used by other File-like modules, such as Image.
+ */
+function file_file_download($uri, $field_type = 'file') {
+ global $user;
+
+ // Get the file record based on the URI. If not in the database just return.
+ $files = file_load_multiple(array(), array('uri' => $uri));
+ if (count($files)) {
+ foreach ($files as $item) {
+ // Since some database servers sometimes use a case-insensitive comparison
+ // by default, double check that the filename is an exact match.
+ if ($item->uri === $uri) {
+ $file = $item;
+ break;
+ }
+ }
+ }
+ if (!isset($file)) {
+ return;
+ }
+
+ // Find out which (if any) fields of this type contain the file.
+ $references = file_get_file_references($file, NULL, FIELD_LOAD_CURRENT, $field_type);
+
+ // Stop processing if there are no references in order to avoid returning
+ // headers for files controlled by other modules. Make an exception for
+ // temporary files where the host entity has not yet been saved (for example,
+ // an image preview on a node/add form) in which case, allow download by the
+ // file's owner.
+ if (empty($references) && ($file->status == FILE_STATUS_PERMANENT || $file->uid != $user->uid)) {
+ return;
+ }
+
+ // Default to allow access.
+ $denied = FALSE;
+ // Loop through all references of this file. If a reference explicitly allows
+ // access to the field to which this file belongs, no further checks are done
+ // and download access is granted. If a reference denies access, eventually
+ // existing additional references are checked. If all references were checked
+ // and no reference denied access, access is granted as well. If at least one
+ // reference denied access, access is denied.
+ foreach ($references as $field_name => $field_references) {
+ foreach ($field_references as $entity_type => $type_references) {
+ foreach ($type_references as $id => $reference) {
+ // Try to load $entity and $field.
+ $entity = entity_load($entity_type, array($id));
+ $entity = reset($entity);
+ $field = NULL;
+ if ($entity) {
+ // Load all fields for that entity.
+ $field_items = field_get_items($entity_type, $entity, $field_name);
+
+ // Find the field item with the matching URI.
+ foreach ($field_items as $field_item) {
+ if ($field_item['uri'] == $uri) {
+ $field = $field_item;
+ break;
+ }
+ }
+ }
+
+ // Check that $entity and $field were loaded successfully and check if
+ // access to that field is not disallowed. If any of these checks fail,
+ // stop checking access for this reference.
+ if (empty($entity) || empty($field) || !field_access('view', $field, $entity_type, $entity)) {
+ $denied = TRUE;
+ break;
+ }
+
+ // Invoke hook and collect grants/denies for download access.
+ // Default to FALSE and let entities overrule this ruling.
+ $grants = array('system' => FALSE);
+ foreach (module_implements('file_download_access') as $module) {
+ $grants = array_merge($grants, array($module => module_invoke($module, 'file_download_access', $field, $entity_type, $entity)));
+ }
+ // Allow other modules to alter the returned grants/denies.
+ drupal_alter('file_download_access', $grants, $field, $entity_type, $entity);
+
+ if (in_array(TRUE, $grants)) {
+ // If TRUE is returned, access is granted and no further checks are
+ // necessary.
+ $denied = FALSE;
+ break 3;
+ }
+
+ if (in_array(FALSE, $grants)) {
+ // If an implementation returns FALSE, access to this entity is denied
+ // but the file could belong to another entity to which the user might
+ // have access. Continue with these.
+ $denied = TRUE;
+ }
+ }
+ }
+ }
+
+ // Access specifically denied.
+ if ($denied) {
+ return -1;
+ }
+
+ // Access is granted.
+ $headers = file_get_content_headers($file);
+ return $headers;
+}
+
+/**
+ * Menu callback; Shared Ajax callback for file uploads and deletions.
+ *
+ * This rebuilds the form element for a particular field item. As long as the
+ * form processing is properly encapsulated in the widget element the form
+ * should rebuild correctly using FAPI without the need for additional callbacks
+ * or processing.
+ */
+function file_ajax_upload() {
+ $form_parents = func_get_args();
+ $form_build_id = (string) array_pop($form_parents);
+
+ if (empty($_POST['form_build_id']) || $form_build_id != $_POST['form_build_id']) {
+ // Invalid request.
+ drupal_set_message(t('An unrecoverable error occurred. The uploaded file likely exceeded the maximum file size (@size) that this server supports.', array('@size' => format_size(file_upload_max_size()))), 'error');
+ $commands = array();
+ $commands[] = ajax_command_replace(NULL, theme('status_messages'));
+ return array('#type' => 'ajax', '#commands' => $commands);
+ }
+
+ list($form, $form_state) = ajax_get_form();
+
+ if (!$form) {
+ // Invalid form_build_id.
+ drupal_set_message(t('An unrecoverable error occurred. Use of this form has expired. Try reloading the page and submitting again.'), 'error');
+ $commands = array();
+ $commands[] = ajax_command_replace(NULL, theme('status_messages'));
+ return array('#type' => 'ajax', '#commands' => $commands);
+ }
+
+ // Get the current element and count the number of files.
+ $current_element = $form;
+ foreach ($form_parents as $parent) {
+ $current_element = $current_element[$parent];
+ }
+ $current_file_count = isset($current_element['#file_upload_delta']) ? $current_element['#file_upload_delta'] : 0;
+
+ // Process user input. $form and $form_state are modified in the process.
+ drupal_process_form($form['#form_id'], $form, $form_state);
+
+ // Retrieve the element to be rendered.
+ foreach ($form_parents as $parent) {
+ $form = $form[$parent];
+ }
+
+ // Add the special Ajax class if a new file was added.
+ if (isset($form['#file_upload_delta']) && $current_file_count < $form['#file_upload_delta']) {
+ $form[$current_file_count]['#attributes']['class'][] = 'ajax-new-content';
+ }
+ // Otherwise just add the new content class on a placeholder.
+ else {
+ $form['#suffix'] .= '<span class="ajax-new-content"></span>';
+ }
+
+ $output = theme('status_messages') . drupal_render($form);
+ $js = drupal_add_js();
+ $settings = call_user_func_array('array_merge_recursive', $js['settings']['data']);
+
+ $commands = array();
+ $commands[] = ajax_command_replace(NULL, $output, $settings);
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Menu callback for upload progress.
+ *
+ * @param $key
+ * The unique key for this upload process.
+ */
+function file_ajax_progress($key) {
+ $progress = array(
+ 'message' => t('Starting upload...'),
+ 'percentage' => -1,
+ );
+
+ $implementation = file_progress_implementation();
+ if ($implementation == 'uploadprogress') {
+ $status = uploadprogress_get_info($key);
+ if (isset($status['bytes_uploaded']) && !empty($status['bytes_total'])) {
+ $progress['message'] = t('Uploading... (@current of @total)', array('@current' => format_size($status['bytes_uploaded']), '@total' => format_size($status['bytes_total'])));
+ $progress['percentage'] = round(100 * $status['bytes_uploaded'] / $status['bytes_total']);
+ }
+ }
+ elseif ($implementation == 'apc') {
+ $status = apc_fetch('upload_' . $key);
+ if (isset($status['current']) && !empty($status['total'])) {
+ $progress['message'] = t('Uploading... (@current of @total)', array('@current' => format_size($status['current']), '@total' => format_size($status['total'])));
+ $progress['percentage'] = round(100 * $status['current'] / $status['total']);
+ }
+ }
+
+ drupal_json_output($progress);
+}
+
+/**
+ * Determine the preferred upload progress implementation.
+ *
+ * @return
+ * A string indicating which upload progress system is available. Either "apc"
+ * or "uploadprogress". If neither are available, returns FALSE.
+ */
+function file_progress_implementation() {
+ static $implementation;
+ if (!isset($implementation)) {
+ $implementation = FALSE;
+
+ // We prefer the PECL extension uploadprogress because it supports multiple
+ // simultaneous uploads. APC only supports one at a time.
+ if (extension_loaded('uploadprogress')) {
+ $implementation = 'uploadprogress';
+ }
+ elseif (extension_loaded('apc') && ini_get('apc.rfc1867')) {
+ $implementation = 'apc';
+ }
+ }
+ return $implementation;
+}
+
+/**
+ * Implements hook_file_delete().
+ */
+function file_file_delete($file) {
+ // TODO: Remove references to a file that is in-use.
+}
+
+/**
+ * Process function to expand the managed_file element type.
+ *
+ * Expands the file type to include Upload and Remove buttons, as well as
+ * support for a default value.
+ */
+function file_managed_file_process($element, &$form_state, $form) {
+ $fid = isset($element['#value']['fid']) ? $element['#value']['fid'] : 0;
+
+ // Set some default element properties.
+ $element['#progress_indicator'] = empty($element['#progress_indicator']) ? 'none' : $element['#progress_indicator'];
+ $element['#file'] = $fid ? file_load($fid) : FALSE;
+ $element['#tree'] = TRUE;
+
+ $ajax_settings = array(
+ 'path' => 'file/ajax/' . implode('/', $element['#array_parents']) . '/' . $form['form_build_id']['#value'],
+ 'wrapper' => $element['#id'] . '-ajax-wrapper',
+ 'effect' => 'fade',
+ 'progress' => array(
+ 'type' => $element['#progress_indicator'],
+ 'message' => $element['#progress_message'],
+ ),
+ );
+
+ // Set up the buttons first since we need to check if they were clicked.
+ $element['upload_button'] = array(
+ '#name' => implode('_', $element['#parents']) . '_upload_button',
+ '#type' => 'submit',
+ '#value' => t('Upload'),
+ '#validate' => array(),
+ '#submit' => array('file_managed_file_submit'),
+ '#limit_validation_errors' => array($element['#parents']),
+ '#ajax' => $ajax_settings,
+ '#weight' => -5,
+ );
+
+ $ajax_settings['progress']['type'] ? $ajax_settings['progress']['type'] == 'bar' : 'throbber';
+ $ajax_settings['progress']['message'] = NULL;
+ $ajax_settings['effect'] = 'none';
+ $element['remove_button'] = array(
+ '#name' => implode('_', $element['#parents']) . '_remove_button',
+ '#type' => 'submit',
+ '#value' => t('Remove'),
+ '#validate' => array(),
+ '#submit' => array('file_managed_file_submit'),
+ '#limit_validation_errors' => array($element['#parents']),
+ '#ajax' => $ajax_settings,
+ '#weight' => -5,
+ );
+
+ $element['fid'] = array(
+ '#type' => 'hidden',
+ '#value' => $fid,
+ );
+
+ // Add progress bar support to the upload if possible.
+ if ($element['#progress_indicator'] == 'bar' && $implementation = file_progress_implementation()) {
+ $upload_progress_key = mt_rand();
+
+ if ($implementation == 'uploadprogress') {
+ $element['UPLOAD_IDENTIFIER'] = array(
+ '#type' => 'hidden',
+ '#value' => $upload_progress_key,
+ '#attributes' => array('class' => array('file-progress')),
+ );
+ }
+ elseif ($implementation == 'apc') {
+ $element['APC_UPLOAD_PROGRESS'] = array(
+ '#type' => 'hidden',
+ '#value' => $upload_progress_key,
+ '#attributes' => array('class' => array('file-progress')),
+ );
+ }
+
+ // Add the upload progress callback.
+ $element['upload_button']['#ajax']['progress']['path'] = 'file/progress/' . $upload_progress_key;
+ }
+
+ // The file upload field itself.
+ $element['upload'] = array(
+ '#name' => 'files[' . implode('_', $element['#parents']) . ']',
+ '#type' => 'file',
+ '#title' => t('Choose a file'),
+ '#title_display' => 'invisible',
+ '#size' => 22,
+ '#theme_wrappers' => array(),
+ '#weight' => -10,
+ );
+
+ if ($fid && $element['#file']) {
+ $element['filename'] = array(
+ '#type' => 'markup',
+ '#markup' => theme('file_link', array('file' => $element['#file'])) . ' ',
+ '#weight' => -10,
+ );
+ }
+
+ // Add the extension list to the page as JavaScript settings.
+ if (isset($element['#upload_validators']['file_validate_extensions'][0])) {
+ $extension_list = implode(',', array_filter(explode(' ', $element['#upload_validators']['file_validate_extensions'][0])));
+ $element['upload']['#attached']['js'] = array(
+ array(
+ 'type' => 'setting',
+ 'data' => array('file' => array('elements' => array('#' . $element['#id'] . '-upload' => $extension_list)))
+ )
+ );
+ }
+
+ // Prefix and suffix used for Ajax replacement.
+ $element['#prefix'] = '<div id="' . $element['#id'] . '-ajax-wrapper">';
+ $element['#suffix'] = '</div>';
+
+ return $element;
+}
+
+/**
+ * The #value_callback for a managed_file type element.
+ */
+function file_managed_file_value(&$element, $input = FALSE, $form_state = NULL) {
+ $fid = 0;
+
+ // Find the current value of this field from the form state.
+ $form_state_fid = $form_state['values'];
+ foreach ($element['#parents'] as $parent) {
+ $form_state_fid = isset($form_state_fid[$parent]) ? $form_state_fid[$parent] : 0;
+ }
+
+ if ($element['#extended'] && isset($form_state_fid['fid'])) {
+ $fid = $form_state_fid['fid'];
+ }
+ elseif (is_numeric($form_state_fid)) {
+ $fid = $form_state_fid;
+ }
+
+ // Process any input and save new uploads.
+ if ($input !== FALSE) {
+ $return = $input;
+
+ // Uploads take priority over all other values.
+ if ($file = file_managed_file_save_upload($element)) {
+ $fid = $file->fid;
+ }
+ else {
+ // Check for #filefield_value_callback values.
+ // Because FAPI does not allow multiple #value_callback values like it
+ // does for #element_validate and #process, this fills the missing
+ // functionality to allow File fields to be extended through FAPI.
+ if (isset($element['#file_value_callbacks'])) {
+ foreach ($element['#file_value_callbacks'] as $callback) {
+ $callback($element, $input, $form_state);
+ }
+ }
+ // Load file if the FID has changed to confirm it exists.
+ if (isset($input['fid']) && $file = file_load($input['fid'])) {
+ $fid = $file->fid;
+ }
+ }
+ }
+
+ // If there is no input, set the default value.
+ else {
+ if ($element['#extended']) {
+ $default_fid = isset($element['#default_value']['fid']) ? $element['#default_value']['fid'] : 0;
+ $return = isset($element['#default_value']) ? $element['#default_value'] : array('fid' => 0);
+ }
+ else {
+ $default_fid = isset($element['#default_value']) ? $element['#default_value'] : 0;
+ $return = array('fid' => 0);
+ }
+
+ // Confirm that the file exists when used as a default value.
+ if ($default_fid && $file = file_load($default_fid)) {
+ $fid = $file->fid;
+ }
+ }
+
+ $return['fid'] = $fid;
+
+ return $return;
+}
+
+/**
+ * An #element_validate callback for the managed_file element.
+ */
+function file_managed_file_validate(&$element, &$form_state) {
+ // If referencing an existing file, only allow if there are existing
+ // references. This prevents unmanaged files from being deleted if this
+ // item were to be deleted.
+ $clicked_button = end($form_state['triggering_element']['#parents']);
+ if ($clicked_button != 'remove_button' && !empty($element['fid']['#value'])) {
+ if ($file = file_load($element['fid']['#value'])) {
+ if ($file->status == FILE_STATUS_PERMANENT) {
+ $references = file_usage_list($file);
+ if (empty($references)) {
+ form_error($element, t('The file used in the !name field may not be referenced.', array('!name' => $element['#title'])));
+ }
+ }
+ }
+ else {
+ form_error($element, t('The file referenced by the !name field does not exist.', array('!name' => $element['#title'])));
+ }
+ }
+
+ // Check required property based on the FID.
+ if ($element['#required'] && empty($element['fid']['#value']) && !in_array($clicked_button, array('upload_button', 'remove_button'))) {
+ form_error($element['upload'], t('!name field is required.', array('!name' => $element['#title'])));
+ }
+
+ // Consolidate the array value of this field to a single FID.
+ if (!$element['#extended']) {
+ form_set_value($element, $element['fid']['#value'], $form_state);
+ }
+}
+
+/**
+ * Submit handler for upload and remove buttons of managed_file elements.
+ */
+function file_managed_file_submit($form, &$form_state) {
+ // Determine whether it was the upload or the remove button that was clicked,
+ // and set $element to the managed_file element that contains that button.
+ $parents = $form_state['triggering_element']['#array_parents'];
+ $button_key = array_pop($parents);
+ $element = drupal_array_get_nested_value($form, $parents);
+
+ // No action is needed here for the upload button, because all file uploads on
+ // the form are processed by file_managed_file_value() regardless of which
+ // button was clicked. Action is needed here for the remove button, because we
+ // only remove a file in response to its remove button being clicked.
+ if ($button_key == 'remove_button') {
+ // If it's a temporary file we can safely remove it immediately, otherwise
+ // it's up to the implementing module to clean up files that are in use.
+ if ($element['#file'] && $element['#file']->status == 0) {
+ file_delete($element['#file']);
+ }
+ // Update both $form_state['values'] and $form_state['input'] to reflect
+ // that the file has been removed, so that the form is rebuilt correctly.
+ // $form_state['values'] must be updated in case additional submit handlers
+ // run, and for form building functions that run during the rebuild, such as
+ // when the managed_file element is part of a field widget.
+ // $form_state['input'] must be updated so that file_managed_file_value()
+ // has correct information during the rebuild.
+ $values_element = $element['#extended'] ? $element['fid'] : $element;
+ form_set_value($values_element, NULL, $form_state);
+ drupal_array_set_nested_value($form_state['input'], $values_element['#parents'], NULL);
+ }
+
+ // Set the form to rebuild so that $form is correctly updated in response to
+ // processing the file removal. Since this function did not change $form_state
+ // if the upload button was clicked, a rebuild isn't necessary in that
+ // situation and setting $form_state['redirect'] to FALSE would suffice.
+ // However, we choose to always rebuild, to keep the form processing workflow
+ // consistent between the two buttons.
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Given a managed_file element, save any files that have been uploaded into it.
+ *
+ * @param $element
+ * The FAPI element whose values are being saved.
+ * @return
+ * The file object representing the file that was saved, or FALSE if no file
+ * was saved.
+ */
+function file_managed_file_save_upload($element) {
+ $upload_name = implode('_', $element['#parents']);
+ if (empty($_FILES['files']['name'][$upload_name])) {
+ return FALSE;
+ }
+
+ $destination = isset($element['#upload_location']) ? $element['#upload_location'] : NULL;
+ if (isset($destination) && !file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {
+ watchdog('file', 'The upload directory %directory for the file field !name could not be created or is not accessible. A newly uploaded file could not be saved in this directory as a consequence, and the upload was canceled.', array('%directory' => $destination, '!name' => $element['#field_name']));
+ form_set_error($upload_name, t('The file could not be uploaded.'));
+ return FALSE;
+ }
+
+ if (!$file = file_save_upload($upload_name, $element['#upload_validators'], $destination)) {
+ watchdog('file', 'The file upload failed. %upload', array('%upload' => $upload_name));
+ form_set_error($upload_name, t('The file in the !name field was unable to be uploaded.', array('!name' => $element['#title'])));
+ return FALSE;
+ }
+
+ return $file;
+}
+
+/**
+ * Returns HTML for a managed file element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the file.
+ *
+ * @ingroup themeable
+ */
+function theme_file_managed_file($variables) {
+ $element = $variables['element'];
+
+ $attributes = array();
+ if (isset($element['#id'])) {
+ $attributes['id'] = $element['#id'];
+ }
+ if (!empty($element['#attributes']['class'])) {
+ $attributes['class'] = (array) $element['#attributes']['class'];
+ }
+ $attributes['class'][] = 'form-managed-file';
+
+ // This wrapper is required to apply JS behaviors and CSS styling.
+ $output = '';
+ $output .= '<div' . drupal_attributes($attributes) . '>';
+ $output .= drupal_render_children($element);
+ $output .= '</div>';
+ return $output;
+}
+
+/**
+ * #pre_render callback to hide display of the upload or remove controls.
+ *
+ * Upload controls are hidden when a file is already uploaded. Remove controls
+ * are hidden when there is no file attached. Controls are hidden here instead
+ * of in file_managed_file_process(), because #access for these buttons depends
+ * on the managed_file element's #value. See the documentation of form_builder()
+ * for more detailed information about the relationship between #process,
+ * #value, and #access.
+ *
+ * Because #access is set here, it affects display only and does not prevent
+ * JavaScript or other untrusted code from submitting the form as though access
+ * were enabled. The form processing functions for these elements should not
+ * assume that the buttons can't be "clicked" just because they are not
+ * displayed.
+ *
+ * @see file_managed_file_process()
+ * @see form_builder()
+ */
+function file_managed_file_pre_render($element) {
+ // If we already have a file, we don't want to show the upload controls.
+ if (!empty($element['#value']['fid'])) {
+ $element['upload']['#access'] = FALSE;
+ $element['upload_button']['#access'] = FALSE;
+ }
+ // If we don't already have a file, there is nothing to remove.
+ else {
+ $element['remove_button']['#access'] = FALSE;
+ }
+ return $element;
+}
+
+/**
+ * Returns HTML for a link to a file.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - file: A file object to which the link will be created.
+ * - icon_directory: (optional) A path to a directory of icons to be used for
+ * files. Defaults to the value of the "file_icon_directory" variable.
+ *
+ * @ingroup themeable
+ */
+function theme_file_link($variables) {
+ $file = $variables['file'];
+ $icon_directory = $variables['icon_directory'];
+
+ $url = file_create_url($file->uri);
+ $icon = theme('file_icon', array('file' => $file, 'icon_directory' => $icon_directory));
+
+ // Set options as per anchor format described at
+ // http://microformats.org/wiki/file-format-examples
+ $options = array(
+ 'attributes' => array(
+ 'type' => $file->filemime . '; length=' . $file->filesize,
+ ),
+ );
+
+ // Use the description as the link text if available.
+ if (empty($file->description)) {
+ $link_text = $file->filename;
+ }
+ else {
+ $link_text = $file->description;
+ $options['attributes']['title'] = check_plain($file->filename);
+ }
+
+ return '<span class="file">' . $icon . ' ' . l($link_text, $url, $options) . '</span>';
+}
+
+/**
+ * Returns HTML for an image with an appropriate icon for the given file.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - file: A file object for which to make an icon.
+ * - icon_directory: (optional) A path to a directory of icons to be used for
+ * files. Defaults to the value of the "file_icon_directory" variable.
+ *
+ * @ingroup themeable
+ */
+function theme_file_icon($variables) {
+ $file = $variables['file'];
+ $icon_directory = $variables['icon_directory'];
+
+ $mime = check_plain($file->filemime);
+ $icon_url = file_icon_url($file, $icon_directory);
+ return '<img class="file-icon" alt="" title="' . $mime . '" src="' . $icon_url . '" />';
+}
+
+/**
+ * Given a file object, create a URL to a matching icon.
+ *
+ * @param $file
+ * A file object.
+ * @param $icon_directory
+ * (optional) A path to a directory of icons to be used for files. Defaults to
+ * the value of the "file_icon_directory" variable.
+ * @return
+ * A URL string to the icon, or FALSE if an appropriate icon cannot be found.
+ */
+function file_icon_url($file, $icon_directory = NULL) {
+ if ($icon_path = file_icon_path($file, $icon_directory)) {
+ return base_path() . $icon_path;
+ }
+ return FALSE;
+}
+
+/**
+ * Given a file object, create a path to a matching icon.
+ *
+ * @param $file
+ * A file object.
+ * @param $icon_directory
+ * (optional) A path to a directory of icons to be used for files. Defaults to
+ * the value of the "file_icon_directory" variable.
+ * @return
+ * A string to the icon as a local path, or FALSE if an appropriate icon could
+ * not be found.
+ */
+function file_icon_path($file, $icon_directory = NULL) {
+ // Use the default set of icons if none specified.
+ if (!isset($icon_directory)) {
+ $icon_directory = variable_get('file_icon_directory', drupal_get_path('module', 'file') . '/icons');
+ }
+
+ // If there's an icon matching the exact mimetype, go for it.
+ $dashed_mime = strtr($file->filemime, array('/' => '-'));
+ $icon_path = $icon_directory . '/' . $dashed_mime . '.png';
+ if (file_exists($icon_path)) {
+ return $icon_path;
+ }
+
+ // For a few mimetypes, we can "manually" map to a generic icon.
+ $generic_mime = (string) file_icon_map($file);
+ $icon_path = $icon_directory . '/' . $generic_mime . '.png';
+ if ($generic_mime && file_exists($icon_path)) {
+ return $icon_path;
+ }
+
+ // Use generic icons for each category that provides such icons.
+ foreach (array('audio', 'image', 'text', 'video') as $category) {
+ if (strpos($file->filemime, $category . '/') === 0) {
+ $icon_path = $icon_directory . '/' . $category . '-x-generic.png';
+ if (file_exists($icon_path)) {
+ return $icon_path;
+ }
+ }
+ }
+
+ // Try application-octet-stream as last fallback.
+ $icon_path = $icon_directory . '/application-octet-stream.png';
+ if (file_exists($icon_path)) {
+ return $icon_path;
+ }
+
+ // No icon can be found.
+ return FALSE;
+}
+
+/**
+ * Determine the generic icon MIME package based on a file's MIME type.
+ *
+ * @param $file
+ * A file object.
+ * @return
+ * The generic icon MIME package expected for this file.
+ */
+function file_icon_map($file) {
+ switch ($file->filemime) {
+ // Word document types.
+ case 'application/msword':
+ case 'application/vnd.ms-word.document.macroEnabled.12':
+ case 'application/vnd.oasis.opendocument.text':
+ case 'application/vnd.oasis.opendocument.text-template':
+ case 'application/vnd.oasis.opendocument.text-master':
+ case 'application/vnd.oasis.opendocument.text-web':
+ case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
+ case 'application/vnd.stardivision.writer':
+ case 'application/vnd.sun.xml.writer':
+ case 'application/vnd.sun.xml.writer.template':
+ case 'application/vnd.sun.xml.writer.global':
+ case 'application/vnd.wordperfect':
+ case 'application/x-abiword':
+ case 'application/x-applix-word':
+ case 'application/x-kword':
+ case 'application/x-kword-crypt':
+ return 'x-office-document';
+
+ // Spreadsheet document types.
+ case 'application/vnd.ms-excel':
+ case 'application/vnd.ms-excel.sheet.macroEnabled.12':
+ case 'application/vnd.oasis.opendocument.spreadsheet':
+ case 'application/vnd.oasis.opendocument.spreadsheet-template':
+ case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
+ case 'application/vnd.stardivision.calc':
+ case 'application/vnd.sun.xml.calc':
+ case 'application/vnd.sun.xml.calc.template':
+ case 'application/vnd.lotus-1-2-3':
+ case 'application/x-applix-spreadsheet':
+ case 'application/x-gnumeric':
+ case 'application/x-kspread':
+ case 'application/x-kspread-crypt':
+ return 'x-office-spreadsheet';
+
+ // Presentation document types.
+ case 'application/vnd.ms-powerpoint':
+ case 'application/vnd.ms-powerpoint.presentation.macroEnabled.12':
+ case 'application/vnd.oasis.opendocument.presentation':
+ case 'application/vnd.oasis.opendocument.presentation-template':
+ case 'application/vnd.openxmlformats-officedocument.presentationml.presentation':
+ case 'application/vnd.stardivision.impress':
+ case 'application/vnd.sun.xml.impress':
+ case 'application/vnd.sun.xml.impress.template':
+ case 'application/x-kpresenter':
+ return 'x-office-presentation';
+
+ // Compressed archive types.
+ case 'application/zip':
+ case 'application/x-zip':
+ case 'application/stuffit':
+ case 'application/x-stuffit':
+ case 'application/x-7z-compressed':
+ case 'application/x-ace':
+ case 'application/x-arj':
+ case 'application/x-bzip':
+ case 'application/x-bzip-compressed-tar':
+ case 'application/x-compress':
+ case 'application/x-compressed-tar':
+ case 'application/x-cpio-compressed':
+ case 'application/x-deb':
+ case 'application/x-gzip':
+ case 'application/x-java-archive':
+ case 'application/x-lha':
+ case 'application/x-lhz':
+ case 'application/x-lzop':
+ case 'application/x-rar':
+ case 'application/x-rpm':
+ case 'application/x-tzo':
+ case 'application/x-tar':
+ case 'application/x-tarz':
+ case 'application/x-tgz':
+ return 'package-x-generic';
+
+ // Script file types.
+ case 'application/ecmascript':
+ case 'application/javascript':
+ case 'application/mathematica':
+ case 'application/vnd.mozilla.xul+xml':
+ case 'application/x-asp':
+ case 'application/x-awk':
+ case 'application/x-cgi':
+ case 'application/x-csh':
+ case 'application/x-m4':
+ case 'application/x-perl':
+ case 'application/x-php':
+ case 'application/x-ruby':
+ case 'application/x-shellscript':
+ case 'text/vnd.wap.wmlscript':
+ case 'text/x-emacs-lisp':
+ case 'text/x-haskell':
+ case 'text/x-literate-haskell':
+ case 'text/x-lua':
+ case 'text/x-makefile':
+ case 'text/x-matlab':
+ case 'text/x-python':
+ case 'text/x-sql':
+ case 'text/x-tcl':
+ return 'text-x-script';
+
+ // HTML aliases.
+ case 'application/xhtml+xml':
+ return 'text-html';
+
+ // Executable types.
+ case 'application/x-macbinary':
+ case 'application/x-ms-dos-executable':
+ case 'application/x-pef-executable':
+ return 'application-x-executable';
+
+ default:
+ return FALSE;
+ }
+}
+
+/**
+ * @defgroup file-module-api File module public API functions
+ * @{
+ * These functions may be used to determine if and where a file is in use.
+ */
+
+/**
+ * Gets a list of references to a file.
+ *
+ * @param $file
+ * A file object.
+ * @param $field
+ * (optional) A field array to be used for this check. If given, limits the
+ * reference check to the given field.
+ * @param $age
+ * (optional) A constant that specifies which references to count. Use
+ * FIELD_LOAD_REVISION to retrieve all references within all revisions or
+ * FIELD_LOAD_CURRENT to retrieve references only in the current revisions.
+ * @param $field_type
+ * (optional) The name of a field type. If given, limits the reference check
+ * to fields of the given type.
+ *
+ * @return
+ * An integer value.
+ */
+function file_get_file_references($file, $field = NULL, $age = FIELD_LOAD_REVISION, $field_type = 'file') {
+ $references = drupal_static(__FUNCTION__, array());
+ $fields = isset($field) ? array($field['field_name'] => $field) : field_info_fields();
+
+ foreach ($fields as $field_name => $file_field) {
+ if ((empty($field_type) || $file_field['type'] == $field_type) && !isset($references[$field_name])) {
+ // Get each time this file is used within a field.
+ $query = new EntityFieldQuery();
+ $query
+ ->fieldCondition($file_field, 'fid', $file->fid)
+ ->age($age);
+ $references[$field_name] = $query->execute();
+ }
+ }
+
+ return isset($field) ? $references[$field['field_name']] : array_filter($references);
+}
+
+/**
+ * @} End of "defgroup file-module-api".
+ */
diff --git a/core/modules/file/icons/application-octet-stream.png b/core/modules/file/icons/application-octet-stream.png
new file mode 100644
index 000000000000..d5453217dc5c
--- /dev/null
+++ b/core/modules/file/icons/application-octet-stream.png
Binary files differ
diff --git a/core/modules/file/icons/application-pdf.png b/core/modules/file/icons/application-pdf.png
new file mode 100644
index 000000000000..36107d6e8040
--- /dev/null
+++ b/core/modules/file/icons/application-pdf.png
Binary files differ
diff --git a/core/modules/file/icons/application-x-executable.png b/core/modules/file/icons/application-x-executable.png
new file mode 100644
index 000000000000..d5453217dc5c
--- /dev/null
+++ b/core/modules/file/icons/application-x-executable.png
Binary files differ
diff --git a/core/modules/file/icons/audio-x-generic.png b/core/modules/file/icons/audio-x-generic.png
new file mode 100644
index 000000000000..28d7f50862b5
--- /dev/null
+++ b/core/modules/file/icons/audio-x-generic.png
Binary files differ
diff --git a/core/modules/file/icons/image-x-generic.png b/core/modules/file/icons/image-x-generic.png
new file mode 100644
index 000000000000..c1b814f7cb6f
--- /dev/null
+++ b/core/modules/file/icons/image-x-generic.png
Binary files differ
diff --git a/core/modules/file/icons/package-x-generic.png b/core/modules/file/icons/package-x-generic.png
new file mode 100644
index 000000000000..21fc382cba23
--- /dev/null
+++ b/core/modules/file/icons/package-x-generic.png
Binary files differ
diff --git a/core/modules/file/icons/text-html.png b/core/modules/file/icons/text-html.png
new file mode 100644
index 000000000000..9c7c7932c25a
--- /dev/null
+++ b/core/modules/file/icons/text-html.png
Binary files differ
diff --git a/core/modules/file/icons/text-plain.png b/core/modules/file/icons/text-plain.png
new file mode 100644
index 000000000000..06804849b833
--- /dev/null
+++ b/core/modules/file/icons/text-plain.png
Binary files differ
diff --git a/core/modules/file/icons/text-x-generic.png b/core/modules/file/icons/text-x-generic.png
new file mode 100644
index 000000000000..06804849b833
--- /dev/null
+++ b/core/modules/file/icons/text-x-generic.png
Binary files differ
diff --git a/core/modules/file/icons/text-x-script.png b/core/modules/file/icons/text-x-script.png
new file mode 100644
index 000000000000..f9ecca813882
--- /dev/null
+++ b/core/modules/file/icons/text-x-script.png
Binary files differ
diff --git a/core/modules/file/icons/video-x-generic.png b/core/modules/file/icons/video-x-generic.png
new file mode 100644
index 000000000000..a2b71f95d9e1
--- /dev/null
+++ b/core/modules/file/icons/video-x-generic.png
Binary files differ
diff --git a/core/modules/file/icons/x-office-document.png b/core/modules/file/icons/x-office-document.png
new file mode 100644
index 000000000000..40db538fcb71
--- /dev/null
+++ b/core/modules/file/icons/x-office-document.png
Binary files differ
diff --git a/core/modules/file/icons/x-office-presentation.png b/core/modules/file/icons/x-office-presentation.png
new file mode 100644
index 000000000000..fb119e5ba91d
--- /dev/null
+++ b/core/modules/file/icons/x-office-presentation.png
Binary files differ
diff --git a/core/modules/file/icons/x-office-spreadsheet.png b/core/modules/file/icons/x-office-spreadsheet.png
new file mode 100644
index 000000000000..9af7b61ea11f
--- /dev/null
+++ b/core/modules/file/icons/x-office-spreadsheet.png
Binary files differ
diff --git a/core/modules/file/tests/file.test b/core/modules/file/tests/file.test
new file mode 100644
index 000000000000..59f6e0cb0bc8
--- /dev/null
+++ b/core/modules/file/tests/file.test
@@ -0,0 +1,1138 @@
+<?php
+
+/**
+ * @file
+ * Tests for file.module.
+ */
+
+/**
+ * This class provides methods specifically for testing File's field handling.
+ */
+class FileFieldTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ function setUp() {
+ // Since this is a base class for many test cases, support the same
+ // flexibility that DrupalWebTestCase::setUp() has for the modules to be
+ // passed in as either an array or a variable number of string arguments.
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ $modules[] = 'file';
+ $modules[] = 'file_module_test';
+ parent::setUp($modules);
+ $this->admin_user = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer users', 'administer permissions', 'administer content types', 'administer nodes', 'bypass node access'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Get a sample file of the specified type.
+ */
+ function getTestFile($type_name, $size = NULL) {
+ // Get a file to upload.
+ $file = current($this->drupalGetTestFiles($type_name, $size));
+
+ // Add a filesize property to files as would be read by file_load().
+ $file->filesize = filesize($file->uri);
+
+ return $file;
+ }
+
+ /**
+ * Get the fid of the last inserted file.
+ */
+ function getLastFileId() {
+ return (int) db_query('SELECT MAX(fid) FROM {file_managed}')->fetchField();
+ }
+
+ /**
+ * Create a new file field.
+ *
+ * @param $name
+ * The name of the new field (all lowercase), exclude the "field_" prefix.
+ * @param $type_name
+ * The node type that this field will be added to.
+ * @param $field_settings
+ * A list of field settings that will be added to the defaults.
+ * @param $instance_settings
+ * A list of instance settings that will be added to the instance defaults.
+ * @param $widget_settings
+ * A list of widget settings that will be added to the widget defaults.
+ */
+ function createFileField($name, $type_name, $field_settings = array(), $instance_settings = array(), $widget_settings = array()) {
+ $field = array(
+ 'field_name' => $name,
+ 'type' => 'file',
+ 'settings' => array(),
+ 'cardinality' => !empty($field_settings['cardinality']) ? $field_settings['cardinality'] : 1,
+ );
+ $field['settings'] = array_merge($field['settings'], $field_settings);
+ field_create_field($field);
+
+ $this->attachFileField($name, 'node', $type_name, $instance_settings, $widget_settings);
+ }
+
+ /**
+ * Attach a file field to an entity.
+ *
+ * @param $name
+ * The name of the new field (all lowercase), exclude the "field_" prefix.
+ * @param $entity_type
+ * The entity type this field will be added to.
+ * @param $bundle
+ * The bundle this field will be added to.
+ * @param $field_settings
+ * A list of field settings that will be added to the defaults.
+ * @param $instance_settings
+ * A list of instance settings that will be added to the instance defaults.
+ * @param $widget_settings
+ * A list of widget settings that will be added to the widget defaults.
+ */
+ function attachFileField($name, $entity_type, $bundle, $instance_settings = array(), $widget_settings = array()) {
+ $instance = array(
+ 'field_name' => $name,
+ 'label' => $name,
+ 'entity_type' => $entity_type,
+ 'bundle' => $bundle,
+ 'required' => !empty($instance_settings['required']),
+ 'settings' => array(),
+ 'widget' => array(
+ 'type' => 'file_generic',
+ 'settings' => array(),
+ ),
+ );
+ $instance['settings'] = array_merge($instance['settings'], $instance_settings);
+ $instance['widget']['settings'] = array_merge($instance['widget']['settings'], $widget_settings);
+ field_create_instance($instance);
+ }
+
+ /**
+ * Update an existing file field with new settings.
+ */
+ function updateFileField($name, $type_name, $instance_settings = array(), $widget_settings = array()) {
+ $instance = field_info_instance('node', $name, $type_name);
+ $instance['settings'] = array_merge($instance['settings'], $instance_settings);
+ $instance['widget']['settings'] = array_merge($instance['widget']['settings'], $widget_settings);
+
+ field_update_instance($instance);
+ }
+
+ /**
+ * Upload a file to a node.
+ */
+ function uploadNodeFile($file, $field_name, $nid_or_type, $new_revision = TRUE, $extras = array()) {
+ $langcode = LANGUAGE_NONE;
+ $edit = array(
+ "title" => $this->randomName(),
+ 'revision' => (string) (int) $new_revision,
+ );
+
+ if (is_numeric($nid_or_type)) {
+ $nid = $nid_or_type;
+ }
+ else {
+ // Add a new node.
+ $extras['type'] = $nid_or_type;
+ $node = $this->drupalCreateNode($extras);
+ $nid = $node->nid;
+ // Save at least one revision to better simulate a real site.
+ $this->drupalCreateNode(get_object_vars($node));
+ $node = node_load($nid, NULL, TRUE);
+ $this->assertNotEqual($nid, $node->vid, t('Node revision exists.'));
+ }
+
+ // Attach a file to the node.
+ $edit['files[' . $field_name . '_' . $langcode . '_0]'] = drupal_realpath($file->uri);
+ $this->drupalPost("node/$nid/edit", $edit, t('Save'));
+
+ return $nid;
+ }
+
+ /**
+ * Remove a file from a node.
+ *
+ * Note that if replacing a file, it must first be removed then added again.
+ */
+ function removeNodeFile($nid, $new_revision = TRUE) {
+ $edit = array(
+ 'revision' => (string) (int) $new_revision,
+ );
+
+ $this->drupalPost('node/' . $nid . '/edit', array(), t('Remove'));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ }
+
+ /**
+ * Replace a file within a node.
+ */
+ function replaceNodeFile($file, $field_name, $nid, $new_revision = TRUE) {
+ $edit = array(
+ 'files[' . $field_name . '_' . LANGUAGE_NONE . '_0]' => drupal_realpath($file->uri),
+ 'revision' => (string) (int) $new_revision,
+ );
+
+ $this->drupalPost('node/' . $nid . '/edit', array(), t('Remove'));
+ $this->drupalPost(NULL, $edit, t('Save'));
+ }
+
+ /**
+ * Assert that a file exists physically on disk.
+ */
+ function assertFileExists($file, $message = NULL) {
+ $message = isset($message) ? $message : t('File %file exists on the disk.', array('%file' => $file->uri));
+ $this->assertTrue(is_file($file->uri), $message);
+ }
+
+ /**
+ * Assert that a file exists in the database.
+ */
+ function assertFileEntryExists($file, $message = NULL) {
+ entity_get_controller('file')->resetCache();
+ $db_file = file_load($file->fid);
+ $message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri));
+ $this->assertEqual($db_file->uri, $file->uri, $message);
+ }
+
+ /**
+ * Assert that a file does not exist on disk.
+ */
+ function assertFileNotExists($file, $message = NULL) {
+ $message = isset($message) ? $message : t('File %file exists on the disk.', array('%file' => $file->uri));
+ $this->assertFalse(is_file($file->uri), $message);
+ }
+
+ /**
+ * Assert that a file does not exist in the database.
+ */
+ function assertFileEntryNotExists($file, $message) {
+ entity_get_controller('file')->resetCache();
+ $message = isset($message) ? $message : t('File %file exists in database at the correct path.', array('%file' => $file->uri));
+ $this->assertFalse(file_load($file->fid), $message);
+ }
+
+ /**
+ * Assert that a file's status is set to permanent in the database.
+ */
+ function assertFileIsPermanent($file, $message = NULL) {
+ $message = isset($message) ? $message : t('File %file is permanent.', array('%file' => $file->uri));
+ $this->assertTrue($file->status == FILE_STATUS_PERMANENT, $message);
+ }
+}
+
+/**
+ * Test class for testing the 'managed_file' element type on its own, not as part of a file field.
+ *
+ * @todo Create a FileTestCase base class and move FileFieldTestCase methods
+ * that aren't related to fields into it.
+ */
+class FileManagedFileElementTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Managed file element test',
+ 'description' => 'Tests the managed_file element type.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Tests the managed_file element type.
+ */
+ function testManagedFile() {
+ // Perform the tests with all permutations of $form['#tree'] and
+ // $element['#extended'].
+ foreach (array(0, 1) as $tree) {
+ foreach (array(0, 1) as $extended) {
+ $test_file = $this->getTestFile('text');
+ $path = 'file/test/' . $tree . '/' . $extended;
+ $input_base_name = $tree ? 'nested_file' : 'file';
+
+ // Submit without a file.
+ $this->drupalPost($path, array(), t('Save'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => 0)), t('Submitted without a file.'));
+
+ // Submit a new file, without using the Upload button.
+ $last_fid_prior = $this->getLastFileId();
+ $edit = array('files[' . $input_base_name . ']' => drupal_realpath($test_file->uri));
+ $this->drupalPost($path, $edit, t('Save'));
+ $last_fid = $this->getLastFileId();
+ $this->assertTrue($last_fid > $last_fid_prior, t('New file got saved.'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => $last_fid)), t('Submit handler has correct file info.'));
+
+ // Submit no new input, but with a default file.
+ $this->drupalPost($path . '/' . $last_fid, array(), t('Save'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => $last_fid)), t('Empty submission did not change an existing file.'));
+
+ // Now, test the Upload and Remove buttons, with and without Ajax.
+ foreach (array(FALSE, TRUE) as $ajax) {
+ // Upload, then Submit.
+ $last_fid_prior = $this->getLastFileId();
+ $this->drupalGet($path);
+ $edit = array('files[' . $input_base_name . ']' => drupal_realpath($test_file->uri));
+ if ($ajax) {
+ $this->drupalPostAJAX(NULL, $edit, $input_base_name . '_upload_button');
+ }
+ else {
+ $this->drupalPost(NULL, $edit, t('Upload'));
+ }
+ $last_fid = $this->getLastFileId();
+ $this->assertTrue($last_fid > $last_fid_prior, t('New file got uploaded.'));
+ $this->drupalPost(NULL, array(), t('Save'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => $last_fid)), t('Submit handler has correct file info.'));
+
+ // Remove, then Submit.
+ $this->drupalGet($path . '/' . $last_fid);
+ if ($ajax) {
+ $this->drupalPostAJAX(NULL, array(), $input_base_name . '_remove_button');
+ }
+ else {
+ $this->drupalPost(NULL, array(), t('Remove'));
+ }
+ $this->drupalPost(NULL, array(), t('Save'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => 0)), t('Submission after file removal was successful.'));
+
+ // Upload, then Remove, then Submit.
+ $this->drupalGet($path);
+ $edit = array('files[' . $input_base_name . ']' => drupal_realpath($test_file->uri));
+ if ($ajax) {
+ $this->drupalPostAJAX(NULL, $edit, $input_base_name . '_upload_button');
+ $this->drupalPostAJAX(NULL, array(), $input_base_name . '_remove_button');
+ }
+ else {
+ $this->drupalPost(NULL, $edit, t('Upload'));
+ $this->drupalPost(NULL, array(), t('Remove'));
+ }
+ $this->drupalPost(NULL, array(), t('Save'));
+ $this->assertRaw(t('The file id is %fid.', array('%fid' => 0)), t('Submission after file upload and removal was successful.'));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Test class to test file field widget, single and multi-valued, with and without Ajax, with public and private files.
+ */
+class FileFieldWidgetTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File field widget test',
+ 'description' => 'Tests the file field widget, single and multi-valued, with and without AJAX, with public and private files.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Tests upload and remove buttons, with and without Ajax, for a single-valued File field.
+ */
+ function testSingleValuedWidget() {
+ // Use 'page' instead of 'article', so that the 'article' image field does
+ // not conflict with this test. If in the future the 'page' type gets its
+ // own default file or image field, this test can be made more robust by
+ // using a custom node type.
+ $type_name = 'page';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ foreach (array('nojs', 'js') as $type) {
+ // Create a new node with the uploaded file and ensure it got uploaded
+ // successfully.
+ // @todo This only tests a 'nojs' submission, because drupalPostAJAX()
+ // does not yet support file uploads.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('New file saved to disk on node creation.'));
+
+ // Ensure the file can be downloaded.
+ $this->drupalGet(file_create_url($node_file->uri));
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the shipped file.'));
+
+ // Ensure the edit page has a remove button instead of an upload button.
+ $this->drupalGet("node/$nid/edit");
+ $this->assertNoFieldByXPath('//input[@type="submit"]', t('Upload'), t('Node with file does not display the "Upload" button.'));
+ $this->assertFieldByXpath('//input[@type="submit"]', t('Remove'), t('Node with file displays the "Remove" button.'));
+
+ // "Click" the remove button (emulating either a nojs or js submission).
+ switch ($type) {
+ case 'nojs':
+ $this->drupalPost(NULL, array(), t('Remove'));
+ break;
+ case 'js':
+ $button = $this->xpath('//input[@type="submit" and @value="' . t('Remove') . '"]');
+ $this->drupalPostAJAX(NULL, array(), array((string) $button[0]['name'] => (string) $button[0]['value']));
+ break;
+ }
+
+ // Ensure the page now has an upload button instead of a remove button.
+ $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), t('After clicking the "Remove" button, it is no longer displayed.'));
+ $this->assertFieldByXpath('//input[@type="submit"]', t('Upload'), t('After clicking the "Remove" button, the "Upload" button is displayed.'));
+
+ // Save the node and ensure it does not have the file.
+ $this->drupalPost(NULL, array(), t('Save'));
+ $node = node_load($nid, NULL, TRUE);
+ $this->assertTrue(empty($node->{$field_name}[LANGUAGE_NONE][0]['fid']), t('File was successfully removed from the node.'));
+ }
+ }
+
+ /**
+ * Tests upload and remove buttons, with and without Ajax, for multiple multi-valued File field.
+ */
+ function testMultiValuedWidget() {
+ // Use 'page' instead of 'article', so that the 'article' image field does
+ // not conflict with this test. If in the future the 'page' type gets its
+ // own default file or image field, this test can be made more robust by
+ // using a custom node type.
+ $type_name = 'page';
+ $field_name = strtolower($this->randomName());
+ $field_name2 = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name, array('cardinality' => 3));
+ $this->createFileField($field_name2, $type_name, array('cardinality' => 3));
+
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $field2 = field_info_field($field_name2);
+ $instance2 = field_info_instance('node', $field_name2, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ foreach (array('nojs', 'js') as $type) {
+ // Visit the node creation form, and upload 3 files for each field. Since
+ // the field has cardinality of 3, ensure the "Upload" button is displayed
+ // until after the 3rd file, and after that, isn't displayed. Because
+ // SimpleTest triggers the last button with a given name, so upload to the
+ // second field first.
+ // @todo This is only testing a non-Ajax upload, because drupalPostAJAX()
+ // does not yet emulate jQuery's file upload.
+ //
+ $this->drupalGet("node/add/$type_name");
+ foreach (array($field_name2, $field_name) as $each_field_name) {
+ for ($delta = 0; $delta < 3; $delta++) {
+ $edit = array('files[' . $each_field_name . '_' . LANGUAGE_NONE . '_' . $delta . ']' => drupal_realpath($test_file->uri));
+ // If the Upload button doesn't exist, drupalPost() will automatically
+ // fail with an assertion message.
+ $this->drupalPost(NULL, $edit, t('Upload'));
+ }
+ }
+ $this->assertNoFieldByXpath('//input[@type="submit"]', t('Upload'), t('After uploading 3 files for each field, the "Upload" button is no longer displayed.'));
+
+ $num_expected_remove_buttons = 6;
+
+ foreach (array($field_name, $field_name2) as $current_field_name) {
+ // How many uploaded files for the current field are remaining.
+ $remaining = 3;
+ // Test clicking each "Remove" button. For extra robustness, test them out
+ // of sequential order. They are 0-indexed, and get renumbered after each
+ // iteration, so array(1, 1, 0) means:
+ // - First remove the 2nd file.
+ // - Then remove what is then the 2nd file (was originally the 3rd file).
+ // - Then remove the first file.
+ foreach (array(1,1,0) as $delta) {
+ // Ensure we have the expected number of Remove buttons, and that they
+ // are numbered sequentially.
+ $buttons = $this->xpath('//input[@type="submit" and @value="Remove"]');
+ $this->assertTrue(is_array($buttons) && count($buttons) === $num_expected_remove_buttons, t('There are %n "Remove" buttons displayed (JSMode=%type).', array('%n' => $num_expected_remove_buttons, '%type' => $type)));
+ foreach ($buttons as $i => $button) {
+ $key = $i >= $remaining ? $i - $remaining : $i;
+ $check_field_name = $field_name2;
+ if ($current_field_name == $field_name && $i < $remaining) {
+ $check_field_name = $field_name;
+ }
+
+ $this->assertIdentical((string) $button['name'], $check_field_name . '_' . LANGUAGE_NONE . '_' . $key. '_remove_button');
+ }
+
+ // "Click" the remove button (emulating either a nojs or js submission).
+ $button_name = $current_field_name . '_' . LANGUAGE_NONE . '_' . $delta . '_remove_button';
+ switch ($type) {
+ case 'nojs':
+ // drupalPost() takes a $submit parameter that is the value of the
+ // button whose click we want to emulate. Since we have multiple
+ // buttons with the value "Remove", and want to control which one we
+ // use, we change the value of the other ones to something else.
+ // Since non-clicked buttons aren't included in the submitted POST
+ // data, and since drupalPost() will result in $this being updated
+ // with a newly rebuilt form, this doesn't cause problems.
+ foreach ($buttons as $button) {
+ if ($button['name'] != $button_name) {
+ $button['value'] = 'DUMMY';
+ }
+ }
+ $this->drupalPost(NULL, array(), t('Remove'));
+ break;
+ case 'js':
+ // drupalPostAJAX() lets us target the button precisely, so we don't
+ // require the workaround used above for nojs.
+ $this->drupalPostAJAX(NULL, array(), array($button_name => t('Remove')));
+ break;
+ }
+ $num_expected_remove_buttons--;
+ $remaining--;
+
+ // Ensure an "Upload" button for the current field is displayed with the
+ // correct name.
+ $upload_button_name = $current_field_name . '_' . LANGUAGE_NONE . '_' . $remaining . '_upload_button';
+ $buttons = $this->xpath('//input[@type="submit" and @value="Upload" and @name=:name]', array(':name' => $upload_button_name));
+ $this->assertTrue(is_array($buttons) && count($buttons) == 1, t('The upload button is displayed with the correct name (JSMode=%type).', array('%type' => $type)));
+
+ // Ensure only at most one button per field is displayed.
+ $buttons = $this->xpath('//input[@type="submit" and @value="Upload"]');
+ $expected = $current_field_name == $field_name ? 1 : 2;
+ $this->assertTrue(is_array($buttons) && count($buttons) == $expected, t('After removing a file, only one "Upload" button for each possible field is displayed (JSMode=%type).', array('%type' => $type)));
+ }
+ }
+
+ // Ensure the page now has no Remove buttons.
+ $this->assertNoFieldByXPath('//input[@type="submit"]', t('Remove'), t('After removing all files, there is no "Remove" button displayed (JSMode=%type).', array('%type' => $type)));
+
+ // Save the node and ensure it does not have any files.
+ $this->drupalPost(NULL, array('title' => $this->randomName()), t('Save'));
+ $matches = array();
+ preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches);
+ $nid = $matches[1];
+ $node = node_load($nid, NULL, TRUE);
+ $this->assertTrue(empty($node->{$field_name}[LANGUAGE_NONE][0]['fid']), t('Node was successfully saved without any files.'));
+ }
+ }
+
+ /**
+ * Tests a file field with a "Private files" upload destination setting.
+ */
+ function testPrivateFileSetting() {
+ // Use 'page' instead of 'article', so that the 'article' image field does
+ // not conflict with this test. If in the future the 'page' type gets its
+ // own default file or image field, this test can be made more robust by
+ // using a custom node type.
+ $type_name = 'page';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ // Change the field setting to make its files private, and upload a file.
+ $edit = array('field[settings][uri_scheme]' => 'private');
+ $this->drupalPost("admin/structure/types/manage/$type_name/fields/$field_name", $edit, t('Save settings'));
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('New file saved to disk on node creation.'));
+
+ // Ensure the private file is available to the user who uploaded it.
+ $this->drupalGet(file_create_url($node_file->uri));
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the shipped file.'));
+
+ // Ensure we can't change 'uri_scheme' field settings while there are some
+ // entities with uploaded files.
+ $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_name");
+ $this->assertFieldByXpath('//input[@id="edit-field-settings-uri-scheme-public" and @disabled="disabled"]', 'public', t('Upload destination setting disabled.'));
+
+ // Delete node and confirm that setting could be changed.
+ node_delete($nid);
+ $this->drupalGet("admin/structure/types/manage/$type_name/fields/$field_name");
+ $this->assertFieldByXpath('//input[@id="edit-field-settings-uri-scheme-public" and not(@disabled)]', 'public', t('Upload destination setting enabled.'));
+ }
+
+ /**
+ * Tests that download restrictions on private files work on comments.
+ */
+ function testPrivateFileComment() {
+ $user = $this->drupalCreateUser(array('access comments'));
+
+ // Remove access comments permission from anon user.
+ $edit = array(
+ '1[access comments]' => FALSE,
+ );
+ $this->drupalPost('admin/people/permissions', $edit, t('Save permissions'));
+
+ // Create a new field.
+ $edit = array(
+ 'fields[_add_new_field][label]' => $label = $this->randomName(),
+ 'fields[_add_new_field][field_name]' => $name = strtolower($this->randomName()),
+ 'fields[_add_new_field][type]' => 'file',
+ 'fields[_add_new_field][widget_type]' => 'file_generic',
+ );
+ $this->drupalPost('admin/structure/types/manage/article/comment/fields', $edit, t('Save'));
+ $edit = array('field[settings][uri_scheme]' => 'private');
+ $this->drupalPost(NULL, $edit, t('Save field settings'));
+ $this->drupalPost(NULL, array(), t('Save settings'));
+
+ // Create node.
+ $text_file = $this->getTestFile('text');
+ $edit = array(
+ 'title' => $this->randomName(),
+ );
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+
+ // Add a comment with a file.
+ $text_file = $this->getTestFile('text');
+ $edit = array(
+ 'files[field_' . $name . '_' . LANGUAGE_NONE . '_' . 0 . ']' => drupal_realpath($text_file->uri),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $comment_body = $this->randomName(),
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Get the comment ID.
+ preg_match('/comment-([0-9]+)/', $this->getUrl(), $matches);
+ $cid = $matches[1];
+
+ // Log in as normal user.
+ $this->drupalLogin($user);
+
+ $comment = comment_load($cid);
+ $comment_file = (object) $comment->{'field_' . $name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($comment_file, t('New file saved to disk on node creation.'));
+ // Test authenticated file download.
+ $url = file_create_url($comment_file->uri);
+ $this->assertNotEqual($url, NULL, t('Confirmed that the URL is valid'));
+ $this->drupalGet(file_create_url($comment_file->uri));
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the shipped file.'));
+
+ // Test anonymous file download.
+ $this->drupalLogout();
+ $this->drupalGet(file_create_url($comment_file->uri));
+ $this->assertResponse(403, t('Confirmed that access is denied for the file without the needed permission.'));
+
+ // Unpublishes node.
+ $this->drupalLogin($this->admin_user);
+ $edit = array(
+ 'status' => FALSE,
+ );
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ // Ensures normal user can no longer download the file.
+ $this->drupalLogin($user);
+ $this->drupalGet(file_create_url($comment_file->uri));
+ $this->assertResponse(403, t('Confirmed that access is denied for the file without the needed permission.'));
+ }
+
+}
+
+/**
+ * Test class to test file handling with node revisions.
+ */
+class FileFieldRevisionTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File field revision test',
+ 'description' => 'Test creating and deleting revisions with files attached.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Test creating multiple revisions of a node and managing the attached files.
+ *
+ * Expected behaviors:
+ * - Adding a new revision will make another entry in the field table, but
+ * the original file will not be duplicated.
+ * - Deleting a revision should not delete the original file if the file
+ * is in use by another revision.
+ * - When the last revision that uses a file is deleted, the original file
+ * should be deleted also.
+ */
+ function testRevisions() {
+ $type_name = 'article';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ // Attach the same fields to users.
+ $this->attachFileField($field_name, 'user', 'user');
+
+ $test_file = $this->getTestFile('text');
+
+ // Create a new node with the uploaded file.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+
+ // Check that the file exists on disk and in the database.
+ $node = node_load($nid, NULL, TRUE);
+ $node_file_r1 = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $node_vid_r1 = $node->vid;
+ $this->assertFileExists($node_file_r1, t('New file saved to disk on node creation.'));
+ $this->assertFileEntryExists($node_file_r1, t('File entry exists in database on node creation.'));
+ $this->assertFileIsPermanent($node_file_r1, t('File is permanent.'));
+
+ // Upload another file to the same node in a new revision.
+ $this->replaceNodeFile($test_file, $field_name, $nid);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file_r2 = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $node_vid_r2 = $node->vid;
+ $this->assertFileExists($node_file_r2, t('Replacement file exists on disk after creating new revision.'));
+ $this->assertFileEntryExists($node_file_r2, t('Replacement file entry exists in database after creating new revision.'));
+ $this->assertFileIsPermanent($node_file_r2, t('Replacement file is permanent.'));
+
+ // Check that the original file is still in place on the first revision.
+ $node = node_load($nid, $node_vid_r1, TRUE);
+ $this->assertEqual($node_file_r1, (object) $node->{$field_name}[LANGUAGE_NONE][0], t('Original file still in place after replacing file in new revision.'));
+ $this->assertFileExists($node_file_r1, t('Original file still in place after replacing file in new revision.'));
+ $this->assertFileEntryExists($node_file_r1, t('Original file entry still in place after replacing file in new revision'));
+ $this->assertFileIsPermanent($node_file_r1, t('Original file is still permanent.'));
+
+ // Save a new version of the node without any changes.
+ // Check that the file is still the same as the previous revision.
+ $this->drupalPost('node/' . $nid . '/edit', array('revision' => '1'), t('Save'));
+ $node = node_load($nid, NULL, TRUE);
+ $node_file_r3 = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $node_vid_r3 = $node->vid;
+ $this->assertEqual($node_file_r2, $node_file_r3, t('Previous revision file still in place after creating a new revision without a new file.'));
+ $this->assertFileIsPermanent($node_file_r3, t('New revision file is permanent.'));
+
+ // Revert to the first revision and check that the original file is active.
+ $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r1 . '/revert', array(), t('Revert'));
+ $node = node_load($nid, NULL, TRUE);
+ $node_file_r4 = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $node_vid_r4 = $node->vid;
+ $this->assertEqual($node_file_r1, $node_file_r4, t('Original revision file still in place after reverting to the original revision.'));
+ $this->assertFileIsPermanent($node_file_r4, t('Original revision file still permanent after reverting to the original revision.'));
+
+ // Delete the second revision and check that the file is kept (since it is
+ // still being used by the third revision).
+ $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r2 . '/delete', array(), t('Delete'));
+ $this->assertFileExists($node_file_r3, t('Second file is still available after deleting second revision, since it is being used by the third revision.'));
+ $this->assertFileEntryExists($node_file_r3, t('Second file entry is still available after deleting second revision, since it is being used by the third revision.'));
+ $this->assertFileIsPermanent($node_file_r3, t('Second file entry is still permanent after deleting second revision, since it is being used by the third revision.'));
+
+ // Attach the second file to a user.
+ $user = $this->drupalCreateUser();
+ $edit = (array) $user;
+ $edit[$field_name][LANGUAGE_NONE][0] = (array) $node_file_r3;
+ user_save($user, $edit);
+ $this->drupalGet('user/' . $user->uid . '/edit');
+
+ // Delete the third revision and check that the file is not deleted yet.
+ $this->drupalPost('node/' . $nid . '/revisions/' . $node_vid_r3 . '/delete', array(), t('Delete'));
+ $this->assertFileExists($node_file_r3, t('Second file is still available after deleting third revision, since it is being used by the user.'));
+ $this->assertFileEntryExists($node_file_r3, t('Second file entry is still available after deleting third revision, since it is being used by the user.'));
+ $this->assertFileIsPermanent($node_file_r3, t('Second file entry is still permanent after deleting third revision, since it is being used by the user.'));
+
+ // Delete the user and check that the file is also deleted.
+ user_delete($user->uid);
+ // TODO: This seems like a bug in File API. Clearing the stat cache should
+ // not be necessary here. The file really is deleted, but stream wrappers
+ // doesn't seem to think so unless we clear the PHP file stat() cache.
+ clearstatcache();
+ $this->assertFileNotExists($node_file_r3, t('Second file is now deleted after deleting third revision, since it is no longer being used by any other nodes.'));
+ $this->assertFileEntryNotExists($node_file_r3, t('Second file entry is now deleted after deleting third revision, since it is no longer being used by any other nodes.'));
+
+ // Delete the entire node and check that the original file is deleted.
+ $this->drupalPost('node/' . $nid . '/delete', array(), t('Delete'));
+ $this->assertFileNotExists($node_file_r1, t('Original file is deleted after deleting the entire node with two revisions remaining.'));
+ $this->assertFileEntryNotExists($node_file_r1, t('Original file entry is deleted after deleting the entire node with two revisions remaining.'));
+ }
+}
+
+/**
+ * Test class to check that formatters are working properly.
+ */
+class FileFieldDisplayTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File field display tests',
+ 'description' => 'Test the display of file fields in node and views.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Test normal formatter display on node display.
+ */
+ function testNodeDisplay() {
+ $field_name = strtolower($this->randomName());
+ $type_name = 'article';
+ $field_settings = array(
+ 'display_field' => '1',
+ 'display_default' => '1',
+ );
+ $instance_settings = array(
+ 'description_field' => '1',
+ );
+ $widget_settings = array();
+ $this->createFileField($field_name, $type_name, $field_settings, $instance_settings, $widget_settings);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ // Create a new node with the uploaded file.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $this->drupalGet('node/' . $nid . '/edit');
+
+ // Check that the default formatter is displaying with the file name.
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $default_output = theme('file_link', array('file' => $node_file));
+ $this->assertRaw($default_output, t('Default formatter displaying correctly on full node view.'));
+
+ // Turn the "display" option off and check that the file is no longer displayed.
+ $edit = array($field_name . '[' . LANGUAGE_NONE . '][0][display]' => FALSE);
+ $this->drupalPost('node/' . $nid . '/edit', $edit, t('Save'));
+
+ $this->assertNoRaw($default_output, t('Field is hidden when "display" option is unchecked.'));
+
+ }
+}
+
+/**
+ * Test class to check for various validations.
+ */
+class FileFieldValidateTestCase extends FileFieldTestCase {
+ protected $field;
+ protected $node_type;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'File field validation tests',
+ 'description' => 'Tests validation functions such as file type, max file size, max size per node, and required.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Test required property on file fields.
+ */
+ function testRequired() {
+ $type_name = 'article';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name, array(), array('required' => '1'));
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ // Try to post a new node without uploading a file.
+ $langcode = LANGUAGE_NONE;
+ $edit = array("title" => $this->randomName());
+ $this->drupalPost('node/add/' . $type_name, $edit, t('Save'));
+ $this->assertRaw(t('!title field is required.', array('!title' => $instance['label'])), t('Node save failed when required file field was empty.'));
+
+ // Create a new node with the uploaded file.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $this->assertTrue($nid !== FALSE, t('uploadNodeFile(@test_file, @field_name, @type_name) succeeded', array('@test_file' => $test_file->uri, '@field_name' => $field_name, '@type_name' => $type_name)));
+
+ $node = node_load($nid, NULL, TRUE);
+
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading to the required field.'));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading to the required field.'));
+
+ // Try again with a multiple value field.
+ field_delete_field($field_name);
+ $this->createFileField($field_name, $type_name, array('cardinality' => FIELD_CARDINALITY_UNLIMITED), array('required' => '1'));
+
+ // Try to post a new node without uploading a file in the multivalue field.
+ $edit = array('title' => $this->randomName());
+ $this->drupalPost('node/add/' . $type_name, $edit, t('Save'));
+ $this->assertRaw(t('!title field is required.', array('!title' => $instance['label'])), t('Node save failed when required multiple value file field was empty.'));
+
+ // Create a new node with the uploaded file into the multivalue field.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading to the required multiple value field.'));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading to the required multipel value field.'));
+
+ // Remove our file field.
+ field_delete_field($field_name);
+ }
+
+ /**
+ * Test the max file size validator.
+ */
+ function testFileMaxSize() {
+ $type_name = 'article';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name, array(), array('required' => '1'));
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $small_file = $this->getTestFile('text', 131072); // 128KB.
+ $large_file = $this->getTestFile('text', 1310720); // 1.2MB
+
+ // Test uploading both a large and small file with different increments.
+ $sizes = array(
+ '1M' => 1048576,
+ '1024K' => 1048576,
+ '1048576' => 1048576,
+ );
+
+ foreach ($sizes as $max_filesize => $file_limit) {
+ // Set the max file upload size.
+ $this->updateFileField($field_name, $type_name, array('max_filesize' => $max_filesize));
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ // Create a new node with the small file, which should pass.
+ $nid = $this->uploadNodeFile($small_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading a file (%filesize) under the max limit (%maxsize).', array('%filesize' => format_size($small_file->filesize), '%maxsize' => $max_filesize)));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file (%filesize) under the max limit (%maxsize).', array('%filesize' => format_size($small_file->filesize), '%maxsize' => $max_filesize)));
+
+ // Check that uploading the large file fails (1M limit).
+ $nid = $this->uploadNodeFile($large_file, $field_name, $type_name);
+ $error_message = t('The file is %filesize exceeding the maximum file size of %maxsize.', array('%filesize' => format_size($large_file->filesize), '%maxsize' => format_size($file_limit)));
+ $this->assertRaw($error_message, t('Node save failed when file (%filesize) exceeded the max upload size (%maxsize).', array('%filesize' => format_size($large_file->filesize), '%maxsize' => $max_filesize)));
+ }
+
+ // Turn off the max filesize.
+ $this->updateFileField($field_name, $type_name, array('max_filesize' => ''));
+
+ // Upload the big file successfully.
+ $nid = $this->uploadNodeFile($large_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading a file (%filesize) with no max limit.', array('%filesize' => format_size($large_file->filesize))));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file (%filesize) with no max limit.', array('%filesize' => format_size($large_file->filesize))));
+
+ // Remove our file field.
+ field_delete_field($field_name);
+ }
+
+ /**
+ * Test the file extension, do additional checks if mimedetect is installed.
+ */
+ function testFileExtension() {
+ $type_name = 'article';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('image');
+ list(, $test_file_extension) = explode('.', $test_file->filename);
+
+ // Disable extension checking.
+ $this->updateFileField($field_name, $type_name, array('file_extensions' => ''));
+
+ // Check that the file can be uploaded with no extension checking.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading a file with no extension checking.'));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file with no extension checking.'));
+
+ // Enable extension checking for text files.
+ $this->updateFileField($field_name, $type_name, array('file_extensions' => 'txt'));
+
+ // Check that the file with the wrong extension cannot be uploaded.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $error_message = t('Only files with the following extensions are allowed: %files-allowed.', array('%files-allowed' => 'txt'));
+ $this->assertRaw($error_message, t('Node save failed when file uploaded with the wrong extension.'));
+
+ // Enable extension checking for text and image files.
+ $this->updateFileField($field_name, $type_name, array('file_extensions' => "txt $test_file_extension"));
+
+ // Check that the file can be uploaded with extension checking.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertFileExists($node_file, t('File exists after uploading a file with extension checking.'));
+ $this->assertFileEntryExists($node_file, t('File entry exists after uploading a file with extension checking.'));
+
+ // Remove our file field.
+ field_delete_field($field_name);
+ }
+}
+
+/**
+ * Test class to check that files are uploaded to proper locations.
+ */
+class FileFieldPathTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File field file path tests',
+ 'description' => 'Test that files are uploaded to the proper location with token support.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Test normal formatter display on node display.
+ */
+ function testUploadPath() {
+ $field_name = strtolower($this->randomName());
+ $type_name = 'article';
+ $field = $this->createFileField($field_name, $type_name);
+ $test_file = $this->getTestFile('text');
+
+ // Create a new node.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+
+ // Check that the file was uploaded to the file root.
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertPathMatch('public://' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path.', array('%file' => $node_file->uri)));
+
+ // Change the path to contain multiple subdirectories.
+ $field = $this->updateFileField($field_name, $type_name, array('file_directory' => 'foo/bar/baz'));
+
+ // Upload a new file into the subdirectories.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+
+ // Check that the file was uploaded into the subdirectory.
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ $this->assertPathMatch('public://foo/bar/baz/' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path.', array('%file' => $node_file->uri)));
+
+ // Check the path when used with tokens.
+ // Change the path to contain multiple token directories.
+ $field = $this->updateFileField($field_name, $type_name, array('file_directory' => '[current-user:uid]/[current-user:name]'));
+
+ // Upload a new file into the token subdirectories.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+
+ // Check that the file was uploaded into the subdirectory.
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ // Do token replacement using the same user which uploaded the file, not
+ // the user running the test case.
+ $data = array('user' => $this->admin_user);
+ $subdirectory = token_replace('[user:uid]/[user:name]', $data);
+ $this->assertPathMatch('public://' . $subdirectory . '/' . $test_file->filename, $node_file->uri, t('The file %file was uploaded to the correct path with token replacements.', array('%file' => $node_file->uri)));
+ }
+
+ /**
+ * A loose assertion to check that a file is uploaded to the right location.
+ *
+ * @param $expected_path
+ * The location where the file is expected to be uploaded. Duplicate file
+ * names to not need to be taken into account.
+ * @param $actual_path
+ * Where the file was actually uploaded.
+ * @param $message
+ * The message to display with this assertion.
+ */
+ function assertPathMatch($expected_path, $actual_path, $message) {
+ // Strip off the extension of the expected path to allow for _0, _1, etc.
+ // suffixes when the file hits a duplicate name.
+ $pos = strrpos($expected_path, '.');
+ $base_path = substr($expected_path, 0, $pos);
+ $extension = substr($expected_path, $pos + 1);
+
+ $result = preg_match('/' . preg_quote($base_path, '/') . '(_[0-9]+)?\.' . preg_quote($extension, '/') . '/', $actual_path);
+ $this->assertTrue($result, $message);
+ }
+}
+
+/**
+ * Test file token replacement in strings.
+ */
+class FileTokenReplaceTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check file token replacement.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Creates a file, then tests the tokens generated from it.
+ */
+ function testFileTokenReplacement() {
+ global $language;
+ $url_options = array(
+ 'absolute' => TRUE,
+ 'language' => $language,
+ );
+
+ // Create file field.
+ $type_name = 'article';
+ $field_name = 'field_' . strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name);
+ $field = field_info_field($field_name);
+ $instance = field_info_instance('node', $field_name, $type_name);
+
+ $test_file = $this->getTestFile('text');
+
+ // Create a new node with the uploaded file.
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name);
+
+ // Load the node and the file.
+ $node = node_load($nid, NULL, TRUE);
+ $file = file_load($node->{$field_name}[LANGUAGE_NONE][0]['fid']);
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[file:fid]'] = $file->fid;
+ $tests['[file:name]'] = check_plain($file->filename);
+ $tests['[file:path]'] = check_plain($file->uri);
+ $tests['[file:mime]'] = check_plain($file->filemime);
+ $tests['[file:size]'] = format_size($file->filesize);
+ $tests['[file:url]'] = check_plain(file_create_url($file->uri));
+ $tests['[file:timestamp]'] = format_date($file->timestamp, 'medium', '', NULL, $language->language);
+ $tests['[file:timestamp:short]'] = format_date($file->timestamp, 'short', '', NULL, $language->language);
+ $tests['[file:owner]'] = check_plain(format_username($this->admin_user));
+ $tests['[file:owner:uid]'] = $file->uid;
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('file' => $file), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized file token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[file:name]'] = $file->filename;
+ $tests['[file:path]'] = $file->uri;
+ $tests['[file:mime]'] = $file->filemime;
+ $tests['[file:size]'] = format_size($file->filesize);
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('file' => $file), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized file token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+/**
+ * Test class to test file access on private nodes.
+ */
+class FilePrivateTestCase extends FileFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Private file test',
+ 'description' => 'Uploads a test to a private node and checks access.',
+ 'group' => 'File',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('node_access_test');
+ node_access_rebuild();
+ variable_set('node_access_test_private', TRUE);
+ }
+
+ /**
+ * Uploads a file to a private node, then tests that access is allowed and denied when appropriate.
+ */
+ function testPrivateFile() {
+ // Use 'page' instead of 'article', so that the 'article' image field does
+ // not conflict with this test. If in the future the 'page' type gets its
+ // own default file or image field, this test can be made more robust by
+ // using a custom node type.
+ $type_name = 'page';
+ $field_name = strtolower($this->randomName());
+ $this->createFileField($field_name, $type_name, array('uri_scheme' => 'private'));
+
+ $test_file = $this->getTestFile('text');
+ $nid = $this->uploadNodeFile($test_file, $field_name, $type_name, TRUE, array('private' => TRUE));
+ $node = node_load($nid, NULL, TRUE);
+ $node_file = (object) $node->{$field_name}[LANGUAGE_NONE][0];
+ // Ensure the file can be downloaded.
+ $this->drupalGet(file_create_url($node_file->uri));
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the shipped file.'));
+ $this->drupalLogOut();
+ $this->drupalGet(file_create_url($node_file->uri));
+ $this->assertResponse(403, t('Confirmed that access is denied for the file without the needed permission.'));
+ }
+}
diff --git a/core/modules/file/tests/file_module_test.info b/core/modules/file/tests/file_module_test.info
new file mode 100644
index 000000000000..d83441cc9751
--- /dev/null
+++ b/core/modules/file/tests/file_module_test.info
@@ -0,0 +1,6 @@
+name = File test
+description = Provides hooks for testing File module functionality.
+package = Core
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/file/tests/file_module_test.module b/core/modules/file/tests/file_module_test.module
new file mode 100644
index 000000000000..ea65981caede
--- /dev/null
+++ b/core/modules/file/tests/file_module_test.module
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * @file
+ * Provides File module pages for testing purposes.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function file_module_test_menu() {
+ $items = array();
+
+ $items['file/test'] = array(
+ 'title' => 'Managed file test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('file_module_test_form'),
+ 'access arguments' => array('access content'),
+ );
+
+ return $items;
+}
+
+/**
+ * Form builder for testing a 'managed_file' element.
+ */
+function file_module_test_form($form, &$form_state, $tree = TRUE, $extended = FALSE, $default_fid = NULL) {
+ $form['#tree'] = (bool) $tree;
+
+ $form['nested']['file'] = array(
+ '#type' => 'managed_file',
+ '#title' => t('Managed file'),
+ '#upload_location' => 'public://test',
+ '#progress_message' => t('Please wait...'),
+ '#extended' => (bool) $extended,
+ );
+ if ($default_fid) {
+ $form['nested']['file']['#default_value'] = $extended ? array('fid' => $default_fid) : $default_fid;
+ }
+
+ $form['textfield'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Type a value and ensure it stays'),
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form submission handler for file_module_test_form().
+ */
+function file_module_test_form_submit($form, &$form_state) {
+ if ($form['#tree']) {
+ $fid = $form['nested']['file']['#extended'] ? $form_state['values']['nested']['file']['fid'] : $form_state['values']['nested']['file'];
+ }
+ else {
+ $fid = $form['nested']['file']['#extended'] ? $form_state['values']['file']['fid'] : $form_state['values']['file'];
+ }
+ drupal_set_message(t('The file id is %fid.', array('%fid' => $fid)));
+}
diff --git a/core/modules/filter/filter.admin.inc b/core/modules/filter/filter.admin.inc
new file mode 100644
index 000000000000..5a21e6e2e610
--- /dev/null
+++ b/core/modules/filter/filter.admin.inc
@@ -0,0 +1,365 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the filter module.
+ */
+
+/**
+ * Menu callback; Displays a list of all text formats and allows them to be rearranged.
+ *
+ * @ingroup forms
+ * @see filter_admin_overview_submit()
+ */
+function filter_admin_overview($form) {
+ // Overview of all formats.
+ $formats = filter_formats();
+ $fallback_format = filter_fallback_format();
+
+ $form['#tree'] = TRUE;
+ foreach ($formats as $id => $format) {
+ // Check whether this is the fallback text format. This format is available
+ // to all roles and cannot be disabled via the admin interface.
+ $form['formats'][$id]['#is_fallback'] = ($id == $fallback_format);
+ if ($form['formats'][$id]['#is_fallback']) {
+ $form['formats'][$id]['name'] = array('#markup' => drupal_placeholder($format->name));
+ $roles_markup = drupal_placeholder(t('All roles may use this format'));
+ }
+ else {
+ $form['formats'][$id]['name'] = array('#markup' => check_plain($format->name));
+ $roles = array_map('check_plain', filter_get_roles_by_format($format));
+ $roles_markup = $roles ? implode(', ', $roles) : t('No roles may use this format');
+ }
+ $form['formats'][$id]['roles'] = array('#markup' => $roles_markup);
+ $form['formats'][$id]['configure'] = array('#type' => 'link', '#title' => t('configure'), '#href' => 'admin/config/content/formats/' . $id);
+ $form['formats'][$id]['disable'] = array('#type' => 'link', '#title' => t('disable'), '#href' => 'admin/config/content/formats/' . $id . '/disable', '#access' => !$form['formats'][$id]['#is_fallback']);
+ $form['formats'][$id]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for @title', array('@title' => $format->name)),
+ '#title_display' => 'invisible',
+ '#default_value' => $format->weight,
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save changes'));
+ return $form;
+}
+
+function filter_admin_overview_submit($form, &$form_state) {
+ foreach ($form_state['values']['formats'] as $id => $data) {
+ if (is_array($data) && isset($data['weight'])) {
+ // Only update if this is a form element with weight.
+ db_update('filter_format')
+ ->fields(array('weight' => $data['weight']))
+ ->condition('format', $id)
+ ->execute();
+ }
+ }
+ filter_formats_reset();
+ drupal_set_message(t('The text format ordering has been saved.'));
+}
+
+/**
+ * Returns HTML for the text format administration overview form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_filter_admin_overview($variables) {
+ $form = $variables['form'];
+
+ $rows = array();
+ foreach (element_children($form['formats']) as $id) {
+ $form['formats'][$id]['weight']['#attributes']['class'] = array('text-format-order-weight');
+ $rows[] = array(
+ 'data' => array(
+ drupal_render($form['formats'][$id]['name']),
+ drupal_render($form['formats'][$id]['roles']),
+ drupal_render($form['formats'][$id]['weight']),
+ drupal_render($form['formats'][$id]['configure']),
+ drupal_render($form['formats'][$id]['disable']),
+ ),
+ 'class' => array('draggable'),
+ );
+ }
+ $header = array(t('Name'), t('Roles'), t('Weight'), array('data' => t('Operations'), 'colspan' => 2));
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'text-format-order')));
+ $output .= drupal_render_children($form);
+
+ drupal_add_tabledrag('text-format-order', 'order', 'sibling', 'text-format-order-weight');
+
+ return $output;
+}
+
+/**
+ * Menu callback; Display a text format form.
+ */
+function filter_admin_format_page($format = NULL) {
+ if (!isset($format->name)) {
+ drupal_set_title(t('Add text format'));
+ $format = (object) array(
+ 'format' => NULL,
+ 'name' => '',
+ );
+ }
+ return drupal_get_form('filter_admin_format_form', $format);
+}
+
+/**
+ * Generate a text format form.
+ *
+ * @ingroup forms
+ * @see filter_admin_format_form_validate()
+ * @see filter_admin_format_form_submit()
+ */
+function filter_admin_format_form($form, &$form_state, $format) {
+ $is_fallback = ($format->format == filter_fallback_format());
+
+ $form['#format'] = $format;
+ $form['#tree'] = TRUE;
+ $form['#attached']['js'][] = drupal_get_path('module', 'filter') . '/filter.admin.js';
+ $form['#attached']['css'][] = drupal_get_path('module', 'filter') . '/filter.css';
+
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#default_value' => $format->name,
+ '#required' => TRUE,
+ );
+ $form['format'] = array(
+ '#type' => 'machine_name',
+ '#required' => TRUE,
+ '#default_value' => $format->format,
+ '#maxlength' => 255,
+ '#machine_name' => array(
+ 'exists' => 'filter_format_exists',
+ ),
+ '#disabled' => !empty($format->format),
+ );
+
+ // Add user role access selection.
+ $form['roles'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Roles'),
+ '#options' => array_map('check_plain', user_roles()),
+ '#disabled' => $is_fallback,
+ );
+ if ($is_fallback) {
+ $form['roles']['#description'] = t('All roles for this text format must be enabled and cannot be changed.');
+ }
+ if (!empty($format->format)) {
+ // If editing an existing text format, pre-select its current permissions.
+ $form['roles']['#default_value'] = array_keys(filter_get_roles_by_format($format));
+ }
+ elseif ($admin_role = variable_get('user_admin_role', 0)) {
+ // If adding a new text format and the site has an administrative role,
+ // pre-select that role so as to grant administrators access to the new
+ // text format permission by default.
+ $form['roles']['#default_value'] = array($admin_role);
+ }
+
+ // Retrieve available filters and load all configured filters for existing
+ // text formats.
+ $filter_info = filter_get_filters();
+ $filters = !empty($format->format) ? filter_list_format($format->format) : array();
+
+ // Prepare filters for form sections.
+ foreach ($filter_info as $name => $filter) {
+ // Create an empty filter object for new/unconfigured filters.
+ if (!isset($filters[$name])) {
+ $filters[$name] = new stdClass();
+ $filters[$name]->format = $format->format;
+ $filters[$name]->module = $filter['module'];
+ $filters[$name]->name = $name;
+ $filters[$name]->status = 0;
+ $filters[$name]->weight = $filter['weight'];
+ $filters[$name]->settings = array();
+ }
+ }
+ $form['#filters'] = $filters;
+
+ // Filter status.
+ $form['filters']['status'] = array(
+ '#type' => 'item',
+ '#title' => t('Enabled filters'),
+ '#prefix' => '<div id="filters-status-wrapper">',
+ '#suffix' => '</div>',
+ );
+ foreach ($filter_info as $name => $filter) {
+ $form['filters']['status'][$name] = array(
+ '#type' => 'checkbox',
+ '#title' => $filter['title'],
+ '#default_value' => $filters[$name]->status,
+ '#parents' => array('filters', $name, 'status'),
+ '#description' => $filter['description'],
+ '#weight' => $filter['weight'],
+ );
+ }
+
+ // Filter order (tabledrag).
+ $form['filters']['order'] = array(
+ '#type' => 'item',
+ '#title' => t('Filter processing order'),
+ '#theme' => 'filter_admin_format_filter_order',
+ );
+ foreach ($filter_info as $name => $filter) {
+ $form['filters']['order'][$name]['filter'] = array(
+ '#markup' => $filter['title'],
+ );
+ $form['filters']['order'][$name]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for @title', array('@title' => $filter['title'])),
+ '#title_display' => 'invisible',
+ '#delta' => 50,
+ '#default_value' => $filters[$name]->weight,
+ '#parents' => array('filters', $name, 'weight'),
+ );
+ $form['filters']['order'][$name]['#weight'] = $filters[$name]->weight;
+ }
+
+ // Filter settings.
+ $form['filter_settings_title'] = array(
+ '#type' => 'item',
+ '#title' => t('Filter settings'),
+ );
+ $form['filter_settings'] = array(
+ '#type' => 'vertical_tabs',
+ );
+
+ foreach ($filter_info as $name => $filter) {
+ if (isset($filter['settings callback']) && function_exists($filter['settings callback'])) {
+ $function = $filter['settings callback'];
+ // Pass along stored filter settings and default settings, but also the
+ // format object and all filters to allow for complex implementations.
+ $defaults = (isset($filter['default settings']) ? $filter['default settings'] : array());
+ $settings_form = $function($form, $form_state, $filters[$name], $format, $defaults, $filters);
+ if (!empty($settings_form)) {
+ $form['filters']['settings'][$name] = array(
+ '#type' => 'fieldset',
+ '#title' => $filter['title'],
+ '#parents' => array('filters', $name, 'settings'),
+ '#weight' => $filter['weight'],
+ '#group' => 'filter_settings',
+ );
+ $form['filters']['settings'][$name] += $settings_form;
+ }
+ }
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save configuration'));
+
+ return $form;
+}
+
+/**
+ * Returns HTML for a text format's filter order form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_filter_admin_format_filter_order($variables) {
+ $element = $variables['element'];
+
+ // Filter order (tabledrag).
+ $rows = array();
+ foreach (element_children($element, TRUE) as $name) {
+ $element[$name]['weight']['#attributes']['class'][] = 'filter-order-weight';
+ $rows[] = array(
+ 'data' => array(
+ drupal_render($element[$name]['filter']),
+ drupal_render($element[$name]['weight']),
+ ),
+ 'class' => array('draggable'),
+ );
+ }
+ $output = drupal_render_children($element);
+ $output .= theme('table', array('rows' => $rows, 'attributes' => array('id' => 'filter-order')));
+ drupal_add_tabledrag('filter-order', 'order', 'sibling', 'filter-order-weight', NULL, NULL, TRUE);
+
+ return $output;
+}
+
+/**
+ * Validate text format form submissions.
+ */
+function filter_admin_format_form_validate($form, &$form_state) {
+ $format_format = trim($form_state['values']['format']);
+ $format_name = trim($form_state['values']['name']);
+
+ // Ensure that the values to be saved later are exactly the ones validated.
+ form_set_value($form['format'], $format_format, $form_state);
+ form_set_value($form['name'], $format_name, $form_state);
+
+ $result = db_query("SELECT format FROM {filter_format} WHERE name = :name AND format <> :format", array(':name' => $format_name, ':format' => $format_format))->fetchField();
+ if ($result) {
+ form_set_error('name', t('Text format names must be unique. A format named %name already exists.', array('%name' => $format_name)));
+ }
+}
+
+/**
+ * Process text format form submissions.
+ */
+function filter_admin_format_form_submit($form, &$form_state) {
+ // Remove unnecessary values.
+ form_state_values_clean($form_state);
+
+ // Add the submitted form values to the text format, and save it.
+ $format = $form['#format'];
+ foreach ($form_state['values'] as $key => $value) {
+ $format->$key = $value;
+ }
+ $status = filter_format_save($format);
+
+ // Save user permissions.
+ if ($permission = filter_permission_name($format)) {
+ foreach ($format->roles as $rid => $enabled) {
+ user_role_change_permissions($rid, array($permission => $enabled));
+ }
+ }
+
+ switch ($status) {
+ case SAVED_NEW:
+ drupal_set_message(t('Added text format %format.', array('%format' => $format->name)));
+ break;
+
+ case SAVED_UPDATED:
+ drupal_set_message(t('The text format %format has been updated.', array('%format' => $format->name)));
+ break;
+ }
+}
+
+/**
+ * Menu callback; confirm deletion of a format.
+ *
+ * @ingroup forms
+ * @see filter_admin_disable_submit()
+ */
+function filter_admin_disable($form, &$form_state, $format) {
+ $form['#format'] = $format;
+
+ return confirm_form($form,
+ t('Are you sure you want to disable the text format %format?', array('%format' => $format->name)),
+ 'admin/config/content/formats',
+ t('Disabled text formats are completely removed from the administrative interface, and any content stored with that format will not be displayed. This action cannot be undone.'),
+ t('Disable')
+ );
+}
+
+/**
+ * Process filter disable form submission.
+ */
+function filter_admin_disable_submit($form, &$form_state) {
+ $format = $form['#format'];
+ filter_format_disable($format);
+ drupal_set_message(t('Disabled text format %format.', array('%format' => $format->name)));
+
+ $form_state['redirect'] = 'admin/config/content/formats';
+}
+
diff --git a/core/modules/filter/filter.admin.js b/core/modules/filter/filter.admin.js
new file mode 100644
index 000000000000..3bc623373ced
--- /dev/null
+++ b/core/modules/filter/filter.admin.js
@@ -0,0 +1,44 @@
+(function ($) {
+
+Drupal.behaviors.filterStatus = {
+ attach: function (context, settings) {
+ $('#filters-status-wrapper input.form-checkbox', context).once('filter-status', function () {
+ var $checkbox = $(this);
+ // Retrieve the tabledrag row belonging to this filter.
+ var $row = $('#' + $checkbox.attr('id').replace(/-status$/, '-weight'), context).closest('tr');
+ // Retrieve the vertical tab belonging to this filter.
+ var tab = $('#' + $checkbox.attr('id').replace(/-status$/, '-settings'), context).data('verticalTab');
+
+ // Bind click handler to this checkbox to conditionally show and hide the
+ // filter's tableDrag row and vertical tab pane.
+ $checkbox.bind('click.filterUpdate', function () {
+ if ($checkbox.is(':checked')) {
+ $row.show();
+ if (tab) {
+ tab.tabShow().updateSummary();
+ }
+ }
+ else {
+ $row.hide();
+ if (tab) {
+ tab.tabHide().updateSummary();
+ }
+ }
+ // Restripe table after toggling visibility of table row.
+ Drupal.tableDrag['filter-order'].restripeTable();
+ });
+
+ // Attach summary for configurable filters (only for screen-readers).
+ if (tab) {
+ tab.fieldset.drupalSetSummary(function (tabContext) {
+ return $checkbox.is(':checked') ? Drupal.t('Enabled') : Drupal.t('Disabled');
+ });
+ }
+
+ // Trigger our bound click handler to update elements to initial state.
+ $checkbox.triggerHandler('click.filterUpdate');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/filter/filter.api.php b/core/modules/filter/filter.api.php
new file mode 100644
index 000000000000..6675e4af556d
--- /dev/null
+++ b/core/modules/filter/filter.api.php
@@ -0,0 +1,323 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Filter module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Define content filters.
+ *
+ * User submitted content is passed through a group of filters before it is
+ * output in HTML, in order to remove insecure or unwanted parts, correct or
+ * enhance the formatting, transform special keywords, etc. A group of filters
+ * is referred to as a "text format". Administrators can create as many text
+ * formats as needed. Individual filters can be enabled and configured
+ * differently for each text format.
+ *
+ * This hook is invoked by filter_get_filters() and allows modules to register
+ * input filters they provide.
+ *
+ * Filtering is a two-step process. First, the content is 'prepared' by calling
+ * the 'prepare callback' function for every filter. The purpose of the 'prepare
+ * callback' is to escape HTML-like structures. For example, imagine a filter
+ * which allows the user to paste entire chunks of programming code without
+ * requiring manual escaping of special HTML characters like < or &. If the
+ * programming code were left untouched, then other filters could think it was
+ * HTML and change it. For many filters, the prepare step is not necessary.
+ *
+ * The second step is the actual processing step. The result from passing the
+ * text through all the filters' prepare steps gets passed to all the filters
+ * again, this time with the 'process callback' function. The process callbacks
+ * should then actually change the content: transform URLs into hyperlinks,
+ * convert smileys into images, etc.
+ *
+ * For performance reasons content is only filtered once; the result is stored
+ * in the cache table and retrieved from the cache the next time the same piece
+ * of content is displayed. If a filter's output is dynamic, it can override the
+ * cache mechanism, but obviously this should be used with caution: having one
+ * filter that does not support caching in a particular text format disables
+ * caching for the entire format, not just for one filter.
+ *
+ * Beware of the filter cache when developing your module: it is advised to set
+ * your filter to 'cache' => FALSE while developing, but be sure to remove that
+ * setting if it's not needed, when you are no longer in development mode.
+ *
+ * @return
+ * An associative array of filters, whose keys are internal filter names,
+ * which should be unique and therefore prefixed with the name of the module.
+ * Each value is an associative array describing the filter, with the
+ * following elements (all are optional except as noted):
+ * - title: (required) An administrative summary of what the filter does.
+ * - description: Additional administrative information about the filter's
+ * behavior, if needed for clarification.
+ * - settings callback: The name of a function that returns configuration form
+ * elements for the filter. See hook_filter_FILTER_settings() for details.
+ * - default settings: An associative array containing default settings for
+ * the filter, to be applied when the filter has not been configured yet.
+ * - prepare callback: The name of a function that escapes the content before
+ * the actual filtering happens. See hook_filter_FILTER_prepare() for
+ * details.
+ * - process callback: (required) The name the function that performs the
+ * actual filtering. See hook_filter_FILTER_process() for details.
+ * - cache (default TRUE): Specifies whether the filtered text can be cached.
+ * Note that setting this to FALSE makes the entire text format not
+ * cacheable, which may have an impact on the site's overall performance.
+ * See filter_format_allowcache() for details.
+ * - tips callback: The name of a function that returns end-user-facing filter
+ * usage guidelines for the filter. See hook_filter_FILTER_tips() for
+ * details.
+ * - weight: A default weight for the filter in new text formats.
+ *
+ * @see filter_example.module
+ * @see hook_filter_info_alter()
+ */
+function hook_filter_info() {
+ $filters['filter_html'] = array(
+ 'title' => t('Limit allowed HTML tags'),
+ 'description' => t('Allows you to restrict the HTML tags the user can use. It will also remove harmful content such as JavaScript events, JavaScript URLs and CSS styles from those tags that are not removed.'),
+ 'process callback' => '_filter_html',
+ 'settings callback' => '_filter_html_settings',
+ 'default settings' => array(
+ 'allowed_html' => '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>',
+ 'filter_html_help' => 1,
+ 'filter_html_nofollow' => 0,
+ ),
+ 'tips callback' => '_filter_html_tips',
+ );
+ $filters['filter_autop'] = array(
+ 'title' => t('Convert line breaks'),
+ 'description' => t('Converts line breaks into HTML (i.e. &lt;br&gt; and &lt;p&gt;) tags.'),
+ 'process callback' => '_filter_autop',
+ 'tips callback' => '_filter_autop_tips',
+ );
+ return $filters;
+}
+
+/**
+ * Perform alterations on filter definitions.
+ *
+ * @param $info
+ * Array of information on filters exposed by hook_filter_info()
+ * implementations.
+ */
+function hook_filter_info_alter(&$info) {
+ // Replace the PHP evaluator process callback with an improved
+ // PHP evaluator provided by a module.
+ $info['php_code']['process callback'] = 'my_module_php_evaluator';
+
+ // Alter the default settings of the URL filter provided by core.
+ $info['filter_url']['default settings'] = array(
+ 'filter_url_length' => 100,
+ );
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
+
+/**
+ * Settings callback for hook_filter_info().
+ *
+ * Note: This is not really a hook. The function name is manually specified via
+ * 'settings callback' in hook_filter_info(), with this recommended callback
+ * name pattern. It is called from filter_admin_format_form().
+ *
+ * This callback function is used to provide a settings form for filter
+ * settings, for filters that need settings on a per-text-format basis. This
+ * function should return the form elements for the settings; the filter
+ * module will take care of saving the settings in the database.
+ *
+ * If the filter's behavior depends on an extensive list and/or external data
+ * (e.g. a list of smileys, a list of glossary terms), then the filter module
+ * can choose to provide a separate, global configuration page rather than
+ * per-text-format settings. In that case, the settings callback function
+ * should provide a link to the separate settings page.
+ *
+ * @param $form
+ * The prepopulated form array of the filter administration form.
+ * @param $form_state
+ * The state of the (entire) configuration form.
+ * @param $filter
+ * The filter object containing the current settings for the given format,
+ * in $filter->settings.
+ * @param $format
+ * The format object being configured.
+ * @param $defaults
+ * The default settings for the filter, as defined in 'default settings' in
+ * hook_filter_info(). These should be combined with $filter->settings to
+ * define the form element defaults.
+ * @param $filters
+ * The complete list of filter objects that are enabled for the given format.
+ *
+ * @return
+ * An array of form elements defining settings for the filter. Array keys
+ * should match the array keys in $filter->settings and $defaults.
+ */
+function hook_filter_FILTER_settings($form, &$form_state, $filter, $format, $defaults, $filters) {
+ $filter->settings += $defaults;
+
+ $elements = array();
+ $elements['nofollow'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Add rel="nofollow" to all links'),
+ '#default_value' => $filter->settings['nofollow'],
+ );
+ return $elements;
+}
+
+/**
+ * Prepare callback for hook_filter_info().
+ *
+ * Note: This is not really a hook. The function name is manually specified via
+ * 'prepare callback' in hook_filter_info(), with this recommended callback
+ * name pattern. It is called from check_markup().
+ *
+ * See hook_filter_info() for a description of the filtering process. Filters
+ * should not use the 'prepare callback' step for anything other than escaping,
+ * because that would short-circuit the control the user has over the order in
+ * which filters are applied.
+ *
+ * @param $text
+ * The text string to be filtered.
+ * @param $filter
+ * The filter object containing settings for the given format.
+ * @param $format
+ * The text format object assigned to the text to be filtered.
+ * @param $langcode
+ * The language code of the text to be filtered.
+ * @param $cache
+ * A Boolean indicating whether the filtered text is going to be cached in
+ * {cache_filter}.
+ * @param $cache_id
+ * The ID of the filtered text in {cache_filter}, if $cache is TRUE.
+ *
+ * @return
+ * The prepared, escaped text.
+ */
+function hook_filter_FILTER_prepare($text, $filter, $format, $langcode, $cache, $cache_id) {
+ // Escape <code> and </code> tags.
+ $text = preg_replace('|<code>(.+?)</code>|se', "[codefilter_code]$1[/codefilter_code]", $text);
+ return $text;
+}
+
+/**
+ * Process callback for hook_filter_info().
+ *
+ * Note: This is not really a hook. The function name is manually specified via
+ * 'process callback' in hook_filter_info(), with this recommended callback
+ * name pattern. It is called from check_markup().
+ *
+ * See hook_filter_info() for a description of the filtering process. This step
+ * is where the filter actually transforms the text.
+ *
+ * @param $text
+ * The text string to be filtered.
+ * @param $filter
+ * The filter object containing settings for the given format.
+ * @param $format
+ * The text format object assigned to the text to be filtered.
+ * @param $langcode
+ * The language code of the text to be filtered.
+ * @param $cache
+ * A Boolean indicating whether the filtered text is going to be cached in
+ * {cache_filter}.
+ * @param $cache_id
+ * The ID of the filtered text in {cache_filter}, if $cache is TRUE.
+ *
+ * @return
+ * The filtered text.
+ */
+function hook_filter_FILTER_process($text, $filter, $format, $langcode, $cache, $cache_id) {
+ $text = preg_replace('|\[codefilter_code\](.+?)\[/codefilter_code\]|se', "<pre>$1</pre>", $text);
+
+ return $text;
+}
+
+/**
+ * Tips callback for hook_filter_info().
+ *
+ * Note: This is not really a hook. The function name is manually specified via
+ * 'tips callback' in hook_filter_info(), with this recommended callback
+ * name pattern. It is called from _filter_tips().
+ *
+ * A filter's tips should be informative and to the point. Short tips are
+ * preferably one-liners.
+ *
+ * @param $filter
+ * An object representing the filter.
+ * @param $format
+ * An object representing the text format the filter is contained in.
+ * @param $long
+ * Whether this callback should return a short tip to display in a form
+ * (FALSE), or whether a more elaborate filter tips should be returned for
+ * theme_filter_tips() (TRUE).
+ *
+ * @return
+ * Translated text to display as a tip.
+ */
+function hook_filter_FILTER_tips($filter, $format, $long) {
+ if ($long) {
+ return t('Lines and paragraphs are automatically recognized. The &lt;br /&gt; line break, &lt;p&gt; paragraph and &lt;/p&gt; close paragraph tags are inserted automatically. If paragraphs are not recognized simply add a couple blank lines.');
+ }
+ else {
+ return t('Lines and paragraphs break automatically.');
+ }
+}
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Perform actions when a new text format has been created.
+ *
+ * @param $format
+ * The format object of the format being updated.
+ *
+ * @see hook_filter_format_update()
+ * @see hook_filter_format_disable()
+ */
+function hook_filter_format_insert($format) {
+ mymodule_cache_rebuild();
+}
+
+/**
+ * Perform actions when a text format has been updated.
+ *
+ * This hook allows modules to act when a text format has been updated in any
+ * way. For example, when filters have been reconfigured, disabled, or
+ * re-arranged in the text format.
+ *
+ * @param $format
+ * The format object of the format being updated.
+ *
+ * @see hook_filter_format_insert()
+ * @see hook_filter_format_disable()
+ */
+function hook_filter_format_update($format) {
+ mymodule_cache_rebuild();
+}
+
+/**
+ * Perform actions when a text format has been disabled.
+ *
+ * @param $format
+ * The format object of the format being disabled.
+ *
+ * @see hook_filter_format_insert()
+ * @see hook_filter_format_update()
+ */
+function hook_filter_format_disable($format) {
+ mymodule_cache_rebuild();
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/filter/filter.css b/core/modules/filter/filter.css
new file mode 100644
index 000000000000..f7317338176b
--- /dev/null
+++ b/core/modules/filter/filter.css
@@ -0,0 +1,53 @@
+
+.text-format-wrapper .form-item {
+ margin-bottom: 0;
+}
+.filter-wrapper {
+ border-top: 0;
+ margin: 0;
+ padding: 1.5em 0 1.5em;
+}
+.filter-wrapper .form-item {
+ float: left;
+ padding: 0 0 0.5em 1.5em;
+}
+.filter-wrapper .form-item label {
+ display: inline;
+}
+.filter-help {
+ float: right;
+ padding: 0 1.5em 0.5em;
+}
+.filter-help p {
+ margin: 0;
+}
+.filter-help a {
+ background: transparent url(../../misc/help.png) right center no-repeat;
+ padding: 0 20px;
+}
+.filter-guidelines {
+ clear: left;
+ padding: 0 1.5em;
+}
+.text-format-wrapper .description {
+ margin-top: 0.5em;
+}
+
+#filter-order tr .form-item {
+ padding: 0.5em 0 0 3em;
+ white-space: normal;
+}
+#filter-order tr .form-type-checkbox .description {
+ padding: 0 0 0 2.5em;
+}
+input#edit-filters-filter-html-settings-allowed-html {
+ width: 100%;
+}
+
+.tips {
+ margin-top: 0;
+ margin-bottom: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ font-size: 0.9em;
+}
diff --git a/core/modules/filter/filter.info b/core/modules/filter/filter.info
new file mode 100644
index 000000000000..022e9ed1702e
--- /dev/null
+++ b/core/modules/filter/filter.info
@@ -0,0 +1,8 @@
+name = Filter
+description = Filters content in preparation for display.
+package = Core
+version = VERSION
+core = 8.x
+files[] = filter.test
+required = TRUE
+configure = admin/config/content/formats
diff --git a/core/modules/filter/filter.install b/core/modules/filter/filter.install
new file mode 100644
index 000000000000..da9ecb84a80b
--- /dev/null
+++ b/core/modules/filter/filter.install
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the filter module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function filter_schema() {
+ $schema['filter'] = array(
+ 'description' => 'Table that maps filters (HTML corrector) to text formats (Filtered HTML).',
+ 'fields' => array(
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'description' => 'Foreign key: The {filter_format}.format to which this filter is assigned.',
+ ),
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The origin module of the filter.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Name of the filter being referenced.',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Weight of filter within format.',
+ ),
+ 'status' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Filter enabled status. (1 = enabled, 0 = disabled)',
+ ),
+ 'settings' => array(
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'serialize' => TRUE,
+ 'description' => 'A serialized array of name value pairs that store the filter settings for the specific format.',
+ ),
+ ),
+ 'primary key' => array('format', 'name'),
+ 'indexes' => array(
+ 'list' => array('weight', 'module', 'name'),
+ ),
+ );
+ $schema['filter_format'] = array(
+ 'description' => 'Stores text formats: custom groupings of filters, such as Filtered HTML.',
+ 'fields' => array(
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique machine name of the format.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Name of the text format (Filtered HTML).',
+ 'translatable' => TRUE,
+ ),
+ 'cache' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Flag to indicate whether format is cacheable. (1 = cacheable, 0 = not cacheable)',
+ ),
+ 'status' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 1,
+ 'size' => 'tiny',
+ 'description' => 'The status of the text format. (1 = enabled, 0 = disabled)',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Weight of text format to use when listing.',
+ ),
+ ),
+ 'primary key' => array('format'),
+ 'unique keys' => array(
+ 'name' => array('name'),
+ ),
+ 'indexes' => array(
+ 'status_weight' => array('status', 'weight'),
+ ),
+ );
+
+ $schema['cache_filter'] = drupal_get_schema_unprocessed('system', 'cache');
+ $schema['cache_filter']['description'] = 'Cache table for the Filter module to store already filtered pieces of text, identified by text format and hash of the text.';
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function filter_install() {
+ // All sites require at least one text format (the fallback format) that all
+ // users have access to, so add it here. We initialize it as a simple, safe
+ // plain text format with very basic formatting, but it can be modified by
+ // installation profiles to have other properties.
+ $plain_text_format = array(
+ 'format' => 'plain_text',
+ 'name' => 'Plain text',
+ 'weight' => 10,
+ 'filters' => array(
+ // Escape all HTML.
+ 'filter_html_escape' => array(
+ 'weight' => 0,
+ 'status' => 1,
+ ),
+ // URL filter.
+ 'filter_url' => array(
+ 'weight' => 1,
+ 'status' => 1,
+ ),
+ // Line break filter.
+ 'filter_autop' => array(
+ 'weight' => 2,
+ 'status' => 1,
+ ),
+ ),
+ );
+ $plain_text_format = (object) $plain_text_format;
+ filter_format_save($plain_text_format);
+
+ // Set the fallback format to plain text.
+ variable_set('filter_fallback_format', $plain_text_format->format);
+}
diff --git a/core/modules/filter/filter.js b/core/modules/filter/filter.js
new file mode 100644
index 000000000000..94e01c1af7e3
--- /dev/null
+++ b/core/modules/filter/filter.js
@@ -0,0 +1,20 @@
+(function ($) {
+
+/**
+ * Automatically display the guidelines of the selected text format.
+ */
+Drupal.behaviors.filterGuidelines = {
+ attach: function (context) {
+ $('.filter-guidelines', context).once('filter-guidelines')
+ .find(':header').hide()
+ .parents('.filter-wrapper').find('select.filter-list')
+ .bind('change', function () {
+ $(this).parents('.filter-wrapper')
+ .find('.filter-guidelines-item').hide()
+ .siblings('.filter-guidelines-' + this.value).show();
+ })
+ .change();
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
new file mode 100644
index 000000000000..2ad725ada082
--- /dev/null
+++ b/core/modules/filter/filter.module
@@ -0,0 +1,1682 @@
+<?php
+
+/**
+ * @file
+ * Framework for handling filtering of content.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function filter_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#filter':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Filter module allows administrators to configure text formats. A text format defines the HTML tags, codes, and other input allowed in content and comments, and is a key feature in guarding against potentially damaging input from malicious users. For more information, see the online handbook entry for <a href="@filter">Filter module</a>.', array('@filter' => 'http://drupal.org/handbook/modules/filter/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Configuring text formats') . '</dt>';
+ $output .= '<dd>' . t('Configure text formats on the <a href="@formats">Text formats page</a>. <strong>Improper text format configuration is a security risk</strong>. To ensure security, untrusted users should only have access to text formats that restrict them to either plain text or a safe set of HTML tags, since certain HTML tags can allow embedding malicious links or scripts in text. More trusted registered users may be granted permission to use less restrictive text formats in order to create rich content.', array('@formats' => url('admin/config/content/formats'))) . '</dd>';
+ $output .= '<dt>' . t('Applying filters to text') . '</dt>';
+ $output .= '<dd>' . t('Each text format uses filters to manipulate text, and most formats apply several different filters to text in a specific order. Each filter is designed for a specific purpose, and generally either adds, removes, or transforms elements within user-entered text before it is displayed. A filter does not change the actual content, but instead, modifies it temporarily before it is displayed. One filter may remove unapproved HTML tags, while another automatically adds HTML to make URLs display as clickable links.') . '</dd>';
+ $output .= '<dt>' . t('Defining text formats') . '</dt>';
+ $output .= '<dd>' . t('One format is included by default: <em>Plain text</em> (which removes all HTML tags). Additional formats may be created by your installation profile when you install Drupal, and more can be created by an administrator on the <a href="@text-formats">Text formats page</a>.', array('@text-formats' => url('admin/config/content/formats'))) . '</dd>';
+ $output .= '<dt>' . t('Choosing a text format') . '</dt>';
+ $output .= '<dd>' . t('Users with access to more than one text format can use the <em>Text format</em> fieldset to choose between available text formats when creating or editing multi-line content. Administrators can define the text formats available to each user role, and control the order of formats listed in the <em>Text format</em> fieldset on the <a href="@text-formats">Text formats page</a>.', array('@text-formats' => url('admin/config/content/formats'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/config/content/formats':
+ $output = '<p>' . t('Text formats define the HTML tags, code, and other formatting that can be used when entering text. <strong>Improper text format configuration is a security risk</strong>. Learn more on the <a href="@filterhelp">Filter module help page</a>.', array('@filterhelp' => url('admin/help/filter'))) . '</p>';
+ $output .= '<p>' . t('Text formats are presented on content editing pages in the order defined on this page. The first format available to a user will be selected by default.') . '</p>';
+ return $output;
+
+ case 'admin/config/content/formats/%':
+ $output = '<p>' . t('A text format contains filters that change the user input, for example stripping out malicious HTML or making URLs clickable. Filters are executed from top to bottom and the order is important, since one filter may prevent another filter from doing its job. For example, when URLs are converted into links before disallowed HTML tags are removed, all links may be removed. When this happens, the order of filters may need to be re-arranged.') . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function filter_theme() {
+ return array(
+ 'filter_admin_overview' => array(
+ 'render element' => 'form',
+ 'file' => 'filter.admin.inc',
+ ),
+ 'filter_admin_format_filter_order' => array(
+ 'render element' => 'element',
+ 'file' => 'filter.admin.inc',
+ ),
+ 'filter_tips' => array(
+ 'variables' => array('tips' => NULL, 'long' => FALSE),
+ 'file' => 'filter.pages.inc',
+ ),
+ 'text_format_wrapper' => array(
+ 'render element' => 'element',
+ ),
+ 'filter_tips_more_info' => array(
+ 'variables' => array(),
+ ),
+ 'filter_guidelines' => array(
+ 'variables' => array('format' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_element_info().
+ *
+ * @see filter_process_format()
+ */
+function filter_element_info() {
+ $type['text_format'] = array(
+ '#process' => array('filter_process_format'),
+ '#base_type' => 'textarea',
+ '#theme_wrappers' => array('text_format_wrapper'),
+ );
+ return $type;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function filter_menu() {
+ $items['filter/tips'] = array(
+ 'title' => 'Compose tips',
+ 'page callback' => 'filter_tips_long',
+ 'access callback' => TRUE,
+ 'type' => MENU_SUGGESTED_ITEM,
+ 'file' => 'filter.pages.inc',
+ );
+ $items['admin/config/content/formats'] = array(
+ 'title' => 'Text formats',
+ 'description' => 'Configure how content input by users is filtered, including allowed HTML tags. Also allows enabling of module-provided filters.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('filter_admin_overview'),
+ 'access arguments' => array('administer filters'),
+ 'file' => 'filter.admin.inc',
+ );
+ $items['admin/config/content/formats/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/config/content/formats/add'] = array(
+ 'title' => 'Add text format',
+ 'page callback' => 'filter_admin_format_page',
+ 'access arguments' => array('administer filters'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'weight' => 1,
+ 'file' => 'filter.admin.inc',
+ );
+ $items['admin/config/content/formats/%filter_format'] = array(
+ 'title callback' => 'filter_admin_format_title',
+ 'title arguments' => array(4),
+ 'page callback' => 'filter_admin_format_page',
+ 'page arguments' => array(4),
+ 'access arguments' => array('administer filters'),
+ 'file' => 'filter.admin.inc',
+ );
+ $items['admin/config/content/formats/%filter_format/disable'] = array(
+ 'title' => 'Disable text format',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('filter_admin_disable', 4),
+ 'access callback' => '_filter_disable_format_access',
+ 'access arguments' => array(4),
+ 'file' => 'filter.admin.inc',
+ );
+ return $items;
+}
+
+/**
+ * Access callback for deleting text formats.
+ *
+ * @param $format
+ * A text format object.
+ * @return
+ * TRUE if the text format can be disabled by the current user, FALSE
+ * otherwise.
+ */
+function _filter_disable_format_access($format) {
+ // The fallback format can never be disabled.
+ return user_access('administer filters') && ($format->format != filter_fallback_format());
+}
+
+/**
+ * Load a text format object from the database.
+ *
+ * @param $format_id
+ * The format ID.
+ *
+ * @return
+ * A fully-populated text format object, if the requested format exists and
+ * is enabled. If the format does not exist, or exists in the database but
+ * has been marked as disabled, FALSE is returned.
+ *
+ * @see filter_format_exists()
+ */
+function filter_format_load($format_id) {
+ $formats = filter_formats();
+ return isset($formats[$format_id]) ? $formats[$format_id] : FALSE;
+}
+
+/**
+ * Save a text format object to the database.
+ *
+ * @param $format
+ * A format object using the properties:
+ * - 'format': A machine-readable name representing the ID of the text format
+ * to save. If this corresponds to an existing text format, that format
+ * will be updated; otherwise, a new format will be created.
+ * - 'name': The title of the text format.
+ * - 'status': (optional) An integer indicating whether the text format is
+ * enabled (1) or not (0). Defaults to 1.
+ * - 'weight': (optional) The weight of the text format, which controls its
+ * placement in text format lists. If omitted, the weight is set to 0.
+ * - 'filters': (optional) An associative, multi-dimensional array of filters
+ * assigned to the text format, keyed by the name of each filter and using
+ * the properties:
+ * - 'weight': (optional) The weight of the filter in the text format. If
+ * omitted, either the currently stored weight is retained (if there is
+ * one), or the filter is assigned a weight of 10, which will usually
+ * put it at the bottom of the list.
+ * - 'status': (optional) A boolean indicating whether the filter is
+ * enabled in the text format. If omitted, the filter will be disabled.
+ * - 'settings': (optional) An array of configured settings for the filter.
+ * See hook_filter_info() for details.
+ */
+function filter_format_save($format) {
+ $format->name = trim($format->name);
+ $format->cache = _filter_format_is_cacheable($format);
+ if (!isset($format->status)) {
+ $format->status = 1;
+ }
+ if (!isset($format->weight)) {
+ $format->weight = 0;
+ }
+
+ // Insert or update the text format.
+ $return = db_merge('filter_format')
+ ->key(array('format' => $format->format))
+ ->fields(array(
+ 'name' => $format->name,
+ 'cache' => (int) $format->cache,
+ 'status' => (int) $format->status,
+ 'weight' => (int) $format->weight,
+ ))
+ ->execute();
+
+ // Programmatic saves may not contain any filters.
+ if (!isset($format->filters)) {
+ $format->filters = array();
+ }
+ $filter_info = filter_get_filters();
+ foreach ($filter_info as $name => $filter) {
+ // Add new filters without weight to the bottom.
+ if (!isset($format->filters[$name]['weight'])) {
+ $format->filters[$name]['weight'] = 10;
+ }
+ $format->filters[$name]['status'] = isset($format->filters[$name]['status']) ? $format->filters[$name]['status'] : 0;
+ $format->filters[$name]['module'] = $filter['module'];
+
+ // If settings were passed, only ensure default settings.
+ if (isset($format->filters[$name]['settings'])) {
+ if (isset($filter['default settings'])) {
+ $format->filters[$name]['settings'] = array_merge($filter['default settings'], $format->filters[$name]['settings']);
+ }
+ }
+ // Otherwise, use default settings or fall back to an empty array.
+ else {
+ $format->filters[$name]['settings'] = isset($filter['default settings']) ? $filter['default settings'] : array();
+ }
+
+ $fields = array();
+ $fields['weight'] = $format->filters[$name]['weight'];
+ $fields['status'] = $format->filters[$name]['status'];
+ $fields['module'] = $format->filters[$name]['module'];
+ $fields['settings'] = serialize($format->filters[$name]['settings']);
+
+ db_merge('filter')
+ ->key(array(
+ 'format' => $format->format,
+ 'name' => $name,
+ ))
+ ->fields($fields)
+ ->execute();
+ }
+
+ if ($return == SAVED_NEW) {
+ module_invoke_all('filter_format_insert', $format);
+ }
+ else {
+ module_invoke_all('filter_format_update', $format);
+ // Explicitly indicate that the format was updated. We need to do this
+ // since if the filters were updated but the format object itself was not,
+ // the merge query above would not return an indication that anything had
+ // changed.
+ $return = SAVED_UPDATED;
+
+ // Clear the filter cache whenever a text format is updated.
+ cache('filter')->deletePrefix($format->format . ':');
+ }
+
+ filter_formats_reset();
+
+ return $return;
+}
+
+/**
+ * Disable a text format.
+ *
+ * There is no core facility to re-enable a disabled format. It is not deleted
+ * to keep information for contrib and to make sure the format ID is never
+ * reused. As there might be content using the disabled format, this would lead
+ * to data corruption.
+ *
+ * @param $format
+ * The text format object to be disabled.
+ */
+function filter_format_disable($format) {
+ db_update('filter_format')
+ ->fields(array('status' => 0))
+ ->condition('format', $format->format)
+ ->execute();
+
+ // Allow modules to react on text format deletion.
+ module_invoke_all('filter_format_disable', $format);
+
+ // Clear the filter cache whenever a text format is disabled.
+ filter_formats_reset();
+ cache('filter')->deletePrefix($format->format . ':');
+}
+
+/**
+ * Determines if a text format exists.
+ *
+ * @param $format_id
+ * The ID of the text format to check.
+ *
+ * @return
+ * TRUE if the text format exists, FALSE otherwise. Note that for disabled
+ * formats filter_format_exists() will return TRUE while filter_format_load()
+ * will return FALSE.
+ *
+ * @see filter_format_load()
+ */
+function filter_format_exists($format_id) {
+ return (bool) db_query_range('SELECT 1 FROM {filter_format} WHERE format = :format', 0, 1, array(':format' => $format_id))->fetchField();
+}
+
+/**
+ * Display a text format form title.
+ */
+function filter_admin_format_title($format) {
+ return $format->name;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function filter_permission() {
+ $perms['administer filters'] = array(
+ 'title' => t('Administer text formats and filters'),
+ 'restrict access' => TRUE,
+ );
+
+ // Generate permissions for each text format. Warn the administrator that any
+ // of them are potentially unsafe.
+ foreach (filter_formats() as $format) {
+ $permission = filter_permission_name($format);
+ if (!empty($permission)) {
+ // Only link to the text format configuration page if the user who is
+ // viewing this will have access to that page.
+ $format_name_replacement = user_access('administer filters') ? l($format->name, 'admin/config/content/formats/' . $format->format) : drupal_placeholder($format->name);
+ $perms[$permission] = array(
+ 'title' => t("Use the !text_format text format", array('!text_format' => $format_name_replacement,)),
+ 'description' => drupal_placeholder(t('Warning: This permission may have security implications depending on how the text format is configured.')),
+ );
+ }
+ }
+ return $perms;
+}
+
+/**
+ * Returns the machine-readable permission name for a provided text format.
+ *
+ * @param $format
+ * An object representing a text format.
+ * @return
+ * The machine-readable permission name, or FALSE if the provided text format
+ * is malformed or is the fallback format (which is available to all users).
+ */
+function filter_permission_name($format) {
+ if (isset($format->format) && $format->format != filter_fallback_format()) {
+ return 'use text format ' . $format->format;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function filter_modules_enabled($modules) {
+ // Reset the static cache of module-provided filters, in case any of the
+ // newly enabled modules defines a new filter or alters existing ones.
+ drupal_static_reset('filter_get_filters');
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function filter_modules_disabled($modules) {
+ // Reset the static cache of module-provided filters, in case any of the
+ // newly disabled modules defined or altered any filters.
+ drupal_static_reset('filter_get_filters');
+}
+
+/**
+ * Retrieve a list of text formats, ordered by weight.
+ *
+ * @param $account
+ * (optional) If provided, only those formats that are allowed for this user
+ * account will be returned. All formats will be returned otherwise.
+ * @return
+ * An array of text format objects, keyed by the format ID and ordered by
+ * weight.
+ *
+ * @see filter_formats_reset()
+ */
+function filter_formats($account = NULL) {
+ global $language;
+ $formats = &drupal_static(__FUNCTION__, array());
+
+ // All available formats are cached for performance.
+ if (!isset($formats['all'])) {
+ if ($cache = cache()->get("filter_formats:{$language->language}")) {
+ $formats['all'] = $cache->data;
+ }
+ else {
+ $formats['all'] = db_select('filter_format', 'ff')
+ ->addTag('translatable')
+ ->fields('ff')
+ ->condition('status', 1)
+ ->orderBy('weight')
+ ->execute()
+ ->fetchAllAssoc('format');
+
+ cache()->set("filter_formats:{$language->language}", $formats['all']);
+ }
+ }
+
+ // Build a list of user-specific formats.
+ if (isset($account) && !isset($formats['user'][$account->uid])) {
+ $formats['user'][$account->uid] = array();
+ foreach ($formats['all'] as $format) {
+ if (filter_access($format, $account)) {
+ $formats['user'][$account->uid][$format->format] = $format;
+ }
+ }
+ }
+
+ return isset($account) ? $formats['user'][$account->uid] : $formats['all'];
+}
+
+/**
+ * Resets text format caches.
+ *
+ * @see filter_formats()
+ */
+function filter_formats_reset() {
+ cache()->deletePrefix('filter_formats');
+ cache()->deletePrefix('filter_list_format');
+ drupal_static_reset('filter_list_format');
+ drupal_static_reset('filter_formats');
+}
+
+/**
+ * Retrieves a list of roles that are allowed to use a given text format.
+ *
+ * @param $format
+ * An object representing the text format.
+ * @return
+ * An array of role names, keyed by role ID.
+ */
+function filter_get_roles_by_format($format) {
+ // Handle the fallback format upfront (all roles have access to this format).
+ if ($format->format == filter_fallback_format()) {
+ return user_roles();
+ }
+ // Do not list any roles if the permission does not exist.
+ $permission = filter_permission_name($format);
+ return !empty($permission) ? user_roles(FALSE, $permission) : array();
+}
+
+/**
+ * Retrieves a list of text formats that are allowed for a given role.
+ *
+ * @param $rid
+ * The user role ID to retrieve text formats for.
+ * @return
+ * An array of text format objects that are allowed for the role, keyed by
+ * the text format ID and ordered by weight.
+ */
+function filter_get_formats_by_role($rid) {
+ $formats = array();
+ foreach (filter_formats() as $format) {
+ $roles = filter_get_roles_by_format($format);
+ if (isset($roles[$rid])) {
+ $formats[$format->format] = $format;
+ }
+ }
+ return $formats;
+}
+
+/**
+ * Returns the ID of the default text format for a particular user.
+ *
+ * The default text format is the first available format that the user is
+ * allowed to access, when the formats are ordered by weight. It should
+ * generally be used as a default choice when presenting the user with a list
+ * of possible text formats (for example, in a node creation form).
+ *
+ * Conversely, when existing content that does not have an assigned text format
+ * needs to be filtered for display, the default text format is the wrong
+ * choice, because it is not guaranteed to be consistent from user to user, and
+ * some trusted users may have an unsafe text format set by default, which
+ * should not be used on text of unknown origin. Instead, the fallback format
+ * returned by filter_fallback_format() should be used, since that is intended
+ * to be a safe, consistent format that is always available to all users.
+ *
+ * @param $account
+ * (optional) The user account to check. Defaults to the currently logged-in
+ * user.
+ * @return
+ * The ID of the user's default text format.
+ *
+ * @see filter_fallback_format()
+ */
+function filter_default_format($account = NULL) {
+ global $user;
+ if (!isset($account)) {
+ $account = $user;
+ }
+ // Get a list of formats for this user, ordered by weight. The first one
+ // available is the user's default format.
+ $formats = filter_formats($account);
+ $format = reset($formats);
+ return $format->format;
+}
+
+/**
+ * Returns the ID of the fallback text format that all users have access to.
+ *
+ * The fallback text format is a regular text format in every respect, except
+ * it does not participate in the filter permission system and cannot be
+ * disabled. It needs to exist because any user who has permission to create
+ * formatted content must always have at least one text format they can use.
+ *
+ * Because the fallback format is available to all users, it should always be
+ * configured securely. For example, when the Filter module is installed, this
+ * format is initialized to output plain text. Installation profiles and site
+ * administrators have the freedom to configure it further.
+ *
+ * Note that the fallback format is completely distinct from the default
+ * format, which differs per user and is simply the first format which that
+ * user has access to. The default and fallback formats are only guaranteed to
+ * be the same for users who do not have access to any other format; otherwise,
+ * the fallback format's weight determines its placement with respect to the
+ * user's other formats.
+ *
+ * Any modules implementing a format deletion functionality must not delete
+ * this format.
+ *
+ * @see hook_filter_format_disable()
+ * @see filter_default_format()
+ */
+function filter_fallback_format() {
+ // This variable is automatically set in the database for all installations
+ // of Drupal. In the event that it gets disabled or deleted somehow, there
+ // is no safe default to return, since we do not want to risk making an
+ // existing (and potentially unsafe) text format on the site automatically
+ // available to all users. Returning NULL at least guarantees that this
+ // cannot happen.
+ return variable_get('filter_fallback_format');
+}
+
+/**
+ * Returns the title of the fallback text format.
+ */
+function filter_fallback_format_title() {
+ $fallback_format = filter_format_load(filter_fallback_format());
+ return filter_admin_format_title($fallback_format);
+}
+
+/**
+ * Return a list of all filters provided by modules.
+ */
+function filter_get_filters() {
+ $filters = &drupal_static(__FUNCTION__, array());
+
+ if (empty($filters)) {
+ foreach (module_implements('filter_info') as $module) {
+ $info = module_invoke($module, 'filter_info');
+ if (isset($info) && is_array($info)) {
+ // Assign the name of the module implementing the filters and ensure
+ // default values.
+ foreach (array_keys($info) as $name) {
+ $info[$name]['module'] = $module;
+ $info[$name] += array(
+ 'description' => '',
+ 'weight' => 0,
+ );
+ }
+ $filters = array_merge($filters, $info);
+ }
+ }
+ // Allow modules to alter filter definitions.
+ drupal_alter('filter_info', $filters);
+
+ uasort($filters, '_filter_list_cmp');
+ }
+
+ return $filters;
+}
+
+/**
+ * Helper function for sorting the filter list by filter name.
+ */
+function _filter_list_cmp($a, $b) {
+ return strcmp($a['title'], $b['title']);
+}
+
+/**
+ * Check if text in a certain text format is allowed to be cached.
+ *
+ * This function can be used to check whether the result of the filtering
+ * process can be cached. A text format may allow caching depending on the
+ * filters enabled.
+ *
+ * @param $format_id
+ * The text format ID to check.
+ * @return
+ * TRUE if the given text format allows caching, FALSE otherwise.
+ */
+function filter_format_allowcache($format_id) {
+ $format = filter_format_load($format_id);
+ return !empty($format->cache);
+}
+
+/**
+ * Helper function to determine whether the output of a given text format can be cached.
+ *
+ * The output of a given text format can be cached when all enabled filters in
+ * the text format allow caching.
+ *
+ * @param $format
+ * The text format object to check.
+ * @return
+ * TRUE if all the filters enabled in the given text format allow caching,
+ * FALSE otherwise.
+ *
+ * @see filter_format_save()
+ */
+function _filter_format_is_cacheable($format) {
+ if (empty($format->filters)) {
+ return TRUE;
+ }
+ $filter_info = filter_get_filters();
+ foreach ($format->filters as $name => $filter) {
+ // By default, 'cache' is TRUE for all filters unless specified otherwise.
+ if (!empty($filter['status']) && isset($filter_info[$name]['cache']) && !$filter_info[$name]['cache']) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Retrieve a list of filters for a given text format.
+ *
+ * Note that this function returns all associated filters regardless of whether
+ * they are enabled or disabled. All functions working with the filter
+ * information outside of filter administration should test for $filter->status
+ * before performing actions with the filter.
+ *
+ * @param $format_id
+ * The format ID to retrieve filters for.
+ *
+ * @return
+ * An array of filter objects associated to the given text format, keyed by
+ * filter name.
+ */
+function filter_list_format($format_id) {
+ $filters = &drupal_static(__FUNCTION__, array());
+ $filter_info = filter_get_filters();
+
+ if (!isset($filters['all'])) {
+ if ($cache = cache()->get('filter_list_format')) {
+ $filters['all'] = $cache->data;
+ }
+ else {
+ $result = db_query('SELECT * FROM {filter} ORDER BY weight, module, name');
+ foreach ($result as $record) {
+ $filters['all'][$record->format][$record->name] = $record;
+ }
+ cache()->set('filter_list_format', $filters['all']);
+ }
+ }
+
+ if (!isset($filters[$format_id])) {
+ $format_filters = array();
+ $filter_map = isset($filters['all'][$format_id]) ? $filters['all'][$format_id] : array();
+ foreach ($filter_map as $name => $filter) {
+ if (isset($filter_info[$name])) {
+ $filter->title = $filter_info[$name]['title'];
+ // Unpack stored filter settings.
+ $filter->settings = (isset($filter->settings) ? unserialize($filter->settings) : array());
+ // Merge in default settings.
+ if (isset($filter_info[$name]['default settings'])) {
+ $filter->settings += $filter_info[$name]['default settings'];
+ }
+
+ $format_filters[$name] = $filter;
+ }
+ }
+ $filters[$format_id] = $format_filters;
+ }
+
+ return isset($filters[$format_id]) ? $filters[$format_id] : array();
+}
+
+/**
+ * Run all the enabled filters on a piece of text.
+ *
+ * Note: Because filters can inject JavaScript or execute PHP code, security is
+ * vital here. When a user supplies a text format, you should validate it using
+ * filter_access() before accepting/using it. This is normally done in the
+ * validation stage of the Form API. You should for example never make a preview
+ * of content in a disallowed format.
+ *
+ * @param $text
+ * The text to be filtered.
+ * @param $format_id
+ * The format id of the text to be filtered. If no format is assigned, the
+ * fallback format will be used.
+ * @param $langcode
+ * Optional: the language code of the text to be filtered, e.g. 'en' for
+ * English. This allows filters to be language aware so language specific
+ * text replacement can be implemented.
+ * @param $cache
+ * Boolean whether to cache the filtered output in the {cache_filter} table.
+ * The caller may set this to FALSE when the output is already cached
+ * elsewhere to avoid duplicate cache lookups and storage.
+ *
+ * @ingroup sanitization
+ */
+function check_markup($text, $format_id = NULL, $langcode = '', $cache = FALSE) {
+ if (!isset($format_id)) {
+ $format_id = filter_fallback_format();
+ }
+ // If the requested text format does not exist, the text cannot be filtered.
+ if (!$format = filter_format_load($format_id)) {
+ watchdog('filter', 'Missing text format: %format.', array('%format' => $format_id), WATCHDOG_ALERT);
+ return '';
+ }
+
+ // Check for a cached version of this piece of text.
+ $cache = $cache && !empty($format->cache);
+ $cache_id = '';
+ if ($cache) {
+ $cache_id = $format->format . ':' . $langcode . ':' . hash('sha256', $text);
+ if ($cached = cache('filter')->get($cache_id)) {
+ return $cached->data;
+ }
+ }
+
+ // Convert all Windows and Mac newlines to a single newline, so filters only
+ // need to deal with one possibility.
+ $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+ // Get a complete list of filters, ordered properly.
+ $filters = filter_list_format($format->format);
+ $filter_info = filter_get_filters();
+
+ // Give filters the chance to escape HTML-like data such as code or formulas.
+ foreach ($filters as $name => $filter) {
+ if ($filter->status && isset($filter_info[$name]['prepare callback']) && function_exists($filter_info[$name]['prepare callback'])) {
+ $function = $filter_info[$name]['prepare callback'];
+ $text = $function($text, $filter, $format, $langcode, $cache, $cache_id);
+ }
+ }
+
+ // Perform filtering.
+ foreach ($filters as $name => $filter) {
+ if ($filter->status && isset($filter_info[$name]['process callback']) && function_exists($filter_info[$name]['process callback'])) {
+ $function = $filter_info[$name]['process callback'];
+ $text = $function($text, $filter, $format, $langcode, $cache, $cache_id);
+ }
+ }
+
+ // Cache the filtered text. This cache is infinitely valid. It becomes
+ // obsolete when $text changes (which leads to a new $cache_id). It is
+ // automatically flushed when the text format is updated.
+ // @see filter_format_save()
+ if ($cache) {
+ cache('filter')->set($cache_id, $text);
+ }
+
+ return $text;
+}
+
+/**
+ * Expands an element into a base element with text format selector attached.
+ *
+ * The form element will be expanded into two separate form elements, one
+ * holding the original element, and the other holding the text format selector:
+ * - value: Holds the original element, having its #type changed to the value of
+ * #base_type or 'textarea' by default.
+ * - format: Holds the text format fieldset and the text format selection, using
+ * the text format id specified in #format or the user's default format by
+ * default, if NULL.
+ *
+ * The resulting value for the element will be an array holding the value and the
+ * format. For example, the value for the body element will be:
+ * @code
+ * $form_state['values']['body']['value'] = 'foo';
+ * $form_state['values']['body']['format'] = 'foo';
+ * @endcode
+ *
+ * @param $element
+ * The form element to process. Properties used:
+ * - #base_type: The form element #type to use for the 'value' element.
+ * 'textarea' by default.
+ * - #format: (optional) The text format id to preselect. If NULL or not set,
+ * the default format for the current user will be used.
+ *
+ * @return
+ * The expanded element.
+ */
+function filter_process_format($element) {
+ global $user;
+
+ // Ensure that children appear as subkeys of this element.
+ $element['#tree'] = TRUE;
+ $blacklist = array(
+ // Make form_builder() regenerate child properties.
+ '#parents',
+ '#id',
+ '#name',
+ // Do not copy this #process function to prevent form_builder() from
+ // recursing infinitely.
+ '#process',
+ // Description is handled by theme_text_format_wrapper().
+ '#description',
+ // Ensure proper ordering of children.
+ '#weight',
+ // Properties already processed for the parent element.
+ '#prefix',
+ '#suffix',
+ '#attached',
+ '#processed',
+ '#theme_wrappers',
+ );
+ // Move this element into sub-element 'value'.
+ unset($element['value']);
+ foreach (element_properties($element) as $key) {
+ if (!in_array($key, $blacklist)) {
+ $element['value'][$key] = $element[$key];
+ }
+ }
+
+ $element['value']['#type'] = $element['#base_type'];
+ $element['value'] += element_info($element['#base_type']);
+
+ // Turn original element into a text format wrapper.
+ $path = drupal_get_path('module', 'filter');
+ $element['#attached']['js'][] = $path . '/filter.js';
+ $element['#attached']['css'][] = $path . '/filter.css';
+
+ // Setup child container for the text format widget.
+ $element['format'] = array(
+ '#type' => 'fieldset',
+ '#attributes' => array('class' => array('filter-wrapper')),
+ );
+
+ // Prepare text format guidelines.
+ $element['format']['guidelines'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('filter-guidelines')),
+ '#weight' => 20,
+ );
+ // Get a list of formats that the current user has access to.
+ $formats = filter_formats($user);
+ foreach ($formats as $format) {
+ $options[$format->format] = $format->name;
+ $element['format']['guidelines'][$format->format] = array(
+ '#theme' => 'filter_guidelines',
+ '#format' => $format,
+ );
+ }
+
+ // Use the default format for this user if none was selected.
+ if (!isset($element['#format'])) {
+ $element['#format'] = filter_default_format($user);
+ }
+
+ $element['format']['format'] = array(
+ '#type' => 'select',
+ '#title' => t('Text format'),
+ '#options' => $options,
+ '#default_value' => $element['#format'],
+ '#access' => count($formats) > 1,
+ '#weight' => 10,
+ '#attributes' => array('class' => array('filter-list')),
+ '#parents' => array_merge($element['#parents'], array('format')),
+ );
+
+ $element['format']['help'] = array(
+ '#type' => 'container',
+ '#theme' => 'filter_tips_more_info',
+ '#attributes' => array('class' => array('filter-help')),
+ '#weight' => 0,
+ );
+
+ $all_formats = filter_formats();
+ $format_exists = isset($all_formats[$element['#format']]);
+ $user_has_access = isset($formats[$element['#format']]);
+ $user_is_admin = user_access('administer filters');
+
+ // If the stored format does not exist, administrators have to assign a new
+ // format.
+ if (!$format_exists && $user_is_admin) {
+ $element['format']['format']['#required'] = TRUE;
+ $element['format']['format']['#default_value'] = NULL;
+ // Force access to the format selector (it may have been denied above if
+ // the user only has access to a single format).
+ $element['format']['format']['#access'] = TRUE;
+ }
+ // Disable this widget, if the user is not allowed to use the stored format,
+ // or if the stored format does not exist. The 'administer filters' permission
+ // only grants access to the filter administration, not to all formats.
+ elseif (!$user_has_access || !$format_exists) {
+ // Overload default values into #value to make them unalterable.
+ $element['value']['#value'] = $element['value']['#default_value'];
+ $element['format']['format']['#value'] = $element['format']['format']['#default_value'];
+
+ // Prepend #pre_render callback to replace field value with user notice
+ // prior to rendering.
+ $element['value'] += array('#pre_render' => array());
+ array_unshift($element['value']['#pre_render'], 'filter_form_access_denied');
+
+ // Cosmetic adjustments.
+ if (isset($element['value']['#rows'])) {
+ $element['value']['#rows'] = 3;
+ }
+ $element['value']['#disabled'] = TRUE;
+ $element['value']['#resizable'] = FALSE;
+
+ // Hide the text format selector and any other child element (such as text
+ // field's summary).
+ foreach (element_children($element) as $key) {
+ if ($key != 'value') {
+ $element[$key]['#access'] = FALSE;
+ }
+ }
+ }
+
+ return $element;
+}
+
+/**
+ * #pre_render callback for #type 'text_format' to hide field value from prying eyes.
+ *
+ * To not break form processing and previews if a user does not have access to a
+ * stored text format, the expanded form elements in filter_process_format() are
+ * forced to take over the stored #default_values for 'value' and 'format'.
+ * However, to prevent the unfiltered, original #value from being displayed to
+ * the user, we replace it with a friendly notice here.
+ *
+ * @see filter_process_format()
+ */
+function filter_form_access_denied($element) {
+ $element['#value'] = t('This field has been disabled because you do not have sufficient permissions to edit it.');
+ return $element;
+}
+
+/**
+ * Returns HTML for a text format-enabled form element.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing #children and #description.
+ *
+ * @ingroup themeable
+ */
+function theme_text_format_wrapper($variables) {
+ $element = $variables['element'];
+ $output = '<div class="text-format-wrapper">';
+ $output .= $element['#children'];
+ if (!empty($element['#description'])) {
+ $output .= '<div class="description">' . $element['#description'] . '</div>';
+ }
+ $output .= "</div>\n";
+
+ return $output;
+}
+
+/**
+ * Checks if a user has access to a particular text format.
+ *
+ * @param $format
+ * An object representing the text format.
+ * @param $account
+ * (optional) The user account to check access for; if omitted, the currently
+ * logged-in user is used.
+ *
+ * @return
+ * Boolean TRUE if the user is allowed to access the given format.
+ */
+function filter_access($format, $account = NULL) {
+ global $user;
+ if (!isset($account)) {
+ $account = $user;
+ }
+ // Handle special cases up front. All users have access to the fallback
+ // format.
+ if ($format->format == filter_fallback_format()) {
+ return TRUE;
+ }
+ // Check the permission if one exists; otherwise, we have a non-existent
+ // format so we return FALSE.
+ $permission = filter_permission_name($format);
+ return !empty($permission) && user_access($permission, $account);
+}
+
+/**
+ * Helper function for fetching filter tips.
+ */
+function _filter_tips($format_id, $long = FALSE) {
+ global $user;
+
+ $formats = filter_formats($user);
+ $filter_info = filter_get_filters();
+
+ $tips = array();
+
+ // If only listing one format, extract it from the $formats array.
+ if ($format_id != -1) {
+ $formats = array($formats[$format_id]);
+ }
+
+ foreach ($formats as $format) {
+ $filters = filter_list_format($format->format);
+ $tips[$format->name] = array();
+ foreach ($filters as $name => $filter) {
+ if ($filter->status && isset($filter_info[$name]['tips callback']) && function_exists($filter_info[$name]['tips callback'])) {
+ $tip = $filter_info[$name]['tips callback']($filter, $format, $long);
+ if (isset($tip)) {
+ $tips[$format->name][$name] = array('tip' => $tip, 'id' => $name);
+ }
+ }
+ }
+ }
+
+ return $tips;
+}
+
+/**
+ * Parses an HTML snippet and returns it as a DOM object.
+ *
+ * This function loads the body part of a partial (X)HTML document
+ * and returns a full DOMDocument object that represents this document.
+ * You can use filter_dom_serialize() to serialize this DOMDocument
+ * back to a XHTML snippet.
+ *
+ * @param $text
+ * The partial (X)HTML snippet to load. Invalid mark-up
+ * will be corrected on import.
+ * @return
+ * A DOMDocument that represents the loaded (X)HTML snippet.
+ */
+function filter_dom_load($text) {
+ $dom_document = new DOMDocument();
+ // Ignore warnings during HTML soup loading.
+ @$dom_document->loadHTML('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /></head><body>' . $text . '</body></html>');
+
+ return $dom_document;
+}
+
+/**
+ * Converts a DOM object back to an HTML snippet.
+ *
+ * The function serializes the body part of a DOMDocument
+ * back to an XHTML snippet.
+ *
+ * The resulting XHTML snippet will be properly formatted
+ * to be compatible with HTML user agents.
+ *
+ * @param $dom_document
+ * A DOMDocument object to serialize, only the tags below
+ * the first <body> node will be converted.
+ * @return
+ * A valid (X)HTML snippet, as a string.
+ */
+function filter_dom_serialize($dom_document) {
+ $body_node = $dom_document->getElementsByTagName('body')->item(0);
+ $body_content = '';
+
+ foreach ($body_node->getElementsByTagName('script') as $node) {
+ filter_dom_serialize_escape_cdata_element($dom_document, $node);
+ }
+
+ foreach ($body_node->getElementsByTagName('style') as $node) {
+ filter_dom_serialize_escape_cdata_element($dom_document, $node, '/*', '*/');
+ }
+
+ foreach ($body_node->childNodes as $child_node) {
+ $body_content .= $dom_document->saveXML($child_node);
+ }
+ return $body_content;
+}
+
+/**
+ * Adds comments around the <!CDATA section in a dom element.
+ *
+ * DOMDocument::loadHTML in filter_dom_load() makes CDATA sections from the
+ * contents of inline script and style tags. This can cause HTML 4 browsers to
+ * throw exceptions.
+ *
+ * This function attempts to solve the problem by creating a DocumentFragment
+ * and imitating the behavior in drupal_get_js(), commenting the CDATA tag.
+ *
+ * @param $dom_document
+ * The DOMDocument containing the $dom_element.
+ * @param $dom_element
+ * The element potentially containing a CDATA node.
+ * @param $comment_start
+ * String to use as a comment start marker to escape the CDATA declaration.
+ * @param $comment_end
+ * String to use as a comment end marker to escape the CDATA declaration.
+ */
+function filter_dom_serialize_escape_cdata_element($dom_document, $dom_element, $comment_start = '//', $comment_end = '') {
+ foreach ($dom_element->childNodes as $node) {
+ if (get_class($node) == 'DOMCdataSection') {
+ // See drupal_get_js(). This code is more or less duplicated there.
+ $embed_prefix = "\n<!--{$comment_start}--><![CDATA[{$comment_start} ><!--{$comment_end}\n";
+ $embed_suffix = "\n{$comment_start}--><!]]>{$comment_end}\n";
+ $fragment = $dom_document->createDocumentFragment();
+ $fragment->appendXML($embed_prefix . $node->data . $embed_suffix);
+ $dom_element->appendChild($fragment);
+ $dom_element->removeChild($node);
+ }
+ }
+}
+
+/**
+ * Returns HTML for a link to the more extensive filter tips.
+ *
+ * @ingroup themeable
+ */
+function theme_filter_tips_more_info() {
+ return '<p>' . l(t('More information about text formats'), 'filter/tips') . '</p>';
+}
+
+/**
+ * Returns HTML for guidelines for a text format.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - format: An object representing a text format.
+ *
+ * @ingroup themeable
+ */
+function theme_filter_guidelines($variables) {
+ $format = $variables['format'];
+ $attributes['class'][] = 'filter-guidelines-item';
+ $attributes['class'][] = 'filter-guidelines-' . $format->format;
+ $output = '<div' . drupal_attributes($attributes) . '>';
+ $output .= '<h3>' . check_plain($format->name) . '</h3>';
+ $output .= theme('filter_tips', array('tips' => _filter_tips($format->format, FALSE)));
+ $output .= '</div>';
+ return $output;
+}
+
+/**
+ * @defgroup standard_filters Standard filters
+ * @{
+ * Filters implemented by the filter.module.
+ */
+
+/**
+ * Implements hook_filter_info().
+ */
+function filter_filter_info() {
+ $filters['filter_html'] = array(
+ 'title' => t('Limit allowed HTML tags'),
+ 'process callback' => '_filter_html',
+ 'settings callback' => '_filter_html_settings',
+ 'default settings' => array(
+ 'allowed_html' => '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>',
+ 'filter_html_help' => 1,
+ 'filter_html_nofollow' => 0,
+ ),
+ 'tips callback' => '_filter_html_tips',
+ 'weight' => -10,
+ );
+ $filters['filter_autop'] = array(
+ 'title' => t('Convert line breaks into HTML (i.e. <code>&lt;br&gt;</code> and <code>&lt;p&gt;</code>)'),
+ 'process callback' => '_filter_autop',
+ 'tips callback' => '_filter_autop_tips',
+ );
+ $filters['filter_url'] = array(
+ 'title' => t('Convert URLs into links'),
+ 'process callback' => '_filter_url',
+ 'settings callback' => '_filter_url_settings',
+ 'default settings' => array(
+ 'filter_url_length' => 72,
+ ),
+ 'tips callback' => '_filter_url_tips',
+ );
+ $filters['filter_htmlcorrector'] = array(
+ 'title' => t('Correct faulty and chopped off HTML'),
+ 'process callback' => '_filter_htmlcorrector',
+ 'weight' => 10,
+ );
+ $filters['filter_html_escape'] = array(
+ 'title' => t('Display any HTML as plain text'),
+ 'process callback' => '_filter_html_escape',
+ 'tips callback' => '_filter_html_escape_tips',
+ 'weight' => -10,
+ );
+ return $filters;
+}
+
+/**
+ * Settings callback for the HTML filter.
+ */
+function _filter_html_settings($form, &$form_state, $filter, $format, $defaults) {
+ $filter->settings += $defaults;
+
+ $settings['allowed_html'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Allowed HTML tags'),
+ '#default_value' => $filter->settings['allowed_html'],
+ '#maxlength' => 1024,
+ '#description' => t('A list of HTML tags that can be used. JavaScript event attributes, JavaScript URLs, and CSS are always stripped.'),
+ );
+ $settings['filter_html_help'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display basic HTML help in long filter tips'),
+ '#default_value' => $filter->settings['filter_html_help'],
+ );
+ $settings['filter_html_nofollow'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Add rel="nofollow" to all links'),
+ '#default_value' => $filter->settings['filter_html_nofollow'],
+ );
+ return $settings;
+}
+
+/**
+ * HTML filter. Provides filtering of input into accepted HTML.
+ */
+function _filter_html($text, $filter) {
+ $allowed_tags = preg_split('/\s+|<|>/', $filter->settings['allowed_html'], -1, PREG_SPLIT_NO_EMPTY);
+ $text = filter_xss($text, $allowed_tags);
+
+ if ($filter->settings['filter_html_nofollow']) {
+ $html_dom = filter_dom_load($text);
+ $links = $html_dom->getElementsByTagName('a');
+ foreach ($links as $link) {
+ $link->setAttribute('rel', 'nofollow');
+ }
+ $text = filter_dom_serialize($html_dom);
+ }
+
+ return trim($text);
+}
+
+/**
+ * Filter tips callback for HTML filter.
+ */
+function _filter_html_tips($filter, $format, $long = FALSE) {
+ global $base_url;
+
+ if (!($allowed_html = $filter->settings['allowed_html'])) {
+ return;
+ }
+ $output = t('Allowed HTML tags: @tags', array('@tags' => $allowed_html));
+ if (!$long) {
+ return $output;
+ }
+
+ $output = '<p>' . $output . '</p>';
+ if (!$filter->settings['filter_html_help']) {
+ return $output;
+ }
+
+ $output .= '<p>' . t('This site allows HTML content. While learning all of HTML may feel intimidating, learning how to use a very small number of the most basic HTML "tags" is very easy. This table provides examples for each tag that is enabled on this site.') . '</p>';
+ $output .= '<p>' . t('For more information see W3C\'s <a href="@html-specifications">HTML Specifications</a> or use your favorite search engine to find other sites that explain HTML.', array('@html-specifications' => 'http://www.w3.org/TR/html/')) . '</p>';
+ $tips = array(
+ 'a' => array(t('Anchors are used to make links to other pages.'), '<a href="' . $base_url . '">' . check_plain(variable_get('site_name', 'Drupal')) . '</a>'),
+ 'br' => array(t('By default line break tags are automatically added, so use this tag to add additional ones. Use of this tag is different because it is not used with an open/close pair like all the others. Use the extra " /" inside the tag to maintain XHTML 1.0 compatibility'), t('Text with <br />line break')),
+ 'p' => array(t('By default paragraph tags are automatically added, so use this tag to add additional ones.'), '<p>' . t('Paragraph one.') . '</p> <p>' . t('Paragraph two.') . '</p>'),
+ 'strong' => array(t('Strong', array(), array('context' => 'Font weight')), '<strong>' . t('Strong', array(), array('context' => 'Font weight')) . '</strong>'),
+ 'em' => array(t('Emphasized'), '<em>' . t('Emphasized') . '</em>'),
+ 'cite' => array(t('Cited'), '<cite>' . t('Cited') . '</cite>'),
+ 'code' => array(t('Coded text used to show programming source code'), '<code>' . t('Coded') . '</code>'),
+ 'b' => array(t('Bolded'), '<b>' . t('Bolded') . '</b>'),
+ 'u' => array(t('Underlined'), '<u>' . t('Underlined') . '</u>'),
+ 'i' => array(t('Italicized'), '<i>' . t('Italicized') . '</i>'),
+ 'sup' => array(t('Superscripted'), t('<sup>Super</sup>scripted')),
+ 'sub' => array(t('Subscripted'), t('<sub>Sub</sub>scripted')),
+ 'pre' => array(t('Preformatted'), '<pre>' . t('Preformatted') . '</pre>'),
+ 'abbr' => array(t('Abbreviation'), t('<abbr title="Abbreviation">Abbrev.</abbr>')),
+ 'acronym' => array(t('Acronym'), t('<acronym title="Three-Letter Acronym">TLA</acronym>')),
+ 'blockquote' => array(t('Block quoted'), '<blockquote>' . t('Block quoted') . '</blockquote>'),
+ 'q' => array(t('Quoted inline'), '<q>' . t('Quoted inline') . '</q>'),
+ // Assumes and describes tr, td, th.
+ 'table' => array(t('Table'), '<table> <tr><th>' . t('Table header') . '</th></tr> <tr><td>' . t('Table cell') . '</td></tr> </table>'),
+ 'tr' => NULL, 'td' => NULL, 'th' => NULL,
+ 'del' => array(t('Deleted'), '<del>' . t('Deleted') . '</del>'),
+ 'ins' => array(t('Inserted'), '<ins>' . t('Inserted') . '</ins>'),
+ // Assumes and describes li.
+ 'ol' => array(t('Ordered list - use the &lt;li&gt; to begin each list item'), '<ol> <li>' . t('First item') . '</li> <li>' . t('Second item') . '</li> </ol>'),
+ 'ul' => array(t('Unordered list - use the &lt;li&gt; to begin each list item'), '<ul> <li>' . t('First item') . '</li> <li>' . t('Second item') . '</li> </ul>'),
+ 'li' => NULL,
+ // Assumes and describes dt and dd.
+ 'dl' => array(t('Definition lists are similar to other HTML lists. &lt;dl&gt; begins the definition list, &lt;dt&gt; begins the definition term and &lt;dd&gt; begins the definition description.'), '<dl> <dt>' . t('First term') . '</dt> <dd>' . t('First definition') . '</dd> <dt>' . t('Second term') . '</dt> <dd>' . t('Second definition') . '</dd> </dl>'),
+ 'dt' => NULL, 'dd' => NULL,
+ 'h1' => array(t('Heading'), '<h1>' . t('Title') . '</h1>'),
+ 'h2' => array(t('Heading'), '<h2>' . t('Subtitle') . '</h2>'),
+ 'h3' => array(t('Heading'), '<h3>' . t('Subtitle three') . '</h3>'),
+ 'h4' => array(t('Heading'), '<h4>' . t('Subtitle four') . '</h4>'),
+ 'h5' => array(t('Heading'), '<h5>' . t('Subtitle five') . '</h5>'),
+ 'h6' => array(t('Heading'), '<h6>' . t('Subtitle six') . '</h6>')
+ );
+ $header = array(t('Tag Description'), t('You Type'), t('You Get'));
+ preg_match_all('/<([a-z0-9]+)[^a-z0-9]/i', $allowed_html, $out);
+ foreach ($out[1] as $tag) {
+ if (!empty($tips[$tag])) {
+ $rows[] = array(
+ array('data' => $tips[$tag][0], 'class' => array('description')),
+ array('data' => '<code>' . check_plain($tips[$tag][1]) . '</code>', 'class' => array('type')),
+ array('data' => $tips[$tag][1], 'class' => array('get'))
+ );
+ }
+ else {
+ $rows[] = array(
+ array('data' => t('No help provided for tag %tag.', array('%tag' => $tag)), 'class' => array('description'), 'colspan' => 3),
+ );
+ }
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows));
+
+ $output .= '<p>' . t('Most unusual characters can be directly entered without any problems.') . '</p>';
+ $output .= '<p>' . t('If you do encounter problems, try using HTML character entities. A common example looks like &amp;amp; for an ampersand &amp; character. For a full list of entities see HTML\'s <a href="@html-entities">entities</a> page. Some of the available characters include:', array('@html-entities' => 'http://www.w3.org/TR/html4/sgml/entities.html')) . '</p>';
+
+ $entities = array(
+ array(t('Ampersand'), '&amp;'),
+ array(t('Greater than'), '&gt;'),
+ array(t('Less than'), '&lt;'),
+ array(t('Quotation mark'), '&quot;'),
+ );
+ $header = array(t('Character Description'), t('You Type'), t('You Get'));
+ unset($rows);
+ foreach ($entities as $entity) {
+ $rows[] = array(
+ array('data' => $entity[0], 'class' => array('description')),
+ array('data' => '<code>' . check_plain($entity[1]) . '</code>', 'class' => array('type')),
+ array('data' => $entity[1], 'class' => array('get'))
+ );
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows));
+ return $output;
+}
+
+/**
+ * Settings callback for URL filter.
+ */
+function _filter_url_settings($form, &$form_state, $filter, $format, $defaults) {
+ $filter->settings += $defaults;
+
+ $settings['filter_url_length'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum link text length'),
+ '#default_value' => $filter->settings['filter_url_length'],
+ '#size' => 5,
+ '#maxlength' => 4,
+ '#field_suffix' => t('characters'),
+ '#description' => t('URLs longer than this number of characters will be truncated to prevent long strings that break formatting. The link itself will be retained; just the text portion of the link will be truncated.'),
+ );
+ return $settings;
+}
+
+/**
+ * URL filter. Automatically converts text into hyperlinks.
+ *
+ * This filter identifies and makes clickable three types of "links".
+ * - URLs like http://example.com.
+ * - E-mail addresses like name@example.com.
+ * - Web addresses without the "http://" protocol defined, like www.example.com.
+ * Each type must be processed separately, as there is no one regular
+ * expression that could possibly match all of the cases in one pass.
+ */
+function _filter_url($text, $filter) {
+ // Tags to skip and not recurse into.
+ $ignore_tags = 'a|script|style|code|pre';
+
+ // Pass length to regexp callback.
+ _filter_url_trim(NULL, $filter->settings['filter_url_length']);
+
+ // Create an array which contains the regexps for each type of link.
+ // The key to the regexp is the name of a function that is used as
+ // callback function to process matches of the regexp. The callback function
+ // is to return the replacement for the match. The array is used and
+ // matching/replacement done below inside some loops.
+ $tasks = array();
+
+ // Prepare protocols pattern for absolute URLs.
+ // check_url() will replace any bad protocols with HTTP, so we need to support
+ // the identical list. While '//' is technically optional for MAILTO only,
+ // we cannot cleanly differ between protocols here without hard-coding MAILTO,
+ // so '//' is optional for all protocols.
+ // @see filter_xss_bad_protocol()
+ $protocols = variable_get('filter_allowed_protocols', array('http', 'https', 'ftp', 'news', 'nntp', 'telnet', 'mailto', 'irc', 'ssh', 'sftp', 'webcal', 'rtsp'));
+ $protocols = implode(':(?://)?|', $protocols) . ':(?://)?';
+
+ // Prepare domain name pattern.
+ // The ICANN seems to be on track towards accepting more diverse top level
+ // domains, so this pattern has been "future-proofed" to allow for TLDs
+ // of length 2-64.
+ $domain = '(?:[A-Za-z0-9._+-]+\.)?[A-Za-z]{2,64}\b';
+ $ip = '(?:[0-9]{1,3}\.){3}[0-9]{1,3}';
+ $auth = '[a-zA-Z0-9:%_+*~#?&=.,/;-]+@';
+ $trail = '[a-zA-Z0-9:%_+*~#&\[\]=/;?!\.,-]*[a-zA-Z0-9:%_+*~#&\[\]=/;-]';
+
+ // Prepare pattern for optional trailing punctuation.
+ // Even these characters could have a valid meaning for the URL, such usage is
+ // rare compared to using a URL at the end of or within a sentence, so these
+ // trailing characters are optionally excluded.
+ $punctuation = '[\.,?!]*?';
+
+ // Match absolute URLs.
+ $url_pattern = "(?:$auth)?(?:$domain|$ip)/?(?:$trail)?";
+ $pattern = "`((?:$protocols)(?:$url_pattern))($punctuation)`";
+ $tasks['_filter_url_parse_full_links'] = $pattern;
+
+ // Match e-mail addresses.
+ $url_pattern = "[A-Za-z0-9._-]+@(?:$domain)";
+ $pattern = "`($url_pattern)`";
+ $tasks['_filter_url_parse_email_links'] = $pattern;
+
+ // Match www domains.
+ $url_pattern = "www\.(?:$domain)/?(?:$trail)?";
+ $pattern = "`($url_pattern)($punctuation)`";
+ $tasks['_filter_url_parse_partial_links'] = $pattern;
+
+ // Each type of URL needs to be processed separately. The text is joined and
+ // re-split after each task, since all injected HTML tags must be correctly
+ // protected before the next task.
+ foreach ($tasks as $task => $pattern) {
+ // HTML comments need to be handled separately, as they may contain HTML
+ // markup, especially a '>'. Therefore, remove all comment contents and add
+ // them back later.
+ _filter_url_escape_comments('', TRUE);
+ $text = preg_replace_callback('`<!--(.*?)-->`s', '_filter_url_escape_comments', $text);
+
+ // Split at all tags; ensures that no tags or attributes are processed.
+ $chunks = preg_split('/(<.+?>)/is', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+ // PHP ensures that the array consists of alternating delimiters and
+ // literals, and begins and ends with a literal (inserting NULL as
+ // required). Therefore, the first chunk is always text:
+ $chunk_type = 'text';
+ // If a tag of $ignore_tags is found, it is stored in $open_tag and only
+ // removed when the closing tag is found. Until the closing tag is found,
+ // no replacements are made.
+ $open_tag = '';
+
+ for ($i = 0; $i < count($chunks); $i++) {
+ if ($chunk_type == 'text') {
+ // Only process this text if there are no unclosed $ignore_tags.
+ if ($open_tag == '') {
+ // If there is a match, inject a link into this chunk via the callback
+ // function contained in $task.
+ $chunks[$i] = preg_replace_callback($pattern, $task, $chunks[$i]);
+ }
+ // Text chunk is done, so next chunk must be a tag.
+ $chunk_type = 'tag';
+ }
+ else {
+ // Only process this tag if there are no unclosed $ignore_tags.
+ if ($open_tag == '') {
+ // Check whether this tag is contained in $ignore_tags.
+ if (preg_match("`<($ignore_tags)(?:\s|>)`i", $chunks[$i], $matches)) {
+ $open_tag = $matches[1];
+ }
+ }
+ // Otherwise, check whether this is the closing tag for $open_tag.
+ else {
+ if (preg_match("`<\/$open_tag>`i", $chunks[$i], $matches)) {
+ $open_tag = '';
+ }
+ }
+ // Tag chunk is done, so next chunk must be text.
+ $chunk_type = 'text';
+ }
+ }
+
+ $text = implode($chunks);
+ // Revert back to the original comment contents
+ _filter_url_escape_comments('', FALSE);
+ $text = preg_replace_callback('`<!--(.*?)-->`', '_filter_url_escape_comments', $text);
+ }
+
+ return $text;
+}
+
+/**
+ * preg_replace callback to make links out of absolute URLs.
+ */
+function _filter_url_parse_full_links($match) {
+ // The $i:th parenthesis in the regexp contains the URL.
+ $i = 1;
+
+ $match[$i] = decode_entities($match[$i]);
+ $caption = check_plain(_filter_url_trim($match[$i]));
+ $match[$i] = check_plain($match[$i]);
+ return '<a href="' . $match[$i] . '">' . $caption . '</a>' . $match[$i + 1];
+}
+
+/**
+ * preg_replace callback to make links out of e-mail addresses.
+ */
+function _filter_url_parse_email_links($match) {
+ // The $i:th parenthesis in the regexp contains the URL.
+ $i = 0;
+
+ $match[$i] = decode_entities($match[$i]);
+ $caption = check_plain(_filter_url_trim($match[$i]));
+ $match[$i] = check_plain($match[$i]);
+ return '<a href="mailto:' . $match[$i] . '">' . $caption . '</a>';
+}
+
+/**
+ * preg_replace callback to make links out of domain names starting with "www."
+ */
+function _filter_url_parse_partial_links($match) {
+ // The $i:th parenthesis in the regexp contains the URL.
+ $i = 1;
+
+ $match[$i] = decode_entities($match[$i]);
+ $caption = check_plain(_filter_url_trim($match[$i]));
+ $match[$i] = check_plain($match[$i]);
+ return '<a href="http://' . $match[$i] . '">' . $caption . '</a>' . $match[$i + 1];
+}
+
+/**
+ * preg_replace callback to escape contents of HTML comments
+ *
+ * @param $match
+ * An array containing matches to replace from preg_replace_callback(),
+ * whereas $match[1] is expected to contain the content to be filtered.
+ * @param $escape
+ * (optional) Boolean whether to escape (TRUE) or unescape comments (FALSE).
+ * Defaults to neither. If TRUE, statically cached $comments are reset.
+ */
+function _filter_url_escape_comments($match, $escape = NULL) {
+ static $mode, $comments = array();
+
+ if (isset($escape)) {
+ $mode = $escape;
+ if ($escape){
+ $comments = array();
+ }
+ return;
+ }
+
+ // Replace all HTML coments with a '<!-- [hash] -->' placeholder.
+ if ($mode) {
+ $content = $match[1];
+ $hash = md5($content);
+ $comments[$hash] = $content;
+ return "<!-- $hash -->";
+ }
+ // Or replace placeholders with actual comment contents.
+ else {
+ $hash = $match[1];
+ $hash = trim($hash);
+ $content = $comments[$hash];
+ return "<!--$content-->";
+ }
+}
+
+/**
+ * Shortens long URLs to http://www.example.com/long/url...
+ */
+function _filter_url_trim($text, $length = NULL) {
+ static $_length;
+ if ($length !== NULL) {
+ $_length = $length;
+ }
+
+ // Use +3 for '...' string length.
+ if ($_length && strlen($text) > $_length + 3) {
+ $text = substr($text, 0, $_length) . '...';
+ }
+
+ return $text;
+}
+
+/**
+ * Filter tips callback for URL filter.
+ */
+function _filter_url_tips($filter, $format, $long = FALSE) {
+ return t('Web page addresses and e-mail addresses turn into links automatically.');
+}
+
+/**
+ * Scan input and make sure that all HTML tags are properly closed and nested.
+ */
+function _filter_htmlcorrector($text) {
+ return filter_dom_serialize(filter_dom_load($text));
+}
+
+/**
+ * Convert line breaks into <p> and <br> in an intelligent fashion.
+ * Based on: http://photomatt.net/scripts/autop
+ */
+function _filter_autop($text) {
+ // All block level tags
+ $block = '(?:table|thead|tfoot|caption|colgroup|tbody|tr|td|th|div|dl|dd|dt|ul|ol|li|pre|select|form|blockquote|address|p|h[1-6]|hr|article|aside|details|figcaption|figure|footer|header|hgroup|menu|nav|section|summary)';
+
+ // Split at opening and closing PRE, SCRIPT, STYLE, OBJECT, IFRAME tags
+ // and comments. We don't apply any processing to the contents of these tags
+ // to avoid messing up code. We look for matched pairs and allow basic
+ // nesting. For example:
+ // "processed <pre> ignored <script> ignored </script> ignored </pre> processed"
+ $chunks = preg_split('@(<!--.*?-->|</?(?:pre|script|style|object|iframe|!--)[^>]*>)@i', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+ // Note: PHP ensures the array consists of alternating delimiters and literals
+ // and begins and ends with a literal (inserting NULL as required).
+ $ignore = FALSE;
+ $ignoretag = '';
+ $output = '';
+ foreach ($chunks as $i => $chunk) {
+ if ($i % 2) {
+ $comment = (substr($chunk, 0, 4) == '<!--');
+ if ($comment) {
+ // Nothing to do, this is a comment.
+ $output .= $chunk;
+ continue;
+ }
+ // Opening or closing tag?
+ $open = ($chunk[1] != '/');
+ list($tag) = preg_split('/[ >]/', substr($chunk, 2 - $open), 2);
+ if (!$ignore) {
+ if ($open) {
+ $ignore = TRUE;
+ $ignoretag = $tag;
+ }
+ }
+ // Only allow a matching tag to close it.
+ elseif (!$open && $ignoretag == $tag) {
+ $ignore = FALSE;
+ $ignoretag = '';
+ }
+ }
+ elseif (!$ignore) {
+ $chunk = preg_replace('|\n*$|', '', $chunk) . "\n\n"; // just to make things a little easier, pad the end
+ $chunk = preg_replace('|<br />\s*<br />|', "\n\n", $chunk);
+ $chunk = preg_replace('!(<' . $block . '[^>]*>)!', "\n$1", $chunk); // Space things out a little
+ $chunk = preg_replace('!(</' . $block . '>)!', "$1\n\n", $chunk); // Space things out a little
+ $chunk = preg_replace("/\n\n+/", "\n\n", $chunk); // take care of duplicates
+ $chunk = preg_replace('/^\n|\n\s*\n$/', '', $chunk);
+ $chunk = '<p>' . preg_replace('/\n\s*\n\n?(.)/', "</p>\n<p>$1", $chunk) . "</p>\n"; // make paragraphs, including one at the end
+ $chunk = preg_replace("|<p>(<li.+?)</p>|", "$1", $chunk); // problem with nested lists
+ $chunk = preg_replace('|<p><blockquote([^>]*)>|i', "<blockquote$1><p>", $chunk);
+ $chunk = str_replace('</blockquote></p>', '</p></blockquote>', $chunk);
+ $chunk = preg_replace('|<p>\s*</p>\n?|', '', $chunk); // under certain strange conditions it could create a P of entirely whitespace
+ $chunk = preg_replace('!<p>\s*(</?' . $block . '[^>]*>)!', "$1", $chunk);
+ $chunk = preg_replace('!(</?' . $block . '[^>]*>)\s*</p>!', "$1", $chunk);
+ $chunk = preg_replace('|(?<!<br />)\s*\n|', "<br />\n", $chunk); // make line breaks
+ $chunk = preg_replace('!(</?' . $block . '[^>]*>)\s*<br />!', "$1", $chunk);
+ $chunk = preg_replace('!<br />(\s*</?(?:p|li|div|th|pre|td|ul|ol)>)!', '$1', $chunk);
+ $chunk = preg_replace('/&([^#])(?![A-Za-z0-9]{1,8};)/', '&amp;$1', $chunk);
+ }
+ $output .= $chunk;
+ }
+ return $output;
+}
+
+/**
+ * Filter tips callback for auto-paragraph filter.
+ */
+function _filter_autop_tips($filter, $format, $long = FALSE) {
+ if ($long) {
+ return t('Lines and paragraphs are automatically recognized. The &lt;br /&gt; line break, &lt;p&gt; paragraph and &lt;/p&gt; close paragraph tags are inserted automatically. If paragraphs are not recognized simply add a couple blank lines.');
+ }
+ else {
+ return t('Lines and paragraphs break automatically.');
+ }
+}
+
+/**
+ * Escapes all HTML tags, so they will be visible instead of being effective.
+ */
+function _filter_html_escape($text) {
+ return trim(check_plain($text));
+}
+
+/**
+ * Filter tips callback for HTML escaping filter.
+ */
+function _filter_html_escape_tips($filter, $format, $long = FALSE) {
+ return t('No HTML tags allowed.');
+}
+
+/**
+ * @} End of "Standard filters".
+ */
diff --git a/core/modules/filter/filter.pages.inc b/core/modules/filter/filter.pages.inc
new file mode 100644
index 000000000000..dbbbe4c5aac1
--- /dev/null
+++ b/core/modules/filter/filter.pages.inc
@@ -0,0 +1,88 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the filter module.
+ */
+
+
+/**
+ * Menu callback; show a page with long filter tips.
+ */
+function filter_tips_long() {
+ $format_id = arg(2);
+ if ($format_id) {
+ $output = theme('filter_tips', array('tips' => _filter_tips($format_id, TRUE), 'long' => TRUE));
+ }
+ else {
+ $output = theme('filter_tips', array('tips' => _filter_tips(-1, TRUE), 'long' => TRUE));
+ }
+ return $output;
+}
+
+
+/**
+ * Returns HTML for a set of filter tips.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - tips: An array containing descriptions and a CSS id in the form of
+ * 'module-name/filter-id' (only used when $long is TRUE) for each
+ * filter in one or more text formats. Example:
+ * @code
+ * array(
+ * 'Full HTML' => array(
+ * 0 => array(
+ * 'tip' => 'Web page addresses and e-mail addresses turn into links automatically.',
+ * 'id' => 'filter/2',
+ * ),
+ * ),
+ * );
+ * @endcode
+ * - long: (optional) Whether the passed-in filter tips contain extended
+ * explanations, i.e. intended to be output on the path 'filter/tips'
+ * (TRUE), or are in a short format, i.e. suitable to be displayed below a
+ * form element. Defaults to FALSE.
+ *
+ * @see _filter_tips()
+ * @ingroup themeable
+ */
+function theme_filter_tips($variables) {
+ $tips = $variables['tips'];
+ $long = $variables['long'];
+ $output = '';
+
+ $multiple = count($tips) > 1;
+ if ($multiple) {
+ $output = '<h2>' . t('Text Formats') . '</h2>';
+ }
+
+ if (count($tips)) {
+ if ($multiple) {
+ $output .= '<div class="compose-tips">';
+ }
+ foreach ($tips as $name => $tiplist) {
+ if ($multiple) {
+ $output .= '<div class="filter-type filter-' . drupal_html_class($name) . '">';
+ $output .= '<h3>' . $name . '</h3>';
+ }
+
+ if (count($tiplist) > 0) {
+ $output .= '<ul class="tips">';
+ foreach ($tiplist as $tip) {
+ $output .= '<li' . ($long ? ' id="filter-' . str_replace("/", "-", $tip['id']) . '">' : '>') . $tip['tip'] . '</li>';
+ }
+ $output .= '</ul>';
+ }
+
+ if ($multiple) {
+ $output .= '</div>';
+ }
+ }
+ if ($multiple) {
+ $output .= '</div>';
+ }
+ }
+
+ return $output;
+}
diff --git a/core/modules/filter/filter.test b/core/modules/filter/filter.test
new file mode 100644
index 000000000000..67d08333dc7d
--- /dev/null
+++ b/core/modules/filter/filter.test
@@ -0,0 +1,1752 @@
+<?php
+
+/**
+ * @file
+ * Tests for filter.module.
+ */
+
+/**
+ * Tests for text format and filter CRUD operations.
+ */
+class FilterCRUDTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filter CRUD operations',
+ 'description' => 'Test creation, loading, updating, deleting of text formats and filters.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('filter_test');
+ }
+
+ /**
+ * Test CRUD operations for text formats and filters.
+ */
+ function testTextFormatCRUD() {
+ // Add a text format with minimum data only.
+ $format = new stdClass();
+ $format->format = 'empty_format';
+ $format->name = 'Empty format';
+ filter_format_save($format);
+ $this->verifyTextFormat($format);
+ $this->verifyFilters($format);
+
+ // Add another text format specifying all possible properties.
+ $format = new stdClass();
+ $format->format = 'custom_format';
+ $format->name = 'Custom format';
+ $format->filters = array(
+ 'filter_url' => array(
+ 'status' => 1,
+ 'settings' => array(
+ 'filter_url_length' => 30,
+ ),
+ ),
+ );
+ filter_format_save($format);
+ $this->verifyTextFormat($format);
+ $this->verifyFilters($format);
+
+ // Alter some text format properties and save again.
+ $format->name = 'Altered format';
+ $format->filters['filter_url']['status'] = 0;
+ $format->filters['filter_autop']['status'] = 1;
+ filter_format_save($format);
+ $this->verifyTextFormat($format);
+ $this->verifyFilters($format);
+
+ // Add a uncacheable filter and save again.
+ $format->filters['filter_test_uncacheable']['status'] = 1;
+ filter_format_save($format);
+ $this->verifyTextFormat($format);
+ $this->verifyFilters($format);
+
+ // Disable the text format.
+ filter_format_disable($format);
+
+ $db_format = db_query("SELECT * FROM {filter_format} WHERE format = :format", array(':format' => $format->format))->fetchObject();
+ $this->assertFalse($db_format->status, t('Database: Disabled text format is marked as disabled.'));
+ $formats = filter_formats();
+ $this->assertTrue(!isset($formats[$format->format]), t('filter_formats: Disabled text format no longer exists.'));
+ }
+
+ /**
+ * Verify that a text format is properly stored.
+ */
+ function verifyTextFormat($format) {
+ $t_args = array('%format' => $format->name);
+ // Verify text format database record.
+ $db_format = db_select('filter_format', 'ff')
+ ->fields('ff')
+ ->condition('format', $format->format)
+ ->execute()
+ ->fetchObject();
+ $this->assertEqual($db_format->format, $format->format, t('Database: Proper format id for text format %format.', $t_args));
+ $this->assertEqual($db_format->name, $format->name, t('Database: Proper title for text format %format.', $t_args));
+ $this->assertEqual($db_format->cache, $format->cache, t('Database: Proper cache indicator for text format %format.', $t_args));
+ $this->assertEqual($db_format->weight, $format->weight, t('Database: Proper weight for text format %format.', $t_args));
+
+ // Verify filter_format_load().
+ $filter_format = filter_format_load($format->format);
+ $this->assertEqual($filter_format->format, $format->format, t('filter_format_load: Proper format id for text format %format.', $t_args));
+ $this->assertEqual($filter_format->name, $format->name, t('filter_format_load: Proper title for text format %format.', $t_args));
+ $this->assertEqual($filter_format->cache, $format->cache, t('filter_format_load: Proper cache indicator for text format %format.', $t_args));
+ $this->assertEqual($filter_format->weight, $format->weight, t('filter_format_load: Proper weight for text format %format.', $t_args));
+
+ // Verify the 'cache' text format property according to enabled filters.
+ $filter_info = filter_get_filters();
+ $filters = filter_list_format($filter_format->format);
+ $cacheable = TRUE;
+ foreach ($filters as $name => $filter) {
+ // If this filter is not cacheable, update $cacheable accordingly, so we
+ // can verify $format->cache after iterating over all filters.
+ if ($filter->status && isset($filter_info[$name]['cache']) && !$filter_info[$name]['cache']) {
+ $cacheable = FALSE;
+ break;
+ }
+ }
+ $this->assertEqual($filter_format->cache, $cacheable, t('Text format contains proper cache property.'));
+ }
+
+ /**
+ * Verify that filters are properly stored for a text format.
+ */
+ function verifyFilters($format) {
+ // Verify filter database records.
+ $filters = db_query("SELECT * FROM {filter} WHERE format = :format", array(':format' => $format->format))->fetchAllAssoc('name');
+ $format_filters = $format->filters;
+ foreach ($filters as $name => $filter) {
+ $t_args = array('%format' => $format->name, '%filter' => $name);
+
+ // Verify that filter status is properly stored.
+ $this->assertEqual($filter->status, $format_filters[$name]['status'], t('Database: Proper status for %filter in text format %format.', $t_args));
+
+ // Verify that filter settings were properly stored.
+ $this->assertEqual(unserialize($filter->settings), isset($format_filters[$name]['settings']) ? $format_filters[$name]['settings'] : array(), t('Database: Proper filter settings for %filter in text format %format.', $t_args));
+
+ // Verify that each filter has a module name assigned.
+ $this->assertTrue(!empty($filter->module), t('Database: Proper module name for %filter in text format %format.', $t_args));
+
+ // Remove the filter from the copy of saved $format to check whether all
+ // filters have been processed later.
+ unset($format_filters[$name]);
+ }
+ // Verify that all filters have been processed.
+ $this->assertTrue(empty($format_filters), t('Database contains values for all filters in the saved format.'));
+
+ // Verify filter_list_format().
+ $filters = filter_list_format($format->format);
+ $format_filters = $format->filters;
+ foreach ($filters as $name => $filter) {
+ $t_args = array('%format' => $format->name, '%filter' => $name);
+
+ // Verify that filter status is properly stored.
+ $this->assertEqual($filter->status, $format_filters[$name]['status'], t('filter_list_format: Proper status for %filter in text format %format.', $t_args));
+
+ // Verify that filter settings were properly stored.
+ $this->assertEqual($filter->settings, isset($format_filters[$name]['settings']) ? $format_filters[$name]['settings'] : array(), t('filter_list_format: Proper filter settings for %filter in text format %format.', $t_args));
+
+ // Verify that each filter has a module name assigned.
+ $this->assertTrue(!empty($filter->module), t('filter_list_format: Proper module name for %filter in text format %format.', $t_args));
+
+ // Remove the filter from the copy of saved $format to check whether all
+ // filters have been processed later.
+ unset($format_filters[$name]);
+ }
+ // Verify that all filters have been processed.
+ $this->assertTrue(empty($format_filters), t('filter_list_format: Loaded filters contain values for all filters in the saved format.'));
+ }
+}
+
+class FilterAdminTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filter administration functionality',
+ 'description' => 'Thoroughly test the administrative interface of the filter module.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create users.
+ $filtered_html_format = filter_format_load('filtered_html');
+ $full_html_format = filter_format_load('full_html');
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'administer filters',
+ filter_permission_name($filtered_html_format),
+ filter_permission_name($full_html_format),
+ ));
+
+ $this->web_user = $this->drupalCreateUser(array('create page content', 'edit own page content'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ function testFormatAdmin() {
+ // Add text format.
+ $this->drupalGet('admin/config/content/formats');
+ $this->clickLink('Add text format');
+ $format_id = drupal_strtolower($this->randomName());
+ $name = $this->randomName();
+ $edit = array(
+ 'format' => $format_id,
+ 'name' => $name,
+ );
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+
+ // Verify default weight of the text format.
+ $this->drupalGet('admin/config/content/formats');
+ $this->assertFieldByName("formats[$format_id][weight]", 0, t('Text format weight was saved.'));
+
+ // Change the weight of the text format.
+ $edit = array(
+ "formats[$format_id][weight]" => 5,
+ );
+ $this->drupalPost('admin/config/content/formats', $edit, t('Save changes'));
+ $this->assertFieldByName("formats[$format_id][weight]", 5, t('Text format weight was saved.'));
+
+ // Edit text format.
+ $this->drupalGet('admin/config/content/formats');
+ $this->assertLinkByHref('admin/config/content/formats/' . $format_id);
+ $this->drupalGet('admin/config/content/formats/' . $format_id);
+ $this->drupalPost(NULL, array(), t('Save configuration'));
+
+ // Verify that the custom weight of the text format has been retained.
+ $this->drupalGet('admin/config/content/formats');
+ $this->assertFieldByName("formats[$format_id][weight]", 5, t('Text format weight was retained.'));
+
+ // Disable text format.
+ $this->assertLinkByHref('admin/config/content/formats/' . $format_id . '/disable');
+ $this->drupalGet('admin/config/content/formats/' . $format_id . '/disable');
+ $this->drupalPost(NULL, array(), t('Disable'));
+
+ // Verify that disabled text format no longer exists.
+ $this->drupalGet('admin/config/content/formats/' . $format_id);
+ $this->assertResponse(404, t('Disabled text format no longer exists.'));
+
+ // Attempt to create a format of the same machine name as the disabled
+ // format but with a different human readable name.
+ $edit = array(
+ 'format' => $format_id,
+ 'name' => 'New format',
+ );
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->assertText('The machine-readable name is already in use. It must be unique.');
+
+ // Attempt to create a format of the same human readable name as the
+ // disabled format but with a different machine name.
+ $edit = array(
+ 'format' => 'new_format',
+ 'name' => $name,
+ );
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->assertRaw(t('Text format names must be unique. A format named %name already exists.', array(
+ '%name' => $name,
+ )));
+ }
+
+ /**
+ * Test filter administration functionality.
+ */
+ function testFilterAdmin() {
+ // URL filter.
+ $first_filter = 'filter_url';
+ // Line filter.
+ $second_filter = 'filter_autop';
+
+ $filtered = 'filtered_html';
+ $full = 'full_html';
+ $plain = 'plain_text';
+
+ // Check that the fallback format exists and cannot be disabled.
+ $this->assertTrue($plain == filter_fallback_format(), t('The fallback format is set to plain text.'));
+ $this->drupalGet('admin/config/content/formats');
+ $this->assertNoRaw('admin/config/content/formats/' . $plain . '/disable', t('Disable link for the fallback format not found.'));
+ $this->drupalGet('admin/config/content/formats/' . $plain . '/disable');
+ $this->assertResponse(403, t('The fallback format cannot be disabled.'));
+
+ // Verify access permissions to Full HTML format.
+ $this->assertTrue(filter_access(filter_format_load($full), $this->admin_user), t('Admin user may use Full HTML.'));
+ $this->assertFalse(filter_access(filter_format_load($full), $this->web_user), t('Web user may not use Full HTML.'));
+
+ // Add an additional tag.
+ $edit = array();
+ $edit['filters[filter_html][settings][allowed_html]'] = '<a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd> <quote>';
+ $this->drupalPost('admin/config/content/formats/' . $filtered, $edit, t('Save configuration'));
+ $this->assertFieldByName('filters[filter_html][settings][allowed_html]', $edit['filters[filter_html][settings][allowed_html]'], t('Allowed HTML tag added.'));
+
+ $result = db_query('SELECT * FROM {cache_filter}')->fetchObject();
+ $this->assertFalse($result, t('Cache cleared.'));
+
+ $elements = $this->xpath('//select[@name=:first]/following::select[@name=:second]', array(
+ ':first' => 'filters[' . $first_filter . '][weight]',
+ ':second' => 'filters[' . $second_filter . '][weight]',
+ ));
+ $this->assertTrue(!empty($elements), t('Order confirmed in admin interface.'));
+
+ // Reorder filters.
+ $edit = array();
+ $edit['filters[' . $second_filter . '][weight]'] = 1;
+ $edit['filters[' . $first_filter . '][weight]'] = 2;
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+ $this->assertFieldByName('filters[' . $second_filter . '][weight]', 1, t('Order saved successfully.'));
+ $this->assertFieldByName('filters[' . $first_filter . '][weight]', 2, t('Order saved successfully.'));
+
+ $elements = $this->xpath('//select[@name=:first]/following::select[@name=:second]', array(
+ ':first' => 'filters[' . $second_filter . '][weight]',
+ ':second' => 'filters[' . $first_filter . '][weight]',
+ ));
+ $this->assertTrue(!empty($elements), t('Reorder confirmed in admin interface.'));
+
+ $result = db_query('SELECT * FROM {filter} WHERE format = :format ORDER BY weight ASC', array(':format' => $filtered));
+ $filters = array();
+ foreach ($result as $filter) {
+ if ($filter->name == $second_filter || $filter->name == $first_filter) {
+ $filters[] = $filter;
+ }
+ }
+ $this->assertTrue(($filters[0]->name == $second_filter && $filters[1]->name == $first_filter), t('Order confirmed in database.'));
+
+ // Add format.
+ $edit = array();
+ $edit['format'] = drupal_strtolower($this->randomName());
+ $edit['name'] = $this->randomName();
+ $edit['roles[2]'] = 1;
+ $edit['filters[' . $second_filter . '][status]'] = TRUE;
+ $edit['filters[' . $first_filter . '][status]'] = TRUE;
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->assertRaw(t('Added text format %format.', array('%format' => $edit['name'])), t('New filter created.'));
+
+ drupal_static_reset('filter_formats');
+ $format = filter_format_load($edit['format']);
+ $this->assertNotNull($format, t('Format found in database.'));
+
+ $this->assertFieldByName('roles[2]', '', t('Role found.'));
+ $this->assertFieldByName('filters[' . $second_filter . '][status]', '', t('Line break filter found.'));
+ $this->assertFieldByName('filters[' . $first_filter . '][status]', '', t('Url filter found.'));
+
+ // Disable new filter.
+ $this->drupalPost('admin/config/content/formats/' . $format->format . '/disable', array(), t('Disable'));
+ $this->assertRaw(t('Disabled text format %format.', array('%format' => $edit['name'])), t('Format successfully disabled.'));
+
+ // Allow authenticated users on full HTML.
+ $format = filter_format_load($full);
+ $edit = array();
+ $edit['roles[1]'] = 0;
+ $edit['roles[2]'] = 1;
+ $this->drupalPost('admin/config/content/formats/' . $full, $edit, t('Save configuration'));
+ $this->assertRaw(t('The text format %format has been updated.', array('%format' => $format->name)), t('Full HTML format successfully updated.'));
+
+ // Switch user.
+ $this->drupalLogout();
+ $this->drupalLogin($this->web_user);
+
+ $this->drupalGet('node/add/page');
+ $this->assertRaw('<option value="' . $full . '">Full HTML</option>', t('Full HTML filter accessible.'));
+
+ // Use filtered HTML and see if it removes tags that are not allowed.
+ $body = '<em>' . $this->randomName() . '</em>';
+ $extra_text = 'text';
+ $text = $body . '<random>' . $extra_text . '</random>';
+
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $text;
+ $edit["body[$langcode][0][format]"] = $filtered;
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been created.', array('%title' => $edit["title"])), t('Filtered node created.'));
+
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw($body . $extra_text, t('Filter removed invalid tag.'));
+
+ // Use plain text and see if it escapes all tags, whether allowed or not.
+ $edit = array();
+ $edit["body[$langcode][0][format]"] = $plain;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText(check_plain($text), t('The "Plain text" text format escapes all HTML tags.'));
+
+ // Switch user.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+
+ // Clean up.
+ // Allowed tags.
+ $edit = array();
+ $edit['filters[filter_html][settings][allowed_html]'] = '<a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>';
+ $this->drupalPost('admin/config/content/formats/' . $filtered, $edit, t('Save configuration'));
+ $this->assertFieldByName('filters[filter_html][settings][allowed_html]', $edit['filters[filter_html][settings][allowed_html]'], t('Changes reverted.'));
+
+ // Full HTML.
+ $edit = array();
+ $edit['roles[2]'] = FALSE;
+ $this->drupalPost('admin/config/content/formats/' . $full, $edit, t('Save configuration'));
+ $this->assertRaw(t('The text format %format has been updated.', array('%format' => $format->name)), t('Full HTML format successfully reverted.'));
+ $this->assertFieldByName('roles[2]', $edit['roles[2]'], t('Changes reverted.'));
+
+ // Filter order.
+ $edit = array();
+ $edit['filters[' . $second_filter . '][weight]'] = 2;
+ $edit['filters[' . $first_filter . '][weight]'] = 1;
+ $this->drupalPost('admin/config/content/formats/' . $filtered, $edit, t('Save configuration'));
+ $this->assertFieldByName('filters[' . $second_filter . '][weight]', $edit['filters[' . $second_filter . '][weight]'], t('Changes reverted.'));
+ $this->assertFieldByName('filters[' . $first_filter . '][weight]', $edit['filters[' . $first_filter . '][weight]'], t('Changes reverted.'));
+ }
+}
+
+class FilterFormatAccessTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $filter_admin_user;
+ protected $web_user;
+ protected $allowed_format;
+ protected $disallowed_format;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filter format access',
+ 'description' => 'Tests access to text formats.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create a user who can administer text formats, but does not have
+ // specific permission to use any of them.
+ $this->filter_admin_user = $this->drupalCreateUser(array(
+ 'administer filters',
+ 'create page content',
+ 'edit any page content',
+ ));
+
+ // Create two text formats.
+ $this->drupalLogin($this->filter_admin_user);
+ $formats = array();
+ for ($i = 0; $i < 2; $i++) {
+ $edit = array(
+ 'format' => drupal_strtolower($this->randomName()),
+ 'name' => $this->randomName(),
+ );
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->resetFilterCaches();
+ $formats[] = filter_format_load($edit['format']);
+ }
+ list($this->allowed_format, $this->disallowed_format) = $formats;
+ $this->drupalLogout();
+
+ // Create a regular user with access to one of the formats.
+ $this->web_user = $this->drupalCreateUser(array(
+ 'create page content',
+ 'edit any page content',
+ filter_permission_name($this->allowed_format),
+ ));
+
+ // Create an administrative user who has access to use both formats.
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'administer filters',
+ 'create page content',
+ 'edit any page content',
+ filter_permission_name($this->allowed_format),
+ filter_permission_name($this->disallowed_format),
+ ));
+ }
+
+ function testFormatPermissions() {
+ // Make sure that a regular user only has access to the text format they
+ // were granted access to, as well to the fallback format.
+ $this->assertTrue(filter_access($this->allowed_format, $this->web_user), t('A regular user has access to a text format they were granted access to.'));
+ $this->assertFalse(filter_access($this->disallowed_format, $this->web_user), t('A regular user does not have access to a text format they were not granted access to.'));
+ $this->assertTrue(filter_access(filter_format_load(filter_fallback_format()), $this->web_user), t('A regular user has access to the fallback format.'));
+
+ // Perform similar checks as above, but now against the entire list of
+ // available formats for this user.
+ $this->assertTrue(in_array($this->allowed_format->format, array_keys(filter_formats($this->web_user))), t('The allowed format appears in the list of available formats for a regular user.'));
+ $this->assertFalse(in_array($this->disallowed_format->format, array_keys(filter_formats($this->web_user))), t('The disallowed format does not appear in the list of available formats for a regular user.'));
+ $this->assertTrue(in_array(filter_fallback_format(), array_keys(filter_formats($this->web_user))), t('The fallback format appears in the list of available formats for a regular user.'));
+
+ // Make sure that a regular user only has permission to use the format
+ // they were granted access to.
+ $this->assertTrue(user_access(filter_permission_name($this->allowed_format), $this->web_user), t('A regular user has permission to use the allowed text format.'));
+ $this->assertFalse(user_access(filter_permission_name($this->disallowed_format), $this->web_user), t('A regular user does not have permission to use the disallowed text format.'));
+
+ // Make sure that the allowed format appears on the node form and that
+ // the disallowed format does not.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/add/page');
+ $langcode = LANGUAGE_NONE;
+ $elements = $this->xpath('//select[@name=:name]/option', array(
+ ':name' => "body[$langcode][0][format]",
+ ':option' => $this->allowed_format->format,
+ ));
+ $options = array();
+ foreach ($elements as $element) {
+ $options[(string) $element['value']] = $element;
+ }
+ $this->assertTrue(isset($options[$this->allowed_format->format]), t('The allowed text format appears as an option when adding a new node.'));
+ $this->assertFalse(isset($options[$this->disallowed_format->format]), t('The disallowed text format does not appear as an option when adding a new node.'));
+ $this->assertTrue(isset($options[filter_fallback_format()]), t('The fallback format appears as an option when adding a new node.'));
+ }
+
+ function testFormatRoles() {
+ // Get the role ID assigned to the regular user; it must be the maximum.
+ $rid = max(array_keys($this->web_user->roles));
+
+ // Check that this role appears in the list of roles that have access to an
+ // allowed text format, but does not appear in the list of roles that have
+ // access to a disallowed text format.
+ $this->assertTrue(in_array($rid, array_keys(filter_get_roles_by_format($this->allowed_format))), t('A role which has access to a text format appears in the list of roles that have access to that format.'));
+ $this->assertFalse(in_array($rid, array_keys(filter_get_roles_by_format($this->disallowed_format))), t('A role which does not have access to a text format does not appear in the list of roles that have access to that format.'));
+
+ // Check that the correct text format appears in the list of formats
+ // available to that role.
+ $this->assertTrue(in_array($this->allowed_format->format, array_keys(filter_get_formats_by_role($rid))), t('A text format which a role has access to appears in the list of formats available to that role.'));
+ $this->assertFalse(in_array($this->disallowed_format->format, array_keys(filter_get_formats_by_role($rid))), t('A text format which a role does not have access to does not appear in the list of formats available to that role.'));
+
+ // Check that the fallback format is always allowed.
+ $this->assertEqual(filter_get_roles_by_format(filter_format_load(filter_fallback_format())), user_roles(), t('All roles have access to the fallback format.'));
+ $this->assertTrue(in_array(filter_fallback_format(), array_keys(filter_get_formats_by_role($rid))), t('The fallback format appears in the list of allowed formats for any role.'));
+ }
+
+ /**
+ * Test editing a page using a disallowed text format.
+ *
+ * Verifies that regular users and administrators are able to edit a page,
+ * but not allowed to change the fields which use an inaccessible text
+ * format. Also verifies that fields which use a text format that does not
+ * exist can be edited by administrators only, but that the administrator is
+ * forced to choose a new format before saving the page.
+ */
+ function testFormatWidgetPermissions() {
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $body_value_key = "body[$langcode][0][value]";
+ $body_format_key = "body[$langcode][0][format]";
+
+ // Create node to edit.
+ $this->drupalLogin($this->admin_user);
+ $edit = array();
+ $edit['title'] = $this->randomName(8);
+ $edit[$body_value_key] = $this->randomName(16);
+ $edit[$body_format_key] = $this->disallowed_format->format;
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+
+ // Try to edit with a less privileged user.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $node->nid);
+ $this->clickLink(t('Edit'));
+
+ // Verify that body field is read-only and contains replacement value.
+ $this->assertFieldByXPath("//textarea[@name='$body_value_key' and @disabled='disabled']", t('This field has been disabled because you do not have sufficient permissions to edit it.'), t('Text format access denied message found.'));
+
+ // Verify that title can be changed, but preview displays original body.
+ $new_edit = array();
+ $new_edit['title'] = $this->randomName(8);
+ $this->drupalPost(NULL, $new_edit, t('Preview'));
+ $this->assertText($edit[$body_value_key], t('Old body found in preview.'));
+
+ // Save and verify that only the title was changed.
+ $this->drupalPost(NULL, $new_edit, t('Save'));
+ $this->assertNoText($edit['title'], t('Old title not found.'));
+ $this->assertText($new_edit['title'], t('New title found.'));
+ $this->assertText($edit[$body_value_key], t('Old body found.'));
+
+ // Check that even an administrator with "administer filters" permission
+ // cannot edit the body field if they do not have specific permission to
+ // use its stored format. (This must be disallowed so that the
+ // administrator is never forced to switch the text format to something
+ // else.)
+ $this->drupalLogin($this->filter_admin_user);
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertFieldByXPath("//textarea[@name='$body_value_key' and @disabled='disabled']", t('This field has been disabled because you do not have sufficient permissions to edit it.'), t('Text format access denied message found.'));
+
+ // Disable the text format used above.
+ filter_format_disable($this->disallowed_format);
+ $this->resetFilterCaches();
+
+ // Log back in as the less privileged user and verify that the body field
+ // is still disabled, since the less privileged user should not be able to
+ // edit content that does not have an assigned format.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertFieldByXPath("//textarea[@name='$body_value_key' and @disabled='disabled']", t('This field has been disabled because you do not have sufficient permissions to edit it.'), t('Text format access denied message found.'));
+
+ // Log back in as the filter administrator and verify that the body field
+ // can be edited.
+ $this->drupalLogin($this->filter_admin_user);
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertNoFieldByXPath("//textarea[@name='$body_value_key' and @disabled='disabled']", NULL, t('Text format access denied message not found.'));
+ $this->assertFieldByXPath("//select[@name='$body_format_key']", NULL, t('Text format selector found.'));
+
+ // Verify that trying to save the node without selecting a new text format
+ // produces an error message, and does not result in the node being saved.
+ $old_title = $new_edit['title'];
+ $new_title = $this->randomName(8);
+ $edit = array('title' => $new_title);
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertText(t('!name field is required.', array('!name' => t('Text format'))), t('Error message is displayed.'));
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($old_title, t('Old title found.'));
+ $this->assertNoText($new_title, t('New title not found.'));
+
+ // Now select a new text format and make sure the node can be saved.
+ $edit[$body_format_key] = filter_fallback_format();
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertUrl('node/' . $node->nid);
+ $this->assertText($new_title, t('New title found.'));
+ $this->assertNoText($old_title, t('Old title not found.'));
+
+ // Switch the text format to a new one, then disable that format and all
+ // other formats on the site (leaving only the fallback format).
+ $this->drupalLogin($this->admin_user);
+ $edit = array($body_format_key => $this->allowed_format->format);
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertUrl('node/' . $node->nid);
+ foreach (filter_formats() as $format) {
+ if ($format->format != filter_fallback_format()) {
+ filter_format_disable($format);
+ }
+ }
+
+ // Since there is now only one available text format, the widget for
+ // selecting a text format would normally not display when the content is
+ // edited. However, we need to verify that the filter administrator still
+ // is forced to make a conscious choice to reassign the text to a different
+ // format.
+ $this->drupalLogin($this->filter_admin_user);
+ $old_title = $new_title;
+ $new_title = $this->randomName(8);
+ $edit = array('title' => $new_title);
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertText(t('!name field is required.', array('!name' => t('Text format'))), t('Error message is displayed.'));
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($old_title, t('Old title found.'));
+ $this->assertNoText($new_title, t('New title not found.'));
+ $edit[$body_format_key] = filter_fallback_format();
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertUrl('node/' . $node->nid);
+ $this->assertText($new_title, t('New title found.'));
+ $this->assertNoText($old_title, t('Old title not found.'));
+ }
+
+ /**
+ * Rebuild text format and permission caches in the thread running the tests.
+ */
+ protected function resetFilterCaches() {
+ filter_formats_reset();
+ $this->checkPermissions(array(), TRUE);
+ }
+}
+
+class FilterDefaultFormatTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Default text format functionality',
+ 'description' => 'Test the default text formats for different users.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function testDefaultTextFormats() {
+ // Create two text formats, and two users. The first user has access to
+ // both formats, but the second user only has access to the second one.
+ $admin_user = $this->drupalCreateUser(array('administer filters'));
+ $this->drupalLogin($admin_user);
+ $formats = array();
+ for ($i = 0; $i < 2; $i++) {
+ $edit = array(
+ 'format' => drupal_strtolower($this->randomName()),
+ 'name' => $this->randomName(),
+ );
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->resetFilterCaches();
+ $formats[] = filter_format_load($edit['format']);
+ }
+ list($first_format, $second_format) = $formats;
+ $first_user = $this->drupalCreateUser(array(filter_permission_name($first_format), filter_permission_name($second_format)));
+ $second_user = $this->drupalCreateUser(array(filter_permission_name($second_format)));
+
+ // Adjust the weights so that the first and second formats (in that order)
+ // are the two lowest weighted formats available to any user.
+ $minimum_weight = db_query("SELECT MIN(weight) FROM {filter_format}")->fetchField();
+ $edit = array();
+ $edit['formats[' . $first_format->format . '][weight]'] = $minimum_weight - 2;
+ $edit['formats[' . $second_format->format . '][weight]'] = $minimum_weight - 1;
+ $this->drupalPost('admin/config/content/formats', $edit, t('Save changes'));
+ $this->resetFilterCaches();
+
+ // Check that each user's default format is the lowest weighted format that
+ // the user has access to.
+ $this->assertEqual(filter_default_format($first_user), $first_format->format, t("The first user's default format is the lowest weighted format that the user has access to."));
+ $this->assertEqual(filter_default_format($second_user), $second_format->format, t("The second user's default format is the lowest weighted format that the user has access to, and is different than the first user's."));
+
+ // Reorder the two formats, and check that both users now have the same
+ // default.
+ $edit = array();
+ $edit['formats[' . $second_format->format . '][weight]'] = $minimum_weight - 3;
+ $this->drupalPost('admin/config/content/formats', $edit, t('Save changes'));
+ $this->resetFilterCaches();
+ $this->assertEqual(filter_default_format($first_user), filter_default_format($second_user), t('After the formats are reordered, both users have the same default format.'));
+ }
+
+ /**
+ * Rebuild text format and permission caches in the thread running the tests.
+ */
+ protected function resetFilterCaches() {
+ filter_formats_reset();
+ $this->checkPermissions(array(), TRUE);
+ }
+}
+
+class FilterNoFormatTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unassigned text format functionality',
+ 'description' => 'Test the behavior of check_markup() when it is called without a text format.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function testCheckMarkupNoFormat() {
+ // Create some text. Include some HTML and line breaks, so we get a good
+ // test of the filtering that is applied to it.
+ $text = "<strong>" . $this->randomName(32) . "</strong>\n\n<div>" . $this->randomName(32) . "</div>";
+
+ // Make sure that when this text is run through check_markup() with no text
+ // format, it is filtered as though it is in the fallback format.
+ $this->assertEqual(check_markup($text), check_markup($text, filter_fallback_format()), t('Text with no format is filtered the same as text in the fallback format.'));
+ }
+}
+
+/**
+ * Security tests for missing/vanished text formats or filters.
+ */
+class FilterSecurityTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Security',
+ 'description' => 'Test the behavior of check_markup() when a filter or text format vanishes.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('php', 'filter_test');
+ $this->admin_user = $this->drupalCreateUser(array('administer modules', 'administer filters', 'administer site configuration'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Test that filtered content is emptied when an actively used filter module is disabled.
+ */
+ function testDisableFilterModule() {
+ // Create a new node.
+ $node = $this->drupalCreateNode(array('promote' => 1));
+ $body_raw = $node->body[LANGUAGE_NONE][0]['value'];
+ $format_id = $node->body[LANGUAGE_NONE][0]['format'];
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($body_raw, t('Node body found.'));
+
+ // Enable the filter_test_replace filter.
+ $edit = array(
+ 'filters[filter_test_replace][status]' => 1,
+ );
+ $this->drupalPost('admin/config/content/formats/' . $format_id, $edit, t('Save configuration'));
+
+ // Verify that filter_test_replace filter replaced the content.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertNoText($body_raw, t('Node body not found.'));
+ $this->assertText('Filter: Testing filter', t('Testing filter output found.'));
+
+ // Disable the text format entirely.
+ $this->drupalPost('admin/config/content/formats/' . $format_id . '/disable', array(), t('Disable'));
+
+ // Verify that the content is empty, because the text format does not exist.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertNoText($body_raw, t('Node body not found.'));
+ }
+}
+
+/**
+ * Unit tests for core filters.
+ */
+class FilterUnitTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filter module filters',
+ 'description' => 'Tests Filter module filters individually.',
+ 'group' => 'Filter',
+ );
+ }
+
+ /**
+ * Test the line break filter.
+ */
+ function testLineBreakFilter() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->callback = '_filter_autop';
+
+ // Since the line break filter naturally needs plenty of newlines in test
+ // strings and expectations, we're using "\n" instead of regular newlines
+ // here.
+ $tests = array(
+ // Single line breaks should be changed to <br /> tags, while paragraphs
+ // separated with double line breaks should be enclosed with <p></p> tags.
+ "aaa\nbbb\n\nccc" => array(
+ "<p>aaa<br />\nbbb</p>\n<p>ccc</p>" => TRUE,
+ ),
+ // Skip contents of certain block tags entirely.
+ "<script>aaa\nbbb\n\nccc</script>
+<style>aaa\nbbb\n\nccc</style>
+<pre>aaa\nbbb\n\nccc</pre>
+<object>aaa\nbbb\n\nccc</object>
+<iframe>aaa\nbbb\n\nccc</iframe>
+" => array(
+ "<script>aaa\nbbb\n\nccc</script>" => TRUE,
+ "<style>aaa\nbbb\n\nccc</style>" => TRUE,
+ "<pre>aaa\nbbb\n\nccc</pre>" => TRUE,
+ "<object>aaa\nbbb\n\nccc</object>" => TRUE,
+ "<iframe>aaa\nbbb\n\nccc</iframe>" => TRUE,
+ ),
+ // Skip comments entirely.
+ "One. <!-- comment --> Two.\n<!--\nThree.\n-->\n" => array(
+ '<!-- comment -->' => TRUE,
+ "<!--\nThree.\n-->" => TRUE,
+ ),
+ // Resulting HTML should produce matching paragraph tags.
+ '<p><div> </div></p>' => array(
+ "<p>\n<div> </div>\n</p>" => TRUE,
+ ),
+ '<div><p> </p></div>' => array(
+ "<div>\n</div>" => TRUE,
+ ),
+ '<blockquote><pre>aaa</pre></blockquote>' => array(
+ "<blockquote><pre>aaa</pre></blockquote>" => TRUE,
+ ),
+ "<pre>aaa\nbbb\nccc</pre>\nddd\neee" => array(
+ "<pre>aaa\nbbb\nccc</pre>" => TRUE,
+ "<p>ddd<br />\neee</p>" => TRUE,
+ ),
+ // Comments remain unchanged and subsequent lines/paragraphs are
+ // transformed normally.
+ "aaa<!--comment-->\n\nbbb\n\nccc\n\nddd<!--comment\nwith linebreak-->\n\neee\n\nfff" => array(
+ "<p>aaa</p>\n<!--comment--><p>\nbbb</p>\n<p>ccc</p>\n<p>ddd</p>" => TRUE,
+ "<!--comment\nwith linebreak--><p>\neee</p>\n<p>fff</p>" => TRUE,
+ ),
+ // Check that a comment in a PRE will result that the text after
+ // the comment, but still in PRE, is not transformed.
+ "<pre>aaa\nbbb<!-- comment -->\n\nccc</pre>\nddd" => array(
+ "<pre>aaa\nbbb<!-- comment -->\n\nccc</pre>" => TRUE,
+ ),
+ // Bug 810824, paragraphs were appearing around iframe tags.
+ "<iframe>aaa</iframe>\n\n" => array(
+ "<p><iframe>aaa</iframe></p>" => FALSE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+
+ // Very long string hitting PCRE limits.
+ $limit = max(ini_get('pcre.backtrack_limit'), ini_get('pcre.recursion_limit'));
+ $source = $this->randomName($limit);
+ $result = _filter_autop($source);
+ $success = $this->assertEqual($result, '<p>' . $source . "</p>\n", t('Line break filter can process very long strings.'));
+ if (!$success) {
+ $this->verbose("\n" . $source . "\n<hr />\n" . $result);
+ }
+ }
+
+ /**
+ * Tests limiting allowed tags and XSS prevention.
+ *
+ * XSS tests assume that script is disallowed by default and src is allowed
+ * by default, but on* and style attributes are disallowed.
+ *
+ * Script injection vectors mostly adopted from http://ha.ckers.org/xss.html.
+ *
+ * Relevant CVEs:
+ * - CVE-2002-1806, ~CVE-2005-0682, ~CVE-2005-2106, CVE-2005-3973,
+ * CVE-2006-1226 (= rev. 1.112?), CVE-2008-0273, CVE-2008-3740.
+ */
+ function testFilterXSS() {
+ // Tag stripping, different ways to work around removal of HTML tags.
+ $f = filter_xss('<script>alert(0)</script>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping -- simple script without special characters.'));
+
+ $f = filter_xss('<script src="http://www.example.com" />');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping -- empty script with source.'));
+
+ $f = filter_xss('<ScRipt sRc=http://www.example.com/>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- varying case.'));
+
+ $f = filter_xss("<script\nsrc\n=\nhttp://www.example.com/\n>");
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- multiline tag.'));
+
+ $f = filter_xss('<script/a src=http://www.example.com/a.js></script>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- non whitespace character after tag name.'));
+
+ $f = filter_xss('<script/src=http://www.example.com/a.js></script>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- no space between tag and attribute.'));
+
+ // Null between < and tag name works at least with IE6.
+ $f = filter_xss("<\0scr\0ipt>alert(0)</script>");
+ $this->assertNoNormalized($f, 'ipt', t('HTML tag stripping evasion -- breaking HTML with nulls.'));
+
+ $f = filter_xss("<scrscriptipt src=http://www.example.com/a.js>");
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- filter just removing "script".'));
+
+ $f = filter_xss('<<script>alert(0);//<</script>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- double opening brackets.'));
+
+ $f = filter_xss('<script src=http://www.example.com/a.js?<b>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- no closing tag.'));
+
+ // DRUPAL-SA-2008-047: This doesn't seem exploitable, but the filter should
+ // work consistently.
+ $f = filter_xss('<script>>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- double closing tag.'));
+
+ $f = filter_xss('<script src=//www.example.com/.a>');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- no scheme or ending slash.'));
+
+ $f = filter_xss('<script src=http://www.example.com/.a');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- no closing bracket.'));
+
+ $f = filter_xss('<script src=http://www.example.com/ <');
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- opening instead of closing bracket.'));
+
+ $f = filter_xss('<nosuchtag attribute="newScriptInjectionVector">');
+ $this->assertNoNormalized($f, 'nosuchtag', t('HTML tag stripping evasion -- unknown tag.'));
+
+ $f = filter_xss('<?xml:namespace ns="urn:schemas-microsoft-com:time">');
+ $this->assertTrue(stripos($f, '<?xml') === FALSE, t('HTML tag stripping evasion -- starting with a question sign (processing instructions).'));
+
+ $f = filter_xss('<t:set attributeName="innerHTML" to="&lt;script defer&gt;alert(0)&lt;/script&gt;">');
+ $this->assertNoNormalized($f, 't:set', t('HTML tag stripping evasion -- colon in the tag name (namespaces\' tricks).'));
+
+ $f = filter_xss('<img """><script>alert(0)</script>', array('img'));
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- a malformed image tag.'));
+
+ $f = filter_xss('<blockquote><script>alert(0)</script></blockquote>', array('blockquote'));
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- script in a blockqoute.'));
+
+ $f = filter_xss("<!--[if true]><script>alert(0)</script><![endif]-->");
+ $this->assertNoNormalized($f, 'script', t('HTML tag stripping evasion -- script within a comment.'));
+
+ // Dangerous attributes removal.
+ $f = filter_xss('<p onmouseover="http://www.example.com/">', array('p'));
+ $this->assertNoNormalized($f, 'onmouseover', t('HTML filter attributes removal -- events, no evasion.'));
+
+ $f = filter_xss('<li style="list-style-image: url(javascript:alert(0))">', array('li'));
+ $this->assertNoNormalized($f, 'style', t('HTML filter attributes removal -- style, no evasion.'));
+
+ $f = filter_xss('<img onerror =alert(0)>', array('img'));
+ $this->assertNoNormalized($f, 'onerror', t('HTML filter attributes removal evasion -- spaces before equals sign.'));
+
+ $f = filter_xss('<img onabort!#$%&()*~+-_.,:;?@[/|\]^`=alert(0)>', array('img'));
+ $this->assertNoNormalized($f, 'onabort', t('HTML filter attributes removal evasion -- non alphanumeric characters before equals sign.'));
+
+ $f = filter_xss('<img oNmediAError=alert(0)>', array('img'));
+ $this->assertNoNormalized($f, 'onmediaerror', t('HTML filter attributes removal evasion -- varying case.'));
+
+ // Works at least with IE6.
+ $f = filter_xss("<img o\0nfocus\0=alert(0)>", array('img'));
+ $this->assertNoNormalized($f, 'focus', t('HTML filter attributes removal evasion -- breaking with nulls.'));
+
+ // Only whitelisted scheme names allowed in attributes.
+ $f = filter_xss('<img src="javascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing -- no evasion.'));
+
+ $f = filter_xss('<img src=javascript:alert(0)>', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- no quotes.'));
+
+ // A bit like CVE-2006-0070.
+ $f = filter_xss('<img src="javascript:confirm(0)">', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- no alert ;)'));
+
+ $f = filter_xss('<img src=`javascript:alert(0)`>', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- grave accents.'));
+
+ $f = filter_xss('<img dynsrc="javascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing -- rare attribute.'));
+
+ $f = filter_xss('<table background="javascript:alert(0)">', array('table'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing -- another tag.'));
+
+ $f = filter_xss('<base href="javascript:alert(0);//">', array('base'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing -- one more attribute and tag.'));
+
+ $f = filter_xss('<img src="jaVaSCriPt:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- varying case.'));
+
+ $f = filter_xss('<img src=&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#97;&#108;&#101;&#114;&#116;&#40;&#48;&#41;>', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- UTF-8 decimal encoding.'));
+
+ $f = filter_xss('<img src=&#00000106&#0000097&#00000118&#0000097&#00000115&#0000099&#00000114&#00000105&#00000112&#00000116&#0000058&#0000097&#00000108&#00000101&#00000114&#00000116&#0000040&#0000048&#0000041>', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- long UTF-8 encoding.'));
+
+ $f = filter_xss('<img src=&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69&#x70&#x74&#x3A&#x61&#x6C&#x65&#x72&#x74&#x28&#x30&#x29>', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- UTF-8 hex encoding.'));
+
+ $f = filter_xss("<img src=\"jav\tascript:alert(0)\">", array('img'));
+ $this->assertNoNormalized($f, 'script', t('HTML scheme clearing evasion -- an embedded tab.'));
+
+ $f = filter_xss('<img src="jav&#x09;ascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'script', t('HTML scheme clearing evasion -- an encoded, embedded tab.'));
+
+ $f = filter_xss('<img src="jav&#x000000A;ascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'script', t('HTML scheme clearing evasion -- an encoded, embedded newline.'));
+
+ // With &#xD; this test would fail, but the entity gets turned into
+ // &amp;#xD;, so it's OK.
+ $f = filter_xss('<img src="jav&#x0D;ascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'script', t('HTML scheme clearing evasion -- an encoded, embedded carriage return.'));
+
+ $f = filter_xss("<img src=\"\n\n\nj\na\nva\ns\ncript:alert(0)\">", array('img'));
+ $this->assertNoNormalized($f, 'cript', t('HTML scheme clearing evasion -- broken into many lines.'));
+
+ $f = filter_xss("<img src=\"jav\0a\0\0cript:alert(0)\">", array('img'));
+ $this->assertNoNormalized($f, 'cript', t('HTML scheme clearing evasion -- embedded nulls.'));
+
+ $f = filter_xss('<img src=" &#14; javascript:alert(0)">', array('img'));
+ $this->assertNoNormalized($f, 'javascript', t('HTML scheme clearing evasion -- spaces and metacharacters before scheme.'));
+
+ $f = filter_xss('<img src="vbscript:msgbox(0)">', array('img'));
+ $this->assertNoNormalized($f, 'vbscript', t('HTML scheme clearing evasion -- another scheme.'));
+
+ $f = filter_xss('<img src="nosuchscheme:notice(0)">', array('img'));
+ $this->assertNoNormalized($f, 'nosuchscheme', t('HTML scheme clearing evasion -- unknown scheme.'));
+
+ // Netscape 4.x javascript entities.
+ $f = filter_xss('<br size="&{alert(0)}">', array('br'));
+ $this->assertNoNormalized($f, 'alert', t('Netscape 4.x javascript entities.'));
+
+ // DRUPAL-SA-2008-006: Invalid UTF-8, these only work as reflected XSS with
+ // Internet Explorer 6.
+ $f = filter_xss("<p arg=\"\xe0\">\" style=\"background-image: url(javascript:alert(0));\"\xe0<p>", array('p'));
+ $this->assertNoNormalized($f, 'style', t('HTML filter -- invalid UTF-8.'));
+
+ $f = filter_xss("\xc0aaa");
+ $this->assertEqual($f, '', t('HTML filter -- overlong UTF-8 sequences.'));
+
+ $f = filter_xss("Who&#039;s Online");
+ $this->assertNormalized($f, "who's online", t('HTML filter -- html entity number'));
+
+ $f = filter_xss("Who&amp;#039;s Online");
+ $this->assertNormalized($f, "who&#039;s online", t('HTML filter -- encoded html entity number'));
+
+ $f = filter_xss("Who&amp;amp;#039; Online");
+ $this->assertNormalized($f, "who&amp;#039; online", t('HTML filter -- double encoded html entity number'));
+ }
+
+ /**
+ * Test filter settings, defaults, access restrictions and similar.
+ *
+ * @todo This is for functions like filter_filter and check_markup, whose
+ * functionality is not completely focused on filtering. Some ideas:
+ * restricting formats according to user permissions, proper cache
+ * handling, defaults -- allowed tags/attributes/protocols.
+ *
+ * @todo It is possible to add script, iframe etc. to allowed tags, but this
+ * makes HTML filter completely ineffective.
+ *
+ * @todo Class, id, name and xmlns should be added to disallowed attributes,
+ * or better a whitelist approach should be used for that too.
+ */
+ function testHtmlFilter() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->settings = array(
+ 'allowed_html' => '<a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd>',
+ 'filter_html_help' => 1,
+ 'filter_html_nofollow' => 0,
+ );
+
+ // HTML filter is not able to secure some tags, these should never be
+ // allowed.
+ $f = _filter_html('<script />', $filter);
+ $this->assertNoNormalized($f, 'script', t('HTML filter should always remove script tags.'));
+
+ $f = _filter_html('<iframe />', $filter);
+ $this->assertNoNormalized($f, 'iframe', t('HTML filter should always remove iframe tags.'));
+
+ $f = _filter_html('<object />', $filter);
+ $this->assertNoNormalized($f, 'object', t('HTML filter should always remove object tags.'));
+
+ $f = _filter_html('<style />', $filter);
+ $this->assertNoNormalized($f, 'style', t('HTML filter should always remove style tags.'));
+
+ // Some tags make CSRF attacks easier, let the user take the risk herself.
+ $f = _filter_html('<img />', $filter);
+ $this->assertNoNormalized($f, 'img', t('HTML filter should remove img tags on default.'));
+
+ $f = _filter_html('<input />', $filter);
+ $this->assertNoNormalized($f, 'img', t('HTML filter should remove input tags on default.'));
+
+ // Filtering content of some attributes is infeasible, these shouldn't be
+ // allowed too.
+ $f = _filter_html('<p style="display: none;" />', $filter);
+ $this->assertNoNormalized($f, 'style', t('HTML filter should remove style attribute on default.'));
+
+ $f = _filter_html('<p onerror="alert(0);" />', $filter);
+ $this->assertNoNormalized($f, 'onerror', t('HTML filter should remove on* attributes on default.'));
+
+ $f = _filter_html('<code onerror>&nbsp;</code>', $filter);
+ $this->assertNoNormalized($f, 'onerror', t('HTML filter should remove empty on* attributes on default.'));
+ }
+
+ /**
+ * Test the spam deterrent.
+ */
+ function testNoFollowFilter() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->settings = array(
+ 'allowed_html' => '<a>',
+ 'filter_html_help' => 1,
+ 'filter_html_nofollow' => 1,
+ );
+
+ // Test if the rel="nofollow" attribute is added, even if we try to prevent
+ // it.
+ $f = _filter_html('<a href="http://www.example.com/">text</a>', $filter);
+ $this->assertNormalized($f, 'rel="nofollow"', t('Spam deterrent -- no evasion.'));
+
+ $f = _filter_html('<A href="http://www.example.com/">text</a>', $filter);
+ $this->assertNormalized($f, 'rel="nofollow"', t('Spam deterrent evasion -- capital A.'));
+
+ $f = _filter_html("<a/href=\"http://www.example.com/\">text</a>", $filter);
+ $this->assertNormalized($f, 'rel="nofollow"', t('Spam deterrent evasion -- non whitespace character after tag name.'));
+
+ $f = _filter_html("<\0a\0 href=\"http://www.example.com/\">text</a>", $filter);
+ $this->assertNormalized($f, 'rel="nofollow"', t('Spam deterrent evasion -- some nulls.'));
+
+ $f = _filter_html('<a href="http://www.example.com/" rel="follow">text</a>', $filter);
+ $this->assertNoNormalized($f, 'rel="follow"', t('Spam deterrent evasion -- with rel set - rel="follow" removed.'));
+ $this->assertNormalized($f, 'rel="nofollow"', t('Spam deterrent evasion -- with rel set - rel="nofollow" added.'));
+ }
+
+ /**
+ * Test the loose, admin HTML filter.
+ */
+ function testFilterXSSAdmin() {
+ // DRUPAL-SA-2008-044
+ $f = filter_xss_admin('<object />');
+ $this->assertNoNormalized($f, 'object', t('Admin HTML filter -- should not allow object tag.'));
+
+ $f = filter_xss_admin('<script />');
+ $this->assertNoNormalized($f, 'script', t('Admin HTML filter -- should not allow script tag.'));
+
+ $f = filter_xss_admin('<style /><iframe /><frame /><frameset /><meta /><link /><embed /><applet /><param /><layer />');
+ $this->assertEqual($f, '', t('Admin HTML filter -- should never allow some tags.'));
+ }
+
+ /**
+ * Tests the HTML escaping filter.
+ *
+ * check_plain() is not tested here.
+ */
+ function testHtmlEscapeFilter() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->callback = '_filter_html_escape';
+
+ $tests = array(
+ " One. <!-- \"comment\" --> Two'.\n<p>Three.</p>\n " => array(
+ "One. &lt;!-- &quot;comment&quot; --&gt; Two&#039;.\n&lt;p&gt;Three.&lt;/p&gt;" => TRUE,
+ ' One.' => FALSE,
+ "</p>\n " => FALSE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+ }
+
+ /**
+ * Tests the URL filter.
+ */
+ function testUrlFilter() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->callback = '_filter_url';
+ $filter->settings = array(
+ 'filter_url_length' => 496,
+ );
+ // @todo Possible categories:
+ // - absolute, mail, partial
+ // - characters/encoding, surrounding markup, security
+
+ // Filter selection/pattern matching.
+ $tests = array(
+ // HTTP URLs.
+ '
+http://example.com or www.example.com
+' => array(
+ '<a href="http://example.com">http://example.com</a>' => TRUE,
+ '<a href="http://www.example.com">www.example.com</a>' => TRUE,
+ ),
+ // MAILTO URLs.
+ '
+person@example.com or mailto:person2@example.com
+' => array(
+ '<a href="mailto:person@example.com">person@example.com</a>' => TRUE,
+ '<a href="mailto:person2@example.com">mailto:person2@example.com</a>' => TRUE,
+ ),
+ // URI parts and special characters.
+ '
+http://trailingslash.com/ or www.trailingslash.com/
+http://host.com/some/path?query=foo&bar[baz]=beer#fragment or www.host.com/some/path?query=foo&bar[baz]=beer#fragment
+http://twitter.com/#!/example/status/22376963142324226
+ftp://user:pass@ftp.example.com/~home/dir1
+sftp://user@nonstandardport:222/dir
+ssh://192.168.0.100/srv/git/drupal.git
+' => array(
+ '<a href="http://trailingslash.com/">http://trailingslash.com/</a>' => TRUE,
+ '<a href="http://www.trailingslash.com/">www.trailingslash.com/</a>' => TRUE,
+ '<a href="http://host.com/some/path?query=foo&amp;bar[baz]=beer#fragment">http://host.com/some/path?query=foo&amp;bar[baz]=beer#fragment</a>' => TRUE,
+ '<a href="http://www.host.com/some/path?query=foo&amp;bar[baz]=beer#fragment">www.host.com/some/path?query=foo&amp;bar[baz]=beer#fragment</a>' => TRUE,
+ '<a href="http://twitter.com/#!/example/status/22376963142324226">http://twitter.com/#!/example/status/22376963142324226</a>' => TRUE,
+ '<a href="ftp://user:pass@ftp.example.com/~home/dir1">ftp://user:pass@ftp.example.com/~home/dir1</a>' => TRUE,
+ '<a href="sftp://user@nonstandardport:222/dir">sftp://user@nonstandardport:222/dir</a>' => TRUE,
+ '<a href="ssh://192.168.0.100/srv/git/drupal.git">ssh://192.168.0.100/srv/git/drupal.git</a>' => TRUE,
+ ),
+ // Encoding.
+ '
+http://ampersand.com/?a=1&b=2
+http://encoded.com/?a=1&amp;b=2
+' => array(
+ '<a href="http://ampersand.com/?a=1&amp;b=2">http://ampersand.com/?a=1&amp;b=2</a>' => TRUE,
+ '<a href="http://encoded.com/?a=1&amp;b=2">http://encoded.com/?a=1&amp;b=2</a>' => TRUE,
+ ),
+ // Domain name length.
+ '
+www.ex.ex or www.example.example or www.toolongdomainexampledomainexampledomainexampledomainexampledomain or
+me@me.tv
+' => array(
+ '<a href="http://www.ex.ex">www.ex.ex</a>' => TRUE,
+ '<a href="http://www.example.example">www.example.example</a>' => TRUE,
+ 'http://www.toolong' => FALSE,
+ '<a href="mailto:me@me.tv">me@me.tv</a>' => TRUE,
+ ),
+ // Absolute URL protocols.
+ // The list to test is found in the beginning of _filter_url() at
+ // $protocols = variable_get('filter_allowed_protocols'... (approx line 1325).
+ '
+https://example.com,
+ftp://ftp.example.com,
+news://example.net,
+telnet://example,
+irc://example.host,
+ssh://odd.geek,
+sftp://secure.host?,
+webcal://calendar,
+rtsp://127.0.0.1,
+not foo://disallowed.com.
+' => array(
+ 'href="https://example.com"' => TRUE,
+ 'href="ftp://ftp.example.com"' => TRUE,
+ 'href="news://example.net"' => TRUE,
+ 'href="telnet://example"' => TRUE,
+ 'href="irc://example.host"' => TRUE,
+ 'href="ssh://odd.geek"' => TRUE,
+ 'href="sftp://secure.host"' => TRUE,
+ 'href="webcal://calendar"' => TRUE,
+ 'href="rtsp://127.0.0.1"' => TRUE,
+ 'href="foo://disallowed.com"' => FALSE,
+ 'not foo://disallowed.com.' => TRUE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+
+ // Surrounding text/punctuation.
+ $tests = array(
+ '
+Partial URL with trailing period www.partial.com.
+E-mail with trailing comma person@example.com,
+Absolute URL with trailing question http://www.absolute.com?
+Query string with trailing exclamation www.query.com/index.php?a=!
+Partial URL with 3 trailing www.partial.periods...
+E-mail with 3 trailing exclamations@example.com!!!
+Absolute URL and query string with 2 different punctuation characters (http://www.example.com/q=abc).
+' => array(
+ 'period <a href="http://www.partial.com">www.partial.com</a>.' => TRUE,
+ 'comma <a href="mailto:person@example.com">person@example.com</a>,' => TRUE,
+ 'question <a href="http://www.absolute.com">http://www.absolute.com</a>?' => TRUE,
+ 'exclamation <a href="http://www.query.com/index.php?a=">www.query.com/index.php?a=</a>!' => TRUE,
+ 'trailing <a href="http://www.partial.periods">www.partial.periods</a>...' => TRUE,
+ 'trailing <a href="mailto:exclamations@example.com">exclamations@example.com</a>!!!' => TRUE,
+ 'characters (<a href="http://www.example.com/q=abc">http://www.example.com/q=abc</a>).' => TRUE,
+ ),
+ '
+(www.parenthesis.com/dir?a=1&b=2#a)
+' => array(
+ '(<a href="http://www.parenthesis.com/dir?a=1&amp;b=2#a">www.parenthesis.com/dir?a=1&amp;b=2#a</a>)' => TRUE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+
+ // Surrounding markup.
+ $tests = array(
+ '
+<p xmlns="www.namespace.com" />
+<p xmlns="http://namespace.com">
+An <a href="http://example.com" title="Read more at www.example.info...">anchor</a>.
+</p>
+' => array(
+ '<p xmlns="www.namespace.com" />' => TRUE,
+ '<p xmlns="http://namespace.com">' => TRUE,
+ 'href="http://www.namespace.com"' => FALSE,
+ 'href="http://namespace.com"' => FALSE,
+ 'An <a href="http://example.com" title="Read more at www.example.info...">anchor</a>.' => TRUE,
+ ),
+ '
+Not <a href="foo">www.relative.com</a> or <a href="http://absolute.com">www.absolute.com</a>
+but <strong>http://www.strong.net</strong> or <em>www.emphasis.info</em>
+' => array(
+ '<a href="foo">www.relative.com</a>' => TRUE,
+ 'href="http://www.relative.com"' => FALSE,
+ '<a href="http://absolute.com">www.absolute.com</a>' => TRUE,
+ '<strong><a href="http://www.strong.net">http://www.strong.net</a></strong>' => TRUE,
+ '<em><a href="http://www.emphasis.info">www.emphasis.info</a></em>' => TRUE,
+ ),
+ '
+Test <code>using www.example.com the code tag</code>.
+' => array(
+ 'href' => FALSE,
+ 'http' => FALSE,
+ ),
+ '
+Intro.
+<blockquote>
+Quoted text linking to www.example.com, written by person@example.com, originating from http://origin.example.com. <code>@see www.usage.example.com or <em>www.example.info</em> bla bla</code>.
+</blockquote>
+
+Outro.
+' => array(
+ 'href="http://www.example.com"' => TRUE,
+ 'href="mailto:person@example.com"' => TRUE,
+ 'href="http://origin.example.com"' => TRUE,
+ 'http://www.usage.example.com' => FALSE,
+ 'http://www.example.info' => FALSE,
+ 'Intro.' => TRUE,
+ 'Outro.' => TRUE,
+ ),
+ '
+Unknown tag <x>containing x and www.example.com</x>? And a tag <pooh>beginning with p and containing www.example.pooh with p?</pooh>
+' => array(
+ 'href="http://www.example.com"' => TRUE,
+ 'href="http://www.example.pooh"' => TRUE,
+ ),
+ '
+<p>Test &lt;br/&gt;: This is a www.example17.com example <strong>with</strong> various http://www.example18.com tags. *<br/>
+ It is important www.example19.com to *<br/>test different URLs and http://www.example20.com in the same paragraph. *<br>
+HTML www.example21.com soup by person@example22.com can litererally http://www.example23.com contain *img*<img> anything. Just a www.example24.com with http://www.example25.com thrown in. www.example26.com from person@example27.com with extra http://www.example28.com.
+' => array(
+ 'href="http://www.example17.com"' => TRUE,
+ 'href="http://www.example18.com"' => TRUE,
+ 'href="http://www.example19.com"' => TRUE,
+ 'href="http://www.example20.com"' => TRUE,
+ 'href="http://www.example21.com"' => TRUE,
+ 'href="mailto:person@example22.com"' => TRUE,
+ 'href="http://www.example23.com"' => TRUE,
+ 'href="http://www.example24.com"' => TRUE,
+ 'href="http://www.example25.com"' => TRUE,
+ 'href="http://www.example26.com"' => TRUE,
+ 'href="mailto:person@example27.com"' => TRUE,
+ 'href="http://www.example28.com"' => TRUE,
+ ),
+ '
+<script>
+<!--
+ // @see www.example.com
+ var exampleurl = "http://example.net";
+-->
+<!--//--><![CDATA[//><!--
+ // @see www.example.com
+ var exampleurl = "http://example.net";
+//--><!]]>
+</script>
+' => array(
+ 'href="http://www.example.com"' => FALSE,
+ 'href="http://example.net"' => FALSE,
+ ),
+ '
+<style>body {
+ background: url(http://example.com/pixel.gif);
+}</style>
+' => array(
+ 'href' => FALSE,
+ ),
+ '
+<!-- Skip any URLs like www.example.com in comments -->
+' => array(
+ 'href' => FALSE,
+ ),
+ '
+<!-- Skip any URLs like
+www.example.com with a newline in comments -->
+' => array(
+ 'href' => FALSE,
+ ),
+ '
+<!-- Skip any URLs like www.comment.com in comments. <p>Also ignore http://commented.out/markup.</p> -->
+' => array(
+ 'href' => FALSE,
+ ),
+ '
+<dl>
+<dt>www.example.com</dt>
+<dd>http://example.com</dd>
+<dd>person@example.com</dd>
+<dt>Check www.example.net</dt>
+<dd>Some text around http://www.example.info by person@example.info?</dd>
+</dl>
+' => array(
+ 'href="http://www.example.com"' => TRUE,
+ 'href="http://example.com"' => TRUE,
+ 'href="mailto:person@example.com"' => TRUE,
+ 'href="http://www.example.net"' => TRUE,
+ 'href="http://www.example.info"' => TRUE,
+ 'href="mailto:person@example.info"' => TRUE,
+ ),
+ '
+<div>www.div.com</div>
+<ul>
+<li>http://listitem.com</li>
+<li class="odd">www.class.listitem.com</li>
+</ul>
+' => array(
+ '<div><a href="http://www.div.com">www.div.com</a></div>' => TRUE,
+ '<li><a href="http://listitem.com">http://listitem.com</a></li>' => TRUE,
+ '<li class="odd"><a href="http://www.class.listitem.com">www.class.listitem.com</a></li>' => TRUE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+
+ // URL trimming.
+ $filter->settings['filter_url_length'] = 20;
+ $tests = array(
+ 'www.trimmed.com/d/ff.ext?a=1&b=2#a1' => array(
+ '<a href="http://www.trimmed.com/d/ff.ext?a=1&amp;b=2#a1">www.trimmed.com/d/ff...</a>' => TRUE,
+ ),
+ );
+ $this->assertFilteredString($filter, $tests);
+ }
+
+ /**
+ * Asserts multiple filter output expectations for multiple input strings.
+ *
+ * @param $filter
+ * A input filter object.
+ * @param $tests
+ * An associative array, whereas each key is an arbitrary input string and
+ * each value is again an associative array whose keys are filter output
+ * strings and whose values are Booleans indicating whether the output is
+ * expected or not.
+ *
+ * For example:
+ * @code
+ * $tests = array(
+ * 'Input string' => array(
+ * '<p>Input string</p>' => TRUE,
+ * 'Input string<br' => FALSE,
+ * ),
+ * );
+ * @endcode
+ */
+ function assertFilteredString($filter, $tests) {
+ foreach ($tests as $source => $tasks) {
+ $function = $filter->callback;
+ $result = $function($source, $filter);
+ foreach ($tasks as $value => $is_expected) {
+ // Not using assertIdentical, since combination with strpos() is hard to grok.
+ if ($is_expected) {
+ $success = $this->assertTrue(strpos($result, $value) !== FALSE, t('@source: @value found.', array(
+ '@source' => var_export($source, TRUE),
+ '@value' => var_export($value, TRUE),
+ )));
+ }
+ else {
+ $success = $this->assertTrue(strpos($result, $value) === FALSE, t('@source: @value not found.', array(
+ '@source' => var_export($source, TRUE),
+ '@value' => var_export($value, TRUE),
+ )));
+ }
+ if (!$success) {
+ $this->verbose('Source:<pre>' . check_plain(var_export($source, TRUE)) . '</pre>'
+ . '<hr />' . 'Result:<pre>' . check_plain(var_export($result, TRUE)) . '</pre>'
+ . '<hr />' . ($is_expected ? 'Expected:' : 'Not expected:')
+ . '<pre>' . check_plain(var_export($value, TRUE)) . '</pre>'
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Tests URL filter on longer content.
+ *
+ * Filters based on regular expressions should also be tested with a more
+ * complex content than just isolated test lines.
+ * The most common errors are:
+ * - accidental '*' (greedy) match instead of '*?' (minimal) match.
+ * - only matching first occurrence instead of all.
+ * - newlines not matching '.*'.
+ *
+ * This test covers:
+ * - Document with multiple newlines and paragraphs (two newlines).
+ * - Mix of several HTML tags, invalid non-HTML tags, tags to ignore and HTML
+ * comments.
+ * - Empty HTML tags (BR, IMG).
+ * - Mix of absolute and partial URLs, and e-mail addresses in one content.
+ */
+ function testUrlFilterContent() {
+ // Setup dummy filter object.
+ $filter = new stdClass();
+ $filter->settings = array(
+ 'filter_url_length' => 496,
+ );
+ $path = drupal_get_path('module', 'filter') . '/tests';
+
+ $input = file_get_contents($path . '/filter.url-input.txt');
+ $expected = file_get_contents($path . '/filter.url-output.txt');
+ $result = _filter_url($input, $filter);
+ $this->assertIdentical($result, $expected, 'Complex HTML document was correctly processed.');
+ }
+
+ /**
+ * Test the HTML corrector filter.
+ *
+ * @todo This test could really use some validity checking function.
+ */
+ function testHtmlCorrectorFilter() {
+ // Tag closing.
+ $f = _filter_htmlcorrector('<p>text');
+ $this->assertEqual($f, '<p>text</p>', t('HTML corrector -- tag closing at the end of input.'));
+
+ $f = _filter_htmlcorrector('<p>text<p><p>text');
+ $this->assertEqual($f, '<p>text</p><p></p><p>text</p>', t('HTML corrector -- tag closing.'));
+
+ $f = _filter_htmlcorrector("<ul><li>e1<li>e2");
+ $this->assertEqual($f, "<ul><li>e1</li><li>e2</li></ul>", t('HTML corrector -- unclosed list tags.'));
+
+ $f = _filter_htmlcorrector('<div id="d">content');
+ $this->assertEqual($f, '<div id="d">content</div>', t('HTML corrector -- unclosed tag with attribute.'));
+
+ // XHTML slash for empty elements.
+ $f = _filter_htmlcorrector('<hr><br>');
+ $this->assertEqual($f, '<hr /><br />', t('HTML corrector -- XHTML closing slash.'));
+
+ $f = _filter_htmlcorrector('<P>test</P>');
+ $this->assertEqual($f, '<p>test</p>', t('HTML corrector -- Convert uppercased tags to proper lowercased ones.'));
+
+ $f = _filter_htmlcorrector('<P>test</p>');
+ $this->assertEqual($f, '<p>test</p>', t('HTML corrector -- Convert uppercased tags to proper lowercased ones.'));
+
+ $f = _filter_htmlcorrector('test<hr />');
+ $this->assertEqual($f, 'test<hr />', t('HTML corrector -- Let proper XHTML pass through.'));
+
+ $f = _filter_htmlcorrector('test<hr/>');
+ $this->assertEqual($f, 'test<hr />', t('HTML corrector -- Let proper XHTML pass through, but ensure there is a single space before the closing slash.'));
+
+ $f = _filter_htmlcorrector('test<hr />');
+ $this->assertEqual($f, 'test<hr />', t('HTML corrector -- Let proper XHTML pass through, but ensure there are not too many spaces before the closing slash.'));
+
+ $f = _filter_htmlcorrector('<span class="test" />');
+ $this->assertEqual($f, '<span class="test"></span>', t('HTML corrector -- Convert XHTML that is properly formed but that would not be compatible with typical HTML user agents.'));
+
+ $f = _filter_htmlcorrector('test1<br class="test">test2');
+ $this->assertEqual($f, 'test1<br class="test" />test2', t('HTML corrector -- Automatically close single tags.'));
+
+ $f = _filter_htmlcorrector('line1<hr>line2');
+ $this->assertEqual($f, 'line1<hr />line2', t('HTML corrector -- Automatically close single tags.'));
+
+ $f = _filter_htmlcorrector('line1<HR>line2');
+ $this->assertEqual($f, 'line1<hr />line2', t('HTML corrector -- Automatically close single tags.'));
+
+ $f = _filter_htmlcorrector('<img src="http://example.com/test.jpg">test</img>');
+ $this->assertEqual($f, '<img src="http://example.com/test.jpg" />test', t('HTML corrector -- Automatically close single tags.'));
+
+ $f = _filter_htmlcorrector('<br></br>');
+ $this->assertEqual($f, '<br />', t("HTML corrector -- Transform empty tags to a single closed tag if the tag's content model is EMPTY."));
+
+ $f = _filter_htmlcorrector('<div></div>');
+ $this->assertEqual($f, '<div></div>', t("HTML corrector -- Do not transform empty tags to a single closed tag if the tag's content model is not EMPTY."));
+
+ $f = _filter_htmlcorrector('<p>line1<br/><hr/>line2</p>');
+ $this->assertEqual($f, '<p>line1<br /></p><hr />line2', t('HTML corrector -- Move non-inline elements outside of inline containers.'));
+
+ $f = _filter_htmlcorrector('<p>line1<div>line2</div></p>');
+ $this->assertEqual($f, '<p>line1</p><div>line2</div>', t('HTML corrector -- Move non-inline elements outside of inline containers.'));
+
+ $f = _filter_htmlcorrector('<p>test<p>test</p>\n');
+ $this->assertEqual($f, '<p>test</p><p>test</p>\n', t('HTML corrector -- Auto-close improperly nested tags.'));
+
+ $f = _filter_htmlcorrector('<p>Line1<br><STRONG>bold stuff</b>');
+ $this->assertEqual($f, '<p>Line1<br /><strong>bold stuff</strong></p>', t('HTML corrector -- Properly close unclosed tags, and remove useless closing tags.'));
+
+ $f = _filter_htmlcorrector('test <!-- this is a comment -->');
+ $this->assertEqual($f, 'test <!-- this is a comment -->', t('HTML corrector -- Do not touch HTML comments.'));
+
+ $f = _filter_htmlcorrector('test <!--this is a comment-->');
+ $this->assertEqual($f, 'test <!--this is a comment-->', t('HTML corrector -- Do not touch HTML comments.'));
+
+ $f = _filter_htmlcorrector('test <!-- comment <p>another
+ <strong>multiple</strong> line
+ comment</p> -->');
+ $this->assertEqual($f, 'test <!-- comment <p>another
+ <strong>multiple</strong> line
+ comment</p> -->', t('HTML corrector -- Do not touch HTML comments.'));
+
+ $f = _filter_htmlcorrector('test <!-- comment <p>another comment</p> -->');
+ $this->assertEqual($f, 'test <!-- comment <p>another comment</p> -->', t('HTML corrector -- Do not touch HTML comments.'));
+
+ $f = _filter_htmlcorrector('test <!--break-->');
+ $this->assertEqual($f, 'test <!--break-->', t('HTML corrector -- Do not touch HTML comments.'));
+
+ $f = _filter_htmlcorrector('<p>test\n</p>\n');
+ $this->assertEqual($f, '<p>test\n</p>\n', t('HTML corrector -- New-lines are accepted and kept as-is.'));
+
+ $f = _filter_htmlcorrector('<p>دروبال');
+ $this->assertEqual($f, '<p>دروبال</p>', t('HTML corrector -- Encoding is correctly kept.'));
+
+ $f = _filter_htmlcorrector('<script type="text/javascript">alert("test")</script>');
+ $this->assertEqual($f, '<script type="text/javascript">
+<!--//--><![CDATA[// ><!--
+alert("test")
+//--><!]]>
+</script>', t('HTML corrector -- CDATA added to script element'));
+
+ $f = _filter_htmlcorrector('<p><script type="text/javascript">alert("test")</script></p>');
+ $this->assertEqual($f, '<p><script type="text/javascript">
+<!--//--><![CDATA[// ><!--
+alert("test")
+//--><!]]>
+</script></p>', t('HTML corrector -- CDATA added to a nested script element'));
+
+ $f = _filter_htmlcorrector('<p><style> /* Styling */ body {color:red}</style></p>');
+ $this->assertEqual($f, '<p><style>
+<!--/*--><![CDATA[/* ><!--*/
+ /* Styling */ body {color:red}
+/*--><!]]>*/
+</style></p>', t('HTML corrector -- CDATA added to a style element.'));
+ }
+
+ /**
+ * Asserts that a text transformed to lowercase with HTML entities decoded does contains a given string.
+ *
+ * Otherwise fails the test with a given message, similar to all the
+ * SimpleTest assert* functions.
+ *
+ * Note that this does not remove nulls, new lines and other characters that
+ * could be used to obscure a tag or an attribute name.
+ *
+ * @param $haystack
+ * Text to look in.
+ * @param $needle
+ * Lowercase, plain text to look for.
+ * @param $message
+ * Message to display if failed.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertNormalized($haystack, $needle, $message = '', $group = 'Other') {
+ return $this->assertTrue(strpos(strtolower(decode_entities($haystack)), $needle) !== FALSE, $message, $group);
+ }
+
+ /**
+ * Asserts that text transformed to lowercase with HTML entities decoded does not contain a given string.
+ *
+ * Otherwise fails the test with a given message, similar to all the
+ * SimpleTest assert* functions.
+ *
+ * Note that this does not remove nulls, new lines, and other character that
+ * could be used to obscure a tag or an attribute name.
+ *
+ * @param $haystack
+ * Text to look in.
+ * @param $needle
+ * Lowercase, plain text to look for.
+ * @param $message
+ * Message to display if failed.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertNoNormalized($haystack, $needle, $message = '', $group = 'Other') {
+ return $this->assertTrue(strpos(strtolower(decode_entities($haystack)), $needle) === FALSE, $message, $group);
+ }
+}
+
+/**
+ * Tests for filter hook invocation.
+ */
+class FilterHooksTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filter format hooks',
+ 'description' => 'Test hooks for text formats insert/update/disable.',
+ 'group' => 'Filter',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('block', 'filter_test');
+ $admin_user = $this->drupalCreateUser(array('administer filters', 'administer blocks'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Test that hooks run correctly on creating, editing, and deleting a text format.
+ */
+ function testFilterHooks() {
+ // Add a text format.
+ $name = $this->randomName();
+ $edit = array();
+ $edit['format'] = drupal_strtolower($this->randomName());
+ $edit['name'] = $name;
+ $edit['roles[1]'] = 1;
+ $this->drupalPost('admin/config/content/formats/add', $edit, t('Save configuration'));
+ $this->assertRaw(t('Added text format %format.', array('%format' => $name)), t('New format created.'));
+ $this->assertText('hook_filter_format_insert invoked.', t('hook_filter_format_insert was invoked.'));
+
+ $format_id = $edit['format'];
+
+ // Update text format.
+ $edit = array();
+ $edit['roles[2]'] = 1;
+ $this->drupalPost('admin/config/content/formats/' . $format_id, $edit, t('Save configuration'));
+ $this->assertRaw(t('The text format %format has been updated.', array('%format' => $name)), t('Format successfully updated.'));
+ $this->assertText('hook_filter_format_update invoked.', t('hook_filter_format_update() was invoked.'));
+
+ // Add a new custom block.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName(8);
+ $custom_block['title'] = $this->randomName(8);
+ $custom_block['body[value]'] = $this->randomName(32);
+ // Use the format created.
+ $custom_block['body[format]'] = $format_id;
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+ $this->assertText(t('The block has been created.'), t('New block successfully created.'));
+
+ // Verify the new block is in the database.
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $this->assertNotNull($bid, t('New block found in database'));
+
+ // Disable the text format.
+ $this->drupalPost('admin/config/content/formats/' . $format_id . '/disable', array(), t('Disable'));
+ $this->assertRaw(t('Disabled text format %format.', array('%format' => $name)), t('Format successfully disabled.'));
+ $this->assertText('hook_filter_format_disable invoked.', t('hook_filter_format_disable() was invoked.'));
+ }
+}
+
diff --git a/core/modules/filter/tests/filter.url-input.txt b/core/modules/filter/tests/filter.url-input.txt
new file mode 100644
index 000000000000..7b33af56ca94
--- /dev/null
+++ b/core/modules/filter/tests/filter.url-input.txt
@@ -0,0 +1,36 @@
+This is just a www.test.com. paragraph with person@test.com. some http://www.test.com. urls thrown in and also <code>using www.test.com the code tag</code>.
+
+<blockquote>
+This is just a www.test.com. paragraph with person@test.com. some http://www.test.com. urls thrown in and also <code>using www.test.com the code tag</code>.
+</blockquote>
+
+<code>Testing code tag http://www.test.com abc</code>
+
+http://www.test.com
+www.test.com
+person@test.com
+<code>www.test.com</code>
+
+What about tags that don't exist <x>like x say www.test.com</x>? And what about tag <pooh>beginning www.test.com with p?</pooh>
+
+Test &lt;br/&gt;: This is just a www.test.com. paragraph <strong>with</strong> some http://www.test.com urls thrown in. *<br/> This is just a www.test.com paragraph *<br/> with some http://www.test.com urls thrown in. *<br/>This is just a www.test.com paragraph person@test.com with some http://www.test.com urls *img*<img/> thrown in. This is just a www.test.com paragraph with some http://www.test.com urls thrown in. This is just a www.test.com paragraph person@test.com with some http://www.test.com urls thrown in.
+
+This is just a www.test.com paragraph <strong>with</strong> some http://www.test.com urls thrown in. <br /> This is just a www.test.com paragraph with some http://www.test.com urls thrown in. This is just a www.test.com paragraph person@test.com with some http://www.test.com urls thrown in. This is just a www.test.com paragraph with some http://www.test.com urls thrown in. This is just a www.test.com paragraph person@test.com with some http://www.test.com urls thrown in.
+
+The old URL filter has problems with <a title="kind of link www.example.com with text" href="http://www.example.com">this kind of link</a> with www address as part of text in title. www.test.com
+
+<!-- This url www.test.com is inside a comment -->
+
+<dl>
+<dt>www.test.com</dt>
+<dd>http://www.test.com</dd>
+<dd>person@test.com</dd>
+<dt>check www.test.com</dt>
+<dd>this with some text around: http://www.test.com not so easy person@test.com now?</dd>
+</dl>
+
+<!-- <p>This url http://www.test.com is
+ inside a comment containing newlines and
+<em>html</em> tags.</p> -->
+
+This is the end! \ No newline at end of file
diff --git a/core/modules/filter/tests/filter.url-output.txt b/core/modules/filter/tests/filter.url-output.txt
new file mode 100644
index 000000000000..9cc507308864
--- /dev/null
+++ b/core/modules/filter/tests/filter.url-output.txt
@@ -0,0 +1,36 @@
+This is just a <a href="http://www.test.com">www.test.com</a>. paragraph with <a href="mailto:person@test.com">person@test.com</a>. some <a href="http://www.test.com">http://www.test.com</a>. urls thrown in and also <code>using www.test.com the code tag</code>.
+
+<blockquote>
+This is just a <a href="http://www.test.com">www.test.com</a>. paragraph with <a href="mailto:person@test.com">person@test.com</a>. some <a href="http://www.test.com">http://www.test.com</a>. urls thrown in and also <code>using www.test.com the code tag</code>.
+</blockquote>
+
+<code>Testing code tag http://www.test.com abc</code>
+
+<a href="http://www.test.com">http://www.test.com</a>
+<a href="http://www.test.com">www.test.com</a>
+<a href="mailto:person@test.com">person@test.com</a>
+<code>www.test.com</code>
+
+What about tags that don't exist <x>like x say <a href="http://www.test.com">www.test.com</a></x>? And what about tag <pooh>beginning <a href="http://www.test.com">www.test.com</a> with p?</pooh>
+
+Test &lt;br/&gt;: This is just a <a href="http://www.test.com">www.test.com</a>. paragraph <strong>with</strong> some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. *<br/> This is just a <a href="http://www.test.com">www.test.com</a> paragraph *<br/> with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. *<br/>This is just a <a href="http://www.test.com">www.test.com</a> paragraph <a href="mailto:person@test.com">person@test.com</a> with some <a href="http://www.test.com">http://www.test.com</a> urls *img*<img/> thrown in. This is just a <a href="http://www.test.com">www.test.com</a> paragraph with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. This is just a <a href="http://www.test.com">www.test.com</a> paragraph <a href="mailto:person@test.com">person@test.com</a> with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in.
+
+This is just a <a href="http://www.test.com">www.test.com</a> paragraph <strong>with</strong> some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. <br /> This is just a <a href="http://www.test.com">www.test.com</a> paragraph with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. This is just a <a href="http://www.test.com">www.test.com</a> paragraph <a href="mailto:person@test.com">person@test.com</a> with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. This is just a <a href="http://www.test.com">www.test.com</a> paragraph with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in. This is just a <a href="http://www.test.com">www.test.com</a> paragraph <a href="mailto:person@test.com">person@test.com</a> with some <a href="http://www.test.com">http://www.test.com</a> urls thrown in.
+
+The old URL filter has problems with <a title="kind of link www.example.com with text" href="http://www.example.com">this kind of link</a> with www address as part of text in title. <a href="http://www.test.com">www.test.com</a>
+
+<!-- This url www.test.com is inside a comment -->
+
+<dl>
+<dt><a href="http://www.test.com">www.test.com</a></dt>
+<dd><a href="http://www.test.com">http://www.test.com</a></dd>
+<dd><a href="mailto:person@test.com">person@test.com</a></dd>
+<dt>check <a href="http://www.test.com">www.test.com</a></dt>
+<dd>this with some text around: <a href="http://www.test.com">http://www.test.com</a> not so easy <a href="mailto:person@test.com">person@test.com</a> now?</dd>
+</dl>
+
+<!-- <p>This url http://www.test.com is
+ inside a comment containing newlines and
+<em>html</em> tags.</p> -->
+
+This is the end! \ No newline at end of file
diff --git a/core/modules/forum/forum-icon.tpl.php b/core/modules/forum/forum-icon.tpl.php
new file mode 100644
index 000000000000..9cf2cd8d3b28
--- /dev/null
+++ b/core/modules/forum/forum-icon.tpl.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display an appropriate icon for a forum post.
+ *
+ * Available variables:
+ * - $new_posts: Indicates whether or not the topic contains new posts.
+ * - $icon_class: The icon to display. May be one of 'hot', 'hot-new', 'new',
+ * 'default', 'closed', or 'sticky'.
+ * - $first_new: Indicates whether this is the first topic with new posts.
+ *
+ * @see template_preprocess_forum_icon()
+ * @see theme_forum_icon()
+ */
+?>
+<div class="topic-status-<?php print $icon_class ?>" title="<?php print $icon_title ?>">
+<?php if ($first_new): ?>
+ <a id="new"></a>
+<?php endif; ?>
+
+ <span class="element-invisible"><?php print $icon_title ?></span>
+
+</div>
diff --git a/core/modules/forum/forum-list.tpl.php b/core/modules/forum/forum-list.tpl.php
new file mode 100644
index 000000000000..257cea947d94
--- /dev/null
+++ b/core/modules/forum/forum-list.tpl.php
@@ -0,0 +1,76 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a list of forums and containers.
+ *
+ * Available variables:
+ * - $forums: An array of forums and containers to display. It is keyed to the
+ * numeric id's of all child forums and containers.
+ * - $forum_id: Forum id for the current forum. Parent to all items within
+ * the $forums array.
+ *
+ * Each $forum in $forums contains:
+ * - $forum->is_container: Is TRUE if the forum can contain other forums. Is
+ * FALSE if the forum can contain only topics.
+ * - $forum->depth: How deep the forum is in the current hierarchy.
+ * - $forum->zebra: 'even' or 'odd' string used for row class.
+ * - $forum->icon_class: 'default' or 'new' string used for forum icon class.
+ * - $forum->icon_title: Text alternative for the forum icon.
+ * - $forum->name: The name of the forum.
+ * - $forum->link: The URL to link to this forum.
+ * - $forum->description: The description of this forum.
+ * - $forum->new_topics: True if the forum contains unread posts.
+ * - $forum->new_url: A URL to the forum's unread posts.
+ * - $forum->new_text: Text for the above URL which tells how many new posts.
+ * - $forum->old_topics: A count of posts that have already been read.
+ * - $forum->num_posts: The total number of posts in the forum.
+ * - $forum->last_reply: Text representing the last time a forum was posted or
+ * commented in.
+ *
+ * @see template_preprocess_forum_list()
+ * @see theme_forum_list()
+ */
+?>
+<table id="forum-<?php print $forum_id; ?>">
+ <thead>
+ <tr>
+ <th><?php print t('Forum'); ?></th>
+ <th><?php print t('Topics');?></th>
+ <th><?php print t('Posts'); ?></th>
+ <th><?php print t('Last post'); ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ($forums as $child_id => $forum): ?>
+ <tr id="forum-list-<?php print $child_id; ?>" class="<?php print $forum->zebra; ?>">
+ <td <?php print $forum->is_container ? 'colspan="4" class="container"' : 'class="forum"'; ?>>
+ <?php /* Enclose the contents of this cell with X divs, where X is the
+ * depth this forum resides at. This will allow us to use CSS
+ * left-margin for indenting.
+ */ ?>
+ <?php print str_repeat('<div class="indent">', $forum->depth); ?>
+ <div class="icon forum-status-<?php print $forum->icon_class; ?>" title="<?php print $forum->icon_title; ?>">
+ <span class="element-invisible"><?php print $forum->icon_title; ?></span>
+ </div>
+ <div class="name"><a href="<?php print $forum->link; ?>"><?php print $forum->name; ?></a></div>
+ <?php if ($forum->description): ?>
+ <div class="description"><?php print $forum->description; ?></div>
+ <?php endif; ?>
+ <?php print str_repeat('</div>', $forum->depth); ?>
+ </td>
+ <?php if (!$forum->is_container): ?>
+ <td class="topics">
+ <?php print $forum->num_topics ?>
+ <?php if ($forum->new_topics): ?>
+ <br />
+ <a href="<?php print $forum->new_url; ?>"><?php print $forum->new_text; ?></a>
+ <?php endif; ?>
+ </td>
+ <td class="posts"><?php print $forum->num_posts ?></td>
+ <td class="last-reply"><?php print $forum->last_reply ?></td>
+ <?php endif; ?>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+</table>
diff --git a/core/modules/forum/forum-rtl.css b/core/modules/forum/forum-rtl.css
new file mode 100644
index 000000000000..81dd4d39606a
--- /dev/null
+++ b/core/modules/forum/forum-rtl.css
@@ -0,0 +1,16 @@
+
+#forum td.forum .icon {
+ float: right;
+ margin: 0 0 0 9px;
+}
+.forum-topic-navigation {
+ padding: 1em 3em 0 0;
+}
+.forum-topic-navigation .topic-previous {
+ text-align: left;
+ float: right;
+}
+.forum-topic-navigation .topic-next {
+ text-align: right;
+ float: left;
+}
diff --git a/core/modules/forum/forum-submitted.tpl.php b/core/modules/forum/forum-submitted.tpl.php
new file mode 100644
index 000000000000..d310448c7b25
--- /dev/null
+++ b/core/modules/forum/forum-submitted.tpl.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to format a simple string indicated when and
+ * by whom a topic was submitted.
+ *
+ * Available variables:
+ *
+ * - $author: The author of the post.
+ * - $time: How long ago the post was created.
+ * - $topic: An object with the raw data of the post. Unsafe, be sure
+ * to clean this data before printing.
+ *
+ * @see template_preprocess_forum_submitted()
+ * @see theme_forum_submitted()
+ */
+?>
+<?php if ($time): ?>
+ <span class="submitted">
+ <?php print t('By !author @time ago', array(
+ '@time' => $time,
+ '!author' => $author,
+ )); ?>
+ </span>
+<?php else: ?>
+ <?php print t('n/a'); ?>
+<?php endif; ?>
diff --git a/core/modules/forum/forum-topic-list.tpl.php b/core/modules/forum/forum-topic-list.tpl.php
new file mode 100644
index 000000000000..33907036fa13
--- /dev/null
+++ b/core/modules/forum/forum-topic-list.tpl.php
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a list of forum topics.
+ *
+ * Available variables:
+ * - $header: The table header. This is pre-generated with click-sorting
+ * information. If you need to change this, see
+ * template_preprocess_forum_topic_list().
+ * - $pager: The pager to display beneath the table.
+ * - $topics: An array of topics to be displayed.
+ * - $topic_id: Numeric id for the current forum topic.
+ *
+ * Each $topic in $topics contains:
+ * - $topic->icon: The icon to display.
+ * - $topic->moved: A flag to indicate whether the topic has been moved to
+ * another forum.
+ * - $topic->title: The title of the topic. Safe to output.
+ * - $topic->message: If the topic has been moved, this contains an
+ * explanation and a link.
+ * - $topic->zebra: 'even' or 'odd' string used for row class.
+ * - $topic->comment_count: The number of replies on this topic.
+ * - $topic->new_replies: A flag to indicate whether there are unread comments.
+ * - $topic->new_url: If there are unread replies, this is a link to them.
+ * - $topic->new_text: Text containing the translated, properly pluralized count.
+ * - $topic->created: An outputtable string represented when the topic was posted.
+ * - $topic->last_reply: An outputtable string representing when the topic was
+ * last replied to.
+ * - $topic->timestamp: The raw timestamp this topic was posted.
+ *
+ * @see template_preprocess_forum_topic_list()
+ * @see theme_forum_topic_list()
+ */
+?>
+<table id="forum-topic-<?php print $topic_id; ?>">
+ <thead>
+ <tr><?php print $header; ?></tr>
+ </thead>
+ <tbody>
+ <?php foreach ($topics as $topic): ?>
+ <tr class="<?php print $topic->zebra;?>">
+ <td class="icon"><?php print $topic->icon; ?></td>
+ <td class="title">
+ <div>
+ <?php print $topic->title; ?>
+ </div>
+ <div>
+ <?php print $topic->created; ?>
+ </div>
+ </td>
+ <?php if ($topic->moved): ?>
+ <td colspan="3"><?php print $topic->message; ?></td>
+ <?php else: ?>
+ <td class="replies">
+ <?php print $topic->comment_count; ?>
+ <?php if ($topic->new_replies): ?>
+ <br />
+ <a href="<?php print $topic->new_url; ?>"><?php print $topic->new_text; ?></a>
+ <?php endif; ?>
+ </td>
+ <td class="last-reply"><?php print $topic->last_reply; ?></td>
+ <?php endif; ?>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+</table>
+<?php print $pager; ?>
diff --git a/core/modules/forum/forum.admin.inc b/core/modules/forum/forum.admin.inc
new file mode 100644
index 000000000000..49c71d90a0bf
--- /dev/null
+++ b/core/modules/forum/forum.admin.inc
@@ -0,0 +1,313 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for the forum module.
+ */
+function forum_form_main($type, $edit = array()) {
+ $edit = (array) $edit;
+ if ((isset($_POST['op']) && $_POST['op'] == t('Delete')) || !empty($_POST['confirm'])) {
+ return drupal_get_form('forum_confirm_delete', $edit['tid']);
+ }
+ switch ($type) {
+ case 'forum':
+ return drupal_get_form('forum_form_forum', $edit);
+ break;
+ case 'container':
+ return drupal_get_form('forum_form_container', $edit);
+ break;
+ }
+}
+
+/**
+ * Returns a form for adding a forum to the forum vocabulary
+ *
+ * @param $edit Associative array containing a forum term to be added or edited.
+ * @ingroup forms
+ * @see forum_form_submit()
+ */
+function forum_form_forum($form, &$form_state, $edit = array()) {
+ $edit += array(
+ 'name' => '',
+ 'description' => '',
+ 'tid' => NULL,
+ 'weight' => 0,
+ );
+ $form['name'] = array('#type' => 'textfield',
+ '#title' => t('Forum name'),
+ '#default_value' => $edit['name'],
+ '#maxlength' => 255,
+ '#description' => t('Short but meaningful name for this collection of threaded discussions.'),
+ '#required' => TRUE,
+ );
+ $form['description'] = array('#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => $edit['description'],
+ '#description' => t('Description and guidelines for discussions within this forum.'),
+ );
+ $form['parent']['#tree'] = TRUE;
+ $form['parent'][0] = _forum_parent_select($edit['tid'], t('Parent'), 'forum');
+ $form['weight'] = array('#type' => 'weight',
+ '#title' => t('Weight'),
+ '#default_value' => $edit['weight'],
+ '#description' => t('Forums are displayed in ascending order by weight (forums with equal weights are displayed alphabetically).'),
+ );
+
+ $form['vid'] = array('#type' => 'hidden', '#value' => variable_get('forum_nav_vocabulary', ''));
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+ if ($edit['tid']) {
+ $form['actions']['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
+ $form['tid'] = array('#type' => 'hidden', '#value' => $edit['tid']);
+ }
+ $form['#submit'][] = 'forum_form_submit';
+ $form['#theme'] = 'forum_form';
+
+ return $form;
+}
+
+/**
+ * Process forum form and container form submissions.
+ */
+function forum_form_submit($form, &$form_state) {
+ if ($form['form_id']['#value'] == 'forum_form_container') {
+ $container = TRUE;
+ $type = t('forum container');
+ }
+ else {
+ $container = FALSE;
+ $type = t('forum');
+ }
+
+ $term = (object) $form_state['values'];
+ $status = taxonomy_term_save($term);
+ switch ($status) {
+ case SAVED_NEW:
+ if ($container) {
+ $containers = variable_get('forum_containers', array());
+ $containers[] = $term->tid;
+ variable_set('forum_containers', $containers);
+ }
+ $form_state['values']['tid'] = $term->tid;
+ drupal_set_message(t('Created new @type %term.', array('%term' => $form_state['values']['name'], '@type' => $type)));
+ break;
+ case SAVED_UPDATED:
+ drupal_set_message(t('The @type %term has been updated.', array('%term' => $form_state['values']['name'], '@type' => $type)));
+ // Clear the page and block caches to avoid stale data.
+ cache_clear_all();
+ break;
+ }
+ $form_state['redirect'] = 'admin/structure/forum';
+ return;
+}
+
+/**
+ * Returns HTML for a forum form.
+ *
+ * By default this does not alter the appearance of a form at all,
+ * but is provided as a convenience for themers.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_forum_form($variables) {
+ return drupal_render_children($variables['form']);
+}
+
+/**
+ * Returns a form for adding a container to the forum vocabulary
+ *
+ * @param $edit Associative array containing a container term to be added or edited.
+ * @ingroup forms
+ * @see forum_form_submit()
+ */
+function forum_form_container($form, &$form_state, $edit = array()) {
+ $edit += array(
+ 'name' => '',
+ 'description' => '',
+ 'tid' => NULL,
+ 'weight' => 0,
+ );
+ // Handle a delete operation.
+ $form['name'] = array(
+ '#title' => t('Container name'),
+ '#type' => 'textfield',
+ '#default_value' => $edit['name'],
+ '#maxlength' => 255,
+ '#description' => t('Short but meaningful name for this collection of related forums.'),
+ '#required' => TRUE
+ );
+
+ $form['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => $edit['description'],
+ '#description' => t('Description and guidelines for forums within this container.')
+ );
+ $form['parent']['#tree'] = TRUE;
+ $form['parent'][0] = _forum_parent_select($edit['tid'], t('Parent'), 'container');
+ $form['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#default_value' => $edit['weight'],
+ '#description' => t('Containers are displayed in ascending order by weight (containers with equal weights are displayed alphabetically).')
+ );
+
+ $form['vid'] = array(
+ '#type' => 'hidden',
+ '#value' => variable_get('forum_nav_vocabulary', ''),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save')
+ );
+ if ($edit['tid']) {
+ $form['actions']['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
+ $form['tid'] = array('#type' => 'value', '#value' => $edit['tid']);
+ }
+ $form['#submit'][] = 'forum_form_submit';
+ $form['#theme'] = 'forum_form';
+
+ return $form;
+}
+
+/**
+ * Returns a confirmation page for deleting a forum taxonomy term.
+ *
+ * @param $tid ID of the term to be deleted
+ */
+function forum_confirm_delete($form, &$form_state, $tid) {
+ $term = taxonomy_term_load($tid);
+
+ $form['tid'] = array('#type' => 'value', '#value' => $tid);
+ $form['name'] = array('#type' => 'value', '#value' => $term->name);
+
+ return confirm_form($form, t('Are you sure you want to delete the forum %name?', array('%name' => $term->name)), 'admin/structure/forum', t('Deleting a forum or container will also delete its sub-forums, if any. To delete posts in this forum, visit <a href="@content">content administration</a> first. This action cannot be undone.', array('@content' => url('admin/content'))), t('Delete'), t('Cancel'));
+}
+
+/**
+ * Implement forms api _submit call. Deletes a forum after confirmation.
+ */
+function forum_confirm_delete_submit($form, &$form_state) {
+ taxonomy_term_delete($form_state['values']['tid']);
+ drupal_set_message(t('The forum %term and all sub-forums have been deleted.', array('%term' => $form_state['values']['name'])));
+ watchdog('content', 'forum: deleted %term and all its sub-forums.', array('%term' => $form_state['values']['name']));
+
+ $form_state['redirect'] = 'admin/structure/forum';
+ return;
+}
+
+/**
+ * Form builder for the forum settings page.
+ *
+ * @see system_settings_form()
+ */
+function forum_admin_settings($form) {
+ $number = drupal_map_assoc(array(5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 80, 100, 150, 200, 250, 300, 350, 400, 500));
+ $form['forum_hot_topic'] = array('#type' => 'select',
+ '#title' => t('Hot topic threshold'),
+ '#default_value' => variable_get('forum_hot_topic', 15),
+ '#options' => $number,
+ '#description' => t('The number of replies a topic must have to be considered "hot".'),
+ );
+ $number = drupal_map_assoc(array(10, 25, 50, 75, 100));
+ $form['forum_per_page'] = array('#type' => 'select',
+ '#title' => t('Topics per page'),
+ '#default_value' => variable_get('forum_per_page', 25),
+ '#options' => $number,
+ '#description' => t('Default number of forum topics displayed per page.'),
+ );
+ $forder = array(1 => t('Date - newest first'), 2 => t('Date - oldest first'), 3 => t('Posts - most active first'), 4 => t('Posts - least active first'));
+ $form['forum_order'] = array('#type' => 'radios',
+ '#title' => t('Default order'),
+ '#default_value' => variable_get('forum_order', 1),
+ '#options' => $forder,
+ '#description' => t('Default display order for topics.'),
+ );
+ return system_settings_form($form);
+}
+
+/**
+ * Returns an overview list of existing forums and containers
+ */
+function forum_overview($form, &$form_state) {
+ module_load_include('inc', 'taxonomy', 'taxonomy.admin');
+
+ $vid = variable_get('forum_nav_vocabulary', '');
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ $form = taxonomy_overview_terms($form, $form_state, $vocabulary);
+
+ foreach (element_children($form) as $key) {
+ if (isset($form[$key]['#term'])) {
+ $term = $form[$key]['#term'];
+ $form[$key]['view']['#href'] = 'forum/' . $term['tid'];
+ if (in_array($form[$key]['#term']['tid'], variable_get('forum_containers', array()))) {
+ $form[$key]['edit']['#title'] = t('edit container');
+ $form[$key]['edit']['#href'] = 'admin/structure/forum/edit/container/' . $term['tid'];
+ }
+ else {
+ $form[$key]['edit']['#title'] = t('edit forum');
+ $form[$key]['edit']['#href'] = 'admin/structure/forum/edit/forum/' . $term['tid'];
+ }
+ }
+ }
+
+ // Remove the alphabetical reset.
+ unset($form['actions']['reset_alphabetical']);
+
+ // The form needs to have submit and validate handlers set explicitly.
+ $form['#theme'] = 'taxonomy_overview_terms';
+ $form['#submit'] = array('taxonomy_overview_terms_submit'); // Use the existing taxonomy overview submit handler.
+ $form['#empty_text'] = t('No containers or forums available. <a href="@container">Add container</a> or <a href="@forum">Add forum</a>.', array('@container' => url('admin/structure/forum/add/container'), '@forum' => url('admin/structure/forum/add/forum')));
+ return $form;
+}
+
+/**
+ * Returns a select box for available parent terms
+ *
+ * @param $tid ID of the term which is being added or edited
+ * @param $title Title to display the select box with
+ * @param $child_type Whether the child is forum or container
+ */
+function _forum_parent_select($tid, $title, $child_type) {
+
+ $parents = taxonomy_get_parents($tid);
+ if ($parents) {
+ $parent = array_shift($parents);
+ $parent = $parent->tid;
+ }
+ else {
+ $parent = 0;
+ }
+
+ $vid = variable_get('forum_nav_vocabulary', '');
+ $children = taxonomy_get_tree($vid, $tid);
+
+ // A term can't be the child of itself, nor of its children.
+ foreach ($children as $child) {
+ $exclude[] = $child->tid;
+ }
+ $exclude[] = $tid;
+
+ $tree = taxonomy_get_tree($vid);
+ $options[0] = '<' . t('root') . '>';
+ if ($tree) {
+ foreach ($tree as $term) {
+ if (!in_array($term->tid, $exclude)) {
+ $options[$term->tid] = str_repeat(' -- ', $term->depth) . $term->name;
+ }
+ }
+ }
+ if ($child_type == 'container') {
+ $description = t('Containers are usually placed at the top (root) level, but may also be placed inside another container or forum.');
+ }
+ elseif ($child_type == 'forum') {
+ $description = t('Forums may be placed at the top (root) level, or inside another container or forum.');
+ }
+
+ return array('#type' => 'select', '#title' => $title, '#default_value' => $parent, '#options' => $options, '#description' => $description, '#required' => TRUE);
+}
diff --git a/core/modules/forum/forum.css b/core/modules/forum/forum.css
new file mode 100644
index 000000000000..4a67c8bcdab2
--- /dev/null
+++ b/core/modules/forum/forum.css
@@ -0,0 +1,50 @@
+
+#forum .description {
+ font-size: 0.9em;
+ margin: 0.5em;
+}
+#forum td.created,
+#forum td.posts,
+#forum td.topics,
+#forum td.last-reply,
+#forum td.replies,
+#forum td.pager {
+ white-space: nowrap;
+}
+
+#forum td.forum .icon {
+ background-image: url(../../misc/forum-icons.png);
+ background-repeat: no-repeat;
+ float: left; /* LTR */
+ height: 24px;
+ margin: 0 9px 0 0; /* LTR */
+ width: 24px;
+}
+#forum td.forum .forum-status-new {
+ background-position: -24px 0;
+}
+
+#forum div.indent {
+ margin-left: 20px;
+}
+#forum .icon div {
+ background-image: url(../../misc/forum-icons.png);
+ background-repeat: no-repeat;
+ width: 24px;
+ height: 24px;
+}
+#forum .icon .topic-status-new {
+ background-position: -24px 0;
+}
+#forum .icon .topic-status-hot {
+ background-position: -48px 0;
+}
+#forum .icon .topic-status-hot-new {
+ background-position: -72px 0;
+}
+#forum .icon .topic-status-sticky {
+ background-position: -96px 0;
+}
+#forum .icon .topic-status-closed {
+ background-position: -120px 0;
+}
diff --git a/core/modules/forum/forum.info b/core/modules/forum/forum.info
new file mode 100644
index 000000000000..cb6e3e76e19c
--- /dev/null
+++ b/core/modules/forum/forum.info
@@ -0,0 +1,10 @@
+name = Forum
+description = Provides discussion forums.
+dependencies[] = taxonomy
+dependencies[] = comment
+package = Core
+version = VERSION
+core = 8.x
+files[] = forum.test
+configure = admin/structure/forum
+stylesheets[all][] = forum.css
diff --git a/core/modules/forum/forum.install b/core/modules/forum/forum.install
new file mode 100644
index 000000000000..2eebd7fba3ee
--- /dev/null
+++ b/core/modules/forum/forum.install
@@ -0,0 +1,246 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the forum module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function forum_install() {
+ // Set the weight of the forum.module to 1 so it is loaded after the taxonomy.module.
+ db_update('system')
+ ->fields(array('weight' => 1))
+ ->condition('name', 'forum')
+ ->execute();
+ // Forum topics are published by default, but do not have any other default
+ // options set (for example, they are not promoted to the front page).
+ variable_set('node_options_forum', array('status'));
+}
+
+/**
+ * Implements hook_enable().
+ */
+function forum_enable() {
+ // If we enable forum at the same time as taxonomy we need to call
+ // field_associate_fields() as otherwise the field won't be enabled until
+ // hook modules_enabled is called which takes place after hook_enable events.
+ field_associate_fields('taxonomy');
+ // Create the forum vocabulary if it does not exist.
+ $vocabulary = taxonomy_vocabulary_load(variable_get('forum_nav_vocabulary', 0));
+ if (!$vocabulary) {
+ $edit = array(
+ 'name' => t('Forums'),
+ 'machine_name' => 'forums',
+ 'description' => t('Forum navigation vocabulary'),
+ 'hierarchy' => 1,
+ 'module' => 'forum',
+ 'weight' => -10,
+ );
+ $vocabulary = (object) $edit;
+ taxonomy_vocabulary_save($vocabulary);
+ variable_set('forum_nav_vocabulary', $vocabulary->vid);
+ }
+
+ // Create the 'taxonomy_forums' field if it doesn't already exist.
+ if (!field_info_field('taxonomy_forums')) {
+ $field = array(
+ 'field_name' => 'taxonomy_forums',
+ 'type' => 'taxonomy_term_reference',
+ 'settings' => array(
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => $vocabulary->machine_name,
+ 'parent' => 0,
+ ),
+ ),
+ ),
+ );
+ field_create_field($field);
+
+ // Create a default forum so forum posts can be created.
+ $edit = array(
+ 'name' => t('General discussion'),
+ 'description' => '',
+ 'parent' => array(0),
+ 'vid' => $vocabulary->vid,
+ );
+ $term = (object) $edit;
+ taxonomy_term_save($term);
+
+ // Create the instance on the bundle.
+ $instance = array(
+ 'field_name' => 'taxonomy_forums',
+ 'entity_type' => 'node',
+ 'label' => $vocabulary->name,
+ 'bundle' => 'forum',
+ 'required' => TRUE,
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ 'weight' => 10,
+ ),
+ 'teaser' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ 'weight' => 10,
+ ),
+ ),
+ );
+ field_create_instance($instance);
+ }
+
+ // Ensure the forum node type is available.
+ node_types_rebuild();
+ $types = node_type_get_types();
+ node_add_body_field($types['forum']);
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function forum_uninstall() {
+ // Load the dependent Taxonomy module, in case it has been disabled.
+ drupal_load('module', 'taxonomy');
+
+ variable_del('forum_containers');
+ variable_del('forum_hot_topic');
+ variable_del('forum_per_page');
+ variable_del('forum_order');
+ variable_del('forum_block_num_active');
+ variable_del('forum_block_num_new');
+ variable_del('node_options_forum');
+
+ field_delete_field('taxonomy_forums');
+ // Purge field data now to allow taxonomy module to be uninstalled
+ // if this is the only field remaining.
+ field_purge_batch(10);
+}
+
+/**
+ * Implements hook_schema().
+ */
+function forum_schema() {
+ $schema['forum'] = array(
+ 'description' => 'Stores the relationship of nodes to forum terms.',
+ 'fields' => array(
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {node}.nid of the node.',
+ ),
+ 'vid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Primary Key: The {node}.vid of the node.',
+ ),
+ 'tid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {taxonomy_term_data}.tid of the forum term assigned to the node.',
+ ),
+ ),
+ 'indexes' => array(
+ 'forum_topic' => array('nid', 'tid'),
+ 'tid' => array('tid'),
+ ),
+ 'primary key' => array('vid'),
+ 'foreign keys' => array(
+ 'forum_node' => array(
+ 'table' => 'node',
+ 'columns' => array(
+ 'nid' => 'nid',
+ 'vid' => 'vid',
+ ),
+ ),
+ ),
+ );
+
+ $schema['forum_index'] = array(
+ 'description' => 'Maintains denormalized information about node/term relationships.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record tracks.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'title' => array(
+ 'description' => 'The title of this node, always treated as non-markup plain text.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'tid' => array(
+ 'description' => 'The term ID.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'sticky' => array(
+ 'description' => 'Boolean indicating whether the node is sticky.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'created' => array(
+ 'description' => 'The Unix timestamp when the node was created.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default'=> 0,
+ ),
+ 'last_comment_timestamp' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The Unix timestamp of the last comment that was posted within this node, from {comment}.timestamp.',
+ ),
+ 'comment_count' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The total number of comments on this node.',
+ ),
+ ),
+ 'indexes' => array(
+ 'forum_topics' => array('nid', 'tid', 'sticky', 'last_comment_timestamp'),
+ ),
+ 'foreign keys' => array(
+ 'tracked_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'term' => array(
+ 'table' => 'taxonomy_term_data',
+ 'columns' => array(
+ 'tid' => 'tid',
+ ),
+ ),
+ ),
+ );
+
+
+ return $schema;
+}
+
+/**
+ * Implements hook_update_last_removed().
+ */
+function forum_update_last_removed() {
+ return 7003;
+}
diff --git a/core/modules/forum/forum.module b/core/modules/forum/forum.module
new file mode 100644
index 000000000000..65c54894186d
--- /dev/null
+++ b/core/modules/forum/forum.module
@@ -0,0 +1,1294 @@
+<?php
+
+/**
+ * @file
+ * Provides discussion forums.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function forum_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#forum':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Forum module lets you create threaded discussion forums with functionality similar to other message board systems. Forums are useful because they allow community members to discuss topics with one another while ensuring those conversations are archived for later reference. In a forum, users post topics and threads in nested hierarchies, allowing discussions to be categorized and grouped. The forum hierarchy consists of:') . '</p>';
+ $output .= '<ul>';
+ $output .= '<li>' . t('Optional containers (for example, <em>Support</em>), which can hold:') . '</li>';
+ $output .= '<ul><li>' . t('Forums (for example, <em>Installing Drupal</em>), which can hold:') . '</li>';
+ $output .= '<ul><li>' . t('Forum topics submitted by users (for example, <em>How to start a Drupal 6 Multisite</em>), which start discussions and are starting points for:') . '</li>';
+ $output .= '<ul><li>' . t('Threaded comments submitted by users (for example, <em>You have these options...</em>).') . '</li>';
+ $output .= '</ul>';
+ $output .= '</ul>';
+ $output .= '</ul>';
+ $output .= '</ul>';
+ $output .= '<p>' . t('For more information, see the online handbook entry for <a href="@forum">Forum module</a>.', array('@forum' => 'http://drupal.org/handbook/modules/forum')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Setting up forum structure') . '</dt>';
+ $output .= '<dd>' . t('Visit the <a href="@forums">Forums page</a> to set up containers and forums to hold your discussion topics.', array('@forums' => url('admin/structure/forum'))) . '</dd>';
+ $output .= '<dt>' . t('Starting a discussion') . '</dt>';
+ $output .= '<dd>' . t('The <a href="@create-topic">Forum topic</a> link on the <a href="@content-add">Add new content</a> page creates the first post of a new threaded discussion, or thread.', array('@create-topic' => url('node/add/forum'), '@content-add' => url('node/add'))) . '</dd>';
+ $output .= '<dt>' . t('Navigation') . '</dt>';
+ $output .= '<dd>' . t('Enabling the Forum module provides a default <em>Forums</em> menu item in the navigation menu that links to the <a href="@forums">Forums page</a>.', array('@forums' => url('forum'))) . '</dd>';
+ $output .= '<dt>' . t('Moving forum topics') . '</dt>';
+ $output .= '<dd>' . t('A forum topic (and all of its comments) may be moved between forums by selecting a different forum while editing a forum topic. When moving a forum topic between forums, the <em>Leave shadow copy</em> option creates a link in the original forum pointing to the new location.') . '</dd>';
+ $output .= '<dt>' . t('Locking and disabling comments') . '</dt>';
+ $output .= '<dd>' . t('Selecting <em>Closed</em> under <em>Comment settings</em> while editing a forum topic will lock (prevent new comments on) the thread. Selecting <em>Hidden</em> under <em>Comment settings</em> while editing a forum topic will hide all existing comments on the thread, and prevent new ones.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/structure/forum':
+ $output = '<p>' . t('Forums contain forum topics. Use containers to group related forums.') . '</p>';
+ $output .= theme('more_help_link', array('url' => 'admin/help/forum'));
+ return $output;
+ case 'admin/structure/forum/add/container':
+ return '<p>' . t('Use containers to group related forums.') . '</p>';
+ case 'admin/structure/forum/add/forum':
+ return '<p>' . t('A forum holds related forum topics.') . '</p>';
+ case 'admin/structure/forum/settings':
+ return '<p>' . t('Adjust the display of your forum topics. Organize the forums on the <a href="@forum-structure">forum structure page</a>.', array('@forum-structure' => url('admin/structure/forum'))) . '</p>';
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function forum_theme() {
+ return array(
+ 'forums' => array(
+ 'template' => 'forums',
+ 'variables' => array('forums' => NULL, 'topics' => NULL, 'parents' => NULL, 'tid' => NULL, 'sortby' => NULL, 'forum_per_page' => NULL),
+ ),
+ 'forum_list' => array(
+ 'template' => 'forum-list',
+ 'variables' => array('forums' => NULL, 'parents' => NULL, 'tid' => NULL),
+ ),
+ 'forum_topic_list' => array(
+ 'template' => 'forum-topic-list',
+ 'variables' => array('tid' => NULL, 'topics' => NULL, 'sortby' => NULL, 'forum_per_page' => NULL),
+ ),
+ 'forum_icon' => array(
+ 'template' => 'forum-icon',
+ 'variables' => array('new_posts' => NULL, 'num_posts' => 0, 'comment_mode' => 0, 'sticky' => 0, 'first_new' => FALSE),
+ ),
+ 'forum_submitted' => array(
+ 'template' => 'forum-submitted',
+ 'variables' => array('topic' => NULL),
+ ),
+ 'forum_form' => array(
+ 'render element' => 'form',
+ 'file' => 'forum.admin.inc',
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function forum_menu() {
+ $items['forum'] = array(
+ 'title' => 'Forums',
+ 'page callback' => 'forum_page',
+ 'access arguments' => array('access content'),
+ 'file' => 'forum.pages.inc',
+ );
+ $items['forum/%forum_forum'] = array(
+ 'title' => 'Forums',
+ 'page callback' => 'forum_page',
+ 'page arguments' => array(1),
+ 'access arguments' => array('access content'),
+ 'file' => 'forum.pages.inc',
+ );
+ $items['admin/structure/forum'] = array(
+ 'title' => 'Forums',
+ 'description' => 'Control forum hierarchy settings.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('forum_overview'),
+ 'access arguments' => array('administer forums'),
+ 'file' => 'forum.admin.inc',
+ );
+ $items['admin/structure/forum/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/structure/forum/add/container'] = array(
+ 'title' => 'Add container',
+ 'page callback' => 'forum_form_main',
+ 'page arguments' => array('container'),
+ 'access arguments' => array('administer forums'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'parent' => 'admin/structure/forum',
+ 'file' => 'forum.admin.inc',
+ );
+ $items['admin/structure/forum/add/forum'] = array(
+ 'title' => 'Add forum',
+ 'page callback' => 'forum_form_main',
+ 'page arguments' => array('forum'),
+ 'access arguments' => array('administer forums'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'parent' => 'admin/structure/forum',
+ 'file' => 'forum.admin.inc',
+ );
+ $items['admin/structure/forum/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('forum_admin_settings'),
+ 'access arguments' => array('administer forums'),
+ 'weight' => 5,
+ 'type' => MENU_LOCAL_TASK,
+ 'parent' => 'admin/structure/forum',
+ 'file' => 'forum.admin.inc',
+ );
+ $items['admin/structure/forum/edit/container/%taxonomy_term'] = array(
+ 'title' => 'Edit container',
+ 'page callback' => 'forum_form_main',
+ 'page arguments' => array('container', 5),
+ 'access arguments' => array('administer forums'),
+ 'file' => 'forum.admin.inc',
+ );
+ $items['admin/structure/forum/edit/forum/%taxonomy_term'] = array(
+ 'title' => 'Edit forum',
+ 'page callback' => 'forum_form_main',
+ 'page arguments' => array('forum', 5),
+ 'access arguments' => array('administer forums'),
+ 'file' => 'forum.admin.inc',
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_menu_local_tasks_alter().
+ */
+function forum_menu_local_tasks_alter(&$data, $router_item, $root_path) {
+ global $user;
+
+ // Add action link to 'node/add/forum' on 'forum' sub-pages.
+ if ($root_path == 'forum' || $root_path == 'forum/%') {
+ $tid = (isset($router_item['page_arguments'][0]) ? $router_item['page_arguments'][0]->tid : 0);
+ $forum_term = forum_forum_load($tid);
+ if ($forum_term) {
+ $links = array();
+ // Loop through all bundles for forum taxonomy vocabulary field.
+ $field = field_info_field('taxonomy_forums');
+ foreach ($field['bundles']['node'] as $type) {
+ if (node_access('create', $type)) {
+ $links[$type] = array(
+ '#theme' => 'menu_local_action',
+ '#link' => array(
+ 'title' => t('Add new @node_type', array('@node_type' => node_type_get_name($type))),
+ 'href' => 'node/add/' . str_replace('_', '-', $type) . '/' . $forum_term->tid,
+ ),
+ );
+ }
+ }
+ if (empty($links)) {
+ // Authenticated user does not have access to create new topics.
+ if ($user->uid) {
+ $links['disallowed'] = array(
+ '#theme' => 'menu_local_action',
+ '#link' => array(
+ 'title' => t('You are not allowed to post new content in the forum.'),
+ ),
+ );
+ }
+ // Anonymous user does not have access to create new topics.
+ else {
+ $links['login'] = array(
+ '#theme' => 'menu_local_action',
+ '#link' => array(
+ 'title' => t('<a href="@login">Log in</a> to post new content in the forum.', array(
+ '@login' => url('user/login', array('query' => drupal_get_destination())),
+ )),
+ 'localized_options' => array('html' => TRUE),
+ ),
+ );
+ }
+ }
+ $data['actions']['output'] = array_merge($data['actions']['output'], $links);
+ }
+ }
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ */
+function forum_entity_info_alter(&$info) {
+ // Take over URI construction for taxonomy terms that are forums.
+ if ($vid = variable_get('forum_nav_vocabulary', 0)) {
+ // Within hook_entity_info(), we can't invoke entity_load() as that would
+ // cause infinite recursion, so we call taxonomy_vocabulary_get_names()
+ // instead of taxonomy_vocabulary_load(). All we need is the machine name
+ // of $vid, so retrieving and iterating all the vocabulary names is somewhat
+ // inefficient, but entity info is cached across page requests, and an
+ // iteration of all vocabularies once per cache clearing isn't a big deal,
+ // and is done as part of taxonomy_entity_info() anyway.
+ foreach (taxonomy_vocabulary_get_names() as $machine_name => $vocabulary) {
+ if ($vid == $vocabulary->vid) {
+ $info['taxonomy_term']['bundles'][$machine_name]['uri callback'] = 'forum_uri';
+ }
+ }
+ }
+}
+
+/**
+ * Entity URI callback.
+ */
+function forum_uri($forum) {
+ return array(
+ 'path' => 'forum/' . $forum->tid,
+ );
+}
+
+/**
+ * Check whether a content type can be used in a forum.
+ *
+ * @param $node
+ * A node object.
+ *
+ * @return
+ * Boolean indicating if the node can be assigned to a forum.
+ */
+function _forum_node_check_node_type($node) {
+ // Fetch information about the forum field.
+ $field = field_info_instance('node', 'taxonomy_forums', $node->type);
+
+ return is_array($field);
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function forum_node_view($node, $view_mode) {
+ $vid = variable_get('forum_nav_vocabulary', 0);
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ if (_forum_node_check_node_type($node)) {
+ if ($view_mode == 'full' && node_is_page($node)) {
+ // Breadcrumb navigation
+ $breadcrumb[] = l(t('Home'), NULL);
+ $breadcrumb[] = l($vocabulary->name, 'forum');
+ if ($parents = taxonomy_get_parents_all($node->forum_tid)) {
+ $parents = array_reverse($parents);
+ foreach ($parents as $parent) {
+ $breadcrumb[] = l($parent->name, 'forum/' . $parent->tid);
+ }
+ }
+ drupal_set_breadcrumb($breadcrumb);
+
+ }
+ }
+}
+
+/**
+ * Implements hook_node_validate().
+ *
+ * Check in particular that only a "leaf" term in the associated taxonomy.
+ */
+function forum_node_validate($node, $form) {
+ if (_forum_node_check_node_type($node)) {
+ $langcode = $form['taxonomy_forums']['#language'];
+ // vocabulary is selected, not a "container" term.
+ if (!empty($node->taxonomy_forums[$langcode])) {
+ // Extract the node's proper topic ID.
+ $containers = variable_get('forum_containers', array());
+ foreach ($node->taxonomy_forums[$langcode] as $delta => $item) {
+ // If no term was selected (e.g. when no terms exist yet), remove the
+ // item.
+ if (empty($item['tid'])) {
+ unset($node->taxonomy_forums[$langcode][$delta]);
+ continue;
+ }
+ $term = taxonomy_term_load($item['tid']);
+ if (!$term) {
+ form_set_error('taxonomy_forums', t('Select a forum.'));
+ continue;
+ }
+ $used = db_query_range('SELECT 1 FROM {taxonomy_term_data} WHERE tid = :tid AND vid = :vid',0 , 1, array(
+ ':tid' => $term->tid,
+ ':vid' => $term->vid,
+ ))->fetchField();
+ if ($used && in_array($term->tid, $containers)) {
+ form_set_error('taxonomy_forums', t('The item %forum is a forum container, not a forum. Select one of the forums below instead.', array('%forum' => $term->name)));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_presave().
+ *
+ * Assign forum taxonomy when adding a topic from within a forum.
+ */
+function forum_node_presave($node) {
+ if (_forum_node_check_node_type($node)) {
+ // Make sure all fields are set properly:
+ $node->icon = !empty($node->icon) ? $node->icon : '';
+ reset($node->taxonomy_forums);
+ $langcode = key($node->taxonomy_forums);
+ if (!empty($node->taxonomy_forums[$langcode])) {
+ $node->forum_tid = $node->taxonomy_forums[$langcode][0]['tid'];
+ $old_tid = db_query_range("SELECT f.tid FROM {forum} f INNER JOIN {node} n ON f.vid = n.vid WHERE n.nid = :nid ORDER BY f.vid DESC", 0, 1, array(':nid' => $node->nid))->fetchField();
+ if ($old_tid && isset($node->forum_tid) && ($node->forum_tid != $old_tid) && !empty($node->shadow)) {
+ // A shadow copy needs to be created. Retain new term and add old term.
+ $node->taxonomy_forums[$langcode][] = array('tid' => $old_tid);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function forum_node_update($node) {
+ if (_forum_node_check_node_type($node)) {
+ if (empty($node->revision) && db_query('SELECT tid FROM {forum} WHERE nid=:nid', array(':nid' => $node->nid))->fetchField()) {
+ if (!empty($node->forum_tid)) {
+ db_update('forum')
+ ->fields(array('tid' => $node->forum_tid))
+ ->condition('vid', $node->vid)
+ ->execute();
+ }
+ // The node is removed from the forum.
+ else {
+ db_delete('forum')
+ ->condition('nid', $node->nid)
+ ->execute();
+ }
+ }
+ else {
+ if (!empty($node->forum_tid)) {
+ db_insert('forum')
+ ->fields(array(
+ 'tid' => $node->forum_tid,
+ 'vid' => $node->vid,
+ 'nid' => $node->nid,
+ ))
+ ->execute();
+ }
+ }
+ // If the node has a shadow forum topic, update the record for this
+ // revision.
+ if (!empty($node->shadow)) {
+ db_delete('forum')
+ ->condition('nid', $node->nid)
+ ->condition('vid', $node->vid)
+ ->execute();
+ db_insert('forum')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'vid' => $node->vid,
+ 'tid' => $node->forum_tid,
+ ))
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function forum_node_insert($node) {
+ if (_forum_node_check_node_type($node)) {
+ if (!empty($node->forum_tid)) {
+ $nid = db_insert('forum')
+ ->fields(array(
+ 'tid' => $node->forum_tid,
+ 'vid' => $node->vid,
+ 'nid' => $node->nid,
+ ))
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function forum_node_delete($node) {
+ if (_forum_node_check_node_type($node)) {
+ db_delete('forum')
+ ->condition('nid', $node->nid)
+ ->execute();
+ db_delete('forum_index')
+ ->condition('nid', $node->nid)
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function forum_node_load($nodes) {
+ $node_vids = array();
+ foreach ($nodes as $node) {
+ if (_forum_node_check_node_type($node)) {
+ $node_vids[] = $node->vid;
+ }
+ }
+ if (!empty($node_vids)) {
+ $query = db_select('forum', 'f');
+ $query
+ ->fields('f', array('nid', 'tid'))
+ ->condition('f.vid', $node_vids);
+ $result = $query->execute();
+ foreach ($result as $record) {
+ $nodes[$record->nid]->forum_tid = $record->tid;
+ }
+ }
+}
+
+/**
+ * Implements hook_node_info().
+ */
+function forum_node_info() {
+ return array(
+ 'forum' => array(
+ 'name' => t('Forum topic'),
+ 'base' => 'forum',
+ 'description' => t('A <em>forum topic</em> starts a new discussion thread within a forum.'),
+ 'title_label' => t('Subject'),
+ )
+ );
+}
+
+/**
+ * Implements hook_permission().
+ */
+function forum_permission() {
+ $perms = array(
+ 'administer forums' => array(
+ 'title' => t('Administer forums'),
+ ),
+ );
+ return $perms;
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function forum_taxonomy_term_delete($tid) {
+ // For containers, remove the tid from the forum_containers variable.
+ $containers = variable_get('forum_containers', array());
+ $key = array_search($tid, $containers);
+ if ($key !== FALSE) {
+ unset($containers[$key]);
+ }
+ variable_set('forum_containers', $containers);
+}
+
+/**
+ * Implements hook_comment_publish().
+ *
+ * This actually handles the insert and update of published nodes since
+ * comment_save() calls hook_comment_publish() for all published comments.
+ */
+function forum_comment_publish($comment) {
+ _forum_update_forum_index($comment->nid);
+}
+
+/**
+ * Implements hook_comment_update().
+ *
+ * Comment module doesn't call hook_comment_unpublish() when saving individual
+ * comments so we need to check for those here.
+ */
+function forum_comment_update($comment) {
+ // comment_save() calls hook_comment_publish() for all published comments
+ // so we to handle all other values here.
+ if (!$comment->status) {
+ _forum_update_forum_index($comment->nid);
+ }
+}
+
+/**
+ * Implements hook_comment_unpublish().
+ */
+function forum_comment_unpublish($comment) {
+ _forum_update_forum_index($comment->nid);
+}
+
+/**
+ * Implements hook_comment_delete().
+ */
+function forum_comment_delete($comment) {
+ _forum_update_forum_index($comment->nid);
+}
+
+/**
+ * Implements hook_field_storage_pre_insert().
+ */
+function forum_field_storage_pre_insert($entity_type, $entity, &$skip_fields) {
+ if ($entity_type == 'node' && $entity->status && _forum_node_check_node_type($entity)) {
+ $query = db_insert('forum_index')->fields(array('nid', 'title', 'tid', 'sticky', 'created', 'comment_count', 'last_comment_timestamp'));
+ foreach ($entity->taxonomy_forums as $language) {
+ foreach ($language as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'title' => $entity->title,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $entity->created,
+ ));
+ }
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Implements hook_field_storage_pre_update().
+ */
+function forum_field_storage_pre_update($entity_type, $entity, &$skip_fields) {
+ $first_call = &drupal_static(__FUNCTION__, array());
+
+ if ($entity_type == 'node' && $entity->status && _forum_node_check_node_type($entity)) {
+ // We don't maintain data for old revisions, so clear all previous values
+ // from the table. Since this hook runs once per field, per object, make
+ // sure we only wipe values once.
+ if (!isset($first_call[$entity->nid])) {
+ $first_call[$entity->nid] = FALSE;
+ db_delete('forum_index')->condition('nid', $entity->nid)->execute();
+ }
+ // Only save data to the table if the node is published.
+ if ($entity->status) {
+ $query = db_insert('forum_index')->fields(array('nid', 'title', 'tid', 'sticky', 'created', 'comment_count', 'last_comment_timestamp'));
+ foreach ($entity->taxonomy_forums as $language) {
+ foreach ($language as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'title' => $entity->title,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $entity->created,
+ ));
+ }
+ }
+ $query->execute();
+ // The logic for determining last_comment_count is fairly complex, so
+ // call _forum_update_forum_index() too.
+ _forum_update_forum_index($entity->nid);
+ }
+ }
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function forum_form_alter(&$form, $form_state, $form_id) {
+ $vid = variable_get('forum_nav_vocabulary', 0);
+ if (isset($form['vid']['#value']) && $form['vid']['#value'] == $vid) {
+ // Hide critical options from forum vocabulary.
+ if ($form_id == 'taxonomy_form_vocabulary') {
+ $form['help_forum_vocab'] = array(
+ '#markup' => t('This is the designated forum vocabulary. Some of the normal vocabulary options have been removed.'),
+ '#weight' => -1,
+ );
+ $form['hierarchy'] = array('#type' => 'value', '#value' => 1);
+ $form['delete']['#access'] = FALSE;
+ }
+ // Hide multiple parents select from forum terms.
+ elseif ($form_id == 'taxonomy_form_term') {
+ $form['advanced']['parent']['#access'] = FALSE;
+ }
+ }
+ if (!empty($form['#node_edit_form']) && isset($form['taxonomy_forums'])) {
+ $langcode = $form['taxonomy_forums']['#language'];
+ // Make the vocabulary required for 'real' forum-nodes.
+ $form['taxonomy_forums'][$langcode]['#required'] = TRUE;
+ $form['taxonomy_forums'][$langcode]['#multiple'] = FALSE;
+ if (empty($form['taxonomy_forums'][$langcode]['#default_value'])) {
+ // If there is no default forum already selected, try to get the forum
+ // ID from the URL (e.g., if we are on a page like node/add/forum/2, we
+ // expect "2" to be the ID of the forum that was requested).
+ $requested_forum_id = arg(3);
+ $form['taxonomy_forums'][$langcode]['#default_value'] = is_numeric($requested_forum_id) ? $requested_forum_id : '';
+ }
+ }
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function forum_block_info() {
+ $blocks['active'] = array(
+ 'info' => t('Active forum topics'),
+ 'cache' => DRUPAL_CACHE_CUSTOM,
+ 'properties' => array('administrative' => TRUE),
+ );
+ $blocks['new'] = array(
+ 'info' => t('New forum topics'),
+ 'cache' => DRUPAL_CACHE_CUSTOM,
+ 'properties' => array('administrative' => TRUE),
+ );
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function forum_block_configure($delta = '') {
+ $form['forum_block_num_' . $delta] = array('#type' => 'select', '#title' => t('Number of topics'), '#default_value' => variable_get('forum_block_num_' . $delta, '5'), '#options' => drupal_map_assoc(array(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)));
+ return $form;
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function forum_block_save($delta = '', $edit = array()) {
+ variable_set('forum_block_num_' . $delta, $edit['forum_block_num_' . $delta]);
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generates a block containing the currently active forum topics and the
+ * most recently added forum topics.
+ */
+function forum_block_view($delta = '') {
+ $query = db_select('forum_index', 'f')
+ ->fields('f')
+ ->addTag('node_access');
+ switch ($delta) {
+ case 'active':
+ $title = t('Active forum topics');
+ $query
+ ->orderBy('f.last_comment_timestamp', 'DESC')
+ ->range(0, variable_get('forum_block_num_active', '5'));
+ break;
+
+ case 'new':
+ $title = t('New forum topics');
+ $query
+ ->orderBy('f.created', 'DESC')
+ ->range(0, variable_get('forum_block_num_new', '5'));
+ break;
+ }
+
+ $block['subject'] = $title;
+ // Cache based on the altered query. Enables us to cache with node access enabled.
+ $block['content'] = drupal_render_cache_by_query($query, 'forum_block_view');
+ $block['content']['#access'] = user_access('access content');
+ return $block;
+}
+
+/**
+* A #pre_render callback. Lists nodes based on the element's #query property.
+*
+* @see forum_block_view()
+*
+* @return
+* A renderable array.
+*/
+function forum_block_view_pre_render($elements) {
+ $result = $elements['#query']->execute();
+ if ($node_title_list = node_title_list($result)) {
+ $elements['forum_list'] = $node_title_list;
+ $elements['forum_more'] = array('#theme' => 'more_link', '#url' => 'forum', '#title' => t('Read the latest forum topics.'));
+ }
+ return $elements;
+}
+
+/**
+ * Implements hook_form().
+ */
+function forum_form($node, $form_state) {
+ $type = node_type_get_type($node);
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => check_plain($type->title_label),
+ '#default_value' => !empty($node->title) ? $node->title : '',
+ '#required' => TRUE, '#weight' => -5
+ );
+
+ if (!empty($node->nid)) {
+ $forum_terms = $node->taxonomy_forums;
+ // If editing, give option to leave shadows
+ $shadow = (count($forum_terms) > 1);
+ $form['shadow'] = array('#type' => 'checkbox', '#title' => t('Leave shadow copy'), '#default_value' => $shadow, '#description' => t('If you move this topic, you can leave a link in the old forum to the new forum.'));
+ $form['forum_tid'] = array('#type' => 'value', '#value' => $node->forum_tid);
+ }
+
+ return $form;
+}
+
+/**
+ * Returns a tree of all forums for a given taxonomy term ID.
+ *
+ * @param $tid
+ * (optional) Taxonomy ID of the forum, if not givin all forums will be returned.
+ * @return
+ * A tree of taxonomy objects, with the following additional properties:
+ * - 'num_topics': Number of topics in the forum
+ * - 'num_posts': Total number of posts in all topics
+ * - 'last_post': Most recent post for the forum
+ * - 'forums': An array of child forums
+ */
+function forum_forum_load($tid = NULL) {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ // Return a cached forum tree if available.
+ if (!isset($tid)) {
+ $tid = 0;
+ }
+ if (isset($cache[$tid])) {
+ return $cache[$tid];
+ }
+
+ $vid = variable_get('forum_nav_vocabulary', 0);
+
+ // Load and validate the parent term.
+ if ($tid) {
+ $forum_term = taxonomy_term_load($tid);
+ if (!$forum_term || ($forum_term->vid != $vid)) {
+ return $cache[$tid] = FALSE;
+ }
+ }
+ // If $tid is 0, create an empty object to hold the child terms.
+ elseif ($tid === 0) {
+ $forum_term = (object) array(
+ 'tid' => 0,
+ );
+ }
+
+ // Determine if the requested term is a container.
+ if (!$forum_term->tid || in_array($forum_term->tid, variable_get('forum_containers', array()))) {
+ $forum_term->container = 1;
+ }
+
+ // Load parent terms.
+ $forum_term->parents = taxonomy_get_parents_all($forum_term->tid);
+
+ // Load the tree below.
+ $forums = array();
+ $_forums = taxonomy_get_tree($vid, $tid);
+
+ if (count($_forums)) {
+ $query = db_select('node', 'n');
+ $query->join('node_comment_statistics', 'ncs', 'n.nid = ncs.nid');
+ $query->join('forum', 'f', 'n.vid = f.vid');
+ $query->addExpression('COUNT(n.nid)', 'topic_count');
+ $query->addExpression('SUM(ncs.comment_count)', 'comment_count');
+ $counts = $query
+ ->fields('f', array('tid'))
+ ->condition('status', 1)
+ ->groupBy('tid')
+ ->addTag('node_access')
+ ->execute()
+ ->fetchAllAssoc('tid');
+ }
+
+ foreach ($_forums as $forum) {
+ // Determine if the child term is a container.
+ if (in_array($forum->tid, variable_get('forum_containers', array()))) {
+ $forum->container = 1;
+ }
+
+ // Merge in the topic and post counters.
+ if (!empty($counts[$forum->tid])) {
+ $forum->num_topics = $counts[$forum->tid]->topic_count;
+ $forum->num_posts = $counts[$forum->tid]->topic_count + $counts[$forum->tid]->comment_count;
+ }
+ else {
+ $forum->num_topics = 0;
+ $forum->num_posts = 0;
+ }
+
+ // Query "Last Post" information for this forum.
+ $query = db_select('node', 'n');
+ $query->join('users', 'u1', 'n.uid = u1.uid');
+ $query->join('forum', 'f', 'n.vid = f.vid AND f.tid = :tid', array(':tid' => $forum->tid));
+ $query->join('node_comment_statistics', 'ncs', 'n.nid = ncs.nid');
+ $query->join('users', 'u2', 'ncs.last_comment_uid = u2.uid');
+ $query->addExpression('CASE ncs.last_comment_uid WHEN 0 THEN ncs.last_comment_name ELSE u2.name END', 'last_comment_name');
+
+ $topic = $query
+ ->fields('ncs', array('last_comment_timestamp', 'last_comment_uid'))
+ ->condition('n.status', 1)
+ ->orderBy('last_comment_timestamp', 'DESC')
+ ->range(0, 1)
+ ->addTag('node_access')
+ ->execute()
+ ->fetchObject();
+
+ // Merge in the "Last Post" information.
+ $last_post = new stdClass();
+ if (!empty($topic->last_comment_timestamp)) {
+ $last_post->created = $topic->last_comment_timestamp;
+ $last_post->name = $topic->last_comment_name;
+ $last_post->uid = $topic->last_comment_uid;
+ }
+ $forum->last_post = $last_post;
+
+ $forums[$forum->tid] = $forum;
+ }
+
+ // Cache the result, and return the tree.
+ $forum_term->forums = $forums;
+ $cache[$tid] = $forum_term;
+ return $forum_term;
+}
+
+/**
+ * Calculate the number of nodes the user has not yet read and are newer
+ * than NODE_NEW_LIMIT.
+ */
+function _forum_topics_unread($term, $uid) {
+ $query = db_select('node', 'n');
+ $query->join('forum', 'f', 'n.vid = f.vid AND f.tid = :tid', array(':tid' => $term));
+ $query->leftJoin('history', 'h', 'n.nid = h.nid AND h.uid = :uid', array(':uid' => $uid));
+ $query->addExpression('COUNT(n.nid)', 'count');
+ return $query
+ ->condition('status', 1)
+ ->condition('n.created', NODE_NEW_LIMIT, '>')
+ ->isNull('h.nid')
+ ->addTag('node_access')
+ ->execute()
+ ->fetchField();
+}
+
+function forum_get_topics($tid, $sortby, $forum_per_page) {
+ global $user, $forum_topic_list_header;
+
+ $forum_topic_list_header = array(
+ NULL,
+ array('data' => t('Topic'), 'field' => 'f.title'),
+ array('data' => t('Replies'), 'field' => 'f.comment_count'),
+ array('data' => t('Last reply'), 'field' => 'f.last_comment_timestamp'),
+ );
+
+ $order = _forum_get_topic_order($sortby);
+ for ($i = 0; $i < count($forum_topic_list_header); $i++) {
+ if ($forum_topic_list_header[$i]['field'] == $order['field']) {
+ $forum_topic_list_header[$i]['sort'] = $order['sort'];
+ }
+ }
+
+ $query = db_select('forum_index', 'f')->extend('PagerDefault')->extend('TableSort');
+ $query->fields('f');
+ $query
+ ->condition('f.tid', $tid)
+ ->addTag('node_access')
+ ->orderBy('f.sticky', 'DESC')
+ ->orderByHeader($forum_topic_list_header)
+ ->limit($forum_per_page);
+
+ $count_query = db_select('forum_index', 'f');
+ $count_query->condition('f.tid', $tid);
+ $count_query->addExpression('COUNT(*)');
+ $count_query->addTag('node_access');
+
+ $query->setCountQuery($count_query);
+ $result = $query->execute();
+ $nids = array();
+ foreach ($result as $record) {
+ $nids[] = $record->nid;
+ }
+ if ($nids) {
+ $query = db_select('node', 'n')->extend('TableSort');
+ $query->fields('n', array('title', 'nid', 'type', 'sticky', 'created', 'uid'));
+ $query->addField('n', 'comment', 'comment_mode');
+
+ $query->join('node_comment_statistics', 'ncs', 'n.nid = ncs.nid');
+ $query->fields('ncs', array('cid', 'last_comment_uid', 'last_comment_timestamp', 'comment_count'));
+
+ $query->join('forum_index', 'f', 'f.nid = ncs.nid');
+ $query->addField('f', 'tid', 'forum_tid');
+
+ $query->join('users', 'u', 'n.uid = u.uid');
+ $query->addField('u', 'name');
+
+ $query->join('users', 'u2', 'ncs.last_comment_uid = u2.uid');
+
+ $query->addExpression('CASE ncs.last_comment_uid WHEN 0 THEN ncs.last_comment_name ELSE u2.name END', 'last_comment_name');
+
+ $query
+ ->orderBy('f.sticky', 'DESC')
+ ->orderByHeader($forum_topic_list_header)
+ ->condition('n.nid', $nids);
+
+ $result = $query->execute();
+ }
+ else {
+ $result = array();
+ }
+
+ $topics = array();
+ $first_new_found = FALSE;
+ foreach ($result as $topic) {
+ if ($user->uid) {
+ // folder is new if topic is new or there are new comments since last visit
+ if ($topic->forum_tid != $tid) {
+ $topic->new = 0;
+ }
+ else {
+ $history = _forum_user_last_visit($topic->nid);
+ $topic->new_replies = comment_num_new($topic->nid, $history);
+ $topic->new = $topic->new_replies || ($topic->last_comment_timestamp > $history);
+ }
+ }
+ else {
+ // Do not track "new replies" status for topics if the user is anonymous.
+ $topic->new_replies = 0;
+ $topic->new = 0;
+ }
+
+ // Make sure only one topic is indicated as the first new topic.
+ $topic->first_new = FALSE;
+ if ($topic->new != 0 && !$first_new_found) {
+ $topic->first_new = TRUE;
+ $first_new_found = TRUE;
+ }
+
+ if ($topic->comment_count > 0) {
+ $last_reply = new stdClass();
+ $last_reply->created = $topic->last_comment_timestamp;
+ $last_reply->name = $topic->last_comment_name;
+ $last_reply->uid = $topic->last_comment_uid;
+ $topic->last_reply = $last_reply;
+ }
+ $topics[] = $topic;
+ }
+
+ return $topics;
+}
+
+/**
+ * Process variables for forums.tpl.php
+ *
+ * The $variables array contains the following arguments:
+ * - $forums
+ * - $topics
+ * - $parents
+ * - $tid
+ * - $sortby
+ * - $forum_per_page
+ *
+ * @see forums.tpl.php
+ */
+function template_preprocess_forums(&$variables) {
+ global $user;
+
+ $vid = variable_get('forum_nav_vocabulary', 0);
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ $title = !empty($vocabulary->name) ? $vocabulary->name : '';
+
+ // Breadcrumb navigation:
+ $breadcrumb[] = l(t('Home'), NULL);
+ if ($variables['tid']) {
+ $breadcrumb[] = l($vocabulary->name, 'forum');
+ }
+ if ($variables['parents']) {
+ $variables['parents'] = array_reverse($variables['parents']);
+ foreach ($variables['parents'] as $p) {
+ if ($p->tid == $variables['tid']) {
+ $title = $p->name;
+ }
+ else {
+ $breadcrumb[] = l($p->name, 'forum/' . $p->tid);
+ }
+ }
+ }
+ drupal_set_breadcrumb($breadcrumb);
+ drupal_set_title($title);
+
+ if ($variables['forums_defined'] = count($variables['forums']) || count($variables['parents'])) {
+ if (!empty($variables['forums'])) {
+ $variables['forums'] = theme('forum_list', $variables);
+ }
+ else {
+ $variables['forums'] = '';
+ }
+
+ if ($variables['tid'] && !in_array($variables['tid'], variable_get('forum_containers', array()))) {
+ $variables['topics'] = theme('forum_topic_list', $variables);
+ drupal_add_feed('taxonomy/term/' . $variables['tid'] . '/feed', 'RSS - ' . $title);
+ }
+ else {
+ $variables['topics'] = '';
+ }
+
+ // Provide separate template suggestions based on what's being output. Topic id is also accounted for.
+ // Check both variables to be safe then the inverse. Forums with topic ID's take precedence.
+ if ($variables['forums'] && !$variables['topics']) {
+ $variables['theme_hook_suggestions'][] = 'forums__containers';
+ $variables['theme_hook_suggestions'][] = 'forums__' . $variables['tid'];
+ $variables['theme_hook_suggestions'][] = 'forums__containers__' . $variables['tid'];
+ }
+ elseif (!$variables['forums'] && $variables['topics']) {
+ $variables['theme_hook_suggestions'][] = 'forums__topics';
+ $variables['theme_hook_suggestions'][] = 'forums__' . $variables['tid'];
+ $variables['theme_hook_suggestions'][] = 'forums__topics__' . $variables['tid'];
+ }
+ else {
+ $variables['theme_hook_suggestions'][] = 'forums__' . $variables['tid'];
+ }
+
+ }
+ else {
+ drupal_set_title(t('No forums defined'));
+ $variables['forums'] = '';
+ $variables['topics'] = '';
+ }
+}
+
+/**
+ * Process variables to format a forum listing.
+ *
+ * $variables contains the following information:
+ * - $forums
+ * - $parents
+ * - $tid
+ *
+ * @see forum-list.tpl.php
+ * @see theme_forum_list()
+ */
+function template_preprocess_forum_list(&$variables) {
+ global $user;
+ $row = 0;
+ // Sanitize each forum so that the template can safely print the data.
+ foreach ($variables['forums'] as $id => $forum) {
+ $variables['forums'][$id]->description = !empty($forum->description) ? filter_xss_admin($forum->description) : '';
+ $variables['forums'][$id]->link = url("forum/$forum->tid");
+ $variables['forums'][$id]->name = check_plain($forum->name);
+ $variables['forums'][$id]->is_container = !empty($forum->container);
+ $variables['forums'][$id]->zebra = $row % 2 == 0 ? 'odd' : 'even';
+ $row++;
+
+ $variables['forums'][$id]->new_text = '';
+ $variables['forums'][$id]->new_url = '';
+ $variables['forums'][$id]->new_topics = 0;
+ $variables['forums'][$id]->old_topics = $forum->num_topics;
+ $variables['forums'][$id]->icon_class = 'default';
+ $variables['forums'][$id]->icon_title = t('No new posts');
+ if ($user->uid) {
+ $variables['forums'][$id]->new_topics = _forum_topics_unread($forum->tid, $user->uid);
+ if ($variables['forums'][$id]->new_topics) {
+ $variables['forums'][$id]->new_text = format_plural($variables['forums'][$id]->new_topics, '1 new', '@count new');
+ $variables['forums'][$id]->new_url = url("forum/$forum->tid", array('fragment' => 'new'));
+ $variables['forums'][$id]->icon_class = 'new';
+ $variables['forums'][$id]->icon_title = t('New posts');
+ }
+ $variables['forums'][$id]->old_topics = $forum->num_topics - $variables['forums'][$id]->new_topics;
+ }
+ $variables['forums'][$id]->last_reply = theme('forum_submitted', array('topic' => $forum->last_post));
+ }
+ // Give meaning to $tid for themers. $tid actually stands for term id.
+ $variables['forum_id'] = $variables['tid'];
+ unset($variables['tid']);
+}
+
+/**
+ * Preprocess variables to format the topic listing.
+ *
+ * $variables contains the following data:
+ * - $tid
+ * - $topics
+ * - $sortby
+ * - $forum_per_page
+ *
+ * @see forum-topic-list.tpl.php
+ * @see theme_forum_topic_list()
+ */
+function template_preprocess_forum_topic_list(&$variables) {
+ global $forum_topic_list_header;
+
+ // Create the tablesorting header.
+ $ts = tablesort_init($forum_topic_list_header);
+ $header = '';
+ foreach ($forum_topic_list_header as $cell) {
+ $cell = tablesort_header($cell, $forum_topic_list_header, $ts);
+ $header .= _theme_table_cell($cell, TRUE);
+ }
+ $variables['header'] = $header;
+
+ if (!empty($variables['topics'])) {
+ $row = 0;
+ foreach ($variables['topics'] as $id => $topic) {
+ $variables['topics'][$id]->icon = theme('forum_icon', array('new_posts' => $topic->new, 'num_posts' => $topic->comment_count, 'comment_mode' => $topic->comment_mode, 'sticky' => $topic->sticky, 'first_new' => $topic->first_new));
+ $variables['topics'][$id]->zebra = $row % 2 == 0 ? 'odd' : 'even';
+ $row++;
+
+ // We keep the actual tid in forum table, if it's different from the
+ // current tid then it means the topic appears in two forums, one of
+ // them is a shadow copy.
+ if ($variables['tid'] != $topic->forum_tid) {
+ $variables['topics'][$id]->moved = TRUE;
+ $variables['topics'][$id]->title = check_plain($topic->title);
+ $variables['topics'][$id]->message = l(t('This topic has been moved'), "forum/$topic->forum_tid");
+ }
+ else {
+ $variables['topics'][$id]->moved = FALSE;
+ $variables['topics'][$id]->title = l($topic->title, "node/$topic->nid");
+ $variables['topics'][$id]->message = '';
+ }
+ $variables['topics'][$id]->created = theme('forum_submitted', array('topic' => $topic));
+ $variables['topics'][$id]->last_reply = theme('forum_submitted', array('topic' => isset($topic->last_reply) ? $topic->last_reply : NULL));
+
+ $variables['topics'][$id]->new_text = '';
+ $variables['topics'][$id]->new_url = '';
+ if ($topic->new_replies) {
+ $variables['topics'][$id]->new_text = format_plural($topic->new_replies, '1 new', '@count new');
+ $variables['topics'][$id]->new_url = url("node/$topic->nid", array('query' => comment_new_page_count($topic->comment_count, $topic->new_replies, $topic), 'fragment' => 'new'));
+ }
+
+ }
+ }
+ else {
+ // Make this safe for the template
+ $variables['topics'] = array();
+ }
+ // Give meaning to $tid for themers. $tid actually stands for term id.
+ $variables['topic_id'] = $variables['tid'];
+ unset($variables['tid']);
+
+ $variables['pager'] = theme('pager');
+}
+
+/**
+ * Process variables to format the icon for each individual topic.
+ *
+ * $variables contains the following data:
+ * - $new_posts
+ * - $num_posts = 0
+ * - $comment_mode = 0
+ * - $sticky = 0
+ * - $first_new
+ *
+ * @see forum-icon.tpl.php
+ * @see theme_forum_icon()
+ */
+function template_preprocess_forum_icon(&$variables) {
+ $variables['hot_threshold'] = variable_get('forum_hot_topic', 15);
+ if ($variables['num_posts'] > $variables['hot_threshold']) {
+ $variables['icon_class'] = $variables['new_posts'] ? 'hot-new' : 'hot';
+ $variables['icon_title'] = $variables['new_posts'] ? t('Hot topic, new comments') : t('Hot topic');
+ }
+ else {
+ $variables['icon_class'] = $variables['new_posts'] ? 'new' : 'default';
+ $variables['icon_title'] = $variables['new_posts'] ? t('New comments') : t('Normal topic');
+ }
+
+ if ($variables['comment_mode'] == COMMENT_NODE_CLOSED || $variables['comment_mode'] == COMMENT_NODE_HIDDEN) {
+ $variables['icon_class'] = 'closed';
+ $variables['icon_title'] = t('Closed topic');
+ }
+
+ if ($variables['sticky'] == 1) {
+ $variables['icon_class'] = 'sticky';
+ $variables['icon_title'] = t('Sticky topic');
+ }
+}
+
+/**
+ * Process variables to format submission info for display in the forum list and topic list.
+ *
+ * $variables will contain: $topic
+ *
+ * @see forum-submitted.tpl.php
+ * @see theme_forum_submitted()
+ */
+function template_preprocess_forum_submitted(&$variables) {
+ $variables['author'] = isset($variables['topic']->uid) ? theme('username', array('account' => $variables['topic'])) : '';
+ $variables['time'] = isset($variables['topic']->created) ? format_interval(REQUEST_TIME - $variables['topic']->created) : '';
+}
+
+function _forum_user_last_visit($nid) {
+ global $user;
+ $history = &drupal_static(__FUNCTION__, array());
+
+ if (empty($history)) {
+ $result = db_query('SELECT nid, timestamp FROM {history} WHERE uid = :uid', array(':uid' => $user->uid));
+ foreach ($result as $t) {
+ $history[$t->nid] = $t->timestamp > NODE_NEW_LIMIT ? $t->timestamp : NODE_NEW_LIMIT;
+ }
+ }
+ return isset($history[$nid]) ? $history[$nid] : NODE_NEW_LIMIT;
+}
+
+function _forum_get_topic_order($sortby) {
+ switch ($sortby) {
+ case 1:
+ return array('field' => 'f.last_comment_timestamp', 'sort' => 'desc');
+ break;
+ case 2:
+ return array('field' => 'f.last_comment_timestamp', 'sort' => 'asc');
+ break;
+ case 3:
+ return array('field' => 'f.comment_count', 'sort' => 'desc');
+ break;
+ case 4:
+ return array('field' => 'f.comment_count', 'sort' => 'asc');
+ break;
+ }
+}
+
+/**
+ * Updates the taxonomy index for a given node.
+ *
+ * @param $nid
+ * The ID of the node to update.
+ */
+function _forum_update_forum_index($nid) {
+ $count = db_query('SELECT COUNT(cid) FROM {comment} WHERE nid = :nid AND status = :status', array(
+ ':nid' => $nid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchField();
+
+ if ($count > 0) {
+ // Comments exist.
+ $last_reply = db_query_range('SELECT cid, name, created, uid FROM {comment} WHERE nid = :nid AND status = :status ORDER BY cid DESC', 0, 1, array(
+ ':nid' => $nid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchObject();
+ db_update('forum_index')
+ ->fields( array(
+ 'comment_count' => $count,
+ 'last_comment_timestamp' => $last_reply->created,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ }
+ else {
+ // Comments do not exist.
+ $node = db_query('SELECT uid, created FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetchObject();
+ db_update('forum_index')
+ ->fields( array(
+ 'comment_count' => 0,
+ 'last_comment_timestamp' => $node->created,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function forum_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'node',
+ 'bundle' => 'forum',
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post', 'sioct:BoardPost'),
+ 'taxonomy_forums' => array(
+ 'predicates' => array('sioc:has_container'),
+ 'type' => 'rel',
+ ),
+ ),
+ ),
+ array(
+ 'type' => 'taxonomy_term',
+ 'bundle' => 'forums',
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Container', 'sioc:Forum'),
+ ),
+ ),
+ );
+}
diff --git a/core/modules/forum/forum.pages.inc b/core/modules/forum/forum.pages.inc
new file mode 100644
index 000000000000..29307e719402
--- /dev/null
+++ b/core/modules/forum/forum.pages.inc
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the forum module.
+ */
+
+/**
+ * Menu callback; prints a forum listing.
+ */
+function forum_page($forum_term = NULL) {
+ if (!isset($forum_term)) {
+ // On the main page, display all the top-level forums.
+ $forum_term = forum_forum_load(0);
+ }
+
+ $forum_per_page = variable_get('forum_per_page', 25);
+ $sortby = variable_get('forum_order', 1);
+
+ if (empty($forum_term->container)) {
+ $topics = forum_get_topics($forum_term->tid, $sortby, $forum_per_page);
+ }
+ else {
+ $topics = '';
+ }
+
+ return theme('forums', array('forums' => $forum_term->forums, 'topics' => $topics, 'parents' => $forum_term->parents, 'tid' => $forum_term->tid, 'sortby' => $sortby, 'forums_per_page' => $forum_per_page));
+}
diff --git a/core/modules/forum/forum.test b/core/modules/forum/forum.test
new file mode 100644
index 000000000000..c7c3d9c1b8c2
--- /dev/null
+++ b/core/modules/forum/forum.test
@@ -0,0 +1,550 @@
+<?php
+
+/**
+ * @file
+ * Tests for forum.module.
+ */
+
+class ForumTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $edit_own_topics_user;
+ protected $edit_any_topics_user;
+ protected $web_user;
+ protected $container;
+ protected $forum;
+ protected $root_forum;
+ protected $nids;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Forum functionality',
+ 'description' => 'Create, view, edit, delete, and change forum entries and verify its consistency in the database.',
+ 'group' => 'Forum',
+ );
+ }
+
+ /**
+ * Enable modules and create users with specific permissions.
+ */
+ function setUp() {
+ parent::setUp('taxonomy', 'comment', 'forum');
+ // Create users.
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'administer modules',
+ 'administer blocks',
+ 'administer forums',
+ 'administer menu',
+ 'administer taxonomy',
+ 'create forum content',
+ ));
+ $this->edit_any_topics_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'create forum content',
+ 'edit any forum content',
+ 'delete any forum content',
+ ));
+ $this->edit_own_topics_user = $this->drupalCreateUser(array(
+ 'create forum content',
+ 'edit own forum content',
+ 'delete own forum content',
+ ));
+ $this->web_user = $this->drupalCreateUser(array());
+ }
+
+ /**
+ * Tests disabling and re-enabling forum.
+ */
+ function testEnableForumField() {
+ $this->drupalLogin($this->admin_user);
+
+ // Disable the forum module.
+ $edit = array();
+ $edit['modules[Core][forum][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ module_list(TRUE);
+ $this->assertFalse(module_exists('forum'), t('Forum module is not enabled.'));
+
+ // Attempt to re-enable the forum module and ensure it does not try to
+ // recreate the taxonomy_forums field.
+ $edit = array();
+ $edit['modules[Core][forum][enable]'] = 'forum';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ module_list(TRUE);
+ $this->assertTrue(module_exists('forum'), t('Forum module is enabled.'));
+ }
+
+ /**
+ * Login users, create forum nodes, and test forum functionality through the admin and user interfaces.
+ */
+ function testForum() {
+ //Check that the basic forum install creates a default forum topic
+ $this->drupalGet("/forum");
+ // Look for the "General discussion" default forum
+ $this->assertText(t("General discussion"), "Found the default forum at the /forum listing");
+
+ // Do the admin tests.
+ $this->doAdminTests($this->admin_user);
+ // Generate topics to populate the active forum block.
+ $this->generateForumTopics($this->forum);
+
+ // Login an unprivileged user to view the forum topics and generate an
+ // active forum topics list.
+ $this->drupalLogin($this->web_user);
+ // Verify that this user is shown a message that they may not post content.
+ $this->drupalGet('forum/' . $this->forum['tid']);
+ $this->assertText(t('You are not allowed to post new content in the forum'), "Authenticated user without permission to post forum content is shown message in local tasks to that effect.");
+
+ $this->viewForumTopics($this->nids);
+
+ // Log in, and do basic tests for a user with permission to edit any forum
+ // content.
+ $this->doBasicTests($this->edit_any_topics_user, TRUE);
+ // Create a forum node authored by this user.
+ $any_topics_user_node = $this->createForumTopic($this->forum, FALSE);
+
+ // Log in, and do basic tests for a user with permission to edit only its
+ // own forum content.
+ $this->doBasicTests($this->edit_own_topics_user, FALSE);
+ // Create a forum node authored by this user.
+ $own_topics_user_node = $this->createForumTopic($this->forum, FALSE);
+ // Verify that this user cannot edit forum content authored by another user.
+ $this->verifyForums($this->edit_any_topics_user, $any_topics_user_node, FALSE, 403);
+
+ // Verify that this user is shown a local task to add new forum content.
+ $this->drupalGet('forum');
+ $this->assertLink(t('Add new Forum topic'));
+ $this->drupalGet('forum/' . $this->forum['tid']);
+ $this->assertLink(t('Add new Forum topic'));
+
+ // Login a user with permission to edit any forum content.
+ $this->drupalLogin($this->edit_any_topics_user);
+ // Verify that this user can edit forum content authored by another user.
+ $this->verifyForums($this->edit_own_topics_user, $own_topics_user_node, TRUE);
+
+ // Verify the topic and post counts on the forum page.
+ $this->drupalGet('forum');
+
+ // Verify row for testing forum.
+ $forum_arg = array(':forum' => 'forum-list-' . $this->forum['tid']);
+
+ // Topics cell contains number of topics and number of unread topics.
+ $xpath = $this->buildXPathQuery('//tr[@id=:forum]//td[@class="topics"]', $forum_arg);
+ $topics = $this->xpath($xpath);
+ $topics = trim($topics[0]);
+ $this->assertEqual($topics, '6', t('Number of topics found.'));
+
+ // Verify the number of unread topics.
+ $unread_topics = _forum_topics_unread($this->forum['tid'], $this->edit_any_topics_user->uid);
+ $unread_topics = format_plural($unread_topics, '1 new', '@count new');
+ $xpath = $this->buildXPathQuery('//tr[@id=:forum]//td[@class="topics"]//a', $forum_arg);
+ $this->assertFieldByXPath($xpath, $unread_topics, t('Number of unread topics found.'));
+
+ // Verify total number of posts in forum.
+ $xpath = $this->buildXPathQuery('//tr[@id=:forum]//td[@class="posts"]', $forum_arg);
+ $this->assertFieldByXPath($xpath, '6', t('Number of posts found.'));
+
+ // Test loading multiple forum nodes on the front page.
+ $this->drupalLogin($this->drupalCreateUser(array('administer content types', 'create forum content')));
+ $this->drupalPost('admin/structure/types/manage/forum', array('node_options[promote]' => 'promote'), t('Save content type'));
+ $this->createForumTopic($this->forum, FALSE);
+ $this->createForumTopic($this->forum, FALSE);
+ $this->drupalGet('node');
+
+ // Test adding a comment to a forum topic.
+ $node = $this->createForumTopic($this->forum, FALSE);
+ $edit = array();
+ $edit['comment_body[' . LANGUAGE_NONE . '][0][value]'] = $this->randomName();
+ $this->drupalPost("node/$node->nid", $edit, t('Save'));
+ $this->assertResponse(200);
+
+ // Test editing a forum topic that has a comment.
+ $this->drupalLogin($this->edit_any_topics_user);
+ $this->drupalGet('forum/' . $this->forum['tid']);
+ $this->drupalPost("node/$node->nid/edit", array(), t('Save'));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Forum nodes should not be created without choosing forum from select list.
+ */
+ function testAddOrphanTopic() {
+ // Must remove forum topics to test creating orphan topics.
+ $vid = variable_get('forum_nav_vocabulary');
+ $tree = taxonomy_get_tree($vid);
+ foreach ($tree as $term) {
+ taxonomy_term_delete($term->tid);
+ }
+
+ // Create an orphan forum item.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalPost('node/add/forum', array('title' => $this->randomName(10), 'body[' . LANGUAGE_NONE .'][0][value]' => $this->randomName(120)), t('Save'));
+
+ $nid_count = db_query('SELECT COUNT(nid) FROM {node}')->fetchField();
+ $this->assertEqual(0, $nid_count, t('A forum node was not created when missing a forum vocabulary.'));
+
+ // Reset the defaults for future tests.
+ module_enable(array('forum'));
+ }
+
+ /**
+ * Run admin tests on the admin user.
+ *
+ * @param object $user The logged in user.
+ */
+ private function doAdminTests($user) {
+ // Login the user.
+ $this->drupalLogin($user);
+
+ // Enable the active forum block.
+ $edit = array();
+ $edit['blocks[forum_active][region]'] = 'sidebar_second';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertResponse(200);
+ $this->assertText(t('The block settings have been updated.'), t('Active forum topics forum block was enabled'));
+
+ // Enable the new forum block.
+ $edit = array();
+ $edit['blocks[forum_new][region]'] = 'sidebar_second';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertResponse(200);
+ $this->assertText(t('The block settings have been updated.'), t('[New forum topics] Forum block was enabled'));
+
+ // Retrieve forum menu id.
+ $mlid = db_query_range("SELECT mlid FROM {menu_links} WHERE link_path = 'forum' AND menu_name = 'navigation' AND module = 'system' ORDER BY mlid ASC", 0, 1)->fetchField();
+
+ // Add forum to navigation menu.
+ $edit = array();
+ $this->drupalPost('admin/structure/menu/manage/navigation', $edit, t('Save configuration'));
+ $this->assertResponse(200);
+
+ // Edit forum taxonomy.
+ // Restoration of the settings fails and causes subsequent tests to fail.
+ $this->container = $this->editForumTaxonomy();
+ // Create forum container.
+ $this->container = $this->createForum('container');
+ // Verify "edit container" link exists and functions correctly.
+ $this->drupalGet('admin/structure/forum');
+ $this->clickLink('edit container');
+ $this->assertRaw('Edit container', t('Followed the link to edit the container'));
+ // Create forum inside the forum container.
+ $this->forum = $this->createForum('forum', $this->container['tid']);
+ // Verify the "edit forum" link exists and functions correctly.
+ $this->drupalGet('admin/structure/forum');
+ $this->clickLink('edit forum');
+ $this->assertRaw('Edit forum', t('Followed the link to edit the forum'));
+ // Navigate back to forum structure page.
+ $this->drupalGet('admin/structure/forum');
+ // Create second forum in container.
+ $this->delete_forum = $this->createForum('forum', $this->container['tid']);
+ // Save forum overview.
+ $this->drupalPost('admin/structure/forum/', array(), t('Save'));
+ $this->assertRaw(t('The configuration options have been saved.'));
+ // Delete this second form.
+ $this->deleteForum($this->delete_forum['tid']);
+ // Create forum at the top (root) level.
+ $this->root_forum = $this->createForum('forum');
+ }
+
+ /**
+ * Edit the forum taxonomy.
+ */
+ function editForumTaxonomy() {
+ // Backup forum taxonomy.
+ $vid = variable_get('forum_nav_vocabulary', '');
+ $original_settings = taxonomy_vocabulary_load($vid);
+
+ // Generate a random name/description.
+ $title = $this->randomName(10);
+ $description = $this->randomName(100);
+
+ $edit = array(
+ 'name' => $title,
+ 'description' => $description,
+ 'machine_name' => drupal_strtolower(drupal_substr($this->randomName(), 3, 9)),
+ );
+
+ // Edit the vocabulary.
+ $this->drupalPost('admin/structure/taxonomy/' . $original_settings->machine_name . '/edit', $edit, t('Save'));
+ $this->assertResponse(200);
+ $this->assertRaw(t('Updated vocabulary %name.', array('%name' => $title)), t('Vocabulary was edited'));
+
+ // Grab the newly edited vocabulary.
+ entity_get_controller('taxonomy_vocabulary')->resetCache();
+ $current_settings = taxonomy_vocabulary_load($vid);
+
+ // Make sure we actually edited the vocabulary properly.
+ $this->assertEqual($current_settings->name, $title, t('The name was updated'));
+ $this->assertEqual($current_settings->description, $description, t('The description was updated'));
+
+ // Restore the original vocabulary.
+ taxonomy_vocabulary_save($original_settings);
+ drupal_static_reset('taxonomy_vocabulary_load');
+ $current_settings = taxonomy_vocabulary_load($vid);
+ $this->assertEqual($current_settings->name, $original_settings->name, 'The original vocabulary settings were restored');
+ }
+
+ /**
+ * Create a forum container or a forum.
+ *
+ * @param $type
+ * Forum type (forum container or forum).
+ * @param $parent
+ * Forum parent (default = 0 = a root forum; >0 = a forum container or
+ * another forum).
+ * @return
+ * taxonomy_term_data created.
+ */
+ function createForum($type, $parent = 0) {
+ // Generate a random name/description.
+ $name = $this->randomName(10);
+ $description = $this->randomName(100);
+
+ $edit = array(
+ 'name' => $name,
+ 'description' => $description,
+ 'parent[0]' => $parent,
+ 'weight' => '0',
+ );
+
+ // Create forum.
+ $this->drupalPost('admin/structure/forum/add/' . $type, $edit, t('Save'));
+ $this->assertResponse(200);
+ $type = ($type == 'container') ? 'forum container' : 'forum';
+ $this->assertRaw(t('Created new @type %term.', array('%term' => $name, '@type' => t($type))), t(ucfirst($type) . ' was created'));
+
+ // Verify forum.
+ $term = db_query("SELECT * FROM {taxonomy_term_data} t WHERE t.vid = :vid AND t.name = :name AND t.description = :desc", array(':vid' => variable_get('forum_nav_vocabulary', ''), ':name' => $name, ':desc' => $description))->fetchAssoc();
+ $this->assertTrue(!empty($term), 'The ' . $type . ' exists in the database');
+
+ // Verify forum hierarchy.
+ $tid = $term['tid'];
+ $parent_tid = db_query("SELECT t.parent FROM {taxonomy_term_hierarchy} t WHERE t.tid = :tid", array(':tid' => $tid))->fetchField();
+ $this->assertTrue($parent == $parent_tid, 'The ' . $type . ' is linked to its container');
+
+ return $term;
+ }
+
+ /**
+ * Delete a forum.
+ *
+ * @param $tid
+ * The forum ID.
+ */
+ function deleteForum($tid) {
+ // Delete the forum.
+ $this->drupalPost('admin/structure/forum/edit/forum/' . $tid, array(), t('Delete'));
+ $this->drupalPost(NULL, array(), t('Delete'));
+
+ // Assert that the forum no longer exists.
+ $this->drupalGet('forum/' . $tid);
+ $this->assertResponse(404, 'The forum was not found');
+ }
+
+ /**
+ * Run basic tests on the indicated user.
+ *
+ * @param $user
+ * The logged in user.
+ * @param $admin
+ * User has 'access administration pages' privilege.
+ */
+ private function doBasicTests($user, $admin) {
+ // Login the user.
+ $this->drupalLogin($user);
+ // Attempt to create forum topic under a container.
+ $this->createForumTopic($this->container, TRUE);
+ // Create forum node.
+ $node = $this->createForumTopic($this->forum, FALSE);
+ // Verify the user has access to all the forum nodes.
+ $this->verifyForums($user, $node, $admin);
+ }
+
+ /**
+ * Create forum topic.
+ *
+ * @param array $forum
+ * Forum array.
+ * @param boolean $container
+ * True if $forum is a container.
+ *
+ * @return object
+ * Topic node created.
+ */
+ function createForumTopic($forum, $container = FALSE) {
+ // Generate a random subject/body.
+ $title = $this->randomName(20);
+ $body = $this->randomName(200);
+
+ $langcode = LANGUAGE_NONE;
+ $edit = array(
+ "title" => $title,
+ "body[$langcode][0][value]" => $body,
+ );
+ $tid = $forum['tid'];
+
+ // Create the forum topic, preselecting the forum ID via a URL parameter.
+ $this->drupalPost('node/add/forum/' . $tid, $edit, t('Save'));
+
+ $type = t('Forum topic');
+ if ($container) {
+ $this->assertNoRaw(t('@type %title has been created.', array('@type' => $type, '%title' => $title)), t('Forum topic was not created'));
+ $this->assertRaw(t('The item %title is a forum container, not a forum.', array('%title' => $forum['name'])), t('Error message was shown'));
+ return;
+ }
+ else {
+ $this->assertRaw(t('@type %title has been created.', array('@type' => $type, '%title' => $title)), t('Forum topic was created'));
+ $this->assertNoRaw(t('The item %title is a forum container, not a forum.', array('%title' => $forum['name'])), t('No error message was shown'));
+ }
+
+ // Retrieve node object, ensure that the topic was created and in the proper forum.
+ $node = $this->drupalGetNodeByTitle($title);
+ $this->assertTrue($node != NULL, t('Node @title was loaded', array('@title' => $title)));
+ $this->assertEqual($node->taxonomy_forums[LANGUAGE_NONE][0]['tid'], $tid, 'Saved forum topic was in the expected forum');
+
+ // View forum topic.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw($title, t('Subject was found'));
+ $this->assertRaw($body, t('Body was found'));
+
+ return $node;
+ }
+
+ /**
+ * Verify the logged in user has access to a forum nodes.
+ *
+ * @param $node_user
+ * The user who creates the node.
+ * @param $node
+ * The node being checked.
+ * @param $admin
+ * Boolean to indicate whether the user can 'access administration pages'.
+ * @param $response
+ * The exptected HTTP response code.
+ */
+ private function verifyForums($node_user, $node, $admin, $response = 200) {
+ $response2 = ($admin) ? 200 : 403;
+
+ // View forum help node.
+ $this->drupalGet('admin/help/forum');
+ $this->assertResponse($response2);
+ if ($response2 == 200) {
+ $this->assertTitle(t('Forum | Drupal'), t('Forum help title was displayed'));
+ $this->assertText(t('Forum'), t('Forum help node was displayed'));
+ }
+
+ // Verify the forum blocks were displayed.
+ $this->drupalGet('');
+ $this->assertResponse(200);
+ $this->assertText(t('New forum topics'), t('[New forum topics] Forum block was displayed'));
+
+ // View forum container page.
+ $this->verifyForumView($this->container);
+ // View forum page.
+ $this->verifyForumView($this->forum, $this->container);
+ // View root forum page.
+ $this->verifyForumView($this->root_forum);
+
+ // View forum node.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertResponse(200);
+ $this->assertTitle($node->title . ' | Drupal', t('Forum node was displayed'));
+ $breadcrumb = array(
+ l(t('Home'), NULL),
+ l(t('Forums'), 'forum'),
+ l($this->container['name'], 'forum/' . $this->container['tid']),
+ l($this->forum['name'], 'forum/' . $this->forum['tid']),
+ );
+ $this->assertRaw(theme('breadcrumb', array('breadcrumb' => $breadcrumb)), t('Breadcrumbs were displayed'));
+
+ // View forum edit node.
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertTitle('Edit Forum topic ' . $node->title . ' | Drupal', t('Forum edit node was displayed'));
+ }
+
+ if ($response == 200) {
+ // Edit forum node (including moving it to another forum).
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = 'node/' . $node->nid;
+ $edit["body[$langcode][0][value]"] = $this->randomName(256);
+ // Assume the topic is initially associated with $forum.
+ $edit["taxonomy_forums[$langcode]"] = $this->root_forum['tid'];
+ $edit['shadow'] = TRUE;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('Forum topic %title has been updated.', array('%title' => $edit["title"])), t('Forum node was edited'));
+
+ // Verify topic was moved to a different forum.
+ $forum_tid = db_query("SELECT tid FROM {forum} WHERE nid = :nid AND vid = :vid", array(
+ ':nid' => $node->nid,
+ ':vid' => $node->vid,
+ ))->fetchField();
+ $this->assertTrue($forum_tid == $this->root_forum['tid'], 'The forum topic is linked to a different forum');
+
+ // Delete forum node.
+ $this->drupalPost('node/' . $node->nid . '/delete', array(), t('Delete'));
+ $this->assertResponse($response);
+ $this->assertRaw(t('Forum topic %title has been deleted.', array('%title' => $edit['title'])), t('Forum node was deleted'));
+ }
+ }
+
+ /**
+ * Verify display of forum page.
+ *
+ * @param $forum
+ * A row from taxonomy_term_data table in array.
+ */
+ private function verifyForumView($forum, $parent = NULL) {
+ // View forum page.
+ $this->drupalGet('forum/' . $forum['tid']);
+ $this->assertResponse(200);
+ $this->assertTitle($forum['name'] . ' | Drupal', t('Forum name was displayed'));
+
+ $breadcrumb = array(
+ l(t('Home'), NULL),
+ l(t('Forums'), 'forum'),
+ );
+ if (isset($parent)) {
+ $breadcrumb[] = l($parent['name'], 'forum/' . $parent['tid']);
+ }
+
+ $this->assertRaw(theme('breadcrumb', array('breadcrumb' => $breadcrumb)), t('Breadcrumbs were displayed'));
+ }
+
+ /**
+ * Generate forum topics to test display of active forum block.
+ *
+ * @param array $forum Forum array (a row from taxonomy_term_data table).
+ */
+ private function generateForumTopics($forum) {
+ $this->nids = array();
+ for ($i = 0; $i < 5; $i++) {
+ $node = $this->createForumTopic($this->forum, FALSE);
+ $this->nids[] = $node->nid;
+ }
+ }
+
+ /**
+ * View forum topics to test display of active forum block.
+ *
+ * @todo The logic here is completely incorrect, since the active
+ * forum topics block is determined by comments on the node, not by views.
+ * @todo DIE
+ *
+ * @param $nids
+ * An array of forum node IDs.
+ */
+ private function viewForumTopics($nids) {
+ for ($i = 0; $i < 2; $i++) {
+ foreach ($nids as $nid) {
+ $this->drupalGet('node/' . $nid);
+ $this->drupalGet('node/' . $nid);
+ $this->drupalGet('node/' . $nid);
+ }
+ }
+ }
+}
diff --git a/core/modules/forum/forums.tpl.php b/core/modules/forum/forums.tpl.php
new file mode 100644
index 000000000000..55a760f57ee6
--- /dev/null
+++ b/core/modules/forum/forums.tpl.php
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a forum which may contain forum
+ * containers as well as forum topics.
+ *
+ * Variables available:
+ * - $forums: The forums to display (as processed by forum-list.tpl.php)
+ * - $topics: The topics to display (as processed by forum-topic-list.tpl.php)
+ * - $forums_defined: A flag to indicate that the forums are configured.
+ *
+ * @see template_preprocess_forums()
+ * @see theme_forums()
+ */
+?>
+<?php if ($forums_defined): ?>
+<div id="forum">
+ <?php print $forums; ?>
+ <?php print $topics; ?>
+</div>
+<?php endif; ?>
diff --git a/core/modules/help/help-rtl.css b/core/modules/help/help-rtl.css
new file mode 100644
index 000000000000..8e40a8c255ec
--- /dev/null
+++ b/core/modules/help/help-rtl.css
@@ -0,0 +1,10 @@
+
+.help-items {
+ float: right;
+ padding-right: 0;
+ padding-left: 3%;
+}
+.help-items-last {
+ padding-right: 0;
+ padding-left: 0;
+}
diff --git a/core/modules/help/help.admin.inc b/core/modules/help/help.admin.inc
new file mode 100644
index 000000000000..3db06ca1a245
--- /dev/null
+++ b/core/modules/help/help.admin.inc
@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the help module.
+ */
+
+/**
+ * Menu callback; prints a page listing a glossary of Drupal terminology.
+ */
+function help_main() {
+ // Add CSS
+ drupal_add_css(drupal_get_path('module', 'help') . '/help.css');
+ $output = '<h2>' . t('Help topics') . '</h2><p>' . t('Help is available on the following items:') . '</p>' . help_links_as_list();
+ return $output;
+}
+
+/**
+ * Menu callback; prints a page listing general help for a module.
+ */
+function help_page($name) {
+ $output = '';
+ if (module_hook($name, 'help')) {
+ $info = system_get_info('module');
+ drupal_set_title($info[$name]['name']);
+
+ $temp = module_invoke($name, 'help', "admin/help#$name", drupal_help_arg());
+ if (empty($temp)) {
+ $output .= t("No help is available for module %module.", array('%module' => $info[$name]['name']));
+ }
+ else {
+ $output .= $temp;
+ }
+
+ // Only print list of administration pages if the module in question has
+ // any such pages associated to it.
+ $admin_tasks = system_get_module_admin_tasks($name, $info[$name]);
+ if (!empty($admin_tasks)) {
+ $links = array();
+ foreach ($admin_tasks as $task) {
+ $links[] = l($task['title'], $task['link_path'], $task['localized_options']);
+ }
+ $output .= theme('item_list', array('items' => $links, 'title' => t('@module administration pages', array('@module' => $info[$name]['name']))));
+ }
+ }
+ return $output;
+}
+
+function help_links_as_list() {
+ $empty_arg = drupal_help_arg();
+ $module_info = system_rebuild_module_data();
+
+ $modules = array();
+ foreach (module_implements('help', TRUE) as $module) {
+ if (module_invoke($module, 'help', "admin/help#$module", $empty_arg)) {
+ $modules[$module] = $module_info[$module]->info['name'];
+ }
+ }
+ asort($modules);
+
+ // Output pretty four-column list
+ $count = count($modules);
+ $break = ceil($count / 4);
+ $output = '<div class="clearfix"><div class="help-items"><ul>';
+ $i = 0;
+ foreach ($modules as $module => $name) {
+ $output .= '<li>' . l($name, 'admin/help/' . $module) . '</li>';
+ if (($i + 1) % $break == 0 && ($i + 1) != $count) {
+ $output .= '</ul></div><div class="help-items' . ($i + 1 == $break * 3 ? ' help-items-last' : '') . '"><ul>';
+ }
+ $i++;
+ }
+ $output .= '</ul></div></div>';
+
+ return $output;
+}
+
diff --git a/core/modules/help/help.api.php b/core/modules/help/help.api.php
new file mode 100644
index 000000000000..ff2f97c6e8c2
--- /dev/null
+++ b/core/modules/help/help.api.php
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Help module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Provide online user help.
+ *
+ * By implementing hook_help(), a module can make documentation available to
+ * the user for the module as a whole, or for specific paths. Help for
+ * developers should usually be provided via function header comments in the
+ * code, or in special API example files.
+ *
+ * For a detailed usage example, see page_example.module.
+ *
+ * @param $path
+ * The router menu path, as defined in hook_menu(), for the help that is
+ * being requested; e.g., 'admin/people' or 'user/register'. If the router
+ * path includes a wildcard, then this will appear in $path as %, even if it
+ * is a named %autoloader wildcard in the hook_menu() implementation; for
+ * example, node pages would have $path equal to 'node/%' or 'node/%/view'.
+ * To provide a help page for a whole module with a listing on admin/help,
+ * your hook implementation should match a path with a special descriptor
+ * after a "#" sign:
+ * 'admin/help#modulename'
+ * The main module help text, displayed on the admin/help/modulename
+ * page and linked to from the admin/help page.
+ * @param $arg
+ * An array that corresponds to the return value of the arg() function, for
+ * modules that want to provide help that is specific to certain values
+ * of wildcards in $path. For example, you could provide help for the path
+ * 'user/1' by looking for the path 'user/%' and $arg[1] == '1'. This given
+ * array should always be used rather than directly invoking arg(), because
+ * your hook implementation may be called for other purposes besides building
+ * the current page's help. Note that depending on which module is invoking
+ * hook_help, $arg may contain only empty strings. Regardless, $arg[0] to
+ * $arg[11] will always be set.
+ * @return
+ * A localized string containing the help text.
+ */
+function hook_help($path, $arg) {
+ switch ($path) {
+ // Main module help for the block module
+ case 'admin/help#block':
+ return '<p>' . t('Blocks are boxes of content rendered into an area, or region, of a web page. The default theme Bartik, for example, implements the regions "Sidebar first", "Sidebar second", "Featured", "Content", "Header", "Footer", etc., and a block may appear in any one of these areas. The <a href="@blocks">blocks administration page</a> provides a drag-and-drop interface for assigning a block to a region, and for controlling the order of blocks within regions.', array('@blocks' => url('admin/structure/block'))) . '</p>';
+
+ // Help for another path in the block module
+ case 'admin/structure/block':
+ return '<p>' . t('This page provides a drag-and-drop interface for assigning a block to a region, and for controlling the order of blocks within regions. Since not all themes implement the same regions, or display regions in the same way, blocks are positioned on a per-theme basis. Remember that your changes will not be saved until you click the <em>Save blocks</em> button at the bottom of the page.') . '</p>';
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/help/help.css b/core/modules/help/help.css
new file mode 100644
index 000000000000..92281707a5a6
--- /dev/null
+++ b/core/modules/help/help.css
@@ -0,0 +1,9 @@
+
+.help-items {
+ float: left; /* LTR */
+ width: 22%;
+ padding-right: 3%; /* LTR */
+}
+.help-items-last {
+ padding-right: 0; /* LTR */
+}
diff --git a/core/modules/help/help.info b/core/modules/help/help.info
new file mode 100644
index 000000000000..b01a8ae06e87
--- /dev/null
+++ b/core/modules/help/help.info
@@ -0,0 +1,6 @@
+name = Help
+description = Manages the display of online help.
+package = Core
+version = VERSION
+core = 8.x
+files[] = help.test
diff --git a/core/modules/help/help.module b/core/modules/help/help.module
new file mode 100644
index 000000000000..773a52df98fe
--- /dev/null
+++ b/core/modules/help/help.module
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @file
+ * Manages displaying online help.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function help_menu() {
+ $items['admin/help'] = array(
+ 'title' => 'Help',
+ 'description' => 'Reference for usage, configuration, and modules.',
+ 'page callback' => 'help_main',
+ 'access arguments' => array('access administration pages'),
+ 'weight' => 9,
+ 'file' => 'help.admin.inc',
+ );
+
+ foreach (module_implements('help', TRUE) as $module) {
+ $items['admin/help/' . $module] = array(
+ 'title' => $module,
+ 'page callback' => 'help_page',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access administration pages'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ 'file' => 'help.admin.inc',
+ );
+ }
+
+ return $items;
+}
+
+/**
+ * Implements hook_help().
+ */
+function help_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help':
+ $output = '<p>' . t('Follow these steps to set up and start using your website:') . '</p>';
+ $output .= '<ol>';
+ $output .= '<li>' . t('<strong>Configure your website</strong> Once logged in, visit the <a href="@admin">administration section</a>, where you can <a href="@config">customize and configure</a> all aspects of your website.', array('@admin' => url('admin'), '@config' => url('admin/config'))) . '</li>';
+ $output .= '<li>' . t('<strong>Enable additional functionality</strong> Next, visit the <a href="@modules">module list</a> and enable features which suit your specific needs. You can find additional modules in the <a href="@download_modules">Drupal modules download section</a>.', array('@modules' => url('admin/modules'), '@download_modules' => 'http://drupal.org/project/modules')) . '</li>';
+ $output .= '<li>' . t('<strong>Customize your website design</strong> To change the "look and feel" of your website, visit the <a href="@themes">themes section</a>. You may choose from one of the included themes or download additional themes from the <a href="@download_themes">Drupal themes download section</a>.', array('@themes' => url('admin/appearance'), '@download_themes' => 'http://drupal.org/project/themes')) . '</li>';
+ $output .= '<li>' . t('<strong>Start posting content</strong> Finally, you can <a href="@content">add new content</a> for your website.', array('@content' => url('node/add'))) . '</li>';
+ $output .= '</ol>';
+ $output .= '<p>' . t('For more information, refer to the specific topics listed in the next section or to the <a href="@handbook">online Drupal handbooks</a>. You may also post at the <a href="@forum">Drupal forum</a> or view the wide range of <a href="@support">other support options</a> available.', array('@help' => url('admin/help'), '@handbook' => 'http://drupal.org/handbooks', '@forum' => 'http://drupal.org/forum', '@support' => 'http://drupal.org/support')) . '</p>';
+ return $output;
+ case 'admin/help#help':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Help module provides <a href="@help-page">Help reference pages</a> and context-sensitive advice to guide you through the use and configuration of modules. It is a starting point for the online <a href="@handbook">Drupal handbooks</a>. The handbooks contain more extensive and up-to-date information, are annotated with user-contributed comments, and serve as the definitive reference point for all Drupal documentation. For more information, see the online handbook entry for the <a href="@help">Help module</a>.', array('@help' => 'http://drupal.org/handbook/modules/help/', '@handbook' => 'http://drupal.org/handbook', '@help-page' => url('admin/help'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Providing a help reference') . '</dt>';
+ $output .= '<dd>' . t('The Help module displays explanations for using each module listed on the main <a href="@help">Help reference page</a>.', array('@help' => url('admin/help'))) . '</dd>';
+ $output .= '<dt>' . t('Providing context-sensitive help') . '</dt>';
+ $output .= '<dd>' . t('The Help module displays context-sensitive advice and explanations on various pages.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
diff --git a/core/modules/help/help.test b/core/modules/help/help.test
new file mode 100644
index 000000000000..5b90a91f7521
--- /dev/null
+++ b/core/modules/help/help.test
@@ -0,0 +1,125 @@
+<?php
+
+/**
+ * @file
+ * Tests for help.module.
+ */
+
+class HelpTestCase extends DrupalWebTestCase {
+ protected $big_user;
+ protected $any_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Help functionality',
+ 'description' => 'Verify help display and user access to help based on permissions.',
+ 'group' => 'Help',
+ );
+ }
+
+ /**
+ * Enable modules and create users with specific permissions.
+ */
+ function setUp() {
+ parent::setUp('poll');
+
+ $this->getModuleList();
+
+ // Create users.
+ $this->big_user = $this->drupalCreateUser(array('access administration pages', 'view the administration theme', 'administer permissions'));
+ $this->any_user = $this->drupalCreateUser(array());
+ }
+
+ /**
+ * Login users, create dblog events, and test dblog functionality through the admin and user interfaces.
+ */
+ function testHelp() {
+ // Login the admin user.
+ $this->drupalLogin($this->big_user);
+ $this->verifyHelp();
+
+ // Login the regular user.
+ $this->drupalLogin($this->any_user);
+ $this->verifyHelp(403);
+
+ // Check for css on admin/help.
+ $this->drupalLogin($this->big_user);
+ $this->drupalGet('admin/help');
+ $this->assertRaw(drupal_get_path('module', 'help') . '/help.css', t('The help.css file is present in the HTML.'));
+
+ // Verify that introductory help text exists, goes for 100% module coverage.
+ $this->assertRaw(t('For more information, refer to the specific topics listed in the next section or to the <a href="@drupal">online Drupal handbooks</a>.', array('@drupal' => 'http://drupal.org/handbooks')), 'Help intro text correctly appears.');
+
+ // Verify that help topics text appears.
+ $this->assertRaw('<h2>' . t('Help topics') . '</h2><p>' . t('Help is available on the following items:') . '</p>', t('Help topics text correctly appears.'));
+
+ // Make sure links are properly added for modules implementing hook_help().
+ foreach ($this->modules as $module => $name) {
+ $this->assertLink($name, 0, t('Link properly added to @name (admin/help/@module)', array('@module' => $module, '@name' => $name)));
+ }
+ }
+
+ /**
+ * Verify the logged in user has the desired access to the various help nodes and the nodes display help.
+ *
+ * @param integer $response HTTP response code.
+ */
+ protected function verifyHelp($response = 200) {
+ foreach ($this->modules as $module => $name) {
+ // View module help node.
+ $this->drupalGet('admin/help/' . $module);
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertTitle($name . ' | Drupal', t('[' . $module . '] Title was displayed'));
+ $this->assertRaw('<h1 class="page-title">' . t($name) . '</h1>', t('[' . $module . '] Heading was displayed'));
+ }
+ }
+ }
+
+ /**
+ * Get list of enabled modules that implement hook_help().
+ *
+ * @return array Enabled modules.
+ */
+ protected function getModuleList() {
+ $this->modules = array();
+ $result = db_query("SELECT name, filename, info FROM {system} WHERE type = 'module' AND status = 1 ORDER BY weight ASC, filename ASC");
+ foreach ($result as $module) {
+ if (file_exists($module->filename) && function_exists($module->name . '_help')) {
+ $fullname = unserialize($module->info);
+ $this->modules[$module->name] = $fullname['name'];
+ }
+ }
+ }
+}
+
+/**
+ * Tests module without help to verify it is not listed in help page.
+ */
+class NoHelpTestCase extends DrupalWebTestCase {
+ protected $big_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'No help',
+ 'description' => 'Verify no help is displayed for modules not providing any help.',
+ 'group' => 'Help',
+ );
+ }
+
+ function setUp() {
+ // Use one of the test modules that do not implement hook_help().
+ parent::setUp('menu_test');
+ $this->big_user = $this->drupalCreateUser(array('access administration pages'));
+ }
+
+ /**
+ * Ensure modules not implementing help do not appear on admin/help.
+ */
+ function testMainPageNoHelp() {
+ $this->drupalLogin($this->big_user);
+
+ $this->drupalGet('admin/help');
+ $this->assertNoText('Hook menu tests', t('Making sure the test module menu_test does not display a help link in admin/help'));
+ }
+}
diff --git a/core/modules/image/image-rtl.css b/core/modules/image/image-rtl.css
new file mode 100644
index 000000000000..2a7a855ede53
--- /dev/null
+++ b/core/modules/image/image-rtl.css
@@ -0,0 +1,11 @@
+
+/**
+ * Image upload widget.
+ */
+div.image-preview {
+ float: right;
+ padding: 0 0 10px 10px;
+}
+div.image-widget-data {
+ float: right;
+}
diff --git a/core/modules/image/image.admin.css b/core/modules/image/image.admin.css
new file mode 100644
index 000000000000..3115c8dce97d
--- /dev/null
+++ b/core/modules/image/image.admin.css
@@ -0,0 +1,60 @@
+
+/**
+ * Image style configuration pages.
+ */
+div.image-style-new,
+div.image-style-new div {
+ display: inline;
+}
+div.image-style-preview div.preview-image-wrapper {
+ float: left;
+ padding-bottom: 2em;
+ text-align: center;
+ top: 50%;
+ width: 48%;
+}
+div.image-style-preview div.preview-image {
+ margin: auto;
+ position: relative;
+}
+div.image-style-preview div.preview-image div.width {
+ border: 1px solid #666;
+ border-top: none;
+ height: 2px;
+ left: -1px;
+ bottom: -6px;
+ position: absolute;
+}
+div.image-style-preview div.preview-image div.width span {
+ position: relative;
+ top: 4px;
+}
+div.image-style-preview div.preview-image div.height {
+ border: 1px solid #666;
+ border-left: none;
+ position: absolute;
+ right: -6px;
+ top: -1px;
+ width: 2px;
+}
+div.image-style-preview div.preview-image div.height span {
+ height: 2em;
+ left: 10px;
+ margin-top: -1em;
+ position: absolute;
+ top: 50%;
+}
+
+/**
+ * Image anchor element.
+ */
+table.image-anchor {
+ width: auto;
+}
+table.image-anchor tr.even,
+table.image-anchor tr.odd {
+ background: none;
+}
+table.image-anchor td {
+ border: 1px solid #CCC;
+}
diff --git a/core/modules/image/image.admin.inc b/core/modules/image/image.admin.inc
new file mode 100644
index 000000000000..d72fdf4feecb
--- /dev/null
+++ b/core/modules/image/image.admin.inc
@@ -0,0 +1,907 @@
+<?php
+
+/**
+ * @file
+ * Administration pages for image settings.
+ */
+
+/**
+ * Menu callback; Listing of all current image styles.
+ */
+function image_style_list() {
+ $page = array();
+
+ $styles = image_styles();
+ $page['image_style_list'] = array(
+ '#markup' => theme('image_style_list', array('styles' => $styles)),
+ '#attached' => array(
+ 'css' => array(drupal_get_path('module', 'image') . '/image.admin.css' => array()),
+ ),
+ );
+
+ return $page;
+
+}
+
+/**
+ * Form builder; Edit an image style name and effects order.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $style
+ * An image style array.
+ * @ingroup forms
+ * @see image_style_form_submit()
+ * @see image_style_name_validate()
+ */
+function image_style_form($form, &$form_state, $style) {
+ $title = t('Edit %name style', array('%name' => $style['name']));
+ drupal_set_title($title, PASS_THROUGH);
+
+ // Adjust this form for styles that must be overridden to edit.
+ $editable = (bool) ($style['storage'] & IMAGE_STORAGE_EDITABLE);
+
+ if (!$editable && empty($form_state['input'])) {
+ drupal_set_message(t('This image style is currently being provided by a module. Click the "Override defaults" button to change its settings.'), 'warning');
+ }
+
+ $form_state['image_style'] = $style;
+ $form['#tree'] = TRUE;
+ $form['#attached']['css'][drupal_get_path('module', 'image') . '/image.admin.css'] = array();
+
+ // Show the thumbnail preview.
+ $form['preview'] = array(
+ '#type' => 'item',
+ '#title' => t('Preview'),
+ '#markup' => theme('image_style_preview', array('style' => $style)),
+ );
+
+ // Allow the name of the style to be changed, unless this style is
+ // provided by a module's hook_default_image_styles().
+ if ($style['storage'] & IMAGE_STORAGE_MODULE) {
+ $form['name'] = array(
+ '#type' => 'item',
+ '#title' => t('Image style name'),
+ '#markup' => $style['name'],
+ '#description' => t('This image style is being provided by %module module and may not be renamed.', array('%module' => $style['module'])),
+ );
+ }
+ else {
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#size' => '64',
+ '#title' => t('Image style name'),
+ '#default_value' => $style['name'],
+ '#description' => t('The name is used in URLs for generated images. Use only lowercase alphanumeric characters, underscores (_), and hyphens (-).'),
+ '#element_validate' => array('image_style_name_validate'),
+ '#required' => TRUE,
+ );
+ }
+
+ // Build the list of existing image effects for this image style.
+ $form['effects'] = array(
+ '#theme' => 'image_style_effects',
+ );
+ foreach ($style['effects'] as $key => $effect) {
+ $form['effects'][$key]['#weight'] = isset($form_state['input']['effects']) ? $form_state['input']['effects'][$key]['weight'] : NULL;
+ $form['effects'][$key]['label'] = array(
+ '#markup' => $effect['label'],
+ );
+ $form['effects'][$key]['summary'] = array(
+ '#markup' => isset($effect['summary theme']) ? theme($effect['summary theme'], array('data' => $effect['data'])) : '',
+ );
+ $form['effects'][$key]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for @title', array('@title' => $effect['label'])),
+ '#title_display' => 'invisible',
+ '#default_value' => $effect['weight'],
+ '#access' => $editable,
+ );
+
+ // Only attempt to display these fields for editable styles as the 'ieid'
+ // key is not set for styles defined in code.
+ if ($editable) {
+ $form['effects'][$key]['configure'] = array(
+ '#type' => 'link',
+ '#title' => t('edit'),
+ '#href' => 'admin/config/media/image-styles/edit/' . $style['name'] . '/effects/' . $effect['ieid'],
+ '#access' => $editable && isset($effect['form callback']),
+ );
+ $form['effects'][$key]['remove'] = array(
+ '#type' => 'link',
+ '#title' => t('delete'),
+ '#href' => 'admin/config/media/image-styles/edit/' . $style['name'] . '/effects/' . $effect['ieid'] . '/delete',
+ '#access' => $editable,
+ );
+ }
+ }
+
+ // Build the new image effect addition form and add it to the effect list.
+ $new_effect_options = array();
+ foreach (image_effect_definitions() as $effect => $definition) {
+ $new_effect_options[$effect] = check_plain($definition['label']);
+ }
+ $form['effects']['new'] = array(
+ '#tree' => FALSE,
+ '#weight' => isset($form_state['input']['weight']) ? $form_state['input']['weight'] : NULL,
+ '#access' => $editable,
+ );
+ $form['effects']['new']['new'] = array(
+ '#type' => 'select',
+ '#title' => t('Effect'),
+ '#title_display' => 'invisible',
+ '#options' => $new_effect_options,
+ '#empty_option' => t('Select a new effect'),
+ );
+ $form['effects']['new']['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for new effect'),
+ '#title_display' => 'invisible',
+ '#default_value' => count($form['effects']) - 1,
+ );
+ $form['effects']['new']['add'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add'),
+ '#validate' => array('image_style_form_add_validate'),
+ '#submit' => array('image_style_form_submit', 'image_style_form_add_submit'),
+ );
+
+ // Show the Override or Submit button for this style.
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['override'] = array(
+ '#type' => 'submit',
+ '#value' => t('Override defaults'),
+ '#validate' => array(),
+ '#submit' => array('image_style_form_override_submit'),
+ '#access' => !$editable,
+ );
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Update style'),
+ '#access' => $editable,
+ );
+
+ return $form;
+}
+
+/**
+ * Validate handler for adding a new image effect to an image style.
+ */
+function image_style_form_add_validate($form, &$form_state) {
+ if (!$form_state['values']['new']) {
+ form_error($form['effects']['new']['new'], t('Select an effect to add.'));
+ }
+}
+
+/**
+ * Submit handler for adding a new image effect to an image style.
+ */
+function image_style_form_add_submit($form, &$form_state) {
+ $style = $form_state['image_style'];
+ // Check if this field has any configuration options.
+ $effect = image_effect_definition_load($form_state['values']['new']);
+
+ // Load the configuration form for this option.
+ if (isset($effect['form callback'])) {
+ $path = 'admin/config/media/image-styles/edit/' . $form_state['image_style']['name'] . '/add/' . $form_state['values']['new'];
+ $form_state['redirect'] = array($path, array('query' => array('weight' => $form_state['values']['weight'])));
+ }
+ // If there's no form, immediately add the image effect.
+ else {
+ $effect['isid'] = $style['isid'];
+ $effect['weight'] = $form_state['values']['weight'];
+ image_effect_save($effect);
+ drupal_set_message(t('The image effect was successfully applied.'));
+ }
+}
+
+/**
+ * Submit handler for overriding a module-defined style.
+ */
+function image_style_form_override_submit($form, &$form_state) {
+ drupal_set_message(t('The %style style has been overridden, allowing you to change its settings.', array('%style' => $form_state['image_style']['name'])));
+ image_default_style_save($form_state['image_style']);
+}
+
+/**
+ * Submit handler for saving an image style.
+ */
+function image_style_form_submit($form, &$form_state) {
+ // Update the image style name if it has changed.
+ $style = $form_state['image_style'];
+ if (isset($form_state['values']['name']) && $style['name'] != $form_state['values']['name']) {
+ $style['name'] = $form_state['values']['name'];
+ }
+
+ // Update image effect weights.
+ if (!empty($form_state['values']['effects'])) {
+ foreach ($form_state['values']['effects'] as $ieid => $effect_data) {
+ if (isset($style['effects'][$ieid])) {
+ $effect = $style['effects'][$ieid];
+ $effect['weight'] = $effect_data['weight'];
+ image_effect_save($effect);
+ }
+ }
+ }
+
+ image_style_save($style);
+ if ($form_state['values']['op'] == t('Update style')) {
+ drupal_set_message(t('Changes to the style have been saved.'));
+ }
+ $form_state['redirect'] = 'admin/config/media/image-styles/edit/' . $style['name'];
+}
+
+/**
+ * Form builder; Form for adding a new image style.
+ *
+ * @ingroup forms
+ * @see image_style_add_form_submit()
+ * @see image_style_name_validate()
+ */
+function image_style_add_form($form, &$form_state) {
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#size' => '64',
+ '#title' => t('Style name'),
+ '#default_value' => '',
+ '#description' => t('The name is used in URLs for generated images. Use only lowercase alphanumeric characters, underscores (_), and hyphens (-).'),
+ '#element_validate' => array('image_style_name_validate'),
+ '#required' => TRUE,
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create new style'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for adding a new image style.
+ */
+function image_style_add_form_submit($form, &$form_state) {
+ $style = array('name' => $form_state['values']['name']);
+ $style = image_style_save($style);
+ drupal_set_message(t('Style %name was created.', array('%name' => $style['name'])));
+ $form_state['redirect'] = 'admin/config/media/image-styles/edit/' . $style['name'];
+}
+
+/**
+ * Element validate function to ensure unique, URL safe style names.
+ */
+function image_style_name_validate($element, $form_state) {
+ // Check for duplicates.
+ $styles = image_styles();
+ if (isset($styles[$element['#value']]) && (!isset($form_state['image_style']['isid']) || $styles[$element['#value']]['isid'] != $form_state['image_style']['isid'])) {
+ form_set_error($element['#name'], t('The image style name %name is already in use.', array('%name' => $element['#value'])));
+ }
+
+ // Check for illegal characters in image style names.
+ if (preg_match('/[^0-9a-z_\-]/', $element['#value'])) {
+ form_set_error($element['#name'], t('Please only use lowercase alphanumeric characters, underscores (_), and hyphens (-) for style names.'));
+ }
+}
+
+/**
+ * Form builder; Form for deleting an image style.
+ *
+ * @param $style
+ * An image style array.
+ *
+ * @ingroup forms
+ * @see image_style_delete_form_submit()
+ */
+function image_style_delete_form($form, $form_state, $style) {
+ $form_state['image_style'] = $style;
+
+ $replacement_styles = array_diff_key(image_style_options(), array($style['name'] => ''));
+ $form['replacement'] = array(
+ '#title' => t('Replacement style'),
+ '#type' => 'select',
+ '#options' => $replacement_styles,
+ '#empty_option' => t('No replacement, just delete'),
+ );
+
+ return confirm_form(
+ $form,
+ t('Optionally select a style before deleting %style', array('%style' => $style['name'])),
+ 'admin/config/media/image-styles',
+ t('If this style is in use on the site, you may select another style to replace it. All images that have been generated for this style will be permanently deleted.'),
+ t('Delete'), t('Cancel')
+ );
+}
+
+/**
+ * Submit handler to delete an image style.
+ */
+function image_style_delete_form_submit($form, &$form_state) {
+ $style = $form_state['image_style'];
+
+ image_style_delete($style, $form_state['values']['replacement']);
+ drupal_set_message(t('Style %name was deleted.', array('%name' => $style['name'])));
+ $form_state['redirect'] = 'admin/config/media/image-styles';
+}
+
+/**
+ * Confirmation form to revert a database style to its default.
+ */
+function image_style_revert_form($form, $form_state, $style) {
+ $form_state['image_style'] = $style;
+
+ return confirm_form(
+ $form,
+ t('Revert the %style style?', array('%style' => $style['name'])),
+ 'admin/config/media/image-styles',
+ t('Reverting this style will delete the customized settings and restore the defaults provided by the @module module.', array('@module' => $style['module'])),
+ t('Revert'), t('Cancel')
+ );
+}
+
+/**
+ * Submit handler to convert an overridden style to its default.
+ */
+function image_style_revert_form_submit($form, &$form_state) {
+ drupal_set_message(t('The %style style has been revert to its defaults.', array('%style' => $form_state['image_style']['name'])));
+ image_default_style_revert($form_state['image_style']);
+ $form_state['redirect'] = 'admin/config/media/image-styles';
+}
+
+/**
+ * Form builder; Form for adding and editing image effects.
+ *
+ * This form is used universally for editing all image effects. Each effect adds
+ * its own custom section to the form by calling the form function specified in
+ * hook_image_effects().
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $style
+ * An image style array.
+ * @param $effect
+ * An image effect array.
+ *
+ * @ingroup forms
+ * @see hook_image_effects()
+ * @see image_effects()
+ * @see image_resize_form()
+ * @see image_scale_form()
+ * @see image_rotate_form()
+ * @see image_crop_form()
+ * @see image_effect_form_submit()
+ */
+function image_effect_form($form, &$form_state, $style, $effect) {
+ if (!empty($effect['data'])) {
+ $title = t('Edit %label effect', array('%label' => $effect['label']));
+ }
+ else{
+ $title = t('Add %label effect', array('%label' => $effect['label']));
+ }
+ drupal_set_title($title, PASS_THROUGH);
+
+ $form_state['image_style'] = $style;
+ $form_state['image_effect'] = $effect;
+
+ // If no configuration for this image effect, return to the image style page.
+ if (!isset($effect['form callback'])) {
+ drupal_goto('admin/config/media/image-styles/edit/' . $style['name']);
+ }
+
+ $form['#tree'] = TRUE;
+ $form['#attached']['css'][drupal_get_path('module', 'image') . '/image.admin.css'] = array();
+ if (function_exists($effect['form callback'])) {
+ $form['data'] = call_user_func($effect['form callback'], $effect['data']);
+ }
+
+ // Check the URL for a weight, then the image effect, otherwise use default.
+ $form['weight'] = array(
+ '#type' => 'hidden',
+ '#value' => isset($_GET['weight']) ? intval($_GET['weight']) : (isset($effect['weight']) ? $effect['weight'] : count($style['effects'])),
+ );
+
+ $form['actions'] = array('#tree' => FALSE, '#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => isset($effect['ieid']) ? t('Update effect') : t('Add effect'),
+ );
+ $form['actions']['cancel'] = array(
+ '#type' => 'link',
+ '#title' => t('Cancel'),
+ '#href' => 'admin/config/media/image-styles/edit/' . $style['name'],
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for updating an image effect.
+ */
+function image_effect_form_submit($form, &$form_state) {
+ $style = $form_state['image_style'];
+ $effect = array_merge($form_state['image_effect'], $form_state['values']);
+ $effect['isid'] = $style['isid'];
+ image_effect_save($effect);
+ drupal_set_message(t('The image effect was successfully applied.'));
+ $form_state['redirect'] = 'admin/config/media/image-styles/edit/' . $style['name'];
+}
+
+/**
+ * Form builder; Form for deleting an image effect.
+ *
+ * @param $style
+ * Name of the image style from which the image effect will be removed.
+ * @param $effect
+ * Name of the image effect to remove.
+ * @ingroup forms
+ * @see image_effect_delete_form_submit()
+ */
+function image_effect_delete_form($form, &$form_state, $style, $effect) {
+ $form_state['image_style'] = $style;
+ $form_state['image_effect'] = $effect;
+
+ $question = t('Are you sure you want to delete the @effect effect from the %style style?', array('%style' => $style['name'], '@effect' => $effect['label']));
+ return confirm_form($form, $question, 'admin/config/media/image-styles/edit/' . $style['name'], '', t('Delete'));
+}
+
+/**
+ * Submit handler to delete an image effect.
+ */
+function image_effect_delete_form_submit($form, &$form_state) {
+ $style = $form_state['image_style'];
+ $effect = $form_state['image_effect'];
+
+ image_effect_delete($effect);
+ drupal_set_message(t('The image effect %name has been deleted.', array('%name' => $effect['label'])));
+ $form_state['redirect'] = 'admin/config/media/image-styles/edit/' . $style['name'];
+}
+
+/**
+ * Element validate handler to ensure an integer pixel value.
+ *
+ * The property #allow_negative = TRUE may be set to allow negative integers.
+ */
+function image_effect_integer_validate($element, &$form_state) {
+ $value = empty($element['#allow_negative']) ? $element['#value'] : preg_replace('/^-/', '', $element['#value']);
+ if ($element['#value'] != '' && (!is_numeric($value) || intval($value) <= 0)) {
+ if (empty($element['#allow_negative'])) {
+ form_error($element, t('!name must be an integer.', array('!name' => $element['#title'])));
+ }
+ else {
+ form_error($element, t('!name must be a positive integer.', array('!name' => $element['#title'])));
+ }
+ }
+}
+
+/**
+ * Element validate handler to ensure a hexadecimal color value.
+ */
+function image_effect_color_validate($element, &$form_state) {
+ if ($element['#value'] != '') {
+ $hex_value = preg_replace('/^#/', '', $element['#value']);
+ if (!preg_match('/^#[0-9A-F]{3}([0-9A-F]{3})?$/', $element['#value'])) {
+ form_error($element, t('!name must be a hexadecimal color value.', array('!name' => $element['#title'])));
+ }
+ }
+}
+
+/**
+ * Element validate handler to ensure that either a height or a width is
+ * specified.
+ */
+function image_effect_scale_validate($element, &$form_state) {
+ if (empty($element['width']['#value']) && empty($element['height']['#value'])) {
+ form_error($element, t('Width and height can not both be blank.'));
+ }
+}
+
+/**
+ * Form structure for the image resize form.
+ *
+ * Note that this is not a complete form, it only contains the portion of the
+ * form for configuring the resize options. Therefore it does not not need to
+ * include metadata about the effect, nor a submit button.
+ *
+ * @param $data
+ * The current configuration for this resize effect.
+ */
+function image_resize_form($data) {
+ $form['width'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Width'),
+ '#default_value' => isset($data['width']) ? $data['width'] : '',
+ '#field_suffix' => ' ' . t('pixels'),
+ '#required' => TRUE,
+ '#size' => 10,
+ '#element_validate' => array('image_effect_integer_validate'),
+ '#allow_negative' => FALSE,
+ );
+ $form['height'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Height'),
+ '#default_value' => isset($data['height']) ? $data['height'] : '',
+ '#field_suffix' => ' ' . t('pixels'),
+ '#required' => TRUE,
+ '#size' => 10,
+ '#element_validate' => array('image_effect_integer_validate'),
+ '#allow_negative' => FALSE,
+ );
+ return $form;
+}
+
+/**
+ * Form structure for the image scale form.
+ *
+ * Note that this is not a complete form, it only contains the portion of the
+ * form for configuring the scale options. Therefore it does not not need to
+ * include metadata about the effect, nor a submit button.
+ *
+ * @param $data
+ * The current configuration for this scale effect.
+ */
+function image_scale_form($data) {
+ $form = image_resize_form($data);
+ $form['#element_validate'] = array('image_effect_scale_validate');
+ $form['width']['#required'] = FALSE;
+ $form['height']['#required'] = FALSE;
+ $form['upscale'] = array(
+ '#type' => 'checkbox',
+ '#default_value' => (isset($data['upscale'])) ? $data['upscale'] : 0,
+ '#title' => t('Allow Upscaling'),
+ '#description' => t('Let scale make images larger than their original size'),
+ );
+ return $form;
+}
+
+/**
+ * Form structure for the image crop form.
+ *
+ * Note that this is not a complete form, it only contains the portion of the
+ * form for configuring the crop options. Therefore it does not not need to
+ * include metadata about the effect, nor a submit button.
+ *
+ * @param $data
+ * The current configuration for this crop effect.
+ */
+function image_crop_form($data) {
+ $data += array(
+ 'width' => '',
+ 'height' => '',
+ 'anchor' => 'center-center',
+ );
+
+ $form = image_resize_form($data);
+ $form['anchor'] = array(
+ '#type' => 'radios',
+ '#title' => t('Anchor'),
+ '#options' => array(
+ 'left-top' => t('Top') . ' ' . t('Left'),
+ 'center-top' => t('Top') . ' ' . t('Center'),
+ 'right-top' => t('Top') . ' ' . t('Right'),
+ 'left-center' => t('Center') . ' ' . t('Left'),
+ 'center-center' => t('Center'),
+ 'right-center' => t('Center') . ' ' . t('Right'),
+ 'left-bottom' => t('Bottom') . ' ' . t('Left'),
+ 'center-bottom' => t('Bottom') . ' ' . t('Center'),
+ 'right-bottom' => t('Bottom') . ' ' . t('Right'),
+ ),
+ '#theme' => 'image_anchor',
+ '#default_value' => $data['anchor'],
+ '#description' => t('The part of the image that will be retained during the crop.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form structure for the image rotate form.
+ *
+ * Note that this is not a complete form, it only contains the portion of the
+ * form for configuring the rotate options. Therefore it does not not need to
+ * include metadata about the effect, nor a submit button.
+ *
+ * @param $data
+ * The current configuration for this rotate effect.
+ */
+function image_rotate_form($data) {
+ $form['degrees'] = array(
+ '#type' => 'textfield',
+ '#default_value' => (isset($data['degrees'])) ? $data['degrees'] : 0,
+ '#title' => t('Rotation angle'),
+ '#description' => t('The number of degrees the image should be rotated. Positive numbers are clockwise, negative are counter-clockwise.'),
+ '#field_suffix' => '&deg;',
+ '#required' => TRUE,
+ '#size' => 6,
+ '#maxlength' => 4,
+ '#element_validate' => array('image_effect_integer_validate'),
+ '#allow_negative' => TRUE,
+ );
+ $form['bgcolor'] = array(
+ '#type' => 'textfield',
+ '#default_value' => (isset($data['bgcolor'])) ? $data['bgcolor'] : '#FFFFFF',
+ '#title' => t('Background color'),
+ '#description' => t('The background color to use for exposed areas of the image. Use web-style hex colors (#FFFFFF for white, #000000 for black). Leave blank for transparency on image types that support it.'),
+ '#size' => 7,
+ '#maxlength' => 7,
+ '#element_validate' => array('image_effect_color_validate'),
+ );
+ $form['random'] = array(
+ '#type' => 'checkbox',
+ '#default_value' => (isset($data['random'])) ? $data['random'] : 0,
+ '#title' => t('Randomize'),
+ '#description' => t('Randomize the rotation angle for each image. The angle specified above is used as a maximum.'),
+ );
+ return $form;
+}
+
+/**
+ * Returns HTML for the page containing the list of image styles.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - styles: An array of all the image styles returned by image_get_styles().
+ *
+ * @see image_get_styles()
+ * @ingroup themeable
+ */
+function theme_image_style_list($variables) {
+ $styles = $variables['styles'];
+
+ $header = array(t('Style name'), t('Settings'), array('data' => t('Operations'), 'colspan' => 3));
+ $rows = array();
+ foreach ($styles as $style) {
+ $row = array();
+ $row[] = l($style['name'], 'admin/config/media/image-styles/edit/' . $style['name']);
+ $link_attributes = array(
+ 'attributes' => array(
+ 'class' => array('image-style-link'),
+ ),
+ );
+ if ($style['storage'] == IMAGE_STORAGE_NORMAL) {
+ $row[] = t('Custom');
+ $row[] = l(t('edit'), 'admin/config/media/image-styles/edit/' . $style['name'], $link_attributes);
+ $row[] = l(t('delete'), 'admin/config/media/image-styles/delete/' . $style['name'], $link_attributes);
+ }
+ elseif ($style['storage'] == IMAGE_STORAGE_OVERRIDE) {
+ $row[] = t('Overridden');
+ $row[] = l(t('edit'), 'admin/config/media/image-styles/edit/' . $style['name'], $link_attributes);
+ $row[] = l(t('revert'), 'admin/config/media/image-styles/revert/' . $style['name'], $link_attributes);
+ }
+ else {
+ $row[] = t('Default');
+ $row[] = l(t('edit'), 'admin/config/media/image-styles/edit/' . $style['name'], $link_attributes);
+ $row[] = '';
+ }
+ $rows[] = $row;
+ }
+
+ if (empty($rows)) {
+ $rows[] = array(array(
+ 'colspan' => 4,
+ 'data' => t('There are currently no styles. <a href="!url">Add a new one</a>.', array('!url' => url('admin/config/media/image-styles/add'))),
+ ));
+ }
+
+ return theme('table', array('header' => $header, 'rows' => $rows));
+}
+
+/**
+ * Returns HTML for a listing of the effects within a specific image style.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_image_style_effects($variables) {
+ $form = $variables['form'];
+
+ $rows = array();
+
+ foreach (element_children($form) as $key) {
+ $row = array();
+ $form[$key]['weight']['#attributes']['class'] = array('image-effect-order-weight');
+ if (is_numeric($key)) {
+ $summary = drupal_render($form[$key]['summary']);
+ $row[] = drupal_render($form[$key]['label']) . (empty($summary) ? '' : ' ' . $summary);
+ $row[] = drupal_render($form[$key]['weight']);
+ $row[] = drupal_render($form[$key]['configure']);
+ $row[] = drupal_render($form[$key]['remove']);
+ }
+ else {
+ // Add the row for adding a new image effect.
+ $row[] = '<div class="image-style-new">' . drupal_render($form['new']['new']) . drupal_render($form['new']['add']) . '</div>';
+ $row[] = drupal_render($form['new']['weight']);
+ $row[] = array('data' => '', 'colspan' => 2);
+ }
+
+ if (!isset($form[$key]['#access']) || $form[$key]['#access']) {
+ $rows[] = array(
+ 'data' => $row,
+ 'class' => !empty($form[$key]['weight']['#access']) || $key == 'new' ? array('draggable') : array(),
+ );
+ }
+ }
+
+ $header = array(
+ t('Effect'),
+ t('Weight'),
+ array('data' => t('Operations'), 'colspan' => 2),
+ );
+
+ if (count($rows) == 1 && $form['new']['#access']) {
+ array_unshift($rows, array(array(
+ 'data' => t('There are currently no effects in this style. Add one by selecting an option below.'),
+ 'colspan' => 4,
+ )));
+ }
+
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'image-style-effects')));
+ drupal_add_tabledrag('image-style-effects', 'order', 'sibling', 'image-effect-order-weight');
+ return $output;
+}
+
+/**
+ * Returns HTML for a preview of an image style.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - style: The image style array being previewed.
+ *
+ * @ingroup themeable
+ */
+function theme_image_style_preview($variables) {
+ $style = $variables['style'];
+
+ $sample_image = variable_get('image_style_preview_image', drupal_get_path('module', 'image') . '/sample.png');
+ $sample_width = 160;
+ $sample_height = 160;
+
+ // Set up original file information.
+ $original_path = $sample_image;
+ $original_image = image_get_info($original_path);
+ if ($original_image['width'] > $original_image['height']) {
+ $original_width = min($original_image['width'], $sample_width);
+ $original_height = round($original_width / $original_image['width'] * $original_image['height']);
+ }
+ else {
+ $original_height = min($original_image['height'], $sample_height);
+ $original_width = round($original_height / $original_image['height'] * $original_image['width']);
+ }
+ $original_attributes = array_intersect_key($original_image, array('width' => '', 'height' => ''));
+ $original_attributes['style'] = 'width: ' . $original_width . 'px; height: ' . $original_height . 'px;';
+
+ // Set up preview file information.
+ $preview_file = image_style_path($style['name'], $original_path);
+ if (!file_exists($preview_file)) {
+ image_style_create_derivative($style, $original_path, $preview_file);
+ }
+ $preview_image = image_get_info($preview_file);
+ if ($preview_image['width'] > $preview_image['height']) {
+ $preview_width = min($preview_image['width'], $sample_width);
+ $preview_height = round($preview_width / $preview_image['width'] * $preview_image['height']);
+ }
+ else {
+ $preview_height = min($preview_image['height'], $sample_height);
+ $preview_width = round($preview_height / $preview_image['height'] * $preview_image['width']);
+ }
+ $preview_attributes = array_intersect_key($preview_image, array('width' => '', 'height' => ''));
+ $preview_attributes['style'] = 'width: ' . $preview_width . 'px; height: ' . $preview_height . 'px;';
+
+ // In the previews, timestamps are added to prevent caching of images.
+ $output = '<div class="image-style-preview preview clearfix">';
+
+ // Build the preview of the original image.
+ $original_url = file_create_url($original_path);
+ $output .= '<div class="preview-image-wrapper">';
+ $output .= t('original') . ' (' . l(t('view actual size'), $original_url) . ')';
+ $output .= '<div class="preview-image original-image" style="' . $original_attributes['style'] . '">';
+ $output .= '<a href="' . $original_url . '">' . theme('image', array('path' => $original_path, 'alt' => t('Sample original image'), 'title' => '', 'attributes' => $original_attributes)) . '</a>';
+ $output .= '<div class="height" style="height: ' . $original_height . 'px"><span>' . $original_image['height'] . 'px</span></div>';
+ $output .= '<div class="width" style="width: ' . $original_width . 'px"><span>' . $original_image['width'] . 'px</span></div>';
+ $output .= '</div>'; // End preview-image.
+ $output .= '</div>'; // End preview-image-wrapper.
+
+ // Build the preview of the image style.
+ $preview_url = file_create_url($preview_file) . '?cache_bypass=' . REQUEST_TIME;
+ $output .= '<div class="preview-image-wrapper">';
+ $output .= check_plain($style['name']) . ' (' . l(t('view actual size'), file_create_url($preview_file) . '?' . time()) . ')';
+ $output .= '<div class="preview-image modified-image" style="' . $preview_attributes['style'] . '">';
+ $output .= '<a href="' . file_create_url($preview_file) . '?' . time() . '">' . theme('image', array('path' => $preview_url, 'alt' => t('Sample modified image'), 'title' => '', 'attributes' => $preview_attributes)) . '</a>';
+ $output .= '<div class="height" style="height: ' . $preview_height . 'px"><span>' . $preview_image['height'] . 'px</span></div>';
+ $output .= '<div class="width" style="width: ' . $preview_width . 'px"><span>' . $preview_image['width'] . 'px</span></div>';
+ $output .= '</div>'; // End preview-image.
+ $output .= '</div>'; // End preview-image-wrapper.
+
+ $output .= '</div>'; // End image-style-preview.
+
+ return $output;
+}
+
+/**
+ * Returns HTML for a 3x3 grid of checkboxes for image anchors.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element containing radio buttons.
+ *
+ * @ingroup themeable
+ */
+function theme_image_anchor($variables) {
+ $element = $variables['element'];
+
+ $rows = array();
+ $row = array();
+ foreach (element_children($element) as $n => $key) {
+ $element[$key]['#attributes']['title'] = $element[$key]['#title'];
+ unset($element[$key]['#title']);
+ $row[] = drupal_render($element[$key]);
+ if ($n % 3 == 3 - 1) {
+ $rows[] = $row;
+ $row = array();
+ }
+ }
+
+ return theme('table', array('header' => array(), 'rows' => $rows, 'attributes' => array('class' => array('image-anchor'))));
+}
+
+/**
+ * Returns HTML for a summary of an image resize effect.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - data: The current configuration for this resize effect.
+ *
+ * @ingroup themeable
+ */
+function theme_image_resize_summary($variables) {
+ $data = $variables['data'];
+
+ if ($data['width'] && $data['height']) {
+ return check_plain($data['width']) . 'x' . check_plain($data['height']);
+ }
+ else {
+ return ($data['width']) ? t('width @width', array('@width' => $data['width'])) : t('height @height', array('@height' => $data['height']));
+ }
+}
+
+/**
+ * Returns HTML for a summary of an image scale effect.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - data: The current configuration for this scale effect.
+ *
+ * @ingroup themeable
+ */
+function theme_image_scale_summary($variables) {
+ $data = $variables['data'];
+ return theme('image_resize_summary', array('data' => $data)) . ' ' . ($data['upscale'] ? '(' . t('upscaling allowed') . ')' : '');
+}
+
+/**
+ * Returns HTML for a summary of an image crop effect.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - data: The current configuration for this crop effect.
+ *
+ * @ingroup themeable
+ */
+function theme_image_crop_summary($variables) {
+ return theme('image_resize_summary', $variables);
+}
+
+/**
+ * Returns HTML for a summary of an image rotate effect.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - data: The current configuration for this rotate effect.
+ *
+ * @ingroup themeable
+ */
+function theme_image_rotate_summary($variables) {
+ $data = $variables['data'];
+ return ($data['random']) ? t('random between -@degrees&deg and @degrees&deg', array('@degrees' => str_replace('-', '', $data['degrees']))) : t('@degrees&deg', array('@degrees' => $data['degrees']));
+}
diff --git a/core/modules/image/image.api.php b/core/modules/image/image.api.php
new file mode 100644
index 000000000000..758d38bf918f
--- /dev/null
+++ b/core/modules/image/image.api.php
@@ -0,0 +1,199 @@
+<?php
+
+/**
+ * @file
+ * Hooks related to image styles and effects.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Define information about image effects provided by a module.
+ *
+ * This hook enables modules to define image manipulation effects for use with
+ * an image style.
+ *
+ * @return
+ * An array of image effects. This array is keyed on the machine-readable
+ * effect name. Each effect is defined as an associative array containing the
+ * following items:
+ * - "label": The human-readable name of the effect.
+ * - "effect callback": The function to call to perform this image effect.
+ * - "dimensions passthrough": (optional) Set this item if the effect doesn't
+ * change the dimensions of the image.
+ * - "dimensions callback": (optional) The function to call to transform
+ * dimensions for this effect.
+ * - "help": (optional) A brief description of the effect that will be shown
+ * when adding or configuring this image effect.
+ * - "form callback": (optional) The name of a function that will return a
+ * $form array providing a configuration form for this image effect.
+ * - "summary theme": (optional) The name of a theme function that will output
+ * a summary of this image effect's configuration.
+ *
+ * @see hook_image_effect_info_alter()
+ */
+function hook_image_effect_info() {
+ $effects = array();
+
+ $effects['mymodule_resize'] = array(
+ 'label' => t('Resize'),
+ 'help' => t('Resize an image to an exact set of dimensions, ignoring aspect ratio.'),
+ 'effect callback' => 'mymodule_resize_effect',
+ 'dimensions callback' => 'mymodule_resize_dimensions',
+ 'form callback' => 'mymodule_resize_form',
+ 'summary theme' => 'mymodule_resize_summary',
+ );
+
+ return $effects;
+}
+
+/**
+ * Alter the information provided in hook_image_effect_info().
+ *
+ * @param $effects
+ * The array of image effects, keyed on the machine-readable effect name.
+ *
+ * @see hook_image_effect_info()
+ */
+function hook_image_effect_info_alter(&$effects) {
+ // Override the Image module's crop effect with more options.
+ $effects['image_crop']['effect callback'] = 'mymodule_crop_effect';
+ $effects['image_crop']['dimensions callback'] = 'mymodule_crop_dimensions';
+ $effects['image_crop']['form callback'] = 'mymodule_crop_form';
+}
+
+/**
+ * Respond to image style updating.
+ *
+ * This hook enables modules to update settings that might be affected by
+ * changes to an image. For example, updating a module specific variable to
+ * reflect a change in the image style's name.
+ *
+ * @param $style
+ * The image style array that is being updated.
+ */
+function hook_image_style_save($style) {
+ // If a module defines an image style and that style is renamed by the user
+ // the module should update any references to that style.
+ if (isset($style['old_name']) && $style['old_name'] == variable_get('mymodule_image_style', '')) {
+ variable_set('mymodule_image_style', $style['name']);
+ }
+}
+
+/**
+ * Respond to image style deletion.
+ *
+ * This hook enables modules to update settings when a image style is being
+ * deleted. If a style is deleted, a replacement name may be specified in
+ * $style['name'] and the style being deleted will be specified in
+ * $style['old_name'].
+ *
+ * @param $style
+ * The image style array that being deleted.
+ */
+function hook_image_style_delete($style) {
+ // Administrators can choose an optional replacement style when deleting.
+ // Update the modules style variable accordingly.
+ if (isset($style['old_name']) && $style['old_name'] == variable_get('mymodule_image_style', '')) {
+ variable_set('mymodule_image_style', $style['name']);
+ }
+}
+
+/**
+ * Respond to image style flushing.
+ *
+ * This hook enables modules to take effect when a style is being flushed (all
+ * images are being deleted from the server and regenerated). Any
+ * module-specific caches that contain information related to the style should
+ * be cleared using this hook. This hook is called whenever a style is updated,
+ * deleted, or any effect associated with the style is update or deleted.
+ *
+ * @param $style
+ * The image style array that is being flushed.
+ */
+function hook_image_style_flush($style) {
+ // Empty cached data that contains information about the style.
+ cache('mymodule')->flush();
+}
+
+/**
+ * Modify any image styles provided by other modules or the user.
+ *
+ * This hook allows modules to modify, add, or remove image styles. This may
+ * be useful to modify default styles provided by other modules or enforce
+ * that a specific effect is always enabled on a style. Note that modifications
+ * to these styles may negatively affect the user experience, such as if an
+ * effect is added to a style through this hook, the user may attempt to remove
+ * the effect but it will be immediately be re-added.
+ *
+ * The best use of this hook is usually to modify default styles, which are not
+ * editable by the user until they are overridden, so such interface
+ * contradictions will not occur. This hook can target default (or user) styles
+ * by checking the $style['storage'] property.
+ *
+ * If your module needs to provide a new style (rather than modify an existing
+ * one) use hook_image_default_styles() instead.
+ *
+ * @see hook_image_default_styles()
+ */
+function hook_image_styles_alter(&$styles) {
+ // Check that we only affect a default style.
+ if ($styles['thumbnail']['storage'] == IMAGE_STORAGE_DEFAULT) {
+ // Add an additional effect to the thumbnail style.
+ $styles['thumbnail']['effects'][] = array(
+ 'name' => 'image_desaturate',
+ 'data' => array(),
+ 'weight' => 1,
+ 'effect callback' => 'image_desaturate_effect',
+ );
+ }
+}
+
+/**
+ * Provide module-based image styles for reuse throughout Drupal.
+ *
+ * This hook allows your module to provide image styles. This may be useful if
+ * you require images to fit within exact dimensions. Note that you should
+ * attempt to re-use the default styles provided by Image module whenever
+ * possible, rather than creating image styles that are specific to your module.
+ * Image provides the styles "thumbnail", "medium", and "large".
+ *
+ * You may use this hook to more easily manage your site's changes by moving
+ * existing image styles from the database to a custom module. Note however that
+ * moving image styles to code instead storing them in the database has a
+ * negligible effect on performance, since custom image styles are loaded
+ * from the database all at once. Even if all styles are pulled from modules,
+ * Image module will still perform the same queries to check the database for
+ * any custom styles.
+ *
+ * @return
+ * An array of image styles, keyed by the style name.
+ * @see image_image_default_styles()
+ */
+function hook_image_default_styles() {
+ $styles = array();
+
+ $styles['mymodule_preview'] = array(
+ 'effects' => array(
+ array(
+ 'name' => 'image_scale',
+ 'data' => array('width' => 400, 'height' => 400, 'upscale' => 1),
+ 'weight' => 0,
+ ),
+ array(
+ 'name' => 'image_desaturate',
+ 'data' => array(),
+ 'weight' => 1,
+ ),
+ ),
+ );
+
+ return $styles;
+}
+
+ /**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/image/image.css b/core/modules/image/image.css
new file mode 100644
index 000000000000..7db307bf07c8
--- /dev/null
+++ b/core/modules/image/image.css
@@ -0,0 +1,14 @@
+
+/**
+ * Image upload widget.
+ */
+div.image-preview {
+ float: left; /* LTR */
+ padding: 0 10px 10px 0; /* LTR */
+}
+div.image-widget-data {
+ float: left; /* LTR */
+}
+div.image-widget-data input.text-field {
+ width: auto;
+}
diff --git a/core/modules/image/image.effects.inc b/core/modules/image/image.effects.inc
new file mode 100644
index 000000000000..ea898f91f0fc
--- /dev/null
+++ b/core/modules/image/image.effects.inc
@@ -0,0 +1,316 @@
+<?php
+
+/**
+ * @file
+ * Functions needed to execute image effects provided by Image module.
+ */
+
+/**
+ * Implements hook_image_effect_info().
+ */
+function image_image_effect_info() {
+ $effects = array(
+ 'image_resize' => array(
+ 'label' => t('Resize'),
+ 'help' => t('Resizing will make images an exact set of dimensions. This may cause images to be stretched or shrunk disproportionately.'),
+ 'effect callback' => 'image_resize_effect',
+ 'dimensions callback' => 'image_resize_dimensions',
+ 'form callback' => 'image_resize_form',
+ 'summary theme' => 'image_resize_summary',
+ ),
+ 'image_scale' => array(
+ 'label' => t('Scale'),
+ 'help' => t('Scaling will maintain the aspect-ratio of the original image. If only a single dimension is specified, the other dimension will be calculated.'),
+ 'effect callback' => 'image_scale_effect',
+ 'dimensions callback' => 'image_scale_dimensions',
+ 'form callback' => 'image_scale_form',
+ 'summary theme' => 'image_scale_summary',
+ ),
+ 'image_scale_and_crop' => array(
+ 'label' => t('Scale and crop'),
+ 'help' => t('Scale and crop will maintain the aspect-ratio of the original image, then crop the larger dimension. This is most useful for creating perfectly square thumbnails without stretching the image.'),
+ 'effect callback' => 'image_scale_and_crop_effect',
+ 'dimensions callback' => 'image_resize_dimensions',
+ 'form callback' => 'image_resize_form',
+ 'summary theme' => 'image_resize_summary',
+ ),
+ 'image_crop' => array(
+ 'label' => t('Crop'),
+ 'help' => t('Cropping will remove portions of an image to make it the specified dimensions.'),
+ 'effect callback' => 'image_crop_effect',
+ 'dimensions callback' => 'image_resize_dimensions',
+ 'form callback' => 'image_crop_form',
+ 'summary theme' => 'image_crop_summary',
+ ),
+ 'image_desaturate' => array(
+ 'label' => t('Desaturate'),
+ 'help' => t('Desaturate converts an image to grayscale.'),
+ 'effect callback' => 'image_desaturate_effect',
+ 'dimensions passthrough' => TRUE,
+ ),
+ 'image_rotate' => array(
+ 'label' => t('Rotate'),
+ 'help' => t('Rotating an image may cause the dimensions of an image to increase to fit the diagonal.'),
+ 'effect callback' => 'image_rotate_effect',
+ 'dimensions callback' => 'image_rotate_dimensions',
+ 'form callback' => 'image_rotate_form',
+ 'summary theme' => 'image_rotate_summary',
+ ),
+ );
+
+ return $effects;
+}
+
+/**
+ * Image effect callback; Resize an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the resize effect with the
+ * following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ *
+ * @return
+ * TRUE on success. FALSE on failure to resize image.
+ *
+ * @see image_resize()
+ */
+function image_resize_effect(&$image, $data) {
+ if (!image_resize($image, $data['width'], $data['height'])) {
+ watchdog('image', 'Image resize failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image dimensions callback; Resize.
+ *
+ * @param $dimensions
+ * Dimensions to be modified - an array with components width and height, in
+ * pixels.
+ * @param $data
+ * An array of attributes to use when performing the resize effect with the
+ * following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ */
+function image_resize_dimensions(array &$dimensions, array $data) {
+ // The new image will have the exact dimensions defined for the effect.
+ $dimensions['width'] = $data['width'];
+ $dimensions['height'] = $data['height'];
+}
+
+/**
+ * Image effect callback; Scale an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the scale effect with the
+ * following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ * - "upscale": A boolean indicating that the image should be upscaled if the
+ * dimensions are larger than the original image.
+ *
+ * @return
+ * TRUE on success. FALSE on failure to scale image.
+ *
+ * @see image_scale()
+ */
+function image_scale_effect(&$image, $data) {
+ // Set sane default values.
+ $data += array(
+ 'upscale' => FALSE,
+ );
+
+ // Set impossibly large values if the width and height aren't set.
+ $data['width'] = empty($data['width']) ? PHP_INT_MAX : $data['width'];
+ $data['height'] = empty($data['height']) ? PHP_INT_MAX : $data['height'];
+
+ if (!image_scale($image, $data['width'], $data['height'], $data['upscale'])) {
+ watchdog('image', 'Image scale failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image dimensions callback; Scale.
+ *
+ * @param $dimensions
+ * Dimensions to be modified - an array with components width and height, in
+ * pixels.
+ * @param $data
+ * An array of attributes to use when performing the scale effect with the
+ * following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ * - "upscale": A boolean indicating that the image should be upscaled if the
+ * dimensions are larger than the original image.
+ */
+function image_scale_dimensions(array &$dimensions, array $data) {
+ if ($dimensions['width'] && $dimensions['height']) {
+ image_dimensions_scale($dimensions, $data['width'], $data['height'], $data['upscale']);
+ }
+}
+
+/**
+ * Image effect callback; Crop an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the crop effect with the
+ * following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ * - "anchor": A string describing where the crop should originate in the form
+ * of "XOFFSET-YOFFSET". XOFFSET is either a number of pixels or
+ * "left", "center", "right" and YOFFSET is either a number of pixels or
+ * "top", "center", "bottom".
+ * @return
+ * TRUE on success. FALSE on failure to crop image.
+ * @see image_crop()
+ */
+function image_crop_effect(&$image, $data) {
+ // Set sane default values.
+ $data += array(
+ 'anchor' => 'center-center',
+ );
+
+ list($x, $y) = explode('-', $data['anchor']);
+ $x = image_filter_keyword($x, $image->info['width'], $data['width']);
+ $y = image_filter_keyword($y, $image->info['height'], $data['height']);
+ if (!image_crop($image, $x, $y, $data['width'], $data['height'])) {
+ watchdog('image', 'Image crop failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image effect callback; Scale and crop an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the scale and crop effect
+ * with the following items:
+ * - "width": An integer representing the desired width in pixels.
+ * - "height": An integer representing the desired height in pixels.
+ * @return
+ * TRUE on success. FALSE on failure to scale and crop image.
+ * @see image_scale_and_crop()
+ */
+function image_scale_and_crop_effect(&$image, $data) {
+ if (!image_scale_and_crop($image, $data['width'], $data['height'])) {
+ watchdog('image', 'Image scale and crop failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image effect callback; Desaturate (grayscale) an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the desaturate effect.
+ * @return
+ * TRUE on success. FALSE on failure to desaturate image.
+ * @see image_desaturate()
+ */
+function image_desaturate_effect(&$image, $data) {
+ if (!image_desaturate($image)) {
+ watchdog('image', 'Image desaturate failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image effect callback; Rotate an image resource.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array of attributes to use when performing the rotate effect containing
+ * the following items:
+ * - "degrees": The number of (clockwise) degrees to rotate the image.
+ * - "random": A boolean indicating that a random rotation angle should be
+ * used for this image. The angle specified in "degrees" is used as a
+ * positive and negative maximum.
+ * - "bgcolor": The background color to use for exposed areas of the image.
+ * Use web-style hex colors (#FFFFFF for white, #000000 for black). Leave
+ * blank for transparency on image types that support it.
+ * @return
+ * TRUE on success. FALSE on failure to rotate image.
+ * @see image_rotate().
+ */
+function image_rotate_effect(&$image, $data) {
+ // Set sane default values.
+ $data += array(
+ 'degrees' => 0,
+ 'bgcolor' => NULL,
+ 'random' => FALSE,
+ );
+
+ // Convert short #FFF syntax to full #FFFFFF syntax.
+ if (strlen($data['bgcolor']) == 4) {
+ $c = $data['bgcolor'];
+ $data['bgcolor'] = $c[0] . $c[1] . $c[1] . $c[2] . $c[2] . $c[3] . $c[3];
+ }
+
+ // Convert #FFFFFF syntax to hexadecimal colors.
+ if ($data['bgcolor'] != '') {
+ $data['bgcolor'] = hexdec(str_replace('#', '0x', $data['bgcolor']));
+ }
+ else {
+ $data['bgcolor'] = NULL;
+ }
+
+ if (!empty($data['random'])) {
+ $degrees = abs((float) $data['degrees']);
+ $data['degrees'] = rand(-1 * $degrees, $degrees);
+ }
+
+ if (!image_rotate($image, $data['degrees'], $data['bgcolor'])) {
+ watchdog('image', 'Image rotate failed using the %toolkit toolkit on %path (%mimetype, %dimensions)', array('%toolkit' => $image->toolkit, '%path' => $image->source, '%mimetype' => $image->info['mime_type'], '%dimensions' => $image->info['width'] . 'x' . $image->info['height']), WATCHDOG_ERROR);
+ return FALSE;
+ }
+ return TRUE;
+}
+
+/**
+ * Image dimensions callback; Rotate.
+ *
+ * @param $dimensions
+ * Dimensions to be modified - an array with components width and height, in
+ * pixels.
+ * @param $data
+ * An array of attributes to use when performing the rotate effect containing
+ * the following items:
+ * - "degrees": The number of (clockwise) degrees to rotate the image.
+ * - "random": A boolean indicating that a random rotation angle should be
+ * used for this image. The angle specified in "degrees" is used as a
+ * positive and negative maximum.
+ */
+function image_rotate_dimensions(array &$dimensions, array $data) {
+ // If the rotate is not random and the angle is a multiple of 90 degrees,
+ // then the new dimensions can be determined.
+ if (!$data['random'] && ((int) ($data['degrees']) == $data['degrees']) && ($data['degrees'] % 90 == 0)) {
+ if ($data['degrees'] % 180 != 0) {
+ $temp = $dimensions['width'];
+ $dimensions['width'] = $dimensions['height'];
+ $dimensions['height'] = $temp;
+ }
+ }
+ else {
+ $dimensions['width'] = $dimensions['height'] = NULL;
+ }
+}
diff --git a/core/modules/image/image.field.inc b/core/modules/image/image.field.inc
new file mode 100644
index 000000000000..c3ac1d561f0f
--- /dev/null
+++ b/core/modules/image/image.field.inc
@@ -0,0 +1,610 @@
+<?php
+
+/**
+ * @file
+ * Implement an image field, based on the file module's file field.
+ */
+
+/**
+ * Implements hook_field_info().
+ */
+function image_field_info() {
+ return array(
+ 'image' => array(
+ 'label' => t('Image'),
+ 'description' => t('This field stores the ID of an image file as an integer value.'),
+ 'settings' => array(
+ 'uri_scheme' => variable_get('file_default_scheme', 'public'),
+ 'default_image' => 0,
+ ),
+ 'instance_settings' => array(
+ 'file_extensions' => 'png gif jpg jpeg',
+ 'file_directory' => '',
+ 'max_filesize' => '',
+ 'alt_field' => 0,
+ 'title_field' => 0,
+ 'max_resolution' => '',
+ 'min_resolution' => '',
+ ),
+ 'default_widget' => 'image_image',
+ 'default_formatter' => 'image',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_settings_form().
+ */
+function image_field_settings_form($field, $instance) {
+ $defaults = field_info_field_settings($field['type']);
+ $settings = array_merge($defaults, $field['settings']);
+
+ $scheme_options = array();
+ foreach (file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE) as $scheme => $stream_wrapper) {
+ $scheme_options[$scheme] = $stream_wrapper['name'];
+ }
+ $form['uri_scheme'] = array(
+ '#type' => 'radios',
+ '#title' => t('Upload destination'),
+ '#options' => $scheme_options,
+ '#default_value' => $settings['uri_scheme'],
+ '#description' => t('Select where the final files should be stored. Private file storage has significantly more overhead than public files, but allows restricted access to files within this field.'),
+ );
+
+ // When the user sets the scheme on the UI, even for the first time, it's
+ // updating a field because fields are created on the "Manage fields"
+ // page. So image_field_update_field() can handle this change.
+ $form['default_image'] = array(
+ '#title' => t('Default image'),
+ '#type' => 'managed_file',
+ '#description' => t('If no image is uploaded, this image will be shown on display.'),
+ '#default_value' => $field['settings']['default_image'],
+ '#upload_location' => $settings['uri_scheme'] . '://default_images/',
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_instance_settings_form().
+ */
+function image_field_instance_settings_form($field, $instance) {
+ $settings = $instance['settings'];
+
+ // Use the file field instance settings form as a basis.
+ $form = file_field_instance_settings_form($field, $instance);
+
+ // Add maximum and minimum resolution settings.
+ $max_resolution = explode('x', $settings['max_resolution']) + array('', '');
+ $form['max_resolution'] = array(
+ '#type' => 'item',
+ '#title' => t('Maximum image resolution'),
+ '#element_validate' => array('_image_field_resolution_validate'),
+ '#weight' => 4.1,
+ '#field_prefix' => '<div class="container-inline">',
+ '#field_suffix' => '</div>',
+ '#description' => t('The maximum allowed image size expressed as WIDTHxHEIGHT (e.g. 640x480). Leave blank for no restriction. If a larger image is uploaded, it will be resized to reflect the given width and height. Resizing images on upload will cause the loss of <a href="http://en.wikipedia.org/wiki/Exchangeable_image_file_format">EXIF data</a> in the image.'),
+ );
+ $form['max_resolution']['x'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum width'),
+ '#title_display' => 'invisible',
+ '#default_value' => $max_resolution[0],
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => ' x ',
+ );
+ $form['max_resolution']['y'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Maximum height'),
+ '#title_display' => 'invisible',
+ '#default_value' => $max_resolution[1],
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => ' ' . t('pixels'),
+ );
+
+ $min_resolution = explode('x', $settings['min_resolution']) + array('', '');
+ $form['min_resolution'] = array(
+ '#type' => 'item',
+ '#title' => t('Minimum image resolution'),
+ '#element_validate' => array('_image_field_resolution_validate'),
+ '#weight' => 4.2,
+ '#field_prefix' => '<div class="container-inline">',
+ '#field_suffix' => '</div>',
+ '#description' => t('The minimum allowed image size expressed as WIDTHxHEIGHT (e.g. 640x480). Leave blank for no restriction. If a smaller image is uploaded, it will be rejected.'),
+ );
+ $form['min_resolution']['x'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Minimum width'),
+ '#title_display' => 'invisible',
+ '#default_value' => $min_resolution[0],
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => ' x ',
+ );
+ $form['min_resolution']['y'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Minimum height'),
+ '#title_display' => 'invisible',
+ '#default_value' => $min_resolution[1],
+ '#size' => 5,
+ '#maxlength' => 5,
+ '#field_suffix' => ' ' . t('pixels'),
+ );
+
+ // Remove the description option.
+ unset($form['description_field']);
+
+ // Add title and alt configuration options.
+ $form['alt_field'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable <em>Alt</em> field'),
+ '#default_value' => $settings['alt_field'],
+ '#description' => t('The alt attribute may be used by search engines, screen readers, and when the image cannot be loaded.'),
+ '#weight' => 10,
+ );
+ $form['title_field'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable <em>Title</em> field'),
+ '#default_value' => $settings['title_field'],
+ '#description' => t('The title attribute is used as a tooltip when the mouse hovers over the image.'),
+ '#weight' => 11,
+ );
+
+ return $form;
+}
+
+/**
+ * Element validate function for resolution fields.
+ */
+function _image_field_resolution_validate($element, &$form_state) {
+ if (!empty($element['x']['#value']) || !empty($element['y']['#value'])) {
+ foreach (array('x', 'y') as $dimension) {
+ $value = $element[$dimension]['#value'];
+ if (!is_numeric($value)) {
+ form_error($element[$dimension], t('Height and width values must be numeric.'));
+ return;
+ }
+ if (intval($value) == 0) {
+ form_error($element[$dimension], t('Both a height and width value must be specified in the !name field.', array('!name' => $element['#title'])));
+ return;
+ }
+ }
+ form_set_value($element, intval($element['x']['#value']) . 'x' . intval($element['y']['#value']), $form_state);
+ }
+ else {
+ form_set_value($element, '', $form_state);
+ }
+}
+
+/**
+ * Implements hook_field_load().
+ */
+function image_field_load($entity_type, $entities, $field, $instances, $langcode, &$items, $age) {
+ file_field_load($entity_type, $entities, $field, $instances, $langcode, $items, $age);
+}
+
+/**
+ * Implements hook_field_prepare_view().
+ */
+function image_field_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items) {
+ // If there are no files specified at all, use the default.
+ foreach ($entities as $id => $entity) {
+ if (empty($items[$id]) && $field['settings']['default_image']) {
+ if ($file = file_load($field['settings']['default_image'])) {
+ $items[$id][0] = (array) $file + array(
+ 'is_default' => TRUE,
+ 'alt' => '',
+ 'title' => '',
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_presave().
+ */
+function image_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ file_field_presave($entity_type, $entity, $field, $instance, $langcode, $items);
+
+ // Determine the dimensions if necessary.
+ foreach ($items as &$item) {
+ if (!isset($item['width']) || !isset($item['height'])) {
+ $info = image_get_info(file_load($item['fid'])->uri);
+
+ if (is_array($info)) {
+ $item['width'] = $info['width'];
+ $item['height'] = $info['height'];
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_insert().
+ */
+function image_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ file_field_insert($entity_type, $entity, $field, $instance, $langcode, $items);
+}
+
+/**
+ * Implements hook_field_update().
+ */
+function image_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ file_field_update($entity_type, $entity, $field, $instance, $langcode, $items);
+}
+
+/**
+ * Implements hook_field_delete().
+ */
+function image_field_delete($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ file_field_delete($entity_type, $entity, $field, $instance, $langcode, $items);
+}
+
+/**
+ * Implements hook_field_delete_revision().
+ */
+function image_field_delete_revision($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ file_field_delete_revision($entity_type, $entity, $field, $instance, $langcode, $items);
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function image_field_is_empty($item, $field) {
+ return file_field_is_empty($item, $field);
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function image_field_widget_info() {
+ return array(
+ 'image_image' => array(
+ 'label' => t('Image'),
+ 'field types' => array('image'),
+ 'settings' => array(
+ 'progress_indicator' => 'throbber',
+ 'preview_image_style' => 'thumbnail',
+ ),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ 'default value' => FIELD_BEHAVIOR_NONE,
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_settings_form().
+ */
+function image_field_widget_settings_form($field, $instance) {
+ $widget = $instance['widget'];
+ $settings = $widget['settings'];
+
+ // Use the file widget settings form.
+ $form = file_field_widget_settings_form($field, $instance);
+
+ $form['preview_image_style'] = array(
+ '#title' => t('Preview image style'),
+ '#type' => 'select',
+ '#options' => image_style_options(FALSE),
+ '#empty_option' => '<' . t('no preview') . '>',
+ '#default_value' => $settings['preview_image_style'],
+ '#description' => t('The preview image will be shown while editing the content.'),
+ '#weight' => 15,
+ );
+
+ return $form;
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function image_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+
+ // Add display_field setting to field because file_field_widget_form() assumes it is set.
+ $field['settings']['display_field'] = 0;
+
+ $elements = file_field_widget_form($form, $form_state, $field, $instance, $langcode, $items, $delta, $element);
+ $settings = $instance['settings'];
+
+ foreach (element_children($elements) as $delta) {
+ // Add upload resolution validation.
+ if ($settings['max_resolution'] || $settings['min_resolution']) {
+ $elements[$delta]['#upload_validators']['file_validate_image_resolution'] = array($settings['max_resolution'], $settings['min_resolution']);
+ }
+
+ // If not using custom extension validation, ensure this is an image.
+ $supported_extensions = array('png', 'gif', 'jpg', 'jpeg');
+ $extensions = isset($elements[$delta]['#upload_validators']['file_validate_extensions'][0]) ? $elements[$delta]['#upload_validators']['file_validate_extensions'][0] : implode(' ', $supported_extensions);
+ $extensions = array_intersect(explode(' ', $extensions), $supported_extensions);
+ $elements[$delta]['#upload_validators']['file_validate_extensions'][0] = implode(' ', $extensions);
+
+ // Add all extra functionality provided by the image widget.
+ $elements[$delta]['#process'][] = 'image_field_widget_process';
+ }
+
+ if ($field['cardinality'] == 1) {
+ // If there's only one field, return it as delta 0.
+ if (empty($elements[0]['#default_value']['fid'])) {
+ $elements[0]['#description'] = theme('file_upload_help', array('description' => $instance['description'], 'upload_validators' => $elements[0]['#upload_validators']));
+ }
+ }
+ else {
+ $elements['#file_upload_description'] = theme('file_upload_help', array('upload_validators' => $elements[0]['#upload_validators']));
+ }
+ return $elements;
+}
+
+/**
+ * An element #process callback for the image_image field type.
+ *
+ * Expands the image_image type to include the alt and title fields.
+ */
+function image_field_widget_process($element, &$form_state, $form) {
+ $item = $element['#value'];
+ $item['fid'] = $element['fid']['#value'];
+
+ $instance = field_widget_instance($element, $form_state);
+
+ $settings = $instance['settings'];
+ $widget_settings = $instance['widget']['settings'];
+
+ $element['#theme'] = 'image_widget';
+ $element['#attached']['css'][] = drupal_get_path('module', 'image') . '/image.css';
+
+ // Add the image preview.
+ if ($element['#file'] && $widget_settings['preview_image_style']) {
+ $variables = array(
+ 'style_name' => $widget_settings['preview_image_style'],
+ 'path' => $element['#file']->uri,
+ );
+
+ // Determine image dimensions.
+ if (isset($element['#value']['width']) && isset($element['#value']['height'])) {
+ $variables['width'] = $element['#value']['width'];
+ $variables['height'] = $element['#value']['height'];
+ }
+ else {
+ $info = image_get_info($element['#file']->uri);
+
+ if (is_array($info)) {
+ $variables['width'] = $info['width'];
+ $variables['height'] = $info['height'];
+ }
+ else {
+ $variables['width'] = $variables['height'] = NULL;
+ }
+ }
+
+ $element['preview'] = array(
+ '#type' => 'markup',
+ '#markup' => theme('image_style', $variables),
+ );
+
+ // Store the dimensions in the form so the file doesn't have to be accessed
+ // again. This is important for remote files.
+ $element['width'] = array(
+ '#type' => 'hidden',
+ '#value' => $variables['width'],
+ );
+ $element['height'] = array(
+ '#type' => 'hidden',
+ '#value' => $variables['height'],
+ );
+ }
+
+ // Add the additional alt and title fields.
+ $element['alt'] = array(
+ '#title' => t('Alternate text'),
+ '#type' => 'textfield',
+ '#default_value' => isset($item['alt']) ? $item['alt'] : '',
+ '#description' => t('This text will be used by screen readers, search engines, or when the image cannot be loaded.'),
+ '#maxlength' => variable_get('image_alt_length', 80), // See http://www.gawds.org/show.php?contentid=28.
+ '#weight' => -2,
+ '#access' => (bool) $item['fid'] && $settings['alt_field'],
+ );
+ $element['title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Title'),
+ '#default_value' => isset($item['title']) ? $item['title'] : '',
+ '#description' => t('The title is used as a tool tip when the user hovers the mouse over the image.'),
+ '#maxlength' => variable_get('image_title_length', 500),
+ '#weight' => -1,
+ '#access' => (bool) $item['fid'] && $settings['title_field'],
+ );
+
+ return $element;
+}
+
+/**
+ * Returns HTML for an image field widget.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: A render element representing the image field widget.
+ *
+ * @ingroup themeable
+ */
+function theme_image_widget($variables) {
+ $element = $variables['element'];
+ $output = '';
+ $output .= '<div class="image-widget form-managed-file clearfix">';
+
+ if (isset($element['preview'])) {
+ $output .= '<div class="image-preview">';
+ $output .= drupal_render($element['preview']);
+ $output .= '</div>';
+ }
+
+ $output .= '<div class="image-widget-data">';
+ if ($element['fid']['#value'] != 0) {
+ $element['filename']['#markup'] .= ' <span class="file-size">(' . format_size($element['#file']->filesize) . ')</span> ';
+ }
+ $output .= drupal_render_children($element);
+ $output .= '</div>';
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function image_field_formatter_info() {
+ $formatters = array(
+ 'image' => array(
+ 'label' => t('Image'),
+ 'field types' => array('image'),
+ 'settings' => array('image_style' => '', 'image_link' => ''),
+ ),
+ );
+
+ return $formatters;
+}
+
+/**
+ * Implements hook_field_formatter_settings_form().
+ */
+function image_field_formatter_settings_form($field, $instance, $view_mode, $form, &$form_state) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $image_styles = image_style_options(FALSE);
+ $element['image_style'] = array(
+ '#title' => t('Image style'),
+ '#type' => 'select',
+ '#default_value' => $settings['image_style'],
+ '#empty_option' => t('None (original image)'),
+ '#options' => $image_styles,
+ );
+
+ $link_types = array(
+ 'content' => t('Content'),
+ 'file' => t('File'),
+ );
+ $element['image_link'] = array(
+ '#title' => t('Link image to'),
+ '#type' => 'select',
+ '#default_value' => $settings['image_link'],
+ '#empty_option' => t('Nothing'),
+ '#options' => $link_types,
+ );
+
+ return $element;
+}
+
+/**
+ * Implements hook_field_formatter_settings_summary().
+ */
+function image_field_formatter_settings_summary($field, $instance, $view_mode) {
+ $display = $instance['display'][$view_mode];
+ $settings = $display['settings'];
+
+ $summary = array();
+
+ $image_styles = image_style_options(FALSE);
+ // Unset possible 'No defined styles' option.
+ unset($image_styles['']);
+ // Styles could be lost because of enabled/disabled modules that defines
+ // their styles in code.
+ if (isset($image_styles[$settings['image_style']])) {
+ $summary[] = t('Image style: @style', array('@style' => $image_styles[$settings['image_style']]));
+ }
+ else {
+ $summary[] = t('Original image');
+ }
+
+ $link_types = array(
+ 'content' => t('Linked to content'),
+ 'file' => t('Linked to file'),
+ );
+ // Display this setting only if image is linked.
+ if (isset($link_types[$settings['image_link']])) {
+ $summary[] = $link_types[$settings['image_link']];
+ }
+
+ return implode('<br />', $summary);
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function image_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+
+ // Check if the formatter involves a link.
+ if ($display['settings']['image_link'] == 'content') {
+ $uri = entity_uri($entity_type, $entity);
+ }
+ elseif ($display['settings']['image_link'] == 'file') {
+ $link_file = TRUE;
+ }
+
+ foreach ($items as $delta => $item) {
+ if (isset($link_file)) {
+ $uri = array(
+ 'path' => file_create_url($item['uri']),
+ 'options' => array(),
+ );
+ }
+ $element[$delta] = array(
+ '#theme' => 'image_formatter',
+ '#item' => $item,
+ '#image_style' => $display['settings']['image_style'],
+ '#path' => isset($uri) ? $uri : '',
+ );
+ }
+
+ return $element;
+}
+
+/**
+ * Returns HTML for an image field formatter.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - item: An array of image data.
+ * - image_style: An optional image style.
+ * - path: An array containing the link 'path' and link 'options'.
+ *
+ * @ingroup themeable
+ */
+function theme_image_formatter($variables) {
+ $item = $variables['item'];
+ $image = array(
+ 'path' => $item['uri'],
+ 'alt' => $item['alt'],
+ );
+
+ if (isset($item['width']) && isset($item['height'])) {
+ $image['width'] = $item['width'];
+ $image['height'] = $item['height'];
+ }
+
+ // Do not output an empty 'title' attribute.
+ if (drupal_strlen($item['title']) > 0) {
+ $image['title'] = $item['title'];
+ }
+
+ if ($variables['image_style']) {
+ $image['style_name'] = $variables['image_style'];
+ $output = theme('image_style', $image);
+ }
+ else {
+ $output = theme('image', $image);
+ }
+
+ if (!empty($variables['path']['path'])) {
+ $path = $variables['path']['path'];
+ $options = $variables['path']['options'];
+ // When displaying an image inside a link, the html option must be TRUE.
+ $options['html'] = TRUE;
+ $output = l($output, $path, $options);
+ }
+
+ return $output;
+}
diff --git a/core/modules/image/image.info b/core/modules/image/image.info
new file mode 100644
index 000000000000..5f9b6511a0f7
--- /dev/null
+++ b/core/modules/image/image.info
@@ -0,0 +1,8 @@
+name = Image
+description = Provides image manipulation tools.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = file
+files[] = image.test
+configure = admin/config/media/image-styles
diff --git a/core/modules/image/image.install b/core/modules/image/image.install
new file mode 100644
index 000000000000..4d4399cc1367
--- /dev/null
+++ b/core/modules/image/image.install
@@ -0,0 +1,189 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the image module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function image_install() {
+ // Create the styles directory and ensure it's writable.
+ $directory = file_default_scheme() . '://styles';
+ file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function image_uninstall() {
+ // Remove the styles directory and generated images.
+ file_unmanaged_delete_recursive(file_default_scheme() . '://styles');
+}
+
+/**
+ * Implements hook_schema().
+ */
+function image_schema() {
+ $schema = array();
+
+ $schema['image_styles'] = array(
+ 'description' => 'Stores configuration options for image styles.',
+ 'fields' => array(
+ 'isid' => array(
+ 'description' => 'The primary identifier for an image style.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'name' => array(
+ 'description' => 'The style name.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('isid'),
+ 'unique keys' => array(
+ 'name' => array('name'),
+ ),
+ );
+
+ $schema['image_effects'] = array(
+ 'description' => 'Stores configuration options for image effects.',
+ 'fields' => array(
+ 'ieid' => array(
+ 'description' => 'The primary identifier for an image effect.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'isid' => array(
+ 'description' => 'The {image_styles}.isid for an image style.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'weight' => array(
+ 'description' => 'The weight of the effect in the style.',
+ 'type' => 'int',
+ 'unsigned' => FALSE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'name' => array(
+ 'description' => 'The unique name of the effect to be executed.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'data' => array(
+ 'description' => 'The configuration data for the effect.',
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'serialize' => TRUE,
+ ),
+ ),
+ 'primary key' => array('ieid'),
+ 'indexes' => array(
+ 'isid' => array('isid'),
+ 'weight' => array('weight'),
+ ),
+ 'foreign keys' => array(
+ 'image_style' => array(
+ 'table' => 'image_styles',
+ 'columns' => array('isid' => 'isid'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_field_schema().
+ */
+function image_field_schema($field) {
+ return array(
+ 'columns' => array(
+ 'fid' => array(
+ 'description' => 'The {file_managed}.fid being referenced in this field.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'unsigned' => TRUE,
+ ),
+ 'alt' => array(
+ 'description' => "Alternative image text, for the image's 'alt' attribute.",
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => FALSE,
+ ),
+ 'title' => array(
+ 'description' => "Image title text, for the image's 'title' attribute.",
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => FALSE,
+ ),
+ 'width' => array(
+ 'description' => 'The width of the image in pixels.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ ),
+ 'height' => array(
+ 'description' => 'The height of the image in pixels.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ ),
+ ),
+ 'indexes' => array(
+ 'fid' => array('fid'),
+ ),
+ 'foreign keys' => array(
+ 'fid' => array(
+ 'table' => 'file_managed',
+ 'columns' => array('fid' => 'fid'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_requirements() to check the PHP GD Library.
+ *
+ * @param $phase
+ */
+function image_requirements($phase) {
+ $requirements = array();
+
+ if ($phase == 'runtime') {
+ // Check for the PHP GD library.
+ if (function_exists('imagegd2')) {
+ $info = gd_info();
+ $requirements['image_gd'] = array(
+ 'value' => $info['GD Version'],
+ );
+
+ // Check for filter and rotate support.
+ if (function_exists('imagefilter') && function_exists('imagerotate')) {
+ $requirements['image_gd']['severity'] = REQUIREMENT_OK;
+ }
+ else {
+ $requirements['image_gd']['severity'] = REQUIREMENT_WARNING;
+ $requirements['image_gd']['description'] = t('The GD Library for PHP is enabled, but was compiled without support for functions used by the rotate and desaturate effects. It was probably compiled using the official GD libraries from http://www.libgd.org instead of the GD library bundled with PHP. You should recompile PHP --with-gd using the bundled GD library. See <a href="http://www.php.net/manual/book.image.php">the PHP manual</a>.');
+ }
+ }
+ else {
+ $requirements['image_gd'] = array(
+ 'value' => t('Not installed'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => t('The GD library for PHP is missing or outdated. Check the <a href="@url">PHP image documentation</a> for information on how to correct this.', array('@url' => 'http://www.php.net/manual/book.image.php')),
+ );
+ }
+ $requirements['image_gd']['title'] = t('GD library rotate and desaturate effects');
+ }
+
+ return $requirements;
+}
diff --git a/core/modules/image/image.module b/core/modules/image/image.module
new file mode 100644
index 000000000000..54d98ebaeff9
--- /dev/null
+++ b/core/modules/image/image.module
@@ -0,0 +1,1224 @@
+<?php
+
+/**
+ * @file
+ * Exposes global functionality for creating image styles.
+ */
+
+/**
+ * Image style constant for user presets in the database.
+ */
+define('IMAGE_STORAGE_NORMAL', 1);
+
+/**
+ * Image style constant for user presets that override module-defined presets.
+ */
+define('IMAGE_STORAGE_OVERRIDE', 2);
+
+/**
+ * Image style constant for module-defined presets in code.
+ */
+define('IMAGE_STORAGE_DEFAULT', 4);
+
+/**
+ * Image style constant to represent an editable preset.
+ */
+define('IMAGE_STORAGE_EDITABLE', IMAGE_STORAGE_NORMAL | IMAGE_STORAGE_OVERRIDE);
+
+/**
+ * Image style constant to represent any module-based preset.
+ */
+define('IMAGE_STORAGE_MODULE', IMAGE_STORAGE_OVERRIDE | IMAGE_STORAGE_DEFAULT);
+
+// Load all Field module hooks for Image.
+require_once DRUPAL_ROOT . '/core/modules/image/image.field.inc';
+
+/**
+ * Implement of hook_help().
+ */
+function image_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#image':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Image module allows you to manipulate images on your website. It exposes a setting for using the <em>Image toolkit</em>, allows you to configure <em>Image styles</em> that can be used for resizing or adjusting images on display, and provides an <em>Image</em> field for attaching images to content. For more information, see the online handbook entry for <a href="@image">Image module</a>.', array('@image' => 'http://drupal.org/handbook/modules/image')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Manipulating images') . '</dt>';
+ $output .= '<dd>' . t('With the Image module you can scale, crop, resize, rotate and desaturate images without affecting the original image using <a href="@image">image styles</a>. When you change an image style, the module automatically refreshes all created images. Every image style must have a name, which will be used in the URL of the generated images. There are two common approaches to naming image styles (which you use will depend on how the image style is being applied):',array('@image' => url('admin/config/media/image-styles')));
+ $output .= '<ul><li>' . t('Based on where it will be used: eg. <em>profile-picture</em>') . '</li>';
+ $output .= '<li>' . t('Describing its appearance: eg. <em>square-85x85</em>') . '</li></ul>';
+ $output .= t('After you create an image style, you can add effects: crop, scale, resize, rotate, and desaturate (other contributed modules provide additional effects). For example, by combining effects as crop, scale, and desaturate, you can create square, grayscale thumbnails.') . '<dd>';
+ $output .= '<dt>' . t('Attaching images to content as fields') . '</dt>';
+ $output .= '<dd>' . t("Image module also allows you to attach images to content as fields. To add an image field to a <a href='@content-type'>content type</a>, go to the content type's <em>manage fields</em> page, and add a new field of type <em>Image</em>. Attaching images to content this way allows image styles to be applied and maintained, and also allows you more flexibility when theming.", array('@content-type' => url('admin/structure/types'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/config/media/image-styles':
+ return '<p>' . t('Image styles commonly provide thumbnail sizes by scaling and cropping images, but can also add various effects before an image is displayed. When an image is displayed with a style, a new file is created and the original image is left unchanged.') . '</p>';
+ case 'admin/config/media/image-styles/edit/%/add/%':
+ $effect = image_effect_definition_load($arg[7]);
+ return isset($effect['help']) ? ('<p>' . $effect['help'] . '</p>') : NULL;
+ case 'admin/config/media/image-styles/edit/%/effects/%':
+ $effect = ($arg[5] == 'add') ? image_effect_definition_load($arg[6]) : image_effect_load($arg[6], $arg[4]);
+ return isset($effect['help']) ? ('<p>' . $effect['help'] . '</p>') : NULL;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function image_menu() {
+ $items = array();
+
+ // Generate image derivatives of publicly available files.
+ // If clean URLs are disabled, image derivatives will always be served
+ // through the menu system.
+ // If clean URLs are enabled and the image derivative already exists,
+ // PHP will be bypassed.
+ $directory_path = file_stream_wrapper_get_instance_by_scheme('public')->getDirectoryPath();
+ $items[$directory_path . '/styles/%image_style'] = array(
+ 'title' => 'Generate image style',
+ 'page callback' => 'image_style_deliver',
+ 'page arguments' => array(count(explode('/', $directory_path)) + 1),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ // Generate and deliver image derivatives of private files.
+ // These image derivatives are always delivered through the menu system.
+ $items['system/files/styles/%image_style'] = array(
+ 'title' => 'Generate image style',
+ 'page callback' => 'image_style_deliver',
+ 'page arguments' => array(3),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['admin/config/media/image-styles'] = array(
+ 'title' => 'Image styles',
+ 'description' => 'Configure styles that can be used for resizing or adjusting images on display.',
+ 'page callback' => 'image_style_list',
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/list'] = array(
+ 'title' => 'List',
+ 'description' => 'List the current image styles on the site.',
+ 'page callback' => 'image_style_list',
+ 'access arguments' => array('administer image styles'),
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => 1,
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/add'] = array(
+ 'title' => 'Add style',
+ 'description' => 'Add a new image style.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_style_add_form'),
+ 'access arguments' => array('administer image styles'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'weight' => 2,
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/edit/%image_style'] = array(
+ 'title' => 'Edit style',
+ 'description' => 'Configure an image style.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_style_form', 5),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/delete/%image_style'] = array(
+ 'title' => 'Delete style',
+ 'description' => 'Delete an image style.',
+ 'load arguments' => array(NULL, (string) IMAGE_STORAGE_NORMAL),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_style_delete_form', 5),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/revert/%image_style'] = array(
+ 'title' => 'Revert style',
+ 'description' => 'Revert an image style.',
+ 'load arguments' => array(NULL, (string) IMAGE_STORAGE_OVERRIDE),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_style_revert_form', 5),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/edit/%image_style/effects/%image_effect'] = array(
+ 'title' => 'Edit image effect',
+ 'description' => 'Edit an existing effect within a style.',
+ 'load arguments' => array(5, (string) IMAGE_STORAGE_EDITABLE),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_effect_form', 5, 7),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/edit/%image_style/effects/%image_effect/delete'] = array(
+ 'title' => 'Delete image effect',
+ 'description' => 'Delete an existing effect from a style.',
+ 'load arguments' => array(5, (string) IMAGE_STORAGE_EDITABLE),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_effect_delete_form', 5, 7),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+ $items['admin/config/media/image-styles/edit/%image_style/add/%image_effect_definition'] = array(
+ 'title' => 'Add image effect',
+ 'description' => 'Add a new effect to a style.',
+ 'load arguments' => array(5),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('image_effect_form', 5, 7),
+ 'access arguments' => array('administer image styles'),
+ 'file' => 'image.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function image_theme() {
+ return array(
+ // Theme functions in image.module.
+ 'image_style' => array(
+ 'variables' => array(
+ 'style_name' => NULL,
+ 'path' => NULL,
+ 'width' => NULL,
+ 'height' => NULL,
+ 'alt' => '',
+ 'title' => NULL,
+ 'attributes' => array(),
+ ),
+ ),
+
+ // Theme functions in image.admin.inc.
+ 'image_style_list' => array(
+ 'variables' => array('styles' => NULL),
+ ),
+ 'image_style_effects' => array(
+ 'render element' => 'form',
+ ),
+ 'image_style_preview' => array(
+ 'variables' => array('style' => NULL),
+ ),
+ 'image_anchor' => array(
+ 'render element' => 'element',
+ ),
+ 'image_resize_summary' => array(
+ 'variables' => array('data' => NULL),
+ ),
+ 'image_scale_summary' => array(
+ 'variables' => array('data' => NULL),
+ ),
+ 'image_crop_summary' => array(
+ 'variables' => array('data' => NULL),
+ ),
+ 'image_rotate_summary' => array(
+ 'variables' => array('data' => NULL),
+ ),
+
+ // Theme functions in image.field.inc.
+ 'image_widget' => array(
+ 'render element' => 'element',
+ ),
+ 'image_formatter' => array(
+ 'variables' => array('item' => NULL, 'path' => NULL, 'image_style' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_permission().
+ */
+function image_permission() {
+ return array(
+ 'administer image styles' => array(
+ 'title' => t('Administer image styles'),
+ 'description' => t('Create and modify styles for generating image modifications such as thumbnails.'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function image_form_system_file_system_settings_alter(&$form, &$form_state) {
+ $form['#submit'][] = 'image_system_file_system_settings_submit';
+}
+
+/**
+ * Submit handler for the file system settings form.
+ *
+ * Adds a menu rebuild after the public file path has been changed, so that the
+ * menu router item depending on that file path will be regenerated.
+ */
+function image_system_file_system_settings_submit($form, &$form_state) {
+ if ($form['file_public_path']['#default_value'] !== $form_state['values']['file_public_path']) {
+ variable_set('menu_rebuild_needed', TRUE);
+ }
+}
+
+/**
+ * Implements hook_file_download().
+ *
+ * Control the access to files underneath the styles directory.
+ */
+function image_file_download($uri) {
+ $path = file_uri_target($uri);
+
+ // Private file access for image style derivatives.
+ if (strpos($path, 'styles/') === 0) {
+ $args = explode('/', $path);
+ // Discard the first part of the path (styles).
+ array_shift($args);
+ // Get the style name from the second part.
+ $style_name = array_shift($args);
+ // Remove the scheme from the path.
+ array_shift($args);
+
+ // Then the remaining parts are the path to the image.
+ $original_uri = file_uri_scheme($uri) . '://' . implode('/', $args);
+
+ // Check that the file exists and is an image.
+ if ($info = image_get_info($uri)) {
+ // Check the permissions of the original to grant access to this image.
+ $headers = module_invoke_all('file_download', $original_uri);
+ if (!in_array(-1, $headers)) {
+ return array(
+ // Send headers describing the image's size, and MIME-type...
+ 'Content-Type' => $info['mime_type'],
+ 'Content-Length' => $info['file_size'],
+ // ...and allow the file to be cached for two weeks (matching the
+ // value we/ use for the mod_expires settings in .htaccess) and
+ // ensure that caching proxies do not share the image with other
+ // users.
+ 'Expires' => gmdate(DATE_RFC1123, REQUEST_TIME + 1209600),
+ 'Cache-Control' => 'max-age=1209600, private, must-revalidate',
+ );
+ }
+ }
+ return -1;
+ }
+
+ // Private file access for the original files. Note that we only
+ // check access for non-temporary images, since file.module will
+ // grant access for all temporary files.
+ $files = file_load_multiple(array(), array('uri' => $uri));
+ if (count($files)) {
+ $file = reset($files);
+ if ($file->status) {
+ return file_file_download($uri, 'image');
+ }
+ }
+}
+
+/**
+ * Implements hook_file_move().
+ */
+function image_file_move($file, $source) {
+ // Delete any image derivatives at the original image path.
+ image_path_flush($file->uri);
+}
+
+/**
+ * Implements hook_file_delete().
+ */
+function image_file_delete($file) {
+ // Delete any image derivatives of this image.
+ image_path_flush($file->uri);
+}
+
+/**
+ * Implements hook_image_default_styles().
+ */
+function image_image_default_styles() {
+ $styles = array();
+
+ $styles['thumbnail'] = array(
+ 'effects' => array(
+ array(
+ 'name' => 'image_scale',
+ 'data' => array('width' => 100, 'height' => 100, 'upscale' => 1),
+ 'weight' => 0,
+ ),
+ )
+ );
+
+ $styles['medium'] = array(
+ 'effects' => array(
+ array(
+ 'name' => 'image_scale',
+ 'data' => array('width' => 220, 'height' => 220, 'upscale' => 1),
+ 'weight' => 0,
+ ),
+ )
+ );
+
+ $styles['large'] = array(
+ 'effects' => array(
+ array(
+ 'name' => 'image_scale',
+ 'data' => array('width' => 480, 'height' => 480, 'upscale' => 0),
+ 'weight' => 0,
+ ),
+ )
+ );
+
+ return $styles;
+}
+
+/**
+ * Implements hook_image_style_save().
+ */
+function image_image_style_save($style) {
+ if (isset($style['old_name']) && $style['old_name'] != $style['name']) {
+ $instances = field_read_instances();
+ // Loop through all fields searching for image fields.
+ foreach ($instances as $instance) {
+ if ($instance['widget']['module'] == 'image') {
+ $instance_changed = FALSE;
+ foreach ($instance['display'] as $view_mode => $display) {
+ // Check if the formatter involves an image style.
+ if ($display['type'] == 'image' && $display['settings']['image_style'] == $style['old_name']) {
+ // Update display information for any instance using the image
+ // style that was just deleted.
+ $instance['display'][$view_mode]['settings']['image_style'] = $style['name'];
+ $instance_changed = TRUE;
+ }
+ }
+ if ($instance['widget']['settings']['preview_image_style'] == $style['old_name']) {
+ $instance['widget']['settings']['preview_image_style'] = $style['name'];
+ $instance_changed = TRUE;
+ }
+ if ($instance_changed) {
+ field_update_instance($instance);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_image_style_delete().
+ */
+function image_image_style_delete($style) {
+ image_image_style_save($style);
+}
+
+/**
+ * Implements hook_field_delete_field().
+ */
+function image_field_delete_field($field) {
+ if ($field['type'] != 'image') {
+ return;
+ }
+
+ // The value of a managed_file element can be an array if #extended == TRUE.
+ $fid = (is_array($field['settings']['default_image']) ? $field['settings']['default_image']['fid'] : $field['settings']['default_image']);
+ if ($fid && ($file = file_load($fid))) {
+ file_usage_delete($file, 'image', 'default_image', $field['id']);
+ }
+}
+
+/**
+ * Implements hook_field_update_field().
+ */
+function image_field_update_field($field, $prior_field, $has_data) {
+ if ($field['type'] != 'image') {
+ return;
+ }
+
+ // The value of a managed_file element can be an array if #extended == TRUE.
+ $fid_new = (is_array($field['settings']['default_image']) ? $field['settings']['default_image']['fid'] : $field['settings']['default_image']);
+ $fid_old = (is_array($prior_field['settings']['default_image']) ? $prior_field['settings']['default_image']['fid'] : $prior_field['settings']['default_image']);
+
+ $file_new = $fid_new ? file_load($fid_new) : FALSE;
+
+ if ($fid_new != $fid_old) {
+
+ // Is there a new file?
+ if ($file_new) {
+ $file_new->status = FILE_STATUS_PERMANENT;
+ file_save($file_new);
+ file_usage_add($file_new, 'image', 'default_image', $field['id']);
+ }
+
+ // Is there an old file?
+ if ($fid_old && ($file_old = file_load($fid_old))) {
+ file_usage_delete($file_old, 'image', 'default_image', $field['id']);
+ }
+ }
+
+ // If the upload destination changed, then move the file.
+ if ($file_new && (file_uri_scheme($file_new->uri) != $field['settings']['uri_scheme'])) {
+ $directory = $field['settings']['uri_scheme'] . '://default_images/';
+ file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
+ file_move($file_new, $directory . $file_new->filename);
+ }
+}
+
+/**
+ * Clear cached versions of a specific file in all styles.
+ *
+ * @param $path
+ * The Drupal file path to the original image.
+ */
+function image_path_flush($path) {
+ $styles = image_styles();
+ foreach ($styles as $style) {
+ $image_path = image_style_path($style['name'], $path);
+ if (file_exists($image_path)) {
+ file_unmanaged_delete($image_path);
+ }
+ }
+}
+
+/**
+ * Get an array of all styles and their settings.
+ *
+ * @return
+ * An array of styles keyed by the image style ID (isid).
+ * @see image_style_load()
+ */
+function image_styles() {
+ $styles = &drupal_static(__FUNCTION__);
+
+ // Grab from cache or build the array.
+ if (!isset($styles)) {
+ if ($cache = cache()->get('image_styles')) {
+ $styles = $cache->data;
+ }
+ else {
+ $styles = array();
+
+ // Select the module-defined styles.
+ foreach (module_implements('image_default_styles') as $module) {
+ $module_styles = module_invoke($module, 'image_default_styles');
+ foreach ($module_styles as $style_name => $style) {
+ $style['name'] = $style_name;
+ $style['module'] = $module;
+ $style['storage'] = IMAGE_STORAGE_DEFAULT;
+ foreach ($style['effects'] as $key => $effect) {
+ $definition = image_effect_definition_load($effect['name']);
+ $effect = array_merge($definition, $effect);
+ $style['effects'][$key] = $effect;
+ }
+ $styles[$style_name] = $style;
+ }
+ }
+
+ // Select all the user-defined styles.
+ $user_styles = db_select('image_styles', NULL, array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('image_styles')
+ ->orderBy('name')
+ ->execute()
+ ->fetchAllAssoc('name', PDO::FETCH_ASSOC);
+
+ // Allow the user styles to override the module styles.
+ foreach ($user_styles as $style_name => $style) {
+ $style['module'] = NULL;
+ $style['storage'] = IMAGE_STORAGE_NORMAL;
+ $style['effects'] = image_style_effects($style);
+ if (isset($styles[$style_name]['module'])) {
+ $style['module'] = $styles[$style_name]['module'];
+ $style['storage'] = IMAGE_STORAGE_OVERRIDE;
+ }
+ $styles[$style_name] = $style;
+ }
+
+ drupal_alter('image_styles', $styles);
+ cache()->set('image_styles', $styles);
+ }
+ }
+
+ return $styles;
+}
+
+/**
+ * Load a style by style name or ID. May be used as a loader for menu items.
+ *
+ * @param $name
+ * The name of the style.
+ * @param $isid
+ * Optional. The numeric id of a style if the name is not known.
+ * @param $include
+ * If set, this loader will restrict to a specific type of image style, may be
+ * one of the defined Image style storage constants.
+ * @return
+ * An image style array containing the following keys:
+ * - "isid": The unique image style ID.
+ * - "name": The unique image style name.
+ * - "effects": An array of image effects within this image style.
+ * If the image style name or ID is not valid, an empty array is returned.
+ * @see image_effect_load()
+ */
+function image_style_load($name = NULL, $isid = NULL, $include = NULL) {
+ $styles = image_styles();
+
+ // If retrieving by name.
+ if (isset($name) && isset($styles[$name])) {
+ $style = $styles[$name];
+ }
+
+ // If retrieving by image style id.
+ if (!isset($name) && isset($isid)) {
+ foreach ($styles as $name => $database_style) {
+ if (isset($database_style['isid']) && $database_style['isid'] == $isid) {
+ $style = $database_style;
+ break;
+ }
+ }
+ }
+
+ // Restrict to the specific type of flag. This bitwise operation basically
+ // states "if the storage is X, then allow".
+ if (isset($style) && (!isset($include) || ($style['storage'] & (int) $include))) {
+ return $style;
+ }
+
+ // Otherwise the style was not found.
+ return FALSE;
+}
+
+/**
+ * Save an image style.
+ *
+ * @param style
+ * An image style array.
+ * @return
+ * An image style array. In the case of a new style, 'isid' will be populated.
+ */
+function image_style_save($style) {
+ if (isset($style['isid']) && is_numeric($style['isid'])) {
+ // Load the existing style to make sure we account for renamed styles.
+ $old_style = image_style_load(NULL, $style['isid']);
+ image_style_flush($old_style);
+ drupal_write_record('image_styles', $style, 'isid');
+ if ($old_style['name'] != $style['name']) {
+ $style['old_name'] = $old_style['name'];
+ }
+ }
+ else {
+ drupal_write_record('image_styles', $style);
+ $style['is_new'] = TRUE;
+ }
+
+ // Let other modules update as necessary on save.
+ module_invoke_all('image_style_save', $style);
+
+ // Clear all caches and flush.
+ image_style_flush($style);
+
+ return $style;
+}
+
+/**
+ * Delete an image style.
+ *
+ * @param $style
+ * An image style array.
+ * @param $replacement_style_name
+ * (optional) When deleting a style, specify a replacement style name so
+ * that existing settings (if any) may be converted to a new style.
+ * @return
+ * TRUE on success.
+ */
+function image_style_delete($style, $replacement_style_name = '') {
+ image_style_flush($style);
+
+ db_delete('image_effects')->condition('isid', $style['isid'])->execute();
+ db_delete('image_styles')->condition('isid', $style['isid'])->execute();
+
+ // Let other modules update as necessary on save.
+ $style['old_name'] = $style['name'];
+ $style['name'] = $replacement_style_name;
+ module_invoke_all('image_style_delete', $style);
+
+ return TRUE;
+}
+
+/**
+ * Load all the effects for an image style.
+ *
+ * @param $style
+ * An image style array.
+ * @return
+ * An array of image effects associated with specified image style in the
+ * format array('isid' => array()), or an empty array if the specified style
+ * has no effects.
+ */
+function image_style_effects($style) {
+ $effects = image_effects();
+ $style_effects = array();
+ foreach ($effects as $effect) {
+ if ($style['isid'] == $effect['isid']) {
+ $style_effects[$effect['ieid']] = $effect;
+ }
+ }
+
+ return $style_effects;
+}
+
+/**
+ * Get an array of image styles suitable for using as select list options.
+ *
+ * @param $include_empty
+ * If TRUE a <none> option will be inserted in the options array.
+ * @return
+ * Array of image styles both key and value are set to style name.
+ */
+function image_style_options($include_empty = TRUE) {
+ $styles = image_styles();
+ $options = array();
+ if ($include_empty && !empty($styles)) {
+ $options[''] = t('<none>');
+ }
+ $options = array_merge($options, drupal_map_assoc(array_keys($styles)));
+ if (empty($options)) {
+ $options[''] = t('No defined styles');
+ }
+ return $options;
+}
+
+/**
+ * Menu callback; Given a style and image path, generate a derivative.
+ *
+ * After generating an image, transfer it to the requesting agent.
+ *
+ * @param $style
+ * The image style
+ */
+function image_style_deliver($style, $scheme) {
+ // Check that the style is defined and the scheme is valid.
+ if (!$style || !file_stream_wrapper_valid_scheme($scheme)) {
+ drupal_exit();
+ }
+
+ $args = func_get_args();
+ array_shift($args);
+ array_shift($args);
+ $target = implode('/', $args);
+
+ $image_uri = $scheme . '://' . $target;
+ $derivative_uri = image_style_path($style['name'], $image_uri);
+
+ // If using the private scheme, let other modules provide headers and
+ // control access to the file.
+ if ($scheme == 'private') {
+ if (file_exists($derivative_uri)) {
+ file_download($scheme, file_uri_target($derivative_uri));
+ }
+ else {
+ $headers = module_invoke_all('file_download', $image_uri);
+ if (in_array(-1, $headers) || empty($headers)) {
+ return drupal_access_denied();
+ }
+ if (count($headers)) {
+ foreach ($headers as $name => $value) {
+ drupal_add_http_header($name, $value);
+ }
+ }
+ }
+ }
+
+ // Don't start generating the image if the derivative already exists or if
+ // generation is in progress in another thread.
+ $lock_name = 'image_style_deliver:' . $style['name'] . ':' . drupal_hash_base64($image_uri);
+ if (!file_exists($derivative_uri)) {
+ $lock_acquired = lock_acquire($lock_name);
+ if (!$lock_acquired) {
+ // Tell client to retry again in 3 seconds. Currently no browsers are known
+ // to support Retry-After.
+ drupal_add_http_header('Status', '503 Service Unavailable');
+ drupal_add_http_header('Retry-After', 3);
+ print t('Image generation in progress. Try again shortly.');
+ drupal_exit();
+ }
+ }
+
+ // Try to generate the image, unless another thread just did it while we were
+ // acquiring the lock.
+ $success = file_exists($derivative_uri) || image_style_create_derivative($style, $image_uri, $derivative_uri);
+
+ if (!empty($lock_acquired)) {
+ lock_release($lock_name);
+ }
+
+ if ($success) {
+ $image = image_load($derivative_uri);
+ file_transfer($image->source, array('Content-Type' => $image->info['mime_type'], 'Content-Length' => $image->info['file_size']));
+ }
+ else {
+ watchdog('image', 'Unable to generate the derived image located at %path.', array('%path' => $derivative_uri));
+ drupal_add_http_header('Status', '500 Internal Server Error');
+ print t('Error generating image.');
+ drupal_exit();
+ }
+}
+
+/**
+ * Creates a new image derivative based on an image style.
+ *
+ * Generates an image derivative by creating the destination folder (if it does
+ * not already exist), applying all image effects defined in $style['effects'],
+ * and saving a cached version of the resulting image.
+ *
+ * @param $style
+ * An image style array.
+ * @param $source
+ * Path of the source file.
+ * @param $destination
+ * Path or URI of the destination file.
+ *
+ * @return
+ * TRUE if an image derivative was generated, or FALSE if the image derivative
+ * could not be generated.
+ *
+ * @see image_style_load()
+ */
+function image_style_create_derivative($style, $source, $destination) {
+ // Get the folder for the final location of this style.
+ $directory = drupal_dirname($destination);
+
+ // Build the destination folder tree if it doesn't already exist.
+ if (!file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
+ watchdog('image', 'Failed to create style directory: %directory', array('%directory' => $directory), WATCHDOG_ERROR);
+ return FALSE;
+ }
+
+ if (!$image = image_load($source)) {
+ return FALSE;
+ }
+
+ foreach ($style['effects'] as $effect) {
+ image_effect_apply($image, $effect);
+ }
+
+ if (!image_save($image, $destination)) {
+ if (file_exists($destination)) {
+ watchdog('image', 'Cached image file %destination already exists. There may be an issue with your rewrite configuration.', array('%destination' => $destination), WATCHDOG_ERROR);
+ }
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Determines the dimensions of the styled image.
+ *
+ * Applies all of an image style's effects to $dimensions.
+ *
+ * @param $style_name
+ * The name of the style to be applied.
+ * @param $dimensions
+ * Dimensions to be modified - an array with components width and height, in
+ * pixels.
+ */
+function image_style_transform_dimensions($style_name, array &$dimensions) {
+ module_load_include('inc', 'image', 'image.effects');
+ $style = image_style_load($style_name);
+
+ if (!is_array($style)) {
+ return;
+ }
+
+ foreach ($style['effects'] as $effect) {
+ if (isset($effect['dimensions passthrough'])) {
+ continue;
+ }
+
+ if (isset($effect['dimensions callback'])) {
+ $effect['dimensions callback']($dimensions, $effect['data']);
+ }
+ else {
+ $dimensions['width'] = $dimensions['height'] = NULL;
+ }
+ }
+}
+
+/**
+ * Flush cached media for a style.
+ *
+ * @param $style
+ * An image style array.
+ */
+function image_style_flush($style) {
+ $style_directory = drupal_realpath(file_default_scheme() . '://styles/' . $style['name']);
+ if (is_dir($style_directory)) {
+ file_unmanaged_delete_recursive($style_directory);
+ }
+
+ // Let other modules update as necessary on flush.
+ module_invoke_all('image_style_flush', $style);
+
+ // Clear image style and effect caches.
+ cache()->delete('image_styles');
+ cache()->deletePrefix('image_effects:');
+ drupal_static_reset('image_styles');
+ drupal_static_reset('image_effects');
+
+ // Clear field caches so that formatters may be added for this style.
+ field_info_cache_clear();
+ drupal_theme_rebuild();
+
+ // Clear page caches when flushing.
+ if (module_exists('block')) {
+ cache('block')->flush();
+ }
+ cache('page')->flush();
+}
+
+/**
+ * Return the URL for an image derivative given a style and image path.
+ *
+ * @param $style_name
+ * The name of the style to be used with this image.
+ * @param $path
+ * The path to the image.
+ * @return
+ * The absolute URL where a style image can be downloaded, suitable for use
+ * in an <img> tag. Requesting the URL will cause the image to be created.
+ * @see image_style_deliver()
+ */
+function image_style_url($style_name, $path) {
+ $uri = image_style_path($style_name, $path);
+
+ // If not using clean URLs, the image derivative callback is only available
+ // with the query string. If the file does not exist, use url() to ensure
+ // that it is included. Once the file exists it's fine to fall back to the
+ // actual file path, this avoids bootstrapping PHP once the files are built.
+ if (!variable_get('clean_url') && file_uri_scheme($uri) == 'public' && !file_exists($uri)) {
+ $directory_path = file_stream_wrapper_get_instance_by_uri($uri)->getDirectoryPath();
+ return url($directory_path . '/' . file_uri_target($uri), array('absolute' => TRUE));
+ }
+
+ return file_create_url($uri);
+}
+
+/**
+ * Return the URI of an image when using a style.
+ *
+ * The path returned by this function may not exist. The default generation
+ * method only creates images when they are requested by a user's browser.
+ *
+ * @param $style_name
+ * The name of the style to be used with this image.
+ * @param $uri
+ * The URI or path to the image.
+ * @return
+ * The URI to an image style image.
+ * @see image_style_url()
+ */
+function image_style_path($style_name, $uri) {
+ $scheme = file_uri_scheme($uri);
+ if ($scheme) {
+ $path = file_uri_target($uri);
+ }
+ else {
+ $path = $uri;
+ $scheme = file_default_scheme();
+ }
+ return $scheme . '://styles/' . $style_name . '/' . $scheme . '/' . $path;
+}
+
+/**
+ * Save a default image style to the database.
+ *
+ * @param style
+ * An image style array provided by a module.
+ * @return
+ * An image style array. The returned style array will include the new 'isid'
+ * assigned to the style.
+ */
+function image_default_style_save($style) {
+ $style = image_style_save($style);
+ $effects = array();
+ foreach ($style['effects'] as $effect) {
+ $effect['isid'] = $style['isid'];
+ $effect = image_effect_save($effect);
+ $effects[$effect['ieid']] = $effect;
+ }
+ $style['effects'] = $effects;
+ return $style;
+}
+
+/**
+ * Revert the changes made by users to a default image style.
+ *
+ * @param style
+ * An image style array.
+ * @return
+ * Boolean TRUE if the operation succeeded.
+ */
+function image_default_style_revert($style) {
+ image_style_flush($style);
+
+ db_delete('image_effects')->condition('isid', $style['isid'])->execute();
+ db_delete('image_styles')->condition('isid', $style['isid'])->execute();
+
+ return TRUE;
+}
+
+/**
+ * Pull in image effects exposed by modules implementing hook_image_effect_info().
+ *
+ * @return
+ * An array of image effects to be used when transforming images.
+ * @see hook_image_effect_info()
+ * @see image_effect_definition_load()
+ */
+function image_effect_definitions() {
+ global $language;
+
+ // hook_image_effect_info() includes translated strings, so each language is
+ // cached separately.
+ $langcode = $language->language;
+
+ $effects = &drupal_static(__FUNCTION__);
+
+ if (!isset($effects)) {
+ if ($cache = cache()->get("image_effects:$langcode") && !empty($cache->data)) {
+ $effects = $cache->data;
+ }
+ else {
+ $effects = array();
+ include_once DRUPAL_ROOT . '/core/modules/image/image.effects.inc';
+ foreach (module_implements('image_effect_info') as $module) {
+ foreach (module_invoke($module, 'image_effect_info') as $name => $effect) {
+ // Ensure the current toolkit supports the effect.
+ $effect['module'] = $module;
+ $effect['name'] = $name;
+ $effect['data'] = isset($effect['data']) ? $effect['data'] : array();
+ $effects[$name] = $effect;
+ }
+ }
+ uasort($effects, '_image_effect_definitions_sort');
+ drupal_alter('image_effect_info', $effects);
+ cache()->set("image_effects:$langcode", $effects);
+ }
+ }
+
+ return $effects;
+}
+
+/**
+ * Load the definition for an image effect.
+ *
+ * The effect definition is a set of core properties for an image effect, not
+ * containing any user-settings. The definition defines various functions to
+ * call when configuring or executing an image effect. This loader is mostly for
+ * internal use within image.module. Use image_effect_load() or
+ * image_style_load() to get image effects that contain configuration.
+ *
+ * @param $effect
+ * The name of the effect definition to load.
+ * @param $style
+ * An image style array to which this effect will be added.
+ * @return
+ * An array containing the image effect definition with the following keys:
+ * - "effect": The unique name for the effect being performed. Usually prefixed
+ * with the name of the module providing the effect.
+ * - "module": The module providing the effect.
+ * - "help": A description of the effect.
+ * - "function": The name of the function that will execute the effect.
+ * - "form": (optional) The name of a function to configure the effect.
+ * - "summary": (optional) The name of a theme function that will display a
+ * one-line summary of the effect. Does not include the "theme_" prefix.
+ */
+function image_effect_definition_load($effect, $style_name = NULL) {
+ $definitions = image_effect_definitions();
+
+ // If a style is specified, do not allow loading of default style
+ // effects.
+ if (isset($style_name)) {
+ $style = image_style_load($style_name, NULL);
+ if ($style['storage'] == IMAGE_STORAGE_DEFAULT) {
+ return FALSE;
+ }
+ }
+
+ return isset($definitions[$effect]) ? $definitions[$effect] : FALSE;
+}
+
+/**
+ * Load all image effects from the database.
+ *
+ * @return
+ * An array of all image effects.
+ * @see image_effect_load()
+ */
+function image_effects() {
+ $effects = &drupal_static(__FUNCTION__);
+
+ if (!isset($effects)) {
+ $effects = array();
+
+ // Add database image effects.
+ $result = db_select('image_effects', NULL, array('fetch' => PDO::FETCH_ASSOC))
+ ->fields('image_effects')
+ ->orderBy('image_effects.weight', 'ASC')
+ ->execute();
+ foreach ($result as $effect) {
+ $effect['data'] = unserialize($effect['data']);
+ $definition = image_effect_definition_load($effect['name']);
+ // Do not load image effects whose definition cannot be found.
+ if ($definition) {
+ $effect = array_merge($definition, $effect);
+ $effects[$effect['ieid']] = $effect;
+ }
+ }
+ }
+
+ return $effects;
+}
+
+/**
+ * Load a single image effect.
+ *
+ * @param $ieid
+ * The image effect ID.
+ * @param $style_name
+ * The image style name.
+ * @param $include
+ * If set, this loader will restrict to a specific type of image style, may be
+ * one of the defined Image style storage constants.
+ * @return
+ * An image effect array, consisting of the following keys:
+ * - "ieid": The unique image effect ID.
+ * - "isid": The unique image style ID that contains this image effect.
+ * - "weight": The weight of this image effect within the image style.
+ * - "name": The name of the effect definition that powers this image effect.
+ * - "data": An array of configuration options for this image effect.
+ * Besides these keys, the entirety of the image definition is merged into
+ * the image effect array. Returns FALSE if the specified effect cannot be
+ * found.
+ * @see image_style_load()
+ * @see image_effect_definition_load()
+ */
+function image_effect_load($ieid, $style_name, $include = NULL) {
+ if (($style = image_style_load($style_name, NULL, $include)) && isset($style['effects'][$ieid])) {
+ return $style['effects'][$ieid];
+ }
+ return FALSE;
+}
+
+/**
+ * Save an image effect.
+ *
+ * @param $effect
+ * An image effect array.
+ * @return
+ * An image effect array. In the case of a new effect, 'ieid' will be set.
+ */
+function image_effect_save($effect) {
+ if (!empty($effect['ieid'])) {
+ drupal_write_record('image_effects', $effect, 'ieid');
+ }
+ else {
+ drupal_write_record('image_effects', $effect);
+ }
+ $style = image_style_load(NULL, $effect['isid']);
+ image_style_flush($style);
+ return $effect;
+}
+
+/**
+ * Delete an image effect.
+ *
+ * @param $effect
+ * An image effect array.
+ */
+function image_effect_delete($effect) {
+ db_delete('image_effects')->condition('ieid', $effect['ieid'])->execute();
+ $style = image_style_load(NULL, $effect['isid']);
+ image_style_flush($style);
+}
+
+/**
+ * Given an image object and effect, perform the effect on the file.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $effect
+ * An image effect array.
+ * @return
+ * TRUE on success. FALSE if unable to perform the image effect on the image.
+ */
+function image_effect_apply($image, $effect) {
+ module_load_include('inc', 'image', 'image.effects');
+ $function = $effect['effect callback'];
+ if (function_exists($function)) {
+ return $function($image, $effect['data']);
+ }
+ return FALSE;
+}
+
+/**
+ * Returns HTML for an image using a specific image style.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - style_name: The name of the style to be used to alter the original image.
+ * - path: The path of the image file relative to the Drupal files directory.
+ * This function does not work with images outside the files directory nor
+ * with remotely hosted images.
+ * - width: The width of the source image (if known).
+ * - height: The height of the source image (if known).
+ * - alt: The alternative text for text-based browsers.
+ * - title: The title text is displayed when the image is hovered in some
+ * popular browsers.
+ * - attributes: Associative array of attributes to be placed in the img tag.
+ *
+ * @ingroup themeable
+ */
+function theme_image_style($variables) {
+ // Determine the dimensions of the styled image.
+ $dimensions = array(
+ 'width' => $variables['width'],
+ 'height' => $variables['height'],
+ );
+
+ image_style_transform_dimensions($variables['style_name'], $dimensions);
+
+ $variables['width'] = $dimensions['width'];
+ $variables['height'] = $dimensions['height'];
+
+ // Determine the url for the styled image.
+ $variables['path'] = image_style_url($variables['style_name'], $variables['path']);
+ return theme('image', $variables);
+}
+
+/**
+ * Accept a keyword (center, top, left, etc) and return it as a pixel offset.
+ *
+ * @param $value
+ * @param $current_pixels
+ * @param $new_pixels
+ */
+function image_filter_keyword($value, $current_pixels, $new_pixels) {
+ switch ($value) {
+ case 'top':
+ case 'left':
+ return 0;
+
+ case 'bottom':
+ case 'right':
+ return $current_pixels - $new_pixels;
+
+ case 'center':
+ return $current_pixels / 2 - $new_pixels / 2;
+ }
+ return $value;
+}
+
+/**
+ * Internal function for sorting image effect definitions through uasort().
+ *
+ * @see image_effect_definitions()
+ */
+function _image_effect_definitions_sort($a, $b) {
+ return strcasecmp($a['name'], $b['name']);
+}
diff --git a/core/modules/image/image.test b/core/modules/image/image.test
new file mode 100644
index 000000000000..a29b4f3a1d7f
--- /dev/null
+++ b/core/modules/image/image.test
@@ -0,0 +1,1131 @@
+<?php
+
+/**
+ * @file
+ * Tests for image.module.
+ */
+
+/**
+ * TODO: Test the following functions.
+ *
+ * image.effects.inc:
+ * image_style_generate()
+ * image_style_create_derivative()
+ *
+ * image.module:
+ * image_style_load()
+ * image_style_save()
+ * image_style_delete()
+ * image_style_options()
+ * image_style_flush()
+ * image_effect_definition_load()
+ * image_effect_load()
+ * image_effect_save()
+ * image_effect_delete()
+ * image_filter_keyword()
+ */
+
+/**
+ * This class provides methods specifically for testing Image's field handling.
+ */
+class ImageFieldTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ function setUp() {
+ parent::setUp('image');
+ $this->admin_user = $this->drupalCreateUser(array('access content', 'access administration pages', 'administer site configuration', 'administer content types', 'administer nodes', 'create article content', 'edit any article content', 'delete any article content', 'administer image styles'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Create a new image field.
+ *
+ * @param $name
+ * The name of the new field (all lowercase), exclude the "field_" prefix.
+ * @param $type_name
+ * The node type that this field will be added to.
+ * @param $field_settings
+ * A list of field settings that will be added to the defaults.
+ * @param $instance_settings
+ * A list of instance settings that will be added to the instance defaults.
+ * @param $widget_settings
+ * A list of widget settings that will be added to the widget defaults.
+ */
+ function createImageField($name, $type_name, $field_settings = array(), $instance_settings = array(), $widget_settings = array()) {
+ $field = array(
+ 'field_name' => $name,
+ 'type' => 'image',
+ 'settings' => array(),
+ 'cardinality' => !empty($field_settings['cardinality']) ? $field_settings['cardinality'] : 1,
+ );
+ $field['settings'] = array_merge($field['settings'], $field_settings);
+ field_create_field($field);
+
+ $instance = array(
+ 'field_name' => $field['field_name'],
+ 'entity_type' => 'node',
+ 'label' => $name,
+ 'bundle' => $type_name,
+ 'required' => !empty($instance_settings['required']),
+ 'settings' => array(),
+ 'widget' => array(
+ 'type' => 'image_image',
+ 'settings' => array(),
+ ),
+ );
+ $instance['settings'] = array_merge($instance['settings'], $instance_settings);
+ $instance['widget']['settings'] = array_merge($instance['widget']['settings'], $widget_settings);
+ return field_create_instance($instance);
+ }
+
+ /**
+ * Upload an image to a node.
+ *
+ * @param $image
+ * A file object representing the image to upload.
+ * @param $field_name
+ * Name of the image field the image should be attached to.
+ * @param $type
+ * The type of node to create.
+ */
+ function uploadNodeImage($image, $field_name, $type) {
+ $edit = array(
+ 'title' => $this->randomName(),
+ );
+ $edit['files[' . $field_name . '_' . LANGUAGE_NONE . '_0]'] = drupal_realpath($image->uri);
+ $this->drupalPost('node/add/' . $type, $edit, t('Save'));
+
+ // Retrieve ID of the newly created node from the current URL.
+ $matches = array();
+ preg_match('/node\/([0-9]+)/', $this->getUrl(), $matches);
+ return isset($matches[1]) ? $matches[1] : FALSE;
+ }
+}
+
+/**
+ * Tests the functions for generating paths and URLs for image styles.
+ */
+class ImageStylesPathAndUrlUnitTest extends DrupalWebTestCase {
+ protected $style_name;
+ protected $image_info;
+ protected $image_filepath;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image styles path and URL functions',
+ 'description' => 'Tests functions for generating paths and URLs to image styles.',
+ 'group' => 'Image',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('image_module_test');
+
+ $this->style_name = 'style_foo';
+ image_style_save(array('name' => $this->style_name));
+ }
+
+ /**
+ * Test image_style_path().
+ */
+ function testImageStylePath() {
+ $scheme = 'public';
+ $actual = image_style_path($this->style_name, "$scheme://foo/bar.gif");
+ $expected = "$scheme://styles/" . $this->style_name . "/$scheme/foo/bar.gif";
+ $this->assertEqual($actual, $expected, t('Got the path for a file URI.'));
+
+ $actual = image_style_path($this->style_name, 'foo/bar.gif');
+ $expected = "$scheme://styles/" . $this->style_name . "/$scheme/foo/bar.gif";
+ $this->assertEqual($actual, $expected, t('Got the path for a relative file path.'));
+ }
+
+ /**
+ * Test image_style_url() with a file using the "public://" scheme.
+ */
+ function testImageStyleUrlAndPathPublic() {
+ $this->_testImageStyleUrlAndPath('public');
+ }
+
+ /**
+ * Test image_style_url() with a file using the "private://" scheme.
+ */
+ function testImageStyleUrlAndPathPrivate() {
+ $this->_testImageStyleUrlAndPath('private');
+ }
+
+ /**
+ * Test image_style_url() with the "public://" scheme and unclean URLs.
+ */
+ function testImageStylUrlAndPathPublicUnclean() {
+ $this->_testImageStyleUrlAndPath('public', FALSE);
+ }
+
+ /**
+ * Test image_style_url() with the "private://" schema and unclean URLs.
+ */
+ function testImageStyleUrlAndPathPrivateUnclean() {
+ $this->_testImageStyleUrlAndPath('private', FALSE);
+ }
+
+ /**
+ * Test image_style_url().
+ */
+ function _testImageStyleUrlAndPath($scheme, $clean_url = TRUE) {
+ // Make the default scheme neither "public" nor "private" to verify the
+ // functions work for other than the default scheme.
+ variable_set('file_default_scheme', 'temporary');
+ variable_set('clean_url', $clean_url);
+
+ // Create the directories for the styles.
+ $directory = $scheme . '://styles/' . $this->style_name;
+ $status = file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
+ $this->assertNotIdentical(FALSE, $status, t('Created the directory for the generated images for the test style.'));
+
+ // Create a working copy of the file.
+ $files = $this->drupalGetTestFiles('image');
+ $file = reset($files);
+ $image_info = image_get_info($file->uri);
+ $original_uri = file_unmanaged_copy($file->uri, $scheme . '://', FILE_EXISTS_RENAME);
+ // Let the image_module_test module know about this file, so it can claim
+ // ownership in hook_file_download().
+ variable_set('image_module_test_file_download', $original_uri);
+ $this->assertNotIdentical(FALSE, $original_uri, t('Created the generated image file.'));
+
+ // Get the URL of a file that has not been generated and try to create it.
+ $generated_uri = $scheme . '://styles/' . $this->style_name . '/' . $scheme . '/'. basename($original_uri);
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $generate_url = image_style_url($this->style_name, $original_uri);
+
+ if (!$clean_url) {
+ $this->assertTrue(strpos($generate_url, '?q=') !== FALSE, 'When using non-clean URLS, the system path contains the query string.');
+ }
+
+ // Fetch the URL that generates the file.
+ $this->drupalGet($generate_url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $this->assertRaw(file_get_contents($generated_uri), t('URL returns expected file.'));
+ $generated_image_info = image_get_info($generated_uri);
+ $this->assertEqual($this->drupalGetHeader('Content-Type'), $generated_image_info['mime_type'], t('Expected Content-Type was reported.'));
+ $this->assertEqual($this->drupalGetHeader('Content-Length'), $generated_image_info['file_size'], t('Expected Content-Length was reported.'));
+ if ($scheme == 'private') {
+ $this->assertEqual($this->drupalGetHeader('X-Image-Owned-By'), 'image_module_test', t('Expected custom header has been added.'));
+ }
+ }
+}
+
+/**
+ * Use the image_test.module's mock toolkit to ensure that the effects are
+ * properly passing parameters to the image toolkit.
+ */
+class ImageEffectsUnitTest extends ImageToolkitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image effects',
+ 'description' => 'Test that the image effects pass parameters to the toolkit correctly.',
+ 'group' => 'Image',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('image_test');
+ module_load_include('inc', 'image', 'image.effects');
+ }
+
+ /**
+ * Test the image_resize_effect() function.
+ */
+ function testResizeEffect() {
+ $this->assertTrue(image_resize_effect($this->image, array('width' => 1, 'height' => 2)), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['resize'][0][1], 1, t('Width was passed correctly'));
+ $this->assertEqual($calls['resize'][0][2], 2, t('Height was passed correctly'));
+ }
+
+ /**
+ * Test the image_scale_effect() function.
+ */
+ function testScaleEffect() {
+ // @todo: need to test upscaling.
+ $this->assertTrue(image_scale_effect($this->image, array('width' => 10, 'height' => 10)), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['resize'][0][1], 10, t('Width was passed correctly'));
+ $this->assertEqual($calls['resize'][0][2], 5, t('Height was based off aspect ratio and passed correctly'));
+ }
+
+ /**
+ * Test the image_crop_effect() function.
+ */
+ function testCropEffect() {
+ // @todo should test the keyword offsets.
+ $this->assertTrue(image_crop_effect($this->image, array('anchor' => 'top-1', 'width' => 3, 'height' => 4)), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('crop'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['crop'][0][1], 0, t('X was passed correctly'));
+ $this->assertEqual($calls['crop'][0][2], 1, t('Y was passed correctly'));
+ $this->assertEqual($calls['crop'][0][3], 3, t('Width was passed correctly'));
+ $this->assertEqual($calls['crop'][0][4], 4, t('Height was passed correctly'));
+ }
+
+ /**
+ * Test the image_scale_and_crop_effect() function.
+ */
+ function testScaleAndCropEffect() {
+ $this->assertTrue(image_scale_and_crop_effect($this->image, array('width' => 5, 'height' => 10)), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize', 'crop'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['crop'][0][1], 7.5, t('X was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][2], 0, t('Y was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][3], 5, t('Width was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][4], 10, t('Height was computed and passed correctly'));
+ }
+
+ /**
+ * Test the image_desaturate_effect() function.
+ */
+ function testDesaturateEffect() {
+ $this->assertTrue(image_desaturate_effect($this->image, array()), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('desaturate'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual(count($calls['desaturate'][0]), 1, t('Only the image was passed.'));
+ }
+
+ /**
+ * Test the image_rotate_effect() function.
+ */
+ function testRotateEffect() {
+ // @todo: need to test with 'random' => TRUE
+ $this->assertTrue(image_rotate_effect($this->image, array('degrees' => 90, 'bgcolor' => '#fff')), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('rotate'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['rotate'][0][1], 90, t('Degrees were passed correctly'));
+ $this->assertEqual($calls['rotate'][0][2], 0xffffff, t('Background color was passed correctly'));
+ }
+}
+
+/**
+ * Tests creation, deletion, and editing of image styles and effects.
+ */
+class ImageAdminStylesUnitTest extends ImageFieldTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image styles and effects UI configuration',
+ 'description' => 'Tests creation, deletion, and editing of image styles and effects at the UI level.',
+ 'group' => 'Image',
+ );
+ }
+
+ /**
+ * Given an image style, generate an image.
+ */
+ function createSampleImage($style) {
+ static $file_path;
+
+ // First, we need to make sure we have an image in our testing
+ // file directory. Copy over an image on the first run.
+ if (!isset($file_path)) {
+ $files = $this->drupalGetTestFiles('image');
+ $file = reset($files);
+ $file_path = file_unmanaged_copy($file->uri);
+ }
+
+ return image_style_url($style['name'], $file_path) ? $file_path : FALSE;
+ }
+
+ /**
+ * Count the number of images currently create for a style.
+ */
+ function getImageCount($style) {
+ return count(file_scan_directory('public://styles/' . $style['name'], '/.*/'));
+ }
+
+ /**
+ * General test to add a style, add/remove/edit effects to it, then delete it.
+ */
+ function testStyle() {
+ // Setup a style to be created and effects to add to it.
+ $style_name = strtolower($this->randomName(10));
+ $style_path = 'admin/config/media/image-styles/edit/' . $style_name;
+ $effect_edits = array(
+ 'image_resize' => array(
+ 'data[width]' => 100,
+ 'data[height]' => 101,
+ ),
+ 'image_scale' => array(
+ 'data[width]' => 110,
+ 'data[height]' => 111,
+ 'data[upscale]' => 1,
+ ),
+ 'image_scale_and_crop' => array(
+ 'data[width]' => 120,
+ 'data[height]' => 121,
+ ),
+ 'image_crop' => array(
+ 'data[width]' => 130,
+ 'data[height]' => 131,
+ 'data[anchor]' => 'center-center',
+ ),
+ 'image_desaturate' => array(
+ // No options for desaturate.
+ ),
+ 'image_rotate' => array(
+ 'data[degrees]' => 5,
+ 'data[random]' => 1,
+ 'data[bgcolor]' => '#FFFF00',
+ ),
+ );
+
+ // Add style form.
+
+ $edit = array(
+ 'name' => $style_name,
+ );
+ $this->drupalPost('admin/config/media/image-styles/add', $edit, t('Create new style'));
+ $this->assertRaw(t('Style %name was created.', array('%name' => $style_name)), t('Image style successfully created.'));
+
+ // Add effect form.
+
+ // Add each sample effect to the style.
+ foreach ($effect_edits as $effect => $edit) {
+ // Add the effect.
+ $this->drupalPost($style_path, array('new' => $effect), t('Add'));
+ if (!empty($edit)) {
+ $this->drupalPost(NULL, $edit, t('Add effect'));
+ }
+ }
+
+ // Edit effect form.
+
+ // Revisit each form to make sure the effect was saved.
+ drupal_static_reset('image_styles');
+ $style = image_style_load($style_name);
+
+ foreach ($style['effects'] as $ieid => $effect) {
+ $this->drupalGet($style_path . '/effects/' . $ieid);
+ foreach ($effect_edits[$effect['name']] as $field => $value) {
+ $this->assertFieldByName($field, $value, t('The %field field in the %effect effect has the correct value of %value.', array('%field' => $field, '%effect' => $effect['name'], '%value' => $value)));
+ }
+ }
+
+ // Image style overview form (ordering and renaming).
+
+ // Confirm the order of effects is maintained according to the order we
+ // added the fields.
+ $effect_edits_order = array_keys($effect_edits);
+ $effects_order = array_values($style['effects']);
+ $order_correct = TRUE;
+ foreach ($effects_order as $index => $effect) {
+ if ($effect_edits_order[$index] != $effect['name']) {
+ $order_correct = FALSE;
+ }
+ }
+ $this->assertTrue($order_correct, t('The order of the effects is correctly set by default.'));
+
+ // Test the style overview form.
+ // Change the name of the style and adjust the weights of effects.
+ $style_name = strtolower($this->randomName(10));
+ $weight = count($effect_edits);
+ $edit = array(
+ 'name' => $style_name,
+ );
+ foreach ($style['effects'] as $ieid => $effect) {
+ $edit['effects[' . $ieid . '][weight]'] = $weight;
+ $weight--;
+ }
+
+ // Create an image to make sure it gets flushed after saving.
+ $image_path = $this->createSampleImage($style);
+ $this->assertEqual($this->getImageCount($style), 1, t('Image style %style image %file successfully generated.', array('%style' => $style['name'], '%file' => $image_path)));
+
+ $this->drupalPost($style_path, $edit, t('Update style'));
+
+ // Note that after changing the style name, the style path is changed.
+ $style_path = 'admin/config/media/image-styles/edit/' . $style_name;
+
+ // Check that the URL was updated.
+ $this->drupalGet($style_path);
+ $this->assertResponse(200, t('Image style %original renamed to %new', array('%original' => $style['name'], '%new' => $style_name)));
+
+ // Check that the image was flushed after updating the style.
+ // This is especially important when renaming the style. Make sure that
+ // the old image directory has been deleted.
+ $this->assertEqual($this->getImageCount($style), 0, t('Image style %style was flushed after renaming the style and updating the order of effects.', array('%style' => $style['name'])));
+
+ // Load the style by the new name with the new weights.
+ drupal_static_reset('image_styles');
+ $style = image_style_load($style_name, NULL);
+
+ // Confirm the new style order was saved.
+ $effect_edits_order = array_reverse($effect_edits_order);
+ $effects_order = array_values($style['effects']);
+ $order_correct = TRUE;
+ foreach ($effects_order as $index => $effect) {
+ if ($effect_edits_order[$index] != $effect['name']) {
+ $order_correct = FALSE;
+ }
+ }
+ $this->assertTrue($order_correct, t('The order of the effects is correctly set by default.'));
+
+ // Image effect deletion form.
+
+ // Create an image to make sure it gets flushed after deleting an effect.
+ $image_path = $this->createSampleImage($style);
+ $this->assertEqual($this->getImageCount($style), 1, t('Image style %style image %file successfully generated.', array('%style' => $style['name'], '%file' => $image_path)));
+
+ // Test effect deletion form.
+ $effect = array_pop($style['effects']);
+ $this->drupalPost($style_path . '/effects/' . $effect['ieid'] . '/delete', array(), t('Delete'));
+ $this->assertRaw(t('The image effect %name has been deleted.', array('%name' => $effect['label'])), t('Image effect deleted.'));
+
+ // Style deletion form.
+
+ // Delete the style.
+ $this->drupalPost('admin/config/media/image-styles/delete/' . $style_name, array(), t('Delete'));
+
+ // Confirm the style directory has been removed.
+ $directory = file_default_scheme() . '://styles/' . $style_name;
+ $this->assertFalse(is_dir($directory), t('Image style %style directory removed on style deletion.', array('%style' => $style['name'])));
+
+ drupal_static_reset('image_styles');
+ $this->assertFalse(image_style_load($style_name), t('Image style %style successfully deleted.', array('%style' => $style['name'])));
+
+ }
+
+ /**
+ * Test to override, edit, then revert a style.
+ */
+ function testDefaultStyle() {
+ // Setup a style to be created and effects to add to it.
+ $style_name = 'thumbnail';
+ $edit_path = 'admin/config/media/image-styles/edit/' . $style_name;
+ $delete_path = 'admin/config/media/image-styles/delete/' . $style_name;
+ $revert_path = 'admin/config/media/image-styles/revert/' . $style_name;
+
+ // Ensure deleting a default is not possible.
+ $this->drupalGet($delete_path);
+ $this->assertText(t('Page not found'), t('Default styles may not be deleted.'));
+
+ // Ensure that editing a default is not possible (without overriding).
+ $this->drupalGet($edit_path);
+ $this->assertNoField('edit-name', t('Default styles may not be renamed.'));
+ $this->assertNoField('edit-submit', t('Default styles may not be edited.'));
+ $this->assertNoField('edit-add', t('Default styles may not have new effects added.'));
+
+ // Create an image to make sure the default works before overriding.
+ drupal_static_reset('image_styles');
+ $style = image_style_load($style_name);
+ $image_path = $this->createSampleImage($style);
+ $this->assertEqual($this->getImageCount($style), 1, t('Image style %style image %file successfully generated.', array('%style' => $style['name'], '%file' => $image_path)));
+
+ // Verify that effects attached to a default style do not have an ieid key.
+ foreach ($style['effects'] as $effect) {
+ $this->assertFalse(isset($effect['ieid']), t('The %effect effect does not have an ieid.', array('%effect' => $effect['name'])));
+ }
+
+ // Override the default.
+ $this->drupalPost($edit_path, array(), t('Override defaults'));
+ $this->assertRaw(t('The %style style has been overridden, allowing you to change its settings.', array('%style' => $style_name)), t('Default image style may be overridden.'));
+
+ // Add sample effect to the overridden style.
+ $this->drupalPost($edit_path, array('new' => 'image_desaturate'), t('Add'));
+ drupal_static_reset('image_styles');
+ $style = image_style_load($style_name);
+
+ // Verify that effects attached to the style have an ieid now.
+ foreach ($style['effects'] as $effect) {
+ $this->assertTrue(isset($effect['ieid']), t('The %effect effect has an ieid.', array('%effect' => $effect['name'])));
+ }
+
+ // The style should now have 2 effect, the original scale provided by core
+ // and the desaturate effect we added in the override.
+ $effects = array_values($style['effects']);
+ $this->assertEqual($effects[0]['name'], 'image_scale', t('The default effect still exists in the overridden style.'));
+ $this->assertEqual($effects[1]['name'], 'image_desaturate', t('The added effect exists in the overridden style.'));
+
+ // Check that we are unable to rename an overridden style.
+ $this->drupalGet($edit_path);
+ $this->assertNoField('edit-name', t('Overridden styles may not be renamed.'));
+
+ // Create an image to ensure the override works properly.
+ $image_path = $this->createSampleImage($style);
+ $this->assertEqual($this->getImageCount($style), 1, t('Image style %style image %file successfully generated.', array('%style' => $style['name'], '%file' => $image_path)));
+
+ // Revert the image style.
+ $this->drupalPost($revert_path, array(), t('Revert'));
+ drupal_static_reset('image_styles');
+ $style = image_style_load($style_name);
+
+ // The style should now have the single effect for scale.
+ $effects = array_values($style['effects']);
+ $this->assertEqual($effects[0]['name'], 'image_scale', t('The default effect still exists in the reverted style.'));
+ $this->assertFalse(array_key_exists(1, $effects), t('The added effect has been removed in the reverted style.'));
+ }
+
+ /**
+ * Test deleting a style and choosing a replacement style.
+ */
+ function testStyleReplacement() {
+ // Create a new style.
+ $style_name = strtolower($this->randomName(10));
+ image_style_save(array('name' => $style_name));
+ $style_path = 'admin/config/media/image-styles/edit/' . $style_name;
+
+ // Create an image field that uses the new style.
+ $field_name = strtolower($this->randomName(10));
+ $instance = $this->createImageField($field_name, 'article');
+ $instance['display']['default']['type'] = 'image';
+ $instance['display']['default']['settings']['image_style'] = $style_name;
+ field_update_instance($instance);
+
+ // Create a new node with an image attached.
+ $test_image = current($this->drupalGetTestFiles('image'));
+ $nid = $this->uploadNodeImage($test_image, $field_name, 'article');
+ $node = node_load($nid);
+
+ // Test that image is displayed using newly created style.
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw(image_style_url($style_name, $node->{$field_name}[LANGUAGE_NONE][0]['uri']), t('Image displayed using style @style.', array('@style' => $style_name)));
+
+ // Rename the style and make sure the image field is updated.
+ $new_style_name = strtolower($this->randomName(10));
+ $edit = array(
+ 'name' => $new_style_name,
+ );
+ $this->drupalPost('admin/config/media/image-styles/edit/' . $style_name, $edit, t('Update style'));
+ $this->assertText(t('Changes to the style have been saved.'), t('Style %name was renamed to %new_name.', array('%name' => $style_name, '%new_name' => $new_style_name)));
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw(image_style_url($new_style_name, $node->{$field_name}[LANGUAGE_NONE][0]['uri']), t('Image displayed using style replacement style.'));
+
+ // Delete the style and choose a replacement style.
+ $edit = array(
+ 'replacement' => 'thumbnail',
+ );
+ $this->drupalPost('admin/config/media/image-styles/delete/' . $new_style_name, $edit, t('Delete'));
+ $message = t('Style %name was deleted.', array('%name' => $new_style_name));
+ $this->assertRaw($message, $message);
+
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw(image_style_url('thumbnail', $node->{$field_name}[LANGUAGE_NONE][0]['uri']), t('Image displayed using style replacement style.'));
+ }
+}
+
+/**
+ * Test class to check that formatters and display settings are working.
+ */
+class ImageFieldDisplayTestCase extends ImageFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image field display tests',
+ 'description' => 'Test the display of image fields.',
+ 'group' => 'Image',
+ );
+ }
+
+ /**
+ * Test image formatters on node display for public files.
+ */
+ function testImageFieldFormattersPublic() {
+ $this->_testImageFieldFormatters('public');
+ }
+
+ /**
+ * Test image formatters on node display for private files.
+ */
+ function testImageFieldFormattersPrivate() {
+ // Remove access content permission from anonymous users.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array('access content' => FALSE));
+ $this->_testImageFieldFormatters('private');
+ }
+
+ /**
+ * Test image formatters on node display.
+ */
+ function _testImageFieldFormatters($scheme) {
+ $field_name = strtolower($this->randomName());
+ $this->createImageField($field_name, 'article', array('uri_scheme' => $scheme));
+ // Create a new node with an image attached.
+ $test_image = current($this->drupalGetTestFiles('image'));
+ $nid = $this->uploadNodeImage($test_image, $field_name, 'article');
+ $node = node_load($nid, NULL, TRUE);
+
+ // Test that the default formatter is being used.
+ $image_uri = $node->{$field_name}[LANGUAGE_NONE][0]['uri'];
+ $image_info = array(
+ 'path' => $image_uri,
+ 'width' => 40,
+ 'height' => 20,
+ );
+ $default_output = theme('image', $image_info);
+ $this->assertRaw($default_output, t('Default formatter displaying correctly on full node view.'));
+
+ // Test the image linked to file formatter.
+ $instance = field_info_instance('node', $field_name, 'article');
+ $instance['display']['default']['type'] = 'image';
+ $instance['display']['default']['settings']['image_link'] = 'file';
+ field_update_instance($instance);
+ $default_output = l(theme('image', $image_info), file_create_url($image_uri), array('html' => TRUE));
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw($default_output, t('Image linked to file formatter displaying correctly on full node view.'));
+ // Verify that the image can be downloaded.
+ $this->assertEqual(file_get_contents($test_image->uri), $this->drupalGet(file_create_url($image_uri)), t('File was downloaded successfully.'));
+ if ($scheme == 'private') {
+ // Only verify HTTP headers when using private scheme and the headers are
+ // sent by Drupal.
+ $this->assertEqual($this->drupalGetHeader('Content-Type'), 'image/png; name="' . $test_image->filename . '"', t('Content-Type header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Content-Disposition'), 'inline; filename="' . $test_image->filename . '"', t('Content-Disposition header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Cache-Control'), 'private', t('Cache-Control header was sent.'));
+
+ // Log out and try to access the file.
+ $this->drupalLogout();
+ $this->drupalGet(file_create_url($image_uri));
+ $this->assertResponse('403', t('Access denied to original image as anonymous user.'));
+
+ // Log in again.
+ $this->drupalLogin($this->admin_user);
+ }
+
+ // Test the image linked to content formatter.
+ $instance['display']['default']['settings']['image_link'] = 'content';
+ field_update_instance($instance);
+ $default_output = l(theme('image', $image_info), 'node/' . $nid, array('html' => TRUE, 'attributes' => array('class' => 'active')));
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw($default_output, t('Image linked to content formatter displaying correctly on full node view.'));
+
+ // Test the image style 'thumbnail' formatter.
+ $instance['display']['default']['settings']['image_link'] = '';
+ $instance['display']['default']['settings']['image_style'] = 'thumbnail';
+ field_update_instance($instance);
+ // Ensure the derivative image is generated so we do not have to deal with
+ // image style callback paths.
+ $this->drupalGet(image_style_url('thumbnail', $image_uri));
+ $image_info['path'] = image_style_path('thumbnail', $image_uri);
+ $image_info['width'] = 100;
+ $image_info['height'] = 50;
+ $default_output = theme('image', $image_info);
+ $this->drupalGet('node/' . $nid);
+ $this->assertRaw($default_output, t('Image style thumbnail formatter displaying correctly on full node view.'));
+
+ if ($scheme == 'private') {
+ // Log out and try to access the file.
+ $this->drupalLogout();
+ $this->drupalGet(image_style_url('thumbnail', $image_uri));
+ $this->assertResponse('403', t('Access denied to image style thumbnail as anonymous user.'));
+ }
+ }
+
+ /**
+ * Tests for image field settings.
+ */
+ function testImageFieldSettings() {
+ $test_image = current($this->drupalGetTestFiles('image'));
+ list(, $test_image_extension) = explode('.', $test_image->filename);
+ $field_name = strtolower($this->randomName());
+ $instance_settings = array(
+ 'alt_field' => 1,
+ 'file_extensions' => $test_image_extension,
+ 'max_filesize' => '50 KB',
+ 'max_resolution' => '100x100',
+ 'min_resolution' => '10x10',
+ 'title_field' => 1,
+ );
+ $widget_settings = array(
+ 'preview_image_style' => 'medium',
+ );
+ $this->createImageField($field_name, 'article', array(), $instance_settings, $widget_settings);
+ $instance = field_info_instance('node', $field_name, 'article');
+
+ $this->drupalGet('node/add/article');
+ $this->assertText(t('Files must be less than 50 KB.'), t('Image widget max file size is displayed on article form.'));
+ $this->assertText(t('Allowed file types: ' . $test_image_extension . '.'), t('Image widget allowed file types displayed on article form.'));
+ $this->assertText(t('Images must be between 10x10 and 100x100 pixels.'), t('Image widget allowed resolution displayed on article form.'));
+
+ // We have to create the article first and then edit it because the alt
+ // and title fields do not display until the image has been attached.
+ $nid = $this->uploadNodeImage($test_image, $field_name, 'article');
+ $this->drupalGet('node/' . $nid . '/edit');
+ $this->assertFieldByName($field_name . '[' . LANGUAGE_NONE . '][0][alt]', '', t('Alt field displayed on article form.'));
+ $this->assertFieldByName($field_name . '[' . LANGUAGE_NONE . '][0][title]', '', t('Title field displayed on article form.'));
+ // Verify that the attached image is being previewed using the 'medium'
+ // style.
+ $node = node_load($nid, NULL, TRUE);
+ $image_info = array(
+ 'path' => image_style_url('medium', $node->{$field_name}[LANGUAGE_NONE][0]['uri']),
+ 'width' => 220,
+ 'height' => 110,
+ );
+ $default_output = theme('image', $image_info);
+ $this->assertRaw($default_output, t("Preview image is displayed using 'medium' style."));
+
+ // Add alt/title fields to the image and verify that they are displayed.
+ $image_info = array(
+ 'path' => $node->{$field_name}[LANGUAGE_NONE][0]['uri'],
+ 'alt' => $this->randomName(),
+ 'title' => $this->randomName(),
+ 'width' => 40,
+ 'height' => 20,
+ );
+ $edit = array(
+ $field_name . '[' . LANGUAGE_NONE . '][0][alt]' => $image_info['alt'],
+ $field_name . '[' . LANGUAGE_NONE . '][0][title]' => $image_info['title'],
+ );
+ $this->drupalPost('node/' . $nid . '/edit', $edit, t('Save'));
+ $default_output = theme('image', $image_info);
+ $this->assertRaw($default_output, t('Image displayed using user supplied alt and title attributes.'));
+ }
+
+ /**
+ * Test use of a default image with an image field.
+ */
+ function testImageFieldDefaultImage() {
+ // Create a new image field.
+ $field_name = strtolower($this->randomName());
+ $this->createImageField($field_name, 'article');
+
+ // Create a new node, with no images and verify that no images are
+ // displayed.
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $this->drupalGet('node/' . $node->nid);
+ // Verify that no image is displayed on the page by checking for the class
+ // that would be used on the image field.
+ $this->assertNoPattern('<div class="(.*?)field-name-' . strtr($field_name, '_', '-') . '(.*?)">', t('No image displayed when no image is attached and no default image specified.'));
+
+ // Add a default image to the public imagefield instance.
+ $images = $this->drupalGetTestFiles('image');
+ $edit = array(
+ 'files[field_settings_default_image]' => drupal_realpath($images[0]->uri),
+ );
+ $this->drupalPost('admin/structure/types/manage/article/fields/' . $field_name, $edit, t('Save settings'));
+ // Clear field info cache so the new default image is detected.
+ field_info_cache_clear();
+ $field = field_info_field($field_name);
+ $image = file_load($field['settings']['default_image']);
+ $this->assertTrue($image->status == FILE_STATUS_PERMANENT, t('The default image status is permanent.'));
+ $default_output = theme('image', array('path' => $image->uri));
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw($default_output, t('Default image displayed when no user supplied image is present.'));
+
+ // Create a node with an image attached and ensure that the default image
+ // is not displayed.
+ $nid = $this->uploadNodeImage($images[1], $field_name, 'article');
+ $node = node_load($nid, NULL, TRUE);
+ $image_info = array(
+ 'path' => $node->{$field_name}[LANGUAGE_NONE][0]['uri'],
+ 'width' => 40,
+ 'height' => 20,
+ );
+ $image_output = theme('image', $image_info);
+ $this->drupalGet('node/' . $nid);
+ $this->assertNoRaw($default_output, t('Default image is not displayed when user supplied image is present.'));
+ $this->assertRaw($image_output, t('User supplied image is displayed.'));
+
+ // Remove default image from the field and make sure it is no longer used.
+ $edit = array(
+ 'field[settings][default_image][fid]' => 0,
+ );
+ $this->drupalPost('admin/structure/types/manage/article/fields/' . $field_name, $edit, t('Save settings'));
+ // Clear field info cache so the new default image is detected.
+ field_info_cache_clear();
+ $field = field_info_field($field_name);
+ $this->assertFalse($field['settings']['default_image'], t('Default image removed from field.'));
+ // Create an image field that uses the private:// scheme and test that the
+ // default image works as expected.
+ $private_field_name = strtolower($this->randomName());
+ $this->createImageField($private_field_name, 'article', array('uri_scheme' => 'private'));
+ // Add a default image to the new field.
+ $edit = array(
+ 'files[field_settings_default_image]' => drupal_realpath($images[1]->uri),
+ );
+ $this->drupalPost('admin/structure/types/manage/article/fields/' . $private_field_name, $edit, t('Save settings'));
+ $private_field = field_info_field($private_field_name);
+ $image = file_load($private_field['settings']['default_image']);
+ $this->assertEqual('private', file_uri_scheme($image->uri), t('Default image uses private:// scheme.'));
+ $this->assertTrue($image->status == FILE_STATUS_PERMANENT, t('The default image status is permanent.'));
+ // Create a new node with no image attached and ensure that default private
+ // image is displayed.
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $default_output = theme('image', array('path' => $image->uri));
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw($default_output, t('Default private image displayed when no user supplied image is present.'));
+ }
+}
+
+/**
+ * Test class to check for various validations.
+ */
+class ImageFieldValidateTestCase extends ImageFieldTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image field validation tests',
+ 'description' => 'Tests validation functions such as min/max resolution.',
+ 'group' => 'Image',
+ );
+ }
+
+ /**
+ * Test min/max resolution settings.
+ */
+ function testResolution() {
+ $field_name = strtolower($this->randomName());
+ $min_resolution = 50;
+ $max_resolution = 100;
+ $instance_settings = array(
+ 'max_resolution' => $max_resolution . 'x' . $max_resolution,
+ 'min_resolution' => $min_resolution . 'x' . $min_resolution,
+ );
+ $this->createImageField($field_name, 'article', array(), $instance_settings);
+
+ // We want a test image that is too small, and a test image that is too
+ // big, so cycle through test image files until we have what we need.
+ $image_that_is_too_big = FALSE;
+ $image_that_is_too_small = FALSE;
+ foreach ($this->drupalGetTestFiles('image') as $image) {
+ $info = image_get_info($image->uri);
+ if ($info['width'] > $max_resolution) {
+ $image_that_is_too_big = $image;
+ }
+ if ($info['width'] < $min_resolution) {
+ $image_that_is_too_small = $image;
+ }
+ if ($image_that_is_too_small && $image_that_is_too_big) {
+ break;
+ }
+ }
+ $nid = $this->uploadNodeImage($image_that_is_too_small, $field_name, 'article');
+ $this->assertText(t('The specified file ' . $image_that_is_too_small->filename . ' could not be uploaded. The image is too small; the minimum dimensions are 50x50 pixels.'), t('Node save failed when minimum image resolution was not met.'));
+ $nid = $this->uploadNodeImage($image_that_is_too_big, $field_name, 'article');
+ $this->assertText(t('The image was resized to fit within the maximum allowed dimensions of 100x100 pixels.'), t('Image exceeding max resolution was properly resized.'));
+ }
+}
+
+/**
+ * Tests that images have correct dimensions when styled.
+ */
+class ImageDimensionsUnitTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image dimensions',
+ 'description' => 'Tests that images have correct dimensions when styled.',
+ 'group' => 'Image',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('image_module_test');
+ }
+
+ /**
+ * Test styled image dimensions cumulatively.
+ */
+ function testImageDimensions() {
+ // Create a working copy of the file.
+ $files = $this->drupalGetTestFiles('image');
+ $file = reset($files);
+ $original_uri = file_unmanaged_copy($file->uri, 'public://', FILE_EXISTS_RENAME);
+
+ // Create a style.
+ $style = image_style_save(array('name' => 'test'));
+ $generated_uri = 'public://styles/test/public/'. basename($original_uri);
+ $url = image_style_url('test', $original_uri);
+
+ $variables = array(
+ 'style_name' => 'test',
+ 'path' => $original_uri,
+ 'width' => 40,
+ 'height' => 20,
+ );
+
+ // Scale an image that is wider than it is high.
+ $effect = array(
+ 'name' => 'image_scale',
+ 'data' => array(
+ 'width' => 120,
+ 'height' => 90,
+ 'upscale' => TRUE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="120" height="60" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 120, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 60, t('Expected height was found.'));
+
+ // Rotate 90 degrees anticlockwise.
+ $effect = array(
+ 'name' => 'image_rotate',
+ 'data' => array(
+ 'degrees' => -90,
+ 'random' => FALSE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="60" height="120" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 60, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 120, t('Expected height was found.'));
+
+ // Scale an image that is higher than it is wide (rotated by previous effect).
+ $effect = array(
+ 'name' => 'image_scale',
+ 'data' => array(
+ 'width' => 120,
+ 'height' => 90,
+ 'upscale' => TRUE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="45" height="90" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 45, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 90, t('Expected height was found.'));
+
+ // Test upscale disabled.
+ $effect = array(
+ 'name' => 'image_scale',
+ 'data' => array(
+ 'width' => 400,
+ 'height' => 200,
+ 'upscale' => FALSE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="45" height="90" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 45, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 90, t('Expected height was found.'));
+
+ // Add a desaturate effect.
+ $effect = array(
+ 'name' => 'image_desaturate',
+ 'data' => array(),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="45" height="90" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 45, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 90, t('Expected height was found.'));
+
+ // Add a random rotate effect.
+ $effect = array(
+ 'name' => 'image_rotate',
+ 'data' => array(
+ 'degrees' => 180,
+ 'random' => TRUE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+
+
+ // Add a crop effect.
+ $effect = array(
+ 'name' => 'image_crop',
+ 'data' => array(
+ 'width' => 30,
+ 'height' => 30,
+ 'anchor' => 'center-center',
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" width="30" height="30" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+ $image_info = image_get_info($generated_uri);
+ $this->assertEqual($image_info['width'], 30, t('Expected width was found.'));
+ $this->assertEqual($image_info['height'], 30, t('Expected height was found.'));
+
+ // Rotate to a non-multiple of 90 degrees.
+ $effect = array(
+ 'name' => 'image_rotate',
+ 'data' => array(
+ 'degrees' => 57,
+ 'random' => FALSE,
+ ),
+ 'isid' => $style['isid'],
+ );
+
+ $effect = image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" alt="" />', t('Expected img tag was found.'));
+ $this->assertFalse(file_exists($generated_uri), t('Generated file does not exist.'));
+ $this->drupalGet($url);
+ $this->assertResponse(200, t('Image was generated at the URL.'));
+ $this->assertTrue(file_exists($generated_uri), t('Generated file does exist after we accessed it.'));
+
+ image_effect_delete($effect);
+
+ // Ensure that an effect with no dimensions callback unsets the dimensions.
+ // This ensures compatibility with 7.0 contrib modules.
+ $effect = array(
+ 'name' => 'image_module_test_null',
+ 'data' => array(),
+ 'isid' => $style['isid'],
+ );
+
+ image_effect_save($effect);
+ $img_tag = theme_image_style($variables);
+ $this->assertEqual($img_tag, '<img typeof="foaf:Image" src="' . $url . '" alt="" />', t('Expected img tag was found.'));
+ }
+}
diff --git a/core/modules/image/sample.png b/core/modules/image/sample.png
new file mode 100644
index 000000000000..f22e0df98448
--- /dev/null
+++ b/core/modules/image/sample.png
Binary files differ
diff --git a/core/modules/image/tests/image_module_test.info b/core/modules/image/tests/image_module_test.info
new file mode 100644
index 000000000000..01d4d7149f49
--- /dev/null
+++ b/core/modules/image/tests/image_module_test.info
@@ -0,0 +1,7 @@
+name = Image test
+description = Provides hook implementations for testing Image module functionality.
+package = Core
+version = VERSION
+core = 8.x
+files[] = image_module_test.module
+hidden = TRUE
diff --git a/core/modules/image/tests/image_module_test.module b/core/modules/image/tests/image_module_test.module
new file mode 100644
index 000000000000..766a9d957691
--- /dev/null
+++ b/core/modules/image/tests/image_module_test.module
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @file
+ * Provides Image module hook implementations for testing purposes.
+ */
+
+function image_module_test_file_download($uri) {
+ if (variable_get('image_module_test_file_download', FALSE) == $uri) {
+ return array('X-Image-Owned-By' => 'image_module_test');
+ }
+ return -1;
+}
+
+/**
+ * Implements hook_image_effect_info().
+ */
+function image_module_test_image_effect_info() {
+ $effects = array(
+ 'image_module_test_null' => array(
+ 'effect callback' => 'image_module_test_null_effect',
+ ),
+ );
+
+ return $effects;
+}
+
+/**
+ * Image effect callback; Null.
+ *
+ * @param $image
+ * An image object returned by image_load().
+ * @param $data
+ * An array with no attributes.
+ *
+ * @return
+ * TRUE
+ */
+function image_module_test_null_effect(array &$image, array $data) {
+ return TRUE;
+}
diff --git a/core/modules/locale/locale-rtl.css b/core/modules/locale/locale-rtl.css
new file mode 100644
index 000000000000..aaf1988dd17d
--- /dev/null
+++ b/core/modules/locale/locale-rtl.css
@@ -0,0 +1,12 @@
+
+#locale-translation-filter-form .form-item-language,
+#locale-translation-filter-form .form-item-translation,
+#locale-translation-filter-form .form-item-group {
+ float: right;
+ padding-left: .8em;
+ padding-right: 0;
+}
+#locale-translation-filter-form .form-actions {
+ float: right;
+ padding: 3ex 1em 0 0;
+}
diff --git a/core/modules/locale/locale.admin.inc b/core/modules/locale/locale.admin.inc
new file mode 100644
index 000000000000..82397c8cd2f0
--- /dev/null
+++ b/core/modules/locale/locale.admin.inc
@@ -0,0 +1,987 @@
+<?php
+
+/**
+ * @file
+ * Administration functions for locale.module.
+ */
+
+/**
+ * @defgroup locale-language-administration Language administration interface
+ * @{
+ * Administration interface for languages.
+ *
+ * These functions provide the user interface to show, add, edit and
+ * delete languages as well as providing options for language negotiation.
+ */
+
+/**
+ * User interface for the language overview screen.
+ */
+function locale_language_overview_form($form, &$form_state) {
+ drupal_static_reset('language');
+ $languages = language_list('language');
+ $default = language_default();
+
+ $form['languages'] = array(
+ '#languages' => $languages,
+ '#language_default' => $default,
+ '#tree' => TRUE,
+ '#header' => array(
+ t('Name'),
+ t('Enabled'),
+ t('Default'),
+ t('Weight'),
+ t('Operations'),
+ ),
+ '#theme' => 'locale_language_overview_form_table',
+ );
+
+ foreach ($languages as $langcode => $language) {
+ $form['languages'][$langcode]['#weight'] = $language->weight;
+ $form['languages'][$langcode]['name'] = array(
+ '#markup' => check_plain($language->name),
+ );
+ $form['languages'][$langcode]['enabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable @title', array('@title' => $language->name)),
+ '#title_display' => 'invisible',
+ '#default_value' => (int) $language->enabled,
+ '#disabled' => $langcode == $default->language,
+ );
+ $form['languages'][$langcode]['default'] = array(
+ '#type' => 'radio',
+ '#parents' => array('site_default'),
+ '#title' => t('Set @title as default', array('@title' => $language->name)),
+ '#title_display' => 'invisible',
+ '#return_value' => $langcode,
+ '#default_value' => ($langcode == $default->language ? $langcode : NULL),
+ '#id' => 'edit-site-default-' . $langcode,
+ );
+ $form['languages'][$langcode]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for @title', array('@title' => $language->name)),
+ '#title_display' => 'invisible',
+ '#default_value' => $language->weight,
+ '#attributes' => array(
+ 'class' => array('language-order-weight'),
+ ),
+ );
+ $form['languages'][$langcode]['operations'] = array(
+ '#theme_wrappers' => array('locale_language_operations'),
+ '#weight' => 100,
+ );
+ $form['languages'][$langcode]['operations']['edit'] = array(
+ '#type' => 'link',
+ '#title' => t('edit'),
+ '#href' => 'admin/config/regional/language/edit/' . $langcode,
+ );
+ $form['languages'][$langcode]['operations']['delete'] = array(
+ '#type' => 'link',
+ '#title' => t('delete'),
+ '#href' => 'admin/config/regional/language/delete/' . $langcode,
+ '#access' => $langcode != $default->language,
+ );
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+
+ return $form;
+}
+
+/**
+ * Returns HTML for operation links in locale_language_overview_form() table.
+ *
+ * @todo Introduce #type '[table_]operations' or just simply #type 'links'.
+ */
+function theme_locale_language_operations($variables) {
+ $links = array();
+ foreach (element_children($variables['elements']) as $key) {
+ // Children are only rendered if the current user has access.
+ if (isset($variables['elements'][$key]['#children'])) {
+ $links[$key] = $variables['elements'][$key]['#children'];
+ }
+ }
+ // If there are links, render a link list.
+ if (!empty($links)) {
+ return theme('item_list__operations', array(
+ 'items' => $links,
+ 'attributes' => array('class' => array('links', 'inline')),
+ ));
+ }
+ // Otherwise, ensure to produce no output.
+ return '';
+}
+
+/**
+ * Returns HTML for the language overview form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_locale_language_overview_form_table($variables) {
+ $form = $variables['form'];
+
+ $rows = array();
+ foreach (element_children($form, TRUE) as $langcode) {
+ $element = &$form[$langcode];
+ $row = array(
+ 'class' => array('draggable'),
+ );
+ foreach (element_children($element, TRUE) as $column) {
+ $cell = &$element[$column];
+ $row['data'][] = drupal_render($cell);
+ }
+ $rows[] = $row;
+ }
+
+ $output = theme('table', array(
+ 'header' => $form['#header'],
+ 'rows' => $rows,
+ 'attributes' => array('id' => 'language-order'),
+ ));
+ $output .= drupal_render_children($form);
+
+ drupal_add_tabledrag('language-order', 'order', 'sibling', 'language-order-weight');
+
+ return $output;
+}
+
+/**
+ * Process language overview form submissions, updating existing languages.
+ */
+function locale_language_overview_form_submit($form, &$form_state) {
+ $languages = language_list();
+ $old_default = language_default();
+ $url_prefixes = variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX) == LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX;
+
+ foreach ($languages as $langcode => $language) {
+ $language->default = ($form_state['values']['site_default'] == $langcode);
+ $language->weight = $form_state['values']['languages'][$langcode]['weight'];
+
+ if ($language->default || $old_default->language == $langcode) {
+ // Automatically enable the default language and the language
+ // which was default previously (because we will not get the
+ // value from that disabled checkbox).
+ $form_state['values']['languages'][$langcode]['enabled'] = 1;
+ }
+ $language->enabled = (int) !empty($form_state['values']['languages'][$langcode]['enabled']);
+
+ // If language URL prefixes are enabled we must clear language domains and
+ // assign a valid prefix to each non-default language.
+ if ($url_prefixes) {
+ $language->domain = '';
+ if (empty($language->prefix) && !$language->default) {
+ $language->prefix = $langcode;
+ }
+ }
+
+ locale_language_save($language);
+ }
+
+ drupal_set_message(t('Configuration saved.'));
+}
+
+/**
+ * User interface for the language addition screen.
+ */
+function locale_languages_add_form($form, &$form_state) {
+ $predefined_languages = _locale_prepare_predefined_list();
+ $predefined_languages['custom'] = t('Custom language...');
+ $predefined_default = !empty($form_state['values']['predefined_langcode']) ? $form_state['values']['predefined_langcode'] : key($predefined_languages);
+ $form['predefined_langcode'] = array(
+ '#type' => 'select',
+ '#title' => t('Language name'),
+ '#default_value' => $predefined_default,
+ '#options' => $predefined_languages,
+ );
+ $form['predefined_submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add language'),
+ '#limit_validation_errors' => array(array('predefined_langcode'), array('predefined_submit')),
+ '#states' => array(
+ 'invisible' => array(
+ 'select#edit-predefined-langcode' => array('value' => 'custom'),
+ ),
+ ),
+ '#validate' => array('locale_languages_add_predefined_form_validate'),
+ '#submit' => array('locale_languages_add_predefined_form_submit'),
+ );
+
+ $form['custom_language'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ 'visible' => array(
+ 'select#edit-predefined-langcode' => array('value' => 'custom'),
+ ),
+ ),
+ );
+ _locale_languages_common_controls($form['custom_language']);
+ $form['custom_language']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add custom language'),
+ '#validate' => array('locale_languages_add_custom_form_validate'),
+ '#submit' => array('locale_languages_add_custom_form_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Editing screen for a particular language.
+ *
+ * @param $langcode
+ * Language code of the language to edit.
+ */
+function locale_languages_edit_form($form, &$form_state, $language) {
+ _locale_languages_common_controls($form, $language);
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save language')
+ );
+ $form['#submit'][] = 'locale_languages_edit_form_submit';
+ $form['#validate'][] = 'locale_languages_edit_form_validate';
+ return $form;
+}
+
+/**
+ * Common elements of the language addition and editing form.
+ *
+ * @param $form
+ * A parent form item (or empty array) to add items below.
+ * @param $language
+ * Language object to edit.
+ */
+function _locale_languages_common_controls(&$form, $language = NULL) {
+ if (!is_object($language)) {
+ $language = new stdClass();
+ }
+ if (isset($language->language)) {
+ $form['langcode_view'] = array(
+ '#type' => 'item',
+ '#title' => t('Language code'),
+ '#markup' => $language->language
+ );
+ $form['langcode'] = array(
+ '#type' => 'value',
+ '#value' => $language->language
+ );
+ }
+ else {
+ $form['langcode'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Language code'),
+ '#size' => 12,
+ '#maxlength' => 60,
+ '#required' => TRUE,
+ '#default_value' => @$language->language,
+ '#disabled' => (isset($language->language)),
+ '#description' => t('Use language codes as <a href="@w3ctags">defined by the W3C</a> for interoperability. <em>Examples: "en", "en-gb" and "zh-hant".</em>', array('@w3ctags' => 'http://www.w3.org/International/articles/language-tags/')),
+ );
+ }
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Language name'),
+ '#maxlength' => 64,
+ '#default_value' => @$language->name,
+ '#required' => TRUE,
+ );
+ $form['direction'] = array(
+ '#type' => 'radios',
+ '#title' => t('Direction'),
+ '#required' => TRUE,
+ '#description' => t('Direction that text in this language is presented.'),
+ '#default_value' => @$language->direction,
+ '#options' => array(LANGUAGE_LTR => t('Left to right'), LANGUAGE_RTL => t('Right to left'))
+ );
+ return $form;
+}
+
+/**
+ * Element specific validator for the Add language button.
+ */
+function locale_languages_add_predefined_form_validate($form, &$form_state) {
+ $langcode = $form_state['values']['predefined_langcode'];
+ if ($langcode == 'custom') {
+ form_set_error('predefined_langcode', t('Fill in the language details and save the language with <em>Add custom language</em>.'));
+ }
+ else {
+ if (language_load($langcode)) {
+ form_set_error('predefined_langcode', t('The language %language (%code) already exists.', array('%language' => $languages[$langcode]->name, '%code' => $langcode)));
+ }
+ }
+}
+
+/**
+ * Validate the language addition form on custom language button.
+ */
+function locale_languages_add_custom_form_validate($form, &$form_state) {
+ if ($form_state['values']['predefined_langcode'] == 'custom') {
+ $langcode = $form_state['values']['langcode'];
+ // Reuse the editing form validation routine if we add a custom language.
+ locale_languages_edit_form_validate($form, $form_state);
+
+ $languages = language_list();
+ if (isset($languages[$langcode])) {
+ form_set_error('langcode', t('The language %language (%code) already exists.', array('%language' => $languages[$langcode]->name, '%code' => $langcode)));
+ }
+ }
+ else {
+ form_set_error('predefined_langcode', t('Use the <em>Add language</em> button to save a predefined language.'));
+ }
+}
+
+/**
+ * Process the custom language addition form submission.
+ */
+function locale_languages_add_custom_form_submit($form, &$form_state) {
+ $langcode = $form_state['values']['langcode'];
+ // Custom language form.
+ $language = (object) array(
+ 'language' => $langcode,
+ 'name' => $form_state['values']['name'],
+ 'direction' => $form_state['values']['direction'],
+ );
+ locale_language_save($language);
+ drupal_set_message(t('The language %language has been created and can now be used. More information is available on the <a href="@locale-help">help screen</a>.', array('%language' => $form_state['values']['name'], '@locale-help' => url('admin/help/locale'))));
+ locale_languages_add_set_batch($langcode);
+ $form_state['redirect'] = 'admin/config/regional/language';
+}
+
+/**
+ * Process the predefined language addition form submission.
+ */
+function locale_languages_add_predefined_form_submit($form, &$form_state) {
+ // Predefined language selection.
+ $langcode = $form_state['values']['predefined_langcode'];
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $predefined = standard_language_list();
+ $language = (object) array(
+ 'language' => $langcode,
+ );
+ locale_language_save($language);
+ drupal_set_message(t('The language %language has been created and can now be used. More information is available on the <a href="@locale-help">help screen</a>.', array('%language' => t($predefined[$langcode][0]), '@locale-help' => url('admin/help/locale'))));
+ locale_languages_add_set_batch($langcode);
+ $form_state['redirect'] = 'admin/config/regional/language';
+}
+
+/**
+ * Set a batch for newly added language.
+ */
+function locale_languages_add_set_batch($langcode) {
+ // See if we have language files to import for the newly added
+ // language, collect and import them.
+ include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc';
+ if ($batch = locale_translate_batch_import_files($langcode, TRUE)) {
+ batch_set($batch);
+ }
+}
+
+/**
+ * Validate the language editing form. Reused for custom language addition too.
+ */
+function locale_languages_edit_form_validate($form, &$form_state) {
+ // Ensure sane field values for langcode and name.
+ if (!isset($form['langcode_view']) && preg_match('@[^a-zA-Z_-]@', $form_state['values']['langcode'])) {
+ form_set_error('langcode', t('%field may only contain characters a-z, underscores, or hyphens.', array('%field' => $form['langcode']['#title'])));
+ }
+ if ($form_state['values']['name'] != check_plain($form_state['values']['name'])) {
+ form_set_error('name', t('%field cannot contain any markup.', array('%field' => $form['name']['#title'])));
+ }
+}
+
+/**
+ * Process the language editing form submission.
+ */
+function locale_languages_edit_form_submit($form, &$form_state) {
+ // Prepare a language object for saving
+ $languages = language_list();
+ $langcode = $form_state['values']['langcode'];
+ $language = $languages[$langcode];
+ $language->name = $form_state['values']['name'];
+ $language->direction = $form_state['values']['direction'];
+ locale_language_save($language);
+ $form_state['redirect'] = 'admin/config/regional/language';
+}
+
+/**
+ * User interface for the language deletion confirmation screen.
+ */
+function locale_languages_delete_form($form, &$form_state, $language) {
+ $langcode = $language->language;
+
+ if (language_default()->language == $langcode) {
+ drupal_set_message(t('The default language cannot be deleted.'));
+ drupal_goto('admin/config/regional/language');
+ }
+
+ // For other languages, warn user that data loss is ahead.
+ $languages = language_list();
+
+ if (!isset($languages[$langcode])) {
+ drupal_not_found();
+ drupal_exit();
+ }
+ else {
+ $form['langcode'] = array('#type' => 'value', '#value' => $langcode);
+ return confirm_form($form, t('Are you sure you want to delete the language %name?', array('%name' => $languages[$langcode]->name)), 'admin/config/regional/language', t('Deleting a language will remove all interface translations associated with it, and posts in this language will be set to be language neutral. This action cannot be undone.'), t('Delete'), t('Cancel'));
+ }
+}
+
+/**
+ * Process language deletion submissions.
+ */
+function locale_languages_delete_form_submit($form, &$form_state) {
+ $langcode = $form_state['values']['langcode'];
+ $languages = language_list();
+ $language = $languages[$langcode];
+
+ $success = locale_language_delete($langcode);
+
+ if ($success) {
+ $variables = array('%locale' => $language->name);
+ drupal_set_message(t('The language %locale has been removed.', $variables));
+ }
+
+ $form_state['redirect'] = 'admin/config/regional/language';
+}
+
+/**
+ * Setting for language negotiation options
+ */
+function locale_languages_configure_form() {
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+
+ $form = array(
+ '#submit' => array('locale_languages_configure_form_submit'),
+ '#theme' => 'locale_languages_configure_form',
+ '#language_types' => language_types_configurable(FALSE),
+ '#language_types_info' => language_types_info(),
+ '#language_providers' => language_negotiation_info(),
+ );
+
+ foreach ($form['#language_types'] as $type) {
+ _locale_languages_configure_form_language_table($form, $type);
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save settings'),
+ );
+
+ return $form;
+}
+
+/**
+ * Helper function to build a language provider table.
+ */
+function _locale_languages_configure_form_language_table(&$form, $type) {
+ $info = $form['#language_types_info'][$type];
+
+ $table_form = array(
+ '#title' => t('@type language detection', array('@type' => $info['name'])),
+ '#tree' => TRUE,
+ '#description' => $info['description'],
+ '#language_providers' => array(),
+ '#show_operations' => FALSE,
+ 'weight' => array('#tree' => TRUE),
+ 'enabled' => array('#tree' => TRUE),
+ );
+
+ $language_providers = $form['#language_providers'];
+ $enabled_providers = variable_get("language_negotiation_$type", array());
+ $providers_weight = variable_get("locale_language_providers_weight_$type", array());
+
+ // Add missing data to the providers lists.
+ foreach ($language_providers as $id => $provider) {
+ if (!isset($providers_weight[$id])) {
+ $providers_weight[$id] = language_provider_weight($provider);
+ }
+ }
+
+ // Order providers list by weight.
+ asort($providers_weight);
+
+ foreach ($providers_weight as $id => $weight) {
+ // A language provider might be no more available if the defining module has
+ // been disabled after the last configuration saving.
+ if (!isset($language_providers[$id])) {
+ continue;
+ }
+
+ $enabled = isset($enabled_providers[$id]);
+ $provider = $language_providers[$id];
+
+ // List the provider only if the current type is defined in its 'types' key.
+ // If it is not defined default to all the configurable language types.
+ $types = array_flip(isset($provider['types']) ? $provider['types'] : $form['#language_types']);
+
+ if (isset($types[$type])) {
+ $table_form['#language_providers'][$id] = $provider;
+ $provider_name = check_plain($provider['name']);
+
+ $table_form['weight'][$id] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for !title language detection method', array('!title' => drupal_strtolower($provider_name))),
+ '#title_display' => 'invisible',
+ '#default_value' => $weight,
+ '#attributes' => array('class' => array("language-provider-weight-$type")),
+ );
+
+ $table_form['title'][$id] = array('#markup' => $provider_name);
+
+ $table_form['enabled'][$id] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable !title language detection method', array('!title' => drupal_strtolower($provider_name))),
+ '#title_display' => 'invisible',
+ '#default_value' => $enabled,
+ );
+ if ($id === LANGUAGE_NEGOTIATION_DEFAULT) {
+ $table_form['enabled'][$id]['#default_value'] = TRUE;
+ $table_form['enabled'][$id]['#attributes'] = array('disabled' => 'disabled');
+ }
+
+ $table_form['description'][$id] = array('#markup' => filter_xss_admin($provider['description']));
+
+ $config_op = array();
+ if (isset($provider['config'])) {
+ $config_op = array('#type' => 'link', '#title' => t('Configure'), '#href' => $provider['config']);
+ // If there is at least one operation enabled show the operation column.
+ $table_form['#show_operations'] = TRUE;
+ }
+ $table_form['operation'][$id] = $config_op;
+ }
+ }
+
+ $form[$type] = $table_form;
+}
+
+/**
+ * Returns HTML for a language configuration form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_locale_languages_configure_form($variables) {
+ $form = $variables['form'];
+ $output = '';
+
+ foreach ($form['#language_types'] as $type) {
+ $rows = array();
+ $info = $form['#language_types_info'][$type];
+ $title = '<label>' . $form[$type]['#title'] . '</label>';
+ $description = '<div class="description">' . $form[$type]['#description'] . '</div>';
+
+ foreach ($form[$type]['title'] as $id => $element) {
+ // Do not take form control structures.
+ if (is_array($element) && element_child($id)) {
+ $row = array(
+ 'data' => array(
+ '<strong>' . drupal_render($form[$type]['title'][$id]) . '</strong>',
+ drupal_render($form[$type]['description'][$id]),
+ drupal_render($form[$type]['enabled'][$id]),
+ drupal_render($form[$type]['weight'][$id]),
+ ),
+ 'class' => array('draggable'),
+ );
+ if ($form[$type]['#show_operations']) {
+ $row['data'][] = drupal_render($form[$type]['operation'][$id]);
+ }
+ $rows[] = $row;
+ }
+ }
+
+ $header = array(
+ array('data' => t('Detection method')),
+ array('data' => t('Description')),
+ array('data' => t('Enabled')),
+ array('data' => t('Weight')),
+ );
+
+ // If there is at least one operation enabled show the operation column.
+ if ($form[$type]['#show_operations']) {
+ $header[] = array('data' => t('Operations'));
+ }
+
+ $variables = array(
+ 'header' => $header,
+ 'rows' => $rows,
+ 'attributes' => array('id' => "language-negotiation-providers-$type"),
+ );
+ $table = theme('table', $variables);
+ $table .= drupal_render_children($form[$type]);
+
+ drupal_add_tabledrag("language-negotiation-providers-$type", 'order', 'sibling', "language-provider-weight-$type");
+
+ $output .= '<div class="form-item">' . $title . $description . $table . '</div>';
+ }
+
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+/**
+ * Submit handler for language negotiation settings.
+ */
+function locale_languages_configure_form_submit($form, &$form_state) {
+ $configurable_types = $form['#language_types'];
+
+ foreach ($configurable_types as $type) {
+ $negotiation = array();
+ $enabled_providers = $form_state['values'][$type]['enabled'];
+ $enabled_providers[LANGUAGE_NEGOTIATION_DEFAULT] = TRUE;
+ $providers_weight = $form_state['values'][$type]['weight'];
+
+ foreach ($providers_weight as $id => $weight) {
+ if ($enabled_providers[$id]) {
+ $provider = $form[$type]['#language_providers'][$id];
+ $provider['weight'] = $weight;
+ $negotiation[$id] = $provider;
+ }
+ }
+
+ language_negotiation_set($type, $negotiation);
+ variable_set("locale_language_providers_weight_$type", $providers_weight);
+ }
+
+ // Update non-configurable language types and the related language negotiation
+ // configuration.
+ language_types_set();
+
+ $form_state['redirect'] = 'admin/config/regional/language/configure';
+ drupal_set_message(t('Language negotiation configuration saved.'));
+}
+
+/**
+ * The URL language provider configuration form.
+ */
+function locale_language_providers_url_form($form, &$form_state) {
+ $form['locale_language_negotiation_url_part'] = array(
+ '#title' => t('Part of the URL that determines language'),
+ '#type' => 'radios',
+ '#options' => array(
+ LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX => t('Path prefix'),
+ LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN => t('Domain'),
+ ),
+ '#default_value' => variable_get('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX),
+ );
+
+ $form['prefix'] = array(
+ '#type' => 'fieldset',
+ '#tree' => TRUE,
+ '#title' => t('Path prefix configuration'),
+ '#description' => t('Language codes or other custom text to use as a path prefix for URL language detection. For the default language, this value may be left blank. <strong>Modifying this value may break existing URLs. Use with caution in a production environment.</strong> Example: Specifying "deutsch" as the path prefix code for German results in URLs like "example.com/deutsch/contact".'),
+ '#states' => array(
+ 'visible' => array(
+ ':input[name="locale_language_negotiation_url_part"]' => array(
+ 'value' => (string) LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX,
+ ),
+ ),
+ ),
+ );
+ $form['domain'] = array(
+ '#type' => 'fieldset',
+ '#tree' => TRUE,
+ '#title' => t('Domain configuration'),
+ '#description' => t('The domain names to use for these languages. Leave blank for the default language. Use with caution in a production environment.<strong>Modifying this value may break existing URLs. Use with caution in a production environment.</strong> Example: Specifying "de.example.com" as language domain for German will result in an URL like "http://de.example.com/contact".'),
+ '#states' => array(
+ 'visible' => array(
+ ':input[name="locale_language_negotiation_url_part"]' => array(
+ 'value' => (string) LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN,
+ ),
+ ),
+ ),
+ );
+
+ $languages = language_list('enabled');
+ foreach ($languages[1] as $langcode => $language) {
+ $form['prefix'][$langcode] = array(
+ '#type' => 'textfield',
+ '#title' => t('%language (%langcode) path prefix', array('%language' => $language->name, '%langcode' => $language->language)),
+ '#maxlength' => 64,
+ '#default_value' => $language->prefix,
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q=')
+
+ );
+ $form['domain'][$langcode] = array(
+ '#type' => 'textfield',
+ '#title' => t('%language (%langcode) domain', array('%language' => $language->name, '%langcode' => $language->language)),
+ '#maxlength' => 128,
+ '#default_value' => $language->domain,
+ );
+ }
+
+ $form_state['redirect'] = 'admin/config/regional/language/configure';
+
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+ return $form;
+}
+
+/**
+ * Validation handler for url provider configuration.
+ *
+ * Validate that the prefixes and domains are unique, and make sure that
+ * the prefix and domain are only blank for the default.
+ */
+function locale_language_providers_url_form_validate($form, &$form_state) {
+ $languages = locale_language_list();
+ $default = language_default();
+
+ // Count repeated values for uniqueness check.
+ $count = array_count_values($form_state['values']['prefix']);
+ foreach ($languages as $langcode => $name) {
+ $value = $form_state['values']['prefix'][$langcode];
+
+ if ($value === '') {
+ if ($default->language != $langcode && $form_state['values']['locale_language_negotiation_url_part'] == LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX) {
+ // Validation error if the prefix is blank for a non-default language, and value is for selected negotiation type.
+ form_error($form['prefix'][$langcode], t('The prefix may only be left blank for the default language.'));
+ }
+ }
+ else if (isset($count[$value]) && $count[$value] > 1) {
+ // Validation error if there are two languages with the same domain/prefix.
+ form_error($form['prefix'][$langcode], t('The prefix for %language, %value, is not unique.', array( '%language' => $name, '%value' => $value )));
+ }
+ }
+
+ // Count repeated values for uniqueness check.
+ $count = array_count_values($form_state['values']['domain']);
+ foreach ($languages as $langcode => $name) {
+ $value = $form_state['values']['domain'][$langcode];
+
+ if ($value === '') {
+ if ($default->language != $langcode && $form_state['values']['locale_language_negotiation_url_part'] == LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN) {
+ // Validation error if the domain is blank for a non-default language, and value is for selected negotiation type.
+ form_error($form['domain'][$langcode], t('The domain may only be left blank for the default language.'));
+ }
+ }
+ else if (isset($count[$value]) && $count[$value] > 1) {
+ // Validation error if there are two languages with the same domain/domain.
+ form_error($form['domain'][$langcode], t('The domain for %language, %value, is not unique.', array( '%language' => $name, '%value' => $value )));
+ }
+ }
+}
+
+/**
+ * Save URL negotiation provider settings.
+ */
+function locale_language_providers_url_form_submit($form, &$form_state) {
+
+ // Save selected format (prefix or domain).
+ variable_set('locale_language_negotiation_url_part', $form_state['values']['locale_language_negotiation_url_part']);
+
+ $languages = language_list('enabled');
+ $default = language_default();
+
+ foreach ($languages[1] as $langcode => $language) {
+
+ // Add new prefix and domain settings to the language object.
+ foreach (array('prefix', 'domain') as $type) {
+ if (isset($form_state['values'][$type][$langcode])) {
+ $language->$type = $form_state['values'][$type][$langcode];
+ }
+ }
+
+ // Save the prefix and domain settings for each language.
+ locale_language_save($language);
+ }
+
+ drupal_set_message(t('Configuration saved.'));
+}
+
+/**
+ * The URL language provider configuration form.
+ */
+function locale_language_providers_session_form($form, &$form_state) {
+ $form['locale_language_negotiation_session_param'] = array(
+ '#title' => t('Request/session parameter'),
+ '#type' => 'textfield',
+ '#default_value' => variable_get('locale_language_negotiation_session_param', 'language'),
+ '#description' => t('Name of the request/session parameter used to determine the desired language.'),
+ );
+
+ $form_state['redirect'] = 'admin/config/regional/language/configure';
+
+ return system_settings_form($form);
+}
+
+/**
+ * @} End of "locale-language-administration"
+ */
+
+/**
+ * Returns HTML for a locale date format form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_locale_date_format_form($variables) {
+ $form = $variables['form'];
+ $header = array(
+ t('Date type'),
+ t('Format'),
+ );
+
+ foreach (element_children($form['date_formats']) as $key) {
+ $row = array();
+ $row[] = $form['date_formats'][$key]['#title'];
+ unset($form['date_formats'][$key]['#title']);
+ $row[] = array('data' => drupal_render($form['date_formats'][$key]));
+ $rows[] = $row;
+ }
+
+ $output = drupal_render($form['language']);
+ $output .= theme('table', array('header' => $header, 'rows' => $rows));
+ $output .= drupal_render_children($form);
+
+ return $output;
+}
+
+/**
+ * Display edit date format links for each language.
+ */
+function locale_date_format_language_overview_page() {
+ $header = array(
+ t('Language'),
+ array('data' => t('Operations'), 'colspan' => '2'),
+ );
+
+ // Get list of languages.
+ $languages = locale_language_list();
+
+ foreach ($languages as $langcode => $info) {
+ $row = array();
+ $row[] = $languages[$langcode];
+ $row[] = l(t('edit'), 'admin/config/regional/date-time/locale/' . $langcode . '/edit');
+ $row[] = l(t('reset'), 'admin/config/regional/date-time/locale/' . $langcode . '/reset');
+ $rows[] = $row;
+ }
+
+ return theme('table', array('header' => $header, 'rows' => $rows));
+}
+
+/**
+ * Provide date localization configuration options to users.
+ */
+function locale_date_format_form($form, &$form_state, $langcode) {
+ $languages = locale_language_list();
+ $language_name = $languages[$langcode];
+
+ // Display the current language name.
+ $form['language'] = array(
+ '#type' => 'item',
+ '#title' => t('Language'),
+ '#markup' => check_plain($language_name),
+ '#weight' => -10,
+ );
+ $form['langcode'] = array(
+ '#type' => 'value',
+ '#value' => $langcode,
+ );
+
+ // Get list of date format types.
+ $types = system_get_date_types();
+
+ // Get list of available formats.
+ $formats = system_get_date_formats();
+ $choices = array();
+ foreach ($formats as $type => $list) {
+ foreach ($list as $f => $format) {
+ $choices[$f] = format_date(REQUEST_TIME, 'custom', $f);
+ }
+ }
+ reset($formats);
+
+ // Get configured formats for each language.
+ $locale_formats = system_date_format_locale($langcode);
+ // Display a form field for each format type.
+ foreach ($types as $type => $type_info) {
+ if (!empty($locale_formats) && in_array($type, array_keys($locale_formats))) {
+ $default = $locale_formats[$type];
+ }
+ else {
+ $default = variable_get('date_format_' . $type, key($formats));
+ }
+
+ // Show date format select list.
+ $form['date_formats']['date_format_' . $type] = array(
+ '#type' => 'select',
+ '#title' => check_plain($type_info['title']),
+ '#attributes' => array('class' => array('date-format')),
+ '#default_value' => (isset($choices[$default]) ? $default : 'custom'),
+ '#options' => $choices,
+ );
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for configuring localized date formats on the locale_date_format_form.
+ */
+function locale_date_format_form_submit($form, &$form_state) {
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ $langcode = $form_state['values']['langcode'];
+
+ // Get list of date format types.
+ $types = system_get_date_types();
+ foreach ($types as $type => $type_info) {
+ $format = $form_state['values']['date_format_' . $type];
+ if ($format == 'custom') {
+ $format = $form_state['values']['date_format_' . $type . '_custom'];
+ }
+ locale_date_format_save($langcode, $type, $format);
+ }
+ drupal_set_message(t('Configuration saved.'));
+ $form_state['redirect'] = 'admin/config/regional/date-time/locale';
+}
+
+/**
+ * Reset locale specific date formats to the global defaults.
+ *
+ * @param $langcode
+ * Language code, e.g. 'en'.
+ */
+function locale_date_format_reset_form($form, &$form_state, $langcode) {
+ $form['langcode'] = array('#type' => 'value', '#value' => $langcode);
+ $languages = language_list();
+ return confirm_form($form,
+ t('Are you sure you want to reset the date formats for %language to the global defaults?', array('%language' => $languages[$langcode]->name)),
+ 'admin/config/regional/date-time/locale',
+ t('Resetting will remove all localized date formats for this language. This action cannot be undone.'),
+ t('Reset'), t('Cancel'));
+}
+
+/**
+ * Reset date formats for a specific language to global defaults.
+ */
+function locale_date_format_reset_form_submit($form, &$form_state) {
+ db_delete('date_format_locale')
+ ->condition('language', $form_state['values']['langcode'])
+ ->execute();
+ $form_state['redirect'] = 'admin/config/regional/date-time/locale';
+}
diff --git a/core/modules/locale/locale.api.php b/core/modules/locale/locale.api.php
new file mode 100644
index 000000000000..7b2712edbd1c
--- /dev/null
+++ b/core/modules/locale/locale.api.php
@@ -0,0 +1,217 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Locale module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Allows modules to act after language initialization has been performed.
+ *
+ * This is primarily needed to provide translation for configuration variables
+ * in the proper bootstrap phase. Variables are user-defined strings and
+ * therefore should not be translated via t(), since the source string can
+ * change without notice and any previous translation would be lost. Moreover,
+ * since variables can be used in the bootstrap phase, we need a bootstrap hook
+ * to provide a translation early enough to avoid misalignments between code
+ * using the original values and code using the translated values. However
+ * modules implementing hook_boot() should be aware that language initialization
+ * did not happen yet and thus they cannot rely on translated variables.
+ */
+function hook_language_init() {
+ global $language, $conf;
+
+ switch ($language->language) {
+ case 'it':
+ $conf['site_name'] = 'Il mio sito Drupal';
+ break;
+
+ case 'fr':
+ $conf['site_name'] = 'Mon site Drupal';
+ break;
+ }
+}
+
+/**
+ * Perform alterations on language switcher links.
+ *
+ * A language switcher link may need to point to a different path or use a
+ * translated link text before going through l(), which will just handle the
+ * path aliases.
+ *
+ * @param $links
+ * Nested array of links keyed by language code.
+ * @param $type
+ * The language type the links will switch.
+ * @param $path
+ * The current path.
+ */
+function hook_language_switch_links_alter(array &$links, $type, $path) {
+ global $language;
+
+ if ($type == LANGUAGE_TYPE_CONTENT && isset($links[$language->language])) {
+ foreach ($links[$language->language] as $link) {
+ $link['attributes']['class'][] = 'active-language';
+ }
+ }
+}
+
+/**
+ * Allow modules to define their own language types.
+ *
+ * @return
+ * An array of language type definitions. Each language type has an identifier
+ * key. The language type definition is an associative array that may contain
+ * the following key-value pairs:
+ * - "name": The human-readable language type identifier.
+ * - "description": A description of the language type.
+ * - "fixed": An array of language provider identifiers. Defining this key
+ * makes the language type non-configurable.
+ */
+function hook_language_types_info() {
+ return array(
+ 'custom_language_type' => array(
+ 'name' => t('Custom language'),
+ 'description' => t('A custom language type.'),
+ ),
+ 'fixed_custom_language_type' => array(
+ 'fixed' => array('custom_language_provider'),
+ ),
+ );
+}
+
+/**
+ * Perform alterations on language types.
+ *
+ * @param $language_types
+ * Array of language type definitions.
+ */
+function hook_language_types_info_alter(array &$language_types) {
+ if (isset($language_types['custom_language_type'])) {
+ $language_types['custom_language_type_custom']['description'] = t('A far better description.');
+ }
+}
+
+/**
+ * Allow modules to define their own language providers.
+ *
+ * @return
+ * An array of language provider definitions. Each language provider has an
+ * identifier key. The language provider definition is an associative array
+ * that may contain the following key-value pairs:
+ * - "types": An array of allowed language types. If a language provider does
+ * not specify which language types it should be used with, it will be
+ * available for all the configurable language types.
+ * - "callbacks": An array of functions that will be called to perform various
+ * tasks. Possible key-value pairs are:
+ * - "language": Required. The callback that will determine the language
+ * value.
+ * - "switcher": The callback that will determine the language switch links
+ * associated to the current language provider.
+ * - "url_rewrite": The callback that will provide URL rewriting.
+ * - "file": A file that will be included before the callback is invoked; this
+ * allows callback functions to be in separate files.
+ * - "weight": The default weight the language provider has.
+ * - "name": A human-readable identifier.
+ * - "description": A description of the language provider.
+ * - "config": An internal path pointing to the language provider
+ * configuration page.
+ * - "cache": The value Drupal's page cache should be set to for the current
+ * language provider to be invoked.
+ */
+function hook_language_negotiation_info() {
+ return array(
+ 'custom_language_provider' => array(
+ 'callbacks' => array(
+ 'language' => 'custom_language_provider_callback',
+ 'switcher' => 'custom_language_switcher_callback',
+ 'url_rewrite' => 'custom_language_url_rewrite_callback',
+ ),
+ 'file' => drupal_get_path('module', 'custom') . '/custom.module',
+ 'weight' => -4,
+ 'types' => array('custom_language_type'),
+ 'name' => t('Custom language provider'),
+ 'description' => t('This is a custom language provider.'),
+ 'cache' => 0,
+ ),
+ );
+}
+
+/**
+ * Perform alterations on language providers.
+ *
+ * @param $language_providers
+ * Array of language provider definitions.
+ */
+function hook_language_negotiation_info_alter(array &$language_providers) {
+ if (isset($language_providers['custom_language_provider'])) {
+ $language_providers['custom_language_provider']['config'] = 'admin/config/regional/language/configure/custom-language-provider';
+ }
+}
+
+/**
+ * Perform alterations on the language fallback candidates.
+ *
+ * @param $fallback_candidates
+ * An array of language codes whose order will determine the language fallback
+ * order.
+ */
+function hook_language_fallback_candidates_alter(array &$fallback_candidates) {
+ $fallback_candidates = array_reverse($fallback_candidates);
+}
+
+ /**
+ * React to a language about to be added or updated in the system.
+ *
+ * @param $language
+ * A language object.
+ */
+function hook_locale_language_presave($language) {
+ if ($language->default) {
+ // React to a new default language.
+ example_new_default_language($language);
+ }
+}
+
+/**
+ * React to a language that was just added to the system.
+ *
+ * @param $language
+ * A language object.
+ */
+function hook_locale_language_insert($language) {
+ example_refresh_permissions();
+}
+
+/**
+ * React to a language that was just updated in the system.
+ *
+ * @param $language
+ * A language object.
+ */
+function hook_locale_language_update($language) {
+ example_refresh_permissions();
+}
+
+/**
+ * Allow modules to react before the deletion of a language.
+ *
+ * @param $language
+ * The language object of the language that is about to be deleted.
+ */
+function hook_locale_language_delete($language) {
+ // On nodes with this language, unset the language
+ db_update('node')
+ ->fields(array('language' => ''))
+ ->condition('language', $language->language)
+ ->execute();
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/locale/locale.bulk.inc b/core/modules/locale/locale.bulk.inc
new file mode 100644
index 000000000000..45a31bdcb719
--- /dev/null
+++ b/core/modules/locale/locale.bulk.inc
@@ -0,0 +1,247 @@
+<?php
+
+/**
+ * @file
+ * Mass import-export and batch import functionality for Gettext .po files.
+ */
+
+include_once DRUPAL_ROOT . '/core/includes/gettext.inc';
+
+/**
+ * User interface for the translation import screen.
+ */
+function locale_translate_import_form($form) {
+ // Get all languages, except English
+ drupal_static_reset('language_list');
+ $names = locale_language_list('name');
+ if (!locale_translate_english()) {
+ unset($names['en']);
+ }
+
+ if (!count($names)) {
+ $languages = _locale_prepare_predefined_list();
+ $default = key($languages);
+ }
+ else {
+ $languages = array(
+ t('Already added languages') => $names,
+ t('Languages not yet added') => _locale_prepare_predefined_list()
+ );
+ $default = key($names);
+ }
+
+ $form['import'] = array('#type' => 'fieldset',
+ '#title' => t('Import translation'),
+ );
+ $form['import']['file'] = array('#type' => 'file',
+ '#title' => t('Language file'),
+ '#size' => 50,
+ '#description' => t('A Gettext Portable Object (<em>.po</em>) file.'),
+ );
+ $form['import']['langcode'] = array('#type' => 'select',
+ '#title' => t('Import into'),
+ '#options' => $languages,
+ '#default_value' => $default,
+ '#description' => t('Choose the language you want to add strings into. If you choose a language which is not yet set up, it will be added.'),
+ );
+ $form['import']['mode'] = array('#type' => 'radios',
+ '#title' => t('Mode'),
+ '#default_value' => LOCALE_IMPORT_KEEP,
+ '#options' => array(
+ LOCALE_IMPORT_OVERWRITE => t('Strings in the uploaded file replace existing ones, new ones are added. The plural format is updated.'),
+ LOCALE_IMPORT_KEEP => t('Existing strings and the plural format are kept, only new strings are added.')
+ ),
+ );
+ $form['import']['submit'] = array('#type' => 'submit', '#value' => t('Import'));
+
+ return $form;
+}
+
+/**
+ * Process the locale import form submission.
+ */
+function locale_translate_import_form_submit($form, &$form_state) {
+ $validators = array('file_validate_extensions' => array('po'));
+ // Ensure we have the file uploaded
+ if ($file = file_save_upload('file', $validators)) {
+
+ // Add language, if not yet supported
+ drupal_static_reset('language_list');
+ $languages = language_list('language');
+ $langcode = $form_state['values']['langcode'];
+ if (!isset($languages[$langcode])) {
+ include_once DRUPAL_ROOT . '/core/includes/standard.inc';
+ $predefined = standard_language_list();
+ $language = (object) array(
+ 'language' => $langcode,
+ );
+ locale_language_save($language);
+ drupal_set_message(t('The language %language has been created.', array('%language' => t($predefined[$langcode][0]))));
+ }
+
+ // Now import strings into the language
+ if ($return = _locale_import_po($file, $langcode, $form_state['values']['mode']) == FALSE) {
+ $variables = array('%filename' => $file->filename);
+ drupal_set_message(t('The translation import of %filename failed.', $variables), 'error');
+ watchdog('locale', 'The translation import of %filename failed.', $variables, WATCHDOG_ERROR);
+ }
+ }
+ else {
+ drupal_set_message(t('File to import not found.'), 'error');
+ $form_state['redirect'] = 'admin/config/regional/translate/import';
+ return;
+ }
+
+ $form_state['redirect'] = 'admin/config/regional/translate';
+ return;
+}
+
+/**
+ * User interface for the translation export screen.
+ */
+function locale_translate_export_screen() {
+ // Get all languages, except English
+ drupal_static_reset('language_list');
+ $names = locale_language_list('name');
+ if (!locale_translate_english()) {
+ unset($names['en']);
+ }
+ $output = '';
+ // Offer translation export if any language is set up.
+ if (count($names)) {
+ $elements = drupal_get_form('locale_translate_export_po_form', $names);
+ $output = drupal_render($elements);
+ }
+ $elements = drupal_get_form('locale_translate_export_pot_form');
+ $output .= drupal_render($elements);
+ return $output;
+}
+
+/**
+ * Form to export PO files for the languages provided.
+ *
+ * @param $names
+ * An associate array with localized language names
+ */
+function locale_translate_export_po_form($form, &$form_state, $names) {
+ $form['export_title'] = array('#type' => 'item',
+ '#title' => t('Export translation'),
+ );
+ $form['langcode'] = array('#type' => 'select',
+ '#title' => t('Language name'),
+ '#options' => $names,
+ '#description' => t('Select the language to export in Gettext Portable Object (<em>.po</em>) format.'),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Export'));
+ return $form;
+}
+
+/**
+ * Translation template export form.
+ */
+function locale_translate_export_pot_form() {
+ // Complete template export of the strings
+ $form['export_title'] = array('#type' => 'item',
+ '#title' => t('Export template'),
+ '#description' => t('Generate a Gettext Portable Object Template (<em>.pot</em>) file with all strings from the Drupal locale database.'),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Export'));
+ // Reuse PO export submission callback.
+ $form['#submit'][] = 'locale_translate_export_po_form_submit';
+ return $form;
+}
+
+/**
+ * Process a translation (or template) export form submission.
+ */
+function locale_translate_export_po_form_submit($form, &$form_state) {
+ // If template is required, language code is not given.
+ $language = NULL;
+ if (isset($form_state['values']['langcode'])) {
+ $languages = language_list();
+ $language = $languages[$form_state['values']['langcode']];
+ }
+ _locale_export_po($language, _locale_export_po_generate($language, _locale_export_get_strings($language)));
+}
+
+/**
+ * Prepare a batch to import all translations.
+ *
+ * @param $langcode
+ * (optional) Language code to limit files being imported.
+ * @param $finish_feedback
+ * (optional) Whether to give feedback to the user when finished.
+ *
+ * @todo
+ * Integrate with update status to identify projects needed and integrate
+ * l10n_update functionality to feed in translation files alike.
+ * See http://drupal.org/node/1191488.
+ */
+function locale_translate_batch_import_files($langcode = NULL, $finish_feedback = FALSE) {
+ $directory = variable_get('locale_translate_file_directory', conf_path() . '/files/translations');
+ $files = file_scan_directory($directory, '!' . (!empty($langcode) ? '\.' . preg_quote($langcode, '!') : '') . '\.po$!', array('recurse' => FALSE));
+ return locale_translate_batch_build($files, $finish_feedback);
+}
+
+/**
+ * Build a locale batch from an array of files.
+ *
+ * @param $files
+ * Array of file objects to import.
+ * @param $finish_feedback
+ * (optional) Whether to give feedback to the user when finished.
+ *
+ * @return
+ * A batch structure or FALSE if $files was empty.
+ */
+function locale_translate_batch_build($files, $finish_feedback = FALSE) {
+ $t = get_t();
+ if (count($files)) {
+ $operations = array();
+ foreach ($files as $file) {
+ // We call locale_translate_batch_import for every batch operation.
+ $operations[] = array('locale_translate_batch_import', array($file->uri));
+ }
+ $batch = array(
+ 'operations' => $operations,
+ 'title' => $t('Importing interface translations'),
+ 'init_message' => $t('Starting import'),
+ 'error_message' => $t('Error importing interface translations'),
+ 'file' => drupal_get_path('module', 'locale') . '/locale.bulk.inc',
+ );
+ if ($finish_feedback) {
+ $batch['finished'] = 'locale_translate_batch_finished';
+ }
+ return $batch;
+ }
+ return FALSE;
+}
+
+/**
+ * Perform interface translation import as a batch step.
+ *
+ * @param $filepath
+ * Path to a file to import.
+ * @param $results
+ * Contains a list of files imported.
+ */
+function locale_translate_batch_import($filepath, &$context) {
+ // The filename is either {langcode}.po or {prefix}.{langcode}.po, so
+ // we can extract the language code to use for the import from the end.
+ if (preg_match('!(/|\.)([^\./]+)\.po$!', $filepath, $langcode)) {
+ $file = (object) array('filename' => basename($filepath), 'uri' => $filepath);
+ _locale_import_read_po('db-store', $file, LOCALE_IMPORT_KEEP, $langcode[2]);
+ $context['results'][] = $filepath;
+ }
+}
+
+/**
+ * Finished callback of system page locale import batch.
+ */
+function locale_translate_batch_finished($success, $results) {
+ if ($success) {
+ drupal_set_message(format_plural(count($results), 'One translation file imported.', '@count translation files imported.'));
+ }
+}
diff --git a/core/modules/locale/locale.css b/core/modules/locale/locale.css
new file mode 100644
index 000000000000..38112b506071
--- /dev/null
+++ b/core/modules/locale/locale.css
@@ -0,0 +1,32 @@
+
+.locale-untranslated {
+ font-style: normal;
+ text-decoration: line-through;
+}
+
+#locale-translation-filter-form .form-item-language,
+#locale-translation-filter-form .form-item-translation,
+#locale-translation-filter-form .form-item-group {
+ float: left; /* LTR */
+ padding-right: .8em; /* LTR */
+ margin: 0.1em;
+ /**
+ * In Opera 9, DOM elements with the property of "overflow: auto"
+ * will partially hide its contents with unnecessary scrollbars when
+ * its immediate child is floated without an explicit width set.
+ */
+ width: 15em;
+}
+#locale-translation-filter-form .form-type-select select {
+ width: 100%;
+}
+#locale-translation-filter-form .form-actions {
+ float: left; /* LTR */
+ padding: 3ex 0 0 1em; /* LTR */
+}
+.language-switcher-locale-session a.active {
+ color: #0062A0;
+}
+.language-switcher-locale-session a.session-active {
+ color: #000000;
+}
diff --git a/core/modules/locale/locale.datepicker.js b/core/modules/locale/locale.datepicker.js
new file mode 100644
index 000000000000..81f1e17b3cff
--- /dev/null
+++ b/core/modules/locale/locale.datepicker.js
@@ -0,0 +1,69 @@
+(function ($) {
+
+$.datepicker.regional['drupal-locale'] = {
+ closeText: Drupal.t('Done'),
+ prevText: Drupal.t('Prev'),
+ nextText: Drupal.t('Next'),
+ currentText: Drupal.t('Today'),
+ monthNames: [
+ Drupal.t('January'),
+ Drupal.t('February'),
+ Drupal.t('March'),
+ Drupal.t('April'),
+ Drupal.t('May'),
+ Drupal.t('June'),
+ Drupal.t('July'),
+ Drupal.t('August'),
+ Drupal.t('September'),
+ Drupal.t('October'),
+ Drupal.t('November'),
+ Drupal.t('December')
+ ],
+ monthNamesShort: [
+ Drupal.t('Jan'),
+ Drupal.t('Feb'),
+ Drupal.t('Mar'),
+ Drupal.t('Apr'),
+ Drupal.t('May'),
+ Drupal.t('Jun'),
+ Drupal.t('Jul'),
+ Drupal.t('Aug'),
+ Drupal.t('Sep'),
+ Drupal.t('Oct'),
+ Drupal.t('Nov'),
+ Drupal.t('Dec')
+ ],
+ dayNames: [
+ Drupal.t('Sunday'),
+ Drupal.t('Monday'),
+ Drupal.t('Tuesday'),
+ Drupal.t('Wednesday'),
+ Drupal.t('Thursday'),
+ Drupal.t('Friday'),
+ Drupal.t('Saturday')
+ ],
+ dayNamesShort: [
+ Drupal.t('Sun'),
+ Drupal.t('Mon'),
+ Drupal.t('Tue'),
+ Drupal.t('Wed'),
+ Drupal.t('Thu'),
+ Drupal.t('Fri'),
+ Drupal.t('Sat')
+ ],
+ dayNamesMin: [
+ Drupal.t('Su'),
+ Drupal.t('Mo'),
+ Drupal.t('Tu'),
+ Drupal.t('We'),
+ Drupal.t('Th'),
+ Drupal.t('Fr'),
+ Drupal.t('Sa')
+ ],
+ dateFormat: Drupal.t('mm/dd/yy'),
+ firstDay: Drupal.settings.jqueryuidatepicker.firstDay,
+ isRTL: Drupal.settings.jqueryuidatepicker.rtl
+};
+$.datepicker.setDefaults($.datepicker.regional['drupal-locale']);
+
+})(jQuery);
diff --git a/core/modules/locale/locale.info b/core/modules/locale/locale.info
new file mode 100644
index 000000000000..748d274d50a4
--- /dev/null
+++ b/core/modules/locale/locale.info
@@ -0,0 +1,7 @@
+name = Locale
+description = Adds language handling functionality and enables the translation of the user interface to languages other than English.
+package = Core
+version = VERSION
+core = 8.x
+files[] = locale.test
+configure = admin/config/regional/language
diff --git a/core/modules/locale/locale.install b/core/modules/locale/locale.install
new file mode 100644
index 000000000000..77da39c76d84
--- /dev/null
+++ b/core/modules/locale/locale.install
@@ -0,0 +1,255 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the locale module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function locale_install() {
+ // Add the default language to the database too.
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ locale_language_save(language_default());
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function locale_uninstall() {
+ // Delete all JavaScript translation files.
+ $locale_js_directory = 'public://' . variable_get('locale_js_directory', 'languages');
+
+ if (is_dir($locale_js_directory)) {
+ $files = db_query('SELECT language, javascript FROM {languages}');
+ foreach ($files as $file) {
+ if (!empty($file->javascript)) {
+ file_unmanaged_delete($locale_js_directory . '/' . $file->language . '_' . $file->javascript . '.js');
+ }
+ }
+ // Delete the JavaScript translations directory if empty.
+ if (!file_scan_directory($locale_js_directory, '/.*/')) {
+ drupal_rmdir($locale_js_directory);
+ }
+ }
+
+ // Clear variables.
+ variable_del('language_default');
+ variable_del('language_count');
+ variable_del('language_types');
+ variable_del('locale_language_negotiation_url_part');
+ variable_del('locale_language_negotiation_session_param');
+ variable_del('language_content_type_default');
+ variable_del('language_content_type_negotiation');
+ variable_del('locale_cache_strings');
+ variable_del('locale_js_directory');
+ variable_del('javascript_parsed');
+ variable_del('locale_field_language_fallback');
+ variable_del('locale_cache_length');
+
+ foreach (language_types() as $type) {
+ variable_del("language_negotiation_$type");
+ variable_del("locale_language_providers_weight_$type");
+ }
+
+ foreach (node_type_get_types() as $type => $content_type) {
+ $setting = variable_del("language_content_type_$type");
+ }
+
+ // Switch back to English: with a $language->language value different from 'en'
+ // successive calls of t() might result in calling locale(), which in turn might
+ // try to query the unexisting {locales_source} and {locales_target} tables.
+ drupal_language_initialize();
+
+}
+
+/**
+ * Implements hook_schema().
+ */
+function locale_schema() {
+ $schema['languages'] = array(
+ 'description' => 'List of all available languages in the system.',
+ 'fields' => array(
+ 'language' => array(
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "Language code, e.g. 'de' or 'en-US'.",
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Language name.',
+ ),
+ 'direction' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Direction of language (Left-to-Right = 0, Right-to-Left = 1).',
+ ),
+ 'enabled' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Enabled flag (1 = Enabled, 0 = Disabled).',
+ ),
+ 'plurals' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Number of plural indexes in this language.',
+ ),
+ 'formula' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Plural formula in PHP code to evaluate to get plural indexes.',
+ ),
+ 'domain' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Domain to use for this language.',
+ ),
+ 'prefix' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Path prefix to use for this language.',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Weight, used in lists of languages.',
+ ),
+ 'javascript' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Location of JavaScript translation file.',
+ ),
+ ),
+ 'primary key' => array('language'),
+ 'indexes' => array(
+ 'list' => array('weight', 'name'),
+ ),
+ );
+
+ $schema['locales_source'] = array(
+ 'description' => 'List of English source strings.',
+ 'fields' => array(
+ 'lid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Unique identifier of this string.',
+ ),
+ 'location' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'description' => 'Drupal path in case of online discovered translations or file path in case of imported strings.',
+ ),
+ 'source' => array(
+ 'type' => 'text',
+ 'mysql_type' => 'blob',
+ 'not null' => TRUE,
+ 'description' => 'The original string in English.',
+ ),
+ 'context' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The context this string applies to.',
+ ),
+ 'version' => array(
+ 'type' => 'varchar',
+ 'length' => 20,
+ 'not null' => TRUE,
+ 'default' => 'none',
+ 'description' => 'Version of Drupal, where the string was last used (for locales optimization).',
+ ),
+ ),
+ 'primary key' => array('lid'),
+ 'indexes' => array(
+ 'source_context' => array(array('source', 30), 'context'),
+ ),
+ );
+
+ $schema['locales_target'] = array(
+ 'description' => 'Stores translated versions of strings.',
+ 'fields' => array(
+ 'lid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Source string ID. References {locales_source}.lid.',
+ ),
+ 'translation' => array(
+ 'type' => 'text',
+ 'mysql_type' => 'blob',
+ 'not null' => TRUE,
+ 'description' => 'Translation string value in this language.',
+ ),
+ 'language' => array(
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Language code. References {languages}.language.',
+ ),
+ 'plid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE, // This should be NULL for no referenced string, not zero.
+ 'default' => 0,
+ 'description' => 'Parent lid (lid of the previous string in the plural chain) in case of plural strings. References {locales_source}.lid.',
+ ),
+ 'plural' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Plural index number in case of plural strings.',
+ ),
+ ),
+ 'primary key' => array('language', 'lid', 'plural'),
+ 'foreign keys' => array(
+ 'locales_source' => array(
+ 'table' => 'locales_source',
+ 'columns' => array('lid' => 'lid'),
+ ),
+ ),
+ 'indexes' => array(
+ 'lid' => array('lid'),
+ 'plid' => array('plid'),
+ 'plural' => array('plural'),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * @addtogroup updates-7.x-to-8.x
+ * @{
+ */
+
+/**
+ * Remove the native language name column.
+ */
+function locale_update_8000() {
+ db_drop_field('languages', 'native');
+}
+
+/**
+ * @} End of "addtogroup updates-7.x-to-8.x"
+ * The next series of updates should start at 9000.
+ */
diff --git a/core/modules/locale/locale.module b/core/modules/locale/locale.module
new file mode 100644
index 000000000000..1c787e9e96ca
--- /dev/null
+++ b/core/modules/locale/locale.module
@@ -0,0 +1,1174 @@
+<?php
+
+/**
+ * @file
+ * Add language handling functionality and enables the translation of the
+ * user interface to languages other than English.
+ *
+ * When enabled, multiple languages can be set up. The site interface
+ * can be displayed in different languages, as well as nodes can have languages
+ * assigned. The setup of languages and translations is completely web based.
+ * Gettext portable object files are supported.
+ */
+
+// ---------------------------------------------------------------------------------
+// Hook implementations
+
+/**
+ * Implements hook_help().
+ */
+function locale_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#locale':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Locale module allows your Drupal site to be presented in languages other than the default English, and to be multilingual. The Locale module works by maintaining a database of translations, and examining text as it is about to be displayed. When a translation of the text is available in the language to be displayed, the translation is displayed rather than the original text. When a translation is unavailable, the original text is displayed, and then stored for review by a translator. For more information, see the online handbook entry for <a href="@locale">Locale module</a>.', array('@locale' => 'http://drupal.org/handbook/modules/locale/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Translating interface text') . '</dt>';
+ $output .= '<dd>' . t('Translations of text in the Drupal interface may be provided by:');
+ $output .= '<ul>';
+ $output .= '<li>' . t("Translating within your site, using the Locale module's integrated <a href='@translate'>translation interface</a>.", array('@translate' => url('admin/config/regional/translate'))) . '</li>';
+ $output .= '<li>' . t('Importing files from a set of existing translations, known as a translation package. A translation package enables the display of a specific version of Drupal in a specific language, and contains files in the Gettext Portable Object (<em>.po</em>) format. Although not all languages are available for every version of Drupal, translation packages for many languages are available for download from the <a href="@translations">Drupal translations page</a>.', array('@translations' => 'http://localize.drupal.org')) . '</li>';
+ $output .= '<li>' . t("If an existing translation package does not meet your needs, the Gettext Portable Object (<em>.po</em>) files within a package may be modified, or new <em>.po</em> files may be created, using a desktop Gettext editor. The Locale module's <a href='@import'>import</a> feature allows the translated strings from a new or modified <em>.po</em> file to be added to your site. The Locale module's <a href='@export'>export</a> feature generates files from your site's translated strings, that can either be shared with others or edited offline by a Gettext translation editor.", array('@import' => url('admin/config/regional/translate/import'), '@export' => url('admin/config/regional/translate/export'))) . '</li>';
+ $output .= '</ul></dd>';
+ $output .= '<dt>' . t('Configuring a multilingual site') . '</dt>';
+ $output .= '<dd>' . t("Language negotiation allows your site to automatically change language based on the domain or path used for each request. Users may (optionally) select their preferred language on their <em>My account</em> page, and your site can be configured to honor a web browser's preferred language settings. Site content can be translated using the <a href='@content-help'>Content translation module</a>.", array('@content-help' => url('admin/help/translation'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/config/regional/language':
+ $output = '<p>' . t('With multiple languages enabled, interface text can be translated, registered users may select their preferred language, and authors can assign a specific language to content. <a href="@translations">Download contributed translations</a> from Drupal.org.', array('@translations' => 'http://localize.drupal.org')) . '</p>';
+ return $output;
+
+ case 'admin/config/regional/language/add':
+ return '<p>' . t('Add a language to be supported by your site. If your desired language is not available, pick <em>Custom language...</em> at the end and provide a language code and other details manually.') . '</p>';
+
+ case 'admin/config/regional/language/configure':
+ $output = '<p>' . t("Define how to decide which language is used to display page elements (primarily text provided by Drupal and modules, such as field labels and help text). This decision is made by evaluating a series of detection methods for languages; the first detection method that gets a result will determine which language is used for that type of text. Define the order of evaluation of language detection methods on this page.") . '</p>';
+ return $output;
+
+ case 'admin/config/regional/language/configure/session':
+ $output = '<p>' . t('Determine the language from a request/session parameter. Example: "http://example.com?language=de" sets language to German based on the use of "de" within the "language" parameter.') . '</p>';
+ return $output;
+
+ case 'admin/config/regional/translate':
+ $output = '<p>' . t('This page allows a translator to search for specific translated and untranslated strings, and is used when creating or editing translations. (Note: For translation tasks involving many strings, it may be more convenient to <a href="@export">export</a> strings for offline editing in a desktop Gettext translation editor.) Searches may be limited to strings in a specific language.', array('@export' => url('admin/config/regional/translate/export'))) . '</p>';
+ return $output;
+
+ case 'admin/config/regional/translate/import':
+ $output = '<p>' . t('This page imports the translated strings contained in an individual Gettext Portable Object (<em>.po</em>) file. Normally distributed as part of a translation package (each translation package may contain several <em>.po</em> files), a <em>.po</em> file may need to be imported after offline editing in a Gettext translation editor. Importing an individual <em>.po</em> file may be a lengthy process.') . '</p>';
+ $output .= '<p>' . t('Note that the <em>.po</em> files within a translation package are imported automatically (if available) when new modules or themes are enabled, or as new languages are added. Since this page only allows the import of one <em>.po</em> file at a time, it may be simpler to download and extract a translation package into your Drupal installation directory and <a href="@language-add">add the language</a> (which automatically imports all <em>.po</em> files within the package). Translation packages are available for download on the <a href="@translations">Drupal translation page</a>.', array('@language-add' => url('admin/config/regional/language/add'), '@translations' => 'http://localize.drupal.org')) . '</p>';
+ return $output;
+
+ case 'admin/config/regional/translate/export':
+ return '<p>' . t('This page exports the translated strings used by your site. An export file may be in Gettext Portable Object (<em>.po</em>) form, which includes both the original string and the translation (used to share translations with others), or in Gettext Portable Object Template (<em>.pot</em>) form, which includes the original strings only (used to create new translations with a Gettext translation editor).') . '</p>';
+
+ case 'admin/structure/block/manage/%/%':
+ if ($arg[4] == 'locale' && $arg[5] == 'language') {
+ return '<p>' . t('This block is only shown if <a href="@languages">at least two languages are enabled</a> and <a href="@configuration">language negotiation</a> is set to <em>URL</em> or <em>Session</em>.', array('@languages' => url('admin/config/regional/language'), '@configuration' => url('admin/config/regional/language/configure'))) . '</p>';
+ }
+ break;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function locale_menu() {
+ // Manage languages
+ $items['admin/config/regional/language'] = array(
+ 'title' => 'Languages',
+ 'description' => 'Configure languages for content and the user interface.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_language_overview_form'),
+ 'access arguments' => array('administer languages'),
+ 'file' => 'locale.admin.inc',
+ 'weight' => -10,
+ );
+ $items['admin/config/regional/language/overview'] = array(
+ 'title' => 'List',
+ 'weight' => 0,
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/config/regional/language/add'] = array(
+ 'title' => 'Add language',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_languages_add_form'),
+ 'access arguments' => array('administer languages'),
+ 'weight' => 5,
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'locale.admin.inc',
+ );
+ $items['admin/config/regional/language/configure'] = array(
+ 'title' => 'Detection and selection',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_languages_configure_form'),
+ 'access arguments' => array('administer languages'),
+ 'weight' => 10,
+ 'file' => 'locale.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ );
+ $items['admin/config/regional/language/configure/url'] = array(
+ 'title' => 'URL language detection configuration',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_language_providers_url_form'),
+ 'access arguments' => array('administer languages'),
+ 'file' => 'locale.admin.inc',
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ );
+ $items['admin/config/regional/language/configure/session'] = array(
+ 'title' => 'Session language detection configuration',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_language_providers_session_form'),
+ 'access arguments' => array('administer languages'),
+ 'file' => 'locale.admin.inc',
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ );
+ $items['admin/config/regional/language/edit/%language'] = array(
+ 'title' => 'Edit language',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_languages_edit_form', 5),
+ 'access arguments' => array('administer languages'),
+ 'file' => 'locale.admin.inc',
+ );
+ $items['admin/config/regional/language/delete/%language'] = array(
+ 'title' => 'Confirm',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_languages_delete_form', 5),
+ 'access arguments' => array('administer languages'),
+ 'file' => 'locale.admin.inc',
+ );
+
+ // Translation functionality
+ $items['admin/config/regional/translate'] = array(
+ 'title' => 'User interface translation',
+ 'description' => 'Translate the built-in user interface.',
+ 'page callback' => 'locale_translate_seek_screen',
+ 'access arguments' => array('translate interface'),
+ 'file' => 'locale.pages.inc',
+ 'weight' => -5,
+ );
+ $items['admin/config/regional/translate/translate'] = array(
+ 'title' => 'Translate',
+ 'weight' => -10,
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/config/regional/translate/import'] = array(
+ 'title' => 'Import',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_translate_import_form'),
+ 'access arguments' => array('translate interface'),
+ 'weight' => 20,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'locale.bulk.inc',
+ );
+ $items['admin/config/regional/translate/export'] = array(
+ 'title' => 'Export',
+ 'page callback' => 'locale_translate_export_screen', // possibly multiple forms concatenated
+ 'access arguments' => array('translate interface'),
+ 'weight' => 30,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'locale.bulk.inc',
+ );
+ $items['admin/config/regional/translate/edit/%'] = array(
+ 'title' => 'Edit string',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_translate_edit_form', 5),
+ 'access arguments' => array('translate interface'),
+ 'file' => 'locale.pages.inc',
+ );
+ $items['admin/config/regional/translate/delete/%'] = array(
+ 'title' => 'Delete string',
+ 'page callback' => 'locale_translate_delete_page',
+ 'page arguments' => array(5),
+ 'access arguments' => array('translate interface'),
+ 'file' => 'locale.pages.inc',
+ );
+
+ // Localize date formats.
+ $items['admin/config/regional/date-time/locale'] = array(
+ 'title' => 'Localize',
+ 'description' => 'Configure date formats for each locale',
+ 'page callback' => 'locale_date_format_language_overview_page',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => -8,
+ 'file' => 'locale.admin.inc',
+ );
+ $items['admin/config/regional/date-time/locale/%/edit'] = array(
+ 'title' => 'Localize date formats',
+ 'description' => 'Configure date formats for each locale',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_date_format_form', 5),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'locale.admin.inc',
+ );
+ $items['admin/config/regional/date-time/locale/%/reset'] = array(
+ 'title' => 'Reset date formats',
+ 'description' => 'Reset localized date formats to global defaults',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('locale_date_format_reset_form', 5),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'locale.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_init().
+ *
+ * Initialize date formats according to the user's current locale.
+ */
+function locale_init() {
+ global $conf, $language;
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+
+ // For each date type (e.g. long, short), get the localized date format
+ // for the user's current language and override the default setting for it
+ // in $conf. This should happen on all pages except the date and time formats
+ // settings page, where we want to display the site default and not the
+ // localized version.
+ if (strpos($_GET['q'], 'admin/config/regional/date-time/formats') !== 0) {
+ $languages = array($language->language);
+
+ // Setup appropriate date formats for this locale.
+ $formats = locale_get_localized_date_format($languages);
+ foreach ($formats as $format_type => $format) {
+ $conf[$format_type] = $format;
+ }
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function locale_permission() {
+ return array(
+ 'administer languages' => array(
+ 'title' => t('Administer languages'),
+ ),
+ 'translate interface' => array(
+ 'title' => t('Translate interface texts'),
+ ),
+ );
+}
+
+/**
+ * Loads a language object from the database.
+ *
+ * @param $langcode
+ * The language code.
+ *
+ * @return
+ * A fully-populated language object or FALSE.
+ */
+function language_load($langcode) {
+ $languages = language_list();
+ return isset($languages[$langcode]) ? $languages[$langcode] : FALSE;
+}
+
+/**
+ * Form builder callback to display language selection widget.
+ *
+ * @ingroup forms
+ * @see locale_form_alter()
+ */
+function locale_language_selector_form(&$form, &$form_state, $user) {
+ global $language;
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+
+ // If the user is being created, we set the user language to the page language.
+ $user_preferred_language = $user->uid ? user_preferred_language($user) : $language;
+
+ $names = array();
+ foreach ($languages as $langcode => $item) {
+ $names[$langcode] = $item->name;
+ }
+ $form['locale'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Language settings'),
+ '#weight' => 1,
+ '#access' => ($form['#user_category'] == 'account' || ($form['#user_category'] == 'register' && user_access('administer users'))),
+ );
+
+ // Get language negotiation settings.
+ $mode = language_negotiation_get(LANGUAGE_TYPE_INTERFACE) != LANGUAGE_NEGOTIATION_DEFAULT;
+ $form['locale']['language'] = array(
+ '#type' => (count($names) <= 5 ? 'radios' : 'select'),
+ '#title' => t('Language'),
+ '#default_value' => $user_preferred_language->language,
+ '#options' => $names,
+ '#description' => $mode ? t("This account's default language for e-mails, and preferred language for site presentation.") : t("This account's default language for e-mails."),
+ );
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function locale_form_node_type_form_alter(&$form, &$form_state) {
+ if (isset($form['type'])) {
+ $form['workflow']['language_content_type'] = array(
+ '#type' => 'radios',
+ '#title' => t('Multilingual support'),
+ '#default_value' => variable_get('language_content_type_' . $form['#node_type']->type, 0),
+ '#options' => array(t('Disabled'), t('Enabled')),
+ '#description' => t('Enable multilingual support for this content type. If enabled, a language selection field will be added to the editing form, allowing you to select from one of the <a href="!languages">enabled languages</a>. If disabled, new posts are saved with the default language. Existing content will not be affected by changing this option.', array('!languages' => url('admin/config/regional/language'))),
+ );
+ }
+}
+
+/**
+ * Return whether the given content type has multilingual support.
+ *
+ * @return
+ * True if multilingual support is enabled.
+ */
+function locale_multilingual_node_type($type_name) {
+ return (bool) variable_get('language_content_type_' . $type_name, 0);
+}
+
+/**
+ * Implements hook_form_alter().
+ *
+ * Adds language fields to user forms.
+ */
+function locale_form_alter(&$form, &$form_state, $form_id) {
+ // Only alter user forms if there is more than one language.
+ if (drupal_multilingual()) {
+ // Display language selector when either creating a user on the admin
+ // interface or editing a user account.
+ if ($form_id == 'user_register_form' || ($form_id == 'user_profile_form' && $form['#user_category'] == 'account')) {
+ locale_language_selector_form($form, $form_state, $form['#user']);
+ }
+ }
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ */
+function locale_form_node_form_alter(&$form, &$form_state) {
+ if (isset($form['#node']->type) && locale_multilingual_node_type($form['#node']->type)) {
+ $form['language'] = array(
+ '#type' => 'select',
+ '#title' => t('Language'),
+ '#default_value' => (isset($form['#node']->language) ? $form['#node']->language : ''),
+ '#options' => array(LANGUAGE_NONE => t('Language neutral')) + locale_language_list('name'),
+ );
+ }
+ // Node type without language selector: assign the default for new nodes
+ elseif (!isset($form['#node']->nid)) {
+ $default = language_default();
+ $form['language'] = array(
+ '#type' => 'value',
+ '#value' => $default->language
+ );
+ }
+ $form['#submit'][] = 'locale_field_node_form_submit';
+}
+
+/**
+ * Form submit handler for node_form().
+ *
+ * Checks if Locale is registered as a translation handler and handle possible
+ * node language changes.
+ *
+ * This submit handler needs to run before entity_form_submit_build_entity()
+ * is invoked by node_form_submit_build_node(), because it alters the values of
+ * attached fields. Therefore, it cannot be a hook_node_submit() implementation.
+ */
+function locale_field_node_form_submit($form, &$form_state) {
+ if (field_has_translation_handler('node', 'locale')) {
+ $node = (object) $form_state['values'];
+ $available_languages = field_content_languages();
+ list(, , $bundle) = entity_extract_ids('node', $node);
+
+ foreach (field_info_instances('node', $bundle) as $instance) {
+ $field_name = $instance['field_name'];
+ $field = field_info_field($field_name);
+ $previous_language = $form[$field_name]['#language'];
+
+ // Handle a possible language change: new language values are inserted,
+ // previous ones are deleted.
+ if ($field['translatable'] && $previous_language != $node->language) {
+ $form_state['values'][$field_name][$node->language] = $node->{$field_name}[$previous_language];
+ $form_state['values'][$field_name][$previous_language] = array();
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function locale_theme() {
+ return array(
+ 'locale_language_overview_form_table' => array(
+ 'render element' => 'form',
+ 'file' => 'locale.admin.inc',
+ ),
+ 'locale_language_operations' => array(
+ 'render element' => 'elements',
+ 'file' => 'locale.admin.inc',
+ ),
+ 'locale_languages_configure_form' => array(
+ 'render element' => 'form',
+ ),
+ 'locale_date_format_form' => array(
+ 'render element' => 'form',
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_language_alter().
+ */
+function locale_field_language_alter(&$display_language, $context) {
+ // Do not apply core language fallback rules if they are disabled or if Locale
+ // is not registered as a translation handler.
+ if (variable_get('locale_field_language_fallback', TRUE) && field_has_translation_handler($context['entity_type'], 'locale')) {
+ locale_field_language_fallback($display_language, $context['entity'], $context['language']);
+ }
+}
+
+/**
+ * Applies language fallback rules to the fields attached to the given entity.
+ *
+ * Core language fallback rules simply check if fields have a field translation
+ * for the requested language code. If so the requested language is returned,
+ * otherwise all the fallback candidates are inspected to see if there is a
+ * field translation available in another language.
+ * By default this is called by locale_field_language_alter(), but this
+ * behavior can be disabled by setting the 'locale_field_language_fallback'
+ * variable to FALSE.
+ *
+ * @param $display_language
+ * A reference to an array of language codes keyed by field name.
+ * @param $entity
+ * The entity to be displayed.
+ * @param $langcode
+ * The language code $entity has to be displayed in.
+ */
+function locale_field_language_fallback(&$display_language, $entity, $langcode) {
+ // Lazily init fallback candidates to avoid unnecessary calls.
+ $fallback_candidates = NULL;
+ $field_languages = array();
+
+ foreach ($display_language as $field_name => $field_language) {
+ // If the requested language is defined for the current field use it,
+ // otherwise search for a fallback value among the fallback candidates.
+ if (isset($entity->{$field_name}[$langcode])) {
+ $display_language[$field_name] = $langcode;
+ }
+ elseif (!empty($entity->{$field_name})) {
+ if (!isset($fallback_candidates)) {
+ require_once DRUPAL_ROOT . '/core/includes/language.inc';
+ $fallback_candidates = language_fallback_get_candidates();
+ }
+ foreach ($fallback_candidates as $fallback_language) {
+ if (isset($entity->{$field_name}[$fallback_language])) {
+ $display_language[$field_name] = $fallback_language;
+ break;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ */
+function locale_entity_info_alter(&$entity_info) {
+ $entity_info['node']['translation']['locale'] = TRUE;
+}
+
+/**
+ * Implements hook_language_types_info().
+ *
+ * Defines the three core language types:
+ * - Interface language is the only configurable language type in core. It is
+ * used by t() as the default language if none is specified.
+ * - Content language is by default non-configurable and inherits the interface
+ * language negotiated value. It is used by the Field API to determine the
+ * display language for fields if no explicit value is specified.
+ * - URL language is by default non-configurable and is determined through the
+ * URL language provider or the URL fallback provider if no language can be
+ * detected. It is used by l() as the default language if none is specified.
+ */
+function locale_language_types_info() {
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ return array(
+ LANGUAGE_TYPE_INTERFACE => array(
+ 'name' => t('User interface text'),
+ 'description' => t('Order of language detection methods for user interface text. If a translation of user interface text is available in the detected language, it will be displayed.'),
+ ),
+ LANGUAGE_TYPE_CONTENT => array(
+ 'name' => t('Content'),
+ 'description' => t('Order of language detection methods for content. If a version of content is available in the detected language, it will be displayed.'),
+ 'fixed' => array(LOCALE_LANGUAGE_NEGOTIATION_INTERFACE),
+ ),
+ LANGUAGE_TYPE_URL => array(
+ 'fixed' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LOCALE_LANGUAGE_NEGOTIATION_URL_FALLBACK),
+ ),
+ );
+}
+
+/**
+ * Implements hook_language_negotiation_info().
+ */
+function locale_language_negotiation_info() {
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ $file = '/core/includes/locale.inc';
+ $providers = array();
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_URL] = array(
+ 'types' => array(LANGUAGE_TYPE_CONTENT, LANGUAGE_TYPE_INTERFACE, LANGUAGE_TYPE_URL),
+ 'callbacks' => array(
+ 'language' => 'locale_language_from_url',
+ 'switcher' => 'locale_language_switcher_url',
+ 'url_rewrite' => 'locale_language_url_rewrite_url',
+ ),
+ 'file' => $file,
+ 'weight' => -8,
+ 'name' => t('URL'),
+ 'description' => t('Determine the language from the URL (Path prefix or domain).'),
+ 'config' => 'admin/config/regional/language/configure/url',
+ );
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_SESSION] = array(
+ 'callbacks' => array(
+ 'language' => 'locale_language_from_session',
+ 'switcher' => 'locale_language_switcher_session',
+ 'url_rewrite' => 'locale_language_url_rewrite_session',
+ ),
+ 'file' => $file,
+ 'weight' => -6,
+ 'name' => t('Session'),
+ 'description' => t('Determine the language from a request/session parameter.'),
+ 'config' => 'admin/config/regional/language/configure/session',
+ );
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_USER] = array(
+ 'callbacks' => array('language' => 'locale_language_from_user'),
+ 'file' => $file,
+ 'weight' => -4,
+ 'name' => t('User'),
+ 'description' => t("Follow the user's language preference."),
+ );
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_BROWSER] = array(
+ 'callbacks' => array('language' => 'locale_language_from_browser'),
+ 'file' => $file,
+ 'weight' => -2,
+ 'cache' => 0,
+ 'name' => t('Browser'),
+ 'description' => t("Determine the language from the browser's language settings."),
+ );
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_INTERFACE] = array(
+ 'types' => array(LANGUAGE_TYPE_CONTENT),
+ 'callbacks' => array('language' => 'locale_language_from_interface'),
+ 'file' => $file,
+ 'weight' => 8,
+ 'name' => t('Interface'),
+ 'description' => t('Use the detected interface language.'),
+ );
+
+ $providers[LOCALE_LANGUAGE_NEGOTIATION_URL_FALLBACK] = array(
+ 'types' => array(LANGUAGE_TYPE_URL),
+ 'callbacks' => array('language' => 'locale_language_url_fallback'),
+ 'file' => $file,
+ 'weight' => 8,
+ 'name' => t('URL fallback'),
+ 'description' => t('Use an already detected language for URLs if none is found.'),
+ );
+
+ return $providers;
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function locale_modules_enabled($modules) {
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+ language_types_set();
+ language_negotiation_purge();
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function locale_modules_disabled($modules) {
+ locale_modules_enabled($modules);
+}
+
+// ---------------------------------------------------------------------------------
+// Locale core functionality
+
+/**
+ * Provides interface translation services.
+ *
+ * This function is called from t() to translate a string if needed.
+ *
+ * @param $string
+ * A string to look up translation for. If omitted, all the
+ * cached strings will be returned in all languages already
+ * used on the page.
+ * @param $context
+ * The context of this string.
+ * @param $langcode
+ * Language code to use for the lookup.
+ */
+function locale($string = NULL, $context = NULL, $langcode = NULL) {
+ global $language;
+ $locale_t = &drupal_static(__FUNCTION__);
+
+ if (!isset($string)) {
+ // Return all cached strings if no string was specified
+ return $locale_t;
+ }
+
+ $langcode = isset($langcode) ? $langcode : $language->language;
+
+ // Store database cached translations in a static variable. Only build the
+ // cache after $language has been set to avoid an unnecessary cache rebuild.
+ if (!isset($locale_t[$langcode]) && isset($language)) {
+ $locale_t[$langcode] = array();
+ // Disabling the usage of string caching allows a module to watch for
+ // the exact list of strings used on a page. From a performance
+ // perspective that is a really bad idea, so we have no user
+ // interface for this. Be careful when turning this option off!
+ if (variable_get('locale_cache_strings', 1) == 1) {
+ if ($cache = cache()->get('locale:' . $langcode)) {
+ $locale_t[$langcode] = $cache->data;
+ }
+ elseif (lock_acquire('locale_cache_' . $langcode)) {
+ // Refresh database stored cache of translations for given language.
+ // We only store short strings used in current version, to improve
+ // performance and consume less memory.
+ $result = db_query("SELECT s.source, s.context, t.translation, t.language FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.version = :version AND LENGTH(s.source) < :length", array(':language' => $langcode, ':version' => VERSION, ':length' => variable_get('locale_cache_length', 75)));
+ foreach ($result as $data) {
+ $locale_t[$langcode][$data->context][$data->source] = (empty($data->translation) ? TRUE : $data->translation);
+ }
+ cache()->set('locale:' . $langcode, $locale_t[$langcode]);
+ lock_release('locale_cache_' . $langcode);
+ }
+ }
+ }
+
+ // If we have the translation cached, skip checking the database
+ if (!isset($locale_t[$langcode][$context][$string])) {
+
+ // We do not have this translation cached, so get it from the DB.
+ $translation = db_query("SELECT s.lid, t.translation, s.version FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.source = :source AND s.context = :context", array(
+ ':language' => $langcode,
+ ':source' => $string,
+ ':context' => (string) $context,
+ ))->fetchObject();
+ if ($translation) {
+ // We have the source string at least.
+ // Cache translation string or TRUE if no translation exists.
+ $locale_t[$langcode][$context][$string] = (empty($translation->translation) ? TRUE : $translation->translation);
+
+ if ($translation->version != VERSION) {
+ // This is the first use of this string under current Drupal version. Save version
+ // and clear cache, to include the string into caching next time. Saved version is
+ // also a string-history information for later pruning of the tables.
+ db_update('locales_source')
+ ->fields(array('version' => VERSION))
+ ->condition('lid', $translation->lid)
+ ->execute();
+ cache()->deletePrefix('locale:');
+ }
+ }
+ else {
+ // We don't have the source string, cache this as untranslated.
+ db_insert('locales_source')
+ ->fields(array(
+ 'location' => request_uri(),
+ 'source' => $string,
+ 'context' => (string) $context,
+ 'version' => VERSION,
+ ))
+ ->execute();
+ $locale_t[$langcode][$context][$string] = TRUE;
+ // Clear locale cache so this string can be added in a later request.
+ cache()->deletePrefix('locale:');
+ }
+ }
+
+ return ($locale_t[$langcode][$context][$string] === TRUE ? $string : $locale_t[$langcode][$context][$string]);
+}
+
+/**
+ * Reset static variables used by locale().
+ */
+function locale_reset() {
+ drupal_static_reset('locale');
+}
+
+/**
+ * Returns plural form index for a specific number.
+ *
+ * The index is computed from the formula of this language.
+ *
+ * @param $count
+ * Number to return plural for.
+ * @param $langcode
+ * Optional language code to translate to a language other than
+ * what is used to display the page.
+ */
+function locale_get_plural($count, $langcode = NULL) {
+ global $language;
+ $locale_formula = &drupal_static(__FUNCTION__, array());
+ $plurals = &drupal_static(__FUNCTION__ . ':plurals', array());
+
+ $langcode = $langcode ? $langcode : $language->language;
+
+ if (!isset($plurals[$langcode][$count])) {
+ if (empty($locale_formula)) {
+ $language_list = language_list();
+ $locale_formula[$langcode] = $language_list[$langcode]->formula;
+ }
+ if ($locale_formula[$langcode]) {
+ $n = $count;
+ $plurals[$langcode][$count] = @eval('return intval(' . $locale_formula[$langcode] . ');');
+ return $plurals[$langcode][$count];
+ }
+ else {
+ $plurals[$langcode][$count] = -1;
+ return -1;
+ }
+ }
+ return $plurals[$langcode][$count];
+}
+
+
+/**
+ * Returns a language name
+ */
+function locale_language_name($lang) {
+ $list = &drupal_static(__FUNCTION__);
+ if (!isset($list)) {
+ $list = locale_language_list();
+ }
+ return ($lang && isset($list[$lang])) ? $list[$lang] : t('All');
+}
+
+/**
+ * Returns array of language names
+ *
+ * @param $field
+ * Name of language object field.
+ * @param $all
+ * Boolean to return all languages or only enabled ones
+ */
+function locale_language_list($field = 'name', $all = FALSE) {
+ if ($all) {
+ $languages = language_list();
+ }
+ else {
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+ }
+ $list = array();
+ foreach ($languages as $language) {
+ $list[$language->language] = $language->$field;
+ }
+ return $list;
+}
+
+/**
+ * Delete a language.
+ *
+ * @param $langcode
+ * Language code of the language to be deleted.
+ * @return
+ * TRUE if language is successfully deleted. Otherwise FALSE.
+ */
+function locale_language_delete($langcode) {
+ $languages = language_list();
+ if (isset($languages[$langcode])) {
+ $language = $languages[$langcode];
+
+ module_invoke_all('locale_language_delete', $language);
+
+ // Remove translations first.
+ db_delete('locales_target')
+ ->condition('language', $language->language)
+ ->execute();
+
+ // Remove the language.
+ db_delete('languages')
+ ->condition('language', $language->language)
+ ->execute();
+
+ if ($language->enabled) {
+ variable_set('language_count', variable_get('language_count', 1) - 1);
+ }
+
+ drupal_static_reset('language_list');
+ _locale_invalidate_js($language->language);
+
+ // Changing the language settings impacts the interface:
+ cache('page')->flush();
+
+ // Clearing all locale cache from database
+ cache()->delete('locale:' . $language->language);
+
+ $variables = array('%locale' => $language->name);
+ watchdog('locale', 'The language %locale has been removed.', $variables);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_modules_installed().
+ */
+function locale_modules_installed($modules) {
+ locale_system_update($modules);
+}
+
+/**
+ * Implements hook_themes_enabled().
+ *
+ * @todo This is technically wrong. We must not import upon enabling, but upon
+ * initial installation. The theme system is missing an installation hook.
+ */
+function locale_themes_enabled($themes) {
+ locale_system_update($themes);
+}
+
+/**
+ * Imports translations when new modules or themes are installed.
+ *
+ * This function will either import translation for the component change
+ * right away, or start a batch if more files need to be imported.
+ *
+ * @param $components
+ * An array of component (theme and/or module) names to import
+ * translations for.
+ *
+ * @todo
+ * This currently imports all .po files available, independent of
+ * $components. Once we integrated with update status for project
+ * identification, we should revisit and only import files for the
+ * identified projects for the components.
+ */
+function locale_system_update($components) {
+ include_once drupal_get_path('module', 'locale') . '/locale.bulk.inc';
+ if ($batch = locale_translate_batch_import_files(NULL, TRUE)) {
+ batch_set($batch);
+ }
+}
+
+/**
+ * Implements hook_js_alter().
+ *
+ * This function checks all JavaScript files currently added via drupal_add_js()
+ * and invokes parsing if they have not yet been parsed for Drupal.t()
+ * and Drupal.formatPlural() calls. Also refreshes the JavaScript translation
+ * file if necessary, and adds it to the page.
+ */
+function locale_js_alter(&$javascript) {
+ global $language;
+
+ $dir = 'public://' . variable_get('locale_js_directory', 'languages');
+ $parsed = variable_get('javascript_parsed', array());
+ $files = $new_files = FALSE;
+
+ // Require because locale_js_alter() could be called without locale_init().
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+
+ foreach ($javascript as $item) {
+ if ($item['type'] == 'file') {
+ $files = TRUE;
+ $filepath = $item['data'];
+ if (!in_array($filepath, $parsed)) {
+ // Don't parse our own translations files.
+ if (substr($filepath, 0, strlen($dir)) != $dir) {
+ _locale_parse_js_file($filepath);
+ $parsed[] = $filepath;
+ $new_files = TRUE;
+ }
+ }
+ }
+ }
+
+ // If there are any new source files we parsed, invalidate existing
+ // JavaScript translation files for all languages, adding the refresh
+ // flags into the existing array.
+ if ($new_files) {
+ $parsed += _locale_invalidate_js();
+ }
+
+ // If necessary, rebuild the translation file for the current language.
+ if (!empty($parsed['refresh:' . $language->language])) {
+ // Don't clear the refresh flag on failure, so that another try will
+ // be performed later.
+ if (_locale_rebuild_js()) {
+ unset($parsed['refresh:' . $language->language]);
+ }
+ // Store any changes after refresh was attempted.
+ variable_set('javascript_parsed', $parsed);
+ }
+ // If no refresh was attempted, but we have new source files, we need
+ // to store them too. This occurs if current page is in English.
+ elseif ($new_files) {
+ variable_set('javascript_parsed', $parsed);
+ }
+
+ // Add the translation JavaScript file to the page.
+ if ($files && !empty($language->javascript)) {
+ // Add the translation JavaScript file to the page.
+ $file = $dir . '/' . $language->language . '_' . $language->javascript . '.js';
+ $javascript[$file] = drupal_js_defaults($file);
+ }
+}
+
+/**
+ * Implements hook_css_alter().
+ *
+ * This function checks all CSS files currently added via drupal_add_css() and
+ * and checks to see if a related right to left CSS file should be included.
+ */
+function locale_css_alter(&$css) {
+ global $language;
+
+ // If the current language is RTL, add the CSS file with the RTL overrides.
+ if ($language->direction == LANGUAGE_RTL) {
+ foreach ($css as $data => $item) {
+ // Only provide RTL overrides for files.
+ if ($item['type'] == 'file') {
+ $rtl_path = str_replace('.css', '-rtl.css', $item['data']);
+ if (file_exists($rtl_path) && !isset($css[$rtl_path])) {
+ // Replicate the same item, but with the RTL path and a little larger
+ // weight so that it appears directly after the original CSS file.
+ $item['data'] = $rtl_path;
+ $item['weight'] += 0.01;
+ $css[$rtl_path] = $item;
+ }
+ }
+ }
+ }
+}
+
+ /**
+ * Implement hook_library_info_alter().
+ *
+ * Provides the language support for the jQuery UI Date Picker.
+ */
+function locale_library_info_alter(&$libraries, $module) {
+ global $language;
+ if ($module == 'system' && isset($libraries['system']['ui.datepicker'])) {
+ $datepicker = drupal_get_path('module', 'locale') . '/locale.datepicker.js';
+ $libraries['system']['ui.datepicker']['js'][$datepicker] = array('group' => JS_THEME);
+ $libraries['system']['ui.datepicker']['js'][] = array(
+ 'data' => array(
+ 'jqueryuidatepicker' => array(
+ 'rtl' => $language->direction == LANGUAGE_RTL,
+ 'firstDay' => variable_get('date_first_day', 0),
+ ),
+ ),
+ 'type' => 'setting',
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------------
+// Language switcher block
+
+/**
+ * Implements hook_block_info().
+ */
+function locale_block_info() {
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+ $block = array();
+ $info = language_types_info();
+ foreach (language_types_configurable(FALSE) as $type) {
+ $block[$type] = array(
+ 'info' => t('Language switcher (@type)', array('@type' => $info[$type]['name'])),
+ // Not worth caching.
+ 'cache' => DRUPAL_NO_CACHE,
+ );
+ }
+ return $block;
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Displays a language switcher. Only show if we have at least two languages.
+ */
+function locale_block_view($type) {
+ if (drupal_multilingual()) {
+ $path = drupal_is_front_page() ? '<front>' : $_GET['q'];
+ $links = language_negotiation_get_switch_links($type, $path);
+
+ if (isset($links->links)) {
+ drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css');
+ $class = "language-switcher-{$links->provider}";
+ $variables = array('links' => $links->links, 'attributes' => array('class' => array($class)));
+ $block['content'] = theme('links__locale_block', $variables);
+ $block['subject'] = t('Languages');
+ return $block;
+ }
+ }
+}
+
+/**
+ * Implements hook_url_outbound_alter().
+ *
+ * Rewrite outbound URLs with language based prefixes.
+ */
+function locale_url_outbound_alter(&$path, &$options, $original_path) {
+ // Only modify internal URLs.
+ if (!$options['external'] && drupal_multilingual()) {
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['callbacks'] = &drupal_static(__FUNCTION__);
+ }
+ $callbacks = &$drupal_static_fast['callbacks'];
+
+ if (!isset($callbacks)) {
+ $callbacks = array();
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+
+ foreach (language_types_configurable() as $type) {
+ // Get url rewriter callbacks only from enabled language providers.
+ $negotiation = variable_get("language_negotiation_$type", array());
+
+ foreach ($negotiation as $id => $provider) {
+ if (isset($provider['file'])) {
+ require_once DRUPAL_ROOT . '/' . $provider['file'];
+ }
+
+ // Avoid duplicate callback entries.
+ if (isset($provider['callbacks']['url_rewrite'])) {
+ $callbacks[$provider['callbacks']['url_rewrite']] = NULL;
+ }
+ }
+ }
+
+ $callbacks = array_keys($callbacks);
+ }
+
+ foreach ($callbacks as $callback) {
+ $callback($path, $options);
+ }
+
+ // No language dependent path allowed in this mode.
+ if (empty($callbacks)) {
+ unset($options['language']);
+ }
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function locale_form_comment_form_alter(&$form, &$form_state, $form_id) {
+ // If a content type has multilingual support we set the content language as
+ // comment language.
+ if ($form['language']['#value'] == LANGUAGE_NONE && locale_multilingual_node_type($form['#node']->type)) {
+ global $language_content;
+ $form['language']['#value'] = $language_content->language;
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for locale_language_overview_form().
+ */
+function locale_form_locale_language_overview_form_alter(&$form, &$form_state) {
+ $languages = $form['languages']['#languages'];
+
+ $total_strings = db_query("SELECT COUNT(*) FROM {locales_source}")->fetchField();
+ $stats = array_fill_keys(array_keys($languages), array());
+
+ // If we have source strings, count translations and calculate progress.
+ if (!empty($total_strings)) {
+ $translations = db_query("SELECT COUNT(*) AS translated, t.language FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid GROUP BY language");
+ foreach ($translations as $data) {
+ $stats[$data->language]['translated'] = $data->translated;
+ if ($data->translated > 0) {
+ $stats[$data->language]['ratio'] = round($data->translated / $total_strings * 100, 2);
+ }
+ }
+ }
+
+ array_splice($form['languages']['#header'], -1, 0, t('Interface translation'));
+
+ foreach ($languages as $langcode => $language) {
+ $stats[$langcode] += array(
+ 'translated' => 0,
+ 'ratio' => 0,
+ );
+ if ($langcode != 'en' || locale_translate_english()) {
+ $form['languages'][$langcode]['locale_statistics'] = array(
+ '#type' => 'link',
+ '#title' => t('@translated/@total (@ratio%)', array(
+ '@translated' => $stats[$langcode]['translated'],
+ '@total' => $total_strings,
+ '@ratio' => $stats[$langcode]['ratio'],
+ )),
+ '#href' => 'admin/config/regional/translate/translate',
+ );
+ }
+ else {
+ $form['languages'][$langcode]['locale_statistics'] = array(
+ '#markup' => t('not applicable'),
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for locale_languages_edit_form().
+ */
+function locale_form_locale_languages_edit_form_alter(&$form, &$form_state) {
+ if ($form['langcode']['#type'] == 'value' && $form['langcode']['#value'] == 'en') {
+ $form['locale_translate_english'] = array(
+ '#title' => t('Enable interface translation to English'),
+ '#type' => 'checkbox',
+ '#default_value' => locale_translate_english(),
+ );
+ $form['#submit'][] = 'locale_form_locale_languages_edit_form_alter_submit';
+ }
+}
+
+/**
+ * Submission handler to record our custom setting.
+ */
+function locale_form_locale_languages_edit_form_alter_submit($form, $form_state) {
+ variable_set('locale_translate_english', $form_state['values']['locale_translate_english']);
+}
+
+/**
+ * Utility function to tell if locale translates to English.
+ */
+function locale_translate_english() {
+ return variable_get('locale_translate_english', FALSE);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for system_file_system_settings().
+ *
+ * Add interface translation directory setting to directories configuration.
+ */
+function locale_form_system_file_system_settings_alter(&$form, $form_state) {
+ $form['locale_translate_file_directory'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Interface translations directory'),
+ '#default_value' => variable_get('locale_translate_file_directory', conf_path() . '/files/translations'),
+ '#maxlength' => 255,
+ '#description' => t('A local file system path where interface translation files are looked for. This directory must exist.'),
+ '#after_build' => array('system_check_directory'),
+ '#weight' => 10,
+ );
+ if ($form['file_default_scheme']) {
+ $form['file_default_scheme']['#weight'] = 20;
+ }
+}
diff --git a/core/modules/locale/locale.pages.inc b/core/modules/locale/locale.pages.inc
new file mode 100644
index 000000000000..ef843ec1fabb
--- /dev/null
+++ b/core/modules/locale/locale.pages.inc
@@ -0,0 +1,431 @@
+<?php
+
+/**
+ * @file
+ * Interface translation summary, editing and deletion user interfaces.
+ */
+
+/**
+ * String search screen.
+ */
+function locale_translate_seek_screen() {
+ // Add CSS.
+ drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css');
+
+ $elements = drupal_get_form('locale_translation_filter_form');
+ $output = drupal_render($elements);
+ $output .= _locale_translate_seek();
+ return $output;
+}
+
+/**
+ * Perform a string search and display results in a table
+ */
+function _locale_translate_seek() {
+ $output = '';
+
+ // We have at least one criterion to match
+ if (!($query = _locale_translate_seek_query())) {
+ $query = array(
+ 'translation' => 'all',
+ 'language' => 'all',
+ 'string' => '',
+ );
+ }
+
+ $sql_query = db_select('locales_source', 's');
+ $sql_query->leftJoin('locales_target', 't', 't.lid = s.lid');
+ $sql_query->fields('s', array('source', 'location', 'context', 'lid'));
+ $sql_query->fields('t', array('translation', 'language'));
+
+ // Compute LIKE section.
+ switch ($query['translation']) {
+ case 'translated':
+ $sql_query->condition('t.translation', '%' . db_like($query['string']) . '%', 'LIKE');
+ $sql_query->orderBy('t.translation', 'DESC');
+ break;
+ case 'untranslated':
+ $sql_query->condition(db_and()
+ ->condition('s.source', '%' . db_like($query['string']) . '%', 'LIKE')
+ ->isNull('t.translation')
+ );
+ $sql_query->orderBy('s.source');
+ break;
+ case 'all' :
+ default:
+ $condition = db_or()
+ ->condition('s.source', '%' . db_like($query['string']) . '%', 'LIKE');
+ if ($query['language'] != LANGUAGE_SYSTEM) {
+ // Only search in translations if the language is not forced to system language.
+ $condition->condition('t.translation', '%' . db_like($query['string']) . '%', 'LIKE');
+ }
+ $sql_query->condition($condition);
+ break;
+ }
+
+ $limit_language = NULL;
+ if ($query['language'] != LANGUAGE_SYSTEM && $query['language'] != 'all') {
+ $sql_query->condition('language', $query['language']);
+ $limit_language = $query['language'];
+ }
+
+ $sql_query = $sql_query->extend('PagerDefault')->limit(50);
+ $locales = $sql_query->execute();
+
+ $header = array(t('String'), t('Context'), ($limit_language) ? t('Language') : t('Languages'), array('data' => t('Operations'), 'colspan' => '2'));
+
+ $strings = array();
+ foreach ($locales as $locale) {
+ if (!isset($strings[$locale->lid])) {
+ $strings[$locale->lid] = array(
+ 'languages' => array(),
+ 'location' => $locale->location,
+ 'source' => $locale->source,
+ 'context' => $locale->context,
+ );
+ }
+ if (isset($locale->language)) {
+ $strings[$locale->lid]['languages'][$locale->language] = $locale->translation;
+ }
+ }
+
+ $rows = array();
+ foreach ($strings as $lid => $string) {
+ $rows[] = array(
+ array('data' => check_plain(truncate_utf8($string['source'], 150, FALSE, TRUE)) . '<br /><small>' . $string['location'] . '</small>'),
+ $string['context'],
+ array('data' => _locale_translate_language_list($string['languages'], $limit_language), 'align' => 'center'),
+ array('data' => l(t('edit'), "admin/config/regional/translate/edit/$lid", array('query' => drupal_get_destination())), 'class' => array('nowrap')),
+ array('data' => l(t('delete'), "admin/config/regional/translate/delete/$lid", array('query' => drupal_get_destination())), 'class' => array('nowrap')),
+ );
+ }
+
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No strings available.')));
+ $output .= theme('pager');
+
+ return $output;
+}
+
+/**
+ * List languages in search result table
+ */
+function _locale_translate_language_list($translation, $limit_language) {
+ // Add CSS.
+ drupal_add_css(drupal_get_path('module', 'locale') . '/locale.css');
+
+ $languages = language_list();
+ if (!locale_translate_english()) {
+ unset($languages['en']);
+ }
+ $output = '';
+ foreach ($languages as $langcode => $language) {
+ if (!$limit_language || $limit_language == $langcode) {
+ $output .= (!empty($translation[$langcode])) ? $langcode . ' ' : "<em class=\"locale-untranslated\">$langcode</em> ";
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Build array out of search criteria specified in request variables
+ */
+function _locale_translate_seek_query() {
+ $query = &drupal_static(__FUNCTION__);
+ if (!isset($query)) {
+ $query = array();
+ $fields = array('string', 'language', 'translation');
+ foreach ($fields as $field) {
+ if (isset($_SESSION['locale_translation_filter'][$field])) {
+ $query[$field] = $_SESSION['locale_translation_filter'][$field];
+ }
+ }
+ }
+ return $query;
+}
+
+/**
+ * List locale translation filters that can be applied.
+ */
+function locale_translation_filters() {
+ $filters = array();
+
+ // Get all languages, except English
+ drupal_static_reset('language_list');
+ $languages = locale_language_list('name');
+ if (!locale_translate_english()) {
+ unset($languages['en']);
+ }
+
+ $filters['string'] = array(
+ 'title' => t('String contains'),
+ 'description' => t('Leave blank to show all strings. The search is case sensitive.'),
+ );
+
+ $filters['language'] = array(
+ 'title' => t('Language'),
+ 'options' => array_merge(array('all' => t('All languages'), LANGUAGE_SYSTEM => t('System (English)')), $languages),
+ );
+
+ $filters['translation'] = array(
+ 'title' => t('Search in'),
+ 'options' => array('all' => t('Both translated and untranslated strings'), 'translated' => t('Only translated strings'), 'untranslated' => t('Only untranslated strings')),
+ );
+
+ return $filters;
+}
+
+/**
+ * Return form for locale translation filters.
+ *
+ * @ingroup forms
+ */
+function locale_translation_filter_form() {
+ $filters = locale_translation_filters();
+
+ $form['filters'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Filter translatable strings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => FALSE,
+ );
+ foreach ($filters as $key => $filter) {
+ // Special case for 'string' filter.
+ if ($key == 'string') {
+ $form['filters']['status']['string'] = array(
+ '#type' => 'textfield',
+ '#title' => $filter['title'],
+ '#description' => $filter['description'],
+ );
+ }
+ else {
+ $form['filters']['status'][$key] = array(
+ '#title' => $filter['title'],
+ '#type' => 'select',
+ '#empty_value' => 'all',
+ '#empty_option' => $filter['options']['all'],
+ '#size' => 0,
+ '#options' => $filter['options'],
+ );
+ }
+ if (!empty($_SESSION['locale_translation_filter'][$key])) {
+ $form['filters']['status'][$key]['#default_value'] = $_SESSION['locale_translation_filter'][$key];
+ }
+ }
+
+ $form['filters']['actions'] = array(
+ '#type' => 'actions',
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['filters']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Filter'),
+ );
+ if (!empty($_SESSION['locale_translation_filter'])) {
+ $form['filters']['actions']['reset'] = array(
+ '#type' => 'submit',
+ '#value' => t('Reset')
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Validate result from locale translation filter form.
+ */
+function locale_translation_filter_form_validate($form, &$form_state) {
+ if ($form_state['values']['op'] == t('Filter') && empty($form_state['values']['language'])) {
+ form_set_error('type', t('You must select something to filter by.'));
+ }
+}
+
+/**
+ * Process result from locale translation filter form.
+ */
+function locale_translation_filter_form_submit($form, &$form_state) {
+ $op = $form_state['values']['op'];
+ $filters = locale_translation_filters();
+ switch ($op) {
+ case t('Filter'):
+ foreach ($filters as $name => $filter) {
+ if (isset($form_state['values'][$name])) {
+ $_SESSION['locale_translation_filter'][$name] = $form_state['values'][$name];
+ }
+ }
+ break;
+ case t('Reset'):
+ $_SESSION['locale_translation_filter'] = array();
+ break;
+ }
+
+ $form_state['redirect'] = 'admin/config/regional/translate/translate';
+}
+
+
+/**
+ * User interface for string editing.
+ *
+ * @ingroup forms
+ */
+function locale_translate_edit_form($form, &$form_state, $lid) {
+ // Fetch source string, if possible.
+ $source = db_query('SELECT source, context, location FROM {locales_source} WHERE lid = :lid', array(':lid' => $lid))->fetchObject();
+ if (!$source) {
+ drupal_set_message(t('String not found.'), 'error');
+ drupal_goto('admin/config/regional/translate/translate');
+ }
+
+ // Add original text to the top and some values for form altering.
+ $form['original'] = array(
+ '#type' => 'item',
+ '#title' => t('Original text'),
+ '#markup' => check_plain(wordwrap($source->source, 0)),
+ );
+ if (!empty($source->context)) {
+ $form['context'] = array(
+ '#type' => 'item',
+ '#title' => t('Context'),
+ '#markup' => check_plain($source->context),
+ );
+ }
+ $form['lid'] = array(
+ '#type' => 'value',
+ '#value' => $lid
+ );
+ $form['location'] = array(
+ '#type' => 'value',
+ '#value' => $source->location
+ );
+
+ // Include default form controls with empty values for all languages.
+ // This ensures that the languages are always in the same order in forms.
+ $languages = language_list();
+ if (!locale_translate_english()) {
+ unset($languages['en']);
+ }
+ $form['translations'] = array('#tree' => TRUE);
+ // Approximate the number of rows to use in the default textarea.
+ $rows = min(ceil(str_word_count($source->source) / 12), 10);
+ foreach ($languages as $langcode => $language) {
+ $form['translations'][$langcode] = array(
+ '#type' => 'textarea',
+ '#title' => $language->name,
+ '#rows' => $rows,
+ '#default_value' => '',
+ );
+ }
+
+ // Fetch translations and fill in default values in the form.
+ $result = db_query("SELECT DISTINCT translation, language FROM {locales_target} WHERE lid = :lid", array(':lid' => $lid));
+ foreach ($result as $translation) {
+ $form['translations'][$translation->language]['#default_value'] = $translation->translation;
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save translations'));
+ return $form;
+}
+
+/**
+ * Validate string editing form submissions.
+ */
+function locale_translate_edit_form_validate($form, &$form_state) {
+ foreach ($form_state['values']['translations'] as $key => $value) {
+ if (!locale_string_is_safe($value)) {
+ form_set_error('translations', t('The submitted string contains disallowed HTML: %string', array('%string' => $value)));
+ watchdog('locale', 'Attempted submission of a translation string with disallowed HTML: %string', array('%string' => $value), WATCHDOG_WARNING);
+ }
+ }
+}
+
+/**
+ * Process string editing form submissions.
+ *
+ * Saves all translations of one string submitted from a form.
+ */
+function locale_translate_edit_form_submit($form, &$form_state) {
+ $lid = $form_state['values']['lid'];
+ foreach ($form_state['values']['translations'] as $key => $value) {
+ $translation = db_query("SELECT translation FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $key))->fetchField();
+ if (!empty($value)) {
+ // Only update or insert if we have a value to use.
+ if (!empty($translation)) {
+ db_update('locales_target')
+ ->fields(array(
+ 'translation' => $value,
+ ))
+ ->condition('lid', $lid)
+ ->condition('language', $key)
+ ->execute();
+ }
+ else {
+ db_insert('locales_target')
+ ->fields(array(
+ 'lid' => $lid,
+ 'translation' => $value,
+ 'language' => $key,
+ ))
+ ->execute();
+ }
+ }
+ elseif (!empty($translation)) {
+ // Empty translation entered: remove existing entry from database.
+ db_delete('locales_target')
+ ->condition('lid', $lid)
+ ->condition('language', $key)
+ ->execute();
+ }
+
+ // Force JavaScript translation file recreation for this language.
+ _locale_invalidate_js($key);
+ }
+
+ drupal_set_message(t('The string has been saved.'));
+
+ // Clear locale cache.
+ _locale_invalidate_js();
+ cache()->deletePrefix('locale:');
+
+ $form_state['redirect'] = 'admin/config/regional/translate/translate';
+ return;
+}
+
+/**
+ * String deletion confirmation page.
+ */
+function locale_translate_delete_page($lid) {
+ if ($source = db_query('SELECT lid, source FROM {locales_source} WHERE lid = :lid', array(':lid' => $lid))->fetchObject()) {
+ return drupal_get_form('locale_translate_delete_form', $source);
+ }
+ else {
+ return drupal_not_found();
+ }
+}
+
+/**
+ * User interface for the string deletion confirmation screen.
+ *
+ * @ingroup forms
+ */
+function locale_translate_delete_form($form, &$form_state, $source) {
+ $form['lid'] = array('#type' => 'value', '#value' => $source->lid);
+ return confirm_form($form, t('Are you sure you want to delete the string "%source"?', array('%source' => $source->source)), 'admin/config/regional/translate/translate', t('Deleting the string will remove all translations of this string in all languages. This action cannot be undone.'), t('Delete'), t('Cancel'));
+}
+
+/**
+ * Process string deletion submissions.
+ */
+function locale_translate_delete_form_submit($form, &$form_state) {
+ db_delete('locales_source')
+ ->condition('lid', $form_state['values']['lid'])
+ ->execute();
+ db_delete('locales_target')
+ ->condition('lid', $form_state['values']['lid'])
+ ->execute();
+ // Force JavaScript translation file recreation for all languages.
+ _locale_invalidate_js();
+ cache()->deletePrefix('locale:');
+ drupal_set_message(t('The string has been removed.'));
+ $form_state['redirect'] = 'admin/config/regional/translate/translate';
+}
diff --git a/core/modules/locale/locale.test b/core/modules/locale/locale.test
new file mode 100644
index 000000000000..ad28164a4d49
--- /dev/null
+++ b/core/modules/locale/locale.test
@@ -0,0 +1,2736 @@
+<?php
+
+/**
+ * @file
+ * Tests for locale.module.
+ *
+ * The test file includes:
+ * - a functional test for the language configuration forms;
+ * - functional tests for the translation functionalities, including searching;
+ * - a functional test for the PO files import feature, including validation;
+ * - functional tests for translations and templates export feature;
+ * - functional tests for the uninstall process;
+ * - a functional test for the language switching feature;
+ * - a functional test for a user's ability to change their default language;
+ * - a functional test for configuring a different path alias per language;
+ * - a functional test for configuring a different path alias per language;
+ * - a functional test for multilingual support by content type and on nodes.
+ * - a functional test for multilingual fields.
+ * - a functional test for comment language.
+ * - a functional test fot language types/negotiation info.
+ */
+
+
+/**
+ * Functional tests for the language configuration forms.
+ */
+class LocaleConfigurationTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Language configuration',
+ 'description' => 'Adds a new locale and tests changing its status and the default language.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ }
+
+ /**
+ * Functional tests for adding, editing and deleting languages.
+ */
+ function testLanguageConfiguration() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ $this->drupalLogin($admin_user);
+
+ // Add predefined language.
+ $edit = array(
+ 'predefined_langcode' => 'fr',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+ $this->assertText('fr', t('Language added successfully.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+ // Add custom language.
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language.
+ $name = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertRaw('"edit-site-default-' . $langcode .'"', t('Language code found.'));
+ $this->assertText(t($name), t('Test language added.'));
+
+ // Check if we can change the default language.
+ $path = 'admin/config/regional/language';
+ $this->drupalGet($path);
+ $this->assertFieldChecked('edit-site-default-en', t('English is the default language.'));
+ // Change the default language.
+ $edit = array(
+ 'site_default' => $langcode,
+ );
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-site-default-en', t('Default language updated.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+ // Check if a valid language prefix is added afrer changing the default
+ // language.
+ $this->drupalGet('admin/config/regional/language/configure/url');
+ $this->assertFieldByXPath('//input[@name="prefix[en]"]', 'en', t('A valid path prefix has been added to the previous default language.'));
+
+ // Ensure we can't delete the default language.
+ $this->drupalGet('admin/config/regional/language/delete/' . $langcode);
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('The default language cannot be deleted.'), t('Failed to delete the default language.'));
+
+ // Check if we can disable a language.
+ $edit = array(
+ 'languages[en][enabled]' => FALSE,
+ );
+ $this->drupalPost($path, $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-languages-en-enabled', t('Language disabled.'));
+
+ // Set disabled language to be the default and ensure it is re-enabled.
+ $edit = array(
+ 'site_default' => 'en',
+ );
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+ $this->assertFieldChecked('edit-languages-en-enabled', t('Default language re-enabled.'));
+
+ // Ensure 'edit' link works.
+ $this->clickLink(t('edit'));
+ $this->assertTitle(t('Edit language | Drupal'), t('Page title is "Edit language".'));
+ // Edit a language.
+ $name = $this->randomName(16);
+ $edit = array(
+ 'name' => $name,
+ );
+ $this->drupalPost('admin/config/regional/language/edit/' . $langcode, $edit, t('Save language'));
+ $this->assertRaw($name, t('The language has been updated.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+ // Ensure 'delete' link works.
+ $this->drupalGet('admin/config/regional/language');
+ $this->clickLink(t('delete'));
+ $this->assertText(t('Are you sure you want to delete the language'), t('"delete" link is correct.'));
+ // Delete an enabled language.
+ $this->drupalGet('admin/config/regional/language/delete/' . $langcode);
+ // First test the 'cancel' link.
+ $this->clickLink(t('Cancel'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertRaw($name, t('The language was not deleted.'));
+ // Delete the language for real. This a confirm form, we do not need any
+ // fields changed.
+ $this->drupalPost('admin/config/regional/language/delete/' . $langcode, array(), t('Delete'));
+ // We need raw here because %locale will add HTML.
+ $this->assertRaw(t('The language %locale has been removed.', array('%locale' => $name)), t('The test language has been removed.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ // Verify that language is no longer found.
+ $this->drupalGet('admin/config/regional/language/delete/' . $langcode);
+ $this->assertResponse(404, t('Language no longer found.'));
+ // Make sure the "language_count" variable has been updated correctly.
+ drupal_static_reset('language_list');
+ $enabled = language_list('enabled');
+ $this->assertEqual(variable_get('language_count', 1), count($enabled[1]), t('Language count is correct.'));
+ // Delete a disabled language.
+ // Disable an enabled language.
+ $edit = array(
+ 'languages[fr][enabled]' => FALSE,
+ );
+ $this->drupalPost($path, $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-languages-fr-enabled', t('French language disabled.'));
+ // Get the count of enabled languages.
+ drupal_static_reset('language_list');
+ $enabled = language_list('enabled');
+ // Delete the disabled language.
+ $this->drupalPost('admin/config/regional/language/delete/fr', array(), t('Delete'));
+ // We need raw here because %locale will add HTML.
+ $this->assertRaw(t('The language %locale has been removed.', array('%locale' => 'French')), t('Disabled language has been removed.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ // Verify that language is no longer found.
+ $this->drupalGet('admin/config/regional/language/delete/fr');
+ $this->assertResponse(404, t('Language no longer found.'));
+ // Make sure the "language_count" variable has not changed.
+ $this->assertEqual(variable_get('language_count', 1), count($enabled[1]), t('Language count is correct.'));
+
+ // Ensure we can delete the English language. Right now English is the only
+ // language so we must add a new language and make it the default before
+ // deleting English.
+ $langcode = 'xx';
+ $name = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText($name, t('Name found.'));
+
+ // Check if we can change the default language.
+ $path = 'admin/config/regional/language';
+ $this->drupalGet($path);
+ $this->assertFieldChecked('edit-site-default-en', t('English is the default language.'));
+ // Change the default language.
+ $edit = array(
+ 'site_default' => $langcode,
+ );
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-site-default-en', t('Default language updated.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+ $this->drupalPost('admin/config/regional/language/delete/en', array(), t('Delete'));
+ // We need raw here because %locale will add HTML.
+ $this->assertRaw(t('The language %locale has been removed.', array('%locale' => 'English')), t('The English language has been removed.'));
+ }
+
+}
+
+/**
+ * Functional tests for JavaScript parsing for translatable strings.
+ */
+class LocaleJavascriptTranslationTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Javascript translation',
+ 'description' => 'Tests parsing js files for translatable strings',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'locale_test');
+ }
+
+ function testFileParsing() {
+
+ $filename = drupal_get_path('module', 'locale_test') . '/locale_test.js';
+
+ // Parse the file to look for source strings.
+ _locale_parse_js_file($filename);
+
+ // Get all of the source strings that were found.
+ $source_strings = db_select('locales_source', 's')
+ ->fields('s', array('source', 'context'))
+ ->condition('s.location', $filename)
+ ->execute()
+ ->fetchAllKeyed();
+
+ // List of all strings that should be in the file.
+ $test_strings = array(
+ "Standard Call t" => '',
+ "Whitespace Call t" => '',
+
+ "Single Quote t" => '',
+ "Single Quote \\'Escaped\\' t" => '',
+ "Single Quote Concat strings t" => '',
+
+ "Double Quote t" => '',
+ "Double Quote \\\"Escaped\\\" t" => '',
+ "Double Quote Concat strings t" => '',
+
+ "Context !key Args t" => "Context string",
+
+ "Context Unquoted t" => "Context string unquoted",
+ "Context Single Quoted t" => "Context string single quoted",
+ "Context Double Quoted t" => "Context string double quoted",
+
+ "Standard Call plural" => '',
+ "Standard Call @count plural" => '',
+ "Whitespace Call plural" => '',
+ "Whitespace Call @count plural" => '',
+
+ "Single Quote plural" => '',
+ "Single Quote @count plural" => '',
+ "Single Quote \\'Escaped\\' plural" => '',
+ "Single Quote \\'Escaped\\' @count plural" => '',
+
+ "Double Quote plural" => '',
+ "Double Quote @count plural" => '',
+ "Double Quote \\\"Escaped\\\" plural" => '',
+ "Double Quote \\\"Escaped\\\" @count plural" => '',
+
+ "Context !key Args plural" => "Context string",
+ "Context !key Args @count plural" => "Context string",
+
+ "Context Unquoted plural" => "Context string unquoted",
+ "Context Unquoted @count plural" => "Context string unquoted",
+ "Context Single Quoted plural" => "Context string single quoted",
+ "Context Single Quoted @count plural" => "Context string single quoted",
+ "Context Double Quoted plural" => "Context string double quoted",
+ "Context Double Quoted @count plural" => "Context string double quoted",
+ );
+
+ // Assert that all strings were found properly.
+ foreach ($test_strings as $str => $context) {
+ $args = array('%source' => $str, '%context' => $context);
+
+ // Make sure that the string was found in the file.
+ $this->assertTrue(isset($source_strings[$str]), t("Found source string: %source", $args));
+
+ // Make sure that the proper context was matched.
+ $this->assertTrue(isset($source_strings[$str]) && $source_strings[$str] === $context, strlen($context) > 0 ? t("Context for %source is %context", $args) : t("Context for %source is blank", $args));
+ }
+
+ $this->assertEqual(count($source_strings), count($test_strings), t("Found correct number of source strings."));
+ }
+}
+/**
+ * Functional test for string translation and validation.
+ */
+class LocaleTranslationFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'String translate, search and validate',
+ 'description' => 'Adds a new locale and translates its name. Checks the validation of translation strings and search results.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ }
+
+ /**
+ * Adds a language and tests string translation by users with the appropriate permissions.
+ */
+ function testStringTranslation() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ // User to translate and delete string.
+ $translate_user = $this->drupalCreateUser(array('translate interface', 'access administration pages'));
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language. This will be translated.
+ $name = $this->randomName(16);
+ // This is the language indicator on the translation search screen for
+ // untranslated strings. Copied straight from locale.inc.
+ $language_indicator = "<em class=\"locale-untranslated\">$langcode</em> ";
+ // This will be the translation of $name.
+ $translation = $this->randomName(16);
+ $translation_to_en = $this->randomName(16);
+
+ // Add custom language.
+ $this->drupalLogin($admin_user);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ // Add string.
+ t($name, array(), array('langcode' => $langcode));
+ // Reset locale cache.
+ locale_reset();
+ $this->assertRaw('"edit-site-default-' . $langcode .'"', t('Language code found.'));
+ $this->assertText(t($name), t('Test language added.'));
+ $this->drupalLogout();
+
+ // Search for the name and translate it.
+ $this->drupalLogin($translate_user);
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // assertText() seems to remove the input field where $name always could be
+ // found, so this is not a false assert. See how assertNoText succeeds
+ // later.
+ $this->assertText($name, t('Search found the name.'));
+ $this->assertRaw($language_indicator, t('Name is untranslated.'));
+ // Assume this is the only result, given the random name.
+ $this->clickLink(t('edit'));
+ // We save the lid from the path.
+ $matches = array();
+ preg_match('!admin/config/regional/translate/edit/(\d+)!', $this->getUrl(), $matches);
+ $lid = $matches[1];
+ // No t() here, it's surely not translated yet.
+ $this->assertText($name, t('name found on edit screen.'));
+ $this->assertNoText('English', t('No way to translate the string to English.'));
+ $this->drupalLogout();
+ $this->drupalLogin($admin_user);
+ $this->drupalPost('admin/config/regional/language/edit/en', array('locale_translate_english' => TRUE), t('Save language'));
+ $this->drupalLogout();
+ $this->drupalLogin($translate_user);
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // assertText() seems to remove the input field where $name always could be
+ // found, so this is not a false assert. See how assertNoText succeeds
+ // later.
+ $this->assertText($name, t('Search found the name.'));
+ $this->assertRaw($language_indicator, t('Name is untranslated.'));
+ // Assume this is the only result, given the random name.
+ $this->clickLink(t('edit'));
+ $string_edit_url = $this->getUrl();
+ $edit = array(
+ "translations[$langcode]" => $translation,
+ 'translations[en]' => $translation_to_en,
+ );
+ $this->drupalPost(NULL, $edit, t('Save translations'));
+ $this->assertText(t('The string has been saved.'), t('The string has been saved.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate/translate', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->drupalGet($string_edit_url);
+ $this->assertRaw($translation, t('Non-English translation properly saved.'));
+ $this->assertRaw($translation_to_en, t('English translation properly saved.'));
+ $this->assertTrue($name != $translation && t($name, array(), array('langcode' => $langcode)) == $translation, t('t() works for non-English.'));
+ // Refresh the locale() cache to get fresh data from t() below. We are in
+ // the same HTTP request and therefore t() is not refreshed by saving the
+ // translation above.
+ locale_reset();
+ // Now we should get the proper fresh translation from t().
+ $this->assertTrue($name != $translation_to_en && t($name, array(), array('langcode' => 'en')) == $translation_to_en, t('t() works for English.'));
+ $this->assertTrue(t($name, array(), array('langcode' => LANGUAGE_SYSTEM)) == $name, t('t() works for LANGUAGE_SYSTEM.'));
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // The indicator should not be here.
+ $this->assertNoRaw($language_indicator, t('String is translated.'));
+
+ // Try to edit a non-existent string and ensure we're redirected correctly.
+ // Assuming we don't have 999,999 strings already.
+ $random_lid = 999999;
+ $this->drupalGet('admin/config/regional/translate/edit/' . $random_lid);
+ $this->assertText(t('String not found'), t('String not found.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate/translate', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->drupalLogout();
+
+ // Delete the language.
+ $this->drupalLogin($admin_user);
+ $path = 'admin/config/regional/language/delete/' . $langcode;
+ // This a confirm form, we do not need any fields changed.
+ $this->drupalPost($path, array(), t('Delete'));
+ // We need raw here because %locale will add HTML.
+ $this->assertRaw(t('The language %locale has been removed.', array('%locale' => $name)), t('The test language has been removed.'));
+ // Reload to remove $name.
+ $this->drupalGet($path);
+ // Verify that language is no longer found.
+ $this->assertResponse(404, t('Language no longer found.'));
+ $this->drupalLogout();
+
+ // Delete the string.
+ $this->drupalLogin($translate_user);
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // Assume this is the only result, given the random name.
+ $this->clickLink(t('delete'));
+ $this->assertText(t('Are you sure you want to delete the string'), t('"delete" link is correct.'));
+ // Delete the string.
+ $path = 'admin/config/regional/translate/delete/' . $lid;
+ $this->drupalGet($path);
+ // First test the 'cancel' link.
+ $this->clickLink(t('Cancel'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate/translate', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertRaw($name, t('The string was not deleted.'));
+ // Delete the name string.
+ $this->drupalPost('admin/config/regional/translate/delete/' . $lid, array(), t('Delete'));
+ $this->assertText(t('The string has been removed.'), t('The string has been removed message.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate/translate', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText($name, t('Search now can not find the name.'));
+ }
+
+ /*
+ * Adds a language and checks that the JavaScript translation files are
+ * properly created and rebuilt on deletion.
+ */
+ function testJavaScriptTranslation() {
+ $user = $this->drupalCreateUser(array('translate interface', 'administer languages', 'access administration pages'));
+ $this->drupalLogin($user);
+
+ $langcode = 'xx';
+ // The English name for the language. This will be translated.
+ $name = $this->randomName(16);
+
+ // Add custom language.
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ drupal_static_reset('language_list');
+
+ // Build the JavaScript translation file.
+ $this->drupalGet('admin/config/regional/translate/translate');
+
+ // Retrieve the id of the first string available in the {locales_source}
+ // table and translate it.
+ $query = db_select('locales_source', 'l');
+ $query->addExpression('min(l.lid)', 'lid');
+ $result = $query->condition('l.location', '%.js%', 'LIKE')->execute();
+ $url = 'admin/config/regional/translate/edit/' . $result->fetchObject()->lid;
+ $edit = array('translations['. $langcode .']' => $this->randomName());
+ $this->drupalPost($url, $edit, t('Save translations'));
+
+ // Trigger JavaScript translation parsing and building.
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ _locale_rebuild_js($langcode);
+
+ // Retrieve the JavaScript translation hash code for the custom language to
+ // check that the translation file has been properly built.
+ $file = db_select('languages', 'l')
+ ->fields('l', array('javascript'))
+ ->condition('language', $langcode)
+ ->execute()
+ ->fetchObject();
+ $js_file = 'public://' . variable_get('locale_js_directory', 'languages') . '/' . $langcode . '_' . $file->javascript . '.js';
+ $this->assertTrue($result = file_exists($js_file), t('JavaScript file created: %file', array('%file' => $result ? $js_file : t('not found'))));
+
+ // Test JavaScript translation rebuilding.
+ file_unmanaged_delete($js_file);
+ $this->assertTrue($result = !file_exists($js_file), t('JavaScript file deleted: %file', array('%file' => $result ? $js_file : t('found'))));
+ cache_clear_all();
+ _locale_rebuild_js($langcode);
+ $this->assertTrue($result = file_exists($js_file), t('JavaScript file rebuilt: %file', array('%file' => $result ? $js_file : t('not found'))));
+ }
+
+ /**
+ * Tests the validation of the translation input.
+ */
+ function testStringValidation() {
+ global $base_url;
+
+ // User to add language and strings.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages', 'translate interface'));
+ $this->drupalLogin($admin_user);
+ $langcode = 'xx';
+ // The English name for the language. This will be translated.
+ $name = $this->randomName(16);
+ // This is the language indicator on the translation search screen for
+ // untranslated strings. Copied straight from locale.inc.
+ $language_indicator = "<em class=\"locale-untranslated\">$langcode</em> ";
+ // These will be the invalid translations of $name.
+ $key = $this->randomName(16);
+ $bad_translations[$key] = "<script>alert('xss');</script>" . $key;
+ $key = $this->randomName(16);
+ $bad_translations[$key] = '<img SRC="javascript:alert(\'xss\');">' . $key;
+ $key = $this->randomName(16);
+ $bad_translations[$key] = '<<SCRIPT>alert("xss");//<</SCRIPT>' . $key;
+ $key = $this->randomName(16);
+ $bad_translations[$key] ="<BODY ONLOAD=alert('xss')>" . $key;
+
+ // Add custom language.
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ // Add string.
+ t($name, array(), array('langcode' => $langcode));
+ // Reset locale cache.
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // Find the edit path.
+ $content = $this->drupalGetContent();
+ $this->assertTrue(preg_match('@(admin/config/regional/translate/edit/[0-9]+)@', $content, $matches), t('Found the edit path.'));
+ $path = $matches[0];
+ foreach ($bad_translations as $key => $translation) {
+ $edit = array(
+ "translations[$langcode]" => $translation,
+ );
+ $this->drupalPost($path, $edit, t('Save translations'));
+ // Check for a form error on the textarea.
+ $form_class = $this->xpath('//form[@id="locale-translate-edit-form"]//textarea/@class');
+ $this->assertNotIdentical(FALSE, strpos($form_class[0], 'error'), t('The string was rejected as unsafe.'));
+ $this->assertNoText(t('The string has been saved.'), t('The string was not saved.'));
+ }
+ }
+
+ /**
+ * Tests translation search form.
+ */
+ function testStringSearch() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ // User to translate and delete string.
+ $translate_user = $this->drupalCreateUser(array('translate interface', 'access administration pages'));
+
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language. This will be translated.
+ $name = $this->randomName(16);
+ // This is the language indicator on the translation search screen for
+ // untranslated strings. Copied straight from locale.inc.
+ $language_indicator = "<em class=\"locale-untranslated\">$langcode</em> ";
+ // This will be the translation of $name.
+ $translation = $this->randomName(16);
+
+ // Add custom language.
+ $this->drupalLogin($admin_user);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ // Add string.
+ t($name, array(), array('langcode' => $langcode));
+ // Reset locale cache.
+ locale_reset();
+ $this->drupalLogout();
+
+ // Search for the name.
+ $this->drupalLogin($translate_user);
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // assertText() seems to remove the input field where $name always could be
+ // found, so this is not a false assert. See how assertNoText succeeds
+ // later.
+ $this->assertText($name, t('Search found the string.'));
+
+ // Ensure untranslated string doesn't appear if searching on 'only
+ // translated strings'.
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'translated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t("Search didn't find the string."));
+
+ // Ensure untranslated string appears if searching on 'only untranslated
+ // strings'.
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'untranslated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), t('Search found the string.'));
+
+ // Add translation.
+ // Assume this is the only result, given the random name.
+ $this->clickLink(t('edit'));
+ // We save the lid from the path.
+ $matches = array();
+ preg_match('!admin/config/regional/translate/edit/(\d)+!', $this->getUrl(), $matches);
+ $lid = $matches[1];
+ $edit = array(
+ "translations[$langcode]" => $translation,
+ );
+ $this->drupalPost(NULL, $edit, t('Save translations'));
+
+ // Ensure translated string does appear if searching on 'only
+ // translated strings'.
+ $search = array(
+ 'string' => $translation,
+ 'language' => 'all',
+ 'translation' => 'translated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), t('Search found the translation.'));
+
+ // Ensure translated source string doesn't appear if searching on 'only
+ // untranslated strings'.
+ $search = array(
+ 'string' => $name,
+ 'language' => 'all',
+ 'translation' => 'untranslated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t("Search didn't find the source string."));
+
+ // Ensure translated string doesn't appear if searching on 'only
+ // untranslated strings'.
+ $search = array(
+ 'string' => $translation,
+ 'language' => 'all',
+ 'translation' => 'untranslated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t("Search didn't find the translation."));
+
+ // Ensure translated string does appear if searching on the custom language.
+ $search = array(
+ 'string' => $translation,
+ 'language' => $langcode,
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), t('Search found the translation.'));
+
+ // Ensure translated string doesn't appear if searching in System (English).
+ $search = array(
+ 'string' => $translation,
+ 'language' => LANGUAGE_SYSTEM,
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t("Search didn't find the translation."));
+
+ // Search for a string that isn't in the system.
+ $unavailable_string = $this->randomName(16);
+ $search = array(
+ 'string' => $unavailable_string,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t("Search didn't find the invalid string."));
+ }
+}
+
+/**
+ * Functional tests for the import of translation files.
+ */
+class LocaleImportFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Translation import',
+ 'description' => 'Tests the import of locale files.',
+ 'group' => 'Locale',
+ );
+ }
+
+ /**
+ * A user able to create languages and import translations.
+ */
+ protected $admin_user = NULL;
+
+ function setUp() {
+ parent::setUp('locale', 'locale_test');
+
+ // Set the translation file directory.
+ variable_set('locale_translate_file_directory', drupal_get_path('module', 'locale_test'));
+
+ $this->admin_user = $this->drupalCreateUser(array('administer languages', 'translate interface', 'access administration pages'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Test import of standalone .po files.
+ */
+ function testStandalonePoFile() {
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFile(), array(
+ 'langcode' => 'fr',
+ ));
+
+ // The import should automatically create the corresponding language.
+ $this->assertRaw(t('The language %language has been created.', array('%language' => 'French')), t('The language has been automatically created.'));
+
+ // The import should have created 7 strings.
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 9, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.'));
+
+ // This import should have saved plural forms to have 2 variants.
+ $this->assert(db_query("SELECT plurals FROM {languages} WHERE language = 'fr'")->fetchField() == 2, t('Plural number initialized.'));
+
+ // Ensure we were redirected correctly.
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+
+ // Try importing a .po file with invalid tags.
+ $this->importPoFile($this->getBadPoFile(), array(
+ 'langcode' => 'fr',
+ ));
+
+ // The import should have created 1 string and rejected 2.
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 1, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.'));
+ $skip_message = format_plural(2, 'A translation string was skipped because of disallowed or malformed HTML. <a href="@url">See the log</a> for details.', '@count translation strings were skipped because of disallowed or malformed HTML. <a href="@url">See the log</a> for details.', array('@url' => url('admin/reports/dblog')));
+ $this->assertRaw($skip_message, t('Unsafe strings were skipped.'));
+
+
+ // Try importing a .po file which doesn't exist.
+ $name = $this->randomName(16);
+ $this->drupalPost('admin/config/regional/translate/import', array(
+ 'langcode' => 'fr',
+ 'files[file]' => $name,
+ ), t('Import'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/translate/import', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('File to import not found.'), t('File to import not found message.'));
+
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // strings are kept.
+ $this->importPoFile($this->getOverwritePoFile(), array(
+ 'langcode' => 'fr',
+ 'mode' => 1, // Existing strings are kept, only new strings are added.
+ ));
+
+ // The import should have created 1 string.
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 1, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.'));
+ // Ensure string wasn't overwritten.
+ $search = array(
+ 'string' => 'Montag',
+ 'language' => 'fr',
+ 'translation' => 'translated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertText(t('No strings available.'), t('String not overwritten by imported string.'));
+
+ // This import should not have changed number of plural forms.
+ $this->assert(db_query("SELECT plurals FROM {languages} WHERE language = 'fr'")->fetchField() == 2, t('Plural numbers untouched.'));
+
+ // Try importing a .po file with overriding strings, and ensure existing
+ // strings are overwritten.
+ $this->importPoFile($this->getOverwritePoFile(), array(
+ 'langcode' => 'fr',
+ 'mode' => 0, // Strings in the uploaded file replace existing ones, new ones are added.
+ ));
+
+ // The import should have updated 2 strings.
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 0, '%update' => 2, '%delete' => 0)), t('The translation file was successfully imported.'));
+ // Ensure string was overwritten.
+ $search = array(
+ 'string' => 'Montag',
+ 'language' => 'fr',
+ 'translation' => 'translated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), t('String overwritten by imported string.'));
+ // This import should have changed number of plural forms.
+ $this->assert(db_query("SELECT plurals FROM {languages} WHERE language = 'fr'")->fetchField() == 3, t('Plural numbers changed.'));
+ }
+
+ /**
+ * Test automatic import of a module's translation files when a language is
+ * enabled.
+ */
+ function testAutomaticModuleTranslationImportLanguageEnable() {
+ // Code for the language - manually set to match the test translation file.
+ $langcode = 'xx';
+ // The English name for the language.
+ $name = $this->randomName(16);
+
+ // Create a custom language.
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Ensure the translation file was automatically imported when language was
+ // added.
+ $this->assertText(t('One translation file imported.'), t('Language file automatically imported.'));
+
+ // Ensure strings were successfully imported.
+ $search = array(
+ 'string' => 'lundi',
+ 'language' => $langcode,
+ 'translation' => 'translated',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ $this->assertNoText(t('No strings available.'), t('String successfully imported.'));
+ }
+
+ /**
+ * Test msgctxt context support.
+ */
+ function testLanguageContext() {
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithContext(), array(
+ 'langcode' => 'hr',
+ ));
+
+ $this->assertIdentical(t('May', array(), array('langcode' => 'hr', 'context' => 'Long month name')), 'Svibanj', t('Long month name context is working.'));
+ $this->assertIdentical(t('May', array(), array('langcode' => 'hr')), 'Svi.', t('Default context is working.'));
+ }
+
+ /**
+ * Test empty msgstr at end of .po file see #611786.
+ */
+ function testEmptyMsgstr() {
+ $langcode = 'hu';
+
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithMsgstr(), array(
+ 'langcode' => $langcode,
+ ));
+
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 1, '%update' => 0, '%delete' => 0)), t('The translation file was successfully imported.'));
+ $this->assertIdentical(t('Operations', array(), array('langcode' => $langcode)), 'Műveletek', t('String imported and translated.'));
+
+ // Try importing a .po file.
+ $this->importPoFile($this->getPoFileWithEmptyMsgstr(), array(
+ 'langcode' => $langcode,
+ 'mode' => 0,
+ ));
+ $this->assertRaw(t('The translation was successfully imported. There are %number newly created translated strings, %update strings were updated and %delete strings were removed.', array('%number' => 0, '%update' => 0, '%delete' => 1)), t('The translation file was successfully imported.'));
+ // This is the language indicator on the translation search screen for
+ // untranslated strings. Copied straight from locale.inc.
+ $language_indicator = "<em class=\"locale-untranslated\">$langcode</em> ";
+ $str = "Operations";
+ $search = array(
+ 'string' => $str,
+ 'language' => 'all',
+ 'translation' => 'all',
+ );
+ $this->drupalPost('admin/config/regional/translate/translate', $search, t('Filter'));
+ // assertText() seems to remove the input field where $str always could be
+ // found, so this is not a false assert.
+ $this->assertText($str, t('Search found the string.'));
+ $this->assertRaw($language_indicator, t('String is untranslated again.'));
+ }
+
+ /**
+ * Helper function: import a standalone .po file in a given language.
+ *
+ * @param $contents
+ * Contents of the .po file to import.
+ * @param $options
+ * Additional options to pass to the translation import form.
+ */
+ function importPoFile($contents, array $options = array()) {
+ $name = tempnam('temporary://', "po_") . '.po';
+ file_put_contents($name, $contents);
+ $options['files[file]'] = $name;
+ $this->drupalPost('admin/config/regional/translate/import', $options, t('Import'));
+ drupal_unlink($name);
+ }
+
+ /**
+ * Helper function that returns a proper .po file.
+ */
+ function getPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "One sheep"
+msgid_plural "@count sheep"
+msgstr[0] "un mouton"
+msgstr[1] "@count moutons"
+
+msgid "Monday"
+msgstr "lundi"
+
+msgid "Tuesday"
+msgstr "mardi"
+
+msgid "Wednesday"
+msgstr "mercredi"
+
+msgid "Thursday"
+msgstr "jeudi"
+
+msgid "Friday"
+msgstr "vendredi"
+
+msgid "Saturday"
+msgstr "samedi"
+
+msgid "Sunday"
+msgstr "dimanche"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a bad .po file.
+ */
+ function getBadPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Save configuration"
+msgstr "Enregistrer la configuration"
+
+msgid "edit"
+msgstr "modifier<img SRC="javascript:alert(\'xss\');">"
+
+msgid "delete"
+msgstr "supprimer<script>alert('xss');</script>"
+
+EOF;
+ }
+
+ /**
+ * Helper function that returns a proper .po file, for testing overwriting
+ * existing translations.
+ */
+ function getOverwritePoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n"
+
+msgid "Monday"
+msgstr "Montag"
+
+msgid "Day"
+msgstr "Jour"
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with context.
+ */
+ function getPoFileWithContext() {
+ // Croatian (code hr) is one the the languages that have a different
+ // form for the full name and the abbreviated name for the month May.
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\\n"
+
+msgctxt "Long month name"
+msgid "May"
+msgstr "Svibanj"
+
+msgid "May"
+msgstr "Svi."
+EOF;
+ }
+
+ /**
+ * Helper function that returns a .po file with an empty last item.
+ */
+ function getPoFileWithEmptyMsgstr() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Operations"
+msgstr ""
+
+EOF;
+ }
+ /**
+ * Helper function that returns a .po file with an empty last item.
+ */
+ function getPoFileWithMsgstr() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Operations"
+msgstr "Műveletek"
+
+msgid "Will not appear in Drupal core, so we can ensure the test passes"
+msgstr ""
+
+EOF;
+ }
+
+}
+
+/**
+ * Functional tests for the export of translation files.
+ */
+class LocaleExportFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Translation export',
+ 'description' => 'Tests the exportation of locale files.',
+ 'group' => 'Locale',
+ );
+ }
+
+ /**
+ * A user able to create languages and export translations.
+ */
+ protected $admin_user = NULL;
+
+ function setUp() {
+ parent::setUp('locale', 'locale_test');
+
+ $this->admin_user = $this->drupalCreateUser(array('administer languages', 'translate interface', 'access administration pages'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Test exportation of translations.
+ */
+ function testExportTranslation() {
+ // First import some known translations.
+ // This will also automatically enable the 'fr' language.
+ $name = tempnam('temporary://', "po_") . '.po';
+ file_put_contents($name, $this->getPoFile());
+ $this->drupalPost('admin/config/regional/translate/import', array(
+ 'langcode' => 'fr',
+ 'files[file]' => $name,
+ ), t('Import'));
+ drupal_unlink($name);
+
+ // Get the French translations.
+ $this->drupalPost('admin/config/regional/translate/export', array(
+ 'langcode' => 'fr',
+ ), t('Export'));
+
+ // Ensure we have a translation file.
+ $this->assertRaw('# French translation of Drupal', t('Exported French translation file.'));
+ // Ensure our imported translations exist in the file.
+ $this->assertRaw('msgstr "lundi"', t('French translations present in exported file.'));
+ }
+
+ /**
+ * Test exportation of translation template file.
+ */
+ function testExportTranslationTemplateFile() {
+ // Get the translation template file.
+ // There are two 'Export' buttons on this page, but it somehow works. It'd
+ // be better if we could use the submit button id like documented but that
+ // doesn't work.
+ $this->drupalPost('admin/config/regional/translate/export', array(), t('Export'));
+ // Ensure we have a translation file.
+ $this->assertRaw('# LANGUAGE translation of PROJECT', t('Exported translation template file.'));
+ }
+
+ /**
+ * Helper function that returns a proper .po file.
+ */
+ function getPoFile() {
+ return <<< EOF
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 6\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Monday"
+msgstr "lundi"
+EOF;
+ }
+
+}
+
+/**
+ * Tests for the st() function.
+ */
+class LocaleInstallTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'String translation using st()',
+ 'description' => 'Tests that st() works like t().',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+
+ // st() lives in install.inc, so ensure that it is loaded for all tests.
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ }
+
+ /**
+ * Verify that function signatures of t() and st() are equal.
+ */
+ function testFunctionSignatures() {
+ $reflector_t = new ReflectionFunction('t');
+ $reflector_st = new ReflectionFunction('st');
+ $this->assertEqual($reflector_t->getParameters(), $reflector_st->getParameters(), t('Function signatures of t() and st() are equal.'));
+ }
+}
+
+/**
+ * Locale uninstall with English UI functional test.
+ */
+class LocaleUninstallFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Locale uninstall (EN)',
+ 'description' => 'Tests the uninstall process using the built-in UI language.',
+ 'group' => 'Locale',
+ );
+ }
+
+ /**
+ * The default language set for the UI before uninstall.
+ */
+ protected $language;
+
+ function setUp() {
+ parent::setUp('locale');
+ $this->language = 'en';
+ }
+
+ /**
+ * Check if the values of the Locale variables are correct after uninstall.
+ */
+ function testUninstallProcess() {
+ $locale_module = array('locale');
+
+ // Add a new language and optionally set it as default.
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+
+ $language = (object) array(
+ 'language' => 'fr',
+ 'name' => 'French',
+ 'default' => $this->language == 'fr',
+ );
+ locale_language_save($language);
+
+ // Check the UI language.
+ drupal_language_initialize();
+ $this->assertEqual($GLOBALS['language']->language, $this->language, t('Current language: %lang', array('%lang' => $GLOBALS['language']->language)));
+
+ // Enable multilingual workflow option for articles.
+ variable_set('language_content_type_article', 1);
+
+ // Change JavaScript translations directory.
+ variable_set('locale_js_directory', 'js_translations');
+
+ // Build the JavaScript translation file for French.
+ $user = $this->drupalCreateUser(array('translate interface', 'access administration pages'));
+ $this->drupalLogin($user);
+ $this->drupalGet('admin/config/regional/translate/translate');
+ $string = db_query('SELECT min(lid) AS lid FROM {locales_source} WHERE location LIKE :location', array(
+ ':location' => '%.js%',
+ ))->fetchObject();
+ $edit = array('translations[fr]' => 'french translation');
+ $this->drupalPost('admin/config/regional/translate/edit/' . $string->lid, $edit, t('Save translations'));
+ _locale_rebuild_js('fr');
+ $file = db_query('SELECT javascript FROM {languages} WHERE language = :language', array(':language' => 'fr'))->fetchObject();
+ $js_file = 'public://' . variable_get('locale_js_directory', 'languages') . '/fr_' . $file->javascript . '.js';
+ $this->assertTrue($result = file_exists($js_file), t('JavaScript file created: %file', array('%file' => $result ? $js_file : t('none'))));
+
+ // Disable string caching.
+ variable_set('locale_cache_strings', 0);
+
+ // Change language negotiation options.
+ drupal_load('module', 'locale');
+ variable_set('language_types', drupal_language_types() + array('language_custom' => TRUE));
+ variable_set('language_negotiation_' . LANGUAGE_TYPE_INTERFACE, locale_language_negotiation_info());
+ variable_set('language_negotiation_' . LANGUAGE_TYPE_CONTENT, locale_language_negotiation_info());
+ variable_set('language_negotiation_' . LANGUAGE_TYPE_URL, locale_language_negotiation_info());
+
+ // Change language providers settings.
+ variable_set('locale_language_negotiation_url_part', LOCALE_LANGUAGE_NEGOTIATION_URL_PREFIX);
+ variable_set('locale_language_negotiation_session_param', TRUE);
+
+ // Uninstall Locale.
+ module_disable($locale_module);
+ drupal_uninstall_modules($locale_module);
+
+ // Visit the front page.
+ $this->drupalGet('');
+
+ // Check the init language logic.
+ drupal_language_initialize();
+ $this->assertEqual($GLOBALS['language']->language, 'en', t('Language after uninstall: %lang', array('%lang' => $GLOBALS['language']->language)));
+
+ // Check JavaScript files deletion.
+ $this->assertTrue($result = !file_exists($js_file), t('JavaScript file deleted: %file', array('%file' => $result ? $js_file : t('found'))));
+
+ // Check language count.
+ $language_count = variable_get('language_count', 1);
+ $this->assertEqual($language_count, 1, t('Language count: %count', array('%count' => $language_count)));
+
+ // Check language negotiation.
+ require_once DRUPAL_ROOT . '/core/includes/language.inc';
+ $this->assertTrue(count(language_types()) == count(drupal_language_types()), t('Language types reset'));
+ $language_negotiation = language_negotiation_get(LANGUAGE_TYPE_INTERFACE) == LANGUAGE_NEGOTIATION_DEFAULT;
+ $this->assertTrue($language_negotiation, t('Interface language negotiation: %setting', array('%setting' => t($language_negotiation ? 'none' : 'set'))));
+ $language_negotiation = language_negotiation_get(LANGUAGE_TYPE_CONTENT) == LANGUAGE_NEGOTIATION_DEFAULT;
+ $this->assertTrue($language_negotiation, t('Content language negotiation: %setting', array('%setting' => t($language_negotiation ? 'none' : 'set'))));
+ $language_negotiation = language_negotiation_get(LANGUAGE_TYPE_URL) == LANGUAGE_NEGOTIATION_DEFAULT;
+ $this->assertTrue($language_negotiation, t('URL language negotiation: %setting', array('%setting' => t($language_negotiation ? 'none' : 'set'))));
+
+ // Check language providers settings.
+ $this->assertFalse(variable_get('locale_language_negotiation_url_part', FALSE), t('URL language provider indicator settings cleared.'));
+ $this->assertFalse(variable_get('locale_language_negotiation_session_param', FALSE), t('Visit language provider settings cleared.'));
+
+ // Check JavaScript parsed.
+ $javascript_parsed_count = count(variable_get('javascript_parsed', array()));
+ $this->assertEqual($javascript_parsed_count, 0, t('JavaScript parsed count: %count', array('%count' => $javascript_parsed_count)));
+
+ // Check multilingual workflow option for articles.
+ $multilingual = variable_get('language_content_type_article', 0);
+ $this->assertEqual($multilingual, 0, t('Multilingual workflow option: %status', array('%status' => t($multilingual ? 'enabled': 'disabled'))));
+
+ // Check JavaScript translations directory.
+ $locale_js_directory = variable_get('locale_js_directory', 'languages');
+ $this->assertEqual($locale_js_directory, 'languages', t('JavaScript translations directory: %dir', array('%dir' => $locale_js_directory)));
+
+ // Check string caching.
+ $locale_cache_strings = variable_get('locale_cache_strings', 1);
+ $this->assertEqual($locale_cache_strings, 1, t('String caching: %status', array('%status' => t($locale_cache_strings ? 'enabled': 'disabled'))));
+ }
+}
+
+/**
+ * Locale uninstall with French UI functional test.
+ *
+ * Because this class extends LocaleUninstallFunctionalTest, it doesn't require a new
+ * test of its own. Rather, it switches the default UI language in setUp and then
+ * runs the testUninstallProcess (which it inherits from LocaleUninstallFunctionalTest)
+ * to test with this new language.
+ */
+class LocaleUninstallFrenchFunctionalTest extends LocaleUninstallFunctionalTest {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Locale uninstall (FR)',
+ 'description' => 'Tests the uninstall process using French as interface language.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->language = 'fr';
+ }
+}
+
+
+/**
+ * Functional tests for the language switching feature.
+ */
+class LocaleLanguageSwitchingFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Language switching',
+ 'description' => 'Tests for the language switching feature.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+
+ // Create and login user.
+ $admin_user = $this->drupalCreateUser(array('administer blocks', 'administer languages', 'translate interface', 'access administration pages'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Functional tests for the language switcher block.
+ */
+ function testLanguageBlock() {
+ // Enable the language switching block.
+ $language_type = LANGUAGE_TYPE_INTERFACE;
+ $edit = array(
+ "blocks[locale_{$language_type}][region]" => 'sidebar_first',
+ );
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Add language.
+ $edit = array(
+ 'predefined_langcode' => 'fr',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => '1');
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Assert that the language switching block is displayed on the frontpage.
+ $this->drupalGet('');
+ $this->assertText(t('Languages'), t('Language switcher block found.'));
+
+ // Assert that only the current language is marked as active.
+ list($language_switcher) = $this->xpath('//div[@id=:id]/div[@class="content"]', array(':id' => 'block-locale-' . $language_type));
+ $links = array(
+ 'active' => array(),
+ 'inactive' => array(),
+ );
+ $anchors = array(
+ 'active' => array(),
+ 'inactive' => array(),
+ );
+ foreach ($language_switcher->ul->li as $link) {
+ $classes = explode(" ", (string) $link['class']);
+ list($langcode) = array_intersect($classes, array('en', 'fr'));
+ if (in_array('active', $classes)) {
+ $links['active'][] = $langcode;
+ }
+ else {
+ $links['inactive'][] = $langcode;
+ }
+ $anchor_classes = explode(" ", (string) $link->a['class']);
+ if (in_array('active', $anchor_classes)) {
+ $anchors['active'][] = $langcode;
+ }
+ else {
+ $anchors['inactive'][] = $langcode;
+ }
+ }
+ $this->assertIdentical($links, array('active' => array('en'), 'inactive' => array('fr')), t('Only the current language list item is marked as active on the language switcher block.'));
+ $this->assertIdentical($anchors, array('active' => array('en'), 'inactive' => array('fr')), t('Only the current language anchor is marked as active on the language switcher block.'));
+ }
+}
+
+/**
+ * Test browser language detection.
+ */
+class LocaleBrowserDetectionTest extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Browser language detection',
+ 'description' => 'Tests for the browser language detection.',
+ 'group' => 'Locale',
+ );
+ }
+
+ /**
+ * Unit tests for the locale_language_from_browser() function.
+ */
+ function testLanguageFromBrowser() {
+ // Load the required functions.
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+
+ $languages = array(
+ // In our test case, 'en' has priority over 'en-US'.
+ 'en' => (object) array(
+ 'language' => 'en',
+ ),
+ 'en-US' => (object) array(
+ 'language' => 'en-US',
+ ),
+ // But 'fr-CA' has priority over 'fr'.
+ 'fr-CA' => (object) array(
+ 'language' => 'fr-CA',
+ ),
+ 'fr' => (object) array(
+ 'language' => 'fr',
+ ),
+ // 'es-MX' is alone.
+ 'es-MX' => (object) array(
+ 'language' => 'es-MX',
+ ),
+ // 'pt' is alone.
+ 'pt' => (object) array(
+ 'language' => 'pt',
+ ),
+ // Language codes with more then one dash are actually valid.
+ // eh-oh-laa-laa is the official language code of the Teletubbies.
+ 'eh-oh-laa-laa' => (object) array(
+ 'language' => 'eh-oh-laa-laa',
+ ),
+ );
+
+ $test_cases = array(
+ // Equal qvalue for each language, choose the site prefered one.
+ 'en,en-US,fr-CA,fr,es-MX' => 'en',
+ 'en-US,en,fr-CA,fr,es-MX' => 'en',
+ 'fr,en' => 'en',
+ 'en,fr' => 'en',
+ 'en-US,fr' => 'en',
+ 'fr,en-US' => 'en',
+ 'fr,fr-CA' => 'fr-CA',
+ 'fr-CA,fr' => 'fr-CA',
+ 'fr' => 'fr-CA',
+ 'fr;q=1' => 'fr-CA',
+ 'fr,es-MX' => 'fr-CA',
+ 'fr,es' => 'fr-CA',
+ 'es,fr' => 'fr-CA',
+ 'es-MX,de' => 'es-MX',
+ 'de,es-MX' => 'es-MX',
+
+ // Different cases and whitespace.
+ 'en' => 'en',
+ 'En' => 'en',
+ 'EN' => 'en',
+ ' en' => 'en',
+ 'en ' => 'en',
+
+ // A less specific language from the browser matches a more specific one
+ // from the website, and the other way around for compatibility with
+ // some versions of Internet Explorer.
+ 'es' => 'es-MX',
+ 'es-MX' => 'es-MX',
+ 'pt' => 'pt',
+ 'pt-PT' => 'pt',
+ 'pt-PT;q=0.5,pt-BR;q=1,en;q=0.7' => 'en',
+ 'pt-PT;q=1,pt-BR;q=0.5,en;q=0.7' => 'en',
+ 'pt-PT;q=0.4,pt-BR;q=0.1,en;q=0.7' => 'en',
+ 'pt-PT;q=0.1,pt-BR;q=0.4,en;q=0.7' => 'en',
+
+ // Language code with several dashes are valid. The less specific language
+ // from the browser matches the more specific one from the website.
+ 'eh-oh-laa-laa' => 'eh-oh-laa-laa',
+ 'eh-oh-laa' => 'eh-oh-laa-laa',
+ 'eh-oh' => 'eh-oh-laa-laa',
+ 'eh' => 'eh-oh-laa-laa',
+
+ // Different qvalues.
+ 'en-US,en;q=0.5,fr;q=0.25' => 'en-US',
+ 'fr,en;q=0.5' => 'fr-CA',
+ 'fr,en;q=0.5,fr-CA;q=0.25' => 'fr',
+
+ // Silly wildcards are also valid.
+ '*,fr-CA;q=0.5' => 'en',
+ '*,en;q=0.25' => 'fr-CA',
+ 'en,en-US;q=0.5,fr;q=0.25' => 'en',
+ 'en-US,en;q=0.5,fr;q=0.25' => 'en-US',
+
+ // Unresolvable cases.
+ '' => FALSE,
+ 'de,pl' => FALSE,
+ $this->randomName(10) => FALSE,
+ );
+
+ foreach ($test_cases as $accept_language => $expected_result) {
+ $_SERVER['HTTP_ACCEPT_LANGUAGE'] = $accept_language;
+ $result = locale_language_from_browser($languages);
+ $this->assertIdentical($result, $expected_result, t("Language selection '@accept-language' selects '@result', result = '@actual'", array('@accept-language' => $accept_language, '@result' => $expected_result, '@actual' => isset($result) ? $result : 'none')));
+ }
+ }
+}
+
+/**
+ * Functional tests for a user's ability to change their default language.
+ */
+class LocaleUserLanguageFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User language settings',
+ 'description' => "Tests user's ability to change their default language.",
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ }
+
+ /**
+ * Test if user can change their default language.
+ */
+ function testUserLanguageConfiguration() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ // User to change their default language.
+ $web_user = $this->drupalCreateUser();
+
+ // Add custom language.
+ $this->drupalLogin($admin_user);
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language.
+ $name = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Add custom language and disable it.
+ // Code for the language.
+ $langcode_disabled = 'xx-yy';
+ // The English name for the language. This will be translated.
+ $name_disabled = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode_disabled,
+ 'name' => $name_disabled,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ // Disable the language.
+ $edit = array(
+ 'languages[' . $langcode_disabled . '][enabled]' => FALSE,
+ );
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+ $this->drupalLogout();
+
+ // Login as normal user and edit account settings.
+ $this->drupalLogin($web_user);
+ $path = 'user/' . $web_user->uid . '/edit';
+ $this->drupalGet($path);
+ // Ensure language settings fieldset is available.
+ $this->assertText(t('Language settings'), t('Language settings available.'));
+ // Ensure custom language is present.
+ $this->assertText($name, t('Language present on form.'));
+ // Ensure disabled language isn't present.
+ $this->assertNoText($name_disabled, t('Disabled language not present on form.'));
+ // Switch to our custom language.
+ $edit = array(
+ 'language' => $langcode,
+ );
+ $this->drupalPost($path, $edit, t('Save'));
+ // Ensure form was submitted successfully.
+ $this->assertText(t('The changes have been saved.'), t('Changes were saved.'));
+ // Check if language was changed.
+ $elements = $this->xpath('//input[@id=:id]', array(':id' => 'edit-language-' . $langcode));
+ $this->assertTrue(isset($elements[0]) && !empty($elements[0]['checked']), t('Default language successfully updated.'));
+
+ $this->drupalLogout();
+ }
+}
+
+/**
+ * Functional test for language handling during user creation.
+ */
+class LocaleUserCreationTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User creation',
+ 'description' => 'Tests whether proper language is stored for new users and access to language selector.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ }
+
+ /**
+ * Functional test for language handling during user creation.
+ */
+ function testLocalUserCreation() {
+ // User to add and remove language and create new users.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages', 'administer users'));
+ $this->drupalLogin($admin_user);
+
+ // Add predefined language.
+ $langcode = 'fr';
+ $edit = array(
+ 'predefined_langcode' => 'fr',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+ $this->assertText($langcode, t('Language added successfully.'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/language', array('absolute' => TRUE)), t('Correct page redirection.'));
+
+ // Set language negotiation.
+ $edit = array(
+ 'language[enabled][locale-url]' => TRUE,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ $this->assertText(t('Language negotiation configuration saved.'), t('Set language negotiation.'));
+
+ // Check if the language selector is available on admin/people/create and
+ // set to the currently active language.
+ $this->drupalGet($langcode . '/admin/people/create');
+ $this->assertFieldChecked("edit-language-$langcode", t('Global language set in the language selector.'));
+
+ // Create a user with the admin/people/create form and check if the correct
+ // language is set.
+ $username = $this->randomName(10);
+ $edit = array(
+ 'name' => $username,
+ 'mail' => $this->randomName(4) . '@example.com',
+ 'pass[pass1]' => $username,
+ 'pass[pass2]' => $username,
+ );
+
+ $this->drupalPost($langcode . '/admin/people/create', $edit, t('Create new account'));
+
+ $user = user_load_by_name($username);
+ $this->assertEqual($user->language, $langcode, t('New user has correct language set.'));
+
+ // Register a new user and check if the language selector is hidden.
+ $this->drupalLogout();
+
+ $this->drupalGet($langcode . '/user/register');
+ $this->assertNoFieldByName('language[fr]', t('Language selector is not accessible.'));
+
+ $username = $this->randomName(10);
+ $edit = array(
+ 'name' => $username,
+ 'mail' => $this->randomName(4) . '@example.com',
+ );
+
+ $this->drupalPost($langcode . '/user/register', $edit, t('Create new account'));
+
+ $user = user_load_by_name($username);
+ $this->assertEqual($user->language, $langcode, t('New user has correct language set.'));
+
+ // Test if the admin can use the language selector and if the
+ // correct language is was saved.
+ $user_edit = $langcode . '/user/' . $user->uid . '/edit';
+
+ $this->drupalLogin($admin_user);
+ $this->drupalGet($user_edit);
+ $this->assertFieldChecked("edit-language-$langcode", t('Language selector is accessible and correct language is selected.'));
+
+ // Set pass_raw so we can login the new user.
+ $user->pass_raw = $this->randomName(10);
+ $edit = array(
+ 'pass[pass1]' => $user->pass_raw,
+ 'pass[pass2]' => $user->pass_raw,
+ );
+
+ $this->drupalPost($user_edit, $edit, t('Save'));
+
+ $this->drupalLogin($user);
+ $this->drupalGet($user_edit);
+ $this->assertFieldChecked("edit-language-$langcode", t('Language selector is accessible and correct language is selected.'));
+ }
+}
+
+/**
+ * Functional tests for configuring a different path alias per language.
+ */
+class LocalePathFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Path language settings',
+ 'description' => 'Checks you can configure a language for individual url aliases.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'path');
+ }
+
+ /**
+ * Test if a language can be associated with a path alias.
+ */
+ function testPathLanguageConfiguration() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'create page content', 'administer url aliases', 'create url aliases', 'access administration pages'));
+
+ // Add custom language.
+ $this->drupalLogin($admin_user);
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language.
+ $name = $this->randomName(16);
+ // The domain prefix.
+ $prefix = $langcode;
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Set path prefix.
+ $edit = array( "prefix[$langcode]" => $prefix );
+ $this->drupalPost('admin/config/regional/language/configure/url', $edit, t('Save configuration'));
+
+ // Check that the "xx" front page is not available when path prefixes are
+ // not enabled yet.
+ $this->drupalPost('admin/config/regional/language/configure', array(), t('Save settings'));
+ $this->drupalGet($prefix);
+ $this->assertResponse(404, t('The "xx" front page is not available yet.'));
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => 1);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Create a node.
+ $node = $this->drupalCreateNode(array('type' => 'page'));
+
+ // Create a path alias in default language (English).
+ $path = 'admin/config/search/path/add';
+ $english_path = $this->randomName(8);
+ $edit = array(
+ 'source' => 'node/' . $node->nid,
+ 'alias' => $english_path,
+ 'language' => 'en',
+ );
+ $this->drupalPost($path, $edit, t('Save'));
+
+ // Create a path alias in new custom language.
+ $custom_language_path = $this->randomName(8);
+ $edit = array(
+ 'source' => 'node/' . $node->nid,
+ 'alias' => $custom_language_path,
+ 'language' => $langcode,
+ );
+ $this->drupalPost($path, $edit, t('Save'));
+
+ // Confirm English language path alias works.
+ $this->drupalGet($english_path);
+ $this->assertText($node->title, t('English alias works.'));
+
+ // Confirm custom language path alias works.
+ $this->drupalGet($prefix . '/' . $custom_language_path);
+ $this->assertText($node->title, t('Custom language alias works.'));
+
+ // Create a custom path.
+ $custom_path = $this->randomName(8);
+
+ // Check priority of language for alias by source path.
+ $edit = array(
+ 'source' => 'node/' . $node->nid,
+ 'alias' => $custom_path,
+ 'language' => LANGUAGE_NONE,
+ );
+ path_save($edit);
+ $lookup_path = drupal_lookup_path('alias', 'node/' . $node->nid, 'en');
+ $this->assertEqual($english_path, $lookup_path, t('English language alias has priority.'));
+ // Same check for language 'xx'.
+ $lookup_path = drupal_lookup_path('alias', 'node/' . $node->nid, $prefix);
+ $this->assertEqual($custom_language_path, $lookup_path, t('Custom language alias has priority.'));
+ path_delete($edit);
+
+ // Create language nodes to check priority of aliases.
+ $first_node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $second_node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+
+ // Assign a custom path alias to the first node with the English language.
+ $edit = array(
+ 'source' => 'node/' . $first_node->nid,
+ 'alias' => $custom_path,
+ 'language' => 'en',
+ );
+ path_save($edit);
+
+ // Assign a custom path alias to second node with LANGUAGE_NONE.
+ $edit = array(
+ 'source' => 'node/' . $second_node->nid,
+ 'alias' => $custom_path,
+ 'language' => LANGUAGE_NONE,
+ );
+ path_save($edit);
+
+ // Test that both node titles link to our path alias.
+ $this->drupalGet('<front>');
+ $custom_path_url = base_path() . (variable_get('clean_url', 0) ? $custom_path : '?q=' . $custom_path);
+ $elements = $this->xpath('//a[@href=:href and .=:title]', array(':href' => $custom_path_url, ':title' => $first_node->title));
+ $this->assertTrue(!empty($elements), t('First node links to the path alias.'));
+ $elements = $this->xpath('//a[@href=:href and .=:title]', array(':href' => $custom_path_url, ':title' => $second_node->title));
+ $this->assertTrue(!empty($elements), t('Second node links to the path alias.'));
+
+ // Confirm that the custom path leads to the first node.
+ $this->drupalGet($custom_path);
+ $this->assertText($first_node->title, t('Custom alias returns first node.'));
+
+ // Confirm that the custom path with prefix leads to the second node.
+ $this->drupalGet($prefix . '/' . $custom_path);
+ $this->assertText($second_node->title, t('Custom alias with prefix returns second node.'));
+
+ }
+}
+
+/**
+ * Functional tests for multilingual support on nodes.
+ */
+class LocaleContentFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Content language settings',
+ 'description' => 'Checks you can enable multilingual support on content types and configure a language for a node.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ }
+
+ /**
+ * Test if a content type can be set to multilingual and language setting is
+ * present on node add and edit forms.
+ */
+ function testContentTypeLanguageConfiguration() {
+ global $base_url;
+
+ // User to add and remove language.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'administer content types', 'access administration pages'));
+ // User to create a node.
+ $web_user = $this->drupalCreateUser(array('create article content', 'create page content', 'edit any page content'));
+
+ // Add custom language.
+ $this->drupalLogin($admin_user);
+ // Code for the language.
+ $langcode = 'xx';
+ // The English name for the language.
+ $name = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode,
+ 'name' => $name,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Add disabled custom language.
+ // Code for the language.
+ $langcode_disabled = 'xx-yy';
+ // The English name for the language.
+ $name_disabled = $this->randomName(16);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => $langcode_disabled,
+ 'name' => $name_disabled,
+ 'direction' => '0',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+ // Disable second custom language.
+ $path = 'admin/config/regional/language';
+ $edit = array(
+ 'languages[' . $langcode_disabled . '][enabled]' => FALSE,
+ );
+ $this->drupalPost($path, $edit, t('Save configuration'));
+
+ // Set "Basic page" content type to use multilingual support.
+ $this->drupalGet('admin/structure/types/manage/page');
+ $this->assertText(t('Multilingual support'), t('Multilingual support fieldset present on content type configuration form.'));
+ $edit = array(
+ 'language_content_type' => 1,
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ $this->assertRaw(t('The content type %type has been updated.', array('%type' => 'Basic page')), t('Basic page content type has been updated.'));
+ $this->drupalLogout();
+
+ // Verify language selection is not present on add article form.
+ $this->drupalLogin($web_user);
+ $this->drupalGet('node/add/article');
+ // Verify language select list is not present.
+ $this->assertNoFieldByName('language', NULL, t('Language select not present on add article form.'));
+
+ // Verify language selection appears on add "Basic page" form.
+ $this->drupalGet('node/add/page');
+ // Verify language select list is present.
+ $this->assertFieldByName('language', NULL, t('Language select present on add Basic page form.'));
+ // Ensure enabled language appears.
+ $this->assertText($name, t('Enabled language present.'));
+ // Ensure disabled language doesn't appear.
+ $this->assertNoText($name_disabled, t('Disabled language not present.'));
+
+ // Create "Basic page" content.
+ $node_title = $this->randomName();
+ $node_body = $this->randomName();
+ $edit = array(
+ 'type' => 'page',
+ 'title' => $node_title,
+ 'body' => array($langcode => array(array('value' => $node_body))),
+ 'language' => $langcode,
+ );
+ $node = $this->drupalCreateNode($edit);
+ // Edit the content and ensure correct language is selected.
+ $path = 'node/' . $node->nid . '/edit';
+ $this->drupalGet($path);
+ $this->assertRaw('<option value="' . $langcode . '" selected="selected">' . $name . '</option>', t('Correct language selected.'));
+ // Ensure we can change the node language.
+ $edit = array(
+ 'language' => 'en',
+ );
+ $this->drupalPost($path, $edit, t('Save'));
+ $this->assertRaw(t('%title has been updated.', array('%title' => $node_title)), t('Basic page content updated.'));
+
+ $this->drupalLogout();
+ }
+}
+
+/**
+ * Test UI language negotiation
+ * 1. URL (PATH) > DEFAULT
+ * UI Language base on URL prefix, browser language preference has no
+ * influence:
+ * admin/config
+ * UI in site default language
+ * zh-hans/admin/config
+ * UI in Chinese
+ * blah-blah/admin/config
+ * 404
+ * 2. URL (PATH) > BROWSER > DEFAULT
+ * admin/config
+ * UI in user's browser language preference if the site has that
+ * language enabled, if not, the default language
+ * zh-hans/admin/config
+ * UI in Chinese
+ * blah-blah/admin/config
+ * 404
+ * 3. URL (DOMAIN) > DEFAULT
+ * http://example.com/admin/config
+ * UI language in site default
+ * http://example.cn/admin/config
+ * UI language in Chinese
+ */
+class LocaleUILanguageNegotiationTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'UI language negotiation',
+ 'description' => 'Test UI language switching by url path prefix and domain.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'locale_test');
+ require_once DRUPAL_ROOT . '/core/includes/language.inc';
+ drupal_load('module', 'locale');
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'translate interface', 'access administration pages', 'administer blocks'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Tests for language switching by URL path.
+ */
+ function testUILanguageNegotiation() {
+ // A few languages to switch to.
+ // This one is unknown, should get the default lang version.
+ $langcode_unknown = 'blah-blah';
+ // For testing browser lang preference.
+ $langcode_browser_fallback = 'vi';
+ // For testing path prefix.
+ $langcode = 'zh-hans';
+ // For setting browser language preference to 'vi'.
+ $http_header_browser_fallback = array("Accept-Language: $langcode_browser_fallback;q=1");
+ // For setting browser language preference to some unknown.
+ $http_header_blah = array("Accept-Language: blah;q=1");
+
+ // This domain should switch the UI to Chinese.
+ $language_domain = 'example.cn';
+
+ // Setup the site languages by installing two languages.
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ $language = (object) array(
+ 'language' => $langcode_browser_fallback,
+ );
+ locale_language_save($language);
+ $language = (object) array(
+ 'language' => $langcode,
+ );
+ locale_language_save($language);
+
+ // We will look for this string in the admin/config screen to see if the
+ // corresponding translated string is shown.
+ $default_string = 'Configure languages for content and the user interface';
+
+ // Set the default language in order for the translated string to be registered
+ // into database when seen by t(). Without doing this, our target string
+ // is for some reason not found when doing translate search. This might
+ // be some bug.
+ drupal_static_reset('language_list');
+ $languages = language_list('enabled');
+ variable_set('language_default', $languages[1]['vi']);
+ // First visit this page to make sure our target string is searchable.
+ $this->drupalGet('admin/config');
+ // Now the t()'ed string is in db so switch the language back to default.
+ variable_del('language_default');
+
+ // Translate the string.
+ $language_browser_fallback_string = "In $langcode_browser_fallback In $langcode_browser_fallback In $langcode_browser_fallback";
+ $language_string = "In $langcode In $langcode In $langcode";
+ // Do a translate search of our target string.
+ $edit = array( 'string' => $default_string);
+ $this->drupalPost('admin/config/regional/translate/translate', $edit, t('Filter'));
+ // Should find the string and now click edit to post translated string.
+ $this->clickLink('edit');
+ $edit = array(
+ "translations[$langcode_browser_fallback]" => $language_browser_fallback_string,
+ "translations[$langcode]" => $language_string,
+ );
+ $this->drupalPost(NULL, $edit, t('Save translations'));
+
+ // Configure URL language rewrite.
+ variable_set('locale_language_negotiation_url_type', LANGUAGE_TYPE_INTERFACE);
+
+ $tests = array(
+ // Default, browser preference should have no influence.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LANGUAGE_NEGOTIATION_DEFAULT),
+ 'path' => 'admin/config',
+ 'expect' => $default_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (PATH) > DEFAULT: no language prefix, UI language is default and the browser language preference setting is not used.',
+ ),
+ // Language prefix.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LANGUAGE_NEGOTIATION_DEFAULT),
+ 'path' => "$langcode/admin/config",
+ 'expect' => $language_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (PATH) > DEFAULT: with language prefix, UI language is switched based on path prefix',
+ ),
+ // Default, go by browser preference.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LOCALE_LANGUAGE_NEGOTIATION_BROWSER),
+ 'path' => 'admin/config',
+ 'expect' => $language_browser_fallback_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (PATH) > BROWSER: no language prefix, UI language is determined by browser language preference',
+ ),
+ // Prefix, switch to the language.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LOCALE_LANGUAGE_NEGOTIATION_BROWSER),
+ 'path' => "$langcode/admin/config",
+ 'expect' => $language_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (PATH) > BROWSER: with langage prefix, UI language is based on path prefix',
+ ),
+ // Default, browser language preference is not one of site's lang.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LOCALE_LANGUAGE_NEGOTIATION_BROWSER, LANGUAGE_NEGOTIATION_DEFAULT),
+ 'path' => 'admin/config',
+ 'expect' => $default_string,
+ 'http_header' => $http_header_blah,
+ 'message' => 'URL (PATH) > BROWSER > DEFAULT: no language prefix and browser language preference set to unknown language should use default language',
+ ),
+ );
+
+ foreach ($tests as $test) {
+ $this->runTest($test);
+ }
+
+ // Unknown language prefix should return 404.
+ variable_set('language_negotiation_' . LANGUAGE_TYPE_INTERFACE, locale_language_negotiation_info());
+ $this->drupalGet("$langcode_unknown/admin/config", array(), $http_header_browser_fallback);
+ $this->assertResponse(404, "Unknown language path prefix should return 404");
+
+ // Setup for domain negotiation, first configure the language to have domain
+ // URL. We use https and a port to make sure that only the domain name is used.
+ $edit = array("domain[$langcode]" => "https://$language_domain:99");
+ $this->drupalPost("admin/config/regional/language/configure/url", $edit, t('Save configuration'));
+ // Set the site to use domain language negotiation.
+
+ $tests = array(
+ // Default domain, browser preference should have no influence.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LANGUAGE_NEGOTIATION_DEFAULT),
+ 'locale_language_negotiation_url_part' => LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN,
+ 'path' => 'admin/config',
+ 'expect' => $default_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (DOMAIN) > DEFAULT: default domain should get default language',
+ ),
+ // Language domain specific URL, we set the $_SERVER['HTTP_HOST'] in
+ // locale_test.module hook_boot() to simulate this.
+ array(
+ 'language_negotiation' => array(LOCALE_LANGUAGE_NEGOTIATION_URL, LANGUAGE_NEGOTIATION_DEFAULT),
+ 'locale_language_negotiation_url_part' => LOCALE_LANGUAGE_NEGOTIATION_URL_DOMAIN,
+ 'locale_test_domain' => $language_domain,
+ 'path' => 'admin/config',
+ 'expect' => $language_string,
+ 'http_header' => $http_header_browser_fallback,
+ 'message' => 'URL (DOMAIN) > DEFAULT: domain example.cn should switch to Chinese',
+ ),
+ );
+
+ foreach ($tests as $test) {
+ $this->runTest($test);
+ }
+ }
+
+ private function runTest($test) {
+ if (!empty($test['language_negotiation'])) {
+ $negotiation = array_flip($test['language_negotiation']);
+ language_negotiation_set(LANGUAGE_TYPE_INTERFACE, $negotiation);
+ }
+ if (!empty($test['locale_language_negotiation_url_part'])) {
+ variable_set('locale_language_negotiation_url_part', $test['locale_language_negotiation_url_part']);
+ }
+ if (!empty($test['locale_test_domain'])) {
+ variable_set('locale_test_domain', $test['locale_test_domain']);
+ }
+ $this->drupalGet($test['path'], array(), $test['http_header']);
+ $this->assertText($test['expect'], $test['message']);
+ }
+
+ /**
+ * Test URL language detection when the requested URL has no language.
+ */
+ function testUrlLanguageFallback() {
+ // Add the Italian language.
+ $langcode_browser_fallback = 'it';
+ $language = (object) array(
+ 'language' => $langcode_browser_fallback,
+ );
+ locale_language_save($language);
+ $languages = language_list();
+
+ // Enable the path prefix for the default language: this way any unprefixed
+ // URL must have a valid fallback value.
+ $edit = array('prefix[en]' => 'en');
+ $this->drupalPost('admin/config/regional/language/configure/url', $edit, t('Save configuration'));
+
+
+ // Enable browser and URL language detection.
+ $edit = array(
+ 'language[enabled][locale-browser]' => TRUE,
+ 'language[enabled][locale-url]' => TRUE,
+ 'language[weight][locale-browser]' => -8,
+ 'language[weight][locale-url]' => -10,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ $this->drupalGet('admin/config/regional/language/configure');
+
+ // Enable the language switcher block.
+ $edit = array('blocks[locale_language][region]' => 'sidebar_first');
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Access the front page without specifying any valid URL language prefix
+ // and having as browser language preference a non-default language.
+ $http_header = array("Accept-Language: $langcode_browser_fallback;q=1");
+ $this->drupalGet('', array(), $http_header);
+
+ // Check that the language switcher active link matches the given browser
+ // language.
+ $args = array(':url' => base_path() . (!empty($GLOBALS['conf']['clean_url']) ? $langcode_browser_fallback : "?q=$langcode_browser_fallback"));
+ $fields = $this->xpath('//div[@id="block-locale-language"]//a[@class="language-link active" and @href=:url]', $args);
+ $this->assertTrue($fields[0] == $languages[$langcode_browser_fallback]->name, t('The browser language is the URL active language'));
+
+ // Check that URLs are rewritten using the given browser language.
+ $fields = $this->xpath('//div[@id="site-name"]//a[@rel="home" and @href=:url]//span', $args);
+ $this->assertTrue($fields[0] == 'Drupal', t('URLs are rewritten using the browser language.'));
+ }
+}
+
+/**
+ * Test that URL rewriting works as expected.
+ */
+class LocaleUrlRewritingTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'URL rewriting',
+ 'description' => 'Test that URL rewriting works as expected.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+
+ // Create and login user.
+ $this->web_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ $this->drupalLogin($this->web_user);
+
+ // Install French language.
+ $edit = array();
+ $edit['predefined_langcode'] = 'fr';
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Install Italian language.
+ $edit = array();
+ $edit['predefined_langcode'] = 'it';
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Disable Italian language.
+ $edit = array('languages[it][enabled]' => FALSE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => 1);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Reset static caching.
+ drupal_static_reset('language_list');
+ drupal_static_reset('locale_url_outbound_alter');
+ drupal_static_reset('locale_language_url_rewrite_url');
+ }
+
+ /**
+ * Check that disabled or non-installed languages are not considered.
+ */
+ function testUrlRewritingEdgeCases() {
+ // Check URL rewriting with a disabled language.
+ $languages = language_list();
+ $this->checkUrl($languages['it'], t('Path language is ignored if language is disabled.'), t('URL language negotiation does not work with disabled languages'));
+
+ // Check URL rewriting with a non-installed language.
+ $non_existing = language_default();
+ $non_existing->language = $this->randomName();
+ $non_existing->prefix = $this->randomName();
+ $this->checkUrl($non_existing, t('Path language is ignored if language is not installed.'), t('URL language negotiation does not work with non-installed languages'));
+ }
+
+ /**
+ * Check URL rewriting for the given language.
+ *
+ * The test is performed with a fixed URL (the default front page) to simply
+ * check that language prefixes are not added to it and that the prefixed URL
+ * is actually not working.
+ */
+ private function checkUrl($language, $message1, $message2) {
+ $options = array('language' => $language);
+ $base_path = trim(base_path(), '/');
+ $rewritten_path = trim(str_replace(array('?q=', $base_path), '', url('node', $options)), '/');
+ $segments = explode('/', $rewritten_path, 2);
+ $prefix = $segments[0];
+ $path = isset($segments[1]) ? $segments[1] : $prefix;
+ // If the rewritten URL has not a language prefix we pick the right one from
+ // the language object so we can always check the prefixed URL.
+ if ($this->assertNotEqual($language->prefix, $prefix, $message1)) {
+ $prefix = $language->prefix;
+ }
+ $this->drupalGet("$prefix/$path");
+ $this->assertResponse(404, $message2);
+ }
+}
+
+/**
+ * Functional test for multilingual fields.
+ */
+class LocaleMultilingualFieldsFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Multilingual fields',
+ 'description' => 'Test multilingual support for fields.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ // Setup users.
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'administer content types', 'access administration pages', 'create page content', 'edit own page content'));
+ $this->drupalLogin($admin_user);
+
+ // Add a new language.
+ require_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ $language = (object) array(
+ 'language' => 'it',
+ 'name' => 'Italian',
+ );
+ locale_language_save($language);
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => '1');
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Set "Basic page" content type to use multilingual support.
+ $edit = array(
+ 'language_content_type' => 1,
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ $this->assertRaw(t('The content type %type has been updated.', array('%type' => 'Basic page')), t('Basic page content type has been updated.'));
+
+ // Make node body translatable.
+ $field = field_info_field('body');
+ $field['translatable'] = TRUE;
+ field_update_field($field);
+ }
+
+ /**
+ * Test if field languages are correctly set through the node form.
+ */
+ function testMultilingualNodeForm() {
+ // Create "Basic page" content.
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $title_value = $this->randomName(8);
+ $body_key = "body[$langcode][0][value]";
+ $body_value = $this->randomName(16);
+
+ // Create node to edit.
+ $edit = array();
+ $edit[$title_key] = $title_value;
+ $edit[$body_key] = $body_value;
+ $edit['language'] = 'en';
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the node exists in the database.
+ $node = $this->drupalGetNodeByTitle($edit[$title_key]);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ $assert = isset($node->body['en']) && !isset($node->body[LANGUAGE_NONE]) && $node->body['en'][0]['value'] == $body_value;
+ $this->assertTrue($assert, t('Field language correctly set.'));
+
+ // Change node language.
+ $this->drupalGet("node/$node->nid/edit");
+ $edit = array(
+ $title_key => $this->randomName(8),
+ 'language' => 'it'
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($edit[$title_key]);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ $assert = isset($node->body['it']) && !isset($node->body['en']) && $node->body['it'][0]['value'] == $body_value;
+ $this->assertTrue($assert, t('Field language correctly changed.'));
+
+ // Enable content language URL detection.
+ language_negotiation_set(LANGUAGE_TYPE_CONTENT, array(LOCALE_LANGUAGE_NEGOTIATION_URL => 0));
+
+ // Test multilingual field language fallback logic.
+ $this->drupalGet("it/node/$node->nid");
+ $this->assertRaw($body_value, t('Body correctly displayed using Italian as requested language'));
+
+ $this->drupalGet("node/$node->nid");
+ $this->assertRaw($body_value, t('Body correctly displayed using English as requested language'));
+ }
+
+ /*
+ * Test multilingual field display settings.
+ */
+ function testMultilingualDisplaySettings() {
+ // Create "Basic page" content.
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $title_value = $this->randomName(8);
+ $body_key = "body[$langcode][0][value]";
+ $body_value = $this->randomName(16);
+
+ // Create node to edit.
+ $edit = array();
+ $edit[$title_key] = $title_value;
+ $edit[$body_key] = $body_value;
+ $edit['language'] = 'en';
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the node exists in the database.
+ $node = $this->drupalGetNodeByTitle($edit[$title_key]);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ // Check if node body is showed.
+ $this->drupalGet("node/$node->nid");
+ $body = $this->xpath('//div[@id=:id]//div[@property="content:encoded"]/p', array(':id' => 'node-' . $node->nid));
+ $this->assertEqual(current($body), $node->body['en'][0]['value'], 'Node body is correctly showed.');
+ }
+}
+
+/**
+ * Functional tests for comment language.
+ */
+class LocaleCommentLanguageFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment language',
+ 'description' => 'Tests for comment language.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'locale_test');
+
+ // Create and login user.
+ $admin_user = $this->drupalCreateUser(array('administer site configuration', 'administer languages', 'access administration pages', 'administer content types', 'create article content'));
+ $this->drupalLogin($admin_user);
+
+ // Add language.
+ $edit = array('predefined_langcode' => 'fr');
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Set "Article" content type to use multilingual support.
+ $edit = array('language_content_type' => 1);
+ $this->drupalPost('admin/structure/types/manage/article', $edit, t('Save content type'));
+
+ // Enable content language negotiation UI.
+ variable_set('locale_test_content_language_type', TRUE);
+
+ // Set interface language detection to user and content language detection
+ // to URL. Disable inheritance from interface language to ensure content
+ // language will fall back to the default language if no URL language can be
+ // detected.
+ $edit = array(
+ 'language[enabled][locale-user]' => TRUE,
+ 'language_content[enabled][locale-url]' => TRUE,
+ 'language_content[enabled][locale-interface]' => FALSE,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Change user language preference, this way interface language is always
+ // French no matter what path prefix the URLs have.
+ $edit = array('language' => 'fr');
+ $this->drupalPost("user/{$admin_user->uid}/edit", $edit, t('Save'));
+ }
+
+ /**
+ * Test that comment language is properly set.
+ */
+ function testCommentLanguage() {
+ drupal_static_reset('language_list');
+
+ // Create two nodes, one for english and one for french, and comment each
+ // node using both english and french as content language by changing URL
+ // language prefixes. Meanwhile interface language is always French, which
+ // is the user language preference. This way we can ensure that node
+ // language and interface language do not influence comment language, as
+ // only content language has to.
+ foreach (language_list() as $node_langcode => $node_language) {
+ $langcode_none = LANGUAGE_NONE;
+
+ // Create "Article" content.
+ $title = $this->randomName();
+ $edit = array(
+ "title" => $title,
+ "body[$langcode_none][0][value]" => $this->randomName(),
+ "language" => $node_langcode,
+ );
+ $this->drupalPost("node/add/article", $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($title);
+
+ foreach (language_list() as $langcode => $language) {
+ // Post a comment with content language $langcode.
+ $prefix = empty($language->prefix) ? '' : $language->prefix . '/';
+ $edit = array("comment_body[$langcode_none][0][value]" => $this->randomName());
+ $this->drupalPost("{$prefix}node/{$node->nid}", $edit, t('Save'));
+
+ // Check that comment language matches the current content language.
+ $comment = db_select('comment', 'c')
+ ->fields('c')
+ ->condition('nid', $node->nid)
+ ->orderBy('cid', 'DESC')
+ ->execute()
+ ->fetchObject();
+ $args = array('%node_language' => $node_langcode, '%comment_language' => $comment->language, '%langcode' => $langcode);
+ $this->assertEqual($comment->language, $langcode, t('The comment posted with content language %langcode and belonging to the node with language %node_language has language %comment_language', $args));
+ }
+ }
+ }
+}
+/**
+ * Functional tests for localizing date formats.
+ */
+class LocaleDateFormatsFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Localize date formats',
+ 'description' => 'Tests for the localization of date formats.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+
+ // Create and login user.
+ $admin_user = $this->drupalCreateUser(array('administer site configuration', 'administer languages', 'access administration pages', 'create article content'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Functional tests for localizing date formats.
+ */
+ function testLocalizeDateFormats() {
+ // Add language.
+ $edit = array(
+ 'predefined_langcode' => 'fr',
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Set language negotiation.
+ $language_type = LANGUAGE_TYPE_INTERFACE;
+ $edit = array(
+ "{$language_type}[enabled][locale-url]" => TRUE,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Configure date formats.
+ $this->drupalGet('admin/config/regional/date-time/locale');
+ $this->assertText('French', 'Configured languages appear.');
+ $edit = array(
+ 'date_format_long' => 'd.m.Y - H:i',
+ 'date_format_medium' => 'd.m.Y - H:i',
+ 'date_format_short' => 'd.m.Y - H:i',
+ );
+ $this->drupalPost('admin/config/regional/date-time/locale/fr/edit', $edit, t('Save configuration'));
+ $this->assertText(t('Configuration saved.'), 'French date formats updated.');
+ $edit = array(
+ 'date_format_long' => 'j M Y - g:ia',
+ 'date_format_medium' => 'j M Y - g:ia',
+ 'date_format_short' => 'j M Y - g:ia',
+ );
+ $this->drupalPost('admin/config/regional/date-time/locale/en/edit', $edit, t('Save configuration'));
+ $this->assertText(t('Configuration saved.'), 'English date formats updated.');
+
+ // Create node content.
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+
+ // Configure format for the node posted date changes with the language.
+ $this->drupalGet('node/' . $node->nid);
+ $english_date = format_date($node->created, 'custom', 'j M Y');
+ $this->assertText($english_date, t('English date format appears'));
+ $this->drupalGet('fr/node/' . $node->nid);
+ $french_date = format_date($node->created, 'custom', 'd.m.Y');
+ $this->assertText($french_date, t('French date format appears'));
+ }
+}
+
+/**
+ * Functional test for language types/negotiation info.
+ */
+class LocaleLanguageNegotiationInfoFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Language negotiation info',
+ 'description' => 'Tests alterations to language types/negotiation info.',
+ 'group' => 'Locale',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ require_once DRUPAL_ROOT .'/core/includes/language.inc';
+ $admin_user = $this->drupalCreateUser(array('administer languages', 'access administration pages', 'view the administration theme'));
+ $this->drupalLogin($admin_user);
+ $this->drupalPost('admin/config/regional/language/add', array('predefined_langcode' => 'it'), t('Add language'));
+ }
+
+ /**
+ * Tests alterations to language types/negotiation info.
+ */
+ function testInfoAlterations() {
+ // Enable language type/negotiation info alterations.
+ variable_set('locale_test_language_types', TRUE);
+ variable_set('locale_test_language_negotiation_info', TRUE);
+ $this->languageNegotiationUpdate();
+
+ // Check that fixed language types are properly configured without the need
+ // of saving the language negotiation settings.
+ $this->checkFixedLanguageTypes();
+
+ // Make the content language type configurable by updating the language
+ // negotiation settings with the proper flag enabled.
+ variable_set('locale_test_content_language_type', TRUE);
+ $this->languageNegotiationUpdate();
+ $type = LANGUAGE_TYPE_CONTENT;
+ $language_types = variable_get('language_types', drupal_language_types());
+ $this->assertTrue($language_types[$type], t('Content language type is configurable.'));
+
+ // Enable some core and custom language providers. The test language type is
+ // supposed to be configurable.
+ $test_type = 'test_language_type';
+ $provider = LOCALE_LANGUAGE_NEGOTIATION_INTERFACE;
+ $test_provider = 'test_language_provider';
+ $form_field = $type . '[enabled]['. $provider .']';
+ $edit = array(
+ $form_field => TRUE,
+ $type . '[enabled][' . $test_provider . ']' => TRUE,
+ $test_type . '[enabled][' . $test_provider . ']' => TRUE,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Remove the interface language provider by updating the language
+ // negotiation settings with the proper flag enabled.
+ variable_set('locale_test_language_negotiation_info_alter', TRUE);
+ $this->languageNegotiationUpdate();
+ $negotiation = variable_get("language_negotiation_$type", array());
+ $this->assertFalse(isset($negotiation[$provider]), t('Interface language provider removed from the stored settings.'));
+ $this->assertNoFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Interface language provider unavailable.'));
+
+ // Check that type-specific language providers can be assigned only to the
+ // corresponding language types.
+ foreach (language_types_configurable() as $type) {
+ $form_field = $type . '[enabled][test_language_provider_ts]';
+ if ($type == $test_type) {
+ $this->assertFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Type-specific test language provider available for %type.', array('%type' => $type)));
+ }
+ else {
+ $this->assertNoFieldByXPath("//input[@name=\"$form_field\"]", NULL, t('Type-specific test language provider unavailable for %type.', array('%type' => $type)));
+ }
+ }
+
+ // Check language negotiation results.
+ $this->drupalGet('');
+ $last = variable_get('locale_test_language_negotiation_last', array());
+ foreach (language_types() as $type) {
+ $langcode = $last[$type];
+ $value = $type == LANGUAGE_TYPE_CONTENT || strpos($type, 'test') !== FALSE ? 'it' : 'en';
+ $this->assertEqual($langcode, $value, t('The negotiated language for %type is %language', array('%type' => $type, '%language' => $langcode)));
+ }
+
+ // Disable locale_test and check that everything is set back to the original
+ // status.
+ $this->languageNegotiationUpdate('disable');
+
+ // Check that only the core language types are available.
+ foreach (language_types() as $type) {
+ $this->assertTrue(strpos($type, 'test') === FALSE, t('The %type language is still available', array('%type' => $type)));
+ }
+
+ // Check that fixed language types are properly configured, even those
+ // previously set to configurable.
+ $this->checkFixedLanguageTypes();
+
+ // Check that unavailable language providers are not present in the
+ // negotiation settings.
+ $negotiation = variable_get("language_negotiation_$type", array());
+ $this->assertFalse(isset($negotiation[$test_provider]), t('The disabled test language provider is not part of the content language negotiation settings.'));
+
+ // Check that configuration page presents the correct options and settings.
+ $this->assertNoRaw(t('Test language detection'), t('No test language type configuration available.'));
+ $this->assertNoRaw(t('This is a test language provider'), t('No test language provider available.'));
+ }
+
+ /**
+ * Update language types/negotiation information.
+ *
+ * Manually invoke locale_modules_enabled()/locale_modules_disabled() since
+ * they would not be invoked after enabling/disabling locale_test the first
+ * time.
+ */
+ private function languageNegotiationUpdate($op = 'enable') {
+ static $last_op = NULL;
+ $modules = array('locale_test');
+
+ // Enable/disable locale_test only if we did not already before.
+ if ($last_op != $op) {
+ $function = "module_{$op}";
+ $function($modules);
+ // Reset hook implementation cache.
+ module_implements_reset();
+ }
+
+ drupal_static_reset('language_types_info');
+ drupal_static_reset('language_negotiation_info');
+ $function = "locale_modules_{$op}d";
+ if (function_exists($function)) {
+ $function($modules);
+ }
+
+ $this->drupalGet('admin/config/regional/language/configure');
+ }
+
+ /**
+ * Check that language negotiation for fixed types matches the stored one.
+ */
+ private function checkFixedLanguageTypes() {
+ drupal_static_reset('language_types_info');
+ foreach (language_types_info() as $type => $info) {
+ if (isset($info['fixed'])) {
+ $negotiation = variable_get("language_negotiation_$type", array());
+ $equal = count($info['fixed']) == count($negotiation);
+ while ($equal && list($id) = each($negotiation)) {
+ list(, $info_id) = each($info['fixed']);
+ $equal = $info_id == $id;
+ }
+ $this->assertTrue($equal, t('language negotiation for %type is properly set up', array('%type' => $type)));
+ }
+ }
+ }
+}
diff --git a/core/modules/locale/tests/locale_test.info b/core/modules/locale/tests/locale_test.info
new file mode 100644
index 000000000000..83cae4fd0083
--- /dev/null
+++ b/core/modules/locale/tests/locale_test.info
@@ -0,0 +1,6 @@
+name = "Locale Test"
+description = "Support module for the locale layer tests."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/locale/tests/locale_test.js b/core/modules/locale/tests/locale_test.js
new file mode 100644
index 000000000000..515bb3423e47
--- /dev/null
+++ b/core/modules/locale/tests/locale_test.js
@@ -0,0 +1,46 @@
+
+Drupal.t("Standard Call t");
+Drupal
+.
+t
+(
+"Whitespace Call t"
+)
+;
+
+Drupal.t('Single Quote t');
+Drupal.t('Single Quote \'Escaped\' t');
+Drupal.t('Single Quote ' + 'Concat ' + 'strings ' + 't');
+
+Drupal.t("Double Quote t");
+Drupal.t("Double Quote \"Escaped\" t");
+Drupal.t("Double Quote " + "Concat " + "strings " + "t");
+
+Drupal.t("Context Unquoted t", {}, {context: "Context string unquoted"});
+Drupal.t("Context Single Quoted t", {}, {'context': "Context string single quoted"});
+Drupal.t("Context Double Quoted t", {}, {"context": "Context string double quoted"});
+
+Drupal.t("Context !key Args t", {'!key': 'value'}, {context: "Context string"});
+
+Drupal.formatPlural(1, "Standard Call plural", "Standard Call @count plural");
+Drupal
+.
+formatPlural
+(
+1,
+"Whitespace Call plural",
+"Whitespace Call @count plural",
+)
+;
+
+Drupal.formatPlural(1, 'Single Quote plural', 'Single Quote @count plural');
+Drupal.formatPlural(1, 'Single Quote \'Escaped\' plural', 'Single Quote \'Escaped\' @count plural');
+
+Drupal.formatPlural(1, "Double Quote plural", "Double Quote @count plural");
+Drupal.formatPlural(1, "Double Quote \"Escaped\" plural", "Double Quote \"Escaped\" @count plural");
+
+Drupal.formatPlural(1, "Context Unquoted plural", "Context Unquoted @count plural", {}, {context: "Context string unquoted"});
+Drupal.formatPlural(1, "Context Single Quoted plural", "Context Single Quoted @count plural", {}, {'context': "Context string single quoted"});
+Drupal.formatPlural(1, "Context Double Quoted plural", "Context Double Quoted @count plural", {}, {"context": "Context string double quoted"});
+
+Drupal.formatPlural(1, "Context !key Args plural", "Context !key Args @count plural", {'!key': 'value'}, {context: "Context string"});
diff --git a/core/modules/locale/tests/locale_test.module b/core/modules/locale/tests/locale_test.module
new file mode 100644
index 000000000000..b29d6654b25e
--- /dev/null
+++ b/core/modules/locale/tests/locale_test.module
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * @file
+ * Mock module for locale layer tests.
+ */
+
+/**
+ * Implements hook_boot().
+ *
+ * For testing domain language negotiation, we fake it by setting
+ * the HTTP_HOST here
+ */
+function locale_test_boot() {
+ if (variable_get('locale_test_domain')) {
+ $_SERVER['HTTP_HOST'] = variable_get('locale_test_domain');
+ }
+}
+
+/**
+ * Implements hook_init().
+ */
+function locale_test_init() {
+ locale_test_store_language_negotiation();
+}
+
+/**
+ * Implements hook_language_types_info().
+ */
+function locale_test_language_types_info() {
+ if (variable_get('locale_test_language_types', FALSE)) {
+ return array(
+ 'test_language_type' => array(
+ 'name' => t('Test'),
+ 'description' => t('A test language type.'),
+ ),
+ 'fixed_test_language_type' => array(
+ 'fixed' => array('test_language_provider'),
+ ),
+ );
+ }
+}
+
+/**
+ * Implements hook_language_types_info_alter().
+ */
+function locale_test_language_types_info_alter(array &$language_types) {
+ if (variable_get('locale_test_content_language_type', FALSE)) {
+ unset($language_types[LANGUAGE_TYPE_CONTENT]['fixed']);
+ }
+}
+
+/**
+ * Implements hook_language_negotiation_info().
+ */
+function locale_test_language_negotiation_info() {
+ if (variable_get('locale_test_language_negotiation_info', FALSE)) {
+ $info = array(
+ 'callbacks' => array(
+ 'language' => 'locale_test_language_provider',
+ ),
+ 'file' => drupal_get_path('module', 'locale_test') .'/locale_test.module',
+ 'weight' => -10,
+ 'description' => t('This is a test language provider.'),
+ );
+
+ return array(
+ 'test_language_provider' => array(
+ 'name' => t('Test'),
+ 'types' => array(LANGUAGE_TYPE_CONTENT, 'test_language_type', 'fixed_test_language_type'),
+ ) + $info,
+ 'test_language_provider_ts' => array(
+ 'name' => t('Type-specific test'),
+ 'types' => array('test_language_type'),
+ ) + $info,
+ );
+ }
+}
+
+/**
+ * Implements hook_language_negotiation_info_alter().
+ */
+function locale_test_language_negotiation_info_alter(array &$language_providers) {
+ if (variable_get('locale_test_language_negotiation_info_alter', FALSE)) {
+ unset($language_providers[LOCALE_LANGUAGE_NEGOTIATION_INTERFACE]);
+ }
+}
+
+/**
+ * Store the last negotiated languages.
+ */
+function locale_test_store_language_negotiation() {
+ $last = array();
+ foreach (language_types() as $type) {
+ $last[$type] = $GLOBALS[$type]->language;
+ }
+ variable_set('locale_test_language_negotiation_last', $last);
+}
+
+/**
+ * Test language provider.
+ */
+function locale_test_language_provider($languages) {
+ return 'it';
+}
diff --git a/core/modules/locale/tests/test.xx.po b/core/modules/locale/tests/test.xx.po
new file mode 100644
index 000000000000..659a6e3f12bc
--- /dev/null
+++ b/core/modules/locale/tests/test.xx.po
@@ -0,0 +1,28 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Drupal 7\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=UTF-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+
+msgid "Monday"
+msgstr "lundi"
+
+msgid "Tuesday"
+msgstr "mardi"
+
+msgid "Wednesday"
+msgstr "mercredi"
+
+msgid "Thursday"
+msgstr "jeudi"
+
+msgid "Friday"
+msgstr "vendredi"
+
+msgid "Saturday"
+msgstr "samedi"
+
+msgid "Sunday"
+msgstr "dimanche"
diff --git a/core/modules/menu/menu.admin.inc b/core/modules/menu/menu.admin.inc
new file mode 100644
index 000000000000..f933feb1b1fa
--- /dev/null
+++ b/core/modules/menu/menu.admin.inc
@@ -0,0 +1,686 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for menu module.
+ */
+
+/**
+ * Menu callback which shows an overview page of all the custom menus and their descriptions.
+ */
+function menu_overview_page() {
+ $result = db_query("SELECT * FROM {menu_custom} ORDER BY title", array(), array('fetch' => PDO::FETCH_ASSOC));
+ $header = array(t('Title'), array('data' => t('Operations'), 'colspan' => '3'));
+ $rows = array();
+ foreach ($result as $menu) {
+ $row = array(theme('menu_admin_overview', array('title' => $menu['title'], 'name' => $menu['menu_name'], 'description' => $menu['description'])));
+ $row[] = array('data' => l(t('list links'), 'admin/structure/menu/manage/' . $menu['menu_name']));
+ $row[] = array('data' => l(t('edit menu'), 'admin/structure/menu/manage/' . $menu['menu_name'] . '/edit'));
+ $row[] = array('data' => l(t('add link'), 'admin/structure/menu/manage/' . $menu['menu_name'] . '/add'));
+ $rows[] = $row;
+ }
+
+ return theme('table', array('header' => $header, 'rows' => $rows));
+}
+
+/**
+ * Returns HTML for a menu title and description for the menu overview page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - title: The menu's title.
+ * - description: The menu's description.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_admin_overview($variables) {
+ $output = check_plain($variables['title']);
+ $output .= '<div class="description">' . filter_xss_admin($variables['description']) . '</div>';
+
+ return $output;
+}
+
+/**
+ * Form for editing an entire menu tree at once.
+ *
+ * Shows for one menu the menu links accessible to the current user and
+ * relevant operations.
+ */
+function menu_overview_form($form, &$form_state, $menu) {
+ global $menu_admin;
+ $form['#attached']['css'] = array(drupal_get_path('module', 'menu') . '/menu.css');
+ $sql = "
+ SELECT m.load_functions, m.to_arg_functions, m.access_callback, m.access_arguments, m.page_callback, m.page_arguments, m.delivery_callback, m.title, m.title_callback, m.title_arguments, m.type, m.description, ml.*
+ FROM {menu_links} ml LEFT JOIN {menu_router} m ON m.path = ml.router_path
+ WHERE ml.menu_name = :menu
+ ORDER BY p1 ASC, p2 ASC, p3 ASC, p4 ASC, p5 ASC, p6 ASC, p7 ASC, p8 ASC, p9 ASC";
+ $result = db_query($sql, array(':menu' => $menu['menu_name']), array('fetch' => PDO::FETCH_ASSOC));
+ $links = array();
+ foreach ($result as $item) {
+ $links[] = $item;
+ }
+ $tree = menu_tree_data($links);
+ $node_links = array();
+ menu_tree_collect_node_links($tree, $node_links);
+ // We indicate that a menu administrator is running the menu access check.
+ $menu_admin = TRUE;
+ menu_tree_check_access($tree, $node_links);
+ $menu_admin = FALSE;
+
+ $form = array_merge($form, _menu_overview_tree_form($tree));
+ $form['#menu'] = $menu;
+
+ if (element_children($form)) {
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+ }
+ else {
+ $form['#empty_text'] = t('There are no menu links yet. <a href="@link">Add link</a>.', array('@link' => url('admin/structure/menu/manage/'. $form['#menu']['menu_name'] .'/add')));
+ }
+ return $form;
+}
+
+/**
+ * Recursive helper function for menu_overview_form().
+ *
+ * @param $tree
+ * The menu_tree retrieved by menu_tree_data.
+ */
+function _menu_overview_tree_form($tree) {
+ $form = &drupal_static(__FUNCTION__, array('#tree' => TRUE));
+ foreach ($tree as $data) {
+ $title = '';
+ $item = $data['link'];
+ // Don't show callbacks; these have $item['hidden'] < 0.
+ if ($item && $item['hidden'] >= 0) {
+ $mlid = 'mlid:' . $item['mlid'];
+ $form[$mlid]['#item'] = $item;
+ $form[$mlid]['#attributes'] = $item['hidden'] ? array('class' => array('menu-disabled')) : array('class' => array('menu-enabled'));
+ $form[$mlid]['title']['#markup'] = l($item['title'], $item['href'], $item['localized_options']) . ($item['hidden'] ? ' (' . t('disabled') . ')' : '');
+ $form[$mlid]['hidden'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable @title menu link', array('@title' => $item['title'])),
+ '#title_display' => 'invisible',
+ '#default_value' => !$item['hidden'],
+ );
+ $form[$mlid]['weight'] = array(
+ '#type' => 'weight',
+ '#delta' => 50,
+ '#default_value' => $item['weight'],
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for @title', array('@title' => $item['title'])),
+ );
+ $form[$mlid]['mlid'] = array(
+ '#type' => 'hidden',
+ '#value' => $item['mlid'],
+ );
+ $form[$mlid]['plid'] = array(
+ '#type' => 'hidden',
+ '#default_value' => $item['plid'],
+ );
+ // Build a list of operations.
+ $operations = array();
+ $operations['edit'] = array('#type' => 'link', '#title' => t('edit'), '#href' => 'admin/structure/menu/item/' . $item['mlid'] . '/edit');
+ // Only items created by the menu module can be deleted.
+ if ($item['module'] == 'menu' || $item['updated'] == 1) {
+ $operations['delete'] = array('#type' => 'link', '#title' => t('delete'), '#href' => 'admin/structure/menu/item/' . $item['mlid'] . '/delete');
+ }
+ // Set the reset column.
+ elseif ($item['module'] == 'system' && $item['customized']) {
+ $operations['reset'] = array('#type' => 'link', '#title' => t('reset'), '#href' => 'admin/structure/menu/item/' . $item['mlid'] . '/reset');
+ }
+ $form[$mlid]['operations'] = $operations;
+ }
+
+ if ($data['below']) {
+ _menu_overview_tree_form($data['below']);
+ }
+ }
+ return $form;
+}
+
+/**
+ * Submit handler for the menu overview form.
+ *
+ * This function takes great care in saving parent items first, then items
+ * underneath them. Saving items in the incorrect order can break the menu tree.
+ *
+ * @see menu_overview_form()
+ */
+function menu_overview_form_submit($form, &$form_state) {
+ // When dealing with saving menu items, the order in which these items are
+ // saved is critical. If a changed child item is saved before its parent,
+ // the child item could be saved with an invalid path past its immediate
+ // parent. To prevent this, save items in the form in the same order they
+ // are sent by $_POST, ensuring parents are saved first, then their children.
+ // See http://drupal.org/node/181126#comment-632270
+ $order = array_flip(array_keys($form_state['input'])); // Get the $_POST order.
+ $form = array_intersect_key(array_merge($order, $form), $form); // Update our original form with the new order.
+
+ $updated_items = array();
+ $fields = array('weight', 'plid');
+ foreach (element_children($form) as $mlid) {
+ if (isset($form[$mlid]['#item'])) {
+ $element = $form[$mlid];
+ // Update any fields that have changed in this menu item.
+ foreach ($fields as $field) {
+ if ($element[$field]['#value'] != $element[$field]['#default_value']) {
+ $element['#item'][$field] = $element[$field]['#value'];
+ $updated_items[$mlid] = $element['#item'];
+ }
+ }
+ // Hidden is a special case, the value needs to be reversed.
+ if ($element['hidden']['#value'] != $element['hidden']['#default_value']) {
+ // Convert to integer rather than boolean due to PDO cast to string.
+ $element['#item']['hidden'] = $element['hidden']['#value'] ? 0 : 1;
+ $updated_items[$mlid] = $element['#item'];
+ }
+ }
+ }
+
+ // Save all our changed items to the database.
+ foreach ($updated_items as $item) {
+ $item['customized'] = 1;
+ menu_link_save($item);
+ }
+ drupal_set_message(t('Your configuration has been saved.'));
+}
+
+/**
+ * Returns HTML for the menu overview form into a table.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_menu_overview_form($variables) {
+ $form = $variables['form'];
+
+ drupal_add_tabledrag('menu-overview', 'match', 'parent', 'menu-plid', 'menu-plid', 'menu-mlid', TRUE, MENU_MAX_DEPTH - 1);
+ drupal_add_tabledrag('menu-overview', 'order', 'sibling', 'menu-weight');
+
+ $header = array(
+ t('Menu link'),
+ array('data' => t('Enabled'), 'class' => array('checkbox')),
+ t('Weight'),
+ array('data' => t('Operations'), 'colspan' => '3'),
+ );
+
+ $rows = array();
+ foreach (element_children($form) as $mlid) {
+ if (isset($form[$mlid]['hidden'])) {
+ $element = &$form[$mlid];
+ // Build a list of operations.
+ $operations = array();
+ foreach (element_children($element['operations']) as $op) {
+ $operations[] = array('data' => drupal_render($element['operations'][$op]), 'class' => array('menu-operations'));
+ }
+ while (count($operations) < 2) {
+ $operations[] = '';
+ }
+
+ // Add special classes to be used for tabledrag.js.
+ $element['plid']['#attributes']['class'] = array('menu-plid');
+ $element['mlid']['#attributes']['class'] = array('menu-mlid');
+ $element['weight']['#attributes']['class'] = array('menu-weight');
+
+ // Change the parent field to a hidden. This allows any value but hides the field.
+ $element['plid']['#type'] = 'hidden';
+
+ $row = array();
+ $row[] = theme('indentation', array('size' => $element['#item']['depth'] - 1)) . drupal_render($element['title']);
+ $row[] = array('data' => drupal_render($element['hidden']), 'class' => array('checkbox', 'menu-enabled'));
+ $row[] = drupal_render($element['weight']) . drupal_render($element['plid']) . drupal_render($element['mlid']);
+ $row = array_merge($row, $operations);
+
+ $row = array_merge(array('data' => $row), $element['#attributes']);
+ $row['class'][] = 'draggable';
+ $rows[] = $row;
+ }
+ }
+ $output = '';
+ if (empty($rows)) {
+ $rows[] = array(array('data' => $form['#empty_text'], 'colspan' => '7'));
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'menu-overview')));
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+/**
+ * Menu callback; Build the menu link editing form.
+ */
+function menu_edit_item($form, &$form_state, $type, $item, $menu) {
+ if ($type == 'add' || empty($item)) {
+ // This is an add form, initialize the menu link.
+ $item = array('link_title' => '', 'mlid' => 0, 'plid' => 0, 'menu_name' => $menu['menu_name'], 'weight' => 0, 'link_path' => '', 'options' => array(), 'module' => 'menu', 'expanded' => 0, 'hidden' => 0, 'has_children' => 0);
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['link_title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Menu link title'),
+ '#default_value' => $item['link_title'],
+ '#description' => t('The text to be used for this link in the menu.'),
+ '#required' => TRUE,
+ );
+ foreach (array('link_path', 'mlid', 'module', 'has_children', 'options') as $key) {
+ $form[$key] = array('#type' => 'value', '#value' => $item[$key]);
+ }
+ // Any item created or edited via this interface is considered "customized".
+ $form['customized'] = array('#type' => 'value', '#value' => 1);
+ $form['original_item'] = array('#type' => 'value', '#value' => $item);
+
+ $path = $item['link_path'];
+ if (isset($item['options']['query'])) {
+ $path .= '?' . drupal_http_build_query($item['options']['query']);
+ }
+ if (isset($item['options']['fragment'])) {
+ $path .= '#' . $item['options']['fragment'];
+ }
+ if ($item['module'] == 'menu') {
+ $form['link_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Path'),
+ '#maxlength' => 255,
+ '#default_value' => $path,
+ '#description' => t('The path for this menu link. This can be an internal Drupal path such as %add-node or an external URL such as %drupal. Enter %front to link to the front page.', array('%front' => '<front>', '%add-node' => 'node/add', '%drupal' => 'http://drupal.org')),
+ '#required' => TRUE,
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#access' => $item['mlid'],
+ '#submit' => array('menu_item_delete_submit'),
+ '#weight' => 10,
+ );
+ }
+ else {
+ $form['_path'] = array(
+ '#type' => 'item',
+ '#title' => t('Path'),
+ '#description' => l($item['link_title'], $item['href'], $item['options']),
+ );
+ }
+ $form['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => isset($item['options']['attributes']['title']) ? $item['options']['attributes']['title'] : '',
+ '#rows' => 1,
+ '#description' => t('Shown when hovering over the menu link.'),
+ );
+ $form['enabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enabled'),
+ '#default_value' => !$item['hidden'],
+ '#description' => t('Menu links that are not enabled will not be listed in any menu.'),
+ );
+ $form['expanded'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Show as expanded'),
+ '#default_value' => $item['expanded'],
+ '#description' => t('If selected and this menu link has children, the menu will always appear expanded.'),
+ );
+
+ // Generate a list of possible parents (not including this link or descendants).
+ $options = menu_parent_options(menu_get_menus(), $item);
+ $default = $item['menu_name'] . ':' . $item['plid'];
+ if (!isset($options[$default])) {
+ $default = 'navigation:0';
+ }
+ $form['parent'] = array(
+ '#type' => 'select',
+ '#title' => t('Parent link'),
+ '#default_value' => $default,
+ '#options' => $options,
+ '#description' => t('The maximum depth for a link and all its children is fixed at !maxdepth. Some menu links may not be available as parents if selecting them would exceed this limit.', array('!maxdepth' => MENU_MAX_DEPTH)),
+ '#attributes' => array('class' => array('menu-title-select')),
+ );
+ $form['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#delta' => 50,
+ '#default_value' => $item['weight'],
+ '#description' => t('Optional. In the menu, the heavier links will sink and the lighter links will be positioned nearer the top.'),
+ );
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+
+ return $form;
+}
+
+/**
+ * Validate form values for a menu link being added or edited.
+ */
+function menu_edit_item_validate($form, &$form_state) {
+ $item = &$form_state['values'];
+ $normal_path = drupal_get_normal_path($item['link_path']);
+ if ($item['link_path'] != $normal_path) {
+ drupal_set_message(t('The menu system stores system paths only, but will use the URL alias for display. %link_path has been stored as %normal_path', array('%link_path' => $item['link_path'], '%normal_path' => $normal_path)));
+ $item['link_path'] = $normal_path;
+ }
+ if (!url_is_external($item['link_path'])) {
+ $parsed_link = parse_url($item['link_path']);
+ if (isset($parsed_link['query'])) {
+ $item['options']['query'] = drupal_get_query_array($parsed_link['query']);
+ }
+ else {
+ // Use unset() rather than setting to empty string
+ // to avoid redundant serialized data being stored.
+ unset($item['options']['query']);
+ }
+ if (isset($parsed_link['fragment'])) {
+ $item['options']['fragment'] = $parsed_link['fragment'];
+ }
+ else {
+ unset($item['options']['fragment']);
+ }
+ if (isset($parsed_link['path']) && $item['link_path'] != $parsed_link['path']) {
+ $item['link_path'] = $parsed_link['path'];
+ }
+ }
+ if (!trim($item['link_path']) || !drupal_valid_path($item['link_path'], TRUE)) {
+ form_set_error('link_path', t("The path '@link_path' is either invalid or you do not have access to it.", array('@link_path' => $item['link_path'])));
+ }
+}
+
+/**
+ * Submit function for the delete button on the menu item editing form.
+ */
+function menu_item_delete_submit($form, &$form_state) {
+ $form_state['redirect'] = 'admin/structure/menu/item/' . $form_state['values']['mlid'] . '/delete';
+}
+
+/**
+ * Process menu and menu item add/edit form submissions.
+ */
+function menu_edit_item_submit($form, &$form_state) {
+ $item = &$form_state['values'];
+
+ // The value of "hidden" is the opposite of the value
+ // supplied by the "enabled" checkbox.
+ $item['hidden'] = (int) !$item['enabled'];
+ unset($item['enabled']);
+
+ $item['options']['attributes']['title'] = $item['description'];
+ list($item['menu_name'], $item['plid']) = explode(':', $item['parent']);
+ if (!menu_link_save($item)) {
+ drupal_set_message(t('There was an error saving the menu link.'), 'error');
+ }
+ else {
+ drupal_set_message(t('Your configuration has been saved.'));
+ }
+ $form_state['redirect'] = 'admin/structure/menu/manage/' . $item['menu_name'];
+}
+
+/**
+ * Menu callback; Build the form that handles the adding/editing of a custom menu.
+ */
+function menu_edit_menu($form, &$form_state, $type, $menu = array()) {
+ $system_menus = menu_list_system_menus();
+ $menu += array(
+ 'menu_name' => '',
+ 'old_name' => !empty($menu['menu_name']) ? $menu['menu_name'] : '',
+ 'title' => '',
+ 'description' => '',
+ );
+ // Allow menu_edit_menu_submit() and other form submit handlers to determine
+ // whether the menu already exists.
+ $form['#insert'] = empty($menu['old_name']);
+ $form['old_name'] = array(
+ '#type' => 'value',
+ '#value' => $menu['old_name'],
+ );
+
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Title'),
+ '#default_value' => $menu['title'],
+ '#required' => TRUE,
+ // The title of a system menu cannot be altered.
+ '#access' => !isset($system_menus[$menu['menu_name']]),
+ );
+
+ $form['menu_name'] = array(
+ '#type' => 'machine_name',
+ '#title' => t('Menu name'),
+ '#default_value' => $menu['menu_name'],
+ '#maxlength' => MENU_MAX_MENU_NAME_LENGTH_UI,
+ '#description' => t('A unique name to construct the URL for the menu. It must only contain lowercase letters, numbers and hyphens.'),
+ '#machine_name' => array(
+ 'exists' => 'menu_edit_menu_name_exists',
+ 'source' => array('title'),
+ 'label' => t('URL path'),
+ 'replace_pattern' => '[^a-z0-9-]+',
+ 'replace' => '-',
+ ),
+ // A menu's machine name cannot be changed.
+ '#disabled' => !empty($menu['old_name']) || isset($system_menus[$menu['menu_name']]),
+ );
+
+ $form['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => $menu['description'],
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+ // Only custom menus may be deleted.
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#access' => $type == 'edit' && !isset($system_menus[$menu['menu_name']]),
+ '#submit' => array('menu_custom_delete_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit function for the 'Delete' button on the menu editing form.
+ */
+function menu_custom_delete_submit($form, &$form_state) {
+ $form_state['redirect'] = 'admin/structure/menu/manage/' . $form_state['values']['menu_name'] . '/delete';
+}
+
+/**
+ * Menu callback; check access and get a confirm form for deletion of a custom menu.
+ */
+function menu_delete_menu_page($menu) {
+ // System-defined menus may not be deleted.
+ $system_menus = menu_list_system_menus();
+ if (isset($system_menus[$menu['menu_name']])) {
+ drupal_access_denied();
+ return;
+ }
+ return drupal_get_form('menu_delete_menu_confirm', $menu);
+}
+
+/**
+ * Build a confirm form for deletion of a custom menu.
+ */
+function menu_delete_menu_confirm($form, &$form_state, $menu) {
+ $form['#menu'] = $menu;
+ $caption = '';
+ $num_links = db_query("SELECT COUNT(*) FROM {menu_links} WHERE menu_name = :menu", array(':menu' => $menu['menu_name']))->fetchField();
+ if ($num_links) {
+ $caption .= '<p>' . format_plural($num_links, '<strong>Warning:</strong> There is currently 1 menu link in %title. It will be deleted (system-defined items will be reset).', '<strong>Warning:</strong> There are currently @count menu links in %title. They will be deleted (system-defined links will be reset).', array('%title' => $menu['title'])) . '</p>';
+ }
+ $caption .= '<p>' . t('This action cannot be undone.') . '</p>';
+ return confirm_form($form, t('Are you sure you want to delete the custom menu %title?', array('%title' => $menu['title'])), 'admin/structure/menu/manage/' . $menu['menu_name'], $caption, t('Delete'));
+}
+
+/**
+ * Delete a custom menu and all links in it.
+ */
+function menu_delete_menu_confirm_submit($form, &$form_state) {
+ $menu = $form['#menu'];
+ $form_state['redirect'] = 'admin/structure/menu';
+
+ // System-defined menus may not be deleted - only menus defined by this module.
+ $system_menus = menu_list_system_menus();
+ if (isset($system_menus[$menu['menu_name']]) || !(db_query("SELECT 1 FROM {menu_custom} WHERE menu_name = :menu", array(':menu' => $menu['menu_name']))->fetchField())) {
+ return;
+ }
+
+ // Reset all the menu links defined by the system via hook_menu().
+ $result = db_query("SELECT * FROM {menu_links} ml INNER JOIN {menu_router} m ON ml.router_path = m.path WHERE ml.menu_name = :menu AND ml.module = 'system' ORDER BY m.number_parts ASC", array(':menu' => $menu['menu_name']), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $link) {
+ menu_reset_item($link);
+ }
+
+ // Delete all links to the overview page for this menu.
+ $result = db_query("SELECT mlid FROM {menu_links} ml WHERE ml.link_path = :link", array(':link' => 'admin/structure/menu/manage/' . $menu['menu_name']), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $link) {
+ menu_link_delete($link['mlid']);
+ }
+
+ // Delete the custom menu and all its menu links.
+ menu_delete($menu);
+
+ $t_args = array('%title' => $menu['title']);
+ drupal_set_message(t('The custom menu %title has been deleted.', $t_args));
+ watchdog('menu', 'Deleted custom menu %title and all its menu links.', $t_args, WATCHDOG_NOTICE);
+}
+
+/**
+ * Returns whether a menu name already exists.
+ *
+ * @see menu_edit_menu()
+ * @see form_validate_machine_name()
+ */
+function menu_edit_menu_name_exists($value) {
+ // 'menu-' is added to the menu name to avoid name-space conflicts.
+ $value = 'menu-' . $value;
+ $custom_exists = db_query_range('SELECT 1 FROM {menu_custom} WHERE menu_name = :menu', 0, 1, array(':menu' => $value))->fetchField();
+ $link_exists = db_query_range("SELECT 1 FROM {menu_links} WHERE menu_name = :menu", 0, 1, array(':menu' => $value))->fetchField();
+
+ return $custom_exists || $link_exists;
+}
+
+/**
+ * Submit function for adding or editing a custom menu.
+ */
+function menu_edit_menu_submit($form, &$form_state) {
+ $menu = $form_state['values'];
+ $path = 'admin/structure/menu/manage/';
+ if ($form['#insert']) {
+ // Add 'menu-' to the menu name to help avoid name-space conflicts.
+ $menu['menu_name'] = 'menu-' . $menu['menu_name'];
+ $link['link_title'] = $menu['title'];
+ $link['link_path'] = $path . $menu['menu_name'];
+ $link['router_path'] = $path . '%';
+ $link['module'] = 'menu';
+ $link['plid'] = db_query("SELECT mlid FROM {menu_links} WHERE link_path = :link AND module = :module", array(
+ ':link' => 'admin/structure/menu',
+ ':module' => 'system'
+ ))
+ ->fetchField();
+
+ menu_link_save($link);
+ menu_save($menu);
+ }
+ else {
+ menu_save($menu);
+ $result = db_query("SELECT mlid FROM {menu_links} WHERE link_path = :path", array(':path' => $path . $menu['menu_name']), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $m) {
+ $link = menu_link_load($m['mlid']);
+ $link['link_title'] = $menu['title'];
+ menu_link_save($link);
+ }
+ }
+ drupal_set_message(t('Your configuration has been saved.'));
+ $form_state['redirect'] = $path . $menu['menu_name'];
+}
+
+/**
+ * Menu callback; Check access and present a confirm form for deleting a menu link.
+ */
+function menu_item_delete_page($item) {
+ // Links defined via hook_menu may not be deleted. Updated items are an
+ // exception, as they can be broken.
+ if ($item['module'] == 'system' && !$item['updated']) {
+ drupal_access_denied();
+ return;
+ }
+ return drupal_get_form('menu_item_delete_form', $item);
+}
+
+/**
+ * Build a confirm form for deletion of a single menu link.
+ */
+function menu_item_delete_form($form, &$form_state, $item) {
+ $form['#item'] = $item;
+ return confirm_form($form, t('Are you sure you want to delete the custom menu link %item?', array('%item' => $item['link_title'])), 'admin/structure/menu/manage/' . $item['menu_name']);
+}
+
+/**
+ * Process menu delete form submissions.
+ */
+function menu_item_delete_form_submit($form, &$form_state) {
+ $item = $form['#item'];
+ menu_link_delete($item['mlid']);
+ $t_args = array('%title' => $item['link_title']);
+ drupal_set_message(t('The menu link %title has been deleted.', $t_args));
+ watchdog('menu', 'Deleted menu link %title.', $t_args, WATCHDOG_NOTICE);
+ $form_state['redirect'] = 'admin/structure/menu/manage/' . $item['menu_name'];
+}
+
+/**
+ * Menu callback; reset a single modified menu link.
+ */
+function menu_reset_item_confirm($form, &$form_state, $item) {
+ $form['item'] = array('#type' => 'value', '#value' => $item);
+ return confirm_form($form, t('Are you sure you want to reset the link %item to its default values?', array('%item' => $item['link_title'])), 'admin/structure/menu/manage/' . $item['menu_name'], t('Any customizations will be lost. This action cannot be undone.'), t('Reset'));
+}
+
+/**
+ * Process menu reset item form submissions.
+ */
+function menu_reset_item_confirm_submit($form, &$form_state) {
+ $item = $form_state['values']['item'];
+ $new_item = menu_reset_item($item);
+ drupal_set_message(t('The menu link was reset to its default settings.'));
+ $form_state['redirect'] = 'admin/structure/menu/manage/' . $new_item['menu_name'];
+}
+
+/**
+ * Menu callback; Build the form presenting menu configuration options.
+ */
+function menu_configure() {
+ $form['intro'] = array(
+ '#type' => 'item',
+ '#markup' => t('The menu module allows on-the-fly creation of menu links in the content authoring forms. To configure these settings for a particular content type, visit the <a href="@content-types">Content types</a> page, click the <em>edit</em> link for the content type, and go to the <em>Menu settings</em> section.', array('@content-types' => url('admin/structure/types'))),
+ );
+
+ $menu_options = menu_get_menus();
+
+ $main = variable_get('menu_main_links_source', 'main-menu');
+ $form['menu_main_links_source'] = array(
+ '#type' => 'select',
+ '#title' => t('Source for the Main links'),
+ '#default_value' => variable_get('menu_main_links_source', 'main-menu'),
+ '#empty_option' => t('No Main links'),
+ '#options' => $menu_options,
+ '#tree' => FALSE,
+ '#description' => t('Select what should be displayed as the Main links (typically at the top of the page).'),
+ );
+
+ $form['menu_secondary_links_source'] = array(
+ '#type' => 'select',
+ '#title' => t('Source for the Secondary links'),
+ '#default_value' => variable_get('menu_secondary_links_source', 'user-menu'),
+ '#empty_option' => t('No Secondary links'),
+ '#options' => $menu_options,
+ '#tree' => FALSE,
+ '#description' => t('Select the source for the Secondary links. An advanced option allows you to use the same source for both Main links (currently %main) and Secondary links: if your source menu has two levels of hierarchy, the top level menu links will appear in the Main links, and the children of the active link will appear in the Secondary links.', array('%main' => $main ? $menu_options[$main] : t('none'))),
+ );
+
+ return system_settings_form($form);
+}
diff --git a/core/modules/menu/menu.admin.js b/core/modules/menu/menu.admin.js
new file mode 100644
index 000000000000..15bc2e7c790b
--- /dev/null
+++ b/core/modules/menu/menu.admin.js
@@ -0,0 +1,47 @@
+
+(function ($) {
+
+ Drupal.behaviors.menuChangeParentItems = {
+ attach: function (context, settings) {
+ $('fieldset#edit-menu input').each(function () {
+ $(this).change(function () {
+ // Update list of available parent menu items.
+ Drupal.menu_update_parent_list();
+ });
+ });
+ }
+ }
+
+ /**
+ * Function to set the options of the menu parent item dropdown.
+ */
+ Drupal.menu_update_parent_list = function () {
+ var values = [];
+
+ $('input:checked', $('fieldset#edit-menu')).each(function () {
+ // Get the names of all checked menus.
+ values.push(Drupal.checkPlain($.trim($(this).val())));
+ });
+
+ var url = Drupal.settings.basePath + 'admin/structure/menu/parents';
+ $.ajax({
+ url: location.protocol + '//' + location.host + url,
+ type: 'POST',
+ data: {'menus[]' : values},
+ dataType: 'json',
+ success: function (options) {
+ // Save key of last selected element.
+ var selected = $('fieldset#edit-menu #edit-menu-parent :selected').val();
+ // Remove all exisiting options from dropdown.
+ $('fieldset#edit-menu #edit-menu-parent').children().remove();
+ // Add new options to dropdown.
+ jQuery.each(options, function(index, value) {
+ $('fieldset#edit-menu #edit-menu-parent').append(
+ $('<option ' + (index == selected ? ' selected="selected"' : '') + '></option>').val(index).text(value)
+ );
+ });
+ }
+ });
+ }
+
+})(jQuery);
diff --git a/core/modules/menu/menu.api.php b/core/modules/menu/menu.api.php
new file mode 100644
index 000000000000..3f3818e17256
--- /dev/null
+++ b/core/modules/menu/menu.api.php
@@ -0,0 +1,87 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Menu module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Informs modules that a custom menu was created.
+ *
+ * This hook is used to notify modules that a custom menu has been created.
+ * Contributed modules may use the information to perform actions based on the
+ * information entered into the menu system.
+ *
+ * @param $menu
+ * An array representing a custom menu:
+ * - menu_name: The unique name of the custom menu.
+ * - title: The human readable menu title.
+ * - description: The custom menu description.
+ *
+ * @see hook_menu_update()
+ * @see hook_menu_delete()
+ */
+function hook_menu_insert($menu) {
+ // For example, we track available menus in a variable.
+ $my_menus = variable_get('my_module_menus', array());
+ $my_menus[$menu['menu_name']] = $menu['menu_name'];
+ variable_set('my_module_menus', $my_menus);
+}
+
+/**
+ * Informs modules that a custom menu was updated.
+ *
+ * This hook is used to notify modules that a custom menu has been updated.
+ * Contributed modules may use the information to perform actions based on the
+ * information entered into the menu system.
+ *
+ * @param $menu
+ * An array representing a custom menu:
+ * - menu_name: The unique name of the custom menu.
+ * - title: The human readable menu title.
+ * - description: The custom menu description.
+ * - old_name: The current 'menu_name'. Note that internal menu names cannot
+ * be changed after initial creation.
+ *
+ * @see hook_menu_insert()
+ * @see hook_menu_delete()
+ */
+function hook_menu_update($menu) {
+ // For example, we track available menus in a variable.
+ $my_menus = variable_get('my_module_menus', array());
+ $my_menus[$menu['menu_name']] = $menu['menu_name'];
+ variable_set('my_module_menus', $my_menus);
+}
+
+/**
+ * Informs modules that a custom menu was deleted.
+ *
+ * This hook is used to notify modules that a custom menu along with all links
+ * contained in it (if any) has been deleted. Contributed modules may use the
+ * information to perform actions based on the information entered into the menu
+ * system.
+ *
+ * @param $link
+ * An array representing a custom menu:
+ * - menu_name: The unique name of the custom menu.
+ * - title: The human readable menu title.
+ * - description: The custom menu description.
+ *
+ * @see hook_menu_insert()
+ * @see hook_menu_update()
+ */
+function hook_menu_delete($menu) {
+ // Delete the record from our variable.
+ $my_menus = variable_get('my_module_menus', array());
+ unset($my_menus[$menu['menu_name']]);
+ variable_set('my_module_menus', $my_menus);
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/menu/menu.css b/core/modules/menu/menu.css
new file mode 100644
index 000000000000..96f861adbefa
--- /dev/null
+++ b/core/modules/menu/menu.css
@@ -0,0 +1,12 @@
+
+.menu-operations {
+ width: 100px;
+}
+
+.menu-enabled {
+ width: 70px;
+}
+
+.menu-enabled input {
+ margin-left:25px;
+}
diff --git a/core/modules/menu/menu.info b/core/modules/menu/menu.info
new file mode 100644
index 000000000000..d93c54268e70
--- /dev/null
+++ b/core/modules/menu/menu.info
@@ -0,0 +1,7 @@
+name = Menu
+description = Allows administrators to customize the site navigation menu.
+package = Core
+version = VERSION
+core = 8.x
+files[] = menu.test
+configure = admin/structure/menu
diff --git a/core/modules/menu/menu.install b/core/modules/menu/menu.install
new file mode 100644
index 000000000000..05aed283fae4
--- /dev/null
+++ b/core/modules/menu/menu.install
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the menu module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function menu_schema() {
+ $schema['menu_custom'] = array(
+ 'description' => 'Holds definitions for top-level custom menus (for example, Main menu).',
+ 'fields' => array(
+ 'menu_name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Primary Key: Unique key for menu. This is used as a block delta so length is 32.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Menu title; displayed at top of block.',
+ 'translatable' => TRUE,
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'description' => 'Menu description.',
+ 'translatable' => TRUE,
+ ),
+ ),
+ 'primary key' => array('menu_name'),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function menu_install() {
+ $system_menus = menu_list_system_menus();
+ $t = get_t();
+ $descriptions = array(
+ 'navigation' => $t('The <em>Navigation</em> menu contains links intended for site visitors. Links are added to the <em>Navigation</em> menu automatically by some modules.'),
+ 'user-menu' => $t("The <em>User</em> menu contains links related to the user's account, as well as the 'Log out' link."),
+ 'management' => $t('The <em>Management</em> menu contains links for administrative tasks.'),
+ 'main-menu' => $t('The <em>Main</em> menu is used on many sites to show the major sections of the site, often in a top navigation bar.'),
+ );
+ foreach ($system_menus as $menu_name => $title) {
+ $menu = array(
+ 'menu_name' => $menu_name,
+ 'title' => $t($title),
+ 'description' => $descriptions[$menu_name],
+ );
+ menu_save($menu);
+ }
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function menu_uninstall() {
+ menu_rebuild();
+}
+
diff --git a/core/modules/menu/menu.js b/core/modules/menu/menu.js
new file mode 100644
index 000000000000..40c1bfe95fa3
--- /dev/null
+++ b/core/modules/menu/menu.js
@@ -0,0 +1,66 @@
+
+(function ($) {
+
+Drupal.behaviors.menuFieldsetSummaries = {
+ attach: function (context) {
+ $('fieldset.menu-link-form', context).drupalSetSummary(function (context) {
+ if ($('.form-item-menu-enabled input', context).is(':checked')) {
+ return Drupal.checkPlain($('.form-item-menu-link-title input', context).val());
+ }
+ else {
+ return Drupal.t('Not in menu');
+ }
+ });
+ }
+};
+
+/**
+ * Automatically fill in a menu link title, if possible.
+ */
+Drupal.behaviors.menuLinkAutomaticTitle = {
+ attach: function (context) {
+ $('fieldset.menu-link-form', context).each(function () {
+ // Try to find menu settings widget elements as well as a 'title' field in
+ // the form, but play nicely with user permissions and form alterations.
+ var $checkbox = $('.form-item-menu-enabled input', this);
+ var $link_title = $('.form-item-menu-link-title input', context);
+ var $title = $(this).closest('form').find('.form-item-title input');
+ // Bail out if we do not have all required fields.
+ if (!($checkbox.length && $link_title.length && $title.length)) {
+ return;
+ }
+ // If there is a link title already, mark it as overridden. The user expects
+ // that toggling the checkbox twice will take over the node's title.
+ if ($checkbox.is(':checked') && $link_title.val().length) {
+ $link_title.data('menuLinkAutomaticTitleOveridden', true);
+ }
+ // Whenever the value is changed manually, disable this behavior.
+ $link_title.keyup(function () {
+ $link_title.data('menuLinkAutomaticTitleOveridden', true);
+ });
+ // Global trigger on checkbox (do not fill-in a value when disabled).
+ $checkbox.change(function () {
+ if ($checkbox.is(':checked')) {
+ if (!$link_title.data('menuLinkAutomaticTitleOveridden')) {
+ $link_title.val($title.val());
+ }
+ }
+ else {
+ $link_title.val('');
+ $link_title.removeData('menuLinkAutomaticTitleOveridden');
+ }
+ $checkbox.closest('fieldset.vertical-tabs-pane').trigger('summaryUpdated');
+ $checkbox.trigger('formUpdated');
+ });
+ // Take over any title change.
+ $title.keyup(function () {
+ if (!$link_title.data('menuLinkAutomaticTitleOveridden') && $checkbox.is(':checked')) {
+ $link_title.val($title.val());
+ $link_title.val($title.val()).trigger('formUpdated');
+ }
+ });
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/menu/menu.module b/core/modules/menu/menu.module
new file mode 100644
index 000000000000..cd422561d914
--- /dev/null
+++ b/core/modules/menu/menu.module
@@ -0,0 +1,777 @@
+<?php
+
+/**
+ * @file
+ * Allows administrators to customize the site's navigation menus.
+ *
+ * A menu (in this context) is a hierarchical collection of links, generally
+ * used for navigation. This is not to be confused with the
+ * @link menu Menu system @endlink of menu.inc and hook_menu(), which defines
+ * page routing requests for Drupal, and also allows the defined page routing
+ * URLs to be added to the main site navigation menu.
+ */
+
+/**
+ * Maximum length of menu name as entered by the user. Database length is 32
+ * and we add a menu- prefix.
+ */
+define('MENU_MAX_MENU_NAME_LENGTH_UI', 27);
+
+/**
+ * Implements hook_help().
+ */
+function menu_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#menu':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Menu module provides an interface for managing menus. A menu is a hierarchical collection of links, which can be within or external to the site, generally used for navigation. Each menu is rendered in a block that can be enabled and positioned through the <a href="@blocks">Blocks administration page</a>. You can view and manage menus on the <a href="@menus">Menus administration page</a>. For more information, see the online handbook entry for the <a href="@menu">Menu module</a>.', array('@blocks' => url('admin/structure/block'), '@menus' => url('admin/structure/menu'), '@menu' => 'http://drupal.org/handbook/modules/menu/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Managing menus') . '</dt>';
+ $output .= '<dd>' . t('Users with the <em>Administer menus and menu items</em> permission can add, edit and delete custom menus on the <a href="@menu">Menus administration page</a>. Custom menus can be special site menus, menus of external links, or any combination of internal and external links. You may create an unlimited number of additional menus, each of which will automatically have an associated block. By selecting <em>list links</em>, you can add, edit, or delete links for a given menu. The links listing page provides a drag-and-drop interface for controlling the order of links, and creating a hierarchy within the menu.', array('@menu' => url('admin/structure/menu'), '@add-menu' => url('admin/structure/menu/add'))) . '</dd>';
+ $output .= '<dt>' . t('Displaying menus') . '</dt>';
+ $output .= '<dd>' . t('After you have created a menu, you must enable and position the associated block on the <a href="@blocks">Blocks administration page</a>.', array('@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/structure/menu/add':
+ return '<p>' . t('You can enable the newly-created block for this menu on the <a href="@blocks">Blocks administration page</a>.', array('@blocks' => url('admin/structure/block'))) . '</p>';
+ }
+ if ($path == 'admin/structure/menu' && module_exists('block')) {
+ return '<p>' . t('Each menu has a corresponding block that is managed on the <a href="@blocks">Blocks administration page</a>.', array('@blocks' => url('admin/structure/block'))) . '</p>';
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function menu_permission() {
+ return array(
+ 'administer menu' => array(
+ 'title' => t('Administer menus and menu items'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function menu_menu() {
+ $items['admin/structure/menu'] = array(
+ 'title' => 'Menus',
+ 'description' => 'Add new menus to your site, edit existing menus, and rename and reorganize menu links.',
+ 'page callback' => 'menu_overview_page',
+ 'access callback' => 'user_access',
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/parents'] = array(
+ 'title' => 'Parent menu items',
+ 'page callback' => 'menu_parent_options_js',
+ 'type' => MENU_CALLBACK,
+ 'access arguments' => array(TRUE),
+ );
+ $items['admin/structure/menu/list'] = array(
+ 'title' => 'List menus',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/structure/menu/add'] = array(
+ 'title' => 'Add menu',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_edit_menu', 'add'),
+ 'access arguments' => array('administer menu'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_configure'),
+ 'access arguments' => array('administer menu'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 5,
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/manage/%menu'] = array(
+ 'title' => 'Customize menu',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_overview_form', 4),
+ 'title callback' => 'menu_overview_title',
+ 'title arguments' => array(4),
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/manage/%menu/list'] = array(
+ 'title' => 'List links',
+ 'weight' => -10,
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ );
+ $items['admin/structure/menu/manage/%menu/add'] = array(
+ 'title' => 'Add link',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_edit_item', 'add', NULL, 4),
+ 'access arguments' => array('administer menu'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/manage/%menu/edit'] = array(
+ 'title' => 'Edit menu',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_edit_menu', 'edit', 4),
+ 'access arguments' => array('administer menu'),
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/manage/%menu/delete'] = array(
+ 'title' => 'Delete menu',
+ 'page callback' => 'menu_delete_menu_page',
+ 'page arguments' => array(4),
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/item/%menu_link/edit'] = array(
+ 'title' => 'Edit menu link',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_edit_item', 'edit', 4, NULL),
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/item/%menu_link/reset'] = array(
+ 'title' => 'Reset menu link',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('menu_reset_item_confirm', 4),
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ $items['admin/structure/menu/item/%menu_link/delete'] = array(
+ 'title' => 'Delete menu link',
+ 'page callback' => 'menu_item_delete_page',
+ 'page arguments' => array(4),
+ 'access arguments' => array('administer menu'),
+ 'file' => 'menu.admin.inc',
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function menu_theme() {
+ return array(
+ 'menu_overview_form' => array(
+ 'file' => 'menu.admin.inc',
+ 'render element' => 'form',
+ ),
+ 'menu_admin_overview' => array(
+ 'file' => 'menu.admin.inc',
+ 'variables' => array('title' => NULL, 'name' => NULL, 'description' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_enable().
+ *
+ * Add a link for each custom menu.
+ */
+function menu_enable() {
+ menu_rebuild();
+ $base_link = db_query("SELECT mlid AS plid, menu_name FROM {menu_links} WHERE link_path = 'admin/structure/menu' AND module = 'system'")->fetchAssoc();
+ $base_link['router_path'] = 'admin/structure/menu/manage/%';
+ $base_link['module'] = 'menu';
+ $result = db_query("SELECT * FROM {menu_custom}", array(), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $menu) {
+ // $link is passed by reference to menu_link_save(), so we make a copy of $base_link.
+ $link = $base_link;
+ $link['mlid'] = 0;
+ $link['link_title'] = $menu['title'];
+ $link['link_path'] = 'admin/structure/menu/manage/' . $menu['menu_name'];
+ $menu_link = db_query("SELECT mlid FROM {menu_links} WHERE link_path = :path AND plid = :plid", array(
+ ':path' => $link['link_path'],
+ ':plid' => $link['plid']
+ ))
+ ->fetchField();
+ if (!$menu_link) {
+ menu_link_save($link);
+ }
+ }
+ menu_cache_clear_all();
+}
+
+/**
+ * Title callback for the menu overview page and links.
+ */
+function menu_overview_title($menu) {
+ return $menu['title'];
+}
+
+/**
+ * Load the data for a single custom menu.
+ *
+ * @param $menu_name
+ * The unique name of a custom menu to load.
+ * @return
+ * Array defining the custom menu, or FALSE if the menu doesn't exist.
+ */
+function menu_load($menu_name) {
+ $all_menus = menu_load_all();
+ return isset($all_menus[$menu_name]) ? $all_menus[$menu_name] : FALSE;
+}
+
+/**
+ * Load all custom menu data.
+ *
+ * @return
+ * Array of custom menu data.
+ */
+function menu_load_all() {
+ $custom_menus = &drupal_static(__FUNCTION__);
+ if (!isset($custom_menus)) {
+ if ($cached = cache('menu')->get('menu_custom')) {
+ $custom_menus = $cached->data;
+ }
+ else {
+ $custom_menus = db_query('SELECT * FROM {menu_custom}')->fetchAllAssoc('menu_name', PDO::FETCH_ASSOC);
+ cache('menu')->set('menu_custom', $custom_menus);
+ }
+ }
+ return $custom_menus;
+}
+
+/**
+ * Save a custom menu.
+ *
+ * @param $menu
+ * An array representing a custom menu:
+ * - menu_name: The unique name of the custom menu (composed of lowercase
+ * letters, numbers, and hyphens).
+ * - title: The human readable menu title.
+ * - description: The custom menu description.
+ *
+ * Modules should always pass a fully populated $menu when saving a custom
+ * menu, so other modules are able to output proper status or watchdog messages.
+ *
+ * @see menu_load()
+ */
+function menu_save($menu) {
+ $status = db_merge('menu_custom')
+ ->key(array('menu_name' => $menu['menu_name']))
+ ->fields(array(
+ 'title' => $menu['title'],
+ 'description' => $menu['description'],
+ ))
+ ->execute();
+ menu_cache_clear_all();
+
+ switch ($status) {
+ case SAVED_NEW:
+ module_invoke_all('menu_insert', $menu);
+ break;
+
+ case SAVED_UPDATED:
+ module_invoke_all('menu_update', $menu);
+ break;
+ }
+}
+
+/**
+ * Delete a custom menu and all contained links.
+ *
+ * Note that this function deletes all menu links in a custom menu. While menu
+ * links derived from router paths may be restored by rebuilding the menu, all
+ * customized and custom links will be irreversibly gone. Therefore, this
+ * function should usually be called from a user interface (form submit) handler
+ * only, which allows the user to confirm the action.
+ *
+ * @param $menu
+ * An array representing a custom menu:
+ * - menu_name: The unique name of the custom menu.
+ * - title: The human readable menu title.
+ * - description: The custom menu description.
+ *
+ * Modules should always pass a fully populated $menu when deleting a custom
+ * menu, so other modules are able to output proper status or watchdog messages.
+ *
+ * @see menu_load()
+ *
+ * menu_delete_links() will take care of clearing the page cache. Other modules
+ * should take care of their menu-related data by implementing
+ * hook_menu_delete().
+ */
+function menu_delete($menu) {
+ // Delete all links from the menu.
+ menu_delete_links($menu['menu_name']);
+
+ // Delete the custom menu.
+ db_delete('menu_custom')
+ ->condition('menu_name', $menu['menu_name'])
+ ->execute();
+
+ menu_cache_clear_all();
+ module_invoke_all('menu_delete', $menu);
+}
+
+/**
+ * Return a list of menu items that are valid possible parents for the given menu item.
+ *
+ * @param $menus
+ * An array of menu names and titles, such as from menu_get_menus().
+ * @param $item
+ * The menu item or the node type for which to generate a list of parents.
+ * If $item['mlid'] == 0 then the complete tree is returned.
+ * @param $type
+ * The node type for which to generate a list of parents.
+ * If $item itself is a node type then $type is ignored.
+ * @return
+ * An array of menu link titles keyed on the a string containing the menu name
+ * and mlid. The list excludes the given item and its children.
+ *
+ * @todo This has to be turned into a #process form element callback. The
+ * 'menu_override_parent_selector' variable is entirely superfluous.
+ */
+function menu_parent_options($menus, $item, $type = '') {
+ // The menu_links table can be practically any size and we need a way to
+ // allow contrib modules to provide more scalable pattern choosers.
+ // hook_form_alter is too late in itself because all the possible parents are
+ // retrieved here, unless menu_override_parent_selector is set to TRUE.
+ if (variable_get('menu_override_parent_selector', FALSE)) {
+ return array();
+ }
+
+ $available_menus = array();
+ if (!is_array($item)) {
+ // If $item is not an array then it is a node type.
+ // Use it as $type and prepare a dummy menu item for _menu_get_options().
+ $type = $item;
+ $item = array('mlid' => 0);
+ }
+ if (empty($type)) {
+ // If no node type is set, use all menus given to this function.
+ $available_menus = $menus;
+ }
+ else {
+ // If a node type is set, use all available menus for this type.
+ $type_menus = variable_get('menu_options_' . $type, array('main-menu' => 'main-menu'));
+ foreach ($type_menus as $menu) {
+ $available_menus[$menu] = $menu;
+ }
+ }
+
+ return _menu_get_options($menus, $available_menus, $item);
+}
+
+/**
+ * Page callback.
+ * Get all the available menus and menu items as a JavaScript array.
+ */
+function menu_parent_options_js() {
+ $available_menus = array();
+ if (isset($_POST['menus']) && count($_POST['menus'])) {
+ foreach ($_POST['menus'] as $menu) {
+ $available_menus[$menu] = $menu;
+ }
+ }
+ $options = _menu_get_options(menu_get_menus(), $available_menus, array('mlid' => 0));
+
+ drupal_json_output($options);
+}
+
+/**
+ * Helper function to get the items of the given menu.
+ */
+function _menu_get_options($menus, $available_menus, $item) {
+ // If the item has children, there is an added limit to the depth of valid parents.
+ if (isset($item['parent_depth_limit'])) {
+ $limit = $item['parent_depth_limit'];
+ }
+ else {
+ $limit = _menu_parent_depth_limit($item);
+ }
+
+ $options = array();
+ foreach ($menus as $menu_name => $title) {
+ if (isset($available_menus[$menu_name])) {
+ $tree = menu_tree_all_data($menu_name, NULL);
+ $options[$menu_name . ':0'] = '<' . $title . '>';
+ _menu_parents_recurse($tree, $menu_name, '--', $options, $item['mlid'], $limit);
+ }
+ }
+ return $options;
+}
+
+/**
+ * Recursive helper function for menu_parent_options().
+ */
+function _menu_parents_recurse($tree, $menu_name, $indent, &$options, $exclude, $depth_limit) {
+ foreach ($tree as $data) {
+ if ($data['link']['depth'] > $depth_limit) {
+ // Don't iterate through any links on this level.
+ break;
+ }
+ if ($data['link']['mlid'] != $exclude && $data['link']['hidden'] >= 0) {
+ $title = $indent . ' ' . truncate_utf8($data['link']['title'], 30, TRUE, FALSE);
+ if ($data['link']['hidden']) {
+ $title .= ' (' . t('disabled') . ')';
+ }
+ $options[$menu_name . ':' . $data['link']['mlid']] = $title;
+ if ($data['below']) {
+ _menu_parents_recurse($data['below'], $menu_name, $indent . '--', $options, $exclude, $depth_limit);
+ }
+ }
+ }
+}
+
+/**
+ * Reset a system-defined menu link.
+ */
+function menu_reset_item($link) {
+ // To reset the link to its original values, we need to retrieve its
+ // definition from hook_menu(). Otherwise, for example, the link's menu would
+ // not be reset, because properties like the original 'menu_name' are not
+ // stored anywhere else. Since resetting a link happens rarely and this is a
+ // one-time operation, retrieving the full menu router does no harm.
+ $menu = menu_get_router();
+ $router_item = $menu[$link['router_path']];
+ $new_link = _menu_link_build($router_item);
+ // Merge existing menu link's ID and 'has_children' property.
+ foreach (array('mlid', 'has_children') as $key) {
+ $new_link[$key] = $link[$key];
+ }
+ menu_link_save($new_link);
+ return $new_link;
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function menu_block_info() {
+ $menus = menu_get_menus(FALSE);
+
+ $blocks = array();
+ foreach ($menus as $name => $title) {
+ // Default "Navigation" block is handled by user.module.
+ $blocks[$name]['info'] = check_plain($title);
+ // Menu blocks can't be cached because each menu item can have
+ // a custom access callback. menu.inc manages its own caching.
+ $blocks[$name]['cache'] = DRUPAL_NO_CACHE;
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function menu_block_view($delta = '') {
+ $menus = menu_get_menus(FALSE);
+ $data['subject'] = check_plain($menus[$delta]);
+ $data['content'] = menu_tree($delta);
+ // Add contextual links for this block.
+ if (!empty($data['content'])) {
+ $data['content']['#contextual_links']['menu'] = array('admin/structure/menu/manage', array($delta));
+ }
+ return $data;
+}
+
+/**
+ * Implements hook_block_view_alter().
+ */
+function menu_block_view_alter(&$data, $block) {
+ // Add contextual links for system menu blocks.
+ if ($block->module == 'system' && !empty($data['content'])) {
+ $system_menus = menu_list_system_menus();
+ if (isset($system_menus[$block->delta])) {
+ $data['content']['#contextual_links']['menu'] = array('admin/structure/menu/manage', array($block->delta));
+ }
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function menu_node_insert($node) {
+ menu_node_save($node);
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function menu_node_update($node) {
+ menu_node_save($node);
+}
+
+/**
+ * Helper for hook_node_insert() and hook_node_update().
+ */
+function menu_node_save($node) {
+ if (isset($node->menu)) {
+ $link = &$node->menu;
+ if (empty($link['enabled'])) {
+ if (!empty($link['mlid'])) {
+ menu_link_delete($link['mlid']);
+ }
+ }
+ elseif (trim($link['link_title'])) {
+ $link['link_title'] = trim($link['link_title']);
+ $link['link_path'] = "node/$node->nid";
+ if (trim($link['description'])) {
+ $link['options']['attributes']['title'] = trim($link['description']);
+ }
+ else {
+ // If the description field was left empty, remove the title attribute
+ // from the menu link.
+ unset($link['options']['attributes']['title']);
+ }
+ if (!menu_link_save($link)) {
+ drupal_set_message(t('There was an error saving the menu link.'), 'error');
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function menu_node_delete($node) {
+ // Delete all menu module links that point to this node.
+ $result = db_query("SELECT mlid FROM {menu_links} WHERE link_path = :path AND module = 'menu'", array(':path' => 'node/' . $node->nid), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $m) {
+ menu_link_delete($m['mlid']);
+ }
+}
+
+/**
+ * Implements hook_node_prepare().
+ */
+function menu_node_prepare($node) {
+ if (empty($node->menu)) {
+ // Prepare the node for the edit form so that $node->menu always exists.
+ $menu_name = strtok(variable_get('menu_parent_' . $node->type, 'main-menu:0'), ':');
+ $item = array();
+ if (isset($node->nid)) {
+ $mlid = FALSE;
+ // Give priority to the default menu
+ $type_menus = variable_get('menu_options_' . $node->type, array('main-menu' => 'main-menu'));
+ if (in_array($menu_name, $type_menus)) {
+ $mlid = db_query_range("SELECT mlid FROM {menu_links} WHERE link_path = :path AND menu_name = :menu_name AND module = 'menu' ORDER BY mlid ASC", 0, 1, array(
+ ':path' => 'node/' . $node->nid,
+ ':menu_name' => $menu_name,
+ ))->fetchField();
+ }
+ // Check all allowed menus if a link does not exist in the default menu.
+ if (!$mlid && !empty($type_menus)) {
+ $mlid = db_query_range("SELECT mlid FROM {menu_links} WHERE link_path = :path AND module = 'menu' AND menu_name IN (:type_menus) ORDER BY mlid ASC", 0, 1, array(
+ ':path' => 'node/' . $node->nid,
+ ':type_menus' => array_values($type_menus),
+ ))->fetchField();
+ }
+ if ($mlid) {
+ $item = menu_link_load($mlid);
+ }
+ }
+ // Set default values.
+ $node->menu = $item + array(
+ 'link_title' => '',
+ 'mlid' => 0,
+ 'plid' => 0,
+ 'menu_name' => $menu_name,
+ 'weight' => 0,
+ 'options' => array(),
+ 'module' => 'menu',
+ 'expanded' => 0,
+ 'hidden' => 0,
+ 'has_children' => 0,
+ 'customized' => 0,
+ );
+ }
+ // Find the depth limit for the parent select.
+ if (!isset($node->menu['parent_depth_limit'])) {
+ $node->menu['parent_depth_limit'] = _menu_parent_depth_limit($node->menu);
+ }
+}
+
+/**
+ * Find the depth limit for items in the parent select.
+ */
+function _menu_parent_depth_limit($item) {
+ return MENU_MAX_DEPTH - 1 - (($item['mlid'] && $item['has_children']) ? menu_link_children_relative_depth($item) : 0);
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ *
+ * Adds menu item fields to the node form.
+ *
+ * @see menu_node_submit()
+ */
+function menu_form_node_form_alter(&$form, $form_state) {
+ // Generate a list of possible parents (not including this link or descendants).
+ // @todo This must be handled in a #process handler.
+ $link = $form['#node']->menu;
+ $type = $form['#node']->type;
+ // menu_parent_options() is goofy and can actually handle either a menu link
+ // or a node type both as second argument. Pick based on whether there is
+ // a link already (menu_node_prepare() sets mlid default to 0).
+ $options = menu_parent_options(menu_get_menus(), $link['mlid'] ? $link : $type);
+ // If no possible parent menu items were found, there is nothing to display.
+ if (empty($options)) {
+ return;
+ }
+
+ $form['menu'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Menu settings'),
+ '#access' => user_access('administer menu'),
+ '#collapsible' => TRUE,
+ '#collapsed' => !$link['link_title'],
+ '#group' => 'additional_settings',
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'menu') . '/menu.js'),
+ ),
+ '#tree' => TRUE,
+ '#weight' => -2,
+ '#attributes' => array('class' => array('menu-link-form')),
+ );
+ $form['menu']['enabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Provide a menu link'),
+ '#default_value' => (int) (bool) $link['mlid'],
+ );
+ $form['menu']['link'] = array(
+ '#type' => 'container',
+ '#parents' => array('menu'),
+ '#states' => array(
+ 'invisible' => array(
+ 'input[name="menu[enabled]"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+
+ // Populate the element with the link data.
+ foreach (array('mlid', 'module', 'hidden', 'has_children', 'customized', 'options', 'expanded', 'hidden', 'parent_depth_limit') as $key) {
+ $form['menu']['link'][$key] = array('#type' => 'value', '#value' => $link[$key]);
+ }
+
+ $form['menu']['link']['link_title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Menu link title'),
+ '#default_value' => $link['link_title'],
+ );
+
+ $form['menu']['link']['description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Description'),
+ '#default_value' => isset($link['options']['attributes']['title']) ? $link['options']['attributes']['title'] : '',
+ '#rows' => 1,
+ '#description' => t('Shown when hovering over the menu link.'),
+ );
+
+ $default = ($link['mlid'] ? $link['menu_name'] . ':' . $link['plid'] : variable_get('menu_parent_' . $type, 'main-menu:0'));
+ // If the current parent menu item is not present in options, use the first
+ // available option as default value.
+ // @todo User should not be allowed to access menu link settings in such a
+ // case.
+ if (!isset($options[$default])) {
+ $array = array_keys($options);
+ $default = reset($array);
+ }
+ $form['menu']['link']['parent'] = array(
+ '#type' => 'select',
+ '#title' => t('Parent item'),
+ '#default_value' => $default,
+ '#options' => $options,
+ '#attributes' => array('class' => array('menu-parent-select')),
+ );
+ $form['menu']['link']['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#delta' => 50,
+ '#default_value' => $link['weight'],
+ '#description' => t('Menu links with smaller weights are displayed before links with larger weights.'),
+ );
+}
+
+/**
+ * Implements hook_node_submit().
+ *
+ * @see menu_form_node_form_alter()
+ */
+function menu_node_submit($node, $form, $form_state) {
+ // Decompose the selected menu parent option into 'menu_name' and 'plid', if
+ // the form used the default parent selection widget.
+ if (!empty($form_state['values']['menu']['parent'])) {
+ list($node->menu['menu_name'], $node->menu['plid']) = explode(':', $form_state['values']['menu']['parent']);
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Adds menu options to the node type form.
+ */
+function menu_form_node_type_form_alter(&$form, $form_state) {
+ $menu_options = menu_get_menus();
+ $type = $form['#node_type'];
+ $form['menu'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Menu settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'menu') . '/menu.admin.js'),
+ ),
+ '#group' => 'additional_settings',
+ );
+ $form['menu']['menu_options'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Available menus'),
+ '#default_value' => variable_get('menu_options_' . $type->type, array('main-menu')),
+ '#options' => $menu_options,
+ '#description' => t('The menus available to place links in for this content type.'),
+ );
+ // To avoid an 'illegal option' error after saving the form we have to load
+ // all available menu items.
+ // Otherwise it is not possible to dynamically add options to the list.
+ // @todo Convert menu_parent_options() into a #process callback.
+ $options = menu_parent_options(menu_get_menus(), array('mlid' => 0));
+ $form['menu']['menu_parent'] = array(
+ '#type' => 'select',
+ '#title' => t('Default parent item'),
+ '#default_value' => variable_get('menu_parent_' . $type->type, 'main-menu:0'),
+ '#options' => $options,
+ '#description' => t('Choose the menu item to be the default parent for a new link in the content authoring form.'),
+ '#attributes' => array('class' => array('menu-title-select')),
+ );
+
+ // Call Drupal.menu_update_parent_list() to filter the list of
+ // available default parent menu items based on the selected menus.
+ drupal_add_js(
+ '(function ($) { Drupal.menu_update_parent_list(); })(jQuery);',
+ array('scope' => 'footer', 'type' => 'inline')
+ );
+}
+
+/**
+ * Return an associative array of the custom menus names.
+ *
+ * @param $all
+ * If FALSE return only user-added menus, or if TRUE also include
+ * the menus defined by the system.
+ * @return
+ * An array with the machine-readable names as the keys, and human-readable
+ * titles as the values.
+ */
+function menu_get_menus($all = TRUE) {
+ if ($custom_menus = menu_load_all()) {
+ if (!$all) {
+ $custom_menus = array_diff_key($custom_menus, menu_list_system_menus());
+ }
+ foreach ($custom_menus as $menu_name => $menu) {
+ $custom_menus[$menu_name] = t($menu['title']);
+ }
+ asort($custom_menus);
+ }
+ return $custom_menus;
+}
diff --git a/core/modules/menu/menu.test b/core/modules/menu/menu.test
new file mode 100644
index 000000000000..08bb7e8ef420
--- /dev/null
+++ b/core/modules/menu/menu.test
@@ -0,0 +1,722 @@
+<?php
+
+/**
+ * @file
+ * Tests for menu.module.
+ */
+
+class MenuTestCase extends DrupalWebTestCase {
+ protected $big_user;
+ protected $std_user;
+ protected $menu;
+ protected $items;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu link creation/deletion',
+ 'description' => 'Add a custom menu, add menu links to the custom menu and Navigation menu, check their data, and delete them using the menu module UI.',
+ 'group' => 'Menu'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('menu');
+ // Create users.
+ $this->big_user = $this->drupalCreateUser(array('access administration pages', 'administer blocks', 'administer menu', 'create article content'));
+ $this->std_user = $this->drupalCreateUser(array());
+ }
+
+ /**
+ * Login users, add menus and menu links, and test menu functionality through the admin and user interfaces.
+ */
+ function testMenu() {
+ // Login the user.
+ $this->drupalLogin($this->big_user);
+ $this->items = array();
+
+ // Do standard menu tests.
+ $this->doStandardMenuTests();
+
+ // Do custom menu tests.
+ $this->doCustomMenuTests();
+
+ // Do standard user tests.
+ // Login the user.
+ $this->drupalLogin($this->std_user);
+ $this->verifyAccess(403);
+ foreach ($this->items as $item) {
+ $node = node_load(substr($item['link_path'], 5)); // Paths were set as 'node/$nid'.
+ $this->verifyMenuLink($item, $node);
+ }
+
+ // Login the user.
+ $this->drupalLogin($this->big_user);
+
+ // Delete menu links.
+ foreach ($this->items as $item) {
+ $this->deleteMenuLink($item);
+ }
+
+ // Delete custom menu.
+ $this->deleteCustomMenu($this->menu);
+
+ // Modify and reset a standard menu link.
+ $item = $this->getStandardMenuLink();
+ $old_title = $item['link_title'];
+ $this->modifyMenuLink($item);
+ $item = menu_link_load($item['mlid']);
+ // Verify that a change to the description is saved.
+ $description = $this->randomName(16);
+ $item['options']['attributes']['title'] = $description;
+ menu_link_save($item);
+ $saved_item = menu_link_load($item['mlid']);
+ $this->assertEqual($description, $saved_item['options']['attributes']['title'], t('Saving an existing link updates the description (title attribute)'));
+ $this->resetMenuLink($item, $old_title);
+ }
+
+ /**
+ * Test standard menu functionality using navigation menu.
+ *
+ */
+ function doStandardMenuTests() {
+ $this->doMenuTests();
+ $this->addInvalidMenuLink();
+ }
+
+ /**
+ * Test custom menu functionality using navigation menu.
+ *
+ */
+ function doCustomMenuTests() {
+ $this->menu = $this->addCustomMenu();
+ $this->doMenuTests($this->menu['menu_name']);
+ $this->addInvalidMenuLink($this->menu['menu_name']);
+ $this->addCustomMenuCRUD();
+ }
+
+ /**
+ * Add custom menu using CRUD functions.
+ */
+ function addCustomMenuCRUD() {
+ // Add a new custom menu.
+ $menu_name = substr(hash('sha256', $this->randomName(16)), 0, MENU_MAX_MENU_NAME_LENGTH_UI);
+ $title = $this->randomName(16);
+
+ $menu = array(
+ 'menu_name' => $menu_name,
+ 'title' => $title,
+ 'description' => 'Description text',
+ );
+ menu_save($menu);
+
+ // Assert the new menu.
+ $this->drupalGet('admin/structure/menu/manage/' . $menu_name . '/edit');
+ $this->assertRaw($title, t('Custom menu was added.'));
+
+ // Edit the menu.
+ $new_title = $this->randomName(16);
+ $menu['title'] = $new_title;
+ menu_save($menu);
+ $this->drupalGet('admin/structure/menu/manage/' . $menu_name . '/edit');
+ $this->assertRaw($new_title, t('Custom menu was edited.'));
+ }
+
+ /**
+ * Add custom menu.
+ */
+ function addCustomMenu() {
+ // Add custom menu.
+
+ // Try adding a menu using a menu_name that is too long.
+ $this->drupalGet('admin/structure/menu/add');
+ $menu_name = substr(hash('sha256', $this->randomName(16)), 0, MENU_MAX_MENU_NAME_LENGTH_UI + 1);
+ $title = $this->randomName(16);
+ $edit = array(
+ 'menu_name' => $menu_name,
+ 'description' => '',
+ 'title' => $title,
+ );
+ $this->drupalPost('admin/structure/menu/add', $edit, t('Save'));
+
+ // Verify that using a menu_name that is too long results in a validation message.
+ $this->assertRaw(t('!name cannot be longer than %max characters but is currently %length characters long.', array(
+ '!name' => t('Menu name'),
+ '%max' => MENU_MAX_MENU_NAME_LENGTH_UI,
+ '%length' => drupal_strlen($menu_name),
+ )));
+
+ // Change the menu_name so it no longer exceeds the maximum length.
+ $menu_name = substr(hash('sha256', $this->randomName(16)), 0, MENU_MAX_MENU_NAME_LENGTH_UI);
+ $edit['menu_name'] = $menu_name;
+ $this->drupalPost('admin/structure/menu/add', $edit, t('Save'));
+
+ // Verify that no validation error is given for menu_name length.
+ $this->assertNoRaw(t('!name cannot be longer than %max characters but is currently %length characters long.', array(
+ '!name' => t('Menu name'),
+ '%max' => MENU_MAX_MENU_NAME_LENGTH_UI,
+ '%length' => drupal_strlen($menu_name),
+ )));
+ // Unlike most other modules, there is no confirmation message displayed.
+
+ $this->drupalGet('admin/structure/menu');
+ $this->assertText($title, 'Menu created');
+
+ // Enable the custom menu block.
+ $menu_name = 'menu-' . $menu_name; // Drupal prepends the name with 'menu-'.
+ $edit = array();
+ $edit['blocks[menu_' . $menu_name . '][region]'] = 'sidebar_first';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertResponse(200);
+ $this->assertText(t('The block settings have been updated.'), t('Custom menu block was enabled'));
+
+ return menu_load($menu_name);
+ }
+
+ /**
+ * Delete custom menu.
+ *
+ * @param string $menu_name Custom menu name.
+ */
+ function deleteCustomMenu($menu) {
+ $menu_name = $this->menu['menu_name'];
+ $title = $this->menu['title'];
+
+ // Delete custom menu.
+ $this->drupalPost("admin/structure/menu/manage/$menu_name/delete", array(), t('Delete'));
+ $this->assertResponse(200);
+ $this->assertRaw(t('The custom menu %title has been deleted.', array('%title' => $title)), t('Custom menu was deleted'));
+ $this->assertFalse(menu_load($menu_name), 'Custom menu was deleted');
+ // Test if all menu links associated to the menu were removed from database.
+ $result = db_query("SELECT menu_name FROM {menu_links} WHERE menu_name = :menu_name", array(':menu_name' => $menu_name))->fetchField();
+ $this->assertFalse($result, t('All menu links associated to the custom menu were deleted.'));
+ }
+
+ /**
+ * Test menu functionality using navigation menu.
+ *
+ */
+ function doMenuTests($menu_name = 'navigation') {
+ // Add nodes to use as links for menu links.
+ $node1 = $this->drupalCreateNode(array('type' => 'article'));
+ $node2 = $this->drupalCreateNode(array('type' => 'article'));
+ $node3 = $this->drupalCreateNode(array('type' => 'article'));
+ $node4 = $this->drupalCreateNode(array('type' => 'article'));
+ $node5 = $this->drupalCreateNode(array('type' => 'article'));
+
+ // Add menu links.
+ $item1 = $this->addMenuLink(0, 'node/' . $node1->nid, $menu_name);
+ $item2 = $this->addMenuLink($item1['mlid'], 'node/' . $node2->nid, $menu_name);
+ $item3 = $this->addMenuLink($item2['mlid'], 'node/' . $node3->nid, $menu_name);
+ $this->assertMenuLink($item1['mlid'], array('depth' => 1, 'has_children' => 1, 'p1' => $item1['mlid'], 'p2' => 0));
+ $this->assertMenuLink($item2['mlid'], array('depth' => 2, 'has_children' => 1, 'p1' => $item1['mlid'], 'p2' => $item2['mlid'], 'p3' => 0));
+ $this->assertMenuLink($item3['mlid'], array('depth' => 3, 'has_children' => 0, 'p1' => $item1['mlid'], 'p2' => $item2['mlid'], 'p3' => $item3['mlid'], 'p4' => 0));
+
+ // Verify menu links.
+ $this->verifyMenuLink($item1, $node1);
+ $this->verifyMenuLink($item2, $node2, $item1, $node1);
+ $this->verifyMenuLink($item3, $node3, $item2, $node2);
+
+ // Add more menu links.
+ $item4 = $this->addMenuLink(0, 'node/' . $node4->nid, $menu_name);
+ $item5 = $this->addMenuLink($item4['mlid'], 'node/' . $node5->nid, $menu_name);
+ $this->assertMenuLink($item4['mlid'], array('depth' => 1, 'has_children' => 1, 'p1' => $item4['mlid'], 'p2' => 0));
+ $this->assertMenuLink($item5['mlid'], array('depth' => 2, 'has_children' => 0, 'p1' => $item4['mlid'], 'p2' => $item5['mlid'], 'p3' => 0));
+
+ // Modify menu links.
+ $this->modifyMenuLink($item1);
+ $this->modifyMenuLink($item2);
+
+ // Toggle menu links.
+ $this->toggleMenuLink($item1);
+ $this->toggleMenuLink($item2);
+
+ // Move link and verify that descendants are updated.
+ $this->moveMenuLink($item2, $item5['mlid'], $menu_name);
+ $this->assertMenuLink($item1['mlid'], array('depth' => 1, 'has_children' => 0, 'p1' => $item1['mlid'], 'p2' => 0));
+ $this->assertMenuLink($item4['mlid'], array('depth' => 1, 'has_children' => 1, 'p1' => $item4['mlid'], 'p2' => 0));
+ $this->assertMenuLink($item5['mlid'], array('depth' => 2, 'has_children' => 1, 'p1' => $item4['mlid'], 'p2' => $item5['mlid'], 'p3' => 0));
+ $this->assertMenuLink($item2['mlid'], array('depth' => 3, 'has_children' => 1, 'p1' => $item4['mlid'], 'p2' => $item5['mlid'], 'p3' => $item2['mlid'], 'p4' => 0));
+ $this->assertMenuLink($item3['mlid'], array('depth' => 4, 'has_children' => 0, 'p1' => $item4['mlid'], 'p2' => $item5['mlid'], 'p3' => $item2['mlid'], 'p4' => $item3['mlid'], 'p5' => 0));
+
+ // Enable a link via the overview form.
+ $this->disableMenuLink($item1);
+ $edit = array();
+
+ // Note in the UI the 'mlid:x[hidden]' form element maps to enabled, or
+ // NOT hidden.
+ $edit['mlid:' . $item1['mlid'] . '[hidden]'] = TRUE;
+ $this->drupalPost('admin/structure/menu/manage/' . $item1['menu_name'], $edit, t('Save configuration'));
+
+ // Verify in the database.
+ $this->assertMenuLink($item1['mlid'], array('hidden' => 0));
+
+ // Save menu links for later tests.
+ $this->items[] = $item1;
+ $this->items[] = $item2;
+ }
+
+ /**
+ * Add and remove a menu link with a query string and fragment.
+ */
+ function testMenuQueryAndFragment() {
+ $this->drupalLogin($this->big_user);
+
+ // Make a path with query and fragment on.
+ $path = 'node?arg1=value1&arg2=value2';
+ $item = $this->addMenuLink(0, $path);
+
+ $this->drupalGet('admin/structure/menu/item/' . $item['mlid'] . '/edit');
+ $this->assertFieldByName('link_path', $path, t('Path is found with both query and fragment.'));
+
+ // Now change the path to something without query and fragment.
+ $path = 'node';
+ $this->drupalPost('admin/structure/menu/item/' . $item['mlid'] . '/edit', array('link_path' => $path), t('Save'));
+ $this->drupalGet('admin/structure/menu/item/' . $item['mlid'] . '/edit');
+ $this->assertFieldByName('link_path', $path, t('Path no longer has query or fragment.'));
+ }
+
+ /**
+ * Add a menu link using the menu module UI.
+ *
+ * @param integer $plid Parent menu link id.
+ * @param string $link Link path.
+ * @param string $menu_name Menu name.
+ * @return array Menu link created.
+ */
+ function addMenuLink($plid = 0, $link = '<front>', $menu_name = 'navigation') {
+ // View add menu link page.
+ $this->drupalGet("admin/structure/menu/manage/$menu_name/add");
+ $this->assertResponse(200);
+
+ $title = '!link_' . $this->randomName(16);
+ $edit = array(
+ 'link_path' => $link,
+ 'link_title' => $title,
+ 'description' => '',
+ 'enabled' => TRUE, // Use this to disable the menu and test.
+ 'expanded' => TRUE, // Setting this to true should test whether it works when we do the std_user tests.
+ 'parent' => $menu_name . ':' . $plid,
+ 'weight' => '0',
+ );
+
+ // Add menu link.
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertResponse(200);
+ // Unlike most other modules, there is no confirmation message displayed.
+ $this->assertText($title, 'Menu link was added');
+
+ $item = db_query('SELECT * FROM {menu_links} WHERE link_title = :title', array(':title' => $title))->fetchAssoc();
+ $this->assertTrue(t('Menu link was found in database.'));
+ $this->assertMenuLink($item['mlid'], array('menu_name' => $menu_name, 'link_path' => $link, 'has_children' => 0, 'plid' => $plid));
+
+ return $item;
+ }
+
+ /**
+ * Attempt to add menu link with invalid path or no access permission.
+ *
+ * @param string $menu_name Menu name.
+ */
+ function addInvalidMenuLink($menu_name = 'navigation') {
+ foreach (array('-&-', 'admin/people/permissions', '#') as $link_path) {
+ $edit = array(
+ 'link_path' => $link_path,
+ 'link_title' => 'title',
+ );
+ $this->drupalPost("admin/structure/menu/manage/$menu_name/add", $edit, t('Save'));
+ $this->assertRaw(t("The path '@path' is either invalid or you do not have access to it.", array('@path' => $link_path)), 'Menu link was not created');
+ }
+ }
+
+ /**
+ * Verify a menu link using the menu module UI.
+ *
+ * @param array $item Menu link.
+ * @param object $item_node Menu link content node.
+ * @param array $parent Parent menu link.
+ * @param object $parent_node Parent menu link content node.
+ */
+ function verifyMenuLink($item, $item_node, $parent = NULL, $parent_node = NULL) {
+ // View home page.
+ $this->drupalGet('');
+ $this->assertResponse(200);
+
+ // Verify parent menu link.
+ if (isset($parent)) {
+ // Verify menu link.
+ $title = $parent['link_title'];
+ $this->assertText($title, 'Parent menu link was displayed');
+
+ // Verify menu link link.
+ $this->clickLink($title);
+ $title = $parent_node->title;
+ $this->assertTitle(t("@title | Drupal", array('@title' => $title)), t('Parent menu link link target was correct'));
+ }
+
+ // Verify menu link.
+ $title = $item['link_title'];
+ $this->assertText($title, 'Menu link was displayed');
+
+ // Verify menu link link.
+ $this->clickLink($title);
+ $title = $item_node->title;
+ $this->assertTitle(t("@title | Drupal", array('@title' => $title)), t('Menu link link target was correct'));
+ }
+
+ /**
+ * Change the parent of a menu link using the menu module UI.
+ */
+ function moveMenuLink($item, $plid, $menu_name) {
+ $mlid = $item['mlid'];
+
+ $edit = array(
+ 'parent' => $menu_name . ':' . $plid,
+ );
+ $this->drupalPost("admin/structure/menu/item/$mlid/edit", $edit, t('Save'));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Modify a menu link using the menu module UI.
+ *
+ * @param array $item Menu link passed by reference.
+ */
+ function modifyMenuLink(&$item) {
+ $item['link_title'] = $this->randomName(16);
+
+ $mlid = $item['mlid'];
+ $title = $item['link_title'];
+
+ // Edit menu link.
+ $edit = array();
+ $edit['link_title'] = $title;
+ $this->drupalPost("admin/structure/menu/item/$mlid/edit", $edit, t('Save'));
+ $this->assertResponse(200);
+ // Unlike most other modules, there is no confirmation message displayed.
+
+ // Verify menu link.
+ $this->drupalGet('admin/structure/menu/manage/' . $item['menu_name']);
+ $this->assertText($title, 'Menu link was edited');
+ }
+
+ /**
+ * Reset a standard menu link using the menu module UI.
+ *
+ * @param array $item Menu link.
+ * @param string $old_title Original title for menu link.
+ */
+ function resetMenuLink($item, $old_title) {
+ $mlid = $item['mlid'];
+ $title = $item['link_title'];
+
+ // Reset menu link.
+ $this->drupalPost("admin/structure/menu/item/$mlid/reset", array(), t('Reset'));
+ $this->assertResponse(200);
+ $this->assertRaw(t('The menu link was reset to its default settings.'), t('Menu link was reset'));
+
+ // Verify menu link.
+ $this->drupalGet('');
+ $this->assertNoText($title, 'Menu link was reset');
+ $this->assertText($old_title, 'Menu link was reset');
+ }
+
+ /**
+ * Delete a menu link using the menu module UI.
+ *
+ * @param array $item Menu link.
+ */
+ function deleteMenuLink($item) {
+ $mlid = $item['mlid'];
+ $title = $item['link_title'];
+
+ // Delete menu link.
+ $this->drupalPost("admin/structure/menu/item/$mlid/delete", array(), t('Confirm'));
+ $this->assertResponse(200);
+ $this->assertRaw(t('The menu link %title has been deleted.', array('%title' => $title)), t('Menu link was deleted'));
+
+ // Verify deletion.
+ $this->drupalGet('');
+ $this->assertNoText($title, 'Menu link was deleted');
+ }
+
+ /**
+ * Alternately disable and enable a menu link.
+ *
+ * @param $item
+ * Menu link.
+ */
+ function toggleMenuLink($item) {
+ $this->disableMenuLink($item);
+
+ // Verify menu link is absent.
+ $this->drupalGet('');
+ $this->assertNoText($item['link_title'], 'Menu link was not displayed');
+ $this->enableMenuLink($item);
+
+ // Verify menu link is displayed.
+ $this->drupalGet('');
+ $this->assertText($item['link_title'], 'Menu link was displayed');
+ }
+
+ /**
+ * Disable a menu link.
+ *
+ * @param $item
+ * Menu link.
+ */
+ function disableMenuLink($item) {
+ $mlid = $item['mlid'];
+ $edit['enabled'] = FALSE;
+ $this->drupalPost("admin/structure/menu/item/$mlid/edit", $edit, t('Save'));
+
+ // Unlike most other modules, there is no confirmation message displayed.
+ // Verify in the database.
+ $this->assertMenuLink($mlid, array('hidden' => 1));
+ }
+
+ /**
+ * Enable a menu link.
+ *
+ * @param $item
+ * Menu link.
+ */
+ function enableMenuLink($item) {
+ $mlid = $item['mlid'];
+ $edit['enabled'] = TRUE;
+ $this->drupalPost("admin/structure/menu/item/$mlid/edit", $edit, t('Save'));
+
+ // Verify in the database.
+ $this->assertMenuLink($mlid, array('hidden' => 0));
+ }
+
+ /**
+ * Fetch the menu item from the database and compare it to the specified
+ * array.
+ *
+ * @param $mlid
+ * Menu item id.
+ * @param $item
+ * Array containing properties to verify.
+ */
+ function assertMenuLink($mlid, array $expected_item) {
+ // Retrieve menu link.
+ $item = db_query('SELECT * FROM {menu_links} WHERE mlid = :mlid', array(':mlid' => $mlid))->fetchAssoc();
+ $options = unserialize($item['options']);
+ if (!empty($options['query'])) {
+ $item['link_path'] .= '?' . drupal_http_build_query($options['query']);
+ }
+ if (!empty($options['fragment'])) {
+ $item['link_path'] .= '#' . $options['fragment'];
+ }
+ foreach ($expected_item as $key => $value) {
+ $this->assertEqual($item[$key], $value, t('Parameter %key had expected value.', array('%key' => $key)));
+ }
+ }
+
+ /**
+ * Get standard menu link.
+ */
+ private function getStandardMenuLink() {
+ // Retrieve menu link id of the Log out menu link, which will always be on the front page.
+ $mlid = db_query("SELECT mlid FROM {menu_links} WHERE module = 'system' AND router_path = 'user/logout'")->fetchField();
+ $this->assertTrue($mlid > 0, 'Standard menu link id was found');
+ // Load menu link.
+ // Use api function so that link is translated for rendering.
+ $item = menu_link_load($mlid);
+ $this->assertTrue((bool) $item, 'Standard menu link was loaded');
+ return $item;
+ }
+
+ /**
+ * Verify the logged in user has the desired access to the various menu nodes.
+ *
+ * @param integer $response HTTP response code.
+ */
+ private function verifyAccess($response = 200) {
+ // View menu help node.
+ $this->drupalGet('admin/help/menu');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Menu'), t('Menu help was displayed'));
+ }
+
+ // View menu build overview node.
+ $this->drupalGet('admin/structure/menu');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Menus'), t('Menu build overview node was displayed'));
+ }
+
+ // View navigation menu customization node.
+ $this->drupalGet('admin/structure/menu/manage/navigation');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Navigation'), t('Navigation menu node was displayed'));
+ }
+
+ // View menu edit node.
+ $item = $this->getStandardMenuLink();
+ $this->drupalGet('admin/structure/menu/item/' . $item['mlid'] . '/edit');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Edit menu item'), t('Menu edit node was displayed'));
+ }
+
+ // View menu settings node.
+ $this->drupalGet('admin/structure/menu/settings');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Menus'), t('Menu settings node was displayed'));
+ }
+
+ // View add menu node.
+ $this->drupalGet('admin/structure/menu/add');
+ $this->assertResponse($response);
+ if ($response == 200) {
+ $this->assertText(t('Menus'), t('Add menu node was displayed'));
+ }
+ }
+}
+
+/**
+ * Test menu settings for nodes.
+ */
+class MenuNodeTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu settings for nodes',
+ 'description' => 'Add, edit, and delete a node with menu link.',
+ 'group' => 'Menu',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('menu');
+
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'administer content types',
+ 'administer menu',
+ 'create page content',
+ 'edit any page content',
+ 'delete any page content',
+ ));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Test creating, editing, deleting menu links via node form widget.
+ */
+ function testMenuNodeFormWidget() {
+ // Enable Navigation menu as available menu.
+ $edit = array(
+ 'menu_options[navigation]' => 1,
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ // Change default parent item to Navigation menu, so we can assert more
+ // easily.
+ $edit = array(
+ 'menu_parent' => 'navigation:0',
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+
+ // Create a node.
+ $node_title = $this->randomName();
+ $language = LANGUAGE_NONE;
+ $edit = array(
+ "title" => $node_title,
+ "body[$language][0][value]" => $this->randomString(),
+ );
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($node_title);
+ // Assert that there is no link for the node.
+ $this->drupalGet('');
+ $this->assertNoLink($node_title);
+
+ // Edit the node, enable the menu link setting, but skip the link title.
+ $edit = array(
+ 'menu[enabled]' => 1,
+ );
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ // Assert that there is no link for the node.
+ $this->drupalGet('');
+ $this->assertNoLink($node_title);
+
+ // Edit the node and create a menu link.
+ $edit = array(
+ 'menu[enabled]' => 1,
+ 'menu[link_title]' => $node_title,
+ 'menu[weight]' => 17,
+ );
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ // Assert that the link exists.
+ $this->drupalGet('');
+ $this->assertLink($node_title);
+
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertOptionSelected('edit-menu-weight', 17, t('Menu weight correct in edit form'));
+
+ // Edit the node and remove the menu link.
+ $edit = array(
+ 'menu[enabled]' => FALSE,
+ );
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ // Assert that there is no link for the node.
+ $this->drupalGet('');
+ $this->assertNoLink($node_title);
+
+ // Add a menu link to the Management menu.
+ $item = array(
+ 'link_path' => 'node/' . $node->nid,
+ 'link_title' => $this->randomName(16),
+ 'menu_name' => 'management',
+ );
+ menu_link_save($item);
+
+ // Assert that disabled Management menu is not shown on the node/$nid/edit page.
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertText('Provide a menu link', t('Link in not allowed menu not shown in node edit form'));
+ // Assert that the link is still in the management menu after save.
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $link = menu_link_load($item['mlid']);
+ $this->assertTrue($link, t('Link in not allowed menu still exists after saving node'));
+
+ // Move the menu link back to the Navigation menu.
+ $item['menu_name'] = 'navigation';
+ menu_link_save($item);
+ // Create a second node.
+ $child_node = $this->drupalCreateNode(array('type' => 'article'));
+ // Assign a menu link to the second node, being a child of the first one.
+ $child_item = array(
+ 'link_path' => 'node/'. $child_node->nid,
+ 'link_title' => $this->randomName(16),
+ 'plid' => $item['mlid'],
+ );
+ menu_link_save($child_item);
+ // Edit the first node.
+ $this->drupalGet('node/'. $node->nid .'/edit');
+ // Assert that it is not possible to set the parent of the first node to itself or the second node.
+ $this->assertNoOption('edit-menu-parent', 'navigation:'. $item['mlid']);
+ $this->assertNoOption('edit-menu-parent', 'navigation:'. $child_item['mlid']);
+ }
+
+ /**
+ * Asserts that a select option in the current page does not exist.
+ *
+ * @param $id
+ * Id of select field to assert.
+ * @param $option
+ * Option to assert.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ *
+ * @todo move to simpletest drupal_web_test_case.php.
+ */
+ protected function assertNoOption($id, $option, $message = '') {
+ $selects = $this->xpath('//select[@id=:id]', array(':id' => $id));
+ $options = $this->xpath('//select[@id=:id]//option[@value=:option]', array(':id' => $id, ':option' => $option));
+ return $this->assertTrue(isset($selects[0]) && !isset($options[0]), $message ? $message : t('Option @option for field @id does not exist.', array('@option' => $option, '@id' => $id)), t('Browser'));
+ }
+}
diff --git a/core/modules/node/content_types.inc b/core/modules/node/content_types.inc
new file mode 100644
index 000000000000..d58bc318f378
--- /dev/null
+++ b/core/modules/node/content_types.inc
@@ -0,0 +1,447 @@
+<?php
+
+/**
+ * @file
+ * Content type editing UI.
+ */
+
+/**
+ * Displays the content type admin overview page.
+ */
+function node_overview_types() {
+ $types = node_type_get_types();
+ $names = node_type_get_names();
+ $field_ui = module_exists('field_ui');
+ $header = array(t('Name'), array('data' => t('Operations'), 'colspan' => $field_ui ? '4' : '2'));
+ $rows = array();
+
+ foreach ($names as $key => $name) {
+ $type = $types[$key];
+ if (node_hook($type->type, 'form')) {
+ $type_url_str = str_replace('_', '-', $type->type);
+ $row = array(theme('node_admin_overview', array('name' => $name, 'type' => $type)));
+ // Set the edit column.
+ $row[] = array('data' => l(t('edit'), 'admin/structure/types/manage/' . $type_url_str));
+
+ if ($field_ui) {
+ // Manage fields.
+ $row[] = array('data' => l(t('manage fields'), 'admin/structure/types/manage/' . $type_url_str . '/fields'));
+
+ // Display fields.
+ $row[] = array('data' => l(t('manage display'), 'admin/structure/types/manage/' . $type_url_str . '/display'));
+ }
+
+ // Set the delete column.
+ if ($type->custom) {
+ $row[] = array('data' => l(t('delete'), 'admin/structure/types/manage/' . $type_url_str . '/delete'));
+ }
+ else {
+ $row[] = array('data' => '');
+ }
+
+ $rows[] = $row;
+ }
+ }
+
+ $build['node_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No content types available. <a href="@link">Add content type</a>.', array('@link' => url('admin/structure/types/add'))),
+ );
+
+ return $build;
+}
+
+/**
+ * Returns HTML for a node type description for the content type admin overview page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - name: The human-readable name of the content type.
+ * - type: An object containing the 'type' (machine name) and 'description' of
+ * the content type.
+ *
+ * @ingroup themeable
+ */
+function theme_node_admin_overview($variables) {
+ $name = $variables['name'];
+ $type = $variables['type'];
+
+ $output = check_plain($name);
+ $output .= ' <small>' . t('(Machine name: @type)', array('@type' => $type->type)) . '</small>';
+ $output .= '<div class="description">' . filter_xss_admin($type->description) . '</div>';
+ return $output;
+}
+
+/**
+ * Form constructor for the node type editing form.
+ *
+ * @param $type
+ * (optional) The machine name of the node type when editing an existing node
+ * type.
+ *
+ * @see node_type_form_validate()
+ * @see node_type_form_submit()
+ * @ingroup forms
+ */
+function node_type_form($form, &$form_state, $type = NULL) {
+ if (!isset($type->type)) {
+ // This is a new type. Node module managed types are custom and unlocked.
+ $type = node_type_set_defaults(array('custom' => 1, 'locked' => 0));
+ }
+
+ // Make the type object available to implementations of hook_form_alter.
+ $form['#node_type'] = $type;
+
+ $form['name'] = array(
+ '#title' => t('Name'),
+ '#type' => 'textfield',
+ '#default_value' => $type->name,
+ '#description' => t('The human-readable name of this content type. This text will be displayed as part of the list on the <em>Add new content</em> page. It is recommended that this name begin with a capital letter and contain only letters, numbers, and spaces. This name must be unique.'),
+ '#required' => TRUE,
+ '#size' => 30,
+ );
+
+ $form['type'] = array(
+ '#type' => 'machine_name',
+ '#default_value' => $type->type,
+ '#maxlength' => 32,
+ '#disabled' => $type->locked,
+ '#machine_name' => array(
+ 'exists' => 'node_type_load',
+ ),
+ '#description' => t('A unique machine-readable name for this content type. It must only contain lowercase letters, numbers, and underscores. This name will be used for constructing the URL of the %node-add page, in which underscores will be converted into hyphens.', array(
+ '%node-add' => t('Add new content'),
+ )),
+ );
+
+ $form['description'] = array(
+ '#title' => t('Description'),
+ '#type' => 'textarea',
+ '#default_value' => $type->description,
+ '#description' => t('Describe this content type. The text will be displayed on the <em>Add new content</em> page.'),
+ );
+
+ $form['additional_settings'] = array(
+ '#type' => 'vertical_tabs',
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'node') . '/content_types.js'),
+ ),
+ );
+
+ $form['submission'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Submission form settings'),
+ '#collapsible' => TRUE,
+ '#group' => 'additional_settings',
+ );
+ $form['submission']['title_label'] = array(
+ '#title' => t('Title field label'),
+ '#type' => 'textfield',
+ '#default_value' => $type->title_label,
+ '#required' => TRUE,
+ );
+ if (!$type->has_title) {
+ // Avoid overwriting a content type that intentionally does not have a
+ // title field.
+ $form['submission']['title_label']['#attributes'] = array('disabled' => 'disabled');
+ $form['submission']['title_label']['#description'] = t('This content type does not have a title field.');
+ $form['submission']['title_label']['#required'] = FALSE;
+ }
+ $form['submission']['node_preview'] = array(
+ '#type' => 'radios',
+ '#title' => t('Preview before submitting'),
+ '#default_value' => variable_get('node_preview_' . $type->type, DRUPAL_OPTIONAL),
+ '#options' => array(
+ DRUPAL_DISABLED => t('Disabled'),
+ DRUPAL_OPTIONAL => t('Optional'),
+ DRUPAL_REQUIRED => t('Required'),
+ ),
+ );
+ $form['submission']['help'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Explanation or submission guidelines'),
+ '#default_value' => $type->help,
+ '#description' => t('This text will be displayed at the top of the page when creating or editing content of this type.'),
+ );
+ $form['workflow'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Publishing options'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ );
+ $form['workflow']['node_options'] = array('#type' => 'checkboxes',
+ '#title' => t('Default options'),
+ '#default_value' => variable_get('node_options_' . $type->type, array('status', 'promote')),
+ '#options' => array(
+ 'status' => t('Published'),
+ 'promote' => t('Promoted to front page'),
+ 'sticky' => t('Sticky at top of lists'),
+ 'revision' => t('Create new revision'),
+ ),
+ '#description' => t('Users with the <em>Administer content</em> permission will be able to override these options.'),
+ );
+ $form['display'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Display settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ );
+ $form['display']['node_submitted'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display author and date information.'),
+ '#default_value' => variable_get('node_submitted_' . $type->type, TRUE),
+ '#description' => t('Author username and publish date will be displayed.'),
+ );
+ $form['old_type'] = array(
+ '#type' => 'value',
+ '#value' => $type->type,
+ );
+ $form['orig_type'] = array(
+ '#type' => 'value',
+ '#value' => isset($type->orig_type) ? $type->orig_type : '',
+ );
+ $form['base'] = array(
+ '#type' => 'value',
+ '#value' => $type->base,
+ );
+ $form['custom'] = array(
+ '#type' => 'value',
+ '#value' => $type->custom,
+ );
+ $form['modified'] = array(
+ '#type' => 'value',
+ '#value' => $type->modified,
+ );
+ $form['locked'] = array(
+ '#type' => 'value',
+ '#value' => $type->locked,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save content type'),
+ '#weight' => 40,
+ );
+
+ if ($type->custom) {
+ if (!empty($type->type)) {
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete content type'),
+ '#weight' => 45,
+ );
+ }
+ }
+
+ return $form;
+}
+
+/**
+ * Helper function for teaser length choices.
+ */
+function _node_characters($length) {
+ return ($length == 0) ? t('Unlimited') : format_plural($length, '1 character', '@count characters');
+}
+
+/**
+ * Form validation handler for node_type_form().
+ *
+ * @see node_type_form_submit()
+ */
+function node_type_form_validate($form, &$form_state) {
+ $type = new stdClass();
+ $type->type = trim($form_state['values']['type']);
+ $type->name = trim($form_state['values']['name']);
+
+ // Work out what the type was before the user submitted this form
+ $old_type = trim($form_state['values']['old_type']);
+
+ $types = node_type_get_names();
+
+ if (!$form_state['values']['locked']) {
+ // 'theme' conflicts with theme_node_form().
+ // '0' is invalid, since elsewhere we check it using empty().
+ if (in_array($type->type, array('0', 'theme'))) {
+ form_set_error('type', t("Invalid machine-readable name. Enter a name other than %invalid.", array('%invalid' => $type->type)));
+ }
+ }
+
+ $names = array_flip($types);
+
+ if (isset($names[$type->name]) && $names[$type->name] != $old_type) {
+ form_set_error('name', t('The human-readable name %name is already taken.', array('%name' => $type->name)));
+ }
+}
+
+/**
+ * Form submission handler for node_type_form().
+ *
+ * @see node_type_form_validate()
+ */
+function node_type_form_submit($form, &$form_state) {
+ $op = isset($form_state['values']['op']) ? $form_state['values']['op'] : '';
+
+ $type = node_type_set_defaults();
+
+ $type->type = trim($form_state['values']['type']);
+ $type->name = trim($form_state['values']['name']);
+ $type->orig_type = trim($form_state['values']['orig_type']);
+ $type->old_type = isset($form_state['values']['old_type']) ? $form_state['values']['old_type'] : $type->type;
+
+ $type->description = $form_state['values']['description'];
+ $type->help = $form_state['values']['help'];
+ $type->title_label = $form_state['values']['title_label'];
+ // title_label is required in core; has_title will always be true, unless a
+ // module alters the title field.
+ $type->has_title = ($type->title_label != '');
+
+ $type->base = !empty($form_state['values']['base']) ? $form_state['values']['base'] : 'node_content';
+ $type->custom = $form_state['values']['custom'];
+ $type->modified = TRUE;
+ $type->locked = $form_state['values']['locked'];
+ if (isset($form['#node_type']->module)) {
+ $type->module = $form['#node_type']->module;
+ }
+
+ if ($op == t('Delete content type')) {
+ $form_state['redirect'] = 'admin/structure/types/manage/' . str_replace('_', '-', $type->old_type) . '/delete';
+ return;
+ }
+
+ $variables = $form_state['values'];
+
+ // Remove everything that's been saved already - whatever's left is assumed
+ // to be a persistent variable.
+ foreach ($variables as $key => $value) {
+ if (isset($type->$key)) {
+ unset($variables[$key]);
+ }
+ }
+
+ unset($variables['form_token'], $variables['op'], $variables['submit'], $variables['delete'], $variables['reset'], $variables['form_id'], $variables['form_build_id']);
+
+ // Save or reset persistent variable values.
+ foreach ($variables as $key => $value) {
+ $variable_new = $key . '_' . $type->type;
+ $variable_old = $key . '_' . $type->old_type;
+
+ if (is_array($value)) {
+ $value = array_keys(array_filter($value));
+ }
+ variable_set($variable_new, $value);
+
+ if ($variable_new != $variable_old) {
+ variable_del($variable_old);
+ }
+ }
+
+ // Saving the content type after saving the variables allows modules to act
+ // on those variables via hook_node_type_insert().
+ $status = node_type_save($type);
+
+ node_types_rebuild();
+ menu_rebuild();
+ $t_args = array('%name' => $type->name);
+
+ if ($status == SAVED_UPDATED) {
+ drupal_set_message(t('The content type %name has been updated.', $t_args));
+ }
+ elseif ($status == SAVED_NEW) {
+ node_add_body_field($type);
+ drupal_set_message(t('The content type %name has been added.', $t_args));
+ watchdog('node', 'Added content type %name.', $t_args, WATCHDOG_NOTICE, l(t('view'), 'admin/structure/types'));
+ }
+
+ $form_state['redirect'] = 'admin/structure/types';
+ return;
+}
+
+/**
+ * Implements hook_node_type_insert().
+ */
+function node_node_type_insert($info) {
+ if (!empty($info->old_type) && $info->old_type != $info->type) {
+ $update_count = node_type_update_nodes($info->old_type, $info->type);
+
+ if ($update_count) {
+ drupal_set_message(format_plural($update_count, 'Changed the content type of 1 post from %old-type to %type.', 'Changed the content type of @count posts from %old-type to %type.', array('%old-type' => $info->old_type, '%type' => $info->type)));
+ }
+ }
+}
+
+/**
+ * Implements hook_node_type_update().
+ */
+function node_node_type_update($info) {
+ if (!empty($info->old_type) && $info->old_type != $info->type) {
+ $update_count = node_type_update_nodes($info->old_type, $info->type);
+
+ if ($update_count) {
+ drupal_set_message(format_plural($update_count, 'Changed the content type of 1 post from %old-type to %type.', 'Changed the content type of @count posts from %old-type to %type.', array('%old-type' => $info->old_type, '%type' => $info->type)));
+ }
+ }
+}
+
+/**
+ * Resets all of the relevant fields of a module-defined node type to their
+ * default values.
+ *
+ * @param $type
+ * The node type to reset. The node type is passed back by reference with its
+ * resetted values. If there is no module-defined info for this node type,
+ * then nothing happens.
+ */
+function node_type_reset($type) {
+ $info_array = module_invoke_all('node_info');
+ if (isset($info_array[$type->orig_type])) {
+ $info_array[$type->orig_type]['type'] = $type->orig_type;
+ $info = node_type_set_defaults($info_array[$type->orig_type]);
+
+ foreach ($info as $field => $value) {
+ $type->$field = $value;
+ }
+ }
+}
+
+/**
+ * Menu callback; delete a single content type.
+ */
+function node_type_delete_confirm($form, &$form_state, $type) {
+ $form['type'] = array('#type' => 'value', '#value' => $type->type);
+ $form['name'] = array('#type' => 'value', '#value' => $type->name);
+
+ $message = t('Are you sure you want to delete the content type %type?', array('%type' => $type->name));
+ $caption = '';
+
+ $num_nodes = db_query("SELECT COUNT(*) FROM {node} WHERE type = :type", array(':type' => $type->type))->fetchField();
+ if ($num_nodes) {
+ $caption .= '<p>' . format_plural($num_nodes, '%type is used by 1 piece of content on your site. If you remove this content type, you will not be able to edit the %type content and it may not display correctly.', '%type is used by @count pieces of content on your site. If you remove %type, you will not be able to edit the %type content and it may not display correctly.', array('%type' => $type->name)) . '</p>';
+ }
+
+ $caption .= '<p>' . t('This action cannot be undone.') . '</p>';
+
+ return confirm_form($form, $message, 'admin/structure/types', $caption, t('Delete'));
+}
+
+/**
+ * Process content type delete confirm submissions.
+ */
+function node_type_delete_confirm_submit($form, &$form_state) {
+ node_type_delete($form_state['values']['type']);
+
+ variable_del('node_preview_' . $form_state['values']['type']);
+ $t_args = array('%name' => $form_state['values']['name']);
+ drupal_set_message(t('The content type %name has been deleted.', $t_args));
+ watchdog('menu', 'Deleted content type %name.', $t_args, WATCHDOG_NOTICE);
+
+ node_types_rebuild();
+ menu_rebuild();
+
+ $form_state['redirect'] = 'admin/structure/types';
+ return;
+}
diff --git a/core/modules/node/content_types.js b/core/modules/node/content_types.js
new file mode 100644
index 000000000000..0031c323fac3
--- /dev/null
+++ b/core/modules/node/content_types.js
@@ -0,0 +1,34 @@
+(function ($) {
+
+Drupal.behaviors.contentTypes = {
+ attach: function (context) {
+ // Provide the vertical tab summaries.
+ $('fieldset#edit-submission', context).drupalSetSummary(function(context) {
+ var vals = [];
+ vals.push(Drupal.checkPlain($('#edit-title-label', context).val()) || Drupal.t('Requires a title'));
+ return vals.join(', ');
+ });
+ $('fieldset#edit-workflow', context).drupalSetSummary(function(context) {
+ var vals = [];
+ $("input[name^='node_options']:checked", context).parent().each(function() {
+ vals.push(Drupal.checkPlain($(this).text()));
+ });
+ if (!$('#edit-node-options-status', context).is(':checked')) {
+ vals.unshift(Drupal.t('Not published'));
+ }
+ return vals.join(', ');
+ });
+ $('fieldset#edit-display', context).drupalSetSummary(function(context) {
+ var vals = [];
+ $('input:checked', context).next('label').each(function() {
+ vals.push(Drupal.checkPlain($(this).text()));
+ });
+ if (!$('#edit-node-submitted', context).is(':checked')) {
+ vals.unshift(Drupal.t("Don't display post information"));
+ }
+ return vals.join(', ');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/node/node.admin.inc b/core/modules/node/node.admin.inc
new file mode 100644
index 000000000000..8f46df459ed0
--- /dev/null
+++ b/core/modules/node/node.admin.inc
@@ -0,0 +1,596 @@
+<?php
+
+/**
+ * @file
+ * Content administration and module settings UI.
+ */
+
+/**
+ * Menu callback: confirm rebuilding of permissions.
+ */
+function node_configure_rebuild_confirm() {
+ return confirm_form(array(), t('Are you sure you want to rebuild the permissions on site content?'),
+ 'admin/reports/status', t('This action rebuilds all permissions on site content, and may be a lengthy process. This action cannot be undone.'), t('Rebuild permissions'), t('Cancel'));
+}
+
+/**
+ * Handler for wipe confirmation
+ */
+function node_configure_rebuild_confirm_submit($form, &$form_state) {
+ node_access_rebuild(TRUE);
+ $form_state['redirect'] = 'admin/reports/status';
+}
+
+/**
+ * Implements hook_node_operations().
+ */
+function node_node_operations() {
+ $operations = array(
+ 'publish' => array(
+ 'label' => t('Publish selected content'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED)),
+ ),
+ 'unpublish' => array(
+ 'label' => t('Unpublish selected content'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_NOT_PUBLISHED)),
+ ),
+ 'promote' => array(
+ 'label' => t('Promote selected content to front page'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED, 'promote' => NODE_PROMOTED)),
+ ),
+ 'demote' => array(
+ 'label' => t('Demote selected content from front page'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('promote' => NODE_NOT_PROMOTED)),
+ ),
+ 'sticky' => array(
+ 'label' => t('Make selected content sticky'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED, 'sticky' => NODE_STICKY)),
+ ),
+ 'unsticky' => array(
+ 'label' => t('Make selected content not sticky'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('sticky' => NODE_NOT_STICKY)),
+ ),
+ 'delete' => array(
+ 'label' => t('Delete selected content'),
+ 'callback' => NULL,
+ ),
+ );
+ return $operations;
+}
+
+/**
+ * List node administration filters that can be applied.
+ */
+function node_filters() {
+ // Regular filters
+ $filters['status'] = array(
+ 'title' => t('status'),
+ 'options' => array(
+ '[any]' => t('any'),
+ 'status-1' => t('published'),
+ 'status-0' => t('not published'),
+ 'promote-1' => t('promoted'),
+ 'promote-0' => t('not promoted'),
+ 'sticky-1' => t('sticky'),
+ 'sticky-0' => t('not sticky'),
+ ),
+ );
+ // Include translation states if we have this module enabled
+ if (module_exists('translation')) {
+ $filters['status']['options'] += array(
+ 'translate-0' => t('Up to date translation'),
+ 'translate-1' => t('Outdated translation'),
+ );
+ }
+
+ $filters['type'] = array(
+ 'title' => t('type'),
+ 'options' => array(
+ '[any]' => t('any'),
+ ) + node_type_get_names(),
+ );
+
+ // Language filter if there is a list of languages
+ if ($languages = module_invoke('locale', 'language_list')) {
+ $languages = array(LANGUAGE_NONE => t('Language neutral')) + $languages;
+ $filters['language'] = array(
+ 'title' => t('language'),
+ 'options' => array(
+ '[any]' => t('any'),
+ ) + $languages,
+ );
+ }
+ return $filters;
+}
+
+/**
+ * Apply filters for node administration filters based on session.
+ *
+ * @param $query
+ * A SelectQuery to which the filters should be applied.
+ */
+function node_build_filter_query(SelectQueryInterface $query) {
+ // Build query
+ $filter_data = isset($_SESSION['node_overview_filter']) ? $_SESSION['node_overview_filter'] : array();
+ foreach ($filter_data as $index => $filter) {
+ list($key, $value) = $filter;
+ switch ($key) {
+ case 'status':
+ // Note: no exploitable hole as $key/$value have already been checked when submitted
+ list($key, $value) = explode('-', $value, 2);
+ case 'type':
+ case 'language':
+ $query->condition('n.' . $key, $value);
+ break;
+ }
+ }
+}
+
+/**
+ * Return form for node administration filters.
+ */
+function node_filter_form() {
+ $session = isset($_SESSION['node_overview_filter']) ? $_SESSION['node_overview_filter'] : array();
+ $filters = node_filters();
+
+ $i = 0;
+ $form['filters'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Show only items where'),
+ '#theme' => 'exposed_filters__node',
+ );
+ foreach ($session as $filter) {
+ list($type, $value) = $filter;
+ if ($type == 'term') {
+ // Load term name from DB rather than search and parse options array.
+ $value = module_invoke('taxonomy', 'term_load', $value);
+ $value = $value->name;
+ }
+ elseif ($type == 'language') {
+ $value = $value == LANGUAGE_NONE ? t('Language neutral') : module_invoke('locale', 'language_name', $value);
+ }
+ else {
+ $value = $filters[$type]['options'][$value];
+ }
+ $t_args = array('%property' => $filters[$type]['title'], '%value' => $value);
+ if ($i++) {
+ $form['filters']['current'][] = array('#markup' => t('and where %property is %value', $t_args));
+ }
+ else {
+ $form['filters']['current'][] = array('#markup' => t('where %property is %value', $t_args));
+ }
+ if (in_array($type, array('type', 'language'))) {
+ // Remove the option if it is already being filtered on.
+ unset($filters[$type]);
+ }
+ }
+
+ $form['filters']['status'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('clearfix')),
+ '#prefix' => ($i ? '<div class="additional-filters">' . t('and where') . '</div>' : ''),
+ );
+ $form['filters']['status']['filters'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('filters')),
+ );
+ foreach ($filters as $key => $filter) {
+ $form['filters']['status']['filters'][$key] = array(
+ '#type' => 'select',
+ '#options' => $filter['options'],
+ '#title' => $filter['title'],
+ '#default_value' => '[any]',
+ );
+ }
+
+ $form['filters']['status']['actions'] = array(
+ '#type' => 'actions',
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['filters']['status']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => count($session) ? t('Refine') : t('Filter'),
+ );
+ if (count($session)) {
+ $form['filters']['status']['actions']['undo'] = array('#type' => 'submit', '#value' => t('Undo'));
+ $form['filters']['status']['actions']['reset'] = array('#type' => 'submit', '#value' => t('Reset'));
+ }
+
+ drupal_add_js('core/misc/form.js');
+
+ return $form;
+}
+
+/**
+ * Process result from node administration filter form.
+ */
+function node_filter_form_submit($form, &$form_state) {
+ $filters = node_filters();
+ switch ($form_state['values']['op']) {
+ case t('Filter'):
+ case t('Refine'):
+ // Apply every filter that has a choice selected other than 'any'.
+ foreach ($filters as $filter => $options) {
+ if (isset($form_state['values'][$filter]) && $form_state['values'][$filter] != '[any]') {
+ // Flatten the options array to accommodate hierarchical/nested options.
+ $flat_options = form_options_flatten($filters[$filter]['options']);
+ // Only accept valid selections offered on the dropdown, block bad input.
+ if (isset($flat_options[$form_state['values'][$filter]])) {
+ $_SESSION['node_overview_filter'][] = array($filter, $form_state['values'][$filter]);
+ }
+ }
+ }
+ break;
+ case t('Undo'):
+ array_pop($_SESSION['node_overview_filter']);
+ break;
+ case t('Reset'):
+ $_SESSION['node_overview_filter'] = array();
+ break;
+ }
+}
+
+/**
+ * Make mass update of nodes, changing all nodes in the $nodes array
+ * to update them with the field values in $updates.
+ *
+ * IMPORTANT NOTE: This function is intended to work when called
+ * from a form submit handler. Calling it outside of the form submission
+ * process may not work correctly.
+ *
+ * @param array $nodes
+ * Array of node nids to update.
+ * @param array $updates
+ * Array of key/value pairs with node field names and the
+ * value to update that field to.
+ */
+function node_mass_update($nodes, $updates) {
+ // We use batch processing to prevent timeout when updating a large number
+ // of nodes.
+ if (count($nodes) > 10) {
+ $batch = array(
+ 'operations' => array(
+ array('_node_mass_update_batch_process', array($nodes, $updates))
+ ),
+ 'finished' => '_node_mass_update_batch_finished',
+ 'title' => t('Processing'),
+ // We use a single multi-pass operation, so the default
+ // 'Remaining x of y operations' message will be confusing here.
+ 'progress_message' => '',
+ 'error_message' => t('The update has encountered an error.'),
+ // The operations do not live in the .module file, so we need to
+ // tell the batch engine which file to load before calling them.
+ 'file' => drupal_get_path('module', 'node') . '/node.admin.inc',
+ );
+ batch_set($batch);
+ }
+ else {
+ foreach ($nodes as $nid) {
+ _node_mass_update_helper($nid, $updates);
+ }
+ drupal_set_message(t('The update has been performed.'));
+ }
+}
+
+/**
+ * Node Mass Update - helper function.
+ */
+function _node_mass_update_helper($nid, $updates) {
+ $node = node_load($nid, NULL, TRUE);
+ // For efficiency manually save the original node before applying any changes.
+ $node->original = clone $node;
+ foreach ($updates as $name => $value) {
+ $node->$name = $value;
+ }
+ node_save($node);
+ return $node;
+}
+
+/**
+ * Node Mass Update Batch operation
+ */
+function _node_mass_update_batch_process($nodes, $updates, &$context) {
+ if (!isset($context['sandbox']['progress'])) {
+ $context['sandbox']['progress'] = 0;
+ $context['sandbox']['max'] = count($nodes);
+ $context['sandbox']['nodes'] = $nodes;
+ }
+
+ // Process nodes by groups of 5.
+ $count = min(5, count($context['sandbox']['nodes']));
+ for ($i = 1; $i <= $count; $i++) {
+ // For each nid, load the node, reset the values, and save it.
+ $nid = array_shift($context['sandbox']['nodes']);
+ $node = _node_mass_update_helper($nid, $updates);
+
+ // Store result for post-processing in the finished callback.
+ $context['results'][] = l($node->title, 'node/' . $node->nid);
+
+ // Update our progress information.
+ $context['sandbox']['progress']++;
+ }
+
+ // Inform the batch engine that we are not finished,
+ // and provide an estimation of the completion level we reached.
+ if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
+ $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
+ }
+}
+
+/**
+ * Node Mass Update Batch 'finished' callback.
+ */
+function _node_mass_update_batch_finished($success, $results, $operations) {
+ if ($success) {
+ drupal_set_message(t('The update has been performed.'));
+ }
+ else {
+ drupal_set_message(t('An error occurred and processing did not complete.'), 'error');
+ $message = format_plural(count($results), '1 item successfully processed:', '@count items successfully processed:');
+ $message .= theme('item_list', array('items' => $results));
+ drupal_set_message($message);
+ }
+}
+
+/**
+ * Menu callback: content administration.
+ */
+function node_admin_content($form, $form_state) {
+ if (isset($form_state['values']['operation']) && $form_state['values']['operation'] == 'delete') {
+ return node_multiple_delete_confirm($form, $form_state, array_filter($form_state['values']['nodes']));
+ }
+ $form['filter'] = node_filter_form();
+ $form['#submit'][] = 'node_filter_form_submit';
+ $form['admin'] = node_admin_nodes();
+
+ return $form;
+}
+
+/**
+ * Form builder: Builds the node administration overview.
+ */
+function node_admin_nodes() {
+ $admin_access = user_access('administer nodes');
+
+ // Build the 'Update options' form.
+ $form['options'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Update options'),
+ '#attributes' => array('class' => array('container-inline')),
+ '#access' => $admin_access,
+ );
+ $options = array();
+ foreach (module_invoke_all('node_operations') as $operation => $array) {
+ $options[$operation] = $array['label'];
+ }
+ $form['options']['operation'] = array(
+ '#type' => 'select',
+ '#title' => t('Operation'),
+ '#title_display' => 'invisible',
+ '#options' => $options,
+ '#default_value' => 'approve',
+ );
+ $form['options']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Update'),
+ '#validate' => array('node_admin_nodes_validate'),
+ '#submit' => array('node_admin_nodes_submit'),
+ );
+
+ // Enable language column if translation module is enabled or if we have any
+ // node with language.
+ $multilanguage = (module_exists('translation') || db_query_range("SELECT 1 FROM {node} WHERE language <> :language", 0, 1, array(':language' => LANGUAGE_NONE))->fetchField());
+
+ // Build the sortable table header.
+ $header = array(
+ 'title' => array('data' => t('Title'), 'field' => 'n.title'),
+ 'type' => array('data' => t('Content type'), 'field' => 'n.type'),
+ 'author' => t('Author'),
+ 'status' => array('data' => t('Status'), 'field' => 'n.status'),
+ 'changed' => array('data' => t('Updated'), 'field' => 'n.changed', 'sort' => 'desc')
+ );
+ if ($multilanguage) {
+ $header['language'] = array('data' => t('Language'), 'field' => 'n.language');
+ }
+ $header['operations'] = array('data' => t('Operations'));
+
+ $query = db_select('node', 'n')->extend('PagerDefault')->extend('TableSort');
+ node_build_filter_query($query);
+
+ if (!user_access('bypass node access')) {
+ // If the user is able to view their own unpublished nodes, allow them
+ // to see these in addition to published nodes. Check that they actually
+ // have some unpublished nodes to view before adding the condition.
+ if (user_access('view own unpublished content') && $own_unpublished = db_query('SELECT nid FROM {node} WHERE uid = :uid AND status = :status', array(':uid' => $GLOBALS['user']->uid, ':status' => 0))->fetchCol()) {
+ $query->condition(db_or()
+ ->condition('n.status', 1)
+ ->condition('n.nid', $own_unpublished, 'IN')
+ );
+ }
+ else {
+ // If not, restrict the query to published nodes.
+ $query->condition('n.status', 1);
+ }
+ }
+ $nids = $query
+ ->fields('n',array('nid'))
+ ->limit(50)
+ ->orderByHeader($header)
+ ->execute()
+ ->fetchCol();
+ $nodes = node_load_multiple($nids);
+
+ // Prepare the list of nodes.
+ $languages = language_list();
+ $destination = drupal_get_destination();
+ $options = array();
+ foreach ($nodes as $node) {
+ $l_options = $node->language != LANGUAGE_NONE && isset($languages[$node->language]) ? array('language' => $languages[$node->language]) : array();
+ $options[$node->nid] = array(
+ 'title' => array(
+ 'data' => array(
+ '#type' => 'link',
+ '#title' => $node->title,
+ '#href' => 'node/' . $node->nid,
+ '#options' => $l_options,
+ '#suffix' => ' ' . theme('mark', array('type' => node_mark($node->nid, $node->changed))),
+ ),
+ ),
+ 'type' => check_plain(node_type_get_name($node)),
+ 'author' => theme('username', array('account' => $node)),
+ 'status' => $node->status ? t('published') : t('not published'),
+ 'changed' => format_date($node->changed, 'short'),
+ );
+ if ($multilanguage) {
+ if ($node->language == LANGUAGE_NONE || isset($languages[$node->language])) {
+ $options[$node->nid]['language'] = $node->language == LANGUAGE_NONE ? t('Language neutral') : $languages[$node->language]->name;
+ }
+ else {
+ $options[$node->nid]['language'] = t('Undefined language (@langcode)', array('@langcode' => $node->language));
+ }
+ }
+ // Build a list of all the accessible operations for the current node.
+ $operations = array();
+ if (node_access('update', $node)) {
+ $operations['edit'] = array(
+ 'title' => t('edit'),
+ 'href' => 'node/' . $node->nid . '/edit',
+ 'query' => $destination,
+ );
+ }
+ if (node_access('delete', $node)) {
+ $operations['delete'] = array(
+ 'title' => t('delete'),
+ 'href' => 'node/' . $node->nid . '/delete',
+ 'query' => $destination,
+ );
+ }
+ $options[$node->nid]['operations'] = array();
+ if (count($operations) > 1) {
+ // Render an unordered list of operations links.
+ $options[$node->nid]['operations'] = array(
+ 'data' => array(
+ '#theme' => 'links__node_operations',
+ '#links' => $operations,
+ '#attributes' => array('class' => array('links', 'inline')),
+ ),
+ );
+ }
+ elseif (!empty($operations)) {
+ // Render the first and only operation as a link.
+ $link = reset($operations);
+ $options[$node->nid]['operations'] = array(
+ 'data' => array(
+ '#type' => 'link',
+ '#title' => $link['title'],
+ '#href' => $link['href'],
+ '#options' => array('query' => $link['query']),
+ ),
+ );
+ }
+ }
+
+ // Only use a tableselect when the current user is able to perform any
+ // operations.
+ if ($admin_access) {
+ $form['nodes'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#empty' => t('No content available.'),
+ );
+ }
+ // Otherwise, use a simple table.
+ else {
+ $form['nodes'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $options,
+ '#empty' => t('No content available.'),
+ );
+ }
+
+ $form['pager'] = array('#markup' => theme('pager'));
+ return $form;
+}
+
+/**
+ * Validate node_admin_nodes form submissions.
+ *
+ * Check if any nodes have been selected to perform the chosen
+ * 'Update option' on.
+ */
+function node_admin_nodes_validate($form, &$form_state) {
+ // Error if there are no items to select.
+ if (!is_array($form_state['values']['nodes']) || !count(array_filter($form_state['values']['nodes']))) {
+ form_set_error('', t('No items selected.'));
+ }
+}
+
+/**
+ * Process node_admin_nodes form submissions.
+ *
+ * Execute the chosen 'Update option' on the selected nodes.
+ */
+function node_admin_nodes_submit($form, &$form_state) {
+ $operations = module_invoke_all('node_operations');
+ $operation = $operations[$form_state['values']['operation']];
+ // Filter out unchecked nodes
+ $nodes = array_filter($form_state['values']['nodes']);
+ if ($function = $operation['callback']) {
+ // Add in callback arguments if present.
+ if (isset($operation['callback arguments'])) {
+ $args = array_merge(array($nodes), $operation['callback arguments']);
+ }
+ else {
+ $args = array($nodes);
+ }
+ call_user_func_array($function, $args);
+
+ cache_clear_all();
+ }
+ else {
+ // We need to rebuild the form to go to a second step. For example, to
+ // show the confirmation form for the deletion of nodes.
+ $form_state['rebuild'] = TRUE;
+ }
+}
+
+function node_multiple_delete_confirm($form, &$form_state, $nodes) {
+ $form['nodes'] = array('#prefix' => '<ul>', '#suffix' => '</ul>', '#tree' => TRUE);
+ // array_filter returns only elements with TRUE values
+ foreach ($nodes as $nid => $value) {
+ $title = db_query('SELECT title FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetchField();
+ $form['nodes'][$nid] = array(
+ '#type' => 'hidden',
+ '#value' => $nid,
+ '#prefix' => '<li>',
+ '#suffix' => check_plain($title) . "</li>\n",
+ );
+ }
+ $form['operation'] = array('#type' => 'hidden', '#value' => 'delete');
+ $form['#submit'][] = 'node_multiple_delete_confirm_submit';
+ $confirm_question = format_plural(count($nodes),
+ 'Are you sure you want to delete this item?',
+ 'Are you sure you want to delete these items?');
+ return confirm_form($form,
+ $confirm_question,
+ 'admin/content', t('This action cannot be undone.'),
+ t('Delete'), t('Cancel'));
+}
+
+function node_multiple_delete_confirm_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ node_delete_multiple(array_keys($form_state['values']['nodes']));
+ $count = count($form_state['values']['nodes']);
+ watchdog('content', 'Deleted @count posts.', array('@count' => $count));
+ drupal_set_message(format_plural($count, 'Deleted 1 post.', 'Deleted @count posts.'));
+ }
+ $form_state['redirect'] = 'admin/content';
+}
diff --git a/core/modules/node/node.api.php b/core/modules/node/node.api.php
new file mode 100644
index 000000000000..cab476e4abcb
--- /dev/null
+++ b/core/modules/node/node.api.php
@@ -0,0 +1,1275 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Node module.
+ */
+
+/**
+ * @defgroup node_api_hooks Node API Hooks
+ * @{
+ * Functions to define and modify content types.
+ *
+ * Each content type is maintained by a primary module, which is either
+ * node.module (for content types created in the user interface) or the
+ * module that implements hook_node_info() to define the content type.
+ *
+ * During node operations (create, update, view, delete, etc.), there are
+ * several sets of hooks that get invoked to allow modules to modify the base
+ * node operation:
+ * - Node-type-specific hooks: These hooks are only invoked on the primary
+ * module, using the "base" return component of hook_node_info() as the
+ * function prefix. For example, poll.module defines the base for the Poll
+ * content type as "poll", so during creation of a poll node, hook_insert() is
+ * only invoked by calling poll_insert().
+ * - All-module hooks: This set of hooks is invoked on all implementing
+ * modules, to allow other modules to modify what the primary node module is
+ * doing. For example, hook_node_insert() is invoked on all modules when
+ * creating a poll node.
+ * - Field hooks: Hooks related to the fields attached to the node. These are
+ * invoked from the field operations functions described below, and can be
+ * either field-type-specific or all-module hooks.
+ * - Entity hooks: Generic hooks for "entity" operations. These are always
+ * invoked on all modules.
+ *
+ * Here is a list of the node and entity hooks that are invoked, field
+ * operations, and other steps that take place during node operations:
+ * - Creating a new node (calling node_save() on a new node):
+ * - field_attach_presave()
+ * - hook_node_presave() (all)
+ * - hook_entity_presave() (all)
+ * - Node and revision records are written to the database
+ * - hook_insert() (node-type-specific)
+ * - field_attach_insert()
+ * - hook_node_insert() (all)
+ * - hook_entity_insert() (all)
+ * - hook_node_access_records() (all)
+ * - hook_node_access_records_alter() (all)
+ * - Updating an existing node (calling node_save() on an existing node):
+ * - field_attach_presave()
+ * - hook_node_presave() (all)
+ * - hook_entity_presave() (all)
+ * - Node and revision records are written to the database
+ * - hook_update() (node-type-specific)
+ * - field_attach_update()
+ * - hook_node_update() (all)
+ * - hook_entity_update() (all)
+ * - hook_node_access_records() (all)
+ * - hook_node_access_records_alter() (all)
+ * - Loading a node (calling node_load(), node_load_multiple(), or
+ * entity_load() with $entity_type of 'node'):
+ * - Node and revision information is read from database.
+ * - hook_load() (node-type-specific)
+ * - field_attach_load_revision() and field_attach_load()
+ * - hook_entity_load() (all)
+ * - hook_node_load() (all)
+ * - Viewing a single node (calling node_view() - note that the input to
+ * node_view() is a loaded node, so the Loading steps above are already
+ * done):
+ * - hook_view() (node-type-specific)
+ * - field_attach_prepare_view()
+ * - hook_entity_prepare_view() (all)
+ * - field_attach_view()
+ * - hook_node_view() (all)
+ * - hook_entity_view() (all)
+ * - hook_node_view_alter() (all)
+ * - hook_entity_view_alter() (all)
+ * - Viewing multiple nodes (calling node_view_multiple() - note that the input
+ * to node_view_multiple() is a set of loaded nodes, so the Loading steps
+ * above are already done):
+ * - field_attach_prepare_view()
+ * - hook_entity_prepare_view() (all)
+ * - hook_view() (node-type-specific)
+ * - field_attach_view()
+ * - hook_node_view() (all)
+ * - hook_entity_view() (all)
+ * - hook_node_view_alter() (all)
+ * - hook_entity_view_alter() (all)
+ * - Deleting a node (calling node_delete() or node_delete_multiple()):
+ * - Node is loaded (see Loading section above)
+ * - hook_delete() (node-type-specific)
+ * - hook_node_delete() (all)
+ * - hook_entity_delete() (all)
+ * - field_attach_delete()
+ * - Node and revision information are deleted from database
+ * - Deleting a node revision (calling node_revision_delete()):
+ * - Node is loaded (see Loading section above)
+ * - Revision information is deleted from database
+ * - hook_node_revision_delete() (all)
+ * - field_attach_delete_revision()
+ * - Preparing a node for editing (calling node_form() - note that if it's
+ * an existing node, it will already be loaded; see the Loading section
+ * above):
+ * - hook_prepare() (node-type-specific)
+ * - hook_node_prepare() (all)
+ * - hook_form() (node-type-specific)
+ * - field_attach_form()
+ * - Validating a node during editing form submit (calling
+ * node_form_validate()):
+ * - hook_validate() (node-type-specific)
+ * - hook_node_validate() (all)
+ * - field_attach_form_validate()
+ * - Searching (calling node_search_execute()):
+ * - hook_ranking() (all)
+ * - Query is executed to find matching nodes
+ * - Resulting node is loaded (see Loading section above)
+ * - Resulting node is prepared for viewing (see Viewing a single node above)
+ * - comment_node_update_index() is called.
+ * - hook_node_search_result() (all)
+ * - Search indexing (calling node_update_index()):
+ * - Node is loaded (see Loading section above)
+ * - Node is prepared for viewing (see Viewing a single node above)
+ * - hook_node_update_index() (all)
+ * @}
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Inform the node access system what permissions the user has.
+ *
+ * This hook is for implementation by node access modules. In this hook,
+ * the module grants a user different "grant IDs" within one or more
+ * "realms". In hook_node_access_records(), the realms and grant IDs are
+ * associated with permission to view, edit, and delete individual nodes.
+ *
+ * The realms and grant IDs can be arbitrarily defined by your node access
+ * module; it is common to use role IDs as grant IDs, but that is not
+ * required. Your module could instead maintain its own list of users, where
+ * each list has an ID. In that case, the return value of this hook would be
+ * an array of the list IDs that this user is a member of.
+ *
+ * A node access module may implement as many realms as necessary to
+ * properly define the access privileges for the nodes. Note that the system
+ * makes no distinction between published and unpublished nodes. It is the
+ * module's responsibility to provide appropriate realms to limit access to
+ * unpublished content.
+ *
+ * Node access records are stored in the {node_access} table and define which
+ * grants are required to access a node. There is a special case for the view
+ * operation -- a record with node ID 0 corresponds to a "view all" grant for
+ * the realm and grant ID of that record. If there are no node access modules
+ * enabled, the core node module adds a node ID 0 record for realm 'all'. Node
+ * access modules can also grant "view all" permission on their custom realms;
+ * for example, a module could create a record in {node_access} with:
+ * @code
+ * $record = array(
+ * 'nid' => 0,
+ * 'gid' => 888,
+ * 'realm' => 'example_realm',
+ * 'grant_view' => 1,
+ * 'grant_update' => 0,
+ * 'grant_delete' => 0,
+ * );
+ * drupal_write_record('node_access', $record);
+ * @endcode
+ * And then in its hook_node_grants() implementation, it would need to return:
+ * @code
+ * if ($op == 'view') {
+ * $grants['example_realm'] = array(888);
+ * }
+ * @endcode
+ * If you decide to do this, be aware that the node_access_rebuild() function
+ * will erase any node ID 0 entry when it is called, so you will need to make
+ * sure to restore your {node_access} record after node_access_rebuild() is
+ * called.
+ *
+ * @see node_access_view_all_nodes()
+ * @see node_access_rebuild()
+ *
+ * @param $account
+ * The user object whose grants are requested.
+ * @param $op
+ * The node operation to be performed, such as "view", "update", or "delete".
+ *
+ * @return
+ * An array whose keys are "realms" of grants, and whose values are arrays of
+ * the grant IDs within this realm that this user is being granted.
+ *
+ * For a detailed example, see node_access_example.module.
+ *
+ * @ingroup node_access
+ */
+function hook_node_grants($account, $op) {
+ if (user_access('access private content', $account)) {
+ $grants['example'] = array(1);
+ }
+ $grants['example_owner'] = array($account->uid);
+ return $grants;
+}
+
+/**
+ * Set permissions for a node to be written to the database.
+ *
+ * When a node is saved, a module implementing hook_node_access_records() will
+ * be asked if it is interested in the access permissions for a node. If it is
+ * interested, it must respond with an array of permissions arrays for that
+ * node.
+ *
+ * Node access grants apply regardless of the published or unpublished status
+ * of the node. Implementations must make sure not to grant access to
+ * unpublished nodes if they don't want to change the standard access control
+ * behavior. Your module may need to create a separate access realm to handle
+ * access to unpublished nodes.
+ *
+ * Note that the grant values in the return value from your hook must be
+ * integers and not boolean TRUE and FALSE.
+ *
+ * Each permissions item in the array is an array with the following elements:
+ * - 'realm': The name of a realm that the module has defined in
+ * hook_node_grants().
+ * - 'gid': A 'grant ID' from hook_node_grants().
+ * - 'grant_view': If set to 1 a user that has been identified as a member
+ * of this gid within this realm can view this node. This should usually be
+ * set to $node->status. Failure to do so may expose unpublished content
+ * to some users.
+ * - 'grant_update': If set to 1 a user that has been identified as a member
+ * of this gid within this realm can edit this node.
+ * - 'grant_delete': If set to 1 a user that has been identified as a member
+ * of this gid within this realm can delete this node.
+ * - 'priority': If multiple modules seek to set permissions on a node, the
+ * realms that have the highest priority will win out, and realms with a lower
+ * priority will not be written. If there is any doubt, it is best to
+ * leave this 0.
+ *
+ *
+ * When an implementation is interested in a node but want to deny access to
+ * everyone, it may return a "deny all" grant:
+ *
+ * @code
+ * $grants[] = array(
+ * 'realm' => 'all',
+ * 'gid' => 0,
+ * 'grant_view' => 0,
+ * 'grant_update' => 0,
+ * 'grant_delete' => 0,
+ * 'priority' => 1,
+ * );
+ * @endcode
+ *
+ * Setting the priority should cancel out other grants. In the case of a
+ * conflict between modules, it is safer to use hook_node_access_records_alter()
+ * to return only the deny grant.
+ *
+ * Note: a deny all grant is not written to the database; denies are implicit.
+ *
+ * @see _node_access_write_grants()
+ *
+ * @param $node
+ * The node that has just been saved.
+ *
+ * @return
+ * An array of grants as defined above.
+ *
+ * @ingroup node_access
+ */
+function hook_node_access_records($node) {
+ // We only care about the node if it has been marked private. If not, it is
+ // treated just like any other node and we completely ignore it.
+ if ($node->private) {
+ $grants = array();
+ // Only published nodes should be viewable to all users. If we allow access
+ // blindly here, then all users could view an unpublished node.
+ if ($node->status) {
+ $grants[] = array(
+ 'realm' => 'example',
+ 'gid' => 1,
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ 'priority' => 0,
+ );
+ }
+ // For the example_author array, the GID is equivalent to a UID, which
+ // means there are many groups of just 1 user.
+ // Note that an author can always view his or her nodes, even if they
+ // have status unpublished.
+ $grants[] = array(
+ 'realm' => 'example_author',
+ 'gid' => $node->uid,
+ 'grant_view' => 1,
+ 'grant_update' => 1,
+ 'grant_delete' => 1,
+ 'priority' => 0,
+ );
+
+ return $grants;
+ }
+}
+
+/**
+ * Alter permissions for a node before it is written to the database.
+ *
+ * Node access modules establish rules for user access to content. Node access
+ * records are stored in the {node_access} table and define which permissions
+ * are required to access a node. This hook is invoked after node access modules
+ * returned their requirements via hook_node_access_records(); doing so allows
+ * modules to modify the $grants array by reference before it is stored, so
+ * custom or advanced business logic can be applied.
+ *
+ * @see hook_node_access_records()
+ *
+ * Upon viewing, editing or deleting a node, hook_node_grants() builds a
+ * permissions array that is compared against the stored access records. The
+ * user must have one or more matching permissions in order to complete the
+ * requested operation.
+ *
+ * A module may deny all access to a node by setting $grants to an empty array.
+ *
+ * @see hook_node_grants()
+ * @see hook_node_grants_alter()
+ *
+ * @param $grants
+ * The $grants array returned by hook_node_access_records().
+ * @param $node
+ * The node for which the grants were acquired.
+ *
+ * The preferred use of this hook is in a module that bridges multiple node
+ * access modules with a configurable behavior, as shown in the example with the
+ * 'is_preview' field.
+ *
+ * @ingroup node_access
+ */
+function hook_node_access_records_alter(&$grants, $node) {
+ // Our module allows editors to mark specific articles with the 'is_preview'
+ // field. If the node being saved has a TRUE value for that field, then only
+ // our grants are retained, and other grants are removed. Doing so ensures
+ // that our rules are enforced no matter what priority other grants are given.
+ if ($node->is_preview) {
+ // Our module grants are set in $grants['example'].
+ $temp = $grants['example'];
+ // Now remove all module grants but our own.
+ $grants = array('example' => $temp);
+ }
+}
+
+/**
+ * Alter user access rules when trying to view, edit or delete a node.
+ *
+ * Node access modules establish rules for user access to content.
+ * hook_node_grants() defines permissions for a user to view, edit or
+ * delete nodes by building a $grants array that indicates the permissions
+ * assigned to the user by each node access module. This hook is called to allow
+ * modules to modify the $grants array by reference, so the interaction of
+ * multiple node access modules can be altered or advanced business logic can be
+ * applied.
+ *
+ * @see hook_node_grants()
+ *
+ * The resulting grants are then checked against the records stored in the
+ * {node_access} table to determine if the operation may be completed.
+ *
+ * A module may deny all access to a user by setting $grants to an empty array.
+ *
+ * @see hook_node_access_records()
+ * @see hook_node_access_records_alter()
+ *
+ * @param $grants
+ * The $grants array returned by hook_node_grants().
+ * @param $account
+ * The user account requesting access to content.
+ * @param $op
+ * The operation being performed, 'view', 'update' or 'delete'.
+ *
+ * Developers may use this hook to either add additional grants to a user
+ * or to remove existing grants. These rules are typically based on either the
+ * permissions assigned to a user role, or specific attributes of a user
+ * account.
+ *
+ * @ingroup node_access
+ */
+function hook_node_grants_alter(&$grants, $account, $op) {
+ // Our sample module never allows certain roles to edit or delete
+ // content. Since some other node access modules might allow this
+ // permission, we expressly remove it by returning an empty $grants
+ // array for roles specified in our variable setting.
+
+ // Get our list of banned roles.
+ $restricted = variable_get('example_restricted_roles', array());
+
+ if ($op != 'view' && !empty($restricted)) {
+ // Now check the roles for this account against the restrictions.
+ foreach ($restricted as $role_id) {
+ if (isset($user->roles[$role_id])) {
+ $grants = array();
+ }
+ }
+ }
+}
+
+/**
+ * Add mass node operations.
+ *
+ * This hook enables modules to inject custom operations into the mass
+ * operations dropdown found at admin/content, by associating a callback
+ * function with the operation, which is called when the form is submitted. The
+ * callback function receives one initial argument, which is an array of the
+ * checked nodes.
+ *
+ * @return
+ * An array of operations. Each operation is an associative array that may
+ * contain the following key-value pairs:
+ * - 'label': Required. The label for the operation, displayed in the dropdown
+ * menu.
+ * - 'callback': Required. The function to call for the operation.
+ * - 'callback arguments': Optional. An array of additional arguments to pass
+ * to the callback function.
+ */
+function hook_node_operations() {
+ $operations = array(
+ 'publish' => array(
+ 'label' => t('Publish selected content'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED)),
+ ),
+ 'unpublish' => array(
+ 'label' => t('Unpublish selected content'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_NOT_PUBLISHED)),
+ ),
+ 'promote' => array(
+ 'label' => t('Promote selected content to front page'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED, 'promote' => NODE_PROMOTED)),
+ ),
+ 'demote' => array(
+ 'label' => t('Demote selected content from front page'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('promote' => NODE_NOT_PROMOTED)),
+ ),
+ 'sticky' => array(
+ 'label' => t('Make selected content sticky'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('status' => NODE_PUBLISHED, 'sticky' => NODE_STICKY)),
+ ),
+ 'unsticky' => array(
+ 'label' => t('Make selected content not sticky'),
+ 'callback' => 'node_mass_update',
+ 'callback arguments' => array('updates' => array('sticky' => NODE_NOT_STICKY)),
+ ),
+ 'delete' => array(
+ 'label' => t('Delete selected content'),
+ 'callback' => NULL,
+ ),
+ );
+ return $operations;
+}
+
+/**
+ * Respond to node deletion.
+ *
+ * This hook is invoked from node_delete_multiple() after the type-specific
+ * hook_delete() has been invoked, but before hook_entity_delete and
+ * field_attach_delete() are called, and before the node is removed from the
+ * node table in the database.
+ *
+ * @param $node
+ * The node that is being deleted.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_delete($node) {
+ db_delete('mytable')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Respond to deletion of a node revision.
+ *
+ * This hook is invoked from node_revision_delete() after the revision has been
+ * removed from the node_revision table, and before
+ * field_attach_delete_revision() is called.
+ *
+ * @param $node
+ * The node revision (node object) that is being deleted.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_revision_delete($node) {
+ db_delete('mytable')
+ ->condition('vid', $node->vid)
+ ->execute();
+}
+
+/**
+ * Respond to creation of a new node.
+ *
+ * This hook is invoked from node_save() after the node is inserted into the
+ * node table in the database, after the type-specific hook_insert() is invoked,
+ * and after field_attach_insert() is called.
+ *
+ * @param $node
+ * The node that is being created.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_insert($node) {
+ db_insert('mytable')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'extra' => $node->extra,
+ ))
+ ->execute();
+}
+
+/**
+ * Act on nodes being loaded from the database.
+ *
+ * This hook is invoked during node loading, which is handled by entity_load(),
+ * via classes NodeController and DrupalDefaultEntityController. After the node
+ * information is read from the database or the entity cache, hook_load() is
+ * invoked on the node's content type module, then field_attach_node_revision()
+ * or field_attach_load() is called, then hook_entity_load() is invoked on all
+ * implementing modules, and finally hook_node_load() is invoked on all
+ * implementing modules.
+ *
+ * This hook should only be used to add information that is not in the node or
+ * node revisions table, not to replace information that is in these tables
+ * (which could interfere with the entity cache). For performance reasons,
+ * information for all available nodes should be loaded in a single query where
+ * possible.
+ *
+ * The $types parameter allows for your module to have an early return (for
+ * efficiency) if your module only supports certain node types. However, if your
+ * module defines a content type, you can use hook_load() to respond to loading
+ * of just that content type.
+ *
+ * @param $nodes
+ * An array of the nodes being loaded, keyed by nid.
+ * @param $types
+ * An array containing the types of the nodes.
+ *
+ * For a detailed usage example, see nodeapi_example.module.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_load($nodes, $types) {
+ $result = db_query('SELECT nid, foo FROM {mytable} WHERE nid IN(:nids)', array(':nids' => array_keys($nodes)));
+ foreach ($result as $record) {
+ $nodes[$record->nid]->foo = $record->foo;
+ }
+}
+
+/**
+ * Control access to a node.
+ *
+ * Modules may implement this hook if they want to have a say in whether or not
+ * a given user has access to perform a given operation on a node.
+ *
+ * The administrative account (user ID #1) always passes any access check,
+ * so this hook is not called in that case. Users with the "bypass node access"
+ * permission may always view and edit content through the administrative
+ * interface.
+ *
+ * Note that not all modules will want to influence access on all
+ * node types. If your module does not want to actively grant or
+ * block access, return NODE_ACCESS_IGNORE or simply return nothing.
+ * Blindly returning FALSE will break other node access modules.
+ *
+ * @param object|string $node
+ * Either a node object or a (machine-readable) content type on which to
+ * perform the access check.
+ * @param string $op
+ * The operation to be performed. Possible values:
+ * - "create"
+ * - "delete"
+ * - "update"
+ * - "view"
+ * @param object $account
+ * The user object to perform the access check operation on.
+ *
+ * @return integer
+ * NODE_ACCESS_ALLOW if the operation is to be allowed;
+ * NODE_ACCESS_DENY if the operation is to be denied;
+ * NODE_ACCESS_IGNORE to not affect this operation at all.
+ *
+ * @ingroup node_access
+ */
+function hook_node_access($node, $op, $account) {
+ $type = is_string($node) ? $node : $node->type;
+
+ if (in_array($type, node_permissions_get_configured_types())) {
+ if ($op == 'create' && user_access('create ' . $type . ' content', $account)) {
+ return NODE_ACCESS_ALLOW;
+ }
+
+ if ($op == 'update') {
+ if (user_access('edit any ' . $type . ' content', $account) || (user_access('edit own ' . $type . ' content', $account) && ($account->uid == $node->uid))) {
+ return NODE_ACCESS_ALLOW;
+ }
+ }
+
+ if ($op == 'delete') {
+ if (user_access('delete any ' . $type . ' content', $account) || (user_access('delete own ' . $type . ' content', $account) && ($account->uid == $node->uid))) {
+ return NODE_ACCESS_ALLOW;
+ }
+ }
+ }
+
+ // Returning nothing from this function would have the same effect.
+ return NODE_ACCESS_IGNORE;
+}
+
+
+/**
+ * Act on a node object about to be shown on the add/edit form.
+ *
+ * This hook is invoked from node_object_prepare() after the type-specific
+ * hook_prepare() is invoked.
+ *
+ * @param $node
+ * The node that is about to be shown on the add/edit form.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_prepare($node) {
+ if (!isset($node->comment)) {
+ $node->comment = variable_get("comment_$node->type", COMMENT_NODE_OPEN);
+ }
+}
+
+/**
+ * Act on a node being displayed as a search result.
+ *
+ * This hook is invoked from node_search_execute(), after node_load()
+ * and node_view() have been called.
+ *
+ * @param $node
+ * The node being displayed in a search result.
+ *
+ * @return array
+ * Extra information to be displayed with search result. This information
+ * should be presented as an associative array. It will be concatenated
+ * with the post information (last updated, author) in the default search
+ * result theming.
+ *
+ * @see template_preprocess_search_result()
+ * @see search-result.tpl.php
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_search_result($node) {
+ $comments = db_query('SELECT comment_count FROM {node_comment_statistics} WHERE nid = :nid', array('nid' => $node->nid))->fetchField();
+ return array('comment' => format_plural($comments, '1 comment', '@count comments'));
+}
+
+/**
+ * Act on a node being inserted or updated.
+ *
+ * This hook is invoked from node_save() before the node is saved to the
+ * database.
+ *
+ * @param $node
+ * The node that is being inserted or updated.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_presave($node) {
+ if ($node->nid && $node->moderate) {
+ // Reset votes when node is updated:
+ $node->score = 0;
+ $node->users = '';
+ $node->votes = 0;
+ }
+}
+
+/**
+ * Respond to updates to a node.
+ *
+ * This hook is invoked from node_save() after the node is updated in the node
+ * table in the database, after the type-specific hook_update() is invoked, and
+ * after field_attach_update() is called.
+ *
+ * @param $node
+ * The node that is being updated.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_update($node) {
+ db_update('mytable')
+ ->fields(array('extra' => $node->extra))
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Act on a node being indexed for searching.
+ *
+ * This hook is invoked during search indexing, after node_load(), and after
+ * the result of node_view() is added as $node->rendered to the node object.
+ *
+ * @param $node
+ * The node being indexed.
+ *
+ * @return
+ * Array of additional information to be indexed.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_update_index($node) {
+ $text = '';
+ $comments = db_query('SELECT subject, comment, format FROM {comment} WHERE nid = :nid AND status = :status', array(':nid' => $node->nid, ':status' => COMMENT_PUBLISHED));
+ foreach ($comments as $comment) {
+ $text .= '<h2>' . check_plain($comment->subject) . '</h2>' . check_markup($comment->comment, $comment->format, '', TRUE);
+ }
+ return $text;
+}
+
+/**
+ * Perform node validation before a node is created or updated.
+ *
+ * This hook is invoked from node_validate(), after a user has has finished
+ * editing the node and is previewing or submitting it. It is invoked at the
+ * end of all the standard validation steps, and after the type-specific
+ * hook_validate() is invoked.
+ *
+ * To indicate a validation error, use form_set_error().
+ *
+ * Note: Changes made to the $node object within your hook implementation will
+ * have no effect. The preferred method to change a node's content is to use
+ * hook_node_presave() instead. If it is really necessary to change
+ * the node at the validate stage, you can use form_set_value().
+ *
+ * @param $node
+ * The node being validated.
+ * @param $form
+ * The form being used to edit the node.
+ * @param $form_state
+ * The form state array.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_validate($node, $form, &$form_state) {
+ if (isset($node->end) && isset($node->start)) {
+ if ($node->start > $node->end) {
+ form_set_error('time', t('An event may not end before it starts.'));
+ }
+ }
+}
+
+/**
+ * Act on a node after validated form values have been copied to it.
+ *
+ * This hook is invoked when a node form is submitted with either the "Save" or
+ * "Preview" button, after form values have been copied to the form state's node
+ * object, but before the node is saved or previewed. It is a chance for modules
+ * to adjust the node's properties from what they are simply after a copy from
+ * $form_state['values']. This hook is intended for adjusting non-field-related
+ * properties. See hook_field_attach_submit() for customizing field-related
+ * properties.
+ *
+ * @param $node
+ * The node object being updated in response to a form submission.
+ * @param $form
+ * The form being used to edit the node.
+ * @param $form_state
+ * The form state array.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_submit($node, $form, &$form_state) {
+ // Decompose the selected menu parent option into 'menu_name' and 'plid', if
+ // the form used the default parent selection widget.
+ if (!empty($form_state['values']['menu']['parent'])) {
+ list($node->menu['menu_name'], $node->menu['plid']) = explode(':', $form_state['values']['menu']['parent']);
+ }
+}
+
+/**
+ * Act on a node that is being assembled before rendering.
+ *
+ * The module may add elements to $node->content prior to rendering. This hook
+ * will be called after hook_view(). The structure of $node->content is a
+ * renderable array as expected by drupal_render().
+ *
+ * When $view_mode is 'rss', modules can also add extra RSS elements and
+ * namespaces to $node->rss_elements and $node->rss_namespaces respectively for
+ * the RSS item generated for this node.
+ * For details on how this is used, see node_feed().
+ *
+ * @see forum_node_view()
+ * @see comment_node_view()
+ *
+ * @param $node
+ * The node that is being assembled for rendering.
+ * @param $view_mode
+ * The $view_mode parameter from node_view().
+ * @param $langcode
+ * The language code used for rendering.
+ *
+ * @see hook_entity_view()
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_view($node, $view_mode, $langcode) {
+ $node->content['my_additional_field'] = array(
+ '#markup' => $additional_field,
+ '#weight' => 10,
+ '#theme' => 'mymodule_my_additional_field',
+ );
+}
+
+/**
+ * Alter the results of node_view().
+ *
+ * This hook is called after the content has been assembled in a structured
+ * array and may be used for doing processing which requires that the complete
+ * node content structure has been built.
+ *
+ * If the module wishes to act on the rendered HTML of the node rather than the
+ * structured content array, it may use this hook to add a #post_render
+ * callback. Alternatively, it could also implement hook_preprocess_node(). See
+ * drupal_render() and theme() documentation respectively for details.
+ *
+ * @param $build
+ * A renderable array representing the node content.
+ *
+ * @see node_view()
+ * @see hook_entity_view_alter()
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_view_alter(&$build) {
+ if ($build['#view_mode'] == 'full' && isset($build['an_additional_field'])) {
+ // Change its weight.
+ $build['an_additional_field']['#weight'] = -10;
+ }
+
+ // Add a #post_render callback to act on the rendered HTML of the node.
+ $build['#post_render'][] = 'my_module_node_post_render';
+}
+
+/**
+ * Define module-provided node types.
+ *
+ * This hook allows a module to define one or more of its own node types. For
+ * example, the forum module uses it to define a forum node-type named "Forum
+ * topic." The name and attributes of each desired node type are specified in
+ * an array returned by the hook.
+ *
+ * Only module-provided node types should be defined through this hook. User-
+ * provided (or 'custom') node types should be defined only in the 'node_type'
+ * database table, and should be maintained by using the node_type_save() and
+ * node_type_delete() functions.
+ *
+ * @return
+ * An array of information defining the module's node types. The array
+ * contains a sub-array for each node type, with the the machine name of a
+ * content type as the key. Each sub-array has up to 10 attributes.
+ * Possible attributes:
+ * - "name": the human-readable name of the node type. Required.
+ * - "base": the base string used to construct callbacks corresponding to
+ * this node type.
+ * (i.e. if base is defined as example_foo, then example_foo_insert will
+ * be called when inserting a node of that type). This string is usually
+ * the name of the module, but not always. Required.
+ * - "description": a brief description of the node type. Required.
+ * - "help": help information shown to the user when creating a node of
+ * this type.. Optional (defaults to '').
+ * - "has_title": boolean indicating whether or not this node type has a title
+ * field. Optional (defaults to TRUE).
+ * - "title_label": the label for the title field of this content type.
+ * Optional (defaults to 'Title').
+ * - "locked": boolean indicating whether the administrator can change the
+ * machine name of this type. FALSE = changeable (not locked),
+ * TRUE = unchangeable (locked). Optional (defaults to TRUE).
+ *
+ * The machine name of a node type should contain only letters, numbers, and
+ * underscores. Underscores will be converted into hyphens for the purpose of
+ * constructing URLs.
+ *
+ * All attributes of a node type that are defined through this hook (except for
+ * 'locked') can be edited by a site administrator. This includes the
+ * machine-readable name of a node type, if 'locked' is set to FALSE.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_node_info() {
+ return array(
+ 'forum' => array(
+ 'name' => t('Forum topic'),
+ 'base' => 'forum',
+ 'description' => t('A <em>forum topic</em> starts a new discussion thread within a forum.'),
+ 'title_label' => t('Subject'),
+ )
+ );
+}
+
+/**
+ * Provide additional methods of scoring for core search results for nodes.
+ *
+ * A node's search score is used to rank it among other nodes matched by the
+ * search, with the highest-ranked nodes appearing first in the search listing.
+ *
+ * For example, a module allowing users to vote on content could expose an
+ * option to allow search results' rankings to be influenced by the average
+ * voting score of a node.
+ *
+ * All scoring mechanisms are provided as options to site administrators, and
+ * may be tweaked based on individual sites or disabled altogether if they do
+ * not make sense. Individual scoring mechanisms, if enabled, are assigned a
+ * weight from 1 to 10. The weight represents the factor of magnification of
+ * the ranking mechanism, with higher-weighted ranking mechanisms having more
+ * influence. In order for the weight system to work, each scoring mechanism
+ * must return a value between 0 and 1 for every node. That value is then
+ * multiplied by the administrator-assigned weight for the ranking mechanism,
+ * and then the weighted scores from all ranking mechanisms are added, which
+ * brings about the same result as a weighted average.
+ *
+ * @return
+ * An associative array of ranking data. The keys should be strings,
+ * corresponding to the internal name of the ranking mechanism, such as
+ * 'recent', or 'comments'. The values should be arrays themselves, with the
+ * following keys available:
+ * - "title": the human readable name of the ranking mechanism. Required.
+ * - "join": part of a query string to join to any additional necessary
+ * table. This is not necessary if the table required is already joined to
+ * by the base query, such as for the {node} table. Other tables should use
+ * the full table name as an alias to avoid naming collisions. Optional.
+ * - "score": part of a query string to calculate the score for the ranking
+ * mechanism based on values in the database. This does not need to be
+ * wrapped in parentheses, as it will be done automatically; it also does
+ * not need to take the weighted system into account, as it will be done
+ * automatically. It does, however, need to calculate a decimal between
+ * 0 and 1; be careful not to cast the entire score to an integer by
+ * inadvertently introducing a variable argument. Required.
+ * - "arguments": if any arguments are required for the score, they can be
+ * specified in an array here.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_ranking() {
+ // If voting is disabled, we can avoid returning the array, no hard feelings.
+ if (variable_get('vote_node_enabled', TRUE)) {
+ return array(
+ 'vote_average' => array(
+ 'title' => t('Average vote'),
+ // Note that we use i.sid, the search index's search item id, rather than
+ // n.nid.
+ 'join' => 'LEFT JOIN {vote_node_data} vote_node_data ON vote_node_data.nid = i.sid',
+ // The highest possible score should be 1, and the lowest possible score,
+ // always 0, should be 0.
+ 'score' => 'vote_node_data.average / CAST(%f AS DECIMAL)',
+ // Pass in the highest possible voting score as a decimal argument.
+ 'arguments' => array(variable_get('vote_score_max', 5)),
+ ),
+ );
+ }
+}
+
+
+/**
+ * Respond to node type creation.
+ *
+ * This hook is invoked from node_type_save() after the node type is added
+ * to the database.
+ *
+ * @param $info
+ * The node type object that is being created.
+ */
+function hook_node_type_insert($info) {
+}
+
+/**
+ * Respond to node type updates.
+ *
+ * This hook is invoked from node_type_save() after the node type is updated
+ * in the database.
+ *
+ * @param $info
+ * The node type object that is being updated.
+ */
+function hook_node_type_update($info) {
+ if (!empty($info->old_type) && $info->old_type != $info->type) {
+ $setting = variable_get('comment_' . $info->old_type, COMMENT_NODE_OPEN);
+ variable_del('comment_' . $info->old_type);
+ variable_set('comment_' . $info->type, $setting);
+ }
+}
+
+/**
+ * Respond to node type deletion.
+ *
+ * This hook is invoked from node_type_delete() after the node type is removed
+ * from the database.
+ *
+ * @param $info
+ * The node type object that is being deleted.
+ */
+function hook_node_type_delete($info) {
+ variable_del('comment_' . $info->type);
+}
+
+/**
+ * Respond to node deletion.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_delete() to respond to all node deletions).
+ *
+ * This hook is invoked from node_delete_multiple() after the node has been
+ * removed from the node table in the database, before hook_node_delete() is
+ * invoked, and before field_attach_delete() is called.
+ *
+ * @param $node
+ * The node that is being deleted.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_delete($node) {
+ db_delete('mytable')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Act on a node object about to be shown on the add/edit form.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_prepare() to act on all node preparations).
+ *
+ * This hook is invoked from node_object_prepare() before the general
+ * hook_node_prepare() is invoked.
+ *
+ * @param $node
+ * The node that is about to be shown on the add/edit form.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_prepare($node) {
+ if ($file = file_check_upload($field_name)) {
+ $file = file_save_upload($field_name, _image_filename($file->filename, NULL, TRUE));
+ if ($file) {
+ if (!image_get_info($file->uri)) {
+ form_set_error($field_name, t('Uploaded file is not a valid image'));
+ return;
+ }
+ }
+ else {
+ return;
+ }
+ $node->images['_original'] = $file->uri;
+ _image_build_derivatives($node, TRUE);
+ $node->new_file = TRUE;
+ }
+}
+
+/**
+ * Display a node editing form.
+ *
+ * This hook, implemented by node modules, is called to retrieve the form
+ * that is displayed to create or edit a node. This form is displayed at path
+ * node/add/[node type] or node/[node ID]/edit.
+ *
+ * The submit and preview buttons, administrative and display controls, and
+ * sections added by other modules (such as path settings, menu settings,
+ * comment settings, and fields managed by the Field UI module) are
+ * displayed automatically by the node module. This hook just needs to
+ * return the node title and form editing fields specific to the node type.
+ *
+ * @param $node
+ * The node being added or edited.
+ * @param $form_state
+ * The form state array.
+ *
+ * @return
+ * An array containing the title and any custom form elements to be displayed
+ * in the node editing form.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_form($node, &$form_state) {
+ $type = node_type_get_type($node);
+
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => check_plain($type->title_label),
+ '#default_value' => !empty($node->title) ? $node->title : '',
+ '#required' => TRUE, '#weight' => -5
+ );
+
+ $form['field1'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Custom field'),
+ '#default_value' => $node->field1,
+ '#maxlength' => 127,
+ );
+ $form['selectbox'] = array(
+ '#type' => 'select',
+ '#title' => t('Select box'),
+ '#default_value' => $node->selectbox,
+ '#options' => array(
+ 1 => 'Option A',
+ 2 => 'Option B',
+ 3 => 'Option C',
+ ),
+ '#description' => t('Choose an option.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Respond to creation of a new node.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_insert() to act on all node insertions).
+ *
+ * This hook is invoked from node_save() after the node is inserted into the
+ * node table in the database, before field_attach_insert() is called, and
+ * before hook_node_insert() is invoked.
+ *
+ * @param $node
+ * The node that is being created.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_insert($node) {
+ db_insert('mytable')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'extra' => $node->extra,
+ ))
+ ->execute();
+}
+
+/**
+ * Act on nodes being loaded from the database.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_load() to respond to all node loads).
+ *
+ * This hook is invoked during node loading, which is handled by entity_load(),
+ * via classes NodeController and DrupalDefaultEntityController. After the node
+ * information is read from the database or the entity cache, hook_load() is
+ * invoked on the node's content type module, then field_attach_node_revision()
+ * or field_attach_load() is called, then hook_entity_load() is invoked on all
+ * implementing modules, and finally hook_node_load() is invoked on all
+ * implementing modules.
+ *
+ * This hook should only be used to add information that is not in the node or
+ * node revisions table, not to replace information that is in these tables
+ * (which could interfere with the entity cache). For performance reasons,
+ * information for all available nodes should be loaded in a single query where
+ * possible.
+ *
+ * @param $nodes
+ * An array of the nodes being loaded, keyed by nid.
+ *
+ * For a detailed usage example, see node_example.module.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_load($nodes) {
+ $result = db_query('SELECT nid, foo FROM {mytable} WHERE nid IN (:nids)', array(':nids' => array_keys($nodes)));
+ foreach ($result as $record) {
+ $nodes[$record->nid]->foo = $record->foo;
+ }
+}
+
+/**
+ * Respond to updates to a node.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_update() to act on all node updates).
+ *
+ * This hook is invoked from node_save() after the node is updated in the
+ * node table in the database, before field_attach_update() is called, and
+ * before hook_node_update() is invoked.
+ *
+ * @param $node
+ * The node that is being updated.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_update($node) {
+ db_update('mytable')
+ ->fields(array('extra' => $node->extra))
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Perform node validation before a node is created or updated.
+ *
+ * This hook is invoked only on the module that defines the node's content type
+ * (use hook_node_validate() to act on all node validations).
+ *
+ * This hook is invoked from node_validate(), after a user has finished
+ * editing the node and is previewing or submitting it. It is invoked at the end
+ * of all the standard validation steps, and before hook_node_validate() is
+ * invoked.
+ *
+ * To indicate a validation error, use form_set_error().
+ *
+ * Note: Changes made to the $node object within your hook implementation will
+ * have no effect. The preferred method to change a node's content is to use
+ * hook_node_presave() instead.
+ *
+ * @param $node
+ * The node being validated.
+ * @param $form
+ * The form being used to edit the node.
+ * @param $form_state
+ * The form state array.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_validate($node, $form, &$form_state) {
+ if (isset($node->end) && isset($node->start)) {
+ if ($node->start > $node->end) {
+ form_set_error('time', t('An event may not end before it starts.'));
+ }
+ }
+}
+
+/**
+ * Display a node.
+ *
+ * This is a hook used by node modules. It allows a module to define a
+ * custom method of displaying its nodes, usually by displaying extra
+ * information particular to that node type.
+ *
+ * @param $node
+ * The node to be displayed, as returned by node_load().
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser', ...
+ * @return
+ * $node. The passed $node parameter should be modified as necessary and
+ * returned so it can be properly presented. Nodes are prepared for display
+ * by assembling a structured array, formatted as in the Form API, in
+ * $node->content. As with Form API arrays, the #weight property can be
+ * used to control the relative positions of added elements. After this
+ * hook is invoked, node_view() calls field_attach_view() to add field
+ * views to $node->content, and then invokes hook_node_view() and
+ * hook_node_view_alter(), so if you want to affect the final
+ * view of the node, you might consider implementing one of these hooks
+ * instead.
+ *
+ * For a detailed usage example, see node_example.module.
+ *
+ * @ingroup node_api_hooks
+ */
+function hook_view($node, $view_mode) {
+ if ($view_mode == 'full' && node_is_page($node)) {
+ $breadcrumb = array();
+ $breadcrumb[] = l(t('Home'), NULL);
+ $breadcrumb[] = l(t('Example'), 'example');
+ $breadcrumb[] = l($node->field1, 'example/' . $node->field1);
+ drupal_set_breadcrumb($breadcrumb);
+ }
+
+ $node->content['myfield'] = array(
+ '#markup' => theme('mymodule_myfield', $node->myfield),
+ '#weight' => 1,
+ );
+
+ return $node;
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/node/node.css b/core/modules/node/node.css
new file mode 100644
index 000000000000..07540fa90b94
--- /dev/null
+++ b/core/modules/node/node.css
@@ -0,0 +1,10 @@
+
+.node-unpublished {
+ background-color: #fff4f4;
+}
+.preview .node {
+ background-color: #ffffea;
+}
+td.revision-current {
+ background: #ffc;
+}
diff --git a/core/modules/node/node.info b/core/modules/node/node.info
new file mode 100644
index 000000000000..2e410ed7a4b7
--- /dev/null
+++ b/core/modules/node/node.info
@@ -0,0 +1,11 @@
+name = Node
+description = Allows content to be submitted to the site and displayed on pages.
+package = Core
+version = VERSION
+core = 8.x
+files[] = node.module
+files[] = node.test
+required = TRUE
+dependencies[] = entity
+configure = admin/structure/types
+stylesheets[all][] = node.css
diff --git a/core/modules/node/node.install b/core/modules/node/node.install
new file mode 100644
index 000000000000..3f732a487ebc
--- /dev/null
+++ b/core/modules/node/node.install
@@ -0,0 +1,471 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the node module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function node_schema() {
+ $schema['node'] = array(
+ 'description' => 'The base table for nodes.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The primary identifier for a node.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'vid' => array(
+ 'description' => 'The current {node_revision}.vid version identifier.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'type' => array(
+ 'description' => 'The {node_type}.type of this node.',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'language' => array(
+ 'description' => 'The {languages}.language of this node.',
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'title' => array(
+ 'description' => 'The title of this node, always treated as non-markup plain text.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'uid' => array(
+ 'description' => 'The {users}.uid that owns this node; initially, this is the user that created it.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'status' => array(
+ 'description' => 'Boolean indicating whether the node is published (visible to non-administrators).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ 'created' => array(
+ 'description' => 'The Unix timestamp when the node was created.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'changed' => array(
+ 'description' => 'The Unix timestamp when the node was most recently saved.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'comment' => array(
+ 'description' => 'Whether comments are allowed on this node: 0 = no, 1 = closed (read only), 2 = open (read/write).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'promote' => array(
+ 'description' => 'Boolean indicating whether the node should be displayed on the front page.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'sticky' => array(
+ 'description' => 'Boolean indicating whether the node should be displayed at the top of lists in which it appears.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'tnid' => array(
+ 'description' => 'The translation set id for this node, which equals the node id of the source post in each set.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'translate' => array(
+ 'description' => 'A boolean indicating whether this translation page needs to be updated.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'node_changed' => array('changed'),
+ 'node_created' => array('created'),
+ 'node_frontpage' => array('promote', 'status', 'sticky', 'created'),
+ 'node_status_type' => array('status', 'type', 'nid'),
+ 'node_title_type' => array('title', array('type', 4)),
+ 'node_type' => array(array('type', 4)),
+ 'uid' => array('uid'),
+ 'tnid' => array('tnid'),
+ 'translate' => array('translate'),
+ ),
+ 'unique keys' => array(
+ 'vid' => array('vid'),
+ ),
+ 'foreign keys' => array(
+ 'node_revision' => array(
+ 'table' => 'node_revision',
+ 'columns' => array('vid' => 'vid'),
+ ),
+ 'node_author' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ 'primary key' => array('nid'),
+ );
+
+ $schema['node_access'] = array(
+ 'description' => 'Identifies which realm/grant pairs a user must possess in order to view, update, or delete specific nodes.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record affects.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'gid' => array(
+ 'description' => "The grant ID a user must possess in the specified realm to gain this row's privileges on the node.",
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'realm' => array(
+ 'description' => 'The realm in which the user must possess the grant ID. Each node access node can define one or more realms.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'grant_view' => array(
+ 'description' => 'Boolean indicating whether a user with the realm/grant pair can view this node.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'grant_update' => array(
+ 'description' => 'Boolean indicating whether a user with the realm/grant pair can edit this node.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'grant_delete' => array(
+ 'description' => 'Boolean indicating whether a user with the realm/grant pair can delete this node.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ ),
+ 'primary key' => array('nid', 'gid', 'realm'),
+ 'foreign keys' => array(
+ 'affected_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ ),
+ );
+
+ $schema['node_revision'] = array(
+ 'description' => 'Stores information about each saved version of a {node}.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node} this version belongs to.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'vid' => array(
+ 'description' => 'The primary identifier for this version.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'uid' => array(
+ 'description' => 'The {users}.uid that created this version.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'title' => array(
+ 'description' => 'The title of this version.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'log' => array(
+ 'description' => 'The log entry explaining the changes in this version.',
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ ),
+ 'timestamp' => array(
+ 'description' => 'A Unix timestamp indicating when this version was created.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'status' => array(
+ 'description' => 'Boolean indicating whether the node (at the time of this revision) is published (visible to non-administrators).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 1,
+ ),
+ 'comment' => array(
+ 'description' => 'Whether comments are allowed on this node (at the time of this revision): 0 = no, 1 = closed (read only), 2 = open (read/write).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'promote' => array(
+ 'description' => 'Boolean indicating whether the node (at the time of this revision) should be displayed on the front page.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'sticky' => array(
+ 'description' => 'Boolean indicating whether the node (at the time of this revision) should be displayed at the top of lists in which it appears.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'nid' => array('nid'),
+ 'uid' => array('uid'),
+ ),
+ 'primary key' => array('vid'),
+ 'foreign keys' => array(
+ 'versioned_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'version_author' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['node_type'] = array(
+ 'description' => 'Stores information about all defined {node} types.',
+ 'fields' => array(
+ 'type' => array(
+ 'description' => 'The machine-readable name of this type.',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ ),
+ 'name' => array(
+ 'description' => 'The human-readable name of this type.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'translatable' => TRUE,
+ ),
+ 'base' => array(
+ 'description' => 'The base string used to construct callbacks corresponding to this node type.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'module' => array(
+ 'description' => 'The module defining this node type.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'description' => array(
+ 'description' => 'A brief description of this type.',
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'medium',
+ 'translatable' => TRUE,
+ ),
+ 'help' => array(
+ 'description' => 'Help information shown to the user when creating a {node} of this type.',
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'medium',
+ 'translatable' => TRUE,
+ ),
+ 'has_title' => array(
+ 'description' => 'Boolean indicating whether this type uses the {node}.title field.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'size' => 'tiny',
+ ),
+ 'title_label' => array(
+ 'description' => 'The label displayed for the title field on the edit form.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'translatable' => TRUE,
+ ),
+ 'custom' => array(
+ 'description' => 'A boolean indicating whether this type is defined by a module (FALSE) or by a user via Add content type (TRUE).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'modified' => array(
+ 'description' => 'A boolean indicating whether this type has been modified by an administrator; currently not used in any way.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'locked' => array(
+ 'description' => 'A boolean indicating whether the administrator can change the machine name of this type.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'disabled' => array(
+ 'description' => 'A boolean indicating whether the node type is disabled.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny'
+ ),
+ 'orig_type' => array(
+ 'description' => 'The original machine-readable name of this node type. This may be different from the current type name if the locked field is 0.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'primary key' => array('type'),
+ );
+
+ $schema['block_node_type'] = array(
+ 'description' => 'Sets up display criteria for blocks based on content types',
+ 'fields' => array(
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'description' => "The block's origin module, from {block}.module.",
+ ),
+ 'delta' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'description' => "The block's unique delta within module, from {block}.delta.",
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'description' => "The machine-readable name of this type from {node_type}.type.",
+ ),
+ ),
+ 'primary key' => array('module', 'delta', 'type'),
+ 'indexes' => array(
+ 'type' => array('type'),
+ ),
+ );
+
+ $schema['history'] = array(
+ 'description' => 'A record of which {users} have read which {node}s.',
+ 'fields' => array(
+ 'uid' => array(
+ 'description' => 'The {users}.uid that read the {node} nid.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'nid' => array(
+ 'description' => 'The {node}.nid that was read.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'timestamp' => array(
+ 'description' => 'The Unix timestamp at which the read occurred.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('uid', 'nid'),
+ 'indexes' => array(
+ 'nid' => array('nid'),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function node_install() {
+ // Populate the node access table.
+ db_insert('node_access')
+ ->fields(array(
+ 'nid' => 0,
+ 'gid' => 0,
+ 'realm' => 'all',
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ ))
+ ->execute();
+}
+
+/**
+ * Utility function: fetch the node types directly from the database.
+ *
+ * @ingroup update-api-7.x-to-8.x
+ */
+function _update_7000_node_get_types() {
+ $node_types = db_query('SELECT * FROM {node_type}')->fetchAllAssoc('type', PDO::FETCH_OBJ);
+
+ // Create default settings for orphaned nodes.
+ $all_types = db_query('SELECT DISTINCT type FROM {node}')->fetchCol();
+ $extra_types = array_diff($all_types, array_keys($node_types));
+
+ foreach ($extra_types as $type) {
+ $type_object = new stdClass();
+ $type_object->type = $type;
+ // In Drupal 6, whether you have a body field or not is a flag in the node
+ // type table. If it's enabled, nodes may or may not have an empty string
+ // for the bodies. As we can't detect what this setting should be in
+ // Drupal 7 without access to the Drupal 6 node type settings, we assume
+ // the default, which is to enable the body field.
+ $type_object->has_body = 1;
+ $type_object->body_label = 'Body';
+ $node_types[$type_object->type] = $type_object;
+ }
+ return $node_types;
+}
diff --git a/core/modules/node/node.js b/core/modules/node/node.js
new file mode 100644
index 000000000000..ebf68eb3ba2a
--- /dev/null
+++ b/core/modules/node/node.js
@@ -0,0 +1,43 @@
+
+(function ($) {
+
+Drupal.behaviors.nodeFieldsetSummaries = {
+ attach: function (context) {
+ $('fieldset.node-form-revision-information', context).drupalSetSummary(function (context) {
+ var revisionCheckbox = $('.form-item-revision input', context);
+
+ // Return 'New revision' if the 'Create new revision' checkbox is checked,
+ // or if the checkbox doesn't exist, but the revision log does. For users
+ // without the "Administer content" permission the checkbox won't appear,
+ // but the revision log will if the content type is set to auto-revision.
+ if (revisionCheckbox.is(':checked') || (!revisionCheckbox.length && $('.form-item-log textarea', context).length)) {
+ return Drupal.t('New revision');
+ }
+
+ return Drupal.t('No revision');
+ });
+
+ $('fieldset.node-form-author', context).drupalSetSummary(function (context) {
+ var name = $('.form-item-name input', context).val() || Drupal.settings.anonymous,
+ date = $('.form-item-date input', context).val();
+ return date ?
+ Drupal.t('By @name on @date', { '@name': name, '@date': date }) :
+ Drupal.t('By @name', { '@name': name });
+ });
+
+ $('fieldset.node-form-options', context).drupalSetSummary(function (context) {
+ var vals = [];
+
+ $('input:checked', context).parent().each(function () {
+ vals.push(Drupal.checkPlain($.trim($(this).text())));
+ });
+
+ if (!$('.form-item-status input', context).is(':checked')) {
+ vals.unshift(Drupal.t('Not published'));
+ }
+ return vals.join(', ');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/node/node.module b/core/modules/node/node.module
new file mode 100644
index 000000000000..0c3cfb7a0062
--- /dev/null
+++ b/core/modules/node/node.module
@@ -0,0 +1,3959 @@
+<?php
+
+/**
+ * @file
+ * The core that allows content to be submitted to the site. Modules and
+ * scripts may programmatically submit nodes using the usual form API pattern.
+ */
+
+/**
+ * Node is not published.
+ */
+define('NODE_NOT_PUBLISHED', 0);
+
+/**
+ * Node is published.
+ */
+define('NODE_PUBLISHED', 1);
+
+/**
+ * Node is not promoted to front page.
+ */
+define('NODE_NOT_PROMOTED', 0);
+
+/**
+ * Node is promoted to front page.
+ */
+define('NODE_PROMOTED', 1);
+
+/**
+ * Node is not sticky at top of the page.
+ */
+define('NODE_NOT_STICKY', 0);
+
+/**
+ * Node is sticky at top of the page.
+ */
+define('NODE_STICKY', 1);
+
+/**
+ * Nodes changed before this time are always marked as read.
+ *
+ * Nodes changed after this time may be marked new, updated, or read, depending
+ * on their state for the current user. Defaults to 30 days ago.
+ */
+define('NODE_NEW_LIMIT', REQUEST_TIME - 30 * 24 * 60 * 60);
+
+/**
+ * Modules should return this value from hook_node_access() to allow access to a node.
+ */
+define('NODE_ACCESS_ALLOW', 'allow');
+
+/**
+ * Modules should return this value from hook_node_access() to deny access to a node.
+ */
+define('NODE_ACCESS_DENY', 'deny');
+
+/**
+ * Modules should return this value from hook_node_access() to not affect node access.
+ */
+define('NODE_ACCESS_IGNORE', NULL);
+
+/**
+ * Implements hook_help().
+ */
+function node_help($path, $arg) {
+ // Remind site administrators about the {node_access} table being flagged
+ // for rebuild. We don't need to issue the message on the confirm form, or
+ // while the rebuild is being processed.
+ if ($path != 'admin/reports/status/rebuild' && $path != 'batch' && strpos($path, '#') === FALSE
+ && user_access('access administration pages') && node_access_needs_rebuild()) {
+ if ($path == 'admin/reports/status') {
+ $message = t('The content access permissions need to be rebuilt.');
+ }
+ else {
+ $message = t('The content access permissions need to be rebuilt. <a href="@node_access_rebuild">Rebuild permissions</a>.', array('@node_access_rebuild' => url('admin/reports/status/rebuild')));
+ }
+ drupal_set_message($message, 'error');
+ }
+
+ switch ($path) {
+ case 'admin/help#node':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Node module manages the creation, editing, deletion, settings, and display of the main site content. Content items managed by the Node module are typically displayed as pages on your site, and include a title, some meta-data (author, creation time, content type, etc.), and optional fields containing text or other data (fields are managed by the <a href="@field">Field module</a>). For more information, see the online handbook entry for <a href="@node">Node module</a>.', array('@node' => 'http://drupal.org/handbook/modules/node', '@field' => url('admin/help/field'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Creating content') . '</dt>';
+ $output .= '<dd>' . t('When new content is created, the Node module records basic information about the content, including the author, date of creation, and the <a href="@content-type">Content type</a>. It also manages the <em>publishing options</em>, which define whether or not the content is published, promoted to the front page of the site, and/or sticky at the top of content lists. Default settings can be configured for each <a href="@content-type">type of content</a> on your site.', array('@content-type' => url('admin/structure/types'))) . '</dd>';
+ $output .= '<dt>' . t('Creating custom content types') . '</dt>';
+ $output .= '<dd>' . t('The Node module gives users with the <em>Administer content types</em> permission the ability to <a href="@content-new">create new content types</a> in addition to the default ones already configured. Creating custom content types allows you the flexibility to add <a href="@field">fields</a> and configure default settings that suit the differing needs of various site content.', array('@content-new' => url('admin/structure/types/add'), '@field' => url('admin/help/field'))) . '</dd>';
+ $output .= '<dt>' . t('Administering content') . '</dt>';
+ $output .= '<dd>' . t('The <a href="@content">Content administration page</a> allows you to review and bulk manage your site content.', array('@content' => url('admin/content'))) . '</dd>';
+ $output .= '<dt>' . t('Creating revisions') . '</dt>';
+ $output .= '<dd>' . t('The Node module also enables you to create multiple versions of any content, and revert to older versions using the <em>Revision information</em> settings.') . '</dd>';
+ $output .= '<dt>' . t('User permissions') . '</dt>';
+ $output .= '<dd>' . t('The Node module makes a number of permissions available for each content type, which can be set by role on the <a href="@permissions">permissions page</a>.', array('@permissions' => url('admin/people/permissions', array('fragment' => 'module-node')))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/structure/types/add':
+ return '<p>' . t('Individual content types can have different fields, behaviors, and permissions assigned to them.') . '</p>';
+
+ case 'admin/structure/types/manage/%/display':
+ return '<p>' . t('Content items can be displayed using different view modes: Teaser, Full content, Print, RSS, etc. <em>Teaser</em> is a short format that is typically used in lists of multiple content items. <em>Full content</em> is typically used when the content is displayed on its own page.') . '</p>' .
+ '<p>' . t('Here, you can define which fields are shown and hidden when %type content is displayed in each view mode, and define how the fields are displayed in each view mode.', array('%type' => node_type_get_name($arg[4]))) . '</p>';
+
+ case 'node/%/revisions':
+ return '<p>' . t('Revisions allow you to track differences between multiple versions of your content, and revert back to older versions.') . '</p>';
+
+ case 'node/%/edit':
+ $node = node_load($arg[1]);
+ $type = node_type_get_type($node);
+ return (!empty($type->help) ? '<p>' . filter_xss_admin($type->help) . '</p>' : '');
+ }
+
+ if ($arg[0] == 'node' && $arg[1] == 'add' && $arg[2]) {
+ $type = node_type_get_type(str_replace('-', '_', $arg[2]));
+ return (!empty($type->help) ? '<p>' . filter_xss_admin($type->help) . '</p>' : '');
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function node_theme() {
+ return array(
+ 'node' => array(
+ 'render element' => 'elements',
+ 'template' => 'node',
+ ),
+ 'node_search_admin' => array(
+ 'render element' => 'form',
+ ),
+ 'node_add_list' => array(
+ 'variables' => array('content' => NULL),
+ 'file' => 'node.pages.inc',
+ ),
+ 'node_preview' => array(
+ 'variables' => array('node' => NULL),
+ 'file' => 'node.pages.inc',
+ ),
+ 'node_admin_overview' => array(
+ 'variables' => array('name' => NULL, 'type' => NULL),
+ ),
+ 'node_recent_block' => array(
+ 'variables' => array('nodes' => NULL),
+ ),
+ 'node_recent_content' => array(
+ 'variables' => array('node' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_cron().
+ */
+function node_cron() {
+ db_delete('history')
+ ->condition('timestamp', NODE_NEW_LIMIT, '<')
+ ->execute();
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function node_entity_info() {
+ $return = array(
+ 'node' => array(
+ 'label' => t('Node'),
+ 'controller class' => 'NodeController',
+ 'base table' => 'node',
+ 'revision table' => 'node_revision',
+ 'uri callback' => 'node_uri',
+ 'fieldable' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'nid',
+ 'revision' => 'vid',
+ 'bundle' => 'type',
+ 'label' => 'title',
+ ),
+ 'bundle keys' => array(
+ 'bundle' => 'type',
+ ),
+ 'bundles' => array(),
+ 'view modes' => array(
+ 'full' => array(
+ 'label' => t('Full content'),
+ 'custom settings' => FALSE,
+ ),
+ 'teaser' => array(
+ 'label' => t('Teaser'),
+ 'custom settings' => TRUE,
+ ),
+ 'rss' => array(
+ 'label' => t('RSS'),
+ 'custom settings' => FALSE,
+ ),
+ ),
+ ),
+ );
+
+ // Search integration is provided by node.module, so search-related
+ // view modes for nodes are defined here and not in search.module.
+ if (module_exists('search')) {
+ $return['node']['view modes'] += array(
+ 'search_index' => array(
+ 'label' => t('Search index'),
+ 'custom settings' => FALSE,
+ ),
+ 'search_result' => array(
+ 'label' => t('Search result'),
+ 'custom settings' => FALSE,
+ ),
+ );
+ }
+
+ // Bundles must provide a human readable name so we can create help and error
+ // messages, and the path to attach Field admin pages to.
+ foreach (node_type_get_names() as $type => $name) {
+ $return['node']['bundles'][$type] = array(
+ 'label' => $name,
+ 'admin' => array(
+ 'path' => 'admin/structure/types/manage/%node_type',
+ 'real path' => 'admin/structure/types/manage/' . str_replace('_', '-', $type),
+ 'bundle argument' => 4,
+ 'access arguments' => array('administer content types'),
+ ),
+ );
+ }
+
+ return $return;
+}
+
+/**
+ * Implements hook_field_display_ENTITY_TYPE_alter().
+ */
+function node_field_display_node_alter(&$display, $context) {
+ // Hide field labels in search index.
+ if ($context['view_mode'] == 'search_index') {
+ $display['label'] = 'hidden';
+ }
+}
+
+/**
+ * Entity uri callback.
+ */
+function node_uri($node) {
+ return array(
+ 'path' => 'node/' . $node->nid,
+ );
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function node_admin_paths() {
+ if (variable_get('node_admin_theme')) {
+ $paths = array(
+ 'node/*/edit' => TRUE,
+ 'node/*/delete' => TRUE,
+ 'node/*/revisions' => TRUE,
+ 'node/*/revisions/*/revert' => TRUE,
+ 'node/*/revisions/*/delete' => TRUE,
+ 'node/add' => TRUE,
+ 'node/add/*' => TRUE,
+ );
+ return $paths;
+ }
+}
+
+/**
+ * Gathers a listing of links to nodes.
+ *
+ * @param $result
+ * A database result object from a query to fetch node entities. If your
+ * query joins the {node_comment_statistics} table so that the comment_count
+ * field is available, a title attribute will be added to show the number of
+ * comments.
+ * @param $title
+ * A heading for the resulting list.
+ *
+ * @return
+ * A renderable array containing a list of linked node titles fetched from
+ * $result, or FALSE if there are no rows in $result.
+ */
+function node_title_list($result, $title = NULL) {
+ $items = array();
+ $num_rows = FALSE;
+ foreach ($result as $node) {
+ $items[] = l($node->title, 'node/' . $node->nid, !empty($node->comment_count) ? array('attributes' => array('title' => format_plural($node->comment_count, '1 comment', '@count comments'))) : array());
+ $num_rows = TRUE;
+ }
+
+ return $num_rows ? array('#theme' => 'item_list__node', '#items' => $items, '#title' => $title) : FALSE;
+}
+
+/**
+ * Update the 'last viewed' timestamp of the specified node for current user.
+ *
+ * @param $node
+ * A node object.
+ */
+function node_tag_new($node) {
+ global $user;
+ if ($user->uid) {
+ db_merge('history')
+ ->key(array(
+ 'uid' => $user->uid,
+ 'nid' => $node->nid,
+ ))
+ ->fields(array('timestamp' => REQUEST_TIME))
+ ->execute();
+ }
+}
+
+/**
+ * Retrieves the timestamp at which the current user last viewed the
+ * specified node.
+ */
+function node_last_viewed($nid) {
+ global $user;
+ $history = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($history[$nid])) {
+ $history[$nid] = db_query("SELECT timestamp FROM {history} WHERE uid = :uid AND nid = :nid", array(':uid' => $user->uid, ':nid' => $nid))->fetchObject();
+ }
+
+ return (isset($history[$nid]->timestamp) ? $history[$nid]->timestamp : 0);
+}
+
+/**
+ * Decide on the type of marker to be displayed for a given node.
+ *
+ * @param $nid
+ * Node ID whose history supplies the "last viewed" timestamp.
+ * @param $timestamp
+ * Time which is compared against node's "last viewed" timestamp.
+ * @return
+ * One of the MARK constants.
+ */
+function node_mark($nid, $timestamp) {
+ global $user;
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ if (!$user->uid) {
+ return MARK_READ;
+ }
+ if (!isset($cache[$nid])) {
+ $cache[$nid] = node_last_viewed($nid);
+ }
+ if ($cache[$nid] == 0 && $timestamp > NODE_NEW_LIMIT) {
+ return MARK_NEW;
+ }
+ elseif ($timestamp > $cache[$nid] && $timestamp > NODE_NEW_LIMIT) {
+ return MARK_UPDATED;
+ }
+ return MARK_READ;
+}
+
+/**
+ * Extract the type name.
+ *
+ * @param $node
+ * Either a string or object, containing the node type information.
+ *
+ * @return
+ * Node type of the passed-in data.
+ */
+function _node_extract_type($node) {
+ return is_object($node) ? $node->type : $node;
+}
+
+/**
+ * Returns a list of all the available node types.
+ *
+ * This list can include types that are queued for addition or deletion.
+ * See _node_types_build() for details.
+ *
+ * @return
+ * An array of node types, as objects, keyed by the type.
+ *
+ * @see node_type_get_type()
+ */
+function node_type_get_types() {
+ return _node_types_build()->types;
+}
+
+/**
+ * Returns the node type of the passed node or node type string.
+ *
+ * @param $node
+ * A node object or string that indicates the node type to return.
+ *
+ * @return
+ * A single node type, as an object, or FALSE if the node type is not found.
+ * The node type is an object containing fields from hook_node_info() return
+ * values, as well as the field 'type' (the machine-readable type) and other
+ * fields used internally and defined in _node_types_build(),
+ * hook_node_info(), and node_type_set_defaults().
+ */
+function node_type_get_type($node) {
+ $type = _node_extract_type($node);
+ $types = _node_types_build()->types;
+ return isset($types[$type]) ? $types[$type] : FALSE;
+}
+
+/**
+ * Returns the node type base of the passed node or node type string.
+ *
+ * The base indicates which module implements this node type and is used to
+ * execute node-type-specific hooks. For types defined in the user interface
+ * and managed by node.module, the base is 'node_content'.
+ *
+ * @param $node
+ * A node object or string that indicates the node type to return.
+ *
+ * @return
+ * The node type base or FALSE if the node type is not found.
+ *
+ * @see node_invoke()
+ */
+function node_type_get_base($node) {
+ $type = _node_extract_type($node);
+ $types = _node_types_build()->types;
+ return isset($types[$type]) && isset($types[$type]->base) ? $types[$type]->base : FALSE;
+}
+
+/**
+ * Returns a list of available node type names.
+ *
+ * This list can include types that are queued for addition or deletion.
+ * See _node_types_build() for details.
+ *
+ * @return
+ * An array of node type names, keyed by the type.
+ */
+function node_type_get_names() {
+ return _node_types_build()->names;
+}
+
+/**
+ * Returns the node type name of the passed node or node type string.
+ *
+ * @param $node
+ * A node object or string that indicates the node type to return.
+ *
+ * @return
+ * The node type name or FALSE if the node type is not found.
+ */
+function node_type_get_name($node) {
+ $type = _node_extract_type($node);
+ $types = _node_types_build()->names;
+ return isset($types[$type]) ? $types[$type] : FALSE;
+}
+
+/**
+ * Updates the database cache of node types.
+ *
+ * All new module-defined node types are saved to the database via a call to
+ * node_type_save(), and obsolete ones are deleted via a call to
+ * node_type_delete(). See _node_types_build() for an explanation of the new
+ * and obsolete types.
+ */
+function node_types_rebuild() {
+ _node_types_build(TRUE);
+}
+
+/**
+ * Menu argument loader: loads a node type by string.
+ *
+ * @param $name
+ * The machine-readable name of a node type to load, where '_' is replaced
+ * with '-'.
+ *
+ * @return
+ * A node type object or FALSE if $name does not exist.
+ */
+function node_type_load($name) {
+ return node_type_get_type(strtr($name, array('-' => '_')));
+}
+
+/**
+ * Saves a node type to the database.
+ *
+ * @param $info
+ * The node type to save, as an object.
+ *
+ * @return
+ * Status flag indicating outcome of the operation.
+ */
+function node_type_save($info) {
+ $existing_type = !empty($info->old_type) ? $info->old_type : $info->type;
+ $is_existing = (bool) db_query_range('SELECT 1 FROM {node_type} WHERE type = :type', 0, 1, array(':type' => $existing_type))->fetchField();
+ $type = node_type_set_defaults($info);
+
+ $fields = array(
+ 'type' => (string) $type->type,
+ 'name' => (string) $type->name,
+ 'base' => (string) $type->base,
+ 'has_title' => (int) $type->has_title,
+ 'title_label' => (string) $type->title_label,
+ 'description' => (string) $type->description,
+ 'help' => (string) $type->help,
+ 'custom' => (int) $type->custom,
+ 'modified' => (int) $type->modified,
+ 'locked' => (int) $type->locked,
+ 'disabled' => (int) $type->disabled,
+ 'module' => $type->module,
+ );
+
+ if ($is_existing) {
+ db_update('node_type')
+ ->fields($fields)
+ ->condition('type', $existing_type)
+ ->execute();
+
+ if (!empty($type->old_type) && $type->old_type != $type->type) {
+ field_attach_rename_bundle('node', $type->old_type, $type->type);
+ }
+ module_invoke_all('node_type_update', $type);
+ $status = SAVED_UPDATED;
+ }
+ else {
+ $fields['orig_type'] = (string) $type->orig_type;
+ db_insert('node_type')
+ ->fields($fields)
+ ->execute();
+
+ field_attach_create_bundle('node', $type->type);
+
+ module_invoke_all('node_type_insert', $type);
+ $status = SAVED_NEW;
+ }
+
+ // Clear the node type cache.
+ node_type_cache_reset();
+
+ return $status;
+}
+
+/**
+ * Add default body field to a node type.
+ *
+ * @param $type
+ * A node type object.
+ * @param $label
+ * The label for the body instance.
+ *
+ * @return
+ * Body field instance.
+ */
+function node_add_body_field($type, $label = 'Body') {
+ // Add or remove the body field, as needed.
+ $field = field_info_field('body');
+ $instance = field_info_instance('node', 'body', $type->type);
+ if (empty($field)) {
+ $field = array(
+ 'field_name' => 'body',
+ 'type' => 'text_with_summary',
+ 'entity_types' => array('node'),
+ );
+ $field = field_create_field($field);
+ }
+ if (empty($instance)) {
+ $instance = array(
+ 'field_name' => 'body',
+ 'entity_type' => 'node',
+ 'bundle' => $type->type,
+ 'label' => $label,
+ 'widget' => array('type' => 'text_textarea_with_summary'),
+ 'settings' => array('display_summary' => TRUE),
+ 'display' => array(
+ 'default' => array(
+ 'label' => 'hidden',
+ 'type' => 'text_default',
+ ),
+ 'teaser' => array(
+ 'label' => 'hidden',
+ 'type' => 'text_summary_or_trimmed',
+ ),
+ ),
+ );
+ $instance = field_create_instance($instance);
+ }
+ return $instance;
+}
+
+/**
+ * Implements hook_field_extra_fields().
+ */
+function node_field_extra_fields() {
+ $extra = array();
+
+ foreach (node_type_get_types() as $type) {
+ if ($type->has_title) {
+ $extra['node'][$type->type] = array(
+ 'form' => array(
+ 'title' => array(
+ 'label' => $type->title_label,
+ 'description' => t('Node module element'),
+ 'weight' => -5,
+ ),
+ ),
+ );
+ }
+ }
+
+ return $extra;
+}
+
+/**
+ * Deletes a node type from the database.
+ *
+ * @param $type
+ * The machine-readable name of the node type to be deleted.
+ */
+function node_type_delete($type) {
+ $info = node_type_get_type($type);
+ db_delete('node_type')
+ ->condition('type', $type)
+ ->execute();
+ field_attach_delete_bundle('node', $type);
+ module_invoke_all('node_type_delete', $info);
+
+ // Clear the node type cache.
+ node_type_cache_reset();
+}
+
+/**
+ * Updates all nodes of one type to be of another type.
+ *
+ * @param $old_type
+ * The current node type of the nodes.
+ * @param $type
+ * The new node type of the nodes.
+ *
+ * @return
+ * The number of nodes whose node type field was modified.
+ */
+function node_type_update_nodes($old_type, $type) {
+ return db_update('node')
+ ->fields(array('type' => $type))
+ ->condition('type', $old_type)
+ ->execute();
+}
+
+/**
+ * Builds and returns the list of available node types.
+ *
+ * The list of types is built by invoking hook_node_info() on all modules and
+ * comparing this information with the node types in the {node_type} table.
+ * These two information sources are not synchronized during module installation
+ * until node_types_rebuild() is called.
+ *
+ * @param $rebuild
+ * TRUE to rebuild node types. Equivalent to calling node_types_rebuild().
+ * @return
+ * Associative array with two components:
+ * - names: Associative array of the names of node types, keyed by the type.
+ * - types: Associative array of node type objects, keyed by the type.
+ * Both of these arrays will include new types that have been defined by
+ * hook_node_info() implementations but not yet saved in the {node_type}
+ * table. These are indicated in the type object by $type->is_new being set
+ * to the value 1. These arrays will also include obsolete types: types that
+ * were previously defined by modules that have now been disabled, or for
+ * whatever reason are no longer being defined in hook_node_info()
+ * implementations, but are still in the database. These are indicated in the
+ * type object by $type->disabled being set to TRUE.
+ */
+function _node_types_build($rebuild = FALSE) {
+ $cid = 'node_types:' . $GLOBALS['language']->language;
+
+ if (!$rebuild) {
+ $_node_types = &drupal_static(__FUNCTION__);
+ if (isset($_node_types)) {
+ return $_node_types;
+ }
+ if ($cache = cache()->get($cid)) {
+ $_node_types = $cache->data;
+ return $_node_types;
+ }
+ }
+
+ $_node_types = (object) array('types' => array(), 'names' => array());
+
+ foreach (module_implements('node_info') as $module) {
+ $info_array = module_invoke($module, 'node_info');
+ foreach ($info_array as $type => $info) {
+ $info['type'] = $type;
+ $_node_types->types[$type] = node_type_set_defaults($info);
+ $_node_types->types[$type]->module = $module;
+ $_node_types->names[$type] = $info['name'];
+ }
+ }
+ $query = db_select('node_type', 'nt')
+ ->addTag('translatable')
+ ->addTag('node_type_access')
+ ->fields('nt')
+ ->orderBy('nt.type', 'ASC');
+ if (!$rebuild) {
+ $query->condition('disabled', 0);
+ }
+ foreach ($query->execute() as $type_object) {
+ $type_db = $type_object->type;
+ // Original disabled value.
+ $disabled = $type_object->disabled;
+ // Check for node types from disabled modules and mark their types for removal.
+ // Types defined by the node module in the database (rather than by a separate
+ // module using hook_node_info) have a base value of 'node_content'. The isset()
+ // check prevents errors on old (pre-Drupal 7) databases.
+ if (isset($type_object->base) && $type_object->base != 'node_content' && empty($_node_types->types[$type_db])) {
+ $type_object->disabled = TRUE;
+ }
+ if (isset($_node_types->types[$type_db])) {
+ $type_object->disabled = FALSE;
+ }
+ if (!isset($_node_types->types[$type_db]) || $type_object->modified) {
+ $_node_types->types[$type_db] = $type_object;
+ $_node_types->names[$type_db] = $type_object->name;
+
+ if ($type_db != $type_object->orig_type) {
+ unset($_node_types->types[$type_object->orig_type]);
+ unset($_node_types->names[$type_object->orig_type]);
+ }
+ }
+ $_node_types->types[$type_db]->disabled = $type_object->disabled;
+ $_node_types->types[$type_db]->disabled_changed = $disabled != $type_object->disabled;
+ }
+
+ if ($rebuild) {
+ foreach ($_node_types->types as $type => $type_object) {
+ if (!empty($type_object->is_new) || !empty($type_object->disabled_changed)) {
+ node_type_save($type_object);
+ }
+ }
+ }
+
+ asort($_node_types->names);
+
+ cache()->set($cid, $_node_types);
+
+ return $_node_types;
+}
+
+/**
+ * Clears the node type cache.
+ */
+function node_type_cache_reset() {
+ cache()->deletePrefix('node_types:');
+ drupal_static_reset('_node_types_build');
+}
+
+/**
+ * Sets the default values for a node type.
+ *
+ * The defaults are appropriate for a type defined through hook_node_info(),
+ * since 'custom' is TRUE for types defined in the user interface, and FALSE
+ * for types defined by modules. (The 'custom' flag prevents types from being
+ * deleted through the user interface.) Also, the default for 'locked' is TRUE,
+ * which prevents users from changing the machine name of the type.
+ *
+ * @param $info
+ * An object or array containing values to override the defaults. See
+ * hook_node_info() for details on what the array elements mean.
+ *
+ * @return
+ * A node type object, with missing values in $info set to their defaults.
+ */
+function node_type_set_defaults($info = array()) {
+ $info = (array) $info;
+ $new_type = $info + array(
+ 'type' => '',
+ 'name' => '',
+ 'base' => '',
+ 'description' => '',
+ 'help' => '',
+ 'custom' => 0,
+ 'modified' => 0,
+ 'locked' => 1,
+ 'disabled' => 0,
+ 'is_new' => 1,
+ 'has_title' => 1,
+ 'title_label' => 'Title',
+ );
+ $new_type = (object) $new_type;
+
+ // If the type has no title, set an empty label.
+ if (!$new_type->has_title) {
+ $new_type->title_label = '';
+ }
+ if (empty($new_type->module)) {
+ $new_type->module = $new_type->base == 'node_content' ? 'node' : '';
+ }
+ $new_type->orig_type = isset($info['type']) ? $info['type'] : '';
+
+ return $new_type;
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function node_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'node',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Item', 'foaf:Document'),
+ 'title' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'created' => array(
+ 'predicates' => array('dc:date', 'dc:created'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'changed' => array(
+ 'predicates' => array('dc:modified'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'body' => array(
+ 'predicates' => array('content:encoded'),
+ ),
+ 'uid' => array(
+ 'predicates' => array('sioc:has_creator'),
+ 'type' => 'rel',
+ ),
+ 'name' => array(
+ 'predicates' => array('foaf:name'),
+ ),
+ 'comment_count' => array(
+ 'predicates' => array('sioc:num_replies'),
+ 'datatype' => 'xsd:integer',
+ ),
+ 'last_activity' => array(
+ 'predicates' => array('sioc:last_activity_date'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Determine whether a node hook exists.
+ *
+ * @param $node
+ * A node object or a string containing the node type.
+ * @param $hook
+ * A string containing the name of the hook.
+ * @return
+ * TRUE if the $hook exists in the node type of $node.
+ */
+function node_hook($node, $hook) {
+ $base = node_type_get_base($node);
+ return module_hook($base, $hook);
+}
+
+/**
+ * Invoke a node hook.
+ *
+ * @param $node
+ * A node object or a string containing the node type.
+ * @param $hook
+ * A string containing the name of the hook.
+ * @param $a2, $a3, $a4
+ * Arguments to pass on to the hook, after the $node argument.
+ * @return
+ * The returned value of the invoked hook.
+ */
+function node_invoke($node, $hook, $a2 = NULL, $a3 = NULL, $a4 = NULL) {
+ if (node_hook($node, $hook)) {
+ $base = node_type_get_base($node);
+ $function = $base . '_' . $hook;
+ return ($function($node, $a2, $a3, $a4));
+ }
+}
+
+/**
+ * Load node entities from the database.
+ *
+ * This function should be used whenever you need to load more than one node
+ * from the database. Nodes are loaded into memory and will not require
+ * database access if loaded again during the same page request.
+ *
+ * @see entity_load()
+ * @see EntityFieldQuery
+ *
+ * @param $nids
+ * An array of node IDs.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the {node}
+ * table, where the keys are the database fields and the values are the
+ * values those fields must have. Instead, it is preferable to use
+ * EntityFieldQuery to retrieve a list of entity IDs loadable by
+ * this function.
+ * @param $reset
+ * Whether to reset the internal node_load cache.
+ *
+ * @return
+ * An array of node objects indexed by nid.
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function node_load_multiple($nids = array(), $conditions = array(), $reset = FALSE) {
+ return entity_load('node', $nids, $conditions, $reset);
+}
+
+/**
+ * Load a node object from the database.
+ *
+ * @param $nid
+ * The node ID.
+ * @param $vid
+ * The revision ID.
+ * @param $reset
+ * Whether to reset the node_load_multiple cache.
+ *
+ * @return
+ * A fully-populated node object.
+ */
+function node_load($nid = NULL, $vid = NULL, $reset = FALSE) {
+ $nids = (isset($nid) ? array($nid) : array());
+ $conditions = (isset($vid) ? array('vid' => $vid) : array());
+ $node = node_load_multiple($nids, $conditions, $reset);
+ return $node ? reset($node) : FALSE;
+}
+
+/**
+ * Prepares a node object for editing.
+ *
+ * Fills in a few default values, and then invokes hook_prepare() on the node
+ * type module, and hook_node_prepare() on all modules.
+ */
+function node_object_prepare($node) {
+ // Set up default values, if required.
+ $node_options = variable_get('node_options_' . $node->type, array('status', 'promote'));
+ // If this is a new node, fill in the default values.
+ if (!isset($node->nid) || isset($node->is_new)) {
+ foreach (array('status', 'promote', 'sticky') as $key) {
+ // Multistep node forms might have filled in something already.
+ if (!isset($node->$key)) {
+ $node->$key = (int) in_array($key, $node_options);
+ }
+ }
+ global $user;
+ $node->uid = $user->uid;
+ $node->created = REQUEST_TIME;
+ }
+ else {
+ $node->date = format_date($node->created, 'custom', 'Y-m-d H:i:s O');
+ // Remove the log message from the original node object.
+ $node->log = NULL;
+ }
+ // Always use the default revision setting.
+ $node->revision = in_array('revision', $node_options);
+
+ node_invoke($node, 'prepare');
+ module_invoke_all('node_prepare', $node);
+}
+
+/**
+ * Perform validation checks on the given node.
+ */
+function node_validate($node, $form, &$form_state) {
+ $type = node_type_get_type($node);
+
+ if (isset($node->nid) && (node_last_changed($node->nid) > $node->changed)) {
+ form_set_error('changed', t('The content on this page has either been modified by another user, or you have already submitted modifications using this form. As a result, your changes cannot be saved.'));
+ }
+
+ // Validate the "authored by" field.
+ if (!empty($node->name) && !($account = user_load_by_name($node->name))) {
+ // The use of empty() is mandatory in the context of usernames
+ // as the empty string denotes the anonymous user. In case we
+ // are dealing with an anonymous user we set the user ID to 0.
+ form_set_error('name', t('The username %name does not exist.', array('%name' => $node->name)));
+ }
+
+ // Validate the "authored on" field.
+ if (!empty($node->date) && strtotime($node->date) === FALSE) {
+ form_set_error('date', t('You have to specify a valid date.'));
+ }
+
+ // Invoke hook_validate() for node type specific validation and
+ // hook_node_validate() for miscellaneous validation needed by modules. Can't
+ // use node_invoke() or module_invoke_all(), because $form_state must be
+ // receivable by reference.
+ $function = node_type_get_base($node) . '_validate';
+ if (function_exists($function)) {
+ $function($node, $form, $form_state);
+ }
+ foreach (module_implements('node_validate') as $module) {
+ $function = $module . '_node_validate';
+ $function($node, $form, $form_state);
+ }
+}
+
+/**
+ * Prepare node for saving by populating author and creation date.
+ */
+function node_submit($node) {
+ global $user;
+
+ // A user might assign the node author by entering a user name in the node
+ // form, which we then need to translate to a user ID.
+ if (isset($node->name)) {
+ if ($account = user_load_by_name($node->name)) {
+ $node->uid = $account->uid;
+ }
+ else {
+ $node->uid = 0;
+ }
+ }
+
+ $node->created = !empty($node->date) ? strtotime($node->date) : REQUEST_TIME;
+ $node->validated = TRUE;
+
+ return $node;
+}
+
+/**
+ * Save changes to a node or add a new node.
+ *
+ * @param $node
+ * The $node object to be saved. If $node->nid is
+ * omitted (or $node->is_new is TRUE), a new node will be added.
+ */
+function node_save($node) {
+ $transaction = db_transaction();
+
+ try {
+ // Load the stored entity, if any.
+ if (!empty($node->nid) && !isset($node->original)) {
+ $node->original = entity_load_unchanged('node', $node->nid);
+ }
+
+ field_attach_presave('node', $node);
+ global $user;
+
+ // Determine if we will be inserting a new node.
+ if (!isset($node->is_new)) {
+ $node->is_new = empty($node->nid);
+ }
+
+ // Set the timestamp fields.
+ if (empty($node->created)) {
+ $node->created = REQUEST_TIME;
+ }
+ // The changed timestamp is always updated for bookkeeping purposes,
+ // for example: revisions, searching, etc.
+ $node->changed = REQUEST_TIME;
+
+ $node->timestamp = REQUEST_TIME;
+ $update_node = TRUE;
+
+ // Let modules modify the node before it is saved to the database.
+ module_invoke_all('node_presave', $node);
+ module_invoke_all('entity_presave', $node, 'node');
+
+ if ($node->is_new || !empty($node->revision)) {
+ // When inserting either a new node or a new node revision, $node->log
+ // must be set because {node_revision}.log is a text column and therefore
+ // cannot have a default value. However, it might not be set at this
+ // point (for example, if the user submitting a node form does not have
+ // permission to create revisions), so we ensure that it is at least an
+ // empty string in that case.
+ // @todo: Make the {node_revision}.log column nullable so that we can
+ // remove this check.
+ if (!isset($node->log)) {
+ $node->log = '';
+ }
+ }
+ elseif (!isset($node->log) || $node->log === '') {
+ // If we are updating an existing node without adding a new revision, we
+ // need to make sure $node->log is unset whenever it is empty. As long as
+ // $node->log is unset, drupal_write_record() will not attempt to update
+ // the existing database column when re-saving the revision; therefore,
+ // this code allows us to avoid clobbering an existing log entry with an
+ // empty one.
+ unset($node->log);
+ }
+
+ // When saving a new node revision, unset any existing $node->vid so as to
+ // ensure that a new revision will actually be created, then store the old
+ // revision ID in a separate property for use by node hook implementations.
+ if (!$node->is_new && !empty($node->revision) && $node->vid) {
+ $node->old_vid = $node->vid;
+ unset($node->vid);
+ }
+
+ // Save the node and node revision.
+ if ($node->is_new) {
+ // For new nodes, save new records for both the node itself and the node
+ // revision.
+ drupal_write_record('node', $node);
+ _node_save_revision($node, $user->uid);
+ $op = 'insert';
+ }
+ else {
+ // For existing nodes, update the node record which matches the value of
+ // $node->nid.
+ drupal_write_record('node', $node, 'nid');
+ // Then, if a new node revision was requested, save a new record for
+ // that; otherwise, update the node revision record which matches the
+ // value of $node->vid.
+ if (!empty($node->revision)) {
+ _node_save_revision($node, $user->uid);
+ }
+ else {
+ _node_save_revision($node, $user->uid, 'vid');
+ $update_node = FALSE;
+ }
+ $op = 'update';
+ }
+ if ($update_node) {
+ db_update('node')
+ ->fields(array('vid' => $node->vid))
+ ->condition('nid', $node->nid)
+ ->execute();
+ }
+
+ // Call the node specific callback (if any). This can be
+ // node_invoke($node, 'insert') or
+ // node_invoke($node, 'update').
+ node_invoke($node, $op);
+
+ // Save fields.
+ $function = "field_attach_$op";
+ $function('node', $node);
+
+ module_invoke_all('node_' . $op, $node);
+ module_invoke_all('entity_' . $op, $node, 'node');
+
+ // Update the node access table for this node. There's no need to delete
+ // existing records if the node is new.
+ $delete = $op == 'update';
+ node_access_acquire_grants($node, $delete);
+
+ // Clear internal properties.
+ unset($node->is_new);
+ unset($node->original);
+ // Clear the static loading cache.
+ entity_get_controller('node')->resetCache(array($node->nid));
+
+ // Ignore slave server temporarily to give time for the
+ // saved node to be propagated to the slave.
+ db_ignore_slave();
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('node', $e);
+ throw $e;
+ }
+}
+
+/**
+ * Helper function to save a revision with the uid of the current user.
+ *
+ * The resulting revision ID is available afterward in $node->vid.
+ */
+function _node_save_revision($node, $uid, $update = NULL) {
+ $temp_uid = $node->uid;
+ $node->uid = $uid;
+ if (isset($update)) {
+ drupal_write_record('node_revision', $node, $update);
+ }
+ else {
+ drupal_write_record('node_revision', $node);
+ }
+ // Have node object still show node owner's uid, not revision author's.
+ $node->uid = $temp_uid;
+}
+
+/**
+ * Delete a node.
+ *
+ * @param $nid
+ * A node ID.
+ */
+function node_delete($nid) {
+ node_delete_multiple(array($nid));
+}
+
+/**
+ * Delete multiple nodes.
+ *
+ * @param $nids
+ * An array of node IDs.
+ */
+function node_delete_multiple($nids) {
+ $transaction = db_transaction();
+ if (!empty($nids)) {
+ $nodes = node_load_multiple($nids, array());
+
+ try {
+ foreach ($nodes as $nid => $node) {
+ // Call the node-specific callback (if any):
+ node_invoke($node, 'delete');
+ module_invoke_all('node_delete', $node);
+ module_invoke_all('entity_delete', $node, 'node');
+ field_attach_delete('node', $node);
+
+ // Remove this node from the search index if needed.
+ // This code is implemented in node module rather than in search module,
+ // because node module is implementing search module's API, not the other
+ // way around.
+ if (module_exists('search')) {
+ search_reindex($nid, 'node');
+ }
+ }
+
+ // Delete after calling hooks so that they can query node tables as needed.
+ db_delete('node')
+ ->condition('nid', $nids, 'IN')
+ ->execute();
+ db_delete('node_revision')
+ ->condition('nid', $nids, 'IN')
+ ->execute();
+ db_delete('history')
+ ->condition('nid', $nids, 'IN')
+ ->execute();
+ db_delete('node_access')
+ ->condition('nid', $nids, 'IN')
+ ->execute();
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('node', $e);
+ throw $e;
+ }
+
+ // Clear the page and block and node_load_multiple caches.
+ entity_get_controller('node')->resetCache();
+ }
+}
+
+/**
+ * Delete a node revision.
+ *
+ * @param $revision_id
+ * The revision ID to delete.
+ */
+function node_revision_delete($revision_id) {
+ if ($revision = node_load(NULL, $revision_id)) {
+ // Prevent deleting the current revision.
+ $node = node_load($revision->nid);
+ if ($revision_id == $node->vid) {
+ return FALSE;
+ }
+
+ db_delete('node_revision')
+ ->condition('nid', $revision->nid)
+ ->condition('vid', $revision->vid)
+ ->execute();
+ module_invoke_all('node_revision_delete', $revision);
+ field_attach_delete_revision('node', $revision);
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Generate an array for rendering the given node.
+ *
+ * @param $node
+ * A node object.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return
+ * An array as expected by drupal_render().
+ */
+function node_view($node, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Populate $node->content with a render() array.
+ node_build_content($node, $view_mode, $langcode);
+
+ $build = $node->content;
+ // We don't need duplicate rendering info in node->content.
+ unset($node->content);
+
+ $build += array(
+ '#theme' => 'node',
+ '#node' => $node,
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ );
+
+ // Add contextual links for this node, except when the node is already being
+ // displayed on its own page. Modules may alter this behavior (for example,
+ // to restrict contextual links to certain view modes) by implementing
+ // hook_node_view_alter().
+ if (!empty($node->nid) && !($view_mode == 'full' && node_is_page($node))) {
+ $build['#contextual_links']['node'] = array('node', array($node->nid));
+ }
+
+ // Allow modules to modify the structured node.
+ $type = 'node';
+ drupal_alter(array('node_view', 'entity_view'), $build, $type);
+
+ return $build;
+}
+
+/**
+ * Builds a structured array representing the node's content.
+ *
+ * The content built for the node (field values, comments, file attachments or
+ * other node components) will vary depending on the $view_mode parameter.
+ *
+ * Drupal core defines the following view modes for nodes, with the following
+ * default use cases:
+ * - full (default): node is being displayed on its own page (node/123)
+ * - teaser: node is being displayed on the default home page listing, or on
+ * taxonomy listing pages.
+ * - rss: node displayed in an RSS feed.
+ * If search.module is enabled:
+ * - search_index: node is being indexed for search.
+ * - search_result: node is being displayed as a search result.
+ * If book.module is enabled:
+ * - print: node is being displayed in print-friendly mode.
+ * Contributed modules might define additional view modes, or use existing
+ * view modes in additional contexts.
+ *
+ * @param $node
+ * A node object.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ */
+function node_build_content($node, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Remove previously built content, if exists.
+ $node->content = array();
+
+ // The 'view' hook can be implemented to overwrite the default function
+ // to display nodes.
+ if (node_hook($node, 'view')) {
+ $node = node_invoke($node, 'view', $view_mode, $langcode);
+ }
+
+ // Build fields content.
+ // In case of a multiple view, node_view_multiple() already ran the
+ // 'prepare_view' step. An internal flag prevents the operation from running
+ // twice.
+ field_attach_prepare_view('node', array($node->nid => $node), $view_mode, $langcode);
+ entity_prepare_view('node', array($node->nid => $node), $langcode);
+ $node->content += field_attach_view('node', $node, $view_mode, $langcode);
+
+ // Always display a read more link on teasers because we have no way
+ // to know when a teaser view is different than a full view.
+ $links = array();
+ $node->content['links'] = array(
+ '#theme' => 'links__node',
+ '#pre_render' => array('drupal_pre_render_links'),
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ if ($view_mode == 'teaser') {
+ $node_title_stripped = strip_tags($node->title);
+ $links['node-readmore'] = array(
+ 'title' => t('Read more<span class="element-invisible"> about @title</span>', array('@title' => $node_title_stripped)),
+ 'href' => 'node/' . $node->nid,
+ 'html' => TRUE,
+ 'attributes' => array('rel' => 'tag', 'title' => $node_title_stripped),
+ );
+ }
+ $node->content['links']['node'] = array(
+ '#theme' => 'links__node__node',
+ '#links' => $links,
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+
+ // Allow modules to make their own additions to the node.
+ module_invoke_all('node_view', $node, $view_mode, $langcode);
+ module_invoke_all('entity_view', $node, 'node', $view_mode, $langcode);
+}
+
+/**
+ * Generate an array which displays a node detail page.
+ *
+ * @param $node
+ * A node object.
+ * @param $message
+ * A flag which sets a page title relevant to the revision being viewed.
+ * @return
+ * A $page element suitable for use by drupal_page_render().
+ */
+function node_show($node, $message = FALSE) {
+ if ($message) {
+ drupal_set_title(t('Revision of %title from %date', array('%title' => $node->title, '%date' => format_date($node->revision_timestamp))), PASS_THROUGH);
+ }
+
+ // For markup consistency with other pages, use node_view_multiple() rather than node_view().
+ $nodes = node_view_multiple(array($node->nid => $node), 'full');
+
+ // Update the history table, stating that this user viewed this node.
+ node_tag_new($node);
+
+ return $nodes;
+}
+
+/**
+ * Returns whether the current page is the full page view of the passed-in node.
+ *
+ * @param $node
+ * A node object.
+ */
+function node_is_page($node) {
+ $page_node = menu_get_object();
+ return (!empty($page_node) ? $page_node->nid == $node->nid : FALSE);
+}
+
+/**
+ * Process variables for node.tpl.php
+ *
+ * Most themes utilize their own copy of node.tpl.php. The default is located
+ * inside "modules/node/node.tpl.php". Look in there for the full list of
+ * variables.
+ *
+ * The $variables array contains the following arguments:
+ * - $node
+ * - $view_mode
+ * - $page
+ *
+ * @see node.tpl.php
+ */
+function template_preprocess_node(&$variables) {
+ $variables['view_mode'] = $variables['elements']['#view_mode'];
+ // Provide a distinct $teaser boolean.
+ $variables['teaser'] = $variables['view_mode'] == 'teaser';
+ $variables['node'] = $variables['elements']['#node'];
+ $node = $variables['node'];
+
+ $variables['date'] = format_date($node->created);
+ $variables['name'] = theme('username', array('account' => $node));
+
+ $uri = entity_uri('node', $node);
+ $variables['node_url'] = url($uri['path'], $uri['options']);
+ $variables['title'] = check_plain($node->title);
+ $variables['page'] = $variables['view_mode'] == 'full' && node_is_page($node);
+
+ // Flatten the node object's member fields.
+ $variables = array_merge((array) $node, $variables);
+
+ // Helpful $content variable for templates.
+ $variables += array('content' => array());
+ foreach (element_children($variables['elements']) as $key) {
+ $variables['content'][$key] = $variables['elements'][$key];
+ }
+
+ // Make the field variables available with the appropriate language.
+ field_attach_preprocess('node', $node, $variables['content'], $variables);
+
+ // Display post information only on certain node types.
+ if (variable_get('node_submitted_' . $node->type, TRUE)) {
+ $variables['display_submitted'] = TRUE;
+ $variables['submitted'] = t('Submitted by !username on !datetime', array('!username' => $variables['name'], '!datetime' => $variables['date']));
+ $variables['user_picture'] = theme_get_setting('toggle_node_user_picture') ? theme('user_picture', array('account' => $node)) : '';
+ }
+ else {
+ $variables['display_submitted'] = FALSE;
+ $variables['submitted'] = '';
+ $variables['user_picture'] = '';
+ }
+
+ // Gather node classes.
+ $variables['classes_array'][] = drupal_html_class('node-' . $node->type);
+ if ($variables['promote']) {
+ $variables['classes_array'][] = 'node-promoted';
+ }
+ if ($variables['sticky']) {
+ $variables['classes_array'][] = 'node-sticky';
+ }
+ if (!$variables['status']) {
+ $variables['classes_array'][] = 'node-unpublished';
+ }
+ if ($variables['teaser']) {
+ $variables['classes_array'][] = 'node-teaser';
+ }
+ if (isset($variables['preview'])) {
+ $variables['classes_array'][] = 'node-preview';
+ }
+
+ // Clean up name so there are no underscores.
+ $variables['theme_hook_suggestions'][] = 'node__' . $node->type;
+ $variables['theme_hook_suggestions'][] = 'node__' . $node->nid;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function node_permission() {
+ $perms = array(
+ 'bypass node access' => array(
+ 'title' => t('Bypass content access control'),
+ 'description' => t('View, edit and delete all content regardless of permission restrictions.'),
+ 'restrict access' => TRUE,
+ ),
+ 'administer content types' => array(
+ 'title' => t('Administer content types'),
+ 'restrict access' => TRUE,
+ ),
+ 'administer nodes' => array(
+ 'title' => t('Administer content'),
+ 'restrict access' => TRUE,
+ ),
+ 'access content overview' => array(
+ 'title' => t('Access the content overview page'),
+ ),
+ 'access content' => array(
+ 'title' => t('View published content'),
+ ),
+ 'view own unpublished content' => array(
+ 'title' => t('View own unpublished content'),
+ ),
+ 'view revisions' => array(
+ 'title' => t('View content revisions'),
+ ),
+ 'revert revisions' => array(
+ 'title' => t('Revert content revisions'),
+ ),
+ 'delete revisions' => array(
+ 'title' => t('Delete content revisions'),
+ ),
+ );
+
+ // Generate standard node permissions for all applicable node types.
+ foreach (node_permissions_get_configured_types() as $type) {
+ $perms += node_list_permissions($type);
+ }
+
+ return $perms;
+}
+
+/**
+ * Gather the rankings from the the hook_ranking implementations.
+ *
+ * @param $query
+ * A query object that has been extended with the Search DB Extender.
+ */
+function _node_rankings(SelectQueryExtender $query) {
+ if ($ranking = module_invoke_all('ranking')) {
+ $tables = &$query->getTables();
+ foreach ($ranking as $rank => $values) {
+ if ($node_rank = variable_get('node_rank_' . $rank, 0)) {
+ // If the table defined in the ranking isn't already joined, then add it.
+ if (isset($values['join']) && !isset($tables[$values['join']['alias']])) {
+ $query->addJoin($values['join']['type'], $values['join']['table'], $values['join']['alias'], $values['join']['on']);
+ }
+ $arguments = isset($values['arguments']) ? $values['arguments'] : array();
+ $query->addScore($values['score'], $arguments, $node_rank);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_search_info().
+ */
+function node_search_info() {
+ return array(
+ 'title' => 'Content',
+ 'path' => 'node',
+ );
+}
+
+/**
+ * Implements hook_search_access().
+ */
+function node_search_access() {
+ return user_access('access content');
+}
+
+/**
+ * Implements hook_search_reset().
+ */
+function node_search_reset() {
+ db_update('search_dataset')
+ ->fields(array('reindex' => REQUEST_TIME))
+ ->condition('type', 'node')
+ ->execute();
+}
+
+/**
+ * Implements hook_search_status().
+ */
+function node_search_status() {
+ $total = db_query('SELECT COUNT(*) FROM {node}')->fetchField();
+ $remaining = db_query("SELECT COUNT(*) FROM {node} n LEFT JOIN {search_dataset} d ON d.type = 'node' AND d.sid = n.nid WHERE d.sid IS NULL OR d.reindex <> 0")->fetchField();
+ return array('remaining' => $remaining, 'total' => $total);
+}
+
+/**
+ * Implements hook_search_admin().
+ */
+function node_search_admin() {
+ // Output form for defining rank factor weights.
+ $form['content_ranking'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Content ranking'),
+ );
+ $form['content_ranking']['#theme'] = 'node_search_admin';
+ $form['content_ranking']['info'] = array(
+ '#value' => '<em>' . t('The following numbers control which properties the content search should favor when ordering the results. Higher numbers mean more influence, zero means the property is ignored. Changing these numbers does not require the search index to be rebuilt. Changes take effect immediately.') . '</em>'
+ );
+
+ // Note: reversed to reflect that higher number = higher ranking.
+ $options = drupal_map_assoc(range(0, 10));
+ foreach (module_invoke_all('ranking') as $var => $values) {
+ $form['content_ranking']['factors']['node_rank_' . $var] = array(
+ '#title' => $values['title'],
+ '#type' => 'select',
+ '#options' => $options,
+ '#default_value' => variable_get('node_rank_' . $var, 0),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Implements hook_search_execute().
+ */
+function node_search_execute($keys = NULL, $conditions = NULL) {
+ // Build matching conditions
+ $query = db_select('search_index', 'i', array('target' => 'slave'))->extend('SearchQuery')->extend('PagerDefault');
+ $query->join('node', 'n', 'n.nid = i.sid');
+ $query
+ ->condition('n.status', 1)
+ ->addTag('node_access')
+ ->searchExpression($keys, 'node');
+
+ // Insert special keywords.
+ $query->setOption('type', 'n.type');
+ $query->setOption('language', 'n.language');
+ if ($query->setOption('term', 'ti.tid')) {
+ $query->join('taxonomy_index', 'ti', 'n.nid = ti.nid');
+ }
+ // Only continue if the first pass query matches.
+ if (!$query->executeFirstPass()) {
+ return array();
+ }
+
+ // Add the ranking expressions.
+ _node_rankings($query);
+
+ // Load results.
+ $find = $query
+ ->limit(10)
+ ->execute();
+ $results = array();
+ foreach ($find as $item) {
+ // Render the node.
+ $node = node_load($item->sid);
+ $build = node_view($node, 'search_result');
+ unset($build['#theme']);
+ $node->rendered = drupal_render($build);
+
+ // Fetch comments for snippet.
+ $node->rendered .= ' ' . module_invoke('comment', 'node_update_index', $node);
+
+ $extra = module_invoke_all('node_search_result', $node);
+
+ $uri = entity_uri('node', $node);
+ $results[] = array(
+ 'link' => url($uri['path'], array_merge($uri['options'], array('absolute' => TRUE))),
+ 'type' => check_plain(node_type_get_name($node)),
+ 'title' => $node->title,
+ 'user' => theme('username', array('account' => $node)),
+ 'date' => $node->changed,
+ 'node' => $node,
+ 'extra' => $extra,
+ 'score' => $item->calculated_score,
+ 'snippet' => search_excerpt($keys, $node->rendered),
+ 'language' => $node->language,
+ );
+ }
+ return $results;
+}
+
+/**
+ * Implements hook_ranking().
+ */
+function node_ranking() {
+ // Create the ranking array and add the basic ranking options.
+ $ranking = array(
+ 'relevance' => array(
+ 'title' => t('Keyword relevance'),
+ // Average relevance values hover around 0.15
+ 'score' => 'i.relevance',
+ ),
+ 'sticky' => array(
+ 'title' => t('Content is sticky at top of lists'),
+ // The sticky flag is either 0 or 1, which is automatically normalized.
+ 'score' => 'n.sticky',
+ ),
+ 'promote' => array(
+ 'title' => t('Content is promoted to the front page'),
+ // The promote flag is either 0 or 1, which is automatically normalized.
+ 'score' => 'n.promote',
+ ),
+ );
+
+ // Add relevance based on creation or changed date.
+ if ($node_cron_last = variable_get('node_cron_last', 0)) {
+ $ranking['recent'] = array(
+ 'title' => t('Recently posted'),
+ // Exponential decay with half-life of 6 months, starting at last indexed node
+ 'score' => 'POW(2.0, (GREATEST(n.created, n.changed) - :node_cron_last) * 6.43e-8)',
+ 'arguments' => array(':node_cron_last' => $node_cron_last),
+ );
+ }
+ return $ranking;
+}
+
+/**
+ * Implements hook_user_cancel().
+ */
+function node_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_block_unpublish':
+ // Unpublish nodes (current revisions).
+ module_load_include('inc', 'node', 'node.admin');
+ $nodes = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->condition('uid', $account->uid)
+ ->execute()
+ ->fetchCol();
+ node_mass_update($nodes, array('status' => 0));
+ break;
+
+ case 'user_cancel_reassign':
+ // Anonymize nodes (current revisions).
+ module_load_include('inc', 'node', 'node.admin');
+ $nodes = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->condition('uid', $account->uid)
+ ->execute()
+ ->fetchCol();
+ node_mass_update($nodes, array('uid' => 0));
+ // Anonymize old revisions.
+ db_update('node_revision')
+ ->fields(array('uid' => 0))
+ ->condition('uid', $account->uid)
+ ->execute();
+ // Clean history.
+ db_delete('history')
+ ->condition('uid', $account->uid)
+ ->execute();
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function node_user_delete($account) {
+ // Delete nodes (current revisions).
+ // @todo Introduce node_mass_delete() or make node_mass_update() more flexible.
+ $nodes = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->condition('uid', $account->uid)
+ ->execute()
+ ->fetchCol();
+ node_delete_multiple($nodes);
+ // Delete old revisions.
+ $revisions = db_query('SELECT vid FROM {node_revision} WHERE uid = :uid', array(':uid' => $account->uid))->fetchCol();
+ foreach ($revisions as $revision) {
+ node_revision_delete($revision);
+ }
+ // Clean history.
+ db_delete('history')
+ ->condition('uid', $account->uid)
+ ->execute();
+}
+
+/**
+ * Returns HTML for the content ranking part of the search settings admin page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_node_search_admin($variables) {
+ $form = $variables['form'];
+
+ $output = drupal_render($form['info']);
+
+ $header = array(t('Factor'), t('Weight'));
+ foreach (element_children($form['factors']) as $key) {
+ $row = array();
+ $row[] = $form['factors'][$key]['#title'];
+ $form['factors'][$key]['#title_display'] = 'invisible';
+ $row[] = drupal_render($form['factors'][$key]);
+ $rows[] = $row;
+ }
+ $output .= theme('table', array('header' => $header, 'rows' => $rows));
+
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+function _node_revision_access($node, $op = 'view') {
+ $access = &drupal_static(__FUNCTION__, array());
+ if (!isset($access[$node->vid])) {
+ // To save additional calls to the database, return early if the user
+ // doesn't have the required permissions.
+ $map = array('view' => 'view revisions', 'update' => 'revert revisions', 'delete' => 'delete revisions');
+ if (isset($map[$op]) && (!user_access($map[$op]) && !user_access('administer nodes'))) {
+ $access[$node->vid] = FALSE;
+ return FALSE;
+ }
+
+ $node_current_revision = node_load($node->nid);
+ $is_current_revision = $node_current_revision->vid == $node->vid;
+
+ // There should be at least two revisions. If the vid of the given node
+ // and the vid of the current revision differs, then we already have two
+ // different revisions so there is no need for a separate database check.
+ // Also, if you try to revert to or delete the current revision, that's
+ // not good.
+ if ($is_current_revision && (db_query('SELECT COUNT(vid) FROM {node_revision} WHERE nid = :nid', array(':nid' => $node->nid))->fetchField() == 1 || $op == 'update' || $op == 'delete')) {
+ $access[$node->vid] = FALSE;
+ }
+ elseif (user_access('administer nodes')) {
+ $access[$node->vid] = TRUE;
+ }
+ else {
+ // First check the access to the current revision and finally, if the
+ // node passed in is not the current revision then access to that, too.
+ $access[$node->vid] = node_access($op, $node_current_revision) && ($is_current_revision || node_access($op, $node));
+ }
+ }
+ return $access[$node->vid];
+}
+
+function _node_add_access() {
+ $types = node_type_get_types();
+ foreach ($types as $type) {
+ if (node_hook($type->type, 'form') && node_access('create', $type->type)) {
+ return TRUE;
+ }
+ }
+ if (user_access('administer content types')) {
+ // There are no content types defined that the user has permission to create,
+ // but the user does have the permission to administer the content types, so
+ // grant them access to the page anyway.
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function node_menu() {
+ $items['admin/content'] = array(
+ 'title' => 'Content',
+ 'description' => 'Find and manage content.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_admin_content'),
+ 'access arguments' => array('access content overview'),
+ 'weight' => -10,
+ 'file' => 'node.admin.inc',
+ );
+ $items['admin/content/node'] = array(
+ 'title' => 'Content',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+
+ $items['admin/reports/status/rebuild'] = array(
+ 'title' => 'Rebuild permissions',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_configure_rebuild_confirm'),
+ // Any user than can potentially trigger a node_access_needs_rebuild(TRUE)
+ // has to be allowed access to the 'node access rebuild' confirm form.
+ 'access arguments' => array('access administration pages'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'node.admin.inc',
+ );
+
+ $items['admin/structure/types'] = array(
+ 'title' => 'Content types',
+ 'description' => 'Manage content types, including default status, front page promotion, comment settings, etc.',
+ 'page callback' => 'node_overview_types',
+ 'access arguments' => array('administer content types'),
+ 'file' => 'content_types.inc',
+ );
+ $items['admin/structure/types/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/structure/types/add'] = array(
+ 'title' => 'Add content type',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_type_form'),
+ 'access arguments' => array('administer content types'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'content_types.inc',
+ );
+ $items['admin/structure/types/manage/%node_type'] = array(
+ 'title' => 'Edit content type',
+ 'title callback' => 'node_type_page_title',
+ 'title arguments' => array(4),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_type_form', 4),
+ 'access arguments' => array('administer content types'),
+ 'file' => 'content_types.inc',
+ );
+ $items['admin/structure/types/manage/%node_type/edit'] = array(
+ 'title' => 'Edit',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/structure/types/manage/%node_type/delete'] = array(
+ 'title' => 'Delete',
+ 'page arguments' => array('node_type_delete_confirm', 4),
+ 'access arguments' => array('administer content types'),
+ 'file' => 'content_types.inc',
+ );
+
+ $items['node'] = array(
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'menu_name' => 'navigation',
+ 'type' => MENU_CALLBACK,
+ );
+ $items['node/add'] = array(
+ 'title' => 'Add content',
+ 'page callback' => 'node_add_page',
+ 'access callback' => '_node_add_access',
+ 'file' => 'node.pages.inc',
+ );
+ $items['rss.xml'] = array(
+ 'title' => 'RSS feed',
+ 'page callback' => 'node_feed',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ // @todo Remove this loop when we have a 'description callback' property.
+ // Reset internal static cache of _node_types_build(), forces to rebuild the
+ // node type information.
+ node_type_cache_reset();
+ foreach (node_type_get_types() as $type) {
+ $type_url_str = str_replace('_', '-', $type->type);
+ $items['node/add/' . $type_url_str] = array(
+ 'title' => $type->name,
+ 'title callback' => 'check_plain',
+ 'page callback' => 'node_add',
+ 'page arguments' => array($type->type),
+ 'access callback' => 'node_access',
+ 'access arguments' => array('create', $type->type),
+ 'description' => $type->description,
+ 'file' => 'node.pages.inc',
+ );
+ }
+ $items['node/%node'] = array(
+ 'title callback' => 'node_page_title',
+ 'title arguments' => array(1),
+ // The page callback also invokes drupal_set_title() in case
+ // the menu router's title is overridden by a menu link.
+ 'page callback' => 'node_page_view',
+ 'page arguments' => array(1),
+ 'access callback' => 'node_access',
+ 'access arguments' => array('view', 1),
+ );
+ $items['node/%node/view'] = array(
+ 'title' => 'View',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['node/%node/edit'] = array(
+ 'title' => 'Edit',
+ 'page callback' => 'node_page_edit',
+ 'page arguments' => array(1),
+ 'access callback' => 'node_access',
+ 'access arguments' => array('update', 1),
+ 'weight' => 0,
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ 'file' => 'node.pages.inc',
+ );
+ $items['node/%node/delete'] = array(
+ 'title' => 'Delete',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_delete_confirm', 1),
+ 'access callback' => 'node_access',
+ 'access arguments' => array('delete', 1),
+ 'weight' => 1,
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ 'file' => 'node.pages.inc',
+ );
+ $items['node/%node/revisions'] = array(
+ 'title' => 'Revisions',
+ 'page callback' => 'node_revision_overview',
+ 'page arguments' => array(1),
+ 'access callback' => '_node_revision_access',
+ 'access arguments' => array(1),
+ 'weight' => 2,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'node.pages.inc',
+ );
+ $items['node/%node/revisions/%/view'] = array(
+ 'title' => 'Revisions',
+ 'load arguments' => array(3),
+ 'page callback' => 'node_show',
+ 'page arguments' => array(1, TRUE),
+ 'access callback' => '_node_revision_access',
+ 'access arguments' => array(1),
+ );
+ $items['node/%node/revisions/%/revert'] = array(
+ 'title' => 'Revert to earlier revision',
+ 'load arguments' => array(3),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_revision_revert_confirm', 1),
+ 'access callback' => '_node_revision_access',
+ 'access arguments' => array(1, 'update'),
+ 'file' => 'node.pages.inc',
+ );
+ $items['node/%node/revisions/%/delete'] = array(
+ 'title' => 'Delete earlier revision',
+ 'load arguments' => array(3),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('node_revision_delete_confirm', 1),
+ 'access callback' => '_node_revision_access',
+ 'access arguments' => array(1, 'delete'),
+ 'file' => 'node.pages.inc',
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_menu_local_tasks_alter().
+ */
+function node_menu_local_tasks_alter(&$data, $router_item, $root_path) {
+ // Add action link to 'node/add' on 'admin/content' page.
+ if ($root_path == 'admin/content') {
+ $item = menu_get_item('node/add');
+ if ($item['access']) {
+ $data['actions']['output'][] = array(
+ '#theme' => 'menu_local_action',
+ '#link' => $item,
+ );
+ }
+ }
+}
+
+/**
+ * Title callback for a node type.
+ */
+function node_type_page_title($type) {
+ return $type->name;
+}
+
+/**
+ * Title callback.
+ */
+function node_page_title($node) {
+ return $node->title;
+}
+
+/**
+ * Finds the last time a node was changed.
+ *
+ * @param $nid
+ * The ID of a node.
+ *
+ * @return
+ * A unix timestamp indicating the last time the node was changed.
+ */
+function node_last_changed($nid) {
+ return db_query('SELECT changed FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetch()->changed;
+}
+
+/**
+ * Return a list of all the existing revision numbers.
+ */
+function node_revision_list($node) {
+ $revisions = array();
+ $result = db_query('SELECT r.vid, r.title, r.log, r.uid, n.vid AS current_vid, r.timestamp, u.name FROM {node_revision} r LEFT JOIN {node} n ON n.vid = r.vid INNER JOIN {users} u ON u.uid = r.uid WHERE r.nid = :nid ORDER BY r.vid DESC', array(':nid' => $node->nid));
+ foreach ($result as $revision) {
+ $revisions[$revision->vid] = $revision;
+ }
+
+ return $revisions;
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function node_block_info() {
+ $blocks['syndicate']['info'] = t('Syndicate');
+ // Not worth caching.
+ $blocks['syndicate']['cache'] = DRUPAL_NO_CACHE;
+
+ $blocks['recent']['info'] = t('Recent content');
+ $blocks['recent']['properties']['administrative'] = TRUE;
+
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function node_block_view($delta = '') {
+ $block = array();
+
+ switch ($delta) {
+ case 'syndicate':
+ $block['subject'] = t('Syndicate');
+ $block['content'] = array(
+ '#theme' => 'feed_icon',
+ '#url' => 'rss.xml',
+ '#title' => t('Syndicate'),
+ );
+ break;
+
+ case 'recent':
+ if (user_access('access content')) {
+ $block['subject'] = t('Recent content');
+ if ($nodes = node_get_recent(variable_get('node_recent_block_count', 10))) {
+ $block['content'] = array(
+ '#theme' => 'node_recent_block',
+ '#nodes' => $nodes,
+ );
+ } else {
+ $block['content'] = t('No content available.');
+ }
+ }
+ break;
+ }
+ return $block;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function node_block_configure($delta = '') {
+ $form = array();
+ if ($delta == 'recent') {
+ $form['node_recent_block_count'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of recent content items to display'),
+ '#default_value' => variable_get('node_recent_block_count', 10),
+ '#options' => drupal_map_assoc(array(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 30)),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function node_block_save($delta = '', $edit = array()) {
+ if ($delta == 'recent') {
+ variable_set('node_recent_block_count', $edit['node_recent_block_count']);
+ }
+}
+
+/**
+ * Finds the most recently changed nodes that are available to the current user.
+ *
+ * @param $number
+ * (optional) The maximum number of nodes to find. Defaults to 10.
+ *
+ * @return
+ * An array of partial node objects or an empty array if there are no recent
+ * nodes visible to the current user.
+ */
+function node_get_recent($number = 10) {
+ $query = db_select('node', 'n');
+
+ if (!user_access('bypass node access')) {
+ // If the user is able to view their own unpublished nodes, allow them
+ // to see these in addition to published nodes. Check that they actually
+ // have some unpublished nodes to view before adding the condition.
+ if (user_access('view own unpublished content') && $own_unpublished = db_query('SELECT nid FROM {node} WHERE uid = :uid AND status = :status', array(':uid' => $GLOBALS['user']->uid, ':status' => NODE_NOT_PUBLISHED))->fetchCol()) {
+ $query->condition(db_or()
+ ->condition('n.status', NODE_PUBLISHED)
+ ->condition('n.nid', $own_unpublished, 'IN')
+ );
+ }
+ else {
+ // If not, restrict the query to published nodes.
+ $query->condition('n.status', NODE_PUBLISHED);
+ }
+ }
+ $nids = $query
+ ->fields('n', array('nid'))
+ ->orderBy('n.changed', 'DESC')
+ ->range(0, $number)
+ ->addTag('node_access')
+ ->execute()
+ ->fetchCol();
+
+ $nodes = node_load_multiple($nids);
+
+ return $nodes ? $nodes : array();
+}
+
+/**
+ * Returns HTML for a list of recent content.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - nodes: An array of recent node objects.
+ *
+ * @ingroup themeable
+ */
+function theme_node_recent_block($variables) {
+ $rows = array();
+ $output = '';
+
+ $l_options = array('query' => drupal_get_destination());
+ foreach ($variables['nodes'] as $node) {
+ $row = array();
+ $row[] = array(
+ 'data' => theme('node_recent_content', array('node' => $node)),
+ 'class' => 'title-author',
+ );
+ $row[] = array(
+ 'data' => node_access('update', $node) ? l(t('edit'), 'node/' . $node->nid . '/edit', $l_options) : '',
+ 'class' => 'edit',
+ );
+ $row[] = array(
+ 'data' => node_access('delete', $node) ? l(t('delete'), 'node/' . $node->nid . '/delete', $l_options) : '',
+ 'class' => 'delete',
+ );
+ $rows[] = $row;
+ }
+
+ if ($rows) {
+ $output = theme('table', array('rows' => $rows));
+ if (user_access('access content overview')) {
+ $output .= theme('more_link', array('url' => 'admin/content', 'title' => t('Show more content')));
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Returns HTML for a recent node to be displayed in the recent content block.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - node: A node object.
+ *
+ * @ingroup themeable
+ */
+function theme_node_recent_content($variables) {
+ $node = $variables['node'];
+
+ $output = '<div class="node-title">';
+ $output .= l($node->title, 'node/' . $node->nid);
+ $output .= theme('mark', array('type' => node_mark($node->nid, $node->changed)));
+ $output .= '</div><div class="node-author">';
+ $output .= theme('username', array('account' => user_load($node->uid)));
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Implements hook_form_FORMID_alter().
+ *
+ * Adds node-type specific visibility options to add block form.
+ *
+ * @see block_add_block_form()
+ */
+function node_form_block_add_block_form_alter(&$form, &$form_state) {
+ node_form_block_admin_configure_alter($form, $form_state);
+}
+
+/**
+ * Implements hook_form_FORMID_alter().
+ *
+ * Adds node-type specific visibility options to block configuration form.
+ *
+ * @see block_admin_configure()
+ */
+function node_form_block_admin_configure_alter(&$form, &$form_state) {
+ $default_type_options = db_query("SELECT type FROM {block_node_type} WHERE module = :module AND delta = :delta", array(
+ ':module' => $form['module']['#value'],
+ ':delta' => $form['delta']['#value'],
+ ))->fetchCol();
+ $form['visibility']['node_type'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Content types'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'visibility',
+ '#weight' => 5,
+ );
+ $form['visibility']['node_type']['types'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Show block for specific content types'),
+ '#default_value' => $default_type_options,
+ '#options' => node_type_get_names(),
+ '#description' => t('Show this block only on pages that display content of the given type(s). If you select no types, there will be no type-specific limitation.'),
+ );
+ $form['#submit'][] = 'node_form_block_admin_configure_submit';
+}
+
+/**
+ * Form submit handler for block configuration form.
+ *
+ * @see node_form_block_admin_configure_alter()
+ */
+function node_form_block_admin_configure_submit($form, &$form_state) {
+ db_delete('block_node_type')
+ ->condition('module', $form_state['values']['module'])
+ ->condition('delta', $form_state['values']['delta'])
+ ->execute();
+ $query = db_insert('block_node_type')->fields(array('type', 'module', 'delta'));
+ foreach (array_filter($form_state['values']['types']) as $type) {
+ $query->values(array(
+ 'type' => $type,
+ 'module' => $form_state['values']['module'],
+ 'delta' => $form_state['values']['delta'],
+ ));
+ }
+ $query->execute();
+}
+
+/**
+ * Implements hook_form_FORMID_alter().
+ *
+ * Adds node specific submit handler to delete custom block form.
+ *
+ * @see block_custom_block_delete()
+ */
+function node_form_block_custom_block_delete_alter(&$form, &$form_state) {
+ $form['#submit'][] = 'node_form_block_custom_block_delete_submit';
+}
+
+/**
+ * Form submit handler for custom block delete form.
+ *
+ * @see node_form_block_custom_block_delete_alter()
+ */
+function node_form_block_custom_block_delete_submit($form, &$form_state) {
+ db_delete('block_node_type')
+ ->condition('module', 'block')
+ ->condition('delta', $form_state['values']['bid'])
+ ->execute();
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ *
+ * Cleanup {block_node_type} table from modules' blocks.
+ */
+function node_modules_uninstalled($modules) {
+ db_delete('block_node_type')
+ ->condition('module', $modules, 'IN')
+ ->execute();
+}
+
+/**
+ * Implements hook_block_list_alter().
+ *
+ * Check the content type specific visibilty settings.
+ * Remove the block if the visibility conditions are not met.
+ */
+function node_block_list_alter(&$blocks) {
+ global $theme_key;
+
+ // Build an array of node types for each block.
+ $block_node_types = array();
+ $result = db_query('SELECT module, delta, type FROM {block_node_type}');
+ foreach ($result as $record) {
+ $block_node_types[$record->module][$record->delta][$record->type] = TRUE;
+ }
+
+ $node = menu_get_object();
+ $node_types = node_type_get_types();
+ if (arg(0) == 'node' && arg(1) == 'add' && arg(2)) {
+ $node_add_arg = strtr(arg(2), '-', '_');
+ }
+ foreach ($blocks as $key => $block) {
+ if (!isset($block->theme) || !isset($block->status) || $block->theme != $theme_key || $block->status != 1) {
+ // This block was added by a contrib module, leave it in the list.
+ continue;
+ }
+
+ // If a block has no node types associated, it is displayed for every type.
+ // For blocks with node types associated, if the node type does not match
+ // the settings from this block, remove it from the block list.
+ if (isset($block_node_types[$block->module][$block->delta])) {
+ if (!empty($node)) {
+ // This is a node or node edit page.
+ if (!isset($block_node_types[$block->module][$block->delta][$node->type])) {
+ // This block should not be displayed for this node type.
+ unset($blocks[$key]);
+ continue;
+ }
+ }
+ elseif (isset($node_add_arg) && isset($node_types[$node_add_arg])) {
+ // This is a node creation page
+ if (!isset($block_node_types[$block->module][$block->delta][$node_add_arg])) {
+ // This block should not be displayed for this node type.
+ unset($blocks[$key]);
+ continue;
+ }
+ }
+ else {
+ // This is not a node page, remove the block.
+ unset($blocks[$key]);
+ continue;
+ }
+ }
+ }
+}
+
+/**
+ * Generates and prints an RSS feed.
+ *
+ * Generates an RSS feed from an array of node IDs, and prints it with an HTTP
+ * header, with Content Type set to RSS/XML.
+ *
+ * @param $nids
+ * An array of node IDs (nid). Defaults to FALSE so empty feeds can be
+ * generated with passing an empty array, if no items are to be added
+ * to the feed.
+ * @param $channel
+ * An associative array containing title, link, description and other keys,
+ * to be parsed by format_rss_channel() and format_xml_elements().
+ * A list of channel elements can be found at the @link http://cyber.law.harvard.edu/rss/rss.html RSS 2.0 Specification. @endlink
+ * The link should be an absolute URL.
+ */
+function node_feed($nids = FALSE, $channel = array()) {
+ global $base_url, $language_content;
+
+ if ($nids === FALSE) {
+ $nids = db_select('node', 'n')
+ ->fields('n', array('nid', 'created'))
+ ->condition('n.promote', 1)
+ ->condition('n.status', 1)
+ ->orderBy('n.created', 'DESC')
+ ->range(0, variable_get('feed_default_items', 10))
+ ->addTag('node_access')
+ ->execute()
+ ->fetchCol();
+ }
+
+ $item_length = variable_get('feed_item_length', 'fulltext');
+ $namespaces = array('xmlns:dc' => 'http://purl.org/dc/elements/1.1/');
+ $teaser = ($item_length == 'teaser');
+
+ // Load all nodes to be rendered.
+ $nodes = node_load_multiple($nids);
+ $items = '';
+ foreach ($nodes as $node) {
+ $item_text = '';
+
+ $node->link = url("node/$node->nid", array('absolute' => TRUE));
+ $node->rss_namespaces = array();
+ $node->rss_elements = array(
+ array('key' => 'pubDate', 'value' => gmdate('r', $node->created)),
+ array('key' => 'dc:creator', 'value' => $node->name),
+ array('key' => 'guid', 'value' => $node->nid . ' at ' . $base_url, 'attributes' => array('isPermaLink' => 'false'))
+ );
+
+ // The node gets built and modules add to or modify $node->rss_elements
+ // and $node->rss_namespaces.
+ $build = node_view($node, 'rss');
+ unset($build['#theme']);
+
+ if (!empty($node->rss_namespaces)) {
+ $namespaces = array_merge($namespaces, $node->rss_namespaces);
+ }
+
+ if ($item_length != 'title') {
+ // We render node contents and force links to be last.
+ $build['links']['#weight'] = 1000;
+ $item_text .= drupal_render($build);
+ }
+
+ $items .= format_rss_item($node->title, $node->link, $item_text, $node->rss_elements);
+ }
+
+ $channel_defaults = array(
+ 'version' => '2.0',
+ 'title' => variable_get('site_name', 'Drupal'),
+ 'link' => $base_url,
+ 'description' => variable_get('feed_description', ''),
+ 'language' => $language_content->language
+ );
+ $channel_extras = array_diff_key($channel, $channel_defaults);
+ $channel = array_merge($channel_defaults, $channel);
+
+ $output = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n";
+ $output .= "<rss version=\"" . $channel["version"] . "\" xml:base=\"" . $base_url . "\" " . drupal_attributes($namespaces) . ">\n";
+ $output .= format_rss_channel($channel['title'], $channel['link'], $channel['description'], $items, $channel['language'], $channel_extras);
+ $output .= "</rss>\n";
+
+ drupal_add_http_header('Content-Type', 'application/rss+xml; charset=utf-8');
+ print $output;
+}
+
+/**
+ * Construct a drupal_render() style array from an array of loaded nodes.
+ *
+ * @param $nodes
+ * An array of nodes as returned by node_load_multiple().
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $weight
+ * An integer representing the weight of the first node in the list.
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return
+ * An array in the format expected by drupal_render().
+ */
+function node_view_multiple($nodes, $view_mode = 'teaser', $weight = 0, $langcode = NULL) {
+ field_attach_prepare_view('node', $nodes, $view_mode, $langcode);
+ entity_prepare_view('node', $nodes, $langcode);
+ $build = array();
+ foreach ($nodes as $node) {
+ $build['nodes'][$node->nid] = node_view($node, $view_mode, $langcode);
+ $build['nodes'][$node->nid]['#weight'] = $weight;
+ $weight++;
+ }
+ $build['nodes']['#sorted'] = TRUE;
+ return $build;
+}
+
+/**
+ * Menu callback; Generate a listing of promoted nodes.
+ */
+function node_page_default() {
+ $select = db_select('node', 'n')
+ ->fields('n', array('nid', 'sticky', 'created'))
+ ->condition('promote', 1)
+ ->condition('status', 1)
+ ->orderBy('sticky', 'DESC')
+ ->orderBy('created', 'DESC')
+ ->extend('PagerDefault')
+ ->limit(variable_get('default_nodes_main', 10))
+ ->addTag('node_access');
+
+ $nids = $select->execute()->fetchCol();
+
+ if (!empty($nids)) {
+ $nodes = node_load_multiple($nids);
+ $build = node_view_multiple($nodes);
+
+ // 'rss.xml' is a path, not a file, registered in node_menu().
+ drupal_add_feed('rss.xml', variable_get('site_name', 'Drupal') . ' ' . t('RSS'));
+ $build['pager'] = array(
+ '#theme' => 'pager',
+ '#weight' => 5,
+ );
+ drupal_set_title('');
+ }
+ else {
+ drupal_set_title(t('Welcome to @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), PASS_THROUGH);
+
+ $default_message = '<p>' . t('No front page content has been created yet.') . '</p>';
+
+ $default_links = array();
+ if (_node_add_access()) {
+ $default_links[] = l(t('Add new content'), 'node/add');
+ }
+ if (!empty($default_links)) {
+ $default_message .= theme('item_list', array('items' => $default_links));
+ }
+
+ $build['default_message'] = array(
+ '#markup' => $default_message,
+ '#prefix' => '<div id="first-time">',
+ '#suffix' => '</div>',
+ );
+ }
+ return $build;
+}
+
+/**
+ * Menu callback; view a single node.
+ */
+function node_page_view($node) {
+ // If there is a menu link to this node, the link becomes the last part
+ // of the active trail, and the link name becomes the page title.
+ // Thus, we must explicitly set the page title to be the node title.
+ drupal_set_title($node->title);
+ $uri = entity_uri('node', $node);
+ // Set the node path as the canonical URL to prevent duplicate content.
+ drupal_add_html_head_link(array('rel' => 'canonical', 'href' => url($uri['path'], $uri['options'])), TRUE);
+ // Set the non-aliased path as a default shortlink.
+ drupal_add_html_head_link(array('rel' => 'shortlink', 'href' => url($uri['path'], array_merge($uri['options'], array('alias' => TRUE)))), TRUE);
+ return node_show($node);
+}
+
+/**
+ * Implements hook_update_index().
+ */
+function node_update_index() {
+ $limit = (int)variable_get('search_cron_limit', 100);
+
+ $result = db_query_range("SELECT n.nid FROM {node} n LEFT JOIN {search_dataset} d ON d.type = 'node' AND d.sid = n.nid WHERE d.sid IS NULL OR d.reindex <> 0 ORDER BY d.reindex ASC, n.nid ASC", 0, $limit, array(), array('target' => 'slave'));
+
+ foreach ($result as $node) {
+ _node_index_node($node);
+ }
+}
+
+/**
+ * Index a single node.
+ *
+ * @param $node
+ * The node to index.
+ */
+function _node_index_node($node) {
+ $node = node_load($node->nid);
+
+ // Save the changed time of the most recent indexed node, for the search
+ // results half-life calculation.
+ variable_set('node_cron_last', $node->changed);
+
+ // Render the node.
+ $build = node_view($node, 'search_index');
+ unset($build['#theme']);
+ $node->rendered = drupal_render($build);
+
+ $text = '<h1>' . check_plain($node->title) . '</h1>' . $node->rendered;
+
+ // Fetch extra data normally not visible
+ $extra = module_invoke_all('node_update_index', $node);
+ foreach ($extra as $t) {
+ $text .= $t;
+ }
+
+ // Update index
+ search_index($node->nid, 'node', $text);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function node_form_search_form_alter(&$form, $form_state) {
+ if (isset($form['module']) && $form['module']['#value'] == 'node' && user_access('use advanced search')) {
+ // Keyword boxes:
+ $form['advanced'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Advanced search'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#attributes' => array('class' => array('search-advanced')),
+ );
+ $form['advanced']['keywords'] = array(
+ '#prefix' => '<div class="criterion">',
+ '#suffix' => '</div>',
+ );
+ $form['advanced']['keywords']['or'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Containing any of the words'),
+ '#size' => 30,
+ '#maxlength' => 255,
+ );
+ $form['advanced']['keywords']['phrase'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Containing the phrase'),
+ '#size' => 30,
+ '#maxlength' => 255,
+ );
+ $form['advanced']['keywords']['negative'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Containing none of the words'),
+ '#size' => 30,
+ '#maxlength' => 255,
+ );
+
+ // Node types:
+ $types = array_map('check_plain', node_type_get_names());
+ $form['advanced']['type'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Only of the type(s)'),
+ '#prefix' => '<div class="criterion">',
+ '#suffix' => '</div>',
+ '#options' => $types,
+ );
+ $form['advanced']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Advanced search'),
+ '#prefix' => '<div class="action">',
+ '#suffix' => '</div>',
+ '#weight' => 100,
+ );
+
+ // Languages:
+ $language_options = array();
+ foreach (language_list('language') as $key => $entity) {
+ if ($entity->enabled) {
+ $language_options[$key] = $entity->name;
+ }
+ }
+ if (count($language_options) > 1) {
+ $form['advanced']['language'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Languages'),
+ '#prefix' => '<div class="criterion">',
+ '#suffix' => '</div>',
+ '#options' => $language_options,
+ );
+ }
+
+ $form['#validate'][] = 'node_search_validate';
+ }
+}
+
+/**
+ * Form API callback for the search form. Registered in node_form_alter().
+ */
+function node_search_validate($form, &$form_state) {
+ // Initialize using any existing basic search keywords.
+ $keys = $form_state['values']['processed_keys'];
+
+ // Insert extra restrictions into the search keywords string.
+ if (isset($form_state['values']['type']) && is_array($form_state['values']['type'])) {
+ // Retrieve selected types - Forms API sets the value of unselected
+ // checkboxes to 0.
+ $form_state['values']['type'] = array_filter($form_state['values']['type']);
+ if (count($form_state['values']['type'])) {
+ $keys = search_expression_insert($keys, 'type', implode(',', array_keys($form_state['values']['type'])));
+ }
+ }
+
+ if (isset($form_state['values']['term']) && is_array($form_state['values']['term']) && count($form_state['values']['term'])) {
+ $keys = search_expression_insert($keys, 'term', implode(',', $form_state['values']['term']));
+ }
+ if (isset($form_state['values']['language']) && is_array($form_state['values']['language'])) {
+ $languages = array_filter($form_state['values']['language']);
+ if (count($languages)) {
+ $keys = search_expression_insert($keys, 'language', implode(',', $languages));
+ }
+ }
+ if ($form_state['values']['or'] != '') {
+ if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state['values']['or'], $matches)) {
+ $keys .= ' ' . implode(' OR ', $matches[1]);
+ }
+ }
+ if ($form_state['values']['negative'] != '') {
+ if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state['values']['negative'], $matches)) {
+ $keys .= ' -' . implode(' -', $matches[1]);
+ }
+ }
+ if ($form_state['values']['phrase'] != '') {
+ $keys .= ' "' . str_replace('"', ' ', $form_state['values']['phrase']) . '"';
+ }
+ if (!empty($keys)) {
+ form_set_value($form['basic']['processed_keys'], trim($keys), $form_state);
+ }
+}
+
+/**
+ * @defgroup node_access Node access rights
+ * @{
+ * The node access system determines who can do what to which nodes.
+ *
+ * In determining access rights for a node, node_access() first checks
+ * whether the user has the "bypass node access" permission. Such users have
+ * unrestricted access to all nodes. user 1 will always pass this check.
+ *
+ * Next, all implementations of hook_node_access() will be called. Each
+ * implementation may explicitly allow, explicitly deny, or ignore the access
+ * request. If at least one module says to deny the request, it will be rejected.
+ * If no modules deny the request and at least one says to allow it, the request
+ * will be permitted.
+ *
+ * If all modules ignore the access request, then the node_access table is used
+ * to determine access. All node access modules are queried using
+ * hook_node_grants() to assemble a list of "grant IDs" for the user. This list
+ * is compared against the table. If any row contains the node ID in question
+ * (or 0, which stands for "all nodes"), one of the grant IDs returned, and a
+ * value of TRUE for the operation in question, then access is granted. Note
+ * that this table is a list of grants; any matching row is sufficient to
+ * grant access to the node.
+ *
+ * In node listings, the process above is followed except that
+ * hook_node_access() is not called on each node for performance reasons and for
+ * proper functioning of the pager system. When adding a node listing to your
+ * module, be sure to use a dynamic query created by db_select() and add a tag
+ * of "node_access". This will allow modules dealing with node access to ensure
+ * only nodes to which the user has access are retrieved, through the use of
+ * hook_query_TAG_alter().
+ *
+ * Note: Even a single module returning NODE_ACCESS_DENY from hook_node_access()
+ * will block access to the node. Therefore, implementers should take care to
+ * not deny access unless they really intend to. Unless a module wishes to
+ * actively deny access it should return NODE_ACCESS_IGNORE (or simply return
+ * nothing) to allow other modules or the node_access table to control access.
+ *
+ * To see how to write a node access module of your own, see
+ * node_access_example.module.
+ */
+
+/**
+ * Determine whether the current user may perform the given operation on the
+ * specified node.
+ *
+ * @param $op
+ * The operation to be performed on the node. Possible values are:
+ * - "view"
+ * - "update"
+ * - "delete"
+ * - "create"
+ * @param $node
+ * The node object on which the operation is to be performed, or node type
+ * (e.g. 'forum') for "create" operation.
+ * @param $account
+ * Optional, a user object representing the user for whom the operation is to
+ * be performed. Determines access for a user other than the current user.
+ * @return
+ * TRUE if the operation may be performed, FALSE otherwise.
+ */
+function node_access($op, $node, $account = NULL) {
+ $rights = &drupal_static(__FUNCTION__, array());
+
+ if (!$node || !in_array($op, array('view', 'update', 'delete', 'create'), TRUE)) {
+ // If there was no node to check against, or the $op was not one of the
+ // supported ones, we return access denied.
+ return FALSE;
+ }
+ // If no user object is supplied, the access check is for the current user.
+ if (empty($account)) {
+ $account = $GLOBALS['user'];
+ }
+
+ // $node may be either an object or a node type. Since node types cannot be
+ // an integer, use either nid or type as the static cache id.
+
+ $cid = is_object($node) ? $node->nid : $node;
+
+ // If we've already checked access for this node, user and op, return from
+ // cache.
+ if (isset($rights[$account->uid][$cid][$op])) {
+ return $rights[$account->uid][$cid][$op];
+ }
+
+ if (user_access('bypass node access', $account)) {
+ $rights[$account->uid][$cid][$op] = TRUE;
+ return TRUE;
+ }
+ if (!user_access('access content', $account)) {
+ $rights[$account->uid][$cid][$op] = FALSE;
+ return FALSE;
+ }
+
+ // We grant access to the node if both of the following conditions are met:
+ // - No modules say to deny access.
+ // - At least one module says to grant access.
+ // If no module specified either allow or deny, we fall back to the
+ // node_access table.
+ $access = module_invoke_all('node_access', $node, $op, $account);
+ if (in_array(NODE_ACCESS_DENY, $access, TRUE)) {
+ $rights[$account->uid][$cid][$op] = FALSE;
+ return FALSE;
+ }
+ elseif (in_array(NODE_ACCESS_ALLOW, $access, TRUE)) {
+ $rights[$account->uid][$cid][$op] = TRUE;
+ return TRUE;
+ }
+
+ // Check if authors can view their own unpublished nodes.
+ if ($op == 'view' && !$node->status && user_access('view own unpublished content', $account) && $account->uid == $node->uid && $account->uid != 0) {
+ $rights[$account->uid][$cid][$op] = TRUE;
+ return TRUE;
+ }
+
+ // If the module did not override the access rights, use those set in the
+ // node_access table.
+ if ($op != 'create' && $node->nid) {
+ if (module_implements('node_grants')) {
+ $query = db_select('node_access');
+ $query->addExpression('1');
+ $query->condition('grant_' . $op, 1, '>=');
+ $nids = db_or()->condition('nid', $node->nid);
+ if ($node->status) {
+ $nids->condition('nid', 0);
+ }
+ $query->condition($nids);
+ $query->range(0, 1);
+
+ $grants = db_or();
+ foreach (node_access_grants($op, $account) as $realm => $gids) {
+ foreach ($gids as $gid) {
+ $grants->condition(db_and()
+ ->condition('gid', $gid)
+ ->condition('realm', $realm)
+ );
+ }
+ }
+ if (count($grants) > 0) {
+ $query->condition($grants);
+ }
+ $result = (bool) $query
+ ->execute()
+ ->fetchField();
+ $rights[$account->uid][$cid][$op] = $result;
+ return $result;
+ }
+ elseif (is_object($node) && $op == 'view' && $node->status) {
+ // If no modules implement hook_node_grants(), the default behaviour is to
+ // allow all users to view published nodes, so reflect that here.
+ $rights[$account->uid][$cid][$op] = TRUE;
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Implements hook_node_access().
+ */
+function node_node_access($node, $op, $account) {
+ $type = is_string($node) ? $node : $node->type;
+
+ if (in_array($type, node_permissions_get_configured_types())) {
+ if ($op == 'create' && user_access('create ' . $type . ' content', $account)) {
+ return NODE_ACCESS_ALLOW;
+ }
+
+ if ($op == 'update') {
+ if (user_access('edit any ' . $type . ' content', $account) || (user_access('edit own ' . $type . ' content', $account) && ($account->uid == $node->uid))) {
+ return NODE_ACCESS_ALLOW;
+ }
+ }
+
+ if ($op == 'delete') {
+ if (user_access('delete any ' . $type . ' content', $account) || (user_access('delete own ' . $type . ' content', $account) && ($account->uid == $node->uid))) {
+ return NODE_ACCESS_ALLOW;
+ }
+ }
+ }
+
+ return NODE_ACCESS_IGNORE;
+}
+
+/**
+ * Helper function to generate standard node permission list for a given type.
+ *
+ * @param $type
+ * The machine-readable name of the node type.
+ * @return array
+ * An array of permission names and descriptions.
+ */
+function node_list_permissions($type) {
+ $info = node_type_get_type($type);
+ $type = check_plain($info->type);
+
+ // Build standard list of node permissions for this type.
+ $perms = array(
+ "create $type content" => array(
+ 'title' => t('%type_name: Create new content', array('%type_name' => $info->name)),
+ ),
+ "edit own $type content" => array(
+ 'title' => t('%type_name: Edit own content', array('%type_name' => $info->name)),
+ ),
+ "edit any $type content" => array(
+ 'title' => t('%type_name: Edit any content', array('%type_name' => $info->name)),
+ ),
+ "delete own $type content" => array(
+ 'title' => t('%type_name: Delete own content', array('%type_name' => $info->name)),
+ ),
+ "delete any $type content" => array(
+ 'title' => t('%type_name: Delete any content', array('%type_name' => $info->name)),
+ ),
+ );
+
+ return $perms;
+}
+
+/**
+ * Returns an array of node types that should be managed by permissions.
+ *
+ * By default, this will include all node types in the system. To exclude a
+ * specific node from getting permissions defined for it, set the
+ * node_permissions_$type variable to 0. Core does not provide an interface
+ * for doing so, however, contrib modules may exclude their own nodes in
+ * hook_install(). Alternatively, contrib modules may configure all node types
+ * at once, or decide to apply some other hook_node_access() implementation
+ * to some or all node types.
+ *
+ * @return
+ * An array of node types managed by this module.
+ */
+function node_permissions_get_configured_types() {
+
+ $configured_types = array();
+
+ foreach (node_type_get_types() as $type => $info) {
+ if (variable_get('node_permissions_' . $type, 1)) {
+ $configured_types[] = $type;
+ }
+ }
+
+ return $configured_types;
+}
+
+/**
+ * Fetch an array of permission IDs granted to the given user ID.
+ *
+ * The implementation here provides only the universal "all" grant. A node
+ * access module should implement hook_node_grants() to provide a grant
+ * list for the user.
+ *
+ * After the default grants have been loaded, we allow modules to alter
+ * the grants array by reference. This hook allows for complex business
+ * logic to be applied when integrating multiple node access modules.
+ *
+ * @param $op
+ * The operation that the user is trying to perform.
+ * @param $account
+ * The user object for the user performing the operation. If omitted, the
+ * current user is used.
+ * @return
+ * An associative array in which the keys are realms, and the values are
+ * arrays of grants for those realms.
+ */
+function node_access_grants($op, $account = NULL) {
+
+ if (!isset($account)) {
+ $account = $GLOBALS['user'];
+ }
+
+ // Fetch node access grants from other modules.
+ $grants = module_invoke_all('node_grants', $account, $op);
+ // Allow modules to alter the assigned grants.
+ drupal_alter('node_grants', $grants, $account, $op);
+
+ return array_merge(array('all' => array(0)), $grants);
+}
+
+/**
+ * Determines whether the user has a global viewing grant for all nodes.
+ *
+ * Checks to see whether any module grants global 'view' access to a user
+ * account; global 'view' access is encoded in the {node_access} table as a
+ * grant with nid=0. If no node access modules are enabled, node.module defines
+ * such a global 'view' access grant.
+ *
+ * This function is called when a node listing query is tagged with
+ * 'node_access'; when this function returns TRUE, no node access joins are
+ * added to the query.
+ *
+ * @param $account
+ * The user object for the user whose access is being checked. If omitted,
+ * the current user is used.
+ *
+ * @return
+ * TRUE if 'view' access to all nodes is granted, FALSE otherwise.
+ *
+ * @see hook_node_grants()
+ * @see _node_query_node_access_alter()
+ */
+function node_access_view_all_nodes($account = NULL) {
+ global $user;
+ if (!$account) {
+ $account = $user;
+ }
+
+ // Statically cache results in an array keyed by $account->uid.
+ $access = &drupal_static(__FUNCTION__);
+ if (isset($access[$account->uid])) {
+ return $access[$account->uid];
+ }
+
+ // If no modules implement the node access system, access is always TRUE.
+ if (!module_implements('node_grants')) {
+ $access[$account->uid] = TRUE;
+ }
+ else {
+ $query = db_select('node_access');
+ $query->addExpression('COUNT(*)');
+ $query
+ ->condition('nid', 0)
+ ->condition('grant_view', 1, '>=');
+
+ $grants = db_or();
+ foreach (node_access_grants('view', $account) as $realm => $gids) {
+ foreach ($gids as $gid) {
+ $grants->condition(db_and()
+ ->condition('gid', $gid)
+ ->condition('realm', $realm)
+ );
+ }
+ }
+ if (count($grants) > 0 ) {
+ $query->condition($grants);
+ }
+ $access[$account->uid] = $query
+ ->execute()
+ ->fetchField();
+ }
+
+ return $access[$account->uid];
+}
+
+
+/**
+ * Implements hook_query_TAG_alter().
+ *
+ * This is the hook_query_alter() for queries tagged with 'node_access'.
+ * It adds node access checks for the user account given by the 'account'
+ * meta-data (or global $user if not provided), for an operation given by
+ * the 'op' meta-data (or 'view' if not provided; other possible values are
+ * 'update' and 'delete').
+ */
+function node_query_node_access_alter(QueryAlterableInterface $query) {
+ _node_query_node_access_alter($query, 'node');
+}
+
+/**
+ * Implements hook_query_TAG_alter().
+ *
+ * This function implements the same functionality as
+ * node_query_node_access_alter() for the SQL field storage engine. Node access
+ * conditions are added for field values belonging to nodes only.
+ */
+function node_query_entity_field_access_alter(QueryAlterableInterface $query) {
+ _node_query_node_access_alter($query, 'entity');
+}
+
+/**
+ * Helper for node access functions.
+ *
+ * @param $query
+ * The query to add conditions to.
+ * @param $type
+ * Either 'node' or 'entity' depending on what sort of query it is. See
+ * node_query_node_access_alter() and node_query_entity_field_access_alter()
+ * for more.
+ */
+function _node_query_node_access_alter($query, $type) {
+ global $user;
+
+ // Read meta-data from query, if provided.
+ if (!$account = $query->getMetaData('account')) {
+ $account = $user;
+ }
+ if (!$op = $query->getMetaData('op')) {
+ $op = 'view';
+ }
+
+ // If $account can bypass node access, or there are no node access modules,
+ // or the operation is 'view' and the $acount has a global view grant (i.e.,
+ // a view grant for node ID 0), we don't need to alter the query.
+ if (user_access('bypass node access', $account)) {
+ return;
+ }
+ if (!count(module_implements('node_grants'))) {
+ return;
+ }
+ if ($op == 'view' && node_access_view_all_nodes($account)) {
+ return;
+ }
+
+ $tables = $query->getTables();
+ $base_table = $query->getMetaData('base_table');
+ // If no base table is specified explicitly, search for one.
+ if (!$base_table) {
+ $fallback = '';
+ foreach ($tables as $alias => $table_info) {
+ if (!($table_info instanceof SelectQueryInterface)) {
+ $table = $table_info['table'];
+ // If the node table is in the query, it wins immediately.
+ if ($table == 'node') {
+ $base_table = $table;
+ break;
+ }
+ // Check whether the table has a foreign key to node.nid. If it does,
+ // do not run this check again as we found a base table and only node
+ // can triumph that.
+ if (!$base_table) {
+ // The schema is cached.
+ $schema = drupal_get_schema($table);
+ if (isset($schema['fields']['nid'])) {
+ if (isset($schema['foreign keys'])) {
+ foreach ($schema['foreign keys'] as $relation) {
+ if ($relation['table'] === 'node' && $relation['columns'] === array('nid' => 'nid')) {
+ $base_table = $table;
+ }
+ }
+ }
+ else {
+ // At least it's a nid. A table with a field called nid is very
+ // very likely to be a node.nid in a node access query.
+ $fallback = $table;
+ }
+ }
+ }
+ }
+ }
+ // If there is nothing else, use the fallback.
+ if (!$base_table) {
+ if ($fallback) {
+ watchdog('security', 'Your node listing query is using @fallback as a base table in a query tagged for node access. This might not be secure and might not even work. Specify foreign keys in your schema to node.nid ', array('@fallback' => $fallback), WATCHDOG_WARNING);
+ $base_table = $fallback;
+ }
+ else {
+ throw new Exception(t('Query tagged for node access but there is no nid. Add foreign keys to node.nid in schema to fix.'));
+ }
+ }
+ }
+
+ // Prevent duplicate records.
+ $query->distinct();
+
+ // Find all instances of the base table being joined -- could appear
+ // more than once in the query, and could be aliased. Join each one to
+ // the node_access table.
+
+ $grants = node_access_grants($op, $account);
+ if ($type == 'entity') {
+ // The original query looked something like:
+ // @code
+ // SELECT nid FROM sometable s
+ // INNER JOIN node_access na ON na.nid = s.nid
+ // WHERE ($node_access_conditions)
+ // @endcode
+ //
+ // Our query will look like:
+ // @code
+ // SELECT entity_type, entity_id
+ // FROM field_data_something s
+ // LEFT JOIN node_access na ON s.entity_id = na.nid
+ // WHERE (entity_type = 'node' AND $node_access_conditions) OR (entity_type <> 'node')
+ // @endcode
+ //
+ // So instead of directly adding to the query object, we need to collect
+ // in a separate db_and() object and then at the end add it to the query.
+ $entity_conditions = db_and();
+ }
+ foreach ($tables as $nalias => $tableinfo) {
+ $table = $tableinfo['table'];
+ if (!($table instanceof SelectQueryInterface) && $table == $base_table) {
+
+ // The node_access table has the access grants for any given node so JOIN
+ // it to the table containing the nid which can be either the node
+ // table or a field value table.
+ if ($type == 'node') {
+ $access_alias = $query->join('node_access', 'na', '%alias.nid = ' . $nalias . '.nid');
+ }
+ else {
+ $access_alias = $query->leftJoin('node_access', 'na', '%alias.nid = ' . $nalias . '.entity_id');
+ $base_alias = $nalias;
+ }
+
+ $grant_conditions = db_or();
+ // If any grant exists for the specified user, then user has access
+ // to the node for the specified operation.
+ foreach ($grants as $realm => $gids) {
+ foreach ($gids as $gid) {
+ $grant_conditions->condition(db_and()
+ ->condition($access_alias . '.gid', $gid)
+ ->condition($access_alias . '.realm', $realm)
+ );
+ }
+ }
+
+ $count = count($grant_conditions->conditions());
+ if ($type == 'node') {
+ if ($count) {
+ $query->condition($grant_conditions);
+ }
+ $query->condition($access_alias . '.grant_' . $op, 1, '>=');
+ }
+ else {
+ if ($count) {
+ $entity_conditions->condition($grant_conditions);
+ }
+ $entity_conditions->condition($access_alias . '.grant_' . $op, 1, '>=');
+ }
+ }
+ }
+
+ if ($type == 'entity' && count($entity_conditions->conditions())) {
+ // All the node access conditions are only for field values belonging to
+ // nodes.
+ $entity_conditions->condition("$base_alias.entity_type", 'node');
+ $or = db_or();
+ $or->condition($entity_conditions);
+ // If the field value belongs to a non-node entity type then this function
+ // does not do anything with it.
+ $or->condition("$base_alias.entity_type", 'node', '<>');
+ // Add the compiled set of rules to the query.
+ $query->condition($or);
+ }
+}
+
+/**
+ * Gets the list of node access grants and writes them to the database.
+ *
+ * This function is called when a node is saved, and can also be called by
+ * modules if something other than a node save causes node access permissions to
+ * change. It collects all node access grants for the node from
+ * hook_node_access_records() implementations, allows these grants to be altered
+ * via hook_node_access_records_alter() implementations, and saves the collected
+ * and altered grants to the database.
+ *
+ * @param $node
+ * The $node to acquire grants for.
+ *
+ * @param $delete
+ * Whether to delete existing node access records before inserting new ones.
+ * Defaults to TRUE.
+ */
+function node_access_acquire_grants($node, $delete = TRUE) {
+ $grants = module_invoke_all('node_access_records', $node);
+ // Let modules alter the grants.
+ drupal_alter('node_access_records', $grants, $node);
+ // If no grants are set and the node is published, then use the default grant.
+ if (empty($grants) && !empty($node->status)) {
+ $grants[] = array('realm' => 'all', 'gid' => 0, 'grant_view' => 1, 'grant_update' => 0, 'grant_delete' => 0);
+ }
+ else {
+ // Retain grants by highest priority.
+ $grant_by_priority = array();
+ foreach ($grants as $g) {
+ $grant_by_priority[intval($g['priority'])][] = $g;
+ }
+ krsort($grant_by_priority);
+ $grants = array_shift($grant_by_priority);
+ }
+
+ _node_access_write_grants($node, $grants, NULL, $delete);
+}
+
+/**
+ * Writes a list of grants to the database, deleting any previously saved ones.
+ *
+ * If a realm is provided, it will only delete grants from that realm, but it
+ * will always delete a grant from the 'all' realm. Modules that utilize
+ * node_access can use this function when doing mass updates due to widespread
+ * permission changes.
+ *
+ * Note: Don't call this function directly from a contributed module. Call
+ * node_access_acquire_grants() instead.
+ *
+ * @param $node
+ * The $node being written to. All that is necessary is that it contains a
+ * nid.
+ * @param $grants
+ * A list of grants to write. Each grant is an array that must contain the
+ * following keys: realm, gid, grant_view, grant_update, grant_delete.
+ * The realm is specified by a particular module; the gid is as well, and
+ * is a module-defined id to define grant privileges. each grant_* field
+ * is a boolean value.
+ * @param $realm
+ * If provided, only read/write grants for that realm.
+ * @param $delete
+ * If false, do not delete records. This is only for optimization purposes,
+ * and assumes the caller has already performed a mass delete of some form.
+ */
+function _node_access_write_grants($node, $grants, $realm = NULL, $delete = TRUE) {
+ if ($delete) {
+ $query = db_delete('node_access')->condition('nid', $node->nid);
+ if ($realm) {
+ $query->condition('realm', array($realm, 'all'), 'IN');
+ }
+ $query->execute();
+ }
+
+ // Only perform work when node_access modules are active.
+ if (!empty($grants) && count(module_implements('node_grants'))) {
+ $query = db_insert('node_access')->fields(array('nid', 'realm', 'gid', 'grant_view', 'grant_update', 'grant_delete'));
+ foreach ($grants as $grant) {
+ if ($realm && $realm != $grant['realm']) {
+ continue;
+ }
+ // Only write grants; denies are implicit.
+ if ($grant['grant_view'] || $grant['grant_update'] || $grant['grant_delete']) {
+ $grant['nid'] = $node->nid;
+ $query->values($grant);
+ }
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Flag / unflag the node access grants for rebuilding, or read the current
+ * value of the flag.
+ *
+ * When the flag is set, a message is displayed to users with 'access
+ * administration pages' permission, pointing to the 'rebuild' confirm form.
+ * This can be used as an alternative to direct node_access_rebuild calls,
+ * allowing administrators to decide when they want to perform the actual
+ * (possibly time consuming) rebuild.
+ * When unsure the current user is an administrator, node_access_rebuild
+ * should be used instead.
+ *
+ * @param $rebuild
+ * (Optional) The boolean value to be written.
+ * @return
+ * (If no value was provided for $rebuild) The current value of the flag.
+ */
+function node_access_needs_rebuild($rebuild = NULL) {
+ if (!isset($rebuild)) {
+ return variable_get('node_access_needs_rebuild', FALSE);
+ }
+ elseif ($rebuild) {
+ variable_set('node_access_needs_rebuild', TRUE);
+ }
+ else {
+ variable_del('node_access_needs_rebuild');
+ }
+}
+
+/**
+ * Rebuild the node access database. This is occasionally needed by modules
+ * that make system-wide changes to access levels.
+ *
+ * When the rebuild is required by an admin-triggered action (e.g module
+ * settings form), calling node_access_needs_rebuild(TRUE) instead of
+ * node_access_rebuild() lets the user perform his changes and actually
+ * rebuild only once he is done.
+ *
+ * Note : As of Drupal 6, node access modules are not required to (and actually
+ * should not) call node_access_rebuild() in hook_enable/disable anymore.
+ *
+ * @see node_access_needs_rebuild()
+ *
+ * @param $batch_mode
+ * Set to TRUE to process in 'batch' mode, spawning processing over several
+ * HTTP requests (thus avoiding the risk of PHP timeout if the site has a
+ * large number of nodes).
+ * hook_update_N and any form submit handler are safe contexts to use the
+ * 'batch mode'. Less decidable cases (such as calls from hook_user,
+ * hook_taxonomy, etc...) might consider using the non-batch mode.
+ */
+function node_access_rebuild($batch_mode = FALSE) {
+ db_delete('node_access')->execute();
+ // Only recalculate if the site is using a node_access module.
+ if (count(module_implements('node_grants'))) {
+ if ($batch_mode) {
+ $batch = array(
+ 'title' => t('Rebuilding content access permissions'),
+ 'operations' => array(
+ array('_node_access_rebuild_batch_operation', array()),
+ ),
+ 'finished' => '_node_access_rebuild_batch_finished'
+ );
+ batch_set($batch);
+ }
+ else {
+ // Try to allocate enough time to rebuild node grants
+ drupal_set_time_limit(240);
+
+ $nids = db_query("SELECT nid FROM {node}")->fetchCol();
+ foreach ($nids as $nid) {
+ $node = node_load($nid, NULL, TRUE);
+ // To preserve database integrity, only acquire grants if the node
+ // loads successfully.
+ if (!empty($node)) {
+ node_access_acquire_grants($node);
+ }
+ }
+ }
+ }
+ else {
+ // Not using any node_access modules. Add the default grant.
+ db_insert('node_access')
+ ->fields(array(
+ 'nid' => 0,
+ 'realm' => 'all',
+ 'gid' => 0,
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ ))
+ ->execute();
+ }
+
+ if (!isset($batch)) {
+ drupal_set_message(t('Content permissions have been rebuilt.'));
+ node_access_needs_rebuild(FALSE);
+ cache_clear_all();
+ }
+}
+
+/**
+ * Batch operation for node_access_rebuild_batch.
+ *
+ * This is a multistep operation : we go through all nodes by packs of 20.
+ * The batch processing engine interrupts processing and sends progress
+ * feedback after 1 second execution time.
+ */
+function _node_access_rebuild_batch_operation(&$context) {
+ if (empty($context['sandbox'])) {
+ // Initiate multistep processing.
+ $context['sandbox']['progress'] = 0;
+ $context['sandbox']['current_node'] = 0;
+ $context['sandbox']['max'] = db_query('SELECT COUNT(DISTINCT nid) FROM {node}')->fetchField();
+ }
+
+ // Process the next 20 nodes.
+ $limit = 20;
+ $nids = db_query_range("SELECT nid FROM {node} WHERE nid > :nid ORDER BY nid ASC", 0, $limit, array(':nid' => $context['sandbox']['current_node']))->fetchCol();
+ $nodes = node_load_multiple($nids, array(), TRUE);
+ foreach ($nodes as $nid => $node) {
+ // To preserve database integrity, only acquire grants if the node
+ // loads successfully.
+ if (!empty($node)) {
+ node_access_acquire_grants($node);
+ }
+ $context['sandbox']['progress']++;
+ $context['sandbox']['current_node'] = $nid;
+ }
+
+ // Multistep processing : report progress.
+ if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
+ $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
+ }
+}
+
+/**
+ * Post-processing for node_access_rebuild_batch.
+ */
+function _node_access_rebuild_batch_finished($success, $results, $operations) {
+ if ($success) {
+ drupal_set_message(t('The content access permissions have been rebuilt.'));
+ node_access_needs_rebuild(FALSE);
+ }
+ else {
+ drupal_set_message(t('The content access permissions have not been properly rebuilt.'), 'error');
+ }
+ cache_clear_all();
+}
+
+/**
+ * @} End of "defgroup node_access".
+ */
+
+
+/**
+ * @defgroup node_content Hook implementations for user-created content types
+ * @{
+ * Functions that implement hooks for user-created content types.
+ */
+
+/**
+ * Implements hook_form().
+ */
+function node_content_form($node, $form_state) {
+ // It is impossible to define a content type without implementing hook_form()
+ // @todo: remove this requirement.
+ $form = array();
+ $type = node_type_get_type($node);
+
+ if ($type->has_title) {
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => check_plain($type->title_label),
+ '#required' => TRUE,
+ '#default_value' => $node->title,
+ '#maxlength' => 255,
+ '#weight' => -5,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * @} End of "defgroup node_content".
+ */
+
+/**
+ * Implements hook_forms().
+ * All node forms share the same form handler.
+ */
+function node_forms() {
+ $forms = array();
+ if ($types = node_type_get_types()) {
+ foreach (array_keys($types) as $type) {
+ $forms[$type . '_node_form']['callback'] = 'node_form';
+ }
+ }
+ return $forms;
+}
+
+/**
+ * Implements hook_action_info().
+ */
+function node_action_info() {
+ return array(
+ 'node_publish_action' => array(
+ 'type' => 'node',
+ 'label' => t('Publish content'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_unpublish_action' => array(
+ 'type' => 'node',
+ 'label' => t('Unpublish content'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_make_sticky_action' => array(
+ 'type' => 'node',
+ 'label' => t('Make content sticky'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_make_unsticky_action' => array(
+ 'type' => 'node',
+ 'label' => t('Make content unsticky'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_promote_action' => array(
+ 'type' => 'node',
+ 'label' => t('Promote content to front page'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_unpromote_action' => array(
+ 'type' => 'node',
+ 'label' => t('Remove content from front page'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_assign_owner_action' => array(
+ 'type' => 'node',
+ 'label' => t('Change the author of content'),
+ 'configurable' => TRUE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('node_presave', 'comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_save_action' => array(
+ 'type' => 'node',
+ 'label' => t('Save content'),
+ 'configurable' => FALSE,
+ 'triggers' => array('comment_insert', 'comment_update', 'comment_delete'),
+ ),
+ 'node_unpublish_by_keyword_action' => array(
+ 'type' => 'node',
+ 'label' => t('Unpublish content containing keyword(s)'),
+ 'configurable' => TRUE,
+ 'triggers' => array('node_presave', 'node_insert', 'node_update'),
+ ),
+ );
+}
+
+/**
+ * Sets the status of a node to 1 (published).
+ *
+ * @ingroup actions
+ */
+function node_publish_action($node, $context = array()) {
+ $node->status = NODE_PUBLISHED;
+ watchdog('action', 'Set @type %title to published.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Sets the status of a node to 0 (unpublished).
+ *
+ * @ingroup actions
+ */
+function node_unpublish_action($node, $context = array()) {
+ $node->status = NODE_NOT_PUBLISHED;
+ watchdog('action', 'Set @type %title to unpublished.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Sets the sticky-at-top-of-list property of a node to 1.
+ *
+ * @ingroup actions
+ */
+function node_make_sticky_action($node, $context = array()) {
+ $node->sticky = NODE_STICKY;
+ watchdog('action', 'Set @type %title to sticky.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Sets the sticky-at-top-of-list property of a node to 0.
+ *
+ * @ingroup actions
+ */
+function node_make_unsticky_action($node, $context = array()) {
+ $node->sticky = NODE_NOT_STICKY;
+ watchdog('action', 'Set @type %title to unsticky.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Sets the promote property of a node to 1.
+ *
+ * @ingroup actions
+ */
+function node_promote_action($node, $context = array()) {
+ $node->promote = NODE_PROMOTED;
+ watchdog('action', 'Promoted @type %title to front page.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Sets the promote property of a node to 0.
+ *
+ * @ingroup actions
+ */
+function node_unpromote_action($node, $context = array()) {
+ $node->promote = NODE_NOT_PROMOTED;
+ watchdog('action', 'Removed @type %title from front page.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Saves a node.
+ *
+ * @ingroup actions
+ */
+function node_save_action($node) {
+ node_save($node);
+ watchdog('action', 'Saved @type %title', array('@type' => node_type_get_name($node), '%title' => $node->title));
+}
+
+/**
+ * Assigns ownership of a node to a user.
+ *
+ * @param $node
+ * A node object to modify.
+ * @param $context
+ * Array with the following elements:
+ * - 'owner_uid': User ID to assign to the node.
+ *
+ * @ingroup actions
+ */
+function node_assign_owner_action($node, $context) {
+ $node->uid = $context['owner_uid'];
+ $owner_name = db_query("SELECT name FROM {users} WHERE uid = :uid", array(':uid' => $context['owner_uid']))->fetchField();
+ watchdog('action', 'Changed owner of @type %title to uid %name.', array('@type' => node_type_get_name($node), '%title' => $node->title, '%name' => $owner_name));
+}
+
+/**
+ * Generates the settings form for node_assign_owner_action().
+ */
+function node_assign_owner_action_form($context) {
+ $description = t('The username of the user to which you would like to assign ownership.');
+ $count = db_query("SELECT COUNT(*) FROM {users}")->fetchField();
+ $owner_name = '';
+ if (isset($context['owner_uid'])) {
+ $owner_name = db_query("SELECT name FROM {users} WHERE uid = :uid", array(':uid' => $context['owner_uid']))->fetchField();
+ }
+
+ // Use dropdown for fewer than 200 users; textbox for more than that.
+ if (intval($count) < 200) {
+ $options = array();
+ $result = db_query("SELECT uid, name FROM {users} WHERE uid > 0 ORDER BY name");
+ foreach ($result as $data) {
+ $options[$data->name] = $data->name;
+ }
+ $form['owner_name'] = array(
+ '#type' => 'select',
+ '#title' => t('Username'),
+ '#default_value' => $owner_name,
+ '#options' => $options,
+ '#description' => $description,
+ );
+ }
+ else {
+ $form['owner_name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Username'),
+ '#default_value' => $owner_name,
+ '#autocomplete_path' => 'user/autocomplete',
+ '#size' => '6',
+ '#maxlength' => '60',
+ '#description' => $description,
+ );
+ }
+ return $form;
+}
+
+/**
+ * Validates settings form for node_assign_owner_action().
+ */
+function node_assign_owner_action_validate($form, $form_state) {
+ $exists = (bool) db_query_range('SELECT 1 FROM {users} WHERE name = :name', 0, 1, array(':name' => $form_state['values']['owner_name']))->fetchField();
+ if (!$exists) {
+ form_set_error('owner_name', t('Enter a valid username.'));
+ }
+}
+
+/**
+ * Saves settings form for node_assign_owner_action().
+ */
+function node_assign_owner_action_submit($form, $form_state) {
+ // Username can change, so we need to store the ID, not the username.
+ $uid = db_query('SELECT uid from {users} WHERE name = :name', array(':name' => $form_state['values']['owner_name']))->fetchField();
+ return array('owner_uid' => $uid);
+}
+
+/**
+ * Generates settings form for node_unpublish_by_keyword_action().
+ */
+function node_unpublish_by_keyword_action_form($context) {
+ $form['keywords'] = array(
+ '#title' => t('Keywords'),
+ '#type' => 'textarea',
+ '#description' => t('The content will be unpublished if it contains any of the phrases above. Use a case-sensitive, comma-separated list of phrases. Example: funny, bungee jumping, "Company, Inc."'),
+ '#default_value' => isset($context['keywords']) ? drupal_implode_tags($context['keywords']) : '',
+ );
+ return $form;
+}
+
+/**
+ * Saves settings form for node_unpublish_by_keyword_action().
+ */
+function node_unpublish_by_keyword_action_submit($form, $form_state) {
+ return array('keywords' => drupal_explode_tags($form_state['values']['keywords']));
+}
+
+/**
+ * Unpublishes a node containing certain keywords.
+ *
+ * @param $node
+ * A node object to modify.
+ * @param $context
+ * Array with the following elements:
+ * - 'keywords': Array of keywords. If any keyword is present in the rendered
+ * node, the node's status flag is set to unpublished.
+ *
+ * @ingroup actions
+ */
+function node_unpublish_by_keyword_action($node, $context) {
+ foreach ($context['keywords'] as $keyword) {
+ $elements = node_view(clone $node);
+ if (strpos(drupal_render($elements), $keyword) !== FALSE || strpos($node->title, $keyword) !== FALSE) {
+ $node->status = NODE_NOT_PUBLISHED;
+ watchdog('action', 'Set @type %title to unpublished.', array('@type' => node_type_get_name($node), '%title' => $node->title));
+ break;
+ }
+ }
+}
+
+/**
+ * Implements hook_requirements().
+ */
+function node_requirements($phase) {
+ $requirements = array();
+ // Ensure translations don't break at install time
+ $t = get_t();
+ // Only show rebuild button if there are either 0, or 2 or more, rows
+ // in the {node_access} table, or if there are modules that
+ // implement hook_node_grants().
+ $grant_count = db_query('SELECT COUNT(*) FROM {node_access}')->fetchField();
+ if ($grant_count != 1 || count(module_implements('node_grants')) > 0) {
+ $value = format_plural($grant_count, 'One permission in use', '@count permissions in use', array('@count' => $grant_count));
+ } else {
+ $value = $t('Disabled');
+ }
+ $description = $t('If the site is experiencing problems with permissions to content, you may have to rebuild the permissions cache. Rebuilding will remove all privileges to content and replace them with permissions based on the current modules and settings. Rebuilding may take some time if there is a lot of content or complex permission settings. After rebuilding has completed, content will automatically use the new permissions.');
+
+ $requirements['node_access'] = array(
+ 'title' => $t('Node Access Permissions'),
+ 'value' => $value,
+ 'description' => $description . ' ' . l(t('Rebuild permissions'), 'admin/reports/status/rebuild'),
+ );
+ return $requirements;
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function node_modules_enabled($modules) {
+ // Check if any of the newly enabled modules require the node_access table to
+ // be rebuilt.
+ if (!node_access_needs_rebuild() && array_intersect($modules, module_implements('node_grants'))) {
+ node_access_needs_rebuild(TRUE);
+ }
+}
+
+/**
+ * Controller class for nodes.
+ *
+ * This extends the DrupalDefaultEntityController class, adding required
+ * special handling for node objects.
+ */
+class NodeController extends DrupalDefaultEntityController {
+
+ protected function attachLoad(&$nodes, $revision_id = FALSE) {
+ // Create an array of nodes for each content type and pass this to the
+ // object type specific callback.
+ $typed_nodes = array();
+ foreach ($nodes as $id => $entity) {
+ $typed_nodes[$entity->type][$id] = $entity;
+ }
+
+ // Call object type specific callbacks on each typed array of nodes.
+ foreach ($typed_nodes as $node_type => $nodes_of_type) {
+ if (node_hook($node_type, 'load')) {
+ $function = node_type_get_base($node_type) . '_load';
+ $function($nodes_of_type);
+ }
+ }
+ // Besides the list of nodes, pass one additional argument to
+ // hook_node_load(), containing a list of node types that were loaded.
+ $argument = array_keys($typed_nodes);
+ $this->hookLoadArguments = array($argument);
+ parent::attachLoad($nodes, $revision_id);
+ }
+
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ // Ensure that uid is taken from the {node} table,
+ // alias timestamp to revision_timestamp and add revision_uid.
+ $query = parent::buildQuery($ids, $conditions, $revision_id);
+ $fields =& $query->getFields();
+ unset($fields['timestamp']);
+ $query->addField('revision', 'timestamp', 'revision_timestamp');
+ $fields['uid']['table'] = 'base';
+ $query->addField('revision', 'uid', 'revision_uid');
+ return $query;
+ }
+}
+
+/**
+ * Implements hook_file_download_access().
+ */
+function node_file_download_access($field, $entity_type, $entity) {
+ if ($entity_type == 'node') {
+ return node_access('view', $entity);
+ }
+}
+
+/**
+ * Implements hook_locale_language_delete().
+ */
+function node_locale_language_delete($language) {
+ // On nodes with this language, unset the language
+ db_update('node')
+ ->fields(array('language' => ''))
+ ->condition('language', $language->language)
+ ->execute();
+}
diff --git a/core/modules/node/node.pages.inc b/core/modules/node/node.pages.inc
new file mode 100644
index 000000000000..c7b26e75cb21
--- /dev/null
+++ b/core/modules/node/node.pages.inc
@@ -0,0 +1,582 @@
+<?php
+
+/**
+ * @file
+ * Page callbacks for adding, editing, deleting, and revisions management for content.
+ */
+
+
+/**
+ * Menu callback; presents the node editing form.
+ */
+function node_page_edit($node) {
+ $type_name = node_type_get_name($node);
+ drupal_set_title(t('<em>Edit @type</em> @title', array('@type' => $type_name, '@title' => $node->title)), PASS_THROUGH);
+ return drupal_get_form($node->type . '_node_form', $node);
+}
+
+function node_add_page() {
+ $item = menu_get_item();
+ $content = system_admin_menu_block($item);
+ // Bypass the node/add listing if only one content type is available.
+ if (count($content) == 1) {
+ $item = array_shift($content);
+ drupal_goto($item['href']);
+ }
+ return theme('node_add_list', array('content' => $content));
+}
+
+/**
+ * Returns HTML for a list of available node types for node creation.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - content: An array of content types.
+ *
+ * @ingroup themeable
+ */
+function theme_node_add_list($variables) {
+ $content = $variables['content'];
+ $output = '';
+
+ if ($content) {
+ $output = '<dl class="node-type-list">';
+ foreach ($content as $item) {
+ $output .= '<dt>' . l($item['title'], $item['href'], $item['localized_options']) . '</dt>';
+ $output .= '<dd>' . filter_xss_admin($item['description']) . '</dd>';
+ }
+ $output .= '</dl>';
+ }
+ else {
+ $output = '<p>' . t('You have not created any content types yet. Go to the <a href="@create-content">content type creation page</a> to add a new content type.', array('@create-content' => url('admin/structure/types/add'))) . '</p>';
+ }
+ return $output;
+}
+
+
+/**
+ * Returns a node submission form.
+ */
+function node_add($type) {
+ global $user;
+
+ $types = node_type_get_types();
+ $node = (object) array('uid' => $user->uid, 'name' => (isset($user->name) ? $user->name : ''), 'type' => $type, 'language' => LANGUAGE_NONE);
+ drupal_set_title(t('Create @name', array('@name' => $types[$type]->name)), PASS_THROUGH);
+ $output = drupal_get_form($type . '_node_form', $node);
+
+ return $output;
+}
+
+function node_form_validate($form, &$form_state) {
+ // $form_state['node'] contains the actual entity being edited, but we must
+ // not update it with form values that have not yet been validated, so we
+ // create a pseudo-entity to use during validation.
+ $node = (object) $form_state['values'];
+ node_validate($node, $form, $form_state);
+ entity_form_field_validate('node', $form, $form_state);
+}
+
+/**
+ * Generate the node add/edit form array.
+ */
+function node_form($form, &$form_state, $node) {
+ global $user;
+
+ // During initial form build, add the node entity to the form state for use
+ // during form building and processing. During a rebuild, use what is in the
+ // form state.
+ if (!isset($form_state['node'])) {
+ if (!isset($node->title)) {
+ $node->title = NULL;
+ }
+ node_object_prepare($node);
+ $form_state['node'] = $node;
+ }
+ else {
+ $node = $form_state['node'];
+ }
+
+ // Some special stuff when previewing a node.
+ if (isset($form_state['node_preview'])) {
+ $form['#prefix'] = $form_state['node_preview'];
+ $node->in_preview = TRUE;
+ }
+ else {
+ unset($node->in_preview);
+ }
+
+ // Identify this as a node edit form.
+ // @todo D8: Remove. Modules can implement hook_form_BASE_FORM_ID_alter() now.
+ $form['#node_edit_form'] = TRUE;
+
+ // Override the default CSS class name, since the user-defined node type name
+ // in 'TYPE-node-form' potentially clashes with third-party class names.
+ $form['#attributes']['class'][0] = drupal_html_class('node-' . $node->type . '-form');
+
+ // Basic node information.
+ // These elements are just values so they are not even sent to the client.
+ foreach (array('nid', 'vid', 'uid', 'created', 'type', 'language') as $key) {
+ $form[$key] = array(
+ '#type' => 'value',
+ '#value' => isset($node->$key) ? $node->$key : NULL,
+ );
+ }
+
+ // Changed must be sent to the client, for later overwrite error checking.
+ $form['changed'] = array(
+ '#type' => 'hidden',
+ '#default_value' => isset($node->changed) ? $node->changed : NULL,
+ );
+ // Invoke hook_form() to get the node-specific bits. Can't use node_invoke(),
+ // because hook_form() needs to be able to receive $form_state by reference.
+ // @todo hook_form() implementations are unable to add #validate or #submit
+ // handlers to the form buttons below. Remove hook_form() entirely.
+ $function = node_type_get_base($node) . '_form';
+ if (function_exists($function) && ($extra = $function($node, $form_state))) {
+ $form = array_merge_recursive($form, $extra);
+ }
+ // If the node type has a title, and the node type form defined no special
+ // weight for it, we default to a weight of -5 for consistency.
+ if (isset($form['title']) && !isset($form['title']['#weight'])) {
+ $form['title']['#weight'] = -5;
+ }
+ // @todo D8: Remove. Modules should access the node using $form_state['node'].
+ $form['#node'] = $node;
+
+ $form['additional_settings'] = array(
+ '#type' => 'vertical_tabs',
+ '#weight' => 99,
+ );
+
+ // Add a log field if the "Create new revision" option is checked, or if the
+ // current user has the ability to check that option.
+ $form['revision_information'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Revision information'),
+ '#collapsible' => TRUE,
+ // Collapsed by default when "Create new revision" is unchecked
+ '#collapsed' => !$node->revision,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('node-form-revision-information'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'node') . '/node.js'),
+ ),
+ '#weight' => 20,
+ '#access' => $node->revision || user_access('administer nodes'),
+ );
+ $form['revision_information']['revision'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Create new revision'),
+ '#default_value' => $node->revision,
+ '#access' => user_access('administer nodes'),
+ );
+ // Check the revision log checkbox when the log textarea is filled in.
+ // This must not happen if "Create new revision" is enabled by default, since
+ // the state would auto-disable the checkbox otherwise.
+ if (!$node->revision) {
+ $form['revision_information']['revision']['#states'] = array(
+ 'checked' => array(
+ 'textarea[name="log"]' => array('empty' => FALSE),
+ ),
+ );
+ }
+ $form['revision_information']['log'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Revision log message'),
+ '#rows' => 4,
+ '#default_value' => !empty($node->log) ? $node->log : '',
+ '#description' => t('Briefly describe the changes you have made.'),
+ );
+
+ // Node author information for administrators
+ $form['author'] = array(
+ '#type' => 'fieldset',
+ '#access' => user_access('administer nodes'),
+ '#title' => t('Authoring information'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('node-form-author'),
+ ),
+ '#attached' => array(
+ 'js' => array(
+ drupal_get_path('module', 'node') . '/node.js',
+ array(
+ 'type' => 'setting',
+ 'data' => array('anonymous' => variable_get('anonymous', t('Anonymous'))),
+ ),
+ ),
+ ),
+ '#weight' => 90,
+ );
+ $form['author']['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Authored by'),
+ '#maxlength' => 60,
+ '#autocomplete_path' => 'user/autocomplete',
+ '#default_value' => !empty($node->name) ? $node->name : '',
+ '#weight' => -1,
+ '#description' => t('Leave blank for %anonymous.', array('%anonymous' => variable_get('anonymous', t('Anonymous')))),
+ );
+ $form['author']['date'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Authored on'),
+ '#maxlength' => 25,
+ '#description' => t('Format: %time. The date format is YYYY-MM-DD and %timezone is the time zone offset from UTC. Leave blank to use the time of form submission.', array('%time' => !empty($node->date) ? date_format(date_create($node->date), 'Y-m-d H:i:s O') : format_date($node->created, 'custom', 'Y-m-d H:i:s O'), '%timezone' => !empty($node->date) ? date_format(date_create($node->date), 'O') : format_date($node->created, 'custom', 'O'))),
+ '#default_value' => !empty($node->date) ? $node->date : '',
+ );
+
+ // Node options for administrators
+ $form['options'] = array(
+ '#type' => 'fieldset',
+ '#access' => user_access('administer nodes'),
+ '#title' => t('Publishing options'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('node-form-options'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'node') . '/node.js'),
+ ),
+ '#weight' => 95,
+ );
+ $form['options']['status'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Published'),
+ '#default_value' => $node->status,
+ );
+ $form['options']['promote'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Promoted to front page'),
+ '#default_value' => $node->promote,
+ );
+ $form['options']['sticky'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Sticky at top of lists'),
+ '#default_value' => $node->sticky,
+ );
+
+ // Add the buttons.
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#access' => variable_get('node_preview_' . $node->type, DRUPAL_OPTIONAL) != DRUPAL_REQUIRED || (!form_get_errors() && isset($form_state['node_preview'])),
+ '#value' => t('Save'),
+ '#weight' => 5,
+ '#submit' => array('node_form_submit'),
+ );
+ $form['actions']['preview'] = array(
+ '#access' => variable_get('node_preview_' . $node->type, DRUPAL_OPTIONAL) != DRUPAL_DISABLED,
+ '#type' => 'submit',
+ '#value' => t('Preview'),
+ '#weight' => 10,
+ '#submit' => array('node_form_build_preview'),
+ );
+ if (!empty($node->nid) && node_access('delete', $node)) {
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#weight' => 15,
+ '#submit' => array('node_form_delete_submit'),
+ );
+ }
+ // This form uses a button-level #submit handler for the form's main submit
+ // action. node_form_submit() manually invokes all form-level #submit handlers
+ // of the form. Without explicitly setting #submit, Form API would auto-detect
+ // node_form_submit() as submit handler, but that is the button-level #submit
+ // handler for the 'Save' action. To maintain backwards compatibility, a
+ // #submit handler is auto-suggested for custom node type modules.
+ $form['#validate'][] = 'node_form_validate';
+ if (!isset($form['#submit']) && function_exists($node->type . '_node_form_submit')) {
+ $form['#submit'][] = $node->type . '_node_form_submit';
+ }
+ $form += array('#submit' => array());
+
+ field_attach_form('node', $node, $form, $form_state, $node->language);
+ return $form;
+}
+
+/**
+ * Button submit function: handle the 'Delete' button on the node form.
+ */
+function node_form_delete_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ }
+ $node = $form['#node'];
+ $form_state['redirect'] = array('node/' . $node->nid . '/delete', array('query' => $destination));
+}
+
+
+function node_form_build_preview($form, &$form_state) {
+ $node = node_form_submit_build_node($form, $form_state);
+ $form_state['node_preview'] = node_preview($node);
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Generate a node preview.
+ */
+function node_preview($node) {
+ if (node_access('create', $node) || node_access('update', $node)) {
+ _field_invoke_multiple('load', 'node', array($node->nid => $node));
+ // Load the user's name when needed.
+ if (isset($node->name)) {
+ // The use of isset() is mandatory in the context of user IDs, because
+ // user ID 0 denotes the anonymous user.
+ if ($user = user_load_by_name($node->name)) {
+ $node->uid = $user->uid;
+ $node->picture = $user->picture;
+ }
+ else {
+ $node->uid = 0; // anonymous user
+ }
+ }
+ elseif ($node->uid) {
+ $user = user_load($node->uid);
+ $node->name = $user->name;
+ $node->picture = $user->picture;
+ }
+
+ $node->changed = REQUEST_TIME;
+ $nodes = array($node->nid => $node);
+ field_attach_prepare_view('node', $nodes, 'full');
+
+ // Display a preview of the node.
+ if (!form_get_errors()) {
+ $node->in_preview = TRUE;
+ $output = theme('node_preview', array('node' => $node));
+ unset($node->in_preview);
+ }
+ drupal_set_title(t('Preview'), PASS_THROUGH);
+
+ return $output;
+ }
+}
+
+/**
+ * Returns HTML for a node preview for display during node creation and editing.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - node: The node object which is being previewed.
+ *
+ * @ingroup themeable
+ */
+function theme_node_preview($variables) {
+ $node = $variables['node'];
+
+ $output = '<div class="preview">';
+
+ $preview_trimmed_version = FALSE;
+
+ $elements = node_view(clone $node, 'teaser');
+ $trimmed = drupal_render($elements);
+ $elements = node_view($node, 'full');
+ $full = drupal_render($elements);
+
+ // Do we need to preview trimmed version of post as well as full version?
+ if ($trimmed != $full) {
+ drupal_set_message(t('The trimmed version of your post shows what your post looks like when promoted to the main page or when exported for syndication.<span class="no-js"> You can insert the delimiter "&lt;!--break--&gt;" (without the quotes) to fine-tune where your post gets split.</span>'));
+ $output .= '<h3>' . t('Preview trimmed version') . '</h3>';
+ $output .= $trimmed;
+ $output .= '<h3>' . t('Preview full version') . '</h3>';
+ $output .= $full;
+ }
+ else {
+ $output .= $full;
+ }
+ $output .= "</div>\n";
+
+ return $output;
+}
+
+function node_form_submit($form, &$form_state) {
+ $node = node_form_submit_build_node($form, $form_state);
+ $insert = empty($node->nid);
+ node_save($node);
+ $node_link = l(t('view'), 'node/' . $node->nid);
+ $watchdog_args = array('@type' => $node->type, '%title' => $node->title);
+ $t_args = array('@type' => node_type_get_name($node), '%title' => $node->title);
+
+ if ($insert) {
+ watchdog('content', '@type: added %title.', $watchdog_args, WATCHDOG_NOTICE, $node_link);
+ drupal_set_message(t('@type %title has been created.', $t_args));
+ }
+ else {
+ watchdog('content', '@type: updated %title.', $watchdog_args, WATCHDOG_NOTICE, $node_link);
+ drupal_set_message(t('@type %title has been updated.', $t_args));
+ }
+ if ($node->nid) {
+ $form_state['values']['nid'] = $node->nid;
+ $form_state['nid'] = $node->nid;
+ $form_state['redirect'] = 'node/' . $node->nid;
+ }
+ else {
+ // In the unlikely case something went wrong on save, the node will be
+ // rebuilt and node form redisplayed the same way as in preview.
+ drupal_set_message(t('The post could not be saved.'), 'error');
+ $form_state['rebuild'] = TRUE;
+ }
+ // Clear the page and block caches.
+ cache_clear_all();
+}
+
+/**
+ * Updates the form state's node entity by processing this submission's values.
+ *
+ * This is the default builder function for the node form. It is called
+ * during the "Save" and "Preview" submit handlers to retrieve the entity to
+ * save or preview. This function can also be called by a "Next" button of a
+ * wizard to update the form state's entity with the current step's values
+ * before proceeding to the next step.
+ *
+ * @see node_form()
+ */
+function node_form_submit_build_node($form, &$form_state) {
+ // @todo Legacy support for modules that extend the node form with form-level
+ // submit handlers that adjust $form_state['values'] prior to those values
+ // being used to update the entity. Module authors are encouraged to instead
+ // adjust the node directly within a hook_node_submit() implementation. For
+ // Drupal 8, evaluate whether the pattern of triggering form-level submit
+ // handlers during button-level submit processing is worth supporting
+ // properly, and if so, add a Form API function for doing so.
+ unset($form_state['submit_handlers']);
+ form_execute_handlers('submit', $form, $form_state);
+
+ $node = $form_state['node'];
+ entity_form_submit_build_entity('node', $node, $form, $form_state);
+
+ node_submit($node);
+ foreach (module_implements('node_submit') as $module) {
+ $function = $module . '_node_submit';
+ $function($node, $form, $form_state);
+ }
+ return $node;
+}
+
+/**
+ * Menu callback -- ask for confirmation of node deletion
+ */
+function node_delete_confirm($form, &$form_state, $node) {
+ $form['#node'] = $node;
+ // Always provide entity id in the same form key as in the entity edit form.
+ $form['nid'] = array('#type' => 'value', '#value' => $node->nid);
+ return confirm_form($form,
+ t('Are you sure you want to delete %title?', array('%title' => $node->title)),
+ 'node/' . $node->nid,
+ t('This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Execute node deletion
+ */
+function node_delete_confirm_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ $node = node_load($form_state['values']['nid']);
+ node_delete($form_state['values']['nid']);
+ watchdog('content', '@type: deleted %title.', array('@type' => $node->type, '%title' => $node->title));
+ drupal_set_message(t('@type %title has been deleted.', array('@type' => node_type_get_name($node), '%title' => $node->title)));
+ }
+
+ $form_state['redirect'] = '<front>';
+}
+
+/**
+ * Generate an overview table of older revisions of a node.
+ */
+function node_revision_overview($node) {
+ drupal_set_title(t('Revisions for %title', array('%title' => $node->title)), PASS_THROUGH);
+
+ $header = array(t('Revision'), array('data' => t('Operations'), 'colspan' => 2));
+
+ $revisions = node_revision_list($node);
+
+ $rows = array();
+ $revert_permission = FALSE;
+ if ((user_access('revert revisions') || user_access('administer nodes')) && node_access('update', $node)) {
+ $revert_permission = TRUE;
+ }
+ $delete_permission = FALSE;
+ if ((user_access('delete revisions') || user_access('administer nodes')) && node_access('delete', $node)) {
+ $delete_permission = TRUE;
+ }
+ foreach ($revisions as $revision) {
+ $row = array();
+ $operations = array();
+
+ if ($revision->current_vid > 0) {
+ $row[] = array('data' => t('!date by !username', array('!date' => l(format_date($revision->timestamp, 'short'), "node/$node->nid"), '!username' => theme('username', array('account' => $revision))))
+ . (($revision->log != '') ? '<p class="revision-log">' . filter_xss($revision->log) . '</p>' : ''),
+ 'class' => array('revision-current'));
+ $operations[] = array('data' => drupal_placeholder(t('current revision')), 'class' => array('revision-current'), 'colspan' => 2);
+ }
+ else {
+ $row[] = t('!date by !username', array('!date' => l(format_date($revision->timestamp, 'short'), "node/$node->nid/revisions/$revision->vid/view"), '!username' => theme('username', array('account' => $revision))))
+ . (($revision->log != '') ? '<p class="revision-log">' . filter_xss($revision->log) . '</p>' : '');
+ if ($revert_permission) {
+ $operations[] = l(t('revert'), "node/$node->nid/revisions/$revision->vid/revert");
+ }
+ if ($delete_permission) {
+ $operations[] = l(t('delete'), "node/$node->nid/revisions/$revision->vid/delete");
+ }
+ }
+ $rows[] = array_merge($row, $operations);
+ }
+
+ $build['node_revisions_table'] = array(
+ '#theme' => 'table',
+ '#rows' => $rows,
+ '#header' => $header,
+ );
+
+ return $build;
+}
+
+/**
+ * Ask for confirmation of the reversion to prevent against CSRF attacks.
+ */
+function node_revision_revert_confirm($form, $form_state, $node_revision) {
+ $form['#node_revision'] = $node_revision;
+ return confirm_form($form, t('Are you sure you want to revert to the revision from %revision-date?', array('%revision-date' => format_date($node_revision->revision_timestamp))), 'node/' . $node_revision->nid . '/revisions', '', t('Revert'), t('Cancel'));
+}
+
+function node_revision_revert_confirm_submit($form, &$form_state) {
+ $node_revision = $form['#node_revision'];
+ $node_revision->revision = 1;
+ $node_revision->log = t('Copy of the revision from %date.', array('%date' => format_date($node_revision->revision_timestamp)));
+
+ node_save($node_revision);
+
+ watchdog('content', '@type: reverted %title revision %revision.', array('@type' => $node_revision->type, '%title' => $node_revision->title, '%revision' => $node_revision->vid));
+ drupal_set_message(t('@type %title has been reverted back to the revision from %revision-date.', array('@type' => node_type_get_name($node_revision), '%title' => $node_revision->title, '%revision-date' => format_date($node_revision->revision_timestamp))));
+ $form_state['redirect'] = 'node/' . $node_revision->nid . '/revisions';
+}
+
+function node_revision_delete_confirm($form, $form_state, $node_revision) {
+ $form['#node_revision'] = $node_revision;
+ return confirm_form($form, t('Are you sure you want to delete the revision from %revision-date?', array('%revision-date' => format_date($node_revision->revision_timestamp))), 'node/' . $node_revision->nid . '/revisions', t('This action cannot be undone.'), t('Delete'), t('Cancel'));
+}
+
+function node_revision_delete_confirm_submit($form, &$form_state) {
+ $node_revision = $form['#node_revision'];
+ node_revision_delete($node_revision->vid);
+
+ watchdog('content', '@type: deleted %title revision %revision.', array('@type' => $node_revision->type, '%title' => $node_revision->title, '%revision' => $node_revision->vid));
+ drupal_set_message(t('Revision from %revision-date of @type %title has been deleted.', array('%revision-date' => format_date($node_revision->revision_timestamp), '@type' => node_type_get_name($node_revision), '%title' => $node_revision->title)));
+ $form_state['redirect'] = 'node/' . $node_revision->nid;
+ if (db_query('SELECT COUNT(vid) FROM {node_revision} WHERE nid = :nid', array(':nid' => $node_revision->nid))->fetchField() > 1) {
+ $form_state['redirect'] .= '/revisions';
+ }
+}
diff --git a/core/modules/node/node.test b/core/modules/node/node.test
new file mode 100644
index 000000000000..817f3908e01b
--- /dev/null
+++ b/core/modules/node/node.test
@@ -0,0 +1,2293 @@
+<?php
+
+/**
+ * @file
+ * Tests for node.module.
+ */
+
+/**
+ * Test the node_load_multiple() function.
+ */
+class NodeLoadMultipleUnitTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Load multiple nodes',
+ 'description' => 'Test the loading of multiple nodes.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $web_user = $this->drupalCreateUser(array('create article content', 'create page content'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Create four nodes and ensure they're loaded correctly.
+ */
+ function testNodeMultipleLoad() {
+ $node1 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $node2 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $node3 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 0));
+ $node4 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 0));
+
+ // Confirm that promoted nodes appear in the default node listing.
+ $this->drupalGet('node');
+ $this->assertText($node1->title, t('Node title appears on the default listing.'));
+ $this->assertText($node2->title, t('Node title appears on the default listing.'));
+ $this->assertNoText($node3->title, t('Node title does not appear in the default listing.'));
+ $this->assertNoText($node4->title, t('Node title does not appear in the default listing.'));
+
+ // Load nodes with only a condition. Nodes 3 and 4 will be loaded.
+ $nodes = node_load_multiple(NULL, array('promote' => 0));
+ $this->assertEqual($node3->title, $nodes[$node3->nid]->title, t('Node was loaded.'));
+ $this->assertEqual($node4->title, $nodes[$node4->nid]->title, t('Node was loaded.'));
+ $count = count($nodes);
+ $this->assertTrue($count == 2, t('@count nodes loaded.', array('@count' => $count)));
+
+ // Load nodes by nid. Nodes 1, 2 and 4 will be loaded.
+ $nodes = node_load_multiple(array(1, 2, 4));
+ $count = count($nodes);
+ $this->assertTrue(count($nodes) == 3, t('@count nodes loaded', array('@count' => $count)));
+ $this->assertTrue(isset($nodes[$node1->nid]), t('Node is correctly keyed in the array'));
+ $this->assertTrue(isset($nodes[$node2->nid]), t('Node is correctly keyed in the array'));
+ $this->assertTrue(isset($nodes[$node4->nid]), t('Node is correctly keyed in the array'));
+ foreach ($nodes as $node) {
+ $this->assertTrue(is_object($node), t('Node is an object'));
+ }
+
+ // Load nodes by nid, where type = article. Nodes 1, 2 and 3 will be loaded.
+ $nodes = node_load_multiple(array(1, 2, 3, 4), array('type' => 'article'));
+ $count = count($nodes);
+ $this->assertTrue($count == 3, t('@count nodes loaded', array('@count' => $count)));
+ $this->assertEqual($nodes[$node1->nid]->title, $node1->title, t('Node successfully loaded.'));
+ $this->assertEqual($nodes[$node2->nid]->title, $node2->title, t('Node successfully loaded.'));
+ $this->assertEqual($nodes[$node3->nid]->title, $node3->title, t('Node successfully loaded.'));
+ $this->assertFalse(isset($nodes[$node4->nid]));
+
+ // Now that all nodes have been loaded into the static cache, ensure that
+ // they are loaded correctly again when a condition is passed.
+ $nodes = node_load_multiple(array(1, 2, 3, 4), array('type' => 'article'));
+ $count = count($nodes);
+ $this->assertTrue($count == 3, t('@count nodes loaded.', array('@count' => $count)));
+ $this->assertEqual($nodes[$node1->nid]->title, $node1->title, t('Node successfully loaded'));
+ $this->assertEqual($nodes[$node2->nid]->title, $node2->title, t('Node successfully loaded'));
+ $this->assertEqual($nodes[$node3->nid]->title, $node3->title, t('Node successfully loaded'));
+ $this->assertFalse(isset($nodes[$node4->nid]), t('Node was not loaded'));
+
+ // Load nodes by nid, where type = article and promote = 0.
+ $nodes = node_load_multiple(array(1, 2, 3, 4), array('type' => 'article', 'promote' => 0));
+ $count = count($nodes);
+ $this->assertTrue($count == 1, t('@count node loaded', array('@count' => $count)));
+ $this->assertEqual($nodes[$node3->nid]->title, $node3->title, t('Node successfully loaded.'));
+ }
+}
+
+/**
+ * Tests for the hooks invoked during node_load().
+ */
+class NodeLoadHooksTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node load hooks',
+ 'description' => 'Test the hooks invoked when a node is being loaded.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('node_test');
+ }
+
+ /**
+ * Test that hook_node_load() is invoked correctly.
+ */
+ function testHookNodeLoad() {
+ // Create some sample articles and pages.
+ $node1 = $this->drupalCreateNode(array('type' => 'article', 'status' => NODE_PUBLISHED));
+ $node2 = $this->drupalCreateNode(array('type' => 'article', 'status' => NODE_PUBLISHED));
+ $node3 = $this->drupalCreateNode(array('type' => 'article', 'status' => NODE_NOT_PUBLISHED));
+ $node4 = $this->drupalCreateNode(array('type' => 'page', 'status' => NODE_NOT_PUBLISHED));
+
+ // Check that when a set of nodes that only contains articles is loaded,
+ // the properties added to the node by node_test_load_node() correctly
+ // reflect the expected values.
+ $nodes = node_load_multiple(array(), array('status' => NODE_PUBLISHED));
+ $loaded_node = end($nodes);
+ $this->assertEqual($loaded_node->node_test_loaded_nids, array($node1->nid, $node2->nid), t('hook_node_load() received the correct list of node IDs the first time it was called.'));
+ $this->assertEqual($loaded_node->node_test_loaded_types, array('article'), t('hook_node_load() received the correct list of node types the first time it was called.'));
+
+ // Now, as part of the same page request, load a set of nodes that contain
+ // both articles and pages, and make sure the parameters passed to
+ // node_test_node_load() are correctly updated.
+ $nodes = node_load_multiple(array(), array('status' => NODE_NOT_PUBLISHED));
+ $loaded_node = end($nodes);
+ $this->assertEqual($loaded_node->node_test_loaded_nids, array($node3->nid, $node4->nid), t('hook_node_load() received the correct list of node IDs the second time it was called.'));
+ $this->assertEqual($loaded_node->node_test_loaded_types, array('article', 'page'), t('hook_node_load() received the correct list of node types the second time it was called.'));
+ }
+}
+
+class NodeRevisionsTestCase extends DrupalWebTestCase {
+ protected $nodes;
+ protected $logs;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node revisions',
+ 'description' => 'Create a node with revisions and test viewing, saving, reverting, and deleting revisions.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create and login user.
+ $web_user = $this->drupalCreateUser(array('view revisions', 'revert revisions', 'edit any page content',
+ 'delete revisions', 'delete any page content'));
+ $this->drupalLogin($web_user);
+
+ // Create initial node.
+ $node = $this->drupalCreateNode();
+ $settings = get_object_vars($node);
+ $settings['revision'] = 1;
+
+ $nodes = array();
+ $logs = array();
+
+ // Get original node.
+ $nodes[] = $node;
+
+ // Create three revisions.
+ $revision_count = 3;
+ for ($i = 0; $i < $revision_count; $i++) {
+ $logs[] = $settings['log'] = $this->randomName(32);
+
+ // Create revision with random title and body and update variables.
+ $this->drupalCreateNode($settings);
+ $node = node_load($node->nid); // Make sure we get revision information.
+ $settings = get_object_vars($node);
+
+ $nodes[] = $node;
+ }
+
+ $this->nodes = $nodes;
+ $this->logs = $logs;
+ }
+
+ /**
+ * Check node revision related operations.
+ */
+ function testRevisions() {
+ $nodes = $this->nodes;
+ $logs = $this->logs;
+
+ // Get last node for simple checks.
+ $node = $nodes[3];
+
+ // Confirm the correct revision text appears on "view revisions" page.
+ $this->drupalGet("node/$node->nid/revisions/$node->vid/view");
+ $this->assertText($node->body[LANGUAGE_NONE][0]['value'], t('Correct text displays for version.'));
+
+ // Confirm the correct log message appears on "revisions overview" page.
+ $this->drupalGet("node/$node->nid/revisions");
+ foreach ($logs as $log) {
+ $this->assertText($log, t('Log message found.'));
+ }
+
+ // Confirm that revisions revert properly.
+ $this->drupalPost("node/$node->nid/revisions/{$nodes[1]->vid}/revert", array(), t('Revert'));
+ $this->assertRaw(t('@type %title has been reverted back to the revision from %revision-date.',
+ array('@type' => 'Basic page', '%title' => $nodes[1]->title,
+ '%revision-date' => format_date($nodes[1]->revision_timestamp))), t('Revision reverted.'));
+ $reverted_node = node_load($node->nid);
+ $this->assertTrue(($nodes[1]->body[LANGUAGE_NONE][0]['value'] == $reverted_node->body[LANGUAGE_NONE][0]['value']), t('Node reverted correctly.'));
+
+ // Confirm revisions delete properly.
+ $this->drupalPost("node/$node->nid/revisions/{$nodes[1]->vid}/delete", array(), t('Delete'));
+ $this->assertRaw(t('Revision from %revision-date of @type %title has been deleted.',
+ array('%revision-date' => format_date($nodes[1]->revision_timestamp),
+ '@type' => 'Basic page', '%title' => $nodes[1]->title)), t('Revision deleted.'));
+ $this->assertTrue(db_query('SELECT COUNT(vid) FROM {node_revision} WHERE nid = :nid and vid = :vid', array(':nid' => $node->nid, ':vid' => $nodes[1]->vid))->fetchField() == 0, t('Revision not found.'));
+ }
+
+ /**
+ * Checks that revisions are correctly saved without log messages.
+ */
+ function testNodeRevisionWithoutLogMessage() {
+ // Create a node with an initial log message.
+ $log = $this->randomName(10);
+ $node = $this->drupalCreateNode(array('log' => $log));
+
+ // Save over the same revision and explicitly provide an empty log message
+ // (for example, to mimic the case of a node form submitted with no text in
+ // the "log message" field), and check that the original log message is
+ // preserved.
+ $new_title = $this->randomName(10) . 'testNodeRevisionWithoutLogMessage1';
+ $updated_node = (object) array(
+ 'nid' => $node->nid,
+ 'vid' => $node->vid,
+ 'uid' => $node->uid,
+ 'type' => $node->type,
+ 'title' => $new_title,
+ 'log' => '',
+ );
+ node_save($updated_node);
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($new_title, t('New node title appears on the page.'));
+ $node_revision = node_load($node->nid, NULL, TRUE);
+ $this->assertEqual($node_revision->log, $log, t('After an existing node revision is re-saved without a log message, the original log message is preserved.'));
+
+ // Create another node with an initial log message.
+ $node = $this->drupalCreateNode(array('log' => $log));
+
+ // Save a new node revision without providing a log message, and check that
+ // this revision has an empty log message.
+ $new_title = $this->randomName(10) . 'testNodeRevisionWithoutLogMessage2';
+ $updated_node = (object) array(
+ 'nid' => $node->nid,
+ 'vid' => $node->vid,
+ 'uid' => $node->uid,
+ 'type' => $node->type,
+ 'title' => $new_title,
+ 'revision' => 1,
+ );
+ node_save($updated_node);
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($new_title, 'New node title appears on the page.');
+ $node_revision = node_load($node->nid, NULL, TRUE);
+ $this->assertTrue(empty($node_revision->log), 'After a new node revision is saved with an empty log message, the log message for the node is empty.');
+ }
+}
+
+class PageEditTestCase extends DrupalWebTestCase {
+ protected $web_user;
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node edit',
+ 'description' => 'Create a node and test node edit functionality.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->web_user = $this->drupalCreateUser(array('edit own page content', 'create page content'));
+ $this->admin_user = $this->drupalCreateUser(array('bypass node access', 'administer nodes'));
+ }
+
+ /**
+ * Check node edit functionality.
+ */
+ function testPageEdit() {
+ $this->drupalLogin($this->web_user);
+
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $body_key = "body[$langcode][0][value]";
+ // Create node to edit.
+ $edit = array();
+ $edit[$title_key] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the node exists in the database.
+ $node = $this->drupalGetNodeByTitle($edit[$title_key]);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ // Check that "edit" link points to correct page.
+ $this->clickLink(t('Edit'));
+ $edit_url = url("node/$node->nid/edit", array('absolute' => TRUE));
+ $actual_url = $this->getURL();
+ $this->assertEqual($edit_url, $actual_url, t('On edit page.'));
+
+ // Check that the title and body fields are displayed with the correct values.
+ $active = '<span class="element-invisible">' . t('(active tab)') . '</span>';
+ $link_text = t('!local-task-title!active', array('!local-task-title' => t('Edit'), '!active' => $active));
+ $this->assertText(strip_tags($link_text), 0, t('Edit tab found and marked active.'));
+ $this->assertFieldByName($title_key, $edit[$title_key], t('Title field displayed.'));
+ $this->assertFieldByName($body_key, $edit[$body_key], t('Body field displayed.'));
+
+ // Edit the content of the node.
+ $edit = array();
+ $edit[$title_key] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ // Stay on the current page, without reloading.
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Check that the title and body fields are displayed with the updated values.
+ $this->assertText($edit[$title_key], t('Title displayed.'));
+ $this->assertText($edit[$body_key], t('Body displayed.'));
+
+ // Login as a second administrator user.
+ $second_web_user = $this->drupalCreateUser(array('administer nodes', 'edit any page content'));
+ $this->drupalLogin($second_web_user);
+ // Edit the same node, creating a new revision.
+ $this->drupalGet("node/$node->nid/edit");
+ $edit = array();
+ $edit['title'] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ $edit['revision'] = TRUE;
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Ensure that the node revision has been created.
+ $revised_node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertNotIdentical($node->vid, $revised_node->vid, 'A new revision has been created.');
+ // Ensure that the node author is preserved when it was not changed in the
+ // edit form.
+ $this->assertIdentical($node->uid, $revised_node->uid, 'The node author has been preserved.');
+ // Ensure that the revision authors are different since the revisions were
+ // made by different users.
+ $first_node_version = node_load($node->nid, $node->vid);
+ $second_node_version = node_load($node->nid, $revised_node->vid);
+ $this->assertNotIdentical($first_node_version->revision_uid, $second_node_version->revision_uid, 'Each revision has a distinct user.');
+ }
+
+ /**
+ * Check changing node authored by fields.
+ */
+ function testPageAuthoredBy() {
+ $this->drupalLogin($this->admin_user);
+
+ // Create node to edit.
+ $langcode = LANGUAGE_NONE;
+ $body_key = "body[$langcode][0][value]";
+ $edit = array();
+ $edit['title'] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the node was authored by the currently logged in user.
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertIdentical($node->uid, $this->admin_user->uid, 'Node authored by admin user.');
+
+ // Try to change the 'authored by' field to an invalid user name.
+ $edit = array(
+ 'name' => 'invalid-name',
+ );
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertText('The username invalid-name does not exist.');
+
+ // Change the authored by field to an empty string, which should assign
+ // authorship to the anonymous user (uid 0).
+ $edit['name'] = '';
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $node = node_load($node->nid, NULL, TRUE);
+ $this->assertIdentical($node->uid, '0', 'Node authored by anonymous user.');
+
+ // Change the authored by field to another user's name (that is not
+ // logged in).
+ $edit['name'] = $this->web_user->name;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $node = node_load($node->nid, NULL, TRUE);
+ $this->assertIdentical($node->uid, $this->web_user->uid, 'Node authored by normal user.');
+
+ // Check that normal users cannot change the authored by information.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertNoFieldByName('name');
+ }
+}
+
+class PagePreviewTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node preview',
+ 'description' => 'Test node preview functionality.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $web_user = $this->drupalCreateUser(array('edit own page content', 'create page content'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Check the node preview functionality.
+ */
+ function testPagePreview() {
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $body_key = "body[$langcode][0][value]";
+
+ // Fill in node creation form and preview node.
+ $edit = array();
+ $edit[$title_key] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Preview'));
+
+ // Check that the preview is displaying the title and body.
+ $this->assertTitle(t('Preview | Drupal'), t('Basic page title is preview.'));
+ $this->assertText($edit[$title_key], t('Title displayed.'));
+ $this->assertText($edit[$body_key], t('Body displayed.'));
+
+ // Check that the title and body fields are displayed with the correct values.
+ $this->assertFieldByName($title_key, $edit[$title_key], t('Title field displayed.'));
+ $this->assertFieldByName($body_key, $edit[$body_key], t('Body field displayed.'));
+ }
+
+ /**
+ * Check the node preview functionality, when using revisions.
+ */
+ function testPagePreviewWithRevisions() {
+ $langcode = LANGUAGE_NONE;
+ $title_key = "title";
+ $body_key = "body[$langcode][0][value]";
+ // Force revision on "Basic page" content.
+ variable_set('node_options_page', array('status', 'revision'));
+
+ // Fill in node creation form and preview node.
+ $edit = array();
+ $edit[$title_key] = $this->randomName(8);
+ $edit[$body_key] = $this->randomName(16);
+ $edit['log'] = $this->randomName(32);
+ $this->drupalPost('node/add/page', $edit, t('Preview'));
+
+ // Check that the preview is displaying the title and body.
+ $this->assertTitle(t('Preview | Drupal'), t('Basic page title is preview.'));
+ $this->assertText($edit[$title_key], t('Title displayed.'));
+ $this->assertText($edit[$body_key], t('Body displayed.'));
+
+ // Check that the title and body fields are displayed with the correct values.
+ $this->assertFieldByName($title_key, $edit[$title_key], t('Title field displayed.'));
+ $this->assertFieldByName($body_key, $edit[$body_key], t('Body field displayed.'));
+
+ // Check that the log field has the correct value.
+ $this->assertFieldByName('log', $edit['log'], t('Log field displayed.'));
+ }
+}
+
+class NodeCreationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node creation',
+ 'description' => 'Create a node and test saving it.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ // Enable dummy module that implements hook_node_insert for exceptions.
+ parent::setUp('node_test_exception');
+
+ $web_user = $this->drupalCreateUser(array('create page content', 'edit own page content'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Create a "Basic page" node and verify its consistency in the database.
+ */
+ function testNodeCreation() {
+ // Create a node.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName(8);
+ $edit["body[$langcode][0][value]"] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the Basic page has been created.
+ $this->assertRaw(t('!post %title has been created.', array('!post' => 'Basic page', '%title' => $edit["title"])), t('Basic page created.'));
+
+ // Check that the node exists in the database.
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertTrue($node, t('Node found in database.'));
+ }
+
+ /**
+ * Create a page node and verify that a transaction rolls back the failed creation
+ */
+ function testFailedPageCreation() {
+ // Create a node.
+ $edit = array(
+ 'uid' => $this->loggedInUser->uid,
+ 'name' => $this->loggedInUser->name,
+ 'type' => 'page',
+ 'language' => LANGUAGE_NONE,
+ 'title' => 'testing_transaction_exception',
+ );
+
+ try {
+ node_save((object) $edit);
+ $this->fail(t('Expected exception has not been thrown.'));
+ }
+ catch (Exception $e) {
+ $this->pass(t('Expected exception has been thrown.'));
+ }
+
+ if (Database::getConnection()->supportsTransactions()) {
+ // Check that the node does not exist in the database.
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertFalse($node, t('Transactions supported, and node not found in database.'));
+ }
+ else {
+ // Check that the node exists in the database.
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertTrue($node, t('Transactions not supported, and node found in database.'));
+
+ // Check that the failed rollback was logged.
+ $records = db_query("SELECT wid FROM {watchdog} WHERE message LIKE 'Explicit rollback failed%'")->fetchAll();
+ $this->assertTrue(count($records) > 0, t('Transactions not supported, and rollback error logged to watchdog.'));
+ }
+
+ // Check that the rollback error was logged.
+ $records = db_query("SELECT wid FROM {watchdog} WHERE variables LIKE '%Test exception for rollback.%'")->fetchAll();
+ $this->assertTrue(count($records) > 0, t('Rollback explanatory error logged to watchdog.'));
+ }
+}
+
+class PageViewTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node edit permissions',
+ 'description' => 'Create a node and test edit permissions.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Creates a node and then an anonymous and unpermissioned user attempt to edit the node.
+ */
+ function testPageView() {
+ // Create a node to view.
+ $node = $this->drupalCreateNode();
+ $this->assertTrue(node_load($node->nid), t('Node created.'));
+
+ // Try to edit with anonymous user.
+ $html = $this->drupalGet("node/$node->nid/edit");
+ $this->assertResponse(403);
+
+ // Create a user without permission to edit node.
+ $web_user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($web_user);
+
+ // Attempt to access edit page.
+ $this->drupalGet("node/$node->nid/edit");
+ $this->assertResponse(403);
+
+ // Create user with permission to edit node.
+ $web_user = $this->drupalCreateUser(array('bypass node access'));
+ $this->drupalLogin($web_user);
+
+ // Attempt to access edit page.
+ $this->drupalGet("node/$node->nid/edit");
+ $this->assertResponse(200);
+ }
+}
+
+class SummaryLengthTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Summary length',
+ 'description' => 'Test summary length.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Creates a node and then an anonymous and unpermissioned user attempt to edit the node.
+ */
+ function testSummaryLength() {
+ // Create a node to view.
+ $settings = array(
+ 'body' => array(LANGUAGE_NONE => array(array('value' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vitae arcu at leo cursus laoreet. Curabitur dui tortor, adipiscing malesuada tempor in, bibendum ac diam. Cras non tellus a libero pellentesque condimentum. What is a Drupalism? Suspendisse ac lacus libero. Ut non est vel nisl faucibus interdum nec sed leo. Pellentesque sem risus, vulputate eu semper eget, auctor in libero. Ut fermentum est vitae metus convallis scelerisque. Phasellus pellentesque rhoncus tellus, eu dignissim purus posuere id. Quisque eu fringilla ligula. Morbi ullamcorper, lorem et mattis egestas, tortor neque pretium velit, eget eleifend odio turpis eu purus. Donec vitae metus quis leo pretium tincidunt a pulvinar sem. Morbi adipiscing laoreet mauris vel placerat. Nullam elementum, nisl sit amet scelerisque malesuada, dolor nunc hendrerit quam, eu ultrices erat est in orci. Curabitur feugiat egestas nisl sed accumsan.'))),
+ 'promote' => 1,
+ );
+ $node = $this->drupalCreateNode($settings);
+ $this->assertTrue(node_load($node->nid), t('Node created.'));
+
+ // Create user with permission to view the node.
+ $web_user = $this->drupalCreateUser(array('access content', 'administer content types'));
+ $this->drupalLogin($web_user);
+
+ // Attempt to access the front page.
+ $this->drupalGet("node");
+ // The node teaser when it has 600 characters in length
+ $expected = 'What is a Drupalism?';
+ $this->assertRaw($expected, t('Check that the summary is 600 characters in length'), 'Node');
+
+ // Change the teaser length for "Basic page" content type.
+ $instance = field_info_instance('node', 'body', $node->type);
+ $instance['display']['teaser']['settings']['trim_length'] = 200;
+ field_update_instance($instance);
+
+ // Attempt to access the front page again and check if the summary is now only 200 characters in length.
+ $this->drupalGet("node");
+ $this->assertNoRaw($expected, t('Check that the summary is not longer than 200 characters'), 'Node');
+ }
+}
+
+class NodeTitleXSSTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node title XSS filtering',
+ 'description' => 'Create a node with dangerous tags in its title and test that they are escaped.',
+ 'group' => 'Node',
+ );
+ }
+
+ function testNodeTitleXSS() {
+ // Prepare a user to do the stuff.
+ $web_user = $this->drupalCreateUser(array('create page content', 'edit any page content'));
+ $this->drupalLogin($web_user);
+
+ $xss = '<script>alert("xss")</script>';
+ $title = $xss . $this->randomName();
+ $edit = array("title" => $title);
+
+ $this->drupalPost('node/add/page', $edit, t('Preview'));
+ $this->assertNoRaw($xss, t('Harmful tags are escaped when previewing a node.'));
+
+ $settings = array('title' => $title);
+ $node = $this->drupalCreateNode($settings);
+
+ $this->drupalGet('node/' . $node->nid);
+ // assertTitle() decodes HTML-entities inside the <title> element.
+ $this->assertTitle($edit["title"] . ' | Drupal', t('Title is diplayed when viewing a node.'));
+ $this->assertNoRaw($xss, t('Harmful tags are escaped when viewing a node.'));
+
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertNoRaw($xss, t('Harmful tags are escaped when editing a node.'));
+ }
+}
+
+class NodeBlockTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block availability',
+ 'description' => 'Check if the syndicate block is available.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create and login user
+ $admin_user = $this->drupalCreateUser(array('administer blocks'));
+ $this->drupalLogin($admin_user);
+ }
+
+ function testSearchFormBlock() {
+ // Set block title to confirm that the interface is available.
+ $this->drupalPost('admin/structure/block/manage/node/syndicate/configure', array('title' => $this->randomName(8)), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array();
+ $edit['blocks[node_syndicate][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.'));
+ }
+}
+
+/**
+ * Check that the post information displays when enabled for a content type.
+ */
+class NodePostSettingsTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node post information display',
+ 'description' => 'Check that the post information (submitted by Username on date) text displays appropriately.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $web_user = $this->drupalCreateUser(array('create page content', 'administer content types', 'access user profiles'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Set "Basic page" content type to display post information and confirm its presence on a new node.
+ */
+ function testPagePostInfo() {
+
+ // Set "Basic page" content type to display post information.
+ $edit = array();
+ $edit['node_submitted'] = TRUE;
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+
+ // Create a node.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName(8);
+ $edit["body[$langcode][0][value]"] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the post information is displayed.
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $elements = $this->xpath('//div[contains(@class,:class)]', array(':class' => 'submitted'));
+ $this->assertEqual(count($elements), 1, t('Post information is displayed.'));
+ }
+
+ /**
+ * Set "Basic page" content type to not display post information and confirm its absence on a new node.
+ */
+ function testPageNotPostInfo() {
+
+ // Set "Basic page" content type to display post information.
+ $edit = array();
+ $edit['node_submitted'] = FALSE;
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+
+ // Create a node.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName(8);
+ $edit["body[$langcode][0][value]"] = $this->randomName(16);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ // Check that the post information is displayed.
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertNoRaw('<span class="submitted">', t('Post information is not displayed.'));
+ }
+}
+
+/**
+ * Ensure that data added to nodes by other modules appears in RSS feeds.
+ *
+ * Create a node, enable the node_test module to ensure that extra data is
+ * added to the node->content array, then verify that the data appears on the
+ * sitewide RSS feed at rss.xml.
+ */
+class NodeRSSContentTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node RSS Content',
+ 'description' => 'Ensure that data added to nodes by other modules appears in RSS feeds.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ // Enable dummy module that implements hook_node_view.
+ parent::setUp('node_test');
+
+ // Use bypass node access permission here, because the test class uses
+ // hook_grants_alter() to deny access to everyone on node_access
+ // queries.
+ $user = $this->drupalCreateUser(array('bypass node access', 'access content', 'create article content'));
+ $this->drupalLogin($user);
+ }
+
+ /**
+ * Create a new node and ensure that it includes the custom data when added
+ * to an RSS feed.
+ */
+ function testNodeRSSContent() {
+ // Create a node.
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+
+ $this->drupalGet('rss.xml');
+
+ // Check that content added in 'rss' view mode appear in RSS feed.
+ $rss_only_content = t('Extra data that should appear only in the RSS feed for node !nid.', array('!nid' => $node->nid));
+ $this->assertText($rss_only_content, t('Node content designated for RSS appear in RSS feed.'));
+
+ // Check that content added in view modes other than 'rss' doesn't
+ // appear in RSS feed.
+ $non_rss_content = t('Extra data that should appear everywhere except the RSS feed for node !nid.', array('!nid' => $node->nid));
+ $this->assertNoText($non_rss_content, t('Node content not designed for RSS doesn\'t appear in RSS feed.'));
+
+ // Check that extra RSS elements and namespaces are added to RSS feed.
+ $test_element = array(
+ 'key' => 'testElement',
+ 'value' => t('Value of testElement RSS element for node !nid.', array('!nid' => $node->nid)),
+ );
+ $test_ns = 'xmlns:drupaltest="http://example.com/test-namespace"';
+ $this->assertRaw(format_xml_elements(array($test_element)), t('Extra RSS elements appear in RSS feed.'));
+ $this->assertRaw($test_ns, t('Extra namespaces appear in RSS feed.'));
+
+ // Check that content added in 'rss' view mode doesn't appear when
+ // viewing node.
+ $this->drupalGet("node/$node->nid");
+ $this->assertNoText($rss_only_content, t('Node content designed for RSS doesn\'t appear when viewing node.'));
+
+ }
+}
+
+/**
+ * Test case to verify basic node_access functionality.
+ * @todo Cover hook_node_access in a separate test class.
+ * hook_node_access_records is covered in another test class.
+ */
+class NodeAccessUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node access',
+ 'description' => 'Test node_access function',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Asserts node_access correctly grants or denies access.
+ */
+ function assertNodeAccess($ops, $node, $account) {
+ foreach ($ops as $op => $result) {
+ $msg = t("node_access returns @result with operation '@op'.", array('@result' => $result ? 'true' : 'false', '@op' => $op));
+ $this->assertEqual($result, node_access($op, $node, $account), $msg);
+ }
+ }
+
+ function setUp() {
+ parent::setUp();
+ // Clear permissions for authenticated users.
+ db_delete('role_permission')
+ ->condition('rid', DRUPAL_AUTHENTICATED_RID)
+ ->execute();
+ }
+
+ /**
+ * Runs basic tests for node_access function.
+ */
+ function testNodeAccess() {
+ // Ensures user without 'access content' permission can do nothing.
+ $web_user1 = $this->drupalCreateUser(array('create page content', 'edit any page content', 'delete any page content'));
+ $node1 = $this->drupalCreateNode(array('type' => 'page'));
+ $this->assertNodeAccess(array('create' => FALSE), 'page', $web_user1);
+ $this->assertNodeAccess(array('view' => FALSE, 'update' => FALSE, 'delete' => FALSE), $node1, $web_user1);
+
+ // Ensures user with 'bypass node access' permission can do everything.
+ $web_user2 = $this->drupalCreateUser(array('bypass node access'));
+ $node2 = $this->drupalCreateNode(array('type' => 'page'));
+ $this->assertNodeAccess(array('create' => TRUE), 'page', $web_user2);
+ $this->assertNodeAccess(array('view' => TRUE, 'update' => TRUE, 'delete' => TRUE), $node2, $web_user2);
+
+ // User cannot 'view own unpublished content'.
+ $web_user3 = $this->drupalCreateUser(array('access content'));
+ $node3 = $this->drupalCreateNode(array('status' => 0, 'uid' => $web_user3->uid));
+ $this->assertNodeAccess(array('view' => FALSE), $node3, $web_user3);
+
+ // User cannot create content without permission.
+ $this->assertNodeAccess(array('create' => FALSE), 'page', $web_user3);
+
+ // User can 'view own unpublished content', but another user cannot.
+ $web_user4 = $this->drupalCreateUser(array('access content', 'view own unpublished content'));
+ $web_user5 = $this->drupalCreateUser(array('access content', 'view own unpublished content'));
+ $node4 = $this->drupalCreateNode(array('status' => 0, 'uid' => $web_user4->uid));
+ $this->assertNodeAccess(array('view' => TRUE, 'update' => FALSE), $node4, $web_user4);
+ $this->assertNodeAccess(array('view' => FALSE), $node4, $web_user5);
+
+ // Tests the default access provided for a published node.
+ $node5 = $this->drupalCreateNode();
+ $this->assertNodeAccess(array('view' => TRUE, 'update' => FALSE, 'delete' => FALSE), $node5, $web_user3);
+ }
+}
+
+/**
+ * Test case to verify hook_node_access_records functionality.
+ */
+class NodeAccessRecordsUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node access records',
+ 'description' => 'Test hook_node_access_records when acquiring grants.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ // Enable dummy module that implements hook_node_grants(),
+ // hook_node_access_records(), hook_node_grants_alter() and
+ // hook_node_access_records_alter().
+ parent::setUp('node_test');
+ }
+
+ /**
+ * Create a node and test the creation of node access rules.
+ */
+ function testNodeAccessRecords() {
+ // Create an article node.
+ $node1 = $this->drupalCreateNode(array('type' => 'article'));
+ $this->assertTrue(node_load($node1->nid), t('Article node created.'));
+
+ // Check to see if grants added by node_test_node_access_records made it in.
+ $records = db_query('SELECT realm, gid FROM {node_access} WHERE nid = :nid', array(':nid' => $node1->nid))->fetchAll();
+ $this->assertEqual(count($records), 1, t('Returned the correct number of rows.'));
+ $this->assertEqual($records[0]->realm, 'test_article_realm', t('Grant with article_realm acquired for node without alteration.'));
+ $this->assertEqual($records[0]->gid, 1, t('Grant with gid = 1 acquired for node without alteration.'));
+
+ // Create an unpromoted "Basic page" node.
+ $node2 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 0));
+ $this->assertTrue(node_load($node2->nid), t('Unpromoted basic page node created.'));
+
+ // Check to see if grants added by node_test_node_access_records made it in.
+ $records = db_query('SELECT realm, gid FROM {node_access} WHERE nid = :nid', array(':nid' => $node2->nid))->fetchAll();
+ $this->assertEqual(count($records), 1, t('Returned the correct number of rows.'));
+ $this->assertEqual($records[0]->realm, 'test_page_realm', t('Grant with page_realm acquired for node without alteration.'));
+ $this->assertEqual($records[0]->gid, 1, t('Grant with gid = 1 acquired for node without alteration.'));
+
+ // Create an unpromoted, unpublished "Basic page" node.
+ $node3 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 0, 'status' => 0));
+ $this->assertTrue(node_load($node3->nid), t('Unpromoted, unpublished basic page node created.'));
+
+ // Check to see if grants added by node_test_node_access_records made it in.
+ $records = db_query('SELECT realm, gid FROM {node_access} WHERE nid = :nid', array(':nid' => $node3->nid))->fetchAll();
+ $this->assertEqual(count($records), 1, t('Returned the correct number of rows.'));
+ $this->assertEqual($records[0]->realm, 'test_page_realm', t('Grant with page_realm acquired for node without alteration.'));
+ $this->assertEqual($records[0]->gid, 1, t('Grant with gid = 1 acquired for node without alteration.'));
+
+ // Create a promoted "Basic page" node.
+ $node4 = $this->drupalCreateNode(array('type' => 'page', 'promote' => 1));
+ $this->assertTrue(node_load($node4->nid), t('Promoted basic page node created.'));
+
+ // Check to see if grant added by node_test_node_access_records was altered
+ // by node_test_node_access_records_alter.
+ $records = db_query('SELECT realm, gid FROM {node_access} WHERE nid = :nid', array(':nid' => $node4->nid))->fetchAll();
+ $this->assertEqual(count($records), 1, t('Returned the correct number of rows.'));
+ $this->assertEqual($records[0]->realm, 'test_alter_realm', t('Altered grant with alter_realm acquired for node.'));
+ $this->assertEqual($records[0]->gid, 2, t('Altered grant with gid = 2 acquired for node.'));
+
+ // Check to see if we can alter grants with hook_node_grants_alter().
+ $operations = array('view', 'update', 'delete');
+ // Create a user that is allowed to access content.
+ $web_user = $this->drupalCreateUser(array('access content'));
+ foreach ($operations as $op) {
+ $grants = node_test_node_grants($op, $web_user);
+ $altered_grants = $grants;
+ drupal_alter('node_grants', $altered_grants, $web_user, $op);
+ $this->assertNotEqual($grants, $altered_grants, t('Altered the %op grant for a user.', array('%op' => $op)));
+ }
+
+ // Check that core does not grant access to an unpublished node when an
+ // empty $grants array is returned.
+ $node6 = $this->drupalCreateNode(array('status' => 0, 'disable_node_access' => TRUE));
+ $records = db_query('SELECT realm, gid FROM {node_access} WHERE nid = :nid', array(':nid' => $node6->nid))->fetchAll();
+ $this->assertEqual(count($records), 0, t('Returned no records for unpublished node.'));
+ }
+}
+
+/**
+ * Tests for Node Access with a non-node base table.
+ */
+class NodeAccessBaseTableTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node Access on any table',
+ 'description' => 'Checks behavior of the node access subsystem if the base table is not node.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Enable modules and create user with specific permissions.
+ */
+ public function setUp() {
+ parent::setUp('node_access_test');
+ node_access_rebuild();
+ variable_set('node_access_test_private', TRUE);
+ }
+
+ /**
+ * Test the "private" node access.
+ *
+ * - Create 2 users with "access content" and "create article" permissions.
+ * - Each user creates one private and one not private article.
+
+ * - Test that each user can view the other user's non-private article.
+ * - Test that each user cannot view the other user's private article.
+ * - Test that each user finds only appropriate (non-private + own private)
+ * in taxonomy listing.
+ * - Create another user with 'view any private content'.
+ * - Test that user 4 can view all content created above.
+ * - Test that user 4 can view all content on taxonomy listing.
+ */
+ function testNodeAccessBasic() {
+ $num_simple_users = 2;
+ $simple_users = array();
+
+ // nodes keyed by uid and nid: $nodes[$uid][$nid] = $is_private;
+ $this->nodesByUser = array();
+ $titles = array(); // Titles keyed by nid
+ $private_nodes = array(); // Array of nids marked private.
+ for ($i = 0; $i < $num_simple_users; $i++) {
+ $simple_users[$i] = $this->drupalCreateUser(array('access content', 'create article content'));
+ }
+ foreach ($simple_users as $this->webUser) {
+ $this->drupalLogin($this->webUser);
+ foreach (array(0 => 'Public', 1 => 'Private') as $is_private => $type) {
+ $edit = array(
+ 'title' => t('@private_public Article created by @user', array('@private_public' => $type, '@user' => $this->webUser->name)),
+ );
+ if ($is_private) {
+ $edit['private'] = TRUE;
+ $edit['body[und][0][value]'] = 'private node';
+ $edit['field_tags[und]'] = 'private';
+ }
+ else {
+ $edit['body[und][0][value]'] = 'public node';
+ $edit['field_tags[und]'] = 'public';
+ }
+
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ $nid = db_query('SELECT nid FROM {node} WHERE title = :title', array(':title' => $edit['title']))->fetchField();
+ $private_status = db_query('SELECT private FROM {node_access_test} where nid = :nid', array(':nid' => $nid))->fetchField();
+ $this->assertTrue($is_private == $private_status, t('The private status of the node was properly set in the node_access_test table.'));
+ if ($is_private) {
+ $private_nodes[] = $nid;
+ }
+ $titles[$nid] = $edit['title'];
+ $this->nodesByUser[$this->webUser->uid][$nid] = $is_private;
+ }
+ }
+ $this->publicTid = db_query('SELECT tid FROM {taxonomy_term_data} WHERE name = :name', array(':name' => 'public'))->fetchField();
+ $this->privateTid = db_query('SELECT tid FROM {taxonomy_term_data} WHERE name = :name', array(':name' => 'private'))->fetchField();
+ $this->assertTrue($this->publicTid, t('Public tid was found'));
+ $this->assertTrue($this->privateTid, t('Private tid was found'));
+ foreach ($simple_users as $this->webUser) {
+ $this->drupalLogin($this->webUser);
+ // Check own nodes to see that all are readable.
+ foreach ($this->nodesByUser as $uid => $data) {
+ foreach ($data as $nid => $is_private) {
+ $this->drupalGet('node/' . $nid);
+ if ($is_private) {
+ $should_be_visible = $uid == $this->webUser->uid;
+ }
+ else {
+ $should_be_visible = TRUE;
+ }
+ $this->assertResponse($should_be_visible ? 200 : 403, strtr('A %private node by user %uid is %visible for user %current_uid.', array(
+ '%private' => $is_private ? 'private' : 'public',
+ '%uid' => $uid,
+ '%visible' => $should_be_visible ? 'visible' : 'not visible',
+ '%current_uid' => $this->webUser->uid,
+ )));
+ }
+ }
+
+ // Check to see that the correct nodes are shown on taxonomy/private
+ // and taxonomy/public.
+ $this->assertTaxonomyPage(FALSE);
+ }
+
+ // Now test that a user with 'access any private content' can view content.
+ $access_user = $this->drupalCreateUser(array('access content', 'create article content', 'node test view', 'search content'));
+ $this->drupalLogin($access_user);
+
+ foreach ($this->nodesByUser as $uid => $private_status) {
+ foreach ($private_status as $nid => $is_private) {
+ $this->drupalGet('node/' . $nid);
+ $this->assertResponse(200);
+ }
+ }
+
+ // This user should be able to see all of the nodes on the relevant
+ // taxonomy pages.
+ $this->assertTaxonomyPage(TRUE);
+ }
+
+ protected function assertTaxonomyPage($super) {
+ foreach (array($this->publicTid, $this->privateTid) as $tid_is_private => $tid) {
+ $this->drupalGet("taxonomy/term/$tid");
+ $this->nids_visible = array();
+ foreach ($this->xpath("//a[text()='Read more']") as $link) {
+ $this->assertTrue(preg_match('|node/(\d+)$|', (string) $link['href'], $matches), 'Read more points to a node');
+ $this->nids_visible[$matches[1]] = TRUE;
+ }
+ foreach ($this->nodesByUser as $uid => $data) {
+ foreach ($data as $nid => $is_private) {
+ // Private nodes should be visible on the private term page,
+ // public nodes should be visible on the public term page.
+ $should_be_visible = $tid_is_private == $is_private;
+ // Non-superusers on the private page can only see their own nodes.
+ if (!$super && $tid_is_private) {
+ $should_be_visible = $should_be_visible && $uid == $this->webUser->uid;
+ }
+ $this->assertIdentical(isset($this->nids_visible[$nid]), $should_be_visible, strtr('A %private node by user %uid is %visible for user %current_uid on the %tid_is_private page.', array(
+ '%private' => $is_private ? 'private' : 'public',
+ '%uid' => $uid,
+ '%visible' => isset($this->nids_visible[$nid]) ? 'visible' : 'not visible',
+ '%current_uid' => $this->webUser->uid,
+ '%tid_is_private' => $tid_is_private ? 'private' : 'public',
+ )));
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Test case to check node save related functionality, including import-save
+ */
+class NodeSaveTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node save',
+ 'description' => 'Test node_save() for saving content.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('node_test');
+ // Create a user that is allowed to post; we'll use this to test the submission.
+ $web_user = $this->drupalCreateUser(array('create article content'));
+ $this->drupalLogin($web_user);
+ $this->web_user = $web_user;
+ }
+
+ /**
+ * Import test, to check if custom node ids are saved properly.
+ * Workflow:
+ * - first create a piece of content
+ * - save the content
+ * - check if node exists
+ */
+ function testImport() {
+ // Node ID must be a number that is not in the database.
+ $max_nid = db_query('SELECT MAX(nid) FROM {node}')->fetchField();
+ $test_nid = $max_nid + mt_rand(1000, 1000000);
+ $title = $this->randomName(8);
+ $node = array(
+ 'title' => $title,
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $this->randomName(32)))),
+ 'uid' => $this->web_user->uid,
+ 'type' => 'article',
+ 'nid' => $test_nid,
+ 'is_new' => TRUE,
+ );
+ $node = node_submit((object) $node);
+
+ // Verify that node_submit did not overwrite the user ID.
+ $this->assertEqual($node->uid, $this->web_user->uid, t('Function node_submit() preserves user ID'));
+
+ node_save($node);
+ // Test the import.
+ $node_by_nid = node_load($test_nid);
+ $this->assertTrue($node_by_nid, t('Node load by node ID.'));
+
+ $node_by_title = $this->drupalGetNodeByTitle($title);
+ $this->assertTrue($node_by_title, t('Node load by node title.'));
+ }
+
+ /**
+ * Check that the "created" and "changed" timestamps are set correctly when
+ * saving a new node or updating an existing node.
+ */
+ function testTimestamps() {
+ // Use the default timestamps.
+ $edit = array(
+ 'uid' => $this->web_user->uid,
+ 'type' => 'article',
+ 'title' => $this->randomName(8),
+ );
+
+ node_save((object) $edit);
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertEqual($node->created, REQUEST_TIME, t('Creating a node sets default "created" timestamp.'));
+ $this->assertEqual($node->changed, REQUEST_TIME, t('Creating a node sets default "changed" timestamp.'));
+
+ // Store the timestamps.
+ $created = $node->created;
+ $changed = $node->changed;
+
+ node_save($node);
+ $node = $this->drupalGetNodeByTitle($edit['title'], TRUE);
+ $this->assertEqual($node->created, $created, t('Updating a node preserves "created" timestamp.'));
+
+ // Programmatically set the timestamps using hook_node_presave.
+ $node->title = 'testing_node_presave';
+
+ node_save($node);
+ $node = $this->drupalGetNodeByTitle('testing_node_presave', TRUE);
+ $this->assertEqual($node->created, 280299600, t('Saving a node uses "created" timestamp set in presave hook.'));
+ $this->assertEqual($node->changed, 979534800, t('Saving a node uses "changed" timestamp set in presave hook.'));
+
+ // Programmatically set the timestamps on the node.
+ $edit = array(
+ 'uid' => $this->web_user->uid,
+ 'type' => 'article',
+ 'title' => $this->randomName(8),
+ 'created' => 280299600, // Sun, 19 Nov 1978 05:00:00 GMT
+ 'changed' => 979534800, // Drupal 1.0 release.
+ );
+
+ node_save((object) $edit);
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertEqual($node->created, 280299600, t('Creating a node uses user-set "created" timestamp.'));
+ $this->assertNotEqual($node->changed, 979534800, t('Creating a node doesn\'t use user-set "changed" timestamp.'));
+
+ // Update the timestamps.
+ $node->created = 979534800;
+ $node->changed = 280299600;
+
+ node_save($node);
+ $node = $this->drupalGetNodeByTitle($edit['title'], TRUE);
+ $this->assertEqual($node->created, 979534800, t('Updating a node uses user-set "created" timestamp.'));
+ $this->assertNotEqual($node->changed, 280299600, t('Updating a node doesn\'t use user-set "changed" timestamp.'));
+ }
+
+ /**
+ * Tests determing changes in hook_node_presave() and verifies the static node
+ * load cache is cleared upon save.
+ */
+ function testDeterminingChanges() {
+ // Initial creation.
+ $node = (object) array(
+ 'uid' => $this->web_user->uid,
+ 'type' => 'article',
+ 'title' => 'test_changes',
+ );
+ node_save($node);
+
+ // Update the node without applying changes.
+ node_save($node);
+ $this->assertEqual($node->title, 'test_changes', 'No changes have been determined.');
+
+ // Apply changes.
+ $node->title = 'updated';
+ node_save($node);
+
+ // The hook implementations node_test_node_presave() and
+ // node_test_node_update() determine changes and change the title.
+ $this->assertEqual($node->title, 'updated_presave_update', 'Changes have been determined.');
+
+ // Test the static node load cache to be cleared.
+ $node = node_load($node->nid);
+ $this->assertEqual($node->title, 'updated_presave', 'Static cache has been cleared.');
+ }
+}
+
+/**
+ * Tests related to node types.
+ */
+class NodeTypeTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node types',
+ 'description' => 'Ensures that node type functions work correctly.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Ensure that node type functions (node_type_get_*) work correctly.
+ *
+ * Load available node types and validate the returned data.
+ */
+ function testNodeTypeGetFunctions() {
+ $node_types = node_type_get_types();
+ $node_names = node_type_get_names();
+
+ $this->assertTrue(isset($node_types['article']), t('Node type article is available.'));
+ $this->assertTrue(isset($node_types['page']), t('Node type basic page is available.'));
+
+ $this->assertEqual($node_types['article']->name, $node_names['article'], t('Correct node type base has been returned.'));
+
+ $this->assertEqual($node_types['article'], node_type_get_type('article'), t('Correct node type has been returned.'));
+ $this->assertEqual($node_types['article']->name, node_type_get_name('article'), t('Correct node type name has been returned.'));
+ $this->assertEqual($node_types['page']->base, node_type_get_base('page'), t('Correct node type base has been returned.'));
+ }
+
+ /**
+ * Test creating a content type programmatically and via a form.
+ */
+ function testNodeTypeCreation() {
+ // Create a content type programmaticaly.
+ $type = $this->drupalCreateContentType();
+
+ $type_exists = db_query('SELECT 1 FROM {node_type} WHERE type = :type', array(':type' => $type->type))->fetchField();
+ $this->assertTrue($type_exists, 'The new content type has been created in the database.');
+
+ // Login a test user.
+ $web_user = $this->drupalCreateUser(array('create ' . $type->name . ' content'));
+ $this->drupalLogin($web_user);
+
+ $this->drupalGet('node/add/' . str_replace('_', '-', $type->name));
+ $this->assertResponse(200, 'The new content type can be accessed at node/add.');
+
+ // Create a content type via the user interface.
+ $web_user = $this->drupalCreateUser(array('bypass node access', 'administer content types'));
+ $this->drupalLogin($web_user);
+ $edit = array(
+ 'name' => 'foo',
+ 'title_label' => 'title for foo',
+ 'type' => 'foo',
+ );
+ $this->drupalPost('admin/structure/types/add', $edit, t('Save content type'));
+ $type_exists = db_query('SELECT 1 FROM {node_type} WHERE type = :type', array(':type' => 'foo'))->fetchField();
+ $this->assertTrue($type_exists, 'The new content type has been created in the database.');
+ }
+
+ /**
+ * Test editing a node type using the UI.
+ */
+ function testNodeTypeEditing() {
+ $web_user = $this->drupalCreateUser(array('bypass node access', 'administer content types'));
+ $this->drupalLogin($web_user);
+
+ $instance = field_info_instance('node', 'body', 'page');
+ $this->assertEqual($instance['label'], 'Body', t('Body field was found.'));
+
+ // Verify that title and body fields are displayed.
+ $this->drupalGet('node/add/page');
+ $this->assertRaw('Title', t('Title field was found.'));
+ $this->assertRaw('Body', t('Body field was found.'));
+
+ // Rename the title field.
+ $edit = array(
+ 'title_label' => 'Foo',
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ // Refresh the field information for the rest of the test.
+ field_info_cache_clear();
+
+ $this->drupalGet('node/add/page');
+ $this->assertRaw('Foo', t('New title label was displayed.'));
+ $this->assertNoRaw('Title', t('Old title label was not displayed.'));
+
+ // Change the name, machine name and description.
+ $edit = array(
+ 'name' => 'Bar',
+ 'type' => 'bar',
+ 'description' => 'Lorem ipsum.',
+ );
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ field_info_cache_clear();
+
+ $this->drupalGet('node/add');
+ $this->assertRaw('Bar', t('New name was displayed.'));
+ $this->assertRaw('Lorem ipsum', t('New description was displayed.'));
+ $this->clickLink('Bar');
+ $this->assertEqual(url('node/add/bar', array('absolute' => TRUE)), $this->getUrl(), t('New machine name was used in URL.'));
+ $this->assertRaw('Foo', t('Title field was found.'));
+ $this->assertRaw('Body', t('Body field was found.'));
+
+ // Remove the body field.
+ $this->drupalPost('admin/structure/types/manage/bar/fields/body/delete', NULL, t('Delete'));
+ // Resave the settings for this type.
+ $this->drupalPost('admin/structure/types/manage/bar', array(), t('Save content type'));
+ // Check that the body field doesn't exist.
+ $this->drupalGet('node/add/bar');
+ $this->assertNoRaw('Body', t('Body field was not found.'));
+ }
+
+ /**
+ * Test that node_types_rebuild() correctly handles the 'disabled' flag.
+ */
+ function testNodeTypeStatus() {
+ // Enable all core node modules, and all types should be active.
+ module_enable(array('book', 'poll'), FALSE);
+ node_types_rebuild();
+ $types = node_type_get_types();
+ foreach (array('book', 'poll', 'article', 'page') as $type) {
+ $this->assertTrue(isset($types[$type]), t('%type is found in node types.', array('%type' => $type)));
+ $this->assertTrue(isset($types[$type]->disabled) && empty($types[$type]->disabled), t('%type type is enabled.', array('%type' => $type)));
+ }
+
+ // Disable poll module and the respective type should be marked as disabled.
+ module_disable(array('poll'), FALSE);
+ node_types_rebuild();
+ $types = node_type_get_types();
+ $this->assertTrue(!empty($types['poll']->disabled), t("Poll module's node type disabled."));
+
+ // Disable book module and the respective type should still be active, since
+ // it is not provided by hook_node_info().
+ module_disable(array('book'), FALSE);
+ node_types_rebuild();
+ $types = node_type_get_types();
+ $this->assertTrue(isset($types['book']) && empty($types['book']->disabled), t("Book module's node type still active."));
+ $this->assertTrue(!empty($types['poll']->disabled), t("Poll module's node type still disabled."));
+ $this->assertTrue(isset($types['article']) && empty($types['article']->disabled), t("Article node type still active."));
+ $this->assertTrue(isset($types['page']) && empty($types['page']->disabled), t("Basic page node type still active."));
+
+ // Re-enable the modules and verify that the types are active again.
+ module_enable(array('book', 'poll'), FALSE);
+ node_types_rebuild();
+ $types = node_type_get_types();
+ foreach (array('book', 'poll', 'article', 'page') as $type) {
+ $this->assertTrue(isset($types[$type]), t('%type is found in node types.', array('%type' => $type)));
+ $this->assertTrue(isset($types[$type]->disabled) && empty($types[$type]->disabled), t('%type type is enabled.', array('%type' => $type)));
+ }
+ }
+}
+
+/**
+ * Test node type customizations persistence.
+ */
+class NodeTypePersistenceTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node type persist',
+ 'description' => 'Ensures that node type customization survives module enabling and disabling.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Test node type customizations persist through disable and uninstall.
+ */
+ function testNodeTypeCustomizationPersistence() {
+ $web_user = $this->drupalCreateUser(array('bypass node access', 'administer content types', 'administer modules'));
+ $this->drupalLogin($web_user);
+ $poll_key = 'modules[Core][poll][enable]';
+ $poll_enable = array($poll_key => "1");
+ $poll_disable = array($poll_key => FALSE);
+
+ // Enable poll and verify that the node type is in the DB and is not
+ // disabled.
+ $this->drupalPost('admin/modules', $poll_enable, t('Save configuration'));
+ $disabled = db_query('SELECT disabled FROM {node_type} WHERE type = :type', array(':type' => 'poll'))->fetchField();
+ $this->assertNotIdentical($disabled, FALSE, t('Poll node type found in the database'));
+ $this->assertEqual($disabled, 0, t('Poll node type is not disabled'));
+
+ // Check that poll node type (uncustomized) shows up.
+ $this->drupalGet('node/add');
+ $this->assertText('poll', t('poll type is found on node/add'));
+
+ // Customize poll description.
+ $description = $this->randomName();
+ $edit = array('description' => $description);
+ $this->drupalPost('admin/structure/types/manage/poll', $edit, t('Save content type'));
+
+ // Check that poll node type customization shows up.
+ $this->drupalGet('node/add');
+ $this->assertText($description, t('Customized description found'));
+
+ // Disable poll and check that the node type gets disabled.
+ $this->drupalPost('admin/modules', $poll_disable, t('Save configuration'));
+ $disabled = db_query('SELECT disabled FROM {node_type} WHERE type = :type', array(':type' => 'poll'))->fetchField();
+ $this->assertEqual($disabled, 1, t('Poll node type is disabled'));
+ $this->drupalGet('node/add');
+ $this->assertNoText('poll', t('poll type is not found on node/add'));
+
+ // Reenable poll and check that the customization survived the module
+ // disable.
+ $this->drupalPost('admin/modules', $poll_enable, t('Save configuration'));
+ $disabled = db_query('SELECT disabled FROM {node_type} WHERE type = :type', array(':type' => 'poll'))->fetchField();
+ $this->assertNotIdentical($disabled, FALSE, t('Poll node type found in the database'));
+ $this->assertEqual($disabled, 0, t('Poll node type is not disabled'));
+ $this->drupalGet('node/add');
+ $this->assertText($description, t('Customized description found'));
+
+ // Disable and uninstall poll.
+ $this->drupalPost('admin/modules', $poll_disable, t('Save configuration'));
+ $edit = array('uninstall[poll]' => 'poll');
+ $this->drupalPost('admin/modules/uninstall', $edit, t('Uninstall'));
+ $this->drupalPost(NULL, array(), t('Uninstall'));
+ $disabled = db_query('SELECT disabled FROM {node_type} WHERE type = :type', array(':type' => 'poll'))->fetchField();
+ $this->assertTrue($disabled, t('Poll node type is in the database and is disabled'));
+ $this->drupalGet('node/add');
+ $this->assertNoText('poll', t('poll type is no longer found on node/add'));
+
+ // Reenable poll and check that the customization survived the module
+ // uninstall.
+ $this->drupalPost('admin/modules', $poll_enable, t('Save configuration'));
+ $this->drupalGet('node/add');
+ $this->assertText($description, t('Customized description is found even after uninstall and reenable.'));
+ }
+}
+
+/**
+ * Rebuild the node_access table.
+ */
+class NodeAccessRebuildTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node access rebuild',
+ 'description' => 'Ensures that node access rebuild functions work correctly.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $web_user = $this->drupalCreateUser(array('administer site configuration', 'access administration pages', 'access site reports'));
+ $this->drupalLogin($web_user);
+ $this->web_user = $web_user;
+ }
+
+ function testNodeAccessRebuild() {
+ $this->drupalGet('admin/reports/status');
+ $this->clickLink(t('Rebuild permissions'));
+ $this->drupalPost(NULL, array(), t('Rebuild permissions'));
+ $this->assertText(t('Content permissions have been rebuilt.'));
+ }
+}
+
+/**
+ * Test node administration page functionality.
+ */
+class NodeAdminTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node administration',
+ 'description' => 'Test node administration page functionality.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Remove the "view own unpublished content" permission which is set
+ // by default for authenticated users so we can test this permission
+ // correctly.
+ user_role_revoke_permissions(DRUPAL_AUTHENTICATED_RID, array('view own unpublished content'));
+
+ $this->admin_user = $this->drupalCreateUser(array('access administration pages', 'access content overview', 'administer nodes', 'bypass node access'));
+ $this->base_user_1 = $this->drupalCreateUser(array('access content overview'));
+ $this->base_user_2 = $this->drupalCreateUser(array('access content overview', 'view own unpublished content'));
+ $this->base_user_3 = $this->drupalCreateUser(array('access content overview', 'bypass node access'));
+ }
+
+ /**
+ * Tests that the table sorting works on the content admin pages.
+ */
+ function testContentAdminSort() {
+ $this->drupalLogin($this->admin_user);
+ foreach (array('dd', 'aa', 'DD', 'bb', 'cc', 'CC', 'AA', 'BB') as $prefix) {
+ $this->drupalCreateNode(array('title' => $prefix . $this->randomName(6)));
+ }
+
+ // Test that the default sort by node.changed DESC actually fires properly.
+ $nodes_query = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->orderBy('changed', 'DESC')
+ ->execute()
+ ->fetchCol();
+
+ $nodes_form = array();
+ $this->drupalGet('admin/content');
+ foreach ($this->xpath('//table/tbody/tr/td/div/input/@value') as $input) {
+ $nodes_form[] = $input;
+ }
+ $this->assertEqual($nodes_query, $nodes_form, 'Nodes are sorted in the form according to the default query.');
+
+ // Compare the rendered HTML node list to a query for the nodes ordered by
+ // title to account for possible database-dependent sort order.
+ $nodes_query = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->orderBy('title')
+ ->execute()
+ ->fetchCol();
+
+ $nodes_form = array();
+ $this->drupalGet('admin/content', array('query' => array('sort' => 'asc', 'order' => 'Title')));
+ foreach ($this->xpath('//table/tbody/tr/td/div/input/@value') as $input) {
+ $nodes_form[] = $input;
+ }
+ $this->assertEqual($nodes_query, $nodes_form, 'Nodes are sorted in the form the same as they are in the query.');
+ }
+
+ /**
+ * Tests content overview with different user permissions.
+ *
+ * Taxonomy filters are tested separately.
+ * @see TaxonomyNodeFilterTestCase
+ */
+ function testContentAdminPages() {
+ $this->drupalLogin($this->admin_user);
+
+ $nodes['published_page'] = $this->drupalCreateNode(array('type' => 'page'));
+ $nodes['published_article'] = $this->drupalCreateNode(array('type' => 'article'));
+ $nodes['unpublished_page_1'] = $this->drupalCreateNode(array('type' => 'page', 'uid' => $this->base_user_1->uid, 'status' => 0));
+ $nodes['unpublished_page_2'] = $this->drupalCreateNode(array('type' => 'page', 'uid' => $this->base_user_2->uid, 'status' => 0));
+
+ // Verify view, edit, and delete links for any content.
+ $this->drupalGet('admin/content');
+ $this->assertResponse(200);
+ foreach ($nodes as $node) {
+ $this->assertLinkByHref('node/' . $node->nid);
+ $this->assertLinkByHref('node/' . $node->nid . '/edit');
+ $this->assertLinkByHref('node/' . $node->nid . '/delete');
+ // Verify tableselect.
+ $this->assertFieldByName('nodes[' . $node->nid . ']', '', t('Tableselect found.'));
+ }
+
+ // Verify filtering by publishing status.
+ $edit = array(
+ 'status' => 'status-1',
+ );
+ $this->drupalPost(NULL, $edit, t('Filter'));
+
+ $this->assertRaw(t('where %property is %value', array('%property' => t('status'), '%value' => 'published')), t('Content list is filtered by status.'));
+
+ $this->assertLinkByHref('node/' . $nodes['published_page']->nid . '/edit');
+ $this->assertLinkByHref('node/' . $nodes['published_article']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid . '/edit');
+
+ // Verify filtering by status and content type.
+ $edit = array(
+ 'type' => 'page',
+ );
+ $this->drupalPost(NULL, $edit, t('Refine'));
+
+ $this->assertRaw(t('where %property is %value', array('%property' => t('status'), '%value' => 'published')), t('Content list is filtered by status.'));
+ $this->assertRaw(t('and where %property is %value', array('%property' => t('type'), '%value' => 'Basic page')), t('Content list is filtered by content type.'));
+
+ $this->assertLinkByHref('node/' . $nodes['published_page']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['published_article']->nid . '/edit');
+
+ // Verify no operation links are displayed for regular users.
+ $this->drupalLogout();
+ $this->drupalLogin($this->base_user_1);
+ $this->drupalGet('admin/content');
+ $this->assertResponse(200);
+ $this->assertLinkByHref('node/' . $nodes['published_page']->nid);
+ $this->assertLinkByHref('node/' . $nodes['published_article']->nid);
+ $this->assertNoLinkByHref('node/' . $nodes['published_page']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['published_page']->nid . '/delete');
+ $this->assertNoLinkByHref('node/' . $nodes['published_article']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['published_article']->nid . '/delete');
+
+ // Verify no unpublished content is displayed without permission.
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid);
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid . '/delete');
+
+ // Verify no tableselect.
+ $this->assertNoFieldByName('nodes[' . $nodes['published_page']->nid . ']', '', t('No tableselect found.'));
+
+ // Verify unpublished content is displayed with permission.
+ $this->drupalLogout();
+ $this->drupalLogin($this->base_user_2);
+ $this->drupalGet('admin/content');
+ $this->assertResponse(200);
+ $this->assertLinkByHref('node/' . $nodes['unpublished_page_2']->nid);
+ // Verify no operation links are displayed.
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_2']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_2']->nid . '/delete');
+
+ // Verify user cannot see unpublished content of other users.
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid);
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid . '/edit');
+ $this->assertNoLinkByHref('node/' . $nodes['unpublished_page_1']->nid . '/delete');
+
+ // Verify no tableselect.
+ $this->assertNoFieldByName('nodes[' . $nodes['unpublished_page_2']->nid . ']', '', t('No tableselect found.'));
+
+ // Verify node access can be bypassed.
+ $this->drupalLogout();
+ $this->drupalLogin($this->base_user_3);
+ $this->drupalGet('admin/content');
+ $this->assertResponse(200);
+ foreach ($nodes as $node) {
+ $this->assertLinkByHref('node/' . $node->nid);
+ $this->assertLinkByHref('node/' . $node->nid . '/edit');
+ $this->assertLinkByHref('node/' . $node->nid . '/delete');
+ }
+ }
+}
+
+/**
+ * Test node title.
+ */
+class NodeTitleTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node title',
+ 'description' => 'Test node title.',
+ 'group' => 'Node'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->admin_user = $this->drupalCreateUser(array('administer nodes', 'create article content', 'create page content'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Create one node and test if the node title has the correct value.
+ */
+ function testNodeTitle() {
+ // Create "Basic page" content with title.
+ // Add the node to the frontpage so we can test if teaser links are clickable.
+ $settings = array(
+ 'title' => $this->randomName(8),
+ 'promote' => 1,
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Test <title> tag.
+ $this->drupalGet("node/$node->nid");
+ $xpath = '//title';
+ $this->assertEqual(current($this->xpath($xpath)), $node->title .' | Drupal', 'Page title is equal to node title.', 'Node');
+
+ // Test breadcrumb in comment preview.
+ $this->drupalGet("comment/reply/$node->nid");
+ $xpath = '//div[@class="breadcrumb"]/a[last()]';
+ $this->assertEqual(current($this->xpath($xpath)), $node->title, 'Node breadcrumb is equal to node title.', 'Node');
+
+ // Test node title in comment preview.
+ $this->assertEqual(current($this->xpath('//div[@id=:id]/h2/a', array(':id' => 'node-' . $node->nid))), $node->title, 'Node preview title is equal to node title.', 'Node');
+
+ // Test node title is clickable on teaser list (/node).
+ $this->drupalGet('node');
+ $this->clickLink($node->title);
+ }
+}
+
+/**
+ * Test the node_feed() functionality.
+ */
+class NodeFeedTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node feed',
+ 'description' => 'Ensures that node_feed() functions correctly.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Ensure that node_feed accepts and prints extra channel elements.
+ */
+ function testNodeFeedExtraChannelElements() {
+ ob_start();
+ node_feed(array(), array('copyright' => 'Drupal is a registered trademark of Dries Buytaert.'));
+ $output = ob_get_clean();
+
+ $this->assertTrue(strpos($output, '<copyright>Drupal is a registered trademark of Dries Buytaert.</copyright>') !== FALSE);
+ }
+}
+
+/**
+ * Functional tests for the node module blocks.
+ */
+class NodeBlockFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node blocks',
+ 'description' => 'Test node block functionality.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('node', 'block');
+
+ // Create users and test node.
+ $this->admin_user = $this->drupalCreateUser(array('administer content types', 'administer nodes', 'administer blocks'));
+ $this->web_user = $this->drupalCreateUser(array('access content', 'create article content'));
+ }
+
+ /**
+ * Test the recent comments block.
+ */
+ function testRecentNodeBlock() {
+ $this->drupalLogin($this->admin_user);
+
+ // Disallow anonymous users to view content.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access content' => FALSE,
+ ));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array(
+ 'blocks[node_recent][region]' => 'sidebar_first',
+ );
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block saved to first sidebar region.'));
+
+ // Set block title and variables.
+ $block = array(
+ 'title' => $this->randomName(),
+ 'node_recent_block_count' => 2,
+ );
+ $this->drupalPost('admin/structure/block/manage/node/recent/configure', $block, t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block saved.'));
+
+ // Test that block is not visible without nodes
+ $this->drupalGet('');
+ $this->assertText(t('No content available.'), t('Block with "No content available." found.'));
+
+ // Add some test nodes.
+ $default_settings = array('uid' => $this->web_user->uid, 'type' => 'article');
+ $node1 = $this->drupalCreateNode($default_settings);
+ $node2 = $this->drupalCreateNode($default_settings);
+ $node3 = $this->drupalCreateNode($default_settings);
+
+ // Change the changed time for node so that we can test ordering.
+ db_update('node')
+ ->fields(array(
+ 'changed' => $node1->changed + 100,
+ ))
+ ->condition('nid', $node2->nid)
+ ->execute();
+ db_update('node')
+ ->fields(array(
+ 'changed' => $node1->changed + 200,
+ ))
+ ->condition('nid', $node3->nid)
+ ->execute();
+
+ // Test that a user without the 'access content' permission cannot
+ // see the block.
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertNoText($block['title'], t('Block was not found.'));
+
+ // Test that only the 2 latest nodes are shown.
+ $this->drupalLogin($this->web_user);
+ $this->assertNoText($node1->title, t('Node not found in block.'));
+ $this->assertText($node2->title, t('Node found in block.'));
+ $this->assertText($node3->title, t('Node found in block.'));
+
+ // Check to make sure nodes are in the right order.
+ $this->assertTrue($this->xpath('//div[@id="block-node-recent"]/div/table/tbody/tr[position() = 1]/td/div/a[text() = "' . $node3->title . '"]'), t('Nodes were ordered correctly in block.'));
+
+ // Set the number of recent nodes to show to 10.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $block = array(
+ 'node_recent_block_count' => 10,
+ );
+ $this->drupalPost('admin/structure/block/manage/node/recent/configure', $block, t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block saved.'));
+
+ // Post an additional node.
+ $node4 = $this->drupalCreateNode($default_settings);
+ // drupalCreateNode() does not automatically flush content caches unlike
+ // posting a node from a node form.
+ cache_clear_all();
+
+ // Test that all four nodes are shown.
+ $this->drupalGet('');
+ $this->assertText($node1->title, t('Node found in block.'));
+ $this->assertText($node2->title, t('Node found in block.'));
+ $this->assertText($node3->title, t('Node found in block.'));
+ $this->assertText($node4->title, t('Node found in block.'));
+
+ // Create the custom block.
+ $custom_block = array();
+ $custom_block['info'] = $this->randomName();
+ $custom_block['title'] = $this->randomName();
+ $custom_block['types[article]'] = TRUE;
+ $custom_block['body[value]'] = $this->randomName(32);
+ $custom_block['regions[' . variable_get('theme_default', 'bartik') . ']'] = 'content';
+ if ($admin_theme = variable_get('admin_theme')) {
+ $custom_block['regions[' . $admin_theme . ']'] = 'content';
+ }
+ $this->drupalPost('admin/structure/block/add', $custom_block, t('Save block'));
+
+ $bid = db_query("SELECT bid FROM {block_custom} WHERE info = :info", array(':info' => $custom_block['info']))->fetchField();
+ $this->assertTrue($bid, t('Custom block with visibility rule was created.'));
+
+ // Verify visibility rules.
+ $this->drupalGet('');
+ $this->assertNoText($custom_block['title'], t('Block was displayed on the front page.'));
+ $this->drupalGet('node/add/article');
+ $this->assertText($custom_block['title'], t('Block was displayed on the node/add/article page.'));
+ $this->drupalGet('node/' . $node1->nid);
+ $this->assertText($custom_block['title'], t('Block was displayed on the node/N.'));
+
+ // Delete the created custom block & verify that it's been deleted.
+ $this->drupalPost('admin/structure/block/manage/block/' . $bid . '/delete', array(), t('Delete'));
+ $bid = db_query("SELECT 1 FROM {block_node_type} WHERE module = 'block' AND delta = :delta", array(':delta' => $bid))->fetchField();
+ $this->assertFalse($bid, t('Custom block was deleted.'));
+ }
+}
+/**
+ * Test multistep node forms basic options.
+ */
+class MultiStepNodeFormBasicOptionsTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Multistep node form basic options',
+ 'description' => 'Test the persistence of basic options through multiple steps.',
+ 'group' => 'Node',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ $web_user = $this->drupalCreateUser(array('administer nodes', 'create poll content'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Change the default values of basic options to ensure they persist.
+ */
+ function testMultiStepNodeFormBasicOptions() {
+ $edit = array(
+ 'title' => 'a',
+ 'status' => FALSE,
+ 'promote' => FALSE,
+ 'sticky' => 1,
+ 'choice[new:0][chtext]' => 'a',
+ 'choice[new:1][chtext]' => 'a',
+ );
+ $this->drupalPost('node/add/poll', $edit, t('Add another choice'));
+ $this->assertNoFieldChecked('edit-status', 'status stayed unchecked');
+ $this->assertNoFieldChecked('edit-promote', 'promote stayed unchecked');
+ $this->assertFieldChecked('edit-sticky', 'sticky stayed checked');
+ }
+}
+
+/**
+ * Test to ensure that a node's content is always rebuilt.
+ */
+class NodeBuildContent extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Rebuild content',
+ 'description' => 'Test the rebuilding of content for different build modes.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Test to ensure that a node's content array is rebuilt on every call to node_build_content().
+ */
+ function testNodeRebuildContent() {
+ $node = $this->drupalCreateNode();
+
+ // Set a property in the content array so we can test for its existence later on.
+ $node->content['test_content_property'] = array('#value' => $this->randomString());
+ $content = node_build_content($node);
+
+ // If the property doesn't exist it means the node->content was rebuilt.
+ $this->assertFalse(isset($content['test_content_property']), t('Node content was emptied prior to being built.'));
+ }
+}
+
+/**
+ * Tests node_query_node_access_alter().
+ */
+class NodeQueryAlter extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node query alter',
+ 'description' => 'Test that node access queries are properly altered by the node module.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * User with permission to view content.
+ */
+ protected $accessUser;
+
+ /**
+ * User without permission to view content.
+ */
+ protected $noAccessUser;
+
+ function setUp() {
+ parent::setUp('node_access_test');
+ node_access_rebuild();
+
+ // Create some content.
+ $this->drupalCreateNode();
+ $this->drupalCreateNode();
+ $this->drupalCreateNode();
+ $this->drupalCreateNode();
+
+ // Create user with simple node access permission. The 'node test view'
+ // permission is implemented and granted by the node_access_test module.
+ $this->accessUser = $this->drupalCreateUser(array('access content', 'node test view'));
+ $this->noAccessUser = $this->drupalCreateUser(array('access content'));
+ $this->noAccessUser2 = $this->drupalCreateUser(array('access content'));
+ }
+
+ /**
+ * Tests that node access permissions are followed.
+ */
+ function testNodeQueryAlterWithUI() {
+ // Verify that a user with access permission can see at least one node.
+ $this->drupalLogin($this->accessUser);
+ $this->drupalGet('node_access_test_page');
+ $this->assertText('Yes, 4 nodes', "4 nodes were found for access user");
+ $this->assertNoText('Exception', "No database exception");
+
+ // Verify that a user with no access permission cannot see nodes.
+ $this->drupalLogin($this->noAccessUser);
+ $this->drupalGet('node_access_test_page');
+ $this->assertText('No nodes', "No nodes were found for no access user");
+ $this->assertNoText('Exception', "No database exception");
+ }
+
+ /**
+ * Lower-level test of 'node_access' query alter, for user with access.
+ *
+ * Verifies that a non-standard table alias can be used, and that a
+ * user with node access can view the nodes.
+ */
+ function testNodeQueryAlterLowLevelWithAccess() {
+ // User with access should be able to view 4 nodes.
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $query->addMetaData('op', 'view');
+ $query->addMetaData('account', $this->accessUser);
+
+ $result = $query->execute()->fetchAll();
+ $this->assertEqual(count($result), 4, t('User with access can see correct nodes'));
+ }
+ catch (Exception $e) {
+ $this->fail(t('Altered query is malformed'));
+ }
+ }
+
+ /**
+ * Lower-level test of 'node_access' query alter, for user without access.
+ *
+ * Verifies that a non-standard table alias can be used, and that a
+ * user without node access cannot view the nodes.
+ */
+ function testNodeQueryAlterLowLevelNoAccess() {
+ // User without access should be able to view 0 nodes.
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $query->addMetaData('op', 'view');
+ $query->addMetaData('account', $this->noAccessUser);
+
+ $result = $query->execute()->fetchAll();
+ $this->assertEqual(count($result), 0, t('User with no access cannot see nodes'));
+ }
+ catch (Exception $e) {
+ $this->fail(t('Altered query is malformed'));
+ }
+ }
+
+ /**
+ * Lower-level test of 'node_access' query alter, for edit access.
+ *
+ * Verifies that a non-standard table alias can be used, and that a
+ * user with view-only node access cannot edit the nodes.
+ */
+ function testNodeQueryAlterLowLevelEditAccess() {
+ // User with view-only access should not be able to edit nodes.
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $query->addMetaData('op', 'update');
+ $query->addMetaData('account', $this->accessUser);
+
+ $result = $query->execute()->fetchAll();
+ $this->assertEqual(count($result), 0, t('User with view-only access cannot edit nodes'));
+ }
+ catch (Exception $e) {
+ $this->fail($e->getMessage());
+ $this->fail((string) $query);
+ $this->fail(t('Altered query is malformed'));
+ }
+ }
+
+ /**
+ * Lower-level test of 'node_access' query alter override.
+ *
+ * Verifies that node_access_view_all_nodes() is called from
+ * node_query_node_access_alter(). We do this by checking that
+ * a user which normally would not have view privileges is able
+ * to view the nodes when we add a record to {node_access} paired
+ * with a corresponding privilege in hook_node_grants().
+ */
+ function testNodeQueryAlterOverride() {
+ $record = array(
+ 'nid' => 0,
+ 'gid' => 0,
+ 'realm' => 'node_access_all',
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ );
+ drupal_write_record('node_access', $record);
+
+ // Test that the noAccessUser still doesn't have the 'view'
+ // privilege after adding the node_access record.
+ drupal_static_reset('node_access_view_all_nodes');
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $query->addMetaData('op', 'view');
+ $query->addMetaData('account', $this->noAccessUser);
+
+ $result = $query->execute()->fetchAll();
+ $this->assertEqual(count($result), 0, t('User view privileges are not overridden'));
+ }
+ catch (Exception $e) {
+ $this->fail(t('Altered query is malformed'));
+ }
+
+ // Have node_test_node_grants return a node_access_all privilege,
+ // to grant the noAccessUser 'view' access. To verify that
+ // node_access_view_all_nodes is properly checking the specified
+ // $account instead of the global $user, we will log in as
+ // noAccessUser2.
+ $this->drupalLogin($this->noAccessUser2);
+ variable_set('node_test_node_access_all_uid', $this->noAccessUser->uid);
+ drupal_static_reset('node_access_view_all_nodes');
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $query->addMetaData('op', 'view');
+ $query->addMetaData('account', $this->noAccessUser);
+
+ $result = $query->execute()->fetchAll();
+ $this->assertEqual(count($result), 4, t('User view privileges are overridden'));
+ }
+ catch (Exception $e) {
+ $this->fail(t('Altered query is malformed'));
+ }
+ variable_del('node_test_node_access_all_uid');
+ }
+}
+
+
+/**
+ * Tests node_query_entity_field_access_alter().
+ */
+class NodeEntityFieldQueryAlter extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node entity query alter',
+ 'description' => 'Test that node access entity queries are properly altered by the node module.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * User with permission to view content.
+ */
+ protected $accessUser;
+
+ /**
+ * User without permission to view content.
+ */
+ protected $noAccessUser;
+
+ function setUp() {
+ parent::setUp('node_access_test');
+ node_access_rebuild();
+
+ // Creating 4 nodes with an entity field so we can test that sort of query
+ // alter. All field values starts with 'A' so we can identify and fetch them
+ // in the node_access_test module.
+ $settings = array('language' => LANGUAGE_NONE);
+ for ($i = 0; $i < 4; $i++) {
+ $body = array(
+ 'value' => 'A' . $this->randomName(32),
+ 'format' => filter_default_format(),
+ );
+ $settings['body'][LANGUAGE_NONE][0] = $body;
+ $this->drupalCreateNode($settings);
+ }
+
+ // Create user with simple node access permission. The 'node test view'
+ // permission is implemented and granted by the node_access_test module.
+ $this->accessUser = $this->drupalCreateUser(array('access content', 'node test view'));
+ $this->noAccessUser = $this->drupalCreateUser(array('access content'));
+ }
+
+ /**
+ * Tests that node access permissions are followed.
+ */
+ function testNodeQueryAlterWithUI() {
+ // Verify that a user with access permission can see at least one node.
+ $this->drupalLogin($this->accessUser);
+ $this->drupalGet('node_access_entity_test_page');
+ $this->assertText('Yes, 4 nodes', "4 nodes were found for access user");
+ $this->assertNoText('Exception', "No database exception");
+
+ // Verify that a user with no access permission cannot see nodes.
+ $this->drupalLogin($this->noAccessUser);
+ $this->drupalGet('node_access_entity_test_page');
+ $this->assertText('No nodes', "No nodes were found for no access user");
+ $this->assertNoText('Exception', "No database exception");
+ }
+}
+
+/**
+ * Test node token replacement in strings.
+ */
+class NodeTokenReplaceTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Node token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check node token replacement.',
+ 'group' => 'Node',
+ );
+ }
+
+ /**
+ * Creates a node, then tests the tokens generated from it.
+ */
+ function testNodeTokenReplacement() {
+ global $language;
+ $url_options = array(
+ 'absolute' => TRUE,
+ 'language' => $language,
+ );
+
+ // Create a user and a node.
+ $account = $this->drupalCreateUser();
+ $settings = array(
+ 'type' => 'article',
+ 'uid' => $account->uid,
+ 'title' => '<blink>Blinking Text</blink>',
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $this->randomName(32), 'summary' => $this->randomName(16)))),
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Load node so that the body and summary fields are structured properly.
+ $node = node_load($node->nid);
+ $instance = field_info_instance('node', 'body', $node->type);
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[node:nid]'] = $node->nid;
+ $tests['[node:vid]'] = $node->vid;
+ $tests['[node:tnid]'] = $node->tnid;
+ $tests['[node:type]'] = 'article';
+ $tests['[node:type-name]'] = 'Article';
+ $tests['[node:title]'] = check_plain($node->title);
+ $tests['[node:body]'] = _text_sanitize($instance, $node->language, $node->body[$node->language][0], 'value');
+ $tests['[node:summary]'] = _text_sanitize($instance, $node->language, $node->body[$node->language][0], 'summary');
+ $tests['[node:language]'] = check_plain($node->language);
+ $tests['[node:url]'] = url('node/' . $node->nid, $url_options);
+ $tests['[node:edit-url]'] = url('node/' . $node->nid . '/edit', $url_options);
+ $tests['[node:author]'] = check_plain(format_username($account));
+ $tests['[node:author:uid]'] = $node->uid;
+ $tests['[node:author:name]'] = check_plain(format_username($account));
+ $tests['[node:created:since]'] = format_interval(REQUEST_TIME - $node->created, 2, $language->language);
+ $tests['[node:changed:since]'] = format_interval(REQUEST_TIME - $node->changed, 2, $language->language);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $node), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized node token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[node:title]'] = $node->title;
+ $tests['[node:body]'] = $node->body[$node->language][0]['value'];
+ $tests['[node:summary]'] = $node->body[$node->language][0]['summary'];
+ $tests['[node:language]'] = $node->language;
+ $tests['[node:author:name]'] = format_username($account);
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $node), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized node token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
diff --git a/core/modules/node/node.tokens.inc b/core/modules/node/node.tokens.inc
new file mode 100644
index 000000000000..491ec81c4094
--- /dev/null
+++ b/core/modules/node/node.tokens.inc
@@ -0,0 +1,190 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for node-related data.
+ */
+
+
+
+/**
+ * Implements hook_token_info().
+ */
+function node_token_info() {
+ $type = array(
+ 'name' => t('Nodes'),
+ 'description' => t('Tokens related to individual content items, or "nodes".'),
+ 'needs-data' => 'node',
+ );
+
+ // Core tokens for nodes.
+ $node['nid'] = array(
+ 'name' => t("Content ID"),
+ 'description' => t('The unique ID of the content item, or "node".'),
+ );
+ $node['vid'] = array(
+ 'name' => t("Revision ID"),
+ 'description' => t("The unique ID of the node's latest revision."),
+ );
+ $node['tnid'] = array(
+ 'name' => t("Translation set ID"),
+ 'description' => t("The unique ID of the original-language version of this node, if one exists."),
+ );
+ $node['type'] = array(
+ 'name' => t("Content type"),
+ 'description' => t("The type of the node."),
+ );
+ $node['type-name'] = array(
+ 'name' => t("Content type name"),
+ 'description' => t("The human-readable name of the node type."),
+ );
+ $node['title'] = array(
+ 'name' => t("Title"),
+ 'description' => t("The title of the node."),
+ );
+ $node['body'] = array(
+ 'name' => t("Body"),
+ 'description' => t("The main body text of the node."),
+ );
+ $node['summary'] = array(
+ 'name' => t("Summary"),
+ 'description' => t("The summary of the node's main body text."),
+ );
+ $node['language'] = array(
+ 'name' => t("Language"),
+ 'description' => t("The language the node is written in."),
+ );
+ $node['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The URL of the node."),
+ );
+ $node['edit-url'] = array(
+ 'name' => t("Edit URL"),
+ 'description' => t("The URL of the node's edit page."),
+ );
+
+ // Chained tokens for nodes.
+ $node['created'] = array(
+ 'name' => t("Date created"),
+ 'description' => t("The date the node was posted."),
+ 'type' => 'date',
+ );
+ $node['changed'] = array(
+ 'name' => t("Date changed"),
+ 'description' => t("The date the node was most recently updated."),
+ 'type' => 'date',
+ );
+ $node['author'] = array(
+ 'name' => t("Author"),
+ 'description' => t("The author of the node."),
+ 'type' => 'user',
+ );
+
+ return array(
+ 'types' => array('node' => $type),
+ 'tokens' => array('node' => $node),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function node_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ $replacements = array();
+
+ if ($type == 'node' && !empty($data['node'])) {
+ $node = $data['node'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ // Simple key values on the node.
+ case 'nid':
+ $replacements[$original] = $node->nid;
+ break;
+
+ case 'vid':
+ $replacements[$original] = $node->vid;
+ break;
+
+ case 'tnid':
+ $replacements[$original] = $node->tnid;
+ break;
+
+ case 'type':
+ $replacements[$original] = $sanitize ? check_plain($node->type) : $node->type;
+ break;
+
+ case 'type-name':
+ $type_name = node_type_get_name($node);
+ $replacements[$original] = $sanitize ? check_plain($type_name) : $type_name;
+ break;
+
+ case 'title':
+ $replacements[$original] = $sanitize ? check_plain($node->title) : $node->title;
+ break;
+
+ case 'body':
+ case 'summary':
+ if ($items = field_get_items('node', $node, 'body', $language_code)) {
+ $column = ($name == 'body') ? 'value' : 'summary';
+ $instance = field_info_instance('node', 'body', $node->type);
+ $field_langcode = field_language('node', $node, 'body', $language_code);
+ $replacements[$original] = $sanitize ? _text_sanitize($instance, $field_langcode, $items[0], $column) : $items[0][$column];
+ }
+ break;
+
+ case 'language':
+ $replacements[$original] = $sanitize ? check_plain($node->language) : $node->language;
+ break;
+
+ case 'url':
+ $replacements[$original] = url('node/' . $node->nid, $url_options);
+ break;
+
+ case 'edit-url':
+ $replacements[$original] = url('node/' . $node->nid . '/edit', $url_options);
+ break;
+
+ // Default values for the chained tokens handled below.
+ case 'author':
+ $account = user_load($node->uid);
+ $name = format_username($account);
+ $replacements[$original] = $sanitize ? check_plain($name) : $name;
+ break;
+
+ case 'created':
+ $replacements[$original] = format_date($node->created, 'medium', '', NULL, $language_code);
+ break;
+
+ case 'changed':
+ $replacements[$original] = format_date($node->changed, 'medium', '', NULL, $language_code);
+ break;
+ }
+ }
+
+ if ($author_tokens = token_find_with_prefix($tokens, 'author')) {
+ $author = user_load($node->uid);
+ $replacements += token_generate('user', $author_tokens, array('user' => $author), $options);
+ }
+
+ if ($created_tokens = token_find_with_prefix($tokens, 'created')) {
+ $replacements += token_generate('date', $created_tokens, array('date' => $node->created), $options);
+ }
+
+ if ($changed_tokens = token_find_with_prefix($tokens, 'changed')) {
+ $replacements += token_generate('date', $changed_tokens, array('date' => $node->changed), $options);
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/node/node.tpl.php b/core/modules/node/node.tpl.php
new file mode 100644
index 000000000000..06dc1997bc8e
--- /dev/null
+++ b/core/modules/node/node.tpl.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a node.
+ *
+ * Available variables:
+ * - $title: the (sanitized) title of the node.
+ * - $content: An array of node items. Use render($content) to print them all,
+ * or print a subset such as render($content['field_example']). Use
+ * hide($content['field_example']) to temporarily suppress the printing of a
+ * given element.
+ * - $user_picture: The node author's picture from user-picture.tpl.php.
+ * - $date: Formatted creation date. Preprocess functions can reformat it by
+ * calling format_date() with the desired parameters on the $created variable.
+ * - $name: Themed username of node author output from theme_username().
+ * - $node_url: Direct url of the current node.
+ * - $display_submitted: Whether submission information should be displayed.
+ * - $submitted: Submission information created from $name and $date during
+ * template_preprocess_node().
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the
+ * following:
+ * - node: The current template type, i.e., "theming hook".
+ * - node-[type]: The current node type. For example, if the node is a
+ * "Article" it would result in "node-article". Note that the machine
+ * name will often be in a short form of the human readable label.
+ * - node-teaser: Nodes in teaser form.
+ * - node-preview: Nodes in preview mode.
+ * The following are controlled through the node publishing options.
+ * - node-promoted: Nodes promoted to the front page.
+ * - node-sticky: Nodes ordered above other non-sticky nodes in teaser
+ * listings.
+ * - node-unpublished: Unpublished nodes visible only to administrators.
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * Other variables:
+ * - $node: Full node object. Contains data that may not be safe.
+ * - $type: Node type, i.e. page, article, etc.
+ * - $comment_count: Number of comments attached to the node.
+ * - $uid: User ID of the node author.
+ * - $created: Time the node was published formatted in Unix timestamp.
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $zebra: Outputs either "even" or "odd". Useful for zebra striping in
+ * teaser listings.
+ * - $id: Position of the node. Increments each time it's output.
+ *
+ * Node status variables:
+ * - $view_mode: View mode, e.g. 'full', 'teaser'...
+ * - $teaser: Flag for the teaser state (shortcut for $view_mode == 'teaser').
+ * - $page: Flag for the full page state.
+ * - $promote: Flag for front page promotion state.
+ * - $sticky: Flags for sticky post setting.
+ * - $status: Flag for published status.
+ * - $comment: State of comment settings for the node.
+ * - $readmore: Flags true if the teaser content of the node cannot hold the
+ * main body content.
+ * - $is_front: Flags true when presented in the front page.
+ * - $logged_in: Flags true when the current user is a logged-in member.
+ * - $is_admin: Flags true when the current user is an administrator.
+ *
+ * Field variables: for each field instance attached to the node a corresponding
+ * variable is defined, e.g. $node->body becomes $body. When needing to access
+ * a field's raw values, developers/themers are strongly encouraged to use these
+ * variables. Otherwise they will have to explicitly specify the desired field
+ * language, e.g. $node->body['en'], thus overriding any language negotiation
+ * rule that was previously applied.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_node()
+ * @see template_process()
+ */
+?>
+<div id="node-<?php print $node->nid; ?>" class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
+
+ <?php print $user_picture; ?>
+
+ <?php print render($title_prefix); ?>
+ <?php if (!$page): ?>
+ <h2<?php print $title_attributes; ?>><a href="<?php print $node_url; ?>"><?php print $title; ?></a></h2>
+ <?php endif; ?>
+ <?php print render($title_suffix); ?>
+
+ <?php if ($display_submitted): ?>
+ <div class="submitted">
+ <?php print $submitted; ?>
+ </div>
+ <?php endif; ?>
+
+ <div class="content"<?php print $content_attributes; ?>>
+ <?php
+ // We hide the comments and links now so that we can render them later.
+ hide($content['comments']);
+ hide($content['links']);
+ print render($content);
+ ?>
+ </div>
+
+ <?php print render($content['links']); ?>
+
+ <?php print render($content['comments']); ?>
+
+</div>
diff --git a/core/modules/node/tests/node_access_test.info b/core/modules/node/tests/node_access_test.info
new file mode 100644
index 000000000000..4de1c2daf24c
--- /dev/null
+++ b/core/modules/node/tests/node_access_test.info
@@ -0,0 +1,6 @@
+name = "Node module access tests"
+description = "Support module for node permission testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/node/tests/node_access_test.install b/core/modules/node/tests/node_access_test.install
new file mode 100644
index 000000000000..6b3ef5dcc295
--- /dev/null
+++ b/core/modules/node/tests/node_access_test.install
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the node_access_test module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function node_access_test_schema() {
+ $schema['node_access_test'] = array(
+ 'description' => 'The base table for node_access_test.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record affects.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'private' => array(
+ 'description' => 'Boolean indicating whether the node is private (visible to administrator) or not (visible to non-administrators).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'nid' => array('nid'),
+ ),
+ 'primary key' => array('nid'),
+ 'foreign keys' => array(
+ 'versioned_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ ),
+ );
+
+ return $schema;
+} \ No newline at end of file
diff --git a/core/modules/node/tests/node_access_test.module b/core/modules/node/tests/node_access_test.module
new file mode 100644
index 000000000000..91c117a6fe14
--- /dev/null
+++ b/core/modules/node/tests/node_access_test.module
@@ -0,0 +1,218 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing node access related hooks to test API interaction
+ * with the Node module. This module restricts view permission to those with
+ * a special 'node test view' permission.
+ */
+
+/**
+ * Implements hook_node_grants().
+ */
+function node_access_test_node_grants($account, $op) {
+ $grants = array();
+ // First grant a grant to the author for own content.
+ $grants['node_access_test_author'] = array($account->uid);
+ if ($op == 'view' && user_access('node test view', $account)) {
+ $grants['node_access_test'] = array(8888);
+ }
+ if ($op == 'view' && $account->uid == variable_get('node_test_node_access_all_uid', 0)) {
+ $grants['node_access_all'] = array(0);
+ }
+ return $grants;
+}
+
+/**
+ * Implements hook_node_access_records().
+ */
+function node_access_test_node_access_records($node) {
+ $grants = array();
+ // For NodeAccessBaseTableTestCase, only set records for private nodes.
+ if (!variable_get('node_access_test_private') || $node->private) {
+ $grants[] = array(
+ 'realm' => 'node_access_test',
+ 'gid' => 8888,
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ 'priority' => 0,
+ );
+ // For the author realm, the GID is equivalent to a UID, which
+ // means there are many many groups of just 1 user.
+ $grants[] = array(
+ 'realm' => 'node_access_test_author',
+ 'gid' => $node->uid,
+ 'grant_view' => 1,
+ 'grant_update' => 1,
+ 'grant_delete' => 1,
+ 'priority' => 0,
+ );
+ }
+
+ return $grants;
+}
+
+/**
+ * Implements hook_permission().
+ *
+ * Sets up permissions for this module.
+ */
+function node_access_test_permission() {
+ return array('node test view' => array('title' => 'View content'));
+}
+
+/**
+ * Implements hook_menu().
+ *
+ * Sets up a page that lists nodes.
+ */
+function node_access_test_menu() {
+ $items = array();
+ $items['node_access_test_page'] = array(
+ 'title' => 'Node access test',
+ 'page callback' => 'node_access_test_page',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ );
+ $items['node_access_entity_test_page'] = array(
+ 'title' => 'Node access test',
+ 'page callback' => 'node_access_entity_test_page',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ );
+ return $items;
+}
+
+/**
+ * Page callback for node access test page.
+ *
+ * Page should say "No nodes" if there are no nodes, and "Yes, # nodes" (with
+ * the number filled in) if there were nodes the user could access. Also, the
+ * database query is shown, and a list of the node IDs, for debugging purposes.
+ * And if there is a query exception, the page says "Exception" and gives the
+ * error.
+ */
+function node_access_test_page() {
+ $output = '';
+
+ try {
+ $query = db_select('node', 'mytab')
+ ->fields('mytab');
+ $query->addTag('node_access');
+ $result = $query->execute()->fetchAll();
+
+ if (count($result)) {
+ $output .= '<p>Yes, ' . count($result) . ' nodes</p>';
+ $output .= '<ul>';
+ foreach ($result as $item) {
+ $output .= '<li>' . $item->nid . '</li>';
+ }
+ $output .= '</ul>';
+ }
+ else {
+ $output .= '<p>No nodes</p>';
+ }
+
+ $output .= '<p>' . ((string) $query ) . '</p>';
+ }
+ catch (Exception $e) {
+ $output = '<p>Exception</p>';
+ $output .= '<p>' . $e->getMessage() . '</p>';
+ }
+
+ return $output;
+}
+
+/**
+ * Page callback for node access entity test page.
+ *
+ * Page should say "No nodes" if there are no nodes, and "Yes, # nodes" (with
+ * the number filled in) if there were nodes the user could access. Also, the
+ * database query is shown, and a list of the node IDs, for debugging purposes.
+ * And if there is a query exception, the page says "Exception" and gives the
+ * error.
+ */
+function node_access_entity_test_page() {
+ $output = '';
+ try {
+ $query = new EntityFieldQuery;
+ $result = $query->fieldCondition('body', 'value', 'A', 'STARTS_WITH')->execute();
+ if (!empty($result['node'])) {
+ $output .= '<p>Yes, ' . count($result['node']) . ' nodes</p>';
+ $output .= '<ul>';
+ foreach ($result['node'] as $nid => $v) {
+ $output .= '<li>' . $nid . '</li>';
+ }
+ $output .= '</ul>';
+ }
+ else {
+ $output .= '<p>No nodes</p>';
+ }
+ }
+ catch (Exception $e) {
+ $output = '<p>Exception</p>';
+ $output .= '<p>' . $e->getMessage() . '</p>';
+ }
+
+ return $output;
+}
+
+/**
+ * Implements hook_form_node_form_alter().
+ */
+function node_access_test_form_node_form_alter(&$form, $form_state) {
+ // Only show this checkbox for NodeAccessBaseTableTestCase.
+ if (variable_get('node_access_test_private')) {
+ $form['private'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Private'),
+ '#description' => t('Check here if this content should be set private and only shown to privileged users.'),
+ '#default_value' => isset($form['#node']->private) ? $form['#node']->private : FALSE,
+ );
+ }
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function node_access_test_node_load($nodes, $types) {
+ $result = db_query('SELECT nid, private FROM {node_access_test} WHERE nid IN(:nids)', array(':nids' => array_keys($nodes)));
+ foreach ($result as $record) {
+ $nodes[$record->nid]->private = $record->private;
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+
+function node_access_test_node_delete($node) {
+ db_delete('node_access_test')->condition('nid', $node->nid)->execute();
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function node_access_test_node_insert($node) {
+ _node_access_test_node_write($node);
+}
+
+/**
+ * Implements hook_nodeapi_update().
+ */
+function node_access_test_node_update($node) {
+ _node_access_test_node_write($node);
+}
+
+/**
+ * Helper for node insert/update.
+ */
+function _node_access_test_node_write($node) {
+ if (isset($node->private)) {
+ db_merge('node_access_test')
+ ->key(array('nid' => $node->nid))
+ ->fields(array('private' => (int) $node->private))
+ ->execute();
+ }
+}
diff --git a/core/modules/node/tests/node_test.info b/core/modules/node/tests/node_test.info
new file mode 100644
index 000000000000..86eed52f8df5
--- /dev/null
+++ b/core/modules/node/tests/node_test.info
@@ -0,0 +1,6 @@
+name = "Node module tests"
+description = "Support module for node related testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/node/tests/node_test.module b/core/modules/node/tests/node_test.module
new file mode 100644
index 000000000000..b0ebc149a150
--- /dev/null
+++ b/core/modules/node/tests/node_test.module
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing node related hooks to test API interaction with
+ * the Node module.
+ */
+
+/**
+ * Implements hook_node_load().
+ */
+function node_test_node_load($nodes, $types) {
+ // Add properties to each loaded node which record the parameters that were
+ // passed in to this function, so the tests can check that (a) this hook was
+ // called, and (b) the parameters were what we expected them to be.
+ $nids = array_keys($nodes);
+ ksort($nids);
+ sort($types);
+ foreach ($nodes as $node) {
+ $node->node_test_loaded_nids = $nids;
+ $node->node_test_loaded_types = $types;
+ }
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function node_test_node_view($node, $view_mode) {
+ if ($view_mode == 'rss') {
+ // Add RSS elements and namespaces when building the RSS feed.
+ $node->rss_elements[] = array(
+ 'key' => 'testElement',
+ 'value' => t('Value of testElement RSS element for node !nid.', array('!nid' => $node->nid)),
+ );
+ $node->rss_namespaces['xmlns:drupaltest'] = 'http://example.com/test-namespace';
+
+ // Add content that should be displayed only in the RSS feed.
+ $node->content['extra_feed_content'] = array(
+ '#markup' => '<p>' . t('Extra data that should appear only in the RSS feed for node !nid.', array('!nid' => $node->nid)) . '</p>',
+ '#weight' => 10,
+ );
+ }
+
+ if ($view_mode != 'rss') {
+ // Add content that should NOT be displayed in the RSS feed.
+ $node->content['extra_non_feed_content'] = array(
+ '#markup' => '<p>' . t('Extra data that should appear everywhere except the RSS feed for node !nid.', array('!nid' => $node->nid)) . '</p>',
+ );
+ }
+}
+
+/**
+ * Implements hook_node_grants().
+ */
+function node_test_node_grants($account, $op) {
+ // Give everyone full grants so we don't break other node tests.
+ // Our node access tests asserts three realms of access.
+ // See testGrantAlter().
+ return array(
+ 'test_article_realm' => array(1),
+ 'test_page_realm' => array(1),
+ 'test_alter_realm' => array(2),
+ );
+}
+
+/**
+ * Implements hook_node_access_records().
+ */
+function node_test_node_access_records($node) {
+ // Return nothing when testing for empty responses.
+ if (!empty($node->disable_node_access)) {
+ return;
+ }
+ $grants = array();
+ if ($node->type == 'article') {
+ // Create grant in arbitrary article_realm for article nodes.
+ $grants[] = array(
+ 'realm' => 'test_article_realm',
+ 'gid' => 1,
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ 'priority' => 0,
+ );
+ }
+ elseif ($node->type == 'page') {
+ // Create grant in arbitrary page_realm for page nodes.
+ $grants[] = array(
+ 'realm' => 'test_page_realm',
+ 'gid' => 1,
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ 'priority' => 0,
+ );
+ }
+ return $grants;
+}
+
+/**
+ * Implements hook_node_access_records_alter().
+ */
+function node_test_node_access_records_alter(&$grants, $node) {
+ if (!empty($grants)) {
+ foreach ($grants as $key => $grant) {
+ // Alter grant from test_page_realm to test_alter_realm and modify the gid.
+ if ($grant['realm'] == 'test_page_realm' && $node->promote) {
+ $grants[$key]['realm'] = 'test_alter_realm';
+ $grants[$key]['gid'] = 2;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_grants_alter().
+ */
+function node_test_node_grants_alter(&$grants, $account, $op) {
+ // Return an empty array of grants to prove that we can alter by reference.
+ $grants = array();
+}
+
+/**
+ * Implements hook_node_presave().
+ */
+function node_test_node_presave($node) {
+ if ($node->title == 'testing_node_presave') {
+ // Sun, 19 Nov 1978 05:00:00 GMT
+ $node->created = 280299600;
+ // Drupal 1.0 release.
+ $node->changed = 979534800;
+ }
+ // Determine changes.
+ if (!empty($node->original) && $node->original->title == 'test_changes') {
+ if ($node->original->title != $node->title) {
+ $node->title .= '_presave';
+ }
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function node_test_node_update($node) {
+ // Determine changes on update.
+ if (!empty($node->original) && $node->original->title == 'test_changes') {
+ if ($node->original->title != $node->title) {
+ $node->title .= '_update';
+ }
+ }
+}
diff --git a/core/modules/node/tests/node_test_exception.info b/core/modules/node/tests/node_test_exception.info
new file mode 100644
index 000000000000..3289912b6a1b
--- /dev/null
+++ b/core/modules/node/tests/node_test_exception.info
@@ -0,0 +1,6 @@
+name = "Node module exception tests"
+description = "Support module for node related exception testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/node/tests/node_test_exception.module b/core/modules/node/tests/node_test_exception.module
new file mode 100644
index 000000000000..0fe9f35ea564
--- /dev/null
+++ b/core/modules/node/tests/node_test_exception.module
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing node related hooks to test API interaction with
+ * the Node module.
+ */
+
+/**
+ * Implements hook_node_insert().
+ */
+function node_test_exception_node_insert($node) {
+ if ($node->title == 'testing_transaction_exception') {
+ throw new Exception('Test exception for rollback.');
+ }
+}
diff --git a/core/modules/openid/login-bg.png b/core/modules/openid/login-bg.png
new file mode 100644
index 000000000000..532614ffa3db
--- /dev/null
+++ b/core/modules/openid/login-bg.png
Binary files differ
diff --git a/core/modules/openid/openid-rtl.css b/core/modules/openid/openid-rtl.css
new file mode 100644
index 000000000000..861f6d7d19de
--- /dev/null
+++ b/core/modules/openid/openid-rtl.css
@@ -0,0 +1,18 @@
+
+#edit-openid-identifier {
+ background-position: right 50%;
+ padding-left: 0;
+ padding-right: 20px;
+}
+#user-login .openid-links {
+ padding-right: 0;
+}
+html.js #user-login-form li.openid-link,
+html.js #user-login li.openid-link {
+ margin-right: 0;
+}
+#user-login-form li.openid-link a,
+#user-login li.openid-link a {
+ background-position: right top;
+ padding: 0 1.5em 0 0;
+}
diff --git a/core/modules/openid/openid.api.php b/core/modules/openid/openid.api.php
new file mode 100644
index 000000000000..5e3d15d94cbe
--- /dev/null
+++ b/core/modules/openid/openid.api.php
@@ -0,0 +1,116 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the OpenID module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Allow modules to modify the OpenID request parameters.
+ *
+ * @param $op
+ * The operation to be performed.
+ * Possible values:
+ * - request: Modify parameters before they are sent to the OpenID provider.
+ * @param $request
+ * An associative array of parameter defaults to which to modify or append.
+ * @return
+ * An associative array of parameters to be merged with the default list.
+ *
+ */
+function hook_openid($op, $request) {
+ if ($op == 'request') {
+ $request['openid.identity'] = 'http://myname.myopenid.com/';
+ }
+ return $request;
+}
+
+/**
+ * Allow modules to act upon a successful OpenID login.
+ *
+ * @param $response
+ * Response values from the OpenID Provider.
+ * @param $account
+ * The Drupal user account that logged in
+ *
+ */
+function hook_openid_response($response, $account) {
+ if (isset($response['openid.ns.ax'])) {
+ _mymodule_store_ax_fields($response, $account);
+ }
+}
+
+/**
+ * Allow modules to declare OpenID discovery methods.
+ *
+ * The discovery function callbacks will be called in turn with an unique
+ * parameter, the claimed identifier. They have to return an associative array
+ * with array of services and claimed identifier in the same form as returned by
+ * openid_discover(). The resulting array must contain following keys:
+ * - 'services' (required) an array of discovered services (including OpenID
+ * version, endpoint URI, etc).
+ * - 'claimed_id' (optional) new claimed identifer, found by following HTTP
+ * redirects during the services discovery.
+ *
+ * The first discovery method that succeed (return at least one services) will
+ * stop the discovery process.
+ *
+ * @return
+ * An associative array which keys are the name of the discovery methods and
+ * values are function callbacks.
+ *
+ * @see hook_openid_discovery_method_info_alter()
+ */
+function hook_openid_discovery_method_info() {
+ return array(
+ 'new_discovery_idea' => '_my_discovery_method',
+ );
+}
+
+/**
+ * Allow modules to alter discovery methods.
+ */
+function hook_openid_discovery_method_info_alter(&$methods) {
+ // Remove XRI discovery scheme.
+ unset($methods['xri']);
+}
+
+/**
+ * Allow modules to declare OpenID normalization methods.
+ *
+ * The discovery function callbacks will be called in turn with an unique
+ * parameter, the identifier to normalize. They have to return a normalized
+ * identifier, or NULL if the identifier is not in a form they can handle.
+ *
+ * The first normalization method that succeed (return a value that is not NULL)
+ * will stop the normalization process.
+ *
+ * @return
+ * An array with a set of function callbacks, that will be called in turn
+ * when normalizing an OpenID identifier. The normalization functions have
+ * to return a normalized identifier, or NULL if the identifier is not in
+ * a form they can handle.
+ * @see hook_openid_normalization_method_info_alter()
+ */
+function hook_openid_normalization_method_info() {
+ return array(
+ 'new_normalization_idea' => '_my_normalization_method',
+ );
+}
+
+/**
+ * Allow modules to alter normalization methods.
+ */
+function hook_openid_normalization_method_info_alter(&$methods) {
+ // Remove Google IDP normalization.
+ unset($methods['google_idp']);
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/openid/openid.css b/core/modules/openid/openid.css
new file mode 100644
index 000000000000..48b170fbb94d
--- /dev/null
+++ b/core/modules/openid/openid.css
@@ -0,0 +1,46 @@
+
+#edit-openid-identifier {
+ background-image: url("login-bg.png");
+ background-position: left 50%; /* LTR */
+ background-repeat: no-repeat;
+ padding-left: 20px; /* LTR */
+}
+div.form-item-openid-identifier {
+ display: block;
+}
+html.js #user-login-form div.form-item-openid-identifier,
+html.js #user-login div.form-item-openid-identifier {
+ display: none;
+}
+#user-login-form ul {
+ margin-top: 0;
+}
+#user-login ul {
+ margin: 0 0 5px;
+}
+#user-login ul li {
+ margin: 0;
+}
+#user-login-form .openid-links {
+ padding-bottom: 0;
+}
+#user-login .openid-links {
+ padding-left: 0; /* LTR */
+}
+#user-login-form .openid-links li,
+#user-login .openid-links li {
+ display: none;
+ list-style: none;
+}
+html.js #user-login-form li.openid-link,
+html.js #user-login li.openid-link {
+ display: block;
+ margin-left: 0; /* LTR */
+}
+#user-login-form li.openid-link a,
+#user-login li.openid-link a {
+ background-image: url("login-bg.png");
+ background-position: left top; /* LTR */
+ background-repeat: no-repeat;
+ padding: 0 0 0 1.5em; /* LTR */
+}
diff --git a/core/modules/openid/openid.inc b/core/modules/openid/openid.inc
new file mode 100644
index 000000000000..6945f34ed5a8
--- /dev/null
+++ b/core/modules/openid/openid.inc
@@ -0,0 +1,795 @@
+<?php
+
+/**
+ * @file
+ * OpenID utility functions.
+ */
+
+/**
+ * Diffie-Hellman Key Exchange Default Value.
+ *
+ * This is used to establish an association between the Relying Party and the
+ * OpenID Provider.
+ *
+ * See RFC 2631: http://www.ietf.org/rfc/rfc2631.txt
+ */
+define('OPENID_DH_DEFAULT_MOD', '155172898181473697471232257763715539915724801' .
+ '966915404479707795314057629378541917580651227423698188993727816152646631' .
+ '438561595825688188889951272158842675419950341258706556549803580104870537' .
+ '681476726513255747040765857479291291572334510643245094715007229621094194' .
+ '349783925984760375594985848253359305585439638443');
+
+/**
+ * Diffie-Hellman generator; used for Diffie-Hellman key exchange computations.
+ */
+define('OPENID_DH_DEFAULT_GEN', '2');
+
+/**
+ * SHA-1 hash block size; used for Diffie-Hellman key exchange computations.
+ */
+define('OPENID_SHA1_BLOCKSIZE', 64);
+
+/**
+ * Random number generator; used for Diffie-Hellman key exchange computations.
+ */
+define('OPENID_RAND_SOURCE', '/dev/urandom');
+
+/**
+ * OpenID Authentication 2.0 namespace URL.
+ */
+define('OPENID_NS_2_0', 'http://specs.openid.net/auth/2.0');
+
+/**
+ * OpenID Authentication 1.1 namespace URL; used for backwards-compatibility.
+ */
+define('OPENID_NS_1_1', 'http://openid.net/signon/1.1');
+
+/**
+ * OpenID Authentication 1.0 namespace URL; used for backwards-compatibility.
+ */
+define('OPENID_NS_1_0', 'http://openid.net/signon/1.0');
+
+/**
+ * OpenID namespace used in Yadis documents.
+ */
+define('OPENID_NS_OPENID', 'http://openid.net/xmlns/1.0');
+
+/**
+ * OpenID Simple Registration extension.
+ */
+define('OPENID_NS_SREG', 'http://openid.net/extensions/sreg/1.1');
+
+/**
+ * OpenID Attribute Exchange extension.
+ */
+define('OPENID_NS_AX', 'http://openid.net/srv/ax/1.0');
+
+/**
+ * Extensible Resource Descriptor documents.
+ */
+define('OPENID_NS_XRD', 'xri://$xrd*($v*2.0)');
+
+/**
+ * Performs an HTTP 302 redirect (for the 1.x protocol).
+ */
+function openid_redirect_http($url, $message) {
+ $query = array();
+ foreach ($message as $key => $val) {
+ $query[] = $key . '=' . urlencode($val);
+ }
+
+ $sep = (strpos($url, '?') === FALSE) ? '?' : '&';
+ header('Location: ' . $url . $sep . implode('&', $query), TRUE, 302);
+
+ drupal_exit();
+}
+
+/**
+ * Creates a js auto-submit redirect for (for the 2.x protocol)
+ */
+function openid_redirect($url, $message) {
+ global $language;
+
+ $output = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">' . "\n";
+ $output .= '<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="' . $language->language . '" lang="' . $language->language . '">' . "\n";
+ $output .= "<head>\n";
+ $output .= "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n";
+ $output .= "<title>" . t('OpenID redirect') . "</title>\n";
+ $output .= "</head>\n";
+ $output .= "<body>\n";
+ $elements = drupal_get_form('openid_redirect_form', $url, $message);
+ $output .= drupal_render($elements);
+ $output .= '<script type="text/javascript">document.getElementById("openid-redirect-form").submit();</script>' . "\n";
+ $output .= "</body>\n";
+ $output .= "</html>\n";
+ print $output;
+
+ drupal_exit();
+}
+
+function openid_redirect_form($form, &$form_state, $url, $message) {
+ $form['#action'] = $url;
+ $form['#method'] = "post";
+ foreach ($message as $key => $value) {
+ $form[$key] = array(
+ '#type' => 'hidden',
+ '#name' => $key,
+ '#value' => $value,
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#prefix' => '<noscript><div>',
+ '#suffix' => '</div></noscript>',
+ '#value' => t('Send'),
+ );
+
+ return $form;
+}
+
+/**
+ * Parse an XRDS document.
+ *
+ * @param $raw_xml
+ * A string containing the XRDS document.
+ * @return
+ * An array of service entries.
+ */
+function _openid_xrds_parse($raw_xml) {
+ $services = array();
+ try {
+ $xml = @new SimpleXMLElement($raw_xml);
+ foreach ($xml->children(OPENID_NS_XRD)->XRD as $xrd) {
+ foreach ($xrd->children(OPENID_NS_XRD)->Service as $service_element) {
+ $service = array(
+ 'priority' => $service_element->attributes()->priority ? (int)$service_element->attributes()->priority : PHP_INT_MAX,
+ 'types' => array(),
+ 'uri' => (string)$service_element->children(OPENID_NS_XRD)->URI,
+ 'service' => $service_element,
+ 'xrd' => $xrd,
+ );
+ foreach ($service_element->Type as $type) {
+ $service['types'][] = (string)$type;
+ }
+ if ($service_element->children(OPENID_NS_XRD)->LocalID) {
+ $service['identity'] = (string)$service_element->children(OPENID_NS_XRD)->LocalID;
+ }
+ elseif ($service_element->children(OPENID_NS_OPENID)->Delegate) {
+ $service['identity'] = (string)$service_element->children(OPENID_NS_OPENID)->Delegate;
+ }
+ else {
+ $service['identity'] = FALSE;
+ }
+ $services[] = $service;
+ }
+ }
+ }
+ catch (Exception $e) {
+ // Invalid XML.
+ }
+ return $services;
+}
+
+/**
+ * Select a service element.
+ *
+ * The procedure is described in OpenID Authentication 2.0, section 7.3.2.
+ *
+ * A new entry is added to the returned array with the key 'version' and the
+ * value 1 or 2 specifying the protocol version used by the service.
+ *
+ * @param $services
+ * An array of service arrays as returned by openid_discovery().
+ * @return
+ * The selected service array, or NULL if no valid services were found.
+ */
+function _openid_select_service(array $services) {
+ // Extensible Resource Identifier (XRI) Resolution Version 2.0, section 4.3.3:
+ // Find the service with the highest priority (lowest integer value). If there
+ // is a tie, select a random one, not just the first in the XML document.
+ shuffle($services);
+ $selected_service = NULL;
+ $selected_type_priority = FALSE;
+
+ // Search for an OP Identifier Element.
+ foreach ($services as $service) {
+ if (!empty($service['uri'])) {
+ $type_priority = FALSE;
+ if (in_array('http://specs.openid.net/auth/2.0/server', $service['types'])) {
+ $service['version'] = 2;
+ $type_priority = 1;
+ }
+ elseif (in_array('http://specs.openid.net/auth/2.0/signon', $service['types'])) {
+ $service['version'] = 2;
+ $type_priority = 2;
+ }
+ elseif (in_array(OPENID_NS_1_0, $service['types']) || in_array(OPENID_NS_1_1, $service['types'])) {
+ $service['version'] = 1;
+ $type_priority = 3;
+ }
+
+ if ($type_priority
+ && (!$selected_service
+ || $type_priority < $selected_type_priority
+ || ($type_priority == $selected_type_priority && $service['priority'] < $selected_service['priority']))) {
+ $selected_service = $service;
+ $selected_type_priority = $type_priority;
+ }
+ }
+ }
+
+ if ($selected_service) {
+ // Unset SimpleXMLElement instances that cannot be saved in $_SESSION.
+ unset($selected_service['xrd']);
+ unset($selected_service['service']);
+ }
+
+ return $selected_service;
+}
+
+/**
+ * Determine if the given identifier is an XRI ID.
+ */
+function _openid_is_xri($identifier) {
+ // Strip the xri:// scheme from the identifier if present.
+ if (stripos($identifier, 'xri://') === 0) {
+ $identifier = substr($identifier, 6);
+ }
+
+ // Test whether the identifier starts with an XRI global context symbol or (.
+ $firstchar = substr($identifier, 0, 1);
+ if (strpos("=@+$!(", $firstchar) !== FALSE) {
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Normalize the given identifier.
+ *
+ * The procedure is described in OpenID Authentication 2.0, section 7.2.
+ */
+function openid_normalize($identifier) {
+ $methods = module_invoke_all('openid_normalization_method_info');
+ drupal_alter('openid_normalization_method_info', $methods);
+
+ // Execute each method in turn, stopping after the first method accepted
+ // the identifier.
+ foreach ($methods as $method) {
+ $result = $method($identifier);
+ if ($result !== NULL) {
+ $identifier = $result;
+ break;
+ }
+ }
+
+ return $identifier;
+}
+
+/**
+ * OpenID normalization method: normalize XRI identifiers.
+ */
+function _openid_xri_normalize($identifier) {
+ if (_openid_is_xri($identifier)) {
+ if (stristr($identifier, 'xri://') !== FALSE) {
+ $identifier = substr($identifier, 6);
+ }
+ return $identifier;
+ }
+}
+
+/**
+ * OpenID normalization method: normalize URL identifiers.
+ */
+function _openid_url_normalize($url) {
+ $normalized_url = $url;
+
+ if (stristr($url, '://') === FALSE) {
+ $normalized_url = 'http://' . $url;
+ }
+
+ // Strip the fragment and fragment delimiter if present.
+ $normalized_url = strtok($normalized_url, '#');
+
+ if (substr_count($normalized_url, '/') < 3) {
+ $normalized_url .= '/';
+ }
+
+ return $normalized_url;
+}
+
+/**
+ * Create a serialized message packet as per spec: $key:$value\n .
+ */
+function _openid_create_message($data) {
+ $serialized = '';
+
+ foreach ($data as $key => $value) {
+ if ((strpos($key, ':') !== FALSE) || (strpos($key, "\n") !== FALSE) || (strpos($value, "\n") !== FALSE)) {
+ return NULL;
+ }
+ $serialized .= "$key:$value\n";
+ }
+ return $serialized;
+}
+
+/**
+ * Encode a message from _openid_create_message for HTTP Post
+ */
+function _openid_encode_message($message) {
+ $encoded_message = '';
+
+ $items = explode("\n", $message);
+ foreach ($items as $item) {
+ $parts = explode(':', $item, 2);
+
+ if (count($parts) == 2) {
+ if ($encoded_message != '') {
+ $encoded_message .= '&';
+ }
+ $encoded_message .= rawurlencode(trim($parts[0])) . '=' . rawurlencode(trim($parts[1]));
+ }
+ }
+
+ return $encoded_message;
+}
+
+/**
+ * Convert a direct communication message
+ * into an associative array.
+ */
+function _openid_parse_message($message) {
+ $parsed_message = array();
+
+ $items = explode("\n", $message);
+ foreach ($items as $item) {
+ $parts = explode(':', $item, 2);
+
+ if (count($parts) == 2) {
+ $parsed_message[$parts[0]] = $parts[1];
+ }
+ }
+
+ return $parsed_message;
+}
+
+/**
+ * Return a nonce value - formatted per OpenID spec.
+ */
+function _openid_nonce() {
+ // YYYY-MM-DDThh:mm:ssZ, plus some optional extra unique characters.
+ return gmdate('Y-m-d\TH:i:s\Z') .
+ chr(mt_rand(0, 25) + 65) .
+ chr(mt_rand(0, 25) + 65) .
+ chr(mt_rand(0, 25) + 65) .
+ chr(mt_rand(0, 25) + 65);
+}
+
+/**
+ * Pull the href attribute out of an html link element.
+ */
+function _openid_link_href($rel, $html) {
+ $rel = preg_quote($rel);
+ preg_match('|<link\s+rel=["\'](.*)' . $rel . '(.*)["\'](.*)/?>|iUs', $html, $matches);
+ if (isset($matches[3])) {
+ preg_match('|href=["\']([^"]+)["\']|iU', $matches[3], $href);
+ return trim($href[1]);
+ }
+ return FALSE;
+}
+
+/**
+ * Pull the http-equiv attribute out of an html meta element
+ */
+function _openid_meta_httpequiv($equiv, $html) {
+ preg_match('|<meta\s+http-equiv=["\']' . $equiv . '["\'](.*)/?>|iUs', $html, $matches);
+ if (isset($matches[1])) {
+ preg_match('|content=["\']([^"]+)["\']|iUs', $matches[1], $content);
+ if (isset($content[1])) {
+ return $content[1];
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Sign certain keys in a message
+ * @param $association - object loaded from openid_association or openid_server_association table
+ * - important fields are ->assoc_type and ->mac_key
+ * @param $message_array - array of entire message about to be sent
+ * @param $keys_to_sign - keys in the message to include in signature (without
+ * 'openid.' appended)
+ */
+function _openid_signature($association, $message_array, $keys_to_sign) {
+ $signature = '';
+ $sign_data = array();
+
+ foreach ($keys_to_sign as $key) {
+ if (isset($message_array['openid.' . $key])) {
+ $sign_data[$key] = $message_array['openid.' . $key];
+ }
+ }
+
+ $message = _openid_create_message($sign_data);
+ $secret = base64_decode($association->mac_key);
+ $signature = _openid_hmac($secret, $message);
+
+ return base64_encode($signature);
+}
+
+function _openid_hmac($key, $text) {
+ if (strlen($key) > OPENID_SHA1_BLOCKSIZE) {
+ $key = sha1($key, TRUE);
+ }
+
+ $key = str_pad($key, OPENID_SHA1_BLOCKSIZE, chr(0x00));
+ $ipad = str_repeat(chr(0x36), OPENID_SHA1_BLOCKSIZE);
+ $opad = str_repeat(chr(0x5c), OPENID_SHA1_BLOCKSIZE);
+ $hash1 = sha1(($key ^ $ipad) . $text, TRUE);
+ $hmac = sha1(($key ^ $opad) . $hash1, TRUE);
+
+ return $hmac;
+}
+
+function _openid_dh_base64_to_long($str) {
+ $b64 = base64_decode($str);
+
+ return _openid_dh_binary_to_long($b64);
+}
+
+function _openid_dh_long_to_base64($str) {
+ return base64_encode(_openid_dh_long_to_binary($str));
+}
+
+function _openid_dh_binary_to_long($str) {
+ $bytes = array_merge(unpack('C*', $str));
+
+ $n = 0;
+ foreach ($bytes as $byte) {
+ $n = _openid_math_mul($n, pow(2, 8));
+ $n = _openid_math_add($n, $byte);
+ }
+
+ return $n;
+}
+
+function _openid_dh_long_to_binary($long) {
+ $cmp = _openid_math_cmp($long, 0);
+ if ($cmp < 0) {
+ return FALSE;
+ }
+
+ if ($cmp == 0) {
+ return "\x00";
+ }
+
+ $bytes = array();
+
+ while (_openid_math_cmp($long, 0) > 0) {
+ array_unshift($bytes, _openid_math_mod($long, 256));
+ $long = _openid_math_div($long, pow(2, 8));
+ }
+
+ if ($bytes && ($bytes[0] > 127)) {
+ array_unshift($bytes, 0);
+ }
+
+ $string = '';
+ foreach ($bytes as $byte) {
+ $string .= pack('C', $byte);
+ }
+
+ return $string;
+}
+
+function _openid_dh_xorsecret($shared, $secret) {
+ $dh_shared_str = _openid_dh_long_to_binary($shared);
+ $sha1_dh_shared = sha1($dh_shared_str, TRUE);
+ $xsecret = "";
+ for ($i = 0; $i < strlen($secret); $i++) {
+ $xsecret .= chr(ord($secret[$i]) ^ ord($sha1_dh_shared[$i]));
+ }
+
+ return $xsecret;
+}
+
+function _openid_dh_rand($stop) {
+ $duplicate_cache = &drupal_static(__FUNCTION__, array());
+
+ // Used as the key for the duplicate cache
+ $rbytes = _openid_dh_long_to_binary($stop);
+
+ if (isset($duplicate_cache[$rbytes])) {
+ list($duplicate, $nbytes) = $duplicate_cache[$rbytes];
+ }
+ else {
+ if ($rbytes[0] == "\x00") {
+ $nbytes = strlen($rbytes) - 1;
+ }
+ else {
+ $nbytes = strlen($rbytes);
+ }
+
+ $mxrand = _openid_math_pow(256, $nbytes);
+
+ // If we get a number less than this, then it is in the
+ // duplicated range.
+ $duplicate = _openid_math_mod($mxrand, $stop);
+
+ if (count($duplicate_cache) > 10) {
+ $duplicate_cache = array();
+ }
+
+ $duplicate_cache[$rbytes] = array($duplicate, $nbytes);
+ }
+
+ do {
+ $bytes = "\x00" . _openid_get_bytes($nbytes);
+ $n = _openid_dh_binary_to_long($bytes);
+ // Keep looping if this value is in the low duplicated range.
+ } while (_openid_math_cmp($n, $duplicate) < 0);
+
+ return _openid_math_mod($n, $stop);
+}
+
+function _openid_get_bytes($num_bytes) {
+ $f = &drupal_static(__FUNCTION__);
+ $bytes = '';
+ if (!isset($f)) {
+ $f = @fopen(OPENID_RAND_SOURCE, "r");
+ }
+ if (!$f) {
+ // pseudorandom used
+ $bytes = '';
+ for ($i = 0; $i < $num_bytes; $i += 4) {
+ $bytes .= pack('L', mt_rand());
+ }
+ $bytes = substr($bytes, 0, $num_bytes);
+ }
+ else {
+ $bytes = fread($f, $num_bytes);
+ }
+ return $bytes;
+}
+
+function _openid_response($str = NULL) {
+ $data = array();
+
+ if (isset($_SERVER['REQUEST_METHOD'])) {
+ $data = _openid_get_params($_SERVER['QUERY_STRING']);
+
+ if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+ $str = file_get_contents('php://input');
+
+ $post = array();
+ if ($str !== FALSE) {
+ $post = _openid_get_params($str);
+ }
+
+ $data = array_merge($data, $post);
+ }
+ }
+
+ return $data;
+}
+
+function _openid_get_params($str) {
+ $chunks = explode("&", $str);
+
+ $data = array();
+ foreach ($chunks as $chunk) {
+ $parts = explode("=", $chunk, 2);
+
+ if (count($parts) == 2) {
+ list($k, $v) = $parts;
+ $data[$k] = urldecode($v);
+ }
+ }
+ return $data;
+}
+
+/**
+ * Extract all the parameters belonging to an extension in a response message.
+ *
+ * OpenID 2.0 defines a simple extension mechanism, based on a namespace prefix.
+ *
+ * Each request or response can define a prefix using:
+ * @code
+ * openid.ns.[prefix] = [extension_namespace]
+ * openid.[prefix].[key1] = [value1]
+ * openid.[prefix].[key2] = [value2]
+ * ...
+ * @endcode
+ *
+ * This function extracts all the keys belonging to an extension namespace in a
+ * response, optionally using a fallback prefix if none is provided in the response.
+ *
+ * Note that you cannot assume that a given extension namespace will use the same
+ * prefix on the response and the request: each party may use a different prefix
+ * to refer to the same namespace.
+ *
+ * @param $response
+ * The response array.
+ * @param $extension_namespace
+ * The namespace of the extension.
+ * @param $fallback_prefix
+ * An optional prefix that will be used in case no prefix is found for the
+ * target extension namespace.
+ * @return
+ * An associative array containing all the parameters in the response message
+ * that belong to the extension. The keys are stripped from their namespace
+ * prefix.
+ * @see http://openid.net/specs/openid-authentication-2_0.html#extensions
+ */
+function openid_extract_namespace($response, $extension_namespace, $fallback_prefix = NULL) {
+ // Find the namespace prefix.
+ $prefix = $fallback_prefix;
+ foreach ($response as $key => $value) {
+ if ($value == $extension_namespace && preg_match('/^openid\.ns\.([^.]+)$/', $key, $matches)) {
+ $prefix = $matches[1];
+ break;
+ }
+ }
+
+ // Now extract the namespace keys from the response.
+ $output = array();
+ if (!isset($prefix)) {
+ return $output;
+ }
+ foreach ($response as $key => $value) {
+ if (preg_match('/^openid\.' . $prefix . '\.(.+)$/', $key, $matches)) {
+ $local_key = $matches[1];
+ $output[$local_key] = $value;
+ }
+ }
+
+ return $output;
+}
+
+/**
+ * Extracts values from an OpenID AX Response.
+ *
+ * The values can be returned in two forms:
+ * - only openid.ax.value.<alias> (for single-valued answers)
+ * - both openid.ax.count.<alias> and openid.ax.value.<alias>.<count> (for both
+ * single and multiple-valued answers)
+ *
+ * @param $values
+ * An array as returned by openid_extract_namespace(..., OPENID_NS_AX).
+ * @param $uris
+ * An array of identifier URIs.
+ * @return
+ * An array of values.
+ * @see http://openid.net/specs/openid-attribute-exchange-1_0.html#fetch_response
+ */
+function openid_extract_ax_values($values, $uris) {
+ $output = array();
+ foreach ($values as $key => $value) {
+ if (in_array($value, $uris) && preg_match('/^type\.([^.]+)$/', $key, $matches)) {
+ $alias = $matches[1];
+ if (isset($values['count.' . $alias])) {
+ for ($i = 1; $i <= $values['count.' . $alias]; $i++) {
+ $output[] = $values['value.' . $alias . '.' . $i];
+ }
+ }
+ elseif (isset($values['value.' . $alias])) {
+ $output[] = $values['value.' . $alias];
+ }
+ break;
+ }
+ }
+ return $output;
+}
+
+/**
+ * Determine the available math library GMP vs. BCMath, favouring GMP for performance.
+ */
+function _openid_get_math_library() {
+ // Not drupal_static(), because a function is not going to disappear and
+ // change the output of this under any circumstances.
+ static $library;
+
+ if (empty($library)) {
+ if (function_exists('gmp_add')) {
+ $library = 'gmp';
+ }
+ elseif (function_exists('bcadd')) {
+ $library = 'bcmath';
+ }
+ }
+
+ return $library;
+}
+
+/**
+ * Calls the add function from the available math library for OpenID.
+ */
+function _openid_math_add($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_strval(gmp_add($x, $y));
+ case 'bcmath':
+ return bcadd($x, $y);
+ }
+}
+
+/**
+ * Calls the mul function from the available math library for OpenID.
+ */
+function _openid_math_mul($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_mul($x, $y);
+ case 'bcmath':
+ return bcmul($x, $y);
+ }
+}
+
+/**
+ * Calls the div function from the available math library for OpenID.
+ */
+function _openid_math_div($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_div($x, $y);
+ case 'bcmath':
+ return bcdiv($x, $y);
+ }
+}
+
+/**
+ * Calls the cmp function from the available math library for OpenID.
+ */
+function _openid_math_cmp($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_cmp($x, $y);
+ case 'bcmath':
+ return bccomp($x, $y);
+ }
+}
+
+/**
+ * Calls the mod function from the available math library for OpenID.
+ */
+function _openid_math_mod($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_mod($x, $y);
+ case 'bcmath':
+ return bcmod($x, $y);
+ }
+}
+
+/**
+ * Calls the pow function from the available math library for OpenID.
+ */
+function _openid_math_pow($x, $y) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_pow($x, $y);
+ case 'bcmath':
+ return bcpow($x, $y);
+ }
+}
+
+/**
+ * Calls the mul function from the available math library for OpenID.
+ */
+function _openid_math_powmod($x, $y, $z) {
+ $library = _openid_get_math_library();
+ switch ($library) {
+ case 'gmp':
+ return gmp_powm($x, $y, $z);
+ case 'bcmath':
+ return bcpowmod($x, $y, $z);
+ }
+}
diff --git a/core/modules/openid/openid.info b/core/modules/openid/openid.info
new file mode 100644
index 000000000000..9ecdba0f7df0
--- /dev/null
+++ b/core/modules/openid/openid.info
@@ -0,0 +1,6 @@
+name = OpenID
+description = "Allows users to log into your site using OpenID."
+version = VERSION
+package = Core
+core = 8.x
+files[] = openid.test
diff --git a/core/modules/openid/openid.install b/core/modules/openid/openid.install
new file mode 100644
index 000000000000..2df39aa6969f
--- /dev/null
+++ b/core/modules/openid/openid.install
@@ -0,0 +1,160 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the openid module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function openid_schema() {
+ $schema['openid_association'] = array(
+ 'description' => 'Stores temporary shared key association information for OpenID authentication.',
+ 'fields' => array(
+ 'idp_endpoint_uri' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'URI of the OpenID Provider endpoint.',
+ ),
+ 'assoc_handle' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Used to refer to this association in subsequent messages.',
+ ),
+ 'assoc_type' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'description' => 'The signature algorithm used: one of HMAC-SHA1 or HMAC-SHA256.',
+ ),
+ 'session_type' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'description' => 'Valid association session types: "no-encryption", "DH-SHA1", and "DH-SHA256".',
+ ),
+ 'mac_key' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'The MAC key (shared secret) for this association.',
+ ),
+ 'created' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'UNIX timestamp for when the association was created.',
+ ),
+ 'expires_in' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The lifetime, in seconds, of this association.',
+ ),
+ ),
+ 'primary key' => array('assoc_handle'),
+ );
+
+ $schema['openid_nonce'] = array(
+ 'description' => 'Stores received openid.response_nonce per OpenID endpoint URL to prevent replay attacks.',
+ 'fields' => array(
+ 'idp_endpoint_uri' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'URI of the OpenID Provider endpoint.',
+ ),
+ 'nonce' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'The value of openid.response_nonce.',
+ ),
+ 'expires' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'A Unix timestamp indicating when the entry should expire.',
+ ),
+ ),
+ 'indexes' => array(
+ 'nonce' => array('nonce'),
+ 'expires' => array('expires'),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_requirements().
+ */
+function openid_requirements($phase) {
+ $requirements = array();
+
+ if ($phase == 'runtime') {
+ // Check for the PHP BC Math library.
+ if (!function_exists('bcadd') && !function_exists('gmp_add')) {
+ $requirements['openid_math'] = array(
+ 'value' => t('Not installed'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => t('OpenID suggests the use of either the <a href="@gmp">GMP Math</a> (recommended for performance) or <a href="@bc">BC Math</a> libraries to enable OpenID associations.', array('@gmp' => 'http://php.net/manual/en/book.gmp.php', '@bc' => 'http://www.php.net/manual/en/book.bc.php')),
+ );
+ }
+ elseif (!function_exists('gmp_add')) {
+ $requirements['openid_math'] = array(
+ 'value' => t('Not optimized'),
+ 'severity' => REQUIREMENT_WARNING,
+ 'description' => t('OpenID suggests the use of the GMP Math library for PHP for optimal performance. Check the <a href="@url">GMP Math Library documentation</a> for installation instructions.', array('@url' => 'http://www.php.net/manual/en/book.gmp.php')),
+ );
+ }
+ else {
+ $requirements['openid_math'] = array(
+ 'value' => t('Installed'),
+ 'severity' => REQUIREMENT_OK,
+ );
+ }
+ $requirements['openid_math']['title'] = t('OpenID Math library');
+ }
+
+ return $requirements;
+}
+
+/**
+ * @addtogroup updates-6.x-to-7.x
+ * @{
+ */
+
+/**
+ * Add a table to store nonces.
+ */
+function openid_update_6000() {
+ $schema['openid_nonce'] = array(
+ 'description' => 'Stores received openid.response_nonce per OpenID endpoint URL to prevent replay attacks.',
+ 'fields' => array(
+ 'idp_endpoint_uri' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'URI of the OpenID Provider endpoint.',
+ ),
+ 'nonce' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'description' => 'The value of openid.response_nonce'
+ ),
+ 'expires' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'A Unix timestamp indicating when the entry should expire.',
+ ),
+ ),
+ 'indexes' => array(
+ 'nonce' => array('nonce'),
+ 'expires' => array('expires'),
+ ),
+ );
+
+ db_create_table('openid_nonce', $schema['openid_nonce']);
+}
+
+/**
+ * @} End of "addtogroup updates-6.x-to-7.x"
+ */
diff --git a/core/modules/openid/openid.js b/core/modules/openid/openid.js
new file mode 100644
index 000000000000..fdc97fa06be3
--- /dev/null
+++ b/core/modules/openid/openid.js
@@ -0,0 +1,49 @@
+(function ($) {
+
+Drupal.behaviors.openid = {
+ attach: function (context) {
+ var loginElements = $('.form-item-name, .form-item-pass, li.openid-link');
+ var openidElements = $('.form-item-openid-identifier, li.user-link');
+ var cookie = $.cookie('Drupal.visitor.openid_identifier');
+
+ // This behavior attaches by ID, so is only valid once on a page.
+ if (!$('#edit-openid-identifier.openid-processed').size()) {
+ if (cookie) {
+ $('#edit-openid-identifier').val(cookie);
+ }
+ if ($('#edit-openid-identifier').val() || location.hash == '#openid-login') {
+ $('#edit-openid-identifier').addClass('openid-processed');
+ loginElements.hide();
+ // Use .css('display', 'block') instead of .show() to be Konqueror friendly.
+ openidElements.css('display', 'block');
+ }
+ }
+
+ $('li.openid-link:not(.openid-processed)', context)
+ .addClass('openid-processed')
+ .click(function () {
+ loginElements.hide();
+ openidElements.css('display', 'block');
+ // Remove possible error message.
+ $('#edit-name, #edit-pass').removeClass('error');
+ $('div.messages.error').hide();
+ // Set focus on OpenID Identifier field.
+ $('#edit-openid-identifier')[0].focus();
+ return false;
+ });
+ $('li.user-link:not(.openid-processed)', context)
+ .addClass('openid-processed')
+ .click(function () {
+ openidElements.hide();
+ loginElements.css('display', 'block');
+ // Clear OpenID Identifier field and remove possible error message.
+ $('#edit-openid-identifier').val('').removeClass('error');
+ $('div.messages.error').css('display', 'block');
+ // Set focus on username field.
+ $('#edit-name')[0].focus();
+ return false;
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/openid/openid.module b/core/modules/openid/openid.module
new file mode 100644
index 000000000000..2bf891ab69ac
--- /dev/null
+++ b/core/modules/openid/openid.module
@@ -0,0 +1,1007 @@
+<?php
+
+/**
+ * @file
+ * Implement OpenID Relying Party support for Drupal
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function openid_menu() {
+ $items['openid/authenticate'] = array(
+ 'title' => 'OpenID Login',
+ 'page callback' => 'openid_authentication_page',
+ 'access callback' => 'user_is_anonymous',
+ 'type' => MENU_CALLBACK,
+ 'file' => 'openid.pages.inc',
+ );
+ $items['user/%user/openid'] = array(
+ 'title' => 'OpenID identities',
+ 'page callback' => 'openid_user_identities',
+ 'page arguments' => array(1),
+ 'access callback' => 'user_edit_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'openid.pages.inc',
+ );
+ $items['user/%user/openid/delete'] = array(
+ 'title' => 'Delete OpenID',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('openid_user_delete_form', 1),
+ 'access callback' => 'user_edit_access',
+ 'access arguments' => array(1),
+ 'file' => 'openid.pages.inc',
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_menu_site_status_alter().
+ */
+function openid_menu_site_status_alter(&$menu_site_status, $path) {
+ // Allow access to openid/authenticate even if site is in offline mode.
+ if ($menu_site_status == MENU_SITE_OFFLINE && user_is_anonymous() && $path == 'openid/authenticate') {
+ $menu_site_status = MENU_SITE_ONLINE;
+ }
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function openid_admin_paths() {
+ $paths = array(
+ 'user/*/openid' => TRUE,
+ 'user/*/openid/delete' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Implements hook_help().
+ */
+function openid_help($path, $arg) {
+ switch ($path) {
+ case 'user/%/openid':
+ $output = '<p>' . t('This site supports <a href="@openid-net">OpenID</a>, a secure way to log in to many websites using a single username and password. OpenID can reduce the necessity of managing many usernames and passwords for many websites.', array('@openid-net' => 'http://openid.net')) . '</p>';
+ $output .= '<p>' . t('To use OpenID you must first establish an identity on a public or private OpenID server. If you do not have an OpenID and would like one, look into one of the <a href="@openid-providers">free public providers</a>. You can find out more about OpenID at <a href="@openid-net">this website</a>.', array('@openid-providers' => 'http://openid.net/get/', '@openid-net' => 'http://openid.net')) . '</p>';
+ $output .= '<p>' . t('If you already have an OpenID, enter the URL to your OpenID server below (e.g. myusername.openidprovider.com). Next time you log in, you will be able to use this URL instead of a regular username and password. You can have multiple OpenID servers if you like; just keep adding them here.') . '</p>';
+ return $output;
+ case 'admin/help#openid':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The OpenID module allows users to log in using the OpenID single sign on service. <a href="@openid-net">OpenID</a> is a secure method for logging into many websites with a single username and password. It does not require special software, and it does not share passwords with any site to which it is associated, including the site being logged into. The main benefit to users is that they can have a single password that they can use on many websites. This means they can easily update their single password from a centralized location, rather than having to change dozens of passwords individually. For more information, see the online handbook entry for <a href="@handbook">OpenID module</a>.', array('@openid-net' => 'http://openid.net', '@handbook' => 'http://drupal.org/handbook/modules/openid')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Logging in with OpenID') . '</dt>';
+ $output .= '<dd>' . t("To log in using OpenID, a user must already have an OpenID account. Users can then create site accounts using their OpenID, assign one or more OpenIDs to an existing account, and log in using an OpenID. This lowers the barrier to registration, which helps increase the user base, and offers convenience and security to the users. Because OpenID cannot guarantee a user is legitimate, email verification is still necessary. When logging in, users are presented with the option of entering their OpenID URL, which will look like <em>myusername.openidprovider.com</em>. The site then communicates with the OpenID server, asking it to verify the identity of the user. If the user is logged into their OpenID server, the server communicates back to your site, verifying the user. If they are not logged in, the OpenID server will ask the user for their password. At no point does the site being logged into record the user's OpenID password.") . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_user_insert().
+ */
+function openid_user_insert(&$edit, $account, $category) {
+ if (!empty($edit['openid_claimed_id'])) {
+ // The user has registered after trying to log in via OpenID.
+ if (variable_get('user_email_verification', TRUE)) {
+ drupal_set_message(t('Once you have verified your e-mail address, you may log in via OpenID.'));
+ }
+ user_set_authmaps($account, array('authname_openid' => $edit['openid_claimed_id']));
+ unset($_SESSION['openid']);
+ unset($edit['openid_claimed_id']);
+ }
+}
+
+/**
+ * Implements hook_user_login().
+ *
+ * Save openid_identifier to visitor cookie.
+ */
+function openid_user_login(&$edit, $account) {
+ if (isset($_SESSION['openid'])) {
+ // The user has logged in via OpenID.
+ user_cookie_save(array_intersect_key($_SESSION['openid']['user_login_values'], array_flip(array('openid_identifier'))));
+ unset($_SESSION['openid']);
+ }
+}
+
+/**
+ * Implements hook_user_logout().
+ *
+ * Delete any openid_identifier in visitor cookie.
+ */
+function openid_user_logout($account) {
+ if (isset($_COOKIE['Drupal_visitor_openid_identifier'])) {
+ user_cookie_delete('openid_identifier');
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function openid_form_user_login_block_alter(&$form, &$form_state) {
+ _openid_user_login_form_alter($form, $form_state);
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function openid_form_user_login_alter(&$form, &$form_state) {
+ _openid_user_login_form_alter($form, $form_state);
+}
+
+function _openid_user_login_form_alter(&$form, &$form_state) {
+ $form['#attached']['css'][] = drupal_get_path('module', 'openid') . '/openid.css';
+ $form['#attached']['js'][] = drupal_get_path('module', 'openid') . '/openid.js';
+ $form['#attached']['library'][] = array('system', 'jquery.cookie');
+ if (!empty($form_state['input']['openid_identifier'])) {
+ $form['name']['#required'] = FALSE;
+ $form['pass']['#required'] = FALSE;
+ unset($form['#submit']);
+ $form['#validate'] = array('openid_login_validate');
+ }
+
+ $items = array();
+ $items[] = array(
+ 'data' => l(t('Log in using OpenID'), '#openid-login', array('external' => TRUE)),
+ 'class' => array('openid-link'),
+ );
+ $items[] = array(
+ 'data' => l(t('Cancel OpenID login'), '#', array('external' => TRUE)),
+ 'class' => array('user-link'),
+ );
+
+ $form['openid_links'] = array(
+ '#theme' => 'item_list',
+ '#items' => $items,
+ '#attributes' => array('class' => array('openid-links')),
+ '#weight' => 1,
+ );
+
+ $form['links']['#weight'] = 2;
+
+ $form['openid_identifier'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Log in using OpenID'),
+ '#size' => $form['name']['#size'],
+ '#maxlength' => 255,
+ '#weight' => -1,
+ '#description' => l(t('What is OpenID?'), 'http://openid.net/', array('external' => TRUE)),
+ );
+ $form['openid.return_to'] = array('#type' => 'hidden', '#value' => url('openid/authenticate', array('absolute' => TRUE, 'query' => user_login_destination())));
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Prefills the login form with values acquired via OpenID.
+ */
+function openid_form_user_register_form_alter(&$form, &$form_state) {
+ if (isset($_SESSION['openid']['response'])) {
+ module_load_include('inc', 'openid');
+
+ $response = $_SESSION['openid']['response'];
+
+ // Extract Simple Registration keys from the response.
+ $sreg_values = openid_extract_namespace($response, OPENID_NS_SREG, 'sreg');
+ // Extract Attribute Exchanges keys from the response.
+ $ax_values = openid_extract_namespace($response, OPENID_NS_AX, 'ax');
+
+ if (!empty($sreg_values['nickname'])) {
+ // Use the nickname returned by Simple Registration if available.
+ $form['account']['name']['#default_value'] = $sreg_values['nickname'];
+ }
+ elseif ($ax_name_values = openid_extract_ax_values($ax_values, array('http://axschema.org/namePerson/friendly', 'http://schema.openid.net/namePerson/friendly'))) {
+ // Else, use the first nickname returned by AX if available.
+ $form['account']['name']['#default_value'] = current($ax_name_values);
+ }
+ else {
+ $form['account']['name']['#default_value'] = '';
+ }
+
+ if (!empty($sreg_values['email'])) {
+ // Use the email returned by Simple Registration if available.
+ $form['account']['mail']['#default_value'] = $sreg_values['email'];
+ }
+ elseif ($ax_mail_values = openid_extract_ax_values($ax_values, array('http://axschema.org/contact/email', 'http://schema.openid.net/contact/email'))) {
+ // Else, use the first nickname returned by AX if available.
+ $form['account']['mail']['#default_value'] = current($ax_mail_values);
+ }
+
+ // If user_email_verification is off, hide the password field and just fill
+ // with random password to avoid confusion.
+ if (!variable_get('user_email_verification', TRUE)) {
+ $form['account']['pass']['#type'] = 'hidden';
+ $form['account']['pass']['#value'] = user_password();
+ }
+
+ $form['openid_claimed_id'] = array(
+ '#type' => 'value',
+ '#default_value' => $response['openid.claimed_id'],
+ );
+ $form['openid_display'] = array(
+ '#type' => 'item',
+ '#title' => t('Your OpenID'),
+ '#description' => t('This OpenID will be attached to your account after registration.'),
+ '#markup' => check_plain($response['openid.claimed_id']),
+ );
+ }
+}
+
+/**
+ * Login form _validate hook
+ */
+function openid_login_validate($form, &$form_state) {
+ $return_to = $form_state['values']['openid.return_to'];
+ if (empty($return_to)) {
+ $return_to = url('', array('absolute' => TRUE));
+ }
+
+ openid_begin($form_state['values']['openid_identifier'], $return_to, $form_state['values']);
+}
+
+/**
+ * The initial step of OpenID authentication responsible for the following:
+ * - Perform discovery on the claimed OpenID.
+ * - If possible, create an association with the Provider's endpoint.
+ * - Create the authentication request.
+ * - Perform the appropriate redirect.
+ *
+ * @param $claimed_id The OpenID to authenticate
+ * @param $return_to The endpoint to return to from the OpenID Provider
+ */
+function openid_begin($claimed_id, $return_to = '', $form_values = array()) {
+ module_load_include('inc', 'openid');
+
+ $service = NULL;
+ $claimed_id = openid_normalize($claimed_id);
+ $discovery = openid_discovery($claimed_id);
+
+ if (!empty($discovery['services'])) {
+ $service = _openid_select_service($discovery['services']);
+ }
+
+ // Quit if the discovery result was empty or if we can't select any service.
+ if (!$discovery || !$service) {
+ form_set_error('openid_identifier', t('Sorry, that is not a valid OpenID. Ensure you have spelled your ID correctly.'));
+ return;
+ }
+
+ // Set claimed id from discovery.
+ if (!empty($discovery['claimed_id'])) {
+ $claimed_id = $discovery['claimed_id'];
+ }
+
+ // Store discovered information in the users' session so we don't have to rediscover.
+ $_SESSION['openid']['service'] = $service;
+ // Store the claimed id
+ $_SESSION['openid']['claimed_id'] = $claimed_id;
+ // Store the login form values so we can pass them to
+ // user_exteral_login later.
+ $_SESSION['openid']['user_login_values'] = $form_values;
+
+ // If a supported math library is present, then create an association.
+ $assoc_handle = '';
+ if (_openid_get_math_library()) {
+ $assoc_handle = openid_association($service['uri']);
+ }
+
+ if (in_array('http://specs.openid.net/auth/2.0/server', $service['types'])) {
+ // User entered an OP Identifier.
+ $claimed_id = $identity = 'http://specs.openid.net/auth/2.0/identifier_select';
+ }
+ else {
+ // Use Claimed ID and/or OP-Local Identifier from service description, if
+ // available.
+ if (!empty($service['claimed_id'])) {
+ $claimed_id = $service['claimed_id'];
+ }
+ $identity = !empty($service['identity']) ? $service['identity'] : $claimed_id;
+ }
+ $request = openid_authentication_request($claimed_id, $identity, $return_to, $assoc_handle, $service);
+
+ if ($service['version'] == 2) {
+ openid_redirect($service['uri'], $request);
+ }
+ else {
+ openid_redirect_http($service['uri'], $request);
+ }
+}
+
+/**
+ * Completes OpenID authentication by validating returned data from the OpenID
+ * Provider.
+ *
+ * @param $response Array of returned values from the OpenID Provider.
+ *
+ * @return $response Response values for further processing with
+ * $response['status'] set to one of 'success', 'failed' or 'cancel'.
+ */
+function openid_complete($response = array()) {
+ module_load_include('inc', 'openid');
+
+ if (count($response) == 0) {
+ $response = _openid_response();
+ }
+
+ // Default to failed response
+ $response['status'] = 'failed';
+ if (isset($_SESSION['openid']['service']['uri']) && isset($_SESSION['openid']['claimed_id'])) {
+ $service = $_SESSION['openid']['service'];
+ $claimed_id = $_SESSION['openid']['claimed_id'];
+ unset($_SESSION['openid']['service']);
+ unset($_SESSION['openid']['claimed_id']);
+ if (isset($response['openid.mode'])) {
+ if ($response['openid.mode'] == 'cancel') {
+ $response['status'] = 'cancel';
+ }
+ else {
+ if (openid_verify_assertion($service, $response)) {
+ // OpenID Authentication, section 7.3.2.3 and Appendix A.5:
+ // The CanonicalID specified in the XRDS document must be used as the
+ // account key. We rely on the XRI proxy resolver to verify that the
+ // provider is authorized to respond on behalf of the specified
+ // identifer (required per Extensible Resource Identifier (XRI)
+ // (XRI) Resolution Version 2.0, section 14.3):
+ if (!empty($service['claimed_id'])) {
+ $response['openid.claimed_id'] = $service['claimed_id'];
+ }
+ elseif ($service['version'] == 2) {
+ // Returned Claimed Identifier could contain unique fragment
+ // identifier to allow identifier recycling so we need to preserve
+ // it in the response.
+ $response_claimed_id = openid_normalize($response['openid.claimed_id']);
+
+ // OpenID Authentication, section 11.2:
+ // If the returned Claimed Identifier is different from the one sent
+ // to the OpenID Provider, we need to do discovery on the returned
+ // identififer to make sure that the provider is authorized to
+ // respond on behalf of this.
+ if ($response_claimed_id != $claimed_id) {
+ $discovery = openid_discovery($response['openid.claimed_id']);
+ if ($discovery && !empty($discovery['services'])) {
+ $uris = array();
+ foreach ($discovery['services'] as $discovered_service) {
+ if (in_array('http://specs.openid.net/auth/2.0/server', $discovered_service['types']) || in_array('http://specs.openid.net/auth/2.0/signon', $discovered_service['types'])) {
+ $uris[] = $discovered_service['uri'];
+ }
+ }
+ }
+ if (!in_array($service['uri'], $uris)) {
+ return $response;
+ }
+ }
+ }
+ else {
+ $response['openid.claimed_id'] = $claimed_id;
+ }
+ $response['status'] = 'success';
+ }
+ }
+ }
+ }
+ return $response;
+}
+
+/**
+ * Perform discovery on a claimed ID to determine the OpenID provider endpoint.
+ *
+ * Discovery methods are provided by the hook_openid_discovery_method_info and
+ * could be further altered using the hook_openid_discovery_method_info_alter.
+ *
+ * @param $claimed_id
+ * The OpenID URL to perform discovery on.
+ *
+ * @return
+ * The resulting discovery array from the first successful discovery method,
+ * which must contain following keys:
+ * - 'services' (required) an array of discovered services (including OpenID
+ * version, endpoint URI, etc).
+ * - 'claimed_id' (optional) new claimed identifer, found by following HTTP
+ * redirects during the services discovery.
+ * If all the discovery method fails or if no appropriate discovery method is
+ * found, FALSE is returned.
+ */
+function openid_discovery($claimed_id) {
+ module_load_include('inc', 'openid');
+
+ $methods = module_invoke_all('openid_discovery_method_info');
+ drupal_alter('openid_discovery_method_info', $methods);
+
+ // Execute each method in turn and return first successful discovery.
+ foreach ($methods as $method) {
+ $discovery = $method($claimed_id);
+ if (!empty($discovery)) {
+ return $discovery;
+ }
+ }
+
+ return FALSE;
+}
+
+/**
+ * Implementation of hook_openid_discovery_method_info().
+ *
+ * Define standard discovery methods.
+ */
+function openid_openid_discovery_method_info() {
+ // The discovery process will stop as soon as one discovery method succeed.
+ // We first attempt to discover XRI-based identifiers, then standard XRDS
+ // identifiers via Yadis and HTML-based discovery, conforming to the OpenID 2.0
+ // specification.
+ return array(
+ 'xri' => '_openid_xri_discovery',
+ 'xrds' => '_openid_xrds_discovery',
+ );
+}
+
+/**
+ * OpenID discovery method: perform an XRI discovery.
+ *
+ * @see http://openid.net/specs/openid-authentication-2_0.html#discovery
+ * @see hook_openid_discovery_method_info()
+ * @see openid_discovery()
+ *
+ * @return
+ * An array of discovered services and claimed identifier or NULL. See
+ * openid_discovery() for more specific information.
+ */
+function _openid_xri_discovery($claimed_id) {
+ if (_openid_is_xri($claimed_id)) {
+ // Resolve XRI using a proxy resolver (Extensible Resource Identifier (XRI)
+ // Resolution Version 2.0, section 11.2 and 14.3).
+ $xrds_url = variable_get('xri_proxy_resolver', 'http://xri.net/') . rawurlencode($claimed_id) . '?_xrd_r=application/xrds+xml';
+ $discovery = _openid_xrds_discovery($xrds_url);
+ if (!empty($discovery['services']) && is_array($discovery['services'])) {
+ foreach ($discovery['services'] as $i => &$service) {
+ $status = $service['xrd']->children(OPENID_NS_XRD)->Status;
+ if ($status && $status->attributes()->cid == 'verified') {
+ $service['claimed_id'] = openid_normalize((string)$service['xrd']->children(OPENID_NS_XRD)->CanonicalID);
+ }
+ else {
+ // Ignore service if the Canonical ID could not be verified.
+ unset($discovery['services'][$i]);
+ }
+ }
+ if (!empty($discovery['services'])) {
+ return $discovery;
+ }
+ }
+ }
+}
+
+/**
+ * OpenID discovery method: perform a XRDS discovery.
+ *
+ * @see http://openid.net/specs/openid-authentication-2_0.html#discovery
+ * @see hook_openid_discovery_method_info()
+ * @see openid_discovery()
+ *
+ * @return
+ * An array of discovered services and claimed identifier or NULL. See
+ * openid_discovery() for more specific information.
+ */
+function _openid_xrds_discovery($claimed_id) {
+ $services = array();
+
+ $xrds_url = $claimed_id;
+ $scheme = @parse_url($xrds_url, PHP_URL_SCHEME);
+ if ($scheme == 'http' || $scheme == 'https') {
+ // For regular URLs, try Yadis resolution first, then HTML-based discovery
+ $headers = array('Accept' => 'application/xrds+xml');
+ $result = drupal_http_request($xrds_url, array('headers' => $headers));
+
+ // Check for HTTP error and make sure, that we reach the target. If the
+ // maximum allowed redirects are exhausted, final destination URL isn't
+ // reached, but drupal_http_request() doesn't return any error.
+ // @todo Remove the check for 200 HTTP result code after the following issue
+ // will be fixed: http://drupal.org/node/1096890.
+ if (!isset($result->error) && $result->code == 200) {
+
+ // Replace the user-entered claimed_id if we received a redirect.
+ if (!empty($result->redirect_url)) {
+ $claimed_id = openid_normalize($result->redirect_url);
+ }
+
+ if (isset($result->headers['content-type']) && preg_match("/application\/xrds\+xml/", $result->headers['content-type'])) {
+ // Parse XML document to find URL
+ $services = _openid_xrds_parse($result->data);
+ }
+ else {
+ $xrds_url = NULL;
+ if (isset($result->headers['x-xrds-location'])) {
+ $xrds_url = $result->headers['x-xrds-location'];
+ }
+ else {
+ // Look for meta http-equiv link in HTML head
+ $xrds_url = _openid_meta_httpequiv('X-XRDS-Location', $result->data);
+ }
+ if (!empty($xrds_url)) {
+ $headers = array('Accept' => 'application/xrds+xml');
+ $xrds_result = drupal_http_request($xrds_url, array('headers' => $headers));
+ if (!isset($xrds_result->error)) {
+ $services = _openid_xrds_parse($xrds_result->data);
+ }
+ }
+ }
+
+ // Check for HTML delegation
+ if (count($services) == 0) {
+ // Look for 2.0 links
+ $uri = _openid_link_href('openid2.provider', $result->data);
+ $identity = _openid_link_href('openid2.local_id', $result->data);
+ $type = 'http://specs.openid.net/auth/2.0/signon';
+
+ // 1.x links
+ if (empty($uri)) {
+ $uri = _openid_link_href('openid.server', $result->data);
+ $identity = _openid_link_href('openid.delegate', $result->data);
+ $type = 'http://openid.net/signon/1.1';
+ }
+ if (!empty($uri)) {
+ $services[] = array(
+ 'uri' => $uri,
+ 'identity' => $identity,
+ 'types' => array($type),
+ );
+ }
+ }
+ }
+ }
+
+ if (!empty($services)) {
+ return array(
+ 'services' => $services,
+ 'claimed_id' => $claimed_id,
+ );
+ }
+}
+
+/**
+ * Implementation of hook_openid_normalization_method_info().
+ *
+ * Define standard normalization methods.
+ */
+function openid_openid_normalization_method_info() {
+ // OpenID Authentication 2.0, section 7.2:
+ // If the User-supplied Identifier looks like an XRI, treat it as such;
+ // otherwise treat it as an HTTP URL.
+ return array(
+ 'xri' => '_openid_xri_normalize',
+ 'url' => '_openid_url_normalize',
+ );
+}
+
+/**
+ * Attempt to create a shared secret with the OpenID Provider.
+ *
+ * @param $op_endpoint URL of the OpenID Provider endpoint.
+ *
+ * @return $assoc_handle The association handle.
+ */
+function openid_association($op_endpoint) {
+ module_load_include('inc', 'openid');
+
+ // Remove Old Associations:
+ db_delete('openid_association')
+ ->where('created + expires_in < :request_time', array(':request_time' => REQUEST_TIME))
+ ->execute();
+
+ // Check to see if we have an association for this IdP already
+ $assoc_handle = db_query("SELECT assoc_handle FROM {openid_association} WHERE idp_endpoint_uri = :endpoint", array(':endpoint' => $op_endpoint))->fetchField();
+ if (empty($assoc_handle)) {
+ $mod = OPENID_DH_DEFAULT_MOD;
+ $gen = OPENID_DH_DEFAULT_GEN;
+ $r = _openid_dh_rand($mod);
+ $private = _openid_math_add($r, 1);
+ $public = _openid_math_powmod($gen, $private, $mod);
+
+ // If there is no existing association, then request one
+ $assoc_request = openid_association_request($public);
+ $assoc_message = _openid_encode_message(_openid_create_message($assoc_request));
+ $assoc_options = array(
+ 'headers' => array('Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8'),
+ 'method' => 'POST',
+ 'data' => $assoc_message,
+ );
+ $assoc_result = drupal_http_request($op_endpoint, $assoc_options);
+ if (isset($assoc_result->error)) {
+ return FALSE;
+ }
+
+ $assoc_response = _openid_parse_message($assoc_result->data);
+ if (isset($assoc_response['mode']) && $assoc_response['mode'] == 'error') {
+ return FALSE;
+ }
+
+ if ($assoc_response['session_type'] == 'DH-SHA1') {
+ $spub = _openid_dh_base64_to_long($assoc_response['dh_server_public']);
+ $enc_mac_key = base64_decode($assoc_response['enc_mac_key']);
+ $shared = _openid_math_powmod($spub, $private, $mod);
+ $assoc_response['mac_key'] = base64_encode(_openid_dh_xorsecret($shared, $enc_mac_key));
+ }
+ db_insert('openid_association')
+ ->fields(array(
+ 'idp_endpoint_uri' => $op_endpoint,
+ 'session_type' => $assoc_response['session_type'],
+ 'assoc_handle' => $assoc_response['assoc_handle'],
+ 'assoc_type' => $assoc_response['assoc_type'],
+ 'expires_in' => $assoc_response['expires_in'],
+ 'mac_key' => $assoc_response['mac_key'],
+ 'created' => REQUEST_TIME,
+ ))
+ ->execute();
+ $assoc_handle = $assoc_response['assoc_handle'];
+ }
+ return $assoc_handle;
+}
+
+/**
+ * Authenticate a user or attempt registration.
+ *
+ * @param $response Response values from the OpenID Provider.
+ */
+function openid_authentication($response) {
+ $identity = $response['openid.claimed_id'];
+
+ $account = user_external_load($identity);
+ if (isset($account->uid)) {
+ if (!variable_get('user_email_verification', TRUE) || $account->login) {
+ // Check if user is blocked.
+ $state['values']['name'] = $account->name;
+ user_login_name_validate(array(), $state);
+ if (!form_get_errors()) {
+ // Load global $user and perform final login tasks.
+ $form_state['uid'] = $account->uid;
+ user_login_submit(array(), $form_state);
+ // Let other modules act on OpenID login
+ module_invoke_all('openid_response', $response, $account);
+ }
+ }
+ else {
+ drupal_set_message(t('You must validate your email address for this account before logging in via OpenID.'));
+ }
+ }
+ elseif (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL)) {
+ // Register new user.
+
+ // Save response for use in openid_form_user_register_form_alter().
+ $_SESSION['openid']['response'] = $response;
+
+ $form_state['values'] = array();
+ $form_state['values']['op'] = t('Create new account');
+ drupal_form_submit('user_register_form', $form_state);
+
+ if (!empty($form_state['user'])) {
+ module_invoke_all('openid_response', $response, $form_state['user']);
+ drupal_goto();
+ }
+
+ $messages = drupal_get_messages('error');
+ if (empty($form_state['values']['name']) || empty($form_state['values']['mail'])) {
+ // If the OpenID provider did not provide both a user name and an email
+ // address, ask the user to complete the registration manually instead of
+ // showing the error messages about the missing values generated by FAPI.
+ drupal_set_message(t('Complete the registration by filling out the form below. If you already have an account, you can <a href="@login">log in</a> now and add your OpenID under "My account".', array('@login' => url('user/login'))), 'warning');
+ }
+ else {
+ drupal_set_message(t('Account registration using the information provided by your OpenID provider failed due to the reasons listed below. Complete the registration by filling out the form below. If you already have an account, you can <a href="@login">log in</a> now and add your OpenID under "My account".', array('@login' => url('user/login'))), 'warning');
+ // Append form validation errors below the above warning.
+ foreach ($messages['error'] as $message) {
+ drupal_set_message( $message, 'error');
+ }
+ }
+
+ // We were unable to register a valid new user. Redirect to the normal
+ // registration page and prefill with the values we received.
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ drupal_goto('user/register', array('query' => $destination));
+ }
+ else {
+ drupal_set_message(t('Only site administrators can create new user accounts.'), 'error');
+ }
+ drupal_goto();
+}
+
+function openid_association_request($public) {
+ module_load_include('inc', 'openid');
+
+ $request = array(
+ 'openid.ns' => OPENID_NS_2_0,
+ 'openid.mode' => 'associate',
+ 'openid.session_type' => 'DH-SHA1',
+ 'openid.assoc_type' => 'HMAC-SHA1'
+ );
+
+ if ($request['openid.session_type'] == 'DH-SHA1' || $request['openid.session_type'] == 'DH-SHA256') {
+ $cpub = _openid_dh_long_to_base64($public);
+ $request['openid.dh_consumer_public'] = $cpub;
+ }
+
+ return $request;
+}
+
+function openid_authentication_request($claimed_id, $identity, $return_to = '', $assoc_handle = '', $service) {
+ global $base_url;
+
+ module_load_include('inc', 'openid');
+
+ $request = array(
+ 'openid.mode' => 'checkid_setup',
+ 'openid.identity' => $identity,
+ 'openid.assoc_handle' => $assoc_handle,
+ 'openid.return_to' => $return_to,
+ );
+
+ if ($service['version'] == 2) {
+ $request['openid.ns'] = OPENID_NS_2_0;
+ $request['openid.claimed_id'] = $claimed_id;
+ $request['openid.realm'] = $base_url .'/';
+ }
+ else {
+ $request['openid.trust_root'] = $base_url .'/';
+ }
+
+ // Always request Simple Registration. The specification doesn't mandate
+ // that the Endpoint advertise OPENID_NS_SREG in the service description.
+ $request['openid.ns.sreg'] = OPENID_NS_SREG;
+ $request['openid.sreg.required'] = 'nickname,email';
+
+ // Request Attribute Exchange, if available.
+ // We only request the minimum attributes we need here, contributed modules
+ // can alter the request to add more attribute, and map them to profile fields.
+ if (in_array(OPENID_NS_AX, $service['types'])) {
+ $request['openid.ns.ax'] = OPENID_NS_AX;
+ $request['openid.ax.mode'] = 'fetch_request';
+ $request['openid.ax.required'] = 'mail_ao,name_ao,mail_son,name_son';
+
+ // Implementors disagree on which URIs to use, even for simple
+ // attributes like name and email (*sigh*). We ask for both axschema.org
+ // attributes (which are supposed to be newer), and schema.openid.net ones
+ // (which are supposed to be legacy).
+
+ // Attributes as defined by axschema.org.
+ $request['openid.ax.type.mail_ao'] = 'http://axschema.org/contact/email';
+ $request['openid.ax.type.name_ao'] = 'http://axschema.org/namePerson/friendly';
+
+ // Attributes as defined by schema.openid.net.
+ $request['openid.ax.type.mail_son'] = 'http://schema.openid.net/contact/email';
+ $request['openid.ax.type.name_son'] = 'http://schema.openid.net/namePerson/friendly';
+ }
+
+ $request = array_merge($request, module_invoke_all('openid', 'request', $request));
+
+ return $request;
+}
+
+/**
+ * Attempt to verify the response received from the OpenID Provider.
+ *
+ * @param $service
+ * Array describing the OpenID provider.
+ * @param $response
+ * Array of response values from the provider.
+ *
+ * @return boolean
+ * @see http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4
+ */
+function openid_verify_assertion($service, $response) {
+ module_load_include('inc', 'openid');
+
+ // http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.3
+ // Check the Nonce to protect against replay attacks.
+ if (!openid_verify_assertion_nonce($service, $response)) {
+ return FALSE;
+ }
+
+ // http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.1
+ // Verifying the return URL.
+ if (!openid_verify_assertion_return_url($service, $response)) {
+ return FALSE;
+ }
+
+ // http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4
+ // Verify the signatures.
+ $valid = FALSE;
+ $association = FALSE;
+
+ // If the OP returned a openid.invalidate_handle, we have to proceed with
+ // direct verification: ignore the openid.assoc_handle, even if present.
+ // See http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4.1
+ if (!empty($response['openid.assoc_handle']) && empty($response['openid.invalidate_handle'])) {
+ $association = db_query("SELECT * FROM {openid_association} WHERE assoc_handle = :assoc_handle", array(':assoc_handle' => $response['openid.assoc_handle']))->fetchObject();
+ }
+
+ if ($association && isset($association->session_type)) {
+ // http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4.2
+ // Verification using an association.
+ $valid = openid_verify_assertion_signature($service, $association, $response);
+ }
+ else {
+ // http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4.2
+ // Direct verification.
+ // The verification requests contain all the fields from the response,
+ // except openid.mode.
+ $request = $response;
+ $request['openid.mode'] = 'check_authentication';
+ $message = _openid_create_message($request);
+ $options = array(
+ 'headers' => array('Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8'),
+ 'method' => 'POST',
+ 'data' => _openid_encode_message($message),
+ );
+ $result = drupal_http_request($service['uri'], $options);
+ if (!isset($result->error)) {
+ $response = _openid_parse_message($result->data);
+
+ if (strtolower(trim($response['is_valid'])) == 'true') {
+ $valid = TRUE;
+ if (!empty($response['invalidate_handle'])) {
+ // This association handle has expired on the OP side, remove it from the
+ // database to avoid reusing it again on a subsequent authentication request.
+ // See http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4.2.2
+ db_delete('openid_association')
+ ->condition('assoc_handle', $response['invalidate_handle'])
+ ->execute();
+ }
+ }
+ else {
+ $valid = FALSE;
+ }
+ }
+ }
+ return $valid;
+}
+
+
+/**
+ * Verify the signature of the response received from the OpenID provider.
+ *
+ * @param $service
+ * Array describing the OpenID provider.
+ * @param $association
+ * Information on the association with the OpenID provider.
+ * @param $response
+ * Array of response values from the provider.
+ *
+ * @return
+ * TRUE if the signature is valid and covers all fields required to be signed.
+ * @see http://openid.net/specs/openid-authentication-2_0.html#rfc.section.11.4
+ */
+function openid_verify_assertion_signature($service, $association, $response) {
+ if ($service['version'] == 2) {
+ // OpenID Authentication 2.0, section 10.1:
+ // These keys must always be signed.
+ $mandatory_keys = array('op_endpoint', 'return_to', 'response_nonce', 'assoc_handle');
+ if (isset($response['openid.claimed_id'])) {
+ // If present, these two keys must also be signed. According to the spec,
+ // they are either both present or both absent.
+ $mandatory_keys[] = 'claimed_id';
+ $mandatory_keys[] = 'identity';
+ }
+ }
+ else {
+ // OpenID Authentication 1.1. section 4.3.3.
+ $mandatory_keys = array('identity', 'return_to');
+ }
+
+ $keys_to_sign = explode(',', $response['openid.signed']);
+
+ if (count(array_diff($mandatory_keys, $keys_to_sign)) > 0) {
+ return FALSE;
+ }
+
+ return _openid_signature($association, $response, $keys_to_sign) === $response['openid.sig'];
+}
+
+/**
+ * Verify that the nonce has not been used in earlier assertions from the same OpenID provider.
+ *
+ * @param $service
+ * Array describing the OpenID provider.
+ * @param $response
+ * Array of response values from the provider.
+ *
+ * @return
+ * TRUE if the nonce has not expired and has not been used earlier.
+ */
+function openid_verify_assertion_nonce($service, $response) {
+ if ($service['version'] != 2) {
+ return TRUE;
+ }
+
+ if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z/', $response['openid.response_nonce'], $matches)) {
+ list(, $year, $month, $day, $hour, $minutes, $seconds) = $matches;
+ $nonce_timestamp = gmmktime($hour, $minutes, $seconds, $month, $day, $year);
+ }
+ else {
+ watchdog('openid', 'Nonce from @endpoint rejected because it is not correctly formatted, nonce: @nonce.', array('@endpoint' => $service['uri'], '@nonce' => $response['openid.response_nonce']), WATCHDOG_WARNING);
+ return FALSE;
+ }
+
+ // A nonce with a timestamp to far in the past or future will already have
+ // been removed and cannot be checked for single use anymore.
+ $time = time();
+ $expiry = 900;
+ if ($nonce_timestamp <= $time - $expiry || $nonce_timestamp >= $time + $expiry) {
+ watchdog('openid', 'Nonce received from @endpoint is out of range (time difference: @intervals). Check possible clock skew.', array('@endpoint' => $service['uri'], '@interval' => $time - $nonce_timestamp), WATCHDOG_WARNING);
+ return FALSE;
+ }
+
+ // Record that this nonce was used.
+ db_insert('openid_nonce')
+ ->fields(array(
+ 'idp_endpoint_uri' => $service['uri'],
+ 'nonce' => $response['openid.response_nonce'],
+ 'expires' => $nonce_timestamp + $expiry,
+ ))
+ ->execute();
+
+ // Count the number of times this nonce was used.
+ $count_used = db_query("SELECT COUNT(*) FROM {openid_nonce} WHERE nonce = :nonce AND idp_endpoint_uri = :idp_endpoint_uri", array(
+ ':nonce' => $response['openid.response_nonce'],
+ ':idp_endpoint_uri' => $service['uri'],
+ ))->fetchField();
+
+ if ($count_used == 1) {
+ return TRUE;
+ }
+ else {
+ watchdog('openid', 'Nonce replay attempt blocked from @ip, nonce: @nonce.', array('@ip' => ip_address(), '@nonce' => $response['openid.response_nonce']), WATCHDOG_CRITICAL);
+ return FALSE;
+ }
+}
+
+
+/**
+ * Verify that openid.return_to matches the current URL.
+ *
+ * See OpenID Authentication 2.0, section 11.1. While OpenID Authentication
+ * 1.1, section 4.3 does not mandate return_to verification, the received
+ * return_to should still match these constraints.
+ *
+ * @param $service
+ * Array describing the OpenID provider.
+ * @param $response
+ * Array of response values from the provider.
+ *
+ * @return
+ * TRUE if return_to is valid, FALSE otherwise.
+ */
+function openid_verify_assertion_return_url($service, $response) {
+ global $base_url;
+
+ $return_to_parts = parse_url($response['openid.return_to']);
+
+ $base_url_parts = parse_url($base_url);
+ $current_parts = parse_url($base_url_parts['scheme'] .'://'. $base_url_parts['host'] . request_uri());
+
+ if ($return_to_parts['scheme'] != $current_parts['scheme'] || $return_to_parts['host'] != $current_parts['host'] || $return_to_parts['path'] != $current_parts['path']) {
+ return FALSE;
+ }
+ // Verify that all query parameters in the openid.return_to URL have
+ // the same value in the current URL. In addition, the current URL
+ // contains a number of other parameters added by the OpenID Provider.
+ parse_str(isset($return_to_parts['query']) ? $return_to_parts['query'] : '', $return_to_query_parameters);
+ foreach ($return_to_query_parameters as $name => $value) {
+ if (!isset($_GET[$name]) || $_GET[$name] != $value) {
+ return FALSE;
+ }
+ }
+ return TRUE;
+}
+
+/**
+ * Remove expired nonces from the database.
+ *
+ * Implementation of hook_cron().
+ */
+function openid_cron() {
+ db_delete('openid_nonce')
+ ->condition('expires', REQUEST_TIME, '<')
+ ->execute();
+}
diff --git a/core/modules/openid/openid.pages.inc b/core/modules/openid/openid.pages.inc
new file mode 100644
index 000000000000..6e3f096e4699
--- /dev/null
+++ b/core/modules/openid/openid.pages.inc
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the openid module.
+ */
+
+/**
+ * Menu callback; Process an OpenID authentication.
+ */
+function openid_authentication_page() {
+ $result = openid_complete();
+ switch ($result['status']) {
+ case 'success':
+ return openid_authentication($result);
+ case 'failed':
+ drupal_set_message(t('OpenID login failed.'), 'error');
+ break;
+ case 'cancel':
+ drupal_set_message(t('OpenID login cancelled.'));
+ break;
+ }
+ drupal_goto();
+}
+
+/**
+ * Menu callback; Manage OpenID identities for the specified user.
+ */
+function openid_user_identities($account) {
+ drupal_set_title(format_username($account));
+ drupal_add_css(drupal_get_path('module', 'openid') . '/openid.css');
+
+ // Check to see if we got a response
+ $result = openid_complete();
+ if ($result['status'] == 'success') {
+ $identity = $result['openid.claimed_id'];
+ $query = db_insert('authmap')
+ ->fields(array(
+ 'uid' => $account->uid,
+ 'authname' => $identity,
+ 'module' => 'openid',
+ ))
+ ->execute();
+ drupal_set_message(t('Successfully added %identity', array('%identity' => $identity)));
+ }
+
+ $header = array(t('OpenID'), t('Operations'));
+ $rows = array();
+
+ $result = db_query("SELECT * FROM {authmap} WHERE module='openid' AND uid=:uid", array(':uid' => $account->uid));
+ foreach ($result as $identity) {
+ $rows[] = array(check_plain($identity->authname), l(t('Delete'), 'user/' . $account->uid . '/openid/delete/' . $identity->aid));
+ }
+
+ $build['openid_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ );
+ $build['openid_user_add'] = drupal_get_form('openid_user_add');
+ return $build;
+}
+
+/**
+ * Form builder; Add an OpenID identity.
+ *
+ * @ingroup forms
+ * @see openid_user_add_validate()
+ */
+function openid_user_add() {
+ $form['openid_identifier'] = array(
+ '#type' => 'textfield',
+ '#title' => t('OpenID'),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Add an OpenID'));
+ return $form;
+}
+
+function openid_user_add_validate($form, &$form_state) {
+ // Check for existing entries.
+ $claimed_id = openid_normalize($form_state['values']['openid_identifier']);
+ if (db_query("SELECT authname FROM {authmap} WHERE authname = :authname", (array(':authname' => $claimed_id)))->fetchField()) {
+ form_set_error('openid_identifier', t('That OpenID is already in use on this site.'));
+ }
+}
+
+function openid_user_add_submit($form, &$form_state) {
+ $return_to = url('user/' . arg(1) . '/openid', array('absolute' => TRUE));
+ openid_begin($form_state['values']['openid_identifier'], $return_to);
+}
+
+/**
+ * Menu callback; Delete the specified OpenID identity from the system.
+ */
+function openid_user_delete_form($form, $form_state, $account, $aid = 0) {
+ $authname = db_query("SELECT authname FROM {authmap} WHERE uid = :uid AND aid = :aid AND module = 'openid'", array(
+ ':uid' => $account->uid,
+ ':aid' => $aid,
+ ))
+ ->fetchField();
+ return confirm_form(array(), t('Are you sure you want to delete the OpenID %authname for %user?', array('%authname' => $authname, '%user' => $account->name)), 'user/' . $account->uid . '/openid');
+}
+
+function openid_user_delete_form_submit($form, &$form_state) {
+ $query = db_delete('authmap')
+ ->condition('uid', $form_state['build_info']['args'][0]->uid)
+ ->condition('aid', $form_state['build_info']['args'][1])
+ ->condition('module', 'openid')
+ ->execute();
+ if ($query) {
+ drupal_set_message(t('OpenID deleted.'));
+ }
+ $form_state['redirect'] = 'user/' . $form_state['build_info']['args'][0]->uid . '/openid';
+}
diff --git a/core/modules/openid/openid.test b/core/modules/openid/openid.test
new file mode 100644
index 000000000000..d873c3243374
--- /dev/null
+++ b/core/modules/openid/openid.test
@@ -0,0 +1,648 @@
+<?php
+
+/**
+ * @file
+ * Tests for openid.module.
+ */
+
+/**
+ * Base class for OpenID tests.
+ */
+abstract class OpenIDWebTestCase extends DrupalWebTestCase {
+
+ /**
+ * Initiates the login procedure using the specified User-supplied Identity.
+ */
+ function submitLoginForm($identity) {
+ // Fill out and submit the login form.
+ $edit = array('openid_identifier' => $identity);
+ $this->drupalPost('', $edit, t('Log in'));
+
+ // Check we are on the OpenID redirect form.
+ $this->assertTitle(t('OpenID redirect'), t('OpenID redirect page was displayed.'));
+
+ // Submit form to the OpenID Provider Endpoint.
+ $this->drupalPost(NULL, array(), t('Send'));
+ }
+
+ /**
+ * Parses the last sent e-mail and returns the one-time login link URL.
+ */
+ function getPasswordResetURLFromMail() {
+ $mails = $this->drupalGetMails();
+ $mail = end($mails);
+ preg_match('@.+user/reset/.+@', $mail['body'], $matches);
+ return $matches[0];
+ }
+}
+
+/**
+ * Test discovery and login using OpenID
+ */
+class OpenIDFunctionalTestCase extends OpenIDWebTestCase {
+ protected $web_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'OpenID discovery and login',
+ 'description' => "Adds an identity to a user's profile and uses it to log in.",
+ 'group' => 'OpenID'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('openid', 'openid_test');
+
+ // User doesn't need special permissions; only the ability to log in.
+ $this->web_user = $this->drupalCreateUser(array());
+ }
+
+ /**
+ * Test discovery of OpenID Provider Endpoint via Yadis and HTML.
+ */
+ function testDiscovery() {
+ $this->drupalLogin($this->web_user);
+
+ // The User-supplied Identifier entered by the user may indicate the URL of
+ // the OpenID Provider Endpoint in various ways, as described in OpenID
+ // Authentication 2.0 and Yadis Specification 1.0.
+ // Note that all of the tested identifiers refer to the same endpoint, so
+ // only the first will trigger an associate request in openid_association()
+ // (association is only done the first time Drupal encounters a given
+ // endpoint).
+
+
+ // Yadis discovery (see Yadis Specification 1.0, section 6.2.5):
+ // If the User-supplied Identifier is a URL, it may be a direct or indirect
+ // reference to an XRDS document (a Yadis Resource Descriptor) that contains
+ // the URL of the OpenID Provider Endpoint.
+
+ // Identifier is the URL of an XRDS document.
+ // The URL scheme is stripped in order to test that the supplied identifier
+ // is normalized in openid_begin().
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->addIdentity(preg_replace('@^https?://@', '', $identity), 2, 'http://example.com/xrds', $identity);
+
+ $identity = url('openid-test/yadis/xrds/delegate', array('absolute' => TRUE));
+ $this->addIdentity(preg_replace('@^https?://@', '', $identity), 2, 'http://example.com/xrds-delegate', $identity);
+
+ // Identifier is the URL of an XRDS document containing an OP Identifier
+ // Element. The Relying Party sends the special value
+ // "http://specs.openid.net/auth/2.0/identifier_select" as Claimed
+ // Identifier. The OpenID Provider responds with the actual identifier
+ // including the fragment.
+ $identity = url('openid-test/yadis/xrds/dummy-user', array('absolute' => TRUE, 'fragment' => $this->randomName()));
+ // Tell openid_test.module to respond with this identifier. We test if
+ // openid_complete() processes it right.
+ variable_set('openid_test_response', array('openid.claimed_id' => $identity));
+ $this->addIdentity(url('openid-test/yadis/xrds/server', array('absolute' => TRUE)), 2, 'http://specs.openid.net/auth/2.0/identifier_select', $identity);
+ variable_set('openid_test_response', array());
+
+ // Identifier is the URL of an HTML page that is sent with an HTTP header
+ // that contains the URL of an XRDS document.
+ $this->addIdentity(url('openid-test/yadis/x-xrds-location', array('absolute' => TRUE)), 2);
+
+ // Identifier is the URL of an HTML page containing a <meta http-equiv=...>
+ // element that contains the URL of an XRDS document.
+ $this->addIdentity(url('openid-test/yadis/http-equiv', array('absolute' => TRUE)), 2);
+
+ // Identifier is an XRI. Resolve using our own dummy proxy resolver.
+ variable_set('xri_proxy_resolver', url('openid-test/yadis/xrds/xri', array('absolute' => TRUE)) . '/');
+ $this->addIdentity('@example*résumé;%25', 2, 'http://example.com/xrds', 'http://example.com/user');
+
+ // Make sure that unverified CanonicalID are not trusted.
+ variable_set('openid_test_canonical_id_status', 'bad value');
+ $this->addIdentity('@example*résumé;%25', 2, FALSE, FALSE);
+
+ // HTML-based discovery:
+ // If the User-supplied Identifier is a URL of an HTML page, the page may
+ // contain a <link rel=...> element containing the URL of the OpenID
+ // Provider Endpoint. OpenID 1 and 2 describe slightly different formats.
+
+ // OpenID Authentication 1.1, section 3.1:
+ $this->addIdentity(url('openid-test/html/openid1', array('absolute' => TRUE)), 1, 'http://example.com/html-openid1');
+
+ // OpenID Authentication 2.0, section 7.3.3:
+ $this->addIdentity(url('openid-test/html/openid2', array('absolute' => TRUE)), 2, 'http://example.com/html-openid2');
+
+ // OpenID Authentication 2.0, section 7.2.4:
+ // URL Identifiers MUST then be further normalized by both (1) following
+ // redirects when retrieving their content and finally (2) applying the
+ // rules in Section 6 of RFC3986 to the final destination URL. This final
+ // URL MUST be noted by the Relying Party as the Claimed Identifier and be
+ // used when requesting authentication.
+
+ // Single redirect.
+ $identity = $expected_claimed_id = url('openid-test/redirected/yadis/xrds/1', array('absolute' => TRUE));
+ $this->addRedirectedIdentity($identity, 2, 'http://example.com/xrds', $expected_claimed_id, 0);
+
+ // Exact 3 redirects (default value for the 'max_redirects' option in
+ // drupal_http_request()).
+ $identity = $expected_claimed_id = url('openid-test/redirected/yadis/xrds/2', array('absolute' => TRUE));
+ $this->addRedirectedIdentity($identity, 2, 'http://example.com/xrds', $expected_claimed_id, 2);
+
+ // Fails because there are more than 3 redirects (default value for the
+ // 'max_redirects' option in drupal_http_request()).
+ $identity = url('openid-test/redirected/yadis/xrds/3', array('absolute' => TRUE));
+ $expected_claimed_id = FALSE;
+ $this->addRedirectedIdentity($identity, 2, 'http://example.com/xrds', $expected_claimed_id, 3);
+ }
+
+ /**
+ * Test login using OpenID.
+ */
+ function testLogin() {
+ $this->drupalLogin($this->web_user);
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->addIdentity($identity);
+
+ $this->drupalLogout();
+
+ // Test logging in via the login block on the front page.
+ $this->submitLoginForm($identity);
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+
+ $this->drupalLogout();
+
+ // Test logging in via the user/login page.
+ $edit = array('openid_identifier' => $identity);
+ $this->drupalPost('user/login', $edit, t('Log in'));
+
+ // Check we are on the OpenID redirect form.
+ $this->assertTitle(t('OpenID redirect'), t('OpenID redirect page was displayed.'));
+
+ // Submit form to the OpenID Provider Endpoint.
+ $this->drupalPost(NULL, array(), t('Send'));
+
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+
+ // Verify user was redirected away from user/login to an accessible page.
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Test login using OpenID during maintenance mode.
+ */
+ function testLoginMaintenanceMode() {
+ $this->web_user = $this->drupalCreateUser(array('access site in maintenance mode'));
+ $this->drupalLogin($this->web_user);
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->addIdentity($identity);
+ $this->drupalLogout();
+
+ // Enable maintenance mode.
+ variable_set('maintenance_mode', 1);
+
+ // Test logging in via the user/login page while the site is offline.
+ $edit = array('openid_identifier' => $identity);
+ $this->drupalPost('user/login', $edit, t('Log in'));
+
+ // Check we are on the OpenID redirect form.
+ $this->assertTitle(t('OpenID redirect'), t('OpenID redirect page was displayed.'));
+
+ // Submit form to the OpenID Provider Endpoint.
+ $this->drupalPost(NULL, array(), t('Send'));
+
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+
+ // Verify user was redirected away from user/login to an accessible page.
+ $this->assertText(t('Operating in maintenance mode.'));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Test deleting an OpenID identity from a user's profile.
+ */
+ function testDelete() {
+ $this->drupalLogin($this->web_user);
+
+ // Add identity to user's profile.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->addIdentity($identity);
+ $this->assertText($identity, t('Identity appears in list.'));
+
+ // Delete the newly added identity.
+ $this->clickLink(t('Delete'));
+ $this->drupalPost(NULL, array(), t('Confirm'));
+
+ $this->assertText(t('OpenID deleted.'), t('Identity deleted'));
+ $this->assertNoText($identity, t('Identity no longer appears in list.'));
+ }
+
+ /**
+ * Test that a blocked user cannot log in.
+ */
+ function testBlockedUserLogin() {
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+
+ // Log in and add an OpenID Identity to the account.
+ $this->drupalLogin($this->web_user);
+ $this->addIdentity($identity);
+ $this->drupalLogout();
+
+ // Log in as an admin user and block the account.
+ $admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($admin_user);
+ $this->drupalGet('admin/people');
+ $edit = array(
+ 'operation' => 'block',
+ 'accounts[' . $this->web_user->uid . ']' => TRUE,
+ );
+ $this->drupalPost('admin/people', $edit, t('Update'));
+ $this->assertRaw('The update has been performed.', t('Account was blocked.'));
+ $this->drupalLogout();
+
+ $this->submitLoginForm($identity);
+ $this->assertRaw(t('The username %name has not been activated or is blocked.', array('%name' => $this->web_user->name)), t('User login was blocked.'));
+ }
+
+ /**
+ * Add OpenID identity to user's profile.
+ *
+ * @param $identity
+ * The User-supplied Identifier.
+ * @param $version
+ * The protocol version used by the service.
+ * @param $local_id
+ * The expected OP-Local Identifier found during discovery.
+ * @param $claimed_id
+ * The expected Claimed Identifier returned by the OpenID Provider, or FALSE
+ * if the discovery is expected to fail.
+ */
+ function addIdentity($identity, $version = 2, $local_id = 'http://example.com/xrds', $claimed_id = NULL) {
+ // Tell openid_test.module to only accept this OP-Local Identifier.
+ variable_set('openid_test_identity', $local_id);
+
+ $edit = array('openid_identifier' => $identity);
+ $this->drupalPost('user/' . $this->web_user->uid . '/openid', $edit, t('Add an OpenID'));
+
+ if ($claimed_id === FALSE) {
+ $this->assertRaw(t('Sorry, that is not a valid OpenID. Ensure you have spelled your ID correctly.'), t('Invalid identity was rejected.'));
+ return;
+ }
+
+ // OpenID 1 used a HTTP redirect, OpenID 2 uses a HTML form that is submitted automatically using JavaScript.
+ if ($version == 2) {
+ // Check we are on the OpenID redirect form.
+ $this->assertTitle(t('OpenID redirect'), t('OpenID redirect page was displayed.'));
+
+ // Submit form to the OpenID Provider Endpoint.
+ $this->drupalPost(NULL, array(), t('Send'));
+ }
+
+ if (!isset($claimed_id)) {
+ $claimed_id = $identity;
+ }
+ $this->assertRaw(t('Successfully added %identity', array('%identity' => $claimed_id)), t('Identity %identity was added.', array('%identity' => $identity)));
+ }
+
+ /**
+ * Add OpenID identity, changed by the following redirects, to user's profile.
+ *
+ * According to OpenID Authentication 2.0, section 7.2.4, URL Identifiers MUST
+ * be further normalized by following redirects when retrieving their content
+ * and this final URL MUST be noted by the Relying Party as the Claimed
+ * Identifier and be used when requesting authentication.
+ *
+ * @param $identity
+ * The User-supplied Identifier.
+ * @param $version
+ * The protocol version used by the service.
+ * @param $local_id
+ * The expected OP-Local Identifier found during discovery.
+ * @param $claimed_id
+ * The expected Claimed Identifier returned by the OpenID Provider, or FALSE
+ * if the discovery is expected to fail.
+ * @param $redirects
+ * The number of redirects.
+ */
+ function addRedirectedIdentity($identity, $version = 2, $local_id = 'http://example.com/xrds', $claimed_id = NULL, $redirects = 0) {
+ // Set the final destination URL which is the same as the Claimed
+ // Identifier, we insert the same identifier also to the provider response,
+ // but provider could further change the Claimed ID actually (e.g. it could
+ // add unique fragment).
+ variable_set('openid_test_redirect_url', $identity);
+ variable_set('openid_test_response', array('openid.claimed_id' => $identity));
+
+ $this->addIdentity(url('openid-test/redirect/' . $redirects, array('absolute' => TRUE)), $version, $local_id, $claimed_id);
+
+ // Clean up.
+ variable_del('openid_test_redirect_url');
+ variable_del('openid_test_response');
+ }
+
+ /**
+ * Tests that openid.signed is verified.
+ */
+ function testSignatureValidation() {
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+
+ // Do not sign all mandatory fields (e.g. assoc_handle).
+ variable_set('openid_test_response', array('openid.signed' => 'op_endpoint,claimed_id,identity,return_to,response_nonce'));
+ $this->submitLoginForm($identity);
+ $this->assertRaw('OpenID login failed.');
+
+ // Sign all mandatory fields and some custom fields.
+ variable_set('openid_test_response', array('openid.foo' => 'bar', 'openid.signed' => 'op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle,foo'));
+ $this->submitLoginForm($identity);
+ $this->assertNoRaw('OpenID login failed.');
+ }
+
+}
+
+/**
+ * Test account registration using Simple Registration and Attribute Exchange.
+ */
+class OpenIDRegistrationTestCase extends OpenIDWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'OpenID account registration',
+ 'description' => 'Creates a user account using auto-registration.',
+ 'group' => 'OpenID'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('openid', 'openid_test');
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ }
+
+ /**
+ * Test OpenID auto-registration with e-mail verification enabled.
+ */
+ function testRegisterUserWithEmailVerification() {
+ variable_set('user_email_verification', TRUE);
+
+ // Tell openid_test.module to respond with these SREG fields.
+ variable_set('openid_test_response', array('openid.sreg.nickname' => 'john', 'openid.sreg.email' => 'john@example.com'));
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->submitLoginForm($identity);
+ $this->assertRaw(t('Once you have verified your e-mail address, you may log in via OpenID.'), t('User was asked to verify e-mail address.'));
+ $this->assertRaw(t('A welcome message with further instructions has been sent to your e-mail address.'), t('A welcome message was sent to the user.'));
+ $reset_url = $this->getPasswordResetURLFromMail();
+
+ $user = user_load_by_name('john');
+ $this->assertTrue($user, t('User was registered with right username.'));
+ $this->assertEqual($user->mail, 'john@example.com', t('User was registered with right email address.'));
+ $this->assertFalse($user->data, t('No additional user info was saved.'));
+
+ $this->submitLoginForm($identity);
+ $this->assertRaw(t('You must validate your email address for this account before logging in via OpenID.'));
+
+ // Follow the one-time login that was sent in the welcome e-mail.
+ $this->drupalGet($reset_url);
+ $this->drupalPost(NULL, array(), t('Log in'));
+
+ $this->drupalLogout();
+
+ // Verify that the account was activated.
+ $this->submitLoginForm($identity);
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+ }
+
+ /**
+ * Test OpenID auto-registration with e-mail verification disabled.
+ */
+ function testRegisterUserWithoutEmailVerification() {
+ variable_set('user_email_verification', FALSE);
+
+ // Tell openid_test.module to respond with these SREG fields.
+ variable_set('openid_test_response', array('openid.sreg.nickname' => 'john', 'openid.sreg.email' => 'john@example.com'));
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->submitLoginForm($identity);
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+
+ $user = user_load_by_name('john');
+ $this->assertTrue($user, t('User was registered with right username.'));
+ $this->assertEqual($user->mail, 'john@example.com', t('User was registered with right email address.'));
+ $this->assertFalse($user->data, t('No additional user info was saved.'));
+
+ $this->drupalLogout();
+
+ $this->submitLoginForm($identity);
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+ }
+
+ /**
+ * Test OpenID auto-registration with a provider that supplies invalid SREG
+ * information (a username that is already taken, and no e-mail address).
+ */
+ function testRegisterUserWithInvalidSreg() {
+ // Tell openid_test.module to respond with these SREG fields.
+ $web_user = $this->drupalCreateUser(array());
+ variable_set('openid_test_response', array('openid.sreg.nickname' => $web_user->name, 'openid.sreg.email' => 'mail@invalid#'));
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->submitLoginForm($identity);
+
+ $this->assertRaw(t('Account registration using the information provided by your OpenID provider failed due to the reasons listed below. Complete the registration by filling out the form below. If you already have an account, you can <a href="@login">log in</a> now and add your OpenID under "My account".', array('@login' => url('user/login'))), t('User was asked to complete the registration process manually.'));
+ $this->assertRaw(t('The name %name is already taken.', array('%name' => $web_user->name)), t('Form validation error for username was displayed.'));
+ $this->assertRaw(t('The e-mail address %mail is not valid.', array('%mail' => 'mail@invalid#')), t('Form validation error for e-mail address was displayed.'));
+
+ // Enter username and e-mail address manually.
+ $edit = array('name' => 'john', 'mail' => 'john@example.com');
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ $this->assertRaw(t('Once you have verified your e-mail address, you may log in via OpenID.'), t('User was asked to verify e-mail address.'));
+ $reset_url = $this->getPasswordResetURLFromMail();
+
+ $user = user_load_by_name('john');
+ $this->assertTrue($user, t('User was registered with right username.'));
+ $this->assertFalse($user->data, t('No additional user info was saved.'));
+
+ // Follow the one-time login that was sent in the welcome e-mail.
+ $this->drupalGet($reset_url);
+ $this->drupalPost(NULL, array(), t('Log in'));
+
+ // The user is taken to user/%uid/edit.
+ $this->assertFieldByName('mail', 'john@example.com', t('User was registered with right e-mail address.'));
+
+ $this->clickLink(t('OpenID identities'));
+ $this->assertRaw($identity, t('OpenID identity was registered.'));
+ }
+
+ /**
+ * Test OpenID auto-registration with a provider that does not supply SREG
+ * information (i.e. no username or e-mail address).
+ */
+ function testRegisterUserWithoutSreg() {
+ // Load the front page to get the user login block.
+ $this->drupalGet('');
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->submitLoginForm($identity);
+ $this->assertRaw(t('Complete the registration by filling out the form below. If you already have an account, you can <a href="@login">log in</a> now and add your OpenID under "My account".', array('@login' => url('user/login'))), t('User was asked to complete the registration process manually.'));
+ $this->assertNoRaw(t('You must enter a username.'), t('Form validation error for username was not displayed.'));
+ $this->assertNoRaw(t('You must enter an e-mail address.'), t('Form validation error for e-mail address was not displayed.'));
+
+ // Enter username and e-mail address manually.
+ $edit = array('name' => 'john', 'mail' => 'john@example.com');
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ $this->assertRaw(t('Once you have verified your e-mail address, you may log in via OpenID.'), t('User was asked to verify e-mail address.'));
+ $reset_url = $this->getPasswordResetURLFromMail();
+
+ $user = user_load_by_name('john');
+ $this->assertTrue($user, t('User was registered with right username.'));
+ $this->assertFalse($user->data, t('No additional user info was saved.'));
+
+ // Follow the one-time login that was sent in the welcome e-mail.
+ $this->drupalGet($reset_url);
+ $this->drupalPost(NULL, array(), t('Log in'));
+
+ // The user is taken to user/%uid/edit.
+ $this->assertFieldByName('mail', 'john@example.com', t('User was registered with right e-mail address.'));
+
+ $this->clickLink(t('OpenID identities'));
+ $this->assertRaw($identity, t('OpenID identity was registered.'));
+ }
+
+ /**
+ * Test OpenID auto-registration with a provider that supplies AX information,
+ * but no SREG.
+ */
+ function testRegisterUserWithAXButNoSREG() {
+ variable_set('user_email_verification', FALSE);
+
+ // Tell openid_test.module to respond with these AX fields.
+ variable_set('openid_test_response', array(
+ 'openid.ns.ext123' => 'http://openid.net/srv/ax/1.0',
+ 'openid.ext123.type.mail456' => 'http://axschema.org/contact/email',
+ 'openid.ext123.value.mail456' => 'john@example.com',
+ 'openid.ext123.type.name789' => 'http://schema.openid.net/namePerson/friendly',
+ 'openid.ext123.count.name789' => '1',
+ 'openid.ext123.value.name789.1' => 'john',
+ ));
+
+ // Use a User-supplied Identity that is the URL of an XRDS document.
+ $identity = url('openid-test/yadis/xrds', array('absolute' => TRUE));
+ $this->submitLoginForm($identity);
+ $this->assertLink(t('Log out'), 0, t('User was logged in.'));
+
+ $user = user_load_by_name('john');
+ $this->assertTrue($user, t('User was registered with right username.'));
+ $this->assertEqual($user->mail, 'john@example.com', t('User was registered with right email address.'));
+ }
+}
+
+/**
+ * Test internal helper functions.
+ */
+class OpenIDUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'OpenID helper functions',
+ 'description' => 'Test OpenID helper functions.',
+ 'group' => 'OpenID'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('openid');
+ module_load_include('inc', 'openid');
+ }
+
+ /**
+ * Test _openid_dh_XXX_to_XXX() functions.
+ */
+ function testConversion() {
+ $this->assertEqual(_openid_dh_long_to_base64('12345678901234567890123456789012345678901234567890'), 'CHJ/Y2mq+DyhUCZ0evjH8ZbOPwrS', t('_openid_dh_long_to_base64() returned expected result.'));
+ $this->assertEqual(_openid_dh_base64_to_long('BsH/g8Nrpn2dtBSdu/sr1y8hxwyx'), '09876543210987654321098765432109876543210987654321', t('_openid_dh_base64_to_long() returned expected result.'));
+
+ $this->assertEqual(_openid_dh_long_to_binary('12345678901234567890123456789012345678901234567890'), "\x08r\x7fci\xaa\xf8<\xa1P&tz\xf8\xc7\xf1\x96\xce?\x0a\xd2", t('_openid_dh_long_to_binary() returned expected result.'));
+ $this->assertEqual(_openid_dh_binary_to_long("\x06\xc1\xff\x83\xc3k\xa6}\x9d\xb4\x14\x9d\xbb\xfb+\xd7/!\xc7\x0c\xb1"), '09876543210987654321098765432109876543210987654321', t('_openid_dh_binary_to_long() returned expected result.'));
+ }
+
+ /**
+ * Test _openid_dh_xorsecret().
+ */
+ function testOpenidDhXorsecret() {
+ $this->assertEqual(_openid_dh_xorsecret('123456790123456790123456790', "abc123ABC\x00\xFF"), "\xa4'\x06\xbe\xf1.\x00y\xff\xc2\xc1", t('_openid_dh_xorsecret() returned expected result.'));
+ }
+
+ /**
+ * Test _openid_get_bytes().
+ */
+ function testOpenidGetBytes() {
+ $this->assertEqual(strlen(_openid_get_bytes(20)), 20, t('_openid_get_bytes() returned expected result.'));
+ }
+
+ /**
+ * Test _openid_signature().
+ */
+ function testOpenidSignature() {
+ // Test that signature is calculated according to OpenID Authentication 2.0,
+ // section 6.1. In the following array, only the two first entries should be
+ // included in the calculation, because the substring following the period
+ // is mentioned in the third argument for _openid_signature(). The last
+ // entry should not be included, because it does not start with "openid.".
+ $response = array(
+ 'openid.foo' => 'abc1',
+ 'openid.bar' => 'abc2',
+ 'openid.baz' => 'abc3',
+ 'foobar.foo' => 'abc4',
+ );
+ $association = new stdClass();
+ $association->mac_key = "1234567890abcdefghij\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xF0\xF1\xF2\xF3\xF4\xF5\xF6\xF7\xF8\xF9";
+ $this->assertEqual(_openid_signature($association, $response, array('foo', 'bar')), 'QnKZQzSFstT+GNiJDFOptdcZjrc=', t('Expected signature calculated.'));
+ }
+
+ /**
+ * Test _openid_is_xri().
+ */
+ function testOpenidXRITest() {
+ // Test that the XRI test is according to OpenID Authentication 2.0,
+ // section 7.2. If the user-supplied string starts with xri:// it should be
+ // stripped and the resulting string should be treated as an XRI when it
+ // starts with "=", "@", "+", "$", "!" or "(".
+ $this->assertTrue(_openid_is_xri('xri://=foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme.'));
+ $this->assertTrue(_openid_is_xri('xri://@foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme.'));
+ $this->assertTrue(_openid_is_xri('xri://+foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme.'));
+ $this->assertTrue(_openid_is_xri('xri://$foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme.'));
+ $this->assertTrue(_openid_is_xri('xri://!foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme..'));
+ $this->assertTrue(_openid_is_xri('xri://(foo'), t('_openid_is_xri() returned expected result for an xri identifier with xri scheme..'));
+
+ $this->assertTrue(_openid_is_xri('=foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+ $this->assertTrue(_openid_is_xri('@foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+ $this->assertTrue(_openid_is_xri('+foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+ $this->assertTrue(_openid_is_xri('$foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+ $this->assertTrue(_openid_is_xri('!foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+ $this->assertTrue(_openid_is_xri('(foo'), t('_openid_is_xri() returned expected result for an xri identifier.'));
+
+ $this->assertFalse(_openid_is_xri('foo'), t('_openid_is_xri() returned expected result for an http URL.'));
+ $this->assertFalse(_openid_is_xri('xri://foo'), t('_openid_is_xri() returned expected result for an http URL.'));
+ $this->assertFalse(_openid_is_xri('http://foo/'), t('_openid_is_xri() returned expected result for an http URL.'));
+ $this->assertFalse(_openid_is_xri('http://example.com/'), t('_openid_is_xri() returned expected result for an http URL.'));
+ $this->assertFalse(_openid_is_xri('user@example.com/'), t('_openid_is_xri() returned expected result for an http URL.'));
+ $this->assertFalse(_openid_is_xri('http://user@example.com/'), t('_openid_is_xri() returned expected result for an http URL.'));
+ }
+
+ /**
+ * Test openid_normalize().
+ */
+ function testOpenidNormalize() {
+ // Test that the normalization is according to OpenID Authentication 2.0,
+ // section 7.2 and 11.5.2.
+
+ $this->assertEqual(openid_normalize('$foo'), '$foo', t('openid_normalize() correctly normalized an XRI.'));
+ $this->assertEqual(openid_normalize('xri://$foo'), '$foo', t('openid_normalize() correctly normalized an XRI with an xri:// scheme.'));
+
+ $this->assertEqual(openid_normalize('example.com/'), 'http://example.com/', t('openid_normalize() correctly normalized a URL with a missing scheme.'));
+ $this->assertEqual(openid_normalize('example.com'), 'http://example.com/', t('openid_normalize() correctly normalized a URL with a missing scheme and empty path.'));
+ $this->assertEqual(openid_normalize('http://example.com'), 'http://example.com/', t('openid_normalize() correctly normalized a URL with an empty path.'));
+
+ $this->assertEqual(openid_normalize('http://example.com/path'), 'http://example.com/path', t('openid_normalize() correctly normalized a URL with a path.'));
+
+ $this->assertEqual(openid_normalize('http://example.com/path#fragment'), 'http://example.com/path', t('openid_normalize() correctly normalized a URL with a fragment.'));
+ }
+}
diff --git a/core/modules/openid/tests/openid_test.info b/core/modules/openid/tests/openid_test.info
new file mode 100644
index 000000000000..94d125dc2fa5
--- /dev/null
+++ b/core/modules/openid/tests/openid_test.info
@@ -0,0 +1,7 @@
+name = OpenID dummy provider
+description = "OpenID provider used for testing."
+package = Testing
+version = VERSION
+core = 8.x
+dependencies[] = openid
+hidden = TRUE
diff --git a/core/modules/openid/tests/openid_test.install b/core/modules/openid/tests/openid_test.install
new file mode 100644
index 000000000000..3bd4978f1a2e
--- /dev/null
+++ b/core/modules/openid/tests/openid_test.install
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the openid_test module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function openid_test_install() {
+ module_load_include('inc', 'openid');
+ // Generate a MAC key (Message Authentication Code) used for signing messages.
+ // The variable is base64-encoded, because variables cannot contain non-UTF-8
+ // data.
+ variable_set('openid_test_mac_key', base64_encode(_openid_get_bytes(20)));
+}
diff --git a/core/modules/openid/tests/openid_test.module b/core/modules/openid/tests/openid_test.module
new file mode 100644
index 000000000000..629dcd3356a5
--- /dev/null
+++ b/core/modules/openid/tests/openid_test.module
@@ -0,0 +1,352 @@
+<?php
+
+/**
+ * @file
+ * Dummy OpenID Provider used with SimpleTest.
+ *
+ * The provider simply responds positively to all authentication requests. In
+ * addition to a Provider Endpoint (a URL used for Drupal to communicate with
+ * the provider using the OpenID Authentication protocol) the module provides
+ * URLs used by the various discovery mechanisms.
+ *
+ * When a user enters an OpenID identity, the Relying Party (in the testing
+ * scenario, this is the OpenID module) looks up the URL of the Provider
+ * Endpoint using one of several discovery mechanisms. The Relying Party then
+ * redirects the user to Provider Endpoint. The provider verifies the user's
+ * identity and redirects the user back to the Relying Party accompanied by a
+ * signed message confirming the identity. Before redirecting to a provider for
+ * the first time, the Relying Party fetches a secret MAC key from the provider
+ * by doing a direct "associate" HTTP request to the Provider Endpoint. This
+ * key is used for verifying the signed messages from the provider.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function openid_test_menu() {
+ $items['openid-test/yadis/xrds'] = array(
+ 'title' => 'XRDS service document',
+ 'page callback' => 'openid_test_yadis_xrds',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/yadis/x-xrds-location'] = array(
+ 'title' => 'Yadis discovery using X-XRDS-Location header',
+ 'page callback' => 'openid_test_yadis_x_xrds_location',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/yadis/http-equiv'] = array(
+ 'title' => 'Yadis discovery using <meta http-equiv="X-XRDS-Location" ...>',
+ 'page callback' => 'openid_test_yadis_http_equiv',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/html/openid1'] = array(
+ 'title' => 'HTML-based discovery using <link rel="openid.server" ...>',
+ 'page callback' => 'openid_test_html_openid1',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/html/openid2'] = array(
+ 'title' => 'HTML-based discovery using <link rel="openid2.provider" ...>',
+ 'page callback' => 'openid_test_html_openid2',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/endpoint'] = array(
+ 'title' => 'OpenID Provider Endpoint',
+ 'page callback' => 'openid_test_endpoint',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/redirect'] = array(
+ 'title' => 'OpenID Provider Redirection Point',
+ 'page callback' => 'openid_test_redirect',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['openid-test/redirected/%/%'] = array(
+ 'title' => 'OpenID Provider Final URL',
+ 'page callback' => 'openid_test_redirected_method',
+ 'page arguments' => array(2, 3),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_menu_site_status_alter().
+ */
+function openid_test_menu_site_status_alter(&$menu_site_status, $path) {
+ // Allow access to openid endpoint and identity even in offline mode.
+ if ($menu_site_status == MENU_SITE_OFFLINE && user_is_anonymous() && in_array($path, array('openid-test/yadis/xrds', 'openid-test/endpoint'))) {
+ $menu_site_status = MENU_SITE_ONLINE;
+ }
+}
+
+/**
+ * Menu callback; XRDS document that references the OP Endpoint URL.
+ */
+function openid_test_yadis_xrds() {
+ if ($_SERVER['HTTP_ACCEPT'] == 'application/xrds+xml') {
+ // Only respond to XRI requests for one specific XRI. The is used to verify
+ // that the XRI has been properly encoded. The "+" sign in the _xrd_r query
+ // parameter is decoded to a space by PHP.
+ if (arg(3) == 'xri') {
+ if (variable_get('clean_url', 0)) {
+ if (arg(4) != '@example*résumé;%25' || $_GET['_xrd_r'] != 'application/xrds xml') {
+ drupal_not_found();
+ }
+ }
+ else {
+ // Drupal cannot properly emulate an XRI proxy resolver using unclean
+ // URLs, so the arguments gets messed up.
+ if (arg(4) . '/' . arg(5) != '@example*résumé;%25?_xrd_r=application/xrds xml') {
+ drupal_not_found();
+ }
+ }
+ }
+ drupal_add_http_header('Content-Type', 'application/xrds+xml');
+ print '<?xml version="1.0" encoding="UTF-8"?>
+ <xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)" xmlns:openid="http://openid.net/xmlns/1.0">
+ <XRD>
+ <Status cid="' . check_plain(variable_get('openid_test_canonical_id_status', 'verified')) . '"/>
+ <ProviderID>xri://@</ProviderID>
+ <CanonicalID>http://example.com/user</CanonicalID>
+ <Service>
+ <Type>http://example.com/this-is-ignored</Type>
+ </Service>
+ <Service priority="5">
+ <Type>http://openid.net/signon/1.0</Type>
+ <URI>http://example.com/this-is-only-openid-1.0</URI>
+ </Service>
+ <Service priority="10">
+ <Type>http://specs.openid.net/auth/2.0/signon</Type>
+ <Type>http://openid.net/srv/ax/1.0</Type>
+ <URI>' . url('openid-test/endpoint', array('absolute' => TRUE)) . '</URI>
+ <LocalID>http://example.com/xrds</LocalID>
+ </Service>
+ <Service priority="15">
+ <Type>http://specs.openid.net/auth/2.0/signon</Type>
+ <URI>http://example.com/this-has-too-low-priority</URI>
+ </Service>
+ <Service>
+ <Type>http://specs.openid.net/auth/2.0/signon</Type>
+ <URI>http://example.com/this-has-too-low-priority</URI>
+ </Service>
+ ';
+ if (arg(3) == 'server') {
+ print '
+ <Service>
+ <Type>http://specs.openid.net/auth/2.0/server</Type>
+ <URI>http://example.com/this-has-too-low-priority</URI>
+ </Service>
+ <Service priority="20">
+ <Type>http://specs.openid.net/auth/2.0/server</Type>
+ <URI>' . url('openid-test/endpoint', array('absolute' => TRUE)) . '</URI>
+ </Service>';
+ }
+ elseif (arg(3) == 'delegate') {
+ print '
+ <Service priority="0">
+ <Type>http://specs.openid.net/auth/2.0/signon</Type>
+ <Type>http://openid.net/srv/ax/1.0</Type>
+ <URI>' . url('openid-test/endpoint', array('absolute' => TRUE)) . '</URI>
+ <openid:Delegate>http://example.com/xrds-delegate</openid:Delegate>
+ </Service>';
+ }
+ print '
+ </XRD>
+ </xrds:XRDS>';
+ }
+ else {
+ return t('This is a regular HTML page. If the client sends an Accept: application/xrds+xml header when requesting this URL, an XRDS document is returned.');
+ }
+}
+
+/**
+ * Menu callback; regular HTML page with an X-XRDS-Location HTTP header.
+ */
+function openid_test_yadis_x_xrds_location() {
+ drupal_add_http_header('X-XRDS-Location', url('openid-test/yadis/xrds', array('absolute' => TRUE)));
+ return t('This page includes an X-RDS-Location HTTP header containing the URL of an XRDS document.');
+}
+
+/**
+ * Menu callback; regular HTML page with <meta> element.
+ */
+function openid_test_yadis_http_equiv() {
+ $element = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'http-equiv' => 'X-XRDS-Location',
+ 'content' => url('openid-test/yadis/xrds', array('absolute' => TRUE)),
+ ),
+ );
+ drupal_add_html_head($element, 'openid_test_yadis_http_equiv');
+ return t('This page includes a &lt;meta equiv=...&gt; element containing the URL of an XRDS document.');
+}
+
+/**
+ * Menu callback; regular HTML page with OpenID 1.0 <link> element.
+ */
+function openid_test_html_openid1() {
+ drupal_add_html_head_link(array('rel' => 'openid.server', 'href' => url('openid-test/endpoint', array('absolute' => TRUE))));
+ drupal_add_html_head_link(array('rel' => 'openid.delegate', 'href' => 'http://example.com/html-openid1'));
+ return t('This page includes a &lt;link rel=...&gt; element containing the URL of an OpenID Provider Endpoint.');
+}
+
+/**
+ * Menu callback; regular HTML page with OpenID 2.0 <link> element.
+ */
+function openid_test_html_openid2() {
+ drupal_add_html_head_link(array('rel' => 'openid2.provider', 'href' => url('openid-test/endpoint', array('absolute' => TRUE))));
+ drupal_add_html_head_link(array('rel' => 'openid2.local_id', 'href' => 'http://example.com/html-openid2'));
+ return t('This page includes a &lt;link rel=...&gt; element containing the URL of an OpenID Provider Endpoint.');
+}
+
+/**
+ * Menu callback; OpenID Provider Endpoint.
+ *
+ * It accepts "associate" requests directly from the Relying Party, and
+ * "checkid_setup" requests made by the user's browser based on HTTP redirects
+ * (in OpenID 1) or HTML forms (in OpenID 2) generated by the Relying Party.
+ */
+function openid_test_endpoint() {
+ switch ($_REQUEST['openid_mode']) {
+ case 'associate':
+ _openid_test_endpoint_associate();
+ break;
+ case 'checkid_setup':
+ _openid_test_endpoint_authenticate();
+ break;
+ }
+}
+
+/**
+ * Menu callback; redirect during Normalization/Discovery.
+ */
+function openid_test_redirect($count = 0) {
+ if ($count == 0) {
+ $url = variable_get('openid_test_redirect_url', '');
+ }
+ else {
+ $url = url('openid-test/redirect/' . --$count, array('absolute' => TRUE));
+ }
+ $http_response_code = variable_get('openid_test_redirect_http_reponse_code', 301);
+ header('Location: ' . $url, TRUE, $http_response_code);
+ exit();
+}
+
+/**
+ * Menu callback; respond with appropriate callback.
+ */
+function openid_test_redirected_method($method1, $method2) {
+ return call_user_func('openid_test_' . $method1 . '_' . $method2);
+}
+
+/**
+ * OpenID endpoint; handle "associate" requests (see OpenID Authentication 2.0,
+ * section 8).
+ *
+ * The purpose of association is to send the secret MAC key to the Relying Party
+ * using Diffie-Hellman key exchange. The MAC key is used in subsequent
+ * "authenticate" requests. The "associate" request is made by the Relying Party
+ * (in the testing scenario, this is the OpenID module that communicates with
+ * the endpoint using drupal_http_request()).
+ */
+function _openid_test_endpoint_associate() {
+ module_load_include('inc', 'openid');
+
+ // Use default parameters for Diffie-Helmann key exchange.
+ $mod = OPENID_DH_DEFAULT_MOD;
+ $gen = OPENID_DH_DEFAULT_GEN;
+
+ // Generate private Diffie-Helmann key.
+ $r = _openid_dh_rand($mod);
+ $private = _openid_math_add($r, 1);
+
+ // Calculate public Diffie-Helmann key.
+ $public = _openid_math_powmod($gen, $private, $mod);
+
+ // Calculate shared secret based on Relying Party's public key.
+ $cpub = _openid_dh_base64_to_long($_REQUEST['openid_dh_consumer_public']);
+ $shared = _openid_math_powmod($cpub, $private, $mod);
+
+ // Encrypt the MAC key using the shared secret.
+ $enc_mac_key = base64_encode(_openid_dh_xorsecret($shared, base64_decode(variable_get('mac_key'))));
+
+ // Generate response including our public key and the MAC key. Using our
+ // public key and its own private key, the Relying Party can calculate the
+ // shared secret, and with this it can decrypt the encrypted MAC key.
+ $response = array(
+ 'ns' => 'http://specs.openid.net/auth/2.0',
+ 'assoc_handle' => 'openid-test',
+ 'session_type' => $_REQUEST['openid_session_type'],
+ 'assoc_type' => $_REQUEST['openid_assoc_type'],
+ 'expires_in' => '3600',
+ 'dh_server_public' => _openid_dh_long_to_base64($public),
+ 'enc_mac_key' => $enc_mac_key,
+ );
+
+ // Respond to Relying Party in the special Key-Value Form Encoding (see OpenID
+ // Authentication 1.0, section 4.1.1).
+ drupal_add_http_header('Content-Type', 'text/plain');
+ print _openid_create_message($response);
+}
+
+/**
+ * OpenID endpoint; handle "authenticate" requests.
+ *
+ * All requests result in a successful response. The request is a GET or POST
+ * made by the user's browser based on an HTML form or HTTP redirect generated
+ * by the Relying Party. The user is redirected back to the Relying Party using
+ * a URL containing a signed message in the query string confirming the user's
+ * identity.
+ */
+function _openid_test_endpoint_authenticate() {
+ module_load_include('inc', 'openid');
+
+ $expected_identity = variable_get('openid_test_identity');
+ if ($expected_identity && $_REQUEST['openid_identity'] != $expected_identity) {
+ $response = variable_get('openid_test_response', array()) + array(
+ 'openid.ns' => OPENID_NS_2_0,
+ 'openid.mode' => 'error',
+ 'openid.error' => 'Unexpted identity',
+ );
+ drupal_add_http_header('Content-Type', 'text/plain');
+ header('Location: ' . url($_REQUEST['openid_return_to'], array('query' => $response, 'external' => TRUE)));
+ return;
+ }
+
+ // Generate unique identifier for this authentication.
+ $nonce = _openid_nonce();
+
+ // Generate response containing the user's identity. The openid.sreg.xxx
+ // entries contain profile data stored by the OpenID Provider (see OpenID
+ // Simple Registration Extension 1.0).
+ $response = variable_get('openid_test_response', array()) + array(
+ 'openid.ns' => OPENID_NS_2_0,
+ 'openid.mode' => 'id_res',
+ 'openid.op_endpoint' => url('openid-test/endpoint', array('absolute' => TRUE)),
+ 'openid.claimed_id' => !empty($_REQUEST['openid_claimed_id']) ? $_REQUEST['openid_claimed_id'] : '',
+ 'openid.identity' => $_REQUEST['openid_identity'],
+ 'openid.return_to' => $_REQUEST['openid_return_to'],
+ 'openid.response_nonce' => $nonce,
+ 'openid.assoc_handle' => 'openid-test',
+ 'openid.signed' => 'op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle',
+ );
+
+ // Sign the message using the MAC key that was exchanged during association.
+ $association = new stdClass();
+ $association->mac_key = variable_get('mac_key');
+ $keys_to_sign = explode(',', $response['openid.signed']);
+ $response['openid.sig'] = _openid_signature($association, $response, $keys_to_sign);
+
+ // Put the signed message into the query string of a URL supplied by the
+ // Relying Party, and redirect the user.
+ drupal_add_http_header('Content-Type', 'text/plain');
+ header('Location: ' . url($_REQUEST['openid_return_to'], array('query' => $response, 'external' => TRUE)));
+}
diff --git a/core/modules/overlay/images/background.png b/core/modules/overlay/images/background.png
new file mode 100644
index 000000000000..9be21936e15f
--- /dev/null
+++ b/core/modules/overlay/images/background.png
Binary files differ
diff --git a/core/modules/overlay/images/close-rtl.png b/core/modules/overlay/images/close-rtl.png
new file mode 100644
index 000000000000..ae05d11140b8
--- /dev/null
+++ b/core/modules/overlay/images/close-rtl.png
Binary files differ
diff --git a/core/modules/overlay/images/close.png b/core/modules/overlay/images/close.png
new file mode 100644
index 000000000000..436985e226fd
--- /dev/null
+++ b/core/modules/overlay/images/close.png
Binary files differ
diff --git a/core/modules/overlay/overlay-child-rtl.css b/core/modules/overlay/overlay-child-rtl.css
new file mode 100644
index 000000000000..8d90fab2af53
--- /dev/null
+++ b/core/modules/overlay/overlay-child-rtl.css
@@ -0,0 +1,35 @@
+
+html {
+ direction: rtl;
+}
+
+#overlay-title {
+ float: right;
+ left: auto;
+}
+#overlay {
+ padding: 0.2em;
+ padding-left: 26px;
+}
+#overlay-close-wrapper {
+ left: 0;
+ right: auto;
+}
+#overlay-close,
+#overlay-close:hover {
+ background: transparent url(images/close-rtl.png) no-repeat;
+ -moz-border-radius-topright: 0;
+ -webkit-border-top-right-radius: 0;
+ border-top-right-radius: 0;
+}
+
+/**
+ * Tabs on the overlay.
+ */
+#overlay-tabs {
+ left: 20px;
+ right: auto;
+}
+#overlay-tabs li {
+ margin: 0 -3px 0 O;
+}
diff --git a/core/modules/overlay/overlay-child.css b/core/modules/overlay/overlay-child.css
new file mode 100644
index 000000000000..d047535415d7
--- /dev/null
+++ b/core/modules/overlay/overlay-child.css
@@ -0,0 +1,158 @@
+
+html.js {
+ background: transparent !important;
+ overflow-y: scroll;
+}
+html.js body {
+ background: transparent !important;
+ margin-left: 0;
+ margin-right: 0;
+ padding: 20px 0;
+}
+
+#overlay {
+ display: table;
+ margin: 0 auto;
+ min-height: 100px;
+ min-width: 700px;
+ position: relative;
+ padding: .2em;
+ padding-bottom: 2em;
+ padding-right: 26px; /* LTR */
+ width: 88%;
+}
+#overlay-titlebar {
+ padding: 0 20px;
+ position: relative;
+ white-space: nowrap;
+ z-index: 100;
+}
+#overlay-content {
+ background: #fff;
+ clear: both;
+ color: #000;
+ padding: .5em 1em;
+ position: relative;
+}
+
+#overlay-title-wrapper {
+ overflow: hidden;
+}
+#overlay-title {
+ color: #fff;
+ float: left; /* LTR */
+ font-size: 20px;
+ margin: 0;
+ padding: 0.3em 0;
+}
+#overlay-title:active,
+#overlay-title:focus {
+ outline: 0;
+}
+
+.overlay #skip-link {
+ margin-top: -20px;
+}
+.overlay #skip-link a {
+ color: #fff; /* This is white to contrast with the dark background behind it. */
+}
+
+#overlay-close-wrapper {
+ position: absolute;
+ right: 0; /* LTR */
+}
+#overlay-close,
+#overlay-close:hover {
+ background: transparent url(images/close.png) no-repeat; /* LTR */
+ -moz-border-radius-topleft: 0; /* LTR */
+ -webkit-border-top-left-radius: 0; /* LTR */
+ border-top-left-radius: 0; /* LTR */
+ display: block;
+ height: 26px;
+ margin: 0;
+ padding: 0;
+ /* Replace with position:fixed to get a scrolling close button. */
+ position: absolute;
+ width: 26px;
+}
+
+/**
+ * Tabs on the overlay.
+ */
+#overlay-tabs {
+ line-height: 27px;
+ margin: -28px 0 0 0;
+ position: absolute;
+ right: 20px; /* LTR */
+ text-transform: uppercase;
+}
+#overlay-tabs li {
+ display: inline;
+ list-style: none;
+ margin: 0 0 0 -3px; /* LTR */
+ padding: 0;
+}
+#overlay-tabs li a,
+#overlay-tabs li a:active,
+#overlay-tabs li a:visited,
+#overlay-tabs li a:hover {
+ background-color: #a6a7a2;
+ -moz-border-radius: 8px 8px 0 0;
+ -webkit-border-top-left-radius: 8px;
+ -webkit-border-top-right-radius: 8px;
+ border-radius: 8px 8px 0 0;
+ color: #000;
+ display: inline-block;
+ font-size: 11px;
+ font-weight: bold;
+ margin: 0 0 2px 0;
+ outline: 0;
+ padding: 0 14px;
+ text-decoration: none;
+}
+#overlay-tabs li.active a,
+#overlay-tabs li.active a.active,
+#overlay-tabs li.active a:active,
+#overlay-tabs li.active a:visited {
+ background-color: #fff;
+ margin: 0;
+ padding-bottom: 2px;
+}
+#overlay-tabs li a:focus,
+#overlay-tabs li a:hover {
+ color: #fff;
+}
+#overlay-tabs li.active a:focus,
+#overlay-tabs li.active a:hover {
+ color: #000;
+}
+
+/**
+ * Add to shortcuts link
+ */
+#overlay-titlebar .add-or-remove-shortcuts {
+ padding-top: 0.9em;
+}
+
+/**
+ * Disable message.
+ */
+#overlay-disable-message {
+ background-color: #fff;
+ margin: -20px auto 20px;
+ width: 80%;
+ -moz-border-radius: 0 0 8px 8px;
+ -webkit-border-bottom-left-radius: 8px;
+ -webkit-border-bottom-right-radius: 8px;
+ border-radius: 0 0 8px 8px;
+}
+.overlay-disable-message-focused {
+ padding: 0.5em;
+}
+.overlay-disable-message-focused a {
+ display: block;
+ float: left;
+}
+.overlay-disable-message-focused #overlay-dismiss-message {
+ float: right;
+}
diff --git a/core/modules/overlay/overlay-child.js b/core/modules/overlay/overlay-child.js
new file mode 100644
index 000000000000..e78e3831c918
--- /dev/null
+++ b/core/modules/overlay/overlay-child.js
@@ -0,0 +1,192 @@
+
+(function ($) {
+
+/**
+ * Attach the child dialog behavior to new content.
+ */
+Drupal.behaviors.overlayChild = {
+ attach: function (context, settings) {
+ // Make sure this behavior is not processed more than once.
+ if (this.processed) {
+ return;
+ }
+ this.processed = true;
+
+ // If we cannot reach the parent window, break out of the overlay.
+ if (!parent.Drupal || !parent.Drupal.overlay) {
+ window.location = window.location.href.replace(/([?&]?)render=overlay&?/g, '$1').replace(/\?$/, '');
+ }
+
+ var settings = settings.overlayChild || {};
+
+ // If the entire parent window should be refreshed when the overlay is
+ // closed, pass that information to the parent window.
+ if (settings.refreshPage) {
+ parent.Drupal.overlay.refreshPage = true;
+ }
+
+ // If a form has been submitted successfully, then the server side script
+ // may have decided to tell the parent window to close the popup dialog.
+ if (settings.closeOverlay) {
+ parent.Drupal.overlay.bindChild(window, true);
+ // Use setTimeout to close the child window from a separate thread,
+ // because the current one is busy processing Drupal behaviors.
+ setTimeout(function () {
+ if (typeof settings.redirect == 'string') {
+ parent.Drupal.overlay.redirect(settings.redirect);
+ }
+ else {
+ parent.Drupal.overlay.close();
+ }
+ }, 1);
+ return;
+ }
+
+ // If one of the regions displaying outside the overlay needs to be
+ // reloaded immediately, let the parent window know.
+ if (settings.refreshRegions) {
+ parent.Drupal.overlay.refreshRegions(settings.refreshRegions);
+ }
+
+ // Ok, now we can tell the parent window we're ready.
+ parent.Drupal.overlay.bindChild(window);
+
+ // IE8 crashes on certain pages if this isn't called; reason unknown.
+ window.scrollTo(window.scrollX, window.scrollY);
+
+ // Attach child related behaviors to the iframe document.
+ Drupal.overlayChild.attachBehaviors(context, settings);
+
+ // There are two links within the message that informs people about the
+ // overlay and how to disable it. Make sure both links are visible when
+ // either one has focus and add a class to the wrapper for styling purposes.
+ $('#overlay-disable-message', context)
+ .focusin(function () {
+ $(this).addClass('overlay-disable-message-focused');
+ $('a.element-focusable', this).removeClass('element-invisible');
+ })
+ .focusout(function () {
+ $(this).removeClass('overlay-disable-message-focused');
+ $('a.element-focusable', this).addClass('element-invisible');
+ });
+ }
+};
+
+/**
+ * Overlay object for child windows.
+ */
+Drupal.overlayChild = Drupal.overlayChild || {
+ behaviors: {}
+};
+
+Drupal.overlayChild.prototype = {};
+
+/**
+ * Attach child related behaviors to the iframe document.
+ */
+Drupal.overlayChild.attachBehaviors = function (context, settings) {
+ $.each(this.behaviors, function () {
+ this(context, settings);
+ });
+};
+
+/**
+ * Capture and handle clicks.
+ *
+ * Instead of binding a click event handler to every link we bind one to the
+ * document and handle events that bubble up. This also allows other scripts
+ * to bind their own handlers to links and also to prevent overlay's handling.
+ */
+Drupal.overlayChild.behaviors.addClickHandler = function (context, settings) {
+ $(document).bind('click.drupal-overlay mouseup.drupal-overlay', $.proxy(parent.Drupal.overlay, 'eventhandlerOverrideLink'));
+};
+
+/**
+ * Modify forms depending on their relation to the overlay.
+ *
+ * By default, forms are assumed to keep the flow in the overlay. Thus their
+ * action attribute get a ?render=overlay suffix.
+ */
+Drupal.overlayChild.behaviors.parseForms = function (context, settings) {
+ $('form', context).once('overlay', function () {
+ // Obtain the action attribute of the form.
+ var action = $(this).attr('action');
+ // Keep internal forms in the overlay.
+ if (action == undefined || (action.indexOf('http') != 0 && action.indexOf('https') != 0)) {
+ action += (action.indexOf('?') > -1 ? '&' : '?') + 'render=overlay';
+ $(this).attr('action', action);
+ }
+ // Submit external forms into a new window.
+ else {
+ $(this).attr('target', '_new');
+ }
+ });
+};
+
+/**
+ * Replace the overlay title with a message while loading another page.
+ */
+Drupal.overlayChild.behaviors.loading = function (context, settings) {
+ var $title;
+ var text = Drupal.t('Loading');
+ var dots = '';
+
+ $(document).bind('drupalOverlayBeforeLoad.drupal-overlay.drupal-overlay-child-loading', function () {
+ $title = $('#overlay-title').text(text);
+ var id = setInterval(function () {
+ dots = (dots.length > 10) ? '' : dots + '.';
+ $title.text(text + dots);
+ }, 500);
+ });
+};
+
+/**
+ * Switch active tab immediately.
+ */
+Drupal.overlayChild.behaviors.tabs = function (context, settings) {
+ var $tabsLinks = $('#overlay-tabs > li > a');
+
+ $('#overlay-tabs > li > a').bind('click.drupal-overlay', function () {
+ var active_tab = Drupal.t('(active tab)');
+ $tabsLinks.parent().siblings().removeClass('active').find('element-invisible:contains(' + active_tab + ')').appendTo(this);
+ $(this).parent().addClass('active');
+ });
+};
+
+/**
+ * If the shortcut add/delete button exists, move it to the overlay titlebar.
+ */
+Drupal.overlayChild.behaviors.shortcutAddLink = function (context, settings) {
+ // Remove any existing shortcut button markup from the titlebar.
+ $('#overlay-titlebar').find('.add-or-remove-shortcuts').remove();
+ // If the shortcut add/delete button exists, move it to the titlebar.
+ var $addToShortcuts = $('.add-or-remove-shortcuts');
+ if ($addToShortcuts.length) {
+ $addToShortcuts.insertAfter('#overlay-title');
+ }
+
+ $(document).bind('drupalOverlayBeforeLoad.drupal-overlay.drupal-overlay-child-loading', function () {
+ $('#overlay-titlebar').find('.add-or-remove-shortcuts').remove();
+ });
+};
+
+/**
+ * Use displacement from parent window.
+ */
+Drupal.overlayChild.behaviors.alterTableHeaderOffset = function (context, settings) {
+ if (Drupal.settings.tableHeaderOffset) {
+ Drupal.overlayChild.prevTableHeaderOffset = Drupal.settings.tableHeaderOffset;
+ }
+ Drupal.settings.tableHeaderOffset = 'Drupal.overlayChild.tableHeaderOffset';
+};
+
+/**
+ * Callback for Drupal.settings.tableHeaderOffset.
+ */
+Drupal.overlayChild.tableHeaderOffset = function () {
+ var topOffset = Drupal.overlayChild.prevTableHeaderOffset ? eval(Drupal.overlayChild.prevTableHeaderOffset + '()') : 0;
+
+ return topOffset + parseInt($(document.body).css('marginTop'));
+};
+
+})(jQuery);
diff --git a/core/modules/overlay/overlay-parent.css b/core/modules/overlay/overlay-parent.css
new file mode 100644
index 000000000000..dad6d5575b56
--- /dev/null
+++ b/core/modules/overlay/overlay-parent.css
@@ -0,0 +1,50 @@
+
+html.overlay-open,
+html.overlay-open body {
+ height: 100%;
+ overflow: hidden;
+}
+
+#overlay-container,
+.overlay-modal-background,
+.overlay-element {
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ z-index: 500;
+}
+
+.overlay-modal-background {
+ /* Using a transparent png renders faster than using opacity */
+ background: transparent url(images/background.png) repeat;
+}
+
+.overlay-element {
+ background: transparent;
+ left: -200%;
+ z-index: 501;
+}
+.overlay-element.overlay-active {
+ left: 0;
+}
+
+html.overlay-open .displace-top,
+html.overlay-open .displace-bottom {
+ z-index: 600;
+}
+
+/**
+ * Within the overlay parent, the message about disabling the overlay is for
+ * screen-reader users only. It is always kept invisible with the
+ * element-invisible class, and removed from the tab order. Overlay-child.css
+ * contains styling for the same message appearing within the overlay, and
+ * intended for sighted users.
+ */
+#overlay-disable-message {
+ display: none;
+}
+html.overlay-open #overlay-disable-message {
+ display: block;
+}
diff --git a/core/modules/overlay/overlay-parent.js b/core/modules/overlay/overlay-parent.js
new file mode 100644
index 000000000000..135320fe603f
--- /dev/null
+++ b/core/modules/overlay/overlay-parent.js
@@ -0,0 +1,991 @@
+
+(function ($) {
+
+/**
+ * Open the overlay, or load content into it, when an admin link is clicked.
+ */
+Drupal.behaviors.overlayParent = {
+ attach: function (context, settings) {
+ if (Drupal.overlay.isOpen) {
+ Drupal.overlay.makeDocumentUntabbable(context);
+ }
+
+ if (this.processed) {
+ return;
+ }
+ this.processed = true;
+
+ $(window)
+ // When the hash (URL fragment) changes, open the overlay if needed.
+ .bind('hashchange.drupal-overlay', $.proxy(Drupal.overlay, 'eventhandlerOperateByURLFragment'))
+ // Trigger the hashchange handler once, after the page is loaded, so that
+ // permalinks open the overlay.
+ .triggerHandler('hashchange.drupal-overlay');
+
+ $(document)
+ // Instead of binding a click event handler to every link we bind one to
+ // the document and only handle events that bubble up. This allows other
+ // scripts to bind their own handlers to links and also to prevent
+ // overlay's handling.
+ .bind('click.drupal-overlay mouseup.drupal-overlay', $.proxy(Drupal.overlay, 'eventhandlerOverrideLink'));
+ }
+};
+
+/**
+ * Overlay object for parent windows.
+ *
+ * Events
+ * Overlay triggers a number of events that can be used by other scripts.
+ * - drupalOverlayOpen: This event is triggered when the overlay is opened.
+ * - drupalOverlayBeforeClose: This event is triggered when the overlay attempts
+ * to close. If an event handler returns false, the close will be prevented.
+ * - drupalOverlayClose: This event is triggered when the overlay is closed.
+ * - drupalOverlayBeforeLoad: This event is triggered right before a new URL
+ * is loaded into the overlay.
+ * - drupalOverlayReady: This event is triggered when the DOM of the overlay
+ * child document is fully loaded.
+ * - drupalOverlayLoad: This event is triggered when the overlay is finished
+ * loading.
+ * - drupalOverlayResize: This event is triggered when the overlay is being
+ * resized to match the parent window.
+ */
+Drupal.overlay = Drupal.overlay || {
+ isOpen: false,
+ isOpening: false,
+ isClosing: false,
+ isLoading: false
+};
+
+Drupal.overlay.prototype = {};
+
+/**
+ * Open the overlay.
+ *
+ * @param url
+ * The URL of the page to open in the overlay.
+ *
+ * @return
+ * TRUE if the overlay was opened, FALSE otherwise.
+ */
+Drupal.overlay.open = function (url) {
+ // Just one overlay is allowed.
+ if (this.isOpen || this.isOpening) {
+ return this.load(url);
+ }
+ this.isOpening = true;
+ // Store the original document title.
+ this.originalTitle = document.title;
+
+ // Create the dialog and related DOM elements.
+ this.create();
+
+ this.isOpening = false;
+ this.isOpen = true;
+ $(document.documentElement).addClass('overlay-open');
+ this.makeDocumentUntabbable();
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayOpen');
+
+ return this.load(url);
+};
+
+/**
+ * Create the underlying markup and behaviors for the overlay.
+ */
+Drupal.overlay.create = function () {
+ this.$container = $(Drupal.theme('overlayContainer'))
+ .appendTo(document.body);
+
+ // Overlay uses transparent iframes that cover the full parent window.
+ // When the overlay is open the scrollbar of the parent window is hidden.
+ // Because some browsers show a white iframe background for a short moment
+ // while loading a page into an iframe, overlay uses two iframes. By loading
+ // the page in a hidden (inactive) iframe the user doesn't see the white
+ // background. When the page is loaded the active and inactive iframes
+ // are switched.
+ this.activeFrame = this.$iframeA = $(Drupal.theme('overlayElement'))
+ .appendTo(this.$container);
+
+ this.inactiveFrame = this.$iframeB = $(Drupal.theme('overlayElement'))
+ .appendTo(this.$container);
+
+ this.$iframeA.bind('load.drupal-overlay', { self: this.$iframeA[0], sibling: this.$iframeB }, $.proxy(this, 'loadChild'));
+ this.$iframeB.bind('load.drupal-overlay', { self: this.$iframeB[0], sibling: this.$iframeA }, $.proxy(this, 'loadChild'));
+
+ // Add a second class "drupal-overlay-open" to indicate these event handlers
+ // should only be bound when the overlay is open.
+ var eventClass = '.drupal-overlay.drupal-overlay-open';
+ $(window)
+ .bind('resize' + eventClass, $.proxy(this, 'eventhandlerOuterResize'));
+ $(document)
+ .bind('drupalOverlayLoad' + eventClass, $.proxy(this, 'eventhandlerOuterResize'))
+ .bind('drupalOverlayReady' + eventClass +
+ ' drupalOverlayClose' + eventClass, $.proxy(this, 'eventhandlerSyncURLFragment'))
+ .bind('drupalOverlayClose' + eventClass, $.proxy(this, 'eventhandlerRefreshPage'))
+ .bind('drupalOverlayBeforeClose' + eventClass +
+ ' drupalOverlayBeforeLoad' + eventClass +
+ ' drupalOverlayResize' + eventClass, $.proxy(this, 'eventhandlerDispatchEvent'));
+
+ if ($('.overlay-displace-top, .overlay-displace-bottom').length) {
+ $(document)
+ .bind('drupalOverlayResize' + eventClass, $.proxy(this, 'eventhandlerAlterDisplacedElements'))
+ .bind('drupalOverlayClose' + eventClass, $.proxy(this, 'eventhandlerRestoreDisplacedElements'));
+ }
+};
+
+/**
+ * Load the given URL into the overlay iframe.
+ *
+ * Use this method to change the URL being loaded in the overlay if it is
+ * already open.
+ *
+ * @return
+ * TRUE if URL is loaded into the overlay, FALSE otherwise.
+ */
+Drupal.overlay.load = function (url) {
+ if (!this.isOpen) {
+ return false;
+ }
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayBeforeLoad');
+
+ $(document.documentElement).addClass('overlay-loading');
+
+ // The contentDocument property is not supported in IE until IE8.
+ var iframeDocument = this.inactiveFrame[0].contentDocument || this.inactiveFrame[0].contentWindow.document;
+
+ // location.replace doesn't create a history entry. location.href does.
+ // In this case, we want location.replace, as we're creating the history
+ // entry using URL fragments.
+ iframeDocument.location.replace(url);
+
+ return true;
+};
+
+/**
+ * Close the overlay and remove markup related to it from the document.
+ *
+ * @return
+ * TRUE if the overlay was closed, FALSE otherwise.
+ */
+Drupal.overlay.close = function () {
+ // Prevent double execution when close is requested more than once.
+ if (!this.isOpen || this.isClosing) {
+ return false;
+ }
+
+ // Allow other scripts to respond to this event.
+ var event = $.Event('drupalOverlayBeforeClose');
+ $(document).trigger(event);
+ // If a handler returned false, the close will be prevented.
+ if (event.isDefaultPrevented()) {
+ return false;
+ }
+
+ this.isClosing = true;
+ this.isOpen = false;
+ $(document.documentElement).removeClass('overlay-open');
+ // Restore the original document title.
+ document.title = this.originalTitle;
+ this.makeDocumentTabbable();
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayClose');
+
+ // When the iframe is still loading don't destroy it immediately but after
+ // the content is loaded (see Drupal.overlay.loadChild).
+ if (!this.isLoading) {
+ this.destroy();
+ this.isClosing = false;
+ }
+ return true;
+};
+
+/**
+ * Destroy the overlay.
+ */
+Drupal.overlay.destroy = function () {
+ $([document, window]).unbind('.drupal-overlay-open');
+ this.$container.remove();
+
+ this.$container = null;
+ this.$iframeA = null;
+ this.$iframeB = null;
+
+ this.iframeWindow = null;
+};
+
+/**
+ * Redirect the overlay parent window to the given URL.
+ *
+ * @param url
+ * Can be an absolute URL or a relative link to the domain root.
+ */
+Drupal.overlay.redirect = function (url) {
+ // Create a native Link object, so we can use its object methods.
+ var link = $(url.link(url)).get(0);
+
+ // If the link is already open, force the hashchange event to simulate reload.
+ if (window.location.href == link.href) {
+ $(window).triggerHandler('hashchange.drupal-overlay');
+ }
+
+ window.location.href = link.href;
+ return true;
+};
+
+/**
+ * Bind the child window.
+ *
+ * Note that this function is fired earlier than Drupal.overlay.loadChild.
+ */
+Drupal.overlay.bindChild = function (iframeWindow, isClosing) {
+ this.iframeWindow = iframeWindow;
+
+ // We are done if the child window is closing.
+ if (isClosing || this.isClosing || !this.isOpen) {
+ return;
+ }
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayReady');
+};
+
+/**
+ * Event handler: load event handler for the overlay iframe.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: load
+ * - event.currentTarget: iframe
+ */
+Drupal.overlay.loadChild = function (event) {
+ var iframe = event.data.self;
+ var iframeDocument = iframe.contentDocument || iframe.contentWindow.document;
+ var iframeWindow = iframeDocument.defaultView || iframeDocument.parentWindow;
+ if (iframeWindow.location == 'about:blank') {
+ return;
+ }
+
+ this.isLoading = false;
+ $(document.documentElement).removeClass('overlay-loading');
+ event.data.sibling.removeClass('overlay-active').attr({ 'tabindex': -1 });
+
+ // Only continue when overlay is still open and not closing.
+ if (this.isOpen && !this.isClosing) {
+ // And child document is an actual overlayChild.
+ if (iframeWindow.Drupal && iframeWindow.Drupal.overlayChild) {
+ // Replace the document title with title of iframe.
+ document.title = iframeWindow.document.title;
+
+ this.activeFrame = $(iframe)
+ .addClass('overlay-active')
+ // Add a title attribute to the iframe for accessibility.
+ .attr('title', Drupal.t('@title dialog', { '@title': iframeWindow.jQuery('#overlay-title').text() })).removeAttr('tabindex');
+ this.inactiveFrame = event.data.sibling;
+
+ // Load an empty document into the inactive iframe.
+ (this.inactiveFrame[0].contentDocument || this.inactiveFrame[0].contentWindow.document).location.replace('about:blank');
+
+ // Move the focus to just before the "skip to main content" link inside
+ // the overlay.
+ this.activeFrame.focus();
+ var skipLink = iframeWindow.jQuery('a:first');
+ Drupal.overlay.setFocusBefore(skipLink, iframeWindow.document);
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayLoad');
+ }
+ else {
+ window.location = iframeWindow.location.href.replace(/([?&]?)render=overlay&?/g, '$1').replace(/\?$/, '');
+ }
+ }
+ else {
+ this.destroy();
+ }
+};
+
+/**
+ * Creates a placeholder element to receive document focus.
+ *
+ * Setting the document focus to a link will make it visible, even if it's a
+ * "skip to main content" link that should normally be visible only when the
+ * user tabs to it. This function can be used to set the document focus to
+ * just before such an invisible link.
+ *
+ * @param $element
+ * The jQuery element that should receive focus on the next tab press.
+ * @param document
+ * The iframe window element to which the placeholder should be added. The
+ * placeholder element has to be created inside the same iframe as the element
+ * it precedes, to keep IE happy. (http://bugs.jquery.com/ticket/4059)
+ */
+Drupal.overlay.setFocusBefore = function ($element, document) {
+ // Create an anchor inside the placeholder document.
+ var placeholder = document.createElement('a');
+ var $placeholder = $(placeholder).addClass('element-invisible').attr('href', '#');
+ // Put the placeholder where it belongs, and set the document focus to it.
+ $placeholder.insertBefore($element);
+ $placeholder.focus();
+ // Make the placeholder disappear as soon as it loses focus, so that it
+ // doesn't appear in the tab order again.
+ $placeholder.one('blur', function () {
+ $(this).remove();
+ });
+}
+
+/**
+ * Check if the given link is in the administrative section of the site.
+ *
+ * @param url
+ * The url to be tested.
+ *
+ * @return boolean
+ * TRUE if the URL represents an administrative link, FALSE otherwise.
+ */
+Drupal.overlay.isAdminLink = function (url) {
+ if (Drupal.overlay.isExternalLink(url)) {
+ return false;
+ }
+
+ var path = this.getPath(url);
+
+ // Turn the list of administrative paths into a regular expression.
+ if (!this.adminPathRegExp) {
+ var regExpPrefix = '^' + Drupal.settings.pathPrefix + '(';
+ var adminPaths = regExpPrefix + Drupal.settings.overlay.paths.admin.replace(/\s+/g, ')$|' + regExpPrefix) + ')$';
+ var nonAdminPaths = regExpPrefix + Drupal.settings.overlay.paths.non_admin.replace(/\s+/g, ')$|'+ regExpPrefix) + ')$';
+ adminPaths = adminPaths.replace(/\*/g, '.*');
+ nonAdminPaths = nonAdminPaths.replace(/\*/g, '.*');
+ this.adminPathRegExp = new RegExp(adminPaths);
+ this.nonAdminPathRegExp = new RegExp(nonAdminPaths);
+ }
+
+ return this.adminPathRegExp.exec(path) && !this.nonAdminPathRegExp.exec(path);
+};
+
+/**
+ * Determine whether a link is external to the site.
+ *
+ * @param url
+ * The url to be tested.
+ *
+ * @return boolean
+ * TRUE if the URL is external to the site, FALSE otherwise.
+ */
+Drupal.overlay.isExternalLink = function (url) {
+ var re = RegExp('^((f|ht)tps?:)?//(?!' + window.location.host + ')');
+ return re.test(url);
+};
+
+/**
+ * Event handler: resizes overlay according to the size of the parent window.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: any
+ * - event.currentTarget: any
+ */
+Drupal.overlay.eventhandlerOuterResize = function (event) {
+ // Proceed only if the overlay still exists.
+ if (!(this.isOpen || this.isOpening) || this.isClosing || !this.iframeWindow) {
+ return;
+ }
+
+ // IE6 uses position:absolute instead of position:fixed.
+ if (typeof document.body.style.maxHeight != 'string') {
+ this.activeFrame.height($(window).height());
+ }
+
+ // Allow other scripts to respond to this event.
+ $(document).trigger('drupalOverlayResize');
+};
+
+/**
+ * Event handler: resizes displaced elements so they won't overlap the scrollbar
+ * of overlay's iframe.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: any
+ * - event.currentTarget: any
+ */
+Drupal.overlay.eventhandlerAlterDisplacedElements = function (event) {
+ // Proceed only if the overlay still exists.
+ if (!(this.isOpen || this.isOpening) || this.isClosing || !this.iframeWindow) {
+ return;
+ }
+
+ $(this.iframeWindow.document.body).css({
+ marginTop: Drupal.overlay.getDisplacement('top'),
+ marginBottom: Drupal.overlay.getDisplacement('bottom')
+ })
+ // IE7 isn't reflowing the document immediately.
+ // @todo This might be fixed in a cleaner way.
+ .addClass('overlay-trigger-reflow').removeClass('overlay-trigger-reflow');
+
+ var documentHeight = this.iframeWindow.document.body.clientHeight;
+ var documentWidth = this.iframeWindow.document.body.clientWidth;
+ // IE6 doesn't support maxWidth, use width instead.
+ var maxWidthName = 'maxWidth';
+
+ if (Drupal.overlay.leftSidedScrollbarOffset === undefined && $(document.documentElement).attr('dir') === 'rtl') {
+ // We can't use element.clientLeft to detect whether scrollbars are placed
+ // on the left side of the element when direction is set to "rtl" as most
+ // browsers dont't support it correctly.
+ // http://www.gtalbot.org/BugzillaSection/DocumentAllDHTMLproperties.html
+ // There seems to be absolutely no way to detect whether the scrollbar
+ // is on the left side in Opera; always expect scrollbar to be on the left.
+ if ($.browser.opera) {
+ Drupal.overlay.leftSidedScrollbarOffset = document.documentElement.clientWidth - this.iframeWindow.document.documentElement.clientWidth + this.iframeWindow.document.documentElement.clientLeft;
+ }
+ else if (this.iframeWindow.document.documentElement.clientLeft) {
+ Drupal.overlay.leftSidedScrollbarOffset = this.iframeWindow.document.documentElement.clientLeft;
+ }
+ else {
+ var el1 = $('<div style="direction: rtl; overflow: scroll;"></div>').appendTo(document.body);
+ var el2 = $('<div></div>').appendTo(el1);
+ Drupal.overlay.leftSidedScrollbarOffset = parseInt(el2[0].offsetLeft - el1[0].offsetLeft);
+ el1.remove();
+ }
+ }
+
+ // Consider any element that should be visible above the overlay (such as
+ // a toolbar).
+ $('.overlay-displace-top, .overlay-displace-bottom').each(function () {
+ var data = $(this).data();
+ var maxWidth = documentWidth;
+ // In IE, Shadow filter makes element to overlap the scrollbar with 1px.
+ if (this.filters && this.filters.length && this.filters.item('DXImageTransform.Microsoft.Shadow')) {
+ maxWidth -= 1;
+ }
+
+ if (Drupal.overlay.leftSidedScrollbarOffset) {
+ $(this).css('left', Drupal.overlay.leftSidedScrollbarOffset);
+ }
+
+ // Prevent displaced elements overlapping window's scrollbar.
+ var currentMaxWidth = parseInt($(this).css(maxWidthName));
+ if ((data.drupalOverlay && data.drupalOverlay.maxWidth) || isNaN(currentMaxWidth) || currentMaxWidth > maxWidth || currentMaxWidth <= 0) {
+ $(this).css(maxWidthName, maxWidth);
+ (data.drupalOverlay = data.drupalOverlay || {}).maxWidth = true;
+ }
+
+ // Use a more rigorous approach if the displaced element still overlaps
+ // window's scrollbar; clip the element on the right.
+ var offset = $(this).offset();
+ var offsetRight = offset.left + $(this).outerWidth();
+ if ((data.drupalOverlay && data.drupalOverlay.clip) || offsetRight > maxWidth) {
+ if (Drupal.overlay.leftSidedScrollbarOffset) {
+ $(this).css('clip', 'rect(auto, auto, ' + (documentHeight - offset.top) + 'px, ' + (Drupal.overlay.leftSidedScrollbarOffset + 2) + 'px)');
+ }
+ else {
+ $(this).css('clip', 'rect(auto, ' + (maxWidth - offset.left) + 'px, ' + (documentHeight - offset.top) + 'px, auto)');
+ }
+ (data.drupalOverlay = data.drupalOverlay || {}).clip = true;
+ }
+ });
+};
+
+/**
+ * Event handler: restores size of displaced elements as they were before
+ * overlay was opened.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: any
+ * - event.currentTarget: any
+ */
+Drupal.overlay.eventhandlerRestoreDisplacedElements = function (event) {
+ var $displacedElements = $('.overlay-displace-top, .overlay-displace-bottom');
+ try {
+ $displacedElements.css({ maxWidth: '', clip: '' });
+ }
+ // IE bug that doesn't allow unsetting style.clip (http://dev.jquery.com/ticket/6512).
+ catch (err) {
+ $displacedElements.attr('style', function (index, attr) {
+ return attr.replace(/clip\s*:\s*rect\([^)]+\);?/i, '');
+ });
+ }
+};
+
+/**
+ * Event handler: overrides href of administrative links to be opened in
+ * the overlay.
+ *
+ * This click event handler should be bound to any document (for example the
+ * overlay iframe) of which you want links to open in the overlay.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: click, mouseup
+ * - event.currentTarget: document
+ *
+ * @see Drupal.overlayChild.behaviors.addClickHandler
+ */
+Drupal.overlay.eventhandlerOverrideLink = function (event) {
+ // In some browsers the click event isn't fired for right-clicks. Use the
+ // mouseup event for right-clicks and the click event for everything else.
+ if ((event.type == 'click' && event.button == 2) || (event.type == 'mouseup' && event.button != 2)) {
+ return;
+ }
+
+ var $target = $(event.target);
+
+ // Only continue if clicked target (or one of its parents) is a link.
+ if (!$target.is('a')) {
+ $target = $target.closest('a');
+ if (!$target.length) {
+ return;
+ }
+ }
+
+ // Never open links in the overlay that contain the overlay-exclude class.
+ if ($target.hasClass('overlay-exclude')) {
+ return;
+ }
+
+ // Close the overlay when the link contains the overlay-close class.
+ if ($target.hasClass('overlay-close')) {
+ // Clearing the overlay URL fragment will close the overlay.
+ $.bbq.removeState('overlay');
+ return;
+ }
+
+ var target = $target[0];
+ var href = target.href;
+ // Only handle links that have an href attribute and use the http(s) protocol.
+ if (href != undefined && href != '' && target.protocol.match(/^https?\:/)) {
+ var anchor = href.replace(target.ownerDocument.location.href, '');
+ // Skip anchor links.
+ if (anchor.length == 0 || anchor.charAt(0) == '#') {
+ return;
+ }
+ // Open admin links in the overlay.
+ else if (this.isAdminLink(href)) {
+ // If the link contains the overlay-restore class and the overlay-context
+ // state is set, also update the parent window's location.
+ var parentLocation = ($target.hasClass('overlay-restore') && typeof $.bbq.getState('overlay-context') == 'string')
+ ? Drupal.settings.basePath + $.bbq.getState('overlay-context')
+ : null;
+ href = this.fragmentizeLink($target.get(0), parentLocation);
+ // Only override default behavior when left-clicking and user is not
+ // pressing the ALT, CTRL, META (Command key on the Macintosh keyboard)
+ // or SHIFT key.
+ if (event.button == 0 && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
+ // Redirect to a fragmentized href. This will trigger a hashchange event.
+ this.redirect(href);
+ // Prevent default action and further propagation of the event.
+ return false;
+ }
+ // Otherwise alter clicked link's href. This is being picked up by
+ // the default action handler.
+ else {
+ $target
+ // Restore link's href attribute on blur or next click.
+ .one('blur mousedown', { target: target, href: target.href }, function (event) { $(event.data.target).attr('href', event.data.href); })
+ .attr('href', href);
+ }
+ }
+ // Non-admin links should close the overlay and open in the main window,
+ // which is the default action for a link. We only need to handle them
+ // if the overlay is open and the clicked link is inside the overlay iframe.
+ else if (this.isOpen && target.ownerDocument === this.iframeWindow.document) {
+ // Open external links in the immediate parent of the frame, unless the
+ // link already has a different target.
+ if (target.hostname != window.location.hostname) {
+ if (!$target.attr('target')) {
+ $target.attr('target', '_parent');
+ }
+ }
+ else {
+ // Add the overlay-context state to the link, so "overlay-restore" links
+ // can restore the context.
+ $target.attr('href', $.param.fragment(href, { 'overlay-context': this.getPath(window.location) + window.location.search }));
+
+ // When the link has a destination query parameter and that destination
+ // is an admin link we need to fragmentize it. This will make it reopen
+ // in the overlay.
+ var params = $.deparam.querystring(href);
+ if (params.destination && this.isAdminLink(params.destination)) {
+ var fragmentizedDestination = $.param.fragment(this.getPath(window.location), { overlay: params.destination });
+ $target.attr('href', $.param.querystring(href, { destination: fragmentizedDestination }));
+ }
+
+ // Make the link open in the immediate parent of the frame.
+ $target.attr('target', '_parent');
+ }
+ }
+ }
+};
+
+/**
+ * Event handler: opens or closes the overlay based on the current URL fragment.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: hashchange
+ * - event.currentTarget: document
+ */
+Drupal.overlay.eventhandlerOperateByURLFragment = function (event) {
+ // If we changed the hash to reflect an internal redirect in the overlay,
+ // its location has already been changed, so don't do anything.
+ if ($.data(window.location, window.location.href) === 'redirect') {
+ $.data(window.location, window.location.href, null);
+ return;
+ }
+
+ // Get the overlay URL from the current URL fragment.
+ var state = $.bbq.getState('overlay');
+ if (state) {
+ // Append render variable, so the server side can choose the right
+ // rendering and add child frame code to the page if needed.
+ var url = $.param.querystring(Drupal.settings.basePath + state, { render: 'overlay' });
+
+ this.open(url);
+ this.resetActiveClass(this.getPath(Drupal.settings.basePath + state));
+ }
+ // If there is no overlay URL in the fragment and the overlay is (still)
+ // open, close the overlay.
+ else if (this.isOpen && !this.isClosing) {
+ this.close();
+ this.resetActiveClass(this.getPath(window.location));
+ }
+};
+
+/**
+ * Event handler: makes sure the internal overlay URL is reflected in the parent
+ * URL fragment.
+ *
+ * Normally the parent URL fragment determines the overlay location. However, if
+ * the overlay redirects internally, the parent doesn't get informed, and the
+ * parent URL fragment will be out of date. This is a sanity check to make
+ * sure we're in the right place.
+ *
+ * The parent URL fragment is also not updated automatically when overlay's
+ * open, close or load functions are used directly (instead of through
+ * eventhandlerOperateByURLFragment).
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: drupalOverlayReady, drupalOverlayClose
+ * - event.currentTarget: document
+ */
+Drupal.overlay.eventhandlerSyncURLFragment = function (event) {
+ if (this.isOpen) {
+ var expected = $.bbq.getState('overlay');
+ // This is just a sanity check, so we're comparing paths, not query strings.
+ if (this.getPath(Drupal.settings.basePath + expected) != this.getPath(this.iframeWindow.document.location)) {
+ // There may have been a redirect inside the child overlay window that the
+ // parent wasn't aware of. Update the parent URL fragment appropriately.
+ var newLocation = Drupal.overlay.fragmentizeLink(this.iframeWindow.document.location);
+ // Set a 'redirect' flag on the new location so the hashchange event handler
+ // knows not to change the overlay's content.
+ $.data(window.location, newLocation, 'redirect');
+ // Use location.replace() so we don't create an extra history entry.
+ window.location.replace(newLocation);
+ }
+ }
+ else {
+ $.bbq.removeState('overlay');
+ }
+};
+
+/**
+ * Event handler: if the child window suggested that the parent refresh on
+ * close, force a page refresh.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: drupalOverlayClose
+ * - event.currentTarget: document
+ */
+Drupal.overlay.eventhandlerRefreshPage = function (event) {
+ if (Drupal.overlay.refreshPage) {
+ window.location.reload(true);
+ }
+};
+
+/**
+ * Event handler: dispatches events to the overlay document.
+ *
+ * @param event
+ * Event being triggered, with the following restrictions:
+ * - event.type: any
+ * - event.currentTarget: any
+ */
+Drupal.overlay.eventhandlerDispatchEvent = function (event) {
+ if (this.iframeWindow && this.iframeWindow.document) {
+ this.iframeWindow.jQuery(this.iframeWindow.document).trigger(event);
+ }
+};
+
+/**
+ * Make a regular admin link into a URL that will trigger the overlay to open.
+ *
+ * @param link
+ * A JavaScript Link object (i.e. an <a> element).
+ * @param parentLocation
+ * (optional) URL to override the parent window's location with.
+ *
+ * @return
+ * A URL that will trigger the overlay (in the form
+ * /node/1#overlay=admin/config).
+ */
+Drupal.overlay.fragmentizeLink = function (link, parentLocation) {
+ // Don't operate on links that are already overlay-ready.
+ var params = $.deparam.fragment(link.href);
+ if (params.overlay) {
+ return link.href;
+ }
+
+ // Determine the link's original destination. Set ignorePathFromQueryString to
+ // true to prevent transforming this link into a clean URL while clean URLs
+ // may be disabled.
+ var path = this.getPath(link, true);
+ // Preserve existing query and fragment parameters in the URL, except for
+ // "render=overlay" which is re-added in Drupal.overlay.eventhandlerOperateByURLFragment.
+ var destination = path + link.search.replace(/&?render=overlay/, '').replace(/\?$/, '') + link.hash;
+
+ // Assemble and return the overlay-ready link.
+ return $.param.fragment(parentLocation || window.location.href, { overlay: destination });
+};
+
+/**
+ * Refresh any regions of the page that are displayed outside the overlay.
+ *
+ * @param data
+ * An array of objects with information on the page regions to be refreshed.
+ * For each object, the key is a CSS class identifying the region to be
+ * refreshed, and the value represents the section of the Drupal $page array
+ * corresponding to this region.
+ */
+Drupal.overlay.refreshRegions = function (data) {
+ $.each(data, function () {
+ var region_info = this;
+ $.each(region_info, function (regionClass) {
+ var regionName = region_info[regionClass];
+ var regionSelector = '.' + regionClass;
+ // Allow special behaviors to detach.
+ Drupal.detachBehaviors($(regionSelector));
+ $.get(Drupal.settings.basePath + Drupal.settings.overlay.ajaxCallback + '/' + regionName, function (newElement) {
+ $(regionSelector).replaceWith($(newElement));
+ Drupal.attachBehaviors($(regionSelector), Drupal.settings);
+ });
+ });
+ });
+};
+
+/**
+ * Reset the active class on links in displaced elements according to
+ * given path.
+ *
+ * @param activePath
+ * Path to match links against.
+ */
+Drupal.overlay.resetActiveClass = function(activePath) {
+ var self = this;
+ var windowDomain = window.location.protocol + window.location.hostname;
+
+ $('.overlay-displace-top, .overlay-displace-bottom')
+ .find('a[href]')
+ // Remove active class from all links in displaced elements.
+ .removeClass('active')
+ // Add active class to links that match activePath.
+ .each(function () {
+ var linkDomain = this.protocol + this.hostname;
+ var linkPath = self.getPath(this);
+
+ // A link matches if it is part of the active trail of activePath, except
+ // for frontpage links.
+ if (linkDomain == windowDomain && (activePath + '/').indexOf(linkPath + '/') === 0 && (linkPath !== '' || activePath === '')) {
+ $(this).addClass('active');
+ }
+ });
+};
+
+/**
+ * Helper function to get the (corrected) Drupal path of a link.
+ *
+ * @param link
+ * Link object or string to get the Drupal path from.
+ * @param ignorePathFromQueryString
+ * Boolean whether to ignore path from query string if path appears empty.
+ *
+ * @return
+ * The Drupal path.
+ */
+Drupal.overlay.getPath = function (link, ignorePathFromQueryString) {
+ if (typeof link == 'string') {
+ // Create a native Link object, so we can use its object methods.
+ link = $(link.link(link)).get(0);
+ }
+
+ var path = link.pathname;
+ // Ensure a leading slash on the path, omitted in some browsers.
+ if (path.charAt(0) != '/') {
+ path = '/' + path;
+ }
+ path = path.replace(new RegExp(Drupal.settings.basePath + '(?:index.php)?'), '');
+ if (path == '' && !ignorePathFromQueryString) {
+ // If the path appears empty, it might mean the path is represented in the
+ // query string (clean URLs are not used).
+ var match = new RegExp('([?&])q=(.+)([&#]|$)').exec(link.search);
+ if (match && match.length == 4) {
+ path = match[2];
+ }
+ }
+
+ return path;
+};
+
+/**
+ * Get the total displacement of given region.
+ *
+ * @param region
+ * Region name. Either "top" or "bottom".
+ *
+ * @return
+ * The total displacement of given region in pixels.
+ */
+Drupal.overlay.getDisplacement = function (region) {
+ var displacement = 0;
+ var lastDisplaced = $('.overlay-displace-' + region + ':last');
+ if (lastDisplaced.length) {
+ displacement = lastDisplaced.offset().top + lastDisplaced.outerHeight();
+
+ // Remove height added by IE Shadow filter.
+ if (lastDisplaced[0].filters && lastDisplaced[0].filters.length && lastDisplaced[0].filters.item('DXImageTransform.Microsoft.Shadow')) {
+ displacement -= lastDisplaced[0].filters.item('DXImageTransform.Microsoft.Shadow').strength;
+ displacement = Math.max(0, displacement);
+ }
+ }
+ return displacement;
+};
+
+/**
+ * Makes elements outside the overlay unreachable via the tab key.
+ *
+ * @param context
+ * The part of the DOM that should have its tabindexes changed. Defaults to
+ * the entire page.
+ */
+Drupal.overlay.makeDocumentUntabbable = function (context) {
+ // Manipulating tabindexes for the entire document is unacceptably slow in IE6
+ // and IE7, so in those browsers, the underlying page will still be reachable
+ // via the tab key. However, we still make the links within the Disable
+ // message unreachable, because the same message also exists within the
+ // child document. The duplicate copy in the underlying document is only for
+ // assisting screen-reader users navigating the document with reading commands
+ // that follow markup order rather than tab order.
+ if (jQuery.browser.msie && parseInt(jQuery.browser.version, 10) < 8) {
+ $('#overlay-disable-message a', context).attr('tabindex', -1);
+ return;
+ }
+
+ context = context || document.body;
+ var $overlay, $tabbable, $hasTabindex;
+
+ // Determine which elements on the page already have a tabindex.
+ $hasTabindex = $('[tabindex] :not(.overlay-element)', context);
+ // Record the tabindex for each element, so we can restore it later.
+ $hasTabindex.each(Drupal.overlay._recordTabindex);
+ // Add the tabbable elements from the current context to any that we might
+ // have previously recorded.
+ Drupal.overlay._hasTabindex = $hasTabindex.add(Drupal.overlay._hasTabindex);
+
+ // Set tabindex to -1 on everything outside the overlay and toolbars, so that
+ // the underlying page is unreachable.
+
+ // By default, browsers make a, area, button, input, object, select, textarea,
+ // and iframe elements reachable via the tab key.
+ $tabbable = $('a, area, button, input, object, select, textarea, iframe');
+ // If another element (like a div) has a tabindex, it's also tabbable.
+ $tabbable = $tabbable.add($hasTabindex);
+ // Leave links inside the overlay and toolbars alone.
+ $overlay = $('.overlay-element, #overlay-container, .overlay-displace-top, .overlay-displace-bottom').find('*');
+ $tabbable = $tabbable.not($overlay);
+ // We now have a list of everything in the underlying document that could
+ // possibly be reachable via the tab key. Make it all unreachable.
+ $tabbable.attr('tabindex', -1);
+};
+
+/**
+ * Restores the original tabindex value of a group of elements.
+ *
+ * @param context
+ * The part of the DOM that should have its tabindexes restored. Defaults to
+ * the entire page.
+ */
+Drupal.overlay.makeDocumentTabbable = function (context) {
+ // Manipulating tabindexes is unacceptably slow in IE6 and IE7. In those
+ // browsers, the underlying page was never made unreachable via tab, so
+ // there is no work to be done here.
+ if (jQuery.browser.msie && parseInt(jQuery.browser.version, 10) < 8) {
+ return;
+ }
+
+ var $needsTabindex;
+ context = context || document.body;
+
+ // Make the underlying document tabbable again by removing all existing
+ // tabindex attributes.
+ var $tabindex = $('[tabindex]', context);
+ if (jQuery.browser.msie && parseInt(jQuery.browser.version, 10) < 8) {
+ // removeAttr('tabindex') is broken in IE6-7, but the DOM function
+ // removeAttribute works.
+ var i;
+ var length = $tabindex.length;
+ for (i = 0; i < length; i++) {
+ $tabindex[i].removeAttribute('tabIndex');
+ }
+ }
+ else {
+ $tabindex.removeAttr('tabindex');
+ }
+
+ // Restore the tabindex attributes that existed before the overlay was opened.
+ $needsTabindex = $(Drupal.overlay._hasTabindex, context);
+ $needsTabindex.each(Drupal.overlay._restoreTabindex);
+ Drupal.overlay._hasTabindex = Drupal.overlay._hasTabindex.not($needsTabindex);
+};
+
+/**
+ * Record the tabindex for an element, using $.data.
+ *
+ * Meant to be used as a jQuery.fn.each callback.
+ */
+Drupal.overlay._recordTabindex = function () {
+ var $element = $(this);
+ var tabindex = $(this).attr('tabindex');
+ $element.data('drupalOverlayOriginalTabIndex', tabindex);
+}
+
+/**
+ * Restore an element's original tabindex.
+ *
+ * Meant to be used as a jQuery.fn.each callback.
+ */
+Drupal.overlay._restoreTabindex = function () {
+ var $element = $(this);
+ var tabindex = $element.data('drupalOverlayOriginalTabIndex');
+ $element.attr('tabindex', tabindex);
+};
+
+/**
+ * Theme function to create the overlay iframe element.
+ */
+Drupal.theme.prototype.overlayContainer = function () {
+ return '<div id="overlay-container"><div class="overlay-modal-background"></div></div>';
+};
+
+/**
+ * Theme function to create an overlay iframe element.
+ */
+Drupal.theme.prototype.overlayElement = function (url) {
+ return '<iframe class="overlay-element" frameborder="0" scrolling="auto" allowtransparency="true"></iframe>';
+};
+
+})(jQuery);
diff --git a/core/modules/overlay/overlay.api.php b/core/modules/overlay/overlay.api.php
new file mode 100644
index 000000000000..bc23546df7ed
--- /dev/null
+++ b/core/modules/overlay/overlay.api.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by Overlay module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Allow modules to act when an overlay parent window is initialized.
+ *
+ * The parent window is initialized when a page is displayed in which the
+ * overlay might be required to be displayed, so modules can act here if they
+ * need to take action to accommodate the possibility of the overlay appearing
+ * within a Drupal page.
+ */
+function hook_overlay_parent_initialize() {
+ // Add our custom JavaScript.
+ drupal_add_js(drupal_get_path('module', 'hook') . '/hook-overlay.js');
+}
+
+/**
+ * Allow modules to act when an overlay child window is initialized.
+ *
+ * The child window is initialized when a page is displayed from within the
+ * overlay, so modules can act here if they need to take action to work from
+ * within the confines of the overlay.
+ */
+function hook_overlay_child_initialize() {
+ // Add our custom JavaScript.
+ drupal_add_js(drupal_get_path('module', 'hook') . '/hook-overlay-child.js');
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/overlay/overlay.info b/core/modules/overlay/overlay.info
new file mode 100644
index 000000000000..a78279206b07
--- /dev/null
+++ b/core/modules/overlay/overlay.info
@@ -0,0 +1,5 @@
+name = Overlay
+description = Displays the Drupal administration interface in an overlay.
+package = Core
+version = VERSION
+core = 8.x
diff --git a/core/modules/overlay/overlay.install b/core/modules/overlay/overlay.install
new file mode 100644
index 000000000000..2fa7c84bc658
--- /dev/null
+++ b/core/modules/overlay/overlay.install
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the overlay module.
+ */
+
+/**
+ * Implements hook_enable().
+ *
+ * If the module is being enabled through the admin UI, and not from an
+ * install profile, reopen the modules page in an overlay.
+ */
+function overlay_enable() {
+ if (strpos(current_path(), 'admin/modules') === 0) {
+ // Flag for a redirect to <front>#overlay=admin/modules on hook_init().
+ $_SESSION['overlay_enable_redirect'] = 1;
+ }
+}
diff --git a/core/modules/overlay/overlay.module b/core/modules/overlay/overlay.module
new file mode 100644
index 000000000000..6e137b748738
--- /dev/null
+++ b/core/modules/overlay/overlay.module
@@ -0,0 +1,975 @@
+<?php
+
+/**
+ * @file
+ * Displays the Drupal administration interface in an overlay.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function overlay_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#overlay':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Overlay module makes the administration pages on your site display in a JavaScript overlay of the page you were viewing when you clicked the administrative link, instead of replacing the page in your browser window. Use the close link on the overlay to return to the page you were viewing when you clicked the link. For more information, see the online handbook entry for <a href="@overlay">Overlay module</a>.', array('@overlay' => 'http://drupal.org/handbook/modules/overlay')) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function overlay_menu() {
+ $items['overlay-ajax/%'] = array(
+ 'title' => '',
+ 'page callback' => 'overlay_ajax_render_region',
+ 'page arguments' => array(1),
+ 'access arguments' => array('access overlay'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['overlay/dismiss-message'] = array(
+ 'title' => '',
+ 'page callback' => 'overlay_user_dismiss_message',
+ 'access arguments' => array('access overlay'),
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function overlay_admin_paths() {
+ $paths = array(
+ // This is marked as an administrative path so that if it is visited from
+ // within the overlay, the user will stay within the overlay while the
+ // callback is being processed.
+ 'overlay/dismiss-message' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function overlay_permission() {
+ return array(
+ 'access overlay' => array(
+ 'title' => t('Access the administrative overlay'),
+ 'description' => t('View administrative pages in the overlay.'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_theme().
+ */
+function overlay_theme() {
+ return array(
+ 'overlay' => array(
+ 'render element' => 'page',
+ 'template' => 'overlay',
+ ),
+ 'overlay_disable_message' => array(
+ 'render element' => 'element',
+ ),
+ );
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function overlay_form_user_profile_form_alter(&$form, &$form_state) {
+ if ($form['#user_category'] == 'account') {
+ $account = $form['#user'];
+ if (user_access('access overlay', $account)) {
+ $form['overlay_control'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Administrative overlay'),
+ '#weight' => 4,
+ '#collapsible' => TRUE,
+ );
+
+ $form['overlay_control']['overlay'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Use the overlay for administrative pages.'),
+ '#description' => t('Show administrative pages on top of the page you started from.'),
+ '#default_value' => isset($account->data['overlay']) ? $account->data['overlay'] : 1,
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_user_presave().
+ */
+function overlay_user_presave(&$edit, $account, $category) {
+ if (isset($edit['overlay'])) {
+ $edit['data']['overlay'] = $edit['overlay'];
+ }
+}
+
+/**
+ * Implements hook_init().
+ *
+ * Determine whether the current page request is destined to appear in the
+ * parent window or in the overlay window, and format the page accordingly.
+ *
+ * @see overlay_set_mode()
+ */
+function overlay_init() {
+ global $user;
+
+ $mode = overlay_get_mode();
+
+ // Only act if the user has access to the overlay and a mode was not already
+ // set. Other modules can also enable the overlay directly for other uses.
+ $use_overlay = !isset($user->data['overlay']) || $user->data['overlay'];
+ if (empty($mode) && user_access('access overlay') && $use_overlay) {
+ $current_path = current_path();
+ // After overlay is enabled on the modules page, redirect to
+ // <front>#overlay=admin/modules to actually enable the overlay.
+ if (isset($_SESSION['overlay_enable_redirect']) && $_SESSION['overlay_enable_redirect']) {
+ unset($_SESSION['overlay_enable_redirect']);
+ drupal_goto('<front>', array('fragment' => 'overlay=' . $current_path));
+ }
+
+ if (isset($_GET['render']) && $_GET['render'] == 'overlay') {
+ // If a previous page requested that we close the overlay, close it and
+ // redirect to the final destination.
+ if (isset($_SESSION['overlay_close_dialog'])) {
+ call_user_func_array('overlay_close_dialog', $_SESSION['overlay_close_dialog']);
+ unset($_SESSION['overlay_close_dialog']);
+ }
+ // If this page shouldn't be rendered inside the overlay, redirect to the
+ // parent.
+ elseif (!path_is_admin($current_path)) {
+ overlay_close_dialog($current_path);
+ }
+
+ // Indicate that we are viewing an overlay child page.
+ overlay_set_mode('child');
+
+ // Unset the render parameter to avoid it being included in URLs on the page.
+ unset($_GET['render']);
+ }
+ // Do not enable the overlay if we already are on an admin page.
+ elseif (!path_is_admin($current_path)) {
+ // Otherwise add overlay parent code and our behavior.
+ overlay_set_mode('parent');
+ }
+ }
+}
+
+/**
+ * Implements hook_exit().
+ *
+ * When viewing an overlay child page, check if we need to trigger a refresh of
+ * the supplemental regions of the overlay on the next page request.
+ */
+function overlay_exit() {
+ // Check that we are in an overlay child page. Note that this should never
+ // return TRUE on a cached page view, since the child mode is not set until
+ // overlay_init() is called.
+ if (overlay_get_mode() == 'child') {
+ // Load any markup that was stored earlier in the page request, via calls
+ // to overlay_store_rendered_content(). If none was stored, this is not a
+ // page request where we expect any changes to the overlay supplemental
+ // regions to have occurred, so we do not need to proceed any further.
+ $original_markup = overlay_get_rendered_content();
+ if (!empty($original_markup)) {
+ // Compare the original markup to the current markup that we get from
+ // rendering each overlay supplemental region now. If they don't match,
+ // something must have changed, so we request a refresh of that region
+ // within the parent window on the next page request.
+ foreach (overlay_supplemental_regions() as $region) {
+ if (!isset($original_markup[$region]) || $original_markup[$region] != overlay_render_region($region)) {
+ overlay_request_refresh($region);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_library_info().
+ */
+function overlay_library_info() {
+ $module_path = drupal_get_path('module', 'overlay');
+
+ // Overlay parent.
+ $libraries['parent'] = array(
+ 'title' => 'Overlay: Parent',
+ 'website' => 'http://drupal.org/handbook/modules/overlay',
+ 'version' => '1.0',
+ 'js' => array(
+ $module_path . '/overlay-parent.js' => array(),
+ ),
+ 'css' => array(
+ $module_path . '/overlay-parent.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui'),
+ array('system', 'jquery.bbq'),
+ ),
+ );
+ // Overlay child.
+ $libraries['child'] = array(
+ 'title' => 'Overlay: Child',
+ 'website' => 'http://drupal.org/handbook/modules/overlay',
+ 'version' => '1.0',
+ 'js' => array(
+ $module_path . '/overlay-child.js' => array(),
+ ),
+ 'css' => array(
+ $module_path . '/overlay-child.css' => array(),
+ ),
+ );
+
+ return $libraries;
+}
+
+/**
+ * Implements hook_drupal_goto_alter().
+ */
+function overlay_drupal_goto_alter(&$path, &$options, &$http_response_code) {
+ if (overlay_get_mode() == 'child') {
+ // The authorize.php script bootstraps Drupal to a very low level, where
+ // the PHP code that is necessary to close the overlay properly will not be
+ // loaded. Therefore, if we are redirecting to authorize.php inside the
+ // overlay, instead redirect back to the current page with instructions to
+ // close the overlay there before redirecting to the final destination; see
+ // overlay_init().
+ if ($path == system_authorized_get_url() || $path == system_authorized_batch_processing_url()) {
+ $_SESSION['overlay_close_dialog'] = array($path, $options);
+ $path = current_path();
+ $options = drupal_get_query_parameters();
+ }
+
+ // If the current page request is inside the overlay, add ?render=overlay
+ // to the new path, so that it appears correctly inside the overlay.
+ if (isset($options['query'])) {
+ $options['query'] += array('render' => 'overlay');
+ }
+ else {
+ $options['query'] = array('render' => 'overlay');
+ }
+ }
+}
+
+/**
+ * Implements hook_batch_alter().
+ *
+ * If the current page request is inside the overlay, add ?render=overlay to
+ * the success callback URL, so that it appears correctly within the overlay.
+ *
+ * @see overlay_get_mode()
+ */
+function overlay_batch_alter(&$batch) {
+ if (overlay_get_mode() == 'child') {
+ if (isset($batch['url_options']['query'])) {
+ $batch['url_options']['query']['render'] = 'overlay';
+ }
+ else {
+ $batch['url_options']['query'] = array('render' => 'overlay');
+ }
+ }
+}
+
+/**
+ * Implements hook_page_alter().
+ */
+function overlay_page_alter(&$page) {
+ // If we are limiting rendering to a subset of page regions, deny access to
+ // all other regions so that they will not be processed.
+ if ($regions_to_render = overlay_get_regions_to_render()) {
+ $skipped_regions = array_diff(element_children($page), $regions_to_render);
+ foreach ($skipped_regions as $skipped_region) {
+ $page[$skipped_region]['#access'] = FALSE;
+ }
+ }
+
+ $mode = overlay_get_mode();
+ if ($mode == 'child') {
+ // Add the overlay wrapper before the html wrapper.
+ array_unshift($page['#theme_wrappers'], 'overlay');
+ }
+ elseif ($mode == 'parent' && ($message = overlay_disable_message())) {
+ $page['page_top']['disable_overlay'] = $message;
+ }
+}
+
+/**
+ * Menu callback; dismisses the overlay accessibility message for this user.
+ */
+function overlay_user_dismiss_message() {
+ global $user;
+ // It's unlikely, but possible that "access overlay" permission is granted to
+ // the anonymous role. In this case, we do not display the message to disable
+ // the overlay, so there is nothing to dismiss. Also, protect against
+ // cross-site request forgeries by validating a token.
+ if (empty($user->uid) || !isset($_GET['token']) || !drupal_valid_token($_GET['token'], 'overlay')) {
+ return MENU_ACCESS_DENIED;
+ }
+ else {
+ user_save(user_load($user->uid), array('data' => array('overlay_message_dismissed' => 1)));
+ drupal_set_message(t('The message has been dismissed. You can change your overlay settings at any time by visiting your profile page.'));
+ // Destination is normally given. Go to the user profile as a fallback.
+ drupal_goto('user/' . $user->uid . '/edit');
+ }
+}
+
+/**
+ * Returns a renderable array representing a message for disabling the overlay.
+ *
+ * If the current user can access the overlay and has not previously indicated
+ * that this message should be dismissed, this function returns a message
+ * containing a link to disable the overlay. Nothing is returned for anonymous
+ * users, because the links control per-user settings. Therefore, because some
+ * screen readers are unable to properly read overlay contents, site builders
+ * are discouraged from granting the "access overlay" permission to the
+ * anonymous role. See http://drupal.org/node/890284.
+ */
+function overlay_disable_message() {
+ global $user;
+
+ if (!empty($user->uid) && empty($user->data['overlay_message_dismissed']) && (!isset($user->data['overlay']) || $user->data['overlay']) && user_access('access overlay')) {
+ $build = array(
+ '#theme' => 'overlay_disable_message',
+ '#weight' => -99,
+ // Link to the user's profile page, where the overlay can be disabled.
+ 'profile_link' => array(
+ '#type' => 'link',
+ '#title' => t('If you have problems accessing administrative pages on this site, disable the overlay on your profile page.'),
+ '#href' => 'user/' . $user->uid . '/edit',
+ '#options' => array(
+ 'query' => drupal_get_destination(),
+ 'fragment' => 'edit-overlay-control',
+ 'attributes' => array(
+ 'id' => 'overlay-profile-link',
+ // Prevent the target page from being opened in the overlay.
+ 'class' => array('overlay-exclude'),
+ ),
+ ),
+ ),
+ // Link to a menu callback that allows this message to be permanently
+ // dismissed for the current user.
+ 'dismiss_message_link' => array(
+ '#type' => 'link',
+ '#title' => t('Dismiss this message.'),
+ '#href' => 'overlay/dismiss-message',
+ '#options' => array(
+ 'query' => drupal_get_destination() + array(
+ // Add a token to protect against cross-site request forgeries.
+ 'token' => drupal_get_token('overlay'),
+ ),
+ 'attributes' => array(
+ 'id' => 'overlay-dismiss-message',
+ // If this message is being displayed outside the overlay, prevent
+ // this link from opening the overlay.
+ 'class' => (overlay_get_mode() == 'parent') ? array('overlay-exclude') : array(),
+ ),
+ ),
+ )
+ );
+ }
+ else {
+ $build = array();
+ }
+
+ return $build;
+}
+
+/**
+ * Returns the HTML for the message about how to disable the overlay.
+ *
+ * @see overlay_disable_message()
+ */
+function theme_overlay_disable_message($variables) {
+ $element = $variables['element'];
+
+ // Add CSS classes to hide the links from most sighted users, while keeping
+ // them accessible to screen-reader users and keyboard-only users. To assist
+ // screen-reader users, this message appears in both the parent and child
+ // documents, but only the one in the child document is part of the tab order.
+ foreach (array('profile_link', 'dismiss_message_link') as $key) {
+ $element[$key]['#options']['attributes']['class'][] = 'element-invisible';
+ if (overlay_get_mode() == 'child') {
+ $element[$key]['#options']['attributes']['class'][] = 'element-focusable';
+ }
+ }
+
+ // Render the links.
+ $output = drupal_render($element['profile_link']) . ' ' . drupal_render($element['dismiss_message_link']);
+
+ // Add a heading for screen-reader users. The heading doesn't need to be seen
+ // by sighted users.
+ $output = '<h3 class="element-invisible">' . t('Options for the administrative overlay') . '</h3>' . $output;
+
+ // Wrap in a container for styling.
+ $output = '<div id="overlay-disable-message" class="clearfix">' . $output . '</div>';
+
+ return $output;
+}
+
+/**
+ * Implements hook_block_list_alter().
+ */
+function overlay_block_list_alter(&$blocks) {
+ // If we are limiting rendering to a subset of page regions, hide all blocks
+ // which appear in regions not on that list. Note that overlay_page_alter()
+ // does a more comprehensive job of preventing unwanted regions from being
+ // displayed (regardless of whether they contain blocks or not), but the
+ // reason for duplicating effort here is performance; we do not even want
+ // these blocks to be built if they are not going to be displayed.
+ if ($regions_to_render = overlay_get_regions_to_render()) {
+ foreach ($blocks as $bid => $block) {
+ if (!in_array($block->region, $regions_to_render)) {
+ unset($blocks[$bid]);
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Add default regions for the overlay.
+ */
+function overlay_system_info_alter(&$info, $file, $type) {
+ if ($type == 'theme') {
+ $info['overlay_regions'][] = 'content';
+ $info['overlay_regions'][] = 'help';
+ }
+}
+
+/**
+ * Implements hook_preprocess_html().
+ *
+ * If the current page request is inside the overlay, add appropriate classes
+ * to the <body> element, and simplify the page title.
+ *
+ * @see overlay_get_mode()
+ */
+function overlay_preprocess_html(&$variables) {
+ if (overlay_get_mode() == 'child') {
+ // Add overlay class, so themes can react to being displayed in the overlay.
+ $variables['classes_array'][] = 'overlay';
+ }
+}
+
+/**
+ * Implements hook_preprocess_maintenance_page().
+ *
+ * If the current page request is inside the overlay, add appropriate classes
+ * to the <body> element, and simplify the page title.
+ *
+ * @see overlay_preprocess_maintenance_page()
+ */
+function overlay_preprocess_maintenance_page(&$variables) {
+ overlay_preprocess_html($variables);
+}
+
+/**
+ * Preprocesses template variables for overlay.tpl.php
+ *
+ * @see overlay.tpl.php
+ */
+function template_preprocess_overlay(&$variables) {
+ $variables['tabs'] = menu_primary_local_tasks();
+ $variables['title'] = drupal_get_title();
+ $variables['disable_overlay'] = overlay_disable_message();
+ $variables['content_attributes_array']['class'][] = 'clearfix';
+}
+
+/**
+ * Processes variables for overlay.tpl.php
+ *
+ * @see template_preprocess_overlay()
+ * @see overlay.tpl.php
+ */
+function template_process_overlay(&$variables) {
+ // Place the rendered HTML for the page body into a top level variable.
+ $variables['page'] = $variables['page']['#children'];
+}
+
+/**
+ * Implements hook_preprocess_page().
+ *
+ * Hide tabs inside the overlay.
+ *
+ * @see overlay_get_mode()
+ */
+function overlay_preprocess_page(&$variables) {
+ if (overlay_get_mode() == 'child') {
+ unset($variables['tabs']['#primary']);
+ }
+}
+
+/**
+ * Callback to request that the overlay display an empty page.
+ *
+ * This is used to prevent a page request which closes the overlay (for
+ * example, a form submission) from being fully re-rendered before the overlay
+ * is closed. Instead, we store a variable which will cause the page to be
+ * rendered by a delivery callback function that does not actually print
+ * visible HTML (but rather only the bare minimum scripts and styles necessary
+ * to trigger the overlay to close), thereby allowing the dialog to be closed
+ * faster and with less interruption, and also allowing the display of messages
+ * to be deferred to the parent window (rather than displaying them in the
+ * child window, which will close before the user has had a chance to read
+ * them).
+ *
+ * @param $value
+ * By default, an empty page will not be displayed. Set to TRUE to request
+ * an empty page display, or FALSE to disable the empty page display (if it
+ * was previously enabled on this page request).
+ *
+ * @return
+ * TRUE if the current behavior is to display an empty page, or FALSE if not.
+ *
+ * @see overlay_page_delivery_callback_alter()
+ */
+function overlay_display_empty_page($value = NULL) {
+ $display_empty_page = &drupal_static(__FUNCTION__, FALSE);
+ if (isset($value)) {
+ $display_empty_page = $value;
+ }
+ return $display_empty_page;
+}
+
+/**
+ * Implements hook_page_delivery_callback_alter().
+ */
+function overlay_page_delivery_callback_alter(&$callback) {
+ if (overlay_display_empty_page()) {
+ $callback = 'overlay_deliver_empty_page';
+ }
+}
+
+/**
+ * Delivery callback to display an empty page.
+ *
+ * This function is used to print out a bare minimum empty page which still has
+ * the scripts and styles necessary in order to trigger the overlay to close.
+ */
+function overlay_deliver_empty_page() {
+ $empty_page = '<html><head><title></title>' . drupal_get_css() . drupal_get_js() . '</head><body class="overlay"></body></html>';
+ print $empty_page;
+ drupal_exit();
+}
+
+/**
+ * Get the current overlay mode.
+ *
+ * @see overlay_set_mode()
+ */
+function overlay_get_mode() {
+ return overlay_set_mode(NULL);
+}
+
+/**
+ * Sets the overlay mode and adds proper JavaScript and styles to the page.
+ *
+ * Note that since setting the overlay mode triggers a variety of behaviors
+ * (including hooks being invoked), it can only be done once per page request.
+ * Therefore, the first call to this function which passes along a value of the
+ * $mode parameter controls the overlay mode that will be used.
+ *
+ * @param $mode
+ * To set the mode, pass in one of the following values:
+ * - 'parent': This is used in the context of a parent window (a regular
+ * browser window). If set, JavaScript is added so that administrative
+ * links in the parent window will open in an overlay.
+ * - 'child': This is used in the context of the child overlay window (the
+ * page actually appearing within the overlay iframe). If set, JavaScript
+ * and CSS are added so that Drupal behaves nicely from within the overlay.
+ * - 'none': This is used to avoid adding any overlay-related code to the
+ * page at all. Modules can set this to explicitly prevent the overlay from
+ * being used. For example, since the overlay module itself sets the mode
+ * to 'parent' or 'child' in overlay_init() when certain conditions are
+ * met, other modules which want to override that behavior can do so by
+ * setting the mode to 'none' earlier in the page request - e.g., in their
+ * own hook_init() implementations, if they have a lower weight.
+ * This parameter is optional, and if omitted, the current mode will be
+ * returned with no action taken.
+ *
+ * @return
+ * The current mode, if any has been set, or NULL if no mode has been set.
+ *
+ * @ingroup overlay_api
+ * @see overlay_init()
+ */
+function overlay_set_mode($mode = NULL) {
+ global $base_path;
+ $overlay_mode = &drupal_static(__FUNCTION__);
+
+ // Make sure external resources are not included more than once. Also return
+ // the current mode, if no mode was specified.
+ if (isset($overlay_mode) || !isset($mode)) {
+ return $overlay_mode;
+ }
+ $overlay_mode = $mode;
+
+ switch ($overlay_mode) {
+ case 'parent':
+ drupal_add_library('overlay', 'parent');
+
+ // Allow modules to act upon overlay events.
+ module_invoke_all('overlay_parent_initialize');
+ break;
+
+ case 'child':
+ drupal_add_library('overlay', 'child');
+
+ // Allow modules to act upon overlay events.
+ module_invoke_all('overlay_child_initialize');
+ break;
+ }
+ return $overlay_mode;
+}
+
+/**
+ * Implements hook_overlay_parent_initialize().
+ */
+function overlay_overlay_parent_initialize() {
+ // Let the client side know which paths are administrative.
+ $paths = path_get_admin_paths();
+ foreach ($paths as &$type) {
+ $type = str_replace('<front>', variable_get('site_frontpage', 'node'), $type);
+ }
+ drupal_add_js(array('overlay' => array('paths' => $paths)), 'setting');
+ // Pass along the Ajax callback for rerendering sections of the parent window.
+ drupal_add_js(array('overlay' => array('ajaxCallback' => 'overlay-ajax')), 'setting');
+}
+
+/**
+ * Implements hook_overlay_child_initialize().
+ */
+function overlay_overlay_child_initialize() {
+ // Check if the parent window needs to refresh any page regions on this page
+ // request.
+ overlay_trigger_refresh();
+ // If this is a POST request, or a GET request with a token parameter, we
+ // have an indication that something in the supplemental regions of the
+ // overlay might change during the current page request. We therefore store
+ // the initial rendered content of those regions here, so that we can compare
+ // it to the same content rendered in overlay_exit(), at the end of the page
+ // request. This allows us to check if anything actually did change, and, if
+ // so, trigger an immediate Ajax refresh of the parent window.
+ if (!empty($_POST) || isset($_GET['token'])) {
+ foreach (overlay_supplemental_regions() as $region) {
+ overlay_store_rendered_content($region, overlay_render_region($region));
+ }
+ // In addition, notify the parent window that when the overlay closes,
+ // the entire parent window should be refreshed.
+ overlay_request_page_refresh();
+ }
+ // Indicate that when the main page rendering occurs later in the page
+ // request, only the regions that appear within the overlay should be
+ // rendered.
+ overlay_set_regions_to_render(overlay_regions());
+}
+
+/**
+ * Callback to request that the overlay close as soon as the page is displayed.
+ *
+ * @param $redirect
+ * (optional) The path that should open in the parent window after the
+ * overlay closes. If not set, no redirect will be performed on the parent
+ * window.
+ * @param $redirect_options
+ * (optional) An associative array of options to use when generating the
+ * redirect URL.
+ */
+function overlay_close_dialog($redirect = NULL, $redirect_options = array()) {
+ $settings = array(
+ 'overlayChild' => array(
+ 'closeOverlay' => TRUE,
+ ),
+ );
+
+ // Tell the child window to perform the redirection when requested to.
+ if (isset($redirect)) {
+ $settings['overlayChild']['redirect'] = url($redirect, $redirect_options);
+ }
+
+ drupal_add_js($settings, array('type' => 'setting'));
+
+ // Since we are closing the overlay as soon as the page is displayed, we do
+ // not want to show any of the page's actual content.
+ overlay_display_empty_page(TRUE);
+}
+
+/**
+ * Returns a list of page regions that appear in the overlay.
+ *
+ * Overlay regions correspond to the entire contents of the overlay child
+ * window and are refreshed each time a new page request is made within the
+ * overlay.
+ *
+ * @return
+ * An array of region names that correspond to those which appear in the
+ * overlay, within the theme that is being used to display the current page.
+ *
+ * @see overlay_supplemental_regions()
+ */
+function overlay_regions() {
+ return _overlay_region_list('overlay_regions');
+}
+
+/**
+ * Returns a list of supplemental page regions for the overlay.
+ *
+ * Supplemental overlay regions are those which are technically part of the
+ * parent window, but appear to the user as being related to the overlay
+ * (usually because they are displayed next to, rather than underneath, the
+ * main overlay regions) and therefore need to be dynamically refreshed if any
+ * administrative actions taken within the overlay change their contents.
+ *
+ * An example of a typical overlay supplemental region would be the 'page_top'
+ * region, in the case where a toolbar is being displayed there.
+ *
+ * @return
+ * An array of region names that correspond to supplemental overlay regions,
+ * within the theme that is being used to display the current page.
+ *
+ * @see overlay_regions()
+ */
+function overlay_supplemental_regions() {
+ return _overlay_region_list('overlay_supplemental_regions');
+}
+
+/**
+ * Helper function for returning a list of page regions related to the overlay.
+ *
+ * @param $type
+ * The type of regions to return. This can either be 'overlay_regions' or
+ * 'overlay_supplemental_regions'.
+ *
+ * @return
+ * An array of region names of the given type, within the theme that is being
+ * used to display the current page.
+ *
+ * @see overlay_regions()
+ * @see overlay_supplemental_regions()
+ */
+function _overlay_region_list($type) {
+ // Obtain the current theme. We need to first make sure the theme system is
+ // initialized, since this function can be called early in the page request.
+ drupal_theme_initialize();
+ $themes = list_themes();
+ $theme = $themes[$GLOBALS['theme']];
+ // Return the list of regions stored within the theme's info array, or an
+ // empty array if no regions of the appropriate type are defined.
+ return !empty($theme->info[$type]) ? $theme->info[$type] : array();
+}
+
+/**
+ * Returns a list of page regions that rendering should be limited to.
+ *
+ * @return
+ * An array containing the names of the regions that will be rendered when
+ * drupal_render_page() is called. If empty, then no limits will be imposed,
+ * and all regions of the page will be rendered.
+ *
+ * @see overlay_page_alter()
+ * @see overlay_block_list_alter()
+ * @see overlay_set_regions_to_render()
+ */
+function overlay_get_regions_to_render() {
+ return overlay_set_regions_to_render();
+}
+
+/**
+ * Sets the regions of the page that rendering will be limited to.
+ *
+ * @param $regions
+ * (Optional) An array containing the names of the regions that should be
+ * rendered when drupal_render_page() is called. Pass in an empty array to
+ * remove all limits and cause drupal_render_page() to render all page
+ * regions (the default behavior). If this parameter is omitted, no change
+ * will be made to the current list of regions to render.
+ *
+ * @return
+ * The current list of regions to render, or an empty array if the regions
+ * are not being limited.
+ *
+ * @see overlay_page_alter()
+ * @see overlay_block_list_alter()
+ * @see overlay_get_regions_to_render()
+ */
+function overlay_set_regions_to_render($regions = NULL) {
+ $regions_to_render = &drupal_static(__FUNCTION__, array());
+ if (isset($regions)) {
+ $regions_to_render = $regions;
+ }
+ return $regions_to_render;
+}
+
+/**
+ * Renders an individual page region.
+ *
+ * This function is primarily intended to be used for checking the content of
+ * supplemental overlay regions (e.g., a region containing a toolbar). Passing
+ * in a region that is intended to display the main page content is not
+ * supported; the region will be rendered by this function, but the main page
+ * content will not appear in it. In addition, although this function returns
+ * the rendered HTML for the provided region, it does not place it on the final
+ * page, nor add any of its associated JavaScript or CSS to the page.
+ *
+ * @param $region
+ * The name of the page region that should be rendered.
+ *
+ * @return
+ * The rendered HTML of the provided region.
+ */
+function overlay_render_region($region) {
+ // Indicate the region that we will be rendering, so that other regions will
+ // be hidden by overlay_page_alter() and overlay_block_list_alter().
+ overlay_set_regions_to_render(array($region));
+ // Do what is necessary to force drupal_render_page() to only display HTML
+ // from the requested region. Specifically, declare that the main page
+ // content does not need to automatically be added to the page, and pass in
+ // a page array that has all theme functions removed (so that overall HTML
+ // for the page will not be added either).
+ $system_main_content_added = &drupal_static('system_main_content_added');
+ $system_main_content_added = TRUE;
+ $page = array(
+ '#type' => 'page',
+ '#theme' => NULL,
+ '#theme_wrappers' => array(),
+ );
+ // Render the region, but do not cache any JavaScript or CSS associated with
+ // it. This region might not be included the next time drupal_render_page()
+ // is called, and we do not want its JavaScript or CSS to erroneously appear
+ // on the final rendered page.
+ $original_js = drupal_add_js();
+ $original_css = drupal_add_css();
+ $original_libraries = drupal_static('drupal_add_library');
+ $js = &drupal_static('drupal_add_js');
+ $css = &drupal_static('drupal_add_css');
+ $libraries = &drupal_static('drupal_add_library');
+ $markup = drupal_render_page($page);
+ $js = $original_js;
+ $css = $original_css;
+ $libraries = $original_libraries;
+ // Indicate that the main page content has not, in fact, been displayed, so
+ // that future calls to drupal_render_page() will be able to render it
+ // correctly.
+ $system_main_content_added = FALSE;
+ // Restore the original behavior of rendering all regions for the next time
+ // drupal_render_page() is called.
+ overlay_set_regions_to_render(array());
+ return $markup;
+}
+
+/**
+ * Returns any rendered content that was stored earlier in the page request.
+ *
+ * @return
+ * An array of all rendered HTML that was stored earlier in the page request,
+ * keyed by the identifier with which it was stored. If no content was
+ * stored, an empty array is returned.
+ *
+ * @see overlay_store_rendered_content()
+ */
+function overlay_get_rendered_content() {
+ return overlay_store_rendered_content();
+}
+
+/**
+ * Stores strings representing rendered HTML content.
+ *
+ * This function is used to keep a static cache of rendered content that can be
+ * referred to later in the page request.
+ *
+ * @param $id
+ * (Optional) An identifier for the content which is being stored, which will
+ * be used as an array key in the returned array. If omitted, no change will
+ * be made to the current stored data.
+ * @param $content
+ * (Optional) A string representing the rendered data to store. This only has
+ * an effect if $id is also provided.
+ *
+ * @return
+ * An array representing all data that is currently being stored, or an empty
+ * array if there is none.
+ *
+ * @see overlay_get_rendered_content()
+ */
+function overlay_store_rendered_content($id = NULL, $content = NULL) {
+ $rendered_content = &drupal_static(__FUNCTION__, array());
+ if (isset($id)) {
+ $rendered_content[$id] = $content;
+ }
+ return $rendered_content;
+}
+
+/**
+ * Request that the parent window refresh a particular page region.
+ *
+ * @param $region
+ * The name of the page region to refresh. The parent window will trigger a
+ * refresh of this region on the next page load.
+ *
+ * @see overlay_trigger_refresh()
+ * @see Drupal.overlay.refreshRegions()
+ */
+function overlay_request_refresh($region) {
+ $class = drupal_region_class($region);
+ $_SESSION['overlay_regions_to_refresh'][] = array($class => $region);
+}
+
+/**
+ * Request that the entire parent window be reloaded when the overlay closes.
+ *
+ * @see overlay_trigger_refresh()
+ */
+function overlay_request_page_refresh() {
+ $_SESSION['overlay_refresh_parent'] = TRUE;
+}
+
+/**
+ * Check if the parent window needs to be refreshed on this page load.
+ *
+ * If the previous page load requested that any page regions be refreshed, or
+ * if it requested that the entire page be refreshed when the overlay closes,
+ * pass that request via JavaScript to the child window, so it can in turn pass
+ * the request to the parent window.
+ *
+ * @see overlay_request_refresh()
+ * @see overlay_request_page_refresh()
+ * @see Drupal.overlay.refreshRegions()
+ */
+function overlay_trigger_refresh() {
+ if (!empty($_SESSION['overlay_regions_to_refresh'])) {
+ $settings = array(
+ 'overlayChild' => array(
+ 'refreshRegions' => $_SESSION['overlay_regions_to_refresh'],
+ ),
+ );
+ drupal_add_js($settings, array('type' => 'setting'));
+ unset($_SESSION['overlay_regions_to_refresh']);
+ }
+ if (!empty($_SESSION['overlay_refresh_parent'])) {
+ drupal_add_js(array('overlayChild' => array('refreshPage' => TRUE)), array('type' => 'setting'));
+ unset($_SESSION['overlay_refresh_parent']);
+ }
+}
+
+/**
+ * Prints the markup obtained by rendering a single region of the page.
+ *
+ * This function is intended to be called via Ajax.
+ *
+ * @param $region
+ * The name of the page region to render.
+ *
+ * @see Drupal.overlay.refreshRegions()
+ */
+function overlay_ajax_render_region($region) {
+ print overlay_render_region($region);
+}
diff --git a/core/modules/overlay/overlay.tpl.php b/core/modules/overlay/overlay.tpl.php
new file mode 100644
index 000000000000..54b10af1f1c2
--- /dev/null
+++ b/core/modules/overlay/overlay.tpl.php
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a page in the overlay.
+ *
+ * Available variables:
+ * - $title: the (sanitized) title of the page.
+ * - $page: The rendered page content.
+ * - $tabs (array): Tabs linking to any sub-pages beneath the current page
+ * (e.g., the view and edit tabs when displaying a node).
+ *
+ * Helper variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_overlay()
+ * @see template_process()
+ */
+?>
+
+<?php print render($disable_overlay); ?>
+<div id="overlay" <?php print $attributes; ?>>
+ <div id="overlay-titlebar" class="clearfix">
+ <div id="overlay-title-wrapper" class="clearfix">
+ <h1 id="overlay-title"<?php print $title_attributes; ?>><?php print $title; ?></h1>
+ </div>
+ <div id="overlay-close-wrapper">
+ <a id="overlay-close" href="#" class="overlay-close"><span class="element-invisible"><?php print t('Close overlay'); ?></span></a>
+ </div>
+ <?php if ($tabs): ?><h2 class="element-invisible"><?php print t('Primary tabs'); ?></h2><ul id="overlay-tabs"><?php print render($tabs); ?></ul><?php endif; ?>
+ </div>
+ <div id="overlay-content"<?php print $content_attributes; ?>>
+ <?php print $page; ?>
+ </div>
+</div>
diff --git a/core/modules/path/path.admin.inc b/core/modules/path/path.admin.inc
new file mode 100644
index 000000000000..c8a69639aa68
--- /dev/null
+++ b/core/modules/path/path.admin.inc
@@ -0,0 +1,295 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for the path module.
+ */
+
+/**
+ * Return a listing of all defined URL aliases.
+ *
+ * When filter key passed, perform a standard search on the given key,
+ * and return the list of matching URL aliases.
+ */
+function path_admin_overview($keys = NULL) {
+ // Add the filter form above the overview table.
+ $build['path_admin_filter_form'] = drupal_get_form('path_admin_filter_form', $keys);
+ // Enable language column if locale is enabled or if we have any alias with language
+ $alias_exists = (bool) db_query_range('SELECT 1 FROM {url_alias} WHERE language <> :language', 0, 1, array(':language' => LANGUAGE_NONE))->fetchField();
+ $multilanguage = (module_exists('locale') || $alias_exists);
+
+ $header = array();
+ $header[] = array('data' => t('Alias'), 'field' => 'alias', 'sort' => 'asc');
+ $header[] = array('data' => t('System'), 'field' => 'source');
+ if ($multilanguage) {
+ $header[] = array('data' => t('Language'), 'field' => 'language');
+ }
+ $header[] = array('data' => t('Operations'));
+
+ $query = db_select('url_alias')->extend('PagerDefault')->extend('TableSort');
+ if ($keys) {
+ // Replace wildcards with PDO wildcards.
+ $query->condition('alias', '%' . preg_replace('!\*+!', '%', $keys) . '%', 'LIKE');
+ }
+ $result = $query
+ ->fields('url_alias')
+ ->orderByHeader($header)
+ ->limit(50)
+ ->execute();
+
+ $rows = array();
+ $destination = drupal_get_destination();
+ foreach ($result as $data) {
+ $row = array();
+ $row['data']['alias'] = l($data->alias, $data->source);
+ $row['data']['source'] = l($data->source, $data->source, array('alias' => TRUE));
+ if ($multilanguage) {
+ $row['data']['language'] = module_invoke('locale', 'language_name', $data->language);
+ }
+
+ $operations = array();
+ $operations['edit'] = array(
+ 'title' => t('edit'),
+ 'href' => "admin/config/search/path/edit/$data->pid",
+ 'query' => $destination,
+ );
+ $operations['delete'] = array(
+ 'title' => t('delete'),
+ 'href' => "admin/config/search/path/delete/$data->pid",
+ 'query' => $destination,
+ );
+ $row['data']['operations'] = array(
+ 'data' => array(
+ '#theme' => 'links',
+ '#links' => $operations,
+ '#attributes' => array('class' => array('links', 'inline', 'nowrap')),
+ ),
+ );
+
+ // If the system path maps to a different URL alias, highlight this table
+ // row to let the user know of old aliases.
+ if ($data->alias != drupal_get_path_alias($data->source, $data->language)) {
+ $row['class'] = array('warning');
+ }
+
+ $rows[] = $row;
+ }
+
+ $build['path_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No URL aliases available. <a href="@link">Add URL alias</a>.', array('@link' => url('admin/config/search/path/add'))),
+ );
+ $build['path_pager'] = array('#theme' => 'pager');
+
+ return $build;
+}
+
+/**
+ * Menu callback; handles pages for creating and editing URL aliases.
+ */
+function path_admin_edit($path = array()) {
+ if ($path) {
+ drupal_set_title($path['alias']);
+ $output = drupal_get_form('path_admin_form', $path);
+ }
+ else {
+ $output = drupal_get_form('path_admin_form');
+ }
+
+ return $output;
+}
+
+/**
+ * Return a form for editing or creating an individual URL alias.
+ *
+ * @ingroup forms
+ * @see path_admin_form_validate()
+ * @see path_admin_form_submit()
+ */
+function path_admin_form($form, &$form_state, $path = array('source' => '', 'alias' => '', 'language' => LANGUAGE_NONE, 'pid' => NULL)) {
+ $form['source'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Existing system path'),
+ '#default_value' => $path['source'],
+ '#maxlength' => 255,
+ '#size' => 45,
+ '#description' => t('Specify the existing path you wish to alias. For example: node/28, forum/1, taxonomy/term/1.'),
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q='),
+ '#required' => TRUE,
+ );
+ $form['alias'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Path alias'),
+ '#default_value' => $path['alias'],
+ '#maxlength' => 255,
+ '#size' => 45,
+ '#description' => t('Specify an alternative path by which this data can be accessed. For example, type "about" when writing an about page. Use a relative path and don\'t add a trailing slash or the URL alias won\'t work.'),
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q='),
+ '#required' => TRUE,
+ );
+
+ // A hidden value unless locale module is enabled.
+ if (module_exists('locale')) {
+ $form['language'] = array(
+ '#type' => 'select',
+ '#title' => t('Language'),
+ '#options' => array(LANGUAGE_NONE => t('All languages')) + locale_language_list('name'),
+ '#default_value' => $path['language'],
+ '#weight' => -10,
+ '#description' => t('A path alias set for a specific language will always be used when displaying this page in that language, and takes precedence over path aliases set for <em>All languages</em>.'),
+ );
+ }
+ else {
+ $form['language'] = array(
+ '#type' => 'value',
+ '#value' => $path['language']
+ );
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+ if ($path['pid']) {
+ $form['pid'] = array(
+ '#type' => 'hidden',
+ '#value' => $path['pid'],
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#submit' => array('path_admin_form_delete_submit'),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Submit function for the 'Delete' button on the URL alias editing form.
+ */
+function path_admin_form_delete_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ }
+ $form_state['redirect'] = array('admin/config/search/path/delete/' . $form_state['values']['pid'], array('query' => $destination));
+}
+
+/**
+ * Verify that a URL alias is valid
+ */
+function path_admin_form_validate($form, &$form_state) {
+ $source = &$form_state['values']['source'];
+ $source = drupal_get_normal_path($source);
+ $alias = $form_state['values']['alias'];
+ $pid = isset($form_state['values']['pid']) ? $form_state['values']['pid'] : 0;
+ // Language is only set if locale module is enabled, otherwise save for all languages.
+ $language = isset($form_state['values']['language']) ? $form_state['values']['language'] : LANGUAGE_NONE;
+
+ $has_alias = db_query("SELECT COUNT(alias) FROM {url_alias} WHERE pid <> :pid AND alias = :alias AND language = :language", array(
+ ':pid' => $pid,
+ ':alias' => $alias,
+ ':language' => $language,
+ ))
+ ->fetchField();
+
+ if ($has_alias) {
+ form_set_error('alias', t('The alias %alias is already in use in this language.', array('%alias' => $alias)));
+ }
+ if (!drupal_valid_path($source)) {
+ form_set_error('source', t("The path '@link_path' is either invalid or you do not have access to it.", array('@link_path' => $source)));
+ }
+}
+
+/**
+ * Save a URL alias to the database.
+ */
+function path_admin_form_submit($form, &$form_state) {
+ // Remove unnecessary values.
+ form_state_values_clean($form_state);
+
+ path_save($form_state['values']);
+
+ drupal_set_message(t('The alias has been saved.'));
+ $form_state['redirect'] = 'admin/config/search/path';
+}
+
+/**
+ * Menu callback; confirms deleting an URL alias
+ */
+function path_admin_delete_confirm($form, &$form_state, $path) {
+ if (user_access('administer url aliases')) {
+ $form_state['path'] = $path;
+ return confirm_form(
+ $form,
+ t('Are you sure you want to delete path alias %title?',
+ array('%title' => $path['alias'])),
+ 'admin/config/search/path'
+ );
+ }
+ return array();
+}
+
+/**
+ * Execute URL alias deletion
+ */
+function path_admin_delete_confirm_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ path_delete($form_state['path']['pid']);
+ $form_state['redirect'] = 'admin/config/search/path';
+ }
+}
+
+/**
+ * Return a form to filter URL aliases.
+ *
+ * @ingroup forms
+ * @see path_admin_filter_form_submit()
+ */
+function path_admin_filter_form($form, &$form_state, $keys = '') {
+ $form['#attributes'] = array('class' => array('search-form'));
+ $form['basic'] = array('#type' => 'fieldset',
+ '#title' => t('Filter aliases'),
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['basic']['filter'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Path alias',
+ '#title_display' => 'invisible',
+ '#default_value' => $keys,
+ '#maxlength' => 128,
+ '#size' => 25,
+ );
+ $form['basic']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Filter'),
+ '#submit' => array('path_admin_filter_form_submit_filter'),
+ );
+ if ($keys) {
+ $form['basic']['reset'] = array(
+ '#type' => 'submit',
+ '#value' => t('Reset'),
+ '#submit' => array('path_admin_filter_form_submit_reset'),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Process filter form submission when the Filter button is pressed.
+ */
+function path_admin_filter_form_submit_filter($form, &$form_state) {
+ $form_state['redirect'] = 'admin/config/search/path/list/' . trim($form_state['values']['filter']);
+}
+
+/**
+ * Process filter form submission when the Reset button is pressed.
+ */
+function path_admin_filter_form_submit_reset($form, &$form_state) {
+ $form_state['redirect'] = 'admin/config/search/path/list';
+}
diff --git a/core/modules/path/path.api.php b/core/modules/path/path.api.php
new file mode 100644
index 000000000000..d1a007ac8cfa
--- /dev/null
+++ b/core/modules/path/path.api.php
@@ -0,0 +1,74 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Path module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+
+/**
+ * Allow modules to respond to a path being inserted.
+ *
+ * @param $path
+ * An associative array containing the following keys:
+ * - source: The internal system path.
+ * - alias: The URL alias.
+ * - pid: Unique path alias identifier.
+ * - language: The language of the alias.
+ *
+ * @see path_save()
+ */
+function hook_path_insert($path) {
+ db_insert('mytable')
+ ->fields(array(
+ 'alias' => $path['alias'],
+ 'pid' => $path['pid'],
+ ))
+ ->execute();
+}
+
+/**
+ * Allow modules to respond to a path being updated.
+ *
+ * @param $path
+ * An associative array containing the following keys:
+ * - source: The internal system path.
+ * - alias: The URL alias.
+ * - pid: Unique path alias identifier.
+ * - language: The language of the alias.
+ *
+ * @see path_save()
+ */
+function hook_path_update($path) {
+ db_update('mytable')
+ ->fields(array('alias' => $path['alias']))
+ ->condition('pid', $path['pid'])
+ ->execute();
+}
+
+/**
+ * Allow modules to respond to a path being deleted.
+ *
+ * @param $path
+ * An associative array containing the following keys:
+ * - source: The internal system path.
+ * - alias: The URL alias.
+ * - pid: Unique path alias identifier.
+ * - language: The language of the alias.
+ *
+ * @see path_delete()
+ */
+function hook_path_delete($path) {
+ db_delete('mytable')
+ ->condition('pid', $path['pid'])
+ ->execute();
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/path/path.info b/core/modules/path/path.info
new file mode 100644
index 000000000000..55b6f9c0061a
--- /dev/null
+++ b/core/modules/path/path.info
@@ -0,0 +1,7 @@
+name = Path
+description = Allows users to rename URLs.
+package = Core
+version = VERSION
+core = 8.x
+files[] = path.test
+configure = admin/config/search/path
diff --git a/core/modules/path/path.js b/core/modules/path/path.js
new file mode 100644
index 000000000000..fcc0acc413fa
--- /dev/null
+++ b/core/modules/path/path.js
@@ -0,0 +1,16 @@
+
+(function ($) {
+
+Drupal.behaviors.pathFieldsetSummaries = {
+ attach: function (context) {
+ $('fieldset.path-form', context).drupalSetSummary(function (context) {
+ var path = $('.form-item-path-alias input').val();
+
+ return path ?
+ Drupal.t('Alias: @alias', { '@alias': path }) :
+ Drupal.t('No alias');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/path/path.module b/core/modules/path/path.module
new file mode 100644
index 000000000000..332287de2c2e
--- /dev/null
+++ b/core/modules/path/path.module
@@ -0,0 +1,306 @@
+<?php
+
+/**
+ * @file
+ * Enables users to rename URLs.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function path_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#path':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Path module allows you to specify an alias, or custom URL, for any existing internal system path. Aliases should not be confused with URL redirects, which allow you to forward a changed or inactive URL to a new URL. In addition to making URLs more readable, aliases also help search engines index content more effectively. Multiple aliases may be used for a single internal system path. To automate the aliasing of paths, you can install the contributed module <a href="@pathauto">Pathauto</a>. For more information, see the online handbook entry for the <a href="@path">Path module</a>.', array('@path' => 'http://drupal.org/handbook/modules/path', '@pathauto' => 'http://drupal.org/project/pathauto')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Creating aliases') . '</dt>';
+ $output .= '<dd>' . t('Users with sufficient <a href="@permissions">permissions</a> can create aliases under the <em>URL path settings</em> section when they create or edit content. Some examples of aliases are: ', array('@permissions' => url('admin/people/permissions', array('fragment' => 'module-path'))));
+ $output .= '<ul><li>' . t('<em>member/jane-smith</em> aliased to internal path <em>user/123</em>') . '</li>';
+ $output .= '<li>' . t('<em>about-us/team</em> aliased to internal path <em>node/456</em>') . '</li>';
+ $output .= '</ul></dd>';
+ $output .= '<dt>' . t('Managing aliases') . '</dt>';
+ $output .= '<dd>' . t('The Path module provides a way to search and view a <a href="@aliases">list of all aliases</a> that are in use on your website. Aliases can be added, edited and deleted through this list.', array('@aliases' => url('admin/config/search/path'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/config/search/path':
+ return '<p>' . t("An alias defines a different name for an existing URL path - for example, the alias 'about' for the URL path 'node/1'. A URL path can have multiple aliases.") . '</p>';
+
+ case 'admin/config/search/path/add':
+ return '<p>' . t('Enter the path you wish to create the alias for, followed by the name of the new alias.') . '</p>';
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function path_permission() {
+ return array(
+ 'administer url aliases' => array(
+ 'title' => t('Administer URL aliases'),
+ ),
+ 'create url aliases' => array(
+ 'title' => t('Create and edit URL aliases'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function path_menu() {
+ $items['admin/config/search/path'] = array(
+ 'title' => 'URL aliases',
+ 'description' => "Change your site's URL paths by aliasing them.",
+ 'page callback' => 'path_admin_overview',
+ 'access arguments' => array('administer url aliases'),
+ 'weight' => -5,
+ 'file' => 'path.admin.inc',
+ );
+ $items['admin/config/search/path/edit/%path'] = array(
+ 'title' => 'Edit alias',
+ 'page callback' => 'path_admin_edit',
+ 'page arguments' => array(5),
+ 'access arguments' => array('administer url aliases'),
+ 'file' => 'path.admin.inc',
+ );
+ $items['admin/config/search/path/delete/%path'] = array(
+ 'title' => 'Delete alias',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('path_admin_delete_confirm', 5),
+ 'access arguments' => array('administer url aliases'),
+ 'file' => 'path.admin.inc',
+ );
+ $items['admin/config/search/path/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/config/search/path/add'] = array(
+ 'title' => 'Add alias',
+ 'page callback' => 'path_admin_edit',
+ 'access arguments' => array('administer url aliases'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'path.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ */
+function path_form_node_form_alter(&$form, $form_state) {
+ $path = array();
+ if (!empty($form['#node']->nid)) {
+ $conditions = array('source' => 'node/' . $form['#node']->nid);
+ if ($form['#node']->language != LANGUAGE_NONE) {
+ $conditions['language'] = $form['#node']->language;
+ }
+ $path = path_load($conditions);
+ if ($path === FALSE) {
+ $path = array();
+ }
+ }
+ $path += array(
+ 'pid' => NULL,
+ 'source' => isset($form['#node']->nid) ? 'node/' . $form['#node']->nid : NULL,
+ 'alias' => '',
+ 'language' => isset($form['#node']->language) ? $form['#node']->language : LANGUAGE_NONE,
+ );
+
+ $form['path'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('URL path settings'),
+ '#collapsible' => TRUE,
+ '#collapsed' => empty($path['alias']),
+ '#group' => 'additional_settings',
+ '#attributes' => array(
+ 'class' => array('path-form'),
+ ),
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'path') . '/path.js'),
+ ),
+ '#access' => user_access('create url aliases') || user_access('administer url aliases'),
+ '#weight' => 30,
+ '#tree' => TRUE,
+ '#element_validate' => array('path_form_element_validate'),
+ );
+ $form['path']['alias'] = array(
+ '#type' => 'textfield',
+ '#title' => t('URL alias'),
+ '#default_value' => $path['alias'],
+ '#maxlength' => 255,
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Optionally specify an alternative URL by which this content can be accessed. For example, type "about" when writing an about page. Use a relative path and don\'t add a trailing slash or the URL alias won\'t work.'),
+ );
+ $form['path']['pid'] = array('#type' => 'value', '#value' => $path['pid']);
+ $form['path']['source'] = array('#type' => 'value', '#value' => $path['source']);
+ $form['path']['language'] = array('#type' => 'value', '#value' => $path['language']);
+}
+
+/**
+ * Form element validation handler for URL alias form element.
+ */
+function path_form_element_validate($element, &$form_state, $complete_form) {
+ if (!empty($form_state['values']['path']['alias'])) {
+ // Trim the submitted value.
+ $alias = trim($form_state['values']['path']['alias']);
+ form_set_value($element['alias'], $alias, $form_state);
+ // Node language (Locale module) needs special care. Since the language of
+ // the URL alias depends on the node language, and the node language can be
+ // switched right within the same form, we need to conditionally overload
+ // the originally assigned URL alias language.
+ // @todo Remove this after converting Path module to a field, and, after
+ // stopping Locale module from abusing the content language system.
+ if (isset($form_state['values']['language'])) {
+ form_set_value($element['language'], $form_state['values']['language'], $form_state);
+ }
+
+ $path = $form_state['values']['path'];
+
+ // Ensure that the submitted alias does not exist yet.
+ $query = db_select('url_alias')
+ ->condition('alias', $path['alias'])
+ ->condition('language', $path['language']);
+ if (!empty($path['source'])) {
+ $query->condition('source', $path['source'], '<>');
+ }
+ $query->addExpression('1');
+ $query->range(0, 1);
+ if ($query->execute()->fetchField()) {
+ form_set_error('alias', t('The alias is already in use.'));
+ }
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function path_node_insert($node) {
+ if (isset($node->path)) {
+ $path = $node->path;
+ $path['alias'] = trim($path['alias']);
+ // Only save a non-empty alias.
+ if (!empty($path['alias'])) {
+ // Ensure fields for programmatic executions.
+ $path['source'] = 'node/' . $node->nid;
+ $path['language'] = isset($node->language) ? $node->language : LANGUAGE_NONE;
+ path_save($path);
+ }
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function path_node_update($node) {
+ if (isset($node->path)) {
+ $path = $node->path;
+ $path['alias'] = trim($path['alias']);
+ // Delete old alias if user erased it.
+ if (!empty($path['pid']) && empty($path['alias'])) {
+ path_delete($path['pid']);
+ }
+ // Only save a non-empty alias.
+ if (!empty($path['alias'])) {
+ // Ensure fields for programmatic executions.
+ $path['source'] = 'node/' . $node->nid;
+ $path['language'] = isset($node->language) ? $node->language : LANGUAGE_NONE;
+ path_save($path);
+ }
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function path_node_delete($node) {
+ // Delete all aliases associated with this node.
+ path_delete(array('source' => 'node/' . $node->nid));
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function path_form_taxonomy_form_term_alter(&$form, $form_state) {
+ // Make sure this does not show up on the delete confirmation form.
+ if (empty($form_state['confirm_delete'])) {
+ $path = (isset($form['#term']['tid']) ? path_load('taxonomy/term/' . $form['#term']['tid']) : array());
+ if ($path === FALSE) {
+ $path = array();
+ }
+ $path += array(
+ 'pid' => NULL,
+ 'source' => isset($form['#term']['tid']) ? 'taxonomy/term/' . $form['#term']['tid'] : NULL,
+ 'alias' => '',
+ 'language' => LANGUAGE_NONE,
+ );
+ $form['path'] = array(
+ '#access' => user_access('create url aliases') || user_access('administer url aliases'),
+ '#tree' => TRUE,
+ '#element_validate' => array('path_form_element_validate'),
+ );
+ $form['path']['alias'] = array(
+ '#type' => 'textfield',
+ '#title' => t('URL alias'),
+ '#default_value' => $path['alias'],
+ '#maxlength' => 255,
+ '#weight' => 0,
+ '#description' => t("Optionally specify an alternative URL by which this term can be accessed. Use a relative path and don't add a trailing slash or the URL alias won't work."),
+ );
+ $form['path']['pid'] = array('#type' => 'value', '#value' => $path['pid']);
+ $form['path']['source'] = array('#type' => 'value', '#value' => $path['source']);
+ $form['path']['language'] = array('#type' => 'value', '#value' => $path['language']);
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_insert().
+ */
+function path_taxonomy_term_insert($term) {
+ if (isset($term->path)) {
+ $path = $term->path;
+ $path['alias'] = trim($path['alias']);
+ // Only save a non-empty alias.
+ if (!empty($path['alias'])) {
+ // Ensure fields for programmatic executions.
+ $path['source'] = 'taxonomy/term/' . $term->tid;
+ $path['language'] = LANGUAGE_NONE;
+ path_save($path);
+ }
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_update().
+ */
+function path_taxonomy_term_update($term) {
+ if (isset($term->path)) {
+ $path = $term->path;
+ $path['alias'] = trim($path['alias']);
+ // Delete old alias if user erased it.
+ if (!empty($path['pid']) && empty($path['alias'])) {
+ path_delete($path['pid']);
+ }
+ // Only save a non-empty alias.
+ if (!empty($path['alias'])) {
+ // Ensure fields for programmatic executions.
+ $path['source'] = 'taxonomy/term/' . $term->tid;
+ $path['language'] = LANGUAGE_NONE;
+ path_save($path);
+ }
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function path_taxonomy_term_delete($term) {
+ // Delete all aliases associated with this term.
+ path_delete(array('source' => 'taxonomy/term/' . $term->tid));
+}
diff --git a/core/modules/path/path.test b/core/modules/path/path.test
new file mode 100644
index 000000000000..8f0406ef8044
--- /dev/null
+++ b/core/modules/path/path.test
@@ -0,0 +1,505 @@
+<?php
+
+/**
+ * @file
+ * Tests for path.module.
+ */
+
+class PathTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Path alias functionality',
+ 'description' => 'Add, edit, delete, and change alias and verify its consistency in the database.',
+ 'group' => 'Path',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('path');
+
+ // Create test user and login.
+ $web_user = $this->drupalCreateUser(array('create page content', 'edit own page content', 'administer url aliases', 'create url aliases'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Test the path cache.
+ */
+ function testPathCache() {
+ // Create test node.
+ $node1 = $this->drupalCreateNode();
+
+ // Create alias.
+ $edit = array();
+ $edit['source'] = 'node/' . $node1->nid;
+ $edit['alias'] = $this->randomName(8);
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ // Visit the system path for the node and confirm a cache entry is
+ // created.
+ cache('path')->flush();
+ $this->drupalGet($edit['source']);
+ $this->assertTrue(cache('path')->get($edit['source']), t('Cache entry was created.'));
+
+ // Visit the alias for the node and confirm a cache entry is created.
+ cache('path')->flush();
+ $this->drupalGet($edit['alias']);
+ $this->assertTrue(cache('path')->get($edit['source']), t('Cache entry was created.'));
+ }
+
+ /**
+ * Test alias functionality through the admin interfaces.
+ */
+ function testAdminAlias() {
+ // Create test node.
+ $node1 = $this->drupalCreateNode();
+
+ // Create alias.
+ $edit = array();
+ $edit['source'] = 'node/' . $node1->nid;
+ $edit['alias'] = $this->randomName(8);
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($edit['alias']);
+ $this->assertText($node1->title, 'Alias works.');
+ $this->assertResponse(200);
+
+ // Change alias to one containing "exotic" characters.
+ $pid = $this->getPID($edit['alias']);
+
+ $previous = $edit['alias'];
+ $edit['alias'] = "- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters.
+ "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string.
+ "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets.
+ $this->drupalPost('admin/config/search/path/edit/' . $pid, $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($edit['alias']);
+ $this->assertText($node1->title, 'Changed alias works.');
+ $this->assertResponse(200);
+
+ drupal_static_reset('drupal_lookup_path');
+ // Confirm that previous alias no longer works.
+ $this->drupalGet($previous);
+ $this->assertNoText($node1->title, 'Previous alias no longer works.');
+ $this->assertResponse(404);
+
+ // Create second test node.
+ $node2 = $this->drupalCreateNode();
+
+ // Set alias to second test node.
+ $edit['source'] = 'node/' . $node2->nid;
+ // leave $edit['alias'] the same
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ // Confirm no duplicate was created.
+ $this->assertRaw(t('The alias %alias is already in use in this language.', array('%alias' => $edit['alias'])), 'Attempt to move alias was rejected.');
+
+ // Delete alias.
+ $this->drupalPost('admin/config/search/path/edit/' . $pid, array(), t('Delete'));
+ $this->drupalPost(NULL, array(), t('Confirm'));
+
+ // Confirm that the alias no longer works.
+ $this->drupalGet($edit['alias']);
+ $this->assertNoText($node1->title, 'Alias was successfully deleted.');
+ $this->assertResponse(404);
+ }
+
+ /**
+ * Test alias functionality through the node interfaces.
+ */
+ function testNodeAlias() {
+ // Create test node.
+ $node1 = $this->drupalCreateNode();
+
+ // Create alias.
+ $edit = array();
+ $edit['path[alias]'] = $this->randomName(8);
+ $this->drupalPost('node/' . $node1->nid . '/edit', $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($edit['path[alias]']);
+ $this->assertText($node1->title, 'Alias works.');
+ $this->assertResponse(200);
+
+ // Change alias to one containing "exotic" characters.
+ $previous = $edit['path[alias]'];
+ $edit['path[alias]'] = "- ._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters.
+ "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string.
+ "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets.
+ $this->drupalPost('node/' . $node1->nid . '/edit', $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($edit['path[alias]']);
+ $this->assertText($node1->title, 'Changed alias works.');
+ $this->assertResponse(200);
+
+ // Make sure that previous alias no longer works.
+ $this->drupalGet($previous);
+ $this->assertNoText($node1->title, 'Previous alias no longer works.');
+ $this->assertResponse(404);
+
+ // Create second test node.
+ $node2 = $this->drupalCreateNode();
+
+ // Set alias to second test node.
+ // Leave $edit['path[alias]'] the same.
+ $this->drupalPost('node/' . $node2->nid . '/edit', $edit, t('Save'));
+
+ // Confirm that the alias didn't make a duplicate.
+ $this->assertText(t('The alias is already in use.'), 'Attempt to moved alias was rejected.');
+
+ // Delete alias.
+ $this->drupalPost('node/' . $node1->nid . '/edit', array('path[alias]' => ''), t('Save'));
+
+ // Confirm that the alias no longer works.
+ $this->drupalGet($edit['path[alias]']);
+ $this->assertNoText($node1->title, 'Alias was successfully deleted.');
+ $this->assertResponse(404);
+ }
+
+ function getPID($alias) {
+ return db_query("SELECT pid FROM {url_alias} WHERE alias = :alias", array(':alias' => $alias))->fetchField();
+ }
+}
+
+/**
+ * Test URL aliases for taxonomy terms.
+ */
+class PathTaxonomyTermTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term URL aliases',
+ 'description' => 'Tests URL aliases for taxonomy terms.',
+ 'group' => 'Path',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('path', 'taxonomy');
+
+ // Create and login user.
+ $web_user = $this->drupalCreateUser(array('administer url aliases', 'administer taxonomy', 'access administration pages'));
+ $this->drupalLogin($web_user);
+ }
+
+ /**
+ * Test alias functionality through the admin interfaces.
+ */
+ function testTermAlias() {
+ // Create a term in the default 'Tags' vocabulary with URL alias.
+ $vocabulary = taxonomy_vocabulary_load(1);
+ $description = $this->randomName();;
+ $edit = array();
+ $edit['name'] = $this->randomName();
+ $edit['description[value]'] = $description;
+ $edit['path[alias]'] = $this->randomName();
+ $this->drupalPost('admin/structure/taxonomy/' . $vocabulary->machine_name . '/add', $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($edit['path[alias]']);
+ $this->assertText($description, 'Term can be accessed on URL alias.');
+
+ // Change the term's URL alias.
+ $tid = db_query("SELECT tid FROM {taxonomy_term_data} WHERE name = :name", array(':name' => $edit['name']))->fetchField();
+ $edit2 = array();
+ $edit2['path[alias]'] = $this->randomName();
+ $this->drupalPost('taxonomy/term/' . $tid . '/edit', $edit2, t('Save'));
+
+ // Confirm that the changed alias works.
+ $this->drupalGet($edit2['path[alias]']);
+ $this->assertText($description, 'Term can be accessed on changed URL alias.');
+
+ // Confirm that the old alias no longer works.
+ $this->drupalGet($edit['path[alias]']);
+ $this->assertNoText($description, 'Old URL alias has been removed after altering.');
+ $this->assertResponse(404, 'Old URL alias returns 404.');
+
+ // Remove the term's URL alias.
+ $edit3 = array();
+ $edit3['path[alias]'] = '';
+ $this->drupalPost('taxonomy/term/' . $tid . '/edit', $edit3, t('Save'));
+
+ // Confirm that the alias no longer works.
+ $this->drupalGet($edit2['path[alias]']);
+ $this->assertNoText($description, 'Old URL alias has been removed after altering.');
+ $this->assertResponse(404, 'Old URL alias returns 404.');
+ }
+}
+
+class PathLanguageTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Path aliases with translated nodes',
+ 'description' => 'Confirm that paths work with translated nodes',
+ 'group' => 'Path',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('path', 'locale', 'translation');
+
+ // Create and login user.
+ $this->web_user = $this->drupalCreateUser(array('edit any page content', 'create page content', 'administer url aliases', 'create url aliases', 'administer languages', 'translate content', 'access administration pages'));
+ $this->drupalLogin($this->web_user);
+
+ // Enable French language.
+ $edit = array();
+ $edit['predefined_langcode'] = 'fr';
+
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => 1);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ }
+
+ /**
+ * Test alias functionality through the admin interfaces.
+ */
+ function testAliasTranslation() {
+ // Set 'page' content type to enable translation.
+ variable_set('language_content_type_page', 2);
+
+ $english_node = $this->drupalCreateNode(array('type' => 'page'));
+ $english_alias = $this->randomName();
+
+ // Edit the node to set language and path.
+ $edit = array();
+ $edit['language'] = 'en';
+ $edit['path[alias]'] = $english_alias;
+ $this->drupalPost('node/' . $english_node->nid . '/edit', $edit, t('Save'));
+
+ // Confirm that the alias works.
+ $this->drupalGet($english_alias);
+ $this->assertText($english_node->title, 'Alias works.');
+
+ // Translate the node into French.
+ $this->drupalGet('node/' . $english_node->nid . '/translate');
+ $this->clickLink(t('add translation'));
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $french_alias = $this->randomName();
+ $edit['path[alias]'] = $french_alias;
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Clear the path lookup cache.
+ drupal_lookup_path('wipe');
+
+ // Ensure the node was created.
+ $french_node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertTrue(($french_node), 'Node found in database.');
+
+ // Confirm that the alias works.
+ $this->drupalGet('fr/' . $edit['path[alias]']);
+ $this->assertText($french_node->title, 'Alias for French translation works.');
+
+ // Confirm that the alias is returned by url().
+ drupal_static_reset('language_list');
+ drupal_static_reset('locale_url_outbound_alter');
+ $languages = language_list();
+ $url = url('node/' . $french_node->nid, array('language' => $languages[$french_node->language]));
+ $this->assertTrue(strpos($url, $edit['path[alias]']), t('URL contains the path alias.'));
+
+ // Confirm that the alias works even when changing language negotiation
+ // options. Enable User language detection and selection over URL one.
+ $edit = array(
+ 'language[enabled][locale-user]' => 1,
+ 'language[weight][locale-user]' => -9,
+ 'language[enabled][locale-url]' => 1,
+ 'language[weight][locale-url]' => -8,
+ );
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Change user language preference.
+ $edit = array('language' => 'fr');
+ $this->drupalPost("user/{$this->web_user->uid}/edit", $edit, t('Save'));
+
+ // Check that the English alias works. In this situation French is the
+ // current UI and content language, while URL language is English (since we
+ // do not have a path prefix we fall back to the site's default language).
+ // We need to ensure that the user language preference is not taken into
+ // account while determining the path alias language, because if this
+ // happens we have no way to check that the path alias is valid: there is no
+ // path alias for French matching the english alias. So drupal_lookup_path()
+ // needs to use the URL language to check whether the alias is valid.
+ $this->drupalGet($english_alias);
+ $this->assertText($english_node->title, 'Alias for English translation works.');
+
+ // Check that the French alias works.
+ $this->drupalGet("fr/$french_alias");
+ $this->assertText($french_node->title, 'Alias for French translation works.');
+
+ // Disable URL language negotiation.
+ $edit = array('language[enabled][locale-url]' => FALSE);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Check that the English alias still works.
+ $this->drupalGet($english_alias);
+ $this->assertText($english_node->title, 'Alias for English translation works.');
+
+ // Check that the French alias is not available. We check the unprefixed
+ // alias because we disabled URL language negotiation above. In this
+ // situation only aliases in the default language and language neutral ones
+ // should keep working.
+ $this->drupalGet($french_alias);
+ $this->assertResponse(404, t('Alias for French translation is unavailable when URL language negotiation is disabled.'));
+
+ // drupal_lookup_path() has an internal static cache. Check to see that
+ // it has the appropriate contents at this point.
+ drupal_lookup_path('wipe');
+ $french_node_path = drupal_lookup_path('source', $french_alias, $french_node->language);
+ $this->assertEqual($french_node_path, 'node/' . $french_node->nid, t('Normal path works.'));
+ // Second call should return the same path.
+ $french_node_path = drupal_lookup_path('source', $french_alias, $french_node->language);
+ $this->assertEqual($french_node_path, 'node/' . $french_node->nid, t('Normal path is the same.'));
+
+ // Confirm that the alias works.
+ $french_node_alias = drupal_lookup_path('alias', 'node/' . $french_node->nid, $french_node->language);
+ $this->assertEqual($french_node_alias, $french_alias, t('Alias works.'));
+ // Second call should return the same alias.
+ $french_node_alias = drupal_lookup_path('alias', 'node/' . $french_node->nid, $french_node->language);
+ $this->assertEqual($french_node_alias, $french_alias, t('Alias is the same.'));
+ }
+}
+
+/**
+ * Tests the user interface for creating path aliases, with languages.
+ */
+class PathLanguageUITestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Path aliases with languages',
+ 'description' => 'Confirm that the Path module user interface works with languages.',
+ 'group' => 'Path',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('path', 'locale');
+
+ // Create and login user.
+ $web_user = $this->drupalCreateUser(array('edit any page content', 'create page content', 'administer url aliases', 'create url aliases', 'administer languages', 'access administration pages'));
+ $this->drupalLogin($web_user);
+
+ // Enable French language.
+ $edit = array();
+ $edit['predefined_langcode'] = 'fr';
+
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Enable URL language detection and selection.
+ $edit = array('language[enabled][locale-url]' => 1);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ }
+
+ /**
+ * Tests that a language-neutral URL alias works.
+ */
+ function testLanguageNeutralURLs() {
+ $name = $this->randomName(8);
+ $edit = array();
+ $edit['source'] = 'admin/config/search/path';
+ $edit['alias'] = $name;
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ $this->drupalGet($name);
+ $this->assertText(t('Filter aliases'), 'Language-neutral URL alias works');
+ }
+
+ /**
+ * Tests that a default language URL alias works.
+ */
+ function testDefaultLanguageURLs() {
+ $name = $this->randomName(8);
+ $edit = array();
+ $edit['source'] = 'admin/config/search/path';
+ $edit['alias'] = $name;
+ $edit['language'] = 'en';
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ $this->drupalGet($name);
+ $this->assertText(t('Filter aliases'), 'English URL alias works');
+ }
+
+ /**
+ * Tests that a non-default language URL alias works.
+ */
+ function testNonDefaultURLs() {
+ $name = $this->randomName(8);
+ $edit = array();
+ $edit['source'] = 'admin/config/search/path';
+ $edit['alias'] = $name;
+ $edit['language'] = 'fr';
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+
+ $this->drupalGet('fr/' . $name);
+ $this->assertText(t('Filter aliases'), 'Foreign URL alias works');
+ }
+
+}
+
+/**
+ * Tests that paths are not prefixed on a monolingual site.
+ */
+class PathMonolingualTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Paths on non-English monolingual sites',
+ 'description' => 'Confirm that paths are not changed on monolingual non-English sites',
+ 'group' => 'Path',
+ );
+ }
+
+ function setUp() {
+ global $language;
+ parent::setUp('path', 'locale', 'translation');
+
+ // Create and login user.
+ $web_user = $this->drupalCreateUser(array('administer languages', 'access administration pages'));
+ $this->drupalLogin($web_user);
+
+ // Enable French language.
+ $edit = array();
+ $edit['predefined_langcode'] = 'fr';
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Make French the default language.
+ $edit = array('site_default' => 'fr');
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+
+ // Disable English.
+ $edit = array('languages[en][enabled]' => FALSE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+
+ // Verify that French is the only language.
+ $this->assertFalse(drupal_multilingual(), t('Site is mono-lingual'));
+ $this->assertEqual(language_default()->language, 'fr', t('French is the default language'));
+
+ // Set language detection to URL.
+ $edit = array('language[enabled][locale-url]' => TRUE);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+
+ // Force languages to be initialized.
+ drupal_language_initialize();
+ }
+
+ /**
+ * Verifies that links do not have language prefixes in them.
+ */
+ function testPageLinks() {
+ // Navigate to 'admin/config' path.
+ $this->drupalGet('admin/config');
+
+ // Verify that links in this page do not have a 'fr/' prefix.
+ $this->assertNoLinkByHref('/fr/', 'Links do not contain language prefix');
+
+ // Verify that links in this page can be followed and work.
+ $this->clickLink(t('Languages'));
+ $this->assertResponse(200, 'Clicked link results in a valid page');
+ $this->assertText(t('Add language'), 'Page contains the add language text');
+ }
+}
diff --git a/core/modules/php/php.info b/core/modules/php/php.info
new file mode 100644
index 000000000000..669a138f8f44
--- /dev/null
+++ b/core/modules/php/php.info
@@ -0,0 +1,6 @@
+name = PHP filter
+description = Allows embedded PHP code/snippets to be evaluated.
+package = Core
+version = VERSION
+core = 8.x
+files[] = php.test
diff --git a/core/modules/php/php.install b/core/modules/php/php.install
new file mode 100644
index 000000000000..12944ddd75c4
--- /dev/null
+++ b/core/modules/php/php.install
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the php module.
+ */
+
+/**
+ * Implements hook_enable().
+ */
+function php_enable() {
+ $format_exists = (bool) db_query_range('SELECT 1 FROM {filter_format} WHERE name = :name', 0, 1, array(':name' => 'PHP code'))->fetchField();
+ // Add a PHP code text format, if it does not exist. Do this only for the
+ // first install (or if the format has been manually deleted) as there is no
+ // reliable method to identify the format in an uninstall hook or in
+ // subsequent clean installs.
+ if (!$format_exists) {
+ $php_format = array(
+ 'format' => 'php_code',
+ 'name' => 'PHP code',
+ // 'Plain text' format is installed with a weight of 10 by default. Use a
+ // higher weight here to ensure that this format will not be the default
+ // format for anyone.
+ 'weight' => 11,
+ 'filters' => array(
+ // Enable the PHP evaluator filter.
+ 'php_code' => array(
+ 'weight' => 0,
+ 'status' => 1,
+ ),
+ ),
+ );
+ $php_format = (object) $php_format;
+ filter_format_save($php_format);
+
+ drupal_set_message(t('A <a href="@php-code">PHP code</a> text format has been created.', array('@php-code' => url('admin/config/content/formats/' . $php_format->format))));
+ }
+}
+
+/**
+ * Implements hook_disable().
+ */
+function php_disable() {
+ drupal_set_message(t('The PHP module has been disabled. Any existing content that was using the PHP filter will now be visible in plain text. This might pose a security risk by exposing sensitive information, if any, used in the PHP code.'));
+}
diff --git a/core/modules/php/php.module b/core/modules/php/php.module
new file mode 100644
index 000000000000..37bf9a1f337f
--- /dev/null
+++ b/core/modules/php/php.module
@@ -0,0 +1,140 @@
+<?php
+
+/**
+ * @file
+ * Additional filter for PHP input.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function php_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#php':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The PHP filter module adds a PHP filter to your site, for use with <a href="@filter">text formats</a>. This filter adds the ability to execute PHP code in any text field that uses a text format (such as the body of a content item or the text of a comment). <a href="@php-net">PHP</a> is a general-purpose scripting language widely-used for web development, and is the language with which Drupal has been developed. For more information, see the online handbook entry for the <a href="@php">PHP filter module</a>.', array('@filter' => url('admin/help/filter'), '@php-net' => 'http://www.php.net', '@php' => 'http://drupal.org/handbook/modules/php/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Enabling execution of PHP in text fields') . '</dt>';
+ $output .= '<dd>' . t('The PHP filter module allows users with the proper permissions to include custom PHP code that will get executed when pages of your site are processed. While this is a powerful and flexible feature if used by a trusted user with PHP experience, it is a significant and dangerous security risk in the hands of a malicious or inexperienced user. Even a trusted user may accidentally compromise the site by entering malformed or incorrect PHP code. Only the most trusted users should be granted permission to use the PHP filter, and all PHP code added through the PHP filter should be carefully examined before use. <a href="@php-snippets">Example PHP snippets</a> can be found on Drupal.org.', array('@php-snippets' => url('http://drupal.org/handbook/customization/php-snippets'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function php_permission() {
+ return array(
+ 'use PHP for settings' => array(
+ 'title' => t('Use PHP for settings'),
+ 'restrict access' => TRUE,
+ ),
+ );
+}
+
+/**
+ * Evaluate a string of PHP code.
+ *
+ * This is a wrapper around PHP's eval(). It uses output buffering to capture both
+ * returned and printed text. Unlike eval(), we require code to be surrounded by
+ * <?php ?> tags; in other words, we evaluate the code as if it were a stand-alone
+ * PHP file.
+ *
+ * Using this wrapper also ensures that the PHP code which is evaluated can not
+ * overwrite any variables in the calling code, unlike a regular eval() call.
+ *
+ * @param $code
+ * The code to evaluate.
+ * @return
+ * A string containing the printed output of the code, followed by the returned
+ * output of the code.
+ *
+ * @ingroup php_wrappers
+ */
+function php_eval($code) {
+ global $theme_path, $theme_info, $conf;
+
+ // Store current theme path.
+ $old_theme_path = $theme_path;
+
+ // Restore theme_path to the theme, as long as php_eval() executes,
+ // so code evaluated will not see the caller module as the current theme.
+ // If theme info is not initialized get the path from theme_default.
+ if (!isset($theme_info)) {
+ $theme_path = drupal_get_path('theme', $conf['theme_default']);
+ }
+ else {
+ $theme_path = dirname($theme_info->filename);
+ }
+
+ ob_start();
+ print eval('?>' . $code);
+ $output = ob_get_contents();
+ ob_end_clean();
+
+ // Recover original theme path.
+ $theme_path = $old_theme_path;
+
+ return $output;
+}
+
+/**
+ * Tips callback for php filter.
+ */
+function _php_filter_tips($filter, $format, $long = FALSE) {
+ global $base_url;
+ if ($long) {
+ $output = '<h4>' . t('Using custom PHP code') . '</h4>';
+ $output .= '<p>' . t('Custom PHP code may be embedded in some types of site content, including posts and blocks. While embedding PHP code inside a post or block is a powerful and flexible feature when used by a trusted user with PHP experience, it is a significant and dangerous security risk when used improperly. Even a small mistake when posting PHP code may accidentally compromise your site.') . '</p>';
+ $output .= '<p>' . t('If you are unfamiliar with PHP, SQL, or Drupal, avoid using custom PHP code within posts. Experimenting with PHP may corrupt your database, render your site inoperable, or significantly compromise security.') . '</p>';
+ $output .= '<p>' . t('Notes:') . '</p>';
+ $output .= '<ul><li>' . t('Remember to double-check each line for syntax and logic errors <strong>before</strong> saving.') . '</li>';
+ $output .= '<li>' . t('Statements must be correctly terminated with semicolons.') . '</li>';
+ $output .= '<li>' . t('Global variables used within your PHP code retain their values after your script executes.') . '</li>';
+ $output .= '<li>' . t('<code>register_globals</code> is <strong>turned off</strong>. If you need to use forms, understand and use the functions in <a href="@formapi">the Drupal Form API</a>.', array('@formapi' => url('http://api.drupal.org/api/group/form_api/7'))) . '</li>';
+ $output .= '<li>' . t('Use a <code>print</code> or <code>return</code> statement in your code to output content.') . '</li>';
+ $output .= '<li>' . t('Develop and test your PHP code using a separate test script and sample database before deploying on a production site.') . '</li>';
+ $output .= '<li>' . t('Consider including your custom PHP code within a site-specific module or <code>template.php</code> file rather than embedding it directly into a post or block.') . '</li>';
+ $output .= '<li>' . t('Be aware that the ability to embed PHP code within content is provided by the PHP Filter module. If this module is disabled or deleted, then blocks and posts with embedded PHP may display, rather than execute, the PHP code.') . '</li></ul>';
+ $output .= '<p>' . t('A basic example: <em>Creating a "Welcome" block that greets visitors with a simple message.</em>') . '</p>';
+ $output .= '<ul><li>' . t('<p>Add a custom block to your site, named "Welcome" . With its text format set to "PHP code" (or another format supporting PHP input), add the following in the Block body:</p>
+<pre>
+print t(\'Welcome visitor! Thank you for visiting.\');
+</pre>') . '</li>';
+ $output .= '<li>' . t('<p>To display the name of a registered user, use this instead:</p>
+<pre>
+global $user;
+if ($user->uid) {
+ print t(\'Welcome @name! Thank you for visiting.\', array(\'@name\' => format_username($user)));
+}
+else {
+ print t(\'Welcome visitor! Thank you for visiting.\');
+}
+</pre>') . '</li></ul>';
+ $output .= '<p>' . t('<a href="@drupal">Drupal.org</a> offers <a href="@php-snippets">some example PHP snippets</a>, or you can create your own with some PHP experience and knowledge of the Drupal system.', array('@drupal' => url('http://drupal.org'), '@php-snippets' => url('http://drupal.org/handbook/customization/php-snippets'))) . '</p>';
+ return $output;
+ }
+ else {
+ return t('You may post PHP code. You should include &lt;?php ?&gt; tags.');
+ }
+}
+
+/**
+ * Implements hook_filter_info().
+ *
+ * Provide PHP code filter. Use with care.
+ */
+function php_filter_info() {
+ $filters['php_code'] = array(
+ 'title' => t('PHP evaluator'),
+ 'description' => t('Executes a piece of PHP code. The usage of this filter should be restricted to administrators only!'),
+ 'process callback' => 'php_eval',
+ 'tips callback' => '_php_filter_tips',
+ 'cache' => FALSE,
+ );
+ return $filters;
+}
+
diff --git a/core/modules/php/php.test b/core/modules/php/php.test
new file mode 100644
index 000000000000..8ead2ac02ae4
--- /dev/null
+++ b/core/modules/php/php.test
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @file
+ * Tests for php.module.
+ */
+
+/**
+ * Base PHP test case class.
+ */
+class PHPTestCase extends DrupalWebTestCase {
+ protected $php_code_format;
+
+ function setUp() {
+ parent::setUp('php');
+
+ // Create and login admin user.
+ $admin_user = $this->drupalCreateUser(array('administer filters'));
+ $this->drupalLogin($admin_user);
+
+ // Verify that the PHP code text format was inserted.
+ $php_format_id = 'php_code';
+ $this->php_code_format = filter_format_load($php_format_id);
+ $this->assertEqual($this->php_code_format->name, 'PHP code', t('PHP code text format was created.'));
+
+ // Verify that the format has the PHP code filter enabled.
+ $filters = filter_list_format($php_format_id);
+ $this->assertTrue($filters['php_code']->status, t('PHP code filter is enabled.'));
+
+ // Verify that the format exists on the administration page.
+ $this->drupalGet('admin/config/content/formats');
+ $this->assertText('PHP code', t('PHP code text format was created.'));
+
+ // Verify that anonymous and authenticated user roles do not have access.
+ $this->drupalGet('admin/config/content/formats/' . $php_format_id);
+ $this->assertFieldByName('roles[1]', FALSE, t('Anonymous users do not have access to PHP code format.'));
+ $this->assertFieldByName('roles[2]', FALSE, t('Authenticated users do not have access to PHP code format.'));
+ }
+
+ /**
+ * Create a test node with PHP code in the body.
+ *
+ * @return stdObject Node object.
+ */
+ function createNodeWithCode() {
+ return $this->drupalCreateNode(array('body' => array(LANGUAGE_NONE => array(array('value' => '<?php print "SimpleTest PHP was executed!"; ?>')))));
+ }
+}
+
+/**
+ * Tests to make sure the PHP filter actually evaluates PHP code when used.
+ */
+class PHPFilterTestCase extends PHPTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'PHP filter functionality',
+ 'description' => 'Make sure that PHP filter properly evaluates PHP code when enabled.',
+ 'group' => 'PHP',
+ );
+ }
+
+ /**
+ * Make sure that the PHP filter evaluates PHP code when used.
+ */
+ function testPHPFilter() {
+ // Log in as a user with permission to use the PHP code text format.
+ $php_code_permission = filter_permission_name(filter_format_load('php_code'));
+ $web_user = $this->drupalCreateUser(array('access content', 'create page content', 'edit own page content', $php_code_permission));
+ $this->drupalLogin($web_user);
+
+ // Create a node with PHP code in it.
+ $node = $this->createNodeWithCode();
+
+ // Make sure that the PHP code shows up as text.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText('print "SimpleTest PHP was executed!"', t('PHP code is displayed.'));
+
+ // Change filter to PHP filter and see that PHP code is evaluated.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["body[$langcode][0][format]"] = $this->php_code_format->format;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been updated.', array('%title' => $node->title)), t('PHP code filter turned on.'));
+
+ // Make sure that the PHP code shows up as text.
+ $this->assertNoText('print "SimpleTest PHP was executed!"', t("PHP code isn't displayed."));
+ $this->assertText('SimpleTest PHP was executed!', t('PHP code has been evaluated.'));
+ }
+}
+
+/**
+ * Tests to make sure access to the PHP filter is properly restricted.
+ */
+class PHPAccessTestCase extends PHPTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'PHP filter access check',
+ 'description' => 'Make sure that users who don\'t have access to the PHP filter can\'t see it.',
+ 'group' => 'PHP',
+ );
+ }
+
+ /**
+ * Make sure that user can't use the PHP filter when not given access.
+ */
+ function testNoPrivileges() {
+ // Create node with PHP filter enabled.
+ $web_user = $this->drupalCreateUser(array('access content', 'create page content', 'edit own page content'));
+ $this->drupalLogin($web_user);
+ $node = $this->createNodeWithCode();
+
+ // Make sure that the PHP code shows up as text.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText('print', t('PHP code was not evaluated.'));
+
+ // Make sure that user doesn't have access to filter.
+ $this->drupalGet('node/' . $node->nid . '/edit');
+ $this->assertNoRaw('<option value="' . $this->php_code_format->format . '">', t('PHP code format not available.'));
+ }
+}
diff --git a/core/modules/poll/poll-bar--block.tpl.php b/core/modules/poll/poll-bar--block.tpl.php
new file mode 100644
index 000000000000..3b91afc3a052
--- /dev/null
+++ b/core/modules/poll/poll-bar--block.tpl.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display the bar for a single choice in a
+ * poll.
+ *
+ * Variables available:
+ * - $title: The title of the poll.
+ * - $votes: The number of votes for this choice
+ * - $total_votes: The number of votes for this choice
+ * - $percentage: The percentage of votes for this choice.
+ * - $vote: The choice number of the current user's vote.
+ * - $voted: Set to TRUE if the user voted for this choice.
+ *
+ * @see template_preprocess_poll_bar()
+ */
+?>
+
+<div class="text"><?php print $title; ?></div>
+<div class="bar">
+ <div style="width: <?php print $percentage; ?>%;" class="foreground"></div>
+</div>
+<div class="percent">
+ <?php print $percentage; ?>%
+</div>
diff --git a/core/modules/poll/poll-bar.tpl.php b/core/modules/poll/poll-bar.tpl.php
new file mode 100644
index 000000000000..9426ff59f2a6
--- /dev/null
+++ b/core/modules/poll/poll-bar.tpl.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display the bar for a single choice in a
+ * poll.
+ *
+ * Variables available:
+ * - $title: The title of the poll.
+ * - $votes: The number of votes for this choice
+ * - $total_votes: The number of votes for this choice
+ * - $percentage: The percentage of votes for this choice.
+ * - $vote: The choice number of the current user's vote.
+ * - $voted: Set to TRUE if the user voted for this choice.
+ *
+ * @see template_preprocess_poll_bar()
+ */
+?>
+
+<div class="text"><?php print $title; ?></div>
+<div class="bar">
+ <div style="width: <?php print $percentage; ?>%;" class="foreground"></div>
+</div>
+<div class="percent">
+ <?php print $percentage; ?>% (<?php print format_plural($votes, '1 vote', '@count votes'); ?>)
+</div>
diff --git a/core/modules/poll/poll-results--block.tpl.php b/core/modules/poll/poll-results--block.tpl.php
new file mode 100644
index 000000000000..f8387f5657c8
--- /dev/null
+++ b/core/modules/poll/poll-results--block.tpl.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * @file
+ * Default theme implementation to display the poll results in a block.
+ *
+ * Variables available:
+ * - $title: The title of the poll.
+ * - $results: The results of the poll.
+ * - $votes: The total results in the poll.
+ * - $links: Links in the poll.
+ * - $nid: The nid of the poll
+ * - $cancel_form: A form to cancel the user's vote, if allowed.
+ * - $raw_links: The raw array of links. Should be run through theme('links')
+ * if used.
+ * - $vote: The choice number of the current user's vote.
+ *
+ * @see template_preprocess_poll_results()
+ */
+?>
+
+<div class="poll">
+ <div class="title"><?php print $title ?></div>
+ <?php print $results ?>
+ <div class="total">
+ <?php print t('Total votes: @votes', array('@votes' => $votes)); ?>
+ </div>
+</div>
+<div class="links"><?php print $links; ?></div>
diff --git a/core/modules/poll/poll-results.tpl.php b/core/modules/poll/poll-results.tpl.php
new file mode 100644
index 000000000000..5e14dec21e42
--- /dev/null
+++ b/core/modules/poll/poll-results.tpl.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display the poll results in a block.
+ *
+ * Variables available:
+ * - $title: The title of the poll.
+ * - $results: The results of the poll.
+ * - $votes: The total results in the poll.
+ * - $links: Links in the poll.
+ * - $nid: The nid of the poll
+ * - $cancel_form: A form to cancel the user's vote, if allowed.
+ * - $raw_links: The raw array of links.
+ * - $vote: The choice number of the current user's vote.
+ *
+ * @see template_preprocess_poll_results()
+ */
+?>
+<div class="poll">
+ <?php print $results; ?>
+ <div class="total">
+ <?php print t('Total votes: @votes', array('@votes' => $votes)); ?>
+ </div>
+ <?php if (!empty($cancel_form)): ?>
+ <?php print $cancel_form; ?>
+ <?php endif; ?>
+</div>
diff --git a/core/modules/poll/poll-rtl.css b/core/modules/poll/poll-rtl.css
new file mode 100644
index 000000000000..14d42e691a76
--- /dev/null
+++ b/core/modules/poll/poll-rtl.css
@@ -0,0 +1,10 @@
+
+.poll .bar .foreground {
+ float: right;
+}
+.poll .percent {
+ text-align: left;
+}
+.poll .vote-form .choices {
+ text-align: right;
+}
diff --git a/core/modules/poll/poll-vote.tpl.php b/core/modules/poll/poll-vote.tpl.php
new file mode 100644
index 000000000000..068ff7c05352
--- /dev/null
+++ b/core/modules/poll/poll-vote.tpl.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display voting form for a poll.
+ *
+ * - $choice: The radio buttons for the choices in the poll.
+ * - $title: The title of the poll.
+ * - $block: True if this is being displayed as a block.
+ * - $vote: The vote button
+ * - $rest: Anything else in the form that may have been added via
+ * form_alter hooks.
+ *
+ * @see template_preprocess_poll_vote()
+ */
+?>
+<div class="poll">
+ <div class="vote-form">
+ <div class="choices">
+ <?php if ($block): ?>
+ <div class="title"><?php print $title; ?></div>
+ <?php endif; ?>
+ <?php print $choice; ?>
+ </div>
+ <?php print $vote; ?>
+ </div>
+ <?php // This is the 'rest' of the form, in case items have been added. ?>
+ <?php print $rest ?>
+</div>
diff --git a/core/modules/poll/poll.css b/core/modules/poll/poll.css
new file mode 100644
index 000000000000..8b04e380911e
--- /dev/null
+++ b/core/modules/poll/poll.css
@@ -0,0 +1,51 @@
+
+.poll {
+ overflow: hidden;
+}
+.poll .bar {
+ height: 1em;
+ margin: 1px 0;
+ background-color: #ddd;
+}
+.poll .bar .foreground {
+ background-color: #000;
+ height: 1em;
+ float: left; /* LTR */
+}
+.poll .links {
+ text-align: center;
+}
+.poll .percent {
+ text-align: right; /* LTR */
+}
+.poll .total {
+ text-align: center;
+}
+.poll .vote-form {
+ text-align: center;
+}
+.poll .vote-form .choices {
+ text-align: left; /* LTR */
+ margin: 0 auto;
+ display: table;
+}
+.poll .vote-form .choices .title {
+ font-weight: bold;
+}
+.node-form #edit-poll-more {
+ margin: 0;
+}
+.node-form #poll-choice-table .form-text {
+ display: inline;
+ width: auto;
+}
+.node-form #poll-choice-table td.choice-flag {
+ white-space: nowrap;
+ width: 4em;
+}
+td.poll-chtext {
+ width: 80%;
+}
+td.poll-chvotes .form-text {
+ width: 85%;
+}
diff --git a/core/modules/poll/poll.info b/core/modules/poll/poll.info
new file mode 100644
index 000000000000..de6ac250cee6
--- /dev/null
+++ b/core/modules/poll/poll.info
@@ -0,0 +1,7 @@
+name = Poll
+description = Allows your site to capture votes on different topics in the form of multiple choice questions.
+package = Core
+version = VERSION
+core = 8.x
+files[] = poll.test
+stylesheets[all][] = poll.css
diff --git a/core/modules/poll/poll.install b/core/modules/poll/poll.install
new file mode 100644
index 000000000000..c848445fc5db
--- /dev/null
+++ b/core/modules/poll/poll.install
@@ -0,0 +1,149 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the poll module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function poll_schema() {
+ $schema['poll'] = array(
+ 'description' => 'Stores poll-specific information for poll nodes.',
+ 'fields' => array(
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "The poll's {node}.nid.",
+ ),
+ 'runtime' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The number of seconds past {node}.created during which the poll is open.',
+ ),
+ 'active' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Boolean indicating whether or not the poll is open.',
+ ),
+ ),
+ 'primary key' => array('nid'),
+ 'foreign keys' => array(
+ 'poll_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ ),
+ );
+
+ $schema['poll_choice'] = array(
+ 'description' => 'Stores information about all choices for all {poll}s.',
+ 'fields' => array(
+ 'chid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Unique identifier for a poll choice.',
+ ),
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {node}.nid this choice belongs to.',
+ ),
+ 'chtext' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The text for this choice.',
+ 'translatable' => TRUE,
+ ),
+ 'chvotes' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The total number of votes this choice has received by all users.',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The sort order of this choice among all choices for the same node.',
+ ),
+ ),
+ 'indexes' => array(
+ 'nid' => array('nid'),
+ ),
+ 'primary key' => array('chid'),
+ 'foreign keys' => array(
+ 'choice_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ ),
+ );
+
+ $schema['poll_vote'] = array(
+ 'description' => 'Stores per-{users} votes for each {poll}.',
+ 'fields' => array(
+ 'chid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => "The {users}'s vote for this poll.",
+ ),
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'The {poll} node this vote is for.',
+ ),
+ 'uid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {users}.uid this vote is from unless the voter was anonymous.',
+ ),
+ 'hostname' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The IP address this vote is from unless the voter was logged in.',
+ ),
+ 'timestamp' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The timestamp of the vote creation.',
+ ),
+ ),
+ 'primary key' => array('nid', 'uid', 'hostname'),
+ 'foreign keys' => array(
+ 'poll_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'voter' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ 'indexes' => array(
+ 'chid' => array('chid'),
+ 'hostname' => array('hostname'),
+ 'uid' => array('uid'),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/poll/poll.module b/core/modules/poll/poll.module
new file mode 100644
index 000000000000..ec5452e34ebe
--- /dev/null
+++ b/core/modules/poll/poll.module
@@ -0,0 +1,1010 @@
+<?php
+
+/**
+ * @file
+ * Enables your site to capture votes on different topics in the form of multiple
+ * choice questions.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function poll_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#poll':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Poll module can be used to create simple surveys or questionnaires that display cumulative results. A poll is a good way to receive feedback from site users and community members. For more information, see the online handbook entry for the <a href="@poll">Poll module</a>.', array('@poll' => 'http://drupal.org/handbook/modules/poll/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Creating a poll') . '</dt>';
+ $output .= '<dd>' . t('Users can create a poll by clicking on Poll on the <a href="@add-content">Add new content</a> page, and entering the question being posed, the answer choices, and beginning vote counts for each choice. The status (closed or active) and duration (length of time the poll remains active for new votes) can also be specified.', array('@add-content' => url('node/add'))) . '</dd>';
+ $output .= '<dt>' . t('Viewing polls') . '</dt>';
+ $output .= '<dd>' . t('You can visit the <a href="@poll">Polls</a> page to view all current polls, or alternately enable the <em>Most recent poll</em> block on the <a href="@blocks">Blocks administration page</a>. To vote in or view the results of a specific poll, you can click on the poll itself.', array('@poll' => url('poll'), '@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function poll_theme() {
+ $theme_hooks = array(
+ 'poll_vote' => array(
+ 'template' => 'poll-vote',
+ 'render element' => 'form',
+ ),
+ 'poll_choices' => array(
+ 'render element' => 'form',
+ ),
+ 'poll_results' => array(
+ 'template' => 'poll-results',
+ 'variables' => array('raw_title' => NULL, 'results' => NULL, 'votes' => NULL, 'raw_links' => NULL, 'block' => NULL, 'nid' => NULL, 'vote' => NULL),
+ ),
+ 'poll_bar' => array(
+ 'template' => 'poll-bar',
+ 'variables' => array('title' => NULL, 'votes' => NULL, 'total_votes' => NULL, 'vote' => NULL, 'block' => NULL),
+ ),
+ );
+ // The theme system automatically discovers the theme's functions and
+ // templates that implement more targeted "suggestions" of generic theme
+ // hooks. But suggestions implemented by a module must be explicitly
+ // registered.
+ $theme_hooks += array(
+ 'poll_results__block' => array(
+ 'template' => 'poll-results--block',
+ 'variables' => $theme_hooks['poll_results']['variables'],
+ ),
+ 'poll_bar__block' => array(
+ 'template' => 'poll-bar--block',
+ 'variables' => $theme_hooks['poll_bar']['variables'],
+ ),
+ );
+ return $theme_hooks;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function poll_permission() {
+ $perms = array(
+ 'vote on polls' => array(
+ 'title' => t('Vote on polls'),
+ ),
+ 'cancel own vote' => array(
+ 'title' => t('Cancel and change own votes'),
+ ),
+ 'inspect all votes' => array(
+ 'title' => t('View voting results'),
+ ),
+ );
+
+ return $perms;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function poll_menu() {
+ $items['poll'] = array(
+ 'title' => 'Polls',
+ 'page callback' => 'poll_page',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ 'file' => 'poll.pages.inc',
+ );
+
+ $items['node/%node/votes'] = array(
+ 'title' => 'Votes',
+ 'page callback' => 'poll_votes',
+ 'page arguments' => array(1),
+ 'access callback' => '_poll_menu_access',
+ 'access arguments' => array(1, 'inspect all votes', FALSE),
+ 'weight' => 3,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'poll.pages.inc',
+ );
+
+ $items['node/%node/results'] = array(
+ 'title' => 'Results',
+ 'page callback' => 'poll_results',
+ 'page arguments' => array(1),
+ 'access callback' => '_poll_menu_access',
+ 'access arguments' => array(1, 'access content', TRUE),
+ 'weight' => 3,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'poll.pages.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Callback function to see if a node is acceptable for poll menu items.
+ */
+function _poll_menu_access($node, $perm, $inspect_allowvotes) {
+ return user_access($perm) && ($node->type == 'poll') && ($node->allowvotes || !$inspect_allowvotes);
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function poll_block_info() {
+ $blocks['recent']['info'] = t('Most recent poll');
+ $blocks['recent']['properties']['administrative'] = TRUE;
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generates a block containing the latest poll.
+ */
+function poll_block_view($delta = '') {
+ if (user_access('access content')) {
+ // Retrieve the latest poll.
+ $select = db_select('node', 'n');
+ $select->join('poll', 'p', 'p.nid = n.nid');
+ $select->fields('n', array('nid'))
+ ->condition('n.status', 1)
+ ->condition('p.active', 1)
+ ->orderBy('n.created', 'DESC')
+ ->range(0, 1)
+ ->addTag('node_access');
+
+ $record = $select->execute()->fetchObject();
+ if ($record) {
+ $poll = node_load($record->nid);
+ if ($poll->nid) {
+ $poll = poll_block_latest_poll_view($poll);
+ $block['subject'] = t('Poll');
+ $block['content'] = $poll->content;
+ return $block;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Closes polls that have exceeded their allowed runtime.
+ */
+function poll_cron() {
+ $nids = db_query('SELECT p.nid FROM {poll} p INNER JOIN {node} n ON p.nid = n.nid WHERE (n.created + p.runtime) < :request_time AND p.active = :active AND p.runtime <> :runtime', array(':request_time' => REQUEST_TIME, ':active' => 1, ':runtime' => 0))->fetchCol();
+ if (!empty($nids)) {
+ db_update('poll')
+ ->fields(array('active' => 0))
+ ->condition('nid', $nids, 'IN')
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_node_info().
+ */
+function poll_node_info() {
+ return array(
+ 'poll' => array(
+ 'name' => t('Poll'),
+ 'base' => 'poll',
+ 'description' => t('A <em>poll</em> is a question with a set of possible responses. A <em>poll</em>, once created, automatically provides a simple running count of the number of votes received for each response.'),
+ 'title_label' => t('Question'),
+ 'has_body' => FALSE,
+ )
+ );
+}
+
+/**
+ * Implements hook_field_extra_fields().
+ */
+function poll_field_extra_fields() {
+ $extra['node']['poll'] = array(
+ 'form' => array(
+ 'choice_wrapper' => array(
+ 'label' => t('Poll choices'),
+ 'description' => t('Poll choices'),
+ 'weight' => -4,
+ ),
+ 'settings' => array(
+ 'label' => t('Poll settings'),
+ 'description' => t('Poll module settings'),
+ 'weight' => -3,
+ ),
+ ),
+ 'display' => array(
+ 'poll_view_voting' => array(
+ 'label' => t('Poll vote'),
+ 'description' => t('Poll vote'),
+ 'weight' => 0,
+ ),
+ 'poll_view_results' => array(
+ 'label' => t('Poll results'),
+ 'description' => t('Poll results'),
+ 'weight' => 0,
+ ),
+ )
+ );
+
+ return $extra;
+}
+
+/**
+ * Implements hook_form().
+ */
+function poll_form($node, &$form_state) {
+ global $user;
+
+ $admin = user_access('bypass node access') || user_access('edit any poll content') || (user_access('edit own poll content') && $user->uid == $node->uid);
+
+ $type = node_type_get_type($node);
+
+ // The submit handlers to add more poll choices require that this form is
+ // cached, regardless of whether Ajax is used.
+ $form_state['cache'] = TRUE;
+
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => check_plain($type->title_label),
+ '#required' => TRUE,
+ '#default_value' => $node->title,
+ '#weight' => -5,
+ );
+
+ if (isset($form_state['choice_count'])) {
+ $choice_count = $form_state['choice_count'];
+ }
+ else {
+ $choice_count = max(2, empty($node->choice) ? 2 : count($node->choice));
+ }
+
+ // Add a wrapper for the choices and more button.
+ $form['choice_wrapper'] = array(
+ '#tree' => FALSE,
+ '#weight' => -4,
+ '#prefix' => '<div class="clearfix" id="poll-choice-wrapper">',
+ '#suffix' => '</div>',
+ );
+
+ // Container for just the poll choices.
+ $form['choice_wrapper']['choice'] = array(
+ '#prefix' => '<div id="poll-choices">',
+ '#suffix' => '</div>',
+ '#theme' => 'poll_choices',
+ );
+
+ // Add the current choices to the form.
+ $delta = 0;
+ $weight = 0;
+ if (isset($node->choice)) {
+ $delta = count($node->choice);
+ foreach ($node->choice as $chid => $choice) {
+ $key = 'chid:' . $chid;
+ $form['choice_wrapper']['choice'][$key] = _poll_choice_form($key, $choice['chid'], $choice['chtext'], $choice['chvotes'], $choice['weight'], $choice_count);
+ $weight = max($choice['weight'], $weight);
+ }
+ }
+
+ // Add initial or additional choices.
+ $existing_delta = $delta;
+ $weight++;
+ for ($delta; $delta < $choice_count; $delta++) {
+ $key = 'new:' . ($delta - $existing_delta);
+ $form['choice_wrapper']['choice'][$key] = _poll_choice_form($key, NULL, '', 0, $weight, $choice_count);
+ }
+
+ // We name our button 'poll_more' to avoid conflicts with other modules using
+ // Ajax-enabled buttons with the id 'more'.
+ $form['choice_wrapper']['poll_more'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add another choice'),
+ '#weight' => 1,
+ '#limit_validation_errors' => array(array('choice')),
+ '#submit' => array('poll_more_choices_submit'),
+ '#ajax' => array(
+ 'callback' => 'poll_choice_js',
+ 'wrapper' => 'poll-choices',
+ 'effect' => 'fade',
+ ),
+ );
+
+ // Poll attributes
+ $duration = array(
+ // 1-6 days.
+ 86400, 2 * 86400, 3 * 86400, 4 * 86400, 5 * 86400, 6 * 86400,
+ // 1-3 weeks (7 days).
+ 604800, 2 * 604800, 3 * 604800,
+ // 1-3,6,9 months (30 days).
+ 2592000, 2 * 2592000, 3 * 2592000, 6 * 2592000, 9 * 2592000,
+ // 1 year (365 days).
+ 31536000,
+ );
+ $duration = array(0 => t('Unlimited')) + drupal_map_assoc($duration, 'format_interval');
+ $active = array(0 => t('Closed'), 1 => t('Active'));
+
+ $form['settings'] = array(
+ '#type' => 'fieldset',
+ '#collapsible' => TRUE,
+ '#title' => t('Poll settings'),
+ '#weight' => -3,
+ '#access' => $admin,
+ );
+
+ $form['settings']['active'] = array(
+ '#type' => 'radios',
+ '#title' => t('Poll status'),
+ '#default_value' => isset($node->active) ? $node->active : 1,
+ '#options' => $active,
+ '#description' => t('When a poll is closed, visitors can no longer vote for it.'),
+ '#access' => $admin,
+ );
+ $form['settings']['runtime'] = array(
+ '#type' => 'select',
+ '#title' => t('Poll duration'),
+ '#default_value' => isset($node->runtime) ? $node->runtime : 0,
+ '#options' => $duration,
+ '#description' => t('After this period, the poll will be closed automatically.'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler to add more choices to a poll form.
+ *
+ * This handler is run regardless of whether JS is enabled or not. It makes
+ * changes to the form state. If the button was clicked with JS disabled, then
+ * the page is reloaded with the complete rebuilt form. If the button was
+ * clicked with JS enabled, then ajax_form_callback() calls poll_choice_js() to
+ * return just the changed part of the form.
+ */
+function poll_more_choices_submit($form, &$form_state) {
+ // Add one more choice to the form.
+ if ($form_state['values']['poll_more']) {
+ $form_state['choice_count'] = count($form_state['values']['choice']) + 1;
+ }
+ // Renumber the choices. This invalidates the corresponding key/value
+ // associations in $form_state['input'], so clear that out. This requires
+ // poll_form() to rebuild the choices with the values in
+ // $form_state['node']->choice, which it does.
+ $form_state['node']->choice = array_values($form_state['values']['choice']);
+ unset($form_state['input']['choice']);
+ $form_state['rebuild'] = TRUE;
+}
+
+function _poll_choice_form($key, $chid = NULL, $value = '', $votes = 0, $weight = 0, $size = 10) {
+ $form = array(
+ '#tree' => TRUE,
+ '#weight' => $weight,
+ );
+
+ // We'll manually set the #parents property of these fields so that
+ // their values appear in the $form_state['values']['choice'] array.
+ $form['chid'] = array(
+ '#type' => 'value',
+ '#value' => $chid,
+ '#parents' => array('choice', $key, 'chid'),
+ );
+
+ $form['chtext'] = array(
+ '#type' => 'textfield',
+ '#title' => $value !== '' ? t('Choice label') : t('New choice label'),
+ '#title_display' => 'invisible',
+ '#default_value' => $value,
+ '#parents' => array('choice', $key, 'chtext'),
+ );
+
+ $form['chvotes'] = array(
+ '#type' => 'textfield',
+ '#title' => $value !== '' ? t('Vote count for choice @label', array('@label' => $value)) : t('Vote count for new choice'),
+ '#title_display' => 'invisible',
+ '#default_value' => $votes,
+ '#size' => 5,
+ '#maxlength' => 7,
+ '#parents' => array('choice', $key, 'chvotes'),
+ '#access' => user_access('administer nodes'),
+ );
+
+ $form['weight'] = array(
+ '#type' => 'weight',
+ '#title' => $value !== '' ? t('Weight for choice @label', array('@label' => $value)) : t('Weight for new choice'),
+ '#title_display' => 'invisible',
+ '#default_value' => $weight,
+ '#delta' => $size,
+ '#parents' => array('choice', $key, 'weight'),
+ );
+
+ return $form;
+}
+
+/**
+ * Ajax callback in response to new choices being added to the form.
+ *
+ * This returns the new page content to replace the page content made obsolete
+ * by the form submission.
+ *
+ * @see poll_more_choices_submit()
+ */
+function poll_choice_js($form, $form_state) {
+ return $form['choice_wrapper']['choice'];
+}
+
+/**
+ * Form submit handler for node_form().
+ *
+ * Upon preview and final submission, we need to renumber poll choices and
+ * create a teaser output.
+ */
+function poll_node_form_submit(&$form, &$form_state) {
+ // Renumber choices.
+ $form_state['values']['choice'] = array_values($form_state['values']['choice']);
+ $form_state['values']['teaser'] = poll_teaser((object) $form_state['values']);
+}
+
+/**
+ * Implements hook_validate().
+ */
+function poll_validate($node, $form) {
+ if (isset($node->title)) {
+ // Check for at least two options and validate amount of votes:
+ $realchoices = 0;
+ // Renumber fields
+ $node->choice = array_values($node->choice);
+ foreach ($node->choice as $i => $choice) {
+ if ($choice['chtext'] != '') {
+ $realchoices++;
+ }
+ if (isset($choice['chvotes']) && $choice['chvotes'] < 0) {
+ form_set_error("choice][$i][chvotes", t('Negative values are not allowed.'));
+ }
+ }
+
+ if ($realchoices < 2) {
+ form_set_error("choice][$realchoices][chtext", t('You must fill in at least two choices.'));
+ }
+ }
+}
+
+/**
+ * Implements hook_field_attach_prepare_translation_alter().
+ */
+function poll_field_attach_prepare_translation_alter(&$entity, $context) {
+ if ($context['entity_type'] == 'node' && $entity->type == 'poll') {
+ $entity->choice = $context['source_entity']->choice;
+ }
+}
+
+/**
+ * Implements hook_load().
+ */
+function poll_load($nodes) {
+ global $user;
+ foreach ($nodes as $node) {
+ $poll = db_query("SELECT runtime, active FROM {poll} WHERE nid = :nid", array(':nid' => $node->nid))->fetchObject();
+
+ if (empty($poll)) {
+ $poll = new stdClass();
+ }
+
+ // Load the appropriate choices into the $poll object.
+ $poll->choice = db_select('poll_choice', 'c')
+ ->addTag('translatable')
+ ->fields('c', array('chid', 'chtext', 'chvotes', 'weight'))
+ ->condition('c.nid', $node->nid)
+ ->orderBy('weight')
+ ->execute()->fetchAllAssoc('chid', PDO::FETCH_ASSOC);
+
+ // Determine whether or not this user is allowed to vote.
+ $poll->allowvotes = FALSE;
+ if (user_access('vote on polls') && $poll->active) {
+ if ($user->uid) {
+ // If authenticated, find existing vote based on uid.
+ $poll->vote = db_query('SELECT chid FROM {poll_vote} WHERE nid = :nid AND uid = :uid', array(':nid' => $node->nid, ':uid' => $user->uid))->fetchField();
+ if (empty($poll->vote)) {
+ $poll->vote = -1;
+ $poll->allowvotes = TRUE;
+ }
+ }
+ elseif (!empty($_SESSION['poll_vote'][$node->nid])) {
+ // Otherwise the user is anonymous. Look for an existing vote in the
+ // user's session.
+ $poll->vote = $_SESSION['poll_vote'][$node->nid];
+ }
+ else {
+ // Finally, query the database for an existing vote based on anonymous
+ // user's hostname.
+ $poll->allowvotes = !db_query("SELECT 1 FROM {poll_vote} WHERE nid = :nid AND hostname = :hostname AND uid = 0", array(':nid' => $node->nid, ':hostname' => ip_address()))->fetchField();
+ }
+ }
+ foreach ($poll as $key => $value) {
+ $nodes[$node->nid]->$key = $value;
+ }
+ }
+}
+
+/**
+ * Implements hook_insert().
+ */
+function poll_insert($node) {
+ if (!user_access('administer nodes')) {
+ // Make sure all votes are 0 initially
+ foreach ($node->choice as $i => $choice) {
+ $node->choice[$i]['chvotes'] = 0;
+ }
+ $node->active = 1;
+ }
+
+ db_insert('poll')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'runtime' => $node->runtime,
+ 'active' => $node->active,
+ ))
+ ->execute();
+
+ foreach ($node->choice as $choice) {
+ if ($choice['chtext'] != '') {
+ db_insert('poll_choice')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'chtext' => $choice['chtext'],
+ 'chvotes' => $choice['chvotes'],
+ 'weight' => $choice['weight'],
+ ))
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_update().
+ */
+function poll_update($node) {
+ // Update poll settings.
+ db_update('poll')
+ ->fields(array(
+ 'runtime' => $node->runtime,
+ 'active' => $node->active,
+ ))
+ ->condition('nid', $node->nid)
+ ->execute();
+
+ // Poll choices with empty titles signifies removal. We remove all votes to
+ // the removed options, so people who voted on them can vote again.
+ foreach ($node->choice as $key => $choice) {
+ if (!empty($choice['chtext'])) {
+ db_merge('poll_choice')
+ ->key(array('chid' => $choice['chid']))
+ ->fields(array(
+ 'chtext' => $choice['chtext'],
+ 'chvotes' => (int) $choice['chvotes'],
+ 'weight' => $choice['weight'],
+ ))
+ ->insertFields(array(
+ 'nid' => $node->nid,
+ 'chtext' => $choice['chtext'],
+ ))
+ ->execute();
+ }
+ else {
+ db_delete('poll_vote')
+ ->condition('nid', $node->nid)
+ ->condition('chid', $key)
+ ->execute();
+ db_delete('poll_choice')
+ ->condition('nid', $node->nid)
+ ->condition('chid', $choice['chid'])
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_delete().
+ */
+function poll_delete($node) {
+ db_delete('poll')
+ ->condition('nid', $node->nid)
+ ->execute();
+ db_delete('poll_choice')
+ ->condition('nid', $node->nid)
+ ->execute();
+ db_delete('poll_vote')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Return content for 'latest poll' block.
+ *
+ * @param $node
+ * The node object to load.
+ */
+function poll_block_latest_poll_view($node) {
+ global $user;
+ $output = '';
+
+ // This is necessary for shared objects because PHP doesn't copy objects, but
+ // passes them by reference. So when the objects are cached it can result in
+ // the wrong output being displayed on subsequent calls. The cloning and
+ // unsetting of $node->content prevents the block output from being the same
+ // as the node output.
+ $node = clone $node;
+ unset($node->content);
+
+ // No 'read more' link.
+ $node->readmore = FALSE;
+ $node->teaser = '';
+
+ $links = array();
+ $links[] = array('title' => t('Older polls'), 'href' => 'poll', 'attributes' => array('title' => t('View the list of polls on this site.')));
+ if ($node->allowvotes) {
+ $links[] = array('title' => t('Results'), 'href' => 'node/' . $node->nid . '/results', 'attributes' => array('title' => t('View the current poll results.')));
+ }
+
+ $node->links = $links;
+
+ if (!empty($node->allowvotes)) {
+ $node->content['poll_view_voting'] = drupal_get_form('poll_view_voting', $node, TRUE);
+ $node->content['links'] = array(
+ '#theme' => 'links',
+ '#links' => $node->links,
+ '#weight' => 5,
+ );
+ }
+ else {
+ $node->content['poll_view_results'] = array('#markup' => poll_view_results($node, TRUE, TRUE));
+ }
+
+ return $node;
+}
+
+
+/**
+ * Implements hook_view().
+ */
+function poll_view($node, $view_mode) {
+ global $user;
+ $output = '';
+
+ if (!empty($node->allowvotes) && empty($node->show_results)) {
+ $node->content['poll_view_voting'] = drupal_get_form('poll_view_voting', $node);
+ }
+ else {
+ $node->content['poll_view_results'] = array('#markup' => poll_view_results($node, $view_mode));
+ }
+ return $node;
+}
+
+/**
+ * Creates a simple teaser that lists all the choices.
+ *
+ * This is primarily used for RSS.
+ */
+function poll_teaser($node) {
+ $teaser = NULL;
+ if (is_array($node->choice)) {
+ foreach ($node->choice as $k => $choice) {
+ if ($choice['chtext'] != '') {
+ $teaser .= '* ' . check_plain($choice['chtext']) . "\n";
+ }
+ }
+ }
+ return $teaser;
+}
+
+/**
+ * Generates the voting form for a poll.
+ *
+ * @ingroup forms
+ * @see poll_vote()
+ * @see phptemplate_preprocess_poll_vote()
+ */
+function poll_view_voting($form, &$form_state, $node, $block = FALSE) {
+ if ($node->choice) {
+ $list = array();
+ foreach ($node->choice as $i => $choice) {
+ $list[$i] = check_plain($choice['chtext']);
+ }
+ $form['choice'] = array(
+ '#type' => 'radios',
+ '#title' => t('Choices'),
+ '#title_display' => 'invisible',
+ '#default_value' => -1,
+ '#options' => $list,
+ );
+ }
+
+ $form['vote'] = array(
+ '#type' => 'submit',
+ '#value' => t('Vote'),
+ '#submit' => array('poll_vote'),
+ );
+
+ // Store the node so we can get to it in submit functions.
+ $form['#node'] = $node;
+ $form['#block'] = $block;
+
+ // Set form caching because we could have multiple of these forms on
+ // the same page, and we want to ensure the right one gets picked.
+ $form_state['cache'] = TRUE;
+
+ // Provide a more cleanly named voting form theme.
+ $form['#theme'] = 'poll_vote';
+ return $form;
+}
+
+/**
+ * Validation function for processing votes
+ */
+function poll_view_voting_validate($form, &$form_state) {
+ if ($form_state['values']['choice'] == -1) {
+ form_set_error( 'choice', t('Your vote could not be recorded because you did not select any of the choices.'));
+ }
+}
+
+/**
+ * Submit handler for processing a vote.
+ */
+function poll_vote($form, &$form_state) {
+ $node = $form['#node'];
+ $choice = $form_state['values']['choice'];
+
+ global $user;
+ db_insert('poll_vote')
+ ->fields(array(
+ 'nid' => $node->nid,
+ 'chid' => $choice,
+ 'uid' => $user->uid,
+ 'hostname' => ip_address(),
+ 'timestamp' => REQUEST_TIME,
+ ))
+ ->execute();
+
+ // Add one to the votes.
+ db_update('poll_choice')
+ ->expression('chvotes', 'chvotes + 1')
+ ->condition('chid', $choice)
+ ->execute();
+
+ cache_clear_all();
+
+ if (!$user->uid) {
+ // The vote is recorded so the user gets the result view instead of the
+ // voting form when viewing the poll. Saving a value in $_SESSION has the
+ // convenient side effect of preventing the user from hitting the page
+ // cache. When anonymous voting is allowed, the page cache should only
+ // contain the voting form, not the results.
+ $_SESSION['poll_vote'][$node->nid] = $choice;
+ }
+
+ drupal_set_message(t('Your vote was recorded.'));
+
+ // Return the user to whatever page they voted from.
+}
+
+/**
+ * Themes the voting form for a poll.
+ *
+ * Inputs: $form
+ */
+function template_preprocess_poll_vote(&$variables) {
+ $form = $variables['form'];
+ $variables['choice'] = drupal_render($form['choice']);
+ $variables['title'] = check_plain($form['#node']->title);
+ $variables['vote'] = drupal_render($form['vote']);
+ $variables['rest'] = drupal_render_children($form);
+ $variables['block'] = $form['#block'];
+ if ($variables['block']) {
+ $variables['theme_hook_suggestions'][] = 'poll_vote__block';
+ }
+}
+
+/**
+ * Generates a graphical representation of the results of a poll.
+ */
+function poll_view_results($node, $view_mode, $block = FALSE) {
+ // Make sure that choices are ordered by their weight.
+ uasort($node->choice, 'drupal_sort_weight');
+
+ // Count the votes and find the maximum
+ $total_votes = 0;
+ $max_votes = 0;
+ foreach ($node->choice as $choice) {
+ if (isset($choice['chvotes'])) {
+ $total_votes += $choice['chvotes'];
+ $max_votes = max($max_votes, $choice['chvotes']);
+ }
+ }
+
+ $poll_results = '';
+ foreach ($node->choice as $i => $choice) {
+ if (!empty($choice['chtext'])) {
+ $chvotes = isset($choice['chvotes']) ? $choice['chvotes'] : NULL;
+ $poll_results .= theme('poll_bar', array('title' => $choice['chtext'], 'votes' => $chvotes, 'total_votes' => $total_votes, 'vote' => isset($node->vote) && $node->vote == $i, 'block' => $block));
+ }
+ }
+
+ return theme('poll_results', array('raw_title' => $node->title, 'results' => $poll_results, 'votes' => $total_votes, 'raw_links' => isset($node->links) ? $node->links : array(), 'block' => $block, 'nid' => $node->nid, 'vote' => isset($node->vote) ? $node->vote : NULL));
+}
+
+
+/**
+ * Returns HTML for an admin poll form for choices.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_poll_choices($variables) {
+ $form = $variables['form'];
+
+ drupal_add_tabledrag('poll-choice-table', 'order', 'sibling', 'poll-weight');
+
+ $is_admin= user_access('administer nodes');
+ $delta = 0;
+ $rows = array();
+ $headers = array('', t('Choice'));
+ if ($is_admin) {
+ $headers[] = t('Vote count');
+ }
+ $headers[] = t('Weight');
+
+ foreach (element_children($form) as $key) {
+ $delta++;
+ // Set special classes for drag and drop updating.
+ $form[$key]['weight']['#attributes']['class'] = array('poll-weight');
+
+ // Build the table row.
+ $row = array(
+ 'data' => array(
+ array('class' => array('choice-flag')),
+ drupal_render($form[$key]['chtext']),
+ ),
+ 'class' => array('draggable'),
+ );
+ if ($is_admin) {
+ $row['data'][] = drupal_render($form[$key]['chvotes']);
+ }
+ $row['data'][] = drupal_render($form[$key]['weight']);
+
+ // Add any additional classes set on the row.
+ if (!empty($form[$key]['#attributes']['class'])) {
+ $row['class'] = array_merge($row['class'], $form[$key]['#attributes']['class']);
+ }
+
+ $rows[] = $row;
+ }
+
+ $output = theme('table', array('header' => $headers, 'rows' => $rows, 'attributes' => array('id' => 'poll-choice-table')));
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+/**
+ * Preprocess the poll_results theme hook.
+ *
+ * Inputs: $raw_title, $results, $votes, $raw_links, $block, $nid, $vote. The
+ * $raw_* inputs to this are naturally unsafe; often safe versions are
+ * made to simply overwrite the raw version, but in this case it seems likely
+ * that the title and the links may be overridden by the theme layer, so they
+ * are left in with a different name for that purpose.
+ *
+ * @see poll-results.tpl.php
+ * @see poll-results--block.tpl.php
+ */
+function template_preprocess_poll_results(&$variables) {
+ $variables['links'] = theme('links__poll_results', array('links' => $variables['raw_links']));
+ if (isset($variables['vote']) && $variables['vote'] > -1 && user_access('cancel own vote')) {
+ $elements = drupal_get_form('poll_cancel_form', $variables['nid']);
+ $variables['cancel_form'] = drupal_render($elements);
+ }
+ $variables['title'] = check_plain($variables['raw_title']);
+
+ if ($variables['block']) {
+ $variables['theme_hook_suggestions'][] = 'poll_results__block';
+ }
+}
+
+/**
+ * Preprocess the poll_bar theme hook.
+ *
+ * Inputs: $title, $votes, $total_votes, $voted, $block
+ *
+ * @see poll-bar.tpl.php
+ * @see poll-bar--block.tpl.php
+ * @see theme_poll_bar()
+ */
+function template_preprocess_poll_bar(&$variables) {
+ if ($variables['block']) {
+ $variables['theme_hook_suggestions'][] = 'poll_bar__block';
+ }
+ $variables['title'] = check_plain($variables['title']);
+ $variables['percentage'] = round($variables['votes'] * 100 / max($variables['total_votes'], 1));
+}
+
+/**
+ * Builds the cancel form for a poll.
+ *
+ * @ingroup forms
+ * @see poll_cancel()
+ */
+function poll_cancel_form($form, &$form_state, $nid) {
+ $form_state['cache'] = TRUE;
+
+ // Store the nid so we can get to it in submit functions.
+ $form['#nid'] = $nid;
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Cancel your vote'),
+ '#submit' => array('poll_cancel')
+ );
+
+ return $form;
+}
+
+/**
+ * Submit callback for poll_cancel_form().
+ */
+function poll_cancel($form, &$form_state) {
+ global $user;
+ $node = node_load($form['#nid']);
+
+ db_delete('poll_vote')
+ ->condition('nid', $node->nid)
+ ->condition($user->uid ? 'uid' : 'hostname', $user->uid ? $user->uid : ip_address())
+ ->execute();
+
+ // Subtract from the votes.
+ db_update('poll_choice')
+ ->expression('chvotes', 'chvotes - 1')
+ ->condition('chid', $node->vote)
+ ->execute();
+
+ unset($_SESSION['poll_vote'][$node->nid]);
+
+ drupal_set_message(t('Your vote was cancelled.'));
+}
+
+/**
+ * Implements hook_user_cancel().
+ */
+function poll_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_reassign':
+ db_update('poll_vote')
+ ->fields(array('uid' => 0))
+ ->condition('uid', $account->uid)
+ ->execute();
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function poll_user_delete($account) {
+ db_delete('poll_vote')
+ ->condition('uid', $account->uid)
+ ->execute();
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function poll_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'node',
+ 'bundle' => 'poll',
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post', 'sioct:Poll'),
+ ),
+ ),
+ );
+}
diff --git a/core/modules/poll/poll.pages.inc b/core/modules/poll/poll.pages.inc
new file mode 100644
index 000000000000..15f3ba790554
--- /dev/null
+++ b/core/modules/poll/poll.pages.inc
@@ -0,0 +1,97 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the poll module.
+ */
+
+/**
+ * Menu callback to provide a simple list of all polls available.
+ */
+function poll_page() {
+ $polls_per_page = 15;
+
+ $count_select = db_select('node', 'n');
+ $count_select->addExpression('COUNT(*)', 'expression');
+ $count_select->join('poll', 'p', 'p.nid = n.nid');
+ $count_select->condition('n.status', 1);
+
+ // List all polls.
+ $select = db_select('node', 'n');
+ $select->join('poll', 'p', 'p.nid = n.nid');
+ $select->join('poll_choice', 'c', 'c.nid = n.nid');
+ $select->addExpression('SUM(c.chvotes)', 'votes');
+ $select = $select->fields('n', array('nid', 'title', 'created'))
+ ->fields('p', array('active'))
+ ->condition('n.status', 1)
+ ->orderBy('n.created', 'DESC')
+ ->groupBy('n.nid')
+ ->groupBy('n.title')
+ ->groupBy('p.active')
+ ->groupBy('n.created')
+ ->extend('PagerDefault')
+ ->limit($polls_per_page)
+ ->addTag('node_access');
+ $select->setCountQuery($count_select);
+ $queried_nodes = $select->execute()
+ ->fetchAllAssoc('nid');
+
+ $output = '<ul>';
+ foreach ($queried_nodes as $node) {
+ $output .= '<li>' . l($node->title, "node/$node->nid") . ' - ' . format_plural($node->votes, '1 vote', '@count votes') . ' - ' . ($node->active ? t('open') : t('closed')) . '</li>';
+ }
+ $output .= '</ul>';
+ $output .= theme('pager');
+ return $output;
+}
+
+/**
+ * Callback for the 'votes' tab for polls you can see other votes on
+ */
+function poll_votes($node) {
+ $votes_per_page = 20;
+ drupal_set_title($node->title);
+
+ $header[] = array('data' => t('Visitor'), 'field' => 'u.name');
+ $header[] = array('data' => t('Vote'), 'field' => 'pc.chtext');
+ $header[] = array('data' => t('Timestamp'), 'field' => 'pv.timestamp', 'sort' => 'desc');
+
+ $select = db_select('poll_vote', 'pv')->extend('PagerDefault')->extend('TableSort');
+ $select->join('poll_choice', 'pc', 'pv.chid = pc.chid');
+ $select->join('users', 'u', 'pv.uid = u.uid');
+ $queried_votes = $select
+ ->addTag('translatable')
+ ->fields('pv', array('chid', 'uid', 'hostname', 'timestamp', 'nid'))
+ ->fields('pc', array('chtext'))
+ ->fields('u', array('name'))
+ ->condition('pv.nid', $node->nid)
+ ->limit($votes_per_page)
+ ->orderByHeader($header)
+ ->execute();
+
+ $rows = array();
+ foreach ($queried_votes as $vote) {
+ $rows[] = array(
+ $vote->name ? theme('username', array('account' => $vote)) : check_plain($vote->hostname),
+ check_plain($vote->chtext),
+ format_date($vote->timestamp),
+ );
+ }
+ $build['poll_votes_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#prefix' => t('This table lists all the recorded votes for this poll. If anonymous users are allowed to vote, they will be identified by the IP address of the computer they used when they voted.'),
+ );
+ $build['poll_votes_pager'] = array('#theme' => 'pager');
+ return $build;
+}
+
+/**
+ * Callback for the 'results' tab for polls you can vote on
+ */
+function poll_results($node) {
+ drupal_set_title($node->title);
+ $node->show_results = TRUE;
+ return node_show($node);
+}
diff --git a/core/modules/poll/poll.test b/core/modules/poll/poll.test
new file mode 100644
index 000000000000..20a46787ea3e
--- /dev/null
+++ b/core/modules/poll/poll.test
@@ -0,0 +1,784 @@
+<?php
+
+/**
+ * @file
+ * Tests for poll.module.
+ */
+
+class PollTestCase extends DrupalWebTestCase {
+
+ /**
+ * Creates a poll.
+ *
+ * @param string $title
+ * The title of the poll.
+ * @param array $choices
+ * A list of choice labels.
+ * @param boolean $preview
+ * (optional) Whether to test if the preview is working or not. Defaults to
+ * TRUE.
+ *
+ * @return
+ * The node id of the created poll, or FALSE on error.
+ */
+ function pollCreate($title, $choices, $preview = TRUE) {
+ $this->assertTrue(TRUE, 'Create a poll');
+
+ $web_user = $this->drupalCreateUser(array('create poll content', 'access content', 'edit own poll content'));
+ $this->drupalLogin($web_user);
+
+ // Get the form first to initialize the state of the internal browser.
+ $this->drupalGet('node/add/poll');
+
+ // Prepare a form with two choices.
+ list($edit, $index) = $this->_pollGenerateEdit($title, $choices);
+
+ // Re-submit the form until all choices are filled in.
+ if (count($choices) > 2) {
+ while ($index < count($choices)) {
+ $this->drupalPost(NULL, $edit, t('Add another choice'));
+ $this->assertPollChoiceOrder($choices, $index);
+ list($edit, $index) = $this->_pollGenerateEdit($title, $choices, $index);
+ }
+ }
+
+ if ($preview) {
+ $this->drupalPost(NULL, $edit, t('Preview'));
+ $this->assertPollChoiceOrder($choices, $index, TRUE);
+ list($edit, $index) = $this->_pollGenerateEdit($title, $choices, $index);
+ }
+
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $node = $this->drupalGetNodeByTitle($title);
+ $this->assertText(t('@type @title has been created.', array('@type' => node_type_get_name('poll'), '@title' => $title)), 'Poll has been created.');
+ $this->assertTrue($node->nid, t('Poll has been found in the database.'));
+
+ return isset($node->nid) ? $node->nid : FALSE;
+ }
+
+ /**
+ * Generates POST values for the poll node form, specifically poll choices.
+ *
+ * @param $title
+ * The title for the poll node.
+ * @param $choices
+ * An array containing poll choices, as generated by
+ * PollTestCase::_generateChoices().
+ * @param $index
+ * (optional) The amount/number of already submitted poll choices. Defaults
+ * to 0.
+ *
+ * @return
+ * An indexed array containing:
+ * - The generated POST values, suitable for
+ * DrupalWebTestCase::drupalPost().
+ * - The number of poll choices contained in 'edit', for potential re-usage
+ * in subsequent invocations of this function.
+ */
+ function _pollGenerateEdit($title, array $choices, $index = 0) {
+ $max_new_choices = ($index == 0 ? 2 : 1);
+ $already_submitted_choices = array_slice($choices, 0, $index);
+ $new_choices = array_values(array_slice($choices, $index, $max_new_choices));
+
+ $edit = array(
+ 'title' => $title,
+ );
+ foreach ($already_submitted_choices as $k => $text) {
+ $edit['choice[chid:' . $k . '][chtext]'] = $text;
+ }
+ foreach ($new_choices as $k => $text) {
+ $edit['choice[new:' . $k . '][chtext]'] = $text;
+ // To test poll choice weights, every new choice is sorted in front of
+ // existing choices. Existing/already submitted choices should keep their
+ // weight.
+ $edit['choice[new:' . $k . '][weight]'] = (- $index - $k);
+ }
+ return array($edit, count($already_submitted_choices) + count($new_choices));
+ }
+
+ function _generateChoices($count = 7) {
+ $choices = array();
+ for ($i = 1; $i <= $count; $i++) {
+ $choices[] = $this->randomName();
+ }
+ return $choices;
+ }
+
+ /**
+ * Assert correct poll choice order in the node form after submission.
+ *
+ * Verifies both the order in the DOM and in the 'weight' form elements.
+ *
+ * @param $choices
+ * An array containing poll choices, as generated by
+ * PollTestCase::_generateChoices().
+ * @param $index
+ * (optional) The amount/number of already submitted poll choices. Defaults
+ * to 0.
+ * @param $preview
+ * (optional) Whether to also check the poll preview.
+ *
+ * @see PollTestCase::_pollGenerateEdit()
+ */
+ function assertPollChoiceOrder(array $choices, $index = 0, $preview = FALSE) {
+ $expected = array();
+ foreach ($choices as $id => $label) {
+ if ($id < $index) {
+ // The expected weight of each choice is exactly the negated id.
+ // @see PollTestCase::_pollGenerateEdit()
+ $weight = -$id;
+ // Directly assert the weight form element value for this choice.
+ $this->assertFieldByName('choice[chid:' . $id . '][weight]', $weight, t('Found choice @id with weight @weight.', array(
+ '@id' => $id,
+ '@weight' => $weight,
+ )));
+ // Append to our (to be reversed) stack of labels.
+ $expected[$weight] = $label;
+ }
+ }
+ ksort($expected);
+
+ // Verify DOM order of poll choices (i.e., #weight of form elements).
+ $elements = $this->xpath('//input[starts-with(@name, :prefix) and contains(@name, :suffix)]', array(
+ ':prefix' => 'choice[chid:',
+ ':suffix' => '][chtext]',
+ ));
+ $expected_order = $expected;
+ foreach ($elements as $element) {
+ $next_label = array_shift($expected_order);
+ $this->assertEqual((string) $element['value'], $next_label);
+ }
+
+ // If requested, also verify DOM order in preview.
+ if ($preview) {
+ $elements = $this->xpath('//div[contains(@class, :teaser)]/descendant::div[@class=:text]', array(
+ ':teaser' => 'node-teaser',
+ ':text' => 'text',
+ ));
+ $expected_order = $expected;
+ foreach ($elements as $element) {
+ $next_label = array_shift($expected_order);
+ $this->assertEqual((string) $element, $next_label, t('Found choice @label in preview.', array(
+ '@label' => $next_label,
+ )));
+ }
+ }
+ }
+
+ function pollUpdate($nid, $title, $edit) {
+ // Edit the poll node.
+ $this->drupalPost('node/' . $nid . '/edit', $edit, t('Save'));
+ $this->assertText(t('@type @title has been updated.', array('@type' => node_type_get_name('poll'), '@title' => $title)), 'Poll has been updated.');
+ }
+}
+
+class PollCreateTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll create',
+ 'description' => 'Adds "more choices", previews and creates a poll.',
+ 'group' => 'Poll'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ function testPollCreate() {
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(7);
+ $poll_nid = $this->pollCreate($title, $choices, TRUE);
+
+ // Verify poll appears on 'poll' page.
+ $this->drupalGet('poll');
+ $this->assertText($title, 'Poll appears in poll list.');
+ $this->assertText('open', 'Poll is active.');
+
+ // Click on the poll title to go to node page.
+ $this->clickLink($title);
+ $this->assertText('Total votes: 0', 'Link to poll correct.');
+
+ // Now add a new option to make sure that when we update the node the
+ // option is displayed.
+ $node = node_load($poll_nid);
+
+ $new_option = $this->randomName();
+
+ $node->choice[] = array(
+ 'chid' => '',
+ 'chtext' => $new_option,
+ 'chvotes' => 0,
+ 'weight' => 0,
+ );
+
+ node_save($node);
+
+ $this->drupalGet('poll');
+ $this->clickLink($title);
+ $this->assertText($new_option, 'New option found.');
+ }
+
+ function testPollClose() {
+ $content_user = $this->drupalCreateUser(array('create poll content', 'edit any poll content', 'access content'));
+ $vote_user = $this->drupalCreateUser(array('cancel own vote', 'inspect all votes', 'vote on polls', 'access content'));
+
+ // Create poll.
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(7);
+ $poll_nid = $this->pollCreate($title, $choices, FALSE);
+
+ $this->drupalLogout();
+ $this->drupalLogin($content_user);
+
+ // Edit the poll node and close the poll.
+ $close_edit = array('active' => 0);
+ $this->pollUpdate($poll_nid, $title, $close_edit);
+
+ // Verify 'Vote' button no longer appears.
+ $this->drupalGet('node/' . $poll_nid);
+ $elements = $this->xpath('//input[@id="edit-vote"]');
+ $this->assertTrue(empty($elements), t("Vote button doesn't appear."));
+
+ // Verify status on 'poll' page is 'closed'.
+ $this->drupalGet('poll');
+ $this->assertText($title, 'Poll appears in poll list.');
+ $this->assertText('closed', 'Poll is closed.');
+
+ // Edit the poll node and re-activate.
+ $open_edit = array('active' => 1);
+ $this->pollUpdate($poll_nid, $title, $open_edit);
+
+ // Vote on the poll.
+ $this->drupalLogout();
+ $this->drupalLogin($vote_user);
+ $vote_edit = array('choice' => '1');
+ $this->drupalPost('node/' . $poll_nid, $vote_edit, t('Vote'));
+ $this->assertText('Your vote was recorded.', 'Your vote was recorded.');
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(isset($elements[0]), t("'Cancel your vote' button appears."));
+
+ // Edit the poll node and close the poll.
+ $this->drupalLogout();
+ $this->drupalLogin($content_user);
+ $close_edit = array('active' => 0);
+ $this->pollUpdate($poll_nid, $title, $close_edit);
+
+ // Verify 'Cancel your vote' button no longer appears.
+ $this->drupalGet('node/' . $poll_nid);
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(empty($elements), t("'Cancel your vote' button no longer appears."));
+ }
+}
+
+class PollVoteTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll vote',
+ 'description' => 'Vote on a poll',
+ 'group' => 'Poll'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ function tearDown() {
+ parent::tearDown();
+ }
+
+ function testPollVote() {
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(7);
+ $poll_nid = $this->pollCreate($title, $choices, FALSE);
+ $this->drupalLogout();
+
+ $vote_user = $this->drupalCreateUser(array('cancel own vote', 'inspect all votes', 'vote on polls', 'access content'));
+ $restricted_vote_user = $this->drupalCreateUser(array('vote on polls', 'access content'));
+
+ $this->drupalLogin($vote_user);
+
+ // Record a vote for the first choice.
+ $edit = array(
+ 'choice' => '1',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->assertText('Your vote was recorded.', 'Your vote was recorded.');
+ $this->assertText('Total votes: 1', 'Vote count updated correctly.');
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(isset($elements[0]), t("'Cancel your vote' button appears."));
+
+ $this->drupalGet("node/$poll_nid/votes");
+ $this->assertText(t('This table lists all the recorded votes for this poll. If anonymous users are allowed to vote, they will be identified by the IP address of the computer they used when they voted.'), 'Vote table text.');
+ $this->assertText($choices[0], 'Vote recorded');
+
+ // Ensure poll listing page has correct number of votes.
+ $this->drupalGet('poll');
+ $this->assertText($title, 'Poll appears in poll list.');
+ $this->assertText('1 vote', 'Poll has 1 vote.');
+
+ // Cancel a vote.
+ $this->drupalPost('node/' . $poll_nid, array(), t('Cancel your vote'));
+ $this->assertText('Your vote was cancelled.', 'Your vote was cancelled.');
+ $this->assertNoText('Cancel your vote', "Cancel vote button doesn't appear.");
+
+ $this->drupalGet("node/$poll_nid/votes");
+ $this->assertNoText($choices[0], 'Vote cancelled');
+
+ // Ensure poll listing page has correct number of votes.
+ $this->drupalGet('poll');
+ $this->assertText($title, 'Poll appears in poll list.');
+ $this->assertText('0 votes', 'Poll has 0 votes.');
+
+ // Log in as a user who can only vote on polls.
+ $this->drupalLogout();
+ $this->drupalLogin($restricted_vote_user);
+
+ // Vote on a poll.
+ $edit = array(
+ 'choice' => '1',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->assertText('Your vote was recorded.', 'Your vote was recorded.');
+ $this->assertText('Total votes: 1', 'Vote count updated correctly.');
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(empty($elements), t("'Cancel your vote' button does not appear."));
+ }
+}
+
+class PollBlockTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block availability',
+ 'description' => 'Check if the most recent poll block is available.',
+ 'group' => 'Poll',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+
+ // Create and login user
+ $admin_user = $this->drupalCreateUser(array('administer blocks'));
+ $this->drupalLogin($admin_user);
+ }
+
+ function testRecentBlock() {
+ // Set block title to confirm that the interface is available.
+ $this->drupalPost('admin/structure/block/manage/poll/recent/configure', array('title' => $this->randomName(8)), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array();
+ $edit['blocks[poll_recent][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.'));
+
+ // Create a poll which should appear in recent polls block.
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(7);
+ $poll_nid = $this->pollCreate($title, $choices, TRUE);
+
+ // Verify poll appears in a block.
+ // View user page so we're not matching the poll node on front page.
+ $this->drupalGet('user');
+ // If a 'block' view not generated, this title would not appear even though
+ // the choices might.
+ $this->assertText($title, 'Poll appears in block.');
+
+ // Logout and login back in as a user who can vote.
+ $this->drupalLogout();
+ $vote_user = $this->drupalCreateUser(array('cancel own vote', 'inspect all votes', 'vote on polls', 'access content'));
+ $this->drupalLogin($vote_user);
+
+ // Verify we can vote via the block.
+ $edit = array(
+ 'choice' => '1',
+ );
+ $this->drupalPost('user/' . $vote_user->uid, $edit, t('Vote'));
+ $this->assertText('Your vote was recorded.', 'Your vote was recorded.');
+ $this->assertText('Total votes: 1', 'Vote count updated correctly.');
+ $this->assertText('Older polls', 'Link to older polls appears.');
+ $this->clickLink('Older polls');
+ $this->assertText('1 vote - open', 'Link to poll listing correct.');
+
+ // Close the poll and verify block doesn't appear.
+ $content_user = $this->drupalCreateUser(array('create poll content', 'edit any poll content', 'access content'));
+ $this->drupalLogout();
+ $this->drupalLogin($content_user);
+ $close_edit = array('active' => 0);
+ $this->pollUpdate($poll_nid, $title, $close_edit);
+ $this->drupalGet('user/' . $content_user->uid);
+ $this->assertNoText($title, 'Poll no longer appears in block.');
+ }
+}
+
+/**
+ * Test adding new choices.
+ */
+class PollJSAddChoice extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll add choice',
+ 'description' => 'Submits a POST request for an additional poll choice.',
+ 'group' => 'Poll'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ /**
+ * Test adding a new choice.
+ */
+ function testAddChoice() {
+ $web_user = $this->drupalCreateUser(array('create poll content', 'access content'));
+ $this->drupalLogin($web_user);
+ $this->drupalGet('node/add/poll');
+ $edit = array(
+ "title" => $this->randomName(),
+ 'choice[new:0][chtext]' => $this->randomName(),
+ 'choice[new:1][chtext]' => $this->randomName(),
+ );
+
+ // Press 'add choice' button through Ajax, and place the expected HTML result
+ // as the tested content.
+ $commands = $this->drupalPostAJAX(NULL, $edit, array('op' => t('Add another choice')));
+ $this->content = $commands[1]['data'];
+
+ $this->assertFieldByName('choice[chid:0][chtext]', $edit['choice[new:0][chtext]'], t('Field !i found', array('!i' => 0)));
+ $this->assertFieldByName('choice[chid:1][chtext]', $edit['choice[new:1][chtext]'], t('Field !i found', array('!i' => 1)));
+ $this->assertFieldByName('choice[new:0][chtext]', '', t('Field !i found', array('!i' => 2)));
+ }
+}
+
+class PollVoteCheckHostname extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User poll vote capability.',
+ 'description' => 'Check that users and anonymous users from specified ip-address can only vote once.',
+ 'group' => 'Poll'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+
+ // Create and login user.
+ $this->admin_user = $this->drupalCreateUser(array('administer permissions', 'create poll content'));
+ $this->drupalLogin($this->admin_user);
+
+ // Allow anonymous users to vote on polls.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access content' => TRUE,
+ 'vote on polls' => TRUE,
+ 'cancel own vote' => TRUE,
+ ));
+
+ // Enable page cache to verify that the result page is not saved in the
+ // cache when anonymous voting is allowed.
+ variable_set('cache', 1);
+
+ // Create poll.
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(3);
+ $this->poll_nid = $this->pollCreate($title, $choices, FALSE);
+
+ $this->drupalLogout();
+
+ // Create web users.
+ $this->web_user1 = $this->drupalCreateUser(array('access content', 'vote on polls', 'cancel own vote'));
+ $this->web_user2 = $this->drupalCreateUser(array('access content', 'vote on polls'));
+ }
+
+ /**
+ * Check that anonymous users with same ip cannot vote on poll more than once
+ * unless user is logged in.
+ */
+ function testHostnamePollVote() {
+ // Login User1.
+ $this->drupalLogin($this->web_user1);
+
+ $edit = array(
+ 'choice' => '1',
+ );
+
+ // User1 vote on Poll.
+ $this->drupalPost('node/' . $this->poll_nid, $edit, t('Vote'));
+ $this->assertText(t('Your vote was recorded.'), t('%user vote was recorded.', array('%user' => $this->web_user1->name)));
+ $this->assertText(t('Total votes: @votes', array('@votes' => 1)), t('Vote count updated correctly.'));
+
+ // Check to make sure User1 cannot vote again.
+ $this->drupalGet('node/' . $this->poll_nid);
+ $elements = $this->xpath('//input[@value="Vote"]');
+ $this->assertTrue(empty($elements), t("%user is not able to vote again.", array('%user' => $this->web_user1->name)));
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(!empty($elements), t("'Cancel your vote' button appears."));
+
+ // Logout User1.
+ $this->drupalLogout();
+
+ // Fill the page cache by requesting the poll.
+ $this->drupalGet('node/' . $this->poll_nid);
+ $this->assertEqual($this->drupalGetHeader('x-drupal-cache'), 'MISS', t('Page was cacheable but was not in the cache.'));
+ $this->drupalGet('node/' . $this->poll_nid);
+ $this->assertEqual($this->drupalGetHeader('x-drupal-cache'), 'HIT', t('Page was cached.'));
+
+ // Anonymous user vote on Poll.
+ $this->drupalPost(NULL, $edit, t('Vote'));
+ $this->assertText(t('Your vote was recorded.'), t('Anonymous vote was recorded.'));
+ $this->assertText(t('Total votes: @votes', array('@votes' => 2)), t('Vote count updated correctly.'));
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(!empty($elements), t("'Cancel your vote' button appears."));
+
+ // Check to make sure Anonymous user cannot vote again.
+ $this->drupalGet('node/' . $this->poll_nid);
+ $this->assertFalse($this->drupalGetHeader('x-drupal-cache'), t('Page was not cacheable.'));
+ $elements = $this->xpath('//input[@value="Vote"]');
+ $this->assertTrue(empty($elements), t("Anonymous is not able to vote again."));
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(!empty($elements), t("'Cancel your vote' button appears."));
+
+ // Login User2.
+ $this->drupalLogin($this->web_user2);
+
+ // User2 vote on poll.
+ $this->drupalPost('node/' . $this->poll_nid, $edit, t('Vote'));
+ $this->assertText(t('Your vote was recorded.'), t('%user vote was recorded.', array('%user' => $this->web_user2->name)));
+ $this->assertText(t('Total votes: @votes', array('@votes' => 3)), 'Vote count updated correctly.');
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(empty($elements), t("'Cancel your vote' button does not appear."));
+
+ // Logout User2.
+ $this->drupalLogout();
+
+ // Change host name for anonymous users.
+ db_update('poll_vote')
+ ->fields(array(
+ 'hostname' => '123.456.789.1',
+ ))
+ ->condition('hostname', '', '<>')
+ ->execute();
+
+ // Check to make sure Anonymous user can vote again with a new session after
+ // a hostname change.
+ $this->drupalGet('node/' . $this->poll_nid);
+ $this->assertEqual($this->drupalGetHeader('x-drupal-cache'), 'MISS', t('Page was cacheable but was not in the cache.'));
+ $this->drupalPost(NULL, $edit, t('Vote'));
+ $this->assertText(t('Your vote was recorded.'), t('%user vote was recorded.', array('%user' => $this->web_user2->name)));
+ $this->assertText(t('Total votes: @votes', array('@votes' => 4)), 'Vote count updated correctly.');
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(!empty($elements), t("'Cancel your vote' button appears."));
+
+ // Check to make sure Anonymous user cannot vote again with a new session,
+ // and that the vote from the previous session cannot be cancelledd.
+ $this->curlClose();
+ $this->drupalGet('node/' . $this->poll_nid);
+ $this->assertEqual($this->drupalGetHeader('x-drupal-cache'), 'MISS', t('Page was cacheable but was not in the cache.'));
+ $elements = $this->xpath('//input[@value="Vote"]');
+ $this->assertTrue(empty($elements), t('Anonymous is not able to vote again.'));
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(empty($elements), t("'Cancel your vote' button does not appear."));
+
+ // Login User1.
+ $this->drupalLogin($this->web_user1);
+
+ // Check to make sure User1 still cannot vote even after hostname changed.
+ $this->drupalGet('node/' . $this->poll_nid);
+ $elements = $this->xpath('//input[@value="Vote"]');
+ $this->assertTrue(empty($elements), t("%user is not able to vote again.", array('%user' => $this->web_user1->name)));
+ $elements = $this->xpath('//input[@value="Cancel your vote"]');
+ $this->assertTrue(!empty($elements), t("'Cancel your vote' button appears."));
+ }
+}
+
+/**
+ * Test poll token replacement in strings.
+ */
+class PollTokenReplaceTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check poll token replacement.',
+ 'group' => 'Poll',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ /**
+ * Creates a poll, then tests the tokens generated from it.
+ */
+ function testPollTokenReplacement() {
+ global $language;
+
+ // Craete a poll with three choices.
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(3);
+ $poll_nid = $this->pollCreate($title, $choices, FALSE);
+ $this->drupalLogout();
+
+ // Create four users and have each of them vote.
+ $vote_user1 = $this->drupalCreateUser(array('vote on polls', 'access content'));
+ $this->drupalLogin($vote_user1);
+ $edit = array(
+ 'choice' => '1',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->drupalLogout();
+
+ $vote_user2 = $this->drupalCreateUser(array('vote on polls', 'access content'));
+ $this->drupalLogin($vote_user2);
+ $edit = array(
+ 'choice' => '1',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->drupalLogout();
+
+ $vote_user3 = $this->drupalCreateUser(array('vote on polls', 'access content'));
+ $this->drupalLogin($vote_user3);
+ $edit = array(
+ 'choice' => '2',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->drupalLogout();
+
+ $vote_user4 = $this->drupalCreateUser(array('vote on polls', 'access content'));
+ $this->drupalLogin($vote_user4);
+ $edit = array(
+ 'choice' => '3',
+ );
+ $this->drupalPost('node/' . $poll_nid, $edit, t('Vote'));
+ $this->drupalLogout();
+
+ $poll = node_load($poll_nid, NULL, TRUE);
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[node:poll-votes]'] = 4;
+ $tests['[node:poll-winner]'] = filter_xss($poll->choice[1]['chtext']);
+ $tests['[node:poll-winner-votes]'] = 2;
+ $tests['[node:poll-winner-percent]'] = 50;
+ $tests['[node:poll-duration]'] = format_interval($poll->runtime, 1, $language->language);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $poll), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized poll token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[node:poll-winner]'] = $poll->choice[1]['chtext'];
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $poll), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized poll token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+class PollExpirationTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll expiration',
+ 'description' => 'Test the poll auto-expiration logic.',
+ 'group' => 'Poll',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ function testAutoExpire() {
+ // Set up a poll.
+ $title = $this->randomName();
+ $choices = $this->_generateChoices(2);
+ $poll_nid = $this->pollCreate($title, $choices, FALSE);
+ $this->assertTrue($poll_nid, t('Poll for auto-expire test created.'));
+
+ // Visit the poll edit page and verify that by default, expiration
+ // is set to unlimited.
+ $this->drupalGet("node/$poll_nid/edit");
+ $this->assertField('runtime', t('Poll expiration setting found.'));
+ $elements = $this->xpath('//select[@id="edit-runtime"]/option[@selected="selected"]');
+ $this->assertTrue(isset($elements[0]['value']) && $elements[0]['value'] == 0, t('Poll expiration set to unlimited.'));
+
+ // Set the expiration to one week.
+ $edit = array();
+ $poll_expiration = 604800; // One week.
+ $edit['runtime'] = $poll_expiration;
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('Poll %title has been updated.', array('%title' => $title)), t('Poll expiration settings saved.'));
+
+ // Make sure that the changed expiration settings is kept.
+ $this->drupalGet("node/$poll_nid/edit");
+ $elements = $this->xpath('//select[@id="edit-runtime"]/option[@selected="selected"]');
+ $this->assertTrue(isset($elements[0]['value']) && $elements[0]['value'] == $poll_expiration, t('Poll expiration set to unlimited.'));
+
+ // Force a cron run. Since the expiration date has not yet been reached,
+ // the poll should remain active.
+ drupal_cron_run();
+ $this->drupalGet("node/$poll_nid/edit");
+ $elements = $this->xpath('//input[@id="edit-active-1"]');
+ $this->assertTrue(isset($elements[0]) && !empty($elements[0]['checked']), t('Poll is still active.'));
+
+ // Test expiration. Since REQUEST_TIME is a constant and we don't
+ // want to keep SimpleTest waiting until the moment of expiration arrives,
+ // we forcibly change the expiration date in the database.
+ $created = db_query('SELECT created FROM {node} WHERE nid = :nid', array(':nid' => $poll_nid))->fetchField();
+ db_update('node')
+ ->fields(array('created' => $created - ($poll_expiration * 1.01)))
+ ->condition('nid', $poll_nid)
+ ->execute();
+
+ // Run cron and verify that the poll is now marked as "closed".
+ drupal_cron_run();
+ $this->drupalGet("node/$poll_nid/edit");
+ $elements = $this->xpath('//input[@id="edit-active-0"]');
+ $this->assertTrue(isset($elements[0]) && !empty($elements[0]['checked']), t('Poll has expired.'));
+ }
+}
+
+class PollDeleteChoiceTestCase extends PollTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Poll choice deletion',
+ 'description' => 'Test the poll choice deletion logic.',
+ 'group' => 'Poll',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('poll');
+ }
+
+ function testChoiceRemoval() {
+ // Set up a poll with three choices.
+ $title = $this->randomName();
+ $choices = array('First choice', 'Second choice', 'Third choice');
+ $poll_nid = $this->pollCreate($title, $choices, FALSE);
+ $this->assertTrue($poll_nid, t('Poll for choice deletion logic test created.'));
+
+ // Edit the poll, and try to delete first poll choice.
+ $this->drupalGet("node/$poll_nid/edit");
+ $edit['choice[chid:1][chtext]'] = '';
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Click on the poll title to go to node page.
+ $this->drupalGet('poll');
+ $this->clickLink($title);
+
+ // Check the first poll choice is deleted, while the others remain.
+ $this->assertNoText('First choice', t('First choice removed.'));
+ $this->assertText('Second choice', t('Second choice remains.'));
+ $this->assertText('Third choice', t('Third choice remains.'));
+ }
+}
diff --git a/core/modules/poll/poll.tokens.inc b/core/modules/poll/poll.tokens.inc
new file mode 100644
index 000000000000..eda628bacffe
--- /dev/null
+++ b/core/modules/poll/poll.tokens.inc
@@ -0,0 +1,107 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for values specific to Poll nodes.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function poll_token_info() {
+ $node['poll-votes'] = array(
+ 'name' => t("Poll votes"),
+ 'description' => t("The number of votes that have been cast on a poll."),
+ );
+ $node['poll-winner'] = array(
+ 'name' => t("Poll winner"),
+ 'description' => t("The winning poll answer."),
+ );
+ $node['poll-winner-votes'] = array(
+ 'name' => t("Poll winner votes"),
+ 'description' => t("The number of votes received by the winning poll answer."),
+ );
+ $node['poll-winner-percent'] = array(
+ 'name' => t("Poll winner percent"),
+ 'description' => t("The percentage of votes received by the winning poll answer."),
+ );
+ $node['poll-duration'] = array(
+ 'name' => t("Poll duration"),
+ 'description' => t("The length of time the poll is set to run."),
+ );
+
+ return array(
+ 'tokens' => array('node' => $node),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function poll_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $sanitize = !empty($options['sanitize']);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+
+ $replacements = array();
+
+ if ($type == 'node' && !empty($data['node']) && $data['node']->type == 'poll') {
+ $node = $data['node'];
+
+ $total_votes = 0;
+ $highest_votes = 0;
+ foreach ($node->choice as $choice) {
+ if ($choice['chvotes'] > $highest_votes) {
+ $winner = $choice;
+ $highest_votes = $choice['chvotes'];
+ }
+ $total_votes = $total_votes + $choice['chvotes'];
+ }
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ case 'poll-votes':
+ $replacements[$original] = $total_votes;
+ break;
+
+ case 'poll-winner':
+ if (isset($winner)) {
+ $replacements[$original] = $sanitize ? filter_xss($winner['chtext']) : $winner['chtext'];
+ }
+ else {
+ $replacements[$original] = '';
+ }
+ break;
+
+ case 'poll-winner-votes':
+ if (isset($winner)) {
+ $replacements[$original] = $winner['chvotes'];
+ }
+ else {
+ $replacements[$original] = '';
+ }
+ break;
+
+ case 'poll-winner-percent':
+ if (isset($winner)) {
+ $percent = ($winner['chvotes'] / $total_votes) * 100;
+ $replacements[$original] = number_format($percent, 0);
+ }
+ else {
+ $replacements[$original] = '';
+ }
+ break;
+
+ case 'poll-duration':
+ $replacements[$original] = format_interval($node->runtime, 1, $language_code);
+ break;
+ }
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/rdf/rdf.api.php b/core/modules/rdf/rdf.api.php
new file mode 100644
index 000000000000..b3f95ba00097
--- /dev/null
+++ b/core/modules/rdf/rdf.api.php
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the RDF module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Allow modules to define RDF mappings for field bundles.
+ *
+ * Modules defining their own field bundles can specify which RDF semantics
+ * should be used to annotate these bundles. These mappings are then used for
+ * automatic RDFa output in the HTML code.
+ *
+ * @return
+ * A list of mapping structures, where each mapping is an associative array:
+ * - type: The name of an entity type (e.g., 'node', 'comment', and so on.)
+ * - bundle: The name of the bundle (e.g., 'page', 'article', or
+ * RDF_DEFAULT_BUNDLE for default mappings.)
+ * - mapping: The mapping structure which applies to the entity type and
+ * bundle. A mapping structure is an array with keys corresponding to
+ * existing field instances in the bundle. Each field is then described in
+ * terms of the RDF mapping:
+ * - predicates: An array of RDF predicates which describe the relation
+ * between the bundle (RDF subject) and the value of the field (RDF
+ * object). This value is either some text, another bundle, or a URI in
+ * general.
+ * - datatype: Is used along with 'callback' to format data so that it is
+ * readable by machines. A typical example is a date which can be written
+ * in many different formats but should be translated into a uniform
+ * format for machine consumption.
+ * - callback: A function name to invoke for 'datatype'.
+ * - type: A string used to determine the type of RDFa markup which will be
+ * used in the final HTML output, depending on whether the RDF object is a
+ * literal text or another RDF resource.
+ * - rdftype: A special property used to define the type of the instance.
+ * Its value should be an array of RDF classes.
+ *
+ * @ingroup rdf
+ */
+function hook_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'comment',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post', 'sioct:Comment'),
+ 'title' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'created' => array(
+ 'predicates' => array('dc:date', 'dc:created'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'changed' => array(
+ 'predicates' => array('dc:modified'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'comment_body' => array(
+ 'predicates' => array('content:encoded'),
+ ),
+ 'pid' => array(
+ 'predicates' => array('sioc:reply_of'),
+ 'type' => 'rel',
+ ),
+ 'uid' => array(
+ 'predicates' => array('sioc:has_creator'),
+ 'type' => 'rel',
+ ),
+ 'name' => array(
+ 'predicates' => array('foaf:name'),
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Allow modules to define namespaces for RDF mappings.
+ *
+ * Many common namespace prefixes are defined in rdf_rdf_namespaces(). However,
+ * if a module implements hook_rdf_mapping() and uses a prefix that is not
+ * defined in rdf_rdf_namespaces(), this hook should be used to define the new
+ * namespace prefix.
+ *
+ * @return
+ * An associative array of namespaces where the key is the namespace prefix
+ * and the value is the namespace URI.
+ *
+ * @ingroup rdf
+ */
+function hook_rdf_namespaces() {
+ return array(
+ 'content' => 'http://purl.org/rss/1.0/modules/content/',
+ 'dc' => 'http://purl.org/dc/terms/',
+ 'foaf' => 'http://xmlns.com/foaf/0.1/',
+ 'og' => 'http://ogp.me/ns#',
+ 'rdfs' => 'http://www.w3.org/2000/01/rdf-schema#',
+ 'sioc' => 'http://rdfs.org/sioc/ns#',
+ 'sioct' => 'http://rdfs.org/sioc/types#',
+ 'skos' => 'http://www.w3.org/2004/02/skos/core#',
+ 'xsd' => 'http://www.w3.org/2001/XMLSchema#',
+ );
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/rdf/rdf.info b/core/modules/rdf/rdf.info
new file mode 100644
index 000000000000..a27d68065b6d
--- /dev/null
+++ b/core/modules/rdf/rdf.info
@@ -0,0 +1,6 @@
+name = RDF
+description = Enriches your content with metadata to let other applications (e.g. search engines, aggregators) better understand its relationships and attributes.
+package = Core
+version = VERSION
+core = 8.x
+files[] = rdf.test
diff --git a/core/modules/rdf/rdf.install b/core/modules/rdf/rdf.install
new file mode 100644
index 000000000000..10d3f8d94c8a
--- /dev/null
+++ b/core/modules/rdf/rdf.install
@@ -0,0 +1,49 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the rdf module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function rdf_schema() {
+ $schema['rdf_mapping'] = array(
+ 'description' => 'Stores custom RDF mappings for user defined content types or overriden module-defined mappings',
+ 'fields' => array(
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'description' => 'The name of the entity type a mapping applies to (node, user, comment, etc.).',
+ ),
+ 'bundle' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'description' => 'The name of the bundle a mapping applies to.',
+ ),
+ 'mapping' => array(
+ 'description' => 'The serialized mapping of the bundle type and fields to RDF terms.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'serialize' => TRUE,
+ ),
+ ),
+ 'primary key' => array('type', 'bundle'),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function rdf_install() {
+ // Collect any RDF mappings that were declared by modules installed before
+ // this one.
+ $modules = module_implements('rdf_mapping');
+ rdf_modules_installed($modules);
+}
diff --git a/core/modules/rdf/rdf.module b/core/modules/rdf/rdf.module
new file mode 100644
index 000000000000..1a296469aa46
--- /dev/null
+++ b/core/modules/rdf/rdf.module
@@ -0,0 +1,882 @@
+<?php
+
+/**
+ * @file
+ * Enables semantically enriched output for Drupal sites in the form of RDFa.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function rdf_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#rdf':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The RDF module enriches your content with metadata to let other applications (e.g., search engines, aggregators, and so on) better understand its relationships and attributes. This semantically enriched, machine-readable output for Drupal sites uses the <a href="@rdfa">RDFa specification</a> which allows RDF data to be embedded in HTML markup. Other modules can define mappings of their data to RDF terms, and the RDF module makes this RDF data available to the theme. The core Drupal modules define RDF mappings for their data model, and the core Drupal themes output this RDF metadata information along with the human-readable visual information. For more information, see the online handbook entry for <a href="@rdf">RDF module</a>.', array('@rdfa' => 'http://www.w3.org/TR/xhtml-rdfa-primer/', '@rdf' => 'http://drupal.org/handbook/modules/rdf')) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * @defgroup rdf RDF Mapping API
+ * @{
+ * Functions to describe entities and bundles in RDF.
+ *
+ * The RDF module introduces RDF and RDFa to Drupal. RDF is a W3C standard to
+ * describe structured data. RDF can be serialized as RDFa in XHTML attributes
+ * to augment visual data with machine-readable hints.
+ * @see http://www.w3.org/RDF/
+ * @see http://www.w3.org/TR/xhtml-rdfa-primer/
+ *
+ * Modules can provide mappings of their bundles' data and metadata to RDF
+ * classes and properties. This module takes care of injecting these mappings
+ * into variables available to theme functions and templates. All Drupal core
+ * themes are coded to be RDFa compatible.
+ *
+ * Example mapping from node.module:
+ * @code
+ * array(
+ * 'type' => 'node',
+ * 'bundle' => RDF_DEFAULT_BUNDLE,
+ * 'mapping' => array(
+ * 'rdftype' => array('sioc:Item', 'foaf:Document'),
+ * 'title' => array(
+ * 'predicates' => array('dc:title'),
+ * ),
+ * 'created' => array(
+ * 'predicates' => array('dc:date', 'dc:created'),
+ * 'datatype' => 'xsd:dateTime',
+ * 'callback' => 'date_iso8601',
+ * ),
+ * 'body' => array(
+ * 'predicates' => array('content:encoded'),
+ * ),
+ * 'uid' => array(
+ * 'predicates' => array('sioc:has_creator'),
+ * ),
+ * 'name' => array(
+ * 'predicates' => array('foaf:name'),
+ * ),
+ * ),
+ * );
+ * @endcode
+ */
+
+/**
+ * RDF bundle flag: Default bundle.
+ *
+ * Implementations of hook_rdf_mapping() should use this constant for the
+ * 'bundle' key when defining a default set of RDF mappings for an entity type.
+ * Each bundle will inherit the default mappings defined for the entity type
+ * unless the bundle defines its own specific mappings.
+ */
+define('RDF_DEFAULT_BUNDLE', '');
+
+/**
+ * Implements hook_rdf_namespaces().
+ */
+function rdf_rdf_namespaces() {
+ return array(
+ 'content' => 'http://purl.org/rss/1.0/modules/content/',
+ 'dc' => 'http://purl.org/dc/terms/',
+ 'foaf' => 'http://xmlns.com/foaf/0.1/',
+ 'og' => 'http://ogp.me/ns#',
+ 'rdfs' => 'http://www.w3.org/2000/01/rdf-schema#',
+ 'sioc' => 'http://rdfs.org/sioc/ns#',
+ 'sioct' => 'http://rdfs.org/sioc/types#',
+ 'skos' => 'http://www.w3.org/2004/02/skos/core#',
+ 'xsd' => 'http://www.w3.org/2001/XMLSchema#',
+ );
+}
+
+/**
+ * Returns an array of RDF namespaces defined in modules that implement
+ * hook_rdf_namespaces().
+ */
+function rdf_get_namespaces() {
+ $rdf_namespaces = module_invoke_all('rdf_namespaces');
+ // module_invoke_all() uses array_merge_recursive() which might return nested
+ // arrays if several modules redefine the same prefix multiple times. We need
+ // to ensure the array of namespaces is flat and only contains strings as
+ // URIs.
+ foreach ($rdf_namespaces as $prefix => $uri) {
+ if (is_array($uri)) {
+ if (count(array_unique($uri)) == 1) {
+ // All namespaces declared for this prefix are the same, merge them all
+ // into a single namespace.
+ $rdf_namespaces[$prefix] = $uri[0];
+ }
+ else {
+ // There are conflicting namespaces for this prefix, do not include
+ // duplicates in order to avoid asserting any inaccurate RDF
+ // statements.
+ unset($rdf_namespaces[$prefix]);
+ }
+ }
+ }
+ return $rdf_namespaces;
+}
+
+/**
+ * Returns the mapping for attributes of a given entity type/bundle pair.
+ *
+ * @param $type
+ * An entity type.
+ * @param $bundle
+ * (optional) A bundle name.
+ *
+ * @return
+ * The mapping corresponding to the requested entity type/bundle pair or an
+ * empty array.
+ */
+function rdf_mapping_load($type, $bundle = RDF_DEFAULT_BUNDLE) {
+ // Retrieves the bundle-specific mapping from the entity info.
+ $entity_info = entity_get_info($type);
+ if (!empty($entity_info['bundles'][$bundle]['rdf_mapping'])) {
+ return $entity_info['bundles'][$bundle]['rdf_mapping'];
+ }
+ // If there is no mapping defined for this bundle, we return the default
+ // mapping that is defined for this entity type.
+ else {
+ return _rdf_get_default_mapping($type);
+ }
+}
+
+/**
+ * @} End of "defgroup rdf".
+ */
+
+/**
+ * Helper function to get the default RDF mapping for a given entity type.
+ *
+ * @param $type
+ * An entity type, e.g. 'node' or 'comment'.
+ *
+ * @return
+ * The RDF mapping or an empty array if no mapping is defined for this entity
+ * type.
+ */
+function _rdf_get_default_mapping($type) {
+ $default_mappings = &drupal_static(__FUNCTION__);
+
+ if (!isset($default_mappings)) {
+ // Get all of the modules that implement hook_rdf_mapping().
+ $modules = module_implements('rdf_mapping');
+
+ // Only consider the default entity mapping definitions.
+ foreach ($modules as $module) {
+ $mappings = module_invoke($module, 'rdf_mapping');
+ foreach ($mappings as $mapping) {
+ if ($mapping['bundle'] === RDF_DEFAULT_BUNDLE) {
+ $default_mappings[$mapping['type']] = $mapping['mapping'];
+ }
+ }
+ }
+ }
+
+ return isset($default_mappings[$type]) ? $default_mappings[$type] : array();
+}
+
+/**
+ * Helper function to retrieve an RDF mapping from the database.
+ *
+ * @param $type
+ * The entity type the mapping refers to.
+ * @param $bundle
+ * The bundle the mapping refers to.
+ *
+ * @return
+ * An RDF mapping structure or an empty array if no record was found.
+ */
+function _rdf_mapping_load($type, $bundle) {
+ $mapping = db_select('rdf_mapping')
+ ->fields(NULL, array('mapping'))
+ ->condition('type', $type)
+ ->condition('bundle', $bundle)
+ ->execute()
+ ->fetchField();
+
+ if (!$mapping) {
+ return array();
+ }
+ return unserialize($mapping);
+}
+
+/**
+ * @addtogroup rdf
+ * @{
+ */
+
+/**
+ * Saves an RDF mapping to the database.
+ *
+ * Takes a mapping structure returned by hook_rdf_mapping() implementations
+ * and creates or updates a record mapping for each encountered entity
+ * type/bundle pair. If available, adds default values for non-existent mapping
+ * keys.
+ *
+ * @param $mapping
+ * The RDF mapping to save, as an array.
+ *
+ * @return
+ * Status flag indicating the outcome of the operation.
+ */
+function rdf_mapping_save($mapping) {
+ // In the case where a field has a mapping defined in the default entity
+ // mapping, but a mapping is not specified in the bundle-specific mapping,
+ // then use the default mapping for that field.
+ $mapping['mapping'] += _rdf_get_default_mapping($mapping['type']);
+
+ $status = db_merge('rdf_mapping')
+ ->key(array(
+ 'type' => $mapping['type'],
+ 'bundle' => $mapping['bundle'],
+ ))
+ ->fields(array(
+ 'mapping' => serialize($mapping['mapping']),
+ ))
+ ->execute();
+
+ entity_info_cache_clear();
+
+ return $status;
+}
+
+/**
+ * Deletes the mapping for the given bundle from the database.
+ *
+ * @param $type
+ * The entity type the mapping refers to.
+ * @param $bundle
+ * The bundle the mapping refers to.
+ *
+ * @return
+ * Return boolean TRUE if mapping deleted, FALSE if not.
+ */
+function rdf_mapping_delete($type, $bundle) {
+ $num_rows = db_delete('rdf_mapping')
+ ->condition('type', $type)
+ ->condition('bundle', $bundle)
+ ->execute();
+
+ return (bool) ($num_rows > 0);
+}
+
+/**
+ * Builds an array of RDFa attributes for a given mapping. This array will
+ * typically be passed through drupal_attributes() to create the attributes
+ * variables that are available to template files. These include $attributes,
+ * $title_attributes, $content_attributes and the field-specific
+ * $item_attributes variables. For more information, see
+ * theme_rdf_template_variable_wrapper().
+ *
+ * @param $mapping
+ * An array containing a mandatory 'predicates' key and optional 'datatype',
+ * 'callback' and 'type' keys. For example:
+ * @code
+ * array(
+ * 'predicates' => array('dc:created'),
+ * 'datatype' => 'xsd:dateTime',
+ * 'callback' => 'date_iso8601',
+ * ),
+ * );
+ * @endcode
+ * @param $data
+ * A value that needs to be converted by the provided callback function.
+ *
+ * @return
+ * An array containing RDFa attributes suitable for drupal_attributes().
+ */
+function rdf_rdfa_attributes($mapping, $data = NULL) {
+ // The type of mapping defaults to 'property'.
+ $type = isset($mapping['type']) ? $mapping['type'] : 'property';
+
+ switch ($type) {
+ // The mapping expresses the relationship between two resources.
+ case 'rel':
+ case 'rev':
+ $attributes[$type] = $mapping['predicates'];
+ break;
+
+ // The mapping expresses the relationship between a resource and some
+ // literal text.
+ case 'property':
+ $attributes['property'] = $mapping['predicates'];
+ // Convert $data to a specific format as per the callback function.
+ if (isset($data) && isset($mapping['callback']) && function_exists($mapping['callback'])) {
+ $callback = $mapping['callback'];
+ $attributes['content'] = $callback($data);
+ }
+ if (isset($mapping['datatype'])) {
+ $attributes['datatype'] = $mapping['datatype'];
+ }
+ break;
+ }
+
+ return $attributes;
+}
+
+/**
+ * @} End of "addtogroup rdf".
+ */
+
+/**
+ * Implements hook_modules_installed().
+ *
+ * Checks if the installed modules have any RDF mapping definitions to declare
+ * and stores them in the rdf_mapping table.
+ *
+ * While both default entity mappings and specific bundle mappings can be
+ * defined in hook_rdf_mapping(), default entity mappings are not stored in the
+ * database. Only overridden mappings are stored in the database. The default
+ * entity mappings can be overriden by specific bundle mappings which are
+ * stored in the database and can be altered via the RDF CRUD mapping API.
+ */
+function rdf_modules_installed($modules) {
+ foreach ($modules as $module) {
+ $function = $module . '_rdf_mapping';
+ if (function_exists($function)) {
+ foreach ($function() as $mapping) {
+ // Only the bundle mappings are saved in the database.
+ if ($mapping['bundle'] !== RDF_DEFAULT_BUNDLE) {
+ rdf_mapping_save($mapping);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function rdf_modules_uninstalled($modules) {
+ // @todo Remove RDF mappings of uninstalled modules.
+}
+
+/**
+ * Implements hook_entity_info_alter().
+ *
+ * Adds the proper RDF mapping to each entity type/bundle pair.
+ *
+ * @todo May need to move the comment below to another place.
+ * This hook should not be used by modules to alter the bundle mappings.
+ * The UI should always be authoritative. UI mappings are stored in the
+ * database and if hook_entity_info_alter was used to override module defined
+ * mappings, it would override the user defined mapping as well.
+ */
+function rdf_entity_info_alter(&$entity_info) {
+ // Loop through each entity type and its bundles.
+ foreach ($entity_info as $entity_type => $entity_type_info) {
+ if (isset($entity_type_info['bundles'])) {
+ foreach ($entity_type_info['bundles'] as $bundle => $bundle_info) {
+ if ($mapping = _rdf_mapping_load($entity_type, $bundle)) {
+ $entity_info[$entity_type]['bundles'][$bundle]['rdf_mapping'] = $mapping;
+ }
+ else {
+ // If no mapping was found in the database, assign the default RDF
+ // mapping for this entity type.
+ $entity_info[$entity_type]['bundles'][$bundle]['rdf_mapping'] = _rdf_get_default_mapping($entity_type);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function rdf_entity_load($entities, $type) {
+ foreach ($entities as $entity) {
+ // Extracts the bundle of the entity being loaded.
+ list($id, $vid, $bundle) = entity_extract_ids($type, $entity);
+ $entity->rdf_mapping = rdf_mapping_load($type, $bundle);
+ }
+}
+
+/**
+ * Implements hook_comment_load().
+ */
+function rdf_comment_load($comments) {
+ foreach ($comments as $comment) {
+ // Pages with many comments can show poor performance. This information
+ // isn't needed until rdf_preprocess_comment() is called, but set it here
+ // to optimize performance for websites that implement an entity cache.
+ $comment->rdf_data['date'] = rdf_rdfa_attributes($comment->rdf_mapping['created'], $comment->created);
+ $comment->rdf_data['nid_uri'] = url('node/' . $comment->nid);
+ if ($comment->pid) {
+ $comment->rdf_data['pid_uri'] = url('comment/' . $comment->pid, array('fragment' => 'comment-' . $comment->pid));
+ }
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function rdf_theme() {
+ return array(
+ 'rdf_template_variable_wrapper' => array(
+ 'variables' => array('content' => NULL, 'attributes' => array(), 'context' => array(), 'inline' => TRUE),
+ ),
+ 'rdf_metadata' => array(
+ 'variables' => array('metadata' => array()),
+ ),
+ );
+}
+
+/**
+ * Template process function for adding extra tags to hold RDFa attributes.
+ *
+ * Since template files already have built-in support for $attributes,
+ * $title_attributes, and $content_attributes, and field templates have support
+ * for $item_attributes, we try to leverage those as much as possible. However,
+ * in some cases additional attributes are needed not covered by these. We deal
+ * with those here.
+ */
+function rdf_process(&$variables, $hook) {
+ // Handles attributes needed for content not covered by title, content,
+ // and field items. It does this by adjusting the variable sent to the
+ // template so that the template doesn't have to worry about it. See
+ // theme_rdf_template_variable_wrapper().
+ if (!empty($variables['rdf_template_variable_attributes_array'])) {
+ foreach ($variables['rdf_template_variable_attributes_array'] as $variable_name => $attributes) {
+ $context = array(
+ 'hook' => $hook,
+ 'variable_name' => $variable_name,
+ 'variables' => $variables,
+ );
+ $variables[$variable_name] = theme('rdf_template_variable_wrapper', array('content' => $variables[$variable_name], 'attributes' => $attributes, 'context' => $context));
+ }
+ }
+ // Handles additional attributes about a template entity that for RDF parsing
+ // reasons, can't be placed into that template's $attributes variable. This
+ // is "meta" information that is related to particular content, so render it
+ // close to that content.
+ if (!empty($variables['rdf_metadata_attributes_array'])) {
+ if (!isset($variables['content']['#prefix'])) {
+ $variables['content']['#prefix'] = '';
+ }
+ $variables['content']['#prefix'] = theme('rdf_metadata', array('metadata' => $variables['rdf_metadata_attributes_array'])) . $variables['content']['#prefix'];
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK()
+ */
+function rdf_preprocess_html(&$variables) {
+ // Adds RDF namespace prefix bindings in the form of an RDFa 1.1 prefix
+ // attribute inside the html element.
+ $prefixes = array();
+ foreach(rdf_get_namespaces() as $prefix => $uri) {
+ $variables['html_attributes_array']['prefix'][] = $prefix . ': ' . $uri . "\n";
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_node(&$variables) {
+ // Adds RDFa markup to the node container. The about attribute specifies the
+ // URI of the resource described within the HTML element, while the @typeof
+ // attribute indicates its RDF type (e.g., foaf:Document, sioc:Person, and so
+ // on.)
+ $variables['attributes_array']['about'] = empty($variables['node_url']) ? NULL: $variables['node_url'];
+ $variables['attributes_array']['typeof'] = empty($variables['node']->rdf_mapping['rdftype']) ? NULL : $variables['node']->rdf_mapping['rdftype'];
+
+ // Adds RDFa markup to the title of the node. Because the RDFa markup is
+ // added to the <h2> tag which might contain HTML code, we specify an empty
+ // datatype to ensure the value of the title read by the RDFa parsers is a
+ // literal.
+ $variables['title_attributes_array']['property'] = empty($variables['node']->rdf_mapping['title']['predicates']) ? NULL : $variables['node']->rdf_mapping['title']['predicates'];
+ $variables['title_attributes_array']['datatype'] = '';
+
+ // In full node mode, the title is not displayed by node.tpl.php so it is
+ // added in the <head> tag of the HTML page.
+ if ($variables['page']) {
+ $element = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'content' => $variables['title'],
+ 'about' => $variables['node_url'],
+ ),
+ );
+ if (!empty($variables['node']->rdf_mapping['title']['predicates'])) {
+ $element['#attributes']['property'] = $variables['node']->rdf_mapping['title']['predicates'];
+ }
+ drupal_add_html_head($element, 'rdf_node_title');
+ }
+
+ // Adds RDFa markup for the date.
+ if (!empty($variables['rdf_mapping']['created'])) {
+ $date_attributes_array = rdf_rdfa_attributes($variables['rdf_mapping']['created'], $variables['created']);
+ $variables['rdf_template_variable_attributes_array']['date'] = $date_attributes_array;
+ if ($variables['submitted']) {
+ $variables['rdf_template_variable_attributes_array']['submitted'] = $date_attributes_array;
+ }
+ }
+ // Adds RDFa markup for the relation between the node and its author.
+ if (!empty($variables['rdf_mapping']['uid'])) {
+ $variables['rdf_template_variable_attributes_array']['name']['rel'] = $variables['rdf_mapping']['uid']['predicates'];
+ if ($variables['submitted']) {
+ $variables['rdf_template_variable_attributes_array']['submitted']['rel'] = $variables['rdf_mapping']['uid']['predicates'];
+ }
+ }
+
+ // Adds RDFa markup annotating the number of comments a node has.
+ if (isset($variables['node']->comment_count) && !empty($variables['node']->rdf_mapping['comment_count']['predicates'])) {
+ // Annotates the 'x comments' link in teaser view.
+ if (isset($variables['content']['links']['comment']['#links']['comment-comments'])) {
+ $comment_count_attributes['property'] = $variables['node']->rdf_mapping['comment_count']['predicates'];
+ $comment_count_attributes['content'] = $variables['node']->comment_count;
+ $comment_count_attributes['datatype'] = $variables['node']->rdf_mapping['comment_count']['datatype'];
+ // According to RDFa parsing rule number 4, a new subject URI is created
+ // from the href attribute if no rel/rev attribute is present. To get the
+ // original node URL from the about attribute of the parent container we
+ // set an empty rel attribute which triggers rule number 5. See
+ // http://www.w3.org/TR/rdfa-syntax/#sec_5.5.
+ $comment_count_attributes['rel'] = '';
+ $variables['content']['links']['comment']['#links']['comment-comments']['attributes'] += $comment_count_attributes;
+ }
+ // In full node view, the number of comments is not displayed by
+ // node.tpl.php so it is expressed in RDFa in the <head> tag of the HTML
+ // page.
+ if ($variables['page'] && user_access('access comments')) {
+ $element = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'about' => $variables['node_url'],
+ 'property' => $variables['node']->rdf_mapping['comment_count']['predicates'],
+ 'content' => $variables['node']->comment_count,
+ 'datatype' => $variables['node']->rdf_mapping['comment_count']['datatype'],
+ ),
+ );
+ drupal_add_html_head($element, 'rdf_node_comment_count');
+ }
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_field(&$variables) {
+ $element = $variables['element'];
+ $mapping = rdf_mapping_load($element['#entity_type'], $element['#bundle']);
+ $field_name = $element['#field_name'];
+
+ if (!empty($mapping) && !empty($mapping[$field_name])) {
+ foreach ($element['#items'] as $delta => $item) {
+ $variables['item_attributes_array'][$delta] = rdf_rdfa_attributes($mapping[$field_name], $item);
+ // If this field is an image, RDFa will not output correctly when the
+ // image is in a containing <a> tag. If the field is a file, RDFa will
+ // not output correctly if the filetype icon comes before the link to the
+ // file. We correct this by adding a resource attribute to the div if
+ // this field has a URI.
+ if (isset($item['uri'])) {
+ if (!empty($element[$delta]['#image_style'])) {
+ $variables['item_attributes_array'][$delta]['resource'] = image_style_url($element[$delta]['#image_style'], $item['uri']);
+ }
+ else {
+ $variables['item_attributes_array'][$delta]['resource'] = file_create_url($item['uri']);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_user_profile(&$variables) {
+ $account = $variables['elements']['#account'];
+ $uri = entity_uri('user', $account);
+
+ // Adds RDFa markup to the user profile page. Fields displayed in this page
+ // will automatically describe the user.
+ if (!empty($account->rdf_mapping['rdftype'])) {
+ $variables['attributes_array']['typeof'] = $account->rdf_mapping['rdftype'];
+ $variables['attributes_array']['about'] = url($uri['path'], $uri['options']);
+ }
+ // Adds the relationship between the sioc:UserAccount and the foaf:Person who
+ // holds the account.
+ $account_holder_meta = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'about' => url($uri['path'], array_merge($uri['options'], array('fragment' => 'me'))),
+ 'typeof' => array('foaf:Person'),
+ 'rel' => array('foaf:account'),
+ 'resource' => url($uri['path'], $uri['options']),
+ ),
+ );
+ // Adds the markup for username.
+ $username_meta = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'about' => url($uri['path'], $uri['options']),
+ 'property' => $account->rdf_mapping['name']['predicates'],
+ 'content' => $account->name,
+ )
+ );
+ drupal_add_html_head($account_holder_meta, 'rdf_user_account_holder');
+ drupal_add_html_head($username_meta, 'rdf_user_username');
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_username(&$variables) {
+ // Because xml:lang is set on the HTML element that wraps the page, the
+ // username inherits this language attribute. However, since the username
+ // might not be transliterated to the same language that the content is in,
+ // we do not want it to inherit the language attribute, so we set the
+ // attribute to an empty string.
+ if (empty($variables['attributes_array']['xml:lang'])) {
+ $variables['attributes_array']['xml:lang'] = '';
+ }
+
+ // $variables['account'] is a pseudo account object, and as such, does not
+ // contain the RDF mappings for the user. In the case of nodes and comments,
+ // it contains the mappings for the node or comment object instead. However,
+ // while the RDF mappings are available from a full user_load(), this should
+ // be avoided for performance reasons. Since the type and bundle for users is
+ // already known, call rdf_mapping_load() directly.
+ $rdf_mapping = rdf_mapping_load('user', 'user');
+
+ // The profile URI is used to identify the user account. The about attribute
+ // is used to set the URI as the default subject of the predicates embedded
+ // as RDFa in the child elements. Even if the user profile is not accessible
+ // to the current user, we use its URI in order to identify the user in RDF.
+ // We do not use this attribute for the anonymous user because we do not have
+ // a user profile URI for it (only a homepage which cannot be used as user
+ // profile in RDF.)
+ if ($variables['uid'] > 0) {
+ $variables['attributes_array']['about'] = url('user/' . $variables['uid']);
+ }
+
+ $attributes = array();
+ // The typeof attribute specifies the RDF type(s) of this resource. They
+ // are defined in the 'rdftype' property of the user RDF mapping.
+ if (!empty($rdf_mapping['rdftype'])) {
+ $attributes['typeof'] = $rdf_mapping['rdftype'];
+ }
+ // Annotate the user name in RDFa. The property attribute is used here
+ // because the user name is a literal.
+ if (!empty($rdf_mapping['name'])) {
+ $attributes['property'] = $rdf_mapping['name']['predicates'];
+ }
+ // Add the homepage RDFa markup if present.
+ if (!empty($variables['homepage']) && !empty($rdf_mapping['homepage'])) {
+ $attributes['rel'] = $rdf_mapping['homepage']['predicates'];
+ }
+ // The remaining attributes can have multiple values listed, with whitespace
+ // separating the values in the RDFa attributes
+ // (see http://www.w3.org/TR/rdfa-syntax/#rdfa-attributes).
+ // Therefore, merge rather than override so as not to clobber values set by
+ // earlier preprocess functions.
+ $variables['attributes_array'] = array_merge_recursive($variables['attributes_array'], $attributes);
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_comment(&$variables) {
+ $comment = $variables['comment'];
+ if (!empty($comment->rdf_mapping['rdftype'])) {
+ // Adds RDFa markup to the comment container. The about attribute specifies
+ // the URI of the resource described within the HTML element, while the
+ // typeof attribute indicates its RDF type (e.g., sioc:Post, foaf:Document,
+ // and so on.)
+ $uri = entity_uri('comment', $comment);
+ $variables['attributes_array']['about'] = url($uri['path'], $uri['options']);
+ $variables['attributes_array']['typeof'] = $comment->rdf_mapping['rdftype'];
+ }
+
+ // Adds RDFa markup for the date of the comment.
+ if (!empty($comment->rdf_mapping['created'])) {
+ // The comment date is precomputed as part of the rdf_data so that it can be
+ // cached as part of the entity.
+ $date_attributes_array = $comment->rdf_data['date'];
+ $variables['rdf_template_variable_attributes_array']['created'] = $date_attributes_array;
+ $variables['rdf_template_variable_attributes_array']['submitted'] = $date_attributes_array;
+ }
+ // Adds RDFa markup for the relation between the comment and its author.
+ if (!empty($comment->rdf_mapping['uid'])) {
+ $variables['rdf_template_variable_attributes_array']['author']['rel'] = $comment->rdf_mapping['uid']['predicates'];
+ $variables['rdf_template_variable_attributes_array']['submitted']['rel'] = $comment->rdf_mapping['uid']['predicates'];
+ }
+ if (!empty($comment->rdf_mapping['title'])) {
+ // Adds RDFa markup to the subject of the comment. Because the RDFa markup
+ // is added to an <h3> tag which might contain HTML code, we specify an
+ // empty datatype to ensure the value of the title read by the RDFa parsers
+ // is a literal.
+ $variables['title_attributes_array']['property'] = $comment->rdf_mapping['title']['predicates'];
+ $variables['title_attributes_array']['datatype'] = '';
+ }
+
+ // Annotates the parent relationship between the current comment and the node
+ // it belongs to. If available, the parent comment is also annotated.
+ if (!empty($comment->rdf_mapping['pid'])) {
+ // Adds the relation to the parent node.
+ $parent_node_attributes['rel'] = $comment->rdf_mapping['pid']['predicates'];
+ // The parent node URI is precomputed as part of the rdf_data so that it can
+ // be cached as part of the entity.
+ $parent_node_attributes['resource'] = $comment->rdf_data['nid_uri'];
+ $variables['rdf_metadata_attributes_array'][] = $parent_node_attributes;
+
+ // Adds the relation to parent comment, if it exists.
+ if ($comment->pid != 0) {
+ $parent_comment_attributes['rel'] = $comment->rdf_mapping['pid']['predicates'];
+ // The parent comment URI is precomputed as part of the rdf_data so that
+ // it can be cached as part of the entity.
+ $parent_comment_attributes['resource'] = $comment->rdf_data['pid_uri'];
+ $variables['rdf_metadata_attributes_array'][] = $parent_comment_attributes;
+ }
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_taxonomy_term(&$variables) {
+ // Adds the RDF type of the term and the term name in a <meta> tag.
+ $term = $variables['term'];
+ $term_label_meta = array(
+ '#tag' => 'meta',
+ '#attributes' => array(
+ 'about' => url('taxonomy/term/' . $term->tid),
+ 'typeof' => $term->rdf_mapping['rdftype'],
+ 'property' => $term->rdf_mapping['name']['predicates'],
+ 'content' => $term->name,
+ ),
+ );
+ drupal_add_html_head($term_label_meta, 'rdf_term_label');
+}
+
+/**
+ * Implements hook_field_attach_view_alter().
+ */
+function rdf_field_attach_view_alter(&$output, $context) {
+ // Append term mappings on displayed taxonomy links.
+ foreach (element_children($output) as $field_name) {
+ $element = &$output[$field_name];
+ if ($element['#field_type'] == 'taxonomy_term_reference' && $element['#formatter'] == 'taxonomy_term_reference_link') {
+ foreach ($element['#items'] as $delta => $item) {
+ // This function is invoked during entity preview when taxonomy term
+ // reference items might contain free-tagging terms that do not exist
+ // yet and thus have no $item['taxonomy_term'].
+ if (isset($item['taxonomy_term'])) {
+ $term = $item['taxonomy_term'];
+ if (!empty($term->rdf_mapping['rdftype'])) {
+ $element[$delta]['#options']['attributes']['typeof'] = $term->rdf_mapping['rdftype'];
+ }
+ if (!empty($term->rdf_mapping['name']['predicates'])) {
+ $element[$delta]['#options']['attributes']['property'] = $term->rdf_mapping['name']['predicates'];
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements MODULE_preprocess_HOOK().
+ */
+function rdf_preprocess_image(&$variables) {
+ $variables['attributes']['typeof'] = array('foaf:Image');
+}
+
+/**
+ * Returns HTML for a template variable wrapped in an HTML element with the
+ * RDF attributes.
+ *
+ * This is called by rdf_process() shortly before the theme system renders
+ * a template file. It is called once for each template variable for which
+ * additional attributes are needed. While template files are responsible for
+ * rendering the attributes for the template's primary object (via the
+ * $attributes variable), title (via the $title_attributes variable), and
+ * content (via the $content_attributes variable), additional template
+ * variables that need containing attributes are routed through this function,
+ * allowing the template file to receive properly wrapped variables.
+ *
+ * Tip for themers: if you're already outputting a wrapper element around a
+ * particular template variable in your template file, and if you don't want
+ * an extra wrapper element, you can override this function to not wrap that
+ * variable and instead print the following inside your template file:
+ * @code
+ * drupal_attributes($rdf_template_variable_attributes_array[$variable_name])
+ * @endcode
+ *
+ * @param $variables
+ * An associative array containing:
+ * - content: A string of content to be wrapped with attributes.
+ * - attributes: An array of attributes to be placed on the wrapping element.
+ * - context: An array of context information about the content to be wrapped:
+ * - hook: The theme hook that will use the wrapped content. This
+ * corresponds to the key within the theme registry for this template.
+ * For example, if this content is about to be used in node.tpl.php or
+ * node-[type].tpl.php, then the 'hook' is 'node'.
+ * - variable_name: The name of the variable by which the template will
+ * refer to this content. Each template file has documentation about
+ * the variables it uses. For example, if this function is called in
+ * preparing the $author variable for comment.tpl.php, then the
+ * 'variable_name' is 'author'.
+ * - variables: The full array of variables about to be passed to the
+ * template.
+ * - inline: TRUE if the content contains only inline HTML elements and
+ * therefore can be validly wrapped by a <span> tag. FALSE if the content
+ * might contain block level HTML elements and therefore cannot be validly
+ * wrapped by a <span> tag. Modules implementing preprocess functions that
+ * set 'rdf_template_variable_attributes_array' for a particular template
+ * variable that might contain block level HTML must also implement
+ * hook_preprocess_rdf_template_variable_wrapper() and set 'inline' to FALSE
+ * for that context. Themes that render normally inline content with block
+ * level HTML must similarly implement
+ * hook_preprocess_rdf_template_variable_wrapper() and set 'inline'
+ * accordingly.
+ *
+ * @see rdf_process()
+ * @ingroup themeable
+ * @ingroup rdf
+ */
+function theme_rdf_template_variable_wrapper($variables) {
+ $output = $variables['content'];
+ if (!empty($output) && !empty($variables['attributes'])) {
+ $attributes = drupal_attributes($variables['attributes']);
+ $output = $variables['inline'] ? "<span$attributes>$output</span>" : "<div$attributes>$output</div>";
+ }
+ return $output;
+}
+
+/**
+ * Returns HTML for a series of empty spans for exporting RDF metadata in RDFa.
+ *
+ * Sometimes it is useful to export data which is not semantically present in
+ * the HTML output. For example, a hierarchy of comments is visible for a human
+ * but not for machines because this hiearchy is not present in the DOM tree.
+ * We can express it in RDFa via empty <span> tags. These aren't visible and
+ * give machines extra information about the content and its structure.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - metadata: An array of attribute arrays. Each item in the array
+ * corresponds to its own set of attributes, and therefore, needs its own
+ * element.
+ *
+ * @see rdf_process()
+ * @ingroup themeable
+ * @ingroup rdf
+ */
+function theme_rdf_metadata($variables) {
+ $output = '';
+ foreach ($variables['metadata'] as $attributes) {
+ // Add a class so that developers viewing the HTML source can see why there
+ // are empty <span> tags in the document. The class can also be used to set
+ // a CSS display:none rule in a theme where empty spans affect display.
+ $attributes['class'][] = 'rdf-meta';
+ // The XHTML+RDFa doctype allows either <span></span> or <span /> syntax to
+ // be used, but for maximum browser compatibility, W3C recommends the
+ // former when serving pages using the text/html media type, see
+ // http://www.w3.org/TR/xhtml1/#C_3.
+ $output .= '<span' . drupal_attributes($attributes) . '></span>';
+ }
+ return $output;
+}
diff --git a/core/modules/rdf/rdf.test b/core/modules/rdf/rdf.test
new file mode 100644
index 000000000000..50177ca78d4e
--- /dev/null
+++ b/core/modules/rdf/rdf.test
@@ -0,0 +1,760 @@
+<?php
+
+/**
+ * @file
+ * Tests for rdf.module.
+ */
+
+class RdfMappingHookTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF mapping hook',
+ 'description' => 'Test hook_rdf_mapping().',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test', 'field_test');
+ }
+
+ /**
+ * Test that hook_rdf_mapping() correctly returns and processes mapping.
+ */
+ function testMapping() {
+ // Test that the mapping is returned correctly by the hook.
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $this->assertIdentical($mapping['rdftype'], array('sioc:Post'), t('Mapping for rdftype is sioc:Post.'));
+ $this->assertIdentical($mapping['title'], array('predicates' => array('dc:title')), t('Mapping for title is dc:title.'));
+ $this->assertIdentical($mapping['created'], array(
+ 'predicates' => array('dc:created'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ), t('Mapping for created is dc:created with datatype xsd:dateTime and callback date_iso8601.'));
+ $this->assertIdentical($mapping['uid'], array('predicates' => array('sioc:has_creator', 'dc:creator'), 'type' => 'rel'), t('Mapping for uid is sioc:has_creator and dc:creator, and type is rel.'));
+
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle_no_mapping');
+ $this->assertEqual($mapping, array(), t('Empty array returned when an entity type, bundle pair has no mapping.'));
+ }
+}
+
+/**
+ * Test RDFa markup generation.
+ */
+class RdfRdfaMarkupTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDFa markup',
+ 'description' => 'Test RDFa markup generation.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'field_test', 'rdf_test');
+ }
+
+ /**
+ * Test rdf_rdfa_attributes().
+ */
+ function testDrupalRdfaAttributes() {
+ // Same value as the one in the HTML tag (no callback function).
+ $expected_attributes = array(
+ 'property' => array('dc:title'),
+ );
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $attributes = rdf_rdfa_attributes($mapping['title']);
+ ksort($expected_attributes);
+ ksort($attributes);
+ $this->assertEqual($expected_attributes, $attributes);
+
+ // Value different from the one in the HTML tag (callback function).
+ $date = 1252750327;
+ $isoDate = date('c', $date);
+ $expected_attributes = array(
+ 'datatype' => 'xsd:dateTime',
+ 'property' => array('dc:created'),
+ 'content' => $isoDate,
+ );
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $attributes = rdf_rdfa_attributes($mapping['created'], $date);
+ ksort($expected_attributes);
+ ksort($attributes);
+ $this->assertEqual($expected_attributes, $attributes);
+
+ // Same value as the one in the HTML tag with datatype.
+ $expected_attributes = array(
+ 'datatype' => 'foo:bar1type',
+ 'property' => array('foo:bar1'),
+ );
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $attributes = rdf_rdfa_attributes($mapping['foobar1']);
+ ksort($expected_attributes);
+ ksort($attributes);
+ $this->assertEqual($expected_attributes, $attributes);
+
+ // ObjectProperty mapping (rel).
+ $expected_attributes = array(
+ 'rel' => array('sioc:has_creator', 'dc:creator'),
+ );
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $attributes = rdf_rdfa_attributes($mapping['foobar_objproperty1']);
+ ksort($expected_attributes);
+ ksort($attributes);
+ $this->assertEqual($expected_attributes, $attributes);
+
+ // Inverse ObjectProperty mapping (rev).
+ $expected_attributes = array(
+ 'rev' => array('sioc:reply_of'),
+ );
+ $mapping = rdf_mapping_load('test_entity', 'test_bundle');
+ $attributes = rdf_rdfa_attributes($mapping['foobar_objproperty2']);
+ ksort($expected_attributes);
+ ksort($attributes);
+ $this->assertEqual($expected_attributes, $attributes);
+ }
+
+ /**
+ * Ensure that file fields have the correct resource as the object in RDFa
+ * when displayed as a teaser.
+ */
+ function testAttributesInMarkupFile() {
+ // Create a user to post the image.
+ $admin_user = $this->drupalCreateUser(array('edit own article content', 'revert revisions', 'administer content types'));
+ $this->drupalLogin($admin_user);
+
+ $langcode = LANGUAGE_NONE;
+ $bundle_name = "article";
+
+ $field_name = 'file_test';
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'file',
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'node',
+ 'bundle' => $bundle_name,
+ 'display' => array(
+ 'teaser' => array(
+ 'type' => 'file_default',
+ ),
+ ),
+ );
+ field_create_instance($instance);
+
+ // Set the RDF mapping for the new field.
+ $rdf_mapping = rdf_mapping_load('node', $bundle_name);
+ $rdf_mapping += array($field_name => array('predicates' => array('rdfs:seeAlso'), 'type' => 'rel'));
+ $rdf_mapping_save = array('mapping' => $rdf_mapping, 'type' => 'node', 'bundle' => $bundle_name);
+ rdf_mapping_save($rdf_mapping_save);
+
+ // Get the test file that simpletest provides.
+ $file = current($this->drupalGetTestFiles('text'));
+
+ // Prepare image variables.
+ $image_field = "field_image";
+ // Get the test image that simpletest provides.
+ $image = current($this->drupalGetTestFiles('image'));
+
+ // Create an array for drupalPost with the field names as the keys and
+ // the uris for the test files as the values.
+ $edit = array("files[" . $field_name . "_" . $langcode . "_0]" => drupal_realpath($file->uri),
+ "files[" . $image_field . "_" . $langcode . "_0]" => drupal_realpath($image->uri));
+
+ // Create node and save, then edit node to upload files.
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ // Get filenames and nid for comparison with HTML output.
+ $file_filename = $file->filename;
+ $image_filename = $image->filename;
+ $nid = $node->nid;
+ // Navigate to front page, where node is displayed in teaser form.
+ $this->drupalGet('node');
+
+ // We only check to make sure that the resource attribute contains '.txt'
+ // instead of the full file name because the filename is altered on upload.
+ $file_rel = $this->xpath('//div[contains(@about, :node-uri)]//div[contains(@rel, "rdfs:seeAlso") and contains(@resource, ".txt")]', array(
+ ':node-uri' => 'node/' . $nid,
+ ));
+ $this->assertTrue(!empty($file_rel), t('Attribute \'rel\' set on file field. Attribute \'resource\' is also set.'));
+ $image_rel = $this->xpath('//div[contains(@about, :node-uri)]//div[contains(@rel, "rdfs:seeAlso") and contains(@resource, :image)]//img[contains(@typeof, "foaf:Image")]', array(
+ ':node-uri' => 'node/' . $nid,
+ ':image' => $image_filename,
+ ));
+
+ $this->assertTrue(!empty($image_rel), t('Attribute \'rel\' set on image field. Attribute \'resource\' is also set.'));
+
+ // Edits the node to add tags.
+ $tag1 = $this->randomName(8);
+ $tag2 = $this->randomName(8);
+ $edit = array();
+ $edit['field_tags[' . LANGUAGE_NONE . ']'] = "$tag1, $tag2";
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ // Ensures the RDFa markup for the relationship between the node and its
+ // tags is correct.
+ $term_rdfa_meta = $this->xpath('//div[@about=:node-url and contains(@typeof, "sioc:Item") and contains(@typeof, "foaf:Document")]//ul[@class="links"]/li[@rel="dc:subject"]/a[@typeof="skos:Concept" and text()=:term-name]', array(
+ ':node-url' => url('node/' . $node->nid),
+ ':term-name' => $tag1,
+ ));
+ $this->assertTrue(!empty($term_rdfa_meta), t('Property dc:subject is present for the tag1 field item.'));
+ $term_rdfa_meta = $this->xpath('//div[@about=:node-url and contains(@typeof, "sioc:Item") and contains(@typeof, "foaf:Document")]//ul[@class="links"]/li[@rel="dc:subject"]/a[@typeof="skos:Concept" and text()=:term-name]', array(
+ ':node-url' => url('node/' . $node->nid),
+ ':term-name' => $tag2,
+ ));
+ $this->assertTrue(!empty($term_rdfa_meta), t('Property dc:subject is present for the tag2 field item.'));
+ }
+}
+
+class RdfCrudTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF mapping CRUD functions',
+ 'description' => 'Test the RDF mapping CRUD functions.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test');
+ }
+
+ /**
+ * Test inserting, loading, updating, and deleting RDF mappings.
+ */
+ function testCRUD() {
+ // Verify loading of a default mapping.
+ $mapping = _rdf_mapping_load('test_entity', 'test_bundle');
+ $this->assertTrue(count($mapping), t('Default mapping was found.'));
+
+ // Verify saving a mapping.
+ $mapping = array(
+ 'type' => 'crud_test_entity',
+ 'bundle' => 'crud_test_bundle',
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post'),
+ 'title' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'uid' => array(
+ 'predicates' => array('sioc:has_creator', 'dc:creator'),
+ 'type' => 'rel',
+ ),
+ ),
+ );
+ $this->assertTrue(rdf_mapping_save($mapping) === SAVED_NEW, t('Mapping was saved.'));
+
+ // Read the raw record from the {rdf_mapping} table.
+ $result = db_query('SELECT * FROM {rdf_mapping} WHERE type = :type AND bundle = :bundle', array(':type' => $mapping['type'], ':bundle' => $mapping['bundle']));
+ $stored_mapping = $result->fetchAssoc();
+ $stored_mapping['mapping'] = unserialize($stored_mapping['mapping']);
+ $this->assertEqual($mapping, $stored_mapping, t('Mapping was stored properly in the {rdf_mapping} table.'));
+
+ // Verify loading of saved mapping.
+ $this->assertEqual($mapping['mapping'], _rdf_mapping_load($mapping['type'], $mapping['bundle']), t('Saved mapping loaded successfully.'));
+
+ // Verify updating of mapping.
+ $mapping['mapping']['title'] = array(
+ 'predicates' => array('dc2:bar2'),
+ );
+ $this->assertTrue(rdf_mapping_save($mapping) === SAVED_UPDATED, t('Mapping was updated.'));
+
+ // Read the raw record from the {rdf_mapping} table.
+ $result = db_query('SELECT * FROM {rdf_mapping} WHERE type = :type AND bundle = :bundle', array(':type' => $mapping['type'], ':bundle' => $mapping['bundle']));
+ $stored_mapping = $result->fetchAssoc();
+ $stored_mapping['mapping'] = unserialize($stored_mapping['mapping']);
+ $this->assertEqual($mapping, $stored_mapping, t('Updated mapping was stored properly in the {rdf_mapping} table.'));
+
+ // Verify loading of saved mapping.
+ $this->assertEqual($mapping['mapping'], _rdf_mapping_load($mapping['type'], $mapping['bundle']), t('Saved mapping loaded successfully.'));
+
+ // Verify deleting of mapping.
+ $this->assertTrue(rdf_mapping_delete($mapping['type'], $mapping['bundle']), t('Mapping was deleted.'));
+ $this->assertFalse(_rdf_mapping_load($mapping['type'], $mapping['bundle']), t('Deleted mapping is no longer found in the database.'));
+ }
+}
+
+class RdfMappingDefinitionTestCase extends TaxonomyWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF mapping definition functionality',
+ 'description' => 'Test the different types of RDF mappings and ensure the proper RDFa markup in included in nodes and user profile pages.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test');
+ }
+
+ /**
+ * Create a node of type article and test whether the RDF mapping defined for
+ * this node type in rdf_test.module is used in the node page.
+ */
+ function testAttributesInMarkup1() {
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $isoDate = date('c', $node->changed);
+ $url = url('node/' . $node->nid);
+ $this->drupalGet('node/' . $node->nid);
+
+ // Ensure the default bundle mapping for node is used. These attributes come
+ // from the node default bundle definition.
+ $node_title = $this->xpath("//meta[@property='dc:title' and @content='$node->title']");
+ $node_meta = $this->xpath("//div[(@about='$url')]//span[contains(@property, 'dc:date') and contains(@property, 'dc:created') and @datatype='xsd:dateTime' and @content='$isoDate']");
+ $this->assertTrue(!empty($node_title), t('Property dc:title is present in meta tag.'));
+ $this->assertTrue(!empty($node_meta), t('RDF type is present on post. Properties dc:date and dc:created are present on post date.'));
+ }
+
+ /**
+ * Create a content type and a node of type test_bundle_hook_install and test
+ * whether the RDF mapping defined in rdf_test.install is used.
+ */
+ function testAttributesInMarkup2() {
+ $type = $this->drupalCreateContentType(array('type' => 'test_bundle_hook_install'));
+ $node = $this->drupalCreateNode(array('type' => 'test_bundle_hook_install'));
+ $isoDate = date('c', $node->changed);
+ $url = url('node/' . $node->nid);
+ $this->drupalGet('node/' . $node->nid);
+
+ // Ensure the mapping defined in rdf_module.test is used.
+ $test_bundle_title = $this->xpath("//meta[@property='dc:title' and @content='$node->title']");
+ $test_bundle_meta = $this->xpath("//div[(@about='$url') and contains(@typeof, 'foo:mapping_install1') and contains(@typeof, 'bar:mapping_install2')]//span[contains(@property, 'dc:date') and contains(@property, 'dc:created') and @datatype='xsd:dateTime' and @content='$isoDate']");
+ $this->assertTrue(!empty($test_bundle_title), t('Property dc:title is present in meta tag.'));
+ $this->assertTrue(!empty($test_bundle_meta), t('RDF type is present on post. Properties dc:date and dc:created are present on post date.'));
+ }
+
+ /**
+ * Create a random content type and node and ensure the default mapping for
+ * node is used.
+ */
+ function testAttributesInMarkup3() {
+ $type = $this->drupalCreateContentType();
+ $node = $this->drupalCreateNode(array('type' => $type->type));
+ $isoDate = date('c', $node->changed);
+ $url = url('node/' . $node->nid);
+ $this->drupalGet('node/' . $node->nid);
+
+ // Ensure the default bundle mapping for node is used. These attributes come
+ // from the node default bundle definition.
+ $random_bundle_title = $this->xpath("//meta[@property='dc:title' and @content='$node->title']");
+ $random_bundle_meta = $this->xpath("//div[(@about='$url') and contains(@typeof, 'sioc:Item') and contains(@typeof, 'foaf:Document')]//span[contains(@property, 'dc:date') and contains(@property, 'dc:created') and @datatype='xsd:dateTime' and @content='$isoDate']");
+ $this->assertTrue(!empty($random_bundle_title), t('Property dc:title is present in meta tag.'));
+ $this->assertTrue(!empty($random_bundle_meta), t('RDF type is present on post. Properties dc:date and dc:created are present on post date.'));
+ }
+
+ /**
+ * Create a random user and ensure the default mapping for user is used.
+ */
+ function testUserAttributesInMarkup() {
+ // Create two users, one with access to user profiles.
+ $user1 = $this->drupalCreateUser(array('access user profiles'));
+ $user2 = $this->drupalCreateUser();
+ $username = $user2->name;
+ $this->drupalLogin($user1);
+ // Browse to the user profile page.
+ $this->drupalGet('user/' . $user2->uid);
+ // Ensure the default bundle mapping for user is used on the user profile
+ // page. These attributes come from the user default bundle definition.
+ $account_uri = url('user/' . $user2->uid);
+ $person_uri = url('user/' . $user2->uid, array('fragment' => 'me'));
+
+ $user2_profile_about = $this->xpath('//div[@class="profile" and @typeof="sioc:UserAccount" and @about=:account-uri]', array(
+ ':account-uri' => $account_uri,
+ ));
+ $this->assertTrue(!empty($user2_profile_about), t('RDFa markup found on user profile page'));
+
+ $user_account_holder = $this->xpath('//meta[contains(@typeof, "foaf:Person") and @about=:person-uri and @resource=:account-uri and contains(@rel, "foaf:account")]', array(
+ ':person-uri' => $person_uri,
+ ':account-uri' => $account_uri,
+ ));
+ $this->assertTrue(!empty($user_account_holder), t('URI created for account holder and username set on sioc:UserAccount.'));
+
+ $user_username = $this->xpath('//meta[@about=:account-uri and contains(@property, "foaf:name") and @content=:username]', array(
+ ':account-uri' => $account_uri,
+ ':username' => $username,
+ ));
+ $this->assertTrue(!empty($user_username), t('foaf:name set on username.'));
+
+ // User 2 creates node.
+ $this->drupalLogin($user2);
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $this->drupalLogin($user1);
+ $this->drupalGet('node/' . $node->nid);
+ // Ensures the default bundle mapping for user is used on the Authored By
+ // information on the node.
+ $author_about = $this->xpath('//a[@typeof="sioc:UserAccount" and @about=:account-uri and @property="foaf:name" and contains(@xml:lang, "")]', array(
+ ':account-uri' => $account_uri,
+ ));
+ $this->assertTrue(!empty($author_about), t('RDFa markup found on author information on post. xml:lang on username is set to empty string.'));
+ }
+
+ /**
+ * Creates a random term and ensures the right RDFa markup is used.
+ */
+ function testTaxonomyTermRdfaAttributes() {
+ $vocabulary = $this->createVocabulary();
+ $term = $this->createTerm($vocabulary);
+
+ // Views the term and checks that the RDFa markup is correct.
+ $this->drupalGet('taxonomy/term/' . $term->tid);
+ $term_url = url('taxonomy/term/' . $term->tid);
+ $term_name = $term->name;
+ $term_rdfa_meta = $this->xpath('//meta[@typeof="skos:Concept" and @about=:term-url and contains(@property, "rdfs:label") and contains(@property, "skos:prefLabel") and @content=:term-name]', array(
+ ':term-url' => $term_url,
+ ':term-name' => $term_name,
+ ));
+ $this->assertTrue(!empty($term_rdfa_meta), t('RDFa markup found on term page.'));
+ }
+}
+
+class RdfCommentAttributesTestCase extends CommentHelperCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF comment mapping',
+ 'description' => 'Tests the RDFa markup of comments.',
+ 'group' => 'RDF',
+ );
+ }
+
+ public function setUp() {
+ parent::setUp('comment', 'rdf', 'rdf_test');
+
+ $this->admin_user = $this->drupalCreateUser(array('administer content types', 'administer comments', 'administer permissions', 'administer blocks'));
+ $this->web_user = $this->drupalCreateUser(array('access comments', 'post comments', 'create article content', 'access user profiles'));
+
+ // Enables anonymous user comments.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => TRUE,
+ ));
+ // Allows anonymous to leave their contact information.
+ $this->setCommentAnonymous(COMMENT_ANONYMOUS_MAY_CONTACT);
+ $this->setCommentPreview(DRUPAL_OPTIONAL);
+ $this->setCommentForm(TRUE);
+ $this->setCommentSubject(TRUE);
+ $this->setCommentSettings('comment_default_mode', COMMENT_MODE_THREADED, t('Comment paging changed.'));
+
+ // Creates the nodes on which the test comments will be posted.
+ $this->drupalLogin($this->web_user);
+ $this->node1 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $this->node2 = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $this->drupalLogout();
+ }
+
+ /**
+ * Tests the presence of the RDFa markup for the number of comments.
+ */
+ public function testNumberOfCommentsRdfaMarkup() {
+ // Posts 2 comments as a registered user.
+ $this->drupalLogin($this->web_user);
+ $this->postComment($this->node1, $this->randomName(), $this->randomName());
+ $this->postComment($this->node1, $this->randomName(), $this->randomName());
+
+ // Tests number of comments in teaser view.
+ $this->drupalGet('node');
+ $comment_count_teaser = $this->xpath('//div[contains(@typeof, "sioc:Item")]//li[contains(@class, "comment-comments")]/a[contains(@property, "sioc:num_replies") and contains(@content, "2") and @datatype="xsd:integer"]');
+ $this->assertTrue(!empty($comment_count_teaser), t('RDFa markup for the number of comments found on teaser view.'));
+ $comment_count_link = $this->xpath('//div[@about=:url]//a[contains(@property, "sioc:num_replies") and @rel=""]', array(':url' => url("node/{$this->node1->nid}")));
+ $this->assertTrue(!empty($comment_count_link), t('Empty rel attribute found in comment count link.'));
+
+ // Tests number of comments in full node view.
+ $this->drupalGet('node/' . $this->node1->nid);
+ $node_url = url('node/' . $this->node1->nid);
+ $comment_count_teaser = $this->xpath('/html/head/meta[@about=:node-url and @property="sioc:num_replies" and @content="2" and @datatype="xsd:integer"]', array(':node-url' => $node_url));
+ $this->assertTrue(!empty($comment_count_teaser), t('RDFa markup for the number of comments found on full node view.'));
+ }
+
+ /**
+ * Tests the presence of the RDFa markup for the title, date and author and
+ * homepage on registered users and anonymous comments.
+ */
+ public function testCommentRdfaMarkup() {
+
+ // Posts comment #1 as a registered user.
+ $this->drupalLogin($this->web_user);
+ $comment1_subject = $this->randomName();
+ $comment1_body = $this->randomName();
+ $comment1 = $this->postComment($this->node1, $comment1_body, $comment1_subject);
+
+ // Tests comment #1 with access to the user profile.
+ $this->drupalGet('node/' . $this->node1->nid);
+ $this->_testBasicCommentRdfaMarkup($comment1);
+
+ // Tests comment #1 with no access to the user profile (as anonymous user).
+ $this->drupalLogout();
+ $this->drupalGet('node/' . $this->node1->nid);
+ $this->_testBasicCommentRdfaMarkup($comment1);
+
+ // Posts comment #2 as anonymous user.
+ $comment2_subject = $this->randomName();
+ $comment2_body = $this->randomName();
+ $anonymous_user = array();
+ $anonymous_user['name'] = $this->randomName();
+ $anonymous_user['mail'] = 'tester@simpletest.org';
+ $anonymous_user['homepage'] = 'http://example.org/';
+ $comment2 = $this->postComment($this->node2, $comment2_body, $comment2_subject, $anonymous_user);
+ $this->drupalGet('node/' . $this->node2->nid);
+
+ // Tests comment #2 as anonymous user.
+ $this->_testBasicCommentRdfaMarkup($comment2, $anonymous_user);
+ // Tests the RDFa markup for the homepage (specific to anonymous comments).
+ $comment_homepage = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//span[@rel="sioc:has_creator"]/a[contains(@class, "username") and @typeof="sioc:UserAccount" and @property="foaf:name" and @href="http://example.org/" and contains(@rel, "foaf:page")]');
+ $this->assertTrue(!empty($comment_homepage), t('RDFa markup for the homepage of anonymous user found.'));
+ // There should be no about attribute on anonymous comments.
+ $comment_homepage = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//span[@rel="sioc:has_creator"]/a[@about]');
+ $this->assertTrue(empty($comment_homepage), t('No about attribute is present on anonymous user comment.'));
+
+ // Tests comment #2 as logged in user.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('node/' . $this->node2->nid);
+ $this->_testBasicCommentRdfaMarkup($comment2, $anonymous_user);
+ // Tests the RDFa markup for the homepage (specific to anonymous comments).
+ $comment_homepage = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//span[@rel="sioc:has_creator"]/a[contains(@class, "username") and @typeof="sioc:UserAccount" and @property="foaf:name" and @href="http://example.org/" and contains(@rel, "foaf:page")]');
+ $this->assertTrue(!empty($comment_homepage), t("RDFa markup for the homepage of anonymous user found."));
+ // There should be no about attribute on anonymous comments.
+ $comment_homepage = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//span[@rel="sioc:has_creator"]/a[@about]');
+ $this->assertTrue(empty($comment_homepage), t("No about attribute is present on anonymous user comment."));
+ }
+
+ /**
+ * Test RDF comment replies.
+ */
+ public function testCommentReplyOfRdfaMarkup() {
+ // Posts comment #1 as a registered user.
+ $this->drupalLogin($this->web_user);
+ $comments[] = $this->postComment($this->node1, $this->randomName(), $this->randomName());
+
+ // Tests the reply_of relationship of a first level comment.
+ $result = $this->xpath("(id('comments')//div[contains(@class,'comment ')])[position()=1]//span[@rel='sioc:reply_of' and @resource=:node]", array(':node' => url("node/{$this->node1->nid}")));
+ $this->assertEqual(1, count($result), t('RDFa markup referring to the node is present.'));
+ $result = $this->xpath("(id('comments')//div[contains(@class,'comment ')])[position()=1]//span[@rel='sioc:reply_of' and @resource=:comment]", array(':comment' => url('comment/1#comment-1')));
+ $this->assertFalse($result, t('No RDFa markup referring to the comment itself is present.'));
+
+ // Posts a reply to the first comment.
+ $this->drupalGet('comment/reply/' . $this->node1->nid . '/' . $comments[0]->id);
+ $comments[] = $this->postComment(NULL, $this->randomName(), $this->randomName(), TRUE);
+
+ // Tests the reply_of relationship of a second level comment.
+ $result = $this->xpath("(id('comments')//div[contains(@class,'comment ')])[position()=2]//span[@rel='sioc:reply_of' and @resource=:node]", array(':node' => url("node/{$this->node1->nid}")));
+ $this->assertEqual(1, count($result), t('RDFa markup referring to the node is present.'));
+ $result = $this->xpath("(id('comments')//div[contains(@class,'comment ')])[position()=2]//span[@rel='sioc:reply_of' and @resource=:comment]", array(':comment' => url('comment/1', array('fragment' => 'comment-1'))));
+ $this->assertEqual(1, count($result), t('RDFa markup referring to the parent comment is present.'));
+ $comments = $this->xpath("(id('comments')//div[contains(@class,'comment ')])[position()=2]");
+ }
+
+ /**
+ * Helper function for testCommentRdfaMarkup().
+ *
+ * Tests the current page for basic comment RDFa markup.
+ *
+ * @param $comment
+ * Comment object.
+ * @param $account
+ * An array containing information about an anonymous user.
+ */
+ function _testBasicCommentRdfaMarkup($comment, $account = array()) {
+ $comment_container = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]');
+ $this->assertTrue(!empty($comment_container), t("Comment RDF type for comment found."));
+ $comment_title = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//h3[@property="dc:title"]');
+ $this->assertEqual((string)$comment_title[0]->a, $comment->subject, t("RDFa markup for the comment title found."));
+ $comment_date = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//*[contains(@property, "dc:date") and contains(@property, "dc:created")]');
+ $this->assertTrue(!empty($comment_date), t("RDFa markup for the date of the comment found."));
+ // The author tag can be either a or span
+ $comment_author = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//span[@rel="sioc:has_creator"]/*[contains(@class, "username") and @typeof="sioc:UserAccount" and @property="foaf:name"]');
+ $name = empty($account["name"]) ? $this->web_user->name : $account["name"] . " (not verified)";
+ $this->assertEqual((string)$comment_author[0], $name, t("RDFa markup for the comment author found."));
+ $comment_body = $this->xpath('//div[contains(@class, "comment") and contains(@typeof, "sioct:Comment")]//div[@class="content"]//div[contains(@class, "comment-body")]//div[@property="content:encoded"]');
+ $this->assertEqual((string)$comment_body[0]->p, $comment->comment, t("RDFa markup for the comment body found."));
+ }
+}
+
+class RdfTrackerAttributesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF tracker page mapping',
+ 'description' => 'Test the mapping for the tracker page and ensure the proper RDFa markup in included.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test', 'tracker');
+ // Enable anonymous posting of content.
+ user_role_change_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'create article content' => TRUE,
+ 'access comments' => TRUE,
+ 'post comments' => TRUE,
+ 'skip comment approval' => TRUE,
+ ));
+ }
+
+ /**
+ * Create nodes as both admin and anonymous user and test for correct RDFa
+ * markup on the tracker page for those nodes and their comments.
+ */
+ function testAttributesInTracker() {
+ // Create node as anonymous user.
+ $node_anon = $this->drupalCreateNode(array('type' => 'article', 'uid' => 0));
+ // Create node as admin user.
+ $node_admin = $this->drupalCreateNode(array('type' => 'article', 'uid' => 1));
+
+ // Pass both the anonymously posted node and the administrator posted node
+ // through to test for the RDF attributes.
+ $this->_testBasicTrackerRdfaMarkup($node_anon);
+ $this->_testBasicTrackerRdfaMarkup($node_admin);
+
+ }
+
+ /**
+ * Helper function for testAttributesInTracker().
+ *
+ * Tests the tracker page for RDFa markup.
+ *
+ * @param $node
+ * The node just created.
+ */
+ function _testBasicTrackerRdfaMarkup($node) {
+ $url = url('node/' . $node->nid);
+
+ $user = ($node->uid == 0) ? 'Anonymous user' : 'Registered user';
+
+ // Navigate to tracker page.
+ $this->drupalGet('tracker');
+
+ // Tests whether the about property is applied. This is implicit in the
+ // success of the following tests, but making it explicit will make
+ // debugging easier in case of failure.
+ $tracker_about = $this->xpath('//tr[@about=:url]', array(':url' => $url));
+ $this->assertTrue(!empty($tracker_about), t('About attribute found on table row for @user content.', array('@user'=> $user)));
+
+ // Tests whether the title has the correct property attribute.
+ $tracker_title = $this->xpath('//tr[@about=:url]/td[@property="dc:title" and @datatype=""]', array(':url' => $url));
+ $this->assertTrue(!empty($tracker_title), t('Title property attribute found on @user content.', array('@user'=> $user)));
+
+ // Tests whether the relationship between the content and user has been set.
+ $tracker_user = $this->xpath('//tr[@about=:url]//td[contains(@rel, "sioc:has_creator")]//*[contains(@typeof, "sioc:UserAccount") and contains(@property, "foaf:name")]', array(':url' => $url));
+ $this->assertTrue(!empty($tracker_user), t('Typeof and name property attributes found on @user.', array('@user'=> $user)));
+ // There should be an about attribute on logged in users and no about
+ // attribute for anonymous users.
+ $tracker_user = $this->xpath('//tr[@about=:url]//td[@rel="sioc:has_creator"]/*[@about]', array(':url' => $url));
+ if ($node->uid == 0) {
+ $this->assertTrue(empty($tracker_user), t('No about attribute is present on @user.', array('@user'=> $user)));
+ }
+ elseif ($node->uid > 0) {
+ $this->assertTrue(!empty($tracker_user), t('About attribute is present on @user.', array('@user'=> $user)));
+ }
+
+ // Tests whether the property has been set for number of comments.
+ $tracker_replies = $this->xpath('//tr[@about=:url]//td[contains(@property, "sioc:num_replies") and contains(@content, "0") and @datatype="xsd:integer"]', array(':url' => $url));
+ $this->assertTrue($tracker_replies, t('Num replies property and content attributes found on @user content.', array('@user'=> $user)));
+
+ // Tests that the appropriate RDFa markup to annotate the latest activity
+ // date has been added to the tracker output before comments have been
+ // posted, meaning the latest activity reflects changes to the node itself.
+ $isoDate = date('c', $node->changed);
+ $tracker_activity = $this->xpath('//tr[@about=:url]//td[contains(@property, "dc:modified") and contains(@property, "sioc:last_activity_date") and contains(@datatype, "xsd:dateTime") and @content=:date]', array(':url' => $url, ':date' => $isoDate));
+ $this->assertTrue(!empty($tracker_activity), t('Latest activity date and changed properties found when there are no comments on @user content. Latest activity date content is correct.', array('@user'=> $user)));
+
+ // Tests that the appropriate RDFa markup to annotate the latest activity
+ // date has been added to the tracker output after a comment is posted.
+ $comment = array(
+ 'subject' => $this->randomName(),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $this->randomName(),
+ );
+ $this->drupalPost('comment/reply/' . $node->nid, $comment, t('Save'));
+ $this->drupalGet('tracker');
+
+ // Tests whether the property has been set for number of comments.
+ $tracker_replies = $this->xpath('//tr[@about=:url]//td[contains(@property, "sioc:num_replies") and contains(@content, "1") and @datatype="xsd:integer"]', array(':url' => $url));
+ $this->assertTrue($tracker_replies, t('Num replies property and content attributes found on @user content.', array('@user'=> $user)));
+
+ // Need to query database directly to obtain last_activity_date because
+ // it cannot be accessed via node_load().
+ $result = db_query('SELECT t.changed FROM {tracker_node} t WHERE t.nid = (:nid)', array(':nid' => $node->nid));
+ foreach ($result as $node) {
+ $expected_last_activity_date = $node->changed;
+ }
+ $isoDate = date('c', $expected_last_activity_date);
+ $tracker_activity = $this->xpath('//tr[@about=:url]//td[@property="sioc:last_activity_date" and @datatype="xsd:dateTime" and @content=:date]', array(':url' => $url, ':date' => $isoDate));
+ $this->assertTrue(!empty($tracker_activity), t('Latest activity date found when there are comments on @user content. Latest activity date content is correct.', array('@user'=> $user)));
+ }
+}
+
+/**
+ * Tests for RDF namespaces declaration with hook_rdf_namespaces().
+ */
+class RdfGetRdfNamespacesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF namespaces',
+ 'description' => 'Test hook_rdf_namespaces() and ensure only "safe" namespaces are returned.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test');
+ }
+
+ /**
+ * Test getting RDF namesapces.
+ */
+ function testGetRdfNamespaces() {
+ // Get all RDF namespaces.
+ $ns = rdf_get_namespaces();
+
+ $this->assertEqual($ns['rdfs'], 'http://www.w3.org/2000/01/rdf-schema#', t('A prefix declared once is included.'));
+ $this->assertEqual($ns['foaf'], 'http://xmlns.com/foaf/0.1/', t('The same prefix declared in several implementations of hook_rdf_namespaces() is valid as long as all the namespaces are the same.'));
+ $this->assertEqual($ns['foaf1'], 'http://xmlns.com/foaf/0.1/', t('Two prefixes can be assigned the same namespace.'));
+ $this->assertTrue(!isset($ns['dc']), t('A prefix with conflicting namespaces is discarded.'));
+ }
+}
+
+/**
+ * Tests for RDF namespaces XML serialization.
+ */
+class DrupalGetRdfNamespacesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'RDF namespaces serialization test',
+ 'description' => 'Confirm that the serialization of RDF namespaces in present in the HTML markup.',
+ 'group' => 'RDF',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('rdf', 'rdf_test');
+ }
+
+ /**
+ * Test RDF namespaces.
+ */
+ function testGetRdfNamespaces() {
+ // Fetches the front page and extracts RDFa 1.1 prefixes.
+ $this->drupalGet('');
+
+ $element = $this->xpath('//html[contains(@prefix, :prefix_binding)]', array(
+ ':prefix_binding' => 'rdfs: http://www.w3.org/2000/01/rdf-schema#',
+ ));
+ $this->assertTrue(!empty($element), t('A prefix declared once is displayed.'));
+
+ $element = $this->xpath('//html[contains(@prefix, :prefix_binding)]', array(
+ ':prefix_binding' => 'foaf: http://xmlns.com/foaf/0.1/',
+ ));
+ $this->assertTrue(!empty($element), t('The same prefix declared in several implementations of hook_rdf_namespaces() is valid as long as all the namespaces are the same.'));
+
+ $element = $this->xpath('//html[contains(@prefix, :prefix_binding)]', array(
+ ':prefix_binding' => 'foaf1: http://xmlns.com/foaf/0.1/',
+ ));
+ $this->assertTrue(!empty($element), t('Two prefixes can be assigned the same namespace.'));
+
+ $element = $this->xpath('//html[contains(@prefix, :prefix_binding)]', array(
+ ':prefix_binding' => 'dc: ',
+ ));
+ $this->assertTrue(empty($element), t('A prefix with conflicting namespaces is discarded.'));
+ }
+}
diff --git a/core/modules/rdf/tests/rdf_test.info b/core/modules/rdf/tests/rdf_test.info
new file mode 100644
index 000000000000..b168815f8f56
--- /dev/null
+++ b/core/modules/rdf/tests/rdf_test.info
@@ -0,0 +1,6 @@
+name = "RDF module tests"
+description = "Support module for RDF module testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/rdf/tests/rdf_test.install b/core/modules/rdf/tests/rdf_test.install
new file mode 100644
index 000000000000..91a33927dac0
--- /dev/null
+++ b/core/modules/rdf/tests/rdf_test.install
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the rdf module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function rdf_test_install() {
+ $rdf_mappings = array(
+ array(
+ 'type' => 'node',
+ 'bundle' => 'test_bundle_hook_install',
+ 'mapping' => array(
+ 'rdftype' => array('foo:mapping_install1', 'bar:mapping_install2'),
+ ),
+ ),
+ );
+
+ foreach ($rdf_mappings as $rdf_mapping) {
+ rdf_mapping_save($rdf_mapping);
+ }
+}
diff --git a/core/modules/rdf/tests/rdf_test.module b/core/modules/rdf/tests/rdf_test.module
new file mode 100644
index 000000000000..4ad43f31e50c
--- /dev/null
+++ b/core/modules/rdf/tests/rdf_test.module
@@ -0,0 +1,59 @@
+<?php
+
+/**
+ * @file
+ * Test API interaction with the RDF module.
+ */
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function rdf_test_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'mapping' => array(
+ 'rdftype' => array('sioc:Post'),
+ 'title' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'created' => array(
+ 'predicates' => array('dc:created'),
+ 'datatype' => 'xsd:dateTime',
+ 'callback' => 'date_iso8601',
+ ),
+ 'uid' => array(
+ 'predicates' => array('sioc:has_creator', 'dc:creator'),
+ 'type' => 'rel',
+ ),
+ 'foobar' => array(
+ 'predicates' => array('foo:bar'),
+ ),
+ 'foobar1' => array(
+ 'datatype' => 'foo:bar1type',
+ 'predicates' => array('foo:bar1'),
+ ),
+ 'foobar_objproperty1' => array(
+ 'predicates' => array('sioc:has_creator', 'dc:creator'),
+ 'type' => 'rel',
+ ),
+ 'foobar_objproperty2' => array(
+ 'predicates' => array('sioc:reply_of'),
+ 'type' => 'rev',
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_rdf_namespaces().
+ */
+function rdf_test_rdf_namespaces() {
+ return array(
+ 'dc' => 'http://purl.org/conflicting/namespace',
+ 'foaf' => 'http://xmlns.com/foaf/0.1/',
+ 'foaf1' => 'http://xmlns.com/foaf/0.1/',
+ );
+}
diff --git a/core/modules/search/search-result.tpl.php b/core/modules/search/search-result.tpl.php
new file mode 100644
index 000000000000..949452ac34cb
--- /dev/null
+++ b/core/modules/search/search-result.tpl.php
@@ -0,0 +1,79 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for displaying a single search result.
+ *
+ * This template renders a single search result and is collected into
+ * search-results.tpl.php. This and the parent template are
+ * dependent to one another sharing the markup for definition lists.
+ *
+ * Available variables:
+ * - $url: URL of the result.
+ * - $title: Title of the result.
+ * - $snippet: A small preview of the result. Does not apply to user searches.
+ * - $info: String of all the meta information ready for print. Does not apply
+ * to user searches.
+ * - $info_split: Contains same data as $info, split into a keyed array.
+ * - $module: The machine-readable name of the module (tab) being searched, such
+ * as "node" or "user".
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * Default keys within $info_split:
+ * - $info_split['type']: Node type (or item type string supplied by module).
+ * - $info_split['user']: Author of the node linked to users profile. Depends
+ * on permission.
+ * - $info_split['date']: Last update of the node. Short formatted.
+ * - $info_split['comment']: Number of comments output as "% comments", %
+ * being the count. Depends on comment.module.
+ *
+ * Other variables:
+ * - $classes_array: Array of HTML class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $title_attributes_array: Array of HTML attributes for the title. It is
+ * flattened into a string within the variable $title_attributes.
+ * - $content_attributes_array: Array of HTML attributes for the content. It is
+ * flattened into a string within the variable $content_attributes.
+ *
+ * Since $info_split is keyed, a direct print of the item is possible.
+ * This array does not apply to user searches so it is recommended to check
+ * for its existence before printing. The default keys of 'type', 'user' and
+ * 'date' always exist for node searches. Modules may provide other data.
+ * @code
+ * <?php if (isset($info_split['comment'])): ?>
+ * <span class="info-comment">
+ * <?php print $info_split['comment']; ?>
+ * </span>
+ * <?php endif; ?>
+ * @endcode
+ *
+ * To check for all available data within $info_split, use the code below.
+ * @code
+ * <?php print '<pre>'. check_plain(print_r($info_split, 1)) .'</pre>'; ?>
+ * @endcode
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_search_result()
+ * @see template_process()
+ */
+?>
+<li class="<?php print $classes; ?>"<?php print $attributes; ?>>
+ <?php print render($title_prefix); ?>
+ <h3 class="title"<?php print $title_attributes; ?>>
+ <a href="<?php print $url; ?>"><?php print $title; ?></a>
+ </h3>
+ <?php print render($title_suffix); ?>
+ <div class="search-snippet-info">
+ <?php if ($snippet): ?>
+ <p class="search-snippet"<?php print $content_attributes; ?>><?php print $snippet; ?></p>
+ <?php endif; ?>
+ <?php if ($info): ?>
+ <p class="search-info"><?php print $info; ?></p>
+ <?php endif; ?>
+ </div>
+</li>
diff --git a/core/modules/search/search-results.tpl.php b/core/modules/search/search-results.tpl.php
new file mode 100644
index 000000000000..e35be1edcfaa
--- /dev/null
+++ b/core/modules/search/search-results.tpl.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation for displaying search results.
+ *
+ * This template collects each invocation of theme_search_result(). This and
+ * the child template are dependent to one another sharing the markup for
+ * definition lists.
+ *
+ * Note that modules may implement their own search type and theme function
+ * completely bypassing this template.
+ *
+ * Available variables:
+ * - $search_results: All results as it is rendered through
+ * search-result.tpl.php
+ * - $module: The machine-readable name of the module (tab) being searched, such
+ * as "node" or "user".
+ *
+ *
+ * @see template_preprocess_search_results()
+ */
+?>
+<?php if ($search_results): ?>
+ <h2><?php print t('Search results');?></h2>
+ <ol class="search-results <?php print $module; ?>-results">
+ <?php print $search_results; ?>
+ </ol>
+ <?php print $pager; ?>
+<?php else : ?>
+ <h2><?php print t('Your search yielded no results');?></h2>
+ <?php print search_help('search#noresults', drupal_help_arg()); ?>
+<?php endif; ?>
diff --git a/core/modules/search/search-rtl.css b/core/modules/search/search-rtl.css
new file mode 100644
index 000000000000..da9e8d9de548
--- /dev/null
+++ b/core/modules/search/search-rtl.css
@@ -0,0 +1,13 @@
+
+.search-advanced .criterion {
+ float: right;
+ margin-right: 0;
+ margin-left: 2em;
+}
+.search-advanced .action {
+ float: right;
+ clear: right;
+}
+.search-results .search-snippet-info {
+ padding-right: 1em; /* LTR */
+} \ No newline at end of file
diff --git a/core/modules/search/search.admin.inc b/core/modules/search/search.admin.inc
new file mode 100644
index 000000000000..fda14ee7b6a3
--- /dev/null
+++ b/core/modules/search/search.admin.inc
@@ -0,0 +1,185 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the search module.
+ */
+
+/**
+ * Menu callback: confirm wiping of the index.
+ */
+function search_reindex_confirm() {
+ return confirm_form(array(), t('Are you sure you want to re-index the site?'),
+ 'admin/config/search/settings', t('The search index is not cleared but systematically updated to reflect the new settings. Searching will continue to work but new content won\'t be indexed until all existing content has been re-indexed. This action cannot be undone.'), t('Re-index site'), t('Cancel'));
+}
+
+/**
+ * Handler for wipe confirmation
+ */
+function search_reindex_confirm_submit(&$form, &$form_state) {
+ if ($form['confirm']) {
+ search_reindex();
+ drupal_set_message(t('The index will be rebuilt.'));
+ $form_state['redirect'] = 'admin/config/search/settings';
+ return;
+ }
+}
+
+/**
+ * Helper function to get real module names.
+ */
+function _search_get_module_names() {
+
+ $search_info = search_get_info(TRUE);
+ $system_info = system_get_info('module');
+ $names = array();
+ foreach ($search_info as $module => $info) {
+ $names[$module] = $system_info[$module]['name'];
+ }
+ asort($names, SORT_STRING);
+ return $names;
+}
+
+/**
+ * Menu callback: displays the search module settings page.
+ *
+ * @ingroup forms
+ *
+ * @see search_admin_settings_validate()
+ * @see search_admin_settings_submit()
+ * @see search_admin_reindex_submit()
+ */
+function search_admin_settings($form) {
+ // Collect some stats
+ $remaining = 0;
+ $total = 0;
+ foreach (variable_get('search_active_modules', array('node', 'user')) as $module) {
+ if ($status = module_invoke($module, 'search_status')) {
+ $remaining += $status['remaining'];
+ $total += $status['total'];
+ }
+ }
+
+ $count = format_plural($remaining, 'There is 1 item left to index.', 'There are @count items left to index.');
+ $percentage = ((int)min(100, 100 * ($total - $remaining) / max(1, $total))) . '%';
+ $status = '<p><strong>' . t('%percentage of the site has been indexed.', array('%percentage' => $percentage)) . ' ' . $count . '</strong></p>';
+ $form['status'] = array('#type' => 'fieldset', '#title' => t('Indexing status'));
+ $form['status']['status'] = array('#markup' => $status);
+ $form['status']['wipe'] = array('#type' => 'submit', '#value' => t('Re-index site'), '#submit' => array('search_admin_reindex_submit'));
+
+ $items = drupal_map_assoc(array(10, 20, 50, 100, 200, 500));
+
+ // Indexing throttle:
+ $form['indexing_throttle'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Indexing throttle')
+ );
+ $form['indexing_throttle']['search_cron_limit'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of items to index per cron run'),
+ '#default_value' => variable_get('search_cron_limit', 100),
+ '#options' => $items,
+ '#description' => t('The maximum number of items indexed in each pass of a <a href="@cron">cron maintenance task</a>. If necessary, reduce the number of items to prevent timeouts and memory errors while indexing.', array('@cron' => url('admin/reports/status')))
+ );
+ // Indexing settings:
+ $form['indexing_settings'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Indexing settings')
+ );
+ $form['indexing_settings']['info'] = array(
+ '#markup' => t('<p><em>Changing the settings below will cause the site index to be rebuilt. The search index is not cleared but systematically updated to reflect the new settings. Searching will continue to work but new content won\'t be indexed until all existing content has been re-indexed.</em></p><p><em>The default settings should be appropriate for the majority of sites.</em></p>')
+ );
+ $form['indexing_settings']['minimum_word_size'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Minimum word length to index'),
+ '#default_value' => variable_get('minimum_word_size', 3),
+ '#size' => 5,
+ '#maxlength' => 3,
+ '#description' => t('The number of characters a word has to be to be indexed. A lower setting means better search result ranking, but also a larger database. Each search query must contain at least one keyword that is this size (or longer).')
+ );
+ $form['indexing_settings']['overlap_cjk'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Simple CJK handling'),
+ '#default_value' => variable_get('overlap_cjk', TRUE),
+ '#description' => t('Whether to apply a simple Chinese/Japanese/Korean tokenizer based on overlapping sequences. Turn this off if you want to use an external preprocessor for this instead. Does not affect other languages.')
+ );
+
+ $form['active'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Active search modules')
+ );
+ $module_options = _search_get_module_names();
+ $form['active']['search_active_modules'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Active modules'),
+ '#title_display' => 'invisible',
+ '#default_value' => variable_get('search_active_modules', array('node', 'user')),
+ '#options' => $module_options,
+ '#description' => t('Choose which search modules are active from the available modules.')
+ );
+ $form['active']['search_default_module'] = array(
+ '#title' => t('Default search module'),
+ '#type' => 'radios',
+ '#default_value' => variable_get('search_default_module', 'node'),
+ '#options' => $module_options,
+ '#description' => t('Choose which search module is the default.')
+ );
+ $form['#validate'][] = 'search_admin_settings_validate';
+ $form['#submit'][] = 'search_admin_settings_submit';
+
+ // Per module settings
+ foreach (variable_get('search_active_modules', array('node', 'user')) as $module) {
+ $added_form = module_invoke($module, 'search_admin');
+ if (is_array($added_form)) {
+ $form = array_merge($form, $added_form);
+ }
+ }
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form validation handler for search_admin_settings().
+ */
+function search_admin_settings_validate($form, &$form_state) {
+ // Check whether we selected a valid default.
+ if ($form_state['triggering_element']['#value'] != t('Reset to defaults')) {
+ $new_modules = array_filter($form_state['values']['search_active_modules']);
+ $default = $form_state['values']['search_default_module'];
+ if (!in_array($default, $new_modules, TRUE)) {
+ form_set_error('search_default_module', t('Your default search module is not selected as an active module.'));
+ }
+ }
+}
+
+/**
+ * Form submission handler for search_admin_settings().
+ */
+function search_admin_settings_submit($form, &$form_state) {
+ // If these settings change, the index needs to be rebuilt.
+ if ((variable_get('minimum_word_size', 3) != $form_state['values']['minimum_word_size']) ||
+ (variable_get('overlap_cjk', TRUE) != $form_state['values']['overlap_cjk'])) {
+ drupal_set_message(t('The index will be rebuilt.'));
+ search_reindex();
+ }
+ $current_modules = variable_get('search_active_modules', array('node', 'user'));
+ // Check whether we are resetting the values.
+ if ($form_state['triggering_element']['#value'] == t('Reset to defaults')) {
+ $new_modules = array('node', 'user');
+ }
+ else {
+ $new_modules = array_filter($form_state['values']['search_active_modules']);
+ }
+ if (array_diff($current_modules, $new_modules)) {
+ drupal_set_message(t('The active search modules have been changed.'));
+ variable_set('menu_rebuild_needed', TRUE);
+ }
+}
+
+/**
+ * Form submission handler for reindex button on search_admin_settings_form().
+ */
+function search_admin_reindex_submit($form, &$form_state) {
+ // send the user to the confirmation page
+ $form_state['redirect'] = 'admin/config/search/settings/reindex';
+}
diff --git a/core/modules/search/search.api.php b/core/modules/search/search.api.php
new file mode 100644
index 000000000000..3f745bfdb0b9
--- /dev/null
+++ b/core/modules/search/search.api.php
@@ -0,0 +1,368 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Search module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Define a custom search type.
+ *
+ * This hook allows a module to tell search.module that it wishes to perform
+ * searches on content it defines (custom node types, users, or comments for
+ * example) when a site search is performed.
+ *
+ * In order for the search to do anything, your module must also implement
+ * hook_search_execute(), which is called when someone requests a search
+ * on your module's type of content. If you want to have your content
+ * indexed in the standard search index, your module should also implement
+ * hook_update_index(). If your search type has settings, you can implement
+ * hook_search_admin() to add them to the search settings page. You can also
+ * alter the display of your module's search results by implementing
+ * hook_search_page(). You can use hook_form_FORM_ID_alter(), with
+ * FORM_ID set to 'search', to add fields to the search form (see
+ * node_form_search_form_alter() for an example). You can use
+ * hook_search_access() to limit access to searching, and hook_search_page() to
+ * override how search results are displayed.
+ *
+ * @return
+ * Array with optional keys:
+ * - 'title': Title for the tab on the search page for this module. Defaults
+ * to the module name if not given.
+ * - 'path': Path component after 'search/' for searching with this module.
+ * Defaults to the module name if not given.
+ * - 'conditions_callback': Name of a callback function that is invoked by
+ * search_view() to get an array of additional search conditions to pass to
+ * search_data(). For example, a search module may get additional keywords,
+ * filters, or modifiers for the search from the query string. Sample
+ * callback function: sample_search_conditions_callback().
+ *
+ * @ingroup search
+ */
+function hook_search_info() {
+ return array(
+ 'title' => 'Content',
+ 'path' => 'node',
+ 'conditions_callback' => 'sample_search_conditions_callback',
+ );
+}
+
+/**
+ * An example conditions callback function for search.
+ *
+ * This example pulls additional search keywords out of the $_REQUEST variable,
+ * (i.e. from the query string of the request). The conditions may also be
+ * generated internally - for example based on a module's settings.
+ *
+ * @see hook_search_info()
+ * @ingroup search
+ */
+function sample_search_conditions_callback($keys) {
+ $conditions = array();
+
+ if (!empty($_REQUEST['keys'])) {
+ $conditions['keys'] = $_REQUEST['keys'];
+ }
+ if (!empty($_REQUEST['sample_search_keys'])) {
+ $conditions['sample_search_keys'] = $_REQUEST['sample_search_keys'];
+ }
+ if ($force_keys = variable_get('sample_search_force_keywords', '')) {
+ $conditions['sample_search_force_keywords'] = $force_keys;
+ }
+ return $conditions;
+}
+
+/**
+ * Define access to a custom search routine.
+ *
+ * This hook allows a module to define permissions for a search tab.
+ *
+ * @ingroup search
+ */
+function hook_search_access() {
+ return user_access('access content');
+}
+
+/**
+ * Take action when the search index is going to be rebuilt.
+ *
+ * Modules that use hook_update_index() should update their indexing
+ * bookkeeping so that it starts from scratch the next time
+ * hook_update_index() is called.
+ *
+ * @ingroup search
+ */
+function hook_search_reset() {
+ db_update('search_dataset')
+ ->fields(array('reindex' => REQUEST_TIME))
+ ->condition('type', 'node')
+ ->execute();
+}
+
+/**
+ * Report the status of indexing.
+ *
+ * The core search module only invokes this hook on active modules.
+ * Implementing modules do not need to check whether they are active when
+ * calculating their return values.
+ *
+ * @return
+ * An associative array with the key-value pairs:
+ * - 'remaining': The number of items left to index.
+ * - 'total': The total number of items to index.
+ *
+ * @ingroup search
+ */
+function hook_search_status() {
+ $total = db_query('SELECT COUNT(*) FROM {node} WHERE status = 1')->fetchField();
+ $remaining = db_query("SELECT COUNT(*) FROM {node} n LEFT JOIN {search_dataset} d ON d.type = 'node' AND d.sid = n.nid WHERE n.status = 1 AND d.sid IS NULL OR d.reindex <> 0")->fetchField();
+ return array('remaining' => $remaining, 'total' => $total);
+}
+
+/**
+ * Add elements to the search settings form.
+ *
+ * @return
+ * Form array for the Search settings page at admin/config/search/settings.
+ *
+ * @ingroup search
+ */
+function hook_search_admin() {
+ // Output form for defining rank factor weights.
+ $form['content_ranking'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Content ranking'),
+ );
+ $form['content_ranking']['#theme'] = 'node_search_admin';
+ $form['content_ranking']['info'] = array(
+ '#value' => '<em>' . t('The following numbers control which properties the content search should favor when ordering the results. Higher numbers mean more influence, zero means the property is ignored. Changing these numbers does not require the search index to be rebuilt. Changes take effect immediately.') . '</em>'
+ );
+
+ // Note: reversed to reflect that higher number = higher ranking.
+ $options = drupal_map_assoc(range(0, 10));
+ foreach (module_invoke_all('ranking') as $var => $values) {
+ $form['content_ranking']['factors']['node_rank_' . $var] = array(
+ '#title' => $values['title'],
+ '#type' => 'select',
+ '#options' => $options,
+ '#default_value' => variable_get('node_rank_' . $var, 0),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Execute a search for a set of key words.
+ *
+ * Use database API with the 'PagerDefault' query extension to perform your
+ * search.
+ *
+ * If your module uses hook_update_index() and search_index() to index its
+ * items, use table 'search_index' aliased to 'i' as the main table in your
+ * query, with the 'SearchQuery' extension. You can join to your module's table
+ * using the 'i.sid' field, which will contain the $sid values you provided to
+ * search_index(). Add the main keywords to the query by using method
+ * searchExpression(). The functions search_expression_extract() and
+ * search_expression_insert() may also be helpful for adding custom search
+ * parameters to the search expression.
+ *
+ * See node_search_execute() for an example of a module that uses the search
+ * index, and user_search_execute() for an example that doesn't use the search
+ * index.
+ *
+ * @param $keys
+ * The search keywords as entered by the user.
+ * @param $conditions
+ * An optional array of additional conditions, such as filters.
+ *
+ * @return
+ * An array of search results. To use the default search result
+ * display, each item should have the following keys':
+ * - 'link': Required. The URL of the found item.
+ * - 'type': The type of item (such as the content type).
+ * - 'title': Required. The name of the item.
+ * - 'user': The author of the item.
+ * - 'date': A timestamp when the item was last modified.
+ * - 'extra': An array of optional extra information items.
+ * - 'snippet': An excerpt or preview to show with the result (can be
+ * generated with search_excerpt()).
+ * - 'language': Language code for the item (usually two characters).
+ *
+ * @ingroup search
+ */
+function hook_search_execute($keys = NULL, $conditions = NULL) {
+ // Build matching conditions
+ $query = db_select('search_index', 'i', array('target' => 'slave'))->extend('SearchQuery')->extend('PagerDefault');
+ $query->join('node', 'n', 'n.nid = i.sid');
+ $query
+ ->condition('n.status', 1)
+ ->addTag('node_access')
+ ->searchExpression($keys, 'node');
+
+ // Insert special keywords.
+ $query->setOption('type', 'n.type');
+ $query->setOption('language', 'n.language');
+ if ($query->setOption('term', 'ti.tid')) {
+ $query->join('taxonomy_index', 'ti', 'n.nid = ti.nid');
+ }
+ // Only continue if the first pass query matches.
+ if (!$query->executeFirstPass()) {
+ return array();
+ }
+
+ // Add the ranking expressions.
+ _node_rankings($query);
+
+ // Load results.
+ $find = $query
+ ->limit(10)
+ ->execute();
+ $results = array();
+ foreach ($find as $item) {
+ // Build the node body.
+ $node = node_load($item->sid);
+ node_build_content($node, 'search_result');
+ $node->body = drupal_render($node->content);
+
+ // Fetch comments for snippet.
+ $node->rendered .= ' ' . module_invoke('comment', 'node_update_index', $node);
+ // Fetch terms for snippet.
+ $node->rendered .= ' ' . module_invoke('taxonomy', 'node_update_index', $node);
+
+ $extra = module_invoke_all('node_search_result', $node);
+
+ $results[] = array(
+ 'link' => url('node/' . $item->sid, array('absolute' => TRUE)),
+ 'type' => check_plain(node_type_get_name($node)),
+ 'title' => $node->title,
+ 'user' => theme('username', array('account' => $node)),
+ 'date' => $node->changed,
+ 'node' => $node,
+ 'extra' => $extra,
+ 'score' => $item->calculated_score,
+ 'snippet' => search_excerpt($keys, $node->body),
+ );
+ }
+ return $results;
+}
+
+/**
+ * Override the rendering of search results.
+ *
+ * A module that implements hook_search_info() to define a type of search
+ * may implement this hook in order to override the default theming of
+ * its search results, which is otherwise themed using theme('search_results').
+ *
+ * Note that by default, theme('search_results') and theme('search_result')
+ * work together to create an ordered list (OL). So your hook_search_page()
+ * implementation should probably do this as well.
+ *
+ * @see search-result.tpl.php, search-results.tpl.php
+ *
+ * @param $results
+ * An array of search results.
+ *
+ * @return
+ * A renderable array, which will render the formatted search results with
+ * a pager included.
+ */
+function hook_search_page($results) {
+ $output['prefix']['#markup'] = '<ol class="search-results">';
+
+ foreach ($results as $entry) {
+ $output[] = array(
+ '#theme' => 'search_result',
+ '#result' => $entry,
+ '#module' => 'my_module_name',
+ );
+ }
+ $output['suffix']['#markup'] = '</ol>' . theme('pager');
+
+ return $output;
+}
+
+/**
+ * Preprocess text for search.
+ *
+ * This hook is called to preprocess both the text added to the search index and
+ * the keywords users have submitted for searching.
+ *
+ * Possible uses:
+ * - Adding spaces between words of Chinese or Japanese text.
+ * - Stemming words down to their root words to allow matches between, for
+ * instance, walk, walked, walking, and walks in searching.
+ * - Expanding abbreviations and acronymns that occur in text.
+ *
+ * @param $text
+ * The text to preprocess. This is a single piece of plain text extracted
+ * from between two HTML tags or from the search query. It will not contain
+ * any HTML entities or HTML tags.
+ *
+ * @return
+ * The text after preprocessing. Note that if your module decides not to alter
+ * the text, it should return the original text. Also, after preprocessing,
+ * words in the text should be separated by a space.
+ *
+ * @ingroup search
+ */
+function hook_search_preprocess($text) {
+ // Do processing on $text
+ return $text;
+}
+
+/**
+ * Update the search index for this module.
+ *
+ * This hook is called every cron run if search.module is enabled, your
+ * module has implemented hook_search_info(), and your module has been set as
+ * an active search module on the Search settings page
+ * (admin/config/search/settings). It allows your module to add items to the
+ * built-in search index using search_index(), or to add them to your module's
+ * own indexing mechanism.
+ *
+ * When implementing this hook, your module should index content items that
+ * were modified or added since the last run. PHP has a time limit
+ * for cron, though, so it is advisable to limit how many items you index
+ * per run using variable_get('search_cron_limit') (see example below). Also,
+ * since the cron run could time out and abort in the middle of your run, you
+ * should update your module's internal bookkeeping on when items have last
+ * been indexed as you go rather than waiting to the end of indexing.
+ *
+ * @ingroup search
+ */
+function hook_update_index() {
+ $limit = (int)variable_get('search_cron_limit', 100);
+
+ $result = db_query_range("SELECT n.nid FROM {node} n LEFT JOIN {search_dataset} d ON d.type = 'node' AND d.sid = n.nid WHERE d.sid IS NULL OR d.reindex <> 0 ORDER BY d.reindex ASC, n.nid ASC", 0, $limit);
+
+ foreach ($result as $node) {
+ $node = node_load($node->nid);
+
+ // Save the changed time of the most recent indexed node, for the search
+ // results half-life calculation.
+ variable_set('node_cron_last', $node->changed);
+
+ // Render the node.
+ node_build_content($node, 'search_index');
+ $node->rendered = drupal_render($node->content);
+
+ $text = '<h1>' . check_plain($node->title) . '</h1>' . $node->rendered;
+
+ // Fetch extra data normally not visible
+ $extra = module_invoke_all('node_update_index', $node);
+ foreach ($extra as $t) {
+ $text .= $t;
+ }
+
+ // Update index
+ search_index($node->nid, 'node', $text);
+ }
+}
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/search/search.css b/core/modules/search/search.css
new file mode 100644
index 000000000000..ff7230fbc40f
--- /dev/null
+++ b/core/modules/search/search.css
@@ -0,0 +1,34 @@
+
+.search-form {
+ margin-bottom: 1em;
+}
+.search-form input {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.search-results {
+ list-style: none;
+}
+.search-results p {
+ margin-top: 0;
+}
+.search-results .title {
+ font-size: 1.2em;
+}
+.search-results li {
+ margin-bottom: 1em;
+}
+.search-results .search-snippet-info {
+ padding-left: 1em; /* LTR */
+}
+.search-results .search-info {
+ font-size: 0.85em;
+}
+.search-advanced .criterion {
+ float: left; /* LTR */
+ margin-right: 2em; /* LTR */
+}
+.search-advanced .action {
+ float: left; /* LTR */
+ clear: left; /* LTR */
+}
diff --git a/core/modules/search/search.extender.inc b/core/modules/search/search.extender.inc
new file mode 100644
index 000000000000..b7af4d06ace4
--- /dev/null
+++ b/core/modules/search/search.extender.inc
@@ -0,0 +1,483 @@
+<?php
+
+/**
+ * @file
+ * Search query extender and helper functions.
+ */
+
+/**
+ * Do a query on the full-text search index for a word or words.
+ *
+ * This function is normally only called by each module that supports the
+ * indexed search (and thus, implements hook_update_index()).
+ *
+ * Results are retrieved in two logical passes. However, the two passes are
+ * joined together into a single query. And in the case of most simple
+ * queries the second pass is not even used.
+ *
+ * The first pass selects a set of all possible matches, which has the benefit
+ * of also providing the exact result set for simple "AND" or "OR" searches.
+ *
+ * The second portion of the query further refines this set by verifying
+ * advanced text conditions (such as negative or phrase matches).
+ *
+ * The used query object has the tag 'search_$module' and can be further
+ * extended with hook_query_alter().
+ */
+class SearchQuery extends SelectQueryExtender {
+ /**
+ * The search query that is used for searching.
+ *
+ * @var string
+ */
+ protected $searchExpression;
+
+ /**
+ * Type of search (search module).
+ *
+ * This maps to the value of the type column in search_index, and is equal
+ * to the machine-readable name of the module that implements
+ * hook_search_info().
+ *
+ * @var string
+ */
+ protected $type;
+
+ /**
+ * Positive and negative search keys.
+ *
+ * @var array
+ */
+ protected $keys = array('positive' => array(), 'negative' => array());
+
+ /**
+ * Indicates whether the first pass query requires complex conditions (LIKE).
+ *
+ * @var boolean.
+ */
+ protected $simple = TRUE;
+
+ /**
+ * Conditions that are used for exact searches.
+ *
+ * This is always used for the second pass query but not for the first pass,
+ * unless $this->simple is FALSE.
+ *
+ * @var DatabaseCondition
+ */
+ protected $conditions;
+
+ /**
+ * Indicates how many matches for a search query are necessary.
+ *
+ * @var int
+ */
+ protected $matches = 0;
+
+ /**
+ * Array of search words.
+ *
+ * These words have to match against {search_index}.word.
+ *
+ * @var array
+ */
+ protected $words = array();
+
+ /**
+ * Multiplier for the normalized search score.
+ *
+ * This value is calculated by the first pass query and multiplied with the
+ * actual score of a specific word to make sure that the resulting calculated
+ * score is between 0 and 1.
+ *
+ * @var float
+ */
+ protected $normalize;
+
+ /**
+ * Indicates whether the first pass query has been executed.
+ *
+ * @var boolean
+ */
+ protected $executedFirstPass = FALSE;
+
+ /**
+ * Stores score expressions.
+ *
+ * @var array
+ */
+ protected $scores = array();
+
+ /**
+ * Stores arguments for score expressions.
+ *
+ * @var array
+ */
+ protected $scoresArguments = array();
+
+ /**
+ * Total value of all the multipliers.
+ *
+ * @var array
+ */
+ protected $multiply = array();
+
+ /**
+ * Sets up the search query expression.
+ *
+ * @param $query
+ * A search query string, which can contain options.
+ * @param $module
+ * The search module. This maps to {search_index}.type in the database.
+ *
+ * @return
+ * The SearchQuery object.
+ */
+ public function searchExpression($expression, $module) {
+ $this->searchExpression = $expression;
+ $this->type = $module;
+
+ return $this;
+ }
+
+ /**
+ * Applies a search option and removes it from the search query string.
+ *
+ * These options are in the form option:value,value2,value3.
+ *
+ * @param $option
+ * Name of the option.
+ * @param $column
+ * Name of the database column to which the value should be applied.
+ *
+ * @return
+ * TRUE if a value for that option was found, FALSE if not.
+ */
+ public function setOption($option, $column) {
+ if ($values = search_expression_extract($this->searchExpression, $option)) {
+ $or = db_or();
+ foreach (explode(',', $values) as $value) {
+ $or->condition($column, $value);
+ }
+ $this->condition($or);
+ $this->searchExpression = search_expression_insert($this->searchExpression, $option);
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Parses the search query into SQL conditions.
+ *
+ * We build two queries that match the dataset bodies.
+ */
+ protected function parseSearchExpression() {
+ // Matchs words optionally prefixed by a dash. A word in this case is
+ // something between two spaces, optionally quoted.
+ preg_match_all('/ (-?)("[^"]+"|[^" ]+)/i', ' ' . $this->searchExpression , $keywords, PREG_SET_ORDER);
+
+ if (count($keywords) == 0) {
+ return;
+ }
+
+ // Classify tokens.
+ $or = FALSE;
+ $warning = '';
+ foreach ($keywords as $match) {
+ $phrase = FALSE;
+ // Strip off phrase quotes.
+ if ($match[2]{0} == '"') {
+ $match[2] = substr($match[2], 1, -1);
+ $phrase = TRUE;
+ $this->simple = FALSE;
+ }
+ // Simplify keyword according to indexing rules and external
+ // preprocessors. Use same process as during search indexing, so it
+ // will match search index.
+ $words = search_simplify($match[2]);
+ // Re-explode in case simplification added more words, except when
+ // matching a phrase.
+ $words = $phrase ? array($words) : preg_split('/ /', $words, -1, PREG_SPLIT_NO_EMPTY);
+ // Negative matches.
+ if ($match[1] == '-') {
+ $this->keys['negative'] = array_merge($this->keys['negative'], $words);
+ }
+ // OR operator: instead of a single keyword, we store an array of all
+ // OR'd keywords.
+ elseif ($match[2] == 'OR' && count($this->keys['positive'])) {
+ $last = array_pop($this->keys['positive']);
+ // Starting a new OR?
+ if (!is_array($last)) {
+ $last = array($last);
+ }
+ $this->keys['positive'][] = $last;
+ $or = TRUE;
+ continue;
+ }
+ // AND operator: implied, so just ignore it.
+ elseif ($match[2] == 'AND' || $match[2] == 'and') {
+ $warning = $match[2];
+ continue;
+ }
+
+ // Plain keyword.
+ else {
+ if ($match[2] == 'or') {
+ $warning = $match[2];
+ }
+ if ($or) {
+ // Add to last element (which is an array).
+ $this->keys['positive'][count($this->keys['positive']) - 1] = array_merge($this->keys['positive'][count($this->keys['positive']) - 1], $words);
+ }
+ else {
+ $this->keys['positive'] = array_merge($this->keys['positive'], $words);
+ }
+ }
+ $or = FALSE;
+ }
+
+ // Convert keywords into SQL statements.
+ $this->conditions = db_and();
+ $simple_and = FALSE;
+ $simple_or = FALSE;
+ // Positive matches.
+ foreach ($this->keys['positive'] as $key) {
+ // Group of ORed terms.
+ if (is_array($key) && count($key)) {
+ $simple_or = TRUE;
+ $any = FALSE;
+ $queryor = db_or();
+ foreach ($key as $or) {
+ list($num_new_scores) = $this->parseWord($or);
+ $any |= $num_new_scores;
+ $queryor->condition('d.data', "% $or %", 'LIKE');
+ }
+ if (count($queryor)) {
+ $this->conditions->condition($queryor);
+ // A group of OR keywords only needs to match once.
+ $this->matches += ($any > 0);
+ }
+ }
+ // Single ANDed term.
+ else {
+ $simple_and = TRUE;
+ list($num_new_scores, $num_valid_words) = $this->parseWord($key);
+ $this->conditions->condition('d.data', "% $key %", 'LIKE');
+ if (!$num_valid_words) {
+ $this->simple = FALSE;
+ }
+ // Each AND keyword needs to match at least once.
+ $this->matches += $num_new_scores;
+ }
+ }
+ if ($simple_and && $simple_or) {
+ $this->simple = FALSE;
+ }
+ // Negative matches.
+ foreach ($this->keys['negative'] as $key) {
+ $this->conditions->condition('d.data', "% $key %", 'NOT LIKE');
+ $this->simple = FALSE;
+ }
+
+ if ($warning == 'or') {
+ drupal_set_message(t('Search for either of the two terms with uppercase <strong>OR</strong>. For example, <strong>cats OR dogs</strong>.'));
+ }
+ }
+
+ /**
+ * Helper function for parseQuery().
+ */
+ protected function parseWord($word) {
+ $num_new_scores = 0;
+ $num_valid_words = 0;
+ // Determine the scorewords of this word/phrase.
+ $split = explode(' ', $word);
+ foreach ($split as $s) {
+ $num = is_numeric($s);
+ if ($num || drupal_strlen($s) >= variable_get('minimum_word_size', 3)) {
+ if (!isset($this->words[$s])) {
+ $this->words[$s] = $s;
+ $num_new_scores++;
+ }
+ $num_valid_words++;
+ }
+ }
+ // Return matching snippet and number of added words.
+ return array($num_new_scores, $num_valid_words);
+ }
+
+ /**
+ * Executes the first pass query.
+ *
+ * This can either be done explicitly, so that additional scores and
+ * conditions can be applied to the second pass query, or implicitly by
+ * addScore() or execute().
+ *
+ * @return
+ * TRUE if search items exist, FALSE if not.
+ */
+ public function executeFirstPass() {
+ $this->parseSearchExpression();
+
+ if (count($this->words) == 0) {
+ form_set_error('keys', format_plural(variable_get('minimum_word_size', 3), 'You must include at least one positive keyword with 1 character or more.', 'You must include at least one positive keyword with @count characters or more.'));
+ return FALSE;
+ }
+ $this->executedFirstPass = TRUE;
+
+ if (!empty($this->words)) {
+ $or = db_or();
+ foreach ($this->words as $word) {
+ $or->condition('i.word', $word);
+ }
+ $this->condition($or);
+ }
+ // Build query for keyword normalization.
+ $this->join('search_total', 't', 'i.word = t.word');
+ $this
+ ->condition('i.type', $this->type)
+ ->groupBy('i.type')
+ ->groupBy('i.sid')
+ ->having('COUNT(*) >= :matches', array(':matches' => $this->matches));
+
+ // Clone the query object to do the firstPass query;
+ $first = clone $this->query;
+
+ // For complex search queries, add the LIKE conditions to the first pass query.
+ if (!$this->simple) {
+ $first->join('search_dataset', 'd', 'i.sid = d.sid AND i.type = d.type');
+ $first->condition($this->conditions);
+ }
+
+ // Calculate maximum keyword relevance, to normalize it.
+ $first->addExpression('SUM(i.score * t.count)', 'calculated_score');
+ $this->normalize = $first
+ ->range(0, 1)
+ ->orderBy('calculated_score', 'DESC')
+ ->execute()
+ ->fetchField();
+
+ if ($this->normalize) {
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ /**
+ * Adds a custom score expression to the search query.
+ *
+ * Each score expression can optionally use a multiplier, and multiple
+ * expressions are combined.
+ *
+ * @param $score
+ * The score expression.
+ * @param $arguments
+ * Custom query arguments for that expression.
+ * @param $multiply
+ * If set, the score is multiplied with that value. Search query ensures
+ * that the search scores are still normalized.
+ */
+ public function addScore($score, $arguments = array(), $multiply = FALSE) {
+ if ($multiply) {
+ $i = count($this->multiply);
+ $score = "CAST(:multiply_$i AS DECIMAL) * COALESCE(( " . $score . "), 0) / CAST(:total_$i AS DECIMAL)";
+ $arguments[':multiply_' . $i] = $multiply;
+ $this->multiply[] = $multiply;
+ }
+
+ $this->scores[] = $score;
+ $this->scoresArguments += $arguments;
+
+ return $this;
+ }
+
+ /**
+ * Executes the search.
+ *
+ * If not already done, this executes the first pass query. Then the complex
+ * conditions are applied to the query including score expressions and
+ * ordering.
+ *
+ * @return
+ * FALSE if the first pass query returned no results, and a database result
+ * set if there were results.
+ */
+ public function execute()
+ {
+ if (!$this->executedFirstPass) {
+ $this->executeFirstPass();
+ }
+ if (!$this->normalize) {
+ return new DatabaseStatementEmpty();
+ }
+
+ // Add conditions to query.
+ $this->join('search_dataset', 'd', 'i.sid = d.sid AND i.type = d.type');
+ $this->condition($this->conditions);
+
+ if (empty($this->scores)) {
+ // Add default score.
+ $this->addScore('i.relevance');
+ }
+
+ if (count($this->multiply)) {
+ // Add the total multiplicator as many times as requested to maintain
+ // normalization as far as possible.
+ $i = 0;
+ $sum = array_sum($this->multiply);
+ foreach ($this->multiply as $total) {
+ $this->scoresArguments[':total_' . $i] = $sum;
+ $i++;
+ }
+ }
+
+ // Replace i.relevance pseudo-field with the actual, normalized value.
+ $this->scores = str_replace('i.relevance', '(' . (1.0 / $this->normalize) . ' * i.score * t.count)', $this->scores);
+ // Convert scores to an expression.
+ $this->addExpression('SUM(' . implode(' + ', $this->scores) . ')', 'calculated_score', $this->scoresArguments);
+
+ if (count($this->getOrderBy()) == 0) {
+ // Add default order after adding the expression.
+ $this->orderBy('calculated_score', 'DESC');
+ }
+
+ // Add tag and useful metadata.
+ $this
+ ->addTag('search_' . $this->type)
+ ->addMetaData('normalize', $this->normalize)
+ ->fields('i', array('type', 'sid'));
+
+ return $this->query->execute();
+ }
+
+ /**
+ * Builds the default count query for SearchQuery.
+ *
+ * Since SearchQuery always uses GROUP BY, we can default to a subquery. We
+ * also add the same conditions as execute() because countQuery() is called
+ * first.
+ */
+ public function countQuery() {
+ // Clone the inner query.
+ $inner = clone $this->query;
+
+ // Add conditions to query.
+ $inner->join('search_dataset', 'd', 'i.sid = d.sid AND i.type = d.type');
+ $inner->condition($this->conditions);
+
+ // Remove existing fields and expressions, they are not needed for a count
+ // query.
+ $fields =& $inner->getFields();
+ $fields = array();
+ $expressions =& $inner->getExpressions();
+ $expressions = array();
+
+ // Add the sid as the only field and count them as a subquery.
+ $count = db_select($inner->fields('i', array('sid')), NULL, array('target' => 'slave'));
+
+ // Add the COUNT() expression.
+ $count->addExpression('COUNT(*)');
+
+ return $count;
+ }
+}
diff --git a/core/modules/search/search.info b/core/modules/search/search.info
new file mode 100644
index 000000000000..d8d7baa232bd
--- /dev/null
+++ b/core/modules/search/search.info
@@ -0,0 +1,9 @@
+name = Search
+description = Enables site-wide keyword searching.
+package = Core
+version = VERSION
+core = 8.x
+files[] = search.extender.inc
+files[] = search.test
+configure = admin/config/search/settings
+stylesheets[all][] = search.css
diff --git a/core/modules/search/search.install b/core/modules/search/search.install
new file mode 100644
index 000000000000..c450f0593ff1
--- /dev/null
+++ b/core/modules/search/search.install
@@ -0,0 +1,155 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the search module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function search_uninstall() {
+ variable_del('minimum_word_size');
+ variable_del('overlap_cjk');
+ variable_del('search_cron_limit');
+}
+
+/**
+ * Implements hook_schema().
+ */
+function search_schema() {
+ $schema['search_dataset'] = array(
+ 'description' => 'Stores items that will be searched.',
+ 'fields' => array(
+ 'sid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Search item ID, e.g. node ID for nodes.',
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 16,
+ 'not null' => TRUE,
+ 'description' => 'Type of item, e.g. node.',
+ ),
+ 'data' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'description' => 'List of space-separated words from the item.',
+ ),
+ 'reindex' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Set to force node reindexing.',
+ ),
+ ),
+ 'primary key' => array('sid', 'type'),
+ );
+
+ $schema['search_index'] = array(
+ 'description' => 'Stores the search index, associating words, items and scores.',
+ 'fields' => array(
+ 'word' => array(
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The {search_total}.word that is associated with the search item.',
+ ),
+ 'sid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {search_dataset}.sid of the searchable item to which the word belongs.',
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 16,
+ 'not null' => TRUE,
+ 'description' => 'The {search_dataset}.type of the searchable item to which the word belongs.',
+ ),
+ 'score' => array(
+ 'type' => 'float',
+ 'not null' => FALSE,
+ 'description' => 'The numeric score of the word, higher being more important.',
+ ),
+ ),
+ 'indexes' => array(
+ 'sid_type' => array('sid', 'type'),
+ ),
+ 'foreign keys' => array(
+ 'search_dataset' => array(
+ 'table' => 'search_dataset',
+ 'columns' => array(
+ 'sid' => 'sid',
+ 'type' => 'type',
+ ),
+ ),
+ ),
+ 'primary key' => array('word', 'sid', 'type'),
+ );
+
+ $schema['search_total'] = array(
+ 'description' => 'Stores search totals for words.',
+ 'fields' => array(
+ 'word' => array(
+ 'description' => 'Primary Key: Unique word in the search index.',
+ 'type' => 'varchar',
+ 'length' => 50,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'count' => array(
+ 'description' => "The count of the word in the index using Zipf's law to equalize the probability distribution.",
+ 'type' => 'float',
+ 'not null' => FALSE,
+ ),
+ ),
+ 'primary key' => array('word'),
+ );
+
+ $schema['search_node_links'] = array(
+ 'description' => 'Stores items (like nodes) that link to other nodes, used to improve search scores for nodes that are frequently linked to.',
+ 'fields' => array(
+ 'sid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {search_dataset}.sid of the searchable item containing the link to the node.',
+ ),
+ 'type' => array(
+ 'type' => 'varchar',
+ 'length' => 16,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The {search_dataset}.type of the searchable item containing the link to the node.',
+ ),
+ 'nid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {node}.nid that this item links to.',
+ ),
+ 'caption' => array(
+ 'type' => 'text',
+ 'size' => 'big',
+ 'not null' => FALSE,
+ 'description' => 'The text used to link to the {node}.nid.',
+ ),
+ ),
+ 'primary key' => array('sid', 'type', 'nid'),
+ 'indexes' => array(
+ 'nid' => array('nid'),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/search/search.module b/core/modules/search/search.module
new file mode 100644
index 000000000000..e731e4f2dc44
--- /dev/null
+++ b/core/modules/search/search.module
@@ -0,0 +1,1328 @@
+<?php
+
+/**
+ * @file
+ * Enables site-wide keyword searching.
+ */
+
+/**
+ * Matches all 'N' Unicode character classes (numbers)
+ */
+define('PREG_CLASS_NUMBERS',
+ '\x{30}-\x{39}\x{b2}\x{b3}\x{b9}\x{bc}-\x{be}\x{660}-\x{669}\x{6f0}-\x{6f9}' .
+ '\x{966}-\x{96f}\x{9e6}-\x{9ef}\x{9f4}-\x{9f9}\x{a66}-\x{a6f}\x{ae6}-\x{aef}' .
+ '\x{b66}-\x{b6f}\x{be7}-\x{bf2}\x{c66}-\x{c6f}\x{ce6}-\x{cef}\x{d66}-\x{d6f}' .
+ '\x{e50}-\x{e59}\x{ed0}-\x{ed9}\x{f20}-\x{f33}\x{1040}-\x{1049}\x{1369}-' .
+ '\x{137c}\x{16ee}-\x{16f0}\x{17e0}-\x{17e9}\x{17f0}-\x{17f9}\x{1810}-\x{1819}' .
+ '\x{1946}-\x{194f}\x{2070}\x{2074}-\x{2079}\x{2080}-\x{2089}\x{2153}-\x{2183}' .
+ '\x{2460}-\x{249b}\x{24ea}-\x{24ff}\x{2776}-\x{2793}\x{3007}\x{3021}-\x{3029}' .
+ '\x{3038}-\x{303a}\x{3192}-\x{3195}\x{3220}-\x{3229}\x{3251}-\x{325f}\x{3280}-' .
+ '\x{3289}\x{32b1}-\x{32bf}\x{ff10}-\x{ff19}');
+
+/**
+ * Matches all 'P' Unicode character classes (punctuation)
+ */
+define('PREG_CLASS_PUNCTUATION',
+ '\x{21}-\x{23}\x{25}-\x{2a}\x{2c}-\x{2f}\x{3a}\x{3b}\x{3f}\x{40}\x{5b}-\x{5d}' .
+ '\x{5f}\x{7b}\x{7d}\x{a1}\x{ab}\x{b7}\x{bb}\x{bf}\x{37e}\x{387}\x{55a}-\x{55f}' .
+ '\x{589}\x{58a}\x{5be}\x{5c0}\x{5c3}\x{5f3}\x{5f4}\x{60c}\x{60d}\x{61b}\x{61f}' .
+ '\x{66a}-\x{66d}\x{6d4}\x{700}-\x{70d}\x{964}\x{965}\x{970}\x{df4}\x{e4f}' .
+ '\x{e5a}\x{e5b}\x{f04}-\x{f12}\x{f3a}-\x{f3d}\x{f85}\x{104a}-\x{104f}\x{10fb}' .
+ '\x{1361}-\x{1368}\x{166d}\x{166e}\x{169b}\x{169c}\x{16eb}-\x{16ed}\x{1735}' .
+ '\x{1736}\x{17d4}-\x{17d6}\x{17d8}-\x{17da}\x{1800}-\x{180a}\x{1944}\x{1945}' .
+ '\x{2010}-\x{2027}\x{2030}-\x{2043}\x{2045}-\x{2051}\x{2053}\x{2054}\x{2057}' .
+ '\x{207d}\x{207e}\x{208d}\x{208e}\x{2329}\x{232a}\x{23b4}-\x{23b6}\x{2768}-' .
+ '\x{2775}\x{27e6}-\x{27eb}\x{2983}-\x{2998}\x{29d8}-\x{29db}\x{29fc}\x{29fd}' .
+ '\x{3001}-\x{3003}\x{3008}-\x{3011}\x{3014}-\x{301f}\x{3030}\x{303d}\x{30a0}' .
+ '\x{30fb}\x{fd3e}\x{fd3f}\x{fe30}-\x{fe52}\x{fe54}-\x{fe61}\x{fe63}\x{fe68}' .
+ '\x{fe6a}\x{fe6b}\x{ff01}-\x{ff03}\x{ff05}-\x{ff0a}\x{ff0c}-\x{ff0f}\x{ff1a}' .
+ '\x{ff1b}\x{ff1f}\x{ff20}\x{ff3b}-\x{ff3d}\x{ff3f}\x{ff5b}\x{ff5d}\x{ff5f}-' .
+ '\x{ff65}');
+
+/**
+ * Matches CJK (Chinese, Japanese, Korean) letter-like characters.
+ *
+ * This list is derived from the "East Asian Scripts" section of
+ * http://www.unicode.org/charts/index.html, as well as a comment on
+ * http://unicode.org/reports/tr11/tr11-11.html listing some character
+ * ranges that are reserved for additional CJK ideographs.
+ *
+ * The character ranges do not include numbers, punctuation, or symbols, since
+ * these are handled separately in search. Note that radicals and strokes are
+ * considered symbols. (See
+ * http://www.unicode.org/Public/UNIDATA/extracted/DerivedGeneralCategory.txt)
+ *
+ * @see search_expand_cjk()
+ */
+define('PREG_CLASS_CJK', '\x{1100}-\x{11FF}\x{3040}-\x{309F}\x{30A1}-\x{318E}' .
+ '\x{31A0}-\x{31B7}\x{31F0}-\x{31FF}\x{3400}-\x{4DBF}\x{4E00}-\x{9FCF}' .
+ '\x{A000}-\x{A48F}\x{A4D0}-\x{A4FD}\x{A960}-\x{A97F}\x{AC00}-\x{D7FF}' .
+ '\x{F900}-\x{FAFF}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF66}-\x{FFDC}' .
+ '\x{20000}-\x{2FFFD}\x{30000}-\x{3FFFD}');
+
+/**
+ * Implements hook_help().
+ */
+function search_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#search':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Search module provides the ability to index and search for content by exact keywords, and for users by username or e-mail. For more information, see the online handbook entry for <a href="@search-module">Search module</a>.', array('@search-module' => 'http://drupal.org/handbook/modules/search/', '@search' => url('search'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Searching content and users') . '</dt>';
+ $output .= '<dd>' . t('Users with <em>Use search</em> permission can use the search block and <a href="@search">Search page</a>. Users with the <em>View published content</em> permission can search for content containing exact keywords. Users with the <em>View user profiles</em> permission can search for users containing the keyword anywhere in the user name, and users with the <em>Administer users</em> permission can search for users by email address. Additionally, users with <em>Use advanced search</em> permission can find content using more complex search methods and filtering by choosing the <em>Advanced search</em> option on the <a href="@search">Search page</a>.', array('@search' => url('search'))) . '</dd>';
+ $output .= '<dt>' . t('Indexing content with cron') . '</dt>';
+ $output .= '<dd>' . t('To provide keyword searching, the search engine maintains an index of words found in the content and its fields, along with text added to your content by other modules (such as comments from the core Comment module, and taxonomy terms from the core Taxonomy module). To build and maintain this index, a correctly configured <a href="@cron">cron maintenance task</a> is required. Users with <em>Administer search</em> permission can further configure the cron settings on the <a href="@searchsettings">Search settings page</a>.', array('@cron' => 'http://drupal.org/cron', '@searchsettings' => url('admin/config/search/settings'))) . '</dd>';
+ $output .= '<dt>' . t('Content reindexing') . '</dt>';
+ $output .= '<dd>' . t('Content-related actions on your site (creating, editing, or deleting content and comments) automatically cause affected content items to be marked for indexing or reindexing at the next cron run. When content is marked for reindexing, the previous content remains in the index until cron runs, at which time it is replaced by the new content. Unlike content-related actions, actions related to the structure of your site do not cause affected content to be marked for reindexing. Examples of structure-related actions that affect content include deleting or editing taxonomy terms, enabling or disabling modules that add text to content (such as Taxonomy, Comment, and field-providing modules), and modifying the fields or display parameters of your content types. If you take one of these actions and you want to ensure that the search index is updated to reflect your changed site structure, you can mark all content for reindexing by clicking the "Re-index site" button on the <a href="@searchsettings">Search settings page</a>. If you have a lot of content on your site, it may take several cron runs for the content to be reindexed.', array('@searchsettings' => url('admin/config/search/settings'))) . '</dd>';
+ $output .= '<dt>' . t('Configuring search settings') . '</dt>';
+ $output .= '<dd>' . t('Indexing behavior can be adjusted using the <a href="@searchsettings">Search settings page</a>. Users with <em>Administer search</em> permission can control settings such as the <em>Number of items to index per cron run</em>, <em>Indexing settings</em> (word length), <em>Active search modules</em>, and <em>Content ranking</em>, which lets you adjust the priority in which indexed content is returned in results.', array('@searchsettings' => url('admin/config/search/settings'))) . '</dd>';
+ $output .= '<dt>' . t('Search block') . '</dt>';
+ $output .= '<dd>' . t('The Search module includes a default <em>Search form</em> block, which can be enabled and configured on the <a href="@blocks">Blocks administration page</a>. The block is available to users with the <em>Search content</em> permission.', array('@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '<dt>' . t('Extending Search module') . '</dt>';
+ $output .= '<dd>' . t('By default, the Search module only supports exact keyword matching in content searches. You can modify this behavior by installing a language-specific stemming module for your language (such as <a href="http://drupal.org/project/porterstemmer">Porter Stemmer</a> for American English), which allows words such as walk, walking, and walked to be matched in the Search module. Another approach is to use a third-party search technology with stemming or partial word matching features built in, such as <a href="http://drupal.org/project/apachesolr">Apache Solr</a> or <a href="http://drupal.org/project/sphinx">Sphinx</a>. These and other <a href="@contrib-search">search-related contributed modules</a> can be downloaded by visiting Drupal.org.', array('@contrib-search' => 'http://drupal.org/project/modules?filters=tid%3A105')) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/config/search/settings':
+ return '<p>' . t('The search engine maintains an index of words found in your site\'s content. To build and maintain this index, a correctly configured <a href="@cron">cron maintenance task</a> is required. Indexing behavior can be adjusted using the settings below.', array('@cron' => url('admin/reports/status'))) . '</p>';
+ case 'search#noresults':
+ return t('<ul>
+<li>Check if your spelling is correct.</li>
+<li>Remove quotes around phrases to search for each word individually. <em>bike shed</em> will often show more results than <em>&quot;bike shed&quot;</em>.</li>
+<li>Consider loosening your query with <em>OR</em>. <em>bike OR shed</em> will often show more results than <em>bike shed</em>.</li>
+</ul>');
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function search_theme() {
+ return array(
+ 'search_result' => array(
+ 'variables' => array('result' => NULL, 'module' => NULL),
+ 'file' => 'search.pages.inc',
+ 'template' => 'search-result',
+ ),
+ 'search_results' => array(
+ 'variables' => array('results' => NULL, 'module' => NULL),
+ 'file' => 'search.pages.inc',
+ 'template' => 'search-results',
+ ),
+ );
+}
+
+/**
+ * Implements hook_permission().
+ */
+function search_permission() {
+ return array(
+ 'administer search' => array(
+ 'title' => t('Administer search'),
+ ),
+ 'search content' => array(
+ 'title' => t('Use search'),
+ ),
+ 'use advanced search' => array(
+ 'title' => t('Use advanced search'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function search_block_info() {
+ $blocks['form']['info'] = t('Search form');
+ // Not worth caching.
+ $blocks['form']['cache'] = DRUPAL_NO_CACHE;
+ $blocks['form']['properties']['administrative'] = TRUE;
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function search_block_view($delta = '') {
+ if (user_access('search content')) {
+ $block['subject'] = t('Search');
+ $block['content'] = drupal_get_form('search_block_form');
+ return $block;
+ }
+}
+
+/**
+ * Implements hook_preprocess_block().
+ */
+function search_preprocess_block(&$variables) {
+ if ($variables['block']->module == 'search' && $variables['block']->delta == 'form') {
+ $variables['content_attributes_array']['class'][] = 'container-inline';
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function search_menu() {
+ $items['search'] = array(
+ 'title' => 'Search',
+ 'page callback' => 'search_view',
+ 'access callback' => 'search_is_active',
+ 'type' => MENU_SUGGESTED_ITEM,
+ 'file' => 'search.pages.inc',
+ );
+ $items['admin/config/search/settings'] = array(
+ 'title' => 'Search settings',
+ 'description' => 'Configure relevance settings for search and other indexing options.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('search_admin_settings'),
+ 'access arguments' => array('administer search'),
+ 'weight' => -10,
+ 'file' => 'search.admin.inc',
+ );
+ $items['admin/config/search/settings/reindex'] = array(
+ 'title' => 'Clear index',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('search_reindex_confirm'),
+ 'access arguments' => array('administer search'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ 'file' => 'search.admin.inc',
+ );
+
+ // Add paths for searching. We add each module search path twice: once without
+ // and once with %menu_tail appended. The reason for this is that we want to
+ // preserve keywords when switching tabs, and also to have search tabs
+ // highlighted properly. The only way to do that within the Drupal menu
+ // system appears to be having two sets of tabs. See discussion on issue
+ // http://drupal.org/node/245103 for details.
+
+ drupal_static_reset('search_get_info');
+ $default_info = search_get_default_module_info();
+ if ($default_info) {
+ foreach (search_get_info() as $module => $search_info) {
+ $path = 'search/' . $search_info['path'];
+ $items[$path] = array(
+ 'title' => $search_info['title'],
+ 'page callback' => 'search_view',
+ 'page arguments' => array($module, ''),
+ 'access callback' => '_search_menu_access',
+ 'access arguments' => array($module),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'search.pages.inc',
+ 'weight' => $module == $default_info['module'] ? -10 : 0,
+ );
+ $items["$path/%menu_tail"] = array(
+ 'title' => $search_info['title'],
+ 'load arguments' => array('%map', '%index'),
+ 'page callback' => 'search_view',
+ 'page arguments' => array($module, 2),
+ 'access callback' => '_search_menu_access',
+ 'access arguments' => array($module),
+ // The default local task points to its parent, but this item points to
+ // where it should so it should not be changed.
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'search.pages.inc',
+ 'weight' => 0,
+ // These tabs are not subtabs.
+ 'tab_root' => 'search/' . $default_info['path'] . '/%',
+ // These tabs need to display at the same level.
+ 'tab_parent' => 'search/' . $default_info['path'],
+ );
+ }
+ }
+ return $items;
+}
+
+/**
+ * Determines access for the ?q=search path.
+ */
+function search_is_active() {
+ // This path cannot be accessed if there are no active modules.
+ return user_access('search content') && search_get_info();
+}
+
+/**
+ * Returns information about available search modules.
+ *
+ * @param $all
+ * If TRUE, information about all enabled modules implementing
+ * hook_search_info() will be returned. If FALSE (default), only modules that
+ * have been set to active on the search settings page will be returned.
+ *
+ * @return
+ * Array of hook_search_info() return values, keyed by module name. The
+ * 'title' and 'path' array elements will be set to defaults for each module
+ * if not supplied by hook_search_info(), and an additional array element of
+ * 'module' will be added (set to the module name).
+ */
+function search_get_info($all = FALSE) {
+ $search_hooks = &drupal_static(__FUNCTION__);
+
+ if (!isset($search_hooks)) {
+ foreach (module_implements('search_info') as $module) {
+ $search_hooks[$module] = call_user_func($module . '_search_info');
+ // Use module name as the default value.
+ $search_hooks[$module] += array('title' => $module, 'path' => $module);
+ // Include the module name itself in the array.
+ $search_hooks[$module]['module'] = $module;
+ }
+ }
+
+ if ($all) {
+ return $search_hooks;
+ }
+
+ $active = variable_get('search_active_modules', array('node', 'user'));
+ return array_intersect_key($search_hooks, array_flip($active));
+}
+
+/**
+ * Returns information about the default search module.
+ *
+ * @return
+ * The search_get_info() array element for the default search module, if any.
+ */
+function search_get_default_module_info() {
+ $info = search_get_info();
+ $default = variable_get('search_default_module', 'node');
+ if (isset($info[$default])) {
+ return $info[$default];
+ }
+ // The variable setting does not match any active module, so just return
+ // the info for the first active module (if any).
+ return reset($info);
+}
+
+/**
+ * Access callback for search tabs.
+ */
+function _search_menu_access($name) {
+ return user_access('search content') && (!function_exists($name . '_search_access') || module_invoke($name, 'search_access'));
+}
+
+/**
+ * Clears a part of or the entire search index.
+ *
+ * @param $sid
+ * (optional) The ID of the item to remove from the search index. If
+ * specified, $module must also be given. Omit both $sid and $module to clear
+ * the entire search index.
+ * @param $module
+ * (optional) The machine-readable name of the module for the item to remove
+ * from the search index.
+ */
+function search_reindex($sid = NULL, $module = NULL, $reindex = FALSE) {
+ if ($module == NULL && $sid == NULL) {
+ module_invoke_all('search_reset');
+ }
+ else {
+ db_delete('search_dataset')
+ ->condition('sid', $sid)
+ ->condition('type', $module)
+ ->execute();
+ db_delete('search_index')
+ ->condition('sid', $sid)
+ ->condition('type', $module)
+ ->execute();
+ // Don't remove links if re-indexing.
+ if (!$reindex) {
+ db_delete('search_node_links')
+ ->condition('sid', $sid)
+ ->condition('type', $module)
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Marks a word as "dirty" (changed), or retrieves the list of dirty words.
+ *
+ * This is used during indexing (cron). Words that are dirty have outdated
+ * total counts in the search_total table, and need to be recounted.
+ */
+function search_dirty($word = NULL) {
+ $dirty = &drupal_static(__FUNCTION__, array());
+ if ($word !== NULL) {
+ $dirty[$word] = TRUE;
+ }
+ else {
+ return $dirty;
+ }
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Fires hook_update_index() in all modules and cleans up dirty words.
+ *
+ * @see search_dirty()
+ */
+function search_cron() {
+ // We register a shutdown function to ensure that search_total is always up
+ // to date.
+ drupal_register_shutdown_function('search_update_totals');
+
+ foreach (variable_get('search_active_modules', array('node', 'user')) as $module) {
+ // Update word index
+ module_invoke($module, 'update_index');
+ }
+}
+
+/**
+ * Updates the {search_total} database table.
+ *
+ * This function is called on shutdown to ensure that {search_total} is always
+ * up to date (even if cron times out or otherwise fails).
+ */
+function search_update_totals() {
+ // Update word IDF (Inverse Document Frequency) counts for new/changed words.
+ foreach (search_dirty() as $word => $dummy) {
+ // Get total count
+ $total = db_query("SELECT SUM(score) FROM {search_index} WHERE word = :word", array(':word' => $word), array('target' => 'slave'))->fetchField();
+ // Apply Zipf's law to equalize the probability distribution.
+ $total = log10(1 + 1/(max(1, $total)));
+ db_merge('search_total')
+ ->key(array('word' => $word))
+ ->fields(array('count' => $total))
+ ->execute();
+ }
+ // Find words that were deleted from search_index, but are still in
+ // search_total. We use a LEFT JOIN between the two tables and keep only the
+ // rows which fail to join.
+ $result = db_query("SELECT t.word AS realword, i.word FROM {search_total} t LEFT JOIN {search_index} i ON t.word = i.word WHERE i.word IS NULL", array(), array('target' => 'slave'));
+ $or = db_or();
+ foreach ($result as $word) {
+ $or->condition('word', $word->realword);
+ }
+ if (count($or) > 0) {
+ db_delete('search_total')
+ ->condition($or)
+ ->execute();
+ }
+}
+
+/**
+ * Simplifies a string according to indexing rules.
+ *
+ * @param $text
+ * Text to simplify.
+ *
+ * @return
+ * Simplified text.
+ *
+ * @see hook_search_preprocess()
+ */
+function search_simplify($text) {
+ // Decode entities to UTF-8
+ $text = decode_entities($text);
+
+ // Lowercase
+ $text = drupal_strtolower($text);
+
+ // Call an external processor for word handling.
+ search_invoke_preprocess($text);
+
+ // Simple CJK handling
+ if (variable_get('overlap_cjk', TRUE)) {
+ $text = preg_replace_callback('/[' . PREG_CLASS_CJK . ']+/u', 'search_expand_cjk', $text);
+ }
+
+ // To improve searching for numerical data such as dates, IP addresses
+ // or version numbers, we consider a group of numerical characters
+ // separated only by punctuation characters to be one piece.
+ // This also means that searching for e.g. '20/03/1984' also returns
+ // results with '20-03-1984' in them.
+ // Readable regexp: ([number]+)[punctuation]+(?=[number])
+ $text = preg_replace('/([' . PREG_CLASS_NUMBERS . ']+)[' . PREG_CLASS_PUNCTUATION . ']+(?=[' . PREG_CLASS_NUMBERS . '])/u', '\1', $text);
+
+ // Multiple dot and dash groups are word boundaries and replaced with space.
+ // No need to use the unicode modifer here because 0-127 ASCII characters
+ // can't match higher UTF-8 characters as the leftmost bit of those are 1.
+ $text = preg_replace('/[.-]{2,}/', ' ', $text);
+
+ // The dot, underscore and dash are simply removed. This allows meaningful
+ // search behavior with acronyms and URLs. See unicode note directly above.
+ $text = preg_replace('/[._-]+/', '', $text);
+
+ // With the exception of the rules above, we consider all punctuation,
+ // marks, spacers, etc, to be a word boundary.
+ $text = preg_replace('/[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . ']+/u', ' ', $text);
+
+ // Truncate everything to 50 characters.
+ $words = explode(' ', $text);
+ array_walk($words, '_search_index_truncate');
+ $text = implode(' ', $words);
+
+ return $text;
+}
+
+/**
+ * Splits CJK (Chinese, Japanese, Korean) text into tokens.
+ *
+ * The Search module matches exact words, where a word is defined to be a
+ * sequence of characters delimited by spaces or punctuation. CJK languages are
+ * written in long strings of characters, though, not split up into words. So
+ * in order to allow search matching, we split up CJK text into tokens
+ * consisting of consecutive, overlapping sequences of characters whose length
+ * is equal to the 'minimum_word_size' variable. This tokenizing is only done if
+ * the 'overlap_cjk' variable is TRUE.
+ *
+ * @param $matches
+ * This function is a callback for preg_replace_callback(), which is called
+ * from search_simplify(). So, $matches is an array of regular expression
+ * matches, which means that $matches[0] contains the matched text -- a string
+ * of CJK characters to tokenize.
+ *
+ * @return
+ * Tokenized text, starting and ending with a space character.
+ */
+function search_expand_cjk($matches) {
+ $min = variable_get('minimum_word_size', 3);
+ $str = $matches[0];
+ $length = drupal_strlen($str);
+ // If the text is shorter than the minimum word size, don't tokenize it.
+ if ($length <= $min) {
+ return ' ' . $str . ' ';
+ }
+ $tokens = ' ';
+ // Build a FIFO queue of characters.
+ $chars = array();
+ for ($i = 0; $i < $length; $i++) {
+ // Add the next character off the beginning of the string to the queue.
+ $current = drupal_substr($str, 0, 1);
+ $str = substr($str, strlen($current));
+ $chars[] = $current;
+ if ($i >= $min - 1) {
+ // Make a token of $min characters, and add it to the token string.
+ $tokens .= implode('', $chars) . ' ';
+ // Shift out the first character in the queue.
+ array_shift($chars);
+ }
+ }
+ return $tokens;
+}
+
+/**
+ * Simplifies and splits a string into tokens for indexing.
+ */
+function search_index_split($text) {
+ $last = &drupal_static(__FUNCTION__);
+ $lastsplit = &drupal_static(__FUNCTION__ . ':lastsplit');
+
+ if ($last == $text) {
+ return $lastsplit;
+ }
+ // Process words
+ $text = search_simplify($text);
+ $words = explode(' ', $text);
+
+ // Save last keyword result
+ $last = $text;
+ $lastsplit = $words;
+
+ return $words;
+}
+
+/**
+ * Helper function for array_walk in search_index_split.
+ */
+function _search_index_truncate(&$text) {
+ if (is_numeric($text)) {
+ $text = ltrim($text, '0');
+ }
+ $text = truncate_utf8($text, 50);
+}
+
+/**
+ * Invokes hook_search_preprocess() in modules.
+ */
+function search_invoke_preprocess(&$text) {
+ foreach (module_implements('search_preprocess') as $module) {
+ $text = module_invoke($module, 'search_preprocess', $text);
+ }
+}
+
+/**
+ * Update the full-text search index for a particular item.
+ *
+ * @param $sid
+ * An ID number identifying this particular item (e.g., node ID).
+ * @param $module
+ * The machine-readable name of the module that this item comes from (a module
+ * that implements hook_search_info()).
+ * @param $text
+ * The content of this item. Must be a piece of HTML or plain text.
+ *
+ * @ingroup search
+ */
+function search_index($sid, $module, $text) {
+ $minimum_word_size = variable_get('minimum_word_size', 3);
+
+ // Link matching
+ global $base_url;
+ $node_regexp = '@href=[\'"]?(?:' . preg_quote($base_url, '@') . '/|' . preg_quote(base_path(), '@') . ')(?:\?q=)?/?((?![a-z]+:)[^\'">]+)[\'">]@i';
+
+ // Multipliers for scores of words inside certain HTML tags. The weights are stored
+ // in a variable so that modules can overwrite the default weights.
+ // Note: 'a' must be included for link ranking to work.
+ $tags = variable_get('search_tag_weights', array(
+ 'h1' => 25,
+ 'h2' => 18,
+ 'h3' => 15,
+ 'h4' => 12,
+ 'h5' => 9,
+ 'h6' => 6,
+ 'u' => 3,
+ 'b' => 3,
+ 'i' => 3,
+ 'strong' => 3,
+ 'em' => 3,
+ 'a' => 10));
+
+ // Strip off all ignored tags to speed up processing, but insert space before/after
+ // them to keep word boundaries.
+ $text = str_replace(array('<', '>'), array(' <', '> '), $text);
+ $text = strip_tags($text, '<' . implode('><', array_keys($tags)) . '>');
+
+ // Split HTML tags from plain text.
+ $split = preg_split('/\s*<([^>]+?)>\s*/', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
+ // Note: PHP ensures the array consists of alternating delimiters and literals
+ // and begins and ends with a literal (inserting $null as required).
+
+ $tag = FALSE; // Odd/even counter. Tag or no tag.
+ $link = FALSE; // State variable for link analyzer
+ $score = 1; // Starting score per word
+ $accum = ' '; // Accumulator for cleaned up data
+ $tagstack = array(); // Stack with open tags
+ $tagwords = 0; // Counter for consecutive words
+ $focus = 1; // Focus state
+
+ $results = array(0 => array()); // Accumulator for words for index
+
+ foreach ($split as $value) {
+ if ($tag) {
+ // Increase or decrease score per word based on tag
+ list($tagname) = explode(' ', $value, 2);
+ $tagname = drupal_strtolower($tagname);
+ // Closing or opening tag?
+ if ($tagname[0] == '/') {
+ $tagname = substr($tagname, 1);
+ // If we encounter unexpected tags, reset score to avoid incorrect boosting.
+ if (!count($tagstack) || $tagstack[0] != $tagname) {
+ $tagstack = array();
+ $score = 1;
+ }
+ else {
+ // Remove from tag stack and decrement score
+ $score = max(1, $score - $tags[array_shift($tagstack)]);
+ }
+ if ($tagname == 'a') {
+ $link = FALSE;
+ }
+ }
+ else {
+ if (isset($tagstack[0]) && $tagstack[0] == $tagname) {
+ // None of the tags we look for make sense when nested identically.
+ // If they are, it's probably broken HTML.
+ $tagstack = array();
+ $score = 1;
+ }
+ else {
+ // Add to open tag stack and increment score
+ array_unshift($tagstack, $tagname);
+ $score += $tags[$tagname];
+ }
+ if ($tagname == 'a') {
+ // Check if link points to a node on this site
+ if (preg_match($node_regexp, $value, $match)) {
+ $path = drupal_get_normal_path($match[1]);
+ if (preg_match('!(?:node|book)/(?:view/)?([0-9]+)!i', $path, $match)) {
+ $linknid = $match[1];
+ if ($linknid > 0) {
+ $node = db_query('SELECT title, nid, vid FROM {node} WHERE nid = :nid', array(':nid' => $linknid), array('target' => 'slave'))->fetchObject();
+ $link = TRUE;
+ $linktitle = $node->title;
+ }
+ }
+ }
+ }
+ }
+ // A tag change occurred, reset counter.
+ $tagwords = 0;
+ }
+ else {
+ // Note: use of PREG_SPLIT_DELIM_CAPTURE above will introduce empty values
+ if ($value != '') {
+ if ($link) {
+ // Check to see if the node link text is its URL. If so, we use the target node title instead.
+ if (preg_match('!^https?://!i', $value)) {
+ $value = $linktitle;
+ }
+ }
+ $words = search_index_split($value);
+ foreach ($words as $word) {
+ // Add word to accumulator
+ $accum .= $word . ' ';
+ // Check wordlength
+ if (is_numeric($word) || drupal_strlen($word) >= $minimum_word_size) {
+ // Links score mainly for the target.
+ if ($link) {
+ if (!isset($results[$linknid])) {
+ $results[$linknid] = array();
+ }
+ $results[$linknid][] = $word;
+ // Reduce score of the link caption in the source.
+ $focus *= 0.2;
+ }
+ // Fall-through
+ if (!isset($results[0][$word])) {
+ $results[0][$word] = 0;
+ }
+ $results[0][$word] += $score * $focus;
+
+ // Focus is a decaying value in terms of the amount of unique words up to this point.
+ // From 100 words and more, it decays, to e.g. 0.5 at 500 words and 0.3 at 1000 words.
+ $focus = min(1, .01 + 3.5 / (2 + count($results[0]) * .015));
+ }
+ $tagwords++;
+ // Too many words inside a single tag probably mean a tag was accidentally left open.
+ if (count($tagstack) && $tagwords >= 15) {
+ $tagstack = array();
+ $score = 1;
+ }
+ }
+ }
+ }
+ $tag = !$tag;
+ }
+
+ search_reindex($sid, $module, TRUE);
+
+ // Insert cleaned up data into dataset
+ db_insert('search_dataset')
+ ->fields(array(
+ 'sid' => $sid,
+ 'type' => $module,
+ 'data' => $accum,
+ 'reindex' => 0,
+ ))
+ ->execute();
+
+ // Insert results into search index
+ foreach ($results[0] as $word => $score) {
+ // If a word already exists in the database, its score gets increased
+ // appropriately. If not, we create a new record with the appropriate
+ // starting score.
+ db_merge('search_index')
+ ->key(array(
+ 'word' => $word,
+ 'sid' => $sid,
+ 'type' => $module,
+ ))
+ ->fields(array('score' => $score))
+ ->expression('score', 'score + :score', array(':score' => $score))
+ ->execute();
+ search_dirty($word);
+ }
+ unset($results[0]);
+
+ // Get all previous links from this item.
+ $result = db_query("SELECT nid, caption FROM {search_node_links} WHERE sid = :sid AND type = :type", array(
+ ':sid' => $sid,
+ ':type' => $module
+ ), array('target' => 'slave'));
+ $links = array();
+ foreach ($result as $link) {
+ $links[$link->nid] = $link->caption;
+ }
+
+ // Now store links to nodes.
+ foreach ($results as $nid => $words) {
+ $caption = implode(' ', $words);
+ if (isset($links[$nid])) {
+ if ($links[$nid] != $caption) {
+ // Update the existing link and mark the node for reindexing.
+ db_update('search_node_links')
+ ->fields(array('caption' => $caption))
+ ->condition('sid', $sid)
+ ->condition('type', $module)
+ ->condition('nid', $nid)
+ ->execute();
+ search_touch_node($nid);
+ }
+ // Unset the link to mark it as processed.
+ unset($links[$nid]);
+ }
+ elseif ($sid != $nid || $module != 'node') {
+ // Insert the existing link and mark the node for reindexing, but don't
+ // reindex if this is a link in a node pointing to itself.
+ db_insert('search_node_links')
+ ->fields(array(
+ 'caption' => $caption,
+ 'sid' => $sid,
+ 'type' => $module,
+ 'nid' => $nid,
+ ))
+ ->execute();
+ search_touch_node($nid);
+ }
+ }
+ // Any left-over links in $links no longer exist. Delete them and mark the nodes for reindexing.
+ foreach ($links as $nid => $caption) {
+ db_delete('search_node_links')
+ ->condition('sid', $sid)
+ ->condition('type', $module)
+ ->condition('nid', $nid)
+ ->execute();
+ search_touch_node($nid);
+ }
+}
+
+/**
+ * Changes a node's changed timestamp to 'now' to force reindexing.
+ *
+ * @param $nid
+ * The node ID of the node that needs reindexing.
+ */
+function search_touch_node($nid) {
+ db_update('search_dataset')
+ ->fields(array('reindex' => REQUEST_TIME))
+ ->condition('type', 'node')
+ ->condition('sid', $nid)
+ ->execute();
+}
+
+/**
+ * Implements hook_node_update_index().
+ */
+function search_node_update_index($node) {
+ // Transplant links to a node into the target node.
+ $result = db_query("SELECT caption FROM {search_node_links} WHERE nid = :nid", array(':nid' => $node->nid), array('target' => 'slave'));
+ $output = array();
+ foreach ($result as $link) {
+ $output[] = $link->caption;
+ }
+ if (count($output)) {
+ return '<a>(' . implode(', ', $output) . ')</a>';
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function search_node_update($node) {
+ // Reindex the node when it is updated. The node is automatically indexed
+ // when it is added, simply by being added to the node table.
+ search_touch_node($node->nid);
+}
+
+/**
+ * Implements hook_comment_insert().
+ */
+function search_comment_insert($comment) {
+ // Reindex the node when comments are added.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Implements hook_comment_update().
+ */
+function search_comment_update($comment) {
+ // Reindex the node when comments are changed.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Implements hook_comment_delete().
+ */
+function search_comment_delete($comment) {
+ // Reindex the node when comments are deleted.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Implements hook_comment_publish().
+ */
+function search_comment_publish($comment) {
+ // Reindex the node when comments are published.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Implements hook_comment_unpublish().
+ */
+function search_comment_unpublish($comment) {
+ // Reindex the node when comments are unpublished.
+ search_touch_node($comment->nid);
+}
+
+/**
+ * Extracts a module-specific search option from a search expression.
+ *
+ * Search options are added using search_expression_insert(), and retrieved
+ * using search_expression_extract(). They take the form option:value, and
+ * are added to the ordinary keywords in the search expression.
+ *
+ * @param $expression
+ * The search expression to extract from.
+ * @param $option
+ * The name of the option to retrieve from the search expression.
+ *
+ * @return
+ * The value previously stored in the search expression for option $option,
+ * if any. Trailing spaces in values will not be included.
+ */
+function search_expression_extract($expression, $option) {
+ if (preg_match('/(^| )' . $option . ':([^ ]*)( |$)/i', $expression, $matches)) {
+ return $matches[2];
+ }
+}
+
+/**
+ * Adds a module-specific search option to a search expression.
+ *
+ * Search options are added using search_expression_insert(), and retrieved
+ * using search_expression_extract(). They take the form option:value, and
+ * are added to the ordinary keywords in the search expression.
+ *
+ * @param $expression
+ * The search expression to add to.
+ * @param $option
+ * The name of the option to add to the search expression.
+ * @param $value
+ * The value to add for the option. If present, it will replace any previous
+ * value added for the option. Cannot contain any spaces or | characters, as
+ * these are used as delimiters. If you want to add a blank value $option: to
+ * the search expression, pass in an empty string or a string that is composed
+ * of only spaces. To clear a previously-stored option without adding a
+ * replacement, pass in NULL for $value or omit.
+ *
+ * @return
+ * $expression, with any previous value for this option removed, and a new
+ * $option:$value pair added if $value was provided.
+ */
+function search_expression_insert($expression, $option, $value = NULL) {
+ // Remove any previous values stored with $option.
+ $expression = trim(preg_replace('/(^| )' . $option . ':[^ ]*/i', '', $expression));
+
+ // Set new value, if provided.
+ if (isset($value)) {
+ $expression .= ' ' . $option . ':' . trim($value);
+ }
+ return $expression;
+}
+
+/**
+ * @defgroup search Search interface
+ * @{
+ * The Drupal search interface manages a global search mechanism.
+ *
+ * Modules may plug into this system to provide searches of different types of
+ * data. Most of the system is handled by search.module, so this must be enabled
+ * for all of the search features to work.
+ *
+ * There are three ways to interact with the search system:
+ * - Specifically for searching nodes, you can implement
+ * hook_node_update_index() and hook_node_search_result(). However, note that
+ * the search system already indexes all visible output of a node; i.e.,
+ * everything displayed normally by hook_view() and hook_node_view(). This is
+ * usually sufficient. You should only use this mechanism if you want
+ * additional, non-visible data to be indexed.
+ * - Implement hook_search_info(). This will create a search tab for your module
+ * on the /search page with a simple keyword search form. You will also need
+ * to implement hook_search_execute() to perform the search.
+ * - Implement hook_update_index(). This allows your module to use Drupal's
+ * HTML indexing mechanism for searching full text efficiently.
+ *
+ * If your module needs to provide a more complicated search form, then you need
+ * to implement it yourself without hook_search_info(). In that case, you should
+ * define it as a local task (tab) under the /search page (e.g. /search/mymodule)
+ * so that users can easily find it.
+ */
+
+/**
+ * Builds a search form.
+ *
+ * @param $action
+ * Form action. Defaults to "search/$path", where $path is the search path
+ * associated with the module in its hook_search_info(). This will be
+ * run through url().
+ * @param $keys
+ * The search string entered by the user, containing keywords for the search.
+ * @param $module
+ * The search module to render the form for: a module that implements
+ * hook_search_info(). If not supplied, the default search module is used.
+ * @param $prompt
+ * Label for the keywords field. Defaults to t('Enter your keywords') if NULL.
+ * Supply '' to omit.
+ *
+ * @return
+ * A Form API array for the search form.
+ */
+function search_form($form, &$form_state, $action = '', $keys = '', $module = NULL, $prompt = NULL) {
+ $module_info = FALSE;
+ if (!$module) {
+ $module_info = search_get_default_module_info();
+ }
+ else {
+ $info = search_get_info();
+ $module_info = isset($info[$module]) ? $info[$module] : FALSE;
+ }
+
+ // Sanity check.
+ if (!$module_info) {
+ form_set_error(NULL, t('Search is currently disabled.'), 'error');
+ return $form;
+ }
+
+ if (!$action) {
+ $action = 'search/' . $module_info['path'];
+ }
+ if (!isset($prompt)) {
+ $prompt = t('Enter your keywords');
+ }
+
+ $form['#action'] = url($action);
+ // Record the $action for later use in redirecting.
+ $form_state['action'] = $action;
+ $form['module'] = array('#type' => 'value', '#value' => $module);
+ $form['basic'] = array('#type' => 'container', '#attributes' => array('class' => array('container-inline')));
+ $form['basic']['keys'] = array(
+ '#type' => 'textfield',
+ '#title' => $prompt,
+ '#default_value' => $keys,
+ '#size' => $prompt ? 40 : 20,
+ '#maxlength' => 255,
+ );
+ // processed_keys is used to coordinate keyword passing between other forms
+ // that hook into the basic search form.
+ $form['basic']['processed_keys'] = array('#type' => 'value', '#value' => '');
+ $form['basic']['submit'] = array('#type' => 'submit', '#value' => t('Search'));
+
+ return $form;
+}
+
+/**
+ * Form builder; Output a search form for the search block's search box.
+ *
+ * @ingroup forms
+ * @see search_box_form_submit()
+ */
+function search_box($form, &$form_state, $form_id) {
+ $form[$form_id] = array(
+ '#type' => 'textfield',
+ '#title' => t('Search'),
+ '#title_display' => 'invisible',
+ '#size' => 15,
+ '#default_value' => '',
+ '#attributes' => array('title' => t('Enter the terms you wish to search for.')),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Search'));
+ $form['#submit'][] = 'search_box_form_submit';
+
+ return $form;
+}
+
+/**
+ * Process a block search form submission.
+ */
+function search_box_form_submit($form, &$form_state) {
+ // The search form relies on control of the redirect destination for its
+ // functionality, so we override any static destination set in the request,
+ // for example by drupal_access_denied() or drupal_not_found()
+ // (see http://drupal.org/node/292565).
+ if (isset($_GET['destination'])) {
+ unset($_GET['destination']);
+ }
+
+ // Check to see if the form was submitted empty.
+ // If it is empty, display an error message.
+ // (This method is used instead of setting #required to TRUE for this field
+ // because that results in a confusing error message. It would say a plain
+ // "field is required" because the search keywords field has no title.
+ // The error message would also complain about a missing #title field.)
+ if ($form_state['values']['search_block_form'] == '') {
+ form_set_error('keys', t('Please enter some keywords.'));
+ }
+
+ $form_id = $form['form_id']['#value'];
+ $info = search_get_default_module_info();
+ if ($info) {
+ $form_state['redirect'] = 'search/' . $info['path'] . '/' . trim($form_state['values'][$form_id]);
+ }
+ else {
+ form_set_error(NULL, t('Search is currently disabled.'), 'error');
+ }
+}
+
+/**
+ * Performs a search by calling hook_search_execute().
+ *
+ * @param $keys
+ * Keyword query to search on.
+ * @param $module
+ * Search module to search.
+ * @param $conditions
+ * Optional array of additional search conditions.
+ *
+ * @return
+ * Renderable array of search results. No return value if $keys are not
+ * supplied or if the given search module is not active.
+ */
+function search_data($keys, $module, $conditions = NULL) {
+ if (module_hook($module, 'search_execute')) {
+ $results = module_invoke($module, 'search_execute', $keys, $conditions);
+ if (module_hook($module, 'search_page')) {
+ return module_invoke($module, 'search_page', $results);
+ }
+ else {
+ return array(
+ '#theme' => 'search_results',
+ '#results' => $results,
+ '#module' => $module,
+ );
+ }
+ }
+}
+
+/**
+ * Returns snippets from a piece of text, with certain keywords highlighted.
+ * Used for formatting search results.
+ *
+ * @param $keys
+ * A string containing a search query.
+ *
+ * @param $text
+ * The text to extract fragments from.
+ *
+ * @return
+ * A string containing HTML for the excerpt.
+ */
+function search_excerpt($keys, $text) {
+ // We highlight around non-indexable or CJK characters.
+ $boundary = '(?:(?<=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . '])|(?=[' . PREG_CLASS_UNICODE_WORD_BOUNDARY . PREG_CLASS_CJK . ']))';
+
+ // Extract positive keywords and phrases
+ preg_match_all('/ ("([^"]+)"|(?!OR)([^" ]+))/', ' ' . $keys, $matches);
+ $keys = array_merge($matches[2], $matches[3]);
+
+ // Prepare text by stripping HTML tags and decoding HTML entities.
+ $text = strip_tags(str_replace(array('<', '>'), array(' <', '> '), $text));
+ $text = decode_entities($text);
+
+ // Slash-escape quotes in the search keyword string.
+ array_walk($keys, '_search_excerpt_replace');
+ $workkeys = $keys;
+
+ // Extract fragments around keywords.
+ // First we collect ranges of text around each keyword, starting/ending
+ // at spaces, trying to get to 256 characters.
+ // If the sum of all fragments is too short, we look for second occurrences.
+ $ranges = array();
+ $included = array();
+ $foundkeys = array();
+ $length = 0;
+ while ($length < 256 && count($workkeys)) {
+ foreach ($workkeys as $k => $key) {
+ if (strlen($key) == 0) {
+ unset($workkeys[$k]);
+ unset($keys[$k]);
+ continue;
+ }
+ if ($length >= 256) {
+ break;
+ }
+ // Remember occurrence of key so we can skip over it if more occurrences
+ // are desired.
+ if (!isset($included[$key])) {
+ $included[$key] = 0;
+ }
+ // Locate a keyword (position $p, always >0 because $text starts with a
+ // space). First try bare keyword, but if that doesn't work, try to find a
+ // derived form from search_simplify().
+ $p = 0;
+ if (preg_match('/' . $boundary . $key . $boundary . '/iu', $text, $match, PREG_OFFSET_CAPTURE, $included[$key])) {
+ $p = $match[0][1];
+ }
+ else {
+ $info = search_simplify_excerpt_match($key, $text, $included[$key], $boundary);
+ if ($info['where']) {
+ $p = $info['where'];
+ if ($info['keyword']) {
+ $foundkeys[] = $info['keyword'];
+ }
+ }
+ }
+ // Now locate a space in front (position $q) and behind it (position $s),
+ // leaving about 60 characters extra before and after for context.
+ // Note that a space was added to the front and end of $text above.
+ if ($p) {
+ if (($q = strpos(' ' . $text, ' ', max(0, $p - 61))) !== FALSE) {
+ $end = substr($text . ' ', $p, 80);
+ if (($s = strrpos($end, ' ')) !== FALSE) {
+ // Account for the added spaces.
+ $q = max($q - 1, 0);
+ $s = min($s, drupal_strlen($end) - 1);
+ $ranges[$q] = $p + $s;
+ $length += $p + $s - $q;
+ $included[$key] = $p + 1;
+ }
+ else {
+ unset($workkeys[$k]);
+ }
+ }
+ else {
+ unset($workkeys[$k]);
+ }
+ }
+ else {
+ unset($workkeys[$k]);
+ }
+ }
+ }
+
+ if (count($ranges) == 0) {
+ // We didn't find any keyword matches, so just return the first part of the
+ // text. We also need to re-encode any HTML special characters that we
+ // entity-decoded above.
+ return check_plain(truncate_utf8($text, 256, TRUE, TRUE));
+ }
+
+ // Sort the text ranges by starting position.
+ ksort($ranges);
+
+ // Now we collapse overlapping text ranges into one. The sorting makes it O(n).
+ $newranges = array();
+ foreach ($ranges as $from2 => $to2) {
+ if (!isset($from1)) {
+ $from1 = $from2;
+ $to1 = $to2;
+ continue;
+ }
+ if ($from2 <= $to1) {
+ $to1 = max($to1, $to2);
+ }
+ else {
+ $newranges[$from1] = $to1;
+ $from1 = $from2;
+ $to1 = $to2;
+ }
+ }
+ $newranges[$from1] = $to1;
+
+ // Fetch text
+ $out = array();
+ foreach ($newranges as $from => $to) {
+ $out[] = substr($text, $from, $to - $from);
+ }
+
+ // Let translators have the ... separator text as one chunk.
+ $dots = explode('!excerpt', t('... !excerpt ... !excerpt ...'));
+
+ $text = (isset($newranges[0]) ? '' : $dots[0]) . implode($dots[1], $out) . $dots[2];
+ $text = check_plain($text);
+
+ // Slash-escape quotes in keys found in a derived form and merge with original keys.
+ array_walk($foundkeys, '_search_excerpt_replace');
+ $keys = array_merge($keys, $foundkeys);
+
+ // Highlight keywords. Must be done at once to prevent conflicts ('strong' and '<strong>').
+ $text = preg_replace('/' . $boundary . '(' . implode('|', $keys) . ')' . $boundary . '/iu', '<strong>\0</strong>', $text);
+ return $text;
+}
+
+/**
+ * @} End of "defgroup search".
+ */
+
+/**
+ * Helper function for array_walk() in search_excerpt().
+ */
+function _search_excerpt_replace(&$text) {
+ $text = preg_quote($text, '/');
+}
+
+/**
+ * Find words in the original text that matched via search_simplify().
+ *
+ * This is called in search_excerpt() if an exact match is not found in the
+ * text, so that we can find the derived form that matches.
+ *
+ * @param $key
+ * The keyword to find.
+ * @param $text
+ * The text to search for the keyword.
+ * @param $offset
+ * Offset position in $text to start searching at.
+ * @param $boundary
+ * Text to include in a regular expression that will match a word boundary.
+ *
+ * @return
+ * FALSE if no match is found. If a match is found, return an associative
+ * array with element 'where' giving the position of the match, and element
+ * 'keyword' giving the actual word found in the text at that position.
+ */
+function search_simplify_excerpt_match($key, $text, $offset, $boundary) {
+ $pos = NULL;
+ $simplified_key = search_simplify($key);
+ $simplified_text = search_simplify($text);
+
+ // Check if we have a match after simplification in the text.
+ if (!preg_match('/' . $boundary . $simplified_key . $boundary . '/iu', $simplified_text, $match, PREG_OFFSET_CAPTURE, $offset)) {
+ return FALSE;
+ }
+
+ // If we get here, we have a match. Now find the exact location of the match
+ // and the original text that matched. Start by splitting up the text by all
+ // potential starting points of the matching text and iterating through them.
+ $split = array_filter(preg_split('/' . $boundary . '/iu', $text, -1, PREG_SPLIT_OFFSET_CAPTURE), '_search_excerpt_match_filter');
+ foreach ($split as $value) {
+ // Skip starting points before the offset.
+ if ($value[1] < $offset) {
+ continue;
+ }
+
+ // Check a window of 80 characters after the starting point for a match,
+ // based on the size of the excerpt window.
+ $window = substr($text, $value[1], 80);
+ $simplified_window = search_simplify($window);
+ if (strpos($simplified_window, $simplified_key) === 0) {
+ // We have a match in this window. Store the position of the match.
+ $pos = $value[1];
+ // Iterate through the text in the window until we find the full original
+ // matching text.
+ $length = strlen($window);
+ for ($i = 1; $i <= $length; $i++) {
+ $keyfound = substr($text, $value[1], $i);
+ if ($simplified_key == search_simplify($keyfound)) {
+ break;
+ }
+ }
+ break;
+ }
+ }
+
+ return $pos ? array('where' => $pos, 'keyword' => $keyfound) : FALSE;
+}
+
+/**
+ * Helper function for array_filter() in search_search_excerpt_match().
+ */
+function _search_excerpt_match_filter($var) {
+ return strlen(trim($var[0]));
+}
+
+/**
+ * Implements hook_forms().
+ */
+function search_forms() {
+ $forms['search_block_form']= array(
+ 'callback' => 'search_box',
+ 'callback arguments' => array('search_block_form'),
+ );
+ return $forms;
+}
+
diff --git a/core/modules/search/search.pages.inc b/core/modules/search/search.pages.inc
new file mode 100644
index 000000000000..833ea8bccd53
--- /dev/null
+++ b/core/modules/search/search.pages.inc
@@ -0,0 +1,160 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the search module.
+ */
+
+/**
+ * Menu callback; presents the search form and/or search results.
+ *
+ * @param $module
+ * Search module to use for the search.
+ * @param $keys
+ * Keywords to use for the search.
+ */
+function search_view($module = NULL, $keys = '') {
+ $info = FALSE;
+ $redirect = FALSE;
+ $keys = trim($keys);
+ // Also try to pull search keywords out of the $_REQUEST variable to
+ // support old GET format of searches for existing links.
+ if (!$keys && !empty($_REQUEST['keys'])) {
+ $keys = trim($_REQUEST['keys']);
+ }
+
+ if (!empty($module)) {
+ $active_module_info = search_get_info();
+ if (isset($active_module_info[$module])) {
+ $info = $active_module_info[$module];
+ }
+ }
+
+ if (empty($info)) {
+ // No path or invalid path: find the default module. Note that if there
+ // are no enabled search modules, this function should never be called,
+ // since hook_menu() would not have defined any search paths.
+ $info = search_get_default_module_info();
+ // Redirect from bare /search or an invalid path to the default search path.
+ $path = 'search/' . $info['path'];
+ if ($keys) {
+ $path .= '/' . $keys;
+ }
+ drupal_goto($path);
+ }
+
+ // Default results output is an empty string.
+ $results = array('#markup' => '');
+ // Process the search form. Note that if there is $_POST data,
+ // search_form_submit() will cause a redirect to search/[module path]/[keys],
+ // which will get us back to this page callback. In other words, the search
+ // form submits with POST but redirects to GET. This way we can keep
+ // the search query URL clean as a whistle.
+ if (empty($_POST['form_id']) || $_POST['form_id'] != 'search_form') {
+ $conditions = NULL;
+ if (isset($info['conditions_callback']) && function_exists($info['conditions_callback'])) {
+ // Build an optional array of more search conditions.
+ $conditions = call_user_func($info['conditions_callback'], $keys);
+ }
+ // Only search if there are keywords or non-empty conditions.
+ if ($keys || !empty($conditions)) {
+ // Log the search keys.
+ watchdog('search', 'Searched %type for %keys.', array('%keys' => $keys, '%type' => $info['title']), WATCHDOG_NOTICE, l(t('results'), 'search/' . $info['path'] . '/' . $keys));
+
+ // Collect the search results.
+ $results = search_data($keys, $info['module'], $conditions);
+ }
+ }
+ // The form may be altered based on whether the search was run.
+ $build['search_form'] = drupal_get_form('search_form', NULL, $keys, $info['module']);
+ $build['search_results'] = $results;
+
+ return $build;
+}
+
+/**
+ * Process variables for search-results.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $results: Search results array.
+ * - $module: Module the search results came from (module implementing
+ * hook_search_info()).
+ *
+ * @see search-results.tpl.php
+ */
+function template_preprocess_search_results(&$variables) {
+ $variables['search_results'] = '';
+ if (!empty($variables['module'])) {
+ $variables['module'] = check_plain($variables['module']);
+ }
+ foreach ($variables['results'] as $result) {
+ $variables['search_results'] .= theme('search_result', array('result' => $result, 'module' => $variables['module']));
+ }
+ $variables['pager'] = theme('pager', array('tags' => NULL));
+ $variables['theme_hook_suggestions'][] = 'search_results__' . $variables['module'];
+}
+
+/**
+ * Process variables for search-result.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $result
+ * - $module
+ *
+ * @see search-result.tpl.php
+ */
+function template_preprocess_search_result(&$variables) {
+ global $language;
+
+ $result = $variables['result'];
+ $variables['url'] = check_url($result['link']);
+ $variables['title'] = check_plain($result['title']);
+ if (isset($result['language']) && $result['language'] != $language->language && $result['language'] != LANGUAGE_NONE) {
+ $variables['title_attributes_array']['xml:lang'] = $result['language'];
+ $variables['content_attributes_array']['xml:lang'] = $result['language'];
+ }
+
+ $info = array();
+ if (!empty($result['module'])) {
+ $info['module'] = check_plain($result['module']);
+ }
+ if (!empty($result['user'])) {
+ $info['user'] = $result['user'];
+ }
+ if (!empty($result['date'])) {
+ $info['date'] = format_date($result['date'], 'short');
+ }
+ if (isset($result['extra']) && is_array($result['extra'])) {
+ $info = array_merge($info, $result['extra']);
+ }
+ // Check for existence. User search does not include snippets.
+ $variables['snippet'] = isset($result['snippet']) ? $result['snippet'] : '';
+ // Provide separated and grouped meta information..
+ $variables['info_split'] = $info;
+ $variables['info'] = implode(' - ', $info);
+ $variables['theme_hook_suggestions'][] = 'search_result__' . $variables['module'];
+}
+
+/**
+ * As the search form collates keys from other modules hooked in via
+ * hook_form_alter, the validation takes place in _submit.
+ * search_form_validate() is used solely to set the 'processed_keys' form
+ * value for the basic search form.
+ */
+function search_form_validate($form, &$form_state) {
+ form_set_value($form['basic']['processed_keys'], trim($form_state['values']['keys']), $form_state);
+}
+
+/**
+ * Process a search form submission.
+ */
+function search_form_submit($form, &$form_state) {
+ $keys = $form_state['values']['processed_keys'];
+ if ($keys == '') {
+ form_set_error('keys', t('Please enter some keywords.'));
+ // Fall through to the form redirect.
+ }
+
+ $form_state['redirect'] = $form_state['action'] . '/' . $keys;
+ return;
+}
diff --git a/core/modules/search/search.test b/core/modules/search/search.test
new file mode 100644
index 000000000000..3ea089ce9b85
--- /dev/null
+++ b/core/modules/search/search.test
@@ -0,0 +1,1992 @@
+<?php
+
+/**
+ * @file
+ * Tests for search.module.
+ */
+
+// The search index can contain different types of content. Typically the type is 'node'.
+// Here we test with _test_ and _test2_ as the type.
+define('SEARCH_TYPE', '_test_');
+define('SEARCH_TYPE_2', '_test2_');
+define('SEARCH_TYPE_JPN', '_test3_');
+
+class SearchMatchTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search engine queries',
+ 'description' => 'Indexes content and queries it.',
+ 'group' => 'Search',
+ );
+ }
+
+ /**
+ * Implementation setUp().
+ */
+ function setUp() {
+ parent::setUp('search');
+ }
+
+ /**
+ * Test search indexing.
+ */
+ function testMatching() {
+ $this->_setup();
+ $this->_testQueries();
+ }
+
+ /**
+ * Set up a small index of items to test against.
+ */
+ function _setup() {
+ variable_set('minimum_word_size', 3);
+
+ for ($i = 1; $i <= 7; ++$i) {
+ search_index($i, SEARCH_TYPE, $this->getText($i));
+ }
+ for ($i = 1; $i <= 5; ++$i) {
+ search_index($i + 7, SEARCH_TYPE_2, $this->getText2($i));
+ }
+ // No getText builder function for Japanese text; just a simple array.
+ foreach (array(
+ 13 => '以呂波耳・ほへとち。リヌルヲ。',
+ 14 => 'ドルーパルが大好きよ!',
+ 15 => 'コーヒーとケーキ',
+ ) as $i => $jpn) {
+ search_index($i, SEARCH_TYPE_JPN, $jpn);
+ }
+ search_update_totals();
+ }
+
+ /**
+ * _test_: Helper method for generating snippets of content.
+ *
+ * Generated items to test against:
+ * 1 ipsum
+ * 2 dolore sit
+ * 3 sit am ut
+ * 4 am ut enim am
+ * 5 ut enim am minim veniam
+ * 6 enim am minim veniam es cillum
+ * 7 am minim veniam es cillum dolore eu
+ */
+ function getText($n) {
+ $words = explode(' ', "Ipsum dolore sit am. Ut enim am minim veniam. Es cillum dolore eu.");
+ return implode(' ', array_slice($words, $n - 1, $n));
+ }
+
+ /**
+ * _test2_: Helper method for generating snippets of content.
+ *
+ * Generated items to test against:
+ * 8 dear
+ * 9 king philip
+ * 10 philip came over
+ * 11 came over from germany
+ * 12 over from germany swimming
+ */
+ function getText2($n) {
+ $words = explode(' ', "Dear King Philip came over from Germany swimming.");
+ return implode(' ', array_slice($words, $n - 1, $n));
+ }
+
+ /**
+ * Run predefine queries looking for indexed terms.
+ */
+ function _testQueries() {
+ /*
+ Note: OR queries that include short words in OR groups are only accepted
+ if the ORed terms are ANDed with at least one long word in the rest of the query.
+
+ e.g. enim dolore OR ut = enim (dolore OR ut) = (enim dolor) OR (enim ut) -> good
+ e.g. dolore OR ut = (dolore) OR (ut) -> bad
+
+ This is a design limitation to avoid full table scans.
+ */
+ $queries = array(
+ // Simple AND queries.
+ 'ipsum' => array(1),
+ 'enim' => array(4, 5, 6),
+ 'xxxxx' => array(),
+ 'enim minim' => array(5, 6),
+ 'enim xxxxx' => array(),
+ 'dolore eu' => array(7),
+ 'dolore xx' => array(),
+ 'ut minim' => array(5),
+ 'xx minim' => array(),
+ 'enim veniam am minim ut' => array(5),
+ // Simple OR queries.
+ 'dolore OR ipsum' => array(1, 2, 7),
+ 'dolore OR xxxxx' => array(2, 7),
+ 'dolore OR ipsum OR enim' => array(1, 2, 4, 5, 6, 7),
+ 'ipsum OR dolore sit OR cillum' => array(2, 7),
+ 'minim dolore OR ipsum' => array(7),
+ 'dolore OR ipsum veniam' => array(7),
+ 'minim dolore OR ipsum OR enim' => array(5, 6, 7),
+ 'dolore xx OR yy' => array(),
+ 'xxxxx dolore OR ipsum' => array(),
+ // Negative queries.
+ 'dolore -sit' => array(7),
+ 'dolore -eu' => array(2),
+ 'dolore -xxxxx' => array(2, 7),
+ 'dolore -xx' => array(2, 7),
+ // Phrase queries.
+ '"dolore sit"' => array(2),
+ '"sit dolore"' => array(),
+ '"am minim veniam es"' => array(6, 7),
+ '"minim am veniam es"' => array(),
+ // Mixed queries.
+ '"am minim veniam es" OR dolore' => array(2, 6, 7),
+ '"minim am veniam es" OR "dolore sit"' => array(2),
+ '"minim am veniam es" OR "sit dolore"' => array(),
+ '"am minim veniam es" -eu' => array(6),
+ '"am minim veniam" -"cillum dolore"' => array(5, 6),
+ '"am minim veniam" -"dolore cillum"' => array(5, 6, 7),
+ 'xxxxx "minim am veniam es" OR dolore' => array(),
+ 'xx "minim am veniam es" OR dolore' => array()
+ );
+ foreach ($queries as $query => $results) {
+ $result = db_select('search_index', 'i')
+ ->extend('SearchQuery')
+ ->searchExpression($query, SEARCH_TYPE)
+ ->execute();
+
+ $set = $result ? $result->fetchAll() : array();
+ $this->_testQueryMatching($query, $set, $results);
+ $this->_testQueryScores($query, $set, $results);
+ }
+
+ // These queries are run against the second index type, SEARCH_TYPE_2.
+ $queries = array(
+ // Simple AND queries.
+ 'ipsum' => array(),
+ 'enim' => array(),
+ 'enim minim' => array(),
+ 'dear' => array(8),
+ 'germany' => array(11, 12),
+ );
+ foreach ($queries as $query => $results) {
+ $result = db_select('search_index', 'i')
+ ->extend('SearchQuery')
+ ->searchExpression($query, SEARCH_TYPE_2)
+ ->execute();
+
+ $set = $result ? $result->fetchAll() : array();
+ $this->_testQueryMatching($query, $set, $results);
+ $this->_testQueryScores($query, $set, $results);
+ }
+
+ // These queries are run against the third index type, SEARCH_TYPE_JPN.
+ $queries = array(
+ // Simple AND queries.
+ '呂波耳' => array(13),
+ '以呂波耳' => array(13),
+ 'ほへと ヌルヲ' => array(13),
+ 'とちリ' => array(),
+ 'ドルーパル' => array(14),
+ 'パルが大' => array(14),
+ 'コーヒー' => array(15),
+ 'ヒーキ' => array(),
+ );
+ foreach ($queries as $query => $results) {
+ $result = db_select('search_index', 'i')
+ ->extend('SearchQuery')
+ ->searchExpression($query, SEARCH_TYPE_JPN)
+ ->execute();
+
+ $set = $result ? $result->fetchAll() : array();
+ $this->_testQueryMatching($query, $set, $results);
+ $this->_testQueryScores($query, $set, $results);
+ }
+ }
+
+ /**
+ * Test the matching abilities of the engine.
+ *
+ * Verify if a query produces the correct results.
+ */
+ function _testQueryMatching($query, $set, $results) {
+ // Get result IDs.
+ $found = array();
+ foreach ($set as $item) {
+ $found[] = $item->sid;
+ }
+
+ // Compare $results and $found.
+ sort($found);
+ sort($results);
+ $this->assertEqual($found, $results, "Query matching '$query'");
+ }
+
+ /**
+ * Test the scoring abilities of the engine.
+ *
+ * Verify if a query produces normalized, monotonous scores.
+ */
+ function _testQueryScores($query, $set, $results) {
+ // Get result scores.
+ $scores = array();
+ foreach ($set as $item) {
+ $scores[] = $item->calculated_score;
+ }
+
+ // Check order.
+ $sorted = $scores;
+ sort($sorted);
+ $this->assertEqual($scores, array_reverse($sorted), "Query order '$query'");
+
+ // Check range.
+ $this->assertEqual(!count($scores) || (min($scores) > 0.0 && max($scores) <= 1.0001), TRUE, "Query scoring '$query'");
+ }
+}
+
+/**
+ * Tests the bike shed text on no results page, and text on the search page.
+ */
+class SearchPageText extends DrupalWebTestCase {
+ protected $searching_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search page text',
+ 'description' => 'Tests the bike shed text on the no results page, and various other text on search pages.',
+ 'group' => 'Search'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+
+ // Create user.
+ $this->searching_user = $this->drupalCreateUser(array('search content', 'access user profiles'));
+ }
+
+ /**
+ * Tests the failed search text, and various other text on the search page.
+ */
+ function testSearchText() {
+ $this->drupalLogin($this->searching_user);
+ $this->drupalGet('search/node');
+ $this->assertText(t('Enter your keywords'));
+ $this->assertText(t('Search'));
+ $title = t('Search') . ' | Drupal';
+ $this->assertTitle($title, 'Search page title is correct');
+
+ $edit = array();
+ $edit['keys'] = 'bike shed ' . $this->randomName();
+ $this->drupalPost('search/node', $edit, t('Search'));
+ $this->assertText(t('Consider loosening your query with OR. bike OR shed will often show more results than bike shed.'), t('Help text is displayed when search returns no results.'));
+ $this->assertText(t('Search'));
+ $this->assertTitle($title, 'Search page title is correct');
+
+ $edit['keys'] = $this->searching_user->name;
+ $this->drupalPost('search/user', $edit, t('Search'));
+ $this->assertText(t('Search'));
+ $this->assertTitle($title, 'Search page title is correct');
+
+ // Test that search keywords containing slashes are correctly loaded
+ // from the path and displayed in the search form.
+ $arg = $this->randomName() . '/' . $this->randomName();
+ $this->drupalGet('search/node/' . $arg);
+ $input = $this->xpath("//input[@id='edit-keys' and @value='{$arg}']");
+ $this->assertFalse(empty($input), 'Search keys with a / are correctly set as the default value in the search box.');
+ }
+}
+
+class SearchAdvancedSearchForm extends DrupalWebTestCase {
+ protected $node;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Advanced search form',
+ 'description' => 'Indexes content and tests the advanced search form.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+ // Create and login user.
+ $test_user = $this->drupalCreateUser(array('access content', 'search content', 'use advanced search', 'administer nodes'));
+ $this->drupalLogin($test_user);
+
+ // Create initial node.
+ $node = $this->drupalCreateNode();
+ $this->node = $this->drupalCreateNode();
+
+ // First update the index. This does the initial processing.
+ node_update_index();
+
+ // Then, run the shutdown function. Testing is a unique case where indexing
+ // and searching has to happen in the same request, so running the shutdown
+ // function manually is needed to finish the indexing process.
+ search_update_totals();
+ }
+
+ /**
+ * Test using the search form with GET and POST queries.
+ * Test using the advanced search form to limit search to nodes of type "Basic page".
+ */
+ function testNodeType() {
+ $this->assertTrue($this->node->type == 'page', t('Node type is Basic page.'));
+
+ // Assert that the dummy title doesn't equal the real title.
+ $dummy_title = 'Lorem ipsum';
+ $this->assertNotEqual($dummy_title, $this->node->title, t("Dummy title doesn't equal node title"));
+
+ // Search for the dummy title with a GET query.
+ $this->drupalGet('search/node/' . $dummy_title);
+ $this->assertNoText($this->node->title, t('Basic page node is not found with dummy title.'));
+
+ // Search for the title of the node with a GET query.
+ $this->drupalGet('search/node/' . $this->node->title);
+ $this->assertText($this->node->title, t('Basic page node is found with GET query.'));
+
+ // Search for the title of the node with a POST query.
+ $edit = array('or' => $this->node->title);
+ $this->drupalPost('search/node', $edit, t('Advanced search'));
+ $this->assertText($this->node->title, t('Basic page node is found with POST query.'));
+
+ // Advanced search type option.
+ $this->drupalPost('search/node', array_merge($edit, array('type[page]' => 'page')), t('Advanced search'));
+ $this->assertText($this->node->title, t('Basic page node is found with POST query and type:page.'));
+
+ $this->drupalPost('search/node', array_merge($edit, array('type[article]' => 'article')), t('Advanced search'));
+ $this->assertText('bike shed', t('Article node is not found with POST query and type:article.'));
+ }
+}
+
+class SearchRankingTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search engine ranking',
+ 'description' => 'Indexes content and tests ranking factors.',
+ 'group' => 'Search',
+ );
+ }
+
+ /**
+ * Implementation setUp().
+ */
+ function setUp() {
+ parent::setUp('search', 'statistics', 'comment');
+ }
+
+ function testRankings() {
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->drupalCreateUser(array('skip comment approval', 'create page content')));
+
+ // Build a list of the rankings to test.
+ $node_ranks = array('sticky', 'promote', 'relevance', 'recent', 'comments', 'views');
+
+ // Create nodes for testing.
+ foreach ($node_ranks as $node_rank) {
+ $settings = array(
+ 'type' => 'page',
+ 'title' => 'Drupal rocks',
+ 'body' => array(LANGUAGE_NONE => array(array('value' => "Drupal's search rocks"))),
+ );
+ foreach (array(0, 1) as $num) {
+ if ($num == 1) {
+ switch ($node_rank) {
+ case 'sticky':
+ case 'promote':
+ $settings[$node_rank] = 1;
+ break;
+ case 'relevance':
+ $settings['body'][LANGUAGE_NONE][0]['value'] .= " really rocks";
+ break;
+ case 'recent':
+ $settings['created'] = REQUEST_TIME + 3600;
+ break;
+ case 'comments':
+ $settings['comment'] = 2;
+ break;
+ }
+ }
+ $nodes[$node_rank][$num] = $this->drupalCreateNode($settings);
+ }
+ }
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ // Add a comment to one of the nodes.
+ $edit = array();
+ $edit['subject'] = 'my comment title';
+ $edit['comment_body[' . LANGUAGE_NONE . '][0][value]'] = 'some random comment';
+ $this->drupalGet('comment/reply/' . $nodes['comments'][1]->nid);
+ $this->drupalPost(NULL, $edit, t('Preview'));
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Enable counting of statistics.
+ variable_set('statistics_count_content_views', 1);
+
+ // Then View one of the nodes a bunch of times.
+ for ($i = 0; $i < 5; $i ++) {
+ $this->drupalGet('node/' . $nodes['views'][1]->nid);
+ }
+
+ // Test each of the possible rankings.
+ foreach ($node_ranks as $node_rank) {
+ // Disable all relevancy rankings except the one we are testing.
+ foreach ($node_ranks as $var) {
+ variable_set('node_rank_' . $var, $var == $node_rank ? 10 : 0);
+ }
+
+ // Do the search and assert the results.
+ $set = node_search_execute('rocks');
+ $this->assertEqual($set[0]['node']->nid, $nodes[$node_rank][1]->nid, 'Search ranking "' . $node_rank . '" order.');
+ }
+ }
+
+ /**
+ * Test rankings of HTML tags.
+ */
+ function testHTMLRankings() {
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->drupalCreateUser(array('create page content')));
+
+ // Test HTML tags with different weights.
+ $sorted_tags = array('h1', 'h2', 'h3', 'h4', 'a', 'h5', 'h6', 'notag');
+ $shuffled_tags = $sorted_tags;
+
+ // Shuffle tags to ensure HTML tags are ranked properly.
+ shuffle($shuffled_tags);
+ $settings = array(
+ 'type' => 'page',
+ 'title' => 'Simple node',
+ );
+ foreach ($shuffled_tags as $tag) {
+ switch ($tag) {
+ case 'a':
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => l('Drupal Rocks', 'node'), 'format' => 'full_html')));
+ break;
+ case 'notag':
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'Drupal Rocks')));
+ break;
+ default:
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => "<$tag>Drupal Rocks</$tag>", 'format' => 'full_html')));
+ break;
+ }
+ $nodes[$tag] = $this->drupalCreateNode($settings);
+ }
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ // Disable all other rankings.
+ $node_ranks = array('sticky', 'promote', 'recent', 'comments', 'views');
+ foreach ($node_ranks as $node_rank) {
+ variable_set('node_rank_' . $node_rank, 0);
+ }
+ $set = node_search_execute('rocks');
+
+ // Test the ranking of each tag.
+ foreach ($sorted_tags as $tag_rank => $tag) {
+ // Assert the results.
+ if ($tag == 'notag') {
+ $this->assertEqual($set[$tag_rank]['node']->nid, $nodes[$tag]->nid, 'Search tag ranking for plain text order.');
+ } else {
+ $this->assertEqual($set[$tag_rank]['node']->nid, $nodes[$tag]->nid, 'Search tag ranking for "&lt;' . $sorted_tags[$tag_rank] . '&gt;" order.');
+ }
+ }
+
+ // Test tags with the same weight against the sorted tags.
+ $unsorted_tags = array('u', 'b', 'i', 'strong', 'em');
+ foreach ($unsorted_tags as $tag) {
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => "<$tag>Drupal Rocks</$tag>", 'format' => 'full_html')));
+ $node = $this->drupalCreateNode($settings);
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ $set = node_search_execute('rocks');
+
+ // Ranking should always be second to last.
+ $set = array_slice($set, -2, 1);
+
+ // Assert the results.
+ $this->assertEqual($set[0]['node']->nid, $node->nid, 'Search tag ranking for "&lt;' . $tag . '&gt;" order.');
+
+ // Delete node so it doesn't show up in subsequent search results.
+ node_delete($node->nid);
+ }
+ }
+
+ /**
+ * Verifies that if we combine two rankings, search still works.
+ *
+ * See issue http://drupal.org/node/771596
+ */
+ function testDoubleRankings() {
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->drupalCreateUser(array('skip comment approval', 'create page content')));
+
+ // See testRankings() above - build a node that will rank high for sticky.
+ $settings = array(
+ 'type' => 'page',
+ 'title' => 'Drupal rocks',
+ 'body' => array(LANGUAGE_NONE => array(array('value' => "Drupal's search rocks"))),
+ 'sticky' => 1,
+ );
+
+ $node = $this->drupalCreateNode($settings);
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ // Set up for ranking sticky and lots of comments; make sure others are
+ // disabled.
+ $node_ranks = array('sticky', 'promote', 'relevance', 'recent', 'comments', 'views');
+ foreach ($node_ranks as $var) {
+ $value = ($var == 'sticky' || $var == 'comments') ? 10 : 0;
+ variable_set('node_rank_' . $var, $value);
+ }
+
+ // Do the search and assert the results.
+ $set = node_search_execute('rocks');
+ $this->assertEqual($set[0]['node']->nid, $node->nid, 'Search double ranking order.');
+ }
+}
+
+class SearchBlockTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block availability',
+ 'description' => 'Check if the search form block is available.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+
+ // Create and login user
+ $admin_user = $this->drupalCreateUser(array('administer blocks', 'search content'));
+ $this->drupalLogin($admin_user);
+ }
+
+ function testSearchFormBlock() {
+ // Set block title to confirm that the interface is available.
+ $this->drupalPost('admin/structure/block/manage/search/form/configure', array('title' => $this->randomName(8)), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the block to a region to confirm block is available.
+ $edit = array();
+ $edit['blocks[search_form][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully move to footer region.'));
+ }
+
+ /**
+ * Test that the search block form works correctly.
+ */
+ function testBlock() {
+ // Enable the block, and place it in the 'content' region so that it isn't
+ // hidden on 404 pages.
+ $edit = array('blocks[search_form][region]' => 'content');
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Test a normal search via the block form, from the front page.
+ $terms = array('search_block_form' => 'test');
+ $this->drupalPost('node', $terms, t('Search'));
+ $this->assertText('Your search yielded no results');
+
+ // Test a search from the block on a 404 page.
+ $this->drupalGet('foo');
+ $this->assertResponse(404);
+ $this->drupalPost(NULL, $terms, t('Search'));
+ $this->assertResponse(200);
+ $this->assertText('Your search yielded no results');
+
+ // Test a search from the block when it doesn't appear on the search page.
+ $edit = array('pages' => 'search');
+ $this->drupalPost('admin/structure/block/manage/search/form/configure', $edit, t('Save block'));
+ $this->drupalPost('node', $terms, t('Search'));
+ $this->assertText('Your search yielded no results');
+
+ // Confirm that the user is redirected to the search page.
+ $this->assertEqual(
+ $this->getUrl(),
+ url('search/node/' . $terms['search_block_form'], array('absolute' => TRUE)),
+ t('Redirected to correct url.')
+ );
+
+ // Test an empty search via the block form, from the front page.
+ $terms = array('search_block_form' => '');
+ $this->drupalPost('node', $terms, t('Search'));
+ $this->assertText('Please enter some keywords');
+
+ // Confirm that the user is redirected to the search page, when form is submitted empty.
+ $this->assertEqual(
+ $this->getUrl(),
+ url('search/node/', array('absolute' => TRUE)),
+ t('Redirected to correct url.')
+ );
+ }
+}
+
+/**
+ * Tests that searching for a phrase gets the correct page count.
+ */
+class SearchExactTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search engine phrase queries',
+ 'description' => 'Tests that searching for a phrase gets the correct page count.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+ }
+
+ /**
+ * Tests that the correct number of pager links are found for both keywords and phrases.
+ */
+ function testExactQuery() {
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->drupalCreateUser(array('create page content', 'search content')));
+
+ $settings = array(
+ 'type' => 'page',
+ 'title' => 'Simple Node',
+ );
+ // Create nodes with exact phrase.
+ for ($i = 0; $i <= 17; $i++) {
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'love pizza')));
+ $this->drupalCreateNode($settings);
+ }
+ // Create nodes containing keywords.
+ for ($i = 0; $i <= 17; $i++) {
+ $settings['body'] = array(LANGUAGE_NONE => array(array('value' => 'love cheesy pizza')));
+ $this->drupalCreateNode($settings);
+ }
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ // Test that the correct number of pager links are found for keyword search.
+ $edit = array('keys' => 'love pizza');
+ $this->drupalPost('search/node', $edit, t('Search'));
+ $this->assertLinkByHref('page=1', 0, '2nd page link is found for keyword search.');
+ $this->assertLinkByHref('page=2', 0, '3rd page link is found for keyword search.');
+ $this->assertLinkByHref('page=3', 0, '4th page link is found for keyword search.');
+ $this->assertNoLinkByHref('page=4', '5th page link is not found for keyword search.');
+
+ // Test that the correct number of pager links are found for exact phrase search.
+ $edit = array('keys' => '"love pizza"');
+ $this->drupalPost('search/node', $edit, t('Search'));
+ $this->assertLinkByHref('page=1', 0, '2nd page link is found for exact phrase search.');
+ $this->assertNoLinkByHref('page=2', '3rd page link is not found for exact phrase search.');
+ }
+}
+
+/**
+ * Test integration searching comments.
+ */
+class SearchCommentTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment Search tests',
+ 'description' => 'Verify text formats and filters used elsewhere.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('comment', 'search');
+
+ // Create and log in an administrative user having access to the Full HTML
+ // text format.
+ $full_html_format = filter_format_load('full_html');
+ $permissions = array(
+ 'administer filters',
+ filter_permission_name($full_html_format),
+ 'administer permissions',
+ 'create page content',
+ 'skip comment approval',
+ 'access comments',
+ );
+ $this->admin_user = $this->drupalCreateUser($permissions);
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Verify that comments are rendered using proper format in search results.
+ */
+ function testSearchResultsComment() {
+ $comment_body = 'Test comment body';
+
+ variable_set('comment_preview_article', DRUPAL_OPTIONAL);
+ // Enable check_plain() for 'Filtered HTML' text format.
+ $filtered_html_format_id = 'filtered_html';
+ $edit = array(
+ 'filters[filter_html_escape][status]' => TRUE,
+ );
+ $this->drupalPost('admin/config/content/formats/' . $filtered_html_format_id, $edit, t('Save configuration'));
+ // Allow anonymous users to search content.
+ $edit = array(
+ DRUPAL_ANONYMOUS_RID . '[search content]' => 1,
+ DRUPAL_ANONYMOUS_RID . '[access comments]' => 1,
+ DRUPAL_ANONYMOUS_RID . '[post comments]' => 1,
+ );
+ $this->drupalPost('admin/people/permissions', $edit, t('Save permissions'));
+
+ // Create a node.
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ // Post a comment using 'Full HTML' text format.
+ $edit_comment = array();
+ $edit_comment['subject'] = 'Test comment subject';
+ $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][value]'] = '<h1>' . $comment_body . '</h1>';
+ $full_html_format_id = 'full_html';
+ $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][format]'] = $full_html_format_id;
+ $this->drupalPost('comment/reply/' . $node->nid, $edit_comment, t('Save'));
+
+ // Invoke search index update.
+ $this->drupalLogout();
+ $this->cronRun();
+
+ // Search for the comment subject.
+ $edit = array(
+ 'search_block_form' => "'" . $edit_comment['subject'] . "'",
+ );
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertText($node->title, t('Node found in search results.'));
+ $this->assertText($edit_comment['subject'], t('Comment subject found in search results.'));
+
+ // Search for the comment body.
+ $edit = array(
+ 'search_block_form' => "'" . $comment_body . "'",
+ );
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertText($node->title, t('Node found in search results.'));
+
+ // Verify that comment is rendered using proper format.
+ $this->assertText($comment_body, t('Comment body text found in search results.'));
+ $this->assertNoRaw(t('n/a'), t('HTML in comment body is not hidden.'));
+ $this->assertNoRaw(check_plain($edit_comment['comment_body[' . LANGUAGE_NONE . '][0][value]']), t('HTML in comment body is not escaped.'));
+
+ // Hide comments.
+ $this->drupalLogin($this->admin_user);
+ $node->comment = 0;
+ node_save($node);
+
+ // Invoke search index update.
+ $this->drupalLogout();
+ $this->cronRun();
+
+ // Search for $title.
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertNoText($comment_body, t('Comment body text not found in search results.'));
+ }
+
+ /**
+ * Verify access rules for comment indexing with different permissions.
+ */
+ function testSearchResultsCommentAccess() {
+ $comment_body = 'Test comment body';
+ $this->comment_subject = 'Test comment subject';
+ $this->admin_role = $this->admin_user->roles;
+ unset($this->admin_role[DRUPAL_AUTHENTICATED_RID]);
+ $this->admin_role = key($this->admin_role);
+
+ // Create a node.
+ variable_set('comment_preview_article', DRUPAL_OPTIONAL);
+ $this->node = $this->drupalCreateNode(array('type' => 'article'));
+
+ // Post a comment using 'Full HTML' text format.
+ $edit_comment = array();
+ $edit_comment['subject'] = $this->comment_subject;
+ $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][value]'] = '<h1>' . $comment_body . '</h1>';
+ $this->drupalPost('comment/reply/' . $this->node->nid, $edit_comment, t('Save'));
+
+ $this->drupalLogout();
+ $this->setRolePermissions(DRUPAL_ANONYMOUS_RID);
+ $this->checkCommentAccess('Anon user has search permission but no access comments permission, comments should not be indexed');
+
+ $this->setRolePermissions(DRUPAL_ANONYMOUS_RID, TRUE);
+ $this->checkCommentAccess('Anon user has search permission and access comments permission, comments should be indexed', TRUE);
+
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('admin/people/permissions');
+
+ // Disable search access for authenticated user to test admin user.
+ $this->setRolePermissions(DRUPAL_AUTHENTICATED_RID, FALSE, FALSE);
+
+ $this->setRolePermissions($this->admin_role);
+ $this->checkCommentAccess('Admin user has search permission but no access comments permission, comments should not be indexed');
+
+ $this->setRolePermissions($this->admin_role, TRUE);
+ $this->checkCommentAccess('Admin user has search permission and access comments permission, comments should be indexed', TRUE);
+
+ $this->setRolePermissions(DRUPAL_AUTHENTICATED_RID);
+ $this->checkCommentAccess('Authenticated user has search permission but no access comments permission, comments should not be indexed');
+
+ $this->setRolePermissions(DRUPAL_AUTHENTICATED_RID, TRUE);
+ $this->checkCommentAccess('Authenticated user has search permission and access comments permission, comments should be indexed', TRUE);
+
+ // Verify that access comments permission is inherited from the
+ // authenticated role.
+ $this->setRolePermissions(DRUPAL_AUTHENTICATED_RID, TRUE, FALSE);
+ $this->setRolePermissions($this->admin_role);
+ $this->checkCommentAccess('Admin user has search permission and no access comments permission, but comments should be indexed because admin user inherits authenticated user\'s permission to access comments', TRUE);
+
+ // Verify that search content permission is inherited from the authenticated
+ // role.
+ $this->setRolePermissions(DRUPAL_AUTHENTICATED_RID, TRUE, TRUE);
+ $this->setRolePermissions($this->admin_role, TRUE, FALSE);
+ $this->checkCommentAccess('Admin user has access comments permission and no search permission, but comments should be indexed because admin user inherits authenticated user\'s permission to search', TRUE);
+
+ }
+
+ /**
+ * Set permissions for role.
+ */
+ function setRolePermissions($rid, $access_comments = FALSE, $search_content = TRUE) {
+ $permissions = array(
+ 'access comments' => $access_comments,
+ 'search content' => $search_content,
+ );
+ user_role_change_permissions($rid, $permissions);
+ }
+
+ /**
+ * Update search index and search for comment.
+ */
+ function checkCommentAccess($message, $assume_access = FALSE) {
+ // Invoke search index update.
+ search_touch_node($this->node->nid);
+ $this->cronRun();
+
+ // Search for the comment subject.
+ $edit = array(
+ 'search_block_form' => "'" . $this->comment_subject . "'",
+ );
+ $this->drupalPost('', $edit, t('Search'));
+ $method = $assume_access ? 'assertText' : 'assertNoText';
+ $verb = $assume_access ? 'found' : 'not found';
+ $this->{$method}($this->node->title, "Node $verb in search results: " . $message);
+ $this->{$method}($this->comment_subject, "Comment subject $verb in search results: " . $message);
+ }
+
+ /**
+ * Verify that 'add new comment' does not appear in search results or index.
+ */
+ function testAddNewComment() {
+ // Create a node with a short body.
+ $settings = array(
+ 'type' => 'article',
+ 'title' => 'short title',
+ 'body' => array(LANGUAGE_NONE => array(array('value' => 'short body text'))),
+ );
+
+ $user = $this->drupalCreateUser(array('search content', 'create article content', 'access content'));
+ $this->drupalLogin($user);
+
+ $node = $this->drupalCreateNode($settings);
+ // Verify that if you view the node on its own page, 'add new comment'
+ // is there.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText(t('Add new comment'), t('Add new comment appears on node page'));
+
+ // Run cron to index this page.
+ $this->drupalLogout();
+ $this->cronRun();
+
+ // Search for 'comment'. Should be no results.
+ $this->drupalLogin($user);
+ $this->drupalPost('search/node', array('keys' => 'comment'), t('Search'));
+ $this->assertText(t('Your search yielded no results'), t('No results searching for the word comment'));
+
+ // Search for the node title. Should be found, and 'Add new comment' should
+ // not be part of the search snippet.
+ $this->drupalPost('search/node', array('keys' => 'short'), t('Search'));
+ $this->assertText($node->title, t('Search for keyword worked'));
+ $this->assertNoText(t('Add new comment'), t('Add new comment does not appear on search results page'));
+ }
+
+}
+
+/**
+ * Tests search_expression_insert() and search_expression_extract().
+ *
+ * @see http://drupal.org/node/419388 (issue)
+ */
+class SearchExpressionInsertExtractTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search expression insert/extract',
+ 'description' => 'Tests the functions search_expression_insert() and search_expression_extract()',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ drupal_load('module', 'search');
+ parent::setUp();
+ }
+
+ /**
+ * Tests search_expression_insert() and search_expression_extract().
+ */
+ function testInsertExtract() {
+ $base_expression = "mykeyword";
+ // Build an array of option, value, what should be in the expression, what
+ // should be retrieved from expression.
+ $cases = array(
+ array('foo', 'bar', 'foo:bar', 'bar'), // Normal case.
+ array('foo', NULL, '', NULL), // Empty value: shouldn't insert.
+ array('foo', ' ', 'foo:', ''), // Space as value: should insert but retrieve empty string.
+ array('foo', '', 'foo:', ''), // Empty string as value: should insert but retrieve empty string.
+ array('foo', '0', 'foo:0', '0'), // String zero as value: should insert.
+ array('foo', 0, 'foo:0', '0'), // Numeric zero as value: should insert.
+ );
+
+ foreach ($cases as $index => $case) {
+ $after_insert = search_expression_insert($base_expression, $case[0], $case[1]);
+ if (empty($case[2])) {
+ $this->assertEqual($after_insert, $base_expression, "Empty insert does not change expression in case $index");
+ }
+ else {
+ $this->assertEqual($after_insert, $base_expression . ' ' . $case[2], "Insert added correct expression for case $index");
+ }
+
+ $retrieved = search_expression_extract($after_insert, $case[0]);
+ if (!isset($case[3])) {
+ $this->assertFalse(isset($retrieved), "Empty retrieval results in unset value in case $index");
+ }
+ else {
+ $this->assertEqual($retrieved, $case[3], "Value is retrieved for case $index");
+ }
+
+ $after_clear = search_expression_insert($after_insert, $case[0]);
+ $this->assertEqual(trim($after_clear), $base_expression, "After clearing, base expression is restored for case $index");
+
+ $cleared = search_expression_extract($after_clear, $case[0]);
+ $this->assertFalse(isset($cleared), "After clearing, value could not be retrieved for case $index");
+ }
+ }
+}
+
+/**
+ * Tests that comment count display toggles properly on comment status of node
+ *
+ * Issue 537278
+ *
+ * - Nodes with comment status set to Open should always how comment counts
+ * - Nodes with comment status set to Closed should show comment counts
+ * only when there are comments
+ * - Nodes with comment status set to Hidden should never show comment counts
+ */
+class SearchCommentCountToggleTestCase extends DrupalWebTestCase {
+ protected $searching_user;
+ protected $searchable_nodes;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Comment count toggle',
+ 'description' => 'Verify that comment count display toggles properly on comment status of node.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+
+ // Create searching user.
+ $this->searching_user = $this->drupalCreateUser(array('search content', 'access content', 'access comments', 'skip comment approval'));
+
+ // Create initial nodes.
+ $node_params = array('type' => 'article', 'body' => array(LANGUAGE_NONE => array(array('value' => 'SearchCommentToggleTestCase'))));
+
+ $this->searchable_nodes['1 comment'] = $this->drupalCreateNode($node_params);
+ $this->searchable_nodes['0 comments'] = $this->drupalCreateNode($node_params);
+
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->searching_user);
+
+ // Create a comment array
+ $edit_comment = array();
+ $edit_comment['subject'] = $this->randomName();
+ $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][value]'] = $this->randomName();
+ $filtered_html_format_id = 'filtered_html';
+ $edit_comment['comment_body[' . LANGUAGE_NONE . '][0][format]'] = $filtered_html_format_id;
+
+ // Post comment to the test node with comment
+ $this->drupalPost('comment/reply/' . $this->searchable_nodes['1 comment']->nid, $edit_comment, t('Save'));
+
+ // First update the index. This does the initial processing.
+ node_update_index();
+
+ // Then, run the shutdown function. Testing is a unique case where indexing
+ // and searching has to happen in the same request, so running the shutdown
+ // function manually is needed to finish the indexing process.
+ search_update_totals();
+ }
+
+ /**
+ * Verify that comment count display toggles properly on comment status of node
+ */
+ function testSearchCommentCountToggle() {
+ // Search for the nodes by string in the node body.
+ $edit = array(
+ 'search_block_form' => "'SearchCommentToggleTestCase'",
+ );
+
+ // Test comment count display for nodes with comment status set to Open
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertText(t('0 comments'), t('Empty comment count displays for nodes with comment status set to Open'));
+ $this->assertText(t('1 comment'), t('Non-empty comment count displays for nodes with comment status set to Open'));
+
+ // Test comment count display for nodes with comment status set to Closed
+ $this->searchable_nodes['0 comments']->comment = COMMENT_NODE_CLOSED;
+ node_save($this->searchable_nodes['0 comments']);
+ $this->searchable_nodes['1 comment']->comment = COMMENT_NODE_CLOSED;
+ node_save($this->searchable_nodes['1 comment']);
+
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertNoText(t('0 comments'), t('Empty comment count does not display for nodes with comment status set to Closed'));
+ $this->assertText(t('1 comment'), t('Non-empty comment count displays for nodes with comment status set to Closed'));
+
+ // Test comment count display for nodes with comment status set to Hidden
+ $this->searchable_nodes['0 comments']->comment = COMMENT_NODE_HIDDEN;
+ node_save($this->searchable_nodes['0 comments']);
+ $this->searchable_nodes['1 comment']->comment = COMMENT_NODE_HIDDEN;
+ node_save($this->searchable_nodes['1 comment']);
+
+ $this->drupalPost('', $edit, t('Search'));
+ $this->assertNoText(t('0 comments'), t('Empty comment count does not display for nodes with comment status set to Hidden'));
+ $this->assertNoText(t('1 comment'), t('Non-empty comment count does not display for nodes with comment status set to Hidden'));
+ }
+}
+
+/**
+ * Test search_simplify() on every Unicode character, and some other cases.
+ */
+class SearchSimplifyTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search simplify',
+ 'description' => 'Check that the search_simply() function works as intended.',
+ 'group' => 'Search',
+ );
+ }
+
+ /**
+ * Tests that all Unicode characters simplify correctly.
+ */
+ function testSearchSimplifyUnicode() {
+ // This test uses a file that was constructed so that the even lines are
+ // boundary characters, and the odd lines are valid word characters. (It
+ // was generated as a sequence of all the Unicode characters, and then the
+ // boundary chararacters (punctuation, spaces, etc.) were split off into
+ // their own lines). So the even-numbered lines should simplify to nothing,
+ // and the odd-numbered lines we need to split into shorter chunks and
+ // verify that simplification doesn't lose any characters.
+ $input = file_get_contents(DRUPAL_ROOT . '/core/modules/search/tests/UnicodeTest.txt');
+ $basestrings = explode(chr(10), $input);
+ $strings = array();
+ foreach ($basestrings as $key => $string) {
+ if ($key %2) {
+ // Even line - should simplify down to a space.
+ $simplified = search_simplify($string);
+ $this->assertIdentical($simplified, ' ', "Line $key is excluded from the index");
+ }
+ else {
+ // Odd line, should be word characters.
+ // Split this into 30-character chunks, so we don't run into limits
+ // of truncation in search_simplify().
+ $start = 0;
+ while ($start < drupal_strlen($string)) {
+ $newstr = drupal_substr($string, $start, 30);
+ // Special case: leading zeros are removed from numeric strings,
+ // and there's one string in this file that is numbers starting with
+ // zero, so prepend a 1 on that string.
+ if (preg_match('/^[0-9]+$/', $newstr)) {
+ $newstr = '1' . $newstr;
+ }
+ $strings[] = $newstr;
+ $start += 30;
+ }
+ }
+ }
+ foreach ($strings as $key => $string) {
+ $simplified = search_simplify($string);
+ $this->assertTrue(drupal_strlen($simplified) >= drupal_strlen($string), "Nothing is removed from string $key.");
+ }
+
+ // Test the low-numbered ASCII control characters separately. They are not
+ // in the text file because they are problematic for diff, especially \0.
+ $string = '';
+ for ($i = 0; $i < 32; $i++) {
+ $string .= chr($i);
+ }
+ $this->assertIdentical(' ', search_simplify($string), t('Search simplify works for ASCII control characters.'));
+ }
+
+ /**
+ * Tests that search_simplify() does the right thing with punctuation.
+ */
+ function testSearchSimplifyPunctuation() {
+ $cases = array(
+ array('20.03/94-28,876', '20039428876', 'Punctuation removed from numbers'),
+ array('great...drupal--module', 'great drupal module', 'Multiple dot and dashes are word boundaries'),
+ array('very_great-drupal.module', 'verygreatdrupalmodule', 'Single dot, dash, underscore are removed'),
+ array('regular,punctuation;word', 'regular punctuation word', 'Punctuation is a word boundary'),
+ );
+
+ foreach ($cases as $case) {
+ $out = trim(search_simplify($case[0]));
+ $this->assertEqual($out, $case[1], $case[2]);
+ }
+ }
+}
+
+
+/**
+ * Tests keywords and conditions.
+ */
+class SearchKeywordsConditions extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Keywords and conditions',
+ 'description' => 'Verify the search pulls in keywords and extra conditions.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search', 'search_extra_type');
+ // Create searching user.
+ $this->searching_user = $this->drupalCreateUser(array('search content', 'access content', 'access comments', 'skip comment approval'));
+ // Login with sufficient privileges.
+ $this->drupalLogin($this->searching_user);
+ // Test with all search modules enabled.
+ variable_set('search_active_modules', array('node' => 'node', 'user' => 'user', 'search_extra_type' => 'search_extra_type'));
+ menu_rebuild();
+ }
+
+ /**
+ * Verify the kewords are captured and conditions respected.
+ */
+ function testSearchKeyswordsConditions() {
+ // No keys, not conditions - no results.
+ $this->drupalGet('search/dummy_path');
+ $this->assertNoText('Dummy search snippet to display');
+ // With keys - get results.
+ $keys = 'bike shed ' . $this->randomName();
+ $this->drupalGet("search/dummy_path/{$keys}");
+ $this->assertText("Dummy search snippet to display. Keywords: {$keys}");
+ $keys = 'blue drop ' . $this->randomName();
+ $this->drupalGet("search/dummy_path", array('query' => array('keys' => $keys)));
+ $this->assertText("Dummy search snippet to display. Keywords: {$keys}");
+ // Add some conditions and keys.
+ $keys = 'moving drop ' . $this->randomName();
+ $this->drupalGet("search/dummy_path/bike", array('query' => array('search_conditions' => $keys)));
+ $this->assertText("Dummy search snippet to display.");
+ $this->assertRaw(print_r(array('search_conditions' => $keys), TRUE));
+ // Add some conditions and no keys.
+ $keys = 'drop kick ' . $this->randomName();
+ $this->drupalGet("search/dummy_path", array('query' => array('search_conditions' => $keys)));
+ $this->assertText("Dummy search snippet to display.");
+ $this->assertRaw(print_r(array('search_conditions' => $keys), TRUE));
+ }
+}
+
+/**
+ * Tests that numbers can be searched.
+ */
+class SearchNumbersTestCase extends DrupalWebTestCase {
+ protected $test_user;
+ protected $numbers;
+ protected $nodes;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search numbers',
+ 'description' => 'Check that numbers can be searched',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+
+ $this->test_user = $this->drupalCreateUser(array('search content', 'access content', 'administer nodes', 'access site reports'));
+ $this->drupalLogin($this->test_user);
+
+ // Create content with various numbers in it.
+ // Note: 50 characters is the current limit of the search index's word
+ // field.
+ $this->numbers = array(
+ 'ISBN' => '978-0446365383',
+ 'UPC' => '036000 291452',
+ 'EAN bar code' => '5901234123457',
+ 'negative' => '-123456.7890',
+ 'quoted negative' => '"-123456.7890"',
+ 'leading zero' => '0777777777',
+ 'tiny' => '111',
+ 'small' => '22222222222222',
+ 'medium' => '333333333333333333333333333',
+ 'large' => '444444444444444444444444444444444444444',
+ 'gigantic' => '5555555555555555555555555555555555555555555555555',
+ 'over fifty characters' => '666666666666666666666666666666666666666666666666666666666666',
+ 'date', '01/02/2009',
+ 'commas', '987,654,321',
+ );
+
+ foreach ($this->numbers as $doc => $num) {
+ $info = array(
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $num))),
+ 'type' => 'page',
+ 'language' => LANGUAGE_NONE,
+ 'title' => $doc . ' number',
+ );
+ $this->nodes[$doc] = $this->drupalCreateNode($info);
+ }
+
+ // Run cron to ensure the content is indexed.
+ $this->cronRun();
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertText(t('Cron run completed'), 'Log shows cron run completed');
+ }
+
+ /**
+ * Tests that all the numbers can be searched.
+ */
+ function testNumberSearching() {
+ $types = array_keys($this->numbers);
+
+ foreach ($types as $type) {
+ $number = $this->numbers[$type];
+ // If the number is negative, remove the - sign, because - indicates
+ // "not keyword" when searching.
+ $number = ltrim($number, '-');
+ $node = $this->nodes[$type];
+
+ // Verify that the node title does not appear on the search page
+ // with a dummy search.
+ $this->drupalPost('search/node',
+ array('keys' => 'foo'),
+ t('Search'));
+ $this->assertNoText($node->title, $type . ': node title not shown in dummy search');
+
+ // Verify that the node title does appear as a link on the search page
+ // when searching for the number.
+ $this->drupalPost('search/node',
+ array('keys' => $number),
+ t('Search'));
+ $this->assertText($node->title, $type . ': node title shown (search found the node) in search for number ' . $number);
+ }
+ }
+}
+
+/**
+ * Tests that numbers can be searched, with more complex matching.
+ */
+class SearchNumberMatchingTestCase extends DrupalWebTestCase {
+ protected $test_user;
+ protected $numbers;
+ protected $nodes;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search number matching',
+ 'description' => 'Check that numbers can be searched with more complex matching',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+
+ $this->test_user = $this->drupalCreateUser(array('search content', 'access content', 'administer nodes', 'access site reports'));
+ $this->drupalLogin($this->test_user);
+
+ // Define a group of numbers that should all match each other --
+ // numbers with internal punctuation should match each other, as well
+ // as numbers with and without leading zeros and leading/trailing
+ // . and -.
+ $this->numbers = array(
+ '123456789',
+ '12/34/56789',
+ '12.3456789',
+ '12-34-56789',
+ '123,456,789',
+ '-123456789',
+ '0123456789',
+ );
+
+ foreach ($this->numbers as $num) {
+ $info = array(
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $num))),
+ 'type' => 'page',
+ 'language' => LANGUAGE_NONE,
+ );
+ $this->nodes[] = $this->drupalCreateNode($info);
+ }
+
+ // Run cron to ensure the content is indexed.
+ $this->cronRun();
+ $this->drupalGet('admin/reports/dblog');
+ $this->assertText(t('Cron run completed'), 'Log shows cron run completed');
+ }
+
+ /**
+ * Tests that all the numbers can be searched.
+ */
+ function testNumberSearching() {
+ for ($i = 0; $i < count($this->numbers); $i++) {
+ $node = $this->nodes[$i];
+
+ // Verify that the node title does not appear on the search page
+ // with a dummy search.
+ $this->drupalPost('search/node',
+ array('keys' => 'foo'),
+ t('Search'));
+ $this->assertNoText($node->title, $i . ': node title not shown in dummy search');
+
+ // Now verify that we can find node i by searching for any of the
+ // numbers.
+ for ($j = 0; $j < count($this->numbers); $j++) {
+ $number = $this->numbers[$j];
+ // If the number is negative, remove the - sign, because - indicates
+ // "not keyword" when searching.
+ $number = ltrim($number, '-');
+
+ $this->drupalPost('search/node',
+ array('keys' => $number),
+ t('Search'));
+ $this->assertText($node->title, $i . ': node title shown (search found the node) in search for number ' . $number);
+ }
+ }
+
+ }
+}
+
+/**
+ * Test config page.
+ */
+class SearchConfigSettingsForm extends DrupalWebTestCase {
+ public $search_user;
+ public $search_node;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Config settings form',
+ 'description' => 'Verify the search config settings form.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search', 'search_extra_type');
+
+ // Login as a user that can create and search content.
+ $this->search_user = $this->drupalCreateUser(array('search content', 'administer search', 'administer nodes', 'bypass node access', 'access user profiles', 'administer users', 'administer blocks'));
+ $this->drupalLogin($this->search_user);
+
+ // Add a single piece of content and index it.
+ $node = $this->drupalCreateNode();
+ $this->search_node = $node;
+ // Link the node to itself to test that it's only indexed once. The content
+ // also needs the word "pizza" so we can use it as the search keyword.
+ $langcode = LANGUAGE_NONE;
+ $body_key = "body[$langcode][0][value]";
+ $edit[$body_key] = l($node->title, 'node/' . $node->nid) . ' pizza sandwich';
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ node_update_index();
+ search_update_totals();
+
+ // Enable the search block.
+ $edit = array();
+ $edit['blocks[search_form][region]'] = 'content';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ }
+
+ /**
+ * Verify the search settings form.
+ */
+ function testSearchSettingsPage() {
+
+ // Test that the settings form displays the correct count of items left to index.
+ $this->drupalGet('admin/config/search/settings');
+ $this->assertText(t('There are @count items left to index.', array('@count' => 0)));
+
+ // Test the re-index button.
+ $this->drupalPost('admin/config/search/settings', array(), t('Re-index site'));
+ $this->assertText(t('Are you sure you want to re-index the site'));
+ $this->drupalPost('admin/config/search/settings/reindex', array(), t('Re-index site'));
+ $this->assertText(t('The index will be rebuilt'));
+ $this->drupalGet('admin/config/search/settings');
+ $this->assertText(t('There is 1 item left to index.'));
+ }
+
+ /**
+ * Verify that you can disable individual search modules.
+ */
+ function testSearchModuleDisabling() {
+ // Array of search modules to test: 'path' is the search path, 'title' is
+ // the tab title, 'keys' are the keywords to search for, and 'text' is
+ // the text to assert is on the results page.
+ $module_info = array(
+ 'node' => array(
+ 'path' => 'node',
+ 'title' => 'Content',
+ 'keys' => 'pizza',
+ 'text' => $this->search_node->title,
+ ),
+ 'user' => array(
+ 'path' => 'user',
+ 'title' => 'User',
+ 'keys' => $this->search_user->name,
+ 'text' => $this->search_user->mail,
+ ),
+ 'search_extra_type' => array(
+ 'path' => 'dummy_path',
+ 'title' => 'Dummy search type',
+ 'keys' => 'foo',
+ 'text' => 'Dummy search snippet to display',
+ ),
+ );
+ $modules = array_keys($module_info);
+
+ // Test each module if it's enabled as the only search module.
+ foreach ($modules as $module) {
+ // Enable the one module and disable other ones.
+ $info = $module_info[$module];
+ $edit = array();
+ foreach ($modules as $other) {
+ $edit['search_active_modules[' . $other . ']'] = (($other == $module) ? $module : FALSE);
+ }
+ $edit['search_default_module'] = $module;
+ $this->drupalPost('admin/config/search/settings', $edit, t('Save configuration'));
+
+ // Run a search from the correct search URL.
+ $this->drupalGet('search/' . $info['path'] . '/' . $info['keys']);
+ $this->assertNoText('no results', $info['title'] . ' search found results');
+ $this->assertText($info['text'], 'Correct search text found');
+
+ // Verify that other module search tab titles are not visible.
+ foreach ($modules as $other) {
+ if ($other != $module) {
+ $title = $module_info[$other]['title'];
+ $this->assertNoText($title, $title . ' search tab is not shown');
+ }
+ }
+
+ // Run a search from the search block on the node page. Verify you get
+ // to this module's search results page.
+ $terms = array('search_block_form' => $info['keys']);
+ $this->drupalPost('node', $terms, t('Search'));
+ $this->assertEqual(
+ $this->getURL(),
+ url('search/' . $info['path'] . '/' . $info['keys'], array('absolute' => TRUE)),
+ 'Block redirected to right search page');
+
+ // Try an invalid search path. Should redirect to our active module.
+ $this->drupalGet('search/not_a_module_path');
+ $this->assertEqual(
+ $this->getURL(),
+ url('search/' . $info['path'], array('absolute' => TRUE)),
+ 'Invalid search path redirected to default search page');
+ }
+
+ // Test with all search modules enabled. When you go to the search
+ // page or run search, all modules should be shown.
+ $edit = array();
+ foreach ($modules as $module) {
+ $edit['search_active_modules[' . $module . ']'] = $module;
+ }
+ $edit['search_default_module'] = 'node';
+
+ $this->drupalPost('admin/config/search/settings', $edit, t('Save configuration'));
+
+ foreach (array('search/node/pizza', 'search/node') as $path) {
+ $this->drupalGet($path);
+ foreach ($modules as $module) {
+ $title = $module_info[$module]['title'];
+ $this->assertText($title, $title . ' search tab is shown');
+ }
+ }
+ }
+}
+
+/**
+ * Tests the search_excerpt() function.
+ */
+class SearchExcerptTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search excerpt extraction',
+ 'description' => 'Tests that the search_excerpt() function works.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ drupal_load('module', 'search');
+ parent::setUp();
+ }
+
+ /**
+ * Tests search_excerpt() with several simulated search keywords.
+ *
+ * Passes keywords and a sample marked up string, "The quick
+ * brown fox jumps over the lazy dog", and compares it to the
+ * correctly marked up string. The correctly marked up string
+ * contains either highlighted keywords or the original marked
+ * up string if no keywords matched the string.
+ */
+ function testSearchExcerpt() {
+ // Make some text with entities and tags.
+ $text = 'The <strong>quick</strong> <a href="#">brown</a> fox &amp; jumps <h2>over</h2> the lazy dog';
+ // Note: The search_excerpt() function adds some extra spaces -- not
+ // important for HTML formatting. Remove these for comparison.
+ $expected = 'The quick brown fox &amp; jumps over the lazy dog';
+ $result = preg_replace('| +|', ' ', search_excerpt('nothing', $text));
+ $this->assertEqual(preg_replace('| +|', ' ', $result), $expected, 'Entire string is returned when keyword is not found in short string');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('fox', $text));
+ $this->assertEqual($result, 'The quick brown <strong>fox</strong> &amp; jumps over the lazy dog ...', 'Found keyword is highlighted');
+
+ $longtext = str_repeat($text . ' ', 10);
+ $result = preg_replace('| +|', ' ', search_excerpt('nothing', $text));
+ $this->assertTrue(strpos($result, $expected) === 0, 'When keyword is not found in long string, return value starts as expected');
+
+ $entities = str_repeat('k&eacute;sz&iacute;t&eacute;se ', 20);
+ $result = preg_replace('| +|', ' ', search_excerpt('nothing', $entities));
+ $this->assertFalse(strpos($result, '&'), 'Entities are not present in excerpt');
+ $this->assertTrue(strpos($result, 'í') > 0, 'Entities are converted in excerpt');
+ }
+
+ /**
+ * Tests search_excerpt() with search keywords matching simplified words.
+ *
+ * Excerpting should handle keywords that are matched only after going through
+ * search_simplify(). This test passes keywords that match simplified words
+ * and compares them with strings that contain the original unsimplified word.
+ */
+ function testSearchExcerptSimplified() {
+ $lorem1 = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vitae arcu at leo cursus laoreet. Curabitur dui tortor, adipiscing malesuada tempor in, bibendum ac diam. Cras non tellus a libero pellentesque condimentum. What is a Drupalism? Suspendisse ac lacus libero. Ut non est vel nisl faucibus interdum nec sed leo. Pellentesque sem risus, vulputate eu semper eget, auctor in libero.';
+ $lorem2 = 'Ut fermentum est vitae metus convallis scelerisque. Phasellus pellentesque rhoncus tellus, eu dignissim purus posuere id. Quisque eu fringilla ligula. Morbi ullamcorper, lorem et mattis egestas, tortor neque pretium velit, eget eleifend odio turpis eu purus. Donec vitae metus quis leo pretium tincidunt a pulvinar sem. Morbi adipiscing laoreet mauris vel placerat. Nullam elementum, nisl sit amet scelerisque malesuada, dolor nunc hendrerit quam, eu ultrices erat est in orci.';
+
+ // Make some text with some keywords that will get simplified.
+ $text = $lorem1 . ' Number: 123456.7890 Hyphenated: one-two abc,def ' . $lorem2;
+ // Note: The search_excerpt() function adds some extra spaces -- not
+ // important for HTML formatting. Remove these for comparison.
+ $result = preg_replace('| +|', ' ', search_excerpt('123456.7890', $text));
+ $this->assertTrue(strpos($result, 'Number: <strong>123456.7890</strong>') !== FALSE, 'Numeric keyword is highlighted with exact match');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('1234567890', $text));
+ $this->assertTrue(strpos($result, 'Number: <strong>123456.7890</strong>') !== FALSE, 'Numeric keyword is highlighted with simplified match');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('Number 1234567890', $text));
+ $this->assertTrue(strpos($result, '<strong>Number</strong>: <strong>123456.7890</strong>') !== FALSE, 'Punctuated and numeric keyword is highlighted with simplified match');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('"Number 1234567890"', $text));
+ $this->assertTrue(strpos($result, '<strong>Number: 123456.7890</strong>') !== FALSE, 'Phrase with punctuated and numeric keyword is highlighted with simplified match');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('"Hyphenated onetwo"', $text));
+ $this->assertTrue(strpos($result, '<strong>Hyphenated: one-two</strong>') !== FALSE, 'Phrase with punctuated and hyphenated keyword is highlighted with simplified match');
+
+ $result = preg_replace('| +|', ' ', search_excerpt('"abc def"', $text));
+ $this->assertTrue(strpos($result, '<strong>abc,def</strong>') !== FALSE, 'Phrase with keyword simplified into two separate words is highlighted with simplified match');
+ }
+}
+
+/**
+ * Test the CJK tokenizer.
+ */
+class SearchTokenizerTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'CJK tokenizer',
+ 'description' => 'Check that CJK tokenizer works as intended.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search');
+ }
+
+ /**
+ * Verifies that strings of CJK characters are tokenized.
+ *
+ * The search_simplify() function does special things with numbers, symbols,
+ * and punctuation. So we only test that CJK characters that are not in these
+ * character classes are tokenized properly. See PREG_CLASS_CKJ for more
+ * information.
+ */
+ function testTokenizer() {
+ // Set the minimum word size to 1 (to split all CJK characters) and make
+ // sure CJK tokenizing is turned on.
+ variable_set('minimum_word_size', 1);
+ variable_set('overlap_cjk', TRUE);
+ $this->refreshVariables();
+
+ // Create a string of CJK characters from various character ranges in
+ // the Unicode tables.
+
+ // Beginnings of the character ranges.
+ $starts = array(
+ 'CJK unified' => 0x4e00,
+ 'CJK Ext A' => 0x3400,
+ 'CJK Compat' => 0xf900,
+ 'Hangul Jamo' => 0x1100,
+ 'Hangul Ext A' => 0xa960,
+ 'Hangul Ext B' => 0xd7b0,
+ 'Hangul Compat' => 0x3131,
+ 'Half non-punct 1' => 0xff21,
+ 'Half non-punct 2' => 0xff41,
+ 'Half non-punct 3' => 0xff66,
+ 'Hangul Syllables' => 0xac00,
+ 'Hiragana' => 0x3040,
+ 'Katakana' => 0x30a1,
+ 'Katakana Ext' => 0x31f0,
+ 'CJK Reserve 1' => 0x20000,
+ 'CJK Reserve 2' => 0x30000,
+ 'Bomofo' => 0x3100,
+ 'Bomofo Ext' => 0x31a0,
+ 'Lisu' => 0xa4d0,
+ 'Yi' => 0xa000,
+ );
+
+ // Ends of the character ranges.
+ $ends = array(
+ 'CJK unified' => 0x9fcf,
+ 'CJK Ext A' => 0x4dbf,
+ 'CJK Compat' => 0xfaff,
+ 'Hangul Jamo' => 0x11ff,
+ 'Hangul Ext A' => 0xa97f,
+ 'Hangul Ext B' => 0xd7ff,
+ 'Hangul Compat' => 0x318e,
+ 'Half non-punct 1' => 0xff3a,
+ 'Half non-punct 2' => 0xff5a,
+ 'Half non-punct 3' => 0xffdc,
+ 'Hangul Syllables' => 0xd7af,
+ 'Hiragana' => 0x309f,
+ 'Katakana' => 0x30ff,
+ 'Katakana Ext' => 0x31ff,
+ 'CJK Reserve 1' => 0x2fffd,
+ 'CJK Reserve 2' => 0x3fffd,
+ 'Bomofo' => 0x312f,
+ 'Bomofo Ext' => 0x31b7,
+ 'Lisu' => 0xa4fd,
+ 'Yi' => 0xa48f,
+ );
+
+ // Generate characters consisting of starts, midpoints, and ends.
+ $chars = array();
+ $charcodes = array();
+ foreach ($starts as $key => $value) {
+ $charcodes[] = $starts[$key];
+ $chars[] = $this->code2utf($starts[$key]);
+ $mid = round(0.5 * ($starts[$key] + $ends[$key]));
+ $charcodes[] = $mid;
+ $chars[] = $this->code2utf($mid);
+ $charcodes[] = $ends[$key];
+ $chars[] = $this->code2utf($ends[$key]);
+ }
+
+ // Merge into a string and tokenize.
+ $string = implode('', $chars);
+ $out = trim(search_simplify($string));
+ $expected = drupal_strtolower(implode(' ', $chars));
+
+ // Verify that the output matches what we expect.
+ $this->assertEqual($out, $expected, 'CJK tokenizer worked on all supplied CJK characters');
+ }
+
+ /**
+ * Verifies that strings of non-CJK characters are not tokenized.
+ *
+ * This is just a sanity check - it verifies that strings of letters are
+ * not tokenized.
+ */
+ function testNoTokenizer() {
+ // Set the minimum word size to 1 (to split all CJK characters) and make
+ // sure CJK tokenizing is turned on.
+ variable_set('minimum_word_size', 1);
+ variable_set('overlap_cjk', TRUE);
+ $this->refreshVariables();
+
+ $letters = 'abcdefghijklmnopqrstuvwxyz';
+ $out = trim(search_simplify($letters));
+
+ $this->assertEqual($letters, $out, 'Letters are not CJK tokenized');
+ }
+
+ /**
+ * Like PHP chr() function, but for unicode characters.
+ *
+ * chr() only works for ASCII characters up to character 255. This function
+ * converts a number to the corresponding unicode character. Adapted from
+ * functions supplied in comments on several functions on php.net.
+ */
+ function code2utf($num) {
+ if ($num < 128) {
+ return chr($num);
+ }
+
+ if ($num < 2048) {
+ return chr(($num >> 6) + 192) . chr(($num & 63) + 128);
+ }
+
+ if ($num < 65536) {
+ return chr(($num >> 12) + 224) . chr((($num >> 6) & 63) + 128) . chr(($num & 63) + 128);
+ }
+
+ if ($num < 2097152) {
+ return chr(($num >> 18) + 240) . chr((($num >> 12) & 63) + 128) . chr((($num >> 6) & 63) + 128) . chr(($num & 63) + 128);
+ }
+
+ return '';
+ }
+}
+
+/**
+ * Tests that we can embed a form in search results and submit it.
+ */
+class SearchEmbedForm extends DrupalWebTestCase {
+ /**
+ * Node used for testing.
+ */
+ public $node;
+
+ /**
+ * Count of how many times the form has been submitted.
+ */
+ public $submit_count = 0;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Embedded forms',
+ 'description' => 'Verifies that a form embedded in search results works',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search', 'search_embedded_form');
+
+ // Create a user and a node, and update the search index.
+ $test_user = $this->drupalCreateUser(array('access content', 'search content', 'administer nodes'));
+ $this->drupalLogin($test_user);
+
+ $this->node = $this->drupalCreateNode();
+
+ node_update_index();
+ search_update_totals();
+
+ // Set up a dummy initial count of times the form has been submitted.
+ $this->submit_count = 12;
+ variable_set('search_embedded_form_submitted', $this->submit_count);
+ $this->refreshVariables();
+ }
+
+ /**
+ * Tests that the embedded form appears and can be submitted.
+ */
+ function testEmbeddedForm() {
+ // First verify we can submit the form from the module's page.
+ $this->drupalPost('search_embedded_form',
+ array('name' => 'John'),
+ t('Send away'));
+ $this->assertText(t('Test form was submitted'), 'Form message appears');
+ $count = variable_get('search_embedded_form_submitted', 0);
+ $this->assertEqual($this->submit_count + 1, $count, 'Form submission count is correct');
+ $this->submit_count = $count;
+
+ // Now verify that we can see and submit the form from the search results.
+ $this->drupalGet('search/node/' . $this->node->title);
+ $this->assertText(t('Your name'), 'Form is visible');
+ $this->drupalPost('search/node/' . $this->node->title,
+ array('name' => 'John'),
+ t('Send away'));
+ $this->assertText(t('Test form was submitted'), 'Form message appears');
+ $count = variable_get('search_embedded_form_submitted', 0);
+ $this->assertEqual($this->submit_count + 1, $count, 'Form submission count is correct');
+ $this->submit_count = $count;
+
+ // Now verify that if we submit the search form, it doesn't count as
+ // our form being submitted.
+ $this->drupalPost('search',
+ array('keys' => 'foo'),
+ t('Search'));
+ $this->assertNoText(t('Test form was submitted'), 'Form message does not appear');
+ $count = variable_get('search_embedded_form_submitted', 0);
+ $this->assertEqual($this->submit_count, $count, 'Form submission count is correct');
+ $this->submit_count = $count;
+ }
+}
+
+/**
+ * Tests that hook_search_page runs.
+ */
+class SearchPageOverride extends DrupalWebTestCase {
+ public $search_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search page override',
+ 'description' => 'Verify that hook_search_page can override search page display.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search', 'search_extra_type');
+
+ // Login as a user that can create and search content.
+ $this->search_user = $this->drupalCreateUser(array('search content', 'administer search'));
+ $this->drupalLogin($this->search_user);
+
+ // Enable the extra type module for searching.
+ variable_set('search_active_modules', array('node' => 'node', 'user' => 'user', 'search_extra_type' => 'search_extra_type'));
+ menu_rebuild();
+ }
+
+ function testSearchPageHook() {
+ $keys = 'bike shed ' . $this->randomName();
+ $this->drupalGet("search/dummy_path/{$keys}");
+ $this->assertText('Dummy search snippet', 'Dummy search snippet is shown');
+ $this->assertText('Test page text is here', 'Page override is working');
+ }
+}
+
+/**
+ * Test node search with multiple languages.
+ */
+class SearchLanguageTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search language selection',
+ 'description' => 'Tests advanced search with different languages enabled.',
+ 'group' => 'Search',
+ );
+ }
+
+ /**
+ * Implementation setUp().
+ */
+ function setUp() {
+ parent::setUp('search', 'locale');
+
+ // Create and login user.
+ $test_user = $this->drupalCreateUser(array('access content', 'search content', 'use advanced search', 'administer nodes', 'administer languages', 'access administration pages'));
+ $this->drupalLogin($test_user);
+ }
+
+ function testLanguages() {
+ // Check that there are initially no languages displayed.
+ $this->drupalGet('search/node');
+ $this->assertNoText(t('Languages'), t('No languages to choose from.'));
+
+ // Add predefined language.
+ $edit = array('predefined_langcode' => 'fr');
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+ $this->assertText('fr', t('Language added successfully.'));
+
+ // Now we should have languages displayed.
+ $this->drupalGet('search/node');
+ $this->assertText(t('Languages'), t('Languages displayed to choose from.'));
+ $this->assertText(t('English'), t('English is a possible choice.'));
+ $this->assertText(t('French'), t('French is a possible choice.'));
+
+ // Ensure selecting no language does not make the query different.
+ $this->drupalPost('search/node', array(), t('Advanced search'));
+ $this->assertEqual($this->getUrl(), url('search/node/', array('absolute' => TRUE)), t('Correct page redirection, no language filtering.'));
+
+ // Pick French and ensure it is selected.
+ $edit = array('language[fr]' => TRUE);
+ $this->drupalPost('search/node', $edit, t('Advanced search'));
+ $this->assertFieldByXPath('//input[@name="keys"]', 'language:fr', t('Language filter added to query.'));
+
+ // Change the default language and disable English.
+ $path = 'admin/config/regional/language';
+ $this->drupalGet($path);
+ $this->assertFieldChecked('edit-site-default-en', t('English is the default language.'));
+ $edit = array('site_default' => 'fr');
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-site-default-en', t('Default language updated.'));
+ $edit = array('languages[en][enabled]' => FALSE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+ $this->assertNoFieldChecked('edit-languages-en-enabled', t('Language disabled.'));
+
+ // Check that there are again no languages displayed.
+ $this->drupalGet('search/node');
+ $this->assertNoText(t('Languages'), t('No languages to choose from.'));
+ }
+}
+
+/**
+ * Tests node search with node access control.
+ */
+class SearchNodeAccessTest extends DrupalWebTestCase {
+ public $test_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Search and node access',
+ 'description' => 'Tests search functionality with node access control.',
+ 'group' => 'Search',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('search', 'node_access_test');
+ node_access_rebuild();
+
+ // Create a test user and log in.
+ $this->test_user = $this->drupalCreateUser(array('access content', 'search content', 'use advanced search'));
+ $this->drupalLogin($this->test_user);
+ }
+
+ /**
+ * Tests that search returns results with punctuation in the search phrase.
+ */
+ function testPhraseSearchPunctuation() {
+ $node = $this->drupalCreateNode(array('body' => array(LANGUAGE_NONE => array(array('value' => "The bunny's ears were furry.")))));
+
+ // Update the search index.
+ module_invoke_all('update_index');
+ search_update_totals();
+
+ // Refresh variables after the treatment.
+ $this->refreshVariables();
+
+ // Submit a phrase wrapped in double quotes to include the punctuation.
+ $edit = array('keys' => '"bunny\'s"');
+ $this->drupalPost('search/node', $edit, t('Search'));
+ $this->assertText($node->title);
+ }
+}
diff --git a/core/modules/search/tests/UnicodeTest.txt b/core/modules/search/tests/UnicodeTest.txt
new file mode 100644
index 000000000000..af8a65c7ff73
--- /dev/null
+++ b/core/modules/search/tests/UnicodeTest.txt
@@ -0,0 +1,333 @@
+
+ !"#$%&'()*+,-./
+0123456789
+:;<=>?@
+ABCDEFGHIJKLMNOPQRSTUVWXYZ
+[\]^_`
+abcdefghijklmnopqrstuvwxyz
+{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©
+«¬­®¯°±
+²³
+¶·¸
+¹º
+¼½¾
+¿
+ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ
+ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö
+øùúûüýþÿĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħĨĩĪīĬĭĮįİıIJijĴĵĶķĸĹĺĻļĽľĿŀŁłŃńŅņŇňʼnŊŋŌōŎŏŐőŒœŔŕŖŗŘřŚśŜŝŞşŠšŢţŤťŦŧŨũŪūŬŭŮůŰűŲųŴŵŶŷŸŹźŻżŽžſƀƁƂƃƄƅƆƇƈƉƊƋƌƍƎƏƐƑƒƓƔƕƖƗƘƙƚƛƜƝƞƟƠơƢƣƤƥƦƧƨƩƪƫƬƭƮƯưƱƲƳƴƵƶƷƸƹƺƻƼƽƾƿǀǁǂǃDŽDždžLJLjljNJNjnjǍǎǏǐǑǒǓǔǕǖǗǘǙǚǛǜǝǞǟǠǡǢǣǤǥǦǧǨǩǪǫǬǭǮǯǰDZDzdzǴǵǶǷǸǹǺǻǼǽǾǿȀȁȂȃȄȅȆȇȈȉȊȋȌȍȎȏȐȑȒȓȔȕȖȗȘșȚțȜȝȞȟȠȡȢȣȤȥȦȧȨȩȪȫȬȭȮȯȰȱȲȳȴȵȶȷȸȹȺȻȼȽȾȿɀɁɂɃɄɅɆɇɈɉɊɋɌɍɎɏɐɑɒɓɔɕɖɗɘəɚɛɜɝɞɟɠɡɢɣɤɥɦɧɨɩɪɫɬɭɮɯɰɱɲɳɴɵɶɷɸɹɺɻɼɽɾɿʀʁʂʃʄʅʆʇʈʉʊʋʌʍʎʏʐʑʒʓʔʕʖʗʘʙʚʛʜʝʞʟʠʡʢʣʤʥʦʧʨʩʪʫʬʭʮʯʰʱʲʳʴʵʶʷʸʹʺʻʼʽʾʿˀˁ
+˂˃˄˅
+ˆˇˈˉˊˋˌˍˎˏːˑ
+˒˓˔˕˖˗˘˙˚˛˜˝˞˟
+ˠˡˢˣˤ
+˥˦˧˨˩˪˫
+˯˰˱˲˳˴˵˶˷˸˹˺˻˼˽˾˿
+̴̵̶̷̸̡̢̧̨̛̖̗̘̙̜̝̞̟̠̣̤̥̦̩̪̫̬̭̮̯̰̱̲̳̹̺̻̼͇͈͉͍͎̀́̂̃̄̅̆̇̈̉̊̋̌̍̎̏̐̑̒̓̔̽̾̿̀́͂̓̈́͆͊͋͌̕̚ͅ͏͓͔͕͖͙͚͐͑͒͗͛ͣͤͥͦͧͨͩͪͫͬͭͮͯ͘͜͟͢͝͞͠͡ͰͱͲͳʹ
+Ͷͷͺͻͼͽ
+;΄΅
+ΈΉΊΌΎΏΐΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩΪΫάέήίΰαβγδεζηθικλμνξοπρςστυφχψωϊϋόύώϏϐϑϒϓϔϕϖϗϘϙϚϛϜϝϞϟϠϡϢϣϤϥϦϧϨϩϪϫϬϭϮϯϰϱϲϳϴϵ
+ϷϸϹϺϻϼϽϾϿЀЁЂЃЄЅІЇЈЉЊЋЌЍЎЏАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяѐёђѓєѕіїјљњћќѝўџѠѡѢѣѤѥѦѧѨѩѪѫѬѭѮѯѰѱѲѳѴѵѶѷѸѹѺѻѼѽѾѿҀҁ
+҃҄҅҆҇҈҉ҊҋҌҍҎҏҐґҒғҔҕҖҗҘҙҚқҜҝҞҟҠҡҢңҤҥҦҧҨҩҪҫҬҭҮүҰұҲҳҴҵҶҷҸҹҺһҼҽҾҿӀӁӂӃӄӅӆӇӈӉӊӋӌӍӎӏӐӑӒӓӔӕӖӗӘәӚӛӜӝӞӟӠӡӢӣӤӥӦӧӨөӪӫӬӭӮӯӰӱӲӳӴӵӶӷӸӹӺӻӼӽӾӿԀԁԂԃԄԅԆԇԈԉԊԋԌԍԎԏԐԑԒԓԔԕԖԗԘԙԚԛԜԝԞԟԠԡԢԣԤԥԱԲԳԴԵԶԷԸԹԺԻԼԽԾԿՀՁՂՃՄՅՆՇՈՉՊՋՌՍՎՏՐՑՒՓՔՕՖՙ
+՚՛՜՝՞՟
+աբգդեզէըթժիլխծկհձղճմյնշոչպջռսվտրցւփքօֆև
+։֊
+ְֱֲֳִֵֶַָֹֺֻּֽ֑֖֛֢֣֤֥֦֧֪֚֭֮֒֓֔֕֗֘֙֜֝֞֟֠֡֨֩֫֬֯
+ֿ
+ׁׂ
+ׅׄ
+ׇאבגדהוזחטיךכלםמןנסעףפץצקרשתװױײ
+׳״؀؁؂؃؆؇؈؉؊؋،؍؎؏
+ؘؙؚؐؑؒؓؔؕؖؗ
+؛؞؟
+ءآأؤإئابةتثجحخدذرزسشصضطظعغػؼؽؾؿـفقكلمنهوىيًٌٍَُِّْٕٖٜٓٔٗ٘ٙٚٛٝٞ٠١٢٣٤٥٦٧٨٩
+٪٫٬٭
+ٮٯٰٱٲٳٴٵٶٷٸٹٺٻټٽپٿڀځڂڃڄڅچڇڈډڊڋڌڍڎڏڐڑڒړڔڕږڗژڙښڛڜڝڞڟڠڡڢڣڤڥڦڧڨکڪګڬڭڮگڰڱڲڳڴڵڶڷڸڹںڻڼڽھڿۀہۂۃۄۅۆۇۈۉۊۋیۍێۏېۑےۓ
+ەۖۗۘۙۚۛۜ
+۞ۣ۟۠ۡۢۤۥۦۧۨ
+۪ۭ۫۬ۮۯ۰۱۲۳۴۵۶۷۸۹ۺۻۼ
+۽۾
+ۿ
+܀܁܂܃܄܅܆܇܈܉܊܋܌܍܏
+ܐܑܒܓܔܕܖܗܘܙܚܛܜܝܞܟܠܡܢܣܤܥܦܧܨܩܪܫܬܭܮܯܱܴܷܸܹܻܼܾ݂݄݆݈ܰܲܳܵܶܺܽܿ݀݁݃݅݇݉݊ݍݎݏݐݑݒݓݔݕݖݗݘݙݚݛݜݝݞݟݠݡݢݣݤݥݦݧݨݩݪݫݬݭݮݯݰݱݲݳݴݵݶݷݸݹݺݻݼݽݾݿހށނރބޅކއވމފދތލގޏސޑޒޓޔޕޖޗޘޙޚޛޜޝޞޟޠޡޢޣޤޥަާިީުޫެޭޮޯްޱ߀߁߂߃߄߅߆߇߈߉ߊߋߌߍߎߏߐߑߒߓߔߕߖߗߘߙߚߛߜߝߞߟߠߡߢߣߤߥߦߧߨߩߪ߲߫߬߭߮߯߰߱߳ߴߵ
+߶߷߸߹
+ߺࠀࠁࠂࠃࠄࠅࠆࠇࠈࠉࠊࠋࠌࠍࠎࠏࠐࠑࠒࠓࠔࠕࠖࠗ࠘࠙ࠚࠛࠜࠝࠞࠟࠠࠡࠢࠣࠤࠥࠦࠧࠨࠩࠪࠫࠬ࠭
+࠰࠱࠲࠳࠴࠵࠶࠷࠸࠹࠺࠻࠼࠽࠾
+ऀँंःऄअआइईउऊऋऌऍऎएऐऑऒओऔकखगघङचछजझञटठडढणतथदधनऩपफबभमयरऱलळऴवशषसह़ऽािीुूृॄॅॆेैॉॊोौ्ॎॐ॒॑॓॔ॕक़ख़ग़ज़ड़ढ़फ़य़ॠॡॢॣ
+।॥
+०१२३४५६७८९
+॰
+ॱॲॹॺॻॼॽॾॿঁংঃঅআইঈউঊঋঌএঐওঔকখগঘঙচছজঝঞটঠডঢণতথদধনপফবভমযরলশষসহ়ঽািীুূৃৄেৈোৌ্ৎৗড়ঢ়য়ৠৡৢৣ০১২৩৪৫৬৭৮৯ৰৱ
+৲৳
+৴৵৶৷৸৹
+৺৻
+ਁਂਃਅਆਇਈਉਊਏਐਓਔਕਖਗਘਙਚਛਜਝਞਟਠਡਢਣਤਥਦਧਨਪਫਬਭਮਯਰਲਲ਼ਵਸ਼ਸਹ਼ਾਿੀੁੂੇੈੋੌ੍ੑਖ਼ਗ਼ਜ਼ੜਫ਼੦੧੨੩੪੫੬੭੮੯ੰੱੲੳੴੵઁંઃઅઆઇઈઉઊઋઌઍએઐઑઓઔકખગઘઙચછજઝઞટઠડઢણતથદધનપફબભમયરલળવશષસહ઼ઽાિીુૂૃૄૅેૈૉોૌ્ૐૠૡૢૣ૦૧૨૩૪૫૬૭૮૯
+૱
+ଁଂଃଅଆଇଈଉଊଋଌଏଐଓଔକଖଗଘଙଚଛଜଝଞଟଠଡଢଣତଥଦଧନପଫବଭମଯରଲଳଵଶଷସହ଼ଽାିୀୁୂୃୄେୈୋୌ୍ୖୗଡ଼ଢ଼ୟୠୡୢୣ୦୧୨୩୪୫୬୭୮୯
+୰
+ୱஂஃஅஆஇஈஉஊஎஏஐஒஓஔகஙசஜஞடணதநனபமயரறலளழவஶஷஸஹாிீுூெேைொோௌ்ௐௗ௦௧௨௩௪௫௬௭௮௯௰௱௲
+௳௴௵௶௷௸௹௺
+ఁంఃఅఆఇఈఉఊఋఌఎఏఐఒఓఔకఖగఘఙచఛజఝఞటఠడఢణతథదధనపఫబభమయరఱలళవశషసహఽాిీుూృౄెేైొోౌ్ౕౖౘౙౠౡౢౣ౦౧౨౩౪౫౬౭౮౯౸౹౺౻౼౽౾
+౿
+ಂಃಅಆಇಈಉಊಋಌಎಏಐಒಓಔಕಖಗಘಙಚಛಜಝಞಟಠಡಢಣತಥದಧನಪಫಬಭಮಯರಱಲಳವಶಷಸಹ಼ಽಾಿೀುೂೃೄೆೇೈೊೋೌ್ೕೖೞೠೡೢೣ೦೧೨೩೪೫೬೭೮೯
+ೱೲ
+ംഃഅആഇഈഉഊഋഌഎഏഐഒഓഔകഖഗഘങചഛജഝഞടഠഡഢണതഥദധനപഫബഭമയരറലളഴവശഷസഹഽാിീുൂൃൄെേൈൊോൌ്ൗൠൡൢൣ൦൧൨൩൪൫൬൭൮൯൰൱൲൳൴൵
+൹
+ൺൻർൽൾൿංඃඅආඇඈඉඊඋඌඍඎඏඐඑඒඓඔඕඖකඛගඝඞඟචඡජඣඤඥඦටඨඩඪණඬතථදධනඳපඵබභමඹයරලවශෂසහළෆ්ාැෑිීුූෘෙේෛොෝෞෟෲෳ
+෴
+กขฃคฅฆงจฉชซฌญฎฏฐฑฒณดตถทธนบปผฝพฟภมยรฤลฦวศษสหฬอฮฯะัาำิีึืฺุู
+฿
+เแโใไๅๆ็่้๊๋์ํ๎
+๏
+๐๑๒๓๔๕๖๗๘๙
+๚๛
+ກຂຄງຈຊຍດຕຖທນບປຜຝພຟມຢຣລວສຫອຮຯະັາຳິີຶືຸູົຼຽເແໂໃໄໆ່້໊໋໌ໍ໐໑໒໓໔໕໖໗໘໙ໜໝༀ
+༁༂༃༄༅༆༇༈༉༊་༌།༎༏༐༑༒༓༔༕༖༗
+༘༙
+༚༛༜༝༞༟
+༠༡༢༣༤༥༦༧༨༩༪༫༬༭༮༯༰༱༲༳
+༴
+༵
+༶
+༷
+༸
+༹
+༺༻༼༽
+༾༿ཀཁགགྷངཅཆཇཉཊཋཌཌྷཎཏཐདདྷནཔཕབབྷམཙཚཛཛྷཝཞཟའཡརལཤཥསཧཨཀྵཪཫཬཱཱཱིིུུྲྀཷླྀཹེཻོཽཾཿ྄ཱྀྀྂྃ
+྅
+྆྇ྈྉྊྋྐྑྒྒྷྔྕྖྗྙྚྛྜྜྷྞྟྠྡྡྷྣྤྥྦྦྷྨྩྪྫྫྷྭྮྯྰྱྲླྴྵྶྷྸྐྵྺྻྼ
+྾྿࿀࿁࿂࿃࿄࿅
+࿆
+࿇࿈࿉࿊࿋࿌࿎࿏࿐࿑࿒࿓࿔࿕࿖࿗࿘
+ကခဂဃငစဆဇဈဉညဋဌဍဎဏတထဒဓနပဖဗဘမယရလဝသဟဠအဢဣဤဥဦဧဨဩဪါာိီုူေဲဳဴဵံ့း္်ျြွှဿ၀၁၂၃၄၅၆၇၈၉
+၊။၌၍၎၏
+ၐၑၒၓၔၕၖၗၘၙၚၛၜၝၞၟၠၡၢၣၤၥၦၧၨၩၪၫၬၭၮၯၰၱၲၳၴၵၶၷၸၹၺၻၼၽၾၿႀႁႂႃႄႅႆႇႈႉႊႋႌႍႎႏ႐႑႒႓႔႕႖႗႘႙ႚႛႜႝ
+႞႟
+ႠႡႢႣႤႥႦႧႨႩႪႫႬႭႮႯႰႱႲႳႴႵႶႷႸႹႺႻႼႽႾႿჀჁჂჃჄჅაბგდევზთიკლმნოპჟრსტუფქღყშჩცძწჭხჯჰჱჲჳჴჵჶჷჸჹჺ
+჻
+ჼᄀᄁᄂᄃᄄᄅᄆᄇᄈᄉᄊᄋᄌᄍᄎᄏᄐᄑᄒᄓᄔᄕᄖᄗᄘᄙᄚᄛᄜᄝᄞᄟᄠᄡᄢᄣᄤᄥᄦᄧᄨᄩᄪᄫᄬᄭᄮᄯᄰᄱᄲᄳᄴᄵᄶᄷᄸᄹᄺᄻᄼᄽᄾᄿᅀᅁᅂᅃᅄᅅᅆᅇᅈᅉᅊᅋᅌᅍᅎᅏᅐᅑᅒᅓᅔᅕᅖᅗᅘᅙᅚᅛᅜᅝᅞᅟᅠᅡᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵᅶᅷᅸᅹᅺᅻᅼᅽᅾᅿᆀᆁᆂᆃᆄᆅᆆᆇᆈᆉᆊᆋᆌᆍᆎᆏᆐᆑᆒᆓᆔᆕᆖᆗᆘᆙᆚᆛᆜᆝᆞᆟᆠᆡᆢᆣᆤᆥᆦᆧᆨᆩᆪᆫᆬᆭᆮᆯᆰᆱᆲᆳᆴᆵᆶᆷᆸᆹᆺᆻᆼᆽᆾᆿᇀᇁᇂᇃᇄᇅᇆᇇᇈᇉᇊᇋᇌᇍᇎᇏᇐᇑᇒᇓᇔᇕᇖᇗᇘᇙᇚᇛᇜᇝᇞᇟᇠᇡᇢᇣᇤᇥᇦᇧᇨᇩᇪᇫᇬᇭᇮᇯᇰᇱᇲᇳᇴᇵᇶᇷᇸᇹᇺᇻᇼᇽᇾᇿሀሁሂሃሄህሆሇለሉሊላሌልሎሏሐሑሒሓሔሕሖሗመሙሚማሜምሞሟሠሡሢሣሤሥሦሧረሩሪራሬርሮሯሰሱሲሳሴስሶሷሸሹሺሻሼሽሾሿቀቁቂቃቄቅቆቇቈቊቋቌቍቐቑቒቓቔቕቖቘቚቛቜቝበቡቢባቤብቦቧቨቩቪቫቬቭቮቯተቱቲታቴትቶቷቸቹቺቻቼችቾቿኀኁኂኃኄኅኆኇኈኊኋኌኍነኑኒናኔንኖኗኘኙኚኛኜኝኞኟአኡኢኣኤእኦኧከኩኪካኬክኮኯኰኲኳኴኵኸኹኺኻኼኽኾዀዂዃዄዅወዉዊዋዌውዎዏዐዑዒዓዔዕዖዘዙዚዛዜዝዞዟዠዡዢዣዤዥዦዧየዩዪያዬይዮዯደዱዲዳዴድዶዷዸዹዺዻዼዽዾዿጀጁጂጃጄጅጆጇገጉጊጋጌግጎጏጐጒጓጔጕጘጙጚጛጜጝጞጟጠጡጢጣጤጥጦጧጨጩጪጫጬጭጮጯጰጱጲጳጴጵጶጷጸጹጺጻጼጽጾጿፀፁፂፃፄፅፆፇፈፉፊፋፌፍፎፏፐፑፒፓፔፕፖፗፘፙፚ፟
+፠፡።፣፤፥፦፧፨
+፩፪፫፬፭፮፯፰፱፲፳፴፵፶፷፸፹፺፻፼ᎀᎁᎂᎃᎄᎅᎆᎇᎈᎉᎊᎋᎌᎍᎎᎏ
+᎐᎑᎒᎓᎔᎕᎖᎗᎘᎙
+ᎠᎡᎢᎣᎤᎥᎦᎧᎨᎩᎪᎫᎬᎭᎮᎯᎰᎱᎲᎳᎴᎵᎶᎷᎸᎹᎺᎻᎼᎽᎾᎿᏀᏁᏂᏃᏄᏅᏆᏇᏈᏉᏊᏋᏌᏍᏎᏏᏐᏑᏒᏓᏔᏕᏖᏗᏘᏙᏚᏛᏜᏝᏞᏟᏠᏡᏢᏣᏤᏥᏦᏧᏨᏩᏪᏫᏬᏭᏮᏯᏰᏱᏲᏳᏴ
+᐀
+ᐁᐂᐃᐄᐅᐆᐇᐈᐉᐊᐋᐌᐍᐎᐏᐐᐑᐒᐓᐔᐕᐖᐗᐘᐙᐚᐛᐜᐝᐞᐟᐠᐡᐢᐣᐤᐥᐦᐧᐨᐩᐪᐫᐬᐭᐮᐯᐰᐱᐲᐳᐴᐵᐶᐷᐸᐹᐺᐻᐼᐽᐾᐿᑀᑁᑂᑃᑄᑅᑆᑇᑈᑉᑊᑋᑌᑍᑎᑏᑐᑑᑒᑓᑔᑕᑖᑗᑘᑙᑚᑛᑜᑝᑞᑟᑠᑡᑢᑣᑤᑥᑦᑧᑨᑩᑪᑫᑬᑭᑮᑯᑰᑱᑲᑳᑴᑵᑶᑷᑸᑹᑺᑻᑼᑽᑾᑿᒀᒁᒂᒃᒄᒅᒆᒇᒈᒉᒊᒋᒌᒍᒎᒏᒐᒑᒒᒓᒔᒕᒖᒗᒘᒙᒚᒛᒜᒝᒞᒟᒠᒡᒢᒣᒤᒥᒦᒧᒨᒩᒪᒫᒬᒭᒮᒯᒰᒱᒲᒳᒴᒵᒶᒷᒸᒹᒺᒻᒼᒽᒾᒿᓀᓁᓂᓃᓄᓅᓆᓇᓈᓉᓊᓋᓌᓍᓎᓏᓐᓑᓒᓓᓔᓕᓖᓗᓘᓙᓚᓛᓜᓝᓞᓟᓠᓡᓢᓣᓤᓥᓦᓧᓨᓩᓪᓫᓬᓭᓮᓯᓰᓱᓲᓳᓴᓵᓶᓷᓸᓹᓺᓻᓼᓽᓾᓿᔀᔁᔂᔃᔄᔅᔆᔇᔈᔉᔊᔋᔌᔍᔎᔏᔐᔑᔒᔓᔔᔕᔖᔗᔘᔙᔚᔛᔜᔝᔞᔟᔠᔡᔢᔣᔤᔥᔦᔧᔨᔩᔪᔫᔬᔭᔮᔯᔰᔱᔲᔳᔴᔵᔶᔷᔸᔹᔺᔻᔼᔽᔾᔿᕀᕁᕂᕃᕄᕅᕆᕇᕈᕉᕊᕋᕌᕍᕎᕏᕐᕑᕒᕓᕔᕕᕖᕗᕘᕙᕚᕛᕜᕝᕞᕟᕠᕡᕢᕣᕤᕥᕦᕧᕨᕩᕪᕫᕬᕭᕮᕯᕰᕱᕲᕳᕴᕵᕶᕷᕸᕹᕺᕻᕼᕽᕾᕿᖀᖁᖂᖃᖄᖅᖆᖇᖈᖉᖊᖋᖌᖍᖎᖏᖐᖑᖒᖓᖔᖕᖖᖗᖘᖙᖚᖛᖜᖝᖞᖟᖠᖡᖢᖣᖤᖥᖦᖧᖨᖩᖪᖫᖬᖭᖮᖯᖰᖱᖲᖳᖴᖵᖶᖷᖸᖹᖺᖻᖼᖽᖾᖿᗀᗁᗂᗃᗄᗅᗆᗇᗈᗉᗊᗋᗌᗍᗎᗏᗐᗑᗒᗓᗔᗕᗖᗗᗘᗙᗚᗛᗜᗝᗞᗟᗠᗡᗢᗣᗤᗥᗦᗧᗨᗩᗪᗫᗬᗭᗮᗯᗰᗱᗲᗳᗴᗵᗶᗷᗸᗹᗺᗻᗼᗽᗾᗿᘀᘁᘂᘃᘄᘅᘆᘇᘈᘉᘊᘋᘌᘍᘎᘏᘐᘑᘒᘓᘔᘕᘖᘗᘘᘙᘚᘛᘜᘝᘞᘟᘠᘡᘢᘣᘤᘥᘦᘧᘨᘩᘪᘫᘬᘭᘮᘯᘰᘱᘲᘳᘴᘵᘶᘷᘸᘹᘺᘻᘼᘽᘾᘿᙀᙁᙂᙃᙄᙅᙆᙇᙈᙉᙊᙋᙌᙍᙎᙏᙐᙑᙒᙓᙔᙕᙖᙗᙘᙙᙚᙛᙜᙝᙞᙟᙠᙡᙢᙣᙤᙥᙦᙧᙨᙩᙪᙫᙬ
+᙭᙮
+ᙯᙰᙱᙲᙳᙴᙵᙶᙷᙸᙹᙺᙻᙼᙽᙾᙿ
+ 
+ᚁᚂᚃᚄᚅᚆᚇᚈᚉᚊᚋᚌᚍᚎᚏᚐᚑᚒᚓᚔᚕᚖᚗᚘᚙᚚ
+᚛᚜
+ᚠᚡᚢᚣᚤᚥᚦᚧᚨᚩᚪᚫᚬᚭᚮᚯᚰᚱᚲᚳᚴᚵᚶᚷᚸᚹᚺᚻᚼᚽᚾᚿᛀᛁᛂᛃᛄᛅᛆᛇᛈᛉᛊᛋᛌᛍᛎᛏᛐᛑᛒᛓᛔᛕᛖᛗᛘᛙᛚᛛᛜᛝᛞᛟᛠᛡᛢᛣᛤᛥᛦᛧᛨᛩᛪ
+᛫᛬᛭
+ᛮᛯᛰᜀᜁᜂᜃᜄᜅᜆᜇᜈᜉᜊᜋᜌᜎᜏᜐᜑᜒᜓ᜔ᜠᜡᜢᜣᜤᜥᜦᜧᜨᜩᜪᜫᜬᜭᜮᜯᜰᜱᜲᜳ᜴
+᜵᜶
+ᝀᝁᝂᝃᝄᝅᝆᝇᝈᝉᝊᝋᝌᝍᝎᝏᝐᝑᝒᝓᝠᝡᝢᝣᝤᝥᝦᝧᝨᝩᝪᝫᝬᝮᝯᝰᝲᝳកខគឃងចឆជឈញដឋឌឍណតថទធនបផពភមយរលវឝឞសហឡអឣឤឥឦឧឨឩឪឫឬឭឮឯឰឱឲឳ
+឴឵
+ាិីឹឺុូួើឿៀេែៃោៅំះៈ៉៊់៌៍៎៏័៑្៓
+។៕៖
+ៗ
+៘៙៚៛
+ៜ៝០១២៣៤៥៦៧៨៩៰៱៲៳៴៵៶៷៸៹
+᠀᠁᠂᠃᠄᠅᠆᠇᠈᠉᠊
+᠋᠌᠍
+᠎
+᠐᠑᠒᠓᠔᠕᠖᠗᠘᠙ᠠᠡᠢᠣᠤᠥᠦᠧᠨᠩᠪᠫᠬᠭᠮᠯᠰᠱᠲᠳᠴᠵᠶᠷᠸᠹᠺᠻᠼᠽᠾᠿᡀᡁᡂᡃᡄᡅᡆᡇᡈᡉᡊᡋᡌᡍᡎᡏᡐᡑᡒᡓᡔᡕᡖᡗᡘᡙᡚᡛᡜᡝᡞᡟᡠᡡᡢᡣᡤᡥᡦᡧᡨᡩᡪᡫᡬᡭᡮᡯᡰᡱᡲᡳᡴᡵᡶᡷᢀᢁᢂᢃᢄᢅᢆᢇᢈᢉᢊᢋᢌᢍᢎᢏᢐᢑᢒᢓᢔᢕᢖᢗᢘᢙᢚᢛᢜᢝᢞᢟᢠᢡᢢᢣᢤᢥᢦᢧᢨᢩᢪᢰᢱᢲᢳᢴᢵᢶᢷᢸᢹᢺᢻᢼᢽᢾᢿᣀᣁᣂᣃᣄᣅᣆᣇᣈᣉᣊᣋᣌᣍᣎᣏᣐᣑᣒᣓᣔᣕᣖᣗᣘᣙᣚᣛᣜᣝᣞᣟᣠᣡᣢᣣᣤᣥᣦᣧᣨᣩᣪᣫᣬᣭᣮᣯᣰᣱᣲᣳᣴᣵᤀᤁᤂᤃᤄᤅᤆᤇᤈᤉᤊᤋᤌᤍᤎᤏᤐᤑᤒᤓᤔᤕᤖᤗᤘᤙᤚᤛᤜᤠᤡᤢᤣᤤᤥᤦᤧᤨᤩᤪᤫᤰᤱᤲᤳᤴᤵᤶᤷᤸ᤻᤹᤺
+᥀᥄᥅
+᥆᥇᥈᥉᥊᥋᥌᥍᥎᥏ᥐᥑᥒᥓᥔᥕᥖᥗᥘᥙᥚᥛᥜᥝᥞᥟᥠᥡᥢᥣᥤᥥᥦᥧᥨᥩᥪᥫᥬᥭᥰᥱᥲᥳᥴᦀᦁᦂᦃᦄᦅᦆᦇᦈᦉᦊᦋᦌᦍᦎᦏᦐᦑᦒᦓᦔᦕᦖᦗᦘᦙᦚᦛᦜᦝᦞᦟᦠᦡᦢᦣᦤᦥᦦᦧᦨᦩᦪᦫᦰᦱᦲᦳᦴᦵᦶᦷᦸᦹᦺᦻᦼᦽᦾᦿᧀᧁᧂᧃᧄᧅᧆᧇᧈᧉ᧐᧑᧒᧓᧔᧕᧖᧗᧘᧙᧚
+᧞᧟᧠᧡᧢᧣᧤᧥᧦᧧᧨᧩᧪᧫᧬᧭᧮᧯᧰᧱᧲᧳᧴᧵᧶᧷᧸᧹᧺᧻᧼᧽᧾᧿
+ᨀᨁᨂᨃᨄᨅᨆᨇᨈᨉᨊᨋᨌᨍᨎᨏᨐᨑᨒᨓᨔᨕᨖᨘᨗᨙᨚᨛ
+᨞᨟
+ᨠᨡᨢᨣᨤᨥᨦᨧᨨᨩᨪᨫᨬᨭᨮᨯᨰᨱᨲᨳᨴᨵᨶᨷᨸᨹᨺᨻᨼᨽᨾᨿᩀᩁᩂᩃᩄᩅᩆᩇᩈᩉᩊᩋᩌᩍᩎᩏᩐᩑᩒᩓᩔᩕᩖᩗᩘᩙᩚᩛᩜᩝᩞ᩠ᩡᩢᩣᩤᩥᩦᩧᩨᩩᩪᩫᩬᩭᩮᩯᩰᩱᩲᩳᩴ᩿᩵᩶᩷᩸᩹᩺᩻᩼᪀᪁᪂᪃᪄᪅᪆᪇᪈᪉᪐᪑᪒᪓᪔᪕᪖᪗᪘᪙
+᪠᪡᪢᪣᪤᪥᪦
+ᪧ
+᪨᪩᪪᪫᪬᪭
+ᬀᬁᬂᬃᬄᬅᬆᬇᬈᬉᬊᬋᬌᬍᬎᬏᬐᬑᬒᬓᬔᬕᬖᬗᬘᬙᬚᬛᬜᬝᬞᬟᬠᬡᬢᬣᬤᬥᬦᬧᬨᬩᬪᬫᬬᬭᬮᬯᬰᬱᬲᬳ᬴ᬵᬶᬷᬸᬹᬺᬻᬼᬽᬾᬿᭀᭁᭂᭃ᭄ᭅᭆᭇᭈᭉᭊᭋ᭐᭑᭒᭓᭔᭕᭖᭗᭘᭙
+᭚᭛᭜᭝᭞᭟᭠᭡᭢᭣᭤᭥᭦᭧᭨᭩᭪
+᭬᭫᭭᭮᭯᭰᭱᭲᭳
+᭴᭵᭶᭷᭸᭹᭺᭻᭼
+ᮀᮁᮂᮃᮄᮅᮆᮇᮈᮉᮊᮋᮌᮍᮎᮏᮐᮑᮒᮓᮔᮕᮖᮗᮘᮙᮚᮛᮜᮝᮞᮟᮠᮡᮢᮣᮤᮥᮦᮧᮨᮩ᮪ᮮᮯ᮰᮱᮲᮳᮴᮵᮶᮷᮸᮹ᰀᰁᰂᰃᰄᰅᰆᰇᰈᰉᰊᰋᰌᰍᰎᰏᰐᰑᰒᰓᰔᰕᰖᰗᰘᰙᰚᰛᰜᰝᰞᰟᰠᰡᰢᰣᰤᰥᰦᰧᰨᰩᰪᰫᰬᰭᰮᰯᰰᰱᰲᰳᰴᰵᰶ᰷
+᰻᰼᰽᰾᰿
+᱀᱁᱂᱃᱄᱅᱆᱇᱈᱉ᱍᱎᱏ᱐᱑᱒᱓᱔᱕᱖᱗᱘᱙ᱚᱛᱜᱝᱞᱟᱠᱡᱢᱣᱤᱥᱦᱧᱨᱩᱪᱫᱬᱭᱮᱯᱰᱱᱲᱳᱴᱵᱶᱷᱸᱹᱺᱻᱼᱽ
+᱾᱿
+᳐᳑᳒
+᳓
+᳔᳕᳖᳗᳘᳙᳜᳝᳞᳟᳚᳛᳠᳡᳢᳣᳤᳥᳦᳧᳨ᳩᳪᳫᳬ᳭ᳮᳯᳰᳱᳲᴀᴁᴂᴃᴄᴅᴆᴇᴈᴉᴊᴋᴌᴍᴎᴏᴐᴑᴒᴓᴔᴕᴖᴗᴘᴙᴚᴛᴜᴝᴞᴟᴠᴡᴢᴣᴤᴥᴦᴧᴨᴩᴪᴫᴬᴭᴮᴯᴰᴱᴲᴳᴴᴵᴶᴷᴸᴹᴺᴻᴼᴽᴾᴿᵀᵁᵂᵃᵄᵅᵆᵇᵈᵉᵊᵋᵌᵍᵎᵏᵐᵑᵒᵓᵔᵕᵖᵗᵘᵙᵚᵛᵜᵝᵞᵟᵠᵡᵢᵣᵤᵥᵦᵧᵨᵩᵪᵫᵬᵭᵮᵯᵰᵱᵲᵳᵴᵵᵶᵷᵸᵹᵺᵻᵼᵽᵾᵿᶀᶁᶂᶃᶄᶅᶆᶇᶈᶉᶊᶋᶌᶍᶎᶏᶐᶑᶒᶓᶔᶕᶖᶗᶘᶙᶚᶛᶜᶝᶞᶟᶠᶡᶢᶣᶤᶥᶦᶧᶨᶩᶪᶫᶬᶭᶮᶯᶰᶱᶲᶳᶴᶵᶶᶷᶸᶹᶺᶻᶼᶽᶾᶿ᷐᷎᷂᷊᷏᷽᷿᷀᷁᷃᷄᷅᷆᷇᷈᷉᷋᷌᷑᷒ᷓᷔᷕᷖᷗᷘᷙᷚᷛᷜᷝᷞᷟᷠᷡᷢᷣᷤᷥᷦ᷾᷍ḀḁḂḃḄḅḆḇḈḉḊḋḌḍḎḏḐḑḒḓḔḕḖḗḘḙḚḛḜḝḞḟḠḡḢḣḤḥḦḧḨḩḪḫḬḭḮḯḰḱḲḳḴḵḶḷḸḹḺḻḼḽḾḿṀṁṂṃṄṅṆṇṈṉṊṋṌṍṎṏṐṑṒṓṔṕṖṗṘṙṚṛṜṝṞṟṠṡṢṣṤṥṦṧṨṩṪṫṬṭṮṯṰṱṲṳṴṵṶṷṸṹṺṻṼṽṾṿẀẁẂẃẄẅẆẇẈẉẊẋẌẍẎẏẐẑẒẓẔẕẖẗẘẙẚẛẜẝẞẟẠạẢảẤấẦầẨẩẪẫẬậẮắẰằẲẳẴẵẶặẸẹẺẻẼẽẾếỀềỂểỄễỆệỈỉỊịỌọỎỏỐốỒồỔổỖỗỘộỚớỜờỞởỠỡỢợỤụỦủỨứỪừỬửỮữỰựỲỳỴỵỶỷỸỹỺỻỼỽỾỿἀἁἂἃἄἅἆἇἈἉἊἋἌἍἎἏἐἑἒἓἔἕἘἙἚἛἜἝἠἡἢἣἤἥἦἧἨἩἪἫἬἭἮἯἰἱἲἳἴἵἶἷἸἹἺἻἼἽἾἿὀὁὂὃὄὅὈὉὊὋὌὍὐὑὒὓὔὕὖὗὙὛὝὟὠὡὢὣὤὥὦὧὨὩὪὫὬὭὮὯὰάὲέὴήὶίὸόὺύὼώᾀᾁᾂᾃᾄᾅᾆᾇᾈᾉᾊᾋᾌᾍᾎᾏᾐᾑᾒᾓᾔᾕᾖᾗᾘᾙᾚᾛᾜᾝᾞᾟᾠᾡᾢᾣᾤᾥᾦᾧᾨᾩᾪᾫᾬᾭᾮᾯᾰᾱᾲᾳᾴᾶᾷᾸᾹᾺΆᾼ
+᾽
+ι
+᾿῀῁
+ῂῃῄῆῇῈΈῊΉῌ
+῍῎῏
+ῐῑῒΐῖῗῘῙῚΊ
+῝῞῟
+ῠῡῢΰῤῥῦῧῨῩῪΎῬ
+῭΅`
+ῲῳῴῶῷῸΌῺΏῼ
+´῾           ​‌‍‎‏‐‑‒–—―‖‗‘’‚‛“”„‟†‡•‣․‥…‧

‪‫‬‭‮ ‰‱′″‴‵‶‷‸‹›※‼‽‾‿⁀⁁⁂⁃⁄⁅⁆⁇⁈⁉⁊⁋⁌⁍⁎⁏⁐⁑⁒⁓⁔⁕⁖⁗⁘⁙⁚⁛⁜⁝⁞ ⁠⁡⁢⁣⁤
+⁰ⁱ⁴⁵⁶⁷⁸⁹
+⁺⁻⁼⁽⁾
+ⁿ₀₁₂₃₄₅₆₇₈₉
+₊₋₌₍₎
+ₐₑₒₓₔ
+₠₡₢₣₤₥₦₧₨₩₪₫€₭₮₯₰₱₲₳₴₵₶₷₸
+⃒⃓⃘⃙⃚⃐⃑⃔⃕⃖⃗⃛⃜⃝⃞⃟⃠⃡⃢⃣⃤⃥⃦⃪⃫⃨⃬⃭⃮⃯⃧⃩⃰
+℀℁
+ℂ
+℃℄℅℆
+ℇ
+℈℉
+ℊℋℌℍℎℏℐℑℒℓ
+℔
+ℕ
+№℗℘
+ℙℚℛℜℝ
+℞℟℠℡™℣
+ℤ
+℥
+Ω
+℧
+ℨ
+℩
+KÅℬℭ
+℮
+ℯℰℱℲℳℴℵℶℷℸℹ
+℺℻
+ℼℽℾℿ
+⅀⅁⅂⅃⅄
+ⅅⅆⅇⅈⅉ
+⅊⅋⅌⅍
+ⅎ
+⅏
+⅐⅑⅒⅓⅔⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞⅟ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩⅪⅫⅬⅭⅮⅯⅰⅱⅲⅳⅴⅵⅶⅷⅸⅹⅺⅻⅼⅽⅾⅿↀↁↂↃↄↅↆↇↈ↉
+←↑→↓↔↕↖↗↘↙↚↛↜↝↞↟↠↡↢↣↤↥↦↧↨↩↪↫↬↭↮↯↰↱↲↳↴↵↶↷↸↹↺↻↼↽↾↿⇀⇁⇂⇃⇄⇅⇆⇇⇈⇉⇊⇋⇌⇍⇎⇏⇐⇑⇒⇓⇔⇕⇖⇗⇘⇙⇚⇛⇜⇝⇞⇟⇠⇡⇢⇣⇤⇥⇦⇧⇨⇩⇪⇫⇬⇭⇮⇯⇰⇱⇲⇳⇴⇵⇶⇷⇸⇹⇺⇻⇼⇽⇾⇿∀∁∂∃∄∅∆∇∈∉∊∋∌∍∎∏∐∑−∓∔∕∖∗∘∙√∛∜∝∞∟∠∡∢∣∤∥∦∧∨∩∪∫∬∭∮∯∰∱∲∳∴∵∶∷∸∹∺∻∼∽∾∿≀≁≂≃≄≅≆≇≈≉≊≋≌≍≎≏≐≑≒≓≔≕≖≗≘≙≚≛≜≝≞≟≠≡≢≣≤≥≦≧≨≩≪≫≬≭≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊌⊍⊎⊏⊐⊑⊒⊓⊔⊕⊖⊗⊘⊙⊚⊛⊜⊝⊞⊟⊠⊡⊢⊣⊤⊥⊦⊧⊨⊩⊪⊫⊬⊭⊮⊯⊰⊱⊲⊳⊴⊵⊶⊷⊸⊹⊺⊻⊼⊽⊾⊿⋀⋁⋂⋃⋄⋅⋆⋇⋈⋉⋊⋋⋌⋍⋎⋏⋐⋑⋒⋓⋔⋕⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋮⋯⋰⋱⋲⋳⋴⋵⋶⋷⋸⋹⋺⋻⋼⋽⋾⋿⌀⌁⌂⌃⌄⌅⌆⌇⌈⌉⌊⌋⌌⌍⌎⌏⌐⌑⌒⌓⌔⌕⌖⌗⌘⌙⌚⌛⌜⌝⌞⌟⌠⌡⌢⌣⌤⌥⌦⌧⌨〈〉⌫⌬⌭⌮⌯⌰⌱⌲⌳⌴⌵⌶⌷⌸⌹⌺⌻⌼⌽⌾⌿⍀⍁⍂⍃⍄⍅⍆⍇⍈⍉⍊⍋⍌⍍⍎⍏⍐⍑⍒⍓⍔⍕⍖⍗⍘⍙⍚⍛⍜⍝⍞⍟⍠⍡⍢⍣⍤⍥⍦⍧⍨⍩⍪⍫⍬⍭⍮⍯⍰⍱⍲⍳⍴⍵⍶⍷⍸⍹⍺⍻⍼⍽⍾⍿⎀⎁⎂⎃⎄⎅⎆⎇⎈⎉⎊⎋⎌⎍⎎⎏⎐⎑⎒⎓⎔⎕⎖⎗⎘⎙⎚⎛⎜⎝⎞⎟⎠⎡⎢⎣⎤⎥⎦⎧⎨⎩⎪⎫⎬⎭⎮⎯⎰⎱⎲⎳⎴⎵⎶⎷⎸⎹⎺⎻⎼⎽⎾⎿⏀⏁⏂⏃⏄⏅⏆⏇⏈⏉⏊⏋⏌⏍⏎⏏⏐⏑⏒⏓⏔⏕⏖⏗⏘⏙⏚⏛⏜⏝⏞⏟⏠⏡⏢⏣⏤⏥⏦⏧⏨␀␁␂␃␄␅␆␇␈␉␊␋␌␍␎␏␐␑␒␓␔␕␖␗␘␙␚␛␜␝␞␟␠␡␢␣␤␥␦⑀⑁⑂⑃⑄⑅⑆⑇⑈⑉⑊
+①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛
+⒜⒝⒞⒟⒠⒡⒢⒣⒤⒥⒦⒧⒨⒩⒪⒫⒬⒭⒮⒯⒰⒱⒲⒳⒴⒵ⒶⒷⒸⒹⒺⒻⒼⒽⒾⒿⓀⓁⓂⓃⓄⓅⓆⓇⓈⓉⓊⓋⓌⓍⓎⓏⓐⓑⓒⓓⓔⓕⓖⓗⓘⓙⓚⓛⓜⓝⓞⓟⓠⓡⓢⓣⓤⓥⓦⓧⓨⓩ
+⓪⓫⓬⓭⓮⓯⓰⓱⓲⓳⓴⓵⓶⓷⓸⓹⓺⓻⓼⓽⓾⓿
+─━│┃┄┅┆┇┈┉┊┋┌┍┎┏┐┑┒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋╌╍╎╏═║╒╓╔╕╖╗╘╙╚╛╜╝╞╟╠╡╢╣╤╥╦╧╨╩╪╫╬╭╮╯╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿▀▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▐░▒▓▔▕▖▗▘▙▚▛▜▝▞▟■□▢▣▤▥▦▧▨▩▪▫▬▭▮▯▰▱▲△▴▵▶▷▸▹►▻▼▽▾▿◀◁◂◃◄◅◆◇◈◉◊○◌◍◎●◐◑◒◓◔◕◖◗◘◙◚◛◜◝◞◟◠◡◢◣◤◥◦◧◨◩◪◫◬◭◮◯◰◱◲◳◴◵◶◷◸◹◺◻◼◽◾◿☀☁☂☃☄★☆☇☈☉☊☋☌☍☎☏☐☑☒☓☔☕☖☗☘☙☚☛☜☝☞☟☠☡☢☣☤☥☦☧☨☩☪☫☬☭☮☯☰☱☲☳☴☵☶☷☸☹☺☻☼☽☾☿♀♁♂♃♄♅♆♇♈♉♊♋♌♍♎♏♐♑♒♓♔♕♖♗♘♙♚♛♜♝♞♟♠♡♢♣♤♥♦♧♨♩♪♫♬♭♮♯♰♱♲♳♴♵♶♷♸♹♺♻♼♽♾♿⚀⚁⚂⚃⚄⚅⚆⚇⚈⚉⚊⚋⚌⚍⚎⚏⚐⚑⚒⚓⚔⚕⚖⚗⚘⚙⚚⚛⚜⚝⚞⚟⚠⚡⚢⚣⚤⚥⚦⚧⚨⚩⚪⚫⚬⚭⚮⚯⚰⚱⚲⚳⚴⚵⚶⚷⚸⚹⚺⚻⚼⚽⚾⚿⛀⛁⛂⛃⛄⛅⛆⛇⛈⛉⛊⛋⛌⛍⛏⛐⛑⛒⛓⛔⛕⛖⛗⛘⛙⛚⛛⛜⛝⛞⛟⛠⛡⛣⛨⛩⛪⛫⛬⛭⛮⛯⛰⛱⛲⛳⛴⛵⛶⛷⛸⛹⛺⛻⛼⛽⛾⛿✁✂✃✄✆✇✈✉✌✍✎✏✐✑✒✓✔✕✖✗✘✙✚✛✜✝✞✟✠✡✢✣✤✥✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❄❅❆❇❈❉❊❋❍❏❐❑❒❖❗❘❙❚❛❜❝❞❡❢❣❤❥❦❧❨❩❪❫❬❭❮❯❰❱❲❳❴❵
+❶❷❸❹❺❻❼❽❾❿➀➁➂➃➄➅➆➇➈➉➊➋➌➍➎➏➐➑➒➓
+➔➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫➬➭➮➯➱➲➳➴➵➶➷➸➹➺➻➼➽➾⟀⟁⟂⟃⟄⟅⟆⟇⟈⟉⟊⟌⟐⟑⟒⟓⟔⟕⟖⟗⟘⟙⟚⟛⟜⟝⟞⟟⟠⟡⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫⟬⟭⟮⟯⟰⟱⟲⟳⟴⟵⟶⟷⟸⟹⟺⟻⟼⟽⟾⟿⠀⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿⡀⡁⡂⡃⡄⡅⡆⡇⡈⡉⡊⡋⡌⡍⡎⡏⡐⡑⡒⡓⡔⡕⡖⡗⡘⡙⡚⡛⡜⡝⡞⡟⡠⡡⡢⡣⡤⡥⡦⡧⡨⡩⡪⡫⡬⡭⡮⡯⡰⡱⡲⡳⡴⡵⡶⡷⡸⡹⡺⡻⡼⡽⡾⡿⢀⢁⢂⢃⢄⢅⢆⢇⢈⢉⢊⢋⢌⢍⢎⢏⢐⢑⢒⢓⢔⢕⢖⢗⢘⢙⢚⢛⢜⢝⢞⢟⢠⢡⢢⢣⢤⢥⢦⢧⢨⢩⢪⢫⢬⢭⢮⢯⢰⢱⢲⢳⢴⢵⢶⢷⢸⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣈⣉⣊⣋⣌⣍⣎⣏⣐⣑⣒⣓⣔⣕⣖⣗⣘⣙⣚⣛⣜⣝⣞⣟⣠⣡⣢⣣⣤⣥⣦⣧⣨⣩⣪⣫⣬⣭⣮⣯⣰⣱⣲⣳⣴⣵⣶⣷⣸⣹⣺⣻⣼⣽⣾⣿⤀⤁⤂⤃⤄⤅⤆⤇⤈⤉⤊⤋⤌⤍⤎⤏⤐⤑⤒⤓⤔⤕⤖⤗⤘⤙⤚⤛⤜⤝⤞⤟⤠⤡⤢⤣⤤⤥⤦⤧⤨⤩⤪⤫⤬⤭⤮⤯⤰⤱⤲⤳⤴⤵⤶⤷⤸⤹⤺⤻⤼⤽⤾⤿⥀⥁⥂⥃⥄⥅⥆⥇⥈⥉⥊⥋⥌⥍⥎⥏⥐⥑⥒⥓⥔⥕⥖⥗⥘⥙⥚⥛⥜⥝⥞⥟⥠⥡⥢⥣⥤⥥⥦⥧⥨⥩⥪⥫⥬⥭⥮⥯⥰⥱⥲⥳⥴⥵⥶⥷⥸⥹⥺⥻⥼⥽⥾⥿⦀⦁⦂⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦎⦏⦐⦑⦒⦓⦔⦕⦖⦗⦘⦙⦚⦛⦜⦝⦞⦟⦠⦡⦢⦣⦤⦥⦦⦧⦨⦩⦪⦫⦬⦭⦮⦯⦰⦱⦲⦳⦴⦵⦶⦷⦸⦹⦺⦻⦼⦽⦾⦿⧀⧁⧂⧃⧄⧅⧆⧇⧈⧉⧊⧋⧌⧍⧎⧏⧐⧑⧒⧓⧔⧕⧖⧗⧘⧙⧚⧛⧜⧝⧞⧟⧠⧡⧢⧣⧤⧥⧦⧧⧨⧩⧪⧫⧬⧭⧮⧯⧰⧱⧲⧳⧴⧵⧶⧷⧸⧹⧺⧻⧼⧽⧾⧿⨀⨁⨂⨃⨄⨅⨆⨇⨈⨉⨊⨋⨌⨍⨎⨏⨐⨑⨒⨓⨔⨕⨖⨗⨘⨙⨚⨛⨜⨝⨞⨟⨠⨡⨢⨣⨤⨥⨦⨧⨨⨩⨪⨫⨬⨭⨮⨯⨰⨱⨲⨳⨴⨵⨶⨷⨸⨹⨺⨻⨼⨽⨾⨿⩀⩁⩂⩃⩄⩅⩆⩇⩈⩉⩊⩋⩌⩍⩎⩏⩐⩑⩒⩓⩔⩕⩖⩗⩘⩙⩚⩛⩜⩝⩞⩟⩠⩡⩢⩣⩤⩥⩦⩧⩨⩩⩪⩫⩬⩭⩮⩯⩰⩱⩲⩳⩴⩵⩶⩷⩸⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚⪛⪜⪝⪞⪟⪠⪡⪢⪣⪤⪥⪦⪧⪨⪩⪪⪫⪬⪭⪮⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐⫑⫒⫓⫔⫕⫖⫗⫘⫙⫚⫛⫝̸⫝⫞⫟⫠⫡⫢⫣⫤⫥⫦⫧⫨⫩⫪⫫⫬⫭⫮⫯⫰⫱⫲⫳⫴⫵⫶⫷⫸⫹⫺⫻⫼⫽⫾⫿⬀⬁⬂⬃⬄⬅⬆⬇⬈⬉⬊⬋⬌⬍⬎⬏⬐⬑⬒⬓⬔⬕⬖⬗⬘⬙⬚⬛⬜⬝⬞⬟⬠⬡⬢⬣⬤⬥⬦⬧⬨⬩⬪⬫⬬⬭⬮⬯⬰⬱⬲⬳⬴⬵⬶⬷⬸⬹⬺⬻⬼⬽⬾⬿⭀⭁⭂⭃⭄⭅⭆⭇⭈⭉⭊⭋⭌⭐⭑⭒⭓⭔⭕⭖⭗⭘⭙
+ⰀⰁⰂⰃⰄⰅⰆⰇⰈⰉⰊⰋⰌⰍⰎⰏⰐⰑⰒⰓⰔⰕⰖⰗⰘⰙⰚⰛⰜⰝⰞⰟⰠⰡⰢⰣⰤⰥⰦⰧⰨⰩⰪⰫⰬⰭⰮⰰⰱⰲⰳⰴⰵⰶⰷⰸⰹⰺⰻⰼⰽⰾⰿⱀⱁⱂⱃⱄⱅⱆⱇⱈⱉⱊⱋⱌⱍⱎⱏⱐⱑⱒⱓⱔⱕⱖⱗⱘⱙⱚⱛⱜⱝⱞⱠⱡⱢⱣⱤⱥⱦⱧⱨⱩⱪⱫⱬⱭⱮⱯⱰⱱⱲⱳⱴⱵⱶⱷⱸⱹⱺⱻⱼⱽⱾⱿⲀⲁⲂⲃⲄⲅⲆⲇⲈⲉⲊⲋⲌⲍⲎⲏⲐⲑⲒⲓⲔⲕⲖⲗⲘⲙⲚⲛⲜⲝⲞⲟⲠⲡⲢⲣⲤⲥⲦⲧⲨⲩⲪⲫⲬⲭⲮⲯⲰⲱⲲⲳⲴⲵⲶⲷⲸⲹⲺⲻⲼⲽⲾⲿⳀⳁⳂⳃⳄⳅⳆⳇⳈⳉⳊⳋⳌⳍⳎⳏⳐⳑⳒⳓⳔⳕⳖⳗⳘⳙⳚⳛⳜⳝⳞⳟⳠⳡⳢⳣⳤ
+⳥⳦⳧⳨⳩⳪
+ⳫⳬⳭⳮ⳯⳰⳱
+⳹⳺⳻⳼
+⳽
+⳾⳿
+ⴀⴁⴂⴃⴄⴅⴆⴇⴈⴉⴊⴋⴌⴍⴎⴏⴐⴑⴒⴓⴔⴕⴖⴗⴘⴙⴚⴛⴜⴝⴞⴟⴠⴡⴢⴣⴤⴥⴰⴱⴲⴳⴴⴵⴶⴷⴸⴹⴺⴻⴼⴽⴾⴿⵀⵁⵂⵃⵄⵅⵆⵇⵈⵉⵊⵋⵌⵍⵎⵏⵐⵑⵒⵓⵔⵕⵖⵗⵘⵙⵚⵛⵜⵝⵞⵟⵠⵡⵢⵣⵤⵥⵯⶀⶁⶂⶃⶄⶅⶆⶇⶈⶉⶊⶋⶌⶍⶎⶏⶐⶑⶒⶓⶔⶕⶖⶠⶡⶢⶣⶤⶥⶦⶨⶩⶪⶫⶬⶭⶮⶰⶱⶲⶳⶴⶵⶶⶸⶹⶺⶻⶼⶽⶾⷀⷁⷂⷃⷄⷅⷆⷈⷉⷊⷋⷌⷍⷎⷐⷑⷒⷓⷔⷕⷖⷘⷙⷚⷛⷜⷝⷞⷠⷡⷢⷣⷤⷥⷦⷧⷨⷩⷪⷫⷬⷭⷮⷯⷰⷱⷲⷳⷴⷵⷶⷷⷸⷹⷺⷻⷼⷽⷾⷿ
+⸀⸁⸂⸃⸄⸅⸆⸇⸈⸉⸊⸋⸌⸍⸎⸏⸐⸑⸒⸓⸔⸕⸖⸗⸘⸙⸚⸛⸜⸝⸞⸟⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⸪⸫⸬⸭⸮
+ⸯ
+⸰⸱⺀⺁⺂⺃⺄⺅⺆⺇⺈⺉⺊⺋⺌⺍⺎⺏⺐⺑⺒⺓⺔⺕⺖⺗⺘⺙⺛⺜⺝⺞⺟⺠⺡⺢⺣⺤⺥⺦⺧⺨⺩⺪⺫⺬⺭⺮⺯⺰⺱⺲⺳⺴⺵⺶⺷⺸⺹⺺⺻⺼⺽⺾⺿⻀⻁⻂⻃⻄⻅⻆⻇⻈⻉⻊⻋⻌⻍⻎⻏⻐⻑⻒⻓⻔⻕⻖⻗⻘⻙⻚⻛⻜⻝⻞⻟⻠⻡⻢⻣⻤⻥⻦⻧⻨⻩⻪⻫⻬⻭⻮⻯⻰⻱⻲⻳⼀⼁⼂⼃⼄⼅⼆⼇⼈⼉⼊⼋⼌⼍⼎⼏⼐⼑⼒⼓⼔⼕⼖⼗⼘⼙⼚⼛⼜⼝⼞⼟⼠⼡⼢⼣⼤⼥⼦⼧⼨⼩⼪⼫⼬⼭⼮⼯⼰⼱⼲⼳⼴⼵⼶⼷⼸⼹⼺⼻⼼⼽⼾⼿⽀⽁⽂⽃⽄⽅⽆⽇⽈⽉⽊⽋⽌⽍⽎⽏⽐⽑⽒⽓⽔⽕⽖⽗⽘⽙⽚⽛⽜⽝⽞⽟⽠⽡⽢⽣⽤⽥⽦⽧⽨⽩⽪⽫⽬⽭⽮⽯⽰⽱⽲⽳⽴⽵⽶⽷⽸⽹⽺⽻⽼⽽⽾⽿⾀⾁⾂⾃⾄⾅⾆⾇⾈⾉⾊⾋⾌⾍⾎⾏⾐⾑⾒⾓⾔⾕⾖⾗⾘⾙⾚⾛⾜⾝⾞⾟⾠⾡⾢⾣⾤⾥⾦⾧⾨⾩⾪⾫⾬⾭⾮⾯⾰⾱⾲⾳⾴⾵⾶⾷⾸⾹⾺⾻⾼⾽⾾⾿⿀⿁⿂⿃⿄⿅⿆⿇⿈⿉⿊⿋⿌⿍⿎⿏⿐⿑⿒⿓⿔⿕⿰⿱⿲⿳⿴⿵⿶⿷⿸⿹⿺⿻ 、。〃〄
+々〆〇
+〈〉《》「」『』【】〒〓〔〕〖〗〘〙〚〛〜〝〞〟〠
+〡〢〣〤〥〦〧〨〩〪〭〮〯〫〬
+〰
+〱〲〳〴〵
+〶〷
+〸〹〺〻〼
+〽〾〿
+ぁあぃいぅうぇえぉおかがきぎくぐけげこごさざしじすずせぜそぞただちぢっつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもゃやゅゆょよらりるれろゎわゐゑをんゔゕゖ゙゚
+゛゜
+ゝゞゟ
+゠
+ァアィイゥウェエォオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂッツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモャヤュユョヨラリルレロヮワヰヱヲンヴヵヶヷヸヹヺ
+・
+ーヽヾヿㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦㄧㄨㄩㄪㄫㄬㄭㄱㄲㄳㄴㄵㄶㄷㄸㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅃㅄㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣㅤㅥㅦㅧㅨㅩㅪㅫㅬㅭㅮㅯㅰㅱㅲㅳㅴㅵㅶㅷㅸㅹㅺㅻㅼㅽㅾㅿㆀㆁㆂㆃㆄㆅㆆㆇㆈㆉㆊㆋㆌㆍㆎ
+㆐㆑
+㆒㆓㆔㆕
+㆖㆗㆘㆙㆚㆛㆜㆝㆞㆟
+ㆠㆡㆢㆣㆤㆥㆦㆧㆨㆩㆪㆫㆬㆭㆮㆯㆰㆱㆲㆳㆴㆵㆶㆷ
+㇀㇁㇂㇃㇄㇅㇆㇇㇈㇉㇊㇋㇌㇍㇎㇏㇐㇑㇒㇓㇔㇕㇖㇗㇘㇙㇚㇛㇜㇝㇞㇟㇠㇡㇢㇣
+ㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿ
+㈀㈁㈂㈃㈄㈅㈆㈇㈈㈉㈊㈋㈌㈍㈎㈏㈐㈑㈒㈓㈔㈕㈖㈗㈘㈙㈚㈛㈜㈝㈞
+㈠㈡㈢㈣㈤㈥㈦㈧㈨㈩
+㈪㈫㈬㈭㈮㈯㈰㈱㈲㈳㈴㈵㈶㈷㈸㈹㈺㈻㈼㈽㈾㈿㉀㉁㉂㉃㉄㉅㉆㉇㉈㉉㉊㉋㉌㉍㉎㉏㉐
+㉑㉒㉓㉔㉕㉖㉗㉘㉙㉚㉛㉜㉝㉞㉟
+㉠㉡㉢㉣㉤㉥㉦㉧㉨㉩㉪㉫㉬㉭㉮㉯㉰㉱㉲㉳㉴㉵㉶㉷㉸㉹㉺㉻㉼㉽㉾㉿
+㊀㊁㊂㊃㊄㊅㊆㊇㊈㊉
+㊊㊋㊌㊍㊎㊏㊐㊑㊒㊓㊔㊕㊖㊗㊘㊙㊚㊛㊜㊝㊞㊟㊠㊡㊢㊣㊤㊥㊦㊧㊨㊩㊪㊫㊬㊭㊮㊯㊰
+㊱㊲㊳㊴㊵㊶㊷㊸㊹㊺㊻㊼㊽㊾㊿
+㋀㋁㋂㋃㋄㋅㋆㋇㋈㋉㋊㋋㋌㋍㋎㋏㋐㋑㋒㋓㋔㋕㋖㋗㋘㋙㋚㋛㋜㋝㋞㋟㋠㋡㋢㋣㋤㋥㋦㋧㋨㋩㋪㋫㋬㋭㋮㋯㋰㋱㋲㋳㋴㋵㋶㋷㋸㋹㋺㋻㋼㋽㋾㌀㌁㌂㌃㌄㌅㌆㌇㌈㌉㌊㌋㌌㌍㌎㌏㌐㌑㌒㌓㌔㌕㌖㌗㌘㌙㌚㌛㌜㌝㌞㌟㌠㌡㌢㌣㌤㌥㌦㌧㌨㌩㌪㌫㌬㌭㌮㌯㌰㌱㌲㌳㌴㌵㌶㌷㌸㌹㌺㌻㌼㌽㌾㌿㍀㍁㍂㍃㍄㍅㍆㍇㍈㍉㍊㍋㍌㍍㍎㍏㍐㍑㍒㍓㍔㍕㍖㍗㍘㍙㍚㍛㍜㍝㍞㍟㍠㍡㍢㍣㍤㍥㍦㍧㍨㍩㍪㍫㍬㍭㍮㍯㍰㍱㍲㍳㍴㍵㍶㍷㍸㍹㍺㍻㍼㍽㍾㍿㎀㎁㎂㎃㎄㎅㎆㎇㎈㎉㎊㎋㎌㎍㎎㎏㎐㎑㎒㎓㎔㎕㎖㎗㎘㎙㎚㎛㎜㎝㎞㎟㎠㎡㎢㎣㎤㎥㎦㎧㎨㎩㎪㎫㎬㎭㎮㎯㎰㎱㎲㎳㎴㎵㎶㎷㎸㎹㎺㎻㎼㎽㎾㎿㏀㏁㏂㏃㏄㏅㏆㏇㏈㏉㏊㏋㏌㏍㏎㏏㏐㏑㏒㏓㏔㏕㏖㏗㏘㏙㏚㏛㏜㏝㏞㏟㏠㏡㏢㏣㏤㏥㏦㏧㏨㏩㏪㏫㏬㏭㏮㏯㏰㏱㏲㏳㏴㏵㏶㏷㏸㏹㏺㏻㏼㏽㏾㏿
+㐀䶵
+䷀䷁䷂䷃䷄䷅䷆䷇䷈䷉䷊䷋䷌䷍䷎䷏䷐䷑䷒䷓䷔䷕䷖䷗䷘䷙䷚䷛䷜䷝䷞䷟䷠䷡䷢䷣䷤䷥䷦䷧䷨䷩䷪䷫䷬䷭䷮䷯䷰䷱䷲䷳䷴䷵䷶䷷䷸䷹䷺䷻䷼䷽䷾䷿
+一鿋ꀀꀁꀂꀃꀄꀅꀆꀇꀈꀉꀊꀋꀌꀍꀎꀏꀐꀑꀒꀓꀔꀕꀖꀗꀘꀙꀚꀛꀜꀝꀞꀟꀠꀡꀢꀣꀤꀥꀦꀧꀨꀩꀪꀫꀬꀭꀮꀯꀰꀱꀲꀳꀴꀵꀶꀷꀸꀹꀺꀻꀼꀽꀾꀿꁀꁁꁂꁃꁄꁅꁆꁇꁈꁉꁊꁋꁌꁍꁎꁏꁐꁑꁒꁓꁔꁕꁖꁗꁘꁙꁚꁛꁜꁝꁞꁟꁠꁡꁢꁣꁤꁥꁦꁧꁨꁩꁪꁫꁬꁭꁮꁯꁰꁱꁲꁳꁴꁵꁶꁷꁸꁹꁺꁻꁼꁽꁾꁿꂀꂁꂂꂃꂄꂅꂆꂇꂈꂉꂊꂋꂌꂍꂎꂏꂐꂑꂒꂓꂔꂕꂖꂗꂘꂙꂚꂛꂜꂝꂞꂟꂠꂡꂢꂣꂤꂥꂦꂧꂨꂩꂪꂫꂬꂭꂮꂯꂰꂱꂲꂳꂴꂵꂶꂷꂸꂹꂺꂻꂼꂽꂾꂿꃀꃁꃂꃃꃄꃅꃆꃇꃈꃉꃊꃋꃌꃍꃎꃏꃐꃑꃒꃓꃔꃕꃖꃗꃘꃙꃚꃛꃜꃝꃞꃟꃠꃡꃢꃣꃤꃥꃦꃧꃨꃩꃪꃫꃬꃭꃮꃯꃰꃱꃲꃳꃴꃵꃶꃷꃸꃹꃺꃻꃼꃽꃾꃿꄀꄁꄂꄃꄄꄅꄆꄇꄈꄉꄊꄋꄌꄍꄎꄏꄐꄑꄒꄓꄔꄕꄖꄗꄘꄙꄚꄛꄜꄝꄞꄟꄠꄡꄢꄣꄤꄥꄦꄧꄨꄩꄪꄫꄬꄭꄮꄯꄰꄱꄲꄳꄴꄵꄶꄷꄸꄹꄺꄻꄼꄽꄾꄿꅀꅁꅂꅃꅄꅅꅆꅇꅈꅉꅊꅋꅌꅍꅎꅏꅐꅑꅒꅓꅔꅕꅖꅗꅘꅙꅚꅛꅜꅝꅞꅟꅠꅡꅢꅣꅤꅥꅦꅧꅨꅩꅪꅫꅬꅭꅮꅯꅰꅱꅲꅳꅴꅵꅶꅷꅸꅹꅺꅻꅼꅽꅾꅿꆀꆁꆂꆃꆄꆅꆆꆇꆈꆉꆊꆋꆌꆍꆎꆏꆐꆑꆒꆓꆔꆕꆖꆗꆘꆙꆚꆛꆜꆝꆞꆟꆠꆡꆢꆣꆤꆥꆦꆧꆨꆩꆪꆫꆬꆭꆮꆯꆰꆱꆲꆳꆴꆵꆶꆷꆸꆹꆺꆻꆼꆽꆾꆿꇀꇁꇂꇃꇄꇅꇆꇇꇈꇉꇊꇋꇌꇍꇎꇏꇐꇑꇒꇓꇔꇕꇖꇗꇘꇙꇚꇛꇜꇝꇞꇟꇠꇡꇢꇣꇤꇥꇦꇧꇨꇩꇪꇫꇬꇭꇮꇯꇰꇱꇲꇳꇴꇵꇶꇷꇸꇹꇺꇻꇼꇽꇾꇿꈀꈁꈂꈃꈄꈅꈆꈇꈈꈉꈊꈋꈌꈍꈎꈏꈐꈑꈒꈓꈔꈕꈖꈗꈘꈙꈚꈛꈜꈝꈞꈟꈠꈡꈢꈣꈤꈥꈦꈧꈨꈩꈪꈫꈬꈭꈮꈯꈰꈱꈲꈳꈴꈵꈶꈷꈸꈹꈺꈻꈼꈽꈾꈿꉀꉁꉂꉃꉄꉅꉆꉇꉈꉉꉊꉋꉌꉍꉎꉏꉐꉑꉒꉓꉔꉕꉖꉗꉘꉙꉚꉛꉜꉝꉞꉟꉠꉡꉢꉣꉤꉥꉦꉧꉨꉩꉪꉫꉬꉭꉮꉯꉰꉱꉲꉳꉴꉵꉶꉷꉸꉹꉺꉻꉼꉽꉾꉿꊀꊁꊂꊃꊄꊅꊆꊇꊈꊉꊊꊋꊌꊍꊎꊏꊐꊑꊒꊓꊔꊕꊖꊗꊘꊙꊚꊛꊜꊝꊞꊟꊠꊡꊢꊣꊤꊥꊦꊧꊨꊩꊪꊫꊬꊭꊮꊯꊰꊱꊲꊳꊴꊵꊶꊷꊸꊹꊺꊻꊼꊽꊾꊿꋀꋁꋂꋃꋄꋅꋆꋇꋈꋉꋊꋋꋌꋍꋎꋏꋐꋑꋒꋓꋔꋕꋖꋗꋘꋙꋚꋛꋜꋝꋞꋟꋠꋡꋢꋣꋤꋥꋦꋧꋨꋩꋪꋫꋬꋭꋮꋯꋰꋱꋲꋳꋴꋵꋶꋷꋸꋹꋺꋻꋼꋽꋾꋿꌀꌁꌂꌃꌄꌅꌆꌇꌈꌉꌊꌋꌌꌍꌎꌏꌐꌑꌒꌓꌔꌕꌖꌗꌘꌙꌚꌛꌜꌝꌞꌟꌠꌡꌢꌣꌤꌥꌦꌧꌨꌩꌪꌫꌬꌭꌮꌯꌰꌱꌲꌳꌴꌵꌶꌷꌸꌹꌺꌻꌼꌽꌾꌿꍀꍁꍂꍃꍄꍅꍆꍇꍈꍉꍊꍋꍌꍍꍎꍏꍐꍑꍒꍓꍔꍕꍖꍗꍘꍙꍚꍛꍜꍝꍞꍟꍠꍡꍢꍣꍤꍥꍦꍧꍨꍩꍪꍫꍬꍭꍮꍯꍰꍱꍲꍳꍴꍵꍶꍷꍸꍹꍺꍻꍼꍽꍾꍿꎀꎁꎂꎃꎄꎅꎆꎇꎈꎉꎊꎋꎌꎍꎎꎏꎐꎑꎒꎓꎔꎕꎖꎗꎘꎙꎚꎛꎜꎝꎞꎟꎠꎡꎢꎣꎤꎥꎦꎧꎨꎩꎪꎫꎬꎭꎮꎯꎰꎱꎲꎳꎴꎵꎶꎷꎸꎹꎺꎻꎼꎽꎾꎿꏀꏁꏂꏃꏄꏅꏆꏇꏈꏉꏊꏋꏌꏍꏎꏏꏐꏑꏒꏓꏔꏕꏖꏗꏘꏙꏚꏛꏜꏝꏞꏟꏠꏡꏢꏣꏤꏥꏦꏧꏨꏩꏪꏫꏬꏭꏮꏯꏰꏱꏲꏳꏴꏵꏶꏷꏸꏹꏺꏻꏼꏽꏾꏿꐀꐁꐂꐃꐄꐅꐆꐇꐈꐉꐊꐋꐌꐍꐎꐏꐐꐑꐒꐓꐔꐕꐖꐗꐘꐙꐚꐛꐜꐝꐞꐟꐠꐡꐢꐣꐤꐥꐦꐧꐨꐩꐪꐫꐬꐭꐮꐯꐰꐱꐲꐳꐴꐵꐶꐷꐸꐹꐺꐻꐼꐽꐾꐿꑀꑁꑂꑃꑄꑅꑆꑇꑈꑉꑊꑋꑌꑍꑎꑏꑐꑑꑒꑓꑔꑕꑖꑗꑘꑙꑚꑛꑜꑝꑞꑟꑠꑡꑢꑣꑤꑥꑦꑧꑨꑩꑪꑫꑬꑭꑮꑯꑰꑱꑲꑳꑴꑵꑶꑷꑸꑹꑺꑻꑼꑽꑾꑿꒀꒁꒂꒃꒄꒅꒆꒇꒈꒉꒊꒋꒌ
+꒐꒑꒒꒓꒔꒕꒖꒗꒘꒙꒚꒛꒜꒝꒞꒟꒠꒡꒢꒣꒤꒥꒦꒧꒨꒩꒪꒫꒬꒭꒮꒯꒰꒱꒲꒳꒴꒵꒶꒷꒸꒹꒺꒻꒼꒽꒾꒿꓀꓁꓂꓃꓄꓅꓆
+ꓐꓑꓒꓓꓔꓕꓖꓗꓘꓙꓚꓛꓜꓝꓞꓟꓠꓡꓢꓣꓤꓥꓦꓧꓨꓩꓪꓫꓬꓭꓮꓯꓰꓱꓲꓳꓴꓵꓶꓷꓸꓹꓺꓻꓼꓽ
+꓾꓿
+ꔀꔁꔂꔃꔄꔅꔆꔇꔈꔉꔊꔋꔌꔍꔎꔏꔐꔑꔒꔓꔔꔕꔖꔗꔘꔙꔚꔛꔜꔝꔞꔟꔠꔡꔢꔣꔤꔥꔦꔧꔨꔩꔪꔫꔬꔭꔮꔯꔰꔱꔲꔳꔴꔵꔶꔷꔸꔹꔺꔻꔼꔽꔾꔿꕀꕁꕂꕃꕄꕅꕆꕇꕈꕉꕊꕋꕌꕍꕎꕏꕐꕑꕒꕓꕔꕕꕖꕗꕘꕙꕚꕛꕜꕝꕞꕟꕠꕡꕢꕣꕤꕥꕦꕧꕨꕩꕪꕫꕬꕭꕮꕯꕰꕱꕲꕳꕴꕵꕶꕷꕸꕹꕺꕻꕼꕽꕾꕿꖀꖁꖂꖃꖄꖅꖆꖇꖈꖉꖊꖋꖌꖍꖎꖏꖐꖑꖒꖓꖔꖕꖖꖗꖘꖙꖚꖛꖜꖝꖞꖟꖠꖡꖢꖣꖤꖥꖦꖧꖨꖩꖪꖫꖬꖭꖮꖯꖰꖱꖲꖳꖴꖵꖶꖷꖸꖹꖺꖻꖼꖽꖾꖿꗀꗁꗂꗃꗄꗅꗆꗇꗈꗉꗊꗋꗌꗍꗎꗏꗐꗑꗒꗓꗔꗕꗖꗗꗘꗙꗚꗛꗜꗝꗞꗟꗠꗡꗢꗣꗤꗥꗦꗧꗨꗩꗪꗫꗬꗭꗮꗯꗰꗱꗲꗳꗴꗵꗶꗷꗸꗹꗺꗻꗼꗽꗾꗿꘀꘁꘂꘃꘄꘅꘆꘇꘈꘉꘊꘋꘌ
+꘍꘎꘏
+ꘐꘑꘒꘓꘔꘕꘖꘗꘘꘙꘚꘛꘜꘝꘞꘟ꘠꘡꘢꘣꘤꘥꘦꘧꘨꘩ꘪꘫꙀꙁꙂꙃꙄꙅꙆꙇꙈꙉꙊꙋꙌꙍꙎꙏꙐꙑꙒꙓꙔꙕꙖꙗꙘꙙꙚꙛꙜꙝꙞꙟꙢꙣꙤꙥꙦꙧꙨꙩꙪꙫꙬꙭꙮ꙯꙰꙱꙲
+꙳
+꙼꙽
+꙾
+ꙿꚀꚁꚂꚃꚄꚅꚆꚇꚈꚉꚊꚋꚌꚍꚎꚏꚐꚑꚒꚓꚔꚕꚖꚗꚠꚡꚢꚣꚤꚥꚦꚧꚨꚩꚪꚫꚬꚭꚮꚯꚰꚱꚲꚳꚴꚵꚶꚷꚸꚹꚺꚻꚼꚽꚾꚿꛀꛁꛂꛃꛄꛅꛆꛇꛈꛉꛊꛋꛌꛍꛎꛏꛐꛑꛒꛓꛔꛕꛖꛗꛘꛙꛚꛛꛜꛝꛞꛟꛠꛡꛢꛣꛤꛥꛦꛧꛨꛩꛪꛫꛬꛭꛮꛯ꛰꛱
+꛲꛳꛴꛵꛶꛷꜀꜁꜂꜃꜄꜅꜆꜇꜈꜉꜊꜋꜌꜍꜎꜏꜐꜑꜒꜓꜔꜕꜖
+ꜗꜘꜙꜚꜛꜜꜝꜞꜟ
+꜠꜡
+ꜢꜣꜤꜥꜦꜧꜨꜩꜪꜫꜬꜭꜮꜯꜰꜱꜲꜳꜴꜵꜶꜷꜸꜹꜺꜻꜼꜽꜾꜿꝀꝁꝂꝃꝄꝅꝆꝇꝈꝉꝊꝋꝌꝍꝎꝏꝐꝑꝒꝓꝔꝕꝖꝗꝘꝙꝚꝛꝜꝝꝞꝟꝠꝡꝢꝣꝤꝥꝦꝧꝨꝩꝪꝫꝬꝭꝮꝯꝰꝱꝲꝳꝴꝵꝶꝷꝸꝹꝺꝻꝼꝽꝾꝿꞀꞁꞂꞃꞄꞅꞆꞇꞈ
+꞉꞊
+Ꞌꞌꟻꟼꟽꟾꟿꠀꠁꠂꠃꠄꠅ꠆ꠇꠈꠉꠊꠋꠌꠍꠎꠏꠐꠑꠒꠓꠔꠕꠖꠗꠘꠙꠚꠛꠜꠝꠞꠟꠠꠡꠢꠣꠤꠥꠦꠧ
+꠨꠩꠪꠫
+꠰꠱꠲꠳꠴꠵
+꠶꠷꠸꠹
+ꡀꡁꡂꡃꡄꡅꡆꡇꡈꡉꡊꡋꡌꡍꡎꡏꡐꡑꡒꡓꡔꡕꡖꡗꡘꡙꡚꡛꡜꡝꡞꡟꡠꡡꡢꡣꡤꡥꡦꡧꡨꡩꡪꡫꡬꡭꡮꡯꡰꡱꡲꡳ
+꡴꡵꡶꡷
+ꢀꢁꢂꢃꢄꢅꢆꢇꢈꢉꢊꢋꢌꢍꢎꢏꢐꢑꢒꢓꢔꢕꢖꢗꢘꢙꢚꢛꢜꢝꢞꢟꢠꢡꢢꢣꢤꢥꢦꢧꢨꢩꢪꢫꢬꢭꢮꢯꢰꢱꢲꢳꢴꢵꢶꢷꢸꢹꢺꢻꢼꢽꢾꢿꣀꣁꣂꣃ꣄
+꣎꣏
+꣐꣑꣒꣓꣔꣕꣖꣗꣘꣙꣠꣡꣢꣣꣤꣥꣦꣧꣨꣩꣪꣫꣬꣭꣮꣯꣰꣱ꣲꣳꣴꣵꣶꣷ
+꣸꣹꣺
+ꣻ꤀꤁꤂꤃꤄꤅꤆꤇꤈꤉ꤊꤋꤌꤍꤎꤏꤐꤑꤒꤓꤔꤕꤖꤗꤘꤙꤚꤛꤜꤝꤞꤟꤠꤡꤢꤣꤤꤥꤦꤧꤨꤩꤪ꤫꤬꤭
+꤮꤯
+ꤰꤱꤲꤳꤴꤵꤶꤷꤸꤹꤺꤻꤼꤽꤾꤿꥀꥁꥂꥃꥄꥅꥆꥇꥈꥉꥊꥋꥌꥍꥎꥏꥐꥑꥒ꥓
+꥟
+ꥠꥡꥢꥣꥤꥥꥦꥧꥨꥩꥪꥫꥬꥭꥮꥯꥰꥱꥲꥳꥴꥵꥶꥷꥸꥹꥺꥻꥼꦀꦁꦂꦃꦄꦅꦆꦇꦈꦉꦊꦋꦌꦍꦎꦏꦐꦑꦒꦓꦔꦕꦖꦗꦘꦙꦚꦛꦜꦝꦞꦟꦠꦡꦢꦣꦤꦥꦦꦧꦨꦩꦪꦫꦬꦭꦮꦯꦰꦱꦲ꦳ꦴꦵꦶꦷꦸꦹꦺꦻꦼꦽꦾꦿ꧀
+꧁꧂꧃꧄꧅꧆꧇꧈꧉꧊꧋꧌꧍
+ꧏ꧐꧑꧒꧓꧔꧕꧖꧗꧘꧙
+꧞꧟
+ꨀꨁꨂꨃꨄꨅꨆꨇꨈꨉꨊꨋꨌꨍꨎꨏꨐꨑꨒꨓꨔꨕꨖꨗꨘꨙꨚꨛꨜꨝꨞꨟꨠꨡꨢꨣꨤꨥꨦꨧꨨꨩꨪꨫꨬꨭꨮꨯꨰꨱꨲꨳꨴꨵꨶꩀꩁꩂꩃꩄꩅꩆꩇꩈꩉꩊꩋꩌꩍ꩐꩑꩒꩓꩔꩕꩖꩗꩘꩙
+꩜꩝꩞꩟
+ꩠꩡꩢꩣꩤꩥꩦꩧꩨꩩꩪꩫꩬꩭꩮꩯꩰꩱꩲꩳꩴꩵꩶ
+꩷꩸꩹
+ꩺꩻꪀꪁꪂꪃꪄꪅꪆꪇꪈꪉꪊꪋꪌꪍꪎꪏꪐꪑꪒꪓꪔꪕꪖꪗꪘꪙꪚꪛꪜꪝꪞꪟꪠꪡꪢꪣꪤꪥꪦꪧꪨꪩꪪꪫꪬꪭꪮꪯꪰꪱꪴꪲꪳꪵꪶꪷꪸꪹꪺꪻꪼꪽꪾ꪿ꫀ꫁ꫂꫛꫜꫝ
+꫞꫟
+ꯀꯁꯂꯃꯄꯅꯆꯇꯈꯉꯊꯋꯌꯍꯎꯏꯐꯑꯒꯓꯔꯕꯖꯗꯘꯙꯚꯛꯜꯝꯞꯟꯠꯡꯢꯣꯤꯥꯦꯧꯨꯩꯪ
+꯫
+꯬꯭꯰꯱꯲꯳꯴꯵꯶꯷꯸꯹가힣ힰힱힲힳힴힵힶힷힸힹힺힻힼힽힾힿퟀퟁퟂퟃퟄퟅퟆퟋퟌퟍퟎퟏퟐퟑퟒퟓퟔퟕퟖퟗퟘퟙퟚퟛퟜퟝퟞퟟퟠퟡퟢퟣퟤퟥퟦퟧퟨퟩퟪퟫퟬퟭퟮퟯퟰퟱퟲퟳퟴퟵퟶퟷퟸퟹퟺퟻ
+
+豈更車賈滑串句龜龜契金喇奈懶癩羅蘿螺裸邏樂洛烙珞落酪駱亂卵欄爛蘭鸞嵐濫藍襤拉臘蠟廊朗浪狼郎來冷勞擄櫓爐盧老蘆虜路露魯鷺碌祿綠菉錄鹿論壟弄籠聾牢磊賂雷壘屢樓淚漏累縷陋勒肋凜凌稜綾菱陵讀拏樂諾丹寧怒率異北磻便復不泌數索參塞省葉說殺辰沈拾若掠略亮兩凉梁糧良諒量勵呂女廬旅濾礪閭驪麗黎力曆歷轢年憐戀撚漣煉璉秊練聯輦蓮連鍊列劣咽烈裂說廉念捻殮簾獵令囹寧嶺怜玲瑩羚聆鈴零靈領例禮醴隸惡了僚寮尿料樂燎療蓼遼龍暈阮劉杻柳流溜琉留硫紐類六戮陸倫崙淪輪律慄栗率隆利吏履易李梨泥理痢罹裏裡里離匿溺吝燐璘藺隣鱗麟林淋臨立笠粒狀炙識什茶刺切度拓糖宅洞暴輻行降見廓兀嗀﨎﨏塚﨑晴﨓﨔凞猪益礼神祥福靖精羽﨟蘒﨡諸﨣﨤逸都﨧﨨﨩飯飼館鶴侮僧免勉勤卑喝嘆器塀墨層屮悔慨憎懲敏既暑梅海渚漢煮爫琢碑社祉祈祐祖祝禍禎穀突節練縉繁署者臭艹艹著褐視謁謹賓贈辶逸難響頻恵𤋮舘並况全侀充冀勇勺喝啕喙嗢塚墳奄奔婢嬨廒廙彩徭惘慎愈憎慠懲戴揄搜摒敖晴朗望杖歹殺流滛滋漢瀞煮瞧爵犯猪瑱甆画瘝瘟益盛直睊着磌窱節类絛練缾者荒華蝹襁覆視調諸請謁諾諭謹變贈輸遲醙鉶陼難靖韛響頋頻鬒龜𢡊𢡄𣏕㮝䀘䀹𥉉𥳐𧻓齃龎fffiflffifflſtstﬓﬔﬕﬖﬗיִﬞײַﬠﬡﬢﬣﬤﬥﬦﬧﬨ
+﬩
+שׁשׂשּׁשּׂאַאָאּבּגּדּהּוּזּטּיּךּכּלּמּנּסּףּפּצּקּרּשּתּוֹבֿכֿפֿﭏﭐﭑﭒﭓﭔﭕﭖﭗﭘﭙﭚﭛﭜﭝﭞﭟﭠﭡﭢﭣﭤﭥﭦﭧﭨﭩﭪﭫﭬﭭﭮﭯﭰﭱﭲﭳﭴﭵﭶﭷﭸﭹﭺﭻﭼﭽﭾﭿﮀﮁﮂﮃﮄﮅﮆﮇﮈﮉﮊﮋﮌﮍﮎﮏﮐﮑﮒﮓﮔﮕﮖﮗﮘﮙﮚﮛﮜﮝﮞﮟﮠﮡﮢﮣﮤﮥﮦﮧﮨﮩﮪﮫﮬﮭﮮﮯﮰﮱﯓﯔﯕﯖﯗﯘﯙﯚﯛﯜﯝﯞﯟﯠﯡﯢﯣﯤﯥﯦﯧﯨﯩﯪﯫﯬﯭﯮﯯﯰﯱﯲﯳﯴﯵﯶﯷﯸﯹﯺﯻﯼﯽﯾﯿﰀﰁﰂﰃﰄﰅﰆﰇﰈﰉﰊﰋﰌﰍﰎﰏﰐﰑﰒﰓﰔﰕﰖﰗﰘﰙﰚﰛﰜﰝﰞﰟﰠﰡﰢﰣﰤﰥﰦﰧﰨﰩﰪﰫﰬﰭﰮﰯﰰﰱﰲﰳﰴﰵﰶﰷﰸﰹﰺﰻﰼﰽﰾﰿﱀﱁﱂﱃﱄﱅﱆﱇﱈﱉﱊﱋﱌﱍﱎﱏﱐﱑﱒﱓﱔﱕﱖﱗﱘﱙﱚﱛﱜﱝﱞﱟﱠﱡﱢﱣﱤﱥﱦﱧﱨﱩﱪﱫﱬﱭﱮﱯﱰﱱﱲﱳﱴﱵﱶﱷﱸﱹﱺﱻﱼﱽﱾﱿﲀﲁﲂﲃﲄﲅﲆﲇﲈﲉﲊﲋﲌﲍﲎﲏﲐﲑﲒﲓﲔﲕﲖﲗﲘﲙﲚﲛﲜﲝﲞﲟﲠﲡﲢﲣﲤﲥﲦﲧﲨﲩﲪﲫﲬﲭﲮﲯﲰﲱﲲﲳﲴﲵﲶﲷﲸﲹﲺﲻﲼﲽﲾﲿﳀﳁﳂﳃﳄﳅﳆﳇﳈﳉﳊﳋﳌﳍﳎﳏﳐﳑﳒﳓﳔﳕﳖﳗﳘﳙﳚﳛﳜﳝﳞﳟﳠﳡﳢﳣﳤﳥﳦﳧﳨﳩﳪﳫﳬﳭﳮﳯﳰﳱﳲﳳﳴﳵﳶﳷﳸﳹﳺﳻﳼﳽﳾﳿﴀﴁﴂﴃﴄﴅﴆﴇﴈﴉﴊﴋﴌﴍﴎﴏﴐﴑﴒﴓﴔﴕﴖﴗﴘﴙﴚﴛﴜﴝﴞﴟﴠﴡﴢﴣﴤﴥﴦﴧﴨﴩﴪﴫﴬﴭﴮﴯﴰﴱﴲﴳﴴﴵﴶﴷﴸﴹﴺﴻﴼﴽ
+﴾﴿
+ﵐﵑﵒﵓﵔﵕﵖﵗﵘﵙﵚﵛﵜﵝﵞﵟﵠﵡﵢﵣﵤﵥﵦﵧﵨﵩﵪﵫﵬﵭﵮﵯﵰﵱﵲﵳﵴﵵﵶﵷﵸﵹﵺﵻﵼﵽﵾﵿﶀﶁﶂﶃﶄﶅﶆﶇﶈﶉﶊﶋﶌﶍﶎﶏﶒﶓﶔﶕﶖﶗﶘﶙﶚﶛﶜﶝﶞﶟﶠﶡﶢﶣﶤﶥﶦﶧﶨﶩﶪﶫﶬﶭﶮﶯﶰﶱﶲﶳﶴﶵﶶﶷﶸﶹﶺﶻﶼﶽﶾﶿﷀﷁﷂﷃﷄﷅﷆﷇﷰﷱﷲﷳﷴﷵﷶﷷﷸﷹﷺﷻ
+﷼﷽
+︀︁︂︃︄︅︆︇︈︉︊︋︌︍︎️
+︐︑︒︓︔︕︖︗︘︙
+︠︡︢︣︤︥︦
+︰︱︲︳︴︵︶︷︸︹︺︻︼︽︾︿﹀﹁﹂﹃﹄﹅﹆﹇﹈﹉﹊﹋﹌﹍﹎﹏﹐﹑﹒﹔﹕﹖﹗﹘﹙﹚﹛﹜﹝﹞﹟﹠﹡﹢﹣﹤﹥﹦﹨﹩﹪﹫
+ﹰﹱﹲﹳﹴﹶﹷﹸﹹﹺﹻﹼﹽﹾﹿﺀﺁﺂﺃﺄﺅﺆﺇﺈﺉﺊﺋﺌﺍﺎﺏﺐﺑﺒﺓﺔﺕﺖﺗﺘﺙﺚﺛﺜﺝﺞﺟﺠﺡﺢﺣﺤﺥﺦﺧﺨﺩﺪﺫﺬﺭﺮﺯﺰﺱﺲﺳﺴﺵﺶﺷﺸﺹﺺﺻﺼﺽﺾﺿﻀﻁﻂﻃﻄﻅﻆﻇﻈﻉﻊﻋﻌﻍﻎﻏﻐﻑﻒﻓﻔﻕﻖﻗﻘﻙﻚﻛﻜﻝﻞﻟﻠﻡﻢﻣﻤﻥﻦﻧﻨﻩﻪﻫﻬﻭﻮﻯﻰﻱﻲﻳﻴﻵﻶﻷﻸﻹﻺﻻﻼ
+!"#$%&'()*+,-./
+0123456789
+:;<=>?@
+ABCDEFGHIJKLMNOPQRSTUVWXYZ
+[\]^_`
+abcdefghijklmnopqrstuvwxyz
+{|}~⦅⦆。「」、・
+ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン゙゚ᅠᄀᄁᆪᄂᆬᆭᄃᄄᄅᆰᆱᆲᆳᆴᆵᄚᄆᄇᄈᄡᄉᄊᄋᄌᄍᄎᄏᄐᄑ하ᅢᅣᅤᅥᅦᅧᅨᅩᅪᅫᅬᅭᅮᅯᅰᅱᅲᅳᅴᅵ
+¢£¬ ̄¦¥₩│←↑→↓■○�
+𐀀 \ No newline at end of file
diff --git a/core/modules/search/tests/search_embedded_form.info b/core/modules/search/tests/search_embedded_form.info
new file mode 100644
index 000000000000..2dad9eeda08b
--- /dev/null
+++ b/core/modules/search/tests/search_embedded_form.info
@@ -0,0 +1,6 @@
+name = "Search embedded form"
+description = "Support module for search module testing of embedded forms."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/search/tests/search_embedded_form.module b/core/modules/search/tests/search_embedded_form.module
new file mode 100644
index 000000000000..484579674576
--- /dev/null
+++ b/core/modules/search/tests/search_embedded_form.module
@@ -0,0 +1,70 @@
+<?php
+
+/**
+ * @file
+ * Test module implementing a form that can be embedded in search results.
+ *
+ * Embedded form are important, for example, for ecommerce sites where each
+ * search result may included an embedded form with buttons like "Add to cart"
+ * for each individual product (node) listed in the search results.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function search_embedded_form_menu() {
+ $items['search_embedded_form'] = array(
+ 'title' => 'Search_Embed_Form',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('search_embedded_form_form'),
+ 'access arguments' => array('search content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Builds a form for embedding in search results for testing.
+ *
+ * @see search_embedded_form_form_submit().
+ */
+function search_embedded_form_form($form, &$form_state) {
+ $count = variable_get('search_embedded_form_submitted', 0);
+
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Your name'),
+ '#maxlength' => 255,
+ '#default_value' => '',
+ '#required' => TRUE,
+ '#description' => t('Times form has been submitted: %count', array('%count' => $count)),
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Send away'),
+ );
+
+ $form['#submit'][] = 'search_embedded_form_form_submit';
+
+ return $form;
+}
+
+/**
+ * Submit handler for search_embedded_form_form().
+ */
+function search_embedded_form_form_submit($form, &$form_state) {
+ $count = variable_get('search_embedded_form_submitted', 0) + 1;
+ variable_set('search_embedded_form_submitted', $count);
+ drupal_set_message(t('Test form was submitted'));
+}
+
+/**
+ * Adds the test form to search results.
+ */
+function search_embedded_form_preprocess_search_result(&$variables) {
+ $form = drupal_get_form('search_embedded_form_form');
+ $variables['snippet'] .= drupal_render($form);
+}
diff --git a/core/modules/search/tests/search_extra_type.info b/core/modules/search/tests/search_extra_type.info
new file mode 100644
index 000000000000..23f4dea93138
--- /dev/null
+++ b/core/modules/search/tests/search_extra_type.info
@@ -0,0 +1,6 @@
+name = "Test search type"
+description = "Support module for search module testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/search/tests/search_extra_type.module b/core/modules/search/tests/search_extra_type.module
new file mode 100644
index 000000000000..80c050c21f79
--- /dev/null
+++ b/core/modules/search/tests/search_extra_type.module
@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing a search type for search module testing.
+ */
+
+/**
+ * Implements hook_search_info().
+ */
+function search_extra_type_search_info() {
+ return array(
+ 'title' => 'Dummy search type',
+ 'path' => 'dummy_path',
+ 'conditions_callback' => 'search_extra_type_conditions',
+ );
+}
+
+/**
+ * Test conditions callback for hook_search_info().
+ */
+function search_extra_type_conditions() {
+ $conditions = array();
+
+ if (!empty($_REQUEST['search_conditions'])) {
+ $conditions['search_conditions'] = $_REQUEST['search_conditions'];
+ }
+ return $conditions;
+}
+
+/**
+ * Implements hook_search_execute().
+ *
+ * This is a dummy search, so when search "executes", we just return a dummy
+ * result containing the keywords and a list of conditions.
+ */
+function search_extra_type_search_execute($keys = NULL, $conditions = NULL) {
+ if (!$keys) {
+ $keys = '';
+ }
+ return array(
+ array(
+ 'link' => url('node'),
+ 'type' => 'Dummy result type',
+ 'title' => 'Dummy title',
+ 'snippet' => "Dummy search snippet to display. Keywords: {$keys}\n\nConditions: " . print_r($conditions, TRUE),
+ ),
+ );
+}
+
+/**
+ * Implements hook_search_page().
+ *
+ * Adds some text to the search page so we can verify that it runs.
+ */
+function search_extra_type_search_page($results) {
+ $output['prefix']['#markup'] = '<h2>Test page text is here</h2> <ol class="search-results">';
+
+ foreach ($results as $entry) {
+ $output[] = array(
+ '#theme' => 'search_result',
+ '#result' => $entry,
+ '#module' => 'search_extra_type',
+ );
+ }
+ $output['suffix']['#markup'] = '</ol>' . theme('pager');
+
+ return $output;
+}
diff --git a/core/modules/shortcut/shortcut-rtl.css b/core/modules/shortcut/shortcut-rtl.css
new file mode 100644
index 000000000000..5dec95701fb5
--- /dev/null
+++ b/core/modules/shortcut/shortcut-rtl.css
@@ -0,0 +1,48 @@
+
+div#toolbar a#edit-shortcuts {
+ position: absolute;
+ left: 0;
+ top: 0;
+ padding: 5px 5px 5px 10px;
+}
+div#toolbar div.toolbar-shortcuts ul {
+ float: none;
+ margin-right: 5px;
+ margin-left: 10em;
+}
+div#toolbar div.toolbar-shortcuts ul li a {
+ margin-left: 5px;
+ margin-right: 0;
+ padding: 0 5px;
+}
+div#toolbar div.toolbar-shortcuts span.icon {
+ float: right;
+}
+div.add-or-remove-shortcuts a span.icon {
+ float: right;
+ margin-right: 8px;
+ margin-left: 0;
+}
+div.add-or-remove-shortcuts a span.text {
+ float: right;
+ padding-right: 10px;
+ padding-left: 0;
+}
+div.add-or-remove-shortcuts a:focus span.text,
+div.add-or-remove-shortcuts a:hover span.text {
+ -moz-border-radius: 5px 0 0 5px;
+ -webkit-border-top-left-radius: 5px;
+ -webkit-border-bottom-left-radius: 5px;
+ border-radius: 5px 0 0 5px;
+ padding-left: 6px;
+}
+#shortcut-set-switch .form-item-new {
+ padding-right: 17px;
+ padding-left: 0;
+}
+div.add-shortcut a:hover span.icon {
+ background-position: 0 -24px;
+}
+div.remove-shortcut a:hover span.icon {
+ background-position: -12px -24px;
+}
diff --git a/core/modules/shortcut/shortcut.admin.css b/core/modules/shortcut/shortcut.admin.css
new file mode 100644
index 000000000000..8ca03be87358
--- /dev/null
+++ b/core/modules/shortcut/shortcut.admin.css
@@ -0,0 +1,8 @@
+
+.shortcut-slot-hidden {
+ display: none;
+}
+
+div.form-item-set div.form-item-new {
+ display: inline;
+}
diff --git a/core/modules/shortcut/shortcut.admin.inc b/core/modules/shortcut/shortcut.admin.inc
new file mode 100644
index 000000000000..75c12b404d69
--- /dev/null
+++ b/core/modules/shortcut/shortcut.admin.inc
@@ -0,0 +1,779 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for the shortcut module.
+ */
+
+/**
+ * Returns the maximum number of shortcut "slots" available per shortcut set.
+ *
+ * This is used as a limitation in the user interface only.
+ *
+ * @return
+ * The maximum number of shortcuts allowed to be added to a shortcut set.
+ */
+function shortcut_max_slots() {
+ return variable_get('shortcut_max_slots', 7);
+}
+
+/**
+ * Form callback: builds the form for switching shortcut sets.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $account
+ * (optional) The user account whose shortcuts will be switched. Defaults to
+ * the current logged-in user.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_set_switch_validate()
+ * @see shortcut_set_switch_submit()
+ */
+function shortcut_set_switch($form, &$form_state, $account = NULL) {
+ global $user;
+ if (!isset($account)) {
+ $account = $user;
+ }
+
+ // Prepare the list of shortcut sets.
+ $sets = shortcut_sets();
+ $current_set = shortcut_current_displayed_set($account);
+
+ $options = array();
+ foreach ($sets as $name => $set) {
+ $options[$name] = check_plain($set->title);
+ }
+
+ // Only administrators can add shortcut sets.
+ $add_access = user_access('administer shortcuts');
+ if ($add_access) {
+ $options['new'] = t('New set');
+ }
+
+ if (count($options) > 1) {
+ $form['account'] = array(
+ '#type' => 'value',
+ '#value' => $account,
+ );
+
+ $form['set'] = array(
+ '#type' => 'radios',
+ '#title' => $user->uid == $account->uid ? t('Choose a set of shortcuts to use') : t('Choose a set of shortcuts for this user'),
+ '#options' => $options,
+ '#default_value' => $current_set->set_name,
+ );
+
+ $form['new'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#title_display' => 'invisible',
+ '#description' => t('The new set is created by copying items from your default shortcut set.'),
+ '#access' => $add_access,
+ );
+
+ if ($user->uid != $account->uid) {
+ $default_set = shortcut_default_set($account);
+ $form['new']['#description'] = t('The new set is created by copying items from the %default set.', array('%default' => $default_set->title));
+ }
+
+ $form['#attached'] = array(
+ 'css' => array(drupal_get_path('module', 'shortcut') . '/shortcut.admin.css'),
+ 'js' => array(drupal_get_path('module', 'shortcut') . '/shortcut.admin.js'),
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Change set'),
+ );
+ }
+ else {
+ // There is only 1 option, so output a message in the $form array.
+ $form['info'] = array(
+ '#markup' => '<p>' . t('You are currently using the %set-name shortcut set.', array('%set-name' => $current_set->title)) . '</p>',
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Validation handler for shortcut_set_switch().
+ */
+function shortcut_set_switch_validate($form, &$form_state) {
+ if ($form_state['values']['set'] == 'new') {
+ // Check to prevent creating a shortcut set with an empty title.
+ if (trim($form_state['values']['new']) == '') {
+ form_set_error('new', t('The new set name is required.'));
+ }
+ // Check to prevent a duplicate title.
+ if (shortcut_set_title_exists($form_state['values']['new'])) {
+ form_set_error('new', t('The shortcut set %name already exists. Choose another name.', array('%name' => $form_state['values']['new'])));
+ }
+ }
+}
+
+/**
+ * Submit handler for shortcut_set_switch().
+ */
+function shortcut_set_switch_submit($form, &$form_state) {
+ global $user;
+ $account = $form_state['values']['account'];
+
+ if ($form_state['values']['set'] == 'new') {
+ // Save a new shortcut set with links copied from the user's default set.
+ $default_set = shortcut_default_set($account);
+ $set = (object) array(
+ 'title' => $form_state['values']['new'],
+ 'links' => menu_links_clone($default_set->links),
+ );
+ shortcut_set_save($set);
+ $replacements = array(
+ '%user' => $account->name,
+ '%set_name' => $set->title,
+ '@switch-url' => url(current_path()),
+ );
+ if ($account->uid == $user->uid) {
+ // Only administrators can create new shortcut sets, so we know they have
+ // access to switch back.
+ drupal_set_message(t('You are now using the new %set_name shortcut set. You can edit it from this page or <a href="@switch-url">switch back to a different one.</a>', $replacements));
+ }
+ else {
+ drupal_set_message(t('%user is now using a new shortcut set called %set_name. You can edit it from this page.', $replacements));
+ }
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut/' . $set->set_name;
+ }
+ else {
+ // Switch to a different shortcut set.
+ $set = shortcut_set_load($form_state['values']['set']);
+ $replacements = array(
+ '%user' => $account->name,
+ '%set_name' => $set->title,
+ );
+ drupal_set_message($account->uid == $user->uid ? t('You are now using the %set_name shortcut set.', $replacements) : t('%user is now using the %set_name shortcut set.', $replacements));
+ }
+
+ // Assign the shortcut set to the provided user account.
+ shortcut_set_assign_user($set, $account);
+}
+
+/**
+ * Menu page callback: builds the page for administering shortcut sets.
+ */
+function shortcut_set_admin() {
+ $shortcut_sets = shortcut_sets();
+ $header = array(t('Name'), array('data' => t('Operations'), 'colspan' => 4));
+
+ $rows = array();
+ foreach ($shortcut_sets as $set) {
+ $row = array(
+ check_plain($set->title),
+ l(t('list links'), "admin/config/user-interface/shortcut/$set->set_name"),
+ l(t('edit set name'), "admin/config/user-interface/shortcut/$set->set_name/edit"),
+ );
+ if (shortcut_set_delete_access($set)) {
+ $row[] = l(t('delete set'), "admin/config/user-interface/shortcut/$set->set_name/delete");
+ }
+ else {
+ $row[] = '';
+ }
+
+ $rows[] = $row;
+ }
+
+ return theme('table', array('header' => $header, 'rows' => $rows));
+}
+
+/**
+ * Form callback: builds the form for adding a shortcut set.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_set_add_form_validate()
+ * @see shortcut_set_add_form_submit()
+ */
+function shortcut_set_add_form($form, &$form_state) {
+ $form['new'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Set name'),
+ '#description' => t('The new set is created by copying items from your default shortcut set.'),
+ '#required' => TRUE,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create new set'),
+ );
+
+ return $form;
+}
+
+/**
+ * Validation handler for shortcut_set_add_form().
+ */
+function shortcut_set_add_form_validate($form, &$form_state) {
+ // Check to prevent a duplicate title.
+ if (shortcut_set_title_exists($form_state['values']['new'])) {
+ form_set_error('new', t('The shortcut set %name already exists. Choose another name.', array('%name' => $form_state['values']['new'])));
+ }
+}
+
+/**
+ * Submit handler for shortcut_set_add_form().
+ */
+function shortcut_set_add_form_submit($form, &$form_state) {
+ // Save a new shortcut set with links copied from the user's default set.
+ $default_set = shortcut_default_set();
+ $set = (object) array(
+ 'title' => $form_state['values']['new'],
+ 'links' => menu_links_clone($default_set->links),
+ );
+ shortcut_set_save($set);
+ drupal_set_message(t('The %set_name shortcut set has been created. You can edit it from this page.', array('%set_name' => $set->title)));
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut/' . $set->set_name;
+}
+
+/**
+ * Form callback: builds the form for customizing shortcut sets.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $shortcut_set
+ * An object representing the shortcut set which is being edited.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_set_customize_submit()
+ */
+function shortcut_set_customize($form, &$form_state, $shortcut_set) {
+ $form['#shortcut_set_name'] = $shortcut_set->set_name;
+ $form['shortcuts'] = array(
+ '#tree' => TRUE,
+ '#weight' => -20,
+ 'enabled' => array(),
+ 'disabled' => array(),
+ );
+
+ foreach ($shortcut_set->links as $link) {
+ $mlid = $link['mlid'];
+ $status = $link['hidden'] ? 'disabled' : 'enabled';
+ $form['shortcuts'][$status][$mlid]['name']['#markup'] = l($link['link_title'], $link['link_path']);
+ $form['shortcuts'][$status][$mlid]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight'),
+ '#delta' => 50,
+ '#default_value' => $link['weight'],
+ '#attributes' => array('class' => array('shortcut-weight')),
+ );
+ $form['shortcuts'][$status][$mlid]['status'] = array(
+ '#type' => 'select',
+ '#title' => t('Status'),
+ '#options' => array('disabled' => t('Disabled'), 'enabled' => t('Enabled')),
+ '#default_value' => $status,
+ '#attributes' => array('class' => array('shortcut-status-select')),
+ );
+
+ $form['shortcuts'][$status][$mlid]['edit']['#markup'] = l(t('edit'), 'admin/config/user-interface/shortcut/link/' . $mlid);
+ $form['shortcuts'][$status][$mlid]['delete']['#markup'] = l(t('delete'), 'admin/config/user-interface/shortcut/link/' . $mlid . '/delete');
+ }
+
+ $form['#attached'] = array(
+ 'css' => array(drupal_get_path('module', 'shortcut') . '/shortcut.admin.css'),
+ 'js' => array(drupal_get_path('module', 'shortcut') . '/shortcut.admin.js'),
+ );
+
+ $form['actions'] = array(
+ '#type' => 'actions',
+ '#access' => !empty($shortcut_set->links),
+ );
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save changes'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for shortcut_set_customize().
+ */
+function shortcut_set_customize_submit($form, &$form_state) {
+ foreach ($form_state['values']['shortcuts'] as $group => $links) {
+ foreach ($links as $mlid => $data) {
+ $link = menu_link_load($mlid);
+ $link['hidden'] = $data['status'] == 'enabled' ? 0 : 1;
+ $link['weight'] = $data['weight'];
+ menu_link_save($link);
+ }
+ }
+ drupal_set_message(t('The shortcut set has been updated.'));
+}
+
+/**
+ * Returns HTML for a shortcut set customization form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @see shortcut_set_customize()
+ * @ingroup themeable
+ */
+function theme_shortcut_set_customize($variables) {
+ $form = $variables['form'];
+ $map = array('disabled' => t('Disabled'), 'enabled' => t('Enabled'));
+ $shortcuts_by_status = array(
+ 'enabled' => element_children($form['shortcuts']['enabled']),
+ 'disabled' => element_children($form['shortcuts']['disabled']),
+ );
+ // Do not add any rows to the table if there are no shortcuts to display.
+ $statuses = empty($shortcuts_by_status['enabled']) && empty($shortcuts_by_status['disabled']) ? array() : array_keys($shortcuts_by_status);
+
+ $rows = array();
+ foreach ($statuses as $status) {
+ drupal_add_tabledrag('shortcuts', 'match', 'sibling', 'shortcut-status-select');
+ drupal_add_tabledrag('shortcuts', 'order', 'sibling', 'shortcut-weight');
+ $rows[] = array(
+ 'data' => array(array(
+ 'colspan' => 5,
+ 'data' => '<strong>' . $map[$status] . '</strong>',
+ )),
+ 'class' => array('shortcut-status', 'shortcut-status-' . $status),
+ );
+
+ foreach ($shortcuts_by_status[$status] as $key) {
+ $shortcut = &$form['shortcuts'][$status][$key];
+ $row = array();
+ $row[] = drupal_render($shortcut['name']);
+ $row[] = drupal_render($shortcut['weight']);
+ $row[] = drupal_render($shortcut['status']);
+ $row[] = drupal_render($shortcut['edit']);
+ $row[] = drupal_render($shortcut['delete']);
+ $rows[] = array(
+ 'data' => $row,
+ 'class' => array('draggable'),
+ );
+ }
+
+ if ($status == 'enabled') {
+ for ($i = 0; $i < shortcut_max_slots(); $i++) {
+ $rows['empty-' . $i] = array(
+ 'data' => array(array(
+ 'colspan' => 5,
+ 'data' => '<em>' . t('Empty') . '</em>',
+ )),
+ 'class' => array('shortcut-slot-empty'),
+ );
+ }
+ $count_shortcuts = count($shortcuts_by_status[$status]);
+ if (!empty($count_shortcuts)) {
+ for ($i = 0; $i < min($count_shortcuts, shortcut_max_slots()); $i++) {
+ $rows['empty-' . $i]['class'][] = 'shortcut-slot-hidden';
+ }
+ }
+ }
+ }
+
+ $header = array(t('Name'), t('Weight'), t('Status'), array('data' => t('Operations'), 'colspan' => 2));
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'shortcuts'), 'empty' => t('No shortcuts available. <a href="@link">Add a shortcut</a>.', array('@link' => url('admin/config/user-interface/shortcut/' . $form['#shortcut_set_name'] . '/add-link')))));
+ $output .= drupal_render($form['actions']);
+ $output = drupal_render_children($form) . $output;
+ return $output;
+}
+
+/**
+ * Form callback: builds the form for adding a new shortcut link.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $shortcut_set
+ * An object representing the shortcut set to which the link will be added.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_link_edit_validate()
+ * @see shortcut_link_add_submit()
+ */
+function shortcut_link_add($form, &$form_state, $shortcut_set) {
+ drupal_set_title(t('Add new shortcut'));
+ $form['shortcut_set'] = array(
+ '#type' => 'value',
+ '#value' => $shortcut_set,
+ );
+ $form += _shortcut_link_form_elements();
+ return $form;
+}
+
+/**
+ * Form callback: builds the form for editing a shortcut link.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $shortcut_link
+ * An array representing the link that is being edited.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_link_edit_validate()
+ * @see shortcut_link_edit_submit()
+ */
+function shortcut_link_edit($form, &$form_state, $shortcut_link) {
+ drupal_set_title(t('Editing @shortcut', array('@shortcut' => $shortcut_link['link_title'])));
+ $form['original_shortcut_link'] = array(
+ '#type' => 'value',
+ '#value' => $shortcut_link,
+ );
+ $form += _shortcut_link_form_elements($shortcut_link);
+ return $form;
+}
+
+/**
+ * Helper function for building a form for adding or editing shortcut links.
+ *
+ * @param $shortcut_link
+ * (optional) An array representing the shortcut link that will be edited. If
+ * not provided, a new link will be created.
+ *
+ * @return
+ * An array of form elements.
+ */
+function _shortcut_link_form_elements($shortcut_link = NULL) {
+ if (!isset($shortcut_link)) {
+ $shortcut_link = array(
+ 'link_title' => '',
+ 'link_path' => ''
+ );
+ }
+ else {
+ $shortcut_link['link_path'] = drupal_get_path_alias($shortcut_link['link_path']);
+ }
+
+ $form['shortcut_link']['#tree'] = TRUE;
+ $form['shortcut_link']['link_title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#size' => 40,
+ '#maxlength' => 255,
+ '#default_value' => $shortcut_link['link_title'],
+ '#required' => TRUE,
+ );
+
+ $form['shortcut_link']['link_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Path'),
+ '#size' => 40,
+ '#maxlength' => 255,
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q='),
+ '#default_value' => $shortcut_link['link_path'],
+ );
+
+ $form['#validate'][] = 'shortcut_link_edit_validate';
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+
+ return $form;
+}
+
+/**
+ * Validation handler for the shortcut link add and edit forms.
+ */
+function shortcut_link_edit_validate($form, &$form_state) {
+ if (!shortcut_valid_link($form_state['values']['shortcut_link']['link_path'])) {
+ form_set_error('shortcut_link][link_path', t('The link must correspond to a valid path on the site.'));
+ }
+}
+
+/**
+ * Submit handler for shortcut_link_edit().
+ */
+function shortcut_link_edit_submit($form, &$form_state) {
+ // Normalize the path in case it is an alias.
+ $form_state['values']['shortcut_link']['link_path'] = drupal_get_normal_path($form_state['values']['shortcut_link']['link_path']);
+
+ $shortcut_link = array_merge($form_state['values']['original_shortcut_link'], $form_state['values']['shortcut_link']);
+
+ menu_link_save($shortcut_link);
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut/' . $shortcut_link['menu_name'];
+ drupal_set_message(t('The shortcut %link has been updated.', array('%link' => $shortcut_link['link_title'])));
+}
+
+/**
+ * Submit handler for shortcut_link_add().
+ */
+function shortcut_link_add_submit($form, &$form_state) {
+ // Add the shortcut link to the set.
+ $shortcut_set = $form_state['values']['shortcut_set'];
+ $shortcut_link = $form_state['values']['shortcut_link'];
+ $shortcut_link['menu_name'] = $shortcut_set->set_name;
+ shortcut_admin_add_link($shortcut_link, $shortcut_set, shortcut_max_slots());
+ shortcut_set_save($shortcut_set);
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut/' . $shortcut_link['menu_name'];
+ drupal_set_message(t('Added a shortcut for %title.', array('%title' => $shortcut_link['link_title'])));
+}
+
+/**
+ * Adds a link to the end of a shortcut set, keeping within a prescribed limit.
+ *
+ * @param $link
+ * An array representing a shortcut link.
+ * @param $shortcut_set
+ * An object representing the shortcut set which the link will be added to.
+ * The links in the shortcut set will be re-weighted so that the new link is
+ * at the end, and some existing links may be disabled (if the $limit
+ * parameter is provided).
+ * @param $limit
+ * (optional) The maximum number of links that are allowed to be enabled for
+ * this shortcut set. If provided, existing links at the end of the list that
+ * exceed the limit will be automatically disabled. If not provided, no limit
+ * will be enforced.
+ */
+function shortcut_admin_add_link($shortcut_link, &$shortcut_set, $limit = NULL) {
+ if (isset($limit)) {
+ // Disable any existing links at the end of the list that would cause the
+ // limit to be exceeded. Take into account whether or not the new link will
+ // be enabled and count towards the total.
+ $number_enabled = !empty($shortcut_link['hidden']) ? 0 : 1;
+ foreach ($shortcut_set->links as &$link) {
+ if (!$link['hidden']) {
+ $number_enabled++;
+ if ($number_enabled > $limit) {
+ $link['hidden'] = 1;
+ }
+ }
+ }
+ }
+
+ // Normalize the path in case it is an alias.
+ $shortcut_link['link_path'] = drupal_get_normal_path($shortcut_link['link_path']);
+
+ // Add the link to the end of the list.
+ $shortcut_set->links[] = $shortcut_link;
+ shortcut_set_reset_link_weights($shortcut_set);
+}
+
+/**
+ * Form callback: builds the form for editing the shortcut set name.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param object $shortcut_set
+ * An object representing the shortcut set, as returned from
+ * shortcut_set_load().
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_set_edit_form_validate()
+ * @see shortcut_set_edit_form_submit()
+ */
+function shortcut_set_edit_form($form, &$form_state, $shortcut_set) {
+ $form['shortcut_set'] = array(
+ '#type' => 'value',
+ '#value' => $shortcut_set,
+ );
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Set name'),
+ '#default_value' => $shortcut_set->title,
+ '#maxlength' => 255,
+ '#required' => TRUE,
+ '#weight' => -5,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#weight' => 5,
+ );
+
+ return $form;
+}
+
+/**
+ * Validation handler for shortcut_set_edit_form().
+ */
+function shortcut_set_edit_form_validate($form, &$form_state) {
+ // Check to prevent a duplicate title, if the title was edited from its
+ // original value.
+ if ($form_state['values']['title'] != $form_state['values']['shortcut_set']->title && shortcut_set_title_exists($form_state['values']['title'])) {
+ form_set_error('title', t('The shortcut set %name already exists. Choose another name.', array('%name' => $form_state['values']['title'])));
+ }
+}
+
+/**
+ * Submit handler for shortcut_set_edit_form().
+ */
+function shortcut_set_edit_form_submit($form, &$form_state) {
+ $shortcut_set = $form_state['values']['shortcut_set'];
+ $shortcut_set->title = $form_state['values']['title'];
+ shortcut_set_save($shortcut_set);
+ drupal_set_message(t('Updated set name to %set-name.', array('%set-name' => $shortcut_set->title)));
+ $form_state['redirect'] = "admin/config/user-interface/shortcut/$shortcut_set->set_name";
+}
+
+/**
+ * Form callback: builds the confirmation form for deleting a shortcut set.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param object $shortcut_set
+ * An object representing the shortcut set, as returned from
+ * shortcut_set_load().
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_set_delete_form_submit()
+ */
+function shortcut_set_delete_form($form, &$form_state, $shortcut_set) {
+ $form['shortcut_set'] = array(
+ '#type' => 'value',
+ '#value' => $shortcut_set->set_name,
+ );
+
+ // Find out how many users are directly assigned to this shortcut set, and
+ // make a message.
+ $number = db_query('SELECT COUNT(*) FROM {shortcut_set_users} WHERE set_name = :name', array(':name' => $shortcut_set->set_name))->fetchField();
+ $info = '';
+ if ($number) {
+ $info .= '<p>' . format_plural($number,
+ '1 user has chosen or been assigned to this shortcut set.',
+ '@count users have chosen or been assigned to this shortcut set.') . '</p>';
+ }
+
+ // Also, if a module implements hook_shortcut_default_set(), it's possible
+ // that this set is being used as a default set. Add a message about that too.
+ if (count(module_implements('shortcut_default_set')) > 0) {
+ $info .= '<p>' . t('If you have chosen this shortcut set as the default for some or all users, they may also be affected by deleting it.') . '</p>';
+ }
+
+ $form['info'] = array(
+ '#markup' => $info,
+ );
+
+ return confirm_form(
+ $form,
+ t('Are you sure you want to delete the shortcut set %title?', array('%title' => $shortcut_set->title)),
+ 'admin/config/user-interface/shortcut/' . $shortcut_set->set_name,
+ t('This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Submit handler for shortcut_set_delete_form().
+ */
+function shortcut_set_delete_form_submit($form, &$form_state) {
+ $shortcut_set = shortcut_set_load($form_state['values']['shortcut_set']);
+ shortcut_set_delete($shortcut_set);
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut';
+ drupal_set_message(t('The shortcut set %title has been deleted.', array('%title' => $shortcut_set->title)));
+}
+
+/**
+ * Form callback: builds the confirmation form for deleting a shortcut link.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ * @param $shortcut_link
+ * An array representing the link that will be deleted.
+ *
+ * @return
+ * An array representing the form definition.
+ *
+ * @ingroup forms
+ * @see shortcut_link_delete_submit()
+ */
+function shortcut_link_delete($form, &$form_state, $shortcut_link) {
+ $form['shortcut_link'] = array(
+ '#type' => 'value',
+ '#value' => $shortcut_link,
+ );
+
+ return confirm_form(
+ $form,
+ t('Are you sure you want to delete the shortcut %title?', array('%title' => $shortcut_link['link_title'])),
+ 'admin/config/user-interface/shortcut/' . $shortcut_link['menu_name'],
+ t('This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Submit handler for shortcut_link_delete_submit().
+ */
+function shortcut_link_delete_submit($form, &$form_state) {
+ $shortcut_link = $form_state['values']['shortcut_link'];
+ menu_link_delete($shortcut_link['mlid']);
+ $form_state['redirect'] = 'admin/config/user-interface/shortcut/' . $shortcut_link['menu_name'];
+ drupal_set_message(t('The shortcut %title has been deleted.', array('%title' => $shortcut_link['link_title'])));
+}
+
+/**
+ * Menu page callback: creates a new link in the provided shortcut set.
+ *
+ * After completion, redirects the user back to where they came from.
+ *
+ * @param $shortcut_set
+ * Returned from shortcut_set_load().
+ */
+function shortcut_link_add_inline($shortcut_set) {
+ if (isset($_REQUEST['token']) && drupal_valid_token($_REQUEST['token'], 'shortcut-add-link') && shortcut_valid_link($_GET['link'])) {
+ $item = menu_get_item($_GET['link']);
+ $title = ($item && $item['title']) ? $item['title'] : $_GET['name'];
+ $link = array(
+ 'link_title' => $title,
+ 'link_path' => $_GET['link'],
+ );
+ shortcut_admin_add_link($link, $shortcut_set, shortcut_max_slots());
+ if (shortcut_set_save($shortcut_set)) {
+ drupal_set_message(t('Added a shortcut for %title.', array('%title' => $link['link_title'])));
+ }
+ else {
+ drupal_set_message(t('Unable to add a shortcut for %title.', array('%title' => $link['link_title'])));
+ }
+ drupal_goto();
+ }
+
+ return drupal_access_denied();
+}
diff --git a/core/modules/shortcut/shortcut.admin.js b/core/modules/shortcut/shortcut.admin.js
new file mode 100644
index 000000000000..5e71e6fb6546
--- /dev/null
+++ b/core/modules/shortcut/shortcut.admin.js
@@ -0,0 +1,99 @@
+(function ($) {
+
+/**
+ * Handle the concept of a fixed number of slots.
+ *
+ * This behavior is dependent on the tableDrag behavior, since it uses the
+ * objects initialized in that behavior to update the row.
+ */
+Drupal.behaviors.shortcutDrag = {
+ attach: function (context, settings) {
+ if (Drupal.tableDrag) {
+ var table = $('table#shortcuts'),
+ visibleLength = 0,
+ slots = 0,
+ tableDrag = Drupal.tableDrag.shortcuts;
+ $('> tbody > tr, > tr', table)
+ .filter(':visible')
+ .filter(':odd').filter('.odd')
+ .removeClass('odd').addClass('even')
+ .end().end()
+ .filter(':even').filter('.even')
+ .removeClass('even').addClass('odd')
+ .end().end()
+ .end()
+ .filter('.shortcut-slot-empty').each(function(index) {
+ if ($(this).is(':visible')) {
+ visibleLength++;
+ }
+ slots++;
+ });
+
+ // Add a handler for when a row is swapped.
+ tableDrag.row.prototype.onSwap = function (swappedRow) {
+ var disabledIndex = $(table).find('tr').index($(table).find('tr.shortcut-status-disabled')) - slots - 2,
+ count = 0;
+ $(table).find('tr.shortcut-status-enabled').nextAll().filter(':not(.shortcut-slot-empty)').each(function(index) {
+ if (index < disabledIndex) {
+ count++;
+ }
+ });
+ var total = slots - count;
+ if (total == -1) {
+ var disabled = $(table).find('tr.shortcut-status-disabled');
+ disabled.after(disabled.prevAll().filter(':not(.shortcut-slot-empty)').get(0));
+ }
+ else if (total != visibleLength) {
+ if (total > visibleLength) {
+ // Less slots on screen than needed.
+ $('.shortcut-slot-empty:hidden:last').show();
+ visibleLength++;
+ }
+ else {
+ // More slots on screen than needed.
+ $('.shortcut-slot-empty:visible:last').hide();
+ visibleLength--;
+ }
+ }
+ };
+
+ // Add a handler so when a row is dropped, update fields dropped into new regions.
+ tableDrag.onDrop = function () {
+ // Use "status-message" row instead of "status" row because
+ // "status-{status_name}-message" is less prone to regexp match errors.
+ var statusRow = $(this.rowObject.element).prevAll('tr.shortcut-status').get(0);
+ var statusName = statusRow.className.replace(/([^ ]+[ ]+)*shortcut-status-([^ ]+)([ ]+[^ ]+)*/, '$2');
+ var statusField = $('select.shortcut-status-select', this.rowObject.element);
+ statusField.val(statusName);
+ return true;
+ };
+
+ tableDrag.restripeTable = function () {
+ // :even and :odd are reversed because jQuery counts from 0 and
+ // we count from 1, so we're out of sync.
+ // Match immediate children of the parent element to allow nesting.
+ $('> tbody > tr:visible, > tr:visible', this.table)
+ .filter(':odd').filter('.odd')
+ .removeClass('odd').addClass('even')
+ .end().end()
+ .filter(':even').filter('.even')
+ .removeClass('even').addClass('odd');
+ };
+ }
+ }
+};
+
+/**
+ * Make it so when you enter text into the "New set" textfield, the
+ * corresponding radio button gets selected.
+ */
+Drupal.behaviors.newSet = {
+ attach: function (context, settings) {
+ var selectDefault = function() {
+ $($(this).parents('div.form-item').get(1)).find('> label > input').attr('checked', 'checked');
+ };
+ $('div.form-item-new input').focus(selectDefault).keyup(selectDefault);
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/shortcut/shortcut.api.php b/core/modules/shortcut/shortcut.api.php
new file mode 100644
index 000000000000..717a7c92b08c
--- /dev/null
+++ b/core/modules/shortcut/shortcut.api.php
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Shortcut module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Return the name of a default shortcut set for the provided user account.
+ *
+ * This hook allows modules to define default shortcut sets for a particular
+ * user that differ from the site-wide default (for example, a module may want
+ * to define default shortcuts on a per-role basis).
+ *
+ * The default shortcut set is used only when the user does not have any other
+ * shortcut set explicitly assigned to them.
+ *
+ * Note that only one default shortcut set can exist per user, so when multiple
+ * modules implement this hook, the last (i.e., highest weighted) module which
+ * returns a valid shortcut set name will prevail.
+ *
+ * @param $account
+ * The user account whose default shortcut set is being requested.
+ * @return
+ * The name of the shortcut set that this module recommends for that user, if
+ * there is one.
+ */
+function hook_shortcut_default_set($account) {
+ // Use a special set of default shortcuts for administrators only.
+ if (in_array(variable_get('user_admin_role', 0), $account->roles)) {
+ return variable_get('mymodule_shortcut_admin_default_set');
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/shortcut/shortcut.css b/core/modules/shortcut/shortcut.css
new file mode 100644
index 000000000000..3afcb94332ae
--- /dev/null
+++ b/core/modules/shortcut/shortcut.css
@@ -0,0 +1,106 @@
+div#toolbar a#edit-shortcuts {
+ float: right;
+ padding: 5px 10px 5px 5px;
+ line-height: 24px;
+ color: #fefefe;
+}
+div#toolbar a#edit-shortcuts:focus,
+div#toolbar a#edit-shortcuts:hover,
+div#toolbar a#edit-shortcuts.active {
+ color: #fff;
+ text-decoration: underline;
+}
+
+div#toolbar div.toolbar-shortcuts ul {
+ padding: 5px 0 2px 0;
+ height: 28px;
+ line-height: 24px;
+ float: left; /* LTR */
+ margin-left:5px; /* LTR */
+}
+
+div#toolbar div.toolbar-shortcuts ul li a {
+ padding: 0 5px 0 5px;
+ margin-right: 5px; /* LTR */
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+}
+
+div#toolbar div.toolbar-shortcuts ul li a:focus,
+div#toolbar div.toolbar-shortcuts ul li a:hover,
+div#toolbar div.toolbar-shortcuts ul li a.active:focus {
+ background: #555;
+}
+
+div#toolbar div.toolbar-shortcuts ul li a.active:hover,
+div#toolbar div.toolbar-shortcuts ul li a.active {
+ background: #000;
+}
+
+div#toolbar div.toolbar-shortcuts span.icon {
+ float: left; /* LTR */
+ background: #444;
+ width: 30px;
+ height: 30px;
+ margin-right: 5px; /* LTR */
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+}
+
+div.add-or-remove-shortcuts {
+ padding-top: 5px;
+}
+
+div.add-or-remove-shortcuts a span.icon {
+ display: block;
+ width: 12px;
+ background: transparent url(shortcut.png) no-repeat scroll 0 0;
+ height: 12px;
+ float: left;
+ margin-left:8px;
+}
+
+div.add-shortcut a:focus span.icon,
+div.add-shortcut a:hover span.icon {
+ background-position: 0 -12px;
+}
+div.remove-shortcut a span.icon {
+ background-position: -12px 0;
+}
+div.remove-shortcut a:focus span.icon,
+div.remove-shortcut a:hover span.icon {
+ background-position: -12px -12px;
+}
+
+div.add-or-remove-shortcuts a span.text {
+ float: left;
+ padding-left:10px;
+ display: none;
+}
+
+div.add-or-remove-shortcuts a:focus span.text,
+div.add-or-remove-shortcuts a:hover span.text {
+ font-size: 10px;
+ line-height: 12px;
+ color: #fff;
+ background-color: #5f605b;
+ display: block;
+ padding-right: 6px; /* LTR */
+ cursor: pointer;
+ -moz-border-radius: 0 5px 5px 0; /* LTR */
+ -webkit-border-top-right-radius: 5px; /* LTR */
+ -webkit-border-bottom-right-radius: 5px; /* LTR */
+ border-radius: 0 5px 5px 0; /* LTR */
+}
+
+#shortcut-set-switch .form-type-radios {
+ padding-bottom: 0;
+ margin-bottom: 0;
+}
+
+#shortcut-set-switch .form-item-new {
+ padding-top: 0;
+ padding-left: 17px; /* LTR */
+}
diff --git a/core/modules/shortcut/shortcut.info b/core/modules/shortcut/shortcut.info
new file mode 100644
index 000000000000..0030605d3f0d
--- /dev/null
+++ b/core/modules/shortcut/shortcut.info
@@ -0,0 +1,7 @@
+name = Shortcut
+description = Allows users to manage customizable lists of shortcut links.
+package = Core
+version = VERSION
+core = 8.x
+files[] = shortcut.test
+configure = admin/config/user-interface/shortcut
diff --git a/core/modules/shortcut/shortcut.install b/core/modules/shortcut/shortcut.install
new file mode 100644
index 000000000000..60ee6be8ddf0
--- /dev/null
+++ b/core/modules/shortcut/shortcut.install
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the shortcut module.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function shortcut_install() {
+ $t = get_t();
+ // Create an initial default shortcut set.
+ $shortcut_set = new stdClass();
+ $shortcut_set->title = $t('Default');
+ $shortcut_set->links = array(
+ array(
+ 'link_path' => 'node/add',
+ 'link_title' => $t('Add content'),
+ 'weight' => -20,
+ ),
+ array(
+ 'link_path' => 'admin/content',
+ 'link_title' => $t('Find content'),
+ 'weight' => -19,
+ ),
+ );
+ // If Drupal is being installed, rebuild the menu before saving the shortcut
+ // set, to make sure the links defined above can be correctly saved. (During
+ // installation, the menu might not have been built at all yet, or it might
+ // have been built but without the node module's links in it.)
+ if (drupal_installation_attempted()) {
+ menu_rebuild();
+ }
+ shortcut_set_save($shortcut_set);
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function shortcut_uninstall() {
+ drupal_load('module', 'shortcut');
+ // Delete the menu links associated with each shortcut set.
+ foreach (shortcut_sets() as $shortcut_set) {
+ menu_delete_links($shortcut_set->set_name);
+ }
+}
+
+/**
+ * Implements hook_schema().
+ */
+function shortcut_schema() {
+ $schema['shortcut_set'] = array(
+ 'description' => 'Stores information about sets of shortcuts links.',
+ 'fields' => array(
+ 'set_name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "Primary Key: The {menu_links}.menu_name under which the set's links are stored.",
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The title of the set.',
+ ),
+ ),
+ 'primary key' => array('set_name'),
+ 'foreign keys' => array(
+ 'menu_name' => array(
+ 'table' => 'menu_links',
+ 'columns' => array('set_name' => 'menu_name'),
+ ),
+ ),
+ );
+
+ $schema['shortcut_set_users'] = array(
+ 'description' => 'Maps users to shortcut sets.',
+ 'fields' => array(
+ 'uid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {users}.uid for this set.',
+ ),
+ 'set_name' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "The {shortcut_set}.set_name that will be displayed for this user.",
+ ),
+ ),
+ 'primary key' => array('uid'),
+ 'indexes' => array(
+ 'set_name' => array('set_name'),
+ ),
+ 'foreign keys' => array(
+ 'set_user' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ 'set_name' => array(
+ 'table' => 'shortcut_set',
+ 'columns' => array('set_name' => 'set_name'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/shortcut/shortcut.module b/core/modules/shortcut/shortcut.module
new file mode 100644
index 000000000000..f8ddcc24facd
--- /dev/null
+++ b/core/modules/shortcut/shortcut.module
@@ -0,0 +1,749 @@
+<?php
+
+/**
+ * @file
+ * Allows users to manage customizable lists of shortcut links.
+ */
+
+/**
+ * The name of the default shortcut set.
+ *
+ * This set will be displayed to any user that does not have another set
+ * assigned, unless overridden by a hook_shortcut_default_set() implementation.
+ */
+define('SHORTCUT_DEFAULT_SET_NAME', 'shortcut-set-1');
+
+/**
+ * Implements hook_help().
+ */
+function shortcut_help($path, $arg) {
+ global $user;
+
+ switch ($path) {
+ case 'admin/help#shortcut':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Shortcut module allows users to create sets of <em>shortcut</em> links to commonly-visited pages of the site. Shortcuts are contained within <em>sets</em>. Each user with <em>Select any shortcut set</em> permission can select a shortcut set created by anyone at the site. For more information, see the online handbook entry for <a href="@shortcut">Shortcut module</a>.', array('@shortcut' => 'http://drupal.org/handbook/modules/shortcut/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl><dt>' . t('Administering shortcuts') . '</dt>';
+ $output .= '<dd>' . t('Users with the <em>Administer shortcuts</em> permission can manage shortcut sets and edit the shortcuts within sets from the <a href="@shortcuts">Shortcuts administration page</a>.', array('@shortcuts' => url('admin/config/user-interface/shortcut'))) . '</dd>';
+ $output .= '<dt>' . t('Choosing shortcut sets') . '</dt>';
+ $output .= '<dd>' . t('Users with permission to switch shortcut sets can choose a shortcut set to use from the Shortcuts tab of their user account page.') . '</dd>';
+ $output .= '<dt>' . t('Adding and removing shortcuts') . '</dt>';
+ $output .= '<dd>' . t('The Shortcut module creates an add/remove link for each page on your site; the link lets you add or remove the current page from the currently-enabled set of shortcuts (if your theme displays it and you have permission to edit your shortcut set). The core Seven administration theme displays this link next to the page title, as a small + or - sign. If you click on the + sign, you will add that page to your preferred set of shortcuts. If the page is already part of your shortcut set, the link will be a - sign, and will allow you to remove the current page from your shortcut set.') . '</dd>';
+ $output .= '<dt>' . t('Displaying shortcuts') . '</dt>';
+ $output .= '<dd>' . t('You can display your shortcuts by enabling the Shortcuts block on the <a href="@blocks">Blocks administration page</a>. Certain administrative modules also display your shortcuts; for example, the core <a href="@toolbar-help">Toolbar module</a> displays them near the top of the page, along with an <em>Edit shortcuts</em> link.', array('@blocks' => url('admin/structure/block'), '@toolbar-help' => url('admin/help/toolbar'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+
+ case 'admin/config/user-interface/shortcut':
+ case 'admin/config/user-interface/shortcut/%':
+ if (user_access('switch shortcut sets')) {
+ $output = '<p>' . t('Define which shortcut set you are using on the <a href="@shortcut-link">Shortcuts tab</a> of your account page.', array('@shortcut-link' => url("user/{$user->uid}/shortcuts"))) . '</p>';
+ return $output;
+ }
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function shortcut_permission() {
+ return array(
+ 'administer shortcuts' => array(
+ 'title' => t('Administer shortcuts'),
+ ),
+ 'customize shortcut links' => array(
+ 'title' => t('Edit current shortcut set'),
+ 'description' => t('Editing the current shortcut set will affect other users if that set has been assigned to or selected by other users. Granting "Select any shortcut set" permission along with this permission will grant permission to edit any shortcut set.'),
+ ),
+ 'switch shortcut sets' => array(
+ 'title' => t('Select any shortcut set'),
+ 'description' => t('From all shortcut sets, select one to be own active set. Without this permission, an administrator selects shortcut sets for users.'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function shortcut_menu() {
+ $items['admin/config/user-interface/shortcut'] = array(
+ 'title' => 'Shortcuts',
+ 'description' => 'Add and modify shortcut sets.',
+ 'page callback' => 'shortcut_set_admin',
+ 'access arguments' => array('administer shortcuts'),
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/add-set'] = array(
+ 'title' => 'Add shortcut set',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_set_add_form'),
+ 'access arguments' => array('administer shortcuts'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set'] = array(
+ 'title' => 'Edit shortcuts',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_set_customize', 4),
+ 'title callback' => 'shortcut_set_title',
+ 'title arguments' => array(4),
+ 'access callback' => 'shortcut_set_edit_access',
+ 'access arguments' => array(4),
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set/links'] = array(
+ 'title' => 'List links',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set/edit'] = array(
+ 'title' => 'Edit set name',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_set_edit_form', 4),
+ 'access callback' => 'shortcut_set_edit_access',
+ 'access arguments' => array(4),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'shortcut.admin.inc',
+ 'weight' => 10,
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set/delete'] = array(
+ 'title' => 'Delete shortcut set',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_set_delete_form', 4),
+ 'access callback' => 'shortcut_set_delete_access',
+ 'access arguments' => array(4),
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set/add-link'] = array(
+ 'title' => 'Add shortcut',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_link_add', 4),
+ 'access callback' => 'shortcut_set_edit_access',
+ 'access arguments' => array(4),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/%shortcut_set/add-link-inline'] = array(
+ 'title' => 'Add shortcut',
+ 'page callback' => 'shortcut_link_add_inline',
+ 'page arguments' => array(4),
+ 'access callback' => 'shortcut_set_edit_access',
+ 'access arguments' => array(4),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/link/%menu_link'] = array(
+ 'title' => 'Edit shortcut',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_link_edit', 5),
+ 'access callback' => 'shortcut_link_access',
+ 'access arguments' => array(5),
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['admin/config/user-interface/shortcut/link/%menu_link/delete'] = array(
+ 'title' => 'Delete shortcut',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_link_delete', 5),
+ 'access callback' => 'shortcut_link_access',
+ 'access arguments' => array(5),
+ 'file' => 'shortcut.admin.inc',
+ );
+ $items['user/%user/shortcuts'] = array(
+ 'title' => 'Shortcuts',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('shortcut_set_switch', 1),
+ 'access callback' => 'shortcut_set_switch_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'shortcut.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function shortcut_admin_paths() {
+ $paths = array(
+ 'user/*/shortcuts' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function shortcut_theme() {
+ return array(
+ 'shortcut_set_customize' => array(
+ 'render element' => 'form',
+ 'file' => 'shortcut.admin.inc',
+ ),
+ );
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function shortcut_block_info() {
+ $blocks['shortcuts']['info'] = t('Shortcuts');
+ // Shortcut blocks can't be cached because each menu item can have a custom
+ // access callback. menu.inc manages its own caching.
+ $blocks['shortcuts']['cache'] = DRUPAL_NO_CACHE;
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function shortcut_block_view($delta = '') {
+ if ($delta == 'shortcuts') {
+ $shortcut_set = shortcut_current_displayed_set();
+ $data['subject'] = t('@shortcut_set shortcuts', array('@shortcut_set' => $shortcut_set->title));
+ $data['content'] = shortcut_renderable_links($shortcut_set);
+ return $data;
+ }
+}
+
+/**
+ * Access callback for editing a shortcut set.
+ *
+ * @param object $shortcut_set
+ * (optional) The shortcut set to be edited. If not set, the current user's
+ * shortcut set will be used.
+ *
+ * @return
+ * TRUE if the current user has access to edit the shortcut set, FALSE
+ * otherwise.
+ */
+function shortcut_set_edit_access($shortcut_set = NULL) {
+ // Sufficiently-privileged users can edit their currently displayed shortcut
+ // set, but not other sets. Shortcut administrators can edit any set.
+ if (user_access('administer shortcuts')) {
+ return TRUE;
+ }
+ if (user_access('customize shortcut links')) {
+ return !isset($shortcut_set) || $shortcut_set == shortcut_current_displayed_set();
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for deleting a shortcut set.
+ *
+ * @param $shortcut_set
+ * The shortcut set to be deleted.
+ *
+ * @return
+ * TRUE if the current user has access to delete shortcut sets and this is
+ * not the site-wide default set; FALSE otherwise.
+ */
+function shortcut_set_delete_access($shortcut_set) {
+ // Only admins can delete sets.
+ if (!user_access('administer shortcuts')) {
+ return FALSE;
+ }
+
+ // Never let the default shortcut set be deleted.
+ if ($shortcut_set->set_name == SHORTCUT_DEFAULT_SET_NAME) {
+ return FALSE;
+ }
+
+ return TRUE;
+}
+
+/**
+ * Access callback for switching the shortcut set assigned to a user account.
+ *
+ * @param object $account
+ * (optional) The user account whose shortcuts will be switched. If not set,
+ * permissions will be checked for switching the logged-in user's own
+ * shortcut set.
+ *
+ * @return
+ * TRUE if the current user has access to switch the shortcut set of the
+ * provided account, FALSE otherwise.
+ */
+function shortcut_set_switch_access($account = NULL) {
+ global $user;
+
+ if (user_access('administer shortcuts')) {
+ // Administrators can switch anyone's shortcut set.
+ return TRUE;
+ }
+
+ if (!user_access('switch shortcut sets')) {
+ // The user has no permission to switch anyone's shortcut set.
+ return FALSE;
+ }
+
+ if (!isset($account) || $user->uid == $account->uid) {
+ // Users with the 'switch shortcut sets' permission can switch their own
+ // shortcuts sets.
+ return TRUE;
+ }
+
+ return FALSE;
+}
+
+/**
+ * Access callback for editing a link in a shortcut set.
+ */
+function shortcut_link_access($menu_link) {
+ // The link must belong to a shortcut set that the current user has access
+ // to edit.
+ if ($shortcut_set = shortcut_set_load($menu_link['menu_name'])) {
+ return shortcut_set_edit_access($shortcut_set);
+ }
+ return FALSE;
+}
+
+/**
+ * Loads the data for a shortcut set.
+ *
+ * @param $set_name
+ * The name of the shortcut set to load.
+ *
+ * @return object
+ * If the shortcut set exists, an object containing the following properties:
+ * - 'set_name': The internal name of the shortcut set.
+ * - 'title': The title of the shortcut set.
+ * - 'links': An array of links associated with this shortcut set.
+ * If the shortcut set does not exist, the function returns FALSE.
+ */
+function shortcut_set_load($set_name) {
+ $set = db_select('shortcut_set', 'ss')
+ ->fields('ss')
+ ->condition('set_name', $set_name)
+ ->execute()
+ ->fetchObject();
+ if (!$set) {
+ return FALSE;
+ }
+ $set->links = menu_load_links($set_name);
+ return $set;
+}
+
+/**
+ * Saves a shortcut set.
+ *
+ * @param $shortcut_set
+ * An object containing the following properties:
+ * - 'title': The title of the shortcut set.
+ * - 'set_name': (optional) The internal name of the shortcut set. If
+ * omitted, a new shortcut set will be created, and the 'set_name' property
+ * will be added to the passed-in object.
+ * - 'links': (optional) An array of menu links to save for the shortcut set.
+ * Each link is an array containing at least the following keys (which will
+ * be expanded to fill in other default values after the shortcut set is
+ * saved):
+ * - 'link_path': The Drupal path or external path that the link points to.
+ * - 'link_title': The title of the link.
+ * Any other keys accepted by menu_link_save() may also be provided.
+ *
+ * @return
+ * A constant which is either SAVED_NEW or SAVED_UPDATED depending on whether
+ * a new set was created or an existing one was updated.
+ *
+ * @see menu_link_save()
+ */
+function shortcut_set_save(&$shortcut_set) {
+ // First save the shortcut set itself.
+ if (isset($shortcut_set->set_name)) {
+ $return = drupal_write_record('shortcut_set', $shortcut_set, 'set_name');
+ }
+ else {
+ $shortcut_set->set_name = shortcut_set_get_unique_name();
+ $return = drupal_write_record('shortcut_set', $shortcut_set);
+ }
+ // If links were provided for the set, save them.
+ if (isset($shortcut_set->links)) {
+ foreach ($shortcut_set->links as &$link) {
+ // Do not specifically associate these links with the shortcut module,
+ // since other modules may make them editable via the menu system.
+ // However, we do need to specify the correct menu name.
+ $link['menu_name'] = $shortcut_set->set_name;
+ $link['plid'] = 0;
+ menu_link_save($link);
+ }
+ // Make sure that we have a return value, since if the links were updated
+ // but the shortcut set was not, the call to drupal_write_record() above
+ // would not return an indication that anything had changed.
+ if (empty($return)) {
+ $return = SAVED_UPDATED;
+ }
+ }
+ return $return;
+}
+
+/**
+ * Deletes a shortcut set.
+ *
+ * Note that the default set cannot be deleted.
+ *
+ * @param $shortcut_set
+ * An object representing the shortcut set to delete.
+ *
+ * @return
+ * TRUE if the set was deleted, FALSE otherwise.
+ */
+function shortcut_set_delete($shortcut_set) {
+ // Don't allow deletion of the system default shortcut set.
+ if ($shortcut_set->set_name == SHORTCUT_DEFAULT_SET_NAME) {
+ return FALSE;
+ }
+
+ // First, delete any user assignments for this set, so that each of these
+ // users will go back to using whatever default set applies.
+ db_delete('shortcut_set_users')
+ ->condition('set_name', $shortcut_set->set_name)
+ ->execute();
+
+ // Next, delete the menu links for this set.
+ menu_delete_links($shortcut_set->set_name);
+
+ // Finally, delete the set itself.
+ $deleted = db_delete('shortcut_set')
+ ->condition('set_name', $shortcut_set->set_name)
+ ->execute();
+
+ return (bool) $deleted;
+}
+
+/**
+ * Resets the link weights in a shortcut set to match their current order.
+ *
+ * This function can be used, for example, when a new shortcut link is added to
+ * the set. If the link is added to the end of the array and this function is
+ * called, it will force that link to display at the end of the list.
+ *
+ * @param object $shortcut_set
+ * An object representing a shortcut set. The link weights of the passed-in
+ * object will be reset as described above.
+ */
+function shortcut_set_reset_link_weights(&$shortcut_set) {
+ $weight = -50;
+ foreach ($shortcut_set->links as &$link) {
+ $link['weight'] = $weight;
+ $weight++;
+ }
+}
+
+/**
+ * Assigns a user to a particular shortcut set.
+ *
+ * @param $shortcut_set
+ * An object representing the shortcut set.
+ * @param $account
+ * A user account that will be assigned to use the set.
+ */
+function shortcut_set_assign_user($shortcut_set, $account) {
+ db_merge('shortcut_set_users')
+ ->key(array('uid' => $account->uid))
+ ->fields(array('set_name' => $shortcut_set->set_name))
+ ->execute();
+ drupal_static_reset('shortcut_current_displayed_set');
+}
+
+/**
+ * Unassigns a user from any shortcut set they may have been assigned to.
+ *
+ * The user will go back to using whatever default set applies.
+ *
+ * @param $account
+ * A user account that will be removed from the shortcut set assignment.
+ *
+ * @return
+ * TRUE if the user was previously assigned to a shortcut set and has been
+ * successfully removed from it. FALSE if the user was already not assigned
+ * to any set.
+ */
+function shortcut_set_unassign_user($account) {
+ $deleted = db_delete('shortcut_set_users')
+ ->condition('uid', $account->uid)
+ ->execute();
+ return (bool) $deleted;
+}
+
+/**
+ * Returns the current displayed shortcut set for the provided user account.
+ *
+ * @param $account
+ * (optional) The user account whose shortcuts will be returned. Defaults to
+ * the currently logged-in user.
+ *
+ * @return
+ * An object representing the shortcut set that should be displayed to the
+ * current user. If the user does not have an explicit shortcut set defined,
+ * the default set is returned.
+ */
+function shortcut_current_displayed_set($account = NULL) {
+ $shortcut_sets = &drupal_static(__FUNCTION__, array());
+ global $user;
+ if (!isset($account)) {
+ $account = $user;
+ }
+ // Try to return a shortcut set from the static cache.
+ if (isset($shortcut_sets[$account->uid])) {
+ return $shortcut_sets[$account->uid];
+ }
+ // If none was found, try to find a shortcut set that is explicitly assigned
+ // to this user.
+ $query = db_select('shortcut_set', 's');
+ $query->addField('s', 'set_name');
+ $query->join('shortcut_set_users', 'u', 's.set_name = u.set_name');
+ $query->condition('u.uid', $account->uid);
+ $shortcut_set_name = $query->execute()->fetchField();
+ if ($shortcut_set_name) {
+ $shortcut_set = shortcut_set_load($shortcut_set_name);
+ }
+ // Otherwise, use the default set.
+ else {
+ $shortcut_set = shortcut_default_set($account);
+ }
+
+ $shortcut_sets[$account->uid] = $shortcut_set;
+ return $shortcut_set;
+}
+
+/**
+ * Returns the default shortcut set for a given user account.
+ *
+ * @param object $account
+ * (optional) The user account whose default shortcut set will be returned.
+ * If not provided, the function will return the currently logged-in user's
+ * default shortcut set.
+ *
+ * @return
+ * An object representing the default shortcut set.
+ */
+function shortcut_default_set($account = NULL) {
+ global $user;
+ if (!isset($account)) {
+ $account = $user;
+ }
+
+ // Allow modules to return a default shortcut set name. Since we can only
+ // have one, we allow the last module which returns a valid result to take
+ // precedence. If no module returns a valid set, fall back on the site-wide
+ // default, which is the lowest-numbered shortcut set.
+ $suggestions = array_reverse(module_invoke_all('shortcut_default_set', $account));
+ $suggestions[] = SHORTCUT_DEFAULT_SET_NAME;
+ foreach ($suggestions as $name) {
+ if ($shortcut_set = shortcut_set_load($name)) {
+ break;
+ }
+ }
+
+ return $shortcut_set;
+}
+
+/**
+ * Returns a unique, machine-readable shortcut set name.
+ */
+function shortcut_set_get_unique_name() {
+ // Shortcut sets are numbered sequentially, so we keep trying until we find
+ // one that is available. For better performance, we start with a number
+ // equal to one more than the current number of shortcut sets, so that if
+ // no shortcut sets have been deleted from the database, this will
+ // automatically give us the correct one.
+ $number = db_query("SELECT COUNT(*) FROM {shortcut_set}")->fetchField() + 1;
+ do {
+ $name = shortcut_set_name($number);
+ $number++;
+ } while ($shortcut_set = shortcut_set_load($name));
+ return $name;
+}
+
+/**
+ * Returns the name of a shortcut set, based on a provided number.
+ *
+ * All shortcut sets have names like "shortcut-set-N" so that they can be
+ * matched with a properly-namespaced entry in the {menu_links} table.
+ *
+ * @param $number
+ * A number representing the shortcut set whose name should be retrieved.
+ *
+ * @return
+ * A string representing the expected shortcut name.
+ */
+function shortcut_set_name($number) {
+ return "shortcut-set-$number";
+}
+
+/**
+ * Returns an array of all shortcut sets, keyed by the set name.
+ *
+ * @return
+ * An array of shortcut sets. Note that only the basic shortcut set
+ * properties (name and title) are returned by this function, not the list
+ * of menu links that belong to the set.
+ */
+function shortcut_sets() {
+ return db_select('shortcut_set', 'ss')
+ ->fields('ss')
+ ->execute()
+ ->fetchAllAssoc('set_name');
+}
+
+/**
+ * Check to see if a shortcut set with the given title already exists.
+ *
+ * @param $title
+ * Human-readable name of the shortcut set to check.
+ *
+ * @return
+ * TRUE if a shortcut set with that title exists; FALSE otherwise.
+ */
+function shortcut_set_title_exists($title) {
+ return (bool) db_query_range('SELECT 1 FROM {shortcut_set} WHERE title = :title', 0, 1, array(':title' => $title))->fetchField();
+}
+
+/**
+ * Determines if a path corresponds to a valid shortcut link.
+ *
+ * @param $path
+ * The path to the link.
+ * @return
+ * TRUE if the shortcut link is valid, FALSE otherwise. Valid links are ones
+ * that correspond to actual paths on the site.
+ *
+ * @see menu_edit_item_validate()
+ */
+function shortcut_valid_link($path) {
+ // Do not use URL aliases.
+ $normal_path = drupal_get_normal_path($path);
+ if ($path != $normal_path) {
+ $path = $normal_path;
+ }
+ // Only accept links that correspond to valid paths on the site itself.
+ return !url_is_external($path) && menu_get_item($path);
+}
+
+/**
+ * Returns an array of shortcut links, suitable for rendering.
+ *
+ * @param $shortcut_set
+ * (optional) An object representing the set whose links will be displayed.
+ * If not provided, the user's current set will be displayed.
+ * @return
+ * An array of shortcut links, in the format returned by the menu system.
+ *
+ * @see menu_tree()
+ */
+function shortcut_renderable_links($shortcut_set = NULL) {
+ if (!isset($shortcut_set)) {
+ $shortcut_set = shortcut_current_displayed_set();
+ }
+ return menu_tree($shortcut_set->set_name);
+}
+
+/**
+ * Implements hook_preprocess_page().
+ */
+function shortcut_preprocess_page(&$variables) {
+ // Only display the shortcut link if the user has the ability to edit
+ // shortcuts and if the page's actual content is being shown (for example,
+ // we do not want to display it on "access denied" or "page not found"
+ // pages).
+ if (shortcut_set_edit_access() && ($item = menu_get_item()) && $item['access']) {
+ $link = $_GET['q'];
+ $query_parameters = drupal_get_query_parameters();
+ if (!empty($query_parameters)) {
+ $link .= '?' . drupal_http_build_query($query_parameters);
+ }
+ $query = array(
+ 'link' => $link,
+ 'name' => drupal_get_title(),
+ );
+ $query += drupal_get_destination();
+
+ $shortcut_set = shortcut_current_displayed_set();
+
+ // Check if $link is already a shortcut and set $link_mode accordingly.
+ foreach ($shortcut_set->links as $shortcut) {
+ if ($link == $shortcut['link_path']) {
+ $mlid = $shortcut['mlid'];
+ break;
+ }
+ }
+ $link_mode = isset($mlid) ? "remove" : "add";
+
+ if ($link_mode == "add") {
+ $query['token'] = drupal_get_token('shortcut-add-link');
+ $link_text = shortcut_set_switch_access() ? t('Add to %shortcut_set shortcuts', array('%shortcut_set' => $shortcut_set->title)) : t('Add to shortcuts');
+ $link_path = 'admin/config/user-interface/shortcut/' . $shortcut_set->set_name . '/add-link-inline';
+ }
+ else {
+ $query['mlid'] = $mlid;
+ $link_text = shortcut_set_switch_access() ? t('Remove from %shortcut_set shortcuts', array('%shortcut_set' => $shortcut_set->title)) : t('Remove from shortcuts');
+ $link_path = 'admin/config/user-interface/shortcut/link/' . $mlid . '/delete';
+ }
+
+ if (theme_get_setting('shortcut_module_link')) {
+ $variables['title_suffix']['add_or_remove_shortcut'] = array(
+ '#attached' => array('css' => array(drupal_get_path('module', 'shortcut') . '/shortcut.css')),
+ '#prefix' => '<div class="add-or-remove-shortcuts ' . $link_mode . '-shortcut">',
+ '#type' => 'link',
+ '#title' => '<span class="icon"></span><span class="text">' . $link_text . '</span>',
+ '#href' => $link_path,
+ '#options' => array('query' => $query, 'html' => TRUE),
+ '#suffix' => '</div>',
+ );
+ }
+ }
+}
+
+/**
+ * Implements hook_page_alter().
+ */
+function shortcut_page_alter(&$page) {
+ if (isset($page['page_top']['toolbar'])) {
+ // If the toolbar is available, add a pre-render function to display the
+ // current shortcuts in the toolbar drawer.
+ $page['page_top']['toolbar']['#pre_render'][] = 'shortcut_toolbar_pre_render';
+ }
+}
+
+/**
+ * Pre-render function for adding shortcuts to the toolbar drawer.
+ */
+function shortcut_toolbar_pre_render($toolbar) {
+ $links = shortcut_renderable_links();
+ $links['#attached'] = array('css' => array(drupal_get_path('module', 'shortcut') . '/shortcut.css'));
+ $links['#prefix'] = '<div class="toolbar-shortcuts">';
+ $links['#suffix'] = '</div>';
+ $shortcut_set = shortcut_current_displayed_set();
+ $configure_link = NULL;
+ if (shortcut_set_edit_access($shortcut_set)) {
+ $configure_link = array(
+ '#type' => 'link',
+ '#title' => t('Edit shortcuts'),
+ '#href' => 'admin/config/user-interface/shortcut/' . $shortcut_set->set_name,
+ '#options' => array('attributes' => array('id' => 'edit-shortcuts')),
+ );
+ }
+
+ $drawer = array(
+ 'shortcuts' => $links,
+ 'configure' => $configure_link,
+ );
+
+ $toolbar['toolbar_drawer'][] = $drawer;
+ return $toolbar;
+}
+
+/**
+ * Returns the title of a shortcut set.
+ *
+ * Title callback for the editing pages for shortcut sets.
+ *
+ * @param $shortcut_set
+ * An object representing the shortcut set, as returned by
+ * shortcut_set_load().
+ */
+function shortcut_set_title($shortcut_set) {
+ return check_plain($shortcut_set->title);
+}
+
diff --git a/core/modules/shortcut/shortcut.png b/core/modules/shortcut/shortcut.png
new file mode 100644
index 000000000000..2924557bfaab
--- /dev/null
+++ b/core/modules/shortcut/shortcut.png
Binary files differ
diff --git a/core/modules/shortcut/shortcut.test b/core/modules/shortcut/shortcut.test
new file mode 100644
index 000000000000..322c63f1119b
--- /dev/null
+++ b/core/modules/shortcut/shortcut.test
@@ -0,0 +1,369 @@
+<?php
+
+/**
+ * @file
+ * Tests for shortcut.module.
+ */
+
+/**
+ * Defines base class for shortcut test cases.
+ */
+class ShortcutTestCase extends DrupalWebTestCase {
+
+ /**
+ * User with permission to administer shortcuts.
+ */
+ protected $admin_user;
+
+ /**
+ * User with permission to use shortcuts, but not administer them.
+ */
+ protected $shortcut_user;
+
+ /**
+ * Generic node used for testing.
+ */
+ protected $node;
+
+ /**
+ * Site-wide default shortcut set.
+ */
+ protected $set;
+
+ function setUp() {
+ parent::setUp('toolbar', 'shortcut');
+ // Create users.
+ $this->admin_user = $this->drupalCreateUser(array('access toolbar', 'administer shortcuts', 'view the administration theme', 'create article content', 'create page content', 'access content overview'));
+ $this->shortcut_user = $this->drupalCreateUser(array('customize shortcut links', 'switch shortcut sets'));
+
+ // Create a node.
+ $this->node = $this->drupalCreateNode(array('type' => 'article'));
+
+ // Log in as admin and grab the default shortcut set.
+ $this->drupalLogin($this->admin_user);
+ $this->set = shortcut_set_load(SHORTCUT_DEFAULT_SET_NAME);
+ shortcut_set_assign_user($this->set, $this->admin_user);
+ }
+
+ /**
+ * Creates a generic shortcut set.
+ */
+ function generateShortcutSet($title = '', $default_links = TRUE) {
+ $set = new stdClass();
+ $set->title = empty($title) ? $this->randomName(10) : $title;
+ if ($default_links) {
+ $set->links = array();
+ $set->links[] = $this->generateShortcutLink('node/add');
+ $set->links[] = $this->generateShortcutLink('admin/content');
+ }
+ shortcut_set_save($set);
+
+ return $set;
+ }
+
+ /**
+ * Creates a generic shortcut link.
+ */
+ function generateShortcutLink($path, $title = '') {
+ $link = array(
+ 'link_path' => $path,
+ 'link_title' => !empty($title) ? $title : $this->randomName(10),
+ );
+
+ return $link;
+ }
+
+ /**
+ * Extracts information from shortcut set links.
+ *
+ * @param object $set
+ * The shortcut set object to extract information from.
+ * @param string $key
+ * The array key indicating what information to extract from each link:
+ * - 'link_path': Extract link paths.
+ * - 'link_title': Extract link titles.
+ * - 'mlid': Extract the menu link item ID numbers.
+ *
+ * @return array
+ * Array of the requested information from each link.
+ */
+ function getShortcutInformation($set, $key) {
+ $info = array();
+ foreach ($set->links as $link) {
+ $info[] = $link[$key];
+ }
+ return $info;
+ }
+}
+
+/**
+ * Defines shortcut links test cases.
+ */
+class ShortcutLinksTestCase extends ShortcutTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Shortcut link functionality',
+ 'description' => 'Create, view, edit, delete, and change shortcut links.',
+ 'group' => 'Shortcut',
+ );
+ }
+
+ /**
+ * Tests that creating a shortcut works properly.
+ */
+ function testShortcutLinkAdd() {
+ $set = $this->set;
+
+ // Create an alias for the node so we can test aliases.
+ $path = array(
+ 'source' => 'node/' . $this->node->nid,
+ 'alias' => $this->randomName(8),
+ );
+ path_save($path);
+
+ // Create some paths to test.
+ $test_cases = array(
+ array('path' => 'admin'),
+ array('path' => 'admin/config/system/site-information'),
+ array('path' => "node/{$this->node->nid}/edit"),
+ array('path' => $path['alias']),
+ );
+
+ // Check that each new shortcut links where it should.
+ foreach ($test_cases as $test) {
+ $title = $this->randomName(10);
+ $form_data = array(
+ 'shortcut_link[link_title]' => $title,
+ 'shortcut_link[link_path]' => $test['path'],
+ );
+ $this->drupalPost('admin/config/user-interface/shortcut/' . $set->set_name . '/add-link', $form_data, t('Save'));
+ $this->assertResponse(200);
+ $saved_set = shortcut_set_load($set->set_name);
+ $paths = $this->getShortcutInformation($saved_set, 'link_path');
+ $this->assertTrue(in_array(drupal_get_normal_path($test['path']), $paths), 'Shortcut created: '. $test['path']);
+ $this->assertLink($title, 0, 'Shortcut link found on the page.');
+ }
+ }
+
+ /**
+ * Tests that the "add to shortcut" link changes to "remove shortcut".
+ */
+ function testShortcutQuickLink() {
+ $this->drupalGet($this->set->links[0]['link_path']);
+ $this->assertRaw(t('Remove from %title shortcuts', array('%title' => $this->set->title)), '"Add to shortcuts" link properly switched to "Remove from shortcuts".');
+ }
+
+ /**
+ * Tests that shortcut links can be renamed.
+ */
+ function testShortcutLinkRename() {
+ $set = $this->set;
+
+ // Attempt to rename shortcut link.
+ $new_link_name = $this->randomName(10);
+
+ $this->drupalPost('admin/config/user-interface/shortcut/link/' . $set->links[0]['mlid'], array('shortcut_link[link_title]' => $new_link_name, 'shortcut_link[link_path]' => $set->links[0]['link_path']), t('Save'));
+ $saved_set = shortcut_set_load($set->set_name);
+ $titles = $this->getShortcutInformation($saved_set, 'link_title');
+ $this->assertTrue(in_array($new_link_name, $titles), 'Shortcut renamed: ' . $new_link_name);
+ $this->assertLink($new_link_name, 0, 'Renamed shortcut link appears on the page.');
+ }
+
+ /**
+ * Tests that changing the path of a shortcut link works.
+ */
+ function testShortcutLinkChangePath() {
+ $set = $this->set;
+
+ // Tests changing a shortcut path.
+ $new_link_path = 'admin/config';
+
+ $this->drupalPost('admin/config/user-interface/shortcut/link/' . $set->links[0]['mlid'], array('shortcut_link[link_title]' => $set->links[0]['link_title'], 'shortcut_link[link_path]' => $new_link_path), t('Save'));
+ $saved_set = shortcut_set_load($set->set_name);
+ $paths = $this->getShortcutInformation($saved_set, 'link_path');
+ $this->assertTrue(in_array($new_link_path, $paths), 'Shortcut path changed: ' . $new_link_path);
+ $this->assertLinkByHref($new_link_path, 0, 'Shortcut with new path appears on the page.');
+ }
+
+ /**
+ * Tests deleting a shortcut link.
+ */
+ function testShortcutLinkDelete() {
+ $set = $this->set;
+
+ $this->drupalPost('admin/config/user-interface/shortcut/link/' . $set->links[0]['mlid'] . '/delete', array(), 'Delete');
+ $saved_set = shortcut_set_load($set->set_name);
+ $mlids = $this->getShortcutInformation($saved_set, 'mlid');
+ $this->assertFalse(in_array($set->links[0]['mlid'], $mlids), 'Successfully deleted a shortcut.');
+ }
+
+ /**
+ * Tests that the add shortcut link is not displayed for 404/403 errors.
+ *
+ * Tests that the "Add to shortcuts" link is not displayed on a page not
+ * found or a page the user does not have access to.
+ */
+ function testNoShortcutLink() {
+ // Change to a theme that displays shortcuts.
+ variable_set('theme_default', 'seven');
+
+ $this->drupalGet('page-that-does-not-exist');
+ $this->assertNoRaw('add-shortcut', t('Add to shortcuts link was not shown on a page not found.'));
+
+ // The user does not have access to this path.
+ $this->drupalGet('admin/modules');
+ $this->assertNoRaw('add-shortcut', t('Add to shortcuts link was not shown on a page the user does not have access to.'));
+
+ // Verify that the testing mechanism works by verifying the shortcut
+ // link appears on admin/content/node.
+ $this->drupalGet('admin/content/node');
+ $this->assertRaw('add-shortcut', t('Add to shortcuts link was shown on a page the user does have access to.'));
+ }
+}
+
+/**
+ * Defines shortcut set test cases.
+ */
+class ShortcutSetsTestCase extends ShortcutTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Shortcut set functionality',
+ 'description' => 'Create, view, edit, delete, and change shortcut sets.',
+ 'group' => 'Shortcut',
+ );
+ }
+
+ /**
+ * Tests creating a shortcut set.
+ */
+ function testShortcutSetAdd() {
+ $new_set = $this->generateShortcutSet($this->randomName(10));
+ $sets = shortcut_sets();
+ $this->assertTrue(isset($sets[$new_set->set_name]), 'Successfully created a shortcut set.');
+ $this->drupalGet('user/' . $this->admin_user->uid . '/shortcuts');
+ $this->assertText($new_set->title, 'Generated shortcut set was listed as a choice on the user account page.');
+ }
+
+ /**
+ * Tests switching a user's own shortcut set.
+ */
+ function testShortcutSetSwitchOwn() {
+ $new_set = $this->generateShortcutSet($this->randomName(10));
+
+ // Attempt to switch the default shortcut set to the newly created shortcut
+ // set.
+ $this->drupalPost('user/' . $this->admin_user->uid . '/shortcuts', array('set' => $new_set->set_name), t('Change set'));
+ $this->assertResponse(200);
+ $current_set = shortcut_current_displayed_set($this->admin_user);
+ $this->assertTrue($new_set->set_name == $current_set->set_name, 'Successfully switched own shortcut set.');
+ }
+
+ /**
+ * Tests switching another user's shortcut set.
+ */
+ function testShortcutSetAssign() {
+ $new_set = $this->generateShortcutSet($this->randomName(10));
+
+ shortcut_set_assign_user($new_set, $this->shortcut_user);
+ $current_set = shortcut_current_displayed_set($this->shortcut_user);
+ $this->assertTrue($new_set->set_name == $current_set->set_name, "Successfully switched another user's shortcut set.");
+ }
+
+ /**
+ * Tests switching a user's shortcut set and creating one at the same time.
+ */
+ function testShortcutSetSwitchCreate() {
+ $edit = array(
+ 'set' => 'new',
+ 'new' => $this->randomName(10),
+ );
+ $this->drupalPost('user/' . $this->admin_user->uid . '/shortcuts', $edit, t('Change set'));
+ $current_set = shortcut_current_displayed_set($this->admin_user);
+ $this->assertNotEqual($current_set->set_name, $this->set->set_name, 'A shortcut set can be switched to at the same time as it is created.');
+ $this->assertEqual($current_set->title, $edit['new'], 'The new set is correctly assigned to the user.');
+ }
+
+ /**
+ * Tests switching a user's shortcut set without providing a new set name.
+ */
+ function testShortcutSetSwitchNoSetName() {
+ $edit = array('set' => 'new');
+ $this->drupalPost('user/' . $this->admin_user->uid . '/shortcuts', $edit, t('Change set'));
+ $this->assertText(t('The new set name is required.'));
+ $current_set = shortcut_current_displayed_set($this->admin_user);
+ $this->assertEqual($current_set->set_name, $this->set->set_name, 'Attempting to switch to a new shortcut set without providing a set name does not succeed.');
+ }
+
+ /**
+ * Tests that shortcut_set_save() correctly updates existing links.
+ */
+ function testShortcutSetSave() {
+ $set = $this->set;
+ $old_mlids = $this->getShortcutInformation($set, 'mlid');
+
+ $set->links[] = $this->generateShortcutLink('admin', $this->randomName(10));
+ shortcut_set_save($set);
+ $saved_set = shortcut_set_load($set->set_name);
+
+ $new_mlids = $this->getShortcutInformation($saved_set, 'mlid');
+ $this->assertTrue(count(array_intersect($old_mlids, $new_mlids)) == count($old_mlids), 'shortcut_set_save() did not inadvertently change existing mlids.');
+ }
+
+ /**
+ * Tests renaming a shortcut set.
+ */
+ function testShortcutSetRename() {
+ $set = $this->set;
+
+ $new_title = $this->randomName(10);
+ $this->drupalPost('admin/config/user-interface/shortcut/' . $set->set_name . '/edit', array('title' => $new_title), t('Save'));
+ $set = shortcut_set_load($set->set_name);
+ $this->assertTrue($set->title == $new_title, 'Shortcut set has been successfully renamed.');
+ }
+
+ /**
+ * Tests renaming a shortcut set to the same name as another set.
+ */
+ function testShortcutSetRenameAlreadyExists() {
+ $set = $this->generateShortcutSet($this->randomName(10));
+ $existing_title = $this->set->title;
+ $this->drupalPost('admin/config/user-interface/shortcut/' . $set->set_name . '/edit', array('title' => $existing_title), t('Save'));
+ $this->assertRaw(t('The shortcut set %name already exists. Choose another name.', array('%name' => $existing_title)));
+ $set = shortcut_set_load($set->set_name);
+ $this->assertNotEqual($set->title, $existing_title, t('The shortcut set %title cannot be renamed to %new-title because a shortcut set with that title already exists.', array('%title' => $set->title, '%new-title' => $existing_title)));
+ }
+
+ /**
+ * Tests unassigning a shortcut set.
+ */
+ function testShortcutSetUnassign() {
+ $new_set = $this->generateShortcutSet($this->randomName(10));
+
+ shortcut_set_assign_user($new_set, $this->shortcut_user);
+ shortcut_set_unassign_user($this->shortcut_user);
+ $current_set = shortcut_current_displayed_set($this->shortcut_user);
+ $default_set = shortcut_default_set($this->shortcut_user);
+ $this->assertTrue($current_set->set_name == $default_set->set_name, "Successfully unassigned another user's shortcut set.");
+ }
+
+ /**
+ * Tests deleting a shortcut set.
+ */
+ function testShortcutSetDelete() {
+ $new_set = $this->generateShortcutSet($this->randomName(10));
+
+ $this->drupalPost('admin/config/user-interface/shortcut/' . $new_set->set_name . '/delete', array(), t('Delete'));
+ $sets = shortcut_sets();
+ $this->assertFalse(isset($sets[$new_set->set_name]), 'Successfully deleted a shortcut set.');
+ }
+
+ /**
+ * Tests deleting the default shortcut set.
+ */
+ function testShortcutSetDeleteDefault() {
+ $this->drupalGet('admin/config/user-interface/shortcut/' . SHORTCUT_DEFAULT_SET_NAME . '/delete');
+ $this->assertResponse(403);
+ }
+}
diff --git a/core/modules/simpletest/drupal_web_test_case.php b/core/modules/simpletest/drupal_web_test_case.php
new file mode 100644
index 000000000000..9d993d475455
--- /dev/null
+++ b/core/modules/simpletest/drupal_web_test_case.php
@@ -0,0 +1,3438 @@
+<?php
+
+/**
+ * Global variable that holds information about the tests being run.
+ *
+ * An array, with the following keys:
+ * - 'test_run_id': the ID of the test being run, in the form 'simpletest_%"
+ * - 'in_child_site': TRUE if the current request is a cURL request from
+ * the parent site.
+ *
+ * @var array
+ */
+global $drupal_test_info;
+
+/**
+ * Base class for Drupal tests.
+ *
+ * Do not extend this class, use one of the subclasses in this file.
+ */
+abstract class DrupalTestCase {
+ /**
+ * The test run ID.
+ *
+ * @var string
+ */
+ protected $testId;
+
+ /**
+ * The database prefix of this test run.
+ *
+ * @var string
+ */
+ protected $databasePrefix = NULL;
+
+ /**
+ * The original file directory, before it was changed for testing purposes.
+ *
+ * @var string
+ */
+ protected $originalFileDirectory = NULL;
+
+ /**
+ * Time limit for the test.
+ */
+ protected $timeLimit = 500;
+
+ /**
+ * Current results of this test case.
+ *
+ * @var Array
+ */
+ public $results = array(
+ '#pass' => 0,
+ '#fail' => 0,
+ '#exception' => 0,
+ '#debug' => 0,
+ );
+
+ /**
+ * Assertions thrown in that test case.
+ *
+ * @var Array
+ */
+ protected $assertions = array();
+
+ /**
+ * This class is skipped when looking for the source of an assertion.
+ *
+ * When displaying which function an assert comes from, it's not too useful
+ * to see "drupalWebTestCase->drupalLogin()', we would like to see the test
+ * that called it. So we need to skip the classes defining these helper
+ * methods.
+ */
+ protected $skipClasses = array(__CLASS__ => TRUE);
+
+ /**
+ * Constructor for DrupalTestCase.
+ *
+ * @param $test_id
+ * Tests with the same id are reported together.
+ */
+ public function __construct($test_id = NULL) {
+ $this->testId = $test_id;
+ }
+
+ /**
+ * Internal helper: stores the assert.
+ *
+ * @param $status
+ * Can be 'pass', 'fail', 'exception'.
+ * TRUE is a synonym for 'pass', FALSE for 'fail'.
+ * @param $message
+ * The message string.
+ * @param $group
+ * Which group this assert belongs to.
+ * @param $caller
+ * By default, the assert comes from a function whose name starts with
+ * 'test'. Instead, you can specify where this assert originates from
+ * by passing in an associative array as $caller. Key 'file' is
+ * the name of the source file, 'line' is the line number and 'function'
+ * is the caller function itself.
+ */
+ protected function assert($status, $message = '', $group = 'Other', array $caller = NULL) {
+ // Convert boolean status to string status.
+ if (is_bool($status)) {
+ $status = $status ? 'pass' : 'fail';
+ }
+
+ // Increment summary result counter.
+ $this->results['#' . $status]++;
+
+ // Get the function information about the call to the assertion method.
+ if (!$caller) {
+ $caller = $this->getAssertionCall();
+ }
+
+ // Creation assertion array that can be displayed while tests are running.
+ $this->assertions[] = $assertion = array(
+ 'test_id' => $this->testId,
+ 'test_class' => get_class($this),
+ 'status' => $status,
+ 'message' => $message,
+ 'message_group' => $group,
+ 'function' => $caller['function'],
+ 'line' => $caller['line'],
+ 'file' => $caller['file'],
+ );
+
+ // Store assertion for display after the test has completed.
+ Database::getConnection('default', 'simpletest_original_default')
+ ->insert('simpletest')
+ ->fields($assertion)
+ ->execute();
+
+ // We do not use a ternary operator here to allow a breakpoint on
+ // test failure.
+ if ($status == 'pass') {
+ return TRUE;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Store an assertion from outside the testing context.
+ *
+ * This is useful for inserting assertions that can only be recorded after
+ * the test case has been destroyed, such as PHP fatal errors. The caller
+ * information is not automatically gathered since the caller is most likely
+ * inserting the assertion on behalf of other code. In all other respects
+ * the method behaves just like DrupalTestCase::assert() in terms of storing
+ * the assertion.
+ *
+ * @return
+ * Message ID of the stored assertion.
+ *
+ * @see DrupalTestCase::assert()
+ * @see DrupalTestCase::deleteAssert()
+ */
+ public static function insertAssert($test_id, $test_class, $status, $message = '', $group = 'Other', array $caller = array()) {
+ // Convert boolean status to string status.
+ if (is_bool($status)) {
+ $status = $status ? 'pass' : 'fail';
+ }
+
+ $caller += array(
+ 'function' => t('Unknown'),
+ 'line' => 0,
+ 'file' => t('Unknown'),
+ );
+
+ $assertion = array(
+ 'test_id' => $test_id,
+ 'test_class' => $test_class,
+ 'status' => $status,
+ 'message' => $message,
+ 'message_group' => $group,
+ 'function' => $caller['function'],
+ 'line' => $caller['line'],
+ 'file' => $caller['file'],
+ );
+
+ return db_insert('simpletest')
+ ->fields($assertion)
+ ->execute();
+ }
+
+ /**
+ * Delete an assertion record by message ID.
+ *
+ * @param $message_id
+ * Message ID of the assertion to delete.
+ * @return
+ * TRUE if the assertion was deleted, FALSE otherwise.
+ *
+ * @see DrupalTestCase::insertAssert()
+ */
+ public static function deleteAssert($message_id) {
+ return (bool) db_delete('simpletest')
+ ->condition('message_id', $message_id)
+ ->execute();
+ }
+
+ /**
+ * Cycles through backtrace until the first non-assertion method is found.
+ *
+ * @return
+ * Array representing the true caller.
+ */
+ protected function getAssertionCall() {
+ $backtrace = debug_backtrace();
+
+ // The first element is the call. The second element is the caller.
+ // We skip calls that occurred in one of the methods of our base classes
+ // or in an assertion function.
+ while (($caller = $backtrace[1]) &&
+ ((isset($caller['class']) && isset($this->skipClasses[$caller['class']])) ||
+ substr($caller['function'], 0, 6) == 'assert')) {
+ // We remove that call.
+ array_shift($backtrace);
+ }
+
+ return _drupal_get_last_caller($backtrace);
+ }
+
+ /**
+ * Check to see if a value is not false (not an empty string, 0, NULL, or FALSE).
+ *
+ * @param $value
+ * The value on which the assertion is to be done.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertTrue($value, $message = '', $group = 'Other') {
+ return $this->assert((bool) $value, $message ? $message : t('Value @value is TRUE.', array('@value' => var_export($value, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if a value is false (an empty string, 0, NULL, or FALSE).
+ *
+ * @param $value
+ * The value on which the assertion is to be done.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertFalse($value, $message = '', $group = 'Other') {
+ return $this->assert(!$value, $message ? $message : t('Value @value is FALSE.', array('@value' => var_export($value, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if a value is NULL.
+ *
+ * @param $value
+ * The value on which the assertion is to be done.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNull($value, $message = '', $group = 'Other') {
+ return $this->assert(!isset($value), $message ? $message : t('Value @value is NULL.', array('@value' => var_export($value, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if a value is not NULL.
+ *
+ * @param $value
+ * The value on which the assertion is to be done.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNotNull($value, $message = '', $group = 'Other') {
+ return $this->assert(isset($value), $message ? $message : t('Value @value is not NULL.', array('@value' => var_export($value, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if two values are equal.
+ *
+ * @param $first
+ * The first value to check.
+ * @param $second
+ * The second value to check.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertEqual($first, $second, $message = '', $group = 'Other') {
+ return $this->assert($first == $second, $message ? $message : t('Value @first is equal to value @second.', array('@first' => var_export($first, TRUE), '@second' => var_export($second, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if two values are not equal.
+ *
+ * @param $first
+ * The first value to check.
+ * @param $second
+ * The second value to check.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNotEqual($first, $second, $message = '', $group = 'Other') {
+ return $this->assert($first != $second, $message ? $message : t('Value @first is not equal to value @second.', array('@first' => var_export($first, TRUE), '@second' => var_export($second, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if two values are identical.
+ *
+ * @param $first
+ * The first value to check.
+ * @param $second
+ * The second value to check.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertIdentical($first, $second, $message = '', $group = 'Other') {
+ return $this->assert($first === $second, $message ? $message : t('Value @first is identical to value @second.', array('@first' => var_export($first, TRUE), '@second' => var_export($second, TRUE))), $group);
+ }
+
+ /**
+ * Check to see if two values are not identical.
+ *
+ * @param $first
+ * The first value to check.
+ * @param $second
+ * The second value to check.
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNotIdentical($first, $second, $message = '', $group = 'Other') {
+ return $this->assert($first !== $second, $message ? $message : t('Value @first is not identical to value @second.', array('@first' => var_export($first, TRUE), '@second' => var_export($second, TRUE))), $group);
+ }
+
+ /**
+ * Fire an assertion that is always positive.
+ *
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * TRUE.
+ */
+ protected function pass($message = NULL, $group = 'Other') {
+ return $this->assert(TRUE, $message, $group);
+ }
+
+ /**
+ * Fire an assertion that is always negative.
+ *
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @return
+ * FALSE.
+ */
+ protected function fail($message = NULL, $group = 'Other') {
+ return $this->assert(FALSE, $message, $group);
+ }
+
+ /**
+ * Fire an error assertion.
+ *
+ * @param $message
+ * The message to display along with the assertion.
+ * @param $group
+ * The type of assertion - examples are "Browser", "PHP".
+ * @param $caller
+ * The caller of the error.
+ * @return
+ * FALSE.
+ */
+ protected function error($message = '', $group = 'Other', array $caller = NULL) {
+ if ($group == 'User notice') {
+ // Since 'User notice' is set by trigger_error() which is used for debug
+ // set the message to a status of 'debug'.
+ return $this->assert('debug', $message, 'Debug', $caller);
+ }
+
+ return $this->assert('exception', $message, $group, $caller);
+ }
+
+ /**
+ * Logs verbose message in a text file.
+ *
+ * The a link to the vebose message will be placed in the test results via
+ * as a passing assertion with the text '[verbose message]'.
+ *
+ * @param $message
+ * The verbose message to be stored.
+ *
+ * @see simpletest_verbose()
+ */
+ protected function verbose($message) {
+ if ($id = simpletest_verbose($message)) {
+ $url = file_create_url($this->originalFileDirectory . '/simpletest/verbose/' . get_class($this) . '-' . $id . '.html');
+ $this->error(l(t('Verbose message'), $url, array('attributes' => array('target' => '_blank'))), 'User notice');
+ }
+ }
+
+ /**
+ * Run all tests in this class.
+ *
+ * Regardless of whether $methods are passed or not, only method names
+ * starting with "test" are executed.
+ *
+ * @param $methods
+ * (optional) A list of method names in the test case class to run; e.g.,
+ * array('testFoo', 'testBar'). By default, all methods of the class are
+ * taken into account, but it can be useful to only run a few selected test
+ * methods during debugging.
+ */
+ public function run(array $methods = array()) {
+ // Initialize verbose debugging.
+ simpletest_verbose(NULL, variable_get('file_public_path', conf_path() . '/files'), get_class($this));
+
+ // HTTP auth settings (<username>:<password>) for the simpletest browser
+ // when sending requests to the test site.
+ $this->httpauth_method = variable_get('simpletest_httpauth_method', CURLAUTH_BASIC);
+ $username = variable_get('simpletest_httpauth_username', NULL);
+ $password = variable_get('simpletest_httpauth_password', NULL);
+ if ($username && $password) {
+ $this->httpauth_credentials = $username . ':' . $password;
+ }
+
+ set_error_handler(array($this, 'errorHandler'));
+ $class = get_class($this);
+ // Iterate through all the methods in this class, unless a specific list of
+ // methods to run was passed.
+ $class_methods = get_class_methods($class);
+ if ($methods) {
+ $class_methods = array_intersect($class_methods, $methods);
+ }
+ foreach ($class_methods as $method) {
+ // If the current method starts with "test", run it - it's a test.
+ if (strtolower(substr($method, 0, 4)) == 'test') {
+ // Insert a fail record. This will be deleted on completion to ensure
+ // that testing completed.
+ $method_info = new ReflectionMethod($class, $method);
+ $caller = array(
+ 'file' => $method_info->getFileName(),
+ 'line' => $method_info->getStartLine(),
+ 'function' => $class . '->' . $method . '()',
+ );
+ $completion_check_id = DrupalTestCase::insertAssert($this->testId, $class, FALSE, t('The test did not complete due to a fatal error.'), 'Completion check', $caller);
+ $this->setUp();
+ try {
+ $this->$method();
+ // Finish up.
+ }
+ catch (Exception $e) {
+ $this->exceptionHandler($e);
+ }
+ $this->tearDown();
+ // Remove the completion check record.
+ DrupalTestCase::deleteAssert($completion_check_id);
+ }
+ }
+ // Clear out the error messages and restore error handler.
+ drupal_get_messages();
+ restore_error_handler();
+ }
+
+ /**
+ * Handle errors during test runs.
+ *
+ * Because this is registered in set_error_handler(), it has to be public.
+ * @see set_error_handler
+ */
+ public function errorHandler($severity, $message, $file = NULL, $line = NULL) {
+ if ($severity & error_reporting()) {
+ $error_map = array(
+ E_STRICT => 'Run-time notice',
+ E_WARNING => 'Warning',
+ E_NOTICE => 'Notice',
+ E_CORE_ERROR => 'Core error',
+ E_CORE_WARNING => 'Core warning',
+ E_USER_ERROR => 'User error',
+ E_USER_WARNING => 'User warning',
+ E_USER_NOTICE => 'User notice',
+ E_RECOVERABLE_ERROR => 'Recoverable error',
+ );
+
+ $backtrace = debug_backtrace();
+ $this->error($message, $error_map[$severity], _drupal_get_last_caller($backtrace));
+ }
+ return TRUE;
+ }
+
+ /**
+ * Handle exceptions.
+ *
+ * @see set_exception_handler
+ */
+ protected function exceptionHandler($exception) {
+ $backtrace = $exception->getTrace();
+ // Push on top of the backtrace the call that generated the exception.
+ array_unshift($backtrace, array(
+ 'line' => $exception->getLine(),
+ 'file' => $exception->getFile(),
+ ));
+ require_once DRUPAL_ROOT . '/core/includes/errors.inc';
+ // The exception message is run through check_plain() by _drupal_decode_exception().
+ $this->error(t('%type: !message in %function (line %line of %file).', _drupal_decode_exception($exception)), 'Uncaught exception', _drupal_get_last_caller($backtrace));
+ }
+
+ /**
+ * Generates a random string of ASCII characters of codes 32 to 126.
+ *
+ * The generated string includes alpha-numeric characters and common misc
+ * characters. Use this method when testing general input where the content
+ * is not restricted.
+ *
+ * @param $length
+ * Length of random string to generate.
+ * @return
+ * Randomly generated string.
+ */
+ public static function randomString($length = 8) {
+ $str = '';
+ for ($i = 0; $i < $length; $i++) {
+ $str .= chr(mt_rand(32, 126));
+ }
+ return $str;
+ }
+
+ /**
+ * Generates a random string containing letters and numbers.
+ *
+ * The string will always start with a letter. The letters may be upper or
+ * lower case. This method is better for restricted inputs that do not
+ * accept certain characters. For example, when testing input fields that
+ * require machine readable values (i.e. without spaces and non-standard
+ * characters) this method is best.
+ *
+ * @param $length
+ * Length of random string to generate.
+ * @return
+ * Randomly generated string.
+ */
+ public static function randomName($length = 8) {
+ $values = array_merge(range(65, 90), range(97, 122), range(48, 57));
+ $max = count($values) - 1;
+ $str = chr(mt_rand(97, 122));
+ for ($i = 1; $i < $length; $i++) {
+ $str .= chr($values[mt_rand(0, $max)]);
+ }
+ return $str;
+ }
+
+ /**
+ * Converts a list of possible parameters into a stack of permutations.
+ *
+ * Takes a list of parameters containing possible values, and converts all of
+ * them into a list of items containing every possible permutation.
+ *
+ * Example:
+ * @code
+ * $parameters = array(
+ * 'one' => array(0, 1),
+ * 'two' => array(2, 3),
+ * );
+ * $permutations = $this->permute($parameters);
+ * // Result:
+ * $permutations == array(
+ * array('one' => 0, 'two' => 2),
+ * array('one' => 1, 'two' => 2),
+ * array('one' => 0, 'two' => 3),
+ * array('one' => 1, 'two' => 3),
+ * )
+ * @endcode
+ *
+ * @param $parameters
+ * An associative array of parameters, keyed by parameter name, and whose
+ * values are arrays of parameter values.
+ *
+ * @return
+ * A list of permutations, which is an array of arrays. Each inner array
+ * contains the full list of parameters that have been passed, but with a
+ * single value only.
+ */
+ public static function generatePermutations($parameters) {
+ $all_permutations = array(array());
+ foreach ($parameters as $parameter => $values) {
+ $new_permutations = array();
+ // Iterate over all values of the parameter.
+ foreach ($values as $value) {
+ // Iterate over all existing permutations.
+ foreach ($all_permutations as $permutation) {
+ // Add the new parameter value to existing permutations.
+ $new_permutations[] = $permutation + array($parameter => $value);
+ }
+ }
+ // Replace the old permutations with the new permutations.
+ $all_permutations = $new_permutations;
+ }
+ return $all_permutations;
+ }
+}
+
+/**
+ * Test case for Drupal unit tests.
+ *
+ * These tests can not access the database nor files. Calling any Drupal
+ * function that needs the database will throw exceptions. These include
+ * watchdog(), module_implements(), module_invoke_all() etc.
+ */
+class DrupalUnitTestCase extends DrupalTestCase {
+
+ /**
+ * Constructor for DrupalUnitTestCase.
+ */
+ function __construct($test_id = NULL) {
+ parent::__construct($test_id);
+ $this->skipClasses[__CLASS__] = TRUE;
+ }
+
+ /**
+ * Sets up unit test environment.
+ *
+ * Unlike DrupalWebTestCase::setUp(), DrupalUnitTestCase::setUp() does not
+ * install modules because tests are performed without accessing the database.
+ * Any required files must be explicitly included by the child class setUp()
+ * method.
+ */
+ protected function setUp() {
+ global $conf;
+
+ // Store necessary current values before switching to the test environment.
+ $this->originalFileDirectory = variable_get('file_public_path', conf_path() . '/files');
+
+ // Reset all statics so that test is performed with a clean environment.
+ drupal_static_reset();
+
+ // Generate temporary prefixed database to ensure that tests have a clean starting point.
+ $this->databasePrefix = Database::getConnection()->prefixTables('{simpletest' . mt_rand(1000, 1000000) . '}');
+
+ // Create test directory.
+ $public_files_directory = $this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10);
+ file_prepare_directory($public_files_directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+ $conf['file_public_path'] = $public_files_directory;
+
+ // Clone the current connection and replace the current prefix.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::renameConnection('default', 'simpletest_original_default');
+ foreach ($connection_info as $target => $value) {
+ $connection_info[$target]['prefix'] = array(
+ 'default' => $value['prefix']['default'] . $this->databasePrefix,
+ );
+ }
+ Database::addConnectionInfo('default', 'default', $connection_info['default']);
+
+ // Set user agent to be consistent with web test case.
+ $_SERVER['HTTP_USER_AGENT'] = $this->databasePrefix;
+
+ // If locale is enabled then t() will try to access the database and
+ // subsequently will fail as the database is not accessible.
+ $module_list = module_list();
+ if (isset($module_list['locale'])) {
+ $this->originalModuleList = $module_list;
+ unset($module_list['locale']);
+ module_list(TRUE, FALSE, FALSE, $module_list);
+ }
+ }
+
+ protected function tearDown() {
+ global $conf;
+
+ // Get back to the original connection.
+ Database::removeConnection('default');
+ Database::renameConnection('simpletest_original_default', 'default');
+
+ $conf['file_public_path'] = $this->originalFileDirectory;
+ // Restore modules if necessary.
+ if (isset($this->originalModuleList)) {
+ module_list(TRUE, FALSE, FALSE, $this->originalModuleList);
+ }
+ }
+}
+
+/**
+ * Test case for typical Drupal tests.
+ */
+class DrupalWebTestCase extends DrupalTestCase {
+ /**
+ * The profile to install as a basis for testing.
+ *
+ * @var string
+ */
+ protected $profile = 'standard';
+
+ /**
+ * The URL currently loaded in the internal browser.
+ *
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * The handle of the current cURL connection.
+ *
+ * @var resource
+ */
+ protected $curlHandle;
+
+ /**
+ * The headers of the page currently loaded in the internal browser.
+ *
+ * @var Array
+ */
+ protected $headers;
+
+ /**
+ * The content of the page currently loaded in the internal browser.
+ *
+ * @var string
+ */
+ protected $content;
+
+ /**
+ * The content of the page currently loaded in the internal browser (plain text version).
+ *
+ * @var string
+ */
+ protected $plainTextContent;
+
+ /**
+ * The value of the Drupal.settings JavaScript variable for the page currently loaded in the internal browser.
+ *
+ * @var Array
+ */
+ protected $drupalSettings;
+
+ /**
+ * The parsed version of the page.
+ *
+ * @var SimpleXMLElement
+ */
+ protected $elements = NULL;
+
+ /**
+ * The current user logged in using the internal browser.
+ *
+ * @var bool
+ */
+ protected $loggedInUser = FALSE;
+
+ /**
+ * The current cookie file used by cURL.
+ *
+ * We do not reuse the cookies in further runs, so we do not need a file
+ * but we still need cookie handling, so we set the jar to NULL.
+ */
+ protected $cookieFile = NULL;
+
+ /**
+ * Additional cURL options.
+ *
+ * DrupalWebTestCase itself never sets this but always obeys what is set.
+ */
+ protected $additionalCurlOptions = array();
+
+ /**
+ * The original user, before it was changed to a clean uid = 1 for testing purposes.
+ *
+ * @var object
+ */
+ protected $originalUser = NULL;
+
+ /**
+ * The original shutdown handlers array, before it was cleaned for testing purposes.
+ *
+ * @var array
+ */
+ protected $originalShutdownCallbacks = array();
+
+ /**
+ * HTTP authentication method
+ */
+ protected $httpauth_method = CURLAUTH_BASIC;
+
+ /**
+ * HTTP authentication credentials (<username>:<password>).
+ */
+ protected $httpauth_credentials = NULL;
+
+ /**
+ * The current session name, if available.
+ */
+ protected $session_name = NULL;
+
+ /**
+ * The current session ID, if available.
+ */
+ protected $session_id = NULL;
+
+ /**
+ * Whether the files were copied to the test files directory.
+ */
+ protected $generatedTestFiles = FALSE;
+
+ /**
+ * The number of redirects followed during the handling of a request.
+ */
+ protected $redirect_count;
+
+ /**
+ * Constructor for DrupalWebTestCase.
+ */
+ function __construct($test_id = NULL) {
+ parent::__construct($test_id);
+ $this->skipClasses[__CLASS__] = TRUE;
+ }
+
+ /**
+ * Get a node from the database based on its title.
+ *
+ * @param $title
+ * A node title, usually generated by $this->randomName().
+ * @param $reset
+ * (optional) Whether to reset the internal node_load() cache.
+ *
+ * @return
+ * A node object matching $title.
+ */
+ function drupalGetNodeByTitle($title, $reset = FALSE) {
+ $nodes = node_load_multiple(array(), array('title' => $title), $reset);
+ // Load the first node returned from the database.
+ $returned_node = reset($nodes);
+ return $returned_node;
+ }
+
+ /**
+ * Creates a node based on default settings.
+ *
+ * @param $settings
+ * An associative array of settings to change from the defaults, keys are
+ * node properties, for example 'title' => 'Hello, world!'.
+ * @return
+ * Created node object.
+ */
+ protected function drupalCreateNode($settings = array()) {
+ // Populate defaults array.
+ $settings += array(
+ 'body' => array(LANGUAGE_NONE => array(array())),
+ 'title' => $this->randomName(8),
+ 'comment' => 2,
+ 'changed' => REQUEST_TIME,
+ 'moderate' => 0,
+ 'promote' => 0,
+ 'revision' => 1,
+ 'log' => '',
+ 'status' => 1,
+ 'sticky' => 0,
+ 'type' => 'page',
+ 'revisions' => NULL,
+ 'language' => LANGUAGE_NONE,
+ );
+
+ // Use the original node's created time for existing nodes.
+ if (isset($settings['created']) && !isset($settings['date'])) {
+ $settings['date'] = format_date($settings['created'], 'custom', 'Y-m-d H:i:s O');
+ }
+
+ // If the node's user uid is not specified manually, use the currently
+ // logged in user if available, or else the user running the test.
+ if (!isset($settings['uid'])) {
+ if ($this->loggedInUser) {
+ $settings['uid'] = $this->loggedInUser->uid;
+ }
+ else {
+ global $user;
+ $settings['uid'] = $user->uid;
+ }
+ }
+
+ // Merge body field value and format separately.
+ $body = array(
+ 'value' => $this->randomName(32),
+ 'format' => filter_default_format(),
+ );
+ $settings['body'][$settings['language']][0] += $body;
+
+ $node = (object) $settings;
+ node_save($node);
+
+ // Small hack to link revisions to our test user.
+ db_update('node_revision')
+ ->fields(array('uid' => $node->uid))
+ ->condition('vid', $node->vid)
+ ->execute();
+ return $node;
+ }
+
+ /**
+ * Creates a custom content type based on default settings.
+ *
+ * @param $settings
+ * An array of settings to change from the defaults.
+ * Example: 'type' => 'foo'.
+ * @return
+ * Created content type.
+ */
+ protected function drupalCreateContentType($settings = array()) {
+ // Find a non-existent random type name.
+ do {
+ $name = strtolower($this->randomName(8));
+ } while (node_type_get_type($name));
+
+ // Populate defaults array.
+ $defaults = array(
+ 'type' => $name,
+ 'name' => $name,
+ 'base' => 'node_content',
+ 'description' => '',
+ 'help' => '',
+ 'title_label' => 'Title',
+ 'body_label' => 'Body',
+ 'has_title' => 1,
+ 'has_body' => 1,
+ );
+ // Imposed values for a custom type.
+ $forced = array(
+ 'orig_type' => '',
+ 'old_type' => '',
+ 'module' => 'node',
+ 'custom' => 1,
+ 'modified' => 1,
+ 'locked' => 0,
+ );
+ $type = $forced + $settings + $defaults;
+ $type = (object) $type;
+
+ $saved_type = node_type_save($type);
+ node_types_rebuild();
+ menu_rebuild();
+ node_add_body_field($type);
+
+ $this->assertEqual($saved_type, SAVED_NEW, t('Created content type %type.', array('%type' => $type->type)));
+
+ // Reset permissions so that permissions for this content type are available.
+ $this->checkPermissions(array(), TRUE);
+
+ return $type;
+ }
+
+ /**
+ * Get a list files that can be used in tests.
+ *
+ * @param $type
+ * File type, possible values: 'binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'.
+ * @param $size
+ * File size in bytes to match. Please check the tests/files folder.
+ * @return
+ * List of files that match filter.
+ */
+ protected function drupalGetTestFiles($type, $size = NULL) {
+ if (empty($this->generatedTestFiles)) {
+ // Generate binary test files.
+ $lines = array(64, 1024);
+ $count = 0;
+ foreach ($lines as $line) {
+ simpletest_generate_file('binary-' . $count++, 64, $line, 'binary');
+ }
+
+ // Generate text test files.
+ $lines = array(16, 256, 1024, 2048, 20480);
+ $count = 0;
+ foreach ($lines as $line) {
+ simpletest_generate_file('text-' . $count++, 64, $line);
+ }
+
+ // Copy other test files from simpletest.
+ $original = drupal_get_path('module', 'simpletest') . '/files';
+ $files = file_scan_directory($original, '/(html|image|javascript|php|sql)-.*/');
+ foreach ($files as $file) {
+ file_unmanaged_copy($file->uri, variable_get('file_public_path', conf_path() . '/files'));
+ }
+
+ $this->generatedTestFiles = TRUE;
+ }
+
+ $files = array();
+ // Make sure type is valid.
+ if (in_array($type, array('binary', 'html', 'image', 'javascript', 'php', 'sql', 'text'))) {
+ $files = file_scan_directory('public://', '/' . $type . '\-.*/');
+
+ // If size is set then remove any files that are not of that size.
+ if ($size !== NULL) {
+ foreach ($files as $file) {
+ $stats = stat($file->uri);
+ if ($stats['size'] != $size) {
+ unset($files[$file->uri]);
+ }
+ }
+ }
+ }
+ usort($files, array($this, 'drupalCompareFiles'));
+ return $files;
+ }
+
+ /**
+ * Compare two files based on size and file name.
+ */
+ protected function drupalCompareFiles($file1, $file2) {
+ $compare_size = filesize($file1->uri) - filesize($file2->uri);
+ if ($compare_size) {
+ // Sort by file size.
+ return $compare_size;
+ }
+ else {
+ // The files were the same size, so sort alphabetically.
+ return strnatcmp($file1->name, $file2->name);
+ }
+ }
+
+ /**
+ * Create a user with a given set of permissions. The permissions correspond to the
+ * names given on the privileges page.
+ *
+ * @param $permissions
+ * Array of permission names to assign to user.
+ * @return
+ * A fully loaded user object with pass_raw property, or FALSE if account
+ * creation fails.
+ */
+ protected function drupalCreateUser($permissions = array('access comments', 'access content', 'post comments', 'skip comment approval')) {
+ // Create a role with the given permission set.
+ if (!($rid = $this->drupalCreateRole($permissions))) {
+ return FALSE;
+ }
+
+ // Create a user assigned to that role.
+ $edit = array();
+ $edit['name'] = $this->randomName();
+ $edit['mail'] = $edit['name'] . '@example.com';
+ $edit['roles'] = array($rid => $rid);
+ $edit['pass'] = user_password();
+ $edit['status'] = 1;
+
+ $account = user_save(drupal_anonymous_user(), $edit);
+
+ $this->assertTrue(!empty($account->uid), t('User created with name %name and pass %pass', array('%name' => $edit['name'], '%pass' => $edit['pass'])), t('User login'));
+ if (empty($account->uid)) {
+ return FALSE;
+ }
+
+ // Add the raw password so that we can log in as this user.
+ $account->pass_raw = $edit['pass'];
+ return $account;
+ }
+
+ /**
+ * Internal helper function; Create a role with specified permissions.
+ *
+ * @param $permissions
+ * Array of permission names to assign to role.
+ * @param $name
+ * (optional) String for the name of the role. Defaults to a random string.
+ * @return
+ * Role ID of newly created role, or FALSE if role creation failed.
+ */
+ protected function drupalCreateRole(array $permissions, $name = NULL) {
+ // Generate random name if it was not passed.
+ if (!$name) {
+ $name = $this->randomName();
+ }
+
+ // Check the all the permissions strings are valid.
+ if (!$this->checkPermissions($permissions)) {
+ return FALSE;
+ }
+
+ // Create new role.
+ $role = new stdClass();
+ $role->name = $name;
+ user_role_save($role);
+ user_role_grant_permissions($role->rid, $permissions);
+
+ $this->assertTrue(isset($role->rid), t('Created role of name: @name, id: @rid', array('@name' => $name, '@rid' => (isset($role->rid) ? $role->rid : t('-n/a-')))), t('Role'));
+ if ($role && !empty($role->rid)) {
+ $count = db_query('SELECT COUNT(*) FROM {role_permission} WHERE rid = :rid', array(':rid' => $role->rid))->fetchField();
+ $this->assertTrue($count == count($permissions), t('Created permissions: @perms', array('@perms' => implode(', ', $permissions))), t('Role'));
+ return $role->rid;
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Check to make sure that the array of permissions are valid.
+ *
+ * @param $permissions
+ * Permissions to check.
+ * @param $reset
+ * Reset cached available permissions.
+ * @return
+ * TRUE or FALSE depending on whether the permissions are valid.
+ */
+ protected function checkPermissions(array $permissions, $reset = FALSE) {
+ $available = &drupal_static(__FUNCTION__);
+
+ if (!isset($available) || $reset) {
+ $available = array_keys(module_invoke_all('permission'));
+ }
+
+ $valid = TRUE;
+ foreach ($permissions as $permission) {
+ if (!in_array($permission, $available)) {
+ $this->fail(t('Invalid permission %permission.', array('%permission' => $permission)), t('Role'));
+ $valid = FALSE;
+ }
+ }
+ return $valid;
+ }
+
+ /**
+ * Log in a user with the internal browser.
+ *
+ * If a user is already logged in, then the current user is logged out before
+ * logging in the specified user.
+ *
+ * Please note that neither the global $user nor the passed-in user object is
+ * populated with data of the logged in user. If you need full access to the
+ * user object after logging in, it must be updated manually. If you also need
+ * access to the plain-text password of the user (set by drupalCreateUser()),
+ * e.g. to log in the same user again, then it must be re-assigned manually.
+ * For example:
+ * @code
+ * // Create a user.
+ * $account = $this->drupalCreateUser(array());
+ * $this->drupalLogin($account);
+ * // Load real user object.
+ * $pass_raw = $account->pass_raw;
+ * $account = user_load($account->uid);
+ * $account->pass_raw = $pass_raw;
+ * @endcode
+ *
+ * @param $user
+ * User object representing the user to log in.
+ *
+ * @see drupalCreateUser()
+ */
+ protected function drupalLogin(stdClass $user) {
+ if ($this->loggedInUser) {
+ $this->drupalLogout();
+ }
+
+ $edit = array(
+ 'name' => $user->name,
+ 'pass' => $user->pass_raw
+ );
+ $this->drupalPost('user', $edit, t('Log in'));
+
+ // If a "log out" link appears on the page, it is almost certainly because
+ // the login was successful.
+ $pass = $this->assertLink(t('Log out'), 0, t('User %name successfully logged in.', array('%name' => $user->name)), t('User login'));
+
+ if ($pass) {
+ $this->loggedInUser = $user;
+ }
+ }
+
+ /**
+ * Generate a token for the currently logged in user.
+ */
+ protected function drupalGetToken($value = '') {
+ $private_key = drupal_get_private_key();
+ return drupal_hmac_base64($value, $this->session_id . $private_key);
+ }
+
+ /*
+ * Logs a user out of the internal browser, then check the login page to confirm logout.
+ */
+ protected function drupalLogout() {
+ // Make a request to the logout page, and redirect to the user page, the
+ // idea being if you were properly logged out you should be seeing a login
+ // screen.
+ $this->drupalGet('user/logout');
+ $this->drupalGet('user');
+ $pass = $this->assertField('name', t('Username field found.'), t('Logout'));
+ $pass = $pass && $this->assertField('pass', t('Password field found.'), t('Logout'));
+
+ if ($pass) {
+ $this->loggedInUser = FALSE;
+ }
+ }
+
+ /**
+ * Generates a random database prefix, runs the install scripts on the
+ * prefixed database and enable the specified modules. After installation
+ * many caches are flushed and the internal browser is setup so that the
+ * page requests will run on the new prefix. A temporary files directory
+ * is created with the same name as the database prefix.
+ *
+ * @param ...
+ * List of modules to enable for the duration of the test. This can be
+ * either a single array or a variable number of string arguments.
+ */
+ protected function setUp() {
+ global $user, $language, $conf;
+
+ // Generate a temporary prefixed database to ensure that tests have a clean starting point.
+ $this->databasePrefix = 'simpletest' . mt_rand(1000, 1000000);
+ db_update('simpletest_test_id')
+ ->fields(array('last_prefix' => $this->databasePrefix))
+ ->condition('test_id', $this->testId)
+ ->execute();
+
+ // Reset all statics and variables to perform tests in a clean environment.
+ $conf = array();
+ drupal_static_reset();
+
+ // Clone the current connection and replace the current prefix.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::renameConnection('default', 'simpletest_original_default');
+ foreach ($connection_info as $target => $value) {
+ $connection_info[$target]['prefix'] = array(
+ 'default' => $value['prefix']['default'] . $this->databasePrefix,
+ );
+ }
+ Database::addConnectionInfo('default', 'default', $connection_info['default']);
+
+ // Store necessary current values before switching to prefixed database.
+ $this->originalLanguage = $language;
+ $this->originalLanguageDefault = variable_get('language_default');
+ $this->originalFileDirectory = variable_get('file_public_path', conf_path() . '/files');
+ $this->originalProfile = drupal_get_profile();
+ $clean_url_original = variable_get('clean_url', 0);
+
+ // Set to English to prevent exceptions from utf8_truncate() from t()
+ // during install if the current language is not 'en'.
+ // The following array/object conversion is copied from language_default().
+ $language = (object) array('language' => 'en', 'name' => 'English', 'native' => 'English', 'direction' => 0, 'enabled' => 1, 'plurals' => 0, 'formula' => '', 'domain' => '', 'prefix' => '', 'weight' => 0, 'javascript' => '');
+
+ // Save and clean shutdown callbacks array because it static cached and
+ // will be changed by the test run. If we don't, then it will contain
+ // callbacks from both environments. So testing environment will try
+ // to call handlers from original environment.
+ $callbacks = &drupal_register_shutdown_function();
+ $this->originalShutdownCallbacks = $callbacks;
+ $callbacks = array();
+
+ // Create test directory ahead of installation so fatal errors and debug
+ // information can be logged during installation process.
+ // Use temporary files directory with the same prefix as the database.
+ $public_files_directory = $this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10);
+ $private_files_directory = $public_files_directory . '/private';
+ $temp_files_directory = $private_files_directory . '/temp';
+
+ // Create the directories
+ file_prepare_directory($public_files_directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+ file_prepare_directory($private_files_directory, FILE_CREATE_DIRECTORY);
+ file_prepare_directory($temp_files_directory, FILE_CREATE_DIRECTORY);
+ $this->generatedTestFiles = FALSE;
+
+ // Log fatal errors.
+ ini_set('log_errors', 1);
+ ini_set('error_log', $public_files_directory . '/error.log');
+
+ // Set the test information for use in other parts of Drupal.
+ $test_info = &$GLOBALS['drupal_test_info'];
+ $test_info['test_run_id'] = $this->databasePrefix;
+ $test_info['in_child_site'] = FALSE;
+
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+ drupal_install_system();
+
+ $this->preloadRegistry();
+
+ // Set path variables.
+ variable_set('file_public_path', $public_files_directory);
+ variable_set('file_private_path', $private_files_directory);
+ variable_set('file_temporary_path', $temp_files_directory);
+
+ // Include the testing profile.
+ variable_set('install_profile', $this->profile);
+ $profile_details = install_profile_info($this->profile, 'en');
+
+ // Install the modules specified by the testing profile.
+ module_enable($profile_details['dependencies'], FALSE);
+
+ // Install modules needed for this test. This could have been passed in as
+ // either a single array argument or a variable number of string arguments.
+ // @todo Remove this compatibility layer in Drupal 8, and only accept
+ // $modules as a single array argument.
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ if ($modules) {
+ $success = module_enable($modules, TRUE);
+ $this->assertTrue($success, t('Enabled modules: %modules', array('%modules' => implode(', ', $modules))));
+ }
+
+ // Run the profile tasks.
+ $install_profile_module_exists = db_query("SELECT 1 FROM {system} WHERE type = 'module' AND name = :name", array(
+ ':name' => $this->profile,
+ ))->fetchField();
+ if ($install_profile_module_exists) {
+ module_enable(array($this->profile), FALSE);
+ }
+
+ // Reset/rebuild all data structures after enabling the modules.
+ $this->resetAll();
+
+ // Run cron once in that environment, as install.php does at the end of
+ // the installation process.
+ drupal_cron_run();
+
+ // Log in with a clean $user.
+ $this->originalUser = $user;
+ drupal_save_session(FALSE);
+ $user = user_load(1);
+
+ // Restore necessary variables.
+ variable_set('install_task', 'done');
+ variable_set('clean_url', $clean_url_original);
+ variable_set('site_mail', 'simpletest@example.com');
+ variable_set('date_default_timezone', date_default_timezone_get());
+ // Set up English language.
+ unset($GLOBALS['conf']['language_default']);
+ $language = language_default();
+
+ // Use the test mail class instead of the default mail handler class.
+ variable_set('mail_system', array('default-system' => 'TestingMailSystem'));
+
+ drupal_set_time_limit($this->timeLimit);
+ }
+
+ /**
+ * Preload the registry from the testing site.
+ *
+ * This method is called by DrupalWebTestCase::setUp(), and preloads the
+ * registry from the testing site to cut down on the time it takes to
+ * set up a clean environment for the current test run.
+ */
+ protected function preloadRegistry() {
+ // Use two separate queries, each with their own connections: copy the
+ // {registry} and {registry_file} tables over from the parent installation
+ // to the child installation.
+ $original_connection = Database::getConnection('default', 'simpletest_original_default');
+ $test_connection = Database::getConnection();
+
+ foreach (array('registry', 'registry_file') as $table) {
+ // Find the records from the parent database.
+ $source_query = $original_connection
+ ->select($table, array(), array('fetch' => PDO::FETCH_ASSOC))
+ ->fields($table);
+
+ $dest_query = $test_connection->insert($table);
+
+ $first = TRUE;
+ foreach ($source_query->execute() as $row) {
+ if ($first) {
+ $dest_query->fields(array_keys($row));
+ $first = FALSE;
+ }
+ // Insert the records into the child database.
+ $dest_query->values($row);
+ }
+
+ $dest_query->execute();
+ }
+ }
+
+ /**
+ * Reset all data structures after having enabled new modules.
+ *
+ * This method is called by DrupalWebTestCase::setUp() after enabling
+ * the requested modules. It must be called again when additional modules
+ * are enabled later.
+ */
+ protected function resetAll() {
+ // Reset all static variables.
+ drupal_static_reset();
+ // Reset the list of enabled modules.
+ module_list(TRUE);
+
+ // Reset cached schema for new database prefix. This must be done before
+ // drupal_flush_all_caches() so rebuilds can make use of the schema of
+ // modules enabled on the cURL side.
+ drupal_get_schema(NULL, TRUE);
+
+ // Perform rebuilds and flush remaining caches.
+ drupal_flush_all_caches();
+
+ // Reload global $conf array and permissions.
+ $this->refreshVariables();
+ $this->checkPermissions(array(), TRUE);
+ }
+
+ /**
+ * Refresh the in-memory set of variables. Useful after a page request is made
+ * that changes a variable in a different thread.
+ *
+ * In other words calling a settings page with $this->drupalPost() with a changed
+ * value would update a variable to reflect that change, but in the thread that
+ * made the call (thread running the test) the changed variable would not be
+ * picked up.
+ *
+ * This method clears the variables cache and loads a fresh copy from the database
+ * to ensure that the most up-to-date set of variables is loaded.
+ */
+ protected function refreshVariables() {
+ global $conf;
+ cache('bootstrap')->delete('variables');
+ $conf = variable_initialize();
+ }
+
+ /**
+ * Delete created files and temporary files directory, delete the tables created by setUp(),
+ * and reset the database prefix.
+ */
+ protected function tearDown() {
+ global $user, $language;
+
+ // In case a fatal error occurred that was not in the test process read the
+ // log to pick up any fatal errors.
+ simpletest_log_read($this->testId, $this->databasePrefix, get_class($this), TRUE);
+
+ $emailCount = count(variable_get('drupal_test_email_collector', array()));
+ if ($emailCount) {
+ $message = format_plural($emailCount, '1 e-mail was sent during this test.', '@count e-mails were sent during this test.');
+ $this->pass($message, t('E-mail'));
+ }
+
+ // Delete temporary files directory.
+ file_unmanaged_delete_recursive($this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10));
+
+ // Remove all prefixed tables (all the tables in the schema).
+ $schema = drupal_get_schema(NULL, TRUE);
+ foreach ($schema as $name => $table) {
+ db_drop_table($name);
+ }
+
+ // Get back to the original connection.
+ Database::removeConnection('default');
+ Database::renameConnection('simpletest_original_default', 'default');
+
+ // Restore original shutdown callbacks array to prevent original
+ // environment of calling handlers from test run.
+ $callbacks = &drupal_register_shutdown_function();
+ $callbacks = $this->originalShutdownCallbacks;
+
+ // Return the user to the original one.
+ $user = $this->originalUser;
+ drupal_save_session(TRUE);
+
+ // Ensure that internal logged in variable and cURL options are reset.
+ $this->loggedInUser = FALSE;
+ $this->additionalCurlOptions = array();
+
+ // Reload module list and implementations to ensure that test module hooks
+ // aren't called after tests.
+ module_list(TRUE);
+ module_implements_reset();
+
+ // Reset the Field API.
+ field_cache_clear();
+
+ // Rebuild caches.
+ $this->refreshVariables();
+
+ // Reset language.
+ $language = $this->originalLanguage;
+ if ($this->originalLanguageDefault) {
+ $GLOBALS['conf']['language_default'] = $this->originalLanguageDefault;
+ }
+
+ // Close the CURL handler.
+ $this->curlClose();
+ }
+
+ /**
+ * Initializes the cURL connection.
+ *
+ * If the simpletest_httpauth_credentials variable is set, this function will
+ * add HTTP authentication headers. This is necessary for testing sites that
+ * are protected by login credentials from public access.
+ * See the description of $curl_options for other options.
+ */
+ protected function curlInitialize() {
+ global $base_url;
+
+ if (!isset($this->curlHandle)) {
+ $this->curlHandle = curl_init();
+ $curl_options = array(
+ CURLOPT_COOKIEJAR => $this->cookieFile,
+ CURLOPT_URL => $base_url,
+ CURLOPT_FOLLOWLOCATION => FALSE,
+ CURLOPT_RETURNTRANSFER => TRUE,
+ CURLOPT_SSL_VERIFYPEER => FALSE, // Required to make the tests run on https.
+ CURLOPT_SSL_VERIFYHOST => FALSE, // Required to make the tests run on https.
+ CURLOPT_HEADERFUNCTION => array(&$this, 'curlHeaderCallback'),
+ CURLOPT_USERAGENT => $this->databasePrefix,
+ );
+ if (isset($this->httpauth_credentials)) {
+ $curl_options[CURLOPT_HTTPAUTH] = $this->httpauth_method;
+ $curl_options[CURLOPT_USERPWD] = $this->httpauth_credentials;
+ }
+ curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options);
+
+ // By default, the child session name should be the same as the parent.
+ $this->session_name = session_name();
+ }
+ // We set the user agent header on each request so as to use the current
+ // time and a new uniqid.
+ if (preg_match('/simpletest\d+/', $this->databasePrefix, $matches)) {
+ curl_setopt($this->curlHandle, CURLOPT_USERAGENT, drupal_generate_test_ua($matches[0]));
+ }
+ }
+
+ /**
+ * Initializes and executes a cURL request.
+ *
+ * @param $curl_options
+ * An associative array of cURL options to set, where the keys are constants
+ * defined by the cURL library. For a list of valid options, see
+ * http://www.php.net/manual/function.curl-setopt.php
+ * @param $redirect
+ * FALSE if this is an initial request, TRUE if this request is the result
+ * of a redirect.
+ *
+ * @return
+ * The content returned from the call to curl_exec().
+ *
+ * @see curlInitialize()
+ */
+ protected function curlExec($curl_options, $redirect = FALSE) {
+ $this->curlInitialize();
+
+ // cURL incorrectly handles URLs with a fragment by including the
+ // fragment in the request to the server, causing some web servers
+ // to reject the request citing "400 - Bad Request". To prevent
+ // this, we strip the fragment from the request.
+ // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0.
+ if (!empty($curl_options[CURLOPT_URL]) && strpos($curl_options[CURLOPT_URL], '#')) {
+ $original_url = $curl_options[CURLOPT_URL];
+ $curl_options[CURLOPT_URL] = strtok($curl_options[CURLOPT_URL], '#');
+ }
+
+ $url = empty($curl_options[CURLOPT_URL]) ? curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL) : $curl_options[CURLOPT_URL];
+
+ if (!empty($curl_options[CURLOPT_POST])) {
+ // This is a fix for the Curl library to prevent Expect: 100-continue
+ // headers in POST requests, that may cause unexpected HTTP response
+ // codes from some webservers (like lighttpd that returns a 417 error
+ // code). It is done by setting an empty "Expect" header field that is
+ // not overwritten by Curl.
+ $curl_options[CURLOPT_HTTPHEADER][] = 'Expect:';
+ }
+ curl_setopt_array($this->curlHandle, $this->additionalCurlOptions + $curl_options);
+
+ if (!$redirect) {
+ // Reset headers, the session ID and the redirect counter.
+ $this->session_id = NULL;
+ $this->headers = array();
+ $this->redirect_count = 0;
+ }
+
+ $content = curl_exec($this->curlHandle);
+ $status = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
+
+ // cURL incorrectly handles URLs with fragments, so instead of
+ // letting cURL handle redirects we take of them ourselves to
+ // to prevent fragments being sent to the web server as part
+ // of the request.
+ // TODO: Remove this for Drupal 8, since fixed in curl 7.20.0.
+ if (in_array($status, array(300, 301, 302, 303, 305, 307)) && $this->redirect_count < variable_get('simpletest_maximum_redirects', 5)) {
+ if ($this->drupalGetHeader('location')) {
+ $this->redirect_count++;
+ $curl_options = array();
+ $curl_options[CURLOPT_URL] = $this->drupalGetHeader('location');
+ $curl_options[CURLOPT_HTTPGET] = TRUE;
+ return $this->curlExec($curl_options, TRUE);
+ }
+ }
+
+ $this->drupalSetContent($content, isset($original_url) ? $original_url : curl_getinfo($this->curlHandle, CURLINFO_EFFECTIVE_URL));
+ $message_vars = array(
+ '!method' => !empty($curl_options[CURLOPT_NOBODY]) ? 'HEAD' : (empty($curl_options[CURLOPT_POSTFIELDS]) ? 'GET' : 'POST'),
+ '@url' => isset($original_url) ? $original_url : $url,
+ '@status' => $status,
+ '!length' => format_size(strlen($this->drupalGetContent()))
+ );
+ $message = t('!method @url returned @status (!length).', $message_vars);
+ $this->assertTrue($this->drupalGetContent() !== FALSE, $message, t('Browser'));
+ return $this->drupalGetContent();
+ }
+
+ /**
+ * Reads headers and registers errors received from the tested site.
+ *
+ * @see _drupal_log_error().
+ *
+ * @param $curlHandler
+ * The cURL handler.
+ * @param $header
+ * An header.
+ */
+ protected function curlHeaderCallback($curlHandler, $header) {
+ $this->headers[] = $header;
+
+ // Errors are being sent via X-Drupal-Assertion-* headers,
+ // generated by _drupal_log_error() in the exact form required
+ // by DrupalWebTestCase::error().
+ if (preg_match('/^X-Drupal-Assertion-[0-9]+: (.*)$/', $header, $matches)) {
+ // Call DrupalWebTestCase::error() with the parameters from the header.
+ call_user_func_array(array(&$this, 'error'), unserialize(urldecode($matches[1])));
+ }
+
+ // Save cookies.
+ if (preg_match('/^Set-Cookie: ([^=]+)=(.+)/', $header, $matches)) {
+ $name = $matches[1];
+ $parts = array_map('trim', explode(';', $matches[2]));
+ $value = array_shift($parts);
+ $this->cookies[$name] = array('value' => $value, 'secure' => in_array('secure', $parts));
+ if ($name == $this->session_name) {
+ if ($value != 'deleted') {
+ $this->session_id = $value;
+ }
+ else {
+ $this->session_id = NULL;
+ }
+ }
+ }
+
+ // This is required by cURL.
+ return strlen($header);
+ }
+
+ /**
+ * Close the cURL handler and unset the handler.
+ */
+ protected function curlClose() {
+ if (isset($this->curlHandle)) {
+ curl_close($this->curlHandle);
+ unset($this->curlHandle);
+ }
+ }
+
+ /**
+ * Parse content returned from curlExec using DOM and SimpleXML.
+ *
+ * @return
+ * A SimpleXMLElement or FALSE on failure.
+ */
+ protected function parse() {
+ if (!$this->elements) {
+ // DOM can load HTML soup. But, HTML soup can throw warnings, suppress
+ // them.
+ $htmlDom = new DOMDocument();
+ @$htmlDom->loadHTML($this->drupalGetContent());
+ if ($htmlDom) {
+ $this->pass(t('Valid HTML found on "@path"', array('@path' => $this->getUrl())), t('Browser'));
+ // It's much easier to work with simplexml than DOM, luckily enough
+ // we can just simply import our DOM tree.
+ $this->elements = simplexml_import_dom($htmlDom);
+ }
+ }
+ if (!$this->elements) {
+ $this->fail(t('Parsed page successfully.'), t('Browser'));
+ }
+
+ return $this->elements;
+ }
+
+ /**
+ * Retrieves a Drupal path or an absolute path.
+ *
+ * @param $path
+ * Drupal path or URL to load into internal browser
+ * @param $options
+ * Options to be forwarded to url().
+ * @param $headers
+ * An array containing additional HTTP request headers, each formatted as
+ * "name: value".
+ * @return
+ * The retrieved HTML string, also available as $this->drupalGetContent()
+ */
+ protected function drupalGet($path, array $options = array(), array $headers = array()) {
+ $options['absolute'] = TRUE;
+
+ // We re-using a CURL connection here. If that connection still has certain
+ // options set, it might change the GET into a POST. Make sure we clear out
+ // previous options.
+ $out = $this->curlExec(array(CURLOPT_HTTPGET => TRUE, CURLOPT_URL => url($path, $options), CURLOPT_NOBODY => FALSE, CURLOPT_HTTPHEADER => $headers));
+ $this->refreshVariables(); // Ensure that any changes to variables in the other thread are picked up.
+
+ // Replace original page output with new output from redirected page(s).
+ if ($new = $this->checkForMetaRefresh()) {
+ $out = $new;
+ }
+ $this->verbose('GET request to: ' . $path .
+ '<hr />Ending URL: ' . $this->getUrl() .
+ '<hr />' . $out);
+ return $out;
+ }
+
+ /**
+ * Retrieve a Drupal path or an absolute path and JSON decode the result.
+ */
+ protected function drupalGetAJAX($path, array $options = array(), array $headers = array()) {
+ return drupal_json_decode($this->drupalGet($path, $options, $headers));
+ }
+
+ /**
+ * Execute a POST request on a Drupal page.
+ * It will be done as usual POST request with SimpleBrowser.
+ *
+ * @param $path
+ * Location of the post form. Either a Drupal path or an absolute path or
+ * NULL to post to the current page. For multi-stage forms you can set the
+ * path to NULL and have it post to the last received page. Example:
+ *
+ * @code
+ * // First step in form.
+ * $edit = array(...);
+ * $this->drupalPost('some_url', $edit, t('Save'));
+ *
+ * // Second step in form.
+ * $edit = array(...);
+ * $this->drupalPost(NULL, $edit, t('Save'));
+ * @endcode
+ * @param $edit
+ * Field data in an associative array. Changes the current input fields
+ * (where possible) to the values indicated. A checkbox can be set to
+ * TRUE to be checked and FALSE to be unchecked. Note that when a form
+ * contains file upload fields, other fields cannot start with the '@'
+ * character.
+ *
+ * Multiple select fields can be set using name[] and setting each of the
+ * possible values. Example:
+ * @code
+ * $edit = array();
+ * $edit['name[]'] = array('value1', 'value2');
+ * @endcode
+ * @param $submit
+ * Value of the submit button whose click is to be emulated. For example,
+ * t('Save'). The processing of the request depends on this value. For
+ * example, a form may have one button with the value t('Save') and another
+ * button with the value t('Delete'), and execute different code depending
+ * on which one is clicked.
+ *
+ * This function can also be called to emulate an Ajax submission. In this
+ * case, this value needs to be an array with the following keys:
+ * - path: A path to submit the form values to for Ajax-specific processing,
+ * which is likely different than the $path parameter used for retrieving
+ * the initial form. Defaults to 'system/ajax'.
+ * - triggering_element: If the value for the 'path' key is 'system/ajax' or
+ * another generic Ajax processing path, this needs to be set to the name
+ * of the element. If the name doesn't identify the element uniquely, then
+ * this should instead be an array with a single key/value pair,
+ * corresponding to the element name and value. The callback for the
+ * generic Ajax processing path uses this to find the #ajax information
+ * for the element, including which specific callback to use for
+ * processing the request.
+ *
+ * This can also be set to NULL in order to emulate an Internet Explorer
+ * submission of a form with a single text field, and pressing ENTER in that
+ * textfield: under these conditions, no button information is added to the
+ * POST data.
+ * @param $options
+ * Options to be forwarded to url().
+ * @param $headers
+ * An array containing additional HTTP request headers, each formatted as
+ * "name: value".
+ * @param $form_html_id
+ * (optional) HTML ID of the form to be submitted. On some pages
+ * there are many identical forms, so just using the value of the submit
+ * button is not enough. For example: 'trigger-node-presave-assign-form'.
+ * Note that this is not the Drupal $form_id, but rather the HTML ID of the
+ * form, which is typically the same thing but with hyphens replacing the
+ * underscores.
+ * @param $extra_post
+ * (optional) A string of additional data to append to the POST submission.
+ * This can be used to add POST data for which there are no HTML fields, as
+ * is done by drupalPostAJAX(). This string is literally appended to the
+ * POST data, so it must already be urlencoded and contain a leading "&"
+ * (e.g., "&extra_var1=hello+world&extra_var2=you%26me").
+ */
+ protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
+ $submit_matches = FALSE;
+ $ajax = is_array($submit);
+ if (isset($path)) {
+ $this->drupalGet($path, $options);
+ }
+ if ($this->parse()) {
+ $edit_save = $edit;
+ // Let's iterate over all the forms.
+ $xpath = "//form";
+ if (!empty($form_html_id)) {
+ $xpath .= "[@id='" . $form_html_id . "']";
+ }
+ $forms = $this->xpath($xpath);
+ foreach ($forms as $form) {
+ // We try to set the fields of this form as specified in $edit.
+ $edit = $edit_save;
+ $post = array();
+ $upload = array();
+ $submit_matches = $this->handleForm($post, $edit, $upload, $ajax ? NULL : $submit, $form);
+ $action = isset($form['action']) ? $this->getAbsoluteUrl((string) $form['action']) : $this->getUrl();
+ if ($ajax) {
+ $action = $this->getAbsoluteUrl(!empty($submit['path']) ? $submit['path'] : 'system/ajax');
+ // Ajax callbacks verify the triggering element if necessary, so while
+ // we may eventually want extra code that verifies it in the
+ // handleForm() function, it's not currently a requirement.
+ $submit_matches = TRUE;
+ }
+
+ // We post only if we managed to handle every field in edit and the
+ // submit button matches.
+ if (!$edit && ($submit_matches || !isset($submit))) {
+ $post_array = $post;
+ if ($upload) {
+ // TODO: cURL handles file uploads for us, but the implementation
+ // is broken. This is a less than elegant workaround. Alternatives
+ // are being explored at #253506.
+ foreach ($upload as $key => $file) {
+ $file = drupal_realpath($file);
+ if ($file && is_file($file)) {
+ $post[$key] = '@' . $file;
+ }
+ }
+ }
+ else {
+ foreach ($post as $key => $value) {
+ // Encode according to application/x-www-form-urlencoded
+ // Both names and values needs to be urlencoded, according to
+ // http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1
+ $post[$key] = urlencode($key) . '=' . urlencode($value);
+ }
+ $post = implode('&', $post) . $extra_post;
+ }
+ $out = $this->curlExec(array(CURLOPT_URL => $action, CURLOPT_POST => TRUE, CURLOPT_POSTFIELDS => $post, CURLOPT_HTTPHEADER => $headers));
+ // Ensure that any changes to variables in the other thread are picked up.
+ $this->refreshVariables();
+
+ // Replace original page output with new output from redirected page(s).
+ if ($new = $this->checkForMetaRefresh()) {
+ $out = $new;
+ }
+ $this->verbose('POST request to: ' . $path .
+ '<hr />Ending URL: ' . $this->getUrl() .
+ '<hr />Fields: ' . highlight_string('<?php ' . var_export($post_array, TRUE), TRUE) .
+ '<hr />' . $out);
+ return $out;
+ }
+ }
+ // We have not found a form which contained all fields of $edit.
+ foreach ($edit as $name => $value) {
+ $this->fail(t('Failed to set field @name to @value', array('@name' => $name, '@value' => $value)));
+ }
+ if (!$ajax && isset($submit)) {
+ $this->assertTrue($submit_matches, t('Found the @submit button', array('@submit' => $submit)));
+ }
+ $this->fail(t('Found the requested form fields at @path', array('@path' => $path)));
+ }
+ }
+
+ /**
+ * Execute an Ajax submission.
+ *
+ * This executes a POST as ajax.js does. It uses the returned JSON data, an
+ * array of commands, to update $this->content using equivalent DOM
+ * manipulation as is used by ajax.js. It also returns the array of commands.
+ *
+ * @param $path
+ * Location of the form containing the Ajax enabled element to test. Can be
+ * either a Drupal path or an absolute path or NULL to use the current page.
+ * @param $edit
+ * Field data in an associative array. Changes the current input fields
+ * (where possible) to the values indicated.
+ * @param $triggering_element
+ * The name of the form element that is responsible for triggering the Ajax
+ * functionality to test. May be a string or, if the triggering element is
+ * a button, an associative array where the key is the name of the button
+ * and the value is the button label. i.e.) array('op' => t('Refresh')).
+ * @param $ajax_path
+ * (optional) Override the path set by the Ajax settings of the triggering
+ * element. In the absence of both the triggering element's Ajax path and
+ * $ajax_path 'system/ajax' will be used.
+ * @param $options
+ * (optional) Options to be forwarded to url().
+ * @param $headers
+ * (optional) An array containing additional HTTP request headers, each
+ * formatted as "name: value". Forwarded to drupalPost().
+ * @param $form_html_id
+ * (optional) HTML ID of the form to be submitted, use when there is more
+ * than one identical form on the same page and the value of the triggering
+ * element is not enough to identify the form. Note this is not the Drupal
+ * ID of the form but rather the HTML ID of the form.
+ * @param $ajax_settings
+ * (optional) An array of Ajax settings which if specified will be used in
+ * place of the Ajax settings of the triggering element.
+ *
+ * @return
+ * An array of Ajax commands.
+ *
+ * @see drupalPost()
+ * @see ajax.js
+ */
+ protected function drupalPostAJAX($path, $edit, $triggering_element, $ajax_path = NULL, array $options = array(), array $headers = array(), $form_html_id = NULL, $ajax_settings = NULL) {
+ // Get the content of the initial page prior to calling drupalPost(), since
+ // drupalPost() replaces $this->content.
+ if (isset($path)) {
+ $this->drupalGet($path, $options);
+ }
+ $content = $this->content;
+ $drupal_settings = $this->drupalSettings;
+
+ // Get the Ajax settings bound to the triggering element.
+ if (!isset($ajax_settings)) {
+ if (is_array($triggering_element)) {
+ $xpath = '//*[@name="' . key($triggering_element) . '" and @value="' . current($triggering_element) . '"]';
+ }
+ else {
+ $xpath = '//*[@name="' . $triggering_element . '"]';
+ }
+ if (isset($form_html_id)) {
+ $xpath = '//form[@id="' . $form_html_id . '"]' . $xpath;
+ }
+ $element = $this->xpath($xpath);
+ $element_id = (string) $element[0]['id'];
+ $ajax_settings = $drupal_settings['ajax'][$element_id];
+ }
+
+ // Add extra information to the POST data as ajax.js does.
+ $extra_post = '';
+ if (isset($ajax_settings['submit'])) {
+ foreach ($ajax_settings['submit'] as $key => $value) {
+ $extra_post .= '&' . urlencode($key) . '=' . urlencode($value);
+ }
+ }
+ foreach ($this->xpath('//*[@id]') as $element) {
+ $id = (string) $element['id'];
+ $extra_post .= '&' . urlencode('ajax_html_ids[]') . '=' . urlencode($id);
+ }
+ if (isset($drupal_settings['ajaxPageState'])) {
+ $extra_post .= '&' . urlencode('ajax_page_state[theme]') . '=' . urlencode($drupal_settings['ajaxPageState']['theme']);
+ $extra_post .= '&' . urlencode('ajax_page_state[theme_token]') . '=' . urlencode($drupal_settings['ajaxPageState']['theme_token']);
+ foreach ($drupal_settings['ajaxPageState']['css'] as $key => $value) {
+ $extra_post .= '&' . urlencode("ajax_page_state[css][$key]") . '=1';
+ }
+ foreach ($drupal_settings['ajaxPageState']['js'] as $key => $value) {
+ $extra_post .= '&' . urlencode("ajax_page_state[js][$key]") . '=1';
+ }
+ }
+
+ // Unless a particular path is specified, use the one specified by the
+ // Ajax settings, or else 'system/ajax'.
+ if (!isset($ajax_path)) {
+ $ajax_path = isset($ajax_settings['url']) ? $ajax_settings['url'] : 'system/ajax';
+ }
+
+ // Submit the POST request.
+ $return = drupal_json_decode($this->drupalPost(NULL, $edit, array('path' => $ajax_path, 'triggering_element' => $triggering_element), $options, $headers, $form_html_id, $extra_post));
+
+ // Change the page content by applying the returned commands.
+ if (!empty($ajax_settings) && !empty($return)) {
+ // ajax.js applies some defaults to the settings object, so do the same
+ // for what's used by this function.
+ $ajax_settings += array(
+ 'method' => 'replaceWith',
+ );
+ // DOM can load HTML soup. But, HTML soup can throw warnings, suppress
+ // them.
+ $dom = new DOMDocument();
+ @$dom->loadHTML($content);
+ foreach ($return as $command) {
+ switch ($command['command']) {
+ case 'settings':
+ $drupal_settings = drupal_array_merge_deep($drupal_settings, $command['settings']);
+ break;
+
+ case 'insert':
+ // @todo ajax.js can process commands that include a 'selector', but
+ // these are hard to emulate with DOMDocument. For now, we only
+ // implement 'insert' commands that use $ajax_settings['wrapper'].
+ if (!isset($command['selector'])) {
+ // $dom->getElementById() doesn't work when drupalPostAJAX() is
+ // invoked multiple times for a page, so use XPath instead. This
+ // also sets us up for adding support for $command['selector'] in
+ // the future, once we figure out how to transform a jQuery
+ // selector to XPath.
+ $xpath = new DOMXPath($dom);
+ $wrapperNode = $xpath->query('//*[@id="' . $ajax_settings['wrapper'] . '"]')->item(0);
+ if ($wrapperNode) {
+ // ajax.js adds an enclosing DIV to work around a Safari bug.
+ $newDom = new DOMDocument();
+ $newDom->loadHTML('<div>' . $command['data'] . '</div>');
+ $newNode = $dom->importNode($newDom->documentElement->firstChild->firstChild, TRUE);
+ $method = isset($command['method']) ? $command['method'] : $ajax_settings['method'];
+ // The "method" is a jQuery DOM manipulation function. Emulate
+ // each one using PHP's DOMNode API.
+ switch ($method) {
+ case 'replaceWith':
+ $wrapperNode->parentNode->replaceChild($newNode, $wrapperNode);
+ break;
+ case 'append':
+ $wrapperNode->appendChild($newNode);
+ break;
+ case 'prepend':
+ // If no firstChild, insertBefore() falls back to
+ // appendChild().
+ $wrapperNode->insertBefore($newNode, $wrapperNode->firstChild);
+ break;
+ case 'before':
+ $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode);
+ break;
+ case 'after':
+ // If no nextSibling, insertBefore() falls back to
+ // appendChild().
+ $wrapperNode->parentNode->insertBefore($newNode, $wrapperNode->nextSibling);
+ break;
+ case 'html':
+ foreach ($wrapperNode->childNodes as $childNode) {
+ $wrapperNode->removeChild($childNode);
+ }
+ $wrapperNode->appendChild($newNode);
+ break;
+ }
+ }
+ }
+ break;
+
+ // @todo Add suitable implementations for these commands in order to
+ // have full test coverage of what ajax.js can do.
+ case 'remove':
+ break;
+ case 'changed':
+ break;
+ case 'css':
+ break;
+ case 'data':
+ break;
+ case 'restripe':
+ break;
+ }
+ }
+ $content = $dom->saveHTML();
+ }
+ $this->drupalSetContent($content);
+ $this->drupalSetSettings($drupal_settings);
+ return $return;
+ }
+
+ /**
+ * Runs cron in the Drupal installed by Simpletest.
+ */
+ protected function cronRun() {
+ $this->drupalGet($GLOBALS['base_url'] . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => variable_get('cron_key', 'drupal'))));
+ }
+
+ /**
+ * Check for meta refresh tag and if found call drupalGet() recursively. This
+ * function looks for the http-equiv attribute to be set to "Refresh"
+ * and is case-sensitive.
+ *
+ * @return
+ * Either the new page content or FALSE.
+ */
+ protected function checkForMetaRefresh() {
+ if (strpos($this->drupalGetContent(), '<meta ') && $this->parse()) {
+ $refresh = $this->xpath('//meta[@http-equiv="Refresh"]');
+ if (!empty($refresh)) {
+ // Parse the content attribute of the meta tag for the format:
+ // "[delay]: URL=[page_to_redirect_to]".
+ if (preg_match('/\d+;\s*URL=(?P<url>.*)/i', $refresh[0]['content'], $match)) {
+ return $this->drupalGet($this->getAbsoluteUrl(decode_entities($match['url'])));
+ }
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Retrieves only the headers for a Drupal path or an absolute path.
+ *
+ * @param $path
+ * Drupal path or URL to load into internal browser
+ * @param $options
+ * Options to be forwarded to url().
+ * @param $headers
+ * An array containing additional HTTP request headers, each formatted as
+ * "name: value".
+ * @return
+ * The retrieved headers, also available as $this->drupalGetContent()
+ */
+ protected function drupalHead($path, array $options = array(), array $headers = array()) {
+ $options['absolute'] = TRUE;
+ $out = $this->curlExec(array(CURLOPT_NOBODY => TRUE, CURLOPT_URL => url($path, $options), CURLOPT_HTTPHEADER => $headers));
+ $this->refreshVariables(); // Ensure that any changes to variables in the other thread are picked up.
+ return $out;
+ }
+
+ /**
+ * Handle form input related to drupalPost(). Ensure that the specified fields
+ * exist and attempt to create POST data in the correct manner for the particular
+ * field type.
+ *
+ * @param $post
+ * Reference to array of post values.
+ * @param $edit
+ * Reference to array of edit values to be checked against the form.
+ * @param $submit
+ * Form submit button value.
+ * @param $form
+ * Array of form elements.
+ * @return
+ * Submit value matches a valid submit input in the form.
+ */
+ protected function handleForm(&$post, &$edit, &$upload, $submit, $form) {
+ // Retrieve the form elements.
+ $elements = $form->xpath('.//input[not(@disabled)]|.//textarea[not(@disabled)]|.//select[not(@disabled)]');
+ $submit_matches = FALSE;
+ foreach ($elements as $element) {
+ // SimpleXML objects need string casting all the time.
+ $name = (string) $element['name'];
+ // This can either be the type of <input> or the name of the tag itself
+ // for <select> or <textarea>.
+ $type = isset($element['type']) ? (string) $element['type'] : $element->getName();
+ $value = isset($element['value']) ? (string) $element['value'] : '';
+ $done = FALSE;
+ if (isset($edit[$name])) {
+ switch ($type) {
+ case 'text':
+ case 'textarea':
+ case 'hidden':
+ case 'password':
+ $post[$name] = $edit[$name];
+ unset($edit[$name]);
+ break;
+ case 'radio':
+ if ($edit[$name] == $value) {
+ $post[$name] = $edit[$name];
+ unset($edit[$name]);
+ }
+ break;
+ case 'checkbox':
+ // To prevent checkbox from being checked.pass in a FALSE,
+ // otherwise the checkbox will be set to its value regardless
+ // of $edit.
+ if ($edit[$name] === FALSE) {
+ unset($edit[$name]);
+ continue 2;
+ }
+ else {
+ unset($edit[$name]);
+ $post[$name] = $value;
+ }
+ break;
+ case 'select':
+ $new_value = $edit[$name];
+ $options = $this->getAllOptions($element);
+ if (is_array($new_value)) {
+ // Multiple select box.
+ if (!empty($new_value)) {
+ $index = 0;
+ $key = preg_replace('/\[\]$/', '', $name);
+ foreach ($options as $option) {
+ $option_value = (string) $option['value'];
+ if (in_array($option_value, $new_value)) {
+ $post[$key . '[' . $index++ . ']'] = $option_value;
+ $done = TRUE;
+ unset($edit[$name]);
+ }
+ }
+ }
+ else {
+ // No options selected: do not include any POST data for the
+ // element.
+ $done = TRUE;
+ unset($edit[$name]);
+ }
+ }
+ else {
+ // Single select box.
+ foreach ($options as $option) {
+ if ($new_value == $option['value']) {
+ $post[$name] = $new_value;
+ unset($edit[$name]);
+ $done = TRUE;
+ break;
+ }
+ }
+ }
+ break;
+ case 'file':
+ $upload[$name] = $edit[$name];
+ unset($edit[$name]);
+ break;
+ }
+ }
+ if (!isset($post[$name]) && !$done) {
+ switch ($type) {
+ case 'textarea':
+ $post[$name] = (string) $element;
+ break;
+ case 'select':
+ $single = empty($element['multiple']);
+ $first = TRUE;
+ $index = 0;
+ $key = preg_replace('/\[\]$/', '', $name);
+ $options = $this->getAllOptions($element);
+ foreach ($options as $option) {
+ // For single select, we load the first option, if there is a
+ // selected option that will overwrite it later.
+ if ($option['selected'] || ($first && $single)) {
+ $first = FALSE;
+ if ($single) {
+ $post[$name] = (string) $option['value'];
+ }
+ else {
+ $post[$key . '[' . $index++ . ']'] = (string) $option['value'];
+ }
+ }
+ }
+ break;
+ case 'file':
+ break;
+ case 'submit':
+ case 'image':
+ if (isset($submit) && $submit == $value) {
+ $post[$name] = $value;
+ $submit_matches = TRUE;
+ }
+ break;
+ case 'radio':
+ case 'checkbox':
+ if (!isset($element['checked'])) {
+ break;
+ }
+ // Deliberate no break.
+ default:
+ $post[$name] = $value;
+ }
+ }
+ }
+ return $submit_matches;
+ }
+
+ /**
+ * Builds an XPath query.
+ *
+ * Builds an XPath query by replacing placeholders in the query by the value
+ * of the arguments.
+ *
+ * XPath 1.0 (the version supported by libxml2, the underlying XML library
+ * used by PHP) doesn't support any form of quotation. This function
+ * simplifies the building of XPath expression.
+ *
+ * @param $xpath
+ * An XPath query, possibly with placeholders in the form ':name'.
+ * @param $args
+ * An array of arguments with keys in the form ':name' matching the
+ * placeholders in the query. The values may be either strings or numeric
+ * values.
+ * @return
+ * An XPath query with arguments replaced.
+ */
+ protected function buildXPathQuery($xpath, array $args = array()) {
+ // Replace placeholders.
+ foreach ($args as $placeholder => $value) {
+ // XPath 1.0 doesn't support a way to escape single or double quotes in a
+ // string literal. We split double quotes out of the string, and encode
+ // them separately.
+ if (is_string($value)) {
+ // Explode the text at the quote characters.
+ $parts = explode('"', $value);
+
+ // Quote the parts.
+ foreach ($parts as &$part) {
+ $part = '"' . $part . '"';
+ }
+
+ // Return the string.
+ $value = count($parts) > 1 ? 'concat(' . implode(', \'"\', ', $parts) . ')' : $parts[0];
+ }
+ $xpath = preg_replace('/' . preg_quote($placeholder) . '\b/', $value, $xpath);
+ }
+ return $xpath;
+ }
+
+ /**
+ * Perform an xpath search on the contents of the internal browser. The search
+ * is relative to the root element (HTML tag normally) of the page.
+ *
+ * @param $xpath
+ * The xpath string to use in the search.
+ * @return
+ * The return value of the xpath search. For details on the xpath string
+ * format and return values see the SimpleXML documentation,
+ * http://us.php.net/manual/function.simplexml-element-xpath.php.
+ */
+ protected function xpath($xpath, array $arguments = array()) {
+ if ($this->parse()) {
+ $xpath = $this->buildXPathQuery($xpath, $arguments);
+ $result = $this->elements->xpath($xpath);
+ // Some combinations of PHP / libxml versions return an empty array
+ // instead of the documented FALSE. Forcefully convert any falsish values
+ // to an empty array to allow foreach(...) constructions.
+ return $result ? $result : array();
+ }
+ else {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Get all option elements, including nested options, in a select.
+ *
+ * @param $element
+ * The element for which to get the options.
+ * @return
+ * Option elements in select.
+ */
+ protected function getAllOptions(SimpleXMLElement $element) {
+ $options = array();
+ // Add all options items.
+ foreach ($element->option as $option) {
+ $options[] = $option;
+ }
+
+ // Search option group children.
+ if (isset($element->optgroup)) {
+ foreach ($element->optgroup as $group) {
+ $options = array_merge($options, $this->getAllOptions($group));
+ }
+ }
+ return $options;
+ }
+
+ /**
+ * Pass if a link with the specified label is found, and optional with the
+ * specified index.
+ *
+ * @param $label
+ * Text between the anchor tags.
+ * @param $index
+ * Link position counting from zero.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertLink($label, $index = 0, $message = '', $group = 'Other') {
+ $links = $this->xpath('//a[normalize-space(text())=:label]', array(':label' => $label));
+ $message = ($message ? $message : t('Link with label %label found.', array('%label' => $label)));
+ return $this->assert(isset($links[$index]), $message, $group);
+ }
+
+ /**
+ * Pass if a link with the specified label is not found.
+ *
+ * @param $label
+ * Text between the anchor tags.
+ * @param $index
+ * Link position counting from zero.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNoLink($label, $message = '', $group = 'Other') {
+ $links = $this->xpath('//a[normalize-space(text())=:label]', array(':label' => $label));
+ $message = ($message ? $message : t('Link with label %label not found.', array('%label' => $label)));
+ return $this->assert(empty($links), $message, $group);
+ }
+
+ /**
+ * Pass if a link containing a given href (part) is found.
+ *
+ * @param $href
+ * The full or partial value of the 'href' attribute of the anchor tag.
+ * @param $index
+ * Link position counting from zero.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ *
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertLinkByHref($href, $index = 0, $message = '', $group = 'Other') {
+ $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => $href));
+ $message = ($message ? $message : t('Link containing href %href found.', array('%href' => $href)));
+ return $this->assert(isset($links[$index]), $message, $group);
+ }
+
+ /**
+ * Pass if a link containing a given href (part) is not found.
+ *
+ * @param $href
+ * The full or partial value of the 'href' attribute of the anchor tag.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ *
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertNoLinkByHref($href, $message = '', $group = 'Other') {
+ $links = $this->xpath('//a[contains(@href, :href)]', array(':href' => $href));
+ $message = ($message ? $message : t('No link containing href %href found.', array('%href' => $href)));
+ return $this->assert(empty($links), $message, $group);
+ }
+
+ /**
+ * Follows a link by name.
+ *
+ * Will click the first link found with this link text by default, or a
+ * later one if an index is given. Match is case insensitive with
+ * normalized space. The label is translated label. There is an assert
+ * for successful click.
+ *
+ * @param $label
+ * Text between the anchor tags.
+ * @param $index
+ * Link position counting from zero.
+ * @return
+ * Page on success, or FALSE on failure.
+ */
+ protected function clickLink($label, $index = 0) {
+ $url_before = $this->getUrl();
+ $urls = $this->xpath('//a[normalize-space(text())=:label]', array(':label' => $label));
+
+ if (isset($urls[$index])) {
+ $url_target = $this->getAbsoluteUrl($urls[$index]['href']);
+ }
+
+ $this->assertTrue(isset($urls[$index]), t('Clicked link %label (@url_target) from @url_before', array('%label' => $label, '@url_target' => $url_target, '@url_before' => $url_before)), t('Browser'));
+
+ if (isset($url_target)) {
+ return $this->drupalGet($url_target);
+ }
+ return FALSE;
+ }
+
+ /**
+ * Takes a path and returns an absolute path.
+ *
+ * @param $path
+ * A path from the internal browser content.
+ * @return
+ * The $path with $base_url prepended, if necessary.
+ */
+ protected function getAbsoluteUrl($path) {
+ global $base_url, $base_path;
+
+ $parts = parse_url($path);
+ if (empty($parts['host'])) {
+ // Ensure that we have a string (and no xpath object).
+ $path = (string) $path;
+ // Strip $base_path, if existent.
+ $length = strlen($base_path);
+ if (substr($path, 0, $length) === $base_path) {
+ $path = substr($path, $length);
+ }
+ // Ensure that we have an absolute path.
+ if ($path[0] !== '/') {
+ $path = '/' . $path;
+ }
+ // Finally, prepend the $base_url.
+ $path = $base_url . $path;
+ }
+ return $path;
+ }
+
+ /**
+ * Get the current url from the cURL handler.
+ *
+ * @return
+ * The current url.
+ */
+ protected function getUrl() {
+ return $this->url;
+ }
+
+ /**
+ * Gets the HTTP response headers of the requested page. Normally we are only
+ * interested in the headers returned by the last request. However, if a page
+ * is redirected or HTTP authentication is in use, multiple requests will be
+ * required to retrieve the page. Headers from all requests may be requested
+ * by passing TRUE to this function.
+ *
+ * @param $all_requests
+ * Boolean value specifying whether to return headers from all requests
+ * instead of just the last request. Defaults to FALSE.
+ * @return
+ * A name/value array if headers from only the last request are requested.
+ * If headers from all requests are requested, an array of name/value
+ * arrays, one for each request.
+ *
+ * The pseudonym ":status" is used for the HTTP status line.
+ *
+ * Values for duplicate headers are stored as a single comma-separated list.
+ */
+ protected function drupalGetHeaders($all_requests = FALSE) {
+ $request = 0;
+ $headers = array($request => array());
+ foreach ($this->headers as $header) {
+ $header = trim($header);
+ if ($header === '') {
+ $request++;
+ }
+ else {
+ if (strpos($header, 'HTTP/') === 0) {
+ $name = ':status';
+ $value = $header;
+ }
+ else {
+ list($name, $value) = explode(':', $header, 2);
+ $name = strtolower($name);
+ }
+ if (isset($headers[$request][$name])) {
+ $headers[$request][$name] .= ',' . trim($value);
+ }
+ else {
+ $headers[$request][$name] = trim($value);
+ }
+ }
+ }
+ if (!$all_requests) {
+ $headers = array_pop($headers);
+ }
+ return $headers;
+ }
+
+ /**
+ * Gets the value of an HTTP response header. If multiple requests were
+ * required to retrieve the page, only the headers from the last request will
+ * be checked by default. However, if TRUE is passed as the second argument,
+ * all requests will be processed from last to first until the header is
+ * found.
+ *
+ * @param $name
+ * The name of the header to retrieve. Names are case-insensitive (see RFC
+ * 2616 section 4.2).
+ * @param $all_requests
+ * Boolean value specifying whether to check all requests if the header is
+ * not found in the last request. Defaults to FALSE.
+ * @return
+ * The HTTP header value or FALSE if not found.
+ */
+ protected function drupalGetHeader($name, $all_requests = FALSE) {
+ $name = strtolower($name);
+ $header = FALSE;
+ if ($all_requests) {
+ foreach (array_reverse($this->drupalGetHeaders(TRUE)) as $headers) {
+ if (isset($headers[$name])) {
+ $header = $headers[$name];
+ break;
+ }
+ }
+ }
+ else {
+ $headers = $this->drupalGetHeaders();
+ if (isset($headers[$name])) {
+ $header = $headers[$name];
+ }
+ }
+ return $header;
+ }
+
+ /**
+ * Gets the current raw HTML of requested page.
+ */
+ protected function drupalGetContent() {
+ return $this->content;
+ }
+
+ /**
+ * Gets the value of the Drupal.settings JavaScript variable for the currently loaded page.
+ */
+ protected function drupalGetSettings() {
+ return $this->drupalSettings;
+ }
+
+ /**
+ * Gets an array containing all e-mails sent during this test case.
+ *
+ * @param $filter
+ * An array containing key/value pairs used to filter the e-mails that are returned.
+ * @return
+ * An array containing e-mail messages captured during the current test.
+ */
+ protected function drupalGetMails($filter = array()) {
+ $captured_emails = variable_get('drupal_test_email_collector', array());
+ $filtered_emails = array();
+
+ foreach ($captured_emails as $message) {
+ foreach ($filter as $key => $value) {
+ if (!isset($message[$key]) || $message[$key] != $value) {
+ continue 2;
+ }
+ }
+ $filtered_emails[] = $message;
+ }
+
+ return $filtered_emails;
+ }
+
+ /**
+ * Sets the raw HTML content. This can be useful when a page has been fetched
+ * outside of the internal browser and assertions need to be made on the
+ * returned page.
+ *
+ * A good example would be when testing drupal_http_request(). After fetching
+ * the page the content can be set and page elements can be checked to ensure
+ * that the function worked properly.
+ */
+ protected function drupalSetContent($content, $url = 'internal:') {
+ $this->content = $content;
+ $this->url = $url;
+ $this->plainTextContent = FALSE;
+ $this->elements = FALSE;
+ $this->drupalSettings = array();
+ if (preg_match('/jQuery\.extend\(Drupal\.settings, (.*?)\);/', $content, $matches)) {
+ $this->drupalSettings = drupal_json_decode($matches[1]);
+ }
+ }
+
+ /**
+ * Sets the value of the Drupal.settings JavaScript variable for the currently loaded page.
+ */
+ protected function drupalSetSettings($settings) {
+ $this->drupalSettings = $settings;
+ }
+
+ /**
+ * Pass if the internal browser's URL matches the given path.
+ *
+ * @param $path
+ * The expected system path.
+ * @param $options
+ * (optional) Any additional options to pass for $path to url().
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertUrl($path, array $options = array(), $message = '', $group = 'Other') {
+ if (!$message) {
+ $message = t('Current URL is @url.', array(
+ '@url' => var_export(url($path, $options), TRUE),
+ ));
+ }
+ $options['absolute'] = TRUE;
+ return $this->assertEqual($this->getUrl(), url($path, $options), $message, $group);
+ }
+
+ /**
+ * Pass if the raw text IS found on the loaded page, fail otherwise. Raw text
+ * refers to the raw HTML that the page generated.
+ *
+ * @param $raw
+ * Raw (HTML) string to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertRaw($raw, $message = '', $group = 'Other') {
+ if (!$message) {
+ $message = t('Raw "@raw" found', array('@raw' => $raw));
+ }
+ return $this->assert(strpos($this->drupalGetContent(), $raw) !== FALSE, $message, $group);
+ }
+
+ /**
+ * Pass if the raw text is NOT found on the loaded page, fail otherwise. Raw text
+ * refers to the raw HTML that the page generated.
+ *
+ * @param $raw
+ * Raw (HTML) string to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoRaw($raw, $message = '', $group = 'Other') {
+ if (!$message) {
+ $message = t('Raw "@raw" not found', array('@raw' => $raw));
+ }
+ return $this->assert(strpos($this->drupalGetContent(), $raw) === FALSE, $message, $group);
+ }
+
+ /**
+ * Pass if the text IS found on the text version of the page. The text version
+ * is the equivalent of what a user would see when viewing through a web browser.
+ * In other words the HTML has been filtered out of the contents.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertText($text, $message = '', $group = 'Other') {
+ return $this->assertTextHelper($text, $message, $group, FALSE);
+ }
+
+ /**
+ * Pass if the text is NOT found on the text version of the page. The text version
+ * is the equivalent of what a user would see when viewing through a web browser.
+ * In other words the HTML has been filtered out of the contents.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoText($text, $message = '', $group = 'Other') {
+ return $this->assertTextHelper($text, $message, $group, TRUE);
+ }
+
+ /**
+ * Helper for assertText and assertNoText.
+ *
+ * It is not recommended to call this function directly.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @param $not_exists
+ * TRUE if this text should not exist, FALSE if it should.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertTextHelper($text, $message = '', $group, $not_exists) {
+ if ($this->plainTextContent === FALSE) {
+ $this->plainTextContent = filter_xss($this->drupalGetContent(), array());
+ }
+ if (!$message) {
+ $message = !$not_exists ? t('"@text" found', array('@text' => $text)) : t('"@text" not found', array('@text' => $text));
+ }
+ return $this->assert($not_exists == (strpos($this->plainTextContent, $text) === FALSE), $message, $group);
+ }
+
+ /**
+ * Pass if the text is found ONLY ONCE on the text version of the page.
+ *
+ * The text version is the equivalent of what a user would see when viewing
+ * through a web browser. In other words the HTML has been filtered out of
+ * the contents.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertUniqueText($text, $message = '', $group = 'Other') {
+ return $this->assertUniqueTextHelper($text, $message, $group, TRUE);
+ }
+
+ /**
+ * Pass if the text is found MORE THAN ONCE on the text version of the page.
+ *
+ * The text version is the equivalent of what a user would see when viewing
+ * through a web browser. In other words the HTML has been filtered out of
+ * the contents.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to, defaults to 'Other'.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoUniqueText($text, $message = '', $group = 'Other') {
+ return $this->assertUniqueTextHelper($text, $message, $group, FALSE);
+ }
+
+ /**
+ * Helper for assertUniqueText and assertNoUniqueText.
+ *
+ * It is not recommended to call this function directly.
+ *
+ * @param $text
+ * Plain text to look for.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @param $be_unique
+ * TRUE if this text should be found only once, FALSE if it should be found more than once.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertUniqueTextHelper($text, $message = '', $group, $be_unique) {
+ if ($this->plainTextContent === FALSE) {
+ $this->plainTextContent = filter_xss($this->drupalGetContent(), array());
+ }
+ if (!$message) {
+ $message = '"' . $text . '"' . ($be_unique ? ' found only once' : ' found more than once');
+ }
+ $first_occurance = strpos($this->plainTextContent, $text);
+ if ($first_occurance === FALSE) {
+ return $this->assert(FALSE, $message, $group);
+ }
+ $offset = $first_occurance + strlen($text);
+ $second_occurance = strpos($this->plainTextContent, $text, $offset);
+ return $this->assert($be_unique == ($second_occurance === FALSE), $message, $group);
+ }
+
+ /**
+ * Will trigger a pass if the Perl regex pattern is found in the raw content.
+ *
+ * @param $pattern
+ * Perl regex to look for including the regex delimiters.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertPattern($pattern, $message = '', $group = 'Other') {
+ if (!$message) {
+ $message = t('Pattern "@pattern" found', array('@pattern' => $pattern));
+ }
+ return $this->assert((bool) preg_match($pattern, $this->drupalGetContent()), $message, $group);
+ }
+
+ /**
+ * Will trigger a pass if the perl regex pattern is not present in raw content.
+ *
+ * @param $pattern
+ * Perl regex to look for including the regex delimiters.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoPattern($pattern, $message = '', $group = 'Other') {
+ if (!$message) {
+ $message = t('Pattern "@pattern" not found', array('@pattern' => $pattern));
+ }
+ return $this->assert(!preg_match($pattern, $this->drupalGetContent()), $message, $group);
+ }
+
+ /**
+ * Pass if the page title is the given string.
+ *
+ * @param $title
+ * The string the title should be.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertTitle($title, $message = '', $group = 'Other') {
+ $actual = (string) current($this->xpath('//title'));
+ if (!$message) {
+ $message = t('Page title @actual is equal to @expected.', array(
+ '@actual' => var_export($actual, TRUE),
+ '@expected' => var_export($title, TRUE),
+ ));
+ }
+ return $this->assertEqual($actual, $title, $message, $group);
+ }
+
+ /**
+ * Pass if the page title is not the given string.
+ *
+ * @param $title
+ * The string the title should not be.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoTitle($title, $message = '', $group = 'Other') {
+ $actual = (string) current($this->xpath('//title'));
+ if (!$message) {
+ $message = t('Page title @actual is not equal to @unexpected.', array(
+ '@actual' => var_export($actual, TRUE),
+ '@unexpected' => var_export($title, TRUE),
+ ));
+ }
+ return $this->assertNotEqual($actual, $title, $message, $group);
+ }
+
+ /**
+ * Asserts that a field exists in the current page by the given XPath.
+ *
+ * @param $xpath
+ * XPath used to find the field.
+ * @param $value
+ * (optional) Value of the field to assert.
+ * @param $message
+ * (optional) Message to display.
+ * @param $group
+ * (optional) The group this message belongs to.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertFieldByXPath($xpath, $value = NULL, $message = '', $group = 'Other') {
+ $fields = $this->xpath($xpath);
+
+ // If value specified then check array for match.
+ $found = TRUE;
+ if (isset($value)) {
+ $found = FALSE;
+ if ($fields) {
+ foreach ($fields as $field) {
+ if (isset($field['value']) && $field['value'] == $value) {
+ // Input element with correct value.
+ $found = TRUE;
+ }
+ elseif (isset($field->option)) {
+ // Select element found.
+ if ($this->getSelectedItem($field) == $value) {
+ $found = TRUE;
+ }
+ else {
+ // No item selected so use first item.
+ $items = $this->getAllOptions($field);
+ if (!empty($items) && $items[0]['value'] == $value) {
+ $found = TRUE;
+ }
+ }
+ }
+ elseif ((string) $field == $value) {
+ // Text area with correct text.
+ $found = TRUE;
+ }
+ }
+ }
+ }
+ return $this->assertTrue($fields && $found, $message, $group);
+ }
+
+ /**
+ * Get the selected value from a select field.
+ *
+ * @param $element
+ * SimpleXMLElement select element.
+ * @return
+ * The selected value or FALSE.
+ */
+ protected function getSelectedItem(SimpleXMLElement $element) {
+ foreach ($element->children() as $item) {
+ if (isset($item['selected'])) {
+ return $item['value'];
+ }
+ elseif ($item->getName() == 'optgroup') {
+ if ($value = $this->getSelectedItem($item)) {
+ return $value;
+ }
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Asserts that a field does not exist in the current page by the given XPath.
+ *
+ * @param $xpath
+ * XPath used to find the field.
+ * @param $value
+ * (optional) Value of the field to assert.
+ * @param $message
+ * (optional) Message to display.
+ * @param $group
+ * (optional) The group this message belongs to.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoFieldByXPath($xpath, $value = NULL, $message = '', $group = 'Other') {
+ $fields = $this->xpath($xpath);
+
+ // If value specified then check array for match.
+ $found = TRUE;
+ if (isset($value)) {
+ $found = FALSE;
+ if ($fields) {
+ foreach ($fields as $field) {
+ if ($field['value'] == $value) {
+ $found = TRUE;
+ }
+ }
+ }
+ }
+ return $this->assertFalse($fields && $found, $message, $group);
+ }
+
+ /**
+ * Asserts that a field exists in the current page with the given name and value.
+ *
+ * @param $name
+ * Name of field to assert.
+ * @param $value
+ * Value of the field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertFieldByName($name, $value = '', $message = '') {
+ return $this->assertFieldByXPath($this->constructFieldXpath('name', $name), $value, $message ? $message : t('Found field by name @name', array('@name' => $name)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a field does not exist with the given name and value.
+ *
+ * @param $name
+ * Name of field to assert.
+ * @param $value
+ * Value of the field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoFieldByName($name, $value = '', $message = '') {
+ return $this->assertNoFieldByXPath($this->constructFieldXpath('name', $name), $value, $message ? $message : t('Did not find field by name @name', array('@name' => $name)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a field exists in the current page with the given id and value.
+ *
+ * @param $id
+ * Id of field to assert.
+ * @param $value
+ * Value of the field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertFieldById($id, $value = '', $message = '') {
+ return $this->assertFieldByXPath($this->constructFieldXpath('id', $id), $value, $message ? $message : t('Found field by id @id', array('@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a field does not exist with the given id and value.
+ *
+ * @param $id
+ * Id of field to assert.
+ * @param $value
+ * Value of the field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoFieldById($id, $value = '', $message = '') {
+ return $this->assertNoFieldByXPath($this->constructFieldXpath('id', $id), $value, $message ? $message : t('Did not find field by id @id', array('@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a checkbox field in the current page is checked.
+ *
+ * @param $id
+ * Id of field to assert.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertFieldChecked($id, $message = '') {
+ $elements = $this->xpath('//input[@id=:id]', array(':id' => $id));
+ return $this->assertTrue(isset($elements[0]) && !empty($elements[0]['checked']), $message ? $message : t('Checkbox field @id is checked.', array('@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a checkbox field in the current page is not checked.
+ *
+ * @param $id
+ * Id of field to assert.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoFieldChecked($id, $message = '') {
+ $elements = $this->xpath('//input[@id=:id]', array(':id' => $id));
+ return $this->assertTrue(isset($elements[0]) && empty($elements[0]['checked']), $message ? $message : t('Checkbox field @id is not checked.', array('@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a select option in the current page is checked.
+ *
+ * @param $id
+ * Id of select field to assert.
+ * @param $option
+ * Option to assert.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ *
+ * @todo $id is unusable. Replace with $name.
+ */
+ protected function assertOptionSelected($id, $option, $message = '') {
+ $elements = $this->xpath('//select[@id=:id]//option[@value=:option]', array(':id' => $id, ':option' => $option));
+ return $this->assertTrue(isset($elements[0]) && !empty($elements[0]['selected']), $message ? $message : t('Option @option for field @id is selected.', array('@option' => $option, '@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a select option in the current page is not checked.
+ *
+ * @param $id
+ * Id of select field to assert.
+ * @param $option
+ * Option to assert.
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoOptionSelected($id, $option, $message = '') {
+ $elements = $this->xpath('//select[@id=:id]//option[@value=:option]', array(':id' => $id, ':option' => $option));
+ return $this->assertTrue(isset($elements[0]) && empty($elements[0]['selected']), $message ? $message : t('Option @option for field @id is not selected.', array('@option' => $option, '@id' => $id)), t('Browser'));
+ }
+
+ /**
+ * Asserts that a field exists with the given name or id.
+ *
+ * @param $field
+ * Name or id of field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertField($field, $message = '', $group = 'Other') {
+ return $this->assertFieldByXPath($this->constructFieldXpath('name', $field) . '|' . $this->constructFieldXpath('id', $field), NULL, $message, $group);
+ }
+
+ /**
+ * Asserts that a field does not exist with the given name or id.
+ *
+ * @param $field
+ * Name or id of field to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoField($field, $message = '', $group = 'Other') {
+ return $this->assertNoFieldByXPath($this->constructFieldXpath('name', $field) . '|' . $this->constructFieldXpath('id', $field), NULL, $message, $group);
+ }
+
+ /**
+ * Asserts that each HTML ID is used for just a single element.
+ *
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ * @param $ids_to_skip
+ * An optional array of ids to skip when checking for duplicates. It is
+ * always a bug to have duplicate HTML IDs, so this parameter is to enable
+ * incremental fixing of core code. Whenever a test passes this parameter,
+ * it should add a "todo" comment above the call to this function explaining
+ * the legacy bug that the test wishes to ignore and including a link to an
+ * issue that is working to fix that legacy bug.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertNoDuplicateIds($message = '', $group = 'Other', $ids_to_skip = array()) {
+ $status = TRUE;
+ foreach ($this->xpath('//*[@id]') as $element) {
+ $id = (string) $element['id'];
+ if (isset($seen_ids[$id]) && !in_array($id, $ids_to_skip)) {
+ $this->fail(t('The HTML ID %id is unique.', array('%id' => $id)), $group);
+ $status = FALSE;
+ }
+ $seen_ids[$id] = TRUE;
+ }
+ return $this->assert($status, $message, $group);
+ }
+
+ /**
+ * Helper function: construct an XPath for the given set of attributes and value.
+ *
+ * @param $attribute
+ * Field attributes.
+ * @param $value
+ * Value of field.
+ * @return
+ * XPath for specified values.
+ */
+ protected function constructFieldXpath($attribute, $value) {
+ $xpath = '//textarea[@' . $attribute . '=:value]|//input[@' . $attribute . '=:value]|//select[@' . $attribute . '=:value]';
+ return $this->buildXPathQuery($xpath, array(':value' => $value));
+ }
+
+ /**
+ * Asserts the page responds with the specified response code.
+ *
+ * @param $code
+ * Response code. For example 200 is a successful page request. For a list
+ * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html.
+ * @param $message
+ * Message to display.
+ * @return
+ * Assertion result.
+ */
+ protected function assertResponse($code, $message = '') {
+ $curl_code = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
+ $match = is_array($code) ? in_array($curl_code, $code) : $curl_code == $code;
+ return $this->assertTrue($match, $message ? $message : t('HTTP response expected !code, actual !curl_code', array('!code' => $code, '!curl_code' => $curl_code)), t('Browser'));
+ }
+
+ /**
+ * Asserts the page did not return the specified response code.
+ *
+ * @param $code
+ * Response code. For example 200 is a successful page request. For a list
+ * of all codes see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html.
+ * @param $message
+ * Message to display.
+ *
+ * @return
+ * Assertion result.
+ */
+ protected function assertNoResponse($code, $message = '') {
+ $curl_code = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
+ $match = is_array($code) ? in_array($curl_code, $code) : $curl_code == $code;
+ return $this->assertFalse($match, $message ? $message : t('HTTP response not expected !code, actual !curl_code', array('!code' => $code, '!curl_code' => $curl_code)), t('Browser'));
+ }
+
+ /**
+ * Asserts that the most recently sent e-mail message has the given value.
+ *
+ * The field in $name must have the content described in $value.
+ *
+ * @param $name
+ * Name of field or message property to assert. Examples: subject, body, id, ...
+ * @param $value
+ * Value of the field to assert.
+ * @param $message
+ * Message to display.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertMail($name, $value = '', $message = '') {
+ $captured_emails = variable_get('drupal_test_email_collector', array());
+ $email = end($captured_emails);
+ return $this->assertTrue($email && isset($email[$name]) && $email[$name] == $value, $message, t('E-mail'));
+ }
+
+ /**
+ * Asserts that the most recently sent e-mail message has the string in it.
+ *
+ * @param $field_name
+ * Name of field or message property to assert: subject, body, id, ...
+ * @param $string
+ * String to search for.
+ * @param $email_depth
+ * Number of emails to search for string, starting with most recent.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertMailString($field_name, $string, $email_depth) {
+ $mails = $this->drupalGetMails();
+ $string_found = FALSE;
+ for ($i = sizeof($mails) -1; $i >= sizeof($mails) - $email_depth && $i >= 0; $i--) {
+ $mail = $mails[$i];
+ // Normalize whitespace, as we don't know what the mail system might have
+ // done. Any run of whitespace becomes a single space.
+ $normalized_mail = preg_replace('/\s+/', ' ', $mail[$field_name]);
+ $normalized_string = preg_replace('/\s+/', ' ', $string);
+ $string_found = (FALSE !== strpos($normalized_mail, $normalized_string));
+ if ($string_found) {
+ break;
+ }
+ }
+ return $this->assertTrue($string_found, t('Expected text found in @field of email message: "@expected".', array('@field' => $field_name, '@expected' => $string)));
+ }
+
+ /**
+ * Asserts that the most recently sent e-mail message has the pattern in it.
+ *
+ * @param $field_name
+ * Name of field or message property to assert: subject, body, id, ...
+ * @param $regex
+ * Pattern to search for.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function assertMailPattern($field_name, $regex, $message) {
+ $mails = $this->drupalGetMails();
+ $mail = end($mails);
+ $regex_found = preg_match("/$regex/", $mail[$field_name]);
+ return $this->assertTrue($regex_found, t('Expected text found in @field of email message: "@expected".', array('@field' => $field_name, '@expected' => $regex)));
+ }
+
+ /**
+ * Outputs to verbose the most recent $count emails sent.
+ *
+ * @param $count
+ * Optional number of emails to output.
+ */
+ protected function verboseEmail($count = 1) {
+ $mails = $this->drupalGetMails();
+ for ($i = sizeof($mails) -1; $i >= sizeof($mails) - $count && $i >= 0; $i--) {
+ $mail = $mails[$i];
+ $this->verbose(t('Email:') . '<pre>' . print_r($mail, TRUE) . '</pre>');
+ }
+ }
+}
+
+/**
+ * Logs verbose message in a text file.
+ *
+ * If verbose mode is enabled then page requests will be dumped to a file and
+ * presented on the test result screen. The messages will be placed in a file
+ * located in the simpletest directory in the original file system.
+ *
+ * @param $message
+ * The verbose message to be stored.
+ * @param $original_file_directory
+ * The original file directory, before it was changed for testing purposes.
+ * @param $test_class
+ * The active test case class.
+ *
+ * @return
+ * The ID of the message to be placed in related assertion messages.
+ *
+ * @see DrupalTestCase->originalFileDirectory
+ * @see DrupalWebTestCase->verbose()
+ */
+function simpletest_verbose($message, $original_file_directory = NULL, $test_class = NULL) {
+ static $file_directory = NULL, $class = NULL, $id = 1, $verbose = NULL;
+
+ // Will pass first time during setup phase, and when verbose is TRUE.
+ if (!isset($original_file_directory) && !$verbose) {
+ return FALSE;
+ }
+
+ if ($message && $file_directory) {
+ $message = '<hr />ID #' . $id . ' (<a href="' . $class . '-' . ($id - 1) . '.html">Previous</a> | <a href="' . $class . '-' . ($id + 1) . '.html">Next</a>)<hr />' . $message;
+ file_put_contents($file_directory . "/simpletest/verbose/$class-$id.html", $message, FILE_APPEND);
+ return $id++;
+ }
+
+ if ($original_file_directory) {
+ $file_directory = $original_file_directory;
+ $class = $test_class;
+ $verbose = variable_get('simpletest_verbose', TRUE);
+ $directory = $file_directory . '/simpletest/verbose';
+ $writable = file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
+ if ($writable && !file_exists($directory . '/.htaccess')) {
+ file_put_contents($directory . '/.htaccess', "<IfModule mod_expires.c>\nExpiresActive Off\n</IfModule>\n");
+ }
+ return $writable;
+ }
+ return FALSE;
+}
diff --git a/core/modules/simpletest/files/README.txt b/core/modules/simpletest/files/README.txt
new file mode 100644
index 000000000000..c8f39ad3377f
--- /dev/null
+++ b/core/modules/simpletest/files/README.txt
@@ -0,0 +1,4 @@
+
+These files are use in some tests that upload files or other operations were
+a file is useful. These files are copied to the files directory as specified
+in the site settings. Other tests files are generated in order to save space.
diff --git a/core/modules/simpletest/files/css_test_files/comment_hacks.css b/core/modules/simpletest/files/css_test_files/comment_hacks.css
new file mode 100644
index 000000000000..c47e8429ac27
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/comment_hacks.css
@@ -0,0 +1,80 @@
+/*
+* A sample css file, designed to test the effectiveness and stability
+* of function <code>drupal_load_stylesheet_content()</code>.
+*
+*/
+/*
+A large comment block to test for segfaults and speed. This is 60K a's. Extreme but useful to demonstrate flaws in comment striping regexp. aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa*/
+.test1 { display:block;}
+
+/* A multiline IE-mac hack (v.2) taken fron Zen theme*/
+/* Hides from IE-mac \*/
+html .clear-block {
+ height: 1%;
+}
+.clear-block {
+ display: block;
+ font:italic bold 12px/30px Georgia, serif;
+}
+
+/* End hide from IE-mac */
+.test2 { display:block; }
+
+/* v1 of the commented backslash hack. This \ character between rules appears to have the effect
+that macIE5 ignores the following rule. Odd, but extremely useful. */
+.bkslshv1 { background-color: #C00; }
+.test3 { display:block; }
+
+/**************** A multiline, multistar comment ***************
+****************************************************************/
+.test4 { display:block;}
+
+/**************************************/
+.comment-in-double-quotes:before {
+ content: "/* ";
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-double-quotes:after {
+ content: " */";
+}
+/**************************************/
+.comment-in-single-quotes:before {
+ content: '/*';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-single-quotes:after {
+ content: '*/';
+}
+/**************************************/
+.comment-in-mixed-quotes:before {
+ content: '"/*"';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-mixed-quotes:after {
+ content: "'*/'";
+}
+/**************************************/
+.comment-in-quotes-with-escaped:before {
+ content: '/* \" \' */';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-quotes-with-escaped:after {
+ content: "*/ \" \ '";
+}
+/************************************/
+/*
+"This has to go"
+'This has to go'
+*/
diff --git a/core/modules/simpletest/files/css_test_files/comment_hacks.css.optimized.css b/core/modules/simpletest/files/css_test_files/comment_hacks.css.optimized.css
new file mode 100644
index 000000000000..1feb8f1bd7d3
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/comment_hacks.css.optimized.css
@@ -0,0 +1 @@
+.test1{display:block;}html .clear-block{height:1%;}.clear-block{display:block;font:italic bold 12px/30px Georgia,serif;}.test2{display:block;}.bkslshv1{background-color:#C00;}.test3{display:block;}.test4{display:block;}.comment-in-double-quotes:before{content:"/* ";}.this_rule_must_stay{color:#F00;background-color:#FFF;}.comment-in-double-quotes:after{content:" */";}.comment-in-single-quotes:before{content:'/*';}.this_rule_must_stay{color:#F00;background-color:#FFF;}.comment-in-single-quotes:after{content:'*/';}.comment-in-mixed-quotes:before{content:'"/*"';}.this_rule_must_stay{color:#F00;background-color:#FFF;}.comment-in-mixed-quotes:after{content:"'*/'";}.comment-in-quotes-with-escaped:before{content:'/* \" \' */';}.this_rule_must_stay{color:#F00;background-color:#FFF;}.comment-in-quotes-with-escaped:after{content:"*/ \" \ '";}
diff --git a/core/modules/simpletest/files/css_test_files/comment_hacks.css.unoptimized.css b/core/modules/simpletest/files/css_test_files/comment_hacks.css.unoptimized.css
new file mode 100644
index 000000000000..c47e8429ac27
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/comment_hacks.css.unoptimized.css
@@ -0,0 +1,80 @@
+/*
+* A sample css file, designed to test the effectiveness and stability
+* of function <code>drupal_load_stylesheet_content()</code>.
+*
+*/
+/*
+A large comment block to test for segfaults and speed. This is 60K a's. Extreme but useful to demonstrate flaws in comment striping regexp. aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa*/
+.test1 { display:block;}
+
+/* A multiline IE-mac hack (v.2) taken fron Zen theme*/
+/* Hides from IE-mac \*/
+html .clear-block {
+ height: 1%;
+}
+.clear-block {
+ display: block;
+ font:italic bold 12px/30px Georgia, serif;
+}
+
+/* End hide from IE-mac */
+.test2 { display:block; }
+
+/* v1 of the commented backslash hack. This \ character between rules appears to have the effect
+that macIE5 ignores the following rule. Odd, but extremely useful. */
+.bkslshv1 { background-color: #C00; }
+.test3 { display:block; }
+
+/**************** A multiline, multistar comment ***************
+****************************************************************/
+.test4 { display:block;}
+
+/**************************************/
+.comment-in-double-quotes:before {
+ content: "/* ";
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-double-quotes:after {
+ content: " */";
+}
+/**************************************/
+.comment-in-single-quotes:before {
+ content: '/*';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-single-quotes:after {
+ content: '*/';
+}
+/**************************************/
+.comment-in-mixed-quotes:before {
+ content: '"/*"';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-mixed-quotes:after {
+ content: "'*/'";
+}
+/**************************************/
+.comment-in-quotes-with-escaped:before {
+ content: '/* \" \' */';
+}
+.this_rule_must_stay {
+ color: #F00;
+ background-color: #FFF;
+}
+.comment-in-quotes-with-escaped:after {
+ content: "*/ \" \ '";
+}
+/************************************/
+/*
+"This has to go"
+'This has to go'
+*/
diff --git a/core/modules/simpletest/files/css_test_files/css_input_with_import.css b/core/modules/simpletest/files/css_test_files/css_input_with_import.css
new file mode 100644
index 000000000000..87afcb35f87e
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_with_import.css
@@ -0,0 +1,30 @@
+
+
+@import "import1.css";
+@import "import2.css";
+
+body {
+ margin: 0;
+ padding: 0;
+ background: #edf5fa;
+ font: 76%/170% Verdana, sans-serif;
+ color: #494949;
+}
+
+.this .is .a .test {
+ font: 1em/100% Verdana, sans-serif;
+ color: #494949;
+}
+.this
+.is
+.a
+.test {
+font: 1em/100% Verdana, sans-serif;
+color: #494949;
+}
+
+textarea, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
+
diff --git a/core/modules/simpletest/files/css_test_files/css_input_with_import.css.optimized.css b/core/modules/simpletest/files/css_test_files/css_input_with_import.css.optimized.css
new file mode 100644
index 000000000000..698d9aa6cab9
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_with_import.css.optimized.css
@@ -0,0 +1,6 @@
+ul,select{font:1em/160% Verdana,sans-serif;color:#494949;}.ui-icon{background-image:url(images/icon.png);}
+p,select{font:1em/160% Verdana,sans-serif;color:#494949;}
+body{margin:0;padding:0;background:#edf5fa;font:76%/170% Verdana,sans-serif;color:#494949;}.this .is .a .test{font:1em/100% Verdana,sans-serif;color:#494949;}.this
+.is
+.a
+.test{font:1em/100% Verdana,sans-serif;color:#494949;}textarea,select{font:1em/160% Verdana,sans-serif;color:#494949;}
diff --git a/core/modules/simpletest/files/css_test_files/css_input_with_import.css.unoptimized.css b/core/modules/simpletest/files/css_test_files/css_input_with_import.css.unoptimized.css
new file mode 100644
index 000000000000..4c905f562075
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_with_import.css.unoptimized.css
@@ -0,0 +1,30 @@
+
+
+
+
+
+body {
+ margin: 0;
+ padding: 0;
+ background: #edf5fa;
+ font: 76%/170% Verdana, sans-serif;
+ color: #494949;
+}
+
+.this .is .a .test {
+ font: 1em/100% Verdana, sans-serif;
+ color: #494949;
+}
+.this
+.is
+.a
+.test {
+font: 1em/100% Verdana, sans-serif;
+color: #494949;
+}
+
+textarea, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
+
diff --git a/core/modules/simpletest/files/css_test_files/css_input_without_import.css b/core/modules/simpletest/files/css_test_files/css_input_without_import.css
new file mode 100644
index 000000000000..620360abc5d4
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_without_import.css
@@ -0,0 +1,69 @@
+
+/**
+ * @file Basic css that does not use import
+ */
+
+
+body {
+ margin: 0;
+ padding: 0;
+ background: #edf5fa;
+ font: 76%/170% Verdana, sans-serif;
+ color: #494949;
+}
+
+.this .is .a .test {
+ font: 1em/100% Verdana, sans-serif;
+ color: #494949;
+}
+
+/**
+ * CSS spec says that all whitespace is valid whitespace, so this selector
+ * should be just as good as the one above.
+ */
+.this
+.is
+.a
+.test {
+font: 1em/100% Verdana, sans-serif;
+color: #494949;
+}
+
+some :pseudo .thing {
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ filter: progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10');
+ -ms-filter: "progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10')";
+}
+
+::-moz-selection {
+ background: #000;
+ color:#fff;
+
+}
+::selection {
+ background: #000;
+ color: #fff;
+}
+
+@media print {
+ * {
+ background: #000 !important;
+ color: #fff !important;
+ }
+ @page {
+ margin: 0.5cm;
+ }
+}
+
+@media screen and (max-device-width: 480px) {
+ background: #000;
+ color: #fff;
+}
+
+textarea, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
+
diff --git a/core/modules/simpletest/files/css_test_files/css_input_without_import.css.optimized.css b/core/modules/simpletest/files/css_test_files/css_input_without_import.css.optimized.css
new file mode 100644
index 000000000000..c7bb9dcd17cd
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_without_import.css.optimized.css
@@ -0,0 +1,4 @@
+body{margin:0;padding:0;background:#edf5fa;font:76%/170% Verdana,sans-serif;color:#494949;}.this .is .a .test{font:1em/100% Verdana,sans-serif;color:#494949;}.this
+.is
+.a
+.test{font:1em/100% Verdana,sans-serif;color:#494949;}some :pseudo .thing{-moz-border-radius:3px;-webkit-border-radius:3px;border-radius:3px;filter:progid:DXImageTransform.Microsoft.Shadow(color=#000000,direction='180',strength='10');-ms-filter:"progid:DXImageTransform.Microsoft.Shadow(color=#000000,direction='180',strength='10')";}::-moz-selection{background:#000;color:#fff;}::selection{background:#000;color:#fff;}@media print{*{background:#000 !important;color:#fff !important;}@page{margin:0.5cm;}}@media screen and (max-device-width:480px){background:#000;color:#fff;}textarea,select{font:1em/160% Verdana,sans-serif;color:#494949;}
diff --git a/core/modules/simpletest/files/css_test_files/css_input_without_import.css.unoptimized.css b/core/modules/simpletest/files/css_test_files/css_input_without_import.css.unoptimized.css
new file mode 100644
index 000000000000..620360abc5d4
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/css_input_without_import.css.unoptimized.css
@@ -0,0 +1,69 @@
+
+/**
+ * @file Basic css that does not use import
+ */
+
+
+body {
+ margin: 0;
+ padding: 0;
+ background: #edf5fa;
+ font: 76%/170% Verdana, sans-serif;
+ color: #494949;
+}
+
+.this .is .a .test {
+ font: 1em/100% Verdana, sans-serif;
+ color: #494949;
+}
+
+/**
+ * CSS spec says that all whitespace is valid whitespace, so this selector
+ * should be just as good as the one above.
+ */
+.this
+.is
+.a
+.test {
+font: 1em/100% Verdana, sans-serif;
+color: #494949;
+}
+
+some :pseudo .thing {
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+ filter: progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10');
+ -ms-filter: "progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10')";
+}
+
+::-moz-selection {
+ background: #000;
+ color:#fff;
+
+}
+::selection {
+ background: #000;
+ color: #fff;
+}
+
+@media print {
+ * {
+ background: #000 !important;
+ color: #fff !important;
+ }
+ @page {
+ margin: 0.5cm;
+ }
+}
+
+@media screen and (max-device-width: 480px) {
+ background: #000;
+ color: #fff;
+}
+
+textarea, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
+
diff --git a/core/modules/simpletest/files/css_test_files/import1.css b/core/modules/simpletest/files/css_test_files/import1.css
new file mode 100644
index 000000000000..3d5842ece79f
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/import1.css
@@ -0,0 +1,6 @@
+
+ul, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
+.ui-icon{background-image: url(images/icon.png);} \ No newline at end of file
diff --git a/core/modules/simpletest/files/css_test_files/import2.css b/core/modules/simpletest/files/css_test_files/import2.css
new file mode 100644
index 000000000000..367eb5711800
--- /dev/null
+++ b/core/modules/simpletest/files/css_test_files/import2.css
@@ -0,0 +1,5 @@
+
+p, select {
+ font: 1em/160% Verdana, sans-serif;
+ color: #494949;
+}
diff --git a/core/modules/simpletest/files/html-1.txt b/core/modules/simpletest/files/html-1.txt
new file mode 100644
index 000000000000..494470d17178
--- /dev/null
+++ b/core/modules/simpletest/files/html-1.txt
@@ -0,0 +1 @@
+<h1>SimpleTest HTML</h1> \ No newline at end of file
diff --git a/core/modules/simpletest/files/html-2.html b/core/modules/simpletest/files/html-2.html
new file mode 100644
index 000000000000..494470d17178
--- /dev/null
+++ b/core/modules/simpletest/files/html-2.html
@@ -0,0 +1 @@
+<h1>SimpleTest HTML</h1> \ No newline at end of file
diff --git a/core/modules/simpletest/files/image-1.png b/core/modules/simpletest/files/image-1.png
new file mode 100644
index 000000000000..09e64d6edbc2
--- /dev/null
+++ b/core/modules/simpletest/files/image-1.png
Binary files differ
diff --git a/core/modules/simpletest/files/image-2.jpg b/core/modules/simpletest/files/image-2.jpg
new file mode 100644
index 000000000000..ace07d078a00
--- /dev/null
+++ b/core/modules/simpletest/files/image-2.jpg
Binary files differ
diff --git a/core/modules/simpletest/files/image-test.gif b/core/modules/simpletest/files/image-test.gif
new file mode 100644
index 000000000000..432990b832d8
--- /dev/null
+++ b/core/modules/simpletest/files/image-test.gif
Binary files differ
diff --git a/core/modules/simpletest/files/image-test.jpg b/core/modules/simpletest/files/image-test.jpg
new file mode 100644
index 000000000000..de4eace04eca
--- /dev/null
+++ b/core/modules/simpletest/files/image-test.jpg
Binary files differ
diff --git a/core/modules/simpletest/files/image-test.png b/core/modules/simpletest/files/image-test.png
new file mode 100644
index 000000000000..39c041927e1d
--- /dev/null
+++ b/core/modules/simpletest/files/image-test.png
Binary files differ
diff --git a/core/modules/simpletest/files/javascript-1.txt b/core/modules/simpletest/files/javascript-1.txt
new file mode 100644
index 000000000000..efd44fd9360a
--- /dev/null
+++ b/core/modules/simpletest/files/javascript-1.txt
@@ -0,0 +1,3 @@
+<script>
+alert('SimpleTest PHP was executed!');
+</script>
diff --git a/core/modules/simpletest/files/javascript-2.script b/core/modules/simpletest/files/javascript-2.script
new file mode 100644
index 000000000000..e0206ba8319b
--- /dev/null
+++ b/core/modules/simpletest/files/javascript-2.script
@@ -0,0 +1,3 @@
+<script>
+alert('SimpleTest PHP was executed!');
+</script> \ No newline at end of file
diff --git a/core/modules/simpletest/files/php-1.txt b/core/modules/simpletest/files/php-1.txt
new file mode 100644
index 000000000000..52788b6feac0
--- /dev/null
+++ b/core/modules/simpletest/files/php-1.txt
@@ -0,0 +1,3 @@
+<?php
+print 'SimpleTest PHP was executed!';
+?>
diff --git a/core/modules/simpletest/files/php-2.php b/core/modules/simpletest/files/php-2.php
new file mode 100644
index 000000000000..615a8d78fb48
--- /dev/null
+++ b/core/modules/simpletest/files/php-2.php
@@ -0,0 +1,2 @@
+<?php
+print 'SimpleTest PHP was executed!';
diff --git a/core/modules/simpletest/files/sql-1.txt b/core/modules/simpletest/files/sql-1.txt
new file mode 100644
index 000000000000..22017e97232f
--- /dev/null
+++ b/core/modules/simpletest/files/sql-1.txt
@@ -0,0 +1 @@
+SELECT invalid_field FROM {invalid_table} \ No newline at end of file
diff --git a/core/modules/simpletest/files/sql-2.sql b/core/modules/simpletest/files/sql-2.sql
new file mode 100644
index 000000000000..22017e97232f
--- /dev/null
+++ b/core/modules/simpletest/files/sql-2.sql
@@ -0,0 +1 @@
+SELECT invalid_field FROM {invalid_table} \ No newline at end of file
diff --git a/core/modules/simpletest/simpletest.api.php b/core/modules/simpletest/simpletest.api.php
new file mode 100644
index 000000000000..04c080bfd544
--- /dev/null
+++ b/core/modules/simpletest/simpletest.api.php
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the SimpleTest module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Alter the list of tests.
+ *
+ * @param $groups
+ * A two dimension array, the first key is the test group (as defined in
+ * getInfo) the second is the name of the class and the value is the return
+ * value of the getInfo method.
+ */
+function hook_simpletest_alter(&$groups) {
+ // An alternative session handler module would not want to run the original
+ // Session https handling test because it checks the sessions table in the
+ // database.
+ unset($groups['Session']['testHttpsSession']);
+}
+
+/**
+ * A test group has started.
+ *
+ * This hook is called just once at the beginning of a test group.
+ */
+function hook_test_group_started() {
+}
+
+/**
+ * A test group has finished.
+ *
+ * This hook is called just once at the end of a test group.
+ */
+function hook_test_group_finished() {
+}
+
+/**
+ * An individual test has finished.
+ *
+ * This hook is called when an individual test has finished.
+ *
+ * @param
+ * $results The results of the test as gathered by DrupalWebTestCase.
+ *
+ * @see DrupalWebTestCase->results
+ */
+function hook_test_finished($results) {
+}
+
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/simpletest/simpletest.css b/core/modules/simpletest/simpletest.css
new file mode 100644
index 000000000000..0cf9aaa5edd0
--- /dev/null
+++ b/core/modules/simpletest/simpletest.css
@@ -0,0 +1,89 @@
+
+/* Test Table */
+#simpletest-form-table th.select-all {
+ width: 1em;
+}
+th.simpletest_test {
+ width: 16em;
+}
+
+.simpletest-image {
+ display: inline-block;
+ cursor: pointer;
+ width: 1em;
+}
+.simpletest-group-label label {
+ display: inline;
+ font-weight: bold;
+}
+.simpletest-test-label label {
+ margin-left: 1em; /* LTR */
+}
+.simpletest-test-description .description {
+ margin: 0;
+}
+#simpletest-form-table tr td {
+ background-color: white;
+ color: #494949;
+}
+#simpletest-form-table tr.simpletest-group td {
+ background-color: #EDF5FA;
+ color: #494949;
+}
+
+table#simpletest-form-table tr.simpletest-group label {
+ display: inline;
+}
+
+div.message > div.item-list {
+ font-weight: normal;
+}
+
+div.simpletest-pass {
+ color: #33a333;
+}
+.simpletest-fail {
+ color: #981010;
+}
+
+tr.simpletest-pass.odd {
+ background-color: #b6ffb6;
+}
+tr.simpletest-pass.even {
+ background-color: #9bff9b;
+}
+tr.simpletest-fail.odd {
+ background-color: #ffc9c9;
+}
+tr.simpletest-fail.even {
+ background-color: #ffacac;
+}
+tr.simpletest-exception.odd {
+ background-color: #f4ea71;
+}
+tr.simpletest-exception.even {
+ background-color: #f5e742;
+}
+tr.simpletest-debug.odd {
+ background-color: #eee;
+}
+tr.simpletest-debug.even {
+ background-color: #fff;
+}
+
+a.simpletest-collapse {
+ height: 0;
+ width: 0;
+ top: -99em;
+ position: absolute;
+}
+a.simpletest-collapse:focus,
+a.simpletest-collapse:hover {
+ font-size: 80%;
+ top: 0px;
+ height: auto;
+ width: auto;
+ overflow: visible;
+ position: relative;
+ z-index: 1000;
+}
diff --git a/core/modules/simpletest/simpletest.info b/core/modules/simpletest/simpletest.info
new file mode 100644
index 000000000000..fab7b5ebc115
--- /dev/null
+++ b/core/modules/simpletest/simpletest.info
@@ -0,0 +1,41 @@
+name = Testing
+description = Provides a framework for unit and functional testing.
+package = Core
+version = VERSION
+core = 8.x
+files[] = simpletest.test
+files[] = drupal_web_test_case.php
+configure = admin/config/development/testing/settings
+
+; Tests in tests directory.
+files[] = tests/actions.test
+files[] = tests/ajax.test
+files[] = tests/batch.test
+files[] = tests/bootstrap.test
+files[] = tests/cache.test
+files[] = tests/common.test
+files[] = tests/database_test.test
+files[] = tests/error.test
+files[] = tests/file.test
+files[] = tests/filetransfer.test
+files[] = tests/form.test
+files[] = tests/graph.test
+files[] = tests/image.test
+files[] = tests/lock.test
+files[] = tests/mail.test
+files[] = tests/menu.test
+files[] = tests/module.test
+files[] = tests/password.test
+files[] = tests/path.test
+files[] = tests/registry.test
+files[] = tests/schema.test
+files[] = tests/session.test
+files[] = tests/symfony.test
+files[] = tests/tablesort.test
+files[] = tests/theme.test
+files[] = tests/unicode.test
+files[] = tests/update.test
+files[] = tests/xmlrpc.test
+files[] = tests/upgrade/upgrade.test
+files[] = tests/upgrade/upgrade_bare.test
+files[] = tests/upgrade/upgrade_filled.test
diff --git a/core/modules/simpletest/simpletest.install b/core/modules/simpletest/simpletest.install
new file mode 100644
index 000000000000..ea847f4eaefe
--- /dev/null
+++ b/core/modules/simpletest/simpletest.install
@@ -0,0 +1,182 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the simpletest module.
+ */
+
+/**
+ * Minimum value of PHP memory_limit for SimpleTest.
+ */
+define('SIMPLETEST_MINIMUM_PHP_MEMORY_LIMIT', '64M');
+
+/**
+ * Implements hook_requirements().
+ */
+function simpletest_requirements($phase) {
+ $requirements = array();
+ $t = get_t();
+
+ $has_curl = function_exists('curl_init');
+ $has_hash = function_exists('hash_hmac');
+ $has_domdocument = method_exists('DOMDocument', 'loadHTML');
+ $open_basedir = ini_get('open_basedir');
+
+ $requirements['curl'] = array(
+ 'title' => $t('cURL'),
+ 'value' => $has_curl ? $t('Enabled') : $t('Not found'),
+ );
+ if (!$has_curl) {
+ $requirements['curl']['severity'] = REQUIREMENT_ERROR;
+ $requirements['curl']['description'] = $t('The testing framework could not be installed because the PHP <a href="@curl_url">cURL</a> library is not available.', array('@curl_url' => 'http://php.net/manual/en/curl.setup.php'));
+ }
+ $requirements['hash'] = array(
+ 'title' => $t('hash'),
+ 'value' => $has_hash ? $t('Enabled') : $t('Not found'),
+ );
+ if (!$has_hash) {
+ $requirements['hash']['severity'] = REQUIREMENT_ERROR;
+ $requirements['hash']['description'] = $t('The testing framework could not be installed because the PHP <a href="@hash_url">hash</a> extension is disabled.', array('@hash_url' => 'http://php.net/manual/en/book.hash.php'));
+ }
+
+ $requirements['php_domdocument'] = array(
+ 'title' => $t('PHP DOMDocument class'),
+ 'value' => $has_domdocument ? $t('Enabled') : $t('Not found'),
+ );
+ if (!$has_domdocument) {
+ $requirements['php_domdocument']['severity'] = REQUIREMENT_ERROR;
+ $requirements['php_domdocument']['description'] = $t('The testing framework requires the DOMDocument class to be available. Check the configure command at the <a href="@link-phpinfo">PHP info page</a>.', array('@link-phpinfo' => url('admin/reports/status/php')));
+ }
+
+ // SimpleTest currently needs 2 cURL options which are incompatible with
+ // having PHP's open_basedir restriction set.
+ // See http://drupal.org/node/674304.
+ $requirements['php_open_basedir'] = array(
+ 'title' => $t('PHP open_basedir restriction'),
+ 'value' => $open_basedir ? $t('Enabled') : $t('Disabled'),
+ );
+ if ($open_basedir) {
+ $requirements['php_open_basedir']['severity'] = REQUIREMENT_ERROR;
+ $requirements['php_open_basedir']['description'] = $t('The testing framework requires the PHP <a href="@open_basedir-url">open_basedir</a> restriction to be disabled. Check your webserver configuration or contact your web host.', array('@open_basedir-url' => 'http://php.net/manual/en/ini.core.php#ini.open-basedir'));
+ }
+
+ // Check the current memory limit. If it is set too low, SimpleTest will fail
+ // to load all tests and throw a fatal error.
+ $memory_limit = ini_get('memory_limit');
+ if ($memory_limit && $memory_limit != -1 && parse_size($memory_limit) < parse_size(SIMPLETEST_MINIMUM_PHP_MEMORY_LIMIT)) {
+ $requirements['php_memory_limit']['severity'] = REQUIREMENT_ERROR;
+ $requirements['php_memory_limit']['description'] = $t('The testing framework requires the PHP memory limit to be at least %memory_minimum_limit. The current value is %memory_limit. <a href="@url">Follow these steps to continue</a>.', array('%memory_limit' => $memory_limit, '%memory_minimum_limit' => SIMPLETEST_MINIMUM_PHP_MEMORY_LIMIT, '@url' => 'http://drupal.org/node/207036'));
+ }
+
+ return $requirements;
+}
+
+/**
+ * Implements hook_schema().
+ */
+function simpletest_schema() {
+ $schema['simpletest'] = array(
+ 'description' => 'Stores simpletest messages',
+ 'fields' => array(
+ 'message_id' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique simpletest message ID.',
+ ),
+ 'test_id' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Test ID, messages belonging to the same ID are reported together',
+ ),
+ 'test_class' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The name of the class that created this message.',
+ ),
+ 'status' => array(
+ 'type' => 'varchar',
+ 'length' => 9,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Message status. Core understands pass, fail, exception.',
+ ),
+ 'message' => array(
+ 'type' => 'text',
+ 'not null' => TRUE,
+ 'description' => 'The message itself.',
+ ),
+ 'message_group' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The message group this message belongs to. For example: warning, browser, user.',
+ ),
+ 'function' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Name of the assertion function or method that created this message.',
+ ),
+ 'line' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Line number on which the function is called.',
+ ),
+ 'file' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Name of the file where the function is called.',
+ ),
+ ),
+ 'primary key' => array('message_id'),
+ 'indexes' => array(
+ 'reporter' => array('test_class', 'message_id'),
+ ),
+ );
+ $schema['simpletest_test_id'] = array(
+ 'description' => 'Stores simpletest test IDs, used to auto-incrament the test ID so that a fresh test ID is used.',
+ 'fields' => array(
+ 'test_id' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique simpletest ID used to group test results together. Each time a set of tests
+ are run a new test ID is used.',
+ ),
+ 'last_prefix' => array(
+ 'type' => 'varchar',
+ 'length' => 60,
+ 'not null' => FALSE,
+ 'default' => '',
+ 'description' => 'The last database prefix used during testing.',
+ ),
+ ),
+ 'primary key' => array('test_id'),
+ );
+ return $schema;
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function simpletest_uninstall() {
+ drupal_load('module', 'simpletest');
+ simpletest_clean_database();
+
+ // Remove settings variables.
+ variable_del('simpletest_httpauth_method');
+ variable_del('simpletest_httpauth_username');
+ variable_del('simpletest_httpauth_password');
+ variable_del('simpletest_clear_results');
+ variable_del('simpletest_verbose');
+
+ // Remove generated files.
+ file_unmanaged_delete_recursive('public://simpletest');
+}
diff --git a/core/modules/simpletest/simpletest.js b/core/modules/simpletest/simpletest.js
new file mode 100644
index 000000000000..c33ef982a8b2
--- /dev/null
+++ b/core/modules/simpletest/simpletest.js
@@ -0,0 +1,103 @@
+(function ($) {
+
+/**
+ * Add the cool table collapsing on the testing overview page.
+ */
+Drupal.behaviors.simpleTestMenuCollapse = {
+ attach: function (context, settings) {
+ var timeout = null;
+ // Adds expand-collapse functionality.
+ $('div.simpletest-image').each(function () {
+ direction = settings.simpleTest[$(this).attr('id')].imageDirection;
+ $(this).html(settings.simpleTest.images[direction]);
+ });
+
+ // Adds group toggling functionality to arrow images.
+ $('div.simpletest-image').click(function () {
+ var trs = $(this).parents('tbody').children('.' + settings.simpleTest[this.id].testClass);
+ var direction = settings.simpleTest[this.id].imageDirection;
+ var row = direction ? trs.size() - 1 : 0;
+
+ // If clicked in the middle of expanding a group, stop so we can switch directions.
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+
+ // Function to toggle an individual row according to the current direction.
+ // We set a timeout of 20 ms until the next row will be shown/hidden to
+ // create a sliding effect.
+ function rowToggle() {
+ if (direction) {
+ if (row >= 0) {
+ $(trs[row]).hide();
+ row--;
+ timeout = setTimeout(rowToggle, 20);
+ }
+ }
+ else {
+ if (row < trs.size()) {
+ $(trs[row]).removeClass('js-hide').show();
+ row++;
+ timeout = setTimeout(rowToggle, 20);
+ }
+ }
+ }
+
+ // Kick-off the toggling upon a new click.
+ rowToggle();
+
+ // Toggle the arrow image next to the test group title.
+ $(this).html(settings.simpleTest.images[(direction ? 0 : 1)]);
+ settings.simpleTest[this.id].imageDirection = !direction;
+
+ });
+ }
+};
+
+/**
+ * Select/deselect all the inner checkboxes when the outer checkboxes are
+ * selected/deselected.
+ */
+Drupal.behaviors.simpleTestSelectAll = {
+ attach: function (context, settings) {
+ $('td.simpletest-select-all').each(function () {
+ var testCheckboxes = settings.simpleTest['simpletest-test-group-' + $(this).attr('id')].testNames;
+ var groupCheckbox = $('<input type="checkbox" class="form-checkbox" id="' + $(this).attr('id') + '-select-all" />');
+
+ // Each time a single-test checkbox is checked or unchecked, make sure
+ // that the associated group checkbox gets the right state too.
+ var updateGroupCheckbox = function () {
+ var checkedTests = 0;
+ for (var i = 0; i < testCheckboxes.length; i++) {
+ $('#' + testCheckboxes[i]).each(function () {
+ if (($(this).attr('checked'))) {
+ checkedTests++;
+ }
+ });
+ }
+ $(groupCheckbox).attr('checked', (checkedTests == testCheckboxes.length));
+ };
+
+ // Have the single-test checkboxes follow the group checkbox.
+ groupCheckbox.change(function () {
+ var checked = !!($(this).attr('checked'));
+ for (var i = 0; i < testCheckboxes.length; i++) {
+ $('#' + testCheckboxes[i]).attr('checked', checked);
+ }
+ });
+
+ // Have the group checkbox follow the single-test checkboxes.
+ for (var i = 0; i < testCheckboxes.length; i++) {
+ $('#' + testCheckboxes[i]).change(function () {
+ updateGroupCheckbox();
+ });
+ }
+
+ // Initialize status for the group checkbox correctly.
+ updateGroupCheckbox();
+ $(this).append(groupCheckbox);
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module
new file mode 100644
index 000000000000..eb33d1bfb59b
--- /dev/null
+++ b/core/modules/simpletest/simpletest.module
@@ -0,0 +1,506 @@
+<?php
+
+/**
+ * @file
+ * Provides testing functionality.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function simpletest_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#simpletest':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Testing module provides a framework for running automated unit tests. It can be used to verify a working state of Drupal before and after any code changes, or as a means for developers to write and execute tests for their modules. For more information, see the online handbook entry for <a href="@simpletest">Testing module</a>.', array('@simpletest' => 'http://drupal.org/handbook/modules/simpletest', '@blocks' => url('admin/structure/block'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Running tests') . '</dt>';
+ $output .= '<dd>' . t('Visit the <a href="@admin-simpletest">Testing page</a> to display a list of available tests. For comprehensive testing, select <em>all</em> tests, or individually select tests for more targeted testing. Note that it might take several minutes for all tests to complete. For more information on creating and modifying your own tests, see the <a href="@simpletest-api">Testing API Documentation</a> in the Drupal handbook.', array('@simpletest-api' => 'http://drupal.org/simpletest', '@admin-simpletest' => url('admin/config/development/testing'))) . '</dd>';
+ $output .= '<dd>' . t('After the tests run, a message will be displayed next to each test group indicating whether tests within it passed, failed, or had exceptions. A pass means that the test returned the expected results, while fail means that it did not. An exception normally indicates an error outside of the test, such as a PHP warning or notice. If there were failures or exceptions, the results will be expanded to show details, and the tests that had failures or exceptions will be indicated in red or pink rows. You can then use these results to refine your code and tests, until all tests pass.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function simpletest_menu() {
+ $items['admin/config/development/testing'] = array(
+ 'title' => 'Testing',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('simpletest_test_form'),
+ 'description' => 'Run tests against Drupal core and your modules. These tests help assure that your site code is working as designed.',
+ 'access arguments' => array('administer unit tests'),
+ 'file' => 'simpletest.pages.inc',
+ 'weight' => -5,
+ );
+ $items['admin/config/development/testing/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/config/development/testing/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('simpletest_settings_form'),
+ 'access arguments' => array('administer unit tests'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'simpletest.pages.inc',
+ );
+ $items['admin/config/development/testing/results/%'] = array(
+ 'title' => 'Test result',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('simpletest_result_form', 5),
+ 'description' => 'View result of tests.',
+ 'access arguments' => array('administer unit tests'),
+ 'file' => 'simpletest.pages.inc',
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_permission().
+ */
+function simpletest_permission() {
+ return array(
+ 'administer unit tests' => array(
+ 'title' => t('Administer tests'),
+ 'restrict access' => TRUE,
+ ),
+ );
+}
+
+/**
+ * Implements hook_theme().
+ */
+function simpletest_theme() {
+ return array(
+ 'simpletest_test_table' => array(
+ 'render element' => 'table',
+ 'file' => 'simpletest.pages.inc',
+ ),
+ 'simpletest_result_summary' => array(
+ 'render element' => 'form',
+ 'file' => 'simpletest.pages.inc',
+ ),
+ );
+}
+
+/**
+ * Implements hook_js_alter().
+ */
+function simpletest_js_alter(&$javascript) {
+ // Since SimpleTest is a special use case for the table select, stick the
+ // SimpleTest JavaScript above the table select.
+ $simpletest = drupal_get_path('module', 'simpletest') . '/simpletest.js';
+ if (array_key_exists($simpletest, $javascript) && array_key_exists('core/misc/tableselect.js', $javascript)) {
+ $javascript[$simpletest]['weight'] = $javascript['core/misc/tableselect.js']['weight'] - 1;
+ }
+}
+
+function _simpletest_format_summary_line($summary) {
+ $args = array(
+ '@pass' => format_plural(isset($summary['#pass']) ? $summary['#pass'] : 0, '1 pass', '@count passes'),
+ '@fail' => format_plural(isset($summary['#fail']) ? $summary['#fail'] : 0, '1 fail', '@count fails'),
+ '@exception' => format_plural(isset($summary['#exception']) ? $summary['#exception'] : 0, '1 exception', '@count exceptions'),
+ );
+ if (!$summary['#debug']) {
+ return t('@pass, @fail, and @exception', $args);
+ }
+ $args['@debug'] = format_plural(isset($summary['#debug']) ? $summary['#debug'] : 0, '1 debug message', '@count debug messages');
+ return t('@pass, @fail, @exception, and @debug', $args);
+}
+
+/**
+ * Actually runs tests.
+ *
+ * @param $test_list
+ * List of tests to run.
+ * @param $reporter
+ * Which reporter to use. Allowed values are: text, xml, html and drupal,
+ * drupal being the default.
+ */
+function simpletest_run_tests($test_list, $reporter = 'drupal') {
+ $test_id = db_insert('simpletest_test_id')
+ ->useDefaults(array('test_id'))
+ ->execute();
+
+ // Clear out the previous verbose files.
+ file_unmanaged_delete_recursive('public://simpletest/verbose');
+
+ // Get the info for the first test being run.
+ $first_test = array_shift($test_list);
+ $first_instance = new $first_test();
+ array_unshift($test_list, $first_test);
+ $info = $first_instance->getInfo();
+
+ $batch = array(
+ 'title' => t('Running tests'),
+ 'operations' => array(
+ array('_simpletest_batch_operation', array($test_list, $test_id)),
+ ),
+ 'finished' => '_simpletest_batch_finished',
+ 'progress_message' => '',
+ 'css' => array(drupal_get_path('module', 'simpletest') . '/simpletest.css'),
+ 'init_message' => t('Processing test @num of @max - %test.', array('%test' => $info['name'], '@num' => '1', '@max' => count($test_list))),
+ );
+ batch_set($batch);
+
+ module_invoke_all('test_group_started');
+
+ return $test_id;
+}
+
+/**
+ * Batch operation callback.
+ */
+function _simpletest_batch_operation($test_list_init, $test_id, &$context) {
+ // Get working values.
+ if (!isset($context['sandbox']['max'])) {
+ // First iteration: initialize working values.
+ $test_list = $test_list_init;
+ $context['sandbox']['max'] = count($test_list);
+ $test_results = array('#pass' => 0, '#fail' => 0, '#exception' => 0, '#debug' => 0);
+ }
+ else {
+ // Nth iteration: get the current values where we last stored them.
+ $test_list = $context['sandbox']['tests'];
+ $test_results = $context['sandbox']['test_results'];
+ }
+ $max = $context['sandbox']['max'];
+
+ // Perform the next test.
+ $test_class = array_shift($test_list);
+ $test = new $test_class($test_id);
+ $test->run();
+ $size = count($test_list);
+ $info = $test->getInfo();
+
+ module_invoke_all('test_finished', $test->results);
+
+ // Gather results and compose the report.
+ $test_results[$test_class] = $test->results;
+ foreach ($test_results[$test_class] as $key => $value) {
+ $test_results[$key] += $value;
+ }
+ $test_results[$test_class]['#name'] = $info['name'];
+ $items = array();
+ foreach (element_children($test_results) as $class) {
+ array_unshift($items, '<div class="simpletest-' . ($test_results[$class]['#fail'] + $test_results[$class]['#exception'] ? 'fail' : 'pass') . '">' . t('@name: @summary', array('@name' => $test_results[$class]['#name'], '@summary' => _simpletest_format_summary_line($test_results[$class]))) . '</div>');
+ }
+ $context['message'] = t('Processed test @num of @max - %test.', array('%test' => $info['name'], '@num' => $max - $size, '@max' => $max));
+ $context['message'] .= '<div class="simpletest-' . ($test_results['#fail'] + $test_results['#exception'] ? 'fail' : 'pass') . '">Overall results: ' . _simpletest_format_summary_line($test_results) . '</div>';
+ $context['message'] .= theme('item_list', array('items' => $items));
+
+ // Save working values for the next iteration.
+ $context['sandbox']['tests'] = $test_list;
+ $context['sandbox']['test_results'] = $test_results;
+ // The test_id is the only thing we need to save for the report page.
+ $context['results']['test_id'] = $test_id;
+
+ // Multistep processing: report progress.
+ $context['finished'] = 1 - $size / $max;
+}
+
+function _simpletest_batch_finished($success, $results, $operations, $elapsed) {
+ if ($success) {
+ drupal_set_message(t('The test run finished in @elapsed.', array('@elapsed' => $elapsed)));
+ }
+ else {
+ // Use the test_id passed as a parameter to _simpletest_batch_operation().
+ $test_id = $operations[0][1][1];
+
+ // Retrieve the last database prefix used for testing and the last test
+ // class that was run from. Use the information to read the lgo file
+ // in case any fatal errors caused the test to crash.
+ list($last_prefix, $last_test_class) = simpletest_last_test_get($test_id);
+ simpletest_log_read($test_id, $last_prefix, $last_test_class);
+
+ drupal_set_message(t('The test run did not successfully finish.'), 'error');
+ drupal_set_message(t('Use the <em>Clean environment</em> button to clean-up temporary files and tables.'), 'warning');
+ }
+ module_invoke_all('test_group_finished');
+}
+
+/**
+ * Get information about the last test that ran given a test ID.
+ *
+ * @param $test_id
+ * The test ID to get the last test from.
+ * @return
+ * Array containing the last database prefix used and the last test class
+ * that ran.
+ */
+function simpletest_last_test_get($test_id) {
+ $last_prefix = db_query_range('SELECT last_prefix FROM {simpletest_test_id} WHERE test_id = :test_id', 0, 1, array(':test_id' => $test_id))->fetchField();
+ $last_test_class = db_query_range('SELECT test_class FROM {simpletest} WHERE test_id = :test_id ORDER BY message_id DESC', 0, 1, array(':test_id' => $test_id))->fetchField();
+ return array($last_prefix, $last_test_class);
+}
+
+/**
+ * Read the error log and report any errors as assertion failures.
+ *
+ * The errors in the log should only be fatal errors since any other errors
+ * will have been recorded by the error handler.
+ *
+ * @param $test_id
+ * The test ID to which the log relates.
+ * @param $prefix
+ * The database prefix to which the log relates.
+ * @param $test_class
+ * The test class to which the log relates.
+ * @param $during_test
+ * Indicates that the current file directory path is a temporary file
+ * file directory used during testing.
+ * @return
+ * Found any entries in log.
+ */
+function simpletest_log_read($test_id, $prefix, $test_class, $during_test = FALSE) {
+ $log = 'public://' . ($during_test ? '' : '/simpletest/' . substr($prefix, 10)) . '/error.log';
+ $found = FALSE;
+ if (file_exists($log)) {
+ foreach (file($log) as $line) {
+ if (preg_match('/\[.*?\] (.*?): (.*?) in (.*) on line (\d+)/', $line, $match)) {
+ // Parse PHP fatal errors for example: PHP Fatal error: Call to
+ // undefined function break_me() in /path/to/file.php on line 17
+ $caller = array(
+ 'line' => $match[4],
+ 'file' => $match[3],
+ );
+ DrupalTestCase::insertAssert($test_id, $test_class, FALSE, $match[2], $match[1], $caller);
+ }
+ else {
+ // Unknown format, place the entire message in the log.
+ DrupalTestCase::insertAssert($test_id, $test_class, FALSE, $line, 'Fatal error');
+ }
+ $found = TRUE;
+ }
+ }
+ return $found;
+}
+
+/**
+ * Get a list of all of the tests provided by the system.
+ *
+ * The list of test classes is loaded from the registry where it looks for
+ * files ending in ".test". Once loaded the test list is cached and stored in
+ * a static variable. In order to list tests provided by disabled modules
+ * hook_registry_files_alter() is used to forcefully add them to the registry.
+ *
+ * @return
+ * An array of tests keyed with the groups specified in each of the tests
+ * getInfo() method and then keyed by the test class. An example of the array
+ * structure is provided below.
+ *
+ * @code
+ * $groups['Block'] => array(
+ * 'BlockTestCase' => array(
+ * 'name' => 'Block functionality',
+ * 'description' => 'Add, edit and delete custom block...',
+ * 'group' => 'Block',
+ * ),
+ * );
+ * @endcode
+ * @see simpletest_registry_files_alter()
+ */
+function simpletest_test_get_all() {
+ $groups = &drupal_static(__FUNCTION__);
+
+ if (!$groups) {
+ // Load test information from cache if available, otherwise retrieve the
+ // information from each tests getInfo() method.
+ if ($cache = cache()->get('simpletest')) {
+ $groups = $cache->data;
+ }
+ else {
+ // Select all clases in files ending with .test.
+ $classes = db_query("SELECT name FROM {registry} WHERE type = :type AND filename LIKE :name", array(':type' => 'class', ':name' => '%.test'))->fetchCol();
+
+ // Check that each class has a getInfo() method and store the information
+ // in an array keyed with the group specified in the test information.
+ $groups = array();
+ foreach ($classes as $class) {
+ // Test classes need to implement getInfo() to be valid.
+ if (class_exists($class) && method_exists($class, 'getInfo')) {
+ $info = call_user_func(array($class, 'getInfo'));
+
+ // If this test class requires a non-existing module, skip it.
+ if (!empty($info['dependencies'])) {
+ foreach ($info['dependencies'] as $module) {
+ if (!drupal_get_filename('module', $module)) {
+ continue 2;
+ }
+ }
+ }
+
+ $groups[$info['group']][$class] = $info;
+ }
+ }
+
+ // Sort the groups and tests within the groups by name.
+ uksort($groups, 'strnatcasecmp');
+ foreach ($groups as $group => &$tests) {
+ uksort($tests, 'strnatcasecmp');
+ }
+
+ // Allow modules extending core tests to disable originals.
+ drupal_alter('simpletest', $groups);
+ cache()->set('simpletest', $groups);
+ }
+ }
+ return $groups;
+}
+
+/**
+ * Implements hook_registry_files_alter().
+ *
+ * Add the test files for disabled modules so that we get a list containing
+ * all the avialable tests.
+ */
+function simpletest_registry_files_alter(&$files, $modules) {
+ foreach ($modules as $module) {
+ // Only add test files for disabled modules, as enabled modules should
+ // already include any test files they provide.
+ if (!$module->status) {
+ $dir = $module->dir;
+ if (!empty($module->info['files'])) {
+ foreach ($module->info['files'] as $file) {
+ if (substr($file, -5) == '.test') {
+ $files["$dir/$file"] = array('module' => $module->name, 'weight' => $module->weight);
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Generate test file.
+ */
+function simpletest_generate_file($filename, $width, $lines, $type = 'binary-text') {
+ $size = $width * $lines - $lines;
+
+ // Generate random text
+ $text = '';
+ for ($i = 0; $i < $size; $i++) {
+ switch ($type) {
+ case 'text':
+ $text .= chr(rand(32, 126));
+ break;
+ case 'binary':
+ $text .= chr(rand(0, 31));
+ break;
+ case 'binary-text':
+ default:
+ $text .= rand(0, 1);
+ break;
+ }
+ }
+ $text = wordwrap($text, $width - 1, "\n", TRUE) . "\n"; // Add \n for symmetrical file.
+
+ // Create filename.
+ file_put_contents('public://' . $filename . '.txt', $text);
+ return $filename;
+}
+
+/**
+ * Remove all temporary database tables and directories.
+ */
+function simpletest_clean_environment() {
+ simpletest_clean_database();
+ simpletest_clean_temporary_directories();
+ if (variable_get('simpletest_clear_results', TRUE)) {
+ $count = simpletest_clean_results_table();
+ drupal_set_message(format_plural($count, 'Removed 1 test result.', 'Removed @count test results.'));
+ }
+ else {
+ drupal_set_message(t('Clear results is disabled and the test results table will not be cleared.'), 'warning');
+ }
+
+ // Detect test classes that have been added, renamed or deleted.
+ registry_rebuild();
+ cache()->delete('simpletest');
+}
+
+/**
+ * Removed prefixed tables from the database that are left over from crashed tests.
+ */
+function simpletest_clean_database() {
+ $tables = db_find_tables(Database::getConnection()->prefixTables('{simpletest}') . '%');
+ $schema = drupal_get_schema_unprocessed('simpletest');
+ $count = 0;
+ foreach (array_diff_key($tables, $schema) as $table) {
+ // Strip the prefix and skip tables without digits following "simpletest",
+ // e.g. {simpletest_test_id}.
+ if (preg_match('/simpletest\d+.*/', $table, $matches)) {
+ db_drop_table($matches[0]);
+ $count++;
+ }
+ }
+
+ if ($count > 0) {
+ drupal_set_message(format_plural($count, 'Removed 1 leftover table.', 'Removed @count leftover tables.'));
+ }
+ else {
+ drupal_set_message(t('No leftover tables to remove.'));
+ }
+}
+
+/**
+ * Find all leftover temporary directories and remove them.
+ */
+function simpletest_clean_temporary_directories() {
+ $count = 0;
+ if (is_dir('public://simpletest')) {
+ $files = scandir('public://simpletest');
+ foreach ($files as $file) {
+ $path = 'public://simpletest/' . $file;
+ if (is_dir($path) && is_numeric($file)) {
+ file_unmanaged_delete_recursive($path);
+ $count++;
+ }
+ }
+ }
+
+ if ($count > 0) {
+ drupal_set_message(format_plural($count, 'Removed 1 temporary directory.', 'Removed @count temporary directories.'));
+ }
+ else {
+ drupal_set_message(t('No temporary directories to remove.'));
+ }
+}
+
+/**
+ * Clear the test result tables.
+ *
+ * @param $test_id
+ * Test ID to remove results for, or NULL to remove all results.
+ * @return
+ * The number of results removed.
+ */
+function simpletest_clean_results_table($test_id = NULL) {
+ if (variable_get('simpletest_clear_results', TRUE)) {
+ if ($test_id) {
+ $count = db_query('SELECT COUNT(test_id) FROM {simpletest_test_id} WHERE test_id = :test_id', array(':test_id' => $test_id))->fetchField();
+
+ db_delete('simpletest')
+ ->condition('test_id', $test_id)
+ ->execute();
+ db_delete('simpletest_test_id')
+ ->condition('test_id', $test_id)
+ ->execute();
+ }
+ else {
+ $count = db_query('SELECT COUNT(test_id) FROM {simpletest_test_id}')->fetchField();
+
+ // Clear test results.
+ db_delete('simpletest')->execute();
+ db_delete('simpletest_test_id')->execute();
+ }
+
+ return $count;
+ }
+ return 0;
+}
diff --git a/core/modules/simpletest/simpletest.pages.inc b/core/modules/simpletest/simpletest.pages.inc
new file mode 100644
index 000000000000..e65c4d4b727f
--- /dev/null
+++ b/core/modules/simpletest/simpletest.pages.inc
@@ -0,0 +1,510 @@
+<?php
+
+/**
+ * @file
+ * Page callbacks for simpletest module.
+ */
+
+/**
+ * List tests arranged in groups that can be selected and run.
+ */
+function simpletest_test_form($form) {
+ $form['tests'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Tests'),
+ '#description' => t('Select the test(s) or test group(s) you would like to run, and click <em>Run tests</em>.'),
+ );
+
+ $form['tests']['table'] = array(
+ '#theme' => 'simpletest_test_table',
+ );
+
+ // Generate the list of tests arranged by group.
+ $groups = simpletest_test_get_all();
+ foreach ($groups as $group => $tests) {
+ $form['tests']['table'][$group] = array(
+ '#collapsed' => TRUE,
+ );
+
+ foreach ($tests as $class => $info) {
+ $form['tests']['table'][$group][$class] = array(
+ '#type' => 'checkbox',
+ '#title' => $info['name'],
+ '#description' => $info['description'],
+ );
+ }
+ }
+
+ // Operation buttons.
+ $form['tests']['op'] = array(
+ '#type' => 'submit',
+ '#value' => t('Run tests'),
+ );
+ $form['clean'] = array(
+ '#type' => 'fieldset',
+ '#collapsible' => FALSE,
+ '#collapsed' => FALSE,
+ '#title' => t('Clean test environment'),
+ '#description' => t('Remove tables with the prefix "simpletest" and temporary directories that are left over from tests that crashed. This is intended for developers when creating tests.'),
+ );
+ $form['clean']['op'] = array(
+ '#type' => 'submit',
+ '#value' => t('Clean environment'),
+ '#submit' => array('simpletest_clean_environment'),
+ );
+
+ return $form;
+}
+
+/**
+ * Returns HTML for a test list generated by simpletest_test_form() into a table.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - table: A render element representing the table.
+ *
+ * @ingroup themeable
+ */
+function theme_simpletest_test_table($variables) {
+ $table = $variables['table'];
+
+ drupal_add_css(drupal_get_path('module', 'simpletest') . '/simpletest.css');
+ drupal_add_js(drupal_get_path('module', 'simpletest') . '/simpletest.js');
+ drupal_add_js('core/misc/tableselect.js');
+
+ // Create header for test selection table.
+ $header = array(
+ array('class' => array('select-all')),
+ array('data' => t('Test'), 'class' => array('simpletest_test')),
+ array('data' => t('Description'), 'class' => array('simpletest_description')),
+ );
+
+ // Define the images used to expand/collapse the test groups.
+ $js = array(
+ 'images' => array(
+ theme('image', array('path' => 'core/misc/menu-collapsed.png', 'width' => 7, 'height' => 7, 'alt' => t('Expand'), 'title' => t('Expand'))) . ' <a href="#" class="simpletest-collapse">(' . t('Expand') . ')</a>',
+ theme('image', array('path' => 'core/misc/menu-expanded.png', 'width' => 7, 'height' => 7, 'alt' => t('Collapse'), 'title' => t('Collapse'))) . ' <a href="#" class="simpletest-collapse">(' . t('Collapse') . ')</a>',
+ ),
+ );
+
+ // Cycle through each test group and create a row.
+ $rows = array();
+ foreach (element_children($table) as $key) {
+ $element = &$table[$key];
+ $row = array();
+
+ // Make the class name safe for output on the page by replacing all
+ // non-word/decimal characters with a dash (-).
+ $test_class = strtolower(trim(preg_replace("/[^\w\d]/", "-", $key)));
+
+ // Select the right "expand"/"collapse" image, depending on whether the
+ // category is expanded (at least one test selected) or not.
+ $collapsed = !empty($element['#collapsed']);
+ $image_index = $collapsed ? 0 : 1;
+
+ // Place-holder for checkboxes to select group of tests.
+ $row[] = array('id' => $test_class, 'class' => array('simpletest-select-all'));
+
+ // Expand/collapse image and group title.
+ $row[] = array(
+ 'data' => '<div class="simpletest-image" id="simpletest-test-group-' . $test_class . '"></div>' .
+ '<label for="' . $test_class . '-select-all" class="simpletest-group-label">' . $key . '</label>',
+ 'class' => array('simpletest-group-label'),
+ );
+
+ $row[] = array(
+ 'data' => '&nbsp;',
+ 'class' => array('simpletest-group-description'),
+ );
+
+ $rows[] = array('data' => $row, 'class' => array('simpletest-group'));
+
+ // Add individual tests to group.
+ $current_js = array(
+ 'testClass' => $test_class . '-test',
+ 'testNames' => array(),
+ 'imageDirection' => $image_index,
+ 'clickActive' => FALSE,
+ );
+
+ // Sorting $element by children's #title attribute instead of by class name.
+ uasort($element, 'element_sort_by_title');
+
+ // Cycle through each test within the current group.
+ foreach (element_children($element) as $test_name) {
+ $test = $element[$test_name];
+ $row = array();
+
+ $current_js['testNames'][] = $test['#id'];
+
+ // Store test title and description so that checkbox won't render them.
+ $title = $test['#title'];
+ $description = $test['#description'];
+
+ $test['#title_display'] = 'invisible';
+ unset($test['#description']);
+
+ // Test name is used to determine what tests to run.
+ $test['#name'] = $test_name;
+
+ $row[] = array(
+ 'data' => drupal_render($test),
+ 'class' => array('simpletest-test-select'),
+ );
+ $row[] = array(
+ 'data' => '<label for="' . $test['#id'] . '">' . $title . '</label>',
+ 'class' => array('simpletest-test-label'),
+ );
+ $row[] = array(
+ 'data' => '<div class="description">' . $description . '</div>',
+ 'class' => array('simpletest-test-description'),
+ );
+
+ $rows[] = array('data' => $row, 'class' => array($test_class . '-test', ($collapsed ? 'js-hide' : '')));
+ }
+ $js['simpletest-test-group-' . $test_class] = $current_js;
+ unset($table[$key]);
+ }
+
+ // Add js array of settings.
+ drupal_add_js(array('simpleTest' => $js), 'setting');
+
+ if (empty($rows)) {
+ return '<strong>' . t('No tests to display.') . '</strong>';
+ }
+ else {
+ return theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'simpletest-form-table')));
+ }
+}
+
+/**
+ * Run selected tests.
+ */
+function simpletest_test_form_submit($form, &$form_state) {
+ // Get list of tests.
+ $tests_list = array();
+ foreach ($form_state['values'] as $class_name => $value) {
+ // Since class_exists() will likely trigger an autoload lookup,
+ // we do the fast check first.
+ if ($value === 1 && class_exists($class_name)) {
+ $tests_list[] = $class_name;
+ }
+ }
+ if (count($tests_list) > 0 ) {
+ $test_id = simpletest_run_tests($tests_list, 'drupal');
+ $form_state['redirect'] = 'admin/config/development/testing/results/' . $test_id;
+ }
+ else {
+ drupal_set_message(t('No test(s) selected.'), 'error');
+ }
+}
+
+/**
+ * Test results form for $test_id.
+ */
+function simpletest_result_form($form, &$form_state, $test_id) {
+ // Make sure there are test results to display and a re-run is not being performed.
+ $results = array();
+ if (is_numeric($test_id) && !$results = simpletest_result_get($test_id)) {
+ drupal_set_message(t('No test results to display.'), 'error');
+ drupal_goto('admin/config/development/testing');
+ return $form;
+ }
+
+ // Load all classes and include CSS.
+ drupal_add_css(drupal_get_path('module', 'simpletest') . '/simpletest.css');
+
+ // Keep track of which test cases passed or failed.
+ $filter = array(
+ 'pass' => array(),
+ 'fail' => array(),
+ );
+
+ // Summary result fieldset.
+ $form['result'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Results'),
+ );
+ $form['result']['summary'] = $summary = array(
+ '#theme' => 'simpletest_result_summary',
+ '#pass' => 0,
+ '#fail' => 0,
+ '#exception' => 0,
+ '#debug' => 0,
+ );
+
+ // Cycle through each test group.
+ $header = array(t('Message'), t('Group'), t('Filename'), t('Line'), t('Function'), array('colspan' => 2, 'data' => t('Status')));
+ $form['result']['results'] = array();
+ foreach ($results as $group => $assertions) {
+ // Create group fieldset with summary information.
+ $info = call_user_func(array($group, 'getInfo'));
+ $form['result']['results'][$group] = array(
+ '#type' => 'fieldset',
+ '#title' => $info['name'],
+ '#description' => $info['description'],
+ '#collapsible' => TRUE,
+ );
+ $form['result']['results'][$group]['summary'] = $summary;
+ $group_summary = &$form['result']['results'][$group]['summary'];
+
+ // Create table of assertions for the group.
+ $rows = array();
+ foreach ($assertions as $assertion) {
+ $row = array();
+ $row[] = $assertion->message;
+ $row[] = $assertion->message_group;
+ $row[] = basename($assertion->file);
+ $row[] = $assertion->line;
+ $row[] = $assertion->function;
+ $row[] = simpletest_result_status_image($assertion->status);
+
+ $class = 'simpletest-' . $assertion->status;
+ if ($assertion->message_group == 'Debug') {
+ $class = 'simpletest-debug';
+ }
+ $rows[] = array('data' => $row, 'class' => array($class));
+
+ $group_summary['#' . $assertion->status]++;
+ $form['result']['summary']['#' . $assertion->status]++;
+ }
+ $form['result']['results'][$group]['table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ );
+
+ // Set summary information.
+ $group_summary['#ok'] = $group_summary['#fail'] + $group_summary['#exception'] == 0;
+ $form['result']['results'][$group]['#collapsed'] = $group_summary['#ok'];
+
+ // Store test group (class) as for use in filter.
+ $filter[$group_summary['#ok'] ? 'pass' : 'fail'][] = $group;
+ }
+
+ // Overal summary status.
+ $form['result']['summary']['#ok'] = $form['result']['summary']['#fail'] + $form['result']['summary']['#exception'] == 0;
+
+ // Actions.
+ $form['#action'] = url('admin/config/development/testing/results/re-run');
+ $form['action'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Actions'),
+ '#attributes' => array('class' => array('container-inline')),
+ '#weight' => -11,
+ );
+
+ $form['action']['filter'] = array(
+ '#type' => 'select',
+ '#title' => 'Filter',
+ '#options' => array(
+ 'all' => t('All (@count)', array('@count' => count($filter['pass']) + count($filter['fail']))),
+ 'pass' => t('Pass (@count)', array('@count' => count($filter['pass']))),
+ 'fail' => t('Fail (@count)', array('@count' => count($filter['fail']))),
+ ),
+ );
+ $form['action']['filter']['#default_value'] = ($filter['fail'] ? 'fail' : 'all');
+
+ // Categorized test classes for to be used with selected filter value.
+ $form['action']['filter_pass'] = array(
+ '#type' => 'hidden',
+ '#default_value' => implode(',', $filter['pass']),
+ );
+ $form['action']['filter_fail'] = array(
+ '#type' => 'hidden',
+ '#default_value' => implode(',', $filter['fail']),
+ );
+
+ $form['action']['op'] = array(
+ '#type' => 'submit',
+ '#value' => t('Run tests'),
+ );
+
+ $form['action']['return'] = array(
+ '#type' => 'link',
+ '#title' => t('Return to list'),
+ '#href' => 'admin/config/development/testing',
+ );
+
+ if (is_numeric($test_id)) {
+ simpletest_clean_results_table($test_id);
+ }
+
+ return $form;
+}
+
+/**
+ * Re-run the tests that match the filter.
+ */
+function simpletest_result_form_submit($form, &$form_state) {
+ $pass = $form_state['values']['filter_pass'] ? explode(',', $form_state['values']['filter_pass']) : array();
+ $fail = $form_state['values']['filter_fail'] ? explode(',', $form_state['values']['filter_fail']) : array();
+
+ if ($form_state['values']['filter'] == 'all') {
+ $classes = array_merge($pass, $fail);
+ }
+ elseif ($form_state['values']['filter'] == 'pass') {
+ $classes = $pass;
+ }
+ else {
+ $classes = $fail;
+ }
+
+ if (!$classes) {
+ $form_state['redirect'] = 'admin/config/development/testing';
+ return;
+ }
+
+ $form_state_execute = array('values' => array());
+ foreach ($classes as $class) {
+ $form_state_execute['values'][$class] = 1;
+ }
+
+ simpletest_test_form_submit(array(), $form_state_execute);
+ $form_state['redirect'] = $form_state_execute['redirect'];
+}
+
+/**
+ * Returns HTML for the summary status of a simpletest result.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_simpletest_result_summary($variables) {
+ $form = $variables['form'];
+ return '<div class="simpletest-' . ($form['#ok'] ? 'pass' : 'fail') . '">' . _simpletest_format_summary_line($form) . '</div>';
+}
+
+/**
+ * Get test results for $test_id.
+ *
+ * @param $test_id The test_id to retrieve results of.
+ * @return Array of results grouped by test_class.
+ */
+function simpletest_result_get($test_id) {
+ $results = db_select('simpletest')
+ ->fields('simpletest')
+ ->condition('test_id', $test_id)
+ ->orderBy('test_class')
+ ->orderBy('message_id')
+ ->execute();
+
+ $test_results = array();
+ foreach ($results as $result) {
+ if (!isset($test_results[$result->test_class])) {
+ $test_results[$result->test_class] = array();
+ }
+ $test_results[$result->test_class][] = $result;
+ }
+ return $test_results;
+}
+
+/**
+ * Get the appropriate image for the status.
+ *
+ * @param $status Status string, either: pass, fail, exception.
+ * @return HTML image or false.
+ */
+function simpletest_result_status_image($status) {
+ // $map does not use drupal_static() as its value never changes.
+ static $map;
+
+ if (!isset($map)) {
+ $map = array(
+ 'pass' => theme('image', array('path' => 'core/misc/watchdog-ok.png', 'width' => 18, 'height' => 18, 'alt' => t('Pass'))),
+ 'fail' => theme('image', array('path' => 'core/misc/watchdog-error.png', 'width' => 18, 'height' => 18, 'alt' => t('Fail'))),
+ 'exception' => theme('image', array('path' => 'core/misc/watchdog-warning.png', 'width' => 18, 'height' => 18, 'alt' => t('Exception'))),
+ 'debug' => theme('image', array('path' => 'core/misc/watchdog-warning.png', 'width' => 18, 'height' => 18, 'alt' => t('Debug'))),
+ );
+ }
+ if (isset($map[$status])) {
+ return $map[$status];
+ }
+ return FALSE;
+}
+
+/**
+ * Provides settings form for SimpleTest variables.
+ *
+ * @ingroup forms
+ * @see simpletest_settings_form_validate()
+ */
+function simpletest_settings_form($form, &$form_state) {
+ $form['general'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('General'),
+ );
+ $form['general']['simpletest_clear_results'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Clear results after each complete test suite run'),
+ '#description' => t('By default SimpleTest will clear the results after they have been viewed on the results page, but in some cases it may be useful to leave the results in the database. The results can then be viewed at <em>admin/config/development/testing/[test_id]</em>. The test ID can be found in the database, simpletest table, or kept track of when viewing the results the first time. Additionally, some modules may provide more analysis or features that require this setting to be disabled.'),
+ '#default_value' => variable_get('simpletest_clear_results', TRUE),
+ );
+ $form['general']['simpletest_verbose'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Provide verbose information when running tests'),
+ '#description' => t('The verbose data will be printed along with the standard assertions and is useful for debugging. The verbose data will be erased between each test suite run. The verbose data output is very detailed and should only be used when debugging.'),
+ '#default_value' => variable_get('simpletest_verbose', TRUE),
+ );
+
+ $form['httpauth'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('HTTP authentication'),
+ '#description' => t('HTTP auth settings to be used by the SimpleTest browser during testing. Useful when the site requires basic HTTP authentication.'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ );
+ $form['httpauth']['simpletest_httpauth_method'] = array(
+ '#type' => 'select',
+ '#title' => t('Method'),
+ '#options' => array(
+ CURLAUTH_BASIC => t('Basic'),
+ CURLAUTH_DIGEST => t('Digest'),
+ CURLAUTH_GSSNEGOTIATE => t('GSS negotiate'),
+ CURLAUTH_NTLM => t('NTLM'),
+ CURLAUTH_ANY => t('Any'),
+ CURLAUTH_ANYSAFE => t('Any safe'),
+ ),
+ '#default_value' => variable_get('simpletest_httpauth_method', CURLAUTH_BASIC),
+ );
+ $username = variable_get('simpletest_httpauth_username');
+ $password = variable_get('simpletest_httpauth_password');
+ $form['httpauth']['simpletest_httpauth_username'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Username'),
+ '#default_value' => $username,
+ );
+ if ($username && $password) {
+ $form['httpauth']['simpletest_httpauth_username']['#description'] = t('Leave this blank to delete both the existing username and password.');
+ }
+ $form['httpauth']['simpletest_httpauth_password'] = array(
+ '#type' => 'password',
+ '#title' => t('Password'),
+ );
+ if ($password) {
+ $form['httpauth']['simpletest_httpauth_password']['#description'] = t('To change the password, enter the new password here.');
+ }
+
+ return system_settings_form($form);
+}
+
+/**
+ * Validation handler for simpletest_settings_form().
+ */
+function simpletest_settings_form_validate($form, &$form_state) {
+ // If a username was provided but a password wasn't, preserve the existing
+ // password.
+ if (!empty($form_state['values']['simpletest_httpauth_username']) && empty($form_state['values']['simpletest_httpauth_password'])) {
+ $form_state['values']['simpletest_httpauth_password'] = variable_get('simpletest_httpauth_password', '');
+ }
+
+ // If a password was provided but a username wasn't, the credentials are
+ // incorrect, so throw an error.
+ if (empty($form_state['values']['simpletest_httpauth_username']) && !empty($form_state['values']['simpletest_httpauth_password'])) {
+ form_set_error('simpletest_httpauth_username', t('HTTP authentication credentials must include a username in addition to a password.'));
+ }
+}
+
diff --git a/core/modules/simpletest/simpletest.test b/core/modules/simpletest/simpletest.test
new file mode 100644
index 000000000000..7a02aa1e752c
--- /dev/null
+++ b/core/modules/simpletest/simpletest.test
@@ -0,0 +1,505 @@
+<?php
+
+/**
+ * @file
+ * Tests for simpletest.module.
+ */
+
+class SimpleTestFunctionalTest extends DrupalWebTestCase {
+ /**
+ * The results array that has been parsed by getTestResults().
+ */
+ protected $childTestResults;
+
+ /**
+ * Store the test ID from each test run for comparison, to ensure they are
+ * incrementing.
+ */
+ protected $test_ids = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'SimpleTest functionality',
+ 'description' => 'Test SimpleTest\'s web interface: check that the intended tests were
+ run and ensure that test reports display the intended results. Also
+ test SimpleTest\'s internal browser and API\'s both explicitly and
+ implicitly.',
+ 'group' => 'SimpleTest'
+ );
+ }
+
+ function setUp() {
+ if (!$this->inCURL()) {
+ parent::setUp('simpletest');
+
+ // Create and login user
+ $admin_user = $this->drupalCreateUser(array('administer unit tests'));
+ $this->drupalLogin($admin_user);
+ }
+ else {
+ parent::setUp('non_existent_module');
+ }
+ }
+
+ /**
+ * Test the internal browsers functionality.
+ */
+ function testInternalBrowser() {
+ global $conf;
+ if (!$this->inCURL()) {
+ $this->drupalGet('node');
+ $this->assertTrue($this->drupalGetHeader('Date'), t('An HTTP header was received.'));
+ $this->assertTitle(t('Welcome to @site-name | @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), t('Site title matches.'));
+ $this->assertNoTitle('Foo', t('Site title does not match.'));
+ // Make sure that we are locked out of the installer when prefixing
+ // using the user-agent header. This is an important security check.
+ global $base_url;
+
+ $this->drupalGet($base_url . '/core/install.php', array('external' => TRUE));
+ $this->assertResponse(403, 'Cannot access install.php with a "simpletest" user-agent header.');
+
+ $user = $this->drupalCreateUser();
+ $this->drupalLogin($user);
+ $headers = $this->drupalGetHeaders(TRUE);
+ $this->assertEqual(count($headers), 2, t('There was one intermediate request.'));
+ $this->assertTrue(strpos($headers[0][':status'], '302') !== FALSE, t('Intermediate response code was 302.'));
+ $this->assertFalse(empty($headers[0]['location']), t('Intermediate request contained a Location header.'));
+ $this->assertEqual($this->getUrl(), $headers[0]['location'], t('HTTP redirect was followed'));
+ $this->assertFalse($this->drupalGetHeader('Location'), t('Headers from intermediate request were reset.'));
+ $this->assertResponse(200, t('Response code from intermediate request was reset.'));
+
+ // Test the maximum redirection option.
+ $this->drupalLogout();
+ $edit = array(
+ 'name' => $user->name,
+ 'pass' => $user->pass_raw
+ );
+ variable_set('simpletest_maximum_redirects', 1);
+ $this->drupalPost('user?destination=user/logout', $edit, t('Log in'));
+ $headers = $this->drupalGetHeaders(TRUE);
+ $this->assertEqual(count($headers), 2, t('Simpletest stopped following redirects after the first one.'));
+ }
+ }
+
+ /**
+ * Test validation of the User-Agent header we use to perform test requests.
+ */
+ function testUserAgentValidation() {
+ if (!$this->inCURL()) {
+ global $base_url;
+ $simpletest_path = $base_url . '/' . drupal_get_path('module', 'simpletest');
+ $HTTP_path = $simpletest_path .'/tests/http.php?q=node';
+ $https_path = $simpletest_path .'/tests/https.php?q=node';
+ // Generate a valid simpletest User-Agent to pass validation.
+ $this->assertTrue(preg_match('/simpletest\d+/', $this->databasePrefix, $matches), t('Database prefix contains simpletest prefix.'));
+ $test_ua = drupal_generate_test_ua($matches[0]);
+ $this->additionalCurlOptions = array(CURLOPT_USERAGENT => $test_ua);
+
+ // Test pages only available for testing.
+ $this->drupalGet($HTTP_path);
+ $this->assertResponse(200, t('Requesting http.php with a legitimate simpletest User-Agent returns OK.'));
+ $this->drupalGet($https_path);
+ $this->assertResponse(200, t('Requesting https.php with a legitimate simpletest User-Agent returns OK.'));
+
+ // Now slightly modify the HMAC on the header, which should not validate.
+ $this->additionalCurlOptions = array(CURLOPT_USERAGENT => $test_ua . 'X');
+ $this->drupalGet($HTTP_path);
+ $this->assertResponse(403, t('Requesting http.php with a bad simpletest User-Agent fails.'));
+ $this->drupalGet($https_path);
+ $this->assertResponse(403, t('Requesting https.php with a bad simpletest User-Agent fails.'));
+
+ // Use a real User-Agent and verify that the special files http.php and
+ // https.php can't be accessed.
+ $this->additionalCurlOptions = array(CURLOPT_USERAGENT => 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.6; en-US; rv:1.9.2.12) Gecko/20101026 Firefox/3.6.12');
+ $this->drupalGet($HTTP_path);
+ $this->assertResponse(403, t('Requesting http.php with a normal User-Agent fails.'));
+ $this->drupalGet($https_path);
+ $this->assertResponse(403, t('Requesting https.php with a normal User-Agent fails.'));
+ }
+ }
+
+ /**
+ * Make sure that tests selected through the web interface are run and
+ * that the results are displayed correctly.
+ */
+ function testWebTestRunner() {
+ $this->pass = t('SimpleTest pass.');
+ $this->fail = t('SimpleTest fail.');
+ $this->valid_permission = 'access content';
+ $this->invalid_permission = 'invalid permission';
+
+ if ($this->inCURL()) {
+ // Only run following code if this test is running itself through a CURL request.
+ $this->stubTest();
+ }
+ else {
+
+ // Run twice so test_ids can be accumulated.
+ for ($i = 0; $i < 2; $i++) {
+ // Run this test from web interface.
+ $this->drupalGet('admin/config/development/testing');
+
+ $edit = array();
+ $edit['SimpleTestFunctionalTest'] = TRUE;
+ $this->drupalPost(NULL, $edit, t('Run tests'));
+
+ // Parse results and confirm that they are correct.
+ $this->getTestResults();
+ $this->confirmStubTestResults();
+ }
+
+ // Regression test for #290316.
+ // Check that test_id is incrementing.
+ $this->assertTrue($this->test_ids[0] != $this->test_ids[1], t('Test ID is incrementing.'));
+ }
+ }
+
+ /**
+ * Test to be run and the results confirmed.
+ */
+ function stubTest() {
+ $this->pass($this->pass);
+ $this->fail($this->fail);
+
+ $this->drupalCreateUser(array($this->valid_permission));
+ $this->drupalCreateUser(array($this->invalid_permission));
+
+ $this->pass(t('Test ID is @id.', array('@id' => $this->testId)));
+
+ // Generates a warning.
+ $i = 1 / 0;
+
+ // Call an assert function specific to that class.
+ $this->assertNothing();
+
+ // Generates a warning inside a PHP function.
+ array_key_exists(NULL, NULL);
+
+ debug('Foo', 'Debug');
+ }
+
+ /**
+ * Assert nothing.
+ */
+ function assertNothing() {
+ $this->pass("This is nothing.");
+ }
+
+ /**
+ * Confirm that the stub test produced the desired results.
+ */
+ function confirmStubTestResults() {
+ $this->assertAssertion(t('Enabled modules: %modules', array('%modules' => 'non_existent_module')), 'Other', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->setUp()');
+
+ $this->assertAssertion($this->pass, 'Other', 'Pass', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+ $this->assertAssertion($this->fail, 'Other', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ $this->assertAssertion(t('Created permissions: @perms', array('@perms' => $this->valid_permission)), 'Role', 'Pass', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+ $this->assertAssertion(t('Invalid permission %permission.', array('%permission' => $this->invalid_permission)), 'Role', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ // Check that a warning is caught by simpletest.
+ $this->assertAssertion('Division by zero', 'Warning', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ // Check that the backtracing code works for specific assert function.
+ $this->assertAssertion('This is nothing.', 'Other', 'Pass', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ // Check that errors that occur inside PHP internal functions are correctly reported.
+ // The exact error message differs between PHP versions so we check only
+ // the function name 'array_key_exists'.
+ $this->assertAssertion('array_key_exists', 'Warning', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ $this->assertAssertion("Debug: 'Foo'", 'Debug', 'Fail', 'simpletest.test', 'SimpleTestFunctionalTest->stubTest()');
+
+ $this->assertEqual('6 passes, 5 fails, 2 exceptions, and 1 debug message', $this->childTestResults['summary'], 'Stub test summary is correct');
+
+ $this->test_ids[] = $test_id = $this->getTestIdFromResults();
+ $this->assertTrue($test_id, t('Found test ID in results.'));
+ }
+
+ /**
+ * Fetch the test id from the test results.
+ */
+ function getTestIdFromResults() {
+ foreach ($this->childTestResults['assertions'] as $assertion) {
+ if (preg_match('@^Test ID is ([0-9]*)\.$@', $assertion['message'], $matches)) {
+ return $matches[1];
+ }
+ }
+ return NULL;
+ }
+
+ /**
+ * Assert that an assertion with the specified values is displayed
+ * in the test results.
+ *
+ * @param string $message Assertion message.
+ * @param string $type Assertion type.
+ * @param string $status Assertion status.
+ * @param string $file File where the assertion originated.
+ * @param string $functuion Function where the assertion originated.
+ * @return Assertion result.
+ */
+ function assertAssertion($message, $type, $status, $file, $function) {
+ $message = trim(strip_tags($message));
+ $found = FALSE;
+ foreach ($this->childTestResults['assertions'] as $assertion) {
+ if ((strpos($assertion['message'], $message) !== FALSE) &&
+ $assertion['type'] == $type &&
+ $assertion['status'] == $status &&
+ $assertion['file'] == $file &&
+ $assertion['function'] == $function) {
+ $found = TRUE;
+ break;
+ }
+ }
+ return $this->assertTrue($found, t('Found assertion {"@message", "@type", "@status", "@file", "@function"}.', array('@message' => $message, '@type' => $type, '@status' => $status, "@file" => $file, "@function" => $function)));
+ }
+
+ /**
+ * Get the results from a test and store them in the class array $results.
+ */
+ function getTestResults() {
+ $results = array();
+ if ($this->parse()) {
+ if ($fieldset = $this->getResultFieldSet()) {
+ // Code assumes this is the only test in group.
+ $results['summary'] = $this->asText($fieldset->div->div[1]);
+ $results['name'] = $this->asText($fieldset->legend);
+
+ $results['assertions'] = array();
+ $tbody = $fieldset->div->table->tbody;
+ foreach ($tbody->tr as $row) {
+ $assertion = array();
+ $assertion['message'] = $this->asText($row->td[0]);
+ $assertion['type'] = $this->asText($row->td[1]);
+ $assertion['file'] = $this->asText($row->td[2]);
+ $assertion['line'] = $this->asText($row->td[3]);
+ $assertion['function'] = $this->asText($row->td[4]);
+ $ok_url = file_create_url('core/misc/watchdog-ok.png');
+ $assertion['status'] = ($row->td[5]->img['src'] == $ok_url) ? 'Pass' : 'Fail';
+ $results['assertions'][] = $assertion;
+ }
+ }
+ }
+ $this->childTestResults = $results;
+ }
+
+ /**
+ * Get the fieldset containing the results for group this test is in.
+ */
+ function getResultFieldSet() {
+ $fieldsets = $this->xpath('//fieldset');
+ $info = $this->getInfo();
+ foreach ($fieldsets as $fieldset) {
+ if ($this->asText($fieldset->legend) == $info['name']) {
+ return $fieldset;
+ }
+ }
+ return FALSE;
+ }
+
+ /**
+ * Extract the text contained by the element.
+ *
+ * @param $element
+ * Element to extract text from.
+ * @return
+ * Extracted text.
+ */
+ function asText(SimpleXMLElement $element) {
+ if (!is_object($element)) {
+ return $this->fail('The element is not an element.');
+ }
+ return trim(html_entity_decode(strip_tags($element->asXML())));
+ }
+
+ /**
+ * Check if the test is being run from inside a CURL request.
+ */
+ function inCURL() {
+ return (bool) drupal_valid_test_ua();
+ }
+}
+
+/**
+ * Test internal testing framework browser.
+ */
+class SimpleTestBrowserTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'SimpleTest browser',
+ 'description' => 'Test the internal browser of the testing framework.',
+ 'group' => 'SimpleTest',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ }
+
+ /**
+ * Test DrupalWebTestCase::getAbsoluteUrl().
+ */
+ function testGetAbsoluteUrl() {
+ // Testbed runs with Clean URLs disabled, so disable it here.
+ variable_set('clean_url', 0);
+ $url = 'user/login';
+
+ $this->drupalGet($url);
+ $absolute = url($url, array('absolute' => TRUE));
+ $this->assertEqual($absolute, $this->url, t('Passed and requested URL are equal.'));
+ $this->assertEqual($this->url, $this->getAbsoluteUrl($this->url), t('Requested and returned absolute URL are equal.'));
+
+ $this->drupalPost(NULL, array(), t('Log in'));
+ $this->assertEqual($absolute, $this->url, t('Passed and requested URL are equal.'));
+ $this->assertEqual($this->url, $this->getAbsoluteUrl($this->url), t('Requested and returned absolute URL are equal.'));
+
+ $this->clickLink('Create new account');
+ $url = 'user/register';
+ $absolute = url($url, array('absolute' => TRUE));
+ $this->assertEqual($absolute, $this->url, t('Passed and requested URL are equal.'));
+ $this->assertEqual($this->url, $this->getAbsoluteUrl($this->url), t('Requested and returned absolute URL are equal.'));
+ }
+
+ /**
+ * Tests XPath escaping.
+ */
+ function testXPathEscaping() {
+ $testpage = <<< EOF
+<html>
+<body>
+<a href="link1">A "weird" link, just to bother the dumb "XPath 1.0"</a>
+<a href="link2">A second "even more weird" link, in memory of George O'Malley</a>
+</body>
+</html>
+EOF;
+ $this->drupalSetContent($testpage);
+
+ // Matches the first link.
+ $urls = $this->xpath('//a[text()=:text]', array(':text' => 'A "weird" link, just to bother the dumb "XPath 1.0"'));
+ $this->assertEqual($urls[0]['href'], 'link1', 'Match with quotes.');
+
+ $urls = $this->xpath('//a[text()=:text]', array(':text' => 'A second "even more weird" link, in memory of George O\'Malley'));
+ $this->assertEqual($urls[0]['href'], 'link2', 'Match with mixed single and double quotes.');
+ }
+}
+
+class SimpleTestMailCaptureTestCase extends DrupalWebTestCase {
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => 'SimpleTest e-mail capturing',
+ 'description' => 'Test the SimpleTest e-mail capturing logic, the assertMail assertion and the drupalGetMails function.',
+ 'group' => 'SimpleTest',
+ );
+ }
+
+ /**
+ * Test to see if the wrapper function is executed correctly.
+ */
+ function testMailSend() {
+ // Create an e-mail.
+ $subject = $this->randomString(64);
+ $body = $this->randomString(128);
+ $message = array(
+ 'id' => 'drupal_mail_test',
+ 'headers' => array('Content-type'=> 'text/html'),
+ 'subject' => $subject,
+ 'to' => 'foobar@example.com',
+ 'body' => $body,
+ );
+
+ // Before we send the e-mail, drupalGetMails should return an empty array.
+ $captured_emails = $this->drupalGetMails();
+ $this->assertEqual(count($captured_emails), 0, t('The captured e-mails queue is empty.'), t('E-mail'));
+
+ // Send the e-mail.
+ $response = drupal_mail_system('simpletest', 'drupal_mail_test')->mail($message);
+
+ // Ensure that there is one e-mail in the captured e-mails array.
+ $captured_emails = $this->drupalGetMails();
+ $this->assertEqual(count($captured_emails), 1, t('One e-mail was captured.'), t('E-mail'));
+
+ // Assert that the e-mail was sent by iterating over the message properties
+ // and ensuring that they are captured intact.
+ foreach ($message as $field => $value) {
+ $this->assertMail($field, $value, t('The e-mail was sent and the value for property @field is intact.', array('@field' => $field)), t('E-mail'));
+ }
+
+ // Send additional e-mails so more than one e-mail is captured.
+ for ($index = 0; $index < 5; $index++) {
+ $message = array(
+ 'id' => 'drupal_mail_test_' . $index,
+ 'headers' => array('Content-type'=> 'text/html'),
+ 'subject' => $this->randomString(64),
+ 'to' => $this->randomName(32) . '@example.com',
+ 'body' => $this->randomString(512),
+ );
+ drupal_mail_system('drupal_mail_test', $index)->mail($message);
+ }
+
+ // There should now be 6 e-mails captured.
+ $captured_emails = $this->drupalGetMails();
+ $this->assertEqual(count($captured_emails), 6, t('All e-mails were captured.'), t('E-mail'));
+
+ // Test different ways of getting filtered e-mails via drupalGetMails().
+ $captured_emails = $this->drupalGetMails(array('id' => 'drupal_mail_test'));
+ $this->assertEqual(count($captured_emails), 1, t('Only one e-mail is returned when filtering by id.'), t('E-mail'));
+ $captured_emails = $this->drupalGetMails(array('id' => 'drupal_mail_test', 'subject' => $subject));
+ $this->assertEqual(count($captured_emails), 1, t('Only one e-mail is returned when filtering by id and subject.'), t('E-mail'));
+ $captured_emails = $this->drupalGetMails(array('id' => 'drupal_mail_test', 'subject' => $subject, 'from' => 'this_was_not_used@example.com'));
+ $this->assertEqual(count($captured_emails), 0, t('No e-mails are returned when querying with an unused from address.'), t('E-mail'));
+
+ // Send the last e-mail again, so we can confirm that the drupalGetMails-filter
+ // correctly returns all e-mails with a given property/value.
+ drupal_mail_system('drupal_mail_test', $index)->mail($message);
+ $captured_emails = $this->drupalGetMails(array('id' => 'drupal_mail_test_4'));
+ $this->assertEqual(count($captured_emails), 2, t('All e-mails with the same id are returned when filtering by id.'), t('E-mail'));
+ }
+}
+
+/**
+ * Test Folder creation
+ */
+class SimpleTestFolderTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Testing SimpleTest setUp',
+ 'description' => "This test will check SimpleTest's treatment of hook_install during setUp. Image module is used for test.",
+ 'group' => 'SimpleTest',
+ );
+ }
+
+ function setUp() {
+ return parent::setUp('image');
+ }
+
+ function testFolderSetup() {
+ $directory = file_default_scheme() . '://styles';
+ $this->assertTrue(file_prepare_directory($directory, FALSE), "Directory created.");
+ }
+}
+
+/**
+ * Test required modules for tests.
+ */
+class SimpleTestMissingDependentModuleUnitTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Testing dependent module test',
+ 'description' => 'This test should not load since it requires a module that is not found.',
+ 'group' => 'SimpleTest',
+ 'dependencies' => array('simpletest_missing_module'),
+ );
+ }
+
+ /**
+ * Ensure that this test will not be loaded despite its dependency.
+ */
+ function testFail() {
+ $this->fail(t('Running test with missing required module.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/actions.test b/core/modules/simpletest/tests/actions.test
new file mode 100644
index 000000000000..23587f0c54dd
--- /dev/null
+++ b/core/modules/simpletest/tests/actions.test
@@ -0,0 +1,126 @@
+<?php
+
+class ActionsConfigurationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Actions configuration',
+ 'description' => 'Tests complex actions configuration by adding, editing, and deleting a complex action.',
+ 'group' => 'Actions',
+ );
+ }
+
+ /**
+ * Test the configuration of advanced actions through the administration
+ * interface.
+ */
+ function testActionConfiguration() {
+ // Create a user with permission to view the actions administration pages.
+ $user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($user);
+
+ // Make a POST request to admin/config/system/actions/manage.
+ $edit = array();
+ $edit['action'] = drupal_hash_base64('system_goto_action');
+ $this->drupalPost('admin/config/system/actions/manage', $edit, t('Create'));
+
+ // Make a POST request to the individual action configuration page.
+ $edit = array();
+ $action_label = $this->randomName();
+ $edit['actions_label'] = $action_label;
+ $edit['url'] = 'admin';
+ $this->drupalPost('admin/config/system/actions/configure/' . drupal_hash_base64('system_goto_action'), $edit, t('Save'));
+
+ // Make sure that the new complex action was saved properly.
+ $this->assertText(t('The action has been successfully saved.'), t("Make sure we get a confirmation that we've successfully saved the complex action."));
+ $this->assertText($action_label, t("Make sure the action label appears on the configuration page after we've saved the complex action."));
+
+ // Make another POST request to the action edit page.
+ $this->clickLink(t('configure'));
+ preg_match('|admin/config/system/actions/configure/(\d+)|', $this->getUrl(), $matches);
+ $aid = $matches[1];
+ $edit = array();
+ $new_action_label = $this->randomName();
+ $edit['actions_label'] = $new_action_label;
+ $edit['url'] = 'admin';
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Make sure that the action updated properly.
+ $this->assertText(t('The action has been successfully saved.'), t("Make sure we get a confirmation that we've successfully updated the complex action."));
+ $this->assertNoText($action_label, t("Make sure the old action label does NOT appear on the configuration page after we've updated the complex action."));
+ $this->assertText($new_action_label, t("Make sure the action label appears on the configuration page after we've updated the complex action."));
+
+ // Make sure that deletions work properly.
+ $this->clickLink(t('delete'));
+ $edit = array();
+ $this->drupalPost("admin/config/system/actions/delete/$aid", $edit, t('Delete'));
+
+ // Make sure that the action was actually deleted.
+ $this->assertRaw(t('Action %action was deleted', array('%action' => $new_action_label)), t('Make sure that we get a delete confirmation message.'));
+ $this->drupalGet('admin/config/system/actions/manage');
+ $this->assertNoText($new_action_label, t("Make sure the action label does not appear on the overview page after we've deleted the action."));
+ $exists = db_query('SELECT aid FROM {actions} WHERE callback = :callback', array(':callback' => 'drupal_goto_action'))->fetchField();
+ $this->assertFalse($exists, t('Make sure the action is gone from the database after being deleted.'));
+ }
+}
+
+/**
+ * Test actions executing in a potential loop, and make sure they abort properly.
+ */
+class ActionLoopTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Actions executing in a potentially infinite loop',
+ 'description' => 'Tests actions executing in a loop, and makes sure they abort properly.',
+ 'group' => 'Actions',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('dblog', 'trigger', 'actions_loop_test');
+ }
+
+ /**
+ * Set up a loop with 3 - 12 recursions, and see if it aborts properly.
+ */
+ function testActionLoop() {
+ $user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($user);
+
+ $hash = drupal_hash_base64('actions_loop_test_log');
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/actions_loop_test', $edit, t('Assign'));
+
+ // Delete any existing watchdog messages to clear the plethora of
+ // "Action added" messages from when Drupal was installed.
+ db_delete('watchdog')->execute();
+ // To prevent this test from failing when xdebug is enabled, the maximum
+ // recursion level should be kept low enough to prevent the xdebug
+ // infinite recursion protection mechanism from aborting the request.
+ // See http://drupal.org/node/587634.
+ variable_set('actions_max_stack', mt_rand(3, 12));
+ $this->triggerActions();
+ }
+
+ /**
+ * Create an infinite loop by causing a watchdog message to be set,
+ * which causes the actions to be triggered again, up to actions_max_stack
+ * times.
+ */
+ protected function triggerActions() {
+ $this->drupalGet('<front>', array('query' => array('trigger_actions_on_watchdog' => TRUE)));
+ $expected = array();
+ $expected[] = 'Triggering action loop';
+ for ($i = 1; $i <= variable_get('actions_max_stack', 35); $i++) {
+ $expected[] = "Test log #$i";
+ }
+ $expected[] = 'Stack overflow: too many calls to actions_do(). Aborting to prevent infinite recursion.';
+
+ $result = db_query("SELECT message FROM {watchdog} WHERE type = 'actions_loop_test' OR type = 'actions' ORDER BY wid");
+ $loop_started = FALSE;
+ foreach ($result as $row) {
+ $expected_message = array_shift($expected);
+ $this->assertEqual($row->message, $expected_message, t('Expected message %expected, got %message.', array('%expected' => $expected_message, '%message' => $row->message)));
+ }
+ $this->assertTrue(empty($expected), t('All expected messages found.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/actions_loop_test.info b/core/modules/simpletest/tests/actions_loop_test.info
new file mode 100644
index 000000000000..35075114c4ba
--- /dev/null
+++ b/core/modules/simpletest/tests/actions_loop_test.info
@@ -0,0 +1,6 @@
+name = Actions loop test
+description = Support module for action loop testing.
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/actions_loop_test.install b/core/modules/simpletest/tests/actions_loop_test.install
new file mode 100644
index 000000000000..b22fd85ab9d6
--- /dev/null
+++ b/core/modules/simpletest/tests/actions_loop_test.install
@@ -0,0 +1,11 @@
+<?php
+
+/**
+ * Implements hook_install().
+ */
+function actions_loop_test_install() {
+ db_update('system')
+ ->fields(array('weight' => 1))
+ ->condition('name', 'actions_loop_test')
+ ->execute();
+}
diff --git a/core/modules/simpletest/tests/actions_loop_test.module b/core/modules/simpletest/tests/actions_loop_test.module
new file mode 100644
index 000000000000..77764907b9e6
--- /dev/null
+++ b/core/modules/simpletest/tests/actions_loop_test.module
@@ -0,0 +1,94 @@
+<?php
+
+/**
+ * Implements hook_trigger_info().
+ */
+function actions_loop_test_trigger_info() {
+ return array(
+ 'actions_loop_test' => array(
+ 'watchdog' => array(
+ 'label' => t('When a message is logged'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_watchdog().
+ */
+function actions_loop_test_watchdog(array $log_entry) {
+ // If the triggering actions are not explicitly enabled, abort.
+ if (empty($_GET['trigger_actions_on_watchdog'])) {
+ return;
+ }
+ // Get all the action ids assigned to the trigger on the watchdog hook's
+ // "run" event.
+ $aids = trigger_get_assigned_actions('watchdog');
+ // We can pass in any applicable information in $context. There isn't much in
+ // this case, but we'll pass in the hook name as the bare minimum.
+ $context = array(
+ 'hook' => 'watchdog',
+ );
+ // Fire the actions on the associated object ($log_entry) and the context
+ // variable.
+ actions_do(array_keys($aids), $log_entry, $context);
+}
+
+/**
+ * Implements hook_init().
+ */
+function actions_loop_test_init() {
+ if (!empty($_GET['trigger_actions_on_watchdog'])) {
+ watchdog_skip_semaphore('actions_loop_test', 'Triggering action loop');
+ }
+}
+
+/**
+ * Implements hook_action_info().
+ */
+function actions_loop_test_action_info() {
+ return array(
+ 'actions_loop_test_log' => array(
+ 'label' => t('Write a message to the log.'),
+ 'type' => 'system',
+ 'configurable' => FALSE,
+ 'triggers' => array('any'),
+ ),
+ );
+}
+
+/**
+ * Write a message to the log.
+ */
+function actions_loop_test_log() {
+ $count = &drupal_static(__FUNCTION__, 0);
+ $count++;
+ watchdog_skip_semaphore('actions_loop_test', "Test log #$count");
+}
+
+/**
+ * Replacement of the watchdog() function that eliminates the use of semaphores
+ * so that we can test the abortion of an action loop.
+ */
+function watchdog_skip_semaphore($type, $message, $variables = array(), $severity = WATCHDOG_NOTICE, $link = NULL) {
+ global $user, $base_root;
+
+ // Prepare the fields to be logged
+ $log_entry = array(
+ 'type' => $type,
+ 'message' => $message,
+ 'variables' => $variables,
+ 'severity' => $severity,
+ 'link' => $link,
+ 'user' => $user,
+ 'request_uri' => $base_root . request_uri(),
+ 'referer' => $_SERVER['HTTP_REFERER'],
+ 'ip' => ip_address(),
+ 'timestamp' => REQUEST_TIME,
+ );
+
+ // Call the logging hooks to log/process the message
+ foreach (module_implements('watchdog') as $module) {
+ module_invoke($module, 'watchdog', $log_entry);
+ }
+}
diff --git a/core/modules/simpletest/tests/ajax.test b/core/modules/simpletest/tests/ajax.test
new file mode 100644
index 000000000000..9a76b9692a93
--- /dev/null
+++ b/core/modules/simpletest/tests/ajax.test
@@ -0,0 +1,488 @@
+<?php
+
+class AJAXTestCase extends DrupalWebTestCase {
+ function setUp() {
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ parent::setUp(array_unique(array_merge(array('ajax_test', 'ajax_forms_test'), $modules)));
+ }
+
+ /**
+ * Assert that a command with the required properties exists within the array of Ajax commands returned by the server.
+ *
+ * The Ajax framework, via the ajax_deliver() and ajax_render() functions,
+ * returns an array of commands. This array sometimes includes commands
+ * automatically provided by the framework in addition to commands returned by
+ * a particular page callback. During testing, we're usually interested that a
+ * particular command is present, and don't care whether other commands
+ * precede or follow the one we're interested in. Additionally, the command
+ * we're interested in may include additional data that we're not interested
+ * in. Therefore, this function simply asserts that one of the commands in
+ * $haystack contains all of the keys and values in $needle. Furthermore, if
+ * $needle contains a 'settings' key with an array value, we simply assert
+ * that all keys and values within that array are present in the command we're
+ * checking, and do not consider it a failure if the actual command contains
+ * additional settings that aren't part of $needle.
+ *
+ * @param $haystack
+ * An array of Ajax commands returned by the server.
+ * @param $needle
+ * Array of info we're expecting in one of those commands.
+ * @param $message
+ * An assertion message.
+ */
+ protected function assertCommand($haystack, $needle, $message) {
+ $found = FALSE;
+ foreach ($haystack as $command) {
+ // If the command has additional settings that we're not testing for, do
+ // not consider that a failure.
+ if (isset($command['settings']) && is_array($command['settings']) && isset($needle['settings']) && is_array($needle['settings'])) {
+ $command['settings'] = array_intersect_key($command['settings'], $needle['settings']);
+ }
+ // If the command has additional data that we're not testing for, do not
+ // consider that a failure. Also, == instead of ===, because we don't
+ // require the key/value pairs to be in any particular order
+ // (http://www.php.net/manual/en/language.operators.array.php).
+ if (array_intersect_key($command, $needle) == $needle) {
+ $found = TRUE;
+ break;
+ }
+ }
+ $this->assertTrue($found, $message);
+ }
+}
+
+/**
+ * Tests primary Ajax framework functions.
+ */
+class AJAXFrameworkTestCase extends AJAXTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'AJAX framework',
+ 'description' => 'Performs tests on AJAX framework functions.',
+ 'group' => 'AJAX',
+ );
+ }
+
+ /**
+ * Test that ajax_render() returns JavaScript settings generated during the page request.
+ *
+ * @todo Add tests to ensure that ajax_render() returns commands for new CSS
+ * and JavaScript files to be loaded by the page. See
+ * http://drupal.org/node/561858.
+ */
+ function testAJAXRender() {
+ $commands = $this->drupalGetAJAX('ajax-test/render');
+
+ // Verify that there is a command to load settings added with
+ // drupal_add_js().
+ $expected = array(
+ 'command' => 'settings',
+ 'settings' => array('basePath' => base_path(), 'ajax' => 'test'),
+ );
+ $this->assertCommand($commands, $expected, t('ajax_render() loads settings added with drupal_add_js().'));
+
+ // Verify that Ajax settings are loaded for #type 'link'.
+ $this->drupalGet('ajax-test/link');
+ $settings = $this->drupalGetSettings();
+ $this->assertEqual($settings['ajax']['ajax-link']['url'], url('filter/tips'));
+ $this->assertEqual($settings['ajax']['ajax-link']['wrapper'], 'block-system-main');
+ }
+
+ /**
+ * Test behavior of ajax_render_error().
+ */
+ function testAJAXRenderError() {
+ // Verify default error message.
+ $commands = $this->drupalGetAJAX('ajax-test/render-error');
+ $expected = array(
+ 'command' => 'alert',
+ 'text' => t('An error occurred while handling the request: The server received invalid input.'),
+ );
+ $this->assertCommand($commands, $expected, t('ajax_render_error() invokes alert command.'));
+
+ // Verify custom error message.
+ $edit = array(
+ 'message' => 'Custom error message.',
+ );
+ $commands = $this->drupalGetAJAX('ajax-test/render-error', array('query' => $edit));
+ $expected = array(
+ 'command' => 'alert',
+ 'text' => $edit['message'],
+ );
+ $this->assertCommand($commands, $expected, t('Custom error message is output.'));
+ }
+
+ /**
+ * Test that new JavaScript and CSS files added during an AJAX request are returned.
+ */
+ function testLazyLoad() {
+ $expected = array(
+ 'setting_name' => 'ajax_forms_test_lazy_load_form_submit',
+ 'setting_value' => 'executed',
+ 'css' => drupal_get_path('module', 'system') . '/system.admin.css',
+ 'js' => drupal_get_path('module', 'system') . '/system.js',
+ );
+
+ // Get the base page.
+ $this->drupalGet('ajax_forms_test_lazy_load_form');
+ $original_settings = $this->drupalGetSettings();
+ $original_css = $original_settings['ajaxPageState']['css'];
+ $original_js = $original_settings['ajaxPageState']['js'];
+
+ // Verify that the base page doesn't have the settings and files that are to
+ // be lazy loaded as part of the next request.
+ $this->assertTrue(!isset($original_settings[$expected['setting_name']]), t('Page originally lacks the %setting, as expected.', array('%setting' => $expected['setting_name'])));
+ $this->assertTrue(!isset($original_settings[$expected['css']]), t('Page originally lacks the %css file, as expected.', array('%css' => $expected['css'])));
+ $this->assertTrue(!isset($original_settings[$expected['js']]), t('Page originally lacks the %js file, as expected.', array('%js' => $expected['js'])));
+
+ // Submit the AJAX request.
+ $commands = $this->drupalPostAJAX(NULL, array(), array('op' => t('Submit')));
+ $new_settings = $this->drupalGetSettings();
+ $new_css = $new_settings['ajaxPageState']['css'];
+ $new_js = $new_settings['ajaxPageState']['js'];
+
+ // Verify the expected setting was added.
+ $this->assertIdentical($new_settings[$expected['setting_name']], $expected['setting_value'], t('Page now has the %setting.', array('%setting' => $expected['setting_name'])));
+
+ // Verify the expected CSS file was added, both to Drupal.settings, and as
+ // an AJAX command for inclusion into the HTML.
+ // @todo A drupal_css_defaults() function in Drupal 8 would be nice.
+ $expected_css_html = drupal_get_css(array($expected['css'] => array(
+ 'type' => 'file',
+ 'group' => CSS_DEFAULT,
+ 'weight' => 0,
+ 'every_page' => FALSE,
+ 'media' => 'all',
+ 'preprocess' => TRUE,
+ 'data' => $expected['css'],
+ 'browsers' => array('IE' => TRUE, '!IE' => TRUE),
+ )), TRUE);
+ $this->assertEqual($new_css, $original_css + array($expected['css'] => 1), t('Page state now has the %css file.', array('%css' => $expected['css'])));
+ $this->assertCommand($commands, array('data' => $expected_css_html), t('Page now has the %css file.', array('%css' => $expected['css'])));
+
+ // Verify the expected JS file was added, both to Drupal.settings, and as
+ // an AJAX command for inclusion into the HTML. By testing for an exact HTML
+ // string containing the SCRIPT tag, we also ensure that unexpected
+ // JavaScript code, such as a jQuery.extend() that would potentially clobber
+ // rather than properly merge settings, didn't accidentally get added.
+ $expected_js_html = drupal_get_js('header', array($expected['js'] => drupal_js_defaults($expected['js'])), TRUE);
+ $this->assertEqual($new_js, $original_js + array($expected['js'] => 1), t('Page state now has the %js file.', array('%js' => $expected['js'])));
+ $this->assertCommand($commands, array('data' => $expected_js_html), t('Page now has the %js file.', array('%js' => $expected['js'])));
+ }
+}
+
+/**
+ * Tests Ajax framework commands.
+ */
+class AJAXCommandsTestCase extends AJAXTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'AJAX commands',
+ 'description' => 'Performs tests on AJAX framework commands.',
+ 'group' => 'AJAX',
+ );
+ }
+
+ /**
+ * Test the various Ajax Commands.
+ */
+ function testAJAXCommands() {
+ $form_path = 'ajax_forms_test_ajax_commands_form';
+ $web_user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($web_user);
+
+ $edit = array();
+
+ // Tests the 'after' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'After': Click to put something after the div")));
+ $expected = array(
+ 'command' => 'insert',
+ 'method' => 'after',
+ 'data' => 'This will be placed after',
+ );
+ $this->assertCommand($commands, $expected, "'after' AJAX command issued with correct data");
+
+ // Tests the 'alert' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'Alert': Click to alert")));
+ $expected = array(
+ 'command' => 'alert',
+ 'text' => 'Alert',
+ );
+ $this->assertCommand($commands, $expected, "'alert' AJAX Command issued with correct text");
+
+ // Tests the 'append' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'Append': Click to append something")));
+ $expected = array(
+ 'command' => 'insert',
+ 'method' => 'append',
+ 'data' => 'Appended text',
+ );
+ $this->assertCommand($commands, $expected, "'append' AJAX command issued with correct data");
+
+ // Tests the 'before' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'before': Click to put something before the div")));
+ $expected = array(
+ 'command' => 'insert',
+ 'method' => 'before',
+ 'data' => 'Before text',
+ );
+ $this->assertCommand($commands, $expected, "'before' AJAX command issued with correct data");
+
+ // Tests the 'changed' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX changed: Click to mark div changed.")));
+ $expected = array(
+ 'command' => 'changed',
+ 'selector' => '#changed_div',
+ );
+ $this->assertCommand($commands, $expected, "'changed' AJAX command issued with correct selector");
+
+ // Tests the 'changed' command using the second argument.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX changed: Click to mark div changed with asterisk.")));
+ $expected = array(
+ 'command' => 'changed',
+ 'selector' => '#changed_div',
+ 'asterisk' => '#changed_div_mark_this',
+ );
+ $this->assertCommand($commands, $expected, "'changed' AJAX command (with asterisk) issued with correct selector");
+
+ // Tests the 'css' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("Set the the '#box' div to be blue.")));
+ $expected = array(
+ 'command' => 'css',
+ 'selector' => '#css_div',
+ 'argument' => array('background-color' => 'blue'),
+ );
+ $this->assertCommand($commands, $expected, "'css' AJAX command issued with correct selector");
+
+ // Tests the 'data' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX data command: Issue command.")));
+ $expected = array(
+ 'command' => 'data',
+ 'name' => 'testkey',
+ 'value' => 'testvalue',
+ );
+ $this->assertCommand($commands, $expected, "'data' AJAX command issued with correct key and value");
+
+ // Tests the 'invoke' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX invoke command: Invoke addClass() method.")));
+ $expected = array(
+ 'command' => 'invoke',
+ 'method' => 'addClass',
+ 'arguments' => array('error'),
+ );
+ $this->assertCommand($commands, $expected, "'invoke' AJAX command issued with correct method and argument");
+
+ // Tests the 'html' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX html: Replace the HTML in a selector.")));
+ $expected = array(
+ 'command' => 'insert',
+ 'method' => 'html',
+ 'data' => 'replacement text',
+ );
+ $this->assertCommand($commands, $expected, "'html' AJAX command issued with correct data");
+
+ // Tests the 'insert' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX insert: Let client insert based on #ajax['method'].")));
+ $expected = array(
+ 'command' => 'insert',
+ 'data' => 'insert replacement text',
+ );
+ $this->assertCommand($commands, $expected, "'insert' AJAX command issued with correct data");
+
+ // Tests the 'prepend' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'prepend': Click to prepend something")));
+ $expected = array(
+ 'command' => 'insert',
+ 'method' => 'prepend',
+ 'data' => 'prepended text',
+ );
+ $this->assertCommand($commands, $expected, "'prepend' AJAX command issued with correct data");
+
+ // Tests the 'remove' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'remove': Click to remove text")));
+ $expected = array(
+ 'command' => 'remove',
+ 'selector' => '#remove_text',
+ );
+ $this->assertCommand($commands, $expected, "'remove' AJAX command issued with correct command and selector");
+
+ // Tests the 'restripe' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'restripe' command")));
+ $expected = array(
+ 'command' => 'restripe',
+ 'selector' => '#restripe_table',
+ );
+ $this->assertCommand($commands, $expected, "'restripe' AJAX command issued with correct selector");
+
+ // Tests the 'settings' command.
+ $commands = $this->drupalPostAJAX($form_path, $edit, array('op' => t("AJAX 'settings' command")));
+ $expected = array(
+ 'command' => 'settings',
+ 'settings' => array('ajax_forms_test' => array('foo' => 42)),
+ );
+ $this->assertCommand($commands, $expected, "'settings' AJAX command issued with correct data");
+ }
+}
+
+/**
+ * Test that $form_state['values'] is properly delivered to $ajax['callback'].
+ */
+class AJAXFormValuesTestCase extends AJAXTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'AJAX command form values',
+ 'description' => 'Tests that form values are properly delivered to AJAX callbacks.',
+ 'group' => 'AJAX',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->web_user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Create a simple form, then POST to system/ajax to change to it.
+ */
+ function testSimpleAJAXFormValue() {
+ // Verify form values of a select element.
+ foreach (array('red', 'green', 'blue') as $item) {
+ $edit = array(
+ 'select' => $item,
+ );
+ $commands = $this->drupalPostAJAX('ajax_forms_test_get_form', $edit, 'select');
+ $expected = array(
+ 'command' => 'data',
+ 'value' => $item,
+ );
+ $this->assertCommand($commands, $expected, "verification of AJAX form values from a selectbox issued with a correct value");
+ }
+
+ // Verify form values of a checkbox element.
+ foreach (array(FALSE, TRUE) as $item) {
+ $edit = array(
+ 'checkbox' => $item,
+ );
+ $commands = $this->drupalPostAJAX('ajax_forms_test_get_form', $edit, 'checkbox');
+ $expected = array(
+ 'command' => 'data',
+ 'value' => (int) $item,
+ );
+ $this->assertCommand($commands, $expected, "verification of AJAX form values from a checkbox issued with a correct value");
+ }
+ }
+}
+
+/**
+ * Tests that Ajax-enabled forms work when multiple instances of the same form are on a page.
+ */
+class AJAXMultiFormTestCase extends AJAXTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'AJAX multi form',
+ 'description' => 'Tests that AJAX-enabled forms work when multiple instances of the same form are on a page.',
+ 'group' => 'AJAX',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('form_test'));
+
+ // Create a multi-valued field for 'page' nodes to use for Ajax testing.
+ $field_name = 'field_ajax_test';
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'text',
+ 'cardinality' => FIELD_CARDINALITY_UNLIMITED,
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'node',
+ 'bundle' => 'page',
+ );
+ field_create_instance($instance);
+
+ // Login a user who can create 'page' nodes.
+ $this->web_user = $this->drupalCreateUser(array('create page content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Test that a page with the 'page_node_form' included twice works correctly.
+ */
+ function testMultiForm() {
+ // HTML IDs for elements within the field are potentially modified with
+ // each Ajax submission, but these variables are stable and help target the
+ // desired elements.
+ $field_name = 'field_ajax_test';
+ $field_xpaths = array(
+ 'page-node-form' => '//form[@id="page-node-form"]//div[contains(@class, "field-name-field-ajax-test")]',
+ 'page-node-form--2' => '//form[@id="page-node-form--2"]//div[contains(@class, "field-name-field-ajax-test")]',
+ );
+ $button_name = $field_name . '_add_more';
+ $button_value = t('Add another item');
+ $button_xpath_suffix = '//input[@name="' . $button_name . '"]';
+ $field_items_xpath_suffix = '//input[@type="text"]';
+
+ // Ensure the initial page contains both node forms and the correct number
+ // of field items and "add more" button for the multi-valued field within
+ // each form.
+ $this->drupalGet('form-test/two-instances-of-same-form');
+ foreach ($field_xpaths as $form_html_id => $field_xpath) {
+ $this->assert(count($this->xpath($field_xpath . $field_items_xpath_suffix)) == 1, t('Found the correct number of field items on the initial page.'));
+ $this->assertFieldByXPath($field_xpath . $button_xpath_suffix, NULL, t('Found the "add more" button on the initial page.'));
+ }
+ $this->assertNoDuplicateIds(t('Initial page contains unique IDs'), 'Other');
+
+ // Submit the "add more" button of each form twice. After each corresponding
+ // page update, ensure the same as above.
+ foreach ($field_xpaths as $form_html_id => $field_xpath) {
+ for ($i = 0; $i < 2; $i++) {
+ $this->drupalPostAJAX(NULL, array(), array($button_name => $button_value), 'system/ajax', array(), array(), $form_html_id);
+ $this->assert(count($this->xpath($field_xpath . $field_items_xpath_suffix)) == $i+2, t('Found the correct number of field items after an AJAX submission.'));
+ $this->assertFieldByXPath($field_xpath . $button_xpath_suffix, NULL, t('Found the "add more" button after an AJAX submission.'));
+ $this->assertNoDuplicateIds(t('Updated page contains unique IDs'), 'Other');
+ }
+ }
+ }
+}
+
+/**
+ * Miscellaneous Ajax tests using ajax_test module.
+ */
+class AJAXElementValidation extends AJAXTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Miscellaneous AJAX tests',
+ 'description' => 'Various tests of AJAX behavior',
+ 'group' => 'AJAX',
+ );
+ }
+
+ /**
+ * Try to post an Ajax change to a form that has a validated element.
+ *
+ * The drivertext field is Ajax-enabled. An additional field is not, but
+ * is set to be a required field. In this test the required field is not
+ * filled in, and we want to see if the activation of the "drivertext"
+ * Ajax-enabled field fails due to the required field being empty.
+ */
+ function testAJAXElementValidation() {
+ $web_user = $this->drupalCreateUser();
+ $edit = array('drivertext' => t('some dumb text'));
+
+ // Post with 'drivertext' as the triggering element.
+ $post_result = $this->drupalPostAJAX('ajax_validation_test', $edit, 'drivertext');
+ // Look for a validation failure in the resultant JSON.
+ $this->assertNoText(t('Error message'), t("No error message in resultant JSON"));
+ $this->assertText('ajax_forms_test_validation_form_callback invoked', t('The correct callback was invoked'));
+ }
+}
diff --git a/core/modules/simpletest/tests/ajax_forms_test.info b/core/modules/simpletest/tests/ajax_forms_test.info
new file mode 100644
index 000000000000..987ee25afcda
--- /dev/null
+++ b/core/modules/simpletest/tests/ajax_forms_test.info
@@ -0,0 +1,6 @@
+name = "AJAX form test mock module"
+description = "Test for AJAX form calls."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/ajax_forms_test.module b/core/modules/simpletest/tests/ajax_forms_test.module
new file mode 100644
index 000000000000..075b005eaeea
--- /dev/null
+++ b/core/modules/simpletest/tests/ajax_forms_test.module
@@ -0,0 +1,500 @@
+<?php
+
+/**
+ * @file
+ * Simpletest mock module for Ajax forms testing.
+ */
+
+/**
+ * Implements hook_menu().
+ * @return unknown_type
+ */
+function ajax_forms_test_menu() {
+ $items = array();
+ $items['ajax_forms_test_get_form'] = array(
+ 'title' => 'AJAX forms simple form test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('ajax_forms_test_simple_form'),
+ 'access callback' => TRUE,
+ );
+ $items['ajax_forms_test_ajax_commands_form'] = array(
+ 'title' => 'AJAX forms AJAX commands test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('ajax_forms_test_ajax_commands_form'),
+ 'access callback' => TRUE,
+ );
+ $items['ajax_validation_test'] = array(
+ 'title' => 'AJAX Validation Test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('ajax_forms_test_validation_form'),
+ 'access callback' => TRUE,
+ );
+ $items['ajax_forms_test_lazy_load_form'] = array(
+ 'title' => 'AJAX forms lazy load test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('ajax_forms_test_lazy_load_form'),
+ 'access callback' => TRUE,
+ );
+ return $items;
+}
+
+
+/**
+ * A basic form used to test form_state['values'] during callback.
+ */
+function ajax_forms_test_simple_form($form, &$form_state) {
+ $form = array();
+ $form['select'] = array(
+ '#type' => 'select',
+ '#options' => array(
+ 'red' => 'red',
+ 'green' => 'green',
+ 'blue' => 'blue'),
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_simple_form_select_callback',
+ ),
+ '#suffix' => '<div id="ajax_selected_color">No color yet selected</div>',
+ );
+
+ $form['checkbox'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Test checkbox'),
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_simple_form_checkbox_callback',
+ ),
+ '#suffix' => '<div id="ajax_checkbox_value">No action yet</div>',
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('submit'),
+ );
+ return $form;
+}
+
+/**
+ * Ajax callback triggered by select.
+ */
+function ajax_forms_test_simple_form_select_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_html('#ajax_selected_color', $form_state['values']['select']);
+ $commands[] = ajax_command_data('#ajax_selected_color', 'form_state_value_select', $form_state['values']['select']);
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback triggered by checkbox.
+ */
+function ajax_forms_test_simple_form_checkbox_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_html('#ajax_checkbox_value', (int) $form_state['values']['checkbox']);
+ $commands[] = ajax_command_data('#ajax_checkbox_value', 'form_state_value_select', (int) $form_state['values']['checkbox']);
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+
+/**
+ * Form to display the Ajax Commands.
+ * @param $form
+ * @param $form_state
+ * @return unknown_type
+ */
+function ajax_forms_test_ajax_commands_form($form, &$form_state) {
+ $form = array();
+
+ // Shows the 'after' command with a callback generating commands.
+ $form['after_command_example'] = array(
+ '#value' => t("AJAX 'After': Click to put something after the div"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_after_callback',
+ ),
+ '#suffix' => '<div id="after_div">Something can be inserted after this</div>',
+ );
+
+ // Shows the 'alert' command.
+ $form['alert_command_example'] = array(
+ '#value' => t("AJAX 'Alert': Click to alert"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_alert_callback',
+ ),
+ );
+
+ // Shows the 'append' command.
+ $form['append_command_example'] = array(
+ '#value' => t("AJAX 'Append': Click to append something"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_append_callback',
+ ),
+ '#suffix' => '<div id="append_div">Append inside this div</div>',
+ );
+
+
+ // Shows the 'before' command.
+ $form['before_command_example'] = array(
+ '#value' => t("AJAX 'before': Click to put something before the div"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_before_callback',
+ ),
+ '#suffix' => '<div id="before_div">Insert something before this.</div>',
+ );
+
+ // Shows the 'changed' command without asterisk.
+ $form['changed_command_example'] = array(
+ '#value' => t("AJAX changed: Click to mark div changed."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_changed_callback',
+ ),
+ '#suffix' => '<div id="changed_div"> <div id="changed_div_mark_this">This div can be marked as changed or not.</div></div>',
+ );
+ // Shows the 'changed' command adding the asterisk.
+ $form['changed_command_asterisk_example'] = array(
+ '#value' => t("AJAX changed: Click to mark div changed with asterisk."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_changed_asterisk_callback',
+ ),
+ );
+
+ // Shows the Ajax 'css' command.
+ $form['css_command_example'] = array(
+ '#value' => t("Set the the '#box' div to be blue."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_css_callback',
+ ),
+ '#suffix' => '<div id="css_div" style="height: 50px; width: 50px; border: 1px solid black"> box</div>',
+ );
+
+
+ // Shows the Ajax 'data' command. But there is no use of this information,
+ // as this would require a javascript client to use the data.
+ $form['data_command_example'] = array(
+ '#value' => t("AJAX data command: Issue command."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_data_callback',
+ ),
+ '#suffix' => '<div id="data_div">Data attached to this div.</div>',
+ );
+
+ // Shows the Ajax 'invoke' command.
+ $form['invoke_command_example'] = array(
+ '#value' => t("AJAX invoke command: Invoke addClass() method."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_invoke_callback',
+ ),
+ '#suffix' => '<div id="invoke_div">Original contents</div>',
+ );
+
+ // Shows the Ajax 'html' command.
+ $form['html_command_example'] = array(
+ '#value' => t("AJAX html: Replace the HTML in a selector."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_html_callback',
+ ),
+ '#suffix' => '<div id="html_div">Original contents</div>',
+ );
+
+ // Shows the Ajax 'insert' command.
+ $form['insert_command_example'] = array(
+ '#value' => t("AJAX insert: Let client insert based on #ajax['method']."),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_insert_callback',
+ 'method' => 'prepend',
+ ),
+ '#suffix' => '<div id="insert_div">Original contents</div>',
+ );
+
+ // Shows the Ajax 'prepend' command.
+ $form['prepend_command_example'] = array(
+ '#value' => t("AJAX 'prepend': Click to prepend something"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_prepend_callback',
+ ),
+ '#suffix' => '<div id="prepend_div">Something will be prepended to this div. </div>',
+ );
+
+ // Shows the Ajax 'remove' command.
+ $form['remove_command_example'] = array(
+ '#value' => t("AJAX 'remove': Click to remove text"),
+ '#type' => 'submit',
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_remove_callback',
+ ),
+ '#suffix' => '<div id="remove_div"><div id="remove_text">text to be removed</div></div>',
+ );
+
+ // Shows the Ajax 'restripe' command.
+ $form['restripe_command_example'] = array(
+ '#type' => 'submit',
+ '#value' => t("AJAX 'restripe' command"),
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_restripe_callback',
+ ),
+ '#suffix' => '<div id="restripe_div">
+ <table id="restripe_table" style="border: 1px solid black" >
+ <tr id="table-first"><td>first row</td></tr>
+ <tr ><td>second row</td></tr>
+ </table>
+ </div>',
+ );
+
+ // Demonstrates the Ajax 'settings' command. The 'settings' command has
+ // nothing visual to "show", but it can be tested via SimpleTest and via
+ // Firebug.
+ $form['settings_command_example'] = array(
+ '#type' => 'submit',
+ '#value' => t("AJAX 'settings' command"),
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_advanced_commands_settings_callback',
+ ),
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Ajax callback for 'after'.
+ */
+function ajax_forms_test_advanced_commands_after_callback($form, $form_state) {
+ $selector = '#after_div';
+
+ $commands = array();
+ $commands[] = ajax_command_after($selector, "This will be placed after");
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'alert'.
+ */
+function ajax_forms_test_advanced_commands_alert_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_alert("Alert");
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'append'.
+ */
+function ajax_forms_test_advanced_commands_append_callback($form, $form_state) {
+ $selector = '#append_div';
+ $commands = array();
+ $commands[] = ajax_command_append($selector, "Appended text");
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'before'.
+ */
+function ajax_forms_test_advanced_commands_before_callback($form, $form_state) {
+ $selector = '#before_div';
+
+ $commands = array();
+ $commands[] = ajax_command_before($selector, "Before text");
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'changed'.
+ */
+function ajax_forms_test_advanced_commands_changed_callback($form, $form_state) {
+ $commands[] = ajax_command_changed('#changed_div');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+/**
+ * Ajax callback for 'changed' with asterisk marking inner div.
+ */
+function ajax_forms_test_advanced_commands_changed_asterisk_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_changed('#changed_div', '#changed_div_mark_this');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'css'.
+ */
+function ajax_forms_test_advanced_commands_css_callback($form, $form_state) {
+ $selector = '#css_div';
+ $color = 'blue';
+
+ $commands = array();
+ $commands[] = ajax_command_css($selector, array('background-color' => $color));
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'data'.
+ */
+function ajax_forms_test_advanced_commands_data_callback($form, $form_state) {
+ $selector = '#data_div';
+
+ $commands = array();
+ $commands[] = ajax_command_data($selector, 'testkey', 'testvalue');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'invoke'.
+ */
+function ajax_forms_test_advanced_commands_invoke_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_invoke('#invoke_div', 'addClass', array('error'));
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'html'.
+ */
+function ajax_forms_test_advanced_commands_html_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_html('#html_div', 'replacement text');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'insert'.
+ */
+function ajax_forms_test_advanced_commands_insert_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_insert('#insert_div', 'insert replacement text');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'prepend'.
+ */
+function ajax_forms_test_advanced_commands_prepend_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_prepend('#prepend_div', "prepended text");
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'remove'.
+ */
+function ajax_forms_test_advanced_commands_remove_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_remove('#remove_text');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'restripe'.
+ */
+function ajax_forms_test_advanced_commands_restripe_callback($form, $form_state) {
+ $commands = array();
+ $commands[] = ajax_command_restripe('#restripe_table');
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * Ajax callback for 'settings'.
+ */
+function ajax_forms_test_advanced_commands_settings_callback($form, $form_state) {
+ $commands = array();
+ $setting['ajax_forms_test']['foo'] = 42;
+ $commands[] = ajax_command_settings($setting);
+ return array('#type' => 'ajax', '#commands' => $commands);
+}
+
+/**
+ * This form and its related submit and callback functions demonstrate
+ * not validating another form element when a single Ajax element is triggered.
+ *
+ * The "drivertext" element is an Ajax-enabled textfield, free-form.
+ * The "required_field" element is a textfield marked required.
+ *
+ * The correct behavior is that the Ajax-enabled drivertext element should
+ * be able to trigger without causing validation of the "required_field".
+ */
+function ajax_forms_test_validation_form($form, &$form_state) {
+
+ $form['drivertext'] = array(
+ '#title' => t('AJAX-enabled textfield.'),
+ '#description' => t("When this one AJAX-triggers and the spare required field is empty, you should not get an error."),
+ '#type' => 'textfield',
+ '#default_value' => !empty($form_state['values']['drivertext']) ? $form_state['values']['drivertext'] : "",
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_validation_form_callback',
+ 'wrapper' => 'message_area',
+ 'method' => 'replace',
+ ),
+ '#suffix' => '<div id="message_area"></div>',
+ );
+
+ $form['spare_required_field'] = array(
+ '#title' => t("Spare Required Field"),
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+
+ return $form;
+}
+/**
+ * Submit handler for the validation form.
+ */
+function ajax_forms_test_validation_form_submit($form, $form_state) {
+ drupal_set_message(t("Validation form submitted"));
+}
+
+/**
+ * Ajax callback for the 'drivertext' element of the validation form.
+ */
+function ajax_forms_test_validation_form_callback($form, $form_state) {
+ drupal_set_message("ajax_forms_test_validation_form_callback invoked");
+ drupal_set_message(t("Callback: drivertext=%drivertext, spare_required_field=%spare_required_field", array('%drivertext' => $form_state['values']['drivertext'], '%spare_required_field' => $form_state['values']['spare_required_field'])));
+ return '<div id="message_area">ajax_forms_test_validation_form_callback at ' . date('c') . '</div>';
+}
+
+/**
+ * Form builder: Builds a form that triggers a simple AJAX callback.
+ */
+function ajax_forms_test_lazy_load_form($form, &$form_state) {
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ '#ajax' => array(
+ 'callback' => 'ajax_forms_test_lazy_load_form_ajax',
+ ),
+ );
+ return $form;
+}
+
+/**
+ * Form submit handler: Adds JavaScript and CSS that wasn't on the original form.
+ */
+function ajax_forms_test_lazy_load_form_submit($form, &$form_state) {
+ drupal_add_js(array('ajax_forms_test_lazy_load_form_submit' => 'executed'), 'setting');
+ drupal_add_css(drupal_get_path('module', 'system') . '/system.admin.css');
+ drupal_add_js(drupal_get_path('module', 'system') . '/system.js');
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * AJAX callback for the ajax_forms_test_lazy_load_form() form.
+ *
+ * This function returns nothing, because all we're interested in testing is
+ * ajax_render() adding commands for JavaScript and CSS added during the page
+ * request, such as the ones added in ajax_forms_test_lazy_load_form_submit().
+ */
+function ajax_forms_test_lazy_load_form_ajax($form, &$form_state) {
+ return NULL;
+}
diff --git a/core/modules/simpletest/tests/ajax_test.info b/core/modules/simpletest/tests/ajax_test.info
new file mode 100644
index 000000000000..dda7f55e0d26
--- /dev/null
+++ b/core/modules/simpletest/tests/ajax_test.info
@@ -0,0 +1,6 @@
+name = AJAX Test
+description = Support module for AJAX framework tests.
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/ajax_test.module b/core/modules/simpletest/tests/ajax_test.module
new file mode 100644
index 000000000000..4148a0839fb9
--- /dev/null
+++ b/core/modules/simpletest/tests/ajax_test.module
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * @file
+ * Helper module for Ajax framework tests.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function ajax_test_menu() {
+ $items['ajax-test/render'] = array(
+ 'title' => 'ajax_render',
+ 'page callback' => 'ajax_test_render',
+ 'delivery callback' => 'ajax_deliver',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['ajax-test/render-error'] = array(
+ 'title' => 'ajax_render_error',
+ 'page callback' => 'ajax_test_error',
+ 'delivery callback' => 'ajax_deliver',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['ajax-test/link'] = array(
+ 'title' => 'AJAX Link',
+ 'page callback' => 'ajax_test_link',
+ 'access callback' => TRUE,
+ );
+ return $items;
+}
+
+/**
+ * Menu callback; Return an element suitable for use by ajax_deliver().
+ *
+ * Additionally ensures that ajax_render() incorporates JavaScript settings
+ * generated during the page request by invoking drupal_add_js() with a dummy
+ * setting.
+ */
+function ajax_test_render() {
+ drupal_add_js(array('ajax' => 'test'), 'setting');
+ return array('#type' => 'ajax', '#commands' => array());
+}
+
+/**
+ * Menu callback; Returns Ajax element with #error property set.
+ */
+function ajax_test_error() {
+ $message = '';
+ if (!empty($_GET['message'])) {
+ $message = $_GET['message'];
+ }
+ return array('#type' => 'ajax', '#error' => $message);
+}
+
+/**
+ * Menu callback; Renders a #type link with #ajax.
+ */
+function ajax_test_link() {
+ $build['link'] = array(
+ '#type' => 'link',
+ '#title' => 'Show help',
+ '#href' => 'filter/tips',
+ '#ajax' => array(
+ 'wrapper' => 'block-system-main',
+ ),
+ );
+ return $build;
+}
+
diff --git a/core/modules/simpletest/tests/batch.test b/core/modules/simpletest/tests/batch.test
new file mode 100644
index 000000000000..1e9b31ba1bdc
--- /dev/null
+++ b/core/modules/simpletest/tests/batch.test
@@ -0,0 +1,405 @@
+<?php
+
+/**
+ * @file
+ * Tests for the Batch API.
+ */
+
+/**
+ * Tests for the Batch API.
+ */
+class BatchProcessingTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Batch processing',
+ 'description' => 'Test batch processing in form and non-form workflow.',
+ 'group' => 'Batch API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('batch_test');
+ }
+
+ /**
+ * Test batches triggered outside of form submission.
+ */
+ function testBatchNoForm() {
+ // Displaying the page triggers batch 1.
+ $this->drupalGet('batch-test/no-form');
+ $this->assertBatchMessages($this->_resultMessages(1), t('Batch for step 2 performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+ }
+
+ /**
+ * Test batches defined in a form submit handler.
+ */
+ function testBatchForm() {
+ // Batch 0: no operation.
+ $edit = array('batch' => 'batch_0');
+ $this->drupalPost('batch-test/simple', $edit, 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_0'), t('Batch with no operation performed successfully.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+ // Batch 1: several simple operations.
+ $edit = array('batch' => 'batch_1');
+ $this->drupalPost('batch-test/simple', $edit, 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_1'), t('Batch with simple operations performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+ // Batch 2: one multistep operation.
+ $edit = array('batch' => 'batch_2');
+ $this->drupalPost('batch-test/simple', $edit, 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_2'), t('Batch with multistep operation performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_2'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+ // Batch 3: simple + multistep combined.
+ $edit = array('batch' => 'batch_3');
+ $this->drupalPost('batch-test/simple', $edit, 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_3'), t('Batch with simple and multistep operations performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_3'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+
+ // Batch 4: nested batch.
+ $edit = array('batch' => 'batch_4');
+ $this->drupalPost('batch-test/simple', $edit, 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_4'), t('Nested batch performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_4'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+ }
+
+ /**
+ * Test batches defined in a multistep form.
+ */
+ function testBatchFormMultistep() {
+ $this->drupalGet('batch-test/multistep');
+ $this->assertText('step 1', t('Form is displayed in step 1.'));
+
+ // First step triggers batch 1.
+ $this->drupalPost(NULL, array(), 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_1'), t('Batch for step 1 performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_1'), t('Execution order was correct.'));
+ $this->assertText('step 2', t('Form is displayed in step 2.'));
+
+ // Second step triggers batch 2.
+ $this->drupalPost(NULL, array(), 'Submit');
+ $this->assertBatchMessages($this->_resultMessages('batch_2'), t('Batch for step 2 performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_2'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+ }
+
+ /**
+ * Test batches defined in different submit handlers on the same form.
+ */
+ function testBatchFormMultipleBatches() {
+ // Batches 1, 2 and 3 are triggered in sequence by different submit
+ // handlers. Each submit handler modify the submitted 'value'.
+ $value = rand(0, 255);
+ $edit = array('value' => $value);
+ $this->drupalPost('batch-test/chained', $edit, 'Submit');
+ // Check that result messages are present and in the correct order.
+ $this->assertBatchMessages($this->_resultMessages('chained'), t('Batches defined in separate submit handlers performed successfully.'));
+ // The stack contains execution order of batch callbacks and submit
+ // hanlders and logging of corresponding $form_state[{values'].
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('chained', $value), t('Execution order was correct, and $form_state is correctly persisted.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+ }
+
+ /**
+ * Test batches defined in a programmatically submitted form.
+ *
+ * Same as above, but the form is submitted through drupal_form_execute().
+ */
+ function testBatchFormProgrammatic() {
+ // Batches 1, 2 and 3 are triggered in sequence by different submit
+ // handlers. Each submit handler modify the submitted 'value'.
+ $value = rand(0, 255);
+ $this->drupalGet('batch-test/programmatic/' . $value);
+ // Check that result messages are present and in the correct order.
+ $this->assertBatchMessages($this->_resultMessages('chained'), t('Batches defined in separate submit handlers performed successfully.'));
+ // The stack contains execution order of batch callbacks and submit
+ // hanlders and logging of corresponding $form_state[{values'].
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('chained', $value), t('Execution order was correct, and $form_state is correctly persisted.'));
+ $this->assertText('Got out of a programmatic batched form.', t('Page execution continues normally.'));
+ }
+
+ /**
+ * Test that drupal_form_submit() can run within a batch operation.
+ */
+ function testDrupalFormSubmitInBatch() {
+ // Displaying the page triggers a batch that programmatically submits a
+ // form.
+ $value = rand(0, 255);
+ $this->drupalGet('batch-test/nested-programmatic/' . $value);
+ $this->assertEqual(batch_test_stack(), array('mock form submitted with value = ' . $value), t('drupal_form_submit() ran successfully within a batch operation.'));
+ }
+
+ /**
+ * Test batches that return $context['finished'] > 1 do in fact complete.
+ * See http://drupal.org/node/600836
+ */
+ function testBatchLargePercentage() {
+ // Displaying the page triggers batch 5.
+ $this->drupalGet('batch-test/large-percentage');
+ $this->assertBatchMessages($this->_resultMessages(1), t('Batch for step 2 performed successfully.'));
+ $this->assertEqual(batch_test_stack(), $this->_resultStack('batch_5'), t('Execution order was correct.'));
+ $this->assertText('Redirection successful.', t('Redirection after batch execution is correct.'));
+ }
+
+
+ /**
+ * Will trigger a pass if the texts were found in order in the raw content.
+ *
+ * @param $texts
+ * Array of raw strings to look for .
+ * @param $message
+ * Message to display.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertBatchMessages($texts, $message) {
+ $pattern = '|' . implode('.*', $texts) .'|s';
+ return $this->assertPattern($pattern, $message);
+ }
+
+ /**
+ * Helper function: return expected execution stacks for the test batches.
+ */
+ function _resultStack($id, $value = 0) {
+ $stack = array();
+ switch ($id) {
+ case 'batch_1':
+ for ($i = 1; $i <= 10; $i++) {
+ $stack[] = "op 1 id $i";
+ }
+ break;
+
+ case 'batch_2':
+ for ($i = 1; $i <= 10; $i++) {
+ $stack[] = "op 2 id $i";
+ }
+ break;
+
+ case 'batch_3':
+ for ($i = 1; $i <= 5; $i++) {
+ $stack[] = "op 1 id $i";
+ }
+ for ($i = 1; $i <= 5; $i++) {
+ $stack[] = "op 2 id $i";
+ }
+ for ($i = 6; $i <= 10; $i++) {
+ $stack[] = "op 1 id $i";
+ }
+ for ($i = 6; $i <= 10; $i++) {
+ $stack[] = "op 2 id $i";
+ }
+ break;
+
+ case 'batch_4':
+ for ($i = 1; $i <= 5; $i++) {
+ $stack[] = "op 1 id $i";
+ }
+ $stack[] = 'setting up batch 2';
+ for ($i = 6; $i <= 10; $i++) {
+ $stack[] = "op 1 id $i";
+ }
+ $stack = array_merge($stack, $this->_resultStack('batch_2'));
+ break;
+
+ case 'batch_5':
+ for ($i = 1; $i <= 10; $i++) {
+ $stack[] = "op 5 id $i";
+ }
+ break;
+
+ case 'chained':
+ $stack[] = 'submit handler 1';
+ $stack[] = 'value = ' . $value;
+ $stack = array_merge($stack, $this->_resultStack('batch_1'));
+ $stack[] = 'submit handler 2';
+ $stack[] = 'value = ' . ($value + 1);
+ $stack = array_merge($stack, $this->_resultStack('batch_2'));
+ $stack[] = 'submit handler 3';
+ $stack[] = 'value = ' . ($value + 2);
+ $stack[] = 'submit handler 4';
+ $stack[] = 'value = ' . ($value + 3);
+ $stack = array_merge($stack, $this->_resultStack('batch_3'));
+ break;
+ }
+ return $stack;
+ }
+
+ /**
+ * Helper function: return expected result messages for the test batches.
+ */
+ function _resultMessages($id) {
+ $messages = array();
+
+ switch ($id) {
+ case 'batch_0':
+ $messages[] = 'results for batch 0<br />none';
+ break;
+
+ case 'batch_1':
+ $messages[] = 'results for batch 1<br />op 1: processed 10 elements';
+ break;
+
+ case 'batch_2':
+ $messages[] = 'results for batch 2<br />op 2: processed 10 elements';
+ break;
+
+ case 'batch_3':
+ $messages[] = 'results for batch 3<br />op 1: processed 10 elements<br />op 2: processed 10 elements';
+ break;
+
+ case 'batch_4':
+ $messages[] = 'results for batch 4<br />op 1: processed 10 elements';
+ $messages = array_merge($messages, $this->_resultMessages('batch_2'));
+ break;
+
+ case 'batch_5':
+ $messages[] = 'results for batch 5<br />op 1: processed 10 elements. $context[\'finished\'] > 1 returned from batch process, with success.';
+ break;
+
+ case 'chained':
+ $messages = array_merge($messages, $this->_resultMessages('batch_1'));
+ $messages = array_merge($messages, $this->_resultMessages('batch_2'));
+ $messages = array_merge($messages, $this->_resultMessages('batch_3'));
+ break;
+ }
+ return $messages;
+ }
+}
+
+/**
+ * Tests for the Batch API Progress page.
+ */
+class BatchPageTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Batch progress page',
+ 'description' => 'Test the content of the progress page.',
+ 'group' => 'Batch API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('batch_test');
+ }
+
+ /**
+ * Tests that the batch API progress page uses the correct theme.
+ */
+ function testBatchProgressPageTheme() {
+ // Make sure that the page which starts the batch (an administrative page)
+ // is using a different theme than would normally be used by the batch API.
+ variable_set('theme_default', 'bartik');
+ variable_set('admin_theme', 'seven');
+ // Log in as an administrator who can see the administrative theme.
+ $admin_user = $this->drupalCreateUser(array('view the administration theme'));
+ $this->drupalLogin($admin_user);
+ // Visit an administrative page that runs a test batch, and check that the
+ // theme that was used during batch execution (which the batch callback
+ // function saved as a variable) matches the theme used on the
+ // administrative page.
+ $this->drupalGet('admin/batch-test/test-theme');
+ // The stack should contain the name of the theme used on the progress
+ // page.
+ $this->assertEqual(batch_test_stack(), array('seven'), t('A progressive batch correctly uses the theme of the page that started the batch.'));
+ }
+}
+
+/**
+ * Tests the function _batch_api_percentage() to make sure that the rounding
+ * works properly in all cases.
+ */
+class BatchPercentagesUnitTestCase extends DrupalUnitTestCase {
+ protected $testCases = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Batch percentages',
+ 'description' => 'Unit tests of progress percentage rounding.',
+ 'group' => 'Batch API',
+ );
+ }
+
+ function setUp() {
+ // Set up an array of test cases, where the expected values are the keys,
+ // and the values are arrays with the keys 'total' and 'current',
+ // corresponding with the function parameters of _batch_api_percentage().
+ $this->testCases = array(
+ // 1/2 is 50%.
+ '50' => array('total' => 2, 'current' => 1),
+ // Though we should never encounter a case where the current set is set
+ // 0, if we did, we should get 0%.
+ '0' => array('total' => 3, 'current' => 0),
+ // 1/3 is closer to 33% than to 34%.
+ '33' => array('total' => 3, 'current' => 1),
+ // 2/3 is closer to 67% than to 66%.
+ '67' => array('total' => 3, 'current' => 2),
+ // A full 3/3 should equal 100%.
+ '100' => array('total' => 3, 'current' => 3),
+ // 1/199 should round up to 1%.
+ '1' => array('total' => 199, 'current' => 1),
+ // 198/199 should round down to 99%.
+ '99' => array('total' => 199, 'current' => 198),
+ // 199/200 would have rounded up to 100%, which would give the false
+ // impression of being finished, so we add another digit and should get
+ // 99.5%.
+ '99.5' => array('total' => 200, 'current' => 199),
+ // The same logic holds for 1/200: we should get 0.5%.
+ '0.5' => array('total' => 200, 'current' => 1),
+ // Numbers that come out evenly, such as 50/200, should be forced to have
+ // extra digits for consistancy.
+ '25.0' => array('total' => 200, 'current' => 50),
+ // Regardless of number of digits we're using, 100% should always just be
+ // 100%.
+ '100' => array('total' => 200, 'current' => 200),
+ // 1998/1999 should similarly round down to 99.9%.
+ '99.9' => array('total' => 1999, 'current' => 1998),
+ // 1999/2000 should add another digit and go to 99.95%.
+ '99.95' => array('total' => 2000, 'current' => 1999),
+ // 19999/20000 should add yet another digit and go to 99.995%.
+ '99.995' => array('total' => 20000, 'current' => 19999),
+ // The next five test cases simulate a batch with a single operation
+ // ('total' equals 1) that takes several steps to complete. Within the
+ // operation, we imagine that there are 501 items to process, and 100 are
+ // completed during each step. The percentages we get back should be
+ // rounded the usual way for the first few passes (i.e., 20%, 40%, etc.),
+ // but for the last pass through, when 500 out of 501 items have been
+ // processed, we do not want to round up to 100%, since that would
+ // erroneously indicate that the processing is complete.
+ '20' => array('total' => 1, 'current' => 100/501),
+ '40' => array('total' => 1, 'current' => 200/501),
+ '60' => array('total' => 1, 'current' => 300/501),
+ '80' => array('total' => 1, 'current' => 400/501),
+ '99.8' => array('total' => 1, 'current' => 500/501),
+ );
+ require_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ parent::setUp();
+ }
+
+ /**
+ * Test the _batch_api_percentage() function.
+ */
+ function testBatchPercentages() {
+ foreach ($this->testCases as $expected_result => $arguments) {
+ // PHP sometimes casts numeric strings that are array keys to integers,
+ // cast them back here.
+ $expected_result = (string) $expected_result;
+ $total = $arguments['total'];
+ $current = $arguments['current'];
+ $actual_result = _batch_api_percentage($total, $current);
+ if ($actual_result === $expected_result) {
+ $this->pass(t('Expected the batch api percentage at the state @numerator/@denominator to be @expected%, and got @actual%.', array('@numerator' => $current, '@denominator' => $total, '@expected' => $expected_result, '@actual' => $actual_result)));
+ }
+ else {
+ $this->fail(t('Expected the batch api percentage at the state @numerator/@denominator to be @expected%, but got @actual%.', array('@numerator' => $current, '@denominator' => $total, '@expected' => $expected_result, '@actual' => $actual_result)));
+ }
+ }
+ }
+}
diff --git a/core/modules/simpletest/tests/batch_test.callbacks.inc b/core/modules/simpletest/tests/batch_test.callbacks.inc
new file mode 100644
index 000000000000..75e665533e99
--- /dev/null
+++ b/core/modules/simpletest/tests/batch_test.callbacks.inc
@@ -0,0 +1,141 @@
+<?php
+
+
+/**
+ * @file
+ * Batch callbacks for the Batch API tests.
+ */
+
+/**
+ * Simple batch operation.
+ */
+function _batch_test_callback_1($id, $sleep, &$context) {
+ // No-op, but ensure the batch take a couple iterations.
+ // Batch needs time to run for the test, so sleep a bit.
+ usleep($sleep);
+ // Track execution, and store some result for post-processing in the
+ // 'finished' callback.
+ batch_test_stack("op 1 id $id");
+ $context['results'][1][] = $id;
+}
+
+/**
+ * Multistep batch operation.
+ */
+function _batch_test_callback_2($start, $total, $sleep, &$context) {
+ // Initialize context with progress information.
+ if (!isset($context['sandbox']['current'])) {
+ $context['sandbox']['current'] = $start;
+ $context['sandbox']['count'] = 0;
+ }
+
+ // Process by groups of 5 (arbitrary value).
+ $limit = 5;
+ for ($i = 0; $i < $limit && $context['sandbox']['count'] < $total; $i++) {
+ // No-op, but ensure the batch take a couple iterations.
+ // Batch needs time to run for the test, so sleep a bit.
+ usleep($sleep);
+ // Track execution, and store some result for post-processing in the
+ // 'finished' callback.
+ $id = $context['sandbox']['current'] + $i;
+ batch_test_stack("op 2 id $id");
+ $context['results'][2][] = $id;
+
+ // Update progress information.
+ $context['sandbox']['count']++;
+ }
+ $context['sandbox']['current'] += $i;
+
+ // Inform batch engine about progress.
+ if ($context['sandbox']['count'] != $total) {
+ $context['finished'] = $context['sandbox']['count'] / $total;
+ }
+}
+
+/**
+ * Simple batch operation.
+ */
+function _batch_test_callback_5($id, $sleep, &$context) {
+ // No-op, but ensure the batch take a couple iterations.
+ // Batch needs time to run for the test, so sleep a bit.
+ usleep($sleep);
+ // Track execution, and store some result for post-processing in the
+ // 'finished' callback.
+ batch_test_stack("op 5 id $id");
+ $context['results'][5][] = $id;
+ // This test is to test finished > 1
+ $context['finished'] = 3.14;
+}
+
+/**
+ * Batch operation setting up its own batch.
+ */
+function _batch_test_nested_batch_callback() {
+ batch_test_stack('setting up batch 2');
+ batch_set(_batch_test_batch_2());
+}
+
+/**
+ * Common 'finished' callbacks for batches 1 to 4.
+ */
+function _batch_test_finished_helper($batch_id, $success, $results, $operations) {
+ $messages = array("results for batch $batch_id");
+ if ($results) {
+ foreach ($results as $op => $op_results) {
+ $messages[] = 'op '. $op . ': processed ' . count($op_results) . ' elements';
+ }
+ }
+ else {
+ $messages[] = 'none';
+ }
+
+ if (!$success) {
+ // A fatal error occurred during the processing.
+ $error_operation = reset($operations);
+ $messages[] = t('An error occurred while processing @op with arguments:<br/>@args', array('@op' => $error_operation[0], '@args' => print_r($error_operation[1], TRUE)));
+ }
+
+ drupal_set_message(implode('<br />', $messages));
+}
+
+/**
+ * 'finished' callback for batch 0.
+ */
+function _batch_test_finished_0($success, $results, $operations) {
+ _batch_test_finished_helper(0, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 1.
+ */
+function _batch_test_finished_1($success, $results, $operations) {
+ _batch_test_finished_helper(1, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 2.
+ */
+function _batch_test_finished_2($success, $results, $operations) {
+ _batch_test_finished_helper(2, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 3.
+ */
+function _batch_test_finished_3($success, $results, $operations) {
+ _batch_test_finished_helper(3, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 4.
+ */
+function _batch_test_finished_4($success, $results, $operations) {
+ _batch_test_finished_helper(4, $success, $results, $operations);
+}
+
+/**
+ * 'finished' callback for batch 5.
+ */
+function _batch_test_finished_5($success, $results, $operations) {
+ _batch_test_finished_helper(5, $success, $results, $operations);
+}
diff --git a/core/modules/simpletest/tests/batch_test.info b/core/modules/simpletest/tests/batch_test.info
new file mode 100644
index 000000000000..cf2cc3081ee0
--- /dev/null
+++ b/core/modules/simpletest/tests/batch_test.info
@@ -0,0 +1,6 @@
+name = "Batch API test"
+description = "Support module for Batch API tests."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/batch_test.module b/core/modules/simpletest/tests/batch_test.module
new file mode 100644
index 000000000000..1200e767dd7d
--- /dev/null
+++ b/core/modules/simpletest/tests/batch_test.module
@@ -0,0 +1,513 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the Batch API tests.
+ */
+
+/**
+ * Implement hook_menu().
+ */
+function batch_test_menu() {
+ $items = array();
+
+ $items['batch-test'] = array(
+ 'title' => 'Batch test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('batch_test_simple_form'),
+ 'access callback' => TRUE,
+ );
+ // Simple form: one submit handler, setting a batch.
+ $items['batch-test/simple'] = array(
+ 'title' => 'Simple',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => 0,
+ );
+ // Multistep form: two steps, each setting a batch.
+ $items['batch-test/multistep'] = array(
+ 'title' => 'Multistep',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('batch_test_multistep_form'),
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 1,
+ );
+ // Chained form: four submit handlers, several of which set a batch.
+ $items['batch-test/chained'] = array(
+ 'title' => 'Chained',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('batch_test_chained_form'),
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ );
+ // Programmatic form: the page submits the 'Chained' form through
+ // drupal_form_submit().
+ $items['batch-test/programmatic'] = array(
+ 'title' => 'Programmatic',
+ 'page callback' => 'batch_test_programmatic',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 3,
+ );
+ // No form: fire a batch simply by accessing a page.
+ $items['batch-test/no-form'] = array(
+ 'title' => 'Simple page',
+ 'page callback' => 'batch_test_no_form',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 4,
+ );
+ // No form: fire a batch; return > 100% complete
+ $items['batch-test/large-percentage'] = array(
+ 'title' => 'Simple page with batch over 100% complete',
+ 'page callback' => 'batch_test_large_percentage',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 5,
+ );
+ // Tests programmatic form submission within a batch operation.
+ $items['batch-test/nested-programmatic'] = array(
+ 'title' => 'Nested programmatic',
+ 'page callback' => 'batch_test_nested_drupal_form_submit',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 6,
+ );
+ // Landing page to test redirects.
+ $items['batch-test/redirect'] = array(
+ 'title' => 'Redirect',
+ 'page callback' => 'batch_test_redirect_page',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 7,
+ );
+ // This item lives under 'admin' so that the page uses the admin theme.
+ $items['admin/batch-test/test-theme'] = array(
+ 'page callback' => 'batch_test_theme_batch',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Simple form.
+ */
+function batch_test_simple_form() {
+ $form['batch'] = array(
+ '#type' => 'select',
+ '#title' => 'Choose batch',
+ '#options' => array(
+ 'batch_0' => 'batch 0',
+ 'batch_1' => 'batch 1',
+ 'batch_2' => 'batch 2',
+ 'batch_3' => 'batch 3',
+ 'batch_4' => 'batch 4',
+ ),
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for the simple form.
+ */
+function batch_test_simple_form_submit($form, &$form_state) {
+ batch_test_stack(NULL, TRUE);
+
+ $function = '_batch_test_' . $form_state['values']['batch'];
+ batch_set($function());
+
+ $form_state['redirect'] = 'batch-test/redirect';
+}
+
+
+/**
+ * Multistep form.
+ */
+function batch_test_multistep_form($form, &$form_state) {
+ if (empty($form_state['storage']['step'])) {
+ $form_state['storage']['step'] = 1;
+ }
+
+ $form['step_display'] = array(
+ '#markup' => 'step ' . $form_state['storage']['step'] . '<br/>',
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for the multistep form.
+ */
+function batch_test_multistep_form_submit($form, &$form_state) {
+ batch_test_stack(NULL, TRUE);
+
+ switch ($form_state['storage']['step']) {
+ case 1:
+ batch_set(_batch_test_batch_1());
+ break;
+ case 2:
+ batch_set(_batch_test_batch_2());
+ break;
+ }
+
+ if ($form_state['storage']['step'] < 2) {
+ $form_state['storage']['step']++;
+ $form_state['rebuild'] = TRUE;
+ }
+
+ // This will only be effective on the last step.
+ $form_state['redirect'] = 'batch-test/redirect';
+}
+
+/**
+ * Form with chained submit callbacks.
+ */
+function batch_test_chained_form() {
+ // This value is used to test that $form_state persists through batched
+ // submit handlers.
+ $form['value'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Value',
+ '#default_value' => 1,
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ );
+ $form['#submit'] = array(
+ 'batch_test_chained_form_submit_1',
+ 'batch_test_chained_form_submit_2',
+ 'batch_test_chained_form_submit_3',
+ 'batch_test_chained_form_submit_4',
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler #1 for the chained form.
+ */
+function batch_test_chained_form_submit_1($form, &$form_state) {
+ batch_test_stack(NULL, TRUE);
+
+ batch_test_stack('submit handler 1');
+ batch_test_stack('value = ' . $form_state['values']['value']);
+
+ $form_state['values']['value']++;
+ batch_set(_batch_test_batch_1());
+
+ // This redirect should not be taken into account.
+ $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #2 for the chained form.
+ */
+function batch_test_chained_form_submit_2($form, &$form_state) {
+ batch_test_stack('submit handler 2');
+ batch_test_stack('value = ' . $form_state['values']['value']);
+
+ $form_state['values']['value']++;
+ batch_set(_batch_test_batch_2());
+
+ // This redirect should not be taken into account.
+ $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #3 for the chained form.
+ */
+function batch_test_chained_form_submit_3($form, &$form_state) {
+ batch_test_stack('submit handler 3');
+ batch_test_stack('value = ' . $form_state['values']['value']);
+
+ $form_state['values']['value']++;
+
+ // This redirect should not be taken into account.
+ $form_state['redirect'] = 'should/be/discarded';
+}
+
+/**
+ * Submit handler #4 for the chained form.
+ */
+function batch_test_chained_form_submit_4($form, &$form_state) {
+ batch_test_stack('submit handler 4');
+ batch_test_stack('value = ' . $form_state['values']['value']);
+
+ $form_state['values']['value']++;
+ batch_set(_batch_test_batch_3());
+
+ // This is the redirect that should prevail.
+ $form_state['redirect'] = 'batch-test/redirect';
+}
+
+/**
+ * Menu callback: programmatically submits the 'Chained' form.
+ */
+function batch_test_programmatic($value = 1) {
+ $form_state = array(
+ 'values' => array('value' => $value)
+ );
+ drupal_form_submit('batch_test_chained_form', $form_state);
+ return 'Got out of a programmatic batched form.';
+}
+
+/**
+ * Menu callback: programmatically submits a form within a batch.
+ */
+function batch_test_nested_drupal_form_submit($value = 1) {
+ // Set the batch and process it.
+ $batch['operations'] = array(
+ array('_batch_test_nested_drupal_form_submit_callback', array($value)),
+ );
+ batch_set($batch);
+ batch_process('batch-test/redirect');
+}
+
+/**
+ * Batch operation: submits form_test_mock_form using drupal_form_submit().
+ */
+function _batch_test_nested_drupal_form_submit_callback($value) {
+ $state['values']['test_value'] = $value;
+ drupal_form_submit('batch_test_mock_form', $state);
+}
+
+/**
+ * A simple form with a textfield and submit button.
+ */
+function batch_test_mock_form($form, $form_state) {
+ $form['test_value'] = array(
+ '#type' => 'textfield',
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for the batch_test_mock form.
+ */
+function batch_test_mock_form_submit($form, &$form_state) {
+ batch_test_stack('mock form submitted with value = ' . $form_state['values']['test_value']);
+}
+
+/**
+ * Menu callback: fire a batch process without a form submission.
+ */
+function batch_test_no_form() {
+ batch_test_stack(NULL, TRUE);
+
+ batch_set(_batch_test_batch_1());
+ batch_process('batch-test/redirect');
+}
+
+/**
+ * Menu callback: fire a batch process without a form submission.
+ */
+function batch_test_large_percentage() {
+ batch_test_stack(NULL, TRUE);
+
+ batch_set(_batch_test_batch_5());
+ batch_process('batch-test/redirect');
+}
+
+/**
+ * Menu callback: successful redirection.
+ */
+function batch_test_redirect_page() {
+ return 'Redirection successful.';
+}
+
+/**
+ * Batch 0: no operation.
+ */
+function _batch_test_batch_0() {
+ $batch = array(
+ 'operations' => array(),
+ 'finished' => '_batch_test_finished_0',
+ 'file' => drupal_get_path('module', 'batch_test'). '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Batch 1: repeats a simple operation.
+ *
+ * Operations: op 1 from 1 to 10.
+ */
+function _batch_test_batch_1() {
+ // Ensure the batch takes at least two iterations.
+ $total = 10;
+ $sleep = (1000000 / $total) * 2;
+
+ $operations = array();
+ for ($i = 1; $i <= $total; $i++) {
+ $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+ }
+ $batch = array(
+ 'operations' => $operations,
+ 'finished' => '_batch_test_finished_1',
+ 'file' => drupal_get_path('module', 'batch_test'). '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Batch 2: single multistep operation.
+ *
+ * Operations: op 2 from 1 to 10.
+ */
+function _batch_test_batch_2() {
+ // Ensure the batch takes at least two iterations.
+ $total = 10;
+ $sleep = (1000000 / $total) * 2;
+
+ $operations = array(
+ array('_batch_test_callback_2', array(1, $total, $sleep)),
+ );
+ $batch = array(
+ 'operations' => $operations,
+ 'finished' => '_batch_test_finished_2',
+ 'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Batch 3: both single and multistep operations.
+ *
+ * Operations:
+ * - op 1 from 1 to 5,
+ * - op 2 from 1 to 5,
+ * - op 1 from 6 to 10,
+ * - op 2 from 6 to 10.
+ */
+function _batch_test_batch_3() {
+ // Ensure the batch takes at least two iterations.
+ $total = 10;
+ $sleep = (1000000 / $total) * 2;
+
+ $operations = array();
+ for ($i = 1; $i <= round($total / 2); $i++) {
+ $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+ }
+ $operations[] = array('_batch_test_callback_2', array(1, $total / 2, $sleep));
+ for ($i = round($total / 2) + 1; $i <= $total; $i++) {
+ $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+ }
+ $operations[] = array('_batch_test_callback_2', array(6, $total / 2, $sleep));
+ $batch = array(
+ 'operations' => $operations,
+ 'finished' => '_batch_test_finished_3',
+ 'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Batch 4: batch within a batch.
+ *
+ * Operations:
+ * - op 1 from 1 to 5,
+ * - set batch 2 (op 2 from 1 to 10, should run at the end)
+ * - op 1 from 6 to 10,
+ */
+function _batch_test_batch_4() {
+ // Ensure the batch takes at least two iterations.
+ $total = 10;
+ $sleep = (1000000 / $total) * 2;
+
+ $operations = array();
+ for ($i = 1; $i <= round($total / 2); $i++) {
+ $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+ }
+ $operations[] = array('_batch_test_nested_batch_callback', array());
+ for ($i = round($total / 2) + 1; $i <= $total; $i++) {
+ $operations[] = array('_batch_test_callback_1', array($i, $sleep));
+ }
+ $batch = array(
+ 'operations' => $operations,
+ 'finished' => '_batch_test_finished_4',
+ 'file' => drupal_get_path('module', 'batch_test') . '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Batch 5: repeats a simple operation.
+ *
+ * Operations: op 1 from 1 to 10.
+ */
+function _batch_test_batch_5() {
+ // Ensure the batch takes at least two iterations.
+ $total = 10;
+ $sleep = (1000000 / $total) * 2;
+
+ $operations = array();
+ for ($i = 1; $i <= $total; $i++) {
+ $operations[] = array('_batch_test_callback_5', array($i, $sleep));
+ }
+ $batch = array(
+ 'operations' => $operations,
+ 'finished' => '_batch_test_finished_5',
+ 'file' => drupal_get_path('module', 'batch_test'). '/batch_test.callbacks.inc',
+ );
+ return $batch;
+}
+
+/**
+ * Menu callback: run a batch for testing theme used on the progress page.
+ */
+function batch_test_theme_batch() {
+ batch_test_stack(NULL, TRUE);
+ $batch = array(
+ 'operations' => array(
+ array('_batch_test_theme_callback', array()),
+ ),
+ );
+ batch_set($batch);
+ batch_process('batch-test/redirect');
+}
+
+/**
+ * Batch callback function for testing the theme used on the progress page.
+ */
+function _batch_test_theme_callback() {
+ // Because drupalGet() steps through the full progressive batch before
+ // returning control to the test function, we cannot test that the correct
+ // theme is being used on the batch processing page by viewing that page
+ // directly. Instead, we save the theme being used in a variable here, so
+ // that it can be loaded and inspected in the thread running the test.
+ global $theme;
+ batch_test_stack($theme);
+}
+
+/**
+ * Helper function: store or retrieve traced execution data.
+ */
+function batch_test_stack($data = NULL, $reset = FALSE) {
+ if ($reset) {
+ variable_del('batch_test_stack');
+ }
+ if (!isset($data)) {
+ return variable_get('batch_test_stack', array());
+ }
+ $stack = variable_get('batch_test_stack', array());
+ $stack[] = $data;
+ variable_set('batch_test_stack', $stack);
+}
diff --git a/core/modules/simpletest/tests/bootstrap.test b/core/modules/simpletest/tests/bootstrap.test
new file mode 100644
index 000000000000..5829222f0514
--- /dev/null
+++ b/core/modules/simpletest/tests/bootstrap.test
@@ -0,0 +1,502 @@
+<?php
+
+class BootstrapIPAddressTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'IP address and HTTP_HOST test',
+ 'description' => 'Get the IP address from the current visitor from the server variables, check hostname validation.',
+ 'group' => 'Bootstrap'
+ );
+ }
+
+ function setUp() {
+ $this->oldserver = $_SERVER;
+
+ $this->remote_ip = '127.0.0.1';
+ $this->proxy_ip = '127.0.0.2';
+ $this->proxy2_ip = '127.0.0.3';
+ $this->forwarded_ip = '127.0.0.4';
+ $this->cluster_ip = '127.0.0.5';
+ $this->untrusted_ip = '0.0.0.0';
+
+ drupal_static_reset('ip_address');
+
+ $_SERVER['REMOTE_ADDR'] = $this->remote_ip;
+ unset($_SERVER['HTTP_X_FORWARDED_FOR']);
+ unset($_SERVER['HTTP_X_CLUSTER_CLIENT_IP']);
+
+ parent::setUp();
+ }
+
+ function tearDown() {
+ $_SERVER = $this->oldserver;
+ drupal_static_reset('ip_address');
+ parent::tearDown();
+ }
+
+ /**
+ * test IP Address and hostname
+ */
+ function testIPAddressHost() {
+ // Test the normal IP address.
+ $this->assertTrue(
+ ip_address() == $this->remote_ip,
+ t('Got remote IP address.')
+ );
+
+ // Proxy forwarding on but no proxy addresses defined.
+ variable_set('reverse_proxy', 1);
+ $this->assertTrue(
+ ip_address() == $this->remote_ip,
+ t('Proxy forwarding without trusted proxies got remote IP address.')
+ );
+
+ // Proxy forwarding on and proxy address not trusted.
+ variable_set('reverse_proxy_addresses', array($this->proxy_ip, $this->proxy2_ip));
+ drupal_static_reset('ip_address');
+ $_SERVER['REMOTE_ADDR'] = $this->untrusted_ip;
+ $this->assertTrue(
+ ip_address() == $this->untrusted_ip,
+ t('Proxy forwarding with untrusted proxy got remote IP address.')
+ );
+
+ // Proxy forwarding on and proxy address trusted.
+ $_SERVER['REMOTE_ADDR'] = $this->proxy_ip;
+ $_SERVER['HTTP_X_FORWARDED_FOR'] = $this->forwarded_ip;
+ drupal_static_reset('ip_address');
+ $this->assertTrue(
+ ip_address() == $this->forwarded_ip,
+ t('Proxy forwarding with trusted proxy got forwarded IP address.')
+ );
+
+ // Multi-tier architecture with comma separated values in header.
+ $_SERVER['REMOTE_ADDR'] = $this->proxy_ip;
+ $_SERVER['HTTP_X_FORWARDED_FOR'] = implode(', ', array($this->untrusted_ip, $this->forwarded_ip, $this->proxy2_ip));
+ drupal_static_reset('ip_address');
+ $this->assertTrue(
+ ip_address() == $this->forwarded_ip,
+ t('Proxy forwarding with trusted 2-tier proxy got forwarded IP address.')
+ );
+
+ // Custom client-IP header.
+ variable_set('reverse_proxy_header', 'HTTP_X_CLUSTER_CLIENT_IP');
+ $_SERVER['HTTP_X_CLUSTER_CLIENT_IP'] = $this->cluster_ip;
+ drupal_static_reset('ip_address');
+ $this->assertTrue(
+ ip_address() == $this->cluster_ip,
+ t('Cluster environment got cluster client IP.')
+ );
+
+ // Verifies that drupal_valid_http_host() prevents invalid characters.
+ $this->assertFalse(drupal_valid_http_host('security/.drupal.org:80'), t('HTTP_HOST with / is invalid'));
+ $this->assertFalse(drupal_valid_http_host('security\\.drupal.org:80'), t('HTTP_HOST with \\ is invalid'));
+ $this->assertFalse(drupal_valid_http_host('security<.drupal.org:80'), t('HTTP_HOST with &lt; is invalid'));
+ $this->assertFalse(drupal_valid_http_host('security..drupal.org:80'), t('HTTP_HOST with .. is invalid'));
+ // IPv6 loopback address
+ $this->assertTrue(drupal_valid_http_host('[::1]:80'), t('HTTP_HOST containing IPv6 loopback is valid'));
+ }
+}
+
+class BootstrapPageCacheTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Page cache test',
+ 'description' => 'Enable the page cache and test it with various HTTP requests.',
+ 'group' => 'Bootstrap'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+ }
+
+ /**
+ * Test support for requests containing If-Modified-Since and If-None-Match headers.
+ */
+ function testConditionalRequests() {
+ variable_set('cache', 1);
+
+ // Fill the cache.
+ $this->drupalGet('');
+
+ $this->drupalHead('');
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+ $etag = $this->drupalGetHeader('ETag');
+ $last_modified = $this->drupalGetHeader('Last-Modified');
+
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified, 'If-None-Match: ' . $etag));
+ $this->assertResponse(304, t('Conditional request returned 304 Not Modified.'));
+
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC822, strtotime($last_modified)), 'If-None-Match: ' . $etag));
+ $this->assertResponse(304, t('Conditional request with obsolete If-Modified-Since date returned 304 Not Modified.'));
+
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC850, strtotime($last_modified)), 'If-None-Match: ' . $etag));
+ $this->assertResponse(304, t('Conditional request with obsolete If-Modified-Since date returned 304 Not Modified.'));
+
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified));
+ $this->assertResponse(200, t('Conditional request without If-None-Match returned 200 OK.'));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . gmdate(DATE_RFC1123, strtotime($last_modified) + 1), 'If-None-Match: ' . $etag));
+ $this->assertResponse(200, t('Conditional request with new a If-Modified-Since date newer than Last-Modified returned 200 OK.'));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+
+ $user = $this->drupalCreateUser();
+ $this->drupalLogin($user);
+ $this->drupalGet('', array(), array('If-Modified-Since: ' . $last_modified, 'If-None-Match: ' . $etag));
+ $this->assertResponse(200, t('Conditional request returned 200 OK for authenticated user.'));
+ $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), t('Absense of Page was not cached.'));
+ }
+
+ /**
+ * Test cache headers.
+ */
+ function testPageCache() {
+ variable_set('cache', 1);
+
+ // Fill the cache.
+ $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Foo', 'value' => 'bar')));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', t('Page was not cached.'));
+ $this->assertEqual($this->drupalGetHeader('Vary'), 'Cookie,Accept-Encoding', t('Vary header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Cache-Control'), 'public, max-age=0', t('Cache-Control header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Expires'), 'Sun, 19 Nov 1978 05:00:00 GMT', t('Expires header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', t('Custom header was sent.'));
+
+ // Check cache.
+ $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Foo', 'value' => 'bar')));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+ $this->assertEqual($this->drupalGetHeader('Vary'), 'Cookie,Accept-Encoding', t('Vary: Cookie header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Cache-Control'), 'public, max-age=0', t('Cache-Control header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Expires'), 'Sun, 19 Nov 1978 05:00:00 GMT', t('Expires header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', t('Custom header was sent.'));
+
+ // Check replacing default headers.
+ $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Expires', 'value' => 'Fri, 19 Nov 2008 05:00:00 GMT')));
+ $this->assertEqual($this->drupalGetHeader('Expires'), 'Fri, 19 Nov 2008 05:00:00 GMT', t('Default header was replaced.'));
+ $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Vary', 'value' => 'User-Agent')));
+ $this->assertEqual($this->drupalGetHeader('Vary'), 'User-Agent,Accept-Encoding', t('Default header was replaced.'));
+
+ // Check that authenticated users bypass the cache.
+ $user = $this->drupalCreateUser();
+ $this->drupalLogin($user);
+ $this->drupalGet('system-test/set-header', array('query' => array('name' => 'Foo', 'value' => 'bar')));
+ $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), t('Caching was bypassed.'));
+ $this->assertTrue(strpos($this->drupalGetHeader('Vary'), 'Cookie') === FALSE, t('Vary: Cookie header was not sent.'));
+ $this->assertEqual($this->drupalGetHeader('Cache-Control'), 'no-cache, must-revalidate, post-check=0, pre-check=0', t('Cache-Control header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Expires'), 'Sun, 19 Nov 1978 05:00:00 GMT', t('Expires header was sent.'));
+ $this->assertEqual($this->drupalGetHeader('Foo'), 'bar', t('Custom header was sent.'));
+
+ }
+
+ /**
+ * Test page compression.
+ *
+ * The test should pass even if zlib.output_compression is enabled in php.ini,
+ * .htaccess or similar, or if compression is done outside PHP, e.g. by the
+ * mod_deflate Apache module.
+ */
+ function testPageCompression() {
+ variable_set('cache', 1);
+
+ // Fill the cache and verify that output is compressed.
+ $this->drupalGet('', array(), array('Accept-Encoding: gzip,deflate'));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', t('Page was not cached.'));
+ $this->drupalSetContent(gzinflate(substr($this->drupalGetContent(), 10, -8)));
+ $this->assertRaw('</html>', t('Page was gzip compressed.'));
+
+ // Verify that cached output is compressed.
+ $this->drupalGet('', array(), array('Accept-Encoding: gzip,deflate'));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+ $this->assertEqual($this->drupalGetHeader('Content-Encoding'), 'gzip', t('A Content-Encoding header was sent.'));
+ $this->drupalSetContent(gzinflate(substr($this->drupalGetContent(), 10, -8)));
+ $this->assertRaw('</html>', t('Page was gzip compressed.'));
+
+ // Verify that a client without compression support gets an uncompressed page.
+ $this->drupalGet('');
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+ $this->assertFalse($this->drupalGetHeader('Content-Encoding'), t('A Content-Encoding header was not sent.'));
+ $this->assertTitle(t('Welcome to @site-name | @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), t('Site title matches.'));
+ $this->assertRaw('</html>', t('Page was not compressed.'));
+ }
+}
+
+class BootstrapVariableTestCase extends DrupalWebTestCase {
+
+ function setUp() {
+ parent::setUp('system_test');
+ }
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Variable test',
+ 'description' => 'Make sure the variable system functions correctly.',
+ 'group' => 'Bootstrap'
+ );
+ }
+
+ /**
+ * testVariable
+ */
+ function testVariable() {
+ // Setting and retrieving values.
+ $variable = $this->randomName();
+ variable_set('simpletest_bootstrap_variable_test', $variable);
+ $this->assertIdentical($variable, variable_get('simpletest_bootstrap_variable_test'), t('Setting and retrieving values'));
+
+ // Make sure the variable persists across multiple requests.
+ $this->drupalGet('system-test/variable-get');
+ $this->assertText($variable, t('Variable persists across multiple requests'));
+
+ // Deleting variables.
+ $default_value = $this->randomName();
+ variable_del('simpletest_bootstrap_variable_test');
+ $variable = variable_get('simpletest_bootstrap_variable_test', $default_value);
+ $this->assertIdentical($variable, $default_value, t('Deleting variables'));
+ }
+
+ /**
+ * Makes sure that the default variable parameter is passed through okay.
+ */
+ function testVariableDefaults() {
+ // Tests passing nothing through to the default.
+ $this->assertIdentical(NULL, variable_get('simpletest_bootstrap_variable_test'), t('Variables are correctly defaulting to NULL.'));
+
+ // Tests passing 5 to the default parameter.
+ $this->assertIdentical(5, variable_get('simpletest_bootstrap_variable_test', 5), t('The default variable parameter is passed through correctly.'));
+ }
+
+}
+
+/**
+ * Test hook_boot() and hook_exit().
+ */
+class HookBootExitTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Boot and exit hook invocation',
+ 'description' => 'Test that hook_boot() and hook_exit() are called correctly.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test', 'dblog');
+ }
+
+ /**
+ * Test calling of hook_boot() and hook_exit().
+ */
+ function testHookBootExit() {
+ // Test with cache disabled. Boot and exit should always fire.
+ variable_set('cache', 0);
+ $this->drupalGet('');
+ $calls = 1;
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_boot'))->fetchField(), $calls, t('hook_boot called with disabled cache.'));
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_exit'))->fetchField(), $calls, t('hook_exit called with disabled cache.'));
+
+ // Test with normal cache. Boot and exit should be called.
+ variable_set('cache', 1);
+ $this->drupalGet('');
+ $calls++;
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_boot'))->fetchField(), $calls, t('hook_boot called with normal cache.'));
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_exit'))->fetchField(), $calls, t('hook_exit called with normal cache.'));
+
+ // Boot and exit should not fire since the page is cached.
+ variable_set('page_cache_invoke_hooks', FALSE);
+ $this->assertTrue(cache('page')->get(url('', array('absolute' => TRUE))), t('Page has been cached.'));
+ $this->drupalGet('');
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_boot'))->fetchField(), $calls, t('hook_boot not called with aggressive cache and a cached page.'));
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_exit'))->fetchField(), $calls, t('hook_exit not called with aggressive cache and a cached page.'));
+
+ // Test with page cache cleared, boot and exit should be called.
+ $this->assertTrue(db_delete('cache_page')->execute(), t('Page cache cleared.'));
+ $this->drupalGet('');
+ $calls++;
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_boot'))->fetchField(), $calls, t('hook_boot called with aggressive cache and no cached page.'));
+ $this->assertEqual(db_query('SELECT COUNT(*) FROM {watchdog} WHERE type = :type AND message = :message', array(':type' => 'system_test', ':message' => 'hook_exit'))->fetchField(), $calls, t('hook_exit called with aggressive cache and no cached page.'));
+ }
+}
+
+/**
+ * Test drupal_get_filename()'s availability.
+ */
+class BootstrapGetFilenameTestCase extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Get filename test',
+ 'description' => 'Test that drupal_get_filename() works correctly when the file is not found in the database.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ /**
+ * Test that drupal_get_filename() works correctly when the file is not found in the database.
+ */
+ function testDrupalGetFilename() {
+ // Reset the static cache so we can test the "db is not active" code of
+ // drupal_get_filename().
+ drupal_static_reset('drupal_get_filename');
+
+ // Retrieving the location of a module.
+ $this->assertIdentical(drupal_get_filename('module', 'php'), 'core/modules/php/php.module', t('Retrieve module location.'));
+
+ // Retrieving the location of a theme.
+ $this->assertIdentical(drupal_get_filename('theme', 'stark'), 'core/themes/stark/stark.info', t('Retrieve theme location.'));
+
+ // Retrieving the location of a theme engine.
+ $this->assertIdentical(drupal_get_filename('theme_engine', 'phptemplate'), 'core/themes/engines/phptemplate/phptemplate.engine', t('Retrieve theme engine location.'));
+
+ // @todo: This test is broken because drupal_get_filename() does not work
+ // with profiles at all. See this core issue: http://drupal.org/node/1006714
+
+ // Retrieving a file that is definitely not stored in the database.
+ //$this->assertIdentical(drupal_get_filename('profile', 'standard'), 'profiles/standard/standard.profile', t('Retrieve install profile location.'));
+ }
+}
+
+class BootstrapTimerTestCase extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Timer test',
+ 'description' => 'Test that timer_read() works both when a timer is running and when a timer is stopped.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ /**
+ * Test timer_read() to ensure it properly accumulates time when the timer
+ * started and stopped multiple times.
+ * @return
+ */
+ function testTimer() {
+ timer_start('test');
+ sleep(1);
+ $this->assertTrue(timer_read('test') >= 1000, t('Timer measured 1 second of sleeping while running.'));
+ sleep(1);
+ timer_stop('test');
+ $this->assertTrue(timer_read('test') >= 2000, t('Timer measured 2 seconds of sleeping after being stopped.'));
+ timer_start('test');
+ sleep(1);
+ $this->assertTrue(timer_read('test') >= 3000, t('Timer measured 3 seconds of sleeping after being restarted.'));
+ sleep(1);
+ $timer = timer_stop('test');
+ $this->assertTrue(timer_read('test') >= 4000, t('Timer measured 4 seconds of sleeping after being stopped for a second time.'));
+ $this->assertEqual($timer['count'], 2, t('Timer counted 2 instances of being started.'));
+ }
+}
+
+/**
+ * Test that resetting static variables works.
+ */
+class BootstrapResettableStaticTestCase extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Resettable static variables test',
+ 'description' => 'Test that drupal_static() and drupal_static_reset() work.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ /**
+ * Test that a variable reference returned by drupal_static() gets reset when
+ * drupal_static_reset() is called.
+ */
+ function testDrupalStatic() {
+ $name = __CLASS__ . '_' . __METHOD__;
+ $var = &drupal_static($name, 'foo');
+ $this->assertEqual($var, 'foo', t('Variable returned by drupal_static() was set to its default.'));
+
+ // Call the specific reset and the global reset each twice to ensure that
+ // multiple resets can be issued without odd side effects.
+ $var = 'bar';
+ drupal_static_reset($name);
+ $this->assertEqual($var, 'foo', t('Variable was reset after first invocation of name-specific reset.'));
+ $var = 'bar';
+ drupal_static_reset($name);
+ $this->assertEqual($var, 'foo', t('Variable was reset after second invocation of name-specific reset.'));
+ $var = 'bar';
+ drupal_static_reset();
+ $this->assertEqual($var, 'foo', t('Variable was reset after first invocation of global reset.'));
+ $var = 'bar';
+ drupal_static_reset();
+ $this->assertEqual($var, 'foo', t('Variable was reset after second invocation of global reset.'));
+ }
+}
+
+/**
+ * Test miscellaneous functions in bootstrap.inc.
+ */
+class BootstrapMiscTestCase extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Miscellaneous bootstrap unit tests',
+ 'description' => 'Test miscellaneous functions in bootstrap.inc.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ /**
+ * Test miscellaneous functions in bootstrap.inc.
+ */
+ function testMisc() {
+ // Test drupal_array_merge_deep().
+ $link_options_1 = array('fragment' => 'x', 'attributes' => array('title' => 'X', 'class' => array('a', 'b')), 'language' => 'en');
+ $link_options_2 = array('fragment' => 'y', 'attributes' => array('title' => 'Y', 'class' => array('c', 'd')), 'html' => TRUE);
+ $expected = array('fragment' => 'y', 'attributes' => array('title' => 'Y', 'class' => array('a', 'b', 'c', 'd')), 'language' => 'en', 'html' => TRUE);
+ $this->assertIdentical(drupal_array_merge_deep($link_options_1, $link_options_2), $expected, t('drupal_array_merge_deep() returned a properly merged array.'));
+ }
+}
+
+/**
+ * Tests for overriding server variables via the API.
+ */
+class BootstrapOverrideServerVariablesTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Overriding server variables',
+ 'description' => 'Test that drupal_override_server_variables() works correctly.',
+ 'group' => 'Bootstrap',
+ );
+ }
+
+ /**
+ * Test providing a direct URL to to drupal_override_server_variables().
+ */
+ function testDrupalOverrideServerVariablesProvidedURL() {
+ $tests = array(
+ 'http://example.com' => array(
+ 'HTTP_HOST' => 'example.com',
+ 'SCRIPT_NAME' => isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : NULL,
+ ),
+ 'http://example.com/index.php' => array(
+ 'HTTP_HOST' => 'example.com',
+ 'SCRIPT_NAME' => '/index.php',
+ ),
+ 'http://example.com/subdirectory/index.php' => array(
+ 'HTTP_HOST' => 'example.com',
+ 'SCRIPT_NAME' => '/subdirectory/index.php',
+ ),
+ );
+ foreach ($tests as $url => $expected_server_values) {
+ // Remember the original value of $_SERVER, since the function call below
+ // will modify it.
+ $original_server = $_SERVER;
+ // Call drupal_override_server_variables() and ensure that all expected
+ // $_SERVER variables were modified correctly.
+ drupal_override_server_variables(array('url' => $url));
+ foreach ($expected_server_values as $key => $value) {
+ $this->assertIdentical($_SERVER[$key], $value);
+ }
+ // Restore the original value of $_SERVER.
+ $_SERVER = $original_server;
+ }
+ }
+}
+
diff --git a/core/modules/simpletest/tests/cache.test b/core/modules/simpletest/tests/cache.test
new file mode 100644
index 000000000000..664247b8ab58
--- /dev/null
+++ b/core/modules/simpletest/tests/cache.test
@@ -0,0 +1,375 @@
+<?php
+
+class CacheTestCase extends DrupalWebTestCase {
+ protected $default_bin = 'cache_page';
+ protected $default_cid = 'test_temporary';
+ protected $default_value = 'CacheTest';
+
+ /**
+ * Check whether or not a cache entry exists.
+ *
+ * @param $cid
+ * The cache id.
+ * @param $var
+ * The variable the cache should contain.
+ * @param $bin
+ * The bin the cache item was stored in.
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ protected function checkCacheExists($cid, $var, $bin = NULL) {
+ if ($bin == NULL) {
+ $bin = $this->default_bin;
+ }
+
+ $cached = cache($bin)->get($cid);
+
+ return isset($cached->data) && $cached->data == $var;
+ }
+
+ /**
+ * Assert or a cache entry exists.
+ *
+ * @param $message
+ * Message to display.
+ * @param $var
+ * The variable the cache should contain.
+ * @param $cid
+ * The cache id.
+ * @param $bin
+ * The bin the cache item was stored in.
+ */
+ protected function assertCacheExists($message, $var = NULL, $cid = NULL, $bin = NULL) {
+ if ($bin == NULL) {
+ $bin = $this->default_bin;
+ }
+ if ($cid == NULL) {
+ $cid = $this->default_cid;
+ }
+ if ($var == NULL) {
+ $var = $this->default_value;
+ }
+
+ $this->assertTrue($this->checkCacheExists($cid, $var, $bin), $message);
+ }
+
+ /**
+ * Assert or a cache entry has been removed.
+ *
+ * @param $message
+ * Message to display.
+ * @param $cid
+ * The cache id.
+ * @param $bin
+ * The bin the cache item was stored in.
+ */
+ function assertCacheRemoved($message, $cid = NULL, $bin = NULL) {
+ if ($bin == NULL) {
+ $bin = $this->default_bin;
+ }
+ if ($cid == NULL) {
+ $cid = $this->default_cid;
+ }
+
+ $cached = cache($bin)->get($cid);
+ $this->assertFalse($cached, $message);
+ }
+
+ /**
+ * Perform the general wipe.
+ * @param $bin
+ * The bin to perform the wipe on.
+ */
+ protected function generalWipe($bin = NULL) {
+ if ($bin == NULL) {
+ $bin = $this->default_bin;
+ }
+
+ cache($bin)->expire();
+ }
+
+ /**
+ * Setup the lifetime settings for caching.
+ *
+ * @param $time
+ * The time in seconds the cache should minimal live.
+ */
+ protected function setupLifetime($time) {
+ variable_set('cache_lifetime', $time);
+ variable_set('cache_flush', 0);
+ }
+}
+
+class CacheSavingCase extends CacheTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cache saving test',
+ 'description' => 'Check our variables are saved and restored the right way.',
+ 'group' => 'Cache'
+ );
+ }
+
+ /**
+ * Test the saving and restoring of a string.
+ */
+ function testString() {
+ $this->checkVariable($this->randomName(100));
+ }
+
+ /**
+ * Test the saving and restoring of an integer.
+ */
+ function testInteger() {
+ $this->checkVariable(100);
+ }
+
+ /**
+ * Test the saving and restoring of a double.
+ */
+ function testDouble() {
+ $this->checkVariable(1.29);
+ }
+
+ /**
+ * Test the saving and restoring of an array.
+ */
+ function testArray() {
+ $this->checkVariable(array('drupal1', 'drupal2' => 'drupal3', 'drupal4' => array('drupal5', 'drupal6')));
+ }
+
+ /**
+ * Test the saving and restoring of an object.
+ */
+ function testObject() {
+ $test_object = new stdClass();
+ $test_object->test1 = $this->randomName(100);
+ $test_object->test2 = 100;
+ $test_object->test3 = array('drupal1', 'drupal2' => 'drupal3', 'drupal4' => array('drupal5', 'drupal6'));
+
+
+ cache()->set('test_object', $test_object);
+ $cached = cache()->get('test_object');
+ $this->assertTrue(isset($cached->data) && $cached->data == $test_object, t('Object is saved and restored properly.'));
+ }
+
+ /**
+ * Check or a variable is stored and restored properly.
+ */
+ function checkVariable($var) {
+ cache()->set('test_var', $var);
+ $cached = cache()->get('test_var');
+ $this->assertTrue(isset($cached->data) && $cached->data === $var, t('@type is saved and restored properly.', array('@type' => ucfirst(gettype($var)))));
+ }
+
+ /**
+ * Test no empty cids are written in cache table.
+ */
+ function testNoEmptyCids() {
+ $this->drupalGet('user/register');
+ $this->assertFalse(cache_get(''), t('No cache entry is written with an empty cid.'));
+ }
+}
+
+/**
+ * Test getMultiple().
+ */
+class CacheGetMultipleUnitTest extends CacheTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Fetching multiple cache items',
+ 'description' => 'Confirm that multiple records are fetched correctly.',
+ 'group' => 'Cache',
+ );
+ }
+
+ function setUp() {
+ $this->default_bin = 'page';
+ parent::setUp();
+ }
+
+ /**
+ * Test getMultiple().
+ */
+ function testCacheMultiple() {
+ $item1 = $this->randomName(10);
+ $item2 = $this->randomName(10);
+ $cache = cache($this->default_bin);
+ $cache->set('item1', $item1);
+ $cache->set('item2', $item2);
+ $this->assertTrue($this->checkCacheExists('item1', $item1), t('Item 1 is cached.'));
+ $this->assertTrue($this->checkCacheExists('item2', $item2), t('Item 2 is cached.'));
+
+ // Fetch both records from the database with getMultiple().
+ $item_ids = array('item1', 'item2');
+ $items = $cache->getMultiple($item_ids);
+ $this->assertEqual($items['item1']->data, $item1, t('Item was returned from cache successfully.'));
+ $this->assertEqual($items['item2']->data, $item2, t('Item was returned from cache successfully.'));
+
+ // Remove one item from the cache.
+ $cache->delete('item2');
+
+ // Confirm that only one item is returned by getMultiple().
+ $item_ids = array('item1', 'item2');
+ $items = $cache->getMultiple($item_ids);
+ $this->assertEqual($items['item1']->data, $item1, t('Item was returned from cache successfully.'));
+ $this->assertFalse(isset($items['item2']), t('Item was not returned from the cache.'));
+ $this->assertTrue(count($items) == 1, t('Only valid cache entries returned.'));
+ }
+}
+
+/**
+ * Test cache clearing methods.
+ */
+class CacheClearCase extends CacheTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cache clear test',
+ 'description' => 'Check our clearing is done the proper way.',
+ 'group' => 'Cache'
+ );
+ }
+
+ function setUp() {
+ $this->default_bin = 'page';
+ $this->default_value = $this->randomName(10);
+
+ parent::setUp();
+ }
+
+ /**
+ * Test clearing using a cid.
+ */
+ function testClearCid() {
+ $cache = cache($this->default_bin);
+ $cache->set('test_cid_clear', $this->default_value);
+
+ $this->assertCacheExists(t('Cache was set for clearing cid.'), $this->default_value, 'test_cid_clear');
+ $cache->delete('test_cid_clear');
+
+ $this->assertCacheRemoved(t('Cache was removed after clearing cid.'), 'test_cid_clear');
+ }
+
+ /**
+ * Test clearing using wildcard.
+ */
+ function testClearWildcard() {
+ $cache = cache($this->default_bin);
+ $cache->set('test_cid_clear1', $this->default_value);
+ $cache->set('test_cid_clear2', $this->default_value);
+ $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ && $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two caches were created for checking cid "*" with wildcard true.'));
+ $cache->flush();
+ $this->assertFalse($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ || $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two caches removed after clearing cid "*" with wildcard true.'));
+
+ $cache->set('test_cid_clear1', $this->default_value);
+ $cache->set('test_cid_clear2', $this->default_value);
+ $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ && $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two caches were created for checking cid substring with wildcard true.'));
+ $cache->deletePrefix('test_');
+ $this->assertFalse($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ || $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two caches removed after clearing cid substring with wildcard true.'));
+ }
+
+ /**
+ * Test clearing using an array.
+ */
+ function testClearArray() {
+ // Create three cache entries.
+ $cache = cache($this->default_bin);
+ $cache->set('test_cid_clear1', $this->default_value);
+ $cache->set('test_cid_clear2', $this->default_value);
+ $cache->set('test_cid_clear3', $this->default_value);
+ $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ && $this->checkCacheExists('test_cid_clear2', $this->default_value)
+ && $this->checkCacheExists('test_cid_clear3', $this->default_value),
+ t('Three cache entries were created.'));
+
+ // Clear two entries using an array.
+ $cache->deleteMultiple(array('test_cid_clear1', 'test_cid_clear2'));
+ $this->assertFalse($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ || $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two cache entries removed after clearing with an array.'));
+
+ $this->assertTrue($this->checkCacheExists('test_cid_clear3', $this->default_value),
+ t('Entry was not cleared from the cache'));
+
+ // Set the cache clear threshold to 2 to confirm that the full bin is cleared
+ // when the threshold is exceeded.
+ variable_set('cache_clear_threshold', 2);
+ $cache->set('test_cid_clear1', $this->default_value);
+ $cache->set('test_cid_clear2', $this->default_value);
+ $this->assertTrue($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ && $this->checkCacheExists('test_cid_clear2', $this->default_value),
+ t('Two cache entries were created.'));
+ $cache->deleteMultiple(array('test_cid_clear1', 'test_cid_clear2', 'test_cid_clear3'));
+ $this->assertFalse($this->checkCacheExists('test_cid_clear1', $this->default_value)
+ || $this->checkCacheExists('test_cid_clear2', $this->default_value)
+ || $this->checkCacheExists('test_cid_clear3', $this->default_value),
+ t('All cache entries removed when the array exceeded the cache clear threshold.'));
+ }
+
+ /**
+ * Test drupal_flush_all_caches().
+ */
+ function testFlushAllCaches() {
+ // Create cache entries for each flushed cache bin.
+ $bins = array('cache', 'filter', 'page', 'bootstrap', 'path');
+ $bins = array_merge(module_invoke_all('flush_caches'), $bins);
+ foreach ($bins as $id => $bin) {
+ $cid = 'test_cid_clear' . $id;
+ cache($bin)->set($cid, $this->default_value);
+ }
+
+ // Remove all caches then make sure that they are cleared.
+ drupal_flush_all_caches();
+
+ foreach ($bins as $id => $bin) {
+ $cid = 'test_cid_clear' . $id;
+ $this->assertFalse($this->checkCacheExists($cid, $this->default_value, $bin), t('All cache entries removed from @bin.', array('@bin' => $bin)));
+ }
+ }
+}
+
+/**
+ * Test isEmpty() method.
+ */
+class CacheIsEmptyCase extends CacheTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cache emptiness test',
+ 'description' => 'Check if a cache bin is empty after performing clear operations.',
+ 'group' => 'Cache'
+ );
+ }
+
+ function setUp() {
+ $this->default_bin = 'page';
+ $this->default_value = $this->randomName(10);
+
+ parent::setUp();
+ }
+
+ /**
+ * Test clearing using a cid.
+ */
+ function testIsEmpty() {
+ // Clear the cache bin.
+ $cache = cache($this->default_bin);
+ $cache->flush();
+ $this->assertTrue($cache->isEmpty(), t('The cache bin is empty'));
+ // Add some data to the cache bin.
+ $cache->set($this->default_cid, $this->default_value);
+ $this->assertCacheExists(t('Cache was set.'), $this->default_value, $this->default_cid);
+ $this->assertFalse($cache->isEmpty(), t('The cache bin is not empty'));
+ // Remove the cached data.
+ $cache->delete($this->default_cid);
+ $this->assertCacheRemoved(t('Cache was removed.'), $this->default_cid);
+ $this->assertTrue($cache->isEmpty(), t('The cache bin is empty'));
+ }
+}
diff --git a/core/modules/simpletest/tests/common.test b/core/modules/simpletest/tests/common.test
new file mode 100644
index 000000000000..7a68f440de89
--- /dev/null
+++ b/core/modules/simpletest/tests/common.test
@@ -0,0 +1,2470 @@
+<?php
+
+/**
+ * @file
+ * Tests for common.inc functionality.
+ */
+
+/**
+ * Tests for URL generation functions.
+ */
+class DrupalAlterTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'drupal_alter() tests',
+ 'description' => 'Confirm that alteration of arguments passed to drupal_alter() works correctly.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('common_test');
+ }
+
+ function testDrupalAlter() {
+ // This test depends on Bartik, so make sure that it is always the current
+ // active theme.
+ global $theme, $base_theme_info;
+ $theme = 'bartik';
+ $base_theme_info = array();
+
+ $array = array('foo' => 'bar');
+ $entity = new stdClass();
+ $entity->foo = 'bar';
+
+ // Verify alteration of a single argument.
+ $array_copy = $array;
+ $array_expected = array('foo' => 'Drupal theme');
+ drupal_alter('drupal_alter', $array_copy);
+ $this->assertEqual($array_copy, $array_expected, t('Single array was altered.'));
+
+ $entity_copy = clone $entity;
+ $entity_expected = clone $entity;
+ $entity_expected->foo = 'Drupal theme';
+ drupal_alter('drupal_alter', $entity_copy);
+ $this->assertEqual($entity_copy, $entity_expected, t('Single object was altered.'));
+
+ // Verify alteration of multiple arguments.
+ $array_copy = $array;
+ $array_expected = array('foo' => 'Drupal theme');
+ $entity_copy = clone $entity;
+ $entity_expected = clone $entity;
+ $entity_expected->foo = 'Drupal theme';
+ $array2_copy = $array;
+ $array2_expected = array('foo' => 'Drupal theme');
+ drupal_alter('drupal_alter', $array_copy, $entity_copy, $array2_copy);
+ $this->assertEqual($array_copy, $array_expected, t('First argument to drupal_alter() was altered.'));
+ $this->assertEqual($entity_copy, $entity_expected, t('Second argument to drupal_alter() was altered.'));
+ $this->assertEqual($array2_copy, $array2_expected, t('Third argument to drupal_alter() was altered.'));
+ }
+}
+
+/**
+ * Tests for URL generation functions.
+ *
+ * url() calls module_implements(), which may issue a db query, which requires
+ * inheriting from a web test case rather than a unit test case.
+ */
+class CommonURLUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'URL generation tests',
+ 'description' => 'Confirm that url(), drupal_get_query_parameters(), drupal_http_build_query(), and l() work correctly with various input.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Confirm that invalid text given as $path is filtered.
+ */
+ function testLXSS() {
+ $text = $this->randomName();
+ $path = "<SCRIPT>alert('XSS')</SCRIPT>";
+ $link = l($text, $path);
+ $sanitized_path = check_url(url($path));
+ $this->assertTrue(strpos($link, $sanitized_path) !== FALSE, t('XSS attack @path was filtered', array('@path' => $path)));
+ }
+
+ /*
+ * Tests for active class in l() function.
+ */
+ function testLActiveClass() {
+ $link = l($this->randomName(), $_GET['q']);
+ $this->assertTrue($this->hasClass($link, 'active'), t('Class @class is present on link to the current page', array('@class' => 'active')));
+ }
+
+ /**
+ * Tests for custom class in l() function.
+ */
+ function testLCustomClass() {
+ $class = $this->randomName();
+ $link = l($this->randomName(), $_GET['q'], array('attributes' => array('class' => array($class))));
+ $this->assertTrue($this->hasClass($link, $class), t('Custom class @class is present on link when requested', array('@class' => $class)));
+ $this->assertTrue($this->hasClass($link, 'active'), t('Class @class is present on link to the current page', array('@class' => 'active')));
+ }
+
+ private function hasClass($link, $class) {
+ return preg_match('|class="([^\"\s]+\s+)*' . $class . '|', $link);
+ }
+
+ /**
+ * Test drupal_get_query_parameters().
+ */
+ function testDrupalGetQueryParameters() {
+ $original = array(
+ 'a' => 1,
+ 'b' => array(
+ 'd' => 4,
+ 'e' => array(
+ 'f' => 5,
+ ),
+ ),
+ 'c' => 3,
+ 'q' => 'foo/bar',
+ );
+
+ // Default arguments.
+ $result = $_GET;
+ unset($result['q']);
+ $this->assertEqual(drupal_get_query_parameters(), $result, t("\$_GET['q'] was removed."));
+
+ // Default exclusion.
+ $result = $original;
+ unset($result['q']);
+ $this->assertEqual(drupal_get_query_parameters($original), $result, t("'q' was removed."));
+
+ // First-level exclusion.
+ $result = $original;
+ unset($result['b']);
+ $this->assertEqual(drupal_get_query_parameters($original, array('b')), $result, t("'b' was removed."));
+
+ // Second-level exclusion.
+ $result = $original;
+ unset($result['b']['d']);
+ $this->assertEqual(drupal_get_query_parameters($original, array('b[d]')), $result, t("'b[d]' was removed."));
+
+ // Third-level exclusion.
+ $result = $original;
+ unset($result['b']['e']['f']);
+ $this->assertEqual(drupal_get_query_parameters($original, array('b[e][f]')), $result, t("'b[e][f]' was removed."));
+
+ // Multiple exclusions.
+ $result = $original;
+ unset($result['a'], $result['b']['e'], $result['c']);
+ $this->assertEqual(drupal_get_query_parameters($original, array('a', 'b[e]', 'c')), $result, t("'a', 'b[e]', 'c' were removed."));
+ }
+
+ /**
+ * Test drupal_http_build_query().
+ */
+ function testDrupalHttpBuildQuery() {
+ $this->assertEqual(drupal_http_build_query(array('a' => ' &#//+%20@۞')), 'a=%20%26%23//%2B%2520%40%DB%9E', t('Value was properly encoded.'));
+ $this->assertEqual(drupal_http_build_query(array(' &#//+%20@۞' => 'a')), '%20%26%23%2F%2F%2B%2520%40%DB%9E=a', t('Key was properly encoded.'));
+ $this->assertEqual(drupal_http_build_query(array('a' => '1', 'b' => '2', 'c' => '3')), 'a=1&b=2&c=3', t('Multiple values were properly concatenated.'));
+ $this->assertEqual(drupal_http_build_query(array('a' => array('b' => '2', 'c' => '3'), 'd' => 'foo')), 'a[b]=2&a[c]=3&d=foo', t('Nested array was properly encoded.'));
+ }
+
+ /**
+ * Test drupal_parse_url().
+ */
+ function testDrupalParseUrl() {
+ // Relative URL.
+ $url = 'foo/bar?foo=bar&bar=baz&baz#foo';
+ $result = array(
+ 'path' => 'foo/bar',
+ 'query' => array('foo' => 'bar', 'bar' => 'baz', 'baz' => ''),
+ 'fragment' => 'foo',
+ );
+ $this->assertEqual(drupal_parse_url($url), $result, t('Relative URL parsed correctly.'));
+
+ // Relative URL that is known to confuse parse_url().
+ $url = 'foo/bar:1';
+ $result = array(
+ 'path' => 'foo/bar:1',
+ 'query' => array(),
+ 'fragment' => '',
+ );
+ $this->assertEqual(drupal_parse_url($url), $result, t('Relative URL parsed correctly.'));
+
+ // Absolute URL.
+ $url = '/foo/bar?foo=bar&bar=baz&baz#foo';
+ $result = array(
+ 'path' => '/foo/bar',
+ 'query' => array('foo' => 'bar', 'bar' => 'baz', 'baz' => ''),
+ 'fragment' => 'foo',
+ );
+ $this->assertEqual(drupal_parse_url($url), $result, t('Absolute URL parsed correctly.'));
+
+ // External URL testing.
+ $url = 'http://drupal.org/foo/bar?foo=bar&bar=baz&baz#foo';
+
+ // Test that drupal can recognize an absolute URL. Used to prevent attack vectors.
+ $this->assertTrue(url_is_external($url), t('Correctly identified an external URL.'));
+
+ // Test the parsing of absolute URLs.
+ $result = array(
+ 'path' => 'http://drupal.org/foo/bar',
+ 'query' => array('foo' => 'bar', 'bar' => 'baz', 'baz' => ''),
+ 'fragment' => 'foo',
+ );
+ $this->assertEqual(drupal_parse_url($url), $result, t('External URL parsed correctly.'));
+
+ // Verify proper parsing of URLs when clean URLs are disabled.
+ $result = array(
+ 'path' => 'foo/bar',
+ 'query' => array('bar' => 'baz'),
+ 'fragment' => 'foo',
+ );
+ // Non-clean URLs #1: Absolute URL generated by url().
+ $url = $GLOBALS['base_url'] . '/?q=foo/bar&bar=baz#foo';
+ $this->assertEqual(drupal_parse_url($url), $result, t('Absolute URL with clean URLs disabled parsed correctly.'));
+
+ // Non-clean URLs #2: Relative URL generated by url().
+ $url = '?q=foo/bar&bar=baz#foo';
+ $this->assertEqual(drupal_parse_url($url), $result, t('Relative URL with clean URLs disabled parsed correctly.'));
+
+ // Non-clean URLs #3: URL generated by url() on non-Apache webserver.
+ $url = 'index.php?q=foo/bar&bar=baz#foo';
+ $this->assertEqual(drupal_parse_url($url), $result, t('Relative URL on non-Apache webserver with clean URLs disabled parsed correctly.'));
+
+ // Test that drupal_parse_url() does not allow spoofing a URL to force a malicious redirect.
+ $parts = drupal_parse_url('forged:http://cwe.mitre.org/data/definitions/601.html');
+ $this->assertFalse(valid_url($parts['path'], TRUE), t('drupal_parse_url() correctly parsed a forged URL.'));
+ }
+
+ /**
+ * Test url() with/without query, with/without fragment, absolute on/off and
+ * assert all that works when clean URLs are on and off.
+ */
+ function testUrl() {
+ global $base_url;
+
+ foreach (array(FALSE, TRUE) as $absolute) {
+ // Get the expected start of the path string.
+ $base = $absolute ? $base_url . '/' : base_path();
+ $absolute_string = $absolute ? 'absolute' : NULL;
+
+ // Disable Clean URLs.
+ $GLOBALS['conf']['clean_url'] = 0;
+
+ $url = $base . '?q=node/123';
+ $result = url('node/123', array('absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123#foo';
+ $result = url('node/123', array('fragment' => 'foo', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123&foo';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123&foo=bar&bar=baz';
+ $result = url('node/123', array('query' => array('foo' => 'bar', 'bar' => 'baz'), 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123&foo#bar';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'fragment' => 'bar', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123&foo#0';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'fragment' => '0', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . '?q=node/123&foo';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'fragment' => '', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base;
+ $result = url('<front>', array('absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ // Enable Clean URLs.
+ $GLOBALS['conf']['clean_url'] = 1;
+
+ $url = $base . 'node/123';
+ $result = url('node/123', array('absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . 'node/123#foo';
+ $result = url('node/123', array('fragment' => 'foo', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . 'node/123?foo';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . 'node/123?foo=bar&bar=baz';
+ $result = url('node/123', array('query' => array('foo' => 'bar', 'bar' => 'baz'), 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base . 'node/123?foo#bar';
+ $result = url('node/123', array('query' => array('foo' => NULL), 'fragment' => 'bar', 'absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+
+ $url = $base;
+ $result = url('<front>', array('absolute' => $absolute));
+ $this->assertEqual($url, $result, "$url == $result");
+ }
+ }
+
+ /**
+ * Test external URL handling.
+ */
+ function testExternalUrls() {
+ $test_url = 'http://drupal.org/';
+
+ // Verify external URL can contain a fragment.
+ $url = $test_url . '#drupal';
+ $result = url($url);
+ $this->assertEqual($url, $result, t('External URL with fragment works without a fragment in $options.'));
+
+ // Verify fragment can be overidden in an external URL.
+ $url = $test_url . '#drupal';
+ $fragment = $this->randomName(10);
+ $result = url($url, array('fragment' => $fragment));
+ $this->assertEqual($test_url . '#' . $fragment, $result, t('External URL fragment is overidden with a custom fragment in $options.'));
+
+ // Verify external URL can contain a query string.
+ $url = $test_url . '?drupal=awesome';
+ $result = url($url);
+ $this->assertEqual($url, $result, t('External URL with query string works without a query string in $options.'));
+
+ // Verify external URL can be extended with a query string.
+ $url = $test_url;
+ $query = array($this->randomName(5) => $this->randomName(5));
+ $result = url($url, array('query' => $query));
+ $this->assertEqual($url . '?' . http_build_query($query, '', '&'), $result, t('External URL can be extended with a query string in $options.'));
+
+ // Verify query string can be extended in an external URL.
+ $url = $test_url . '?drupal=awesome';
+ $query = array($this->randomName(5) => $this->randomName(5));
+ $result = url($url, array('query' => $query));
+ $this->assertEqual($url . '&' . http_build_query($query, '', '&'), $result, t('External URL query string can be extended with a custom query string in $options.'));
+ }
+}
+
+/**
+ * Tests for the check_plain(), filter_xss() and format_string() functions.
+ */
+class CommonXssUnitTest extends DrupalUnitTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'String filtering tests',
+ 'description' => 'Confirm that check_plain(), filter_xss(), format_string() and check_url() work correctly, including invalid multi-byte sequences.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Check that invalid multi-byte sequences are rejected.
+ */
+ function testInvalidMultiByte() {
+ // Ignore PHP 5.3+ invalid multibyte sequence warning.
+ $text = @check_plain("Foo\xC0barbaz");
+ $this->assertEqual($text, '', 'check_plain() rejects invalid sequence "Foo\xC0barbaz"');
+ // Ignore PHP 5.3+ invalid multibyte sequence warning.
+ $text = @check_plain("\xc2\"");
+ $this->assertEqual($text, '', 'check_plain() rejects invalid sequence "\xc2\""');
+ $text = check_plain("Fooÿñ");
+ $this->assertEqual($text, "Fooÿñ", 'check_plain() accepts valid sequence "Fooÿñ"');
+ $text = filter_xss("Foo\xC0barbaz");
+ $this->assertEqual($text, '', 'filter_xss() rejects invalid sequence "Foo\xC0barbaz"');
+ $text = filter_xss("Fooÿñ");
+ $this->assertEqual($text, "Fooÿñ", 'filter_xss() accepts valid sequence Fooÿñ');
+ }
+
+ /**
+ * Check that special characters are escaped.
+ */
+ function testEscaping() {
+ $text = check_plain("<script>");
+ $this->assertEqual($text, '&lt;script&gt;', 'check_plain() escapes &lt;script&gt;');
+ $text = check_plain('<>&"\'');
+ $this->assertEqual($text, '&lt;&gt;&amp;&quot;&#039;', 'check_plain() escapes reserved HTML characters.');
+ }
+
+ /**
+ * Test t() and format_string() replacement functionality.
+ */
+ function testFormatStringAndT() {
+ foreach (array('format_string', 't') as $function) {
+ $text = $function('Simple text');
+ $this->assertEqual($text, 'Simple text', $function . ' leaves simple text alone.');
+ $text = $function('Escaped text: @value', array('@value' => '<script>'));
+ $this->assertEqual($text, 'Escaped text: &lt;script&gt;', $function . ' replaces and escapes string.');
+ $text = $function('Placeholder text: %value', array('%value' => '<script>'));
+ $this->assertEqual($text, 'Placeholder text: <em class="placeholder">&lt;script&gt;</em>', $function . ' replaces, escapes and themes string.');
+ $text = $function('Verbatim text: !value', array('!value' => '<script>'));
+ $this->assertEqual($text, 'Verbatim text: <script>', $function . ' replaces verbatim string as-is.');
+ }
+ }
+
+ /**
+ * Check that harmful protocols are stripped.
+ */
+ function testBadProtocolStripping() {
+ // Ensure that check_url() strips out harmful protocols, and encodes for
+ // HTML. Ensure drupal_strip_dangerous_protocols() can be used to return a
+ // plain-text string stripped of harmful protocols.
+ $url = 'javascript:http://www.example.com/?x=1&y=2';
+ $expected_plain = 'http://www.example.com/?x=1&y=2';
+ $expected_html = 'http://www.example.com/?x=1&amp;y=2';
+ $this->assertIdentical(check_url($url), $expected_html, t('check_url() filters a URL and encodes it for HTML.'));
+ $this->assertIdentical(drupal_strip_dangerous_protocols($url), $expected_plain, t('drupal_strip_dangerous_protocols() filters a URL and returns plain text.'));
+ }
+}
+
+class CommonSizeTestCase extends DrupalUnitTestCase {
+ protected $exact_test_cases;
+ protected $rounded_test_cases;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Size parsing test',
+ 'description' => 'Parse a predefined amount of bytes and compare the output with the expected value.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ $kb = DRUPAL_KILOBYTE;
+ $this->exact_test_cases = array(
+ '1 byte' => 1,
+ '1 KB' => $kb,
+ '1 MB' => $kb * $kb,
+ '1 GB' => $kb * $kb * $kb,
+ '1 TB' => $kb * $kb * $kb * $kb,
+ '1 PB' => $kb * $kb * $kb * $kb * $kb,
+ '1 EB' => $kb * $kb * $kb * $kb * $kb * $kb,
+ '1 ZB' => $kb * $kb * $kb * $kb * $kb * $kb * $kb,
+ '1 YB' => $kb * $kb * $kb * $kb * $kb * $kb * $kb * $kb,
+ );
+ $this->rounded_test_cases = array(
+ '2 bytes' => 2,
+ '1 MB' => ($kb * $kb) - 1, // rounded to 1 MB (not 1000 or 1024 kilobyte!)
+ round(3623651 / ($this->exact_test_cases['1 MB']), 2) . ' MB' => 3623651, // megabytes
+ round(67234178751368124 / ($this->exact_test_cases['1 PB']), 2) . ' PB' => 67234178751368124, // petabytes
+ round(235346823821125814962843827 / ($this->exact_test_cases['1 YB']), 2) . ' YB' => 235346823821125814962843827, // yottabytes
+ );
+ parent::setUp();
+ }
+
+ /**
+ * Check that format_size() returns the expected string.
+ */
+ function testCommonFormatSize() {
+ foreach (array($this->exact_test_cases, $this->rounded_test_cases) as $test_cases) {
+ foreach ($test_cases as $expected => $input) {
+ $this->assertEqual(
+ ($result = format_size($input, NULL)),
+ $expected,
+ $expected . ' == ' . $result . ' (' . $input . ' bytes)'
+ );
+ }
+ }
+ }
+
+ /**
+ * Check that parse_size() returns the proper byte sizes.
+ */
+ function testCommonParseSize() {
+ foreach ($this->exact_test_cases as $string => $size) {
+ $this->assertEqual(
+ $parsed_size = parse_size($string),
+ $size,
+ $size . ' == ' . $parsed_size . ' (' . $string . ')'
+ );
+ }
+
+ // Some custom parsing tests
+ $string = '23476892 bytes';
+ $this->assertEqual(
+ ($parsed_size = parse_size($string)),
+ $size = 23476892,
+ $string . ' == ' . $parsed_size . ' bytes'
+ );
+ $string = '76MRandomStringThatShouldBeIgnoredByParseSize.'; // 76 MB
+ $this->assertEqual(
+ $parsed_size = parse_size($string),
+ $size = 79691776,
+ $string . ' == ' . $parsed_size . ' bytes'
+ );
+ $string = '76.24 Giggabyte'; // Misspeld text -> 76.24 GB
+ $this->assertEqual(
+ $parsed_size = parse_size($string),
+ $size = 81862076662,
+ $string . ' == ' . $parsed_size . ' bytes'
+ );
+ }
+
+ /**
+ * Cross-test parse_size() and format_size().
+ */
+ function testCommonParseSizeFormatSize() {
+ foreach ($this->exact_test_cases as $size) {
+ $this->assertEqual(
+ $size,
+ ($parsed_size = parse_size($string = format_size($size, NULL))),
+ $size . ' == ' . $parsed_size . ' (' . $string . ')'
+ );
+ }
+ }
+}
+
+/**
+ * Test drupal_explode_tags() and drupal_implode_tags().
+ */
+class DrupalTagsHandlingTestCase extends DrupalWebTestCase {
+ var $validTags = array(
+ 'Drupal' => 'Drupal',
+ 'Drupal with some spaces' => 'Drupal with some spaces',
+ '"Legendary Drupal mascot of doom: ""Druplicon"""' => 'Legendary Drupal mascot of doom: "Druplicon"',
+ '"Drupal, although it rhymes with sloopal, is as awesome as a troopal!"' => 'Drupal, although it rhymes with sloopal, is as awesome as a troopal!',
+ );
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal tags handling',
+ 'description' => "Performs tests on Drupal's handling of tags, both explosion and implosion tactics used.",
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Explode a series of tags.
+ */
+ function testDrupalExplodeTags() {
+ $string = implode(', ', array_keys($this->validTags));
+ $tags = drupal_explode_tags($string);
+ $this->assertTags($tags);
+ }
+
+ /**
+ * Implode a series of tags.
+ */
+ function testDrupalImplodeTags() {
+ $tags = array_values($this->validTags);
+ // Let's explode and implode to our heart's content.
+ for ($i = 0; $i < 10; $i++) {
+ $string = drupal_implode_tags($tags);
+ $tags = drupal_explode_tags($string);
+ }
+ $this->assertTags($tags);
+ }
+
+ /**
+ * Helper function: asserts that the ending array of tags is what we wanted.
+ */
+ function assertTags($tags) {
+ $original = $this->validTags;
+ foreach ($tags as $tag) {
+ $key = array_search($tag, $original);
+ $this->assertTrue($key, t('Make sure tag %tag shows up in the final tags array (originally %original)', array('%tag' => $tag, '%original' => $key)));
+ unset($original[$key]);
+ }
+ foreach ($original as $leftover) {
+ $this->fail(t('Leftover tag %leftover was left over.', array('%leftover' => $leftover)));
+ }
+ }
+}
+
+/**
+ * Test the Drupal CSS system.
+ */
+class CascadingStylesheetsTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cascading stylesheets',
+ 'description' => 'Tests adding various cascading stylesheets to the page.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('php', 'locale', 'common_test');
+ // Reset drupal_add_css() before each test.
+ drupal_static_reset('drupal_add_css');
+ }
+
+ /**
+ * Check default stylesheets as empty.
+ */
+ function testDefault() {
+ $this->assertEqual(array(), drupal_add_css(), t('Default CSS is empty.'));
+ }
+
+ /**
+ * Test that stylesheets in module .info files are loaded.
+ */
+ function testModuleInfo() {
+ $this->drupalGet('');
+
+ // Verify common_test.css in a STYLE media="all" tag.
+ $elements = $this->xpath('//style[@media=:media and contains(text(), :filename)]', array(
+ ':media' => 'all',
+ ':filename' => 'tests/common_test.css',
+ ));
+ $this->assertTrue(count($elements), "Stylesheet with media 'all' in module .info file found.");
+
+ // Verify common_test.print.css in a STYLE media="print" tag.
+ $elements = $this->xpath('//style[@media=:media and contains(text(), :filename)]', array(
+ ':media' => 'print',
+ ':filename' => 'tests/common_test.print.css',
+ ));
+ $this->assertTrue(count($elements), "Stylesheet with media 'print' in module .info file found.");
+ }
+
+ /**
+ * Tests adding a file stylesheet.
+ */
+ function testAddFile() {
+ $path = drupal_get_path('module', 'simpletest') . '/simpletest.css';
+ $css = drupal_add_css($path);
+ $this->assertEqual($css[$path]['data'], $path, t('Adding a CSS file caches it properly.'));
+ }
+
+ /**
+ * Tests adding an external stylesheet.
+ */
+ function testAddExternal() {
+ $path = 'http://example.com/style.css';
+ $css = drupal_add_css($path, 'external');
+ $this->assertEqual($css[$path]['type'], 'external', t('Adding an external CSS file caches it properly.'));
+ }
+
+ /**
+ * Makes sure that reseting the CSS empties the cache.
+ */
+ function testReset() {
+ drupal_static_reset('drupal_add_css');
+ $this->assertEqual(array(), drupal_add_css(), t('Resetting the CSS empties the cache.'));
+ }
+
+ /**
+ * Tests rendering the stylesheets.
+ */
+ function testRenderFile() {
+ $css = drupal_get_path('module', 'simpletest') . '/simpletest.css';
+ drupal_add_css($css);
+ $styles = drupal_get_css();
+ $this->assertTrue(strpos($styles, $css) > 0, t('Rendered CSS includes the added stylesheet.'));
+ }
+
+ /**
+ * Tests rendering an external stylesheet.
+ */
+ function testRenderExternal() {
+ $css = 'http://example.com/style.css';
+ drupal_add_css($css, 'external');
+ $styles = drupal_get_css();
+ // Stylesheet URL may be the href of a LINK tag or in an @import statement
+ // of a STYLE tag.
+ $this->assertTrue(strpos($styles, 'href="' . $css) > 0 || strpos($styles, '@import url("' . $css . '")') > 0, t('Rendering an external CSS file.'));
+ }
+
+ /**
+ * Tests rendering inline stylesheets with preprocessing on.
+ */
+ function testRenderInlinePreprocess() {
+ $css = 'body { padding: 0px; }';
+ $css_preprocessed = '<style type="text/css" media="all">' . "\n<!--/*--><![CDATA[/*><!--*/\n" . drupal_load_stylesheet_content($css, TRUE) . "\n/*]]>*/-->\n" . '</style>';
+ drupal_add_css($css, array('type' => 'inline'));
+ $styles = drupal_get_css();
+ $this->assertEqual(trim($styles), $css_preprocessed, t('Rendering preprocessed inline CSS adds it to the page.'));
+ }
+
+ /**
+ * Tests rendering inline stylesheets with preprocessing off.
+ */
+ function testRenderInlineNoPreprocess() {
+ $css = 'body { padding: 0px; }';
+ drupal_add_css($css, array('type' => 'inline', 'preprocess' => FALSE));
+ $styles = drupal_get_css();
+ $this->assertTrue(strpos($styles, $css) > 0, t('Rendering non-preprocessed inline CSS adds it to the page.'));
+ }
+
+ /**
+ * Tests rendering inline stylesheets through a full page request.
+ */
+ function testRenderInlineFullPage() {
+ $css = 'body { font-size: 254px; }';
+ // Inline CSS is minified unless 'preprocess' => FALSE is passed as a
+ // drupal_add_css() option.
+ $expected = 'body{font-size:254px;}';
+
+ // Create a node, using the PHP filter that tests drupal_add_css().
+ $php_format_id = 'php_code';
+ $settings = array(
+ 'type' => 'page',
+ 'body' => array(
+ LANGUAGE_NONE => array(
+ array(
+ 'value' => t('This tests the inline CSS!') . "<?php drupal_add_css('$css', 'inline'); ?>",
+ 'format' => $php_format_id,
+ ),
+ ),
+ ),
+ 'promote' => 1,
+ );
+ $node = $this->drupalCreateNode($settings);
+
+ // Fetch the page.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertRaw($expected, t('Inline stylesheets appear in the full page rendering.'));
+ }
+
+ /**
+ * Test CSS ordering.
+ */
+ function testRenderOrder() {
+ // A module CSS file.
+ drupal_add_css(drupal_get_path('module', 'simpletest') . '/simpletest.css');
+ // A few system CSS files, ordered in a strange way.
+ $system_path = drupal_get_path('module', 'system');
+ drupal_add_css($system_path . '/system.menus.css', array('group' => CSS_SYSTEM));
+ drupal_add_css($system_path . '/system.base.css', array('group' => CSS_SYSTEM, 'weight' => -10));
+ drupal_add_css($system_path . '/system.theme.css', array('group' => CSS_SYSTEM));
+
+ $expected = array(
+ $system_path . '/system.base.css',
+ $system_path . '/system.menus.css',
+ $system_path . '/system.theme.css',
+ drupal_get_path('module', 'simpletest') . '/simpletest.css',
+ );
+
+
+ $styles = drupal_get_css();
+ // Stylesheet URL may be the href of a LINK tag or in an @import statement
+ // of a STYLE tag.
+ if (preg_match_all('/(href="|url\(")' . preg_quote($GLOBALS['base_url'] . '/', '/') . '([^?]+)\?/', $styles, $matches)) {
+ $result = $matches[2];
+ }
+ else {
+ $result = array();
+ }
+
+ $this->assertIdentical($result, $expected, t('The CSS files are in the expected order.'));
+ }
+
+ /**
+ * Test CSS override.
+ */
+ function testRenderOverride() {
+ $system = drupal_get_path('module', 'system');
+ $simpletest = drupal_get_path('module', 'simpletest');
+
+ drupal_add_css($system . '/system.base.css');
+ drupal_add_css($simpletest . '/tests/system.base.css');
+
+ // The dummy stylesheet should be the only one included.
+ $styles = drupal_get_css();
+ $this->assert(strpos($styles, $simpletest . '/tests/system.base.css') !== FALSE, t('The overriding CSS file is output.'));
+ $this->assert(strpos($styles, $system . '/system.base.css') === FALSE, t('The overridden CSS file is not output.'));
+
+ drupal_add_css($simpletest . '/tests/system.base.css');
+ drupal_add_css($system . '/system.base.css');
+
+ // The standard stylesheet should be the only one included.
+ $styles = drupal_get_css();
+ $this->assert(strpos($styles, $system . '/system.base.css') !== FALSE, t('The overriding CSS file is output.'));
+ $this->assert(strpos($styles, $simpletest . '/tests/system.base.css') === FALSE, t('The overridden CSS file is not output.'));
+ }
+
+ /**
+ * Tests Locale module's CSS Alter to include RTL overrides.
+ */
+ function testAlter() {
+ // Switch the language to a right to left language and add system.base.css.
+ global $language;
+ $language->direction = LANGUAGE_RTL;
+ $path = drupal_get_path('module', 'system');
+ drupal_add_css($path . '/system.base.css');
+
+ // Check to see if system.base-rtl.css was also added.
+ $styles = drupal_get_css();
+ $this->assert(strpos($styles, $path . '/system.base-rtl.css') !== FALSE, t('CSS is alterable as right to left overrides are added.'));
+
+ // Change the language back to left to right.
+ $language->direction = LANGUAGE_LTR;
+ }
+
+ /**
+ * Tests that the query string remains intact when adding CSS files that have
+ * query string parameters.
+ */
+ function testAddCssFileWithQueryString() {
+ $this->drupalGet('common-test/query-string');
+ $query_string = variable_get('css_js_query_string', '0');
+ $this->assertRaw(drupal_get_path('module', 'node') . '/node.css?' . $query_string, t('Query string was appended correctly to css.'));
+ $this->assertRaw(drupal_get_path('module', 'node') . '/node-fake.css?arg1=value1&amp;arg2=value2', t('Query string not escaped on a URI.'));
+ }
+}
+
+/**
+ * Test for cleaning HTML identifiers.
+ */
+class DrupalHTMLIdentifierTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'HTML identifiers',
+ 'description' => 'Test the functions drupal_html_class(), drupal_html_id() and drupal_clean_css_identifier() for expected behavior',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Tests that drupal_clean_css_identifier() cleans the identifier properly.
+ */
+ function testDrupalCleanCSSIdentifier() {
+ // Verify that no valid ASCII characters are stripped from the identifier.
+ $identifier = 'abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ-0123456789';
+ $this->assertIdentical(drupal_clean_css_identifier($identifier, array()), $identifier, t('Verify valid ASCII characters pass through.'));
+
+ // Verify that valid UTF-8 characters are not stripped from the identifier.
+ $identifier = '¡¢£¤¥';
+ $this->assertIdentical(drupal_clean_css_identifier($identifier, array()), $identifier, t('Verify valid UTF-8 characters pass through.'));
+
+ // Verify that invalid characters (including non-breaking space) are stripped from the identifier.
+ $this->assertIdentical(drupal_clean_css_identifier('invalid !"#$%&\'()*+,./:;<=>?@[\\]^`{|}~ identifier', array()), 'invalididentifier', t('Strip invalid characters.'));
+ }
+
+ /**
+ * Tests that drupal_html_class() cleans the class name properly.
+ */
+ function testDrupalHTMLClass() {
+ // Verify Drupal coding standards are enforced.
+ $this->assertIdentical(drupal_html_class('CLASS NAME_[Ü]'), 'class-name--ü', t('Enforce Drupal coding standards.'));
+ }
+
+ /**
+ * Tests that drupal_html_id() cleans the ID properly.
+ */
+ function testDrupalHTMLId() {
+ // Verify that letters, digits, and hyphens are not stripped from the ID.
+ $id = 'abcdefghijklmnopqrstuvwxyz-0123456789';
+ $this->assertIdentical(drupal_html_id($id), $id, t('Verify valid characters pass through.'));
+
+ // Verify that invalid characters are stripped from the ID.
+ $this->assertIdentical(drupal_html_id('invalid,./:@\\^`{Üidentifier'), 'invalididentifier', t('Strip invalid characters.'));
+
+ // Verify Drupal coding standards are enforced.
+ $this->assertIdentical(drupal_html_id('ID NAME_[1]'), 'id-name-1', t('Enforce Drupal coding standards.'));
+
+ // Reset the static cache so we can ensure the unique id count is at zero.
+ drupal_static_reset('drupal_html_id');
+
+ // Clean up IDs with invalid starting characters.
+ $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id', t('Test the uniqueness of IDs #1.'));
+ $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id--2', t('Test the uniqueness of IDs #2.'));
+ $this->assertIdentical(drupal_html_id('test-unique-id'), 'test-unique-id--3', t('Test the uniqueness of IDs #3.'));
+ }
+}
+
+/**
+ * CSS Unit Tests.
+ */
+class CascadingStylesheetsUnitTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'CSS Unit Tests',
+ 'description' => 'Unit tests on CSS functions like aggregation.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Tests basic CSS loading with and without optimization via drupal_load_stylesheet().
+ *
+ * Known tests:
+ * - Retain white-space in selectors. (http://drupal.org/node/472820)
+ * - Proper URLs in imported files. (http://drupal.org/node/265719)
+ * - Retain pseudo-selectors. (http://drupal.org/node/460448)
+ */
+ function testLoadCssBasic() {
+ // Array of files to test living in 'simpletest/files/css_test_files/'.
+ // - Original: name.css
+ // - Unoptimized expected content: name.css.unoptimized.css
+ // - Optimized expected content: name.css.optimized.css
+ $testfiles = array(
+ 'css_input_without_import.css',
+ 'css_input_with_import.css',
+ 'comment_hacks.css'
+ );
+ $path = drupal_get_path('module', 'simpletest') . '/files/css_test_files';
+ foreach ($testfiles as $file) {
+ $expected = file_get_contents("$path/$file.unoptimized.css");
+ $unoptimized_output = drupal_load_stylesheet("$path/$file.unoptimized.css", FALSE);
+ $this->assertEqual($unoptimized_output, $expected, t('Unoptimized CSS file has expected contents (@file)', array('@file' => $file)));
+
+ $expected = file_get_contents("$path/$file.optimized.css");
+ $optimized_output = drupal_load_stylesheet("$path/$file", TRUE);
+ $this->assertEqual($optimized_output, $expected, t('Optimized CSS file has expected contents (@file)', array('@file' => $file)));
+
+ // Repeat the tests by accessing the stylesheets by URL.
+ $expected = file_get_contents("$path/$file.unoptimized.css");
+ $unoptimized_output_url = drupal_load_stylesheet($GLOBALS['base_url'] . "/$path/$file.unoptimized.css", FALSE);
+ $this->assertEqual($unoptimized_output, $expected, t('Unoptimized CSS file (loaded from an URL) has expected contents (@file)', array('@file' => $file)));
+
+ $expected = file_get_contents("$path/$file.optimized.css");
+ $optimized_output = drupal_load_stylesheet($GLOBALS['base_url'] . "/$path/$file", TRUE);
+ $this->assertEqual($optimized_output, $expected, t('Optimized CSS file (loaded from an URL) has expected contents (@file)', array('@file' => $file)));
+ }
+ }
+}
+
+/**
+ * Test drupal_http_request().
+ */
+class DrupalHTTPRequestTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal HTTP request',
+ 'description' => "Performs tests on Drupal's HTTP request mechanism.",
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+ }
+
+ function testDrupalHTTPRequest() {
+ global $is_https;
+
+ // Parse URL schema.
+ $missing_scheme = drupal_http_request('example.com/path');
+ $this->assertEqual($missing_scheme->code, -1002, t('Returned with "-1002" error code.'));
+ $this->assertEqual($missing_scheme->error, 'missing schema', t('Returned with "missing schema" error message.'));
+
+ $unable_to_parse = drupal_http_request('http:///path');
+ $this->assertEqual($unable_to_parse->code, -1001, t('Returned with "-1001" error code.'));
+ $this->assertEqual($unable_to_parse->error, 'unable to parse URL', t('Returned with "unable to parse URL" error message.'));
+
+ // Fetch page.
+ $result = drupal_http_request(url('node', array('absolute' => TRUE)));
+ $this->assertEqual($result->code, 200, t('Fetched page successfully.'));
+ $this->drupalSetContent($result->data);
+ $this->assertTitle(t('Welcome to @site-name | @site-name', array('@site-name' => variable_get('site_name', 'Drupal'))), t('Site title matches.'));
+
+ // Test that code and status message is returned.
+ $result = drupal_http_request(url('pagedoesnotexist', array('absolute' => TRUE)));
+ $this->assertTrue(!empty($result->protocol), t('Result protocol is returned.'));
+ $this->assertEqual($result->code, '404', t('Result code is 404'));
+ $this->assertEqual($result->status_message, 'Not Found', t('Result status message is "Not Found"'));
+
+ // Skip the timeout tests when the testing environment is HTTPS because
+ // stream_set_timeout() does not work for SSL connections.
+ // @link http://bugs.php.net/bug.php?id=47929
+ if (!$is_https) {
+ // Test that timeout is respected. The test machine is expected to be able
+ // to make the connection (i.e. complete the fsockopen()) in 2 seconds and
+ // return within a total of 5 seconds. If the test machine is extremely
+ // slow, the test will fail. fsockopen() has been seen to time out in
+ // slightly less than the specified timeout, so allow a little slack on
+ // the minimum expected time (i.e. 1.8 instead of 2).
+ timer_start(__METHOD__);
+ $result = drupal_http_request(url('system-test/sleep/10', array('absolute' => TRUE)), array('timeout' => 2));
+ $time = timer_read(__METHOD__) / 1000;
+ $this->assertTrue(1.8 < $time && $time < 5, t('Request timed out (%time seconds).', array('%time' => $time)));
+ $this->assertTrue($result->error, t('An error message was returned.'));
+ $this->assertEqual($result->code, HTTP_REQUEST_TIMEOUT, t('Proper error code was returned.'));
+ }
+ }
+
+ function testDrupalHTTPRequestBasicAuth() {
+ $username = $this->randomName();
+ $password = $this->randomName();
+ $url = url('system-test/auth', array('absolute' => TRUE));
+
+ $auth = str_replace('://', '://' . $username . ':' . $password . '@', $url);
+ $result = drupal_http_request($auth);
+
+ $this->drupalSetContent($result->data);
+ $this->assertRaw($username, t('$_SERVER["PHP_AUTH_USER"] is passed correctly.'));
+ $this->assertRaw($password, t('$_SERVER["PHP_AUTH_PW"] is passed correctly.'));
+ }
+
+ function testDrupalHTTPRequestRedirect() {
+ $redirect_301 = drupal_http_request(url('system-test/redirect/301', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_301->redirect_code, 301, t('drupal_http_request follows the 301 redirect.'));
+
+ $redirect_301 = drupal_http_request(url('system-test/redirect/301', array('absolute' => TRUE)), array('max_redirects' => 0));
+ $this->assertFalse(isset($redirect_301->redirect_code), t('drupal_http_request does not follow 301 redirect if max_redirects = 0.'));
+
+ $redirect_invalid = drupal_http_request(url('system-test/redirect-noscheme', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_invalid->code, -1002, t('301 redirect to invalid URL returned with error code !error.', array('!error' => $redirect_invalid->error)));
+ $this->assertEqual($redirect_invalid->error, 'missing schema', t('301 redirect to invalid URL returned with error message "!error".', array('!error' => $redirect_invalid->error)));
+
+ $redirect_invalid = drupal_http_request(url('system-test/redirect-noparse', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_invalid->code, -1001, t('301 redirect to invalid URL returned with error message code "!error".', array('!error' => $redirect_invalid->error)));
+ $this->assertEqual($redirect_invalid->error, 'unable to parse URL', t('301 redirect to invalid URL returned with error message "!error".', array('!error' => $redirect_invalid->error)));
+
+ $redirect_invalid = drupal_http_request(url('system-test/redirect-invalid-scheme', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_invalid->code, -1003, t('301 redirect to invalid URL returned with error code !error.', array('!error' => $redirect_invalid->error)));
+ $this->assertEqual($redirect_invalid->error, 'invalid schema ftp', t('301 redirect to invalid URL returned with error message "!error".', array('!error' => $redirect_invalid->error)));
+
+ $redirect_302 = drupal_http_request(url('system-test/redirect/302', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_302->redirect_code, 302, t('drupal_http_request follows the 302 redirect.'));
+
+ $redirect_302 = drupal_http_request(url('system-test/redirect/302', array('absolute' => TRUE)), array('max_redirects' => 0));
+ $this->assertFalse(isset($redirect_302->redirect_code), t('drupal_http_request does not follow 302 redirect if $retry = 0.'));
+
+ $redirect_307 = drupal_http_request(url('system-test/redirect/307', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($redirect_307->redirect_code, 307, t('drupal_http_request follows the 307 redirect.'));
+
+ $redirect_307 = drupal_http_request(url('system-test/redirect/307', array('absolute' => TRUE)), array('max_redirects' => 0));
+ $this->assertFalse(isset($redirect_307->redirect_code), t('drupal_http_request does not follow 307 redirect if max_redirects = 0.'));
+
+ $multiple_redirect_final_url = url('system-test/multiple-redirects/0', array('absolute' => TRUE));
+ $multiple_redirect_1 = drupal_http_request(url('system-test/multiple-redirects/1', array('absolute' => TRUE)), array('max_redirects' => 1));
+ $this->assertEqual($multiple_redirect_1->redirect_url, $multiple_redirect_final_url, t('redirect_url contains the final redirection location after 1 redirect.'));
+
+ $multiple_redirect_3 = drupal_http_request(url('system-test/multiple-redirects/3', array('absolute' => TRUE)), array('max_redirects' => 3));
+ $this->assertEqual($multiple_redirect_3->redirect_url, $multiple_redirect_final_url, t('redirect_url contains the final redirection location after 3 redirects.'));
+ }
+}
+
+/**
+ * Testing drupal_add_region_content and drupal_get_region_content.
+ */
+class DrupalSetContentTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal set/get regions',
+ 'description' => 'Performs tests on setting and retrieiving content from theme regions.',
+ 'group' => 'System'
+ );
+ }
+
+
+ /**
+ * Test setting and retrieving content for theme regions.
+ */
+ function testRegions() {
+ global $theme_key;
+
+ $block_regions = array_keys(system_region_list($theme_key));
+ $delimiter = $this->randomName(32);
+ $values = array();
+ // Set some random content for each region available.
+ foreach ($block_regions as $region) {
+ $first_chunk = $this->randomName(32);
+ drupal_add_region_content($region, $first_chunk);
+ $second_chunk = $this->randomName(32);
+ drupal_add_region_content($region, $second_chunk);
+ // Store the expected result for a drupal_get_region_content call for this region.
+ $values[$region] = $first_chunk . $delimiter . $second_chunk;
+ }
+
+ // Ensure drupal_get_region_content returns expected results when fetching all regions.
+ $content = drupal_get_region_content(NULL, $delimiter);
+ foreach ($content as $region => $region_content) {
+ $this->assertEqual($region_content, $values[$region], t('@region region text verified when fetching all regions', array('@region' => $region)));
+ }
+
+ // Ensure drupal_get_region_content returns expected results when fetching a single region.
+ foreach ($block_regions as $region) {
+ $region_content = drupal_get_region_content($region, $delimiter);
+ $this->assertEqual($region_content, $values[$region], t('@region region text verified when fetching single region.', array('@region' => $region)));
+ }
+ }
+}
+
+/**
+ * Testing drupal_goto and hook_drupal_goto_alter().
+ */
+class DrupalGotoTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal goto',
+ 'description' => 'Performs tests on the drupal_goto function and hook_drupal_goto_alter',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('common_test');
+ }
+
+ /**
+ * Test drupal_goto().
+ */
+ function testDrupalGoto() {
+ $this->drupalGet('common-test/drupal_goto/redirect');
+ $headers = $this->drupalGetHeaders(TRUE);
+ list(, $status) = explode(' ', $headers[0][':status'], 3);
+ $this->assertEqual($status, 302, t('Expected response code was sent.'));
+ $this->assertText('drupal_goto', t('Drupal goto redirect succeeded.'));
+ $this->assertEqual($this->getUrl(), url('common-test/drupal_goto', array('absolute' => TRUE)), t('Drupal goto redirected to expected URL.'));
+
+ $this->drupalGet('common-test/drupal_goto/redirect_advanced');
+ $headers = $this->drupalGetHeaders(TRUE);
+ list(, $status) = explode(' ', $headers[0][':status'], 3);
+ $this->assertEqual($status, 301, t('Expected response code was sent.'));
+ $this->assertText('drupal_goto', t('Drupal goto redirect succeeded.'));
+ $this->assertEqual($this->getUrl(), url('common-test/drupal_goto', array('query' => array('foo' => '123'), 'absolute' => TRUE)), t('Drupal goto redirected to expected URL.'));
+
+ // Test that drupal_goto() respects ?destination=xxx. Use an complicated URL
+ // to test that the path is encoded and decoded properly.
+ $destination = 'common-test/drupal_goto/destination?foo=%2525&bar=123';
+ $this->drupalGet('common-test/drupal_goto/redirect', array('query' => array('destination' => $destination)));
+ $this->assertText('drupal_goto', t('Drupal goto redirect with destination succeeded.'));
+ $this->assertEqual($this->getUrl(), url('common-test/drupal_goto/destination', array('query' => array('foo' => '%25', 'bar' => '123'), 'absolute' => TRUE)), t('Drupal goto redirected to given query string destination.'));
+ }
+
+ /**
+ * Test hook_drupal_goto_alter().
+ */
+ function testDrupalGotoAlter() {
+ $this->drupalGet('common-test/drupal_goto/redirect_fail');
+
+ $this->assertNoText(t("Drupal goto failed to stop program"), t("Drupal goto stopped program."));
+ $this->assertNoText('drupal_goto_fail', t("Drupal goto redirect failed."));
+ }
+
+ /**
+ * Test drupal_get_destination().
+ */
+ function testDrupalGetDestination() {
+ $query = $this->randomName(10);
+
+ // Verify that a 'destination' query string is used as destination.
+ $this->drupalGet('common-test/destination', array('query' => array('destination' => $query)));
+ $this->assertText('The destination: ' . $query, t('The given query string destination is determined as destination.'));
+
+ // Verify that the current path is used as destination.
+ $this->drupalGet('common-test/destination', array('query' => array($query => NULL)));
+ $url = 'common-test/destination?' . $query;
+ $this->assertText('The destination: ' . $url, t('The current path is determined as destination.'));
+ }
+}
+
+/**
+ * Tests for the JavaScript system.
+ */
+class JavaScriptTestCase extends DrupalWebTestCase {
+ /**
+ * Store configured value for JavaScript preprocessing.
+ */
+ protected $preprocess_js = NULL;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'JavaScript',
+ 'description' => 'Tests the JavaScript system.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ // Enable Locale and SimpleTest in the test environment.
+ parent::setUp('locale', 'simpletest', 'common_test');
+
+ // Disable preprocessing
+ $this->preprocess_js = variable_get('preprocess_js', 0);
+ variable_set('preprocess_js', 0);
+
+ // Reset drupal_add_js() and drupal_add_library() statics before each test.
+ drupal_static_reset('drupal_add_js');
+ drupal_static_reset('drupal_add_library');
+ }
+
+ function tearDown() {
+ // Restore configured value for JavaScript preprocessing.
+ variable_set('preprocess_js', $this->preprocess_js);
+ parent::tearDown();
+ }
+
+ /**
+ * Test default JavaScript is empty.
+ */
+ function testDefault() {
+ $this->assertEqual(array(), drupal_add_js(), t('Default JavaScript is empty.'));
+ }
+
+ /**
+ * Test adding a JavaScript file.
+ */
+ function testAddFile() {
+ $javascript = drupal_add_js('core/misc/collapse.js');
+ $this->assertTrue(array_key_exists('core/misc/jquery.js', $javascript), t('jQuery is added when a file is added.'));
+ $this->assertTrue(array_key_exists('core/misc/drupal.js', $javascript), t('Drupal.js is added when file is added.'));
+ $this->assertTrue(array_key_exists('core/misc/collapse.js', $javascript), t('JavaScript files are correctly added.'));
+ $this->assertEqual(base_path(), $javascript['settings']['data'][0]['basePath'], t('Base path JavaScript setting is correctly set.'));
+ url('', array('prefix' => &$prefix));
+ $this->assertEqual(empty($prefix) ? '' : $prefix, $javascript['settings']['data'][1]['pathPrefix'], t('Path prefix JavaScript setting is correctly set.'));
+ }
+
+ /**
+ * Test adding settings.
+ */
+ function testAddSetting() {
+ $javascript = drupal_add_js(array('drupal' => 'rocks', 'dries' => 280342800), 'setting');
+ $this->assertEqual(280342800, $javascript['settings']['data'][2]['dries'], t('JavaScript setting is set correctly.'));
+ $this->assertEqual('rocks', $javascript['settings']['data'][2]['drupal'], t('The other JavaScript setting is set correctly.'));
+ }
+
+ /**
+ * Tests adding an external JavaScript File.
+ */
+ function testAddExternal() {
+ $path = 'http://example.com/script.js';
+ $javascript = drupal_add_js($path, 'external');
+ $this->assertTrue(array_key_exists('http://example.com/script.js', $javascript), t('Added an external JavaScript file.'));
+ }
+
+ /**
+ * Test drupal_get_js() for JavaScript settings.
+ */
+ function testHeaderSetting() {
+ // Only the second of these two entries should appear in Drupal.settings.
+ drupal_add_js(array('commonTest' => 'commonTestShouldNotAppear'), 'setting');
+ drupal_add_js(array('commonTest' => 'commonTestShouldAppear'), 'setting');
+ // All three of these entries should appear in Drupal.settings.
+ drupal_add_js(array('commonTestArray' => array('commonTestValue0')), 'setting');
+ drupal_add_js(array('commonTestArray' => array('commonTestValue1')), 'setting');
+ drupal_add_js(array('commonTestArray' => array('commonTestValue2')), 'setting');
+ // Only the second of these two entries should appear in Drupal.settings.
+ drupal_add_js(array('commonTestArray' => array('key' => 'commonTestOldValue')), 'setting');
+ drupal_add_js(array('commonTestArray' => array('key' => 'commonTestNewValue')), 'setting');
+
+ $javascript = drupal_get_js('header');
+ $this->assertTrue(strpos($javascript, 'basePath') > 0, t('Rendered JavaScript header returns basePath setting.'));
+ $this->assertTrue(strpos($javascript, 'core/misc/jquery.js') > 0, t('Rendered JavaScript header includes jQuery.'));
+ $this->assertTrue(strpos($javascript, 'pathPrefix') > 0, t('Rendered JavaScript header returns pathPrefix setting.'));
+
+ // Test whether drupal_add_js can be used to override a previous setting.
+ $this->assertTrue(strpos($javascript, 'commonTestShouldAppear') > 0, t('Rendered JavaScript header returns custom setting.'));
+ $this->assertTrue(strpos($javascript, 'commonTestShouldNotAppear') === FALSE, t('drupal_add_js() correctly overrides a custom setting.'));
+
+ // Test whether drupal_add_js can be used to add numerically indexed values
+ // to an array.
+ $array_values_appear = strpos($javascript, 'commonTestValue0') > 0 && strpos($javascript, 'commonTestValue1') > 0 && strpos($javascript, 'commonTestValue2') > 0;
+ $this->assertTrue($array_values_appear, t('drupal_add_js() correctly adds settings to the end of an indexed array.'));
+
+ // Test whether drupal_add_js can be used to override the entry for an
+ // existing key in an associative array.
+ $associative_array_override = strpos($javascript, 'commonTestNewValue') > 0 && strpos($javascript, 'commonTestOldValue') === FALSE;
+ $this->assertTrue($associative_array_override, t('drupal_add_js() correctly overrides settings within an associative array.'));
+ }
+
+ /**
+ * Test to see if resetting the JavaScript empties the cache.
+ */
+ function testReset() {
+ drupal_add_js('core/misc/collapse.js');
+ drupal_static_reset('drupal_add_js');
+ $this->assertEqual(array(), drupal_add_js(), t('Resetting the JavaScript correctly empties the cache.'));
+ }
+
+ /**
+ * Test adding inline scripts.
+ */
+ function testAddInline() {
+ $inline = 'jQuery(function () { });';
+ $javascript = drupal_add_js($inline, array('type' => 'inline', 'scope' => 'footer'));
+ $this->assertTrue(array_key_exists('core/misc/jquery.js', $javascript), t('jQuery is added when inline scripts are added.'));
+ $data = end($javascript);
+ $this->assertEqual($inline, $data['data'], t('Inline JavaScript is correctly added to the footer.'));
+ }
+
+ /**
+ * Test rendering an external JavaScript file.
+ */
+ function testRenderExternal() {
+ $external = 'http://example.com/example.js';
+ drupal_add_js($external, 'external');
+ $javascript = drupal_get_js();
+ // Local files have a base_path() prefix, external files should not.
+ $this->assertTrue(strpos($javascript, 'src="' . $external) > 0, t('Rendering an external JavaScript file.'));
+ }
+
+ /**
+ * Test drupal_get_js() with a footer scope.
+ */
+ function testFooterHTML() {
+ $inline = 'jQuery(function () { });';
+ drupal_add_js($inline, array('type' => 'inline', 'scope' => 'footer'));
+ $javascript = drupal_get_js('footer');
+ $this->assertTrue(strpos($javascript, $inline) > 0, t('Rendered JavaScript footer returns the inline code.'));
+ }
+
+ /**
+ * Test drupal_add_js() sets preproccess to false when cache is set to false.
+ */
+ function testNoCache() {
+ $javascript = drupal_add_js('core/misc/collapse.js', array('cache' => FALSE));
+ $this->assertFalse($javascript['core/misc/collapse.js']['preprocess'], t('Setting cache to FALSE sets proprocess to FALSE when adding JavaScript.'));
+ }
+
+ /**
+ * Test adding a JavaScript file with a different group.
+ */
+ function testDifferentGroup() {
+ $javascript = drupal_add_js('core/misc/collapse.js', array('group' => JS_THEME));
+ $this->assertEqual($javascript['core/misc/collapse.js']['group'], JS_THEME, t('Adding a JavaScript file with a different group caches the given group.'));
+ }
+
+ /**
+ * Test adding a JavaScript file with a different weight.
+ */
+ function testDifferentWeight() {
+ $javascript = drupal_add_js('core/misc/collapse.js', array('weight' => 2));
+ $this->assertEqual($javascript['core/misc/collapse.js']['weight'], 2, t('Adding a JavaScript file with a different weight caches the given weight.'));
+ }
+
+ /**
+ * Test JavaScript ordering.
+ */
+ function testRenderOrder() {
+ // Add a bunch of JavaScript in strange ordering.
+ drupal_add_js('(function($){alert("Weight 5 #1");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => 5));
+ drupal_add_js('(function($){alert("Weight 0 #1");})(jQuery);', array('type' => 'inline', 'scope' => 'footer'));
+ drupal_add_js('(function($){alert("Weight 0 #2");})(jQuery);', array('type' => 'inline', 'scope' => 'footer'));
+ drupal_add_js('(function($){alert("Weight -8 #1");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => -8));
+ drupal_add_js('(function($){alert("Weight -8 #2");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => -8));
+ drupal_add_js('(function($){alert("Weight -8 #3");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => -8));
+ drupal_add_js('http://example.com/example.js?Weight -5 #1', array('type' => 'external', 'scope' => 'footer', 'weight' => -5));
+ drupal_add_js('(function($){alert("Weight -8 #4");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => -8));
+ drupal_add_js('(function($){alert("Weight 5 #2");})(jQuery);', array('type' => 'inline', 'scope' => 'footer', 'weight' => 5));
+ drupal_add_js('(function($){alert("Weight 0 #3");})(jQuery);', array('type' => 'inline', 'scope' => 'footer'));
+
+ // Construct the expected result from the regex.
+ $expected = array(
+ "-8 #1",
+ "-8 #2",
+ "-8 #3",
+ "-8 #4",
+ "-5 #1", // The external script.
+ "0 #1",
+ "0 #2",
+ "0 #3",
+ "5 #1",
+ "5 #2",
+ );
+
+ // Retrieve the rendered JavaScript and test against the regex.
+ $js = drupal_get_js('footer');
+ $matches = array();
+ if (preg_match_all('/Weight\s([-0-9]+\s[#0-9]+)/', $js, $matches)) {
+ $result = $matches[1];
+ }
+ else {
+ $result = array();
+ }
+ $this->assertIdentical($result, $expected, t('JavaScript is added in the expected weight order.'));
+ }
+
+ /**
+ * Test rendering the JavaScript with a file's weight above jQuery's.
+ */
+ function testRenderDifferentWeight() {
+ // JavaScript files are sorted first by group, then by the 'every_page'
+ // flag, then by weight (see drupal_sort_css_js()), so to test the effect of
+ // weight, we need the other two options to be the same.
+ drupal_add_js('core/misc/collapse.js', array('group' => JS_LIBRARY, 'every_page' => TRUE, 'weight' => -21));
+ $javascript = drupal_get_js();
+ $this->assertTrue(strpos($javascript, 'core/misc/collapse.js') < strpos($javascript, 'core/misc/jquery.js'), t('Rendering a JavaScript file above jQuery.'));
+ }
+
+ /**
+ * Test altering a JavaScript's weight via hook_js_alter().
+ *
+ * @see simpletest_js_alter()
+ */
+ function testAlter() {
+ // Add both tableselect.js and simpletest.js, with a larger weight on SimpleTest.
+ drupal_add_js('core/misc/tableselect.js');
+ drupal_add_js(drupal_get_path('module', 'simpletest') . '/simpletest.js', array('weight' => 9999));
+
+ // Render the JavaScript, testing if simpletest.js was altered to be before
+ // tableselect.js. See simpletest_js_alter() to see where this alteration
+ // takes place.
+ $javascript = drupal_get_js();
+ $this->assertTrue(strpos($javascript, 'simpletest.js') < strpos($javascript, 'core/misc/tableselect.js'), t('Altering JavaScript weight through the alter hook.'));
+ }
+
+ /**
+ * Adds a library to the page and tests for both its JavaScript and its CSS.
+ */
+ function testLibraryRender() {
+ $result = drupal_add_library('system', 'farbtastic');
+ $this->assertTrue($result !== FALSE, t('Library was added without errors.'));
+ $scripts = drupal_get_js();
+ $styles = drupal_get_css();
+ $this->assertTrue(strpos($scripts, 'core/misc/farbtastic/farbtastic.js'), t('JavaScript of library was added to the page.'));
+ $this->assertTrue(strpos($styles, 'core/misc/farbtastic/farbtastic.css'), t('Stylesheet of library was added to the page.'));
+ }
+
+ /**
+ * Adds a JavaScript library to the page and alters it.
+ *
+ * @see common_test_library_info_alter()
+ */
+ function testLibraryAlter() {
+ // Verify that common_test altered the title of Farbtastic.
+ $library = drupal_get_library('system', 'farbtastic');
+ $this->assertEqual($library['title'], 'Farbtastic: Altered Library', t('Registered libraries were altered.'));
+
+ // common_test_library_info_alter() also added a dependency on jQuery Form.
+ drupal_add_library('system', 'farbtastic');
+ $scripts = drupal_get_js();
+ $this->assertTrue(strpos($scripts, 'core/misc/jquery.form.js'), t('Altered library dependencies are added to the page.'));
+ }
+
+ /**
+ * Tests that multiple modules can implement the same library.
+ *
+ * @see common_test_library_info()
+ */
+ function testLibraryNameConflicts() {
+ $farbtastic = drupal_get_library('common_test', 'farbtastic');
+ $this->assertEqual($farbtastic['title'], 'Custom Farbtastic Library', t('Alternative libraries can be added to the page.'));
+ }
+
+ /**
+ * Tests non-existing libraries.
+ */
+ function testLibraryUnknown() {
+ $result = drupal_get_library('unknown', 'unknown');
+ $this->assertFalse($result, t('Unknown library returned FALSE.'));
+ drupal_static_reset('drupal_get_library');
+
+ $result = drupal_add_library('unknown', 'unknown');
+ $this->assertFalse($result, t('Unknown library returned FALSE.'));
+ $scripts = drupal_get_js();
+ $this->assertTrue(strpos($scripts, 'unknown') === FALSE, t('Unknown library was not added to the page.'));
+ }
+
+ /**
+ * Tests the addition of libraries through the #attached['library'] property.
+ */
+ function testAttachedLibrary() {
+ $element['#attached']['library'][] = array('system', 'farbtastic');
+ drupal_render($element);
+ $scripts = drupal_get_js();
+ $this->assertTrue(strpos($scripts, 'core/misc/farbtastic/farbtastic.js'), t('The attached_library property adds the additional libraries.'));
+ }
+
+ /**
+ * Tests retrieval of libraries via drupal_get_library().
+ */
+ function testGetLibrary() {
+ // Retrieve all libraries registered by a module.
+ $libraries = drupal_get_library('common_test');
+ $this->assertTrue(isset($libraries['farbtastic']), t('Retrieved all module libraries.'));
+ // Retrieve all libraries for a module not implementing hook_library_info().
+ // Note: This test installs Locale module.
+ $libraries = drupal_get_library('locale');
+ $this->assertEqual($libraries, array(), t('Retrieving libraries from a module not implementing hook_library_info() returns an emtpy array.'));
+
+ // Retrieve a specific library by module and name.
+ $farbtastic = drupal_get_library('common_test', 'farbtastic');
+ $this->assertEqual($farbtastic['version'], '5.3', t('Retrieved a single library.'));
+ // Retrieve a non-existing library by module and name.
+ $farbtastic = drupal_get_library('common_test', 'foo');
+ $this->assertIdentical($farbtastic, FALSE, t('Retrieving a non-existing library returns FALSE.'));
+ }
+
+ /**
+ * Tests that the query string remains intact when adding JavaScript files
+ * that have query string parameters.
+ */
+ function testAddJsFileWithQueryString() {
+ $this->drupalGet('common-test/query-string');
+ $query_string = variable_get('css_js_query_string', '0');
+ $this->assertRaw(drupal_get_path('module', 'node') . '/node.js?' . $query_string, t('Query string was appended correctly to js.'));
+ }
+}
+
+/**
+ * Tests for drupal_render().
+ */
+class DrupalRenderTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'drupal_render()',
+ 'description' => 'Performs functional tests on drupal_render().',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('common_test');
+ }
+
+ /**
+ * Test sorting by weight.
+ */
+ function testDrupalRenderSorting() {
+ $first = $this->randomName();
+ $second = $this->randomName();
+ // Build an array with '#weight' set for each element.
+ $elements = array(
+ 'second' => array(
+ '#weight' => 10,
+ '#markup' => $second,
+ ),
+ 'first' => array(
+ '#weight' => 0,
+ '#markup' => $first,
+ ),
+ );
+ $output = drupal_render($elements);
+
+ // The lowest weight element should appear last in $output.
+ $this->assertTrue(strpos($output, $second) > strpos($output, $first), t('Elements were sorted correctly by weight.'));
+
+ // Confirm that the $elements array has '#sorted' set to TRUE.
+ $this->assertTrue($elements['#sorted'], t("'#sorted' => TRUE was added to the array"));
+
+ // Pass $elements through element_children() and ensure it remains
+ // sorted in the correct order. drupal_render() will return an empty string
+ // if used on the same array in the same request.
+ $children = element_children($elements);
+ $this->assertTrue(array_shift($children) == 'first', t('Child found in the correct order.'));
+ $this->assertTrue(array_shift($children) == 'second', t('Child found in the correct order.'));
+
+
+ // The same array structure again, but with #sorted set to TRUE.
+ $elements = array(
+ 'second' => array(
+ '#weight' => 10,
+ '#markup' => $second,
+ ),
+ 'first' => array(
+ '#weight' => 0,
+ '#markup' => $first,
+ ),
+ '#sorted' => TRUE,
+ );
+ $output = drupal_render($elements);
+
+ // The elements should appear in output in the same order as the array.
+ $this->assertTrue(strpos($output, $second) < strpos($output, $first), t('Elements were not sorted.'));
+ }
+
+ /**
+ * Test #attached functionality in children elements.
+ */
+ function testDrupalRenderChildrenAttached() {
+ // The cache system is turned off for POST requests.
+ $request_method = $_SERVER['REQUEST_METHOD'];
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ // Create an element with a child and subchild. Each element loads a
+ // different JavaScript file using #attached.
+ $parent_js = drupal_get_path('module', 'user') . '/user.js';
+ $child_js = drupal_get_path('module', 'forum') . '/forum.js';
+ $subchild_js = drupal_get_path('module', 'book') . '/book.js';
+ $element = array(
+ '#type' => 'fieldset',
+ '#cache' => array(
+ 'keys' => array('simpletest', 'drupal_render', 'children_attached'),
+ ),
+ '#attached' => array('js' => array($parent_js)),
+ '#title' => 'Parent',
+ );
+ $element['child'] = array(
+ '#type' => 'fieldset',
+ '#attached' => array('js' => array($child_js)),
+ '#title' => 'Child',
+ );
+ $element['child']['subchild'] = array(
+ '#attached' => array('js' => array($subchild_js)),
+ '#markup' => 'Subchild',
+ );
+
+ // Render the element and verify the presence of #attached JavaScript.
+ drupal_render($element);
+ $scripts = drupal_get_js();
+ $this->assertTrue(strpos($scripts, $parent_js), t('The element #attached JavaScript was included.'));
+ $this->assertTrue(strpos($scripts, $child_js), t('The child #attached JavaScript was included.'));
+ $this->assertTrue(strpos($scripts, $subchild_js), t('The subchild #attached JavaScript was included.'));
+
+ // Load the element from cache and verify the presence of the #attached
+ // JavaScript.
+ drupal_static_reset('drupal_add_js');
+ $this->assertTrue(drupal_render_cache_get($element), t('The element was retrieved from cache.'));
+ $scripts = drupal_get_js();
+ $this->assertTrue(strpos($scripts, $parent_js), t('The element #attached JavaScript was included when loading from cache.'));
+ $this->assertTrue(strpos($scripts, $child_js), t('The child #attached JavaScript was included when loading from cache.'));
+ $this->assertTrue(strpos($scripts, $subchild_js), t('The subchild #attached JavaScript was included when loading from cache.'));
+
+ $_SERVER['REQUEST_METHOD'] = $request_method;
+ }
+
+ /**
+ * Test passing arguments to the theme function.
+ */
+ function testDrupalRenderThemeArguments() {
+ $element = array(
+ '#theme' => 'common_test_foo',
+ );
+ // Test that defaults work.
+ $this->assertEqual(drupal_render($element), 'foobar', 'Defaults work');
+ $element = array(
+ '#theme' => 'common_test_foo',
+ '#foo' => $this->randomName(),
+ '#bar' => $this->randomName(),
+ );
+ // Test that passing arguments to the theme function works.
+ $this->assertEqual(drupal_render($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works');
+ }
+
+ /**
+ * Test rendering form elements without passing through form_builder().
+ */
+ function testDrupalRenderFormElements() {
+ // Define a series of form elements.
+ $element = array(
+ '#type' => 'button',
+ '#value' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'submit'));
+
+ $element = array(
+ '#type' => 'textfield',
+ '#title' => $this->randomName(),
+ '#value' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'text'));
+
+ $element = array(
+ '#type' => 'password',
+ '#title' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'password'));
+
+ $element = array(
+ '#type' => 'textarea',
+ '#title' => $this->randomName(),
+ '#value' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//textarea');
+
+ $element = array(
+ '#type' => 'radio',
+ '#title' => $this->randomName(),
+ '#value' => FALSE,
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'radio'));
+
+ $element = array(
+ '#type' => 'checkbox',
+ '#title' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'checkbox'));
+
+ $element = array(
+ '#type' => 'select',
+ '#title' => $this->randomName(),
+ '#options' => array(
+ 0 => $this->randomName(),
+ 1 => $this->randomName(),
+ ),
+ );
+ $this->assertRenderedElement($element, '//select');
+
+ $element = array(
+ '#type' => 'file',
+ '#title' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'file'));
+
+ $element = array(
+ '#type' => 'item',
+ '#title' => $this->randomName(),
+ '#markup' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//div[contains(@class, :class) and contains(., :markup)]/label[contains(., :label)]', array(
+ ':class' => 'form-type-item',
+ ':markup' => $element['#markup'],
+ ':label' => $element['#title'],
+ ));
+
+ $element = array(
+ '#type' => 'hidden',
+ '#title' => $this->randomName(),
+ '#value' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//input[@type=:type]', array(':type' => 'hidden'));
+
+ $element = array(
+ '#type' => 'link',
+ '#title' => $this->randomName(),
+ '#href' => $this->randomName(),
+ '#options' => array(
+ 'absolute' => TRUE,
+ ),
+ );
+ $this->assertRenderedElement($element, '//a[@href=:href and contains(., :title)]', array(
+ ':href' => url($element['#href'], array('absolute' => TRUE)),
+ ':title' => $element['#title'],
+ ));
+
+ $element = array(
+ '#type' => 'fieldset',
+ '#title' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//fieldset/legend[contains(., :title)]', array(
+ ':title' => $element['#title'],
+ ));
+
+ $element['item'] = array(
+ '#type' => 'item',
+ '#title' => $this->randomName(),
+ '#markup' => $this->randomName(),
+ );
+ $this->assertRenderedElement($element, '//fieldset/div/div[contains(@class, :class) and contains(., :markup)]', array(
+ ':class' => 'form-type-item',
+ ':markup' => $element['item']['#markup'],
+ ));
+ }
+
+ /**
+ * Test rendering elements with invalid keys.
+ */
+ function testDrupalRenderInvalidKeys() {
+ $error = array(
+ '%type' => 'User error',
+ '!message' => '"child" is an invalid render array key',
+ '%function' => 'element_children()',
+ );
+ $message = t('%type: !message in %function (line ', $error);
+
+ variable_set('error_level', ERROR_REPORTING_DISPLAY_ALL);
+ $this->drupalGet('common-test/drupal-render-invalid-keys');
+ $this->assertResponse(200, t('Received expected HTTP status code.'));
+ $this->assertRaw($message, t('Found error message: !message.', array('!message' => $message)));
+ }
+
+ protected function assertRenderedElement(array $element, $xpath, array $xpath_args = array()) {
+ $original_element = $element;
+ $this->drupalSetContent(drupal_render($element));
+ $this->verbose('<pre>' . check_plain(var_export($original_element, TRUE)) . '</pre>'
+ . '<pre>' . check_plain(var_export($element, TRUE)) . '</pre>'
+ . '<hr />' . $this->drupalGetContent()
+ );
+
+ // @see DrupalWebTestCase::xpath()
+ $xpath = $this->buildXPathQuery($xpath, $xpath_args);
+ $element += array('#value' => NULL);
+ $this->assertFieldByXPath($xpath, $element['#value'], t('#type @type was properly rendered.', array(
+ '@type' => var_export($element['#type'], TRUE),
+ )));
+ }
+}
+
+/**
+ * Test for valid_url().
+ */
+class ValidUrlTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Valid Url',
+ 'description' => "Performs tests on Drupal's valid url function.",
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Test valid absolute urls.
+ */
+ function testValidAbsolute() {
+ $url_schemes = array('http', 'https', 'ftp');
+ $valid_absolute_urls = array(
+ 'example.com',
+ 'www.example.com',
+ 'ex-ample.com',
+ '3xampl3.com',
+ 'example.com/paren(the)sis',
+ 'example.com/index.html#pagetop',
+ 'example.com:8080',
+ 'subdomain.example.com',
+ 'example.com/index.php?q=node',
+ 'example.com/index.php?q=node&param=false',
+ 'user@www.example.com',
+ 'user:pass@www.example.com:8080/login.php?do=login&style=%23#pagetop',
+ '127.0.0.1',
+ 'example.org?',
+ 'john%20doe:secret:foo@example.org/',
+ 'example.org/~,$\'*;',
+ 'caf%C3%A9.example.org',
+ '[FEDC:BA98:7654:3210:FEDC:BA98:7654:3210]:80/index.html',
+ );
+
+ foreach ($url_schemes as $scheme) {
+ foreach ($valid_absolute_urls as $url) {
+ $test_url = $scheme . '://' . $url;
+ $valid_url = valid_url($test_url, TRUE);
+ $this->assertTrue($valid_url, t('@url is a valid url.', array('@url' => $test_url)));
+ }
+ }
+ }
+
+ /**
+ * Test invalid absolute urls.
+ */
+ function testInvalidAbsolute() {
+ $url_schemes = array('http', 'https', 'ftp');
+ $invalid_ablosule_urls = array(
+ '',
+ 'ex!ample.com',
+ 'ex%ample.com',
+ );
+
+ foreach ($url_schemes as $scheme) {
+ foreach ($invalid_ablosule_urls as $url) {
+ $test_url = $scheme . '://' . $url;
+ $valid_url = valid_url($test_url, TRUE);
+ $this->assertFalse($valid_url, t('@url is NOT a valid url.', array('@url' => $test_url)));
+ }
+ }
+ }
+
+ /**
+ * Test valid relative urls.
+ */
+ function testValidRelative() {
+ $valid_relative_urls = array(
+ 'paren(the)sis',
+ 'index.html#pagetop',
+ 'index.php?q=node',
+ 'index.php?q=node&param=false',
+ 'login.php?do=login&style=%23#pagetop',
+ );
+
+ foreach (array('', '/') as $front) {
+ foreach ($valid_relative_urls as $url) {
+ $test_url = $front . $url;
+ $valid_url = valid_url($test_url);
+ $this->assertTrue($valid_url, t('@url is a valid url.', array('@url' => $test_url)));
+ }
+ }
+ }
+
+ /**
+ * Test invalid relative urls.
+ */
+ function testInvalidRelative() {
+ $invalid_relative_urls = array(
+ 'ex^mple',
+ 'example<>',
+ 'ex%ample',
+ );
+
+ foreach (array('', '/') as $front) {
+ foreach ($invalid_relative_urls as $url) {
+ $test_url = $front . $url;
+ $valid_url = valid_url($test_url);
+ $this->assertFALSE($valid_url, t('@url is NOT a valid url.', array('@url' => $test_url)));
+ }
+ }
+ }
+}
+
+/**
+ * Tests for CRUD API functions.
+ */
+class DrupalDataApiTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Data API functions',
+ 'description' => 'Tests the performance of CRUD APIs.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Test the drupal_write_record() API function.
+ */
+ function testDrupalWriteRecord() {
+ // Insert a record - no columns allow NULL values.
+ $person = new stdClass();
+ $person->name = 'John';
+ $person->unknown_column = 123;
+ $insert_result = drupal_write_record('test', $person);
+ $this->assertTrue($insert_result == SAVED_NEW, t('Correct value returned when a record is inserted with drupal_write_record() for a table with a single-field primary key.'));
+ $this->assertTrue(isset($person->id), t('Primary key is set on record created with drupal_write_record().'));
+ $this->assertIdentical($person->age, 0, t('Age field set to default value.'));
+ $this->assertIdentical($person->job, 'Undefined', t('Job field set to default value.'));
+
+ // Verify that the record was inserted.
+ $result = db_query("SELECT * FROM {test} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'John', t('Name field set.'));
+ $this->assertIdentical($result->age, '0', t('Age field set to default value.'));
+ $this->assertIdentical($result->job, 'Undefined', t('Job field set to default value.'));
+ $this->assertFalse(isset($result->unknown_column), t('Unknown column was ignored.'));
+
+ // Update the newly created record.
+ $person->name = 'Peter';
+ $person->age = 27;
+ $person->job = NULL;
+ $update_result = drupal_write_record('test', $person, array('id'));
+ $this->assertTrue($update_result == SAVED_UPDATED, t('Correct value returned when a record updated with drupal_write_record() for table with single-field primary key.'));
+
+ // Verify that the record was updated.
+ $result = db_query("SELECT * FROM {test} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Peter', t('Name field set.'));
+ $this->assertIdentical($result->age, '27', t('Age field set.'));
+ $this->assertIdentical($result->job, '', t('Job field set and cast to string.'));
+
+ // Try to insert NULL in columns that does not allow this.
+ $person = new stdClass();
+ $person->name = 'Ringo';
+ $person->age = NULL;
+ $person->job = NULL;
+ $insert_result = drupal_write_record('test', $person);
+ $this->assertTrue(isset($person->id), t('Primary key is set on record created with drupal_write_record().'));
+ $result = db_query("SELECT * FROM {test} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Ringo', t('Name field set.'));
+ $this->assertIdentical($result->age, '0', t('Age field set.'));
+ $this->assertIdentical($result->job, '', t('Job field set.'));
+
+ // Insert a record - the "age" column allows NULL.
+ $person = new stdClass();
+ $person->name = 'Paul';
+ $person->age = NULL;
+ $insert_result = drupal_write_record('test_null', $person);
+ $this->assertTrue(isset($person->id), t('Primary key is set on record created with drupal_write_record().'));
+ $result = db_query("SELECT * FROM {test_null} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Paul', t('Name field set.'));
+ $this->assertIdentical($result->age, NULL, t('Age field set.'));
+
+ // Insert a record - do not specify the value of a column that allows NULL.
+ $person = new stdClass();
+ $person->name = 'Meredith';
+ $insert_result = drupal_write_record('test_null', $person);
+ $this->assertTrue(isset($person->id), t('Primary key is set on record created with drupal_write_record().'));
+ $this->assertIdentical($person->age, 0, t('Age field set to default value.'));
+ $result = db_query("SELECT * FROM {test_null} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Meredith', t('Name field set.'));
+ $this->assertIdentical($result->age, '0', t('Age field set to default value.'));
+
+ // Update the newly created record.
+ $person->name = 'Mary';
+ $person->age = NULL;
+ $update_result = drupal_write_record('test_null', $person, array('id'));
+ $result = db_query("SELECT * FROM {test_null} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Mary', t('Name field set.'));
+ $this->assertIdentical($result->age, NULL, t('Age field set.'));
+
+ // Insert a record - the "data" column should be serialized.
+ $person = new stdClass();
+ $person->name = 'Dave';
+ $update_result = drupal_write_record('test_serialized', $person);
+ $result = db_query("SELECT * FROM {test_serialized} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical($result->name, 'Dave', t('Name field set.'));
+ $this->assertIdentical($result->info, NULL, t('Info field set.'));
+
+ $person->info = array();
+ $update_result = drupal_write_record('test_serialized', $person, array('id'));
+ $result = db_query("SELECT * FROM {test_serialized} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical(unserialize($result->info), array(), t('Info field updated.'));
+
+ // Update the serialized record.
+ $data = array('foo' => 'bar', 1 => 2, 'empty' => '', 'null' => NULL);
+ $person->info = $data;
+ $update_result = drupal_write_record('test_serialized', $person, array('id'));
+ $result = db_query("SELECT * FROM {test_serialized} WHERE id = :id", array(':id' => $person->id))->fetchObject();
+ $this->assertIdentical(unserialize($result->info), $data, t('Info field updated.'));
+
+ // Run an update query where no field values are changed. The database
+ // layer should return zero for number of affected rows, but
+ // db_write_record() should still return SAVED_UPDATED.
+ $update_result = drupal_write_record('test_null', $person, array('id'));
+ $this->assertTrue($update_result == SAVED_UPDATED, t('Correct value returned when a valid update is run without changing any values.'));
+
+ // Insert an object record for a table with a multi-field primary key.
+ $node_access = new stdClass();
+ $node_access->nid = mt_rand();
+ $node_access->gid = mt_rand();
+ $node_access->realm = $this->randomName();
+ $insert_result = drupal_write_record('node_access', $node_access);
+ $this->assertTrue($insert_result == SAVED_NEW, t('Correct value returned when a record is inserted with drupal_write_record() for a table with a multi-field primary key.'));
+
+ // Update the record.
+ $update_result = drupal_write_record('node_access', $node_access, array('nid', 'gid', 'realm'));
+ $this->assertTrue($update_result == SAVED_UPDATED, t('Correct value returned when a record is updated with drupal_write_record() for a table with a multi-field primary key.'));
+ }
+
+}
+
+/**
+ * Tests Simpletest error and exception collector.
+ */
+class DrupalErrorCollectionUnitTest extends DrupalWebTestCase {
+
+ /**
+ * Errors triggered during the test.
+ *
+ * Errors are intercepted by the overriden implementation
+ * of DrupalWebTestCase::error below.
+ *
+ * @var Array
+ */
+ protected $collectedErrors = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'SimpleTest error collector',
+ 'description' => 'Performs tests on the Simpletest error and exception collector.',
+ 'group' => 'SimpleTest',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test', 'error_test');
+ }
+
+ /**
+ * Test that simpletest collects errors from the tested site.
+ */
+ function testErrorCollect() {
+ $this->collectedErrors = array();
+ $this->drupalGet('error-test/generate-warnings-with-report');
+ $this->assertEqual(count($this->collectedErrors), 3, t('Three errors were collected'));
+
+ if (count($this->collectedErrors) == 3) {
+ $this->assertError($this->collectedErrors[0], 'Notice', 'error_test_generate_warnings()', 'error_test.module', 'Undefined variable: bananas');
+ $this->assertError($this->collectedErrors[1], 'Warning', 'error_test_generate_warnings()', 'error_test.module', 'Division by zero');
+ $this->assertError($this->collectedErrors[2], 'User warning', 'error_test_generate_warnings()', 'error_test.module', 'Drupal is awesome');
+ }
+ else {
+ // Give back the errors to the log report.
+ foreach ($this->collectedErrors as $error) {
+ parent::error($error['message'], $error['group'], $error['caller']);
+ }
+ }
+ }
+
+ /**
+ * Error handler that collects errors in an array.
+ *
+ * This test class is trying to verify that simpletest correctly sees errors
+ * and warnings. However, it can't generate errors and warnings that
+ * propagate up to the testing framework itself, or these tests would always
+ * fail. So, this special copy of error() doesn't propagate the errors up
+ * the class hierarchy. It just stuffs them into a protected collectedErrors
+ * array for various assertions to inspect.
+ */
+ protected function error($message = '', $group = 'Other', array $caller = NULL) {
+ // Due to a WTF elsewhere, simpletest treats debug() and verbose()
+ // messages as if they were an 'error'. But, we don't want to collect
+ // those here. This function just wants to collect the real errors (PHP
+ // notices, PHP fatal errors, etc.), and let all the 'errors' from the
+ // 'User notice' group bubble up to the parent classes to be handled (and
+ // eventually displayed) as normal.
+ if ($group == 'User notice') {
+ parent::error($message, $group, $caller);
+ }
+ // Everything else should be collected but not propagated.
+ else {
+ $this->collectedErrors[] = array(
+ 'message' => $message,
+ 'group' => $group,
+ 'caller' => $caller
+ );
+ }
+ }
+
+ /**
+ * Assert that a collected error matches what we are expecting.
+ */
+ function assertError($error, $group, $function, $file, $message = NULL) {
+ $this->assertEqual($error['group'], $group, t("Group was %group", array('%group' => $group)));
+ $this->assertEqual($error['caller']['function'], $function, t("Function was %function", array('%function' => $function)));
+ $this->assertEqual(basename($error['caller']['file']), $file, t("File was %file", array('%file' => $file)));
+ if (isset($message)) {
+ $this->assertEqual($error['message'], $message, t("Message was %message", array('%message' => $message)));
+ }
+ }
+}
+
+/**
+ * Test the drupal_parse_info_file() API function.
+ */
+class ParseInfoFilesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Parsing .info files',
+ 'description' => 'Tests parsing .info files.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Parse an example .info file an verify the results.
+ */
+ function testParseInfoFile() {
+ $info_values = drupal_parse_info_file(drupal_get_path('module', 'simpletest') . '/tests/common_test_info.txt');
+ $this->assertEqual($info_values['simple_string'], 'A simple string', t('Simple string value was parsed correctly.'), t('System'));
+ $this->assertEqual($info_values['simple_constant'], WATCHDOG_INFO, t('Constant value was parsed correctly.'), t('System'));
+ $this->assertEqual($info_values['double_colon'], 'dummyClassName::', t('Value containing double-colon was parsed correctly.'), t('System'));
+ }
+}
+
+/**
+ * Tests for the drupal_system_listing() function.
+ */
+class DrupalSystemListingTestCase extends DrupalWebTestCase {
+ /**
+ * Use the testing profile; this is needed for testDirectoryPrecedence().
+ */
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal system listing',
+ 'description' => 'Tests the mechanism for scanning system directories in drupal_system_listing().',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Test that files in different directories take precedence as expected.
+ */
+ function testDirectoryPrecedence() {
+ // Define the module files we will search for, and the directory precedence
+ // we expect.
+ $expected_directories = array(
+ // When the copy of the module in the profile directory is incompatible
+ // with Drupal core, the copy in the core modules directory takes
+ // precedence.
+ 'drupal_system_listing_incompatible_test' => array(
+ 'core/modules/simpletest/tests',
+ 'profiles/testing/modules',
+ ),
+ // When both copies of the module are compatible with Drupal core, the
+ // copy in the profile directory takes precedence.
+ 'drupal_system_listing_compatible_test' => array(
+ 'profiles/testing/modules',
+ 'core/modules/simpletest/tests',
+ ),
+ );
+
+ // This test relies on two versions of the same module existing in
+ // different places in the filesystem. Without that, the test has no
+ // meaning, so assert their presence first.
+ foreach ($expected_directories as $module => $directories) {
+ foreach ($directories as $directory) {
+ $filename = "$directory/$module/$module.module";
+ $this->assertTrue(file_exists(DRUPAL_ROOT . '/' . $filename), t('@filename exists.', array('@filename' => $filename)));
+ }
+ }
+
+ // Now scan the directories and check that the files take precedence as
+ // expected.
+ $files = drupal_system_listing('/\.module$/', 'modules', 'name', 1);
+ foreach ($expected_directories as $module => $directories) {
+ $expected_directory = array_shift($directories);
+ $expected_filename = "$expected_directory/$module/$module.module";
+ $this->assertEqual($files[$module]->uri, $expected_filename, t('Module @module was found at @filename.', array('@module' => $module, '@filename' => $expected_filename)));
+ }
+ }
+}
+
+/**
+ * Tests for the format_date() function.
+ */
+class FormatDateUnitTest extends DrupalWebTestCase {
+
+ /**
+ * Arbitrary langcode for a custom language.
+ */
+ const LANGCODE = 'xx';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Format date',
+ 'description' => 'Test the format_date() function.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale');
+ variable_set('configurable_timezones', 1);
+ variable_set('date_format_long', 'l, j. F Y - G:i');
+ variable_set('date_format_medium', 'j. F Y - G:i');
+ variable_set('date_format_short', 'Y M j - g:ia');
+ variable_set('locale_custom_strings_' . self::LANGCODE, array(
+ '' => array('Sunday' => 'domingo'),
+ 'Long month name' => array('March' => 'marzo'),
+ ));
+ $this->refreshVariables();
+ }
+
+ /**
+ * Test admin-defined formats in format_date().
+ */
+ function testAdminDefinedFormatDate() {
+ // Create an admin user.
+ $this->admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($this->admin_user);
+
+ // Add new date format.
+ $admin_date_format = 'j M y';
+ $edit = array('date_format' => $admin_date_format);
+ $this->drupalPost('admin/config/regional/date-time/formats/add', $edit, t('Add format'));
+
+ // Add new date type.
+ $edit = array(
+ 'date_type' => 'Example Style',
+ 'machine_name' => 'example_style',
+ 'date_format' => $admin_date_format,
+ );
+ $this->drupalPost('admin/config/regional/date-time/types/add', $edit, t('Add date type'));
+
+ $timestamp = strtotime('2007-03-10T00:00:00+00:00');
+ $this->assertIdentical(format_date($timestamp, 'example_style', '', 'America/Los_Angeles'), '9 Mar 07', t('Test format_date() using an admin-defined date type.'));
+ $this->assertIdentical(format_date($timestamp, 'undefined_style'), format_date($timestamp, 'medium'), t('Test format_date() defaulting to medium when $type not found.'));
+ }
+
+ /**
+ * Tests for the format_date() function.
+ */
+ function testFormatDate() {
+ global $user, $language;
+
+ $timestamp = strtotime('2007-03-26T00:00:00+00:00');
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T', 'America/Los_Angeles', 'en'), 'Sunday, 25-Mar-07 17:00:00 PDT', t('Test all parameters.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T', 'America/Los_Angeles', self::LANGCODE), 'domingo, 25-Mar-07 17:00:00 PDT', t('Test translated format.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', '\\l, d-M-y H:i:s T', 'America/Los_Angeles', self::LANGCODE), 'l, 25-Mar-07 17:00:00 PDT', t('Test an escaped format string.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', '\\\\l, d-M-y H:i:s T', 'America/Los_Angeles', self::LANGCODE), '\\domingo, 25-Mar-07 17:00:00 PDT', t('Test format containing backslash character.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', '\\\\\\l, d-M-y H:i:s T', 'America/Los_Angeles', self::LANGCODE), '\\l, 25-Mar-07 17:00:00 PDT', t('Test format containing backslash followed by escaped format string.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T', 'Europe/London', 'en'), 'Monday, 26-Mar-07 01:00:00 BST', t('Test a different time zone.'));
+
+ // Create an admin user and add Spanish language.
+ $admin_user = $this->drupalCreateUser(array('administer languages'));
+ $this->drupalLogin($admin_user);
+ $edit = array(
+ 'predefined_langcode' => 'custom',
+ 'langcode' => self::LANGCODE,
+ 'name' => self::LANGCODE,
+ 'direction' => LANGUAGE_LTR,
+ );
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add custom language'));
+
+ // Set language prefix.
+ $edit = array('prefix[' . self::LANGCODE . ']' => self::LANGCODE);
+ $this->drupalPost('admin/config/regional/language/configure/url', $edit, t('Save configuration'));
+
+ // Create a test user to carry out the tests.
+ $test_user = $this->drupalCreateUser();
+ $this->drupalLogin($test_user);
+ $edit = array('language' => self::LANGCODE, 'mail' => $test_user->mail, 'timezone' => 'America/Los_Angeles');
+ $this->drupalPost('user/' . $test_user->uid . '/edit', $edit, t('Save'));
+
+ // Disable session saving as we are about to modify the global $user.
+ drupal_save_session(FALSE);
+ // Save the original user and language and then replace it with the test user and language.
+ $real_user = $user;
+ $user = user_load($test_user->uid, TRUE);
+ $real_language = $language->language;
+ $language->language = $user->language;
+ // Simulate a Drupal bootstrap with the logged-in user.
+ date_default_timezone_set(drupal_get_user_timezone());
+
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T', 'America/Los_Angeles', 'en'), 'Sunday, 25-Mar-07 17:00:00 PDT', t('Test a different language.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T', 'Europe/London'), 'Monday, 26-Mar-07 01:00:00 BST', t('Test a different time zone.'));
+ $this->assertIdentical(format_date($timestamp, 'custom', 'l, d-M-y H:i:s T'), 'domingo, 25-Mar-07 17:00:00 PDT', t('Test custom date format.'));
+ $this->assertIdentical(format_date($timestamp, 'long'), 'domingo, 25. marzo 2007 - 17:00', t('Test long date format.'));
+ $this->assertIdentical(format_date($timestamp, 'medium'), '25. marzo 2007 - 17:00', t('Test medium date format.'));
+ $this->assertIdentical(format_date($timestamp, 'short'), '2007 Mar 25 - 5:00pm', t('Test short date format.'));
+ $this->assertIdentical(format_date($timestamp), '25. marzo 2007 - 17:00', t('Test default date format.'));
+
+ // Restore the original user and language, and enable session saving.
+ $user = $real_user;
+ $language->language = $real_language;
+ // Restore default time zone.
+ date_default_timezone_set(drupal_get_user_timezone());
+ drupal_save_session(TRUE);
+ }
+}
+
+/**
+ * Tests for the format_date() function.
+ */
+class DrupalAttributesUnitTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'HTML Attributes',
+ 'description' => 'Perform unit tests on the drupal_attributes() function.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Tests that drupal_html_class() cleans the class name properly.
+ */
+ function testDrupalAttributes() {
+ // Verify that special characters are HTML encoded.
+ $this->assertIdentical(drupal_attributes(array('title' => '&"\'<>')), ' title="&amp;&quot;&#039;&lt;&gt;"', t('HTML encode attribute values.'));
+
+ // Verify multi-value attributes are concatenated with spaces.
+ $attributes = array('class' => array('first', 'last'));
+ $this->assertIdentical(drupal_attributes(array('class' => array('first', 'last'))), ' class="first last"', t('Concatenate multi-value attributes.'));
+
+ // Verify empty attribute values are rendered.
+ $this->assertIdentical(drupal_attributes(array('alt' => '')), ' alt=""', t('Empty attribute value #1.'));
+ $this->assertIdentical(drupal_attributes(array('alt' => NULL)), ' alt=""', t('Empty attribute value #2.'));
+
+ // Verify multiple attributes are rendered.
+ $attributes = array(
+ 'id' => 'id-test',
+ 'class' => array('first', 'last'),
+ 'alt' => 'Alternate',
+ );
+ $this->assertIdentical(drupal_attributes($attributes), ' id="id-test" class="first last" alt="Alternate"', t('Multiple attributes.'));
+
+ // Verify empty attributes array is rendered.
+ $this->assertIdentical(drupal_attributes(array()), '', t('Empty attributes array.'));
+ }
+}
+
+/**
+ * Tests converting PHP variables to JSON strings and back.
+ */
+class DrupalJSONTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'JSON',
+ 'description' => 'Perform unit tests on the drupal_json_encode() and drupal_json_decode() functions.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Tests converting PHP variables to JSON strings and back.
+ */
+ function testJSON() {
+ // Setup a string with the full ASCII table.
+ // @todo: Add tests for non-ASCII characters and Unicode.
+ $str = '';
+ for ($i=0; $i < 128; $i++) {
+ $str .= chr($i);
+ }
+ // Characters that must be escaped.
+ $html_unsafe = array('<', '>', '&');
+ $html_unsafe_escaped = array('\u003c', '\u003e', '\u0026');
+
+ // Verify there aren't character encoding problems with the source string.
+ $this->assertIdentical(strlen($str), 128, t('A string with the full ASCII table has the correct length.'));
+ foreach ($html_unsafe as $char) {
+ $this->assertTrue(strpos($str, $char) > 0, t('A string with the full ASCII table includes @s.', array('@s' => $char)));
+ }
+
+ // Verify that JSON encoding produces a string with all of the characters.
+ $json = drupal_json_encode($str);
+ $this->assertTrue(strlen($json) > strlen($str), t('A JSON encoded string is larger than the source string.'));
+
+ // Verify that encoding/decoding is reversible.
+ $json_decoded = drupal_json_decode($json);
+ $this->assertIdentical($str, $json_decoded, t('Encoding a string to JSON and decoding back results in the original string.'));
+
+ // Verify reversibility for structured data. Also verify that necessary
+ // characters are escaped.
+ $source = array(TRUE, FALSE, 0, 1, '0', '1', $str, array('key1' => $str, 'key2' => array('nested' => TRUE)));
+ $json = drupal_json_encode($source);
+ foreach ($html_unsafe as $char) {
+ $this->assertTrue(strpos($json, $char) === FALSE, t('A JSON encoded string does not contain @s.', array('@s' => $char)));
+ }
+ // Verify that JSON encoding escapes the HTML unsafe characters
+ foreach ($html_unsafe_escaped as $char) {
+ $this->assertTrue(strpos($json, $char) > 0, t('A JSON encoded string contains @s.', array('@s' => $char)));
+ }
+ $json_decoded = drupal_json_decode($json);
+ $this->assertNotIdentical($source, $json, t('An array encoded in JSON is not identical to the source.'));
+ $this->assertIdentical($source, $json_decoded, t('Encoding structured data to JSON and decoding back results in the original data.'));
+ }
+}
+
+/**
+ * Basic tests for drupal_add_feed().
+ */
+class DrupalAddFeedTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'drupal_add_feed() tests',
+ 'description' => 'Make sure that drupal_add_feed() works correctly with various constructs.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Test drupal_add_feed() with paths, URLs, and titles.
+ */
+ function testBasicFeedAddNoTitle() {
+ $path = $this->randomName(12);
+ $external_url = 'http://' . $this->randomName(12) . '/' . $this->randomName(12);
+ $fully_qualified_local_url = url($this->randomName(12), array('absolute' => TRUE));
+
+ $path_for_title = $this->randomName(12);
+ $external_for_title = 'http://' . $this->randomName(12) . '/' . $this->randomName(12);
+ $fully_qualified_for_title = url($this->randomName(12), array('absolute' => TRUE));
+
+ // Possible permutations of drupal_add_feed() to test.
+ // - 'input_url': the path passed to drupal_add_feed(),
+ // - 'output_url': the expected URL to be found in the header.
+ // - 'title' == the title of the feed as passed into drupal_add_feed().
+ $urls = array(
+ 'path without title' => array(
+ 'input_url' => $path,
+ 'output_url' => url($path, array('absolute' => TRUE)),
+ 'title' => '',
+ ),
+ 'external url without title' => array(
+ 'input_url' => $external_url,
+ 'output_url' => $external_url,
+ 'title' => '',
+ ),
+ 'local url without title' => array(
+ 'input_url' => $fully_qualified_local_url,
+ 'output_url' => $fully_qualified_local_url,
+ 'title' => '',
+ ),
+ 'path with title' => array(
+ 'input_url' => $path_for_title,
+ 'output_url' => url($path_for_title, array('absolute' => TRUE)),
+ 'title' => $this->randomName(12),
+ ),
+ 'external url with title' => array(
+ 'input_url' => $external_for_title,
+ 'output_url' => $external_for_title,
+ 'title' => $this->randomName(12),
+ ),
+ 'local url with title' => array(
+ 'input_url' => $fully_qualified_for_title,
+ 'output_url' => $fully_qualified_for_title,
+ 'title' => $this->randomName(12),
+ ),
+ );
+
+ foreach ($urls as $description => $feed_info) {
+ drupal_add_feed($feed_info['input_url'], $feed_info['title']);
+ }
+
+ $this->drupalSetContent(drupal_get_html_head());
+ foreach ($urls as $description => $feed_info) {
+ $this->assertPattern($this->urlToRSSLinkPattern($feed_info['output_url'], $feed_info['title']), t('Found correct feed header for %description', array('%description' => $description)));
+ }
+ }
+
+ /**
+ * Create a pattern representing the RSS feed in the page.
+ */
+ function urlToRSSLinkPattern($url, $title = '') {
+ // Escape any regular expression characters in the url ('?' is the worst).
+ $url = preg_replace('/([+?.*])/', '[$0]', $url);
+ $generated_pattern = '%<link +rel="alternate" +type="application/rss.xml" +title="' . $title . '" +href="' . $url . '" */>%';
+ return $generated_pattern;
+ }
+}
diff --git a/core/modules/simpletest/tests/common_test.css b/core/modules/simpletest/tests/common_test.css
new file mode 100644
index 000000000000..b86ceadb7b90
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test.css
@@ -0,0 +1,2 @@
+
+/* This file is for testing CSS file inclusion, no contents are necessary. */
diff --git a/core/modules/simpletest/tests/common_test.info b/core/modules/simpletest/tests/common_test.info
new file mode 100644
index 000000000000..9e6d24f9a24f
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test.info
@@ -0,0 +1,8 @@
+name = "Common Test"
+description = "Support module for Common tests."
+package = Testing
+version = VERSION
+core = 8.x
+stylesheets[all][] = common_test.css
+stylesheets[print][] = common_test.print.css
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/common_test.module b/core/modules/simpletest/tests/common_test.module
new file mode 100644
index 000000000000..08fb8e86c7da
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test.module
@@ -0,0 +1,262 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the Common tests.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function common_test_menu() {
+ $items['common-test/drupal_goto'] = array(
+ 'title' => 'Drupal Goto',
+ 'page callback' => 'common_test_drupal_goto_land',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/drupal_goto/fail'] = array(
+ 'title' => 'Drupal Goto',
+ 'page callback' => 'common_test_drupal_goto_land_fail',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/drupal_goto/redirect'] = array(
+ 'title' => 'Drupal Goto',
+ 'page callback' => 'common_test_drupal_goto_redirect',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/drupal_goto/redirect_advanced'] = array(
+ 'title' => 'Drupal Goto',
+ 'page callback' => 'common_test_drupal_goto_redirect_advanced',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/drupal_goto/redirect_fail'] = array(
+ 'title' => 'Drupal Goto Failure',
+ 'page callback' => 'drupal_goto',
+ 'page arguments' => array('common-test/drupal_goto/fail'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/destination'] = array(
+ 'title' => 'Drupal Get Destination',
+ 'page callback' => 'common_test_destination',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/query-string'] = array(
+ 'title' => 'Test querystring',
+ 'page callback' => 'common_test_js_and_css_querystring',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['common-test/drupal-render-invalid-keys'] = array(
+ 'title' => 'Drupal Render',
+ 'page callback' => 'common_test_drupal_render_invalid_keys',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Redirect using drupal_goto().
+ */
+function common_test_drupal_goto_redirect() {
+ drupal_goto('common-test/drupal_goto');
+}
+
+/**
+ * Redirect using drupal_goto().
+ */
+function common_test_drupal_goto_redirect_advanced() {
+ drupal_goto('common-test/drupal_goto', array('query' => array('foo' => '123')), 301);
+}
+
+/**
+ * Landing page for drupal_goto().
+ */
+function common_test_drupal_goto_land() {
+ print "drupal_goto";
+}
+
+/**
+ * Fail landing page for drupal_goto().
+ */
+function common_test_drupal_goto_land_fail() {
+ print "drupal_goto_fail";
+}
+
+/**
+ * Implements hook_drupal_goto_alter().
+ */
+function common_test_drupal_goto_alter(&$path, &$options, &$http_response_code) {
+ if ($path == 'common-test/drupal_goto/fail') {
+ $path = 'common-test/drupal_goto/redirect';
+ }
+}
+
+/**
+ * Print destination query parameter.
+ */
+function common_test_destination() {
+ $destination = drupal_get_destination();
+ print "The destination: " . check_plain($destination['destination']);
+}
+
+/**
+ * Render an element with an invalid render array key.
+ */
+function common_test_drupal_render_invalid_keys() {
+ define('SIMPLETEST_COLLECT_ERRORS', FALSE);
+
+ // Keys that begin with # may contain a value of any type, otherwise they must
+ // contain arrays.
+ $key = 'child';
+ $value = 'This should be an array.';
+ $element = array(
+ $key => $value,
+ );
+ return drupal_render($element);
+}
+
+/**
+ * Implements hook_TYPE_alter().
+ */
+function common_test_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL) {
+ // Alter first argument.
+ if (is_array($data)) {
+ $data['foo'] = 'Drupal';
+ }
+ elseif (is_object($data)) {
+ $data->foo = 'Drupal';
+ }
+ // Alter second argument, if present.
+ if (isset($arg2)) {
+ if (is_array($arg2)) {
+ $arg2['foo'] = 'Drupal';
+ }
+ elseif (is_object($arg2)) {
+ $arg2->foo = 'Drupal';
+ }
+ }
+ // Try to alter third argument, if present.
+ if (isset($arg3)) {
+ if (is_array($arg3)) {
+ $arg3['foo'] = 'Drupal';
+ }
+ elseif (is_object($arg3)) {
+ $arg3->foo = 'Drupal';
+ }
+ }
+}
+
+/**
+ * Implements hook_TYPE_alter() on behalf of Bartik theme.
+ *
+ * Same as common_test_drupal_alter_alter(), but here, we verify that themes
+ * can also alter and come last.
+ */
+function bartik_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL) {
+ // Alter first argument.
+ if (is_array($data)) {
+ $data['foo'] .= ' theme';
+ }
+ elseif (is_object($data)) {
+ $data->foo .= ' theme';
+ }
+ // Alter second argument, if present.
+ if (isset($arg2)) {
+ if (is_array($arg2)) {
+ $arg2['foo'] .= ' theme';
+ }
+ elseif (is_object($arg2)) {
+ $arg2->foo .= ' theme';
+ }
+ }
+ // Try to alter third argument, if present.
+ if (isset($arg3)) {
+ if (is_array($arg3)) {
+ $arg3['foo'] .= ' theme';
+ }
+ elseif (is_object($arg3)) {
+ $arg3->foo .= ' theme';
+ }
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function common_test_theme() {
+ return array(
+ 'common_test_foo' => array(
+ 'variables' => array('foo' => 'foo', 'bar' => 'bar'),
+ ),
+ );
+}
+
+/**
+ * Theme function for testing drupal_render() theming.
+ */
+function theme_common_test_foo($variables) {
+ return $variables['foo'] . $variables['bar'];
+}
+
+/**
+ * Implements hook_library_info_alter().
+ */
+function common_test_library_info_alter(&$libraries, $module) {
+ if ($module == 'system' && isset($libraries['farbtastic'])) {
+ // Change the title of Farbtastic to "Farbtastic: Altered Library".
+ $libraries['farbtastic']['title'] = 'Farbtastic: Altered Library';
+ // Make Farbtastic depend on jQuery Form to test library dependencies.
+ $libraries['farbtastic']['dependencies'][] = array('system', 'form');
+ }
+}
+
+/**
+ * Implements hook_library_info().
+ *
+ * Adds Farbtastic in a different version.
+ */
+function common_test_library_info() {
+ $libraries['farbtastic'] = array(
+ 'title' => 'Custom Farbtastic Library',
+ 'website' => 'http://code.google.com/p/farbtastic/',
+ 'version' => '5.3',
+ 'js' => array(
+ 'core/misc/farbtastic/farbtastic.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/farbtastic/farbtastic.css' => array(),
+ ),
+ );
+ return $libraries;
+}
+
+/**
+ * Adds a JavaScript file and a CSS file with a query string appended.
+ */
+function common_test_js_and_css_querystring() {
+ drupal_add_js(drupal_get_path('module', 'node') . '/node.js');
+ drupal_add_css(drupal_get_path('module', 'node') . '/node.css');
+ // A relative URI may have a query string.
+ drupal_add_css('/' . drupal_get_path('module', 'node') . '/node-fake.css?arg1=value1&arg2=value2');
+ return '';
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * System module should handle if a module does not catch an exception and keep
+ * cron going.
+ *
+ * @see common_test_cron_helper()
+ *
+ */
+function common_test_cron() {
+ throw new Exception(t('Uncaught exception'));
+}
diff --git a/core/modules/simpletest/tests/common_test.print.css b/core/modules/simpletest/tests/common_test.print.css
new file mode 100644
index 000000000000..b86ceadb7b90
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test.print.css
@@ -0,0 +1,2 @@
+
+/* This file is for testing CSS file inclusion, no contents are necessary. */
diff --git a/core/modules/simpletest/tests/common_test_cron_helper.info b/core/modules/simpletest/tests/common_test_cron_helper.info
new file mode 100644
index 000000000000..ce1a6326fb33
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test_cron_helper.info
@@ -0,0 +1,6 @@
+name = "Common Test Cron Helper"
+description = "Helper module for CronRunTestCase::testCronExceptions()."
+package = Testing
+version = VERSION
+core = 7.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/common_test_cron_helper.module b/core/modules/simpletest/tests/common_test_cron_helper.module
new file mode 100644
index 000000000000..94a2b2c43821
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test_cron_helper.module
@@ -0,0 +1,17 @@
+<?php
+/**
+ * @file
+ * Helper module for the testCronExceptions in addition to common_test module.
+ */
+
+/**
+ * Implements hook_cron().
+ *
+ * common_test_cron() throws an exception, but the execution should reach this
+ * function as well.
+ *
+ * @see common_test_cron()
+ */
+function common_test_cron_helper_cron() {
+ variable_set('common_test_cron', 'success');
+}
diff --git a/core/modules/simpletest/tests/common_test_info.txt b/core/modules/simpletest/tests/common_test_info.txt
new file mode 100644
index 000000000000..ae217b917036
--- /dev/null
+++ b/core/modules/simpletest/tests/common_test_info.txt
@@ -0,0 +1,9 @@
+; Test parsing with a simple string.
+simple_string = A simple string
+
+; Test that constants can be used as values.
+simple_constant = WATCHDOG_INFO
+
+; After parsing the .info file, 'double_colon' should hold the literal value.
+; Parsing should not throw a fatal error or try to access a class constant.
+double_colon = dummyClassName::
diff --git a/core/modules/simpletest/tests/database_test.info b/core/modules/simpletest/tests/database_test.info
new file mode 100644
index 000000000000..7f3866178339
--- /dev/null
+++ b/core/modules/simpletest/tests/database_test.info
@@ -0,0 +1,6 @@
+name = "Database Test"
+description = "Support module for Database layer tests."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/database_test.install b/core/modules/simpletest/tests/database_test.install
new file mode 100644
index 000000000000..4dce2b19af88
--- /dev/null
+++ b/core/modules/simpletest/tests/database_test.install
@@ -0,0 +1,217 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the database_test module.
+ */
+
+/**
+ * Implements hook_schema().
+ *
+ * The database tests use the database API which depends on schema
+ * information for certain operations on certain databases.
+ * Therefore, the schema must actually be declared in a normal module
+ * like any other, not directly in the test file.
+ */
+function database_test_schema() {
+ $schema['test'] = array(
+ 'description' => 'Basic test table for the database unit tests.',
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'name' => array(
+ 'description' => "A person's name",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'age' => array(
+ 'description' => "The person's age",
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'job' => array(
+ 'description' => "The person's job",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => 'Undefined',
+ ),
+ ),
+ 'primary key' => array('id'),
+ 'unique keys' => array(
+ 'name' => array('name')
+ ),
+ 'indexes' => array(
+ 'ages' => array('age'),
+ ),
+ );
+
+ // This is an alternate version of the same table that is structured the same
+ // but has a non-serial Primary Key.
+ $schema['test_people'] = array(
+ 'description' => 'A duplicate version of the test table, used for additional tests.',
+ 'fields' => array(
+ 'name' => array(
+ 'description' => "A person's name",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'age' => array(
+ 'description' => "The person's age",
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'job' => array(
+ 'description' => "The person's job",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'primary key' => array('job'),
+ 'indexes' => array(
+ 'ages' => array('age'),
+ ),
+ );
+
+ $schema['test_one_blob'] = array(
+ 'description' => 'A simple table including a BLOB field for testing BLOB behavior.',
+ 'fields' => array(
+ 'id' => array(
+ 'description' => 'Simple unique ID.',
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ ),
+ 'blob1' => array(
+ 'description' => 'A BLOB field.',
+ 'type' => 'blob',
+ ),
+ ),
+ 'primary key' => array('id'),
+ );
+
+ $schema['test_two_blobs'] = array(
+ 'description' => 'A simple test table with two BLOB fields.',
+ 'fields' => array(
+ 'id' => array(
+ 'description' => 'Simple unique ID.',
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ ),
+ 'blob1' => array(
+ 'description' => 'A dummy BLOB field.',
+ 'type' => 'blob',
+ ),
+ 'blob2' => array(
+ 'description' => 'A second BLOB field.',
+ 'type' => 'blob'
+ ),
+ ),
+ 'primary key' => array('id'),
+ );
+
+ $schema['test_task'] = array(
+ 'description' => 'A task list for people in the test table.',
+ 'fields' => array(
+ 'tid' => array(
+ 'description' => 'Task ID, primary key.',
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ ),
+ 'pid' => array(
+ 'description' => 'The {test_people}.pid, foreign key for the test table.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'task' => array(
+ 'description' => 'The task to be completed.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'priority' => array(
+ 'description' => 'The priority of the task.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('tid'),
+ );
+
+ $schema['test_null'] = array(
+ 'description' => 'Basic test table for NULL value handling.',
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'name' => array(
+ 'description' => "A person's name.",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'default' => '',
+ ),
+ 'age' => array(
+ 'description' => "The person's age.",
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => FALSE,
+ 'default' => 0),
+ ),
+ 'primary key' => array('id'),
+ 'unique keys' => array(
+ 'name' => array('name')
+ ),
+ 'indexes' => array(
+ 'ages' => array('age'),
+ ),
+ );
+
+ $schema['test_serialized'] = array(
+ 'description' => 'Basic test table for NULL value handling.',
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'name' => array(
+ 'description' => "A person's name.",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'default' => '',
+ ),
+ 'info' => array(
+ 'description' => "The person's data in serialized form.",
+ 'type' => 'blob',
+ 'serialize' => TRUE,
+ ),
+ ),
+ 'primary key' => array('id'),
+ 'unique keys' => array(
+ 'name' => array('name')
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/simpletest/tests/database_test.module b/core/modules/simpletest/tests/database_test.module
new file mode 100644
index 000000000000..6fac31919c73
--- /dev/null
+++ b/core/modules/simpletest/tests/database_test.module
@@ -0,0 +1,241 @@
+<?php
+
+/**
+ * Implements hook_query_alter().
+ */
+function database_test_query_alter(QueryAlterableInterface $query) {
+
+ if ($query->hasTag('database_test_alter_add_range')) {
+ $query->range(0, 2);
+ }
+
+ if ($query->hasTag('database_test_alter_add_join')) {
+ $people_alias = $query->join('test', 'people', "test_task.pid = %alias.id");
+ $name_field = $query->addField($people_alias, 'name', 'name');
+ $query->condition($people_alias . '.id', 2);
+ }
+
+ if ($query->hasTag('database_test_alter_change_conditional')) {
+ $conditions =& $query->conditions();
+ $conditions[0]['value'] = 2;
+ }
+
+ if ($query->hasTag('database_test_alter_change_fields')) {
+ $fields =& $query->getFields();
+ unset($fields['age']);
+ }
+
+ if ($query->hasTag('database_test_alter_change_expressions')) {
+ $expressions =& $query->getExpressions();
+ $expressions['double_age']['expression'] = 'age*3';
+ }
+}
+
+
+/**
+ * Implements hook_query_TAG_alter().
+ *
+ * Called by DatabaseTestCase::testAlterRemoveRange.
+ */
+function database_test_query_database_test_alter_remove_range_alter(QueryAlterableInterface $query) {
+ $query->range();
+}
+
+/**
+ * Implements hook_menu().
+ */
+function database_test_menu() {
+ $items['database_test/db_query_temporary'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'database_test_db_query_temporary',
+ );
+ $items['database_test/pager_query_even'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'database_test_even_pager_query',
+ );
+ $items['database_test/pager_query_odd'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'database_test_odd_pager_query',
+ );
+ $items['database_test/tablesort'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'database_test_tablesort',
+ );
+ $items['database_test/tablesort_first'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'database_test_tablesort_first',
+ );
+ $items['database_test/tablesort_default_sort'] = array(
+ 'access callback' => TRUE,
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('database_test_theme_tablesort'),
+ );
+ return $items;
+}
+
+/**
+ * Run a db_query_temporary and output the table name and its number of rows.
+ *
+ * We need to test that the table created is temporary, so we run it here, in a
+ * separate menu callback request; After this request is done, the temporary
+ * table should automatically dropped.
+ */
+function database_test_db_query_temporary() {
+ $table_name = db_query_temporary('SELECT status FROM {system}', array());
+ drupal_json_output(array(
+ 'table_name' => $table_name,
+ 'row_count' => db_select($table_name)->countQuery()->execute()->fetchField(),
+ ));
+ exit;
+}
+
+/**
+ * Run a pager query and return the results.
+ *
+ * This function does care about the page GET parameter, as set by the
+ * simpletest HTTP call.
+ */
+function database_test_even_pager_query($limit) {
+
+ $query = db_select('test', 't');
+ $query
+ ->fields('t', array('name'))
+ ->orderBy('age');
+
+ // This should result in 2 pages of results.
+ $query = $query->extend('PagerDefault')->limit($limit);
+
+ $names = $query->execute()->fetchCol();
+
+ drupal_json_output(array(
+ 'names' => $names,
+ ));
+ exit;
+}
+
+/**
+ * Run a pager query and return the results.
+ *
+ * This function does care about the page GET parameter, as set by the
+ * simpletest HTTP call.
+ */
+function database_test_odd_pager_query($limit) {
+
+ $query = db_select('test_task', 't');
+ $query
+ ->fields('t', array('task'))
+ ->orderBy('pid');
+
+ // This should result in 4 pages of results.
+ $query = $query->extend('PagerDefault')->limit($limit);
+
+ $names = $query->execute()->fetchCol();
+
+ drupal_json_output(array(
+ 'names' => $names,
+ ));
+ exit;
+}
+
+/**
+ * Run a tablesort query and return the results.
+ *
+ * This function does care about the page GET parameter, as set by the
+ * simpletest HTTP call.
+ */
+function database_test_tablesort() {
+ $header = array(
+ 'tid' => array('data' => t('Task ID'), 'field' => 'tid', 'sort' => 'desc'),
+ 'pid' => array('data' => t('Person ID'), 'field' => 'pid'),
+ 'task' => array('data' => t('Task'), 'field' => 'task'),
+ 'priority' => array('data' => t('Priority'), 'field' => 'priority', ),
+ );
+
+ $query = db_select('test_task', 't');
+ $query
+ ->fields('t', array('tid', 'pid', 'task', 'priority'));
+
+ $query = $query->extend('TableSort')->orderByHeader($header);
+
+ // We need all the results at once to check the sort.
+ $tasks = $query->execute()->fetchAll();
+
+ drupal_json_output(array(
+ 'tasks' => $tasks,
+ ));
+ exit;
+}
+
+/**
+ * Run a tablesort query with a second order_by after and return the results.
+ *
+ * This function does care about the page GET parameter, as set by the
+ * simpletest HTTP call.
+ */
+function database_test_tablesort_first() {
+ $header = array(
+ 'tid' => array('data' => t('Task ID'), 'field' => 'tid', 'sort' => 'desc'),
+ 'pid' => array('data' => t('Person ID'), 'field' => 'pid'),
+ 'task' => array('data' => t('Task'), 'field' => 'task'),
+ 'priority' => array('data' => t('Priority'), 'field' => 'priority', ),
+ );
+
+ $query = db_select('test_task', 't');
+ $query
+ ->fields('t', array('tid', 'pid', 'task', 'priority'));
+
+ $query = $query->extend('TableSort')->orderByHeader($header)->orderBy('priority');
+
+ // We need all the results at once to check the sort.
+ $tasks = $query->execute()->fetchAll();
+
+ drupal_json_output(array(
+ 'tasks' => $tasks,
+ ));
+ exit;
+}
+
+/**
+ * Output a form without setting a header sort.
+ */
+function database_test_theme_tablesort($form, &$form_state) {
+ $header = array(
+ 'username' => array('data' => t('Username'), 'field' => 'u.name'),
+ 'status' => array('data' => t('Status'), 'field' => 'u.status'),
+ );
+
+ $query = db_select('users', 'u');
+ $query->condition('u.uid', 0, '<>');
+ user_build_filter_query($query);
+
+ $count_query = clone $query;
+ $count_query->addExpression('COUNT(u.uid)');
+
+ $query = $query->extend('PagerDefault')->extend('TableSort');
+ $query
+ ->fields('u', array('uid', 'name', 'status', 'created', 'access'))
+ ->limit(50)
+ ->orderByHeader($header)
+ ->setCountQuery($count_query);
+ $result = $query->execute();
+
+ $options = array();
+
+ $status = array(t('blocked'), t('active'));
+ $accounts = array();
+ foreach ($result as $account) {
+ $options[$account->uid] = array(
+ 'username' => check_plain($account->name),
+ 'status' => $status[$account->status],
+ );
+ }
+
+ $form['accounts'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#empty' => t('No people available.'),
+ );
+
+ return $form;
+}
diff --git a/core/modules/simpletest/tests/database_test.test b/core/modules/simpletest/tests/database_test.test
new file mode 100644
index 000000000000..87d386aa7e5b
--- /dev/null
+++ b/core/modules/simpletest/tests/database_test.test
@@ -0,0 +1,3691 @@
+<?php
+
+/**
+ * Dummy class for fetching into a class.
+ *
+ * PDO supports using a new instance of an arbitrary class for records
+ * rather than just a stdClass or array. This class is for testing that
+ * functionality. (See testQueryFetchClass() below)
+ */
+class FakeRecord { }
+
+/**
+ * Base test class for databases.
+ *
+ * Because all database tests share the same test data, we can centralize that
+ * here.
+ */
+class DatabaseTestCase extends DrupalWebTestCase {
+ protected $profile = 'testing';
+
+ function setUp() {
+ parent::setUp('database_test');
+
+ $schema['test'] = drupal_get_schema('test');
+ $schema['test_people'] = drupal_get_schema('test_people');
+ $schema['test_one_blob'] = drupal_get_schema('test_one_blob');
+ $schema['test_two_blobs'] = drupal_get_schema('test_two_blobs');
+ $schema['test_task'] = drupal_get_schema('test_task');
+
+ $this->installTables($schema);
+
+ $this->addSampleData();
+ }
+
+ /**
+ * Set up several tables needed by a certain test.
+ *
+ * @param $schema
+ * An array of table definitions to install.
+ */
+ function installTables($schema) {
+ // This ends up being a test for table drop and create, too, which is nice.
+ foreach ($schema as $name => $data) {
+ if (db_table_exists($name)) {
+ db_drop_table($name);
+ }
+ db_create_table($name, $data);
+ }
+
+ foreach ($schema as $name => $data) {
+ $this->assertTrue(db_table_exists($name), t('Table @name created successfully.', array('@name' => $name)));
+ }
+ }
+
+ /**
+ * Set up tables for NULL handling.
+ */
+ function ensureSampleDataNull() {
+ $schema['test_null'] = drupal_get_schema('test_null');
+ $this->installTables($schema);
+
+ db_insert('test_null')
+ ->fields(array('name', 'age'))
+ ->values(array(
+ 'name' => 'Kermit',
+ 'age' => 25,
+ ))
+ ->values(array(
+ 'name' => 'Fozzie',
+ 'age' => NULL,
+ ))
+ ->values(array(
+ 'name' => 'Gonzo',
+ 'age' => 27,
+ ))
+ ->execute();
+ }
+
+ /**
+ * Setup our sample data.
+ *
+ * These are added using db_query(), since we're not trying to test the
+ * INSERT operations here, just populate.
+ */
+ function addSampleData() {
+ // We need the IDs, so we can't use a multi-insert here.
+ $john = db_insert('test')
+ ->fields(array(
+ 'name' => 'John',
+ 'age' => 25,
+ 'job' => 'Singer',
+ ))
+ ->execute();
+
+ $george = db_insert('test')
+ ->fields(array(
+ 'name' => 'George',
+ 'age' => 27,
+ 'job' => 'Singer',
+ ))
+ ->execute();
+
+ $ringo = db_insert('test')
+ ->fields(array(
+ 'name' => 'Ringo',
+ 'age' => 28,
+ 'job' => 'Drummer',
+ ))
+ ->execute();
+
+ $paul = db_insert('test')
+ ->fields(array(
+ 'name' => 'Paul',
+ 'age' => 26,
+ 'job' => 'Songwriter',
+ ))
+ ->execute();
+
+ db_insert('test_people')
+ ->fields(array(
+ 'name' => 'Meredith',
+ 'age' => 30,
+ 'job' => 'Speaker',
+ ))
+ ->execute();
+
+ db_insert('test_task')
+ ->fields(array('pid', 'task', 'priority'))
+ ->values(array(
+ 'pid' => $john,
+ 'task' => 'eat',
+ 'priority' => 3,
+ ))
+ ->values(array(
+ 'pid' => $john,
+ 'task' => 'sleep',
+ 'priority' => 4,
+ ))
+ ->values(array(
+ 'pid' => $john,
+ 'task' => 'code',
+ 'priority' => 1,
+ ))
+ ->values(array(
+ 'pid' => $george,
+ 'task' => 'sing',
+ 'priority' => 2,
+ ))
+ ->values(array(
+ 'pid' => $george,
+ 'task' => 'sleep',
+ 'priority' => 2,
+ ))
+ ->values(array(
+ 'pid' => $paul,
+ 'task' => 'found new band',
+ 'priority' => 1,
+ ))
+ ->values(array(
+ 'pid' => $paul,
+ 'task' => 'perform at superbowl',
+ 'priority' => 3,
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Test connection management.
+ */
+class DatabaseConnectionTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Connection tests',
+ 'description' => 'Tests of the core database system.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that connections return appropriate connection objects.
+ */
+ function testConnectionRouting() {
+ // Clone the master credentials to a slave connection.
+ // Note this will result in two independent connection objects that happen
+ // to point to the same place.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::addConnectionInfo('default', 'slave', $connection_info['default']);
+
+ $db1 = Database::getConnection('default', 'default');
+ $db2 = Database::getConnection('slave', 'default');
+
+ $this->assertNotNull($db1, t('default connection is a real connection object.'));
+ $this->assertNotNull($db2, t('slave connection is a real connection object.'));
+ $this->assertNotIdentical($db1, $db2, t('Each target refers to a different connection.'));
+
+ // Try to open those targets another time, that should return the same objects.
+ $db1b = Database::getConnection('default', 'default');
+ $db2b = Database::getConnection('slave', 'default');
+ $this->assertIdentical($db1, $db1b, t('A second call to getConnection() returns the same object.'));
+ $this->assertIdentical($db2, $db2b, t('A second call to getConnection() returns the same object.'));
+
+ // Try to open an unknown target.
+ $unknown_target = $this->randomName();
+ $db3 = Database::getConnection($unknown_target, 'default');
+ $this->assertNotNull($db3, t('Opening an unknown target returns a real connection object.'));
+ $this->assertIdentical($db1, $db3, t('An unknown target opens the default connection.'));
+
+ // Try to open that unknown target another time, that should return the same object.
+ $db3b = Database::getConnection($unknown_target, 'default');
+ $this->assertIdentical($db3, $db3b, t('A second call to getConnection() returns the same object.'));
+ }
+
+ /**
+ * Test that connections return appropriate connection objects.
+ */
+ function testConnectionRoutingOverride() {
+ // Clone the master credentials to a slave connection.
+ // Note this will result in two independent connection objects that happen
+ // to point to the same place.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::addConnectionInfo('default', 'slave', $connection_info['default']);
+
+ Database::ignoreTarget('default', 'slave');
+
+ $db1 = Database::getConnection('default', 'default');
+ $db2 = Database::getConnection('slave', 'default');
+
+ $this->assertIdentical($db1, $db2, t('Both targets refer to the same connection.'));
+ }
+
+ /**
+ * Tests the closing of a database connection.
+ */
+ function testConnectionClosing() {
+ // Open the default target so we have an object to compare.
+ $db1 = Database::getConnection('default', 'default');
+
+ // Try to close the the default connection, then open a new one.
+ Database::closeConnection('default', 'default');
+ $db2 = Database::getConnection('default', 'default');
+
+ // Opening a connection after closing it should yield an object different than the original.
+ $this->assertNotIdentical($db1, $db2, t('Opening the default connection after it is closed returns a new object.'));
+ }
+
+ /**
+ * Tests the connection options of the active database.
+ */
+ function testConnectionOptions() {
+ $connection_info = Database::getConnectionInfo('default');
+
+ // Be sure we're connected to the default database.
+ $db = Database::getConnection('default', 'default');
+ $connectionOptions = $db->getConnectionOptions();
+
+ // In the MySQL driver, the port can be different, so check individual
+ // options.
+ $this->assertEqual($connection_info['default']['driver'], $connectionOptions['driver'], t('The default connection info driver matches the current connection options driver.'));
+ $this->assertEqual($connection_info['default']['database'], $connectionOptions['database'], t('The default connection info database matches the current connection options database.'));
+
+ // Set up identical slave and confirm connection options are identical.
+ Database::addConnectionInfo('default', 'slave', $connection_info['default']);
+ $db2 = Database::getConnection('slave', 'default');
+ $connectionOptions2 = $db2->getConnectionOptions();
+
+ // Get a fresh copy of the default connection options.
+ $connectionOptions = $db->getConnectionOptions();
+ $this->assertIdentical($connectionOptions, $connectionOptions2, t('The default and slave connection options are identical.'));
+
+ // Set up a new connection with different connection info.
+ $test = $connection_info['default'];
+ $test['database'] .= 'test';
+ Database::addConnectionInfo('test', 'default', $test);
+ $connection_info = Database::getConnectionInfo('test');
+
+ // Get a fresh copy of the default connection options.
+ $connectionOptions = $db->getConnectionOptions();
+ $this->assertNotEqual($connection_info['default']['database'], $connectionOptions['database'], t('The test connection info database does not match the current connection options database.'));
+ }
+}
+
+/**
+ * Test fetch actions, part 1.
+ *
+ * We get timeout errors if we try to run too many tests at once.
+ */
+class DatabaseFetchTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Fetch tests',
+ 'description' => 'Test the Database system\'s various fetch capabilities.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that we can fetch a record properly in default object mode.
+ */
+ function testQueryFetchDefault() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25));
+ $this->assertTrue($result instanceof DatabaseStatementInterface, t('Result set is a Drupal statement object.'));
+ foreach ($result as $record) {
+ $records[] = $record;
+ $this->assertTrue(is_object($record), t('Record is an object.'));
+ $this->assertIdentical($record->name, 'John', t('25 year old is John.'));
+ }
+
+ $this->assertIdentical(count($records), 1, t('There is only one record.'));
+ }
+
+ /**
+ * Confirm that we can fetch a record to an object explicitly.
+ */
+ function testQueryFetchObject() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25), array('fetch' => PDO::FETCH_OBJ));
+ foreach ($result as $record) {
+ $records[] = $record;
+ $this->assertTrue(is_object($record), t('Record is an object.'));
+ $this->assertIdentical($record->name, 'John', t('25 year old is John.'));
+ }
+
+ $this->assertIdentical(count($records), 1, t('There is only one record.'));
+ }
+
+ /**
+ * Confirm that we can fetch a record to an array associative explicitly.
+ */
+ function testQueryFetchArray() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25), array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $record) {
+ $records[] = $record;
+ if ($this->assertTrue(is_array($record), t('Record is an array.'))) {
+ $this->assertIdentical($record['name'], 'John', t('Record can be accessed associatively.'));
+ }
+ }
+
+ $this->assertIdentical(count($records), 1, t('There is only one record.'));
+ }
+
+ /**
+ * Confirm that we can fetch a record into a new instance of a custom class.
+ *
+ * @see FakeRecord
+ */
+ function testQueryFetchClass() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25), array('fetch' => 'FakeRecord'));
+ foreach ($result as $record) {
+ $records[] = $record;
+ if ($this->assertTrue($record instanceof FakeRecord, t('Record is an object of class FakeRecord.'))) {
+ $this->assertIdentical($record->name, 'John', t('25 year old is John.'));
+ }
+ }
+
+ $this->assertIdentical(count($records), 1, t('There is only one record.'));
+ }
+}
+
+/**
+ * Test fetch actions, part 2.
+ *
+ * We get timeout errors if we try to run too many tests at once.
+ */
+class DatabaseFetch2TestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Fetch tests, part 2',
+ 'description' => 'Test the Database system\'s various fetch capabilities.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ }
+
+ // Confirm that we can fetch a record into an indexed array explicitly.
+ function testQueryFetchNum() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25), array('fetch' => PDO::FETCH_NUM));
+ foreach ($result as $record) {
+ $records[] = $record;
+ if ($this->assertTrue(is_array($record), t('Record is an array.'))) {
+ $this->assertIdentical($record[0], 'John', t('Record can be accessed numerically.'));
+ }
+ }
+
+ $this->assertIdentical(count($records), 1, 'There is only one record');
+ }
+
+ /**
+ * Confirm that we can fetch a record into a doubly-keyed array explicitly.
+ */
+ function testQueryFetchBoth() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 25), array('fetch' => PDO::FETCH_BOTH));
+ foreach ($result as $record) {
+ $records[] = $record;
+ if ($this->assertTrue(is_array($record), t('Record is an array.'))) {
+ $this->assertIdentical($record[0], 'John', t('Record can be accessed numerically.'));
+ $this->assertIdentical($record['name'], 'John', t('Record can be accessed associatively.'));
+ }
+ }
+
+ $this->assertIdentical(count($records), 1, t('There is only one record.'));
+ }
+
+ /**
+ * Confirm that we can fetch an entire column of a result set at once.
+ */
+ function testQueryFetchCol() {
+ $records = array();
+ $result = db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25));
+ $column = $result->fetchCol();
+ $this->assertIdentical(count($column), 3, t('fetchCol() returns the right number of records.'));
+
+ $result = db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25));
+ $i = 0;
+ foreach ($result as $record) {
+ $this->assertIdentical($record->name, $column[$i++], t('Column matches direct accesss.'));
+ }
+ }
+}
+
+/**
+ * Test the insert builder.
+ */
+class DatabaseInsertTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Insert tests',
+ 'description' => 'Test the Insert query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test the very basic insert functionality.
+ */
+ function testSimpleInsert() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ $query = db_insert('test');
+ $query->fields(array(
+ 'name' => 'Yoko',
+ 'age' => '29',
+ ));
+ $query->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+ $this->assertIdentical($num_records_before + 1, (int) $num_records_after, t('Record inserts correctly.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Yoko'))->fetchField();
+ $this->assertIdentical($saved_age, '29', t('Can retrieve after inserting.'));
+ }
+
+ /**
+ * Test that we can insert multiple records in one query object.
+ */
+ function testMultiInsert() {
+ $num_records_before = (int) db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ $query = db_insert('test');
+ $query->fields(array(
+ 'name' => 'Larry',
+ 'age' => '30',
+ ));
+
+ // We should be able to specify values in any order if named.
+ $query->values(array(
+ 'age' => '31',
+ 'name' => 'Curly',
+ ));
+
+ // We should be able to say "use the field order".
+ // This is not the recommended mechanism for most cases, but it should work.
+ $query->values(array('Moe', '32'));
+ $query->execute();
+
+ $num_records_after = (int) db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+ $this->assertIdentical($num_records_before + 3, $num_records_after, t('Record inserts correctly.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Larry'))->fetchField();
+ $this->assertIdentical($saved_age, '30', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Curly'))->fetchField();
+ $this->assertIdentical($saved_age, '31', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Moe'))->fetchField();
+ $this->assertIdentical($saved_age, '32', t('Can retrieve after inserting.'));
+ }
+
+ /**
+ * Test that an insert object can be reused with new data after it executes.
+ */
+ function testRepeatedInsert() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ $query = db_insert('test');
+
+ $query->fields(array(
+ 'name' => 'Larry',
+ 'age' => '30',
+ ));
+ $query->execute(); // This should run the insert, but leave the fields intact.
+
+ // We should be able to specify values in any order if named.
+ $query->values(array(
+ 'age' => '31',
+ 'name' => 'Curly',
+ ));
+ $query->execute();
+
+ // We should be able to say "use the field order".
+ $query->values(array('Moe', '32'));
+ $query->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+ $this->assertIdentical((int) $num_records_before + 3, (int) $num_records_after, t('Record inserts correctly.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Larry'))->fetchField();
+ $this->assertIdentical($saved_age, '30', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Curly'))->fetchField();
+ $this->assertIdentical($saved_age, '31', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Moe'))->fetchField();
+ $this->assertIdentical($saved_age, '32', t('Can retrieve after inserting.'));
+ }
+
+ /**
+ * Test that we can specify fields without values and specify values later.
+ */
+ function testInsertFieldOnlyDefinintion() {
+ // This is useful for importers, when we want to create a query and define
+ // its fields once, then loop over a multi-insert execution.
+ db_insert('test')
+ ->fields(array('name', 'age'))
+ ->values(array('Larry', '30'))
+ ->values(array('Curly', '31'))
+ ->values(array('Moe', '32'))
+ ->execute();
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Larry'))->fetchField();
+ $this->assertIdentical($saved_age, '30', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Curly'))->fetchField();
+ $this->assertIdentical($saved_age, '31', t('Can retrieve after inserting.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Moe'))->fetchField();
+ $this->assertIdentical($saved_age, '32', t('Can retrieve after inserting.'));
+ }
+
+ /**
+ * Test that inserts return the proper auto-increment ID.
+ */
+ function testInsertLastInsertID() {
+ $id = db_insert('test')
+ ->fields(array(
+ 'name' => 'Larry',
+ 'age' => '30',
+ ))
+ ->execute();
+
+ $this->assertIdentical($id, '5', t('Auto-increment ID returned successfully.'));
+ }
+
+ /**
+ * Test that the INSERT INTO ... SELECT ... syntax works.
+ */
+ function testInsertSelect() {
+ $query = db_select('test_people', 'tp');
+ // The query builder will always append expressions after fields.
+ // Add the expression first to test that the insert fields are correctly
+ // re-ordered.
+ $query->addExpression('tp.age', 'age');
+ $query
+ ->fields('tp', array('name','job'))
+ ->condition('tp.name', 'Meredith');
+
+ // The resulting query should be equivalent to:
+ // INSERT INTO test (age, name, job)
+ // SELECT tp.age AS age, tp.name AS name, tp.job AS job
+ // FROM test_people tp
+ // WHERE tp.name = 'Meredith'
+ db_insert('test')
+ ->from($query)
+ ->execute();
+
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Meredith'))->fetchField();
+ $this->assertIdentical($saved_age, '30', t('Can retrieve after inserting.'));
+ }
+}
+
+/**
+ * Insert tests using LOB fields, which are weird on some databases.
+ */
+class DatabaseInsertLOBTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Insert tests, LOB fields',
+ 'description' => 'Test the Insert query builder with LOB fields.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that we can insert a single blob field successfully.
+ */
+ function testInsertOneBlob() {
+ $data = "This is\000a test.";
+ $this->assertTrue(strlen($data) === 15, t('Test data contains a NULL.'));
+ $id = db_insert('test_one_blob')
+ ->fields(array('blob1' => $data))
+ ->execute();
+ $r = db_query('SELECT * FROM {test_one_blob} WHERE id = :id', array(':id' => $id))->fetchAssoc();
+ $this->assertTrue($r['blob1'] === $data, t('Can insert a blob: id @id, @data.', array('@id' => $id, '@data' => serialize($r))));
+ }
+
+ /**
+ * Test that we can insert multiple blob fields in the same query.
+ */
+ function testInsertMultipleBlob() {
+ $id = db_insert('test_two_blobs')
+ ->fields(array(
+ 'blob1' => 'This is',
+ 'blob2' => 'a test',
+ ))
+ ->execute();
+ $r = db_query('SELECT * FROM {test_two_blobs} WHERE id = :id', array(':id' => $id))->fetchAssoc();
+ $this->assertTrue($r['blob1'] === 'This is' && $r['blob2'] === 'a test', t('Can insert multiple blobs per row.'));
+ }
+}
+
+/**
+ * Insert tests for "database default" values.
+ */
+class DatabaseInsertDefaultsTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Insert tests, default fields',
+ 'description' => 'Test the Insert query builder with default values.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that we can run a query that is "default values for everything".
+ */
+ function testDefaultInsert() {
+ $query = db_insert('test')->useDefaults(array('job'));
+ $id = $query->execute();
+
+ $schema = drupal_get_schema('test');
+
+ $job = db_query('SELECT job FROM {test} WHERE id = :id', array(':id' => $id))->fetchField();
+ $this->assertEqual($job, $schema['fields']['job']['default'], t('Default field value is set.'));
+ }
+
+ /**
+ * Test that no action will be preformed if no fields are specified.
+ */
+ function testDefaultEmptyInsert() {
+ $num_records_before = (int) db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ try {
+ $result = db_insert('test')->execute();
+ // This is only executed if no exception has been thrown.
+ $this->fail(t('Expected exception NoFieldsException has not been thrown.'));
+ } catch (NoFieldsException $e) {
+ $this->pass(t('Expected exception NoFieldsException has been thrown.'));
+ }
+
+ $num_records_after = (int) db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+ $this->assertIdentical($num_records_before, $num_records_after, t('Do nothing as no fields are specified.'));
+ }
+
+ /**
+ * Test that we can insert fields with values and defaults in the same query.
+ */
+ function testDefaultInsertWithFields() {
+ $query = db_insert('test')
+ ->fields(array('name' => 'Bob'))
+ ->useDefaults(array('job'));
+ $id = $query->execute();
+
+ $schema = drupal_get_schema('test');
+
+ $job = db_query('SELECT job FROM {test} WHERE id = :id', array(':id' => $id))->fetchField();
+ $this->assertEqual($job, $schema['fields']['job']['default'], t('Default field value is set.'));
+ }
+}
+
+/**
+ * Update builder tests.
+ */
+class DatabaseUpdateTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update tests',
+ 'description' => 'Test the Update query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that we can update a single record successfully.
+ */
+ function testSimpleUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('name' => 'Tiffany'))
+ ->condition('id', 1)
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $saved_name = db_query('SELECT name FROM {test} WHERE id = :id', array(':id' => 1))->fetchField();
+ $this->assertIdentical($saved_name, 'Tiffany', t('Updated name successfully.'));
+ }
+
+ /**
+ * Confirm updating to NULL.
+ */
+ function testSimpleNullUpdate() {
+ $this->ensureSampleDataNull();
+ $num_updated = db_update('test_null')
+ ->fields(array('age' => NULL))
+ ->condition('name', 'Kermit')
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $saved_age = db_query('SELECT age FROM {test_null} WHERE name = :name', array(':name' => 'Kermit'))->fetchField();
+ $this->assertNull($saved_age, t('Updated name successfully.'));
+ }
+
+ /**
+ * Confirm that we can update a multiple records successfully.
+ */
+ function testMultiUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('job', 'Singer')
+ ->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Confirm that we can update a multiple records with a non-equality condition.
+ */
+ function testMultiGTUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('age', 26, '>')
+ ->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Confirm that we can update a multiple records with a where call.
+ */
+ function testWhereUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->where('age > :age', array(':age' => 26))
+ ->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Confirm that we can stack condition and where calls.
+ */
+ function testWhereAndConditionUpdate() {
+ $update = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->where('age > :age', array(':age' => 26))
+ ->condition('name', 'Ringo');
+ $num_updated = $update->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test updating with expressions.
+ */
+ function testExpressionUpdate() {
+ // Set age = 1 for a single row for this test to work.
+ db_update('test')
+ ->condition('id', 1)
+ ->fields(array('age' => 1))
+ ->execute();
+
+ // Ensure that expressions are handled properly. This should set every
+ // record's age to a square of itself, which will change only three of the
+ // four records in the table since 1*1 = 1. That means only three records
+ // are modified, so we should get back 3, not 4, from execute().
+ $num_rows = db_update('test')
+ ->expression('age', 'age * age')
+ ->execute();
+ $this->assertIdentical($num_rows, 3, t('Number of affected rows are returned.'));
+ }
+}
+
+/**
+ * Tests for more complex update statements.
+ */
+class DatabaseUpdateComplexTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update tests, Complex',
+ 'description' => 'Test the Update query builder, complex queries.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test updates with OR conditionals.
+ */
+ function testOrConditionUpdate() {
+ $update = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition(db_or()
+ ->condition('name', 'John')
+ ->condition('name', 'Paul')
+ );
+ $num_updated = $update->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test WHERE IN clauses.
+ */
+ function testInConditionUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('name', array('John', 'Paul'), 'IN')
+ ->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test WHERE NOT IN clauses.
+ */
+ function testNotInConditionUpdate() {
+ // The o is lowercase in the 'NoT IN' operator, to make sure the operators
+ // work in mixed case.
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('name', array('John', 'Paul', 'George'), 'NoT IN')
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test BETWEEN conditional clauses.
+ */
+ function testBetweenConditionUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('age', array(25, 26), 'BETWEEN')
+ ->execute();
+ $this->assertIdentical($num_updated, 2, t('Updated 2 records.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test LIKE conditionals.
+ */
+ function testLikeConditionUpdate() {
+ $num_updated = db_update('test')
+ ->fields(array('job' => 'Musician'))
+ ->condition('name', '%ge%', 'LIKE')
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Updated fields successfully.'));
+ }
+
+ /**
+ * Test update with expression values.
+ */
+ function testUpdateExpression() {
+ $before_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetchField();
+ $GLOBALS['larry_test'] = 1;
+ $num_updated = db_update('test')
+ ->condition('name', 'Ringo')
+ ->fields(array('job' => 'Musician'))
+ ->expression('age', 'age + :age', array(':age' => 4))
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $num_matches = db_query('SELECT COUNT(*) FROM {test} WHERE job = :job', array(':job' => 'Musician'))->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Updated fields successfully.'));
+
+ $person = db_query('SELECT * FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetch();
+ $this->assertEqual($person->name, 'Ringo', t('Name set correctly.'));
+ $this->assertEqual($person->age, $before_age + 4, t('Age set correctly.'));
+ $this->assertEqual($person->job, 'Musician', t('Job set correctly.'));
+ $GLOBALS['larry_test'] = 0;
+ }
+
+ /**
+ * Test update with only expression values.
+ */
+ function testUpdateOnlyExpression() {
+ $before_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetchField();
+ $num_updated = db_update('test')
+ ->condition('name', 'Ringo')
+ ->expression('age', 'age + :age', array(':age' => 4))
+ ->execute();
+ $this->assertIdentical($num_updated, 1, t('Updated 1 record.'));
+
+ $after_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetchField();
+ $this->assertEqual($before_age + 4, $after_age, t('Age updated correctly'));
+ }
+}
+
+/**
+ * Test update queries involving LOB values.
+ */
+class DatabaseUpdateLOBTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update tests, LOB',
+ 'description' => 'Test the Update query builder with LOB fields.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that we can update a blob column.
+ */
+ function testUpdateOneBlob() {
+ $data = "This is\000a test.";
+ $this->assertTrue(strlen($data) === 15, t('Test data contains a NULL.'));
+ $id = db_insert('test_one_blob')
+ ->fields(array('blob1' => $data))
+ ->execute();
+
+ $data .= $data;
+ db_update('test_one_blob')
+ ->condition('id', $id)
+ ->fields(array('blob1' => $data))
+ ->execute();
+
+ $r = db_query('SELECT * FROM {test_one_blob} WHERE id = :id', array(':id' => $id))->fetchAssoc();
+ $this->assertTrue($r['blob1'] === $data, t('Can update a blob: id @id, @data.', array('@id' => $id, '@data' => serialize($r))));
+ }
+
+ /**
+ * Confirm that we can update two blob columns in the same table.
+ */
+ function testUpdateMultipleBlob() {
+ $id = db_insert('test_two_blobs')
+ ->fields(array(
+ 'blob1' => 'This is',
+ 'blob2' => 'a test',
+ ))
+ ->execute();
+
+ db_update('test_two_blobs')
+ ->condition('id', $id)
+ ->fields(array('blob1' => 'and so', 'blob2' => 'is this'))
+ ->execute();
+
+ $r = db_query('SELECT * FROM {test_two_blobs} WHERE id = :id', array(':id' => $id))->fetchAssoc();
+ $this->assertTrue($r['blob1'] === 'and so' && $r['blob2'] === 'is this', t('Can update multiple blobs per row.'));
+ }
+}
+
+/**
+ * Delete/Truncate tests.
+ *
+ * The DELETE tests are not as extensive, as all of the interesting code for
+ * DELETE queries is in the conditional which is identical to the UPDATE and
+ * SELECT conditional handling.
+ *
+ * The TRUNCATE tests are not extensive either, because the behavior of
+ * TRUNCATE queries is not consistent across database engines. We only test
+ * that a TRUNCATE query actually deletes all rows from the target table.
+ */
+class DatabaseDeleteTruncateTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Delete/Truncate tests',
+ 'description' => 'Test the Delete and Truncate query builders.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that we can use a subselect in a delete successfully.
+ */
+ function testSubselectDelete() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_task}')->fetchField();
+ $pid_to_delete = db_query("SELECT * FROM {test_task} WHERE task = 'sleep'")->fetchField();
+
+ $subquery = db_select('test', 't')
+ ->fields('t', array('id'))
+ ->condition('t.id', array($pid_to_delete), 'IN');
+ $delete = db_delete('test_task')
+ ->condition('task', 'sleep')
+ ->condition('pid', $subquery, 'IN');
+
+ $num_deleted = $delete->execute();
+ $this->assertEqual($num_deleted, 1, t("Deleted 1 record."));
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_task}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after + $num_deleted, t('Deletion adds up.'));
+ }
+
+ /**
+ * Confirm that we can delete a single record successfully.
+ */
+ function testSimpleDelete() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ $num_deleted = db_delete('test')
+ ->condition('id', 1)
+ ->execute();
+ $this->assertIdentical($num_deleted, 1, t('Deleted 1 record.'));
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after + $num_deleted, t('Deletion adds up.'));
+ }
+
+ /**
+ * Confirm that we can truncate a whole table successfully.
+ */
+ function testTruncate() {
+ $num_records_before = db_query("SELECT COUNT(*) FROM {test}")->fetchField();
+
+ db_truncate('test')->execute();
+
+ $num_records_after = db_query("SELECT COUNT(*) FROM {test}")->fetchField();
+ $this->assertEqual(0, $num_records_after, t('Truncate really deletes everything.'));
+ }
+}
+
+/**
+ * Test the MERGE query builder.
+ */
+class DatabaseMergeTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Merge tests',
+ 'description' => 'Test the Merge query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that we can merge-insert a record successfully.
+ */
+ function testMergeInsert() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ $result = db_merge('test_people')
+ ->key(array('job' => 'Presenter'))
+ ->fields(array(
+ 'age' => 31,
+ 'name' => 'Tiffany',
+ ))
+ ->execute();
+
+ $this->assertEqual($result, MergeQuery::STATUS_INSERT, t('Insert status returned.'));
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before + 1, $num_records_after, t('Merge inserted properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Presenter'))->fetch();
+ $this->assertEqual($person->name, 'Tiffany', t('Name set correctly.'));
+ $this->assertEqual($person->age, 31, t('Age set correctly.'));
+ $this->assertEqual($person->job, 'Presenter', t('Job set correctly.'));
+ }
+
+ /**
+ * Confirm that we can merge-update a record successfully.
+ */
+ function testMergeUpdate() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ $result = db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->fields(array(
+ 'age' => 31,
+ 'name' => 'Tiffany',
+ ))
+ ->execute();
+
+ $this->assertEqual($result, MergeQuery::STATUS_UPDATE, t('Update status returned.'));
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge updated properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Tiffany', t('Name set correctly.'));
+ $this->assertEqual($person->age, 31, t('Age set correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job set correctly.'));
+ }
+
+ /**
+ * Confirm that we can merge-update a record successfully, with different insert and update.
+ */
+ function testMergeUpdateExcept() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->insertFields(array('age' => 31))
+ ->updateFields(array('name' => 'Tiffany'))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge updated properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Tiffany', t('Name set correctly.'));
+ $this->assertEqual($person->age, 30, t('Age skipped correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job set correctly.'));
+ }
+
+ /**
+ * Confirm that we can merge-update a record successfully, with alternate replacement.
+ */
+ function testMergeUpdateExplicit() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->insertFields(array(
+ 'age' => 31,
+ 'name' => 'Tiffany',
+ ))
+ ->updateFields(array(
+ 'name' => 'Joe',
+ ))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge updated properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Joe', t('Name set correctly.'));
+ $this->assertEqual($person->age, 30, t('Age skipped correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job set correctly.'));
+ }
+
+ /**
+ * Confirm that we can merge-update a record successfully, with expressions.
+ */
+ function testMergeUpdateExpression() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ $age_before = db_query('SELECT age FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetchField();
+
+ // This is a very contrived example, as I have no idea why you'd want to
+ // change age this way, but that's beside the point.
+ // Note that we are also double-setting age here, once as a literal and
+ // once as an expression. This test will only pass if the expression wins,
+ // which is what is supposed to happen.
+ db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->fields(array('name' => 'Tiffany'))
+ ->insertFields(array('age' => 31))
+ ->expression('age', 'age + :age', array(':age' => 4))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge updated properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Tiffany', t('Name set correctly.'));
+ $this->assertEqual($person->age, $age_before + 4, t('Age updated correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job set correctly.'));
+ }
+
+ /**
+ * Test that we can merge-insert without any update fields.
+ */
+ function testMergeInsertWithoutUpdate() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ db_merge('test_people')
+ ->key(array('job' => 'Presenter'))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before + 1, $num_records_after, t('Merge inserted properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Presenter'))->fetch();
+ $this->assertEqual($person->name, '', t('Name set correctly.'));
+ $this->assertEqual($person->age, 0, t('Age set correctly.'));
+ $this->assertEqual($person->job, 'Presenter', t('Job set correctly.'));
+ }
+
+ /**
+ * Confirm that we can merge-update without any update fields.
+ */
+ function testMergeUpdateWithoutUpdate() {
+ $num_records_before = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+ db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge skipped properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Meredith', t('Name skipped correctly.'));
+ $this->assertEqual($person->age, 30, t('Age skipped correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job skipped correctly.'));
+
+ db_merge('test_people')
+ ->key(array('job' => 'Speaker'))
+ ->insertFields(array('age' => 31))
+ ->execute();
+
+ $num_records_after = db_query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+ $this->assertEqual($num_records_before, $num_records_after, t('Merge skipped properly.'));
+
+ $person = db_query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+ $this->assertEqual($person->name, 'Meredith', t('Name skipped correctly.'));
+ $this->assertEqual($person->age, 30, t('Age skipped correctly.'));
+ $this->assertEqual($person->job, 'Speaker', t('Job skipped correctly.'));
+ }
+
+ /**
+ * Test that an invalid merge query throws an exception like it is supposed to.
+ */
+ function testInvalidMerge() {
+ try {
+ // This query should die because there is no key field specified.
+ db_merge('test_people')
+ ->fields(array(
+ 'age' => 31,
+ 'name' => 'Tiffany',
+ ))
+ ->execute();
+ }
+ catch (InvalidMergeQueryException $e) {
+ $this->pass(t('InvalidMergeQueryException thrown for invalid query.'));
+ return;
+ }
+ $this->fail(t('No InvalidMergeQueryException thrown'));
+ }
+}
+
+/**
+ * Test the SELECT builder.
+ */
+class DatabaseSelectTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Select tests',
+ 'description' => 'Test the Select query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test rudimentary SELECT statements.
+ */
+ function testSimpleSelect() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test rudimentary SELECT statement with a COMMENT.
+ */
+ function testSimpleComment() {
+ $query = db_select('test')->comment('Testing query comments');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $query = (string)$query;
+ $expected = "/* Testing query comments */ SELECT test.name AS name, test.age AS age\nFROM \n{test} test";
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ $this->assertEqual($query, $expected, t('The flattened query contains the comment string.'));
+ }
+
+ /**
+ * Test query COMMENT system against vulnerabilities.
+ */
+ function testVulnerableComment() {
+ $query = db_select('test')->comment('Testing query comments */ SELECT nid FROM {node}; --');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $query = (string)$query;
+ $expected = "/* Testing query comments SELECT nid FROM {node}; -- */ SELECT test.name AS name, test.age AS age\nFROM \n{test} test";
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ $this->assertEqual($query, $expected, t('The flattened query contains the sanitised comment string.'));
+ }
+
+ /**
+ * Test basic conditionals on SELECT statements.
+ */
+ function testSimpleSelectConditional() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->condition('age', 27);
+ $result = $query->execute();
+
+ // Check that the aliases are being created the way we want.
+ $this->assertEqual($name_field, 'name', t('Name field alias is correct.'));
+ $this->assertEqual($age_field, 'age', t('Age field alias is correct.'));
+
+ // Ensure that we got the right record.
+ $record = $result->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Fetched name is correct.'));
+ $this->assertEqual($record->$age_field, 27, t('Fetched age is correct.'));
+ }
+
+ /**
+ * Test SELECT statements with expressions.
+ */
+ function testSimpleSelectExpression() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addExpression("age*2", 'double_age');
+ $query->condition('age', 27);
+ $result = $query->execute();
+
+ // Check that the aliases are being created the way we want.
+ $this->assertEqual($name_field, 'name', t('Name field alias is correct.'));
+ $this->assertEqual($age_field, 'double_age', t('Age field alias is correct.'));
+
+ // Ensure that we got the right record.
+ $record = $result->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Fetched name is correct.'));
+ $this->assertEqual($record->$age_field, 27*2, t('Fetched age expression is correct.'));
+ }
+
+ /**
+ * Test SELECT statements with multiple expressions.
+ */
+ function testSimpleSelectExpressionMultiple() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_double_field = $query->addExpression("age*2");
+ $age_triple_field = $query->addExpression("age*3");
+ $query->condition('age', 27);
+ $result = $query->execute();
+
+ // Check that the aliases are being created the way we want.
+ $this->assertEqual($age_double_field, 'expression', t('Double age field alias is correct.'));
+ $this->assertEqual($age_triple_field, 'expression_2', t('Triple age field alias is correct.'));
+
+ // Ensure that we got the right record.
+ $record = $result->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Fetched name is correct.'));
+ $this->assertEqual($record->$age_double_field, 27*2, t('Fetched double age expression is correct.'));
+ $this->assertEqual($record->$age_triple_field, 27*3, t('Fetched triple age expression is correct.'));
+ }
+
+ /**
+ * Test adding multiple fields to a select statement at the same time.
+ */
+ function testSimpleSelectMultipleFields() {
+ $record = db_select('test')
+ ->fields('test', array('id', 'name', 'age', 'job'))
+ ->condition('age', 27)
+ ->execute()->fetchObject();
+
+ // Check that all fields we asked for are present.
+ $this->assertNotNull($record->id, t('ID field is present.'));
+ $this->assertNotNull($record->name, t('Name field is present.'));
+ $this->assertNotNull($record->age, t('Age field is present.'));
+ $this->assertNotNull($record->job, t('Job field is present.'));
+
+ // Ensure that we got the right record.
+ // Check that all fields we asked for are present.
+ $this->assertEqual($record->id, 2, t('ID field has the correct value.'));
+ $this->assertEqual($record->name, 'George', t('Name field has the correct value.'));
+ $this->assertEqual($record->age, 27, t('Age field has the correct value.'));
+ $this->assertEqual($record->job, 'Singer', t('Job field has the correct value.'));
+ }
+
+ /**
+ * Test adding all fields from a given table to a select statement.
+ */
+ function testSimpleSelectAllFields() {
+ $record = db_select('test')
+ ->fields('test')
+ ->condition('age', 27)
+ ->execute()->fetchObject();
+
+ // Check that all fields we asked for are present.
+ $this->assertNotNull($record->id, t('ID field is present.'));
+ $this->assertNotNull($record->name, t('Name field is present.'));
+ $this->assertNotNull($record->age, t('Age field is present.'));
+ $this->assertNotNull($record->job, t('Job field is present.'));
+
+ // Ensure that we got the right record.
+ // Check that all fields we asked for are present.
+ $this->assertEqual($record->id, 2, t('ID field has the correct value.'));
+ $this->assertEqual($record->name, 'George', t('Name field has the correct value.'));
+ $this->assertEqual($record->age, 27, t('Age field has the correct value.'));
+ $this->assertEqual($record->job, 'Singer', t('Job field has the correct value.'));
+ }
+
+ /**
+ * Test that we can find a record with a NULL value.
+ */
+ function testNullCondition() {
+ $this->ensureSampleDataNull();
+
+ $names = db_select('test_null', 'tn')
+ ->fields('tn', array('name'))
+ ->isNull('age')
+ ->execute()->fetchCol();
+
+ $this->assertEqual(count($names), 1, t('Correct number of records found with NULL age.'));
+ $this->assertEqual($names[0], 'Fozzie', t('Correct record returned for NULL age.'));
+ }
+
+ /**
+ * Test that we can find a record without a NULL value.
+ */
+ function testNotNullCondition() {
+ $this->ensureSampleDataNull();
+
+ $names = db_select('test_null', 'tn')
+ ->fields('tn', array('name'))
+ ->isNotNull('tn.age')
+ ->orderBy('name')
+ ->execute()->fetchCol();
+
+ $this->assertEqual(count($names), 2, t('Correct number of records found withNOT NULL age.'));
+ $this->assertEqual($names[0], 'Gonzo', t('Correct record returned for NOT NULL age.'));
+ $this->assertEqual($names[1], 'Kermit', t('Correct record returned for NOT NULL age.'));
+ }
+
+ /**
+ * Test that we can UNION multiple Select queries together. This is
+ * semantically equal to UNION DISTINCT, so we don't explicity test that.
+ */
+ function testUnion() {
+ $query_1 = db_select('test', 't')
+ ->fields('t', array('name'))
+ ->condition('age', array(27, 28), 'IN');
+
+ $query_2 = db_select('test', 't')
+ ->fields('t', array('name'))
+ ->condition('age', 28);
+
+ $query_1->union($query_2);
+
+ $names = $query_1->execute()->fetchCol();
+
+ // Ensure we only get 2 records.
+ $this->assertEqual(count($names), 2, t('UNION correctly discarded duplicates.'));
+
+ $this->assertEqual($names[0], 'George', t('First query returned correct name.'));
+ $this->assertEqual($names[1], 'Ringo', t('Second query returned correct name.'));
+ }
+
+ /**
+ * Test that we can UNION ALL multiple Select queries together.
+ */
+ function testUnionAll() {
+ $query_1 = db_select('test', 't')
+ ->fields('t', array('name'))
+ ->condition('age', array(27, 28), 'IN');
+
+ $query_2 = db_select('test', 't')
+ ->fields('t', array('name'))
+ ->condition('age', 28);
+
+ $query_1->union($query_2, 'ALL');
+
+ $names = $query_1->execute()->fetchCol();
+
+ // Ensure we get all 3 records.
+ $this->assertEqual(count($names), 3, t('UNION ALL correctly preserved duplicates.'));
+
+ $this->assertEqual($names[0], 'George', t('First query returned correct first name.'));
+ $this->assertEqual($names[1], 'Ringo', t('Second query returned correct second name.'));
+ $this->assertEqual($names[2], 'Ringo', t('Third query returned correct name.'));
+ }
+
+ /**
+ * Test that random ordering of queries works.
+ *
+ * We take the approach of testing the Drupal layer only, rather than trying
+ * to test that the database's random number generator actually produces
+ * random queries (which is very difficult to do without an unacceptable risk
+ * of the test failing by accident).
+ *
+ * Therefore, in this test we simply run the same query twice and assert that
+ * the two results are reordered versions of each other (as well as of the
+ * same query without the random ordering). It is reasonable to assume that
+ * if we run the same select query twice and the results are in a different
+ * order each time, the only way this could happen is if we have successfully
+ * triggered the database's random ordering functionality.
+ */
+ function testRandomOrder() {
+ // Use 52 items, so the chance that this test fails by accident will be the
+ // same as the chance that a deck of cards will come out in the same order
+ // after shuffling it (in other words, nearly impossible).
+ $number_of_items = 52;
+ while (db_query("SELECT MAX(id) FROM {test}")->fetchField() < $number_of_items) {
+ db_insert('test')->fields(array('name' => $this->randomName()))->execute();
+ }
+
+ // First select the items in order and make sure we get an ordered list.
+ $expected_ids = range(1, $number_of_items);
+ $ordered_ids = db_select('test', 't')
+ ->fields('t', array('id'))
+ ->range(0, $number_of_items)
+ ->orderBy('id')
+ ->execute()
+ ->fetchCol();
+ $this->assertEqual($ordered_ids, $expected_ids, t('A query without random ordering returns IDs in the correct order.'));
+
+ // Now perform the same query, but instead choose a random ordering. We
+ // expect this to contain a differently ordered version of the original
+ // result.
+ $randomized_ids = db_select('test', 't')
+ ->fields('t', array('id'))
+ ->range(0, $number_of_items)
+ ->orderRandom()
+ ->execute()
+ ->fetchCol();
+ $this->assertNotEqual($randomized_ids, $ordered_ids, t('A query with random ordering returns an unordered set of IDs.'));
+ $sorted_ids = $randomized_ids;
+ sort($sorted_ids);
+ $this->assertEqual($sorted_ids, $ordered_ids, t('After sorting the random list, the result matches the original query.'));
+
+ // Now perform the exact same query again, and make sure the order is
+ // different.
+ $randomized_ids_second_set = db_select('test', 't')
+ ->fields('t', array('id'))
+ ->range(0, $number_of_items)
+ ->orderRandom()
+ ->execute()
+ ->fetchCol();
+ $this->assertNotEqual($randomized_ids_second_set, $randomized_ids, t('Performing the query with random ordering a second time returns IDs in a different order.'));
+ $sorted_ids_second_set = $randomized_ids_second_set;
+ sort($sorted_ids_second_set);
+ $this->assertEqual($sorted_ids_second_set, $sorted_ids, t('After sorting the second random list, the result matches the sorted version of the first random list.'));
+ }
+
+ /**
+ * Test that aliases are renamed when duplicates.
+ */
+ function testSelectDuplicateAlias() {
+ $query = db_select('test', 't');
+ $alias1 = $query->addField('t', 'name', 'the_alias');
+ $alias2 = $query->addField('t', 'age', 'the_alias');
+ $this->assertNotIdentical($alias1, $alias2, 'Duplicate aliases are renamed.');
+ }
+}
+
+/**
+ * Test case for subselects in a dynamic SELECT query.
+ */
+class DatabaseSelectSubqueryTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Select tests, subqueries',
+ 'description' => 'Test the Select query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that we can use a subquery in a FROM clause.
+ */
+ function testFromSubquerySelect() {
+ // Create a subquery, which is just a normal query object.
+ $subquery = db_select('test_task', 'tt');
+ $subquery->addField('tt', 'pid', 'pid');
+ $subquery->addField('tt', 'task', 'task');
+ $subquery->condition('priority', 1);
+
+ for ($i = 0; $i < 2; $i++) {
+ // Create another query that joins against the virtual table resulting
+ // from the subquery.
+ $select = db_select($subquery, 'tt2');
+ $select->join('test', 't', 't.id=tt2.pid');
+ $select->addField('t', 'name');
+ if ($i) {
+ // Use a different number of conditions here to confuse the subquery
+ // placeholder counter, testing http://drupal.org/node/1112854.
+ $select->condition('name', 'John');
+ }
+ $select->condition('task', 'code');
+
+ // The resulting query should be equivalent to:
+ // SELECT t.name
+ // FROM (SELECT tt.pid AS pid, tt.task AS task FROM test_task tt WHERE priority=1) tt
+ // INNER JOIN test t ON t.id=tt.pid
+ // WHERE tt.task = 'code'
+ $people = $select->execute()->fetchCol();
+
+ $this->assertEqual(count($people), 1, t('Returned the correct number of rows.'));
+ }
+ }
+
+ /**
+ * Test that we can use a subquery in a FROM clause with a limit.
+ */
+ function testFromSubquerySelectWithLimit() {
+ // Create a subquery, which is just a normal query object.
+ $subquery = db_select('test_task', 'tt');
+ $subquery->addField('tt', 'pid', 'pid');
+ $subquery->addField('tt', 'task', 'task');
+ $subquery->orderBy('priority', 'DESC');
+ $subquery->range(0, 1);
+
+ // Create another query that joins against the virtual table resulting
+ // from the subquery.
+ $select = db_select($subquery, 'tt2');
+ $select->join('test', 't', 't.id=tt2.pid');
+ $select->addField('t', 'name');
+
+ // The resulting query should be equivalent to:
+ // SELECT t.name
+ // FROM (SELECT tt.pid AS pid, tt.task AS task FROM test_task tt ORDER BY priority DESC LIMIT 1 OFFSET 0) tt
+ // INNER JOIN test t ON t.id=tt.pid
+ $people = $select->execute()->fetchCol();
+
+ $this->assertEqual(count($people), 1, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test that we can use a subquery in a WHERE clause.
+ */
+ function testConditionSubquerySelect() {
+ // Create a subquery, which is just a normal query object.
+ $subquery = db_select('test_task', 'tt');
+ $subquery->addField('tt', 'pid', 'pid');
+ $subquery->condition('tt.priority', 1);
+
+ // Create another query that joins against the virtual table resulting
+ // from the subquery.
+ $select = db_select('test_task', 'tt2');
+ $select->addField('tt2', 'task');
+ $select->condition('tt2.pid', $subquery, 'IN');
+
+ // The resulting query should be equivalent to:
+ // SELECT tt2.name
+ // FROM test tt2
+ // WHERE tt2.pid IN (SELECT tt.pid AS pid FROM test_task tt WHERE tt.priority=1)
+ $people = $select->execute()->fetchCol();
+ $this->assertEqual(count($people), 5, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test that we can use a subquery in a JOIN clause.
+ */
+ function testJoinSubquerySelect() {
+ // Create a subquery, which is just a normal query object.
+ $subquery = db_select('test_task', 'tt');
+ $subquery->addField('tt', 'pid', 'pid');
+ $subquery->condition('priority', 1);
+
+ // Create another query that joins against the virtual table resulting
+ // from the subquery.
+ $select = db_select('test', 't');
+ $select->join($subquery, 'tt', 't.id=tt.pid');
+ $select->addField('t', 'name');
+
+ // The resulting query should be equivalent to:
+ // SELECT t.name
+ // FROM test t
+ // INNER JOIN (SELECT tt.pid AS pid FROM test_task tt WHERE priority=1) tt ON t.id=tt.pid
+ $people = $select->execute()->fetchCol();
+
+ $this->assertEqual(count($people), 2, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test EXISTS subquery conditionals on SELECT statements.
+ *
+ * We essentially select all rows from the {test} table that have matching
+ * rows in the {test_people} table based on the shared name column.
+ */
+ function testExistsSubquerySelect() {
+ // Put George into {test_people}.
+ db_insert('test_people')
+ ->fields(array(
+ 'name' => 'George',
+ 'age' => 27,
+ 'job' => 'Singer',
+ ))
+ ->execute();
+ // Base query to {test}.
+ $query = db_select('test', 't')
+ ->fields('t', array('name'));
+ // Subquery to {test_people}.
+ $subquery = db_select('test_people', 'tp')
+ ->fields('tp', array('name'))
+ ->where('tp.name = t.name');
+ $query->exists($subquery);
+ $result = $query->execute();
+
+ // Ensure that we got the right record.
+ $record = $result->fetch();
+ $this->assertEqual($record->name, 'George', t('Fetched name is correct using EXISTS query.'));
+ }
+
+ /**
+ * Test NOT EXISTS subquery conditionals on SELECT statements.
+ *
+ * We essentially select all rows from the {test} table that don't have
+ * matching rows in the {test_people} table based on the shared name column.
+ */
+ function testNotExistsSubquerySelect() {
+ // Put George into {test_people}.
+ db_insert('test_people')
+ ->fields(array(
+ 'name' => 'George',
+ 'age' => 27,
+ 'job' => 'Singer',
+ ))
+ ->execute();
+
+ // Base query to {test}.
+ $query = db_select('test', 't')
+ ->fields('t', array('name'));
+ // Subquery to {test_people}.
+ $subquery = db_select('test_people', 'tp')
+ ->fields('tp', array('name'))
+ ->where('tp.name = t.name');
+ $query->notExists($subquery);
+
+ // Ensure that we got the right number of records.
+ $people = $query->execute()->fetchCol();
+ $this->assertEqual(count($people), 3, t('NOT EXISTS query returned the correct results.'));
+ }
+}
+
+/**
+ * Test select with order by clauses.
+ */
+class DatabaseSelectOrderedTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Select tests, ordered',
+ 'description' => 'Test the Select query builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test basic order by.
+ */
+ function testSimpleSelectOrdered() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->orderBy($age_field);
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_age = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue($record->age >= $last_age, t('Results returned in correct order.'));
+ $last_age = $record->age;
+ }
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test multiple order by.
+ */
+ function testSimpleSelectMultiOrdered() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $job_field = $query->addField('test', 'job');
+ $query->orderBy($job_field);
+ $query->orderBy($age_field);
+ $result = $query->execute();
+
+ $num_records = 0;
+ $expected = array(
+ array('Ringo', 28, 'Drummer'),
+ array('John', 25, 'Singer'),
+ array('George', 27, 'Singer'),
+ array('Paul', 26, 'Songwriter'),
+ );
+ $results = $result->fetchAll(PDO::FETCH_NUM);
+ foreach ($expected as $k => $record) {
+ $num_records++;
+ foreach ($record as $kk => $col) {
+ if ($expected[$k][$kk] != $results[$k][$kk]) {
+ $this->assertTrue(FALSE, t('Results returned in correct order.'));
+ }
+ }
+ }
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test order by descending.
+ */
+ function testSimpleSelectOrderedDesc() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->orderBy($age_field, 'DESC');
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_age = 100000000;
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue($record->age <= $last_age, t('Results returned in correct order.'));
+ $last_age = $record->age;
+ }
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ }
+}
+
+/**
+ * Test more complex select statements.
+ */
+class DatabaseSelectComplexTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Select tests, complex',
+ 'description' => 'Test the Select query builder with more complex queries.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test simple JOIN statements.
+ */
+ function testDefaultJoin() {
+ $query = db_select('test_task', 't');
+ $people_alias = $query->join('test', 'p', 't.pid = p.id');
+ $name_field = $query->addField($people_alias, 'name', 'name');
+ $task_field = $query->addField('t', 'task', 'task');
+ $priority_field = $query->addField('t', 'priority', 'priority');
+
+ $query->orderBy($priority_field);
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_priority = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue($record->$priority_field >= $last_priority, t('Results returned in correct order.'));
+ $this->assertNotEqual($record->$name_field, 'Ringo', t('Taskless person not selected.'));
+ $last_priority = $record->$priority_field;
+ }
+
+ $this->assertEqual($num_records, 7, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test LEFT OUTER joins.
+ */
+ function testLeftOuterJoin() {
+ $query = db_select('test', 'p');
+ $people_alias = $query->leftJoin('test_task', 't', 't.pid = p.id');
+ $name_field = $query->addField('p', 'name', 'name');
+ $task_field = $query->addField($people_alias, 'task', 'task');
+ $priority_field = $query->addField($people_alias, 'priority', 'priority');
+
+ $query->orderBy($name_field);
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_name = 0;
+
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue(strcmp($record->$name_field, $last_name) >= 0, t('Results returned in correct order.'));
+ $last_priority = $record->$name_field;
+ }
+
+ $this->assertEqual($num_records, 8, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test GROUP BY clauses.
+ */
+ function testGroupBy() {
+ $query = db_select('test_task', 't');
+ $count_field = $query->addExpression('COUNT(task)', 'num');
+ $task_field = $query->addField('t', 'task');
+ $query->orderBy($count_field);
+ $query->groupBy($task_field);
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_count = 0;
+ $records = array();
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue($record->$count_field >= $last_count, t('Results returned in correct order.'));
+ $last_count = $record->$count_field;
+ $records[$record->$task_field] = $record->$count_field;
+ }
+
+ $correct_results = array(
+ 'eat' => 1,
+ 'sleep' => 2,
+ 'code' => 1,
+ 'found new band' => 1,
+ 'perform at superbowl' => 1,
+ );
+
+ foreach ($correct_results as $task => $count) {
+ $this->assertEqual($records[$task], $count, t("Correct number of '@task' records found.", array('@task' => $task)));
+ }
+
+ $this->assertEqual($num_records, 6, t('Returned the correct number of total rows.'));
+ }
+
+ /**
+ * Test GROUP BY and HAVING clauses together.
+ */
+ function testGroupByAndHaving() {
+ $query = db_select('test_task', 't');
+ $count_field = $query->addExpression('COUNT(task)', 'num');
+ $task_field = $query->addField('t', 'task');
+ $query->orderBy($count_field);
+ $query->groupBy($task_field);
+ $query->having('COUNT(task) >= 2');
+ $result = $query->execute();
+
+ $num_records = 0;
+ $last_count = 0;
+ $records = array();
+ foreach ($result as $record) {
+ $num_records++;
+ $this->assertTrue($record->$count_field >= 2, t('Record has the minimum count.'));
+ $this->assertTrue($record->$count_field >= $last_count, t('Results returned in correct order.'));
+ $last_count = $record->$count_field;
+ $records[$record->$task_field] = $record->$count_field;
+ }
+
+ $correct_results = array(
+ 'sleep' => 2,
+ );
+
+ foreach ($correct_results as $task => $count) {
+ $this->assertEqual($records[$task], $count, t("Correct number of '@task' records found.", array('@task' => $task)));
+ }
+
+ $this->assertEqual($num_records, 1, t('Returned the correct number of total rows.'));
+ }
+
+ /**
+ * Test range queries. The SQL clause varies with the database.
+ */
+ function testRange() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->range(0, 2);
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $this->assertEqual($num_records, 2, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test distinct queries.
+ */
+ function testDistinct() {
+ $query = db_select('test_task');
+ $task_field = $query->addField('test_task', 'task');
+ $query->distinct();
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $this->assertEqual($num_records, 6, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test that we can generate a count query from a built query.
+ */
+ function testCountQuery() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->orderBy('name');
+
+ $count = $query->countQuery()->execute()->fetchField();
+
+ $this->assertEqual($count, 4, t('Counted the correct number of records.'));
+
+ // Now make sure we didn't break the original query! We should still have
+ // all of the fields we asked for.
+ $record = $query->execute()->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Correct data retrieved.'));
+ $this->assertEqual($record->$age_field, 27, t('Correct data retrieved.'));
+ }
+
+ /**
+ * Test that countQuery properly removes 'all_fields' statements and
+ * ordering clauses.
+ */
+ function testCountQueryRemovals() {
+ $query = db_select('test');
+ $query->fields('test');
+ $query->orderBy('name');
+ $count = $query->countQuery();
+
+ // Check that the 'all_fields' statement is handled properly.
+ $tables = $query->getTables();
+ $this->assertEqual($tables['test']['all_fields'], 1, t('Query correctly sets \'all_fields\' statement.'));
+ $tables = $count->getTables();
+ $this->assertFalse(isset($tables['test']['all_fields']), t('Count query correctly unsets \'all_fields\' statement.'));
+
+ // Check that the ordering clause is handled properly.
+ $orderby = $query->getOrderBy();
+ $this->assertEqual($orderby['name'], 'ASC', t('Query correctly sets ordering clause.'));
+ $orderby = $count->getOrderBy();
+ $this->assertFalse(isset($orderby['name']), t('Count query correctly unsets ordering caluse.'));
+
+ // Make sure that the count query works.
+ $count = $count->execute()->fetchField();
+
+ $this->assertEqual($count, 4, t('Counted the correct number of records.'));
+ }
+
+
+ /**
+ * Test that countQuery properly removes fields and expressions.
+ */
+ function testCountQueryFieldRemovals() {
+ // countQuery should remove all fields and expressions, so this can be
+ // tested by adding a non-existent field and expression: if it ends
+ // up in the query, an error will be thrown. If not, it will return the
+ // number of records, which in this case happens to be 4 (there are four
+ // records in the {test} table).
+ $query = db_select('test');
+ $query->fields('test', array('fail'));
+ $this->assertEqual(4, $query->countQuery()->execute()->fetchField(), t('Count Query removed fields'));
+
+ $query = db_select('test');
+ $query->addExpression('fail');
+ $this->assertEqual(4, $query->countQuery()->execute()->fetchField(), t('Count Query removed expressions'));
+ }
+
+ /**
+ * Test that we can generate a count query from a query with distinct.
+ */
+ function testCountQueryDistinct() {
+ $query = db_select('test_task');
+ $task_field = $query->addField('test_task', 'task');
+ $query->distinct();
+
+ $count = $query->countQuery()->execute()->fetchField();
+
+ $this->assertEqual($count, 6, t('Counted the correct number of records.'));
+ }
+
+ /**
+ * Test that we can generate a count query from a query with GROUP BY.
+ */
+ function testCountQueryGroupBy() {
+ $query = db_select('test_task');
+ $pid_field = $query->addField('test_task', 'pid');
+ $query->groupBy('pid');
+
+ $count = $query->countQuery()->execute()->fetchField();
+
+ $this->assertEqual($count, 3, t('Counted the correct number of records.'));
+
+ // Use a column alias as, without one, the query can succeed for the wrong
+ // reason.
+ $query = db_select('test_task');
+ $pid_field = $query->addField('test_task', 'pid', 'pid_alias');
+ $query->addExpression('COUNT(test_task.task)', 'count');
+ $query->groupBy('pid_alias');
+ $query->orderBy('pid_alias', 'asc');
+
+ $count = $query->countQuery()->execute()->fetchField();
+
+ $this->assertEqual($count, 3, t('Counted the correct number of records.'));
+ }
+
+ /**
+ * Confirm that we can properly nest conditional clauses.
+ */
+ function testNestedConditions() {
+ // This query should translate to:
+ // "SELECT job FROM {test} WHERE name = 'Paul' AND (age = 26 OR age = 27)"
+ // That should find only one record. Yes it's a non-optimal way of writing
+ // that query but that's not the point!
+ $query = db_select('test');
+ $query->addField('test', 'job');
+ $query->condition('name', 'Paul');
+ $query->condition(db_or()->condition('age', 26)->condition('age', 27));
+
+ $job = $query->execute()->fetchField();
+ $this->assertEqual($job, 'Songwriter', t('Correct data retrieved.'));
+ }
+
+ /**
+ * Confirm we can join on a single table twice with a dynamic alias.
+ */
+ function testJoinTwice() {
+ $query = db_select('test')->fields('test');
+ $alias = $query->join('test', 'test', 'test.job = %alias.job');
+ $query->addField($alias, 'name', 'othername');
+ $query->addField($alias, 'job', 'otherjob');
+ $query->where("$alias.name <> test.name");
+ $crowded_job = $query->execute()->fetch();
+ $this->assertEqual($crowded_job->job, $crowded_job->otherjob, t('Correctly joined same table twice.'));
+ $this->assertNotEqual($crowded_job->name, $crowded_job->othername, t('Correctly joined same table twice.'));
+ }
+
+}
+
+/**
+ * Test more complex select statements, part 2.
+ */
+class DatabaseSelectComplexTestCase2 extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Select tests, complex 2',
+ 'description' => 'Test the Select query builder with even more complex queries.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ DrupalWebTestCase::setUp('database_test', 'node_access_test');
+
+ $schema['test'] = drupal_get_schema('test');
+ $schema['test_people'] = drupal_get_schema('test_people');
+ $schema['test_one_blob'] = drupal_get_schema('test_one_blob');
+ $schema['test_two_blobs'] = drupal_get_schema('test_two_blobs');
+ $schema['test_task'] = drupal_get_schema('test_task');
+
+ $this->installTables($schema);
+
+ $this->addSampleData();
+ }
+
+ /**
+ * Test that we can join on a query.
+ */
+ function testJoinSubquery() {
+ $acct = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($acct);
+
+ $query = db_select('test_task', 'tt', array('target' => 'slave'));
+ $query->addExpression('tt.pid + 1', 'abc');
+ $query->condition('priority', 1, '>');
+ $query->condition('priority', 100, '<');
+
+ $subquery = db_select('test', 'tp');
+ $subquery->join('test_one_blob', 'tpb', 'tp.id = tpb.id');
+ $subquery->join('node', 'n', 'tp.id = n.nid');
+ $subquery->addTag('node_access');
+ $subquery->addMetaData('account', $acct);
+ $subquery->addField('tp', 'id');
+ $subquery->condition('age', 5, '>');
+ $subquery->condition('age', 500, '<');
+
+ $query->leftJoin($subquery, 'sq', 'tt.pid = sq.id');
+ $query->join('test_one_blob', 'tb3', 'tt.pid = tb3.id');
+
+ // Construct the query string.
+ // This is the same sequence that SelectQuery::execute() goes through.
+ $query->preExecute();
+ $query->getArguments();
+ $str = (string) $query;
+
+ // Verify that the string only has one copy of condition placeholder 0.
+ $pos = strpos($str, 'db_condition_placeholder_0', 0);
+ $pos2 = strpos($str, 'db_condition_placeholder_0', $pos + 1);
+ $this->assertFalse($pos2, "Condition placeholder is not repeated");
+ }
+}
+
+class DatabaseSelectPagerDefaultTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Pager query tests',
+ 'description' => 'Test the pager query extender.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that a pager query returns the correct results.
+ *
+ * Note that we have to make an HTTP request to a test page handler
+ * because the pager depends on GET parameters.
+ */
+ function testEvenPagerQuery() {
+ // To keep the test from being too brittle, we determine up front
+ // what the page count should be dynamically, and pass the control
+ // information forward to the actual query on the other side of the
+ // HTTP request.
+ $limit = 2;
+ $count = db_query('SELECT COUNT(*) FROM {test}')->fetchField();
+
+ $correct_number = $limit;
+ $num_pages = floor($count / $limit);
+
+ // If there is no remainder from rounding, subtract 1 since we index from 0.
+ if (!($num_pages * $limit < $count)) {
+ $num_pages--;
+ }
+
+ for ($page = 0; $page <= $num_pages; ++$page) {
+ $this->drupalGet('database_test/pager_query_even/' . $limit, array('query' => array('page' => $page)));
+ $data = json_decode($this->drupalGetContent());
+
+ if ($page == $num_pages) {
+ $correct_number = $count - ($limit * $page);
+ }
+
+ $this->assertEqual(count($data->names), $correct_number, t('Correct number of records returned by pager: @number', array('@number' => $correct_number)));
+ }
+ }
+
+ /**
+ * Confirm that a pager query returns the correct results.
+ *
+ * Note that we have to make an HTTP request to a test page handler
+ * because the pager depends on GET parameters.
+ */
+ function testOddPagerQuery() {
+ // To keep the test from being too brittle, we determine up front
+ // what the page count should be dynamically, and pass the control
+ // information forward to the actual query on the other side of the
+ // HTTP request.
+ $limit = 2;
+ $count = db_query('SELECT COUNT(*) FROM {test_task}')->fetchField();
+
+ $correct_number = $limit;
+ $num_pages = floor($count / $limit);
+
+ // If there is no remainder from rounding, subtract 1 since we index from 0.
+ if (!($num_pages * $limit < $count)) {
+ $num_pages--;
+ }
+
+ for ($page = 0; $page <= $num_pages; ++$page) {
+ $this->drupalGet('database_test/pager_query_odd/' . $limit, array('query' => array('page' => $page)));
+ $data = json_decode($this->drupalGetContent());
+
+ if ($page == $num_pages) {
+ $correct_number = $count - ($limit * $page);
+ }
+
+ $this->assertEqual(count($data->names), $correct_number, t('Correct number of records returned by pager: @number', array('@number' => $correct_number)));
+ }
+ }
+
+ /**
+ * Confirm that a pager query with inner pager query returns valid results.
+ *
+ * This is a regression test for #467984.
+ */
+ function testInnerPagerQuery() {
+ $query = db_select('test', 't')->extend('PagerDefault');
+ $query
+ ->fields('t', array('age'))
+ ->orderBy('age')
+ ->limit(5);
+
+ $outer_query = db_select($query);
+ $outer_query->addField('subquery', 'age');
+
+ $ages = $outer_query
+ ->execute()
+ ->fetchCol();
+ $this->assertEqual($ages, array(25, 26, 27, 28), t('Inner pager query returned the correct ages.'));
+ }
+
+ /**
+ * Confirm that a paging query with a having expression returns valid results.
+ *
+ * This is a regression test for #467984.
+ */
+ function testHavingPagerQuery() {
+ $query = db_select('test', 't')->extend('PagerDefault');
+ $query
+ ->fields('t', array('name'))
+ ->orderBy('name')
+ ->groupBy('name')
+ ->having('MAX(age) > :count', array(':count' => 26))
+ ->limit(5);
+
+ $ages = $query
+ ->execute()
+ ->fetchCol();
+ $this->assertEqual($ages, array('George', 'Ringo'), t('Pager query with having expression returned the correct ages.'));
+ }
+
+ /**
+ * Confirm that every pager gets a valid non-overlaping element ID.
+ */
+ function testElementNumbers() {
+ $_GET['page'] = '3, 2, 1, 0';
+
+ $name = db_select('test', 't')->extend('PagerDefault')
+ ->element(2)
+ ->fields('t', array('name'))
+ ->orderBy('age')
+ ->limit(1)
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($name, 'Paul', t('Pager query #1 with a specified element ID returned the correct results.'));
+
+ // Setting an element smaller than the previous one
+ // should not overwrite the pager $maxElement with a smaller value.
+ $name = db_select('test', 't')->extend('PagerDefault')
+ ->element(1)
+ ->fields('t', array('name'))
+ ->orderBy('age')
+ ->limit(1)
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($name, 'George', t('Pager query #2 with a specified element ID returned the correct results.'));
+
+ $name = db_select('test', 't')->extend('PagerDefault')
+ ->fields('t', array('name'))
+ ->orderBy('age')
+ ->limit(1)
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($name, 'John', t('Pager query #3 with a generated element ID returned the correct results.'));
+
+ unset($_GET['page']);
+ }
+}
+
+
+class DatabaseSelectTableSortDefaultTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Tablesort query tests',
+ 'description' => 'Test the tablesort query extender.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that a tablesort query returns the correct results.
+ *
+ * Note that we have to make an HTTP request to a test page handler
+ * because the pager depends on GET parameters.
+ */
+ function testTableSortQuery() {
+ $sorts = array(
+ array('field' => t('Task ID'), 'sort' => 'desc', 'first' => 'perform at superbowl', 'last' => 'eat'),
+ array('field' => t('Task ID'), 'sort' => 'asc', 'first' => 'eat', 'last' => 'perform at superbowl'),
+ array('field' => t('Task'), 'sort' => 'asc', 'first' => 'code', 'last' => 'sleep'),
+ array('field' => t('Task'), 'sort' => 'desc', 'first' => 'sleep', 'last' => 'code'),
+ // more elements here
+
+ );
+
+ foreach ($sorts as $sort) {
+ $this->drupalGet('database_test/tablesort/', array('query' => array('order' => $sort['field'], 'sort' => $sort['sort'])));
+ $data = json_decode($this->drupalGetContent());
+
+ $first = array_shift($data->tasks);
+ $last = array_pop($data->tasks);
+
+ $this->assertEqual($first->task, $sort['first'], t('Items appear in the correct order.'));
+ $this->assertEqual($last->task, $sort['last'], t('Items appear in the correct order.'));
+ }
+ }
+
+ /**
+ * Confirm that if a tablesort's orderByHeader is called before another orderBy, that the header happens first.
+ *
+ */
+ function testTableSortQueryFirst() {
+ $sorts = array(
+ array('field' => t('Task ID'), 'sort' => 'desc', 'first' => 'perform at superbowl', 'last' => 'eat'),
+ array('field' => t('Task ID'), 'sort' => 'asc', 'first' => 'eat', 'last' => 'perform at superbowl'),
+ array('field' => t('Task'), 'sort' => 'asc', 'first' => 'code', 'last' => 'sleep'),
+ array('field' => t('Task'), 'sort' => 'desc', 'first' => 'sleep', 'last' => 'code'),
+ // more elements here
+
+ );
+
+ foreach ($sorts as $sort) {
+ $this->drupalGet('database_test/tablesort_first/', array('query' => array('order' => $sort['field'], 'sort' => $sort['sort'])));
+ $data = json_decode($this->drupalGetContent());
+
+ $first = array_shift($data->tasks);
+ $last = array_pop($data->tasks);
+
+ $this->assertEqual($first->task, $sort['first'], t('Items appear in the correct order sorting by @field @sort.', array('@field' => $sort['field'], '@sort' => $sort['sort'])));
+ $this->assertEqual($last->task, $sort['last'], t('Items appear in the correct order sorting by @field @sort.', array('@field' => $sort['field'], '@sort' => $sort['sort'])));
+ }
+ }
+
+ /**
+ * Confirm that if a sort is not set in a tableselect form there is no error thrown when using the default.
+ */
+ function testTableSortDefaultSort() {
+ $this->drupalGet('database_test/tablesort_default_sort');
+ // Any PHP errors or notices thrown would trigger a simpletest exception, so
+ // no additional assertions are needed.
+ }
+}
+
+/**
+ * Select tagging tests.
+ *
+ * Tags are a way to flag queries for alter hooks so they know
+ * what type of query it is, such as "node_access".
+ */
+class DatabaseTaggingTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Query tagging tests',
+ 'description' => 'Test the tagging capabilities of the Select builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that a query has a "tag" added to it.
+ */
+ function testHasTag() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+
+ $query->addTag('test');
+
+ $this->assertTrue($query->hasTag('test'), t('hasTag() returned true.'));
+ $this->assertFalse($query->hasTag('other'), t('hasTag() returned false.'));
+ }
+
+ /**
+ * Test query tagging "has all of these tags" functionality.
+ */
+ function testHasAllTags() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+
+ $query->addTag('test');
+ $query->addTag('other');
+
+ $this->assertTrue($query->hasAllTags('test', 'other'), t('hasAllTags() returned true.'));
+ $this->assertFalse($query->hasAllTags('test', 'stuff'), t('hasAllTags() returned false.'));
+ }
+
+ /**
+ * Test query tagging "has at least one of these tags" functionality.
+ */
+ function testHasAnyTag() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+
+ $query->addTag('test');
+
+ $this->assertTrue($query->hasAnyTag('test', 'other'), t('hasAnyTag() returned true.'));
+ $this->assertFalse($query->hasAnyTag('other', 'stuff'), t('hasAnyTag() returned false.'));
+ }
+
+ /**
+ * Test that we can attach meta data to a query object.
+ *
+ * This is how we pass additional context to alter hooks.
+ */
+ function testMetaData() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+
+ $data = array(
+ 'a' => 'A',
+ 'b' => 'B',
+ );
+
+ $query->addMetaData('test', $data);
+
+ $return = $query->getMetaData('test');
+ $this->assertEqual($data, $return, t('Corect metadata returned.'));
+
+ $return = $query->getMetaData('nothere');
+ $this->assertNull($return, t('Non-existent key returned NULL.'));
+ }
+}
+
+/**
+ * Select alter tests.
+ *
+ * @see database_test_query_alter()
+ */
+class DatabaseAlterTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Query altering tests',
+ 'description' => 'Test the hook_query_alter capabilities of the Select builder.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that we can do basic alters.
+ */
+ function testSimpleAlter() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+ $query->addTag('database_test_alter_add_range');
+
+ $result = $query->execute();
+
+ $num_records = 0;
+ foreach ($result as $record) {
+ $num_records++;
+ }
+
+ $this->assertEqual($num_records, 2, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test that we can alter the joins on a query.
+ */
+ function testAlterWithJoin() {
+ $query = db_select('test_task');
+ $tid_field = $query->addField('test_task', 'tid');
+ $task_field = $query->addField('test_task', 'task');
+ $query->orderBy($task_field);
+ $query->addTag('database_test_alter_add_join');
+
+ $result = $query->execute();
+
+ $records = $result->fetchAll();
+
+ $this->assertEqual(count($records), 2, t('Returned the correct number of rows.'));
+
+ $this->assertEqual($records[0]->name, 'George', t('Correct data retrieved.'));
+ $this->assertEqual($records[0]->$tid_field, 4, t('Correct data retrieved.'));
+ $this->assertEqual($records[0]->$task_field, 'sing', t('Correct data retrieved.'));
+ $this->assertEqual($records[1]->name, 'George', t('Correct data retrieved.'));
+ $this->assertEqual($records[1]->$tid_field, 5, t('Correct data retrieved.'));
+ $this->assertEqual($records[1]->$task_field, 'sleep', t('Correct data retrieved.'));
+ }
+
+ /**
+ * Test that we can alter a query's conditionals.
+ */
+ function testAlterChangeConditional() {
+ $query = db_select('test_task');
+ $tid_field = $query->addField('test_task', 'tid');
+ $pid_field = $query->addField('test_task', 'pid');
+ $task_field = $query->addField('test_task', 'task');
+ $people_alias = $query->join('test', 'people', "test_task.pid = people.id");
+ $name_field = $query->addField($people_alias, 'name', 'name');
+ $query->condition('test_task.tid', '1');
+ $query->orderBy($tid_field);
+ $query->addTag('database_test_alter_change_conditional');
+
+ $result = $query->execute();
+
+ $records = $result->fetchAll();
+
+ $this->assertEqual(count($records), 1, t('Returned the correct number of rows.'));
+ $this->assertEqual($records[0]->$name_field, 'John', t('Correct data retrieved.'));
+ $this->assertEqual($records[0]->$tid_field, 2, t('Correct data retrieved.'));
+ $this->assertEqual($records[0]->$pid_field, 1, t('Correct data retrieved.'));
+ $this->assertEqual($records[0]->$task_field, 'sleep', t('Correct data retrieved.'));
+ }
+
+ /**
+ * Test that we can alter the fields of a query.
+ */
+ function testAlterChangeFields() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addField('test', 'age', 'age');
+ $query->orderBy('name');
+ $query->addTag('database_test_alter_change_fields');
+
+ $record = $query->execute()->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Correct data retrieved.'));
+ $this->assertFalse(isset($record->$age_field), t('Age field not found, as intended.'));
+ }
+
+ /**
+ * Test that we can alter expressions in the query.
+ */
+ function testAlterExpression() {
+ $query = db_select('test');
+ $name_field = $query->addField('test', 'name');
+ $age_field = $query->addExpression("age*2", 'double_age');
+ $query->condition('age', 27);
+ $query->addTag('database_test_alter_change_expressions');
+ $result = $query->execute();
+
+ // Ensure that we got the right record.
+ $record = $result->fetch();
+
+ $this->assertEqual($record->$name_field, 'George', t('Fetched name is correct.'));
+ $this->assertEqual($record->$age_field, 27*3, t('Fetched age expression is correct.'));
+ }
+
+ /**
+ * Test that we can remove a range() value from a query. This also tests hook_query_TAG_alter().
+ */
+ function testAlterRemoveRange() {
+ $query = db_select('test');
+ $query->addField('test', 'name');
+ $query->addField('test', 'age', 'age');
+ $query->range(0, 2);
+ $query->addTag('database_test_alter_remove_range');
+
+ $num_records = count($query->execute()->fetchAll());
+
+ $this->assertEqual($num_records, 4, t('Returned the correct number of rows.'));
+ }
+
+ /**
+ * Test that we can do basic alters on subqueries.
+ */
+ function testSimpleAlterSubquery() {
+ // Create a sub-query with an alter tag.
+ $subquery = db_select('test', 'p');
+ $subquery->addField('p', 'name');
+ $subquery->addField('p', 'id');
+ // Pick out George.
+ $subquery->condition('age', 27);
+ $subquery->addExpression("age*2", 'double_age');
+ // This query alter should change it to age * 3.
+ $subquery->addTag('database_test_alter_change_expressions');
+
+ // Create a main query and join to sub-query.
+ $query = db_select('test_task', 'tt');
+ $query->join($subquery, 'pq', 'pq.id = tt.pid');
+ $age_field = $query->addField('pq', 'double_age');
+ $name_field = $query->addField('pq', 'name');
+
+ $record = $query->execute()->fetch();
+ $this->assertEqual($record->$name_field, 'George', t('Fetched name is correct.'));
+ $this->assertEqual($record->$age_field, 27*3, t('Fetched age expression is correct.'));
+ }
+}
+
+/**
+ * Regression tests.
+ */
+class DatabaseRegressionTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Regression tests',
+ 'description' => 'Regression tests cases for the database layer.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Regression test for #310447.
+ *
+ * Tries to insert non-ascii UTF-8 data in a database column and checks
+ * if its stored properly.
+ */
+ function testRegression_310447() {
+ // That's a 255 character UTF-8 string.
+ $name = str_repeat("é", 255);
+ db_insert('test')
+ ->fields(array(
+ 'name' => $name,
+ 'age' => 20,
+ 'job' => 'Dancer',
+ ))->execute();
+
+ $from_database = db_query('SELECT name FROM {test} WHERE name = :name', array(':name' => $name))->fetchField();
+ $this->assertIdentical($name, $from_database, t("The database handles UTF-8 characters cleanly."));
+ }
+
+ /**
+ * Test the db_table_exists() function.
+ */
+ function testDBTableExists() {
+ $this->assertIdentical(TRUE, db_table_exists('node'), t('Returns true for existent table.'));
+ $this->assertIdentical(FALSE, db_table_exists('nosuchtable'), t('Returns false for nonexistent table.'));
+ }
+
+ /**
+ * Test the db_field_exists() function.
+ */
+ function testDBFieldExists() {
+ $this->assertIdentical(TRUE, db_field_exists('node', 'nid'), t('Returns true for existent column.'));
+ $this->assertIdentical(FALSE, db_field_exists('node', 'nosuchcolumn'), t('Returns false for nonexistent column.'));
+ }
+
+ /**
+ * Test the db_index_exists() function.
+ */
+ function testDBIndexExists() {
+ $this->assertIdentical(TRUE, db_index_exists('node', 'node_created'), t('Returns true for existent index.'));
+ $this->assertIdentical(FALSE, db_index_exists('node', 'nosuchindex'), t('Returns false for nonexistent index.'));
+ }
+}
+
+/**
+ * Query logging tests.
+ */
+class DatabaseLoggingTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Query logging',
+ 'description' => 'Test the query logging facility.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Test that we can log the existence of a query.
+ */
+ function testEnableLogging() {
+ Database::startLog('testing');
+
+ db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25))->fetchCol();
+ db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetchCol();
+
+ $queries = Database::getLog('testing', 'default');
+
+ $this->assertEqual(count($queries), 2, t('Correct number of queries recorded.'));
+
+ foreach ($queries as $query) {
+ $this->assertEqual($query['caller']['function'], __FUNCTION__, t('Correct function in query log.'));
+ }
+ }
+
+ /**
+ * Test that we can run two logs in parallel.
+ */
+ function testEnableMultiLogging() {
+ Database::startLog('testing1');
+
+ db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25))->fetchCol();
+
+ Database::startLog('testing2');
+
+ db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'))->fetchCol();
+
+ $queries1 = Database::getLog('testing1');
+ $queries2 = Database::getLog('testing2');
+
+ $this->assertEqual(count($queries1), 2, t('Correct number of queries recorded for log 1.'));
+ $this->assertEqual(count($queries2), 1, t('Correct number of queries recorded for log 2.'));
+ }
+
+ /**
+ * Test that we can log queries against multiple targets on the same connection.
+ */
+ function testEnableTargetLogging() {
+ // Clone the master credentials to a slave connection and to another fake
+ // connection.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::addConnectionInfo('default', 'slave', $connection_info['default']);
+
+ Database::startLog('testing1');
+
+ db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25))->fetchCol();
+
+ db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'), array('target' => 'slave'));//->fetchCol();
+
+ $queries1 = Database::getLog('testing1');
+
+ $this->assertEqual(count($queries1), 2, t('Recorded queries from all targets.'));
+ $this->assertEqual($queries1[0]['target'], 'default', t('First query used default target.'));
+ $this->assertEqual($queries1[1]['target'], 'slave', t('Second query used slave target.'));
+ }
+
+ /**
+ * Test that logs to separate targets collapse to the same connection properly.
+ *
+ * This test is identical to the one above, except that it doesn't create
+ * a fake target so the query should fall back to running on the default
+ * target.
+ */
+ function testEnableTargetLoggingNoTarget() {
+ Database::startLog('testing1');
+
+ db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25))->fetchCol();
+
+ // We use "fake" here as a target because any non-existent target will do.
+ // However, because all of the tests in this class share a single page
+ // request there is likely to be a target of "slave" from one of the other
+ // unit tests, so we use a target here that we know with absolute certainty
+ // does not exist.
+ db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'), array('target' => 'fake'))->fetchCol();
+
+ $queries1 = Database::getLog('testing1');
+
+ $this->assertEqual(count($queries1), 2, t('Recorded queries from all targets.'));
+ $this->assertEqual($queries1[0]['target'], 'default', t('First query used default target.'));
+ $this->assertEqual($queries1[1]['target'], 'default', t('Second query used default target as fallback.'));
+ }
+
+ /**
+ * Test that we can log queries separately on different connections.
+ */
+ function testEnableMultiConnectionLogging() {
+ // Clone the master credentials to a fake connection.
+ // That both connections point to the same physical database is irrelevant.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::addConnectionInfo('test2', 'default', $connection_info['default']);
+
+ Database::startLog('testing1');
+ Database::startLog('testing1', 'test2');
+
+ db_query('SELECT name FROM {test} WHERE age > :age', array(':age' => 25))->fetchCol();
+
+ $old_key = db_set_active('test2');
+
+ db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'Ringo'), array('target' => 'slave'))->fetchCol();
+
+ db_set_active($old_key);
+
+ $queries1 = Database::getLog('testing1');
+ $queries2 = Database::getLog('testing1', 'test2');
+
+ $this->assertEqual(count($queries1), 1, t('Correct number of queries recorded for first connection.'));
+ $this->assertEqual(count($queries2), 1, t('Correct number of queries recorded for second connection.'));
+ }
+}
+
+/**
+ * Query serialization tests.
+ */
+class DatabaseSerializeQueryTestCase extends DatabaseTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Serialize query',
+ 'description' => 'Test serializing and unserializing a query.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Confirm that a query can be serialized and unserialized.
+ */
+ function testSerializeQuery() {
+ $query = db_select('test');
+ $query->addField('test', 'age');
+ $query->condition('name', 'Ringo');
+ // If this doesn't work, it will throw an exception, so no need for an
+ // assertion.
+ $query = unserialize(serialize($query));
+ $results = $query->execute()->fetchCol();
+ $this->assertEqual($results[0], 28, t('Query properly executed after unserialization.'));
+ }
+}
+
+/**
+ * Range query tests.
+ */
+class DatabaseRangeQueryTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Range query test',
+ 'description' => 'Test the Range query functionality.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Confirm that range query work and return correct result.
+ */
+ function testRangeQuery() {
+ // Test if return correct number of rows.
+ $range_rows = db_query_range("SELECT name FROM {system} ORDER BY name", 2, 3)->fetchAll();
+ $this->assertEqual(count($range_rows), 3, t('Range query work and return correct number of rows.'));
+
+ // Test if return target data.
+ $raw_rows = db_query('SELECT name FROM {system} ORDER BY name')->fetchAll();
+ $raw_rows = array_slice($raw_rows, 2, 3);
+ $this->assertEqual($range_rows, $raw_rows, t('Range query work and return target data.'));
+ }
+}
+
+/**
+ * Temporary query tests.
+ */
+class DatabaseTemporaryQueryTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Temporary query test',
+ 'description' => 'Test the temporary query functionality.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Return the number of rows of a table.
+ */
+ function countTableRows($table_name) {
+ return db_select($table_name)->countQuery()->execute()->fetchField();
+ }
+
+ /**
+ * Confirm that temporary tables work and are limited to one request.
+ */
+ function testTemporaryQuery() {
+ $this->drupalGet('database_test/db_query_temporary');
+ $data = json_decode($this->drupalGetContent());
+ if ($data) {
+ $this->assertEqual($this->countTableRows("system"), $data->row_count, t('The temporary table contains the correct amount of rows.'));
+ $this->assertFalse(db_table_exists($data->table_name), t('The temporary table is, indeed, temporary.'));
+ }
+ else {
+ $this->fail(t("The creation of the temporary table failed."));
+ }
+
+ // Now try to run two db_query_temporary() in the same request.
+ $table_name_system = db_query_temporary('SELECT status FROM {system}', array());
+ $table_name_users = db_query_temporary('SELECT uid FROM {users}', array());
+
+ $this->assertEqual($this->countTableRows($table_name_system), $this->countTableRows("system"), t('A temporary table was created successfully in this request.'));
+ $this->assertEqual($this->countTableRows($table_name_users), $this->countTableRows("users"), t('A second temporary table was created successfully in this request.'));
+ }
+}
+
+/**
+ * Test how the current database driver interprets the SQL syntax.
+ *
+ * In order to ensure consistent SQL handling throughout Drupal
+ * across multiple kinds of database systems, we test that the
+ * database system interprets SQL syntax in an expected fashion.
+ */
+class DatabaseBasicSyntaxTestCase extends DatabaseTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Basic SQL syntax tests',
+ 'description' => 'Test SQL syntax interpretation.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Test for string concatenation.
+ */
+ function testBasicConcat() {
+ $result = db_query('SELECT CONCAT(:a1, CONCAT(:a2, CONCAT(:a3, CONCAT(:a4, :a5))))', array(
+ ':a1' => 'This',
+ ':a2' => ' ',
+ ':a3' => 'is',
+ ':a4' => ' a ',
+ ':a5' => 'test.',
+ ));
+ $this->assertIdentical($result->fetchField(), 'This is a test.', t('Basic CONCAT works.'));
+ }
+
+ /**
+ * Test for string concatenation with field values.
+ */
+ function testFieldConcat() {
+ $result = db_query('SELECT CONCAT(:a1, CONCAT(name, CONCAT(:a2, CONCAT(age, :a3)))) FROM {test} WHERE age = :age', array(
+ ':a1' => 'The age of ',
+ ':a2' => ' is ',
+ ':a3' => '.',
+ ':age' => 25,
+ ));
+ $this->assertIdentical($result->fetchField(), 'The age of John is 25.', t('Field CONCAT works.'));
+ }
+
+ /**
+ * Test escaping of LIKE wildcards.
+ */
+ function testLikeEscape() {
+ db_insert('test')
+ ->fields(array(
+ 'name' => 'Ring_',
+ ))
+ ->execute();
+
+ // Match both "Ringo" and "Ring_".
+ $num_matches = db_select('test', 't')
+ ->condition('name', 'Ring_', 'LIKE')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Found 2 records.'));
+ // Match only "Ring_" using a LIKE expression with no wildcards.
+ $num_matches = db_select('test', 't')
+ ->condition('name', db_like('Ring_'), 'LIKE')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Found 1 record.'));
+ }
+
+ /**
+ * Test LIKE query containing a backslash.
+ */
+ function testLikeBackslash() {
+ db_insert('test')
+ ->fields(array('name'))
+ ->values(array(
+ 'name' => 'abcde\f',
+ ))
+ ->values(array(
+ 'name' => 'abc%\_',
+ ))
+ ->execute();
+
+ // Match both rows using a LIKE expression with two wildcards and a verbatim
+ // backslash.
+ $num_matches = db_select('test', 't')
+ ->condition('name', 'abc%\\\\_', 'LIKE')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical($num_matches, '2', t('Found 2 records.'));
+ // Match only the former using a LIKE expression with no wildcards.
+ $num_matches = db_select('test', 't')
+ ->condition('name', db_like('abc%\_'), 'LIKE')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical($num_matches, '1', t('Found 1 record.'));
+ }
+}
+
+/**
+ * Test invalid data handling.
+ */
+class DatabaseInvalidDataTestCase extends DatabaseTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Invalid data',
+ 'description' => 'Test handling of some invalid data.',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Traditional SQL database systems abort inserts when invalid data is encountered.
+ */
+ function testInsertDuplicateData() {
+ // Try to insert multiple records where at least one has bad data.
+ try {
+ db_insert('test')
+ ->fields(array('name', 'age', 'job'))
+ ->values(array(
+ 'name' => 'Elvis',
+ 'age' => 63,
+ 'job' => 'Singer',
+ ))->values(array(
+ 'name' => 'John', // <-- Duplicate value on unique field.
+ 'age' => 17,
+ 'job' => 'Consultant',
+ ))
+ ->values(array(
+ 'name' => 'Frank',
+ 'age' => 75,
+ 'job' => 'Singer',
+ ))
+ ->execute();
+ $this->fail(t('Insert succeedded when it should not have.'));
+ }
+ catch (Exception $e) {
+ // Check if the first record was inserted.
+ $name = db_query('SELECT name FROM {test} WHERE age = :age', array(':age' => 63))->fetchField();
+
+ if ($name == 'Elvis') {
+ if (!Database::getConnection()->supportsTransactions()) {
+ // This is an expected fail.
+ // Database engines that don't support transactions can leave partial
+ // inserts in place when an error occurs. This is the case for MySQL
+ // when running on a MyISAM table.
+ $this->pass(t("The whole transaction has not been rolled-back when a duplicate key insert occurs, this is expected because the database doesn't support transactions"));
+ }
+ else {
+ $this->fail(t('The whole transaction is rolled back when a duplicate key insert occurs.'));
+ }
+ }
+ else {
+ $this->pass(t('The whole transaction is rolled back when a duplicate key insert occurs.'));
+ }
+
+ // Ensure the other values were not inserted.
+ $record = db_select('test')
+ ->fields('test', array('name', 'age'))
+ ->condition('age', array(17, 75), 'IN')
+ ->execute()->fetchObject();
+
+ $this->assertFalse($record, t('The rest of the insert aborted as expected.'));
+ }
+ }
+
+}
+
+/**
+ * Drupal-specific SQL syntax tests.
+ */
+class DatabaseQueryTestCase extends DatabaseTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Custom query syntax tests',
+ 'description' => 'Test Drupal\'s extended prepared statement syntax..',
+ 'group' => 'Database',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('database_test');
+ }
+
+ /**
+ * Test that we can specify an array of values in the query by simply passing in an array.
+ */
+ function testArraySubstitution() {
+ $names = db_query('SELECT name FROM {test} WHERE age IN (:ages) ORDER BY age', array(':ages' => array(25, 26, 27)))->fetchAll();
+
+ $this->assertEqual(count($names), 3, t('Correct number of names returned'));
+ }
+}
+
+/**
+ * Test transaction support, particularly nesting.
+ *
+ * We test nesting by having two transaction layers, an outer and inner. The
+ * outer layer encapsulates the inner layer. Our transaction nesting abstraction
+ * should allow the outer layer function to call any function it wants,
+ * especially the inner layer that starts its own transaction, and be
+ * confident that, when the function it calls returns, its own transaction
+ * is still "alive."
+ *
+ * Call structure:
+ * transactionOuterLayer()
+ * Start transaction
+ * transactionInnerLayer()
+ * Start transaction (does nothing in database)
+ * [Maybe decide to roll back]
+ * Do more stuff
+ * Should still be in transaction A
+ *
+ */
+class DatabaseTransactionTestCase extends DatabaseTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Transaction tests',
+ 'description' => 'Test the transaction abstraction system.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ * Helper method for transaction unit test. This "outer layer" transaction
+ * starts and then encapsulates the "inner layer" transaction. This nesting
+ * is used to evaluate whether the the database transaction API properly
+ * supports nesting. By "properly supports," we mean the outer transaction
+ * continues to exist regardless of what functions are called and whether
+ * those functions start their own transactions.
+ *
+ * In contrast, a typical database would commit the outer transaction, start
+ * a new transaction for the inner layer, commit the inner layer transaction,
+ * and then be confused when the outer layer transaction tries to commit its
+ * transaction (which was already committed when the inner transaction
+ * started).
+ *
+ * @param $suffix
+ * Suffix to add to field values to differentiate tests.
+ * @param $rollback
+ * Whether or not to try rolling back the transaction when we're done.
+ * @param $ddl_statement
+ * Whether to execute a DDL statement during the inner transaction.
+ */
+ protected function transactionOuterLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
+ $connection = Database::getConnection();
+ $depth = $connection->transactionDepth();
+ $txn = db_transaction();
+
+ // Insert a single row into the testing table.
+ db_insert('test')
+ ->fields(array(
+ 'name' => 'David' . $suffix,
+ 'age' => '24',
+ ))
+ ->execute();
+
+ $this->assertTrue($connection->inTransaction(), t('In transaction before calling nested transaction.'));
+
+ // We're already in a transaction, but we call ->transactionInnerLayer
+ // to nest another transaction inside the current one.
+ $this->transactionInnerLayer($suffix, $rollback, $ddl_statement);
+
+ $this->assertTrue($connection->inTransaction(), t('In transaction after calling nested transaction.'));
+
+ if ($rollback) {
+ // Roll back the transaction, if requested.
+ // This rollback should propagate to the last savepoint.
+ $txn->rollback();
+ $this->assertTrue(($connection->transactionDepth() == $depth), t('Transaction has rolled back to the last savepoint after calling rollback().'));
+ }
+ }
+
+ /**
+ * Helper method for transaction unit tests. This "inner layer" transaction
+ * is either used alone or nested inside of the "outer layer" transaction.
+ *
+ * @param $suffix
+ * Suffix to add to field values to differentiate tests.
+ * @param $rollback
+ * Whether or not to try rolling back the transaction when we're done.
+ * @param $ddl_statement
+ * Whether to execute a DDL statement during the transaction.
+ */
+ protected function transactionInnerLayer($suffix, $rollback = FALSE, $ddl_statement = FALSE) {
+ $connection = Database::getConnection();
+
+ $depth = $connection->transactionDepth();
+ // Start a transaction. If we're being called from ->transactionOuterLayer,
+ // then we're already in a transaction. Normally, that would make starting
+ // a transaction here dangerous, but the database API handles this problem
+ // for us by tracking the nesting and avoiding the danger.
+ $txn = db_transaction();
+
+ $depth2 = $connection->transactionDepth();
+ $this->assertTrue($depth < $depth2, t('Transaction depth is has increased with new transaction.'));
+
+ // Insert a single row into the testing table.
+ db_insert('test')
+ ->fields(array(
+ 'name' => 'Daniel' . $suffix,
+ 'age' => '19',
+ ))
+ ->execute();
+
+ $this->assertTrue($connection->inTransaction(), t('In transaction inside nested transaction.'));
+
+ if ($ddl_statement) {
+ $table = array(
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('id'),
+ );
+ db_create_table('database_test_1', $table);
+
+ $this->assertTrue($connection->inTransaction(), t('In transaction inside nested transaction.'));
+ }
+
+ if ($rollback) {
+ // Roll back the transaction, if requested.
+ // This rollback should propagate to the last savepoint.
+ $txn->rollback();
+ $this->assertTrue(($connection->transactionDepth() == $depth), t('Transaction has rolled back to the last savepoint after calling rollback().'));
+ }
+ }
+
+ /**
+ * Test transaction rollback on a database that supports transactions.
+ *
+ * If the active connection does not support transactions, this test does nothing.
+ */
+ function testTransactionRollBackSupported() {
+ // This test won't work right if transactions are not supported.
+ if (!Database::getConnection()->supportsTransactions()) {
+ return;
+ }
+ try {
+ // Create two nested transactions. Roll back from the inner one.
+ $this->transactionOuterLayer('B', TRUE);
+
+ // Neither of the rows we inserted in the two transaction layers
+ // should be present in the tables post-rollback.
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DavidB'))->fetchField();
+ $this->assertNotIdentical($saved_age, '24', t('Cannot retrieve DavidB row after commit.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DanielB'))->fetchField();
+ $this->assertNotIdentical($saved_age, '19', t('Cannot retrieve DanielB row after commit.'));
+ }
+ catch (Exception $e) {
+ $this->fail($e->getMessage());
+ }
+ }
+
+ /**
+ * Test transaction rollback on a database that does not support transactions.
+ *
+ * If the active driver supports transactions, this test does nothing.
+ */
+ function testTransactionRollBackNotSupported() {
+ // This test won't work right if transactions are supported.
+ if (Database::getConnection()->supportsTransactions()) {
+ return;
+ }
+ try {
+ // Create two nested transactions. Attempt to roll back from the inner one.
+ $this->transactionOuterLayer('B', TRUE);
+
+ // Because our current database claims to not support transactions,
+ // the inserted rows should be present despite the attempt to roll back.
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DavidB'))->fetchField();
+ $this->assertIdentical($saved_age, '24', t('DavidB not rolled back, since transactions are not supported.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DanielB'))->fetchField();
+ $this->assertIdentical($saved_age, '19', t('DanielB not rolled back, since transactions are not supported.'));
+ }
+ catch (Exception $e) {
+ $this->fail($e->getMessage());
+ }
+ }
+
+ /**
+ * Test committed transaction.
+ *
+ * The behavior of this test should be identical for connections that support
+ * transactions and those that do not.
+ */
+ function testCommittedTransaction() {
+ try {
+ // Create two nested transactions. The changes should be committed.
+ $this->transactionOuterLayer('A');
+
+ // Because we committed, both of the inserted rows should be present.
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DavidA'))->fetchField();
+ $this->assertIdentical($saved_age, '24', t('Can retrieve DavidA row after commit.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DanielA'))->fetchField();
+ $this->assertIdentical($saved_age, '19', t('Can retrieve DanielA row after commit.'));
+ }
+ catch (Exception $e) {
+ $this->fail($e->getMessage());
+ }
+ }
+
+ /**
+ * Test the compatibility of transactions with DDL statements.
+ */
+ function testTransactionWithDdlStatement() {
+ // First, test that a commit works normally, even with DDL statements.
+ try {
+ $this->transactionOuterLayer('D', FALSE, TRUE);
+
+ // Because we committed, the inserted rows should both be present.
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DavidD'))->fetchField();
+ $this->assertIdentical($saved_age, '24', t('Can retrieve DavidD row after commit.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DanielD'))->fetchField();
+ $this->assertIdentical($saved_age, '19', t('Can retrieve DanielD row after commit.'));
+ // The created table should also exist.
+ $count = db_query('SELECT COUNT(id) FROM {database_test_1}')->fetchField();
+ $this->assertIdentical($count, '0', t('Table was successfully created inside a transaction.'));
+ }
+ catch (Exception $e) {
+ $this->fail((string) $e);
+ }
+
+ // If we rollback the transaction, an exception might be thrown.
+ try {
+ $this->transactionOuterLayer('E', TRUE, TRUE);
+
+ // Because we rolled back, the inserted rows shouldn't be present.
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DavidE'))->fetchField();
+ $this->assertNotIdentical($saved_age, '24', t('Cannot retrieve DavidE row after rollback.'));
+ $saved_age = db_query('SELECT age FROM {test} WHERE name = :name', array(':name' => 'DanielE'))->fetchField();
+ $this->assertNotIdentical($saved_age, '19', t('Cannot retrieve DanielE row after rollback.'));
+ }
+ catch (Exception $e) {
+ // An exception also lets the test pass.
+ $this->assertTrue(true, t('Exception thrown on rollback after a DDL statement was executed.'));
+ }
+ }
+
+ /**
+ * Insert a single row into the testing table.
+ */
+ protected function insertRow($name) {
+ db_insert('test')
+ ->fields(array(
+ 'name' => $name,
+ ))
+ ->execute();
+ }
+
+ /**
+ * Start over for a new test.
+ */
+ protected function cleanUp() {
+ db_truncate('test')
+ ->execute();
+ }
+
+ /**
+ * Assert that a given row is present in the test table.
+ *
+ * @param $name
+ * The name of the row.
+ * @param $message
+ * The message to log for the assertion.
+ */
+ function assertRowPresent($name, $message = NULL) {
+ if (!isset($message)) {
+ $message = t('Row %name is present.', array('%name' => $name));
+ }
+ $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', array(':name' => $name))->fetchField();
+ return $this->assertTrue($present, $message);
+ }
+
+ /**
+ * Assert that a given row is absent from the test table.
+ *
+ * @param $name
+ * The name of the row.
+ * @param $message
+ * The message to log for the assertion.
+ */
+ function assertRowAbsent($name, $message = NULL) {
+ if (!isset($message)) {
+ $message = t('Row %name is absent.', array('%name' => $name));
+ }
+ $present = (boolean) db_query('SELECT 1 FROM {test} WHERE name = :name', array(':name' => $name))->fetchField();
+ return $this->assertFalse($present, $message);
+ }
+
+ /**
+ * Test transaction stacking and commit / rollback.
+ */
+ function testTransactionStacking() {
+ // This test won't work right if transactions are supported.
+ if (Database::getConnection()->supportsTransactions()) {
+ return;
+ }
+
+ $database = Database::getConnection();
+
+ // Standard case: pop the inner transaction before the outer transaction.
+ $transaction = db_transaction();
+ $this->insertRow('outer');
+ $transaction2 = db_transaction();
+ $this->insertRow('inner');
+ // Pop the inner transaction.
+ unset($transaction2);
+ $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the inner transaction'));
+ // Pop the outer transaction.
+ unset($transaction);
+ $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the outer transaction'));
+ $this->assertRowPresent('outer');
+ $this->assertRowPresent('inner');
+
+ // Pop the transaction in a different order they have been pushed.
+ $this->cleanUp();
+ $transaction = db_transaction();
+ $this->insertRow('outer');
+ $transaction2 = db_transaction();
+ $this->insertRow('inner');
+ // Pop the outer transaction, nothing should happen.
+ unset($transaction);
+ $this->insertRow('inner-after-outer-commit');
+ $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction'));
+ // Pop the inner transaction, the whole transaction should commit.
+ unset($transaction2);
+ $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction'));
+ $this->assertRowPresent('outer');
+ $this->assertRowPresent('inner');
+ $this->assertRowPresent('inner-after-outer-commit');
+
+ // Rollback the inner transaction.
+ $this->cleanUp();
+ $transaction = db_transaction();
+ $this->insertRow('outer');
+ $transaction2 = db_transaction();
+ $this->insertRow('inner');
+ // Now rollback the inner transaction.
+ $transaction2->rollback();
+ unset($transaction2);
+ $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction'));
+ // Pop the outer transaction, it should commit.
+ $this->insertRow('outer-after-inner-rollback');
+ unset($transaction);
+ $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction'));
+ $this->assertRowPresent('outer');
+ $this->assertRowAbsent('inner');
+ $this->assertRowPresent('outer-after-inner-rollback');
+
+ // Rollback the inner transaction after committing the outer one.
+ $this->cleanUp();
+ $transaction = db_transaction();
+ $this->insertRow('outer');
+ $transaction2 = db_transaction();
+ $this->insertRow('inner');
+ // Pop the outer transaction, nothing should happen.
+ unset($transaction);
+ $this->assertTrue($database->inTransaction(), t('Still in a transaction after popping the outer transaction'));
+ // Now rollback the inner transaction, it should rollback.
+ $transaction2->rollback();
+ unset($transaction2);
+ $this->assertFalse($database->inTransaction(), t('Transaction closed after popping the inner transaction'));
+ $this->assertRowPresent('outer');
+ $this->assertRowAbsent('inner');
+
+ // Rollback the outer transaction while the inner transaction is active.
+ // In that case, an exception will be triggered because we cannot
+ // ensure that the final result will have any meaning.
+ $this->cleanUp();
+ $transaction = db_transaction();
+ $this->insertRow('outer');
+ $transaction2 = db_transaction();
+ $this->insertRow('inner');
+ // Rollback the outer transaction.
+ try {
+ $transaction->rollback();
+ unset($transaction);
+ $this->fail(t('Rolling back the outer transaction while the inner transaction is active resulted in an exception.'));
+ }
+ catch (Exception $e) {
+ $this->pass(t('Rolling back the outer transaction while the inner transaction is active resulted in an exception.'));
+ }
+ $this->assertFalse($database->inTransaction(), t('No more in a transaction after rolling back the outer transaction'));
+ // Try to commit the inner transaction.
+ try {
+ unset($transaction2);
+ $this->fail(t('Trying to commit the inner transaction resulted in an exception.'));
+ }
+ catch (Exception $e) {
+ $this->pass(t('Trying to commit the inner transaction resulted in an exception.'));
+ }
+ $this->assertRowAbsent('outer');
+ $this->assertRowAbsent('inner');
+ }
+}
+
+
+/**
+ * Check the sequences API.
+ */
+class DatabaseNextIdCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => t('Sequences API'),
+ 'description' => t('Test the secondary sequences API.'),
+ 'group' => t('Database'),
+ );
+ }
+
+ /**
+ * Test that the sequences API work.
+ */
+ function testDbNextId() {
+ $first = db_next_id();
+ $second = db_next_id();
+ // We can test for exact increase in here because we know there is no
+ // other process operating on these tables -- normally we could only
+ // expect $second > $first.
+ $this->assertEqual($first + 1, $second, t('The second call from a sequence provides a number increased by one.'));
+ $result = db_next_id(1000);
+ $this->assertEqual($result, 1001, t('Sequence provides a larger number than the existing ID.'));
+ }
+}
+
+/**
+ * Tests the empty pseudo-statement class.
+ */
+class DatabaseEmptyStatementTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => t('Empty statement'),
+ 'description' => t('Test the empty pseudo-statement class.'),
+ 'group' => t('Database'),
+ );
+ }
+
+ /**
+ * Test that the empty result set behaves as empty.
+ */
+ function testEmpty() {
+ $result = new DatabaseStatementEmpty();
+
+ $this->assertTrue($result instanceof DatabaseStatementInterface, t('Class implements expected interface'));
+ $this->assertNull($result->fetchObject(), t('Null result returned.'));
+ }
+
+ /**
+ * Test that the empty result set iterates safely.
+ */
+ function testEmptyIteration() {
+ $result = new DatabaseStatementEmpty();
+
+ foreach ($result as $record) {
+ $this->fail(t('Iterating empty result set should not iterate.'));
+ return;
+ }
+
+ $this->pass(t('Iterating empty result set skipped iteration.'));
+ }
+
+ /**
+ * Test that the empty result set mass-fetches in an expected way.
+ */
+ function testEmptyFetchAll() {
+ $result = new DatabaseStatementEmpty();
+
+ $this->assertEqual($result->fetchAll(), array(), t('Empty array returned from empty result set.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.info b/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.info
new file mode 100644
index 000000000000..53515e769e3a
--- /dev/null
+++ b/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.info
@@ -0,0 +1,6 @@
+name = "Drupal system listing compatible test"
+description = "Support module for testing the drupal_system_listing function."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.module b/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/drupal_system_listing_compatible_test/drupal_system_listing_compatible_test.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.info b/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.info
new file mode 100644
index 000000000000..8753edaab9f4
--- /dev/null
+++ b/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.info
@@ -0,0 +1,6 @@
+name = "Drupal system listing incompatible test"
+description = "Support module for testing the drupal_system_listing function."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.module b/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/drupal_system_listing_incompatible_test/drupal_system_listing_incompatible_test.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/error.test b/core/modules/simpletest/tests/error.test
new file mode 100644
index 000000000000..8c5a8487255f
--- /dev/null
+++ b/core/modules/simpletest/tests/error.test
@@ -0,0 +1,116 @@
+<?php
+
+/**
+ * Tests Drupal error and exception handlers.
+ */
+class DrupalErrorHandlerUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal error handlers',
+ 'description' => 'Performs tests on the Drupal error and exception handler.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('error_test');
+ }
+
+ /**
+ * Test the error handler.
+ */
+ function testErrorHandler() {
+ $error_notice = array(
+ '%type' => 'Notice',
+ '!message' => 'Undefined variable: bananas',
+ '%function' => 'error_test_generate_warnings()',
+ '%file' => drupal_realpath('core/modules/simpletest/tests/error_test.module'),
+ );
+ $error_warning = array(
+ '%type' => 'Warning',
+ '!message' => 'Division by zero',
+ '%function' => 'error_test_generate_warnings()',
+ '%file' => drupal_realpath('core/modules/simpletest/tests/error_test.module'),
+ );
+ $error_user_notice = array(
+ '%type' => 'User warning',
+ '!message' => 'Drupal is awesome',
+ '%function' => 'error_test_generate_warnings()',
+ '%file' => drupal_realpath('core/modules/simpletest/tests/error_test.module'),
+ );
+
+ // Set error reporting to collect notices.
+ variable_set('error_level', ERROR_REPORTING_DISPLAY_ALL);
+ $this->drupalGet('error-test/generate-warnings');
+ $this->assertResponse(200, t('Received expected HTTP status code.'));
+ $this->assertErrorMessage($error_notice);
+ $this->assertErrorMessage($error_warning);
+ $this->assertErrorMessage($error_user_notice);
+
+ // Set error reporting to not collect notices.
+ variable_set('error_level', ERROR_REPORTING_DISPLAY_SOME);
+ $this->drupalGet('error-test/generate-warnings');
+ $this->assertResponse(200, t('Received expected HTTP status code.'));
+ $this->assertNoErrorMessage($error_notice);
+ $this->assertErrorMessage($error_warning);
+ $this->assertErrorMessage($error_user_notice);
+
+ // Set error reporting to not show any errors.
+ variable_set('error_level', ERROR_REPORTING_HIDE);
+ $this->drupalGet('error-test/generate-warnings');
+ $this->assertResponse(200, t('Received expected HTTP status code.'));
+ $this->assertNoErrorMessage($error_notice);
+ $this->assertNoErrorMessage($error_warning);
+ $this->assertNoErrorMessage($error_user_notice);
+ }
+
+ /**
+ * Test the exception handler.
+ */
+ function testExceptionHandler() {
+ $error_exception = array(
+ '%type' => 'Exception',
+ '!message' => 'Drupal is awesome',
+ '%function' => 'error_test_trigger_exception()',
+ '%line' => 57,
+ '%file' => drupal_realpath('core/modules/simpletest/tests/error_test.module'),
+ );
+ $error_pdo_exception = array(
+ '%type' => 'PDOException',
+ '!message' => 'SELECT * FROM bananas_are_awesome',
+ '%function' => 'error_test_trigger_pdo_exception()',
+ '%line' => 65,
+ '%file' => drupal_realpath('core/modules/simpletest/tests/error_test.module'),
+ );
+
+ $this->drupalGet('error-test/trigger-exception');
+ $this->assertTrue(strpos($this->drupalGetHeader(':status'), '500 Service unavailable (with message)'), t('Received expected HTTP status line.'));
+ $this->assertErrorMessage($error_exception);
+
+ $this->drupalGet('error-test/trigger-pdo-exception');
+ $this->assertTrue(strpos($this->drupalGetHeader(':status'), '500 Service unavailable (with message)'), t('Received expected HTTP status line.'));
+ // We cannot use assertErrorMessage() since the extact error reported
+ // varies from database to database. Check that the SQL string is displayed.
+ $this->assertText($error_pdo_exception['%type'], t('Found %type in error page.', $error_pdo_exception));
+ $this->assertText($error_pdo_exception['!message'], t('Found !message in error page.', $error_pdo_exception));
+ $error_details = t('in %function (line ', $error_pdo_exception);
+ $this->assertRaw($error_details, t("Found '!message' in error page.", array('!message' => $error_details)));
+ }
+
+ /**
+ * Helper function: assert that the error message is found.
+ */
+ function assertErrorMessage(array $error) {
+ $message = t('%type: !message in %function (line ', $error);
+ $this->assertRaw($message, t('Found error message: !message.', array('!message' => $message)));
+ }
+
+ /**
+ * Helper function: assert that the error message is not found.
+ */
+ function assertNoErrorMessage(array $error) {
+ $message = t('%type: !message in %function (line ', $error);
+ $this->assertNoRaw($message, t('Did not find error message: !message.', array('!message' => $message)));
+ }
+}
+
diff --git a/core/modules/simpletest/tests/error_test.info b/core/modules/simpletest/tests/error_test.info
new file mode 100644
index 000000000000..d5db3ee392fd
--- /dev/null
+++ b/core/modules/simpletest/tests/error_test.info
@@ -0,0 +1,6 @@
+name = "Error test"
+description = "Support module for error and exception testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/error_test.module b/core/modules/simpletest/tests/error_test.module
new file mode 100644
index 000000000000..d062cb067c47
--- /dev/null
+++ b/core/modules/simpletest/tests/error_test.module
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function error_test_menu() {
+ $items['error-test/generate-warnings'] = array(
+ 'title' => 'Generate warnings',
+ 'page callback' => 'error_test_generate_warnings',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['error-test/generate-warnings-with-report'] = array(
+ 'title' => 'Generate warnings with Simpletest reporting',
+ 'page callback' => 'error_test_generate_warnings',
+ 'page arguments' => array(TRUE),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['error-test/trigger-exception'] = array(
+ 'title' => 'Trigger an exception',
+ 'page callback' => 'error_test_trigger_exception',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['error-test/trigger-pdo-exception'] = array(
+ 'title' => 'Trigger a PDO exception',
+ 'page callback' => 'error_test_trigger_pdo_exception',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Menu callback; generate warnings to test the error handler.
+ */
+function error_test_generate_warnings($collect_errors = FALSE) {
+ // Tell Drupal error reporter to send errors to Simpletest or not.
+ define('SIMPLETEST_COLLECT_ERRORS', $collect_errors);
+ // This will generate a notice.
+ $monkey_love = $bananas;
+ // This will generate a warning.
+ $awesomely_big = 1/0;
+ // This will generate a user error.
+ trigger_error("Drupal is awesome", E_USER_WARNING);
+ return "";
+}
+
+/**
+ * Menu callback; trigger an exception to test the exception handler.
+ */
+function error_test_trigger_exception() {
+ define('SIMPLETEST_COLLECT_ERRORS', FALSE);
+ throw new Exception("Drupal is awesome");
+}
+
+/**
+ * Menu callback; trigger an exception to test the exception handler.
+ */
+function error_test_trigger_pdo_exception() {
+ define('SIMPLETEST_COLLECT_ERRORS', FALSE);
+ db_query('SELECT * FROM bananas_are_awesome');
+}
diff --git a/core/modules/simpletest/tests/file.test b/core/modules/simpletest/tests/file.test
new file mode 100644
index 000000000000..7496902faa88
--- /dev/null
+++ b/core/modules/simpletest/tests/file.test
@@ -0,0 +1,2748 @@
+<?php
+
+/**
+ * @file
+ * This provides SimpleTests for the core file handling functionality.
+ * These include FileValidateTest and FileSaveTest.
+ */
+
+/**
+ * Helper validator that returns the $errors parameter.
+ */
+function file_test_validator($file, $errors) {
+ return $errors;
+}
+
+/**
+ * Helper function for testing file_scan_directory().
+ *
+ * Each time the function is called the file is stored in a static variable.
+ * When the function is called with no $filepath parameter, the results are
+ * returned.
+ *
+ * @param $filepath
+ * File path
+ * @return
+ * If $filepath is NULL, an array of all previous $filepath parameters
+ */
+function file_test_file_scan_callback($filepath = NULL) {
+ $files = &drupal_static(__FUNCTION__, array());
+ if (isset($filepath)) {
+ $files[] = $filepath;
+ }
+ else {
+ return $files;
+ }
+}
+
+/**
+ * Reset static variables used by file_test_file_scan_callback().
+ */
+function file_test_file_scan_callback_reset() {
+ drupal_static_reset('file_test_file_scan_callback');
+}
+
+/**
+ * Base class for file tests that adds some additional file specific
+ * assertions and helper functions.
+ */
+class FileTestCase extends DrupalWebTestCase {
+ /**
+ * Check that two files have the same values for all fields other than the
+ * timestamp.
+ *
+ * @param $before
+ * File object to compare.
+ * @param $after
+ * File object to compare.
+ */
+ function assertFileUnchanged($before, $after) {
+ $this->assertEqual($before->fid, $after->fid, t('File id is the same: %file1 == %file2.', array('%file1' => $before->fid, '%file2' => $after->fid)), 'File unchanged');
+ $this->assertEqual($before->uid, $after->uid, t('File owner is the same: %file1 == %file2.', array('%file1' => $before->uid, '%file2' => $after->uid)), 'File unchanged');
+ $this->assertEqual($before->filename, $after->filename, t('File name is the same: %file1 == %file2.', array('%file1' => $before->filename, '%file2' => $after->filename)), 'File unchanged');
+ $this->assertEqual($before->uri, $after->uri, t('File path is the same: %file1 == %file2.', array('%file1' => $before->uri, '%file2' => $after->uri)), 'File unchanged');
+ $this->assertEqual($before->filemime, $after->filemime, t('File MIME type is the same: %file1 == %file2.', array('%file1' => $before->filemime, '%file2' => $after->filemime)), 'File unchanged');
+ $this->assertEqual($before->filesize, $after->filesize, t('File size is the same: %file1 == %file2.', array('%file1' => $before->filesize, '%file2' => $after->filesize)), 'File unchanged');
+ $this->assertEqual($before->status, $after->status, t('File status is the same: %file1 == %file2.', array('%file1' => $before->status, '%file2' => $after->status)), 'File unchanged');
+ }
+
+ /**
+ * Check that two files are not the same by comparing the fid and filepath.
+ *
+ * @param $file1
+ * File object to compare.
+ * @param $file2
+ * File object to compare.
+ */
+ function assertDifferentFile($file1, $file2) {
+ $this->assertNotEqual($file1->fid, $file2->fid, t('Files have different ids: %file1 != %file2.', array('%file1' => $file1->fid, '%file2' => $file2->fid)), 'Different file');
+ $this->assertNotEqual($file1->uri, $file2->uri, t('Files have different paths: %file1 != %file2.', array('%file1' => $file1->uri, '%file2' => $file2->uri)), 'Different file');
+ }
+
+ /**
+ * Check that two files are the same by comparing the fid and filepath.
+ *
+ * @param $file1
+ * File object to compare.
+ * @param $file2
+ * File object to compare.
+ */
+ function assertSameFile($file1, $file2) {
+ $this->assertEqual($file1->fid, $file2->fid, t('Files have the same ids: %file1 == %file2.', array('%file1' => $file1->fid, '%file2-fid' => $file2->fid)), 'Same file');
+ $this->assertEqual($file1->uri, $file2->uri, t('Files have the same path: %file1 == %file2.', array('%file1' => $file1->uri, '%file2' => $file2->uri)), 'Same file');
+ }
+
+ /**
+ * Helper function to test the permissions of a file.
+ *
+ * @param $filepath
+ * String file path.
+ * @param $expected_mode
+ * Octal integer like 0664 or 0777.
+ * @param $message
+ * Optional message.
+ */
+ function assertFilePermissions($filepath, $expected_mode, $message = NULL) {
+ // Clear out PHP's file stat cache to be sure we see the current value.
+ clearstatcache();
+
+ // Mask out all but the last three octets.
+ $actual_mode = fileperms($filepath) & 0777;
+
+ // PHP on Windows has limited support for file permissions. Usually each of
+ // "user", "group" and "other" use one octal digit (3 bits) to represent the
+ // read/write/execute bits. On Windows, chmod() ignores the "group" and
+ // "other" bits, and fileperms() returns the "user" bits in all three
+ // positions. $expected_mode is updated to reflect this.
+ if (substr(PHP_OS, 0, 3) == 'WIN') {
+ // Reset the "group" and "other" bits.
+ $expected_mode = $expected_mode & 0700;
+ // Shift the "user" bits to the "group" and "other" positions also.
+ $expected_mode = $expected_mode | $expected_mode >> 3 | $expected_mode >> 6;
+ }
+
+ if (!isset($message)) {
+ $message = t('Expected file permission to be %expected, actually were %actual.', array('%actual' => decoct($actual_mode), '%expected' => decoct($expected_mode)));
+ }
+ $this->assertEqual($actual_mode, $expected_mode, $message);
+ }
+
+ /**
+ * Helper function to test the permissions of a directory.
+ *
+ * @param $directory
+ * String directory path.
+ * @param $expected_mode
+ * Octal integer like 0664 or 0777.
+ * @param $message
+ * Optional message.
+ */
+ function assertDirectoryPermissions($directory, $expected_mode, $message = NULL) {
+ // Clear out PHP's file stat cache to be sure we see the current value.
+ clearstatcache();
+
+ // Mask out all but the last three octets.
+ $actual_mode = fileperms($directory) & 0777;
+
+ // PHP on Windows has limited support for file permissions. Usually each of
+ // "user", "group" and "other" use one octal digit (3 bits) to represent the
+ // read/write/execute bits. On Windows, chmod() ignores the "group" and
+ // "other" bits, and fileperms() returns the "user" bits in all three
+ // positions. $expected_mode is updated to reflect this.
+ if (substr(PHP_OS, 0, 3) == 'WIN') {
+ // Reset the "group" and "other" bits.
+ $expected_mode = $expected_mode & 0700;
+ // Shift the "user" bits to the "group" and "other" positions also.
+ $expected_mode = $expected_mode | $expected_mode >> 3 | $expected_mode >> 6;
+ }
+
+ if (!isset($message)) {
+ $message = t('Expected directory permission to be %expected, actually were %actual.', array('%actual' => decoct($actual_mode), '%expected' => decoct($expected_mode)));
+ }
+ $this->assertEqual($actual_mode, $expected_mode, $message);
+ }
+
+ /**
+ * Create a directory and assert it exists.
+ *
+ * @param $path
+ * Optional string with a directory path. If none is provided, a random
+ * name in the site's files directory will be used.
+ * @return
+ * The path to the directory.
+ */
+ function createDirectory($path = NULL) {
+ // A directory to operate on.
+ if (!isset($path)) {
+ $path = file_default_scheme() . '://' . $this->randomName();
+ }
+ $this->assertTrue(drupal_mkdir($path) && is_dir($path), t('Directory was created successfully.'));
+ return $path;
+ }
+
+ /**
+ * Create a file and save it to the files table and assert that it occurs
+ * correctly.
+ *
+ * @param $filepath
+ * Optional string specifying the file path. If none is provided then a
+ * randomly named file will be created in the site's files directory.
+ * @param $contents
+ * Optional contents to save into the file. If a NULL value is provided an
+ * arbitrary string will be used.
+ * @param $scheme
+ * Optional string indicating the stream scheme to use. Drupal core includes
+ * public, private, and temporary. The public wrapper is the default.
+ * @return
+ * File object.
+ */
+ function createFile($filepath = NULL, $contents = NULL, $scheme = NULL) {
+ if (!isset($filepath)) {
+ $filepath = $this->randomName();
+ }
+ if (!isset($scheme)) {
+ $scheme = file_default_scheme();
+ }
+ $filepath = $scheme . '://' . $filepath;
+
+ if (!isset($contents)) {
+ $contents = "file_put_contents() doesn't seem to appreciate empty strings so let's put in some data.";
+ }
+
+ file_put_contents($filepath, $contents);
+ $this->assertTrue(is_file($filepath), t('The test file exists on the disk.'), 'Create test file');
+
+ $file = new stdClass();
+ $file->uri = $filepath;
+ $file->filename = basename($file->uri);
+ $file->filemime = 'text/plain';
+ $file->uid = 1;
+ $file->timestamp = REQUEST_TIME;
+ $file->filesize = filesize($file->uri);
+ $file->status = 0;
+ // Write the record directly rather than calling file_save() so we don't
+ // invoke the hooks.
+ $this->assertNotIdentical(drupal_write_record('file_managed', $file), FALSE, t('The file was added to the database.'), 'Create test file');
+
+ return $file;
+ }
+}
+
+/**
+ * Base class for file tests that use the file_test module to test uploads and
+ * hooks.
+ */
+class FileHookTestCase extends FileTestCase {
+ function setUp() {
+ // Install file_test module
+ parent::setUp('file_test');
+ // Clear out any hook calls.
+ file_test_reset();
+ }
+
+ /**
+ * Assert that all of the specified hook_file_* hooks were called once, other
+ * values result in failure.
+ *
+ * @param $expected
+ * Array with string containing with the hook name, e.g. 'load', 'save',
+ * 'insert', etc.
+ */
+ function assertFileHooksCalled($expected) {
+ // Determine which hooks were called.
+ $actual = array_keys(array_filter(file_test_get_all_calls()));
+
+ // Determine if there were any expected that were not called.
+ $uncalled = array_diff($expected, $actual);
+ if (count($uncalled)) {
+ $this->assertTrue(FALSE, t('Expected hooks %expected to be called but %uncalled was not called.', array('%expected' => implode(', ', $expected), '%uncalled' => implode(', ', $uncalled))));
+ }
+ else {
+ $this->assertTrue(TRUE, t('All the expected hooks were called: %expected', array('%expected' => empty($expected) ? t('(none)') : implode(', ', $expected))));
+ }
+
+ // Determine if there were any unexpected calls.
+ $unexpected = array_diff($actual, $expected);
+ if (count($unexpected)) {
+ $this->assertTrue(FALSE, t('Unexpected hooks were called: %unexpected.', array('%unexpected' => empty($unexpected) ? t('(none)') : implode(', ', $unexpected))));
+ }
+ else {
+ $this->assertTrue(TRUE, t('No unexpected hooks were called.'));
+ }
+ }
+
+ /**
+ * Assert that a hook_file_* hook was called a certain number of times.
+ *
+ * @param $hook
+ * String with the hook name, e.g. 'load', 'save', 'insert', etc.
+ * @param $expected_count
+ * Optional integer count.
+ * @param $message
+ * Optional translated string message.
+ */
+ function assertFileHookCalled($hook, $expected_count = 1, $message = NULL) {
+ $actual_count = count(file_test_get_calls($hook));
+
+ if (!isset($message)) {
+ if ($actual_count == $expected_count) {
+ $message = t('hook_file_@name was called correctly.', array('@name' => $hook));
+ }
+ elseif ($expected_count == 0) {
+ $message = format_plural($actual_count, 'hook_file_@name was not expected to be called but was actually called once.', 'hook_file_@name was not expected to be called but was actually called @count times.', array('@name' => $hook, '@count' => $actual_count));
+ }
+ else {
+ $message = t('hook_file_@name was expected to be called %expected times but was called %actual times.', array('@name' => $hook, '%expected' => $expected_count, '%actual' => $actual_count));
+ }
+ }
+ $this->assertEqual($actual_count, $expected_count, $message);
+ }
+}
+
+
+/**
+ * This will run tests against the file_space_used() function.
+ */
+class FileSpaceUsedTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File space used tests',
+ 'description' => 'Tests the file_space_used() function.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create records for a couple of users with different sizes.
+ $file = array('uid' => 2, 'uri' => 'public://example1.txt', 'filesize' => 50, 'status' => FILE_STATUS_PERMANENT);
+ drupal_write_record('file_managed', $file);
+ $file = array('uid' => 2, 'uri' => 'public://example2.txt', 'filesize' => 20, 'status' => FILE_STATUS_PERMANENT);
+ drupal_write_record('file_managed', $file);
+ $file = array('uid' => 3, 'uri' => 'public://example3.txt', 'filesize' => 100, 'status' => FILE_STATUS_PERMANENT);
+ drupal_write_record('file_managed', $file);
+ $file = array('uid' => 3, 'uri' => 'public://example4.txt', 'filesize' => 200, 'status' => FILE_STATUS_PERMANENT);
+ drupal_write_record('file_managed', $file);
+
+ // Now create some non-permanent files.
+ $file = array('uid' => 2, 'uri' => 'public://example5.txt', 'filesize' => 1, 'status' => 0);
+ drupal_write_record('file_managed', $file);
+ $file = array('uid' => 3, 'uri' => 'public://example6.txt', 'filesize' => 3, 'status' => 0);
+ drupal_write_record('file_managed', $file);
+ }
+
+ /**
+ * Test different users with the default status.
+ */
+ function testFileSpaceUsed() {
+ // Test different users with default status.
+ $this->assertEqual(file_space_used(2), 70);
+ $this->assertEqual(file_space_used(3), 300);
+ $this->assertEqual(file_space_used(), 370);
+
+ // Test the status fields
+ $this->assertEqual(file_space_used(NULL, 0), 4);
+ $this->assertEqual(file_space_used(NULL, FILE_STATUS_PERMANENT), 370);
+
+ // Test both the user and status.
+ $this->assertEqual(file_space_used(1, 0), 0);
+ $this->assertEqual(file_space_used(1, FILE_STATUS_PERMANENT), 0);
+ $this->assertEqual(file_space_used(2, 0), 1);
+ $this->assertEqual(file_space_used(2, FILE_STATUS_PERMANENT), 70);
+ $this->assertEqual(file_space_used(3, 0), 3);
+ $this->assertEqual(file_space_used(3, FILE_STATUS_PERMANENT), 300);
+ }
+}
+
+/**
+ * This will run tests against the file validation functions (file_validate_*).
+ */
+class FileValidatorTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File validator tests',
+ 'description' => 'Tests the functions used to validate uploaded files.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->image = new stdClass();
+ $this->image->uri = 'core/misc/druplicon.png';
+ $this->image->filename = basename($this->image->uri);
+
+ $this->non_image = new stdClass();
+ $this->non_image->uri = 'core/misc/jquery.js';
+ $this->non_image->filename = basename($this->non_image->uri);
+ }
+
+ /**
+ * Test the file_validate_extensions() function.
+ */
+ function testFileValidateExtensions() {
+ $file = new stdClass();
+ $file->filename = 'asdf.txt';
+ $errors = file_validate_extensions($file, 'asdf txt pork');
+ $this->assertEqual(count($errors), 0, t('Valid extension accepted.'), 'File');
+
+ $file->filename = 'asdf.txt';
+ $errors = file_validate_extensions($file, 'exe png');
+ $this->assertEqual(count($errors), 1, t('Invalid extension blocked.'), 'File');
+ }
+
+ /**
+ * This ensures a specific file is actually an image.
+ */
+ function testFileValidateIsImage() {
+ $this->assertTrue(file_exists($this->image->uri), t('The image being tested exists.'), 'File');
+ $errors = file_validate_is_image($this->image);
+ $this->assertEqual(count($errors), 0, t('No error reported for our image file.'), 'File');
+
+ $this->assertTrue(file_exists($this->non_image->uri), t('The non-image being tested exists.'), 'File');
+ $errors = file_validate_is_image($this->non_image);
+ $this->assertEqual(count($errors), 1, t('An error reported for our non-image file.'), 'File');
+ }
+
+ /**
+ * This ensures the resolution of a specific file is within bounds.
+ * The image will be resized if it's too large.
+ */
+ function testFileValidateImageResolution() {
+ // Non-images.
+ $errors = file_validate_image_resolution($this->non_image);
+ $this->assertEqual(count($errors), 0, t("Shouldn't get any errors for a non-image file."), 'File');
+ $errors = file_validate_image_resolution($this->non_image, '50x50', '100x100');
+ $this->assertEqual(count($errors), 0, t("Don't check the resolution on non files."), 'File');
+
+ // Minimum size.
+ $errors = file_validate_image_resolution($this->image);
+ $this->assertEqual(count($errors), 0, t('No errors for an image when there is no minimum or maximum resolution.'), 'File');
+ $errors = file_validate_image_resolution($this->image, 0, '200x1');
+ $this->assertEqual(count($errors), 1, t("Got an error for an image that wasn't wide enough."), 'File');
+ $errors = file_validate_image_resolution($this->image, 0, '1x200');
+ $this->assertEqual(count($errors), 1, t("Got an error for an image that wasn't tall enough."), 'File');
+ $errors = file_validate_image_resolution($this->image, 0, '200x200');
+ $this->assertEqual(count($errors), 1, t('Small images report an error.'), 'File');
+
+ // Maximum size.
+ if (image_get_toolkit()) {
+ // Copy the image so that the original doesn't get resized.
+ copy('core/misc/druplicon.png', 'temporary://druplicon.png');
+ $this->image->uri = 'temporary://druplicon.png';
+
+ $errors = file_validate_image_resolution($this->image, '10x5');
+ $this->assertEqual(count($errors), 0, t('No errors should be reported when an oversized image can be scaled down.'), 'File');
+
+ $info = image_get_info($this->image->uri);
+ $this->assertTrue($info['width'] <= 10, t('Image scaled to correct width.'), 'File');
+ $this->assertTrue($info['height'] <= 5, t('Image scaled to correct height.'), 'File');
+
+ drupal_unlink('temporary://druplicon.png');
+ }
+ else {
+ // TODO: should check that the error is returned if no toolkit is available.
+ $errors = file_validate_image_resolution($this->image, '5x10');
+ $this->assertEqual(count($errors), 1, t("Oversize images that can't be scaled get an error."), 'File');
+ }
+ }
+
+ /**
+ * This will ensure the filename length is valid.
+ */
+ function testFileValidateNameLength() {
+ // Create a new file object.
+ $file = new stdClass();
+
+ // Add a filename with an allowed length and test it.
+ $file->filename = str_repeat('x', 240);
+ $this->assertEqual(strlen($file->filename), 240);
+ $errors = file_validate_name_length($file);
+ $this->assertEqual(count($errors), 0, t('No errors reported for 240 length filename.'), 'File');
+
+ // Add a filename with a length too long and test it.
+ $file->filename = str_repeat('x', 241);
+ $errors = file_validate_name_length($file);
+ $this->assertEqual(count($errors), 1, t('An error reported for 241 length filename.'), 'File');
+
+ // Add a filename with an empty string and test it.
+ $file->filename = '';
+ $errors = file_validate_name_length($file);
+ $this->assertEqual(count($errors), 1, t('An error reported for 0 length filename.'), 'File');
+ }
+
+
+ /**
+ * Test file_validate_size().
+ */
+ function testFileValidateSize() {
+ global $user;
+ $original_user = $user;
+ drupal_save_session(FALSE);
+
+ // Run these test as uid = 1.
+ $user = user_load(1);
+
+ $file = new stdClass();
+ $file->filesize = 999999;
+ $errors = file_validate_size($file, 1, 1);
+ $this->assertEqual(count($errors), 0, t('No size limits enforced on uid=1.'), 'File');
+
+ // Run these tests as a regular user.
+ $user = $this->drupalCreateUser();
+
+ // Create a file with a size of 1000 bytes, and quotas of only 1 byte.
+ $file = new stdClass();
+ $file->filesize = 1000;
+ $errors = file_validate_size($file, 0, 0);
+ $this->assertEqual(count($errors), 0, t('No limits means no errors.'), 'File');
+ $errors = file_validate_size($file, 1, 0);
+ $this->assertEqual(count($errors), 1, t('Error for the file being over the limit.'), 'File');
+ $errors = file_validate_size($file, 0, 1);
+ $this->assertEqual(count($errors), 1, t('Error for the user being over their limit.'), 'File');
+ $errors = file_validate_size($file, 1, 1);
+ $this->assertEqual(count($errors), 2, t('Errors for both the file and their limit.'), 'File');
+
+ $user = $original_user;
+ drupal_save_session(TRUE);
+ }
+}
+
+
+
+/**
+ * Tests the file_unmanaged_save_data() function.
+ */
+class FileUnmanagedSaveDataTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unmanaged file save data',
+ 'description' => 'Tests the unmanaged file save data function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test the file_unmanaged_save_data() function.
+ */
+ function testFileSaveData() {
+ $contents = $this->randomName(8);
+
+ // No filename.
+ $filepath = file_unmanaged_save_data($contents);
+ $this->assertTrue($filepath, t('Unnamed file saved correctly.'));
+ $this->assertEqual(file_uri_scheme($filepath), file_default_scheme(), t("File was placed in Drupal's files directory."));
+ $this->assertEqual($contents, file_get_contents($filepath), t('Contents of the file are correct.'));
+
+ // Provide a filename.
+ $filepath = file_unmanaged_save_data($contents, 'public://asdf.txt', FILE_EXISTS_REPLACE);
+ $this->assertTrue($filepath, t('Unnamed file saved correctly.'));
+ $this->assertEqual('asdf.txt', basename($filepath), t('File was named correctly.'));
+ $this->assertEqual($contents, file_get_contents($filepath), t('Contents of the file are correct.'));
+ $this->assertFilePermissions($filepath, variable_get('file_chmod_file', 0664));
+ }
+}
+
+/**
+ * Tests the file_unmanaged_save_data() function on remote filesystems.
+ */
+class RemoteFileUnmanagedSaveDataTest extends FileUnmanagedSaveDataTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Test the file_save_upload() function.
+ */
+class FileSaveUploadTest extends FileHookTestCase {
+ /**
+ * An image file path for uploading.
+ */
+ protected $image;
+
+ /**
+ * A PHP file path for upload security testing.
+ */
+ protected $phpfile;
+
+ /**
+ * The largest file id when the test starts.
+ */
+ protected $maxFidBefore;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'File uploading',
+ 'description' => 'Tests the file uploading functions.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $account = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($account);
+
+ $image_files = $this->drupalGetTestFiles('image');
+ $this->image = current($image_files);
+
+ list(, $this->image_extension) = explode('.', $this->image->filename);
+ $this->assertTrue(is_file($this->image->uri), t("The image file we're going to upload exists."));
+
+ $this->phpfile = current($this->drupalGetTestFiles('php'));
+ $this->assertTrue(is_file($this->phpfile->uri), t("The PHP file we're going to upload exists."));
+
+ $this->maxFidBefore = db_query('SELECT MAX(fid) AS fid FROM {file_managed}')->fetchField();
+
+ // Upload with replace to guarantee there's something there.
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called then clean out the hook
+ // counters.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+ file_test_reset();
+ }
+
+ /**
+ * Test the file_save_upload() function.
+ */
+ function testNormal() {
+ $max_fid_after = db_query('SELECT MAX(fid) AS fid FROM {file_managed}')->fetchField();
+ $this->assertTrue($max_fid_after > $this->maxFidBefore, t('A new file was created.'));
+ $file1 = file_load($max_fid_after);
+ $this->assertTrue($file1, t('Loaded the file.'));
+ // MIME type of the uploaded image may be either image/jpeg or image/png.
+ $this->assertEqual(substr($file1->filemime, 0, 5), 'image', 'A MIME type was set.');
+
+ // Reset the hook counters to get rid of the 'load' we just called.
+ file_test_reset();
+
+ // Upload a second file.
+ $max_fid_before = db_query('SELECT MAX(fid) AS fid FROM {file_managed}')->fetchField();
+ $image2 = current($this->drupalGetTestFiles('image'));
+ $edit = array('files[file_test_upload]' => drupal_realpath($image2->uri));
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('You WIN!'));
+ $max_fid_after = db_query('SELECT MAX(fid) AS fid FROM {file_managed}')->fetchField();
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+
+ $file2 = file_load($max_fid_after);
+ $this->assertTrue($file2);
+ // MIME type of the uploaded image may be either image/jpeg or image/png.
+ $this->assertEqual(substr($file2->filemime, 0, 5), 'image', 'A MIME type was set.');
+
+ // Load both files using file_load_multiple().
+ $files = file_load_multiple(array($file1->fid, $file2->fid));
+ $this->assertTrue(isset($files[$file1->fid]), t('File was loaded successfully'));
+ $this->assertTrue(isset($files[$file2->fid]), t('File was loaded successfully'));
+
+ // Upload a third file to a subdirectory.
+ $image3 = current($this->drupalGetTestFiles('image'));
+ $image3_realpath = drupal_realpath($image3->uri);
+ $dir = $this->randomName();
+ $edit = array(
+ 'files[file_test_upload]' => $image3_realpath,
+ 'file_subdir' => $dir,
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('You WIN!'));
+ $this->assertTrue(is_file('temporary://' . $dir . '/' . trim(basename($image3_realpath))));
+
+ // Check that file_load_multiple() with no arguments returns FALSE.
+ $this->assertFalse(file_load_multiple(), t('No files were loaded.'));
+ }
+
+ /**
+ * Test extension handling.
+ */
+ function testHandleExtension() {
+ // The file being tested is a .gif which is in the default safe list
+ // of extensions to allow when the extension validator isn't used. This is
+ // implicitly tested at the testNormal() test. Here we tell
+ // file_save_upload() to only allow ".foo".
+ $extensions = 'foo';
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ 'extensions' => $extensions,
+ );
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $message = t('Only files with the following extensions are allowed:') . ' <em class="placeholder">' . $extensions . '</em>';
+ $this->assertRaw($message, t('Can\'t upload a disallowed extension'));
+ $this->assertRaw(t('Epic upload FAIL!'), t('Found the failure message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate'));
+
+ // Reset the hook counters.
+ file_test_reset();
+
+ $extensions = 'foo ' . $this->image_extension;
+ // Now tell file_save_upload() to allow the extension of our test image.
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ 'extensions' => $extensions,
+ );
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertNoRaw(t('Only files with the following extensions are allowed:'), t('Can upload an allowed extension.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'load', 'update'));
+
+ // Reset the hook counters.
+ file_test_reset();
+
+ // Now tell file_save_upload() to allow any extension.
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ 'allow_all_extensions' => TRUE,
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertNoRaw(t('Only files with the following extensions are allowed:'), t('Can upload any extension.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'load', 'update'));
+ }
+
+ /**
+ * Test dangerous file handling.
+ */
+ function testHandleDangerousFile() {
+ // Allow the .php extension and make sure it gets renamed to .txt for
+ // safety. Also check to make sure its MIME type was changed.
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->phpfile->uri),
+ 'is_image_file' => FALSE,
+ 'extensions' => 'php',
+ );
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $message = t('For security reasons, your upload has been renamed to') . ' <em class="placeholder">' . $this->phpfile->filename . '.txt' . '</em>';
+ $this->assertRaw($message, t('Dangerous file was renamed.'));
+ $this->assertRaw(t('File MIME type is text/plain.'), t('Dangerous file\'s MIME type was changed.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+
+ // Ensure dangerous files are not renamed when insecure uploads is TRUE.
+ // Turn on insecure uploads.
+ variable_set('allow_insecure_uploads', 1);
+ // Reset the hook counters.
+ file_test_reset();
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertNoRaw(t('For security reasons, your upload has been renamed'), t('Found no security message.'));
+ $this->assertRaw(t('File name is !filename', array('!filename' => $this->phpfile->filename)), t('Dangerous file was not renamed when insecure uploads is TRUE.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+
+ // Turn off insecure uploads.
+ variable_set('allow_insecure_uploads', 0);
+ }
+
+ /**
+ * Test file munge handling.
+ */
+ function testHandleFileMunge() {
+ // Ensure insecure uploads are disabled for this test.
+ variable_set('allow_insecure_uploads', 0);
+ $this->image = file_move($this->image, $this->image->uri . '.foo.' . $this->image_extension);
+
+ // Reset the hook counters to get rid of the 'move' we just called.
+ file_test_reset();
+
+ $extensions = $this->image_extension;
+ $edit = array(
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ 'extensions' => $extensions,
+ );
+
+ $munged_filename = $this->image->filename;
+ $munged_filename = substr($munged_filename, 0, strrpos($munged_filename, '.'));
+ $munged_filename .= '_.' . $this->image_extension;
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('For security reasons, your upload has been renamed'), t('Found security message.'));
+ $this->assertRaw(t('File name is !filename', array('!filename' => $munged_filename)), t('File was successfully munged.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+
+ // Ensure we don't munge files if we're allowing any extension.
+ // Reset the hook counters.
+ file_test_reset();
+
+ $edit = array(
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri),
+ 'allow_all_extensions' => TRUE,
+ );
+
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertNoRaw(t('For security reasons, your upload has been renamed'), t('Found no security message.'));
+ $this->assertRaw(t('File name is !filename', array('!filename' => $this->image->filename)), t('File was not munged when allowing any extension.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+ }
+
+ /**
+ * Test renaming when uploading over a file that already exists.
+ */
+ function testExistingRename() {
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_RENAME,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri)
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'insert'));
+ }
+
+ /**
+ * Test replacement when uploading over a file that already exists.
+ */
+ function testExistingReplace() {
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_REPLACE,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri)
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('You WIN!'), t('Found the success message.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('validate', 'load', 'update'));
+ }
+
+ /**
+ * Test for failure when uploading over a file that already exists.
+ */
+ function testExistingError() {
+ $edit = array(
+ 'file_test_replace' => FILE_EXISTS_ERROR,
+ 'files[file_test_upload]' => drupal_realpath($this->image->uri)
+ );
+ $this->drupalPost('file-test/upload', $edit, t('Submit'));
+ $this->assertResponse(200, t('Received a 200 response for posted test file.'));
+ $this->assertRaw(t('Epic upload FAIL!'), t('Found the failure message.'));
+
+ // Check that the no hooks were called while failing.
+ $this->assertFileHooksCalled(array());
+ }
+
+ /**
+ * Test for no failures when not uploading a file.
+ */
+ function testNoUpload() {
+ $this->drupalPost('file-test/upload', array(), t('Submit'));
+ $this->assertNoRaw(t('Epic upload FAIL!'), t('Failure message not found.'));
+ }
+}
+
+/**
+ * Test the file_save_upload() function on remote filesystems.
+ */
+class RemoteFileSaveUploadTest extends FileSaveUploadTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Directory related tests.
+ */
+class FileDirectoryTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File paths and directories',
+ 'description' => 'Tests operations dealing with directories.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test directory handling functions.
+ */
+ function testFileCheckDirectoryHandling() {
+ // A directory to operate on.
+ $directory = file_default_scheme() . '://' . $this->randomName() . '/' . $this->randomName();
+ $this->assertFalse(is_dir($directory), t('Directory does not exist prior to testing.'));
+
+ // Non-existent directory.
+ $this->assertFalse(file_prepare_directory($directory, 0), t('Error reported for non-existing directory.'), 'File');
+
+ // Make a directory.
+ $this->assertTrue(file_prepare_directory($directory, FILE_CREATE_DIRECTORY), t('No error reported when creating a new directory.'), 'File');
+
+ // Make sure directory actually exists.
+ $this->assertTrue(is_dir($directory), t('Directory actually exists.'), 'File');
+
+ if (substr(PHP_OS, 0, 3) != 'WIN') {
+ // PHP on Windows doesn't support any kind of useful read-only mode for
+ // directories. When executing a chmod() on a directory, PHP only sets the
+ // read-only flag, which doesn't prevent files to actually be written
+ // in the directory on any recent version of Windows.
+
+ // Make directory read only.
+ @drupal_chmod($directory, 0444);
+ $this->assertFalse(file_prepare_directory($directory, 0), t('Error reported for a non-writeable directory.'), 'File');
+
+ // Test directory permission modification.
+ $this->assertTrue(file_prepare_directory($directory, FILE_MODIFY_PERMISSIONS), t('No error reported when making directory writeable.'), 'File');
+ }
+
+ // Test that the directory has the correct permissions.
+ $this->assertDirectoryPermissions($directory, variable_get('file_chmod_directory', 0775));
+
+ // Remove .htaccess file to then test that it gets re-created.
+ @drupal_unlink(file_default_scheme() . '://.htaccess');
+ $this->assertFalse(is_file(file_default_scheme() . '://.htaccess'), t('Successfully removed the .htaccess file in the files directory.'), 'File');
+ file_ensure_htaccess();
+ $this->assertTrue(is_file(file_default_scheme() . '://.htaccess'), t('Successfully re-created the .htaccess file in the files directory.'), 'File');
+ // Verify contents of .htaccess file.
+ $file = file_get_contents(file_default_scheme() . '://.htaccess');
+ $this->assertEqual($file, "SetHandler Drupal_Security_Do_Not_Remove_See_SA_2006_006\nOptions None\nOptions +FollowSymLinks", t('The .htaccess file contains the proper content.'), 'File');
+ }
+
+ /**
+ * This will take a directory and path, and find a valid filepath that is not
+ * taken by another file.
+ */
+ function testFileCreateNewFilepath() {
+ // First we test against an imaginary file that does not exist in a
+ // directory.
+ $basename = 'xyz.txt';
+ $directory = 'core/misc';
+ $original = $directory . '/' . $basename;
+ $path = file_create_filename($basename, $directory);
+ $this->assertEqual($path, $original, t('New filepath %new equals %original.', array('%new' => $path, '%original' => $original)), 'File');
+
+ // Then we test against a file that already exists within that directory.
+ $basename = 'druplicon.png';
+ $original = $directory . '/' . $basename;
+ $expected = $directory . '/druplicon_0.png';
+ $path = file_create_filename($basename, $directory);
+ $this->assertEqual($path, $expected, t('Creating a new filepath from %original equals %new (expected %expected).', array('%new' => $path, '%original' => $original, '%expected' => $expected)), 'File');
+
+ // @TODO: Finally we copy a file into a directory several times, to ensure a properly iterating filename suffix.
+ }
+
+ /**
+ * This will test the filepath for a destination based on passed flags and
+ * whether or not the file exists.
+ *
+ * If a file exists, file_destination($destination, $replace) will either
+ * return:
+ * - the existing filepath, if $replace is FILE_EXISTS_REPLACE
+ * - a new filepath if FILE_EXISTS_RENAME
+ * - an error (returning FALSE) if FILE_EXISTS_ERROR.
+ * If the file doesn't currently exist, then it will simply return the
+ * filepath.
+ */
+ function testFileDestination() {
+ // First test for non-existent file.
+ $destination = 'core/misc/xyz.txt';
+ $path = file_destination($destination, FILE_EXISTS_REPLACE);
+ $this->assertEqual($path, $destination, t('Non-existing filepath destination is correct with FILE_EXISTS_REPLACE.'), 'File');
+ $path = file_destination($destination, FILE_EXISTS_RENAME);
+ $this->assertEqual($path, $destination, t('Non-existing filepath destination is correct with FILE_EXISTS_RENAME.'), 'File');
+ $path = file_destination($destination, FILE_EXISTS_ERROR);
+ $this->assertEqual($path, $destination, t('Non-existing filepath destination is correct with FILE_EXISTS_ERROR.'), 'File');
+
+ $destination = 'core/misc/druplicon.png';
+ $path = file_destination($destination, FILE_EXISTS_REPLACE);
+ $this->assertEqual($path, $destination, t('Existing filepath destination remains the same with FILE_EXISTS_REPLACE.'), 'File');
+ $path = file_destination($destination, FILE_EXISTS_RENAME);
+ $this->assertNotEqual($path, $destination, t('A new filepath destination is created when filepath destination already exists with FILE_EXISTS_RENAME.'), 'File');
+ $path = file_destination($destination, FILE_EXISTS_ERROR);
+ $this->assertEqual($path, FALSE, t('An error is returned when filepath destination already exists with FILE_EXISTS_ERROR.'), 'File');
+ }
+
+ /**
+ * Ensure that the file_directory_temp() function always returns a value.
+ */
+ function testFileDirectoryTemp() {
+ // Start with an empty variable to ensure we have a clean slate.
+ variable_set('file_temporary_path', '');
+ $tmp_directory = file_directory_temp();
+ $this->assertEqual(empty($tmp_directory), FALSE, t('file_directory_temp() returned a non-empty value.'));
+ $setting = variable_get('file_temporary_path', '');
+ $this->assertEqual($setting, $tmp_directory, t("The 'file_temporary_path' variable has the same value that file_directory_temp() returned."));
+ }
+}
+
+/**
+ * Directory related tests.
+ */
+class RemoteFileDirectoryTest extends FileDirectoryTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Tests the file_scan_directory() function.
+ */
+class FileScanDirectoryTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File scan directory',
+ 'description' => 'Tests the file_scan_directory() function.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->path = drupal_get_path('module', 'simpletest') . '/files';
+ }
+
+ /**
+ * Check the format of the returned values.
+ */
+ function testReturn() {
+ // Grab a listing of all the JavaSscript files and check that they're
+ // passed to the callback.
+ $all_files = file_scan_directory($this->path, '/^javascript-/');
+ ksort($all_files);
+ $this->assertEqual(2, count($all_files), t('Found two, expected javascript files.'));
+
+ // Check the first file.
+ $file = reset($all_files);
+ $this->assertEqual(key($all_files), $file->uri, t('Correct array key was used for the first returned file.'));
+ $this->assertEqual($file->uri, $this->path . '/javascript-1.txt', t('First file name was set correctly.'));
+ $this->assertEqual($file->filename, 'javascript-1.txt', t('First basename was set correctly'));
+ $this->assertEqual($file->name, 'javascript-1', t('First name was set correctly.'));
+
+ // Check the second file.
+ $file = next($all_files);
+ $this->assertEqual(key($all_files), $file->uri, t('Correct array key was used for the second returned file.'));
+ $this->assertEqual($file->uri, $this->path . '/javascript-2.script', t('Second file name was set correctly.'));
+ $this->assertEqual($file->filename, 'javascript-2.script', t('Second basename was set correctly'));
+ $this->assertEqual($file->name, 'javascript-2', t('Second name was set correctly.'));
+ }
+
+ /**
+ * Check that the callback function is called correctly.
+ */
+ function testOptionCallback() {
+ // When nothing is matched nothing should be passed to the callback.
+ $all_files = file_scan_directory($this->path, '/^NONEXISTINGFILENAME/', array('callback' => 'file_test_file_scan_callback'));
+ $this->assertEqual(0, count($all_files), t('No files were found.'));
+ $results = file_test_file_scan_callback();
+ file_test_file_scan_callback_reset();
+ $this->assertEqual(0, count($results), t('No files were passed to the callback.'));
+
+ // Grab a listing of all the JavaSscript files and check that they're
+ // passed to the callback.
+ $all_files = file_scan_directory($this->path, '/^javascript-/', array('callback' => 'file_test_file_scan_callback'));
+ $this->assertEqual(2, count($all_files), t('Found two, expected javascript files.'));
+ $results = file_test_file_scan_callback();
+ file_test_file_scan_callback_reset();
+ $this->assertEqual(2, count($results), t('Files were passed to the callback.'));
+ }
+
+ /**
+ * Check that the no-mask parameter is honored.
+ */
+ function testOptionNoMask() {
+ // Grab a listing of all the JavaSscript files.
+ $all_files = file_scan_directory($this->path, '/^javascript-/');
+ $this->assertEqual(2, count($all_files), t('Found two, expected javascript files.'));
+
+ // Now use the nomast parameter to filter out the .script file.
+ $filtered_files = file_scan_directory($this->path, '/^javascript-/', array('nomask' => '/.script$/'));
+ $this->assertEqual(1, count($filtered_files), t('Filtered correctly.'));
+ }
+
+ /**
+ * Check that key parameter sets the return value's key.
+ */
+ function testOptionKey() {
+ // "filename", for the path starting with $dir.
+ $expected = array($this->path . '/javascript-1.txt', $this->path . '/javascript-2.script');
+ $actual = array_keys(file_scan_directory($this->path, '/^javascript-/', array('key' => 'filepath')));
+ sort($actual);
+ $this->assertEqual($expected, $actual, t('Returned the correct values for the filename key.'));
+
+ // "basename", for the basename of the file.
+ $expected = array('javascript-1.txt', 'javascript-2.script');
+ $actual = array_keys(file_scan_directory($this->path, '/^javascript-/', array('key' => 'filename')));
+ sort($actual);
+ $this->assertEqual($expected, $actual, t('Returned the correct values for the basename key.'));
+
+ // "name" for the name of the file without an extension.
+ $expected = array('javascript-1', 'javascript-2');
+ $actual = array_keys(file_scan_directory($this->path, '/^javascript-/', array('key' => 'name')));
+ sort($actual);
+ $this->assertEqual($expected, $actual, t('Returned the correct values for the name key.'));
+
+ // Invalid option that should default back to "filename".
+ $expected = array($this->path . '/javascript-1.txt', $this->path . '/javascript-2.script');
+ $actual = array_keys(file_scan_directory($this->path, '/^javascript-/', array('key' => 'INVALID')));
+ sort($actual);
+ $this->assertEqual($expected, $actual, t('An invalid key defaulted back to the default.'));
+ }
+
+ /**
+ * Check that the recurse option decends into subdirectories.
+ */
+ function testOptionRecurse() {
+ $files = file_scan_directory(drupal_get_path('module', 'simpletest'), '/^javascript-/', array('recurse' => FALSE));
+ $this->assertTrue(empty($files), t("Without recursion couldn't find javascript files."));
+
+ $files = file_scan_directory(drupal_get_path('module', 'simpletest'), '/^javascript-/', array('recurse' => TRUE));
+ $this->assertEqual(2, count($files), t('With recursion we found the expected javascript files.'));
+ }
+
+
+ /**
+ * Check that the min_depth options lets us ignore files in the starting
+ * directory.
+ */
+ function testOptionMinDepth() {
+ $files = file_scan_directory($this->path, '/^javascript-/', array('min_depth' => 0));
+ $this->assertEqual(2, count($files), t('No minimum-depth gets files in current directory.'));
+
+ $files = file_scan_directory($this->path, '/^javascript-/', array('min_depth' => 1));
+ $this->assertTrue(empty($files), t("Minimum-depth of 1 successfully excludes files from current directory."));
+ }
+}
+
+/**
+ * Tests the file_scan_directory() function on remote filesystems.
+ */
+class RemoteFileScanDirectoryTest extends FileScanDirectoryTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Deletion related tests.
+ */
+class FileUnmanagedDeleteTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unmanaged file delete',
+ 'description' => 'Tests the unmanaged file delete function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Delete a normal file.
+ */
+ function testNormal() {
+ // Create a file for testing
+ $file = $this->createFile();
+
+ // Delete a regular file
+ $this->assertTrue(file_unmanaged_delete($file->uri), t('Deleted worked.'));
+ $this->assertFalse(file_exists($file->uri), t('Test file has actually been deleted.'));
+ }
+
+ /**
+ * Try deleting a missing file.
+ */
+ function testMissing() {
+ // Try to delete a non-existing file
+ $this->assertTrue(file_unmanaged_delete(file_default_scheme() . '/' . $this->randomName()), t('Returns true when deleting a non-existent file.'));
+ }
+
+ /**
+ * Try deleting a directory.
+ */
+ function testDirectory() {
+ // A directory to operate on.
+ $directory = $this->createDirectory();
+
+ // Try to delete a directory
+ $this->assertFalse(file_unmanaged_delete($directory), t('Could not delete the delete directory.'));
+ $this->assertTrue(file_exists($directory), t('Directory has not been deleted.'));
+ }
+}
+
+/**
+ * Deletion related tests on remote filesystems.
+ */
+class RemoteFileUnmanagedDeleteTest extends FileUnmanagedDeleteTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Deletion related tests.
+ */
+class FileUnmanagedDeleteRecursiveTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unmanaged recursive file delete',
+ 'description' => 'Tests the unmanaged file delete recursive function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Delete a normal file.
+ */
+ function testSingleFile() {
+ // Create a file for testing
+ $filepath = file_default_scheme() . '://' . $this->randomName();
+ file_put_contents($filepath, '');
+
+ // Delete the file.
+ $this->assertTrue(file_unmanaged_delete_recursive($filepath), t('Function reported success.'));
+ $this->assertFalse(file_exists($filepath), t('Test file has been deleted.'));
+ }
+
+ /**
+ * Try deleting an empty directory.
+ */
+ function testEmptyDirectory() {
+ // A directory to operate on.
+ $directory = $this->createDirectory();
+
+ // Delete the directory.
+ $this->assertTrue(file_unmanaged_delete_recursive($directory), t('Function reported success.'));
+ $this->assertFalse(file_exists($directory), t('Directory has been deleted.'));
+ }
+
+ /**
+ * Try deleting a directory with some files.
+ */
+ function testDirectory() {
+ // A directory to operate on.
+ $directory = $this->createDirectory();
+ $filepathA = $directory . '/A';
+ $filepathB = $directory . '/B';
+ file_put_contents($filepathA, '');
+ file_put_contents($filepathB, '');
+
+ // Delete the directory.
+ $this->assertTrue(file_unmanaged_delete_recursive($directory), t('Function reported success.'));
+ $this->assertFalse(file_exists($filepathA), t('Test file A has been deleted.'));
+ $this->assertFalse(file_exists($filepathB), t('Test file B has been deleted.'));
+ $this->assertFalse(file_exists($directory), t('Directory has been deleted.'));
+ }
+
+ /**
+ * Try deleting subdirectories with some files.
+ */
+ function testSubDirectory() {
+ // A directory to operate on.
+ $directory = $this->createDirectory();
+ $subdirectory = $this->createDirectory($directory . '/sub');
+ $filepathA = $directory . '/A';
+ $filepathB = $subdirectory . '/B';
+ file_put_contents($filepathA, '');
+ file_put_contents($filepathB, '');
+
+ // Delete the directory.
+ $this->assertTrue(file_unmanaged_delete_recursive($directory), t('Function reported success.'));
+ $this->assertFalse(file_exists($filepathA), t('Test file A has been deleted.'));
+ $this->assertFalse(file_exists($filepathB), t('Test file B has been deleted.'));
+ $this->assertFalse(file_exists($subdirectory), t('Subdirectory has been deleted.'));
+ $this->assertFalse(file_exists($directory), t('Directory has been deleted.'));
+ }
+}
+
+/**
+ * Deletion related tests on remote filesystems.
+ */
+class RemoteFileUnmanagedDeleteRecursiveTest extends FileUnmanagedDeleteRecursiveTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Unmanaged move related tests.
+ */
+class FileUnmanagedMoveTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unmanaged file moving',
+ 'description' => 'Tests the unmanaged file move function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Move a normal file.
+ */
+ function testNormal() {
+ // Create a file for testing
+ $file = $this->createFile();
+
+ // Moving to a new name.
+ $desired_filepath = 'public://' . $this->randomName();
+ $new_filepath = file_unmanaged_move($file->uri, $desired_filepath, FILE_EXISTS_ERROR);
+ $this->assertTrue($new_filepath, t('Move was successful.'));
+ $this->assertEqual($new_filepath, $desired_filepath, t('Returned expected filepath.'));
+ $this->assertTrue(file_exists($new_filepath), t('File exists at the new location.'));
+ $this->assertFalse(file_exists($file->uri), t('No file remains at the old location.'));
+ $this->assertFilePermissions($new_filepath, variable_get('file_chmod_file', 0664));
+
+ // Moving with rename.
+ $desired_filepath = 'public://' . $this->randomName();
+ $this->assertTrue(file_exists($new_filepath), t('File exists before moving.'));
+ $this->assertTrue(file_put_contents($desired_filepath, ' '), t('Created a file so a rename will have to happen.'));
+ $newer_filepath = file_unmanaged_move($new_filepath, $desired_filepath, FILE_EXISTS_RENAME);
+ $this->assertTrue($newer_filepath, t('Move was successful.'));
+ $this->assertNotEqual($newer_filepath, $desired_filepath, t('Returned expected filepath.'));
+ $this->assertTrue(file_exists($newer_filepath), t('File exists at the new location.'));
+ $this->assertFalse(file_exists($new_filepath), t('No file remains at the old location.'));
+ $this->assertFilePermissions($newer_filepath, variable_get('file_chmod_file', 0664));
+
+ // TODO: test moving to a directory (rather than full directory/file path)
+ // TODO: test creating and moving normal files (rather than streams)
+ }
+
+ /**
+ * Try to move a missing file.
+ */
+ function testMissing() {
+ // Move non-existent file.
+ $new_filepath = file_unmanaged_move($this->randomName(), $this->randomName());
+ $this->assertFalse($new_filepath, t('Moving a missing file fails.'));
+ }
+
+ /**
+ * Try to move a file onto itself.
+ */
+ function testOverwriteSelf() {
+ // Create a file for testing.
+ $file = $this->createFile();
+
+ // Move the file onto itself without renaming shouldn't make changes.
+ $new_filepath = file_unmanaged_move($file->uri, $file->uri, FILE_EXISTS_REPLACE);
+ $this->assertFalse($new_filepath, t('Moving onto itself without renaming fails.'));
+ $this->assertTrue(file_exists($file->uri), t('File exists after moving onto itself.'));
+
+ // Move the file onto itself with renaming will result in a new filename.
+ $new_filepath = file_unmanaged_move($file->uri, $file->uri, FILE_EXISTS_RENAME);
+ $this->assertTrue($new_filepath, t('Moving onto itself with renaming works.'));
+ $this->assertFalse(file_exists($file->uri), t('Original file has been removed.'));
+ $this->assertTrue(file_exists($new_filepath), t('File exists after moving onto itself.'));
+ }
+}
+
+/**
+ * Unmanaged move related tests on remote filesystems.
+ */
+class RemoteFileUnmanagedMoveTest extends FileUnmanagedMoveTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Unmanaged copy related tests.
+ */
+class FileUnmanagedCopyTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unmanaged file copying',
+ 'description' => 'Tests the unmanaged file copy function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Copy a normal file.
+ */
+ function testNormal() {
+ // Create a file for testing
+ $file = $this->createFile();
+
+ // Copying to a new name.
+ $desired_filepath = 'public://' . $this->randomName();
+ $new_filepath = file_unmanaged_copy($file->uri, $desired_filepath, FILE_EXISTS_ERROR);
+ $this->assertTrue($new_filepath, t('Copy was successful.'));
+ $this->assertEqual($new_filepath, $desired_filepath, t('Returned expected filepath.'));
+ $this->assertTrue(file_exists($file->uri), t('Original file remains.'));
+ $this->assertTrue(file_exists($new_filepath), t('New file exists.'));
+ $this->assertFilePermissions($new_filepath, variable_get('file_chmod_file', 0664));
+
+ // Copying with rename.
+ $desired_filepath = 'public://' . $this->randomName();
+ $this->assertTrue(file_put_contents($desired_filepath, ' '), t('Created a file so a rename will have to happen.'));
+ $newer_filepath = file_unmanaged_copy($file->uri, $desired_filepath, FILE_EXISTS_RENAME);
+ $this->assertTrue($newer_filepath, t('Copy was successful.'));
+ $this->assertNotEqual($newer_filepath, $desired_filepath, t('Returned expected filepath.'));
+ $this->assertTrue(file_exists($file->uri), t('Original file remains.'));
+ $this->assertTrue(file_exists($newer_filepath), t('New file exists.'));
+ $this->assertFilePermissions($newer_filepath, variable_get('file_chmod_file', 0664));
+
+ // TODO: test copying to a directory (rather than full directory/file path)
+ // TODO: test copying normal files using normal paths (rather than only streams)
+ }
+
+ /**
+ * Copy a non-existent file.
+ */
+ function testNonExistent() {
+ // Copy non-existent file
+ $desired_filepath = $this->randomName();
+ $this->assertFalse(file_exists($desired_filepath), t("Randomly named file doesn't exists."));
+ $new_filepath = file_unmanaged_copy($desired_filepath, $this->randomName());
+ $this->assertFalse($new_filepath, t('Copying a missing file fails.'));
+ }
+
+ /**
+ * Copy a file onto itself.
+ */
+ function testOverwriteSelf() {
+ // Create a file for testing
+ $file = $this->createFile();
+
+ // Copy the file onto itself with renaming works.
+ $new_filepath = file_unmanaged_copy($file->uri, $file->uri, FILE_EXISTS_RENAME);
+ $this->assertTrue($new_filepath, t('Copying onto itself with renaming works.'));
+ $this->assertNotEqual($new_filepath, $file->uri, t('Copied file has a new name.'));
+ $this->assertTrue(file_exists($file->uri), t('Original file exists after copying onto itself.'));
+ $this->assertTrue(file_exists($new_filepath), t('Copied file exists after copying onto itself.'));
+ $this->assertFilePermissions($new_filepath, variable_get('file_chmod_file', 0664));
+
+ // Copy the file onto itself without renaming fails.
+ $new_filepath = file_unmanaged_copy($file->uri, $file->uri, FILE_EXISTS_ERROR);
+ $this->assertFalse($new_filepath, t('Copying onto itself without renaming fails.'));
+ $this->assertTrue(file_exists($file->uri), t('File exists after copying onto itself.'));
+
+ // Copy the file into same directory without renaming fails.
+ $new_filepath = file_unmanaged_copy($file->uri, drupal_dirname($file->uri), FILE_EXISTS_ERROR);
+ $this->assertFalse($new_filepath, t('Copying onto itself fails.'));
+ $this->assertTrue(file_exists($file->uri), t('File exists after copying onto itself.'));
+
+ // Copy the file into same directory with renaming works.
+ $new_filepath = file_unmanaged_copy($file->uri, drupal_dirname($file->uri), FILE_EXISTS_RENAME);
+ $this->assertTrue($new_filepath, t('Copying into same directory works.'));
+ $this->assertNotEqual($new_filepath, $file->uri, t('Copied file has a new name.'));
+ $this->assertTrue(file_exists($file->uri), t('Original file exists after copying onto itself.'));
+ $this->assertTrue(file_exists($new_filepath), t('Copied file exists after copying onto itself.'));
+ $this->assertFilePermissions($new_filepath, variable_get('file_chmod_file', 0664));
+ }
+}
+
+/**
+ * Unmanaged copy related tests on remote filesystems.
+ */
+class RemoteFileUnmanagedCopyTest extends FileUnmanagedCopyTest {
+ public static function getInfo() {
+ $info = parent::getInfo();
+ $info['group'] = 'File API (remote)';
+ return $info;
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ variable_set('file_default_scheme', 'dummy-remote');
+ }
+}
+
+/**
+ * Deletion related tests.
+ */
+class FileDeleteTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File delete',
+ 'description' => 'Tests the file delete function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Tries deleting a normal file (as opposed to a directory, symlink, etc).
+ */
+ function testUnused() {
+ $file = $this->createFile();
+
+ // Check that deletion removes the file and database record.
+ $this->assertTrue(is_file($file->uri), t('File exists.'));
+ $this->assertIdentical(file_delete($file), TRUE, t('Delete worked.'));
+ $this->assertFileHooksCalled(array('delete'));
+ $this->assertFalse(file_exists($file->uri), t('Test file has actually been deleted.'));
+ $this->assertFalse(file_load($file->fid), t('File was removed from the database.'));
+ }
+
+ /**
+ * Tries deleting a file that is in use.
+ */
+ function testInUse() {
+ $file = $this->createFile();
+ file_usage_add($file, 'testing', 'test', 1);
+ file_usage_add($file, 'testing', 'test', 1);
+
+ file_usage_delete($file, 'testing', 'test', 1);
+ file_delete($file);
+ $usage = file_usage_list($file);
+ $this->assertEqual($usage['testing']['test'], array(1 => 1), t('Test file is still in use.'));
+ $this->assertTrue(file_exists($file->uri), t('File still exists on the disk.'));
+ $this->assertTrue(file_load($file->fid), t('File still exists in the database.'));
+
+ // Clear out the call to hook_file_load().
+ file_test_reset();
+
+ file_usage_delete($file, 'testing', 'test', 1);
+ file_delete($file);
+ $usage = file_usage_list($file);
+ $this->assertFileHooksCalled(array('delete'));
+ $this->assertTrue(empty($usage), t('File usage data was removed.'));
+ $this->assertFalse(file_exists($file->uri), t('File has been deleted after its last usage was removed.'));
+ $this->assertFalse(file_load($file->fid), t('File was removed from the database.'));
+ }
+}
+
+
+/**
+ * Move related tests
+ */
+class FileMoveTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File moving',
+ 'description' => 'Tests the file move function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Move a normal file.
+ */
+ function testNormal() {
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $desired_filepath = 'public://' . $this->randomName();
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_move(clone $source, $desired_filepath, FILE_EXISTS_ERROR);
+
+ // Check the return status and that the contents changed.
+ $this->assertTrue($result, t('File moved successfully.'));
+ $this->assertFalse(file_exists($source->uri));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file correctly written.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('move', 'load', 'update'));
+
+ // Make sure we got the same file back.
+ $this->assertEqual($source->fid, $result->fid, t("Source file id's' %fid is unchanged after move.", array('%fid' => $source->fid)));
+
+ // Reload the file from the database and check that the changes were
+ // actually saved.
+ $loaded_file = file_load($result->fid, TRUE);
+ $this->assertTrue($loaded_file, t('File can be loaded from the database.'));
+ $this->assertFileUnchanged($result, $loaded_file);
+ }
+
+ /**
+ * Test renaming when moving onto a file that already exists.
+ */
+ function testExistingRename() {
+ // Setup a file to overwrite.
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $target = $this->createFile();
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_move(clone $source, $target->uri, FILE_EXISTS_RENAME);
+
+ // Check the return status and that the contents changed.
+ $this->assertTrue($result, t('File moved successfully.'));
+ $this->assertFalse(file_exists($source->uri));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file correctly written.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('move', 'load', 'update'));
+
+ // Compare the returned value to what made it into the database.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ // The target file should not have been altered.
+ $this->assertFileUnchanged($target, file_load($target->fid, TRUE));
+ // Make sure we end up with two distinct files afterwards.
+ $this->assertDifferentFile($target, $result);
+
+ // Compare the source and results.
+ $loaded_source = file_load($source->fid, TRUE);
+ $this->assertEqual($loaded_source->fid, $result->fid, t("Returned file's id matches the source."));
+ $this->assertNotEqual($loaded_source->uri, $source->uri, t("Returned file path has changed from the original."));
+ }
+
+ /**
+ * Test replacement when moving onto a file that already exists.
+ */
+ function testExistingReplace() {
+ // Setup a file to overwrite.
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $target = $this->createFile();
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_move(clone $source, $target->uri, FILE_EXISTS_REPLACE);
+
+ // Look at the results.
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file were overwritten.'));
+ $this->assertFalse(file_exists($source->uri));
+ $this->assertTrue($result, t('File moved successfully.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('move', 'update', 'delete', 'load'));
+
+ // Reload the file from the database and check that the changes were
+ // actually saved.
+ $loaded_result = file_load($result->fid, TRUE);
+ $this->assertFileUnchanged($result, $loaded_result);
+ // Check that target was re-used.
+ $this->assertSameFile($target, $loaded_result);
+ // Source and result should be totally different.
+ $this->assertDifferentFile($source, $loaded_result);
+ }
+
+ /**
+ * Test replacement when moving onto itself.
+ */
+ function testExistingReplaceSelf() {
+ // Setup a file to overwrite.
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+
+ // Copy the file over itself. Clone the object so we don't have to worry
+ // about the function changing our reference copy.
+ $result = file_move(clone $source, $source->uri, FILE_EXISTS_REPLACE);
+ $this->assertFalse($result, t('File move failed.'));
+ $this->assertEqual($contents, file_get_contents($source->uri), t('Contents of file were not altered.'));
+
+ // Check that no hooks were called while failing.
+ $this->assertFileHooksCalled(array());
+
+ // Load the file from the database and make sure it is identical to what
+ // was returned.
+ $this->assertFileUnchanged($source, file_load($source->fid, TRUE));
+ }
+
+ /**
+ * Test that moving onto an existing file fails when FILE_EXISTS_ERROR is
+ * specified.
+ */
+ function testExistingError() {
+ $contents = $this->randomName(10);
+ $source = $this->createFile();
+ $target = $this->createFile(NULL, $contents);
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_move(clone $source, $target->uri, FILE_EXISTS_ERROR);
+
+ // Check the return status and that the contents did not change.
+ $this->assertFalse($result, t('File move failed.'));
+ $this->assertTrue(file_exists($source->uri));
+ $this->assertEqual($contents, file_get_contents($target->uri), t('Contents of file were not altered.'));
+
+ // Check that no hooks were called while failing.
+ $this->assertFileHooksCalled(array());
+
+ // Load the file from the database and make sure it is identical to what
+ // was returned.
+ $this->assertFileUnchanged($source, file_load($source->fid, TRUE));
+ $this->assertFileUnchanged($target, file_load($target->fid, TRUE));
+ }
+}
+
+
+/**
+ * Copy related tests.
+ */
+class FileCopyTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File copying',
+ 'description' => 'Tests the file copy function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test file copying in the normal, base case.
+ */
+ function testNormal() {
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $desired_uri = 'public://' . $this->randomName();
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_copy(clone $source, $desired_uri, FILE_EXISTS_ERROR);
+
+ // Check the return status and that the contents changed.
+ $this->assertTrue($result, t('File copied successfully.'));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file were copied correctly.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('copy', 'insert'));
+
+ $this->assertDifferentFile($source, $result);
+ $this->assertEqual($result->uri, $desired_uri, t('The copied file object has the desired filepath.'));
+ $this->assertTrue(file_exists($source->uri), t('The original file still exists.'));
+ $this->assertTrue(file_exists($result->uri), t('The copied file exists.'));
+
+ // Reload the file from the database and check that the changes were
+ // actually saved.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ }
+
+ /**
+ * Test renaming when copying over a file that already exists.
+ */
+ function testExistingRename() {
+ // Setup a file to overwrite.
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $target = $this->createFile();
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_copy(clone $source, $target->uri, FILE_EXISTS_RENAME);
+
+ // Check the return status and that the contents changed.
+ $this->assertTrue($result, t('File copied successfully.'));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file were copied correctly.'));
+ $this->assertNotEqual($result->uri, $source->uri, t('Returned file path has changed from the original.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('copy', 'insert'));
+
+ // Load all the affected files to check the changes that actually made it
+ // to the database.
+ $loaded_source = file_load($source->fid, TRUE);
+ $loaded_target = file_load($target->fid, TRUE);
+ $loaded_result = file_load($result->fid, TRUE);
+
+ // Verify that the source file wasn't changed.
+ $this->assertFileUnchanged($source, $loaded_source);
+
+ // Verify that what was returned is what's in the database.
+ $this->assertFileUnchanged($result, $loaded_result);
+
+ // Make sure we end up with three distinct files afterwards.
+ $this->assertDifferentFile($loaded_source, $loaded_target);
+ $this->assertDifferentFile($loaded_target, $loaded_result);
+ $this->assertDifferentFile($loaded_source, $loaded_result);
+ }
+
+ /**
+ * Test replacement when copying over a file that already exists.
+ */
+ function testExistingReplace() {
+ // Setup a file to overwrite.
+ $contents = $this->randomName(10);
+ $source = $this->createFile(NULL, $contents);
+ $target = $this->createFile();
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_copy(clone $source, $target->uri, FILE_EXISTS_REPLACE);
+
+ // Check the return status and that the contents changed.
+ $this->assertTrue($result, t('File copied successfully.'));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of file were overwritten.'));
+ $this->assertDifferentFile($source, $result);
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('load', 'copy', 'update'));
+
+ // Load all the affected files to check the changes that actually made it
+ // to the database.
+ $loaded_source = file_load($source->fid, TRUE);
+ $loaded_target = file_load($target->fid, TRUE);
+ $loaded_result = file_load($result->fid, TRUE);
+
+ // Verify that the source file wasn't changed.
+ $this->assertFileUnchanged($source, $loaded_source);
+
+ // Verify that what was returned is what's in the database.
+ $this->assertFileUnchanged($result, $loaded_result);
+
+ // Target file was reused for the result.
+ $this->assertFileUnchanged($loaded_target, $loaded_result);
+ }
+
+ /**
+ * Test that copying over an existing file fails when FILE_EXISTS_ERROR is
+ * specified.
+ */
+ function testExistingError() {
+ $contents = $this->randomName(10);
+ $source = $this->createFile();
+ $target = $this->createFile(NULL, $contents);
+ $this->assertDifferentFile($source, $target);
+
+ // Clone the object so we don't have to worry about the function changing
+ // our reference copy.
+ $result = file_copy(clone $source, $target->uri, FILE_EXISTS_ERROR);
+
+ // Check the return status and that the contents were not changed.
+ $this->assertFalse($result, t('File copy failed.'));
+ $this->assertEqual($contents, file_get_contents($target->uri), t('Contents of file were not altered.'));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array());
+
+ $this->assertFileUnchanged($source, file_load($source->fid, TRUE));
+ $this->assertFileUnchanged($target, file_load($target->fid, TRUE));
+ }
+}
+
+
+/**
+ * Tests the file_load() function.
+ */
+class FileLoadTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File loading',
+ 'description' => 'Tests the file_load() function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Try to load a non-existent file by fid.
+ */
+ function testLoadMissingFid() {
+ $this->assertFalse(file_load(-1), t("Try to load an invalid fid fails."));
+ $this->assertFileHooksCalled(array());
+ }
+
+ /**
+ * Try to load a non-existent file by URI.
+ */
+ function testLoadMissingFilepath() {
+ $files = file_load_multiple(array(), array('uri' => 'foobar://misc/druplicon.png'));
+ $this->assertFalse(reset($files), t("Try to load a file that doesn't exist in the database fails."));
+ $this->assertFileHooksCalled(array());
+ }
+
+ /**
+ * Try to load a non-existent file by status.
+ */
+ function testLoadInvalidStatus() {
+ $files = file_load_multiple(array(), array('status' => -99));
+ $this->assertFalse(reset($files), t("Trying to load a file with an invalid status fails."));
+ $this->assertFileHooksCalled(array());
+ }
+
+ /**
+ * Load a single file and ensure that the correct values are returned.
+ */
+ function testSingleValues() {
+ // Create a new file object from scratch so we know the values.
+ $file = $this->createFile('druplicon.txt', NULL, 'public');
+
+ $by_fid_file = file_load($file->fid);
+ $this->assertFileHookCalled('load');
+ $this->assertTrue(is_object($by_fid_file), t('file_load() returned an object.'));
+ $this->assertEqual($by_fid_file->fid, $file->fid, t("Loading by fid got the same fid."), 'File');
+ $this->assertEqual($by_fid_file->uri, $file->uri, t("Loading by fid got the correct filepath."), 'File');
+ $this->assertEqual($by_fid_file->filename, $file->filename, t("Loading by fid got the correct filename."), 'File');
+ $this->assertEqual($by_fid_file->filemime, $file->filemime, t("Loading by fid got the correct MIME type."), 'File');
+ $this->assertEqual($by_fid_file->status, $file->status, t("Loading by fid got the correct status."), 'File');
+ $this->assertTrue($by_fid_file->file_test['loaded'], t('file_test_file_load() was able to modify the file during load.'));
+ }
+
+ /**
+ * This will test loading file data from the database.
+ */
+ function testMultiple() {
+ // Create a new file object.
+ $file = $this->createFile('druplicon.txt', NULL, 'public');
+
+ // Load by path.
+ file_test_reset();
+ $by_path_files = file_load_multiple(array(), array('uri' => $file->uri));
+ $this->assertFileHookCalled('load');
+ $this->assertEqual(1, count($by_path_files), t('file_load_multiple() returned an array of the correct size.'));
+ $by_path_file = reset($by_path_files);
+ $this->assertTrue($by_path_file->file_test['loaded'], t('file_test_file_load() was able to modify the file during load.'));
+ $this->assertEqual($by_path_file->fid, $file->fid, t("Loading by filepath got the correct fid."), 'File');
+
+ // Load by fid.
+ file_test_reset();
+ $by_fid_files = file_load_multiple(array($file->fid), array());
+ $this->assertFileHookCalled('load');
+ $this->assertEqual(1, count($by_fid_files), t('file_load_multiple() returned an array of the correct size.'));
+ $by_fid_file = reset($by_fid_files);
+ $this->assertTrue($by_fid_file->file_test['loaded'], t('file_test_file_load() was able to modify the file during load.'));
+ $this->assertEqual($by_fid_file->uri, $file->uri, t("Loading by fid got the correct filepath."), 'File');
+ }
+}
+
+/**
+ * Tests the file_save() function.
+ */
+class FileSaveTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File saving',
+ 'description' => 'Tests the file_save() function.',
+ 'group' => 'File API',
+ );
+ }
+
+ function testFileSave() {
+ // Create a new file object.
+ $file = array(
+ 'uid' => 1,
+ 'filename' => 'druplicon.txt',
+ 'uri' => 'public://druplicon.txt',
+ 'filemime' => 'text/plain',
+ 'timestamp' => 1,
+ 'status' => FILE_STATUS_PERMANENT,
+ );
+ $file = (object) $file;
+ file_put_contents($file->uri, 'hello world');
+
+ // Save it, inserting a new record.
+ $saved_file = file_save($file);
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('insert'));
+
+ $this->assertNotNull($saved_file, t("Saving the file should give us back a file object."), 'File');
+ $this->assertTrue($saved_file->fid > 0, t("A new file ID is set when saving a new file to the database."), 'File');
+ $loaded_file = db_query('SELECT * FROM {file_managed} f WHERE f.fid = :fid', array(':fid' => $saved_file->fid))->fetch(PDO::FETCH_OBJ);
+ $this->assertNotNull($loaded_file, t("Record exists in the database."));
+ $this->assertEqual($loaded_file->status, $file->status, t("Status was saved correctly."));
+ $this->assertEqual($saved_file->filesize, filesize($file->uri), t("File size was set correctly."), 'File');
+ $this->assertTrue($saved_file->timestamp > 1, t("File size was set correctly."), 'File');
+
+
+ // Resave the file, updating the existing record.
+ file_test_reset();
+ $saved_file->status = 7;
+ $resaved_file = file_save($saved_file);
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('load', 'update'));
+
+ $this->assertEqual($resaved_file->fid, $saved_file->fid, t("The file ID of an existing file is not changed when updating the database."), 'File');
+ $this->assertTrue($resaved_file->timestamp >= $saved_file->timestamp, t("Timestamp didn't go backwards."), 'File');
+ $loaded_file = db_query('SELECT * FROM {file_managed} f WHERE f.fid = :fid', array(':fid' => $saved_file->fid))->fetch(PDO::FETCH_OBJ);
+ $this->assertNotNull($loaded_file, t("Record still exists in the database."), 'File');
+ $this->assertEqual($loaded_file->status, $saved_file->status, t("Status was saved correctly."));
+ }
+}
+
+/**
+ * Tests file usage functions.
+ */
+class FileUsageTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File usage',
+ 'description' => 'Tests the file usage functions.',
+ 'group' => 'File',
+ );
+ }
+
+ /**
+ * Tests file_usage_list().
+ */
+ function testGetUsage() {
+ $file = $this->createFile();
+ db_insert('file_usage')
+ ->fields(array(
+ 'fid' => $file->fid,
+ 'module' => 'testing',
+ 'type' => 'foo',
+ 'id' => 1,
+ 'count' => 1
+ ))
+ ->execute();
+ db_insert('file_usage')
+ ->fields(array(
+ 'fid' => $file->fid,
+ 'module' => 'testing',
+ 'type' => 'bar',
+ 'id' => 2,
+ 'count' => 2
+ ))
+ ->execute();
+
+ $usage = file_usage_list($file);
+
+ $this->assertEqual(count($usage['testing']), 2, t('Returned the correct number of items.'));
+ $this->assertTrue(isset($usage['testing']['foo'][1]), t('Returned the correct id.'));
+ $this->assertTrue(isset($usage['testing']['bar'][2]), t('Returned the correct id.'));
+ $this->assertEqual($usage['testing']['foo'][1], 1, t('Returned the correct count.'));
+ $this->assertEqual($usage['testing']['bar'][2], 2, t('Returned the correct count.'));
+ }
+
+ /**
+ * Tests file_usage_add().
+ */
+ function testAddUsage() {
+ $file = $this->createFile();
+ file_usage_add($file, 'testing', 'foo', 1);
+ // Add the file twice to ensure that the count is incremented rather than
+ // creating additional records.
+ file_usage_add($file, 'testing', 'bar', 2);
+ file_usage_add($file, 'testing', 'bar', 2);
+
+ $usage = db_select('file_usage', 'f')
+ ->fields('f')
+ ->condition('f.fid', $file->fid)
+ ->execute()
+ ->fetchAllAssoc('id');
+ $this->assertEqual(count($usage), 2, t('Created two records'));
+ $this->assertEqual($usage[1]->module, 'testing', t('Correct module'));
+ $this->assertEqual($usage[2]->module, 'testing', t('Correct module'));
+ $this->assertEqual($usage[1]->type, 'foo', t('Correct type'));
+ $this->assertEqual($usage[2]->type, 'bar', t('Correct type'));
+ $this->assertEqual($usage[1]->count, 1, t('Correct count'));
+ $this->assertEqual($usage[2]->count, 2, t('Correct count'));
+ }
+
+ /**
+ * Tests file_usage_delete().
+ */
+ function testRemoveUsage() {
+ $file = $this->createFile();
+ db_insert('file_usage')
+ ->fields(array(
+ 'fid' => $file->fid,
+ 'module' => 'testing',
+ 'type' => 'bar',
+ 'id' => 2,
+ 'count' => 3,
+ ))
+ ->execute();
+
+ // Normal decrement.
+ file_usage_delete($file, 'testing', 'bar', 2);
+ $count = db_select('file_usage', 'f')
+ ->fields('f', array('count'))
+ ->condition('f.fid', $file->fid)
+ ->execute()
+ ->fetchField();
+ $this->assertEqual(2, $count, t('The count was decremented correctly.'));
+
+ // Multiple decrement and removal.
+ file_usage_delete($file, 'testing', 'bar', 2, 2);
+ $count = db_select('file_usage', 'f')
+ ->fields('f', array('count'))
+ ->condition('f.fid', $file->fid)
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical(FALSE, $count, t('The count was removed entirely when empty.'));
+
+ // Non-existent decrement.
+ file_usage_delete($file, 'testing', 'bar', 2);
+ $count = db_select('file_usage', 'f')
+ ->fields('f', array('count'))
+ ->condition('f.fid', $file->fid)
+ ->execute()
+ ->fetchField();
+ $this->assertIdentical(FALSE, $count, t('Decrementing non-exist record complete.'));
+ }
+}
+
+/**
+ * Tests the file_validate() function..
+ */
+class FileValidateTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File validate',
+ 'description' => 'Tests the file_validate() function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test that the validators passed into are checked.
+ */
+ function testCallerValidation() {
+ $file = $this->createFile();
+
+ // Empty validators.
+ $this->assertEqual(file_validate($file, array()), array(), t('Validating an empty array works successfully.'));
+ $this->assertFileHooksCalled(array('validate'));
+
+ // Use the file_test.module's test validator to ensure that passing tests
+ // return correctly.
+ file_test_reset();
+ file_test_set_return('validate', array());
+ $passing = array('file_test_validator' => array(array()));
+ $this->assertEqual(file_validate($file, $passing), array(), t('Validating passes.'));
+ $this->assertFileHooksCalled(array('validate'));
+
+ // Now test for failures in validators passed in and by hook_validate.
+ file_test_reset();
+ file_test_set_return('validate', array('Epic fail'));
+ $failing = array('file_test_validator' => array(array('Failed', 'Badly')));
+ $this->assertEqual(file_validate($file, $failing), array('Failed', 'Badly', 'Epic fail'), t('Validating returns errors.'));
+ $this->assertFileHooksCalled(array('validate'));
+ }
+}
+
+/**
+ * Tests the file_save_data() function.
+ */
+class FileSaveDataTest extends FileHookTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File save data',
+ 'description' => 'Tests the file save data function.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test the file_save_data() function when no filename is provided.
+ */
+ function testWithoutFilename() {
+ $contents = $this->randomName(8);
+
+ $result = file_save_data($contents);
+ $this->assertTrue($result, t('Unnamed file saved correctly.'));
+
+ $this->assertEqual(file_default_scheme(), file_uri_scheme($result->uri), t("File was placed in Drupal's files directory."));
+ $this->assertEqual($result->filename, basename($result->uri), t("Filename was set to the file's basename."));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of the file are correct.'));
+ $this->assertEqual($result->filemime, 'application/octet-stream', t('A MIME type was set.'));
+ $this->assertEqual($result->status, FILE_STATUS_PERMANENT, t("The file's status was set to permanent."));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('insert'));
+
+ // Verify that what was returned is what's in the database.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ }
+
+ /**
+ * Test the file_save_data() function when a filename is provided.
+ */
+ function testWithFilename() {
+ $contents = $this->randomName(8);
+
+ $result = file_save_data($contents, 'public://' . 'asdf.txt');
+ $this->assertTrue($result, t('Unnamed file saved correctly.'));
+
+ $this->assertEqual('public', file_uri_scheme($result->uri), t("File was placed in Drupal's files directory."));
+ $this->assertEqual('asdf.txt', basename($result->uri), t('File was named correctly.'));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of the file are correct.'));
+ $this->assertEqual($result->filemime, 'text/plain', t('A MIME type was set.'));
+ $this->assertEqual($result->status, FILE_STATUS_PERMANENT, t("The file's status was set to permanent."));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('insert'));
+
+ // Verify that what was returned is what's in the database.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ }
+
+ /**
+ * Test file_save_data() when renaming around an existing file.
+ */
+ function testExistingRename() {
+ // Setup a file to overwrite.
+ $existing = $this->createFile();
+ $contents = $this->randomName(8);
+
+ $result = file_save_data($contents, $existing->uri, FILE_EXISTS_RENAME);
+ $this->assertTrue($result, t("File saved successfully."));
+
+ $this->assertEqual('public', file_uri_scheme($result->uri), t("File was placed in Drupal's files directory."));
+ $this->assertEqual($result->filename, $existing->filename, t("Filename was set to the basename of the source, rather than that of the renamed file."));
+ $this->assertEqual($contents, file_get_contents($result->uri), t("Contents of the file are correct."));
+ $this->assertEqual($result->filemime, 'application/octet-stream', t("A MIME type was set."));
+ $this->assertEqual($result->status, FILE_STATUS_PERMANENT, t("The file's status was set to permanent."));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('insert'));
+
+ // Ensure that the existing file wasn't overwritten.
+ $this->assertDifferentFile($existing, $result);
+ $this->assertFileUnchanged($existing, file_load($existing->fid, TRUE));
+
+ // Verify that was returned is what's in the database.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ }
+
+ /**
+ * Test file_save_data() when replacing an existing file.
+ */
+ function testExistingReplace() {
+ // Setup a file to overwrite.
+ $existing = $this->createFile();
+ $contents = $this->randomName(8);
+
+ $result = file_save_data($contents, $existing->uri, FILE_EXISTS_REPLACE);
+ $this->assertTrue($result, t('File saved successfully.'));
+
+ $this->assertEqual('public', file_uri_scheme($result->uri), t("File was placed in Drupal's files directory."));
+ $this->assertEqual($result->filename, $existing->filename, t('Filename was set to the basename of the existing file, rather than preserving the original name.'));
+ $this->assertEqual($contents, file_get_contents($result->uri), t('Contents of the file are correct.'));
+ $this->assertEqual($result->filemime, 'application/octet-stream', t('A MIME type was set.'));
+ $this->assertEqual($result->status, FILE_STATUS_PERMANENT, t("The file's status was set to permanent."));
+
+ // Check that the correct hooks were called.
+ $this->assertFileHooksCalled(array('load', 'update'));
+
+ // Verify that the existing file was re-used.
+ $this->assertSameFile($existing, $result);
+
+ // Verify that what was returned is what's in the database.
+ $this->assertFileUnchanged($result, file_load($result->fid, TRUE));
+ }
+
+ /**
+ * Test that file_save_data() fails overwriting an existing file.
+ */
+ function testExistingError() {
+ $contents = $this->randomName(8);
+ $existing = $this->createFile(NULL, $contents);
+
+ // Check the overwrite error.
+ $result = file_save_data('asdf', $existing->uri, FILE_EXISTS_ERROR);
+ $this->assertFalse($result, t('Overwriting a file fails when FILE_EXISTS_ERROR is specified.'));
+ $this->assertEqual($contents, file_get_contents($existing->uri), t('Contents of existing file were unchanged.'));
+
+ // Check that no hooks were called while failing.
+ $this->assertFileHooksCalled(array());
+
+ // Ensure that the existing file wasn't overwritten.
+ $this->assertFileUnchanged($existing, file_load($existing->fid, TRUE));
+ }
+}
+
+/**
+ * Tests for download/file transfer functions.
+ */
+class FileDownloadTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File download',
+ 'description' => 'Tests for file download/transfer functions.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ // Clear out any hook calls.
+ file_test_reset();
+ }
+
+ /**
+ * Test the public file transfer system.
+ */
+ function testPublicFileTransfer() {
+ // Test generating an URL to a created file.
+ $file = $this->createFile();
+ $url = file_create_url($file->uri);
+ $this->assertEqual($GLOBALS['base_url'] . '/' . file_stream_wrapper_get_instance_by_scheme('public')->getDirectoryPath() . '/' . $file->filename, $url, t('Correctly generated a URL for a created file.'));
+ $this->drupalHead($url);
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the created file.'));
+
+ // Test generating an URL to a shipped file (i.e. a file that is part of
+ // Drupal core, a module or a theme, for example a JavaScript file).
+ $filepath = 'core/misc/jquery.js';
+ $url = file_create_url($filepath);
+ $this->assertEqual($GLOBALS['base_url'] . '/' . $filepath, $url, t('Correctly generated a URL for a shipped file.'));
+ $this->drupalHead($url);
+ $this->assertResponse(200, t('Confirmed that the generated URL is correct by downloading the shipped file.'));
+ }
+
+ /**
+ * Test the private file transfer system.
+ */
+ function testPrivateFileTransfer() {
+ // Set file downloads to private so handler functions get called.
+
+ // Create a file.
+ $file = $this->createFile(NULL, NULL, 'private');
+ $url = file_create_url($file->uri);
+
+ // Set file_test access header to allow the download.
+ file_test_set_return('download', array('x-foo' => 'Bar'));
+ $this->drupalHead($url);
+ $headers = $this->drupalGetHeaders();
+ $this->assertEqual($headers['x-foo'] , 'Bar', t('Found header set by file_test module on private download.'));
+ $this->assertResponse(200, t('Correctly allowed access to a file when file_test provides headers.'));
+
+ // Deny access to all downloads via a -1 header.
+ file_test_set_return('download', -1);
+ $this->drupalHead($url);
+ $this->assertResponse(403, t('Correctly denied access to a file when file_test sets the header to -1.'));
+
+ // Try non-existent file.
+ $url = file_create_url('private://' . $this->randomName());
+ $this->drupalHead($url);
+ $this->assertResponse(404, t('Correctly returned 404 response for a non-existent file.'));
+ }
+
+ /**
+ * Test file_create_url().
+ */
+ function testFileCreateUrl() {
+ global $base_url;
+
+ // Tilde (~) is excluded from this test because it is encoded by
+ // rawurlencode() in PHP 5.2 but not in PHP 5.3, as per RFC 3986.
+ // @see http://www.php.net/manual/en/function.rawurlencode.php#86506
+ $basename = " -._!$'\"()*@[]?&+%#,;=:\n\x00" . // "Special" ASCII characters.
+ "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string.
+ "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets.
+ $basename_encoded = '%20-._%21%24%27%22%28%29%2A%40%5B%5D%3F%26%2B%25%23%2C%3B%3D%3A__' .
+ '%2523%2525%2526%252B%252F%253F' .
+ '%C3%A9%C3%B8%C3%AF%D0%B2%CE%B2%E4%B8%AD%E5%9C%8B%E6%9B%B8%DB%9E';
+
+ $this->checkUrl('public', '', $basename, $base_url . '/' . file_stream_wrapper_get_instance_by_scheme('public')->getDirectoryPath() . '/' . $basename_encoded);
+ $this->checkUrl('private', '', $basename, $base_url . '/system/files/' . $basename_encoded);
+ $this->checkUrl('private', '', $basename, $base_url . '/?q=system/files/' . $basename_encoded, '0');
+ }
+
+ /**
+ * Download a file from the URL generated by file_create_url().
+ *
+ * Create a file with the specified scheme, directory and filename; check that
+ * the URL generated by file_create_url() for the specified file equals the
+ * specified URL; fetch the URL and then compare the contents to the file.
+ *
+ * @param $scheme
+ * A scheme, e.g. "public"
+ * @param $directory
+ * A directory, possibly ""
+ * @param $filename
+ * A filename
+ * @param $expected_url
+ * The expected URL
+ * @param $clean_url
+ * The value of the clean_url setting
+ */
+ private function checkUrl($scheme, $directory, $filename, $expected_url, $clean_url = '1') {
+ variable_set('clean_url', $clean_url);
+
+ // Convert $filename to a valid filename, i.e. strip characters not
+ // supported by the filesystem, and create the file in the specified
+ // directory.
+ $filepath = file_create_filename($filename, $directory);
+ $directory_uri = $scheme . '://' . dirname($filepath);
+ file_prepare_directory($directory_uri, FILE_CREATE_DIRECTORY);
+ $file = $this->createFile($filepath, NULL, $scheme);
+
+ $url = file_create_url($file->uri);
+ $this->assertEqual($url, $expected_url, t('Generated URL matches expected URL.'));
+
+ if ($scheme == 'private') {
+ // Tell the implementation of hook_file_download() in file_test.module
+ // that this file may be downloaded.
+ file_test_set_return('download', array('x-foo' => 'Bar'));
+ }
+
+ $this->drupalGet($url);
+ if ($this->assertResponse(200) == 'pass') {
+ $this->assertRaw(file_get_contents($file->uri), t('Contents of the file are correct.'));
+ }
+
+ file_delete($file);
+ }
+}
+
+/**
+ * Tests for file URL rewriting.
+ */
+class FileURLRewritingTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File URL rewriting',
+ 'description' => 'Tests for file URL rewriting.',
+ 'group' => 'File',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ }
+
+ /**
+ * Test the generating of rewritten shipped file URLs.
+ */
+ function testShippedFileURL() {
+ // Test generating an URL to a shipped file (i.e. a file that is part of
+ // Drupal core, a module or a theme, for example a JavaScript file).
+
+ // Test alteration of file URLs to use a CDN.
+ variable_set('file_test_hook_file_url_alter', 'cdn');
+ $filepath = 'core/misc/jquery.js';
+ $url = file_create_url($filepath);
+ $this->assertEqual(FILE_URL_TEST_CDN_1 . '/' . $filepath, $url, t('Correctly generated a CDN URL for a shipped file.'));
+ $filepath = 'core/misc/favicon.ico';
+ $url = file_create_url($filepath);
+ $this->assertEqual(FILE_URL_TEST_CDN_2 . '/' . $filepath, $url, t('Correctly generated a CDN URL for a shipped file.'));
+
+ // Test alteration of file URLs to use root-relative URLs.
+ variable_set('file_test_hook_file_url_alter', 'root-relative');
+ $filepath = 'core/misc/jquery.js';
+ $url = file_create_url($filepath);
+ $this->assertEqual(base_path() . '/' . $filepath, $url, t('Correctly generated a root-relative URL for a shipped file.'));
+ $filepath = 'core/misc/favicon.ico';
+ $url = file_create_url($filepath);
+ $this->assertEqual(base_path() . '/' . $filepath, $url, t('Correctly generated a root-relative URL for a shipped file.'));
+
+ // Test alteration of file URLs to use protocol-relative URLs.
+ variable_set('file_test_hook_file_url_alter', 'protocol-relative');
+ $filepath = 'core/misc/jquery.js';
+ $url = file_create_url($filepath);
+ $this->assertEqual('/' . base_path() . '/' . $filepath, $url, t('Correctly generated a protocol-relative URL for a shipped file.'));
+ $filepath = 'core/misc/favicon.ico';
+ $url = file_create_url($filepath);
+ $this->assertEqual('/' . base_path() . '/' . $filepath, $url, t('Correctly generated a protocol-relative URL for a shipped file.'));
+ }
+
+ /**
+ * Test the generating of rewritten public created file URLs.
+ */
+ function testPublicCreatedFileURL() {
+ // Test generating an URL to a created file.
+
+ // Test alteration of file URLs to use a CDN.
+ variable_set('file_test_hook_file_url_alter', 'cdn');
+ $file = $this->createFile();
+ $url = file_create_url($file->uri);
+ $public_directory_path = file_stream_wrapper_get_instance_by_scheme('public')->getDirectoryPath();
+ $this->assertEqual(FILE_URL_TEST_CDN_2 . '/' . $public_directory_path . '/' . $file->filename, $url, t('Correctly generated a CDN URL for a created file.'));
+
+ // Test alteration of file URLs to use root-relative URLs.
+ variable_set('file_test_hook_file_url_alter', 'root-relative');
+ $file = $this->createFile();
+ $url = file_create_url($file->uri);
+ $this->assertEqual(base_path() . '/' . $public_directory_path . '/' . $file->filename, $url, t('Correctly generated a root-relative URL for a created file.'));
+
+ // Test alteration of file URLs to use a protocol-relative URLs.
+ variable_set('file_test_hook_file_url_alter', 'protocol-relative');
+ $file = $this->createFile();
+ $url = file_create_url($file->uri);
+ $this->assertEqual('/' . base_path() . '/' . $public_directory_path . '/' . $file->filename, $url, t('Correctly generated a protocol-relative URL for a created file.'));
+ }
+}
+
+/**
+ * Tests for file_munge_filename() and file_unmunge_filename().
+ */
+class FileNameMungingTest extends FileTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'File naming',
+ 'description' => 'Test filename munging and unmunging.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->bad_extension = 'php';
+ $this->name = $this->randomName() . '.' . $this->bad_extension . '.txt';
+ }
+
+ /**
+ * Create a file and munge/unmunge the name.
+ */
+ function testMunging() {
+ // Disable insecure uploads.
+ variable_set('allow_insecure_uploads', 0);
+ $munged_name = file_munge_filename($this->name, '', TRUE);
+ $messages = drupal_get_messages();
+ $this->assertTrue(in_array(t('For security reasons, your upload has been renamed to %filename.', array('%filename' => $munged_name)), $messages['status']), t('Alert properly set when a file is renamed.'));
+ $this->assertNotEqual($munged_name, $this->name, t('The new filename (%munged) has been modified from the original (%original)', array('%munged' => $munged_name, '%original' => $this->name)));
+ }
+
+ /**
+ * If the allow_insecure_uploads variable evaluates to true, the file should
+ * come out untouched, no matter how evil the filename.
+ */
+ function testMungeIgnoreInsecure() {
+ variable_set('allow_insecure_uploads', 1);
+ $munged_name = file_munge_filename($this->name, '');
+ $this->assertIdentical($munged_name, $this->name, t('The original filename (%original) matches the munged filename (%munged) when insecure uploads are enabled.', array('%munged' => $munged_name, '%original' => $this->name)));
+ }
+
+ /**
+ * White listed extensions are ignored by file_munge_filename().
+ */
+ function testMungeIgnoreWhitelisted() {
+ // Declare our extension as whitelisted.
+ $munged_name = file_munge_filename($this->name, $this->bad_extension);
+ $this->assertIdentical($munged_name, $this->name, t('The new filename (%munged) matches the original (%original) once the extension has been whitelisted.', array('%munged' => $munged_name, '%original' => $this->name)));
+ }
+
+ /**
+ * Ensure that unmunge gets your name back.
+ */
+ function testUnMunge() {
+ $munged_name = file_munge_filename($this->name, '', FALSE);
+ $unmunged_name = file_unmunge_filename($munged_name);
+ $this->assertIdentical($unmunged_name, $this->name, t('The unmunged (%unmunged) filename matches the original (%original)', array('%unmunged' => $unmunged_name, '%original' => $this->name)));
+ }
+}
+
+/**
+ * Tests for file_get_mimetype().
+ */
+class FileMimeTypeTest extends DrupalWebTestCase {
+ function setUp() {
+ parent::setUp('file_test');
+ }
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'File mimetypes',
+ 'description' => 'Test filename mimetype detection.',
+ 'group' => 'File API',
+ );
+ }
+
+ /**
+ * Test mapping of mimetypes from filenames.
+ */
+ public function testFileMimeTypeDetection() {
+ $prefix = 'simpletest://';
+
+ $test_case = array(
+ 'test.jar' => 'application/java-archive',
+ 'test.jpeg' => 'image/jpeg',
+ 'test.JPEG' => 'image/jpeg',
+ 'test.jpg' => 'image/jpeg',
+ 'test.jar.jpg' => 'image/jpeg',
+ 'test.jpg.jar' => 'application/java-archive',
+ 'test.pcf.Z' => 'application/x-font',
+ 'pcf.z' => 'application/octet-stream',
+ 'jar' => 'application/octet-stream',
+ 'some.junk' => 'application/octet-stream',
+ 'foo.file_test_1' => 'madeup/file_test_1',
+ 'foo.file_test_2' => 'madeup/file_test_2',
+ 'foo.doc' => 'madeup/doc',
+ 'test.ogg' => 'audio/ogg',
+ );
+
+ // Test using default mappings.
+ foreach ($test_case as $input => $expected) {
+ // Test stream [URI].
+ $output = file_get_mimetype($prefix . $input);
+ $this->assertIdentical($output, $expected, t('Mimetype for %input is %output (expected: %expected).', array('%input' => $input, '%output' => $output, '%expected' => $expected)));
+
+ // Test normal path equivalent
+ $output = file_get_mimetype($input);
+ $this->assertIdentical($output, $expected, t('Mimetype (using default mappings) for %input is %output (expected: %expected).', array('%input' => $input, '%output' => $output, '%expected' => $expected)));
+ }
+
+ // Now test passing in the map.
+ $mapping = array(
+ 'mimetypes' => array(
+ 0 => 'application/java-archive',
+ 1 => 'image/jpeg',
+ ),
+ 'extensions' => array(
+ 'jar' => 0,
+ 'jpg' => 1,
+ )
+ );
+
+ $test_case = array(
+ 'test.jar' => 'application/java-archive',
+ 'test.jpeg' => 'application/octet-stream',
+ 'test.jpg' => 'image/jpeg',
+ 'test.jar.jpg' => 'image/jpeg',
+ 'test.jpg.jar' => 'application/java-archive',
+ 'test.pcf.z' => 'application/octet-stream',
+ 'pcf.z' => 'application/octet-stream',
+ 'jar' => 'application/octet-stream',
+ 'some.junk' => 'application/octet-stream',
+ 'foo.file_test_1' => 'application/octet-stream',
+ 'foo.file_test_2' => 'application/octet-stream',
+ 'foo.doc' => 'application/octet-stream',
+ 'test.ogg' => 'application/octet-stream',
+ );
+
+ foreach ($test_case as $input => $expected) {
+ $output = file_get_mimetype($input, $mapping);
+ $this->assertIdentical($output, $expected, t('Mimetype (using passed-in mappings) for %input is %output (expected: %expected).', array('%input' => $input, '%output' => $output, '%expected' => $expected)));
+ }
+ }
+}
+
+/**
+ * Tests stream wrapper functions.
+ */
+class StreamWrapperTest extends DrupalWebTestCase {
+
+ protected $scheme = 'dummy';
+ protected $classname = 'DrupalDummyStreamWrapper';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Stream wrappers',
+ 'description' => 'Tests stream wrapper functions.',
+ 'group' => 'File API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('file_test');
+ drupal_static_reset('file_get_stream_wrappers');
+ }
+
+ function tearDown() {
+ parent::tearDown();
+ stream_wrapper_unregister($this->scheme);
+ }
+
+ /**
+ * Test the getClassName() function.
+ */
+ function testGetClassName() {
+ // Check the dummy scheme.
+ $this->assertEqual($this->classname, file_stream_wrapper_get_class($this->scheme), t('Got correct class name for dummy scheme.'));
+ // Check core's scheme.
+ $this->assertEqual('DrupalPublicStreamWrapper', file_stream_wrapper_get_class('public'), t('Got correct class name for public scheme.'));
+ }
+
+ /**
+ * Test the file_stream_wrapper_get_instance_by_scheme() function.
+ */
+ function testGetInstanceByScheme() {
+ $instance = file_stream_wrapper_get_instance_by_scheme($this->scheme);
+ $this->assertEqual($this->classname, get_class($instance), t('Got correct class type for dummy scheme.'));
+
+ $instance = file_stream_wrapper_get_instance_by_scheme('public');
+ $this->assertEqual('DrupalPublicStreamWrapper', get_class($instance), t('Got correct class type for public scheme.'));
+ }
+
+ /**
+ * Test the URI and target functions.
+ */
+ function testUriFunctions() {
+ $instance = file_stream_wrapper_get_instance_by_uri($this->scheme . '://foo');
+ $this->assertEqual($this->classname, get_class($instance), t('Got correct class type for dummy URI.'));
+
+ $instance = file_stream_wrapper_get_instance_by_uri('public://foo');
+ $this->assertEqual('DrupalPublicStreamWrapper', get_class($instance), t('Got correct class type for public URI.'));
+
+ // Test file_uri_target().
+ $this->assertEqual(file_uri_target('public://foo/bar.txt'), 'foo/bar.txt', t('Got a valid stream target from public://foo/bar.txt.'));
+ $this->assertFalse(file_uri_target('foo/bar.txt'), t('foo/bar.txt is not a valid stream.'));
+
+ // Test file_build_uri() and DrupalLocalStreamWrapper::getDirectoryPath().
+ $this->assertEqual(file_build_uri('foo/bar.txt'), 'public://foo/bar.txt', t('Expected scheme was added.'));
+ $this->assertEqual(file_stream_wrapper_get_instance_by_scheme('public')->getDirectoryPath(), variable_get('file_public_path'), t('Expected default directory path was returned.'));
+ $this->assertEqual(file_stream_wrapper_get_instance_by_scheme('temporary')->getDirectoryPath(), variable_get('file_temporary_path'), t('Expected temporary directory path was returned.'));
+
+ variable_set('file_default_scheme', 'private');
+ $this->assertEqual(file_build_uri('foo/bar.txt'), 'private://foo/bar.txt', t('Got a valid URI from foo/bar.txt.'));
+ }
+
+ /**
+ * Test the scheme functions.
+ */
+ function testGetValidStreamScheme() {
+ $this->assertEqual('foo', file_uri_scheme('foo://pork//chops'), t('Got the correct scheme from foo://asdf'));
+ $this->assertTrue(file_stream_wrapper_valid_scheme(file_uri_scheme('public://asdf')), t('Got a valid stream scheme from public://asdf'));
+ $this->assertFalse(file_stream_wrapper_valid_scheme(file_uri_scheme('foo://asdf')), t('Did not get a valid stream scheme from foo://asdf'));
+ }
+}
diff --git a/core/modules/simpletest/tests/file_test.info b/core/modules/simpletest/tests/file_test.info
new file mode 100644
index 000000000000..b71f2a028a91
--- /dev/null
+++ b/core/modules/simpletest/tests/file_test.info
@@ -0,0 +1,7 @@
+name = "File test"
+description = "Support module for file handling tests."
+package = Testing
+version = VERSION
+core = 8.x
+files[] = file_test.module
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/file_test.module b/core/modules/simpletest/tests/file_test.module
new file mode 100644
index 000000000000..b3c43e071bb4
--- /dev/null
+++ b/core/modules/simpletest/tests/file_test.module
@@ -0,0 +1,461 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the file tests.
+ *
+ * The caller is must call file_test_reset() to initializing this module before
+ * calling file_test_get_calls() or file_test_set_return().
+ */
+
+
+define('FILE_URL_TEST_CDN_1', 'http://cdn1.example.com');
+define('FILE_URL_TEST_CDN_2', 'http://cdn2.example.com');
+
+
+/**
+ * Implements hook_menu().
+ */
+function file_test_menu() {
+ $items['file-test/upload'] = array(
+ 'title' => 'Upload test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_file_test_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_stream_wrappers().
+ */
+function file_test_stream_wrappers() {
+ return array(
+ 'dummy' => array(
+ 'name' => t('Dummy files'),
+ 'class' => 'DrupalDummyStreamWrapper',
+ 'description' => t('Dummy wrapper for simpletest.'),
+ ),
+ 'dummy-remote' => array(
+ 'name' => t('Dummy files (remote)'),
+ 'class' => 'DrupalDummyRemoteStreamWrapper',
+ 'description' => t('Dummy wrapper for simpletest (remote).'),
+ ),
+ );
+}
+
+/**
+ * Form to test file uploads.
+ */
+function _file_test_form($form, &$form_state) {
+ $form['file_test_upload'] = array(
+ '#type' => 'file',
+ '#title' => t('Upload a file'),
+ );
+ $form['file_test_replace'] = array(
+ '#type' => 'select',
+ '#title' => t('Replace existing image'),
+ '#options' => array(
+ FILE_EXISTS_RENAME => t('Appends number until name is unique'),
+ FILE_EXISTS_REPLACE => t('Replace the existing file'),
+ FILE_EXISTS_ERROR => t('Fail with an error'),
+ ),
+ '#default_value' => FILE_EXISTS_RENAME,
+ );
+ $form['file_subdir'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subdirectory for test file'),
+ '#default_value' => '',
+ );
+
+ $form['extensions'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Allowed extensions.'),
+ '#default_value' => '',
+ );
+
+ $form['allow_all_extensions'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Allow all extensions?'),
+ '#default_value' => FALSE,
+ );
+
+ $form['is_image_file'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Is this an image file?'),
+ '#default_value' => TRUE,
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+ return $form;
+}
+
+/**
+ * Process the upload.
+ */
+function _file_test_form_submit(&$form, &$form_state) {
+ // Process the upload and perform validation. Note: we're using the
+ // form value for the $replace parameter.
+ if (!empty($form_state['values']['file_subdir'])) {
+ $destination = 'temporary://' . $form_state['values']['file_subdir'];
+ file_prepare_directory($destination, FILE_CREATE_DIRECTORY);
+ }
+ else {
+ $destination = FALSE;
+ }
+
+ // Setup validators.
+ $validators = array();
+ if ($form_state['values']['is_image_file']) {
+ $validators['file_validate_is_image'] = array();
+ }
+
+ if ($form_state['values']['allow_all_extensions']) {
+ $validators['file_validate_extensions'] = array();
+ }
+ elseif (!empty($form_state['values']['extensions'])) {
+ $validators['file_validate_extensions'] = array($form_state['values']['extensions']);
+ }
+
+ $file = file_save_upload('file_test_upload', $validators, $destination, $form_state['values']['file_test_replace']);
+ if ($file) {
+ $form_state['values']['file_test_upload'] = $file;
+ drupal_set_message(t('File @filepath was uploaded.', array('@filepath' => $file->uri)));
+ drupal_set_message(t('File name is @filename.', array('@filename' => $file->filename)));
+ drupal_set_message(t('File MIME type is @mimetype.', array('@mimetype' => $file->filemime)));
+ drupal_set_message(t('You WIN!'));
+ }
+ elseif ($file === FALSE) {
+ drupal_set_message(t('Epic upload FAIL!'), 'error');
+ }
+}
+
+
+/**
+ * Reset/initialize the history of calls to the file_* hooks.
+ *
+ * @see file_test_get_calls()
+ * @see file_test_reset()
+ */
+function file_test_reset() {
+ // Keep track of calls to these hooks
+ $results = array(
+ 'load' => array(),
+ 'validate' => array(),
+ 'download' => array(),
+ 'insert' => array(),
+ 'update' => array(),
+ 'copy' => array(),
+ 'move' => array(),
+ 'delete' => array(),
+ );
+ variable_set('file_test_results', $results);
+
+ // These hooks will return these values, see file_test_set_return().
+ $return = array(
+ 'validate' => array(),
+ 'download' => NULL,
+ );
+ variable_set('file_test_return', $return);
+}
+
+/**
+ * Get the arguments passed to invocation of a given hook since
+ * file_test_reset() was last called.
+ *
+ * @param $op
+ * One of the hook_file_* operations: 'load', 'validate', 'download',
+ * 'insert', 'update', 'copy', 'move', 'delete'.
+ *
+ * @return
+ * Array of the parameters passed to each call.
+ *
+ * @see _file_test_log_call()
+ * @see file_test_reset()
+ */
+function file_test_get_calls($op) {
+ $results = variable_get('file_test_results', array());
+ return $results[$op];
+}
+
+/**
+ * Get an array with the calls for all hooks.
+ *
+ * @return
+ * An array keyed by hook name ('load', 'validate', 'download', 'insert',
+ * 'update', 'copy', 'move', 'delete') with values being arrays of parameters
+ * passed to each call.
+ */
+function file_test_get_all_calls() {
+ return variable_get('file_test_results', array());
+}
+
+/**
+ * Store the values passed to a hook invocation.
+ *
+ * @param $op
+ * One of the hook_file_* operations: 'load', 'validate', 'download',
+ * 'insert', 'update', 'copy', 'move', 'delete'.
+ * @param $args
+ * Values passed to hook.
+ *
+ * @see file_test_get_calls()
+ * @see file_test_reset()
+ */
+function _file_test_log_call($op, $args) {
+ $results = variable_get('file_test_results', array());
+ $results[$op][] = $args;
+ variable_set('file_test_results', $results);
+}
+
+/**
+ * Load the appropriate return value.
+ *
+ * @param $op
+ * One of the hook_file_[validate,download] operations.
+ *
+ * @return
+ * Value set by file_test_set_return().
+ *
+ * @see file_test_set_return()
+ * @see file_test_reset()
+ */
+function _file_test_get_return($op) {
+ $return = variable_get('file_test_return', array($op => NULL));
+ return $return[$op];
+}
+
+/**
+ * Assign a return value for a given operation.
+ *
+ * @param $op
+ * One of the hook_file_[validate,download] operations.
+ * @param $value
+ * Value for the hook to return.
+ *
+ * @see _file_test_get_return()
+ * @see file_test_reset()
+ */
+function file_test_set_return($op, $value) {
+ $return = variable_get('file_test_return', array());
+ $return[$op] = $value;
+ variable_set('file_test_return', $return);
+}
+
+/**
+ * Implements hook_file_load().
+ */
+function file_test_file_load($files) {
+ foreach ($files as $file) {
+ _file_test_log_call('load', array($file));
+ // Assign a value on the object so that we can test that the $file is passed
+ // by reference.
+ $file->file_test['loaded'] = TRUE;
+ }
+}
+
+/**
+ * Implements hook_file_validate().
+ */
+function file_test_file_validate($file) {
+ _file_test_log_call('validate', array($file));
+ return _file_test_get_return('validate');
+}
+
+/**
+ * Implements hook_file_download().
+ */
+function file_test_file_download($uri) {
+ _file_test_log_call('download', array($uri));
+ return _file_test_get_return('download');
+}
+
+/**
+ * Implements hook_file_insert().
+ */
+function file_test_file_insert($file) {
+ _file_test_log_call('insert', array($file));
+}
+
+/**
+ * Implements hook_file_update().
+ */
+function file_test_file_update($file) {
+ _file_test_log_call('update', array($file));
+}
+
+/**
+ * Implements hook_file_copy().
+ */
+function file_test_file_copy($file, $source) {
+ _file_test_log_call('copy', array($file, $source));
+}
+
+/**
+ * Implements hook_file_move().
+ */
+function file_test_file_move($file, $source) {
+ _file_test_log_call('move', array($file, $source));
+}
+
+/**
+ * Implements hook_file_delete().
+ */
+function file_test_file_delete($file) {
+ _file_test_log_call('delete', array($file));
+}
+
+/**
+ * Implements hook_file_url_alter().
+ */
+function file_test_file_url_alter(&$uri) {
+ // Only run this hook when this variable is set. Otherwise, we'd have to add
+ // another hidden test module just for this hook.
+ $alter_mode = variable_get('file_test_hook_file_url_alter', FALSE);
+ if (!$alter_mode) {
+ return;
+ }
+ // Test alteration of file URLs to use a CDN.
+ elseif ($alter_mode == 'cdn') {
+ $cdn_extensions = array('css', 'js', 'gif', 'jpg', 'jpeg', 'png');
+
+ // Most CDNs don't support private file transfers without a lot of hassle,
+ // so don't support this in the common case.
+ $schemes = array('public');
+
+ $scheme = file_uri_scheme($uri);
+
+ // Only serve shipped files and public created files from the CDN.
+ if (!$scheme || in_array($scheme, $schemes)) {
+ // Shipped files.
+ if (!$scheme) {
+ $path = $uri;
+ }
+ // Public created files.
+ else {
+ $wrapper = file_stream_wrapper_get_instance_by_scheme($scheme);
+ $path = $wrapper->getDirectoryPath() . '/' . file_uri_target($uri);
+ }
+
+ // Clean up Windows paths.
+ $path = str_replace('\\', '/', $path);
+
+ // Serve files with one of the CDN extensions from CDN 1, all others from
+ // CDN 2.
+ $pathinfo = pathinfo($path);
+ if (array_key_exists('extension', $pathinfo) && in_array($pathinfo['extension'], $cdn_extensions)) {
+ $uri = FILE_URL_TEST_CDN_1 . '/' . $path;
+ }
+ else {
+ $uri = FILE_URL_TEST_CDN_2 . '/' . $path;
+ }
+ }
+ }
+ // Test alteration of file URLs to use root-relative URLs.
+ elseif ($alter_mode == 'root-relative') {
+ // Only serve shipped files and public created files with root-relative
+ // URLs.
+ $scheme = file_uri_scheme($uri);
+ if (!$scheme || $scheme == 'public') {
+ // Shipped files.
+ if (!$scheme) {
+ $path = $uri;
+ }
+ // Public created files.
+ else {
+ $wrapper = file_stream_wrapper_get_instance_by_scheme($scheme);
+ $path = $wrapper->getDirectoryPath() . '/' . file_uri_target($uri);
+ }
+
+ // Clean up Windows paths.
+ $path = str_replace('\\', '/', $path);
+
+ // Generate a root-relative URL.
+ $uri = base_path() . '/' . $path;
+ }
+ }
+ // Test alteration of file URLs to use protocol-relative URLs.
+ elseif ($alter_mode == 'protocol-relative') {
+ // Only serve shipped files and public created files with protocol-relative
+ // URLs.
+ $scheme = file_uri_scheme($uri);
+ if (!$scheme || $scheme == 'public') {
+ // Shipped files.
+ if (!$scheme) {
+ $path = $uri;
+ }
+ // Public created files.
+ else {
+ $wrapper = file_stream_wrapper_get_instance_by_scheme($scheme);
+ $path = $wrapper->getDirectoryPath() . '/' . file_uri_target($uri);
+ }
+
+ // Clean up Windows paths.
+ $path = str_replace('\\', '/', $path);
+
+ // Generate a protocol-relative URL.
+ $uri = '/' . base_path() . '/' . $path;
+ }
+ }
+}
+
+/**
+ * Implements hook_file_mimetype_mapping_alter().
+ */
+function file_test_file_mimetype_mapping_alter(&$mapping) {
+ // Add new mappings.
+ $mapping['mimetypes']['file_test_mimetype_1'] = 'madeup/file_test_1';
+ $mapping['mimetypes']['file_test_mimetype_2'] = 'madeup/file_test_2';
+ $mapping['mimetypes']['file_test_mimetype_3'] = 'madeup/doc';
+ $mapping['extensions']['file_test_1'] = 'file_test_mimetype_1';
+ $mapping['extensions']['file_test_2'] = 'file_test_mimetype_2';
+ $mapping['extensions']['file_test_3'] = 'file_test_mimetype_2';
+ // Override existing mapping.
+ $mapping['extensions']['doc'] = 'file_test_mimetype_3';
+}
+
+/**
+ * Helper class for testing the stream wrapper registry.
+ *
+ * Dummy stream wrapper implementation (dummy://).
+ */
+class DrupalDummyStreamWrapper extends DrupalLocalStreamWrapper {
+ function getDirectoryPath() {
+ return variable_get('stream_public_path', 'sites/default/files');
+ }
+
+ /**
+ * Override getInternalUri().
+ *
+ * Return a dummy path for testing.
+ */
+ function getInternalUri() {
+ return '/dummy/example.txt';
+ }
+
+ /**
+ * Override getExternalUrl().
+ *
+ * Return the HTML URI of a public file.
+ */
+ function getExternalUrl() {
+ return '/dummy/example.txt';
+ }
+}
+
+/**
+ * Helper class for testing the stream wrapper registry.
+ *
+ * Dummy remote stream wrapper implementation (dummy-remote://).
+ *
+ * Basically just the public scheme but not returning a local file for realpath.
+ */
+class DrupalDummyRemoteStreamWrapper extends DrupalPublicStreamWrapper {
+ function realpath() {
+ return FALSE;
+ }
+}
diff --git a/core/modules/simpletest/tests/filetransfer.test b/core/modules/simpletest/tests/filetransfer.test
new file mode 100644
index 000000000000..905d23cab7f3
--- /dev/null
+++ b/core/modules/simpletest/tests/filetransfer.test
@@ -0,0 +1,168 @@
+<?php
+
+
+class FileTranferTest extends DrupalWebTestCase {
+ protected $hostname = 'localhost';
+ protected $username = 'drupal';
+ protected $password = 'password';
+ protected $port = '42';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'FileTransfer unit tests',
+ 'description' => 'Test that the jail is respected and that protocols using recursive file move operations work.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->testConnection = TestFileTransfer::factory(DRUPAL_ROOT, array('hostname' => $this->hostname, 'username' => $this->username, 'password' => $this->password, 'port' => $this->port));
+ }
+
+ function _getFakeModuleFiles() {
+ $files = array(
+ 'fake.module',
+ 'fake.info',
+ 'theme' => array(
+ 'fake.tpl.php'
+ ),
+ 'inc' => array(
+ 'fake.inc'
+ )
+ );
+ return $files;
+ }
+
+ function _buildFakeModule() {
+ $location = 'temporary://fake';
+ if (is_dir($location)) {
+ $ret = 0;
+ $output = array();
+ exec('rm -Rf ' . escapeshellarg($location), $output, $ret);
+ if ($ret != 0) {
+ throw new Exception('Error removing fake module directory.');
+ }
+ }
+
+ $files = $this->_getFakeModuleFiles();
+ $this->_writeDirectory($location, $files);
+ return $location;
+ }
+
+ function _writeDirectory($base, $files = array()) {
+ mkdir($base);
+ foreach ($files as $key => $file) {
+ if (is_array($file)) {
+ $this->_writeDirectory($base . DIRECTORY_SEPARATOR . $key, $file);
+ }
+ else {
+ //just write the filename into the file
+ file_put_contents($base . DIRECTORY_SEPARATOR . $file, $file);
+ }
+ }
+ }
+
+ function testJail() {
+ $source = $this->_buildFakeModule();
+
+ // This convoluted piece of code is here because our testing framework does
+ // not support expecting exceptions.
+ $gotit = FALSE;
+ try {
+ $this->testConnection->copyDirectory($source, '/tmp');
+ }
+ catch (FileTransferException $e) {
+ $gotit = TRUE;
+ }
+ $this->assertTrue($gotit, 'Was not able to copy a directory outside of the jailed area.');
+
+ $gotit = TRUE;
+ try {
+ $this->testConnection->copyDirectory($source, DRUPAL_ROOT . '/'. variable_get('file_public_path', conf_path() . '/files'));
+ }
+ catch (FileTransferException $e) {
+ $gotit = FALSE;
+ }
+ $this->assertTrue($gotit, 'Was able to copy a directory inside of the jailed area');
+ }
+}
+
+/**
+ * Mock FileTransfer object for test case.
+ */
+class TestFileTransfer extends FileTransfer {
+ protected $host = NULL;
+ protected $username = NULL;
+ protected $password = NULL;
+ protected $port = NULL;
+
+ /**
+ * This is for testing the CopyRecursive logic.
+ */
+ public $shouldIsDirectoryReturnTrue = FALSE;
+
+ function __construct($jail, $username, $password, $hostname = 'localhost', $port = 9999) {
+ parent::__construct($jail, $username, $password, $hostname, $port);
+ }
+
+ static function factory($jail, $settings) {
+ return new TestFileTransfer($jail, $settings['username'], $settings['password'], $settings['hostname'], $settings['port']);
+ }
+
+ function connect() {
+ $parts = explode(':', $this->hostname);
+ $port = (count($parts) == 2) ? $parts[1] : $this->port;
+ $this->connection = new MockTestConnection();
+ $this->connection->connectionString = 'test://' . urlencode($this->username) . ':' . urlencode($this->password) . "@$this->host:$this->port/";
+ }
+
+ function copyFileJailed($source, $destination) {
+ $this->connection->run("copyFile $source $destination");
+ }
+
+ protected function removeDirectoryJailed($directory) {
+ $this->connection->run("rmdir $directory");
+ }
+
+ function createDirectoryJailed($directory) {
+ $this->connection->run("mkdir $directory");
+ }
+
+ function removeFileJailed($destination) {
+ if (!ftp_delete($this->connection, $item)) {
+ throw new FileTransferException('Unable to remove to file @file.', NULL, array('@file' => $item));
+ }
+ }
+
+ function isDirectory($path) {
+ return $this->shouldIsDirectoryReturnTrue;
+ }
+
+ function isFile($path) {
+ return FALSE;
+ }
+
+ function chmodJailed($path, $mode, $recursive) {
+ return;
+ }
+}
+
+/**
+ * Mock connection object for test case.
+ */
+class MockTestConnection {
+
+ var $commandsRun = array();
+ var $connectionString;
+
+ function run($cmd) {
+ $this->commandsRun[] = $cmd;
+ }
+
+ function flushCommands() {
+ $out = $this->commandsRun;
+ $this->commandsRun = array();
+ return $out;
+ }
+}
diff --git a/core/modules/simpletest/tests/filter_test.info b/core/modules/simpletest/tests/filter_test.info
new file mode 100644
index 000000000000..ee27c29d13d2
--- /dev/null
+++ b/core/modules/simpletest/tests/filter_test.info
@@ -0,0 +1,6 @@
+name = Filter test module
+description = Tests filter hooks and functions.
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/filter_test.module b/core/modules/simpletest/tests/filter_test.module
new file mode 100644
index 000000000000..2cebc7085d0e
--- /dev/null
+++ b/core/modules/simpletest/tests/filter_test.module
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * @file
+ * Test module for Filter module hooks and functions not used in core.
+ */
+
+/**
+ * Implements hook_filter_format_insert().
+ */
+function filter_test_filter_format_insert($format) {
+ drupal_set_message('hook_filter_format_insert invoked.');
+}
+
+/**
+ * Implements hook_filter_format_update().
+ */
+function filter_test_filter_format_update($format) {
+ drupal_set_message('hook_filter_format_update invoked.');
+}
+
+/**
+ * Implements hook_filter_format_disable().
+ */
+function filter_test_filter_format_disable($format) {
+ drupal_set_message('hook_filter_format_disable invoked.');
+}
+
+/**
+ * Implements hook_filter_info().
+ */
+function filter_test_filter_info() {
+ $filters['filter_test_uncacheable'] = array(
+ 'title' => 'Uncacheable filter',
+ 'description' => 'Does nothing, but makes a text format uncacheable.',
+ 'cache' => FALSE,
+ );
+ $filters['filter_test_replace'] = array(
+ 'title' => 'Testing filter',
+ 'description' => 'Replaces all content with filter and text format information.',
+ 'process callback' => 'filter_test_replace',
+ );
+ return $filters;
+}
+
+/**
+ * Process handler for filter_test_replace filter.
+ *
+ * Replaces all text with filter and text format information.
+ */
+function filter_test_replace($text, $filter, $format, $langcode, $cache, $cache_id) {
+ $text = array();
+ $text[] = 'Filter: ' . $filter->title . ' (' . $filter->name . ')';
+ $text[] = 'Format: ' . $format->name . ' (' . $format->format . ')';
+ $text[] = 'Language: ' . $langcode;
+ $text[] = 'Cache: ' . ($cache ? 'Enabled' : 'Disabled');
+ if ($cache_id) {
+ $text[] = 'Cache ID: ' . $cache_id;
+ }
+ return implode("<br />\n", $text);
+}
+
diff --git a/core/modules/simpletest/tests/form.test b/core/modules/simpletest/tests/form.test
new file mode 100644
index 000000000000..1508f4c8b481
--- /dev/null
+++ b/core/modules/simpletest/tests/form.test
@@ -0,0 +1,1574 @@
+<?php
+
+/**
+ * @file
+ * Unit tests for the Drupal Form API.
+ */
+
+class FormsTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form element validation',
+ 'description' => 'Tests various form element validation mechanisms.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Check several empty values for required forms elements.
+ *
+ * Carriage returns, tabs, spaces, and unchecked checkbox elements are not
+ * valid content for a required field.
+ *
+ * If the form field is found in form_get_errors() then the test pass.
+ */
+ function testRequiredFields() {
+ // Originates from http://drupal.org/node/117748
+ // Sets of empty strings and arrays.
+ $empty_strings = array('""' => "", '"\n"' => "\n", '" "' => " ", '"\t"' => "\t", '" \n\t "' => " \n\t ", '"\n\n\n\n\n"' => "\n\n\n\n\n");
+ $empty_arrays = array('array()' => array());
+ $empty_checkbox = array(NULL);
+
+ $elements['textfield']['element'] = array('#title' => $this->randomName(), '#type' => 'textfield');
+ $elements['textfield']['empty_values'] = $empty_strings;
+
+ $elements['password']['element'] = array('#title' => $this->randomName(), '#type' => 'password');
+ $elements['password']['empty_values'] = $empty_strings;
+
+ $elements['password_confirm']['element'] = array('#title' => $this->randomName(), '#type' => 'password_confirm');
+ // Provide empty values for both password fields.
+ foreach ($empty_strings as $key => $value) {
+ $elements['password_confirm']['empty_values'][$key] = array('pass1' => $value, 'pass2' => $value);
+ }
+
+ $elements['textarea']['element'] = array('#title' => $this->randomName(), '#type' => 'textarea');
+ $elements['textarea']['empty_values'] = $empty_strings;
+
+ $elements['radios']['element'] = array('#title' => $this->randomName(), '#type' => 'radios', '#options' => array('' => t('None'), $this->randomName(), $this->randomName(), $this->randomName()));
+ $elements['radios']['empty_values'] = $empty_arrays;
+
+ $elements['checkbox']['element'] = array('#title' => $this->randomName(), '#type' => 'checkbox', '#required' => TRUE, '#title' => $this->randomName());
+ $elements['checkbox']['empty_values'] = $empty_checkbox;
+
+ $elements['checkboxes']['element'] = array('#title' => $this->randomName(), '#type' => 'checkboxes', '#options' => array($this->randomName(), $this->randomName(), $this->randomName()));
+ $elements['checkboxes']['empty_values'] = $empty_arrays;
+
+ $elements['select']['element'] = array('#title' => $this->randomName(), '#type' => 'select', '#options' => array('' => t('None'), $this->randomName(), $this->randomName(), $this->randomName()));
+ $elements['select']['empty_values'] = $empty_strings;
+
+ $elements['file']['element'] = array('#title' => $this->randomName(), '#type' => 'file');
+ $elements['file']['empty_values'] = $empty_strings;
+
+ // Regular expression to find the expected marker on required elements.
+ $required_marker_preg = '@<label.*<abbr class="form-required" title="This field is required\.">\*</abbr></label>@';
+
+ // Go through all the elements and all the empty values for them.
+ foreach ($elements as $type => $data) {
+ foreach ($data['empty_values'] as $key => $empty) {
+ foreach (array(TRUE, FALSE) as $required) {
+ $form_id = $this->randomName();
+ $form = array();
+ $form_state = form_state_defaults();
+ form_clear_error();
+ $form['op'] = array('#type' => 'submit', '#value' => t('Submit'));
+ $element = $data['element']['#title'];
+ $form[$element] = $data['element'];
+ $form[$element]['#required'] = $required;
+ $form_state['input'][$element] = $empty;
+ $form_state['input']['form_id'] = $form_id;
+ $form_state['method'] = 'post';
+ drupal_prepare_form($form_id, $form, $form_state);
+ drupal_process_form($form_id, $form, $form_state);
+ $errors = form_get_errors();
+ // Form elements of type 'radios' throw all sorts of PHP notices
+ // when you try to render them like this, so we ignore those for
+ // testing the required marker.
+ // @todo Fix this work-around (http://drupal.org/node/588438).
+ $form_output = ($type == 'radios') ? '' : drupal_render($form);
+ if ($required) {
+ // Make sure we have a form error for this element.
+ $this->assertTrue(isset($errors[$element]), "Check empty($key) '$type' field '$element'");
+ if (!empty($form_output)) {
+ // Make sure the form element is marked as required.
+ $this->assertTrue(preg_match($required_marker_preg, $form_output), "Required '$type' field is marked as required");
+ }
+ }
+ else {
+ if (!empty($form_output)) {
+ // Make sure the form element is *not* marked as required.
+ $this->assertFalse(preg_match($required_marker_preg, $form_output), "Optional '$type' field is not marked as required");
+ }
+ if ($type == 'select') {
+ // Select elements are going to have validation errors with empty
+ // input, since those are illegal choices. Just make sure the
+ // error is not "field is required".
+ $this->assertTrue((empty($errors[$element]) || strpos('field is required', $errors[$element]) === FALSE), "Optional '$type' field '$element' is not treated as a required element");
+ }
+ else {
+ // Make sure there is *no* form error for this element.
+ $this->assertTrue(empty($errors[$element]), "Optional '$type' field '$element' has no errors with empty input");
+ }
+ }
+ }
+ }
+ }
+ // Clear the expected form error messages so they don't appear as exceptions.
+ drupal_get_messages();
+ }
+
+ /**
+ * Test default value handling for checkboxes.
+ *
+ * @see _form_test_checkbox()
+ */
+ function testCheckboxProcessing() {
+ // First, try to submit without the required checkbox.
+ $edit = array();
+ $this->drupalPost('form-test/checkbox', $edit, t('Submit'));
+ $this->assertRaw(t('!name field is required.', array('!name' => 'required_checkbox')), t('A required checkbox is actually mandatory'));
+
+ // Now try to submit the form correctly.
+ $values = drupal_json_decode($this->drupalPost(NULL, array('required_checkbox' => 1), t('Submit')));
+ $expected_values = array(
+ 'disabled_checkbox_on' => 'disabled_checkbox_on',
+ 'disabled_checkbox_off' => '',
+ 'checkbox_on' => 'checkbox_on',
+ 'checkbox_off' => '',
+ 'zero_checkbox_on' => '0',
+ 'zero_checkbox_off' => '',
+ );
+ foreach ($expected_values as $widget => $expected_value) {
+ $this->assertEqual($values[$widget], $expected_value, t('Checkbox %widget returns expected value (expected: %expected, got: %value)', array(
+ '%widget' => var_export($widget, TRUE),
+ '%expected' => var_export($expected_value, TRUE),
+ '%value' => var_export($values[$widget], TRUE),
+ )));
+ }
+ }
+
+ /**
+ * Tests validation of #type 'select' elements.
+ */
+ function testSelect() {
+ $form = $form_state = array();
+ $form = form_test_select($form, $form_state);
+ $error = '!name field is required.';
+ $this->drupalGet('form-test/select');
+
+ // Posting without any values should throw validation errors.
+ $this->drupalPost(NULL, array(), 'Submit');
+ $this->assertNoText(t($error, array('!name' => $form['select']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['select_required']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['select_optional']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['empty_value']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['empty_value_one']['#title'])));
+ $this->assertText(t($error, array('!name' => $form['no_default']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['no_default_optional']['#title'])));
+ $this->assertText(t($error, array('!name' => $form['no_default_empty_option']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['no_default_empty_option_optional']['#title'])));
+ $this->assertText(t($error, array('!name' => $form['no_default_empty_value']['#title'])));
+ $this->assertText(t($error, array('!name' => $form['no_default_empty_value_one']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['no_default_empty_value_optional']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['multiple']['#title'])));
+ $this->assertNoText(t($error, array('!name' => $form['multiple_no_default']['#title'])));
+ $this->assertText(t($error, array('!name' => $form['multiple_no_default_required']['#title'])));
+
+ // Post values for required fields.
+ $edit = array(
+ 'no_default' => 'three',
+ 'no_default_empty_option' => 'three',
+ 'no_default_empty_value' => 'three',
+ 'no_default_empty_value_one' => 'three',
+ 'multiple_no_default_required[]' => 'three',
+ );
+ $this->drupalPost(NULL, $edit, 'Submit');
+ $values = drupal_json_decode($this->drupalGetContent());
+
+ // Verify expected values.
+ $expected = array(
+ 'select' => 'one',
+ 'empty_value' => 'one',
+ 'empty_value_one' => 'one',
+ 'no_default' => 'three',
+ 'no_default_optional' => 'one',
+ 'no_default_optional_empty_value' => '',
+ 'no_default_empty_option' => 'three',
+ 'no_default_empty_option_optional' => '',
+ 'no_default_empty_value' => 'three',
+ 'no_default_empty_value_one' => 'three',
+ 'no_default_empty_value_optional' => 0,
+ 'multiple' => array('two' => 'two'),
+ 'multiple_no_default' => array(),
+ 'multiple_no_default_required' => array('three' => 'three'),
+ );
+ foreach ($expected as $key => $value) {
+ $this->assertIdentical($values[$key], $value, t('@name: @actual is equal to @expected.', array(
+ '@name' => $key,
+ '@actual' => var_export($values[$key], TRUE),
+ '@expected' => var_export($value, TRUE),
+ )));
+ }
+ }
+
+ /**
+ * Test handling of disabled elements.
+ *
+ * @see _form_test_disabled_elements()
+ */
+ function testDisabledElements() {
+ // Get the raw form in its original state.
+ $form_state = array();
+ $form = _form_test_disabled_elements(array(), $form_state);
+
+ // Build a submission that tries to hijack the form by submitting input for
+ // elements that are disabled.
+ $edit = array();
+ foreach (element_children($form) as $key) {
+ if (isset($form[$key]['#test_hijack_value'])) {
+ if (is_array($form[$key]['#test_hijack_value'])) {
+ foreach ($form[$key]['#test_hijack_value'] as $subkey => $value) {
+ $edit[$key . '[' . $subkey . ']'] = $value;
+ }
+ }
+ else {
+ $edit[$key] = $form[$key]['#test_hijack_value'];
+ }
+ }
+ }
+
+ // Submit the form with no input, as the browser does for disabled elements,
+ // and fetch the $form_state['values'] that is passed to the submit handler.
+ $this->drupalPost('form-test/disabled-elements', array(), t('Submit'));
+ $returned_values['normal'] = drupal_json_decode($this->content);
+
+ // Do the same with input, as could happen if JavaScript un-disables an
+ // element. drupalPost() emulates a browser by not submitting input for
+ // disabled elements, so we need to un-disable those elements first.
+ $this->drupalGet('form-test/disabled-elements');
+ $disabled_elements = array();
+ foreach ($this->xpath('//*[@disabled]') as $element) {
+ $disabled_elements[] = (string) $element['name'];
+ unset($element['disabled']);
+ }
+
+ // All the elements should be marked as disabled, including the ones below
+ // the disabled container.
+ $this->assertEqual(count($disabled_elements), 32, t('The correct elements have the disabled property in the HTML code.'));
+
+ $this->drupalPost(NULL, $edit, t('Submit'));
+ $returned_values['hijacked'] = drupal_json_decode($this->content);
+
+ // Ensure that the returned values match the form's default values in both
+ // cases.
+ foreach ($returned_values as $type => $values) {
+ $this->assertFormValuesDefault($values, $form);
+ }
+ }
+
+ /**
+ * Assert that the values submitted to a form matches the default values of the elements.
+ */
+ function assertFormValuesDefault($values, $form) {
+ foreach (element_children($form) as $key) {
+ if (isset($form[$key]['#default_value'])) {
+ if (isset($form[$key]['#expected_value'])) {
+ $expected_value = $form[$key]['#expected_value'];
+ }
+ else {
+ $expected_value = $form[$key]['#default_value'];
+ }
+
+ if ($key == 'checkboxes_multiple') {
+ // Checkboxes values are not filtered out.
+ $values[$key] = array_filter($values[$key]);
+ }
+ $this->assertIdentical($expected_value, $values[$key], t('Default value for %type: expected %expected, returned %returned.', array('%type' => $key, '%expected' => var_export($expected_value, TRUE), '%returned' => var_export($values[$key], TRUE))));
+ }
+
+ // Recurse children.
+ $this->assertFormValuesDefault($values, $form[$key]);
+ }
+ }
+
+ /**
+ * Verify markup for disabled form elements.
+ *
+ * @see _form_test_disabled_elements()
+ */
+ function testDisabledMarkup() {
+ $this->drupalGet('form-test/disabled-elements');
+ $form_state = array();
+ $form = _form_test_disabled_elements(array(), $form_state);
+ $type_map = array(
+ 'textarea' => 'textarea',
+ 'select' => 'select',
+ 'weight' => 'select',
+ 'date' => 'select',
+ );
+
+ foreach ($form as $name => $item) {
+ // Skip special #types.
+ if (!isset($item['#type']) || in_array($item['#type'], array('hidden', 'text_format'))) {
+ continue;
+ }
+ // Setup XPath and CSS class depending on #type.
+ if (in_array($item['#type'], array('image_button', 'button', 'submit'))) {
+ $path = "//!type[contains(@class, :div-class) and @value=:value]";
+ $class = 'form-button-disabled';
+ }
+ else {
+ // starts-with() required for checkboxes.
+ $path = "//div[contains(@class, :div-class)]/descendant::!type[starts-with(@name, :name)]";
+ $class = 'form-disabled';
+ }
+ // Replace DOM element name in $path according to #type.
+ $type = 'input';
+ if (isset($type_map[$item['#type']])) {
+ $type = $type_map[$item['#type']];
+ }
+ $path = strtr($path, array('!type' => $type));
+ // Verify that the element exists.
+ $element = $this->xpath($path, array(
+ ':name' => check_plain($name),
+ ':div-class' => $class,
+ ':value' => isset($item['#value']) ? $item['#value'] : '',
+ ));
+ $this->assertTrue(isset($element[0]), t('Disabled form element class found for #type %type.', array('%type' => $item['#type'])));
+ }
+
+ // Verify special element #type text-format.
+ $element = $this->xpath('//div[contains(@class, :div-class)]/descendant::textarea[@name=:name]', array(
+ ':name' => 'text_format[value]',
+ ':div-class' => 'form-disabled',
+ ));
+ $this->assertTrue(isset($element[0]), t('Disabled form element class found for #type %type.', array('%type' => 'text_format[value]')));
+ $element = $this->xpath('//div[contains(@class, :div-class)]/descendant::select[@name=:name]', array(
+ ':name' => 'text_format[format]',
+ ':div-class' => 'form-disabled',
+ ));
+ $this->assertTrue(isset($element[0]), t('Disabled form element class found for #type %type.', array('%type' => 'text_format[format]')));
+ }
+
+ /**
+ * Test Form API protections against input forgery.
+ *
+ * @see _form_test_input_forgery()
+ */
+ function testInputForgery() {
+ $this->drupalGet('form-test/input-forgery');
+ $checkbox = $this->xpath('//input[@name="checkboxes[two]"]');
+ $checkbox[0]['value'] = 'FORGERY';
+ $this->drupalPost(NULL, array('checkboxes[one]' => TRUE, 'checkboxes[two]' => TRUE), t('Submit'));
+ $this->assertText('An illegal choice has been detected.', t('Input forgery was detected.'));
+ }
+}
+
+/**
+ * Tests building and processing of core form elements.
+ */
+class FormElementTestCase extends DrupalWebTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Element processing',
+ 'description' => 'Tests building and processing of core form elements.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('form_test'));
+ }
+
+ /**
+ * Tests placeholder text for textfield, password, and textarea.
+ */
+ function testPlaceHolderText() {
+ $this->drupalGet('form-test/placeholder-text');
+ $expected = 'placeholder-text';
+ // Test to make sure textfields and passwords have the proper placeholder
+ // text.
+ foreach (array('textfield', 'password') as $type) {
+ $element = $this->xpath('//input[@id=:id and @placeholder=:expected]', array(
+ ':id' => 'edit-' . $type,
+ ':expected' => $expected,
+ ));
+ $this->assertTrue(!empty($element), t('Placeholder text placed in @type.', array('@type' => $type)));
+ }
+
+ // Test to make sure textarea has the proper placeholder text.
+ $element = $this->xpath('//textarea[@id=:id and @placeholder=:expected]', array(
+ ':id' => 'edit-textarea',
+ ':expected' => $expected,
+ ));
+ $this->assertTrue(!empty($element), t('Placeholder text placed in textarea.'));
+ }
+
+ /**
+ * Tests expansion of #options for #type checkboxes and radios.
+ */
+ function testOptions() {
+ $this->drupalGet('form-test/checkboxes-radios');
+
+ // Verify that all options appear in their defined order.
+ foreach (array('checkbox', 'radio') as $type) {
+ $elements = $this->xpath('//input[@type=:type]', array(':type' => $type));
+ $expected_values = array('0', 'foo', '1', 'bar', '>');
+ foreach ($elements as $element) {
+ $expected = array_shift($expected_values);
+ $this->assertIdentical((string) $element['value'], $expected);
+ }
+ }
+
+ // Enable customized option sub-elements.
+ $this->drupalGet('form-test/checkboxes-radios/customize');
+
+ // Verify that all options appear in their defined order, taking a custom
+ // #weight into account.
+ foreach (array('checkbox', 'radio') as $type) {
+ $elements = $this->xpath('//input[@type=:type]', array(':type' => $type));
+ $expected_values = array('0', 'foo', 'bar', '>', '1');
+ foreach ($elements as $element) {
+ $expected = array_shift($expected_values);
+ $this->assertIdentical((string) $element['value'], $expected);
+ }
+ }
+ // Verify that custom #description properties are output.
+ foreach (array('checkboxes', 'radios') as $type) {
+ $elements = $this->xpath('//input[@id=:id]/following-sibling::div[@class=:class]', array(
+ ':id' => 'edit-' . $type . '-foo',
+ ':class' => 'description',
+ ));
+ $this->assertTrue(count($elements), t('Custom %type option description found.', array(
+ '%type' => $type,
+ )));
+ }
+ }
+}
+
+/**
+ * Test form alter hooks.
+ */
+class FormAlterTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form alter hooks',
+ 'description' => 'Tests hook_form_alter() and hook_form_FORM_ID_alter().',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Tests execution order of hook_form_alter() and hook_form_FORM_ID_alter().
+ */
+ function testExecutionOrder() {
+ $this->drupalGet('form-test/alter');
+ // Ensure that the order is first by module, then for a given module, the
+ // id-specific one after the generic one.
+ $expected = array(
+ 'block_form_form_test_alter_form_alter() executed.',
+ 'form_test_form_alter() executed.',
+ 'form_test_form_form_test_alter_form_alter() executed.',
+ 'system_form_form_test_alter_form_alter() executed.',
+ );
+ $content = preg_replace('/\s+/', ' ', filter_xss($this->content, array()));
+ $this->assert(strpos($content, implode(' ', $expected)) !== FALSE, t('Form alter hooks executed in the expected order.'));
+ }
+}
+
+/**
+ * Test form validation handlers.
+ */
+class FormValidationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form validation handlers',
+ 'description' => 'Tests form processing and alteration via form validation handlers.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Tests form alterations by #element_validate, #validate, and form_set_value().
+ */
+ function testValidate() {
+ $this->drupalGet('form-test/validate');
+ // Verify that #element_validate handlers can alter the form and submitted
+ // form values.
+ $edit = array(
+ 'name' => 'element_validate',
+ );
+ $this->drupalPost(NULL, $edit, 'Save');
+ $this->assertFieldByName('name', '#value changed by #element_validate', t('Form element #value was altered.'));
+ $this->assertText('Name value: value changed by form_set_value() in #element_validate', t('Form element value in $form_state was altered.'));
+
+ // Verify that #validate handlers can alter the form and submitted
+ // form values.
+ $edit = array(
+ 'name' => 'validate',
+ );
+ $this->drupalPost(NULL, $edit, 'Save');
+ $this->assertFieldByName('name', '#value changed by #validate', t('Form element #value was altered.'));
+ $this->assertText('Name value: value changed by form_set_value() in #validate', t('Form element value in $form_state was altered.'));
+
+ // Verify that #element_validate handlers can make form elements
+ // inaccessible, but values persist.
+ $edit = array(
+ 'name' => 'element_validate_access',
+ );
+ $this->drupalPost(NULL, $edit, 'Save');
+ $this->assertNoFieldByName('name', t('Form element was hidden.'));
+ $this->assertText('Name value: element_validate_access', t('Value for inaccessible form element exists.'));
+
+ // Verify that value for inaccessible form element persists.
+ $this->drupalPost(NULL, array(), 'Save');
+ $this->assertNoFieldByName('name', t('Form element was hidden.'));
+ $this->assertText('Name value: element_validate_access', t('Value for inaccessible form element exists.'));
+ }
+
+ /**
+ * Tests partial form validation through #limit_validation_errors.
+ */
+ function testValidateLimitErrors() {
+ $edit = array(
+ 'test' => 'invalid',
+ 'test_numeric_index[0]' => 'invalid',
+ 'test_substring[foo]' => 'invalid',
+ );
+ $path = 'form-test/limit-validation-errors';
+
+ // Submit the form by pressing the 'Partial validate' button (uses
+ // #limit_validation_errors) and ensure that the title field is not
+ // validated, but the #element_validate handler for the 'test' field
+ // is triggered.
+ $this->drupalPost($path, $edit, t('Partial validate'));
+ $this->assertNoText(t('!name field is required.', array('!name' => 'Title')));
+ $this->assertText('Test element is invalid');
+
+ // Edge case of #limit_validation_errors containing numeric indexes: same
+ // thing with the 'Partial validate (numeric index)' button and the
+ // 'test_numeric_index' field.
+ $this->drupalPost($path, $edit, t('Partial validate (numeric index)'));
+ $this->assertNoText(t('!name field is required.', array('!name' => 'Title')));
+ $this->assertText('Test (numeric index) element is invalid');
+
+ // Ensure something like 'foobar' isn't considered "inside" 'foo'.
+ $this->drupalPost($path, $edit, t('Partial validate (substring)'));
+ $this->assertNoText(t('!name field is required.', array('!name' => 'Title')));
+ $this->assertText('Test (substring) foo element is invalid');
+
+ // Ensure not validated values are not available to submit handlers.
+ $this->drupalPost($path, array('title' => '', 'test' => 'valid'), t('Partial validate'));
+ $this->assertText('Only validated values appear in the form values.');
+
+ // Now test full form validation and ensure that the #element_validate
+ // handler is still triggered.
+ $this->drupalPost($path, $edit, t('Full validate'));
+ $this->assertText(t('!name field is required.', array('!name' => 'Title')));
+ $this->assertText('Test element is invalid');
+ }
+}
+
+/**
+ * Test form element labels, required markers and associated output.
+ */
+class FormsElementsLabelsTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form element and label output test',
+ 'description' => 'Test form element labels, required markers and associated output.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Test form elements, labels, title attibutes and required marks output
+ * correctly and have the correct label option class if needed.
+ */
+ function testFormLabels() {
+ $this->drupalGet('form_test/form-labels');
+
+ // Check that the checkbox/radio processing is not interfering with
+ // basic placement.
+ $elements = $this->xpath('//input[@id="edit-form-checkboxes-test-third-checkbox"]/following-sibling::label[@for="edit-form-checkboxes-test-third-checkbox" and @class="option"]');
+ $this->assertTrue(isset($elements[0]), t("Label follows field and label option class correct for regular checkboxes."));
+
+ $elements = $this->xpath('//input[@id="edit-form-radios-test-second-radio"]/following-sibling::label[@for="edit-form-radios-test-second-radio" and @class="option"]');
+ $this->assertTrue(isset($elements[0]), t("Label follows field and label option class correct for regular radios."));
+
+ // Exercise various defaults for checkboxes and modifications to ensure
+ // appropriate override and correct behaviour.
+ $elements = $this->xpath('//input[@id="edit-form-checkbox-test"]/following-sibling::label[@for="edit-form-checkbox-test" and @class="option"]');
+ $this->assertTrue(isset($elements[0]), t("Label follows field and label option class correct for a checkbox by default."));
+
+ // Exercise various defaults for textboxes and modifications to ensure
+ // appropriate override and correct behaviour.
+ $elements = $this->xpath('//label[@for="edit-form-textfield-test-title-and-required"]/child::abbr[@class="form-required"]/parent::*/following-sibling::input[@id="edit-form-textfield-test-title-and-required"]');
+ $this->assertTrue(isset($elements[0]), t("Label precedes textfield, with required marker inside label."));
+
+ $elements = $this->xpath('//input[@id="edit-form-textfield-test-no-title-required"]/preceding-sibling::label[@for="edit-form-textfield-test-no-title-required"]/abbr[@class="form-required"]');
+ $this->assertTrue(isset($elements[0]), t("Label tag with required marker precedes required textfield with no title."));
+
+ $elements = $this->xpath('//input[@id="edit-form-textfield-test-title-invisible"]/preceding-sibling::label[@for="edit-form-textfield-test-title-invisible" and @class="element-invisible"]');
+ $this->assertTrue(isset($elements[0]), t("Label preceding field and label class is element-invisible."));
+
+ $elements = $this->xpath('//input[@id="edit-form-textfield-test-title"]/preceding-sibling::abbr[@class="form-required"]');
+ $this->assertFalse(isset($elements[0]), t("No required marker on non-required field."));
+
+ $elements = $this->xpath('//input[@id="edit-form-textfield-test-title-after"]/following-sibling::label[@for="edit-form-textfield-test-title-after" and @class="option"]');
+ $this->assertTrue(isset($elements[0]), t("Label after field and label option class correct for text field."));
+
+ $elements = $this->xpath('//label[@for="edit-form-textfield-test-title-no-show"]');
+ $this->assertFalse(isset($elements[0]), t("No label tag when title set not to display."));
+
+ // Check #field_prefix and #field_suffix placement.
+ $elements = $this->xpath('//span[@class="field-prefix"]/following-sibling::div[@id="edit-form-radios-test"]');
+ $this->assertTrue(isset($elements[0]), t("Properly placed the #field_prefix element after the label and before the field."));
+
+ $elements = $this->xpath('//span[@class="field-suffix"]/preceding-sibling::div[@id="edit-form-radios-test"]');
+ $this->assertTrue(isset($elements[0]), t("Properly places the #field_suffix element immediately after the form field."));
+
+ // Check #prefix and #suffix placement.
+ $elements = $this->xpath('//div[@id="form-test-textfield-title-prefix"]/following-sibling::div[contains(@class, \'form-item-form-textfield-test-title\')]');
+ $this->assertTrue(isset($elements[0]), t("Properly places the #prefix element before the form item."));
+
+ $elements = $this->xpath('//div[@id="form-test-textfield-title-suffix"]/preceding-sibling::div[contains(@class, \'form-item-form-textfield-test-title\')]');
+ $this->assertTrue(isset($elements[0]), t("Properly places the #suffix element before the form item."));
+ }
+}
+
+/**
+ * Test the tableselect form element for expected behavior.
+ */
+class FormsElementsTableSelectFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Tableselect form element type test',
+ 'description' => 'Test the tableselect element for expected behavior',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+
+ /**
+ * Test the display of checkboxes when #multiple is TRUE.
+ */
+ function testMultipleTrue() {
+
+ $this->drupalGet('form_test/tableselect/multiple-true');
+
+ $this->assertNoText(t('Empty text.'), t('Empty text should not be displayed.'));
+
+ // Test for the presence of the Select all rows tableheader.
+ $this->assertFieldByXPath('//th[@class="select-all"]', NULL, t('Presence of the "Select all" checkbox.'));
+
+ $rows = array('row1', 'row2', 'row3');
+ foreach ($rows as $row) {
+ $this->assertFieldByXPath('//input[@type="checkbox"]', $row, t('Checkbox for value @row.', array('@row' => $row)));
+ }
+ }
+
+ /**
+ * Test the display of radios when #multiple is FALSE.
+ */
+ function testMultipleFalse() {
+ $this->drupalGet('form_test/tableselect/multiple-false');
+
+ $this->assertNoText(t('Empty text.'), t('Empty text should not be displayed.'));
+
+ // Test for the absence of the Select all rows tableheader.
+ $this->assertNoFieldByXPath('//th[@class="select-all"]', '', t('Absence of the "Select all" checkbox.'));
+
+ $rows = array('row1', 'row2', 'row3');
+ foreach ($rows as $row) {
+ $this->assertFieldByXPath('//input[@type="radio"]', $row, t('Radio button for value @row.', array('@row' => $row)));
+ }
+ }
+
+ /**
+ * Test the display of the #empty text when #options is an empty array.
+ */
+ function testEmptyText() {
+ $this->drupalGet('form_test/tableselect/empty-text');
+ $this->assertText(t('Empty text.'), t('Empty text should be displayed.'));
+ }
+
+ /**
+ * Test the submission of single and multiple values when #multiple is TRUE.
+ */
+ function testMultipleTrueSubmit() {
+
+ // Test a submission with one checkbox checked.
+ $edit = array();
+ $edit['tableselect[row1]'] = TRUE;
+ $this->drupalPost('form_test/tableselect/multiple-true', $edit, 'Submit');
+
+ $this->assertText(t('Submitted: row1 = row1'), t('Checked checkbox row1'));
+ $this->assertText(t('Submitted: row2 = 0'), t('Unchecked checkbox row2.'));
+ $this->assertText(t('Submitted: row3 = 0'), t('Unchecked checkbox row3.'));
+
+ // Test a submission with multiple checkboxes checked.
+ $edit['tableselect[row1]'] = TRUE;
+ $edit['tableselect[row3]'] = TRUE;
+ $this->drupalPost('form_test/tableselect/multiple-true', $edit, 'Submit');
+
+ $this->assertText(t('Submitted: row1 = row1'), t('Checked checkbox row1.'));
+ $this->assertText(t('Submitted: row2 = 0'), t('Unchecked checkbox row2.'));
+ $this->assertText(t('Submitted: row3 = row3'), t('Checked checkbox row3.'));
+
+ }
+
+ /**
+ * Test submission of values when #multiple is FALSE.
+ */
+ function testMultipleFalseSubmit() {
+ $edit['tableselect'] = 'row1';
+ $this->drupalPost('form_test/tableselect/multiple-false', $edit, 'Submit');
+ $this->assertText(t('Submitted: row1'), t('Selected radio button'));
+ }
+
+ /**
+ * Test the #js_select property.
+ */
+ function testAdvancedSelect() {
+ // When #multiple = TRUE a Select all checkbox should be displayed by default.
+ $this->drupalGet('form_test/tableselect/advanced-select/multiple-true-default');
+ $this->assertFieldByXPath('//th[@class="select-all"]', NULL, t('Display a "Select all" checkbox by default when #multiple is TRUE.'));
+
+ // When #js_select is set to FALSE, a "Select all" checkbox should not be displayed.
+ $this->drupalGet('form_test/tableselect/advanced-select/multiple-true-no-advanced-select');
+ $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, t('Do not display a "Select all" checkbox when #js_select is FALSE.'));
+
+ // A "Select all" checkbox never makes sense when #multiple = FALSE, regardless of the value of #js_select.
+ $this->drupalGet('form_test/tableselect/advanced-select/multiple-false-default');
+ $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, t('Do not display a "Select all" checkbox when #multiple is FALSE.'));
+
+ $this->drupalGet('form_test/tableselect/advanced-select/multiple-false-advanced-select');
+ $this->assertNoFieldByXPath('//th[@class="select-all"]', NULL, t('Do not display a "Select all" checkbox when #multiple is FALSE, even when #js_select is TRUE.'));
+ }
+
+
+ /**
+ * Test the whether the option checker gives an error on invalid tableselect values for checkboxes.
+ */
+ function testMultipleTrueOptionchecker() {
+
+ list($header, $options) = _form_test_tableselect_get_data();
+
+ $form['tableselect'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ );
+
+ // Test with a valid value.
+ list($processed_form, $form_state, $errors) = $this->formSubmitHelper($form, array('tableselect' => array('row1' => 'row1')));
+ $this->assertFalse(isset($errors['tableselect']), t('Option checker allows valid values for checkboxes.'));
+
+ // Test with an invalid value.
+ list($processed_form, $form_state, $errors) = $this->formSubmitHelper($form, array('tableselect' => array('non_existing_value' => 'non_existing_value')));
+ $this->assertTrue(isset($errors['tableselect']), t('Option checker disallows invalid values for checkboxes.'));
+
+ }
+
+
+ /**
+ * Test the whether the option checker gives an error on invalid tableselect values for radios.
+ */
+ function testMultipleFalseOptionchecker() {
+
+ list($header, $options) = _form_test_tableselect_get_data();
+
+ $form['tableselect'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#multiple' => FALSE,
+ );
+
+ // Test with a valid value.
+ list($processed_form, $form_state, $errors) = $this->formSubmitHelper($form, array('tableselect' => 'row1'));
+ $this->assertFalse(isset($errors['tableselect']), t('Option checker allows valid values for radio buttons.'));
+
+ // Test with an invalid value.
+ list($processed_form, $form_state, $errors) = $this->formSubmitHelper($form, array('tableselect' => 'non_existing_value'));
+ $this->assertTrue(isset($errors['tableselect']), t('Option checker disallows invalid values for radio buttons.'));
+ }
+
+
+ /**
+ * Helper function for the option check test to submit a form while collecting errors.
+ *
+ * @param $form_element
+ * A form element to test.
+ * @param $edit
+ * An array containing post data.
+ *
+ * @return
+ * An array containing the processed form, the form_state and any errors.
+ */
+ private function formSubmitHelper($form, $edit) {
+ $form_id = $this->randomName();
+ $form_state = form_state_defaults();
+
+ $form['op'] = array('#type' => 'submit', '#value' => t('Submit'));
+
+ $form_state['input'] = $edit;
+ $form_state['input']['form_id'] = $form_id;
+
+ drupal_prepare_form($form_id, $form, $form_state);
+
+ drupal_process_form($form_id, $form, $form_state);
+
+ $errors = form_get_errors();
+
+ // Clear errors and messages.
+ drupal_get_messages();
+ form_clear_error();
+
+ // Return the processed form together with form_state and errors
+ // to allow the caller lowlevel access to the form.
+ return array($form, $form_state, $errors);
+ }
+
+}
+
+/**
+ * Test the vertical_tabs form element for expected behavior.
+ */
+class FormsElementsVerticalTabsFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Vertical tabs form element type test',
+ 'description' => 'Test the vertical_tabs element for expected behavior',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Ensures that vertical-tabs.js is included before collapse.js.
+ *
+ * Otherwise, collapse.js adds "SHOW" or "HIDE" labels to the tabs.
+ */
+ function testJavaScriptOrdering() {
+ $this->drupalGet('form_test/vertical-tabs');
+ $position1 = strpos($this->content, 'core/misc/vertical-tabs.js');
+ $position2 = strpos($this->content, 'core/misc/collapse.js');
+ $this->assertTrue($position1 !== FALSE && $position2 !== FALSE && $position1 < $position2, t('vertical-tabs.js is included before collapse.js'));
+ }
+}
+
+/**
+ * Test the form storage on a multistep form.
+ *
+ * The tested form puts data into the storage during the initial form
+ * construction. These tests verify that there are no duplicate form
+ * constructions, with and without manual form caching activiated. Furthermore
+ * when a validation error occurs, it makes sure that changed form element
+ * values aren't lost due to a wrong form rebuild.
+ */
+class FormsFormStorageTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Multistep form using form storage',
+ 'description' => 'Tests a multistep form using form storage and makes sure validation and caching works right.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+
+ $this->web_user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Tests using the form in a usual way.
+ */
+ function testForm() {
+ $this->drupalGet('form_test/form-storage');
+ $this->assertText('Form constructions: 1');
+
+ $edit = array('title' => 'new', 'value' => 'value_is_set');
+
+ // Use form rebuilding triggered by a submit button.
+ $this->drupalPost(NULL, $edit, 'Continue submit');
+ $this->assertText('Form constructions: 2');
+ $this->assertText('Form constructions: 3');
+
+ // Reset the form to the values of the storage, using a form rebuild
+ // triggered by button of type button.
+ $this->drupalPost(NULL, array('title' => 'changed'), 'Reset');
+ $this->assertFieldByName('title', 'new', 'Values have been resetted.');
+ // After rebuilding, the form has been cached.
+ $this->assertText('Form constructions: 4');
+
+ $this->drupalPost(NULL, $edit, 'Save');
+ $this->assertText('Form constructions: 4');
+ $this->assertText('Title: new', t('The form storage has stored the values.'));
+ }
+
+ /**
+ * Tests using the form with an activated $form_state['cache'] property.
+ */
+ function testFormCached() {
+ $this->drupalGet('form_test/form-storage', array('query' => array('cache' => 1)));
+ $this->assertText('Form constructions: 1');
+
+ $edit = array('title' => 'new', 'value' => 'value_is_set');
+
+ // Use form rebuilding triggered by a submit button.
+ $this->drupalPost(NULL, $edit, 'Continue submit');
+ $this->assertText('Form constructions: 2');
+
+ // Reset the form to the values of the storage, using a form rebuild
+ // triggered by button of type button.
+ $this->drupalPost(NULL, array('title' => 'changed'), 'Reset');
+ $this->assertFieldByName('title', 'new', 'Values have been resetted.');
+ $this->assertText('Form constructions: 3');
+
+ $this->drupalPost(NULL, $edit, 'Save');
+ $this->assertText('Form constructions: 3');
+ $this->assertText('Title: new', t('The form storage has stored the values.'));
+ }
+
+ /**
+ * Tests validation when form storage is used.
+ */
+ function testValidation() {
+ $this->drupalPost('form_test/form-storage', array('title' => '', 'value' => 'value_is_set'), 'Continue submit');
+ $this->assertPattern('/value_is_set/', t('The input values have been kept.'));
+ }
+
+ /**
+ * Tests updating cached form storage during form validation.
+ *
+ * If form caching is enabled and a form stores data in the form storage, then
+ * the form storage also has to be updated in case of a validation error in
+ * the form. This test re-uses the existing form for multi-step tests, but
+ * triggers a special #element_validate handler to update the form storage
+ * during form validation, while another, required element in the form
+ * triggers a form validation error.
+ */
+ function testCachedFormStorageValidation() {
+ // Request the form with 'cache' query parameter to enable form caching.
+ $this->drupalGet('form_test/form-storage', array('query' => array('cache' => 1)));
+
+ // Skip step 1 of the multi-step form, since the first step copies over
+ // 'title' into form storage, but we want to verify that changes in the form
+ // storage are updated in the cache during form validation.
+ $edit = array('title' => 'foo');
+ $this->drupalPost(NULL, $edit, 'Continue submit');
+
+ // In step 2, trigger a validation error for the required 'title' field, and
+ // post the special 'change_title' value for the 'value' field, which
+ // conditionally invokes the #element_validate handler to update the form
+ // storage.
+ $edit = array('title' => '', 'value' => 'change_title');
+ $this->drupalPost(NULL, $edit, 'Save');
+
+ // At this point, the form storage should contain updated values, but we do
+ // not see them, because the form has not been rebuilt yet due to the
+ // validation error. Post again and verify that the rebuilt form contains
+ // the values of the updated form storage.
+ $this->drupalPost(NULL, array('title' => 'foo', 'value' => 'bar'), 'Save');
+ $this->assertText("The thing has been changed.", 'The altered form storage value was updated in cache and taken over.');
+ }
+
+ /**
+ * Tests a form using form state without using 'storage' to pass data from the
+ * constructor to a submit handler. The data has to persist even when caching
+ * gets activated, what may happen when a modules alter the form and adds
+ * #ajax properties.
+ */
+ function testFormStatePersist() {
+ // Test the form one time with caching activated and one time without.
+ $run_options = array(
+ array(),
+ array('query' => array('cache' => 1)),
+ );
+ foreach ($run_options as $options) {
+ $this->drupalPost('form-test/state-persist', array(), t('Submit'), $options);
+ // The submit handler outputs the value in $form_state, assert it's there.
+ $this->assertText('State persisted.');
+
+ // Test it again, but first trigger a validation error, then test.
+ $this->drupalPost('form-test/state-persist', array('title' => ''), t('Submit'), $options);
+ $this->assertText(t('!name field is required.', array('!name' => 'title')));
+ // Submit the form again triggering no validation error.
+ $this->drupalPost(NULL, array('title' => 'foo'), t('Submit'), $options);
+ $this->assertText('State persisted.');
+
+ // Now post to the rebuilt form and verify it's still there afterwards.
+ $this->drupalPost(NULL, array('title' => 'bar'), t('Submit'), $options);
+ $this->assertText('State persisted.');
+ }
+ }
+}
+
+/**
+ * Test wrapper form callbacks.
+ */
+class FormsFormWrapperTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form wrapper callback',
+ 'description' => 'Tests form wrapper callbacks to pass a prebuilt form to form builder functions.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Tests using the form in a usual way.
+ */
+ function testWrapperCallback() {
+ $this->drupalGet('form_test/wrapper-callback');
+ $this->assertText('Form wrapper callback element output.', t('The form contains form wrapper elements.'));
+ $this->assertText('Form builder element output.', t('The form contains form builder elements.'));
+ }
+}
+
+/**
+ * Test $form_state clearance.
+ */
+class FormStateValuesCleanTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form state values clearance',
+ 'description' => 'Test proper removal of submitted form values using form_state_values_clean().',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Tests form_state_values_clean().
+ */
+ function testFormStateValuesClean() {
+ $values = drupal_json_decode($this->drupalPost('form_test/form-state-values-clean', array(), t('Submit')));
+
+ // Setup the expected result.
+ $result = array(
+ 'beer' => 1000,
+ 'baz' => array('beer' => 2000),
+ );
+
+ // Verify that all internal Form API elements were removed.
+ $this->assertFalse(isset($values['form_id']), t('%element was removed.', array('%element' => 'form_id')));
+ $this->assertFalse(isset($values['form_token']), t('%element was removed.', array('%element' => 'form_token')));
+ $this->assertFalse(isset($values['form_build_id']), t('%element was removed.', array('%element' => 'form_build_id')));
+ $this->assertFalse(isset($values['op']), t('%element was removed.', array('%element' => 'op')));
+
+ // Verify that all buttons were removed.
+ $this->assertFalse(isset($values['foo']), t('%element was removed.', array('%element' => 'foo')));
+ $this->assertFalse(isset($values['bar']), t('%element was removed.', array('%element' => 'bar')));
+ $this->assertFalse(isset($values['baz']['foo']), t('%element was removed.', array('%element' => 'foo')));
+ $this->assertFalse(isset($values['baz']['baz']), t('%element was removed.', array('%element' => 'baz')));
+
+ // Verify that nested form value still exists.
+ $this->assertTrue(isset($values['baz']['beer']), t('Nested form value still exists.'));
+
+ // Verify that actual form values equal resulting form values.
+ $this->assertEqual($values, $result, t('Expected form values equal actual form values.'));
+ }
+}
+
+/**
+ * Tests form rebuilding.
+ *
+ * @todo Add tests for other aspects of form rebuilding.
+ */
+class FormsRebuildTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form rebuilding',
+ 'description' => 'Tests functionality of drupal_rebuild_form().',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+
+ $this->web_user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($this->web_user);
+ }
+
+ /**
+ * Tests preservation of values.
+ */
+ function testRebuildPreservesValues() {
+ $edit = array(
+ 'checkbox_1_default_off' => TRUE,
+ 'checkbox_1_default_on' => FALSE,
+ 'text_1' => 'foo',
+ );
+ $this->drupalPost('form-test/form-rebuild-preserve-values', $edit, 'Add more');
+
+ // Verify that initial elements retained their submitted values.
+ $this->assertFieldChecked('edit-checkbox-1-default-off', t('A submitted checked checkbox retained its checked state during a rebuild.'));
+ $this->assertNoFieldChecked('edit-checkbox-1-default-on', t('A submitted unchecked checkbox retained its unchecked state during a rebuild.'));
+ $this->assertFieldById('edit-text-1', 'foo', t('A textfield retained its submitted value during a rebuild.'));
+
+ // Verify that newly added elements were initialized with their default values.
+ $this->assertFieldChecked('edit-checkbox-2-default-on', t('A newly added checkbox was initialized with a default checked state.'));
+ $this->assertNoFieldChecked('edit-checkbox-2-default-off', t('A newly added checkbox was initialized with a default unchecked state.'));
+ $this->assertFieldById('edit-text-2', 'DEFAULT 2', t('A newly added textfield was initialized with its default value.'));
+ }
+
+ /**
+ * Tests that a form's action is retained after an Ajax submission.
+ *
+ * The 'action' attribute of a form should not change after an Ajax submission
+ * followed by a non-Ajax submission, which triggers a validation error.
+ */
+ function testPreserveFormActionAfterAJAX() {
+ // Create a multi-valued field for 'page' nodes to use for Ajax testing.
+ $field_name = 'field_ajax_test';
+ $field = array(
+ 'field_name' => $field_name,
+ 'type' => 'text',
+ 'cardinality' => FIELD_CARDINALITY_UNLIMITED,
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => $field_name,
+ 'entity_type' => 'node',
+ 'bundle' => 'page',
+ );
+ field_create_instance($instance);
+
+ // Log in a user who can create 'page' nodes.
+ $this->web_user = $this->drupalCreateUser(array('create page content'));
+ $this->drupalLogin($this->web_user);
+
+ // Get the form for adding a 'page' node. Submit an "add another item" Ajax
+ // submission and verify it worked by ensuring the updated page has two text
+ // field items in the field for which we just added an item.
+ $this->drupalGet('node/add/page');
+ $this->drupalPostAJAX(NULL, array(), array('field_ajax_test_add_more' => t('Add another item')), 'system/ajax', array(), array(), 'page-node-form');
+ $this->assert(count($this->xpath('//div[contains(@class, "field-name-field-ajax-test")]//input[@type="text"]')) == 2, t('AJAX submission succeeded.'));
+
+ // Submit the form with the non-Ajax "Save" button, leaving the title field
+ // blank to trigger a validation error, and ensure that a validation error
+ // occurred, because this test is for testing what happens when a form is
+ // re-rendered without being re-built, which is what happens when there's
+ // a validation error.
+ $this->drupalPost(NULL, array(), t('Save'));
+ $this->assertText('Title field is required.', t('Non-AJAX submission correctly triggered a validation error.'));
+
+ // Ensure that the form contains two items in the multi-valued field, so we
+ // know we're testing a form that was correctly retrieved from cache.
+ $this->assert(count($this->xpath('//form[contains(@id, "page-node-form")]//div[contains(@class, "form-item-field-ajax-test")]//input[@type="text"]')) == 2, t('Form retained its state from cache.'));
+
+ // Ensure that the form's action is correct.
+ $forms = $this->xpath('//form[contains(@class, "node-page-form")]');
+ $this->assert(count($forms) == 1 && $forms[0]['action'] == url('node/add/page'), t('Re-rendered form contains the correct action value.'));
+ }
+}
+
+/**
+ * Test the programmatic form submission behavior.
+ */
+class FormsProgrammaticTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Programmatic form submissions',
+ 'description' => 'Test the programmatic form submission behavior.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Test the programmatic form submission workflow.
+ */
+ function testSubmissionWorkflow() {
+ // Backup the current batch status and reset it to avoid conflicts while
+ // processing the dummy form submit handler.
+ $current_batch = $batch =& batch_get();
+ $batch = array();
+
+ // Test that a programmatic form submission is rejected when a required
+ // textfield is omitted and correctly processed when it is provided.
+ $this->submitForm(array(), FALSE);
+ $this->submitForm(array('textfield' => 'test 1'), TRUE);
+ $this->submitForm(array(), FALSE);
+ $this->submitForm(array('textfield' => 'test 2'), TRUE);
+
+ // Test that a programmatic form submission can turn on and off checkboxes
+ // which are, by default, checked.
+ $this->submitForm(array('textfield' => 'dummy value', 'checkboxes' => array(1 => 1, 2 => 2)), TRUE);
+ $this->submitForm(array('textfield' => 'dummy value', 'checkboxes' => array(1 => 1, 2 => NULL)), TRUE);
+ $this->submitForm(array('textfield' => 'dummy value', 'checkboxes' => array(1 => NULL, 2 => 2)), TRUE);
+ $this->submitForm(array('textfield' => 'dummy value', 'checkboxes' => array(1 => NULL, 2 => NULL)), TRUE);
+
+ // Test that a programmatic form submission can correctly click a button
+ // that limits validation errors based on user input. Since we do not
+ // submit any values for "textfield" here and the textfield is required, we
+ // only expect form validation to pass when validation is limited to a
+ // different field.
+ $this->submitForm(array('op' => 'Submit with limited validation', 'field_to_validate' => 'all'), FALSE);
+ $this->submitForm(array('op' => 'Submit with limited validation', 'field_to_validate' => 'textfield'), FALSE);
+ $this->submitForm(array('op' => 'Submit with limited validation', 'field_to_validate' => 'field_to_validate'), TRUE);
+
+ // Restore the current batch status.
+ $batch = $current_batch;
+ }
+
+ /**
+ * Helper function used to programmatically submit the form defined in
+ * form_test.module with the given values.
+ *
+ * @param $values
+ * An array of field values to be submitted.
+ * @param $valid_input
+ * A boolean indicating whether or not the form submission is expected to
+ * be valid.
+ */
+ private function submitForm($values, $valid_input) {
+ // Programmatically submit the given values.
+ $form_state = array('values' => $values);
+ drupal_form_submit('form_test_programmatic_form', $form_state);
+
+ // Check that the form returns an error when expected, and vice versa.
+ $errors = form_get_errors();
+ $valid_form = empty($errors);
+ $args = array(
+ '%values' => print_r($values, TRUE),
+ '%errors' => $valid_form ? t('None') : implode(' ', $errors),
+ );
+ $this->assertTrue($valid_input == $valid_form, t('Input values: %values<br/>Validation handler errors: %errors', $args));
+
+ // We check submitted values only if we have a valid input.
+ if ($valid_input) {
+ // By fetching the values from $form_state['storage'] we ensure that the
+ // submission handler was properly executed.
+ $stored_values = $form_state['storage']['programmatic_form_submit'];
+ foreach ($values as $key => $value) {
+ $this->assertTrue(isset($stored_values[$key]) && $stored_values[$key] == $value, t('Submission handler correctly executed: %stored_key is %stored_value', array('%stored_key' => $key, '%stored_value' => print_r($value, TRUE))));
+ }
+ }
+ }
+}
+
+/**
+ * Test that FAPI correctly determines $form_state['triggering_element'].
+ */
+class FormsTriggeringElementTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form triggering element determination',
+ 'description' => 'Test the determination of $form_state[\'triggering_element\'].',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Test the determination of $form_state['triggering_element'] when no button
+ * information is included in the POST data, as is sometimes the case when
+ * the ENTER key is pressed in a textfield in Internet Explorer.
+ */
+ function testNoButtonInfoInPost() {
+ $path = 'form-test/clicked-button';
+ $edit = array();
+ $form_html_id = 'form-test-clicked-button';
+
+ // Ensure submitting a form with no buttons results in no
+ // $form_state['triggering_element'] and the form submit handler not
+ // running.
+ $this->drupalPost($path, $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('There is no clicked button.', t('$form_state[\'triggering_element\'] set to NULL.'));
+ $this->assertNoText('Submit handler for form_test_clicked_button executed.', t('Form submit handler did not execute.'));
+
+ // Ensure submitting a form with one or more submit buttons results in
+ // $form_state['triggering_element'] being set to the first one the user has
+ // access to. An argument with 'r' in it indicates a restricted
+ // (#access=FALSE) button.
+ $this->drupalPost($path . '/s', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button1.', t('$form_state[\'triggering_element\'] set to only button.'));
+ $this->assertText('Submit handler for form_test_clicked_button executed.', t('Form submit handler executed.'));
+
+ $this->drupalPost($path . '/s/s', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button1.', t('$form_state[\'triggering_element\'] set to first button.'));
+ $this->assertText('Submit handler for form_test_clicked_button executed.', t('Form submit handler executed.'));
+
+ $this->drupalPost($path . '/rs/s', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button2.', t('$form_state[\'triggering_element\'] set to first available button.'));
+ $this->assertText('Submit handler for form_test_clicked_button executed.', t('Form submit handler executed.'));
+
+ // Ensure submitting a form with buttons of different types results in
+ // $form_state['triggering_element'] being set to the first button,
+ // regardless of type. For the FAPI 'button' type, this should result in the
+ // submit handler not executing. The types are 's'(ubmit), 'b'(utton), and
+ // 'i'(mage_button).
+ $this->drupalPost($path . '/s/b/i', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button1.', t('$form_state[\'triggering_element\'] set to first button.'));
+ $this->assertText('Submit handler for form_test_clicked_button executed.', t('Form submit handler executed.'));
+
+ $this->drupalPost($path . '/b/s/i', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button1.', t('$form_state[\'triggering_element\'] set to first button.'));
+ $this->assertNoText('Submit handler for form_test_clicked_button executed.', t('Form submit handler did not execute.'));
+
+ $this->drupalPost($path . '/i/s/b', $edit, NULL, array(), array(), $form_html_id);
+ $this->assertText('The clicked button is button1.', t('$form_state[\'triggering_element\'] set to first button.'));
+ $this->assertText('Submit handler for form_test_clicked_button executed.', t('Form submit handler executed.'));
+ }
+
+ /**
+ * Test that $form_state['triggering_element'] does not get set to a button
+ * with #access=FALSE.
+ */
+ function testAttemptAccessControlBypass() {
+ $path = 'form-test/clicked-button';
+ $form_html_id = 'form-test-clicked-button';
+
+ // Retrieve a form where 'button1' has #access=FALSE and 'button2' doesn't.
+ $this->drupalGet($path . '/rs/s');
+
+ // Submit the form with 'button1=button1' in the POST data, which someone
+ // trying to get around security safeguards could easily do. We have to do
+ // a little trickery here, to work around the safeguards in drupalPost(): by
+ // renaming the text field that is in the form to 'button1', we can get the
+ // data we want into $_POST.
+ $elements = $this->xpath('//form[@id="' . $form_html_id . '"]//input[@name="text"]');
+ $elements[0]['name'] = 'button1';
+ $this->drupalPost(NULL, array('button1' => 'button1'), NULL, array(), array(), $form_html_id);
+
+ // Ensure that $form_state['triggering_element'] was not set to the
+ // restricted button. Do this with both a negative and positive assertion,
+ // because negative assertions alone can be brittle. See
+ // testNoButtonInfoInPost() for why the triggering element gets set to
+ // 'button2'.
+ $this->assertNoText('The clicked button is button1.', t('$form_state[\'triggering_element\'] not set to a restricted button.'));
+ $this->assertText('The clicked button is button2.', t('$form_state[\'triggering_element\'] not set to a restricted button.'));
+ }
+}
+
+/**
+ * Tests rebuilding of arbitrary forms by altering them.
+ */
+class FormsArbitraryRebuildTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Rebuild arbitrary forms',
+ 'description' => 'Tests altering forms to be rebuilt so there are multiple steps.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ // Auto-create a field for testing.
+ $field = array(
+ 'field_name' => 'test_multiple',
+ 'type' => 'text',
+ 'cardinality' => -1,
+ 'translatable' => FALSE,
+ );
+ field_create_field($field);
+
+ $instance = array(
+ 'entity_type' => 'node',
+ 'field_name' => 'test_multiple',
+ 'bundle' => 'page',
+ 'label' => 'Test a multiple valued field',
+ 'widget' => array(
+ 'type' => 'text_textfield',
+ 'weight' => 0,
+ ),
+ );
+ field_create_instance($instance);
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ }
+
+ /**
+ * Tests a basic rebuild with the user registration form.
+ */
+ function testUserRegistrationRebuild() {
+ $edit = array(
+ 'name' => 'foo',
+ 'mail' => 'bar@example.com',
+ );
+ $this->drupalPost('user/register', $edit, 'Rebuild');
+ $this->assertText('Form rebuilt.');
+ $this->assertFieldByName('name', 'foo', 'Entered user name has been kept.');
+ $this->assertFieldByName('mail', 'bar@example.com', 'Entered mail address has been kept.');
+ }
+
+ /**
+ * Tests a rebuild caused by a multiple value field.
+ */
+ function testUserRegistrationMultipleField() {
+ $edit = array(
+ 'name' => 'foo',
+ 'mail' => 'bar@example.com',
+ );
+ $this->drupalPost('user/register', $edit, t('Add another item'), array('query' => array('field' => TRUE)));
+ $this->assertText('Test a multiple valued field', 'Form has been rebuilt.');
+ $this->assertFieldByName('name', 'foo', 'Entered user name has been kept.');
+ $this->assertFieldByName('mail', 'bar@example.com', 'Entered mail address has been kept.');
+ }
+}
+
+/**
+ * Tests form API file inclusion.
+ */
+class FormsFileInclusionTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form API file inclusion',
+ 'description' => 'Tests form API file inclusion.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ /**
+ * Tests loading an include specified in hook_menu().
+ */
+ function testLoadMenuInclude() {
+ $this->drupalPostAJAX('form-test/load-include-menu', array(), array('op' => t('Save')), 'system/ajax', array(), array(), 'form-test-load-include-menu');
+ $this->assertText('Submit callback called.');
+ }
+
+ /**
+ * Tests loading a custom specified inlcude.
+ */
+ function testLoadCustomInclude() {
+ $this->drupalPost('form-test/load-include-custom', array(), t('Save'));
+ $this->assertText('Submit callback called.');
+ }
+}
+
+/**
+ * Tests checkbox element.
+ */
+class FormCheckboxTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Form API checkbox',
+ 'description' => 'Tests form API checkbox handling of various combinations of #default_value and #return_value.',
+ 'group' => 'Form API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('form_test');
+ }
+
+ function testFormCheckbox() {
+ // Ensure that the checked state is determined and rendered correctly for
+ // tricky combinations of default and return values.
+ foreach (array(FALSE, NULL, TRUE, 0, '0', '', 1, '1', 'foobar', '1foobar') as $default_value) {
+ // Only values that can be used for array indeces are supported for
+ // #return_value, with the exception of integer 0, which is not supported.
+ // @see form_process_checkbox().
+ foreach (array('0', '', 1, '1', 'foobar', '1foobar') as $return_value) {
+ $form_array = drupal_get_form('form_test_checkbox_type_juggling', $default_value, $return_value);
+ $form = drupal_render($form_array);
+ if ($default_value === TRUE) {
+ $checked = TRUE;
+ }
+ elseif ($return_value === '0') {
+ $checked = ($default_value === '0');
+ }
+ elseif ($return_value === '') {
+ $checked = ($default_value === '');
+ }
+ elseif ($return_value === 1 || $return_value === '1') {
+ $checked = ($default_value === 1 || $default_value === '1');
+ }
+ elseif ($return_value === 'foobar') {
+ $checked = ($default_value === 'foobar');
+ }
+ elseif ($return_value === '1foobar') {
+ $checked = ($default_value === '1foobar');
+ }
+ $checked_in_html = strpos($form, 'checked') !== FALSE;
+ $message = t('#default_value is %default_value #return_value is %return_value.', array('%default_value' => var_export($default_value, TRUE), '%return_value' => var_export($return_value, TRUE)));
+ $this->assertIdentical($checked, $checked_in_html, $message);
+ }
+ }
+
+ // Ensure that $form_state['values'] is populated correctly for a checkboxes
+ // group that includes a 0-indexed array of options.
+ $results = json_decode($this->drupalPost('form-test/checkboxes-zero', array(), 'Save'));
+ $this->assertIdentical($results->checkbox_off, array(0, 0, 0), t('All three in checkbox_off are zeroes: off.'));
+ $this->assertIdentical($results->checkbox_zero_default, array('0', 0, 0), t('The first choice is on in checkbox_zero_default'));
+ $this->assertIdentical($results->checkbox_string_zero_default, array('0', 0, 0), t('The first choice is on in checkbox_string_zero_default'));
+ $edit = array('checkbox_off[0]' => '0');
+ $results = json_decode($this->drupalPost('form-test/checkboxes-zero', $edit, 'Save'));
+ $this->assertIdentical($results->checkbox_off, array('0', 0, 0), t('The first choice is on in checkbox_off but the rest is not'));
+
+ // Ensure that each checkbox is rendered correctly for a checkboxes group
+ // that includes a 0-indexed array of options.
+ $this->drupalPost('form-test/checkboxes-zero/0', array(), 'Save');
+ $checkboxes = $this->xpath('//input[@type="checkbox"]');
+ foreach ($checkboxes as $checkbox) {
+ $checked = isset($checkbox['checked']);
+ $name = (string) $checkbox['name'];
+ $this->assertIdentical($checked, $name == 'checkbox_zero_default[0]' || $name == 'checkbox_string_zero_default[0]', t('Checkbox %name correctly checked', array('%name' => $name)));
+ }
+ $edit = array('checkbox_off[0]' => '0');
+ $this->drupalPost('form-test/checkboxes-zero/0', $edit, 'Save');
+ $checkboxes = $this->xpath('//input[@type="checkbox"]');
+ foreach ($checkboxes as $checkbox) {
+ $checked = isset($checkbox['checked']);
+ $name = (string) $checkbox['name'];
+ $this->assertIdentical($checked, $name == 'checkbox_off[0]' || $name == 'checkbox_zero_default[0]' || $name == 'checkbox_string_zero_default[0]', t('Checkbox %name correctly checked', array('%name' => $name)));
+ }
+ }
+}
diff --git a/core/modules/simpletest/tests/form_test.file.inc b/core/modules/simpletest/tests/form_test.file.inc
new file mode 100644
index 000000000000..f9197ead2a5b
--- /dev/null
+++ b/core/modules/simpletest/tests/form_test.file.inc
@@ -0,0 +1,48 @@
+<?php
+
+/**
+ * @file
+ * An include file to test loading it with the form API.
+ */
+
+/**
+ * Form constructor for testing FAPI file inclusion of the file specified in
+ * hook_menu().
+ */
+function form_test_load_include_menu($form, &$form_state) {
+ // Submit the form via Ajax. That way the FAPI has to care about including
+ // the file specified in hook_menu().
+ $ajax_wrapper_id = drupal_html_id('form-test-load-include-menu-ajax-wrapper');
+ $form['ajax_wrapper'] = array(
+ '#markup' => '<div id="' . $ajax_wrapper_id . '"></div>',
+ );
+ $form['button'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#submit' => array('form_test_load_include_submit'),
+ '#ajax' => array(
+ 'wrapper' => $ajax_wrapper_id,
+ 'method' => 'append',
+ 'callback' => 'form_test_load_include_menu_ajax',
+ ),
+ );
+ return $form;
+}
+
+/**
+ * Submit callback for the form API file inclusion test forms.
+ */
+function form_test_load_include_submit($form, $form_state) {
+ drupal_set_message('Submit callback called.');
+}
+
+/**
+ * Ajax callback for the file inclusion via menu test.
+ */
+function form_test_load_include_menu_ajax($form) {
+ // We don't need to return anything, since #ajax['method'] is 'append', which
+ // does not remove the original #ajax['wrapper'] element, and status messages
+ // are automatically added by the Ajax framework as long as there's a wrapper
+ // element to add them to.
+ return '';
+}
diff --git a/core/modules/simpletest/tests/form_test.info b/core/modules/simpletest/tests/form_test.info
new file mode 100644
index 000000000000..5354350daf03
--- /dev/null
+++ b/core/modules/simpletest/tests/form_test.info
@@ -0,0 +1,6 @@
+name = "FormAPI Test"
+description = "Support module for Form API tests."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/form_test.module b/core/modules/simpletest/tests/form_test.module
new file mode 100644
index 000000000000..50b1c6814d23
--- /dev/null
+++ b/core/modules/simpletest/tests/form_test.module
@@ -0,0 +1,1648 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the form API tests.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function form_test_menu() {
+ $items['form-test/alter'] = array(
+ 'title' => 'Form altering test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_alter_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form-test/validate'] = array(
+ 'title' => 'Form validation handlers test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_validate_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form-test/limit-validation-errors'] = array(
+ 'title' => 'Form validation with some error suppression',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_limit_validation_errors_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/tableselect/multiple-true'] = array(
+ 'title' => 'Tableselect checkboxes test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_tableselect_multiple_true_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form_test/tableselect/multiple-false'] = array(
+ 'title' => 'Tableselect radio button test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_tableselect_multiple_false_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form_test/tableselect/empty-text'] = array(
+ 'title' => 'Tableselect empty text test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_tableselect_empty_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form_test/tableselect/advanced-select'] = array(
+ 'title' => 'Tableselect js_select tests',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_tableselect_js_select_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/vertical-tabs'] = array(
+ 'title' => 'Vertical tabs tests',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_vertical_tabs_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/form-storage'] = array(
+ 'title' => 'Form storage test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_storage_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/wrapper-callback'] = array(
+ 'title' => 'Form wrapper callback test',
+ 'page callback' => 'form_test_wrapper_callback',
+ 'page arguments' => array('form_test_wrapper_callback_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/form-state-values-clean'] = array(
+ 'title' => 'Form state values clearance test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_form_state_values_clean_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/checkbox'] = array(
+ 'title' => t('Form test'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_checkbox'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form-test/select'] = array(
+ 'title' => t('Select'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_select'),
+ 'access callback' => TRUE,
+ );
+ $items['form-test/placeholder-text'] = array(
+ 'title' => 'Placeholder',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_placeholder_test'),
+ 'access callback' => TRUE,
+ );
+ $items['form-test/checkboxes-radios'] = array(
+ 'title' => t('Checkboxes, Radios'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_checkboxes_radios'),
+ 'access callback' => TRUE,
+ );
+
+ $items['form-test/disabled-elements'] = array(
+ 'title' => t('Form test'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_disabled_elements'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/input-forgery'] = array(
+ 'title' => t('Form test'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('_form_test_input_forgery'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/form-rebuild-preserve-values'] = array(
+ 'title' => 'Form values preservation during rebuild test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_form_rebuild_preserve_values_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form_test/form-labels'] = array(
+ 'title' => 'Form label test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_label_test_form'),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/state-persist'] = array(
+ 'title' => 'Form state persistence without storage',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_state_persist'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/clicked-button'] = array(
+ 'title' => 'Clicked button test',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_clicked_button'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ if (module_exists('node')) {
+ $items['form-test/two-instances-of-same-form'] = array(
+ 'title' => 'AJAX test with two form instances',
+ 'page callback' => 'form_test_two_instances',
+ 'access callback' => 'node_access',
+ 'access arguments' => array('create', 'page'),
+ 'file path' => drupal_get_path('module', 'node'),
+ 'file' => 'node.pages.inc',
+ 'type' => MENU_CALLBACK,
+ );
+ }
+
+ $items['form-test/load-include-menu'] = array(
+ 'title' => 'FAPI test loading includes',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_load_include_menu'),
+ 'access callback' => TRUE,
+ 'file' => 'form_test.file.inc',
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['form-test/load-include-custom'] = array(
+ 'title' => 'FAPI test loading includes',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_load_include_custom'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['form-test/checkboxes-zero'] = array(
+ 'title' => 'FAPI test involving checkboxes and zero',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('form_test_checkboxes_zero'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Form submit handler to return form values as JSON.
+ */
+function _form_test_submit_values_json($form, &$form_state) {
+ drupal_json_output($form_state['values']);
+ drupal_exit();
+}
+
+/**
+ * Form builder for testing hook_form_alter() and hook_form_FORM_ID_alter().
+ */
+function form_test_alter_form($form, &$form_state) {
+ // Elements can be added as needed for future testing needs, but for now,
+ // we're only testing alter hooks that do not require any elements added by
+ // this function.
+ return $form;
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() on behalf of block.module.
+ */
+function block_form_form_test_alter_form_alter(&$form, &$form_state) {
+ drupal_set_message('block_form_form_test_alter_form_alter() executed.');
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function form_test_form_alter(&$form, &$form_state, $form_id) {
+ if ($form_id == 'form_test_alter_form') {
+ drupal_set_message('form_test_form_alter() executed.');
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function form_test_form_form_test_alter_form_alter(&$form, &$form_state) {
+ drupal_set_message('form_test_form_form_test_alter_form_alter() executed.');
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() on behalf of system.module.
+ */
+function system_form_form_test_alter_form_alter(&$form, &$form_state) {
+ drupal_set_message('system_form_form_test_alter_form_alter() executed.');
+}
+
+/**
+ * Form builder for testing drupal_validate_form().
+ *
+ * Serves for testing form processing and alterations by form validation
+ * handlers, especially for the case of a validation error:
+ * - form_set_value() should be able to alter submitted values in
+ * $form_state['values'] without affecting the form element.
+ * - #element_validate handlers should be able to alter the $element in the form
+ * structure and the alterations should be contained in the rebuilt form.
+ * - #validate handlers should be able to alter the $form and the alterations
+ * should be contained in the rebuilt form.
+ */
+function form_test_validate_form($form, &$form_state) {
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Name',
+ '#default_value' => '',
+ '#element_validate' => array('form_test_element_validate_name'),
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Save',
+ );
+
+ // To simplify this test, enable form caching and use form storage to
+ // remember our alteration.
+ $form_state['cache'] = TRUE;
+
+ return $form;
+}
+
+/**
+ * Form element validation handler for 'name' in form_test_validate_form().
+ */
+function form_test_element_validate_name(&$element, &$form_state) {
+ $triggered = FALSE;
+ if ($form_state['values']['name'] == 'element_validate') {
+ // Alter the form element.
+ $element['#value'] = '#value changed by #element_validate';
+ // Alter the submitted value in $form_state.
+ form_set_value($element, 'value changed by form_set_value() in #element_validate', $form_state);
+
+ $triggered = TRUE;
+ }
+ if ($form_state['values']['name'] == 'element_validate_access') {
+ $form_state['storage']['form_test_name'] = $form_state['values']['name'];
+ // Alter the form element.
+ $element['#access'] = FALSE;
+
+ $triggered = TRUE;
+ }
+ elseif (!empty($form_state['storage']['form_test_name'])) {
+ // To simplify this test, just take over the element's value into $form_state.
+ form_set_value($element, $form_state['storage']['form_test_name'], $form_state);
+
+ $triggered = TRUE;
+ }
+
+ if ($triggered) {
+ // Output the element's value from $form_state.
+ drupal_set_message(t('@label value: @value', array('@label' => $element['#title'], '@value' => $form_state['values']['name'])));
+
+ // Trigger a form validation error to see our changes.
+ form_set_error('');
+ }
+}
+
+/**
+ * Form validation handler for form_test_validate_form().
+ */
+function form_test_validate_form_validate(&$form, &$form_state) {
+ if ($form_state['values']['name'] == 'validate') {
+ // Alter the form element.
+ $form['name']['#value'] = '#value changed by #validate';
+ // Alter the submitted value in $form_state.
+ form_set_value($form['name'], 'value changed by form_set_value() in #validate', $form_state);
+ // Output the element's value from $form_state.
+ drupal_set_message(t('@label value: @value', array('@label' => $form['name']['#title'], '@value' => $form_state['values']['name'])));
+
+ // Trigger a form validation error to see our changes.
+ form_set_error('');
+ }
+}
+
+/**
+ * Builds a simple form with a button triggering partial validation.
+ */
+function form_test_limit_validation_errors_form($form, &$form_state) {
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Title',
+ '#required' => TRUE,
+ );
+
+ $form['test'] = array(
+ '#title' => 'Test',
+ '#type' => 'textfield',
+ '#element_validate' => array('form_test_limit_validation_errors_element_validate_test'),
+ );
+ $form['test_numeric_index'] = array(
+ '#tree' => TRUE,
+ );
+ $form['test_numeric_index'][0] = array(
+ '#title' => 'Test (numeric index)',
+ '#type' => 'textfield',
+ '#element_validate' => array('form_test_limit_validation_errors_element_validate_test'),
+ );
+
+ $form['test_substring'] = array(
+ '#tree' => TRUE,
+ );
+ $form['test_substring']['foo'] = array(
+ '#title' => 'Test (substring) foo',
+ '#type' => 'textfield',
+ '#element_validate' => array('form_test_limit_validation_errors_element_validate_test'),
+ );
+ $form['test_substring']['foobar'] = array(
+ '#title' => 'Test (substring) foobar',
+ '#type' => 'textfield',
+ '#element_validate' => array('form_test_limit_validation_errors_element_validate_test'),
+ );
+
+ $form['actions']['partial'] = array(
+ '#type' => 'submit',
+ '#limit_validation_errors' => array(array('test')),
+ '#submit' => array('form_test_limit_validation_errors_form_partial_submit'),
+ '#value' => t('Partial validate'),
+ );
+ $form['actions']['partial_numeric_index'] = array(
+ '#type' => 'submit',
+ '#limit_validation_errors' => array(array('test_numeric_index', 0)),
+ '#submit' => array('form_test_limit_validation_errors_form_partial_submit'),
+ '#value' => t('Partial validate (numeric index)'),
+ );
+ $form['actions']['substring'] = array(
+ '#type' => 'submit',
+ '#limit_validation_errors' => array(array('test_substring', 'foo')),
+ '#submit' => array('form_test_limit_validation_errors_form_partial_submit'),
+ '#value' => t('Partial validate (substring)'),
+ );
+ $form['actions']['full'] = array(
+ '#type' => 'submit',
+ '#value' => t('Full validate'),
+ );
+ return $form;
+}
+
+/**
+ * Form element validation handler for the 'test' element.
+ */
+function form_test_limit_validation_errors_element_validate_test(&$element, &$form_state) {
+ if ($element['#value'] == 'invalid') {
+ form_error($element, t('@label element is invalid', array('@label' => $element['#title'])));
+ }
+}
+
+/**
+ * Form submit handler for the partial validation submit button.
+ */
+function form_test_limit_validation_errors_form_partial_submit($form, $form_state) {
+ // The title has not been validated, thus its value - in case of the test case
+ // an empty string - may not be set.
+ if (!isset($form_state['values']['title']) && isset($form_state['values']['test'])) {
+ drupal_set_message('Only validated values appear in the form values.');
+ }
+}
+
+/**
+ * Create a header and options array. Helper function for callbacks.
+ */
+function _form_test_tableselect_get_data() {
+ $header = array(
+ 'one' => t('One'),
+ 'two' => t('Two'),
+ 'three' => t('Three'),
+ 'four' => t('Four'),
+ );
+
+ $options['row1'] = array(
+ 'one' => 'row1col1',
+ 'two' => t('row1col2'),
+ 'three' => t('row1col3'),
+ 'four' => t('row1col4'),
+ );
+
+ $options['row2'] = array(
+ 'one' => 'row2col1',
+ 'two' => t('row2col2'),
+ 'three' => t('row2col3'),
+ 'four' => t('row2col4'),
+ );
+
+ $options['row3'] = array(
+ 'one' => 'row3col1',
+ 'two' => t('row3col2'),
+ 'three' => t('row3col3'),
+ 'four' => t('row3col4'),
+ );
+
+ return array($header, $options);
+}
+
+/**
+ * Build a form to test the tableselect element.
+ *
+ * @param $form_state
+ * The form_state
+ * @param $element_properties
+ * An array of element properties for the tableselect element.
+ *
+ * @return
+ * A form with a tableselect element and a submit button.
+ */
+function _form_test_tableselect_form_builder($form, $form_state, $element_properties) {
+ list($header, $options) = _form_test_tableselect_get_data();
+
+ $form['tableselect'] = $element_properties;
+
+ $form['tableselect'] += array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#multiple' => FALSE,
+ '#empty' => t('Empty text.'),
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Test the tableselect #multiple = TRUE functionality.
+ */
+function _form_test_tableselect_multiple_true_form($form, $form_state) {
+ return _form_test_tableselect_form_builder($form, $form_state, array('#multiple' => TRUE));
+}
+
+/**
+ * Process the tableselect #multiple = TRUE submitted values.
+ */
+function _form_test_tableselect_multiple_true_form_submit($form, &$form_state) {
+ $selected = $form_state['values']['tableselect'];
+ foreach ($selected as $key => $value) {
+ drupal_set_message(t('Submitted: @key = @value', array('@key' => $key, '@value' => $value)));
+ }
+}
+
+/**
+ * Test the tableselect #multiple = FALSE functionality.
+ */
+function _form_test_tableselect_multiple_false_form($form, $form_state) {
+ return _form_test_tableselect_form_builder($form, $form_state, array('#multiple' => FALSE));
+}
+
+/**
+ * Process the tableselect #multiple = FALSE submitted values.
+ */
+function _form_test_tableselect_multiple_false_form_submit($form, &$form_state) {
+ drupal_set_message(t('Submitted: @value', array('@value' => $form_state['values']['tableselect'])));
+}
+
+/**
+ * Test functionality of the tableselect #empty property.
+ */
+function _form_test_tableselect_empty_form($form, $form_state) {
+ return _form_test_tableselect_form_builder($form, $form_state, array('#options' => array()));
+}
+
+/**
+ * Test functionality of the tableselect #js_select property.
+ */
+function _form_test_tableselect_js_select_form($form, $form_state, $action) {
+ switch ($action) {
+ case 'multiple-true-default':
+ $options = array('#multiple' => TRUE);
+ break;
+
+ case 'multiple-false-default':
+ $options = array('#multiple' => FALSE);
+ break;
+
+ case 'multiple-true-no-advanced-select':
+ $options = array('#multiple' => TRUE, '#js_select' => FALSE);
+ break;
+
+ case 'multiple-false-advanced-select':
+ $options = array('#multiple' => FALSE, '#js_select' => TRUE);
+ break;
+ }
+
+ return _form_test_tableselect_form_builder($form, $form_state, $options);
+}
+
+/**
+ * Tests functionality of vertical tabs.
+ */
+function _form_test_vertical_tabs_form($form, &$form_state) {
+ $form['vertical_tabs'] = array(
+ '#type' => 'vertical_tabs',
+ );
+ $form['tab1'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Tab 1'),
+ '#collapsible' => TRUE,
+ '#group' => 'vertical_tabs',
+ );
+ $form['tab1']['field1'] = array(
+ '#title' => t('Field 1'),
+ '#type' => 'textfield',
+ );
+ $form['tab2'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Tab 2'),
+ '#collapsible' => TRUE,
+ '#group' => 'vertical_tabs',
+ );
+ $form['tab2']['field2'] = array(
+ '#title' => t('Field 2'),
+ '#type' => 'textfield',
+ );
+ return $form;
+}
+
+/**
+ * A multistep form for testing the form storage.
+ *
+ * It uses two steps for editing a virtual "thing". Any changes to it are saved
+ * in the form storage and have to be present during any step. By setting the
+ * request parameter "cache" the form can be tested with caching enabled, as
+ * it would be the case, if the form would contain some #ajax callbacks.
+ *
+ * @see form_test_storage_form_submit()
+ */
+function form_test_storage_form($form, &$form_state) {
+ if ($form_state['rebuild']) {
+ $form_state['input'] = array();
+ }
+ // Initialize
+ if (empty($form_state['storage'])) {
+ if (empty($form_state['input'])) {
+ $_SESSION['constructions'] = 0;
+ }
+ // Put the initial thing into the storage
+ $form_state['storage'] = array(
+ 'thing' => array(
+ 'title' => 'none',
+ 'value' => '',
+ ),
+ );
+ }
+ // Count how often the form is constructed.
+ $_SESSION['constructions']++;
+ drupal_set_message("Form constructions: " . $_SESSION['constructions']);
+
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Title',
+ '#default_value' => $form_state['storage']['thing']['title'],
+ '#required' => TRUE,
+ );
+ $form['value'] = array(
+ '#type' => 'textfield',
+ '#title' => 'Value',
+ '#default_value' => $form_state['storage']['thing']['value'],
+ '#element_validate' => array('form_test_storage_element_validate_value_cached'),
+ );
+ $form['continue_button'] = array(
+ '#type' => 'button',
+ '#value' => 'Reset',
+ // Rebuilds the form without keeping the values.
+ );
+ $form['continue_submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Continue submit',
+ '#submit' => array('form_storage_test_form_continue_submit'),
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Save',
+ );
+
+ if (isset($_REQUEST['cache'])) {
+ // Manually activate caching, so we can test that the storage keeps working
+ // when it's enabled.
+ $form_state['cache'] = TRUE;
+ }
+
+ return $form;
+}
+
+/**
+ * Form element validation handler for 'value' element in form_test_storage_form().
+ *
+ * Tests updating of cached form storage during validation.
+ */
+function form_test_storage_element_validate_value_cached($element, &$form_state) {
+ // If caching is enabled and we receive a certain value, change the storage.
+ // This presumes that another submitted form value triggers a validation error
+ // elsewhere in the form. Form API should still update the cached form storage
+ // though.
+ if (isset($_REQUEST['cache']) && $form_state['values']['value'] == 'change_title') {
+ $form_state['storage']['thing']['changed'] = TRUE;
+ }
+}
+
+/**
+ * Form submit handler to continue multi-step form.
+ */
+function form_storage_test_form_continue_submit($form, &$form_state) {
+ $form_state['storage']['thing']['title'] = $form_state['values']['title'];
+ $form_state['storage']['thing']['value'] = $form_state['values']['value'];
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Form submit handler to finish multi-step form.
+ */
+function form_test_storage_form_submit($form, &$form_state) {
+ drupal_set_message("Title: " . check_plain($form_state['values']['title']));
+ drupal_set_message("Form constructions: " . $_SESSION['constructions']);
+ if (isset($form_state['storage']['thing']['changed'])) {
+ drupal_set_message("The thing has been changed.");
+ }
+ $form_state['redirect'] = 'node';
+}
+
+/**
+ * A form for testing form labels and required marks.
+ */
+function form_label_test_form() {
+ $form['form_checkboxes_test'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Checkboxes test'),
+ '#options' => array(
+ 'first-checkbox' => t('First checkbox'),
+ 'second-checkbox' => t('Second checkbox'),
+ 'third-checkbox' => t('Third checkbox'),
+ ),
+ );
+ $form['form_radios_test'] = array(
+ '#type' => 'radios',
+ '#title' => t('Radios test'),
+ '#options' => array(
+ 'first-radio' => t('First radio'),
+ 'second-radio' => t('Second radio'),
+ 'third-radio' => t('Third radio'),
+ ),
+ // Test #field_prefix and #field_suffix placement.
+ '#field_prefix' => '<span id="form-test-radios-field-prefix">' . t('Radios #field_prefix element') . '</span>',
+ '#field_suffix' => '<span id="form-test-radios-field-suffix">' . t('Radios #field_suffix element') . '</span>',
+ );
+ $form['form_checkbox_test'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Checkbox test'),
+ );
+ $form['form_textfield_test_title_and_required'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Textfield test for required with title'),
+ '#required' => TRUE,
+ );
+ $form['form_textfield_test_no_title_required'] = array(
+ '#type' => 'textfield',
+ // We use an empty title, since not setting #title suppresses the label
+ // and required marker.
+ '#title' => '',
+ '#required' => TRUE,
+ );
+ $form['form_textfield_test_title'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Textfield test for title only'),
+ // Not required.
+ // Test #prefix and #suffix placement.
+ '#prefix' => '<div id="form-test-textfield-title-prefix">' . t('Textfield #prefix element') . '</div>',
+ '#suffix' => '<div id="form-test-textfield-title-suffix">' . t('Textfield #suffix element') . '</div>',
+ );
+ $form['form_textfield_test_title_after'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Textfield test for title after element'),
+ '#title_display' => 'after',
+ );
+ $form['form_textfield_test_title_invisible'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Textfield test for invisible title'),
+ '#title_display' => 'invisible',
+ );
+ // Textfield test for title set not to display
+ $form['form_textfield_test_title_no_show'] = array(
+ '#type' => 'textfield',
+ );
+
+ return $form;
+}
+
+/**
+ * Menu callback; Invokes a form builder function with a wrapper callback.
+ */
+function form_test_wrapper_callback($form_id) {
+ $form_state = array(
+ 'build_info' => array('args' => array()),
+ 'wrapper_callback' => 'form_test_wrapper_callback_wrapper',
+ );
+ return drupal_build_form($form_id, $form_state);
+}
+
+/**
+ * Form wrapper for form_test_wrapper_callback_form().
+ */
+function form_test_wrapper_callback_wrapper($form, &$form_state) {
+ $form['wrapper'] = array('#markup' => 'Form wrapper callback element output.');
+ return $form;
+}
+
+/**
+ * Form builder for form wrapper callback test.
+ */
+function form_test_wrapper_callback_form($form, &$form_state) {
+ $form['builder'] = array('#markup' => 'Form builder element output.');
+ return $form;
+}
+
+/**
+ * Form builder for form_state_values_clean() test.
+ */
+function form_test_form_state_values_clean_form($form, &$form_state) {
+ // Build an example form containing multiple submit and button elements; not
+ // only on the top-level.
+ $form = array('#tree' => TRUE);
+ $form['foo'] = array('#type' => 'submit', '#value' => t('Submit'));
+ $form['bar'] = array('#type' => 'submit', '#value' => t('Submit'));
+ $form['beer'] = array('#type' => 'value', '#value' => 1000);
+ $form['baz']['foo'] = array('#type' => 'button', '#value' => t('Submit'));
+ $form['baz']['baz'] = array('#type' => 'submit', '#value' => t('Submit'));
+ $form['baz']['beer'] = array('#type' => 'value', '#value' => 2000);
+ return $form;
+}
+
+/**
+ * Form submit handler for form_state_values_clean() test form.
+ */
+function form_test_form_state_values_clean_form_submit($form, &$form_state) {
+ form_state_values_clean($form_state);
+ drupal_json_output($form_state['values']);
+ exit;
+}
+
+/**
+ * Build a form to test a checkbox.
+ */
+function _form_test_checkbox($form, &$form_state) {
+ // A required checkbox.
+ $form['required_checkbox'] = array(
+ '#type' => 'checkbox',
+ '#required' => TRUE,
+ '#title' => 'required_checkbox',
+ );
+
+ // A disabled checkbox should get its default value back.
+ $form['disabled_checkbox_on'] = array(
+ '#type' => 'checkbox',
+ '#disabled' => TRUE,
+ '#return_value' => 'disabled_checkbox_on',
+ '#default_value' => 'disabled_checkbox_on',
+ '#title' => 'disabled_checkbox_on',
+ );
+ $form['disabled_checkbox_off'] = array(
+ '#type' => 'checkbox',
+ '#disabled' => TRUE,
+ '#return_value' => 'disabled_checkbox_off',
+ '#default_value' => NULL,
+ '#title' => 'disabled_checkbox_off',
+ );
+
+ // A checkbox is active when #default_value == #return_value.
+ $form['checkbox_on'] = array(
+ '#type' => 'checkbox',
+ '#return_value' => 'checkbox_on',
+ '#default_value' => 'checkbox_on',
+ '#title' => 'checkbox_on',
+ );
+
+ // But inactive in any other case.
+ $form['checkbox_off'] = array(
+ '#type' => 'checkbox',
+ '#return_value' => 'checkbox_off',
+ '#default_value' => 'checkbox_on',
+ '#title' => 'checkbox_off',
+ );
+
+ // Checkboxes with a #return_value of '0' are supported.
+ $form['zero_checkbox_on'] = array(
+ '#type' => 'checkbox',
+ '#return_value' => '0',
+ '#default_value' => '0',
+ '#title' => 'zero_checkbox_on',
+ );
+
+ // In that case, passing a #default_value != '0' means that the checkbox is off.
+ $form['zero_checkbox_off'] = array(
+ '#type' => 'checkbox',
+ '#return_value' => '0',
+ '#default_value' => '1',
+ '#title' => 'zero_checkbox_off',
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit')
+ );
+
+ return $form;
+}
+
+/**
+ * Return the form values via JSON.
+ */
+function _form_test_checkbox_submit($form, &$form_state) {
+ drupal_json_output($form_state['values']);
+ exit();
+}
+
+/**
+ * Builds a form to test #type 'select' validation.
+ */
+function form_test_select($form, &$form_state) {
+ $base = array(
+ '#type' => 'select',
+ '#options' => drupal_map_assoc(array('one', 'two', 'three')),
+ );
+
+ $form['select'] = $base + array(
+ '#title' => '#default_value one',
+ '#default_value' => 'one',
+ );
+ $form['select_required'] = $base + array(
+ '#title' => '#default_value one, #required',
+ '#required' => TRUE,
+ '#default_value' => 'one',
+ );
+ $form['select_optional'] = $base + array(
+ '#title' => '#default_value one, not #required',
+ '#required' => FALSE,
+ '#default_value' => 'one',
+ );
+ $form['empty_value'] = $base + array(
+ '#title' => '#default_value one, #required, #empty_value 0',
+ '#required' => TRUE,
+ '#default_value' => 'one',
+ '#empty_value' => 0,
+ );
+ $form['empty_value_one'] = $base + array(
+ '#title' => '#default_value = #empty_value, #required',
+ '#required' => TRUE,
+ '#default_value' => 'one',
+ '#empty_value' => 'one',
+ );
+
+ $form['no_default'] = $base + array(
+ '#title' => 'No #default_value, #required',
+ '#required' => TRUE,
+ );
+ $form['no_default_optional'] = $base + array(
+ '#title' => 'No #default_value, not #required',
+ '#required' => FALSE,
+ '#description' => 'Should result in "one" because it is not required and there is no #empty_value requested, so default browser behavior of preselecting first option is in effect.',
+ );
+ $form['no_default_optional_empty_value'] = $base + array(
+ '#title' => 'No #default_value, not #required, #empty_value empty string',
+ '#empty_value' => '',
+ '#required' => FALSE,
+ '#description' => 'Should result in an empty string (due to #empty_value) because it is optional.',
+ );
+
+ $form['no_default_empty_option'] = $base + array(
+ '#title' => 'No #default_value, #required, #empty_option',
+ '#required' => TRUE,
+ '#empty_option' => '- Choose -',
+ );
+ $form['no_default_empty_option_optional'] = $base + array(
+ '#title' => 'No #default_value, not #required, #empty_option',
+ '#empty_option' => '- Dismiss -',
+ '#description' => 'Should result in an empty string (default of #empty_value) because it is optional.',
+ );
+
+ $form['no_default_empty_value'] = $base + array(
+ '#title' => 'No #default_value, #required, #empty_value 0',
+ '#required' => TRUE,
+ '#empty_value' => 0,
+ '#description' => 'Should never result in 0.',
+ );
+ $form['no_default_empty_value_one'] = $base + array(
+ '#title' => 'No #default_value, #required, #empty_value one',
+ '#required' => TRUE,
+ '#empty_value' => 'one',
+ '#description' => 'A mistakenly assigned #empty_value contained in #options should not be valid.',
+ );
+ $form['no_default_empty_value_optional'] = $base + array(
+ '#title' => 'No #default_value, not #required, #empty_value 0',
+ '#required' => FALSE,
+ '#empty_value' => 0,
+ '#description' => 'Should result in 0 because it is optional.',
+ );
+
+ $form['multiple'] = $base + array(
+ '#title' => '#multiple, #default_value two',
+ '#default_value' => array('two'),
+ '#multiple' => TRUE,
+ );
+ $form['multiple_no_default'] = $base + array(
+ '#title' => '#multiple, no #default_value',
+ '#multiple' => TRUE,
+ );
+ $form['multiple_no_default_required'] = $base + array(
+ '#title' => '#multiple, #required, no #default_value',
+ '#required' => TRUE,
+ '#multiple' => TRUE,
+ );
+
+ $form['submit'] = array('#type' => 'submit', '#value' => 'Submit');
+ return $form;
+}
+
+/**
+ * Form submit handler for form_test_select().
+ */
+function form_test_select_submit($form, &$form_state) {
+ drupal_json_output($form_state['values']);
+ exit();
+}
+
+/**
+ * Builds a form to test the placeholder attribute.
+ */
+function form_test_placeholder_test($form, &$form_state) {
+ foreach (array('textfield', 'textarea', 'password') as $type) {
+ $form[$type] = array(
+ '#type' => $type,
+ '#title' => $type,
+ '#placeholder' => 'placeholder-text',
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Form constructor to test expansion of #type checkboxes and radios.
+ */
+function form_test_checkboxes_radios($form, &$form_state, $customize = FALSE) {
+ $form['#submit'] = array('_form_test_submit_values_json');
+
+ // Expand #type checkboxes, setting custom element properties for some but not
+ // all options.
+ $form['checkboxes'] = array(
+ '#type' => 'checkboxes',
+ '#title' => 'Checkboxes',
+ '#options' => array(
+ 0 => 'Zero',
+ 'foo' => 'Foo',
+ 1 => 'One',
+ 'bar' => 'Bar',
+ '>' => 'Special Char',
+ ),
+ );
+ if ($customize) {
+ $form['checkboxes'] += array(
+ 'foo' => array(
+ '#description' => 'Enable to foo.',
+ ),
+ 1 => array(
+ '#weight' => 10,
+ ),
+ );
+ }
+
+ // Expand #type radios, setting custom element properties for some but not
+ // all options.
+ $form['radios'] = array(
+ '#type' => 'radios',
+ '#title' => 'Radios',
+ '#options' => array(
+ 0 => 'Zero',
+ 'foo' => 'Foo',
+ 1 => 'One',
+ 'bar' => 'Bar',
+ '>' => 'Special Char',
+ ),
+ );
+ if ($customize) {
+ $form['radios'] += array(
+ 'foo' => array(
+ '#description' => 'Enable to foo.',
+ ),
+ 1 => array(
+ '#weight' => 10,
+ ),
+ );
+ }
+
+ $form['submit'] = array('#type' => 'submit', '#value' => 'Submit');
+
+ return $form;
+}
+
+/**
+ * Build a form to test disabled elements.
+ */
+function _form_test_disabled_elements($form, &$form_state) {
+ // Elements that take a simple default value.
+ foreach (array('textfield', 'textarea', 'hidden') as $type) {
+ $form[$type] = array(
+ '#type' => $type,
+ '#title' => $type,
+ '#default_value' => $type,
+ '#test_hijack_value' => 'HIJACK',
+ '#disabled' => TRUE,
+ );
+ }
+
+ // Multiple values option elements.
+ foreach (array('checkboxes', 'select') as $type) {
+ $form[$type . '_multiple'] = array(
+ '#type' => $type,
+ '#title' => $type . ' (multiple)',
+ '#options' => array(
+ 'test_1' => 'Test 1',
+ 'test_2' => 'Test 2',
+ ),
+ '#multiple' => TRUE,
+ '#default_value' => array('test_2' => 'test_2'),
+ // The keys of #test_hijack_value need to match the #name of the control.
+ // @see FormsTestCase::testDisabledElements()
+ '#test_hijack_value' => $type == 'select' ? array('' => 'test_1') : array('test_1' => 'test_1'),
+ '#disabled' => TRUE,
+ );
+ }
+
+ // Single values option elements.
+ foreach (array('radios', 'select') as $type) {
+ $form[$type . '_single'] = array(
+ '#type' => $type,
+ '#title' => $type . ' (single)',
+ '#options' => array(
+ 'test_1' => 'Test 1',
+ 'test_2' => 'Test 2',
+ ),
+ '#multiple' => FALSE,
+ '#default_value' => 'test_2',
+ '#test_hijack_value' => 'test_1',
+ '#disabled' => TRUE,
+ );
+ }
+
+ // Checkbox and radio.
+ foreach (array('checkbox', 'radio') as $type) {
+ $form[$type . '_unchecked'] = array(
+ '#type' => $type,
+ '#title' => $type . ' (unchecked)',
+ '#return_value' => 1,
+ '#default_value' => 0,
+ '#test_hijack_value' => 1,
+ '#disabled' => TRUE,
+ );
+ $form[$type . '_checked'] = array(
+ '#type' => $type,
+ '#title' => $type . ' (checked)',
+ '#return_value' => 1,
+ '#default_value' => 1,
+ '#test_hijack_value' => NULL,
+ '#disabled' => TRUE,
+ );
+ }
+
+ // Weight.
+ $form['weight'] = array(
+ '#type' => 'weight',
+ '#title' => 'weight',
+ '#default_value' => 10,
+ '#test_hijack_value' => 5,
+ '#disabled' => TRUE,
+ );
+
+ // Date.
+ $form['date'] = array(
+ '#type' => 'date',
+ '#title' => 'date',
+ '#disabled' => TRUE,
+ '#default_value' => array(
+ 'day' => 19,
+ 'month' => 11,
+ 'year' => 1978,
+ ),
+ '#test_hijack_value' => array(
+ 'day' => 20,
+ 'month' => 12,
+ 'year' => 1979,
+ ),
+ );
+
+ // The #disabled state should propagate to children.
+ $form['disabled_container'] = array(
+ '#disabled' => TRUE,
+ );
+ foreach (array('textfield', 'textarea', 'hidden') as $type) {
+ $form['disabled_container']['disabled_container_' . $type] = array(
+ '#type' => $type,
+ '#title' => $type,
+ '#default_value' => $type,
+ '#test_hijack_value' => 'HIJACK',
+ );
+ }
+
+ // Text format.
+ $form['text_format'] = array(
+ '#type' => 'text_format',
+ '#title' => 'Text format',
+ '#disabled' => TRUE,
+ '#default_value' => 'Text value',
+ '#format' => 'plain_text',
+ '#expected_value' => array(
+ 'value' => 'Text value',
+ 'format' => 'plain_text',
+ ),
+ '#test_hijack_value' => array(
+ 'value' => 'HIJACK',
+ 'format' => 'filtered_html',
+ ),
+ );
+
+ // Password fields.
+ $form['password'] = array(
+ '#type' => 'password',
+ '#title' => 'Password',
+ '#disabled' => TRUE,
+ );
+ $form['password_confirm'] = array(
+ '#type' => 'password_confirm',
+ '#title' => 'Password confirm',
+ '#disabled' => TRUE,
+ );
+
+ // Files.
+ $form['file'] = array(
+ '#type' => 'file',
+ '#title' => 'File',
+ '#disabled' => TRUE,
+ );
+ $form['managed_file'] = array(
+ '#type' => 'managed_file',
+ '#title' => 'Managed file',
+ '#disabled' => TRUE,
+ );
+
+ // Buttons.
+ $form['image_button'] = array(
+ '#type' => 'image_button',
+ '#value' => 'Image button',
+ '#disabled' => TRUE,
+ );
+ $form['button'] = array(
+ '#type' => 'button',
+ '#value' => 'Button',
+ '#disabled' => TRUE,
+ );
+ $form['submit_disabled'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ '#disabled' => TRUE,
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Return the form values via JSON.
+ */
+function _form_test_disabled_elements_submit($form, &$form_state) {
+ drupal_json_output($form_state['values']);
+ exit();
+}
+
+/**
+ * Build a form to test input forgery of enabled elements.
+ */
+function _form_test_input_forgery($form, &$form_state) {
+ // For testing that a user can't submit a value not matching one of the
+ // allowed options.
+ $form['checkboxes'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array(
+ 'one' => 'One',
+ 'two' => 'Two',
+ ),
+ );
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+ return $form;
+}
+
+/**
+ * Return the form values via JSON.
+ */
+function _form_test_input_forgery_submit($form, &$form_state) {
+ drupal_json_output($form_state['values']);
+ exit();
+}
+
+/**
+ * Form builder for testing preservation of values during a rebuild.
+ */
+function form_test_form_rebuild_preserve_values_form($form, &$form_state) {
+ // Start the form with two checkboxes, to test different defaults, and a
+ // textfield, to test more than one element type.
+ $form = array(
+ 'checkbox_1_default_off' => array(
+ '#type' => 'checkbox',
+ '#title' => t('This checkbox defaults to unchecked.'),
+ '#default_value' => FALSE,
+ ),
+ 'checkbox_1_default_on' => array(
+ '#type' => 'checkbox',
+ '#title' => t('This checkbox defaults to checked.'),
+ '#default_value' => TRUE,
+ ),
+ 'text_1' => array(
+ '#type' => 'textfield',
+ '#title' => t('This textfield has a non-empty default value.'),
+ '#default_value' => 'DEFAULT 1',
+ ),
+ );
+ // Provide an 'add more' button that rebuilds the form with an additional two
+ // checkboxes and a textfield. The test is to make sure that the rebuild
+ // triggered by this button preserves the user input values for the initial
+ // elements and initializes the new elements with the correct default values.
+ if (empty($form_state['storage']['add_more'])) {
+ $form['add_more'] = array(
+ '#type' => 'submit',
+ '#value' => 'Add more',
+ '#submit' => array('form_test_form_rebuild_preserve_values_form_add_more'),
+ );
+ }
+ else {
+ $form += array(
+ 'checkbox_2_default_off' => array(
+ '#type' => 'checkbox',
+ '#title' => t('This checkbox defaults to unchecked.'),
+ '#default_value' => FALSE,
+ ),
+ 'checkbox_2_default_on' => array(
+ '#type' => 'checkbox',
+ '#title' => t('This checkbox defaults to checked.'),
+ '#default_value' => TRUE,
+ ),
+ 'text_2' => array(
+ '#type' => 'textfield',
+ '#title' => t('This textfield has a non-empty default value.'),
+ '#default_value' => 'DEFAULT 2',
+ ),
+ );
+ }
+ // A submit button that finishes the form workflow (does not rebuild).
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ );
+ return $form;
+}
+
+/**
+ * Button submit handler for form_test_form_rebuild_preserve_values_form().
+ */
+function form_test_form_rebuild_preserve_values_form_add_more($form, &$form_state) {
+ // Rebuild, to test preservation of input values.
+ $form_state['storage']['add_more'] = TRUE;
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Form submit handler for form_test_form_rebuild_preserve_values_form().
+ */
+function form_test_form_rebuild_preserve_values_form_submit($form, &$form_state) {
+ // Finish the workflow. Do not rebuild.
+ drupal_set_message(t('Form values: %values', array('%values' => var_export($form_state['values'], TRUE))));
+}
+
+/**
+ * Form constructor for testing form state persistence.
+ */
+function form_test_state_persist($form, &$form_state) {
+ $form['title'] = array(
+ '#type' => 'textfield',
+ '#title' => 'title',
+ '#default_value' => 'DEFAULT',
+ '#required' => TRUE,
+ );
+ $form_state['value'] = 'State persisted.';
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Submit'),
+ );
+ return $form;
+}
+
+/**
+ * Submit handler.
+ *
+ * @see form_test_state_persist()
+ */
+function form_test_state_persist_submit($form, &$form_state) {
+ drupal_set_message($form_state['value']);
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * @see form_test_state_persist()
+ */
+function form_test_form_form_test_state_persist_alter(&$form, &$form_state) {
+ // Simulate a form alter implementation inserting form elements that enable
+ // caching of the form, e.g. elements having #ajax.
+ if (!empty($_REQUEST['cache'])) {
+ $form_state['cache'] = TRUE;
+ }
+}
+
+/**
+ * Form builder to test programmatic form submissions.
+ */
+function form_test_programmatic_form($form, &$form_state) {
+ $form['textfield'] = array(
+ '#title' => 'Textfield',
+ '#type' => 'textfield',
+ );
+
+ $form['checkboxes'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array(
+ 1 => 'First checkbox',
+ 2 => 'Second checkbox',
+ ),
+ // Both checkboxes are selected by default so that we can test the ability
+ // of programmatic form submissions to uncheck them.
+ '#default_value' => array(1, 2),
+ );
+
+ $form['field_to_validate'] = array(
+ '#type' => 'radios',
+ '#title' => 'Field to validate (in the case of limited validation)',
+ '#description' => 'If the form is submitted by clicking the "Submit with limited validation" button, then validation can be limited based on the value of this radio button.',
+ '#options' => array(
+ 'all' => 'Validate all fields',
+ 'textfield' => 'Validate the "Textfield" field',
+ 'field_to_validate' => 'Validate the "Field to validate" field',
+ ),
+ '#default_value' => 'all',
+ );
+
+ // The main submit button for the form.
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit',
+ );
+ // A secondary submit button that allows validation to be limited based on
+ // the value of the above radio selector.
+ $form['submit_limit_validation'] = array(
+ '#type' => 'submit',
+ '#value' => 'Submit with limited validation',
+ // Use the same submit handler for this button as for the form itself.
+ // (This must be set explicitly or otherwise the form API will ignore the
+ // #limit_validation_errors property.)
+ '#submit' => array('form_test_programmatic_form_submit'),
+ );
+ if (!empty($form_state['input']['field_to_validate']) && $form_state['input']['field_to_validate'] != 'all') {
+ $form['submit_limit_validation']['#limit_validation_errors'] = array(
+ array($form_state['input']['field_to_validate']),
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Form validation handler for programmatic form submissions.
+ *
+ * To test that the validation handler is correctly executed, the field value is
+ * explicitly required here.
+ */
+function form_test_programmatic_form_validate($form, &$form_state) {
+ if (empty($form_state['values']['textfield'])) {
+ form_set_error('textfield', t('Textfield is required.'));
+ }
+}
+
+/**
+ * Form submit handler for programmatic form submissions.
+ *
+ * To test that the submission handler is correctly executed, we store the
+ * submitted values in a place we can access from the caller context.
+ */
+function form_test_programmatic_form_submit($form, &$form_state) {
+ $form_state['storage']['programmatic_form_submit'] = $form_state['values'];
+}
+
+/**
+ * Form builder to test button click detection.
+ */
+function form_test_clicked_button($form, &$form_state) {
+ // A single text field. In IE, when a form has only one non-button input field
+ // and the ENTER key is pressed while that field has focus, the form is
+ // submitted without any information identifying the button responsible for
+ // the submission. In other browsers, the form is submitted as though the
+ // first button were clicked.
+ $form['text'] = array(
+ '#title' => 'Text',
+ '#type' => 'textfield',
+ );
+
+ // Loop through each path argument, addding buttons based on the information
+ // in the argument. For example, if the path is
+ // form-test/clicked-button/s/i/rb, then 3 buttons are added: a 'submit', an
+ // 'image_button', and a 'button' with #access=FALSE. This enables form.test
+ // to test a variety of combinations.
+ $i=0;
+ $args = array_slice(arg(), 2);
+ foreach ($args as $arg) {
+ $name = 'button' . ++$i;
+ // 's', 'b', or 'i' in the argument define the button type wanted.
+ if (strpos($arg, 's') !== FALSE) {
+ $type = 'submit';
+ }
+ elseif (strpos($arg, 'b') !== FALSE) {
+ $type = 'button';
+ }
+ elseif (strpos($arg, 'i') !== FALSE) {
+ $type = 'image_button';
+ }
+ else {
+ $type = NULL;
+ }
+ if (isset($type)) {
+ $form[$name] = array(
+ '#type' => $type,
+ '#name' => $name,
+ );
+ // Image buttons need a #src; the others need a #value.
+ if ($type == 'image_button') {
+ $form[$name]['#src'] = 'core/misc/druplicon.png';
+ }
+ else {
+ $form[$name]['#value'] = $name;
+ }
+ // 'r' for restricted, so we can test that button click detection code
+ // correctly takes #access security into account.
+ if (strpos($arg, 'r') !== FALSE) {
+ $form[$name]['#access'] = FALSE;
+ }
+ }
+ }
+
+ return $form;
+}
+
+/**
+ * Form validation handler for the form_test_clicked_button() form.
+ */
+function form_test_clicked_button_validate($form, &$form_state) {
+ if (isset($form_state['triggering_element'])) {
+ drupal_set_message(t('The clicked button is %name.', array('%name' => $form_state['triggering_element']['#name'])));
+ }
+ else {
+ drupal_set_message('There is no clicked button.');
+ }
+}
+
+/**
+ * Form submit handler for the form_test_clicked_button() form.
+ */
+function form_test_clicked_button_submit($form, &$form_state) {
+ drupal_set_message('Submit handler for form_test_clicked_button executed.');
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for the registration form.
+ */
+function form_test_form_user_register_form_alter(&$form, &$form_state) {
+ $form['test_rebuild'] = array(
+ '#type' => 'submit',
+ '#value' => t('Rebuild'),
+ '#submit' => array('form_test_user_register_form_rebuild'),
+ );
+ // If requested, add the test field by attaching the node page form.
+ if (!empty($_REQUEST['field'])) {
+ $node = (object)array('type' => 'page');
+ field_attach_form('node', $node, $form, $form_state);
+ }
+}
+
+/**
+ * Submit callback that just lets the form rebuild.
+ */
+function form_test_user_register_form_rebuild($form, &$form_state) {
+ drupal_set_message('Form rebuilt.');
+ $form_state['rebuild'] = TRUE;
+}
+
+/**
+ * Menu callback that returns two instances of the node form.
+ */
+function form_test_two_instances() {
+ global $user;
+ $node1 = (object) array(
+ 'uid' => $user->uid,
+ 'name' => (isset($user->name) ? $user->name : ''),
+ 'type' => 'page',
+ 'language' => LANGUAGE_NONE,
+ );
+ $node2 = clone($node1);
+ $return['node_form_1'] = drupal_get_form('page_node_form', $node1);
+ $return['node_form_2'] = drupal_get_form('page_node_form', $node2);
+ return $return;
+}
+
+/**
+ * Menu callback for testing custom form includes.
+ */
+function form_test_load_include_custom($form, &$form_state) {
+ $form['button'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#submit' => array('form_test_load_include_submit'),
+ );
+ // Specify the include file and enable form caching. That way the form is
+ // cached when it is submitted, but needs to find the specified submit handler
+ // in the include.
+ // Filename is a bit weird here: modules/simpletest/tests/form_test.file.inc
+ form_load_include($form_state, 'inc', 'form_test', 'form_test.file');
+ $form_state['cache'] = TRUE;
+ return $form;
+}
+
+function form_test_checkbox_type_juggling($form, $form_state, $default_value, $return_value) {
+ $form['checkbox'] = array(
+ '#type' => 'checkbox',
+ '#return_value' => $return_value,
+ '#default_value' => $default_value,
+ );
+ return $form;
+}
+
+function form_test_checkboxes_zero($form, &$form_state, $json = TRUE) {
+ $form['checkbox_off'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array('foo', 'bar', 'baz'),
+ );
+ $form['checkbox_zero_default'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array('foo', 'bar', 'baz'),
+ '#default_value' => array(0),
+ );
+ $form['checkbox_string_zero_default'] = array(
+ '#type' => 'checkboxes',
+ '#options' => array('foo', 'bar', 'baz'),
+ '#default_value' => array('0'),
+ );
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Save',
+ );
+ if ($json) {
+ $form['#submit'][] = '_form_test_checkbox_submit';
+ }
+ else {
+ $form['#submit'][] = '_form_test_checkboxes_zero_no_redirect';
+ }
+ return $form;
+}
+
+function _form_test_checkboxes_zero_no_redirect($form, &$form_state) {
+ $form_state['redirect'] = FALSE;
+}
diff --git a/core/modules/simpletest/tests/graph.test b/core/modules/simpletest/tests/graph.test
new file mode 100644
index 000000000000..e60cd390b173
--- /dev/null
+++ b/core/modules/simpletest/tests/graph.test
@@ -0,0 +1,195 @@
+<?php
+
+/**
+ * @file
+ * Provides unit tests for graph.inc.
+ */
+
+/**
+ * Unit tests for the graph handling features.
+ */
+class GraphUnitTest extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Graph',
+ 'description' => 'Graph handling unit tests.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ require_once DRUPAL_ROOT . '/core/includes/graph.inc';
+ parent::setUp();
+ }
+
+ /**
+ * Test depth-first-search features.
+ */
+ function testDepthFirstSearch() {
+ // The sample graph used is:
+ // 1 --> 2 --> 3 5 ---> 6
+ // | ^ ^
+ // | | |
+ // | | |
+ // +---> 4 <-- 7 8 ---> 9
+ $graph = $this->normalizeGraph(array(
+ 1 => array(2),
+ 2 => array(3, 4),
+ 3 => array(),
+ 4 => array(3),
+ 5 => array(6),
+ 7 => array(4, 5),
+ 8 => array(9),
+ 9 => array(),
+ ));
+ drupal_depth_first_search($graph);
+
+ $expected_paths = array(
+ 1 => array(2, 3, 4),
+ 2 => array(3, 4),
+ 3 => array(),
+ 4 => array(3),
+ 5 => array(6),
+ 7 => array(4, 3, 5, 6),
+ 8 => array(9),
+ 9 => array(),
+ );
+ $this->assertPaths($graph, $expected_paths);
+
+ $expected_reverse_paths = array(
+ 1 => array(),
+ 2 => array(1),
+ 3 => array(2, 1, 4, 7),
+ 4 => array(2, 1, 7),
+ 5 => array(7),
+ 7 => array(),
+ 8 => array(),
+ 9 => array(8),
+ );
+ $this->assertReversePaths($graph, $expected_reverse_paths);
+
+ // Assert that DFS didn't created "missing" vertexes automatically.
+ $this->assertFALSE(isset($graph[6]), t('Vertex 6 has not been created'));
+
+ $expected_components = array(
+ array(1, 2, 3, 4, 5, 7),
+ array(8, 9),
+ );
+ $this->assertComponents($graph, $expected_components);
+
+ $expected_weights = array(
+ array(1, 2, 3),
+ array(2, 4, 3),
+ array(7, 4, 3),
+ array(7, 5),
+ array(8, 9),
+ );
+ $this->assertWeights($graph, $expected_weights);
+ }
+
+ /**
+ * Return a normalized version of a graph.
+ */
+ function normalizeGraph($graph) {
+ $normalized_graph = array();
+ foreach ($graph as $vertex => $edges) {
+ // Create vertex even if it hasn't any edges.
+ $normalized_graph[$vertex] = array();
+ foreach ($edges as $edge) {
+ $normalized_graph[$vertex]['edges'][$edge] = TRUE;
+ }
+ }
+ return $normalized_graph;
+ }
+
+ /**
+ * Verify expected paths in a graph.
+ *
+ * @param $graph
+ * A graph array processed by drupal_depth_first_search().
+ * @param $expected_paths
+ * An associative array containing vertices with their expected paths.
+ */
+ function assertPaths($graph, $expected_paths) {
+ foreach ($expected_paths as $vertex => $paths) {
+ // Build an array with keys = $paths and values = TRUE.
+ $expected = array_fill_keys($paths, TRUE);
+ $result = isset($graph[$vertex]['paths']) ? $graph[$vertex]['paths'] : array();
+ $this->assertEqual($expected, $result, t('Expected paths for vertex @vertex: @expected-paths, got @paths', array('@vertex' => $vertex, '@expected-paths' => $this->displayArray($expected, TRUE), '@paths' => $this->displayArray($result, TRUE))));
+ }
+ }
+
+ /**
+ * Verify expected reverse paths in a graph.
+ *
+ * @param $graph
+ * A graph array processed by drupal_depth_first_search().
+ * @param $expected_reverse_paths
+ * An associative array containing vertices with their expected reverse
+ * paths.
+ */
+ function assertReversePaths($graph, $expected_reverse_paths) {
+ foreach ($expected_reverse_paths as $vertex => $paths) {
+ // Build an array with keys = $paths and values = TRUE.
+ $expected = array_fill_keys($paths, TRUE);
+ $result = isset($graph[$vertex]['reverse_paths']) ? $graph[$vertex]['reverse_paths'] : array();
+ $this->assertEqual($expected, $result, t('Expected reverse paths for vertex @vertex: @expected-paths, got @paths', array('@vertex' => $vertex, '@expected-paths' => $this->displayArray($expected, TRUE), '@paths' => $this->displayArray($result, TRUE))));
+ }
+ }
+
+ /**
+ * Verify expected components in a graph.
+ *
+ * @param $graph
+ * A graph array processed by drupal_depth_first_search().
+ * @param $expected_components
+ * An array containing of components defined as a list of their vertices.
+ */
+ function assertComponents($graph, $expected_components) {
+ $unassigned_vertices = array_fill_keys(array_keys($graph), TRUE);
+ foreach ($expected_components as $component) {
+ $result_components = array();
+ foreach ($component as $vertex) {
+ $result_components[] = $graph[$vertex]['component'];
+ unset($unassigned_vertices[$vertex]);
+ }
+ $this->assertEqual(1, count(array_unique($result_components)), t('Expected one unique component for vertices @vertices, got @components', array('@vertices' => $this->displayArray($component), '@components' => $this->displayArray($result_components))));
+ }
+ $this->assertEqual(array(), $unassigned_vertices, t('Vertices not assigned to a component: @vertices', array('@vertices' => $this->displayArray($unassigned_vertices, TRUE))));
+ }
+
+ /**
+ * Verify expected order in a graph.
+ *
+ * @param $graph
+ * A graph array processed by drupal_depth_first_search().
+ * @param $expected_orders
+ * An array containing lists of vertices in their expected order.
+ */
+ function assertWeights($graph, $expected_orders) {
+ foreach ($expected_orders as $order) {
+ $previous_vertex = array_shift($order);
+ foreach ($order as $vertex) {
+ $this->assertTrue($graph[$previous_vertex]['weight'] < $graph[$vertex]['weight'], t('Weights of @previous-vertex and @vertex are correct relative to each other', array('@previous-vertex' => $previous_vertex, '@vertex' => $vertex)));
+ }
+ }
+ }
+
+ /**
+ * Helper function to output vertices as comma-separated list.
+ *
+ * @param $paths
+ * An array containing a list of vertices.
+ * @param $keys
+ * (optional) Whether to output the keys of $paths instead of the values.
+ */
+ function displayArray($paths, $keys = FALSE) {
+ if (!empty($paths)) {
+ return implode(', ', $keys ? array_keys($paths) : $paths);
+ }
+ else {
+ return '(empty)';
+ }
+ }
+}
+
diff --git a/core/modules/simpletest/tests/http.php b/core/modules/simpletest/tests/http.php
new file mode 100644
index 000000000000..91985a63eed9
--- /dev/null
+++ b/core/modules/simpletest/tests/http.php
@@ -0,0 +1,32 @@
+<?php
+
+/**
+ * @file
+ * Fake an HTTP request, for use during testing.
+ */
+
+// Set a global variable to indicate a mock HTTP request.
+$is_http_mock = !empty($_SERVER['HTTPS']);
+
+// Change to HTTP.
+$_SERVER['HTTPS'] = NULL;
+ini_set('session.cookie_secure', FALSE);
+foreach ($_SERVER as $key => $value) {
+ $_SERVER[$key] = str_replace('core/modules/simpletest/tests/http.php', 'index.php', $value);
+ $_SERVER[$key] = str_replace('https://', 'http://', $_SERVER[$key]);
+}
+
+// Change current directory to the Drupal root.
+chdir('../../../..');
+define('DRUPAL_ROOT', getcwd());
+require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+
+// Make sure this file can only be used by simpletest.
+drupal_bootstrap(DRUPAL_BOOTSTRAP_CONFIGURATION);
+if (!drupal_valid_test_ua()) {
+ header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
+ exit;
+}
+
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+menu_execute_active_handler();
diff --git a/core/modules/simpletest/tests/https.php b/core/modules/simpletest/tests/https.php
new file mode 100644
index 000000000000..c342abcf7735
--- /dev/null
+++ b/core/modules/simpletest/tests/https.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Fake an https request, for use during testing.
+ */
+
+// Set a global variable to indicate a mock HTTPS request.
+$is_https_mock = empty($_SERVER['HTTPS']);
+
+// Change to https.
+$_SERVER['HTTPS'] = 'on';
+foreach ($_SERVER as $key => $value) {
+ $_SERVER[$key] = str_replace('core/modules/simpletest/tests/https.php', 'index.php', $value);
+ $_SERVER[$key] = str_replace('http://', 'https://', $_SERVER[$key]);
+}
+
+// Change current directory to the Drupal root.
+chdir('../../../..');
+define('DRUPAL_ROOT', getcwd());
+require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+
+// Make sure this file can only be used by simpletest.
+drupal_bootstrap(DRUPAL_BOOTSTRAP_CONFIGURATION);
+if (!drupal_valid_test_ua()) {
+ header($_SERVER['SERVER_PROTOCOL'] . ' 403 Forbidden');
+ exit;
+}
+
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+menu_execute_active_handler();
diff --git a/core/modules/simpletest/tests/image.test b/core/modules/simpletest/tests/image.test
new file mode 100644
index 000000000000..60599bee529a
--- /dev/null
+++ b/core/modules/simpletest/tests/image.test
@@ -0,0 +1,460 @@
+<?php
+
+/**
+ * @file
+ * Tests for core image handling API.
+ */
+
+/**
+ * Base class for image manipulation testing.
+ */
+class ImageToolkitTestCase extends DrupalWebTestCase {
+ protected $toolkit;
+ protected $file;
+ protected $image;
+
+ function setUp() {
+ parent::setUp('image_test');
+
+ // Use the image_test.module's test toolkit.
+ $this->toolkit = 'test';
+
+ // Pick a file for testing.
+ $file = current($this->drupalGetTestFiles('image'));
+ $this->file = $file->uri;
+
+ // Setup a dummy image to work with, this replicate image_load() so we
+ // can avoid calling it.
+ $this->image = new stdClass();
+ $this->image->source = $this->file;
+ $this->image->info = image_get_info($this->file);
+ $this->image->toolkit = $this->toolkit;
+
+ // Clear out any hook calls.
+ image_test_reset();
+ }
+
+ /**
+ * Assert that all of the specified image toolkit operations were called
+ * exactly once once, other values result in failure.
+ *
+ * @param $expected
+ * Array with string containing with the operation name, e.g. 'load',
+ * 'save', 'crop', etc.
+ */
+ function assertToolkitOperationsCalled(array $expected) {
+ // Determine which operations were called.
+ $actual = array_keys(array_filter(image_test_get_all_calls()));
+
+ // Determine if there were any expected that were not called.
+ $uncalled = array_diff($expected, $actual);
+ if (count($uncalled)) {
+ $this->assertTrue(FALSE, t('Expected operations %expected to be called but %uncalled was not called.', array('%expected' => implode(', ', $expected), '%uncalled' => implode(', ', $uncalled))));
+ }
+ else {
+ $this->assertTrue(TRUE, t('All the expected operations were called: %expected', array('%expected' => implode(', ', $expected))));
+ }
+
+ // Determine if there were any unexpected calls.
+ $unexpected = array_diff($actual, $expected);
+ if (count($unexpected)) {
+ $this->assertTrue(FALSE, t('Unexpected operations were called: %unexpected.', array('%unexpected' => implode(', ', $unexpected))));
+ }
+ else {
+ $this->assertTrue(TRUE, t('No unexpected operations were called.'));
+ }
+ }
+}
+
+/**
+ * Test that the functions in image.inc correctly pass data to the toolkit.
+ */
+class ImageToolkitUnitTest extends ImageToolkitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image toolkit tests',
+ 'description' => 'Check image toolkit functions.',
+ 'group' => 'Image',
+ );
+ }
+
+ /**
+ * Check that hook_image_toolkits() is called and only available toolkits are
+ * returned.
+ */
+ function testGetAvailableToolkits() {
+ $toolkits = image_get_available_toolkits();
+ $this->assertTrue(isset($toolkits['test']), t('The working toolkit was returned.'));
+ $this->assertFalse(isset($toolkits['broken']), t('The toolkit marked unavailable was not returned'));
+ $this->assertToolkitOperationsCalled(array());
+ }
+
+ /**
+ * Test the image_load() function.
+ */
+ function testLoad() {
+ $image = image_load($this->file, $this->toolkit);
+ $this->assertTrue(is_object($image), t('Returned an object.'));
+ $this->assertEqual($this->toolkit, $image->toolkit, t('Image had toolkit set.'));
+ $this->assertToolkitOperationsCalled(array('load', 'get_info'));
+ }
+
+ /**
+ * Test the image_save() function.
+ */
+ function testSave() {
+ $this->assertFalse(image_save($this->image), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('save'));
+ }
+
+ /**
+ * Test the image_resize() function.
+ */
+ function testResize() {
+ $this->assertTrue(image_resize($this->image, 1, 2), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['resize'][0][1], 1, t('Width was passed correctly'));
+ $this->assertEqual($calls['resize'][0][2], 2, t('Height was passed correctly'));
+ }
+
+ /**
+ * Test the image_scale() function.
+ */
+ function testScale() {
+// TODO: need to test upscaling
+ $this->assertTrue(image_scale($this->image, 10, 10), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['resize'][0][1], 10, t('Width was passed correctly'));
+ $this->assertEqual($calls['resize'][0][2], 5, t('Height was based off aspect ratio and passed correctly'));
+ }
+
+ /**
+ * Test the image_scale_and_crop() function.
+ */
+ function testScaleAndCrop() {
+ $this->assertTrue(image_scale_and_crop($this->image, 5, 10), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('resize', 'crop'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+
+ $this->assertEqual($calls['crop'][0][1], 7.5, t('X was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][2], 0, t('Y was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][3], 5, t('Width was computed and passed correctly'));
+ $this->assertEqual($calls['crop'][0][4], 10, t('Height was computed and passed correctly'));
+ }
+
+ /**
+ * Test the image_rotate() function.
+ */
+ function testRotate() {
+ $this->assertTrue(image_rotate($this->image, 90, 1), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('rotate'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['rotate'][0][1], 90, t('Degrees were passed correctly'));
+ $this->assertEqual($calls['rotate'][0][2], 1, t('Background color was passed correctly'));
+ }
+
+ /**
+ * Test the image_crop() function.
+ */
+ function testCrop() {
+ $this->assertTrue(image_crop($this->image, 1, 2, 3, 4), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('crop'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual($calls['crop'][0][1], 1, t('X was passed correctly'));
+ $this->assertEqual($calls['crop'][0][2], 2, t('Y was passed correctly'));
+ $this->assertEqual($calls['crop'][0][3], 3, t('Width was passed correctly'));
+ $this->assertEqual($calls['crop'][0][4], 4, t('Height was passed correctly'));
+ }
+
+ /**
+ * Test the image_desaturate() function.
+ */
+ function testDesaturate() {
+ $this->assertTrue(image_desaturate($this->image), t('Function returned the expected value.'));
+ $this->assertToolkitOperationsCalled(array('desaturate'));
+
+ // Check the parameters.
+ $calls = image_test_get_all_calls();
+ $this->assertEqual(count($calls['desaturate'][0]), 1, t('Only the image was passed.'));
+ }
+}
+
+/**
+ * Test the core GD image manipulation functions.
+ */
+class ImageToolkitGdTestCase extends DrupalWebTestCase {
+ // Colors that are used in testing.
+ protected $black = array(0, 0, 0, 0);
+ protected $red = array(255, 0, 0, 0);
+ protected $green = array(0, 255, 0, 0);
+ protected $blue = array(0, 0, 255, 0);
+ protected $yellow = array(255, 255, 0, 0);
+ protected $fuchsia = array(255, 0, 255, 0); // Used as background colors.
+ protected $transparent = array(0, 0, 0, 127);
+ protected $white = array(255, 255, 255, 0);
+
+ protected $width = 40;
+ protected $height = 20;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Image GD manipulation tests',
+ 'description' => 'Check that core image manipulations work properly: scale, resize, rotate, crop, scale and crop, and desaturate.',
+ 'group' => 'Image',
+ );
+ }
+
+ /**
+ * Function to compare two colors by RGBa.
+ */
+ function colorsAreEqual($color_a, $color_b) {
+ // Fully transparent pixels are equal, regardless of RGB.
+ if ($color_a[3] == 127 && $color_b[3] == 127) {
+ return TRUE;
+ }
+
+ foreach ($color_a as $key => $value) {
+ if ($color_b[$key] != $value) {
+ return FALSE;
+ }
+ }
+
+ return TRUE;
+ }
+
+ /**
+ * Function for finding a pixel's RGBa values.
+ */
+ function getPixelColor($image, $x, $y) {
+ $color_index = imagecolorat($image->resource, $x, $y);
+
+ $transparent_index = imagecolortransparent($image->resource);
+ if ($color_index == $transparent_index) {
+ return array(0, 0, 0, 127);
+ }
+
+ return array_values(imagecolorsforindex($image->resource, $color_index));
+ }
+
+ /**
+ * Since PHP can't visually check that our images have been manipulated
+ * properly, build a list of expected color values for each of the corners and
+ * the expected height and widths for the final images.
+ */
+ function testManipulations() {
+ // If GD isn't available don't bother testing this.
+ if (!function_exists('image_gd_check_settings') || !image_gd_check_settings()) {
+ $this->pass(t('Image manipulations for the GD toolkit were skipped because the GD toolkit is not available.'));
+ return;
+ }
+
+ // Typically the corner colors will be unchanged. These colors are in the
+ // order of top-left, top-right, bottom-right, bottom-left.
+ $default_corners = array($this->red, $this->green, $this->blue, $this->transparent);
+
+ // A list of files that will be tested.
+ $files = array(
+ 'image-test.png',
+ 'image-test.gif',
+ 'image-test.jpg',
+ );
+
+ // Setup a list of tests to perform on each type.
+ $operations = array(
+ 'resize' => array(
+ 'function' => 'resize',
+ 'arguments' => array(20, 10),
+ 'width' => 20,
+ 'height' => 10,
+ 'corners' => $default_corners,
+ ),
+ 'scale_x' => array(
+ 'function' => 'scale',
+ 'arguments' => array(20, NULL),
+ 'width' => 20,
+ 'height' => 10,
+ 'corners' => $default_corners,
+ ),
+ 'scale_y' => array(
+ 'function' => 'scale',
+ 'arguments' => array(NULL, 10),
+ 'width' => 20,
+ 'height' => 10,
+ 'corners' => $default_corners,
+ ),
+ 'upscale_x' => array(
+ 'function' => 'scale',
+ 'arguments' => array(80, NULL, TRUE),
+ 'width' => 80,
+ 'height' => 40,
+ 'corners' => $default_corners,
+ ),
+ 'upscale_y' => array(
+ 'function' => 'scale',
+ 'arguments' => array(NULL, 40, TRUE),
+ 'width' => 80,
+ 'height' => 40,
+ 'corners' => $default_corners,
+ ),
+ 'crop' => array(
+ 'function' => 'crop',
+ 'arguments' => array(12, 4, 16, 12),
+ 'width' => 16,
+ 'height' => 12,
+ 'corners' => array_fill(0, 4, $this->white),
+ ),
+ 'scale_and_crop' => array(
+ 'function' => 'scale_and_crop',
+ 'arguments' => array(10, 8),
+ 'width' => 10,
+ 'height' => 8,
+ 'corners' => array_fill(0, 4, $this->black),
+ ),
+ );
+
+ // Systems using non-bundled GD2 don't have imagerotate. Test if available.
+ if (function_exists('imagerotate')) {
+ $operations += array(
+ 'rotate_5' => array(
+ 'function' => 'rotate',
+ 'arguments' => array(5, 0xFF00FF), // Fuchsia background.
+ 'width' => 42,
+ 'height' => 24,
+ 'corners' => array_fill(0, 4, $this->fuchsia),
+ ),
+ 'rotate_90' => array(
+ 'function' => 'rotate',
+ 'arguments' => array(90, 0xFF00FF), // Fuchsia background.
+ 'width' => 20,
+ 'height' => 40,
+ 'corners' => array($this->fuchsia, $this->red, $this->green, $this->blue),
+ ),
+ 'rotate_transparent_5' => array(
+ 'function' => 'rotate',
+ 'arguments' => array(5),
+ 'width' => 42,
+ 'height' => 24,
+ 'corners' => array_fill(0, 4, $this->transparent),
+ ),
+ 'rotate_transparent_90' => array(
+ 'function' => 'rotate',
+ 'arguments' => array(90),
+ 'width' => 20,
+ 'height' => 40,
+ 'corners' => array($this->transparent, $this->red, $this->green, $this->blue),
+ ),
+ );
+ }
+
+ // Systems using non-bundled GD2 don't have imagefilter. Test if available.
+ if (function_exists('imagefilter')) {
+ $operations += array(
+ 'desaturate' => array(
+ 'function' => 'desaturate',
+ 'arguments' => array(),
+ 'height' => 20,
+ 'width' => 40,
+ // Grayscale corners are a bit funky. Each of the corners are a shade of
+ // gray. The values of these were determined simply by looking at the
+ // final image to see what desaturated colors end up being.
+ 'corners' => array(
+ array_fill(0, 3, 76) + array(3 => 0),
+ array_fill(0, 3, 149) + array(3 => 0),
+ array_fill(0, 3, 29) + array(3 => 0),
+ array_fill(0, 3, 0) + array(3 => 127)
+ ),
+ ),
+ );
+ }
+
+ foreach ($files as $file) {
+ foreach ($operations as $op => $values) {
+ // Load up a fresh image.
+ $image = image_load(drupal_get_path('module', 'simpletest') . '/files/' . $file, 'gd');
+ if (!$image) {
+ $this->fail(t('Could not load image %file.', array('%file' => $file)));
+ continue 2;
+ }
+
+ // Transparent GIFs and the imagefilter function don't work together.
+ // There is a todo in image.gd.inc to correct this.
+ if ($image->info['extension'] == 'gif') {
+ if ($op == 'desaturate') {
+ $values['corners'][3] = $this->white;
+ }
+ }
+
+ // Perform our operation.
+ $function = 'image_' . $values['function'];
+ $arguments = array();
+ $arguments[] = &$image;
+ $arguments = array_merge($arguments, $values['arguments']);
+ call_user_func_array($function, $arguments);
+
+ // To keep from flooding the test with assert values, make a general
+ // value for whether each group of values fail.
+ $correct_dimensions_real = TRUE;
+ $correct_dimensions_object = TRUE;
+ $correct_colors = TRUE;
+
+ // Check the real dimensions of the image first.
+ if (imagesy($image->resource) != $values['height'] || imagesx($image->resource) != $values['width']) {
+ $correct_dimensions_real = FALSE;
+ }
+
+ // Check that the image object has an accurate record of the dimensions.
+ if ($image->info['width'] != $values['width'] || $image->info['height'] != $values['height']) {
+ $correct_dimensions_object = FALSE;
+ }
+ // Now check each of the corners to ensure color correctness.
+ foreach ($values['corners'] as $key => $corner) {
+ // Get the location of the corner.
+ switch ($key) {
+ case 0:
+ $x = 0;
+ $y = 0;
+ break;
+ case 1:
+ $x = $values['width'] - 1;
+ $y = 0;
+ break;
+ case 2:
+ $x = $values['width'] - 1;
+ $y = $values['height'] - 1;
+ break;
+ case 3:
+ $x = 0;
+ $y = $values['height'] - 1;
+ break;
+ }
+ $color = $this->getPixelColor($image, $x, $y);
+ $correct_colors = $this->colorsAreEqual($color, $corner);
+ }
+
+ $directory = file_default_scheme() . '://imagetests';
+ file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
+ image_save($image, $directory . '/' . $op . '.' . $image->info['extension']);
+
+ $this->assertTrue($correct_dimensions_real, t('Image %file after %action action has proper dimensions.', array('%file' => $file, '%action' => $op)));
+ $this->assertTrue($correct_dimensions_object, t('Image %file object after %action action is reporting the proper height and width values.', array('%file' => $file, '%action' => $op)));
+ // JPEG colors will always be messed up due to compression.
+ if ($image->info['extension'] != 'jpg') {
+ $this->assertTrue($correct_colors, t('Image %file object after %action action has the correct color placement.', array('%file' => $file, '%action' => $op)));
+ }
+ }
+ }
+
+ }
+}
diff --git a/core/modules/simpletest/tests/image_test.info b/core/modules/simpletest/tests/image_test.info
new file mode 100644
index 000000000000..becc207718dd
--- /dev/null
+++ b/core/modules/simpletest/tests/image_test.info
@@ -0,0 +1,6 @@
+name = "Image test"
+description = "Support module for image toolkit tests."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/image_test.module b/core/modules/simpletest/tests/image_test.module
new file mode 100644
index 000000000000..de640f0ba24c
--- /dev/null
+++ b/core/modules/simpletest/tests/image_test.module
@@ -0,0 +1,138 @@
+<?php
+
+/**
+ * @file
+ * Helper module for the image tests.
+ */
+
+/**
+ * Implements hook_image_toolkits().
+ */
+function image_test_image_toolkits() {
+ return array(
+ 'test' => array(
+ 'title' => t('A dummy toolkit that works'),
+ 'available' => TRUE,
+ ),
+ 'broken' => array(
+ 'title' => t('A dummy toolkit that is "broken"'),
+ 'available' => FALSE,
+ ),
+ );
+}
+
+/**
+ * Reset/initialize the history of calls to the toolkit functions.
+ *
+ * @see image_test_get_all_calls()
+ */
+function image_test_reset() {
+ // Keep track of calls to these operations
+ $results = array(
+ 'load' => array(),
+ 'save' => array(),
+ 'settings' => array(),
+ 'resize' => array(),
+ 'rotate' => array(),
+ 'crop' => array(),
+ 'desaturate' => array(),
+ );
+ variable_set('image_test_results', $results);
+}
+
+/**
+ * Get an array with the all the calls to the toolkits since image_test_reset()
+ * was called.
+ *
+ * @return
+ * An array keyed by operation name ('load', 'save', 'settings', 'resize',
+ * 'rotate', 'crop', 'desaturate') with values being arrays of parameters
+ * passed to each call.
+ */
+function image_test_get_all_calls() {
+ return variable_get('image_test_results', array());
+}
+
+/**
+ * Store the values passed to a toolkit call.
+ *
+ * @param $op
+ * One of the image toolkit operations: 'get_info', 'load', 'save',
+ * 'settings', 'resize', 'rotate', 'crop', 'desaturate'.
+ * @param $args
+ * Values passed to hook.
+ *
+ * @see image_test_get_all_calls()
+ * @see image_test_reset()
+ */
+function _image_test_log_call($op, $args) {
+ $results = variable_get('image_test_results', array());
+ $results[$op][] = $args;
+ variable_set('image_test_results', $results);
+}
+
+/**
+ * Image tookit's settings operation.
+ */
+function image_test_settings() {
+ _image_test_log_call('settings', array());
+ return array();
+}
+
+/**
+ * Image toolkit's get_info operation.
+ */
+function image_test_get_info(stdClass $image) {
+ _image_test_log_call('get_info', array($image));
+ return array();
+}
+
+/**
+ * Image tookit's load operation.
+ */
+function image_test_load(stdClass $image) {
+ _image_test_log_call('load', array($image));
+ return $image;
+}
+
+/**
+ * Image tookit's save operation.
+ */
+function image_test_save(stdClass $image, $destination) {
+ _image_test_log_call('save', array($image, $destination));
+ // Return false so that image_save() doesn't try to chmod the destination
+ // file that we didn't bother to create.
+ return FALSE;
+}
+
+/**
+ * Image tookit's crop operation.
+ */
+function image_test_crop(stdClass $image, $x, $y, $width, $height) {
+ _image_test_log_call('crop', array($image, $x, $y, $width, $height));
+ return TRUE;
+}
+
+/**
+ * Image tookit's resize operation.
+ */
+function image_test_resize(stdClass $image, $width, $height) {
+ _image_test_log_call('resize', array($image, $width, $height));
+ return TRUE;
+}
+
+/**
+ * Image tookit's rotate operation.
+ */
+function image_test_rotate(stdClass $image, $degrees, $background = NULL) {
+ _image_test_log_call('rotate', array($image, $degrees, $background));
+ return TRUE;
+}
+
+/**
+ * Image tookit's desaturate operation.
+ */
+function image_test_desaturate(stdClass $image) {
+ _image_test_log_call('desaturate', array($image));
+ return TRUE;
+}
diff --git a/core/modules/simpletest/tests/lock.test b/core/modules/simpletest/tests/lock.test
new file mode 100644
index 000000000000..0b423ffdd4d6
--- /dev/null
+++ b/core/modules/simpletest/tests/lock.test
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * Tests for the lock system.
+ */
+class LockFunctionalTest extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Locking framework tests',
+ 'description' => 'Confirm locking works between two separate requests.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+ }
+
+ /**
+ * Confirm that we can acquire and release locks in two parallel requests.
+ */
+ function testLockAcquire() {
+ $lock_acquired = 'TRUE: Lock successfully acquired in system_test_lock_acquire()';
+ $lock_not_acquired = 'FALSE: Lock not acquired in system_test_lock_acquire()';
+ $this->assertTrue(lock_acquire('system_test_lock_acquire'), t('Lock acquired by this request.'), t('Lock'));
+ $this->assertTrue(lock_acquire('system_test_lock_acquire'), t('Lock extended by this request.'), t('Lock'));
+ lock_release('system_test_lock_acquire');
+
+ // Cause another request to acquire the lock.
+ $this->drupalGet('system-test/lock-acquire');
+ $this->assertText($lock_acquired, t('Lock acquired by the other request.'), t('Lock'));
+ // The other request has finished, thus it should have released its lock.
+ $this->assertTrue(lock_acquire('system_test_lock_acquire'), t('Lock acquired by this request.'), t('Lock'));
+ // This request holds the lock, so the other request cannot acquire it.
+ $this->drupalGet('system-test/lock-acquire');
+ $this->assertText($lock_not_acquired, t('Lock not acquired by the other request.'), t('Lock'));
+ lock_release('system_test_lock_acquire');
+
+ // Try a very short timeout and lock breaking.
+ $this->assertTrue(lock_acquire('system_test_lock_acquire', 0.5), t('Lock acquired by this request.'), t('Lock'));
+ sleep(1);
+ // The other request should break our lock.
+ $this->drupalGet('system-test/lock-acquire');
+ $this->assertText($lock_acquired, t('Lock acquired by the other request, breaking our lock.'), t('Lock'));
+ // We cannot renew it, since the other thread took it.
+ $this->assertFalse(lock_acquire('system_test_lock_acquire'), t('Lock cannot be extended by this request.'), t('Lock'));
+
+ // Check the shut-down function.
+ $lock_acquired_exit = 'TRUE: Lock successfully acquired in system_test_lock_exit()';
+ $lock_not_acquired_exit = 'FALSE: Lock not acquired in system_test_lock_exit()';
+ $this->drupalGet('system-test/lock-exit');
+ $this->assertText($lock_acquired_exit, t('Lock acquired by the other request before exit.'), t('Lock'));
+ $this->assertTrue(lock_acquire('system_test_lock_exit'), t('Lock acquired by this request after the other request exits.'), t('Lock'));
+ }
+}
+
diff --git a/core/modules/simpletest/tests/mail.test b/core/modules/simpletest/tests/mail.test
new file mode 100644
index 000000000000..a6c7b40e5ef3
--- /dev/null
+++ b/core/modules/simpletest/tests/mail.test
@@ -0,0 +1,418 @@
+<?php
+
+/**
+ * @file
+ * Test the Drupal mailing system.
+ */
+class MailTestCase extends DrupalWebTestCase implements MailSystemInterface {
+ /**
+ * The most recent message that was sent through the test case.
+ *
+ * We take advantage here of the fact that static variables are shared among
+ * all instance of the same class.
+ */
+ private static $sent_message;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Mail system',
+ 'description' => 'Performs tests on the pluggable mailing framework.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Set MailTestCase (i.e. this class) as the SMTP library
+ variable_set('mail_system', array('default-system' => 'MailTestCase'));
+ }
+
+ /**
+ * Assert that the pluggable mail system is functional.
+ */
+ function testPluggableFramework() {
+ global $language;
+
+ // Use MailTestCase for sending a message.
+ $message = drupal_mail('simpletest', 'mail_test', 'testing@drupal.org', $language);
+
+ // Assert whether the message was sent through the send function.
+ $this->assertEqual(self::$sent_message['to'], 'testing@drupal.org', t('Pluggable mail system is extendable.'));
+ }
+
+ /**
+ * Concatenate and wrap the e-mail body for plain-text mails.
+ *
+ * @see DefaultMailSystem
+ */
+ public function format(array $message) {
+ // Join the body array into one string.
+ $message['body'] = implode("\n\n", $message['body']);
+ // Convert any HTML to plain-text.
+ $message['body'] = drupal_html_to_text($message['body']);
+ // Wrap the mail body for sending.
+ $message['body'] = drupal_wrap_mail($message['body']);
+ return $message;
+ }
+
+ /**
+ * Send function that is called through the mail system.
+ */
+ public function mail(array $message) {
+ self::$sent_message = $message;
+ }
+}
+
+/**
+ * Unit tests for drupal_html_to_text().
+ */
+class DrupalHtmlToTextTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'HTML to text conversion',
+ 'description' => 'Tests drupal_html_to_text().',
+ 'group' => 'Mail',
+ );
+ }
+
+ /**
+ * Converts a string to its PHP source equivalent for display in test messages.
+ *
+ * @param $text
+ * The text string to convert.
+ *
+ * @return
+ * An HTML representation of the text string that, when displayed in a
+ * browser, represents the PHP source code equivalent of $text.
+ */
+ function stringToHtml($text) {
+ return '"' .
+ str_replace(
+ array("\n", ' '),
+ array('\n', '&nbsp;'),
+ check_plain($text)
+ ) . '"';
+ }
+
+ /**
+ * Helper function for testing drupal_html_to_text().
+ *
+ * @param $html
+ * The source HTML string to be converted.
+ * @param $text
+ * The expected result of converting $html to text.
+ * @param $message
+ * A text message to display in the assertion message.
+ * @param $allowed_tags
+ * (optional) An array of allowed tags, or NULL to default to the full
+ * set of tags supported by drupal_html_to_text().
+ */
+ function assertHtmlToText($html, $text, $message, $allowed_tags = NULL) {
+ preg_match_all('/<([a-z0-6]+)/', drupal_strtolower($html), $matches);
+ $tested_tags = implode(', ', array_unique($matches[1]));
+ $message .= ' (' . $tested_tags . ')';
+ $result = drupal_html_to_text($html, $allowed_tags);
+ $pass = $this->assertEqual($result, $text, check_plain($message));
+ $verbose = 'html = <pre>' . $this->stringToHtml($html)
+ . '</pre><br />' . 'result = <pre>' . $this->stringToHtml($result)
+ . '</pre><br />' . 'expected = <pre>' . $this->stringToHtml($text)
+ . '</pre>';
+ $this->verbose($verbose);
+ if (!$pass) {
+ $this->pass("Previous test verbose info:<br />$verbose");
+ }
+ }
+
+ /**
+ * Test all supported tags of drupal_html_to_text().
+ */
+ function testTags() {
+ global $base_path, $base_url;
+ $tests = array(
+ // @todo Trailing linefeeds should be trimmed.
+ '<a href = "http://drupal.org">Drupal.org</a>' => "Drupal.org [1]\n\n[1] http://drupal.org\n",
+ // @todo Footer urls should be absolute.
+ "<a href = \"$base_path\">Homepage</a>" => "Homepage [1]\n\n[1] $base_url/\n",
+ '<address>Drupal</address>' => "Drupal\n",
+ // @todo The <address> tag is currently not supported.
+ '<address>Drupal</address><address>Drupal</address>' => "DrupalDrupal\n",
+ '<b>Drupal</b>' => "*Drupal*\n",
+ // @todo There should be a space between the '>' and the text.
+ '<blockquote>Drupal</blockquote>' => ">Drupal\n",
+ '<blockquote>Drupal</blockquote><blockquote>Drupal</blockquote>' => ">Drupal\n>Drupal\n",
+ '<br />Drupal<br />Drupal<br /><br />Drupal' => "Drupal\nDrupal\nDrupal\n",
+ '<br/>Drupal<br/>Drupal<br/><br/>Drupal' => "Drupal\nDrupal\nDrupal\n",
+ // @todo There should be two line breaks before the paragraph.
+ '<br/>Drupal<br/>Drupal<br/><br/>Drupal<p>Drupal</p>' => "Drupal\nDrupal\nDrupal\nDrupal\n\n",
+ '<div>Drupal</div>' => "Drupal\n",
+ // @todo The <div> tag is currently not supported.
+ '<div>Drupal</div><div>Drupal</div>' => "DrupalDrupal\n",
+ '<em>Drupal</em>' => "/Drupal/\n",
+ '<h1>Drupal</h1>' => "======== DRUPAL ==============================================================\n\n",
+ '<h1>Drupal</h1><p>Drupal</p>' => "======== DRUPAL ==============================================================\n\nDrupal\n\n",
+ '<h2>Drupal</h2>' => "-------- DRUPAL --------------------------------------------------------------\n\n",
+ '<h2>Drupal</h2><p>Drupal</p>' => "-------- DRUPAL --------------------------------------------------------------\n\nDrupal\n\n",
+ '<h3>Drupal</h3>' => ".... Drupal\n\n",
+ '<h3>Drupal</h3><p>Drupal</p>' => ".... Drupal\n\nDrupal\n\n",
+ '<h4>Drupal</h4>' => ".. Drupal\n\n",
+ '<h4>Drupal</h4><p>Drupal</p>' => ".. Drupal\n\nDrupal\n\n",
+ '<h5>Drupal</h5>' => "Drupal\n\n",
+ '<h5>Drupal</h5><p>Drupal</p>' => "Drupal\n\nDrupal\n\n",
+ '<h6>Drupal</h6>' => "Drupal\n\n",
+ '<h6>Drupal</h6><p>Drupal</p>' => "Drupal\n\nDrupal\n\n",
+ '<hr />Drupal<hr />' => "------------------------------------------------------------------------------\nDrupal\n------------------------------------------------------------------------------\n",
+ '<hr/>Drupal<hr/>' => "------------------------------------------------------------------------------\nDrupal\n------------------------------------------------------------------------------\n",
+ '<hr/>Drupal<hr/><p>Drupal</p>' => "------------------------------------------------------------------------------\nDrupal\n------------------------------------------------------------------------------\nDrupal\n\n",
+ '<i>Drupal</i>' => "/Drupal/\n",
+ '<p>Drupal</p>' => "Drupal\n\n",
+ '<p>Drupal</p><p>Drupal</p>' => "Drupal\n\nDrupal\n\n",
+ '<strong>Drupal</strong>' => "*Drupal*\n",
+ // @todo Tables are currently not supported.
+ '<table><tr><td>Drupal</td><td>Drupal</td></tr><tr><td>Drupal</td><td>Drupal</td></tr></table>' => "DrupalDrupalDrupalDrupal\n",
+ '<table><tr><td>Drupal</td></tr></table><p>Drupal</p>' => "Drupal\nDrupal\n\n",
+ // @todo The <u> tag is currently not supported.
+ '<u>Drupal</u>' => "Drupal\n",
+ '<ul><li>Drupal</li></ul>' => " * Drupal\n\n",
+ '<ul><li>Drupal <em>Drupal</em> Drupal</li></ul>' => " * Drupal /Drupal/ Drupal\n\n",
+ // @todo Lines containing nothing but spaces should be trimmed.
+ '<ul><li>Drupal</li><li><ol><li>Drupal</li><li>Drupal</li></ol></li></ul>' => " * Drupal\n * 1) Drupal\n 2) Drupal\n \n\n",
+ '<ul><li>Drupal</li><li><ol><li>Drupal</li></ol></li><li>Drupal</li></ul>' => " * Drupal\n * 1) Drupal\n \n * Drupal\n\n",
+ '<ul><li>Drupal</li><li>Drupal</li></ul>' => " * Drupal\n * Drupal\n\n",
+ '<ul><li>Drupal</li></ul><p>Drupal</p>' => " * Drupal\n\nDrupal\n\n",
+ '<ol><li>Drupal</li></ol>' => " 1) Drupal\n\n",
+ '<ol><li>Drupal</li><li><ul><li>Drupal</li><li>Drupal</li></ul></li></ol>' => " 1) Drupal\n 2) * Drupal\n * Drupal\n \n\n",
+ '<ol><li>Drupal</li><li>Drupal</li></ol>' => " 1) Drupal\n 2) Drupal\n\n",
+ '<ol>Drupal</ol>' => "Drupal\n\n",
+ '<ol><li>Drupal</li></ol><p>Drupal</p>' => " 1) Drupal\n\nDrupal\n\n",
+ '<dl><dt>Drupal</dt></dl>' => "Drupal\n\n",
+ '<dl><dt>Drupal</dt><dd>Drupal</dd></dl>' => "Drupal\n Drupal\n\n",
+ '<dl><dt>Drupal</dt><dd>Drupal</dd><dt>Drupal</dt><dd>Drupal</dd></dl>' => "Drupal\n Drupal\nDrupal\n Drupal\n\n",
+ '<dl><dt>Drupal</dt><dd>Drupal</dd></dl><p>Drupal</p>' => "Drupal\n Drupal\n\nDrupal\n\n",
+ '<dl><dt>Drupal<dd>Drupal</dl>' => "Drupal\n Drupal\n\n",
+ '<dl><dt>Drupal</dt></dl><p>Drupal</p>' => "Drupal\n\nDrupal\n\n",
+ // @todo Again, lines containing only spaces should be trimmed.
+ '<ul><li>Drupal</li><li><dl><dt>Drupal</dt><dd>Drupal</dd><dt>Drupal</dt><dd>Drupal</dd></dl></li><li>Drupal</li></ul>' => " * Drupal\n * Drupal\n Drupal\n Drupal\n Drupal\n \n * Drupal\n\n",
+ // Tests malformed HTML tags.
+ '<br>Drupal<br>Drupal' => "Drupal\nDrupal\n",
+ '<hr>Drupal<hr>Drupal' => "------------------------------------------------------------------------------\nDrupal\n------------------------------------------------------------------------------\nDrupal\n",
+ '<ol><li>Drupal<li>Drupal</ol>' => " 1) Drupal\n 2) Drupal\n\n",
+ '<ul><li>Drupal <em>Drupal</em> Drupal</ul></ul>' => " * Drupal /Drupal/ Drupal\n\n",
+ '<ul><li>Drupal<li>Drupal</ol>' => " * Drupal\n * Drupal\n\n",
+ '<ul><li>Drupal<li>Drupal</ul>' => " * Drupal\n * Drupal\n\n",
+ '<ul>Drupal</ul>' => "Drupal\n\n",
+ 'Drupal</ul></ol></dl><li>Drupal' => "Drupal\n * Drupal\n",
+ '<dl>Drupal</dl>' => "Drupal\n\n",
+ '<dl>Drupal</dl><p>Drupal</p>' => "Drupal\n\nDrupal\n\n",
+ '<dt>Drupal</dt>' => "Drupal\n",
+ // Tests some unsupported HTML tags.
+ '<html>Drupal</html>' => "Drupal\n",
+ // @todo Perhaps the contents of <script> tags should be dropped.
+ '<script type="text/javascript">Drupal</script>' => "Drupal\n",
+ );
+
+ foreach ($tests as $html => $text) {
+ $this->assertHtmlToText($html, $text, 'Supported tags');
+ }
+ }
+
+ /**
+ * Test $allowed_tags argument of drupal_html_to_text().
+ */
+ function testDrupalHtmlToTextArgs() {
+ // The second parameter of drupal_html_to_text() overrules the allowed tags.
+ $this->assertHtmlToText(
+ 'Drupal <b>Drupal</b> Drupal',
+ "Drupal *Drupal* Drupal\n",
+ 'Allowed <b> tag found',
+ array('b')
+ );
+ $this->assertHtmlToText(
+ 'Drupal <h1>Drupal</h1> Drupal',
+ "Drupal Drupal Drupal\n",
+ 'Disallowed <h1> tag not found',
+ array('b')
+ );
+
+ $this->assertHtmlToText(
+ 'Drupal <p><em><b>Drupal</b></em><p> Drupal',
+ "Drupal Drupal Drupal\n",
+ 'Disallowed <p>, <em>, and <b> tags not found',
+ array('a', 'br', 'h1')
+ );
+
+ $this->assertHtmlToText(
+ '<html><body>Drupal</body></html>',
+ "Drupal\n",
+ 'Unsupported <html> and <body> tags not found',
+ array('html', 'body')
+ );
+ }
+
+ /**
+ * Test that whitespace is collapsed.
+ */
+ function testDrupalHtmltoTextCollapsesWhitespace() {
+ $input = "<p>Drupal Drupal\n\nDrupal<pre>Drupal Drupal\n\nDrupal</pre>Drupal Drupal\n\nDrupal</p>";
+ // @todo The whitespace should be collapsed.
+ $collapsed = "Drupal Drupal\n\nDrupalDrupal Drupal\n\nDrupalDrupal Drupal\n\nDrupal\n\n";
+ $this->assertHtmlToText(
+ $input,
+ $collapsed,
+ 'Whitespace is collapsed',
+ array('p')
+ );
+ }
+
+ /**
+ * Test that text separated by block-level tags in HTML get separated by
+ * (at least) a newline in the plaintext version.
+ */
+ function testDrupalHtmlToTextBlockTagToNewline() {
+ $input = '[text]'
+ . '<blockquote>[blockquote]</blockquote>'
+ . '<br />[br]'
+ . '<dl><dt>[dl-dt]</dt>'
+ . '<dt>[dt]</dt>'
+ . '<dd>[dd]</dd>'
+ . '<dd>[dd-dl]</dd></dl>'
+ . '<h1>[h1]</h1>'
+ . '<h2>[h2]</h2>'
+ . '<h3>[h3]</h3>'
+ . '<h4>[h4]</h4>'
+ . '<h5>[h5]</h5>'
+ . '<h6>[h6]</h6>'
+ . '<hr />[hr]'
+ . '<ol><li>[ol-li]</li>'
+ . '<li>[li]</li>'
+ . '<li>[li-ol]</li></ol>'
+ . '<p>[p]</p>'
+ . '<ul><li>[ul-li]</li>'
+ . '<li>[li-ul]</li></ul>'
+ . '[text]';
+ $output = drupal_html_to_text($input);
+ $pass = $this->assertFalse(
+ preg_match('/\][^\n]*\[/s', $output),
+ 'Block-level HTML tags should force newlines'
+ );
+ if (!$pass) {
+ $this->verbose($this->stringToHtml($output));
+ }
+ $output_upper = drupal_strtoupper($output);
+ $upper_input = drupal_strtoupper($input);
+ $upper_output = drupal_html_to_text($upper_input);
+ $pass = $this->assertEqual(
+ $upper_output,
+ $output_upper,
+ 'Tag recognition should be case-insensitive'
+ );
+ if (!$pass) {
+ $this->verbose(
+ $upper_output
+ . '<br />should be equal to <br />'
+ . $output_upper
+ );
+ }
+ }
+
+ /**
+ * Test that headers are properly separated from surrounding text.
+ */
+ function testHeaderSeparation() {
+ $html = 'Drupal<h1>Drupal</h1>Drupal';
+ // @todo There should be more space above the header than below it.
+ $text = "Drupal\n======== DRUPAL ==============================================================\n\nDrupal\n";
+ $this->assertHtmlToText($html, $text,
+ 'Text before and after <h1> tag');
+ $html = '<p>Drupal</p><h1>Drupal</h1>Drupal';
+ // @todo There should be more space above the header than below it.
+ $text = "Drupal\n\n======== DRUPAL ==============================================================\n\nDrupal\n";
+ $this->assertHtmlToText($html, $text,
+ 'Paragraph before and text after <h1> tag');
+ $html = 'Drupal<h1>Drupal</h1><p>Drupal</p>';
+ // @todo There should be more space above the header than below it.
+ $text = "Drupal\n======== DRUPAL ==============================================================\n\nDrupal\n\n";
+ $this->assertHtmlToText($html, $text,
+ 'Text before and paragraph after <h1> tag');
+ $html = '<p>Drupal</p><h1>Drupal</h1><p>Drupal</p>';
+ $text = "Drupal\n\n======== DRUPAL ==============================================================\n\nDrupal\n\n";
+ $this->assertHtmlToText($html, $text,
+ 'Paragraph before and after <h1> tag');
+ }
+
+ /**
+ * Test that footnote references are properly generated.
+ */
+ function testFootnoteReferences() {
+ global $base_path, $base_url;
+ $source = '<a href="http://www.example.com/node/1">Host and path</a>'
+ . '<br /><a href="http://www.example.com">Host, no path</a>'
+ . '<br /><a href="' . $base_path . 'node/1">Path, no host</a>'
+ . '<br /><a href="node/1">Relative path</a>';
+ // @todo Footnote urls should be absolute.
+ $tt = "Host and path [1]"
+ . "\nHost, no path [2]"
+ // @todo The following two references should be combined.
+ . "\nPath, no host [3]"
+ . "\nRelative path [4]"
+ . "\n"
+ . "\n[1] http://www.example.com/node/1"
+ . "\n[2] http://www.example.com"
+ // @todo The following two references should be combined.
+ . "\n[3] $base_url/node/1"
+ . "\n[4] node/1\n";
+ $this->assertHtmlToText($source, $tt, 'Footnotes');
+ }
+
+ /**
+ * Test that combinations of paragraph breaks, line breaks, linefeeds,
+ * and spaces are properly handled.
+ */
+ function testDrupalHtmlToTextParagraphs() {
+ $tests = array();
+ $tests[] = array(
+ 'html' => "<p>line 1<br />\nline 2<br />line 3\n<br />line 4</p><p>paragraph</p>",
+ // @todo Trailing line breaks should be trimmed.
+ 'text' => "line 1\nline 2\nline 3\nline 4\n\nparagraph\n\n",
+ );
+ $tests[] = array(
+ 'html' => "<p>line 1<br /> line 2</p> <p>line 4<br /> line 5</p> <p>0</p>",
+ // @todo Trailing line breaks should be trimmed.
+ 'text' => "line 1\nline 2\n\nline 4\nline 5\n\n0\n\n",
+ );
+ foreach ($tests as $test) {
+ $this->assertHtmlToText($test['html'], $test['text'], 'Paragraph breaks');
+ }
+ }
+
+ /**
+ * Tests that drupal_html_to_text() wraps before 1000 characters.
+ *
+ * RFC 3676 says, "The Text/Plain media type is the lowest common
+ * denominator of Internet email, with lines of no more than 998 characters."
+ *
+ * RFC 2046 says, "SMTP [RFC-821] allows a maximum of 998 octets before the
+ * next CRLF sequence."
+ *
+ * RFC 821 says, "The maximum total length of a text line including the
+ * <CRLF> is 1000 characters."
+ */
+ function testVeryLongLineWrap() {
+ $input = 'Drupal<br /><p>' . str_repeat('x', 2100) . '</><br />Drupal';
+ $output = drupal_html_to_text($input);
+ // This awkward construct comes from includes/mail.inc lines 8-13.
+ $eol = variable_get('mail_line_endings', MAIL_LINE_ENDINGS);
+ // We must use strlen() rather than drupal_strlen() in order to count
+ // octets rather than characters.
+ $line_length_limit = 1000 - drupal_strlen($eol);
+ $maximum_line_length = 0;
+ foreach (explode($eol, $output) as $line) {
+ // We must use strlen() rather than drupal_strlen() in order to count
+ // octets rather than characters.
+ $maximum_line_length = max($maximum_line_length, strlen($line . $eol));
+ }
+ $verbose = 'Maximum line length found was ' . $maximum_line_length . ' octets.';
+ // @todo This should assert that $maximum_line_length <= 1000.
+ $this->pass($verbose);
+ }
+}
diff --git a/core/modules/simpletest/tests/menu.test b/core/modules/simpletest/tests/menu.test
new file mode 100644
index 000000000000..d0612ac77a6b
--- /dev/null
+++ b/core/modules/simpletest/tests/menu.test
@@ -0,0 +1,1621 @@
+<?php
+
+/**
+ * @file
+ * Provides SimpleTests for menu.inc.
+ */
+
+class MenuWebTestCase extends DrupalWebTestCase {
+ function setUp() {
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ parent::setUp($modules);
+ }
+
+ /**
+ * Assert that a given path shows certain breadcrumb links.
+ *
+ * @param string $goto
+ * (optional) A system path to pass to DrupalWebTestCase::drupalGet().
+ * @param array $trail
+ * An associative array whose keys are expected breadcrumb link paths and
+ * whose values are expected breadcrumb link texts (not sanitized).
+ * @param string $page_title
+ * (optional) A page title to additionally assert via
+ * DrupalWebTestCase::assertTitle(). Without site name suffix.
+ * @param array $tree
+ * (optional) An associative array whose keys are link paths and whose
+ * values are link titles (not sanitized) of an expected active trail in a
+ * menu tree output on the page.
+ * @param $last_active
+ * (optional) Whether the last link in $tree is expected to be active (TRUE)
+ * or just to be in the active trail (FALSE).
+ */
+ protected function assertBreadcrumb($goto, array $trail, $page_title = NULL, array $tree = array(), $last_active = TRUE) {
+ if (isset($goto)) {
+ $this->drupalGet($goto);
+ }
+ // Compare paths with actual breadcrumb.
+ $parts = $this->getParts();
+ $pass = TRUE;
+ foreach ($trail as $path => $title) {
+ $url = url($path);
+ $part = array_shift($parts);
+ $pass = ($pass && $part['href'] === $url && $part['text'] === check_plain($title));
+ }
+ // No parts must be left, or an expected "Home" will always pass.
+ $pass = ($pass && empty($parts));
+
+ $this->assertTrue($pass, t('Breadcrumb %parts found on @path.', array(
+ '%parts' => implode(' » ', $trail),
+ '@path' => $this->getUrl(),
+ )));
+
+ // Additionally assert page title, if given.
+ if (isset($page_title)) {
+ $this->assertTitle(strtr('@title | Drupal', array('@title' => $page_title)));
+ }
+
+ // Additionally assert active trail in a menu tree output, if given.
+ if ($tree) {
+ end($tree);
+ $active_link_path = key($tree);
+ $active_link_title = array_pop($tree);
+ $xpath = '';
+ if ($tree) {
+ $i = 0;
+ foreach ($tree as $link_path => $link_title) {
+ $part_xpath = (!$i ? '//' : '/following-sibling::ul/descendant::');
+ $part_xpath .= 'li[contains(@class, :class)]/a[contains(@href, :href) and contains(text(), :title)]';
+ $part_args = array(
+ ':class' => 'active-trail',
+ ':href' => url($link_path),
+ ':title' => $link_title,
+ );
+ $xpath .= $this->buildXPathQuery($part_xpath, $part_args);
+ $i++;
+ }
+ $elements = $this->xpath($xpath);
+ $this->assertTrue(!empty($elements), t('Active trail to current page was found in menu tree.'));
+
+ // Append prefix for active link asserted below.
+ $xpath .= '/following-sibling::ul/descendant::';
+ }
+ else {
+ $xpath .= '//';
+ }
+ $xpath_last_active = ($last_active ? 'and contains(@class, :class-active)' : '');
+ $xpath .= 'li[contains(@class, :class-trail)]/a[contains(@href, :href) ' . $xpath_last_active . 'and contains(text(), :title)]';
+ $args = array(
+ ':class-trail' => 'active-trail',
+ ':class-active' => 'active',
+ ':href' => url($active_link_path),
+ ':title' => $active_link_title,
+ );
+ $elements = $this->xpath($xpath, $args);
+ $this->assertTrue(!empty($elements), t('Active link %title was found in menu tree, including active trail links %tree.', array(
+ '%title' => $active_link_title,
+ '%tree' => implode(' » ', $tree),
+ )));
+ }
+ }
+
+ /**
+ * Returns the breadcrumb contents of the current page in the internal browser.
+ */
+ protected function getParts() {
+ $parts = array();
+ $elements = $this->xpath('//div[@class="breadcrumb"]/a');
+ if (!empty($elements)) {
+ foreach ($elements as $element) {
+ $parts[] = array(
+ 'text' => (string) $element,
+ 'href' => (string) $element['href'],
+ 'title' => (string) $element['title'],
+ );
+ }
+ }
+ return $parts;
+ }
+}
+
+class MenuRouterTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu router',
+ 'description' => 'Tests menu router and hook_menu() functionality.',
+ 'group' => 'Menu',
+ );
+ }
+
+ function setUp() {
+ // Enable dummy module that implements hook_menu.
+ parent::setUp('menu_test');
+ // Make the tests below more robust by explicitly setting the default theme
+ // and administrative theme that they expect.
+ theme_enable(array('bartik'));
+ variable_set('theme_default', 'bartik');
+ variable_set('admin_theme', 'seven');
+ }
+
+ /**
+ * Test title callback set to FALSE.
+ */
+ function testTitleCallbackFalse() {
+ $this->drupalGet('node');
+ $this->assertText('A title with @placeholder', t('Raw text found on the page'));
+ $this->assertNoText(t('A title with @placeholder', array('@placeholder' => 'some other text')), t('Text with placeholder substitutions not found.'));
+ }
+
+ /**
+ * Tests page title of MENU_CALLBACKs.
+ */
+ function testTitleMenuCallback() {
+ // Verify that the menu router item title is not visible.
+ $this->drupalGet('');
+ $this->assertNoText(t('Menu Callback Title'));
+ // Verify that the menu router item title is output as page title.
+ $this->drupalGet('menu_callback_title');
+ $this->assertText(t('Menu Callback Title'));
+ }
+
+ /**
+ * Test the theme callback when it is set to use an administrative theme.
+ */
+ function testThemeCallbackAdministrative() {
+ $this->drupalGet('menu-test/theme-callback/use-admin-theme');
+ $this->assertText('Custom theme: seven. Actual theme: seven.', t('The administrative theme can be correctly set in a theme callback.'));
+ $this->assertRaw('seven/style.css', t("The administrative theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test that the theme callback is properly inherited.
+ */
+ function testThemeCallbackInheritance() {
+ $this->drupalGet('menu-test/theme-callback/use-admin-theme/inheritance');
+ $this->assertText('Custom theme: seven. Actual theme: seven. Theme callback inheritance is being tested.', t('Theme callback inheritance correctly uses the administrative theme.'));
+ $this->assertRaw('seven/style.css', t("The administrative theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test that 'page callback', 'file' and 'file path' keys are properly
+ * inherited from parent menu paths.
+ */
+ function testFileInheritance() {
+ $this->drupalGet('admin/config/development/file-inheritance');
+ $this->assertText('File inheritance test description', t('File inheritance works.'));
+ }
+
+ /**
+ * Test path containing "exotic" characters.
+ */
+ function testExoticPath() {
+ $path = "menu-test/ -._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters.
+ "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string.
+ "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets.
+ $this->drupalGet($path);
+ $this->assertRaw('This is menu_test_callback().');
+ }
+
+ /**
+ * Test the theme callback when the site is in maintenance mode.
+ */
+ function testThemeCallbackMaintenanceMode() {
+ variable_set('maintenance_mode', TRUE);
+
+ // For a regular user, the fact that the site is in maintenance mode means
+ // we expect the theme callback system to be bypassed entirely.
+ $this->drupalGet('menu-test/theme-callback/use-admin-theme');
+ $this->assertRaw('bartik/css/style.css', t("The maintenance theme's CSS appears on the page."));
+
+ // An administrator, however, should continue to see the requested theme.
+ $admin_user = $this->drupalCreateUser(array('access site in maintenance mode'));
+ $this->drupalLogin($admin_user);
+ $this->drupalGet('menu-test/theme-callback/use-admin-theme');
+ $this->assertText('Custom theme: seven. Actual theme: seven.', t('The theme callback system is correctly triggered for an administrator when the site is in maintenance mode.'));
+ $this->assertRaw('seven/style.css', t("The administrative theme's CSS appears on the page."));
+ }
+
+ /**
+ * Make sure the maintenance mode can be bypassed using hook_menu_site_status_alter().
+ *
+ * @see hook_menu_site_status_alter().
+ */
+ function testMaintenanceModeLoginPaths() {
+ variable_set('maintenance_mode', TRUE);
+
+ $offline_message = t('@site is currently under maintenance. We should be back shortly. Thank you for your patience.', array('@site' => variable_get('site_name', 'Drupal')));
+ $this->drupalLogout();
+ $this->drupalGet('node');
+ $this->assertText($offline_message);
+ $this->drupalGet('menu_login_callback');
+ $this->assertText('This is menu_login_callback().', t('Maintenance mode can be bypassed through hook_login_paths().'));
+ }
+
+ /**
+ * Test that an authenticated user hitting 'user/login' gets redirected to
+ * 'user' and 'user/register' gets redirected to the user edit page.
+ */
+ function testAuthUserUserLogin() {
+ $loggedInUser = $this->drupalCreateUser(array());
+ $this->drupalLogin($loggedInUser);
+
+ $this->DrupalGet('user/login');
+ // Check that we got to 'user'.
+ $this->assertTrue($this->url == url('user', array('absolute' => TRUE)), t("Logged-in user redirected to q=user on accessing q=user/login"));
+
+ // user/register should redirect to user/UID/edit.
+ $this->DrupalGet('user/register');
+ $this->assertTrue($this->url == url('user/' . $this->loggedInUser->uid . '/edit', array('absolute' => TRUE)), t("Logged-in user redirected to q=user/UID/edit on accessing q=user/register"));
+ }
+
+ /**
+ * Test the theme callback when it is set to use an optional theme.
+ */
+ function testThemeCallbackOptionalTheme() {
+ // Request a theme that is not enabled.
+ $this->drupalGet('menu-test/theme-callback/use-stark-theme');
+ $this->assertText('Custom theme: NONE. Actual theme: bartik.', t('The theme callback system falls back on the default theme when a theme that is not enabled is requested.'));
+ $this->assertRaw('bartik/css/style.css', t("The default theme's CSS appears on the page."));
+
+ // Now enable the theme and request it again.
+ theme_enable(array('stark'));
+ $this->drupalGet('menu-test/theme-callback/use-stark-theme');
+ $this->assertText('Custom theme: stark. Actual theme: stark.', t('The theme callback system uses an optional theme once it has been enabled.'));
+ $this->assertRaw('stark/layout.css', t("The optional theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test the theme callback when it is set to use a theme that does not exist.
+ */
+ function testThemeCallbackFakeTheme() {
+ $this->drupalGet('menu-test/theme-callback/use-fake-theme');
+ $this->assertText('Custom theme: NONE. Actual theme: bartik.', t('The theme callback system falls back on the default theme when a theme that does not exist is requested.'));
+ $this->assertRaw('bartik/css/style.css', t("The default theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test the theme callback when no theme is requested.
+ */
+ function testThemeCallbackNoThemeRequested() {
+ $this->drupalGet('menu-test/theme-callback/no-theme-requested');
+ $this->assertText('Custom theme: NONE. Actual theme: bartik.', t('The theme callback system falls back on the default theme when no theme is requested.'));
+ $this->assertRaw('bartik/css/style.css', t("The default theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test that hook_custom_theme() can control the theme of a page.
+ */
+ function testHookCustomTheme() {
+ // Trigger hook_custom_theme() to dynamically request the Stark theme for
+ // the requested page.
+ variable_set('menu_test_hook_custom_theme_name', 'stark');
+ theme_enable(array('stark'));
+
+ // Visit a page that does not implement a theme callback. The above request
+ // should be honored.
+ $this->drupalGet('menu-test/no-theme-callback');
+ $this->assertText('Custom theme: stark. Actual theme: stark.', t('The result of hook_custom_theme() is used as the theme for the current page.'));
+ $this->assertRaw('stark/layout.css', t("The Stark theme's CSS appears on the page."));
+ }
+
+ /**
+ * Test that the theme callback wins out over hook_custom_theme().
+ */
+ function testThemeCallbackHookCustomTheme() {
+ // Trigger hook_custom_theme() to dynamically request the Stark theme for
+ // the requested page.
+ variable_set('menu_test_hook_custom_theme_name', 'stark');
+ theme_enable(array('stark'));
+
+ // The menu "theme callback" should take precedence over a value set in
+ // hook_custom_theme().
+ $this->drupalGet('menu-test/theme-callback/use-admin-theme');
+ $this->assertText('Custom theme: seven. Actual theme: seven.', t('The result of hook_custom_theme() does not override what was set in a theme callback.'));
+ $this->assertRaw('seven/style.css', t("The Seven theme's CSS appears on the page."));
+ }
+
+ /**
+ * Tests for menu_link_maintain().
+ */
+ function testMenuLinkMaintain() {
+ $admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($admin_user);
+
+ // Create three menu items.
+ menu_link_maintain('menu_test', 'insert', 'menu_test_maintain/1', 'Menu link #1');
+ menu_link_maintain('menu_test', 'insert', 'menu_test_maintain/1', 'Menu link #1-1');
+ menu_link_maintain('menu_test', 'insert', 'menu_test_maintain/2', 'Menu link #2');
+
+ // Move second link to the main-menu, to test caching later on.
+ db_update('menu_links')
+ ->fields(array('menu_name' => 'main-menu'))
+ ->condition('link_title', 'Menu link #1-1')
+ ->condition('customized', 0)
+ ->condition('module', 'menu_test')
+ ->execute();
+ menu_cache_clear('main-menu');
+
+ // Load front page.
+ $this->drupalGet('node');
+ $this->assertLink(t('Menu link #1'), 0, 'Found menu link #1');
+ $this->assertLink(t('Menu link #1-1'), 0, 'Found menu link #1-1');
+ $this->assertLink(t('Menu link #2'), 0, 'Found menu link #2');
+
+ // Rename all links for the given path.
+ menu_link_maintain('menu_test', 'update', 'menu_test_maintain/1', 'Menu link updated');
+ // Load a different page to be sure that we have up to date information.
+ $this->drupalGet('menu_test_maintain/1');
+ $this->assertLink(t('Menu link updated'), 0, t('Found updated menu link'));
+ $this->assertNoLink(t('Menu link #1'), 0, t('Not found menu link #1'));
+ $this->assertNoLink(t('Menu link #1'), 0, t('Not found menu link #1-1'));
+ $this->assertLink(t('Menu link #2'), 0, t('Found menu link #2'));
+
+ // Delete all links for the given path.
+ menu_link_maintain('menu_test', 'delete', 'menu_test_maintain/1', '');
+ // Load a different page to be sure that we have up to date information.
+ $this->drupalGet('menu_test_maintain/2');
+ $this->assertNoLink(t('Menu link updated'), 0, t('Not found deleted menu link'));
+ $this->assertNoLink(t('Menu link #1'), 0, t('Not found menu link #1'));
+ $this->assertNoLink(t('Menu link #1'), 0, t('Not found menu link #1-1'));
+ $this->assertLink(t('Menu link #2'), 0, t('Found menu link #2'));
+ }
+
+ /**
+ * Tests for menu_name parameter for hook_menu().
+ */
+ function testMenuName() {
+ $admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($admin_user);
+
+ $sql = "SELECT menu_name FROM {menu_links} WHERE router_path = 'menu_name_test'";
+ $name = db_query($sql)->fetchField();
+ $this->assertEqual($name, 'original', t('Menu name is "original".'));
+
+ // Change the menu_name parameter in menu_test.module, then force a menu
+ // rebuild.
+ menu_test_menu_name('changed');
+ menu_rebuild();
+
+ $sql = "SELECT menu_name FROM {menu_links} WHERE router_path = 'menu_name_test'";
+ $name = db_query($sql)->fetchField();
+ $this->assertEqual($name, 'changed', t('Menu name was successfully changed after rebuild.'));
+ }
+
+ /**
+ * Tests for menu hierarchy.
+ */
+ function testMenuHierarchy() {
+ $parent_link = db_query('SELECT * FROM {menu_links} WHERE link_path = :link_path', array(':link_path' => 'menu-test/hierarchy/parent'))->fetchAssoc();
+ $child_link = db_query('SELECT * FROM {menu_links} WHERE link_path = :link_path', array(':link_path' => 'menu-test/hierarchy/parent/child'))->fetchAssoc();
+ $unattached_child_link = db_query('SELECT * FROM {menu_links} WHERE link_path = :link_path', array(':link_path' => 'menu-test/hierarchy/parent/child2/child'))->fetchAssoc();
+
+ $this->assertEqual($child_link['plid'], $parent_link['mlid'], t('The parent of a directly attached child is correct.'));
+ $this->assertEqual($unattached_child_link['plid'], $parent_link['mlid'], t('The parent of a non-directly attached child is correct.'));
+ }
+
+ /**
+ * Tests menu link depth and parents of local tasks and menu callbacks.
+ */
+ function testMenuHidden() {
+ // Verify links for one dynamic argument.
+ $links = db_select('menu_links', 'ml')
+ ->fields('ml')
+ ->condition('ml.router_path', 'menu-test/hidden/menu%', 'LIKE')
+ ->orderBy('ml.router_path')
+ ->execute()
+ ->fetchAllAssoc('router_path', PDO::FETCH_ASSOC);
+
+ $parent = $links['menu-test/hidden/menu'];
+ $depth = $parent['depth'] + 1;
+ $plid = $parent['mlid'];
+
+ $link = $links['menu-test/hidden/menu/list'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/add'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/settings'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/manage/%'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $parent = $links['menu-test/hidden/menu/manage/%'];
+ $depth = $parent['depth'] + 1;
+ $plid = $parent['mlid'];
+
+ $link = $links['menu-test/hidden/menu/manage/%/list'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/manage/%/add'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/manage/%/edit'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/menu/manage/%/delete'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ // Verify links for two dynamic arguments.
+ $links = db_select('menu_links', 'ml')
+ ->fields('ml')
+ ->condition('ml.router_path', 'menu-test/hidden/block%', 'LIKE')
+ ->orderBy('ml.router_path')
+ ->execute()
+ ->fetchAllAssoc('router_path', PDO::FETCH_ASSOC);
+
+ $parent = $links['menu-test/hidden/block'];
+ $depth = $parent['depth'] + 1;
+ $plid = $parent['mlid'];
+
+ $link = $links['menu-test/hidden/block/list'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/block/add'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/block/manage/%/%'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $parent = $links['menu-test/hidden/block/manage/%/%'];
+ $depth = $parent['depth'] + 1;
+ $plid = $parent['mlid'];
+
+ $link = $links['menu-test/hidden/block/manage/%/%/configure'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+
+ $link = $links['menu-test/hidden/block/manage/%/%/delete'];
+ $this->assertEqual($link['depth'], $depth, t('%path depth @link_depth is equal to @depth.', array('%path' => $link['router_path'], '@link_depth' => $link['depth'], '@depth' => $depth)));
+ $this->assertEqual($link['plid'], $plid, t('%path plid @link_plid is equal to @plid.', array('%path' => $link['router_path'], '@link_plid' => $link['plid'], '@plid' => $plid)));
+ }
+
+ /**
+ * Test menu_get_item() with empty ancestors.
+ */
+ function testMenuGetItemNoAncestors() {
+ variable_set('menu_masks', array());
+ $this->drupalGet('');
+ }
+
+ /**
+ * Test menu_set_item().
+ */
+ function testMenuSetItem() {
+ $item = menu_get_item('node');
+
+ $this->assertEqual($item['path'], 'node', t("Path from menu_get_item('node') is equal to 'node'"), 'menu');
+
+ // Modify the path for the item then save it.
+ $item['path'] = 'node_test';
+ $item['href'] = 'node_test';
+
+ menu_set_item('node', $item);
+ $compare_item = menu_get_item('node');
+ $this->assertEqual($compare_item, $item, t('Modified menu item is equal to newly retrieved menu item.'), 'menu');
+ }
+
+ /**
+ * Test menu maintenance hooks.
+ */
+ function testMenuItemHooks() {
+ // Create an item.
+ menu_link_maintain('menu_test', 'insert', 'menu_test_maintain/4', 'Menu link #4');
+ $this->assertEqual(menu_test_static_variable(), 'insert', t('hook_menu_link_insert() fired correctly'));
+ // Update the item.
+ menu_link_maintain('menu_test', 'update', 'menu_test_maintain/4', 'Menu link updated');
+ $this->assertEqual(menu_test_static_variable(), 'update', t('hook_menu_link_update() fired correctly'));
+ // Delete the item.
+ menu_link_maintain('menu_test', 'delete', 'menu_test_maintain/4', '');
+ $this->assertEqual(menu_test_static_variable(), 'delete', t('hook_menu_link_delete() fired correctly'));
+ }
+
+ /**
+ * Test menu link 'options' storage and rendering.
+ */
+ function testMenuLinkOptions() {
+ // Create a menu link with options.
+ $menu_link = array(
+ 'link_title' => 'Menu link options test',
+ 'link_path' => 'node',
+ 'module' => 'menu_test',
+ 'options' => array(
+ 'attributes' => array(
+ 'title' => 'Test title attribute',
+ ),
+ 'query' => array(
+ 'testparam' => 'testvalue',
+ ),
+ ),
+ );
+ menu_link_save($menu_link);
+
+ // Load front page.
+ $this->drupalGet('node');
+ $this->assertRaw('title="Test title attribute"', t('Title attribute of a menu link renders.'));
+ $this->assertRaw('testparam=testvalue', t('Query parameter added to menu link.'));
+ }
+
+ /**
+ * Tests the possible ways to set the title for menu items.
+ * Also tests that menu item titles work with string overrides.
+ */
+ function testMenuItemTitlesCases() {
+
+ // Build array with string overrides.
+ $test_data = array(
+ 1 => array('Example title - Case 1' => 'Alternative example title - Case 1'),
+ 2 => array('Example @sub1 - Case @op2' => 'Alternative example @sub1 - Case @op2'),
+ 3 => array('Example title' => 'Alternative example title'),
+ 4 => array('Example title' => 'Alternative example title'),
+ );
+
+ foreach ($test_data as $case_no => $override) {
+ $this->menuItemTitlesCasesHelper($case_no);
+ variable_set('locale_custom_strings_en', array('' => $override));
+ $this->menuItemTitlesCasesHelper($case_no, TRUE);
+ variable_set('locale_custom_strings_en', array());
+ }
+ }
+
+ /**
+ * Get a url and assert the title given a case number. If override is true,
+ * the title is asserted to begin with "Alternative".
+ */
+ private function menuItemTitlesCasesHelper($case_no, $override = FALSE) {
+ $this->drupalGet('menu-title-test/case' . $case_no);
+ $this->assertResponse(200);
+ $asserted_title = $override ? 'Alternative example title - Case ' . $case_no : 'Example title - Case ' . $case_no;
+ $this->assertTitle($asserted_title . ' | Drupal', t('Menu title is') . ': ' . $asserted_title, 'Menu');
+ }
+
+ /**
+ * Load the router for a given path.
+ */
+ protected function menuLoadRouter($router_path) {
+ return db_query('SELECT * FROM {menu_router} WHERE path = :path', array(':path' => $router_path))->fetchAssoc();
+ }
+
+ /**
+ * Tests inheritance of 'load arguments'.
+ */
+ function testMenuLoadArgumentsInheritance() {
+ $expected = array(
+ 'menu-test/arguments/%/%' => array(
+ 2 => array('menu_test_argument_load' => array(3)),
+ 3 => NULL,
+ ),
+ // Arguments are inherited to normal children.
+ 'menu-test/arguments/%/%/default' => array(
+ 2 => array('menu_test_argument_load' => array(3)),
+ 3 => NULL,
+ ),
+ // Arguments are inherited to tab children.
+ 'menu-test/arguments/%/%/task' => array(
+ 2 => array('menu_test_argument_load' => array(3)),
+ 3 => NULL,
+ ),
+ // Arguments are only inherited to the same loader functions.
+ 'menu-test/arguments/%/%/common-loader' => array(
+ 2 => array('menu_test_argument_load' => array(3)),
+ 3 => 'menu_test_other_argument_load',
+ ),
+ // Arguments are not inherited to children not using the same loader
+ // function.
+ 'menu-test/arguments/%/%/different-loaders-1' => array(
+ 2 => NULL,
+ 3 => 'menu_test_argument_load',
+ ),
+ 'menu-test/arguments/%/%/different-loaders-2' => array(
+ 2 => 'menu_test_other_argument_load',
+ 3 => NULL,
+ ),
+ 'menu-test/arguments/%/%/different-loaders-3' => array(
+ 2 => NULL,
+ 3 => NULL,
+ ),
+ // Explicit loader arguments should not be overriden by parent.
+ 'menu-test/arguments/%/%/explicit-arguments' => array(
+ 2 => array('menu_test_argument_load' => array()),
+ 3 => NULL,
+ ),
+ );
+
+ foreach ($expected as $router_path => $load_functions) {
+ $router_item = $this->menuLoadRouter($router_path);
+ $this->assertIdentical(unserialize($router_item['load_functions']), $load_functions, t('Expected load functions for router %router_path' , array('%router_path' => $router_path)));
+ }
+ }
+}
+
+/**
+ * Tests for menu links.
+ */
+class MenuLinksUnitTestCase extends DrupalWebTestCase {
+ // Use the lightweight testing profile for this test.
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu links',
+ 'description' => 'Test handling of menu links hierarchies.',
+ 'group' => 'Menu',
+ );
+ }
+
+ /**
+ * Create a simple hierarchy of links.
+ */
+ function createLinkHierarchy($module = 'menu_test') {
+ // First remove all the menu links.
+ db_truncate('menu_links')->execute();
+
+ // Then create a simple link hierarchy:
+ // - $parent
+ // - $child-1
+ // - $child-1-1
+ // - $child-1-2
+ // - $child-2
+ $base_options = array(
+ 'link_title' => 'Menu link test',
+ 'module' => $module,
+ 'menu_name' => 'menu_test',
+ );
+
+ $links['parent'] = $base_options + array(
+ 'link_path' => 'menu-test/parent',
+ );
+ menu_link_save($links['parent']);
+
+ $links['child-1'] = $base_options + array(
+ 'link_path' => 'menu-test/parent/child-1',
+ 'plid' => $links['parent']['mlid'],
+ );
+ menu_link_save($links['child-1']);
+
+ $links['child-1-1'] = $base_options + array(
+ 'link_path' => 'menu-test/parent/child-1/child-1-1',
+ 'plid' => $links['child-1']['mlid'],
+ );
+ menu_link_save($links['child-1-1']);
+
+ $links['child-1-2'] = $base_options + array(
+ 'link_path' => 'menu-test/parent/child-1/child-1-2',
+ 'plid' => $links['child-1']['mlid'],
+ );
+ menu_link_save($links['child-1-2']);
+
+ $links['child-2'] = $base_options + array(
+ 'link_path' => 'menu-test/parent/child-2',
+ 'plid' => $links['parent']['mlid'],
+ );
+ menu_link_save($links['child-2']);
+
+ return $links;
+ }
+
+ /**
+ * Assert that at set of links is properly parented.
+ */
+ function assertMenuLinkParents($links, $expected_hierarchy) {
+ foreach ($expected_hierarchy as $child => $parent) {
+ $mlid = $links[$child]['mlid'];
+ $plid = $parent ? $links[$parent]['mlid'] : 0;
+
+ $menu_link = menu_link_load($mlid);
+ menu_link_save($menu_link);
+ $this->assertEqual($menu_link['plid'], $plid, t('Menu link %mlid has parent of %plid, expected %expected_plid.', array('%mlid' => $mlid, '%plid' => $menu_link['plid'], '%expected_plid' => $plid)));
+ }
+ }
+
+ /**
+ * Test automatic reparenting of menu links.
+ */
+ function testMenuLinkReparenting($module = 'menu_test') {
+ // Check the initial hierarchy.
+ $links = $this->createLinkHierarchy($module);
+
+ $expected_hierarchy = array(
+ 'parent' => FALSE,
+ 'child-1' => 'parent',
+ 'child-1-1' => 'child-1',
+ 'child-1-2' => 'child-1',
+ 'child-2' => 'parent',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+
+ // Start over, and move child-1 under child-2, and check that all the
+ // childs of child-1 have been moved too.
+ $links = $this->createLinkHierarchy($module);
+ $links['child-1']['plid'] = $links['child-2']['mlid'];
+ menu_link_save($links['child-1']);
+
+ $expected_hierarchy = array(
+ 'parent' => FALSE,
+ 'child-1' => 'child-2',
+ 'child-1-1' => 'child-1',
+ 'child-1-2' => 'child-1',
+ 'child-2' => 'parent',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+
+ // Start over, and delete child-1, and check that the children of child-1
+ // have been reassigned to the parent. menu_link_delete() will cowardly
+ // refuse to delete a menu link defined by the system module, so skip the
+ // test in that case.
+ if ($module != 'system') {
+ $links = $this->createLinkHierarchy($module);
+ menu_link_delete($links['child-1']['mlid']);
+
+ $expected_hierarchy = array(
+ 'parent' => FALSE,
+ 'child-1-1' => 'parent',
+ 'child-1-2' => 'parent',
+ 'child-2' => 'parent',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+ }
+
+ // Start over, forcefully delete child-1 from the database, simulating a
+ // database crash. Check that the children of child-1 have been reassigned
+ // to the parent, going up on the old path hierarchy stored in each of the
+ // links.
+ $links = $this->createLinkHierarchy($module);
+ // Don't do that at home.
+ db_delete('menu_links')
+ ->condition('mlid', $links['child-1']['mlid'])
+ ->execute();
+
+ $expected_hierarchy = array(
+ 'parent' => FALSE,
+ 'child-1-1' => 'parent',
+ 'child-1-2' => 'parent',
+ 'child-2' => 'parent',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+
+ // Start over, forcefully delete the parent from the database, simulating a
+ // database crash. Check that the children of parent are now top-level.
+ $links = $this->createLinkHierarchy($module);
+ // Don't do that at home.
+ db_delete('menu_links')
+ ->condition('mlid', $links['parent']['mlid'])
+ ->execute();
+
+ $expected_hierarchy = array(
+ 'child-1-1' => 'child-1',
+ 'child-1-2' => 'child-1',
+ 'child-2' => FALSE,
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+ }
+
+ /**
+ * Test automatic reparenting of menu links derived from menu routers.
+ */
+ function testMenuLinkRouterReparenting() {
+ // Run all the standard parenting tests on menu links derived from
+ // menu routers.
+ $this->testMenuLinkReparenting('system');
+
+ // Additionnaly, test reparenting based on path.
+ $links = $this->createLinkHierarchy('system');
+
+ // Move child-1-2 has a child of child-2, making the link hierarchy
+ // inconsistent with the path hierarchy.
+ $links['child-1-2']['plid'] = $links['child-2']['mlid'];
+ menu_link_save($links['child-1-2']);
+
+ // Check the new hierarchy.
+ $expected_hierarchy = array(
+ 'parent' => FALSE,
+ 'child-1' => 'parent',
+ 'child-1-1' => 'child-1',
+ 'child-2' => 'parent',
+ 'child-1-2' => 'child-2',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+
+ // Now delete 'parent' directly from the database, simulating a database
+ // crash. 'child-1' and 'child-2' should get moved to the
+ // top-level.
+ // Don't do that at home.
+ db_delete('menu_links')
+ ->condition('mlid', $links['parent']['mlid'])
+ ->execute();
+ $expected_hierarchy = array(
+ 'child-1' => FALSE,
+ 'child-1-1' => 'child-1',
+ 'child-2' => FALSE,
+ 'child-1-2' => 'child-2',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+
+ // Now delete 'child-2' directly from the database, simulating a database
+ // crash. 'child-1-2' will get reparented under 'child-1' based on its
+ // path.
+ // Don't do that at home.
+ db_delete('menu_links')
+ ->condition('mlid', $links['child-2']['mlid'])
+ ->execute();
+ $expected_hierarchy = array(
+ 'child-1' => FALSE,
+ 'child-1-1' => 'child-1',
+ 'child-1-2' => 'child-1',
+ );
+ $this->assertMenuLinkParents($links, $expected_hierarchy);
+ }
+}
+
+/**
+ * Tests rebuilding the menu by setting 'menu_rebuild_needed.'
+ */
+class MenuRebuildTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu rebuild test',
+ 'description' => 'Test rebuilding of menu.',
+ 'group' => 'Menu',
+ );
+ }
+
+ /**
+ * Test if the 'menu_rebuild_needed' variable triggers a menu_rebuild() call.
+ */
+ function testMenuRebuildByVariable() {
+ // Check if 'admin' path exists.
+ $admin_exists = db_query('SELECT path from {menu_router} WHERE path = :path', array(':path' => 'admin'))->fetchField();
+ $this->assertEqual($admin_exists, 'admin', t("The path 'admin/' exists prior to deleting."));
+
+ // Delete the path item 'admin', and test that the path doesn't exist in the database.
+ $delete = db_delete('menu_router')
+ ->condition('path', 'admin')
+ ->execute();
+ $admin_exists = db_query('SELECT path from {menu_router} WHERE path = :path', array(':path' => 'admin'))->fetchField();
+ $this->assertFalse($admin_exists, t("The path 'admin/' has been deleted and doesn't exist in the database."));
+
+ // Now we enable the rebuild variable and trigger menu_execute_active_handler()
+ // to rebuild the menu item. Now 'admin' should exist.
+ variable_set('menu_rebuild_needed', TRUE);
+ // menu_execute_active_handler() should trigger the rebuild.
+ $this->drupalGet('<front>');
+ $admin_exists = db_query('SELECT path from {menu_router} WHERE path = :path', array(':path' => 'admin'))->fetchField();
+ $this->assertEqual($admin_exists, 'admin', t("The menu has been rebuilt, the path 'admin' now exists again."));
+ }
+
+}
+
+/**
+ * Menu tree data related tests.
+ */
+class MenuTreeDataTestCase extends DrupalUnitTestCase {
+ /**
+ * Dummy link structure acceptable for menu_tree_data().
+ */
+ var $links = array(
+ 1 => array('mlid' => 1, 'depth' => 1),
+ 2 => array('mlid' => 2, 'depth' => 1),
+ 3 => array('mlid' => 3, 'depth' => 2),
+ 4 => array('mlid' => 4, 'depth' => 3),
+ 5 => array('mlid' => 5, 'depth' => 1),
+ );
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu tree generation',
+ 'description' => 'Tests recursive menu tree generation functions.',
+ 'group' => 'Menu',
+ );
+ }
+
+ /**
+ * Validate the generation of a proper menu tree hierarchy.
+ */
+ function testMenuTreeData() {
+ $tree = menu_tree_data($this->links);
+
+ // Validate that parent items #1, #2, and #5 exist on the root level.
+ $this->assertSameLink($this->links[1], $tree[1]['link'], t('Parent item #1 exists.'));
+ $this->assertSameLink($this->links[2], $tree[2]['link'], t('Parent item #2 exists.'));
+ $this->assertSameLink($this->links[5], $tree[5]['link'], t('Parent item #5 exists.'));
+
+ // Validate that child item #4 exists at the correct location in the hierarchy.
+ $this->assertSameLink($this->links[4], $tree[2]['below'][3]['below'][4]['link'], t('Child item #4 exists in the hierarchy.'));
+ }
+
+ /**
+ * Check that two menu links are the same by comparing the mlid.
+ *
+ * @param $link1
+ * A menu link item.
+ * @param $link2
+ * A menu link item.
+ * @param $message
+ * The message to display along with the assertion.
+ * @return
+ * TRUE if the assertion succeeded, FALSE otherwise.
+ */
+ protected function assertSameLink($link1, $link2, $message = '') {
+ return $this->assert($link1['mlid'] == $link2['mlid'], $message ? $message : t('First link is identical to second link'));
+ }
+}
+
+/**
+ * Menu tree output related tests.
+ */
+class MenuTreeOutputTestCase extends DrupalWebTestCase {
+ /**
+ * Dummy link structure acceptable for menu_tree_output().
+ */
+ var $tree_data = array(
+ '1'=> array(
+ 'link' => array( 'menu_name' => 'main-menu', 'mlid' => 1, 'hidden'=>0, 'has_children' => 1, 'title' => 'Item 1', 'in_active_trail' => 1, 'access'=>1, 'href' => 'a', 'localized_options' => array('attributes' => array('title' =>'')) ),
+ 'below' => array(
+ '2' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 2, 'hidden'=>0, 'has_children' => 1, 'title' => 'Item 2', 'in_active_trail' => 1, 'access'=>1, 'href' => 'a/b', 'localized_options' => array('attributes' => array('title' =>'')) ),
+ 'below' => array(
+ '3' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 3, 'hidden'=>0, 'has_children' => 0, 'title' => 'Item 3', 'in_active_trail' => 0, 'access'=>1, 'href' => 'a/b/c', 'localized_options' => array('attributes' => array('title' =>'')) ),
+ 'below' => array() ),
+ '4' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 4, 'hidden'=>0, 'has_children' => 0, 'title' => 'Item 4', 'in_active_trail' => 0, 'access'=>1, 'href' => 'a/b/d', 'localized_options' => array('attributes' => array('title' =>'')) ),
+ 'below' => array() )
+ )
+ )
+ )
+ ),
+ '5' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 5, 'hidden'=>1, 'has_children' => 0, 'title' => 'Item 5', 'in_active_trail' => 0, 'access'=>1, 'href' => 'e', 'localized_options' => array('attributes' => array('title' =>'')) ), 'below' => array( ) ),
+ '6' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 6, 'hidden'=>0, 'has_children' => 0, 'title' => 'Item 6', 'in_active_trail' => 0, 'access'=>0, 'href' => 'f', 'localized_options' => array('attributes' => array('title' =>'')) ), 'below' => array( ) ),
+ '7' => array('link' => array( 'menu_name' => 'main-menu', 'mlid' => 7, 'hidden'=>0, 'has_children' => 0, 'title' => 'Item 7', 'in_active_trail' => 0, 'access'=>1, 'href' => 'g', 'localized_options' => array('attributes' => array('title' =>'')) ), 'below' => array( ) )
+ );
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Menu tree output',
+ 'description' => 'Tests menu tree output functions.',
+ 'group' => 'Menu',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ }
+
+ /**
+ * Validate the generation of a proper menu tree output.
+ */
+ function testMenuTreeData() {
+ $output = menu_tree_output($this->tree_data);
+
+ // Validate that the - in main-menu is changed into an underscore
+ $this->assertEqual( $output['1']['#theme'], 'menu_link__main_menu', t('Hyphen is changed to a dash on menu_link'));
+ $this->assertEqual( $output['#theme_wrappers'][0], 'menu_tree__main_menu', t('Hyphen is changed to a dash on menu_tree wrapper'));
+ // Looking for child items in the data
+ $this->assertEqual( $output['1']['#below']['2']['#href'], 'a/b', t('Checking the href on a child item'));
+ $this->assertTrue( in_array('active-trail',$output['1']['#below']['2']['#attributes']['class']) , t('Checking the active trail class'));
+ // Validate that the hidden and no access items are missing
+ $this->assertFalse( isset($output['5']), t('Hidden item should be missing'));
+ $this->assertFalse( isset($output['6']), t('False access should be missing'));
+ // Item 7 is after a couple hidden items. Just to make sure that 5 and 6 are skipped and 7 still included
+ $this->assertTrue( isset($output['7']), t('Item after hidden items is present'));
+ }
+}
+
+/**
+ * Menu breadcrumbs related tests.
+ */
+class MenuBreadcrumbTestCase extends MenuWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Breadcrumbs',
+ 'description' => 'Tests breadcrumbs functionality.',
+ 'group' => 'Menu',
+ );
+ }
+
+ function setUp() {
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ $modules[] = 'menu_test';
+ parent::setUp($modules);
+ $perms = array_keys(module_invoke_all('permission'));
+ $this->admin_user = $this->drupalCreateUser($perms);
+ $this->drupalLogin($this->admin_user);
+
+ // This test puts menu links in the Navigation menu and then tests for
+ // their presence on the page, so we need to ensure that the Navigation
+ // block will be displayed in all active themes.
+ db_update('block')
+ ->fields(array(
+ // Use a region that is valid for all themes.
+ 'region' => 'content',
+ 'status' => 1,
+ ))
+ ->condition('module', 'system')
+ ->condition('delta', 'navigation')
+ ->execute();
+ }
+
+ /**
+ * Tests breadcrumbs on node and administrative paths.
+ */
+ function testBreadCrumbs() {
+ // Prepare common base breadcrumb elements.
+ $home = array('<front>' => 'Home');
+ $admin = $home + array('admin' => t('Administration'));
+ $config = $admin + array('admin/config' => t('Configuration'));
+ $type = 'article';
+ $langcode = LANGUAGE_NONE;
+
+ // Verify breadcrumbs for default local tasks.
+ $expected = array(
+ 'menu-test' => t('Menu test root'),
+ );
+ $title = t('Breadcrumbs test: Local tasks');
+ $trail = $home + $expected;
+ $tree = $expected + array(
+ 'menu-test/breadcrumb/tasks' => $title,
+ );
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks', $trail, $title, $tree);
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/first', $trail, $title, $tree);
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/first/first', $trail, $title, $tree);
+ $trail += array(
+ 'menu-test/breadcrumb/tasks' => t('Breadcrumbs test: Local tasks'),
+ );
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/first/second', $trail, $title, $tree);
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/second', $trail, $title, $tree);
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/second/first', $trail, $title, $tree);
+ $trail += array(
+ 'menu-test/breadcrumb/tasks/second' => t('Second'),
+ );
+ $this->assertBreadcrumb('menu-test/breadcrumb/tasks/second/second', $trail, $title, $tree);
+
+ // Verify Taxonomy administration breadcrumbs.
+ $trail = $admin + array(
+ 'admin/structure' => t('Structure'),
+ );
+ $this->assertBreadcrumb('admin/structure/taxonomy', $trail);
+
+ $trail += array(
+ 'admin/structure/taxonomy' => t('Taxonomy'),
+ );
+ $this->assertBreadcrumb('admin/structure/taxonomy/tags', $trail);
+ $trail += array(
+ 'admin/structure/taxonomy/tags' => t('Tags'),
+ );
+ $this->assertBreadcrumb('admin/structure/taxonomy/tags/edit', $trail);
+ $this->assertBreadcrumb('admin/structure/taxonomy/tags/fields', $trail);
+ $this->assertBreadcrumb('admin/structure/taxonomy/tags/add', $trail);
+
+ // Verify Menu administration breadcrumbs.
+ $trail = $admin + array(
+ 'admin/structure' => t('Structure'),
+ );
+ $this->assertBreadcrumb('admin/structure/menu', $trail);
+
+ $trail += array(
+ 'admin/structure/menu' => t('Menus'),
+ );
+ $this->assertBreadcrumb('admin/structure/menu/manage/navigation', $trail);
+ $trail += array(
+ 'admin/structure/menu/manage/navigation' => t('Navigation'),
+ );
+ $this->assertBreadcrumb('admin/structure/menu/manage/navigation/edit', $trail);
+ $this->assertBreadcrumb('admin/structure/menu/manage/navigation/add', $trail);
+
+ // Verify Node administration breadcrumbs.
+ $trail = $admin + array(
+ 'admin/structure' => t('Structure'),
+ 'admin/structure/types' => t('Content types'),
+ );
+ $this->assertBreadcrumb('admin/structure/types/add', $trail);
+ $this->assertBreadcrumb("admin/structure/types/manage/$type", $trail);
+ $trail += array(
+ "admin/structure/types/manage/$type" => t('Article'),
+ );
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/fields", $trail);
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/display", $trail);
+ $trail_teaser = $trail + array(
+ "admin/structure/types/manage/$type/display" => t('Manage display'),
+ );
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/display/teaser", $trail_teaser);
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/comment/fields", $trail);
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/comment/display", $trail);
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/delete", $trail);
+ $trail += array(
+ "admin/structure/types/manage/$type/fields" => t('Manage fields'),
+ );
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/fields/body", $trail);
+ $trail += array(
+ "admin/structure/types/manage/$type/fields/body" => t('Body'),
+ );
+ $this->assertBreadcrumb("admin/structure/types/manage/$type/fields/body/widget-type", $trail);
+
+ // Verify Filter text format administration breadcrumbs.
+ $format = db_query_range("SELECT format, name FROM {filter_format}", 1, 1)->fetch();
+ $format_id = $format->format;
+ $trail = $config + array(
+ 'admin/config/content' => t('Content authoring'),
+ );
+ $this->assertBreadcrumb('admin/config/content/formats', $trail);
+
+ $trail += array(
+ 'admin/config/content/formats' => t('Text formats'),
+ );
+ $this->assertBreadcrumb('admin/config/content/formats/add', $trail);
+ $this->assertBreadcrumb("admin/config/content/formats/$format_id", $trail);
+ $trail += array(
+ "admin/config/content/formats/$format_id" => $format->name,
+ );
+ $this->assertBreadcrumb("admin/config/content/formats/$format_id/disable", $trail);
+
+ // Verify node breadcrumbs (without menu link).
+ $node1 = $this->drupalCreateNode();
+ $nid1 = $node1->nid;
+ $trail = $home;
+ $this->assertBreadcrumb("node/$nid1", $trail);
+ // Also verify that the node does not appear elsewhere (e.g., menu trees).
+ $this->assertNoLink($node1->title);
+ // The node itself should not be contained in the breadcrumb on the default
+ // local task, since there is no difference between both pages.
+ $this->assertBreadcrumb("node/$nid1/view", $trail);
+ // Also verify that the node does not appear elsewhere (e.g., menu trees).
+ $this->assertNoLink($node1->title);
+
+ $trail += array(
+ "node/$nid1" => $node1->title,
+ );
+ $this->assertBreadcrumb("node/$nid1/edit", $trail);
+
+ // Verify that breadcrumb on node listing page contains "Home" only.
+ $trail = array();
+ $this->assertBreadcrumb('node', $trail);
+
+ // Verify node breadcrumbs (in menu).
+ // Do this separately for Main menu and Navigation menu, since only the
+ // latter is a preferred menu by default.
+ // @todo Also test all themes? Manually testing led to the suspicion that
+ // breadcrumbs may differ, possibly due to template.php overrides.
+ $menus = array('main-menu', 'navigation');
+ // Alter node type menu settings.
+ variable_set("menu_options_$type", $menus);
+ variable_set("menu_parent_$type", 'navigation:0');
+
+ foreach ($menus as $menu) {
+ // Create a parent node in the current menu.
+ $title = $this->randomName();
+ $node2 = $this->drupalCreateNode(array(
+ 'type' => $type,
+ 'title' => $title,
+ 'menu' => array(
+ 'enabled' => 1,
+ 'link_title' => 'Parent ' . $title,
+ 'description' => '',
+ 'menu_name' => $menu,
+ 'plid' => 0,
+ ),
+ ));
+ $nid2 = $node2->nid;
+
+ $trail = $home;
+ $tree = array(
+ "node/$nid2" => $node2->menu['link_title'],
+ );
+ $this->assertBreadcrumb("node/$nid2", $trail, $node2->title, $tree);
+ // The node itself should not be contained in the breadcrumb on the
+ // default local task, since there is no difference between both pages.
+ $this->assertBreadcrumb("node/$nid2/view", $trail, $node2->title, $tree);
+ $trail += array(
+ "node/$nid2" => $node2->menu['link_title'],
+ );
+ $this->assertBreadcrumb("node/$nid2/edit", $trail);
+
+ // Create a child node in the current menu.
+ $title = $this->randomName();
+ $node3 = $this->drupalCreateNode(array(
+ 'type' => $type,
+ 'title' => $title,
+ 'menu' => array(
+ 'enabled' => 1,
+ 'link_title' => 'Child ' . $title,
+ 'description' => '',
+ 'menu_name' => $menu,
+ 'plid' => $node2->menu['mlid'],
+ ),
+ ));
+ $nid3 = $node3->nid;
+
+ $this->assertBreadcrumb("node/$nid3", $trail, $node3->title, $tree, FALSE);
+ // The node itself should not be contained in the breadcrumb on the
+ // default local task, since there is no difference between both pages.
+ $this->assertBreadcrumb("node/$nid3/view", $trail, $node3->title, $tree, FALSE);
+ $trail += array(
+ "node/$nid3" => $node3->menu['link_title'],
+ );
+ $tree += array(
+ "node/$nid3" => $node3->menu['link_title'],
+ );
+ $this->assertBreadcrumb("node/$nid3/edit", $trail);
+
+ // Verify that node listing page still contains "Home" only.
+ $trail = array();
+ $this->assertBreadcrumb('node', $trail);
+
+ if ($menu == 'navigation') {
+ $parent = $node2;
+ $child = $node3;
+ }
+ }
+
+ // Create a Navigation menu link for 'node', move the last parent node menu
+ // link below it, and verify a full breadcrumb for the last child node.
+ $menu = 'navigation';
+ $edit = array(
+ 'link_title' => 'Root',
+ 'link_path' => 'node',
+ );
+ $this->drupalPost("admin/structure/menu/manage/$menu/add", $edit, t('Save'));
+ $link = db_query('SELECT * FROM {menu_links} WHERE link_title = :title', array(':title' => 'Root'))->fetchAssoc();
+
+ $edit = array(
+ 'menu[parent]' => $link['menu_name'] . ':' . $link['mlid'],
+ );
+ $this->drupalPost("node/{$parent->nid}/edit", $edit, t('Save'));
+ $expected = array(
+ "node" => $link['link_title'],
+ );
+ $trail = $home + $expected;
+ $tree = $expected + array(
+ "node/{$parent->nid}" => $parent->menu['link_title'],
+ );
+ $this->assertBreadcrumb(NULL, $trail, $parent->title, $tree);
+ $trail += array(
+ "node/{$parent->nid}" => $parent->menu['link_title'],
+ );
+ $tree += array(
+ "node/{$child->nid}" => $child->menu['link_title'],
+ );
+ $this->assertBreadcrumb("node/{$child->nid}", $trail, $child->title, $tree);
+
+ // Add a taxonomy term/tag to last node, and add a link for that term to the
+ // Navigation menu.
+ $tags = array(
+ 'Drupal' => array(),
+ 'Breadcrumbs' => array(),
+ );
+ $edit = array(
+ "field_tags[$langcode]" => implode(',', array_keys($tags)),
+ );
+ $this->drupalPost("node/{$parent->nid}/edit", $edit, t('Save'));
+
+ // Put both terms into a hierarchy Drupal » Breadcrumbs. Required for both
+ // the menu links and the terms itself, since taxonomy_term_page() resets
+ // the breadcrumb based on taxonomy term hierarchy.
+ $parent_tid = 0;
+ foreach ($tags as $name => $null) {
+ $terms = taxonomy_term_load_multiple(NULL, array('name' => $name));
+ $term = reset($terms);
+ $tags[$name]['term'] = $term;
+ if ($parent_tid) {
+ $edit = array(
+ 'parent[]' => array($parent_tid),
+ );
+ $this->drupalPost("taxonomy/term/{$term->tid}/edit", $edit, t('Save'));
+ }
+ $parent_tid = $term->tid;
+ }
+ $parent_mlid = 0;
+ foreach ($tags as $name => $data) {
+ $term = $data['term'];
+ $edit = array(
+ 'link_title' => "$name link",
+ 'link_path' => "taxonomy/term/{$term->tid}",
+ 'parent' => "$menu:{$parent_mlid}",
+ );
+ $this->drupalPost("admin/structure/menu/manage/$menu/add", $edit, t('Save'));
+ $tags[$name]['link'] = db_query('SELECT * FROM {menu_links} WHERE link_title = :title AND link_path = :href', array(
+ ':title' => $edit['link_title'],
+ ':href' => $edit['link_path'],
+ ))->fetchAssoc();
+ $tags[$name]['link']['link_path'] = $edit['link_path'];
+ $parent_mlid = $tags[$name]['link']['mlid'];
+ }
+
+ // Verify expected breadcrumbs for menu links.
+ $trail = $home;
+ $tree = array();
+ foreach ($tags as $name => $data) {
+ $term = $data['term'];
+ $link = $data['link'];
+
+ $tree += array(
+ $link['link_path'] => $link['link_title'],
+ );
+ $this->assertBreadcrumb($link['link_path'], $trail, $term->name, $tree);
+ $this->assertRaw(check_plain($parent->title), 'Tagged node found.');
+
+ // Additionally make sure that this link appears only once; i.e., the
+ // untranslated menu links automatically generated from menu router items
+ // ('taxonomy/term/%') should never be translated and appear in any menu
+ // other than the breadcrumb trail.
+ $elements = $this->xpath('//div[@id=:menu]/descendant::a[@href=:href]', array(
+ ':menu' => 'block-system-navigation',
+ ':href' => url($link['link_path']),
+ ));
+ $this->assertTrue(count($elements) == 1, "Link to {$link['link_path']} appears only once.");
+
+ // Next iteration should expect this tag as parent link.
+ // Note: Term name, not link name, due to taxonomy_term_page().
+ $trail += array(
+ $link['link_path'] => $term->name,
+ );
+ }
+
+ // Verify breadcrumbs on user and user/%.
+ // We need to log back in and out below, and cannot simply grant the
+ // 'administer users' permission, since user_page() makes your head explode.
+ user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array(
+ 'access user profiles',
+ ));
+ $this->drupalLogout();
+
+ // Verify breadcrumb on front page.
+ $this->assertBreadcrumb('<front>', array());
+
+ // Verify breadcrumb on user pages (without menu link) for anonymous user.
+ $trail = $home;
+ $this->assertBreadcrumb('user', $trail, t('User account'));
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid, $trail, $this->admin_user->name);
+
+ // Verify breadcrumb on user pages (without menu link) for registered users.
+ $this->drupalLogin($this->admin_user);
+ $trail = $home;
+ $this->assertBreadcrumb('user', $trail, $this->admin_user->name);
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid, $trail, $this->admin_user->name);
+ $trail += array(
+ 'user/' . $this->admin_user->uid => $this->admin_user->name,
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid . '/edit', $trail, $this->admin_user->name);
+
+ // Create a second user to verify breadcrumb on user pages again.
+ $this->web_user = $this->drupalCreateUser(array(
+ 'administer users',
+ 'access user profiles',
+ ));
+ $this->drupalLogin($this->web_user);
+
+ // Verify correct breadcrumb and page title on another user's account pages
+ // (without menu link).
+ $trail = $home;
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid, $trail, $this->admin_user->name);
+ $trail += array(
+ 'user/' . $this->admin_user->uid => $this->admin_user->name,
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid . '/edit', $trail, $this->admin_user->name);
+
+ // Verify correct breadcrumb and page title when viewing own user account
+ // pages (without menu link).
+ $trail = $home;
+ $this->assertBreadcrumb('user/' . $this->web_user->uid, $trail, $this->web_user->name);
+ $trail += array(
+ 'user/' . $this->web_user->uid => $this->web_user->name,
+ );
+ $this->assertBreadcrumb('user/' . $this->web_user->uid . '/edit', $trail, $this->web_user->name);
+
+ // Add a Navigation menu links for 'user' and $this->admin_user.
+ // Although it may be faster to manage these links via low-level API
+ // functions, there's a lot that can go wrong in doing so.
+ $this->drupalLogin($this->admin_user);
+ $edit = array(
+ 'link_title' => 'User',
+ 'link_path' => 'user',
+ );
+ $this->drupalPost("admin/structure/menu/manage/$menu/add", $edit, t('Save'));
+ $link_user = db_query('SELECT * FROM {menu_links} WHERE link_title = :title AND link_path = :href', array(
+ ':title' => $edit['link_title'],
+ ':href' => $edit['link_path'],
+ ))->fetchAssoc();
+
+ $edit = array(
+ 'link_title' => $this->admin_user->name . ' link',
+ 'link_path' => 'user/' . $this->admin_user->uid,
+ );
+ $this->drupalPost("admin/structure/menu/manage/$menu/add", $edit, t('Save'));
+ $link_admin_user = db_query('SELECT * FROM {menu_links} WHERE link_title = :title AND link_path = :href', array(
+ ':title' => $edit['link_title'],
+ ':href' => $edit['link_path'],
+ ))->fetchAssoc();
+
+ // Verify expected breadcrumbs for the two separate links.
+ $this->drupalLogout();
+ $trail = $home;
+ $tree = array(
+ $link_user['link_path'] => $link_user['link_title'],
+ );
+ $this->assertBreadcrumb('user', $trail, $link_user['link_title'], $tree);
+ $tree = array(
+ $link_admin_user['link_path'] => $link_admin_user['link_title'],
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid, $trail, $link_admin_user['link_title'], $tree);
+
+ $this->drupalLogin($this->admin_user);
+ $trail += array(
+ $link_admin_user['link_path'] => $link_admin_user['link_title'],
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid . '/edit', $trail, $link_admin_user['link_title'], $tree, FALSE);
+
+ // Move 'user/%' below 'user' and verify again.
+ $edit = array(
+ 'parent' => "$menu:{$link_user['mlid']}",
+ );
+ $this->drupalPost("admin/structure/menu/item/{$link_admin_user['mlid']}/edit", $edit, t('Save'));
+
+ $this->drupalLogout();
+ $trail = $home;
+ $tree = array(
+ $link_user['link_path'] => $link_user['link_title'],
+ );
+ $this->assertBreadcrumb('user', $trail, $link_user['link_title'], $tree);
+ $trail += array(
+ $link_user['link_path'] => $link_user['link_title'],
+ );
+ $tree += array(
+ $link_admin_user['link_path'] => $link_admin_user['link_title'],
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid, $trail, $link_admin_user['link_title'], $tree);
+
+ $this->drupalLogin($this->admin_user);
+ $trail += array(
+ $link_admin_user['link_path'] => $link_admin_user['link_title'],
+ );
+ $this->assertBreadcrumb('user/' . $this->admin_user->uid . '/edit', $trail, $link_admin_user['link_title'], $tree, FALSE);
+
+ // Create an only slightly privileged user being able to access site reports
+ // but not administration pages.
+ $this->web_user = $this->drupalCreateUser(array(
+ 'access site reports',
+ ));
+ $this->drupalLogin($this->web_user);
+
+ // Verify that we can access recent log entries, there is a corresponding
+ // page title, and that the breadcrumb is empty (because the user is not
+ // able to access "Administer", so the trail cannot recurse into it).
+ $trail = array();
+ $this->assertBreadcrumb('admin', $trail, t('Access denied'));
+ $this->assertResponse(403);
+
+ $trail = $home;
+ $this->assertBreadcrumb('admin/reports', $trail, t('Reports'));
+ $this->assertNoResponse(403);
+
+ $this->assertBreadcrumb('admin/reports/dblog', $trail, t('Recent log messages'));
+ $this->assertNoResponse(403);
+ }
+}
+
+/**
+ * Tests active menu trails.
+ */
+class MenuTrailTestCase extends MenuWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Active trail',
+ 'description' => 'Tests active menu trails and alteration functionality.',
+ 'group' => 'Menu',
+ );
+ }
+
+ function setUp() {
+ $modules = func_get_args();
+ if (isset($modules[0]) && is_array($modules[0])) {
+ $modules = $modules[0];
+ }
+ $modules[] = 'menu_test';
+ parent::setUp($modules);
+ $this->admin_user = $this->drupalCreateUser(array('administer site configuration', 'access administration pages'));
+ $this->drupalLogin($this->admin_user);
+
+ // This test puts menu links in the Navigation menu and then tests for
+ // their presence on the page, so we need to ensure that the Navigation
+ // block will be displayed in all active themes.
+ db_update('block')
+ ->fields(array(
+ // Use a region that is valid for all themes.
+ 'region' => 'content',
+ 'status' => 1,
+ ))
+ ->condition('module', 'system')
+ ->condition('delta', 'navigation')
+ ->execute();
+
+ // This test puts menu links in the Management menu and then tests for
+ // their presence on the page, so we need to ensure that the Management
+ // block will be displayed in all active themes.
+ db_update('block')
+ ->fields(array(
+ // Use a region that is valid for all themes.
+ 'region' => 'content',
+ 'status' => 1,
+ ))
+ ->condition('module', 'system')
+ ->condition('delta', 'management')
+ ->execute();
+ }
+
+ /**
+ * Tests active trails are properly affected by menu_tree_set_path().
+ */
+ function testMenuTreeSetPath() {
+ $home = array('<front>' => 'Home');
+ $config_tree = array(
+ 'admin' => t('Administration'),
+ 'admin/config' => t('Configuration'),
+ );
+ $config = $home + $config_tree;
+
+ // The menu_test_menu_tree_set_path system variable controls whether or not
+ // the menu_test_menu_trail_callback() callback (used by all paths in these
+ // tests) issues an overriding call to menu_trail_set_path().
+ $test_menu_path = array(
+ 'menu_name' => 'management',
+ 'path' => 'admin/config/system/site-information',
+ );
+
+ $breadcrumb = $home + array(
+ 'menu-test' => t('Menu test root'),
+ );
+ $tree = array(
+ 'menu-test' => t('Menu test root'),
+ 'menu-test/menu-trail' => t('Menu trail - Case 1'),
+ );
+
+ // Test the tree generation for the Navigation menu.
+ variable_del('menu_test_menu_tree_set_path');
+ $this->assertBreadcrumb('menu-test/menu-trail', $breadcrumb, t('Menu trail - Case 1'), $tree);
+
+ // Override the active trail for the Management tree; it should not affect
+ // the Navigation tree.
+ variable_set('menu_test_menu_tree_set_path', $test_menu_path);
+ $this->assertBreadcrumb('menu-test/menu-trail', $breadcrumb, t('Menu trail - Case 1'), $tree);
+
+ $breadcrumb = $config + array(
+ 'admin/config/development' => t('Development'),
+ );
+ $tree = $config_tree + array(
+ 'admin/config/development' => t('Development'),
+ 'admin/config/development/menu-trail' => t('Menu trail - Case 2'),
+ );
+
+ $override_breadcrumb = $config + array(
+ 'admin/config/system' => t('System'),
+ 'admin/config/system/site-information' => t('Site information'),
+ );
+ $override_tree = $config_tree + array(
+ 'admin/config/system' => t('System'),
+ 'admin/config/system/site-information' => t('Site information'),
+ );
+
+ // Test the tree generation for the Management menu.
+ variable_del('menu_test_menu_tree_set_path');
+ $this->assertBreadcrumb('admin/config/development/menu-trail', $breadcrumb, t('Menu trail - Case 2'), $tree);
+
+ // Override the active trail for the Management tree; it should affect the
+ // breadcrumbs and Management tree.
+ variable_set('menu_test_menu_tree_set_path', $test_menu_path);
+ $this->assertBreadcrumb('admin/config/development/menu-trail', $override_breadcrumb, t('Menu trail - Case 2'), $override_tree);
+ }
+}
diff --git a/core/modules/simpletest/tests/menu_test.info b/core/modules/simpletest/tests/menu_test.info
new file mode 100644
index 000000000000..4549a25c7cbb
--- /dev/null
+++ b/core/modules/simpletest/tests/menu_test.info
@@ -0,0 +1,6 @@
+name = "Hook menu tests"
+description = "Support module for menu hook testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/menu_test.module b/core/modules/simpletest/tests/menu_test.module
new file mode 100644
index 000000000000..c42aca60fe67
--- /dev/null
+++ b/core/modules/simpletest/tests/menu_test.module
@@ -0,0 +1,527 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing hook menu.
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function menu_test_menu() {
+ // The name of the menu changes during the course of the test. Using a $_GET.
+ $items['menu_name_test'] = array(
+ 'title' => 'Test menu_name router item',
+ 'page callback' => 'node_save',
+ 'menu_name' => menu_test_menu_name(),
+ );
+ // This item is of type MENU_CALLBACK with no parents to test title.
+ $items['menu_callback_title'] = array(
+ 'title' => 'Menu Callback Title',
+ 'page callback' => 'menu_test_callback',
+ 'type' => MENU_CALLBACK,
+ 'access arguments' => array('access content'),
+ );
+ // Use FALSE as 'title callback' to bypass t().
+ $items['menu_no_title_callback'] = array(
+ 'title' => 'A title with @placeholder',
+ 'title callback' => FALSE,
+ 'title arguments' => array('@placeholder' => 'some other text'),
+ 'page callback' => 'menu_test_callback',
+ 'access arguments' => array('access content'),
+ );
+
+ // Hidden link for menu_link_maintain tests
+ $items['menu_test_maintain/%'] = array(
+ 'title' => 'Menu maintain test',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ // Hierarchical tests.
+ $items['menu-test/hierarchy/parent'] = array(
+ 'title' => 'Parent menu router',
+ 'page callback' => 'node_page_default',
+ );
+ $items['menu-test/hierarchy/parent/child'] = array(
+ 'title' => 'Child menu router',
+ 'page callback' => 'node_page_default',
+ );
+ $items['menu-test/hierarchy/parent/child2/child'] = array(
+ 'title' => 'Unattached subchild router',
+ 'page callback' => 'node_page_default',
+ );
+ // Theme callback tests.
+ $items['menu-test/theme-callback/%'] = array(
+ 'title' => 'Page that displays different themes',
+ 'page callback' => 'menu_test_theme_page_callback',
+ 'access arguments' => array('access content'),
+ 'theme callback' => 'menu_test_theme_callback',
+ 'theme arguments' => array(2),
+ );
+ $items['menu-test/theme-callback/%/inheritance'] = array(
+ 'title' => 'Page that tests theme callback inheritance.',
+ 'page callback' => 'menu_test_theme_page_callback',
+ 'page arguments' => array(TRUE),
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/no-theme-callback'] = array(
+ 'title' => 'Page that displays different themes without using a theme callback.',
+ 'page callback' => 'menu_test_theme_page_callback',
+ 'access arguments' => array('access content'),
+ );
+ // Path containing "exotic" characters.
+ $path = "menu-test/ -._~!$'\"()*@[]?&+%#,;=:" . // "Special" ASCII characters.
+ "%23%25%26%2B%2F%3F" . // Characters that look like a percent-escaped string.
+ "éøïвβ中國書۞"; // Characters from various non-ASCII alphabets.
+ $items[$path] = array(
+ 'title' => '"Exotic" path',
+ 'page callback' => 'menu_test_callback',
+ 'access arguments' => array('access content'),
+ );
+
+ // Hidden tests; base parents.
+ // Same structure as in Menu and Block modules. Since those structures can
+ // change, we need to simulate our own in here.
+ $items['menu-test'] = array(
+ 'title' => 'Menu test root',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/hidden'] = array(
+ 'title' => 'Hidden test root',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+
+ // Hidden tests; one dynamic argument.
+ $items['menu-test/hidden/menu'] = array(
+ 'title' => 'Menus',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/hidden/menu/list'] = array(
+ 'title' => 'List menus',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['menu-test/hidden/menu/add'] = array(
+ 'title' => 'Add menu',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_ACTION,
+ );
+ $items['menu-test/hidden/menu/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 5,
+ );
+ $items['menu-test/hidden/menu/manage/%menu'] = array(
+ 'title' => 'Customize menu',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/hidden/menu/manage/%menu/list'] = array(
+ 'title' => 'List links',
+ 'weight' => -10,
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ );
+ $items['menu-test/hidden/menu/manage/%menu/add'] = array(
+ 'title' => 'Add link',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_ACTION,
+ );
+ $items['menu-test/hidden/menu/manage/%menu/edit'] = array(
+ 'title' => 'Edit menu',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ );
+ $items['menu-test/hidden/menu/manage/%menu/delete'] = array(
+ 'title' => 'Delete menu',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+
+ // Hidden tests; two dynamic arguments.
+ $items['menu-test/hidden/block'] = array(
+ 'title' => 'Blocks',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/hidden/block/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['menu-test/hidden/block/add'] = array(
+ 'title' => 'Add block',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_ACTION,
+ );
+ $items['menu-test/hidden/block/manage/%/%'] = array(
+ 'title' => 'Configure block',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ );
+ $items['menu-test/hidden/block/manage/%/%/configure'] = array(
+ 'title' => 'Configure block',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_INLINE,
+ );
+ $items['menu-test/hidden/block/manage/%/%/delete'] = array(
+ 'title' => 'Delete block',
+ 'page callback' => 'node_page_default',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_LOCAL_TASK,
+ 'context' => MENU_CONTEXT_NONE,
+ );
+
+ // Breadcrumbs tests.
+ // @see MenuBreadcrumbTestCase
+ $base = array(
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+ // Local tasks: Second level below default local task.
+ $items['menu-test/breadcrumb/tasks'] = array(
+ 'title' => 'Breadcrumbs test: Local tasks',
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/first'] = array(
+ 'title' => 'First',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/second'] = array(
+ 'title' => 'Second',
+ 'type' => MENU_LOCAL_TASK,
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/first/first'] = array(
+ 'title' => 'First first',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/first/second'] = array(
+ 'title' => 'First second',
+ 'type' => MENU_LOCAL_TASK,
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/second/first'] = array(
+ 'title' => 'Second first',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ ) + $base;
+ $items['menu-test/breadcrumb/tasks/second/second'] = array(
+ 'title' => 'Second second',
+ 'type' => MENU_LOCAL_TASK,
+ ) + $base;
+
+ // Menu trail tests.
+ // @see MenuTrailTestCase
+ $items['menu-test/menu-trail'] = array(
+ 'title' => 'Menu trail - Case 1',
+ 'page callback' => 'menu_test_menu_trail_callback',
+ 'access arguments' => array('access content'),
+ );
+ $items['admin/config/development/menu-trail'] = array(
+ 'title' => 'Menu trail - Case 2',
+ 'description' => 'Tests menu_tree_set_path()',
+ 'page callback' => 'menu_test_menu_trail_callback',
+ 'access arguments' => array('access administration pages'),
+ );
+
+ // File inheritance tests. This menu item should inherit the page callback
+ // system_admin_menu_block_page() and therefore render its children as links
+ // on the page.
+ $items['admin/config/development/file-inheritance'] = array(
+ 'title' => 'File inheritance',
+ 'description' => 'Test file inheritance',
+ 'access arguments' => array('access content'),
+ );
+ $items['admin/config/development/file-inheritance/inherit'] = array(
+ 'title' => 'Inherit',
+ 'description' => 'File inheritance test description',
+ 'page callback' => 'menu_test_callback',
+ 'access arguments' => array('access content'),
+ );
+
+ $items['menu_login_callback'] = array(
+ 'title' => 'Used as a login path',
+ 'page callback' => 'menu_login_callback',
+ 'access callback' => TRUE,
+ );
+
+ $items['menu-title-test/case1'] = array(
+ 'title' => 'Example title - Case 1',
+ 'access callback' => TRUE,
+ 'page callback' => 'menu_test_callback',
+ );
+ $items['menu-title-test/case2'] = array(
+ 'title' => 'Example @sub1 - Case @op2',
+ // If '2' is not in quotes, the argument becomes arg(2).
+ 'title arguments' => array('@sub1' => 'title', '@op2' => '2'),
+ 'access callback' => TRUE,
+ 'page callback' => 'menu_test_callback',
+ );
+ $items['menu-title-test/case3'] = array(
+ 'title' => 'Example title',
+ 'title callback' => 'menu_test_title_callback',
+ 'access callback' => TRUE,
+ 'page callback' => 'menu_test_callback',
+ );
+ $items['menu-title-test/case4'] = array(
+ // Title gets completely ignored. Good thing, too.
+ 'title' => 'Bike sheds full of blue smurfs',
+ 'title callback' => 'menu_test_title_callback',
+ // If '4' is not in quotes, the argument becomes arg(4).
+ 'title arguments' => array('Example title', '4'),
+ 'access callback' => TRUE,
+ 'page callback' => 'menu_test_callback',
+ );
+
+ // Load arguments inheritance test.
+ $items['menu-test/arguments/%menu_test_argument/%'] = array(
+ 'title' => 'Load arguments inheritance test',
+ 'load arguments' => array(3),
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+ $items['menu-test/arguments/%menu_test_argument/%/default'] = array(
+ 'title' => 'Default local task',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['menu-test/arguments/%menu_test_argument/%/task'] = array(
+ 'title' => 'Local task',
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ );
+ // For this path, load arguments should be inherited for the first loader only.
+ $items['menu-test/arguments/%menu_test_argument/%menu_test_other_argument/common-loader'] = array(
+ 'title' => 'Local task',
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ );
+ // For these paths, no load arguments should be inherited.
+ // Not on the same position.
+ $items['menu-test/arguments/%/%menu_test_argument/different-loaders-1'] = array(
+ 'title' => 'An item not sharing the same loader',
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+ // Not the same loader.
+ $items['menu-test/arguments/%menu_test_other_argument/%/different-loaders-2'] = array(
+ 'title' => 'An item not sharing the same loader',
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+ // Not the same loader.
+ $items['menu-test/arguments/%/%/different-loaders-3'] = array(
+ 'title' => 'An item not sharing the same loader',
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+ // Explict load arguments should not be overriden (even if empty).
+ $items['menu-test/arguments/%menu_test_argument/%/explicit-arguments'] = array(
+ 'title' => 'An item defining explicit load arguments',
+ 'load arguments' => array(),
+ 'page callback' => 'menu_test_callback',
+ 'access callback' => TRUE,
+ );
+
+ return $items;
+}
+
+/**
+ * Dummy argument loader for hook_menu() to point to.
+ */
+function menu_test_argument_load($arg1) {
+ return FALSE;
+}
+
+/**
+ * Dummy argument loader for hook_menu() to point to.
+ */
+function menu_test_other_argument_load($arg1) {
+ return FALSE;
+}
+
+/**
+ * Dummy callback for hook_menu() to point to.
+ *
+ * @return
+ * A random string.
+ */
+function menu_test_callback() {
+ return 'This is menu_test_callback().';
+}
+
+/**
+ * Callback that test menu_test_menu_tree_set_path().
+ */
+function menu_test_menu_trail_callback() {
+ $menu_path = variable_get('menu_test_menu_tree_set_path', array());
+ if (!empty($menu_path)) {
+ menu_tree_set_path($menu_path['menu_name'], $menu_path['path']);
+ }
+ return 'This is menu_test_menu_trail_callback().';
+}
+
+/**
+ * Page callback to use when testing the theme callback functionality.
+ *
+ * @param $inherited
+ * An optional boolean to set to TRUE when the requested page is intended to
+ * inherit the theme of its parent.
+ * @return
+ * A string describing the requested custom theme and actual theme being used
+ * for the current page request.
+ */
+function menu_test_theme_page_callback($inherited = FALSE) {
+ global $theme_key;
+ // Initialize the theme system so that $theme_key will be populated.
+ drupal_theme_initialize();
+ // Now check both the requested custom theme and the actual theme being used.
+ $custom_theme = menu_get_custom_theme();
+ $requested_theme = empty($custom_theme) ? 'NONE' : $custom_theme;
+ $output = "Custom theme: $requested_theme. Actual theme: $theme_key.";
+ if ($inherited) {
+ $output .= ' Theme callback inheritance is being tested.';
+ }
+ return $output;
+}
+
+/**
+ * Theme callback to use when testing the theme callback functionality.
+ *
+ * @param $argument
+ * The argument passed in from the URL.
+ * @return
+ * The name of the custom theme to request for the current page.
+ */
+function menu_test_theme_callback($argument) {
+ // Test using the variable administrative theme.
+ if ($argument == 'use-admin-theme') {
+ return variable_get('admin_theme');
+ }
+ // Test using a theme that exists, but may or may not be enabled.
+ elseif ($argument == 'use-stark-theme') {
+ return 'stark';
+ }
+ // Test using a theme that does not exist.
+ elseif ($argument == 'use-fake-theme') {
+ return 'fake_theme';
+ }
+ // For any other value of the URL argument, do not return anything. This
+ // allows us to test that returning nothing from a theme callback function
+ // causes the page to correctly fall back on using the main site theme.
+}
+
+/**
+ * Implement hook_custom_theme().
+ *
+ * @return
+ * The name of the custom theme to use for the current page.
+ */
+function menu_test_custom_theme() {
+ // If an appropriate variable has been set in the database, request the theme
+ // that is stored there. Otherwise, do not attempt to dynamically set the
+ // theme.
+ if ($theme = variable_get('menu_test_hook_custom_theme_name', FALSE)) {
+ return $theme;
+ }
+}
+
+/**
+ * Helper function for the testMenuName() test. Used to change the menu_name
+ * parameter of a menu.
+ *
+ * @param $new_name
+ * If set, will change the menu_name value.
+ * @return
+ * The menu_name value to use.
+ */
+function menu_test_menu_name($new_name = '') {
+ static $name = 'original';
+ if ($new_name) {
+ $name = $new_name;
+ }
+ return $name;
+}
+
+/**
+ * Implements hook_menu_link_insert().
+ *
+ * @return
+ * A random string.
+ */
+function menu_test_menu_link_insert($item) {
+ menu_test_static_variable('insert');
+}
+
+/**
+ * Implements hook_menu_link_update().
+ *
+ * @return
+ * A random string.
+ */
+function menu_test_menu_link_update($item) {
+ menu_test_static_variable('update');
+}
+
+/**
+ * Implements hook_menu_link_delete().
+ *
+ * @return
+ * A random string.
+ */
+function menu_test_menu_link_delete($item) {
+ menu_test_static_variable('delete');
+}
+
+/**
+ * Static function for testing hook results.
+ *
+ * @param $value
+ * The value to set or NULL to return the current value.
+ * @return
+ * A text string for comparison to test assertions.
+ */
+function menu_test_static_variable($value = NULL) {
+ static $variable;
+ if (!empty($value)) {
+ $variable = $value;
+ }
+ return $variable;
+}
+
+/**
+ * Implements hook_menu_site_status_alter().
+ */
+function menu_test_menu_site_status_alter(&$menu_site_status, $path) {
+ // Allow access to ?q=menu_login_callback even if in maintenance mode.
+ if ($menu_site_status == MENU_SITE_OFFLINE && $path == 'menu_login_callback') {
+ $menu_site_status = MENU_SITE_ONLINE;
+ }
+}
+
+/**
+ * Menu callback to be used as a login path.
+ */
+function menu_login_callback() {
+ return 'This is menu_login_callback().';
+}
+
+/**
+ * Concatenates a string, by using the t() function and a case number.
+ *
+ * @param $title
+ * Title string.
+ * @param $case_number
+ * The current case number which is tests (defaults to 3).
+ */
+function menu_test_title_callback($title, $case_no = 3) {
+ return t($title) . ' - Case ' . $case_no;
+}
diff --git a/core/modules/simpletest/tests/module.test b/core/modules/simpletest/tests/module.test
new file mode 100644
index 000000000000..c9601c9b9ab7
--- /dev/null
+++ b/core/modules/simpletest/tests/module.test
@@ -0,0 +1,304 @@
+<?php
+
+/**
+ * @file
+ * Tests for the module API.
+ */
+
+/**
+ * Unit tests for the module API.
+ */
+class ModuleUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Module API',
+ 'description' => 'Test low-level module functions.',
+ 'group' => 'Module',
+ );
+ }
+
+ /**
+ * The basic functionality of module_list().
+ */
+ function testModuleList() {
+ // Build a list of modules, sorted alphabetically.
+ $profile_info = install_profile_info('standard', 'en');
+ $module_list = $profile_info['dependencies'];
+
+ // Install profile is a module that is expected to be loaded.
+ $module_list[] = 'standard';
+
+ sort($module_list);
+ // Compare this list to the one returned by module_list(). We expect them
+ // to match, since all default profile modules have a weight equal to 0
+ // (except for block.module, which has a lower weight but comes first in
+ // the alphabet anyway).
+ $this->assertModuleList($module_list, t('Standard profile'));
+
+ // Try to install a new module.
+ module_enable(array('contact'));
+ $module_list[] = 'contact';
+ sort($module_list);
+ $this->assertModuleList($module_list, t('After adding a module'));
+
+ // Try to mess with the module weights.
+ db_update('system')
+ ->fields(array('weight' => 20))
+ ->condition('name', 'contact')
+ ->condition('type', 'module')
+ ->execute();
+ // Reset the module list.
+ module_list(TRUE);
+ // Move contact to the end of the array.
+ unset($module_list[array_search('contact', $module_list)]);
+ $module_list[] = 'contact';
+ $this->assertModuleList($module_list, t('After changing weights'));
+
+ // Test the fixed list feature.
+ $fixed_list = array(
+ 'system' => array('filename' => drupal_get_path('module', 'system')),
+ 'menu' => array('filename' => drupal_get_path('module', 'menu')),
+ );
+ module_list(FALSE, FALSE, FALSE, $fixed_list);
+ $new_module_list = array_combine(array_keys($fixed_list), array_keys($fixed_list));
+ $this->assertModuleList($new_module_list, t('When using a fixed list'));
+
+ // Reset the module list.
+ module_list(TRUE);
+ $this->assertModuleList($module_list, t('After reset'));
+ }
+
+ /**
+ * Assert that module_list() return the expected values.
+ *
+ * @param $expected_values
+ * The expected values, sorted by weight and module name.
+ */
+ protected function assertModuleList(Array $expected_values, $condition) {
+ $expected_values = array_combine($expected_values, $expected_values);
+ $this->assertEqual($expected_values, module_list(), t('@condition: module_list() returns correct results', array('@condition' => $condition)));
+ ksort($expected_values);
+ $this->assertIdentical($expected_values, module_list(FALSE, FALSE, TRUE), t('@condition: module_list() returns correctly sorted results', array('@condition' => $condition)));
+ }
+
+ /**
+ * Test module_implements() caching.
+ */
+ function testModuleImplements() {
+ // Clear the cache.
+ cache('bootstrap')->delete('module_implements');
+ $this->assertFalse(cache('bootstrap')->get('module_implements'), t('The module implements cache is empty.'));
+ $this->drupalGet('');
+ $this->assertTrue(cache('bootstrap')->get('module_implements'), t('The module implements cache is populated after requesting a page.'));
+
+ // Test again with an authenticated user.
+ $this->user = $this->drupalCreateUser();
+ $this->drupalLogin($this->user);
+ cache('bootstrap')->delete('module_implements');
+ $this->drupalGet('');
+ $this->assertTrue(cache('bootstrap')->get('module_implements'), t('The module implements cache is populated after requesting a page.'));
+
+ // Make sure group include files are detected properly even when the file is
+ // already loaded when the cache is rebuilt.
+ // For that activate the module_test which provides the file to load.
+ module_enable(array('module_test'));
+
+ module_load_include('inc', 'module_test', 'module_test.file');
+ $modules = module_implements('test_hook');
+ $static = drupal_static('module_implements');
+ $this->assertTrue(in_array('module_test', $modules), 'Hook found.');
+ $this->assertEqual($static['test_hook']['module_test'], 'file', 'Include file detected.');
+ }
+
+ /**
+ * Test that module_invoke() can load a hook defined in hook_hook_info().
+ */
+ function testModuleInvoke() {
+ module_enable(array('module_test'), FALSE);
+ $this->resetAll();
+ $this->drupalGet('module-test/hook-dynamic-loading-invoke');
+ $this->assertText('success!', t('module_invoke() dynamically loads a hook defined in hook_hook_info().'));
+ }
+
+ /**
+ * Test that module_invoke_all() can load a hook defined in hook_hook_info().
+ */
+ function testModuleInvokeAll() {
+ module_enable(array('module_test'), FALSE);
+ $this->resetAll();
+ $this->drupalGet('module-test/hook-dynamic-loading-invoke-all');
+ $this->assertText('success!', t('module_invoke_all() dynamically loads a hook defined in hook_hook_info().'));
+ }
+
+ /**
+ * Test dependency resolution.
+ */
+ function testDependencyResolution() {
+ // Enable the test module, and make sure that other modules we are testing
+ // are not already enabled. (If they were, the tests below would not work
+ // correctly.)
+ module_enable(array('module_test'), FALSE);
+ $this->assertTrue(module_exists('module_test'), t('Test module is enabled.'));
+ $this->assertFalse(module_exists('forum'), t('Forum module is disabled.'));
+ $this->assertFalse(module_exists('poll'), t('Poll module is disabled.'));
+ $this->assertFalse(module_exists('php'), t('PHP module is disabled.'));
+
+ // First, create a fake missing dependency. Forum depends on poll, which
+ // depends on a made-up module, foo. Nothing should be installed.
+ variable_set('dependency_test', 'missing dependency');
+ drupal_static_reset('system_rebuild_module_data');
+ $result = module_enable(array('forum'));
+ $this->assertFalse($result, t('module_enable() returns FALSE if dependencies are missing.'));
+ $this->assertFalse(module_exists('forum'), t('module_enable() aborts if dependencies are missing.'));
+
+ // Now, fix the missing dependency. Forum module depends on poll, but poll
+ // depends on the PHP module. module_enable() should work.
+ variable_set('dependency_test', 'dependency');
+ drupal_static_reset('system_rebuild_module_data');
+ $result = module_enable(array('forum'));
+ $this->assertTrue($result, t('module_enable() returns the correct value.'));
+ // Verify that the fake dependency chain was installed.
+ $this->assertTrue(module_exists('poll') && module_exists('php'), t('Dependency chain was installed by module_enable().'));
+ // Verify that the original module was installed.
+ $this->assertTrue(module_exists('forum'), t('Module installation with unlisted dependencies succeeded.'));
+ // Finally, verify that the modules were enabled in the correct order.
+ $this->assertEqual(variable_get('test_module_enable_order', array()), array('php', 'poll', 'forum'), t('Modules were enabled in the correct order by module_enable().'));
+
+ // Now, disable the PHP module. Both forum and poll should be disabled as
+ // well, in the correct order.
+ module_disable(array('php'));
+ $this->assertTrue(!module_exists('forum') && !module_exists('poll'), t('Depedency chain was disabled by module_disable().'));
+ $this->assertFalse(module_exists('php'), t('Disabling a module with unlisted dependents succeeded.'));
+ $this->assertEqual(variable_get('test_module_disable_order', array()), array('forum', 'poll', 'php'), t('Modules were disabled in the correct order by module_disable().'));
+
+ // Disable a module that is listed as a dependency by the install profile.
+ // Make sure that the profile itself is not on the list of dependent
+ // modules to be disabled.
+ $profile = drupal_get_profile();
+ $info = install_profile_info($profile);
+ $this->assertTrue(in_array('comment', $info['dependencies']), t('Comment module is listed as a dependency of the install profile.'));
+ $this->assertTrue(module_exists('comment'), t('Comment module is enabled.'));
+ module_disable(array('comment'));
+ $this->assertFalse(module_exists('comment'), t('Comment module was disabled.'));
+ $disabled_modules = variable_get('test_module_disable_order', array());
+ $this->assertTrue(in_array('comment', $disabled_modules), t('Comment module is in the list of disabled modules.'));
+ $this->assertFalse(in_array($profile, $disabled_modules), t('The installation profile is not in the list of disabled modules.'));
+
+ // Try to uninstall the PHP module by itself. This should be rejected,
+ // since the modules which it depends on need to be uninstalled first, and
+ // that is too destructive to perform automatically.
+ $result = drupal_uninstall_modules(array('php'));
+ $this->assertFalse($result, t('Calling drupal_uninstall_modules() on a module whose dependents are not uninstalled fails.'));
+ foreach (array('forum', 'poll', 'php') as $module) {
+ $this->assertNotEqual(drupal_get_installed_schema_version($module), SCHEMA_UNINSTALLED, t('The @module module was not uninstalled.', array('@module' => $module)));
+ }
+
+ // Now uninstall all three modules explicitly, but in the incorrect order,
+ // and make sure that drupal_uninstal_modules() uninstalled them in the
+ // correct sequence.
+ $result = drupal_uninstall_modules(array('poll', 'php', 'forum'));
+ $this->assertTrue($result, t('drupal_uninstall_modules() returns the correct value.'));
+ foreach (array('forum', 'poll', 'php') as $module) {
+ $this->assertEqual(drupal_get_installed_schema_version($module), SCHEMA_UNINSTALLED, t('The @module module was uninstalled.', array('@module' => $module)));
+ }
+ $this->assertEqual(variable_get('test_module_uninstall_order', array()), array('forum', 'poll', 'php'), t('Modules were uninstalled in the correct order by drupal_uninstall_modules().'));
+
+ // Uninstall the profile module from above, and make sure that the profile
+ // itself is not on the list of dependent modules to be uninstalled.
+ $result = drupal_uninstall_modules(array('comment'));
+ $this->assertTrue($result, t('drupal_uninstall_modules() returns the correct value.'));
+ $this->assertEqual(drupal_get_installed_schema_version('comment'), SCHEMA_UNINSTALLED, t('Comment module was uninstalled.'));
+ $uninstalled_modules = variable_get('test_module_uninstall_order', array());
+ $this->assertTrue(in_array('comment', $uninstalled_modules), t('Comment module is in the list of uninstalled modules.'));
+ $this->assertFalse(in_array($profile, $uninstalled_modules), t('The installation profile is not in the list of uninstalled modules.'));
+
+ // Enable forum module again, which should enable both the poll module and
+ // php module. But, this time do it with poll module declaring a dependency
+ // on a specific version of php module in its info file. Make sure that
+ // module_enable() still works.
+ variable_set('dependency_test', 'version dependency');
+ drupal_static_reset('system_rebuild_module_data');
+ $result = module_enable(array('forum'));
+ $this->assertTrue($result, t('module_enable() returns the correct value.'));
+ // Verify that the fake dependency chain was installed.
+ $this->assertTrue(module_exists('poll') && module_exists('php'), t('Dependency chain was installed by module_enable().'));
+ // Verify that the original module was installed.
+ $this->assertTrue(module_exists('forum'), t('Module installation with version dependencies succeeded.'));
+ // Finally, verify that the modules were enabled in the correct order.
+ $enable_order = variable_get('test_module_enable_order', array());
+ $php_position = array_search('php', $enable_order);
+ $poll_position = array_search('poll', $enable_order);
+ $forum_position = array_search('forum', $enable_order);
+ $php_before_poll = $php_position !== FALSE && $poll_position !== FALSE && $php_position < $poll_position;
+ $poll_before_forum = $poll_position !== FALSE && $forum_position !== FALSE && $poll_position < $forum_position;
+ $this->assertTrue($php_before_poll && $poll_before_forum, t('Modules were enabled in the correct order by module_enable().'));
+ }
+}
+
+/**
+ * Unit tests for module installation.
+ */
+class ModuleInstallTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Module installation',
+ 'description' => 'Tests the installation of modules.',
+ 'group' => 'Module',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('module_test');
+ }
+
+ /**
+ * Test that calls to drupal_write_record() work during module installation.
+ *
+ * This is a useful function to test because modules often use it to insert
+ * initial data in their database tables when they are being installed or
+ * enabled. Furthermore, drupal_write_record() relies on the module schema
+ * information being available, so this also checks that the data from one of
+ * the module's hook implementations, in particular hook_schema(), is
+ * properly available during this time. Therefore, this test helps ensure
+ * that modules are fully functional while Drupal is installing and enabling
+ * them.
+ */
+ function testDrupalWriteRecord() {
+ // Check for data that was inserted using drupal_write_record() while the
+ // 'module_test' module was being installed and enabled.
+ $data = db_query("SELECT data FROM {module_test}")->fetchCol();
+ $this->assertTrue(in_array('Data inserted in hook_install()', $data), t('Data inserted using drupal_write_record() in hook_install() is correctly saved.'));
+ $this->assertTrue(in_array('Data inserted in hook_enable()', $data), t('Data inserted using drupal_write_record() in hook_enable() is correctly saved.'));
+ }
+}
+
+/**
+ * Unit tests for module uninstallation and related hooks.
+ */
+class ModuleUninstallTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Module uninstallation',
+ 'description' => 'Tests the uninstallation of modules.',
+ 'group' => 'Module',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('module_test', 'user');
+ }
+
+ /**
+ * Tests the hook_modules_uninstalled() of the user module.
+ */
+ function testUserPermsUninstalled() {
+ // Uninstalls the module_test module, so hook_modules_uninstalled()
+ // is executed.
+ module_disable(array('module_test'));
+ drupal_uninstall_modules(array('module_test'));
+
+ // Are the perms defined by module_test removed from {role_permission}.
+ $count = db_query("SELECT COUNT(rid) FROM {role_permission} WHERE permission = :perm", array(':perm' => 'module_test perm'))->fetchField();
+ $this->assertEqual(0, $count, t('Permissions were all removed.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/module_test.file.inc b/core/modules/simpletest/tests/module_test.file.inc
new file mode 100644
index 000000000000..c0d3ec41e68c
--- /dev/null
+++ b/core/modules/simpletest/tests/module_test.file.inc
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * @file
+ * A file to test module_implements() loading includes.
+ */
+
+/**
+ * Implements hook_test_hook().
+ */
+function module_test_test_hook() {
+ return array('module_test' => 'success!');
+}
diff --git a/core/modules/simpletest/tests/module_test.info b/core/modules/simpletest/tests/module_test.info
new file mode 100644
index 000000000000..c0b243c2dcc4
--- /dev/null
+++ b/core/modules/simpletest/tests/module_test.info
@@ -0,0 +1,6 @@
+name = "Module test"
+description = "Support module for module system testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/module_test.install b/core/modules/simpletest/tests/module_test.install
new file mode 100644
index 000000000000..4cc09df5a8d2
--- /dev/null
+++ b/core/modules/simpletest/tests/module_test.install
@@ -0,0 +1,42 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the module_test module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function module_test_schema() {
+ $schema['module_test'] = array(
+ 'description' => 'Dummy table to test the behavior of hook_schema() during module installation.',
+ 'fields' => array(
+ 'data' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'An example data column for the module.',
+ ),
+ ),
+ );
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function module_test_install() {
+ $record = array('data' => 'Data inserted in hook_install()');
+ drupal_write_record('module_test', $record);
+}
+
+/**
+ * Implements hook_enable().
+ */
+function module_test_enable() {
+ $record = array('data' => 'Data inserted in hook_enable()');
+ drupal_write_record('module_test', $record);
+}
+
diff --git a/core/modules/simpletest/tests/module_test.module b/core/modules/simpletest/tests/module_test.module
new file mode 100644
index 000000000000..bd850498ce51
--- /dev/null
+++ b/core/modules/simpletest/tests/module_test.module
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * Implements hook_permission().
+ */
+function module_test_permission() {
+ return array(
+ 'module_test perm' => t('example perm for module_test module'),
+ );
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Manipulate module dependencies to test dependency chains.
+ */
+function module_test_system_info_alter(&$info, $file, $type) {
+ if (variable_get('dependency_test', FALSE) == 'missing dependency') {
+ if ($file->name == 'forum') {
+ // Make forum module depend on poll.
+ $info['dependencies'][] = 'poll';
+ }
+ elseif ($file->name == 'poll') {
+ // Make poll depend on a made-up module.
+ $info['dependencies'][] = 'foo';
+ }
+ }
+ elseif (variable_get('dependency_test', FALSE) == 'dependency') {
+ if ($file->name == 'forum') {
+ // Make the forum module depend on poll.
+ $info['dependencies'][] = 'poll';
+ }
+ elseif ($file->name == 'poll') {
+ // Make poll depend on php module.
+ $info['dependencies'][] = 'php';
+ }
+ }
+ elseif (variable_get('dependency_test', FALSE) == 'version dependency') {
+ if ($file->name == 'forum') {
+ // Make the forum module depend on poll.
+ $info['dependencies'][] = 'poll';
+ }
+ elseif ($file->name == 'poll') {
+ // Make poll depend on a specific version of php module.
+ $info['dependencies'][] = 'php (1.x)';
+ }
+ elseif ($file->name == 'php') {
+ // Set php module to a version compatible with the above.
+ $info['version'] = '8.x-1.0';
+ }
+ }
+ if ($file->name == 'seven' && $type == 'theme') {
+ $info['regions']['test_region'] = t('Test region');
+ }
+}
+
+/**
+ * Implements hook_hook_info().
+ */
+function module_test_hook_info() {
+ $hooks['test_hook'] = array(
+ 'group' => 'file',
+ );
+ return $hooks;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function module_test_menu() {
+ $items['module-test/hook-dynamic-loading-invoke'] = array(
+ 'title' => 'Test hook dynamic loading (invoke)',
+ 'page callback' => 'module_test_hook_dynamic_loading_invoke',
+ 'access arguments' => array('access content'),
+ );
+ $items['module-test/hook-dynamic-loading-invoke-all'] = array(
+ 'title' => 'Test hook dynamic loading (invoke_all)',
+ 'page callback' => 'module_test_hook_dynamic_loading_invoke_all',
+ 'access arguments' => array('access content'),
+ );
+ return $items;
+}
+
+/**
+ * Page callback for 'hook dynamic loading' test.
+ *
+ * If the hook is dynamically loaded correctly, the menu callback should
+ * return 'success!'.
+ */
+function module_test_hook_dynamic_loading_invoke() {
+ $result = module_invoke('module_test', 'test_hook');
+ return $result['module_test'];
+}
+
+/**
+ * Page callback for 'hook dynamic loading' test.
+ *
+ * If the hook is dynamically loaded correctly, the menu callback should
+ * return 'success!'.
+ */
+function module_test_hook_dynamic_loading_invoke_all() {
+ $result = module_invoke_all('test_hook');
+ return $result['module_test'];
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function module_test_modules_enabled($modules) {
+ // Record the ordered list of modules that were passed in to this hook so we
+ // can check that the modules were enabled in the correct sequence.
+ variable_set('test_module_enable_order', $modules);
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function module_test_modules_disabled($modules) {
+ // Record the ordered list of modules that were passed in to this hook so we
+ // can check that the modules were disabled in the correct sequence.
+ variable_set('test_module_disable_order', $modules);
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function module_test_modules_uninstalled($modules) {
+ // Record the ordered list of modules that were passed in to this hook so we
+ // can check that the modules were uninstalled in the correct sequence.
+ variable_set('test_module_uninstall_order', $modules);
+}
diff --git a/core/modules/simpletest/tests/password.test b/core/modules/simpletest/tests/password.test
new file mode 100644
index 000000000000..e0139e992102
--- /dev/null
+++ b/core/modules/simpletest/tests/password.test
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @file
+ * Provides unit tests for password.inc.
+ */
+
+/**
+ * Unit tests for password hashing API.
+ */
+class PasswordHashingTest extends DrupalWebTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Password hashing',
+ 'description' => 'Password hashing unit tests.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ parent::setUp();
+ }
+
+ /**
+ * Test password hashing.
+ */
+ function testPasswordHashing() {
+ // Set a log2 iteration count that is deliberately out of bounds to test
+ // that it is corrected to be within bounds.
+ variable_set('password_count_log2', 1);
+ // Set up a fake $account with a password 'baz', hashed with md5.
+ $password = 'baz';
+ $account = (object) array('name' => 'foo', 'pass' => md5($password));
+ // The md5 password should be flagged as needing an update.
+ $this->assertTrue(user_needs_new_hash($account), t('User with md5 password needs a new hash.'));
+ // Re-hash the password.
+ $old_hash = $account->pass;
+ $account->pass = user_hash_password($password);
+ $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT, t('Re-hashed password has the minimum number of log2 iterations.'));
+ $this->assertTrue($account->pass != $old_hash, t('Password hash changed.'));
+ $this->assertTrue(user_check_password($password, $account), t('Password check succeeds.'));
+ // Since the log2 setting hasn't changed and the user has a valid password,
+ // user_needs_new_hash() should return FALSE.
+ $this->assertFalse(user_needs_new_hash($account), t('User does not need a new hash.'));
+ // Increment the log2 iteration to MIN + 1.
+ variable_set('password_count_log2', DRUPAL_MIN_HASH_COUNT + 1);
+ $this->assertTrue(user_needs_new_hash($account), t('User needs a new hash after incrementing the log2 count.'));
+ // Re-hash the password.
+ $old_hash = $account->pass;
+ $account->pass = user_hash_password($password);
+ $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_MIN_HASH_COUNT + 1, t('Re-hashed password has the correct number of log2 iterations.'));
+ $this->assertTrue($account->pass != $old_hash, t('Password hash changed again.'));
+ // Now the hash should be OK.
+ $this->assertFalse(user_needs_new_hash($account), t('Re-hashed password does not need a new hash.'));
+ $this->assertTrue(user_check_password($password, $account), t('Password check succeeds with re-hashed password.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/path.test b/core/modules/simpletest/tests/path.test
new file mode 100644
index 000000000000..82598b5a2d3d
--- /dev/null
+++ b/core/modules/simpletest/tests/path.test
@@ -0,0 +1,327 @@
+<?php
+
+/**
+ * @file
+ * Tests for path.inc.
+ */
+
+/**
+ * Unit tests for the drupal_match_path() function in path.inc.
+ *
+ * @see drupal_match_path().
+ */
+class DrupalMatchPathTestCase extends DrupalWebTestCase {
+ protected $front;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Drupal match path',
+ 'description' => 'Tests the drupal_match_path() function to make sure it works properly.',
+ 'group' => 'Path API',
+ );
+ }
+
+ function setUp() {
+ // Set up the database and testing environment.
+ parent::setUp();
+
+ // Set up a random site front page to test the '<front>' placeholder.
+ $this->front = $this->randomName();
+ variable_set('site_frontpage', $this->front);
+ // Refresh our static variables from the database.
+ $this->refreshVariables();
+ }
+
+ /**
+ * Run through our test cases, making sure each one works as expected.
+ */
+ function testDrupalMatchPath() {
+ // Set up our test cases.
+ $tests = $this->drupalMatchPathTests();
+ foreach ($tests as $patterns => $cases) {
+ foreach ($cases as $path => $expected_result) {
+ $actual_result = drupal_match_path($path, $patterns);
+ $this->assertIdentical($actual_result, $expected_result, t('Tried matching the path <code>@path</code> to the pattern <pre>@patterns</pre> - expected @expected, got @actual.', array('@path' => $path, '@patterns' => $patterns, '@expected' => var_export($expected_result, TRUE), '@actual' => var_export($actual_result, TRUE))));
+ }
+ }
+ }
+
+ /**
+ * Helper function for testDrupalMatchPath(): set up an array of test cases.
+ *
+ * @return
+ * An array of test cases to cycle through.
+ */
+ private function drupalMatchPathTests() {
+ return array(
+ // Single absolute paths.
+ 'example/1' => array(
+ 'example/1' => TRUE,
+ 'example/2' => FALSE,
+ 'test' => FALSE,
+ ),
+ // Single paths with wildcards.
+ 'example/*' => array(
+ 'example/1' => TRUE,
+ 'example/2' => TRUE,
+ 'example/3/edit' => TRUE,
+ 'example/' => TRUE,
+ 'example' => FALSE,
+ 'test' => FALSE,
+ ),
+ // Single paths with multiple wildcards.
+ 'node/*/revisions/*' => array(
+ 'node/1/revisions/3' => TRUE,
+ 'node/345/revisions/test' => TRUE,
+ 'node/23/edit' => FALSE,
+ 'test' => FALSE,
+ ),
+ // Single paths with '<front>'.
+ '<front>' => array(
+ $this->front => TRUE,
+ "$this->front/" => FALSE,
+ "$this->front/edit" => FALSE,
+ 'node' => FALSE,
+ '' => FALSE,
+ ),
+ // Paths with both '<front>' and wildcards (should not work).
+ '<front>/*' => array(
+ $this->front => FALSE,
+ "$this->front/" => FALSE,
+ "$this->front/edit" => FALSE,
+ 'node/12' => FALSE,
+ '' => FALSE,
+ ),
+ // Multiple paths with the \n delimiter.
+ "node/*\nnode/*/edit" => array(
+ 'node/1' => TRUE,
+ 'node/view' => TRUE,
+ 'node/32/edit' => TRUE,
+ 'node/delete/edit' => TRUE,
+ 'node/50/delete' => TRUE,
+ 'test/example' => FALSE,
+ ),
+ // Multiple paths with the \r delimiter.
+ "user/*\rexample/*" => array(
+ 'user/1' => TRUE,
+ 'example/1' => TRUE,
+ 'user/1/example/1' => TRUE,
+ 'user/example' => TRUE,
+ 'test/example' => FALSE,
+ 'user' => FALSE,
+ 'example' => FALSE,
+ ),
+ // Multiple paths with the \r\n delimiter.
+ "test\r\n<front>" => array(
+ 'test' => TRUE,
+ $this->front => TRUE,
+ 'example' => FALSE,
+ ),
+ // Test existing regular expressions (should be escaped).
+ '[^/]+?/[0-9]' => array(
+ 'test/1' => FALSE,
+ '[^/]+?/[0-9]' => TRUE,
+ ),
+ );
+ }
+}
+
+/**
+ * Tests hook_url_alter functions.
+ */
+class UrlAlterFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => t('URL altering'),
+ 'description' => t('Tests hook_url_inbound_alter() and hook_url_outbound_alter().'),
+ 'group' => t('Path API'),
+ );
+ }
+
+ function setUp() {
+ parent::setUp('path', 'forum', 'url_alter_test');
+ }
+
+ /**
+ * Test that URL altering works and that it occurs in the correct order.
+ */
+ function testUrlAlter() {
+ $account = $this->drupalCreateUser(array('administer url aliases'));
+ $this->drupalLogin($account);
+
+ $uid = $account->uid;
+ $name = $account->name;
+
+ // Test a single altered path.
+ $this->assertUrlInboundAlter("user/$name", "user/$uid");
+ $this->assertUrlOutboundAlter("user/$uid", "user/$name");
+
+ // Test that a path always uses its alias.
+ $path = array('source' => "user/$uid/test1", 'alias' => 'alias/test1');
+ path_save($path);
+ $this->assertUrlInboundAlter('alias/test1', "user/$uid/test1");
+ $this->assertUrlOutboundAlter("user/$uid/test1", 'alias/test1');
+
+ // Test that alias source paths are normalized in the interface.
+ $edit = array('source' => "user/$name/edit", 'alias' => 'alias/test2');
+ $this->drupalPost('admin/config/search/path/add', $edit, t('Save'));
+ $this->assertText(t('The alias has been saved.'));
+
+ // Test that a path always uses its alias.
+ $this->assertUrlInboundAlter('alias/test2', "user/$uid/edit");
+ $this->assertUrlOutboundAlter("user/$uid/edit", 'alias/test2');
+
+ // Test a non-existent user is not altered.
+ $uid++;
+ $this->assertUrlInboundAlter("user/$uid", "user/$uid");
+ $this->assertUrlOutboundAlter("user/$uid", "user/$uid");
+
+ // Test that 'forum' is altered to 'community' correctly, both at the root
+ // level and for a specific existing forum.
+ $this->assertUrlInboundAlter('community', 'forum');
+ $this->assertUrlOutboundAlter('forum', 'community');
+ $forum_vid = variable_get('forum_nav_vocabulary');
+ $tid = db_insert('taxonomy_term_data')
+ ->fields(array(
+ 'name' => $this->randomName(),
+ 'vid' => $forum_vid,
+ ))
+ ->execute();
+ $this->assertUrlInboundAlter("community/$tid", "forum/$tid");
+ $this->assertUrlOutboundAlter("forum/$tid", "community/$tid");
+ }
+
+ /**
+ * Test current_path() and request_path().
+ */
+ function testCurrentUrlRequestedPath() {
+ $this->drupalGet('url-alter-test/bar');
+ $this->assertRaw('request_path=url-alter-test/bar', t('request_path() returns the requested path.'));
+ $this->assertRaw('current_path=url-alter-test/foo', t('current_path() returns the internal path.'));
+ }
+
+ /**
+ * Assert that an outbound path is altered to an expected value.
+ *
+ * @param $original
+ * A string with the original path that is run through url().
+ * @param $final
+ * A string with the expected result after url().
+ * @return
+ * TRUE if $original was correctly altered to $final, FALSE otherwise.
+ */
+ protected function assertUrlOutboundAlter($original, $final) {
+ // Test outbound altering.
+ $result = url($original);
+ $base_path = base_path() . (variable_get('clean_url', '0') ? '' : '?q=');
+ $result = substr($result, strlen($base_path));
+ $this->assertIdentical($result, $final, t('Altered outbound URL %original, expected %final, and got %result.', array('%original' => $original, '%final' => $final, '%result' => $result)));
+ }
+
+ /**
+ * Assert that a inbound path is altered to an expected value.
+ *
+ * @param $original
+ * A string with the aliased or un-normal path that is run through
+ * drupal_get_normal_path().
+ * @param $final
+ * A string with the expected result after url().
+ * @return
+ * TRUE if $original was correctly altered to $final, FALSE otherwise.
+ */
+ protected function assertUrlInboundAlter($original, $final) {
+ // Test inbound altering.
+ $result = drupal_get_normal_path($original);
+ $this->assertIdentical($result, $final, t('Altered inbound URL %original, expected %final, and got %result.', array('%original' => $original, '%final' => $final, '%result' => $result)));
+ }
+}
+
+/**
+ * Unit test for drupal_lookup_path().
+ */
+class PathLookupTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => t('Path lookup'),
+ 'description' => t('Tests that drupal_lookup_path() returns correct paths.'),
+ 'group' => t('Path API'),
+ );
+ }
+
+ /**
+ * Test that drupal_lookup_path() returns the correct path.
+ */
+ function testDrupalLookupPath() {
+ $account = $this->drupalCreateUser();
+ $uid = $account->uid;
+ $name = $account->name;
+
+ // Test the situation where the source is the same for multiple aliases.
+ // Start with a language-neutral alias, which we will override.
+ $path = array(
+ 'source' => "user/$uid",
+ 'alias' => 'foo',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), $path['alias'], t('Basic alias lookup works.'));
+ $this->assertEqual(drupal_lookup_path('source', $path['alias']), $path['source'], t('Basic source lookup works.'));
+
+ // Create a language specific alias for the default language (English).
+ $path = array(
+ 'source' => "user/$uid",
+ 'alias' => "users/$name",
+ 'language' => 'en',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), $path['alias'], t('English alias overrides language-neutral alias.'));
+ $this->assertEqual(drupal_lookup_path('source', $path['alias']), $path['source'], t('English source overrides language-neutral source.'));
+
+ // Create a language-neutral alias for the same path, again.
+ $path = array(
+ 'source' => "user/$uid",
+ 'alias' => 'bar',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), "users/$name", t('English alias still returned after entering a language-neutral alias.'));
+
+ // Create a language-specific (xx-lolspeak) alias for the same path.
+ $path = array(
+ 'source' => "user/$uid",
+ 'alias' => 'LOL',
+ 'language' => 'xx-lolspeak',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), "users/$name", t('English alias still returned after entering a LOLspeak alias.'));
+ // The LOLspeak alias should be returned if we really want LOLspeak.
+ $this->assertEqual(drupal_lookup_path('alias', $path['source'], 'xx-lolspeak'), 'LOL', t('LOLspeak alias returned if we specify xx-lolspeak to drupal_lookup_path().'));
+
+ // Create a new alias for this path in English, which should override the
+ // previous alias for "user/$uid".
+ $path = array(
+ 'source' => "user/$uid",
+ 'alias' => 'users/my-new-path',
+ 'language' => 'en',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), $path['alias'], t('Recently created English alias returned.'));
+ $this->assertEqual(drupal_lookup_path('source', $path['alias']), $path['source'], t('Recently created English source returned.'));
+
+ // Remove the English aliases, which should cause a fallback to the most
+ // recently created language-neutral alias, 'bar'.
+ db_delete('url_alias')
+ ->condition('language', 'en')
+ ->execute();
+ drupal_clear_path_cache();
+ $this->assertEqual(drupal_lookup_path('alias', $path['source']), 'bar', t('Path lookup falls back to recently created language-neutral alias.'));
+
+ // Test the situation where the alias and language are the same, but
+ // the source differs. The newer alias record should be returned.
+ $account2 = $this->drupalCreateUser();
+ $path = array(
+ 'source' => 'user/' . $account2->uid,
+ 'alias' => 'bar',
+ );
+ path_save($path);
+ $this->assertEqual(drupal_lookup_path('source', $path['alias']), $path['source'], t('Newer alias record is returned when comparing two LANGUAGE_NONE paths with the same alias.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/registry.test b/core/modules/simpletest/tests/registry.test
new file mode 100644
index 000000000000..bcd8d4e0dec5
--- /dev/null
+++ b/core/modules/simpletest/tests/registry.test
@@ -0,0 +1,142 @@
+<?php
+
+class RegistryParseFileTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Registry parse file test',
+ 'description' => 'Parse a simple file and check that its resources are saved to the database.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ $chrs = hash('sha256', microtime() . mt_rand());
+ $this->fileName = 'registry_test_' . substr($chrs, 0, 16);
+ $this->className = 'registry_test_class' . substr($chrs, 16, 16);
+ $this->interfaceName = 'registry_test_interface' . substr($chrs, 32, 16);
+ parent::setUp();
+ }
+
+ /**
+ * testRegistryParseFile
+ */
+ function testRegistryParseFile() {
+ _registry_parse_file($this->fileName, $this->getFileContents());
+ foreach (array('className', 'interfaceName') as $resource) {
+ $foundName = db_query('SELECT name FROM {registry} WHERE name = :name', array(':name' => $this->$resource))->fetchField();
+ $this->assertTrue($this->$resource == $foundName, t('Resource "@resource" found.', array('@resource' => $this->$resource)));
+ }
+ }
+
+ /**
+ * getFileContents
+ */
+ function getFileContents() {
+ $file_contents = <<<CONTENTS
+<?php
+
+class {$this->className} {}
+
+interface {$this->interfaceName} {}
+
+CONTENTS;
+ return $file_contents;
+ }
+
+}
+
+class RegistryParseFilesTestCase extends DrupalWebTestCase {
+ protected $fileTypes = array('new', 'existing_changed');
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Registry parse files test',
+ 'description' => 'Read two a simple files from disc, and check that their resources are saved to the database.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ // Create files with some php to parse - one 'new', one 'existing' so
+ // we test all the important code paths in _registry_parse_files.
+ foreach ($this->fileTypes as $fileType) {
+ $chrs = hash('sha256', microtime() . mt_rand());
+ $this->$fileType = new stdClass();
+ $this->$fileType->fileName = 'public://registry_test_' . substr($chrs, 0, 16);
+ $this->$fileType->className = 'registry_test_class' . substr($chrs, 16, 16);
+ $this->$fileType->interfaceName = 'registry_test_interface' . substr($chrs, 32, 16);
+ $this->$fileType->contents = $this->getFileContents($fileType);
+ file_save_data($this->$fileType->contents, $this->$fileType->fileName);
+
+ if ($fileType == 'existing_changed') {
+ // Add a record with an incorrect hash.
+ $this->$fileType->fakeHash = hash('sha256', mt_rand());
+ db_insert('registry_file')
+ ->fields(array(
+ 'hash' => $this->$fileType->fakeHash,
+ 'filename' => $this->$fileType->fileName,
+ ))
+ ->execute();
+
+ // Insert some fake resource records.
+ foreach (array('class', 'interface') as $type) {
+ db_insert('registry')
+ ->fields(array(
+ 'name' => $type . hash('sha256', microtime() . mt_rand()),
+ 'type' => $type,
+ 'filename' => $this->$fileType->fileName,
+ ))
+ ->execute();
+ }
+ }
+ }
+ }
+
+ /**
+ * testRegistryParseFiles
+ */
+ function testRegistryParseFiles() {
+ _registry_parse_files($this->getFiles());
+ foreach ($this->fileTypes as $fileType) {
+ // Test that we have all the right resources.
+ foreach (array('className', 'interfaceName') as $resource) {
+ $foundName = db_query('SELECT name FROM {registry} WHERE name = :name', array(':name' => $this->$fileType->$resource))->fetchField();
+ $this->assertTrue($this->$fileType->$resource == $foundName, t('Resource "@resource" found.', array('@resource' => $this->$fileType->$resource)));
+ }
+ // Test that we have the right hash.
+ $hash = db_query('SELECT hash FROM {registry_file} WHERE filename = :filename', array(':filename' => $this->$fileType->fileName))->fetchField();
+ $this->assertTrue(hash('sha256', $this->$fileType->contents) == $hash, t('sha-256 for "@filename" matched.' . $fileType . $hash, array('@filename' => $this->$fileType->fileName)));
+ }
+ }
+
+ /**
+ * getFiles
+ */
+ function getFiles() {
+ $files = array();
+ foreach ($this->fileTypes as $fileType) {
+ $files[$this->$fileType->fileName] = array('module' => '', 'weight' => 0);
+ if ($fileType == 'existing_changed') {
+ $files[$this->$fileType->fileName]['hash'] = $this->$fileType->fakeHash;
+ }
+ }
+ return $files;
+ }
+
+ /**
+ * getFileContents
+ */
+ function getFileContents($fileType) {
+ $file_contents = <<<CONTENTS
+<?php
+
+class {$this->$fileType->className} {}
+
+interface {$this->$fileType->interfaceName} {}
+
+CONTENTS;
+ return $file_contents;
+ }
+
+}
diff --git a/core/modules/simpletest/tests/requirements1_test.info b/core/modules/simpletest/tests/requirements1_test.info
new file mode 100644
index 000000000000..6daa75e66db8
--- /dev/null
+++ b/core/modules/simpletest/tests/requirements1_test.info
@@ -0,0 +1,6 @@
+name = Requirements 1 Test
+description = "Tests that a module is not installed when it fails hook_requirements('install')."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/requirements1_test.install b/core/modules/simpletest/tests/requirements1_test.install
new file mode 100644
index 000000000000..651d911abb88
--- /dev/null
+++ b/core/modules/simpletest/tests/requirements1_test.install
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * Implements hook_requirements().
+ */
+function requirements1_test_requirements($phase) {
+ $requirements = array();
+ // Ensure translations don't break at install time.
+ $t = get_t();
+
+ // Always fails requirements.
+ if ('install' == $phase) {
+ $requirements['requirements1_test'] = array(
+ 'title' => $t('Requirements 1 Test'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => $t('Requirements 1 Test failed requirements.'),
+ );
+ }
+
+ return $requirements;
+}
diff --git a/core/modules/simpletest/tests/requirements1_test.module b/core/modules/simpletest/tests/requirements1_test.module
new file mode 100644
index 000000000000..e52266b2ec72
--- /dev/null
+++ b/core/modules/simpletest/tests/requirements1_test.module
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * @file
+ * Tests that a module is not installed when it fails
+ * hook_requirements('install').
+ */
diff --git a/core/modules/simpletest/tests/requirements2_test.info b/core/modules/simpletest/tests/requirements2_test.info
new file mode 100644
index 000000000000..270be65cd629
--- /dev/null
+++ b/core/modules/simpletest/tests/requirements2_test.info
@@ -0,0 +1,8 @@
+name = Requirements 2 Test
+description = "Tests that a module is not installed when the one it depends on fails hook_requirements('install)."
+dependencies[] = requirements1_test
+dependencies[] = comment
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/requirements2_test.module b/core/modules/simpletest/tests/requirements2_test.module
new file mode 100644
index 000000000000..a4f43051557f
--- /dev/null
+++ b/core/modules/simpletest/tests/requirements2_test.module
@@ -0,0 +1,7 @@
+<?php
+
+/**
+ * @file
+ * Tests that a module is not installed when the one it depends on fails
+ * hook_requirements('install').
+ */
diff --git a/core/modules/simpletest/tests/schema.test b/core/modules/simpletest/tests/schema.test
new file mode 100644
index 000000000000..8945117cbbdb
--- /dev/null
+++ b/core/modules/simpletest/tests/schema.test
@@ -0,0 +1,384 @@
+<?php
+
+/**
+ * @file
+ * Tests for the Database Schema API.
+ */
+
+/**
+ * Unit tests for the Schema API.
+ */
+class SchemaTestCase extends DrupalWebTestCase {
+ /**
+ * A global counter for table and field creation.
+ */
+ var $counter;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Schema API',
+ 'description' => 'Tests table creation and modification via the schema API.',
+ 'group' => 'Database',
+ );
+ }
+
+ /**
+ *
+ */
+ function testSchema() {
+ // Try creating a table.
+ $table_specification = array(
+ 'description' => 'Schema table description.',
+ 'fields' => array(
+ 'id' => array(
+ 'type' => 'int',
+ 'default' => NULL,
+ ),
+ 'test_field' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'description' => 'Schema column description.',
+ ),
+ ),
+ );
+ db_create_table('test_table', $table_specification);
+
+ // Assert that the table exists.
+ $this->assertTrue(db_table_exists('test_table'), t('The table exists.'));
+
+ // Assert that the table comment has been set.
+ $this->checkSchemaComment($table_specification['description'], 'test_table');
+
+ // Assert that the column comment has been set.
+ $this->checkSchemaComment($table_specification['fields']['test_field']['description'], 'test_table', 'test_field');
+
+ // An insert without a value for the column 'test_table' should fail.
+ $this->assertFalse($this->tryInsert(), t('Insert without a default failed.'));
+
+ // Add a default value to the column.
+ db_field_set_default('test_table', 'test_field', 0);
+ // The insert should now succeed.
+ $this->assertTrue($this->tryInsert(), t('Insert with a default succeeded.'));
+
+ // Remove the default.
+ db_field_set_no_default('test_table', 'test_field');
+ // The insert should fail again.
+ $this->assertFalse($this->tryInsert(), t('Insert without a default failed.'));
+
+ // Test for fake index and test for the boolean result of indexExists().
+ $index_exists = Database::getConnection()->schema()->indexExists('test_table', 'test_field');
+ $this->assertIdentical($index_exists, FALSE, t('Fake index does not exists'));
+ // Add index.
+ db_add_index('test_table', 'test_field', array('test_field'));
+ // Test for created index and test for the boolean result of indexExists().
+ $index_exists = Database::getConnection()->schema()->indexExists('test_table', 'test_field');
+ $this->assertIdentical($index_exists, TRUE, t('Index created.'));
+
+ // Rename the table.
+ db_rename_table('test_table', 'test_table2');
+
+ // Index should be renamed.
+ $index_exists = Database::getConnection()->schema()->indexExists('test_table2', 'test_field');
+ $this->assertTrue($index_exists, t('Index was renamed.'));
+
+ // We need the default so that we can insert after the rename.
+ db_field_set_default('test_table2', 'test_field', 0);
+ $this->assertFalse($this->tryInsert(), t('Insert into the old table failed.'));
+ $this->assertTrue($this->tryInsert('test_table2'), t('Insert into the new table succeeded.'));
+
+ // We should have successfully inserted exactly two rows.
+ $count = db_query('SELECT COUNT(*) FROM {test_table2}')->fetchField();
+ $this->assertEqual($count, 2, t('Two fields were successfully inserted.'));
+
+ // Try to drop the table.
+ db_drop_table('test_table2');
+ $this->assertFalse(db_table_exists('test_table2'), t('The dropped table does not exist.'));
+
+ // Recreate the table.
+ db_create_table('test_table', $table_specification);
+ db_field_set_default('test_table', 'test_field', 0);
+ db_add_field('test_table', 'test_serial', array('type' => 'int', 'not null' => TRUE, 'default' => 0, 'description' => 'Added column description.'));
+
+ // Assert that the column comment has been set.
+ $this->checkSchemaComment('Added column description.', 'test_table', 'test_serial');
+
+ // Change the new field to a serial column.
+ db_change_field('test_table', 'test_serial', 'test_serial', array('type' => 'serial', 'not null' => TRUE, 'description' => 'Changed column description.'), array('primary key' => array('test_serial')));
+
+ // Assert that the column comment has been set.
+ $this->checkSchemaComment('Changed column description.', 'test_table', 'test_serial');
+
+ $this->assertTrue($this->tryInsert(), t('Insert with a serial succeeded.'));
+ $max1 = db_query('SELECT MAX(test_serial) FROM {test_table}')->fetchField();
+ $this->assertTrue($this->tryInsert(), t('Insert with a serial succeeded.'));
+ $max2 = db_query('SELECT MAX(test_serial) FROM {test_table}')->fetchField();
+ $this->assertTrue($max2 > $max1, t('The serial is monotone.'));
+
+ $count = db_query('SELECT COUNT(*) FROM {test_table}')->fetchField();
+ $this->assertEqual($count, 2, t('There were two rows.'));
+
+ // Use database specific data type and ensure that table is created.
+ $table_specification = array(
+ 'description' => 'Schema table description.',
+ 'fields' => array(
+ 'timestamp' => array(
+ 'mysql_type' => 'timestamp',
+ 'pgsql_type' => 'timestamp',
+ 'sqlite_type' => 'datetime',
+ 'not null' => FALSE,
+ 'default' => NULL,
+ ),
+ ),
+ );
+ try {
+ db_create_table('test_timestamp', $table_specification);
+ }
+ catch (Exception $e) {}
+ $this->assertTrue(db_table_exists('test_timestamp'), t('Table with database specific datatype was created.'));
+ }
+
+ function tryInsert($table = 'test_table') {
+ try {
+ db_insert($table)
+ ->fields(array('id' => mt_rand(10, 20)))
+ ->execute();
+ return TRUE;
+ }
+ catch (Exception $e) {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Checks that a table or column comment matches a given description.
+ *
+ * @param $description
+ * The asserted description.
+ * @param $table
+ * The table to test.
+ * @param $column
+ * Optional column to test.
+ */
+ function checkSchemaComment($description, $table, $column = NULL) {
+ if (method_exists(Database::getConnection()->schema(), 'getComment')) {
+ $comment = Database::getConnection()->schema()->getComment($table, $column);
+ $this->assertEqual($comment, $description, t('The comment matches the schema description.'));
+ }
+ }
+
+ /**
+ * Tests creating unsigned columns and data integrity thereof.
+ */
+ function testUnsignedColumns() {
+ // First create the table with just a serial column.
+ $table_name = 'unsigned_table';
+ $table_spec = array(
+ 'fields' => array('serial_column' => array('type' => 'serial', 'unsigned' => TRUE, 'not null' => TRUE)),
+ 'primary key' => array('serial_column'),
+ );
+ $ret = array();
+ db_create_table($table_name, $table_spec);
+
+ // Now set up columns for the other types.
+ $types = array('int', 'float', 'numeric');
+ foreach ($types as $type) {
+ $column_spec = array('type' => $type, 'unsigned'=> TRUE);
+ if ($type == 'numeric') {
+ $column_spec += array('precision' => 10, 'scale' => 0);
+ }
+ $column_name = $type . '_column';
+ $table_spec['fields'][$column_name] = $column_spec;
+ db_add_field($table_name, $column_name, $column_spec);
+ }
+
+ // Finally, check each column and try to insert invalid values into them.
+ foreach ($table_spec['fields'] as $column_name => $column_spec) {
+ $this->assertTrue(db_field_exists($table_name, $column_name), t('Unsigned @type column was created.', array('@type' => $column_spec['type'])));
+ $this->assertFalse($this->tryUnsignedInsert($table_name, $column_name), t('Unsigned @type column rejected a negative value.', array('@type' => $column_spec['type'])));
+ }
+ }
+
+ /**
+ * Tries to insert a negative value into columns defined as unsigned.
+ *
+ * @param $table_name
+ * The table to insert
+ * @param $column_name
+ * The column to insert
+ * @return
+ * TRUE if the insert succeeded, FALSE otherwise
+ */
+ function tryUnsignedInsert($table_name, $column_name) {
+ try {
+ db_insert($table_name)
+ ->fields(array($column_name => -1))
+ ->execute();
+ return TRUE;
+ }
+ catch (Exception $e) {
+ return FALSE;
+ }
+ }
+
+ /**
+ * Test adding columns to an existing table.
+ */
+ function testSchemaAddField() {
+ // Test varchar types.
+ foreach (array(1, 32, 128, 256, 512) as $length) {
+ $base_field_spec = array(
+ 'type' => 'varchar',
+ 'length' => $length,
+ );
+ $variations = array(
+ array('not null' => FALSE),
+ array('not null' => FALSE, 'default' => '7'),
+ array('not null' => TRUE, 'initial' => 'd'),
+ array('not null' => TRUE, 'initial' => 'd', 'default' => '7'),
+ );
+
+ foreach ($variations as $variation) {
+ $field_spec = $variation + $base_field_spec;
+ $this->assertFieldAdditionRemoval($field_spec);
+ }
+ }
+
+ // Test int and float types.
+ foreach (array('int', 'float') as $type) {
+ foreach (array('tiny', 'small', 'medium', 'normal', 'big') as $size) {
+ $base_field_spec = array(
+ 'type' => $type,
+ 'size' => $size,
+ );
+ $variations = array(
+ array('not null' => FALSE),
+ array('not null' => FALSE, 'default' => 7),
+ array('not null' => TRUE, 'initial' => 1),
+ array('not null' => TRUE, 'initial' => 1, 'default' => 7),
+ );
+
+ foreach ($variations as $variation) {
+ $field_spec = $variation + $base_field_spec;
+ $this->assertFieldAdditionRemoval($field_spec);
+ }
+ }
+ }
+
+ // Test numeric types.
+ foreach (array(1, 5, 10, 40, 65) as $precision) {
+ foreach (array(0, 2, 10, 30) as $scale) {
+ if ($precision <= $scale) {
+ // Precision must be smaller then scale.
+ continue;
+ }
+
+ $base_field_spec = array(
+ 'type' => 'numeric',
+ 'scale' => $scale,
+ 'precision' => $precision,
+ );
+ $variations = array(
+ array('not null' => FALSE),
+ array('not null' => FALSE, 'default' => 7),
+ array('not null' => TRUE, 'initial' => 1),
+ array('not null' => TRUE, 'initial' => 1, 'default' => 7),
+ );
+
+ foreach ($variations as $variation) {
+ $field_spec = $variation + $base_field_spec;
+ $this->assertFieldAdditionRemoval($field_spec);
+ }
+ }
+ }
+ }
+
+ /**
+ * Assert that a given field can be added and removed from a table.
+ *
+ * The addition test covers both defining a field of a given specification
+ * when initially creating at table and extending an existing table.
+ *
+ * @param $field_spec
+ * The schema specification of the field.
+ */
+ protected function assertFieldAdditionRemoval($field_spec) {
+ // Try creating the field on a new table.
+ $table_name = 'test_table_' . ($this->counter++);
+ $table_spec = array(
+ 'fields' => array(
+ 'serial_column' => array('type' => 'serial', 'unsigned' => TRUE, 'not null' => TRUE),
+ 'test_field' => $field_spec,
+ ),
+ 'primary key' => array('serial_column'),
+ );
+ db_create_table($table_name, $table_spec);
+ $this->pass(t('Table %table created.', array('%table' => $table_name)));
+
+ // Check the characteristics of the field.
+ $this->assertFieldCharacteristics($table_name, 'test_field', $field_spec);
+
+ // Clean-up.
+ db_drop_table($table_name);
+
+ // Try adding a field to an existing table.
+ $table_name = 'test_table_' . ($this->counter++);
+ $table_spec = array(
+ 'fields' => array(
+ 'serial_column' => array('type' => 'serial', 'unsigned' => TRUE, 'not null' => TRUE),
+ ),
+ 'primary key' => array('serial_column'),
+ );
+ db_create_table($table_name, $table_spec);
+ $this->pass(t('Table %table created.', array('%table' => $table_name)));
+
+ // Insert some rows to the table to test the handling of initial values.
+ for ($i = 0; $i < 3; $i++) {
+ db_insert($table_name)
+ ->useDefaults(array('serial_column'))
+ ->execute();
+ }
+
+ db_add_field($table_name, 'test_field', $field_spec);
+ $this->pass(t('Column %column created.', array('%column' => 'test_field')));
+
+ // Check the characteristics of the field.
+ $this->assertFieldCharacteristics($table_name, 'test_field', $field_spec);
+
+ // Clean-up.
+ db_drop_field($table_name, 'test_field');
+ db_drop_table($table_name);
+ }
+
+ /**
+ * Assert that a newly added field has the correct characteristics.
+ */
+ protected function assertFieldCharacteristics($table_name, $field_name, $field_spec) {
+ // Check that the initial value has been registered.
+ if (isset($field_spec['initial'])) {
+ // There should be no row with a value different then $field_spec['initial'].
+ $count = db_select($table_name)
+ ->fields($table_name, array('serial_column'))
+ ->condition($field_name, $field_spec['initial'], '<>')
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($count, 0, t('Initial values filled out.'));
+ }
+
+ // Check that the default value has been registered.
+ if (isset($field_spec['default'])) {
+ // Try inserting a row, and check the resulting value of the new column.
+ $id = db_insert($table_name)
+ ->useDefaults(array('serial_column'))
+ ->execute();
+ $field_value = db_select($table_name)
+ ->fields($table_name, array($field_name))
+ ->condition('serial_column', $id)
+ ->execute()
+ ->fetchField();
+ $this->assertEqual($field_value, $field_spec['default'], t('Default value registered.'));
+ }
+
+ db_drop_field($table_name, $field_name);
+ }
+}
diff --git a/core/modules/simpletest/tests/session.test b/core/modules/simpletest/tests/session.test
new file mode 100644
index 000000000000..846f6d314c77
--- /dev/null
+++ b/core/modules/simpletest/tests/session.test
@@ -0,0 +1,536 @@
+<?php
+
+/**
+ * @file
+ * Provides SimpleTests for core session handling functionality.
+ */
+
+class SessionTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Session tests',
+ 'description' => 'Drupal session handling tests.',
+ 'group' => 'Session'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('session_test');
+ }
+
+ /**
+ * Tests for drupal_save_session() and drupal_session_regenerate().
+ */
+ function testSessionSaveRegenerate() {
+ $this->assertFalse(drupal_save_session(), t('drupal_save_session() correctly returns FALSE (inside of testing framework) when initially called with no arguments.'), t('Session'));
+ $this->assertFalse(drupal_save_session(FALSE), t('drupal_save_session() correctly returns FALSE when called with FALSE.'), t('Session'));
+ $this->assertFalse(drupal_save_session(), t('drupal_save_session() correctly returns FALSE when saving has been disabled.'), t('Session'));
+ $this->assertTrue(drupal_save_session(TRUE), t('drupal_save_session() correctly returns TRUE when called with TRUE.'), t('Session'));
+ $this->assertTrue(drupal_save_session(), t('drupal_save_session() correctly returns TRUE when saving has been enabled.'), t('Session'));
+
+ // Test session hardening code from SA-2008-044.
+ $user = $this->drupalCreateUser(array('access content'));
+
+ // Enable sessions.
+ $this->sessionReset($user->uid);
+
+ // Make sure the session cookie is set as HttpOnly.
+ $this->drupalLogin($user);
+ $this->assertTrue(preg_match('/HttpOnly/i', $this->drupalGetHeader('Set-Cookie', TRUE)), t('Session cookie is set as HttpOnly.'));
+ $this->drupalLogout();
+
+ // Verify that the session is regenerated if a module calls exit
+ // in hook_user_login().
+ user_save($user, array('name' => 'session_test_user'));
+ $user->name = 'session_test_user';
+ $this->drupalGet('session-test/id');
+ $matches = array();
+ preg_match('/\s*session_id:(.*)\n/', $this->drupalGetContent(), $matches);
+ $this->assertTrue(!empty($matches[1]) , t('Found session ID before logging in.'));
+ $original_session = $matches[1];
+
+ // We cannot use $this->drupalLogin($user); because we exit in
+ // session_test_user_login() which breaks a normal assertion.
+ $edit = array(
+ 'name' => $user->name,
+ 'pass' => $user->pass_raw
+ );
+ $this->drupalPost('user', $edit, t('Log in'));
+ $this->drupalGet('user');
+ $pass = $this->assertText($user->name, t('Found name: %name', array('%name' => $user->name)), t('User login'));
+ $this->_logged_in = $pass;
+
+ $this->drupalGet('session-test/id');
+ $matches = array();
+ preg_match('/\s*session_id:(.*)\n/', $this->drupalGetContent(), $matches);
+ $this->assertTrue(!empty($matches[1]) , t('Found session ID after logging in.'));
+ $this->assertTrue($matches[1] != $original_session, t('Session ID changed after login.'));
+ }
+
+ /**
+ * Test data persistence via the session_test module callbacks. Also tests
+ * drupal_session_count() since session data is already generated here.
+ */
+ function testDataPersistence() {
+ $user = $this->drupalCreateUser(array('access content'));
+ // Enable sessions.
+ $this->sessionReset($user->uid);
+
+ $this->drupalLogin($user);
+
+ $value_1 = $this->randomName();
+ $this->drupalGet('session-test/set/' . $value_1);
+ $this->assertText($value_1, t('The session value was stored.'), t('Session'));
+ $this->drupalGet('session-test/get');
+ $this->assertText($value_1, t('Session correctly returned the stored data for an authenticated user.'), t('Session'));
+
+ // Attempt to write over val_1. If drupal_save_session(FALSE) is working.
+ // properly, val_1 will still be set.
+ $value_2 = $this->randomName();
+ $this->drupalGet('session-test/no-set/' . $value_2);
+ $this->assertText($value_2, t('The session value was correctly passed to session-test/no-set.'), t('Session'));
+ $this->drupalGet('session-test/get');
+ $this->assertText($value_1, t('Session data is not saved for drupal_save_session(FALSE).'), t('Session'));
+
+ // Switch browser cookie to anonymous user, then back to user 1.
+ $this->sessionReset();
+ $this->sessionReset($user->uid);
+ $this->assertText($value_1, t('Session data persists through browser close.'), t('Session'));
+
+ // Logout the user and make sure the stored value no longer persists.
+ $this->drupalLogout();
+ $this->sessionReset();
+ $this->drupalGet('session-test/get');
+ $this->assertNoText($value_1, t("After logout, previous user's session data is not available."), t('Session'));
+
+ // Now try to store some data as an anonymous user.
+ $value_3 = $this->randomName();
+ $this->drupalGet('session-test/set/' . $value_3);
+ $this->assertText($value_3, t('Session data stored for anonymous user.'), t('Session'));
+ $this->drupalGet('session-test/get');
+ $this->assertText($value_3, t('Session correctly returned the stored data for an anonymous user.'), t('Session'));
+
+ // Try to store data when drupal_save_session(FALSE).
+ $value_4 = $this->randomName();
+ $this->drupalGet('session-test/no-set/' . $value_4);
+ $this->assertText($value_4, t('The session value was correctly passed to session-test/no-set.'), t('Session'));
+ $this->drupalGet('session-test/get');
+ $this->assertText($value_3, t('Session data is not saved for drupal_save_session(FALSE).'), t('Session'));
+
+ // Login, the data should persist.
+ $this->drupalLogin($user);
+ $this->sessionReset($user->uid);
+ $this->drupalGet('session-test/get');
+ $this->assertNoText($value_1, t('Session has persisted for an authenticated user after logging out and then back in.'), t('Session'));
+
+ // Change session and create another user.
+ $user2 = $this->drupalCreateUser(array('access content'));
+ $this->sessionReset($user2->uid);
+ $this->drupalLogin($user2);
+ }
+
+ /**
+ * Test that empty anonymous sessions are destroyed.
+ */
+ function testEmptyAnonymousSession() {
+ // Verify that no session is automatically created for anonymous user.
+ $this->drupalGet('');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(TRUE);
+
+ // The same behavior is expected when caching is enabled.
+ variable_set('cache', 1);
+ $this->drupalGet('');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(TRUE);
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', t('Page was not cached.'));
+
+ // Start a new session by setting a message.
+ $this->drupalGet('session-test/set-message');
+ $this->assertSessionCookie(TRUE);
+ $this->assertTrue($this->drupalGetHeader('Set-Cookie'), t('New session was started.'));
+
+ // Display the message, during the same request the session is destroyed
+ // and the session cookie is unset.
+ $this->drupalGet('');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(FALSE);
+ $this->assertFalse($this->drupalGetHeader('X-Drupal-Cache'), t('Caching was bypassed.'));
+ $this->assertText(t('This is a dummy message.'), t('Message was displayed.'));
+ $this->assertTrue(preg_match('/SESS\w+=deleted/', $this->drupalGetHeader('Set-Cookie')), t('Session cookie was deleted.'));
+
+ // Verify that session was destroyed.
+ $this->drupalGet('');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(TRUE);
+ $this->assertNoText(t('This is a dummy message.'), t('Message was not cached.'));
+ $this->assertEqual($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Page was cached.'));
+ $this->assertFalse($this->drupalGetHeader('Set-Cookie'), t('New session was not started.'));
+
+ // Verify that no session is created if drupal_save_session(FALSE) is called.
+ $this->drupalGet('session-test/set-message-but-dont-save');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(TRUE);
+
+ // Verify that no message is displayed.
+ $this->drupalGet('');
+ $this->assertSessionCookie(FALSE);
+ $this->assertSessionEmpty(TRUE);
+ $this->assertNoText(t('This is a dummy message.'), t('The message was not saved.'));
+ }
+
+ /**
+ * Test that sessions are only saved when necessary.
+ */
+ function testSessionWrite() {
+ $user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($user);
+
+ $sql = 'SELECT u.access, s.timestamp FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE u.uid = :uid';
+ $times1 = db_query($sql, array(':uid' => $user->uid))->fetchObject();
+
+ // Before every request we sleep one second to make sure that if the session
+ // is saved, its timestamp will change.
+
+ // Modify the session.
+ sleep(1);
+ $this->drupalGet('session-test/set/foo');
+ $times2 = db_query($sql, array(':uid' => $user->uid))->fetchObject();
+ $this->assertEqual($times2->access, $times1->access, t('Users table was not updated.'));
+ $this->assertNotEqual($times2->timestamp, $times1->timestamp, t('Sessions table was updated.'));
+
+ // Write the same value again, i.e. do not modify the session.
+ sleep(1);
+ $this->drupalGet('session-test/set/foo');
+ $times3 = db_query($sql, array(':uid' => $user->uid))->fetchObject();
+ $this->assertEqual($times3->access, $times1->access, t('Users table was not updated.'));
+ $this->assertEqual($times3->timestamp, $times2->timestamp, t('Sessions table was not updated.'));
+
+ // Do not change the session.
+ sleep(1);
+ $this->drupalGet('');
+ $times4 = db_query($sql, array(':uid' => $user->uid))->fetchObject();
+ $this->assertEqual($times4->access, $times3->access, t('Users table was not updated.'));
+ $this->assertEqual($times4->timestamp, $times3->timestamp, t('Sessions table was not updated.'));
+
+ // Force updating of users and sessions table once per second.
+ variable_set('session_write_interval', 0);
+ $this->drupalGet('');
+ $times5 = db_query($sql, array(':uid' => $user->uid))->fetchObject();
+ $this->assertNotEqual($times5->access, $times4->access, t('Users table was updated.'));
+ $this->assertNotEqual($times5->timestamp, $times4->timestamp, t('Sessions table was updated.'));
+ }
+
+ /**
+ * Test that empty session IDs are not allowed.
+ */
+ function testEmptySessionID() {
+ $user = $this->drupalCreateUser(array('access content'));
+ $this->drupalLogin($user);
+ $this->drupalGet('session-test/is-logged-in');
+ $this->assertResponse(200, t('User is logged in.'));
+
+ // Reset the sid in {sessions} to a blank string. This may exist in the
+ // wild in some cases, although we normally prevent it from happening.
+ db_query("UPDATE {sessions} SET sid = '' WHERE uid = :uid", array(':uid' => $user->uid));
+ // Send a blank sid in the session cookie, and the session should no longer
+ // be valid. Closing the curl handler will stop the previous session ID
+ // from persisting.
+ $this->curlClose();
+ $this->additionalCurlOptions[CURLOPT_COOKIE] = rawurlencode($this->session_name) . '=;';
+ $this->drupalGet('session-test/id-from-cookie');
+ $this->assertRaw("session_id:\n", t('Session ID is blank as sent from cookie header.'));
+ // Assert that we have an anonymous session now.
+ $this->drupalGet('session-test/is-logged-in');
+ $this->assertResponse(403, t('An empty session ID is not allowed.'));
+ }
+
+ /**
+ * Reset the cookie file so that it refers to the specified user.
+ *
+ * @param $uid User id to set as the active session.
+ */
+ function sessionReset($uid = 0) {
+ // Close the internal browser.
+ $this->curlClose();
+ $this->loggedInUser = FALSE;
+
+ // Change cookie file for user.
+ $this->cookieFile = file_stream_wrapper_get_instance_by_scheme('temporary')->getDirectoryPath() . '/cookie.' . $uid . '.txt';
+ $this->additionalCurlOptions[CURLOPT_COOKIEFILE] = $this->cookieFile;
+ $this->additionalCurlOptions[CURLOPT_COOKIESESSION] = TRUE;
+ $this->drupalGet('session-test/get');
+ $this->assertResponse(200, t('Session test module is correctly enabled.'), t('Session'));
+ }
+
+ /**
+ * Assert whether the SimpleTest browser sent a session cookie.
+ */
+ function assertSessionCookie($sent) {
+ if ($sent) {
+ $this->assertNotNull($this->session_id, t('Session cookie was sent.'));
+ }
+ else {
+ $this->assertNull($this->session_id, t('Session cookie was not sent.'));
+ }
+ }
+
+ /**
+ * Assert whether $_SESSION is empty at the beginning of the request.
+ */
+ function assertSessionEmpty($empty) {
+ if ($empty) {
+ $this->assertIdentical($this->drupalGetHeader('X-Session-Empty'), '1', t('Session was empty.'));
+ }
+ else {
+ $this->assertIdentical($this->drupalGetHeader('X-Session-Empty'), '0', t('Session was not empty.'));
+ }
+ }
+}
+
+/**
+ * Ensure that when running under https two session cookies are generated.
+ */
+class SessionHttpsTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Session https handling',
+ 'description' => 'Ensure that when running under https two session cookies are generated.',
+ 'group' => 'Session'
+ );
+ }
+
+ public function setUp() {
+ parent::setUp('session_test');
+ }
+
+ protected function testHttpsSession() {
+ global $is_https;
+
+ if ($is_https) {
+ $secure_session_name = session_name();
+ $insecure_session_name = substr(session_name(), 1);
+ }
+ else {
+ $secure_session_name = 'S' . session_name();
+ $insecure_session_name = session_name();
+ }
+
+ $user = $this->drupalCreateUser(array('access administration pages'));
+
+ // Test HTTPS session handling by altering the form action to submit the
+ // login form through https.php, which creates a mock HTTPS request.
+ $this->drupalGet('user');
+ $form = $this->xpath('//form[@id="user-login"]');
+ $form[0]['action'] = $this->httpsUrl('user');
+ $edit = array('name' => $user->name, 'pass' => $user->pass_raw);
+ $this->drupalPost(NULL, $edit, t('Log in'));
+
+ // Test a second concurrent session.
+ $this->curlClose();
+ $this->drupalGet('user');
+ $form = $this->xpath('//form[@id="user-login"]');
+ $form[0]['action'] = $this->httpsUrl('user');
+ $this->drupalPost(NULL, $edit, t('Log in'));
+
+ // Check secure cookie on secure page.
+ $this->assertTrue($this->cookies[$secure_session_name]['secure'], 'The secure cookie has the secure attribute');
+ // Check insecure cookie is not set.
+ $this->assertFalse(isset($this->cookies[$insecure_session_name]));
+ $ssid = $this->cookies[$secure_session_name]['value'];
+ $this->assertSessionIds($ssid, $ssid, 'Session has a non-empty SID and a correct secure SID.');
+ $cookie = $secure_session_name . '=' . $ssid;
+
+ // Verify that user is logged in on secure URL.
+ $this->curlClose();
+ $this->drupalGet($this->httpsUrl('admin/config'), array(), array('Cookie: ' . $cookie));
+ $this->assertText(t('Configuration'));
+ $this->assertResponse(200);
+
+ // Verify that user is not logged in on non-secure URL.
+ $this->curlClose();
+ $this->drupalGet($this->httpUrl('admin/config'), array(), array('Cookie: ' . $cookie));
+ $this->assertNoText(t('Configuration'));
+ $this->assertResponse(403);
+
+ // Verify that empty SID cannot be used on the non-secure site.
+ $this->curlClose();
+ $cookie = $insecure_session_name . '=';
+ $this->drupalGet($this->httpUrl('admin/config'), array(), array('Cookie: ' . $cookie));
+ $this->assertResponse(403);
+
+ // Test HTTP session handling by altering the form action to submit the
+ // login form through http.php, which creates a mock HTTP request on HTTPS
+ // test environments.
+ $this->curlClose();
+ $this->drupalGet('user');
+ $form = $this->xpath('//form[@id="user-login"]');
+ $form[0]['action'] = $this->httpUrl('user');
+ $edit = array('name' => $user->name, 'pass' => $user->pass_raw);
+ $this->drupalPost(NULL, $edit, t('Log in'));
+ $this->drupalGet($this->httpUrl('admin/config'));
+ $this->assertResponse(200);
+ $sid = $this->cookies[$insecure_session_name]['value'];
+ $this->assertSessionIds($sid, '', 'Session has the correct SID and an empty secure SID.');
+
+ // Verify that empty secure SID cannot be used on the secure site.
+ $this->curlClose();
+ $cookie = $secure_session_name . '=';
+ $this->drupalGet($this->httpsUrl('admin/config'), array(), array('Cookie: ' . $cookie));
+ $this->assertResponse(403);
+
+ // Clear browser cookie jar.
+ $this->cookies = array();
+
+ if ($is_https) {
+ // The functionality does not make sense when running on https.
+ return;
+ }
+
+ // Enable secure pages.
+ variable_set('https', TRUE);
+
+ $this->curlClose();
+ // Start an anonymous session on the insecure site.
+ $session_data = $this->randomName();
+ $this->drupalGet('session-test/set/' . $session_data);
+ // Check secure cookie on insecure page.
+ $this->assertFalse(isset($this->cookies[$secure_session_name]), 'The secure cookie is not sent on insecure pages.');
+ // Check insecure cookie on insecure page.
+ $this->assertFalse($this->cookies[$insecure_session_name]['secure'], 'The insecure cookie does not have the secure attribute');
+
+ // Store the anonymous cookie so we can validate that its session is killed
+ // after login.
+ $anonymous_cookie = $insecure_session_name . '=' . $this->cookies[$insecure_session_name]['value'];
+
+ // Check that password request form action is not secure.
+ $this->drupalGet('user/password');
+ $form = $this->xpath('//form[@id="user-pass"]');
+ $this->assertNotEqual(substr($form[0]['action'], 0, 6), 'https:', 'Password request form action is not secure');
+ $form[0]['action'] = $this->httpsUrl('user');
+
+ // Check that user login form action is secure.
+ $this->drupalGet('user');
+ $form = $this->xpath('//form[@id="user-login"]');
+ $this->assertEqual(substr($form[0]['action'], 0, 6), 'https:', 'Login form action is secure');
+ $form[0]['action'] = $this->httpsUrl('user');
+
+ $edit = array(
+ 'name' => $user->name,
+ 'pass' => $user->pass_raw,
+ );
+ $this->drupalPost(NULL, $edit, t('Log in'));
+ // Check secure cookie on secure page.
+ $this->assertTrue($this->cookies[$secure_session_name]['secure'], 'The secure cookie has the secure attribute');
+ // Check insecure cookie on secure page.
+ $this->assertFalse($this->cookies[$insecure_session_name]['secure'], 'The insecure cookie does not have the secure attribute');
+
+ $sid = $this->cookies[$insecure_session_name]['value'];
+ $ssid = $this->cookies[$secure_session_name]['value'];
+ $this->assertSessionIds($sid, $ssid, 'Session has both secure and insecure SIDs');
+ $cookies = array(
+ $insecure_session_name . '=' . $sid,
+ $secure_session_name . '=' . $ssid,
+ );
+
+ // Test that session data saved before login is still available on the
+ // authenticated session.
+ $this->drupalGet('session-test/get');
+ $this->assertText($session_data, 'Session correctly returned the stored data set by the anonymous session.');
+
+ foreach ($cookies as $cookie_key => $cookie) {
+ foreach (array('admin/config', $this->httpsUrl('admin/config')) as $url_key => $url) {
+ $this->curlClose();
+
+ $this->drupalGet($url, array(), array('Cookie: ' . $cookie));
+ if ($cookie_key == $url_key) {
+ $this->assertText(t('Configuration'));
+ $this->assertResponse(200);
+ }
+ else {
+ $this->assertNoText(t('Configuration'));
+ $this->assertResponse(403);
+ }
+ }
+ }
+
+ // Test that session data saved before login is not available using the
+ // pre-login anonymous cookie.
+ $this->cookies = array();
+ $this->drupalGet('session-test/get', array('Cookie: ' . $anonymous_cookie));
+ $this->assertNoText($session_data, 'Initial anonymous session is inactive after login.');
+
+ // Clear browser cookie jar.
+ $this->cookies = array();
+
+ // Start an anonymous session on the secure site.
+ $this->drupalGet($this->httpsUrl('session-test/set/1'));
+
+ // Mock a login to the secure site using the secure session cookie.
+ $this->drupalGet('user');
+ $form = $this->xpath('//form[@id="user-login"]');
+ $form[0]['action'] = $this->httpsUrl('user');
+ $this->drupalPost(NULL, $edit, t('Log in'), array(), array('Cookie: ' . $secure_session_name . '=' . $this->cookies[$secure_session_name]['value']));
+
+ // Get the insecure session cookie set by the secure login POST request.
+ $headers = $this->drupalGetHeaders(TRUE);
+ strtok($headers[0]['set-cookie'], ';=');
+ $session_id = strtok(';=');
+
+ // Test that the user is also authenticated on the insecure site.
+ $this->drupalGet("user/{$user->uid}/edit", array(), array('Cookie: ' . $insecure_session_name . '=' . $session_id));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Test that there exists a session with two specific session IDs.
+ *
+ * @param $sid
+ * The insecure session ID to search for.
+ * @param $ssid
+ * The secure session ID to search for.
+ * @param $assertion_text
+ * The text to display when we perform the assertion.
+ *
+ * @return
+ * The result of assertTrue() that there's a session in the system that
+ * has the given insecure and secure session IDs.
+ */
+ protected function assertSessionIds($sid, $ssid, $assertion_text) {
+ $args = array(
+ ':sid' => $sid,
+ ':ssid' => $ssid,
+ );
+ return $this->assertTrue(db_query('SELECT timestamp FROM {sessions} WHERE sid = :sid AND ssid = :ssid', $args)->fetchField(), $assertion_text);
+ }
+
+ /**
+ * Builds a URL for submitting a mock HTTPS request to HTTP test environments.
+ *
+ * @param $url
+ * A Drupal path such as 'user'.
+ *
+ * @return
+ * An absolute URL.
+ */
+ protected function httpsUrl($url) {
+ global $base_url;
+ return $base_url . '/core/modules/simpletest/tests/https.php?q=' . $url;
+ }
+
+ /**
+ * Builds a URL for submitting a mock HTTP request to HTTPS test environments.
+ *
+ * @param $url
+ * A Drupal path such as 'user'.
+ *
+ * @return
+ * An absolute URL.
+ */
+ protected function httpUrl($url) {
+ global $base_url;
+ return $base_url . '/core/modules/simpletest/tests/http.php?q=' . $url;
+ }
+}
+
diff --git a/core/modules/simpletest/tests/session_test.info b/core/modules/simpletest/tests/session_test.info
new file mode 100644
index 000000000000..73de0a1a63cf
--- /dev/null
+++ b/core/modules/simpletest/tests/session_test.info
@@ -0,0 +1,6 @@
+name = "Session test"
+description = "Support module for session data testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/session_test.module b/core/modules/simpletest/tests/session_test.module
new file mode 100644
index 000000000000..689ff099ae03
--- /dev/null
+++ b/core/modules/simpletest/tests/session_test.module
@@ -0,0 +1,192 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function session_test_menu() {
+ $items['session-test/get'] = array(
+ 'title' => 'Session value',
+ 'page callback' => '_session_test_get',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/id'] = array(
+ 'title' => 'Session ID',
+ 'page callback' => '_session_test_id',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/id-from-cookie'] = array(
+ 'title' => 'Session ID from cookie',
+ 'page callback' => '_session_test_id_from_cookie',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/set/%'] = array(
+ 'title' => 'Set session value',
+ 'page callback' => '_session_test_set',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/no-set/%'] = array(
+ 'title' => 'Set session value but do not save session',
+ 'page callback' => '_session_test_no_set',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/set-message'] = array(
+ 'title' => 'Set message',
+ 'page callback' => '_session_test_set_message',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/set-message-but-dont-save'] = array(
+ 'title' => 'Set message but do not save session',
+ 'page callback' => '_session_test_set_message_but_dont_save',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/set-not-started'] = array(
+ 'title' => 'Set message when session is not started',
+ 'page callback' => '_session_test_set_not_started',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['session-test/is-logged-in'] = array(
+ 'title' => 'Check if user is logged in',
+ 'page callback' => '_session_test_is_logged_in',
+ 'access callback' => 'user_is_logged_in',
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_boot().
+ */
+function session_test_boot() {
+ header('X-Session-Empty: ' . intval(empty($_SESSION)));
+}
+
+/**
+ * Page callback, prints the stored session value to the screen.
+ */
+function _session_test_get() {
+ if (!empty($_SESSION['session_test_value'])) {
+ return t('The current value of the stored session variable is: %val', array('%val' => $_SESSION['session_test_value']));
+ }
+ else {
+ return "";
+ }
+}
+
+/**
+ * Page callback, stores a value in $_SESSION['session_test_value'].
+ */
+function _session_test_set($value) {
+ $_SESSION['session_test_value'] = $value;
+ return t('The current value of the stored session variable has been set to %val', array('%val' => $value));
+}
+
+/**
+ * Menu callback: turns off session saving and then tries to save a value
+ * anyway.
+ */
+function _session_test_no_set($value) {
+ drupal_save_session(FALSE);
+ _session_test_set($value);
+ return t('session saving was disabled, and then %val was set', array('%val' => $value));
+}
+
+/**
+ * Menu callback: print the current session ID.
+ */
+function _session_test_id() {
+ // Set a value in $_SESSION, so that drupal_session_commit() will start
+ // a session.
+ $_SESSION['test'] = 'test';
+
+ drupal_session_commit();
+
+ return 'session_id:' . session_id() . "\n";
+}
+
+/**
+ * Menu callback: print the current session ID as read from the cookie.
+ */
+function _session_test_id_from_cookie() {
+ return 'session_id:' . $_COOKIE[session_name()] . "\n";
+}
+
+/**
+ * Menu callback, sets a message to me displayed on the following page.
+ */
+function _session_test_set_message() {
+ drupal_set_message(t('This is a dummy message.'));
+ print t('A message was set.');
+ // Do not return anything, so the current request does not result in a themed
+ // page with messages. The message will be displayed in the following request
+ // instead.
+}
+
+/**
+ * Menu callback, sets a message but call drupal_save_session(FALSE).
+ */
+function _session_test_set_message_but_dont_save() {
+ drupal_save_session(FALSE);
+ _session_test_set_message();
+}
+
+/**
+ * Menu callback, stores a value in $_SESSION['session_test_value'] without
+ * having started the session in advance.
+ */
+function _session_test_set_not_started() {
+ if (!drupal_session_will_start()) {
+ $_SESSION['session_test_value'] = t('Session was not started');
+ }
+}
+
+/**
+ * Implements hook_user().
+ */
+function session_test_user_login($edit = array(), $user = NULL) {
+ if ($user->name == 'session_test_user') {
+ // Exit so we can verify that the session was regenerated
+ // before hook_user() was called.
+ exit;
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function session_test_form_user_login_alter(&$form) {
+ $form['#https'] = TRUE;
+}
+
+/**
+ * Implements hook_drupal_goto_alter().
+ *
+ * Force the redirection to go to a non-secure page after being on a secure
+ * page through https.php.
+ */
+function session_test_drupal_goto_alter(&$path, &$options, &$http_response_code) {
+ global $base_insecure_url, $is_https_mock;
+ // Alter the redirect to use HTTP when using a mock HTTPS request through
+ // https.php because form submissions would otherwise redirect to a
+ // non-existent HTTPS site.
+ if (!empty($is_https_mock)) {
+ $path = $base_insecure_url . '/' . $path;
+ }
+}
+
+/**
+ * Menu callback, only available if current user is logged in.
+ */
+function _session_test_is_logged_in() {
+ return t('User is logged in.');
+}
diff --git a/core/modules/simpletest/tests/symfony.test b/core/modules/simpletest/tests/symfony.test
new file mode 100644
index 000000000000..8a7ecb967a69
--- /dev/null
+++ b/core/modules/simpletest/tests/symfony.test
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Tests for Symfony2-related functionality.
+ */
+
+/**
+ * Tests related to Symfony class loading.
+ */
+class SymfonyClassLoaderTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Class loader',
+ 'description' => 'Confirm that the PSR-0 class loader is connected properly',
+ 'group' => 'Symfony',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ }
+
+ /**
+ * Test that we can lazy-load classes from the Symfony framework.
+ */
+ function testClassesLoad() {
+ $class_name = 'Symfony\\Component\\HttpFoundation\\Request';
+ $this->assertTrue(class_exists($class_name), t('Class !class_name exists', array('!class_name' => $class_name)));
+ }
+}
diff --git a/core/modules/simpletest/tests/system.base.css b/core/modules/simpletest/tests/system.base.css
new file mode 100644
index 000000000000..c14ae9b27ba1
--- /dev/null
+++ b/core/modules/simpletest/tests/system.base.css
@@ -0,0 +1,6 @@
+
+/**
+ * This file is for testing CSS file override in
+ * CascadingStylesheetsTestCase::testRenderOverride().
+ * No contents are necessary.
+ */
diff --git a/core/modules/simpletest/tests/system_dependencies_test.info b/core/modules/simpletest/tests/system_dependencies_test.info
new file mode 100644
index 000000000000..c90706d4d7fa
--- /dev/null
+++ b/core/modules/simpletest/tests/system_dependencies_test.info
@@ -0,0 +1,7 @@
+name = "System dependency test"
+description = "Support module for testing system dependencies."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
+dependencies[] = _missing_dependency
diff --git a/core/modules/simpletest/tests/system_dependencies_test.module b/core/modules/simpletest/tests/system_dependencies_test.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/system_dependencies_test.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/system_test.info b/core/modules/simpletest/tests/system_test.info
new file mode 100644
index 000000000000..3b9aebc929c1
--- /dev/null
+++ b/core/modules/simpletest/tests/system_test.info
@@ -0,0 +1,7 @@
+name = System test
+description = Support module for system testing.
+package = Testing
+version = VERSION
+core = 8.x
+files[] = system_test.module
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/system_test.module b/core/modules/simpletest/tests/system_test.module
new file mode 100644
index 000000000000..3bdf8d75995b
--- /dev/null
+++ b/core/modules/simpletest/tests/system_test.module
@@ -0,0 +1,399 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function system_test_menu() {
+ $items['system-test/sleep/%'] = array(
+ 'page callback' => 'system_test_sleep',
+ 'page arguments' => array(2),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/auth'] = array(
+ 'page callback' => 'system_test_basic_auth_page',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/authorize-init/%'] = array(
+ 'page callback' => 'system_test_authorize_init_page',
+ 'page arguments' => array(2),
+ 'access arguments' => array('administer software updates'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/redirect/%'] = array(
+ 'title' => 'Redirect',
+ 'page callback' => 'system_test_redirect',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/multiple-redirects/%'] = array(
+ 'title' => 'Redirect',
+ 'page callback' => 'system_test_multiple_redirects',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/set-header'] = array(
+ 'page callback' => 'system_test_set_header',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/redirect-noscheme'] = array(
+ 'page callback' => 'system_test_redirect_noscheme',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/redirect-noparse'] = array(
+ 'page callback' => 'system_test_redirect_noparse',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system-test/redirect-invalid-scheme'] = array(
+ 'page callback' => 'system_test_redirect_invalid_scheme',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/variable-get'] = array(
+ 'title' => 'Variable Get',
+ 'page callback' => 'variable_get',
+ 'page arguments' => array('simpletest_bootstrap_variable_test', NULL),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/lock-acquire'] = array(
+ 'title' => 'Lock acquire',
+ 'page callback' => 'system_test_lock_acquire',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/lock-exit'] = array(
+ 'title' => 'Lock acquire then exit',
+ 'page callback' => 'system_test_lock_exit',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/main-content-handling'] = array(
+ 'title' => 'Test main content handling',
+ 'page callback' => 'system_test_main_content_fallback',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/main-content-fallback'] = array(
+ 'title' => 'Test main content fallback',
+ 'page callback' => 'system_test_main_content_fallback',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/main-content-duplication'] = array(
+ 'title' => 'Test main content duplication',
+ 'page callback' => 'system_test_main_content_fallback',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ $items['system-test/shutdown-functions'] = array(
+ 'title' => 'Test main content duplication',
+ 'page callback' => 'system_test_page_shutdown_functions',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+function system_test_sleep($seconds) {
+ sleep($seconds);
+}
+
+function system_test_basic_auth_page() {
+ $output = t('$_SERVER[\'PHP_AUTH_USER\'] is @username.', array('@username' => $_SERVER['PHP_AUTH_USER']));
+ $output .= t('$_SERVER[\'PHP_AUTH_PW\'] is @password.', array('@password' => $_SERVER['PHP_AUTH_PW']));
+ return $output;
+}
+
+function system_test_redirect($code) {
+ $code = (int) $code;
+ if ($code != 200) {
+ // Header names are case-insensitive.
+ header("locaTION: " . url('system-test/redirect/200', array('absolute' => TRUE)), TRUE, $code);
+ exit;
+ }
+ return '';
+}
+
+/**
+ * Menu callback; sends a redirect header to itself until $count argument is 0.
+ *
+ * Emulates the variable number of redirects (given by initial $count argument)
+ * to the final destination URL by continuous sending of 301 HTTP redirect
+ * headers to itself together with decrementing the $count parameter until the
+ * $count parameter reaches 0. After that it returns an empty string to render
+ * the final destination page.
+ *
+ * @param $count
+ * The count of redirects left until the final destination page.
+ *
+ * @returns
+ * The location redirect if the $count > 0, otherwise an empty string.
+ */
+function system_test_multiple_redirects($count) {
+ $count = (int) $count;
+ if ($count > 0) {
+ header("location: " . url('system-test/multiple-redirects/' . --$count, array('absolute' => TRUE)), TRUE, 301);
+ exit;
+ }
+ return '';
+}
+
+function system_test_set_header() {
+ drupal_add_http_header($_GET['name'], $_GET['value']);
+ return t('The following header was set: %name: %value', array('%name' => $_GET['name'], '%value' => $_GET['value']));
+}
+
+function system_test_redirect_noscheme() {
+ header("Location: localhost/path", TRUE, 301);
+ exit;
+}
+
+function system_test_redirect_noparse() {
+ header("Location: http:///path", TRUE, 301);
+ exit;
+}
+
+function system_test_redirect_invalid_scheme() {
+ header("Location: ftp://localhost/path", TRUE, 301);
+ exit;
+}
+
+/**
+ * Implements hook_modules_installed().
+ */
+function system_test_modules_installed($modules) {
+ if (variable_get('test_verbose_module_hooks')) {
+ foreach ($modules as $module) {
+ drupal_set_message(t('hook_modules_installed fired for @module', array('@module' => $module)));
+ }
+ }
+}
+
+/**
+ * Implements hook_modules_enabled().
+ */
+function system_test_modules_enabled($modules) {
+ if (variable_get('test_verbose_module_hooks')) {
+ foreach ($modules as $module) {
+ drupal_set_message(t('hook_modules_enabled fired for @module', array('@module' => $module)));
+ }
+ }
+}
+
+/**
+ * Implements hook_modules_disabled().
+ */
+function system_test_modules_disabled($modules) {
+ if (variable_get('test_verbose_module_hooks')) {
+ foreach ($modules as $module) {
+ drupal_set_message(t('hook_modules_disabled fired for @module', array('@module' => $module)));
+ }
+ }
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function system_test_modules_uninstalled($modules) {
+ if (variable_get('test_verbose_module_hooks')) {
+ foreach ($modules as $module) {
+ drupal_set_message(t('hook_modules_uninstalled fired for @module', array('@module' => $module)));
+ }
+ }
+}
+
+/**
+ * Implements hook_boot().
+ */
+function system_test_boot() {
+ watchdog('system_test', 'hook_boot');
+}
+
+/**
+ * Implements hook_init().
+ */
+function system_test_init() {
+ // Used by FrontPageTestCase to get the results of drupal_is_front_page().
+ if (variable_get('front_page_output', 0) && drupal_is_front_page()) {
+ drupal_set_message(t('On front page.'));
+ }
+}
+
+/**
+ * Implements hook_exit().
+ */
+function system_test_exit() {
+ watchdog('system_test', 'hook_exit');
+}
+
+/**
+ * Implements hook_system_info_alter().
+ */
+function system_test_system_info_alter(&$info, $file, $type) {
+ // We need a static otherwise the last test will fail to alter common_test.
+ static $test;
+ if (($dependencies = variable_get('dependencies', array())) || $test) {
+ if ($file->name == 'module_test') {
+ $info['hidden'] = FALSE;
+ $info['dependencies'][] = array_shift($dependencies);
+ variable_set('dependencies', $dependencies);
+ $test = TRUE;
+ }
+ if ($file->name == 'common_test') {
+ $info['hidden'] = FALSE;
+ $info['version'] = '8.x-2.4-beta3';
+ }
+ }
+
+ // Make the system_dependencies_test visible by default.
+ if ($file->name == 'system_dependencies_test') {
+ $info['hidden'] = FALSE;
+ }
+ if ($file->name == 'requirements1_test' || $file->name == 'requirements2_test') {
+ $info['hidden'] = FALSE;
+ }
+}
+
+/**
+ * Try to acquire a named lock and report the outcome.
+ */
+function system_test_lock_acquire() {
+ if (lock_acquire('system_test_lock_acquire')) {
+ lock_release('system_test_lock_acquire');
+ return 'TRUE: Lock successfully acquired in system_test_lock_acquire()';
+ }
+ else {
+ return 'FALSE: Lock not acquired in system_test_lock_acquire()';
+ }
+}
+
+/**
+ * Try to acquire a specific lock, and then exit.
+ */
+function system_test_lock_exit() {
+ if (lock_acquire('system_test_lock_exit', 900)) {
+ echo 'TRUE: Lock successfully acquired in system_test_lock_exit()';
+ // The shut-down function should release the lock.
+ exit();
+ }
+ else {
+ return 'FALSE: Lock not acquired in system_test_lock_exit()';
+ }
+}
+
+/**
+ * Implements hook_page_build().
+ */
+function system_test_page_build(&$page) {
+ $menu_item = menu_get_item();
+ $main_content_display = &drupal_static('system_main_content_added', FALSE);
+
+ if ($menu_item['path'] == 'system-test/main-content-handling') {
+ $page['footer'] = drupal_set_page_content();
+ $page['footer']['main']['#markup'] = '<div id="system-test-content">' . $page['footer']['main']['#markup'] . '</div>';
+ }
+ elseif ($menu_item['path'] == 'system-test/main-content-fallback') {
+ drupal_set_page_content();
+ $main_content_display = FALSE;
+ }
+ elseif ($menu_item['path'] == 'system-test/main-content-duplication') {
+ drupal_set_page_content();
+ }
+}
+
+/**
+ * Menu callback to test main content fallback().
+ */
+function system_test_main_content_fallback() {
+ return t('Content to test main content fallback');
+}
+
+/**
+ * A simple page callback which adds a register shutdown function.
+ */
+function system_test_page_shutdown_functions($arg1, $arg2) {
+ drupal_register_shutdown_function('_system_test_first_shutdown_function', $arg1, $arg2);
+}
+
+/**
+ * Dummy shutdown function which registers another shutdown function.
+ */
+function _system_test_first_shutdown_function($arg1, $arg2) {
+ // Output something, page has already been printed and the session stored
+ // so we can't use drupal_set_message.
+ print t('First shutdown function, arg1 : @arg1, arg2: @arg2', array('@arg1' => $arg1, '@arg2' => $arg2));
+ drupal_register_shutdown_function('_system_test_second_shutdown_function', $arg1, $arg2);
+}
+
+/**
+ * Dummy shutdown function.
+ */
+function _system_test_second_shutdown_function($arg1, $arg2) {
+ // Output something, page has already been printed and the session stored
+ // so we can't use drupal_set_message.
+ print t('Second shutdown function, arg1 : @arg1, arg2: @arg2', array('@arg1' => $arg1, '@arg2' => $arg2));
+
+ // Throw an exception with an HTML tag. Since this is called in a shutdown
+ // function, it will not bubble up to the default exception handler but will
+ // be caught in _drupal_shutdown_function() and be displayed through
+ // _drupal_render_exception_safe().
+ throw new Exception('Drupal is <blink>awesome</blink>.');
+}
+
+/**
+ * Implements hook_filetransfer_info().
+ */
+function system_test_filetransfer_info() {
+ return array(
+ 'system_test' => array(
+ 'title' => t('System Test FileTransfer'),
+ 'file' => 'system_test.module', // Should be a .inc, but for test, ok.
+ 'class' => 'SystemTestFileTransfer',
+ 'weight' => -10,
+ ),
+ );
+}
+
+/**
+ * Mock FileTransfer object to test the settings form functionality.
+ */
+class SystemTestFileTransfer {
+ public static function factory() {
+ return new SystemTestFileTransfer;
+ }
+
+ public function getSettingsForm() {
+ $form = array();
+ $form['system_test_username'] = array(
+ '#type' => 'textfield',
+ '#title' => t('System Test Username'),
+ );
+ return $form;
+ }
+}
+
+/**
+ * Page callback to initialize authorize.php during testing.
+ *
+ * @see system_authorized_init().
+ */
+function system_test_authorize_init_page($page_title) {
+ $authorize_url = $GLOBALS['base_url'] . '/core/authorize.php';
+ system_authorized_init('system_test_authorize_run', drupal_get_path('module', 'system_test') . '/system_test.module', array(), $page_title);
+ drupal_goto($authorize_url);
+}
diff --git a/core/modules/simpletest/tests/tablesort.test b/core/modules/simpletest/tests/tablesort.test
new file mode 100644
index 000000000000..9c068f861459
--- /dev/null
+++ b/core/modules/simpletest/tests/tablesort.test
@@ -0,0 +1,166 @@
+<?php
+
+/**
+ * @file
+ * Various tablesort tests.
+ */
+
+/**
+ * Test unicode handling features implemented in unicode.inc.
+ */
+class TableSortTest extends DrupalUnitTestCase {
+
+ /**
+ * Storage for initial value of $_GET.
+ *
+ * @var array
+ */
+ protected $GET = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Tablesort',
+ 'description' => 'Tests table sorting.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ // Save the original $_GET to be restored later.
+ $this->GET = $_GET;
+
+ parent::setUp();
+ }
+
+ function tearDown() {
+ // Revert $_GET.
+ $_GET = $this->GET;
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test tablesort_init().
+ */
+ function testTableSortInit() {
+
+ // Test simple table headers.
+
+ $headers = array('foo', 'bar', 'baz');
+ // Reset $_GET to prevent parameters from Simpletest and Batch API ending
+ // up in $ts['query'].
+ $_GET = array('q' => 'jahwohl');
+ $expected_ts = array(
+ 'name' => 'foo',
+ 'sql' => '',
+ 'sort' => 'asc',
+ 'query' => array(),
+ );
+ $ts = tablesort_init($headers);
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Simple table headers sorted correctly.'));
+
+ // Test with simple table headers plus $_GET parameters that should _not_
+ // override the default.
+
+ $_GET = array(
+ 'q' => 'jahwohl',
+ // This should not override the table order because only complex
+ // headers are overridable.
+ 'order' => 'bar',
+ );
+ $ts = tablesort_init($headers);
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Simple table headers plus non-overriding $_GET parameters sorted correctly.'));
+
+ // Test with simple table headers plus $_GET parameters that _should_
+ // override the default.
+
+ $_GET = array(
+ 'q' => 'jahwohl',
+ 'sort' => 'DESC',
+ // Add an unrelated parameter to ensure that tablesort will include
+ // it in the links that it creates.
+ 'alpha' => 'beta',
+ );
+ $expected_ts['sort'] = 'desc';
+ $expected_ts['query'] = array('alpha' => 'beta');
+ $ts = tablesort_init($headers);
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Simple table headers plus $_GET parameters sorted correctly.'));
+
+ // Test complex table headers.
+
+ $headers = array(
+ 'foo',
+ array(
+ 'data' => '1',
+ 'field' => 'one',
+ 'sort' => 'asc',
+ 'colspan' => 1,
+ ),
+ array(
+ 'data' => '2',
+ 'field' => 'two',
+ 'sort' => 'desc',
+ ),
+ );
+ // Reset $_GET from previous assertion.
+ $_GET = array(
+ 'q' => 'jahwohl',
+ 'order' => '2',
+ );
+ $ts = tablesort_init($headers);
+ $expected_ts = array(
+ 'name' => '2',
+ 'sql' => 'two',
+ 'sort' => 'desc',
+ 'query' => array(),
+ );
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Complex table headers sorted correctly.'));
+
+ // Test complex table headers plus $_GET parameters that should _not_
+ // override the default.
+
+ $_GET = array(
+ 'q' => 'jahwohl',
+ // This should not override the table order because this header does not
+ // exist.
+ 'order' => 'bar',
+ );
+ $ts = tablesort_init($headers);
+ $expected_ts = array(
+ 'name' => '1',
+ 'sql' => 'one',
+ 'sort' => 'asc',
+ 'query' => array(),
+ );
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Complex table headers plus non-overriding $_GET parameters sorted correctly.'));
+ unset($_GET['sort'], $_GET['order'], $_GET['alpha']);
+
+ // Test complex table headers plus $_GET parameters that _should_
+ // override the default.
+
+ $_GET = array(
+ 'q' => 'jahwohl',
+ 'order' => '1',
+ 'sort' => 'ASC',
+ // Add an unrelated parameter to ensure that tablesort will include
+ // it in the links that it creates.
+ 'alpha' => 'beta',
+ );
+ $expected_ts = array(
+ 'name' => '1',
+ 'sql' => 'one',
+ 'sort' => 'asc',
+ 'query' => array('alpha' => 'beta'),
+ );
+ $ts = tablesort_init($headers);
+ $this->verbose(strtr('$ts: <pre>!ts</pre>', array('!ts' => check_plain(var_export($ts, TRUE)))));
+ $this->assertEqual($ts, $expected_ts, t('Complex table headers plus $_GET parameters sorted correctly.'));
+ unset($_GET['sort'], $_GET['order'], $_GET['alpha']);
+
+ }
+}
diff --git a/core/modules/simpletest/tests/taxonomy_test.info b/core/modules/simpletest/tests/taxonomy_test.info
new file mode 100644
index 000000000000..b4489d6cecc0
--- /dev/null
+++ b/core/modules/simpletest/tests/taxonomy_test.info
@@ -0,0 +1,7 @@
+name = "Taxonomy test module"
+description = "Tests functions and hooks not used in core".
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
+dependencies[] = taxonomy
diff --git a/core/modules/simpletest/tests/taxonomy_test.install b/core/modules/simpletest/tests/taxonomy_test.install
new file mode 100644
index 000000000000..d5c94da5f7b2
--- /dev/null
+++ b/core/modules/simpletest/tests/taxonomy_test.install
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the taxonomy_test module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function taxonomy_test_schema() {
+ $schema['taxonomy_term_antonym'] = array(
+ 'description' => 'Stores term antonym.',
+ 'fields' => array(
+ 'tid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {taxonomy_term_data}.tid of the term.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The name of the antonym.',
+ ),
+ ),
+ 'primary key' => array('tid'),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/simpletest/tests/taxonomy_test.module b/core/modules/simpletest/tests/taxonomy_test.module
new file mode 100644
index 000000000000..aae13a2d448b
--- /dev/null
+++ b/core/modules/simpletest/tests/taxonomy_test.module
@@ -0,0 +1,81 @@
+<?php
+
+/**
+ * @file
+ * Test module for Taxonomy hooks and functions not used in core.
+ */
+
+/**
+ * Implements hook_taxonomy_term_load().
+ */
+function taxonomy_test_taxonomy_term_load($terms) {
+ foreach ($terms as $term) {
+ $antonym = taxonomy_test_get_antonym($term->tid);
+ if ($antonym) {
+ $term->antonym = $antonym;
+ }
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_insert().
+ */
+function taxonomy_test_taxonomy_term_insert($term) {
+ if (!empty($term->antonym)) {
+ db_insert('taxonomy_term_antonym')
+ ->fields(array(
+ 'tid' => $term->tid,
+ 'name' => trim($term->antonym)
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_update().
+ */
+function taxonomy_test_taxonomy_term_update($term) {
+ if (!empty($term->antonym)) {
+ db_merge('taxonomy_term_antonym')
+ ->key(array('tid' => $term->tid))
+ ->fields(array(
+ 'name' => trim($term->antonym)
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function taxonomy_test_taxonomy_term_delete($term) {
+ db_delete('taxonomy_term_antonym')
+ ->condition('tid', $term->tid)
+ ->execute();
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function taxonomy_test_form_alter(&$form, $form_state, $form_id) {
+ if ($form_id == 'taxonomy_form_term') {
+ $antonym = taxonomy_test_get_antonym($form['#term']['tid']);
+ $form['advanced']['antonym'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Antonym'),
+ '#default_value' => !empty($antonym) ? $antonym : '',
+ '#description' => t('Antonym of this term.')
+ );
+ }
+}
+
+/**
+ * Return the antonym of the given term ID.
+ */
+function taxonomy_test_get_antonym($tid) {
+ return db_select('taxonomy_term_antonym', 'ta')
+ ->fields('ta', array('name'))
+ ->condition('tid', $tid)
+ ->execute()
+ ->fetchField();
+}
diff --git a/core/modules/simpletest/tests/theme.test b/core/modules/simpletest/tests/theme.test
new file mode 100644
index 000000000000..7968cf7216f2
--- /dev/null
+++ b/core/modules/simpletest/tests/theme.test
@@ -0,0 +1,490 @@
+<?php
+
+/**
+ * @file
+ * Tests for the theme API.
+ */
+
+/**
+ * Unit tests for the Theme API.
+ */
+class ThemeUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme API',
+ 'description' => 'Test low-level theme functions.',
+ 'group' => 'Theme',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('theme_test');
+ theme_enable(array('test_theme'));
+ }
+
+ /**
+ * Test function theme_get_suggestions() for SA-CORE-2009-003.
+ */
+ function testThemeSuggestions() {
+ // Set the front page as something random otherwise the CLI
+ // test runner fails.
+ variable_set('site_frontpage', 'nobody-home');
+ $args = array('node', '1', 'edit');
+ $suggestions = theme_get_suggestions($args, 'page');
+ $this->assertEqual($suggestions, array('page__node', 'page__node__%', 'page__node__1', 'page__node__edit'), t('Found expected node edit page suggestions'));
+ // Check attack vectors.
+ $args = array('node', '\\1');
+ $suggestions = theme_get_suggestions($args, 'page');
+ $this->assertEqual($suggestions, array('page__node', 'page__node__%', 'page__node__1'), t('Removed invalid \\ from suggestions'));
+ $args = array('node', '1/');
+ $suggestions = theme_get_suggestions($args, 'page');
+ $this->assertEqual($suggestions, array('page__node', 'page__node__%', 'page__node__1'), t('Removed invalid / from suggestions'));
+ $args = array('node', "1\0");
+ $suggestions = theme_get_suggestions($args, 'page');
+ $this->assertEqual($suggestions, array('page__node', 'page__node__%', 'page__node__1'), t('Removed invalid \\0 from suggestions'));
+ // Define path with hyphens to be used to generate suggestions.
+ $args = array('node', '1', 'hyphen-path');
+ $result = array('page__node', 'page__node__%', 'page__node__1', 'page__node__hyphen_path');
+ $suggestions = theme_get_suggestions($args, 'page');
+ $this->assertEqual($suggestions, $result, t('Found expected page suggestions for paths containing hyphens.'));
+ }
+
+ /**
+ * Preprocess functions for the base hook should run even for suggestion implementations.
+ */
+ function testPreprocessForSuggestions() {
+ $this->drupalGet('theme-test/suggestion');
+ $this->assertText('test_theme_breadcrumb__suggestion: 1', t('Theme hook suggestion ran with data available from a preprocess function for the base hook.'));
+ }
+
+ /**
+ * Ensure page-front template suggestion is added when on front page.
+ */
+ function testFrontPageThemeSuggestion() {
+ $q = $_GET['q'];
+ // Set $_GET['q'] to node because theme_get_suggestions() will query it to
+ // see if we are on the front page.
+ $_GET['q'] = variable_get('site_frontpage', 'node');
+ $suggestions = theme_get_suggestions(explode('/', $_GET['q']), 'page');
+ // Set it back to not annoy the batch runner.
+ $_GET['q'] = $q;
+ $this->assertTrue(in_array('page__front', $suggestions), t('Front page template was suggested.'));
+ }
+
+ /**
+ * Ensures theme hook_*_alter() implementations can run before anything is rendered.
+ */
+ function testAlter() {
+ $this->drupalGet('theme-test/alter');
+ $this->assertText('The altered data is test_theme_theme_test_alter_alter was invoked.', t('The theme was able to implement an alter hook during page building before anything was rendered.'));
+ }
+
+ /**
+ * Ensures a theme's .info file is able to override a module CSS file from being added to the page.
+ *
+ * @see test_theme.info
+ */
+ function testCSSOverride() {
+ // Reuse the same page as in testPreprocessForSuggestions(). We're testing
+ // what is output to the HTML HEAD based on what is in a theme's .info file,
+ // so it doesn't matter what page we get, as long as it is themed with the
+ // test theme. First we test with CSS aggregation disabled.
+ variable_set('preprocess_css', 0);
+ $this->drupalGet('theme-test/suggestion');
+ $this->assertNoText('system.base.css', t('The theme\'s .info file is able to override a module CSS file from being added to the page.'));
+
+ // Also test with aggregation enabled, simply ensuring no PHP errors are
+ // triggered during drupal_build_css_cache() when a source file doesn't
+ // exist. Then allow remaining tests to continue with aggregation disabled
+ // by default.
+ variable_set('preprocess_css', 1);
+ $this->drupalGet('theme-test/suggestion');
+ variable_set('preprocess_css', 0);
+ }
+
+ /**
+ * Ensures a themes template is overrideable based on the 'template' filename.
+ */
+ function testTemplateOverride() {
+ variable_set('theme_default', 'test_theme');
+ $this->drupalGet('theme-test/template-test');
+ $this->assertText('Success: Template overridden.', t('Template overridden by defined \'template\' filename.'));
+ }
+}
+
+/**
+ * Unit tests for theme_table().
+ */
+class ThemeTableUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme Table',
+ 'description' => 'Tests built-in theme functions.',
+ 'group' => 'Theme',
+ );
+ }
+
+ /**
+ * Tableheader.js provides 'sticky' table headers, and is included by default.
+ */
+ function testThemeTableStickyHeaders() {
+ $header = array('one', 'two', 'three');
+ $rows = array(array(1,2,3), array(4,5,6), array(7,8,9));
+ $this->content = theme('table', array('header' => $header, 'rows' => $rows));
+ $js = drupal_add_js();
+ $this->assertTrue(isset($js['core/misc/tableheader.js']), t('tableheader.js was included when $sticky = TRUE.'));
+ $this->assertRaw('sticky-enabled', t('Table has a class of sticky-enabled when $sticky = TRUE.'));
+ drupal_static_reset('drupal_add_js');
+ }
+
+ /**
+ * If $sticky is FALSE, no tableheader.js should be included.
+ */
+ function testThemeTableNoStickyHeaders() {
+ $header = array('one', 'two', 'three');
+ $rows = array(array(1,2,3), array(4,5,6), array(7,8,9));
+ $attributes = array();
+ $caption = NULL;
+ $colgroups = array();
+ $this->content = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => $attributes, 'caption' => $caption, 'colgroups' => $colgroups, 'sticky' => FALSE));
+ $js = drupal_add_js();
+ $this->assertFalse(isset($js['core/misc/tableheader.js']), t('tableheader.js was not included because $sticky = FALSE.'));
+ $this->assertNoRaw('sticky-enabled', t('Table does not have a class of sticky-enabled because $sticky = FALSE.'));
+ drupal_static_reset('drupal_add_js');
+ }
+
+ /**
+ * Tests that the table header is printed correctly even if there are no rows,
+ * and that the empty text is displayed correctly.
+ */
+ function testThemeTableWithEmptyMessage() {
+ $header = array(
+ t('Header 1'),
+ array(
+ 'data' => t('Header 2'),
+ 'colspan' => 2,
+ ),
+ );
+ $this->content = theme('table', array('header' => $header, 'rows' => array(), 'empty' => t('No strings available.')));
+ $this->assertRaw('<tr class="odd"><td colspan="3" class="empty message">No strings available.</td>', t('Correct colspan was set on empty message.'));
+ $this->assertRaw('<thead><tr><th>Header 1</th>', t('Table header was printed.'));
+ }
+
+}
+
+/**
+ * Tests for common theme functions.
+ */
+class ThemeFunctionsTestCase extends DrupalWebTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme functions',
+ 'description' => 'Tests common theme functions.',
+ 'group' => 'Theme',
+ );
+ }
+
+ /**
+ * Tests theme_item_list().
+ */
+ function testItemList() {
+ // Verify that empty variables produce no output.
+ $variables = array();
+ $expected = '';
+ $this->assertThemeOutput('item_list', $variables, $expected, 'Empty %callback generates no output.');
+
+ $variables = array();
+ $variables['title'] = 'Some title';
+ $expected = '';
+ $this->assertThemeOutput('item_list', $variables, $expected, 'Empty %callback with title generates no output.');
+
+ // Verify nested item lists.
+ $variables = array();
+ $variables['title'] = 'Some title';
+ $variables['attributes'] = array(
+ 'id' => 'parentlist',
+ );
+ $variables['items'] = array(
+ 'a',
+ array(
+ 'data' => 'b',
+ 'children' => array(
+ 'c',
+ // Nested children may use additional attributes.
+ array(
+ 'data' => 'd',
+ 'class' => array('dee'),
+ ),
+ // Any string key is treated as child list attribute.
+ 'id' => 'childlist',
+ ),
+ // Any other keys are treated as item attributes.
+ 'id' => 'bee',
+ ),
+ array(
+ 'data' => 'e',
+ 'id' => 'E',
+ ),
+ );
+ $inner = '<div class="item-list"><ul id="childlist">';
+ $inner .= '<li class="odd first">c</li>';
+ $inner .= '<li class="dee even last">d</li>';
+ $inner .= '</ul></div>';
+
+ $expected = '<div class="item-list">';
+ $expected .= '<h3>Some title</h3>';
+ $expected .= '<ul id="parentlist">';
+ $expected .= '<li class="odd first">a</li>';
+ $expected .= '<li id="bee" class="even">b' . $inner . '</li>';
+ $expected .= '<li id="E" class="odd last">e</li>';
+ $expected .= '</ul></div>';
+
+ $this->assertThemeOutput('item_list', $variables, $expected);
+ }
+
+ /**
+ * Asserts themed output.
+ *
+ * @param $callback
+ * The name of the theme function to invoke; e.g. 'links' for theme_links().
+ * @param $variables
+ * An array of variables to pass to the theme function.
+ * @param $expected
+ * The expected themed output string.
+ * @param $message
+ * (optional) An assertion message.
+ */
+ protected function assertThemeOutput($callback, array $variables = array(), $expected, $message = '') {
+ $output = theme($callback, $variables);
+ $this->verbose('Variables:' . '<pre>' . check_plain(var_export($variables, TRUE)) . '</pre>'
+ . '<hr />' . 'Result:' . '<pre>' . check_plain(var_export($output, TRUE)) . '</pre>'
+ . '<hr />' . 'Expected:' . '<pre>' . check_plain(var_export($expected, TRUE)) . '</pre>'
+ . '<hr />' . $output
+ );
+ if (!$message) {
+ $message = '%callback rendered correctly.';
+ }
+ $message = t($message, array('%callback' => 'theme_' . $callback . '()'));
+ $this->assertIdentical($output, $expected, $message);
+ }
+}
+
+/**
+ * Unit tests for theme_links().
+ */
+class ThemeLinksTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Links',
+ 'description' => 'Test the theme_links() function and rendering groups of links.',
+ 'group' => 'Theme',
+ );
+ }
+
+ /**
+ * Test the use of drupal_pre_render_links() on a nested array of links.
+ */
+ function testDrupalPreRenderLinks() {
+ // Define the base array to be rendered, containing a variety of different
+ // kinds of links.
+ $base_array = array(
+ '#theme' => 'links',
+ '#pre_render' => array('drupal_pre_render_links'),
+ '#links' => array(
+ 'parent_link' => array(
+ 'title' => 'Parent link original',
+ 'href' => 'parent-link-original',
+ ),
+ ),
+ 'first_child' => array(
+ '#theme' => 'links',
+ '#links' => array(
+ // This should be rendered if 'first_child' is rendered separately,
+ // but ignored if the parent is being rendered (since it duplicates
+ // one of the parent's links).
+ 'parent_link' => array(
+ 'title' => 'Parent link copy',
+ 'href' => 'parent-link-copy',
+ ),
+ // This should always be rendered.
+ 'first_child_link' => array(
+ 'title' => 'First child link',
+ 'href' => 'first-child-link',
+ ),
+ ),
+ ),
+ // This should always be rendered as part of the parent.
+ 'second_child' => array(
+ '#theme' => 'links',
+ '#links' => array(
+ 'second_child_link' => array(
+ 'title' => 'Second child link',
+ 'href' => 'second-child-link',
+ ),
+ ),
+ ),
+ // This should never be rendered, since the user does not have access to
+ // it.
+ 'third_child' => array(
+ '#theme' => 'links',
+ '#links' => array(
+ 'third_child_link' => array(
+ 'title' => 'Third child link',
+ 'href' => 'third-child-link',
+ ),
+ ),
+ '#access' => FALSE,
+ ),
+ );
+
+ // Start with a fresh copy of the base array, and try rendering the entire
+ // thing. We expect a single <ul> with appropriate links contained within
+ // it.
+ $render_array = $base_array;
+ $html = drupal_render($render_array);
+ $dom = new DOMDocument();
+ $dom->loadHTML($html);
+ $this->assertEqual($dom->getElementsByTagName('ul')->length, 1, t('One "ul" tag found in the rendered HTML.'));
+ $list_elements = $dom->getElementsByTagName('li');
+ $this->assertEqual($list_elements->length, 3, t('Three "li" tags found in the rendered HTML.'));
+ $this->assertEqual($list_elements->item(0)->nodeValue, 'Parent link original', t('First expected link found.'));
+ $this->assertEqual($list_elements->item(1)->nodeValue, 'First child link', t('Second expected link found.'));
+ $this->assertEqual($list_elements->item(2)->nodeValue, 'Second child link', t('Third expected link found.'));
+ $this->assertIdentical(strpos($html, 'Parent link copy'), FALSE, t('"Parent link copy" link not found.'));
+ $this->assertIdentical(strpos($html, 'Third child link'), FALSE, t('"Third child link" link not found.'));
+
+ // Now render 'first_child', followed by the rest of the links, and make
+ // sure we get two separate <ul>'s with the appropriate links contained
+ // within each.
+ $render_array = $base_array;
+ $child_html = drupal_render($render_array['first_child']);
+ $parent_html = drupal_render($render_array);
+ // First check the child HTML.
+ $dom = new DOMDocument();
+ $dom->loadHTML($child_html);
+ $this->assertEqual($dom->getElementsByTagName('ul')->length, 1, t('One "ul" tag found in the rendered child HTML.'));
+ $list_elements = $dom->getElementsByTagName('li');
+ $this->assertEqual($list_elements->length, 2, t('Two "li" tags found in the rendered child HTML.'));
+ $this->assertEqual($list_elements->item(0)->nodeValue, 'Parent link copy', t('First expected link found.'));
+ $this->assertEqual($list_elements->item(1)->nodeValue, 'First child link', t('Second expected link found.'));
+ // Then check the parent HTML.
+ $dom = new DOMDocument();
+ $dom->loadHTML($parent_html);
+ $this->assertEqual($dom->getElementsByTagName('ul')->length, 1, t('One "ul" tag found in the rendered parent HTML.'));
+ $list_elements = $dom->getElementsByTagName('li');
+ $this->assertEqual($list_elements->length, 2, t('Two "li" tags found in the rendered parent HTML.'));
+ $this->assertEqual($list_elements->item(0)->nodeValue, 'Parent link original', t('First expected link found.'));
+ $this->assertEqual($list_elements->item(1)->nodeValue, 'Second child link', t('Second expected link found.'));
+ $this->assertIdentical(strpos($parent_html, 'First child link'), FALSE, t('"First child link" link not found.'));
+ $this->assertIdentical(strpos($parent_html, 'Third child link'), FALSE, t('"Third child link" link not found.'));
+ }
+}
+
+/**
+ * Functional test for initialization of the theme system in hook_init().
+ */
+class ThemeHookInitUnitTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme initialization in hook_init()',
+ 'description' => 'Tests that the theme system can be correctly initialized in hook_init().',
+ 'group' => 'Theme',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('theme_test');
+ }
+
+ /**
+ * Test that the theme system can generate output when called by hook_init().
+ */
+ function testThemeInitializationHookInit() {
+ $this->drupalGet('theme-test/hook-init');
+ $this->assertRaw('Themed output generated in hook_init()', t('Themed output generated in hook_init() correctly appears on the page.'));
+ $this->assertRaw('bartik/css/style.css', t("The default theme's CSS appears on the page when the theme system is initialized in hook_init()."));
+ }
+}
+
+/**
+ * Tests autocompletion not loading registry.
+ */
+class ThemeFastTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme fast initialization',
+ 'description' => 'Test that autocompletion does not load the registry.',
+ 'group' => 'Theme'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('theme_test');
+ $this->account = $this->drupalCreateUser(array('access user profiles'));
+ }
+
+ /**
+ * Tests access to user autocompletion and verify the correct results.
+ */
+ function testUserAutocomplete() {
+ $this->drupalLogin($this->account);
+ $this->drupalGet('user/autocomplete/' . $this->account->name);
+ $this->assertText('registry not initialized', t('The registry was not initialized'));
+ }
+}
+
+/**
+ * Unit tests for theme_html_tag().
+ */
+class ThemeHtmlTag extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme HTML Tag',
+ 'description' => 'Tests theme_html_tag() built-in theme functions.',
+ 'group' => 'Theme',
+ );
+ }
+
+ /**
+ * Test function theme_html_tag()
+ */
+ function testThemeHtmlTag() {
+ // Test auto-closure meta tag generation
+ $tag['element'] = array('#tag' => 'meta', '#attributes' => array('name' => 'description', 'content' => 'Drupal test'));
+ $this->assertEqual('<meta name="description" content="Drupal test" />'."\n", theme_html_tag($tag), t('Test auto-closure meta tag generation.'));
+
+ // Test title tag generation
+ $tag['element'] = array('#tag' => 'title', '#value' => 'title test');
+ $this->assertEqual('<title>title test</title>'."\n", theme_html_tag($tag), t('Test title tag generation.'));
+ }
+}
+
+/**
+ * Functional test for attributes of html.tpl.php.
+ */
+class ThemeHtmlTplPhpAttributesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'html.tpl.php html and body attributes',
+ 'description' => 'Tests attributes inserted in the html and body elements of html.tpl.php.',
+ 'group' => 'Theme',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('theme_test');
+ }
+
+ /**
+ * Tests that modules and themes can alter variables in html.tpl.php.
+ */
+ function testThemeHtmlTplPhpAttributes() {
+ $this->drupalGet('');
+ $attributes = $this->xpath('/html[@theme_test_html_attribute="theme test html attribute value"]');
+ $this->assertTrue(count($attributes) == 1, t('Attribute set in the html element via hook_preprocess_html() found.'));
+ $attributes = $this->xpath('/html/body[@theme_test_body_attribute="theme test body attribute value"]');
+ $this->assertTrue(count($attributes) == 1, t('Attribute set in the body element via hook_preprocess_html() found.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/theme_test.info b/core/modules/simpletest/tests/theme_test.info
new file mode 100644
index 000000000000..c4d065919089
--- /dev/null
+++ b/core/modules/simpletest/tests/theme_test.info
@@ -0,0 +1,6 @@
+name = "Theme test"
+description = "Support module for theme system testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/theme_test.module b/core/modules/simpletest/tests/theme_test.module
new file mode 100644
index 000000000000..597c090da0c0
--- /dev/null
+++ b/core/modules/simpletest/tests/theme_test.module
@@ -0,0 +1,137 @@
+<?php
+
+/**
+ * Implements hook_theme().
+ */
+function theme_test_theme($existing, $type, $theme, $path) {
+ $items['theme_test_template_test'] = array(
+ 'template' => 'theme_test.template_test',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function theme_test_menu() {
+ $items['theme-test/suggestion'] = array(
+ 'title' => 'Suggestion',
+ 'page callback' => '_theme_test_suggestion',
+ 'access arguments' => array('access content'),
+ 'theme callback' => '_theme_custom_theme',
+ 'type' => MENU_CALLBACK,
+ );
+ $items['theme-test/alter'] = array(
+ 'title' => 'Suggestion',
+ 'page callback' => '_theme_test_alter',
+ 'access arguments' => array('access content'),
+ 'theme callback' => '_theme_custom_theme',
+ 'type' => MENU_CALLBACK,
+ );
+ $items['theme-test/hook-init'] = array(
+ 'page callback' => 'theme_test_hook_init_page_callback',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['theme-test/template-test'] = array(
+ 'page callback' => 'theme_test_template_test_page_callback',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_init().
+ */
+function theme_test_init() {
+ if (arg(0) == 'theme-test' && arg(1) == 'hook-init') {
+ // First, force the theme registry to be rebuilt on this page request. This
+ // allows us to test a full initialization of the theme system in the code
+ // below.
+ drupal_theme_rebuild();
+ // Next, initialize the theme system by storing themed text in a global
+ // variable. We will use this later in theme_test_hook_init_page_callback()
+ // to test that even when the theme system is initialized this early, it is
+ // still capable of returning output and theming the page as a whole.
+ $GLOBALS['theme_test_output'] = theme('more_link', array('url' => 'user', 'title' => 'Themed output generated in hook_init()'));
+ }
+}
+
+/**
+ * Implements hook_exit().
+ */
+function theme_test_exit() {
+ if (arg(0) == 'user') {
+ // Register a fake registry loading callback. If it gets called by
+ // theme_get_registry(), the registry has not been initialized yet.
+ _theme_registry_callback('_theme_test_load_registry', array());
+ print theme_get_registry() ? 'registry initialized' : 'registry not initialized';
+ }
+}
+
+/**
+ * Fake registry loading callback.
+ */
+function _theme_test_load_registry() {
+ return array();
+}
+
+/**
+ * Menu callback for testing themed output generated in hook_init().
+ */
+function theme_test_hook_init_page_callback() {
+ return $GLOBALS['theme_test_output'];
+}
+
+/**
+ * Menu callback for testing template overridding based on filename.
+ */
+function theme_test_template_test_page_callback() {
+ return theme('theme_test_template_test');
+}
+
+/**
+ * Custom theme callback.
+ */
+function _theme_custom_theme() {
+ return 'test_theme';
+}
+
+/**
+ * Page callback, calls drupal_alter().
+ *
+ * This is for testing that the theme can have hook_*_alter() implementations
+ * that run during page callback execution, even before theme() is called for
+ * the first time.
+ */
+function _theme_test_alter() {
+ $data = 'foo';
+ drupal_alter('theme_test_alter', $data);
+ return "The altered data is $data.";
+}
+
+/**
+ * Page callback, calls a theme hook suggestion.
+ */
+function _theme_test_suggestion() {
+ return theme(array('breadcrumb__suggestion', 'breadcrumb'), array());
+}
+
+/**
+ * Implements hook_preprocess_breadcrumb().
+ *
+ * Set a variable that can later be tested to see if this function ran.
+ */
+function theme_test_preprocess_breadcrumb(&$variables) {
+ $variables['theme_test_preprocess_breadcrumb'] = 1;
+}
+
+/**
+ * Implements hook_preprocess_html().
+ */
+function theme_test_preprocess_html(&$variables) {
+ $variables['html_attributes_array']['theme_test_html_attribute'] = 'theme test html attribute value';
+ $variables['body_attributes_array']['theme_test_body_attribute'] = 'theme test body attribute value';
+}
diff --git a/core/modules/simpletest/tests/theme_test.template_test.tpl.php b/core/modules/simpletest/tests/theme_test.template_test.tpl.php
new file mode 100644
index 000000000000..cde8faadd3c3
--- /dev/null
+++ b/core/modules/simpletest/tests/theme_test.template_test.tpl.php
@@ -0,0 +1,2 @@
+<!-- Output for Theme API test -->
+Fail: Template not overridden.
diff --git a/core/modules/simpletest/tests/unicode.test b/core/modules/simpletest/tests/unicode.test
new file mode 100644
index 000000000000..47a4938fe856
--- /dev/null
+++ b/core/modules/simpletest/tests/unicode.test
@@ -0,0 +1,305 @@
+<?php
+
+/**
+ * @file
+ * Various unicode handling tests.
+ */
+
+/**
+ * Test unicode handling features implemented in unicode.inc.
+ */
+class UnicodeUnitTest extends DrupalWebTestCase {
+
+ /**
+ * Whether to run the extended version of the tests (including non latin1 characters).
+ *
+ * @var boolean
+ */
+ protected $extendedMode = FALSE;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Unicode handling',
+ 'description' => 'Tests Drupal Unicode handling.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Test full unicode features implemented using the mbstring extension.
+ */
+ function testMbStringUnicode() {
+ global $multibyte;
+
+ // mbstring was not detected on this installation, there is no way to test
+ // multibyte features. Treat that as an exception.
+ if ($multibyte == UNICODE_SINGLEBYTE) {
+ $this->error(t('Unable to test Multibyte features: mbstring extension was not detected.'));
+ }
+
+ $multibyte = UNICODE_MULTIBYTE;
+
+ $this->extendedMode = TRUE;
+ $this->pass(t('Testing in mbstring mode'));
+
+ $this->helperTestStrToLower();
+ $this->helperTestStrToUpper();
+ $this->helperTestUcFirst();
+ $this->helperTestStrLen();
+ $this->helperTestSubStr();
+ $this->helperTestTruncate();
+ }
+
+ /**
+ * Test emulated unicode features.
+ */
+ function testEmulatedUnicode() {
+ global $multibyte;
+
+ $multibyte = UNICODE_SINGLEBYTE;
+
+ $this->extendedMode = FALSE;
+
+ $this->pass(t('Testing in emulated (best-effort) mode'));
+
+ $this->helperTestStrToLower();
+ $this->helperTestStrToUpper();
+ $this->helperTestUcFirst();
+ $this->helperTestStrLen();
+ $this->helperTestSubStr();
+ $this->helperTestTruncate();
+ }
+
+ function helperTestStrToLower() {
+ $testcase = array(
+ 'tHe QUIcK bRoWn' => 'the quick brown',
+ 'FrançAIS is ÜBER-åwesome' => 'français is über-åwesome',
+ );
+ if ($this->extendedMode) {
+ $testcase['ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΣὨ'] = 'αβγδεζηθικλμνξοσὠ';
+ }
+
+ foreach ($testcase as $input => $output) {
+ $this->assertEqual(drupal_strtolower($input), $output, t('%input is lowercased as %output', array('%input' => $input, '%output' => $output)));
+ }
+ }
+
+ function helperTestStrToUpper() {
+ $testcase = array(
+ 'tHe QUIcK bRoWn' => 'THE QUICK BROWN',
+ 'FrançAIS is ÜBER-åwesome' => 'FRANÇAIS IS ÜBER-ÅWESOME',
+ );
+ if ($this->extendedMode) {
+ $testcase['αβγδεζηθικλμνξοσὠ'] = 'ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΣὨ';
+ }
+
+ foreach ($testcase as $input => $output) {
+ $this->assertEqual(drupal_strtoupper($input), $output, t('%input is uppercased as %output', array('%input' => $input, '%output' => $output)));
+ }
+ }
+
+ function helperTestUcFirst() {
+ $testcase = array(
+ 'tHe QUIcK bRoWn' => 'THe QUIcK bRoWn',
+ 'françAIS' => 'FrançAIS',
+ 'über' => 'Über',
+ 'åwesome' => 'Åwesome'
+ );
+ if ($this->extendedMode) {
+ $testcase['σion'] = 'Σion';
+ }
+
+ foreach ($testcase as $input => $output) {
+ $this->assertEqual(drupal_ucfirst($input), $output, t('%input is ucfirst-ed as %output', array('%input' => $input, '%output' => $output)));
+ }
+ }
+
+ function helperTestStrLen() {
+ $testcase = array(
+ 'tHe QUIcK bRoWn' => 15,
+ 'ÜBER-åwesome' => 12,
+ );
+
+ foreach ($testcase as $input => $output) {
+ $this->assertEqual(drupal_strlen($input), $output, t('%input length is %output', array('%input' => $input, '%output' => $output)));
+ }
+ }
+
+ function helperTestSubStr() {
+ $testcase = array(
+ // 012345678901234567890123
+ array('frànçAIS is über-åwesome', 0, 0,
+ ''),
+ array('frànçAIS is über-åwesome', 0, 1,
+ 'f'),
+ array('frànçAIS is über-åwesome', 0, 8,
+ 'frànçAIS'),
+ array('frànçAIS is über-åwesome', 0, 23,
+ 'frànçAIS is über-åwesom'),
+ array('frànçAIS is über-åwesome', 0, 24,
+ 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 0, 25,
+ 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 0, 100,
+ 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 4, 4,
+ 'çAIS'),
+ array('frànçAIS is über-åwesome', 1, 0,
+ ''),
+ array('frànçAIS is über-åwesome', 100, 0,
+ ''),
+ array('frànçAIS is über-åwesome', -4, 2,
+ 'so'),
+ array('frànçAIS is über-åwesome', -4, 3,
+ 'som'),
+ array('frànçAIS is über-åwesome', -4, 4,
+ 'some'),
+ array('frànçAIS is über-åwesome', -4, 5,
+ 'some'),
+ array('frànçAIS is über-åwesome', -7, 10,
+ 'åwesome'),
+ array('frànçAIS is über-åwesome', 5, -10,
+ 'AIS is üb'),
+ array('frànçAIS is über-åwesome', 0, -10,
+ 'frànçAIS is üb'),
+ array('frànçAIS is über-åwesome', 0, -1,
+ 'frànçAIS is über-åwesom'),
+ array('frànçAIS is über-åwesome', -7, -2,
+ 'åweso'),
+ array('frànçAIS is über-åwesome', -7, -6,
+ 'å'),
+ array('frànçAIS is über-åwesome', -7, -7,
+ ''),
+ array('frànçAIS is über-åwesome', -7, -8,
+ ''),
+ array('...', 0, 2, '..'),
+ array('以呂波耳・ほへとち。リヌルヲ。', 1, 3,
+ '呂波耳'),
+
+ );
+
+ foreach ($testcase as $test) {
+ list($input, $start, $length, $output) = $test;
+ $result = drupal_substr($input, $start, $length);
+ $this->assertEqual($result, $output, t('%input substring at offset %offset for %length characters is %output (got %result)', array('%input' => $input, '%offset' => $start, '%length' => $length, '%output' => $output, '%result' => $result)));
+ }
+ }
+
+ /**
+ * Test decode_entities().
+ */
+ function testDecodeEntities() {
+ $testcase = array(
+ 'Drupal' => 'Drupal',
+ '<script>' => '<script>',
+ '&lt;script&gt;' => '<script>',
+ '&#60;script&#62;' => '<script>',
+ '&amp;lt;script&amp;gt;' => '&lt;script&gt;',
+ '"' => '"',
+ '&#34;' => '"',
+ '&amp;#34;' => '&#34;',
+ '&quot;' => '"',
+ '&amp;quot;' => '&quot;',
+ "'" => "'",
+ '&#39;' => "'",
+ '&amp;#39;' => '&#39;',
+ '©' => '©',
+ '&copy;' => '©',
+ '&#169;' => '©',
+ '→' => '→',
+ '&#8594;' => '→',
+ '➼' => '➼',
+ '&#10172;' => '➼',
+ '&euro;' => '€',
+ );
+ foreach ($testcase as $input => $output) {
+ $this->assertEqual(decode_entities($input), $output, t('Make sure the decoded entity of @input is @output', array('@input' => $input, '@output' => $output)));
+ }
+ }
+
+ /**
+ * Tests truncate_utf8().
+ */
+ function helperTestTruncate() {
+ // Each case is an array with input string, length to truncate to, and
+ // expected return value.
+
+ // Test non-wordsafe, non-ellipsis cases.
+ $non_wordsafe_non_ellipsis_cases = array(
+ array('frànçAIS is über-åwesome', 24, 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 23, 'frànçAIS is über-åwesom'),
+ array('frànçAIS is über-åwesome', 17, 'frànçAIS is über-'),
+ array('以呂波耳・ほへとち。リヌルヲ。', 6, '以呂波耳・ほ'),
+ );
+ $this->runTruncateTests($non_wordsafe_non_ellipsis_cases, FALSE, FALSE);
+
+ // Test non-wordsafe, ellipsis cases.
+ $non_wordsafe_ellipsis_cases = array(
+ array('frànçAIS is über-åwesome', 24, 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 23, 'frànçAIS is über-åwe...'),
+ array('frànçAIS is über-åwesome', 17, 'frànçAIS is üb...'),
+ );
+ $this->runTruncateTests($non_wordsafe_ellipsis_cases, FALSE, TRUE);
+
+ // Test wordsafe, ellipsis cases.
+ $wordsafe_ellipsis_cases = array(
+ array('123', 1, '.'),
+ array('123', 2, '..'),
+ array('123', 3, '123'),
+ array('1234', 3, '...'),
+ array('1234567890', 10, '1234567890'),
+ array('12345678901', 10, '1234567...'),
+ array('12345678901', 11, '12345678901'),
+ array('123456789012', 11, '12345678...'),
+ array('12345 7890', 10, '12345 7890'),
+ array('12345 7890', 9, '12345...'),
+ array('123 567 90', 10, '123 567 90'),
+ array('123 567 901', 10, '123 567...'),
+ array('Stop. Hammertime.', 17, 'Stop. Hammertime.'),
+ array('Stop. Hammertime.', 16, 'Stop....'),
+ array('frànçAIS is über-åwesome', 24, 'frànçAIS is über-åwesome'),
+ array('frànçAIS is über-åwesome', 23, 'frànçAIS is über...'),
+ array('frànçAIS is über-åwesome', 17, 'frànçAIS is...'),
+ array('¿Dónde está el niño?', 20, '¿Dónde está el niño?'),
+ array('¿Dónde está el niño?', 19, '¿Dónde está el...'),
+ array('¿Dónde está el niño?', 15, '¿Dónde está...'),
+ array('¿Dónde está el niño?', 10, '¿Dónde...'),
+ array('Help! Help! Help!', 17, 'Help! Help! Help!'),
+ array('Help! Help! Help!', 16, 'Help! Help!...'),
+ array('Help! Help! Help!', 15, 'Help! Help!...'),
+ array('Help! Help! Help!', 14, 'Help! Help!...'),
+ array('Help! Help! Help!', 13, 'Help! Help...'),
+ array('Help! Help! Help!', 12, 'Help!...'),
+ array('Help! Help! Help!', 11, 'Help!...'),
+ array('Help! Help! Help!', 10, 'Help!...'),
+ array('Help! Help! Help!', 9, 'Help!...'),
+ array('Help! Help! Help!', 8, 'Help!...'),
+ array('Help! Help! Help!', 7, 'Help...'),
+ array('Help! Help! Help!', 6, 'Hel...'),
+ array('Help! Help! Help!', 5, 'He...'),
+ );
+ $this->runTruncateTests($wordsafe_ellipsis_cases, TRUE, TRUE);
+ }
+
+ /**
+ * Runs test cases for helperTestTruncate().
+ *
+ * Runs each test case through truncate_utf8() and compares the output
+ * to the expected output.
+ *
+ * @param $cases
+ * Cases array. Each case is an array with the input string, length to
+ * truncate to, and expected output.
+ * @param $wordsafe
+ * TRUE to use word-safe truncation, FALSE to not use word-safe truncation.
+ * @param $ellipsis
+ * TRUE to append ... if the input is truncated, FALSE to not append ....
+ */
+ function runTruncateTests($cases, $wordsafe, $ellipsis) {
+ foreach ($cases as $case) {
+ list($input, $max_length, $expected) = $case;
+ $output = truncate_utf8($input, $max_length, $wordsafe, $ellipsis);
+ $this->assertEqual($output, $expected, t('%input truncate to %length characters with %wordsafe, %ellipsis is %expected (got %output)', array('%input' => $input, '%length' => $max_length, '%output' => $output, '%expected' => $expected, '%wordsafe' => ($wordsafe ? 'word-safe' : 'not word-safe'), '%ellipsis' => ($ellipsis ? 'ellipsis' : 'not ellipsis'))));
+ }
+ }
+}
diff --git a/core/modules/simpletest/tests/update.test b/core/modules/simpletest/tests/update.test
new file mode 100644
index 000000000000..abbab47391d9
--- /dev/null
+++ b/core/modules/simpletest/tests/update.test
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @file
+ * Tests for the update system.
+ */
+
+/**
+ * Tests for the update dependency ordering system.
+ */
+class UpdateDependencyOrderingTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update dependency ordering',
+ 'description' => 'Test that update functions are run in the proper order.',
+ 'group' => 'Update API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('update_test_1', 'update_test_2', 'update_test_3');
+ require_once DRUPAL_ROOT . '/core/includes/update.inc';
+ }
+
+ /**
+ * Test that updates within a single module run in the correct order.
+ */
+ function testUpdateOrderingSingleModule() {
+ $starting_updates = array(
+ 'update_test_1' => 8000,
+ );
+ $expected_updates = array(
+ 'update_test_1_update_8000',
+ 'update_test_1_update_8001',
+ 'update_test_1_update_8002',
+ );
+ $actual_updates = array_keys(update_resolve_dependencies($starting_updates));
+ $this->assertEqual($expected_updates, $actual_updates, t('Updates within a single module run in the correct order.'));
+ }
+
+ /**
+ * Test that dependencies between modules are resolved correctly.
+ */
+ function testUpdateOrderingModuleInterdependency() {
+ $starting_updates = array(
+ 'update_test_2' => 8000,
+ 'update_test_3' => 8000,
+ );
+ $update_order = array_keys(update_resolve_dependencies($starting_updates));
+ // Make sure that each dependency is satisfied.
+ $first_dependency_satisfied = array_search('update_test_2_update_8000', $update_order) < array_search('update_test_3_update_8000', $update_order);
+ $this->assertTrue($first_dependency_satisfied, t('The dependency of the second module on the first module is respected by the update function order.'));
+ $second_dependency_satisfied = array_search('update_test_3_update_8000', $update_order) < array_search('update_test_2_update_8001', $update_order);
+ $this->assertTrue($second_dependency_satisfied, t('The dependency of the first module on the second module is respected by the update function order.'));
+ }
+}
+
+/**
+ * Tests for missing update dependencies.
+ */
+class UpdateDependencyMissingTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Missing update dependencies',
+ 'description' => 'Test that missing update dependencies are correctly flagged.',
+ 'group' => 'Update API',
+ );
+ }
+
+ function setUp() {
+ // Only install update_test_2.module, even though its updates have a
+ // dependency on update_test_3.module.
+ parent::setUp('update_test_2');
+ require_once DRUPAL_ROOT . '/core/includes/update.inc';
+ }
+
+ function testMissingUpdate() {
+ $starting_updates = array(
+ 'update_test_2' => 8000,
+ );
+ $update_graph = update_resolve_dependencies($starting_updates);
+ $this->assertTrue($update_graph['update_test_2_update_8000']['allowed'], t("The module's first update function is allowed to run, since it does not have any missing dependencies."));
+ $this->assertFalse($update_graph['update_test_2_update_8001']['allowed'], t("The module's second update function is not allowed to run, since it has a direct dependency on a missing update."));
+ $this->assertFalse($update_graph['update_test_2_update_8002']['allowed'], t("The module's third update function is not allowed to run, since it has an indirect dependency on a missing update."));
+ }
+}
+
+/**
+ * Tests for the invocation of hook_update_dependencies().
+ */
+class UpdateDependencyHookInvocationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update dependency hook invocation',
+ 'description' => 'Test that the hook invocation for determining update dependencies works correctly.',
+ 'group' => 'Update API',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('update_test_1', 'update_test_2');
+ require_once DRUPAL_ROOT . '/core/includes/update.inc';
+ }
+
+ /**
+ * Test the structure of the array returned by hook_update_dependencies().
+ */
+ function testHookUpdateDependencies() {
+ $update_dependencies = update_retrieve_dependencies();
+ $this->assertTrue($update_dependencies['system'][8000]['update_test_1'] == 8000, t('An update function that has a dependency on two separate modules has the first dependency recorded correctly.'));
+ $this->assertTrue($update_dependencies['system'][8000]['update_test_2'] == 8001, t('An update function that has a dependency on two separate modules has the second dependency recorded correctly.'));
+ $this->assertTrue($update_dependencies['system'][8001]['update_test_1'] == 8002, t('An update function that depends on more than one update from the same module only has the dependency on the higher-numbered update function recorded.'));
+ }
+}
+
diff --git a/core/modules/simpletest/tests/update_script_test.info b/core/modules/simpletest/tests/update_script_test.info
new file mode 100644
index 000000000000..04bf73ca8b43
--- /dev/null
+++ b/core/modules/simpletest/tests/update_script_test.info
@@ -0,0 +1,6 @@
+name = "Update script test"
+description = "Support module for update script testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/update_script_test.install b/core/modules/simpletest/tests/update_script_test.install
new file mode 100644
index 000000000000..8af516bc0a3b
--- /dev/null
+++ b/core/modules/simpletest/tests/update_script_test.install
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the update_script_test module.
+ */
+
+/**
+ * Implements hook_requirements().
+ */
+function update_script_test_requirements($phase) {
+ $requirements = array();
+
+ if ($phase == 'update') {
+ // Set a requirements warning or error when the test requests it.
+ $requirement_type = variable_get('update_script_test_requirement_type');
+ switch ($requirement_type) {
+ case REQUIREMENT_WARNING:
+ $requirements['update_script_test'] = array(
+ 'title' => 'Update script test',
+ 'value' => 'Warning',
+ 'description' => 'This is a requirements warning provided by the update_script_test module.',
+ 'severity' => REQUIREMENT_WARNING,
+ );
+ break;
+ case REQUIREMENT_ERROR:
+ $requirements['update_script_test'] = array(
+ 'title' => 'Update script test',
+ 'value' => 'Error',
+ 'description' => 'This is a requirements error provided by the update_script_test module.',
+ 'severity' => REQUIREMENT_ERROR,
+ );
+ break;
+ }
+ }
+
+ return $requirements;
+}
+
+/**
+ * Dummy update function to run during the tests.
+ */
+function update_script_test_update_8000() {
+ return t('The update_script_test_update_8000() update was executed successfully.');
+}
diff --git a/core/modules/simpletest/tests/update_script_test.module b/core/modules/simpletest/tests/update_script_test.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/update_script_test.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/update_test_1.info b/core/modules/simpletest/tests/update_test_1.info
new file mode 100644
index 000000000000..5a5b14fdd9a7
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_1.info
@@ -0,0 +1,6 @@
+name = "Update test"
+description = "Support module for update testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/update_test_1.install b/core/modules/simpletest/tests/update_test_1.install
new file mode 100644
index 000000000000..22398a30f1de
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_1.install
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the update_test_1 module.
+ */
+
+/**
+ * Implements hook_update_dependencies().
+ *
+ * @see update_test_2_update_dependencies()
+ */
+function update_test_1_update_dependencies() {
+ // These dependencies are used in combination with those declared in
+ // update_test_2_update_dependencies() for the sole purpose of testing that
+ // the results of hook_update_dependencies() are collected correctly and have
+ // the correct array structure. Therefore, we use updates from System module
+ // (which have already run), so that they will not get in the way of other
+ // tests.
+ $dependencies['system'][8000] = array(
+ // Compare to update_test_2_update_dependencies(), where the same System
+ // module update function is forced to depend on an update function from a
+ // different module. This allows us to test that both dependencies are
+ // correctly recorded.
+ 'update_test_1' => 8000,
+ );
+ $dependencies['system'][8001] = array(
+ // Compare to update_test_2_update_dependencies(), where the same System
+ // module update function is forced to depend on a different update
+ // function within the same module. This allows us to test that only the
+ // dependency on the higher-numbered update function is recorded.
+ 'update_test_1' => 8002,
+ );
+ return $dependencies;
+}
+
+/**
+ * Dummy update_test_1 update 8000.
+ */
+function update_test_1_update_8000() {
+}
+
+/**
+ * Dummy update_test_1 update 8001.
+ */
+function update_test_1_update_8001() {
+}
+
+/**
+ * Dummy update_test_1 update 8002.
+ */
+function update_test_1_update_8002() {
+}
diff --git a/core/modules/simpletest/tests/update_test_1.module b/core/modules/simpletest/tests/update_test_1.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_1.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/update_test_2.info b/core/modules/simpletest/tests/update_test_2.info
new file mode 100644
index 000000000000..5a5b14fdd9a7
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_2.info
@@ -0,0 +1,6 @@
+name = "Update test"
+description = "Support module for update testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/update_test_2.install b/core/modules/simpletest/tests/update_test_2.install
new file mode 100644
index 000000000000..c73271a1fb00
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_2.install
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the update_test_2 module.
+ */
+
+/**
+ * Implements hook_update_dependencies().
+ *
+ * @see update_test_1_update_dependencies()
+ * @see update_test_3_update_dependencies()
+ */
+function update_test_2_update_dependencies() {
+ // Combined with update_test_3_update_dependencies(), we are declaring here
+ // that these two modules run updates in the following order:
+ // 1. update_test_2_update_8000()
+ // 2. update_test_3_update_8000()
+ // 3. update_test_2_update_8001()
+ // 4. update_test_2_update_8002()
+ $dependencies['update_test_2'][8001] = array(
+ 'update_test_3' => 8000,
+ );
+
+ // These are coordinated with the corresponding dependencies declared in
+ // update_test_1_update_dependencies().
+ $dependencies['system'][8000] = array(
+ 'update_test_2' => 8001,
+ );
+ $dependencies['system'][8001] = array(
+ 'update_test_1' => 8001,
+ );
+
+ return $dependencies;
+}
+
+/**
+ * Dummy update_test_2 update 8000.
+ */
+function update_test_2_update_8000() {
+}
+
+/**
+ * Dummy update_test_2 update 8001.
+ */
+function update_test_2_update_8001() {
+}
+
+/**
+ * Dummy update_test_2 update 8002.
+ */
+function update_test_2_update_8002() {
+}
diff --git a/core/modules/simpletest/tests/update_test_2.module b/core/modules/simpletest/tests/update_test_2.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_2.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/update_test_3.info b/core/modules/simpletest/tests/update_test_3.info
new file mode 100644
index 000000000000..5a5b14fdd9a7
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_3.info
@@ -0,0 +1,6 @@
+name = "Update test"
+description = "Support module for update testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/update_test_3.install b/core/modules/simpletest/tests/update_test_3.install
new file mode 100644
index 000000000000..96830c816f9c
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_3.install
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the update_test_3 module.
+ */
+
+/**
+ * Implements hook_update_dependencies().
+ *
+ * @see update_test_2_update_dependencies()
+ */
+function update_test_3_update_dependencies() {
+ $dependencies['update_test_3'][8000] = array(
+ 'update_test_2' => 8000,
+ );
+ return $dependencies;
+}
+
+/**
+ * Dummy update_test_3 update 8000.
+ */
+function update_test_3_update_8000() {
+}
diff --git a/core/modules/simpletest/tests/update_test_3.module b/core/modules/simpletest/tests/update_test_3.module
new file mode 100644
index 000000000000..b3d9bbc7f371
--- /dev/null
+++ b/core/modules/simpletest/tests/update_test_3.module
@@ -0,0 +1 @@
+<?php
diff --git a/core/modules/simpletest/tests/upgrade/drupal-7.bare.database.php.gz b/core/modules/simpletest/tests/upgrade/drupal-7.bare.database.php.gz
new file mode 100644
index 000000000000..4eddff6ac194
--- /dev/null
+++ b/core/modules/simpletest/tests/upgrade/drupal-7.bare.database.php.gz
Binary files differ
diff --git a/core/modules/simpletest/tests/upgrade/drupal-7.filled.database.php.gz b/core/modules/simpletest/tests/upgrade/drupal-7.filled.database.php.gz
new file mode 100644
index 000000000000..9ff7c0bc4184
--- /dev/null
+++ b/core/modules/simpletest/tests/upgrade/drupal-7.filled.database.php.gz
Binary files differ
diff --git a/core/modules/simpletest/tests/upgrade/upgrade.test b/core/modules/simpletest/tests/upgrade/upgrade.test
new file mode 100644
index 000000000000..ffb3a5595f1e
--- /dev/null
+++ b/core/modules/simpletest/tests/upgrade/upgrade.test
@@ -0,0 +1,336 @@
+<?php
+
+/**
+ * Perform end-to-end tests of the upgrade path.
+ */
+abstract class UpgradePathTestCase extends DrupalWebTestCase {
+
+ /**
+ * The file path(s) to the dumped database(s) to load into the child site.
+ *
+ * @var array
+ */
+ var $databaseDumpFiles = array();
+
+ /**
+ * Flag that indicates whether the child site has been upgraded.
+ */
+ var $upgradedSite = FALSE;
+
+ /**
+ * Array of errors triggered during the upgrade process.
+ */
+ var $upgradeErrors = array();
+
+ /**
+ * Array of modules loaded when the test starts.
+ */
+ var $loadedModules = array();
+
+ /**
+ * Override of DrupalWebTestCase::setUp() specialized for upgrade testing.
+ */
+ protected function setUp() {
+ global $user, $language, $conf;
+
+ // We are going to set a missing zlib requirement property for usage during
+ // the performUpgrade() and tearDown() calls. Also set that the tests failed.
+ if (!function_exists('gzopen')) {
+ $this->missing_zlib_requirement = TRUE;
+ parent::setUp();
+ return;
+ }
+
+ // Load the Update API.
+ require_once DRUPAL_ROOT . '/core/includes/update.inc';
+
+ // Reset flags.
+ $this->upgradedSite = FALSE;
+ $this->upgradeErrors = array();
+
+ $this->loadedModules = module_list();
+
+ // Generate a temporary prefixed database to ensure that tests have a clean starting point.
+ $this->databasePrefix = 'simpletest' . mt_rand(1000, 1000000);
+ db_update('simpletest_test_id')
+ ->fields(array('last_prefix' => $this->databasePrefix))
+ ->condition('test_id', $this->testId)
+ ->execute();
+
+ // Clone the current connection and replace the current prefix.
+ $connection_info = Database::getConnectionInfo('default');
+ Database::renameConnection('default', 'simpletest_original_default');
+ foreach ($connection_info as $target => $value) {
+ $connection_info[$target]['prefix'] = array(
+ 'default' => $value['prefix']['default'] . $this->databasePrefix,
+ );
+ }
+ Database::addConnectionInfo('default', 'default', $connection_info['default']);
+
+ // Store necessary current values before switching to prefixed database.
+ $this->originalLanguage = $language;
+ $this->originalLanguageDefault = variable_get('language_default');
+ $this->originalFileDirectory = variable_get('file_public_path', conf_path() . '/files');
+ $this->originalProfile = drupal_get_profile();
+ $clean_url_original = variable_get('clean_url', 0);
+
+ // Unregister the registry.
+ // This is required to make sure that the database layer works properly.
+ spl_autoload_unregister('drupal_autoload_class');
+ spl_autoload_unregister('drupal_autoload_interface');
+
+ // Create test directories ahead of installation so fatal errors and debug
+ // information can be logged during installation process.
+ // Use mock files directories with the same prefix as the database.
+ $public_files_directory = $this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10);
+ $private_files_directory = $public_files_directory . '/private';
+ $temp_files_directory = $private_files_directory . '/temp';
+
+ // Create the directories.
+ file_prepare_directory($public_files_directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS);
+ file_prepare_directory($private_files_directory, FILE_CREATE_DIRECTORY);
+ file_prepare_directory($temp_files_directory, FILE_CREATE_DIRECTORY);
+ $this->generatedTestFiles = FALSE;
+
+ // Log fatal errors.
+ ini_set('log_errors', 1);
+ ini_set('error_log', $public_files_directory . '/error.log');
+
+ // Reset all statics and variables to perform tests in a clean environment.
+ $conf = array();
+
+ // Load the database from the portable PHP dump.
+ // The files can be gzipped.
+ foreach ($this->databaseDumpFiles as $file) {
+ if (substr($file, -3) == '.gz') {
+ $file = "compress.zlib://$file";
+ }
+ require $file;
+ }
+
+ // Set path variables.
+ $this->variable_set('file_public_path', $public_files_directory);
+ $this->variable_set('file_private_path', $private_files_directory);
+ $this->variable_set('file_temporary_path', $temp_files_directory);
+
+ $this->pass('Finished loading the dump.');
+
+ // Load user 1.
+ $this->originalUser = $user;
+ drupal_save_session(FALSE);
+ $user = db_query('SELECT * FROM {users} WHERE uid = :uid', array(':uid' => 1))->fetchObject();
+
+ // Generate and set a D6-compatible session cookie.
+ $this->curlInitialize();
+ $sid = drupal_hash_base64(uniqid(mt_rand(), TRUE) . drupal_random_bytes(55));
+ curl_setopt($this->curlHandle, CURLOPT_COOKIE, rawurlencode(session_name()) . '=' . rawurlencode($sid));
+
+ // Force our way into the session of the child site.
+ drupal_save_session(TRUE);
+ _drupal_session_write($sid, '');
+ // Remove the temporarily added ssid column.
+ drupal_save_session(FALSE);
+
+ // Restore necessary variables.
+ $this->variable_set('clean_url', $clean_url_original);
+ $this->variable_set('site_mail', 'simpletest@example.com');
+
+ drupal_set_time_limit($this->timeLimit);
+ }
+
+ /**
+ * Override of DrupalWebTestCase::tearDown() specialized for upgrade testing.
+ */
+ protected function tearDown() {
+ global $user, $language;
+
+ if (!empty($this->missing_zlib_requirement)) {
+ parent::tearDown();
+ return;
+ }
+
+ // In case a fatal error occurred that was not in the test process read the
+ // log to pick up any fatal errors.
+ simpletest_log_read($this->testId, $this->databasePrefix, get_class($this), TRUE);
+
+ // Delete temporary files directory.
+ file_unmanaged_delete_recursive($this->originalFileDirectory . '/simpletest/' . substr($this->databasePrefix, 10));
+
+ // Get back to the original connection.
+ Database::removeConnection('default');
+ Database::renameConnection('simpletest_original_default', 'default');
+
+ // Remove all prefixed tables.
+ $tables = db_find_tables($this->databasePrefix . '%');
+ foreach ($tables as $table) {
+ db_drop_table($table);
+ }
+
+ // Return the user to the original one.
+ $user = $this->originalUser;
+ drupal_save_session(TRUE);
+
+ // Ensure that internal logged in variable and cURL options are reset.
+ $this->loggedInUser = FALSE;
+ $this->additionalCurlOptions = array();
+
+ // Reload module list and implementations to ensure that test module hooks
+ // aren't called after tests.
+ module_list(TRUE);
+ module_implements_reset();
+
+ // Reset the Field API.
+ field_cache_clear();
+
+ // Rebuild caches.
+ parent::refreshVariables();
+
+ // Reset language.
+ $language = $this->originalLanguage;
+ if ($this->originalLanguageDefault) {
+ $GLOBALS['conf']['language_default'] = $this->originalLanguageDefault;
+ }
+
+ // Close the CURL handler.
+ $this->curlClose();
+ }
+
+ /**
+ * Specialized variable_set() that works even if the child site is not upgraded.
+ *
+ * @param $name
+ * The name of the variable to set.
+ * @param $value
+ * The value to set. This can be any PHP data type; these functions take care
+ * of serialization as necessary.
+ */
+ protected function variable_set($name, $value) {
+ db_delete('variable')
+ ->condition('name', $name)
+ ->execute();
+ db_insert('variable')
+ ->fields(array(
+ 'name' => $name,
+ 'value' => serialize($value),
+ ))
+ ->execute();
+
+ try {
+ cache()->delete('variables');
+ cache('bootstrap')->delete('variables');
+ }
+ // Since cache_bootstrap won't exist in a Drupal 6 site, ignore the
+ // exception if the above fails.
+ catch (Exception $e) {}
+ }
+
+ /**
+ * Specialized refreshVariables().
+ */
+ protected function refreshVariables() {
+ // No operation if the child has not been upgraded yet.
+ if (!$this->upgradedSite) {
+ return parent::refreshVariables();
+ }
+ }
+
+ /**
+ * Perform the upgrade.
+ *
+ * @param $register_errors
+ * Register the errors during the upgrade process as failures.
+ * @return
+ * TRUE if the upgrade succeeded, FALSE otherwise.
+ */
+ protected function performUpgrade($register_errors = TRUE) {
+ $update_url = $GLOBALS['base_url'] . '/core/update.php';
+
+ if (!empty($this->missing_zlib_requirement)) {
+ $this->fail(t('Missing zlib requirement for upgrade tests.'));
+ return FALSE;
+ }
+
+ // Load the first update screen.
+ $this->drupalGet($update_url, array('external' => TRUE));
+ if (!$this->assertResponse(200)) {
+ return FALSE;
+ }
+
+ // Continue.
+ $this->drupalPost(NULL, array(), t('Continue'));
+ if (!$this->assertResponse(200)) {
+ return FALSE;
+ }
+
+ // Go!
+ $this->drupalPost(NULL, array(), t('Apply pending updates'));
+ if (!$this->assertResponse(200)) {
+ return FALSE;
+ }
+
+ // Check for errors during the update process.
+ foreach ($this->xpath('//li[@class=:class]', array(':class' => 'failure')) as $element) {
+ $message = strip_tags($element->asXML());
+ $this->upgradeErrors[] = $message;
+ if ($register_errors) {
+ $this->fail($message);
+ }
+ }
+
+ if (!empty($this->upgradeErrors)) {
+ // Upgrade failed, the installation might be in an inconsistent state,
+ // don't process.
+ return FALSE;
+ }
+
+ // Check if there still are pending updates.
+ $this->drupalGet($update_url, array('external' => TRUE));
+ $this->drupalPost(NULL, array(), t('Continue'));
+ if (!$this->assertText(t('No pending updates.'), t('No pending updates at the end of the update process.'))) {
+ return FALSE;
+ }
+
+ // Upgrade succeed, rebuild the environment so that we can call the API
+ // of the child site directly from this request.
+ $this->upgradedSite = TRUE;
+
+ // Reload module list. For modules that are enabled in the test database,
+ // but not on the test client, we need to load the code here.
+ $new_modules = array_diff(module_list(TRUE), $this->loadedModules);
+ foreach ($new_modules as $module) {
+ drupal_load('module', $module);
+ }
+
+ // Reload hook implementations
+ module_implements_reset();
+
+ // Rebuild caches.
+ drupal_static_reset();
+ drupal_flush_all_caches();
+
+ // Reload global $conf array and permissions.
+ $this->refreshVariables();
+ $this->checkPermissions(array(), TRUE);
+
+ return TRUE;
+ }
+
+ /**
+ * Force uninstall all modules from a test database, except those listed.
+ *
+ * @param $modules
+ * The list of modules to keep installed. Required core modules will
+ * always be kept.
+ */
+ protected function uninstallModulesExcept(array $modules) {
+ $required_modules = array('block', 'dblog', 'filter', 'node', 'system', 'update', 'user');
+
+ $modules = array_merge($required_modules, $modules);
+
+ db_delete('system')
+ ->condition('type', 'module')
+ ->condition('name', $modules, 'NOT IN')
+ ->execute();
+ }
+
+}
diff --git a/core/modules/simpletest/tests/upgrade/upgrade_bare.test b/core/modules/simpletest/tests/upgrade/upgrade_bare.test
new file mode 100644
index 000000000000..db71228575c9
--- /dev/null
+++ b/core/modules/simpletest/tests/upgrade/upgrade_bare.test
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * Upgrade test for the bare database..
+ *
+ * Load an empty installation of Drupal 7 and run the upgrade process on it.
+ */
+class BareUpgradePathTestCase extends UpgradePathTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Bare upgrade test',
+ 'description' => 'Bare upgrade test.',
+ 'group' => 'Upgrade path',
+ );
+ }
+
+ public function setUp() {
+ // Path to the database dump.
+ $this->databaseDumpFiles = array(
+ drupal_get_path('module', 'simpletest') . '/tests/upgrade/drupal-7.bare.database.php.gz',
+ );
+ parent::setUp();
+ }
+
+ /**
+ * Test a successful upgrade.
+ */
+ public function testBareUpgrade() {
+ $this->assertTrue($this->performUpgrade(), t('The upgrade was completed successfully.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/upgrade/upgrade_filled.test b/core/modules/simpletest/tests/upgrade/upgrade_filled.test
new file mode 100644
index 000000000000..9b17bda4caa4
--- /dev/null
+++ b/core/modules/simpletest/tests/upgrade/upgrade_filled.test
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * Upgrade test for the filled database..
+ *
+ * Load a filled installation of Drupal 7 and run the upgrade process on it.
+ */
+class FilledUpgradePathTestCase extends UpgradePathTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Filled upgrade test',
+ 'description' => 'Filled upgrade test.',
+ 'group' => 'Upgrade path',
+ );
+ }
+
+ public function setUp() {
+ // Path to the database dump.
+ $this->databaseDumpFiles = array(
+ drupal_get_path('module', 'simpletest') . '/tests/upgrade/drupal-7.filled.database.php.gz',
+ );
+ parent::setUp();
+ }
+
+ /**
+ * Test a successful upgrade.
+ */
+ public function testFilledUpgrade() {
+ $this->assertTrue($this->performUpgrade(), t('The upgrade was completed successfully.'));
+ }
+}
diff --git a/core/modules/simpletest/tests/url_alter_test.info b/core/modules/simpletest/tests/url_alter_test.info
new file mode 100644
index 000000000000..1947b2e7fb64
--- /dev/null
+++ b/core/modules/simpletest/tests/url_alter_test.info
@@ -0,0 +1,6 @@
+name = Url_alter tests
+description = A support modules for url_alter hook testing.
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/url_alter_test.install b/core/modules/simpletest/tests/url_alter_test.install
new file mode 100644
index 000000000000..6e09ab5838a7
--- /dev/null
+++ b/core/modules/simpletest/tests/url_alter_test.install
@@ -0,0 +1,12 @@
+<?php
+
+/**
+ * Impelement hook_install().
+ */
+function url_alter_test_install() {
+ // Set the weight of this module to one higher than forum.module.
+ db_update('system')
+ ->fields(array('weight' => 2))
+ ->condition('name', 'url_alter_test')
+ ->execute();
+}
diff --git a/core/modules/simpletest/tests/url_alter_test.module b/core/modules/simpletest/tests/url_alter_test.module
new file mode 100644
index 000000000000..e229ab986527
--- /dev/null
+++ b/core/modules/simpletest/tests/url_alter_test.module
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @file
+ * Module to help test hook_url_inbound_alter() and hook_url_outbound_alter().
+ */
+
+/**
+ * Implements hook_menu().
+ */
+function url_alter_test_menu() {
+ $items['url-alter-test/foo'] = array(
+ 'title' => 'Foo',
+ 'page callback' => 'url_alter_test_foo',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+ return $items;
+}
+
+/**
+ * Menu callback.
+ */
+function url_alter_test_foo() {
+ print 'current_path=' . current_path() . ' request_path=' . request_path();
+ exit;
+}
+
+/**
+ * Implements hook_url_inbound_alter().
+ */
+function url_alter_test_url_inbound_alter(&$path, $original_path, $path_language) {
+ // Rewrite user/username to user/uid.
+ if (preg_match('!^user/([^/]+)(/.*)?!', $path, $matches)) {
+ if ($account = user_load_by_name($matches[1])) {
+ $matches += array(2 => '');
+ $path = 'user/' . $account->uid . $matches[2];
+ }
+ }
+
+ // Rewrite community/ to forum/.
+ if ($path == 'community' || strpos($path, 'community/') === 0) {
+ $path = 'forum' . substr($path, 9);
+ }
+
+ if ($path == 'url-alter-test/bar') {
+ $path = 'url-alter-test/foo';
+ }
+}
+
+/**
+ * Implements hook_url_outbound_alter().
+ */
+function url_alter_test_url_outbound_alter(&$path, &$options, $original_path) {
+ // Rewrite user/uid to user/username.
+ if (preg_match('!^user/([0-9]+)(/.*)?!', $path, $matches)) {
+ if ($account = user_load($matches[1])) {
+ $matches += array(2 => '');
+ $path = 'user/' . $account->name . $matches[2];
+ }
+ }
+
+ // Rewrite forum/ to community/.
+ if ($path == 'forum' || strpos($path, 'forum/') === 0) {
+ $path = 'community' . substr($path, 5);
+ }
+}
diff --git a/core/modules/simpletest/tests/xmlrpc.test b/core/modules/simpletest/tests/xmlrpc.test
new file mode 100644
index 000000000000..60b96247cfc4
--- /dev/null
+++ b/core/modules/simpletest/tests/xmlrpc.test
@@ -0,0 +1,244 @@
+<?php
+
+/**
+ * Perform basic XML-RPC tests that do not require addition callbacks.
+ */
+class XMLRPCBasicTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'XML-RPC basic',
+ 'description' => 'Perform basic XML-RPC tests that do not require additional callbacks.',
+ 'group' => 'XML-RPC',
+ );
+ }
+
+ /**
+ * Ensure that a basic XML-RPC call with no parameters works.
+ */
+ protected function testListMethods() {
+ // Minimum list of methods that should be included.
+ $minimum = array(
+ 'system.multicall',
+ 'system.methodSignature',
+ 'system.getCapabilities',
+ 'system.listMethods',
+ 'system.methodHelp',
+ );
+
+ // Invoke XML-RPC call to get list of methods.
+ $url = url(NULL, array('absolute' => TRUE)) . 'core/xmlrpc.php';
+ $methods = xmlrpc($url, array('system.listMethods' => array()));
+
+ // Ensure that the minimum methods were found.
+ $count = 0;
+ foreach ($methods as $method) {
+ if (in_array($method, $minimum)) {
+ $count++;
+ }
+ }
+
+ $this->assertEqual($count, count($minimum), 'system.listMethods returned at least the minimum listing');
+ }
+
+ /**
+ * Ensure that system.methodSignature returns an array of signatures.
+ */
+ protected function testMethodSignature() {
+ $url = url(NULL, array('absolute' => TRUE)) . 'core/xmlrpc.php';
+ $signature = xmlrpc($url, array('system.methodSignature' => array('system.listMethods')));
+ $this->assert(is_array($signature) && !empty($signature) && is_array($signature[0]),
+ t('system.methodSignature returns an array of signature arrays.'));
+ }
+
+ /**
+ * Ensure that XML-RPC correctly handles invalid messages when parsing.
+ */
+ protected function testInvalidMessageParsing() {
+ $invalid_messages = array(
+ array(
+ 'message' => xmlrpc_message(''),
+ 'assertion' => t('Empty message correctly rejected during parsing.'),
+ ),
+ array(
+ 'message' => xmlrpc_message('<?xml version="1.0" encoding="ISO-8859-1"?>'),
+ 'assertion' => t('Empty message with XML declaration correctly rejected during parsing.'),
+ ),
+ array(
+ 'message' => xmlrpc_message('<?xml version="1.0"?><params><param><value><string>value</string></value></param></params>'),
+ 'assertion' => t('Non-empty message without a valid message type is rejected during parsing.'),
+ ),
+ array(
+ 'message' => xmlrpc_message('<methodResponse><params><param><value><string>value</string></value></param></methodResponse>'),
+ 'assertion' => t('Non-empty malformed message is rejected during parsing.'),
+ ),
+ );
+
+ foreach ($invalid_messages as $assertion) {
+ $this->assertFalse(xmlrpc_message_parse($assertion['message']), $assertion['assertion']);
+ }
+ }
+}
+
+class XMLRPCValidator1IncTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'XML-RPC validator',
+ 'description' => 'See <a href="http://www.xmlrpc.com/validator1Docs">the xmlrpc validator1 specification</a>.',
+ 'group' => 'XML-RPC',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('xmlrpc_test');
+ }
+
+ /**
+ * Run validator1 tests.
+ */
+ function testValidator1() {
+ $xml_url = url(NULL, array('absolute' => TRUE)) . 'core/xmlrpc.php';
+ srand();
+ mt_srand();
+
+ $array_1 = array(array('curly' => mt_rand(-100, 100)),
+ array('curly' => mt_rand(-100, 100)),
+ array('larry' => mt_rand(-100, 100)),
+ array('larry' => mt_rand(-100, 100)),
+ array('moe' => mt_rand(-100, 100)),
+ array('moe' => mt_rand(-100, 100)),
+ array('larry' => mt_rand(-100, 100)));
+ shuffle($array_1);
+ $l_res_1 = xmlrpc_test_arrayOfStructsTest($array_1);
+ $r_res_1 = xmlrpc($xml_url, array('validator1.arrayOfStructsTest' => array($array_1)));
+ $this->assertIdentical($l_res_1, $r_res_1);
+
+ $string_2 = 't\'&>>zf"md>yr>xlcev<h<"k&j<og"w&&>">>uai"np&s>>q\'&b<>"&&&';
+ $l_res_2 = xmlrpc_test_countTheEntities($string_2);
+ $r_res_2 = xmlrpc($xml_url, array('validator1.countTheEntities' => array($string_2)));
+ $this->assertIdentical($l_res_2, $r_res_2);
+
+ $struct_3 = array('moe' => mt_rand(-100, 100), 'larry' => mt_rand(-100, 100), 'curly' => mt_rand(-100, 100), 'homer' => mt_rand(-100, 100));
+ $l_res_3 = xmlrpc_test_easyStructTest($struct_3);
+ $r_res_3 = xmlrpc($xml_url, array('validator1.easyStructTest' => array($struct_3)));
+ $this->assertIdentical($l_res_3, $r_res_3);
+
+ $struct_4 = array('sub1' => array('bar' => 13),
+ 'sub2' => 14,
+ 'sub3' => array('foo' => 1, 'baz' => 2),
+ 'sub4' => array('ss' => array('sss' => array('ssss' => 'sssss'))));
+ $l_res_4 = xmlrpc_test_echoStructTest($struct_4);
+ $r_res_4 = xmlrpc($xml_url, array('validator1.echoStructTest' => array($struct_4)));
+ $this->assertIdentical($l_res_4, $r_res_4);
+
+ $int_5 = mt_rand(-100, 100);
+ $bool_5 = (($int_5 % 2) == 0);
+ $string_5 = $this->randomName();
+ $double_5 = (double)(mt_rand(-1000, 1000) / 100);
+ $time_5 = REQUEST_TIME;
+ $base64_5 = $this->randomName(100);
+ $l_res_5 = xmlrpc_test_manyTypesTest($int_5, $bool_5, $string_5, $double_5, xmlrpc_date($time_5), $base64_5);
+ // See http://drupal.org/node/37766 why this currently fails
+ $l_res_5[5] = $l_res_5[5]->data;
+ $r_res_5 = xmlrpc($xml_url, array('validator1.manyTypesTest' => array($int_5, $bool_5, $string_5, $double_5, xmlrpc_date($time_5), xmlrpc_base64($base64_5))));
+ // @todo Contains objects, objects are not equal.
+ $this->assertEqual($l_res_5, $r_res_5);
+
+ $size = mt_rand(100, 200);
+ $array_6 = array();
+ for ($i = 0; $i < $size; $i++) {
+ $array_6[] = $this->randomName(mt_rand(8, 12));
+ }
+
+ $l_res_6 = xmlrpc_test_moderateSizeArrayCheck($array_6);
+ $r_res_6 = xmlrpc($xml_url, array('validator1.moderateSizeArrayCheck' => array($array_6)));
+ $this->assertIdentical($l_res_6, $r_res_6);
+
+ $struct_7 = array();
+ for ($y = 2000; $y < 2002; $y++) {
+ for ($m = 3; $m < 5; $m++) {
+ for ($d = 1; $d < 6; $d++) {
+ $ys = (string) $y;
+ $ms = sprintf('%02d', $m);
+ $ds = sprintf('%02d', $d);
+ $struct_7[$ys][$ms][$ds]['moe'] = mt_rand(-100, 100);
+ $struct_7[$ys][$ms][$ds]['larry'] = mt_rand(-100, 100);
+ $struct_7[$ys][$ms][$ds]['curly'] = mt_rand(-100, 100);
+ }
+ }
+ }
+ $l_res_7 = xmlrpc_test_nestedStructTest($struct_7);
+ $r_res_7 = xmlrpc($xml_url, array('validator1.nestedStructTest' => array($struct_7)));
+ $this->assertIdentical($l_res_7, $r_res_7);
+
+
+ $int_8 = mt_rand(-100, 100);
+ $l_res_8 = xmlrpc_test_simpleStructReturnTest($int_8);
+ $r_res_8 = xmlrpc($xml_url, array('validator1.simpleStructReturnTest' => array($int_8)));
+ $this->assertIdentical($l_res_8, $r_res_8);
+
+ /* Now test multicall */
+ $x = array();
+ $x['validator1.arrayOfStructsTest'] = array($array_1);
+ $x['validator1.countTheEntities'] = array($string_2);
+ $x['validator1.easyStructTest'] = array($struct_3);
+ $x['validator1.echoStructTest'] = array($struct_4);
+ $x['validator1.manyTypesTest'] = array($int_5, $bool_5, $string_5, $double_5, xmlrpc_date($time_5), xmlrpc_base64($base64_5));
+ $x['validator1.moderateSizeArrayCheck'] = array($array_6);
+ $x['validator1.nestedStructTest'] = array($struct_7);
+ $x['validator1.simpleStructReturnTest'] = array($int_8);
+
+ $a_l_res = array($l_res_1, $l_res_2, $l_res_3, $l_res_4, $l_res_5, $l_res_6, $l_res_7, $l_res_8);
+ $a_r_res = xmlrpc($xml_url, $x);
+ $this->assertEqual($a_l_res, $a_r_res);
+ }
+}
+
+class XMLRPCMessagesTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'XML-RPC message and alteration',
+ 'description' => 'Test large messages and method alterations.',
+ 'group' => 'XML-RPC',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('xmlrpc_test');
+ }
+
+ /**
+ * Make sure that XML-RPC can transfer large messages.
+ */
+ function testSizedMessages() {
+ $xml_url = url(NULL, array('absolute' => TRUE)) . 'core/xmlrpc.php';
+ $sizes = array(8, 80, 160);
+ foreach ($sizes as $size) {
+ $xml_message_l = xmlrpc_test_message_sized_in_kb($size);
+ $xml_message_r = xmlrpc($xml_url, array('messages.messageSizedInKB' => array($size)));
+
+ $this->assertEqual($xml_message_l, $xml_message_r, t('XML-RPC messages.messageSizedInKB of %s Kb size received', array('%s' => $size)));
+ }
+ }
+
+ /**
+ * Ensure that hook_xmlrpc_alter() can hide even builtin methods.
+ */
+ protected function testAlterListMethods() {
+
+ // Ensure xmlrpc_test_xmlrpc_alter() is disabled and retrieve regular list of methods.
+ variable_set('xmlrpc_test_xmlrpc_alter', FALSE);
+ $url = url(NULL, array('absolute' => TRUE)) . 'core/xmlrpc.php';
+ $methods1 = xmlrpc($url, array('system.listMethods' => array()));
+
+ // Enable the alter hook and retrieve the list of methods again.
+ variable_set('xmlrpc_test_xmlrpc_alter', TRUE);
+ $methods2 = xmlrpc($url, array('system.listMethods' => array()));
+
+ $diff = array_diff($methods1, $methods2);
+ $this->assertTrue(is_array($diff) && !empty($diff), t('Method list is altered by hook_xmlrpc_alter'));
+ $removed = reset($diff);
+ $this->assertEqual($removed, 'system.methodSignature', t('Hiding builting system.methodSignature with hook_xmlrpc_alter works'));
+ }
+
+}
diff --git a/core/modules/simpletest/tests/xmlrpc_test.info b/core/modules/simpletest/tests/xmlrpc_test.info
new file mode 100644
index 000000000000..6985439715bb
--- /dev/null
+++ b/core/modules/simpletest/tests/xmlrpc_test.info
@@ -0,0 +1,6 @@
+name = "XML-RPC Test"
+description = "Support module for XML-RPC tests according to the validator1 specification."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/simpletest/tests/xmlrpc_test.module b/core/modules/simpletest/tests/xmlrpc_test.module
new file mode 100644
index 000000000000..db8f113b6b5e
--- /dev/null
+++ b/core/modules/simpletest/tests/xmlrpc_test.module
@@ -0,0 +1,111 @@
+<?php
+
+function xmlrpc_test_arrayOfStructsTest($array) {
+ $sum = 0;
+ foreach ($array as $struct) {
+ if (isset($struct['curly'])) {
+ $sum += $struct['curly'];
+ }
+ }
+ return $sum;
+}
+
+function xmlrpc_test_countTheEntities($string) {
+ return array(
+ 'ctLeftAngleBrackets' => substr_count($string, '<'),
+ 'ctRightAngleBrackets' => substr_count($string, '>'),
+ 'ctAmpersands' => substr_count($string, '&'),
+ 'ctApostrophes' => substr_count($string, "'"),
+ 'ctQuotes' => substr_count($string, '"'),
+ );
+}
+
+function xmlrpc_test_easyStructTest($array) {
+ return $array["curly"] + $array["moe"] + $array["larry"];
+}
+
+function xmlrpc_test_echoStructTest($array) {
+ return $array;
+}
+
+function xmlrpc_test_manyTypesTest($number, $boolean, $string, $double, $dateTime, $base64) {
+ $timestamp = gmmktime($dateTime->hour, $dateTime->minute, $dateTime->second, $dateTime->month, $dateTime->day, $dateTime->year);
+ return array($number, $boolean, $string, $double, xmlrpc_date($timestamp), xmlrpc_Base64($base64));
+}
+
+function xmlrpc_test_moderateSizeArrayCheck($array) {
+ return array_shift($array) . array_pop($array);
+}
+
+function xmlrpc_test_nestedStructTest($array) {
+ return $array["2000"]["04"]["01"]["larry"] + $array["2000"]["04"]["01"]["moe"] + $array["2000"]["04"]["01"]["curly"];
+}
+
+function xmlrpc_test_simpleStructReturnTest($number) {
+ return array("times10" => ($number*10), "times100" => ($number*100), "times1000" => ($number*1000));
+}
+
+/**
+ * Implements hook_xmlrpc().
+ */
+function xmlrpc_test_xmlrpc() {
+ return array(
+ 'validator1.arrayOfStructsTest' => 'xmlrpc_test_arrayOfStructsTest',
+ 'validator1.countTheEntities' => 'xmlrpc_test_countTheEntities',
+ 'validator1.easyStructTest' => 'xmlrpc_test_easyStructTest',
+ 'validator1.echoStructTest' => 'xmlrpc_test_echoStructTest',
+ 'validator1.manyTypesTest' => 'xmlrpc_test_manyTypesTest',
+ 'validator1.moderateSizeArrayCheck' => 'xmlrpc_test_moderateSizeArrayCheck',
+ 'validator1.nestedStructTest' => 'xmlrpc_test_nestedStructTest',
+ 'validator1.simpleStructReturnTest' => 'xmlrpc_test_simpleStructReturnTest',
+ 'messages.messageSizedInKB' => 'xmlrpc_test_message_sized_in_kb',
+ );
+}
+
+/**
+ * Implements hook_xmlrpc_alter().
+ *
+ * Hide (or not) the system.methodSignature() service depending on a variable.
+ */
+function xmlrpc_test_xmlrpc_alter(&$services) {
+ if (variable_get('xmlrpc_test_xmlrpc_alter', FALSE)) {
+ $remove = NULL;
+ foreach ($services as $key => $value) {
+ if (!is_array($value)) {
+ continue;
+ }
+ if ($value[0] == 'system.methodSignature') {
+ $remove = $key;
+ break;
+ }
+ }
+ if (isset($remove)) {
+ unset($services[$remove]);
+ }
+ }
+}
+
+/**
+ * Created a message of the desired size in KB.
+ *
+ * @param $size
+ * Message size in KB.
+ * @return array
+ * Generated message structure.
+ */
+function xmlrpc_test_message_sized_in_kb($size) {
+ $message = array();
+
+ $word = 'abcdefg';
+
+ // Create a ~1KB sized struct.
+ for ($i = 0 ; $i < 128; $i++) {
+ $line['word_' . $i] = $word;
+ }
+
+ for ($i = 0; $i < $size; $i++) {
+ $message['line_' . $i] = $line;
+ }
+
+ return $message;
+}
diff --git a/core/modules/statistics/statistics.admin.inc b/core/modules/statistics/statistics.admin.inc
new file mode 100644
index 000000000000..6606b8b95616
--- /dev/null
+++ b/core/modules/statistics/statistics.admin.inc
@@ -0,0 +1,274 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the statistics module.
+ */
+
+/**
+ * Menu callback; presents the "recent hits" page.
+ */
+function statistics_recent_hits() {
+ $header = array(
+ array('data' => t('Timestamp'), 'field' => 'a.timestamp', 'sort' => 'desc'),
+ array('data' => t('Page'), 'field' => 'a.path'),
+ array('data' => t('User'), 'field' => 'u.name'),
+ array('data' => t('Operations'))
+ );
+
+ $query = db_select('accesslog', 'a', array('target' => 'slave'))->extend('PagerDefault')->extend('TableSort');
+ $query->join('users', 'u', 'a.uid = u.uid');
+ $query
+ ->fields('a', array('aid', 'timestamp', 'path', 'title', 'uid'))
+ ->fields('u', array('name'))
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $result = $query->execute();
+ $rows = array();
+ foreach ($result as $log) {
+ $rows[] = array(
+ array('data' => format_date($log->timestamp, 'short'), 'class' => array('nowrap')),
+ _statistics_format_item($log->title, $log->path),
+ theme('username', array('account' => $log)),
+ l(t('details'), "admin/reports/access/$log->aid"));
+ }
+
+ $build['statistics_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_pager'] = array('#theme' => 'pager');
+ return $build;
+}
+
+/**
+ * Menu callback; presents the "top pages" page.
+ */
+function statistics_top_pages() {
+ $header = array(
+ array('data' => t('Hits'), 'field' => 'hits', 'sort' => 'desc'),
+ array('data' => t('Page'), 'field' => 'path'),
+ array('data' => t('Average page generation time'), 'field' => 'average_time'),
+ array('data' => t('Total page generation time'), 'field' => 'total_time')
+ );
+
+ $query = db_select('accesslog', 'a', array('target' => 'slave'))->extend('PagerDefault')->extend('TableSort');
+ $query->addExpression('COUNT(path)', 'hits');
+ // MAX(title) avoids having empty node titles which otherwise causes duplicates in the top pages list
+ $query->addExpression('MAX(title)', 'title');
+ $query->addExpression('AVG(timer)', 'average_time');
+ $query->addExpression('SUM(timer)', 'total_time');
+
+ $query
+ ->fields('a', array('path'))
+ ->groupBy('path')
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $count_query = db_select('accesslog', 'a', array('target' => 'slave'));
+ $count_query->addExpression('COUNT(DISTINCT path)');
+ $query->setCountQuery($count_query);
+
+ $result = $query->execute();
+ $rows = array();
+ foreach ($result as $page) {
+ $rows[] = array($page->hits, _statistics_format_item($page->title, $page->path), t('%time ms', array('%time' => round($page->average_time))), format_interval(round($page->total_time / 1000)));
+ }
+
+ drupal_set_title(t('Top pages in the past %interval', array('%interval' => format_interval(variable_get('statistics_flush_accesslog_timer', 259200)))), PASS_THROUGH);
+ $build['statistics_top_pages_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_top_pages_pager'] = array('#theme' => 'pager');
+ return $build;
+}
+
+/**
+ * Menu callback; presents the "top visitors" page.
+ */
+function statistics_top_visitors() {
+
+ $header = array(
+ array('data' => t('Hits'), 'field' => 'hits', 'sort' => 'desc'),
+ array('data' => t('Visitor'), 'field' => 'u.name'),
+ array('data' => t('Total page generation time'), 'field' => 'total'),
+ array('data' => user_access('block IP addresses') ? t('Operations') : '', 'colspan' => 2),
+ );
+ $query = db_select('accesslog', 'a', array('target' => 'slave'))->extend('PagerDefault')->extend('TableSort');
+ $query->leftJoin('blocked_ips', 'bl', 'a.hostname = bl.ip');
+ $query->leftJoin('users', 'u', 'a.uid = u.uid');
+
+ $query->addExpression('COUNT(a.uid)', 'hits');
+ $query->addExpression('SUM(a.timer)', 'total');
+ $query
+ ->fields('a', array('uid', 'hostname'))
+ ->fields('u', array('name'))
+ ->fields('bl', array('iid'))
+ ->groupBy('a.hostname')
+ ->groupBy('a.uid')
+ ->groupBy('u.name')
+ ->groupBy('bl.iid')
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $uniques_query = db_select('accesslog')->distinct();
+ $uniques_query->fields('accesslog', array('uid', 'hostname'));
+ $count_query = db_select($uniques_query);
+ $count_query->addExpression('COUNT(*)');
+ $query->setCountQuery($count_query);
+
+ $result = $query->execute();
+ $rows = array();
+ $destination = drupal_get_destination();
+ foreach ($result as $account) {
+ $ban_link = $account->iid ? l(t('unblock IP address'), "admin/config/people/ip-blocking/delete/$account->iid", array('query' => $destination)) : l(t('block IP address'), "admin/config/people/ip-blocking/$account->hostname", array('query' => $destination));
+ $rows[] = array($account->hits, ($account->uid ? theme('username', array('account' => $account)) : $account->hostname), format_interval(round($account->total / 1000)), (user_access('block IP addresses') && !$account->uid) ? $ban_link : '');
+ }
+
+ drupal_set_title(t('Top visitors in the past %interval', array('%interval' => format_interval(variable_get('statistics_flush_accesslog_timer', 259200)))), PASS_THROUGH);
+ $build['statistics_top_visitors_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_top_visitors_pager'] = array('#theme' => 'pager');
+ return $build;
+}
+
+/**
+ * Menu callback; presents the "referrer" page.
+ */
+function statistics_top_referrers() {
+ drupal_set_title(t('Top referrers in the past %interval', array('%interval' => format_interval(variable_get('statistics_flush_accesslog_timer', 259200)))), PASS_THROUGH);
+
+ $header = array(
+ array('data' => t('Hits'), 'field' => 'hits', 'sort' => 'desc'),
+ array('data' => t('Url'), 'field' => 'url'),
+ array('data' => t('Last visit'), 'field' => 'last'),
+ );
+ $query = db_select('accesslog', 'a')->extend('PagerDefault')->extend('TableSort');
+
+ $query->addExpression('COUNT(url)', 'hits');
+ $query->addExpression('MAX(timestamp)', 'last');
+ $query
+ ->fields('a', array('url'))
+ ->condition('url', '%' . $_SERVER['HTTP_HOST'] . '%', 'NOT LIKE')
+ ->condition('url', '', '<>')
+ ->groupBy('url')
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $count_query = db_select('accesslog', 'a', array('target' => 'slave'));
+ $count_query->addExpression('COUNT(DISTINCT url)');
+ $count_query
+ ->condition('url', '%' . $_SERVER['HTTP_HOST'] . '%', 'NOT LIKE')
+ ->condition('url', '', '<>');
+ $query->setCountQuery($count_query);
+
+ $result = $query->execute();
+ $rows = array();
+ foreach ($result as $referrer) {
+ $rows[] = array($referrer->hits, _statistics_link($referrer->url), t('@time ago', array('@time' => format_interval(REQUEST_TIME - $referrer->last))));
+ }
+
+ $build['statistics_top_referrers_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_top_referrers_pager'] = array('#theme' => 'pager');
+ return $build;
+}
+
+/**
+ * Menu callback; Displays recent page accesses.
+ */
+function statistics_access_log($aid) {
+ $access = db_query('SELECT a.*, u.name FROM {accesslog} a LEFT JOIN {users} u ON a.uid = u.uid WHERE aid = :aid', array(':aid' => $aid))->fetch();
+ if ($access) {
+ $rows[] = array(
+ array('data' => t('URL'), 'header' => TRUE),
+ l(url($access->path, array('absolute' => TRUE)), $access->path)
+ );
+ // It is safe to avoid filtering $access->title through check_plain because
+ // it comes from drupal_get_title().
+ $rows[] = array(
+ array('data' => t('Title'), 'header' => TRUE),
+ $access->title
+ );
+ $rows[] = array(
+ array('data' => t('Referrer'), 'header' => TRUE),
+ ($access->url ? l($access->url, $access->url) : '')
+ );
+ $rows[] = array(
+ array('data' => t('Date'), 'header' => TRUE),
+ format_date($access->timestamp, 'long')
+ );
+ $rows[] = array(
+ array('data' => t('User'), 'header' => TRUE),
+ theme('username', array('account' => $access))
+ );
+ $rows[] = array(
+ array('data' => t('Hostname'), 'header' => TRUE),
+ check_plain($access->hostname)
+ );
+
+ $build['statistics_table'] = array(
+ '#theme' => 'table',
+ '#rows' => $rows,
+ );
+ return $build;
+ }
+ else {
+ drupal_not_found();
+ }
+}
+
+/**
+ * Form builder; Configure access logging.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function statistics_settings_form() {
+ // Access log settings.
+ $form['access'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Access log settings'),
+ );
+ $form['access']['statistics_enable_access_log'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable access log'),
+ '#default_value' => variable_get('statistics_enable_access_log', 0),
+ '#description' => t('Log each page access. Required for referrer statistics.'),
+ );
+ $form['access']['statistics_flush_accesslog_timer'] = array(
+ '#type' => 'select',
+ '#title' => t('Discard access logs older than'),
+ '#default_value' => variable_get('statistics_flush_accesslog_timer', 259200),
+ '#options' => array(0 => t('Never')) + drupal_map_assoc(array(3600, 10800, 21600, 32400, 43200, 86400, 172800, 259200, 604800, 1209600, 2419200, 4838400, 9676800), 'format_interval'),
+ '#description' => t('Older access log entries (including referrer statistics) will be automatically discarded. (Requires a correctly configured <a href="@cron">cron maintenance task</a>.)', array('@cron' => url('admin/reports/status'))),
+ );
+
+ // Content counter settings.
+ $form['content'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Content viewing counter settings'),
+ );
+ $form['content']['statistics_count_content_views'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Count content views'),
+ '#default_value' => variable_get('statistics_count_content_views', 0),
+ '#description' => t('Increment a counter each time content is viewed.'),
+ );
+
+ return system_settings_form($form);
+}
diff --git a/core/modules/statistics/statistics.info b/core/modules/statistics/statistics.info
new file mode 100644
index 000000000000..e7add6033adf
--- /dev/null
+++ b/core/modules/statistics/statistics.info
@@ -0,0 +1,7 @@
+name = Statistics
+description = Logs access statistics for your site.
+package = Core
+version = VERSION
+core = 8.x
+files[] = statistics.test
+configure = admin/config/system/statistics
diff --git a/core/modules/statistics/statistics.install b/core/modules/statistics/statistics.install
new file mode 100644
index 000000000000..a5dc7f8f950e
--- /dev/null
+++ b/core/modules/statistics/statistics.install
@@ -0,0 +1,136 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the statistics module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function statistics_uninstall() {
+ // Remove variables.
+ variable_del('statistics_count_content_views');
+ variable_del('statistics_enable_access_log');
+ variable_del('statistics_flush_accesslog_timer');
+ variable_del('statistics_day_timestamp');
+ variable_del('statistics_block_top_day_num');
+ variable_del('statistics_block_top_all_num');
+ variable_del('statistics_block_top_last_num');
+}
+
+/**
+ * Implements hook_schema().
+ */
+function statistics_schema() {
+ $schema['accesslog'] = array(
+ 'description' => 'Stores site access information for statistics.',
+ 'fields' => array(
+ 'aid' => array(
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique accesslog ID.',
+ ),
+ 'sid' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Browser session ID of user that visited page.',
+ ),
+ 'title' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'Title of page visited.',
+ ),
+ 'path' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'Internal path to page visited (relative to Drupal root.)',
+ ),
+ 'url' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'description' => 'Referrer URI.',
+ ),
+ 'hostname' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => FALSE,
+ 'description' => 'Hostname of user that visited the page.',
+ ),
+ 'uid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => FALSE,
+ 'default' => 0,
+ 'description' => 'User {users}.uid that visited the page.',
+ ),
+ 'timer' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Time in milliseconds that the page took to load.',
+ ),
+ 'timestamp' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Timestamp of when the page was visited.',
+ ),
+ ),
+ 'indexes' => array(
+ 'accesslog_timestamp' => array('timestamp'),
+ 'uid' => array('uid'),
+ ),
+ 'primary key' => array('aid'),
+ 'foreign keys' => array(
+ 'visitor' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['node_counter'] = array(
+ 'description' => 'Access statistics for {node}s.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid for these statistics.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'totalcount' => array(
+ 'description' => 'The total number of times the {node} has been viewed.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'big',
+ ),
+ 'daycount' => array(
+ 'description' => 'The total number of times the {node} has been viewed today.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'medium',
+ ),
+ 'timestamp' => array(
+ 'description' => 'The most recent time the {node} has been viewed.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('nid'),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/statistics/statistics.module b/core/modules/statistics/statistics.module
new file mode 100644
index 000000000000..6f015359c74f
--- /dev/null
+++ b/core/modules/statistics/statistics.module
@@ -0,0 +1,430 @@
+<?php
+
+/**
+ * @file
+ * Logs access statistics for your site.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function statistics_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#statistics':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Statistics module shows you how often a given page is viewed, who viewed it, the previous page the user visited (referrer URL), and when it was viewed. These statistics are useful in determining how users are visiting and navigating your site. For more information, see the online handbook entry for the <a href="@statistics">Statistics module</a>.', array('@statistics' => url('http://drupal.org/handbook/modules/statistics/'))) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Managing logs') . '</dt>';
+ $output .= '<dd>' . t('To enable collection of statistics, the <em>Enable access log</em> checkbox on the <a href="@statistics-settings">Statistics settings page</a> must be checked. The <em>Discard access logs older than</em> setting on the settings page specifies the length of time entries are kept in the log before they are deleted. This setting requires a correctly configured <a href="@cron">cron maintenance task</a> to run.', array('@statistics-settings' => url('admin/config/system/statistics'), '@cron' => 'http://drupal.org/cron')) . '</dd>';
+ $output .= '<dt>' . t('Viewing site usage') . '</dt>';
+ $output .= '<dd>' . t('The Statistics module can help you break down details about your users and how they are using the site. The module offers four reports:');
+ $output .= '<ul><li>' . t('<a href="@recent-hits">Recent hits</a> displays information about the latest activity on your site, including the URL and title of the page that was accessed, the user name (if available) and the IP address of the viewer.', array('@recent-hits' => url('admin/reports/hits'))) . '</li>';
+ $output .= '<li>' . t('<a href="@top-referrers">Top referrers</a> displays where visitors came from (referrer URL).', array('@top-referrers' => url('admin/reports/referrers'))) . '</li>';
+ $output .= '<li>' . t('<a href="@top-pages">Top pages</a> displays a list of pages ordered by how often they were viewed.', array('@top-pages' => url('admin/reports/pages'))) . '</li>';
+ $output .= '<li>' . t('<a href="@top-visitors">Top visitors</a> shows you the most active visitors for your site and allows you to ban abusive visitors.', array('@top-visitors' => url('admin/reports/visitors'))) . '</li></ul>';
+ $output .= '<dt>' . t('Displaying popular content') . '</dt>';
+ $output .= '<dd>' . t('The module includes a <em>Popular content</em> block that displays the most viewed pages today and for all time, and the last content viewed. To use the block, enable <em>Count content views</em> on the <a href="@statistics-settings">statistics settings page</a>, and then you can enable and configure the block on the <a href="@blocks">blocks administration page</a>.', array('@statistics-settings' => url('admin/config/system/statistics'), '@blocks' => url('admin/structure/block'))) . '</dd>';
+ $output .= '<dt>' . t('Page view counter') . '</dt>';
+ $output .= '<dd>' . t('The Statistics module includes a counter for each page that increases whenever the page is viewed. To use the counter, enable <em>Count content views</em> on the <a href="@statistics-settings">statistics settings page</a>, and set the necessary <a href="@permissions">permissions</a> (<em>View content hits</em>) so that the counter is visible to the users.', array('@statistics-settings' => url('admin/config/system/statistics'), '@permissions' => url('admin/people/permissions', array('fragment' => 'module-statistics')))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/config/system/statistics':
+ return '<p>' . t('Settings for the statistical information that Drupal will keep about the site. See <a href="@statistics">site statistics</a> for the actual information.', array('@statistics' => url('admin/reports/hits'))) . '</p>';
+ case 'admin/reports/hits':
+ return '<p>' . t("This page displays the site's most recent hits.") . '</p>';
+ case 'admin/reports/referrers':
+ return '<p>' . t('This page displays all external referrers, or external references to your website.') . '</p>';
+ case 'admin/reports/visitors':
+ return '<p>' . t("When you ban a visitor, you prevent the visitor's IP address from accessing your site. Unlike blocking a user, banning a visitor works even for anonymous users. This is most commonly used to block resource-intensive bots or web crawlers.") . '</p>';
+ }
+}
+
+
+/**
+ * Implements hook_exit().
+ *
+ * This is where statistics are gathered on page accesses.
+ */
+function statistics_exit() {
+ global $user;
+
+ // When serving cached pages with the 'page_cache_without_database'
+ // configuration, system variables need to be loaded. This is a major
+ // performance decrease for non-database page caches, but with Statistics
+ // module, it is likely to also have 'statistics_enable_access_log' enabled,
+ // in which case we need to bootstrap to the session phase anyway.
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_VARIABLES);
+
+ if (variable_get('statistics_count_content_views', 0)) {
+ // We are counting content views.
+ if (arg(0) == 'node' && is_numeric(arg(1)) && arg(2) == NULL) {
+ // A node has been viewed, so update the node's counters.
+ db_merge('node_counter')
+ ->key(array('nid' => arg(1)))
+ ->fields(array(
+ 'daycount' => 1,
+ 'totalcount' => 1,
+ 'timestamp' => REQUEST_TIME,
+ ))
+ ->expression('daycount', 'daycount + 1')
+ ->expression('totalcount', 'totalcount + 1')
+ ->execute();
+ }
+ }
+ if (variable_get('statistics_enable_access_log', 0)) {
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_SESSION);
+
+ // For anonymous users unicode.inc will not have been loaded.
+ include_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+ // Log this page access.
+ db_insert('accesslog')
+ ->fields(array(
+ 'title' => truncate_utf8(strip_tags(drupal_get_title()), 255),
+ 'path' => $_GET['q'],
+ 'url' => isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '',
+ 'hostname' => ip_address(),
+ 'uid' => $user->uid,
+ 'sid' => session_id(),
+ 'timer' => (int) timer_read('page'),
+ 'timestamp' => REQUEST_TIME,
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function statistics_permission() {
+ return array(
+ 'administer statistics' => array(
+ 'title' => t('Administer statistics'),
+ ),
+ 'access statistics' => array(
+ 'title' => t('View content access statistics'),
+ ),
+ 'view post access counter' => array(
+ 'title' => t('View content hits'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function statistics_node_view($node, $view_mode) {
+ if ($view_mode != 'rss') {
+ if (user_access('view post access counter')) {
+ $statistics = statistics_get($node->nid);
+ if ($statistics) {
+ $links['statistics_counter']['title'] = format_plural($statistics['totalcount'], '1 read', '@count reads');
+ $node->content['links']['statistics'] = array(
+ '#theme' => 'links__node__statistics',
+ '#links' => $links,
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function statistics_menu() {
+ $items['admin/reports/hits'] = array(
+ 'title' => 'Recent hits',
+ 'description' => 'View pages that have recently been visited.',
+ 'page callback' => 'statistics_recent_hits',
+ 'access arguments' => array('access statistics'),
+ 'file' => 'statistics.admin.inc',
+ );
+ $items['admin/reports/pages'] = array(
+ 'title' => 'Top pages',
+ 'description' => 'View pages that have been hit frequently.',
+ 'page callback' => 'statistics_top_pages',
+ 'access arguments' => array('access statistics'),
+ 'weight' => 1,
+ 'file' => 'statistics.admin.inc',
+ );
+ $items['admin/reports/visitors'] = array(
+ 'title' => 'Top visitors',
+ 'description' => 'View visitors that hit many pages.',
+ 'page callback' => 'statistics_top_visitors',
+ 'access arguments' => array('access statistics'),
+ 'weight' => 2,
+ 'file' => 'statistics.admin.inc',
+ );
+ $items['admin/reports/referrers'] = array(
+ 'title' => 'Top referrers',
+ 'description' => 'View top referrers.',
+ 'page callback' => 'statistics_top_referrers',
+ 'access arguments' => array('access statistics'),
+ 'file' => 'statistics.admin.inc',
+ );
+ $items['admin/reports/access/%'] = array(
+ 'title' => 'Details',
+ 'description' => 'View access log.',
+ 'page callback' => 'statistics_access_log',
+ 'page arguments' => array(3),
+ 'access arguments' => array('access statistics'),
+ 'file' => 'statistics.admin.inc',
+ );
+ $items['admin/config/system/statistics'] = array(
+ 'title' => 'Statistics',
+ 'description' => 'Control details about what and how your site logs access statistics.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('statistics_settings_form'),
+ 'access arguments' => array('administer statistics'),
+ 'file' => 'statistics.admin.inc',
+ 'weight' => -15,
+ );
+ $items['user/%user/track/navigation'] = array(
+ 'title' => 'Page visits',
+ 'page callback' => 'statistics_user_tracker',
+ 'access callback' => 'user_access',
+ 'access arguments' => array('access statistics'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ 'file' => 'statistics.pages.inc',
+ );
+ $items['node/%node/track'] = array(
+ 'title' => 'Track',
+ 'page callback' => 'statistics_node_tracker',
+ 'access callback' => 'user_access',
+ 'access arguments' => array('access statistics'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ 'file' => 'statistics.pages.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_user_cancel().
+ */
+function statistics_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_reassign':
+ db_update('accesslog')
+ ->fields(array('uid' => 0))
+ ->condition('uid', $account->uid)
+ ->execute();
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function statistics_user_delete($account) {
+ db_delete('accesslog')
+ ->condition('uid', $account->uid)
+ ->execute();
+}
+
+/**
+ * Implements hook_cron().
+ */
+function statistics_cron() {
+ $statistics_timestamp = variable_get('statistics_day_timestamp', '');
+
+ if ((REQUEST_TIME - $statistics_timestamp) >= 86400) {
+ // Reset day counts.
+ db_update('node_counter')
+ ->fields(array('daycount' => 0))
+ ->execute();
+ variable_set('statistics_day_timestamp', REQUEST_TIME);
+ }
+
+ // Clean up expired access logs (if applicable).
+ if (variable_get('statistics_flush_accesslog_timer', 259200) > 0) {
+ db_delete('accesslog')
+ ->condition('timestamp', REQUEST_TIME - variable_get('statistics_flush_accesslog_timer', 259200), '<')
+ ->execute();
+ }
+}
+
+/**
+ * Returns all time or today top or last viewed node(s).
+ *
+ * @param $dbfield
+ * one of
+ * - 'totalcount': top viewed content of all time.
+ * - 'daycount': top viewed content for today.
+ * - 'timestamp': last viewed node.
+ *
+ * @param $dbrows
+ * number of rows to be returned.
+ *
+ * @return
+ * A query result containing n.nid, n.title, u.uid, u.name of the selected node(s)
+ * or FALSE if the query could not be executed correctly.
+ */
+function statistics_title_list($dbfield, $dbrows) {
+ if (in_array($dbfield, array('totalcount', 'daycount', 'timestamp'))) {
+ $query = db_select('node', 'n');
+ $query->addTag('node_access');
+ $query->join('node_counter', 's', 'n.nid = s.nid');
+ $query->join('users', 'u', 'n.uid = u.uid');
+
+ return $query
+ ->fields('n', array('nid', 'title'))
+ ->fields('u', array('uid', 'name'))
+ ->condition($dbfield, 0, '<>')
+ ->condition('n.status', 1)
+ ->orderBy($dbfield, 'DESC')
+ ->range(0, $dbrows)
+ ->execute();
+ }
+ return FALSE;
+}
+
+
+/**
+ * Retrieves a node's "view statistics".
+ *
+ * @param $nid
+ * node ID
+ *
+ * @return
+ * An array with three entries: [0]=totalcount, [1]=daycount, [2]=timestamp
+ * - totalcount: count of the total number of times that node has been viewed.
+ * - daycount: count of the total number of times that node has been viewed "today".
+ * For the daycount to be reset, cron must be enabled.
+ * - timestamp: timestamp of when that node was last viewed.
+ */
+function statistics_get($nid) {
+
+ if ($nid > 0) {
+ // Retrieve an array with both totalcount and daycount.
+ return db_query('SELECT totalcount, daycount, timestamp FROM {node_counter} WHERE nid = :nid', array(':nid' => $nid), array('target' => 'slave'))->fetchAssoc();
+ }
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function statistics_block_info() {
+ $blocks = array();
+
+ if (variable_get('statistics_count_content_views', 0)) {
+ $blocks['popular']['info'] = t('Popular content');
+ // Too dynamic to cache.
+ $blocks['popular']['cache'] = DRUPAL_NO_CACHE;
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function statistics_block_configure($delta = '') {
+ // Popular content block settings
+ $numbers = array('0' => t('Disabled')) + drupal_map_assoc(array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30, 40));
+ $form['statistics_block_top_day_num'] = array('#type' => 'select', '#title' => t("Number of day's top views to display"), '#default_value' => variable_get('statistics_block_top_day_num', 0), '#options' => $numbers, '#description' => t('How many content items to display in "day" list.'));
+ $form['statistics_block_top_all_num'] = array('#type' => 'select', '#title' => t('Number of all time views to display'), '#default_value' => variable_get('statistics_block_top_all_num', 0), '#options' => $numbers, '#description' => t('How many content items to display in "all time" list.'));
+ $form['statistics_block_top_last_num'] = array('#type' => 'select', '#title' => t('Number of most recent views to display'), '#default_value' => variable_get('statistics_block_top_last_num', 0), '#options' => $numbers, '#description' => t('How many content items to display in "recently viewed" list.'));
+ return $form;
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function statistics_block_save($delta = '', $edit = array()) {
+ variable_set('statistics_block_top_day_num', $edit['statistics_block_top_day_num']);
+ variable_set('statistics_block_top_all_num', $edit['statistics_block_top_all_num']);
+ variable_set('statistics_block_top_last_num', $edit['statistics_block_top_last_num']);
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function statistics_block_view($delta = '') {
+ if (user_access('access content')) {
+ $content = array();
+
+ $daytop = variable_get('statistics_block_top_day_num', 0);
+ if ($daytop && ($result = statistics_title_list('daycount', $daytop)) && ($node_title_list = node_title_list($result, t("Today's:")))) {
+ $content['top_day'] = $node_title_list;
+ $content['top_day']['#suffix'] = '<br />';
+ }
+
+ $alltimetop = variable_get('statistics_block_top_all_num', 0);
+ if ($alltimetop && ($result = statistics_title_list('totalcount', $alltimetop)) && ($node_title_list = node_title_list($result, t('All time:')))) {
+ $content['top_all'] = $node_title_list;
+ $content['top_all']['#suffix'] = '<br />';
+ }
+
+ $lasttop = variable_get('statistics_block_top_last_num', 0);
+ if ($lasttop && ($result = statistics_title_list('timestamp', $lasttop)) && ($node_title_list = node_title_list($result, t('Last viewed:')))) {
+ $content['top_last'] = $node_title_list;
+ $content['top_last']['#suffix'] = '<br />';
+ }
+
+ if (count($content)) {
+ $block['content'] = $content;
+ $block['subject'] = t('Popular content');
+ return $block;
+ }
+ }
+}
+
+/**
+ * It is possible to adjust the width of columns generated by the
+ * statistics module.
+ */
+function _statistics_link($path, $width = 35) {
+ $title = drupal_get_path_alias($path);
+ $title = truncate_utf8($title, $width, FALSE, TRUE);
+ return l($title, $path);
+}
+
+function _statistics_format_item($title, $path) {
+ $path = ($path ? $path : '/');
+ $output = ($title ? "$title<br />" : '');
+ $output .= _statistics_link($path);
+ return $output;
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function statistics_node_delete($node) {
+ // clean up statistics table when node is deleted
+ db_delete('node_counter')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Implements hook_ranking().
+ */
+function statistics_ranking() {
+ if (variable_get('statistics_count_content_views', 0)) {
+ return array(
+ 'views' => array(
+ 'title' => t('Number of views'),
+ 'join' => array(
+ 'type' => 'LEFT',
+ 'table' => 'node_counter',
+ 'alias' => 'node_counter',
+ 'on' => 'node_counter.nid = i.sid',
+ ),
+ // Inverse law that maps the highest view count on the site to 1 and 0 to 0.
+ 'score' => '2.0 - 2.0 / (1.0 + node_counter.totalcount * CAST(:scale AS DECIMAL))',
+ 'arguments' => array(':scale' => variable_get('node_cron_views_scale', 0)),
+ ),
+ );
+ }
+}
+
+/**
+ * Implements hook_update_index().
+ */
+function statistics_update_index() {
+ variable_set('node_cron_views_scale', 1.0 / max(1, db_query('SELECT MAX(totalcount) FROM {node_counter}')->fetchField()));
+}
diff --git a/core/modules/statistics/statistics.pages.inc b/core/modules/statistics/statistics.pages.inc
new file mode 100644
index 000000000000..bb31f9838f3d
--- /dev/null
+++ b/core/modules/statistics/statistics.pages.inc
@@ -0,0 +1,91 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the statistics module.
+ */
+
+function statistics_node_tracker() {
+ if ($node = node_load(arg(1))) {
+
+ $header = array(
+ array('data' => t('Time'), 'field' => 'a.timestamp', 'sort' => 'desc'),
+ array('data' => t('Referrer'), 'field' => 'a.url'),
+ array('data' => t('User'), 'field' => 'u.name'),
+ array('data' => t('Operations')));
+
+ $query = db_select('accesslog', 'a', array('target' => 'slave'))->extend('PagerDefault')->extend('TableSort');
+ $query->join('users', 'u', 'a.uid = u.uid');
+
+ $query
+ ->fields('a', array('aid', 'timestamp', 'url', 'uid'))
+ ->fields('u', array('name'))
+ ->condition(db_or()
+ ->condition('a.path', 'node/' . $node->nid)
+ ->condition('a.path', 'node/' . $node->nid . '/%', 'LIKE'))
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $result = $query->execute();
+ $rows = array();
+ foreach ($result as $log) {
+ $rows[] = array(
+ array('data' => format_date($log->timestamp, 'short'), 'class' => array('nowrap')),
+ _statistics_link($log->url),
+ theme('username', array('account' => $log)),
+ l(t('details'), "admin/reports/access/$log->aid"),
+ );
+ }
+
+ drupal_set_title($node->title);
+ $build['statistics_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_pager'] = array('#theme' => 'pager');
+ return $build;
+ }
+ else {
+ drupal_not_found();
+ }
+}
+
+function statistics_user_tracker() {
+ if ($account = user_load(arg(1))) {
+
+ $header = array(
+ array('data' => t('Timestamp'), 'field' => 'timestamp', 'sort' => 'desc'),
+ array('data' => t('Page'), 'field' => 'path'),
+ array('data' => t('Operations')));
+ $query = db_select('accesslog', 'a', array('target' => 'slave'))->extend('PagerDefault')->extend('TableSort');
+ $query
+ ->fields('a', array('aid', 'timestamp', 'path', 'title'))
+ ->condition('uid', $account->uid)
+ ->limit(30)
+ ->orderByHeader($header);
+
+ $result = $query->execute();
+ $rows = array();
+ foreach ($result as $log) {
+ $rows[] = array(
+ array('data' => format_date($log->timestamp, 'short'), 'class' => array('nowrap')),
+ _statistics_format_item($log->title, $log->path),
+ l(t('details'), "admin/reports/access/$log->aid"));
+ }
+
+ drupal_set_title(format_username($account));
+ $build['statistics_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No statistics available.'),
+ );
+ $build['statistics_pager'] = array('#theme' => 'pager');
+ return $build;
+ }
+ else {
+ drupal_not_found();
+ }
+}
diff --git a/core/modules/statistics/statistics.test b/core/modules/statistics/statistics.test
new file mode 100644
index 000000000000..1b7f8ac2b99f
--- /dev/null
+++ b/core/modules/statistics/statistics.test
@@ -0,0 +1,456 @@
+<?php
+
+/**
+ * @file
+ * Tests for statistics.module.
+ */
+
+/**
+ * Sets up a base class for the Statistics module.
+ */
+class StatisticsTestCase extends DrupalWebTestCase {
+
+ function setUp() {
+ parent::setUp('statistics');
+
+ // Create user.
+ $this->blocking_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'access site reports',
+ 'access statistics',
+ 'block IP addresses',
+ 'administer blocks',
+ 'administer statistics',
+ 'administer users',
+ ));
+ $this->drupalLogin($this->blocking_user);
+
+ // Enable access logging.
+ variable_set('statistics_enable_access_log', 1);
+ variable_set('statistics_count_content_views', 1);
+
+ // Insert dummy access by anonymous user into access log.
+ db_insert('accesslog')
+ ->fields(array(
+ 'title' => 'test',
+ 'path' => 'node/1',
+ 'url' => 'http://example.com',
+ 'hostname' => '192.168.1.1',
+ 'uid' => 0,
+ 'sid' => 10,
+ 'timer' => 10,
+ 'timestamp' => REQUEST_TIME,
+ ))
+ ->execute();
+ }
+}
+
+/**
+ * Tests that logging via statistics_exit() works for cached and uncached pages.
+ *
+ * Subclass DrupalWebTestCase rather than StatisticsTestCase, because we want
+ * to test requests from an anonymous user.
+ */
+class StatisticsLoggingTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Statistics logging tests',
+ 'description' => 'Tests request logging for cached and uncached pages.',
+ 'group' => 'Statistics'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('statistics');
+
+ $this->auth_user = $this->drupalCreateUser(array('access content', 'create page content', 'edit own page content'));
+
+ // Ensure we have a node page to access.
+ $this->node = $this->drupalCreateNode(array('title' => $this->randomName(255), 'uid' => $this->auth_user->uid));
+
+ // Enable page caching.
+ variable_set('cache', TRUE);
+
+ // Enable access logging.
+ variable_set('statistics_enable_access_log', 1);
+ variable_set('statistics_count_content_views', 1);
+
+ // Clear the logs.
+ db_truncate('accesslog');
+ db_truncate('node_counter');
+ }
+
+ /**
+ * Verifies request logging for cached and uncached pages.
+ */
+ function testLogging() {
+ $path = 'node/' . $this->node->nid;
+ $expected = array(
+ 'title' => $this->node->title,
+ 'path' => $path,
+ );
+
+ // Verify logging of an uncached page.
+ $this->drupalGet($path);
+ $this->assertIdentical($this->drupalGetHeader('X-Drupal-Cache'), 'MISS', t('Testing an uncached page.'));
+ $log = db_query('SELECT * FROM {accesslog}')->fetchAll(PDO::FETCH_ASSOC);
+ $this->assertTrue(is_array($log) && count($log) == 1, t('Page request was logged.'));
+ $this->assertEqual(array_intersect_key($log[0], $expected), $expected);
+ $node_counter = statistics_get($this->node->nid);
+ $this->assertIdentical($node_counter['totalcount'], '1');
+
+ // Verify logging of a cached page.
+ $this->drupalGet($path);
+ $this->assertIdentical($this->drupalGetHeader('X-Drupal-Cache'), 'HIT', t('Testing a cached page.'));
+ $log = db_query('SELECT * FROM {accesslog}')->fetchAll(PDO::FETCH_ASSOC);
+ $this->assertTrue(is_array($log) && count($log) == 2, t('Page request was logged.'));
+ $this->assertEqual(array_intersect_key($log[1], $expected), $expected);
+ $node_counter = statistics_get($this->node->nid);
+ $this->assertIdentical($node_counter['totalcount'], '2');
+
+ // Test logging from authenticated users
+ $this->drupalLogin($this->auth_user);
+ $this->drupalGet($path);
+ $log = db_query('SELECT * FROM {accesslog}')->fetchAll(PDO::FETCH_ASSOC);
+ // Check the 6th item since login and account pages are also logged
+ $this->assertTrue(is_array($log) && count($log) == 6, t('Page request was logged.'));
+ $this->assertEqual(array_intersect_key($log[5], $expected), $expected);
+ $node_counter = statistics_get($this->node->nid);
+ $this->assertIdentical($node_counter['totalcount'], '3');
+
+ // Visit edit page to generate a title greater than 255.
+ $path = 'node/' . $this->node->nid . '/edit';
+ $expected = array(
+ 'title' => truncate_utf8(t('Edit Basic page') . ' ' . $this->node->title, 255),
+ 'path' => $path,
+ );
+ $this->drupalGet($path);
+ $log = db_query('SELECT * FROM {accesslog}')->fetchAll(PDO::FETCH_ASSOC);
+ $this->assertTrue(is_array($log) && count($log) == 7, t('Page request was logged.'));
+ $this->assertEqual(array_intersect_key($log[6], $expected), $expected);
+ }
+}
+
+/**
+ * Tests that report pages render properly, and that access logging works.
+ */
+class StatisticsReportsTestCase extends StatisticsTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Statistics reports tests',
+ 'description' => 'Tests display of statistics report pages and access logging.',
+ 'group' => 'Statistics'
+ );
+ }
+
+ /**
+ * Verifies that 'Recent hits' renders properly and displays the added hit.
+ */
+ function testRecentHits() {
+ $this->drupalGet('admin/reports/hits');
+ $this->assertText('test', t('Hit title found.'));
+ $this->assertText('node/1', t('Hit URL found.'));
+ $this->assertText('Anonymous', t('Hit user found.'));
+ }
+
+ /**
+ * Verifies that 'Top pages' renders properly and displays the added hit.
+ */
+ function testTopPages() {
+ $this->drupalGet('admin/reports/pages');
+ $this->assertText('test', t('Hit title found.'));
+ $this->assertText('node/1', t('Hit URL found.'));
+ }
+
+ /**
+ * Verifies that 'Top referrers' renders properly and displays the added hit.
+ */
+ function testTopReferrers() {
+ $this->drupalGet('admin/reports/referrers');
+ $this->assertText('http://example.com', t('Hit referrer found.'));
+ }
+
+ /**
+ * Verifies that 'Details' page renders properly and displays the added hit.
+ */
+ function testDetails() {
+ $this->drupalGet('admin/reports/access/1');
+ $this->assertText('test', t('Hit title found.'));
+ $this->assertText('node/1', t('Hit URL found.'));
+ $this->assertText('Anonymous', t('Hit user found.'));
+ }
+
+ /**
+ * Verifies that access logging is working and is reported correctly.
+ */
+ function testAccessLogging() {
+ $this->drupalGet('admin/reports/referrers');
+ $this->drupalGet('admin/reports/hits');
+ $this->assertText('Top referrers in the past 3 days', t('Hit title found.'));
+ $this->assertText('admin/reports/referrers', t('Hit URL found.'));
+ }
+
+ /**
+ * Tests the "popular content" block.
+ */
+ function testPopularContentBlock() {
+ // Visit a node to have something show up in the block.
+ $node = $this->drupalCreateNode(array('type' => 'page', 'uid' => $this->blocking_user->uid));
+ $this->drupalGet('node/' . $node->nid);
+
+ // Configure and save the block.
+ $block = block_load('statistics', 'popular');
+ $block->theme = variable_get('theme_default', 'bartik');
+ $block->status = 1;
+ $block->pages = '';
+ $block->region = 'sidebar_first';
+ $block->cache = -1;
+ $block->visibility = 0;
+ $edit = array('statistics_block_top_day_num' => 3, 'statistics_block_top_all_num' => 3, 'statistics_block_top_last_num' => 3);
+ module_invoke('statistics', 'block_save', 'popular', $edit);
+ drupal_write_record('block', $block);
+
+ // Get some page and check if the block is displayed.
+ $this->drupalGet('user');
+ $this->assertText('Popular content', t('Found the popular content block.'));
+ $this->assertText("Today's", t('Found today\'s popular content.'));
+ $this->assertText('All time', t('Found the alll time popular content.'));
+ $this->assertText('Last viewed', t('Found the last viewed popular content.'));
+
+ $this->assertRaw(l($node->title, 'node/' . $node->nid), t('Found link to visited node.'));
+ }
+}
+
+/**
+ * Tests that the visitor blocking functionality works.
+ */
+class StatisticsBlockVisitorsTestCase extends StatisticsTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Top visitor blocking',
+ 'description' => 'Tests blocking of IP addresses via the top visitors report.',
+ 'group' => 'Statistics'
+ );
+ }
+
+ /**
+ * Blocks an IP address via the top visitors report and then unblocks it.
+ */
+ function testIPAddressBlocking() {
+ // IP address for testing.
+ $test_ip_address = '192.168.1.1';
+
+ // Verify the IP address from accesslog appears on the top visitors page
+ // and that a 'block IP address' link is displayed.
+ $this->drupalLogin($this->blocking_user);
+ $this->drupalGet('admin/reports/visitors');
+ $this->assertText($test_ip_address, t('IP address found.'));
+ $this->assertText(t('block IP address'), t('Block IP link displayed'));
+
+ // Block the IP address.
+ $this->clickLink('block IP address');
+ $this->assertText(t('IP address blocking'), t('IP blocking page displayed.'));
+ $edit = array();
+ $edit['ip'] = $test_ip_address;
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $ip = db_query("SELECT iid from {blocked_ips} WHERE ip = :ip", array(':ip' => $edit['ip']))->fetchField();
+ $this->assertNotEqual($ip, FALSE, t('IP address found in database'));
+ $this->assertRaw(t('The IP address %ip has been blocked.', array('%ip' => $edit['ip'])), t('IP address was blocked.'));
+
+ // Verify that the block/unblock link on the top visitors page has been
+ // altered.
+ $this->drupalGet('admin/reports/visitors');
+ $this->assertText(t('unblock IP address'), t('Unblock IP address link displayed'));
+
+ // Unblock the IP address.
+ $this->clickLink('unblock IP address');
+ $this->assertRaw(t('Are you sure you want to delete %ip?', array('%ip' => $test_ip_address)), t('IP address deletion confirmation found.'));
+ $edit = array();
+ $this->drupalPost('admin/config/people/ip-blocking/delete/1', NULL, t('Delete'));
+ $this->assertRaw(t('The IP address %ip was deleted.', array('%ip' => $test_ip_address)), t('IP address deleted.'));
+ }
+}
+
+/**
+ * Test statistics administration screen.
+ */
+class StatisticsAdminTestCase extends DrupalWebTestCase {
+ protected $privileged_user;
+ protected $test_node;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Test statistics admin.',
+ 'description' => 'Tests the statistics admin.',
+ 'group' => 'Statistics'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('statistics');
+ $this->privileged_user = $this->drupalCreateUser(array('access statistics', 'administer statistics', 'view post access counter', 'create page content'));
+ $this->drupalLogin($this->privileged_user);
+ $this->test_node = $this->drupalCreateNode(array('type' => 'page', 'uid' => $this->privileged_user->uid));
+ }
+
+ /**
+ * Verifies that the statistics settings page works.
+ */
+ function testStatisticsSettings() {
+ $this->assertFalse(variable_get('statistics_enable_access_log', 0), t('Access log is disabled by default.'));
+ $this->assertFalse(variable_get('statistics_count_content_views', 0), t('Count content view log is disabled by default.'));
+
+ $this->drupalGet('admin/reports/pages');
+ $this->assertRaw(t('No statistics available.'), t('Verifying text shown when no statistics is available.'));
+
+ // Enable access log and counter on content view.
+ $edit['statistics_enable_access_log'] = 1;
+ $edit['statistics_count_content_views'] = 1;
+ $this->drupalPost('admin/config/system/statistics', $edit, t('Save configuration'));
+ $this->assertTrue(variable_get('statistics_enable_access_log'), t('Access log is enabled.'));
+ $this->assertTrue(variable_get('statistics_count_content_views'), t('Count content view log is enabled.'));
+
+ // Hit the node.
+ $this->drupalGet('node/' . $this->test_node->nid);
+
+ $this->drupalGet('admin/reports/pages');
+ $this->assertText('node/1', t('Test node found.'));
+
+ // Hit the node again (the counter is incremented after the hit, so
+ // "1 read" will actually be shown when the node is hit the second time).
+ $this->drupalGet('node/' . $this->test_node->nid);
+ $this->assertText('1 read', t('Node is read once.'));
+
+ $this->drupalGet('node/' . $this->test_node->nid);
+ $this->assertText('2 reads', t('Node is read 2 times.'));
+ }
+
+ /**
+ * Tests that when a node is deleted, the node counter is deleted too.
+ */
+ function testDeleteNode() {
+ variable_set('statistics_count_content_views', 1);
+
+ $this->drupalGet('node/' . $this->test_node->nid);
+
+ $result = db_select('node_counter', 'n')
+ ->fields('n', array('nid'))
+ ->condition('n.nid', $this->test_node->nid)
+ ->execute()
+ ->fetchAssoc();
+ $this->assertEqual($result['nid'], $this->test_node->nid, 'Verifying that the node counter is incremented.');
+
+ node_delete($this->test_node->nid);
+
+ $result = db_select('node_counter', 'n')
+ ->fields('n', array('nid'))
+ ->condition('n.nid', $this->test_node->nid)
+ ->execute()
+ ->fetchAssoc();
+ $this->assertFalse($result, 'Verifying that the node counter is deleted.');
+ }
+
+ /**
+ * Tests that accesslog reflects when a user is deleted.
+ */
+ function testDeleteUser() {
+ variable_set('statistics_enable_access_log', 1);
+
+ variable_set('user_cancel_method', 'user_cancel_delete');
+ $this->drupalLogout($this->privileged_user);
+ $account = $this->drupalCreateUser(array('access content', 'cancel account'));
+ $this->drupalLogin($account);
+ $this->drupalGet('node/' . $this->test_node->nid);
+
+ $account = user_load($account->uid, TRUE);
+
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+
+ $timestamp = time();
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ // Confirm account cancellation request.
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $this->assertFalse(user_load($account->uid, TRUE), t('User is not found in the database.'));
+
+ $this->drupalGet('admin/reports/visitors');
+ $this->assertNoText($account->name, t('Did not find user in visitor statistics.'));
+ }
+
+ /**
+ * Tests that cron clears day counts and expired access logs.
+ */
+ function testExpiredLogs() {
+ variable_set('statistics_enable_access_log', 1);
+ variable_set('statistics_count_content_views', 1);
+ variable_set('statistics_day_timestamp', 8640000);
+ variable_set('statistics_flush_accesslog_timer', 1);
+
+ $this->drupalGet('node/' . $this->test_node->nid);
+ $this->drupalGet('node/' . $this->test_node->nid);
+ $this->assertText('1 read', t('Node is read once.'));
+
+ $this->drupalGet('admin/reports/pages');
+ $this->assertText('node/' . $this->test_node->nid, t('Hit URL found.'));
+
+ // statistics_cron will subtract the statistics_flush_accesslog_timer
+ // variable from REQUEST_TIME in the delete query, so wait two secs here to
+ // make sure the access log will be flushed for the node just hit.
+ sleep(2);
+ $this->cronRun();
+
+ $this->drupalGet('admin/reports/pages');
+ $this->assertNoText('node/' . $this->test_node->nid, t('No hit URL found.'));
+
+ $result = db_select('node_counter', 'nc')
+ ->fields('nc', array('daycount'))
+ ->condition('nid', $this->test_node->nid, '=')
+ ->execute()
+ ->fetchField();
+ $this->assertFalse($result, t('Daycounter is zero.'));
+ }
+}
+
+/**
+ * Test statistics token replacement in strings.
+ */
+class StatisticsTokenReplaceTestCase extends StatisticsTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Statistics token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check statistics token replacement.',
+ 'group' => 'Statistics',
+ );
+ }
+
+ /**
+ * Creates a node, then tests the statistics tokens generated from it.
+ */
+ function testStatisticsTokenReplacement() {
+ global $language;
+
+ // Create user and node.
+ $user = $this->drupalCreateUser(array('create page content'));
+ $this->drupalLogin($user);
+ $node = $this->drupalCreateNode(array('type' => 'page', 'uid' => $user->uid));
+
+ // Hit the node.
+ $this->drupalGet('node/' . $node->nid);
+ $statistics = statistics_get($node->nid);
+
+ // Generate and test tokens.
+ $tests = array();
+ $tests['[node:total-count]'] = 1;
+ $tests['[node:day-count]'] = 1;
+ $tests['[node:last-view]'] = format_date($statistics['timestamp']);
+ $tests['[node:last-view:short]'] = format_date($statistics['timestamp'], 'short');
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('node' => $node), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Statistics token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
diff --git a/core/modules/statistics/statistics.tokens.inc b/core/modules/statistics/statistics.tokens.inc
new file mode 100644
index 000000000000..c2c8fc3cbc50
--- /dev/null
+++ b/core/modules/statistics/statistics.tokens.inc
@@ -0,0 +1,63 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for node visitor statistics.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function statistics_token_info() {
+ $node['total-count'] = array(
+ 'name' => t("Number of views"),
+ 'description' => t("The number of visitors who have read the node."),
+ );
+ $node['day-count'] = array(
+ 'name' => t("Views today"),
+ 'description' => t("The number of visitors who have read the node today."),
+ );
+ $node['last-view'] = array(
+ 'name' => t("Last view"),
+ 'description' => t("The date on which a visitor last read the node."),
+ 'type' => 'date',
+ );
+
+ return array(
+ 'tokens' => array('node' => $node),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function statistics_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ $replacements = array();
+
+ if ($type == 'node' & !empty($data['node'])) {
+ $node = $data['node'];
+
+ foreach ($tokens as $name => $original) {
+ if ($name == 'total-count') {
+ $statistics = statistics_get($node->nid);
+ $replacements[$original] = $statistics['totalcount'];
+ }
+ elseif ($name == 'day-count') {
+ $statistics = statistics_get($node->nid);
+ $replacements[$original] = $statistics['daycount'];
+ }
+ elseif ($name == 'last-view') {
+ $statistics = statistics_get($node->nid);
+ $replacements[$original] = format_date($statistics['timestamp']);
+ }
+ }
+
+ if ($created_tokens = token_find_with_prefix($tokens, 'last-view')) {
+ $statistics = statistics_get($node->nid);
+ $replacements += token_generate('date', $created_tokens, array('date' => $statistics['timestamp']), $options);
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/syslog/syslog.info b/core/modules/syslog/syslog.info
new file mode 100644
index 000000000000..4024bfd50352
--- /dev/null
+++ b/core/modules/syslog/syslog.info
@@ -0,0 +1,6 @@
+name = Syslog
+description = Logs and records system events to syslog.
+package = Core
+version = VERSION
+core = 8.x
+files[] = syslog.test
diff --git a/core/modules/syslog/syslog.install b/core/modules/syslog/syslog.install
new file mode 100644
index 000000000000..12ff4fb2c757
--- /dev/null
+++ b/core/modules/syslog/syslog.install
@@ -0,0 +1,15 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the syslog module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function syslog_uninstall() {
+ variable_del('syslog_identity');
+ variable_del('syslog_facility');
+ variable_del('syslog_format');
+}
diff --git a/core/modules/syslog/syslog.module b/core/modules/syslog/syslog.module
new file mode 100644
index 000000000000..c4ee38252889
--- /dev/null
+++ b/core/modules/syslog/syslog.module
@@ -0,0 +1,109 @@
+<?php
+
+/**
+ * @file
+ * Redirects logging messages to syslog.
+ */
+
+if (defined('LOG_LOCAL0')) {
+ define('DEFAULT_SYSLOG_FACILITY', LOG_LOCAL0);
+}
+else {
+ define('DEFAULT_SYSLOG_FACILITY', LOG_USER);
+}
+
+/**
+ * Implements hook_help().
+ */
+function syslog_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#syslog':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t("The Syslog module logs events by sending messages to the logging facility of your web server's operating system. Syslog is an operating system administrative logging tool that provides valuable information for use in system management and security auditing. Most suited to medium and large sites, Syslog provides filtering tools that allow messages to be routed by type and severity. For more information, see the online handbook entry for <a href='@syslog'>Syslog module</a> and PHP's <a href='@php_openlog'>openlog</a> and <a href='@php_syslog'>syslog</a> functions.", array('@syslog' => 'http://drupal.org/handbook/modules/syslog', '@php_openlog' => 'http://www.php.net/manual/function.openlog.php', '@php_syslog' => 'http://www.php.net/manual/function.syslog.php')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Logging for UNIX, Linux, and Mac OS X') . '</dt>';
+ $output .= '<dd>' . t('On UNIX, Linux, and Mac OS X, the file <em>/etc/syslog.conf</em> defines the routing configuration. Messages can be flagged with the codes <code>LOG_LOCAL0</code> through <code>LOG_LOCAL7</code>. For information on Syslog facilities, severity levels, and how to set up <em>syslog.conf</em>, see the <em>syslog.conf</em> manual page on your command line.') . '</dd>';
+ $output .= '<dt>' . t('Logging for Microsoft Windows') . '</dt>';
+ $output .= '<dd>' . t('On Microsoft Windows, messages are always sent to the Event Log using the code <code>LOG_USER</code>.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function syslog_form_system_logging_settings_alter(&$form, &$form_state) {
+ $help = module_exists('help') ? ' ' . l(t('More information'), 'admin/help/syslog') . '.' : NULL;
+ $form['syslog_identity'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Syslog identity'),
+ '#default_value' => variable_get('syslog_identity', 'drupal'),
+ '#description' => t('A string that will be prepended to every message logged to Syslog. If you have multiple sites logging to the same Syslog log file, a unique identity per site makes it easy to tell the log entries apart.') . $help,
+ );
+ if (defined('LOG_LOCAL0')) {
+ $form['syslog_facility'] = array(
+ '#type' => 'select',
+ '#title' => t('Syslog facility'),
+ '#default_value' => variable_get('syslog_facility', LOG_LOCAL0),
+ '#options' => syslog_facility_list(),
+ '#description' => t('Depending on the system configuration, Syslog and other logging tools use this code to identify or filter messages from within the entire system log.') . $help,
+ );
+ }
+ $form['syslog_format'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Syslog format'),
+ '#default_value' => variable_get('syslog_format', '!base_url|!timestamp|!type|!ip|!request_uri|!referer|!uid|!link|!message'),
+ '#description' => t('Specify the format of the syslog entry. Available variables are: <dl><dt><code>!base_url</code></dt><dd>Base URL of the site.</dd><dt><code>!timestamp</code></dt><dd>Unix timestamp of the log entry.</dd><dt><code>!type</code></dt><dd>The category to which this message belongs.</dd><dt><code>!ip</code></dt><dd>IP address of the user triggering the message.</dd><dt><code>!request_uri</code></dt><dd>The requested URI.</dd><dt><code>!referer</code></dt><dd>HTTP Referer if available.</dd><dt><code>!uid</code></dt><dd>User ID.</dd><dt><code>!link</code></dt><dd>A link to associate with the message.</dd><dt><code>!message</code></dt><dd>The message to store in the log.</dd></dl>'),
+ );
+ $form['actions']['#weight'] = 1;
+}
+
+ /**
+ * List all possible syslog facilities for UNIX/Linux.
+ *
+ * @return array
+ */
+function syslog_facility_list() {
+ return array(
+ LOG_LOCAL0 => 'LOG_LOCAL0',
+ LOG_LOCAL1 => 'LOG_LOCAL1',
+ LOG_LOCAL2 => 'LOG_LOCAL2',
+ LOG_LOCAL3 => 'LOG_LOCAL3',
+ LOG_LOCAL4 => 'LOG_LOCAL4',
+ LOG_LOCAL5 => 'LOG_LOCAL5',
+ LOG_LOCAL6 => 'LOG_LOCAL6',
+ LOG_LOCAL7 => 'LOG_LOCAL7',
+ );
+}
+
+/**
+ * Implements hook_watchdog().
+ */
+function syslog_watchdog(array $log_entry) {
+ global $base_url;
+
+ $log_init = &drupal_static(__FUNCTION__, FALSE);
+
+ if (!$log_init) {
+ $log_init = TRUE;
+ $default_facility = defined('LOG_LOCAL0') ? LOG_LOCAL0 : LOG_USER;
+ openlog(variable_get('syslog_identity', 'drupal'), LOG_NDELAY, variable_get('syslog_facility', $default_facility));
+ }
+
+ $message = strtr(variable_get('syslog_format', '!base_url|!timestamp|!type|!ip|!request_uri|!referer|!uid|!link|!message'), array(
+ '!base_url' => $base_url,
+ '!timestamp' => $log_entry['timestamp'],
+ '!type' => $log_entry['type'],
+ '!ip' => $log_entry['ip'],
+ '!request_uri' => $log_entry['request_uri'],
+ '!referer' => $log_entry['referer'],
+ '!uid' => $log_entry['user']->uid,
+ '!link' => strip_tags($log_entry['link']),
+ '!message' => strip_tags(!isset($log_entry['variables']) ? $log_entry['message'] : strtr($log_entry['message'], $log_entry['variables'])),
+ ));
+
+ syslog($log_entry['severity'], $message);
+}
diff --git a/core/modules/syslog/syslog.test b/core/modules/syslog/syslog.test
new file mode 100644
index 000000000000..691fb7deeb38
--- /dev/null
+++ b/core/modules/syslog/syslog.test
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @file
+ * Tests for syslog.module.
+ */
+
+class SyslogTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Syslog functionality',
+ 'description' => 'Test syslog settings.',
+ 'group' => 'Syslog'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('syslog');
+ }
+
+ /**
+ * Test the syslog settings page.
+ */
+ function testSettings() {
+ $admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($admin_user);
+
+ $edit = array();
+ // If we're on Windows, there is no configuration form.
+ if (defined('LOG_LOCAL6')) {
+ $this->drupalPost('admin/config/development/logging', array('syslog_facility' => LOG_LOCAL6), t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'));
+
+ $this->drupalGet('admin/config/development/logging');
+ if ($this->parse()) {
+ $field = $this->xpath('//option[@value=:value]', array(':value' => LOG_LOCAL6)); // Should be one field.
+ $this->assertTrue($field[0]['selected'] == 'selected', t('Facility value saved.'));
+ }
+ }
+ }
+}
diff --git a/core/modules/system/html.tpl.php b/core/modules/system/html.tpl.php
new file mode 100644
index 000000000000..d889b23e3840
--- /dev/null
+++ b/core/modules/system/html.tpl.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display the basic html structure of a single
+ * Drupal page.
+ *
+ * Variables:
+ * - $css: An array of CSS files for the current page.
+ * - $language: (object) The language the site is being displayed in.
+ * $language->language contains its textual representation.
+ * $language->dir contains the language direction.
+ * It will either be 'ltr' or 'rtl'.
+ * - $head_title: A modified version of the page title, for use in the TITLE
+ * tag.
+ * - $head_title_array: (array) An associative array containing the string parts
+ * that were used to generate the $head_title variable, already prepared to be
+ * output as TITLE tag. The key/value pairs may contain one or more of the
+ * following, depending on conditions:
+ * - title: The title of the current page, if any.
+ * - name: The name of the site.
+ * - slogan: The slogan of the site, if any, and if there is no title.
+ * - $head: Markup for the HEAD section (including meta tags, keyword tags, and
+ * so on).
+ * - $styles: Style tags necessary to import all CSS files for the page.
+ * - $scripts: Script tags necessary to load the JavaScript files and settings
+ * for the page.
+ * - $page_top: Initial markup from any modules that have altered the
+ * page. This variable should always be output first, before all other dynamic
+ * content.
+ * - $page: The rendered page content.
+ * - $page_bottom: Final closing markup from any modules that have altered the
+ * page. This variable should always be output last, after all other dynamic
+ * content.
+ * - $classes String of classes that can be used to style contextually through
+ * CSS.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_html()
+ * @see template_process()
+ */
+?><!DOCTYPE html>
+<html<?php print $html_attributes; ?>>
+ <head>
+ <?php print $head; ?>
+ <title><?php print $head_title; ?></title>
+ <?php print $styles; ?>
+ <?php print $scripts; ?>
+ </head>
+ <body class="<?php print $classes; ?>" <?php print $body_attributes;?>>
+ <div id="skip-link">
+ <a href="#main-content" class="element-invisible element-focusable"><?php print t('Skip to main content'); ?></a>
+ </div>
+ <?php print $page_top; ?>
+ <?php print $page; ?>
+ <?php print $page_bottom; ?>
+ </body>
+</html>
diff --git a/core/modules/system/image.gd.inc b/core/modules/system/image.gd.inc
new file mode 100644
index 000000000000..39f86dc30e6c
--- /dev/null
+++ b/core/modules/system/image.gd.inc
@@ -0,0 +1,367 @@
+<?php
+
+/**
+ * @file
+ * GD2 toolkit for image manipulation within Drupal.
+ */
+
+/**
+ * @ingroup image
+ * @{
+ */
+
+/**
+ * Retrieve settings for the GD2 toolkit.
+ */
+function image_gd_settings() {
+ if (image_gd_check_settings()) {
+ $form['status'] = array(
+ '#markup' => t('The GD toolkit is installed and working properly.')
+ );
+
+ $form['image_jpeg_quality'] = array(
+ '#type' => 'textfield',
+ '#title' => t('JPEG quality'),
+ '#description' => t('Define the image quality for JPEG manipulations. Ranges from 0 to 100. Higher values mean better image quality but bigger files.'),
+ '#size' => 10,
+ '#maxlength' => 3,
+ '#default_value' => variable_get('image_jpeg_quality', 75),
+ '#field_suffix' => t('%'),
+ );
+ $form['#element_validate'] = array('image_gd_settings_validate');
+
+ return $form;
+ }
+ else {
+ form_set_error('image_toolkit', t('The GD image toolkit requires that the GD module for PHP be installed and configured properly. For more information see <a href="@url">PHP\'s image documentation</a>.', array('@url' => 'http://php.net/image')));
+ return FALSE;
+ }
+}
+
+/**
+ * Validate the submitted GD settings.
+ */
+function image_gd_settings_validate($form, &$form_state) {
+ // Validate image quality range.
+ $value = $form_state['values']['image_jpeg_quality'];
+ if (!is_numeric($value) || $value < 0 || $value > 100) {
+ form_set_error('image_jpeg_quality', t('JPEG quality must be a number between 0 and 100.'));
+ }
+}
+
+/**
+ * Verify GD2 settings (that the right version is actually installed).
+ *
+ * @return
+ * A boolean indicating if the GD toolkit is available on this machine.
+ */
+function image_gd_check_settings() {
+ if ($check = get_extension_funcs('gd')) {
+ if (in_array('imagegd2', $check)) {
+ // GD2 support is available.
+ return TRUE;
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Scale an image to the specified size using GD.
+ *
+ * @param $image
+ * An image object. The $image->resource, $image->info['width'], and
+ * $image->info['height'] values will be modified by this call.
+ * @param $width
+ * The new width of the resized image, in pixels.
+ * @param $height
+ * The new height of the resized image, in pixels.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_resize()
+ */
+function image_gd_resize(stdClass $image, $width, $height) {
+ $res = image_gd_create_tmp($image, $width, $height);
+
+ if (!imagecopyresampled($res, $image->resource, 0, 0, 0, 0, $width, $height, $image->info['width'], $image->info['height'])) {
+ return FALSE;
+ }
+
+ imagedestroy($image->resource);
+ // Update image object.
+ $image->resource = $res;
+ $image->info['width'] = $width;
+ $image->info['height'] = $height;
+ return TRUE;
+}
+
+/**
+ * Rotate an image the given number of degrees.
+ *
+ * @param $image
+ * An image object. The $image->resource, $image->info['width'], and
+ * $image->info['height'] values will be modified by this call.
+ * @param $degrees
+ * The number of (clockwise) degrees to rotate the image.
+ * @param $background
+ * An hexadecimal integer specifying the background color to use for the
+ * uncovered area of the image after the rotation. E.g. 0x000000 for black,
+ * 0xff00ff for magenta, and 0xffffff for white. For images that support
+ * transparency, this will default to transparent. Otherwise it will
+ * be white.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_rotate()
+ */
+function image_gd_rotate(stdClass $image, $degrees, $background = NULL) {
+ // PHP installations using non-bundled GD do not have imagerotate.
+ if (!function_exists('imagerotate')) {
+ watchdog('image', 'The image %file could not be rotated because the imagerotate() function is not available in this PHP installation.', array('%file' => $image->source));
+ return FALSE;
+ }
+
+ $width = $image->info['width'];
+ $height = $image->info['height'];
+
+ // Convert the hexadecimal background value to a color index value.
+ if (isset($background)) {
+ $rgb = array();
+ for ($i = 16; $i >= 0; $i -= 8) {
+ $rgb[] = (($background >> $i) & 0xFF);
+ }
+ $background = imagecolorallocatealpha($image->resource, $rgb[0], $rgb[1], $rgb[2], 0);
+ }
+ // Set the background color as transparent if $background is NULL.
+ else {
+ // Get the current transparent color.
+ $background = imagecolortransparent($image->resource);
+
+ // If no transparent colors, use white.
+ if ($background == 0) {
+ $background = imagecolorallocatealpha($image->resource, 255, 255, 255, 0);
+ }
+ }
+
+ // Images are assigned a new color palette when rotating, removing any
+ // transparency flags. For GIF images, keep a record of the transparent color.
+ if ($image->info['extension'] == 'gif') {
+ $transparent_index = imagecolortransparent($image->resource);
+ if ($transparent_index != 0) {
+ $transparent_gif_color = imagecolorsforindex($image->resource, $transparent_index);
+ }
+ }
+
+ $image->resource = imagerotate($image->resource, 360 - $degrees, $background);
+
+ // GIFs need to reassign the transparent color after performing the rotate.
+ if (isset($transparent_gif_color)) {
+ $background = imagecolorexactalpha($image->resource, $transparent_gif_color['red'], $transparent_gif_color['green'], $transparent_gif_color['blue'], $transparent_gif_color['alpha']);
+ imagecolortransparent($image->resource, $background);
+ }
+
+ $image->info['width'] = imagesx($image->resource);
+ $image->info['height'] = imagesy($image->resource);
+ return TRUE;
+}
+
+/**
+ * Crop an image using the GD toolkit.
+ *
+ * @param $image
+ * An image object. The $image->resource, $image->info['width'], and
+ * $image->info['height'] values will be modified by this call.
+ * @param $x
+ * The starting x offset at which to start the crop, in pixels.
+ * @param $y
+ * The starting y offset at which to start the crop, in pixels.
+ * @param $width
+ * The width of the cropped area, in pixels.
+ * @param $height
+ * The height of the cropped area, in pixels.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_crop()
+ */
+function image_gd_crop(stdClass $image, $x, $y, $width, $height) {
+ $res = image_gd_create_tmp($image, $width, $height);
+
+ if (!imagecopyresampled($res, $image->resource, 0, 0, $x, $y, $width, $height, $width, $height)) {
+ return FALSE;
+ }
+
+ // Destroy the original image and return the modified image.
+ imagedestroy($image->resource);
+ $image->resource = $res;
+ $image->info['width'] = $width;
+ $image->info['height'] = $height;
+ return TRUE;
+}
+
+/**
+ * Convert an image resource to grayscale.
+ *
+ * Note that transparent GIFs loose transparency when desaturated.
+ *
+ * @param $image
+ * An image object. The $image->resource value will be modified by this call.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_desaturate()
+ */
+function image_gd_desaturate(stdClass $image) {
+ // PHP installations using non-bundled GD do not have imagefilter.
+ if (!function_exists('imagefilter')) {
+ watchdog('image', 'The image %file could not be desaturated because the imagefilter() function is not available in this PHP installation.', array('%file' => $image->source));
+ return FALSE;
+ }
+
+ return imagefilter($image->resource, IMG_FILTER_GRAYSCALE);
+}
+
+/**
+ * GD helper function to create an image resource from a file.
+ *
+ * @param $image
+ * An image object. The $image->resource value will populated by this call.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_load()
+ */
+function image_gd_load(stdClass $image) {
+ $extension = str_replace('jpg', 'jpeg', $image->info['extension']);
+ $function = 'imagecreatefrom' . $extension;
+ return (function_exists($function) && $image->resource = $function($image->source));
+}
+
+/**
+ * GD helper to write an image resource to a destination file.
+ *
+ * @param $image
+ * An image object.
+ * @param $destination
+ * A string file URI or path where the image should be saved.
+ * @return
+ * TRUE or FALSE, based on success.
+ *
+ * @see image_save()
+ */
+function image_gd_save(stdClass $image, $destination) {
+ $scheme = file_uri_scheme($destination);
+ // Work around lack of stream wrapper support in imagejpeg() and imagepng().
+ if ($scheme && file_stream_wrapper_valid_scheme($scheme)) {
+ // If destination is not local, save image to temporary local file.
+ $local_wrappers = file_get_stream_wrappers(STREAM_WRAPPERS_LOCAL);
+ if (!isset($local_wrappers[$scheme])) {
+ $permanent_destination = $destination;
+ $destination = drupal_tempnam('temporary://', 'gd_');
+ }
+ // Convert stream wrapper URI to normal path.
+ $destination = drupal_realpath($destination);
+ }
+
+ $extension = str_replace('jpg', 'jpeg', $image->info['extension']);
+ $function = 'image' . $extension;
+ if (!function_exists($function)) {
+ return FALSE;
+ }
+ if ($extension == 'jpeg') {
+ $success = $function($image->resource, $destination, variable_get('image_jpeg_quality', 75));
+ }
+ else {
+ // Always save PNG images with full transparency.
+ if ($extension == 'png') {
+ imagealphablending($image->resource, FALSE);
+ imagesavealpha($image->resource, TRUE);
+ }
+ $success = $function($image->resource, $destination);
+ }
+ // Move temporary local file to remote destination.
+ if (isset($permanent_destination) && $success) {
+ return (bool) file_unmanaged_move($destination, $permanent_destination, FILE_EXISTS_REPLACE);
+ }
+ return $success;
+}
+
+/**
+ * Create a truecolor image preserving transparency from a provided image.
+ *
+ * @param $image
+ * An image object.
+ * @param $width
+ * The new width of the new image, in pixels.
+ * @param $height
+ * The new height of the new image, in pixels.
+ * @return
+ * A GD image handle.
+ */
+function image_gd_create_tmp(stdClass $image, $width, $height) {
+ $res = imagecreatetruecolor($width, $height);
+
+ if ($image->info['extension'] == 'gif') {
+ // Grab transparent color index from image resource.
+ $transparent = imagecolortransparent($image->resource);
+
+ if ($transparent >= 0) {
+ // The original must have a transparent color, allocate to the new image.
+ $transparent_color = imagecolorsforindex($image->resource, $transparent);
+ $transparent = imagecolorallocate($res, $transparent_color['red'], $transparent_color['green'], $transparent_color['blue']);
+
+ // Flood with our new transparent color.
+ imagefill($res, 0, 0, $transparent);
+ imagecolortransparent($res, $transparent);
+ }
+ }
+ elseif ($image->info['extension'] == 'png') {
+ imagealphablending($res, FALSE);
+ $transparency = imagecolorallocatealpha($res, 0, 0, 0, 127);
+ imagefill($res, 0, 0, $transparency);
+ imagealphablending($res, TRUE);
+ imagesavealpha($res, TRUE);
+ }
+ else {
+ imagefill($res, 0, 0, imagecolorallocate($res, 255, 255, 255));
+ }
+
+ return $res;
+}
+
+/**
+ * Get details about an image.
+ *
+ * @param $image
+ * An image object.
+ * @return
+ * FALSE, if the file could not be found or is not an image. Otherwise, a
+ * keyed array containing information about the image:
+ * - "width": Width, in pixels.
+ * - "height": Height, in pixels.
+ * - "extension": Commonly used file extension for the image.
+ * - "mime_type": MIME type ('image/jpeg', 'image/gif', 'image/png').
+ *
+ * @see image_get_info()
+ */
+function image_gd_get_info(stdClass $image) {
+ $details = FALSE;
+ $data = getimagesize($image->source);
+
+ if (isset($data) && is_array($data)) {
+ $extensions = array('1' => 'gif', '2' => 'jpg', '3' => 'png');
+ $extension = isset($extensions[$data[2]]) ? $extensions[$data[2]] : '';
+ $details = array(
+ 'width' => $data[0],
+ 'height' => $data[1],
+ 'extension' => $extension,
+ 'mime_type' => $data['mime'],
+ );
+ }
+
+ return $details;
+}
+
+/**
+ * @} End of "ingroup image".
+ */
diff --git a/core/modules/system/maintenance-page.tpl.php b/core/modules/system/maintenance-page.tpl.php
new file mode 100644
index 000000000000..31de3bb23858
--- /dev/null
+++ b/core/modules/system/maintenance-page.tpl.php
@@ -0,0 +1,93 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a single Drupal page while offline.
+ *
+ * All the available variables are mirrored in html.tpl.php and page.tpl.php.
+ * Some may be blank but they are provided for consistency.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_maintenance_page()
+ */
+?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php print $language->language ?>" lang="<?php print $language->language ?>" dir="<?php print $language->dir ?>">
+
+<head>
+ <title><?php print $head_title; ?></title>
+ <?php print $head; ?>
+ <?php print $styles; ?>
+ <?php print $scripts; ?>
+</head>
+<body class="<?php print $classes; ?>">
+ <div id="page">
+ <div id="header">
+ <div id="logo-title">
+
+ <?php if (!empty($logo)): ?>
+ <a href="<?php print $base_path; ?>" title="<?php print t('Home'); ?>" rel="home" id="logo">
+ <img src="<?php print $logo; ?>" alt="<?php print t('Home'); ?>" />
+ </a>
+ <?php endif; ?>
+
+ <div id="name-and-slogan">
+ <?php if (!empty($site_name)): ?>
+ <h1 id="site-name">
+ <a href="<?php print $base_path ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </h1>
+ <?php endif; ?>
+
+ <?php if (!empty($site_slogan)): ?>
+ <div id="site-slogan"><?php print $site_slogan; ?></div>
+ <?php endif; ?>
+ </div> <!-- /name-and-slogan -->
+ </div> <!-- /logo-title -->
+
+ <?php if (!empty($header)): ?>
+ <div id="header-region">
+ <?php print $header; ?>
+ </div>
+ <?php endif; ?>
+
+ </div> <!-- /header -->
+
+ <div id="container" class="clearfix">
+
+ <?php if (!empty($sidebar_first)): ?>
+ <div id="sidebar-first" class="column sidebar">
+ <?php print $sidebar_first; ?>
+ </div> <!-- /sidebar-first -->
+ <?php endif; ?>
+
+ <div id="main" class="column"><div id="main-squeeze">
+
+ <div id="content">
+ <?php if (!empty($title)): ?><h1 class="title" id="page-title"><?php print $title; ?></h1><?php endif; ?>
+ <?php if (!empty($messages)): print $messages; endif; ?>
+ <div id="content-content" class="clearfix">
+ <?php print $content; ?>
+ </div> <!-- /content-content -->
+ </div> <!-- /content -->
+
+ </div></div> <!-- /main-squeeze /main -->
+
+ <?php if (!empty($sidebar_second)): ?>
+ <div id="sidebar-second" class="column sidebar">
+ <?php print $sidebar_second; ?>
+ </div> <!-- /sidebar-second -->
+ <?php endif; ?>
+
+ </div> <!-- /container -->
+
+ <div id="footer-wrapper">
+ <div id="footer">
+ <?php if (!empty($footer)): print $footer; endif; ?>
+ </div> <!-- /footer -->
+ </div> <!-- /footer-wrapper -->
+
+ </div> <!-- /page -->
+
+</body>
+</html>
diff --git a/core/modules/system/page.tpl.php b/core/modules/system/page.tpl.php
new file mode 100644
index 000000000000..ca9273cd6632
--- /dev/null
+++ b/core/modules/system/page.tpl.php
@@ -0,0 +1,152 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a single Drupal page.
+ *
+ * Available variables:
+ *
+ * General utility variables:
+ * - $base_path: The base URL path of the Drupal installation. At the very
+ * least, this will always default to /.
+ * - $directory: The directory the template is located in, e.g. modules/system
+ * or themes/bartik.
+ * - $is_front: TRUE if the current page is the front page.
+ * - $logged_in: TRUE if the user is registered and signed in.
+ * - $is_admin: TRUE if the user has permission to access administration pages.
+ *
+ * Site identity:
+ * - $front_page: The URL of the front page. Use this instead of $base_path,
+ * when linking to the front page. This includes the language domain or
+ * prefix.
+ * - $logo: The path to the logo image, as defined in theme configuration.
+ * - $site_name: The name of the site, empty when display has been disabled
+ * in theme settings.
+ * - $site_slogan: The slogan of the site, empty when display has been disabled
+ * in theme settings.
+ *
+ * Navigation:
+ * - $main_menu (array): An array containing the Main menu links for the
+ * site, if they have been configured.
+ * - $secondary_menu (array): An array containing the Secondary menu links for
+ * the site, if they have been configured.
+ * - $breadcrumb: The breadcrumb trail for the current page.
+ *
+ * Page content (in order of occurrence in the default page.tpl.php):
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title: The page title, for use in the actual HTML content.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ * - $messages: HTML for status and error messages. Should be displayed
+ * prominently.
+ * - $tabs (array): Tabs linking to any sub-pages beneath the current page
+ * (e.g., the view and edit tabs when displaying a node).
+ * - $action_links (array): Actions local to the page, such as 'Add menu' on the
+ * menu administration interface.
+ * - $feed_icons: A string of all feed icons for the current page.
+ * - $node: The node object, if there is an automatically-loaded node
+ * associated with the page, and the node ID is the second argument
+ * in the page's path (e.g. node/12345 and node/12345/revisions, but not
+ * comment/reply/12345).
+ *
+ * Regions:
+ * - $page['help']: Dynamic help text, mostly for admin pages.
+ * - $page['highlighted']: Items for the highlighted content region.
+ * - $page['content']: The main content of the current page.
+ * - $page['sidebar_first']: Items for the first sidebar.
+ * - $page['sidebar_second']: Items for the second sidebar.
+ * - $page['header']: Items for the header region.
+ * - $page['footer']: Items for the footer region.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_page()
+ * @see template_process()
+ */
+?>
+
+ <div id="page-wrapper"><div id="page">
+
+ <div id="header"><div class="section clearfix">
+
+ <?php if ($logo): ?>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home" id="logo">
+ <img src="<?php print $logo; ?>" alt="<?php print t('Home'); ?>" />
+ </a>
+ <?php endif; ?>
+
+ <?php if ($site_name || $site_slogan): ?>
+ <div id="name-and-slogan">
+ <?php if ($site_name): ?>
+ <?php if ($title): ?>
+ <div id="site-name"><strong>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </strong></div>
+ <?php else: /* Use h1 when the content title is empty */ ?>
+ <h1 id="site-name">
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </h1>
+ <?php endif; ?>
+ <?php endif; ?>
+
+ <?php if ($site_slogan): ?>
+ <div id="site-slogan"><?php print $site_slogan; ?></div>
+ <?php endif; ?>
+ </div> <!-- /#name-and-slogan -->
+ <?php endif; ?>
+
+ <?php print render($page['header']); ?>
+
+ </div></div> <!-- /.section, /#header -->
+
+ <?php if ($main_menu || $secondary_menu): ?>
+ <div id="navigation"><div class="section">
+ <?php print theme('links__system_main_menu', array('links' => $main_menu, 'attributes' => array('id' => 'main-menu', 'class' => array('links', 'inline', 'clearfix')), 'heading' => t('Main menu'))); ?>
+ <?php print theme('links__system_secondary_menu', array('links' => $secondary_menu, 'attributes' => array('id' => 'secondary-menu', 'class' => array('links', 'inline', 'clearfix')), 'heading' => t('Secondary menu'))); ?>
+ </div></div> <!-- /.section, /#navigation -->
+ <?php endif; ?>
+
+ <?php if ($breadcrumb): ?>
+ <div id="breadcrumb"><?php print $breadcrumb; ?></div>
+ <?php endif; ?>
+
+ <?php if ($messages): ?>
+ <div id="messages"><?php print $messages; ?></div>
+ <?php endif; ?>
+
+ <div id="main-wrapper"><div id="main" class="clearfix">
+
+ <div id="content" class="column"><div class="section">
+ <?php if ($page['highlighted']): ?><div id="highlighted"><?php print render($page['highlighted']); ?></div><?php endif; ?>
+ <a id="main-content"></a>
+ <?php print render($title_prefix); ?>
+ <?php if ($title): ?><h1 class="title" id="page-title"><?php print $title; ?></h1><?php endif; ?>
+ <?php print render($title_suffix); ?>
+ <?php if ($tabs): ?><div class="tabs"><?php print render($tabs); ?></div><?php endif; ?>
+ <?php print render($page['help']); ?>
+ <?php if ($action_links): ?><ul class="action-links"><?php print render($action_links); ?></ul><?php endif; ?>
+ <?php print render($page['content']); ?>
+ <?php print $feed_icons; ?>
+ </div></div> <!-- /.section, /#content -->
+
+ <?php if ($page['sidebar_first']): ?>
+ <div id="sidebar-first" class="column sidebar"><div class="section">
+ <?php print render($page['sidebar_first']); ?>
+ </div></div> <!-- /.section, /#sidebar-first -->
+ <?php endif; ?>
+
+ <?php if ($page['sidebar_second']): ?>
+ <div id="sidebar-second" class="column sidebar"><div class="section">
+ <?php print render($page['sidebar_second']); ?>
+ </div></div> <!-- /.section, /#sidebar-second -->
+ <?php endif; ?>
+
+ </div></div> <!-- /#main, /#main-wrapper -->
+
+ <div id="footer"><div class="section">
+ <?php print render($page['footer']); ?>
+ </div></div> <!-- /.section, /#footer -->
+
+ </div></div> <!-- /#page, /#page-wrapper -->
diff --git a/core/modules/system/region.tpl.php b/core/modules/system/region.tpl.php
new file mode 100644
index 000000000000..b29e24f0178f
--- /dev/null
+++ b/core/modules/system/region.tpl.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a region.
+ *
+ * Available variables:
+ * - $content: The content for this region, typically blocks.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the following:
+ * - region: The current template type, i.e., "theming hook".
+ * - region-[name]: The name of the region with underscores replaced with
+ * dashes. For example, the page_top region would have a region-page-top class.
+ * - $region: The name of the region variable as defined in the theme's .info file.
+ *
+ * Helper variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $is_admin: Flags true when the current user is an administrator.
+ * - $is_front: Flags true when presented in the front page.
+ * - $logged_in: Flags true when the current user is a logged-in member.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_region()
+ * @see template_process()
+ */
+?>
+<?php if ($content): ?>
+ <div class="<?php print $classes; ?>">
+ <?php print $content; ?>
+ </div>
+<?php endif; ?>
diff --git a/core/modules/system/system.admin-rtl.css b/core/modules/system/system.admin-rtl.css
new file mode 100644
index 000000000000..362a406c59c7
--- /dev/null
+++ b/core/modules/system/system.admin-rtl.css
@@ -0,0 +1,86 @@
+
+/**
+ * @file
+ * RTL styles for administration pages.
+ */
+
+/**
+ * Administration blocks.
+ */
+div.admin-panel .body {
+ padding: 0 8px 2px 4px;
+}
+div.admin .left {
+ float: right;
+ margin-left: 0;
+ margin-right: 1em;
+}
+div.admin .right {
+ float: left;
+ margin-left: 1em;
+ margin-right: 0;
+}
+div.admin .expert-link {
+ margin-right: 0;
+ margin-left: 1em;
+ padding-right: 0;
+ padding-left: 4px;
+ text-align: left;
+}
+
+/**
+ * Status report.
+ */
+table.system-status-report td.status-icon {
+ padding-left: 0;
+ padding-right: 6px;
+}
+table.system-status-report tr.merge-up td {
+ padding: 0 28px 8px 6px;
+}
+
+/**
+ * Appearance page.
+ */
+table.screenshot {
+ margin-left: 1em;
+}
+.system-themes-list-enabled .theme-selector .screenshot,
+.system-themes-list-enabled .theme-selector .no-screenshot {
+ float: right;
+ margin: 0 0 0 20px;
+}
+.system-themes-list-disabled .theme-selector {
+ float: right;
+ padding: 20px 0 20px 20px;
+}
+.theme-selector .operations li {
+ border-right: none;
+ border-left: 1px solid #cdcdcd;
+ float: right;
+}
+.theme-selector .operations li.last {
+ border-left: none;
+ padding: 0 0.7em 0 0;
+}
+.theme-selector .operations li.first {
+ padding: 0 0 0 0.7em;
+}
+
+/**
+ * Exposed filters.
+ */
+.exposed-filters .filters {
+ float: right;
+ margin-left: 1em;
+ margin-right: 0;
+}
+.exposed-filters .form-item label {
+ float: right;
+}
+/* Current filters */
+.exposed-filters .additional-filters {
+ float: right;
+ margin-left: 1em;
+ margin-right: 0;
+}
diff --git a/core/modules/system/system.admin.css b/core/modules/system/system.admin.css
new file mode 100644
index 000000000000..7299484c38d8
--- /dev/null
+++ b/core/modules/system/system.admin.css
@@ -0,0 +1,270 @@
+
+/**
+ * @file
+ * Styles for administration pages.
+ */
+
+/**
+ * Administration blocks.
+ */
+div.admin-panel {
+ margin: 0;
+ padding: 5px 5px 15px 5px;
+}
+div.admin-panel .description {
+ margin: 0 0 3px;
+ padding: 2px 0 3px 0;
+}
+div.admin-panel .body {
+ padding: 0 4px 2px 8px; /* LTR */
+}
+div.admin {
+ padding-top: 15px;
+}
+div.admin .left {
+ float: left; /* LTR */
+ width: 47%;
+ margin-left: 1em; /* LTR */
+}
+div.admin .right {
+ float: right; /* LTR */
+ width: 47%;
+ margin-right: 1em; /* LTR */
+}
+div.admin .expert-link {
+ text-align: right; /* LTR */
+ margin-right: 1em; /* LTR */
+ padding-right: 4px; /* LTR */
+}
+
+/**
+ * Markup generated by theme_system_compact_link().
+ */
+.compact-link {
+ margin: 0 0 0.5em 0;
+}
+
+/**
+ * Quick inline admin links.
+ */
+small .admin-link:before {
+ content: '[';
+}
+small .admin-link:after {
+ content: ']';
+}
+
+/**
+ * Modules page.
+ */
+#system-modules div.incompatible {
+ font-weight: bold;
+}
+div.admin-requirements,
+div.admin-required {
+ font-size: 0.9em;
+ color: #444;
+}
+span.admin-disabled {
+ color: #800;
+}
+span.admin-enabled {
+ color: #080;
+}
+span.admin-missing {
+ color: #f00;
+}
+a.module-link {
+ display: block;
+ padding: 1px 0 1px 20px; /* LTR */
+ white-space: nowrap;
+}
+a.module-link-help {
+ background: url(../../misc/help.png) 0 50% no-repeat; /* LTR */
+}
+a.module-link-permissions {
+ background: url(../../misc/permissions.png) 0 50% no-repeat; /* LTR */
+}
+a.module-link-configure {
+ background: url(../../misc/configure.png) 0 50% no-repeat; /* LTR */
+}
+.module-help {
+ margin-left: 1em; /* LTR */
+ float: right; /* LTR */
+}
+
+/**
+ * Status report.
+ */
+table.system-status-report td {
+ padding: 6px;
+ vertical-align: middle;
+}
+table.system-status-report tr.merge-up td {
+ padding: 0 6px 8px 28px; /* LTR */
+}
+table.system-status-report td.status-icon {
+ width: 16px;
+ padding-right: 0; /* LTR */
+}
+table.system-status-report td.status-icon div {
+ background-repeat: no-repeat;
+ height: 16px;
+ width: 16px;
+}
+table.system-status-report tr.error td.status-icon div {
+ background-image: url(../../misc/message-16-error.png);
+}
+table.system-status-report tr.warning td.status-icon div {
+ background-image: url(../../misc/message-16-warning.png);
+}
+tr.merge-down,
+tr.merge-down td {
+ border-bottom-width: 0 !important;
+}
+tr.merge-up,
+tr.merge-up td {
+ border-top-width: 0 !important;
+}
+
+/**
+ * Theme settings.
+ */
+.theme-settings-left {
+ float: left;
+ width: 49%;
+}
+.theme-settings-right {
+ float: right;
+ width: 49%;
+}
+.theme-settings-bottom {
+ clear: both;
+}
+
+/**
+ * Appearance page.
+ */
+table.screenshot {
+ margin-right: 1em; /* LTR */
+}
+.theme-info h2 {
+ margin-bottom: 0;
+}
+.theme-info p {
+ margin-top: 0;
+}
+.system-themes-list {
+ margin-bottom: 20px;
+}
+.system-themes-list-disabled {
+ border-top: 1px solid #cdcdcd;
+ padding-top: 20px;
+}
+.system-themes-list h2 {
+ margin: 0;
+}
+.theme-selector {
+ padding-top: 20px;
+}
+.theme-selector .screenshot,
+.theme-selector .no-screenshot {
+ border: 1px solid #e0e0d8;
+ padding: 2px;
+ vertical-align: bottom;
+ width: 294px;
+ height: 219px;
+ line-height: 219px;
+ text-align: center;
+}
+.theme-default .screenshot {
+ border: 1px solid #aaa;
+}
+.system-themes-list-enabled .theme-selector .screenshot,
+.system-themes-list-enabled .theme-selector .no-screenshot {
+ float: left; /* LTR */
+ margin: 0 20px 0 0; /* LTR */
+}
+.system-themes-list-disabled .theme-selector .screenshot,
+.system-themes-list-disabled .theme-selector .no-screenshot {
+ width: 194px;
+ height: 144px;
+ line-height: 144px;
+}
+.theme-selector h3 {
+ font-weight: normal;
+}
+.theme-default h3 {
+ font-weight: bold;
+}
+.system-themes-list-enabled .theme-selector h3 {
+ margin-top: 0;
+}
+.system-themes-list-disabled .theme-selector {
+ width: 300px;
+ float: left; /* LTR */
+ padding: 20px 20px 20px 0; /* LTR */
+}
+.system-themes-list-enabled .theme-info {
+ max-width: 940px;
+}
+.system-themes-list-disabled .theme-info {
+ min-height: 170px;
+}
+.theme-selector .incompatible {
+ margin-top: 10px;
+ font-weight: bold;
+}
+.theme-selector .operations {
+ margin: 10px 0 0 0;
+ padding: 0;
+}
+.theme-selector .operations li {
+ float: left; /* LTR */
+ margin: 0;
+ padding: 0 0.7em;
+ list-style-type: none;
+ border-right: 1px solid #cdcdcd; /* LTR */
+}
+.theme-selector .operations li.last {
+ padding: 0 0 0 0.7em; /* LTR */
+ border-right: none; /* LTR */
+}
+.theme-selector .operations li.first {
+ padding: 0 0.7em 0 0; /* LTR */
+}
+#system-themes-admin-form {
+ clear: left;
+}
+
+/**
+ * Exposed filters.
+ */
+.exposed-filters .filters {
+ float: left; /* LTR */
+ margin-right: 1em; /* LTR */
+}
+.exposed-filters .form-item {
+ margin: 0 0 0.1em 0;
+ padding: 0;
+}
+.exposed-filters .form-item label {
+ float: left; /* LTR */
+ font-weight: normal;
+ width: 10em;
+}
+.exposed-filters .form-select {
+ width: 14em;
+}
+/* Current filters */
+.exposed-filters .current-filters {
+ margin-bottom: 1em;
+}
+.exposed-filters .current-filters .placeholder {
+ font-style: normal;
+ font-weight: bold;
+}
+.exposed-filters .additional-filters {
+ float: left; /* LTR */
+ margin-right: 1em; /* LTR */
+}
diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc
new file mode 100644
index 000000000000..a5a88854ab03
--- /dev/null
+++ b/core/modules/system/system.admin.inc
@@ -0,0 +1,3173 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the system module.
+ */
+
+/**
+ * Menu callback; Provide the administration overview page.
+ */
+function system_admin_config_page() {
+ // Check for status report errors.
+ if (system_status(TRUE) && user_access('administer site configuration')) {
+ drupal_set_message(t('One or more problems were detected with your Drupal installation. Check the <a href="@status">status report</a> for more information.', array('@status' => url('admin/reports/status'))), 'error');
+ }
+ $blocks = array();
+ if ($admin = db_query("SELECT menu_name, mlid FROM {menu_links} WHERE link_path = 'admin/config' AND module = 'system'")->fetchAssoc()) {
+ $result = db_query("
+ SELECT m.*, ml.*
+ FROM {menu_links} ml
+ INNER JOIN {menu_router} m ON ml.router_path = m.path
+ WHERE ml.link_path <> 'admin/help' AND menu_name = :menu_name AND ml.plid = :mlid AND hidden = 0", $admin, array('fetch' => PDO::FETCH_ASSOC));
+ foreach ($result as $item) {
+ _menu_link_translate($item);
+ if (!$item['access']) {
+ continue;
+ }
+ // The link description, either derived from 'description' in hook_menu()
+ // or customized via menu module is used as title attribute.
+ if (!empty($item['localized_options']['attributes']['title'])) {
+ $item['description'] = $item['localized_options']['attributes']['title'];
+ unset($item['localized_options']['attributes']['title']);
+ }
+ $block = $item;
+ $block['content'] = '';
+ $block['content'] .= theme('admin_block_content', array('content' => system_admin_menu_block($item)));
+ if (!empty($block['content'])) {
+ $block['show'] = TRUE;
+ }
+
+ // Prepare for sorting as in function _menu_tree_check_access().
+ // The weight is offset so it is always positive, with a uniform 5-digits.
+ $blocks[(50000 + $item['weight']) . ' ' . $item['title'] . ' ' . $item['mlid']] = $block;
+ }
+ }
+ if ($blocks) {
+ ksort($blocks);
+ return theme('admin_page', array('blocks' => $blocks));
+ }
+ else {
+ return t('You do not have any administrative items.');
+ }
+}
+
+/**
+ * Provide a single block from the administration menu as a page.
+ *
+ * This function is often a destination for these blocks.
+ * For example, 'admin/structure/types' needs to have a destination to be valid
+ * in the Drupal menu system, but too much information there might be
+ * hidden, so we supply the contents of the block.
+ *
+ * @return
+ * The output HTML.
+ */
+function system_admin_menu_block_page() {
+ $item = menu_get_item();
+ if ($content = system_admin_menu_block($item)) {
+ $output = theme('admin_block_content', array('content' => $content));
+ }
+ else {
+ $output = t('You do not have any administrative items.');
+ }
+ return $output;
+}
+
+/**
+ * Menu callback; prints a listing of admin tasks, organized by module.
+ */
+function system_admin_index() {
+ $module_info = system_get_info('module');
+ foreach ($module_info as $module => $info) {
+ $module_info[$module] = new stdClass();
+ $module_info[$module]->info = $info;
+ }
+ uasort($module_info, 'system_sort_modules_by_info_name');
+ $menu_items = array();
+
+ foreach ($module_info as $module => $info) {
+ // Only display a section if there are any available tasks.
+ if ($admin_tasks = system_get_module_admin_tasks($module, $info->info)) {
+ // Sort links by title.
+ uasort($admin_tasks, 'drupal_sort_title');
+ // Move 'Configure permissions' links to the bottom of each section.
+ $permission_key = "admin/people/permissions#module-$module";
+ if (isset($admin_tasks[$permission_key])) {
+ $permission_task = $admin_tasks[$permission_key];
+ unset($admin_tasks[$permission_key]);
+ $admin_tasks[$permission_key] = $permission_task;
+ }
+
+ $menu_items[$info->info['name']] = array($info->info['description'], $admin_tasks);
+ }
+ }
+ return theme('system_admin_index', array('menu_items' => $menu_items));
+}
+
+/**
+ * Menu callback; displays a module's settings page.
+ */
+function system_settings_overview() {
+ // Check database setup if necessary
+ if (function_exists('db_check_setup') && empty($_POST)) {
+ db_check_setup();
+ }
+
+ $item = menu_get_item('admin/config');
+ $content = system_admin_menu_block($item);
+
+ $output = theme('admin_block_content', array('content' => $content));
+
+ return $output;
+}
+
+/**
+ * Menu callback; displays a listing of all themes.
+ */
+function system_themes_page() {
+ // Get current list of themes.
+ $themes = system_rebuild_theme_data();
+ uasort($themes, 'system_sort_modules_by_info_name');
+
+ $theme_default = variable_get('theme_default', 'bartik');
+ $theme_groups = array();
+
+ foreach ($themes as &$theme) {
+ if (!empty($theme->info['hidden'])) {
+ continue;
+ }
+ $admin_theme_options[$theme->name] = $theme->info['name'];
+ $theme->is_default = ($theme->name == $theme_default);
+
+ // Identify theme screenshot.
+ $theme->screenshot = NULL;
+ // Create a list which includes the current theme and all its base themes.
+ if (isset($themes[$theme->name]->base_themes)) {
+ $theme_keys = array_keys($themes[$theme->name]->base_themes);
+ $theme_keys[] = $theme->name;
+ }
+ else {
+ $theme_keys = array($theme->name);
+ }
+ // Look for a screenshot in the current theme or in its closest ancestor.
+ foreach (array_reverse($theme_keys) as $theme_key) {
+ if (isset($themes[$theme_key]) && file_exists($themes[$theme_key]->info['screenshot'])) {
+ $theme->screenshot = array(
+ 'path' => $themes[$theme_key]->info['screenshot'],
+ 'alt' => t('Screenshot for !theme theme', array('!theme' => $theme->info['name'])),
+ 'title' => t('Screenshot for !theme theme', array('!theme' => $theme->info['name'])),
+ 'attributes' => array('class' => array('screenshot')),
+ );
+ break;
+ }
+ }
+
+ if (empty($theme->status)) {
+ // Ensure this theme is compatible with this version of core.
+ // Require the 'content' region to make sure the main page
+ // content has a common place in all themes.
+ $theme->incompatible_core = !isset($theme->info['core']) || ($theme->info['core'] != DRUPAL_CORE_COMPATIBILITY) || (!isset($theme->info['regions']['content']));
+ $theme->incompatible_php = version_compare(phpversion(), $theme->info['php']) < 0;
+ }
+ $query['token'] = drupal_get_token('system-theme-operation-link');
+ $theme->operations = array();
+ if (!empty($theme->status) || !$theme->incompatible_core && !$theme->incompatible_php) {
+ // Create the operations links.
+ $query['theme'] = $theme->name;
+ if (drupal_theme_access($theme)) {
+ $theme->operations[] = array(
+ 'title' => t('Settings'),
+ 'href' => 'admin/appearance/settings/' . $theme->name,
+ 'attributes' => array('title' => t('Settings for !theme theme', array('!theme' => $theme->info['name']))),
+ );
+ }
+ if (!empty($theme->status)) {
+ if (!$theme->is_default) {
+ $theme->operations[] = array(
+ 'title' => t('Disable'),
+ 'href' => 'admin/appearance/disable',
+ 'query' => $query,
+ 'attributes' => array('title' => t('Disable !theme theme', array('!theme' => $theme->info['name']))),
+ );
+ $theme->operations[] = array(
+ 'title' => t('Set default'),
+ 'href' => 'admin/appearance/default',
+ 'query' => $query,
+ 'attributes' => array('title' => t('Set !theme as default theme', array('!theme' => $theme->info['name']))),
+ );
+ }
+ }
+ else {
+ $theme->operations[] = array(
+ 'title' => t('Enable'),
+ 'href' => 'admin/appearance/enable',
+ 'query' => $query,
+ 'attributes' => array('title' => t('Enable !theme theme', array('!theme' => $theme->info['name']))),
+ );
+ $theme->operations[] = array(
+ 'title' => t('Enable and set default'),
+ 'href' => 'admin/appearance/default',
+ 'query' => $query,
+ 'attributes' => array('title' => t('Enable !theme as default theme', array('!theme' => $theme->info['name']))),
+ );
+ }
+ }
+
+ // Add notes to default and administration theme.
+ $theme->notes = array();
+ $theme->classes = array();
+ if ($theme->is_default) {
+ $theme->classes[] = 'theme-default';
+ $theme->notes[] = t('default theme');
+ }
+
+ // Sort enabled and disabled themes into their own groups.
+ $theme_groups[$theme->status ? 'enabled' : 'disabled'][] = $theme;
+ }
+
+ // There are two possible theme groups.
+ $theme_group_titles = array(
+ 'enabled' => format_plural(count($theme_groups['enabled']), 'Enabled theme', 'Enabled themes'),
+ );
+ if (!empty($theme_groups['disabled'])) {
+ $theme_group_titles['disabled'] = format_plural(count($theme_groups['disabled']), 'Disabled theme', 'Disabled themes');
+ }
+
+ uasort($theme_groups['enabled'], 'system_sort_themes');
+ drupal_alter('system_themes_page', $theme_groups);
+
+ $admin_form = drupal_get_form('system_themes_admin_form', $admin_theme_options);
+ return theme('system_themes_page', array('theme_groups' => $theme_groups, 'theme_group_titles' => $theme_group_titles)) . drupal_render($admin_form);
+}
+
+/**
+ * Form to select the administration theme.
+ *
+ * @ingroup forms
+ * @see system_themes_admin_form_submit()
+ */
+function system_themes_admin_form($form, &$form_state, $theme_options) {
+ // Administration theme settings.
+ $form['admin_theme'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Administration theme'),
+ );
+ $form['admin_theme']['admin_theme'] = array(
+ '#type' => 'select',
+ '#options' => array(0 => t('Default theme')) + $theme_options,
+ '#title' => t('Administration theme'),
+ '#description' => t('Choose "Default theme" to always use the same theme as the rest of the site.'),
+ '#default_value' => variable_get('admin_theme', 0),
+ );
+ $form['admin_theme']['node_admin_theme'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Use the administration theme when editing or creating content'),
+ '#default_value' => variable_get('node_admin_theme', '0'),
+ );
+ $form['admin_theme']['actions'] = array('#type' => 'actions');
+ $form['admin_theme']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+ return $form;
+}
+
+/**
+ * Process system_themes_admin_form form submissions.
+ */
+function system_themes_admin_form_submit($form, &$form_state) {
+ drupal_set_message(t('The configuration options have been saved.'));
+ variable_set('admin_theme', $form_state['values']['admin_theme']);
+ variable_set('node_admin_theme', $form_state['values']['node_admin_theme']);
+}
+
+/**
+ * Menu callback; Enables a theme.
+ */
+function system_theme_enable() {
+ if (isset($_REQUEST['theme']) && isset($_REQUEST['token']) && drupal_valid_token($_REQUEST['token'], 'system-theme-operation-link')) {
+ $theme = $_REQUEST['theme'];
+ // Get current list of themes.
+ $themes = list_themes();
+
+ // Check if the specified theme is one recognized by the system.
+ if (!empty($themes[$theme])) {
+ theme_enable(array($theme));
+ drupal_set_message(t('The %theme theme has been enabled.', array('%theme' => $themes[$theme]->info['name'])));
+ }
+ else {
+ drupal_set_message(t('The %theme theme was not found.', array('%theme' => $theme)), 'error');
+ }
+ drupal_goto('admin/appearance');
+ }
+ return drupal_access_denied();
+}
+
+/**
+ * Menu callback; Disables a theme.
+ */
+function system_theme_disable() {
+ if (isset($_REQUEST['theme']) && isset($_REQUEST['token']) && drupal_valid_token($_REQUEST['token'], 'system-theme-operation-link')) {
+ $theme = $_REQUEST['theme'];
+ // Get current list of themes.
+ $themes = list_themes();
+
+ // Check if the specified theme is one recognized by the system.
+ if (!empty($themes[$theme])) {
+ if ($theme == variable_get('theme_default', 'bartik')) {
+ // Don't disable the default theme.
+ drupal_set_message(t('%theme is the default theme and cannot be disabled.', array('%theme' => $themes[$theme]->info['name'])), 'error');
+ }
+ else {
+ theme_disable(array($theme));
+ drupal_set_message(t('The %theme theme has been disabled.', array('%theme' => $themes[$theme]->info['name'])));
+ }
+ }
+ else {
+ drupal_set_message(t('The %theme theme was not found.', array('%theme' => $theme)), 'error');
+ }
+ drupal_goto('admin/appearance');
+ }
+ return drupal_access_denied();
+}
+
+/**
+ * Menu callback; Set the default theme.
+ */
+function system_theme_default() {
+ if (isset($_REQUEST['theme']) && isset($_REQUEST['token']) && drupal_valid_token($_REQUEST['token'], 'system-theme-operation-link')) {
+ $theme = $_REQUEST['theme'];
+ // Get current list of themes.
+ $themes = list_themes();
+
+ // Check if the specified theme is one recognized by the system.
+ if (!empty($themes[$theme])) {
+ // Enable the theme if it is currently disabled.
+ if (empty($themes[$theme]->status)) {
+ theme_enable(array($theme));
+ }
+ // Set the default theme.
+ variable_set('theme_default', $theme);
+
+ // Rebuild the menu. This duplicates the menu_rebuild() in theme_enable().
+ // However, modules must know the current default theme in order to use
+ // this information in hook_menu() or hook_menu_alter() implementations,
+ // and doing the variable_set() before the theme_enable() could result
+ // in a race condition where the theme is default but not enabled.
+ menu_rebuild();
+
+ // The status message depends on whether an admin theme is currently in use:
+ // a value of 0 means the admin theme is set to be the default theme.
+ $admin_theme = variable_get('admin_theme', 0);
+ if ($admin_theme != 0 && $admin_theme != $theme) {
+ drupal_set_message(t('Please note that the administration theme is still set to the %admin_theme theme; consequently, the theme on this page remains unchanged. All non-administrative sections of the site, however, will show the selected %selected_theme theme by default.', array(
+ '%admin_theme' => $themes[$admin_theme]->info['name'],
+ '%selected_theme' => $themes[$theme]->info['name'],
+ )));
+ }
+ else {
+ drupal_set_message(t('%theme is now the default theme.', array('%theme' => $themes[$theme]->info['name'])));
+ }
+ }
+ else {
+ drupal_set_message(t('The %theme theme was not found.', array('%theme' => $theme)), 'error');
+ }
+ drupal_goto('admin/appearance');
+ }
+ return drupal_access_denied();
+}
+
+/**
+ * Form builder; display theme configuration for entire site and individual themes.
+ *
+ * @param $key
+ * A theme name.
+ * @return
+ * The form structure.
+ * @ingroup forms
+ * @see system_theme_settings_submit()
+ */
+function system_theme_settings($form, &$form_state, $key = '') {
+ // Default settings are defined in theme_get_setting() in includes/theme.inc
+ if ($key) {
+ $var = 'theme_' . $key . '_settings';
+ $themes = system_rebuild_theme_data();
+ $features = $themes[$key]->info['features'];
+ }
+ else {
+ $var = 'theme_settings';
+ }
+
+ $form['var'] = array('#type' => 'hidden', '#value' => $var);
+
+ // Toggle settings
+ $toggles = array(
+ 'logo' => t('Logo'),
+ 'name' => t('Site name'),
+ 'slogan' => t('Site slogan'),
+ 'node_user_picture' => t('User pictures in posts'),
+ 'comment_user_picture' => t('User pictures in comments'),
+ 'comment_user_verification' => t('User verification status in comments'),
+ 'favicon' => t('Shortcut icon'),
+ 'main_menu' => t('Main menu'),
+ 'secondary_menu' => t('Secondary menu'),
+ );
+
+ // Some features are not always available
+ $disabled = array();
+ if (!variable_get('user_pictures', 0)) {
+ $disabled['toggle_node_user_picture'] = TRUE;
+ $disabled['toggle_comment_user_picture'] = TRUE;
+ }
+ if (!module_exists('comment')) {
+ $disabled['toggle_comment_user_picture'] = TRUE;
+ $disabled['toggle_comment_user_verification'] = TRUE;
+ }
+
+ $form['theme_settings'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Toggle display'),
+ '#description' => t('Enable or disable the display of certain page elements.'),
+ );
+ foreach ($toggles as $name => $title) {
+ if ((!$key) || in_array($name, $features)) {
+ $form['theme_settings']['toggle_' . $name] = array('#type' => 'checkbox', '#title' => $title, '#default_value' => theme_get_setting('toggle_' . $name, $key));
+ // Disable checkboxes for features not supported in the current configuration.
+ if (isset($disabled['toggle_' . $name])) {
+ $form['theme_settings']['toggle_' . $name]['#disabled'] = TRUE;
+ }
+ }
+ }
+
+ if (!element_children($form['theme_settings'])) {
+ // If there is no element in the theme settings fieldset then do not show
+ // it -- but keep it in the form if another module wants to alter.
+ $form['theme_settings']['#access'] = FALSE;
+ }
+
+ // Logo settings
+ if ((!$key) || in_array('logo', $features)) {
+ $form['logo'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Logo image settings'),
+ '#description' => t('If toggled on, the following logo will be displayed.'),
+ '#attributes' => array('class' => array('theme-settings-bottom')),
+ );
+ $form['logo']['default_logo'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Use the default logo'),
+ '#default_value' => theme_get_setting('default_logo', $key),
+ '#tree' => FALSE,
+ '#description' => t('Check here if you want the theme to use the logo supplied with it.')
+ );
+ $form['logo']['settings'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the logo settings when using the default logo.
+ 'invisible' => array(
+ 'input[name="default_logo"]' => array('checked' => TRUE),
+ ),
+ ),
+ );
+ $logo_path = theme_get_setting('logo_path', $key);
+ // If $logo_path is a public:// URI, display the path relative to the files
+ // directory; stream wrappers are not end-user friendly.
+ if (file_uri_scheme($logo_path) == 'public') {
+ $logo_path = file_uri_target($logo_path);
+ }
+ $form['logo']['settings']['logo_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Path to custom logo'),
+ '#default_value' => $logo_path,
+ '#description' => t('The path to the file you would like to use as your logo file instead of the default logo.'),
+ );
+ $form['logo']['settings']['logo_upload'] = array(
+ '#type' => 'file',
+ '#title' => t('Upload logo image'),
+ '#maxlength' => 40,
+ '#description' => t("If you don't have direct file access to the server, use this field to upload your logo.")
+ );
+ }
+
+ if ((!$key) || in_array('favicon', $features)) {
+ $form['favicon'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Shortcut icon settings'),
+ '#description' => t("Your shortcut icon, or 'favicon', is displayed in the address bar and bookmarks of most browsers."),
+ );
+ $form['favicon']['default_favicon'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Use the default shortcut icon.'),
+ '#default_value' => theme_get_setting('default_favicon', $key),
+ '#description' => t('Check here if you want the theme to use the default shortcut icon.')
+ );
+ $form['favicon']['settings'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the favicon settings when using the default favicon.
+ 'invisible' => array(
+ 'input[name="default_favicon"]' => array('checked' => TRUE),
+ ),
+ ),
+ );
+ $favicon_path = theme_get_setting('favicon_path', $key);
+ // If $favicon_path is a public:// URI, display the path relative to the
+ // files directory; stream wrappers are not end-user friendly.
+ if (file_uri_scheme($favicon_path) == 'public') {
+ $favicon_path = file_uri_target($favicon_path);
+ }
+ $form['favicon']['settings']['favicon_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Path to custom icon'),
+ '#default_value' => $favicon_path,
+ '#description' => t('The path to the image file you would like to use as your custom shortcut icon.')
+ );
+ $form['favicon']['settings']['favicon_upload'] = array(
+ '#type' => 'file',
+ '#title' => t('Upload icon image'),
+ '#description' => t("If you don't have direct file access to the server, use this field to upload your shortcut icon.")
+ );
+ }
+
+ if ($key) {
+ // Call engine-specific settings.
+ $function = $themes[$key]->prefix . '_engine_settings';
+ if (function_exists($function)) {
+ $form['engine_specific'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Theme-engine-specific settings'),
+ '#description' => t('These settings only exist for the themes based on the %engine theme engine.', array('%engine' => $themes[$key]->prefix)),
+ );
+ $function($form, $form_state);
+ }
+
+ // Create a list which includes the current theme and all its base themes.
+ if (isset($themes[$key]->base_themes)) {
+ $theme_keys = array_keys($themes[$key]->base_themes);
+ $theme_keys[] = $key;
+ }
+ else {
+ $theme_keys = array($key);
+ }
+
+ // Save the name of the current theme (if any), so that we can temporarily
+ // override the current theme and allow theme_get_setting() to work
+ // without having to pass the theme name to it.
+ $default_theme = !empty($GLOBALS['theme_key']) ? $GLOBALS['theme_key'] : NULL;
+ $GLOBALS['theme_key'] = $key;
+
+ // Process the theme and all its base themes.
+ foreach ($theme_keys as $theme) {
+ // Include the theme-settings.php file.
+ $filename = DRUPAL_ROOT . '/' . str_replace("/$theme.info", '', $themes[$theme]->filename) . '/theme-settings.php';
+ if (file_exists($filename)) {
+ require_once $filename;
+ }
+
+ // Call theme-specific settings.
+ $function = $theme . '_form_system_theme_settings_alter';
+ if (function_exists($function)) {
+ $function($form, $form_state);
+ }
+ }
+
+ // Restore the original current theme.
+ if (isset($default_theme)) {
+ $GLOBALS['theme_key'] = $default_theme;
+ }
+ else {
+ unset($GLOBALS['theme_key']);
+ }
+ }
+
+ $form = system_settings_form($form);
+ // We don't want to call system_settings_form_submit(), so change #submit.
+ array_pop($form['#submit']);
+ $form['#submit'][] = 'system_theme_settings_submit';
+ $form['#validate'][] = 'system_theme_settings_validate';
+ return $form;
+}
+
+/**
+ * Validator for the system_theme_settings() form.
+ */
+function system_theme_settings_validate($form, &$form_state) {
+ // Handle file uploads.
+ $validators = array('file_validate_is_image' => array());
+
+ // Check for a new uploaded logo.
+ $file = file_save_upload('logo_upload', $validators);
+ if (isset($file)) {
+ // File upload was attempted.
+ if ($file) {
+ // Put the temporary file in form_values so we can save it on submit.
+ $form_state['values']['logo_upload'] = $file;
+ }
+ else {
+ // File upload failed.
+ form_set_error('logo_upload', t('The logo could not be uploaded.'));
+ }
+ }
+
+ $validators = array('file_validate_extensions' => array('ico png gif jpg jpeg apng svg'));
+
+ // Check for a new uploaded favicon.
+ $file = file_save_upload('favicon_upload', $validators);
+ if (isset($file)) {
+ // File upload was attempted.
+ if ($file) {
+ // Put the temporary file in form_values so we can save it on submit.
+ $form_state['values']['favicon_upload'] = $file;
+ }
+ else {
+ // File upload failed.
+ form_set_error('logo_upload', t('The favicon could not be uploaded.'));
+ }
+ }
+
+ // If the user provided a path for a logo or favicon file, make sure a file
+ // exists at that path.
+ if ($form_state['values']['logo_path']) {
+ $path = _system_theme_settings_validate_path($form_state['values']['logo_path']);
+ if (!$path) {
+ form_set_error('logo_path', t('The custom logo path is invalid.'));
+ }
+ }
+ if ($form_state['values']['favicon_path']) {
+ $path = _system_theme_settings_validate_path($form_state['values']['favicon_path']);
+ if (!$path) {
+ form_set_error('favicon_path', t('The custom favicon path is invalid.'));
+ }
+ }
+}
+
+/**
+ * Helper function for the system_theme_settings form.
+ *
+ * Attempts to validate normal system paths, paths relative to the public files
+ * directory, or stream wrapper URIs. If the given path is any of the above,
+ * returns a valid path or URI that the theme system can display.
+ *
+ * @param $path
+ * A path relative to the Drupal root or to the public files directory, or
+ * a stream wrapper URI.
+ * @return mixed
+ * A valid path that can be displayed through the theme system, or FALSE if
+ * the path could not be validated.
+ */
+function _system_theme_settings_validate_path($path) {
+ if (drupal_realpath($path)) {
+ // The path is relative to the Drupal root, or is a valid URI.
+ return $path;
+ }
+ $uri = 'public://' . $path;
+ if (file_exists($uri)) {
+ return $uri;
+ }
+ return FALSE;
+}
+
+/**
+ * Process system_theme_settings form submissions.
+ */
+function system_theme_settings_submit($form, &$form_state) {
+ $values = $form_state['values'];
+
+ // If the user uploaded a new logo or favicon, save it to a permanent location
+ // and use it in place of the default theme-provided file.
+ if ($file = $values['logo_upload']) {
+ unset($values['logo_upload']);
+ $filename = file_unmanaged_copy($file->uri);
+ $values['default_logo'] = 0;
+ $values['logo_path'] = $filename;
+ $values['toggle_logo'] = 1;
+ }
+ if ($file = $values['favicon_upload']) {
+ unset($values['favicon_upload']);
+ $filename = file_unmanaged_copy($file->uri);
+ $values['default_favicon'] = 0;
+ $values['favicon_path'] = $filename;
+ $values['toggle_favicon'] = 1;
+ }
+
+ // If the user entered a path relative to the system files directory for
+ // a logo or favicon, store a public:// URI so the theme system can handle it.
+ if (!empty($values['logo_path'])) {
+ $values['logo_path'] = _system_theme_settings_validate_path($values['logo_path']);
+ }
+ if (!empty($values['favicon_path'])) {
+ $values['favicon_path'] = _system_theme_settings_validate_path($values['favicon_path']);
+ }
+
+ if (empty($values['default_favicon']) && !empty($values['favicon_path'])) {
+ $values['favicon_mimetype'] = file_get_mimetype($values['favicon_path']);
+ }
+ $key = $values['var'];
+
+ // Exclude unnecessary elements before saving.
+ unset($values['var'], $values['submit'], $values['reset'], $values['form_id'], $values['op'], $values['form_build_id'], $values['form_token']);
+ variable_set($key, $values);
+ drupal_set_message(t('The configuration options have been saved.'));
+
+ cache_clear_all();
+}
+
+/**
+ * Recursively check compatibility.
+ *
+ * @param $incompatible
+ * An associative array which at the end of the check contains all
+ * incompatible files as the keys, their values being TRUE.
+ * @param $files
+ * The set of files that will be tested.
+ * @param $file
+ * The file at which the check starts.
+ * @return
+ * Returns TRUE if an incompatible file is found, NULL (no return value)
+ * otherwise.
+ */
+function _system_is_incompatible(&$incompatible, $files, $file) {
+ if (isset($incompatible[$file->name])) {
+ return TRUE;
+ }
+ // Recursively traverse required modules, looking for incompatible modules.
+ foreach ($file->requires as $requires) {
+ if (isset($files[$requires]) && _system_is_incompatible($incompatible, $files, $files[$requires])) {
+ $incompatible[$file->name] = TRUE;
+ return TRUE;
+ }
+ }
+}
+
+/**
+ * Menu callback; provides module enable/disable interface.
+ *
+ * The list of modules gets populated by module.info files, which contain each
+ * module's name, description, and information about which modules it requires.
+ * See drupal_parse_info_file() for information on module.info descriptors.
+ *
+ * Dependency checking is performed to ensure that a module:
+ * - can not be enabled if there are disabled modules it requires.
+ * - can not be disabled if there are enabled modules which depend on it.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form.
+ *
+ * @return
+ * The form array.
+ *
+ * @ingroup forms
+ * @see theme_system_modules()
+ * @see system_modules_submit()
+ */
+function system_modules($form, $form_state = array()) {
+ // Get current list of modules.
+ $files = system_rebuild_module_data();
+
+ // Remove hidden modules from display list.
+ $visible_files = $files;
+ foreach ($visible_files as $filename => $file) {
+ if (!empty($file->info['hidden'])) {
+ unset($visible_files[$filename]);
+ }
+ }
+
+ uasort($visible_files, 'system_sort_modules_by_info_name');
+
+ // If the modules form was submitted, then system_modules_submit() runs first
+ // and if there are unfilled required modules, then $form_state['storage'] is
+ // filled, triggering a rebuild. In this case we need to display a
+ // confirmation form.
+ if (!empty($form_state['storage'])) {
+ return system_modules_confirm_form($visible_files, $form_state['storage']);
+ }
+
+ $modules = array();
+ $form['modules'] = array('#tree' => TRUE);
+
+ // Used when checking if module implements a help page.
+ $help_arg = module_exists('help') ? drupal_help_arg() : FALSE;
+
+ // Used when displaying modules that are required by the install profile.
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ $distribution_name = check_plain(drupal_install_profile_distribution_name());
+
+ // Iterate through each of the modules.
+ foreach ($visible_files as $filename => $module) {
+ $extra = array();
+ $extra['enabled'] = (bool) $module->status;
+ if (!empty($module->info['required'] )) {
+ $extra['disabled'] = TRUE;
+ $extra['required_by'][] = $distribution_name . (!empty($module->info['explanation']) ? ' ('. $module->info['explanation'] .')' : '');
+ }
+
+ // If this module requires other modules, add them to the array.
+ foreach ($module->requires as $requires => $v) {
+ if (!isset($files[$requires])) {
+ $extra['requires'][$requires] = t('@module (<span class="admin-missing">missing</span>)', array('@module' => drupal_ucfirst($requires)));
+ $extra['disabled'] = TRUE;
+ }
+ // Only display visible modules.
+ elseif (isset($visible_files[$requires])) {
+ $requires_name = $files[$requires]->info['name'];
+ if ($incompatible_version = drupal_check_incompatibility($v, str_replace(DRUPAL_CORE_COMPATIBILITY . '-', '', $files[$requires]->info['version']))) {
+ $extra['requires'][$requires] = t('@module (<span class="admin-missing">incompatible with</span> version @version)', array(
+ '@module' => $requires_name . $incompatible_version,
+ '@version' => $files[$requires]->info['version'],
+ ));
+ $extra['disabled'] = TRUE;
+ }
+ elseif ($files[$requires]->status) {
+ $extra['requires'][$requires] = t('@module (<span class="admin-enabled">enabled</span>)', array('@module' => $requires_name));
+ }
+ else {
+ $extra['requires'][$requires] = t('@module (<span class="admin-disabled">disabled</span>)', array('@module' => $requires_name));
+ }
+ }
+ }
+ // Generate link for module's help page, if there is one.
+ if ($help_arg && $module->status && in_array($filename, module_implements('help'))) {
+ if (module_invoke($filename, 'help', "admin/help#$filename", $help_arg)) {
+ $extra['links']['help'] = array(
+ '#type' => 'link',
+ '#title' => t('Help'),
+ '#href' => "admin/help/$filename",
+ '#options' => array('attributes' => array('class' => array('module-link', 'module-link-help'), 'title' => t('Help'))),
+ );
+ }
+ }
+ // Generate link for module's permission, if the user has access to it.
+ if ($module->status && user_access('administer permissions') && in_array($filename, module_implements('permission'))) {
+ $extra['links']['permissions'] = array(
+ '#type' => 'link',
+ '#title' => t('Permissions'),
+ '#href' => 'admin/people/permissions',
+ '#options' => array('fragment' => 'module-' . $filename, 'attributes' => array('class' => array('module-link', 'module-link-permissions'), 'title' => t('Configure permissions'))),
+ );
+ }
+ // Generate link for module's configuration page, if the module provides
+ // one.
+ if ($module->status && isset($module->info['configure'])) {
+ $configure_link = menu_get_item($module->info['configure']);
+ if ($configure_link['access']) {
+ $extra['links']['configure'] = array(
+ '#type' => 'link',
+ '#title' => t('Configure'),
+ '#href' => $configure_link['href'],
+ '#options' => array('attributes' => array('class' => array('module-link', 'module-link-configure'), 'title' => $configure_link['description'])),
+ );
+ }
+ }
+
+ // If this module is required by other modules, list those, and then make it
+ // impossible to disable this one.
+ foreach ($module->required_by as $required_by => $v) {
+ // Hidden modules are unset already.
+ if (isset($visible_files[$required_by])) {
+ if ($files[$required_by]->status == 1 && $module->status == 1) {
+ $extra['required_by'][] = t('@module (<span class="admin-enabled">enabled</span>)', array('@module' => $files[$required_by]->info['name']));
+ $extra['disabled'] = TRUE;
+ }
+ else {
+ $extra['required_by'][] = t('@module (<span class="admin-disabled">disabled</span>)', array('@module' => $files[$required_by]->info['name']));
+ }
+ }
+ }
+ $form['modules'][$module->info['package']][$filename] = _system_modules_build_row($module->info, $extra);
+ }
+
+ // Add basic information to the fieldsets.
+ foreach (element_children($form['modules']) as $package) {
+ $form['modules'][$package] += array(
+ '#type' => 'fieldset',
+ '#title' => t($package),
+ '#collapsible' => TRUE,
+ '#theme' => 'system_modules_fieldset',
+ '#header' => array(
+ array('data' => t('Enabled'), 'class' => array('checkbox')),
+ t('Name'),
+ t('Version'),
+ t('Description'),
+ array('data' => t('Operations'), 'colspan' => 3),
+ ),
+ // Ensure that the "Core" package fieldset comes first.
+ '#weight' => $package == 'Core' ? -10 : NULL,
+ );
+ }
+
+ // Lastly, sort all fieldsets by title.
+ uasort($form['modules'], 'element_sort_by_title');
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save configuration'),
+ );
+ $form['#action'] = url('admin/modules/list/confirm');
+
+ return $form;
+}
+
+/**
+ * Array sorting callback; sorts modules or themes by their name.
+ */
+function system_sort_modules_by_info_name($a, $b) {
+ return strcasecmp($a->info['name'], $b->info['name']);
+}
+
+/**
+ * Array sorting callback; sorts modules or themes by their name.
+ */
+function system_sort_themes($a, $b) {
+ if ($a->is_default) {
+ return -1;
+ }
+ if ($b->is_default) {
+ return 1;
+ }
+ return strcasecmp($a->info['name'], $b->info['name']);
+}
+
+/**
+ * Build a table row for the system modules page.
+ */
+function _system_modules_build_row($info, $extra) {
+ // Add in the defaults.
+ $extra += array(
+ 'requires' => array(),
+ 'required_by' => array(),
+ 'disabled' => FALSE,
+ 'enabled' => FALSE,
+ 'links' => array(),
+ );
+ $form = array(
+ '#tree' => TRUE,
+ );
+ // Set the basic properties.
+ $form['name'] = array(
+ '#markup' => $info['name'],
+ );
+ $form['description'] = array(
+ '#markup' => t($info['description']),
+ );
+ $form['version'] = array(
+ '#markup' => $info['version'],
+ );
+ $form['#requires'] = $extra['requires'];
+ $form['#required_by'] = $extra['required_by'];
+
+ // Check the compatibilities.
+ $compatible = TRUE;
+ $status_short = '';
+ $status_long = '';
+
+ // Check the core compatibility.
+ if (!isset($info['core']) || $info['core'] != DRUPAL_CORE_COMPATIBILITY) {
+ $compatible = FALSE;
+ $status_short .= t('Incompatible with this version of Drupal core.');
+ $status_long .= t('This version is not compatible with Drupal !core_version and should be replaced.', array('!core_version' => DRUPAL_CORE_COMPATIBILITY));
+ }
+
+ // Ensure this module is compatible with the currently installed version of PHP.
+ if (version_compare(phpversion(), $info['php']) < 0) {
+ $compatible = FALSE;
+ $status_short .= t('Incompatible with this version of PHP');
+ $php_required = $info['php'];
+ if (substr_count($info['php'], '.') < 2) {
+ $php_required .= '.*';
+ }
+ $status_long .= t('This module requires PHP version @php_required and is incompatible with PHP version !php_version.', array('@php_required' => $php_required, '!php_version' => phpversion()));
+ }
+
+ // If this module is compatible, present a checkbox indicating
+ // this module may be installed. Otherwise, show a big red X.
+ if ($compatible) {
+ $form['enable'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable'),
+ '#default_value' => $extra['enabled'],
+ );
+ if ($extra['disabled']) {
+ $form['enable']['#disabled'] = TRUE;
+ }
+ }
+ else {
+ $form['enable'] = array(
+ '#markup' => theme('image', array('path' => 'core/misc/watchdog-error.png', 'alt' => $status_short, 'title' => $status_short)),
+ );
+ $form['description']['#markup'] .= theme('system_modules_incompatible', array('message' => $status_long));
+ }
+
+ // Build operation links.
+ foreach (array('help', 'permissions', 'configure') as $key) {
+ $form['links'][$key] = (isset($extra['links'][$key]) ? $extra['links'][$key] : array());
+ }
+
+ return $form;
+}
+
+/**
+ * Display confirmation form for required modules.
+ *
+ * @param $modules
+ * Array of module file objects as returned from system_rebuild_module_data().
+ * @param $storage
+ * The contents of $form_state['storage']; an array with two
+ * elements: the list of required modules and the list of status
+ * form field values from the previous screen.
+ * @ingroup forms
+ */
+function system_modules_confirm_form($modules, $storage) {
+ $items = array();
+
+ $form['validation_modules'] = array('#type' => 'value', '#value' => $modules);
+ $form['status']['#tree'] = TRUE;
+
+ foreach ($storage['more_required'] as $info) {
+ $t_argument = array(
+ '@module' => $info['name'],
+ '@required' => implode(', ', $info['requires']),
+ );
+ $items[] = format_plural(count($info['requires']), 'You must enable the @required module to install @module.', 'You must enable the @required modules to install @module.', $t_argument);
+ }
+
+ foreach ($storage['missing_modules'] as $name => $info) {
+ $t_argument = array(
+ '@module' => $name,
+ '@depends' => implode(', ', $info['depends']),
+ );
+ $items[] = format_plural(count($info['depends']), 'The @module module is missing, so the following module will be disabled: @depends.', 'The @module module is missing, so the following modules will be disabled: @depends.', $t_argument);
+ }
+
+ $form['text'] = array('#markup' => theme('item_list', array('items' => $items)));
+
+ if ($form) {
+ // Set some default form values
+ $form = confirm_form(
+ $form,
+ t('Some required modules must be enabled'),
+ 'admin/modules',
+ t('Would you like to continue with the above?'),
+ t('Continue'),
+ t('Cancel'));
+ return $form;
+ }
+}
+
+/**
+ * Submit callback; handles modules form submission.
+ */
+function system_modules_submit($form, &$form_state) {
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+
+ // Builds list of modules.
+ $modules = array();
+ // If we're not coming from the confirmation form, build the list of modules.
+ if (empty($form_state['storage'])) {
+ // If we're not coming from the confirmation form, build the module list.
+ foreach ($form_state['values']['modules'] as $group_name => $group) {
+ foreach ($group as $module => $enabled) {
+ $modules[$module] = array('group' => $group_name, 'enabled' => $enabled['enable']);
+ }
+ }
+ }
+ else {
+ // If we are coming from the confirmation form, fetch
+ // the modules out of $form_state.
+ $modules = $form_state['storage']['modules'];
+ }
+
+ // Collect data for all modules to be able to determine dependencies.
+ $files = system_rebuild_module_data();
+
+ // Sorts modules by weight.
+ $sort = array();
+ foreach (array_keys($modules) as $module) {
+ $sort[$module] = $files[$module]->sort;
+ }
+ array_multisort($sort, $modules);
+
+ // Makes sure all required modules are set to be enabled.
+ $more_required = array();
+ $missing_modules = array();
+ foreach ($modules as $name => $module) {
+ if ($module['enabled']) {
+ // Checks that all dependencies are set to be enabled. Stores the ones
+ // that are not in $dependencies variable so that the user can be alerted
+ // in the confirmation form that more modules need to be enabled.
+ $dependencies = array();
+ foreach (array_keys($files[$name]->requires) as $required) {
+ if (empty($modules[$required]['enabled'])) {
+ if (isset($files[$required])) {
+ $dependencies[] = $files[$required]->info['name'];
+ $modules[$required]['enabled'] = TRUE;
+ }
+ else {
+ $missing_modules[$required]['depends'][] = $name;
+ $modules[$name]['enabled'] = FALSE;
+ }
+ }
+ }
+
+ // Stores additional modules that need to be enabled in $more_required.
+ if (!empty($dependencies)) {
+ $more_required[$name] = array(
+ 'name' => $files[$name]->info['name'],
+ 'requires' => $dependencies,
+ );
+ }
+ }
+ }
+
+ // Redirects to confirmation form if more modules need to be enabled.
+ if ((!empty($more_required) || !empty($missing_modules)) && !isset($form_state['values']['confirm'])) {
+ $form_state['storage'] = array(
+ 'more_required' => $more_required,
+ 'modules' => $modules,
+ 'missing_modules' => $missing_modules,
+ );
+ $form_state['rebuild'] = TRUE;
+ return;
+ }
+
+ // Invokes hook_requirements('install'). If failures are detected, makes sure
+ // the dependent modules aren't installed either.
+ foreach ($modules as $name => $module) {
+ // Only invoke hook_requirements() on modules that are going to be installed.
+ if ($module['enabled'] && drupal_get_installed_schema_version($name) == SCHEMA_UNINSTALLED) {
+ if (!drupal_check_module($name)) {
+ $modules[$name]['enabled'] = FALSE;
+ foreach (array_keys($files[$name]->required_by) as $required_by) {
+ $modules[$required_by]['enabled'] = FALSE;
+ }
+ }
+ }
+ }
+
+ // Initializes array of actions.
+ $actions = array(
+ 'enable' => array(),
+ 'disable' => array(),
+ 'install' => array(),
+ );
+
+ // Builds arrays of modules that need to be enabled, disabled, and installed.
+ foreach ($modules as $name => $module) {
+ if ($module['enabled']) {
+ if (drupal_get_installed_schema_version($name) == SCHEMA_UNINSTALLED) {
+ $actions['install'][] = $name;
+ $actions['enable'][] = $name;
+ }
+ elseif (!module_exists($name)) {
+ $actions['enable'][] = $name;
+ }
+ }
+ elseif (module_exists($name)) {
+ $actions['disable'][] = $name;
+ }
+ }
+
+ // Gets list of modules prior to install process, unsets $form_state['storage']
+ // so we don't get redirected back to the confirmation form.
+ $pre_install_list = module_list();
+ unset($form_state['storage']);
+
+ // Reverse the 'enable' list, to order dependencies before dependents.
+ krsort($actions['enable']);
+
+ // Installs, enables, and disables modules.
+ module_enable($actions['enable'], FALSE);
+ module_disable($actions['disable'], FALSE);
+
+ // Gets module list after install process, flushes caches and displays a
+ // message if there are changes.
+ $post_install_list = module_list(TRUE);
+ if ($pre_install_list != $post_install_list) {
+ drupal_flush_all_caches();
+ drupal_set_message(t('The configuration options have been saved.'));
+ }
+
+ $form_state['redirect'] = 'admin/modules';
+}
+
+/**
+ * Uninstall functions
+ */
+
+/**
+ * Builds a form of currently disabled modules.
+ *
+ * @ingroup forms
+ * @see system_modules_uninstall_validate()
+ * @see system_modules_uninstall_submit()
+ * @param $form_state['values']
+ * Submitted form values.
+ * @return
+ * A form array representing the currently disabled modules.
+ */
+function system_modules_uninstall($form, $form_state = NULL) {
+ // Make sure the install API is available.
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+
+ // Display the confirm form if any modules have been submitted.
+ if (!empty($form_state['storage']) && $confirm_form = system_modules_uninstall_confirm_form($form_state['storage'])) {
+ return $confirm_form;
+ }
+
+ // Get a list of disabled, installed modules.
+ $all_modules = system_rebuild_module_data();
+ $disabled_modules = array();
+ foreach ($all_modules as $name => $module) {
+ if (empty($module->status) && $module->schema_version > SCHEMA_UNINSTALLED) {
+ $disabled_modules[$name] = $module;
+ }
+ }
+
+ // Only build the rest of the form if there are any modules available to
+ // uninstall.
+ if (!empty($disabled_modules)) {
+ $profile = drupal_get_profile();
+ uasort($disabled_modules, 'system_sort_modules_by_info_name');
+ $form['uninstall'] = array('#tree' => TRUE);
+ foreach ($disabled_modules as $module) {
+ $module_name = $module->info['name'] ? $module->info['name'] : $module->name;
+ $form['modules'][$module->name]['#module_name'] = $module_name;
+ $form['modules'][$module->name]['name']['#markup'] = $module_name;
+ $form['modules'][$module->name]['description']['#markup'] = t($module->info['description']);
+ $form['uninstall'][$module->name] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Uninstall @module module', array('@module' => $module_name)),
+ '#title_display' => 'invisible',
+ );
+ // All modules which depend on this one must be uninstalled first, before
+ // we can allow this module to be uninstalled. (The install profile is
+ // excluded from this list.)
+ foreach (array_keys($module->required_by) as $dependent) {
+ if ($dependent != $profile && drupal_get_installed_schema_version($dependent) != SCHEMA_UNINSTALLED) {
+ $dependent_name = isset($all_modules[$dependent]->info['name']) ? $all_modules[$dependent]->info['name'] : $dependent;
+ $form['modules'][$module->name]['#required_by'][] = $dependent_name;
+ $form['uninstall'][$module->name]['#disabled'] = TRUE;
+ }
+ }
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Uninstall'),
+ );
+ $form['#action'] = url('admin/modules/uninstall/confirm');
+ }
+ else {
+ $form['modules'] = array();
+ }
+
+ return $form;
+}
+
+/**
+ * Confirm uninstall of selected modules.
+ *
+ * @ingroup forms
+ * @param $storage
+ * An associative array of modules selected to be uninstalled.
+ * @return
+ * A form array representing modules to confirm.
+ */
+function system_modules_uninstall_confirm_form($storage) {
+ // Nothing to build.
+ if (empty($storage)) {
+ return;
+ }
+
+ // Construct the hidden form elements and list items.
+ foreach (array_filter($storage['uninstall']) as $module => $value) {
+ $info = drupal_parse_info_file(drupal_get_path('module', $module) . '/' . $module . '.info');
+ $uninstall[] = $info['name'];
+ $form['uninstall'][$module] = array('#type' => 'hidden',
+ '#value' => 1,
+ );
+ }
+
+ // Display a confirm form if modules have been selected.
+ if (isset($uninstall)) {
+ $form['#confirmed'] = TRUE;
+ $form['uninstall']['#tree'] = TRUE;
+ $form['modules'] = array('#markup' => '<p>' . t('The following modules will be completely uninstalled from your site, and <em>all data from these modules will be lost</em>!') . '</p>' . theme('item_list', array('items' => $uninstall)));
+ $form = confirm_form(
+ $form,
+ t('Confirm uninstall'),
+ 'admin/modules/uninstall',
+ t('Would you like to continue with uninstalling the above?'),
+ t('Uninstall'),
+ t('Cancel'));
+ return $form;
+ }
+}
+
+/**
+ * Validates the submitted uninstall form.
+ */
+function system_modules_uninstall_validate($form, &$form_state) {
+ // Form submitted, but no modules selected.
+ if (!count(array_filter($form_state['values']['uninstall']))) {
+ drupal_set_message(t('No modules selected.'), 'error');
+ drupal_goto('admin/modules/uninstall');
+ }
+}
+
+/**
+ * Processes the submitted uninstall form.
+ */
+function system_modules_uninstall_submit($form, &$form_state) {
+ // Make sure the install API is available.
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+
+ if (!empty($form['#confirmed'])) {
+ // Call the uninstall routine for each selected module.
+ $modules = array_keys($form_state['values']['uninstall']);
+ drupal_uninstall_modules($modules);
+ drupal_set_message(t('The selected modules have been uninstalled.'));
+
+ $form_state['redirect'] = 'admin/modules/uninstall';
+ }
+ else {
+ $form_state['storage'] = $form_state['values'];
+ $form_state['rebuild'] = TRUE;
+ }
+}
+
+/**
+ * Menu callback. Display blocked IP addresses.
+ *
+ * @param $default_ip
+ * Optional IP address to be passed on to drupal_get_form() for
+ * use as the default value of the IP address form field.
+ */
+function system_ip_blocking($default_ip = '') {
+ $rows = array();
+ $header = array(t('Blocked IP addresses'), t('Operations'));
+ $result = db_query('SELECT * FROM {blocked_ips}');
+ foreach ($result as $ip) {
+ $rows[] = array(
+ $ip->ip,
+ l(t('delete'), "admin/config/people/ip-blocking/delete/$ip->iid"),
+ );
+ }
+
+ $build['system_ip_blocking_form'] = drupal_get_form('system_ip_blocking_form', $default_ip);
+
+ $build['system_ip_blocking_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ );
+
+ return $build;
+}
+
+/**
+ * Define the form for blocking IP addresses.
+ *
+ * @ingroup forms
+ * @see system_ip_blocking_form_validate()
+ * @see system_ip_blocking_form_submit()
+ */
+function system_ip_blocking_form($form, $form_state, $default_ip) {
+ $form['ip'] = array(
+ '#title' => t('IP address'),
+ '#type' => 'textfield',
+ '#size' => 48,
+ '#maxlength' => 40,
+ '#default_value' => $default_ip,
+ '#description' => t('Enter a valid IP address.'),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add'),
+ );
+ $form['#submit'][] = 'system_ip_blocking_form_submit';
+ $form['#validate'][] = 'system_ip_blocking_form_validate';
+ return $form;
+}
+
+function system_ip_blocking_form_validate($form, &$form_state) {
+ $ip = trim($form_state['values']['ip']);
+ if (db_query("SELECT * FROM {blocked_ips} WHERE ip = :ip", array(':ip' => $ip))->fetchField()) {
+ form_set_error('ip', t('This IP address is already blocked.'));
+ }
+ elseif ($ip == ip_address()) {
+ form_set_error('ip', t('You may not block your own IP address.'));
+ }
+ elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) == FALSE) {
+ form_set_error('ip', t('Enter a valid IP address.'));
+ }
+}
+
+function system_ip_blocking_form_submit($form, &$form_state) {
+ $ip = trim($form_state['values']['ip']);
+ db_insert('blocked_ips')
+ ->fields(array('ip' => $ip))
+ ->execute();
+ drupal_set_message(t('The IP address %ip has been blocked.', array('%ip' => $ip)));
+ $form_state['redirect'] = 'admin/config/people/ip-blocking';
+ return;
+}
+
+/**
+ * IP deletion confirm page.
+ *
+ * @see system_ip_blocking_delete_submit()
+ */
+function system_ip_blocking_delete($form, &$form_state, $iid) {
+ $form['blocked_ip'] = array(
+ '#type' => 'value',
+ '#value' => $iid,
+ );
+ return confirm_form($form, t('Are you sure you want to delete %ip?', array('%ip' => $iid['ip'])), 'admin/config/people/ip-blocking', t('This action cannot be undone.'), t('Delete'), t('Cancel'));
+}
+
+/**
+ * Process system_ip_blocking_delete form submissions.
+ */
+function system_ip_blocking_delete_submit($form, &$form_state) {
+ $blocked_ip = $form_state['values']['blocked_ip'];
+ db_delete('blocked_ips')
+ ->condition('iid', $blocked_ip['iid'])
+ ->execute();
+ watchdog('user', 'Deleted %ip', array('%ip' => $blocked_ip['ip']));
+ drupal_set_message(t('The IP address %ip was deleted.', array('%ip' => $blocked_ip['ip'])));
+ $form_state['redirect'] = 'admin/config/people/ip-blocking';
+}
+
+/**
+ * Form builder; The general site information form.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_site_information_settings() {
+ $form['site_information'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Site details'),
+ );
+ $form['site_information']['site_name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Site name'),
+ '#default_value' => variable_get('site_name', 'Drupal'),
+ '#required' => TRUE
+ );
+ $form['site_information']['site_slogan'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Slogan'),
+ '#default_value' => variable_get('site_slogan', ''),
+ '#description' => t("How this is used depends on your site's theme."),
+ );
+ $form['site_information']['site_mail'] = array(
+ '#type' => 'textfield',
+ '#title' => t('E-mail address'),
+ '#default_value' => variable_get('site_mail', ini_get('sendmail_from')),
+ '#description' => t("The <em>From</em> address in automated e-mails sent during registration and new password requests, and other notifications. (Use an address ending in your site's domain to help prevent this e-mail being flagged as spam.)"),
+ '#required' => TRUE,
+ );
+ $form['front_page'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Front page'),
+ );
+ $form['front_page']['site_frontpage'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Default front page'),
+ '#default_value' => (variable_get('site_frontpage')!='node'?drupal_get_path_alias(variable_get('site_frontpage', 'node')):''),
+ '#size' => 40,
+ '#description' => t('Optionally, specify a relative URL to display as the front page. Leave blank to display the default content feed.'),
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q='),
+ );
+ $form['front_page']['default_nodes_main'] = array(
+ '#type' => 'select', '#title' => t('Number of posts on front page'),
+ '#default_value' => variable_get('default_nodes_main', 10),
+ '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30)),
+ '#description' => t('The maximum number of posts displayed on overview pages such as the front page.'),
+ '#access' => (variable_get('site_frontpage')=='node'),
+ );
+ $form['error_page'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Error pages'),
+ );
+ $form['error_page']['site_403'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Default 403 (access denied) page'),
+ '#default_value' => variable_get('site_403', ''),
+ '#size' => 40,
+ '#description' => t('This page is displayed when the requested document is denied to the current user. Leave blank to display a generic "access denied" page.'),
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q=')
+ );
+ $form['error_page']['site_404'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Default 404 (not found) page'),
+ '#default_value' => variable_get('site_404', ''),
+ '#size' => 40,
+ '#description' => t('This page is displayed when no other content matches the requested document. Leave blank to display a generic "page not found" page.'),
+ '#field_prefix' => url(NULL, array('absolute' => TRUE)) . (variable_get('clean_url', 0) ? '' : '?q=')
+ );
+
+ $form['#validate'][] = 'system_site_information_settings_validate';
+
+ return system_settings_form($form);
+}
+
+/**
+ * Validates the submitted site-information form.
+ */
+function system_site_information_settings_validate($form, &$form_state) {
+ // Validate the e-mail address.
+ if ($error = user_validate_mail($form_state['values']['site_mail'])) {
+ form_set_error('site_mail', $error);
+ }
+ // Check for empty front page path.
+ if (empty($form_state['values']['site_frontpage'])) {
+ // Set to default "node".
+ form_set_value($form['front_page']['site_frontpage'], 'node', $form_state);
+ }
+ else {
+ // Get the normal path of the front page.
+ form_set_value($form['front_page']['site_frontpage'], drupal_get_normal_path($form_state['values']['site_frontpage']), $form_state);
+ }
+ // Validate front page path.
+ if (!drupal_valid_path($form_state['values']['site_frontpage'])) {
+ form_set_error('site_frontpage', t("The path '%path' is either invalid or you do not have access to it.", array('%path' => $form_state['values']['site_frontpage'])));
+ }
+ // Get the normal paths of both error pages.
+ if (!empty($form_state['values']['site_403'])) {
+ form_set_value($form['error_page']['site_403'], drupal_get_normal_path($form_state['values']['site_403']), $form_state);
+ }
+ if (!empty($form_state['values']['site_404'])) {
+ form_set_value($form['error_page']['site_404'], drupal_get_normal_path($form_state['values']['site_404']), $form_state);
+ }
+ // Validate 403 error path.
+ if (!empty($form_state['values']['site_403']) && !drupal_valid_path($form_state['values']['site_403'])) {
+ form_set_error('site_403', t("The path '%path' is either invalid or you do not have access to it.", array('%path' => $form_state['values']['site_403'])));
+ }
+ // Validate 404 error path.
+ if (!empty($form_state['values']['site_404']) && !drupal_valid_path($form_state['values']['site_404'])) {
+ form_set_error('site_404', t("The path '%path' is either invalid or you do not have access to it.", array('%path' => $form_state['values']['site_404'])));
+ }
+}
+
+/**
+ * Form builder; Cron form.
+ *
+ * @see system_settings_form()
+ * @ingroup forms
+ */
+function system_cron_settings() {
+ $form['description'] = array(
+ '#markup' => '<p>' . t('Cron takes care of running periodic tasks like checking for updates and indexing content for search.') . '</p>',
+ );
+ $form['run'] = array(
+ '#type' => 'submit',
+ '#value' => t('Run cron'),
+ '#submit' => array('system_run_cron_submit'),
+ );
+ $status = '<p>' . t('Last run: %cron-last ago.', array('%cron-last' => format_interval(REQUEST_TIME - variable_get('cron_last')),)) . '</p>';
+ $form['status'] = array(
+ '#markup' => $status,
+ );
+ $form['cron'] = array(
+ '#type' => 'fieldset',
+ );
+ $form['cron']['cron_safe_threshold'] = array(
+ '#type' => 'select',
+ '#title' => t('Run cron every'),
+ '#default_value' => variable_get('cron_safe_threshold', DRUPAL_CRON_DEFAULT_THRESHOLD),
+ '#options' => array(0 => t('Never')) + drupal_map_assoc(array(3600, 10800, 21600, 43200, 86400, 604800), 'format_interval'),
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Submit callback; run cron.
+ *
+ * @ingroup forms
+ */
+function system_run_cron_submit($form, &$form_state) {
+ // Run cron manually from Cron form.
+ if (drupal_cron_run()) {
+ drupal_set_message(t('Cron run successfully.'));
+ }
+ else {
+ drupal_set_message(t('Cron run failed.'), 'error');
+ }
+
+ drupal_goto('admin/config/system/cron');
+}
+
+/**
+ * Form builder; Configure error reporting settings.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_logging_settings() {
+ $form['error_level'] = array(
+ '#type' => 'radios',
+ '#title' => t('Error messages to display'),
+ '#default_value' => variable_get('error_level', ERROR_REPORTING_DISPLAY_ALL),
+ '#options' => array(
+ ERROR_REPORTING_HIDE => t('None'),
+ ERROR_REPORTING_DISPLAY_SOME => t('Errors and warnings'),
+ ERROR_REPORTING_DISPLAY_ALL => t('All messages'),
+ ),
+ '#description' => t('It is recommended that sites running on production environments do not display any errors.'),
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure site performance settings.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_performance_settings() {
+ drupal_add_js(drupal_get_path('module', 'system') . '/system.js');
+
+ $form['clear_cache'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Clear cache'),
+ );
+
+ $form['clear_cache']['clear'] = array(
+ '#type' => 'submit',
+ '#value' => t('Clear all caches'),
+ '#submit' => array('system_clear_cache_submit'),
+ );
+
+ $form['caching'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Caching'),
+ );
+
+ $cache = variable_get('cache', 0);
+ $form['caching']['cache'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Cache pages for anonymous users'),
+ '#default_value' => $cache,
+ '#weight' => -2,
+ );
+ $period = drupal_map_assoc(array(0, 60, 180, 300, 600, 900, 1800, 2700, 3600, 10800, 21600, 32400, 43200, 86400), 'format_interval');
+ $period[0] = '<' . t('none') . '>';
+ $form['caching']['cache_lifetime'] = array(
+ '#type' => 'select',
+ '#title' => t('Minimum cache lifetime'),
+ '#default_value' => variable_get('cache_lifetime', 0),
+ '#options' => $period,
+ '#description' => t('Cached pages will not be re-created until at least this much time has elapsed.')
+ );
+ $form['caching']['page_cache_maximum_age'] = array(
+ '#type' => 'select',
+ '#title' => t('Expiration of cached pages'),
+ '#default_value' => variable_get('page_cache_maximum_age', 0),
+ '#options' => $period,
+ '#description' => t('The maximum time an external cache can use an old version of a page.')
+ );
+
+ $directory = 'public://';
+ $is_writable = is_dir($directory) && is_writable($directory);
+ $disabled = !$is_writable;
+ $disabled_message = '';
+ if (!$is_writable) {
+ $disabled_message = ' ' . t('<strong class="error">Set up the <a href="!file-system">public files directory</a> to make these optimizations available.</strong>', array('!file-system' => url('admin/config/media/file-system')));
+ }
+
+ $form['bandwidth_optimization'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Bandwidth optimization'),
+ '#description' => t('External resources can be optimized automatically, which can reduce both the size and number of requests made to your website.') . $disabled_message,
+ );
+
+ $js_hide = $cache ? '' : ' class="js-hide"';
+ $form['bandwidth_optimization']['page_compression'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Compress cached pages.'),
+ '#default_value' => variable_get('page_compression', TRUE),
+ '#prefix' => '<div id="page-compression-wrapper"' . $js_hide . '>',
+ '#suffix' => '</div>',
+ );
+ $form['bandwidth_optimization']['preprocess_css'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Aggregate and compress CSS files.'),
+ '#default_value' => intval(variable_get('preprocess_css', 0) && $is_writable),
+ '#disabled' => $disabled,
+ );
+ $form['bandwidth_optimization']['preprocess_js'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Aggregate JavaScript files.'),
+ '#default_value' => intval(variable_get('preprocess_js', 0) && $is_writable),
+ '#disabled' => $disabled,
+ );
+
+ $form['#submit'][] = 'drupal_clear_css_cache';
+ $form['#submit'][] = 'drupal_clear_js_cache';
+ // This form allows page compression settings to be changed, which can
+ // invalidate the page cache, so it needs to be cleared on form submit.
+ $form['#submit'][] = 'system_clear_page_cache_submit';
+
+ return system_settings_form($form);
+}
+
+/**
+ * Submit callback; clear system caches.
+ *
+ * @ingroup forms
+ */
+function system_clear_cache_submit($form, &$form_state) {
+ drupal_flush_all_caches();
+ drupal_set_message(t('Caches cleared.'));
+}
+
+/**
+ * Submit callback; clear the page cache.
+ *
+ * @ingroup forms
+ */
+function system_clear_page_cache_submit($form, &$form_state) {
+ cache('page')->flush();
+}
+
+/**
+ * Form builder; Configure the site file handling.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_file_system_settings() {
+ $form['file_public_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Public file system path'),
+ '#default_value' => variable_get('file_public_path', conf_path() . '/files'),
+ '#maxlength' => 255,
+ '#description' => t('A local file system path where public files will be stored. This directory must exist and be writable by Drupal. This directory must be relative to the Drupal installation directory and be accessible over the web.'),
+ '#after_build' => array('system_check_directory'),
+ );
+
+ $form['file_private_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Private file system path'),
+ '#default_value' => variable_get('file_private_path', ''),
+ '#maxlength' => 255,
+ '#description' => t('An existing local file system path for storing private files. It should be writable by Drupal and not accessible over the web. See the online handbook for <a href="@handbook">more information about securing private files</a>.', array('@handbook' => 'http://drupal.org/handbook/modules/file')),
+ '#after_build' => array('system_check_directory'),
+ );
+
+ $form['file_temporary_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Temporary directory'),
+ '#default_value' => variable_get('file_temporary_path', file_directory_temp()),
+ '#maxlength' => 255,
+ '#description' => t('A local file system path where temporary files will be stored. This directory should not be accessible over the web.'),
+ '#after_build' => array('system_check_directory'),
+ );
+ // Any visible, writeable wrapper can potentially be used for the files
+ // directory, including a remote file system that integrates with a CDN.
+ foreach (file_get_stream_wrappers(STREAM_WRAPPERS_WRITE_VISIBLE) as $scheme => $info) {
+ $options[$scheme] = check_plain($info['description']);
+ }
+
+ if (!empty($options)) {
+ $form['file_default_scheme'] = array(
+ '#type' => 'radios',
+ '#title' => t('Default download method'),
+ '#default_value' => variable_get('file_default_scheme', isset($options['public']) ? 'public' : key($options)),
+ '#options' => $options,
+ '#description' => t('This setting is used as the preferred download method. The use of public files is more efficient, but does not provide any access control.'),
+ );
+ }
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure site image toolkit usage.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_image_toolkit_settings() {
+ $toolkits_available = image_get_available_toolkits();
+ $current_toolkit = image_get_toolkit();
+
+ if (count($toolkits_available) == 0) {
+ variable_del('image_toolkit');
+ $form['image_toolkit_help'] = array(
+ '#markup' => t("No image toolkits were detected. Drupal includes support for <a href='!gd-link'>PHP's built-in image processing functions</a> but they were not detected on this system. You should consult your system administrator to have them enabled, or try using a third party toolkit.", array('gd-link' => url('http://php.net/gd'))),
+ );
+ return $form;
+ }
+
+ if (count($toolkits_available) > 1) {
+ $form['image_toolkit'] = array(
+ '#type' => 'radios',
+ '#title' => t('Select an image processing toolkit'),
+ '#default_value' => variable_get('image_toolkit', $current_toolkit),
+ '#options' => $toolkits_available
+ );
+ }
+ else {
+ variable_set('image_toolkit', key($toolkits_available));
+ }
+
+ // Get the toolkit's settings form.
+ $function = 'image_' . $current_toolkit . '_settings';
+ if (function_exists($function)) {
+ $form['image_toolkit_settings'] = $function();
+ }
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure how the site handles RSS feeds.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_rss_feeds_settings() {
+ $form['feed_description'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Feed description'),
+ '#default_value' => variable_get('feed_description', ''),
+ '#description' => t('Description of your site, included in each feed.')
+ );
+ $form['feed_default_items'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of items in each feed'),
+ '#default_value' => variable_get('feed_default_items', 10),
+ '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 25, 30)),
+ '#description' => t('Default number of items to include in each feed.')
+ );
+ $form['feed_item_length'] = array(
+ '#type' => 'select',
+ '#title' => t('Feed content'),
+ '#default_value' => variable_get('feed_item_length', 'fulltext'),
+ '#options' => array('title' => t('Titles only'), 'teaser' => t('Titles plus teaser'), 'fulltext' => t('Full text')),
+ '#description' => t('Global setting for the default display of content items in each feed.')
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure the site regional settings.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ * @see system_regional_settings_submit()
+ */
+function system_regional_settings() {
+ include_once DRUPAL_ROOT . '/core/includes/locale.inc';
+ $countries = country_get_list();
+
+ // Date settings:
+ $zones = system_time_zones();
+
+ $form['locale'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Locale'),
+ );
+
+ $form['locale']['site_default_country'] = array(
+ '#type' => 'select',
+ '#title' => t('Default country'),
+ '#empty_value' => '',
+ '#default_value' => variable_get('site_default_country', ''),
+ '#options' => $countries,
+ '#attributes' => array('class' => array('country-detect')),
+ );
+
+ $form['locale']['date_first_day'] = array(
+ '#type' => 'select',
+ '#title' => t('First day of week'),
+ '#default_value' => variable_get('date_first_day', 0),
+ '#options' => array(0 => t('Sunday'), 1 => t('Monday'), 2 => t('Tuesday'), 3 => t('Wednesday'), 4 => t('Thursday'), 5 => t('Friday'), 6 => t('Saturday')),
+ );
+
+ $form['timezone'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Time zones'),
+ );
+
+ $form['timezone']['date_default_timezone'] = array(
+ '#type' => 'select',
+ '#title' => t('Default time zone'),
+ '#default_value' => variable_get('date_default_timezone', date_default_timezone_get()),
+ '#options' => $zones,
+ );
+
+ $configurable_timezones = variable_get('configurable_timezones', 1);
+ $form['timezone']['configurable_timezones'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Users may set their own time zone.'),
+ '#default_value' => $configurable_timezones,
+ );
+
+ $form['timezone']['configurable_timezones_wrapper'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the user configured timezone settings when users are forced to use
+ // the default setting.
+ 'invisible' => array(
+ 'input[name="configurable_timezones"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+ $form['timezone']['configurable_timezones_wrapper']['empty_timezone_message'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Remind users at login if their time zone is not set.'),
+ '#default_value' => variable_get('empty_timezone_message', 0),
+ '#description' => t('Only applied if users may set their own time zone.')
+ );
+
+ $form['timezone']['configurable_timezones_wrapper']['user_default_timezone'] = array(
+ '#type' => 'radios',
+ '#title' => t('Time zone for new users'),
+ '#default_value' => variable_get('user_default_timezone', DRUPAL_USER_TIMEZONE_DEFAULT),
+ '#options' => array(
+ DRUPAL_USER_TIMEZONE_DEFAULT => t('Default time zone.'),
+ DRUPAL_USER_TIMEZONE_EMPTY => t('Empty time zone.'),
+ DRUPAL_USER_TIMEZONE_SELECT => t('Users may set their own time zone at registration.'),
+ ),
+ '#description' => t('Only applied if users may set their own time zone.')
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure the site date and time settings.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_date_time_settings() {
+ // Get list of all available date types.
+ drupal_static_reset('system_get_date_types');
+ $format_types = system_get_date_types();
+
+ // Get list of all available date formats.
+ $all_formats = array();
+ drupal_static_reset('system_get_date_formats');
+ $date_formats = system_get_date_formats(); // Call this to rebuild the list, and to have default list.
+ foreach ($date_formats as $type => $format_info) {
+ $all_formats = array_merge($all_formats, $format_info);
+ }
+ $custom_formats = system_get_date_formats('custom');
+ if (!empty($format_types)) {
+ foreach ($format_types as $type => $type_info) {
+ // If a system type, only show the available formats for that type and
+ // custom ones.
+ if ($type_info['locked'] == 1) {
+ $formats = system_get_date_formats($type);
+ if (empty($formats)) {
+ $formats = $all_formats;
+ }
+ elseif (!empty($custom_formats)) {
+ $formats = array_merge($formats, $custom_formats);
+ }
+ }
+ // If a user configured type, show all available date formats.
+ else {
+ $formats = $all_formats;
+ }
+
+ $choices = array();
+ foreach ($formats as $f => $format) {
+ $choices[$f] = format_date(REQUEST_TIME, 'custom', $f);
+ }
+ reset($formats);
+ $default = variable_get('date_format_' . $type, key($formats));
+
+ // Get date type info for this date type.
+ $type_info = system_get_date_types($type);
+ $form['formats']['#theme'] = 'system_date_time_settings';
+
+ // Show date format select list.
+ $form['formats']['format']['date_format_' . $type] = array(
+ '#type' => 'select',
+ '#title' => check_plain($type_info['title']),
+ '#attributes' => array('class' => array('date-format')),
+ '#default_value' => (isset($choices[$default]) ? $default : 'custom'),
+ '#options' => $choices,
+ );
+
+ // If this isn't a system provided type, allow the user to remove it from
+ // the system.
+ if ($type_info['locked'] == 0) {
+ $form['formats']['delete']['date_format_' . $type . '_delete'] = array(
+ '#type' => 'link',
+ '#title' => t('delete'),
+ '#href' => 'admin/config/regional/date-time/types/' . $type . '/delete',
+ );
+ }
+ }
+ }
+
+ // Display a message if no date types configured.
+ $form['#empty_text'] = t('No date types available. <a href="@link">Add date type</a>.', array('@link' => url('admin/config/regional/date-time/types/add')));
+
+ return system_settings_form($form);
+}
+
+/**
+ * Returns HTML for the date settings form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_system_date_time_settings($variables) {
+ $form = $variables['form'];
+ $header = array(
+ t('Date type'),
+ t('Format'),
+ t('Operations'),
+ );
+
+ foreach (element_children($form['format']) as $key) {
+ $delete_key = $key . '_delete';
+ $row = array();
+ $row[] = $form['format'][$key]['#title'];
+ $form['format'][$key]['#title_display'] = 'invisible';
+ $row[] = array('data' => drupal_render($form['format'][$key]));
+ $row[] = array('data' => drupal_render($form['delete'][$delete_key]));
+ $rows[] = $row;
+ }
+
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'system-date-types')));
+ $output .= drupal_render_children($form);
+
+ return $output;
+}
+
+
+/**
+ * Add new date type.
+ *
+ * @ingroup forms
+ * @ingroup system_add_date_format_type_form_validate()
+ * @ingroup system_add_date_format_type_form_submit()
+ */
+function system_add_date_format_type_form($form, &$form_state) {
+ $form['date_type'] = array(
+ '#title' => t('Date type'),
+ '#type' => 'textfield',
+ '#required' => TRUE,
+ );
+ $form['machine_name'] = array(
+ '#type' => 'machine_name',
+ '#machine_name' => array(
+ 'exists' => 'system_get_date_types',
+ 'source' => array('date_type'),
+ ),
+ );
+
+ // Get list of all available date formats.
+ $formats = array();
+ drupal_static_reset('system_get_date_formats');
+ $date_formats = system_get_date_formats(); // Call this to rebuild the list, and to have default list.
+ foreach ($date_formats as $type => $format_info) {
+ $formats = array_merge($formats, $format_info);
+ }
+ $custom_formats = system_get_date_formats('custom');
+ if (!empty($custom_formats)) {
+ $formats = array_merge($formats, $custom_formats);
+ }
+ $choices = array();
+ foreach ($formats as $f => $format) {
+ $choices[$f] = format_date(REQUEST_TIME, 'custom', $f);
+ }
+ // Show date format select list.
+ $form['date_format'] = array(
+ '#type' => 'select',
+ '#title' => t('Date format'),
+ '#attributes' => array('class' => array('date-format')),
+ '#options' => $choices,
+ '#required' => TRUE,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add date type'),
+ );
+
+ $form['#validate'][] = 'system_add_date_format_type_form_validate';
+ $form['#submit'][] = 'system_add_date_format_type_form_submit';
+
+ return $form;
+}
+
+/**
+ * Validate system_add_date_format_type form submissions.
+ */
+function system_add_date_format_type_form_validate($form, &$form_state) {
+ if (!empty($form_state['values']['machine_name']) && !empty($form_state['values']['date_type'])) {
+ if (!preg_match("/^[a-zA-Z0-9_]+$/", trim($form_state['values']['machine_name']))) {
+ form_set_error('machine_name', t('The date type must contain only alphanumeric characters and underscores.'));
+ }
+ $types = system_get_date_types();
+ if (in_array(trim($form_state['values']['machine_name']), array_keys($types))) {
+ form_set_error('machine_name', t('This date type already exists. Enter a unique type.'));
+ }
+ }
+}
+
+/**
+ * Process system_add_date_format_type form submissions.
+ */
+function system_add_date_format_type_form_submit($form, &$form_state) {
+ $machine_name = trim($form_state['values']['machine_name']);
+
+ $format_type = array();
+ $format_type['title'] = trim($form_state['values']['date_type']);
+ $format_type['type'] = $machine_name;
+ $format_type['locked'] = 0;
+ $format_type['is_new'] = 1;
+ system_date_format_type_save($format_type);
+ variable_set('date_format_' . $machine_name, $form_state['values']['date_format']);
+
+ drupal_set_message(t('New date type added successfully.'));
+ $form_state['redirect'] = 'admin/config/regional/date-time';
+}
+
+/**
+ * Return the date for a given format string via Ajax.
+ */
+function system_date_time_lookup() {
+ $result = format_date(REQUEST_TIME, 'custom', $_GET['format']);
+ drupal_json_output($result);
+}
+
+/**
+ * Form builder; Configure the site's maintenance status.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_site_maintenance_mode() {
+ $form['maintenance_mode'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Put site into maintenance mode'),
+ '#default_value' => variable_get('maintenance_mode', 0),
+ '#description' => t('Visitors will only see the maintenance mode message. Only users with the "Access site in maintenance mode" <a href="@permissions-url">permission</a> will be able to access the site. Authorized users can log in directly via the <a href="@user-login">user login</a> page.', array('@permissions-url' => url('admin/config/people/permissions'), '@user-login' => url('user'))),
+ );
+ $form['maintenance_mode_message'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Message to display when in maintenance mode'),
+ '#default_value' => variable_get('maintenance_mode_message', t('@site is currently under maintenance. We should be back shortly. Thank you for your patience.', array('@site' => variable_get('site_name', 'Drupal')))),
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Form builder; Configure clean URL settings.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function system_clean_url_settings($form, &$form_state) {
+ $available = FALSE;
+ $conflict = FALSE;
+
+ // If the request URI is a clean URL, clean URLs must be available.
+ // Otherwise, run a test.
+ if (strpos(request_uri(), '?q=') === FALSE && strpos(request_uri(), '&q=') === FALSE) {
+ $available = TRUE;
+ }
+ else {
+ $request = drupal_http_request($GLOBALS['base_url'] . '/admin/config/search/clean-urls/check');
+ // If the request returns HTTP 200, clean URLs are available.
+ if (isset($request->code) && $request->code == 200) {
+ $available = TRUE;
+ // If the user started the clean URL test, provide explicit feedback.
+ if (isset($form_state['input']['clean_url_test_execute'])) {
+ drupal_set_message(t('The clean URL test passed.'));
+ }
+ }
+ else {
+ // If the test failed while clean URLs are enabled, make sure clean URLs
+ // can be disabled.
+ if (variable_get('clean_url', 0)) {
+ $conflict = TRUE;
+ // Warn the user of a conflicting situation, unless after processing
+ // a submitted form.
+ if (!isset($form_state['input']['op'])) {
+ drupal_set_message(t('Clean URLs are enabled, but the clean URL test failed. Uncheck the box below to disable clean URLs.'), 'warning');
+ }
+ }
+ // If the user started the clean URL test, provide explicit feedback.
+ elseif (isset($form_state['input']['clean_url_test_execute'])) {
+ drupal_set_message(t('The clean URL test failed.'), 'warning');
+ }
+ }
+ }
+
+ // Show the enable/disable form if clean URLs are available or if the user
+ // must be able to resolve a conflicting setting.
+ if ($available || $conflict) {
+ $form['clean_url'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable clean URLs'),
+ '#default_value' => variable_get('clean_url', 0),
+ '#description' => t('Use URLs like <code>example.com/user</code> instead of <code>example.com/?q=user</code>.'),
+ );
+ $form = system_settings_form($form);
+ if ($conflict) {
+ // $form_state['redirect'] needs to be set to the non-clean URL,
+ // otherwise the setting is not saved.
+ $form_state['redirect'] = url('', array('query' => array('q' => '/admin/config/search/clean-urls')));
+ }
+ }
+ // Show the clean URLs test form.
+ else {
+ drupal_add_js(drupal_get_path('module', 'system') . '/system.js');
+
+ $form_state['redirect'] = url('admin/config/search/clean-urls');
+ $form['clean_url_description'] = array(
+ '#type' => 'markup',
+ '#markup' => '<p>' . t('Use URLs like <code>example.com/user</code> instead of <code>example.com/?q=user</code>.'),
+ );
+ // Explain why the user is seeing this page and what to expect after
+ // clicking the 'Run the clean URL test' button.
+ $form['clean_url_test_result'] = array(
+ '#type' => 'markup',
+ '#markup' => '<p>' . t('Clean URLs cannot be enabled. If you are directed to this page or to a <em>Page not found (404)</em> error after testing for clean URLs, see the <a href="@handbook">online handbook</a>.', array('@handbook' => 'http://drupal.org/node/15365')) . '</p>',
+ );
+ $form['actions'] = array(
+ '#type' => 'actions',
+ 'clean_url_test' => array(
+ '#type' => 'submit',
+ '#value' => t('Run the clean URL test'),
+ ),
+ );
+ $form['clean_url_test_execute'] = array(
+ '#type' => 'hidden',
+ '#value' => 1,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Menu callback: displays the site status report. Can also be used as a pure check.
+ *
+ * @param $check
+ * If true, only returns a boolean whether there are system status errors.
+ */
+function system_status($check = FALSE) {
+ // Load .install files
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+ drupal_load_updates();
+
+ // Check run-time requirements and status information.
+ $requirements = module_invoke_all('requirements', 'runtime');
+ usort($requirements, '_system_sort_requirements');
+
+ if ($check) {
+ return drupal_requirements_severity($requirements) == REQUIREMENT_ERROR;
+ }
+ // MySQL import might have set the uid of the anonymous user to autoincrement
+ // value. Let's try fixing it. See http://drupal.org/node/204411
+ db_update('users')
+ ->expression('uid', 'uid - uid')
+ ->condition('name', '')
+ ->condition('pass', '')
+ ->condition('status', 0)
+ ->execute();
+ return theme('status_report', array('requirements' => $requirements));
+}
+
+/**
+ * Menu callback: run cron manually.
+ */
+function system_run_cron() {
+ // Run cron manually
+ if (drupal_cron_run()) {
+ drupal_set_message(t('Cron ran successfully.'));
+ }
+ else {
+ drupal_set_message(t('Cron run failed.'), 'error');
+ }
+
+ drupal_goto('admin/reports/status');
+}
+
+/**
+ * Menu callback: return information about PHP.
+ */
+function system_php() {
+ phpinfo();
+ drupal_exit();
+}
+
+/**
+ * Default page callback for batches.
+ */
+function system_batch_page() {
+ require_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ $output = _batch_page();
+
+ if ($output === FALSE) {
+ drupal_access_denied();
+ }
+ elseif (isset($output)) {
+ // Force a page without blocks or messages to
+ // display a list of collected messages later.
+ drupal_set_page_content($output);
+ $page = element_info('page');
+ $page['#show_messages'] = FALSE;
+ return $page;
+ }
+}
+
+/**
+ * Returns HTML for an administrative block for display.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - block: An array containing information about the block:
+ * - show: A Boolean whether to output the block. Defaults to FALSE.
+ * - title: The block's title.
+ * - content: (optional) Formatted content for the block.
+ * - description: (optional) Description of the block. Only output if
+ * 'content' is not set.
+ *
+ * @ingroup themeable
+ */
+function theme_admin_block($variables) {
+ $block = $variables['block'];
+ $output = '';
+
+ // Don't display the block if it has no content to display.
+ if (empty($block['show'])) {
+ return $output;
+ }
+
+ $output .= '<div class="admin-panel">';
+ if (!empty($block['title'])) {
+ $output .= '<h3>' . $block['title'] . '</h3>';
+ }
+ if (!empty($block['content'])) {
+ $output .= '<div class="body">' . $block['content'] . '</div>';
+ }
+ else {
+ $output .= '<div class="description">' . $block['description'] . '</div>';
+ }
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the content of an administrative block.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - content: An array containing information about the block. Each element
+ * of the array represents an administrative menu item, and must at least
+ * contain the keys 'title', 'href', and 'localized_options', which are
+ * passed to l(). A 'description' key may also be provided.
+ *
+ * @ingroup themeable
+ */
+function theme_admin_block_content($variables) {
+ $content = $variables['content'];
+ $output = '';
+
+ if (!empty($content)) {
+ $class = 'admin-list';
+ if ($compact = system_admin_compact_mode()) {
+ $class .= ' compact';
+ }
+ $output .= '<dl class="' . $class . '">';
+ foreach ($content as $item) {
+ $output .= '<dt>' . l($item['title'], $item['href'], $item['localized_options']) . '</dt>';
+ if (!$compact && isset($item['description'])) {
+ $output .= '<dd>' . filter_xss_admin($item['description']) . '</dd>';
+ }
+ }
+ $output .= '</dl>';
+ }
+ return $output;
+}
+
+/**
+ * Returns HTML for an administrative page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - blocks: An array of blocks to display. Each array should include a
+ * 'title', a 'description', a formatted 'content' and a 'position' which
+ * will control which container it will be in. This is usually 'left' or
+ * 'right'.
+ *
+ * @ingroup themeable
+ */
+function theme_admin_page($variables) {
+ $blocks = $variables['blocks'];
+
+ $stripe = 0;
+ $container = array();
+
+ foreach ($blocks as $block) {
+ if ($block_output = theme('admin_block', array('block' => $block))) {
+ if (empty($block['position'])) {
+ // perform automatic striping.
+ $block['position'] = ++$stripe % 2 ? 'left' : 'right';
+ }
+ if (!isset($container[$block['position']])) {
+ $container[$block['position']] = '';
+ }
+ $container[$block['position']] .= $block_output;
+ }
+ }
+
+ $output = '<div class="admin clearfix">';
+ $output .= theme('system_compact_link');
+
+ foreach ($container as $id => $data) {
+ $output .= '<div class="' . $id . ' clearfix">';
+ $output .= $data;
+ $output .= '</div>';
+ }
+ $output .= '</div>';
+ return $output;
+}
+
+/**
+ * Returns HTML for the output of the dashboard page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - menu_items: An array of modules to be displayed.
+ *
+ * @ingroup themeable
+ */
+function theme_system_admin_index($variables) {
+ $menu_items = $variables['menu_items'];
+
+ $stripe = 0;
+ $container = array('left' => '', 'right' => '');
+ $flip = array('left' => 'right', 'right' => 'left');
+ $position = 'left';
+
+ // Iterate over all modules.
+ foreach ($menu_items as $module => $block) {
+ list($description, $items) = $block;
+
+ // Output links.
+ if (count($items)) {
+ $block = array();
+ $block['title'] = $module;
+ $block['content'] = theme('admin_block_content', array('content' => $items));
+ $block['description'] = t($description);
+ $block['show'] = TRUE;
+
+ if ($block_output = theme('admin_block', array('block' => $block))) {
+ if (!isset($block['position'])) {
+ // Perform automatic striping.
+ $block['position'] = $position;
+ $position = $flip[$position];
+ }
+ $container[$block['position']] .= $block_output;
+ }
+ }
+ }
+
+ $output = '<div class="admin clearfix">';
+ $output .= theme('system_compact_link');
+ foreach ($container as $id => $data) {
+ $output .= '<div class="' . $id . ' clearfix">';
+ $output .= $data;
+ $output .= '</div>';
+ }
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the status report.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - requirements: An array of requirements.
+ *
+ * @ingroup themeable
+ */
+function theme_status_report($variables) {
+ $requirements = $variables['requirements'];
+ $severities = array(
+ REQUIREMENT_INFO => array(
+ 'title' => t('Info'),
+ 'class' => 'info',
+ ),
+ REQUIREMENT_OK => array(
+ 'title' => t('OK'),
+ 'class' => 'ok',
+ ),
+ REQUIREMENT_WARNING => array(
+ 'title' => t('Warning'),
+ 'class' => 'warning',
+ ),
+ REQUIREMENT_ERROR => array(
+ 'title' => t('Error'),
+ 'class' => 'error',
+ ),
+ );
+ $output = '<table class="system-status-report">';
+
+ foreach ($requirements as $requirement) {
+ if (empty($requirement['#type'])) {
+ $severity = $severities[isset($requirement['severity']) ? (int) $requirement['severity'] : 0];
+ $severity['icon'] = '<div title="' . $severity['title'] . '"><span class="element-invisible">' . $severity['title'] . '</span></div>';
+
+ // Output table row(s)
+ if (!empty($requirement['description'])) {
+ $output .= '<tr class="' . $severity['class'] . ' merge-down"><td class="status-icon">' . $severity['icon'] . '</td><td class="status-title">' . $requirement['title'] . '</td><td class="status-value">' . $requirement['value'] . '</td></tr>';
+ $output .= '<tr class="' . $severity['class'] . ' merge-up"><td colspan="3" class="status-description">' . $requirement['description'] . '</td></tr>';
+ }
+ else {
+ $output .= '<tr class="' . $severity['class'] . '"><td class="status-icon">' . $severity['icon'] . '</td><td class="status-title">' . $requirement['title'] . '</td><td class="status-value">' . $requirement['value'] . '</td></tr>';
+ }
+ }
+ }
+
+ $output .= '</table>';
+ return $output;
+}
+
+/**
+ * Returns HTML for the modules form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_system_modules_fieldset($variables) {
+ $form = $variables['form'];
+
+ // Individual table headers.
+ $rows = array();
+ // Iterate through all the modules, which are
+ // children of this fieldset.
+ foreach (element_children($form) as $key) {
+ // Stick it into $module for easier accessing.
+ $module = $form[$key];
+ $row = array();
+ unset($module['enable']['#title']);
+ $row[] = array('class' => array('checkbox'), 'data' => drupal_render($module['enable']));
+ $label = '<label';
+ if (isset($module['enable']['#id'])) {
+ $label .= ' for="' . $module['enable']['#id'] . '"';
+ }
+ $row[] = $label . '><strong>' . drupal_render($module['name']) . '</strong></label>';
+ $row[] = drupal_render($module['version']);
+ // Add the description, along with any modules it requires.
+ $description = drupal_render($module['description']);
+ if ($module['#requires']) {
+ $description .= '<div class="admin-requirements">' . t('Requires: !module-list', array('!module-list' => implode(', ', $module['#requires']))) . '</div>';
+ }
+ if ($module['#required_by']) {
+ $description .= '<div class="admin-requirements">' . t('Required by: !module-list', array('!module-list' => implode(', ', $module['#required_by']))) . '</div>';
+ }
+ $row[] = array('data' => $description, 'class' => array('description'));
+ // Display links (such as help or permissions) in their own columns.
+ foreach (array('help', 'permissions', 'configure') as $key) {
+ $row[] = array('data' => drupal_render($module['links'][$key]), 'class' => array($key));
+ }
+ $rows[] = $row;
+ }
+
+ return theme('table', array('header' => $form['#header'], 'rows' => $rows));
+}
+
+/**
+ * Returns HTML for a message about incompatible modules.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - message: The form array representing the currently disabled modules.
+ *
+ * @ingroup themeable
+ */
+function theme_system_modules_incompatible($variables) {
+ return '<div class="incompatible">' . $variables['message'] . '</div>';
+}
+
+/**
+ * Returns HTML for a table of currently disabled modules.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_system_modules_uninstall($variables) {
+ $form = $variables['form'];
+
+ // No theming for the confirm form.
+ if (isset($form['confirm'])) {
+ return drupal_render($form);
+ }
+
+ // Table headers.
+ $header = array(t('Uninstall'),
+ t('Name'),
+ t('Description'),
+ );
+
+ // Display table.
+ $rows = array();
+ foreach (element_children($form['modules']) as $module) {
+ if (!empty($form['modules'][$module]['#required_by'])) {
+ $disabled_message = format_plural(count($form['modules'][$module]['#required_by']),
+ 'To uninstall @module, the following module must be uninstalled first: @required_modules',
+ 'To uninstall @module, the following modules must be uninstalled first: @required_modules',
+ array('@module' => $form['modules'][$module]['#module_name'], '@required_modules' => implode(', ', $form['modules'][$module]['#required_by'])));
+ $disabled_message = '<div class="admin-requirements">' . $disabled_message . '</div>';
+ }
+ else {
+ $disabled_message = '';
+ }
+ $rows[] = array(
+ array('data' => drupal_render($form['uninstall'][$module]), 'align' => 'center'),
+ '<strong><label for="' . $form['uninstall'][$module]['#id'] . '">' . drupal_render($form['modules'][$module]['name']) . '</label></strong>',
+ array('data' => drupal_render($form['modules'][$module]['description']) . $disabled_message, 'class' => array('description')),
+ );
+ }
+
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No modules are available to uninstall.')));
+ $output .= drupal_render_children($form);
+
+ return $output;
+}
+
+/**
+ * Returns HTML for the Appearance page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - theme_groups: An associative array containing groups of themes.
+ *
+ * @ingroup themeable
+ */
+function theme_system_themes_page($variables) {
+ $theme_groups = $variables['theme_groups'];
+
+ $output = '<div id="system-themes-page">';
+
+ foreach ($variables['theme_group_titles'] as $state => $title) {
+ if (!count($theme_groups[$state])) {
+ // Skip this group of themes if no theme is there.
+ continue;
+ }
+ // Start new theme group.
+ $output .= '<div class="system-themes-list system-themes-list-'. $state .' clearfix"><h2>'. $title .'</h2>';
+
+ foreach ($theme_groups[$state] as $theme) {
+
+ // Theme the screenshot.
+ $screenshot = $theme->screenshot ? theme('image', $theme->screenshot) : '<div class="no-screenshot">' . t('no screenshot') . '</div>';
+
+ // Localize the theme description.
+ $description = t($theme->info['description']);
+
+ // Style theme info
+ $notes = count($theme->notes) ? ' (' . join(', ', $theme->notes) . ')' : '';
+ $theme->classes[] = 'theme-selector';
+ $theme->classes[] = 'clearfix';
+ $output .= '<div class="'. join(' ', $theme->classes) .'">' . $screenshot . '<div class="theme-info"><h3>' . $theme->info['name'] . ' ' . (isset($theme->info['version']) ? $theme->info['version'] : '') . $notes . '</h3><div class="theme-description">' . $description . '</div>';
+
+ // Make sure to provide feedback on compatibility.
+ if (!empty($theme->incompatible_core)) {
+ $output .= '<div class="incompatible">' . t('This version is not compatible with Drupal !core_version and should be replaced.', array('!core_version' => DRUPAL_CORE_COMPATIBILITY)) . '</div>';
+ }
+ elseif (!empty($theme->incompatible_php)) {
+ if (substr_count($theme->info['php'], '.') < 2) {
+ $theme->info['php'] .= '.*';
+ }
+ $output .= '<div class="incompatible">' . t('This theme requires PHP version @php_required and is incompatible with PHP version !php_version.', array('@php_required' => $theme->info['php'], '!php_version' => phpversion())) . '</div>';
+ }
+ else {
+ $output .= theme('links', array('links' => $theme->operations, 'attributes' => array('class' => array('operations', 'clearfix'))));
+ }
+ $output .= '</div></div>';
+ }
+ $output .= '</div>';
+ }
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Menu callback; present a form for deleting a date format.
+ */
+function system_date_delete_format_form($form, &$form_state, $dfid) {
+ $form['dfid'] = array(
+ '#type' => 'value',
+ '#value' => $dfid,
+ );
+ $format = system_get_date_format($dfid);
+
+ $output = confirm_form($form,
+ t('Are you sure you want to remove the format %format?', array('%format' => format_date(REQUEST_TIME, 'custom', $format->format))),
+ 'admin/config/regional/date-time/formats',
+ t('This action cannot be undone.'),
+ t('Remove'), t('Cancel'),
+ 'confirm'
+ );
+
+ return $output;
+}
+
+/**
+ * Delete a configured date format.
+ */
+function system_date_delete_format_form_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ $format = system_get_date_format($form_state['values']['dfid']);
+ system_date_format_delete($form_state['values']['dfid']);
+ drupal_set_message(t('Removed date format %format.', array('%format' => format_date(REQUEST_TIME, 'custom', $format->format))));
+ $form_state['redirect'] = 'admin/config/regional/date-time/formats';
+ }
+}
+
+/**
+ * Menu callback; present a form for deleting a date type.
+ */
+function system_delete_date_format_type_form($form, &$form_state, $format_type) {
+ $form['format_type'] = array(
+ '#type' => 'value',
+ '#value' => $format_type,
+ );
+ $type_info = system_get_date_types($format_type);
+
+ $output = confirm_form($form,
+ t('Are you sure you want to remove the date type %type?', array('%type' => $type_info['title'])),
+ 'admin/config/regional/date-time',
+ t('This action cannot be undone.'),
+ t('Remove'), t('Cancel'),
+ 'confirm'
+ );
+
+ return $output;
+}
+
+/**
+ * Delete a configured date type.
+ */
+function system_delete_date_format_type_form_submit($form, &$form_state) {
+ if ($form_state['values']['confirm']) {
+ $type_info = system_get_date_types($form_state['values']['format_type']);
+ system_date_format_type_delete($form_state['values']['format_type']);
+ drupal_set_message(t('Removed date type %type.', array('%type' => $type_info['title'])));
+ $form_state['redirect'] = 'admin/config/regional/date-time';
+ }
+}
+
+
+/**
+ * Displays the date format strings overview page.
+ */
+function system_date_time_formats() {
+ $header = array(t('Format'), array('data' => t('Operations'), 'colspan' => '2'));
+ $rows = array();
+
+ drupal_static_reset('system_get_date_formats');
+ $formats = system_get_date_formats('custom');
+ if (!empty($formats)) {
+ foreach ($formats as $format) {
+ $row = array();
+ $row[] = array('data' => format_date(REQUEST_TIME, 'custom', $format['format']));
+ $row[] = array('data' => l(t('edit'), 'admin/config/regional/date-time/formats/' . $format['dfid'] . '/edit'));
+ $row[] = array('data' => l(t('delete'), 'admin/config/regional/date-time/formats/' . $format['dfid'] . '/delete'));
+ $rows[] = $row;
+ }
+ }
+
+ $build['date_formats_table'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ '#empty' => t('No custom date formats available. <a href="@link">Add date format</a>.', array('@link' => url('admin/config/regional/date-time/formats/add'))),
+ );
+
+ return $build;
+}
+
+/**
+ * Allow users to add additional date formats.
+ */
+function system_configure_date_formats_form($form, &$form_state, $dfid = 0) {
+ $js_settings = array(
+ 'type' => 'setting',
+ 'data' => array(
+ 'dateTime' => array(
+ 'date-format' => array(
+ 'text' => t('Displayed as'),
+ 'lookup' => url('admin/config/regional/date-time/formats/lookup'),
+ ),
+ ),
+ ),
+ );
+
+ if ($dfid) {
+ $form['dfid'] = array(
+ '#type' => 'value',
+ '#value' => $dfid,
+ );
+ $format = system_get_date_format($dfid);
+ }
+
+ $now = ($dfid ? t('Displayed as %date', array('%date' => format_date(REQUEST_TIME, 'custom', $format->format))) : '');
+
+ $form['date_format'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Format string'),
+ '#maxlength' => 100,
+ '#description' => t('A user-defined date format. See the <a href="@url">PHP manual</a> for available options.', array('@url' => 'http://php.net/manual/function.date.php')),
+ '#default_value' => ($dfid ? $format->format : ''),
+ '#field_suffix' => ' <small id="edit-date-format-suffix">' . $now . '</small>',
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'system') . '/system.js', $js_settings),
+ ),
+ '#required' => TRUE,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['update'] = array(
+ '#type' => 'submit',
+ '#value' => ($dfid ? t('Save format') : t('Add format')),
+ );
+
+ $form['#validate'][] = 'system_add_date_formats_form_validate';
+ $form['#submit'][] = 'system_add_date_formats_form_submit';
+
+ return $form;
+}
+
+/**
+ * Validate new date format string submission.
+ */
+function system_add_date_formats_form_validate($form, &$form_state) {
+ $formats = system_get_date_formats('custom');
+ $format = trim($form_state['values']['date_format']);
+ if (!empty($formats) && in_array($format, array_keys($formats)) && (!isset($form_state['values']['dfid']) || $form_state['values']['dfid'] != $formats[$format]['dfid'])) {
+ form_set_error('date_format', t('This format already exists. Enter a unique format string.'));
+ }
+}
+
+/**
+ * Process new date format string submission.
+ */
+function system_add_date_formats_form_submit($form, &$form_state) {
+ $format = array();
+ $format['format'] = trim($form_state['values']['date_format']);
+ $format['type'] = 'custom';
+ $format['locked'] = 0;
+ if (!empty($form_state['values']['dfid'])) {
+ system_date_format_save($format, $form_state['values']['dfid']);
+ drupal_set_message(t('Custom date format updated.'));
+ }
+ else {
+ $format['is_new'] = 1;
+ system_date_format_save($format);
+ drupal_set_message(t('Custom date format added.'));
+ }
+
+ $form_state['redirect'] = 'admin/config/regional/date-time/formats';
+}
+
+/**
+ * Menu callback; Displays an overview of available and configured actions.
+ */
+function system_actions_manage() {
+ actions_synchronize();
+ $actions = actions_list();
+ $actions_map = actions_actions_map($actions);
+ $options = array();
+ $unconfigurable = array();
+
+ foreach ($actions_map as $key => $array) {
+ if ($array['configurable']) {
+ $options[$key] = $array['label'] . '...';
+ }
+ else {
+ $unconfigurable[] = $array;
+ }
+ }
+
+ $row = array();
+ $instances_present = db_query("SELECT aid FROM {actions} WHERE parameters <> ''")->fetchField();
+ $header = array(
+ array('data' => t('Action type'), 'field' => 'type'),
+ array('data' => t('Label'), 'field' => 'label'),
+ array('data' => $instances_present ? t('Operations') : '', 'colspan' => '2')
+ );
+ $query = db_select('actions')->extend('PagerDefault')->extend('TableSort');
+ $result = $query
+ ->fields('actions')
+ ->limit(50)
+ ->orderByHeader($header)
+ ->execute();
+
+ foreach ($result as $action) {
+ $row[] = array(
+ array('data' => $action->type),
+ array('data' => check_plain($action->label)),
+ array('data' => $action->parameters ? l(t('configure'), "admin/config/system/actions/configure/$action->aid") : ''),
+ array('data' => $action->parameters ? l(t('delete'), "admin/config/system/actions/delete/$action->aid") : '')
+ );
+ }
+
+ if ($row) {
+ $pager = theme('pager');
+ if (!empty($pager)) {
+ $row[] = array(array('data' => $pager, 'colspan' => '3'));
+ }
+ $build['system_actions_header'] = array('#markup' => '<h3>' . t('Available actions:') . '</h3>');
+ $build['system_actions_table'] = array('#markup' => theme('table', array('header' => $header, 'rows' => $row)));
+ }
+
+ if ($actions_map) {
+ $build['system_actions_manage_form'] = drupal_get_form('system_actions_manage_form', $options);
+ }
+
+ return $build;
+}
+
+/**
+ * Define the form for the actions overview page.
+ *
+ * @param $form_state
+ * An associative array containing the current state of the form; not used.
+ * @param $options
+ * An array of configurable actions.
+ * @return
+ * Form definition.
+ *
+ * @ingroup forms
+ * @see system_actions_manage_form_submit()
+ */
+function system_actions_manage_form($form, &$form_state, $options = array()) {
+ $form['parent'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Create an advanced action'),
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['parent']['action'] = array(
+ '#type' => 'select',
+ '#title' => t('Action'),
+ '#title_display' => 'invisible',
+ '#options' => $options,
+ '#empty_option' => t('Choose an advanced action'),
+ );
+ $form['parent']['actions'] = array('#type' => 'actions');
+ $form['parent']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create'),
+ );
+ return $form;
+}
+
+/**
+ * Process system_actions_manage form submissions.
+ *
+ * @see system_actions_manage_form()
+ */
+function system_actions_manage_form_submit($form, &$form_state) {
+ if ($form_state['values']['action']) {
+ $form_state['redirect'] = 'admin/config/system/actions/configure/' . $form_state['values']['action'];
+ }
+}
+
+/**
+ * Menu callback; Creates the form for configuration of a single action.
+ *
+ * We provide the "Description" field. The rest of the form is provided by the
+ * action. We then provide the Save button. Because we are combining unknown
+ * form elements with the action configuration form, we use an 'actions_' prefix
+ * on our elements.
+ *
+ * @param $action
+ * Hash of an action ID or an integer. If it is a hash, we are
+ * creating a new instance. If it is an integer, we are editing an existing
+ * instance.
+ * @return
+ * A form definition.
+ *
+ * @see system_actions_configure_validate()
+ * @see system_actions_configure_submit()
+ */
+function system_actions_configure($form, &$form_state, $action = NULL) {
+ if ($action === NULL) {
+ drupal_goto('admin/config/system/actions');
+ }
+
+ $actions_map = actions_actions_map(actions_list());
+ $edit = array();
+
+ // Numeric action denotes saved instance of a configurable action.
+ if (is_numeric($action)) {
+ $aid = $action;
+ // Load stored parameter values from database.
+ $data = db_query("SELECT * FROM {actions} WHERE aid = :aid", array(':aid' => $aid))->fetch();
+ $edit['actions_label'] = $data->label;
+ $edit['actions_type'] = $data->type;
+ $function = $data->callback;
+ $action = drupal_hash_base64($data->callback);
+ $params = unserialize($data->parameters);
+ if ($params) {
+ foreach ($params as $name => $val) {
+ $edit[$name] = $val;
+ }
+ }
+ }
+ // Otherwise, we are creating a new action instance.
+ else {
+ $function = $actions_map[$action]['callback'];
+ $edit['actions_label'] = $actions_map[$action]['label'];
+ $edit['actions_type'] = $actions_map[$action]['type'];
+ }
+
+ $form['actions_label'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Label'),
+ '#default_value' => $edit['actions_label'],
+ '#maxlength' => '255',
+ '#description' => t('A unique label for this advanced action. This label will be displayed in the interface of modules that integrate with actions, such as Trigger module.'),
+ '#weight' => -10
+ );
+ $action_form = $function . '_form';
+ $form = array_merge($form, $action_form($edit));
+ $form['actions_type'] = array(
+ '#type' => 'value',
+ '#value' => $edit['actions_type'],
+ );
+ $form['actions_action'] = array(
+ '#type' => 'hidden',
+ '#value' => $action,
+ );
+ // $aid is set when configuring an existing action instance.
+ if (isset($aid)) {
+ $form['actions_aid'] = array(
+ '#type' => 'hidden',
+ '#value' => $aid,
+ );
+ }
+ $form['actions_configured'] = array(
+ '#type' => 'hidden',
+ '#value' => '1',
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#weight' => 13
+ );
+
+ return $form;
+}
+
+/**
+ * Validate system_actions_configure() form submissions.
+ */
+function system_actions_configure_validate($form, &$form_state) {
+ $function = actions_function_lookup($form_state['values']['actions_action']) . '_validate';
+ // Hand off validation to the action.
+ if (function_exists($function)) {
+ $function($form, $form_state);
+ }
+}
+
+/**
+ * Process system_actions_configure() form submissions.
+ */
+function system_actions_configure_submit($form, &$form_state) {
+ $function = actions_function_lookup($form_state['values']['actions_action']);
+ $submit_function = $function . '_submit';
+
+ // Action will return keyed array of values to store.
+ $params = $submit_function($form, $form_state);
+ $aid = isset($form_state['values']['actions_aid']) ? $form_state['values']['actions_aid'] : NULL;
+
+ actions_save($function, $form_state['values']['actions_type'], $params, $form_state['values']['actions_label'], $aid);
+ drupal_set_message(t('The action has been successfully saved.'));
+
+ $form_state['redirect'] = 'admin/config/system/actions/manage';
+}
+
+/**
+ * Create the form for confirmation of deleting an action.
+ *
+ * @see system_actions_delete_form_submit()
+ * @ingroup forms
+ */
+function system_actions_delete_form($form, &$form_state, $action) {
+ $form['aid'] = array(
+ '#type' => 'hidden',
+ '#value' => $action->aid,
+ );
+ return confirm_form($form,
+ t('Are you sure you want to delete the action %action?', array('%action' => $action->label)),
+ 'admin/config/system/actions/manage',
+ t('This cannot be undone.'),
+ t('Delete'),
+ t('Cancel')
+ );
+}
+
+/**
+ * Process system_actions_delete form submissions.
+ *
+ * Post-deletion operations for action deletion.
+ */
+function system_actions_delete_form_submit($form, &$form_state) {
+ $aid = $form_state['values']['aid'];
+ $action = actions_load($aid);
+ actions_delete($aid);
+ watchdog('user', 'Deleted action %aid (%action)', array('%aid' => $aid, '%action' => $action->label));
+ drupal_set_message(t('Action %action was deleted', array('%action' => $action->label)));
+ $form_state['redirect'] = 'admin/config/system/actions/manage';
+}
+
+/**
+ * Post-deletion operations for deleting action orphans.
+ *
+ * @param $orphaned
+ * An array of orphaned actions.
+ */
+function system_action_delete_orphans_post($orphaned) {
+ foreach ($orphaned as $callback) {
+ drupal_set_message(t("Deleted orphaned action (%action).", array('%action' => $callback)));
+ }
+}
+
+/**
+ * Remove actions that are in the database but not supported by any enabled module.
+ */
+function system_actions_remove_orphans() {
+ actions_synchronize(TRUE);
+ drupal_goto('admin/config/system/actions/manage');
+}
diff --git a/core/modules/system/system.api.php b/core/modules/system/system.api.php
new file mode 100644
index 000000000000..851b6fdff630
--- /dev/null
+++ b/core/modules/system/system.api.php
@@ -0,0 +1,4130 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by Drupal core and the System module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Defines one or more hooks that are exposed by a module.
+ *
+ * Normally hooks do not need to be explicitly defined. However, by declaring a
+ * hook explicitly, a module may define a "group" for it. Modules that implement
+ * a hook may then place their implementation in either $module.module or in
+ * $module.$group.inc. If the hook is located in $module.$group.inc, then that
+ * file will be automatically loaded when needed.
+ * In general, hooks that are rarely invoked and/or are very large should be
+ * placed in a separate include file, while hooks that are very short or very
+ * frequently called should be left in the main module file so that they are
+ * always available.
+ *
+ * @return
+ * An associative array whose keys are hook names and whose values are an
+ * associative array containing:
+ * - group: A string defining the group to which the hook belongs. The module
+ * system will determine whether a file with the name $module.$group.inc
+ * exists, and automatically load it when required.
+ *
+ * See system_hook_info() for all hook groups defined by Drupal core.
+ *
+ * @see hook_hook_info_alter().
+ */
+function hook_hook_info() {
+ $hooks['token_info'] = array(
+ 'group' => 'tokens',
+ );
+ $hooks['tokens'] = array(
+ 'group' => 'tokens',
+ );
+ return $hooks;
+}
+
+/**
+ * Alter information from hook_hook_info().
+ *
+ * @param $hooks
+ * Information gathered by module_hook_info() from other modules'
+ * implementations of hook_hook_info(). Alter this array directly.
+ * See hook_hook_info() for information on what this may contain.
+ */
+function hook_hook_info_alter(&$hooks) {
+ // Our module wants to completely override the core tokens, so make
+ // sure the core token hooks are not found.
+ $hooks['token_info']['group'] = 'mytokens';
+ $hooks['tokens']['group'] = 'mytokens';
+}
+
+/**
+ * Define administrative paths.
+ *
+ * Modules may specify whether or not the paths they define in hook_menu() are
+ * to be considered administrative. Other modules may use this information to
+ * display those pages differently (e.g. in a modal overlay, or in a different
+ * theme).
+ *
+ * To change the administrative status of menu items defined in another module's
+ * hook_menu(), modules should implement hook_admin_paths_alter().
+ *
+ * @return
+ * An associative array. For each item, the key is the path in question, in
+ * a format acceptable to drupal_match_path(). The value for each item should
+ * be TRUE (for paths considered administrative) or FALSE (for non-
+ * administrative paths).
+ *
+ * @see hook_menu()
+ * @see drupal_match_path()
+ * @see hook_admin_paths_alter()
+ */
+function hook_admin_paths() {
+ $paths = array(
+ 'mymodule/*/add' => TRUE,
+ 'mymodule/*/edit' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Redefine administrative paths defined by other modules.
+ *
+ * @param $paths
+ * An associative array of administrative paths, as defined by implementations
+ * of hook_admin_paths().
+ *
+ * @see hook_admin_paths()
+ */
+function hook_admin_paths_alter(&$paths) {
+ // Treat all user pages as administrative.
+ $paths['user'] = TRUE;
+ $paths['user/*'] = TRUE;
+ // Treat the forum topic node form as a non-administrative page.
+ $paths['node/add/forum'] = FALSE;
+}
+
+/**
+ * Perform periodic actions.
+ *
+ * Modules that require some commands to be executed periodically can
+ * implement hook_cron(). The engine will then call the hook whenever a cron
+ * run happens, as defined by the administrator. Typical tasks managed by
+ * hook_cron() are database maintenance, backups, recalculation of settings
+ * or parameters, automated mailing, and retrieving remote data.
+ *
+ * Short-running or non-resource-intensive tasks can be executed directly in
+ * the hook_cron() implementation.
+ *
+ * Long-running tasks and tasks that could time out, such as retrieving remote
+ * data, sending email, and intensive file tasks, should use the queue API
+ * instead of executing the tasks directly. To do this, first define one or
+ * more queues via hook_cron_queue_info(). Then, add items that need to be
+ * processed to the defined queues.
+ */
+function hook_cron() {
+ // Short-running operation example, not using a queue:
+ // Delete all expired records since the last cron run.
+ $expires = variable_get('mymodule_cron_last_run', REQUEST_TIME);
+ db_delete('mymodule_table')
+ ->condition('expires', $expires, '>=')
+ ->execute();
+ variable_set('mymodule_cron_last_run', REQUEST_TIME);
+
+ // Long-running operation example, leveraging a queue:
+ // Fetch feeds from other sites.
+ $result = db_query('SELECT * FROM {aggregator_feed} WHERE checked + refresh < :time AND refresh <> :never', array(
+ ':time' => REQUEST_TIME,
+ ':never' => AGGREGATOR_CLEAR_NEVER,
+ ));
+ $queue = DrupalQueue::get('aggregator_feeds');
+ foreach ($result as $feed) {
+ $queue->createItem($feed);
+ }
+}
+
+/**
+ * Declare queues holding items that need to be run periodically.
+ *
+ * While there can be only one hook_cron() process running at the same time,
+ * there can be any number of processes defined here running. Because of
+ * this, long running tasks are much better suited for this API. Items queued
+ * in hook_cron() might be processed in the same cron run if there are not many
+ * items in the queue, otherwise it might take several requests, which can be
+ * run in parallel.
+ *
+ * @return
+ * An associative array where the key is the queue name and the value is
+ * again an associative array. Possible keys are:
+ * - 'worker callback': The name of the function to call. It will be called
+ * with one argument, the item created via DrupalQueue::createItem() in
+ * hook_cron().
+ * - 'time': (optional) How much time Drupal should spend on calling this
+ * worker in seconds. Defaults to 15.
+ *
+ * @see hook_cron()
+ * @see hook_cron_queue_info_alter()
+ */
+function hook_cron_queue_info() {
+ $queues['aggregator_feeds'] = array(
+ 'worker callback' => 'aggregator_refresh',
+ 'time' => 60,
+ );
+ return $queues;
+}
+
+/**
+ * Alter cron queue information before cron runs.
+ *
+ * Called by drupal_cron_run() to allow modules to alter cron queue settings
+ * before any jobs are processesed.
+ *
+ * @param array $queues
+ * An array of cron queue information.
+ *
+ * @see hook_cron_queue_info()
+ * @see drupal_cron_run()
+ */
+function hook_cron_queue_info_alter(&$queues) {
+ // This site has many feeds so let's spend 90 seconds on each cron run
+ // updating feeds instead of the default 60.
+ $queues['aggregator_feeds']['time'] = 90;
+}
+
+/**
+ * Allows modules to declare their own Forms API element types and specify their
+ * default values.
+ *
+ * This hook allows modules to declare their own form element types and to
+ * specify their default values. The values returned by this hook will be
+ * merged with the elements returned by hook_form() implementations and so
+ * can return defaults for any Form APIs keys in addition to those explicitly
+ * mentioned below.
+ *
+ * Each of the form element types defined by this hook is assumed to have
+ * a matching theme function, e.g. theme_elementtype(), which should be
+ * registered with hook_theme() as normal.
+ *
+ * For more information about custom element types see the explanation at
+ * http://drupal.org/node/169815.
+ *
+ * @return
+ * An associative array describing the element types being defined. The array
+ * contains a sub-array for each element type, with the machine-readable type
+ * name as the key. Each sub-array has a number of possible attributes:
+ * - "#input": boolean indicating whether or not this element carries a value
+ * (even if it's hidden).
+ * - "#process": array of callback functions taking $element, $form_state,
+ * and $complete_form.
+ * - "#after_build": array of callback functions taking $element and $form_state.
+ * - "#validate": array of callback functions taking $form and $form_state.
+ * - "#element_validate": array of callback functions taking $element and
+ * $form_state.
+ * - "#pre_render": array of callback functions taking $element and $form_state.
+ * - "#post_render": array of callback functions taking $element and $form_state.
+ * - "#submit": array of callback functions taking $form and $form_state.
+ * - "#title_display": optional string indicating if and how #title should be
+ * displayed, see theme_form_element() and theme_form_element_label().
+ *
+ * @see hook_element_info_alter()
+ * @see system_element_info()
+ */
+function hook_element_info() {
+ $types['filter_format'] = array(
+ '#input' => TRUE,
+ );
+ return $types;
+}
+
+/**
+ * Alter the element type information returned from modules.
+ *
+ * A module may implement this hook in order to alter the element type defaults
+ * defined by a module.
+ *
+ * @param $type
+ * All element type defaults as collected by hook_element_info().
+ *
+ * @see hook_element_info()
+ */
+function hook_element_info_alter(&$type) {
+ // Decrease the default size of textfields.
+ if (isset($type['textfield']['#size'])) {
+ $type['textfield']['#size'] = 40;
+ }
+}
+
+/**
+ * Perform cleanup tasks.
+ *
+ * This hook is run at the end of each page request. It is often used for
+ * page logging and specialized cleanup. This hook MUST NOT print anything.
+ *
+ * Only use this hook if your code must run even for cached page views.
+ * If you have code which must run once on all non-cached pages, use
+ * hook_init() instead. That is the usual case. If you implement this hook
+ * and see an error like 'Call to undefined function', it is likely that
+ * you are depending on the presence of a module which has not been loaded yet.
+ * It is not loaded because Drupal is still in bootstrap mode.
+ *
+ * @param $destination
+ * If this hook is invoked as part of a drupal_goto() call, then this argument
+ * will be a fully-qualified URL that is the destination of the redirect.
+ */
+function hook_exit($destination = NULL) {
+ db_update('counter')
+ ->expression('hits', 'hits + 1')
+ ->condition('type', 1)
+ ->execute();
+}
+
+/**
+ * Perform necessary alterations to the JavaScript before it is presented on
+ * the page.
+ *
+ * @param $javascript
+ * An array of all JavaScript being presented on the page.
+ *
+ * @see drupal_add_js()
+ * @see drupal_get_js()
+ * @see drupal_js_defaults()
+ */
+function hook_js_alter(&$javascript) {
+ // Swap out jQuery to use an updated version of the library.
+ $javascript['core/misc/jquery.js']['data'] = drupal_get_path('module', 'jquery_update') . '/jquery.js';
+}
+
+/**
+ * Registers JavaScript/CSS libraries associated with a module.
+ *
+ * Modules implementing this return an array of arrays. The key to each
+ * sub-array is the machine readable name of the library. Each library may
+ * contain the following items:
+ *
+ * - 'title': The human readable name of the library.
+ * - 'website': The URL of the library's web site.
+ * - 'version': A string specifying the version of the library; intentionally
+ * not a float because a version like "1.2.3" is not a valid float. Use PHP's
+ * version_compare() to compare different versions.
+ * - 'js': An array of JavaScript elements; each element's key is used as $data
+ * argument, each element's value is used as $options array for
+ * drupal_add_js(). To add library-specific (not module-specific) JavaScript
+ * settings, the key may be skipped, the value must specify
+ * 'type' => 'setting', and the actual settings must be contained in a 'data'
+ * element of the value.
+ * - 'css': Like 'js', an array of CSS elements passed to drupal_add_css().
+ * - 'dependencies': An array of libraries that are required for a library. Each
+ * element is an array listing the module and name of another library. Note
+ * that all dependencies for each dependent library will also be added when
+ * this library is added.
+ *
+ * Registered information for a library should contain re-usable data only.
+ * Module- or implementation-specific data and integration logic should be added
+ * separately.
+ *
+ * @return
+ * An array defining libraries associated with a module.
+ *
+ * @see system_library_info()
+ * @see drupal_add_library()
+ * @see drupal_get_library()
+ */
+function hook_library_info() {
+ // Library One.
+ $libraries['library-1'] = array(
+ 'title' => 'Library One',
+ 'website' => 'http://example.com/library-1',
+ 'version' => '1.2',
+ 'js' => array(
+ drupal_get_path('module', 'my_module') . '/library-1.js' => array(),
+ ),
+ 'css' => array(
+ drupal_get_path('module', 'my_module') . '/library-2.css' => array(
+ 'type' => 'file',
+ 'media' => 'screen',
+ ),
+ ),
+ );
+ // Library Two.
+ $libraries['library-2'] = array(
+ 'title' => 'Library Two',
+ 'website' => 'http://example.com/library-2',
+ 'version' => '3.1-beta1',
+ 'js' => array(
+ // JavaScript settings may use the 'data' key.
+ array(
+ 'type' => 'setting',
+ 'data' => array('library2' => TRUE),
+ ),
+ ),
+ 'dependencies' => array(
+ // Require jQuery UI core by System module.
+ array('system', 'ui'),
+ // Require our other library.
+ array('my_module', 'library-1'),
+ // Require another library.
+ array('other_module', 'library-3'),
+ ),
+ );
+ return $libraries;
+}
+
+/**
+ * Alters the JavaScript/CSS library registry.
+ *
+ * Allows certain, contributed modules to update libraries to newer versions
+ * while ensuring backwards compatibility. In general, such manipulations should
+ * only be done by designated modules, since most modules that integrate with a
+ * certain library also depend on the API of a certain library version.
+ *
+ * @param $libraries
+ * The JavaScript/CSS libraries provided by $module. Keyed by internal library
+ * name and passed by reference.
+ * @param $module
+ * The name of the module that registered the libraries.
+ *
+ * @see hook_library_info()
+ */
+function hook_library_info_alter(&$libraries, $module) {
+ // Update Farbtastic to version 2.0.
+ if ($module == 'system' && isset($libraries['farbtastic'])) {
+ // Verify existing version is older than the one we are updating to.
+ if (version_compare($libraries['farbtastic']['version'], '2.0', '<')) {
+ // Update the existing Farbtastic to version 2.0.
+ $libraries['farbtastic']['version'] = '2.0';
+ $libraries['farbtastic']['js'] = array(
+ drupal_get_path('module', 'farbtastic_update') . '/farbtastic-2.0.js' => array(),
+ );
+ }
+ }
+}
+
+/**
+ * Alter CSS files before they are output on the page.
+ *
+ * @param $css
+ * An array of all CSS items (files and inline CSS) being requested on the page.
+ *
+ * @see drupal_add_css()
+ * @see drupal_get_css()
+ */
+function hook_css_alter(&$css) {
+ // Remove defaults.css file.
+ unset($css[drupal_get_path('module', 'system') . '/defaults.css']);
+}
+
+/**
+ * Alter the commands that are sent to the user through the Ajax framework.
+ *
+ * @param $commands
+ * An array of all commands that will be sent to the user.
+ *
+ * @see ajax_render()
+ */
+function hook_ajax_render_alter($commands) {
+ // Inject any new status messages into the content area.
+ $commands[] = ajax_command_prepend('#block-system-main .content', theme('status_messages'));
+}
+
+/**
+ * Add elements to a page before it is rendered.
+ *
+ * Use this hook when you want to add elements at the page level. For your
+ * additions to be printed, they have to be placed below a top level array key
+ * of the $page array that has the name of a region of the active theme.
+ *
+ * By default, valid region keys are 'page_top', 'header', 'sidebar_first',
+ * 'content', 'sidebar_second' and 'page_bottom'. To get a list of all regions
+ * of the active theme, use system_region_list($theme). Note that $theme is a
+ * global variable.
+ *
+ * If you want to alter the elements added by other modules or if your module
+ * depends on the elements of other modules, use hook_page_alter() instead which
+ * runs after this hook.
+ *
+ * @param $page
+ * Nested array of renderable elements that make up the page.
+ *
+ * @see hook_page_alter()
+ * @see drupal_render_page()
+ */
+function hook_page_build(&$page) {
+ if (menu_get_object('node', 1)) {
+ // We are on a node detail page. Append a standard disclaimer to the
+ // content region.
+ $page['content']['disclaimer'] = array(
+ '#markup' => t('Acme, Inc. is not responsible for the contents of this sample code.'),
+ '#weight' => 25,
+ );
+ }
+}
+
+/**
+ * Alter a menu router item right after it has been retrieved from the database or cache.
+ *
+ * This hook is invoked by menu_get_item() and allows for run-time alteration of router
+ * information (page_callback, title, and so on) before it is translated and checked for
+ * access. The passed-in $router_item is statically cached for the current request, so this
+ * hook is only invoked once for any router item that is retrieved via menu_get_item().
+ *
+ * Usually, modules will only want to inspect the router item and conditionally
+ * perform other actions (such as preparing a state for the current request).
+ * Note that this hook is invoked for any router item that is retrieved by
+ * menu_get_item(), which may or may not be called on the path itself, so implementations
+ * should check the $path parameter if the alteration should fire for the current request
+ * only.
+ *
+ * @param $router_item
+ * The menu router item for $path.
+ * @param $path
+ * The originally passed path, for which $router_item is responsible.
+ * @param $original_map
+ * The path argument map, as contained in $path.
+ *
+ * @see menu_get_item()
+ */
+function hook_menu_get_item_alter(&$router_item, $path, $original_map) {
+ // When retrieving the router item for the current path...
+ if ($path == $_GET['q']) {
+ // ...call a function that prepares something for this request.
+ mymodule_prepare_something();
+ }
+}
+
+/**
+ * Define menu items and page callbacks.
+ *
+ * This hook enables modules to register paths in order to define how URL
+ * requests are handled. Paths may be registered for URL handling only, or they
+ * can register a link to be placed in a menu (usually the Navigation menu). A
+ * path and its associated information is commonly called a "menu router item".
+ * This hook is rarely called (for example, when modules are enabled), and
+ * its results are cached in the database.
+ *
+ * hook_menu() implementations return an associative array whose keys define
+ * paths and whose values are an associative array of properties for each
+ * path. (The complete list of properties is in the return value section below.)
+ *
+ * The definition for each path may include a page callback function, which is
+ * invoked when the registered path is requested. If there is no other
+ * registered path that fits the requested path better, any further path
+ * components are passed to the callback function. For example, your module
+ * could register path 'abc/def':
+ * @code
+ * function mymodule_menu() {
+ * $items['abc/def'] = array(
+ * 'page callback' => 'mymodule_abc_view',
+ * );
+ * return $items;
+ * }
+ *
+ * function mymodule_abc_view($ghi = 0, $jkl = '') {
+ * // ...
+ * }
+ * @endcode
+ * When path 'abc/def' is requested, no further path components are in the
+ * request, and no additional arguments are passed to the callback function (so
+ * $ghi and $jkl would take the default values as defined in the function
+ * signature). When 'abc/def/123/foo' is requested, $ghi will be '123' and
+ * $jkl will be 'foo'. Note that this automatic passing of optional path
+ * arguments applies only to page and theme callback functions.
+ *
+ * In addition to optional path arguments, the page callback and other callback
+ * functions may specify argument lists as arrays. These argument lists may
+ * contain both fixed/hard-coded argument values and integers that correspond
+ * to path components. When integers are used and the callback function is
+ * called, the corresponding path components will be substituted for the
+ * integers. That is, the integer 0 in an argument list will be replaced with
+ * the first path component, integer 1 with the second, and so on (path
+ * components are numbered starting from zero). To pass an integer without it
+ * being replaced with its respective path component, use the string value of
+ * the integer (e.g., '1') as the argument value. This substitution feature
+ * allows you to re-use a callback function for several different paths. For
+ * example:
+ * @code
+ * function mymodule_menu() {
+ * $items['abc/def'] = array(
+ * 'page callback' => 'mymodule_abc_view',
+ * 'page arguments' => array(1, 'foo'),
+ * );
+ * return $items;
+ * }
+ * @endcode
+ * When path 'abc/def' is requested, the page callback function will get 'def'
+ * as the first argument and (always) 'foo' as the second argument.
+ *
+ * If a page callback function uses an argument list array, and its path is
+ * requested with optional path arguments, then the list array's arguments are
+ * passed to the callback function first, followed by the optional path
+ * arguments. Using the above example, when path 'abc/def/bar/baz' is requested,
+ * mymodule_abc_view() will be called with 'def', 'foo', 'bar' and 'baz' as
+ * arguments, in that order.
+ *
+ * Special care should be taken for the page callback drupal_get_form(), because
+ * your specific form callback function will always receive $form and
+ * &$form_state as the first function arguments:
+ * @code
+ * function mymodule_abc_form($form, &$form_state) {
+ * // ...
+ * return $form;
+ * }
+ * @endcode
+ * See @link form_api Form API documentation @endlink for details.
+ *
+ * Wildcards within paths also work with integer substitution. For example,
+ * your module could register path 'my-module/%/edit':
+ * @code
+ * $items['my-module/%/edit'] = array(
+ * 'page callback' => 'mymodule_abc_edit',
+ * 'page arguments' => array(1),
+ * );
+ * @endcode
+ * When path 'my-module/foo/edit' is requested, integer 1 will be replaced
+ * with 'foo' and passed to the callback function.
+ *
+ * Registered paths may also contain special "auto-loader" wildcard components
+ * in the form of '%mymodule_abc', where the '%' part means that this path
+ * component is a wildcard, and the 'mymodule_abc' part defines the prefix for a
+ * load function, which here would be named mymodule_abc_load(). When a matching
+ * path is requested, your load function will receive as its first argument the
+ * path component in the position of the wildcard; load functions may also be
+ * passed additional arguments (see "load arguments" in the return value
+ * section below). For example, your module could register path
+ * 'my-module/%mymodule_abc/edit':
+ * @code
+ * $items['my-module/%mymodule_abc/edit'] = array(
+ * 'page callback' => 'mymodule_abc_edit',
+ * 'page arguments' => array(1),
+ * );
+ * @endcode
+ * When path 'my-module/123/edit' is requested, your load function
+ * mymodule_abc_load() will be invoked with the argument '123', and should
+ * load and return an "abc" object with internal id 123:
+ * @code
+ * function mymodule_abc_load($abc_id) {
+ * return db_query("SELECT * FROM {mymodule_abc} WHERE abc_id = :abc_id", array(':abc_id' => $abc_id))->fetchObject();
+ * }
+ * @endcode
+ * This 'abc' object will then be passed into the callback functions defined
+ * for the menu item, such as the page callback function mymodule_abc_edit()
+ * to replace the integer 1 in the argument array.
+ *
+ * You can also define a %wildcard_to_arg() function (for the example menu
+ * entry above this would be 'mymodule_abc_to_arg()'). The _to_arg() function
+ * is invoked to retrieve a value that is used in the path in place of the
+ * wildcard. A good example is user.module, which defines
+ * user_uid_optional_to_arg() (corresponding to the menu entry
+ * 'user/%user_uid_optional'). This function returns the user ID of the
+ * current user.
+ *
+ * The _to_arg() function will get called with three arguments:
+ * - $arg: A string representing whatever argument may have been supplied by
+ * the caller (this is particularly useful if you want the _to_arg()
+ * function only supply a (default) value if no other value is specified,
+ * as in the case of user_uid_optional_to_arg().
+ * - $map: An array of all path fragments (e.g. array('node','123','edit') for
+ * 'node/123/edit').
+ * - $index: An integer indicating which element of $map corresponds to $arg.
+ *
+ * _load() and _to_arg() functions may seem similar at first glance, but they
+ * have different purposes and are called at different times. _load()
+ * functions are called when the menu system is collecting arguments to pass
+ * to the callback functions defined for the menu item. _to_arg() functions
+ * are called when the menu system is generating links to related paths, such
+ * as the tabs for a set of MENU_LOCAL_TASK items.
+ *
+ * You can also make groups of menu items to be rendered (by default) as tabs
+ * on a page. To do that, first create one menu item of type MENU_NORMAL_ITEM,
+ * with your chosen path, such as 'foo'. Then duplicate that menu item, using a
+ * subdirectory path, such as 'foo/tab1', and changing the type to
+ * MENU_DEFAULT_LOCAL_TASK to make it the default tab for the group. Then add
+ * the additional tab items, with paths such as "foo/tab2" etc., with type
+ * MENU_LOCAL_TASK. Example:
+ * @code
+ * // Make "Foo settings" appear on the admin Config page
+ * $items['admin/config/system/foo'] = array(
+ * 'title' => 'Foo settings',
+ * 'type' => MENU_NORMAL_ITEM,
+ * // Page callback, etc. need to be added here.
+ * );
+ * // Make "Tab 1" the main tab on the "Foo settings" page
+ * $items['admin/config/system/foo/tab1'] = array(
+ * 'title' => 'Tab 1',
+ * 'type' => MENU_DEFAULT_LOCAL_TASK,
+ * // Access callback, page callback, and theme callback will be inherited
+ * // from 'admin/config/system/foo', if not specified here to override.
+ * );
+ * // Make an additional tab called "Tab 2" on "Foo settings"
+ * $items['admin/config/system/foo/tab2'] = array(
+ * 'title' => 'Tab 2',
+ * 'type' => MENU_LOCAL_TASK,
+ * // Page callback and theme callback will be inherited from
+ * // 'admin/config/system/foo', if not specified here to override.
+ * // Need to add access callback or access arguments.
+ * );
+ * @endcode
+ *
+ * @return
+ * An array of menu items. Each menu item has a key corresponding to the
+ * Drupal path being registered. The corresponding array value is an
+ * associative array that may contain the following key-value pairs:
+ * - "title": Required. The untranslated title of the menu item.
+ * - "title callback": Function to generate the title; defaults to t().
+ * If you require only the raw string to be output, set this to FALSE.
+ * - "title arguments": Arguments to send to t() or your custom callback,
+ * with path component substitution as described above.
+ * - "description": The untranslated description of the menu item.
+ * - "page callback": The function to call to display a web page when the user
+ * visits the path. If omitted, the parent menu item's callback will be used
+ * instead.
+ * - "page arguments": An array of arguments to pass to the page callback
+ * function, with path component substitution as described above.
+ * - "delivery callback": The function to call to package the result of the
+ * page callback function and send it to the browser. Defaults to
+ * drupal_deliver_html_page() unless a value is inherited from a parent menu
+ * item. Note that this function is called even if the access checks fail,
+ * so any custom delivery callback function should take that into account.
+ * See drupal_deliver_html_page() for an example.
+ * - "access callback": A function returning TRUE if the user has access
+ * rights to this menu item, and FALSE if not. It can also be a boolean
+ * constant instead of a function, and you can also use numeric values
+ * (will be cast to boolean). Defaults to user_access() unless a value is
+ * inherited from the parent menu item; only MENU_DEFAULT_LOCAL_TASK items
+ * can inherit access callbacks. To use the user_access() default callback,
+ * you must specify the permission to check as 'access arguments' (see
+ * below).
+ * - "access arguments": An array of arguments to pass to the access callback
+ * function, with path component substitution as described above. If the
+ * access callback is inherited (see above), the access arguments will be
+ * inherited with it, unless overridden in the child menu item.
+ * - "theme callback": (optional) A function returning the machine-readable
+ * name of the theme that will be used to render the page. If not provided,
+ * the value will be inherited from a parent menu item. If there is no
+ * theme callback, or if the function does not return the name of a current
+ * active theme on the site, the theme for this page will be determined by
+ * either hook_custom_theme() or the default theme instead. As a general
+ * rule, the use of theme callback functions should be limited to pages
+ * whose functionality is very closely tied to a particular theme, since
+ * they can only be overridden by modules which specifically target those
+ * pages in hook_menu_alter(). Modules implementing more generic theme
+ * switching functionality (for example, a module which allows the theme to
+ * be set dynamically based on the current user's role) should use
+ * hook_custom_theme() instead.
+ * - "theme arguments": An array of arguments to pass to the theme callback
+ * function, with path component substitution as described above.
+ * - "file": A file that will be included before the page callback is called;
+ * this allows page callback functions to be in separate files. The file
+ * should be relative to the implementing module's directory unless
+ * otherwise specified by the "file path" option. Does not apply to other
+ * callbacks (only page callback).
+ * - "file path": The path to the directory containing the file specified in
+ * "file". This defaults to the path to the module implementing the hook.
+ * - "load arguments": An array of arguments to be passed to each of the
+ * wildcard object loaders in the path, after the path argument itself.
+ * For example, if a module registers path node/%node/revisions/%/view
+ * with load arguments set to array(3), the '%node' in the path indicates
+ * that the loader function node_load() will be called with the second
+ * path component as the first argument. The 3 in the load arguments
+ * indicates that the fourth path component will also be passed to
+ * node_load() (numbering of path components starts at zero). So, if path
+ * node/12/revisions/29/view is requested, node_load(12, 29) will be called.
+ * There are also two "magic" values that can be used in load arguments.
+ * "%index" indicates the index of the wildcard path component. "%map"
+ * indicates the path components as an array. For example, if a module
+ * registers for several paths of the form 'user/%user_category/edit/*', all
+ * of them can use the same load function user_category_load(), by setting
+ * the load arguments to array('%map', '%index'). For instance, if the user
+ * is editing category 'foo' by requesting path 'user/32/edit/foo', the load
+ * function user_category_load() will be called with 32 as its first
+ * argument, the array ('user', 32, 'edit', 'foo') as the map argument,
+ * and 1 as the index argument (because %user_category is the second path
+ * component and numbering starts at zero). user_category_load() can then
+ * use these values to extract the information that 'foo' is the category
+ * being requested.
+ * - "weight": An integer that determines the relative position of items in
+ * the menu; higher-weighted items sink. Defaults to 0. Menu items with the
+ * same weight are ordered alphabetically.
+ * - "menu_name": Optional. Set this to a custom menu if you don't want your
+ * item to be placed in Navigation.
+ * - "context": (optional) Defines the context a tab may appear in. By
+ * default, all tabs are only displayed as local tasks when being rendered
+ * in a page context. All tabs that should be accessible as contextual links
+ * in page region containers outside of the parent menu item's primary page
+ * context should be registered using one of the following contexts:
+ * - MENU_CONTEXT_PAGE: (default) The tab is displayed as local task for the
+ * page context only.
+ * - MENU_CONTEXT_INLINE: The tab is displayed as contextual link outside of
+ * the primary page context only.
+ * Contexts can be combined. For example, to display a tab both on a page
+ * and inline, a menu router item may specify:
+ * @code
+ * 'context' => MENU_CONTEXT_PAGE | MENU_CONTEXT_INLINE,
+ * @endcode
+ * - "tab_parent": For local task menu items, the path of the task's parent
+ * item; defaults to the same path without the last component (e.g., the
+ * default parent for 'admin/people/create' is 'admin/people').
+ * - "tab_root": For local task menu items, the path of the closest non-tab
+ * item; same default as "tab_parent".
+ * - "position": Position of the block ('left' or 'right') on the system
+ * administration page for this item.
+ * - "type": A bitmask of flags describing properties of the menu item.
+ * Many shortcut bitmasks are provided as constants in menu.inc:
+ * - MENU_NORMAL_ITEM: Normal menu items show up in the menu tree and can be
+ * moved/hidden by the administrator.
+ * - MENU_CALLBACK: Callbacks simply register a path so that the correct
+ * information is generated when the path is accessed.
+ * - MENU_SUGGESTED_ITEM: Modules may "suggest" menu items that the
+ * administrator may enable.
+ * - MENU_LOCAL_ACTION: Local actions are menu items that describe actions
+ * on the parent item such as adding a new user or block, and are
+ * rendered in the action-links list in your theme.
+ * - MENU_LOCAL_TASK: Local tasks are menu items that describe different
+ * displays of data, and are generally rendered as tabs.
+ * - MENU_DEFAULT_LOCAL_TASK: Every set of local tasks should provide one
+ * "default" task, which should display the same page as the parent item.
+ * If the "type" element is omitted, MENU_NORMAL_ITEM is assumed.
+ * - "options": An array of options to be passed to l() when generating a link
+ * from this menu item.
+ *
+ * For a detailed usage example, see page_example.module.
+ * For comprehensive documentation on the menu system, see
+ * http://drupal.org/node/102338.
+ */
+function hook_menu() {
+ $items['example'] = array(
+ 'title' => 'Example Page',
+ 'page callback' => 'example_page',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ );
+ $items['example/feed'] = array(
+ 'title' => 'Example RSS feed',
+ 'page callback' => 'example_feed',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Alter the data being saved to the {menu_router} table after hook_menu is invoked.
+ *
+ * This hook is invoked by menu_router_build(). The menu definitions are passed
+ * in by reference. Each element of the $items array is one item returned
+ * by a module from hook_menu. Additional items may be added, or existing items
+ * altered.
+ *
+ * @param $items
+ * Associative array of menu router definitions returned from hook_menu().
+ */
+function hook_menu_alter(&$items) {
+ // Example - disable the page at node/add
+ $items['node/add']['access callback'] = FALSE;
+}
+
+/**
+ * Alter the data being saved to the {menu_links} table by menu_link_save().
+ *
+ * @param $item
+ * Associative array defining a menu link as passed into menu_link_save().
+ *
+ * @see hook_translated_menu_link_alter()
+ */
+function hook_menu_link_alter(&$item) {
+ // Make all new admin links hidden (a.k.a disabled).
+ if (strpos($item['link_path'], 'admin') === 0 && empty($item['mlid'])) {
+ $item['hidden'] = 1;
+ }
+ // Flag a link to be altered by hook_translated_menu_link_alter().
+ if ($item['link_path'] == 'devel/cache/clear') {
+ $item['options']['alter'] = TRUE;
+ }
+ // Flag a link to be altered by hook_translated_menu_link_alter(), but only
+ // if it is derived from a menu router item; i.e., do not alter a custom
+ // menu link pointing to the same path that has been created by a user.
+ if ($item['link_path'] == 'user' && $item['module'] == 'system') {
+ $item['options']['alter'] = TRUE;
+ }
+}
+
+/**
+ * Alter a menu link after it has been translated and before it is rendered.
+ *
+ * This hook is invoked from _menu_link_translate() after a menu link has been
+ * translated; i.e., after dynamic path argument placeholders (%) have been
+ * replaced with actual values, the user access to the link's target page has
+ * been checked, and the link has been localized. It is only invoked if
+ * $item['options']['alter'] has been set to a non-empty value (e.g., TRUE).
+ * This flag should be set using hook_menu_link_alter().
+ *
+ * Implementations of this hook are able to alter any property of the menu link.
+ * For example, this hook may be used to add a page-specific query string to all
+ * menu links, or hide a certain link by setting:
+ * @code
+ * 'hidden' => 1,
+ * @endcode
+ *
+ * @param $item
+ * Associative array defining a menu link after _menu_link_translate()
+ * @param $map
+ * Associative array containing the menu $map (path parts and/or objects).
+ *
+ * @see hook_menu_link_alter()
+ */
+function hook_translated_menu_link_alter(&$item, $map) {
+ if ($item['href'] == 'devel/cache/clear') {
+ $item['localized_options']['query'] = drupal_get_destination();
+ }
+}
+
+/**
+ * Inform modules that a menu link has been created.
+ *
+ * This hook is used to notify modules that menu items have been
+ * created. Contributed modules may use the information to perform
+ * actions based on the information entered into the menu system.
+ *
+ * @param $link
+ * Associative array defining a menu link as passed into menu_link_save().
+ *
+ * @see hook_menu_link_update()
+ * @see hook_menu_link_delete()
+ */
+function hook_menu_link_insert($link) {
+ // In our sample case, we track menu items as editing sections
+ // of the site. These are stored in our table as 'disabled' items.
+ $record['mlid'] = $link['mlid'];
+ $record['menu_name'] = $link['menu_name'];
+ $record['status'] = 0;
+ drupal_write_record('menu_example', $record);
+}
+
+/**
+ * Inform modules that a menu link has been updated.
+ *
+ * This hook is used to notify modules that menu items have been
+ * updated. Contributed modules may use the information to perform
+ * actions based on the information entered into the menu system.
+ *
+ * @param $link
+ * Associative array defining a menu link as passed into menu_link_save().
+ *
+ * @see hook_menu_link_insert()
+ * @see hook_menu_link_delete()
+ */
+function hook_menu_link_update($link) {
+ // If the parent menu has changed, update our record.
+ $menu_name = db_query("SELECT menu_name FROM {menu_example} WHERE mlid = :mlid", array(':mlid' => $link['mlid']))->fetchField();
+ if ($menu_name != $link['menu_name']) {
+ db_update('menu_example')
+ ->fields(array('menu_name' => $link['menu_name']))
+ ->condition('mlid', $link['mlid'])
+ ->execute();
+ }
+}
+
+/**
+ * Inform modules that a menu link has been deleted.
+ *
+ * This hook is used to notify modules that menu items have been
+ * deleted. Contributed modules may use the information to perform
+ * actions based on the information entered into the menu system.
+ *
+ * @param $link
+ * Associative array defining a menu link as passed into menu_link_save().
+ *
+ * @see hook_menu_link_insert()
+ * @see hook_menu_link_update()
+ */
+function hook_menu_link_delete($link) {
+ // Delete the record from our table.
+ db_delete('menu_example')
+ ->condition('mlid', $link['mlid'])
+ ->execute();
+}
+
+/**
+ * Alter tabs and actions displayed on the page before they are rendered.
+ *
+ * This hook is invoked by menu_local_tasks(). The system-determined tabs and
+ * actions are passed in by reference. Additional tabs or actions may be added,
+ * or existing items altered.
+ *
+ * Each tab or action is an associative array containing:
+ * - #theme: The theme function to use to render.
+ * - #link: An associative array containing:
+ * - title: The localized title of the link.
+ * - href: The system path to link to.
+ * - localized_options: An array of options to pass to url().
+ * - #active: Whether the link should be marked as 'active'.
+ *
+ * @param $data
+ * An associative array containing:
+ * - actions: An associative array containing:
+ * - count: The amount of actions determined by the menu system, which can
+ * be ignored.
+ * - output: A list of of actions, each one being an associative array
+ * as described above.
+ * - tabs: An indexed array (list) of tab levels (up to 2 levels), each
+ * containing an associative array:
+ * - count: The amount of tabs determined by the menu system. This value
+ * does not need to be altered if there is more than one tab.
+ * - output: A list of of tabs, each one being an associative array as
+ * described above.
+ * @param $router_item
+ * The menu system router item of the page.
+ * @param $root_path
+ * The path to the root item for this set of tabs.
+ */
+function hook_menu_local_tasks_alter(&$data, $router_item, $root_path) {
+ // Add an action linking to node/add to all pages.
+ $data['actions']['output'][] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => array(
+ 'title' => t('Add new content'),
+ 'href' => 'node/add',
+ 'localized_options' => array(
+ 'attributes' => array(
+ 'title' => t('Add new content'),
+ ),
+ ),
+ ),
+ );
+
+ // Add a tab linking to node/add to all pages.
+ $data['tabs'][0]['output'][] = array(
+ '#theme' => 'menu_local_task',
+ '#link' => array(
+ 'title' => t('Example tab'),
+ 'href' => 'node/add',
+ 'localized_options' => array(
+ 'attributes' => array(
+ 'title' => t('Add new content'),
+ ),
+ ),
+ ),
+ // Define whether this link is active. This can be omitted for
+ // implementations that add links to pages outside of the current page
+ // context.
+ '#active' => ($router_item['path'] == $root_path),
+ );
+}
+
+/**
+ * Alter links in the active trail before it is rendered as the breadcrumb.
+ *
+ * This hook is invoked by menu_get_active_breadcrumb() and allows alteration
+ * of the breadcrumb links for the current page, which may be preferred instead
+ * of setting a custom breadcrumb via drupal_set_breadcrumb().
+ *
+ * Implementations should take into account that menu_get_active_breadcrumb()
+ * subsequently performs the following adjustments to the active trail *after*
+ * this hook has been invoked:
+ * - The last link in $active_trail is removed, if its 'href' is identical to
+ * the 'href' of $item. This happens, because the breadcrumb normally does
+ * not contain a link to the current page.
+ * - The (second to) last link in $active_trail is removed, if the current $item
+ * is a MENU_DEFAULT_LOCAL_TASK. This happens in order to do not show a link
+ * to the current page, when being on the path for the default local task;
+ * e.g. when being on the path node/%/view, the breadcrumb should not contain
+ * a link to node/%.
+ *
+ * Each link in the active trail must contain:
+ * - title: The localized title of the link.
+ * - href: The system path to link to.
+ * - localized_options: An array of options to pass to url().
+ *
+ * @param $active_trail
+ * An array containing breadcrumb links for the current page.
+ * @param $item
+ * The menu router item of the current page.
+ *
+ * @see drupal_set_breadcrumb()
+ * @see menu_get_active_breadcrumb()
+ * @see menu_get_active_trail()
+ * @see menu_set_active_trail()
+ */
+function hook_menu_breadcrumb_alter(&$active_trail, $item) {
+ // Always display a link to the current page by duplicating the last link in
+ // the active trail. This means that menu_get_active_breadcrumb() will remove
+ // the last link (for the current page), but since it is added once more here,
+ // it will appear.
+ if (!drupal_is_front_page()) {
+ $end = end($active_trail);
+ if ($item['href'] == $end['href']) {
+ $active_trail[] = $end;
+ }
+ }
+}
+
+/**
+ * Alter contextual links before they are rendered.
+ *
+ * This hook is invoked by menu_contextual_links(). The system-determined
+ * contextual links are passed in by reference. Additional links may be added
+ * or existing links can be altered.
+ *
+ * Each contextual link must at least contain:
+ * - title: The localized title of the link.
+ * - href: The system path to link to.
+ * - localized_options: An array of options to pass to url().
+ *
+ * @param $links
+ * An associative array containing contextual links for the given $root_path,
+ * as described above. The array keys are used to build CSS class names for
+ * contextual links and must therefore be unique for each set of contextual
+ * links.
+ * @param $router_item
+ * The menu router item belonging to the $root_path being requested.
+ * @param $root_path
+ * The (parent) path that has been requested to build contextual links for.
+ * This is a normalized path, which means that an originally passed path of
+ * 'node/123' became 'node/%'.
+ *
+ * @see hook_contextual_links_view_alter()
+ * @see menu_contextual_links()
+ * @see hook_menu()
+ * @see contextual_preprocess()
+ */
+function hook_menu_contextual_links_alter(&$links, $router_item, $root_path) {
+ // Add a link to all contextual links for nodes.
+ if ($root_path == 'node/%') {
+ $links['foo'] = array(
+ 'title' => t('Do fu'),
+ 'href' => 'foo/do',
+ 'localized_options' => array(
+ 'query' => array(
+ 'foo' => 'bar',
+ ),
+ ),
+ );
+ }
+}
+
+/**
+ * Perform alterations before a page is rendered.
+ *
+ * Use this hook when you want to remove or alter elements at the page
+ * level, or add elements at the page level that depend on an other module's
+ * elements (this hook runs after hook_page_build().
+ *
+ * If you are making changes to entities such as forms, menus, or user
+ * profiles, use those objects' native alter hooks instead (hook_form_alter(),
+ * for example).
+ *
+ * The $page array contains top level elements for each block region:
+ * @code
+ * $page['page_top']
+ * $page['header']
+ * $page['sidebar_first']
+ * $page['content']
+ * $page['sidebar_second']
+ * $page['page_bottom']
+ * @endcode
+ *
+ * The 'content' element contains the main content of the current page, and its
+ * structure will vary depending on what module is responsible for building the
+ * page. Some legacy modules may not return structured content at all: their
+ * pre-rendered markup will be located in $page['content']['main']['#markup'].
+ *
+ * Pages built by Drupal's core Node module use a standard structure:
+ *
+ * @code
+ * // Node body.
+ * $page['content']['system_main']['nodes'][$nid]['body']
+ * // Array of links attached to the node (add comments, read more).
+ * $page['content']['system_main']['nodes'][$nid]['links']
+ * // The node object itself.
+ * $page['content']['system_main']['nodes'][$nid]['#node']
+ * // The results pager.
+ * $page['content']['system_main']['pager']
+ * @endcode
+ *
+ * Blocks may be referenced by their module/delta pair within a region:
+ * @code
+ * // The login block in the first sidebar region.
+ * $page['sidebar_first']['user_login']['#block'];
+ * @endcode
+ *
+ * @param $page
+ * Nested array of renderable elements that make up the page.
+ *
+ * @see hook_page_build()
+ * @see drupal_render_page()
+ */
+function hook_page_alter(&$page) {
+ // Add help text to the user login block.
+ $page['sidebar_first']['user_login']['help'] = array(
+ '#weight' => -10,
+ '#markup' => t('To post comments or add new content, you first have to log in.'),
+ );
+}
+
+/**
+ * Perform alterations before a form is rendered.
+ *
+ * One popular use of this hook is to add form elements to the node form. When
+ * altering a node form, the node object can be accessed at $form['#node'].
+ *
+ * Note that instead of hook_form_alter(), which is called for all forms, you
+ * can also use hook_form_FORM_ID_alter() to alter a specific form. For each
+ * module (in system weight order) the general form alter hook implementation
+ * is invoked first, then the form ID specific alter implementation is called.
+ * After all module hook implementations are invoked, the hook_form_alter()
+ * implementations from themes are invoked in the same manner.
+ *
+ * @param $form
+ * Nested array of form elements that comprise the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. The arguments
+ * that drupal_get_form() was originally called with are available in the
+ * array $form_state['build_info']['args'].
+ * @param $form_id
+ * String representing the name of the form itself. Typically this is the
+ * name of the function that generated the form.
+ *
+ * @see hook_form_FORM_ID_alter()
+ */
+function hook_form_alter(&$form, &$form_state, $form_id) {
+ if (isset($form['type']) && $form['type']['#value'] . '_node_settings' == $form_id) {
+ $form['workflow']['upload_' . $form['type']['#value']] = array(
+ '#type' => 'radios',
+ '#title' => t('Attachments'),
+ '#default_value' => variable_get('upload_' . $form['type']['#value'], 1),
+ '#options' => array(t('Disabled'), t('Enabled')),
+ );
+ }
+}
+
+/**
+ * Provide a form-specific alteration instead of the global hook_form_alter().
+ *
+ * Modules can implement hook_form_FORM_ID_alter() to modify a specific form,
+ * rather than implementing hook_form_alter() and checking the form ID, or
+ * using long switch statements to alter multiple forms.
+ *
+ * @param $form
+ * Nested array of form elements that comprise the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form. The arguments
+ * that drupal_get_form() was originally called with are available in the
+ * array $form_state['build_info']['args'].
+ * @param $form_id
+ * String representing the name of the form itself. Typically this is the
+ * name of the function that generated the form.
+ *
+ * @see hook_form_alter()
+ * @see drupal_prepare_form()
+ */
+function hook_form_FORM_ID_alter(&$form, &$form_state, $form_id) {
+ // Modification for the form with the given form ID goes here. For example, if
+ // FORM_ID is "user_register_form" this code would run only on the user
+ // registration form.
+
+ // Add a checkbox to registration form about agreeing to terms of use.
+ $form['terms_of_use'] = array(
+ '#type' => 'checkbox',
+ '#title' => t("I agree with the website's terms and conditions."),
+ '#required' => TRUE,
+ );
+}
+
+/**
+ * Provide a form-specific alteration for shared forms.
+ *
+ * Modules can implement hook_form_BASE_FORM_ID_alter() to modify a specific
+ * form belonging to multiple form_ids, rather than implementing
+ * hook_form_alter() and checking for conditions that would identify the
+ * shared form constructor.
+ *
+ * Examples for such forms are node_form() or comment_form().
+ *
+ * Note that this hook fires after hook_form_FORM_ID_alter() and before
+ * hook_form_alter().
+ *
+ * @param $form
+ * Nested array of form elements that comprise the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form.
+ * @param $form_id
+ * String representing the name of the form itself. Typically this is the
+ * name of the function that generated the form.
+ *
+ * @see hook_form_FORM_ID_alter()
+ * @see drupal_prepare_form()
+ */
+function hook_form_BASE_FORM_ID_alter(&$form, &$form_state, $form_id) {
+ // Modification for the form with the given BASE_FORM_ID goes here. For
+ // example, if BASE_FORM_ID is "node_form", this code would run on every
+ // node form, regardless of node type.
+
+ // Add a checkbox to the node form about agreeing to terms of use.
+ $form['terms_of_use'] = array(
+ '#type' => 'checkbox',
+ '#title' => t("I agree with the website's terms and conditions."),
+ '#required' => TRUE,
+ );
+}
+
+/**
+ * Map form_ids to form builder functions.
+ *
+ * By default, when drupal_get_form() is called, the system will look for a
+ * function with the same name as the form ID, and use that function to build
+ * the form. This hook allows you to override that behavior in two ways.
+ *
+ * First, you can use this hook to tell the form system to use a different
+ * function to build certain forms in your module; this is often used to define
+ * a form "factory" function that is used to build several similar forms. In
+ * this case, your hook implementation will likely ignore all of the input
+ * arguments. See node_forms() for an example of this.
+ *
+ * Second, you could use this hook to define how to build a form with a
+ * dynamically-generated form ID. In this case, you would need to verify that
+ * the $form_id input matched your module's format for dynamically-generated
+ * form IDs, and if so, act appropriately.
+ *
+ * @param $form_id
+ * The unique string identifying the desired form.
+ * @param $args
+ * An array containing the original arguments provided to drupal_get_form()
+ * or drupal_form_submit(). These are always passed to the form builder and
+ * do not have to be specified manually in 'callback arguments'.
+ *
+ * @return
+ * An associative array whose keys define form_ids and whose values are an
+ * associative array defining the following keys:
+ * - callback: The name of the form builder function to invoke.
+ * - callback arguments: (optional) Additional arguments to pass to the
+ * function defined in 'callback', which are prepended to $args.
+ * - wrapper_callback: (optional) The name of a form builder function to
+ * invoke before the form builder defined in 'callback' is invoked. This
+ * wrapper callback may prepopulate the $form array with form elements,
+ * which will then be already contained in the $form that is passed on to
+ * the form builder defined in 'callback'. For example, a wrapper callback
+ * could setup wizard-alike form buttons that are the same for a variety of
+ * forms that belong to the wizard, which all share the same wrapper
+ * callback.
+ */
+function hook_forms($form_id, $args) {
+ // Simply reroute the (non-existing) $form_id 'mymodule_first_form' to
+ // 'mymodule_main_form'.
+ $forms['mymodule_first_form'] = array(
+ 'callback' => 'mymodule_main_form',
+ );
+
+ // Reroute the $form_id and prepend an additional argument that gets passed to
+ // the 'mymodule_main_form' form builder function.
+ $forms['mymodule_second_form'] = array(
+ 'callback' => 'mymodule_main_form',
+ 'callback arguments' => array('some parameter'),
+ );
+
+ // Reroute the $form_id, but invoke the form builder function
+ // 'mymodule_main_form_wrapper' first, so we can prepopulate the $form array
+ // that is passed to the actual form builder 'mymodule_main_form'.
+ $forms['mymodule_wrapped_form'] = array(
+ 'callback' => 'mymodule_main_form',
+ 'wrapper_callback' => 'mymodule_main_form_wrapper',
+ );
+
+ return $forms;
+}
+
+/**
+ * Perform setup tasks for all page requests.
+ *
+ * This hook is run at the beginning of the page request. It is typically
+ * used to set up global parameters that are needed later in the request.
+ *
+ * Only use this hook if your code must run even for cached page views. This
+ * hook is called before modules or most include files are loaded into memory.
+ * It happens while Drupal is still in bootstrap mode.
+ *
+ * @see hook_init()
+ */
+function hook_boot() {
+ // We need user_access() in the shutdown function. Make sure it gets loaded.
+ drupal_load('module', 'user');
+ drupal_register_shutdown_function('devel_shutdown');
+}
+
+/**
+ * Perform setup tasks for non-cached page requests.
+ *
+ * This hook is run at the beginning of the page request. It is typically
+ * used to set up global parameters that are needed later in the request.
+ * When this hook is called, all modules are already loaded in memory.
+ *
+ * This hook is not run on cached pages.
+ *
+ * To add CSS or JS that should be present on all pages, modules should not
+ * implement this hook, but declare these files in their .info file.
+ *
+ * @see hook_boot()
+ */
+function hook_init() {
+ // Since this file should only be loaded on the front page, it cannot be
+ // declared in the info file.
+ if (drupal_is_front_page()) {
+ drupal_add_css(drupal_get_path('module', 'foo') . '/foo.css');
+ }
+}
+
+/**
+ * Define image toolkits provided by this module.
+ *
+ * The file which includes each toolkit's functions must be declared as part of
+ * the files array in the module .info file so that the registry will find and
+ * parse it.
+ *
+ * The toolkit's functions must be named image_toolkitname_operation().
+ * where the operation may be:
+ * - 'load': Required. See image_gd_load() for usage.
+ * - 'save': Required. See image_gd_save() for usage.
+ * - 'settings': Optional. See image_gd_settings() for usage.
+ * - 'resize': Optional. See image_gd_resize() for usage.
+ * - 'rotate': Optional. See image_gd_rotate() for usage.
+ * - 'crop': Optional. See image_gd_crop() for usage.
+ * - 'desaturate': Optional. See image_gd_desaturate() for usage.
+ *
+ * @return
+ * An array with the toolkit name as keys and sub-arrays with these keys:
+ * - 'title': A string with the toolkit's title.
+ * - 'available': A Boolean value to indicate that the toolkit is operating
+ * properly, e.g. all required libraries exist.
+ *
+ * @see system_image_toolkits()
+ */
+function hook_image_toolkits() {
+ return array(
+ 'working' => array(
+ 'title' => t('A toolkit that works.'),
+ 'available' => TRUE,
+ ),
+ 'broken' => array(
+ 'title' => t('A toolkit that is "broken" and will not be listed.'),
+ 'available' => FALSE,
+ ),
+ );
+}
+
+/**
+ * Alter an email message created with the drupal_mail() function.
+ *
+ * hook_mail_alter() allows modification of email messages created and sent
+ * with drupal_mail(). Usage examples include adding and/or changing message
+ * text, message fields, and message headers.
+ *
+ * Email messages sent using functions other than drupal_mail() will not
+ * invoke hook_mail_alter(). For example, a contributed module directly
+ * calling the drupal_mail_system()->mail() or PHP mail() function
+ * will not invoke this hook. All core modules use drupal_mail() for
+ * messaging, it is best practice but not mandatory in contributed modules.
+ *
+ * @param $message
+ * An array containing the message data. Keys in this array include:
+ * - 'id':
+ * The drupal_mail() id of the message. Look at module source code or
+ * drupal_mail() for possible id values.
+ * - 'to':
+ * The address or addresses the message will be sent to. The
+ * formatting of this string must comply with RFC 2822.
+ * - 'from':
+ * The address the message will be marked as being from, which is
+ * either a custom address or the site-wide default email address.
+ * - 'subject':
+ * Subject of the email to be sent. This must not contain any newline
+ * characters, or the email may not be sent properly.
+ * - 'body':
+ * An array of strings containing the message text. The message body is
+ * created by concatenating the individual array strings into a single text
+ * string using "\n\n" as a separator.
+ * - 'headers':
+ * Associative array containing mail headers, such as From, Sender,
+ * MIME-Version, Content-Type, etc.
+ * - 'params':
+ * An array of optional parameters supplied by the caller of drupal_mail()
+ * that is used to build the message before hook_mail_alter() is invoked.
+ * - 'language':
+ * The language object used to build the message before hook_mail_alter()
+ * is invoked.
+ *
+ * @see drupal_mail()
+ */
+function hook_mail_alter(&$message) {
+ if ($message['id'] == 'modulename_messagekey') {
+ $message['body'][] = "--\nMail sent out from " . variable_get('sitename', t('Drupal'));
+ }
+}
+
+/**
+ * Alter the registry of modules implementing a hook.
+ *
+ * This hook is invoked during module_implements(). A module may implement this
+ * hook in order to reorder the implementing modules, which are otherwise
+ * ordered by the module's system weight.
+ *
+ * @param $implementations
+ * An array keyed by the module's name. The value of each item corresponds
+ * to a $group, which is usually FALSE, unless the implementation is in a
+ * file named $module.$group.inc.
+ * @param $hook
+ * The name of the module hook being implemented.
+ */
+function hook_module_implements_alter(&$implementations, $hook) {
+ if ($hook == 'rdf_mapping') {
+ // Move my_module_rdf_mapping() to the end of the list. module_implements()
+ // iterates through $implementations with a foreach loop which PHP iterates
+ // in the order that the items were added, so to move an item to the end of
+ // the array, we remove it and then add it.
+ $group = $implementations['my_module'];
+ unset($implementations['my_module']);
+ $implementations['my_module'] = $group;
+ }
+}
+
+/**
+ * Alter the information parsed from module and theme .info files
+ *
+ * This hook is invoked in _system_rebuild_module_data() and in
+ * _system_rebuild_theme_data(). A module may implement this hook in order to
+ * add to or alter the data generated by reading the .info file with
+ * drupal_parse_info_file().
+ *
+ * @param $info
+ * The .info file contents, passed by reference so that it can be altered.
+ * @param $file
+ * Full information about the module or theme, including $file->name, and
+ * $file->filename
+ * @param $type
+ * Either 'module' or 'theme', depending on the type of .info file that was
+ * passed.
+ */
+function hook_system_info_alter(&$info, $file, $type) {
+ // Only fill this in if the .info file does not define a 'datestamp'.
+ if (empty($info['datestamp'])) {
+ $info['datestamp'] = filemtime($file->filename);
+ }
+}
+
+/**
+ * Define user permissions.
+ *
+ * This hook can supply permissions that the module defines, so that they
+ * can be selected on the user permissions page and used to grant or restrict
+ * access to actions the module performs.
+ *
+ * Permissions are checked using user_access().
+ *
+ * For a detailed usage example, see page_example.module.
+ *
+ * @return
+ * An array whose keys are permission names and whose corresponding values
+ * are arrays containing the following key-value pairs:
+ * - title: The human-readable name of the permission, to be shown on the
+ * permission administration page. This should be wrapped in the t()
+ * function so it can be translated.
+ * - description: (optional) A description of what the permission does. This
+ * should be wrapped in the t() function so it can be translated.
+ * - restrict access: (optional) A boolean which can be set to TRUE to
+ * indicate that site administrators should restrict access to this
+ * permission to trusted users. This should be used for permissions that
+ * have inherent security risks across a variety of potential use cases
+ * (for example, the "administer filters" and "bypass node access"
+ * permissions provided by Drupal core). When set to TRUE, a standard
+ * warning message defined in user_admin_permissions() and output via
+ * theme_user_permission_description() will be associated with the
+ * permission and displayed with it on the permission administration page.
+ * Defaults to FALSE.
+ * - warning: (optional) A translated warning message to display for this
+ * permission on the permission administration page. This warning overrides
+ * the automatic warning generated by 'restrict access' being set to TRUE.
+ * This should rarely be used, since it is important for all permissions to
+ * have a clear, consistent security warning that is the same across the
+ * site. Use the 'description' key instead to provide any information that
+ * is specific to the permission you are defining.
+ *
+ * @see theme_user_permission_description()
+ */
+function hook_permission() {
+ return array(
+ 'administer my module' => array(
+ 'title' => t('Administer my module'),
+ 'description' => t('Perform administration tasks for my module.'),
+ ),
+ );
+}
+
+/**
+ * Register a module (or theme's) theme implementations.
+ *
+ * The implementations declared by this hook have two purposes: either they
+ * specify how a particular render array is to be rendered as HTML (this is
+ * usually the case if the theme function is assigned to the render array's
+ * #theme property), or they return the HTML that should be returned by an
+ * invocation of theme().
+ *
+ * The following parameters are all optional.
+ *
+ * @param array $existing
+ * An array of existing implementations that may be used for override
+ * purposes. This is primarily useful for themes that may wish to examine
+ * existing implementations to extract data (such as arguments) so that
+ * it may properly register its own, higher priority implementations.
+ * @param $type
+ * Whether a theme, module, etc. is being processed. This is primarily useful
+ * so that themes tell if they are the actual theme being called or a parent
+ * theme. May be one of:
+ * - 'module': A module is being checked for theme implementations.
+ * - 'base_theme_engine': A theme engine is being checked for a theme that is
+ * a parent of the actual theme being used.
+ * - 'theme_engine': A theme engine is being checked for the actual theme
+ * being used.
+ * - 'base_theme': A base theme is being checked for theme implementations.
+ * - 'theme': The actual theme in use is being checked.
+ * @param $theme
+ * The actual name of theme, module, etc. that is being being processed.
+ * @param $path
+ * The directory path of the theme or module, so that it doesn't need to be
+ * looked up.
+ *
+ * @return array
+ * An associative array of theme hook information. The keys on the outer
+ * array are the internal names of the hooks, and the values are arrays
+ * containing information about the hook. Each information array must contain
+ * either a 'variables' element or a 'render element' element, but not both.
+ * Use 'render element' if you are theming a single element or element tree
+ * composed of elements, such as a form array, a page array, or a single
+ * checkbox element. Use 'variables' if your theme implementation is
+ * intended to be called directly through theme() and has multiple arguments
+ * for the data and style; in this case, the variables not supplied by the
+ * calling function will be given default values and passed to the template
+ * or theme function. The returned theme information array can contain the
+ * following key/value pairs:
+ * - variables: (see above) Each array key is the name of the variable, and
+ * the value given is used as the default value if the function calling
+ * theme() does not supply it. Template implementations receive each array
+ * key as a variable in the template file (so they must be legal PHP
+ * variable names). Function implementations are passed the variables in a
+ * single $variables function argument.
+ * - render element: (see above) The name of the renderable element or element
+ * tree to pass to the theme function. This name is used as the name of the
+ * variable that holds the renderable element or tree in preprocess and
+ * process functions.
+ * - file: The file the implementation resides in. This file will be included
+ * prior to the theme being rendered, to make sure that the function or
+ * preprocess function (as needed) is actually loaded; this makes it
+ * possible to split theme functions out into separate files quite easily.
+ * - path: Override the path of the file to be used. Ordinarily the module or
+ * theme path will be used, but if the file will not be in the default
+ * path, include it here. This path should be relative to the Drupal root
+ * directory.
+ * - template: If specified, this theme implementation is a template, and
+ * this is the template file without an extension. Do not put .tpl.php on
+ * this file; that extension will be added automatically by the default
+ * rendering engine (which is PHPTemplate). If 'path', above, is specified,
+ * the template should also be in this path.
+ * - function: If specified, this will be the function name to invoke for
+ * this implementation. If neither 'template' nor 'function' is specified,
+ * a default function name will be assumed. For example, if a module
+ * registers the 'node' theme hook, 'theme_node' will be assigned to its
+ * function. If the chameleon theme registers the node hook, it will be
+ * assigned 'chameleon_node' as its function.
+ * - pattern: A regular expression pattern to be used to allow this theme
+ * implementation to have a dynamic name. The convention is to use __ to
+ * differentiate the dynamic portion of the theme. For example, to allow
+ * forums to be themed individually, the pattern might be: 'forum__'. Then,
+ * when the forum is themed, call:
+ * @code
+ * theme(array('forum__' . $tid, 'forum'), $forum)
+ * @endcode
+ * - preprocess functions: A list of functions used to preprocess this data.
+ * Ordinarily this won't be used; it's automatically filled in. By default,
+ * for a module this will be filled in as template_preprocess_HOOK. For
+ * a theme this will be filled in as phptemplate_preprocess and
+ * phptemplate_preprocess_HOOK as well as themename_preprocess and
+ * themename_preprocess_HOOK.
+ * - override preprocess functions: Set to TRUE when a theme does NOT want
+ * the standard preprocess functions to run. This can be used to give a
+ * theme FULL control over how variables are set. For example, if a theme
+ * wants total control over how certain variables in the page.tpl.php are
+ * set, this can be set to true. Please keep in mind that when this is used
+ * by a theme, that theme becomes responsible for making sure necessary
+ * variables are set.
+ * - type: (automatically derived) Where the theme hook is defined:
+ * 'module', 'theme_engine', or 'theme'.
+ * - theme path: (automatically derived) The directory path of the theme or
+ * module, so that it doesn't need to be looked up.
+ */
+function hook_theme($existing, $type, $theme, $path) {
+ return array(
+ 'forum_display' => array(
+ 'variables' => array('forums' => NULL, 'topics' => NULL, 'parents' => NULL, 'tid' => NULL, 'sortby' => NULL, 'forum_per_page' => NULL),
+ ),
+ 'forum_list' => array(
+ 'variables' => array('forums' => NULL, 'parents' => NULL, 'tid' => NULL),
+ ),
+ 'forum_topic_list' => array(
+ 'variables' => array('tid' => NULL, 'topics' => NULL, 'sortby' => NULL, 'forum_per_page' => NULL),
+ ),
+ 'forum_icon' => array(
+ 'variables' => array('new_posts' => NULL, 'num_posts' => 0, 'comment_mode' => 0, 'sticky' => 0),
+ ),
+ 'status_report' => array(
+ 'render element' => 'requirements',
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_date_time_settings' => array(
+ 'render element' => 'form',
+ 'file' => 'system.admin.inc',
+ ),
+ );
+}
+
+/**
+ * Alter the theme registry information returned from hook_theme().
+ *
+ * The theme registry stores information about all available theme hooks,
+ * including which callback functions those hooks will call when triggered,
+ * what template files are exposed by these hooks, and so on.
+ *
+ * Note that this hook is only executed as the theme cache is re-built.
+ * Changes here will not be visible until the next cache clear.
+ *
+ * The $theme_registry array is keyed by theme hook name, and contains the
+ * information returned from hook_theme(), as well as additional properties
+ * added by _theme_process_registry().
+ *
+ * For example:
+ * @code
+ * $theme_registry['user_profile'] = array(
+ * 'variables' => array(
+ * 'account' => NULL,
+ * ),
+ * 'template' => 'core/modules/user/user-profile',
+ * 'file' => 'core/modules/user/user.pages.inc',
+ * 'type' => 'module',
+ * 'theme path' => 'core/modules/user',
+ * 'preprocess functions' => array(
+ * 0 => 'template_preprocess',
+ * 1 => 'template_preprocess_user_profile',
+ * ),
+ * );
+ * @endcode
+ *
+ * @param $theme_registry
+ * The entire cache of theme registry information, post-processing.
+ *
+ * @see hook_theme()
+ * @see _theme_process_registry()
+ */
+function hook_theme_registry_alter(&$theme_registry) {
+ // Kill the next/previous forum topic navigation links.
+ foreach ($theme_registry['forum_topic_navigation']['preprocess functions'] as $key => $value) {
+ if ($value == 'template_preprocess_forum_topic_navigation') {
+ unset($theme_registry['forum_topic_navigation']['preprocess functions'][$key]);
+ }
+ }
+}
+
+/**
+ * Return the machine-readable name of the theme to use for the current page.
+ *
+ * This hook can be used to dynamically set the theme for the current page
+ * request. It should be used by modules which need to override the theme
+ * based on dynamic conditions (for example, a module which allows the theme to
+ * be set based on the current user's role). The return value of this hook will
+ * be used on all pages except those which have a valid per-page or per-section
+ * theme set via a theme callback function in hook_menu(); the themes on those
+ * pages can only be overridden using hook_menu_alter().
+ *
+ * Since only one theme can be used at a time, the last (i.e., highest
+ * weighted) module which returns a valid theme name from this hook will
+ * prevail.
+ *
+ * @return
+ * The machine-readable name of the theme that should be used for the current
+ * page request. The value returned from this function will only have an
+ * effect if it corresponds to a currently-active theme on the site.
+ */
+function hook_custom_theme() {
+ // Allow the user to request a particular theme via a query parameter.
+ if (isset($_GET['theme'])) {
+ return $_GET['theme'];
+ }
+}
+
+/**
+ * Register XML-RPC callbacks.
+ *
+ * This hook lets a module register callback functions to be called when
+ * particular XML-RPC methods are invoked by a client.
+ *
+ * @return
+ * An array which maps XML-RPC methods to Drupal functions. Each array
+ * element is either a pair of method => function or an array with four
+ * entries:
+ * - The XML-RPC method name (for example, module.function).
+ * - The Drupal callback function (for example, module_function).
+ * - The method signature is an array of XML-RPC types. The first element
+ * of this array is the type of return value and then you should write a
+ * list of the types of the parameters. XML-RPC types are the following
+ * (See the types at http://www.xmlrpc.com/spec):
+ * - "boolean": 0 (false) or 1 (true).
+ * - "double": a floating point number (for example, -12.214).
+ * - "int": a integer number (for example, -12).
+ * - "array": an array without keys (for example, array(1, 2, 3)).
+ * - "struct": an associative array or an object (for example,
+ * array('one' => 1, 'two' => 2)).
+ * - "date": when you return a date, then you may either return a
+ * timestamp (time(), mktime() etc.) or an ISO8601 timestamp. When
+ * date is specified as an input parameter, then you get an object,
+ * which is described in the function xmlrpc_date
+ * - "base64": a string containing binary data, automatically
+ * encoded/decoded automatically.
+ * - "string": anything else, typically a string.
+ * - A descriptive help string, enclosed in a t() function for translation
+ * purposes.
+ * Both forms are shown in the example.
+ */
+function hook_xmlrpc() {
+ return array(
+ 'drupal.login' => 'drupal_login',
+ array(
+ 'drupal.site.ping',
+ 'drupal_directory_ping',
+ array('boolean', 'string', 'string', 'string', 'string', 'string'),
+ t('Handling ping request'))
+ );
+}
+
+/**
+ * Alters the definition of XML-RPC methods before they are called.
+ *
+ * This hook allows modules to modify the callback definition of declared
+ * XML-RPC methods, right before they are invoked by a client. Methods may be
+ * added, or existing methods may be altered.
+ *
+ * Note that hook_xmlrpc() supports two distinct and incompatible formats to
+ * define a callback, so care must be taken when altering other methods.
+ *
+ * @param $methods
+ * An asssociative array of method callback definitions, as returned from
+ * hook_xmlrpc() implementations.
+ *
+ * @see hook_xmlrpc()
+ * @see xmlrpc_server()
+ */
+function hook_xmlrpc_alter(&$methods) {
+ // Directly change a simple method.
+ $methods['drupal.login'] = 'mymodule_login';
+
+ // Alter complex definitions.
+ foreach ($methods as $key => &$method) {
+ // Skip simple method definitions.
+ if (!is_int($key)) {
+ continue;
+ }
+ // Perform the wanted manipulation.
+ if ($method[0] == 'drupal.site.ping') {
+ $method[1] = 'mymodule_directory_ping';
+ }
+ }
+}
+
+/**
+ * Log an event message
+ *
+ * This hook allows modules to route log events to custom destinations, such as
+ * SMS, Email, pager, syslog, ...etc.
+ *
+ * @param $log_entry
+ * An associative array containing the following keys:
+ * - type: The type of message for this entry. For contributed modules, this is
+ * normally the module name. Do not use 'debug', use severity WATCHDOG_DEBUG instead.
+ * - user: The user object for the user who was logged in when the event happened.
+ * - request_uri: The Request URI for the page the event happened in.
+ * - referer: The page that referred the use to the page where the event occurred.
+ * - ip: The IP address where the request for the page came from.
+ * - timestamp: The UNIX timestamp of the date/time the event occurred
+ * - severity: One of the following values as defined in RFC 3164 http://www.faqs.org/rfcs/rfc3164.html
+ * WATCHDOG_EMERGENCY Emergency: system is unusable
+ * WATCHDOG_ALERT Alert: action must be taken immediately
+ * WATCHDOG_CRITICAL Critical: critical conditions
+ * WATCHDOG_ERROR Error: error conditions
+ * WATCHDOG_WARNING Warning: warning conditions
+ * WATCHDOG_NOTICE Notice: normal but significant condition
+ * WATCHDOG_INFO Informational: informational messages
+ * WATCHDOG_DEBUG Debug: debug-level messages
+ * - link: an optional link provided by the module that called the watchdog() function.
+ * - message: The text of the message to be logged.
+ */
+function hook_watchdog(array $log_entry) {
+ global $base_url, $language;
+
+ $severity_list = array(
+ WATCHDOG_EMERGENCY => t('Emergency'),
+ WATCHDOG_ALERT => t('Alert'),
+ WATCHDOG_CRITICALI => t('Critical'),
+ WATCHDOG_ERROR => t('Error'),
+ WATCHDOG_WARNING => t('Warning'),
+ WATCHDOG_NOTICE => t('Notice'),
+ WATCHDOG_INFO => t('Info'),
+ WATCHDOG_DEBUG => t('Debug'),
+ );
+
+ $to = 'someone@example.com';
+ $params = array();
+ $params['subject'] = t('[@site_name] @severity_desc: Alert from your web site', array(
+ '@site_name' => variable_get('site_name', 'Drupal'),
+ '@severity_desc' => $severity_list[$log_entry['severity']],
+ ));
+
+ $params['message'] = "\nSite: @base_url";
+ $params['message'] .= "\nSeverity: (@severity) @severity_desc";
+ $params['message'] .= "\nTimestamp: @timestamp";
+ $params['message'] .= "\nType: @type";
+ $params['message'] .= "\nIP Address: @ip";
+ $params['message'] .= "\nRequest URI: @request_uri";
+ $params['message'] .= "\nReferrer URI: @referer_uri";
+ $params['message'] .= "\nUser: (@uid) @name";
+ $params['message'] .= "\nLink: @link";
+ $params['message'] .= "\nMessage: \n\n@message";
+
+ $params['message'] = t($params['message'], array(
+ '@base_url' => $base_url,
+ '@severity' => $log_entry['severity'],
+ '@severity_desc' => $severity_list[$log_entry['severity']],
+ '@timestamp' => format_date($log_entry['timestamp']),
+ '@type' => $log_entry['type'],
+ '@ip' => $log_entry['ip'],
+ '@request_uri' => $log_entry['request_uri'],
+ '@referer_uri' => $log_entry['referer'],
+ '@uid' => $log_entry['user']->uid,
+ '@name' => $log_entry['user']->name,
+ '@link' => strip_tags($log_entry['link']),
+ '@message' => strip_tags($log_entry['message']),
+ ));
+
+ drupal_mail('emaillog', 'entry', $to, $language, $params);
+}
+
+/**
+ * Prepare a message based on parameters; called from drupal_mail().
+ *
+ * Note that hook_mail(), unlike hook_mail_alter(), is only called on the
+ * $module argument to drupal_mail(), not all modules.
+ *
+ * @param $key
+ * An identifier of the mail.
+ * @param $message
+ * An array to be filled in. Elements in this array include:
+ * - id: An ID to identify the mail sent. Look at module source code
+ * or drupal_mail() for possible id values.
+ * - to: The address or addresses the message will be sent to. The
+ * formatting of this string must comply with RFC 2822.
+ * - subject: Subject of the e-mail to be sent. This must not contain any
+ * newline characters, or the mail may not be sent properly. drupal_mail()
+ * sets this to an empty string when the hook is invoked.
+ * - body: An array of lines containing the message to be sent. Drupal will
+ * format the correct line endings for you. drupal_mail() sets this to an
+ * empty array when the hook is invoked.
+ * - from: The address the message will be marked as being from, which is
+ * set by drupal_mail() to either a custom address or the site-wide
+ * default email address when the hook is invoked.
+ * - headers: Associative array containing mail headers, such as From,
+ * Sender, MIME-Version, Content-Type, etc. drupal_mail() pre-fills
+ * several headers in this array.
+ * @param $params
+ * An array of parameters supplied by the caller of drupal_mail().
+ */
+function hook_mail($key, &$message, $params) {
+ $account = $params['account'];
+ $context = $params['context'];
+ $variables = array(
+ '%site_name' => variable_get('site_name', 'Drupal'),
+ '%username' => format_username($account),
+ );
+ if ($context['hook'] == 'taxonomy') {
+ $entity = $params['entity'];
+ $vocabulary = taxonomy_vocabulary_load($entity->vid);
+ $variables += array(
+ '%term_name' => $entity->name,
+ '%term_description' => $entity->description,
+ '%term_id' => $entity->tid,
+ '%vocabulary_name' => $vocabulary->name,
+ '%vocabulary_description' => $vocabulary->description,
+ '%vocabulary_id' => $vocabulary->vid,
+ );
+ }
+
+ // Node-based variable translation is only available if we have a node.
+ if (isset($params['node'])) {
+ $node = $params['node'];
+ $variables += array(
+ '%uid' => $node->uid,
+ '%node_url' => url('node/' . $node->nid, array('absolute' => TRUE)),
+ '%node_type' => node_type_get_name($node),
+ '%title' => $node->title,
+ '%teaser' => $node->teaser,
+ '%body' => $node->body,
+ );
+ }
+ $subject = strtr($context['subject'], $variables);
+ $body = strtr($context['message'], $variables);
+ $message['subject'] .= str_replace(array("\r", "\n"), '', $subject);
+ $message['body'][] = drupal_html_to_text($body);
+}
+
+/**
+ * Add a list of cache tables to be cleared.
+ *
+ * This hook allows your module to add cache bins to the list of cache bins
+ * that will be cleared by the Clear button on the Performance page or
+ * whenever drupal_flush_all_caches is invoked.
+ *
+ * @return
+ * An array of cache bins.
+ *
+ * @see drupal_flush_all_caches()
+ */
+function hook_flush_caches() {
+ return array('example');
+}
+
+/**
+ * Perform necessary actions before modules are installed.
+ *
+ * This function allows all modules to react prior to a module being installed.
+ *
+ * @param $modules
+ * An array of modules about to be installed.
+ */
+function hook_modules_preinstall($modules) {
+ mymodule_cache_clear();
+}
+
+/**
+ * Perform necessary actions before modules are enabled.
+ *
+ * This function allows all modules to react prior to a module being enabled.
+ *
+ * @param $module
+ * An array of modules about to be enabled.
+ */
+function hook_modules_preenable($modules) {
+ mymodule_cache_clear();
+}
+
+/**
+ * Perform necessary actions after modules are installed.
+ *
+ * This function differs from hook_install() in that it gives all other modules
+ * a chance to perform actions when a module is installed, whereas
+ * hook_install() is only called on the module actually being installed. See
+ * module_enable() for a detailed description of the order in which install and
+ * enable hooks are invoked.
+ *
+ * @param $modules
+ * An array of the modules that were installed.
+ *
+ * @see module_enable()
+ * @see hook_modules_enabled()
+ * @see hook_install()
+ */
+function hook_modules_installed($modules) {
+ if (in_array('lousy_module', $modules)) {
+ variable_set('lousy_module_conflicting_variable', FALSE);
+ }
+}
+
+/**
+ * Perform necessary actions after modules are enabled.
+ *
+ * This function differs from hook_enable() in that it gives all other modules a
+ * chance to perform actions when modules are enabled, whereas hook_enable() is
+ * only called on the module actually being enabled. See module_enable() for a
+ * detailed description of the order in which install and enable hooks are
+ * invoked.
+ *
+ * @param $modules
+ * An array of the modules that were enabled.
+ *
+ * @see hook_enable()
+ * @see hook_modules_installed()
+ * @see module_enable()
+ */
+function hook_modules_enabled($modules) {
+ if (in_array('lousy_module', $modules)) {
+ drupal_set_message(t('mymodule is not compatible with lousy_module'), 'error');
+ mymodule_disable_functionality();
+ }
+}
+
+/**
+ * Perform necessary actions after modules are disabled.
+ *
+ * This function differs from hook_disable() in that it gives all other modules
+ * a chance to perform actions when modules are disabled, whereas hook_disable()
+ * is only called on the module actually being disabled.
+ *
+ * @param $modules
+ * An array of the modules that were disabled.
+ *
+ * @see hook_disable()
+ * @see hook_modules_uninstalled()
+ */
+function hook_modules_disabled($modules) {
+ if (in_array('lousy_module', $modules)) {
+ mymodule_enable_functionality();
+ }
+}
+
+/**
+ * Perform necessary actions after modules are uninstalled.
+ *
+ * This function differs from hook_uninstall() in that it gives all other
+ * modules a chance to perform actions when a module is uninstalled, whereas
+ * hook_uninstall() is only called on the module actually being uninstalled.
+ *
+ * It is recommended that you implement this hook if your module stores
+ * data that may have been set by other modules.
+ *
+ * @param $modules
+ * An array of the modules that were uninstalled.
+ *
+ * @see hook_uninstall()
+ * @see hook_modules_disabled()
+ */
+function hook_modules_uninstalled($modules) {
+ foreach ($modules as $module) {
+ db_delete('mymodule_table')
+ ->condition('module', $module)
+ ->execute();
+ }
+ mymodule_cache_rebuild();
+}
+
+/**
+ * Registers PHP stream wrapper implementations associated with a module.
+ *
+ * Provide a facility for managing and querying user-defined stream wrappers
+ * in PHP. PHP's internal stream_get_wrappers() doesn't return the class
+ * registered to handle a stream, which we need to be able to find the handler
+ * for class instantiation.
+ *
+ * If a module registers a scheme that is already registered with PHP, it will
+ * be unregistered and replaced with the specified class.
+ *
+ * @return
+ * A nested array, keyed first by scheme name ("public" for "public://"),
+ * then keyed by the following values:
+ * - 'name' A short string to name the wrapper.
+ * - 'class' A string specifying the PHP class that implements the
+ * DrupalStreamWrapperInterface interface.
+ * - 'description' A string with a short description of what the wrapper does.
+ * - 'type' (Optional) A bitmask of flags indicating what type of streams this
+ * wrapper will access - local or remote, readable and/or writeable, etc.
+ * Many shortcut constants are defined in stream_wrappers.inc. Defaults to
+ * STREAM_WRAPPERS_NORMAL which includes all of these bit flags:
+ * - STREAM_WRAPPERS_READ
+ * - STREAM_WRAPPERS_WRITE
+ * - STREAM_WRAPPERS_VISIBLE
+ *
+ * @see file_get_stream_wrappers()
+ * @see hook_stream_wrappers_alter()
+ * @see system_stream_wrappers()
+ */
+function hook_stream_wrappers() {
+ return array(
+ 'public' => array(
+ 'name' => t('Public files'),
+ 'class' => 'DrupalPublicStreamWrapper',
+ 'description' => t('Public local files served by the webserver.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_NORMAL,
+ ),
+ 'private' => array(
+ 'name' => t('Private files'),
+ 'class' => 'DrupalPrivateStreamWrapper',
+ 'description' => t('Private local files served by Drupal.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_NORMAL,
+ ),
+ 'temp' => array(
+ 'name' => t('Temporary files'),
+ 'class' => 'DrupalTempStreamWrapper',
+ 'description' => t('Temporary local files for upload and previews.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_HIDDEN,
+ ),
+ 'cdn' => array(
+ 'name' => t('Content delivery network files'),
+ 'class' => 'MyModuleCDNStreamWrapper',
+ 'description' => t('Files served by a content delivery network.'),
+ // 'type' can be omitted to use the default of STREAM_WRAPPERS_NORMAL
+ ),
+ 'youtube' => array(
+ 'name' => t('YouTube video'),
+ 'class' => 'MyModuleYouTubeStreamWrapper',
+ 'description' => t('Video streamed from YouTube.'),
+ // A module implementing YouTube integration may decide to support using
+ // the YouTube API for uploading video, but here, we assume that this
+ // particular module only supports playing YouTube video.
+ 'type' => STREAM_WRAPPERS_READ_VISIBLE,
+ ),
+ );
+}
+
+/**
+ * Alters the list of PHP stream wrapper implementations.
+ *
+ * @see file_get_stream_wrappers()
+ * @see hook_stream_wrappers()
+ */
+function hook_stream_wrappers_alter(&$wrappers) {
+ // Change the name of private files to reflect the performance.
+ $wrappers['private']['name'] = t('Slow files');
+}
+
+/**
+ * Load additional information into file objects.
+ *
+ * file_load_multiple() calls this hook to allow modules to load
+ * additional information into each file.
+ *
+ * @param $files
+ * An array of file objects, indexed by fid.
+ *
+ * @see file_load_multiple()
+ * @see upload_file_load()
+ */
+function hook_file_load($files) {
+ // Add the upload specific data into the file object.
+ $result = db_query('SELECT * FROM {upload} u WHERE u.fid IN (:fids)', array(':fids' => array_keys($files)))->fetchAll(PDO::FETCH_ASSOC);
+ foreach ($result as $record) {
+ foreach ($record as $key => $value) {
+ $files[$record['fid']]->$key = $value;
+ }
+ }
+}
+
+/**
+ * Check that files meet a given criteria.
+ *
+ * This hook lets modules perform additional validation on files. They're able
+ * to report a failure by returning one or more error messages.
+ *
+ * @param $file
+ * The file object being validated.
+ * @return
+ * An array of error messages. If there are no problems with the file return
+ * an empty array.
+ *
+ * @see file_validate()
+ */
+function hook_file_validate($file) {
+ $errors = array();
+
+ if (empty($file->filename)) {
+ $errors[] = t("The file's name is empty. Please give a name to the file.");
+ }
+ if (strlen($file->filename) > 255) {
+ $errors[] = t("The file's name exceeds the 255 characters limit. Please rename the file and try again.");
+ }
+
+ return $errors;
+}
+
+/**
+ * Act on a file being inserted or updated.
+ *
+ * This hook is called when a file has been added to the database. The hook
+ * doesn't distinguish between files created as a result of a copy or those
+ * created by an upload.
+ *
+ * @param $file
+ * The file that has just been created.
+ *
+ * @see file_save()
+ */
+function hook_file_presave($file) {
+ // Change the file timestamp to an hour prior.
+ $file->timestamp -= 3600;
+}
+
+/**
+ * Respond to a file being added.
+ *
+ * This hook is called after a file has been added to the database. The hook
+ * doesn't distinguish between files created as a result of a copy or those
+ * created by an upload.
+ *
+ * @param $file
+ * The file that has been added.
+ *
+ * @see file_save()
+ */
+function hook_file_insert($file) {
+ // Add a message to the log, if the file is a jpg
+ $validate = file_validate_extensions($file, 'jpg');
+ if (empty($validate)) {
+ watchdog('file', 'A jpg has been added.');
+ }
+}
+
+/**
+ * Respond to a file being updated.
+ *
+ * This hook is called when file_save() is called on an existing file.
+ *
+ * @param $file
+ * The file that has just been updated.
+ *
+ * @see file_save()
+ */
+function hook_file_update($file) {
+
+}
+
+/**
+ * Respond to a file that has been copied.
+ *
+ * @param $file
+ * The newly copied file object.
+ * @param $source
+ * The original file before the copy.
+ *
+ * @see file_copy()
+ */
+function hook_file_copy($file, $source) {
+
+}
+
+/**
+ * Respond to a file that has been moved.
+ *
+ * @param $file
+ * The updated file object after the move.
+ * @param $source
+ * The original file object before the move.
+ *
+ * @see file_move()
+ */
+function hook_file_move($file, $source) {
+
+}
+
+/**
+ * Respond to a file being deleted.
+ *
+ * @param $file
+ * The file that has just been deleted.
+ *
+ * @see file_delete()
+ * @see upload_file_delete()
+ */
+function hook_file_delete($file) {
+ // Delete all information associated with the file.
+ db_delete('upload')->condition('fid', $file->fid)->execute();
+}
+
+/**
+ * Control access to private file downloads and specify HTTP headers.
+ *
+ * This hook allows modules enforce permissions on file downloads when the
+ * private file download method is selected. Modules can also provide headers
+ * to specify information like the file's name or MIME type.
+ *
+ * @param $uri
+ * The URI of the file.
+ * @return
+ * If the user does not have permission to access the file, return -1. If the
+ * user has permission, return an array with the appropriate headers. If the
+ * file is not controlled by the current module, the return value should be
+ * NULL.
+ *
+ * @see file_download()
+ */
+function hook_file_download($uri) {
+ // Check if the file is controlled by the current module.
+ if (!file_prepare_directory($uri)) {
+ $uri = FALSE;
+ }
+ if (strpos(file_uri_target($uri), variable_get('user_picture_path', 'pictures') . '/picture-') === 0) {
+ if (!user_access('access user profiles')) {
+ // Access to the file is denied.
+ return -1;
+ }
+ else {
+ $info = image_get_info($uri);
+ return array('Content-Type' => $info['mime_type']);
+ }
+ }
+}
+
+/**
+ * Alter the URL to a file.
+ *
+ * This hook is called from file_create_url(), and is called fairly
+ * frequently (10+ times per page), depending on how many files there are in a
+ * given page.
+ * If CSS and JS aggregation are disabled, this can become very frequently
+ * (50+ times per page) so performance is critical.
+ *
+ * This function should alter the URI, if it wants to rewrite the file URL.
+ *
+ * @param $uri
+ * The URI to a file for which we need an external URL, or the path to a
+ * shipped file.
+ */
+function hook_file_url_alter(&$uri) {
+ global $user;
+
+ // User 1 will always see the local file in this example.
+ if ($user->uid == 1) {
+ return;
+ }
+
+ $cdn1 = 'http://cdn1.example.com';
+ $cdn2 = 'http://cdn2.example.com';
+ $cdn_extensions = array('css', 'js', 'gif', 'jpg', 'jpeg', 'png');
+
+ // Most CDNs don't support private file transfers without a lot of hassle,
+ // so don't support this in the common case.
+ $schemes = array('public');
+
+ $scheme = file_uri_scheme($uri);
+
+ // Only serve shipped files and public created files from the CDN.
+ if (!$scheme || in_array($scheme, $schemes)) {
+ // Shipped files.
+ if (!$scheme) {
+ $path = $uri;
+ }
+ // Public created files.
+ else {
+ $wrapper = file_stream_wrapper_get_instance_by_scheme($scheme);
+ $path = $wrapper->getDirectoryPath() . '/' . file_uri_target($uri);
+ }
+
+ // Clean up Windows paths.
+ $path = str_replace('\\', '/', $path);
+
+ // Serve files with one of the CDN extensions from CDN 1, all others from
+ // CDN 2.
+ $pathinfo = pathinfo($path);
+ if (isset($pathinfo['extension']) && in_array($pathinfo['extension'], $cdn_extensions)) {
+ $uri = $cdn1 . '/' . $path;
+ }
+ else {
+ $uri = $cdn2 . '/' . $path;
+ }
+ }
+}
+
+/**
+ * Check installation requirements and do status reporting.
+ *
+ * This hook has three closely related uses, determined by the $phase argument:
+ * - Checking installation requirements ($phase == 'install').
+ * - Checking update requirements ($phase == 'update').
+ * - Status reporting ($phase == 'runtime').
+ *
+ * Note that this hook, like all others dealing with installation and updates,
+ * must reside in a module_name.install file, or it will not properly abort
+ * the installation of the module if a critical requirement is missing.
+ *
+ * During the 'install' phase, modules can for example assert that
+ * library or server versions are available or sufficient.
+ * Note that the installation of a module can happen during installation of
+ * Drupal itself (by install.php) with an installation profile or later by hand.
+ * As a consequence, install-time requirements must be checked without access
+ * to the full Drupal API, because it is not available during install.php.
+ * For localization you should for example use $t = get_t() to
+ * retrieve the appropriate localization function name (t() or st()).
+ * If a requirement has a severity of REQUIREMENT_ERROR, install.php will abort
+ * or at least the module will not install.
+ * Other severity levels have no effect on the installation.
+ * Module dependencies do not belong to these installation requirements,
+ * but should be defined in the module's .info file.
+ *
+ * The 'runtime' phase is not limited to pure installation requirements
+ * but can also be used for more general status information like maintenance
+ * tasks and security issues.
+ * The returned 'requirements' will be listed on the status report in the
+ * administration section, with indication of the severity level.
+ * Moreover, any requirement with a severity of REQUIREMENT_ERROR severity will
+ * result in a notice on the the administration overview page.
+ *
+ * @param $phase
+ * The phase in which requirements are checked:
+ * - install: The module is being installed.
+ * - update: The module is enabled and update.php is run.
+ * - runtime: The runtime requirements are being checked and shown on the
+ * status report page.
+ *
+ * @return
+ * A keyed array of requirements. Each requirement is itself an array with
+ * the following items:
+ * - title: The name of the requirement.
+ * - value: The current value (e.g., version, time, level, etc). During
+ * install phase, this should only be used for version numbers, do not set
+ * it if not applicable.
+ * - description: The description of the requirement/status.
+ * - severity: The requirement's result/severity level, one of:
+ * - REQUIREMENT_INFO: For info only.
+ * - REQUIREMENT_OK: The requirement is satisfied.
+ * - REQUIREMENT_WARNING: The requirement failed with a warning.
+ * - REQUIREMENT_ERROR: The requirement failed with an error.
+ */
+function hook_requirements($phase) {
+ $requirements = array();
+ // Ensure translations don't break at install time
+ $t = get_t();
+
+ // Report Drupal version
+ if ($phase == 'runtime') {
+ $requirements['drupal'] = array(
+ 'title' => $t('Drupal'),
+ 'value' => VERSION,
+ 'severity' => REQUIREMENT_INFO
+ );
+ }
+
+ // Test PHP version
+ $requirements['php'] = array(
+ 'title' => $t('PHP'),
+ 'value' => ($phase == 'runtime') ? l(phpversion(), 'admin/logs/status/php') : phpversion(),
+ );
+ if (version_compare(phpversion(), DRUPAL_MINIMUM_PHP) < 0) {
+ $requirements['php']['description'] = $t('Your PHP installation is too old. Drupal requires at least PHP %version.', array('%version' => DRUPAL_MINIMUM_PHP));
+ $requirements['php']['severity'] = REQUIREMENT_ERROR;
+ }
+
+ // Report cron status
+ if ($phase == 'runtime') {
+ $cron_last = variable_get('cron_last');
+
+ if (is_numeric($cron_last)) {
+ $requirements['cron']['value'] = $t('Last run !time ago', array('!time' => format_interval(REQUEST_TIME - $cron_last)));
+ }
+ else {
+ $requirements['cron'] = array(
+ 'description' => $t('Cron has not run. It appears cron jobs have not been setup on your system. Check the help pages for <a href="@url">configuring cron jobs</a>.', array('@url' => 'http://drupal.org/cron')),
+ 'severity' => REQUIREMENT_ERROR,
+ 'value' => $t('Never run'),
+ );
+ }
+
+ $requirements['cron']['description'] .= ' ' . $t('You can <a href="@cron">run cron manually</a>.', array('@cron' => url('admin/logs/status/run-cron')));
+
+ $requirements['cron']['title'] = $t('Cron maintenance tasks');
+ }
+
+ return $requirements;
+}
+
+/**
+ * Define the current version of the database schema.
+ *
+ * A Drupal schema definition is an array structure representing one or
+ * more tables and their related keys and indexes. A schema is defined by
+ * hook_schema() which must live in your module's .install file.
+ *
+ * This hook is called at both install and uninstall time, and in the latter
+ * case, it cannot rely on the .module file being loaded or hooks being known.
+ * If the .module file is needed, it may be loaded with drupal_load().
+ *
+ * The tables declared by this hook will be automatically created when
+ * the module is first enabled, and removed when the module is uninstalled.
+ * This happens before hook_install() is invoked, and after hook_uninstall()
+ * is invoked, respectively.
+ *
+ * By declaring the tables used by your module via an implementation of
+ * hook_schema(), these tables will be available on all supported database
+ * engines. You don't have to deal with the different SQL dialects for table
+ * creation and alteration of the supported database engines *
+ * See the Schema API Handbook at http://drupal.org/node/146843 for
+ * details on schema definition structures.
+ *
+ * @return
+ * A schema definition structure array. For each element of the
+ * array, the key is a table name and the value is a table structure
+ * definition.
+ *
+ * @ingroup schemaapi
+ */
+function hook_schema() {
+ $schema['node'] = array(
+ // example (partial) specification for table "node"
+ 'description' => 'The base table for nodes.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The primary identifier for a node.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE),
+ 'vid' => array(
+ 'description' => 'The current {node_revision}.vid version identifier.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0),
+ 'type' => array(
+ 'description' => 'The {node_type} of this node.',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => ''),
+ 'title' => array(
+ 'description' => 'The title of this node, always treated as non-markup plain text.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => ''),
+ ),
+ 'indexes' => array(
+ 'node_changed' => array('changed'),
+ 'node_created' => array('created'),
+ ),
+ 'unique keys' => array(
+ 'nid_vid' => array('nid', 'vid'),
+ 'vid' => array('vid')
+ ),
+ 'foreign keys' => array(
+ 'node_revision' => array(
+ 'table' => 'node_revision',
+ 'columns' => array('vid' => 'vid'),
+ ),
+ 'node_author' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid')
+ ),
+ ),
+ 'primary key' => array('nid'),
+ );
+ return $schema;
+}
+
+/**
+ * Perform alterations to existing database schemas.
+ *
+ * When a module modifies the database structure of another module (by
+ * changing, adding or removing fields, keys or indexes), it should
+ * implement hook_schema_alter() to update the default $schema to take its
+ * changes into account.
+ *
+ * See hook_schema() for details on the schema definition structure.
+ *
+ * @param $schema
+ * Nested array describing the schemas for all modules.
+ */
+function hook_schema_alter(&$schema) {
+ // Add field to existing schema.
+ $schema['users']['fields']['timezone_id'] = array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Per-user timezone configuration.',
+ );
+}
+
+/**
+ * Perform alterations to a structured query.
+ *
+ * Structured (aka dynamic) queries that have tags associated may be altered by any module
+ * before the query is executed.
+ *
+ * @param $query
+ * A Query object describing the composite parts of a SQL query.
+ *
+ * @see hook_query_TAG_alter()
+ * @see node_query_node_access_alter()
+ * @see QueryAlterableInterface
+ * @see SelectQueryInterface
+ */
+function hook_query_alter(QueryAlterableInterface $query) {
+ if ($query->hasTag('micro_limit')) {
+ $query->range(0, 2);
+ }
+}
+
+/**
+ * Perform alterations to a structured query for a given tag.
+ *
+ * @param $query
+ * An Query object describing the composite parts of a SQL query.
+ *
+ * @see hook_query_alter()
+ * @see node_query_node_access_alter()
+ * @see QueryAlterableInterface
+ * @see SelectQueryInterface
+ */
+function hook_query_TAG_alter(QueryAlterableInterface $query) {
+ // Skip the extra expensive alterations if site has no node access control modules.
+ if (!node_access_view_all_nodes()) {
+ // Prevent duplicates records.
+ $query->distinct();
+ // The recognized operations are 'view', 'update', 'delete'.
+ if (!$op = $query->getMetaData('op')) {
+ $op = 'view';
+ }
+ // Skip the extra joins and conditions for node admins.
+ if (!user_access('bypass node access')) {
+ // The node_access table has the access grants for any given node.
+ $access_alias = $query->join('node_access', 'na', '%alias.nid = n.nid');
+ $or = db_or();
+ // If any grant exists for the specified user, then user has access to the node for the specified operation.
+ foreach (node_access_grants($op, $query->getMetaData('account')) as $realm => $gids) {
+ foreach ($gids as $gid) {
+ $or->condition(db_and()
+ ->condition($access_alias . '.gid', $gid)
+ ->condition($access_alias . '.realm', $realm)
+ );
+ }
+ }
+
+ if (count($or->conditions())) {
+ $query->condition($or);
+ }
+
+ $query->condition($access_alias . 'grant_' . $op, 1, '>=');
+ }
+ }
+}
+
+/**
+ * Perform setup tasks when the module is installed.
+ *
+ * If the module implements hook_schema(), the database tables will
+ * be created before this hook is fired.
+ *
+ * Implementations of this hook are by convention declared in the module's
+ * .install file. The implementation can rely on the .module file being loaded.
+ * The hook will only be called the first time a module is enabled or after it
+ * is re-enabled after being uninstalled. The module's schema version will be
+ * set to the module's greatest numbered update hook. Because of this, any time
+ * a hook_update_N() is added to the module, this function needs to be updated
+ * to reflect the current version of the database schema.
+ *
+ * See the Schema API documentation at
+ * @link http://drupal.org/node/146843 http://drupal.org/node/146843 @endlink
+ * for details on hook_schema and how database tables are defined.
+ *
+ * Note that since this function is called from a full bootstrap, all functions
+ * (including those in modules enabled by the current page request) are
+ * available when this hook is called. Use cases could be displaying a user
+ * message, or calling a module function necessary for initial setup, etc.
+ *
+ * Please be sure that anything added or modified in this function that can
+ * be removed during uninstall should be removed with hook_uninstall().
+ *
+ * @see hook_schema()
+ * @see module_enable()
+ * @see hook_enable()
+ * @see hook_disable()
+ * @see hook_uninstall()
+ * @see hook_modules_installed()
+ */
+function hook_install() {
+ // Populate the default {node_access} record.
+ db_insert('node_access')
+ ->fields(array(
+ 'nid' => 0,
+ 'gid' => 0,
+ 'realm' => 'all',
+ 'grant_view' => 1,
+ 'grant_update' => 0,
+ 'grant_delete' => 0,
+ ))
+ ->execute();
+}
+
+/**
+ * Perform a single update.
+ *
+ * For each patch which requires a database change add a new hook_update_N()
+ * which will be called by update.php. The database updates are numbered
+ * sequentially according to the version of Drupal you are compatible with.
+ *
+ * Schema updates should adhere to the Schema API:
+ * @link http://drupal.org/node/150215 http://drupal.org/node/150215 @endlink
+ *
+ * Database updates consist of 3 parts:
+ * - 1 digit for Drupal core compatibility
+ * - 1 digit for your module's major release version (e.g. is this the 5.x-1.* (1) or 5.x-2.* (2) series of your module?)
+ * - 2 digits for sequential counting starting with 00
+ *
+ * The 2nd digit should be 0 for initial porting of your module to a new Drupal
+ * core API.
+ *
+ * Examples:
+ * - mymodule_update_5200()
+ * - This is the first update to get the database ready to run mymodule 5.x-2.*.
+ * - mymodule_update_6000()
+ * - This is the required update for mymodule to run with Drupal core API 6.x.
+ * - mymodule_update_6100()
+ * - This is the first update to get the database ready to run mymodule 6.x-1.*.
+ * - mymodule_update_6200()
+ * - This is the first update to get the database ready to run mymodule 6.x-2.*.
+ * Users can directly update from 5.x-2.* to 6.x-2.* and they get all 60XX
+ * and 62XX updates, but not 61XX updates, because those reside in the
+ * 6.x-1.x branch only.
+ *
+ * A good rule of thumb is to remove updates older than two major releases of
+ * Drupal. See hook_update_last_removed() to notify Drupal about the removals.
+ *
+ * Never renumber update functions.
+ *
+ * Further information about releases and release numbers:
+ * - @link http://drupal.org/handbook/version-info http://drupal.org/handbook/version-info @endlink
+ * - @link http://drupal.org/node/93999 http://drupal.org/node/93999 @endlink (Overview of contributions branches and tags)
+ * - @link http://drupal.org/handbook/cvs/releases http://drupal.org/handbook/cvs/releases @endlink
+ *
+ * Implementations of this hook should be placed in a mymodule.install file in
+ * the same directory as mymodule.module. Drupal core's updates are implemented
+ * using the system module as a name and stored in database/updates.inc.
+ *
+ * If your update task is potentially time-consuming, you'll need to implement a
+ * multipass update to avoid PHP timeouts. Multipass updates use the $sandbox
+ * parameter provided by the batch API (normally, $context['sandbox']) to store
+ * information between successive calls, and the $sandbox['#finished'] value
+ * to provide feedback regarding completion level.
+ *
+ * See the batch operations page for more information on how to use the batch API:
+ * @link http://drupal.org/node/180528 http://drupal.org/node/180528 @endlink
+ *
+ * @param $sandbox
+ * Stores information for multipass updates. See above for more information.
+ *
+ * @throws DrupalUpdateException, PDOException
+ * In case of error, update hooks should throw an instance of DrupalUpdateException
+ * with a meaningful message for the user. If a database query fails for whatever
+ * reason, it will throw a PDOException.
+ *
+ * @return
+ * Optionally update hooks may return a translated string that will be displayed
+ * to the user. If no message is returned, no message will be presented to the
+ * user.
+ */
+function hook_update_N(&$sandbox) {
+ // For non-multipass updates, the signature can simply be;
+ // function hook_update_N() {
+
+ // For most updates, the following is sufficient.
+ db_add_field('mytable1', 'newcol', array('type' => 'int', 'not null' => TRUE, 'description' => 'My new integer column.'));
+
+ // However, for more complex operations that may take a long time,
+ // you may hook into Batch API as in the following example.
+
+ // Update 3 users at a time to have an exclamation point after their names.
+ // (They're really happy that we can do batch API in this hook!)
+ if (!isset($sandbox['progress'])) {
+ $sandbox['progress'] = 0;
+ $sandbox['current_uid'] = 0;
+ // We'll -1 to disregard the uid 0...
+ $sandbox['max'] = db_query('SELECT COUNT(DISTINCT uid) FROM {users}')->fetchField() - 1;
+ }
+
+ $users = db_select('users', 'u')
+ ->fields('u', array('uid', 'name'))
+ ->condition('uid', $sandbox['current_uid'], '>')
+ ->range(0, 3)
+ ->orderBy('uid', 'ASC')
+ ->execute();
+
+ foreach ($users as $user) {
+ $user->name .= '!';
+ db_update('users')
+ ->fields(array('name' => $user->name))
+ ->condition('uid', $user->uid)
+ ->execute();
+
+ $sandbox['progress']++;
+ $sandbox['current_uid'] = $user->uid;
+ }
+
+ $sandbox['#finished'] = empty($sandbox['max']) ? 1 : ($sandbox['progress'] / $sandbox['max']);
+
+ // To display a message to the user when the update is completed, return it.
+ // If you do not want to display a completion message, simply return nothing.
+ return t('The update did what it was supposed to do.');
+
+ // In case of an error, simply throw an exception with an error message.
+ throw new DrupalUpdateException('Something went wrong; here is what you should do.');
+}
+
+/**
+ * Return an array of information about module update dependencies.
+ *
+ * This can be used to indicate update functions from other modules that your
+ * module's update functions depend on, or vice versa. It is used by the update
+ * system to determine the appropriate order in which updates should be run, as
+ * well as to search for missing dependencies.
+ *
+ * Implementations of this hook should be placed in a mymodule.install file in
+ * the same directory as mymodule.module.
+ *
+ * @return
+ * A multidimensional array containing information about the module update
+ * dependencies. The first two levels of keys represent the module and update
+ * number (respectively) for which information is being returned, and the
+ * value is an array of information about that update's dependencies. Within
+ * this array, each key represents a module, and each value represents the
+ * number of an update function within that module. In the event that your
+ * update function depends on more than one update from a particular module,
+ * you should always list the highest numbered one here (since updates within
+ * a given module always run in numerical order).
+ *
+ * @see update_resolve_dependencies()
+ * @see hook_update_N()
+ */
+function hook_update_dependencies() {
+ // Indicate that the mymodule_update_8000() function provided by this module
+ // must run after the another_module_update_8002() function provided by the
+ // 'another_module' module.
+ $dependencies['mymodule'][8000] = array(
+ 'another_module' => 8002,
+ );
+ // Indicate that the mymodule_update_8001() function provided by this module
+ // must run before the yet_another_module_update_8004() function provided by
+ // the 'yet_another_module' module. (Note that declaring dependencies in this
+ // direction should be done only in rare situations, since it can lead to the
+ // following problem: If a site has already run the yet_another_module
+ // module's database updates before it updates its codebase to pick up the
+ // newest mymodule code, then the dependency declared here will be ignored.)
+ $dependencies['yet_another_module'][8004] = array(
+ 'mymodule' => 8001,
+ );
+ return $dependencies;
+}
+
+/**
+ * Return a number which is no longer available as hook_update_N().
+ *
+ * If you remove some update functions from your mymodule.install file, you
+ * should notify Drupal of those missing functions. This way, Drupal can
+ * ensure that no update is accidentally skipped.
+ *
+ * Implementations of this hook should be placed in a mymodule.install file in
+ * the same directory as mymodule.module.
+ *
+ * @return
+ * An integer, corresponding to hook_update_N() which has been removed from
+ * mymodule.install.
+ *
+ * @see hook_update_N()
+ */
+function hook_update_last_removed() {
+ // We've removed the 5.x-1.x version of mymodule, including database updates.
+ // The next update function is mymodule_update_5200().
+ return 5103;
+}
+
+/**
+ * Remove any information that the module sets.
+ *
+ * The information that the module should remove includes:
+ * - variables that the module has set using variable_set() or system_settings_form()
+ * - modifications to existing tables
+ *
+ * The module should not remove its entry from the {system} table. Database
+ * tables defined by hook_schema() will be removed automatically.
+ *
+ * The uninstall hook must be implemented in the module's .install file. It
+ * will fire when the module gets uninstalled but before the module's database
+ * tables are removed, allowing your module to query its own tables during
+ * this routine.
+ *
+ * When hook_uninstall() is called, your module will already be disabled, so
+ * its .module file will not be automatically included. If you need to call API
+ * functions from your .module file in this hook, use drupal_load() to make
+ * them available. (Keep this usage to a minimum, though, especially when
+ * calling API functions that invoke hooks, or API functions from modules
+ * listed as dependencies, since these may not be available or work as expected
+ * when the module is disabled.)
+ *
+ * @see hook_install()
+ * @see hook_schema()
+ * @see hook_disable()
+ * @see hook_modules_uninstalled()
+ */
+function hook_uninstall() {
+ variable_del('upload_file_types');
+}
+
+/**
+ * Perform necessary actions after module is enabled.
+ *
+ * The hook is called every time the module is enabled. It should be
+ * implemented in the module's .install file. The implementation can
+ * rely on the .module file being loaded.
+ *
+ * @see module_enable()
+ * @see hook_install()
+ * @see hook_modules_enabled()
+ */
+function hook_enable() {
+ mymodule_cache_rebuild();
+}
+
+/**
+ * Perform necessary actions before module is disabled.
+ *
+ * The hook is called every time the module is disabled. It should be
+ * implemented in the module's .install file. The implementation can rely
+ * on the .module file being loaded.
+ *
+ * @see hook_uninstall()
+ * @see hook_modules_disabled()
+ */
+function hook_disable() {
+ mymodule_cache_rebuild();
+}
+
+/**
+ * Perform necessary alterations to the list of files parsed by the registry.
+ *
+ * Modules can manually modify the list of files before the registry parses
+ * them. The $modules array provides the .info file information, which includes
+ * the list of files registered to each module. Any files in the list can then
+ * be added to the list of files that the registry will parse, or modify
+ * attributes of a file.
+ *
+ * A necessary alteration made by the core SimpleTest module is to force .test
+ * files provided by disabled modules into the list of files parsed by the
+ * registry.
+ *
+ * @param $files
+ * List of files to be parsed by the registry. The list will contain
+ * files found in each enabled module's info file and the core includes
+ * directory. The array is keyed by the file path and contains an array of
+ * the related module's name and weight as used internally by
+ * _registry_update() and related functions.
+ *
+ * For example:
+ * @code
+ * $files["modules/system/system.module"] = array(
+ * 'module' => 'system',
+ * 'weight' => 0,
+ * );
+ * @endcode
+ * @param $modules
+ * An array containing all module information stored in the {system} table.
+ * Each element of the array also contains the module's .info file
+ * information in the property 'info'. An additional 'dir' property has been
+ * added to the module information which provides the path to the directory
+ * in which the module resides. The example shows how to take advantage of
+ * both properties.
+ *
+ * @see _registry_update()
+ * @see simpletest_test_get_all()
+ */
+function hook_registry_files_alter(&$files, $modules) {
+ foreach ($modules as $module) {
+ // Only add test files for disabled modules, as enabled modules should
+ // already include any test files they provide.
+ if (!$module->status) {
+ $dir = $module->dir;
+ foreach ($module->info['files'] as $file) {
+ if (substr($file, -5) == '.test') {
+ $files["$dir/$file"] = array('module' => $module->name, 'weight' => $module->weight);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Return an array of tasks to be performed by an installation profile.
+ *
+ * Any tasks you define here will be run, in order, after the installer has
+ * finished the site configuration step but before it has moved on to the
+ * final import of languages and the end of the installation. You can have any
+ * number of custom tasks to perform during this phase.
+ *
+ * Each task you define here corresponds to a callback function which you must
+ * separately define and which is called when your task is run. This function
+ * will receive the global installation state variable, $install_state, as
+ * input, and has the opportunity to access or modify any of its settings. See
+ * the install_state_defaults() function in the installer for the list of
+ * $install_state settings used by Drupal core.
+ *
+ * At the end of your task function, you can indicate that you want the
+ * installer to pause and display a page to the user by returning any themed
+ * output that should be displayed on that page (but see below for tasks that
+ * use the form API or batch API; the return values of these task functions are
+ * handled differently). You should also use drupal_set_title() within the task
+ * callback function to set a custom page title. For some tasks, however, you
+ * may want to simply do some processing and pass control to the next task
+ * without ending the page request; to indicate this, simply do not send back
+ * a return value from your task function at all. This can be used, for
+ * example, by installation profiles that need to configure certain site
+ * settings in the database without obtaining any input from the user.
+ *
+ * The task function is treated specially if it defines a form or requires
+ * batch processing; in that case, you should return either the form API
+ * definition or batch API array, as appropriate. See below for more
+ * information on the 'type' key that you must define in the task definition
+ * to inform the installer that your task falls into one of those two
+ * categories. It is important to use these APIs directly, since the installer
+ * may be run non-interactively (for example, via a command line script), all
+ * in one page request; in that case, the installer will automatically take
+ * care of submitting forms and processing batches correctly for both types of
+ * installations. You can inspect the $install_state['interactive'] boolean to
+ * see whether or not the current installation is interactive, if you need
+ * access to this information.
+ *
+ * Remember that a user installing Drupal interactively will be able to reload
+ * an installation page multiple times, so you should use variable_set() and
+ * variable_get() if you are collecting any data that you need to store and
+ * inspect later. It is important to remove any temporary variables using
+ * variable_del() before your last task has completed and control is handed
+ * back to the installer.
+ *
+ * @return
+ * A keyed array of tasks the profile will perform during the final stage of
+ * the installation. Each key represents the name of a function (usually a
+ * function defined by this profile, although that is not strictly required)
+ * that is called when that task is run. The values are associative arrays
+ * containing the following key-value pairs (all of which are optional):
+ * - 'display_name'
+ * The human-readable name of the task. This will be displayed to the
+ * user while the installer is running, along with a list of other tasks
+ * that are being run. Leave this unset to prevent the task from
+ * appearing in the list.
+ * - 'display'
+ * This is a boolean which can be used to provide finer-grained control
+ * over whether or not the task will display. This is mostly useful for
+ * tasks that are intended to display only under certain conditions; for
+ * these tasks, you can set 'display_name' to the name that you want to
+ * display, but then use this boolean to hide the task only when certain
+ * conditions apply.
+ * - 'type'
+ * A string representing the type of task. This parameter has three
+ * possible values:
+ * - 'normal': This indicates that the task will be treated as a regular
+ * callback function, which does its processing and optionally returns
+ * HTML output. This is the default behavior which is used when 'type' is
+ * not set.
+ * - 'batch': This indicates that the task function will return a batch
+ * API definition suitable for batch_set(). The installer will then take
+ * care of automatically running the task via batch processing.
+ * - 'form': This indicates that the task function will return a standard
+ * form API definition (and separately define validation and submit
+ * handlers, as appropriate). The installer will then take care of
+ * automatically directing the user through the form submission process.
+ * - 'run'
+ * A constant representing the manner in which the task will be run. This
+ * parameter has three possible values:
+ * - INSTALL_TASK_RUN_IF_NOT_COMPLETED: This indicates that the task will
+ * run once during the installation of the profile. This is the default
+ * behavior which is used when 'run' is not set.
+ * - INSTALL_TASK_SKIP: This indicates that the task will not run during
+ * the current installation page request. It can be used to skip running
+ * an installation task when certain conditions are met, even though the
+ * task may still show on the list of installation tasks presented to the
+ * user.
+ * - INSTALL_TASK_RUN_IF_REACHED: This indicates that the task will run
+ * on each installation page request that reaches it. This is rarely
+ * necessary for an installation profile to use; it is primarily used by
+ * the Drupal installer for bootstrap-related tasks.
+ * - 'function'
+ * Normally this does not need to be set, but it can be used to force the
+ * installer to call a different function when the task is run (rather
+ * than the function whose name is given by the array key). This could be
+ * used, for example, to allow the same function to be called by two
+ * different tasks.
+ *
+ * @see install_state_defaults()
+ * @see batch_set()
+ */
+function hook_install_tasks() {
+ // Here, we define a variable to allow tasks to indicate that a particular,
+ // processor-intensive batch process needs to be triggered later on in the
+ // installation.
+ $myprofile_needs_batch_processing = variable_get('myprofile_needs_batch_processing', FALSE);
+ $tasks = array(
+ // This is an example of a task that defines a form which the user who is
+ // installing the site will be asked to fill out. To implement this task,
+ // your profile would define a function named myprofile_data_import_form()
+ // as a normal form API callback function, with associated validation and
+ // submit handlers. In the submit handler, in addition to saving whatever
+ // other data you have collected from the user, you might also call
+ // variable_set('myprofile_needs_batch_processing', TRUE) if the user has
+ // entered data which requires that batch processing will need to occur
+ // later on.
+ 'myprofile_data_import_form' => array(
+ 'display_name' => st('Data import options'),
+ 'type' => 'form',
+ ),
+ // Similarly, to implement this task, your profile would define a function
+ // named myprofile_settings_form() with associated validation and submit
+ // handlers. This form might be used to collect and save additional
+ // information from the user that your profile needs. There are no extra
+ // steps required for your profile to act as an "installation wizard"; you
+ // can simply define as many tasks of type 'form' as you wish to execute,
+ // and the forms will be presented to the user, one after another.
+ 'myprofile_settings_form' => array(
+ 'display_name' => st('Additional options'),
+ 'type' => 'form',
+ ),
+ // This is an example of a task that performs batch operations. To
+ // implement this task, your profile would define a function named
+ // myprofile_batch_processing() which returns a batch API array definition
+ // that the installer will use to execute your batch operations. Due to the
+ // 'myprofile_needs_batch_processing' variable used here, this task will be
+ // hidden and skipped unless your profile set it to TRUE in one of the
+ // previous tasks.
+ 'myprofile_batch_processing' => array(
+ 'display_name' => st('Import additional data'),
+ 'display' => $myprofile_needs_batch_processing,
+ 'type' => 'batch',
+ 'run' => $myprofile_needs_batch_processing ? INSTALL_TASK_RUN_IF_NOT_COMPLETED : INSTALL_TASK_SKIP,
+ ),
+ // This is an example of a task that will not be displayed in the list that
+ // the user sees. To implement this task, your profile would define a
+ // function named myprofile_final_site_setup(), in which additional,
+ // automated site setup operations would be performed. Since this is the
+ // last task defined by your profile, you should also use this function to
+ // call variable_del('myprofile_needs_batch_processing') and clean up the
+ // variable that was used above. If you want the user to pass to the final
+ // Drupal installation tasks uninterrupted, return no output from this
+ // function. Otherwise, return themed output that the user will see (for
+ // example, a confirmation page explaining that your profile's tasks are
+ // complete, with a link to reload the current page and therefore pass on
+ // to the final Drupal installation tasks when the user is ready to do so).
+ 'myprofile_final_site_setup' => array(
+ ),
+ );
+ return $tasks;
+}
+
+/**
+ * Change the page the user is sent to by drupal_goto().
+ *
+ * @param $path
+ * A Drupal path or a full URL.
+ * @param $options
+ * An associative array of additional URL options to pass to url().
+ * @param $http_response_code
+ * The HTTP status code to use for the redirection. See drupal_goto() for more
+ * information.
+ */
+function hook_drupal_goto_alter(&$path, &$options, &$http_response_code) {
+ // A good addition to misery module.
+ $http_response_code = 500;
+}
+
+/**
+ * Alter XHTML HEAD tags before they are rendered by drupal_get_html_head().
+ *
+ * Elements available to be altered are only those added using
+ * drupal_add_html_head_link() or drupal_add_html_head(). CSS and JS files
+ * are handled using drupal_add_css() and drupal_add_js(), so the head links
+ * for those files will not appear in the $head_elements array.
+ *
+ * @param $head_elements
+ * An array of renderable elements. Generally the values of the #attributes
+ * array will be the most likely target for changes.
+ */
+function hook_html_head_alter(&$head_elements) {
+ foreach ($head_elements as $key => $element) {
+ if (isset($element['#attributes']['rel']) && $element['#attributes']['rel'] == 'canonical') {
+ // I want a custom canonical url.
+ $head_elements[$key]['#attributes']['href'] = mymodule_canonical_url();
+ }
+ }
+}
+
+/**
+ * Alter the full list of installation tasks.
+ *
+ * @param $tasks
+ * An array of all available installation tasks, including those provided by
+ * Drupal core. You can modify this array to change or replace any part of
+ * the Drupal installation process that occurs after the installation profile
+ * is selected.
+ * @param $install_state
+ * An array of information about the current installation state.
+ */
+function hook_install_tasks_alter(&$tasks, $install_state) {
+ // Replace the "Choose language" installation task provided by Drupal core
+ // with a custom callback function defined by this installation profile.
+ $tasks['install_select_locale']['function'] = 'myprofile_locale_selection';
+}
+
+/**
+ * Alter MIME type mappings used to determine MIME type from a file extension.
+ *
+ * This hook is run when file_mimetype_mapping() is called. It is used to
+ * allow modules to add to or modify the default mapping from
+ * file_default_mimetype_mapping().
+ *
+ * @param $mapping
+ * An array of mimetypes correlated to the extensions that relate to them.
+ * The array has 'mimetypes' and 'extensions' elements, each of which is an
+ * array.
+ *
+ * @see file_default_mimetype_mapping()
+ */
+function hook_file_mimetype_mapping_alter(&$mapping) {
+ // Add new MIME type 'drupal/info'.
+ $mapping['mimetypes']['example_info'] = 'drupal/info';
+ // Add new extension '.info' and map it to the 'drupal/info' MIME type.
+ $mapping['extensions']['info'] = 'example_info';
+ // Override existing extension mapping for '.ogg' files.
+ $mapping['extensions']['ogg'] = 189;
+}
+
+/**
+ * Declares information about actions.
+ *
+ * Any module can define actions, and then call actions_do() to make those
+ * actions happen in response to events. The trigger module provides a user
+ * interface for associating actions with module-defined triggers, and it makes
+ * sure the core triggers fire off actions when their events happen.
+ *
+ * An action consists of two or three parts:
+ * - an action definition (returned by this hook)
+ * - a function which performs the action (which by convention is named
+ * MODULE_description-of-function_action)
+ * - an optional form definition function that defines a configuration form
+ * (which has the name of the action function with '_form' appended to it.)
+ *
+ * The action function takes two to four arguments, which come from the input
+ * arguments to actions_do().
+ *
+ * @return
+ * An associative array of action descriptions. The keys of the array
+ * are the names of the action functions, and each corresponding value
+ * is an associative array with the following key-value pairs:
+ * - 'type': The type of object this action acts upon. Core actions have types
+ * 'node', 'user', 'comment', and 'system'.
+ * - 'label': The human-readable name of the action, which should be passed
+ * through the t() function for translation.
+ * - 'configurable': If FALSE, then the action doesn't require any extra
+ * configuration. If TRUE, then your module must define a form function with
+ * the same name as the action function with '_form' appended (e.g., the
+ * form for 'node_assign_owner_action' is 'node_assign_owner_action_form'.)
+ * This function takes $context as its only parameter, and is paired with
+ * the usual _submit function, and possibly a _validate function.
+ * - 'triggers': An array of the events (that is, hooks) that can trigger this
+ * action. For example: array('node_insert', 'user_update'). You can also
+ * declare support for any trigger by returning array('any') for this value.
+ * - 'behavior': (optional) A machine-readable array of behaviors of this
+ * action, used to signal additionally required actions that may need to be
+ * triggered. Currently recognized behaviors by Trigger module:
+ * - 'changes_property': If an action with this behavior is assigned to a
+ * trigger other than a "presave" hook, any save actions also assigned to
+ * this trigger are moved later in the list. If no save action is present,
+ * one will be added.
+ * Modules that are processing actions (like Trigger module) should take
+ * special care for the "presave" hook, in which case a dependent "save"
+ * action should NOT be invoked.
+ *
+ * @ingroup actions
+ */
+function hook_action_info() {
+ return array(
+ 'comment_unpublish_action' => array(
+ 'type' => 'comment',
+ 'label' => t('Unpublish comment'),
+ 'configurable' => FALSE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('comment_presave', 'comment_insert', 'comment_update'),
+ ),
+ 'comment_unpublish_by_keyword_action' => array(
+ 'type' => 'comment',
+ 'label' => t('Unpublish comment containing keyword(s)'),
+ 'configurable' => TRUE,
+ 'behavior' => array('changes_property'),
+ 'triggers' => array('comment_presave', 'comment_insert', 'comment_update'),
+ ),
+ 'comment_save_action' => array(
+ 'type' => 'comment',
+ 'label' => t('Save comment'),
+ 'configurable' => FALSE,
+ 'triggers' => array('comment_insert', 'comment_update'),
+ ),
+ );
+}
+
+/**
+ * Executes code after an action is deleted.
+ *
+ * @param $aid
+ * The action ID.
+ */
+function hook_actions_delete($aid) {
+ db_delete('actions_assignments')
+ ->condition('aid', $aid)
+ ->execute();
+}
+
+/**
+ * Alters the actions declared by another module.
+ *
+ * Called by actions_list() to allow modules to alter the return values from
+ * implementations of hook_action_info().
+ *
+ * @see trigger_example_action_info_alter()
+ */
+function hook_action_info_alter(&$actions) {
+ $actions['node_unpublish_action']['label'] = t('Unpublish and remove from public view.');
+}
+
+/**
+ * Declare archivers to the system.
+ *
+ * An archiver is a class that is able to package and unpackage one or more files
+ * into a single possibly compressed file. Common examples of such files are
+ * zip files and tar.gz files. All archiver classes must implement
+ * ArchiverInterface.
+ *
+ * Each entry should be keyed on a unique value, and specify three
+ * additional keys:
+ * - class: The name of the PHP class for this archiver.
+ * - extensions: An array of file extensions that this archiver supports.
+ * - weight: This optional key specifies the weight of this archiver.
+ * When mapping file extensions to archivers, the first archiver by
+ * weight found that supports the requested extension will be used.
+ *
+ * @see hook_archiver_info_alter()
+ */
+function hook_archiver_info() {
+ return array(
+ 'tar' => array(
+ 'class' => 'ArchiverTar',
+ 'extensions' => array('tar', 'tar.gz', 'tar.bz2'),
+ ),
+ );
+}
+
+/**
+ * Alter archiver information declared by other modules.
+ *
+ * See hook_archiver_info() for a description of archivers and the archiver
+ * information structure.
+ *
+ * @param $info
+ * Archiver information to alter (return values from hook_archiver_info()).
+ */
+function hook_archiver_info_alter(&$info) {
+ $info['tar']['extensions'][] = 'tgz';
+}
+
+/**
+ * Define additional date types.
+ *
+ * Next to the 'long', 'medium' and 'short' date types defined in core, any
+ * module can define additional types that can be used when displaying dates,
+ * by implementing this hook. A date type is basically just a name for a date
+ * format.
+ *
+ * Date types are used in the administration interface: a user can assign
+ * date format types defined in hook_date_formats() to date types defined in
+ * this hook. Once a format has been assigned by a user, the machine name of a
+ * type can be used in the format_date() function to format a date using the
+ * chosen formatting.
+ *
+ * To define a date type in a module and make sure a format has been assigned to
+ * it, without requiring a user to visit the administrative interface, use
+ * @code variable_set('date_format_' . $type, $format); @endcode
+ * where $type is the machine-readable name defined here, and $format is a PHP
+ * date format string.
+ *
+ * To avoid namespace collisions with date types defined by other modules, it is
+ * recommended that each date type starts with the module name. A date type
+ * can consist of letters, numbers and underscores.
+ *
+ * @return
+ * An array of date types where the keys are the machine-readable names and
+ * the values are the human-readable labels.
+ *
+ * @see hook_date_formats()
+ * @see format_date()
+ */
+function hook_date_format_types() {
+ // Define the core date format types.
+ return array(
+ 'long' => t('Long'),
+ 'medium' => t('Medium'),
+ 'short' => t('Short'),
+ );
+}
+
+/**
+ * Modify existing date types.
+ *
+ * Allows other modules to modify existing date types like 'long'. Called by
+ * _system_date_format_types_build(). For instance, A module may use this hook
+ * to apply settings across all date types, such as locking all date types so
+ * they appear to be provided by the system.
+ *
+ * @param $types
+ * A list of date types. Each date type is keyed by the machine-readable name
+ * and the values are associative arrays containing:
+ * - is_new: Set to FALSE to override previous settings.
+ * - module: The name of the module that created the date type.
+ * - type: The machine-readable date type name.
+ * - title: The human-readable date type name.
+ * - locked: Specifies that the date type is system-provided.
+ */
+function hook_date_format_types_alter(&$types) {
+ foreach ($types as $name => $type) {
+ $types[$name]['locked'] = 1;
+ }
+}
+
+/**
+ * Define additional date formats.
+ *
+ * This hook is used to define the PHP date format strings that can be assigned
+ * to date types in the administrative interface. A module can provide date
+ * format strings for the core-provided date types ('long', 'medium', and
+ * 'short'), or for date types defined in hook_date_format_types() by itself
+ * or another module.
+ *
+ * Since date formats can be locale-specific, you can specify the locales that
+ * each date format string applies to. There may be more than one locale for a
+ * format. There may also be more than one format for the same locale. For
+ * example d/m/Y and Y/m/d work equally well in some locales. You may wish to
+ * define some additional date formats that aren't specific to any one locale,
+ * for example, "Y m". For these cases, the 'locales' component of the return
+ * value should be omitted.
+ *
+ * Providing a date format here does not normally assign the format to be
+ * used with the associated date type -- a user has to choose a format for each
+ * date type in the administrative interface. There is one exception: locale
+ * initialization chooses a locale-specific format for the three core-provided
+ * types (see locale_get_localized_date_format() for details). If your module
+ * needs to ensure that a date type it defines has a format associated with it,
+ * call @code variable_set('date_format_' . $type, $format); @endcode
+ * where $type is the machine-readable name defined in hook_date_format_types(),
+ * and $format is a PHP date format string.
+ *
+ * @return
+ * A list of date formats to offer as choices in the administrative
+ * interface. Each date format is a keyed array consisting of three elements:
+ * - 'type': The date type name that this format can be used with, as
+ * declared in an implementation of hook_date_format_types().
+ * - 'format': A PHP date format string to use when formatting dates. It
+ * can contain any of the formatting options described at
+ * http://php.net/manual/en/function.date.php
+ * - 'locales': (optional) An array of 2 and 5 character locale codes,
+ * defining which locales this format applies to (for example, 'en',
+ * 'en-us', etc.). If your date format is not language-specific, leave this
+ * array empty.
+ *
+ * @see hook_date_format_types()
+ */
+function hook_date_formats() {
+ return array(
+ array(
+ 'type' => 'mymodule_extra_long',
+ 'format' => 'l jS F Y H:i:s e',
+ 'locales' => array('en-ie'),
+ ),
+ array(
+ 'type' => 'mymodule_extra_long',
+ 'format' => 'l jS F Y h:i:sa',
+ 'locales' => array('en', 'en-us'),
+ ),
+ array(
+ 'type' => 'short',
+ 'format' => 'F Y',
+ 'locales' => array(),
+ ),
+ );
+}
+
+/**
+ * Alter date formats declared by another module.
+ *
+ * Called by _system_date_format_types_build() to allow modules to alter the
+ * return values from implementations of hook_date_formats().
+ */
+function hook_date_formats_alter(&$formats) {
+ foreach ($formats as $id => $format) {
+ $formats[$id]['locales'][] = 'en-ca';
+ }
+}
+
+/**
+ * Alters the delivery callback used to send the result of the page callback to the browser.
+ *
+ * Called by drupal_deliver_page() to allow modules to alter how the
+ * page is delivered to the browser.
+ *
+ * This hook is intended for altering the delivery callback based on
+ * information unrelated to the path of the page accessed. For example,
+ * it can be used to set the delivery callback based on a HTTP request
+ * header (as shown in the code sample). To specify a delivery callback
+ * based on path information, use hook_menu() or hook_menu_alter().
+ *
+ * This hook can also be used as an API function that can be used to explicitly
+ * set the delivery callback from some other function. For example, for a module
+ * named MODULE:
+ * @code
+ * function MODULE_page_delivery_callback_alter(&$callback, $set = FALSE) {
+ * static $stored_callback;
+ * if ($set) {
+ * $stored_callback = $callback;
+ * }
+ * elseif (isset($stored_callback)) {
+ * $callback = $stored_callback;
+ * }
+ * }
+ * function SOMEWHERE_ELSE() {
+ * $desired_delivery_callback = 'foo';
+ * MODULE_page_delivery_callback_alter($desired_delivery_callback, TRUE);
+ * }
+ * @endcode
+ *
+ * @param $callback
+ * The name of a function.
+ *
+ * @see drupal_deliver_page()
+ */
+function hook_page_delivery_callback_alter(&$callback) {
+ // jQuery sets a HTTP_X_REQUESTED_WITH header of 'XMLHttpRequest'.
+ // If a page would normally be delivered as an html page, and it is called
+ // from jQuery, deliver it instead as an Ajax response.
+ if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest' && $callback == 'drupal_deliver_html_page') {
+ $callback = 'ajax_deliver';
+ }
+}
+
+/**
+ * Alters theme operation links.
+ *
+ * @param $theme_groups
+ * An associative array containing groups of themes.
+ *
+ * @see system_themes_page()
+ */
+function hook_system_themes_page_alter(&$theme_groups) {
+ foreach ($theme_groups as $state => &$group) {
+ foreach ($theme_groups[$state] as &$theme) {
+ // Add a foo link to each list of theme operations.
+ $theme->operations[] = array(
+ 'title' => t('Foo'),
+ 'href' => 'admin/appearance/foo',
+ 'query' => array('theme' => $theme->name)
+ );
+ }
+ }
+}
+
+/**
+ * Alters inbound URL requests.
+ *
+ * @param $path
+ * The path being constructed, which, if a path alias, has been resolved to a
+ * Drupal path by the database, and which also may have been altered by other
+ * modules before this one.
+ * @param $original_path
+ * The original path, before being checked for path aliases or altered by any
+ * modules.
+ * @param $path_language
+ * The language of the path.
+ *
+ * @see drupal_get_normal_path()
+ */
+function hook_url_inbound_alter(&$path, $original_path, $path_language) {
+ // Create the path user/me/edit, which allows a user to edit their account.
+ if (preg_match('|^user/me/edit(/.*)?|', $path, $matches)) {
+ global $user;
+ $path = 'user/' . $user->uid . '/edit' . $matches[1];
+ }
+}
+
+/**
+ * Alters outbound URLs.
+ *
+ * @param $path
+ * The outbound path to alter, not adjusted for path aliases yet. It won't be
+ * adjusted for path aliases until all modules are finished altering it, thus
+ * being consistent with hook_url_alter_inbound(), which adjusts for all path
+ * aliases before allowing modules to alter it. This may have been altered by
+ * other modules before this one.
+ * @param $options
+ * A set of URL options for the URL so elements such as a fragment or a query
+ * string can be added to the URL.
+ * @param $original_path
+ * The original path, before being altered by any modules.
+ *
+ * @see url()
+ */
+function hook_url_outbound_alter(&$path, &$options, $original_path) {
+ // Use an external RSS feed rather than the Drupal one.
+ if ($path == 'rss.xml') {
+ $path = 'http://example.com/rss.xml';
+ $options['external'] = TRUE;
+ }
+
+ // Instead of pointing to user/[uid]/edit, point to user/me/edit.
+ if (preg_match('|^user/([0-9]*)/edit(/.*)?|', $path, $matches)) {
+ global $user;
+ if ($user->uid == $matches[1]) {
+ $path = 'user/me/edit' . $matches[2];
+ }
+ }
+}
+
+/**
+ * Alter the username that is displayed for a user.
+ *
+ * Called by format_username() to allow modules to alter the username that's
+ * displayed. Can be used to ensure user privacy in situations where
+ * $account->name is too revealing.
+ *
+ * @param $name
+ * The string that format_username() will return.
+ *
+ * @param $account
+ * The account object passed to format_username().
+ *
+ * @see format_username()
+ */
+function hook_username_alter(&$name, $account) {
+ // Display the user's uid instead of name.
+ if (isset($account->uid)) {
+ $name = t('User !uid', array('!uid' => $account->uid));
+ }
+}
+
+/**
+ * Provide replacement values for placeholder tokens.
+ *
+ * This hook is invoked when someone calls token_replace(). That function first
+ * scans the text for [type:token] patterns, and splits the needed tokens into
+ * groups by type. Then hook_tokens() is invoked on each token-type group,
+ * allowing your module to respond by providing replacement text for any of
+ * the tokens in the group that your module knows how to process.
+ *
+ * A module implementing this hook should also implement hook_token_info() in
+ * order to list its available tokens on editing screens.
+ *
+ * @param $type
+ * The machine-readable name of the type (group) of token being replaced, such
+ * as 'node', 'user', or another type defined by a hook_token_info()
+ * implementation.
+ * @param $tokens
+ * An array of tokens to be replaced. The keys are the machine-readable token
+ * names, and the values are the raw [type:token] strings that appeared in the
+ * original text.
+ * @param $data
+ * (optional) An associative array of data objects to be used when generating
+ * replacement values, as supplied in the $data parameter to token_replace().
+ * @param $options
+ * (optional) An associative array of options for token replacement; see
+ * token_replace() for possible values.
+ *
+ * @return
+ * An associative array of replacement values, keyed by the raw [type:token]
+ * strings from the original text.
+ *
+ * @see hook_token_info()
+ * @see hook_tokens_alter()
+ */
+function hook_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ $replacements = array();
+
+ if ($type == 'node' && !empty($data['node'])) {
+ $node = $data['node'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ // Simple key values on the node.
+ case 'nid':
+ $replacements[$original] = $node->nid;
+ break;
+
+ case 'title':
+ $replacements[$original] = $sanitize ? check_plain($node->title) : $node->title;
+ break;
+
+ case 'edit-url':
+ $replacements[$original] = url('node/' . $node->nid . '/edit', $url_options);
+ break;
+
+ // Default values for the chained tokens handled below.
+ case 'author':
+ $name = ($node->uid == 0) ? variable_get('anonymous', t('Anonymous')) : $node->name;
+ $replacements[$original] = $sanitize ? filter_xss($name) : $name;
+ break;
+
+ case 'created':
+ $replacements[$original] = format_date($node->created, 'medium', '', NULL, $language_code);
+ break;
+ }
+ }
+
+ if ($author_tokens = token_find_with_prefix($tokens, 'author')) {
+ $author = user_load($node->uid);
+ $replacements += token_generate('user', $author_tokens, array('user' => $author), $options);
+ }
+
+ if ($created_tokens = token_find_with_prefix($tokens, 'created')) {
+ $replacements += token_generate('date', $created_tokens, array('date' => $node->created), $options);
+ }
+ }
+
+ return $replacements;
+}
+
+/**
+ * Alter replacement values for placeholder tokens.
+ *
+ * @param $replacements
+ * An associative array of replacements returned by hook_tokens().
+ * @param $context
+ * The context in which hook_tokens() was called. An associative array with
+ * the following keys, which have the same meaning as the corresponding
+ * parameters of hook_tokens():
+ * - 'type'
+ * - 'tokens'
+ * - 'data'
+ * - 'options'
+ *
+ * @see hook_tokens()
+ */
+function hook_tokens_alter(array &$replacements, array $context) {
+ $options = $context['options'];
+
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ if ($context['type'] == 'node' && !empty($context['data']['node'])) {
+ $node = $context['data']['node'];
+
+ // Alter the [node:title] token, and replace it with the rendered content
+ // of a field (field_title).
+ if (isset($context['tokens']['title'])) {
+ $title = field_view_field('node', $node, 'field_title', 'default', $language_code);
+ $replacements[$context['tokens']['title']] = drupal_render($title);
+ }
+ }
+}
+
+/**
+ * Provide information about available placeholder tokens and token types.
+ *
+ * Tokens are placeholders that can be put into text by using the syntax
+ * [type:token], where type is the machine-readable name of a token type, and
+ * token is the machine-readable name of a token within this group. This hook
+ * provides a list of types and tokens to be displayed on text editing screens,
+ * so that people editing text can see what their token options are.
+ *
+ * The actual token replacement is done by token_replace(), which invokes
+ * hook_tokens(). Your module will need to implement that hook in order to
+ * generate token replacements from the tokens defined here.
+ *
+ * @return
+ * An associative array of available tokens and token types. The outer array
+ * has two components:
+ * - types: An associative array of token types (groups). Each token type is
+ * an associative array with the following components:
+ * - name: The translated human-readable short name of the token type.
+ * - description: A translated longer description of the token type.
+ * - needs-data: The type of data that must be provided to token_replace()
+ * in the $data argument (i.e., the key name in $data) in order for tokens
+ * of this type to be used in the $text being processed. For instance, if
+ * the token needs a node object, 'needs-data' should be 'node', and to
+ * use this token in token_replace(), the caller needs to supply a node
+ * object as $data['node']. Some token data can also be supplied
+ * indirectly; for instance, a node object in $data supplies a user object
+ * (the author of the node), allowing user tokens to be used when only
+ * a node data object is supplied.
+ * - tokens: An associative array of tokens. The outer array is keyed by the
+ * group name (the same key as in the types array). Within each group of
+ * tokens, each token item is keyed by the machine name of the token, and
+ * each token item has the following components:
+ * - name: The translated human-readable short name of the token.
+ * - description: A translated longer description of the token.
+ * - type (optional): A 'needs-data' data type supplied by this token, which
+ * should match a 'needs-data' value from another token type. For example,
+ * the node author token provides a user object, which can then be used
+ * for token replacement data in token_replace() without having to supply
+ * a separate user object.
+ *
+ * @see hook_token_info_alter()
+ * @see hook_tokens()
+ */
+function hook_token_info() {
+ $type = array(
+ 'name' => t('Nodes'),
+ 'description' => t('Tokens related to individual nodes.'),
+ 'needs-data' => 'node',
+ );
+
+ // Core tokens for nodes.
+ $node['nid'] = array(
+ 'name' => t("Node ID"),
+ 'description' => t("The unique ID of the node."),
+ );
+ $node['title'] = array(
+ 'name' => t("Title"),
+ 'description' => t("The title of the node."),
+ );
+ $node['edit-url'] = array(
+ 'name' => t("Edit URL"),
+ 'description' => t("The URL of the node's edit page."),
+ );
+
+ // Chained tokens for nodes.
+ $node['created'] = array(
+ 'name' => t("Date created"),
+ 'description' => t("The date the node was posted."),
+ 'type' => 'date',
+ );
+ $node['author'] = array(
+ 'name' => t("Author"),
+ 'description' => t("The author of the node."),
+ 'type' => 'user',
+ );
+
+ return array(
+ 'types' => array('node' => $type),
+ 'tokens' => array('node' => $node),
+ );
+}
+
+/**
+ * Alter the metadata about available placeholder tokens and token types.
+ *
+ * @param $data
+ * The associative array of token definitions from hook_token_info().
+ *
+ * @see hook_token_info()
+ */
+function hook_token_info_alter(&$data) {
+ // Modify description of node tokens for our site.
+ $data['tokens']['node']['nid'] = array(
+ 'name' => t("Node ID"),
+ 'description' => t("The unique ID of the article."),
+ );
+ $data['tokens']['node']['title'] = array(
+ 'name' => t("Title"),
+ 'description' => t("The title of the article."),
+ );
+
+ // Chained tokens for nodes.
+ $data['tokens']['node']['created'] = array(
+ 'name' => t("Date created"),
+ 'description' => t("The date the article was posted."),
+ 'type' => 'date',
+ );
+}
+
+/**
+ * Alter batch information before a batch is processed.
+ *
+ * Called by batch_process() to allow modules to alter a batch before it is
+ * processed.
+ *
+ * @param $batch
+ * The associative array of batch information. See batch_set() for details on
+ * what this could contain.
+ *
+ * @see batch_set()
+ * @see batch_process()
+ *
+ * @ingroup batch
+ */
+function hook_batch_alter(&$batch) {
+ // If the current page request is inside the overlay, add ?render=overlay to
+ // the success callback URL, so that it appears correctly within the overlay.
+ if (overlay_get_mode() == 'child') {
+ if (isset($batch['url_options']['query'])) {
+ $batch['url_options']['query']['render'] = 'overlay';
+ }
+ else {
+ $batch['url_options']['query'] = array('render' => 'overlay');
+ }
+ }
+}
+
+/**
+ * Provide information on Updaters (classes that can update Drupal).
+ *
+ * An Updater is a class that knows how to update various parts of the Drupal
+ * file system, for example to update modules that have newer releases, or to
+ * install a new theme.
+ *
+ * @return
+ * An associative array of information about the updater(s) being provided.
+ * This array is keyed by a unique identifier for each updater, and the
+ * values are subarrays that can contain the following keys:
+ * - class: The name of the PHP class which implements this updater.
+ * - name: Human-readable name of this updater.
+ * - weight: Controls what order the Updater classes are consulted to decide
+ * which one should handle a given task. When an update task is being run,
+ * the system will loop through all the Updater classes defined in this
+ * registry in weight order and let each class respond to the task and
+ * decide if each Updater wants to handle the task. In general, this
+ * doesn't matter, but if you need to override an existing Updater, make
+ * sure your Updater has a lighter weight so that it comes first.
+ *
+ * @see drupal_get_updaters()
+ * @see hook_updater_info_alter()
+ */
+function hook_updater_info() {
+ return array(
+ 'module' => array(
+ 'class' => 'ModuleUpdater',
+ 'name' => t('Update modules'),
+ 'weight' => 0,
+ ),
+ 'theme' => array(
+ 'class' => 'ThemeUpdater',
+ 'name' => t('Update themes'),
+ 'weight' => 0,
+ ),
+ );
+}
+
+/**
+ * Alter the Updater information array.
+ *
+ * An Updater is a class that knows how to update various parts of the Drupal
+ * file system, for example to update modules that have newer releases, or to
+ * install a new theme.
+ *
+ * @param array $updaters
+ * Associative array of updaters as defined through hook_updater_info().
+ * Alter this array directly.
+ *
+ * @see drupal_get_updaters()
+ * @see hook_updater_info()
+ */
+function hook_updater_info_alter(&$updaters) {
+ // Adjust weight so that the theme Updater gets a chance to handle a given
+ // update task before module updaters.
+ $updaters['theme']['weight'] = -1;
+}
+
+/**
+ * Alter the default country list.
+ *
+ * @param $countries
+ * The associative array of countries keyed by ISO 3166-1 country code.
+ *
+ * @see country_get_list()
+ * @see standard_country_list()
+ */
+function hook_countries_alter(&$countries) {
+ // Elbonia is now independent, so add it to the country list.
+ $countries['EB'] = 'Elbonia';
+}
+
+/**
+ * Control site status before menu dispatching.
+ *
+ * The hook is called after checking whether the site is offline but before
+ * the current router item is retrieved and executed by
+ * menu_execute_active_handler(). If the site is in offline mode,
+ * $menu_site_status is set to MENU_SITE_OFFLINE.
+ *
+ * @param $menu_site_status
+ * Supported values are MENU_SITE_OFFLINE, MENU_ACCESS_DENIED,
+ * MENU_NOT_FOUND and MENU_SITE_ONLINE. Any other value than
+ * MENU_SITE_ONLINE will skip the default menu handling system and be passed
+ * for delivery to drupal_deliver_page() with a NULL
+ * $default_delivery_callback.
+ * @param $path
+ * Contains the system path that is going to be loaded. This is read only,
+ * use hook_url_inbound_alter() to change the path.
+ */
+function hook_menu_site_status_alter(&$menu_site_status, $path) {
+ // Allow access to my_module/authentication even if site is in offline mode.
+ if ($menu_site_status == MENU_SITE_OFFLINE && user_is_anonymous() && $path == 'my_module/authentication') {
+ $menu_site_status = MENU_SITE_ONLINE;
+ }
+}
+
+/**
+ * Register information about FileTransfer classes provided by a module.
+ *
+ * The FileTransfer class allows transferring files over a specific type of
+ * connection. Core provides classes for FTP and SSH. Contributed modules are
+ * free to extend the FileTransfer base class to add other connection types,
+ * and if these classes are registered via hook_filetransfer_info(), those
+ * connection types will be available to site administrators using the Update
+ * manager when they are redirected to the authorize.php script to authorize
+ * the file operations.
+ *
+ * @return array
+ * Nested array of information about FileTransfer classes. Each key is a
+ * FileTransfer type (not human readable, used for form elements and
+ * variable names, etc), and the values are subarrays that define properties
+ * of that type. The keys in each subarray are:
+ * - 'title': Required. The human-readable name of the connection type.
+ * - 'class': Required. The name of the FileTransfer class. The constructor
+ * will always be passed the full path to the root of the site that should
+ * be used to restrict where file transfer operations can occur (the $jail)
+ * and an array of settings values returned by the settings form.
+ * - 'file': Required. The include file containing the FileTransfer class.
+ * This should be a separate .inc file, not just the .module file, so that
+ * the minimum possible code is loaded when authorize.php is running.
+ * - 'file path': Optional. The directory (relative to the Drupal root)
+ * where the include file lives. If not defined, defaults to the base
+ * directory of the module implementing the hook.
+ * - 'weight': Optional. Integer weight used for sorting connection types on
+ * the authorize.php form.
+ *
+ * @see FileTransfer
+ * @see authorize.php
+ * @see hook_filetransfer_info_alter()
+ * @see drupal_get_filetransfer_info()
+ */
+function hook_filetransfer_info() {
+ $info['sftp'] = array(
+ 'title' => t('SFTP (Secure FTP)'),
+ 'file' => 'sftp.filetransfer.inc',
+ 'class' => 'FileTransferSFTP',
+ 'weight' => 10,
+ );
+ return $info;
+}
+
+/**
+ * Alter the FileTransfer class registry.
+ *
+ * @param array $filetransfer_info
+ * Reference to a nested array containing information about the FileTransfer
+ * class registry.
+ *
+ * @see hook_filetransfer_info()
+ */
+function hook_filetransfer_info_alter(&$filetransfer_info) {
+ if (variable_get('paranoia', FALSE)) {
+ // Remove the FTP option entirely.
+ unset($filetransfer_info['ftp']);
+ // Make sure the SSH option is listed first.
+ $filetransfer_info['ssh']['weight'] = -10;
+ }
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/system/system.archiver.inc b/core/modules/system/system.archiver.inc
new file mode 100644
index 000000000000..c37f07daa104
--- /dev/null
+++ b/core/modules/system/system.archiver.inc
@@ -0,0 +1,139 @@
+<?php
+
+/**
+ * @file
+ * Archiver implementations provided by the system module.
+ */
+
+/**
+ * Archiver for .tar files.
+ */
+class ArchiverTar implements ArchiverInterface {
+
+ /**
+ * The underlying Archive_Tar instance that does the heavy lifting.
+ *
+ * @var Archive_Tar
+ */
+ protected $tar;
+
+ public function __construct($file_path) {
+ $this->tar = new Archive_Tar($file_path);
+ }
+
+ public function add($file_path) {
+ $this->tar->add($file_path);
+
+ return $this;
+ }
+
+ public function remove($file_path) {
+ // @todo Archive_Tar doesn't have a remove operation
+ // so we'll have to simulate it somehow, probably by
+ // creating a new archive with everything but the removed
+ // file.
+
+ return $this;
+ }
+
+ public function extract($path, Array $files = array()) {
+ if ($files) {
+ $this->tar->extractList($files, $path);
+ }
+ else {
+ $this->tar->extract($path);
+ }
+
+ return $this;
+ }
+
+ public function listContents() {
+ $files = array();
+ foreach ($this->tar->listContent() as $file_data) {
+ $files[] = $file_data['filename'];
+ }
+ return $files;
+ }
+
+ /**
+ * Retrieve the tar engine itself.
+ *
+ * In some cases it may be necessary to directly access the underlying
+ * Archive_Tar object for implementation-specific logic. This is for advanced
+ * use only as it is not shared by other implementations of ArchiveInterface.
+ *
+ * @return
+ * The Archive_Tar object used by this object.
+ */
+ public function getArchive() {
+ return $this->tar;
+ }
+}
+
+/**
+ * Archiver for .zip files.
+ *
+ * @link http://php.net/zip
+ */
+class ArchiverZip implements ArchiverInterface {
+
+ /**
+ * The underlying ZipArchive instance that does the heavy lifting.
+ *
+ * @var ZipArchive
+ */
+ protected $zip;
+
+ public function __construct($file_path) {
+ $this->zip = new ZipArchive();
+ if ($this->zip->open($file_path) !== TRUE) {
+ // @todo: This should be an interface-specific exception some day.
+ throw new Exception(t('Cannot open %file_path', array('%file_path' => $file_path)));
+ }
+ }
+
+ public function add($file_path) {
+ $this->zip->addFile($file_path);
+
+ return $this;
+ }
+
+ public function remove($file_path) {
+ $this->zip->deleteName($file_path);
+
+ return $this;
+ }
+
+ public function extract($path, Array $files = array()) {
+ if ($files) {
+ $this->zip->extractTo($path, $files);
+ }
+ else {
+ $this->zip->extractTo($path);
+ }
+
+ return $this;
+ }
+
+ public function listContents() {
+ $files = array();
+ for ($i=0; $i < $this->zip->numFiles; $i++) {
+ $files[] = $this->zip->getNameIndex($i);
+ }
+ return $files;
+ }
+
+ /**
+ * Retrieve the zip engine itself.
+ *
+ * In some cases it may be necessary to directly access the underlying
+ * ZipArchive object for implementation-specific logic. This is for advanced
+ * use only as it is not shared by other implementations of ArchiveInterface.
+ *
+ * @return
+ * The ZipArchive object used by this object.
+ */
+ public function getArchive() {
+ return $this->zip;
+ }
+}
diff --git a/core/modules/system/system.base-rtl.css b/core/modules/system/system.base-rtl.css
new file mode 100644
index 000000000000..9099c9d72b7f
--- /dev/null
+++ b/core/modules/system/system.base-rtl.css
@@ -0,0 +1,54 @@
+
+/**
+ * @file
+ * Generic theme-independent base styles.
+ */
+
+/**
+ * Autocomplete.
+ */
+/* Animated throbber */
+html.js input.form-autocomplete {
+ background-position: 0% 2px;
+}
+html.js input.throbbing {
+ background-position: 0% -18px;
+}
+
+/**
+ * Progress bar.
+ */
+.progress .percentage {
+ float: left;
+}
+.progress-disabled {
+ float: right;
+}
+.ajax-progress {
+ float: right;
+}
+.ajax-progress .throbber {
+ float: right;
+}
+
+/**
+ * TableDrag behavior.
+ */
+.draggable a.tabledrag-handle {
+ float: right;
+ margin: -0.4em -0.5em -0.4em 0;
+ padding: 0.42em 0.5em 0.42em 1.5em;
+}
+div.indentation {
+ float: right;
+ margin: -0.4em -0.4em -0.4em 0.2em;
+ padding: 0.42em 0.6em 0.42em 0;
+}
+div.tree-child,
+div.tree-child-last {
+ background-position: -65px center;
+}
+.tabledrag-toggle-weight-wrapper {
+ text-align: left;
+}
+
diff --git a/core/modules/system/system.base.css b/core/modules/system/system.base.css
new file mode 100644
index 000000000000..e78edca2c7c9
--- /dev/null
+++ b/core/modules/system/system.base.css
@@ -0,0 +1,281 @@
+
+/**
+ * @file
+ * Generic theme-independent base styles.
+ */
+
+/**
+ * Autocomplete.
+ *
+ * @see autocomplete.js
+ */
+/* Suggestion list */
+#autocomplete {
+ border: 1px solid;
+ overflow: hidden;
+ position: absolute;
+ z-index: 100;
+}
+#autocomplete ul {
+ list-style: none;
+ list-style-image: none;
+ margin: 0;
+ padding: 0;
+}
+#autocomplete li {
+ background: #fff;
+ color: #000;
+ cursor: default;
+ white-space: pre;
+}
+/* Animated throbber */
+html.js input.form-autocomplete {
+ background-image: url(../../misc/throbber.gif);
+ background-position: 100% 2px; /* LTR */
+ background-repeat: no-repeat;
+}
+html.js input.throbbing {
+ background-position: 100% -18px; /* LTR */
+}
+
+/**
+ * Collapsible fieldsets.
+ *
+ * @see collapse.js
+ */
+html.js fieldset.collapsed {
+ border-bottom-width: 0;
+ border-left-width: 0;
+ border-right-width: 0;
+ height: 1em;
+}
+html.js fieldset.collapsed .fieldset-wrapper {
+ display: none;
+}
+fieldset.collapsible {
+ position: relative;
+}
+fieldset.collapsible .fieldset-legend {
+ display: block;
+}
+
+/**
+ * Resizable textareas.
+ *
+ * @see textarea.js
+ */
+.form-textarea-wrapper textarea {
+ display: block;
+ margin: 0;
+ width: 100%;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
+.resizable-textarea .grippie {
+ background: #eee url(../../misc/grippie.png) no-repeat center 2px;
+ border: 1px solid #ddd;
+ border-top-width: 0;
+ cursor: s-resize;
+ height: 9px;
+ overflow: hidden;
+}
+
+/**
+ * TableDrag behavior.
+ *
+ * @see tabledrag.js
+ */
+body.drag {
+ cursor: move;
+}
+.draggable a.tabledrag-handle {
+ cursor: move;
+ float: left; /* LTR */
+ height: 1.7em;
+ margin: -0.4em 0 -0.4em -0.5em; /* LTR */
+ padding: 0.42em 1.5em 0.42em 0.5em; /* LTR */
+ text-decoration: none;
+}
+a.tabledrag-handle:hover {
+ text-decoration: none;
+}
+a.tabledrag-handle .handle {
+ background: url(../../misc/draggable.png) no-repeat 0 0;
+ height: 13px;
+ margin-top: 4px;
+ width: 13px;
+}
+a.tabledrag-handle-hover .handle {
+ background-position: 0 -20px;
+}
+div.indentation {
+ float: left; /* LTR */
+ height: 1.7em;
+ margin: -0.4em 0.2em -0.4em -0.4em; /* LTR */
+ padding: 0.42em 0 0.42em 0.6em; /* LTR */
+ width: 20px;
+}
+div.tree-child {
+ background: url(../../misc/tree.png) no-repeat 11px center; /* LTR */
+}
+div.tree-child-last {
+ background: url(../../misc/tree-bottom.png) no-repeat 11px center; /* LTR */
+}
+div.tree-child-horizontal {
+ background: url(../../misc/tree.png) no-repeat -11px center;
+}
+.tabledrag-toggle-weight-wrapper {
+ text-align: right; /* LTR */
+}
+
+/**
+ * TableHeader behavior.
+ *
+ * @see tableheader.js
+ */
+table.sticky-header {
+ background-color: #fff;
+ margin-top: 0;
+}
+
+/**
+ * Progress behavior.
+ *
+ * @see progress.js
+ */
+/* Bar */
+.progress .bar {
+ background-color: #fff;
+ border: 1px solid;
+}
+.progress .filled {
+ background-color: #000;
+ height: 1.5em;
+ width: 5px;
+}
+.progress .percentage {
+ float: right; /* LTR */
+}
+.progress-disabled {
+ float: left; /* LTR */
+}
+/* Throbber */
+.ajax-progress {
+ float: left; /* LTR */
+}
+.ajax-progress .throbber {
+ background: transparent url(../../misc/throbber.gif) no-repeat 0px -18px;
+ float: left; /* LTR */
+ height: 15px;
+ margin: 2px;
+ width: 15px;
+}
+tr .ajax-progress .throbber {
+ margin: 0 2px;
+}
+.ajax-progress-bar {
+ width: 16em;
+}
+
+/**
+ * Inline items.
+ */
+.container-inline div,
+.container-inline label {
+ display: inline;
+}
+/* Fieldset contents always need to be rendered as block. */
+.container-inline .fieldset-wrapper {
+ display: block;
+}
+
+/**
+ * Prevent text wrapping.
+ */
+.nowrap {
+ white-space: nowrap;
+}
+
+/**
+ * For anything you want to hide on page load when JS is enabled, so
+ * that you can use the JS to control visibility and avoid flicker.
+ */
+html.js .js-hide {
+ display: none;
+}
+
+/**
+ * Hide elements from all users.
+ *
+ * Used for elements which should not be immediately displayed to any user. An
+ * example would be a collapsible fieldset that will be expanded with a click
+ * from a user. The effect of this class can be toggled with the jQuery show()
+ * and hide() functions.
+ */
+.element-hidden {
+ display: none;
+}
+
+/**
+ * Hide elements visually, but keep them available for screen-readers.
+ *
+ * Used for information required for screen-reader users to understand and use
+ * the site where visual display is undesirable. Information provided in this
+ * manner should be kept concise, to avoid unnecessary burden on the user.
+ * "!important" is used to prevent unintentional overrides.
+ */
+.element-invisible {
+ position: absolute !important;
+ clip: rect(1px 1px 1px 1px); /* IE7 */
+ clip: rect(1px, 1px, 1px, 1px);
+}
+
+/**
+ * The .element-focusable class extends the .element-invisible class to allow
+ * the element to be focusable when navigated to via the keyboard.
+ */
+.element-invisible.element-focusable:active,
+.element-invisible.element-focusable:focus {
+ position: static !important;
+ clip: auto;
+}
+
+/**
+ * Float clearing.
+ *
+ * Based on the micro clearfix hack by Nicolas Gallagher, with the :before
+ * pseudo selector removed to allow normal top margin collapse.
+ *
+ * @see http://nicolasgallagher.com/micro-clearfix-hack
+ */
+.clearfix:after {
+ content: "";
+ display: table;
+ clear: both;
+}
+
+.clearfix {
+ zoom: 1; /* hasLayout trigger to clear floats in IE */
+}
+
+/**
+ * Block-level HTML5 display definition.
+ *
+ * Provides display values for browsers that don't recognize the new elements
+ * and therefore display them inline by default.
+ */
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+menu,
+nav,
+section,
+summary {
+ display: block;
+}
diff --git a/core/modules/system/system.cron.js b/core/modules/system/system.cron.js
new file mode 100644
index 000000000000..af17dab52739
--- /dev/null
+++ b/core/modules/system/system.cron.js
@@ -0,0 +1,19 @@
+(function ($) {
+
+/**
+ * Checks to see if the cron should be automatically run.
+ */
+Drupal.behaviors.cronCheck = {
+ attach: function(context, settings) {
+ if (settings.cronCheck || false) {
+ $('body').once('cron-check', function() {
+ // Only execute the cron check if its the right time.
+ if (Math.round(new Date().getTime() / 1000.0) > settings.cronCheck) {
+ $.get(settings.basePath + 'system/run-cron-check');
+ }
+ });
+ }
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/system/system.info b/core/modules/system/system.info
new file mode 100644
index 000000000000..c394849e73bb
--- /dev/null
+++ b/core/modules/system/system.info
@@ -0,0 +1,13 @@
+name = System
+description = Handles general site configuration for administrators.
+package = Core
+version = VERSION
+core = 8.x
+files[] = system.archiver.inc
+files[] = system.mail.inc
+files[] = system.queue.inc
+files[] = system.tar.inc
+files[] = system.updater.inc
+files[] = system.test
+required = TRUE
+configure = admin/config/system
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
new file mode 100644
index 000000000000..6f815097ab12
--- /dev/null
+++ b/core/modules/system/system.install
@@ -0,0 +1,1647 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the system module.
+ */
+
+/**
+ * Test and report Drupal installation requirements.
+ *
+ * @param $phase
+ * The current system installation phase.
+ * @return
+ * An array of system requirements.
+ */
+function system_requirements($phase) {
+ global $base_url;
+ $requirements = array();
+ // Ensure translations don't break at install time
+ $t = get_t();
+
+ // Report Drupal version
+ if ($phase == 'runtime') {
+ $requirements['drupal'] = array(
+ 'title' => $t('Drupal'),
+ 'value' => VERSION,
+ 'severity' => REQUIREMENT_INFO,
+ 'weight' => -10,
+ );
+
+ // Display the currently active install profile, if the site
+ // is not running the default install profile.
+ $profile = drupal_get_profile();
+ if ($profile != 'standard') {
+ $info = system_get_info('module', $profile);
+ $requirements['install_profile'] = array(
+ 'title' => $t('Install profile'),
+ 'value' => $t('%profile_name (%profile-%version)', array(
+ '%profile_name' => $info['name'],
+ '%profile' => $profile,
+ '%version' => $info['version']
+ )),
+ 'severity' => REQUIREMENT_INFO,
+ 'weight' => -9
+ );
+ }
+ }
+
+ // Web server information.
+ $software = $_SERVER['SERVER_SOFTWARE'];
+ $requirements['webserver'] = array(
+ 'title' => $t('Web server'),
+ 'value' => $software,
+ );
+
+ // Test PHP version and show link to phpinfo() if it's available
+ $phpversion = phpversion();
+ if (function_exists('phpinfo')) {
+ $requirements['php'] = array(
+ 'title' => $t('PHP'),
+ 'value' => ($phase == 'runtime') ? $phpversion .' ('. l($t('more information'), 'admin/reports/status/php') .')' : $phpversion,
+ );
+ }
+ else {
+ $requirements['php'] = array(
+ 'title' => $t('PHP'),
+ 'value' => $phpversion,
+ 'description' => $t('The phpinfo() function has been disabled for security reasons. To see your server\'s phpinfo() information, change your PHP settings or contact your server administrator. For more information, <a href="@phpinfo">Enabling and disabling phpinfo()</a> handbook page.', array('@phpinfo' => 'http://drupal.org/node/243993')),
+ 'severity' => REQUIREMENT_INFO,
+ );
+ }
+
+ if (version_compare($phpversion, DRUPAL_MINIMUM_PHP) < 0) {
+ $requirements['php']['description'] = $t('Your PHP installation is too old. Drupal requires at least PHP %version.', array('%version' => DRUPAL_MINIMUM_PHP));
+ $requirements['php']['severity'] = REQUIREMENT_ERROR;
+ // If PHP is old, it's not safe to continue with the requirements check.
+ return $requirements;
+ }
+
+ // Test PHP register_globals setting.
+ $requirements['php_register_globals'] = array(
+ 'title' => $t('PHP register globals'),
+ );
+ $register_globals = trim(ini_get('register_globals'));
+ // Unfortunately, ini_get() may return many different values, and we can't
+ // be certain which values mean 'on', so we instead check for 'not off'
+ // since we never want to tell the user that their site is secure
+ // (register_globals off), when it is in fact on. We can only guarantee
+ // register_globals is off if the value returned is 'off', '', or 0.
+ if (!empty($register_globals) && strtolower($register_globals) != 'off') {
+ $requirements['php_register_globals']['description'] = $t('<em>register_globals</em> is enabled. Drupal requires this configuration directive to be disabled. Your site may not be secure when <em>register_globals</em> is enabled. The PHP manual has instructions for <a href="http://php.net/configuration.changes">how to change configuration settings</a>.');
+ $requirements['php_register_globals']['severity'] = REQUIREMENT_ERROR;
+ $requirements['php_register_globals']['value'] = $t("Enabled ('@value')", array('@value' => $register_globals));
+ }
+ else {
+ $requirements['php_register_globals']['value'] = $t('Disabled');
+ }
+
+ // Test for PHP extensions.
+ $requirements['php_extensions'] = array(
+ 'title' => $t('PHP extensions'),
+ );
+
+ $missing_extensions = array();
+ $required_extensions = array(
+ 'date',
+ 'dom',
+ 'filter',
+ 'gd',
+ 'hash',
+ 'json',
+ 'pcre',
+ 'pdo',
+ 'session',
+ 'SimpleXML',
+ 'SPL',
+ 'xml',
+ );
+ foreach ($required_extensions as $extension) {
+ if (!extension_loaded($extension)) {
+ $missing_extensions[] = $extension;
+ }
+ }
+
+ if (!empty($missing_extensions)) {
+ $description = $t('Drupal requires you to enable the PHP extensions in the following list (see the <a href="@system_requirements">system requirements page</a> for more information):', array(
+ '@system_requirements' => 'http://drupal.org/requirements',
+ ));
+
+ $description .= theme('item_list', array('items' => $missing_extensions));
+
+ $requirements['php_extensions']['value'] = $t('Disabled');
+ $requirements['php_extensions']['severity'] = REQUIREMENT_ERROR;
+ $requirements['php_extensions']['description'] = $description;
+ }
+ else {
+ $requirements['php_extensions']['value'] = $t('Enabled');
+ }
+
+ if ($phase == 'install' || $phase == 'update') {
+ // Test for PDO (database).
+ $requirements['database_extensions'] = array(
+ 'title' => $t('Database support'),
+ );
+
+ // Make sure PDO is available.
+ $database_ok = extension_loaded('pdo');
+ if (!$database_ok) {
+ $pdo_message = $t('Your web server does not appear to support PDO (PHP Data Objects). Ask your hosting provider if they support the native PDO extension. See the <a href="@link">system requirements</a> page for more information.', array(
+ '@link' => 'http://drupal.org/requirements/pdo',
+ ));
+ }
+ else {
+ // Make sure at least one supported database driver exists.
+ $drivers = drupal_detect_database_types();
+ if (empty($drivers)) {
+ $database_ok = FALSE;
+ $pdo_message = $t('Your web server does not appear to support any common PDO database extensions. Check with your hosting provider to see if they support PDO (PHP Data Objects) and offer any databases that <a href="@drupal-databases">Drupal supports</a>.', array(
+ '@drupal-databases' => 'http://drupal.org/node/270#database',
+ ));
+ }
+ // Make sure the native PDO extension is available, not the older PEAR
+ // version. (See install_verify_pdo() for details.)
+ if (!defined('PDO::ATTR_DEFAULT_FETCH_MODE')) {
+ $database_ok = FALSE;
+ $pdo_message = $t('Your web server seems to have the wrong version of PDO installed. Drupal 7 requires the PDO extension from PHP core. This system has the older PECL version. See the <a href="@link">system requirements</a> page for more information.', array(
+ '@link' => 'http://drupal.org/requirements/pdo#pecl',
+ ));
+ }
+ }
+
+ if (!$database_ok) {
+ $requirements['database_extensions']['value'] = $t('Disabled');
+ $requirements['database_extensions']['severity'] = REQUIREMENT_ERROR;
+ $requirements['database_extensions']['description'] = $pdo_message;
+ }
+ else {
+ $requirements['database_extensions']['value'] = $t('Enabled');
+ }
+ }
+ else {
+ // Database information.
+ $class = 'DatabaseTasks_' . Database::getConnection()->driver();
+ $tasks = new $class();
+ $requirements['database_system'] = array(
+ 'title' => $t('Database system'),
+ 'value' => $tasks->name(),
+ );
+ $requirements['database_system_version'] = array(
+ 'title' => $t('Database system version'),
+ 'value' => Database::getConnection()->version(),
+ );
+ }
+
+ // Test PHP memory_limit
+ $memory_limit = ini_get('memory_limit');
+ $requirements['php_memory_limit'] = array(
+ 'title' => $t('PHP memory limit'),
+ 'value' => $memory_limit == -1 ? t('-1 (Unlimited)') : $memory_limit,
+ );
+
+ if ($memory_limit && $memory_limit != -1 && parse_size($memory_limit) < parse_size(DRUPAL_MINIMUM_PHP_MEMORY_LIMIT)) {
+ $description = '';
+ if ($phase == 'install') {
+ $description = $t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the installation process.', array('%memory_minimum_limit' => DRUPAL_MINIMUM_PHP_MEMORY_LIMIT));
+ }
+ elseif ($phase == 'update') {
+ $description = $t('Consider increasing your PHP memory limit to %memory_minimum_limit to help prevent errors in the update process.', array('%memory_minimum_limit' => DRUPAL_MINIMUM_PHP_MEMORY_LIMIT));
+ }
+ elseif ($phase == 'runtime') {
+ $description = $t('Depending on your configuration, Drupal can run with a %memory_limit PHP memory limit. However, a %memory_minimum_limit PHP memory limit or above is recommended, especially if your site uses additional custom or contributed modules.', array('%memory_limit' => $memory_limit, '%memory_minimum_limit' => DRUPAL_MINIMUM_PHP_MEMORY_LIMIT));
+ }
+
+ if (!empty($description)) {
+ if ($php_ini_path = get_cfg_var('cfg_file_path')) {
+ $description .= ' ' . $t('Increase the memory limit by editing the memory_limit parameter in the file %configuration-file and then restart your web server (or contact your system administrator or hosting provider for assistance).', array('%configuration-file' => $php_ini_path));
+ }
+ else {
+ $description .= ' ' . $t('Contact your system administrator or hosting provider for assistance with increasing your PHP memory limit.');
+ }
+
+ $requirements['php_memory_limit']['description'] = $description . ' ' . $t('See the <a href="@url">Drupal requirements</a> for more information.', array('@url' => 'http://drupal.org/requirements'));
+ $requirements['php_memory_limit']['severity'] = REQUIREMENT_WARNING;
+ }
+ }
+
+ // Test settings.php file writability
+ if ($phase == 'runtime') {
+ $conf_dir = drupal_verify_install_file(conf_path(), FILE_NOT_WRITABLE, 'dir');
+ $conf_file = drupal_verify_install_file(conf_path() . '/settings.php', FILE_EXIST|FILE_READABLE|FILE_NOT_WRITABLE);
+ if (!$conf_dir || !$conf_file) {
+ $requirements['settings.php'] = array(
+ 'value' => $t('Not protected'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => '',
+ );
+ if (!$conf_dir) {
+ $requirements['settings.php']['description'] .= $t('The directory %file is not protected from modifications and poses a security risk. You must change the directory\'s permissions to be non-writable. ', array('%file' => conf_path()));
+ }
+ if (!$conf_file) {
+ $requirements['settings.php']['description'] .= $t('The file %file is not protected from modifications and poses a security risk. You must change the file\'s permissions to be non-writable.', array('%file' => conf_path() . '/settings.php'));
+ }
+ }
+ else {
+ $requirements['settings.php'] = array(
+ 'value' => $t('Protected'),
+ );
+ }
+ $requirements['settings.php']['title'] = $t('Configuration file');
+ }
+
+ // Report cron status.
+ if ($phase == 'runtime') {
+ // Cron warning threshold defaults to two days.
+ $threshold_warning = variable_get('cron_threshold_warning', 172800);
+ // Cron error threshold defaults to two weeks.
+ $threshold_error = variable_get('cron_threshold_error', 1209600);
+ // Cron configuration help text.
+ $help = $t('For more information, see the online handbook entry for <a href="@cron-handbook">configuring cron jobs</a>.', array('@cron-handbook' => 'http://drupal.org/cron'));
+
+ // Determine when cron last ran.
+ $cron_last = variable_get('cron_last');
+ if (!is_numeric($cron_last)) {
+ $cron_last = variable_get('install_time', 0);
+ }
+
+ // Determine severity based on time since cron last ran.
+ $severity = REQUIREMENT_OK;
+ if (REQUEST_TIME - $cron_last > $threshold_error) {
+ $severity = REQUIREMENT_ERROR;
+ }
+ elseif (REQUEST_TIME - $cron_last > $threshold_warning) {
+ $severity = REQUIREMENT_WARNING;
+ }
+
+ // Set summary and description based on values determined above.
+ $summary = $t('Last run !time ago', array('!time' => format_interval(REQUEST_TIME - $cron_last)));
+ $description = '';
+ if ($severity != REQUIREMENT_OK) {
+ $description = $t('Cron has not run recently.') . ' ' . $help;
+ }
+
+ $description .= ' ' . $t('You can <a href="@cron">run cron manually</a>.', array('@cron' => url('admin/reports/status/run-cron')));
+ $description .= '<br />' . $t('To run cron from outside the site, go to <a href="!cron">!cron</a>', array('!cron' => url($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => variable_get('cron_key', 'drupal'))))));
+
+ $requirements['cron'] = array(
+ 'title' => $t('Cron maintenance tasks'),
+ 'severity' => $severity,
+ 'value' => $summary,
+ 'description' => $description
+ );
+ }
+
+ // Test files directories.
+ $directories = array(
+ variable_get('file_public_path', conf_path() . '/files'),
+ // By default no private files directory is configured. For private files
+ // to be secure the admin needs to provide a path outside the webroot.
+ variable_get('file_private_path', FALSE),
+ );
+
+ // Do not check for the temporary files directory at install time
+ // unless it has been set in settings.php. In this case the user has
+ // no alternative but to fix the directory if it is not writable.
+ if ($phase == 'install') {
+ $directories[] = variable_get('file_temporary_path', FALSE);
+ }
+ else {
+ $directories[] = variable_get('file_temporary_path', file_directory_temp());
+ }
+
+ $requirements['file system'] = array(
+ 'title' => $t('File system'),
+ );
+
+ $error = '';
+ // For installer, create the directories if possible.
+ foreach ($directories as $directory) {
+ if (!$directory) {
+ continue;
+ }
+ if ($phase == 'install') {
+ file_prepare_directory($directory, FILE_CREATE_DIRECTORY);
+ }
+ $is_writable = is_writable($directory);
+ $is_directory = is_dir($directory);
+ if (!$is_writable || !$is_directory) {
+ $description = '';
+ $requirements['file system']['value'] = $t('Not writable');
+ if (!$is_directory) {
+ $error .= $t('The directory %directory does not exist.', array('%directory' => $directory)) . ' ';
+ }
+ else {
+ $error .= $t('The directory %directory is not writable.', array('%directory' => $directory)) . ' ';
+ }
+ // The files directory requirement check is done only during install and runtime.
+ if ($phase == 'runtime') {
+ $description = $error . $t('You may need to set the correct directory at the <a href="@admin-file-system">file system settings page</a> or change the current directory\'s permissions so that it is writable.', array('@admin-file-system' => url('admin/config/media/file-system')));
+ }
+ elseif ($phase == 'install') {
+ // For the installer UI, we need different wording. 'value' will
+ // be treated as version, so provide none there.
+ $description = $error . $t('An automated attempt to create this directory failed, possibly due to a permissions problem. To proceed with the installation, either create the directory and modify its permissions manually or ensure that the installer has the permissions to create it automatically. For more information, see INSTALL.txt or the <a href="@handbook_url">online handbook</a>.', array('@handbook_url' => 'http://drupal.org/server-permissions'));
+ $requirements['file system']['value'] = '';
+ }
+ if (!empty($description)) {
+ $requirements['file system']['description'] = $description;
+ $requirements['file system']['severity'] = REQUIREMENT_ERROR;
+ }
+ }
+ else {
+ if (file_default_scheme() == 'public') {
+ $requirements['file system']['value'] = $t('Writable (<em>public</em> download method)');
+ }
+ else {
+ $requirements['file system']['value'] = $t('Writable (<em>private</em> download method)');
+ }
+ }
+ }
+
+ // See if updates are available in update.php.
+ if ($phase == 'runtime') {
+ $requirements['update'] = array(
+ 'title' => $t('Database updates'),
+ 'severity' => REQUIREMENT_OK,
+ 'value' => $t('Up to date'),
+ );
+
+ // Check installed modules.
+ foreach (module_list() as $module) {
+ $updates = drupal_get_schema_versions($module);
+ if ($updates !== FALSE) {
+ $default = drupal_get_installed_schema_version($module);
+ if (max($updates) > $default) {
+ $requirements['update']['severity'] = REQUIREMENT_ERROR;
+ $requirements['update']['value'] = $t('Out of date');
+ $requirements['update']['description'] = $t('Some modules have database schema updates to install. You should run the <a href="@update">database update script</a> immediately.', array('@update' => base_path() . 'core/update.php'));
+ break;
+ }
+ }
+ }
+ }
+
+ // Verify the update.php access setting
+ if ($phase == 'runtime') {
+ if (!empty($GLOBALS['update_free_access'])) {
+ $requirements['update access'] = array(
+ 'value' => $t('Not protected'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => $t('The update.php script is accessible to everyone without authentication check, which is a security risk. You must change the $update_free_access value in your settings.php back to FALSE.'),
+ );
+ }
+ else {
+ $requirements['update access'] = array(
+ 'value' => $t('Protected'),
+ );
+ }
+ $requirements['update access']['title'] = $t('Access to update.php');
+ }
+
+ // Display an error if a newly introduced dependency in a module is not resolved.
+ if ($phase == 'update') {
+ $profile = drupal_get_profile();
+ $files = system_rebuild_module_data();
+ foreach ($files as $module => $file) {
+ // Ignore disabled modules and install profiles.
+ if (!$file->status || $module == $profile) {
+ continue;
+ }
+ // Check the module's PHP version.
+ $name = $file->info['name'];
+ $php = $file->info['php'];
+ if (version_compare($php, PHP_VERSION, '>')) {
+ $requirements['php']['description'] .= $t('@name requires at least PHP @version.', array('@name' => $name, '@version' => $php));
+ $requirements['php']['severity'] = REQUIREMENT_ERROR;
+ }
+ // Check the module's required modules.
+ foreach ($file->requires as $requirement) {
+ $required_module = $requirement['name'];
+ // Check if the module exists.
+ if (!isset($files[$required_module])) {
+ $requirements["$module-$required_module"] = array(
+ 'title' => $t('Unresolved dependency'),
+ 'description' => $t('@name requires this module.', array('@name' => $name)),
+ 'value' => t('@required_name (Missing)', array('@required_name' => $required_module)),
+ 'severity' => REQUIREMENT_ERROR,
+ );
+ continue;
+ }
+ // Check for an incompatible version.
+ $required_file = $files[$required_module];
+ $required_name = $required_file->info['name'];
+ $version = str_replace(DRUPAL_CORE_COMPATIBILITY . '-', '', $required_file->info['version']);
+ $compatibility = drupal_check_incompatibility($requirement, $version);
+ if ($compatibility) {
+ $compatibility = rtrim(substr($compatibility, 2), ')');
+ $requirements["$module-$required_module"] = array(
+ 'title' => $t('Unresolved dependency'),
+ 'description' => $t('@name requires this module and version. Currently using @required_name version @version', array('@name' => $name, '@required_name' => $required_name, '@version' => $version)),
+ 'value' => t('@required_name (Version @compatibility required)', array('@required_name' => $required_name, '@compatibility' => $compatibility)),
+ 'severity' => REQUIREMENT_ERROR,
+ );
+ continue;
+ }
+ }
+ }
+ }
+
+ // Test Unicode library
+ include_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+ $requirements = array_merge($requirements, unicode_requirements());
+
+ if ($phase == 'runtime') {
+ // Check for update status module.
+ if (!module_exists('update')) {
+ $requirements['update status'] = array(
+ 'value' => $t('Not enabled'),
+ 'severity' => REQUIREMENT_WARNING,
+ 'description' => $t('Update notifications are not enabled. It is <strong>highly recommended</strong> that you enable the update status module from the <a href="@module">module administration page</a> in order to stay up-to-date on new releases. For more information, <a href="@update">Update status handbook page</a>.', array('@update' => 'http://drupal.org/handbook/modules/update', '@module' => url('admin/modules'))),
+ );
+ }
+ else {
+ $requirements['update status'] = array(
+ 'value' => $t('Enabled'),
+ );
+ }
+ $requirements['update status']['title'] = $t('Update notifications');
+
+ // Check that Drupal can issue HTTP requests.
+ if (variable_get('drupal_http_request_fails', TRUE) && !system_check_http_request()) {
+ $requirements['http requests'] = array(
+ 'title' => $t('HTTP request status'),
+ 'value' => $t('Fails'),
+ 'severity' => REQUIREMENT_ERROR,
+ 'description' => $t('Your system or network configuration does not allow Drupal to access web pages, resulting in reduced functionality. This could be due to your webserver configuration or PHP settings, and should be resolved in order to download information about available updates, fetch aggregator feeds, sign in via OpenID, or use other network-dependent services. If you are certain that Drupal can access web pages but you are still seeing this message, you may add <code>$conf[\'drupal_http_request_fails\'] = FALSE;</code> to the bottom of your settings.php file.'),
+ );
+ }
+ }
+
+ return $requirements;
+}
+
+/**
+ * Implements hook_install().
+ */
+function system_install() {
+ // Create tables.
+ drupal_install_schema('system');
+ $versions = drupal_get_schema_versions('system');
+ $version = $versions ? max($versions) : SCHEMA_INSTALLED;
+ drupal_set_installed_schema_version('system', $version);
+
+ // Clear out module list and hook implementation statics before calling
+ // system_rebuild_theme_data().
+ module_list(TRUE);
+ module_implements_reset();
+
+ // Load system theme data appropriately.
+ system_rebuild_theme_data();
+
+ // Enable the default theme.
+ variable_set('theme_default', 'bartik');
+ db_update('system')
+ ->fields(array('status' => 1))
+ ->condition('type', 'theme')
+ ->condition('name', 'bartik')
+ ->execute();
+
+ // Populate the cron key variable.
+ $cron_key = drupal_hash_base64(drupal_random_bytes(55));
+ variable_set('cron_key', $cron_key);
+}
+
+/**
+ * Implements hook_schema().
+ */
+function system_schema() {
+ // NOTE: {variable} needs to be created before all other tables, as
+ // some database drivers, e.g. Oracle and DB2, will require variable_get()
+ // and variable_set() for overcoming some database specific limitations.
+ $schema['variable'] = array(
+ 'description' => 'Named variable/value pairs created by Drupal core or any other module or theme. All variables are cached in memory at the start of every Drupal request so developers should not be careless about what is stored here.',
+ 'fields' => array(
+ 'name' => array(
+ 'description' => 'The name of the variable.',
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'value' => array(
+ 'description' => 'The value of the variable.',
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ 'translatable' => TRUE,
+ ),
+ ),
+ 'primary key' => array('name'),
+ );
+
+ $schema['actions'] = array(
+ 'description' => 'Stores action information.',
+ 'fields' => array(
+ 'aid' => array(
+ 'description' => 'Primary Key: Unique actions ID.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '0',
+ ),
+ 'type' => array(
+ 'description' => 'The object that that action acts on (node, user, comment, system or custom types.)',
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'callback' => array(
+ 'description' => 'The callback function that executes when the action runs.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'parameters' => array(
+ 'description' => 'Parameters to be passed to the callback function.',
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ 'size' => 'big',
+ ),
+ 'label' => array(
+ 'description' => 'Label of the action.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '0',
+ ),
+ ),
+ 'primary key' => array('aid'),
+ );
+
+ $schema['batch'] = array(
+ 'description' => 'Stores details about batches (processes that run in multiple HTTP requests).',
+ 'fields' => array(
+ 'bid' => array(
+ 'description' => 'Primary Key: Unique batch ID.',
+ // This is not a serial column, to allow both progressive and
+ // non-progressive batches. See batch_process().
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'token' => array(
+ 'description' => "A string token generated against the current user's session id and the batch id, used to ensure that only the user who submitted the batch can effectively access it.",
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'timestamp' => array(
+ 'description' => 'A Unix timestamp indicating when this batch was submitted for processing. Stale batches are purged at cron time.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ ),
+ 'batch' => array(
+ 'description' => 'A serialized array containing the processing data for the batch.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ ),
+ ),
+ 'primary key' => array('bid'),
+ 'indexes' => array(
+ 'token' => array('token'),
+ ),
+ );
+
+ $schema['blocked_ips'] = array(
+ 'description' => 'Stores blocked IP addresses.',
+ 'fields' => array(
+ 'iid' => array(
+ 'description' => 'Primary Key: unique ID for IP addresses.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'ip' => array(
+ 'description' => 'IP address',
+ 'type' => 'varchar',
+ 'length' => 40,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'indexes' => array(
+ 'blocked_ip' => array('ip'),
+ ),
+ 'primary key' => array('iid'),
+ );
+
+ $schema['cache'] = array(
+ 'description' => 'Generic cache table for caching things not separated out into their own tables. Contributed modules may also use this to store cached items.',
+ 'fields' => array(
+ 'cid' => array(
+ 'description' => 'Primary Key: Unique cache ID.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'data' => array(
+ 'description' => 'A collection of data to cache.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ ),
+ 'expire' => array(
+ 'description' => 'A Unix timestamp indicating when the cache entry should expire, or 0 for never.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'created' => array(
+ 'description' => 'A Unix timestamp indicating when the cache entry was created.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'serialized' => array(
+ 'description' => 'A flag to indicate whether content is serialized (1) or not (0).',
+ 'type' => 'int',
+ 'size' => 'small',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'expire' => array('expire'),
+ ),
+ 'primary key' => array('cid'),
+ );
+ $schema['cache_bootstrap'] = $schema['cache'];
+ $schema['cache_bootstrap']['description'] = 'Cache table for data required to bootstrap Drupal, may be routed to a shared memory cache.';
+ $schema['cache_form'] = $schema['cache'];
+ $schema['cache_form']['description'] = 'Cache table for the form system to store recently built forms and their storage data, to be used in subsequent page requests.';
+ $schema['cache_page'] = $schema['cache'];
+ $schema['cache_page']['description'] = 'Cache table used to store compressed pages for anonymous users, if page caching is enabled.';
+ $schema['cache_menu'] = $schema['cache'];
+ $schema['cache_menu']['description'] = 'Cache table for the menu system to store router information as well as generated link trees for various menu/page/user combinations.';
+ $schema['cache_path'] = $schema['cache'];
+ $schema['cache_path']['description'] = 'Cache table for path alias lookup.';
+
+ $schema['date_format_type'] = array(
+ 'description' => 'Stores configured date format types.',
+ 'fields' => array(
+ 'type' => array(
+ 'description' => 'The date format type, e.g. medium.',
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'title' => array(
+ 'description' => 'The human readable name of the format type.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'locked' => array(
+ 'description' => 'Whether or not this is a system provided format.',
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'default' => 0,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('type'),
+ 'indexes' => array(
+ 'title' => array('title'),
+ ),
+ );
+
+ // This table's name is plural as some versions of MySQL can't create a
+ // table named 'date_format'.
+ $schema['date_formats'] = array(
+ 'description' => 'Stores configured date formats.',
+ 'fields' => array(
+ 'dfid' => array(
+ 'description' => 'The date format identifier.',
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ 'unsigned' => TRUE,
+ ),
+ 'format' => array(
+ 'description' => 'The date format string.',
+ 'type' => 'varchar',
+ 'length' => 100,
+ 'not null' => TRUE,
+ ),
+ 'type' => array(
+ 'description' => 'The date format type, e.g. medium.',
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'locked' => array(
+ 'description' => 'Whether or not this format can be modified.',
+ 'type' => 'int',
+ 'size' => 'tiny',
+ 'default' => 0,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('dfid'),
+ 'unique keys' => array('formats' => array('format', 'type')),
+ );
+
+ $schema['date_format_locale'] = array(
+ 'description' => 'Stores configured date formats for each locale.',
+ 'fields' => array(
+ 'format' => array(
+ 'description' => 'The date format string.',
+ 'type' => 'varchar',
+ 'length' => 100,
+ 'not null' => TRUE,
+ ),
+ 'type' => array(
+ 'description' => 'The date format type, e.g. medium.',
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ 'language' => array(
+ 'description' => 'A {languages}.language for this format to be used with.',
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('type', 'language'),
+ );
+
+ $schema['file_managed'] = array(
+ 'description' => 'Stores information for uploaded files.',
+ 'fields' => array(
+ 'fid' => array(
+ 'description' => 'File ID.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'uid' => array(
+ 'description' => 'The {users}.uid of the user who is associated with the file.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'filename' => array(
+ 'description' => 'Name of the file with no path components. This may differ from the basename of the URI if the file is renamed to avoid overwriting an existing file.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'uri' => array(
+ 'description' => 'The URI to access the file (either local or remote).',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'filemime' => array(
+ 'description' => "The file's MIME type.",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'filesize' => array(
+ 'description' => 'The size of the file in bytes.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'status' => array(
+ 'description' => 'A field indicating the status of the file. Two status are defined in core: temporary (0) and permanent (1). Temporary files older than DRUPAL_MAXIMUM_TEMP_FILE_AGE will be removed during a cron run.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'timestamp' => array(
+ 'description' => 'UNIX timestamp for when the file was added.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'uid' => array('uid'),
+ 'status' => array('status'),
+ 'timestamp' => array('timestamp'),
+ ),
+ 'unique keys' => array(
+ 'uri' => array('uri'),
+ ),
+ 'primary key' => array('fid'),
+ 'foreign keys' => array(
+ 'file_owner' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['file_usage'] = array(
+ 'description' => 'Track where a file is used.',
+ 'fields' => array(
+ 'fid' => array(
+ 'description' => 'File ID.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'module' => array(
+ 'description' => 'The name of the module that is using the file.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'type' => array(
+ 'description' => 'The name of the object type in which the file is used.',
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'id' => array(
+ 'description' => 'The primary key of the object using the file.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'count' => array(
+ 'description' => 'The number of times this file is used by this object.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('fid', 'type', 'id', 'module'),
+ 'indexes' => array(
+ 'type_id' => array('type', 'id'),
+ 'fid_count' => array('fid', 'count'),
+ 'fid_module' => array('fid', 'module'),
+ ),
+ );
+
+ $schema['flood'] = array(
+ 'description' => 'Flood controls the threshold of events, such as the number of contact attempts.',
+ 'fields' => array(
+ 'fid' => array(
+ 'description' => 'Unique flood event ID.',
+ 'type' => 'serial',
+ 'not null' => TRUE,
+ ),
+ 'event' => array(
+ 'description' => 'Name of event (e.g. contact).',
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'identifier' => array(
+ 'description' => 'Identifier of the visitor, such as an IP address or hostname.',
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'timestamp' => array(
+ 'description' => 'Timestamp of the event.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'expiration' => array(
+ 'description' => 'Expiration timestamp. Expired events are purged on cron run.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('fid'),
+ 'indexes' => array(
+ 'allow' => array('event', 'identifier', 'timestamp'),
+ 'purge' => array('expiration'),
+ ),
+ );
+
+ $schema['menu_router'] = array(
+ 'description' => 'Maps paths to various callbacks (access, page and title)',
+ 'fields' => array(
+ 'path' => array(
+ 'description' => 'Primary Key: the Drupal path this entry describes',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'load_functions' => array(
+ 'description' => 'A serialized array of function names (like node_load) to be called to load an object corresponding to a part of the current path.',
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ ),
+ 'to_arg_functions' => array(
+ 'description' => 'A serialized array of function names (like user_uid_optional_to_arg) to be called to replace a part of the router path with another string.',
+ 'type' => 'blob',
+ 'not null' => TRUE,
+ ),
+ 'access_callback' => array(
+ 'description' => 'The callback which determines the access to this router path. Defaults to user_access.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'access_arguments' => array(
+ 'description' => 'A serialized array of arguments for the access callback.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ ),
+ 'page_callback' => array(
+ 'description' => 'The name of the function that renders the page.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'page_arguments' => array(
+ 'description' => 'A serialized array of arguments for the page callback.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ ),
+ 'delivery_callback' => array(
+ 'description' => 'The name of the function that sends the result of the page_callback function to the browser.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'fit' => array(
+ 'description' => 'A numeric representation of how specific the path is.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'number_parts' => array(
+ 'description' => 'Number of parts in this router path.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'context' => array(
+ 'description' => 'Only for local tasks (tabs) - the context of a local task to control its placement.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'tab_parent' => array(
+ 'description' => 'Only for local tasks (tabs) - the router path of the parent page (which may also be a local task).',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'tab_root' => array(
+ 'description' => 'Router path of the closest non-tab parent page. For pages that are not local tasks, this will be the same as the path.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'title' => array(
+ 'description' => 'The title for the current page, or the title for the tab if this is a local task.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'title_callback' => array(
+ 'description' => 'A function which will alter the title. Defaults to t()',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'title_arguments' => array(
+ 'description' => 'A serialized array of arguments for the title callback. If empty, the title will be used as the sole argument for the title callback.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'theme_callback' => array(
+ 'description' => 'A function which returns the name of the theme that will be used to render this page. If left empty, the default theme will be used.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'theme_arguments' => array(
+ 'description' => 'A serialized array of arguments for the theme callback.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'type' => array(
+ 'description' => 'Numeric representation of the type of the menu item, like MENU_LOCAL_TASK.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'description' => array(
+ 'description' => 'A description of this item.',
+ 'type' => 'text',
+ 'not null' => TRUE,
+ ),
+ 'position' => array(
+ 'description' => 'The position of the block (left or right) on the system administration page for this item.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'weight' => array(
+ 'description' => 'Weight of the element. Lighter weights are higher up, heavier weights go down.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'include_file' => array(
+ 'description' => 'The file to include for this element, usually the page callback function lives in this file.',
+ 'type' => 'text',
+ 'size' => 'medium',
+ ),
+ ),
+ 'indexes' => array(
+ 'fit' => array('fit'),
+ 'tab_parent' => array(array('tab_parent', 64), 'weight', 'title'),
+ 'tab_root_weight_title' => array(array('tab_root', 64), 'weight', 'title'),
+ ),
+ 'primary key' => array('path'),
+ );
+
+ $schema['menu_links'] = array(
+ 'description' => 'Contains the individual links within a menu.',
+ 'fields' => array(
+ 'menu_name' => array(
+ 'description' => "The menu name. All links with the same menu name (such as 'navigation') are part of the same menu.",
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'mlid' => array(
+ 'description' => 'The menu link ID (mlid) is the integer primary key.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'plid' => array(
+ 'description' => 'The parent link ID (plid) is the mlid of the link above in the hierarchy, or zero if the link is at the top level in its menu.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'link_path' => array(
+ 'description' => 'The Drupal path or external path this link points to.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'router_path' => array(
+ 'description' => 'For links corresponding to a Drupal path (external = 0), this connects the link to a {menu_router}.path for joins.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'link_title' => array(
+ 'description' => 'The text displayed for the link, which may be modified by a title callback stored in {menu_router}.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'translatable' => TRUE,
+ ),
+ 'options' => array(
+ 'description' => 'A serialized array of options to be passed to the url() or l() function, such as a query string or HTML attributes.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'translatable' => TRUE,
+ ),
+ 'module' => array(
+ 'description' => 'The name of the module that generated this link.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => 'system',
+ ),
+ 'hidden' => array(
+ 'description' => 'A flag for whether the link should be rendered in menus. (1 = a disabled menu item that may be shown on admin screens, -1 = a menu callback, 0 = a normal, visible link)',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'external' => array(
+ 'description' => 'A flag to indicate if the link points to a full URL starting with a protocol, like http:// (1 = external, 0 = internal).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'has_children' => array(
+ 'description' => 'Flag indicating whether any links have this link as a parent (1 = children exist, 0 = no children).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'expanded' => array(
+ 'description' => 'Flag for whether this link should be rendered as expanded in menus - expanded links always have their child links displayed, instead of only when the link is in the active trail (1 = expanded, 0 = not expanded)',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'weight' => array(
+ 'description' => 'Link weight among links in the same menu at the same depth.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'depth' => array(
+ 'description' => 'The depth relative to the top level. A link with plid == 0 will have depth == 1.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'customized' => array(
+ 'description' => 'A flag to indicate that the user has manually created or edited the link (1 = customized, 0 = not customized).',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ 'p1' => array(
+ 'description' => 'The first mlid in the materialized path. If N = depth, then pN must equal the mlid. If depth > 1 then p(N-1) must equal the plid. All pX where X > depth must equal zero. The columns p1 .. p9 are also called the parents.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p2' => array(
+ 'description' => 'The second mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p3' => array(
+ 'description' => 'The third mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p4' => array(
+ 'description' => 'The fourth mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p5' => array(
+ 'description' => 'The fifth mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p6' => array(
+ 'description' => 'The sixth mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p7' => array(
+ 'description' => 'The seventh mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p8' => array(
+ 'description' => 'The eighth mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'p9' => array(
+ 'description' => 'The ninth mlid in the materialized path. See p1.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'updated' => array(
+ 'description' => 'Flag that indicates that this link was generated during the update from Drupal 5.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'small',
+ ),
+ ),
+ 'indexes' => array(
+ 'path_menu' => array(array('link_path', 128), 'menu_name'),
+ 'menu_plid_expand_child' => array('menu_name', 'plid', 'expanded', 'has_children'),
+ 'menu_parents' => array('menu_name', 'p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8', 'p9'),
+ 'router_path' => array(array('router_path', 128)),
+ ),
+ 'primary key' => array('mlid'),
+ );
+
+ $schema['queue'] = array(
+ 'description' => 'Stores items in queues.',
+ 'fields' => array(
+ 'item_id' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique item ID.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The queue name.',
+ ),
+ 'data' => array(
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'serialize' => TRUE,
+ 'description' => 'The arbitrary data for the item.',
+ ),
+ 'expire' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Timestamp when the claim lease expires on the item.',
+ ),
+ 'created' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Timestamp when the item was created.',
+ ),
+ ),
+ 'primary key' => array('item_id'),
+ 'indexes' => array(
+ 'name_created' => array('name', 'created'),
+ 'expire' => array('expire'),
+ ),
+ );
+
+ $schema['registry'] = array(
+ 'description' => "Each record is a function, class, or interface name and the file it is in.",
+ 'fields' => array(
+ 'name' => array(
+ 'description' => 'The name of the function, class, or interface.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'type' => array(
+ 'description' => 'Either function or class or interface.',
+ 'type' => 'varchar',
+ 'length' => 9,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'filename' => array(
+ 'description' => 'Name of the file.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'module' => array(
+ 'description' => 'Name of the module the file belongs to.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'weight' => array(
+ 'description' => "The order in which this module's hooks should be invoked relative to other modules. Equal-weighted modules are ordered by name.",
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'primary key' => array('name', 'type'),
+ 'indexes' => array(
+ 'hook' => array('type', 'weight', 'module'),
+ ),
+ );
+
+ $schema['registry_file'] = array(
+ 'description' => "Files parsed to build the registry.",
+ 'fields' => array(
+ 'filename' => array(
+ 'description' => 'Path to the file.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ ),
+ 'hash' => array(
+ 'description' => "sha-256 hash of the file's contents when last parsed.",
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('filename'),
+ );
+
+ $schema['semaphore'] = array(
+ 'description' => 'Table for holding semaphores, locks, flags, etc. that cannot be stored as Drupal variables since they must not be cached.',
+ 'fields' => array(
+ 'name' => array(
+ 'description' => 'Primary Key: Unique name.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'value' => array(
+ 'description' => 'A value for the semaphore.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => ''
+ ),
+ 'expire' => array(
+ 'description' => 'A Unix timestamp with microseconds indicating when the semaphore should expire.',
+ 'type' => 'float',
+ 'size' => 'big',
+ 'not null' => TRUE
+ ),
+ ),
+ 'indexes' => array(
+ 'value' => array('value'),
+ 'expire' => array('expire'),
+ ),
+ 'primary key' => array('name'),
+ );
+
+ $schema['sequences'] = array(
+ 'description' => 'Stores IDs.',
+ 'fields' => array(
+ 'value' => array(
+ 'description' => 'The value of the sequence.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ ),
+ 'primary key' => array('value'),
+ );
+
+ $schema['sessions'] = array(
+ 'description' => "Drupal's session handlers read and write into the sessions table. Each record represents a user session, either anonymous or authenticated.",
+ 'fields' => array(
+ 'uid' => array(
+ 'description' => 'The {users}.uid corresponding to a session, or 0 for anonymous user.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'sid' => array(
+ 'description' => "A session ID. The value is generated by Drupal's session handlers.",
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ ),
+ 'ssid' => array(
+ 'description' => "Secure session ID. The value is generated by Drupal's session handlers.",
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'hostname' => array(
+ 'description' => 'The IP address that last used this session ID (sid).',
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'timestamp' => array(
+ 'description' => 'The Unix timestamp when this session last requested a page. Old records are purged by PHP automatically.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'cache' => array(
+ 'description' => "The time of this user's last post. This is used when the site has specified a minimum_cache_lifetime. See cache_get().",
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'session' => array(
+ 'description' => 'The serialized contents of $_SESSION, an array of name/value pairs that persists across page requests by this session ID. Drupal loads $_SESSION from here at the start of each request and saves it at the end.',
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ ),
+ ),
+ 'primary key' => array(
+ 'sid',
+ 'ssid',
+ ),
+ 'indexes' => array(
+ 'timestamp' => array('timestamp'),
+ 'uid' => array('uid'),
+ 'ssid' => array('ssid'),
+ ),
+ 'foreign keys' => array(
+ 'session_user' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['system'] = array(
+ 'description' => "A list of all modules, themes, and theme engines that are or have been installed in Drupal's file system.",
+ 'fields' => array(
+ 'filename' => array(
+ 'description' => 'The path of the primary file for this item, relative to the Drupal root; e.g. modules/node/node.module.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'name' => array(
+ 'description' => 'The name of the item; e.g. node.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'type' => array(
+ 'description' => 'The type of the item, either module, theme, or theme_engine.',
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'owner' => array(
+ 'description' => "A theme's 'parent' . Can be either a theme or an engine.",
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'status' => array(
+ 'description' => 'Boolean indicating whether or not this item is enabled.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'bootstrap' => array(
+ 'description' => "Boolean indicating whether this module is loaded during Drupal's early bootstrapping phase (e.g. even before the page cache is consulted).",
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'schema_version' => array(
+ 'description' => "The module's database schema version number. -1 if the module is not installed (its tables do not exist); 0 or the largest N of the module's hook_update_N() function that has either been run or existed when the module was first installed.",
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => -1,
+ 'size' => 'small',
+ ),
+ 'weight' => array(
+ 'description' => "The order in which this module's hooks should be invoked relative to other modules. Equal-weighted modules are ordered by name.",
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'info' => array(
+ 'description' => "A serialized array containing information from the module's .info file; keys can include name, description, package, version, core, dependencies, and php.",
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ ),
+ ),
+ 'primary key' => array('filename'),
+ 'indexes' => array(
+ 'system_list' => array('status', 'bootstrap', 'type', 'weight', 'name'),
+ 'type_name' => array('type', 'name'),
+ ),
+ );
+
+ $schema['url_alias'] = array(
+ 'description' => 'A list of URL aliases for Drupal paths; a user may visit either the source or destination path.',
+ 'fields' => array(
+ 'pid' => array(
+ 'description' => 'A unique path alias identifier.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'source' => array(
+ 'description' => 'The Drupal path this alias is for; e.g. node/12.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'alias' => array(
+ 'description' => 'The alias for this path; e.g. title-of-the-story.',
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ 'language' => array(
+ 'description' => "The language this alias is for; if 'und', the alias will be used for unknown languages. Each Drupal path can have an alias for each supported language.",
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ ),
+ ),
+ 'primary key' => array('pid'),
+ 'indexes' => array(
+ 'alias_language_pid' => array('alias', 'language', 'pid'),
+ 'source_language_pid' => array('source', 'language', 'pid'),
+ ),
+ );
+
+ return $schema;
+}
+
+// Updates for core.
+
+function system_update_last_removed() {
+ return 7069;
+}
+
+/**
+ * @defgroup updates-7.x-to-8.x Updates from 7.x to 8.x
+ * @{
+ * Update functions from 7.x to 8.x.
+ */
+
+/**
+ * Enable entity module.
+ */
+function system_update_8000() {
+ update_module_enable(array('entity'));
+}
+
+/**
+ * Move from the Garland theme.
+ */
+function system_update_8001() {
+ $themes = array('theme_default', 'maintenance_theme', 'admin_theme');
+ foreach ($themes as $theme) {
+ if (variable_get($theme) == 'garland') {
+ variable_set($theme, 'bartik');
+ }
+ }
+}
+
+/**
+ * @} End of "defgroup updates-7.x-to-8.x"
+ * The next series of updates should start at 9000.
+ */
diff --git a/core/modules/system/system.js b/core/modules/system/system.js
new file mode 100644
index 000000000000..5446d28a370a
--- /dev/null
+++ b/core/modules/system/system.js
@@ -0,0 +1,137 @@
+(function ($) {
+
+/**
+ * Show/hide the 'Email site administrator when updates are available' checkbox
+ * on the install page.
+ */
+Drupal.hideEmailAdministratorCheckbox = function () {
+ // Make sure the secondary box is shown / hidden as necessary on page load.
+ if ($('#edit-update-status-module-1').is(':checked')) {
+ $('.form-item-update-status-module-2').show();
+ }
+ else {
+ $('.form-item-update-status-module-2').hide();
+ }
+
+ // Toggle the display as necessary when the checkbox is clicked.
+ $('#edit-update-status-module-1').change( function () {
+ $('.form-item-update-status-module-2').toggle();
+ });
+};
+
+/**
+ * Internal function to check using Ajax if clean URLs can be enabled on the
+ * settings page.
+ *
+ * This function is not used to verify whether or not clean URLs
+ * are currently enabled.
+ */
+Drupal.behaviors.cleanURLsSettingsCheck = {
+ attach: function (context, settings) {
+ // This behavior attaches by ID, so is only valid once on a page.
+ // Also skip if we are on an install page, as Drupal.cleanURLsInstallCheck will handle
+ // the processing.
+ if (!($('#edit-clean-url').length) || $('#edit-clean-url.install').once('clean-url').length) {
+ return;
+ }
+ var url = settings.basePath + 'admin/config/search/clean-urls/check';
+ $.ajax({
+ url: location.protocol + '//' + location.host + url,
+ dataType: 'json',
+ success: function () {
+ // Check was successful. Redirect using a "clean URL". This will force the form that allows enabling clean URLs.
+ location = settings.basePath +"admin/config/search/clean-urls";
+ }
+ });
+ }
+};
+
+/**
+ * Internal function to check using Ajax if clean URLs can be enabled on the
+ * install page.
+ *
+ * This function is not used to verify whether or not clean URLs
+ * are currently enabled.
+ */
+Drupal.cleanURLsInstallCheck = function () {
+ var url = location.protocol + '//' + location.host + Drupal.settings.basePath + 'admin/config/search/clean-urls/check';
+ // Submit a synchronous request to avoid database errors associated with
+ // concurrent requests during install.
+ $.ajax({
+ async: false,
+ url: url,
+ dataType: 'json',
+ success: function () {
+ // Check was successful.
+ $('#edit-clean-url').attr('value', 1);
+ }
+ });
+};
+
+/**
+ * When a field is filled out, apply its value to other fields that will likely
+ * use the same value. In the installer this is used to populate the
+ * administrator e-mail address with the same value as the site e-mail address.
+ */
+Drupal.behaviors.copyFieldValue = {
+ attach: function (context, settings) {
+ for (var sourceId in settings.copyFieldValue) {
+ $('#' + sourceId, context).once('copy-field-values').bind('blur', function () {
+ // Get the list of target fields.
+ var targetIds = settings.copyFieldValue[sourceId];
+ // Add the behavior to update target fields on blur of the primary field.
+ for (var delta in targetIds) {
+ var targetField = $('#' + targetIds[delta]);
+ if (targetField.val() == '') {
+ targetField.val(this.value);
+ }
+ }
+ });
+ }
+ }
+};
+
+/**
+ * Show/hide custom format sections on the regional settings page.
+ */
+Drupal.behaviors.dateTime = {
+ attach: function (context, settings) {
+ for (var value in settings.dateTime) {
+ var settings = settings.dateTime[value];
+ var source = '#edit-' + value;
+ var suffix = source + '-suffix';
+
+ // Attach keyup handler to custom format inputs.
+ $('input' + source, context).once('date-time').keyup(function () {
+ var input = $(this);
+ var url = settings.lookup + (settings.lookup.match(/\?q=/) ? '&format=' : '?format=') + encodeURIComponent(input.val());
+ $.getJSON(url, function (data) {
+ $(suffix).empty().append(' ' + settings.text + ': <em>' + data + '</em>');
+ });
+ });
+ }
+ }
+};
+
+ /**
+ * Show/hide settings for page caching depending on whether page caching is
+ * enabled or not.
+ */
+Drupal.behaviors.pageCache = {
+ attach: function (context, settings) {
+ $('#edit-cache-0', context).change(function () {
+ $('#page-compression-wrapper').hide();
+ $('#cache-error').hide();
+ });
+ $('#edit-cache-1', context).change(function () {
+ $('#page-compression-wrapper').show();
+ $('#cache-error').hide();
+ });
+ $('#edit-cache-2', context).change(function () {
+ $('#page-compression-wrapper').show();
+ $('#cache-error').show();
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/system/system.mail.inc b/core/modules/system/system.mail.inc
new file mode 100644
index 000000000000..ef50642c55a9
--- /dev/null
+++ b/core/modules/system/system.mail.inc
@@ -0,0 +1,112 @@
+<?php
+
+/**
+ * @file
+ * Drupal core implementations of MailSystemInterface.
+ */
+
+/**
+ * The default Drupal mail backend using PHP's mail function.
+ */
+class DefaultMailSystem implements MailSystemInterface {
+ /**
+ * Concatenate and wrap the e-mail body for plain-text mails.
+ *
+ * @param $message
+ * A message array, as described in hook_mail_alter().
+ *
+ * @return
+ * The formatted $message.
+ */
+ public function format(array $message) {
+ // Join the body array into one string.
+ $message['body'] = implode("\n\n", $message['body']);
+ // Convert any HTML to plain-text.
+ $message['body'] = drupal_html_to_text($message['body']);
+ // Wrap the mail body for sending.
+ $message['body'] = drupal_wrap_mail($message['body']);
+ return $message;
+ }
+
+ /**
+ * Send an e-mail message, using Drupal variables and default settings.
+ *
+ * @see http://php.net/manual/en/function.mail.php
+ * @see drupal_mail()
+ *
+ * @param $message
+ * A message array, as described in hook_mail_alter().
+ * @return
+ * TRUE if the mail was successfully accepted, otherwise FALSE.
+ */
+ public function mail(array $message) {
+ // If 'Return-Path' isn't already set in php.ini, we pass it separately
+ // as an additional parameter instead of in the header.
+ // However, if PHP's 'safe_mode' is on, this is not allowed.
+ if (isset($message['headers']['Return-Path']) && !ini_get('safe_mode')) {
+ $return_path_set = strpos(ini_get('sendmail_path'), ' -f');
+ if (!$return_path_set) {
+ $message['Return-Path'] = $message['headers']['Return-Path'];
+ unset($message['headers']['Return-Path']);
+ }
+ }
+ $mimeheaders = array();
+ foreach ($message['headers'] as $name => $value) {
+ $mimeheaders[] = $name . ': ' . mime_header_encode($value);
+ }
+ $line_endings = variable_get('mail_line_endings', MAIL_LINE_ENDINGS);
+ // Prepare mail commands.
+ $mail_subject = mime_header_encode($message['subject']);
+ // Note: e-mail uses CRLF for line-endings. PHP's API requires LF
+ // on Unix and CRLF on Windows. Drupal automatically guesses the
+ // line-ending format appropriate for your system. If you need to
+ // override this, adjust $conf['mail_line_endings'] in settings.php.
+ $mail_body = preg_replace('@\r?\n@', $line_endings, $message['body']);
+ // For headers, PHP's API suggests that we use CRLF normally,
+ // but some MTAs incorrectly replace LF with CRLF. See #234403.
+ $mail_headers = join("\n", $mimeheaders);
+ if (isset($message['Return-Path']) && !ini_get('safe_mode')) {
+ $mail_result = mail(
+ $message['to'],
+ $mail_subject,
+ $mail_body,
+ $mail_headers,
+ // Pass the Return-Path via sendmail's -f command.
+ '-f ' . $message['Return-Path']
+ );
+ }
+ else {
+ // The optional $additional_parameters argument to mail() is not allowed
+ // if safe_mode is enabled. Passing any value throws a PHP warning and
+ // makes mail() return FALSE.
+ $mail_result = mail(
+ $message['to'],
+ $mail_subject,
+ $mail_body,
+ $mail_headers
+ );
+ }
+ return $mail_result;
+ }
+}
+
+/**
+ * A mail sending implementation that captures sent messages to a variable.
+ *
+ * This class is for running tests or for development.
+ */
+class TestingMailSystem extends DefaultMailSystem implements MailSystemInterface {
+ /**
+ * Accept an e-mail message and store it in a variable.
+ *
+ * @param $message
+ * An e-mail message.
+ */
+ public function mail(array $message) {
+ $captured_emails = variable_get('drupal_test_email_collector', array());
+ $captured_emails[] = $message;
+ variable_set('drupal_test_email_collector', $captured_emails);
+ return TRUE;
+ }
+}
+
diff --git a/core/modules/system/system.maintenance.css b/core/modules/system/system.maintenance.css
new file mode 100644
index 000000000000..5543c2db816e
--- /dev/null
+++ b/core/modules/system/system.maintenance.css
@@ -0,0 +1,55 @@
+
+/**
+ * Update styles
+ */
+#update-results {
+ margin-top: 3em;
+ padding: 0.25em;
+ border: 1px solid #ccc;
+ background: #eee;
+ font-size: smaller;
+}
+#update-results h2 {
+ margin-top: 0.25em;
+}
+#update-results h4 {
+ margin-bottom: 0.25em;
+}
+#update-results li.none {
+ color: #888;
+ font-style: italic;
+}
+#update-results li.failure strong {
+ color: #b63300;
+}
+
+/**
+ * Authorize.php styles
+ */
+.connection-settings-update-filetransfer-default-wrapper {
+ float: left;
+}
+#edit-submit-connection {
+ clear: both;
+}
+.filetransfer {
+ display: none;
+ clear: both;
+}
+#edit-connection-settings-change-connection-type {
+ margin: 2.6em 0.5em 0em 1em;
+}
+
+/**
+ * Installation task list
+ */
+ol.task-list li.active {
+ font-weight: bold;
+}
+
+/**
+ * Installation clean URLs
+ */
+#clean-url.install {
+ display: none;
+}
diff --git a/core/modules/system/system.menus-rtl.css b/core/modules/system/system.menus-rtl.css
new file mode 100644
index 000000000000..be85245b2bf4
--- /dev/null
+++ b/core/modules/system/system.menus-rtl.css
@@ -0,0 +1,37 @@
+
+/**
+ * @file
+ * RTL styles for menus and navigation markup.
+ */
+
+ul.menu {
+ text-align:right;
+}
+ul.menu li {
+ margin: 0 0.5em 0 0;
+}
+ul li.collapsed {
+ list-style-image: url(../../misc/menu-collapsed-rtl.png);
+}
+li.expanded,
+li.collapsed,
+li.leaf {
+ padding: 0.2em 0 0 0.5em;
+}
+
+/**
+ * Markup generated by theme_menu_local_tasks().
+ */
+ul.primary {
+ padding: 0 1em 0 0;
+}
+ul.primary li a {
+ margin-right: 5px;
+ margin-left: 0.5em;
+}
+ul.secondary li {
+ border-left: 1px solid #ccc;
+ border-right: none;
+ display: inline;
+ padding: 0 1em;
+}
diff --git a/core/modules/system/system.menus.css b/core/modules/system/system.menus.css
new file mode 100644
index 000000000000..514b029527a7
--- /dev/null
+++ b/core/modules/system/system.menus.css
@@ -0,0 +1,116 @@
+
+/**
+ * @file
+ * Styles for menus and navigation markup.
+ */
+
+/**
+ * Markup generated by theme_menu_tree().
+ */
+ul.menu {
+ border: none;
+ list-style: none;
+ text-align: left; /* LTR */
+}
+ul.menu li {
+ margin: 0 0 0 0.5em; /* LTR */
+}
+ul li.expanded {
+ list-style-image: url(../../misc/menu-expanded.png);
+ list-style-type: circle;
+}
+ul li.collapsed {
+ list-style-image: url(../../misc/menu-collapsed.png); /* LTR */
+ list-style-type: disc;
+}
+ul li.leaf {
+ list-style-image: url(../../misc/menu-leaf.png);
+ list-style-type: square;
+}
+li.expanded,
+li.collapsed,
+li.leaf {
+ padding: 0.2em 0.5em 0 0; /* LTR */
+ margin: 0;
+}
+li a.active {
+ color: #000;
+}
+td.menu-disabled {
+ background: #ccc;
+}
+
+/**
+ * Markup generated by theme_links().
+ */
+ul.inline,
+ul.links.inline {
+ display: inline;
+ padding-left: 0;
+}
+ul.inline li {
+ display: inline;
+ list-style-type: none;
+ padding: 0 0.5em;
+}
+
+/**
+ * Markup generated by theme_breadcrumb().
+ */
+.breadcrumb {
+ padding-bottom: 0.5em;
+}
+
+/**
+ * Markup generated by theme_menu_local_tasks().
+ */
+ul.primary {
+ border-bottom: 1px solid #bbb;
+ border-collapse: collapse;
+ height: auto;
+ line-height: normal;
+ list-style: none;
+ margin: 5px;
+ padding: 0 0 0 1em; /* LTR */
+ white-space: nowrap;
+}
+ul.primary li {
+ display: inline;
+}
+ul.primary li a {
+ background-color: #ddd;
+ border-color: #bbb;
+ border-style: solid solid none solid;
+ border-width: 1px;
+ height: auto;
+ margin-right: 0.5em; /* LTR */
+ padding: 0 1em;
+ text-decoration: none;
+}
+ul.primary li.active a {
+ background-color: #fff;
+ border: 1px solid #bbb;
+ border-bottom: 1px solid #fff;
+}
+ul.primary li a:hover {
+ background-color: #eee;
+ border-color: #ccc;
+ border-bottom-color: #eee;
+}
+ul.secondary {
+ border-bottom: 1px solid #bbb;
+ padding: 0.5em 1em;
+ margin: 5px;
+}
+ul.secondary li {
+ border-right: 1px solid #ccc; /* LTR */
+ display: inline;
+ padding: 0 1em;
+}
+ul.secondary a {
+ padding: 0;
+ text-decoration: none;
+}
+ul.secondary a.active {
+ border-bottom: 4px solid #999;
+}
diff --git a/core/modules/system/system.messages-rtl.css b/core/modules/system/system.messages-rtl.css
new file mode 100644
index 000000000000..445417b08c46
--- /dev/null
+++ b/core/modules/system/system.messages-rtl.css
@@ -0,0 +1,13 @@
+
+/**
+ * @file
+ * RTL Styles for system messages.
+ */
+
+div.messages {
+ background-position: 99% 8px;
+ padding: 10px 50px 10px 10px;
+}
+div.messages ul {
+ margin: 0 1em 0 0;
+}
diff --git a/core/modules/system/system.messages.css b/core/modules/system/system.messages.css
new file mode 100644
index 000000000000..ffd4e8efcaaf
--- /dev/null
+++ b/core/modules/system/system.messages.css
@@ -0,0 +1,63 @@
+
+/**
+ * @file
+ * Styles for system messages.
+ */
+
+div.messages {
+ background-position: 8px 8px; /* LTR */
+ background-repeat: no-repeat;
+ border: 1px solid;
+ margin: 6px 0;
+ padding: 10px 10px 10px 50px; /* LTR */
+}
+
+div.status {
+ background-image: url(../../misc/message-24-ok.png);
+ border-color: #be7;
+}
+div.status,
+.ok {
+ color: #234600;
+}
+div.status,
+table tr.ok {
+ background-color: #f8fff0;
+}
+
+div.warning {
+ background-image: url(../../misc/message-24-warning.png);
+ border-color: #ed5;
+}
+div.warning,
+.warning {
+ color: #840;
+}
+div.warning,
+table tr.warning {
+ background-color: #fffce5;
+}
+
+div.error {
+ background-image: url(../../misc/message-24-error.png);
+ border-color: #ed541d;
+}
+div.error,
+.error {
+ color: #8c2e0b;
+}
+div.error,
+table tr.error {
+ background-color: #fef5f1;
+}
+div.error p.error {
+ color: #333;
+}
+
+div.messages ul {
+ margin: 0 0 0 1em; /* LTR */
+ padding: 0;
+}
+div.messages ul li {
+ list-style-image: none;
+}
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
new file mode 100644
index 000000000000..e2e16aec35b5
--- /dev/null
+++ b/core/modules/system/system.module
@@ -0,0 +1,3988 @@
+<?php
+
+/**
+ * @file
+ * Configuration system that lets administrators modify the workings of the site.
+ */
+
+/**
+ * Maximum age of temporary files in seconds.
+ */
+define('DRUPAL_MAXIMUM_TEMP_FILE_AGE', 21600);
+
+/**
+ * Default interval for automatic cron executions in seconds.
+ */
+define('DRUPAL_CRON_DEFAULT_THRESHOLD', 10800);
+
+/**
+ * New users will be set to the default time zone at registration.
+ */
+define('DRUPAL_USER_TIMEZONE_DEFAULT', 0);
+
+/**
+ * New users will get an empty time zone at registration.
+ */
+define('DRUPAL_USER_TIMEZONE_EMPTY', 1);
+
+/**
+ * New users will select their own timezone at registration.
+ */
+define('DRUPAL_USER_TIMEZONE_SELECT', 2);
+
+ /**
+ * Disabled option on forms and settings
+ */
+define('DRUPAL_DISABLED', 0);
+
+/**
+ * Optional option on forms and settings
+ */
+define('DRUPAL_OPTIONAL', 1);
+
+/**
+ * Required option on forms and settings
+ */
+define('DRUPAL_REQUIRED', 2);
+
+/**
+ * Return only visible regions.
+ *
+ * @see system_region_list()
+ */
+define('REGIONS_VISIBLE', 'visible');
+
+/**
+ * Return all regions.
+ *
+ * @see system_region_list()
+ */
+define('REGIONS_ALL', 'all');
+
+/**
+ * Implements hook_help().
+ */
+function system_help($path, $arg) {
+ global $base_url;
+
+ switch ($path) {
+ case 'admin/help#system':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The System module is integral to the site, and provides basic but extensible functionality for use by other modules and themes. Some integral elements of Drupal are contained in and managed by the System module, including caching, enabling and disabling modules and themes, preparing and displaying the administrative page, and configuring fundamental site settings. A number of key system maintenance operations are also part of the System module. For more information, see the online handbook entry for <a href="@system">System module</a>.', array('@system' => 'http://drupal.org/handbook/modules/system')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Managing modules') . '</dt>';
+ $output .= '<dd>' . t('The System module allows users with the appropriate permissions to enable and disable modules on the <a href="@modules">Modules administration page</a>. Drupal comes with a number of core modules, and each module provides a discrete set of features and may be enabled or disabled depending on the needs of the site. Many additional modules contributed by members of the Drupal community are available for download at the <a href="@drupal-modules">Drupal.org module page</a>.', array('@modules' => url('admin/modules'), '@drupal-modules' => 'http://drupal.org/project/modules')) . '</dd>';
+ $output .= '<dt>' . t('Managing themes') . '</dt>';
+ $output .= '<dd>' . t('The System module allows users with the appropriate permissions to enable and disable themes on the <a href="@themes">Appearance administration page</a>. Themes determine the design and presentation of your site. Drupal comes packaged with several core themes, and additional contributed themes are available at the <a href="@drupal-themes">Drupal.org theme page</a>.', array('@themes' => url('admin/appearance'), '@drupal-themes' => 'http://drupal.org/project/themes')) . '</dd>';
+ $output .= '<dt>' . t('Managing caching') . '</dt>';
+ $output .= '<dd>' . t("The System module allows users with the appropriate permissions to manage caching on the <a href='@cache-settings'>Performance settings page</a>. Drupal has a robust caching system that allows the efficient re-use of previously-constructed web pages and web page components. Pages requested by anonymous users are stored in a compressed format; depending on your site configuration and the amount of your web traffic tied to anonymous visitors, the caching system may significantly increase the speed of your site.", array('@cache-settings' => url('admin/config/development/performance'))) . '</dd>';
+ $output .= '<dt>' . t('Performing system maintenance') . '</dt>';
+ $output .= '<dd>' . t('In order for the site and its modules to continue to operate well, a set of routine administrative operations must run on a regular basis. The System module manages this task by making use of a system cron job. You can verify the status of cron tasks by visiting the <a href="@status">Status report page</a>. For more information, see the online handbook entry for <a href="@handbook">configuring cron jobs</a>. You can set up cron job by visiting <a href="@cron">Cron configuration</a> page', array('@status' => url('admin/reports/status'), '@handbook' => 'http://drupal.org/cron', '@cron' => url('admin/config/system/cron'))) . '</dd>';
+ $output .= '<dt>' . t('Configuring basic site settings') . '</dt>';
+ $output .= '<dd>' . t('The System module also handles basic configuration options for your site, including <a href="@date-time-settings">Date and time settings</a>, <a href="@file-system">File system settings</a>, <a href="@clean-url">Clean URL support</a>, <a href="@site-info">Site name and other information</a>, and a <a href="@maintenance-mode">Maintenance mode</a> for taking your site temporarily offline.', array('@date-time-settings' => url('admin/config/regional/date-time'), '@file-system' => url('admin/config/media/file-system'), '@clean-url' => url('admin/config/search/clean-urls'), '@site-info' => url('admin/config/system/site-information'), '@maintenance-mode' => url('admin/config/development/maintenance'))) . '</dd>';
+ $output .= '<dt>' . t('Configuring actions') . '</dt>';
+ $output .= '<dd>' . t('Actions are individual tasks that the system can do, such as unpublishing a piece of content or banning a user. Modules, such as the <a href="@trigger-help">Trigger module</a>, can fire these actions when certain system events happen; for example, when a new post is added or when a user logs in. Modules may also provide additional actions. Visit the <a href="@actions">Actions page</a> to configure actions.', array('@trigger-help' => url('admin/help/trigger'), '@actions' => url('admin/config/system/actions'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/index':
+ return '<p>' . t('This page shows you all available administration tasks for each module.') . '</p>';
+ case 'admin/appearance':
+ $output = '<p>' . t('Set and configure the default theme for your website. Alternative <a href="@themes">themes</a> are available.', array('@themes' => 'http://drupal.org/project/themes')) . '</p>';
+ return $output;
+ case 'admin/appearance/settings/' . $arg[3]:
+ $theme_list = list_themes();
+ $theme = $theme_list[$arg[3]];
+ return '<p>' . t('These options control the display settings for the %name theme. When your site is displayed using this theme, these settings will be used.', array('%name' => $theme->info['name'])) . '</p>';
+ case 'admin/appearance/settings':
+ return '<p>' . t('These options control the default display settings for your entire site, across all themes. Unless they have been overridden by a specific theme, these settings will be used.') . '</p>';
+ case 'admin/modules':
+ $output = '<p>' . t('Download additional <a href="@modules">contributed modules</a> to extend Drupal\'s functionality.', array('@modules' => 'http://drupal.org/project/modules')) . '</p>';
+ if (module_exists('update')) {
+ if (update_manager_access()) {
+ $output .= '<p>' . t('Regularly review and install <a href="@updates">available updates</a> to maintain a secure and current site. Always run the <a href="@update-php">update script</a> each time a module is updated.', array('@update-php' => $base_url . '/core/update.php', '@updates' => url('admin/reports/updates'))) . '</p>';
+ }
+ else {
+ $output .= '<p>' . t('Regularly review <a href="@updates">available updates</a> to maintain a secure and current site. Always run the <a href="@update-php">update script</a> each time a module is updated.', array('@update-php' => $base_url . '/core/update.php', '@updates' => url('admin/reports/updates'))) . '</p>';
+ }
+ }
+ else {
+ $output .= '<p>' . t('Regularly review available updates to maintain a secure and current site. Always run the <a href="@update-php">update script</a> each time a module is updated. Enable the Update manager module to update and install modules and themes.', array('@update-php' => $base_url . '/core/update.php')) . '</p>';
+ }
+ return $output;
+ case 'admin/modules/uninstall':
+ return '<p>' . t('The uninstall process removes all data related to a module. To uninstall a module, you must first disable it on the main <a href="@modules">Modules page</a>.', array('@modules' => url('admin/modules'))) . '</p>';
+ case 'admin/structure/block/manage':
+ if ($arg[4] == 'system' && $arg[5] == 'powered-by') {
+ return '<p>' . t('The <em>Powered by Drupal</em> block is an optional link to the home page of the Drupal project. While there is absolutely no requirement that sites feature this link, it may be used to show support for Drupal.') . '</p>';
+ }
+ break;
+ case 'admin/config/development/maintenance':
+ global $user;
+ if ($user->uid == 1) {
+ return '<p>' . t('Use maintenance mode when making major updates, particularly if the updates could disrupt visitors or the update process. Examples include upgrading, importing or exporting content, modifying a theme, modifying content types, and making backups.') . '</p>';
+ }
+ break;
+ case 'admin/config/system/actions':
+ case 'admin/config/system/actions/manage':
+ $output = '';
+ $output .= '<p>' . t('There are two types of actions: simple and advanced. Simple actions do not require any additional configuration and are listed here automatically. Advanced actions need to be created and configured before they can be used because they have options that need to be specified; for example, sending an e-mail to a specified address or unpublishing content containing certain words. To create an advanced action, select the action from the drop-down list in the advanced action section below and click the <em>Create</em> button.') . '</p>';
+ if (module_exists('trigger')) {
+ $output .= '<p>' . t('You may proceed to the <a href="@url">Triggers</a> page to assign these actions to system events.', array('@url' => url('admin/structure/trigger'))) . '</p>';
+ }
+ return $output;
+ case 'admin/config/system/actions/configure':
+ return t('An advanced action offers additional configuration options which may be filled out below. Changing the <em>Description</em> field is recommended in order to better identify the precise action taking place. This description will be displayed in modules such as the Trigger module when assigning actions to system events, so it is best if it is as descriptive as possible (for example, "Send e-mail to Moderation Team" rather than simply "Send e-mail").');
+ case 'admin/config/people/ip-blocking':
+ return '<p>' . t('IP addresses listed here are blocked from your site. Blocked addresses are completely forbidden from accessing the site and instead see a brief message explaining the situation.') . '</p>';
+ case 'admin/reports/status':
+ return '<p>' . t("Here you can find a short overview of your site's parameters as well as any problems detected with your installation. It may be useful to copy and paste this information into support requests filed on drupal.org's support forums and project issue queues.") . '</p>';
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function system_theme() {
+ return array_merge(drupal_common_theme(), array(
+ 'system_themes_page' => array(
+ 'variables' => array('theme_groups' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_settings_form' => array(
+ 'render element' => 'form',
+ ),
+ 'confirm_form' => array(
+ 'render element' => 'form',
+ ),
+ 'system_modules_fieldset' => array(
+ 'render element' => 'form',
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_modules_incompatible' => array(
+ 'variables' => array('message' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_modules_uninstall' => array(
+ 'render element' => 'form',
+ 'file' => 'system.admin.inc',
+ ),
+ 'status_report' => array(
+ 'render element' => 'requirements',
+ 'file' => 'system.admin.inc',
+ ),
+ 'admin_page' => array(
+ 'variables' => array('blocks' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'admin_block' => array(
+ 'variables' => array('block' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'admin_block_content' => array(
+ 'variables' => array('content' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_admin_index' => array(
+ 'variables' => array('menu_items' => NULL),
+ 'file' => 'system.admin.inc',
+ ),
+ 'system_powered_by' => array(
+ 'variables' => array(),
+ ),
+ 'system_compact_link' => array(
+ 'variables' => array(),
+ ),
+ 'system_date_time_settings' => array(
+ 'render element' => 'form',
+ 'file' => 'system.admin.inc',
+ ),
+ ));
+}
+
+/**
+ * Implements hook_permission().
+ */
+function system_permission() {
+ return array(
+ 'administer modules' => array(
+ 'title' => t('Administer modules'),
+ ),
+ 'administer site configuration' => array(
+ 'title' => t('Administer site configuration'),
+ 'restrict access' => TRUE,
+ ),
+ 'administer themes' => array(
+ 'title' => t('Administer themes'),
+ ),
+ 'administer software updates' => array(
+ 'title' => t('Administer software updates'),
+ 'restrict access' => TRUE,
+ ),
+ 'administer actions' => array(
+ 'title' => t('Administer actions'),
+ ),
+ 'access administration pages' => array(
+ 'title' => t('Use the administration pages and help'),
+ ),
+ 'access site in maintenance mode' => array(
+ 'title' => t('Use the site in maintenance mode'),
+ ),
+ 'view the administration theme' => array(
+ 'title' => t('View the administration theme'),
+ 'description' => variable_get('admin_theme') ? '' : t('This is only used when the site is configured to use a separate administration theme on the <a href="@appearance-url">Appearance</a> page.', array('@appearance-url' => url('admin/appearance'))),
+ ),
+ 'access site reports' => array(
+ 'title' => t('View site reports'),
+ ),
+ 'block IP addresses' => array(
+ 'title' => t('Block IP addresses'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_hook_info().
+ */
+function system_hook_info() {
+ $hooks['token_info'] = array(
+ 'group' => 'tokens',
+ );
+ $hooks['token_info_alter'] = array(
+ 'group' => 'tokens',
+ );
+ $hooks['tokens'] = array(
+ 'group' => 'tokens',
+ );
+ $hooks['tokens_alter'] = array(
+ 'group' => 'tokens',
+ );
+
+ return $hooks;
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function system_entity_info() {
+ return array(
+ 'file' => array(
+ 'label' => t('File'),
+ 'base table' => 'file_managed',
+ 'entity keys' => array(
+ 'id' => 'fid',
+ 'label' => 'filename',
+ ),
+ 'static cache' => FALSE,
+ ),
+ );
+}
+
+/**
+ * Implements hook_element_info().
+ */
+function system_element_info() {
+ // Top level elements.
+ $types['form'] = array(
+ '#method' => 'post',
+ '#action' => request_uri(),
+ '#theme_wrappers' => array('form'),
+ );
+ $types['page'] = array(
+ '#show_messages' => TRUE,
+ '#theme' => 'page',
+ '#theme_wrappers' => array('html'),
+ );
+ // By default, we don't want Ajax commands being rendered in the context of an
+ // HTML page, so we don't provide defaults for #theme or #theme_wrappers.
+ // However, modules can set these properties (for example, to provide an HTML
+ // debugging page that displays rather than executes Ajax commands).
+ $types['ajax'] = array(
+ '#header' => TRUE,
+ '#commands' => array(),
+ '#error' => NULL,
+ );
+ $types['html_tag'] = array(
+ '#theme' => 'html_tag',
+ '#pre_render' => array('drupal_pre_render_conditional_comments'),
+ '#attributes' => array(),
+ '#value' => NULL,
+ );
+ $types['styles'] = array(
+ '#items' => array(),
+ '#pre_render' => array('drupal_pre_render_styles'),
+ '#group_callback' => 'drupal_group_css',
+ '#aggregate_callback' => 'drupal_aggregate_css',
+ );
+
+ // Input elements.
+ $types['submit'] = array(
+ '#input' => TRUE,
+ '#name' => 'op',
+ '#button_type' => 'submit',
+ '#executes_submit_callback' => TRUE,
+ '#limit_validation_errors' => FALSE,
+ '#process' => array('ajax_process_form'),
+ '#theme_wrappers' => array('button'),
+ );
+ $types['button'] = array(
+ '#input' => TRUE,
+ '#name' => 'op',
+ '#button_type' => 'submit',
+ '#executes_submit_callback' => FALSE,
+ '#limit_validation_errors' => FALSE,
+ '#process' => array('ajax_process_form'),
+ '#theme_wrappers' => array('button'),
+ );
+ $types['image_button'] = array(
+ '#input' => TRUE,
+ '#button_type' => 'submit',
+ '#executes_submit_callback' => TRUE,
+ '#limit_validation_errors' => FALSE,
+ '#process' => array('ajax_process_form'),
+ '#return_value' => TRUE,
+ '#has_garbage_value' => TRUE,
+ '#src' => NULL,
+ '#theme_wrappers' => array('image_button'),
+ );
+ $types['textfield'] = array(
+ '#input' => TRUE,
+ '#size' => 60,
+ '#maxlength' => 128,
+ '#autocomplete_path' => FALSE,
+ '#process' => array('ajax_process_form'),
+ '#theme' => 'textfield',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['machine_name'] = array(
+ '#input' => TRUE,
+ '#default_value' => NULL,
+ '#required' => TRUE,
+ '#maxlength' => 64,
+ '#size' => 60,
+ '#autocomplete_path' => FALSE,
+ '#process' => array('form_process_machine_name', 'ajax_process_form'),
+ '#element_validate' => array('form_validate_machine_name'),
+ '#theme' => 'textfield',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['password'] = array(
+ '#input' => TRUE,
+ '#size' => 60,
+ '#maxlength' => 128,
+ '#process' => array('ajax_process_form'),
+ '#theme' => 'password',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['password_confirm'] = array(
+ '#input' => TRUE,
+ '#process' => array('form_process_password_confirm', 'user_form_process_password_confirm'),
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['textarea'] = array(
+ '#input' => TRUE,
+ '#cols' => 60,
+ '#rows' => 5,
+ '#resizable' => TRUE,
+ '#process' => array('ajax_process_form'),
+ '#theme' => 'textarea',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['radios'] = array(
+ '#input' => TRUE,
+ '#process' => array('form_process_radios'),
+ '#theme_wrappers' => array('radios'),
+ '#pre_render' => array('form_pre_render_conditional_form_element'),
+ );
+ $types['radio'] = array(
+ '#input' => TRUE,
+ '#default_value' => NULL,
+ '#process' => array('ajax_process_form'),
+ '#theme' => 'radio',
+ '#theme_wrappers' => array('form_element'),
+ '#title_display' => 'after',
+ );
+ $types['checkboxes'] = array(
+ '#input' => TRUE,
+ '#process' => array('form_process_checkboxes'),
+ '#theme_wrappers' => array('checkboxes'),
+ '#pre_render' => array('form_pre_render_conditional_form_element'),
+ );
+ $types['checkbox'] = array(
+ '#input' => TRUE,
+ '#return_value' => 1,
+ '#theme' => 'checkbox',
+ '#process' => array('form_process_checkbox', 'ajax_process_form'),
+ '#theme_wrappers' => array('form_element'),
+ '#title_display' => 'after',
+ );
+ $types['select'] = array(
+ '#input' => TRUE,
+ '#multiple' => FALSE,
+ '#process' => array('form_process_select', 'ajax_process_form'),
+ '#theme' => 'select',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['weight'] = array(
+ '#input' => TRUE,
+ '#delta' => 10,
+ '#default_value' => 0,
+ '#process' => array('form_process_weight', 'ajax_process_form'),
+ );
+ $types['date'] = array(
+ '#input' => TRUE,
+ '#element_validate' => array('date_validate'),
+ '#process' => array('form_process_date'),
+ '#theme' => 'date',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['file'] = array(
+ '#input' => TRUE,
+ '#size' => 60,
+ '#theme' => 'file',
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['tableselect'] = array(
+ '#input' => TRUE,
+ '#js_select' => TRUE,
+ '#multiple' => TRUE,
+ '#process' => array('form_process_tableselect'),
+ '#options' => array(),
+ '#empty' => '',
+ '#theme' => 'tableselect',
+ );
+
+ // Form structure.
+ $types['item'] = array(
+ '#markup' => '',
+ '#pre_render' => array('drupal_pre_render_markup'),
+ '#theme_wrappers' => array('form_element'),
+ );
+ $types['hidden'] = array(
+ '#input' => TRUE,
+ '#process' => array('ajax_process_form'),
+ '#theme' => 'hidden',
+ );
+ $types['value'] = array(
+ '#input' => TRUE,
+ );
+ $types['markup'] = array(
+ '#markup' => '',
+ '#pre_render' => array('drupal_pre_render_markup'),
+ );
+ $types['link'] = array(
+ '#pre_render' => array('drupal_pre_render_link', 'drupal_pre_render_markup'),
+ );
+ $types['fieldset'] = array(
+ '#collapsible' => FALSE,
+ '#collapsed' => FALSE,
+ '#value' => NULL,
+ '#process' => array('form_process_fieldset', 'ajax_process_form'),
+ '#pre_render' => array('form_pre_render_fieldset'),
+ '#theme_wrappers' => array('fieldset'),
+ );
+ $types['vertical_tabs'] = array(
+ '#theme_wrappers' => array('vertical_tabs'),
+ '#default_tab' => '',
+ '#process' => array('form_process_vertical_tabs'),
+ );
+
+ $types['container'] = array(
+ '#theme_wrappers' => array('container'),
+ '#process' => array('form_process_container'),
+ );
+ $types['actions'] = array(
+ '#theme_wrappers' => array('container'),
+ '#process' => array('form_process_actions', 'form_process_container'),
+ '#weight' => 100,
+ );
+
+ $types['token'] = array(
+ '#input' => TRUE,
+ '#theme' => 'hidden',
+ );
+
+ return $types;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function system_menu() {
+ $items['system/files'] = array(
+ 'title' => 'File download',
+ 'page callback' => 'file_download',
+ 'page arguments' => array('private'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system/temporary'] = array(
+ 'title' => 'Temporary files',
+ 'page callback' => 'file_download',
+ 'page arguments' => array('temporary'),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['system/ajax'] = array(
+ 'title' => 'AHAH callback',
+ 'page callback' => 'ajax_form_callback',
+ 'delivery callback' => 'ajax_deliver',
+ 'access callback' => TRUE,
+ 'theme callback' => 'ajax_base_page_theme',
+ 'type' => MENU_CALLBACK,
+ 'file path' => 'core/includes',
+ 'file' => 'form.inc',
+ );
+ $items['system/timezone'] = array(
+ 'title' => 'Time zone',
+ 'page callback' => 'system_timezone',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin'] = array(
+ 'title' => 'Administration',
+ 'access arguments' => array('access administration pages'),
+ 'page callback' => 'system_admin_menu_block_page',
+ 'weight' => 9,
+ 'menu_name' => 'management',
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/compact'] = array(
+ 'title' => 'Compact mode',
+ 'page callback' => 'system_admin_compact_page',
+ 'access arguments' => array('access administration pages'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/tasks'] = array(
+ 'title' => 'Tasks',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -20,
+ );
+ $items['admin/index'] = array(
+ 'title' => 'Index',
+ 'page callback' => 'system_admin_index',
+ 'access arguments' => array('access administration pages'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => -18,
+ 'file' => 'system.admin.inc',
+ );
+
+ // Menu items that are basically just menu blocks.
+ $items['admin/structure'] = array(
+ 'title' => 'Structure',
+ 'description' => 'Administer blocks, content types, menus, etc.',
+ 'position' => 'right',
+ 'weight' => -8,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ // Appearance.
+ $items['admin/appearance'] = array(
+ 'title' => 'Appearance',
+ 'description' => 'Select and configure your themes.',
+ 'page callback' => 'system_themes_page',
+ 'access arguments' => array('administer themes'),
+ 'position' => 'left',
+ 'weight' => -6,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/appearance/list'] = array(
+ 'title' => 'List',
+ 'description' => 'Select and configure your theme',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -1,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/appearance/enable'] = array(
+ 'title' => 'Enable theme',
+ 'page callback' => 'system_theme_enable',
+ 'access arguments' => array('administer themes'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/appearance/disable'] = array(
+ 'title' => 'Disable theme',
+ 'page callback' => 'system_theme_disable',
+ 'access arguments' => array('administer themes'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/appearance/default'] = array(
+ 'title' => 'Set default theme',
+ 'page callback' => 'system_theme_default',
+ 'access arguments' => array('administer themes'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/appearance/settings'] = array(
+ 'title' => 'Settings',
+ 'description' => 'Configure default and theme specific settings.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_theme_settings'),
+ 'access arguments' => array('administer themes'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'system.admin.inc',
+ 'weight' => 20,
+ );
+ // Theme configuration subtabs.
+ $items['admin/appearance/settings/global'] = array(
+ 'title' => 'Global settings',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -1,
+ );
+
+ foreach (list_themes() as $key => $theme) {
+ $items['admin/appearance/settings/' . $theme->name] = array(
+ 'title' => $theme->info['name'],
+ 'page arguments' => array('system_theme_settings', $theme->name),
+ 'type' => MENU_LOCAL_TASK,
+ 'access callback' => '_system_themes_access',
+ 'access arguments' => array($key),
+ 'file' => 'system.admin.inc',
+ );
+ }
+
+ // Modules.
+ $items['admin/modules'] = array(
+ 'title' => 'Modules',
+ 'description' => 'Extend site functionality.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_modules'),
+ 'access arguments' => array('administer modules'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -2,
+ );
+ $items['admin/modules/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/modules/list/confirm'] = array(
+ 'title' => 'List',
+ 'access arguments' => array('administer modules'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ );
+ $items['admin/modules/uninstall'] = array(
+ 'title' => 'Uninstall',
+ 'page arguments' => array('system_modules_uninstall'),
+ 'access arguments' => array('administer modules'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'system.admin.inc',
+ 'weight' => 20,
+ );
+ $items['admin/modules/uninstall/confirm'] = array(
+ 'title' => 'Uninstall',
+ 'access arguments' => array('administer modules'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ 'file' => 'system.admin.inc',
+ );
+
+ // Configuration.
+ $items['admin/config'] = array(
+ 'title' => 'Configuration',
+ 'description' => 'Administer settings.',
+ 'page callback' => 'system_admin_config_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+
+ // IP address blocking.
+ $items['admin/config/people/ip-blocking'] = array(
+ 'title' => 'IP address blocking',
+ 'description' => 'Manage blocked IP addresses.',
+ 'page callback' => 'system_ip_blocking',
+ 'access arguments' => array('block IP addresses'),
+ 'file' => 'system.admin.inc',
+ 'weight' => 10,
+ );
+ $items['admin/config/people/ip-blocking/delete/%blocked_ip'] = array(
+ 'title' => 'Delete IP address',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_ip_blocking_delete', 5),
+ 'access arguments' => array('block IP addresses'),
+ 'file' => 'system.admin.inc',
+ );
+
+ // Media settings.
+ $items['admin/config/media'] = array(
+ 'title' => 'Media',
+ 'description' => 'Media tools.',
+ 'position' => 'left',
+ 'weight' => -10,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/media/file-system'] = array(
+ 'title' => 'File system',
+ 'description' => 'Tell Drupal where to store uploaded files and how they are accessed.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_file_system_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => -10,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/media/image-toolkit'] = array(
+ 'title' => 'Image toolkit',
+ 'description' => 'Choose which image toolkit to use if you have installed optional toolkits.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_image_toolkit_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => 20,
+ 'file' => 'system.admin.inc',
+ );
+
+ // Service settings.
+ $items['admin/config/services'] = array(
+ 'title' => 'Web services',
+ 'description' => 'Tools related to web services.',
+ 'position' => 'right',
+ 'weight' => 0,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/services/rss-publishing'] = array(
+ 'title' => 'RSS publishing',
+ 'description' => 'Configure the site description, the number of items per feed and whether feeds should be titles/teasers/full-text.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_rss_feeds_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ );
+
+ // Development settings.
+ $items['admin/config/development'] = array(
+ 'title' => 'Development',
+ 'description' => 'Development tools.',
+ 'position' => 'right',
+ 'weight' => -10,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/development/maintenance'] = array(
+ 'title' => 'Maintenance mode',
+ 'description' => 'Take the site offline for maintenance or bring it back online.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_site_maintenance_mode'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -10,
+ );
+ $items['admin/config/development/performance'] = array(
+ 'title' => 'Performance',
+ 'description' => 'Enable or disable page caching for anonymous users and set CSS and JS bandwidth optimization options.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_performance_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -20,
+ );
+ $items['admin/config/development/logging'] = array(
+ 'title' => 'Logging and errors',
+ 'description' => "Settings for logging and alerts modules. Various modules can route Drupal's system events to different destinations, such as syslog, database, email, etc.",
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_logging_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -15,
+ );
+
+ // Regional and date settings.
+ $items['admin/config/regional'] = array(
+ 'title' => 'Regional and language',
+ 'description' => 'Regional settings, localization and translation.',
+ 'position' => 'left',
+ 'weight' => -5,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/settings'] = array(
+ 'title' => 'Regional settings',
+ 'description' => "Settings for the site's default time zone and country.",
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_regional_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => -20,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time'] = array(
+ 'title' => 'Date and time',
+ 'description' => 'Configure display formats for date and time.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_date_time_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => -15,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/types'] = array(
+ 'title' => 'Types',
+ 'description' => 'Configure display formats for date and time.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_date_time_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/types/add'] = array(
+ 'title' => 'Add date type',
+ 'description' => 'Add new date type.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_add_date_format_type_form'),
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'weight' => -10,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/types/%/delete'] = array(
+ 'title' => 'Delete date type',
+ 'description' => 'Allow users to delete a configured date type.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_delete_date_format_type_form', 5),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/formats'] = array(
+ 'title' => 'Formats',
+ 'description' => 'Configure display format strings for date and time.',
+ 'page callback' => 'system_date_time_formats',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => -9,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/formats/add'] = array(
+ 'title' => 'Add format',
+ 'description' => 'Allow users to add additional date formats.',
+ 'type' => MENU_LOCAL_ACTION,
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_configure_date_formats_form'),
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => -10,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/formats/%/edit'] = array(
+ 'title' => 'Edit date format',
+ 'description' => 'Allow users to edit a configured date format.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_configure_date_formats_form', 5),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/formats/%/delete'] = array(
+ 'title' => 'Delete date format',
+ 'description' => 'Allow users to delete a configured date format.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_date_delete_format_form', 5),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/regional/date-time/formats/lookup'] = array(
+ 'title' => 'Date and time lookup',
+ 'page callback' => 'system_date_time_lookup',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+
+ // Search settings.
+ $items['admin/config/search'] = array(
+ 'title' => 'Search and metadata',
+ 'description' => 'Local site search, metadata and SEO.',
+ 'position' => 'left',
+ 'weight' => -10,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/search/clean-urls'] = array(
+ 'title' => 'Clean URLs',
+ 'description' => 'Enable or disable clean URLs for your site.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_clean_url_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => 5,
+ );
+ $items['admin/config/search/clean-urls/check'] = array(
+ 'title' => 'Clean URL check',
+ 'page callback' => 'drupal_json_output',
+ 'page arguments' => array(array('status' => TRUE)),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+
+ // System settings.
+ $items['admin/config/system'] = array(
+ 'title' => 'System',
+ 'description' => 'General system related configuration.',
+ 'position' => 'right',
+ 'weight' => -20,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/actions'] = array(
+ 'title' => 'Actions',
+ 'description' => 'Manage the actions defined for your site.',
+ 'access arguments' => array('administer actions'),
+ 'page callback' => 'system_actions_manage',
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/actions/manage'] = array(
+ 'title' => 'Manage actions',
+ 'description' => 'Manage the actions defined for your site.',
+ 'page callback' => 'system_actions_manage',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -2,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/actions/configure'] = array(
+ 'title' => 'Configure an advanced action',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_actions_configure'),
+ 'access arguments' => array('administer actions'),
+ 'type' => MENU_VISIBLE_IN_BREADCRUMB,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/actions/delete/%actions'] = array(
+ 'title' => 'Delete action',
+ 'description' => 'Delete an action.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_actions_delete_form', 5),
+ 'access arguments' => array('administer actions'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/actions/orphan'] = array(
+ 'title' => 'Remove orphans',
+ 'page callback' => 'system_actions_remove_orphans',
+ 'access arguments' => array('administer actions'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/system/site-information'] = array(
+ 'title' => 'Site information',
+ 'description' => t('Change site name, e-mail address, slogan, default front page, and number of posts per page, error pages.'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_site_information_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -20,
+ );
+ $items['admin/config/system/cron'] = array(
+ 'title' => t('Cron'),
+ 'description' => t('Manage automatic site maintenance tasks.'),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('system_cron_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ 'weight' => 20,
+ );
+ // Additional categories
+ $items['admin/config/user-interface'] = array(
+ 'title' => 'User interface',
+ 'description' => 'Tools that enhance the user interface.',
+ 'position' => 'right',
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ 'weight' => -15,
+ );
+ $items['admin/config/workflow'] = array(
+ 'title' => 'Workflow',
+ 'description' => 'Content workflow, editorial workflow tools.',
+ 'position' => 'right',
+ 'weight' => 5,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/config/content'] = array(
+ 'title' => 'Content authoring',
+ 'description' => 'Settings related to formatting and authoring content.',
+ 'position' => 'left',
+ 'weight' => -15,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ );
+
+ // Reports.
+ $items['admin/reports'] = array(
+ 'title' => 'Reports',
+ 'description' => 'View reports, updates, and errors.',
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access site reports'),
+ 'weight' => 5,
+ 'position' => 'left',
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/reports/status'] = array(
+ 'title' => 'Status report',
+ 'description' => "Get a status report about your site's operation and any detected problems.",
+ 'page callback' => 'system_status',
+ 'weight' => -60,
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/reports/status/run-cron'] = array(
+ 'title' => 'Run cron',
+ 'page callback' => 'system_run_cron',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ $items['admin/reports/status/php'] = array(
+ 'title' => 'PHP',
+ 'page callback' => 'system_php',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+
+ // Default page for batch operations.
+ $items['batch'] = array(
+ 'page callback' => 'system_batch_page',
+ 'access callback' => TRUE,
+ 'theme callback' => '_system_batch_theme',
+ 'type' => MENU_CALLBACK,
+ 'file' => 'system.admin.inc',
+ );
+ return $items;
+}
+
+/**
+ * Theme callback for the default batch page.
+ */
+function _system_batch_theme() {
+ // Retrieve the current state of the batch.
+ $batch = &batch_get();
+ if (!$batch && isset($_REQUEST['id'])) {
+ require_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ $batch = batch_load($_REQUEST['id']);
+ }
+ // Use the same theme as the page that started the batch.
+ if (!empty($batch['theme'])) {
+ return $batch['theme'];
+ }
+}
+
+/**
+ * Implements hook_library_info().
+ */
+function system_library_info() {
+ // Drupal's Ajax framework.
+ $libraries['drupal.ajax'] = array(
+ 'title' => 'Drupal AJAX',
+ 'website' => 'http://api.drupal.org/api/drupal/includes--ajax.inc/group/ajax/7',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/ajax.js' => array('group' => JS_LIBRARY, 'weight' => 2),
+ ),
+ 'dependencies' => array(
+ array('system', 'drupal.progress'),
+ ),
+ );
+
+ // Drupal's batch API.
+ $libraries['drupal.batch'] = array(
+ 'title' => 'Drupal batch API',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/batch.js' => array('group' => JS_DEFAULT, 'cache' => FALSE),
+ ),
+ 'dependencies' => array(
+ array('system', 'drupal.progress'),
+ ),
+ );
+
+ // Drupal's progress indicator.
+ $libraries['drupal.progress'] = array(
+ 'title' => 'Drupal progress indicator',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/progress.js' => array('group' => JS_DEFAULT, 'cache' => FALSE),
+ ),
+ );
+
+ // Drupal's form library.
+ $libraries['drupal.form'] = array(
+ 'title' => 'Drupal form library',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/form.js' => array('group' => JS_LIBRARY, 'weight' => 1),
+ ),
+ );
+
+ // Drupal's states library.
+ $libraries['drupal.states'] = array(
+ 'title' => 'Drupal states',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/states.js' => array('group' => JS_LIBRARY, 'weight' => 1),
+ ),
+ );
+
+ // Drupal's collapsible fieldset.
+ $libraries['drupal.collapse'] = array(
+ 'title' => 'Drupal collapsible fieldset',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/collapse.js' => array('group' => JS_DEFAULT),
+ ),
+ 'dependencies' => array(
+ // collapse.js relies on drupalGetSummary in form.js
+ array('system', 'drupal.form'),
+ ),
+ );
+
+ // Drupal's resizable textarea.
+ $libraries['drupal.textarea'] = array(
+ 'title' => 'Drupal resizable textarea',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/textarea.js' => array('group' => JS_DEFAULT),
+ ),
+ );
+
+ // Drupal's autocomplete widget.
+ $libraries['drupal.autocomplete'] = array(
+ 'title' => 'Drupal autocomplete',
+ 'version' => VERSION,
+ 'js' => array(
+ 'core/misc/autocomplete.js' => array('group' => JS_DEFAULT),
+ ),
+ );
+
+ // jQuery.
+ $libraries['jquery'] = array(
+ 'title' => 'jQuery',
+ 'website' => 'http://jquery.com',
+ 'version' => '1.4.4',
+ 'js' => array(
+ 'core/misc/jquery.js' => array('group' => JS_LIBRARY, 'weight' => -20),
+ ),
+ );
+
+ // jQuery Once.
+ $libraries['jquery.once'] = array(
+ 'title' => 'jQuery Once',
+ 'website' => 'http://plugins.jquery.com/project/once',
+ 'version' => '1.2',
+ 'js' => array(
+ 'core/misc/jquery.once.js' => array('group' => JS_LIBRARY, 'weight' => -19),
+ ),
+ );
+
+ // jQuery Form Plugin.
+ $libraries['jquery.form'] = array(
+ 'title' => 'jQuery Form Plugin',
+ 'website' => 'http://malsup.com/jquery/form/',
+ 'version' => '2.52',
+ 'js' => array(
+ 'core/misc/jquery.form.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'jquery.cookie'),
+ ),
+ );
+
+ // jQuery BBQ plugin.
+ $libraries['jquery.bbq'] = array(
+ 'title' => 'jQuery BBQ',
+ 'website' => 'http://benalman.com/projects/jquery-bbq-plugin/',
+ 'version' => '1.2.1',
+ 'js' => array(
+ 'core/misc/jquery.ba-bbq.js' => array(),
+ ),
+ );
+
+ // Vertical Tabs.
+ $libraries['drupal.vertical-tabs'] = array(
+ 'title' => 'Vertical Tabs',
+ 'website' => 'http://drupal.org/node/323112',
+ 'version' => '1.0',
+ 'js' => array(
+ 'core/misc/vertical-tabs.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/vertical-tabs.css' => array(),
+ ),
+ 'dependencies' => array(
+ // Vertical tabs relies on drupalGetSummary in form.js
+ array('system', 'drupal.form'),
+ ),
+ );
+
+ // Farbtastic.
+ $libraries['farbtastic'] = array(
+ 'title' => 'Farbtastic',
+ 'website' => 'http://code.google.com/p/farbtastic/',
+ 'version' => '1.2',
+ 'js' => array(
+ 'core/misc/farbtastic/farbtastic.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/farbtastic/farbtastic.css' => array(),
+ ),
+ );
+
+ // Cookie.
+ $libraries['jquery.cookie'] = array(
+ 'title' => 'Cookie',
+ 'website' => 'http://plugins.jquery.com/project/cookie',
+ 'version' => '1.0',
+ 'js' => array(
+ 'core/misc/jquery.cookie.js' => array(),
+ ),
+ );
+
+ // jQuery UI.
+ $libraries['ui'] = array(
+ 'title' => 'jQuery UI: Core',
+ 'website' => 'http://jqueryui.com',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.core.min.js' => array('group' => JS_LIBRARY, 'weight' => -11),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.core.css' => array(),
+ 'core/misc/ui/jquery.ui.theme.css' => array(),
+ ),
+ );
+ $libraries['ui.accordion'] = array(
+ 'title' => 'jQuery UI: Accordion',
+ 'website' => 'http://jqueryui.com/demos/accordion/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.accordion.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.accordion.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ ),
+ );
+ $libraries['ui.autocomplete'] = array(
+ 'title' => 'jQuery UI: Autocomplete',
+ 'website' => 'http://jqueryui.com/demos/autocomplete/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.autocomplete.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.autocomplete.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.position'),
+ ),
+ );
+ $libraries['ui.button'] = array(
+ 'title' => 'jQuery UI: Button',
+ 'website' => 'http://jqueryui.com/demos/button/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.button.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.button.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ ),
+ );
+ $libraries['ui.datepicker'] = array(
+ 'title' => 'jQuery UI: Date Picker',
+ 'website' => 'http://jqueryui.com/demos/datepicker/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.datepicker.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.datepicker.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui'),
+ ),
+ );
+ $libraries['ui.dialog'] = array(
+ 'title' => 'jQuery UI: Dialog',
+ 'website' => 'http://jqueryui.com/demos/dialog/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.dialog.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.dialog.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.button'),
+ array('system', 'ui.draggable'),
+ array('system', 'ui.mouse'),
+ array('system', 'ui.position'),
+ array('system', 'ui.resizable'),
+ ),
+ );
+ $libraries['ui.draggable'] = array(
+ 'title' => 'jQuery UI: Draggable',
+ 'website' => 'http://jqueryui.com/demos/draggable/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.draggable.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ ),
+ );
+ $libraries['ui.droppable'] = array(
+ 'title' => 'jQuery UI: Droppable',
+ 'website' => 'http://jqueryui.com/demos/droppable/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.droppable.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ array('system', 'ui.draggable'),
+ ),
+ );
+ $libraries['ui.mouse'] = array(
+ 'title' => 'jQuery UI: Mouse',
+ 'website' => 'http://docs.jquery.com/UI/Mouse',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.mouse.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ ),
+ );
+ $libraries['ui.position'] = array(
+ 'title' => 'jQuery UI: Position',
+ 'website' => 'http://jqueryui.com/demos/position/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.position.min.js' => array(),
+ ),
+ );
+ $libraries['ui.progressbar'] = array(
+ 'title' => 'jQuery UI: Progress Bar',
+ 'website' => 'http://jqueryui.com/demos/progressbar/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.progressbar.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.progressbar.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ ),
+ );
+ $libraries['ui.resizable'] = array(
+ 'title' => 'jQuery UI: Resizable',
+ 'website' => 'http://jqueryui.com/demos/resizable/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.resizable.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.resizable.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ ),
+ );
+ $libraries['ui.selectable'] = array(
+ 'title' => 'jQuery UI: Selectable',
+ 'website' => 'http://jqueryui.com/demos/selectable/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.selectable.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.selectable.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ ),
+ );
+ $libraries['ui.slider'] = array(
+ 'title' => 'jQuery UI: Slider',
+ 'website' => 'http://jqueryui.com/demos/slider/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.slider.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.slider.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ ),
+ );
+ $libraries['ui.sortable'] = array(
+ 'title' => 'jQuery UI: Sortable',
+ 'website' => 'http://jqueryui.com/demos/sortable/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.sortable.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ array('system', 'ui.mouse'),
+ ),
+ );
+ $libraries['ui.tabs'] = array(
+ 'title' => 'jQuery UI: Tabs',
+ 'website' => 'http://jqueryui.com/demos/tabs/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.tabs.min.js' => array(),
+ ),
+ 'css' => array(
+ 'core/misc/ui/jquery.ui.tabs.css' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui.widget'),
+ ),
+ );
+ $libraries['ui.widget'] = array(
+ 'title' => 'jQuery UI: Widget',
+ 'website' => 'http://docs.jquery.com/UI/Widget',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.ui.widget.min.js' => array('group' => JS_LIBRARY, 'weight' => -10),
+ ),
+ 'dependencies' => array(
+ array('system', 'ui'),
+ ),
+ );
+ $libraries['effects'] = array(
+ 'title' => 'jQuery UI: Effects',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.core.min.js' => array('group' => JS_LIBRARY, 'weight' => -9),
+ ),
+ );
+ $libraries['effects.blind'] = array(
+ 'title' => 'jQuery UI: Effects Blind',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.blind.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.bounce'] = array(
+ 'title' => 'jQuery UI: Effects Bounce',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.bounce.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.clip'] = array(
+ 'title' => 'jQuery UI: Effects Clip',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.clip.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.drop'] = array(
+ 'title' => 'jQuery UI: Effects Drop',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.drop.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.explode'] = array(
+ 'title' => 'jQuery UI: Effects Explode',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.explode.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.fade'] = array(
+ 'title' => 'jQuery UI: Effects Fade',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.fade.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.fold'] = array(
+ 'title' => 'jQuery UI: Effects Fold',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.fold.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.highlight'] = array(
+ 'title' => 'jQuery UI: Effects Highlight',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.highlight.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.pulsate'] = array(
+ 'title' => 'jQuery UI: Effects Pulsate',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.pulsate.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.scale'] = array(
+ 'title' => 'jQuery UI: Effects Scale',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.scale.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.shake'] = array(
+ 'title' => 'jQuery UI: Effects Shake',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.shake.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.slide'] = array(
+ 'title' => 'jQuery UI: Effects Slide',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.slide.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+ $libraries['effects.transfer'] = array(
+ 'title' => 'jQuery UI: Effects Transfer',
+ 'website' => 'http://jqueryui.com/demos/effect/',
+ 'version' => '1.8.7',
+ 'js' => array(
+ 'core/misc/ui/jquery.effects.transfer.min.js' => array(),
+ ),
+ 'dependencies' => array(
+ array('system', 'effects'),
+ ),
+ );
+
+ // These library names are deprecated. Earlier versions of Drupal 7 didn't
+ // consistently namespace their libraries, so these names are included for
+ // backwards compatibility with those versions.
+ $libraries['once'] = &$libraries['jquery.once'];
+ $libraries['form'] = &$libraries['jquery.form'];
+ $libraries['jquery-bbq'] = &$libraries['jquery.bbq'];
+ $libraries['vertical-tabs'] = &$libraries['drupal.vertical-tabs'];
+ $libraries['cookie'] = &$libraries['jquery.cookie'];
+
+ return $libraries;
+}
+
+/**
+ * Implements hook_stream_wrappers().
+ */
+function system_stream_wrappers() {
+ $wrappers = array(
+ 'public' => array(
+ 'name' => t('Public files'),
+ 'class' => 'DrupalPublicStreamWrapper',
+ 'description' => t('Public local files served by the webserver.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_NORMAL,
+ ),
+ 'temporary' => array(
+ 'name' => t('Temporary files'),
+ 'class' => 'DrupalTemporaryStreamWrapper',
+ 'description' => t('Temporary local files for upload and previews.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_HIDDEN,
+ ),
+ );
+
+ // Only register the private file stream wrapper if a file path has been set.
+ if (variable_get('file_private_path', FALSE)) {
+ $wrappers['private'] = array(
+ 'name' => t('Private files'),
+ 'class' => 'DrupalPrivateStreamWrapper',
+ 'description' => t('Private local files served by Drupal.'),
+ 'type' => STREAM_WRAPPERS_LOCAL_NORMAL,
+ );
+ }
+
+ return $wrappers;
+}
+
+/**
+ * Retrieve a blocked IP address from the database.
+ *
+ * @param $iid integer
+ * The ID of the blocked IP address to retrieve.
+ *
+ * @return
+ * The blocked IP address from the database as an array.
+ */
+function blocked_ip_load($iid) {
+ return db_query("SELECT * FROM {blocked_ips} WHERE iid = :iid", array(':iid' => $iid))->fetchAssoc();
+}
+
+/**
+ * Menu item access callback - only admin or enabled themes can be accessed.
+ */
+function _system_themes_access($theme) {
+ return user_access('administer themes') && drupal_theme_access($theme);
+}
+
+/**
+ * @defgroup authorize Authorized operations
+ * @{
+ * Functions to run operations with elevated privileges via authorize.php.
+ *
+ * Because of the Update manager functionality included in Drupal core, there
+ * is a mechanism for running operations with elevated file system privileges,
+ * the top-level authorize.php script. This script runs at a reduced Drupal
+ * bootstrap level so that it is not reliant on the entire site being
+ * functional. The operations use a FileTransfer class to manipulate code
+ * installed on the system as the user that owns the files, not the user that
+ * the httpd is running as.
+ *
+ * The first setup is to define a callback function that should be authorized
+ * to run with the elevated privileges. This callback should take a
+ * FileTransfer as its first argument, although you can define an array of
+ * other arguments it should be invoked with. The callback should be placed in
+ * a separate .inc file that will be included by authorize.php.
+ *
+ * To run the operation, certain data must be saved into the SESSION, and then
+ * the flow of control should be redirected to the authorize.php script. There
+ * are two ways to do this, either to call system_authorized_run() directly,
+ * or to call system_authorized_init() and then redirect to authorize.php,
+ * using the URL from system_authorized_get_url(). Redirecting yourself is
+ * necessary when your authorized operation is being triggered by a form
+ * submit handler, since calling drupal_goto() in a submit handler is a bad
+ * idea, and you should instead set $form_state['redirect'].
+ *
+ * Once the SESSION is setup for the operation and the user is redirected to
+ * authorize.php, they will be prompted for their connection credentials (core
+ * provides FTP and SSH by default, although other connection classes can be
+ * added via contributed modules). With valid credentials, authorize.php will
+ * instantiate the appropriate FileTransfer object, and then invoke the
+ * desired operation passing in that object. The authorize.php script can act
+ * as a Batch API processing page, if the operation requires a batch.
+ *
+ * @see authorize.php
+ * @see FileTransfer
+ * @see hook_filetransfer_info()
+ */
+
+/**
+ * Setup a given callback to run via authorize.php with elevated privileges.
+ *
+ * To use authorize.php, certain variables must be stashed into $_SESSION.
+ * This function sets up all the necessary $_SESSION variables, then returns
+ * the full path to authorize.php so the caller can redirect to authorize.php.
+ * That initiates the workflow that will eventually lead to the callback being
+ * invoked. The callback will be invoked at a low bootstrap level, without all
+ * modules being invoked, so it needs to be careful not to assume any code
+ * exists.
+ *
+ * @param $callback
+ * The name of the function to invoke one the user authorizes the operation.
+ * @param $file
+ * The full path to the file where the callback function is implemented.
+ * @param $arguments
+ * Optional array of arguments to pass into the callback when it is invoked.
+ * Note that the first argument to the callback is always the FileTransfer
+ * object created by authorize.php when the user authorizes the operation.
+ * @param $page_title
+ * Optional string to use as the page title once redirected to authorize.php.
+ * @return
+ * Nothing, this function just initializes variables in the user's session.
+ */
+function system_authorized_init($callback, $file, $arguments = array(), $page_title = NULL) {
+ // First, figure out what file transfer backends the site supports, and put
+ // all of those in the SESSION so that authorize.php has access to all of
+ // them via the class autoloader, even without a full bootstrap.
+ $_SESSION['authorize_filetransfer_info'] = drupal_get_filetransfer_info();
+
+ // Now, define the callback to invoke.
+ $_SESSION['authorize_operation'] = array(
+ 'callback' => $callback,
+ 'file' => $file,
+ 'arguments' => $arguments,
+ );
+
+ if (isset($page_title)) {
+ $_SESSION['authorize_operation']['page_title'] = $page_title;
+ }
+}
+
+/**
+ * Return the URL for the authorize.php script.
+ *
+ * @param array $options
+ * Optional array of options to pass to url().
+ * @return
+ * The full URL to authorize.php, using https if available.
+ */
+function system_authorized_get_url(array $options = array()) {
+ global $base_url;
+ // Force https if available, regardless of what the caller specifies.
+ $options['https'] = TRUE;
+ // We prefix with $base_url so we get a full path even if clean URLs are
+ // disabled.
+ return url($base_url . '/core/authorize.php', $options);
+}
+
+/**
+ * Returns the URL for the authorize.php script when it is processing a batch.
+ */
+function system_authorized_batch_processing_url() {
+ return system_authorized_get_url(array('query' => array('batch' => '1')));
+}
+
+/**
+ * Setup and invoke an operation using authorize.php.
+ *
+ * @see system_authorized_init()
+ */
+function system_authorized_run($callback, $file, $arguments = array(), $page_title = NULL) {
+ system_authorized_init($callback, $file, $arguments, $page_title);
+ drupal_goto(system_authorized_get_url());
+}
+
+/**
+ * Use authorize.php to run batch_process().
+ *
+ * @see batch_process()
+ */
+function system_authorized_batch_process() {
+ $finish_url = system_authorized_get_url();
+ $process_url = system_authorized_batch_processing_url();
+ batch_process($finish_url, $process_url);
+}
+
+/**
+ * @} End of "defgroup authorize".
+ */
+
+/**
+ * Implements hook_updater_info().
+ */
+function system_updater_info() {
+ return array(
+ 'module' => array(
+ 'class' => 'ModuleUpdater',
+ 'name' => t('Update modules'),
+ 'weight' => 0,
+ ),
+ 'theme' => array(
+ 'class' => 'ThemeUpdater',
+ 'name' => t('Update themes'),
+ 'weight' => 0,
+ ),
+ );
+}
+
+/**
+ * Implements hook_filetransfer_info().
+ */
+function system_filetransfer_info() {
+ $backends = array();
+
+ // This is the default, will be available on most systems.
+ if (function_exists('ftp_connect')) {
+ $backends['ftp'] = array(
+ 'title' => t('FTP'),
+ 'class' => 'FileTransferFTP',
+ 'file' => 'ftp.inc',
+ 'file path' => 'core/includes/filetransfer',
+ 'weight' => 0,
+ );
+ }
+
+ // SSH2 lib connection is only available if the proper PHP extension is
+ // installed.
+ if (function_exists('ssh2_connect')) {
+ $backends['ssh'] = array(
+ 'title' => t('SSH'),
+ 'class' => 'FileTransferSSH',
+ 'file' => 'ssh.inc',
+ 'file path' => 'core/includes/filetransfer',
+ 'weight' => 20,
+ );
+ }
+ return $backends;
+}
+
+/**
+ * Implements hook_init().
+ */
+function system_init() {
+ $path = drupal_get_path('module', 'system');
+ // Add the CSS for this module. These aren't in system.info, because they
+ // need to be in the CSS_SYSTEM group rather than the CSS_DEFAULT group.
+ drupal_add_css($path . '/system.base.css', array('group' => CSS_SYSTEM, 'every_page' => TRUE));
+ if (path_is_admin(current_path())) {
+ drupal_add_css($path . '/system.admin.css', array('group' => CSS_SYSTEM));
+ }
+ drupal_add_css($path . '/system.menus.css', array('group' => CSS_SYSTEM, 'every_page' => TRUE));
+ drupal_add_css($path . '/system.messages.css', array('group' => CSS_SYSTEM, 'every_page' => TRUE));
+ drupal_add_css($path . '/system.theme.css', array('group' => CSS_SYSTEM, 'every_page' => TRUE));
+
+ // Ignore slave database servers for this request.
+ //
+ // In Drupal's distributed database structure, new data is written to the master
+ // and then propagated to the slave servers. This means there is a lag
+ // between when data is written to the master and when it is available on the slave.
+ // At these times, we will want to avoid using a slave server temporarily.
+ // For example, if a user posts a new node then we want to disable the slave
+ // server for that user temporarily to allow the slave server to catch up.
+ // That way, that user will see their changes immediately while for other
+ // users we still get the benefits of having a slave server, just with slightly
+ // stale data. Code that wants to disable the slave server should use the
+ // db_set_ignore_slave() function to set $_SESSION['ignore_slave_server'] to
+ // the timestamp after which the slave can be re-enabled.
+ if (isset($_SESSION['ignore_slave_server'])) {
+ if ($_SESSION['ignore_slave_server'] >= REQUEST_TIME) {
+ Database::ignoreTarget('default', 'slave');
+ }
+ else {
+ unset($_SESSION['ignore_slave_server']);
+ }
+ }
+
+ // Add CSS/JS files from module .info files.
+ system_add_module_assets();
+}
+
+/**
+ * Adds CSS and JavaScript files declared in module .info files.
+ */
+function system_add_module_assets() {
+ foreach (system_get_info('module') as $module => $info) {
+ if (!empty($info['stylesheets'])) {
+ foreach ($info['stylesheets'] as $media => $stylesheets) {
+ foreach ($stylesheets as $stylesheet) {
+ drupal_add_css($stylesheet, array('every_page' => TRUE, 'media' => $media));
+ }
+ }
+ }
+ if (!empty($info['scripts'])) {
+ foreach ($info['scripts'] as $script) {
+ drupal_add_js($script, array('every_page' => TRUE));
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_custom_theme().
+ */
+function system_custom_theme() {
+ if (user_access('view the administration theme') && path_is_admin(current_path())) {
+ return variable_get('admin_theme');
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function system_form_user_profile_form_alter(&$form, &$form_state) {
+ if ($form['#user_category'] == 'account') {
+ if (variable_get('configurable_timezones', 1)) {
+ system_user_timezone($form, $form_state);
+ }
+ return $form;
+ }
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function system_form_user_register_form_alter(&$form, &$form_state) {
+ if (variable_get('configurable_timezones', 1)) {
+ if (variable_get('user_default_timezone', DRUPAL_USER_TIMEZONE_DEFAULT) == DRUPAL_USER_TIMEZONE_SELECT) {
+ system_user_timezone($form, $form_state);
+ }
+ else {
+ $form['account']['timezone'] = array(
+ '#type' => 'hidden',
+ '#value' => variable_get('user_default_timezone', DRUPAL_USER_TIMEZONE_DEFAULT) ? '' : variable_get('date_default_timezone', ''),
+ );
+ }
+ return $form;
+ }
+}
+
+/**
+ * Implements hook_user_login().
+ */
+function system_user_login(&$edit, $account) {
+ // If the user has a NULL time zone, notify them to set a time zone.
+ if (!$account->timezone && variable_get('configurable_timezones', 1) && variable_get('empty_timezone_message', 0)) {
+ drupal_set_message(t('Configure your <a href="@user-edit">account time zone setting</a>.', array('@user-edit' => url("user/$account->uid/edit", array('query' => drupal_get_destination(), 'fragment' => 'edit-timezone')))));
+ }
+}
+
+/**
+ * Add the time zone field to the user edit and register forms.
+ */
+function system_user_timezone(&$form, &$form_state) {
+ global $user;
+
+ $account = $form['#user'];
+
+ $form['timezone'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Locale settings'),
+ '#weight' => 6,
+ '#collapsible' => TRUE,
+ );
+ $form['timezone']['timezone'] = array(
+ '#type' => 'select',
+ '#title' => t('Time zone'),
+ '#default_value' => isset($account->timezone) ? $account->timezone : ($account->uid == $user->uid ? variable_get('date_default_timezone', '') : ''),
+ '#options' => system_time_zones($account->uid != $user->uid),
+ '#description' => t('Select the desired local time and time zone. Dates and times throughout this site will be displayed using this time zone.'),
+ );
+ if (!isset($account->timezone) && $account->uid == $user->uid && empty($form_state['input']['timezone'])) {
+ $form['timezone']['#description'] = t('Your time zone setting will be automatically detected if possible. Confirm the selection and click save.');
+ $form['timezone']['timezone']['#attributes'] = array('class' => array('timezone-detect'));
+ drupal_add_js('core/misc/timezone.js');
+ }
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function system_block_info() {
+ $blocks['main'] = array(
+ 'info' => t('Main page content'),
+ // Cached elsewhere.
+ 'cache' => DRUPAL_NO_CACHE,
+ );
+ $blocks['powered-by'] = array(
+ 'info' => t('Powered by Drupal'),
+ 'weight' => '10',
+ 'cache' => DRUPAL_NO_CACHE,
+ );
+ $blocks['help'] = array(
+ 'info' => t('System help'),
+ 'weight' => '5',
+ 'cache' => DRUPAL_NO_CACHE,
+ );
+ // System-defined menu blocks.
+ foreach (menu_list_system_menus() as $menu_name => $title) {
+ $blocks[$menu_name]['info'] = t($title);
+ // Menu blocks can't be cached because each menu item can have
+ // a custom access callback. menu.inc manages its own caching.
+ $blocks[$menu_name]['cache'] = DRUPAL_NO_CACHE;
+ }
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_view().
+ *
+ * Generate a block with a promotional link to Drupal.org and
+ * all system menu blocks.
+ */
+function system_block_view($delta = '') {
+ $block = array();
+ switch ($delta) {
+ case 'main':
+ $block['subject'] = NULL;
+ $block['content'] = drupal_set_page_content();
+ return $block;
+ case 'powered-by':
+ $block['subject'] = NULL;
+ $block['content'] = theme('system_powered_by');
+ return $block;
+ case 'help':
+ $block['subject'] = NULL;
+ $block['content'] = menu_get_active_help();
+ return $block;
+ default:
+ // All system menu blocks.
+ $system_menus = menu_list_system_menus();
+ if (isset($system_menus[$delta])) {
+ $block['subject'] = t($system_menus[$delta]);
+ $block['content'] = menu_tree($delta);
+ return $block;
+ }
+ break;
+ }
+}
+
+/**
+ * Implements hook_preprocess_block().
+ */
+function system_preprocess_block(&$variables) {
+ // System menu blocks should get the same class as menu module blocks.
+ if ($variables['block']->module == 'system' && in_array($variables['block']->delta, array_keys(menu_list_system_menus()))) {
+ $variables['classes_array'][] = 'block-menu';
+ }
+}
+
+/**
+ * Provide a single block on the administration overview page.
+ *
+ * @param $item
+ * The menu item to be displayed.
+ */
+function system_admin_menu_block($item) {
+ $cache = &drupal_static(__FUNCTION__, array());
+ // If we are calling this function for a menu item that corresponds to a
+ // local task (for example, admin/tasks), then we want to retrieve the
+ // parent item's child links, not this item's (since this item won't have
+ // any).
+ if ($item['tab_root'] != $item['path']) {
+ $item = menu_get_item($item['tab_root_href']);
+ }
+
+ if (!isset($item['mlid'])) {
+ $item += db_query("SELECT mlid, menu_name FROM {menu_links} ml WHERE ml.router_path = :path AND module = 'system'", array(':path' => $item['path']))->fetchAssoc();
+ }
+
+ if (isset($cache[$item['mlid']])) {
+ return $cache[$item['mlid']];
+ }
+
+ $content = array();
+ $query = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC));
+ $query->join('menu_router', 'm', 'm.path = ml.router_path');
+ $query
+ ->fields('ml')
+ // Weight should be taken from {menu_links}, not {menu_router}.
+ ->fields('m', array_diff(drupal_schema_fields_sql('menu_router'), array('weight')))
+ ->condition('ml.plid', $item['mlid'])
+ ->condition('ml.menu_name', $item['menu_name'])
+ ->condition('ml.hidden', 0);
+
+ foreach ($query->execute() as $link) {
+ _menu_link_translate($link);
+ if ($link['access']) {
+ // The link description, either derived from 'description' in
+ // hook_menu() or customized via menu module is used as title attribute.
+ if (!empty($link['localized_options']['attributes']['title'])) {
+ $link['description'] = $link['localized_options']['attributes']['title'];
+ unset($link['localized_options']['attributes']['title']);
+ }
+ // Prepare for sorting as in function _menu_tree_check_access().
+ // The weight is offset so it is always positive, with a uniform 5-digits.
+ $key = (50000 + $link['weight']) . ' ' . drupal_strtolower($link['title']) . ' ' . $link['mlid'];
+ $content[$key] = $link;
+ }
+ }
+ ksort($content);
+ $cache[$item['mlid']] = $content;
+ return $content;
+}
+
+/**
+ * Checks the existence of the directory specified in $form_element.
+ *
+ * This function is called from the system_settings form to check all core
+ * file directories (file_public_path, file_private_path, file_temporary_path).
+ *
+ * @param $form_element
+ * The form element containing the name of the directory to check.
+ */
+function system_check_directory($form_element) {
+ $directory = $form_element['#value'];
+ if (strlen($directory) == 0) {
+ return $form_element;
+ }
+
+ if (!is_dir($directory) && !drupal_mkdir($directory, NULL, TRUE)) {
+ // If the directory does not exists and cannot be created.
+ form_set_error($form_element['#parents'][0], t('The directory %directory does not exist and could not be created.', array('%directory' => $directory)));
+ watchdog('file system', 'The directory %directory does not exist and could not be created.', array('%directory' => $directory), WATCHDOG_ERROR);
+ }
+
+ if (is_dir($directory) && !is_writable($directory) && !drupal_chmod($directory)) {
+ // If the directory is not writable and cannot be made so.
+ form_set_error($form_element['#parents'][0], t('The directory %directory exists but is not writable and could not be made writable.', array('%directory' => $directory)));
+ watchdog('file system', 'The directory %directory exists but is not writable and could not be made writable.', array('%directory' => $directory), WATCHDOG_ERROR);
+ }
+ elseif (is_dir($directory)) {
+ if ($form_element['#name'] == 'file_public_path') {
+ // Create public .htaccess file.
+ file_save_htaccess($directory, FALSE);
+ }
+ else {
+ // Create private .htaccess file.
+ file_save_htaccess($directory);
+ }
+ }
+
+ return $form_element;
+}
+
+/**
+ * Retrieves the current status of an array of files in the system table.
+ *
+ * @param $files
+ * An array of files to check.
+ * @param $type
+ * The type of the files.
+ */
+function system_get_files_database(&$files, $type) {
+ // Extract current files from database.
+ $result = db_query("SELECT filename, name, type, status, schema_version, weight FROM {system} WHERE type = :type", array(':type' => $type));
+ foreach ($result as $file) {
+ if (isset($files[$file->name]) && is_object($files[$file->name])) {
+ $file->uri = $file->filename;
+ foreach ($file as $key => $value) {
+ if (!isset($files[$file->name]->$key)) {
+ $files[$file->name]->$key = $value;
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Updates the records in the system table based on the files array.
+ *
+ * @param $files
+ * An array of files.
+ * @param $type
+ * The type of the files.
+ */
+function system_update_files_database(&$files, $type) {
+ $result = db_query("SELECT * FROM {system} WHERE type = :type", array(':type' => $type));
+
+ // Add all files that need to be deleted to a DatabaseCondition.
+ $delete = db_or();
+ foreach ($result as $file) {
+ if (isset($files[$file->name]) && is_object($files[$file->name])) {
+ // Keep the old filename from the database in case the file has moved.
+ $old_filename = $file->filename;
+
+ $updated_fields = array();
+
+ // Handle info specially, compare the serialized value.
+ $serialized_info = serialize($files[$file->name]->info);
+ if ($serialized_info != $file->info) {
+ $updated_fields['info'] = $serialized_info;
+ }
+ unset($file->info);
+
+ // Scan remaining fields to find only the updated values.
+ foreach ($file as $key => $value) {
+ if (isset($files[$file->name]->$key) && $files[$file->name]->$key != $value) {
+ $updated_fields[$key] = $files[$file->name]->$key;
+ }
+ }
+
+ // Update the record.
+ if (count($updated_fields)) {
+ db_update('system')
+ ->fields($updated_fields)
+ ->condition('filename', $old_filename)
+ ->execute();
+ }
+
+ // Indicate that the file exists already.
+ $files[$file->name]->exists = TRUE;
+ }
+ else {
+ // File is not found in file system, so delete record from the system table.
+ $delete->condition('filename', $file->filename);
+ }
+ }
+
+ if (count($delete) > 0) {
+ // Delete all missing files from the system table, but only if the plugin
+ // has never been installed.
+ db_delete('system')
+ ->condition($delete)
+ ->condition('schema_version', -1)
+ ->execute();
+ }
+
+ // All remaining files are not in the system table, so we need to add them.
+ $query = db_insert('system')->fields(array('filename', 'name', 'type', 'owner', 'info'));
+ foreach ($files as &$file) {
+ if (isset($file->exists)) {
+ unset($file->exists);
+ }
+ else {
+ $query->values(array(
+ 'filename' => $file->uri,
+ 'name' => $file->name,
+ 'type' => $type,
+ 'owner' => isset($file->owner) ? $file->owner : '',
+ 'info' => serialize($file->info),
+ ));
+ $file->type = $type;
+ $file->status = 0;
+ $file->schema_version = -1;
+ }
+ }
+ $query->execute();
+
+ // If any module or theme was moved to a new location, we need to reset the
+ // system_list() cache or we will continue to load the old copy, look for
+ // schema updates in the wrong place, etc.
+ system_list_reset();
+}
+
+/**
+ * Returns an array of information about enabled modules or themes.
+ *
+ * This function returns the information from the {system} table corresponding
+ * to the cached contents of the .info file for each active module or theme.
+ *
+ * @param $type
+ * Either 'module' or 'theme'.
+ * @param $name
+ * (optional) The name of a module or theme whose information shall be
+ * returned. If omitted, all records for the provided $type will be returned.
+ * If $name does not exist in the provided $type or is not enabled, an empty
+ * array will be returned.
+ *
+ * @return
+ * An associative array of module or theme information keyed by name, or only
+ * information for $name, if given. If no records are available, an empty
+ * array is returned.
+ *
+ * @see system_rebuild_module_data()
+ * @see system_rebuild_theme_data()
+ */
+function system_get_info($type, $name = NULL) {
+ $info = array();
+ if ($type == 'module') {
+ $type = 'module_enabled';
+ }
+ $list = system_list($type);
+ foreach ($list as $shortname => $item) {
+ if (!empty($item->status)) {
+ $info[$shortname] = $item->info;
+ }
+ }
+ if (isset($name)) {
+ return isset($info[$name]) ? $info[$name] : array();
+ }
+ return $info;
+}
+
+/**
+ * Helper function to scan and collect module .info data.
+ *
+ * @return
+ * An associative array of module information.
+ */
+function _system_rebuild_module_data() {
+ // Find modules
+ $modules = drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.module$/', 'modules', 'name', 0);
+
+ // Include the install profile in modules that are loaded.
+ $profile = drupal_get_profile();
+ $modules[$profile] = new stdClass();
+ $modules[$profile]->name = $profile;
+ $modules[$profile]->uri = 'profiles/' . $profile . '/' . $profile . '.profile';
+ $modules[$profile]->filename = $profile . '.profile';
+
+ // Install profile hooks are always executed last.
+ $modules[$profile]->weight = 1000;
+
+ // Set defaults for module info.
+ $defaults = array(
+ 'dependencies' => array(),
+ 'description' => '',
+ 'package' => 'Other',
+ 'version' => NULL,
+ 'php' => DRUPAL_MINIMUM_PHP,
+ 'files' => array(),
+ 'bootstrap' => 0,
+ );
+
+ // Read info files for each module.
+ foreach ($modules as $key => $module) {
+ // The module system uses the key 'filename' instead of 'uri' so copy the
+ // value so it will be used by the modules system.
+ $modules[$key]->filename = $module->uri;
+
+ // Look for the info file.
+ $module->info = drupal_parse_info_file(dirname($module->uri) . '/' . $module->name . '.info');
+
+ // Skip modules that don't provide info.
+ if (empty($module->info)) {
+ unset($modules[$key]);
+ continue;
+ }
+
+ // Merge in defaults and save.
+ $modules[$key]->info = $module->info + $defaults;
+
+ // Prefix stylesheets and scripts with module path.
+ $path = dirname($module->uri);
+ if (isset($module->info['stylesheets'])) {
+ $module->info['stylesheets'] = _system_info_add_path($module->info['stylesheets'], $path);
+ }
+ if (isset($module->info['scripts'])) {
+ $module->info['scripts'] = _system_info_add_path($module->info['scripts'], $path);
+ }
+
+ // Install profiles are hidden by default, unless explicitly specified
+ // otherwise in the .info file.
+ if ($key == $profile && !isset($modules[$key]->info['hidden'])) {
+ $modules[$key]->info['hidden'] = TRUE;
+ }
+
+ // Invoke hook_system_info_alter() to give installed modules a chance to
+ // modify the data in the .info files if necessary.
+ $type = 'module';
+ drupal_alter('system_info', $modules[$key]->info, $modules[$key], $type);
+ }
+
+ if (isset($modules[$profile])) {
+ // The install profile is required, if it's a valid module.
+ $modules[$profile]->info['required'] = TRUE;
+ // Add a default distribution name if the profile did not provide one. This
+ // matches the default value used in install_profile_info().
+ if (!isset($modules[$profile]->info['distribution_name'])) {
+ $modules[$profile]->info['distribution_name'] = 'Drupal';
+ }
+ }
+
+ return $modules;
+}
+
+/**
+ * Rebuild, save, and return data about all currently available modules.
+ *
+ * @return
+ * Array of all available modules and their data.
+ */
+function system_rebuild_module_data() {
+ $modules_cache = &drupal_static(__FUNCTION__);
+ // Only rebuild once per request. $modules and $modules_cache cannot be
+ // combined into one variable, because the $modules_cache variable is reset by
+ // reference from system_list_reset() during the rebuild.
+ if (!isset($modules_cache)) {
+ $modules = _system_rebuild_module_data();
+ ksort($modules);
+ system_get_files_database($modules, 'module');
+ system_update_files_database($modules, 'module');
+ $modules = _module_build_dependencies($modules);
+ $modules_cache = $modules;
+ }
+ return $modules_cache;
+}
+
+/**
+ * Refresh bootstrap column in the system table.
+ *
+ * This is called internally by module_enable/disable() to flag modules that
+ * implement hooks used during bootstrap, such as hook_boot(). These modules
+ * are loaded earlier to invoke the hooks.
+ */
+function _system_update_bootstrap_status() {
+ $bootstrap_modules = array();
+ foreach (bootstrap_hooks() as $hook) {
+ foreach (module_implements($hook) as $module) {
+ $bootstrap_modules[] = $module;
+ }
+ }
+ $query = db_update('system')->fields(array('bootstrap' => 0));
+ if ($bootstrap_modules) {
+ db_update('system')
+ ->fields(array('bootstrap' => 1))
+ ->condition('name', $bootstrap_modules, 'IN')
+ ->execute();
+ $query->condition('name', $bootstrap_modules, 'NOT IN');
+ }
+ $query->execute();
+ // Reset the cached list of bootstrap modules.
+ system_list_reset();
+}
+
+/**
+ * Helper function to scan and collect theme .info data and their engines.
+ *
+ * @return
+ * An associative array of themes information.
+ */
+function _system_rebuild_theme_data() {
+ // Find themes
+ $themes = drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.info$/', 'themes');
+ // Find theme engines
+ $engines = drupal_system_listing('/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.engine$/', 'themes/engines');
+
+ // Set defaults for theme info.
+ $defaults = array(
+ 'engine' => 'phptemplate',
+ 'regions' => array(
+ 'sidebar_first' => 'Left sidebar',
+ 'sidebar_second' => 'Right sidebar',
+ 'content' => 'Content',
+ 'header' => 'Header',
+ 'footer' => 'Footer',
+ 'highlighted' => 'Highlighted',
+ 'help' => 'Help',
+ 'page_top' => 'Page top',
+ 'page_bottom' => 'Page bottom',
+ ),
+ 'description' => '',
+ 'features' => _system_default_theme_features(),
+ 'screenshot' => 'screenshot.png',
+ 'php' => DRUPAL_MINIMUM_PHP,
+ 'stylesheets' => array(),
+ 'scripts' => array(),
+ );
+
+ $sub_themes = array();
+ // Read info files for each theme
+ foreach ($themes as $key => $theme) {
+ $themes[$key]->filename = $theme->uri;
+ $themes[$key]->info = drupal_parse_info_file($theme->uri) + $defaults;
+
+ // Invoke hook_system_info_alter() to give installed modules a chance to
+ // modify the data in the .info files if necessary.
+ $type = 'theme';
+ drupal_alter('system_info', $themes[$key]->info, $themes[$key], $type);
+
+ if (!empty($themes[$key]->info['base theme'])) {
+ $sub_themes[] = $key;
+ }
+ if ($themes[$key]->info['engine'] == 'theme') {
+ $filename = dirname($themes[$key]->uri) . '/' . $themes[$key]->name . '.theme';
+ if (file_exists($filename)) {
+ $themes[$key]->owner = $filename;
+ $themes[$key]->prefix = $key;
+ }
+ }
+ else {
+ $engine = $themes[$key]->info['engine'];
+ if (isset($engines[$engine])) {
+ $themes[$key]->owner = $engines[$engine]->uri;
+ $themes[$key]->prefix = $engines[$engine]->name;
+ $themes[$key]->template = TRUE;
+ }
+ }
+
+ // Prefix stylesheets and scripts with module path.
+ $path = dirname($theme->uri);
+ $theme->info['stylesheets'] = _system_info_add_path($theme->info['stylesheets'], $path);
+ $theme->info['scripts'] = _system_info_add_path($theme->info['scripts'], $path);
+
+ // Give the screenshot proper path information.
+ if (!empty($themes[$key]->info['screenshot'])) {
+ $themes[$key]->info['screenshot'] = $path . '/' . $themes[$key]->info['screenshot'];
+ }
+ }
+
+ // Now that we've established all our master themes, go back and fill in data
+ // for subthemes.
+ foreach ($sub_themes as $key) {
+ $themes[$key]->base_themes = system_find_base_themes($themes, $key);
+ // Don't proceed if there was a problem with the root base theme.
+ if (!current($themes[$key]->base_themes)) {
+ continue;
+ }
+ $base_key = key($themes[$key]->base_themes);
+ foreach (array_keys($themes[$key]->base_themes) as $base_theme) {
+ $themes[$base_theme]->sub_themes[$key] = $themes[$key]->info['name'];
+ }
+ // Copy the 'owner' and 'engine' over if the top level theme uses a theme
+ // engine.
+ if (isset($themes[$base_key]->owner)) {
+ if (isset($themes[$base_key]->info['engine'])) {
+ $themes[$key]->info['engine'] = $themes[$base_key]->info['engine'];
+ $themes[$key]->owner = $themes[$base_key]->owner;
+ $themes[$key]->prefix = $themes[$base_key]->prefix;
+ }
+ else {
+ $themes[$key]->prefix = $key;
+ }
+ }
+ }
+
+ return $themes;
+}
+
+/**
+ * Rebuild, save, and return data about all currently available themes.
+ *
+ * @return
+ * Array of all available themes and their data.
+ */
+function system_rebuild_theme_data() {
+ $themes = _system_rebuild_theme_data();
+ ksort($themes);
+ system_get_files_database($themes, 'theme');
+ system_update_files_database($themes, 'theme');
+ return $themes;
+}
+
+/**
+ * Prefixes all values in an .info file array with a given path.
+ *
+ * This helper function is mainly used to prefix all array values of an .info
+ * file property with a single given path (to the module or theme); e.g., to
+ * prefix all values of the 'stylesheets' or 'scripts' properties with the file
+ * path to the defining module/theme.
+ *
+ * @param $info
+ * A nested array of data of an .info file to be processed.
+ * @param $path
+ * A file path to prepend to each value in $info.
+ *
+ * @return
+ * The $info array with prefixed values.
+ *
+ * @see _system_rebuild_module_data()
+ * @see _system_rebuild_theme_data()
+ */
+function _system_info_add_path($info, $path) {
+ foreach ($info as $key => $value) {
+ // Recurse into nested values until we reach the deepest level.
+ if (is_array($value)) {
+ $info[$key] = _system_info_add_path($info[$key], $path);
+ }
+ // Unset the original value's key and set the new value with prefix, using
+ // the original value as key, so original values can still be looked up.
+ else {
+ unset($info[$key]);
+ $info[$value] = $path . '/' . $value;
+ }
+ }
+ return $info;
+}
+
+/**
+ * Returns an array of default theme features.
+ */
+function _system_default_theme_features() {
+ return array(
+ 'logo',
+ 'favicon',
+ 'name',
+ 'slogan',
+ 'node_user_picture',
+ 'comment_user_picture',
+ 'comment_user_verification',
+ 'main_menu',
+ 'secondary_menu',
+ );
+}
+
+/**
+ * Find all the base themes for the specified theme.
+ *
+ * Themes can inherit templates and function implementations from earlier themes.
+ *
+ * @param $themes
+ * An array of available themes.
+ * @param $key
+ * The name of the theme whose base we are looking for.
+ * @param $used_keys
+ * A recursion parameter preventing endless loops.
+ * @return
+ * Returns an array of all of the theme's ancestors; the first element's value
+ * will be NULL if an error occurred.
+ */
+function system_find_base_themes($themes, $key, $used_keys = array()) {
+ $base_key = $themes[$key]->info['base theme'];
+ // Does the base theme exist?
+ if (!isset($themes[$base_key])) {
+ return array($base_key => NULL);
+ }
+
+ $current_base_theme = array($base_key => $themes[$base_key]->info['name']);
+
+ // Is the base theme itself a child of another theme?
+ if (isset($themes[$base_key]->info['base theme'])) {
+ // Do we already know the base themes of this theme?
+ if (isset($themes[$base_key]->base_themes)) {
+ return $themes[$base_key]->base_themes + $current_base_theme;
+ }
+ // Prevent loops.
+ if (!empty($used_keys[$base_key])) {
+ return array($base_key => NULL);
+ }
+ $used_keys[$base_key] = TRUE;
+ return system_find_base_themes($themes, $base_key, $used_keys) + $current_base_theme;
+ }
+ // If we get here, then this is our parent theme.
+ return $current_base_theme;
+}
+
+/**
+ * Get a list of available regions from a specified theme.
+ *
+ * @param $theme_key
+ * The name of a theme.
+ * @param $show
+ * Possible values: REGIONS_ALL or REGIONS_VISIBLE. Visible excludes hidden
+ * regions.
+ * @return
+ * An array of regions in the form $region['name'] = 'description'.
+ */
+function system_region_list($theme_key, $show = REGIONS_ALL) {
+ $themes = list_themes();
+ if (!isset($themes[$theme_key])) {
+ return array();
+ }
+
+ $list = array();
+ $info = $themes[$theme_key]->info;
+ // If requested, suppress hidden regions. See block_admin_display_form().
+ foreach ($info['regions'] as $name => $label) {
+ if ($show == REGIONS_ALL || !isset($info['regions_hidden']) || !in_array($name, $info['regions_hidden'])) {
+ $list[$name] = t($label);
+ }
+ }
+
+ return $list;
+}
+
+/**
+ * Implements hook_system_info_alter().
+ */
+function system_system_info_alter(&$info, $file, $type) {
+ // Remove page-top from the blocks UI since it is reserved for modules to
+ // populate from outside the blocks system.
+ if ($type == 'theme') {
+ $info['regions_hidden'][] = 'page_top';
+ $info['regions_hidden'][] = 'page_bottom';
+ }
+}
+
+/**
+ * Get the name of the default region for a given theme.
+ *
+ * @param $theme
+ * The name of a theme.
+ * @return
+ * A string that is the region name.
+ */
+function system_default_region($theme) {
+ $regions = array_keys(system_region_list($theme, REGIONS_VISIBLE));
+ return isset($regions[0]) ? $regions[0] : '';
+}
+
+/**
+ * Add default buttons to a form and set its prefix.
+ *
+ * @param $form
+ * An associative array containing the structure of the form.
+ *
+ * @return
+ * The form structure.
+ *
+ * @see system_settings_form_submit()
+ * @ingroup forms
+ */
+function system_settings_form($form) {
+ $form['actions']['#type'] = 'actions';
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save configuration'));
+
+ if (!empty($_POST) && form_get_errors()) {
+ drupal_set_message(t('The settings have not been saved because of the errors.'), 'error');
+ }
+ $form['#submit'][] = 'system_settings_form_submit';
+ // By default, render the form using theme_system_settings_form().
+ if (!isset($form['#theme'])) {
+ $form['#theme'] = 'system_settings_form';
+ }
+ return $form;
+}
+
+/**
+ * Execute the system_settings_form.
+ *
+ * If you want node type configure style handling of your checkboxes,
+ * add an array_filter value to your form.
+ */
+function system_settings_form_submit($form, &$form_state) {
+ // Exclude unnecessary elements.
+ form_state_values_clean($form_state);
+
+ foreach ($form_state['values'] as $key => $value) {
+ if (is_array($value) && isset($form_state['values']['array_filter'])) {
+ $value = array_keys(array_filter($value));
+ }
+ variable_set($key, $value);
+ }
+
+ drupal_set_message(t('The configuration options have been saved.'));
+}
+
+/**
+ * Helper function to sort requirements.
+ */
+function _system_sort_requirements($a, $b) {
+ if (!isset($a['weight'])) {
+ if (!isset($b['weight'])) {
+ return strcmp($a['title'], $b['title']);
+ }
+ return -$b['weight'];
+ }
+ return isset($b['weight']) ? $a['weight'] - $b['weight'] : $a['weight'];
+}
+
+/**
+ * Generates a form array for a confirmation form.
+ *
+ * This function returns a complete form array for confirming an action. The
+ * form contains a confirm button as well as a cancellation link that allows a
+ * user to abort the action.
+ *
+ * If the submit handler for a form that implements confirm_form() is invoked,
+ * the user successfully confirmed the action. You should never directly
+ * inspect $_POST to see if an action was confirmed.
+ *
+ * Note - if the parameters $question, $description, $yes, or $no could contain
+ * any user input (such as node titles or taxonomy terms), it is the
+ * responsibility of the code calling confirm_form() to sanitize them first with
+ * a function like check_plain() or filter_xss().
+ *
+ * @param $form
+ * Additional elements to add to the form. These can be regular form elements,
+ * #value elements, etc., and their values will be available to the submit
+ * handler.
+ * @param $question
+ * The question to ask the user (e.g. "Are you sure you want to delete the
+ * block <em>foo</em>?"). The page title will be set to this value.
+ * @param $path
+ * The page to go to if the user cancels the action. This can be either:
+ * - A string containing a Drupal path.
+ * - An associative array with a 'path' key. Additional array values are
+ * passed as the $options parameter to l().
+ * If the 'destination' query parameter is set in the URL when viewing a
+ * confirmation form, that value will be used instead of $path.
+ * @param $description
+ * Additional text to display. Defaults to t('This action cannot be undone.').
+ * @param $yes
+ * A caption for the button that confirms the action (e.g. "Delete",
+ * "Replace", ...). Defaults to t('Confirm').
+ * @param $no
+ * A caption for the link which cancels the action (e.g. "Cancel"). Defaults
+ * to t('Cancel').
+ * @param $name
+ * The internal name used to refer to the confirmation item.
+ *
+ * @return
+ * The form array.
+ */
+function confirm_form($form, $question, $path, $description = NULL, $yes = NULL, $no = NULL, $name = 'confirm') {
+ $description = isset($description) ? $description : t('This action cannot be undone.');
+
+ // Prepare cancel link.
+ if (isset($_GET['destination'])) {
+ $options = drupal_parse_url(urldecode($_GET['destination']));
+ }
+ elseif (is_array($path)) {
+ $options = $path;
+ }
+ else {
+ $options = array('path' => $path);
+ }
+
+ drupal_set_title($question, PASS_THROUGH);
+
+ $form['#attributes']['class'][] = 'confirmation';
+ $form['description'] = array('#markup' => $description);
+ $form[$name] = array('#type' => 'hidden', '#value' => 1);
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => $yes ? $yes : t('Confirm'),
+ );
+ $form['actions']['cancel'] = array(
+ '#type' => 'link',
+ '#title' => $no ? $no : t('Cancel'),
+ '#href' => $options['path'],
+ '#options' => $options,
+ );
+ // By default, render the form using theme_confirm_form().
+ if (!isset($form['#theme'])) {
+ $form['#theme'] = 'confirm_form';
+ }
+ return $form;
+}
+
+/**
+ * Determines if the current user is in compact mode.
+ *
+ * @return
+ * TRUE when in compact mode, FALSE when in expanded mode.
+ */
+function system_admin_compact_mode() {
+ // PHP converts dots into underscores in cookie names to avoid problems with
+ // its parser, so we use a converted cookie name.
+ return isset($_COOKIE['Drupal_visitor_admin_compact_mode']) ? $_COOKIE['Drupal_visitor_admin_compact_mode'] : variable_get('admin_compact_mode', FALSE);
+}
+
+/**
+ * Menu callback; Sets whether the admin menu is in compact mode or not.
+ *
+ * @param $mode
+ * Valid values are 'on' and 'off'.
+ */
+function system_admin_compact_page($mode = 'off') {
+ user_cookie_save(array('admin_compact_mode' => ($mode == 'on')));
+ drupal_goto();
+}
+
+/**
+ * Generate a list of tasks offered by a specified module.
+ *
+ * @param $module
+ * Module name.
+ * @param $info
+ * The module's information, as provided by system_get_info().
+ *
+ * @return
+ * An array of task links.
+ */
+function system_get_module_admin_tasks($module, $info) {
+ $links = &drupal_static(__FUNCTION__);
+
+ if (!isset($links)) {
+ $links = array();
+ $query = db_select('menu_links', 'ml', array('fetch' => PDO::FETCH_ASSOC));
+ $query->join('menu_router', 'm', 'm.path = ml.router_path');
+ $query
+ ->fields('ml')
+ // Weight should be taken from {menu_links}, not {menu_router}.
+ ->fields('m', array_diff(drupal_schema_fields_sql('menu_router'), array('weight')))
+ ->condition('ml.link_path', 'admin/%', 'LIKE')
+ ->condition('ml.hidden', 0, '>=')
+ ->condition('ml.module', 'system')
+ ->condition('m.number_parts', 1, '>')
+ ->condition('m.page_callback', 'system_admin_menu_block_page', '<>');
+ foreach ($query->execute() as $link) {
+ _menu_link_translate($link);
+ if ($link['access']) {
+ $links[$link['router_path']] = $link;
+ }
+ }
+ }
+
+ $admin_tasks = array();
+ $titles = array();
+ if ($menu = module_invoke($module, 'menu')) {
+ foreach ($menu as $path => $item) {
+ if (isset($links[$path])) {
+ $task = $links[$path];
+ // The link description, either derived from 'description' in
+ // hook_menu() or customized via menu module is used as title attribute.
+ if (!empty($task['localized_options']['attributes']['title'])) {
+ $task['description'] = $task['localized_options']['attributes']['title'];
+ unset($task['localized_options']['attributes']['title']);
+ }
+
+ // Check the admin tasks for duplicate names. If one is found,
+ // append the parent menu item's title to differentiate.
+ $duplicate_path = array_search($task['title'], $titles);
+ if ($duplicate_path !== FALSE) {
+ if ($parent = menu_link_load($task['plid'])) {
+ // Append the parent item's title to this task's title.
+ $task['title'] = t('@original_title (@parent_title)', array('@original_title' => $task['title'], '@parent_title' => $parent['title']));
+ }
+ if ($parent = menu_link_load($admin_tasks[$duplicate_path]['plid'])) {
+ // Append the parent item's title to the duplicated task's title.
+ // We use $links[$duplicate_path] in case there are triplicates.
+ $admin_tasks[$duplicate_path]['title'] = t('@original_title (@parent_title)', array('@original_title' => $links[$duplicate_path]['title'], '@parent_title' => $parent['title']));
+ }
+ }
+ else {
+ $titles[$path] = $task['title'];
+ }
+
+ $admin_tasks[$path] = $task;
+ }
+ }
+ }
+
+ // Append link for permissions.
+ if (module_hook($module, 'permission')) {
+ $item = menu_get_item('admin/people/permissions');
+ if (!empty($item['access'])) {
+ $item['link_path'] = $item['href'];
+ $item['title'] = t('Configure @module permissions', array('@module' => $info['name']));
+ unset($item['description']);
+ $item['localized_options']['fragment'] = 'module-' . $module;
+ $admin_tasks["admin/people/permissions#module-$module"] = $item;
+ }
+ }
+
+ return $admin_tasks;
+}
+
+/**
+ * Implements hook_cron().
+ *
+ * Remove older rows from flood and batch table. Remove old temporary files.
+ */
+function system_cron() {
+ // Cleanup the flood.
+ db_delete('flood')
+ ->condition('expiration', REQUEST_TIME, '<')
+ ->execute();
+
+ // Remove temporary files that are older than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
+ // Use separate placeholders for the status to avoid a bug in some versions
+ // of PHP. See http://drupal.org/node/352956.
+ $result = db_query('SELECT fid FROM {file_managed} WHERE status <> :permanent AND timestamp < :timestamp', array(
+ ':permanent' => FILE_STATUS_PERMANENT,
+ ':timestamp' => REQUEST_TIME - DRUPAL_MAXIMUM_TEMP_FILE_AGE
+ ));
+ foreach ($result as $row) {
+ if ($file = file_load($row->fid)) {
+ $references = file_usage_list($file);
+ if (empty($references)) {
+ if (!file_delete($file)) {
+ watchdog('file system', 'Could not delete temporary file "%path" during garbage collection', array('%path' => $file->uri), WATCHDOG_ERROR);
+ }
+ }
+ else {
+ watchdog('file system', 'Did not delete temporary file "%path" during garbage collection because it is in use by the following modules: %modules.', array('%path' => $file->uri, '%modules' => implode(', ', array_keys($references))), WATCHDOG_INFO);
+ }
+ }
+ }
+
+ $core = array('cache', 'path', 'filter', 'page', 'form', 'menu');
+ $cache_bins = array_merge(module_invoke_all('flush_caches'), $core);
+ foreach ($cache_bins as $bin) {
+ cache($bin)->expire();
+ }
+
+ // Cleanup the batch table and the queue for failed batches.
+ db_delete('batch')
+ ->condition('timestamp', REQUEST_TIME - 864000, '<')
+ ->execute();
+ db_delete('queue')
+ ->condition('created', REQUEST_TIME - 864000, '<')
+ ->condition('name', 'drupal_batch:%', 'LIKE')
+ ->execute();
+
+ // Reset expired items in the default queue implementation table. If that's
+ // not used, this will simply be a no-op.
+ db_update('queue')
+ ->fields(array(
+ 'expire' => 0,
+ ))
+ ->condition('expire', 0, '<>')
+ ->condition('expire', REQUEST_TIME, '<')
+ ->execute();
+}
+
+/**
+ * Implements hook_flush_caches().
+ */
+function system_flush_caches() {
+ // Rebuild list of date formats.
+ system_date_formats_rebuild();
+ // Reset the menu static caches.
+ menu_reset_static_cache();
+}
+
+/**
+ * Implements hook_action_info().
+ */
+function system_action_info() {
+ return array(
+ 'system_message_action' => array(
+ 'type' => 'system',
+ 'label' => t('Display a message to the user'),
+ 'configurable' => TRUE,
+ 'triggers' => array('any'),
+ ),
+ 'system_send_email_action' => array(
+ 'type' => 'system',
+ 'label' => t('Send e-mail'),
+ 'configurable' => TRUE,
+ 'triggers' => array('any'),
+ ),
+ 'system_block_ip_action' => array(
+ 'type' => 'user',
+ 'label' => t('Ban IP address of current user'),
+ 'configurable' => FALSE,
+ 'triggers' => array('any'),
+ ),
+ 'system_goto_action' => array(
+ 'type' => 'system',
+ 'label' => t('Redirect to URL'),
+ 'configurable' => TRUE,
+ 'triggers' => array('any'),
+ ),
+ );
+}
+
+/**
+ * Return a form definition so the Send email action can be configured.
+ *
+ * @param $context
+ * Default values (if we are editing an existing action instance).
+ *
+ * @return
+ * Form definition.
+ *
+ * @see system_send_email_action_validate()
+ * @see system_send_email_action_submit()
+ */
+function system_send_email_action_form($context) {
+ // Set default values for form.
+ if (!isset($context['recipient'])) {
+ $context['recipient'] = '';
+ }
+ if (!isset($context['subject'])) {
+ $context['subject'] = '';
+ }
+ if (!isset($context['message'])) {
+ $context['message'] = '';
+ }
+
+ $form['recipient'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Recipient'),
+ '#default_value' => $context['recipient'],
+ '#maxlength' => '254',
+ '#description' => t('The e-mail address to which the message should be sent OR enter [node:author:mail], [comment:author:mail], etc. if you would like to send an e-mail to the author of the original post.'),
+ );
+ $form['subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => $context['subject'],
+ '#maxlength' => '254',
+ '#description' => t('The subject of the message.'),
+ );
+ $form['message'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Message'),
+ '#default_value' => $context['message'],
+ '#cols' => '80',
+ '#rows' => '20',
+ '#description' => t('The message that should be sent. You may include placeholders like [node:title], [user:name], and [comment:body] to represent data that will be different each time message is sent. Not all placeholders will be available in all contexts.'),
+ );
+ return $form;
+}
+
+/**
+ * Validate system_send_email_action form submissions.
+ */
+function system_send_email_action_validate($form, $form_state) {
+ $form_values = $form_state['values'];
+ // Validate the configuration form.
+ if (!valid_email_address($form_values['recipient']) && strpos($form_values['recipient'], ':mail') === FALSE) {
+ // We want the literal %author placeholder to be emphasized in the error message.
+ form_set_error('recipient', t('Enter a valid email address or use a token e-mail address such as %author.', array('%author' => '[node:author:mail]')));
+ }
+}
+
+/**
+ * Process system_send_email_action form submissions.
+ */
+function system_send_email_action_submit($form, $form_state) {
+ $form_values = $form_state['values'];
+ // Process the HTML form to store configuration. The keyed array that
+ // we return will be serialized to the database.
+ $params = array(
+ 'recipient' => $form_values['recipient'],
+ 'subject' => $form_values['subject'],
+ 'message' => $form_values['message'],
+ );
+ return $params;
+}
+
+/**
+ * Sends an e-mail message.
+ *
+ * @param object $entity
+ * An optional node object, which will be added as $context['node'] if
+ * provided.
+ * @param array $context
+ * Array with the following elements:
+ * - 'recipient': E-mail message recipient. This will be passed through
+ * token_replace().
+ * - 'subject': The subject of the message. This will be passed through
+ * token_replace().
+ * - 'message': The message to send. This will be passed through
+ * token_replace().
+ * - Other elements will be used as the data for token replacement.
+ *
+ * @ingroup actions
+ */
+function system_send_email_action($entity, $context) {
+ if (empty($context['node'])) {
+ $context['node'] = $entity;
+ }
+
+ $recipient = token_replace($context['recipient'], $context);
+
+ // If the recipient is a registered user with a language preference, use
+ // the recipient's preferred language. Otherwise, use the system default
+ // language.
+ $recipient_account = user_load_by_mail($recipient);
+ if ($recipient_account) {
+ $language = user_preferred_language($recipient_account);
+ }
+ else {
+ $language = language_default();
+ }
+ $params = array('context' => $context);
+
+ if (drupal_mail('system', 'action_send_email', $recipient, $language, $params)) {
+ watchdog('action', 'Sent email to %recipient', array('%recipient' => $recipient));
+ }
+ else {
+ watchdog('error', 'Unable to send email to %recipient', array('%recipient' => $recipient));
+ }
+}
+
+/**
+ * Implements hook_mail().
+ */
+function system_mail($key, &$message, $params) {
+ $context = $params['context'];
+
+ $subject = token_replace($context['subject'], $context);
+ $body = token_replace($context['message'], $context);
+
+ $message['subject'] .= str_replace(array("\r", "\n"), '', $subject);
+ $message['body'][] = $body;
+}
+
+function system_message_action_form($context) {
+ $form['message'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Message'),
+ '#default_value' => isset($context['message']) ? $context['message'] : '',
+ '#required' => TRUE,
+ '#rows' => '8',
+ '#description' => t('The message to be displayed to the current user. You may include placeholders like [node:title], [user:name], and [comment:body] to represent data that will be different each time message is sent. Not all placeholders will be available in all contexts.'),
+ );
+ return $form;
+}
+
+function system_message_action_submit($form, $form_state) {
+ return array('message' => $form_state['values']['message']);
+}
+
+/**
+ * Sends a message to the current user's screen.
+ *
+ * @param object $entity
+ * An optional node object, which will be added as $context['node'] if
+ * provided.
+ * @param array $context
+ * Array with the following elements:
+ * - 'message': The message to send. This will be passed through
+ * token_replace().
+ * - Other elements will be used as the data for token replacement in
+ * the message.
+ *
+ * @ingroup actions
+ */
+function system_message_action(&$entity, $context = array()) {
+ if (empty($context['node'])) {
+ $context['node'] = $entity;
+ }
+
+ $context['message'] = token_replace(filter_xss_admin($context['message']), $context);
+ drupal_set_message($context['message']);
+}
+
+/**
+ * Settings form for system_goto_action().
+ */
+function system_goto_action_form($context) {
+ $form['url'] = array(
+ '#type' => 'textfield',
+ '#title' => t('URL'),
+ '#description' => t('The URL to which the user should be redirected. This can be an internal URL like node/1234 or an external URL like http://drupal.org.'),
+ '#default_value' => isset($context['url']) ? $context['url'] : '',
+ '#required' => TRUE,
+ );
+ return $form;
+}
+
+function system_goto_action_submit($form, $form_state) {
+ return array(
+ 'url' => $form_state['values']['url']
+ );
+}
+
+/**
+ * Redirects to a different URL.
+ *
+ * @param $entity
+ * Ignored.
+ * @param array $context
+ * Array with the following elements:
+ * - 'url': URL to redirect to. This will be passed through
+ * token_replace().
+ * - Other elements will be used as the data for token replacement.
+ *
+ * @ingroup actions
+ */
+function system_goto_action($entity, $context) {
+ drupal_goto(token_replace($context['url'], $context));
+}
+
+/**
+ * Blocks the current user's IP address.
+ *
+ * @ingroup actions
+ */
+function system_block_ip_action() {
+ $ip = ip_address();
+ db_insert('blocked_ips')
+ ->fields(array('ip' => $ip))
+ ->execute();
+ watchdog('action', 'Banned IP address %ip', array('%ip' => $ip));
+}
+
+/**
+ * Generate an array of time zones and their local time&date.
+ *
+ * @param $blank
+ * If evaluates true, prepend an empty time zone option to the array.
+ */
+function system_time_zones($blank = NULL) {
+ $zonelist = timezone_identifiers_list();
+ $zones = $blank ? array('' => t('- None selected -')) : array();
+ foreach ($zonelist as $zone) {
+ // Because many time zones exist in PHP only for backward compatibility
+ // reasons and should not be used, the list is filtered by a regular
+ // expression.
+ if (preg_match('!^((Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/|UTC$)!', $zone)) {
+ $zones[$zone] = t('@zone: @date', array('@zone' => t(str_replace('_', ' ', $zone)), '@date' => format_date(REQUEST_TIME, 'custom', variable_get('date_format_long', 'l, F j, Y - H:i') . ' O', $zone)));
+ }
+ }
+ // Sort the translated time zones alphabetically.
+ asort($zones);
+ return $zones;
+}
+
+/**
+ * Checks whether the server is capable of issuing HTTP requests.
+ *
+ * The function sets the drupal_http_request_fail system variable to TRUE if
+ * drupal_http_request() does not work and then the system status report page
+ * will contain an error.
+ *
+ * @return
+ * TRUE if this installation can issue HTTP requests.
+ */
+function system_check_http_request() {
+ // Try to get the content of the front page via drupal_http_request().
+ $result = drupal_http_request(url('', array('absolute' => TRUE)), array('max_redirects' => 0));
+ // We only care that we get a http response - this means that Drupal
+ // can make a http request.
+ $works = isset($result->code) && ($result->code >= 100) && ($result->code < 600);
+ variable_set('drupal_http_request_fails', !$works);
+ return $works;
+}
+
+/**
+ * Menu callback; Retrieve a JSON object containing a suggested time zone name.
+ */
+function system_timezone($abbreviation = '', $offset = -1, $is_daylight_saving_time = NULL) {
+ // An abbreviation of "0" passed in the callback arguments should be
+ // interpreted as the empty string.
+ $abbreviation = $abbreviation ? $abbreviation : '';
+ $timezone = timezone_name_from_abbr($abbreviation, intval($offset), $is_daylight_saving_time);
+ drupal_json_output($timezone);
+}
+
+/**
+ * Returns HTML for the Powered by Drupal text.
+ *
+ * @ingroup themeable
+ */
+function theme_system_powered_by() {
+ return '<span>' . t('Powered by <a href="@poweredby">Drupal</a>', array('@poweredby' => 'http://drupal.org')) . '</span>';
+}
+
+/**
+ * Returns HTML for a link to show or hide inline help descriptions.
+ *
+ * @ingroup themeable
+ */
+function theme_system_compact_link() {
+ $output = '<div class="compact-link">';
+ if (system_admin_compact_mode()) {
+ $output .= l(t('Show descriptions'), 'admin/compact/off', array('attributes' => array('title' => t('Expand layout to include descriptions.')), 'query' => drupal_get_destination()));
+ }
+ else {
+ $output .= l(t('Hide descriptions'), 'admin/compact/on', array('attributes' => array('title' => t('Compress layout by hiding descriptions.')), 'query' => drupal_get_destination()));
+ }
+ $output .= '</div>';
+
+ return $output;
+}
+
+/**
+ * Implements hook_image_toolkits().
+ */
+function system_image_toolkits() {
+ include_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'system') . '/' . 'image.gd.inc';
+ return array(
+ 'gd' => array(
+ 'title' => t('GD2 image manipulation toolkit'),
+ 'available' => function_exists('image_gd_check_settings') && image_gd_check_settings(),
+ ),
+ );
+}
+
+/**
+ * Attempts to get a file using drupal_http_request and to store it locally.
+ *
+ * @param $url
+ * The URL of the file to grab.
+ *
+ * @param $destination
+ * Stream wrapper URI specifying where the file should be placed. If a
+ * directory path is provided, the file is saved into that directory under
+ * its original name. If the path contains a filename as well, that one will
+ * be used instead.
+ * If this value is omitted, the site's default files scheme will be used,
+ * usually "public://".
+ *
+ * @param $managed boolean
+ * If this is set to TRUE, the file API hooks will be invoked and the file is
+ * registered in the database.
+ *
+ * @param $replace boolean
+ * Replace behavior when the destination file already exists:
+ * - FILE_EXISTS_REPLACE: Replace the existing file.
+ * - FILE_EXISTS_RENAME: Append _{incrementing number} until the filename is
+ * unique.
+ * - FILE_EXISTS_ERROR: Do nothing and return FALSE.
+ *
+ * @return
+ * On success the location the file was saved to, FALSE on failure.
+ */
+function system_retrieve_file($url, $destination = NULL, $managed = FALSE, $replace = FILE_EXISTS_RENAME) {
+ $parsed_url = parse_url($url);
+ if (!isset($destination)) {
+ $path = file_build_uri(basename($parsed_url['path']));
+ }
+ else {
+ if (is_dir(drupal_realpath($destination))) {
+ // Prevent URIs with triple slashes when glueing parts together.
+ $path = str_replace('///', '//', "$destination/") . basename($parsed_url['path']);
+ }
+ else {
+ $path = $destination;
+ }
+ }
+ $result = drupal_http_request($url);
+ if ($result->code != 200) {
+ drupal_set_message(t('HTTP error @errorcode occurred when trying to fetch @remote.', array('@errorcode' => $result->code, '@remote' => $url)), 'error');
+ return FALSE;
+ }
+ $local = $managed ? file_save_data($result->data, $path, $replace) : file_unmanaged_save_data($result->data, $path, $replace);
+ if (!$local) {
+ drupal_set_message(t('@remote could not be saved to @path.', array('@remote' => $url, '@path' => $path)), 'error');
+ }
+
+ return $local;
+}
+
+/**
+ * Implements hook_page_alter().
+ */
+function system_page_alter(&$page) {
+ // Find all non-empty page regions, and add a theme wrapper function that
+ // allows them to be consistently themed.
+ $regions = system_region_list($GLOBALS['theme']);
+ foreach (array_keys($regions) as $region) {
+ if (!empty($page[$region])) {
+ $page[$region]['#theme_wrappers'][] = 'region';
+ $page[$region]['#region'] = $region;
+ }
+ }
+}
+
+/**
+ * Run the automated cron if enabled.
+ */
+function system_run_automated_cron() {
+ // If the site is not fully installed, suppress the automated cron run.
+ // Otherwise it could be triggered prematurely by Ajax requests during
+ // installation.
+ if (($threshold = variable_get('cron_safe_threshold', DRUPAL_CRON_DEFAULT_THRESHOLD)) > 0 && variable_get('install_task') == 'done') {
+ $cron_last = variable_get('cron_last', NULL);
+ if (!isset($cron_last) || (REQUEST_TIME - $cron_last > $threshold)) {
+ drupal_cron_run();
+ }
+ }
+}
+
+/**
+ * Gets the list of available date types and attributes.
+ *
+ * @param $type
+ * (optional) The date type name.
+ *
+ * @return
+ * An associative array of date type information keyed by the date type name.
+ * Each date type information array has the following elements:
+ * - type: The machine-readable name of the date type.
+ * - title: The human-readable name of the date type.
+ * - locked: A boolean indicating whether or not this date type should be
+ * configurable from the user interface.
+ * - module: The name of the module that defined this date type in its
+ * hook_date_format_types(). An empty string if the date type was
+ * user-defined.
+ * - is_new: A boolean indicating whether or not this date type is as of yet
+ * unsaved in the database.
+ * If $type was defined, only a single associative array with the above
+ * elements is returned.
+ */
+function system_get_date_types($type = NULL) {
+ $types = &drupal_static(__FUNCTION__);
+
+ if (!isset($types)) {
+ $types = _system_date_format_types_build();
+ }
+
+ return $type ? (isset($types[$type]) ? $types[$type] : FALSE) : $types;
+}
+
+/**
+ * Implements hook_date_format_types().
+ */
+function system_date_format_types() {
+ return array(
+ 'long' => t('Long'),
+ 'medium' => t('Medium'),
+ 'short' => t('Short'),
+ );
+}
+
+/**
+ * Implements hook_date_formats().
+ */
+function system_date_formats() {
+ include_once DRUPAL_ROOT . '/core/includes/date.inc';
+ return system_default_date_formats();
+}
+
+/**
+ * Gets the list of defined date formats and attributes.
+ *
+ * @param $type
+ * (optional) The date type name.
+ *
+ * @return
+ * An associative array of date formats. The top-level keys are the names of
+ * the date types that the date formats belong to. The values are in turn
+ * associative arrays keyed by the format string, with the following keys:
+ * - dfid: The date format ID.
+ * - format: The format string.
+ * - type: The machine-readable name of the date type.
+ * - locales: An array of language codes. This can include both 2 character
+ * language codes like 'en and 'fr' and 5 character language codes like
+ * 'en-gb' and 'en-us'.
+ * - locked: A boolean indicating whether or not this date type should be
+ * configurable from the user interface.
+ * - module: The name of the module that defined this date format in its
+ * hook_date_formats(). An empty string if the format was user-defined.
+ * - is_new: A boolean indicating whether or not this date type is as of yet
+ * unsaved in the database.
+ * If $type was defined, only the date formats associated with the given date
+ * type are returned, in a single associative array keyed by format string.
+ */
+function system_get_date_formats($type = NULL) {
+ $date_formats = &drupal_static(__FUNCTION__);
+
+ if (!isset($date_formats)) {
+ $date_formats = _system_date_formats_build();
+ }
+
+ return $type ? (isset($date_formats[$type]) ? $date_formats[$type] : FALSE) : $date_formats;
+}
+
+/**
+ * Gets the format details for a particular format ID.
+ *
+ * @param $dfid
+ * A date format ID.
+ *
+ * @return
+ * A date format object with the following properties:
+ * - dfid: The date format ID.
+ * - format: The date format string.
+ * - type: The name of the date type.
+ * - locked: Whether the date format can be changed or not.
+ */
+function system_get_date_format($dfid) {
+ return db_query('SELECT df.dfid, df.format, df.type, df.locked FROM {date_formats} df WHERE df.dfid = :dfid', array(':dfid' => $dfid))->fetch();
+}
+
+/**
+ * Resets the database cache of date formats and saves all new date formats.
+ */
+function system_date_formats_rebuild() {
+ drupal_static_reset('system_get_date_formats');
+ $date_formats = system_get_date_formats(NULL);
+
+ foreach ($date_formats as $type => $formats) {
+ foreach ($formats as $format => $info) {
+ system_date_format_save($info);
+ }
+ }
+
+ // Rebuild configured date formats locale list.
+ drupal_static_reset('system_date_format_locale');
+ system_date_format_locale();
+
+ _system_date_formats_build();
+}
+
+/**
+ * Gets the appropriate date format string for a date type and locale.
+ *
+ * @param $langcode
+ * (optional) Language code for the current locale. This can be a 2 character
+ * language code like 'en' and 'fr' or a 5 character language code like
+ * 'en-gb' and 'en-us'.
+ * @param $type
+ * (optional) The date type name.
+ *
+ * @return
+ * If $type and $langcode are specified, returns the corresponding date format
+ * string. If only $langcode is specified, returns an array of all date
+ * format strings for that locale, keyed by the date type. If neither is
+ * specified, or if no matching formats are found, returns FALSE.
+ */
+function system_date_format_locale($langcode = NULL, $type = NULL) {
+ $formats = &drupal_static(__FUNCTION__);
+
+ if (empty($formats)) {
+ $formats = array();
+ $result = db_query("SELECT format, type, language FROM {date_format_locale}");
+ foreach ($result as $record) {
+ if (!isset($formats[$record->language])) {
+ $formats[$record->language] = array();
+ }
+ $formats[$record->language][$record->type] = $record->format;
+ }
+ }
+
+ if ($type && $langcode && !empty($formats[$langcode][$type])) {
+ return $formats[$langcode][$type];
+ }
+ elseif ($langcode && !empty($formats[$langcode])) {
+ return $formats[$langcode];
+ }
+
+ return FALSE;
+}
+
+/**
+ * Builds and returns information about available date types.
+ *
+ * @return
+ * An associative array of date type information keyed by name. Each date type
+ * information array has the following elements:
+ * - type: The machine-readable name of the date type.
+ * - title: The human-readable name of the date type.
+ * - locked: A boolean indicating whether or not this date type should be
+ * configurable from the user interface.
+ * - module: The name of the module that defined this format in its
+ * hook_date_format_types(). An empty string if the format was user-defined.
+ * - is_new: A boolean indicating whether or not this date type is as of yet
+ * unsaved in the database.
+ */
+function _system_date_format_types_build() {
+ $types = array();
+
+ // Get list of modules that implement hook_date_format_types().
+ $modules = module_implements('date_format_types');
+
+ foreach ($modules as $module) {
+ $module_types = module_invoke($module, 'date_format_types');
+ foreach ($module_types as $module_type => $type_title) {
+ $type = array();
+ $type['module'] = $module;
+ $type['type'] = $module_type;
+ $type['title'] = $type_title;
+ $type['locked'] = 1;
+ // Will be over-ridden later if in the db.
+ $type['is_new'] = TRUE;
+ $types[$module_type] = $type;
+ }
+ }
+
+ // Get custom formats added to the database by the end user.
+ $result = db_query('SELECT dft.type, dft.title, dft.locked FROM {date_format_type} dft ORDER BY dft.title');
+ foreach ($result as $record) {
+ if (!isset($types[$record->type])) {
+ $type = array();
+ $type['is_new'] = FALSE;
+ $type['module'] = '';
+ $type['type'] = $record->type;
+ $type['title'] = $record->title;
+ $type['locked'] = $record->locked;
+ $types[$record->type] = $type;
+ }
+ else {
+ $type = array();
+ $type['is_new'] = FALSE; // Over-riding previous setting.
+ $types[$record->type] = array_merge($types[$record->type], $type);
+ }
+ }
+
+ // Allow other modules to modify these date types.
+ drupal_alter('date_format_types', $types);
+
+ return $types;
+}
+
+/**
+ * Builds and returns information about available date formats.
+ *
+ * @return
+ * An associative array of date formats. The top-level keys are the names of
+ * the date types that the date formats belong to. The values are in turn
+ * associative arrays keyed by format with the following keys:
+ * - dfid: The date format ID.
+ * - format: The PHP date format string.
+ * - type: The machine-readable name of the date type the format belongs to.
+ * - locales: An array of language codes. This can include both 2 character
+ * language codes like 'en and 'fr' and 5 character language codes like
+ * 'en-gb' and 'en-us'.
+ * - locked: A boolean indicating whether or not this date type should be
+ * configurable from the user interface.
+ * - module: The name of the module that defined this format in its
+ * hook_date_formats(). An empty string if the format was user-defined.
+ * - is_new: A boolean indicating whether or not this date type is as of yet
+ * unsaved in the database.
+ */
+function _system_date_formats_build() {
+ $date_formats = array();
+
+ // First handle hook_date_format_types().
+ $types = _system_date_format_types_build();
+ foreach ($types as $type => $info) {
+ system_date_format_type_save($info);
+ }
+
+ // Get formats supplied by various contrib modules.
+ $module_formats = module_invoke_all('date_formats');
+
+ foreach ($module_formats as $module_format) {
+ // System types are locked.
+ $module_format['locked'] = 1;
+ // If no date type is specified, assign 'custom'.
+ if (!isset($module_format['type'])) {
+ $module_format['type'] = 'custom';
+ }
+ if (!in_array($module_format['type'], array_keys($types))) {
+ continue;
+ }
+ if (!isset($date_formats[$module_format['type']])) {
+ $date_formats[$module_format['type']] = array();
+ }
+
+ // If another module already set this format, merge in the new settings.
+ if (isset($date_formats[$module_format['type']][$module_format['format']])) {
+ $date_formats[$module_format['type']][$module_format['format']] = array_merge_recursive($date_formats[$module_format['type']][$module_format['format']], $module_format);
+ }
+ else {
+ // This setting will be overridden later if it already exists in the db.
+ $module_format['is_new'] = TRUE;
+ $date_formats[$module_format['type']][$module_format['format']] = $module_format;
+ }
+ }
+
+ // Get custom formats added to the database by the end user.
+ $result = db_query('SELECT df.dfid, df.format, df.type, df.locked, dfl.language FROM {date_formats} df LEFT JOIN {date_format_type} dft ON df.type = dft.type LEFT JOIN {date_format_locale} dfl ON df.format = dfl.format AND df.type = dfl.type ORDER BY df.type, df.format');
+ foreach ($result as $record) {
+ // If this date type isn't set, initialise the array.
+ if (!isset($date_formats[$record->type])) {
+ $date_formats[$record->type] = array();
+ }
+ $format = (array) $record;
+ $format['is_new'] = FALSE; // It's in the db, so override this setting.
+ // If this format not already present, add it to the array.
+ if (!isset($date_formats[$record->type][$record->format])) {
+ $format['module'] = '';
+ $format['locales'] = array($record->language);
+ $date_formats[$record->type][$record->format] = $format;
+ }
+ // Format already present, so merge in settings.
+ else {
+ if (!empty($record->language)) {
+ $format['locales'] = array_merge($date_formats[$record->type][$record->format]['locales'], array($record->language));
+ }
+ $date_formats[$record->type][$record->format] = array_merge($date_formats[$record->type][$record->format], $format);
+ }
+ }
+
+ // Allow other modules to modify these formats.
+ drupal_alter('date_formats', $date_formats);
+
+ return $date_formats;
+}
+
+/**
+ * Saves a date type to the database.
+ *
+ * @param $type
+ * A date type array containing the following keys:
+ * - type: The machine-readable name of the date type.
+ * - title: The human-readable name of the date type.
+ * - locked: A boolean indicating whether or not this date type should be
+ * configurable from the user interface.
+ * - is_new: A boolean indicating whether or not this date type is as of yet
+ * unsaved in the database.
+ */
+function system_date_format_type_save($type) {
+ $info = array();
+ $info['type'] = $type['type'];
+ $info['title'] = $type['title'];
+ $info['locked'] = $type['locked'];
+
+ // Update date_format table.
+ if (!empty($type['is_new'])) {
+ drupal_write_record('date_format_type', $info);
+ }
+ else {
+ drupal_write_record('date_format_type', $info, 'type');
+ }
+}
+
+/**
+ * Deletes a date type from the database.
+ *
+ * @param $type
+ * The machine-readable name of the date type.
+ */
+function system_date_format_type_delete($type) {
+ db_delete('date_formats')
+ ->condition('type', $type)
+ ->execute();
+ db_delete('date_format_type')
+ ->condition('type', $type)
+ ->execute();
+ db_delete('date_format_locale')
+ ->condition('type', $type)
+ ->execute();
+}
+
+/**
+ * Saves a date format to the database.
+ *
+ * @param $date_format
+ * A date format array containing the following keys:
+ * - type: The name of the date type this format is associated with.
+ * - format: The PHP date format string.
+ * - locked: A boolean indicating whether or not this format should be
+ * configurable from the user interface.
+ * @param $dfid
+ * If set, replace the existing date format having this ID with the
+ * information specified in $date_format.
+ *
+ * @see system_get_date_types()
+ * @see http://php.net/date
+ */
+function system_date_format_save($date_format, $dfid = 0) {
+ $info = array();
+ $info['dfid'] = $dfid;
+ $info['type'] = $date_format['type'];
+ $info['format'] = $date_format['format'];
+ $info['locked'] = $date_format['locked'];
+
+ // Update date_format table.
+ if (!empty($date_format['is_new'])) {
+ drupal_write_record('date_formats', $info);
+ }
+ else {
+ $keys = ($dfid ? array('dfid') : array('format', 'type'));
+ drupal_write_record('date_formats', $info, $keys);
+ }
+
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+
+ $locale_format = array();
+ $locale_format['type'] = $date_format['type'];
+ $locale_format['format'] = $date_format['format'];
+
+ // Check if the suggested language codes are configured and enabled.
+ if (!empty($date_format['locales'])) {
+ foreach ($date_format['locales'] as $langcode) {
+ // Only proceed if language is enabled.
+ if (isset($languages[$langcode])) {
+ $is_existing = (bool) db_query_range('SELECT 1 FROM {date_format_locale} WHERE type = :type AND language = :language', 0, 1, array(':type' => $date_format['type'], ':language' => $langcode))->fetchField();
+ if (!$is_existing) {
+ $locale_format['language'] = $langcode;
+ drupal_write_record('date_format_locale', $locale_format);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Deletes a date format from the database.
+ *
+ * @param $dfid
+ * The date format ID.
+ */
+function system_date_format_delete($dfid) {
+ db_delete('date_formats')
+ ->condition('dfid', $dfid)
+ ->execute();
+}
+
+/**
+ * Implements hook_archiver_info().
+ */
+function system_archiver_info() {
+ $archivers['tar'] = array(
+ 'class' => 'ArchiverTar',
+ 'extensions' => array('tar', 'tgz', 'tar.gz', 'tar.bz2'),
+ );
+ if (function_exists('zip_open')) {
+ $archivers['zip'] = array(
+ 'class' => 'ArchiverZip',
+ 'extensions' => array('zip'),
+ );
+ }
+ return $archivers;
+}
+
+/**
+ * Returns HTML for a confirmation form.
+ *
+ * By default this does not alter the appearance of a form at all,
+ * but is provided as a convenience for themers.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_confirm_form($variables) {
+ return drupal_render_children($variables['form']);
+}
+
+/**
+ * Returns HTML for a system settings form.
+ *
+ * By default this does not alter the appearance of a form at all,
+ * but is provided as a convenience for themers.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_system_settings_form($variables) {
+ return drupal_render_children($variables['form']);
+}
+
+/**
+ * Returns HTML for an exposed filter form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: An associative array containing the structure of the form.
+ *
+ * @return
+ * A string containing an HTML-formatted form.
+ *
+ * @ingroup themeable
+ */
+function theme_exposed_filters($variables) {
+ $form = $variables['form'];
+ $output = '';
+
+ if (isset($form['current'])) {
+ $items = array();
+ foreach (element_children($form['current']) as $key) {
+ $items[] = drupal_render($form['current'][$key]);
+ }
+ $output .= theme('item_list', array('items' => $items, 'attributes' => array('class' => array('clearfix', 'current-filters'))));
+ }
+
+ $output .= drupal_render_children($form);
+
+ return '<div class="exposed-filters">' . $output . '</div>';
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function system_admin_paths() {
+ $paths = array(
+ 'admin' => TRUE,
+ 'admin/*' => TRUE,
+ 'batch' => TRUE,
+ // This page should not be treated as administrative since it outputs its
+ // own content (outside of any administration theme).
+ 'admin/reports/status/php' => FALSE,
+ );
+ return $paths;
+}
diff --git a/core/modules/system/system.queue.inc b/core/modules/system/system.queue.inc
new file mode 100644
index 000000000000..00d394060930
--- /dev/null
+++ b/core/modules/system/system.queue.inc
@@ -0,0 +1,371 @@
+<?php
+
+/**
+ * @file
+ * Queue functionality.
+ */
+
+/**
+ * @defgroup queue Queue operations
+ * @{
+ * Queue items to allow later processing.
+ *
+ * The queue system allows placing items in a queue and processing them later.
+ * The system tries to ensure that only one consumer can process an item.
+ *
+ * Before a queue can be used it needs to be created by
+ * DrupalQueueInterface::createQueue().
+ *
+ * Items can be added to the queue by passing an arbitrary data object to
+ * DrupalQueueInterface::createItem().
+ *
+ * To process an item, call DrupalQueueInterface::claimItem() and specify how
+ * long you want to have a lease for working on that item. When finished
+ * processing, the item needs to be deleted by calling
+ * DrupalQueueInterface::deleteItem(). If the consumer dies, the item will be
+ * made available again by the DrupalQueueInterface implementation once the
+ * lease expires. Another consumer will then be able to receive it when calling
+ * DrupalQueueInterface::claimItem(). Due to this, the processing code should
+ * be aware that an item might be handed over for processing more than once.
+ *
+ * The $item object used by the DrupalQueueInterface can contain arbitrary
+ * metadata depending on the implementation. Systems using the interface should
+ * only rely on the data property which will contain the information passed to
+ * DrupalQueueInterface::createItem(). The full queue item returned by
+ * DrupalQueueInterface::claimItem() needs to be passed to
+ * DrupalQueueInterface::deleteItem() once processing is completed.
+ *
+ * There are two kinds of queue backends available: reliable, which preserves
+ * the order of messages and guarantees that every item will be executed at
+ * least once. The non-reliable kind only does a best effort to preserve order
+ * in messages and to execute them at least once but there is a small chance
+ * that some items get lost. For example, some distributed back-ends like
+ * Amazon SQS will be managing jobs for a large set of producers and consumers
+ * where a strict FIFO ordering will likely not be preserved. Another example
+ * would be an in-memory queue backend which might lose items if it crashes.
+ * However, such a backend would be able to deal with significantly more writes
+ * than a reliable queue and for many tasks this is more important. See
+ * aggregator_cron() for an example of how can this not be a problem. Another
+ * example is doing Twitter statistics -- the small possibility of losing a few
+ * items is insignificant next to power of the queue being able to keep up with
+ * writes. As described in the processing section, regardless of the queue
+ * being reliable or not, the processing code should be aware that an item
+ * might be handed over for processing more than once (because the processing
+ * code might time out before it finishes).
+ */
+
+/**
+ * Factory class for interacting with queues.
+ */
+class DrupalQueue {
+ /**
+ * Returns the queue object for a given name.
+ *
+ * The following variables can be set by variable_set or $conf overrides:
+ * - queue_class_$name: the class to be used for the queue $name.
+ * - queue_default_class: the class to use when queue_class_$name is not
+ * defined. Defaults to SystemQueue, a reliable backend using SQL.
+ * - queue_default_reliable_class: the class to use when queue_class_$name is
+ * not defined and the queue_default_class is not reliable. Defaults to
+ * SystemQueue.
+ *
+ * @param $name
+ * Arbitrary string. The name of the queue to work with.
+ * @param $reliable
+ * TRUE if the ordering of items and guaranteeing every item executes at
+ * least once is important, FALSE if scalability is the main concern.
+ *
+ * @return
+ * The queue object for a given name.
+ */
+ public static function get($name, $reliable = FALSE) {
+ static $queues;
+ if (!isset($queues[$name])) {
+ $class = variable_get('queue_class_' . $name, NULL);
+ if (!$class) {
+ $class = variable_get('queue_default_class', 'SystemQueue');
+ }
+ $object = new $class($name);
+ if ($reliable && !$object instanceof DrupalReliableQueueInterface) {
+ $class = variable_get('queue_default_reliable_class', 'SystemQueue');
+ $object = new $class($name);
+ }
+ $queues[$name] = $object;
+ }
+ return $queues[$name];
+ }
+}
+
+interface DrupalQueueInterface {
+ /**
+ * Start working with a queue.
+ *
+ * @param $name
+ * Arbitrary string. The name of the queue to work with.
+ */
+ public function __construct($name);
+
+ /**
+ * Add a queue item and store it directly to the queue.
+ *
+ * @param $data
+ * Arbitrary data to be associated with the new task in the queue.
+ * @return
+ * TRUE if the item was successfully created and was (best effort) added
+ * to the queue, otherwise FALSE. We don't guarantee the item was
+ * committed to disk etc, but as far as we know, the item is now in the
+ * queue.
+ */
+ public function createItem($data);
+
+ /**
+ * Retrieve the number of items in the queue.
+ *
+ * This is intended to provide a "best guess" count of the number of items in
+ * the queue. Depending on the implementation and the setup, the accuracy of
+ * the results of this function may vary.
+ *
+ * e.g. On a busy system with a large number of consumers and items, the
+ * result might only be valid for a fraction of a second and not provide an
+ * accurate representation.
+ *
+ * @return
+ * An integer estimate of the number of items in the queue.
+ */
+ public function numberOfItems();
+
+ /**
+ * Claim an item in the queue for processing.
+ *
+ * @param $lease_time
+ * How long the processing is expected to take in seconds, defaults to an
+ * hour. After this lease expires, the item will be reset and another
+ * consumer can claim the item. For idempotent tasks (which can be run
+ * multiple times without side effects), shorter lease times would result
+ * in lower latency in case a consumer fails. For tasks that should not be
+ * run more than once (non-idempotent), a larger lease time will make it
+ * more rare for a given task to run multiple times in cases of failure,
+ * at the cost of higher latency.
+ * @return
+ * On success we return an item object. If the queue is unable to claim an
+ * item it returns false. This implies a best effort to retrieve an item
+ * and either the queue is empty or there is some other non-recoverable
+ * problem.
+ */
+ public function claimItem($lease_time = 3600);
+
+ /**
+ * Delete a finished item from the queue.
+ *
+ * @param $item
+ * The item returned by DrupalQueueInterface::claimItem().
+ */
+ public function deleteItem($item);
+
+ /**
+ * Release an item that the worker could not process, so another
+ * worker can come in and process it before the timeout expires.
+ *
+ * @param $item
+ * @return boolean
+ */
+ public function releaseItem($item);
+
+ /**
+ * Create a queue.
+ *
+ * Called during installation and should be used to perform any necessary
+ * initialization operations. This should not be confused with the
+ * constructor for these objects, which is called every time an object is
+ * instantiated to operate on a queue. This operation is only needed the
+ * first time a given queue is going to be initialized (for example, to make
+ * a new database table or directory to hold tasks for the queue -- it
+ * depends on the queue implementation if this is necessary at all).
+ */
+ public function createQueue();
+
+ /**
+ * Delete a queue and every item in the queue.
+ */
+ public function deleteQueue();
+}
+
+/**
+ * Reliable queue interface.
+ *
+ * Classes implementing this interface preserve the order of messages and
+ * guarantee that every item will be executed at least once.
+ */
+interface DrupalReliableQueueInterface extends DrupalQueueInterface {
+}
+
+/**
+ * Default queue implementation.
+ */
+class SystemQueue implements DrupalReliableQueueInterface {
+ /**
+ * The name of the queue this instance is working with.
+ *
+ * @var string
+ */
+ protected $name;
+
+ public function __construct($name) {
+ $this->name = $name;
+ }
+
+ public function createItem($data) {
+ // During a Drupal 6.x to 8.x update, drupal_get_schema() does not contain
+ // the queue table yet, so we cannot rely on drupal_write_record().
+ $query = db_insert('queue')
+ ->fields(array(
+ 'name' => $this->name,
+ 'data' => serialize($data),
+ // We cannot rely on REQUEST_TIME because many items might be created
+ // by a single request which takes longer than 1 second.
+ 'created' => time(),
+ ));
+ return (bool) $query->execute();
+ }
+
+ public function numberOfItems() {
+ return db_query('SELECT COUNT(item_id) FROM {queue} WHERE name = :name', array(':name' => $this->name))->fetchField();
+ }
+
+ public function claimItem($lease_time = 30) {
+ // Claim an item by updating its expire fields. If claim is not successful
+ // another thread may have claimed the item in the meantime. Therefore loop
+ // until an item is successfully claimed or we are reasonably sure there
+ // are no unclaimed items left.
+ while (TRUE) {
+ $item = db_query_range('SELECT data, item_id FROM {queue} q WHERE expire = 0 AND name = :name ORDER BY created ASC', 0, 1, array(':name' => $this->name))->fetchObject();
+ if ($item) {
+ // Try to update the item. Only one thread can succeed in UPDATEing the
+ // same row. We cannot rely on REQUEST_TIME because items might be
+ // claimed by a single consumer which runs longer than 1 second. If we
+ // continue to use REQUEST_TIME instead of the current time(), we steal
+ // time from the lease, and will tend to reset items before the lease
+ // should really expire.
+ $update = db_update('queue')
+ ->fields(array(
+ 'expire' => time() + $lease_time,
+ ))
+ ->condition('item_id', $item->item_id)
+ ->condition('expire', 0);
+ // If there are affected rows, this update succeeded.
+ if ($update->execute()) {
+ $item->data = unserialize($item->data);
+ return $item;
+ }
+ }
+ else {
+ // No items currently available to claim.
+ return FALSE;
+ }
+ }
+ }
+
+ public function releaseItem($item) {
+ $update = db_update('queue')
+ ->fields(array(
+ 'expire' => 0,
+ ))
+ ->condition('item_id', $item->item_id);
+ return $update->execute();
+ }
+
+ public function deleteItem($item) {
+ db_delete('queue')
+ ->condition('item_id', $item->item_id)
+ ->execute();
+ }
+
+ public function createQueue() {
+ // All tasks are stored in a single database table (which is created when
+ // Drupal is first installed) so there is nothing we need to do to create
+ // a new queue.
+ }
+
+ public function deleteQueue() {
+ db_delete('queue')
+ ->condition('name', $this->name)
+ ->execute();
+ }
+}
+
+/**
+ * Static queue implementation.
+ *
+ * This allows "undelayed" variants of processes relying on the Queue
+ * interface. The queue data resides in memory. It should only be used for
+ * items that will be queued and dequeued within a given page request.
+ */
+class MemoryQueue implements DrupalQueueInterface {
+ /**
+ * The queue data.
+ *
+ * @var array
+ */
+ protected $queue;
+
+ /**
+ * Counter for item ids.
+ *
+ * @var int
+ */
+ protected $id_sequence;
+
+ public function __construct($name) {
+ $this->queue = array();
+ $this->id_sequence = 0;
+ }
+
+ public function createItem($data) {
+ $item = new stdClass();
+ $item->item_id = $this->id_sequence++;
+ $item->data = $data;
+ $item->created = time();
+ $item->expire = 0;
+ $this->queue[$item->item_id] = $item;
+ }
+
+ public function numberOfItems() {
+ return count($this->queue);
+ }
+
+ public function claimItem($lease_time = 30) {
+ foreach ($this->queue as $key => $item) {
+ if ($item->expire == 0) {
+ $item->expire = time() + $lease_time;
+ $this->queue[$key] = $item;
+ return $item;
+ }
+ }
+ return FALSE;
+ }
+
+ public function deleteItem($item) {
+ unset($this->queue[$item->item_id]);
+ }
+
+ public function releaseItem($item) {
+ if (isset($this->queue[$item->item_id]) && $this->queue[$item->item_id]->expire != 0) {
+ $this->queue[$item->item_id]->expire = 0;
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ public function createQueue() {
+ // Nothing needed here.
+ }
+
+ public function deleteQueue() {
+ $this->queue = array();
+ $this->id_sequence = 0;
+ }
+}
+
+/**
+ * @} End of "defgroup queue".
+ */
diff --git a/core/modules/system/system.tar.inc b/core/modules/system/system.tar.inc
new file mode 100644
index 000000000000..32bf7f066e5c
--- /dev/null
+++ b/core/modules/system/system.tar.inc
@@ -0,0 +1,1892 @@
+<?php
+/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
+
+/**
+ * File::CSV
+ *
+ * PHP versions 4 and 5
+ *
+ * Copyright (c) 1997-2008,
+ * Vincent Blavet <vincent@phpconcept.net>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * * Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ *
+ * @category File_Formats
+ * @package Archive_Tar
+ * @author Vincent Blavet <vincent@phpconcept.net>
+ * @copyright 1997-2008 The Authors
+ * @license http://www.opensource.org/licenses/bsd-license.php New BSD License
+ * @version CVS: Id: Tar.php,v 1.43 2008/10/30 17:58:42 dufuz Exp
+ * @link http://pear.php.net/package/Archive_Tar
+ */
+
+//require_once 'PEAR.php';
+//
+//
+define ('ARCHIVE_TAR_ATT_SEPARATOR', 90001);
+define ('ARCHIVE_TAR_END_BLOCK', pack("a512", ''));
+
+/**
+* Creates a (compressed) Tar archive
+*
+* @author Vincent Blavet <vincent@phpconcept.net>
+* @version Revision: 1.43
+* @license http://www.opensource.org/licenses/bsd-license.php New BSD License
+* @package Archive_Tar
+*/
+class Archive_Tar // extends PEAR
+{
+ /**
+ * @var string Name of the Tar
+ */
+ var $_tarname='';
+
+ /**
+ * @var boolean if true, the Tar file will be gzipped
+ */
+ var $_compress=false;
+
+ /**
+ * @var string Type of compression : 'none', 'gz' or 'bz2'
+ */
+ var $_compress_type='none';
+
+ /**
+ * @var string Explode separator
+ */
+ var $_separator=' ';
+
+ /**
+ * @var file descriptor
+ */
+ var $_file=0;
+
+ /**
+ * @var string Local Tar name of a remote Tar (http:// or ftp://)
+ */
+ var $_temp_tarname='';
+
+ // {{{ constructor
+ /**
+ * Archive_Tar Class constructor. This flavour of the constructor only
+ * declare a new Archive_Tar object, identifying it by the name of the
+ * tar file.
+ * If the compress argument is set the tar will be read or created as a
+ * gzip or bz2 compressed TAR file.
+ *
+ * @param string $p_tarname The name of the tar archive to create
+ * @param string $p_compress can be null, 'gz' or 'bz2'. This
+ * parameter indicates if gzip or bz2 compression
+ * is required. For compatibility reason the
+ * boolean value 'true' means 'gz'.
+ * @access public
+ */
+// function Archive_Tar($p_tarname, $p_compress = null)
+ function __construct($p_tarname, $p_compress = null)
+ {
+// $this->PEAR();
+ $this->_compress = false;
+ $this->_compress_type = 'none';
+ if (($p_compress === null) || ($p_compress == '')) {
+ if (@file_exists($p_tarname)) {
+ if ($fp = @fopen($p_tarname, "rb")) {
+ // look for gzip magic cookie
+ $data = fread($fp, 2);
+ fclose($fp);
+ if ($data == "\37\213") {
+ $this->_compress = true;
+ $this->_compress_type = 'gz';
+ // No sure it's enought for a magic code ....
+ } elseif ($data == "BZ") {
+ $this->_compress = true;
+ $this->_compress_type = 'bz2';
+ }
+ }
+ } else {
+ // probably a remote file or some file accessible
+ // through a stream interface
+ if (substr($p_tarname, -2) == 'gz') {
+ $this->_compress = true;
+ $this->_compress_type = 'gz';
+ } elseif ((substr($p_tarname, -3) == 'bz2') ||
+ (substr($p_tarname, -2) == 'bz')) {
+ $this->_compress = true;
+ $this->_compress_type = 'bz2';
+ }
+ }
+ } else {
+ if (($p_compress === true) || ($p_compress == 'gz')) {
+ $this->_compress = true;
+ $this->_compress_type = 'gz';
+ } else if ($p_compress == 'bz2') {
+ $this->_compress = true;
+ $this->_compress_type = 'bz2';
+ } else {
+ die("Unsupported compression type '$p_compress'\n".
+ "Supported types are 'gz' and 'bz2'.\n");
+ return false;
+ }
+ }
+ $this->_tarname = $p_tarname;
+ if ($this->_compress) { // assert zlib or bz2 extension support
+ if ($this->_compress_type == 'gz')
+ $extname = 'zlib';
+ else if ($this->_compress_type == 'bz2')
+ $extname = 'bz2';
+
+ if (!extension_loaded($extname)) {
+// PEAR::loadExtension($extname);
+ $this->loadExtension($extname);
+ }
+ if (!extension_loaded($extname)) {
+ die("The extension '$extname' couldn't be found.\n".
+ "Please make sure your version of PHP was built ".
+ "with '$extname' support.\n");
+ return false;
+ }
+ }
+ }
+ // }}}
+
+ /**
+ * OS independent PHP extension load. Remember to take care
+ * on the correct extension name for case sensitive OSes.
+ * The function is the copy of PEAR::loadExtension().
+ *
+ * @param string $ext The extension name
+ * @return bool Success or not on the dl() call
+ */
+ function loadExtension($ext)
+ {
+ if (!extension_loaded($ext)) {
+ // if either returns true dl() will produce a FATAL error, stop that
+ if ((ini_get('enable_dl') != 1) || (ini_get('safe_mode') == 1)) {
+ return false;
+ }
+
+ if (OS_WINDOWS) {
+ $suffix = '.dll';
+ } elseif (PHP_OS == 'HP-UX') {
+ $suffix = '.sl';
+ } elseif (PHP_OS == 'AIX') {
+ $suffix = '.a';
+ } elseif (PHP_OS == 'OSX') {
+ $suffix = '.bundle';
+ } else {
+ $suffix = '.so';
+ }
+
+ return @dl('php_'.$ext.$suffix) || @dl($ext.$suffix);
+ }
+
+ return true;
+ }
+
+
+ // {{{ destructor
+// function _Archive_Tar()
+ function __destruct()
+ {
+ $this->_close();
+ // ----- Look for a local copy to delete
+ if ($this->_temp_tarname != '')
+ @drupal_unlink($this->_temp_tarname);
+// $this->_PEAR();
+ }
+ // }}}
+
+ // {{{ create()
+ /**
+ * This method creates the archive file and add the files / directories
+ * that are listed in $p_filelist.
+ * If a file with the same name exist and is writable, it is replaced
+ * by the new tar.
+ * The method return false and a PEAR error text.
+ * The $p_filelist parameter can be an array of string, each string
+ * representing a filename or a directory name with their path if
+ * needed. It can also be a single string with names separated by a
+ * single blank.
+ * For each directory added in the archive, the files and
+ * sub-directories are also added.
+ * See also createModify() method for more details.
+ *
+ * @param array $p_filelist An array of filenames and directory names, or a
+ * single string with names separated by a single
+ * blank space.
+ * @return true on success, false on error.
+ * @see createModify()
+ * @access public
+ */
+ function create($p_filelist)
+ {
+ return $this->createModify($p_filelist, '', '');
+ }
+ // }}}
+
+ // {{{ add()
+ /**
+ * This method add the files / directories that are listed in $p_filelist in
+ * the archive. If the archive does not exist it is created.
+ * The method return false and a PEAR error text.
+ * The files and directories listed are only added at the end of the archive,
+ * even if a file with the same name is already archived.
+ * See also createModify() method for more details.
+ *
+ * @param array $p_filelist An array of filenames and directory names, or a
+ * single string with names separated by a single
+ * blank space.
+ * @return true on success, false on error.
+ * @see createModify()
+ * @access public
+ */
+ function add($p_filelist)
+ {
+ return $this->addModify($p_filelist, '', '');
+ }
+ // }}}
+
+ // {{{ extract()
+ function extract($p_path='')
+ {
+ return $this->extractModify($p_path, '');
+ }
+ // }}}
+
+ // {{{ listContent()
+ function listContent()
+ {
+ $v_list_detail = array();
+
+ if ($this->_openRead()) {
+ if (!$this->_extractList('', $v_list_detail, "list", '', '')) {
+ unset($v_list_detail);
+ $v_list_detail = 0;
+ }
+ $this->_close();
+ }
+
+ return $v_list_detail;
+ }
+ // }}}
+
+ // {{{ createModify()
+ /**
+ * This method creates the archive file and add the files / directories
+ * that are listed in $p_filelist.
+ * If the file already exists and is writable, it is replaced by the
+ * new tar. It is a create and not an add. If the file exists and is
+ * read-only or is a directory it is not replaced. The method return
+ * false and a PEAR error text.
+ * The $p_filelist parameter can be an array of string, each string
+ * representing a filename or a directory name with their path if
+ * needed. It can also be a single string with names separated by a
+ * single blank.
+ * The path indicated in $p_remove_dir will be removed from the
+ * memorized path of each file / directory listed when this path
+ * exists. By default nothing is removed (empty path '')
+ * The path indicated in $p_add_dir will be added at the beginning of
+ * the memorized path of each file / directory listed. However it can
+ * be set to empty ''. The adding of a path is done after the removing
+ * of path.
+ * The path add/remove ability enables the user to prepare an archive
+ * for extraction in a different path than the origin files are.
+ * See also addModify() method for file adding properties.
+ *
+ * @param array $p_filelist An array of filenames and directory names,
+ * or a single string with names separated by
+ * a single blank space.
+ * @param string $p_add_dir A string which contains a path to be added
+ * to the memorized path of each element in
+ * the list.
+ * @param string $p_remove_dir A string which contains a path to be
+ * removed from the memorized path of each
+ * element in the list, when relevant.
+ * @return boolean true on success, false on error.
+ * @access public
+ * @see addModify()
+ */
+ function createModify($p_filelist, $p_add_dir, $p_remove_dir='')
+ {
+ $v_result = true;
+
+ if (!$this->_openWrite())
+ return false;
+
+ if ($p_filelist != '') {
+ if (is_array($p_filelist))
+ $v_list = $p_filelist;
+ elseif (is_string($p_filelist))
+ $v_list = explode($this->_separator, $p_filelist);
+ else {
+ $this->_cleanFile();
+ $this->_error('Invalid file list');
+ return false;
+ }
+
+ $v_result = $this->_addList($v_list, $p_add_dir, $p_remove_dir);
+ }
+
+ if ($v_result) {
+ $this->_writeFooter();
+ $this->_close();
+ } else
+ $this->_cleanFile();
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ addModify()
+ /**
+ * This method add the files / directories listed in $p_filelist at the
+ * end of the existing archive. If the archive does not yet exists it
+ * is created.
+ * The $p_filelist parameter can be an array of string, each string
+ * representing a filename or a directory name with their path if
+ * needed. It can also be a single string with names separated by a
+ * single blank.
+ * The path indicated in $p_remove_dir will be removed from the
+ * memorized path of each file / directory listed when this path
+ * exists. By default nothing is removed (empty path '')
+ * The path indicated in $p_add_dir will be added at the beginning of
+ * the memorized path of each file / directory listed. However it can
+ * be set to empty ''. The adding of a path is done after the removing
+ * of path.
+ * The path add/remove ability enables the user to prepare an archive
+ * for extraction in a different path than the origin files are.
+ * If a file/dir is already in the archive it will only be added at the
+ * end of the archive. There is no update of the existing archived
+ * file/dir. However while extracting the archive, the last file will
+ * replace the first one. This results in a none optimization of the
+ * archive size.
+ * If a file/dir does not exist the file/dir is ignored. However an
+ * error text is send to PEAR error.
+ * If a file/dir is not readable the file/dir is ignored. However an
+ * error text is send to PEAR error.
+ *
+ * @param array $p_filelist An array of filenames and directory
+ * names, or a single string with names
+ * separated by a single blank space.
+ * @param string $p_add_dir A string which contains a path to be
+ * added to the memorized path of each
+ * element in the list.
+ * @param string $p_remove_dir A string which contains a path to be
+ * removed from the memorized path of
+ * each element in the list, when
+ * relevant.
+ * @return true on success, false on error.
+ * @access public
+ */
+ function addModify($p_filelist, $p_add_dir, $p_remove_dir='')
+ {
+ $v_result = true;
+
+ if (!$this->_isArchive())
+ $v_result = $this->createModify($p_filelist, $p_add_dir,
+ $p_remove_dir);
+ else {
+ if (is_array($p_filelist))
+ $v_list = $p_filelist;
+ elseif (is_string($p_filelist))
+ $v_list = explode($this->_separator, $p_filelist);
+ else {
+ $this->_error('Invalid file list');
+ return false;
+ }
+
+ $v_result = $this->_append($v_list, $p_add_dir, $p_remove_dir);
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ addString()
+ /**
+ * This method add a single string as a file at the
+ * end of the existing archive. If the archive does not yet exists it
+ * is created.
+ *
+ * @param string $p_filename A string which contains the full
+ * filename path that will be associated
+ * with the string.
+ * @param string $p_string The content of the file added in
+ * the archive.
+ * @return true on success, false on error.
+ * @access public
+ */
+ function addString($p_filename, $p_string)
+ {
+ $v_result = true;
+
+ if (!$this->_isArchive()) {
+ if (!$this->_openWrite()) {
+ return false;
+ }
+ $this->_close();
+ }
+
+ if (!$this->_openAppend())
+ return false;
+
+ // Need to check the get back to the temporary file ? ....
+ $v_result = $this->_addString($p_filename, $p_string);
+
+ $this->_writeFooter();
+
+ $this->_close();
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ extractModify()
+ /**
+ * This method extract all the content of the archive in the directory
+ * indicated by $p_path. When relevant the memorized path of the
+ * files/dir can be modified by removing the $p_remove_path path at the
+ * beginning of the file/dir path.
+ * While extracting a file, if the directory path does not exists it is
+ * created.
+ * While extracting a file, if the file already exists it is replaced
+ * without looking for last modification date.
+ * While extracting a file, if the file already exists and is write
+ * protected, the extraction is aborted.
+ * While extracting a file, if a directory with the same name already
+ * exists, the extraction is aborted.
+ * While extracting a directory, if a file with the same name already
+ * exists, the extraction is aborted.
+ * While extracting a file/directory if the destination directory exist
+ * and is write protected, or does not exist but can not be created,
+ * the extraction is aborted.
+ * If after extraction an extracted file does not show the correct
+ * stored file size, the extraction is aborted.
+ * When the extraction is aborted, a PEAR error text is set and false
+ * is returned. However the result can be a partial extraction that may
+ * need to be manually cleaned.
+ *
+ * @param string $p_path The path of the directory where the
+ * files/dir need to by extracted.
+ * @param string $p_remove_path Part of the memorized path that can be
+ * removed if present at the beginning of
+ * the file/dir path.
+ * @return boolean true on success, false on error.
+ * @access public
+ * @see extractList()
+ */
+ function extractModify($p_path, $p_remove_path)
+ {
+ $v_result = true;
+ $v_list_detail = array();
+
+ if ($v_result = $this->_openRead()) {
+ $v_result = $this->_extractList($p_path, $v_list_detail,
+ "complete", 0, $p_remove_path);
+ $this->_close();
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ extractInString()
+ /**
+ * This method extract from the archive one file identified by $p_filename.
+ * The return value is a string with the file content, or NULL on error.
+ * @param string $p_filename The path of the file to extract in a string.
+ * @return a string with the file content or NULL.
+ * @access public
+ */
+ function extractInString($p_filename)
+ {
+ if ($this->_openRead()) {
+ $v_result = $this->_extractInString($p_filename);
+ $this->_close();
+ } else {
+ $v_result = NULL;
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ extractList()
+ /**
+ * This method extract from the archive only the files indicated in the
+ * $p_filelist. These files are extracted in the current directory or
+ * in the directory indicated by the optional $p_path parameter.
+ * If indicated the $p_remove_path can be used in the same way as it is
+ * used in extractModify() method.
+ * @param array $p_filelist An array of filenames and directory names,
+ * or a single string with names separated
+ * by a single blank space.
+ * @param string $p_path The path of the directory where the
+ * files/dir need to by extracted.
+ * @param string $p_remove_path Part of the memorized path that can be
+ * removed if present at the beginning of
+ * the file/dir path.
+ * @return true on success, false on error.
+ * @access public
+ * @see extractModify()
+ */
+ function extractList($p_filelist, $p_path='', $p_remove_path='')
+ {
+ $v_result = true;
+ $v_list_detail = array();
+
+ if (is_array($p_filelist))
+ $v_list = $p_filelist;
+ elseif (is_string($p_filelist))
+ $v_list = explode($this->_separator, $p_filelist);
+ else {
+ $this->_error('Invalid string list');
+ return false;
+ }
+
+ if ($v_result = $this->_openRead()) {
+ $v_result = $this->_extractList($p_path, $v_list_detail, "partial",
+ $v_list, $p_remove_path);
+ $this->_close();
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ setAttribute()
+ /**
+ * This method set specific attributes of the archive. It uses a variable
+ * list of parameters, in the format attribute code + attribute values :
+ * $arch->setAttribute(ARCHIVE_TAR_ATT_SEPARATOR, ',');
+ * @param mixed $argv variable list of attributes and values
+ * @return true on success, false on error.
+ * @access public
+ */
+ function setAttribute()
+ {
+ $v_result = true;
+
+ // ----- Get the number of variable list of arguments
+ if (($v_size = func_num_args()) == 0) {
+ return true;
+ }
+
+ // ----- Get the arguments
+ $v_att_list = &func_get_args();
+
+ // ----- Read the attributes
+ $i=0;
+ while ($i<$v_size) {
+
+ // ----- Look for next option
+ switch ($v_att_list[$i]) {
+ // ----- Look for options that request a string value
+ case ARCHIVE_TAR_ATT_SEPARATOR :
+ // ----- Check the number of parameters
+ if (($i+1) >= $v_size) {
+ $this->_error('Invalid number of parameters for '
+ .'attribute ARCHIVE_TAR_ATT_SEPARATOR');
+ return false;
+ }
+
+ // ----- Get the value
+ $this->_separator = $v_att_list[$i+1];
+ $i++;
+ break;
+
+ default :
+ $this->_error('Unknow attribute code '.$v_att_list[$i].'');
+ return false;
+ }
+
+ // ----- Next attribute
+ $i++;
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ _error()
+ function _error($p_message)
+ {
+ // ----- To be completed
+// $this->raiseError($p_message);
+ throw new Exception($p_message);
+ }
+ // }}}
+
+ // {{{ _warning()
+ function _warning($p_message)
+ {
+ // ----- To be completed
+// $this->raiseError($p_message);
+ throw new Exception($p_message);
+ }
+ // }}}
+
+ // {{{ _isArchive()
+ function _isArchive($p_filename=NULL)
+ {
+ if ($p_filename == NULL) {
+ $p_filename = $this->_tarname;
+ }
+ clearstatcache();
+ return @is_file($p_filename) && !@is_link($p_filename);
+ }
+ // }}}
+
+ // {{{ _openWrite()
+ function _openWrite()
+ {
+ if ($this->_compress_type == 'gz')
+ $this->_file = @gzopen($this->_tarname, "wb9");
+ else if ($this->_compress_type == 'bz2')
+ $this->_file = @bzopen($this->_tarname, "w");
+ else if ($this->_compress_type == 'none')
+ $this->_file = @fopen($this->_tarname, "wb");
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ if ($this->_file == 0) {
+ $this->_error('Unable to open in write mode \''
+ .$this->_tarname.'\'');
+ return false;
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _openRead()
+ function _openRead()
+ {
+ if (strtolower(substr($this->_tarname, 0, 7)) == 'http://') {
+
+ // ----- Look if a local copy need to be done
+ if ($this->_temp_tarname == '') {
+ $this->_temp_tarname = uniqid('tar').'.tmp';
+ if (!$v_file_from = @fopen($this->_tarname, 'rb')) {
+ $this->_error('Unable to open in read mode \''
+ .$this->_tarname.'\'');
+ $this->_temp_tarname = '';
+ return false;
+ }
+ if (!$v_file_to = @fopen($this->_temp_tarname, 'wb')) {
+ $this->_error('Unable to open in write mode \''
+ .$this->_temp_tarname.'\'');
+ $this->_temp_tarname = '';
+ return false;
+ }
+ while ($v_data = @fread($v_file_from, 1024))
+ @fwrite($v_file_to, $v_data);
+ @fclose($v_file_from);
+ @fclose($v_file_to);
+ }
+
+ // ----- File to open if the local copy
+ $v_filename = $this->_temp_tarname;
+
+ } else
+ // ----- File to open if the normal Tar file
+ $v_filename = $this->_tarname;
+
+ if ($this->_compress_type == 'gz')
+ $this->_file = @gzopen($v_filename, "rb");
+ else if ($this->_compress_type == 'bz2')
+ $this->_file = @bzopen($v_filename, "r");
+ else if ($this->_compress_type == 'none')
+ $this->_file = @fopen($v_filename, "rb");
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ if ($this->_file == 0) {
+ $this->_error('Unable to open in read mode \''.$v_filename.'\'');
+ return false;
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _openReadWrite()
+ function _openReadWrite()
+ {
+ if ($this->_compress_type == 'gz')
+ $this->_file = @gzopen($this->_tarname, "r+b");
+ else if ($this->_compress_type == 'bz2') {
+ $this->_error('Unable to open bz2 in read/write mode \''
+ .$this->_tarname.'\' (limitation of bz2 extension)');
+ return false;
+ } else if ($this->_compress_type == 'none')
+ $this->_file = @fopen($this->_tarname, "r+b");
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ if ($this->_file == 0) {
+ $this->_error('Unable to open in read/write mode \''
+ .$this->_tarname.'\'');
+ return false;
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _close()
+ function _close()
+ {
+ //if (isset($this->_file)) {
+ if (is_resource($this->_file)) {
+ if ($this->_compress_type == 'gz')
+ @gzclose($this->_file);
+ else if ($this->_compress_type == 'bz2')
+ @bzclose($this->_file);
+ else if ($this->_compress_type == 'none')
+ @fclose($this->_file);
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ $this->_file = 0;
+ }
+
+ // ----- Look if a local copy need to be erase
+ // Note that it might be interesting to keep the url for a time : ToDo
+ if ($this->_temp_tarname != '') {
+ @drupal_unlink($this->_temp_tarname);
+ $this->_temp_tarname = '';
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _cleanFile()
+ function _cleanFile()
+ {
+ $this->_close();
+
+ // ----- Look for a local copy
+ if ($this->_temp_tarname != '') {
+ // ----- Remove the local copy but not the remote tarname
+ @drupal_unlink($this->_temp_tarname);
+ $this->_temp_tarname = '';
+ } else {
+ // ----- Remove the local tarname file
+ @drupal_unlink($this->_tarname);
+ }
+ $this->_tarname = '';
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _writeBlock()
+ function _writeBlock($p_binary_data, $p_len=null)
+ {
+ if (is_resource($this->_file)) {
+ if ($p_len === null) {
+ if ($this->_compress_type == 'gz')
+ @gzputs($this->_file, $p_binary_data);
+ else if ($this->_compress_type == 'bz2')
+ @bzwrite($this->_file, $p_binary_data);
+ else if ($this->_compress_type == 'none')
+ @fputs($this->_file, $p_binary_data);
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+ } else {
+ if ($this->_compress_type == 'gz')
+ @gzputs($this->_file, $p_binary_data, $p_len);
+ else if ($this->_compress_type == 'bz2')
+ @bzwrite($this->_file, $p_binary_data, $p_len);
+ else if ($this->_compress_type == 'none')
+ @fputs($this->_file, $p_binary_data, $p_len);
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ }
+ }
+ return true;
+ }
+ // }}}
+
+ // {{{ _readBlock()
+ function _readBlock()
+ {
+ $v_block = null;
+ if (is_resource($this->_file)) {
+ if ($this->_compress_type == 'gz')
+ $v_block = @gzread($this->_file, 512);
+ else if ($this->_compress_type == 'bz2')
+ $v_block = @bzread($this->_file, 512);
+ else if ($this->_compress_type == 'none')
+ $v_block = @fread($this->_file, 512);
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+ }
+ return $v_block;
+ }
+ // }}}
+
+ // {{{ _jumpBlock()
+ function _jumpBlock($p_len=null)
+ {
+ if (is_resource($this->_file)) {
+ if ($p_len === null)
+ $p_len = 1;
+
+ if ($this->_compress_type == 'gz') {
+ @gzseek($this->_file, gztell($this->_file)+($p_len*512));
+ }
+ else if ($this->_compress_type == 'bz2') {
+ // ----- Replace missing bztell() and bzseek()
+ for ($i=0; $i<$p_len; $i++)
+ $this->_readBlock();
+ } else if ($this->_compress_type == 'none')
+ @fseek($this->_file, ftell($this->_file)+($p_len*512));
+ else
+ $this->_error('Unknown or missing compression type ('
+ .$this->_compress_type.')');
+
+ }
+ return true;
+ }
+ // }}}
+
+ // {{{ _writeFooter()
+ function _writeFooter()
+ {
+ if (is_resource($this->_file)) {
+ // ----- Write the last 0 filled block for end of archive
+ $v_binary_data = pack('a1024', '');
+ $this->_writeBlock($v_binary_data);
+ }
+ return true;
+ }
+ // }}}
+
+ // {{{ _addList()
+ function _addList($p_list, $p_add_dir, $p_remove_dir)
+ {
+ $v_result=true;
+ $v_header = array();
+
+ // ----- Remove potential windows directory separator
+ $p_add_dir = $this->_translateWinPath($p_add_dir);
+ $p_remove_dir = $this->_translateWinPath($p_remove_dir, false);
+
+ if (!$this->_file) {
+ $this->_error('Invalid file descriptor');
+ return false;
+ }
+
+ if (sizeof($p_list) == 0)
+ return true;
+
+ foreach ($p_list as $v_filename) {
+ if (!$v_result) {
+ break;
+ }
+
+ // ----- Skip the current tar name
+ if ($v_filename == $this->_tarname)
+ continue;
+
+ if ($v_filename == '')
+ continue;
+
+ if (!file_exists($v_filename)) {
+ $this->_warning("File '$v_filename' does not exist");
+ continue;
+ }
+
+ // ----- Add the file or directory header
+ if (!$this->_addFile($v_filename, $v_header, $p_add_dir, $p_remove_dir))
+ return false;
+
+ if (@is_dir($v_filename) && !@is_link($v_filename)) {
+ if (!($p_hdir = opendir($v_filename))) {
+ $this->_warning("Directory '$v_filename' can not be read");
+ continue;
+ }
+ while (false !== ($p_hitem = readdir($p_hdir))) {
+ if (($p_hitem != '.') && ($p_hitem != '..')) {
+ if ($v_filename != ".")
+ $p_temp_list[0] = $v_filename.'/'.$p_hitem;
+ else
+ $p_temp_list[0] = $p_hitem;
+
+ $v_result = $this->_addList($p_temp_list,
+ $p_add_dir,
+ $p_remove_dir);
+ }
+ }
+
+ unset($p_temp_list);
+ unset($p_hdir);
+ unset($p_hitem);
+ }
+ }
+
+ return $v_result;
+ }
+ // }}}
+
+ // {{{ _addFile()
+ function _addFile($p_filename, &$p_header, $p_add_dir, $p_remove_dir)
+ {
+ if (!$this->_file) {
+ $this->_error('Invalid file descriptor');
+ return false;
+ }
+
+ if ($p_filename == '') {
+ $this->_error('Invalid file name');
+ return false;
+ }
+
+ // ----- Calculate the stored filename
+ $p_filename = $this->_translateWinPath($p_filename, false);;
+ $v_stored_filename = $p_filename;
+ if (strcmp($p_filename, $p_remove_dir) == 0) {
+ return true;
+ }
+ if ($p_remove_dir != '') {
+ if (substr($p_remove_dir, -1) != '/')
+ $p_remove_dir .= '/';
+
+ if (substr($p_filename, 0, strlen($p_remove_dir)) == $p_remove_dir)
+ $v_stored_filename = substr($p_filename, strlen($p_remove_dir));
+ }
+ $v_stored_filename = $this->_translateWinPath($v_stored_filename);
+ if ($p_add_dir != '') {
+ if (substr($p_add_dir, -1) == '/')
+ $v_stored_filename = $p_add_dir.$v_stored_filename;
+ else
+ $v_stored_filename = $p_add_dir.'/'.$v_stored_filename;
+ }
+
+ $v_stored_filename = $this->_pathReduction($v_stored_filename);
+
+ if ($this->_isArchive($p_filename)) {
+ if (($v_file = @fopen($p_filename, "rb")) == 0) {
+ $this->_warning("Unable to open file '".$p_filename
+ ."' in binary read mode");
+ return true;
+ }
+
+ if (!$this->_writeHeader($p_filename, $v_stored_filename))
+ return false;
+
+ while (($v_buffer = fread($v_file, 512)) != '') {
+ $v_binary_data = pack("a512", "$v_buffer");
+ $this->_writeBlock($v_binary_data);
+ }
+
+ fclose($v_file);
+
+ } else {
+ // ----- Only header for dir
+ if (!$this->_writeHeader($p_filename, $v_stored_filename))
+ return false;
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _addString()
+ function _addString($p_filename, $p_string)
+ {
+ if (!$this->_file) {
+ $this->_error('Invalid file descriptor');
+ return false;
+ }
+
+ if ($p_filename == '') {
+ $this->_error('Invalid file name');
+ return false;
+ }
+
+ // ----- Calculate the stored filename
+ $p_filename = $this->_translateWinPath($p_filename, false);;
+
+ if (!$this->_writeHeaderBlock($p_filename, strlen($p_string),
+ time(), 384, "", 0, 0))
+ return false;
+
+ $i=0;
+ while (($v_buffer = substr($p_string, (($i++)*512), 512)) != '') {
+ $v_binary_data = pack("a512", $v_buffer);
+ $this->_writeBlock($v_binary_data);
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _writeHeader()
+ function _writeHeader($p_filename, $p_stored_filename)
+ {
+ if ($p_stored_filename == '')
+ $p_stored_filename = $p_filename;
+ $v_reduce_filename = $this->_pathReduction($p_stored_filename);
+
+ if (strlen($v_reduce_filename) > 99) {
+ if (!$this->_writeLongHeader($v_reduce_filename))
+ return false;
+ }
+
+ $v_info = lstat($p_filename);
+ $v_uid = sprintf("%6s ", DecOct($v_info[4]));
+ $v_gid = sprintf("%6s ", DecOct($v_info[5]));
+ $v_perms = sprintf("%6s ", DecOct($v_info['mode']));
+
+ $v_mtime = sprintf("%11s", DecOct($v_info['mode']));
+
+ $v_linkname = '';
+
+ if (@is_link($p_filename)) {
+ $v_typeflag = '2';
+ $v_linkname = readlink($p_filename);
+ $v_size = sprintf("%11s ", DecOct(0));
+ } elseif (@is_dir($p_filename)) {
+ $v_typeflag = "5";
+ $v_size = sprintf("%11s ", DecOct(0));
+ } else {
+ $v_typeflag = '';
+ clearstatcache();
+ $v_size = sprintf("%11s ", DecOct($v_info['size']));
+ }
+
+ $v_magic = '';
+
+ $v_version = '';
+
+ $v_uname = '';
+
+ $v_gname = '';
+
+ $v_devmajor = '';
+
+ $v_devminor = '';
+
+ $v_prefix = '';
+
+ $v_binary_data_first = pack("a100a8a8a8a12A12",
+ $v_reduce_filename, $v_perms, $v_uid,
+ $v_gid, $v_size, $v_mtime);
+ $v_binary_data_last = pack("a1a100a6a2a32a32a8a8a155a12",
+ $v_typeflag, $v_linkname, $v_magic,
+ $v_version, $v_uname, $v_gname,
+ $v_devmajor, $v_devminor, $v_prefix, '');
+
+ // ----- Calculate the checksum
+ $v_checksum = 0;
+ // ..... First part of the header
+ for ($i=0; $i<148; $i++)
+ $v_checksum += ord(substr($v_binary_data_first,$i,1));
+ // ..... Ignore the checksum value and replace it by ' ' (space)
+ for ($i=148; $i<156; $i++)
+ $v_checksum += ord(' ');
+ // ..... Last part of the header
+ for ($i=156, $j=0; $i<512; $i++, $j++)
+ $v_checksum += ord(substr($v_binary_data_last,$j,1));
+
+ // ----- Write the first 148 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_first, 148);
+
+ // ----- Write the calculated checksum
+ $v_checksum = sprintf("%6s ", DecOct($v_checksum));
+ $v_binary_data = pack("a8", $v_checksum);
+ $this->_writeBlock($v_binary_data, 8);
+
+ // ----- Write the last 356 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_last, 356);
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _writeHeaderBlock()
+ function _writeHeaderBlock($p_filename, $p_size, $p_mtime=0, $p_perms=0,
+ $p_type='', $p_uid=0, $p_gid=0)
+ {
+ $p_filename = $this->_pathReduction($p_filename);
+
+ if (strlen($p_filename) > 99) {
+ if (!$this->_writeLongHeader($p_filename))
+ return false;
+ }
+
+ if ($p_type == "5") {
+ $v_size = sprintf("%11s ", DecOct(0));
+ } else {
+ $v_size = sprintf("%11s ", DecOct($p_size));
+ }
+
+ $v_uid = sprintf("%6s ", DecOct($p_uid));
+ $v_gid = sprintf("%6s ", DecOct($p_gid));
+ $v_perms = sprintf("%6s ", DecOct($p_perms));
+
+ $v_mtime = sprintf("%11s", DecOct($p_mtime));
+
+ $v_linkname = '';
+
+ $v_magic = '';
+
+ $v_version = '';
+
+ $v_uname = '';
+
+ $v_gname = '';
+
+ $v_devmajor = '';
+
+ $v_devminor = '';
+
+ $v_prefix = '';
+
+ $v_binary_data_first = pack("a100a8a8a8a12A12",
+ $p_filename, $v_perms, $v_uid, $v_gid,
+ $v_size, $v_mtime);
+ $v_binary_data_last = pack("a1a100a6a2a32a32a8a8a155a12",
+ $p_type, $v_linkname, $v_magic,
+ $v_version, $v_uname, $v_gname,
+ $v_devmajor, $v_devminor, $v_prefix, '');
+
+ // ----- Calculate the checksum
+ $v_checksum = 0;
+ // ..... First part of the header
+ for ($i=0; $i<148; $i++)
+ $v_checksum += ord(substr($v_binary_data_first,$i,1));
+ // ..... Ignore the checksum value and replace it by ' ' (space)
+ for ($i=148; $i<156; $i++)
+ $v_checksum += ord(' ');
+ // ..... Last part of the header
+ for ($i=156, $j=0; $i<512; $i++, $j++)
+ $v_checksum += ord(substr($v_binary_data_last,$j,1));
+
+ // ----- Write the first 148 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_first, 148);
+
+ // ----- Write the calculated checksum
+ $v_checksum = sprintf("%6s ", DecOct($v_checksum));
+ $v_binary_data = pack("a8", $v_checksum);
+ $this->_writeBlock($v_binary_data, 8);
+
+ // ----- Write the last 356 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_last, 356);
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _writeLongHeader()
+ function _writeLongHeader($p_filename)
+ {
+ $v_size = sprintf("%11s ", DecOct(strlen($p_filename)));
+
+ $v_typeflag = 'L';
+
+ $v_linkname = '';
+
+ $v_magic = '';
+
+ $v_version = '';
+
+ $v_uname = '';
+
+ $v_gname = '';
+
+ $v_devmajor = '';
+
+ $v_devminor = '';
+
+ $v_prefix = '';
+
+ $v_binary_data_first = pack("a100a8a8a8a12A12",
+ '././@LongLink', 0, 0, 0, $v_size, 0);
+ $v_binary_data_last = pack("a1a100a6a2a32a32a8a8a155a12",
+ $v_typeflag, $v_linkname, $v_magic,
+ $v_version, $v_uname, $v_gname,
+ $v_devmajor, $v_devminor, $v_prefix, '');
+
+ // ----- Calculate the checksum
+ $v_checksum = 0;
+ // ..... First part of the header
+ for ($i=0; $i<148; $i++)
+ $v_checksum += ord(substr($v_binary_data_first,$i,1));
+ // ..... Ignore the checksum value and replace it by ' ' (space)
+ for ($i=148; $i<156; $i++)
+ $v_checksum += ord(' ');
+ // ..... Last part of the header
+ for ($i=156, $j=0; $i<512; $i++, $j++)
+ $v_checksum += ord(substr($v_binary_data_last,$j,1));
+
+ // ----- Write the first 148 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_first, 148);
+
+ // ----- Write the calculated checksum
+ $v_checksum = sprintf("%6s ", DecOct($v_checksum));
+ $v_binary_data = pack("a8", $v_checksum);
+ $this->_writeBlock($v_binary_data, 8);
+
+ // ----- Write the last 356 bytes of the header in the archive
+ $this->_writeBlock($v_binary_data_last, 356);
+
+ // ----- Write the filename as content of the block
+ $i=0;
+ while (($v_buffer = substr($p_filename, (($i++)*512), 512)) != '') {
+ $v_binary_data = pack("a512", "$v_buffer");
+ $this->_writeBlock($v_binary_data);
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _readHeader()
+ function _readHeader($v_binary_data, &$v_header)
+ {
+ if (strlen($v_binary_data)==0) {
+ $v_header['filename'] = '';
+ return true;
+ }
+
+ if (strlen($v_binary_data) != 512) {
+ $v_header['filename'] = '';
+ $this->_error('Invalid block size : '.strlen($v_binary_data));
+ return false;
+ }
+
+ if (!is_array($v_header)) {
+ $v_header = array();
+ }
+ // ----- Calculate the checksum
+ $v_checksum = 0;
+ // ..... First part of the header
+ for ($i=0; $i<148; $i++)
+ $v_checksum+=ord(substr($v_binary_data,$i,1));
+ // ..... Ignore the checksum value and replace it by ' ' (space)
+ for ($i=148; $i<156; $i++)
+ $v_checksum += ord(' ');
+ // ..... Last part of the header
+ for ($i=156; $i<512; $i++)
+ $v_checksum+=ord(substr($v_binary_data,$i,1));
+
+ $v_data = unpack("a100filename/a8mode/a8uid/a8gid/a12size/a12mtime/"
+ ."a8checksum/a1typeflag/a100link/a6magic/a2version/"
+ ."a32uname/a32gname/a8devmajor/a8devminor",
+ $v_binary_data);
+
+ // ----- Extract the checksum
+ $v_header['checksum'] = OctDec(trim($v_data['checksum']));
+ if ($v_header['checksum'] != $v_checksum) {
+ $v_header['filename'] = '';
+
+ // ----- Look for last block (empty block)
+ if (($v_checksum == 256) && ($v_header['checksum'] == 0))
+ return true;
+
+ $this->_error('Invalid checksum for file "'.$v_data['filename']
+ .'" : '.$v_checksum.' calculated, '
+ .$v_header['checksum'].' expected');
+ return false;
+ }
+
+ // ----- Extract the properties
+ $v_header['filename'] = trim($v_data['filename']);
+ if ($this->_maliciousFilename($v_header['filename'])) {
+ $this->_error('Malicious .tar detected, file "' . $v_header['filename'] .
+ '" will not install in desired directory tree');
+ return false;
+ }
+ $v_header['mode'] = OctDec(trim($v_data['mode']));
+ $v_header['uid'] = OctDec(trim($v_data['uid']));
+ $v_header['gid'] = OctDec(trim($v_data['gid']));
+ $v_header['size'] = OctDec(trim($v_data['size']));
+ $v_header['mtime'] = OctDec(trim($v_data['mtime']));
+ if (($v_header['typeflag'] = $v_data['typeflag']) == "5") {
+ $v_header['size'] = 0;
+ }
+ $v_header['link'] = trim($v_data['link']);
+ /* ----- All these fields are removed form the header because
+ they do not carry interesting info
+ $v_header[magic] = trim($v_data[magic]);
+ $v_header[version] = trim($v_data[version]);
+ $v_header[uname] = trim($v_data[uname]);
+ $v_header[gname] = trim($v_data[gname]);
+ $v_header[devmajor] = trim($v_data[devmajor]);
+ $v_header[devminor] = trim($v_data[devminor]);
+ */
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _maliciousFilename()
+ /**
+ * Detect and report a malicious file name
+ *
+ * @param string $file
+ * @return bool
+ * @access private
+ */
+ function _maliciousFilename($file)
+ {
+ if (strpos($file, '/../') !== false) {
+ return true;
+ }
+ if (strpos($file, '../') === 0) {
+ return true;
+ }
+ return false;
+ }
+ // }}}
+
+ // {{{ _readLongHeader()
+ function _readLongHeader(&$v_header)
+ {
+ $v_filename = '';
+ $n = floor($v_header['size']/512);
+ for ($i=0; $i<$n; $i++) {
+ $v_content = $this->_readBlock();
+ $v_filename .= $v_content;
+ }
+ if (($v_header['size'] % 512) != 0) {
+ $v_content = $this->_readBlock();
+ $v_filename .= $v_content;
+ }
+
+ // ----- Read the next header
+ $v_binary_data = $this->_readBlock();
+
+ if (!$this->_readHeader($v_binary_data, $v_header))
+ return false;
+
+ $v_filename = trim($v_filename);
+ $v_header['filename'] = $v_filename;
+ if ($this->_maliciousFilename($v_filename)) {
+ $this->_error('Malicious .tar detected, file "' . $v_filename .
+ '" will not install in desired directory tree');
+ return false;
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _extractInString()
+ /**
+ * This method extract from the archive one file identified by $p_filename.
+ * The return value is a string with the file content, or NULL on error.
+ * @param string $p_filename The path of the file to extract in a string.
+ * @return a string with the file content or NULL.
+ * @access private
+ */
+ function _extractInString($p_filename)
+ {
+ $v_result_str = "";
+
+ While (strlen($v_binary_data = $this->_readBlock()) != 0)
+ {
+ if (!$this->_readHeader($v_binary_data, $v_header))
+ return NULL;
+
+ if ($v_header['filename'] == '')
+ continue;
+
+ // ----- Look for long filename
+ if ($v_header['typeflag'] == 'L') {
+ if (!$this->_readLongHeader($v_header))
+ return NULL;
+ }
+
+ if ($v_header['filename'] == $p_filename) {
+ if ($v_header['typeflag'] == "5") {
+ $this->_error('Unable to extract in string a directory '
+ .'entry {'.$v_header['filename'].'}');
+ return NULL;
+ } else {
+ $n = floor($v_header['size']/512);
+ for ($i=0; $i<$n; $i++) {
+ $v_result_str .= $this->_readBlock();
+ }
+ if (($v_header['size'] % 512) != 0) {
+ $v_content = $this->_readBlock();
+ $v_result_str .= substr($v_content, 0,
+ ($v_header['size'] % 512));
+ }
+ return $v_result_str;
+ }
+ } else {
+ $this->_jumpBlock(ceil(($v_header['size']/512)));
+ }
+ }
+
+ return NULL;
+ }
+ // }}}
+
+ // {{{ _extractList()
+ function _extractList($p_path, &$p_list_detail, $p_mode,
+ $p_file_list, $p_remove_path)
+ {
+ $v_result=true;
+ $v_nb = 0;
+ $v_extract_all = true;
+ $v_listing = false;
+
+ $p_path = $this->_translateWinPath($p_path, false);
+ if ($p_path == '' || (substr($p_path, 0, 1) != '/'
+ && substr($p_path, 0, 3) != "../" && !strpos($p_path, ':'))) {
+ $p_path = "./".$p_path;
+ }
+ $p_remove_path = $this->_translateWinPath($p_remove_path);
+
+ // ----- Look for path to remove format (should end by /)
+ if (($p_remove_path != '') && (substr($p_remove_path, -1) != '/'))
+ $p_remove_path .= '/';
+ $p_remove_path_size = strlen($p_remove_path);
+
+ switch ($p_mode) {
+ case "complete" :
+ $v_extract_all = TRUE;
+ $v_listing = FALSE;
+ break;
+ case "partial" :
+ $v_extract_all = FALSE;
+ $v_listing = FALSE;
+ break;
+ case "list" :
+ $v_extract_all = FALSE;
+ $v_listing = TRUE;
+ break;
+ default :
+ $this->_error('Invalid extract mode ('.$p_mode.')');
+ return false;
+ }
+
+ clearstatcache();
+
+ while (strlen($v_binary_data = $this->_readBlock()) != 0)
+ {
+ $v_extract_file = FALSE;
+ $v_extraction_stopped = 0;
+
+ if (!$this->_readHeader($v_binary_data, $v_header))
+ return false;
+
+ if ($v_header['filename'] == '') {
+ continue;
+ }
+
+ // ----- Look for long filename
+ if ($v_header['typeflag'] == 'L') {
+ if (!$this->_readLongHeader($v_header))
+ return false;
+ }
+
+ if ((!$v_extract_all) && (is_array($p_file_list))) {
+ // ----- By default no unzip if the file is not found
+ $v_extract_file = false;
+
+ for ($i=0; $i<sizeof($p_file_list); $i++) {
+ // ----- Look if it is a directory
+ if (substr($p_file_list[$i], -1) == '/') {
+ // ----- Look if the directory is in the filename path
+ if ((strlen($v_header['filename']) > strlen($p_file_list[$i]))
+ && (substr($v_header['filename'], 0, strlen($p_file_list[$i]))
+ == $p_file_list[$i])) {
+ $v_extract_file = TRUE;
+ break;
+ }
+ }
+
+ // ----- It is a file, so compare the file names
+ elseif ($p_file_list[$i] == $v_header['filename']) {
+ $v_extract_file = TRUE;
+ break;
+ }
+ }
+ } else {
+ $v_extract_file = TRUE;
+ }
+
+ // ----- Look if this file need to be extracted
+ if (($v_extract_file) && (!$v_listing))
+ {
+ if (($p_remove_path != '')
+ && (substr($v_header['filename'], 0, $p_remove_path_size)
+ == $p_remove_path))
+ $v_header['filename'] = substr($v_header['filename'],
+ $p_remove_path_size);
+ if (($p_path != './') && ($p_path != '/')) {
+ while (substr($p_path, -1) == '/')
+ $p_path = substr($p_path, 0, strlen($p_path)-1);
+
+ if (substr($v_header['filename'], 0, 1) == '/')
+ $v_header['filename'] = $p_path.$v_header['filename'];
+ else
+ $v_header['filename'] = $p_path.'/'.$v_header['filename'];
+ }
+ if (file_exists($v_header['filename'])) {
+ if ( (@is_dir($v_header['filename']))
+ && ($v_header['typeflag'] == '')) {
+ $this->_error('File '.$v_header['filename']
+ .' already exists as a directory');
+ return false;
+ }
+ if ( ($this->_isArchive($v_header['filename']))
+ && ($v_header['typeflag'] == "5")) {
+ $this->_error('Directory '.$v_header['filename']
+ .' already exists as a file');
+ return false;
+ }
+ if (!is_writeable($v_header['filename'])) {
+ $this->_error('File '.$v_header['filename']
+ .' already exists and is write protected');
+ return false;
+ }
+ if (filemtime($v_header['filename']) > $v_header['mtime']) {
+ // To be completed : An error or silent no replace ?
+ }
+ }
+
+ // ----- Check the directory availability and create it if necessary
+ elseif (($v_result
+ = $this->_dirCheck(($v_header['typeflag'] == "5"
+ ?$v_header['filename']
+ :dirname($v_header['filename'])))) != 1) {
+ $this->_error('Unable to create path for '.$v_header['filename']);
+ return false;
+ }
+
+ if ($v_extract_file) {
+ if ($v_header['typeflag'] == "5") {
+ if (!@file_exists($v_header['filename'])) {
+ // Drupal integration.
+ // Changed the code to use drupal_mkdir() instead of mkdir().
+ if (!@drupal_mkdir($v_header['filename'], 0777)) {
+ $this->_error('Unable to create directory {'
+ .$v_header['filename'].'}');
+ return false;
+ }
+ }
+ } elseif ($v_header['typeflag'] == "2") {
+ if (@file_exists($v_header['filename'])) {
+ @drupal_unlink($v_header['filename']);
+ }
+ if (!@symlink($v_header['link'], $v_header['filename'])) {
+ $this->_error('Unable to extract symbolic link {'
+ .$v_header['filename'].'}');
+ return false;
+ }
+ } else {
+ if (($v_dest_file = @fopen($v_header['filename'], "wb")) == 0) {
+ $this->_error('Error while opening {'.$v_header['filename']
+ .'} in write binary mode');
+ return false;
+ } else {
+ $n = floor($v_header['size']/512);
+ for ($i=0; $i<$n; $i++) {
+ $v_content = $this->_readBlock();
+ fwrite($v_dest_file, $v_content, 512);
+ }
+ if (($v_header['size'] % 512) != 0) {
+ $v_content = $this->_readBlock();
+ fwrite($v_dest_file, $v_content, ($v_header['size'] % 512));
+ }
+
+ @fclose($v_dest_file);
+
+ // ----- Change the file mode, mtime
+ @touch($v_header['filename'], $v_header['mtime']);
+ if ($v_header['mode'] & 0111) {
+ // make file executable, obey umask
+ $mode = fileperms($v_header['filename']) | (~umask() & 0111);
+ @chmod($v_header['filename'], $mode);
+ }
+ }
+
+ // ----- Check the file size
+ clearstatcache();
+ if (filesize($v_header['filename']) != $v_header['size']) {
+ $this->_error('Extracted file '.$v_header['filename']
+ .' does not have the correct file size \''
+ .filesize($v_header['filename'])
+ .'\' ('.$v_header['size']
+ .' expected). Archive may be corrupted.');
+ return false;
+ }
+ }
+ } else {
+ $this->_jumpBlock(ceil(($v_header['size']/512)));
+ }
+ } else {
+ $this->_jumpBlock(ceil(($v_header['size']/512)));
+ }
+
+ /* TBC : Seems to be unused ...
+ if ($this->_compress)
+ $v_end_of_file = @gzeof($this->_file);
+ else
+ $v_end_of_file = @feof($this->_file);
+ */
+
+ if ($v_listing || $v_extract_file || $v_extraction_stopped) {
+ // ----- Log extracted files
+ if (($v_file_dir = dirname($v_header['filename']))
+ == $v_header['filename'])
+ $v_file_dir = '';
+ if ((substr($v_header['filename'], 0, 1) == '/') && ($v_file_dir == ''))
+ $v_file_dir = '/';
+
+ $p_list_detail[$v_nb++] = $v_header;
+ if (is_array($p_file_list) && (count($p_list_detail) == count($p_file_list))) {
+ return true;
+ }
+ }
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _openAppend()
+ function _openAppend()
+ {
+ if (filesize($this->_tarname) == 0)
+ return $this->_openWrite();
+
+ if ($this->_compress) {
+ $this->_close();
+
+ if (!@rename($this->_tarname, $this->_tarname.".tmp")) {
+ $this->_error('Error while renaming \''.$this->_tarname
+ .'\' to temporary file \''.$this->_tarname
+ .'.tmp\'');
+ return false;
+ }
+
+ if ($this->_compress_type == 'gz')
+ $v_temp_tar = @gzopen($this->_tarname.".tmp", "rb");
+ elseif ($this->_compress_type == 'bz2')
+ $v_temp_tar = @bzopen($this->_tarname.".tmp", "r");
+
+ if ($v_temp_tar == 0) {
+ $this->_error('Unable to open file \''.$this->_tarname
+ .'.tmp\' in binary read mode');
+ @rename($this->_tarname.".tmp", $this->_tarname);
+ return false;
+ }
+
+ if (!$this->_openWrite()) {
+ @rename($this->_tarname.".tmp", $this->_tarname);
+ return false;
+ }
+
+ if ($this->_compress_type == 'gz') {
+ while (!@gzeof($v_temp_tar)) {
+ $v_buffer = @gzread($v_temp_tar, 512);
+ if ($v_buffer == ARCHIVE_TAR_END_BLOCK) {
+ // do not copy end blocks, we will re-make them
+ // after appending
+ continue;
+ }
+ $v_binary_data = pack("a512", $v_buffer);
+ $this->_writeBlock($v_binary_data);
+ }
+
+ @gzclose($v_temp_tar);
+ }
+ elseif ($this->_compress_type == 'bz2') {
+ while (strlen($v_buffer = @bzread($v_temp_tar, 512)) > 0) {
+ if ($v_buffer == ARCHIVE_TAR_END_BLOCK) {
+ continue;
+ }
+ $v_binary_data = pack("a512", $v_buffer);
+ $this->_writeBlock($v_binary_data);
+ }
+
+ @bzclose($v_temp_tar);
+ }
+
+ if (!@drupal_unlink($this->_tarname.".tmp")) {
+ $this->_error('Error while deleting temporary file \''
+ .$this->_tarname.'.tmp\'');
+ }
+
+ } else {
+ // ----- For not compressed tar, just add files before the last
+ // one or two 512 bytes block
+ if (!$this->_openReadWrite())
+ return false;
+
+ clearstatcache();
+ $v_size = filesize($this->_tarname);
+
+ // We might have zero, one or two end blocks.
+ // The standard is two, but we should try to handle
+ // other cases.
+ fseek($this->_file, $v_size - 1024);
+ if (fread($this->_file, 512) == ARCHIVE_TAR_END_BLOCK) {
+ fseek($this->_file, $v_size - 1024);
+ }
+ elseif (fread($this->_file, 512) == ARCHIVE_TAR_END_BLOCK) {
+ fseek($this->_file, $v_size - 512);
+ }
+ }
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _append()
+ function _append($p_filelist, $p_add_dir='', $p_remove_dir='')
+ {
+ if (!$this->_openAppend())
+ return false;
+
+ if ($this->_addList($p_filelist, $p_add_dir, $p_remove_dir))
+ $this->_writeFooter();
+
+ $this->_close();
+
+ return true;
+ }
+ // }}}
+
+ // {{{ _dirCheck()
+
+ /**
+ * Check if a directory exists and create it (including parent
+ * dirs) if not.
+ *
+ * @param string $p_dir directory to check
+ *
+ * @return bool TRUE if the directory exists or was created
+ */
+ function _dirCheck($p_dir)
+ {
+ clearstatcache();
+ if ((@is_dir($p_dir)) || ($p_dir == ''))
+ return true;
+
+ $p_parent_dir = dirname($p_dir);
+
+ if (($p_parent_dir != $p_dir) &&
+ ($p_parent_dir != '') &&
+ (!$this->_dirCheck($p_parent_dir)))
+ return false;
+
+ // Drupal integration.
+ // Changed the code to use drupal_mkdir() instead of mkdir().
+ if (!@drupal_mkdir($p_dir, 0777)) {
+ $this->_error("Unable to create directory '$p_dir'");
+ return false;
+ }
+
+ return true;
+ }
+
+ // }}}
+
+ // {{{ _pathReduction()
+
+ /**
+ * Compress path by changing for example "/dir/foo/../bar" to "/dir/bar",
+ * rand emove double slashes.
+ *
+ * @param string $p_dir path to reduce
+ *
+ * @return string reduced path
+ *
+ * @access private
+ *
+ */
+ function _pathReduction($p_dir)
+ {
+ $v_result = '';
+
+ // ----- Look for not empty path
+ if ($p_dir != '') {
+ // ----- Explode path by directory names
+ $v_list = explode('/', $p_dir);
+
+ // ----- Study directories from last to first
+ for ($i=sizeof($v_list)-1; $i>=0; $i--) {
+ // ----- Look for current path
+ if ($v_list[$i] == ".") {
+ // ----- Ignore this directory
+ // Should be the first $i=0, but no check is done
+ }
+ else if ($v_list[$i] == "..") {
+ // ----- Ignore it and ignore the $i-1
+ $i--;
+ }
+ else if ( ($v_list[$i] == '')
+ && ($i!=(sizeof($v_list)-1))
+ && ($i!=0)) {
+ // ----- Ignore only the double '//' in path,
+ // but not the first and last /
+ } else {
+ $v_result = $v_list[$i].($i!=(sizeof($v_list)-1)?'/'
+ .$v_result:'');
+ }
+ }
+ }
+ $v_result = strtr($v_result, '\\', '/');
+ return $v_result;
+ }
+
+ // }}}
+
+ // {{{ _translateWinPath()
+ function _translateWinPath($p_path, $p_remove_disk_letter=true)
+ {
+ if (defined('OS_WINDOWS') && OS_WINDOWS) {
+ // ----- Look for potential disk letter
+ if ( ($p_remove_disk_letter)
+ && (($v_position = strpos($p_path, ':')) != false)) {
+ $p_path = substr($p_path, $v_position+1);
+ }
+ // ----- Change potential windows directory separator
+ if ((strpos($p_path, '\\') > 0) || (substr($p_path, 0,1) == '\\')) {
+ $p_path = strtr($p_path, '\\', '/');
+ }
+ }
+ return $p_path;
+ }
+ // }}}
+
+}
+?>
diff --git a/core/modules/system/system.test b/core/modules/system/system.test
new file mode 100644
index 000000000000..6cf203d3a524
--- /dev/null
+++ b/core/modules/system/system.test
@@ -0,0 +1,2564 @@
+<?php
+
+/**
+ * @file
+ * Tests for system.module.
+ */
+
+/**
+ * Helper class for module test cases.
+ */
+class ModuleTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ function setUp() {
+ parent::setUp('system_test');
+
+ $this->admin_user = $this->drupalCreateUser(array('access administration pages', 'administer modules'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Assert there are tables that begin with the specified base table name.
+ *
+ * @param $base_table
+ * Beginning of table name to look for.
+ * @param $count
+ * (optional) Whether or not to assert that there are tables that match the
+ * specified base table. Defaults to TRUE.
+ */
+ function assertTableCount($base_table, $count = TRUE) {
+ $tables = db_find_tables(Database::getConnection()->prefixTables('{' . $base_table . '}') . '%');
+
+ if ($count) {
+ return $this->assertTrue($tables, t('Tables matching "@base_table" found.', array('@base_table' => $base_table)));
+ }
+ return $this->assertFalse($tables, t('Tables matching "@base_table" not found.', array('@base_table' => $base_table)));
+ }
+
+ /**
+ * Assert that all tables defined in a module's hook_schema() exist.
+ *
+ * @param $module
+ * The name of the module.
+ */
+ function assertModuleTablesExist($module) {
+ $tables = array_keys(drupal_get_schema_unprocessed($module));
+ $tables_exist = TRUE;
+ foreach ($tables as $table) {
+ if (!db_table_exists($table)) {
+ $tables_exist = FALSE;
+ }
+ }
+ return $this->assertTrue($tables_exist, t('All database tables defined by the @module module exist.', array('@module' => $module)));
+ }
+
+ /**
+ * Assert that none of the tables defined in a module's hook_schema() exist.
+ *
+ * @param $module
+ * The name of the module.
+ */
+ function assertModuleTablesDoNotExist($module) {
+ $tables = array_keys(drupal_get_schema_unprocessed($module));
+ $tables_exist = FALSE;
+ foreach ($tables as $table) {
+ if (db_table_exists($table)) {
+ $tables_exist = TRUE;
+ }
+ }
+ return $this->assertFalse($tables_exist, t('None of the database tables defined by the @module module exist.', array('@module' => $module)));
+ }
+
+ /**
+ * Assert the list of modules are enabled or disabled.
+ *
+ * @param $modules
+ * Module list to check.
+ * @param $enabled
+ * Expected module state.
+ */
+ function assertModules(array $modules, $enabled) {
+ module_list(TRUE);
+ foreach ($modules as $module) {
+ if ($enabled) {
+ $message = 'Module "@module" is enabled.';
+ }
+ else {
+ $message = 'Module "@module" is not enabled.';
+ }
+ $this->assertEqual(module_exists($module), $enabled, t($message, array('@module' => $module)));
+ }
+ }
+
+ /**
+ * Verify a log entry was entered for a module's status change.
+ * Called in the same way of the expected original watchdog() execution.
+ *
+ * @param $type
+ * The category to which this message belongs.
+ * @param $message
+ * The message to store in the log. Keep $message translatable
+ * by not concatenating dynamic values into it! Variables in the
+ * message should be added by using placeholder strings alongside
+ * the variables argument to declare the value of the placeholders.
+ * See t() for documentation on how $message and $variables interact.
+ * @param $variables
+ * Array of variables to replace in the message on display or
+ * NULL if message is already translated or not possible to
+ * translate.
+ * @param $severity
+ * The severity of the message, as per RFC 3164.
+ * @param $link
+ * A link to associate with the message.
+ */
+ function assertLogMessage($type, $message, $variables = array(), $severity = WATCHDOG_NOTICE, $link = '') {
+ $count = db_select('watchdog', 'w')
+ ->condition('type', $type)
+ ->condition('message', $message)
+ ->condition('variables', serialize($variables))
+ ->condition('severity', $severity)
+ ->condition('link', $link)
+ ->countQuery()
+ ->execute()
+ ->fetchField();
+ $this->assertTrue($count > 0, t('watchdog table contains @count rows for @message', array('@count' => $count, '@message' => $message)));
+ }
+}
+
+/**
+ * Test module enabling/disabling functionality.
+ */
+class EnableDisableTestCase extends ModuleTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Enable/disable modules',
+ 'description' => 'Enable/disable core module and confirm table creation/deletion.',
+ 'group' => 'Module',
+ );
+ }
+
+ /**
+ * Test that all core modules can be enabled, disabled and uninstalled.
+ */
+ function testEnableDisable() {
+ // Try to enable, disable and uninstall all core modules, unless they are
+ // hidden or required.
+ $modules = system_rebuild_module_data();
+ foreach ($modules as $name => $module) {
+ if ($module->info['package'] != 'Core' || !empty($module->info['hidden']) || !empty($module->info['required'])) {
+ unset($modules[$name]);
+ }
+ }
+ $this->assertTrue(count($modules), t('Found @count core modules that we can try to enable in this test.', array('@count' => count($modules))));
+
+ // Enable the dblog module first, since we will be asserting the presence
+ // of log messages throughout the test.
+ if (isset($modules['dblog'])) {
+ $modules = array('dblog' => $modules['dblog']) + $modules;
+ }
+
+ // Set a variable so that the hook implementations in system_test.module
+ // will display messages via drupal_set_message().
+ variable_set('test_verbose_module_hooks', TRUE);
+
+ // Throughout this test, some modules may be automatically enabled (due to
+ // dependencies). We'll keep track of them in an array, so we can handle
+ // them separately.
+ $automatically_enabled = array();
+
+ // Go through each module in the list and try to enable it (unless it was
+ // already enabled automatically due to a dependency).
+ foreach ($modules as $name => $module) {
+ if (empty($automatically_enabled[$name])) {
+ // Start a list of modules that we expect to be enabled this time.
+ $modules_to_enable = array($name);
+
+ // Find out if the module has any dependencies that aren't enabled yet;
+ // if so, add them to the list of modules we expect to be automatically
+ // enabled.
+ foreach (array_keys($module->requires) as $dependency) {
+ if (isset($modules[$dependency]) && empty($automatically_enabled[$dependency])) {
+ $modules_to_enable[] = $dependency;
+ $automatically_enabled[$dependency] = TRUE;
+ }
+ }
+
+ // Check that each module is not yet enabled and does not have any
+ // database tables yet.
+ foreach ($modules_to_enable as $module_to_enable) {
+ $this->assertModules(array($module_to_enable), FALSE);
+ $this->assertModuleTablesDoNotExist($module_to_enable);
+ }
+
+ // Install and enable the module.
+ $edit = array();
+ $edit['modules[Core][' . $name . '][enable]'] = $name;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ // Handle the case where modules were installed along with this one and
+ // where we therefore hit a confirmation screen.
+ if (count($modules_to_enable) > 1) {
+ $this->drupalPost(NULL, array(), t('Continue'));
+ }
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+
+ // Check that hook_modules_installed() and hook_modules_enabled() were
+ // invoked with the expected list of modules, that each module's
+ // database tables now exist, and that appropriate messages appear in
+ // the logs.
+ foreach ($modules_to_enable as $module_to_enable) {
+ $this->assertText(t('hook_modules_installed fired for @module', array('@module' => $module_to_enable)));
+ $this->assertText(t('hook_modules_enabled fired for @module', array('@module' => $module_to_enable)));
+ $this->assertModules(array($module_to_enable), TRUE);
+ $this->assertModuleTablesExist($module_to_enable);
+ $this->assertLogMessage('system', "%module module installed.", array('%module' => $module_to_enable), WATCHDOG_INFO);
+ $this->assertLogMessage('system', "%module module enabled.", array('%module' => $module_to_enable), WATCHDOG_INFO);
+ }
+
+ // Disable and uninstall the original module, and check appropriate
+ // hooks, tables, and log messages. (Later, we'll go back and do the
+ // same thing for modules that were enabled automatically.) Skip this
+ // for the dblog module, because that is needed for the test; we'll go
+ // back and do that one at the end also.
+ if ($name != 'dblog') {
+ $this->assertSuccessfulDisableAndUninstall($name);
+ }
+ }
+ }
+
+ // Go through all modules that were automatically enabled, and try to
+ // disable and uninstall them one by one.
+ while (!empty($automatically_enabled)) {
+ $initial_count = count($automatically_enabled);
+ foreach (array_keys($automatically_enabled) as $name) {
+ // If the module can't be disabled due to dependencies, skip it and try
+ // again the next time. Otherwise, try to disable it.
+ $this->drupalGet('admin/modules');
+ $disabled_checkbox = $this->xpath('//input[@type="checkbox" and @disabled="disabled" and @name="modules[Core][' . $name . '][enable]"]');
+ if (empty($disabled_checkbox) && $name != 'dblog') {
+ unset($automatically_enabled[$name]);
+ $this->assertSuccessfulDisableAndUninstall($name);
+ }
+ }
+ $final_count = count($automatically_enabled);
+ // If all checkboxes were disabled, something is really wrong with the
+ // test. Throw a failure and avoid an infinite loop.
+ if ($initial_count == $final_count) {
+ $this->fail(t('Remaining modules could not be disabled.'));
+ break;
+ }
+ }
+
+ // Disable and uninstall the dblog module last, since we needed it for
+ // assertions in all the above tests.
+ if (isset($modules['dblog'])) {
+ $this->assertSuccessfulDisableAndUninstall('dblog');
+ }
+
+ // Now that all modules have been tested, go back and try to enable them
+ // all again at once. This tests two things:
+ // - That each module can be successfully enabled again after being
+ // uninstalled.
+ // - That enabling more than one module at the same time does not lead to
+ // any errors.
+ $edit = array();
+ foreach (array_keys($modules) as $name) {
+ $edit['modules[Core][' . $name . '][enable]'] = $name;
+ }
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ }
+
+ /**
+ * Tests entity cache after enabling a module with a dependency on an enitity
+ * providing module.
+ *
+ * @see entity_cache_test_watchdog()
+ */
+ function testEntityCache() {
+ module_enable(array('entity_cache_test'));
+ $info = variable_get('entity_cache_test');
+ $this->assertEqual($info['label'], 'Entity Cache Test', 'Entity info label is correct.');
+ $this->assertEqual($info['controller class'], 'DrupalDefaultEntityController', 'Entity controller class info is correct.');
+ }
+
+ /**
+ * Disables and uninstalls a module and asserts that it was done correctly.
+ *
+ * @param $module
+ * The name of the module to disable and uninstall.
+ */
+ function assertSuccessfulDisableAndUninstall($module) {
+ // Disable the module.
+ $edit = array();
+ $edit['modules[Core][' . $module . '][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ $this->assertModules(array($module), FALSE);
+
+ // Check that the appropriate hook was fired and the appropriate log
+ // message appears.
+ $this->assertText(t('hook_modules_disabled fired for @module', array('@module' => $module)));
+ $this->assertLogMessage('system', "%module module disabled.", array('%module' => $module), WATCHDOG_INFO);
+
+ // Check that the module's database tables still exist.
+ $this->assertModuleTablesExist($module);
+
+ // Uninstall the module.
+ $edit = array();
+ $edit['uninstall[' . $module . ']'] = $module;
+ $this->drupalPost('admin/modules/uninstall', $edit, t('Uninstall'));
+ $this->drupalPost(NULL, NULL, t('Uninstall'));
+ $this->assertText(t('The selected modules have been uninstalled.'), t('Modules status has been updated.'));
+ $this->assertModules(array($module), FALSE);
+
+ // Check that the appropriate hook was fired and the appropriate log
+ // message appears. (But don't check for the log message if the dblog
+ // module was just uninstalled, since the {watchdog} table won't be there
+ // anymore.)
+ $this->assertText(t('hook_modules_uninstalled fired for @module', array('@module' => $module)));
+ if ($module != 'dblog') {
+ $this->assertLogMessage('system', "%module module uninstalled.", array('%module' => $module), WATCHDOG_INFO);
+ }
+
+ // Check that the module's database tables no longer exist.
+ $this->assertModuleTablesDoNotExist($module);
+ }
+}
+
+/**
+ * Tests failure of hook_requirements('install').
+ */
+class HookRequirementsTestCase extends ModuleTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Requirements hook failure',
+ 'description' => "Attempts enabling a module that fails hook_requirements('install').",
+ 'group' => 'Module',
+ );
+ }
+
+ /**
+ * Assert that a module cannot be installed if it fails hook_requirements().
+ */
+ function testHookRequirementsFailure() {
+ $this->assertModules(array('requirements1_test'), FALSE);
+
+ // Attempt to install the requirements1_test module.
+ $edit = array();
+ $edit['modules[Testing][requirements1_test][enable]'] = 'requirements1_test';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+
+ // Makes sure the module was NOT installed.
+ $this->assertText(t('Requirements 1 Test failed requirements'), t('Modules status has been updated.'));
+ $this->assertModules(array('requirements1_test'), FALSE);
+ }
+}
+
+/**
+ * Test module dependency functionality.
+ */
+class ModuleDependencyTestCase extends ModuleTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Module dependencies',
+ 'description' => 'Enable module without dependency enabled.',
+ 'group' => 'Module',
+ );
+ }
+
+ /**
+ * Attempt to enable translation module without locale enabled.
+ */
+ function testEnableWithoutDependency() {
+ // Attempt to enable content translation without locale enabled.
+ $edit = array();
+ $edit['modules[Core][translation][enable]'] = 'translation';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('Some required modules must be enabled'), t('Dependency required.'));
+
+ $this->assertModules(array('translation', 'locale'), FALSE);
+
+ // Assert that the locale tables weren't enabled.
+ $this->assertTableCount('languages', FALSE);
+ $this->assertTableCount('locale', FALSE);
+
+ $this->drupalPost(NULL, NULL, t('Continue'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+
+ $this->assertModules(array('translation', 'locale'), TRUE);
+
+ // Assert that the locale tables were enabled.
+ $this->assertTableCount('languages', TRUE);
+ $this->assertTableCount('locale', TRUE);
+ }
+
+ /**
+ * Attempt to enable a module with a missing dependency.
+ */
+ function testMissingModules() {
+ // Test that the system_dependencies_test module is marked
+ // as missing a dependency.
+ $this->drupalGet('admin/modules');
+ $this->assertRaw(t('@module (<span class="admin-missing">missing</span>)', array('@module' => drupal_ucfirst('_missing_dependency'))), t('A module with missing dependencies is marked as such.'));
+ $checkbox = $this->xpath('//input[@type="checkbox" and @disabled="disabled" and @name="modules[Testing][system_dependencies_test][enable]"]');
+ $this->assert(count($checkbox) == 1, t('Checkbox for the module is disabled.'));
+
+ // Force enable the system_dependencies_test module.
+ module_enable(array('system_dependencies_test'), FALSE);
+
+ // Verify that the module is forced to be disabled when submitting
+ // the module page.
+ $this->drupalPost('admin/modules', array(), t('Save configuration'));
+ $this->assertText(t('The @module module is missing, so the following module will be disabled: @depends.', array('@module' => '_missing_dependency', '@depends' => 'system_dependencies_test')), t('The module missing dependencies will be disabled.'));
+
+ // Confirm.
+ $this->drupalPost(NULL, NULL, t('Continue'));
+
+ // Verify that the module has been disabled.
+ $this->assertModules(array('system_dependencies_test'), FALSE);
+ }
+
+ /**
+ * Tests enabling a module that depends on a module which fails hook_requirements().
+ */
+ function testEnableRequirementsFailureDependency() {
+ $this->assertModules(array('requirements1_test'), FALSE);
+ $this->assertModules(array('requirements2_test'), FALSE);
+
+ // Attempt to install both modules at the same time.
+ $edit = array();
+ $edit['modules[Testing][requirements1_test][enable]'] = 'requirements1_test';
+ $edit['modules[Testing][requirements2_test][enable]'] = 'requirements2_test';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+
+ // Makes sure the modules were NOT installed.
+ $this->assertText(t('Requirements 1 Test failed requirements'), t('Modules status has been updated.'));
+ $this->assertModules(array('requirements1_test'), FALSE);
+ $this->assertModules(array('requirements2_test'), FALSE);
+
+ // Makes sure that already enabled modules the failing modules depend on
+ // were not disabled.
+ $this->assertModules(array('comment'), TRUE);
+
+ }
+
+ /**
+ * Tests that module dependencies are enabled in the correct order via the
+ * UI. Dependencies should be enabled before their dependents.
+ */
+ function testModuleEnableOrder() {
+ module_enable(array('module_test'), FALSE);
+ $this->resetAll();
+ $this->assertModules(array('module_test'), TRUE);
+ variable_set('dependency_test', 'dependency');
+ // module_test creates a dependency chain: forum depends on poll, which
+ // depends on php. The correct enable order is, php, poll, forum.
+ $expected_order = array('php', 'poll', 'forum');
+
+ // Enable the modules through the UI, verifying that the dependency chain
+ // is correct.
+ $edit = array();
+ $edit['modules[Core][forum][enable]'] = 'forum';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertModules(array('forum'), FALSE);
+ $this->assertText(t('You must enable the Poll, PHP filter modules to install Forum.'), t('Dependency chain created.'));
+ $edit['modules[Core][poll][enable]'] = 'poll';
+ $edit['modules[Core][php][enable]'] = 'php';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertModules(array('forum', 'poll', 'php'), TRUE);
+
+ // Check the actual order which is saved by module_test_modules_enabled().
+ $this->assertIdentical(variable_get('test_module_enable_order', FALSE), $expected_order, t('Modules enabled in the correct order.'));
+ }
+
+ /**
+ * Tests attempting to uninstall a module that has installed dependents.
+ */
+ function testUninstallDependents() {
+ // Enable the forum module.
+ $edit = array('modules[Core][forum][enable]' => 'forum');
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertModules(array('forum'), TRUE);
+
+ // Disable forum and comment. Both should now be installed but disabled.
+ $edit = array('modules[Core][forum][enable]' => FALSE);
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertModules(array('forum'), FALSE);
+ $edit = array('modules[Core][comment][enable]' => FALSE);
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertModules(array('comment'), FALSE);
+
+ // Check that the taxonomy module cannot be uninstalled.
+ $this->drupalGet('admin/modules/uninstall');
+ $checkbox = $this->xpath('//input[@type="checkbox" and @disabled="disabled" and @name="uninstall[comment]"]');
+ $this->assert(count($checkbox) == 1, t('Checkbox for uninstalling the comment module is disabled.'));
+
+ // Uninstall the forum module, and check that taxonomy now can also be
+ // uninstalled.
+ $edit = array('uninstall[forum]' => 'forum');
+ $this->drupalPost('admin/modules/uninstall', $edit, t('Uninstall'));
+ $this->drupalPost(NULL, NULL, t('Uninstall'));
+ $this->assertText(t('The selected modules have been uninstalled.'), t('Modules status has been updated.'));
+ $edit = array('uninstall[comment]' => 'comment');
+ $this->drupalPost('admin/modules/uninstall', $edit, t('Uninstall'));
+ $this->drupalPost(NULL, NULL, t('Uninstall'));
+ $this->assertText(t('The selected modules have been uninstalled.'), t('Modules status has been updated.'));
+ }
+}
+
+/**
+ * Test module dependency on specific versions.
+ */
+class ModuleVersionTestCase extends ModuleTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Module versions',
+ 'description' => 'Check module version dependencies.',
+ 'group' => 'Module',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('module_test');
+ }
+
+ /**
+ * Test version dependencies.
+ */
+ function testModuleVersions() {
+ $dependencies = array(
+ // Alternating between being compatible and incompatible with 8.x-2.4-beta3.
+ // The first is always a compatible.
+ 'common_test',
+ // Branch incompatibility.
+ 'common_test (1.x)',
+ // Branch compatibility.
+ 'common_test (2.x)',
+ // Another branch incompatibility.
+ 'common_test (>2.x)',
+ // Another branch compatibility.
+ 'common_test (<=2.x)',
+ // Another branch incompatibility.
+ 'common_test (<2.x)',
+ // Another branch compatibility.
+ 'common_test (>=2.x)',
+ // Nonsense, misses a dash. Incompatible with everything.
+ 'common_test (=8.x2.x, >=2.4)',
+ // Core version is optional. Compatible.
+ 'common_test (=8.x-2.x, >=2.4-alpha2)',
+ // Test !=, explicitly incompatible.
+ 'common_test (=2.x, !=2.4-beta3)',
+ // Three operations. Compatible.
+ 'common_test (=2.x, !=2.3, <2.5)',
+ // Testing extra version. Incompatible.
+ 'common_test (<=2.4-beta2)',
+ // Testing extra version. Compatible.
+ 'common_test (>2.4-beta2)',
+ // Testing extra version. Incompatible.
+ 'common_test (>2.4-rc0)',
+ );
+ variable_set('dependencies', $dependencies);
+ $n = count($dependencies);
+ for ($i = 0; $i < $n; $i++) {
+ $this->drupalGet('admin/modules');
+ $checkbox = $this->xpath('//input[@id="edit-modules-testing-module-test-enable"]');
+ $this->assertEqual(!empty($checkbox[0]['disabled']), $i % 2, $dependencies[$i]);
+ }
+ }
+}
+
+/**
+ * Test required modules functionality.
+ */
+class ModuleRequiredTestCase extends ModuleTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Required modules',
+ 'description' => 'Attempt disabling of required modules.',
+ 'group' => 'Module',
+ );
+ }
+
+ /**
+ * Assert that core required modules cannot be disabled.
+ */
+ function testDisableRequired() {
+ $module_info = system_get_info('module');
+ $this->drupalGet('admin/modules');
+ foreach ($module_info as $module => $info) {
+ // Check to make sure the checkbox for each required module is disabled
+ // and checked (or absent from the page if the module is also hidden).
+ if (!empty($info['required'])) {
+ $field_name = "modules[{$info['package']}][$module][enable]";
+ if (empty($info['hidden'])) {
+ $this->assertFieldByXPath("//input[@name='$field_name' and @disabled='disabled' and @checked='checked']", '', t('Field @name was disabled and checked.', array('@name' => $field_name)));
+ }
+ else {
+ $this->assertNoFieldByName($field_name);
+ }
+ }
+ }
+ }
+}
+
+class IPAddressBlockingTestCase extends DrupalWebTestCase {
+ protected $blocking_user;
+
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => 'IP address blocking',
+ 'description' => 'Test IP address blocking.',
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Implement setUp().
+ */
+ function setUp() {
+ parent::setUp();
+
+ // Create user.
+ $this->blocking_user = $this->drupalCreateUser(array('block IP addresses'));
+ $this->drupalLogin($this->blocking_user);
+ }
+
+ /**
+ * Test a variety of user input to confirm correct validation and saving of data.
+ */
+ function testIPAddressValidation() {
+ $this->drupalGet('admin/config/people/ip-blocking');
+
+ // Block a valid IP address.
+ $edit = array();
+ $edit['ip'] = '192.168.1.1';
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $ip = db_query("SELECT iid from {blocked_ips} WHERE ip = :ip", array(':ip' => $edit['ip']))->fetchField();
+ $this->assertTrue($ip, t('IP address found in database.'));
+ $this->assertRaw(t('The IP address %ip has been blocked.', array('%ip' => $edit['ip'])), t('IP address was blocked.'));
+
+ // Try to block an IP address that's already blocked.
+ $edit = array();
+ $edit['ip'] = '192.168.1.1';
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $this->assertText(t('This IP address is already blocked.'));
+
+ // Try to block a reserved IP address.
+ $edit = array();
+ $edit['ip'] = '255.255.255.255';
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $this->assertText(t('Enter a valid IP address.'));
+
+ // Try to block a reserved IP address.
+ $edit = array();
+ $edit['ip'] = 'test.example.com';
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $this->assertText(t('Enter a valid IP address.'));
+
+ // Submit an empty form.
+ $edit = array();
+ $edit['ip'] = '';
+ $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Add'));
+ $this->assertText(t('Enter a valid IP address.'));
+
+ // Pass an IP address as a URL parameter and submit it.
+ $submit_ip = '1.2.3.4';
+ $this->drupalPost('admin/config/people/ip-blocking/' . $submit_ip, NULL, t('Add'));
+ $ip = db_query("SELECT iid from {blocked_ips} WHERE ip = :ip", array(':ip' => $submit_ip))->fetchField();
+ $this->assertTrue($ip, t('IP address found in database'));
+ $this->assertRaw(t('The IP address %ip has been blocked.', array('%ip' => $submit_ip)), t('IP address was blocked.'));
+
+ // Submit your own IP address. This fails, although it works when testing manually.
+ // TODO: on some systems this test fails due to a bug or inconsistency in cURL.
+ // $edit = array();
+ // $edit['ip'] = ip_address();
+ // $this->drupalPost('admin/config/people/ip-blocking', $edit, t('Save'));
+ // $this->assertText(t('You may not block your own IP address.'));
+ }
+}
+
+class CronRunTestCase extends DrupalWebTestCase {
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cron run',
+ 'description' => 'Test cron run.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('common_test', 'common_test_cron_helper'));
+ }
+
+ /**
+ * Test cron runs.
+ */
+ function testCronRun() {
+ global $base_url;
+
+ // Run cron anonymously without any cron key.
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE));
+ $this->assertResponse(403);
+
+ // Run cron anonymously with a random cron key.
+ $key = $this->randomName(16);
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertResponse(403);
+
+ // Run cron anonymously with the valid cron key.
+ $key = variable_get('cron_key', 'drupal');
+ $this->drupalGet($base_url . '/core/cron.php', array('external' => TRUE, 'query' => array('cron_key' => $key)));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Ensure that the automatic cron run feature is working.
+ *
+ * In these tests we do not use REQUEST_TIME to track start time, because we
+ * need the exact time when cron is triggered.
+ */
+ function testAutomaticCron() {
+ // Ensure cron does not run when the cron threshold is enabled and was
+ // not passed.
+ $cron_last = time();
+ $cron_safe_threshold = 100;
+ variable_set('cron_last', $cron_last);
+ variable_set('cron_safe_threshold', $cron_safe_threshold);
+ $this->drupalGet('');
+ $this->assertTrue($cron_last == variable_get('cron_last', NULL), t('Cron does not run when the cron threshold is not passed.'));
+
+ // Test if cron runs when the cron threshold was passed.
+ $cron_last = time() - 200;
+ variable_set('cron_last', $cron_last);
+ $this->drupalGet('');
+ sleep(1);
+ $this->assertTrue($cron_last < variable_get('cron_last', NULL), t('Cron runs when the cron threshold is passed.'));
+
+ // Disable the cron threshold through the interface.
+ $admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($admin_user);
+ $this->drupalPost('admin/config/system/cron', array('cron_safe_threshold' => 0), t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'));
+ $this->drupalLogout();
+
+ // Test if cron does not run when the cron threshold is disabled.
+ $cron_last = time() - 200;
+ variable_set('cron_last', $cron_last);
+ $this->drupalGet('');
+ $this->assertTrue($cron_last == variable_get('cron_last', NULL), t('Cron does not run when the cron threshold is disabled.'));
+ }
+
+ /**
+ * Ensure that temporary files are removed.
+ *
+ * Create files for all the possible combinations of age and status. We are
+ * using UPDATE statements rather than file_save() because it would set the
+ * timestamp.
+ */
+ function testTempFileCleanup() {
+ // Temporary file that is older than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
+ $temp_old = file_save_data('');
+ db_update('file_managed')
+ ->fields(array(
+ 'status' => 0,
+ 'timestamp' => 1,
+ ))
+ ->condition('fid', $temp_old->fid)
+ ->execute();
+ $this->assertTrue(file_exists($temp_old->uri), t('Old temp file was created correctly.'));
+
+ // Temporary file that is less than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
+ $temp_new = file_save_data('');
+ db_update('file_managed')
+ ->fields(array('status' => 0))
+ ->condition('fid', $temp_new->fid)
+ ->execute();
+ $this->assertTrue(file_exists($temp_new->uri), t('New temp file was created correctly.'));
+
+ // Permanent file that is older than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
+ $perm_old = file_save_data('');
+ db_update('file_managed')
+ ->fields(array('timestamp' => 1))
+ ->condition('fid', $temp_old->fid)
+ ->execute();
+ $this->assertTrue(file_exists($perm_old->uri), t('Old permanent file was created correctly.'));
+
+ // Permanent file that is newer than DRUPAL_MAXIMUM_TEMP_FILE_AGE.
+ $perm_new = file_save_data('');
+ $this->assertTrue(file_exists($perm_new->uri), t('New permanent file was created correctly.'));
+
+ // Run cron and then ensure that only the old, temp file was deleted.
+ $this->cronRun();
+ $this->assertFalse(file_exists($temp_old->uri), t('Old temp file was correctly removed.'));
+ $this->assertTrue(file_exists($temp_new->uri), t('New temp file was correctly ignored.'));
+ $this->assertTrue(file_exists($perm_old->uri), t('Old permanent file was correctly ignored.'));
+ $this->assertTrue(file_exists($perm_new->uri), t('New permanent file was correctly ignored.'));
+ }
+
+ /**
+ * Make sure exceptions thrown on hook_cron() don't affect other modules.
+ */
+ function testCronExceptions() {
+ variable_del('common_test_cron');
+ // The common_test module throws an exception. If it isn't caught, the tests
+ // won't finish successfully.
+ // The common_test_cron_helper module sets the 'common_test_cron' variable.
+ $this->cronRun();
+ $result = variable_get('common_test_cron');
+ $this->assertEqual($result, 'success', t('Cron correctly handles exceptions thrown during hook_cron() invocations.'));
+ }
+}
+
+class AdminMetaTagTestCase extends DrupalWebTestCase {
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => 'Fingerprinting meta tag',
+ 'description' => 'Confirm that the fingerprinting meta tag appears as expected.',
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Verify that the meta tag HTML is generated correctly.
+ */
+ public function testMetaTag() {
+ list($version, ) = explode('.', VERSION);
+ $string = '<meta name="Generator" content="Drupal ' . $version . ' (http://drupal.org)" />';
+ $this->drupalGet('node');
+ $this->assertRaw($string, t('Fingerprinting meta tag generated correctly.'), t('System'));
+ }
+}
+
+/**
+ * Tests custom access denied functionality.
+ */
+class AccessDeniedTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => '403 functionality',
+ 'description' => 'Tests page access denied functionality, including custom 403 pages.',
+ 'group' => 'System'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create an administrative user.
+ $this->admin_user = $this->drupalCreateUser(array('access administration pages', 'administer site configuration', 'administer blocks'));
+ }
+
+ function testAccessDenied() {
+ $this->drupalGet('admin');
+ $this->assertText(t('Access denied'), t('Found the default 403 page'));
+ $this->assertResponse(403);
+
+ $this->drupalLogin($this->admin_user);
+ $edit = array(
+ 'title' => $this->randomName(10),
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $this->randomName(100)))),
+ );
+ $node = $this->drupalCreateNode($edit);
+
+ // Use a custom 403 page.
+ $this->drupalPost('admin/config/system/site-information', array('site_403' => 'node/' . $node->nid), t('Save configuration'));
+
+ $this->drupalLogout();
+ $this->drupalGet('admin');
+ $this->assertText($node->title, t('Found the custom 403 page'));
+
+ // Logout and check that the user login block is shown on custom 403 pages.
+ $this->drupalLogout();
+
+ $this->drupalGet('admin');
+ $this->assertText($node->title, t('Found the custom 403 page'));
+ $this->assertText(t('User login'), t('Blocks are shown on the custom 403 page'));
+
+ // Log back in and remove the custom 403 page.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalPost('admin/config/system/site-information', array('site_403' => ''), t('Save configuration'));
+
+ // Logout and check that the user login block is shown on default 403 pages.
+ $this->drupalLogout();
+
+ $this->drupalGet('admin');
+ $this->assertText(t('Access denied'), t('Found the default 403 page'));
+ $this->assertResponse(403);
+ $this->assertText(t('User login'), t('Blocks are shown on the default 403 page'));
+
+ // Log back in, set the custom 403 page to /user and remove the block
+ $this->drupalLogin($this->admin_user);
+ variable_set('site_403', 'user');
+ $this->drupalPost('admin/structure/block', array('blocks[user_login][region]' => '-1'), t('Save blocks'));
+
+ // Check that we can log in from the 403 page.
+ $this->drupalLogout();
+ $edit = array(
+ 'name' => $this->admin_user->name,
+ 'pass' => $this->admin_user->pass_raw,
+ );
+ $this->drupalPost('admin/config/system/site-information', $edit, t('Log in'));
+
+ // Check that we're still on the same page.
+ $this->assertText(t('Site information'));
+ }
+}
+
+class PageNotFoundTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => '404 functionality',
+ 'description' => "Tests page not found functionality, including custom 404 pages.",
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Implement setUp().
+ */
+ function setUp() {
+ parent::setUp();
+
+ // Create an administrative user.
+ $this->admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ function testPageNotFound() {
+ $this->drupalGet($this->randomName(10));
+ $this->assertText(t('Page not found'), t('Found the default 404 page'));
+
+ $edit = array(
+ 'title' => $this->randomName(10),
+ 'body' => array(LANGUAGE_NONE => array(array('value' => $this->randomName(100)))),
+ );
+ $node = $this->drupalCreateNode($edit);
+
+ // Use a custom 404 page.
+ $this->drupalPost('admin/config/system/site-information', array('site_404' => 'node/' . $node->nid), t('Save configuration'));
+
+ $this->drupalGet($this->randomName(10));
+ $this->assertText($node->title, t('Found the custom 404 page'));
+ }
+}
+
+/**
+ * Tests site maintenance functionality.
+ */
+class SiteMaintenanceTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Site maintenance mode functionality',
+ 'description' => 'Test access to site while in maintenance mode.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Create a user allowed to access site in maintenance mode.
+ $this->user = $this->drupalCreateUser(array('access site in maintenance mode'));
+ // Create an administrative user.
+ $this->admin_user = $this->drupalCreateUser(array('administer site configuration', 'access site in maintenance mode'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Verify site maintenance mode functionality.
+ */
+ function testSiteMaintenance() {
+ // Turn on maintenance mode.
+ $edit = array(
+ 'maintenance_mode' => 1,
+ );
+ $this->drupalPost('admin/config/development/maintenance', $edit, t('Save configuration'));
+
+ $admin_message = t('Operating in maintenance mode. <a href="@url">Go online.</a>', array('@url' => url('admin/config/development/maintenance')));
+ $user_message = t('Operating in maintenance mode.');
+ $offline_message = t('@site is currently under maintenance. We should be back shortly. Thank you for your patience.', array('@site' => variable_get('site_name', 'Drupal')));
+
+ $this->drupalGet('');
+ $this->assertRaw($admin_message, t('Found the site maintenance mode message.'));
+
+ // Logout and verify that offline message is displayed.
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertText($offline_message);
+ $this->drupalGet('node');
+ $this->assertText($offline_message);
+ $this->drupalGet('user/register');
+ $this->assertText($offline_message);
+
+ // Verify that user is able to log in.
+ $this->drupalGet('user');
+ $this->assertNoText($offline_message);
+ $this->drupalGet('user/login');
+ $this->assertNoText($offline_message);
+
+ // Log in user and verify that maintenance mode message is displayed
+ // directly after login.
+ $edit = array(
+ 'name' => $this->user->name,
+ 'pass' => $this->user->pass_raw,
+ );
+ $this->drupalPost(NULL, $edit, t('Log in'));
+ $this->assertText($user_message);
+
+ // Log in administrative user and configure a custom site offline message.
+ $this->drupalLogout();
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('admin/config/development/maintenance');
+ $this->assertNoRaw($admin_message, t('Site maintenance mode message not displayed.'));
+
+ $offline_message = 'Sorry, not online.';
+ $edit = array(
+ 'maintenance_mode_message' => $offline_message,
+ );
+ $this->drupalPost(NULL, $edit, t('Save configuration'));
+
+ // Logout and verify that custom site offline message is displayed.
+ $this->drupalLogout();
+ $this->drupalGet('');
+ $this->assertRaw($offline_message, t('Found the site offline message.'));
+
+ // Verify that custom site offline message is not displayed on user/password.
+ $this->drupalGet('user/password');
+ $this->assertText(t('Username or e-mail address'), t('Anonymous users can access user/password'));
+
+ // Submit password reset form.
+ $edit = array(
+ 'name' => $this->user->name,
+ );
+ $this->drupalPost('user/password', $edit, t('E-mail new password'));
+ $mails = $this->drupalGetMails();
+ $start = strpos($mails[0]['body'], 'user/reset/'. $this->user->uid);
+ $path = substr($mails[0]['body'], $start, 66 + strlen($this->user->uid));
+
+ // Log in with temporary login link.
+ $this->drupalPost($path, array(), t('Log in'));
+ $this->assertText($user_message);
+ }
+}
+
+/**
+ * Tests generic date and time handling capabilities of Drupal.
+ */
+class DateTimeFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Date and time',
+ 'description' => 'Configure date and time settings. Test date formatting and time zone handling, including daylight saving time.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('locale'));
+
+ // Create admin user and log in admin user.
+ $this->admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+
+ /**
+ * Test time zones and DST handling.
+ */
+ function testTimeZoneHandling() {
+ // Setup date/time settings for Honolulu time.
+ variable_set('date_default_timezone', 'Pacific/Honolulu');
+ variable_set('configurable_timezones', 0);
+ variable_set('date_format_medium', 'Y-m-d H:i:s O');
+
+ // Create some nodes with different authored-on dates.
+ $date1 = '2007-01-31 21:00:00 -1000';
+ $date2 = '2007-07-31 21:00:00 -1000';
+ $node1 = $this->drupalCreateNode(array('created' => strtotime($date1), 'type' => 'article'));
+ $node2 = $this->drupalCreateNode(array('created' => strtotime($date2), 'type' => 'article'));
+
+ // Confirm date format and time zone.
+ $this->drupalGet("node/$node1->nid");
+ $this->assertText('2007-01-31 21:00:00 -1000', t('Date should be identical, with GMT offset of -10 hours.'));
+ $this->drupalGet("node/$node2->nid");
+ $this->assertText('2007-07-31 21:00:00 -1000', t('Date should be identical, with GMT offset of -10 hours.'));
+
+ // Set time zone to Los Angeles time.
+ variable_set('date_default_timezone', 'America/Los_Angeles');
+
+ // Confirm date format and time zone.
+ $this->drupalGet("node/$node1->nid");
+ $this->assertText('2007-01-31 23:00:00 -0800', t('Date should be two hours ahead, with GMT offset of -8 hours.'));
+ $this->drupalGet("node/$node2->nid");
+ $this->assertText('2007-08-01 00:00:00 -0700', t('Date should be three hours ahead, with GMT offset of -7 hours.'));
+ }
+
+ /**
+ * Test date type configuration.
+ */
+ function testDateTypeConfiguration() {
+ // Confirm system date types appear.
+ $this->drupalGet('admin/config/regional/date-time');
+ $this->assertText(t('Medium'), 'System date types appear in date type list.');
+ $this->assertNoRaw('href="/admin/config/regional/date-time/types/medium/delete"', 'No delete link appear for system date types.');
+
+ // Add custom date type.
+ $this->clickLink(t('Add date type'));
+ $date_type = strtolower($this->randomName(8));
+ $machine_name = 'machine_' . $date_type;
+ $date_format = 'd.m.Y - H:i';
+ $edit = array(
+ 'date_type' => $date_type,
+ 'machine_name' => $machine_name,
+ 'date_format' => $date_format,
+ );
+ $this->drupalPost('admin/config/regional/date-time/types/add', $edit, t('Add date type'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/date-time', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('New date type added successfully.'), 'Date type added confirmation message appears.');
+ $this->assertText($date_type, 'Custom date type appears in the date type list.');
+ $this->assertText(t('delete'), 'Delete link for custom date type appears.');
+
+ // Delete custom date type.
+ $this->clickLink(t('delete'));
+ $this->drupalPost('admin/config/regional/date-time/types/' . $machine_name . '/delete', array(), t('Remove'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/date-time', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('Removed date type ' . $date_type), 'Custom date type removed.');
+ }
+
+ /**
+ * Test date format configuration.
+ */
+ function testDateFormatConfiguration() {
+ // Confirm 'no custom date formats available' message appears.
+ $this->drupalGet('admin/config/regional/date-time/formats');
+ $this->assertText(t('No custom date formats available.'), 'No custom date formats message appears.');
+
+ // Add custom date format.
+ $this->clickLink(t('Add format'));
+ $edit = array(
+ 'date_format' => 'Y',
+ );
+ $this->drupalPost('admin/config/regional/date-time/formats/add', $edit, t('Add format'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/date-time/formats', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertNoText(t('No custom date formats available.'), 'No custom date formats message does not appear.');
+ $this->assertText(t('Custom date format added.'), 'Custom date format added.');
+
+ // Ensure custom date format appears in date type configuration options.
+ $this->drupalGet('admin/config/regional/date-time');
+ $this->assertRaw('<option value="Y">', 'Custom date format appears in options.');
+
+ // Edit custom date format.
+ $this->drupalGet('admin/config/regional/date-time/formats');
+ $this->clickLink(t('edit'));
+ $edit = array(
+ 'date_format' => 'Y m',
+ );
+ $this->drupalPost($this->getUrl(), $edit, t('Save format'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/date-time/formats', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('Custom date format updated.'), 'Custom date format successfully updated.');
+
+ // Delete custom date format.
+ $this->clickLink(t('delete'));
+ $this->drupalPost($this->getUrl(), array(), t('Remove'));
+ $this->assertEqual($this->getUrl(), url('admin/config/regional/date-time/formats', array('absolute' => TRUE)), t('Correct page redirection.'));
+ $this->assertText(t('Removed date format'), 'Custom date format removed successfully.');
+ }
+
+ /**
+ * Test if the date formats are stored properly.
+ */
+ function testDateFormatStorage() {
+ $date_format = array(
+ 'type' => 'short',
+ 'format' => 'dmYHis',
+ 'locked' => 0,
+ 'is_new' => 1,
+ );
+ system_date_format_save($date_format);
+
+ $format = db_select('date_formats', 'df')
+ ->fields('df', array('format'))
+ ->condition('type', 'short')
+ ->condition('format', 'dmYHis')
+ ->execute()
+ ->fetchField();
+ $this->verbose($format);
+ $this->assertEqual('dmYHis', $format, 'Unlocalized date format resides in general table.');
+
+ $format = db_select('date_format_locale', 'dfl')
+ ->fields('dfl', array('format'))
+ ->condition('type', 'short')
+ ->condition('format', 'dmYHis')
+ ->execute()
+ ->fetchField();
+ $this->assertFalse($format, 'Unlocalized date format resides not in localized table.');
+
+ // Enable German language
+ $language = (object) array(
+ 'language' => 'de',
+ 'default' => TRUE,
+ );
+ locale_language_save($language);
+
+ $date_format = array(
+ 'type' => 'short',
+ 'format' => 'YMDHis',
+ 'locales' => array('de', 'tr'),
+ 'locked' => 0,
+ 'is_new' => 1,
+ );
+ system_date_format_save($date_format);
+
+ $format = db_select('date_format_locale', 'dfl')
+ ->fields('dfl', array('format'))
+ ->condition('type', 'short')
+ ->condition('format', 'YMDHis')
+ ->condition('language', 'de')
+ ->execute()
+ ->fetchField();
+ $this->assertEqual('YMDHis', $format, 'Localized date format resides in localized table.');
+
+ $format = db_select('date_formats', 'df')
+ ->fields('df', array('format'))
+ ->condition('type', 'short')
+ ->condition('format', 'YMDHis')
+ ->execute()
+ ->fetchField();
+ $this->assertEqual('YMDHis', $format, 'Localized date format resides in general table too.');
+
+ $format = db_select('date_format_locale', 'dfl')
+ ->fields('dfl', array('format'))
+ ->condition('type', 'short')
+ ->condition('format', 'YMDHis')
+ ->condition('language', 'tr')
+ ->execute()
+ ->fetchColumn();
+ $this->assertFalse($format, 'Localized date format for disabled language is ignored.');
+ }
+}
+
+class PageTitleFiltering extends DrupalWebTestCase {
+ protected $content_user;
+ protected $saved_title;
+
+ /**
+ * Implement getInfo().
+ */
+ public static function getInfo() {
+ return array(
+ 'name' => 'HTML in page titles',
+ 'description' => 'Tests correct handling or conversion by drupal_set_title() and drupal_get_title() and checks the correct escaping of site name and slogan.',
+ 'group' => 'System'
+ );
+ }
+
+ /**
+ * Implement setUp().
+ */
+ function setUp() {
+ parent::setUp();
+
+ $this->content_user = $this->drupalCreateUser(array('create page content', 'access content', 'administer themes', 'administer site configuration'));
+ $this->drupalLogin($this->content_user);
+ $this->saved_title = drupal_get_title();
+ }
+
+ /**
+ * Reset page title.
+ */
+ function tearDown() {
+ // Restore the page title.
+ drupal_set_title($this->saved_title, PASS_THROUGH);
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests the handling of HTML by drupal_set_title() and drupal_get_title()
+ */
+ function testTitleTags() {
+ $title = "string with <em>HTML</em>";
+ // drupal_set_title's $filter is CHECK_PLAIN by default, so the title should be
+ // returned with check_plain().
+ drupal_set_title($title, CHECK_PLAIN);
+ $this->assertTrue(strpos(drupal_get_title(), '<em>') === FALSE, t('Tags in title converted to entities when $output is CHECK_PLAIN.'));
+ // drupal_set_title's $filter is passed as PASS_THROUGH, so the title should be
+ // returned with HTML.
+ drupal_set_title($title, PASS_THROUGH);
+ $this->assertTrue(strpos(drupal_get_title(), '<em>') !== FALSE, t('Tags in title are not converted to entities when $output is PASS_THROUGH.'));
+ // Generate node content.
+ $langcode = LANGUAGE_NONE;
+ $edit = array(
+ "title" => '!SimpleTest! ' . $title . $this->randomName(20),
+ "body[$langcode][0][value]" => '!SimpleTest! test body' . $this->randomName(200),
+ );
+ // Create the node with HTML in the title.
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertNotNull($node, 'Node created and found in database');
+ $this->drupalGet("node/" . $node->nid);
+ $this->assertText(check_plain($edit["title"]), 'Check to make sure tags in the node title are converted.');
+ }
+ /**
+ * Test if the title of the site is XSS proof.
+ */
+ function testTitleXSS() {
+ // Set some title with JavaScript and HTML chars to escape.
+ $title = '</title><script type="text/javascript">alert("Title XSS!");</script> & < > " \' ';
+ $title_filtered = check_plain($title);
+
+ $slogan = '<script type="text/javascript">alert("Slogan XSS!");</script>';
+ $slogan_filtered = filter_xss_admin($slogan);
+
+ // Activate needed appearance settings.
+ $edit = array(
+ 'toggle_name' => TRUE,
+ 'toggle_slogan' => TRUE,
+ 'toggle_main_menu' => TRUE,
+ 'toggle_secondary_menu' => TRUE,
+ );
+ $this->drupalPost('admin/appearance/settings', $edit, t('Save configuration'));
+
+ // Set title and slogan.
+ $edit = array(
+ 'site_name' => $title,
+ 'site_slogan' => $slogan,
+ );
+ $this->drupalPost('admin/config/system/site-information', $edit, t('Save configuration'));
+
+ // Load frontpage.
+ $this->drupalGet('');
+
+ // Test the title.
+ $this->assertNoRaw($title, 'Check for the unfiltered version of the title.');
+ // Adding </title> so we do not test the escaped version from drupal_set_title().
+ $this->assertRaw($title_filtered . '</title>', 'Check for the filtered version of the title.');
+
+ // Test the slogan.
+ $this->assertNoRaw($slogan, 'Check for the unfiltered version of the slogan.');
+ $this->assertRaw($slogan_filtered, 'Check for the filtered version of the slogan.');
+ }
+}
+
+/**
+ * Test front page functionality and administration.
+ */
+class FrontPageTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Front page',
+ 'description' => 'Tests front page functionality and administration.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+
+ // Create admin user, log in admin user, and create one node.
+ $this->admin_user = $this->drupalCreateUser(array('access content', 'administer site configuration'));
+ $this->drupalLogin($this->admin_user);
+ $this->node_path = "node/" . $this->drupalCreateNode(array('promote' => 1))->nid;
+
+ // Enable front page logging in system_test.module.
+ variable_set('front_page_output', 1);
+ }
+
+ /**
+ * Test front page functionality.
+ */
+ function testDrupalIsFrontPage() {
+ $this->drupalGet('');
+ $this->assertText(t('On front page.'), t('Path is the front page.'));
+ $this->drupalGet('node');
+ $this->assertText(t('On front page.'), t('Path is the front page.'));
+ $this->drupalGet($this->node_path);
+ $this->assertNoText(t('On front page.'), t('Path is not the front page.'));
+
+ // Change the front page to an invalid path.
+ $edit = array('site_frontpage' => 'kittens');
+ $this->drupalPost('admin/config/system/site-information', $edit, t('Save configuration'));
+ $this->assertText(t("The path '@path' is either invalid or you do not have access to it.", array('@path' => $edit['site_frontpage'])));
+
+ // Change the front page to a valid path.
+ $edit['site_frontpage'] = $this->node_path;
+ $this->drupalPost('admin/config/system/site-information', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('The front page path has been saved.'));
+
+ $this->drupalGet('');
+ $this->assertText(t('On front page.'), t('Path is the front page.'));
+ $this->drupalGet('node');
+ $this->assertNoText(t('On front page.'), t('Path is not the front page.'));
+ $this->drupalGet($this->node_path);
+ $this->assertText(t('On front page.'), t('Path is the front page.'));
+ }
+}
+
+class SystemBlockTestCase extends DrupalWebTestCase {
+ protected $profile = 'testing';
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Block functionality',
+ 'description' => 'Configure and move powered-by block.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('block');
+
+ // Create and login user
+ $admin_user = $this->drupalCreateUser(array('administer blocks', 'access administration pages'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Test displaying and hiding the powered-by and help blocks.
+ */
+ function testSystemBlocks() {
+ // Set block title and some settings to confirm that the interface is available.
+ $this->drupalPost('admin/structure/block/manage/system/powered-by/configure', array('title' => $this->randomName(8)), t('Save block'));
+ $this->assertText(t('The block configuration has been saved.'), t('Block configuration set.'));
+
+ // Set the powered-by block to the footer region.
+ $edit = array();
+ $edit['blocks[system_powered-by][region]'] = 'footer';
+ $edit['blocks[system_main][region]'] = 'content';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->assertText(t('The block settings have been updated.'), t('Block successfully moved to footer region.'));
+
+ // Confirm that the block is being displayed.
+ $this->drupalGet('node');
+ $this->assertRaw('id="block-system-powered-by"', t('Block successfully being displayed on the page.'));
+
+ // Set the block to the disabled region.
+ $edit = array();
+ $edit['blocks[system_powered-by][region]'] = '-1';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Confirm that the block is hidden.
+ $this->assertNoRaw('id="block-system-powered-by"', t('Block no longer appears on page.'));
+
+ // For convenience of developers, set the block to its default settings.
+ $edit = array();
+ $edit['blocks[system_powered-by][region]'] = 'footer';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+ $this->drupalPost('admin/structure/block/manage/system/powered-by/configure', array('title' => ''), t('Save block'));
+
+ // Set the help block to the help region.
+ $edit = array();
+ $edit['blocks[system_help][region]'] = 'help';
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Test displaying the help block with block caching enabled.
+ variable_set('block_cache', TRUE);
+ $this->drupalGet('admin/structure/block/add');
+ $this->assertRaw(t('Use this page to create a new custom block.'));
+ $this->drupalGet('admin/index');
+ $this->assertRaw(t('This page shows you all available administration tasks for each module.'));
+ }
+}
+
+/**
+ * Test main content rendering fallback provided by system module.
+ */
+class SystemMainContentFallback extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $web_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Main content rendering fallback',
+ 'description' => ' Test system module main content rendering fallback.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+
+ // Create and login admin user.
+ $this->admin_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'administer site configuration',
+ 'administer modules',
+ 'administer blocks',
+ 'administer nodes',
+ ));
+ $this->drupalLogin($this->admin_user);
+
+ // Create a web user.
+ $this->web_user = $this->drupalCreateUser(array('access user profiles', 'access content'));
+ }
+
+ /**
+ * Test availability of main content.
+ */
+ function testMainContentFallback() {
+ $edit = array();
+ // Disable the dashboard module, which depends on the block module.
+ $edit['modules[Core][dashboard][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ // Disable the block module.
+ $edit['modules[Core][block][enable]'] = FALSE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ module_list(TRUE);
+ $this->assertFalse(module_exists('block'), t('Block module disabled.'));
+
+ // At this point, no region is filled and fallback should be triggered.
+ $this->drupalGet('admin/config/system/site-information');
+ $this->assertField('site_name', t('Admin interface still available.'));
+
+ // Fallback should not trigger when another module is handling content.
+ $this->drupalGet('system-test/main-content-handling');
+ $this->assertRaw('id="system-test-content"', t('Content handled by another module'));
+ $this->assertText(t('Content to test main content fallback'), t('Main content still displayed.'));
+
+ // Fallback should trigger when another module
+ // indicates that it is not handling the content.
+ $this->drupalGet('system-test/main-content-fallback');
+ $this->assertText(t('Content to test main content fallback'), t('Main content fallback properly triggers.'));
+
+ // Fallback should not trigger when another module is handling content.
+ // Note that this test ensures that no duplicate
+ // content gets created by the fallback.
+ $this->drupalGet('system-test/main-content-duplication');
+ $this->assertNoText(t('Content to test main content fallback'), t('Main content not duplicated.'));
+
+ // Request a user* page and see if it is displayed.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet('user/' . $this->web_user->uid . '/edit');
+ $this->assertField('mail', t('User interface still available.'));
+
+ // Enable the block module again.
+ $this->drupalLogin($this->admin_user);
+ $edit = array();
+ $edit['modules[Core][block][enable]'] = 'block';
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertText(t('The configuration options have been saved.'), t('Modules status has been updated.'));
+ module_list(TRUE);
+ $this->assertTrue(module_exists('block'), t('Block module re-enabled.'));
+ }
+}
+
+/**
+ * Tests for the theme interface functionality.
+ */
+class SystemThemeFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Theme interface functionality',
+ 'description' => 'Tests the theme interface functionality by enabling and switching themes, and using an administration theme.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->admin_user = $this->drupalCreateUser(array('access administration pages', 'view the administration theme', 'administer themes', 'bypass node access', 'administer blocks'));
+ $this->drupalLogin($this->admin_user);
+ $this->node = $this->drupalCreateNode();
+ }
+
+ /**
+ * Test the theme settings form.
+ */
+ function testThemeSettings() {
+ // Specify a filesystem path to be used for the logo.
+ $file = current($this->drupalGetTestFiles('image'));
+ $fullpath = drupal_realpath($file->uri);
+ $edit = array(
+ 'default_logo' => FALSE,
+ 'logo_path' => $fullpath,
+ );
+ $this->drupalPost('admin/appearance/settings', $edit, t('Save configuration'));
+ $this->drupalGet('node');
+ $this->assertRaw($fullpath, t('Logo path successfully changed.'));
+
+ // Upload a file to use for the logo.
+ $file = current($this->drupalGetTestFiles('image'));
+ $edit = array(
+ 'default_logo' => FALSE,
+ 'logo_path' => '',
+ 'files[logo_upload]' => drupal_realpath($file->uri),
+ );
+ $options = array();
+ $this->drupalPost('admin/appearance/settings', $edit, t('Save configuration'), $options);
+ $this->drupalGet('node');
+ $this->assertRaw($file->name, t('Logo file successfully uploaded.'));
+ }
+
+ /**
+ * Test the administration theme functionality.
+ */
+ function testAdministrationTheme() {
+ theme_enable(array('stark'));
+ variable_set('theme_default', 'stark');
+ // Enable an administration theme and show it on the node admin pages.
+ $edit = array(
+ 'admin_theme' => 'seven',
+ 'node_admin_theme' => TRUE,
+ );
+ $this->drupalPost('admin/appearance', $edit, t('Save configuration'));
+
+ $this->drupalGet('admin/config');
+ $this->assertRaw('core/themes/seven', t('Administration theme used on an administration page.'));
+
+ $this->drupalGet('node/' . $this->node->nid);
+ $this->assertRaw('core/themes/stark', t('Site default theme used on node page.'));
+
+ $this->drupalGet('node/add');
+ $this->assertRaw('core/themes/seven', t('Administration theme used on the add content page.'));
+
+ $this->drupalGet('node/' . $this->node->nid . '/edit');
+ $this->assertRaw('core/themes/seven', t('Administration theme used on the edit content page.'));
+
+ // Disable the admin theme on the node admin pages.
+ $edit = array(
+ 'node_admin_theme' => FALSE,
+ );
+ $this->drupalPost('admin/appearance', $edit, t('Save configuration'));
+
+ $this->drupalGet('admin/config');
+ $this->assertRaw('core/themes/seven', t('Administration theme used on an administration page.'));
+
+ $this->drupalGet('node/add');
+ $this->assertRaw('core/themes/stark', t('Site default theme used on the add content page.'));
+
+ // Reset to the default theme settings.
+ variable_set('theme_default', 'bartik');
+ $edit = array(
+ 'admin_theme' => '0',
+ 'node_admin_theme' => FALSE,
+ );
+ $this->drupalPost('admin/appearance', $edit, t('Save configuration'));
+
+ $this->drupalGet('admin');
+ $this->assertRaw('core/themes/bartik', t('Site default theme used on administration page.'));
+
+ $this->drupalGet('node/add');
+ $this->assertRaw('core/themes/bartik', t('Site default theme used on the add content page.'));
+ }
+
+ /**
+ * Test switching the default theme.
+ */
+ function testSwitchDefaultTheme() {
+ // Enable "stark" and set it as the default theme.
+ theme_enable(array('stark'));
+ $this->drupalGet('admin/appearance');
+ $this->clickLink(t('Set default'), 1);
+ $this->assertTrue(variable_get('theme_default', '') == 'stark', t('Site default theme switched successfully.'));
+
+ // Test the default theme on the secondary links (blocks admin page).
+ $this->drupalGet('admin/structure/block');
+ $this->assertText('Stark(' . t('active tab') . ')', t('Default local task on blocks admin page is the default theme.'));
+ // Switch back to Bartik and test again to test that the menu cache is cleared.
+ $this->drupalGet('admin/appearance');
+ $this->clickLink(t('Set default'), 0);
+ $this->drupalGet('admin/structure/block');
+ $this->assertText('Bartik(' . t('active tab') . ')', t('Default local task on blocks admin page has changed.'));
+ }
+}
+
+
+/**
+ * Test the basic queue functionality.
+ */
+class QueueTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Queue functionality',
+ 'description' => 'Queues and dequeues a set of items to check the basic queue functionality.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Queues and dequeues a set of items to check the basic queue functionality.
+ */
+ function testQueue() {
+ // Create two queues.
+ $queue1 = DrupalQueue::get($this->randomName());
+ $queue1->createQueue();
+ $queue2 = DrupalQueue::get($this->randomName());
+ $queue2->createQueue();
+
+ // Create four items.
+ $data = array();
+ for ($i = 0; $i < 4; $i++) {
+ $data[] = array($this->randomName() => $this->randomName());
+ }
+
+ // Queue items 1 and 2 in the queue1.
+ $queue1->createItem($data[0]);
+ $queue1->createItem($data[1]);
+
+ // Retrieve two items from queue1.
+ $items = array();
+ $new_items = array();
+
+ $items[] = $item = $queue1->claimItem();
+ $new_items[] = $item->data;
+
+ $items[] = $item = $queue1->claimItem();
+ $new_items[] = $item->data;
+
+ // First two dequeued items should match the first two items we queued.
+ $this->assertEqual($this->queueScore($data, $new_items), 2, t('Two items matched'));
+
+ // Add two more items.
+ $queue1->createItem($data[2]);
+ $queue1->createItem($data[3]);
+
+ $this->assertTrue($queue1->numberOfItems(), t('Queue 1 is not empty after adding items.'));
+ $this->assertFalse($queue2->numberOfItems(), t('Queue 2 is empty while Queue 1 has items'));
+
+ $items[] = $item = $queue1->claimItem();
+ $new_items[] = $item->data;
+
+ $items[] = $item = $queue1->claimItem();
+ $new_items[] = $item->data;
+
+ // All dequeued items should match the items we queued exactly once,
+ // therefore the score must be exactly 4.
+ $this->assertEqual($this->queueScore($data, $new_items), 4, t('Four items matched'));
+
+ // There should be no duplicate items.
+ $this->assertEqual($this->queueScore($new_items, $new_items), 4, t('Four items matched'));
+
+ // Delete all items from queue1.
+ foreach ($items as $item) {
+ $queue1->deleteItem($item);
+ }
+
+ // Check that both queues are empty.
+ $this->assertFalse($queue1->numberOfItems(), t('Queue 1 is empty'));
+ $this->assertFalse($queue2->numberOfItems(), t('Queue 2 is empty'));
+ }
+
+ /**
+ * This function returns the number of equal items in two arrays.
+ */
+ function queueScore($items, $new_items) {
+ $score = 0;
+ foreach ($items as $item) {
+ foreach ($new_items as $new_item) {
+ if ($item === $new_item) {
+ $score++;
+ }
+ }
+ }
+ return $score;
+ }
+}
+
+/**
+ * Test token replacement in strings.
+ */
+class TokenReplaceTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check token replacement.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Creates a user and a node, then tests the tokens generated from them.
+ */
+ function testTokenReplacement() {
+ // Create the initial objects.
+ $account = $this->drupalCreateUser();
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+ $node->title = '<blink>Blinking Text</blink>';
+ global $user, $language;
+
+ $source = '[node:title]'; // Title of the node we passed in
+ $source .= '[node:author:name]'; // Node author's name
+ $source .= '[node:created:since]'; // Time since the node was created
+ $source .= '[current-user:name]'; // Current user's name
+ $source .= '[date:short]'; // Short date format of REQUEST_TIME
+ $source .= '[user:name]'; // No user passed in, should be untouched
+ $source .= '[bogus:token]'; // Non-existent token
+
+ $target = check_plain($node->title);
+ $target .= check_plain($account->name);
+ $target .= format_interval(REQUEST_TIME - $node->created, 2, $language->language);
+ $target .= check_plain($user->name);
+ $target .= format_date(REQUEST_TIME, 'short', '', NULL, $language->language);
+
+ // Test that the clear parameter cleans out non-existent tokens.
+ $result = token_replace($source, array('node' => $node), array('language' => $language, 'clear' => TRUE));
+ $result = $this->assertEqual($target, $result, 'Valid tokens replaced while invalid tokens cleared out.');
+
+ // Test without using the clear parameter (non-existent token untouched).
+ $target .= '[user:name]';
+ $target .= '[bogus:token]';
+ $result = token_replace($source, array('node' => $node), array('language' => $language));
+ $this->assertEqual($target, $result, 'Valid tokens replaced while invalid tokens ignored.');
+
+ // Check that the results of token_generate are sanitized properly. This does NOT
+ // test the cleanliness of every token -- just that the $sanitize flag is being
+ // passed properly through the call stack and being handled correctly by a 'known'
+ // token, [node:title].
+ $raw_tokens = array('title' => '[node:title]');
+ $generated = token_generate('node', $raw_tokens, array('node' => $node));
+ $this->assertEqual($generated['[node:title]'], check_plain($node->title), t('Token sanitized.'));
+
+ $generated = token_generate('node', $raw_tokens, array('node' => $node), array('sanitize' => FALSE));
+ $this->assertEqual($generated['[node:title]'], $node->title, t('Unsanitized token generated properly.'));
+ }
+
+ /**
+ * Test whether token-replacement works in various contexts.
+ */
+ function testSystemTokenRecognition() {
+ global $language;
+
+ // Generate prefixes and suffixes for the token context.
+ $tests = array(
+ array('prefix' => 'this is the ', 'suffix' => ' site'),
+ array('prefix' => 'this is the', 'suffix' => 'site'),
+ array('prefix' => '[', 'suffix' => ']'),
+ array('prefix' => '', 'suffix' => ']]]'),
+ array('prefix' => '[[[', 'suffix' => ''),
+ array('prefix' => ':[:', 'suffix' => '--]'),
+ array('prefix' => '-[-', 'suffix' => ':]:'),
+ array('prefix' => '[:', 'suffix' => ']'),
+ array('prefix' => '[site:', 'suffix' => ':name]'),
+ array('prefix' => '[site:', 'suffix' => ']'),
+ );
+
+ // Check if the token is recognized in each of the contexts.
+ foreach ($tests as $test) {
+ $input = $test['prefix'] . '[site:name]' . $test['suffix'];
+ $expected = $test['prefix'] . 'Drupal' . $test['suffix'];
+ $output = token_replace($input, array(), array('language' => $language));
+ $this->assertTrue($output == $expected, t('Token recognized in string %string', array('%string' => $input)));
+ }
+ }
+
+ /**
+ * Tests the generation of all system site information tokens.
+ */
+ function testSystemSiteTokenReplacement() {
+ global $language;
+ $url_options = array(
+ 'absolute' => TRUE,
+ 'language' => $language,
+ );
+
+ // Set a few site variables.
+ variable_set('site_name', '<strong>Drupal<strong>');
+ variable_set('site_slogan', '<blink>Slogan</blink>');
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[site:name]'] = check_plain(variable_get('site_name', 'Drupal'));
+ $tests['[site:slogan]'] = check_plain(variable_get('site_slogan', ''));
+ $tests['[site:mail]'] = 'simpletest@example.com';
+ $tests['[site:url]'] = url('<front>', $url_options);
+ $tests['[site:url-brief]'] = preg_replace(array('!^https?://!', '!/$!'), '', url('<front>', $url_options));
+ $tests['[site:login-url]'] = url('user', $url_options);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array(), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized system site information token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[site:name]'] = variable_get('site_name', 'Drupal');
+ $tests['[site:slogan]'] = variable_get('site_slogan', '');
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array(), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized system site information token %token replaced.', array('%token' => $input)));
+ }
+ }
+
+ /**
+ * Tests the generation of all system date tokens.
+ */
+ function testSystemDateTokenReplacement() {
+ global $language;
+
+ // Set time to one hour before request.
+ $date = REQUEST_TIME - 3600;
+
+ // Generate and test tokens.
+ $tests = array();
+ $tests['[date:short]'] = format_date($date, 'short', '', NULL, $language->language);
+ $tests['[date:medium]'] = format_date($date, 'medium', '', NULL, $language->language);
+ $tests['[date:long]'] = format_date($date, 'long', '', NULL, $language->language);
+ $tests['[date:custom:m/j/Y]'] = format_date($date, 'custom', 'm/j/Y', NULL, $language->language);
+ $tests['[date:since]'] = format_interval((REQUEST_TIME - $date), 2, $language->language);
+ $tests['[date:raw]'] = filter_xss($date);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('date' => $date), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Date token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+class InfoFileParserTestCase extends DrupalUnitTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Info file format parser',
+ 'description' => 'Tests proper parsing of a .info file formatted string.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Test drupal_parse_info_format().
+ */
+ function testDrupalParseInfoFormat() {
+ $config = '
+simple = Value
+quoted = " Value"
+multiline = "Value
+ Value"
+array[] = Value1
+array[] = Value2
+array_assoc[a] = Value1
+array_assoc[b] = Value2
+array_deep[][][] = Value
+array_deep_assoc[a][b][c] = Value
+array_space[a b] = Value';
+
+ $expected = array(
+ 'simple' => 'Value',
+ 'quoted' => ' Value',
+ 'multiline' => "Value\n Value",
+ 'array' => array(
+ 0 => 'Value1',
+ 1 => 'Value2',
+ ),
+ 'array_assoc' => array(
+ 'a' => 'Value1',
+ 'b' => 'Value2',
+ ),
+ 'array_deep' => array(
+ 0 => array(
+ 0 => array(
+ 0 => 'Value',
+ ),
+ ),
+ ),
+ 'array_deep_assoc' => array(
+ 'a' => array(
+ 'b' => array(
+ 'c' => 'Value',
+ ),
+ ),
+ ),
+ 'array_space' => array(
+ 'a b' => 'Value',
+ ),
+ );
+
+ $parsed = drupal_parse_info_format($config);
+
+ $this->assertEqual($parsed['simple'], $expected['simple'], t('Set a simple value.'));
+ $this->assertEqual($parsed['quoted'], $expected['quoted'], t('Set a simple value in quotes.'));
+ $this->assertEqual($parsed['multiline'], $expected['multiline'], t('Set a multiline value.'));
+ $this->assertEqual($parsed['array'], $expected['array'], t('Set a simple array.'));
+ $this->assertEqual($parsed['array_assoc'], $expected['array_assoc'], t('Set an associative array.'));
+ $this->assertEqual($parsed['array_deep'], $expected['array_deep'], t('Set a nested array.'));
+ $this->assertEqual($parsed['array_deep_assoc'], $expected['array_deep_assoc'], t('Set a nested associative array.'));
+ $this->assertEqual($parsed['array_space'], $expected['array_space'], t('Set an array with a whitespace in the key.'));
+ $this->assertEqual($parsed, $expected, t('Entire parsed .info string and expected array are identical.'));
+ }
+}
+
+/**
+ * Tests the effectiveness of hook_system_info_alter().
+ */
+class SystemInfoAlterTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'System info alter',
+ 'description' => 'Tests the effectiveness of hook_system_info_alter().',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Tests that {system}.info is rebuilt after a module that implements
+ * hook_system_info_alter() is enabled. Also tests if core *_list() functions
+ * return freshly altered info.
+ */
+ function testSystemInfoAlter() {
+ // Enable our test module. Flush all caches, which we assert is the only
+ // thing necessary to use the rebuilt {system}.info.
+ module_enable(array('module_test'), FALSE);
+ drupal_flush_all_caches();
+ $this->assertTrue(module_exists('module_test'), t('Test module is enabled.'));
+
+ $info = $this->getSystemInfo('seven', 'theme');
+ $this->assertTrue(isset($info['regions']['test_region']), t('Altered theme info was added to {system}.info.'));
+ $seven_regions = system_region_list('seven');
+ $this->assertTrue(isset($seven_regions['test_region']), t('Altered theme info was returned by system_region_list().'));
+ $system_list_themes = system_list('theme');
+ $info = $system_list_themes['seven']->info;
+ $this->assertTrue(isset($info['regions']['test_region']), t('Altered theme info was returned by system_list().'));
+ $list_themes = list_themes();
+ $this->assertTrue(isset($list_themes['seven']->info['regions']['test_region']), t('Altered theme info was returned by list_themes().'));
+
+ // Disable the module and verify that {system}.info is rebuilt without it.
+ module_disable(array('module_test'), FALSE);
+ drupal_flush_all_caches();
+ $this->assertFalse(module_exists('module_test'), t('Test module is disabled.'));
+
+ $info = $this->getSystemInfo('seven', 'theme');
+ $this->assertFalse(isset($info['regions']['test_region']), t('Altered theme info was removed from {system}.info.'));
+ $seven_regions = system_region_list('seven');
+ $this->assertFalse(isset($seven_regions['test_region']), t('Altered theme info was not returned by system_region_list().'));
+ $system_list_themes = system_list('theme');
+ $info = $system_list_themes['seven']->info;
+ $this->assertFalse(isset($info['regions']['test_region']), t('Altered theme info was not returned by system_list().'));
+ $list_themes = list_themes();
+ $this->assertFalse(isset($list_themes['seven']->info['regions']['test_region']), t('Altered theme info was not returned by list_themes().'));
+ }
+
+ /**
+ * Returns the info array as it is stored in {system}.
+ *
+ * @param $name
+ * The name of the record in {system}.
+ * @param $type
+ * The type of record in {system}.
+ *
+ * @return
+ * Array of info, or FALSE if the record is not found.
+ */
+ function getSystemInfo($name, $type) {
+ $raw_info = db_query("SELECT info FROM {system} WHERE name = :name AND type = :type", array(':name' => $name, ':type' => $type))->fetchField();
+ return $raw_info ? unserialize($raw_info) : FALSE;
+ }
+}
+
+/**
+ * Tests for the update system functionality.
+ */
+class UpdateScriptFunctionalTest extends DrupalWebTestCase {
+ private $update_url;
+ private $update_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update functionality',
+ 'description' => 'Tests the update script access and functionality.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('update_script_test');
+ $this->update_url = $GLOBALS['base_url'] . '/core/update.php';
+ $this->update_user = $this->drupalCreateUser(array('administer software updates'));
+ }
+
+ /**
+ * Tests access to the update script.
+ */
+ function testUpdateAccess() {
+ // Try accessing update.php without the proper permission.
+ $regular_user = $this->drupalCreateUser();
+ $this->drupalLogin($regular_user);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertResponse(403);
+
+ // Try accessing update.php as an anonymous user.
+ $this->drupalLogout();
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertResponse(403);
+
+ // Access the update page with the proper permission.
+ $this->drupalLogin($this->update_user);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertResponse(200);
+
+ // Access the update page as user 1.
+ $user1 = user_load(1);
+ $user1->pass_raw = user_password();
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ $user1->pass = user_hash_password(trim($user1->pass_raw));
+ db_query("UPDATE {users} SET pass = :pass WHERE uid = :uid", array(':pass' => $user1->pass, ':uid' => $user1->uid));
+ $this->drupalLogin($user1);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertResponse(200);
+ }
+
+ /**
+ * Tests that requirements warnings and errors are correctly displayed.
+ */
+ function testRequirements() {
+ $this->drupalLogin($this->update_user);
+
+ // If there are no requirements warnings or errors, we expect to be able to
+ // go through the update process uninterrupted.
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->drupalPost(NULL, array(), t('Continue'));
+ $this->assertText(t('No pending updates.'), t('End of update process was reached.'));
+
+ // If there is a requirements warning, we expect it to be initially
+ // displayed, but clicking the link to proceed should allow us to go
+ // through the rest of the update process uninterrupted. (First run this
+ // test with pending updates to make sure they can be run successfully;
+ // then try again without pending updates to make sure that works too.)
+ variable_set('update_script_test_requirement_type', REQUIREMENT_WARNING);
+ drupal_set_installed_schema_version('update_script_test', drupal_get_installed_schema_version('update_script_test') - 1);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertText('This is a requirements warning provided by the update_script_test module.');
+ $this->clickLink('try again');
+ $this->assertNoText('This is a requirements warning provided by the update_script_test module.');
+ $this->drupalPost(NULL, array(), t('Continue'));
+ $this->drupalPost(NULL, array(), t('Apply pending updates'));
+ $this->assertText(t('The update_script_test_update_8000() update was executed successfully.'), t('End of update process was reached.'));
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertText('This is a requirements warning provided by the update_script_test module.');
+ $this->clickLink('try again');
+ $this->assertNoText('This is a requirements warning provided by the update_script_test module.');
+ $this->drupalPost(NULL, array(), t('Continue'));
+ $this->assertText(t('No pending updates.'), t('End of update process was reached.'));
+
+ // If there is a requirements error, it should be displayed even after
+ // clicking the link to proceed (since the problem that triggered the error
+ // has not been fixed).
+ variable_set('update_script_test_requirement_type', REQUIREMENT_ERROR);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $this->assertText('This is a requirements error provided by the update_script_test module.');
+ $this->clickLink('try again');
+ $this->assertText('This is a requirements error provided by the update_script_test module.');
+ }
+
+ /**
+ * Tests the effect of using the update script on the theme system.
+ */
+ function testThemeSystem() {
+ // Since visiting update.php triggers a rebuild of the theme system from an
+ // unusual maintenance mode environment, we check that this rebuild did not
+ // put any incorrect information about the themes into the database.
+ $original_theme_data = db_query("SELECT * FROM {system} WHERE type = 'theme' ORDER BY name")->fetchAll();
+ $this->drupalLogin($this->update_user);
+ $this->drupalGet($this->update_url, array('external' => TRUE));
+ $final_theme_data = db_query("SELECT * FROM {system} WHERE type = 'theme' ORDER BY name")->fetchAll();
+ $this->assertEqual($original_theme_data, $final_theme_data, t('Visiting update.php does not alter the information about themes stored in the database.'));
+ }
+}
+
+/**
+ * Functional tests for the flood control mechanism.
+ */
+class FloodFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Flood control mechanism',
+ 'description' => 'Functional tests for the flood control mechanism.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Test flood control mechanism clean-up.
+ */
+ function testCleanUp() {
+ $threshold = 1;
+ $window_expired = -1;
+ $name = 'flood_test_cleanup';
+
+ // Register expired event.
+ flood_register_event($name, $window_expired);
+ // Verify event is not allowed.
+ $this->assertFalse(flood_is_allowed($name, $threshold));
+ // Run cron and verify event is now allowed.
+ $this->cronRun();
+ $this->assertTrue(flood_is_allowed($name, $threshold));
+
+ // Register unexpired event.
+ flood_register_event($name);
+ // Verify event is not allowed.
+ $this->assertFalse(flood_is_allowed($name, $threshold));
+ // Run cron and verify event is still not allowed.
+ $this->cronRun();
+ $this->assertFalse(flood_is_allowed($name, $threshold));
+ }
+}
+
+/**
+ * Test HTTP file downloading capability.
+ */
+class RetrieveFileTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'HTTP file retrieval',
+ 'description' => 'Checks HTTP file fetching and error handling.',
+ 'group' => 'System',
+ );
+ }
+
+ /**
+ * Invokes system_retrieve_file() in several scenarios.
+ */
+ function testFileRetrieving() {
+ // Test 404 handling by trying to fetch a randomly named file.
+ drupal_mkdir($sourcedir = 'public://' . $this->randomName());
+ $filename = $this->randomName();
+ $url = file_create_url($sourcedir . '/' . $filename);
+ $retrieved_file = system_retrieve_file($url);
+ $this->assertFalse($retrieved_file, t('Non-existent file not fetched.'));
+
+ // Actually create that file, download it via HTTP and test the returned path.
+ file_put_contents($sourcedir . '/' . $filename, 'testing');
+ $retrieved_file = system_retrieve_file($url);
+ $this->assertEqual($retrieved_file, 'public://' . $filename, t('Sane path for downloaded file returned (public:// scheme).'));
+ $this->assertTrue(is_file($retrieved_file), t('Downloaded file does exist (public:// scheme).'));
+ $this->assertEqual(filesize($retrieved_file), 7, t('File size of downloaded file is correct (public:// scheme).'));
+ file_unmanaged_delete($retrieved_file);
+
+ // Test downloading file to a different location.
+ drupal_mkdir($targetdir = 'temporary://' . $this->randomName());
+ $retrieved_file = system_retrieve_file($url, $targetdir);
+ $this->assertEqual($retrieved_file, "$targetdir/$filename", t('Sane path for downloaded file returned (temporary:// scheme).'));
+ $this->assertTrue(is_file($retrieved_file), t('Downloaded file does exist (temporary:// scheme).'));
+ $this->assertEqual(filesize($retrieved_file), 7, t('File size of downloaded file is correct (temporary:// scheme).'));
+ file_unmanaged_delete($retrieved_file);
+
+ file_unmanaged_delete_recursive($sourcedir);
+ file_unmanaged_delete_recursive($targetdir);
+ }
+}
+
+/**
+ * Functional tests shutdown functions.
+ */
+class ShutdownFunctionsTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Shutdown functions',
+ 'description' => 'Functional tests for shutdown functions',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('system_test');
+ }
+
+ /**
+ * Test shutdown functions.
+ */
+ function testShutdownFunctions() {
+ $arg1 = $this->randomName();
+ $arg2 = $this->randomName();
+ $this->drupalGet('system-test/shutdown-functions/' . $arg1 . '/' . $arg2);
+ $this->assertText(t('First shutdown function, arg1 : @arg1, arg2: @arg2', array('@arg1' => $arg1, '@arg2' => $arg2)));
+ $this->assertText(t('Second shutdown function, arg1 : @arg1, arg2: @arg2', array('@arg1' => $arg1, '@arg2' => $arg2)));
+
+ // Make sure exceptions displayed through _drupal_render_exception_safe()
+ // are correctly escaped.
+ $this->assertRaw('Drupal is &amp;lt;blink&amp;gt;awesome&amp;lt;/blink&amp;gt;.');
+ }
+}
+
+/**
+ * Tests administrative overview pages.
+ */
+class SystemAdminTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Administrative pages',
+ 'description' => 'Tests output on administrative pages and compact mode functionality.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ // testAdminPages() requires Locale module.
+ parent::setUp(array('locale'));
+
+ // Create an administrator with all permissions, as well as a regular user
+ // who can only access administration pages and perform some Locale module
+ // administrative tasks, but not all of them.
+ $this->admin_user = $this->drupalCreateUser(array_keys(module_invoke_all('permission')));
+ $this->web_user = $this->drupalCreateUser(array(
+ 'access administration pages',
+ 'translate interface',
+ ));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Tests output on administrative listing pages.
+ */
+ function testAdminPages() {
+ // Go to Administration.
+ $this->drupalGet('admin');
+
+ // Verify that all visible, top-level administration links are listed on
+ // the main administration page.
+ foreach (menu_get_router() as $path => $item) {
+ if (strpos($path, 'admin/') === 0 && ($item['type'] & MENU_VISIBLE_IN_TREE) && $item['_number_parts'] == 2) {
+ $this->assertLink($item['title']);
+ $this->assertLinkByHref($path);
+ $this->assertText($item['description']);
+ }
+ }
+
+ // For each administrative listing page on which the Locale module appears,
+ // verify that there are links to the module's primary configuration pages,
+ // but no links to its individual sub-configuration pages. Also verify that
+ // a user with access to only some Locale module administration pages only
+ // sees links to the pages they have access to.
+ $admin_list_pages = array(
+ 'admin/index',
+ 'admin/config',
+ 'admin/config/regional',
+ );
+
+ foreach ($admin_list_pages as $page) {
+ // For the administrator, verify that there are links to Locale's primary
+ // configuration pages, but no links to individual sub-configuration
+ // pages.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet($page);
+ $this->assertLinkByHref('admin/config');
+ $this->assertLinkByHref('admin/config/regional/settings');
+ $this->assertLinkByHref('admin/config/regional/date-time');
+ $this->assertLinkByHref('admin/config/regional/language');
+ $this->assertNoLinkByHref('admin/config/regional/language/configure/session');
+ $this->assertNoLinkByHref('admin/config/regional/language/configure/url');
+ $this->assertLinkByHref('admin/config/regional/translate');
+ // On admin/index only, the administrator should also see a "Configure
+ // permissions" link for the Locale module.
+ if ($page == 'admin/index') {
+ $this->assertLinkByHref("admin/people/permissions#module-locale");
+ }
+
+ // For a less privileged user, verify that there are no links to Locale's
+ // primary configuration pages, but a link to the translate page exists.
+ $this->drupalLogin($this->web_user);
+ $this->drupalGet($page);
+ $this->assertLinkByHref('admin/config');
+ $this->assertNoLinkByHref('admin/config/regional/settings');
+ $this->assertNoLinkByHref('admin/config/regional/date-time');
+ $this->assertNoLinkByHref('admin/config/regional/language');
+ $this->assertNoLinkByHref('admin/config/regional/language/configure/session');
+ $this->assertNoLinkByHref('admin/config/regional/language/configure/url');
+ $this->assertLinkByHref('admin/config/regional/translate');
+ // This user cannot configure permissions, so even on admin/index should
+ // not see a "Configure permissions" link for the Locale module.
+ if ($page == 'admin/index') {
+ $this->assertNoLinkByHref("admin/people/permissions#module-locale");
+ }
+ }
+ }
+
+ /**
+ * Test compact mode.
+ */
+ function testCompactMode() {
+ $this->drupalGet('admin/compact/on');
+ $this->assertTrue($this->cookies['Drupal.visitor.admin_compact_mode']['value'], t('Compact mode turns on.'));
+ $this->drupalGet('admin/compact/on');
+ $this->assertTrue($this->cookies['Drupal.visitor.admin_compact_mode']['value'], t('Compact mode remains on after a repeat call.'));
+ $this->drupalGet('');
+ $this->assertTrue($this->cookies['Drupal.visitor.admin_compact_mode']['value'], t('Compact mode persists on new requests.'));
+
+ $this->drupalGet('admin/compact/off');
+ $this->assertEqual($this->cookies['Drupal.visitor.admin_compact_mode']['value'], 'deleted', t('Compact mode turns off.'));
+ $this->drupalGet('admin/compact/off');
+ $this->assertEqual($this->cookies['Drupal.visitor.admin_compact_mode']['value'], 'deleted', t('Compact mode remains off after a repeat call.'));
+ $this->drupalGet('');
+ $this->assertTrue($this->cookies['Drupal.visitor.admin_compact_mode']['value'], t('Compact mode persists on new requests.'));
+ }
+}
+
+/**
+ * Tests authorize.php and related hooks.
+ */
+class SystemAuthorizeCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Authorize API',
+ 'description' => 'Tests the authorize.php script and related API.',
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp(array('system_test'));
+
+ variable_set('allow_authorize_operations', TRUE);
+
+ // Create an administrator user.
+ $this->admin_user = $this->drupalCreateUser(array('administer software updates'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Helper function to initialize authorize.php and load it via drupalGet().
+ *
+ * Initializing authorize.php needs to happen in the child Drupal
+ * installation, not the parent. So, we visit a menu callback provided by
+ * system_test.module which calls system_authorized_init() to initialize the
+ * $_SESSION inside the test site, not the framework site. This callback
+ * redirects to authorize.php when it's done initializing.
+ *
+ * @see system_authorized_init().
+ */
+ function drupalGetAuthorizePHP($page_title = 'system-test-auth') {
+ $this->drupalGet('system-test/authorize-init/' . $page_title);
+ }
+
+ /**
+ * Tests the FileTransfer hooks
+ */
+ function testFileTransferHooks() {
+ $page_title = $this->randomName(16);
+ $this->drupalGetAuthorizePHP($page_title);
+ $this->assertTitle(strtr('@title | Drupal', array('@title' => $page_title)), 'authorize.php page title is correct.');
+ $this->assertNoText('It appears you have reached this page in error.');
+ $this->assertText('To continue, provide your server connection details');
+ // Make sure we see the new connection method added by system_test.
+ $this->assertRaw('System Test FileTransfer');
+ // Make sure the settings form callback works.
+ $this->assertText('System Test Username');
+ }
+}
+
+/**
+ * Test the handling of requests containing 'index.php'.
+ */
+class SystemIndexPhpTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Index.php handling',
+ 'description' => "Test the handling of requests containing 'index.php'.",
+ 'group' => 'System',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ }
+
+ /**
+ * Test index.php handling.
+ */
+ function testIndexPhpHandling() {
+ $index_php = $GLOBALS['base_url'] . '/index.php';
+
+ $this->drupalGet($index_php, array('external' => TRUE));
+ $this->assertResponse(200, t('Make sure index.php returns a valid page.'));
+
+ $this->drupalGet($index_php, array('external' => TRUE, 'query' => array('q' => 'user')));
+ $this->assertResponse(200, t('Make sure index.php?q=user returns a valid page.'));
+
+ $this->drupalGet($index_php .'/user', array('external' => TRUE));
+ $this->assertResponse(404, t("Make sure index.php/user returns a 'page not found'."));
+ }
+}
+
+/**
+ * Tests uuid.inc and related functions.
+ */
+class UuidUnitTestCase extends DrupalUnitTestCase {
+
+ /**
+ * The UUID object to be used for generating UUIDs.
+ *
+ * @var Uuid
+ */
+ protected $uuid;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'UUID handling',
+ 'description' => "Test the handling of Universally Unique IDentifiers (UUIDs).",
+ 'group' => 'System',
+ );
+ }
+
+ public function setUp() {
+ // Initiate the generator. This will lazy-load uuid.inc.
+ $this->uuid = new Uuid();
+ parent::setUp();
+ }
+
+ /**
+ * Test generating a UUID.
+ */
+ public function testGenerateUuid() {
+ $uuid = $this->uuid->generate();
+ $this->assertTrue($this->uuid->isValid($uuid), 'UUID generation works.');
+ }
+
+ /**
+ * Test that generated UUIDs are unique.
+ */
+ public function testUuidIsUnique() {
+ $uuid1 = $this->uuid->generate();
+ $uuid2 = $this->uuid->generate();
+ $this->assertNotEqual($uuid1, $uuid2, 'Same UUID was not generated twice.');
+ }
+
+ /**
+ * Test UUID validation.
+ */
+ function testUuidValidation() {
+ // These valid UUIDs.
+ $uuid_fqdn = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
+ $uuid_min = '00000000-0000-0000-0000-000000000000';
+ $uuid_max = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
+
+ $this->assertTrue($this->uuid->isValid($uuid_fqdn), t('FQDN namespace UUID (@uuid) is valid', array('@uuid' => $uuid_fqdn)));
+ $this->assertTrue($this->uuid->isValid($uuid_min), t('Minimum UUID value (@uuid) is valid', array('@uuid' => $uuid_min)));
+ $this->assertTrue($this->uuid->isValid($uuid_max), t('Maximum UUID value (@uuid) is valid', array('@uuid' => $uuid_max)));
+
+ // These are invalid UUIDs.
+ $invalid_format = '0ab26e6b-f074-4e44-9da-601205fa0e976';
+ $invalid_length = '0ab26e6b-f074-4e44-9daf-1205fa0e9761f';
+
+ $this->assertFalse($this->uuid->isValid($invalid_format), t('@uuid is not a valid UUID', array('@uuid' => $invalid_format)));
+ $this->assertFalse($this->uuid->isValid($invalid_length), t('@uuid is not a valid UUID', array('@uuid' => $invalid_length)));
+
+ }
+}
diff --git a/core/modules/system/system.theme-rtl.css b/core/modules/system/system.theme-rtl.css
new file mode 100644
index 000000000000..0cd7fa6431f3
--- /dev/null
+++ b/core/modules/system/system.theme-rtl.css
@@ -0,0 +1,53 @@
+
+/**
+ * @file
+ * RTL styles for common markup.
+ */
+
+/**
+ * HTML elements.
+ */
+th {
+ text-align: right;
+ padding-left: 1em;
+ padding-right: 0;
+}
+
+/**
+ * Markup generated by theme_item_list().
+ */
+.item-list ul li {
+ margin: 0 1.5em 0.25em 0;
+}
+
+/**
+ * Markup generated by theme_more_link().
+ */
+.more-link {
+ text-align: left;
+}
+
+/**
+ * Markup generated by theme_more_help_link().
+ */
+.more-help-link {
+ text-align: left;
+}
+.more-help-link a {
+ background-position: 100% 50%;
+ padding: 1px 20px 1px 0;
+}
+
+/**
+ * Collapsible fieldsets.
+ */
+html.js fieldset.collapsible .fieldset-legend {
+ background-position: 98% 75%;
+ padding-left: 0;
+ padding-right: 15px;
+}
+html.js fieldset.collapsed .fieldset-legend {
+ background-image: url(../../misc/menu-collapsed-rtl.png);
+ background-position: 98% 50%;
+}
+
diff --git a/core/modules/system/system.theme.css b/core/modules/system/system.theme.css
new file mode 100644
index 000000000000..f34a965ec99f
--- /dev/null
+++ b/core/modules/system/system.theme.css
@@ -0,0 +1,239 @@
+
+/**
+ * @file
+ * Basic styling for common markup.
+ */
+
+/**
+ * HTML elements.
+ */
+fieldset {
+ margin-bottom: 1em;
+ padding: 0.5em;
+}
+form {
+ margin: 0;
+ padding: 0;
+}
+hr {
+ border: 1px solid gray;
+ height: 1px;
+}
+img {
+ border: 0;
+}
+table {
+ border-collapse: collapse;
+}
+th {
+ border-bottom: 3px solid #ccc;
+ padding-right: 1em; /* LTR */
+ text-align: left; /* LTR */
+}
+tr.even,
+tr.odd {
+ background-color: #eee;
+ border-bottom: 1px solid #ccc;
+ padding: 0.1em 0.6em;
+}
+
+/**
+ * Markup generated by theme_tablesort_indicator().
+ */
+th.active img {
+ display: inline;
+}
+td.active {
+ background-color: #ddd;
+}
+
+/**
+ * Markup generated by theme_item_list().
+ */
+.item-list .title {
+ font-weight: bold;
+}
+.item-list ul {
+ margin: 0 0 0.75em 0;
+ padding: 0;
+}
+.item-list ul li {
+ margin: 0 0 0.25em 1.5em; /* LTR */
+ padding: 0;
+}
+
+/**
+ * Markup generated by Form API.
+ */
+.form-item,
+.form-actions {
+ margin-top: 1em;
+ margin-bottom: 1em;
+}
+tr.odd .form-item,
+tr.even .form-item {
+ margin-top: 0;
+ margin-bottom: 0;
+ white-space: nowrap;
+}
+.form-item .description {
+ font-size: 0.85em;
+}
+label {
+ display: block;
+ font-weight: bold;
+}
+label.option {
+ display: inline;
+ font-weight: normal;
+}
+.form-checkboxes .form-item,
+.form-radios .form-item {
+ margin-top: 0.4em;
+ margin-bottom: 0.4em;
+}
+.form-type-radio .description,
+.form-type-checkbox .description {
+ margin-left: 2.4em;
+}
+input.form-checkbox,
+input.form-radio {
+ vertical-align: middle;
+}
+.marker,
+.form-required {
+ color: #f00;
+}
+abbr.form-required, abbr.tabledrag-changed, abbr.ajax-changed {
+ border-bottom: none;
+}
+.form-item input.error,
+.form-item textarea.error,
+.form-item select.error {
+ border: 2px solid red;
+}
+
+/**
+ * Inline items.
+ */
+.container-inline .form-actions,
+.container-inline.form-actions {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+/**
+ * Markup generated by theme_more_link().
+ */
+.more-link {
+ text-align: right; /* LTR */
+}
+
+/**
+ * Markup generated by theme_more_help_link().
+ */
+.more-help-link {
+ text-align: right; /* LTR */
+}
+.more-help-link a {
+ background: url(../../misc/help.png) 0 50% no-repeat; /* LTR */
+ padding: 1px 0 1px 20px; /* LTR */
+}
+
+/**
+ * Markup generated by theme_pager().
+ */
+.item-list .pager {
+ clear: both;
+ text-align: center;
+}
+.item-list .pager li {
+ background-image: none;
+ display: inline;
+ list-style-type: none;
+ padding: 0.5em;
+}
+.pager-current {
+ font-weight: bold;
+}
+
+/**
+ * Autocomplete.
+ *
+ * @see autocomplete.js
+ */
+/* Suggestion list */
+#autocomplete li.selected {
+ background: #0072b9;
+ color: #fff;
+}
+
+/**
+ * Collapsible fieldsets.
+ *
+ * @see collapse.js
+ */
+html.js fieldset.collapsible .fieldset-legend {
+ background: url(../../misc/menu-expanded.png) 5px 65% no-repeat; /* LTR */
+ padding-left: 15px; /* LTR */
+}
+html.js fieldset.collapsed .fieldset-legend {
+ background-image: url(../../misc/menu-collapsed.png); /* LTR */
+ background-position: 5px 50%; /* LTR */
+}
+.fieldset-legend span.summary {
+ color: #999;
+ font-size: 0.9em;
+ margin-left: 0.5em;
+}
+
+/**
+ * TableDrag behavior.
+ *
+ * @see tabledrag.js
+ */
+tr.drag {
+ background-color: #fffff0;
+}
+tr.drag-previous {
+ background-color: #ffd;
+}
+.tabledrag-toggle-weight {
+ font-size: 0.9em;
+}
+body div.tabledrag-changed-warning {
+ margin-bottom: 0.5em;
+}
+
+/**
+ * TableSelect behavior.
+ *
+ * @see tableselect.js
+*/
+tr.selected td {
+ background: #ffc;
+}
+td.checkbox,
+th.checkbox {
+ text-align: center;
+}
+
+/**
+ * Progress bar.
+ *
+ * @see progress.js
+ */
+.progress {
+ font-weight: bold;
+}
+.progress .bar {
+ background: #ccc;
+ border-color: #666;
+ margin: 0 0.2em;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ border-radius: 3px;
+}
+.progress .filled {
+ background: #0072b9 url(../../misc/progress.gif);
+}
diff --git a/core/modules/system/system.tokens.inc b/core/modules/system/system.tokens.inc
new file mode 100644
index 000000000000..b612d1057e65
--- /dev/null
+++ b/core/modules/system/system.tokens.inc
@@ -0,0 +1,269 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens system-wide data.
+ *
+ * This file handles tokens for the global 'site' token type, as well as
+ * 'date' and 'file' tokens.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function system_token_info() {
+ $types['site'] = array(
+ 'name' => t("Site information"),
+ 'description' => t("Tokens for site-wide settings and other global information."),
+ );
+ $types['date'] = array(
+ 'name' => t("Dates"),
+ 'description' => t("Tokens related to times and dates."),
+ );
+ $types['file'] = array(
+ 'name' => t("Files"),
+ 'description' => t("Tokens related to uploaded files."),
+ 'needs-data' => 'file',
+ );
+
+ // Site-wide global tokens.
+ $site['name'] = array(
+ 'name' => t("Name"),
+ 'description' => t("The name of the site."),
+ );
+ $site['slogan'] = array(
+ 'name' => t("Slogan"),
+ 'description' => t("The slogan of the site."),
+ );
+ $site['mail'] = array(
+ 'name' => t("Email"),
+ 'description' => t("The administrative email address for the site."),
+ );
+ $site['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The URL of the site's front page."),
+ );
+ $site['url-brief'] = array(
+ 'name' => t("URL (brief)"),
+ 'description' => t("The URL of the site's front page without the protocol."),
+ );
+ $site['login-url'] = array(
+ 'name' => t("Login page"),
+ 'description' => t("The URL of the site's login page."),
+ );
+
+ // Date related tokens.
+ $date['short'] = array(
+ 'name' => t("Short format"),
+ 'description' => t("A date in 'short' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'short'))),
+ );
+ $date['medium'] = array(
+ 'name' => t("Medium format"),
+ 'description' => t("A date in 'medium' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'medium'))),
+ );
+ $date['long'] = array(
+ 'name' => t("Long format"),
+ 'description' => t("A date in 'long' format. (%date)", array('%date' => format_date(REQUEST_TIME, 'long'))),
+ );
+ $date['custom'] = array(
+ 'name' => t("Custom format"),
+ 'description' => t("A date in a custom format. See !php-date for details.", array('!php-date' => l(t('the PHP documentation'), 'http://php.net/manual/en/function.date.php'))),
+ );
+ $date['since'] = array(
+ 'name' => t("Time-since"),
+ 'description' => t("A date in 'time-since' format. (%date)", array('%date' => format_interval(REQUEST_TIME - 360, 2))),
+ );
+ $date['raw'] = array(
+ 'name' => t("Raw timestamp"),
+ 'description' => t("A date in UNIX timestamp format (%date)", array('%date' => REQUEST_TIME)),
+ );
+
+
+ // File related tokens.
+ $file['fid'] = array(
+ 'name' => t("File ID"),
+ 'description' => t("The unique ID of the uploaded file."),
+ );
+ $file['name'] = array(
+ 'name' => t("File name"),
+ 'description' => t("The name of the file on disk."),
+ );
+ $file['path'] = array(
+ 'name' => t("Path"),
+ 'description' => t("The location of the file relative to Drupal root."),
+ );
+ $file['mime'] = array(
+ 'name' => t("MIME type"),
+ 'description' => t("The MIME type of the file."),
+ );
+ $file['size'] = array(
+ 'name' => t("File size"),
+ 'description' => t("The size of the file."),
+ );
+ $file['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The web-accessible URL for the file."),
+ );
+ $file['timestamp'] = array(
+ 'name' => t("Timestamp"),
+ 'description' => t("The date the file was most recently changed."),
+ 'type' => 'date',
+ );
+ $file['owner'] = array(
+ 'name' => t("Owner"),
+ 'description' => t("The user who originally uploaded the file."),
+ 'type' => 'user',
+ );
+
+ return array(
+ 'types' => $types,
+ 'tokens' => array(
+ 'site' => $site,
+ 'date' => $date,
+ 'file' => $file,
+ ),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function system_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ $replacements = array();
+
+ if ($type == 'site') {
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ case 'name':
+ $site_name = variable_get('site_name', 'Drupal');
+ $replacements[$original] = $sanitize ? check_plain($site_name) : $site_name;
+ break;
+
+ case 'slogan':
+ $slogan = variable_get('site_slogan', '');
+ $replacements[$original] = $sanitize ? check_plain($slogan) : $slogan;
+ break;
+
+ case 'mail':
+ $replacements[$original] = variable_get('site_mail', '');
+ break;
+
+ case 'url':
+ $replacements[$original] = url('<front>', $url_options);
+ break;
+
+ case 'url-brief':
+ $replacements[$original] = preg_replace(array('!^https?://!', '!/$!'), '', url('<front>', $url_options));
+ break;
+
+ case 'login-url':
+ $replacements[$original] = url('user', $url_options);
+ break;
+ }
+ }
+ }
+
+ elseif ($type == 'date') {
+ if (empty($data['date'])) {
+ $date = REQUEST_TIME;
+ }
+ else {
+ $date = $data['date'];
+ }
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ case 'short':
+ $replacements[$original] = format_date($date, 'short', '', NULL, $language_code);
+ break;
+
+ case 'medium':
+ $replacements[$original] = format_date($date, 'medium', '', NULL, $language_code);
+ break;
+
+ case 'long':
+ $replacements[$original] = format_date($date, 'long', '', NULL, $language_code);
+ break;
+
+ case 'since':
+ $replacements[$original] = format_interval((REQUEST_TIME - $date), 2, $language_code);
+ break;
+
+ case 'raw':
+ $replacements[$original] = $sanitize ? check_plain($date) : $date;
+ break;
+ }
+ }
+
+ if ($created_tokens = token_find_with_prefix($tokens, 'custom')) {
+ foreach ($created_tokens as $name => $original) {
+ $replacements[$original] = format_date($date, 'custom', $name, NULL, $language_code);
+ }
+ }
+ }
+
+ elseif ($type == 'file' && !empty($data['file'])) {
+ $file = $data['file'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ // Basic keys and values.
+ case 'fid':
+ $replacements[$original] = $file->fid;
+ break;
+
+ // Essential file data
+ case 'name':
+ $replacements[$original] = $sanitize ? check_plain($file->filename) : $file->filename;
+ break;
+
+ case 'path':
+ $replacements[$original] = $sanitize ? check_plain($file->uri) : $file->uri;
+ break;
+
+ case 'mime':
+ $replacements[$original] = $sanitize ? check_plain($file->filemime) : $file->filemime;
+ break;
+
+ case 'size':
+ $replacements[$original] = format_size($file->filesize);
+ break;
+
+ case 'url':
+ $replacements[$original] = $sanitize ? check_plain(file_create_url($file->uri)) : file_create_url($file->uri);
+ break;
+
+ // These tokens are default variations on the chained tokens handled below.
+ case 'timestamp':
+ $replacements[$original] = format_date($file->timestamp, 'medium', '', NULL, $language_code);
+ break;
+
+ case 'owner':
+ $account = user_load($file->uid);
+ $name = format_username($account);
+ $replacements[$original] = $sanitize ? check_plain($name) : $name;
+ break;
+ }
+ }
+
+ if ($date_tokens = token_find_with_prefix($tokens, 'timestamp')) {
+ $replacements += token_generate('date', $date_tokens, array('date' => $file->timestamp), $options);
+ }
+
+ if (($owner_tokens = token_find_with_prefix($tokens, 'owner')) && $account = user_load($file->uid)) {
+ $replacements += token_generate('user', $owner_tokens, array('user' => $account), $options);
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/system/system.updater.inc b/core/modules/system/system.updater.inc
new file mode 100644
index 000000000000..84c1752d3bf6
--- /dev/null
+++ b/core/modules/system/system.updater.inc
@@ -0,0 +1,146 @@
+<?php
+
+/**
+ * @file
+ * Subclasses of the Updater class to update Drupal core knows how to update.
+ * At this time, only modules and themes are supported.
+ */
+
+/**
+ * Class for updating modules using FileTransfer classes via authorize.php.
+ */
+class ModuleUpdater extends Updater implements DrupalUpdaterInterface {
+
+ /**
+ * Return the directory where a module should be installed.
+ *
+ * If the module is already installed, drupal_get_path() will return
+ * a valid path and we should install it there (although we need to use an
+ * absolute path, so we prepend DRUPAL_ROOT). If we're installing a new
+ * module, we always want it to go into sites/all/modules, since that's
+ * where all the documentation recommends users install their modules, and
+ * there's no way that can conflict on a multi-site installation, since
+ * the Update manager won't let you install a new module if it's already
+ * found on your system, and if there was a copy in sites/all, we'd see it.
+ */
+ public function getInstallDirectory() {
+ if ($relative_path = drupal_get_path('module', $this->name)) {
+ $relative_path = dirname($relative_path);
+ }
+ else {
+ $relative_path = 'sites/all/modules';
+ }
+ return DRUPAL_ROOT . '/' . $relative_path;
+ }
+
+ public function isInstalled() {
+ return (bool) drupal_get_path('module', $this->name);
+ }
+
+ public static function canUpdateDirectory($directory) {
+ if (file_scan_directory($directory, '/.*\.module$/')) {
+ return TRUE;
+ }
+ return FALSE;
+ }
+
+ public static function canUpdate($project_name) {
+ return (bool) drupal_get_path('module', $project_name);
+ }
+
+ /**
+ * Return available database schema updates one a new version is installed.
+ */
+ public function getSchemaUpdates() {
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ require_once DRUPAL_ROOT . '/core/includes/update.inc';
+
+ if (_update_get_project_type($project) != 'module') {
+ return array();
+ }
+ module_load_include('install', $project);
+
+ if (!$updates = drupal_get_schema_versions($project)) {
+ return array();
+ }
+ $updates_to_run = array();
+ $modules_with_updates = update_get_update_list();
+ if ($updates = $modules_with_updates[$project]) {
+ if ($updates['start']) {
+ return $updates['pending'];
+ }
+ }
+ return array();
+ }
+
+ public function postInstallTasks() {
+ return array(
+ l(t('Enable newly added modules'), 'admin/modules'),
+ l(t('Administration pages'), 'admin'),
+ );
+ }
+
+ public function postUpdateTasks() {
+ // We don't want to check for DB updates here, we do that once for all
+ // updated modules on the landing page.
+ }
+
+}
+
+/**
+ * Class for updating themes using FileTransfer classes via authorize.php.
+ */
+class ThemeUpdater extends Updater implements DrupalUpdaterInterface {
+
+ /**
+ * Return the directory where a theme should be installed.
+ *
+ * If the theme is already installed, drupal_get_path() will return
+ * a valid path and we should install it there (although we need to use an
+ * absolute path, so we prepend DRUPAL_ROOT). If we're installing a new
+ * theme, we always want it to go into sites/all/themes, since that's
+ * where all the documentation recommends users install their themes, and
+ * there's no way that can conflict on a multi-site installation, since
+ * the Update manager won't let you install a new theme if it's already
+ * found on your system, and if there was a copy in sites/all, we'd see it.
+ */
+ public function getInstallDirectory() {
+ if ($relative_path = drupal_get_path('theme', $this->name)) {
+ $relative_path = dirname($relative_path);
+ }
+ else {
+ $relative_path = 'sites/all/themes';
+ }
+ return DRUPAL_ROOT . '/' . $relative_path;
+ }
+
+ public function isInstalled() {
+ return (bool) drupal_get_path('theme', $this->name);
+ }
+
+ static function canUpdateDirectory($directory) {
+ // This is a lousy test, but don't know how else to confirm it is a theme.
+ if (file_scan_directory($directory, '/.*\.module$/')) {
+ return FALSE;
+ }
+ return TRUE;
+ }
+
+ public static function canUpdate($project_name) {
+ return (bool) drupal_get_path('theme', $project_name);
+ }
+
+ public function postInstall() {
+ // Update the system table.
+ clearstatcache();
+ system_rebuild_theme_data();
+
+ }
+
+ public function postInstallTasks() {
+ return array(
+ l(t('Enable newly added themes'), 'admin/appearance'),
+ l(t('Administration pages'), 'admin'),
+ );
+ }
+}
diff --git a/core/modules/system/theme.api.php b/core/modules/system/theme.api.php
new file mode 100644
index 000000000000..7fee81cb6708
--- /dev/null
+++ b/core/modules/system/theme.api.php
@@ -0,0 +1,230 @@
+<?php
+
+/**
+ * @defgroup themeable Default theme implementations
+ * @{
+ * Functions and templates for the user interface to be implemented by themes.
+ *
+ * Drupal's presentation layer is a pluggable system known as the theme
+ * layer. Each theme can take control over most of Drupal's output, and
+ * has complete control over the CSS.
+ *
+ * Inside Drupal, the theme layer is utilized by the use of the theme()
+ * function, which is passed the name of a component (the theme hook)
+ * and an array of variables. For example,
+ * theme('table', array('header' => $header, 'rows' => $rows));
+ * Additionally, the theme() function can take an array of theme
+ * hooks, which can be used to provide 'fallback' implementations to
+ * allow for more specific control of output. For example, the function:
+ * theme(array('table__foo', 'table'), $variables) would look to see if
+ * 'table__foo' is registered anywhere; if it is not, it would 'fall back'
+ * to the generic 'table' implementation. This can be used to attach specific
+ * theme functions to named objects, allowing the themer more control over
+ * specific types of output.
+ *
+ * As of Drupal 6, every theme hook is required to be registered by the
+ * module that owns it, so that Drupal can tell what to do with it and
+ * to make it simple for themes to identify and override the behavior
+ * for these calls.
+ *
+ * The theme hooks are registered via hook_theme(), which returns an
+ * array of arrays with information about the hook. It describes the
+ * arguments the function or template will need, and provides
+ * defaults for the template in case they are not filled in. If the default
+ * implementation is a function, by convention it is named theme_HOOK().
+ *
+ * Each module should provide a default implementation for theme_hooks that
+ * it registers. This implementation may be either a function or a template;
+ * if it is a function it must be specified via hook_theme(). By convention,
+ * default implementations of theme hooks are named theme_HOOK. Default
+ * template implementations are stored in the module directory.
+ *
+ * Drupal's default template renderer is a simple PHP parsing engine that
+ * includes the template and stores the output. Drupal's theme engines
+ * can provide alternate template engines, such as XTemplate, Smarty and
+ * PHPTal. The most common template engine is PHPTemplate (included with
+ * Drupal and implemented in phptemplate.engine, which uses Drupal's default
+ * template renderer.
+ *
+ * In order to create theme-specific implementations of these hooks, themes can
+ * implement their own version of theme hooks, either as functions or templates.
+ * These implementations will be used instead of the default implementation. If
+ * using a pure .theme without an engine, the .theme is required to implement
+ * its own version of hook_theme() to tell Drupal what it is implementing;
+ * themes utilizing an engine will have their well-named theming functions
+ * automatically registered for them. While this can vary based upon the theme
+ * engine, the standard set by phptemplate is that theme functions should be
+ * named THEMENAME_HOOK. For example, for Drupal's default theme (Bartik) to
+ * implement the 'table' hook, the phptemplate.engine would find
+ * bartik_table().
+ *
+ * The theme system is described and defined in theme.inc.
+ *
+ * @see theme()
+ * @see hook_theme()
+ *
+ * @} End of "defgroup themeable".
+ */
+
+/**
+ * Allow themes to alter the theme-specific settings form.
+ *
+ * With this hook, themes can alter the theme-specific settings form in any way
+ * allowable by Drupal's Forms API, such as adding form elements, changing
+ * default values and removing form elements. See the Forms API documentation on
+ * api.drupal.org for detailed information.
+ *
+ * Note that the base theme's form alterations will be run before any sub-theme
+ * alterations.
+ *
+ * @param $form
+ * Nested array of form elements that comprise the form.
+ * @param $form_state
+ * A keyed array containing the current state of the form.
+ */
+function hook_form_system_theme_settings_alter(&$form, &$form_state) {
+ // Add a checkbox to toggle the breadcrumb trail.
+ $form['toggle_breadcrumb'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display the breadcrumb'),
+ '#default_value' => theme_get_setting('toggle_breadcrumb'),
+ '#description' => t('Show a trail of links from the homepage to the current page.'),
+ );
+}
+
+/**
+ * Preprocess theme variables.
+ *
+ * This hook allows modules to preprocess theme variables for theme templates.
+ * It is called for all invocations of theme(), to allow modules to add to
+ * or override variables for all theme hooks.
+ *
+ * For more detailed information, see theme().
+ *
+ * @param $variables
+ * The variables array (modify in place).
+ * @param $hook
+ * The name of the theme hook.
+ */
+function hook_preprocess(&$variables, $hook) {
+ static $hooks;
+
+ // Add contextual links to the variables, if the user has permission.
+
+ if (!user_access('access contextual links')) {
+ return;
+ }
+
+ if (!isset($hooks)) {
+ $hooks = theme_get_registry();
+ }
+
+ // Determine the primary theme function argument.
+ if (isset($hooks[$hook]['variables'])) {
+ $keys = array_keys($hooks[$hook]['variables']);
+ $key = $keys[0];
+ }
+ else {
+ $key = $hooks[$hook]['render element'];
+ }
+
+ if (isset($variables[$key])) {
+ $element = $variables[$key];
+ }
+
+ if (isset($element) && is_array($element) && !empty($element['#contextual_links'])) {
+ $variables['title_suffix']['contextual_links'] = contextual_links_view($element);
+ if (!empty($variables['title_suffix']['contextual_links'])) {
+ $variables['classes_array'][] = 'contextual-links-region';
+ }
+ }
+}
+
+/**
+ * Preprocess theme variables for a specific theme hook.
+ *
+ * This hook allows modules to preprocess theme variables for a specific theme
+ * hook. It should only be used if a module needs to override or add to the
+ * theme preprocessing for a theme hook it didn't define.
+ *
+ * For more detailed information, see theme().
+ *
+ * @param $variables
+ * The variables array (modify in place).
+ */
+function hook_preprocess_HOOK(&$variables) {
+ // This example is from rdf_preprocess_image(). It adds an RDF attribute
+ // to the image hook's variables.
+ $variables['attributes']['typeof'] = array('foaf:Image');
+}
+
+/**
+ * Process theme variables.
+ *
+ * This hook allows modules to process theme variables for theme templates.
+ * It is called for all invocations of theme(), to allow modules to add to
+ * or override variables for all theme hooks.
+ *
+ * For more detailed information, see theme().
+ *
+ * @param $variables
+ * The variables array (modify in place).
+ * @param $hook
+ * The name of the theme hook.
+ */
+function hook_process(&$variables, $hook) {
+ // Wraps variables in RDF wrappers.
+ if (!empty($variables['rdf_template_variable_attributes_array'])) {
+ foreach ($variables['rdf_template_variable_attributes_array'] as $variable_name => $attributes) {
+ $context = array(
+ 'hook' => $hook,
+ 'variable_name' => $variable_name,
+ 'variables' => $variables,
+ );
+ $variables[$variable_name] = theme('rdf_template_variable_wrapper', array('content' => $variables[$variable_name], 'attributes' => $attributes, 'context' => $context));
+ }
+ }
+}
+
+/**
+ * Process theme variables for a specific theme hook.
+ *
+ * This hook allows modules to process theme variables for a specific theme
+ * hook. It should only be used if a module needs to override or add to the
+ * theme processing for a theme hook it didn't define.
+ *
+ * For more detailed information, see theme().
+ *
+ * @param $variables
+ * The variables array (modify in place).
+ */
+function hook_process_HOOK(&$variables) {
+ $variables['classes'] .= ' my_added_class';
+}
+
+/**
+ * Respond to themes being enabled.
+ *
+ * @param array $theme_list
+ * Array containing the names of the themes being enabled.
+ *
+ * @see theme_enable()
+ */
+function hook_themes_enabled($theme_list) {
+ foreach ($theme_list as $theme) {
+ block_theme_initialize($theme);
+ }
+}
+
+/**
+ * Respond to themes being disabled.
+ *
+ * @param array $theme_list
+ * Array containing the names of the themes being disabled.
+ *
+ * @see theme_disable()
+ */
+function hook_themes_disabled($theme_list) {
+ // Clear all update module caches.
+ _update_cache_clear();
+}
diff --git a/core/modules/taxonomy/taxonomy-term.tpl.php b/core/modules/taxonomy/taxonomy-term.tpl.php
new file mode 100644
index 000000000000..b1ff20e3cee7
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy-term.tpl.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to display a term.
+ *
+ * Available variables:
+ * - $name: the (sanitized) name of the term.
+ * - $content: An array of items for the content of the term (fields and
+ * description). Use render($content) to print them all, or print a subset
+ * such as render($content['field_example']). Use
+ * hide($content['field_example']) to temporarily suppress the printing of a
+ * given element.
+ * - $term_url: Direct url of the current term.
+ * - $term_name: Name of the current term.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the following:
+ * - taxonomy-term: The current template type, i.e., "theming hook".
+ * - vocabulary-[vocabulary-name]: The vocabulary to which the term belongs to.
+ * For example, if the term is a "Tag" it would result in "vocabulary-tag".
+ *
+ * Other variables:
+ * - $term: Full term object. Contains data that may not be safe.
+ * - $view_mode: View mode, e.g. 'full', 'teaser'...
+ * - $page: Flag for the full page state.
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $zebra: Outputs either "even" or "odd". Useful for zebra striping in
+ * teaser listings.
+ * - $id: Position of the term. Increments each time it's output.
+ * - $is_front: Flags true when presented in the front page.
+ * - $logged_in: Flags true when the current user is a logged-in member.
+ * - $is_admin: Flags true when the current user is an administrator.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_taxonomy_term()
+ * @see template_process()
+ */
+?>
+<div id="taxonomy-term-<?php print $term->tid; ?>" class="<?php print $classes; ?>">
+
+ <?php if (!$page): ?>
+ <h2><a href="<?php print $term_url; ?>"><?php print $term_name; ?></a></h2>
+ <?php endif; ?>
+
+ <div class="content">
+ <?php print render($content); ?>
+ </div>
+
+</div>
diff --git a/core/modules/taxonomy/taxonomy.admin.inc b/core/modules/taxonomy/taxonomy.admin.inc
new file mode 100644
index 000000000000..2440a283d8bc
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.admin.inc
@@ -0,0 +1,982 @@
+<?php
+
+/**
+ * @file
+ * Administrative page callbacks for the taxonomy module.
+ */
+
+/**
+ * Form builder to list and manage vocabularies.
+ *
+ * @ingroup forms
+ * @see taxonomy_overview_vocabularies_submit()
+ * @see theme_taxonomy_overview_vocabularies()
+ */
+function taxonomy_overview_vocabularies($form) {
+ $vocabularies = taxonomy_get_vocabularies();
+ $form['#tree'] = TRUE;
+ foreach ($vocabularies as $vocabulary) {
+ $form[$vocabulary->vid]['#vocabulary'] = $vocabulary;
+ $form[$vocabulary->vid]['name'] = array('#markup' => check_plain($vocabulary->name));
+ $form[$vocabulary->vid]['weight'] = array(
+ '#type' => 'weight',
+ '#title' => t('Weight for @title', array('@title' => $vocabulary->name)),
+ '#title_display' => 'invisible',
+ '#delta' => 10,
+ '#default_value' => $vocabulary->weight,
+ );
+ $form[$vocabulary->vid]['edit'] = array('#type' => 'link', '#title' => t('edit vocabulary'), '#href' => "admin/structure/taxonomy/$vocabulary->machine_name/edit");
+ $form[$vocabulary->vid]['list'] = array('#type' => 'link', '#title' => t('list terms'), '#href' => "admin/structure/taxonomy/$vocabulary->machine_name");
+ $form[$vocabulary->vid]['add'] = array('#type' => 'link', '#title' => t('add terms'), '#href' => "admin/structure/taxonomy/$vocabulary->machine_name/add");
+ }
+
+ // Only make this form include a submit button and weight if more than one
+ // vocabulary exists.
+ if (count($vocabularies) > 1) {
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+ }
+ elseif (isset($vocabulary)) {
+ unset($form[$vocabulary->vid]['weight']);
+ }
+ return $form;
+}
+
+/**
+ * Submit handler for vocabularies overview. Updates changed vocabulary weights.
+ *
+ * @see taxonomy_overview_vocabularies()
+ */
+function taxonomy_overview_vocabularies_submit($form, &$form_state) {
+ foreach ($form_state['values'] as $vid => $vocabulary) {
+ if (is_numeric($vid) && $form[$vid]['#vocabulary']->weight != $form_state['values'][$vid]['weight']) {
+ $form[$vid]['#vocabulary']->weight = $form_state['values'][$vid]['weight'];
+ taxonomy_vocabulary_save($form[$vid]['#vocabulary']);
+ }
+ }
+ drupal_set_message(t('The configuration options have been saved.'));
+}
+
+/**
+ * Returns HTML for the vocabulary overview form as a sortable list of vocabularies.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @see taxonomy_overview_vocabularies()
+ * @ingroup themeable
+ */
+function theme_taxonomy_overview_vocabularies($variables) {
+ $form = $variables['form'];
+
+ $rows = array();
+
+ foreach (element_children($form) as $key) {
+ if (isset($form[$key]['name'])) {
+ $vocabulary = &$form[$key];
+
+ $row = array();
+ $row[] = drupal_render($vocabulary['name']);
+ if (isset($vocabulary['weight'])) {
+ $vocabulary['weight']['#attributes']['class'] = array('vocabulary-weight');
+ $row[] = drupal_render($vocabulary['weight']);
+ }
+ $row[] = drupal_render($vocabulary['edit']);
+ $row[] = drupal_render($vocabulary['list']);
+ $row[] = drupal_render($vocabulary['add']);
+ $rows[] = array('data' => $row, 'class' => array('draggable'));
+ }
+ }
+
+ $header = array(t('Vocabulary name'));
+ if (isset($form['actions'])) {
+ $header[] = t('Weight');
+ drupal_add_tabledrag('taxonomy', 'order', 'sibling', 'vocabulary-weight');
+ }
+ $header[] = array('data' => t('Operations'), 'colspan' => '3');
+ return theme('table', array('header' => $header, 'rows' => $rows, 'empty' => t('No vocabularies available. <a href="@link">Add vocabulary</a>.', array('@link' => url('admin/structure/taxonomy/add'))), 'attributes' => array('id' => 'taxonomy'))) . drupal_render_children($form);
+}
+
+/**
+ * Form builder for the vocabulary editing form.
+ *
+ * @ingroup forms
+ * @see taxonomy_form_vocabulary_submit()
+ * @see taxonomy_form_vocabulary_validate()
+ */
+function taxonomy_form_vocabulary($form, &$form_state, $edit = array()) {
+ // During initial form build, add the entity to the form state for use
+ // during form building and processing. During a rebuild, use what is in the
+ // form state.
+ if (!isset($form_state['vocabulary'])) {
+ $vocabulary = is_object($edit) ? $edit : (object) $edit;
+ $defaults = array(
+ 'name' => '',
+ 'machine_name' => '',
+ 'description' => '',
+ 'hierarchy' => 0,
+ 'weight' => 0,
+ );
+ foreach ($defaults as $key => $value) {
+ if (!isset($vocabulary->$key)) {
+ $vocabulary->$key = $value;
+ }
+ }
+ $form_state['vocabulary'] = $vocabulary;
+ }
+ else {
+ $vocabulary = $form_state['vocabulary'];
+ }
+
+ // @todo Legacy support. Modules are encouraged to access the entity using
+ // $form_state. Remove in Drupal 8.
+ $form['#vocabulary'] = $form_state['vocabulary'];
+
+ // Check whether we need a deletion confirmation form.
+ if (isset($form_state['confirm_delete']) && isset($form_state['values']['vid'])) {
+ return taxonomy_vocabulary_confirm_delete($form, $form_state, $form_state['values']['vid']);
+ }
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#default_value' => $vocabulary->name,
+ '#maxlength' => 255,
+ '#required' => TRUE,
+ );
+ $form['machine_name'] = array(
+ '#type' => 'machine_name',
+ '#default_value' => $vocabulary->machine_name,
+ '#maxlength' => 255,
+ '#machine_name' => array(
+ 'exists' => 'taxonomy_vocabulary_machine_name_load',
+ ),
+ );
+ $form['old_machine_name'] = array(
+ '#type' => 'value',
+ '#value' => $vocabulary->machine_name,
+ );
+ $form['description'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Description'),
+ '#default_value' => $vocabulary->description,
+ );
+ // Set the hierarchy to "multiple parents" by default. This simplifies the
+ // vocabulary form and standardizes the term form.
+ $form['hierarchy'] = array(
+ '#type' => 'value',
+ '#value' => '0',
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save'));
+ if (isset($vocabulary->vid)) {
+ $form['actions']['delete'] = array('#type' => 'submit', '#value' => t('Delete'));
+ $form['vid'] = array('#type' => 'value', '#value' => $vocabulary->vid);
+ }
+ $form['#validate'][] = 'taxonomy_form_vocabulary_validate';
+
+ return $form;
+}
+
+/**
+ * Form validation handler for taxonomy_form_vocabulary().
+ *
+ * Makes sure that the machine name of the vocabulary is not in the
+ * disallowed list (names that conflict with menu items, such as 'list'
+ * and 'add').
+ *
+ * @see taxonomy_form_vocabulary()
+ * @see taxonomy_form_vocabulary_submit()
+ */
+function taxonomy_form_vocabulary_validate($form, &$form_state) {
+ // During the deletion there is no 'machine_name' key
+ if (isset($form_state['values']['machine_name'])) {
+ // Do not allow machine names to conflict with taxonomy path arguments.
+ $machine_name = $form_state['values']['machine_name'];
+ $disallowed = array('add', 'list');
+ if (in_array($machine_name, $disallowed)) {
+ form_set_error('machine_name', t('The machine-readable name cannot be "add" or "list".'));
+ }
+ }
+}
+
+/**
+ * Form submission handler for taxonomy_form_vocabulary().
+ *
+ * @see taxonomy_form_vocabulary()
+ * @see taxonomy_form_vocabulary_validate()
+ */
+function taxonomy_form_vocabulary_submit($form, &$form_state) {
+ if ($form_state['triggering_element']['#value'] == t('Delete')) {
+ // Rebuild the form to confirm vocabulary deletion.
+ $form_state['rebuild'] = TRUE;
+ $form_state['confirm_delete'] = TRUE;
+ return;
+ }
+
+ $vocabulary = $form_state['vocabulary'];
+ entity_form_submit_build_entity('taxonomy_vocabulary', $vocabulary, $form, $form_state);
+
+ switch (taxonomy_vocabulary_save($vocabulary)) {
+ case SAVED_NEW:
+ drupal_set_message(t('Created new vocabulary %name.', array('%name' => $vocabulary->name)));
+ watchdog('taxonomy', 'Created new vocabulary %name.', array('%name' => $vocabulary->name), WATCHDOG_NOTICE, l(t('edit'), 'admin/structure/taxonomy/' . $vocabulary->machine_name . '/edit'));
+ break;
+
+ case SAVED_UPDATED:
+ drupal_set_message(t('Updated vocabulary %name.', array('%name' => $vocabulary->name)));
+ watchdog('taxonomy', 'Updated vocabulary %name.', array('%name' => $vocabulary->name), WATCHDOG_NOTICE, l(t('edit'), 'admin/structure/taxonomy/' . $vocabulary->machine_name . '/edit'));
+ break;
+ }
+
+ $form_state['values']['vid'] = $vocabulary->vid;
+ $form_state['vid'] = $vocabulary->vid;
+ $form_state['redirect'] = 'admin/structure/taxonomy';
+}
+
+/**
+ * Form builder for the taxonomy terms overview.
+ *
+ * Display a tree of all the terms in a vocabulary, with options to edit
+ * each one. The form is made drag and drop by the theme function.
+ *
+ * @ingroup forms
+ * @see taxonomy_overview_terms_submit()
+ * @see theme_taxonomy_overview_terms()
+ */
+function taxonomy_overview_terms($form, &$form_state, $vocabulary) {
+ global $pager_page_array, $pager_total, $pager_total_items;
+
+ // Check for confirmation forms.
+ if (isset($form_state['confirm_reset_alphabetical'])) {
+ return taxonomy_vocabulary_confirm_reset_alphabetical($form, $form_state, $vocabulary->vid);
+ }
+
+ $form['#vocabulary'] = $vocabulary;
+ $form['#tree'] = TRUE;
+ $form['#parent_fields'] = FALSE;
+
+ $page = isset($_GET['page']) ? $_GET['page'] : 0;
+ $page_increment = variable_get('taxonomy_terms_per_page_admin', 100); // Number of terms per page.
+ $page_entries = 0; // Elements shown on this page.
+ $before_entries = 0; // Elements at the root level before this page.
+ $after_entries = 0; // Elements at the root level after this page.
+ $root_entries = 0; // Elements at the root level on this page.
+
+ // Terms from previous and next pages are shown if the term tree would have
+ // been cut in the middle. Keep track of how many extra terms we show on each
+ // page of terms.
+ $back_step = NULL;
+ $forward_step = 0;
+
+ // An array of the terms to be displayed on this page.
+ $current_page = array();
+
+ $delta = 0;
+ $term_deltas = array();
+ $tree = taxonomy_get_tree($vocabulary->vid);
+ $term = current($tree);
+ do {
+ // In case this tree is completely empty.
+ if (empty($term)) {
+ break;
+ }
+ $delta++;
+ // Count entries before the current page.
+ if ($page && ($page * $page_increment) > $before_entries && !isset($back_step)) {
+ $before_entries++;
+ continue;
+ }
+ // Count entries after the current page.
+ elseif ($page_entries > $page_increment && isset($complete_tree)) {
+ $after_entries++;
+ continue;
+ }
+
+ // Do not let a term start the page that is not at the root.
+ if (isset($term->depth) && ($term->depth > 0) && !isset($back_step)) {
+ $back_step = 0;
+ while ($pterm = prev($tree)) {
+ $before_entries--;
+ $back_step++;
+ if ($pterm->depth == 0) {
+ prev($tree);
+ continue 2; // Jump back to the start of the root level parent.
+ }
+ }
+ }
+ $back_step = isset($back_step) ? $back_step : 0;
+
+ // Continue rendering the tree until we reach the a new root item.
+ if ($page_entries >= $page_increment + $back_step + 1 && $term->depth == 0 && $root_entries > 1) {
+ $complete_tree = TRUE;
+ // This new item at the root level is the first item on the next page.
+ $after_entries++;
+ continue;
+ }
+ if ($page_entries >= $page_increment + $back_step) {
+ $forward_step++;
+ }
+
+ // Finally, if we've gotten down this far, we're rendering a term on this page.
+ $page_entries++;
+ $term_deltas[$term->tid] = isset($term_deltas[$term->tid]) ? $term_deltas[$term->tid] + 1 : 0;
+ $key = 'tid:' . $term->tid . ':' . $term_deltas[$term->tid];
+
+ // Keep track of the first term displayed on this page.
+ if ($page_entries == 1) {
+ $form['#first_tid'] = $term->tid;
+ }
+ // Keep a variable to make sure at least 2 root elements are displayed.
+ if ($term->parents[0] == 0) {
+ $root_entries++;
+ }
+ $current_page[$key] = $term;
+ } while ($term = next($tree));
+
+ // Because we didn't use a pager query, set the necessary pager variables.
+ $total_entries = $before_entries + $page_entries + $after_entries;
+ $pager_total_items[0] = $total_entries;
+ $pager_page_array[0] = $page;
+ $pager_total[0] = ceil($total_entries / $page_increment);
+
+ // If this form was already submitted once, it's probably hit a validation
+ // error. Ensure the form is rebuilt in the same order as the user submitted.
+ if (!empty($form_state['input'])) {
+ $order = array_flip(array_keys($form_state['input'])); // Get the $_POST order.
+ $current_page = array_merge($order, $current_page); // Update our form with the new order.
+ foreach ($current_page as $key => $term) {
+ // Verify this is a term for the current page and set at the current depth.
+ if (is_array($form_state['input'][$key]) && is_numeric($form_state['input'][$key]['tid'])) {
+ $current_page[$key]->depth = $form_state['input'][$key]['depth'];
+ }
+ else {
+ unset($current_page[$key]);
+ }
+ }
+ }
+
+ // Build the actual form.
+ foreach ($current_page as $key => $term) {
+ // Save the term for the current page so we don't have to load it a second time.
+ $form[$key]['#term'] = (array) $term;
+ if (isset($term->parents)) {
+ $form[$key]['#term']['parent'] = $term->parent = $term->parents[0];
+ unset($form[$key]['#term']['parents'], $term->parents);
+ }
+
+ $form[$key]['view'] = array('#type' => 'link', '#title' => $term->name, '#href' => "taxonomy/term/$term->tid");
+ if ($vocabulary->hierarchy < 2 && count($tree) > 1) {
+ $form['#parent_fields'] = TRUE;
+ $form[$key]['tid'] = array(
+ '#type' => 'hidden',
+ '#value' => $term->tid
+ );
+ $form[$key]['parent'] = array(
+ '#type' => 'hidden',
+ // Yes, default_value on a hidden. It needs to be changeable by the javascript.
+ '#default_value' => $term->parent,
+ );
+ $form[$key]['depth'] = array(
+ '#type' => 'hidden',
+ // Same as above, the depth is modified by javascript, so it's a default_value.
+ '#default_value' => $term->depth,
+ );
+ $form[$key]['weight'] = array(
+ '#type' => 'weight',
+ '#delta' => $delta,
+ '#title_display' => 'invisible',
+ '#title' => t('Weight for added term'),
+ '#default_value' => $term->weight,
+ );
+ }
+ $form[$key]['edit'] = array('#type' => 'link', '#title' => t('edit'), '#href' => 'taxonomy/term/' . $term->tid . '/edit', '#options' => array('query' => drupal_get_destination()));
+ }
+
+ $form['#total_entries'] = $total_entries;
+ $form['#page_increment'] = $page_increment;
+ $form['#page_entries'] = $page_entries;
+ $form['#back_step'] = $back_step;
+ $form['#forward_step'] = $forward_step;
+ $form['#empty_text'] = t('No terms available. <a href="@link">Add term</a>.', array('@link' => url('admin/structure/taxonomy/' . $vocabulary->machine_name . '/add')));
+
+ if ($vocabulary->hierarchy < 2 && count($tree) > 1) {
+ $form['actions'] = array('#type' => 'actions', '#tree' => FALSE);
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save')
+ );
+ $form['actions']['reset_alphabetical'] = array(
+ '#type' => 'submit',
+ '#value' => t('Reset to alphabetical')
+ );
+ $form_state['redirect'] = array($_GET['q'], (isset($_GET['page']) ? array('query' => array('page' => $_GET['page'])) : array()));
+ }
+
+ return $form;
+}
+
+/**
+ * Submit handler for terms overview form.
+ *
+ * Rather than using a textfield or weight field, this form depends entirely
+ * upon the order of form elements on the page to determine new weights.
+ *
+ * Because there might be hundreds or thousands of taxonomy terms that need to
+ * be ordered, terms are weighted from 0 to the number of terms in the
+ * vocabulary, rather than the standard -10 to 10 scale. Numbers are sorted
+ * lowest to highest, but are not necessarily sequential. Numbers may be skipped
+ * when a term has children so that reordering is minimal when a child is
+ * added or removed from a term.
+ *
+ * @see taxonomy_overview_terms()
+ */
+function taxonomy_overview_terms_submit($form, &$form_state) {
+ if ($form_state['triggering_element']['#value'] == t('Reset to alphabetical')) {
+ // Execute the reset action.
+ if ($form_state['values']['reset_alphabetical'] === TRUE) {
+ return taxonomy_vocabulary_confirm_reset_alphabetical_submit($form, $form_state);
+ }
+ // Rebuild the form to confirm the reset action.
+ $form_state['rebuild'] = TRUE;
+ $form_state['confirm_reset_alphabetical'] = TRUE;
+ return;
+ }
+
+ // Sort term order based on weight.
+ uasort($form_state['values'], 'drupal_sort_weight');
+
+ $vocabulary = $form['#vocabulary'];
+ $hierarchy = 0; // Update the current hierarchy type as we go.
+
+ $changed_terms = array();
+ $tree = taxonomy_get_tree($vocabulary->vid);
+
+ if (empty($tree)) {
+ return;
+ }
+
+ // Build a list of all terms that need to be updated on previous pages.
+ $weight = 0;
+ $term = (array) $tree[0];
+ while ($term['tid'] != $form['#first_tid']) {
+ if ($term['parents'][0] == 0 && $term['weight'] != $weight) {
+ $term['parent'] = $term['parents'][0];
+ $term['weight'] = $weight;
+ $changed_terms[$term['tid']] = $term;
+ }
+ $weight++;
+ $hierarchy = $term['parents'][0] != 0 ? 1 : $hierarchy;
+ $term = (array) $tree[$weight];
+ }
+
+ // Renumber the current page weights and assign any new parents.
+ $level_weights = array();
+ foreach ($form_state['values'] as $tid => $values) {
+ if (isset($form[$tid]['#term'])) {
+ $term = $form[$tid]['#term'];
+ // Give terms at the root level a weight in sequence with terms on previous pages.
+ if ($values['parent'] == 0 && $term['weight'] != $weight) {
+ $term['weight'] = $weight;
+ $changed_terms[$term['tid']] = $term;
+ }
+ // Terms not at the root level can safely start from 0 because they're all on this page.
+ elseif ($values['parent'] > 0) {
+ $level_weights[$values['parent']] = isset($level_weights[$values['parent']]) ? $level_weights[$values['parent']] + 1 : 0;
+ if ($level_weights[$values['parent']] != $term['weight']) {
+ $term['weight'] = $level_weights[$values['parent']];
+ $changed_terms[$term['tid']] = $term;
+ }
+ }
+ // Update any changed parents.
+ if ($values['parent'] != $term['parent']) {
+ $term['parent'] = $values['parent'];
+ $changed_terms[$term['tid']] = $term;
+ }
+ $hierarchy = $term['parent'] != 0 ? 1 : $hierarchy;
+ $weight++;
+ }
+ }
+
+ // Build a list of all terms that need to be updated on following pages.
+ for ($weight; $weight < count($tree); $weight++) {
+ $term = (array) $tree[$weight];
+ if ($term['parents'][0] == 0 && $term['weight'] != $weight) {
+ $term['parent'] = $term['parents'][0];
+ $term['weight'] = $weight;
+ $changed_terms[$term['tid']] = $term;
+ }
+ $hierarchy = $term['parents'][0] != 0 ? 1 : $hierarchy;
+ }
+
+ // Save all updated terms.
+ foreach ($changed_terms as $changed) {
+ $term = (object) $changed;
+ // Update term_hierachy and term_data directly since we don't have a
+ // fully populated term object to save.
+ db_update('taxonomy_term_hierarchy')
+ ->fields(array('parent' => $term->parent))
+ ->condition('tid', $term->tid, '=')
+ ->execute();
+
+ db_update('taxonomy_term_data')
+ ->fields(array('weight' => $term->weight))
+ ->condition('tid', $term->tid, '=')
+ ->execute();
+ }
+
+ // Update the vocabulary hierarchy to flat or single hierarchy.
+ if ($vocabulary->hierarchy != $hierarchy) {
+ $vocabulary->hierarchy = $hierarchy;
+ taxonomy_vocabulary_save($vocabulary);
+ }
+ drupal_set_message(t('The configuration options have been saved.'));
+}
+
+/**
+ * Returns HTML for a terms overview form as a sortable list of terms.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @see taxonomy_overview_terms()
+ * @ingroup themeable
+ */
+function theme_taxonomy_overview_terms($variables) {
+ $form = $variables['form'];
+
+ $page_increment = $form['#page_increment'];
+ $page_entries = $form['#page_entries'];
+ $back_step = $form['#back_step'];
+ $forward_step = $form['#forward_step'];
+
+ // Add drag and drop if parent fields are present in the form.
+ if ($form['#parent_fields']) {
+ drupal_add_tabledrag('taxonomy', 'match', 'parent', 'term-parent', 'term-parent', 'term-id', FALSE);
+ drupal_add_tabledrag('taxonomy', 'depth', 'group', 'term-depth', NULL, NULL, FALSE);
+ drupal_add_js(drupal_get_path('module', 'taxonomy') . '/taxonomy.js');
+ drupal_add_js(array('taxonomy' => array('backStep' => $back_step, 'forwardStep' => $forward_step)), 'setting');
+ drupal_add_css(drupal_get_path('module', 'taxonomy') . '/taxonomy.css');
+ }
+ drupal_add_tabledrag('taxonomy', 'order', 'sibling', 'term-weight');
+
+ $errors = form_get_errors() != FALSE ? form_get_errors() : array();
+ $rows = array();
+ foreach (element_children($form) as $key) {
+ if (isset($form[$key]['#term'])) {
+ $term = &$form[$key];
+
+ $row = array();
+ $row[] = (isset($term['#term']['depth']) && $term['#term']['depth'] > 0 ? theme('indentation', array('size' => $term['#term']['depth'])) : ''). drupal_render($term['view']);
+ if ($form['#parent_fields']) {
+ $term['tid']['#attributes']['class'] = array('term-id');
+ $term['parent']['#attributes']['class'] = array('term-parent');
+ $term['depth']['#attributes']['class'] = array('term-depth');
+ $row[0] .= drupal_render($term['parent']) . drupal_render($term['tid']) . drupal_render($term['depth']);
+ }
+ $term['weight']['#attributes']['class'] = array('term-weight');
+ $row[] = drupal_render($term['weight']);
+ $row[] = drupal_render($term['edit']);
+ $row = array('data' => $row);
+ $rows[$key] = $row;
+ }
+ }
+
+ // Add necessary classes to rows.
+ $row_position = 0;
+ foreach ($rows as $key => $row) {
+ $rows[$key]['class'] = array();
+ if (isset($form['#parent_fields'])) {
+ $rows[$key]['class'][] = 'draggable';
+ }
+
+ // Add classes that mark which terms belong to previous and next pages.
+ if ($row_position < $back_step || $row_position >= $page_entries - $forward_step) {
+ $rows[$key]['class'][] = 'taxonomy-term-preview';
+ }
+
+ if ($row_position !== 0 && $row_position !== count($rows) - 1) {
+ if ($row_position == $back_step - 1 || $row_position == $page_entries - $forward_step - 1) {
+ $rows[$key]['class'][] = 'taxonomy-term-divider-top';
+ }
+ elseif ($row_position == $back_step || $row_position == $page_entries - $forward_step) {
+ $rows[$key]['class'][] = 'taxonomy-term-divider-bottom';
+ }
+ }
+
+ // Add an error class if this row contains a form error.
+ foreach ($errors as $error_key => $error) {
+ if (strpos($error_key, $key) === 0) {
+ $rows[$key]['class'][] = 'error';
+ }
+ }
+ $row_position++;
+ }
+
+ if (empty($rows)) {
+ $rows[] = array(array('data' => $form['#empty_text'], 'colspan' => '3'));
+ }
+
+ $header = array(t('Name'), t('Weight'), t('Operations'));
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'taxonomy')));
+ $output .= drupal_render_children($form);
+ $output .= theme('pager');
+
+ return $output;
+}
+
+/**
+ * Form function for the term edit form.
+ *
+ * @ingroup forms
+ * @see taxonomy_form_term_submit()
+ */
+function taxonomy_form_term($form, &$form_state, $edit = array(), $vocabulary = NULL) {
+ // During initial form build, add the term entity to the form state for use
+ // during form building and processing. During a rebuild, use what is in the
+ // form state.
+ if (!isset($form_state['term'])) {
+ $term = is_object($edit) ? $edit : (object) $edit;
+ if (!isset($vocabulary) && isset($term->vid)) {
+ $vocabulary = taxonomy_vocabulary_load($term->vid);
+ }
+ $defaults = array(
+ 'name' => '',
+ 'description' => '',
+ 'format' => NULL,
+ 'vocabulary_machine_name' => isset($vocabulary) ? $vocabulary->machine_name : NULL,
+ 'tid' => NULL,
+ 'weight' => 0,
+ );
+ foreach ($defaults as $key => $value) {
+ if (!isset($term->$key)) {
+ $term->$key = $value;
+ }
+ }
+ $form_state['term'] = $term;
+ }
+ else {
+ $term = $form_state['term'];
+ if (!isset($vocabulary) && isset($term->vid)) {
+ $vocabulary = taxonomy_vocabulary_load($term->vid);
+ }
+ }
+
+ $parent = array_keys(taxonomy_get_parents($term->tid));
+ $form['#term'] = (array) $term;
+ $form['#term']['parent'] = $parent;
+ $form['#vocabulary'] = $vocabulary;
+
+ // Check for confirmation forms.
+ if (isset($form_state['confirm_delete'])) {
+ return array_merge($form, taxonomy_term_confirm_delete($form, $form_state, $term->tid));
+ }
+
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#default_value' => $term->name,
+ '#maxlength' => 255,
+ '#required' => TRUE,
+ '#weight' => -5,
+ );
+ $form['description'] = array(
+ '#type' => 'text_format',
+ '#title' => t('Description'),
+ '#default_value' => $term->description,
+ '#format' => $term->format,
+ '#weight' => 0,
+ );
+
+ $form['vocabulary_machine_name'] = array(
+ '#type' => 'value',
+ '#value' => isset($term->vocabulary_machine_name) ? $term->vocabulary_machine_name : $vocabulary->name,
+ );
+
+ field_attach_form('taxonomy_term', $term, $form, $form_state);
+
+ $form['relations'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Relations'),
+ '#collapsible' => TRUE,
+ '#collapsed' => $vocabulary->hierarchy < 2,
+ '#weight' => 10,
+ );
+
+ // taxonomy_get_tree and taxonomy_get_parents may contain large numbers of
+ // items so we check for taxonomy_override_selector before loading the
+ // full vocabulary. Contrib modules can then intercept before
+ // hook_form_alter to provide scalable alternatives.
+ if (!variable_get('taxonomy_override_selector', FALSE)) {
+ $parent = array_keys(taxonomy_get_parents($term->tid));
+ $children = taxonomy_get_tree($vocabulary->vid, $term->tid);
+
+ // A term can't be the child of itself, nor of its children.
+ foreach ($children as $child) {
+ $exclude[] = $child->tid;
+ }
+ $exclude[] = $term->tid;
+
+ $tree = taxonomy_get_tree($vocabulary->vid);
+ $options = array('<' . t('root') . '>');
+ if (empty($parent)) {
+ $parent = array(0);
+ }
+ foreach ($tree as $item) {
+ if (!in_array($item->tid, $exclude)) {
+ $options[$item->tid] = str_repeat('-', $item->depth) . $item->name;
+ }
+ }
+ $form['relations']['parent'] = array(
+ '#type' => 'select',
+ '#title' => t('Parent terms'),
+ '#options' => $options,
+ '#default_value' => $parent,
+ '#multiple' => TRUE,
+ );
+
+ }
+ $form['relations']['weight'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Weight'),
+ '#size' => 6,
+ '#default_value' => $term->weight,
+ '#description' => t('Terms are displayed in ascending order by weight.'),
+ '#required' => TRUE,
+ );
+ $form['vid'] = array(
+ '#type' => 'value',
+ '#value' => $vocabulary->vid,
+ );
+ $form['tid'] = array(
+ '#type' => 'value',
+ '#value' => $term->tid,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ '#weight' => 5,
+ );
+
+ if ($term->tid) {
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete'),
+ '#access' => user_access("delete terms in $vocabulary->vid") || user_access('administer taxonomy'),
+ '#weight' => 10,
+ );
+ }
+ else {
+ $form_state['redirect'] = $_GET['q'];
+ }
+
+ return $form;
+}
+
+/**
+ * Validation handler for the term form.
+ *
+ * @see taxonomy_form_term()
+ */
+function taxonomy_form_term_validate($form, &$form_state) {
+ entity_form_field_validate('taxonomy_term', $form, $form_state);
+
+ // Ensure numeric values.
+ if (isset($form_state['values']['weight']) && !is_numeric($form_state['values']['weight'])) {
+ form_set_error('weight', t('Weight value must be numeric.'));
+ }
+}
+
+/**
+ * Submit handler to insert or update a term.
+ *
+ * @see taxonomy_form_term()
+ */
+function taxonomy_form_term_submit($form, &$form_state) {
+ if ($form_state['triggering_element']['#value'] == t('Delete')) {
+ // Execute the term deletion.
+ if ($form_state['values']['delete'] === TRUE) {
+ return taxonomy_term_confirm_delete_submit($form, $form_state);
+ }
+ // Rebuild the form to confirm term deletion.
+ $form_state['rebuild'] = TRUE;
+ $form_state['confirm_delete'] = TRUE;
+ return;
+ }
+
+ $term = taxonomy_form_term_submit_build_taxonomy_term($form, $form_state);
+
+ $status = taxonomy_term_save($term);
+ switch ($status) {
+ case SAVED_NEW:
+ drupal_set_message(t('Created new term %term.', array('%term' => $term->name)));
+ watchdog('taxonomy', 'Created new term %term.', array('%term' => $term->name), WATCHDOG_NOTICE, l(t('edit'), 'taxonomy/term/' . $term->tid . '/edit'));
+ break;
+ case SAVED_UPDATED:
+ drupal_set_message(t('Updated term %term.', array('%term' => $term->name)));
+ watchdog('taxonomy', 'Updated term %term.', array('%term' => $term->name), WATCHDOG_NOTICE, l(t('edit'), 'taxonomy/term/' . $term->tid . '/edit'));
+ // Clear the page and block caches to avoid stale data.
+ cache_clear_all();
+ break;
+ }
+
+ $current_parent_count = count($form_state['values']['parent']);
+ $previous_parent_count = count($form['#term']['parent']);
+ // Root doesn't count if it's the only parent.
+ if ($current_parent_count == 1 && isset($form_state['values']['parent'][0])) {
+ $current_parent_count = 0;
+ $form_state['values']['parent'] = array();
+ }
+
+ // If the number of parents has been reduced to one or none, do a check on the
+ // parents of every term in the vocabulary value.
+ if ($current_parent_count < $previous_parent_count && $current_parent_count < 2) {
+ taxonomy_check_vocabulary_hierarchy($form['#vocabulary'], $form_state['values']);
+ }
+ // If we've increased the number of parents and this is a single or flat
+ // hierarchy, update the vocabulary immediately.
+ elseif ($current_parent_count > $previous_parent_count && $form['#vocabulary']->hierarchy < 2) {
+ $form['#vocabulary']->hierarchy = $current_parent_count == 1 ? 1 : 2;
+ taxonomy_vocabulary_save($form['#vocabulary']);
+ }
+
+ $form_state['values']['tid'] = $term->tid;
+ $form_state['tid'] = $term->tid;
+}
+
+/**
+ * Updates the form state's term entity by processing this submission's values.
+ */
+function taxonomy_form_term_submit_build_taxonomy_term($form, &$form_state) {
+ $term = $form_state['term'];
+ entity_form_submit_build_entity('taxonomy_term', $term, $form, $form_state);
+
+ // Convert text_format field into values expected by taxonomy_term_save().
+ $description = $form_state['values']['description'];
+ $term->description = $description['value'];
+ $term->format = $description['format'];
+ return $term;
+}
+
+/**
+ * Form builder for the term delete form.
+ *
+ * @ingroup forms
+ * @see taxonomy_term_confirm_delete_submit()
+ */
+function taxonomy_term_confirm_delete($form, &$form_state, $tid) {
+ $term = taxonomy_term_load($tid);
+
+ // Always provide entity id in the same form key as in the entity edit form.
+ $form['tid'] = array('#type' => 'value', '#value' => $tid);
+
+ $form['#term'] = $term;
+ $form['type'] = array('#type' => 'value', '#value' => 'term');
+ $form['name'] = array('#type' => 'value', '#value' => $term->name);
+ $form['vocabulary_machine_name'] = array('#type' => 'value', '#value' => $term->vocabulary_machine_name);
+ $form['delete'] = array('#type' => 'value', '#value' => TRUE);
+ return confirm_form($form,
+ t('Are you sure you want to delete the term %title?',
+ array('%title' => $term->name)),
+ 'admin/structure/taxonomy',
+ t('Deleting a term will delete all its children if there are any. This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel'));
+}
+
+/**
+ * Submit handler to delete a term after confirmation.
+ *
+ * @see taxonomy_term_confirm_delete()
+ */
+function taxonomy_term_confirm_delete_submit($form, &$form_state) {
+ taxonomy_term_delete($form_state['values']['tid']);
+ taxonomy_check_vocabulary_hierarchy($form['#vocabulary'], $form_state['values']);
+ drupal_set_message(t('Deleted term %name.', array('%name' => $form_state['values']['name'])));
+ watchdog('taxonomy', 'Deleted term %name.', array('%name' => $form_state['values']['name']), WATCHDOG_NOTICE);
+ $form_state['redirect'] = 'admin/structure/taxonomy';
+ cache_clear_all();
+ return;
+}
+
+/**
+ * Form builder for the vocabulary delete confirmation form.
+ *
+ * @ingroup forms
+ * @see taxonomy_vocabulary_confirm_delete_submit()
+ */
+function taxonomy_vocabulary_confirm_delete($form, &$form_state, $vid) {
+ $vocabulary = taxonomy_vocabulary_load($vid);
+
+ // Always provide entity id in the same form key as in the entity edit form.
+ $form['vid'] = array('#type' => 'value', '#value' => $vid);
+
+ $form['#vocabulary'] = $vocabulary;
+ $form['#id'] = 'taxonomy_vocabulary_confirm_delete';
+ $form['type'] = array('#type' => 'value', '#value' => 'vocabulary');
+ $form['name'] = array('#type' => 'value', '#value' => $vocabulary->name);
+ $form['#submit'] = array('taxonomy_vocabulary_confirm_delete_submit');
+ return confirm_form($form,
+ t('Are you sure you want to delete the vocabulary %title?',
+ array('%title' => $vocabulary->name)),
+ 'admin/structure/taxonomy',
+ t('Deleting a vocabulary will delete all the terms in it. This action cannot be undone.'),
+ t('Delete'),
+ t('Cancel'));
+}
+
+/**
+ * Submit handler to delete a vocabulary after confirmation.
+ *
+ * @see taxonomy_vocabulary_confirm_delete()
+ */
+function taxonomy_vocabulary_confirm_delete_submit($form, &$form_state) {
+ $status = taxonomy_vocabulary_delete($form_state['values']['vid']);
+ drupal_set_message(t('Deleted vocabulary %name.', array('%name' => $form_state['values']['name'])));
+ watchdog('taxonomy', 'Deleted vocabulary %name.', array('%name' => $form_state['values']['name']), WATCHDOG_NOTICE);
+ $form_state['redirect'] = 'admin/structure/taxonomy';
+ cache_clear_all();
+ return;
+}
+
+/**
+ * Form builder to confirm resetting a vocabulary to alphabetical order.
+ *
+ * @ingroup forms
+ * @see taxonomy_vocabulary_confirm_reset_alphabetical_submit()
+ */
+function taxonomy_vocabulary_confirm_reset_alphabetical($form, &$form_state, $vid) {
+ $vocabulary = taxonomy_vocabulary_load($vid);
+
+ $form['type'] = array('#type' => 'value', '#value' => 'vocabulary');
+ $form['vid'] = array('#type' => 'value', '#value' => $vid);
+ $form['machine_name'] = array('#type' => 'value', '#value' => $vocabulary->machine_name);
+ $form['name'] = array('#type' => 'value', '#value' => $vocabulary->name);
+ $form['reset_alphabetical'] = array('#type' => 'value', '#value' => TRUE);
+ return confirm_form($form,
+ t('Are you sure you want to reset the vocabulary %title to alphabetical order?',
+ array('%title' => $vocabulary->name)),
+ 'admin/structure/taxonomy/' . $vocabulary->machine_name,
+ t('Resetting a vocabulary will discard all custom ordering and sort items alphabetically.'),
+ t('Reset to alphabetical'),
+ t('Cancel'));
+}
+
+/**
+ * Submit handler to reset a vocabulary to alphabetical order after confirmation.
+ *
+ * @see taxonomy_vocabulary_confirm_reset_alphabetical()
+ */
+function taxonomy_vocabulary_confirm_reset_alphabetical_submit($form, &$form_state) {
+ db_update('taxonomy_term_data')
+ ->fields(array('weight' => 0))
+ ->condition('vid', $form_state['values']['vid'])
+ ->execute();
+ drupal_set_message(t('Reset vocabulary %name to alphabetical order.', array('%name' => $form_state['values']['name'])));
+ watchdog('taxonomy', 'Reset vocabulary %name to alphabetical order.', array('%name' => $form_state['values']['name']), WATCHDOG_NOTICE);
+ $form_state['redirect'] = 'admin/structure/taxonomy/' . $form_state['values']['machine_name'];
+}
diff --git a/core/modules/taxonomy/taxonomy.api.php b/core/modules/taxonomy/taxonomy.api.php
new file mode 100644
index 000000000000..cb778c9a764f
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.api.php
@@ -0,0 +1,214 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Taxonomy module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Act on taxonomy vocabularies when loaded.
+ *
+ * Modules implementing this hook can act on the vocabulary objects before they
+ * are returned by taxonomy_vocabulary_load_multiple().
+ *
+ * @param $vocabulary
+ * An array of taxonomy vocabulary objects.
+ */
+function hook_taxonomy_vocabulary_load($vocabularies) {
+ foreach ($vocabularies as $vocabulary) {
+ $vocabulary->synonyms = variable_get('taxonomy_' . $vocabulary->vid . '_synonyms', FALSE);
+ }
+}
+
+
+/**
+ * Act on taxonomy vocabularies before they are saved.
+ *
+ * Modules implementing this hook can act on the vocabulary object before it is
+ * inserted or updated.
+ *
+ * @param $vocabulary
+ * A taxonomy vocabulary object.
+ */
+function hook_taxonomy_vocabulary_presave($vocabulary) {
+ $vocabulary->foo = 'bar';
+}
+
+/**
+ * Act on taxonomy vocabularies when inserted.
+ *
+ * Modules implementing this hook can act on the vocabulary object when saved
+ * to the database.
+ *
+ * @param $vocabulary
+ * A taxonomy vocabulary object.
+ */
+function hook_taxonomy_vocabulary_insert($vocabulary) {
+ if ($vocabulary->synonyms) {
+ variable_set('taxonomy_' . $vocabulary->vid . '_synonyms', TRUE);
+ }
+}
+
+/**
+ * Act on taxonomy vocabularies when updated.
+ *
+ * Modules implementing this hook can act on the vocabulary object when updated.
+ *
+ * @param $vocabulary
+ * A taxonomy vocabulary object.
+ */
+function hook_taxonomy_vocabulary_update($vocabulary) {
+ $status = $vocabulary->synonyms ? TRUE : FALSE;
+ if ($vocabulary->synonyms) {
+ variable_set('taxonomy_' . $vocabulary->vid . '_synonyms', $status);
+ }
+}
+
+/**
+ * Respond to the deletion of taxonomy vocabularies.
+ *
+ * Modules implementing this hook can respond to the deletion of taxonomy
+ * vocabularies from the database.
+ *
+ * @param $vocabulary
+ * A taxonomy vocabulary object.
+ */
+function hook_taxonomy_vocabulary_delete($vocabulary) {
+ if (variable_get('taxonomy_' . $vocabulary->vid . '_synonyms', FALSE)) {
+ variable_del('taxonomy_' . $vocabulary->vid . '_synonyms');
+ }
+}
+
+/**
+ * Act on taxonomy terms when loaded.
+ *
+ * Modules implementing this hook can act on the term objects returned by
+ * taxonomy_term_load_multiple().
+ *
+ * For performance reasons, information to be added to term objects should be
+ * loaded in a single query for all terms where possible.
+ *
+ * Since terms are stored and retrieved from cache during a page request, avoid
+ * altering properties provided by the {taxonomy_term_data} table, since this
+ * may affect the way results are loaded from cache in subsequent calls.
+ *
+ * @param $terms
+ * An array of term objects, indexed by tid.
+ */
+function hook_taxonomy_term_load($terms) {
+ $result = db_query('SELECT tid, foo FROM {mytable} WHERE tid IN (:tids)', array(':tids' => array_keys($terms)));
+ foreach ($result as $record) {
+ $terms[$record->tid]->foo = $record->foo;
+ }
+}
+
+/**
+ * Act on taxonomy terms before they are saved.
+ *
+ * Modules implementing this hook can act on the term object before it is
+ * inserted or updated.
+ *
+ * @param $term
+ * A term object.
+ */
+function hook_taxonomy_term_presave($term) {
+ $term->foo = 'bar';
+}
+
+/**
+ * Act on taxonomy terms when inserted.
+ *
+ * Modules implementing this hook can act on the term object when saved to
+ * the database.
+ *
+ * @param $term
+ * A taxonomy term object.
+ */
+function hook_taxonomy_term_insert($term) {
+ if (!empty($term->synonyms)) {
+ foreach (explode ("\n", str_replace("\r", '', $term->synonyms)) as $synonym) {
+ if ($synonym) {
+ db_insert('taxonomy_term_synonym')
+ ->fields(array(
+ 'tid' => $term->tid,
+ 'name' => rtrim($synonym),
+ ))
+ ->execute();
+ }
+ }
+ }
+}
+
+/**
+ * Act on taxonomy terms when updated.
+ *
+ * Modules implementing this hook can act on the term object when updated.
+ *
+ * @param $term
+ * A taxonomy term object.
+ */
+function hook_taxonomy_term_update($term) {
+ hook_taxonomy_term_delete($term);
+ if (!empty($term->synonyms)) {
+ foreach (explode ("\n", str_replace("\r", '', $term->synonyms)) as $synonym) {
+ if ($synonym) {
+ db_insert('taxonomy_term_synonym')
+ ->fields(array(
+ 'tid' => $term->tid,
+ 'name' => rtrim($synonym),
+ ))
+ ->execute();
+ }
+ }
+ }
+}
+
+/**
+ * Respond to the deletion of taxonomy terms.
+ *
+ * Modules implementing this hook can respond to the deletion of taxonomy
+ * terms from the database.
+ *
+ * @param $term
+ * A taxonomy term object.
+ */
+function hook_taxonomy_term_delete($term) {
+ db_delete('term_synoynm')->condition('tid', $term->tid)->execute();
+}
+
+/**
+ * Alter the results of taxonomy_term_view().
+ *
+ * This hook is called after the content has been assembled in a structured
+ * array and may be used for doing processing which requires that the complete
+ * taxonomy term content structure has been built.
+ *
+ * If the module wishes to act on the rendered HTML of the term rather than the
+ * structured content array, it may use this hook to add a #post_render
+ * callback. Alternatively, it could also implement
+ * hook_preprocess_taxonomy_term(). See drupal_render() and theme()
+ * documentation respectively for details.
+ *
+ * @param $build
+ * A renderable array representing the node content.
+ *
+ * @see hook_entity_view_alter()
+ */
+function hook_taxonomy_term_view_alter(&$build) {
+ if ($build['#view_mode'] == 'full' && isset($build['an_additional_field'])) {
+ // Change its weight.
+ $build['an_additional_field']['#weight'] = -10;
+ }
+
+ // Add a #post_render callback to act on the rendered HTML of the term.
+ $build['#post_render'][] = 'my_module_node_post_render';
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/taxonomy/taxonomy.css b/core/modules/taxonomy/taxonomy.css
new file mode 100644
index 000000000000..36cd641bec2e
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.css
@@ -0,0 +1,13 @@
+
+tr.taxonomy-term-preview {
+ background-color: #EEE;
+}
+tr.taxonomy-term-divider-top {
+ border-bottom: none;
+}
+tr.taxonomy-term-divider-bottom {
+ border-top: 1px dotted #CCC;
+}
+.taxonomy-term-description {
+ margin: 5px 0 20px;
+}
diff --git a/core/modules/taxonomy/taxonomy.info b/core/modules/taxonomy/taxonomy.info
new file mode 100644
index 000000000000..6a13f81db0af
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.info
@@ -0,0 +1,10 @@
+name = Taxonomy
+description = Enables the categorization of content.
+package = Core
+version = VERSION
+core = 8.x
+dependencies[] = options
+dependencies[] = entity
+files[] = taxonomy.module
+files[] = taxonomy.test
+configure = admin/structure/taxonomy
diff --git a/core/modules/taxonomy/taxonomy.install b/core/modules/taxonomy/taxonomy.install
new file mode 100644
index 000000000000..3e07259f4118
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.install
@@ -0,0 +1,248 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the taxonomy module.
+ */
+
+/**
+ * Implements hook_uninstall().
+ */
+function taxonomy_uninstall() {
+ // Remove variables.
+ variable_del('taxonomy_override_selector');
+ variable_del('taxonomy_terms_per_page_admin');
+ // Remove taxonomy_term bundles.
+ $vocabularies = db_query("SELECT machine_name FROM {taxonomy_vocabulary}")->fetchCol();
+ foreach ($vocabularies as $vocabulary) {
+ field_attach_delete_bundle('taxonomy_term', $vocabulary);
+ }
+}
+
+/**
+ * Implements hook_schema().
+ */
+function taxonomy_schema() {
+ $schema['taxonomy_term_data'] = array(
+ 'description' => 'Stores term information.',
+ 'fields' => array(
+ 'tid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique term ID.',
+ ),
+ 'vid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The {taxonomy_vocabulary}.vid of the vocabulary to which the term is assigned.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The term name.',
+ 'translatable' => TRUE,
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'description' => 'A description of the term.',
+ 'translatable' => TRUE,
+ ),
+ 'format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'The {filter_format}.format of the description.',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The weight of this term in relation to other terms.',
+ ),
+ ),
+ 'primary key' => array('tid'),
+ 'foreign keys' => array(
+ 'vocabulary' => array(
+ 'table' => 'taxonomy_vocabulary',
+ 'columns' => array('vid' => 'vid'),
+ ),
+ ),
+ 'indexes' => array(
+ 'taxonomy_tree' => array('vid', 'weight', 'name'),
+ 'vid_name' => array('vid', 'name'),
+ 'name' => array('name'),
+ ),
+ );
+
+ $schema['taxonomy_term_hierarchy'] = array(
+ 'description' => 'Stores the hierarchical relationship between terms.',
+ 'fields' => array(
+ 'tid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Primary Key: The {taxonomy_term_data}.tid of the term.',
+ ),
+ 'parent' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "Primary Key: The {taxonomy_term_data}.tid of the term's parent. 0 indicates no parent.",
+ ),
+ ),
+ 'indexes' => array(
+ 'parent' => array('parent'),
+ ),
+ 'foreign keys' => array(
+ 'taxonomy_term_data' => array(
+ 'table' => 'taxonomy_term_data',
+ 'columns' => array('tid' => 'tid'),
+ ),
+ ),
+ 'primary key' => array('tid', 'parent'),
+ );
+
+ $schema['taxonomy_vocabulary'] = array(
+ 'description' => 'Stores vocabulary information.',
+ 'fields' => array(
+ 'vid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique vocabulary ID.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Name of the vocabulary.',
+ 'translatable' => TRUE,
+ ),
+ 'machine_name' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'The vocabulary machine name.',
+ ),
+ 'description' => array(
+ 'type' => 'text',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'description' => 'Description of the vocabulary.',
+ 'translatable' => TRUE,
+ ),
+ 'hierarchy' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'The type of hierarchy allowed within the vocabulary. (0 = disabled, 1 = single, 2 = multiple)',
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The weight of this vocabulary in relation to other vocabularies.',
+ ),
+ ),
+ 'primary key' => array('vid'),
+ 'indexes' => array(
+ 'list' => array('weight', 'name'),
+ ),
+ 'unique keys' => array(
+ 'machine_name' => array('machine_name'),
+ ),
+ );
+
+ $schema['taxonomy_index'] = array(
+ 'description' => 'Maintains denormalized information about node/term relationships.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record tracks.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'tid' => array(
+ 'description' => 'The term ID.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'sticky' => array(
+ 'description' => 'Boolean indicating whether the node is sticky.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'created' => array(
+ 'description' => 'The Unix timestamp when the node was created.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default'=> 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'term_node' => array('tid', 'sticky', 'created'),
+ 'nid' => array('nid'),
+ ),
+ 'foreign keys' => array(
+ 'tracked_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'term' => array(
+ 'table' => 'taxonomy_term_data',
+ 'columns' => array('tid' => 'tid'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_field_schema().
+ */
+function taxonomy_field_schema($field) {
+ return array(
+ 'columns' => array(
+ 'tid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => FALSE,
+ ),
+ ),
+ 'indexes' => array(
+ 'tid' => array('tid'),
+ ),
+ 'foreign keys' => array(
+ 'tid' => array(
+ 'table' => 'taxonomy_term_data',
+ 'columns' => array('tid' => 'tid'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Remove the {taxonomy_vocabulary}.module field.
+ */
+function taxonomy_update_8000() {
+ db_drop_field('taxonomy_vocabulary', 'module');
+} \ No newline at end of file
diff --git a/core/modules/taxonomy/taxonomy.js b/core/modules/taxonomy/taxonomy.js
new file mode 100644
index 000000000000..cc9cdf7a61c8
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.js
@@ -0,0 +1,40 @@
+(function ($) {
+
+/**
+ * Move a block in the blocks table from one region to another via select list.
+ *
+ * This behavior is dependent on the tableDrag behavior, since it uses the
+ * objects initialized in that behavior to update the row.
+ */
+Drupal.behaviors.termDrag = {
+ attach: function (context, settings) {
+ var table = $('#taxonomy', context);
+ var tableDrag = Drupal.tableDrag.taxonomy; // Get the blocks tableDrag object.
+ var rows = $('tr', table).size();
+
+ // When a row is swapped, keep previous and next page classes set.
+ tableDrag.row.prototype.onSwap = function (swappedRow) {
+ $('tr.taxonomy-term-preview', table).removeClass('taxonomy-term-preview');
+ $('tr.taxonomy-term-divider-top', table).removeClass('taxonomy-term-divider-top');
+ $('tr.taxonomy-term-divider-bottom', table).removeClass('taxonomy-term-divider-bottom');
+
+ if (settings.taxonomy.backStep) {
+ for (var n = 0; n < settings.taxonomy.backStep; n++) {
+ $(table[0].tBodies[0].rows[n]).addClass('taxonomy-term-preview');
+ }
+ $(table[0].tBodies[0].rows[settings.taxonomy.backStep - 1]).addClass('taxonomy-term-divider-top');
+ $(table[0].tBodies[0].rows[settings.taxonomy.backStep]).addClass('taxonomy-term-divider-bottom');
+ }
+
+ if (settings.taxonomy.forwardStep) {
+ for (var n = rows - settings.taxonomy.forwardStep - 1; n < rows - 1; n++) {
+ $(table[0].tBodies[0].rows[n]).addClass('taxonomy-term-preview');
+ }
+ $(table[0].tBodies[0].rows[rows - settings.taxonomy.forwardStep - 2]).addClass('taxonomy-term-divider-top');
+ $(table[0].tBodies[0].rows[rows - settings.taxonomy.forwardStep - 1]).addClass('taxonomy-term-divider-bottom');
+ }
+ };
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/taxonomy/taxonomy.module b/core/modules/taxonomy/taxonomy.module
new file mode 100644
index 000000000000..eb20c26590fd
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.module
@@ -0,0 +1,1793 @@
+<?php
+
+/**
+ * @file
+ * Enables the organization of content into categories.
+ */
+
+/**
+ * Users can create new terms in a free-tagging vocabulary when
+ * submitting a taxonomy_autocomplete_widget. We store a term object
+ * whose tid is 'autocreate' as a field data item during widget
+ * validation and then actually create the term if/when that field
+ * data item makes it to taxonomy_field_insert/update().
+ */
+
+/**
+ * Implements hook_help().
+ */
+function taxonomy_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#taxonomy':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Taxonomy module allows you to classify the content of your website. To classify content, you define <em>vocabularies</em> that contain related <em>terms</em>, and then assign the vocabularies to content types. For more information, see the online handbook entry for the <a href="@taxonomy">Taxonomy module</a>.', array('@taxonomy' => 'http://drupal.org/handbook/modules/taxonomy/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Creating vocabularies') . '</dt>';
+ $output .= '<dd>' . t('Users with sufficient <a href="@perm">permissions</a> can create <em>vocabularies</em> and <em>terms</em> through the <a href="@taxo">Taxonomy page</a>. The page listing the terms provides a drag-and-drop interface for controlling the order of the terms and sub-terms within a vocabulary, in a hierarchical fashion. A <em>controlled vocabulary</em> classifying music by genre with terms and sub-terms could look as follows:', array('@taxo' => url('admin/structure/taxonomy'), '@perm' => url('admin/people/permissions', array('fragment'=>'module-taxonomy'))));
+ $output .= '<ul><li>' . t('<em>vocabulary</em>: Music') . '</li>';
+ $output .= '<ul><li>' . t('<em>term</em>: Jazz') . '</li>';
+ $output .= '<ul><li>' . t('<em>sub-term</em>: Swing') . '</li>';
+ $output .= '<li>' . t('<em>sub-term</em>: Fusion') . '</li></ul></ul>';
+ $output .= '<ul><li>' . t('<em>term</em>: Rock') . '</li>';
+ $output .= '<ul><li>' . t('<em>sub-term</em>: Country rock') . '</li>';
+ $output .= '<li>' . t('<em>sub-term</em>: Hard rock') . '</li></ul></ul></ul>';
+ $output .= t('You can assign a sub-term to multiple parent terms. For example, <em>fusion</em> can be assigned to both <em>rock</em> and <em>jazz</em>.') . '</dd>';
+ $output .= '<dd>' . t('Terms in a <em>free-tagging vocabulary</em> can be built gradually as you create or edit content. This is often done used for blogs or photo management applications.') . '</dd>';
+ $output .= '<dt>' . t('Assigning vocabularies to content types') . '</dt>';
+ $output .= '<dd>' . t('Before you can use a new vocabulary to classify your content, a new Taxonomy term field must be added to a <a href="@ctedit">content type</a> on its <em>manage fields</em> page. When adding a taxonomy field, you choose a <em>widget</em> to use to enter the taxonomy information on the content editing page: a select list, checkboxes, radio buttons, or an auto-complete field (to build a free-tagging vocabulary). After choosing the field type and widget, on the subsequent <em>field settings</em> page you can choose the desired vocabulary, whether one or multiple terms can be chosen from the vocabulary, and other settings. The same vocabulary can be added to multiple content types, by using the "Add existing field" section on the manage fields page.', array('@ctedit' => url('admin/structure/types'))) . '</dd>';
+ $output .= '<dt>' . t('Classifying content') . '</dt>';
+ $output .= '<dd>' . t('After the vocabulary is assigned to the content type, you can start classifying content. The field with terms will appear on the content editing screen when you edit or <a href="@addnode">add new content</a>.', array('@addnode' => url('node/add'))) . '</dd>';
+ $output .= '<dt>' . t('Viewing listings and RSS feeds by term') . '</dt>';
+ $output .= '<dd>' . t("Each taxonomy term automatically provides a page listing content that has its classification, and a corresponding RSS feed. For example, if the taxonomy term <em>country rock</em> has the ID 123 (you can see this by looking at the URL when hovering on the linked term, which you can click to navigate to the listing page), then you will find this list at the path <em>taxonomy/term/123</em>. The RSS feed will use the path <em>taxonomy/term/123/feed</em> (the RSS icon for this term's listing will automatically display in your browser's address bar when viewing the listing page).") . '</dd>';
+ $output .= '<dt>' . t('Extending Taxonomy module') . '</dt>';
+ $output .= '<dd>' . t('There are <a href="@taxcontrib">many contributed modules</a> that extend the behavior of the Taxonomy module for both display and organization of terms.', array('@taxcontrib' => 'http://drupal.org/project/modules?filters=tid:71&solrsort=sis_project_release_usage%20desc'));
+ $output .= '</dl>';
+ return $output;
+ case 'admin/structure/taxonomy':
+ $output = '<p>' . t('Taxonomy is for categorizing content. Terms are grouped into vocabularies. For example, a vocabulary called "Fruit" would contain the terms "Apple" and "Banana".') . '</p>';
+ return $output;
+ case 'admin/structure/taxonomy/%':
+ $vocabulary = taxonomy_vocabulary_machine_name_load($arg[3]);
+ switch ($vocabulary->hierarchy) {
+ case 0:
+ return '<p>' . t('You can reorganize the terms in %capital_name using their drag-and-drop handles, and group terms under a parent term by sliding them under and to the right of the parent.', array('%capital_name' => drupal_ucfirst($vocabulary->name), '%name' => $vocabulary->name)) . '</p>';
+ case 1:
+ return '<p>' . t('%capital_name contains terms grouped under parent terms. You can reorganize the terms in %capital_name using their drag-and-drop handles.', array('%capital_name' => drupal_ucfirst($vocabulary->name), '%name' => $vocabulary->name)) . '</p>';
+ case 2:
+ return '<p>' . t('%capital_name contains terms with multiple parents. Drag and drop of terms with multiple parents is not supported, but you can re-enable drag-and-drop support by editing each term to include only a single parent.', array('%capital_name' => drupal_ucfirst($vocabulary->name))) . '</p>';
+ }
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function taxonomy_permission() {
+ $permissions = array(
+ 'administer taxonomy' => array(
+ 'title' => t('Administer vocabularies and terms'),
+ ),
+ );
+ foreach (taxonomy_get_vocabularies() as $vocabulary) {
+ $permissions += array(
+ 'edit terms in ' . $vocabulary->vid => array(
+ 'title' => t('Edit terms in %vocabulary', array('%vocabulary' => $vocabulary->name)),
+ ),
+ );
+ $permissions += array(
+ 'delete terms in ' . $vocabulary->vid => array(
+ 'title' => t('Delete terms from %vocabulary', array('%vocabulary' => $vocabulary->name)),
+ ),
+ );
+ }
+ return $permissions;
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function taxonomy_entity_info() {
+ $return = array(
+ 'taxonomy_term' => array(
+ 'label' => t('Taxonomy term'),
+ 'controller class' => 'TaxonomyTermController',
+ 'base table' => 'taxonomy_term_data',
+ 'uri callback' => 'taxonomy_term_uri',
+ 'fieldable' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'tid',
+ 'bundle' => 'vocabulary_machine_name',
+ 'label' => 'name',
+ ),
+ 'bundle keys' => array(
+ 'bundle' => 'machine_name',
+ ),
+ 'bundles' => array(),
+ 'view modes' => array(
+ // @todo View mode for display as a field (when attached to nodes etc).
+ 'full' => array(
+ 'label' => t('Taxonomy term page'),
+ 'custom settings' => FALSE,
+ ),
+ ),
+ ),
+ );
+ foreach (taxonomy_vocabulary_get_names() as $machine_name => $vocabulary) {
+ $return['taxonomy_term']['bundles'][$machine_name] = array(
+ 'label' => $vocabulary->name,
+ 'admin' => array(
+ 'path' => 'admin/structure/taxonomy/%taxonomy_vocabulary_machine_name',
+ 'real path' => 'admin/structure/taxonomy/' . $machine_name,
+ 'bundle argument' => 3,
+ 'access arguments' => array('administer taxonomy'),
+ ),
+ );
+ }
+ $return['taxonomy_vocabulary'] = array(
+ 'label' => t('Taxonomy vocabulary'),
+ 'controller class' => 'TaxonomyVocabularyController',
+ 'base table' => 'taxonomy_vocabulary',
+ 'entity keys' => array(
+ 'id' => 'vid',
+ 'label' => 'name',
+ ),
+ 'fieldable' => FALSE,
+ );
+
+ return $return;
+}
+
+/**
+ * Entity uri callback.
+ */
+function taxonomy_term_uri($term) {
+ return array(
+ 'path' => 'taxonomy/term/' . $term->tid,
+ );
+}
+
+/**
+ * Implements hook_field_extra_fields().
+ */
+function taxonomy_field_extra_fields() {
+ $return = array();
+ $info = entity_get_info('taxonomy_term');
+ foreach (array_keys($info['bundles']) as $bundle) {
+ $return['taxonomy_term'][$bundle] = array(
+ 'form' => array(
+ 'name' => array(
+ 'label' => t('Name'),
+ 'description' => t('Term name textfield'),
+ 'weight' => -5,
+ ),
+ 'description' => array(
+ 'label' => t('Description'),
+ 'description' => t('Term description textarea'),
+ 'weight' => 0,
+ ),
+ ),
+ 'display' => array(
+ 'description' => array(
+ 'label' => t('Description'),
+ 'description' => t('Term description'),
+ 'weight' => 0,
+ ),
+ ),
+ );
+ }
+
+ return $return;
+}
+
+/**
+ * Return nodes attached to a term across all field instances.
+ *
+ * This function requires taxonomy module to be maintaining its own tables,
+ * and will return an empty array if it is not. If using other field storage
+ * methods alternatives methods for listing terms will need to be used.
+ *
+ * @param $tid
+ * The term ID.
+ * @param $pager
+ * Boolean to indicate whether a pager should be used.
+ * @param $limit
+ * Integer. The maximum number of nodes to find.
+ * Set to FALSE for no limit.
+ * @order
+ * An array of fields and directions.
+ *
+ * @return
+ * An array of nids matching the query.
+ */
+function taxonomy_select_nodes($tid, $pager = TRUE, $limit = FALSE, $order = array('t.sticky' => 'DESC', 't.created' => 'DESC')) {
+ if (!variable_get('taxonomy_maintain_index_table', TRUE)) {
+ return array();
+ }
+ $query = db_select('taxonomy_index', 't');
+ $query->addTag('node_access');
+ $query->condition('tid', $tid);
+ if ($pager) {
+ $count_query = clone $query;
+ $count_query->addExpression('COUNT(t.nid)');
+
+ $query = $query->extend('PagerDefault');
+ if ($limit !== FALSE) {
+ $query = $query->limit($limit);
+ }
+ $query->setCountQuery($count_query);
+ }
+ else {
+ if ($limit !== FALSE) {
+ $query->range(0, $limit);
+ }
+ }
+ $query->addField('t', 'nid');
+ $query->addField('t', 'tid');
+ foreach ($order as $field => $direction) {
+ $query->orderBy($field, $direction);
+ // ORDER BY fields need to be loaded too, assume they are in the form
+ // table_alias.name
+ list($table_alias, $name) = explode('.', $field);
+ $query->addField($table_alias, $name);
+ }
+ return $query->execute()->fetchCol();
+}
+
+/**
+ * Implements hook_theme().
+ */
+function taxonomy_theme() {
+ return array(
+ 'taxonomy_overview_vocabularies' => array(
+ 'render element' => 'form',
+ ),
+ 'taxonomy_overview_terms' => array(
+ 'render element' => 'form',
+ ),
+ 'taxonomy_term' => array(
+ 'render element' => 'elements',
+ 'template' => 'taxonomy-term',
+ ),
+ );
+}
+
+/**
+ * Implements hook_menu().
+ */
+function taxonomy_menu() {
+ $items['admin/structure/taxonomy'] = array(
+ 'title' => 'Taxonomy',
+ 'description' => 'Manage tagging, categorization, and classification of your content.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_overview_vocabularies'),
+ 'access arguments' => array('administer taxonomy'),
+ 'file' => 'taxonomy.admin.inc',
+ );
+ $items['admin/structure/taxonomy/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+ $items['admin/structure/taxonomy/add'] = array(
+ 'title' => 'Add vocabulary',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_form_vocabulary'),
+ 'access arguments' => array('administer taxonomy'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'taxonomy.admin.inc',
+ );
+
+ $items['taxonomy/term/%taxonomy_term'] = array(
+ 'title' => 'Taxonomy term',
+ 'title callback' => 'taxonomy_term_title',
+ 'title arguments' => array(2),
+ 'page callback' => 'taxonomy_term_page',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'file' => 'taxonomy.pages.inc',
+ );
+ $items['taxonomy/term/%taxonomy_term/view'] = array(
+ 'title' => 'View',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['taxonomy/term/%taxonomy_term/edit'] = array(
+ 'title' => 'Edit',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_form_term', 2),
+ 'access callback' => 'taxonomy_term_edit_access',
+ 'access arguments' => array(2),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 10,
+ 'file' => 'taxonomy.admin.inc',
+ );
+ $items['taxonomy/term/%taxonomy_term/feed'] = array(
+ 'title' => 'Taxonomy term',
+ 'title callback' => 'taxonomy_term_title',
+ 'title arguments' => array(2),
+ 'page callback' => 'taxonomy_term_feed',
+ 'page arguments' => array(2),
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'taxonomy.pages.inc',
+ );
+ $items['taxonomy/autocomplete'] = array(
+ 'title' => 'Autocomplete taxonomy',
+ 'page callback' => 'taxonomy_autocomplete',
+ 'access arguments' => array('access content'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'taxonomy.pages.inc',
+ );
+
+ $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name'] = array(
+ 'title callback' => 'taxonomy_admin_vocabulary_title_callback',
+ 'title arguments' => array(3),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_overview_terms', 3),
+ 'access arguments' => array('administer taxonomy'),
+ 'file' => 'taxonomy.admin.inc',
+ );
+ $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/list'] = array(
+ 'title' => 'List',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -20,
+ );
+ $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/edit'] = array(
+ 'title' => 'Edit',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_form_vocabulary', 3),
+ 'access arguments' => array('administer taxonomy'),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => -10,
+ 'file' => 'taxonomy.admin.inc',
+ );
+
+ $items['admin/structure/taxonomy/%taxonomy_vocabulary_machine_name/add'] = array(
+ 'title' => 'Add term',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('taxonomy_form_term', array(), 3),
+ 'access arguments' => array('administer taxonomy'),
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'taxonomy.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function taxonomy_admin_paths() {
+ $paths = array(
+ 'taxonomy/term/*/edit' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Return edit access for a given term.
+ */
+function taxonomy_term_edit_access($term) {
+ return user_access("edit terms in $term->vid") || user_access('administer taxonomy');
+}
+
+/**
+ * Return the vocabulary name given the vocabulary object.
+ */
+function taxonomy_admin_vocabulary_title_callback($vocabulary) {
+ return check_plain($vocabulary->name);
+}
+
+/**
+ * Saves a vocabulary.
+ *
+ * @param $vocabulary
+ * A vocabulary object with the following properties:
+ * - vid: The ID of the vocabulary.
+ * - name: The human-readable name of the vocabulary.
+ * - machine_name: The machine name of the vocabulary.
+ * - description: (optional) The vocabulary's description.
+ * - hierarchy: The hierarchy level of the vocabulary.
+ * - module: (optional) The module altering the vocabulary.
+ * - weight: (optional) The weight of this vocabulary in relation to other
+ * vocabularies.
+ * - original: (optional) The original vocabulary object before any changes
+ * are applied.
+ * - old_machine_name: (optional) The original machine name of the
+ * vocabulary.
+ *
+ * @return
+ * Status constant indicating whether the vocabulary was inserted (SAVED_NEW)
+ * or updated(SAVED_UPDATED).
+ */
+function taxonomy_vocabulary_save($vocabulary) {
+ // Prevent leading and trailing spaces in vocabulary names.
+ if (!empty($vocabulary->name)) {
+ $vocabulary->name = trim($vocabulary->name);
+ }
+ // Load the stored entity, if any.
+ if (!empty($vocabulary->vid)) {
+ if (!isset($vocabulary->original)) {
+ $vocabulary->original = entity_load_unchanged('taxonomy_vocabulary', $vocabulary->vid);
+ }
+ // Make sure machine name changes are easily detected.
+ // @todo: Remove in Drupal 8, as it is deprecated by directly reading from
+ // $vocabulary->original.
+ $vocabulary->old_machine_name = $vocabulary->original->machine_name;
+ }
+
+ module_invoke_all('taxonomy_vocabulary_presave', $vocabulary);
+ module_invoke_all('entity_presave', $vocabulary, 'taxonomy_vocabulary');
+
+ if (!empty($vocabulary->vid) && !empty($vocabulary->name)) {
+ $status = drupal_write_record('taxonomy_vocabulary', $vocabulary, 'vid');
+ if ($vocabulary->old_machine_name != $vocabulary->machine_name) {
+ field_attach_rename_bundle('taxonomy_term', $vocabulary->old_machine_name, $vocabulary->machine_name);
+ }
+ module_invoke_all('taxonomy_vocabulary_update', $vocabulary);
+ module_invoke_all('entity_update', $vocabulary, 'taxonomy_vocabulary');
+ }
+ elseif (empty($vocabulary->vid)) {
+ $status = drupal_write_record('taxonomy_vocabulary', $vocabulary);
+ field_attach_create_bundle('taxonomy_term', $vocabulary->machine_name);
+ module_invoke_all('taxonomy_vocabulary_insert', $vocabulary);
+ module_invoke_all('entity_insert', $vocabulary, 'taxonomy_vocabulary');
+ }
+
+ unset($vocabulary->original);
+ cache_clear_all();
+ taxonomy_vocabulary_static_reset(array($vocabulary->vid));
+
+ return $status;
+}
+
+/**
+ * Delete a vocabulary.
+ *
+ * @param $vid
+ * A vocabulary ID.
+ * @return
+ * Constant indicating items were deleted.
+ */
+function taxonomy_vocabulary_delete($vid) {
+ $vocabulary = taxonomy_vocabulary_load($vid);
+
+ $transaction = db_transaction();
+ try {
+ // Only load terms without a parent, child terms will get deleted too.
+ $result = db_query('SELECT t.tid FROM {taxonomy_term_data} t INNER JOIN {taxonomy_term_hierarchy} th ON th.tid = t.tid WHERE t.vid = :vid AND th.parent = 0', array(':vid' => $vid))->fetchCol();
+ foreach ($result as $tid) {
+ taxonomy_term_delete($tid);
+ }
+ db_delete('taxonomy_vocabulary')
+ ->condition('vid', $vid)
+ ->execute();
+
+ field_attach_delete_bundle('taxonomy_term', $vocabulary->machine_name);
+ module_invoke_all('taxonomy_vocabulary_delete', $vocabulary);
+ module_invoke_all('entity_delete', $vocabulary, 'taxonomy_vocabulary');
+
+ cache_clear_all();
+ taxonomy_vocabulary_static_reset();
+
+ return SAVED_DELETED;
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('taxonomy', $e);
+ throw $e;
+ }
+}
+
+/**
+ * Implements hook_taxonomy_vocabulary_update().
+ */
+function taxonomy_taxonomy_vocabulary_update($vocabulary) {
+ // Reflect machine name changes in the definitions of existing 'taxonomy'
+ // fields.
+ if (!empty($vocabulary->old_machine_name) && $vocabulary->old_machine_name != $vocabulary->machine_name) {
+ $fields = field_read_fields();
+ foreach ($fields as $field_name => $field) {
+ $update = FALSE;
+ if ($field['type'] == 'taxonomy_term_reference') {
+ foreach ($field['settings']['allowed_values'] as $key => &$value) {
+ if ($value['vocabulary'] == $vocabulary->old_machine_name) {
+ $value['vocabulary'] = $vocabulary->machine_name;
+ $update = TRUE;
+ }
+ }
+ if ($update) {
+ field_update_field($field);
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Checks and updates the hierarchy flag of a vocabulary.
+ *
+ * Checks the current parents of all terms in a vocabulary and updates the
+ * vocabulary's hierarchy setting to the lowest possible level. If no term
+ * has parent terms then the vocabulary will be given a hierarchy of 0.
+ * If any term has a single parent then the vocabulary will be given a
+ * hierarchy of 1. If any term has multiple parents then the vocabulary
+ * will be given a hierarchy of 2.
+ *
+ * @param $vocabulary
+ * A vocabulary object.
+ * @param $changed_term
+ * An array of the term structure that was updated.
+ *
+ * @return
+ * An integer that represents the level of the vocabulary's hierarchy.
+ */
+function taxonomy_check_vocabulary_hierarchy($vocabulary, $changed_term) {
+ $tree = taxonomy_get_tree($vocabulary->vid);
+ $hierarchy = 0;
+ foreach ($tree as $term) {
+ // Update the changed term with the new parent value before comparison.
+ if ($term->tid == $changed_term['tid']) {
+ $term = (object) $changed_term;
+ $term->parents = $term->parent;
+ }
+ // Check this term's parent count.
+ if (count($term->parents) > 1) {
+ $hierarchy = 2;
+ break;
+ }
+ elseif (count($term->parents) == 1 && 0 !== array_shift($term->parents)) {
+ $hierarchy = 1;
+ }
+ }
+ if ($hierarchy != $vocabulary->hierarchy) {
+ $vocabulary->hierarchy = $hierarchy;
+ taxonomy_vocabulary_save($vocabulary);
+ }
+
+ return $hierarchy;
+}
+
+/**
+ * Saves a term object to the database.
+ *
+ * @param $term
+ * The taxonomy term object with the following properties:
+ * - vid: The ID of the vocabulary the term is assigned to.
+ * - name: The name of the term.
+ * - tid: (optional) The unique ID for the term being saved. If $term->tid is
+ * empty or omitted, a new term will be inserted.
+ * - description: (optional) The term's description.
+ * - format: (optional) The text format for the term's description.
+ * - weight: (optional) The weight of this term in relation to other terms
+ * within the same vocabulary.
+ * - parent: (optional) The parent term(s) for this term. This can be a single
+ * term ID or an array of term IDs. A value of 0 means this term does not
+ * have any parents. When omitting this variable during an update, the
+ * existing hierarchy for the term remains unchanged.
+ * - vocabulary_machine_name: (optional) The machine name of the vocabulary
+ * the term is assigned to. If not given, this value will be set
+ * automatically by loading the vocabulary based on $term->vid.
+ * - original: (optional) The original taxonomy term object before any changes
+ * were applied. When omitted, the unchanged taxonomy term object is
+ * loaded from the database and stored in this property.
+ * Since a taxonomy term is an entity, any fields contained in the term object
+ * are saved alongside the term object.
+ *
+ * @return
+ * Status constant indicating whether term was inserted (SAVED_NEW) or updated
+ * (SAVED_UPDATED). When inserting a new term, $term->tid will contain the
+ * term ID of the newly created term.
+ */
+function taxonomy_term_save($term) {
+ // Prevent leading and trailing spaces in term names.
+ $term->name = trim($term->name);
+ if (!isset($term->vocabulary_machine_name)) {
+ $vocabulary = taxonomy_vocabulary_load($term->vid);
+ $term->vocabulary_machine_name = $vocabulary->machine_name;
+ }
+
+ // Load the stored entity, if any.
+ if (!empty($term->tid) && !isset($term->original)) {
+ $term->original = entity_load_unchanged('taxonomy_term', $term->tid);
+ }
+
+ field_attach_presave('taxonomy_term', $term);
+ module_invoke_all('taxonomy_term_presave', $term);
+ module_invoke_all('entity_presave', $term, 'taxonomy_term');
+
+ if (empty($term->tid)) {
+ $op = 'insert';
+ $status = drupal_write_record('taxonomy_term_data', $term);
+ field_attach_insert('taxonomy_term', $term);
+ if (!isset($term->parent)) {
+ $term->parent = array(0);
+ }
+ }
+ else {
+ $op = 'update';
+ $status = drupal_write_record('taxonomy_term_data', $term, 'tid');
+ field_attach_update('taxonomy_term', $term);
+ if (isset($term->parent)) {
+ db_delete('taxonomy_term_hierarchy')
+ ->condition('tid', $term->tid)
+ ->execute();
+ }
+ }
+
+ if (isset($term->parent)) {
+ if (!is_array($term->parent)) {
+ $term->parent = array($term->parent);
+ }
+ $query = db_insert('taxonomy_term_hierarchy')
+ ->fields(array('tid', 'parent'));
+ foreach ($term->parent as $parent) {
+ if (is_array($parent)) {
+ foreach ($parent as $tid) {
+ $query->values(array(
+ 'tid' => $term->tid,
+ 'parent' => $tid
+ ));
+ }
+ }
+ else {
+ $query->values(array(
+ 'tid' => $term->tid,
+ 'parent' => $parent
+ ));
+ }
+ }
+ $query->execute();
+ }
+
+ // Reset the taxonomy term static variables.
+ taxonomy_terms_static_reset();
+
+ // Invoke the taxonomy hooks.
+ module_invoke_all("taxonomy_term_$op", $term);
+ module_invoke_all("entity_$op", $term, 'taxonomy_term');
+ unset($term->original);
+
+ return $status;
+}
+
+/**
+ * Delete a term.
+ *
+ * @param $tid
+ * The term ID.
+ * @return
+ * Status constant indicating deletion.
+ */
+function taxonomy_term_delete($tid) {
+ $transaction = db_transaction();
+ try {
+ $tids = array($tid);
+ while ($tids) {
+ $children_tids = $orphans = array();
+ foreach ($tids as $tid) {
+ // See if any of the term's children are about to be become orphans:
+ if ($children = taxonomy_get_children($tid)) {
+ foreach ($children as $child) {
+ // If the term has multiple parents, we don't delete it.
+ $parents = taxonomy_get_parents($child->tid);
+ if (count($parents) == 1) {
+ $orphans[] = $child->tid;
+ }
+ }
+ }
+
+ if ($term = taxonomy_term_load($tid)) {
+ db_delete('taxonomy_term_data')
+ ->condition('tid', $tid)
+ ->execute();
+ db_delete('taxonomy_term_hierarchy')
+ ->condition('tid', $tid)
+ ->execute();
+
+ field_attach_delete('taxonomy_term', $term);
+ module_invoke_all('taxonomy_term_delete', $term);
+ module_invoke_all('entity_delete', $term, 'taxonomy_term');
+ taxonomy_terms_static_reset();
+ }
+ }
+
+ $tids = $orphans;
+ }
+ return SAVED_DELETED;
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('taxonomy', $e);
+ throw $e;
+ }
+}
+
+/**
+ * Generate an array for rendering the given term.
+ *
+ * @param $term
+ * A term object.
+ * @param $view_mode
+ * View mode, e.g. 'full', 'teaser'...
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return
+ * An array as expected by drupal_render().
+ */
+function taxonomy_term_view($term, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ field_attach_prepare_view('taxonomy_term', array($term->tid => $term), $view_mode, $langcode);
+ entity_prepare_view('taxonomy_term', array($term->tid => $term), $langcode);
+
+ $build = array(
+ '#theme' => 'taxonomy_term',
+ '#term' => $term,
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ );
+
+ $build += field_attach_view('taxonomy_term', $term, $view_mode, $langcode);
+
+ // Add term description if the term has one.
+ if (!empty($term->description)) {
+ $build['description'] = array(
+ '#markup' => check_markup($term->description, $term->format, '', TRUE),
+ '#weight' => 0,
+ '#prefix' => '<div class="taxonomy-term-description">',
+ '#suffix' => '</div>',
+ );
+ }
+
+ $build['#attached']['css'][] = drupal_get_path('module', 'taxonomy') . '/taxonomy.css';
+
+ // Allow modules to modify the structured term.
+ $type = 'taxonomy_term';
+ drupal_alter(array('taxonomy_term_view', 'entity_view'), $build, $type);
+
+ return $build;
+}
+
+/**
+ * Process variables for taxonomy-term.tpl.php.
+ */
+function template_preprocess_taxonomy_term(&$variables) {
+ $variables['view_mode'] = $variables['elements']['#view_mode'];
+ $variables['term'] = $variables['elements']['#term'];
+ $term = $variables['term'];
+
+ $uri = entity_uri('taxonomy_term', $term);
+ $variables['term_url'] = url($uri['path'], $uri['options']);
+ $variables['term_name'] = check_plain($term->name);
+ $variables['page'] = $variables['view_mode'] == 'full' && taxonomy_term_is_page($term);
+
+ // Flatten the term object's member fields.
+ $variables = array_merge((array) $term, $variables);
+
+ // Helpful $content variable for templates.
+ $variables['content'] = array();
+ foreach (element_children($variables['elements']) as $key) {
+ $variables['content'][$key] = $variables['elements'][$key];
+ }
+
+ // field_attach_preprocess() overwrites the $[field_name] variables with the
+ // values of the field in the language that was selected for display, instead
+ // of the raw values in $term->[field_name], which contain all values in all
+ // languages.
+ field_attach_preprocess('taxonomy_term', $term, $variables['content'], $variables);
+
+ // Gather classes, and clean up name so there are no underscores.
+ $vocabulary_name_css = str_replace('_', '-', $term->vocabulary_machine_name);
+ $variables['classes_array'][] = 'vocabulary-' . $vocabulary_name_css;
+
+ $variables['theme_hook_suggestions'][] = 'taxonomy_term__' . $term->vocabulary_machine_name;
+ $variables['theme_hook_suggestions'][] = 'taxonomy_term__' . $term->tid;
+}
+
+/**
+ * Returns whether the current page is the page of the passed-in term.
+ *
+ * @param $term
+ * A term object.
+ */
+function taxonomy_term_is_page($term) {
+ $page_term = menu_get_object('taxonomy_term', 2);
+ return (!empty($page_term) ? $page_term->tid == $term->tid : FALSE);
+}
+
+/**
+ * Clear all static cache variables for terms.
+ */
+function taxonomy_terms_static_reset() {
+ drupal_static_reset('taxonomy_term_count_nodes');
+ drupal_static_reset('taxonomy_get_tree');
+ drupal_static_reset('taxonomy_get_tree:parents');
+ drupal_static_reset('taxonomy_get_tree:terms');
+ drupal_static_reset('taxonomy_get_parents');
+ drupal_static_reset('taxonomy_get_parents_all');
+ drupal_static_reset('taxonomy_get_children');
+ entity_get_controller('taxonomy_term')->resetCache();
+}
+
+/**
+ * Clear all static cache variables for vocabularies.
+ *
+ * @param $ids
+ * An array of ids to reset in entity controller cache.
+ */
+function taxonomy_vocabulary_static_reset($ids = NULL) {
+ drupal_static_reset('taxonomy_vocabulary_get_names');
+ entity_get_controller('taxonomy_vocabulary')->resetCache($ids);
+}
+
+/**
+ * Return an array of all vocabulary objects.
+ *
+ * @return
+ * An array of all vocabulary objects, indexed by vid.
+ */
+function taxonomy_get_vocabularies() {
+ return taxonomy_vocabulary_load_multiple(FALSE, array());
+}
+
+/**
+ * Get names for all taxonomy vocabularies.
+ *
+ * @return
+ * An array of vocabulary ids, names, machine names, keyed by machine name.
+ */
+function taxonomy_vocabulary_get_names() {
+ $names = &drupal_static(__FUNCTION__);
+
+ if (!isset($names)) {
+ $names = db_query('SELECT name, machine_name, vid FROM {taxonomy_vocabulary}')->fetchAllAssoc('machine_name');
+ }
+
+ return $names;
+}
+
+/**
+ * Finds all parents of a given term ID.
+ *
+ * @param $tid
+ * A taxonomy term ID.
+ *
+ * @return
+ * An array of term objects which are the parents of the term $tid.
+ */
+function taxonomy_get_parents($tid) {
+ $parents = &drupal_static(__FUNCTION__, array());
+
+ if ($tid && !isset($parents[$tid])) {
+ $query = db_select('taxonomy_term_data', 't');
+ $query->join('taxonomy_term_hierarchy', 'h', 'h.parent = t.tid');
+ $query->addField('t', 'tid');
+ $query->condition('h.tid', $tid);
+ $query->addTag('term_access');
+ $query->orderBy('t.weight');
+ $query->orderBy('t.name');
+ $tids = $query->execute()->fetchCol();
+ $parents[$tid] = taxonomy_term_load_multiple($tids);
+ }
+
+ return isset($parents[$tid]) ? $parents[$tid] : array();
+}
+
+/**
+ * Find all ancestors of a given term ID.
+ */
+function taxonomy_get_parents_all($tid) {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ if (isset($cache[$tid])) {
+ return $cache[$tid];
+ }
+
+ $parents = array();
+ if ($term = taxonomy_term_load($tid)) {
+ $parents[] = $term;
+ $n = 0;
+ while ($parent = taxonomy_get_parents($parents[$n]->tid)) {
+ $parents = array_merge($parents, $parent);
+ $n++;
+ }
+ }
+
+ $cache[$tid] = $parents;
+
+ return $parents;
+}
+
+/**
+ * Finds all children of a term ID.
+ *
+ * @param $tid
+ * A taxonomy term ID.
+ * @param $vid
+ * An optional vocabulary ID to restrict the child search.
+ *
+ * @return
+ * An array of term objects that are the children of the term $tid, or an
+ * empty array when no children exist.
+ */
+function taxonomy_get_children($tid, $vid = 0) {
+ $children = &drupal_static(__FUNCTION__, array());
+
+ if ($tid && !isset($children[$tid])) {
+ $query = db_select('taxonomy_term_data', 't');
+ $query->join('taxonomy_term_hierarchy', 'h', 'h.tid = t.tid');
+ $query->addField('t', 'tid');
+ $query->condition('h.parent', $tid);
+ if ($vid) {
+ $query->condition('t.vid', $vid);
+ }
+ $query->addTag('term_access');
+ $query->orderBy('t.weight');
+ $query->orderBy('t.name');
+ $tids = $query->execute()->fetchCol();
+ $children[$tid] = taxonomy_term_load_multiple($tids);
+ }
+
+ return isset($children[$tid]) ? $children[$tid] : array();
+}
+
+/**
+ * Create a hierarchical representation of a vocabulary.
+ *
+ * @param $vid
+ * Which vocabulary to generate the tree for.
+ * @param $parent
+ * The term ID under which to generate the tree. If 0, generate the tree
+ * for the entire vocabulary.
+ * @param $max_depth
+ * The number of levels of the tree to return. Leave NULL to return all levels.
+ * @param $load_entities
+ * If TRUE, a full entity load will occur on the term objects. Otherwise they
+ * are partial objects queried directly from the {taxonomy_term_data} table to
+ * save execution time and memory consumption when listing large numbers of
+ * terms. Defaults to FALSE.
+ *
+ * @return
+ * An array of all term objects in the tree. Each term object is extended
+ * to have "depth" and "parents" attributes in addition to its normal ones.
+ * Results are statically cached. Term objects will be partial or complete
+ * depending on the $load_entities parameter.
+ */
+function taxonomy_get_tree($vid, $parent = 0, $max_depth = NULL, $load_entities = FALSE) {
+ $children = &drupal_static(__FUNCTION__, array());
+ $parents = &drupal_static(__FUNCTION__ . ':parents', array());
+ $terms = &drupal_static(__FUNCTION__ . ':terms', array());
+
+ // We cache trees, so it's not CPU-intensive to call get_tree() on a term
+ // and its children, too.
+ if (!isset($children[$vid])) {
+ $children[$vid] = array();
+ $parents[$vid] = array();
+ $terms[$vid] = array();
+
+ $query = db_select('taxonomy_term_data', 't');
+ $query->join('taxonomy_term_hierarchy', 'h', 'h.tid = t.tid');
+ $result = $query
+ ->addTag('translatable')
+ ->addTag('term_access')
+ ->fields('t')
+ ->fields('h', array('parent'))
+ ->condition('t.vid', $vid)
+ ->orderBy('t.weight')
+ ->orderBy('t.name')
+ ->execute();
+
+ foreach ($result as $term) {
+ $children[$vid][$term->parent][] = $term->tid;
+ $parents[$vid][$term->tid][] = $term->parent;
+ $terms[$vid][$term->tid] = $term;
+ }
+ }
+
+ // Load full entities, if necessary. The entity controller statically
+ // caches the results.
+ if ($load_entities) {
+ $term_entities = taxonomy_term_load_multiple(array_keys($terms[$vid]));
+ }
+
+ $max_depth = (!isset($max_depth)) ? count($children[$vid]) : $max_depth;
+ $tree = array();
+
+ // Keeps track of the parents we have to process, the last entry is used
+ // for the next processing step.
+ $process_parents = array();
+ $process_parents[] = $parent;
+
+ // Loops over the parent terms and adds its children to the tree array.
+ // Uses a loop instead of a recursion, because it's more efficient.
+ while (count($process_parents)) {
+ $parent = array_pop($process_parents);
+ // The number of parents determines the current depth.
+ $depth = count($process_parents);
+ if ($max_depth > $depth && !empty($children[$vid][$parent])) {
+ $has_children = FALSE;
+ $child = current($children[$vid][$parent]);
+ do {
+ if (empty($child)) {
+ break;
+ }
+ $term = $load_entities ? $term_entities[$child] : $terms[$vid][$child];
+ if (count($parents[$vid][$term->tid]) > 1) {
+ // We have a term with multi parents here. Clone the term,
+ // so that the depth attribute remains correct.
+ $term = clone $term;
+ }
+ $term->depth = $depth;
+ unset($term->parent);
+ $term->parents = $parents[$vid][$term->tid];
+ $tree[] = $term;
+ if (!empty($children[$vid][$term->tid])) {
+ $has_children = TRUE;
+
+ // We have to continue with this parent later.
+ $process_parents[] = $parent;
+ // Use the current term as parent for the next iteration.
+ $process_parents[] = $term->tid;
+
+ // Reset pointers for child lists because we step in there more often
+ // with multi parents.
+ reset($children[$vid][$term->tid]);
+ // Move pointer so that we get the correct term the next time.
+ next($children[$vid][$parent]);
+ break;
+ }
+ } while ($child = next($children[$vid][$parent]));
+
+ if (!$has_children) {
+ // We processed all terms in this hierarchy-level, reset pointer
+ // so that this function works the next time it gets called.
+ reset($children[$vid][$parent]);
+ }
+ }
+ }
+
+ return $tree;
+}
+
+/**
+ * Try to map a string to an existing term, as for glossary use.
+ *
+ * Provides a case-insensitive and trimmed mapping, to maximize the
+ * likelihood of a successful match.
+ *
+ * @param $name
+ * Name of the term to search for.
+ *
+ * @return
+ * An array of matching term objects.
+ */
+function taxonomy_get_term_by_name($name) {
+ return taxonomy_term_load_multiple(array(), array('name' => trim($name)));
+}
+
+/**
+ * Controller class for taxonomy terms.
+ *
+ * This extends the DrupalDefaultEntityController class. Only alteration is
+ * that we match the condition on term name case-independently.
+ */
+class TaxonomyTermController extends DrupalDefaultEntityController {
+
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ $query = parent::buildQuery($ids, $conditions, $revision_id);
+ $query->addTag('translatable');
+ $query->addTag('term_access');
+ // When name is passed as a condition use LIKE.
+ if (isset($conditions['name'])) {
+ $query_conditions = &$query->conditions();
+ foreach ($query_conditions as $key => $condition) {
+ if ($condition['field'] == 'base.name') {
+ $query_conditions[$key]['operator'] = 'LIKE';
+ $query_conditions[$key]['value'] = db_like($query_conditions[$key]['value']);
+ }
+ }
+ }
+ // Add the machine name field from the {taxonomy_vocabulary} table.
+ $query->innerJoin('taxonomy_vocabulary', 'v', 'base.vid = v.vid');
+ $query->addField('v', 'machine_name', 'vocabulary_machine_name');
+ return $query;
+ }
+
+ protected function cacheGet($ids, $conditions = array()) {
+ $terms = parent::cacheGet($ids, $conditions);
+ // Name matching is case insensitive, note that with some collations
+ // LOWER() and drupal_strtolower() may return different results.
+ foreach ($terms as $term) {
+ $term_values = (array) $term;
+ if (isset($conditions['name']) && drupal_strtolower($conditions['name'] != drupal_strtolower($term_values['name']))) {
+ unset($terms[$term->tid]);
+ }
+ }
+ return $terms;
+ }
+}
+
+/**
+ * Controller class for taxonomy vocabularies.
+ *
+ * This extends the DrupalDefaultEntityController class, adding required
+ * special handling for taxonomy vocabulary objects.
+ */
+class TaxonomyVocabularyController extends DrupalDefaultEntityController {
+
+ protected function buildQuery($ids, $conditions = array(), $revision_id = FALSE) {
+ $query = parent::buildQuery($ids, $conditions, $revision_id);
+ $query->addTag('translatable');
+ $query->orderBy('base.weight');
+ $query->orderBy('base.name');
+ return $query;
+ }
+}
+
+/**
+ * Load multiple taxonomy terms based on certain conditions.
+ *
+ * This function should be used whenever you need to load more than one term
+ * from the database. Terms are loaded into memory and will not require
+ * database access if loaded again during the same page request.
+ *
+ * @see entity_load()
+ * @see EntityFieldQuery
+ *
+ * @param $tids
+ * An array of taxonomy term IDs.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the {taxonomy_term}
+ * table, where the keys are the database fields and the values are the
+ * values those fields must have. Instead, it is preferable to use
+ * EntityFieldQuery to retrieve a list of entity IDs loadable by
+ * this function.
+ *
+ * @return
+ * An array of term objects, indexed by tid. When no results are found, an
+ * empty array is returned.
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function taxonomy_term_load_multiple($tids = array(), $conditions = array()) {
+ return entity_load('taxonomy_term', $tids, $conditions);
+}
+
+/**
+ * Load multiple taxonomy vocabularies based on certain conditions.
+ *
+ * This function should be used whenever you need to load more than one
+ * vocabulary from the database. Terms are loaded into memory and will not
+ * require database access if loaded again during the same page request.
+ *
+ * @see entity_load()
+ *
+ * @param $vids
+ * An array of taxonomy vocabulary IDs, or FALSE to load all vocabularies.
+ * @param $conditions
+ * An array of conditions to add to the query.
+ *
+ * @return
+ * An array of vocabulary objects, indexed by vid.
+ */
+function taxonomy_vocabulary_load_multiple($vids = array(), $conditions = array()) {
+ return entity_load('taxonomy_vocabulary', $vids, $conditions);
+}
+
+/**
+ * Return the vocabulary object matching a vocabulary ID.
+ *
+ * @param $vid
+ * The vocabulary's ID.
+ *
+ * @return
+ * The vocabulary object with all of its metadata, if exists, FALSE otherwise.
+ * Results are statically cached.
+ */
+function taxonomy_vocabulary_load($vid) {
+ $vocabularies = taxonomy_vocabulary_load_multiple(array($vid));
+ return reset($vocabularies);
+}
+
+/**
+ * Return the vocabulary object matching a vocabulary machine name.
+ *
+ * @param $name
+ * The vocabulary's machine name.
+ *
+ * @return
+ * The vocabulary object with all of its metadata, if exists, FALSE otherwise.
+ * Results are statically cached.
+ */
+function taxonomy_vocabulary_machine_name_load($name) {
+ $vocabularies = taxonomy_vocabulary_load_multiple(NULL, array('machine_name' => $name));
+ return reset($vocabularies);
+}
+
+/**
+ * Return the term object matching a term ID.
+ *
+ * @param $tid
+ * A term's ID
+ *
+ * @return
+ * A term object. Results are statically cached.
+ */
+function taxonomy_term_load($tid) {
+ if (!is_numeric($tid)) {
+ return FALSE;
+ }
+ $term = taxonomy_term_load_multiple(array($tid), array());
+ return $term ? $term[$tid] : FALSE;
+}
+
+/**
+ * Helper function for array_map purposes.
+ */
+function _taxonomy_get_tid_from_term($term) {
+ return $term->tid;
+}
+
+/**
+ * Implodes a list of tags of a certain vocabulary into a string.
+ *
+ * @see drupal_explode_tags()
+ */
+function taxonomy_implode_tags($tags, $vid = NULL) {
+ $typed_tags = array();
+ foreach ($tags as $tag) {
+ // Extract terms belonging to the vocabulary in question.
+ if (!isset($vid) || $tag->vid == $vid) {
+ // Make sure we have a completed loaded taxonomy term.
+ if (isset($tag->name)) {
+ // Commas and quotes in tag names are special cases, so encode 'em.
+ if (strpos($tag->name, ',') !== FALSE || strpos($tag->name, '"') !== FALSE) {
+ $typed_tags[] = '"' . str_replace('"', '""', $tag->name) . '"';
+ }
+ else {
+ $typed_tags[] = $tag->name;
+ }
+ }
+ }
+ }
+ return implode(', ', $typed_tags);
+}
+
+/**
+ * Implements hook_field_info().
+ *
+ * Field settings:
+ * - allowed_values: a list array of one or more vocabulary trees:
+ * - vocabulary: a vocabulary machine name.
+ * - parent: a term ID of a term whose children are allowed. This should be
+ * '0' if all terms in a vocabulary are allowed. The allowed values do not
+ * include the parent term.
+ *
+ */
+function taxonomy_field_info() {
+ return array(
+ 'taxonomy_term_reference' => array(
+ 'label' => t('Term reference'),
+ 'description' => t('This field stores a reference to a taxonomy term.'),
+ 'default_widget' => 'options_select',
+ 'default_formatter' => 'taxonomy_term_reference_link',
+ 'settings' => array(
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => '',
+ 'parent' => '0',
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_info().
+ */
+function taxonomy_field_widget_info() {
+ return array(
+ 'taxonomy_autocomplete' => array(
+ 'label' => t('Autocomplete term widget (tagging)'),
+ 'field types' => array('taxonomy_term_reference'),
+ 'settings' => array(
+ 'size' => 60,
+ 'autocomplete_path' => 'taxonomy/autocomplete',
+ ),
+ 'behaviors' => array(
+ 'multiple values' => FIELD_BEHAVIOR_CUSTOM,
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_widget_info_alter().
+ */
+function taxonomy_field_widget_info_alter(&$info) {
+ $info['options_select']['field types'][] = 'taxonomy_term_reference';
+ $info['options_buttons']['field types'][] = 'taxonomy_term_reference';
+}
+
+/**
+ * Implements hook_options_list().
+ */
+function taxonomy_options_list($field, $instance) {
+ $function = !empty($field['settings']['options_list_callback']) ? $field['settings']['options_list_callback'] : 'taxonomy_allowed_values';
+ return $function($field);
+}
+
+/**
+ * Implements hook_field_validate().
+ *
+ * Taxonomy field settings allow for either a single vocabulary ID, multiple
+ * vocabulary IDs, or sub-trees of a vocabulary to be specified as allowed
+ * values, although only the first of these is supported via the field UI.
+ * Confirm that terms entered as values meet at least one of these conditions.
+ *
+ * Possible error codes:
+ * - 'taxonomy_term_illegal_value': The value is not part of the list of allowed values.
+ */
+function taxonomy_field_validate($entity_type, $entity, $field, $instance, $langcode, $items, &$errors) {
+ // Build an array of existing term IDs so they can be loaded with
+ // taxonomy_term_load_multiple();
+ foreach ($items as $delta => $item) {
+ if (!empty($item['tid']) && $item['tid'] != 'autocreate') {
+ $tids[] = $item['tid'];
+ }
+ }
+ if (!empty($tids)) {
+ $terms = taxonomy_term_load_multiple($tids);
+
+ // Check each existing item to ensure it can be found in the
+ // allowed values for this field.
+ foreach ($items as $delta => $item) {
+ $validate = TRUE;
+ if (!empty($item['tid']) && $item['tid'] != 'autocreate') {
+ $validate = FALSE;
+ foreach ($field['settings']['allowed_values'] as $settings) {
+ // If no parent is specified, check if the term is in the vocabulary.
+ if (isset($settings['vocabulary']) && empty($settings['parent'])) {
+ if ($settings['vocabulary'] == $terms[$item['tid']]->vocabulary_machine_name) {
+ $validate = TRUE;
+ break;
+ }
+ }
+ // If a parent is specified, then to validate it must appear in the
+ // array returned by taxonomy_get_parents_all().
+ elseif (!empty($settings['parent'])) {
+ $ancestors = taxonomy_get_parents_all($item['tid']);
+ foreach ($ancestors as $ancestor) {
+ if ($ancestor->tid == $settings['parent']) {
+ $validate = TRUE;
+ break 2;
+ }
+ }
+ }
+ }
+ }
+ if (!$validate) {
+ $errors[$field['field_name']][$langcode][$delta][] = array(
+ 'error' => 'taxonomy_term_reference_illegal_value',
+ 'message' => t('%name: illegal value.', array('%name' => $instance['label'])),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_field_is_empty().
+ */
+function taxonomy_field_is_empty($item, $field) {
+ if (!is_array($item) || (empty($item['tid']) && (string) $item['tid'] !== '0')) {
+ return TRUE;
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_field_formatter_info().
+ */
+function taxonomy_field_formatter_info() {
+ return array(
+ 'taxonomy_term_reference_link' => array(
+ 'label' => t('Link'),
+ 'field types' => array('taxonomy_term_reference'),
+ ),
+ 'taxonomy_term_reference_plain' => array(
+ 'label' => t('Plain text'),
+ 'field types' => array('taxonomy_term_reference'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_field_formatter_view().
+ */
+function taxonomy_field_formatter_view($entity_type, $entity, $field, $instance, $langcode, $items, $display) {
+ $element = array();
+
+ // Terms whose tid is 'autocreate' do not exist
+ // yet and $item['taxonomy_term'] is not set. Theme such terms as
+ // just their name.
+
+ switch ($display['type']) {
+ case 'taxonomy_term_reference_link':
+ foreach ($items as $delta => $item) {
+ if ($item['tid'] == 'autocreate') {
+ $element[$delta] = array(
+ '#markup' => check_plain($item['name']),
+ );
+ }
+ else {
+ $term = $item['taxonomy_term'];
+ $uri = entity_uri('taxonomy_term', $term);
+ $element[$delta] = array(
+ '#type' => 'link',
+ '#title' => $term->name,
+ '#href' => $uri['path'],
+ '#options' => $uri['options'],
+ );
+ }
+ }
+ break;
+
+ case 'taxonomy_term_reference_plain':
+ foreach ($items as $delta => $item) {
+ $name = ($item['tid'] != 'autocreate' ? $item['taxonomy_term']->name : $item['name']);
+ $element[$delta] = array(
+ '#markup' => check_plain($name),
+ );
+ }
+ break;
+ }
+
+ return $element;
+}
+
+/**
+ * Returns the set of valid terms for a taxonomy field.
+ *
+ * @param $field
+ * The field definition.
+ * @return
+ * The array of valid terms for this field, keyed by term id.
+ */
+function taxonomy_allowed_values($field) {
+ $options = array();
+ foreach ($field['settings']['allowed_values'] as $tree) {
+ if ($vocabulary = taxonomy_vocabulary_machine_name_load($tree['vocabulary'])) {
+ if ($terms = taxonomy_get_tree($vocabulary->vid, $tree['parent'])) {
+ foreach ($terms as $term) {
+ $options[$term->tid] = str_repeat('-', $term->depth) . $term->name;
+ }
+ }
+ }
+ }
+ return $options;
+}
+
+/**
+ * Implements hook_field_formatter_prepare_view().
+ *
+ * This preloads all taxonomy terms for multiple loaded objects at once and
+ * unsets values for invalid terms that do not exist.
+ */
+function taxonomy_field_formatter_prepare_view($entity_type, $entities, $field, $instances, $langcode, &$items, $displays) {
+ $tids = array();
+
+ // Collect every possible term attached to any of the fieldable entities.
+ foreach ($entities as $id => $entity) {
+ foreach ($items[$id] as $delta => $item) {
+ // Force the array key to prevent duplicates.
+ if ($item['tid'] != 'autocreate') {
+ $tids[$item['tid']] = $item['tid'];
+ }
+ }
+ }
+ if ($tids) {
+ $terms = taxonomy_term_load_multiple($tids);
+
+ // Iterate through the fieldable entities again to attach the loaded term data.
+ foreach ($entities as $id => $entity) {
+ $rekey = FALSE;
+
+ foreach ($items[$id] as $delta => $item) {
+ // Check whether the taxonomy term field instance value could be loaded.
+ if (isset($terms[$item['tid']])) {
+ // Replace the instance value with the term data.
+ $items[$id][$delta]['taxonomy_term'] = $terms[$item['tid']];
+ }
+ // Terms to be created are not in $terms, but are still legitimate.
+ else if ($item['tid'] == 'autocreate') {
+ // Leave the item in place.
+ }
+ // Otherwise, unset the instance value, since the term does not exist.
+ else {
+ unset($items[$id][$delta]);
+ $rekey = TRUE;
+ }
+ }
+
+ if ($rekey) {
+ // Rekey the items array.
+ $items[$id] = array_values($items[$id]);
+ }
+ }
+ }
+}
+
+/**
+ * Title callback for term pages.
+ *
+ * @param $term
+ * A term object.
+ *
+ * @return
+ * The term name to be used as the page title.
+ */
+function taxonomy_term_title($term) {
+ return $term->name;
+}
+
+/**
+ * Implements hook_field_widget_form().
+ */
+function taxonomy_field_widget_form(&$form, &$form_state, $field, $instance, $langcode, $items, $delta, $element) {
+ $tags = array();
+ foreach ($items as $item) {
+ $tags[$item['tid']] = isset($item['taxonomy_term']) ? $item['taxonomy_term'] : taxonomy_term_load($item['tid']);
+ }
+
+ $element += array(
+ '#type' => 'textfield',
+ '#default_value' => taxonomy_implode_tags($tags),
+ '#autocomplete_path' => $instance['widget']['settings']['autocomplete_path'] . '/' . $field['field_name'],
+ '#size' => $instance['widget']['settings']['size'],
+ '#maxlength' => 1024,
+ '#element_validate' => array('taxonomy_autocomplete_validate'),
+ );
+
+ return $element;
+}
+
+/**
+ * Form element validate handler for taxonomy term autocomplete element.
+ */
+function taxonomy_autocomplete_validate($element, &$form_state) {
+ // Autocomplete widgets do not send their tids in the form, so we must detect
+ // them here and process them independently.
+ $value = array();
+ if ($tags = $element['#value']) {
+ // Collect candidate vocabularies.
+ $field = field_widget_field($element, $form_state);
+ $vocabularies = array();
+ foreach ($field['settings']['allowed_values'] as $tree) {
+ if ($vocabulary = taxonomy_vocabulary_machine_name_load($tree['vocabulary'])) {
+ $vocabularies[$vocabulary->vid] = $vocabulary;
+ }
+ }
+
+ // Translate term names into actual terms.
+ $typed_terms = drupal_explode_tags($tags);
+ foreach ($typed_terms as $typed_term) {
+ // See if the term exists in the chosen vocabulary and return the tid;
+ // otherwise, create a new 'autocreate' term for insert/update.
+ if ($possibilities = taxonomy_term_load_multiple(array(), array('name' => trim($typed_term), 'vid' => array_keys($vocabularies)))) {
+ $term = array_pop($possibilities);
+ }
+ else {
+ $vocabulary = reset($vocabularies);
+ $term = array(
+ 'tid' => 'autocreate',
+ 'vid' => $vocabulary->vid,
+ 'name' => $typed_term,
+ 'vocabulary_machine_name' => $vocabulary->machine_name,
+ );
+ }
+ $value[] = (array)$term;
+ }
+ }
+
+ form_set_value($element, $value, $form_state);
+}
+
+/**
+ * Implements hook_field_widget_error().
+ */
+function taxonomy_field_widget_error($element, $error, $form, &$form_state) {
+ form_error($element, $error['message']);
+}
+/**
+ * Implements hook_field_settings_form().
+ */
+function taxonomy_field_settings_form($field, $instance, $has_data) {
+ // Get proper values for 'allowed_values_function', which is a core setting.
+ $vocabularies = taxonomy_get_vocabularies();
+ $options = array();
+ foreach ($vocabularies as $vocabulary) {
+ $options[$vocabulary->machine_name] = $vocabulary->name;
+ }
+ $form['allowed_values'] = array(
+ '#tree' => TRUE,
+ );
+
+ foreach ($field['settings']['allowed_values'] as $delta => $tree) {
+ $form['allowed_values'][$delta]['vocabulary'] = array(
+ '#type' => 'select',
+ '#title' => t('Vocabulary'),
+ '#default_value' => $tree['vocabulary'],
+ '#options' => $options,
+ '#required' => TRUE,
+ '#description' => t('The vocabulary which supplies the options for this field.'),
+ '#disabled' => $has_data,
+ );
+ $form['allowed_values'][$delta]['parent'] = array(
+ '#type' => 'value',
+ '#value' => $tree['parent'],
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ *
+ * @return array
+ * The rdf mapping for vocabularies and terms.
+ */
+function taxonomy_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'taxonomy_term',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('skos:Concept'),
+ 'name' => array(
+ 'predicates' => array('rdfs:label', 'skos:prefLabel'),
+ ),
+ 'description' => array(
+ 'predicates' => array('skos:definition'),
+ ),
+ 'vid' => array(
+ 'predicates' => array('skos:inScheme'),
+ 'type' => 'rel',
+ ),
+ 'parent' => array(
+ 'predicates' => array('skos:broader'),
+ 'type' => 'rel',
+ ),
+ ),
+ ),
+ array(
+ 'type' => 'taxonomy_vocabulary',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('skos:ConceptScheme'),
+ 'name' => array(
+ 'predicates' => array('dc:title'),
+ ),
+ 'description' => array(
+ 'predicates' => array('rdfs:comment'),
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * @defgroup taxonomy_index Taxonomy indexing
+ * @{
+ * Functions to maintain taxonomy indexing.
+ *
+ * Taxonomy uses default field storage to store canonical relationships
+ * between terms and fieldable entities. However its most common use case
+ * requires listing all content associated with a term or group of terms
+ * sorted by creation date. To avoid slow queries due to joining across
+ * multiple node and field tables with various conditions and order by criteria,
+ * we maintain a denormalized table with all relationships between terms,
+ * published nodes and common sort criteria such as sticky and created.
+ * This is used as a lookup table by taxonomy_select_nodes(). When using other
+ * field storage engines or alternative methods of denormalizing this data
+ * you should set the variable 'taxonomy_maintain_index_table' to FALSE
+ * to avoid unnecessary writes in SQL.
+ */
+
+/**
+ * Implements hook_field_presave().
+ *
+ * Create any new terms defined in a freetagging vocabulary.
+ */
+function taxonomy_field_presave($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ foreach ($items as $delta => $item) {
+ if ($item['tid'] == 'autocreate') {
+ $term = (object) $item;
+ unset($term->tid);
+ taxonomy_term_save($term);
+ $items[$delta]['tid'] = $term->tid;
+ }
+ }
+}
+
+/**
+ * Implements hook_field_insert().
+ */
+function taxonomy_field_insert($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ // We maintain a denormalized table of term/node relationships, containing
+ // only data for current, published nodes.
+ if (variable_get('taxonomy_maintain_index_table', TRUE) && $field['storage']['type'] == 'field_sql_storage' && $entity_type == 'node' && $entity->status) {
+ $query = db_insert('taxonomy_index')->fields(array('nid', 'tid', 'sticky', 'created', ));
+ foreach ($items as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ ));
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Implements hook_field_update().
+ */
+function taxonomy_field_update($entity_type, $entity, $field, $instance, $langcode, &$items) {
+ if (variable_get('taxonomy_maintain_index_table', TRUE) && $field['storage']['type'] == 'field_sql_storage' && $entity_type == 'node') {
+ $first_call = &drupal_static(__FUNCTION__, array());
+
+ // We don't maintain data for old revisions, so clear all previous values
+ // from the table. Since this hook runs once per field, per object, make
+ // sure we only wipe values once.
+ if (!isset($first_call[$entity->nid])) {
+ $first_call[$entity->nid] = FALSE;
+ db_delete('taxonomy_index')->condition('nid', $entity->nid)->execute();
+ }
+ // Only save data to the table if the node is published.
+ if ($entity->status) {
+ $query = db_insert('taxonomy_index')->fields(array('nid', 'tid', 'sticky', 'created'));
+ foreach ($items as $item) {
+ $query->values(array(
+ 'nid' => $entity->nid,
+ 'tid' => $item['tid'],
+ 'sticky' => $entity->sticky,
+ 'created' => $entity->created,
+ ));
+ }
+ $query->execute();
+ }
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function taxonomy_node_delete($node) {
+ if (variable_get('taxonomy_maintain_index_table', TRUE)) {
+ // Clean up the {taxonomy_index} table when nodes are deleted.
+ db_delete('taxonomy_index')->condition('nid', $node->nid)->execute();
+ }
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function taxonomy_taxonomy_term_delete($term) {
+ if (variable_get('taxonomy_maintain_index_table', TRUE)) {
+ // Clean up the {taxonomy_index} table when terms are deleted.
+ db_delete('taxonomy_index')->condition('tid', $term->tid)->execute();
+ }
+}
+
+/**
+ * @} End of "defgroup taxonomy_index"
+ */
diff --git a/core/modules/taxonomy/taxonomy.pages.inc b/core/modules/taxonomy/taxonomy.pages.inc
new file mode 100644
index 000000000000..2a8d961a3e2c
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.pages.inc
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * @file
+ * Page callbacks for the taxonomy module.
+ */
+
+/**
+ * Menu callback; displays all nodes associated with a term.
+ *
+ * @param $term
+ * The taxonomy term.
+ * @return
+ * The page content.
+ */
+function taxonomy_term_page($term) {
+ // Assign the term name as the page title.
+ drupal_set_title($term->name);
+
+ // Build breadcrumb based on the hierarchy of the term.
+ $current = (object) array(
+ 'tid' => $term->tid,
+ );
+ // @todo This overrides any other possible breadcrumb and is a pure hard-coded
+ // presumption. Make this behavior configurable per vocabulary or term.
+ $breadcrumb = array();
+ while ($parents = taxonomy_get_parents($current->tid)) {
+ $current = array_shift($parents);
+ $breadcrumb[] = l($current->name, 'taxonomy/term/' . $current->tid);
+ }
+ $breadcrumb[] = l(t('Home'), NULL);
+ $breadcrumb = array_reverse($breadcrumb);
+ drupal_set_breadcrumb($breadcrumb);
+ drupal_add_feed('taxonomy/term/' . $term->tid . '/feed', 'RSS - ' . $term->name);
+
+ $build = array();
+
+ $build['term_heading'] = array(
+ '#prefix' => '<div class="term-listing-heading">',
+ '#suffix' => '</div>',
+ 'term' => taxonomy_term_view($term, 'full'),
+ );
+
+ if ($nids = taxonomy_select_nodes($term->tid, TRUE, variable_get('default_nodes_main', 10))) {
+ $nodes = node_load_multiple($nids);
+ $build += node_view_multiple($nodes);
+ $build['pager'] = array(
+ '#theme' => 'pager',
+ '#weight' => 5,
+ );
+ }
+ else {
+ $build['no_content'] = array(
+ '#prefix' => '<p>',
+ '#markup' => t('There is currently no content classified with this term.'),
+ '#suffix' => '</p>',
+ );
+ }
+ return $build;
+}
+
+/**
+ * Generate the content feed for a taxonomy term.
+ *
+ * @param $term
+ * The taxonomy term.
+ */
+function taxonomy_term_feed($term) {
+ $channel['link'] = url('taxonomy/term/' . $term->tid, array('absolute' => TRUE));
+ $channel['title'] = variable_get('site_name', 'Drupal') . ' - ' . $term->name;
+ // Only display the description if we have a single term, to avoid clutter and confusion.
+ // HTML will be removed from feed description.
+ $channel['description'] = check_markup($term->description, $term->format, '', TRUE);
+ $nids = taxonomy_select_nodes($term->tid, FALSE, variable_get('feed_default_items', 10));
+
+ node_feed($nids, $channel);
+}
+
+/**
+ * Helper function for autocompletion
+ */
+function taxonomy_autocomplete($field_name, $tags_typed = '') {
+ $field = field_info_field($field_name);
+
+ // The user enters a comma-separated list of tags. We only autocomplete the last tag.
+ $tags_typed = drupal_explode_tags($tags_typed);
+ $tag_last = drupal_strtolower(array_pop($tags_typed));
+
+ $matches = array();
+ if ($tag_last != '') {
+
+ // Part of the criteria for the query come from the field's own settings.
+ $vids = array();
+ $vocabularies = taxonomy_vocabulary_get_names();
+ foreach ($field['settings']['allowed_values'] as $tree) {
+ $vids[] = $vocabularies[$tree['vocabulary']]->vid;
+ }
+
+ $query = db_select('taxonomy_term_data', 't');
+ $query->addTag('translatable');
+ $query->addTag('term_access');
+
+ // Do not select already entered terms.
+ if (!empty($tags_typed)) {
+ $query->condition('t.name', $tags_typed, 'NOT IN');
+ }
+ // Select rows that match by term name.
+ $tags_return = $query
+ ->fields('t', array('tid', 'name'))
+ ->condition('t.vid', $vids)
+ ->condition('t.name', '%' . db_like($tag_last) . '%', 'LIKE')
+ ->range(0, 10)
+ ->execute()
+ ->fetchAllKeyed();
+
+ $prefix = count($tags_typed) ? drupal_implode_tags($tags_typed) . ', ' : '';
+
+ $term_matches = array();
+ foreach ($tags_return as $tid => $name) {
+ $n = $name;
+ // Term names containing commas or quotes must be wrapped in quotes.
+ if (strpos($name, ',') !== FALSE || strpos($name, '"') !== FALSE) {
+ $n = '"' . str_replace('"', '""', $name) . '"';
+ }
+ $term_matches[$prefix . $n] = check_plain($name);
+ }
+ }
+
+ drupal_json_output($term_matches);
+}
diff --git a/core/modules/taxonomy/taxonomy.test b/core/modules/taxonomy/taxonomy.test
new file mode 100644
index 000000000000..747d8229a379
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.test
@@ -0,0 +1,1286 @@
+<?php
+
+/**
+ * @file
+ * Tests for taxonomy.module.
+ */
+
+/**
+* Class with common helper methods.
+*/
+class TaxonomyWebTestCase extends DrupalWebTestCase {
+
+ /**
+ * Returns a new vocabulary with random properties.
+ */
+ function createVocabulary() {
+ // Create a vocabulary.
+ $vocabulary = new stdClass();
+ $vocabulary->name = $this->randomName();
+ $vocabulary->description = $this->randomName();
+ $vocabulary->machine_name = drupal_strtolower($this->randomName());
+ $vocabulary->help = '';
+ $vocabulary->nodes = array('article' => 'article');
+ $vocabulary->weight = mt_rand(0, 10);
+ taxonomy_vocabulary_save($vocabulary);
+ return $vocabulary;
+ }
+
+ /**
+ * Returns a new term with random properties in vocabulary $vid.
+ */
+ function createTerm($vocabulary) {
+ $term = new stdClass();
+ $term->name = $this->randomName();
+ $term->description = $this->randomName();
+ // Use the first available text format.
+ $term->format = db_query_range('SELECT format FROM {filter_format}', 0, 1)->fetchField();
+ $term->vid = $vocabulary->vid;
+ taxonomy_term_save($term);
+ return $term;
+ }
+}
+
+/**
+* Tests for the taxonomy vocabulary interface.
+*/
+class TaxonomyVocabularyFunctionalTest extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy vocabulary interface',
+ 'description' => 'Test the taxonomy vocabulary interface.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->admin_user = $this->drupalCreateUser(array('administer taxonomy'));
+ $this->drupalLogin($this->admin_user);
+ $this->vocabulary = $this->createVocabulary();
+ }
+
+ /**
+ * Create, edit and delete a vocabulary via the user interface.
+ */
+ function testVocabularyInterface() {
+ // Visit the main taxonomy administration page.
+ $this->drupalGet('admin/structure/taxonomy');
+
+ // Create a new vocabulary.
+ $this->clickLink(t('Add vocabulary'));
+ $edit = array();
+ $machine_name = drupal_strtolower($this->randomName());
+ $edit['name'] = $this->randomName();
+ $edit['description'] = $this->randomName();
+ $edit['machine_name'] = $machine_name;
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('Created new vocabulary %name.', array('%name' => $edit['name'])), t('Vocabulary created successfully'));
+
+ // Edit the vocabulary.
+ $this->drupalGet('admin/structure/taxonomy');
+ $this->assertText($edit['name'], t('Vocabulary found in the vocabulary overview listing.'));
+ $this->clickLink(t('edit vocabulary'));
+ $edit = array();
+ $edit['name'] = $this->randomName();
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->drupalGet('admin/structure/taxonomy');
+ $this->assertText($edit['name'], t('Vocabulary found in the vocabulary overview listing.'));
+
+ // Try to submit a vocabulary with a duplicate machine name.
+ $edit['machine_name'] = $machine_name;
+ $this->drupalPost('admin/structure/taxonomy/add', $edit, t('Save'));
+ $this->assertText(t('The machine-readable name is already in use. It must be unique.'));
+
+ // Try to submit an invalid machine name.
+ $edit['machine_name'] = '!&^%';
+ $this->drupalPost('admin/structure/taxonomy/add', $edit, t('Save'));
+ $this->assertText(t('The machine-readable name must contain only lowercase letters, numbers, and underscores.'));
+ }
+
+ /**
+ * Changing weights on the vocabulary overview with two or more vocabularies.
+ */
+ function testTaxonomyAdminChangingWeights() {
+ // Create some vocabularies.
+ for ($i = 0; $i < 10; $i++) {
+ $this->createVocabulary();
+ }
+ // Get all vocabularies and change their weights.
+ $vocabularies = taxonomy_get_vocabularies();
+ $edit = array();
+ foreach ($vocabularies as $key => $vocabulary) {
+ $vocabulary->weight = -$vocabulary->weight;
+ $vocabularies[$key]->weight = $vocabulary->weight;
+ $edit[$key . '[weight]'] = $vocabulary->weight;
+ }
+ // Saving the new weights via the interface.
+ $this->drupalPost('admin/structure/taxonomy', $edit, t('Save'));
+
+ // Load the vocabularies from the database.
+ $new_vocabularies = taxonomy_get_vocabularies();
+
+ // Check that the weights are saved in the database correctly.
+ foreach ($vocabularies as $key => $vocabulary) {
+ $this->assertEqual($new_vocabularies[$key]->weight, $vocabularies[$key]->weight, t('The vocabulary weight was changed.'));
+ }
+ }
+
+ /**
+ * Test the vocabulary overview with no vocabularies.
+ */
+ function testTaxonomyAdminNoVocabularies() {
+ // Delete all vocabularies.
+ $vocabularies = taxonomy_get_vocabularies();
+ foreach ($vocabularies as $key => $vocabulary) {
+ taxonomy_vocabulary_delete($key);
+ }
+ // Confirm that no vocabularies are found in the database.
+ $this->assertFalse(taxonomy_get_vocabularies(), t('No vocabularies found in the database'));
+ $this->drupalGet('admin/structure/taxonomy');
+ // Check the default message for no vocabularies.
+ $this->assertText(t('No vocabularies available.'), t('No vocabularies were found.'));
+ }
+
+ /**
+ * Deleting a vocabulary.
+ */
+ function testTaxonomyAdminDeletingVocabulary() {
+ // Create a vocabulary.
+ $edit = array(
+ 'name' => $this->randomName(),
+ 'machine_name' => drupal_strtolower($this->randomName()),
+ );
+ $this->drupalPost('admin/structure/taxonomy/add', $edit, t('Save'));
+ $this->assertText(t('Created new vocabulary'), t('New vocabulary was created.'));
+
+ // Check the created vocabulary.
+ $vocabularies = taxonomy_get_vocabularies();
+ $vid = $vocabularies[count($vocabularies)-1]->vid;
+ entity_get_controller('taxonomy_vocabulary')->resetCache();
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ $this->assertTrue($vocabulary, t('Vocabulary found in database'));
+
+ // Delete the vocabulary.
+ $edit = array();
+ $this->drupalPost('admin/structure/taxonomy/' . $vocabulary->machine_name . '/edit', $edit, t('Delete'));
+ $this->assertRaw(t('Are you sure you want to delete the vocabulary %name?', array('%name' => $vocabulary->name)), t('[confirm deletion] Asks for confirmation.'));
+ $this->assertText(t('Deleting a vocabulary will delete all the terms in it. This action cannot be undone.'), t('[confirm deletion] Inform that all terms will be deleted.'));
+
+ // Confirm deletion.
+ $this->drupalPost(NULL, NULL, t('Delete'));
+ $this->assertRaw(t('Deleted vocabulary %name.', array('%name' => $vocabulary->name)), t('Vocabulary deleted'));
+ entity_get_controller('taxonomy_vocabulary')->resetCache();
+ $this->assertFalse(taxonomy_vocabulary_load($vid), t('Vocabulary is not found in the database'));
+ }
+}
+
+
+/**
+ * Tests for taxonomy vocabulary functions.
+ */
+class TaxonomyVocabularyUnitTest extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy vocabularies',
+ 'description' => 'Test loading, saving and deleting vocabularies.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('taxonomy', 'field_test');
+ $admin_user = $this->drupalCreateUser(array('create article content', 'administer taxonomy'));
+ $this->drupalLogin($admin_user);
+ $this->vocabulary = $this->createVocabulary();
+ }
+
+ /**
+ * Ensure that when an invalid vocabulary vid is loaded, it is possible
+ * to load the same vid successfully if it subsequently becomes valid.
+ */
+ function testTaxonomyVocabularyLoadReturnFalse() {
+ // Load a vocabulary that doesn't exist.
+ $vocabularies = taxonomy_get_vocabularies();
+ $vid = count($vocabularies) + 1;
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ // This should not return an object because no such vocabulary exists.
+ $this->assertTrue(empty($vocabulary), t('No object loaded.'));
+
+ // Create a new vocabulary.
+ $this->createVocabulary();
+ // Load the vocabulary with the same $vid from earlier.
+ // This should return a vocabulary object since it now matches a real vid.
+ $vocabulary = taxonomy_vocabulary_load($vid);
+ $this->assertTrue(!empty($vocabulary) && is_object($vocabulary), t('Vocabulary is an object'));
+ $this->assertTrue($vocabulary->vid == $vid, t('Valid vocabulary vid is the same as our previously invalid one.'));
+ }
+
+ /**
+ * Test deleting a taxonomy that contains terms.
+ */
+ function testTaxonomyVocabularyDeleteWithTerms() {
+ // Delete any existing vocabularies.
+ foreach (taxonomy_get_vocabularies() as $vocabulary) {
+ taxonomy_vocabulary_delete($vocabulary->vid);
+ }
+
+ // Assert that there are no terms left.
+ $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {taxonomy_term_data}')->fetchField());
+
+ // Create a new vocabulary and add a few terms to it.
+ $vocabulary = $this->createVocabulary();
+ $terms = array();
+ for ($i = 0; $i < 5; $i++) {
+ $terms[$i] = $this->createTerm($vocabulary);
+ }
+
+ // Set up hierarchy. term 2 is a child of 1 and 4 a child of 1 and 2.
+ $terms[2]->parent = array($terms[1]->tid);
+ taxonomy_term_save($terms[2]);
+ $terms[4]->parent = array($terms[1]->tid, $terms[2]->tid);
+ taxonomy_term_save($terms[4]);
+
+ // Assert that there are now 5 terms.
+ $this->assertEqual(5, db_query('SELECT COUNT(*) FROM {taxonomy_term_data}')->fetchField());
+
+ taxonomy_vocabulary_delete($vocabulary->vid);
+
+ // Assert that there are no terms left.
+ $this->assertEqual(0, db_query('SELECT COUNT(*) FROM {taxonomy_term_data}')->fetchField());
+ }
+
+ /**
+ * Ensure that the vocabulary static reset works correctly.
+ */
+ function testTaxonomyVocabularyLoadStaticReset() {
+ $original_vocabulary = taxonomy_vocabulary_load($this->vocabulary->vid);
+ $this->assertTrue(is_object($original_vocabulary), t('Vocabulary loaded successfully'));
+ $this->assertEqual($this->vocabulary->name, $original_vocabulary->name, t('Vocabulary loaded successfully'));
+
+ // Change the name and description.
+ $vocabulary = $original_vocabulary;
+ $vocabulary->name = $this->randomName();
+ $vocabulary->description = $this->randomName();
+ taxonomy_vocabulary_save($vocabulary);
+
+ // Load the vocabulary.
+ $new_vocabulary = taxonomy_vocabulary_load($original_vocabulary->vid);
+ $this->assertEqual($new_vocabulary->name, $vocabulary->name);
+ $this->assertEqual($new_vocabulary->name, $vocabulary->name);
+
+ // Delete the vocabulary.
+ taxonomy_vocabulary_delete($this->vocabulary->vid);
+ $vocabularies = taxonomy_get_vocabularies();
+ $this->assertTrue(!isset($vocabularies[$this->vocabulary->vid]), t('The vocabulary was deleted'));
+ }
+
+ /**
+ * Tests for loading multiple vocabularies.
+ */
+ function testTaxonomyVocabularyLoadMultiple() {
+
+ // Delete any existing vocabularies.
+ foreach (taxonomy_get_vocabularies() as $vocabulary) {
+ taxonomy_vocabulary_delete($vocabulary->vid);
+ }
+
+ // Create some vocabularies and assign weights.
+ $vocabulary1 = $this->createVocabulary();
+ $vocabulary1->weight = 0;
+ taxonomy_vocabulary_save($vocabulary1);
+ $vocabulary2 = $this->createVocabulary();
+ $vocabulary2->weight = 1;
+ taxonomy_vocabulary_save($vocabulary2);
+ $vocabulary3 = $this->createVocabulary();
+ $vocabulary3->weight = 2;
+ taxonomy_vocabulary_save($vocabulary3);
+
+ // Fetch the names for all vocabularies, confirm that they are keyed by
+ // machine name.
+ $names = taxonomy_vocabulary_get_names();
+ $this->assertEqual($names[$vocabulary1->machine_name]->name, $vocabulary1->name, t('Vocabulary 1 name found.'));
+
+ // Fetch all of the vocabularies using taxonomy_get_vocabularies().
+ // Confirm that the vocabularies are ordered by weight.
+ $vocabularies = taxonomy_get_vocabularies();
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary1->vid, t('Vocabulary was found in the vocabularies array.'));
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary2->vid, t('Vocabulary was found in the vocabularies array.'));
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary3->vid, t('Vocabulary was found in the vocabularies array.'));
+
+ // Fetch the vocabularies with taxonomy_vocabulary_load_multiple(), specifying IDs.
+ // Ensure they are returned in the same order as the original array.
+ $vocabularies = taxonomy_vocabulary_load_multiple(array($vocabulary3->vid, $vocabulary2->vid, $vocabulary1->vid));
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary3->vid, t('Vocabulary loaded successfully by ID.'));
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary2->vid, t('Vocabulary loaded successfully by ID.'));
+ $this->assertEqual(array_shift($vocabularies)->vid, $vocabulary1->vid, t('Vocabulary loaded successfully by ID.'));
+
+ // Fetch vocabulary 1 by name.
+ $vocabulary = current(taxonomy_vocabulary_load_multiple(array(), array('name' => $vocabulary1->name)));
+ $this->assertTrue($vocabulary->vid == $vocabulary1->vid, t('Vocabulary loaded successfully by name.'));
+
+ // Fetch vocabulary 1 by name and ID.
+ $this->assertTrue(current(taxonomy_vocabulary_load_multiple(array($vocabulary1->vid), array('name' => $vocabulary1->name)))->vid == $vocabulary1->vid, t('Vocabulary loaded successfully by name and ID.'));
+ }
+
+ /**
+ * Tests that machine name changes are properly reflected.
+ */
+ function testTaxonomyVocabularyChangeMachineName() {
+ // Add a field instance to the vocabulary.
+ $field = array(
+ 'field_name' => 'field_test',
+ 'type' => 'test_field',
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => 'field_test',
+ 'entity_type' => 'taxonomy_term',
+ 'bundle' => $this->vocabulary->machine_name,
+ );
+ field_create_instance($instance);
+
+ // Change the machine name.
+ $new_name = drupal_strtolower($this->randomName());
+ $this->vocabulary->machine_name = $new_name;
+ taxonomy_vocabulary_save($this->vocabulary);
+
+ // Check that the field instance is still attached to the vocabulary.
+ $this->assertTrue(field_info_instance('taxonomy_term', 'field_test', $new_name), t('The bundle name was updated correctly.'));
+ }
+
+ /**
+ * Test uninstall and reinstall of the taxonomy module.
+ */
+ function testUninstallReinstall() {
+ // Fields and field instances attached to taxonomy term bundles should be
+ // removed when the module is uninstalled.
+ $this->field_name = drupal_strtolower($this->randomName() . '_field_name');
+ $this->field = array('field_name' => $this->field_name, 'type' => 'text', 'cardinality' => 4);
+ $this->field = field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'taxonomy_term',
+ 'bundle' => $this->vocabulary->machine_name,
+ 'label' => $this->randomName() . '_label',
+ );
+ field_create_instance($this->instance);
+
+ module_disable(array('taxonomy'));
+ drupal_flush_all_caches();
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ drupal_uninstall_modules(array('taxonomy'));
+ module_enable(array('taxonomy'));
+
+ // Now create a vocabulary with the same name. All field instances
+ // connected to this vocabulary name should have been removed when the
+ // module was uninstalled. Creating a new field with the same name and
+ // an instance of this field on the same bundle name should be successful.
+ unset($this->vocabulary->vid);
+ taxonomy_vocabulary_save($this->vocabulary);
+ unset($this->field['id']);
+ field_create_field($this->field);
+ field_create_instance($this->instance);
+ }
+}
+
+/**
+ * Unit tests for taxonomy term functions.
+ */
+class TaxonomyTermUnitTest extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term unit tests',
+ 'description' => 'Unit tests for taxonomy term functions.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function testTermDelete() {
+ $vocabulary = $this->createVocabulary();
+ $valid_term = $this->createTerm($vocabulary);
+ // Delete a valid term.
+ taxonomy_term_delete($valid_term->tid);
+ $terms = taxonomy_term_load_multiple(array(), array('vid' => $vocabulary->vid));
+ $this->assertTrue(empty($terms), 'Vocabulary is empty after deletion');
+
+ // Delete an invalid term. Should not throw any notices.
+ taxonomy_term_delete(42);
+ }
+}
+
+/**
+ * Test for legacy node bug.
+ */
+class TaxonomyLegacyTestCase extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Test for legacy node bug.',
+ 'description' => 'Posts an article with a taxonomy term and a date prior to 1970.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('taxonomy');
+ $this->admin_user = $this->drupalCreateUser(array('administer taxonomy', 'administer nodes', 'bypass node access'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Test taxonomy functionality with nodes prior to 1970.
+ */
+ function testTaxonomyLegacyNode() {
+ // Posts an article with a taxonomy term and a date prior to 1970.
+ $langcode = LANGUAGE_NONE;
+ $edit = array();
+ $edit['title'] = $this->randomName();
+ $edit['date'] = '1969-01-01 00:00:00 -0500';
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $edit["field_tags[$langcode]"] = $this->randomName();
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+ // Checks that the node has been saved.
+ $node = $this->drupalGetNodeByTitle($edit['title']);
+ $this->assertEqual($node->created, strtotime($edit['date']), t('Legacy node was saved with the right date.'));
+ }
+}
+
+/**
+ * Tests for taxonomy term functions.
+ */
+class TaxonomyTermTestCase extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term functions and forms.',
+ 'description' => 'Test load, save and delete for taxonomy terms.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('taxonomy');
+ $this->admin_user = $this->drupalCreateUser(array('administer taxonomy', 'bypass node access'));
+ $this->drupalLogin($this->admin_user);
+ $this->vocabulary = $this->createVocabulary();
+
+ $field = array(
+ 'field_name' => 'taxonomy_' . $this->vocabulary->machine_name,
+ 'type' => 'taxonomy_term_reference',
+ 'cardinality' => FIELD_CARDINALITY_UNLIMITED,
+ 'settings' => array(
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => $this->vocabulary->machine_name,
+ 'parent' => 0,
+ ),
+ ),
+ ),
+ );
+ field_create_field($field);
+
+ $this->instance = array(
+ 'field_name' => 'taxonomy_' . $this->vocabulary->machine_name,
+ 'bundle' => 'article',
+ 'entity_type' => 'node',
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ }
+
+ /**
+ * Test terms in a single and multiple hierarchy.
+ */
+ function testTaxonomyTermHierarchy() {
+ // Create two taxonomy terms.
+ $term1 = $this->createTerm($this->vocabulary);
+ $term2 = $this->createTerm($this->vocabulary);
+
+ // Edit $term2, setting $term1 as parent.
+ $edit = array();
+ $edit['parent[]'] = array($term1->tid);
+ $this->drupalPost('taxonomy/term/' . $term2->tid . '/edit', $edit, t('Save'));
+
+ // Check the hierarchy.
+ $children = taxonomy_get_children($term1->tid);
+ $parents = taxonomy_get_parents($term2->tid);
+ $this->assertTrue(isset($children[$term2->tid]), t('Child found correctly.'));
+ $this->assertTrue(isset($parents[$term1->tid]), t('Parent found correctly.'));
+
+ // Load and save a term, confirming that parents are still set.
+ $term = taxonomy_term_load($term2->tid);
+ taxonomy_term_save($term);
+ $parents = taxonomy_get_parents($term2->tid);
+ $this->assertTrue(isset($parents[$term1->tid]), t('Parent found correctly.'));
+
+ // Create a third term and save this as a parent of term2.
+ $term3 = $this->createTerm($this->vocabulary);
+ $term2->parent = array($term1->tid, $term3->tid);
+ taxonomy_term_save($term2);
+ $parents = taxonomy_get_parents($term2->tid);
+ $this->assertTrue(isset($parents[$term1->tid]) && isset($parents[$term3->tid]), t('Both parents found successfully.'));
+ }
+
+ /**
+ * Test that hook_node_$op implementations work correctly.
+ *
+ * Save & edit a node and assert that taxonomy terms are saved/loaded properly.
+ */
+ function testTaxonomyNode() {
+ // Create two taxonomy terms.
+ $term1 = $this->createTerm($this->vocabulary);
+ $term2 = $this->createTerm($this->vocabulary);
+
+ // Post an article.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $edit[$this->instance['field_name'] . '[' . $langcode .'][]'] = $term1->tid;
+ $this->drupalPost('node/add/article', $edit, t('Save'));
+
+ // Check that the term is displayed when the node is viewed.
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($term1->name, t('Term is displayed when viewing the node.'));
+
+ // Edit the node with a different term.
+ $edit[$this->instance['field_name'] . '[' . $langcode . '][]'] = $term2->tid;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertText($term2->name, t('Term is displayed when viewing the node.'));
+
+ // Preview the node.
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Preview'));
+ $this->assertNoUniqueText($term2->name, t('Term is displayed when previewing the node.'));
+ $this->drupalPost(NULL, NULL, t('Preview'));
+ $this->assertNoUniqueText($term2->name, t('Term is displayed when previewing the node again.'));
+ }
+
+ /**
+ * Test term creation with a free-tagging vocabulary from the node form.
+ */
+ function testNodeTermCreationAndDeletion() {
+ // Enable tags in the vocabulary.
+ $instance = $this->instance;
+ $instance['widget'] = array('type' => 'taxonomy_autocomplete');
+ $instance['bundle'] = 'page';
+ field_create_instance($instance);
+ $terms = array(
+ $this->randomName(),
+ $this->randomName() . ', ' . $this->randomName(),
+ $this->randomName(),
+ );
+
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ // Insert the terms in a comma separated list. Vocabulary 1 is a
+ // free-tagging field created by the default profile.
+ $edit[$instance['field_name'] . "[$langcode]"] = drupal_implode_tags($terms);
+
+ // Preview and verify the terms appear but are not created.
+ $this->drupalPost('node/add/page', $edit, t('Preview'));
+ foreach ($terms as $term) {
+ $this->assertText($term, t('The term appears on the node preview'));
+ }
+ $tree = taxonomy_get_tree($this->vocabulary->vid);
+ $this->assertTrue(empty($tree), t('The terms are not created on preview.'));
+
+ // taxonomy.module does not maintain its static caches.
+ drupal_static_reset();
+
+ // Save, creating the terms.
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $this->assertRaw(t('@type %title has been created.', array('@type' => t('Basic page'), '%title' => $edit["title"])), t('The node was created successfully'));
+ foreach ($terms as $term) {
+ $this->assertText($term, t('The term was saved and appears on the node page'));
+ }
+
+ // Get the created terms.
+ list($term1, $term2, $term3) = array_values(taxonomy_term_load_multiple(FALSE));
+
+ // Delete term 1.
+ $this->drupalPost('taxonomy/term/' . $term1->tid . '/edit', array(), t('Delete'));
+ $this->drupalPost(NULL, NULL, t('Delete'));
+ $term_names = array($term2->name, $term3->name);
+
+ // Get the node.
+ $node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->drupalGet('node/' . $node->nid);
+
+ foreach ($term_names as $term_name) {
+ $this->assertText($term_name, t('The term %name appears on the node page after one term %deleted was deleted', array('%name' => $term_name, '%deleted' => $term1->name)));
+ }
+ $this->assertNoText($term1->name, t('The deleted term %name does not appear on the node page.', array('%name' => $term1->name)));
+
+ // Test autocomplete on term 2 - it contains a comma, so expect the key to
+ // be quoted.
+ $input = substr($term2->name, 0, 3);
+ $this->drupalGet('taxonomy/autocomplete/taxonomy_' . $this->vocabulary->machine_name . '/' . $input);
+ $this->assertRaw('{"\"' . $term2->name . '\"":"' . $term2->name . '"}', t('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term2->name)));
+
+ // Test autocomplete on term 3 - it is alphanumeric only, so no extra
+ // quoting.
+ $input = substr($term3->name, 0, 3);
+ $this->drupalGet('taxonomy/autocomplete/taxonomy_' . $this->vocabulary->machine_name . '/' . $input);
+ $this->assertRaw('{"' . $term3->name . '":"' . $term3->name . '"}', t('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term3->name)));
+ }
+
+ /**
+ * Save, edit and delete a term using the user interface.
+ */
+ function testTermInterface() {
+ $edit = array(
+ 'name' => $this->randomName(12),
+ 'description[value]' => $this->randomName(100),
+ );
+ // Explicitly set the parents field to 'root', to ensure that
+ // taxonomy_form_term_submit() handles the invalid term ID correctly.
+ $edit['parent[]'] = array(0);
+
+ // Create the term to edit.
+ $this->drupalPost('admin/structure/taxonomy/' . $this->vocabulary->machine_name . '/add', $edit, t('Save'));
+
+ $terms = taxonomy_get_term_by_name($edit['name']);
+ $term = reset($terms);
+ $this->assertNotNull($term, t('Term found in database'));
+
+ // Submitting a term takes us to the add page; we need the List page.
+ $this->drupalGet('admin/structure/taxonomy/' . $this->vocabulary->machine_name);
+
+ // Test edit link as accessed from Taxonomy administration pages.
+ // Because Simpletest creates its own database when running tests, we know
+ // the first edit link found on the listing page is to our term.
+ $this->clickLink(t('edit'));
+
+ $this->assertRaw($edit['name'], t('The randomly generated term name is present.'));
+ $this->assertText($edit['description[value]'], t('The randomly generated term description is present.'));
+
+ $edit = array(
+ 'name' => $this->randomName(14),
+ 'description[value]' => $this->randomName(102),
+ );
+
+ // Edit the term.
+ $this->drupalPost('taxonomy/term/' . $term->tid . '/edit', $edit, t('Save'));
+
+ // Check that the term is still present at admin UI after edit.
+ $this->drupalGet('admin/structure/taxonomy/' . $this->vocabulary->machine_name);
+ $this->assertText($edit['name'], t('The randomly generated term name is present.'));
+ $this->assertLink(t('edit'));
+
+ // View the term and check that it is correct.
+ $this->drupalGet('taxonomy/term/' . $term->tid);
+ $this->assertText($edit['name'], t('The randomly generated term name is present.'));
+ $this->assertText($edit['description[value]'], t('The randomly generated term description is present.'));
+
+ // Did this page request display a 'term-listing-heading'?
+ $this->assertPattern('|class="taxonomy-term-description"|', 'Term page displayed the term description element.');
+ // Check that it does NOT show a description when description is blank.
+ $term->description = '';
+ taxonomy_term_save($term);
+ $this->drupalGet('taxonomy/term/' . $term->tid);
+ $this->assertNoPattern('|class="taxonomy-term-description"|', 'Term page did not display the term description when description was blank.');
+
+ // Check that the term feed page is working.
+ $this->drupalGet('taxonomy/term/' . $term->tid . '/feed');
+
+ // Delete the term.
+ $this->drupalPost('taxonomy/term/' . $term->tid . '/edit', array(), t('Delete'));
+ $this->drupalPost(NULL, NULL, t('Delete'));
+
+ // Assert that the term no longer exists.
+ $this->drupalGet('taxonomy/term/' . $term->tid);
+ $this->assertResponse(404, t('The taxonomy term page was not found'));
+ }
+
+ /**
+ * Save, edit and delete a term using the user interface.
+ */
+ function testTermReorder() {
+ $this->createTerm($this->vocabulary);
+ $this->createTerm($this->vocabulary);
+ $this->createTerm($this->vocabulary);
+
+ // Fetch the created terms in the default alphabetical order, i.e. term1
+ // precedes term2 alphabetically, and term2 precedes term3.
+ drupal_static_reset('taxonomy_get_tree');
+ drupal_static_reset('taxonomy_get_treeparent');
+ drupal_static_reset('taxonomy_get_treeterms');
+ list($term1, $term2, $term3) = taxonomy_get_tree($this->vocabulary->vid);
+
+ $this->drupalGet('admin/structure/taxonomy/' . $this->vocabulary->machine_name);
+
+ // Each term has four hidden fields, "tid:1:0[tid]", "tid:1:0[parent]",
+ // "tid:1:0[depth]", and "tid:1:0[weight]". Change the order to term2,
+ // term3, term1 by setting weight property, make term3 a child of term2 by
+ // setting the parent and depth properties, and update all hidden fields.
+ $edit = array(
+ 'tid:' . $term2->tid . ':0[tid]' => $term2->tid,
+ 'tid:' . $term2->tid . ':0[parent]' => 0,
+ 'tid:' . $term2->tid . ':0[depth]' => 0,
+ 'tid:' . $term2->tid . ':0[weight]' => 0,
+ 'tid:' . $term3->tid . ':0[tid]' => $term3->tid,
+ 'tid:' . $term3->tid . ':0[parent]' => $term2->tid,
+ 'tid:' . $term3->tid . ':0[depth]' => 1,
+ 'tid:' . $term3->tid . ':0[weight]' => 1,
+ 'tid:' . $term1->tid . ':0[tid]' => $term1->tid,
+ 'tid:' . $term1->tid . ':0[parent]' => 0,
+ 'tid:' . $term1->tid . ':0[depth]' => 0,
+ 'tid:' . $term1->tid . ':0[weight]' => 2,
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ drupal_static_reset('taxonomy_get_tree');
+ drupal_static_reset('taxonomy_get_treeparent');
+ drupal_static_reset('taxonomy_get_treeterms');
+ $terms = taxonomy_get_tree($this->vocabulary->vid);
+ $this->assertEqual($terms[0]->tid, $term2->tid, t('Term 2 was moved above term 1.'));
+ $this->assertEqual($terms[1]->parents, array($term2->tid), t('Term 3 was made a child of term 2.'));
+ $this->assertEqual($terms[2]->tid, $term1->tid, t('Term 1 was moved below term 2.'));
+
+ $this->drupalPost('admin/structure/taxonomy/' . $this->vocabulary->machine_name, array(), t('Reset to alphabetical'));
+ // Submit confirmation form.
+ $this->drupalPost(NULL, array(), t('Reset to alphabetical'));
+
+ drupal_static_reset('taxonomy_get_tree');
+ drupal_static_reset('taxonomy_get_treeparent');
+ drupal_static_reset('taxonomy_get_treeterms');
+ $terms = taxonomy_get_tree($this->vocabulary->vid);
+ $this->assertEqual($terms[0]->tid, $term1->tid, t('Term 1 was moved to back above term 2.'));
+ $this->assertEqual($terms[1]->tid, $term2->tid, t('Term 2 was moved to back below term 1.'));
+ $this->assertEqual($terms[2]->tid, $term3->tid, t('Term 3 is still below term 2.'));
+ $this->assertEqual($terms[2]->parents, array($term2->tid), t('Term 3 is still a child of term 2.').var_export($terms[1]->tid,1));
+ }
+
+ /**
+ * Test saving a term with multiple parents through the UI.
+ */
+ function testTermMultipleParentsInterface() {
+ // Add a new term to the vocabulary so that we can have multiple parents.
+ $parent = $this->createTerm($this->vocabulary);
+
+ // Add a new term with multiple parents.
+ $edit = array(
+ 'name' => $this->randomName(12),
+ 'description[value]' => $this->randomName(100),
+ 'parent[]' => array(0, $parent->tid),
+ );
+ // Save the new term.
+ $this->drupalPost('admin/structure/taxonomy/' . $this->vocabulary->machine_name . '/add', $edit, t('Save'));
+
+ // Check that the term was successfully created.
+ $terms = taxonomy_get_term_by_name($edit['name']);
+ $term = reset($terms);
+ $this->assertNotNull($term, t('Term found in database'));
+ $this->assertEqual($edit['name'], $term->name, t('Term name was successfully saved.'));
+ $this->assertEqual($edit['description[value]'], $term->description, t('Term description was successfully saved.'));
+ // Check that the parent tid is still there. The other parent (<root>) is
+ // not added by taxonomy_get_parents().
+ $parents = taxonomy_get_parents($term->tid);
+ $parent = reset($parents);
+ $this->assertEqual($edit['parent[]'][1], $parent->tid, t('Term parents were successfully saved.'));
+ }
+
+ /**
+ * Test taxonomy_get_term_by_name().
+ */
+ function testTaxonomyGetTermByName() {
+ $term = $this->createTerm($this->vocabulary);
+
+ // Load the term with the exact name.
+ $terms = taxonomy_get_term_by_name($term->name);
+ $this->assertTrue(isset($terms[$term->tid]), t('Term loaded using exact name.'));
+
+ // Load the term with space concatenated.
+ $terms = taxonomy_get_term_by_name(' ' . $term->name . ' ');
+ $this->assertTrue(isset($terms[$term->tid]), t('Term loaded with extra whitespace.'));
+
+ // Load the term with name uppercased.
+ $terms = taxonomy_get_term_by_name(strtoupper($term->name));
+ $this->assertTrue(isset($terms[$term->tid]), t('Term loaded with uppercased name.'));
+
+ // Load the term with name lowercased.
+ $terms = taxonomy_get_term_by_name(strtolower($term->name));
+ $this->assertTrue(isset($terms[$term->tid]), t('Term loaded with lowercased name.'));
+
+ // Try to load an invalid term name.
+ $terms = taxonomy_get_term_by_name('Banana');
+ $this->assertFalse($terms);
+
+ // Try to load the term using a substring of the name.
+ $terms = taxonomy_get_term_by_name(drupal_substr($term->name, 2));
+ $this->assertFalse($terms);
+ }
+}
+
+/**
+ * Test the taxonomy_term_load_multiple() function.
+ */
+class TaxonomyLoadMultipleUnitTest extends TaxonomyWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term multiple loading',
+ 'description' => 'Test the loading of multiple taxonomy terms at once',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->taxonomy_admin = $this->drupalCreateUser(array('administer taxonomy'));
+ $this->drupalLogin($this->taxonomy_admin);
+ }
+
+ /**
+ * Create a vocabulary and some taxonomy terms, ensuring they're loaded
+ * correctly using taxonomy_term_load_multiple().
+ */
+ function testTaxonomyTermMultipleLoad() {
+ // Create a vocabulary.
+ $vocabulary = $this->createVocabulary();
+
+ // Create five terms in the vocabulary.
+ $i = 0;
+ while ($i < 5) {
+ $i++;
+ $this->createTerm($vocabulary);
+ }
+ // Load the terms from the vocabulary.
+ $terms = taxonomy_term_load_multiple(NULL, array('vid' => $vocabulary->vid));
+ $count = count($terms);
+ $this->assertTrue($count == 5, t('Correct number of terms were loaded. !count terms.', array('!count' => $count)));
+
+ // Load the same terms again by tid.
+ $terms2 = taxonomy_term_load_multiple(array_keys($terms));
+ $this->assertTrue($count == count($terms2), t('Five terms were loaded by tid'));
+ $this->assertEqual($terms, $terms2, t('Both arrays contain the same terms'));
+
+ // Load the terms by tid, with a condition on vid.
+ $terms3 = taxonomy_term_load_multiple(array_keys($terms2), array('vid' => $vocabulary->vid));
+ $this->assertEqual($terms2, $terms3);
+
+ // Remove one term from the array, then delete it.
+ $deleted = array_shift($terms3);
+ taxonomy_term_delete($deleted->tid);
+ $deleted_term = taxonomy_term_load($deleted->tid);
+ $this->assertFalse($deleted_term);
+
+ // Load terms from the vocabulary by vid.
+ $terms4 = taxonomy_term_load_multiple(NULL, array('vid' => $vocabulary->vid));
+ $this->assertTrue(count($terms4 == 4), t('Correct number of terms were loaded.'));
+ $this->assertFalse(isset($terms4[$deleted->tid]));
+
+ // Create a single term and load it by name.
+ $term = $this->createTerm($vocabulary);
+ $loaded_terms = taxonomy_term_load_multiple(array(), array('name' => $term->name));
+ $this->assertEqual(count($loaded_terms), 1, t('One term was loaded'));
+ $loaded_term = reset($loaded_terms);
+ $this->assertEqual($term->tid, $loaded_term->tid, t('Term loaded by name successfully.'));
+ }
+}
+
+/**
+ * Tests for taxonomy hook invocation.
+ */
+class TaxonomyHooksTestCase extends TaxonomyWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term hooks',
+ 'description' => 'Hooks for taxonomy term load/save/delete.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('taxonomy', 'taxonomy_test');
+ $taxonomy_admin = $this->drupalCreateUser(array('administer taxonomy'));
+ $this->drupalLogin($taxonomy_admin);
+ }
+
+ /**
+ * Test that hooks are run correctly on creating, editing and deleting a term.
+ */
+ function testTaxonomyTermHooks() {
+ $vocabulary = $this->createVocabulary();
+
+ // Create a term with one antonym.
+ $edit = array(
+ 'name' => $this->randomName(),
+ 'antonym' => 'Long',
+ );
+ $this->drupalPost('admin/structure/taxonomy/' . $vocabulary->machine_name . '/add', $edit, t('Save'));
+ $terms = taxonomy_get_term_by_name($edit['name']);
+ $term = reset($terms);
+ $this->assertEqual($term->antonym, $edit['antonym'], t('Antonym was loaded into the term object'));
+
+ // Update the term with a different antonym.
+ $edit = array(
+ 'name' => $this->randomName(),
+ 'antonym' => 'Short',
+ );
+ $this->drupalPost('taxonomy/term/' . $term->tid . '/edit', $edit, t('Save'));
+ taxonomy_terms_static_reset();
+ $term = taxonomy_term_load($term->tid);
+ $this->assertEqual($edit['antonym'], $term->antonym, t('Antonym was successfully edited'));
+
+ // Delete the term.
+ taxonomy_term_delete($term->tid);
+ $antonym = db_query('SELECT tid FROM {taxonomy_term_antonym} WHERE tid = :tid', array(':tid' => $term->tid))->fetchField();
+ $this->assertFalse($antonym, t('The antonym were deleted from the database.'));
+ }
+}
+
+/**
+ * Tests for taxonomy term field and formatter.
+ */
+class TaxonomyTermFieldTestCase extends TaxonomyWebTestCase {
+ protected $instance;
+ protected $vocabulary;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy term reference field',
+ 'description' => 'Test the creation of term fields.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+
+ $web_user = $this->drupalCreateUser(array('access field_test content', 'administer field_test content', 'administer taxonomy'));
+ $this->drupalLogin($web_user);
+ $this->vocabulary = $this->createVocabulary();
+
+ // Setup a field and instance.
+ $this->field_name = drupal_strtolower($this->randomName());
+ $this->field = array(
+ 'field_name' => $this->field_name,
+ 'type' => 'taxonomy_term_reference',
+ 'settings' => array(
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => $this->vocabulary->machine_name,
+ 'parent' => '0',
+ ),
+ ),
+ )
+ );
+ field_create_field($this->field);
+ $this->instance = array(
+ 'field_name' => $this->field_name,
+ 'entity_type' => 'test_entity',
+ 'bundle' => 'test_bundle',
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ 'display' => array(
+ 'full' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ }
+
+ /**
+ * Test term field validation.
+ */
+ function testTaxonomyTermFieldValidation() {
+ // Test valid and invalid values with field_attach_validate().
+ $langcode = LANGUAGE_NONE;
+ $entity = field_test_create_stub_entity();
+ $term = $this->createTerm($this->vocabulary);
+ $entity->{$this->field_name}[$langcode][0]['tid'] = $term->tid;
+ try {
+ field_attach_validate('test_entity', $entity);
+ $this->pass(t('Correct term does not cause validation error'));
+ }
+ catch (FieldValidationException $e) {
+ $this->fail(t('Correct term does not cause validation error'));
+ }
+
+ $entity = field_test_create_stub_entity();
+ $bad_term = $this->createTerm($this->createVocabulary());
+ $entity->{$this->field_name}[$langcode][0]['tid'] = $bad_term->tid;
+ try {
+ field_attach_validate('test_entity', $entity);
+ $this->fail(t('Wrong term causes validation error'));
+ }
+ catch (FieldValidationException $e) {
+ $this->pass(t('Wrong term causes validation error'));
+ }
+ }
+
+ /**
+ * Test widgets.
+ */
+ function testTaxonomyTermFieldWidgets() {
+ // Create a term in the vocabulary.
+ $term = $this->createTerm($this->vocabulary);
+
+ // Display creation form.
+ $langcode = LANGUAGE_NONE;
+ $this->drupalGet('test-entity/add/test-bundle');
+ $this->assertFieldByName("{$this->field_name}[$langcode]", '', t('Widget is displayed'));
+
+ // Submit with some value.
+ $edit = array(
+ "{$this->field_name}[$langcode]" => array($term->tid),
+ );
+ $this->drupalPost(NULL, $edit, t('Save'));
+ preg_match('|test-entity/manage/(\d+)/edit|', $this->url, $match);
+ $id = $match[1];
+ $this->assertRaw(t('test_entity @id has been created.', array('@id' => $id)), t('Entity was created'));
+
+ // Display the object.
+ $entity = field_test_entity_test_load($id);
+ $entities = array($id => $entity);
+ field_attach_prepare_view('test_entity', $entities, 'full');
+ $entity->content = field_attach_view('test_entity', $entity, 'full');
+ $this->content = drupal_render($entity->content);
+ $this->assertText($term->name, t('Term name is displayed'));
+ }
+
+ /**
+ * Tests that vocabulary machine name changes are mirrored in field definitions.
+ */
+ function testTaxonomyTermFieldChangeMachineName() {
+ // Add several entries in the 'allowed_values' setting, to make sure that
+ // they all get updated.
+ $this->field['settings']['allowed_values'] = array(
+ array(
+ 'vocabulary' => $this->vocabulary->machine_name,
+ 'parent' => '0',
+ ),
+ array(
+ 'vocabulary' => $this->vocabulary->machine_name,
+ 'parent' => '0',
+ ),
+ array(
+ 'vocabulary' => 'foo',
+ 'parent' => '0',
+ ),
+ );
+ field_update_field($this->field);
+ // Change the machine name.
+ $new_name = drupal_strtolower($this->randomName());
+ $this->vocabulary->machine_name = $new_name;
+ taxonomy_vocabulary_save($this->vocabulary);
+
+ // Check that the field instance is still attached to the vocabulary.
+ $field = field_info_field($this->field_name);
+ $allowed_values = $field['settings']['allowed_values'];
+ $this->assertEqual($allowed_values[0]['vocabulary'], $new_name, t('Index 0: Machine name was updated correctly.'));
+ $this->assertEqual($allowed_values[1]['vocabulary'], $new_name, t('Index 1: Machine name was updated correctly.'));
+ $this->assertEqual($allowed_values[2]['vocabulary'], 'foo', t('Index 2: Machine name was left untouched.'));
+ }
+}
+
+/**
+ * Test taxonomy token replacement in strings.
+ */
+class TaxonomyTokenReplaceTestCase extends TaxonomyWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check taxonomy token replacement.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->admin_user = $this->drupalCreateUser(array('administer taxonomy', 'bypass node access'));
+ $this->drupalLogin($this->admin_user);
+ $this->vocabulary = $this->createVocabulary();
+ $this->langcode = LANGUAGE_NONE;
+
+ $field = array(
+ 'field_name' => 'taxonomy_' . $this->vocabulary->machine_name,
+ 'type' => 'taxonomy_term_reference',
+ 'cardinality' => FIELD_CARDINALITY_UNLIMITED,
+ 'settings' => array(
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => $this->vocabulary->machine_name,
+ 'parent' => 0,
+ ),
+ ),
+ ),
+ );
+ field_create_field($field);
+
+ $this->instance = array(
+ 'field_name' => 'taxonomy_' . $this->vocabulary->machine_name,
+ 'bundle' => 'article',
+ 'entity_type' => 'node',
+ 'widget' => array(
+ 'type' => 'options_select',
+ ),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ ),
+ ),
+ );
+ field_create_instance($this->instance);
+ }
+
+ /**
+ * Creates some terms and a node, then tests the tokens generated from them.
+ */
+ function testTaxonomyTokenReplacement() {
+ global $language;
+
+ // Create two taxonomy terms.
+ $term1 = $this->createTerm($this->vocabulary);
+ $term2 = $this->createTerm($this->vocabulary);
+
+ // Edit $term2, setting $term1 as parent.
+ $edit = array();
+ $edit['name'] = '<blink>Blinking Text</blink>';
+ $edit['parent[]'] = array($term1->tid);
+ $this->drupalPost('taxonomy/term/' . $term2->tid . '/edit', $edit, t('Save'));
+
+ // Create node with term2.
+ $edit = array();
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $edit[$this->instance['field_name'] . '[' . $this->langcode . '][]'] = $term2->tid;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+
+ // Generate and test sanitized tokens for term1.
+ $tests = array();
+ $tests['[term:tid]'] = $term1->tid;
+ $tests['[term:name]'] = check_plain($term1->name);
+ $tests['[term:description]'] = check_markup($term1->description, $term1->format);
+ $tests['[term:url]'] = url('taxonomy/term/' . $term1->tid, array('absolute' => TRUE));
+ $tests['[term:node-count]'] = 0;
+ $tests['[term:parent:name]'] = '[term:parent:name]';
+ $tests['[term:vocabulary:name]'] = check_plain($this->vocabulary->name);
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('term' => $term1), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized taxonomy term token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test sanitized tokens for term2.
+ $tests = array();
+ $tests['[term:tid]'] = $term2->tid;
+ $tests['[term:name]'] = check_plain($term2->name);
+ $tests['[term:description]'] = check_markup($term2->description, $term2->format);
+ $tests['[term:url]'] = url('taxonomy/term/' . $term2->tid, array('absolute' => TRUE));
+ $tests['[term:node-count]'] = 1;
+ $tests['[term:parent:name]'] = check_plain($term1->name);
+ $tests['[term:parent:url]'] = url('taxonomy/term/' . $term1->tid, array('absolute' => TRUE));
+ $tests['[term:parent:parent:name]'] = '[term:parent:parent:name]';
+ $tests['[term:vocabulary:name]'] = check_plain($this->vocabulary->name);
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('term' => $term2), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized taxonomy term token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[term:name]'] = $term2->name;
+ $tests['[term:description]'] = $term2->description;
+ $tests['[term:parent:name]'] = $term1->name;
+ $tests['[term:vocabulary:name]'] = $this->vocabulary->name;
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('term' => $term2), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized taxonomy term token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[vocabulary:vid]'] = $this->vocabulary->vid;
+ $tests['[vocabulary:name]'] = check_plain($this->vocabulary->name);
+ $tests['[vocabulary:description]'] = filter_xss($this->vocabulary->description);
+ $tests['[vocabulary:node-count]'] = 1;
+ $tests['[vocabulary:term-count]'] = 2;
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('vocabulary' => $this->vocabulary), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized taxonomy vocabulary token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[vocabulary:name]'] = $this->vocabulary->name;
+ $tests['[vocabulary:description]'] = $this->vocabulary->description;
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('vocabulary' => $this->vocabulary), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized taxonomy vocabulary token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+/**
+ * Tests for verifying that taxonomy pages use the correct theme.
+ */
+class TaxonomyThemeTestCase extends TaxonomyWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Taxonomy theme switching',
+ 'description' => 'Verifies that various taxonomy pages use the expected theme.',
+ 'group' => 'Taxonomy',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Make sure we are using distinct default and administrative themes for
+ // the duration of these tests.
+ variable_set('theme_default', 'bartik');
+ variable_set('admin_theme', 'seven');
+
+ // Create and log in as a user who has permission to add and edit taxonomy
+ // terms and view the administrative theme.
+ $admin_user = $this->drupalCreateUser(array('administer taxonomy', 'view the administration theme'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Test the theme used when adding, viewing and editing taxonomy terms.
+ */
+ function testTaxonomyTermThemes() {
+ // Adding a term to a vocabulary is considered an administrative action and
+ // should use the administrative theme.
+ $vocabulary = $this->createVocabulary();
+ $this->drupalGet('admin/structure/taxonomy/' . $vocabulary->machine_name . '/add');
+ $this->assertRaw('seven/style.css', t("The administrative theme's CSS appears on the page for adding a taxonomy term."));
+
+ // Viewing a taxonomy term should use the default theme.
+ $term = $this->createTerm($vocabulary);
+ $this->drupalGet('taxonomy/term/' . $term->tid);
+ $this->assertRaw('bartik/css/style.css', t("The default theme's CSS appears on the page for viewing a taxonomy term."));
+
+ // Editing a taxonomy term should use the same theme as adding one.
+ $this->drupalGet('taxonomy/term/' . $term->tid . '/edit');
+ $this->assertRaw('seven/style.css', t("The administrative theme's CSS appears on the page for editing a taxonomy term."));
+ }
+}
diff --git a/core/modules/taxonomy/taxonomy.tokens.inc b/core/modules/taxonomy/taxonomy.tokens.inc
new file mode 100644
index 000000000000..f8ae4576d70e
--- /dev/null
+++ b/core/modules/taxonomy/taxonomy.tokens.inc
@@ -0,0 +1,189 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for taxonomy terms and vocabularies.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function taxonomy_token_info() {
+ $types['term'] = array(
+ 'name' => t("Taxonomy terms"),
+ 'description' => t("Tokens related to taxonomy terms."),
+ 'needs-data' => 'term',
+ );
+ $types['vocabulary'] = array(
+ 'name' => t("Vocabularies"),
+ 'description' => t("Tokens related to taxonomy vocabularies."),
+ 'needs-data' => 'vocabulary',
+ );
+
+ // Taxonomy term related variables.
+ $term['tid'] = array(
+ 'name' => t("Term ID"),
+ 'description' => t("The unique ID of the taxonomy term."),
+ );
+ $term['name'] = array(
+ 'name' => t("Name"),
+ 'description' => t("The name of the taxonomy term."),
+ );
+ $term['description'] = array(
+ 'name' => t("Description"),
+ 'description' => t("The optional description of the taxonomy term."),
+ );
+ $term['node-count'] = array(
+ 'name' => t("Node count"),
+ 'description' => t("The number of nodes tagged with the taxonomy term."),
+ );
+ $term['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The URL of the taxonomy term."),
+ );
+
+ // Taxonomy vocabulary related variables.
+ $vocabulary['vid'] = array(
+ 'name' => t("Vocabulary ID"),
+ 'description' => t("The unique ID of the taxonomy vocabulary."),
+ );
+ $vocabulary['name'] = array(
+ 'name' => t("Name"),
+ 'description' => t("The name of the taxonomy vocabulary."),
+ );
+ $vocabulary['description'] = array(
+ 'name' => t("Description"),
+ 'description' => t("The optional description of the taxonomy vocabulary."),
+ );
+ $vocabulary['node-count'] = array(
+ 'name' => t("Node count"),
+ 'description' => t("The number of nodes tagged with terms belonging to the taxonomy vocabulary."),
+ );
+ $vocabulary['term-count'] = array(
+ 'name' => t("Term count"),
+ 'description' => t("The number of terms belonging to the taxonomy vocabulary."),
+ );
+
+ // Chained tokens for taxonomies
+ $term['vocabulary'] = array(
+ 'name' => t("Vocabulary"),
+ 'description' => t("The vocabulary the taxonomy term belongs to."),
+ 'type' => 'vocabulary',
+ );
+ $term['parent'] = array(
+ 'name' => t("Parent term"),
+ 'description' => t("The parent term of the taxonomy term, if one exists."),
+ 'type' => 'term',
+ );
+
+ return array(
+ 'types' => $types,
+ 'tokens' => array(
+ 'term' => $term,
+ 'vocabulary' => $vocabulary,
+ ),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function taxonomy_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $replacements = array();
+ $sanitize = !empty($options['sanitize']);
+
+ if ($type == 'term' && !empty($data['term'])) {
+ $term = $data['term'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ case 'tid':
+ $replacements[$original] = $term->tid;
+ break;
+
+ case 'name':
+ $replacements[$original] = $sanitize ? check_plain($term->name) : $term->name;
+ break;
+
+ case 'description':
+ $replacements[$original] = $sanitize ? check_markup($term->description, $term->format, '', TRUE) : $term->description;
+ break;
+
+ case 'url':
+ $uri = entity_uri('taxonomy_term', $term);
+ $replacements[$original] = url($uri['path'], array_merge($uri['options'], array('absolute' => TRUE)));
+ break;
+
+ case 'node-count':
+ $query = db_select('taxonomy_index');
+ $query->condition('tid', $term->tid);
+ $query->addTag('term_node_count');
+ $count = $query->countQuery()->execute()->fetchField();
+ $replacements[$original] = $count;
+ break;
+
+ case 'vocabulary':
+ $vocabulary = taxonomy_vocabulary_load($term->vid);
+ $replacements[$original] = check_plain($vocabulary->name);
+ break;
+
+ case 'parent':
+ if ($parents = taxonomy_get_parents($term->tid)) {
+ $parent = array_pop($parents);
+ $replacements[$original] = check_plain($parent->name);
+ }
+ break;
+ }
+ }
+
+ if ($vocabulary_tokens = token_find_with_prefix($tokens, 'vocabulary')) {
+ $vocabulary = taxonomy_vocabulary_load($term->vid);
+ $replacements += token_generate('vocabulary', $vocabulary_tokens, array('vocabulary' => $vocabulary), $options);
+ }
+
+ if (($vocabulary_tokens = token_find_with_prefix($tokens, 'parent')) && $parents = taxonomy_get_parents($term->tid)) {
+ $parent = array_pop($parents);
+ $replacements += token_generate('term', $vocabulary_tokens, array('term' => $parent), $options);
+ }
+ }
+
+ elseif ($type == 'vocabulary' && !empty($data['vocabulary'])) {
+ $vocabulary = $data['vocabulary'];
+
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ case 'vid':
+ $replacements[$original] = $vocabulary->vid;
+ break;
+
+ case 'name':
+ $replacements[$original] = $sanitize ? check_plain($vocabulary->name) : $vocabulary->name;
+ break;
+
+ case 'description':
+ $replacements[$original] = $sanitize ? filter_xss($vocabulary->description) : $vocabulary->description;
+ break;
+
+ case 'term-count':
+ $query = db_select('taxonomy_term_data');
+ $query->condition('vid', $vocabulary->vid);
+ $query->addTag('vocabulary_term_count');
+ $count = $query->countQuery()->execute()->fetchField();
+ $replacements[$original] = $count;
+ break;
+
+ case 'node-count':
+ $query = db_select('taxonomy_index', 'ti');
+ $query->addExpression('COUNT(DISTINCT ti.nid)');
+ $query->leftJoin('taxonomy_term_data', 'td', 'ti.tid = td.tid');
+ $query->condition('td.vid', $vocabulary->vid);
+ $query->addTag('vocabulary_node_count');
+ $count = $query->execute()->fetchField();
+ $replacements[$original] = $count;
+ break;
+ }
+ }
+ }
+
+ return $replacements;
+}
diff --git a/core/modules/toolbar/toolbar-rtl.css b/core/modules/toolbar/toolbar-rtl.css
new file mode 100644
index 000000000000..e12154724a70
--- /dev/null
+++ b/core/modules/toolbar/toolbar-rtl.css
@@ -0,0 +1,37 @@
+
+#toolbar,
+#toolbar * {
+ text-align: right;
+}
+#toolbar ul li {
+ float: right;
+}
+#toolbar ul li a {
+ display: inline-block;
+ float: none;
+ zoom: 1;
+}
+#toolbar div.toolbar-menu {
+ padding: 5px 50px 5px 50px;
+}
+#toolbar-user {
+ float: left;
+}
+#toolbar ul#toolbar-user li {
+ float: none;
+ display: inline;
+}
+#toolbar-menu {
+ float: none;
+}
+#toolbar-home {
+ float: right;
+}
+#toolbar ul li.home a {
+ position: absolute;
+ right: 10px;
+}
+#toolbar div.toolbar-menu a.toggle {
+ left: 10px;
+ right: auto;
+}
diff --git a/core/modules/toolbar/toolbar.css b/core/modules/toolbar/toolbar.css
new file mode 100644
index 000000000000..4b62cded060e
--- /dev/null
+++ b/core/modules/toolbar/toolbar.css
@@ -0,0 +1,135 @@
+
+body.toolbar {
+ padding-top: 2.2em;
+}
+body.toolbar-drawer {
+ padding-top: 5.3em;
+}
+
+/**
+ * Aggressive resets so we can achieve a consistent look in hostile CSS
+ * environments.
+ */
+#toolbar,
+#toolbar * {
+ border: 0;
+ font-size: 100%;
+ line-height: inherit;
+ list-style: none;
+ margin: 0;
+ outline: 0;
+ padding: 0;
+ text-align: left; /* LTR */
+ vertical-align: baseline;
+}
+
+/**
+ * Base styles.
+ *
+ * We use a keyword for the toolbar font size to make it display consistently
+ * across different themes, while still allowing browsers to resize the text.
+ */
+#toolbar {
+ background: #666;
+ color: #ccc;
+ font: normal small "Lucida Grande", Verdana, sans-serif;
+ left: 0;
+ margin: 0 -20px;
+ padding: 0 20px;
+ position: fixed;
+ right: 0;
+ top: 0;
+ -moz-box-shadow: 0 3px 20px #000;
+ -webkit-box-shadow: 0 3px 20px #000;
+ box-shadow: 0 3px 20px #000;
+ filter: progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10');
+ -ms-filter: "progid:DXImageTransform.Microsoft.Shadow(color=#000000, direction='180', strength='10')";
+ z-index: 600;
+}
+#toolbar div.collapsed {
+ display: none;
+ visibility: hidden;
+}
+#toolbar a {
+ color: #fff;
+ font-size: .846em;
+ text-decoration: none;
+}
+#toolbar ul li,
+#toolbar ul li a {
+ float: left; /* LTR */
+}
+
+/**
+ * Administration menu.
+ */
+#toolbar div.toolbar-menu {
+ background: #000;
+ line-height: 20px;
+ padding: 5px 50px 5px 10px; /* LTR */
+ position: relative;
+}
+#toolbar-home a span {
+ background: url(toolbar.png) no-repeat 0 -45px;
+ display: block;
+ height: 14px;
+ margin: 3px 0px;
+ text-indent: -9999px;
+ vertical-align: text-bottom;
+ width: 11px;
+}
+#toolbar-user {
+ float: right; /* LTR */
+}
+#toolbar-menu {
+ float: left; /* LTR */
+}
+#toolbar div.toolbar-menu a.toggle {
+ background: url(toolbar.png) 0 -20px no-repeat;
+ bottom: 0;
+ cursor: pointer;
+ height: 25px;
+ overflow: hidden;
+ position: absolute;
+ right: 10px; /* LTR */
+ text-indent: -9999px;
+ width: 25px;
+}
+#toolbar div.toolbar-menu a.toggle:focus,
+#toolbar div.toolbar-menu a.toggle:hover {
+ background-position: -50px -20px;
+}
+#toolbar div.toolbar-menu a.toggle-active {
+ background-position: -25px -20px;
+}
+#toolbar div.toolbar-menu a.toggle-active.toggle:focus,
+#toolbar div.toolbar-menu a.toggle-active.toggle:hover {
+ background-position: -75px -20px;
+}
+#toolbar div.toolbar-menu ul li a {
+ padding: 0 10px;
+ -moz-border-radius: 10px;
+ -webkit-border-radius: 10px;
+ border-radius: 10px;
+}
+#toolbar div.toolbar-menu ul li a:focus,
+#toolbar div.toolbar-menu ul li a:hover,
+#toolbar div.toolbar-menu ul li a:active,
+#toolbar div.toolbar-menu ul li a.active:focus {
+ background: #444;
+}
+#toolbar div.toolbar-menu ul li a.active:hover,
+#toolbar div.toolbar-menu ul li a.active:active,
+#toolbar div.toolbar-menu ul li a.active,
+#toolbar div.toolbar-menu ul li.active-trail a {
+ background: url(toolbar.png) 0 0 repeat-x;
+ text-shadow: #333 0 1px 0;
+}
+
+/**
+ * Collapsed drawer of additional toolbar content.
+ */
+#toolbar div.toolbar-drawer {
+ position: relative;
+ padding: 0 10px;
+}
diff --git a/core/modules/toolbar/toolbar.info b/core/modules/toolbar/toolbar.info
new file mode 100644
index 000000000000..758dc9c6e906
--- /dev/null
+++ b/core/modules/toolbar/toolbar.info
@@ -0,0 +1,5 @@
+name = Toolbar
+description = Provides a toolbar that shows the top-level administration menu items and links from other modules.
+core = 8.x
+package = Core
+version = VERSION
diff --git a/core/modules/toolbar/toolbar.js b/core/modules/toolbar/toolbar.js
new file mode 100644
index 000000000000..5b61634bbdee
--- /dev/null
+++ b/core/modules/toolbar/toolbar.js
@@ -0,0 +1,106 @@
+(function ($) {
+
+Drupal.toolbar = Drupal.toolbar || {};
+
+/**
+ * Attach toggling behavior and notify the overlay of the toolbar.
+ */
+Drupal.behaviors.toolbar = {
+ attach: function(context) {
+
+ // Set the initial state of the toolbar.
+ $('#toolbar', context).once('toolbar', Drupal.toolbar.init);
+
+ // Toggling toolbar drawer.
+ $('#toolbar a.toggle', context).once('toolbar-toggle').click(function(e) {
+ Drupal.toolbar.toggle();
+ // Allow resize event handlers to recalculate sizes/positions.
+ $(window).triggerHandler('resize');
+ return false;
+ });
+ }
+};
+
+/**
+ * Retrieve last saved cookie settings and set up the initial toolbar state.
+ */
+Drupal.toolbar.init = function() {
+ // Retrieve the collapsed status from a stored cookie.
+ var collapsed = $.cookie('Drupal.toolbar.collapsed');
+
+ // Expand or collapse the toolbar based on the cookie value.
+ if (collapsed == 1) {
+ Drupal.toolbar.collapse();
+ }
+ else {
+ Drupal.toolbar.expand();
+ }
+};
+
+/**
+ * Collapse the toolbar.
+ */
+Drupal.toolbar.collapse = function() {
+ var toggle_text = Drupal.t('Show shortcuts');
+ $('#toolbar div.toolbar-drawer').addClass('collapsed');
+ $('#toolbar a.toggle')
+ .removeClass('toggle-active')
+ .attr('title', toggle_text)
+ .html(toggle_text);
+ $('body').removeClass('toolbar-drawer').css('paddingTop', Drupal.toolbar.height());
+ $.cookie(
+ 'Drupal.toolbar.collapsed',
+ 1,
+ {
+ path: Drupal.settings.basePath,
+ // The cookie should "never" expire.
+ expires: 36500
+ }
+ );
+};
+
+/**
+ * Expand the toolbar.
+ */
+Drupal.toolbar.expand = function() {
+ var toggle_text = Drupal.t('Hide shortcuts');
+ $('#toolbar div.toolbar-drawer').removeClass('collapsed');
+ $('#toolbar a.toggle')
+ .addClass('toggle-active')
+ .attr('title', toggle_text)
+ .html(toggle_text);
+ $('body').addClass('toolbar-drawer').css('paddingTop', Drupal.toolbar.height());
+ $.cookie(
+ 'Drupal.toolbar.collapsed',
+ 0,
+ {
+ path: Drupal.settings.basePath,
+ // The cookie should "never" expire.
+ expires: 36500
+ }
+ );
+};
+
+/**
+ * Toggle the toolbar.
+ */
+Drupal.toolbar.toggle = function() {
+ if ($('#toolbar div.toolbar-drawer').hasClass('collapsed')) {
+ Drupal.toolbar.expand();
+ }
+ else {
+ Drupal.toolbar.collapse();
+ }
+};
+
+Drupal.toolbar.height = function() {
+ var height = $('#toolbar').outerHeight();
+ // In IE, Shadow filter adds some extra height, so we need to remove it from
+ // the returned height.
+ if ($('#toolbar').css('filter').match(/DXImageTransform\.Microsoft\.Shadow/)) {
+ height -= $('#toolbar').get(0).filters.item("DXImageTransform.Microsoft.Shadow").strength;
+ }
+ return height;
+};
+
+})(jQuery);
diff --git a/core/modules/toolbar/toolbar.module b/core/modules/toolbar/toolbar.module
new file mode 100644
index 000000000000..d25ca2d0d843
--- /dev/null
+++ b/core/modules/toolbar/toolbar.module
@@ -0,0 +1,351 @@
+<?php
+
+/**
+ * @file
+ * Administration toolbar for quick access to top level administration items.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function toolbar_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#toolbar':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Toolbar module displays links to top-level administration menu items and links from other modules at the top of the screen. For more information, see the online handbook entry for <a href="@toolbar">Toolbar module</a>.', array('@toolbar' => 'http://drupal.org/handbook/modules/toolbar/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Displaying administrative links') . '</dt>';
+ $output .= '<dd>' . t('The Toolbar module displays a bar containing top-level administrative links across the top of the screen. Below that, the Toolbar module has a <em>drawer</em> section where it displays links provided by other modules, such as the core <a href="@shortcuts-help">Shortcut module</a>. The drawer can be hidden/shown by using the show/hide shortcuts link at the end of the toolbar.', array('@shortcuts-help' => url('admin/help/shortcut'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function toolbar_permission() {
+ return array(
+ 'access toolbar' => array(
+ 'title' => t('Use the administration toolbar'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_theme().
+ */
+function toolbar_theme($existing, $type, $theme, $path) {
+ $items['toolbar'] = array(
+ 'render element' => 'toolbar',
+ 'template' => 'toolbar',
+ 'path' => drupal_get_path('module', 'toolbar'),
+ );
+ $items['toolbar_toggle'] = array(
+ 'variables' => array(
+ 'collapsed' => NULL,
+ 'attributes' => array(),
+ ),
+ );
+ return $items;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function toolbar_menu() {
+ $items['toolbar/toggle'] = array(
+ 'title' => 'Toggle drawer visibility',
+ 'type' => MENU_CALLBACK,
+ 'page callback' => 'toolbar_toggle_page',
+ 'access arguments' => array('access toolbar'),
+ );
+ return $items;
+}
+
+/**
+ * Menu callback; toggles the visibility of the toolbar drawer.
+ */
+function toolbar_toggle_page() {
+ global $base_path;
+ // Toggle the value in the cookie.
+ setcookie('Drupal.toolbar.collapsed', !_toolbar_is_collapsed(), NULL, $base_path);
+ // Redirect the user from where he used the toggle element.
+ drupal_goto();
+}
+
+/**
+ * Formats an element used to toggle the toolbar drawer's visibility.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - collapsed: A boolean value representing the toolbar drawer's visibility.
+ * - attributes: An associative array of HTML attributes.
+ * @return
+ * An HTML string representing the element for toggling.
+ *
+ * @ingroup themable
+ */
+function theme_toolbar_toggle($variables) {
+ if ($variables['collapsed']) {
+ $toggle_text = t('Show shortcuts');
+ }
+ else {
+ $toggle_text = t('Hide shortcuts');
+ $variables['attributes']['class'][] = 'toggle-active';
+ }
+ return l($toggle_text, 'toolbar/toggle', array('query' => drupal_get_destination(), 'attributes' => array('title' => $toggle_text) + $variables['attributes']));
+}
+
+/**
+ * Determines the current state of the toolbar drawer's visibility.
+ *
+ * @return
+ * TRUE when drawer is collapsed, FALSE when it is expanded.
+ */
+function _toolbar_is_collapsed() {
+ // PHP converts dots into underscores in cookie names to avoid problems with
+ // its parser, so we use a converted cookie name.
+ return isset($_COOKIE['Drupal_toolbar_collapsed']) ? $_COOKIE['Drupal_toolbar_collapsed'] : 0;
+}
+
+/**
+ * Implements hook_page_build().
+ *
+ * Add admin toolbar to the page_top region automatically.
+ */
+function toolbar_page_build(&$page) {
+ $page['page_top']['toolbar'] = array(
+ '#pre_render' => array('toolbar_pre_render'),
+ '#access' => user_access('access toolbar'),
+ 'toolbar_drawer' => array(),
+ );
+}
+
+/**
+ * Prerender function for the toolbar.
+ *
+ * Since building the toolbar takes some time, it is done just prior to
+ * rendering to ensure that it is built only if it will be displayed.
+ */
+function toolbar_pre_render($toolbar) {
+ $toolbar = array_merge($toolbar, toolbar_view());
+ return $toolbar;
+}
+
+/**
+ * Implements hook_preprocess_html().
+ *
+ * Add some page classes, so global page theming can adjust to the toolbar.
+ */
+function toolbar_preprocess_html(&$vars) {
+ if (isset($vars['page']['page_top']['toolbar']) && user_access('access toolbar')) {
+ $vars['classes_array'][] = 'toolbar';
+ if (!_toolbar_is_collapsed()) {
+ $vars['classes_array'][] = 'toolbar-drawer';
+ }
+ }
+}
+
+/**
+ * Implements hook_preprocess_toolbar().
+ *
+ * Adding the 'overlay-displace-top' class to the toolbar pushes the overlay
+ * down, so it appears below the toolbar.
+ */
+function toolbar_preprocess_toolbar(&$variables) {
+ $variables['classes_array'][] = "overlay-displace-top";
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * Indicate that the 'page_top' region (in which the toolbar will be displayed)
+ * is an overlay supplemental region that should be refreshed whenever its
+ * content is updated.
+ *
+ * This information is provided for any module that might need to use it, not
+ * just the core Overlay module.
+ */
+function toolbar_system_info_alter(&$info, $file, $type) {
+ if ($type == 'theme') {
+ $info['overlay_supplemental_regions'][] = 'page_top';
+ }
+}
+
+/**
+ * Build the admin menu as a structured array ready for drupal_render().
+ */
+function toolbar_view() {
+ global $user;
+
+ $module_path = drupal_get_path('module', 'toolbar');
+ $build = array(
+ '#theme' => 'toolbar',
+ '#attached'=> array(
+ 'js' => array(
+ $module_path . '/toolbar.js',
+ array(
+ 'data' => array('tableHeaderOffset' => 'Drupal.toolbar.height'),
+ 'type' => 'setting'
+ ),
+ ),
+ 'css' => array(
+ $module_path . '/toolbar.css',
+ ),
+ 'library' => array(array('system', 'jquery.cookie')),
+ ),
+ );
+
+ // Retrieve the admin menu from the database.
+ $links = toolbar_menu_navigation_links(toolbar_get_menu_tree());
+ $build['toolbar_menu'] = array(
+ '#theme' => 'links__toolbar_menu',
+ '#links' => $links,
+ '#attributes' => array('id' => 'toolbar-menu'),
+ '#heading' => array('text' => t('Administrative toolbar'), 'level' => 'h2', 'class' => 'element-invisible'),
+ );
+
+ // Add logout & user account links or login link.
+ if ($user->uid) {
+ $links = array(
+ 'account' => array(
+ 'title' => t('Hello <strong>@username</strong>', array('@username' => format_username($user))),
+ 'href' => 'user',
+ 'html' => TRUE,
+ 'attributes' => array('title' => t('User account')),
+ ),
+ 'logout' => array(
+ 'title' => t('Log out'),
+ 'href' => 'user/logout',
+ ),
+ );
+ }
+ else {
+ $links = array(
+ 'login' => array(
+ 'title' => t('Log in'),
+ 'href' => 'user',
+ ),
+ );
+ }
+ $build['toolbar_user'] = array(
+ '#theme' => 'links__toolbar_user',
+ '#links' => $links,
+ '#attributes' => array('id' => 'toolbar-user'),
+ );
+
+ // Add a "home" link.
+ $link = array(
+ 'home' => array(
+ 'title' => '<span class="home-link">Home</span>',
+ 'href' => '<front>',
+ 'html' => TRUE,
+ 'attributes' => array('title' => t('Home')),
+ ),
+ );
+ $build['toolbar_home'] = array(
+ '#theme' => 'links',
+ '#links' => $link,
+ '#attributes' => array('id' => 'toolbar-home'),
+ );
+
+ // Add an anchor to be able to toggle the visibility of the drawer.
+ $build['toolbar_toggle'] = array(
+ '#theme' => 'toolbar_toggle',
+ '#collapsed' => _toolbar_is_collapsed(),
+ '#attributes' => array('class' => array('toggle')),
+ );
+
+ // Prepare the drawer links CSS classes.
+ $toolbar_drawer_classes = array(
+ 'toolbar-drawer',
+ 'clearfix',
+ );
+ if (_toolbar_is_collapsed()) {
+ $toolbar_drawer_classes[] = 'collapsed';
+ }
+ $build['toolbar_drawer']['#type'] = 'container';
+ $build['toolbar_drawer']['#attributes']['class'] = $toolbar_drawer_classes;
+
+ return $build;
+}
+
+/**
+ * Get only the top level items below the 'admin' path.
+ */
+function toolbar_get_menu_tree() {
+ $tree = array();
+ $admin_link = db_query('SELECT * FROM {menu_links} WHERE menu_name = :menu_name AND module = :module AND link_path = :path', array(':menu_name' => 'management', ':module' => 'system', ':path' => 'admin'))->fetchAssoc();
+ if ($admin_link) {
+ $tree = menu_build_tree('management', array(
+ 'expanded' => array($admin_link['mlid']),
+ 'min_depth' => $admin_link['depth'] + 1,
+ 'max_depth' => $admin_link['depth'] + 1,
+ ));
+ }
+
+ return $tree;
+}
+
+/**
+ * Generate a links array from a menu tree array.
+ *
+ * Based on menu_navigation_links(). Adds path based IDs and icon placeholders
+ * to the links.
+ */
+function toolbar_menu_navigation_links($tree) {
+ $links = array();
+ foreach ($tree as $item) {
+ if (!$item['link']['hidden'] && $item['link']['access']) {
+ // Make sure we have a path specific ID in place, so we can attach icons
+ // and behaviors to the items.
+ $id = str_replace(array('/', '<', '>'), array('-', '', ''), $item['link']['href']);
+
+ $link = $item['link']['localized_options'];
+ $link['href'] = $item['link']['href'];
+ // Add icon placeholder.
+ $link['title'] = '<span class="icon"></span>' . check_plain($item['link']['title']);
+ // Add admin link ID.
+ $link['attributes'] = array('id' => 'toolbar-link-' . $id);
+ if (!empty($item['link']['description'])) {
+ $link['title'] .= ' <span class="element-invisible">(' . $item['link']['description'] . ')</span>';
+ $link['attributes']['title'] = $item['link']['description'];
+ }
+ $link['html'] = TRUE;
+
+ $class = ' path-' . $id;
+ if (toolbar_in_active_trail($item['link']['href'])) {
+ $class .= ' active-trail';
+ }
+ $links['menu-' . $item['link']['mlid'] . $class] = $link;
+ }
+ }
+ return $links;
+}
+
+/**
+ * Checks whether an item is in the active trail.
+ *
+ * Useful when using a menu generated by menu_tree_all_data() which does
+ * not set the 'in_active_trail' flag on items.
+ *
+ * @todo
+ * Look at migrating to a menu system level function.
+ */
+function toolbar_in_active_trail($path) {
+ $active_paths = &drupal_static(__FUNCTION__);
+
+ // Gather active paths.
+ if (!isset($active_paths)) {
+ $active_paths = array();
+ $trail = menu_get_active_trail();
+ foreach ($trail as $item) {
+ if (!empty($item['href'])) {
+ $active_paths[] = $item['href'];
+ }
+ }
+ }
+ return in_array($path, $active_paths);
+}
diff --git a/core/modules/toolbar/toolbar.png b/core/modules/toolbar/toolbar.png
new file mode 100644
index 000000000000..f2c7f355a6d9
--- /dev/null
+++ b/core/modules/toolbar/toolbar.png
Binary files differ
diff --git a/core/modules/toolbar/toolbar.tpl.php b/core/modules/toolbar/toolbar.tpl.php
new file mode 100644
index 000000000000..342fa603f1bc
--- /dev/null
+++ b/core/modules/toolbar/toolbar.tpl.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * Default template for admin toolbar.
+ *
+ * Available variables:
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default value has the following:
+ * - toolbar: The current template type, i.e., "theming hook".
+ * - $toolbar['toolbar_user']: User account / logout links.
+ * - $toolbar['toolbar_menu']: Top level management menu links.
+ * - $toolbar['toolbar_drawer']: A place for extended toolbar content.
+ *
+ * Other variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_toolbar()
+ */
+?>
+<div id="toolbar" class="<?php print $classes; ?> clearfix">
+ <div class="toolbar-menu clearfix">
+ <?php print render($toolbar['toolbar_home']); ?>
+ <?php print render($toolbar['toolbar_user']); ?>
+ <?php print render($toolbar['toolbar_menu']); ?>
+ <?php if ($toolbar['toolbar_drawer']):?>
+ <?php print render($toolbar['toolbar_toggle']); ?>
+ <?php endif; ?>
+ </div>
+
+ <?php print render($toolbar['toolbar_drawer']); ?>
+</div>
diff --git a/core/modules/tracker/tracker.css b/core/modules/tracker/tracker.css
new file mode 100644
index 000000000000..d3531c469ff4
--- /dev/null
+++ b/core/modules/tracker/tracker.css
@@ -0,0 +1,7 @@
+
+.page-tracker td.replies {
+ text-align: center;
+}
+.page-tracker table {
+ width: 100%;
+}
diff --git a/core/modules/tracker/tracker.info b/core/modules/tracker/tracker.info
new file mode 100644
index 000000000000..a765504d21ab
--- /dev/null
+++ b/core/modules/tracker/tracker.info
@@ -0,0 +1,7 @@
+name = Tracker
+description = Enables tracking of recent content for users.
+dependencies[] = comment
+package = Core
+version = VERSION
+core = 8.x
+files[] = tracker.test
diff --git a/core/modules/tracker/tracker.install b/core/modules/tracker/tracker.install
new file mode 100644
index 000000000000..cfe8dc7b61fa
--- /dev/null
+++ b/core/modules/tracker/tracker.install
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * Implements hook_uninstall().
+ */
+function tracker_uninstall() {
+ variable_del('tracker_index_nid');
+ variable_del('tracker_batch_size');
+}
+
+/**
+ * Implements hook_enable().
+ */
+function tracker_enable() {
+ $max_nid = db_query('SELECT MAX(nid) FROM {node}')->fetchField();
+ if ($max_nid != 0) {
+ variable_set('tracker_index_nid', $max_nid);
+ // To avoid timing out while attempting to do a complete indexing, we
+ // simply call our cron job to remove stale records and begin the process.
+ tracker_cron();
+ }
+}
+
+/**
+ * Implements hook_schema().
+ */
+function tracker_schema() {
+ $schema['tracker_node'] = array(
+ 'description' => 'Tracks when nodes were last changed or commented on.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record tracks.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'published' => array(
+ 'description' => 'Boolean indicating whether the node is published.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'changed' => array(
+ 'description' => 'The Unix timestamp when the node was most recently saved or commented on.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'tracker' => array('published', 'changed'),
+ ),
+ 'primary key' => array('nid'),
+ 'foreign keys' => array(
+ 'tracked_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ ),
+ );
+
+ $schema['tracker_user'] = array(
+ 'description' => 'Tracks when nodes were last changed or commented on, for each user that authored the node or one of its comments.',
+ 'fields' => array(
+ 'nid' => array(
+ 'description' => 'The {node}.nid this record tracks.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'uid' => array(
+ 'description' => 'The {users}.uid of the node author or commenter.',
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ 'published' => array(
+ 'description' => 'Boolean indicating whether the node is published.',
+ 'type' => 'int',
+ 'not null' => FALSE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ ),
+ 'changed' => array(
+ 'description' => 'The Unix timestamp when the node was most recently saved or commented on.',
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ ),
+ ),
+ 'indexes' => array(
+ 'tracker' => array('uid', 'published', 'changed'),
+ ),
+ 'primary key' => array('nid', 'uid'),
+ 'foreign keys' => array(
+ 'tracked_node' => array(
+ 'table' => 'node',
+ 'columns' => array('nid' => 'nid'),
+ ),
+ 'tracked_user' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
diff --git a/core/modules/tracker/tracker.module b/core/modules/tracker/tracker.module
new file mode 100644
index 000000000000..227cf7209a36
--- /dev/null
+++ b/core/modules/tracker/tracker.module
@@ -0,0 +1,370 @@
+<?php
+
+/**
+ * @file
+ * Enables tracking of recent content for users.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function tracker_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#tracker':
+ $output = '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Tracker module displays the most recently added and updated content on your site, and allows you to follow new content created by each user. This module has no configuration options. For more information, see the online handbook entry for <a href="@tracker">Tracker module</a>.', array('@tracker' => 'http://drupal.org/handbook/modules/tracker/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Navigation') . '</dt>';
+ $output .= '<dd>' . t('The Tracker module adds a new menu item to the Navigation menu, called <em>Recent content</em>. You can configure menu items via the <a href="@menus">Menus administration page</a>.', array('@menus' => url('admin/structure/menu'))) . '</dd>';
+ $output .= '<dt>' . t('Tracking new and updated site content') . '</dt>';
+ $output .= '<dd>' . t("The <a href='@recent'>Recent content</a> page shows new and updated content in reverse chronological order, listing the content type, title, author's name, number of comments, and time of last update. Content is considered updated when changes occur in the text, or when new comments are added. The <em>My recent content</em> tab limits the list to the currently logged-in user.", array('@recent' => url('tracker'))) . '</dd>';
+ $output .= '<dt>' . t('Tracking user-specific content') . '</dt>';
+ $output .= '<dd>' . t("To follow a specific user's new and updated content, select the <em>Track</em> tab from the user's profile page.") . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function tracker_menu() {
+ $items['tracker'] = array(
+ 'title' => 'Recent content',
+ 'page callback' => 'tracker_page',
+ 'access arguments' => array('access content'),
+ 'weight' => 1,
+ 'file' => 'tracker.pages.inc',
+ );
+ $items['tracker/all'] = array(
+ 'title' => 'All recent content',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['tracker/%user_uid_optional'] = array(
+ 'title' => 'My recent content',
+ 'page callback' => 'tracker_page',
+ 'access callback' => '_tracker_myrecent_access',
+ 'access arguments' => array(1),
+ 'page arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'tracker.pages.inc',
+ );
+
+ $items['user/%user/track'] = array(
+ 'title' => 'Track',
+ 'page callback' => 'tracker_page',
+ 'page arguments' => array(1, TRUE),
+ 'access callback' => '_tracker_user_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'tracker.pages.inc',
+ );
+ $items['user/%user/track/content'] = array(
+ 'title' => 'Track content',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_cron().
+ */
+function tracker_cron() {
+ $max_nid = variable_get('tracker_index_nid', 0);
+ $batch_size = variable_get('tracker_batch_size', 1000);
+ if ($max_nid > 0) {
+ $last_nid = FALSE;
+ $result = db_query_range('SELECT nid, uid, status FROM {node} WHERE nid <= :max_nid ORDER BY nid DESC', 0, $batch_size, array(':max_nid' => $max_nid), array('target' => 'slave'));
+
+ $count = 0;
+
+ foreach ($result as $row) {
+ // Calculate the changed timestamp for this node.
+ $changed = _tracker_calculate_changed($row->nid);
+
+ // Remove existing data for this node.
+ db_delete('tracker_node')
+ ->condition('nid', $row->nid)
+ ->execute();
+ db_delete('tracker_user')
+ ->condition('nid', $row->nid)
+ ->execute();
+
+ // Insert the node-level data.
+ db_insert('tracker_node')
+ ->fields(array(
+ 'nid' => $row->nid,
+ 'published' => $row->status,
+ 'changed' => $changed,
+ ))
+ ->execute();
+
+ // Insert the user-level data for the node's author.
+ db_insert('tracker_user')
+ ->fields(array(
+ 'nid' => $row->nid,
+ 'published' => $row->status,
+ 'changed' => $changed,
+ 'uid' => $row->uid,
+ ))
+ ->execute();
+
+ $query = db_select('comment', 'c', array('target' => 'slave'));
+ // Force PostgreSQL to do an implicit cast by adding 0.
+ $query->addExpression('0 + :changed', 'changed', array(':changed' => $changed));
+ $query->addField('c', 'status', 'published');
+ $query
+ ->distinct()
+ ->fields('c', array('uid', 'nid'))
+ ->condition('c.nid', $row->nid)
+ ->condition('c.uid', $row->uid, '<>')
+ ->condition('c.status', COMMENT_PUBLISHED);
+
+ // Insert the user-level data for the commenters (except if a commenter
+ // is the node's author).
+ db_insert('tracker_user')
+ ->from($query)
+ ->execute();
+
+ // Note that we have indexed at least one node.
+ $last_nid = $row->nid;
+
+ $count++;
+ }
+
+ if ($last_nid !== FALSE) {
+ // Prepare a starting point for the next run.
+ variable_set('tracker_index_nid', $last_nid - 1);
+
+ watchdog('tracker', 'Indexed %count content items for tracking.', array('%count' => $count));
+ }
+ else {
+ // If all nodes have been indexed, set to zero to skip future cron runs.
+ variable_set('tracker_index_nid', 0);
+ }
+ }
+}
+
+/**
+ * Access callback for tracker/%user_uid_optional.
+ */
+function _tracker_myrecent_access($account) {
+ // This path is only allowed for authenticated users looking at their own content.
+ return $account->uid && ($GLOBALS['user']->uid == $account->uid) && user_access('access content');
+}
+
+/**
+ * Access callback for user/%user/track.
+ */
+function _tracker_user_access($account) {
+ return user_view_access($account) && user_access('access content');
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function tracker_node_insert($node, $arg = 0) {
+ _tracker_add($node->nid, $node->uid, $node->changed);
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function tracker_node_update($node, $arg = 0) {
+ _tracker_add($node->nid, $node->uid, $node->changed);
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function tracker_node_delete($node, $arg = 0) {
+ db_delete('tracker_node')
+ ->condition('nid', $node->nid)
+ ->execute();
+ db_delete('tracker_user')
+ ->condition('nid', $node->nid)
+ ->execute();
+}
+
+/**
+ * Implements hook_comment_update().
+ *
+ * Comment module doesn't call hook_comment_unpublish() when saving individual
+ * comments so we need to check for those here.
+ */
+function tracker_comment_update($comment) {
+ // comment_save() calls hook_comment_publish() for all published comments
+ // so we to handle all other values here.
+ if ($comment->status != COMMENT_PUBLISHED) {
+ _tracker_remove($comment->nid, $comment->uid, $comment->changed);
+ }
+}
+
+/**
+ * Implements hook_comment_publish().
+ *
+ * This actually handles the insert and update of published nodes since
+ * comment_save() calls hook_comment_publish() for all published comments.
+ */
+function tracker_comment_publish($comment) {
+ _tracker_add($comment->nid, $comment->uid, $comment->changed);
+}
+
+/**
+ * Implements hook_comment_unpublish().
+ */
+function tracker_comment_unpublish($comment) {
+ _tracker_remove($comment->nid, $comment->uid, $comment->changed);
+}
+
+/**
+ * Implements hook_comment_delete().
+ */
+function tracker_comment_delete($comment) {
+ _tracker_remove($comment->nid, $comment->uid, $comment->changed);
+}
+
+/**
+ * Update indexing tables when a node is added, updated or commented on.
+ *
+ * @param $nid
+ * A node ID.
+ * @param $uid
+ * The node or comment author.
+ * @param $changed
+ * The node updated timestamp or comment timestamp.
+ */
+function _tracker_add($nid, $uid, $changed) {
+ $node = db_query('SELECT nid, status, uid, changed FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetchObject();
+
+ // Adding a comment can only increase the changed timestamp, so our
+ // calculation here is simple.
+ $changed = max($node->changed, $changed);
+
+ // Update the node-level data.
+ db_merge('tracker_node')
+ ->key(array('nid' => $nid))
+ ->fields(array(
+ 'changed' => $changed,
+ 'published' => $node->status,
+ ))
+ ->execute();
+
+ // Create or update the user-level data.
+ db_merge('tracker_user')
+ ->key(array(
+ 'nid' => $nid,
+ 'uid' => $uid,
+ ))
+ ->fields(array(
+ 'changed' => $changed,
+ 'published' => $node->status,
+ ))
+ ->execute();
+}
+
+/**
+ * Determine the max timestamp between $node->changed and the last comment.
+ *
+ * @param $nid
+ * A node ID.
+ *
+ * @return
+ * The $node->changed timestamp, or most recent comment timestamp, whichever
+ * is the greatest.
+ */
+function _tracker_calculate_changed($nid) {
+ $changed = db_query('SELECT changed FROM {node} WHERE nid = :nid', array(':nid' => $nid), array('target' => 'slave'))->fetchField();
+ $latest_comment = db_query_range('SELECT cid, changed FROM {comment} WHERE nid = :nid AND status = :status ORDER BY changed DESC', 0, 1, array(
+ ':nid' => $nid,
+ ':status' => COMMENT_PUBLISHED,
+ ), array('target' => 'slave'))->fetchObject();
+ if ($latest_comment && $latest_comment->changed > $changed) {
+ $changed = $latest_comment->changed;
+ }
+ return $changed;
+}
+
+/**
+ * Clean up indexed data when nodes or comments are removed.
+ *
+ * @param $nid
+ * The node ID.
+ * @param $uid
+ * The author of the node or comment.
+ * @param $changed
+ * The last changed timestamp of the node.
+ */
+function _tracker_remove($nid, $uid = NULL, $changed = NULL) {
+ $node = db_query('SELECT nid, status, uid, changed FROM {node} WHERE nid = :nid', array(':nid' => $nid))->fetchObject();
+
+ // The user only keeps his or her subscription if both of the following are true:
+ // (1) The node exists.
+ // (2) The user is either the node author or has commented on the node.
+ $keep_subscription = FALSE;
+
+ if ($node) {
+ // Self-authorship is one reason to keep the user's subscription.
+ $keep_subscription = ($node->uid == $uid);
+
+ // Comments are a second reason to keep the user's subscription.
+ if (!$keep_subscription) {
+ // Check if the user has commented at least once on the given nid
+ $keep_subscription = db_query_range('SELECT COUNT(*) FROM {comment} WHERE nid = :nid AND uid = :uid AND status = :status', 0, 1, array(
+ ':nid' => $nid,
+ ':uid' => $uid,
+ ':status' => COMMENT_PUBLISHED,
+ ))->fetchField();
+ }
+
+ // If we haven't found a reason to keep the user's subscription, delete it.
+ if (!$keep_subscription) {
+ db_delete('tracker_user')
+ ->condition('nid', $nid)
+ ->condition('uid', $uid)
+ ->execute();
+ }
+
+ // Now we need to update the (possibly) changed timestamps for other users
+ // and the node itself.
+
+ // We only need to do this if the removed item has a timestamp that equals
+ // or exceeds the listed changed timestamp for the node
+ $tracker_node = db_query('SELECT nid, changed FROM {tracker_node} WHERE nid = :nid', array(':nid' => $nid))->fetchObject();
+ if ($tracker_node && $changed >= $tracker_node->changed) {
+ // If we're here, the item being removed is *possibly* the item that
+ // established the node's changed timestamp.
+
+ // We just have to recalculate things from scratch.
+ $changed = _tracker_calculate_changed($nid);
+
+ // And then we push the out the new changed timestamp to our denormalized
+ // tables.
+ db_update('tracker_node')
+ ->fields(array(
+ 'changed' => $changed,
+ 'published' => $node->status,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ db_update('tracker_node')
+ ->fields(array(
+ 'changed' => $changed,
+ 'published' => $node->status,
+ ))
+ ->condition('nid', $nid)
+ ->execute();
+ }
+ }
+ else {
+ // If the node doesn't exist, remove everything.
+ db_delete('tracker_node')
+ ->condition('nid', $nid)
+ ->execute();
+ db_delete('tracker_user')
+ ->condition('nid', $nid)
+ ->execute();
+ }
+}
diff --git a/core/modules/tracker/tracker.pages.inc b/core/modules/tracker/tracker.pages.inc
new file mode 100644
index 000000000000..7b1e946f31d9
--- /dev/null
+++ b/core/modules/tracker/tracker.pages.inc
@@ -0,0 +1,126 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the tracker module.
+ */
+
+
+/**
+ * Menu callback; prints a listing of active nodes on the site.
+ */
+function tracker_page($account = NULL, $set_title = FALSE) {
+ if ($account) {
+ $query = db_select('tracker_user', 't')->extend('PagerDefault');
+ $query->condition('t.uid', $account->uid);
+
+ if ($set_title) {
+ // When viewed from user/%user/track, display the name of the user
+ // as page title -- the tab title remains Track so this needs to be done
+ // here and not in the menu definition.
+ drupal_set_title(format_username($account));
+ }
+ }
+ else {
+ $query = db_select('tracker_node', 't', array('target' => 'slave'))->extend('PagerDefault');
+ }
+
+ // This array acts as a placeholder for the data selected later
+ // while keeping the correct order.
+ $nodes = $query
+ ->addTag('node_access')
+ ->fields('t', array('nid', 'changed'))
+ ->condition('t.published', 1)
+ ->orderBy('t.changed', 'DESC')
+ ->limit(25)
+ ->execute()
+ ->fetchAllAssoc('nid');
+
+ $rows = array();
+ if (!empty($nodes)) {
+ // Now, get the data and put into the placeholder array
+ $result = db_query('SELECT n.nid, n.title, n.type, n.changed, n.uid, u.name, l.comment_count FROM {node} n INNER JOIN {node_comment_statistics} l ON n.nid = l.nid INNER JOIN {users} u ON n.uid = u.uid WHERE n.nid IN (:nids)', array(':nids' => array_keys($nodes)), array('target' => 'slave'));
+ foreach ($result as $node) {
+ $node->last_activity = $nodes[$node->nid]->changed;
+ $nodes[$node->nid] = $node;
+ }
+
+ // Finally display the data
+ foreach ($nodes as $node) {
+ // Determine the number of comments:
+ $comments = 0;
+ if ($node->comment_count) {
+ $comments = $node->comment_count;
+
+ if ($new = comment_num_new($node->nid)) {
+ $comments .= '<br />';
+ $comments .= l(format_plural($new, '1 new', '@count new'), 'node/'. $node->nid, array('fragment' => 'new'));
+ }
+ }
+
+ $row = array(
+ 'type' => check_plain(node_type_get_name($node->type)),
+ 'title' => array('data' => l($node->title, 'node/' . $node->nid) . ' ' . theme('mark', array('type' => node_mark($node->nid, $node->changed)))),
+ 'author' => array('data' => theme('username', array('account' => $node))),
+ 'replies' => array('class' => array('replies'), 'data' => $comments),
+ 'last updated' => array('data' => t('!time ago', array('!time' => format_interval(REQUEST_TIME - $node->last_activity)))),
+ );
+
+ // Adds extra RDFa markup to the $row array if the RDF module is enabled.
+ if (function_exists('rdf_mapping_load')) {
+ // Each node is not loaded for performance reasons, as a result we need
+ // to retrieve the RDF mapping for each node type.
+ $mapping = rdf_mapping_load('node', $node->type);
+ // Adds RDFa markup to the title of the node. Because the RDFa markup is
+ // added to the td tag which might contain HTML code, we specify an
+ // empty datatype to ensure the value of the title read by the RDFa
+ // parsers is a plain literal.
+ $row['title'] += rdf_rdfa_attributes($mapping['title']) + array('datatype' => '');
+ // Annotates the td tag containing the author of the node.
+ $row['author'] += rdf_rdfa_attributes($mapping['uid']);
+ // Annotates the td tag containing the number of replies. We add the
+ // content attribute to ensure that only the comment count is used as
+ // the value for 'num_replies'. Otherwise, other text such as a link
+ // to the number of new comments could be included in the 'num_replies'
+ // value.
+ $row['replies'] += rdf_rdfa_attributes($mapping['comment_count']);
+ $row['replies'] += array('content' => $node->comment_count);
+ // If the node has no comments, we assume the node itself was modified
+ // and apply 'changed' in addition to 'last_activity'. If there are
+ // comments present, we cannot infer whether the node itself was
+ // modified or a comment was posted, so we use only 'last_activity'.
+ $mapping_last_activity = rdf_rdfa_attributes($mapping['last_activity'], $node->last_activity);
+ if ($node->comment_count == 0) {
+ $mapping_changed = rdf_rdfa_attributes($mapping['changed'], $node->last_activity);
+ $mapping_last_activity['property'] = array_merge($mapping_last_activity['property'], $mapping_changed['property']);
+ }
+ $row['last updated'] += $mapping_last_activity;
+
+ // We need to add the about attribute on the tr tag to specify which
+ // node the RDFa annoatations above apply to. We move the content of
+ // $row to a 'data' sub array so we can specify attributes for the row.
+ $row = array('data' => $row);
+ $row['about'] = url('node/' . $node->nid);
+ }
+ $rows[] = $row;
+ }
+ }
+
+ $page['tracker'] = array(
+ '#rows' => $rows,
+ '#header' => array(t('Type'), t('Title'), t('Author'), t('Replies'), t('Last updated')),
+ '#theme' => 'table',
+ '#empty' => t('No content available.'),
+ '#attached' => array(
+ 'css' => array(drupal_get_path('module', 'tracker') . '/tracker.css' => array()),
+ ),
+ );
+ $page['pager'] = array(
+ '#theme' => 'pager',
+ '#quantity' => 25,
+ '#weight' => 10,
+ );
+ $page['#sorted'] = TRUE;
+
+ return $page;
+}
diff --git a/core/modules/tracker/tracker.test b/core/modules/tracker/tracker.test
new file mode 100644
index 000000000000..3cc227eae178
--- /dev/null
+++ b/core/modules/tracker/tracker.test
@@ -0,0 +1,254 @@
+<?php
+
+/**
+ * @file
+ * Tests for tracker.module.
+ */
+
+class TrackerTest extends DrupalWebTestCase {
+ protected $user;
+ protected $other_user;
+ protected $new_node;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Tracker',
+ 'description' => 'Create and delete nodes and check for their display in the tracker listings.',
+ 'group' => 'Tracker'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('comment', 'tracker');
+
+ $permissions = array('access comments', 'create page content', 'post comments', 'skip comment approval');
+ $this->user = $this->drupalCreateUser($permissions);
+ $this->other_user = $this->drupalCreateUser($permissions);
+
+ // Make node preview optional.
+ variable_set('comment_preview_page', 0);
+ }
+
+ /**
+ * Test the presence of nodes on the global tracker listing.
+ */
+ function testTrackerAll() {
+ $this->drupalLogin($this->user);
+
+ $unpublished = $this->drupalCreateNode(array(
+ 'title' =>$this->randomName(8),
+ 'status' => 0,
+ ));
+ $published = $this->drupalCreateNode(array(
+ 'title' => $this->randomName(8),
+ 'status' => 1,
+ ));
+
+ $this->drupalGet('tracker');
+ $this->assertNoText($unpublished->title, t('Unpublished node do not show up in the tracker listing.'));
+ $this->assertText($published->title, t('Published node show up in the tracker listing.'));
+ $this->assertLink(t('My recent content'), 0, t('User tab shows up on the global tracker page.'));
+
+ // Delete a node and ensure it no longer appears on the tracker.
+ node_delete($published->nid);
+ $this->drupalGet('tracker');
+ $this->assertNoText($published->title, t('Deleted node do not show up in the tracker listing.'));
+ }
+
+ /**
+ * Test the presence of nodes on a user's tracker listing.
+ */
+ function testTrackerUser() {
+ $this->drupalLogin($this->user);
+
+ $unpublished = $this->drupalCreateNode(array(
+ 'title' => $this->randomName(8),
+ 'uid' => $this->user->uid,
+ 'status' => 0,
+ ));
+ $my_published = $this->drupalCreateNode(array(
+ 'title' => $this->randomName(8),
+ 'uid' => $this->user->uid,
+ 'status' => 1,
+ ));
+ $other_published_no_comment = $this->drupalCreateNode(array(
+ 'title' => $this->randomName(8),
+ 'uid' => $this->other_user->uid,
+ 'status' => 1,
+ ));
+ $other_published_my_comment = $this->drupalCreateNode(array(
+ 'title' => $this->randomName(8),
+ 'uid' => $this->other_user->uid,
+ 'status' => 1,
+ ));
+ $comment = array(
+ 'subject' => $this->randomName(),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $this->randomName(20),
+ );
+ $this->drupalPost('comment/reply/' . $other_published_my_comment->nid, $comment, t('Save'));
+
+ $this->drupalGet('user/' . $this->user->uid . '/track');
+ $this->assertNoText($unpublished->title, t("Unpublished nodes do not show up in the users's tracker listing."));
+ $this->assertText($my_published->title, t("Published nodes show up in the user's tracker listing."));
+ $this->assertNoText($other_published_no_comment->title, t("Other user's nodes do not show up in the user's tracker listing."));
+ $this->assertText($other_published_my_comment->title, t("Nodes that the user has commented on appear in the user's tracker listing."));
+
+ // Verify that unpublished comments are removed from the tracker.
+ $admin_user = $this->drupalCreateUser(array('administer comments', 'access user profiles'));
+ $this->drupalLogin($admin_user);
+ $this->drupalPost('comment/1/edit', array('status' => COMMENT_NOT_PUBLISHED), t('Save'));
+ $this->drupalGet('user/' . $this->user->uid . '/track');
+ $this->assertNoText($other_published_my_comment->title, 'Unpublished comments are not counted on the tracker listing.');
+ }
+
+ /**
+ * Test the presence of the "new" flag for nodes.
+ */
+ function testTrackerNewNodes() {
+ $this->drupalLogin($this->user);
+
+ $edit = array(
+ 'title' => $this->randomName(8),
+ );
+
+ $node = $this->drupalCreateNode($edit);
+ $title = $edit['title'];
+ $this->drupalGet('tracker');
+ $this->assertPattern('/' . $title . '.*new/', t('New nodes are flagged as such in the tracker listing.'));
+
+ $this->drupalGet('node/' . $node->nid);
+ $this->drupalGet('tracker');
+ $this->assertNoPattern('/' . $title . '.*new/', t('Visited nodes are not flagged as new.'));
+
+ $this->drupalLogin($this->other_user);
+ $this->drupalGet('tracker');
+ $this->assertPattern('/' . $title . '.*new/', t('For another user, new nodes are flagged as such in the tracker listing.'));
+
+ $this->drupalGet('node/' . $node->nid);
+ $this->drupalGet('tracker');
+ $this->assertNoPattern('/' . $title . '.*new/', t('For another user, visited nodes are not flagged as new.'));
+ }
+
+ /**
+ * Test comment counters on the tracker listing.
+ */
+ function testTrackerNewComments() {
+ $this->drupalLogin($this->user);
+
+ $node = $this->drupalCreateNode(array(
+ 'comment' => 2,
+ 'title' => array(LANGUAGE_NONE => array(array('value' => $this->randomName(8)))),
+ ));
+
+ // Add a comment to the page.
+ $comment = array(
+ 'subject' => $this->randomName(),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $this->randomName(20),
+ );
+ $this->drupalPost('comment/reply/' . $node->nid, $comment, t('Save')); // The new comment is automatically viewed by the current user.
+
+ $this->drupalLogin($this->other_user);
+ $this->drupalGet('tracker');
+ $this->assertText('1 new', t('New comments are counted on the tracker listing pages.'));
+ $this->drupalGet('node/' . $node->nid);
+
+ // Add another comment as other_user.
+ $comment = array(
+ 'subject' => $this->randomName(),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $this->randomName(20),
+ );
+ // If the comment is posted in the same second as the last one then Drupal
+ // can't tell a difference, so wait one second here.
+ sleep(1);
+ $this->drupalPost('comment/reply/' . $node->nid, $comment, t('Save'));
+
+ $this->drupalLogin($this->user);
+ $this->drupalGet('tracker');
+ $this->assertText('1 new', t('New comments are counted on the tracker listing pages.'));
+ }
+
+ /**
+ * Test that existing nodes are indexed by cron.
+ */
+ function testTrackerCronIndexing() {
+ $this->drupalLogin($this->user);
+
+ // Create 3 nodes.
+ $edits = array();
+ $nodes = array();
+ for ($i = 1; $i <= 3; $i++) {
+ $edits[$i] = array(
+ 'comment' => 2,
+ 'title' => $this->randomName(),
+ );
+ $nodes[$i] = $this->drupalCreateNode($edits[$i]);
+ }
+
+ // Add a comment to the last node as other user.
+ $this->drupalLogin($this->other_user);
+ $comment = array(
+ 'subject' => $this->randomName(),
+ 'comment_body[' . LANGUAGE_NONE . '][0][value]' => $this->randomName(20),
+ );
+ $this->drupalPost('comment/reply/' . $nodes[3]->nid, $comment, t('Save'));
+
+ // Start indexing backwards from node 3.
+ variable_set('tracker_index_nid', 3);
+
+ // Clear the current tracker tables and rebuild them.
+ db_delete('tracker_node')
+ ->execute();
+ db_delete('tracker_user')
+ ->execute();
+ tracker_cron();
+
+ $this->drupalLogin($this->user);
+
+ // Fetch the user's tracker.
+ $this->drupalGet('tracker/' . $this->user->uid);
+
+ // Assert that all node titles are displayed.
+ foreach ($nodes as $i => $node) {
+ $this->assertText($node->title, t('Node @i is displayed on the tracker listing pages.', array('@i' => $i)));
+ }
+ $this->assertText('1 new', t('New comment is counted on the tracker listing pages.'));
+ $this->assertText('updated', t('Node is listed as updated'));
+
+
+ // Fetch the site-wide tracker.
+ $this->drupalGet('tracker');
+
+ // Assert that all node titles are displayed.
+ foreach ($nodes as $i => $node) {
+ $this->assertText($node->title, t('Node @i is displayed on the tracker listing pages.', array('@i' => $i)));
+ }
+ $this->assertText('1 new', t('New comment is counted on the tracker listing pages.'));
+ }
+
+ /**
+ * Test that publish/unpublish works at admin/content/node
+ */
+ function testTrackerAdminUnpublish() {
+ $admin_user = $this->drupalCreateUser(array('access content overview', 'administer nodes', 'bypass node access'));
+ $this->drupalLogin($admin_user);
+
+ $node = $this->drupalCreateNode(array(
+ 'comment' => 2,
+ 'title' => $this->randomName(),
+ ));
+
+ // Assert that the node is displayed.
+ $this->drupalGet('tracker');
+ $this->assertText($node->title, t('Node is displayed on the tracker listing pages.'));
+
+ // Unpublish the node and ensure that it's no longer displayed.
+ $edit = array(
+ 'operation' => 'unpublish',
+ 'nodes[' . $node->nid . ']' => $node->nid,
+ );
+ $this->drupalPost('admin/content', $edit, t('Update'));
+
+ $this->drupalGet('tracker');
+ $this->assertText(t('No content available.'), t('Node is displayed on the tracker listing pages.'));
+ }
+}
diff --git a/core/modules/translation/tests/translation_test.info b/core/modules/translation/tests/translation_test.info
new file mode 100644
index 000000000000..5b42c5ac6537
--- /dev/null
+++ b/core/modules/translation/tests/translation_test.info
@@ -0,0 +1,6 @@
+name = "Content Translation Test"
+description = "Support module for the content translation tests."
+core = 8.x
+package = Testing
+version = VERSION
+hidden = TRUE
diff --git a/core/modules/translation/tests/translation_test.module b/core/modules/translation/tests/translation_test.module
new file mode 100644
index 000000000000..e3bb4b5ff7d8
--- /dev/null
+++ b/core/modules/translation/tests/translation_test.module
@@ -0,0 +1,13 @@
+<?php
+
+/**
+ * @file
+ * Mock module for content translation tests.
+ */
+
+/**
+ * Implements hook_node_insert().
+ */
+function translation_test_node_insert($node) {
+ drupal_write_record('node', $node, 'nid');
+}
diff --git a/core/modules/translation/translation.info b/core/modules/translation/translation.info
new file mode 100644
index 000000000000..ab6feece5124
--- /dev/null
+++ b/core/modules/translation/translation.info
@@ -0,0 +1,7 @@
+name = Content translation
+description = Allows content to be translated into different languages.
+dependencies[] = locale
+package = Core
+version = VERSION
+core = 8.x
+files[] = translation.test
diff --git a/core/modules/translation/translation.module b/core/modules/translation/translation.module
new file mode 100644
index 000000000000..7d47a6d766b2
--- /dev/null
+++ b/core/modules/translation/translation.module
@@ -0,0 +1,535 @@
+<?php
+
+/**
+ * @file
+ * Manages content translations.
+ *
+ * Translations are managed in sets of posts, which represent the same
+ * information in different languages. Only content types for which the
+ * administrator explicitly enabled translations could have translations
+ * associated. Translations are managed in sets with exactly one source
+ * post per set. The source post is used to translate to different
+ * languages, so if the source post is significantly updated, the
+ * editor can decide to mark all translations outdated.
+ *
+ * The node table stores the values used by this module:
+ * - 'tnid' is the translation set id, which equals the node id
+ * of the source post.
+ * - 'translate' is a flag, either indicating that the translation
+ * is up to date (0) or needs to be updated (1).
+ */
+
+/**
+ * Identifies a content type which has translation support enabled.
+ */
+define('TRANSLATION_ENABLED', 2);
+
+/**
+ * Implements hook_help().
+ */
+function translation_help($path, $arg) {
+ switch ($path) {
+ case 'admin/help#translation':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Content translation module allows content to be translated into different languages. Working with the <a href="@locale">Locale module</a> (which manages enabled languages and provides translation for the site interface), the Content translation module is key to creating and maintaining translated site content. For more information, see the online handbook entry for <a href="@translation">Content translation module</a>.', array('@locale' => url('admin/help/locale'), '@translation' => 'http://drupal.org/handbook/modules/translation/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Configuring content types for translation') . '</dt>';
+ $output .= '<dd>' . t('To configure a particular content type for translation, visit the <a href="@content-types">Content types</a> page, and click the <em>edit</em> link for the content type. In the <em>Publishing options</em> section, select <em>Enabled, with translation</em> under <em>Multilingual support</em>.', array('@content-types' => url('admin/structure/types'))) . '</li></ul><dd>';
+ $output .= '<dt>' . t('Assigning a language to content') . '</dt>';
+ $output .= '<dd>' . t('Use the <em>Language</em> drop down to select the appropriate language when creating or editing content.') . '</dd>';
+ $output .= '<dt>' . t('Translating content') . '</dt>';
+ $output .= '<dd>' . t('Users with the <em>translate content</em> permission can translate content, if the content type has been configured to allow translations. To translate content, select the <em>Translation</em> tab when viewing the content, select the language for which you wish to provide content, and then enter the content.') . '</dd>';
+ $output .= '<dt>' . t('Maintaining translations') . '</dt>';
+ $output .= '<dd>' . t('If editing content in one language requires that translated versions also be updated to reflect the change, use the <em>Flag translations as outdated</em> check box to mark the translations as outdated and in need of revision. Individual translations may also be marked for revision by selecting the <em>This translation needs to be updated</em> check box on the translation editing form.') . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'node/%/translate':
+ $output = '<p>' . t('Translations of a piece of content are managed with translation sets. Each translation set has one source post and any number of translations in any of the <a href="!languages">enabled languages</a>. All translations are tracked to be up to date or outdated based on whether the source post was modified significantly.', array('!languages' => url('admin/config/regional/language'))) . '</p>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function translation_menu() {
+ $items = array();
+ $items['node/%node/translate'] = array(
+ 'title' => 'Translate',
+ 'page callback' => 'translation_node_overview',
+ 'page arguments' => array(1),
+ 'access callback' => '_translation_tab_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 2,
+ 'file' => 'translation.pages.inc',
+ );
+ return $items;
+}
+
+/**
+ * Menu access callback.
+ *
+ * Only display translation tab for node types, which have translation enabled
+ * and where the current node is not language neutral (which should span
+ * all languages).
+ */
+function _translation_tab_access($node) {
+ if ($node->language != LANGUAGE_NONE && translation_supported_type($node->type) && node_access('view', $node)) {
+ return user_access('translate content');
+ }
+ return FALSE;
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function translation_admin_paths() {
+ if (variable_get('node_admin_theme')) {
+ $paths = array(
+ 'node/*/translate' => TRUE,
+ );
+ return $paths;
+ }
+}
+
+/**
+ * Implements hook_permission().
+ */
+function translation_permission() {
+ return array(
+ 'translate content' => array(
+ 'title' => t('Translate content'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ */
+function translation_form_node_type_form_alter(&$form, &$form_state) {
+ // Add translation option to content type form.
+ $form['workflow']['language_content_type']['#options'][TRANSLATION_ENABLED] = t('Enabled, with translation');
+ // Description based on text from locale.module.
+ $form['workflow']['language_content_type']['#description'] = t('Enable multilingual support for this content type. If enabled, a language selection field will be added to the editing form, allowing you to select from one of the <a href="!languages">enabled languages</a>. You can also turn on translation for this content type, which lets you have content translated to any of the installed languages. If disabled, new posts are saved with the default language. Existing content will not be affected by changing this option.', array('!languages' => url('admin/config/regional/language')));
+}
+
+/**
+ * Implements hook_form_BASE_FORM_ID_alter().
+ *
+ * This function alters language fields on node edit forms when a translation is
+ * about to be created.
+ */
+function translation_form_node_form_alter(&$form, &$form_state) {
+ if (translation_supported_type($form['#node']->type)) {
+ $node = $form['#node'];
+ $languages = language_list('enabled');
+ $disabled_languages = isset($languages[0]) ? $languages[0] : FALSE;
+ $translator_widget = $disabled_languages && user_access('translate content');
+ $groups = array(t('Disabled'), t('Enabled'));
+ // Allow translators to enter content in disabled languages. Translators
+ // might need to distinguish between enabled and disabled languages, hence
+ // we divide them in two option groups.
+ if ($translator_widget) {
+ $options = array($groups[1] => array(LANGUAGE_NONE => t('Language neutral')));
+ $language_list = locale_language_list('name', TRUE);
+ foreach (array(1, 0) as $status) {
+ $group = $groups[$status];
+ foreach ($languages[$status] as $langcode => $language) {
+ $options[$group][$langcode] = $language_list[$langcode];
+ }
+ }
+ $form['language']['#options'] = $options;
+ }
+ if (!empty($node->translation_source)) {
+ // We are creating a translation. Add values and lock language field.
+ $form['translation_source'] = array('#type' => 'value', '#value' => $node->translation_source);
+ $form['language']['#disabled'] = TRUE;
+ }
+ elseif (!empty($node->nid) && !empty($node->tnid)) {
+ // Disable languages for existing translations, so it is not possible to switch this
+ // node to some language which is already in the translation set. Also remove the
+ // language neutral option.
+ unset($form['language']['#options'][LANGUAGE_NONE]);
+ foreach (translation_node_get_translations($node->tnid) as $langcode => $translation) {
+ if ($translation->nid != $node->nid) {
+ if ($translator_widget) {
+ $group = $groups[(int)!isset($disabled_languages[$langcode])];
+ unset($form['language']['#options'][$group][$langcode]);
+ }
+ else {
+ unset($form['language']['#options'][$langcode]);
+ }
+ }
+ }
+ // Add translation values and workflow options.
+ $form['tnid'] = array('#type' => 'value', '#value' => $node->tnid);
+ $form['translation'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Translation settings'),
+ '#access' => user_access('translate content'),
+ '#collapsible' => TRUE,
+ '#collapsed' => !$node->translate,
+ '#tree' => TRUE,
+ '#weight' => 30,
+ );
+ if ($node->tnid == $node->nid) {
+ // This is the source node of the translation
+ $form['translation']['retranslate'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Flag translations as outdated'),
+ '#default_value' => 0,
+ '#description' => t('If you made a significant change, which means translations should be updated, you can flag all translations of this post as outdated. This will not change any other property of those posts, like whether they are published or not.'),
+ );
+ $form['translation']['status'] = array('#type' => 'value', '#value' => 0);
+ }
+ else {
+ $form['translation']['status'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('This translation needs to be updated'),
+ '#default_value' => $node->translate,
+ '#description' => t('When this option is checked, this translation needs to be updated because the source post has changed. Uncheck when the translation is up to date again.'),
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_view().
+ *
+ * Display translation links with language names, if this node is part of
+ * a translation set. If no language provider is enabled "fall back" to the
+ * simple links built through the result of translation_node_get_translations().
+ */
+function translation_node_view($node, $view_mode) {
+ // If the site has no translations or is not multilingual we have no content
+ // translation links to display.
+ if (isset($node->tnid) && drupal_multilingual() && $translations = translation_node_get_translations($node->tnid)) {
+ $languages = language_list('enabled');
+ $languages = $languages[1];
+
+ // There might be a language provider enabled defining custom language
+ // switch links which need to be taken into account while generating the
+ // content translation links. As custom language switch links are available
+ // only for configurable language types and interface language is the only
+ // configurable language type in core, we use it as default. Contributed
+ // modules can change this behavior by setting the system variable below.
+ $type = variable_get('translation_language_type', LANGUAGE_TYPE_INTERFACE);
+ $custom_links = language_negotiation_get_switch_links($type, "node/$node->nid");
+ $links = array();
+
+ foreach ($translations as $langcode => $translation) {
+ // Do not show links to the same node, to unpublished translations or to
+ // translations in disabled languages.
+ if ($translation->status && isset($languages[$langcode]) && $langcode != $node->language) {
+ $language = $languages[$langcode];
+ $key = "translation_$langcode";
+
+ if (isset($custom_links->links[$langcode])) {
+ $links[$key] = $custom_links->links[$langcode];
+ }
+ else {
+ $links[$key] = array(
+ 'href' => "node/{$translation->nid}",
+ 'title' => $language->name,
+ 'language' => $language,
+ );
+ }
+
+ // Custom switch links are more generic than content translation links,
+ // hence we override existing attributes with the ones below.
+ $links[$key] += array('attributes' => array());
+ $attributes = array(
+ 'title' => $translation->title,
+ 'class' => array('translation-link'),
+ );
+ $links[$key]['attributes'] = $attributes + $links[$key]['attributes'];
+ }
+ }
+
+ $node->content['links']['translation'] = array(
+ '#theme' => 'links__node__translation',
+ '#links' => $links,
+ '#attributes' => array('class' => array('links', 'inline')),
+ );
+ }
+}
+
+/**
+ * Implements hook_node_prepare().
+ */
+function translation_node_prepare($node) {
+ // Only act if we are dealing with a content type supporting translations.
+ if (translation_supported_type($node->type) &&
+ // And it's a new node.
+ empty($node->nid) &&
+ // And the user has permission to translate content.
+ user_access('translate content') &&
+ // And the $_GET variables are set properly.
+ isset($_GET['translation']) &&
+ isset($_GET['target']) &&
+ is_numeric($_GET['translation'])) {
+
+ $source_node = node_load($_GET['translation']);
+ if (empty($source_node) || !node_access('view', $source_node)) {
+ // Source node not found or no access to view. We should not check
+ // for edit access, since the translator might not have permissions
+ // to edit the source node but should still be able to translate.
+ return;
+ }
+
+ $language_list = language_list();
+ $langcode = $_GET['target'];
+ if (!isset($language_list[$langcode]) || ($source_node->language == $langcode)) {
+ // If not supported language, or same language as source node, break.
+ return;
+ }
+
+ // Ensure we don't have an existing translation in this language.
+ if (!empty($source_node->tnid)) {
+ $translations = translation_node_get_translations($source_node->tnid);
+ if (isset($translations[$langcode])) {
+ drupal_set_message(t('A translation of %title in %language already exists, a new %type will be created instead of a translation.', array('%title' => $source_node->title, '%language' => $language_list[$langcode]->name, '%type' => $node->type)), 'error');
+ return;
+ }
+ }
+
+ // Populate fields based on source node.
+ $node->language = $langcode;
+ $node->translation_source = $source_node;
+ $node->title = $source_node->title;
+
+ // Add field translations and let other modules module add custom translated
+ // fields.
+ field_attach_prepare_translation('node', $node, $node->language, $source_node, $source_node->language);
+ }
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function translation_node_insert($node) {
+ // Only act if we are dealing with a content type supporting translations.
+ if (translation_supported_type($node->type)) {
+ if (!empty($node->translation_source)) {
+ if ($node->translation_source->tnid) {
+ // Add node to existing translation set.
+ $tnid = $node->translation_source->tnid;
+ }
+ else {
+ // Create new translation set, using nid from the source node.
+ $tnid = $node->translation_source->nid;
+ db_update('node')
+ ->fields(array(
+ 'tnid' => $tnid,
+ 'translate' => 0,
+ ))
+ ->condition('nid', $node->translation_source->nid)
+ ->execute();
+ }
+ db_update('node')
+ ->fields(array(
+ 'tnid' => $tnid,
+ 'translate' => 0,
+ ))
+ ->condition('nid', $node->nid)
+ ->execute();
+ // Save tnid to avoid loss in case of resave.
+ $node->tnid = $tnid;
+ }
+ }
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function translation_node_update($node) {
+ // Only act if we are dealing with a content type supporting translations.
+ if (translation_supported_type($node->type)) {
+ if (isset($node->translation) && $node->translation && !empty($node->language) && $node->tnid) {
+ // Update translation information.
+ db_update('node')
+ ->fields(array(
+ 'tnid' => $node->tnid,
+ 'translate' => $node->translation['status'],
+ ))
+ ->condition('nid', $node->nid)
+ ->execute();
+ if (!empty($node->translation['retranslate'])) {
+ // This is the source node, asking to mark all translations outdated.
+ db_update('node')
+ ->fields(array('translate' => 1))
+ ->condition('nid', $node->nid, '<>')
+ ->condition('tnid', $node->tnid)
+ ->execute();
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_node_validate().
+ *
+ * Ensure that duplicate translations can not be created for the same source.
+ */
+function translation_node_validate($node, $form) {
+ // Only act on translatable nodes with a tnid or translation_source.
+ if (translation_supported_type($node->type) && (!empty($node->tnid) || !empty($form['#node']->translation_source->nid))) {
+ $tnid = !empty($node->tnid) ? $node->tnid : $form['#node']->translation_source->nid;
+ $translations = translation_node_get_translations($tnid);
+ if (isset($translations[$node->language]) && $translations[$node->language]->nid != $node->nid ) {
+ form_set_error('language', t('There is already a translation in this language.'));
+ }
+ }
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function translation_node_delete($node) {
+ // Only act if we are dealing with a content type supporting translations.
+ if (translation_supported_type($node->type)) {
+ translation_remove_from_set($node);
+ }
+}
+
+/**
+ * Remove a node from its translation set (if any)
+ * and update the set accordingly.
+ */
+function translation_remove_from_set($node) {
+ if (isset($node->tnid)) {
+ $query = db_update('node')
+ ->fields(array(
+ 'tnid' => 0,
+ 'translate' => 0,
+ ));
+ if (db_query('SELECT COUNT(*) FROM {node} WHERE tnid = :tnid', array(':tnid' => $node->tnid))->fetchField() == 1) {
+ // There is only one node left in the set: remove the set altogether.
+ $query
+ ->condition('tnid', $node->tnid)
+ ->execute();
+ }
+ else {
+ $query
+ ->condition('nid', $node->nid)
+ ->execute();
+
+ // If the node being removed was the source of the translation set,
+ // we pick a new source - preferably one that is up to date.
+ if ($node->tnid == $node->nid) {
+ $new_tnid = db_query('SELECT nid FROM {node} WHERE tnid = :tnid ORDER BY translate ASC, nid ASC', array(':tnid' => $node->tnid))->fetchField();
+ db_update('node')
+ ->fields(array('tnid' => $new_tnid))
+ ->condition('tnid', $node->tnid)
+ ->execute();
+ }
+ }
+ }
+}
+
+/**
+ * Get all nodes in a translation set, represented by $tnid.
+ *
+ * @param $tnid
+ * The translation source nid of the translation set, the identifier
+ * of the node used to derive all translations in the set.
+ * @return
+ * Array of partial node objects (nid, title, language) representing
+ * all nodes in the translation set, in effect all translations
+ * of node $tnid, including node $tnid itself. Because these are
+ * partial nodes, you need to node_load() the full node, if you
+ * need more properties. The array is indexed by language code.
+ */
+function translation_node_get_translations($tnid) {
+ if (is_numeric($tnid) && $tnid) {
+ $translations = &drupal_static(__FUNCTION__, array());
+
+ if (!isset($translations[$tnid])) {
+ $translations[$tnid] = array();
+ $result = db_select('node', 'n')
+ ->fields('n', array('nid', 'type', 'uid', 'status', 'title', 'language'))
+ ->condition('n.tnid', $tnid)
+ ->addTag('node_access')
+ ->execute();
+
+ foreach ($result as $node) {
+ $translations[$tnid][$node->language] = $node;
+ }
+ }
+ return $translations[$tnid];
+ }
+}
+
+/**
+ * Returns whether the given content type has support for translations.
+ *
+ * @return
+ * Boolean value.
+ */
+function translation_supported_type($type) {
+ return variable_get('language_content_type_' . $type, 0) == TRANSLATION_ENABLED;
+}
+
+/**
+ * Return paths of all translations of a node, based on
+ * its Drupal path.
+ *
+ * @param $path
+ * A Drupal path, for example node/432.
+ * @return
+ * An array of paths of translations of the node accessible
+ * to the current user keyed with language codes.
+ */
+function translation_path_get_translations($path) {
+ $paths = array();
+ // Check for a node related path, and for its translations.
+ if ((preg_match("!^node/(\d+)(/.+|)$!", $path, $matches)) && ($node = node_load((int) $matches[1])) && !empty($node->tnid)) {
+ foreach (translation_node_get_translations($node->tnid) as $language => $translation_node) {
+ $paths[$language] = 'node/' . $translation_node->nid . $matches[2];
+ }
+ }
+ return $paths;
+}
+
+/**
+ * Implements hook_language_switch_links_alter().
+ *
+ * Replaces links with pointers to translated versions of the content.
+ */
+function translation_language_switch_links_alter(array &$links, $type, $path) {
+ $language_type = variable_get('translation_language_type', LANGUAGE_TYPE_INTERFACE);
+
+ if ($type == $language_type && preg_match("!^node/(\d+)(/.+|)!", $path, $matches)) {
+ $node = node_load((int) $matches[1]);
+
+ if (empty($node->tnid)) {
+ // If the node cannot be found nothing needs to be done. If it does not
+ // have translations it might be a language neutral node, in which case we
+ // must leave the language switch links unaltered. This is true also for
+ // nodes not having translation support enabled.
+ if (empty($node) || $node->language == LANGUAGE_NONE || !translation_supported_type($node->type)) {
+ return;
+ }
+ $translations = array($node->language => $node);
+ }
+ else {
+ $translations = translation_node_get_translations($node->tnid);
+ }
+
+ foreach ($links as $langcode => $link) {
+ if (isset($translations[$langcode]) && $translations[$langcode]->status) {
+ // Translation in a different node.
+ $links[$langcode]['href'] = 'node/' . $translations[$langcode]->nid . $matches[2];
+ }
+ else {
+ // No translation in this language, or no permission to view.
+ unset($links[$langcode]['href']);
+ $links[$langcode]['attributes']['class'] = 'locale-untranslated';
+ }
+ }
+ }
+}
diff --git a/core/modules/translation/translation.pages.inc b/core/modules/translation/translation.pages.inc
new file mode 100644
index 000000000000..8c09850adc53
--- /dev/null
+++ b/core/modules/translation/translation.pages.inc
@@ -0,0 +1,77 @@
+<?php
+
+/**
+ * @file
+ * User page callbacks for the translation module.
+ */
+
+/**
+ * Overview page for a node's translations.
+ *
+ * @param $node
+ * Node object.
+ */
+function translation_node_overview($node) {
+ include_once DRUPAL_ROOT . '/core/includes/language.inc';
+
+ if ($node->tnid) {
+ // Already part of a set, grab that set.
+ $tnid = $node->tnid;
+ $translations = translation_node_get_translations($node->tnid);
+ }
+ else {
+ // We have no translation source nid, this could be a new set, emulate that.
+ $tnid = $node->nid;
+ $translations = array($node->language => $node);
+ }
+
+ $type = variable_get('translation_language_type', LANGUAGE_TYPE_INTERFACE);
+ $header = array(t('Language'), t('Title'), t('Status'), t('Operations'));
+
+ foreach (language_list() as $langcode => $language) {
+ $options = array();
+ $language_name = $language->name;
+ if (isset($translations[$langcode])) {
+ // Existing translation in the translation set: display status.
+ // We load the full node to check whether the user can edit it.
+ $translation_node = node_load($translations[$langcode]->nid);
+ $path = 'node/' . $translation_node->nid;
+ $links = language_negotiation_get_switch_links($type, $path);
+ $title = empty($links->links[$langcode]['href']) ? l($translation_node->title, $path) : l($translation_node->title, $links->links[$langcode]['href'], $links->links[$langcode]);
+ if (node_access('update', $translation_node)) {
+ $text = t('edit');
+ $path = 'node/' . $translation_node->nid . '/edit';
+ $links = language_negotiation_get_switch_links($type, $path);
+ $options[] = empty($links->links[$langcode]['href']) ? l($text, $path) : l($text, $links->links[$langcode]['href'], $links->links[$langcode]);
+ }
+ $status = $translation_node->status ? t('Published') : t('Not published');
+ $status .= $translation_node->translate ? ' - <span class="marker">' . t('outdated') . '</span>' : '';
+ if ($translation_node->nid == $tnid) {
+ $language_name = t('<strong>@language_name</strong> (source)', array('@language_name' => $language_name));
+ }
+ }
+ else {
+ // No such translation in the set yet: help user to create it.
+ $title = t('n/a');
+ if (node_access('create', $node)) {
+ $text = t('add translation');
+ $path = 'node/add/' . str_replace('_', '-', $node->type);
+ $links = language_negotiation_get_switch_links($type, $path);
+ $query = array('query' => array('translation' => $node->nid, 'target' => $langcode));
+ $options[] = empty($links->links[$langcode]['href']) ? l($text, $path, $query) : l($text, $links->links[$langcode]['href'], array_merge_recursive($links->links[$langcode], $query));
+ }
+ $status = t('Not translated');
+ }
+ $rows[] = array($language_name, $title, $status, implode(" | ", $options));
+ }
+
+ drupal_set_title(t('Translations of %title', array('%title' => $node->title)), PASS_THROUGH);
+
+ $build['translation_node_overview'] = array(
+ '#theme' => 'table',
+ '#header' => $header,
+ '#rows' => $rows,
+ );
+
+ return $build;
+}
diff --git a/core/modules/translation/translation.test b/core/modules/translation/translation.test
new file mode 100644
index 000000000000..c8a1ccdbbae9
--- /dev/null
+++ b/core/modules/translation/translation.test
@@ -0,0 +1,468 @@
+<?php
+
+/**
+ * @file
+ * Tests for translation.module
+ */
+
+class TranslationTestCase extends DrupalWebTestCase {
+ protected $book;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Translation functionality',
+ 'description' => 'Create a basic page with translation, modify the page outdating translation, and update translation.',
+ 'group' => 'Translation'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('locale', 'translation', 'translation_test');
+
+ // Setup users.
+ $this->admin_user = $this->drupalCreateUser(array('bypass node access', 'administer nodes', 'administer languages', 'administer content types', 'administer blocks', 'access administration pages', 'translate content'));
+ $this->translator = $this->drupalCreateUser(array('create page content', 'edit own page content', 'translate content'));
+
+ $this->drupalLogin($this->admin_user);
+
+ // Add languages.
+ $this->addLanguage('en');
+ $this->addLanguage('es');
+ $this->addLanguage('it');
+
+ // Disable Italian to test the translation behavior with disabled languages.
+ $edit = array('languages[it][enabled]' => FALSE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+
+ // Set "Basic page" content type to use multilingual support with
+ // translation.
+ $this->drupalGet('admin/structure/types/manage/page');
+ $edit = array();
+ $edit['language_content_type'] = 2;
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ $this->assertRaw(t('The content type %type has been updated.', array('%type' => 'Basic page')), t('Basic page content type has been updated.'));
+
+ // Enable the language switcher block.
+ $language_type = LANGUAGE_TYPE_INTERFACE;
+ $edit = array("blocks[locale_$language_type][region]" => 'sidebar_first');
+ $this->drupalPost('admin/structure/block', $edit, t('Save blocks'));
+
+ // Enable URL language detection and selection to make the language switcher
+ // block appear.
+ $edit = array('language[enabled][locale-url]' => TRUE);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ $this->assertRaw(t('Language negotiation configuration saved.'), t('URL language detection enabled.'));
+ $this->resetCaches();
+
+ $this->drupalLogin($this->translator);
+ }
+
+ /**
+ * Create a basic page with translation, modify the basic page outdating
+ * translation, and update translation.
+ */
+ function testContentTranslation() {
+ // Create Basic page in English.
+ $node_title = $this->randomName();
+ $node_body = $this->randomName();
+ $node = $this->createPage($node_title, $node_body, 'en');
+
+ // Unpublish the original node to check that this has no impact on the
+ // translation overview page, publish it again afterwards.
+ $this->drupalLogin($this->admin_user);
+ $this->drupalPost('node/' . $node->nid . '/edit', array('status' => FALSE), t('Save'));
+ $this->drupalGet('node/' . $node->nid . '/translate');
+ $this->drupalPost('node/' . $node->nid . '/edit', array('status' => NODE_PUBLISHED), t('Save'));
+ $this->drupalLogin($this->translator);
+
+ // Check that the "add translation" link uses a localized path.
+ $languages = language_list();
+ $this->drupalGet('node/' . $node->nid . '/translate');
+ $this->assertLinkByHref($languages['es']->prefix . '/node/add/' . str_replace('_', '-', $node->type), 0, t('The "add translation" link for %language points to the localized path of the target language.', array('%language' => $languages['es']->name)));
+
+ // Submit translation in Spanish.
+ $node_translation_title = $this->randomName();
+ $node_translation_body = $this->randomName();
+ $node_translation = $this->createTranslation($node, $node_translation_title, $node_translation_body, 'es');
+
+ // Check that the "edit translation" and "view node" links use localized
+ // paths.
+ $this->drupalGet('node/' . $node->nid . '/translate');
+ $this->assertLinkByHref($languages['es']->prefix . '/node/' . $node_translation->nid . '/edit', 0, t('The "edit" link for the translation in %language points to the localized path of the translation language.', array('%language' => $languages['es']->name)));
+ $this->assertLinkByHref($languages['es']->prefix . '/node/' . $node_translation->nid, 0, t('The "view" link for the translation in %language points to the localized path of the translation language.', array('%language' => $languages['es']->name)));
+
+ // Attempt to submit a duplicate translation by visiting the node/add page
+ // with identical query string.
+ $this->drupalGet('node/add/page', array('query' => array('translation' => $node->nid, 'target' => 'es')));
+ $this->assertRaw(t('A translation of %title in %language already exists', array('%title' => $node_title, '%language' => $languages['es']->name)), t('Message regarding attempted duplicate translation is displayed.'));
+
+ // Attempt a resubmission of the form - this emulates using the back button
+ // to return to the page then resubmitting the form without a refresh.
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $this->randomName();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $this->drupalPost('node/add/page', $edit, t('Save'), array('query' => array('translation' => $node->nid, 'language' => 'es')));
+ $duplicate = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertEqual($duplicate->tnid, 0, t('The node does not have a tnid.'));
+
+ // Update original and mark translation as outdated.
+ $node_body = $this->randomName();
+ $node->body[LANGUAGE_NONE][0]['value'] = $node_body;
+ $edit = array();
+ $edit["body[$langcode][0][value]"] = $node_body;
+ $edit['translation[retranslate]'] = TRUE;
+ $this->drupalPost('node/' . $node->nid . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been updated.', array('%title' => $node_title)), t('Original node updated.'));
+
+ // Check to make sure that interface shows translation as outdated.
+ $this->drupalGet('node/' . $node->nid . '/translate');
+ $this->assertRaw('<span class="marker">' . t('outdated') . '</span>', t('Translation marked as outdated.'));
+
+ // Update translation and mark as updated.
+ $edit = array();
+ $edit["body[$langcode][0][value]"] = $this->randomName();
+ $edit['translation[status]'] = FALSE;
+ $this->drupalPost('node/' . $node_translation->nid . '/edit', $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been updated.', array('%title' => $node_translation_title)), t('Translated node updated.'));
+
+ // Confirm that disabled languages are an option for translators when
+ // creating nodes.
+ $this->drupalGet('node/add/page');
+ $this->assertFieldByXPath('//select[@name="language"]//option', 'it', t('Italian (disabled) is available in language selection.'));
+ $translation_it = $this->createTranslation($node, $this->randomName(), $this->randomName(), 'it');
+ $this->assertRaw($translation_it->body[LANGUAGE_NONE][0]['value'], t('Content created in Italian (disabled).'));
+
+ // Confirm that language neutral is an option for translators when there are
+ // disabled languages.
+ $this->drupalGet('node/add/page');
+ $this->assertFieldByXPath('//select[@name="language"]//option', LANGUAGE_NONE, t('Language neutral is available in language selection with disabled languages.'));
+ $node2 = $this->createPage($this->randomName(), $this->randomName(), LANGUAGE_NONE);
+ $this->assertRaw($node2->body[LANGUAGE_NONE][0]['value'], t('Language neutral content created with disabled languages available.'));
+
+ // Leave just one language enabled and check that the translation overview
+ // page is still accessible.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('languages[es][enabled]' => FALSE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+ $this->drupalLogin($this->translator);
+ $this->drupalGet('node/' . $node->nid . '/translate');
+ $this->assertRaw(t('Translations of %title', array('%title' => $node->title)), t('Translation overview page available with only one language enabled.'));
+ }
+
+ /**
+ * Check that language switch links behave properly.
+ */
+ function testLanguageSwitchLinks() {
+ // Create a Basic page in English and its translations in Spanish and
+ // Italian.
+ $node = $this->createPage($this->randomName(), $this->randomName(), 'en');
+ $translation_es = $this->createTranslation($node, $this->randomName(), $this->randomName(), 'es');
+ $translation_it = $this->createTranslation($node, $this->randomName(), $this->randomName(), 'it');
+
+ // Check that language switch links are correctly shown only for enabled
+ // languages.
+ $this->assertLanguageSwitchLinks($node, $translation_es);
+ $this->assertLanguageSwitchLinks($translation_es, $node);
+ $this->assertLanguageSwitchLinks($node, $translation_it, FALSE);
+
+ // Check that links to the displayed translation appear only in the language
+ // switcher block.
+ $this->assertLanguageSwitchLinks($node, $node, FALSE, 'node');
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, 'block-locale');
+
+ // Unpublish the Spanish translation to check that the related language
+ // switch link is not shown.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('status' => FALSE);
+ $this->drupalPost("node/$translation_es->nid/edit", $edit, t('Save'));
+ $this->drupalLogin($this->translator);
+ $this->assertLanguageSwitchLinks($node, $translation_es, FALSE);
+
+ // Check that content translation links are shown even when no language
+ // negotiation is configured.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('language[enabled][locale-url]' => FALSE);
+ $this->drupalPost('admin/config/regional/language/configure', $edit, t('Save settings'));
+ $this->resetCaches();
+ $edit = array('status' => TRUE);
+ $this->drupalPost("node/$translation_es->nid/edit", $edit, t('Save'));
+ $this->drupalLogin($this->translator);
+ $this->assertLanguageSwitchLinks($node, $translation_es, TRUE, 'node');
+ }
+
+ /**
+ * Test that the language switcher block alterations work as intended.
+ */
+ function testLanguageSwitcherBlockIntegration() {
+ // Enable Italian to have three items in the language switcher block.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('languages[it][enabled]' => TRUE);
+ $this->drupalPost('admin/config/regional/language', $edit, t('Save configuration'));
+ $this->drupalLogin($this->translator);
+
+ // Create a Basic page in English.
+ $type = 'block-locale';
+ $node = $this->createPage($this->randomName(), $this->randomName(), 'en');
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $this->emptyNode('es'), TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $this->emptyNode('it'), TRUE, $type);
+
+ // Create the Spanish translation.
+ $translation_es = $this->createTranslation($node, $this->randomName(), $this->randomName(), 'es');
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $translation_es, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $this->emptyNode('it'), TRUE, $type);
+
+ // Create the Italian translation.
+ $translation_it = $this->createTranslation($node, $this->randomName(), $this->randomName(), 'it');
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $translation_es, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $translation_it, TRUE, $type);
+
+ // Create a language neutral node and check that the language switcher is
+ // left untouched.
+ $node2 = $this->createPage($this->randomName(), $this->randomName(), LANGUAGE_NONE);
+ $node2_en = (object) array('nid' => $node2->nid, 'language' => 'en');
+ $node2_es = (object) array('nid' => $node2->nid, 'language' => 'es');
+ $node2_it = (object) array('nid' => $node2->nid, 'language' => 'it');
+ $this->assertLanguageSwitchLinks($node2_en, $node2_en, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node2_en, $node2_es, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node2_en, $node2_it, TRUE, $type);
+
+ // Disable translation support to check that the language switcher is left
+ // untouched only for new nodes.
+ $this->drupalLogin($this->admin_user);
+ $edit = array('language_content_type' => 0);
+ $this->drupalPost('admin/structure/types/manage/page', $edit, t('Save content type'));
+ $this->drupalLogin($this->translator);
+
+ // Existing translations trigger alterations even if translation support is
+ // disabled.
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $translation_es, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $translation_it, TRUE, $type);
+
+ // Check that new nodes with a language assigned do not trigger language
+ // switcher alterations when translation support is disabled.
+ $node = $this->createPage($this->randomName(), $this->randomName());
+ $node_es = (object) array('nid' => $node->nid, 'language' => 'es');
+ $node_it = (object) array('nid' => $node->nid, 'language' => 'it');
+ $this->assertLanguageSwitchLinks($node, $node, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $node_es, TRUE, $type);
+ $this->assertLanguageSwitchLinks($node, $node_it, TRUE, $type);
+ }
+
+ /**
+ * Reset static caches to make the test code match the client site behavior.
+ */
+ function resetCaches() {
+ drupal_static_reset('locale_url_outbound_alter');
+ }
+
+ /**
+ * Return an empty node data structure.
+ */
+ function emptyNode($langcode) {
+ return (object) array('nid' => NULL, 'language' => $langcode);
+ }
+
+ /**
+ * Install a the specified language if it has not been already. Otherwise make sure that
+ * the language is enabled.
+ *
+ * @param $language_code
+ * The language code the check.
+ */
+ function addLanguage($language_code) {
+ // Check to make sure that language has not already been installed.
+ $this->drupalGet('admin/config/regional/language');
+
+ if (strpos($this->drupalGetContent(), 'languages[' . $language_code . '][enabled]') === FALSE) {
+ // Doesn't have language installed so add it.
+ $edit = array();
+ $edit['predefined_langcode'] = $language_code;
+ $this->drupalPost('admin/config/regional/language/add', $edit, t('Add language'));
+
+ // Make sure we are not using a stale list.
+ drupal_static_reset('language_list');
+ $languages = language_list('language');
+ $this->assertTrue(array_key_exists($language_code, $languages), t('Language was installed successfully.'));
+
+ if (array_key_exists($language_code, $languages)) {
+ $this->assertRaw(t('The language %language has been created and can now be used. More information is available on the <a href="@locale-help">help screen</a>.', array('%language' => $languages[$language_code]->name, '@locale-help' => url('admin/help/locale'))), t('Language has been created.'));
+ }
+ }
+ elseif ($this->xpath('//input[@type="checkbox" and @name=:name and @checked="checked"]', array(':name' => 'languages[' . $language_code . '][enabled]'))) {
+ // It's installed and enabled. No need to do anything.
+ $this->assertTrue(true, 'Language [' . $language_code . '] already installed and enabled.');
+ }
+ else {
+ // It's installed but not enabled. Enable it.
+ $this->assertTrue(true, 'Language [' . $language_code . '] already installed.');
+ $this->drupalPost(NULL, array('languages[' . $language_code . '][enabled]' => TRUE), t('Save configuration'));
+ $this->assertRaw(t('Configuration saved.'), t('Language successfully enabled.'));
+ }
+ }
+
+ /**
+ * Create a "Basic page" in the specified language.
+ *
+ * @param $title
+ * Title of basic page in specified language.
+ * @param $body
+ * Body of basic page in specified language.
+ * @param
+ * $language Language code.
+ */
+ function createPage($title, $body, $language = NULL) {
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = $title;
+ $edit["body[$langcode][0][value]"] = $body;
+ if (!empty($language)) {
+ $edit['language'] = $language;
+ }
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been created.', array('%title' => $title)), t('Basic page created.'));
+
+ // Check to make sure the node was created.
+ $node = $this->drupalGetNodeByTitle($title);
+ $this->assertTrue($node, t('Node found in database.'));
+
+ return $node;
+ }
+
+ /**
+ * Create a translation for the specified basic page in the specified
+ * language.
+ *
+ * @param $node
+ * The basic page to create translation for.
+ * @param $title
+ * Title of basic page in specified language.
+ * @param $body
+ * Body of basic page in specified language.
+ * @param $language
+ * Language code.
+ */
+ function createTranslation($node, $title, $body, $language) {
+ $this->drupalGet('node/add/page', array('query' => array('translation' => $node->nid, 'target' => $language)));
+
+ $langcode = LANGUAGE_NONE;
+ $body_key = "body[$langcode][0][value]";
+ $this->assertFieldByXPath('//input[@id="edit-title"]', $node->title, "Original title value correctly populated.");
+ $this->assertFieldByXPath("//textarea[@name='$body_key']", $node->body[LANGUAGE_NONE][0]['value'], "Original body value correctly populated.");
+
+ $edit = array();
+ $edit["title"] = $title;
+ $edit[$body_key] = $body;
+ $this->drupalPost(NULL, $edit, t('Save'));
+ $this->assertRaw(t('Basic page %title has been created.', array('%title' => $title)), t('Translation created.'));
+
+ // Check to make sure that translation was successful.
+ $translation = $this->drupalGetNodeByTitle($title);
+ $this->assertTrue($translation, t('Node found in database.'));
+ $this->assertTrue($translation->tnid == $node->nid, t('Translation set id correctly stored.'));
+
+ return $translation;
+ }
+
+ /**
+ * Assert that an element identified by the given XPath has the given content.
+ *
+ * @param $xpath
+ * XPath used to find the element.
+ * @param array $arguments
+ * An array of arguments with keys in the form ':name' matching the
+ * placeholders in the query. The values may be either strings or numeric
+ * values.
+ * @param $value
+ * The text content of the matched element to assert.
+ * @param $message
+ * Message to display.
+ * @param $group
+ * The group this message belongs to.
+ *
+ * @return
+ * TRUE on pass, FALSE on fail.
+ */
+ function assertContentByXPath($xpath, array $arguments = array(), $value = NULL, $message = '', $group = 'Other') {
+ $found = $this->findContentByXPath($xpath, $arguments, $value);
+ return $this->assertTrue($found, $message, $group);
+ }
+
+ /**
+ * Check that the specified language switch links are found/not found.
+ *
+ * @param $node
+ * The node to display.
+ * @param $translation
+ * The translation whose link has to be checked.
+ * @param $find
+ * TRUE if the link must be present in the node page.
+ * @param $types
+ * The page areas to be checked.
+ *
+ * @return
+ * TRUE if the language switch links are found/not found.
+ */
+ function assertLanguageSwitchLinks($node, $translation, $find = TRUE, $types = NULL) {
+ if (empty($types)) {
+ $types = array('node', 'block-locale');
+ }
+ elseif (is_string($types)) {
+ $types = array($types);
+ }
+
+ $result = TRUE;
+ $languages = language_list();
+ $page_language = $languages[$node->language];
+ $translation_language = $languages[$translation->language];
+ $url = url("node/$translation->nid", array('language' => $translation_language));
+
+ $this->drupalGet("node/$node->nid", array('language' => $page_language));
+
+ foreach ($types as $type) {
+ $args = array('%translation_language' => $translation_language->name, '%page_language' => $page_language->name, '%type' => $type);
+ if ($find) {
+ $message = t('[%page_language] Language switch item found for %translation_language language in the %type page area.', $args);
+ }
+ else {
+ $message = t('[%page_language] Language switch item not found for %translation_language language in the %type page area.', $args);
+ }
+
+ if (!empty($translation->nid)) {
+ $xpath = '//div[contains(@class, :type)]//a[@href=:url]';
+ }
+ else {
+ $xpath = '//div[contains(@class, :type)]//span[@class="locale-untranslated"]';
+ }
+
+ $found = $this->findContentByXPath($xpath, array(':type' => $type, ':url' => $url), $translation_language->name);
+ $result = $this->assertTrue($found == $find, $message) && $result;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Search for elements matching the given xpath and value.
+ */
+ function findContentByXPath($xpath, array $arguments = array(), $value = NULL) {
+ $elements = $this->xpath($xpath, $arguments);
+
+ $found = TRUE;
+ if ($value && $elements) {
+ $found = FALSE;
+ foreach ($elements as $element) {
+ if ((string) $element == $value) {
+ $found = TRUE;
+ break;
+ }
+ }
+ }
+
+ return $elements && $found;
+ }
+}
diff --git a/core/modules/trigger/tests/trigger_test.info b/core/modules/trigger/tests/trigger_test.info
new file mode 100644
index 000000000000..8b25cd95dea7
--- /dev/null
+++ b/core/modules/trigger/tests/trigger_test.info
@@ -0,0 +1,5 @@
+name = "Trigger Test"
+description = "Support module for Trigger tests."
+package = Testing
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/trigger/tests/trigger_test.module b/core/modules/trigger/tests/trigger_test.module
new file mode 100644
index 000000000000..0e3f3f877d24
--- /dev/null
+++ b/core/modules/trigger/tests/trigger_test.module
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * @file
+ * Mock module to aid in testing trigger.module.
+ */
+
+/**
+ * Implements hook_action_info().
+ */
+function trigger_test_action_info() {
+ // Register an action that can be assigned to the trigger "cron".
+ return array(
+ 'trigger_test_system_cron_action' => array(
+ 'type' => 'system',
+ 'label' => t('Cron test action'),
+ 'configurable' => FALSE,
+ 'triggers' => array('cron'),
+ ),
+ 'trigger_test_system_cron_conf_action' => array(
+ 'type' => 'system',
+ 'label' => t('Cron test configurable action'),
+ 'configurable' => TRUE,
+ 'triggers' => array('cron'),
+ ),
+ 'trigger_test_generic_action' => array(
+ 'type' => 'system',
+ 'label' => t('Generic test action'),
+ 'configurable' => FALSE,
+ 'triggers' => array(
+ 'taxonomy_term_insert',
+ 'taxonomy_term_update',
+ 'taxonomy_delete',
+ 'comment_insert',
+ 'comment_update',
+ 'comment_delete',
+ 'user_insert',
+ 'user_update',
+ 'user_delete',
+ 'user_login',
+ 'user_logout',
+ 'user_view',
+ ),
+ ),
+ 'trigger_test_generic_any_action' => array(
+ 'type' => 'system',
+ 'label' => t('Generic test action for any trigger'),
+ 'configurable' => FALSE,
+ 'triggers' => array('any'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_trigger_info().
+ */
+function trigger_test_trigger_info() {
+ // Register triggers that this module provides. The first is an additional
+ // node trigger and the second is our own, which should create a new tab
+ // on the trigger assignment page.
+ return array(
+ 'node' => array(
+ 'node_triggertest' => array(
+ 'label' => t('A test trigger is fired'),
+ ),
+ ),
+ 'trigger_test' => array(
+ 'trigger_test_triggertest' => array(
+ 'label' => t('Another test trigger is fired'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Action fired during the "cron run" trigger test.
+ */
+function trigger_test_system_cron_action() {
+ // Indicate successful execution by setting a persistent variable.
+ variable_set('trigger_test_system_cron_action', TRUE);
+}
+
+/**
+ * Implement a configurable Drupal action.
+ */
+function trigger_test_system_cron_conf_action($object, $context) {
+ // Indicate successful execution by incrementing a persistent variable.
+ $value = variable_get('trigger_test_system_cron_conf_action', 0) + 1;
+ variable_set('trigger_test_system_cron_conf_action', $value);
+}
+
+/**
+ * Form for configurable test action.
+ */
+function trigger_test_system_cron_conf_action_form($context) {
+ if (!isset($context['subject'])) {
+ $context['subject'] = '';
+ }
+ $form['subject'] = array(
+ '#type' => 'textfield',
+ '#default_value' => $context['subject'],
+ );
+ return $form;
+}
+
+/**
+ * Form submission handler for configurable test action.
+ */
+function trigger_test_system_cron_conf_action_submit($form, $form_state) {
+ $form_values = $form_state['values'];
+ // Process the HTML form to store configuration. The keyed array that
+ // we return will be serialized to the database.
+ $params = array(
+ 'subject' => $form_values['subject'],
+ );
+ return $params;
+}
+
+/**
+ * Action fired during the "taxonomy", "comment", and "user" trigger tests.
+ */
+function trigger_test_generic_action($context) {
+ // Indicate successful execution by setting a persistent variable.
+ variable_set('trigger_test_generic_action', TRUE);
+}
+
+/**
+ * Action fired during the additional trigger tests.
+ */
+function trigger_test_generic_any_action($context) {
+ // Indicate successful execution by setting a persistent variable.
+ variable_set('trigger_test_generic_any_action', variable_get('trigger_test_generic_any_action', 0) + 1);
+}
diff --git a/core/modules/trigger/trigger.admin.inc b/core/modules/trigger/trigger.admin.inc
new file mode 100644
index 000000000000..7509eb35a608
--- /dev/null
+++ b/core/modules/trigger/trigger.admin.inc
@@ -0,0 +1,309 @@
+<?php
+
+/**
+ * @file
+ * Admin page callbacks for the trigger module.
+ */
+
+/**
+ * Builds the form that allows users to assign actions to triggers.
+ *
+ * @param $module_to_display
+ * Which tab of triggers to display. E.g., 'node' for all
+ * node-related triggers.
+ * @return
+ * HTML form.
+ */
+function trigger_assign($module_to_display = NULL) {
+ // If no type is specified we default to node actions, since they
+ // are the most common.
+ if (!isset($module_to_display)) {
+ drupal_goto('admin/structure/trigger/node');
+ }
+
+ $build = array();
+ $trigger_info = module_invoke_all('trigger_info');
+ drupal_alter('trigger_info', $trigger_info);
+ foreach ($trigger_info as $module => $hooks) {
+ if ($module == $module_to_display) {
+ foreach ($hooks as $hook => $description) {
+ $form_id = 'trigger_' . $hook . '_assign_form';
+ $build[$form_id] = drupal_get_form($form_id, $module, $hook, $description['label']);
+ }
+ }
+ }
+ return $build;
+}
+
+/**
+ * Confirm removal of an assigned action.
+ *
+ * @param $module
+ * The tab of triggers the user will be directed to after successful
+ * removal of the action, or if the confirmation form is cancelled.
+ * @param $hook
+ * @param $aid
+ * The action ID.
+ * @ingroup forms
+ * @see trigger_unassign_submit()
+ */
+function trigger_unassign($form, $form_state, $module, $hook = NULL, $aid = NULL) {
+ if (!($hook && $aid)) {
+ drupal_goto('admin/structure/trigger');
+ }
+
+ $form['hook'] = array(
+ '#type' => 'value',
+ '#value' => $hook,
+ );
+ $form['module'] = array(
+ '#type' => 'value',
+ '#value' => $module,
+ );
+ $form['aid'] = array(
+ '#type' => 'value',
+ '#value' => $aid,
+ );
+
+ $action = actions_function_lookup($aid);
+ $actions = actions_get_all_actions();
+
+ $destination = 'admin/structure/trigger/' . $module;
+
+ return confirm_form($form,
+ t('Are you sure you want to unassign the action %title?', array('%title' => $actions[$action]['label'])),
+ $destination,
+ t('You can assign it again later if you wish.'),
+ t('Unassign'), t('Cancel')
+ );
+}
+
+/**
+ * Submit callback for trigger_unassign() form.
+ */
+function trigger_unassign_submit($form, &$form_state) {
+ if ($form_state['values']['confirm'] == 1) {
+ $aid = actions_function_lookup($form_state['values']['aid']);
+ db_delete('trigger_assignments')
+ ->condition('hook', $form_state['values']['hook'])
+ ->condition('aid', $aid)
+ ->execute();
+ $actions = actions_get_all_actions();
+ watchdog('actions', 'Action %action has been unassigned.', array('%action' => $actions[$aid]['label']));
+ drupal_set_message(t('Action %action has been unassigned.', array('%action' => $actions[$aid]['label'])));
+ $form_state['redirect'] = 'admin/structure/trigger/' . $form_state['values']['module'];
+ }
+ else {
+ drupal_goto('admin/structure/trigger');
+ }
+}
+
+/**
+ * Returns the form for assigning an action to a trigger.
+ *
+ * @param $module
+ * The name of the trigger group, e.g., 'node'.
+ * @param $hook
+ * The name of the trigger hook, e.g., 'node_insert'.
+ * @param $label
+ * A plain English description of what this trigger does.
+ *
+ * @ingoup forms
+ * @see trigger_assign_form_validate()
+ * @see trigger_assign_form_submit()
+ */
+function trigger_assign_form($form, $form_state, $module, $hook, $label) {
+ $form['module'] = array(
+ '#type' => 'hidden',
+ '#value' => $module,
+ );
+ $form['hook'] = array(
+ '#type' => 'hidden',
+ '#value' => $hook,
+ );
+ // All of these forms use the same validate and submit functions.
+ $form['#validate'][] = 'trigger_assign_form_validate';
+ $form['#submit'][] = 'trigger_assign_form_submit';
+
+ $options = array();
+ $functions = array();
+ // Restrict the options list to actions that declare support for this hook.
+ foreach (actions_list() as $func => $metadata) {
+ if (isset($metadata['triggers']) && array_intersect(array($hook, 'any'), $metadata['triggers'])) {
+ $functions[] = $func;
+ }
+ }
+ foreach (actions_actions_map(actions_get_all_actions()) as $aid => $action) {
+ if (in_array($action['callback'], $functions)) {
+ $options[$action['type']][$aid] = $action['label'];
+ }
+ }
+
+ $form[$hook] = array(
+ '#type' => 'fieldset',
+ // !description is correct, since these labels are passed through t() in
+ // hook_trigger_info().
+ '#title' => t('Trigger: !description', array('!description' => $label)),
+ '#theme' => 'trigger_display',
+ );
+
+ // Retrieve actions that are already assigned to this hook combination.
+ $actions = trigger_get_assigned_actions($hook);
+ $form[$hook]['assigned']['#type'] = 'value';
+ $form[$hook]['assigned']['#value'] = array();
+ foreach ($actions as $aid => $info) {
+ // If action is defined unassign it, otherwise offer to delete all orphaned
+ // actions.
+ $hash = drupal_hash_base64($aid, TRUE);
+ if (actions_function_lookup($hash)) {
+ $form[$hook]['assigned']['#value'][$aid] = array(
+ 'label' => $info['label'],
+ 'link' => l(t('unassign'), "admin/structure/trigger/unassign/$module/$hook/$hash"),
+ );
+ }
+ else {
+ // Link to system_actions_remove_orphans() to do the clean up.
+ $form[$hook]['assigned']['#value'][$aid] = array(
+ 'label' => $info['label'],
+ 'link' => l(t('Remove orphaned actions'), "admin/config/system/actions/orphan"),
+ );
+ }
+ }
+
+ $form[$hook]['parent'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ // List possible actions that may be assigned.
+ if (count($options) != 0) {
+ $form[$hook]['parent']['aid'] = array(
+ '#type' => 'select',
+ '#title' => t('List of trigger actions when !description', array('!description' => $label)),
+ '#title_display' => 'invisible',
+ '#options' => $options,
+ '#empty_option' => t('Choose an action'),
+ );
+ $form[$hook]['parent']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Assign')
+ );
+ }
+ else {
+ $form[$hook]['none'] = array(
+ '#markup' => t('No actions available for this trigger. <a href="@link">Add action</a>.', array('@link' => url('admin/config/system/actions/manage')))
+ );
+ }
+ return $form;
+}
+
+/**
+ * Validation function for trigger_assign_form().
+ *
+ * Makes sure that the user is not re-assigning an action to an event.
+ */
+function trigger_assign_form_validate($form, $form_state) {
+ $form_values = $form_state['values'];
+ if (!empty($form_values['aid'])) {
+ $aid = actions_function_lookup($form_values['aid']);
+ $aid_exists = db_query("SELECT aid FROM {trigger_assignments} WHERE hook = :hook AND aid = :aid", array(
+ ':hook' => $form_values['hook'],
+ ':aid' => $aid,
+ ))->fetchField();
+ if ($aid_exists) {
+ form_set_error($form_values['hook'], t('The action you chose is already assigned to that trigger.'));
+ }
+ }
+}
+
+/**
+ * Submit function for trigger_assign_form().
+ */
+function trigger_assign_form_submit($form, &$form_state) {
+ if (!empty($form_state['values']['aid'])) {
+ $aid = actions_function_lookup($form_state['values']['aid']);
+ $weight = db_query("SELECT MAX(weight) FROM {trigger_assignments} WHERE hook = :hook", array(':hook' => $form_state['values']['hook']))->fetchField();
+
+ // Insert the new action.
+ db_insert('trigger_assignments')
+ ->fields(array(
+ 'hook' => $form_state['values']['hook'],
+ 'aid' => $aid,
+ 'weight' => $weight + 1,
+ ))
+ ->execute();
+
+ // If we are not configuring an action for a "presave" hook and this action
+ // changes an object property, then we need to save the object, so the
+ // property change will persist.
+ $actions = actions_list();
+ if (strpos($form_state['values']['hook'], 'presave') === FALSE && isset($actions[$aid]['behavior']) && in_array('changes_property', $actions[$aid]['behavior'])) {
+ // Determine the corresponding save action name for this action.
+ $save_action = strtok($aid, '_') . '_save_action';
+ // If no corresponding save action exists, we need to bail out.
+ if (!isset($actions[$save_action])) {
+ throw new Exception(t('Missing/undefined save action (%save_aid) for %aid action.', array('%save_aid' => $aid, '%aid' => $aid)));
+ }
+ // Delete previous save action if it exists, and re-add it using a higher
+ // weight.
+ $save_action_assigned = db_query("SELECT aid FROM {trigger_assignments} WHERE hook = :hook AND aid = :aid", array(':hook' => $form_state['values']['hook'], ':aid' => $save_action))->fetchField();
+
+ if ($save_action_assigned) {
+ db_delete('trigger_assignments')
+ ->condition('hook', $form_state['values']['hook'])
+ ->condition('aid', $save_action)
+ ->execute();
+ }
+ db_insert('trigger_assignments')
+ ->fields(array(
+ 'hook' => $form_state['values']['hook'],
+ 'aid' => $save_action,
+ 'weight' => $weight + 2,
+ ))
+ ->execute();
+
+ // If no save action existed before, inform the user about it.
+ if (!$save_action_assigned) {
+ drupal_set_message(t('The %label action has been appended, which is required to save the property change.', array('%label' => $actions[$save_action]['label'])));
+ }
+ // Otherwise, just inform about the new weight.
+ else {
+ drupal_set_message(t('The %label action was moved to save the property change.', array('%label' => $actions[$save_action]['label'])));
+ }
+ }
+ }
+}
+
+/**
+ * Returns HTML for the form showing actions assigned to a trigger.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: The fieldset including all assigned actions.
+ *
+ * @ingroup themeable
+ */
+function theme_trigger_display($variables) {
+ $element = $variables['element'];
+
+ $header = array();
+ $rows = array();
+ if (isset($element['assigned']) && count($element['assigned']['#value'])) {
+ $header = array(array('data' => t('Name')), array('data' => t('Operation')));
+ $rows = array();
+ foreach ($element['assigned']['#value'] as $aid => $info) {
+ $rows[] = array(
+ check_plain($info['label']),
+ $info['link']
+ );
+ }
+ }
+
+ if (count($rows)) {
+ $output = theme('table', array('header' => $header, 'rows' => $rows)) . drupal_render_children($element);
+ }
+ else {
+ $output = drupal_render_children($element);
+ }
+ return $output;
+}
+
diff --git a/core/modules/trigger/trigger.api.php b/core/modules/trigger/trigger.api.php
new file mode 100644
index 000000000000..839c1d48d098
--- /dev/null
+++ b/core/modules/trigger/trigger.api.php
@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Trigger module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Declare triggers (events) for users to assign actions to.
+ *
+ * This hook is used by the trigger module to create a list of triggers (events)
+ * that users can assign actions to. Your module is responsible for detecting
+ * that the events have occurred, calling trigger_get_assigned_actions() to find
+ * out which actions the user has associated with your trigger, and then calling
+ * actions_do() to fire off the actions.
+ *
+ * @return
+ * A nested associative array.
+ * - The outermost key is the name of the module that is defining the triggers.
+ * This will be used to create a local task (tab) in the trigger module's
+ * user interface. A contrib module may supply a trigger for a core module by
+ * giving the core module's name as the key. For example, you could use the
+ * 'node' key to add a node-related trigger.
+ * - Within each module, each individual trigger is keyed by a hook name
+ * describing the particular trigger (this is not visible to the user, but
+ * can be used by your module for identification).
+ * - Each trigger is described by an associative array. Currently, the only
+ * key-value pair is 'label', which contains a translated human-readable
+ * description of the triggering event.
+ * For example, the trigger set for the 'node' module has 'node' as the
+ * outermost key and defines triggers for 'node_insert', 'node_update',
+ * 'node_delete' etc. that fire when a node is saved, updated, etc.
+ *
+ * @see hook_action_info()
+ * @see hook_trigger_info_alter()
+ */
+function hook_trigger_info() {
+ return array(
+ 'node' => array(
+ 'node_presave' => array(
+ 'label' => t('When either saving new content or updating existing content'),
+ ),
+ 'node_insert' => array(
+ 'label' => t('After saving new content'),
+ ),
+ 'node_update' => array(
+ 'label' => t('After saving updated content'),
+ ),
+ 'node_delete' => array(
+ 'label' => t('After deleting content'),
+ ),
+ 'node_view' => array(
+ 'label' => t('When content is viewed by an authenticated user'),
+ ),
+ ),
+ );
+}
+
+/**
+ * Alter triggers declared by hook_trigger_info().
+ *
+ * @param $triggers
+ * Array of trigger information returned by hook_trigger_info()
+ * implementations. Modify this array in place. See hook_trigger_info()
+ * for information on what this might contain.
+ */
+function hook_trigger_info_alter(&$triggers) {
+ $triggers['node']['node_insert']['label'] = t('When content is saved');
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/trigger/trigger.info b/core/modules/trigger/trigger.info
new file mode 100644
index 000000000000..f47604d24d15
--- /dev/null
+++ b/core/modules/trigger/trigger.info
@@ -0,0 +1,7 @@
+name = Trigger
+description = Enables actions to be fired on certain system events, such as when new content is created.
+package = Core
+version = VERSION
+core = 8.x
+files[] = trigger.test
+configure = admin/structure/trigger
diff --git a/core/modules/trigger/trigger.install b/core/modules/trigger/trigger.install
new file mode 100644
index 000000000000..7dded60648ae
--- /dev/null
+++ b/core/modules/trigger/trigger.install
@@ -0,0 +1,53 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the trigger module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function trigger_schema() {
+ $schema['trigger_assignments'] = array(
+ 'description' => 'Maps trigger to hook and operation assignments from trigger.module.',
+ 'fields' => array(
+ 'hook' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Primary Key: The name of the internal Drupal hook; for example, node_insert.',
+ ),
+ 'aid' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "Primary Key: Action's {actions}.aid.",
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The weight of the trigger assignment in relation to other triggers.',
+ ),
+ ),
+ 'primary key' => array('hook', 'aid'),
+ 'foreign keys' => array(
+ 'action' => array(
+ 'table' => 'actions',
+ 'columns' => array('aid' => 'aid'),
+ ),
+ ),
+ );
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function trigger_install() {
+ // Do initial synchronization of actions in code and the database.
+ actions_synchronize();
+}
diff --git a/core/modules/trigger/trigger.module b/core/modules/trigger/trigger.module
new file mode 100644
index 000000000000..6c1f58ffa33e
--- /dev/null
+++ b/core/modules/trigger/trigger.module
@@ -0,0 +1,631 @@
+<?php
+
+/**
+ * @file
+ * Enables functions to be stored and executed at a later time when
+ * triggered by other modules or by one of Drupal's core API hooks.
+ */
+
+/**
+ * Implements hook_help().
+ */
+function trigger_help($path, $arg) {
+ // Generate help text for admin/structure/trigger/(module) tabs.
+ $matches = array();
+ if (preg_match('|^admin/structure/trigger/(.*)$|', $path, $matches)) {
+ $explanation = '<p>' . t('Triggers are events on your site, such as new content being added or a user logging in. The Trigger module associates these triggers with actions (functional tasks), such as unpublishing content containing certain keywords or e-mailing an administrator. The <a href="@url">Actions settings page</a> contains a list of existing actions and provides the ability to create and configure advanced actions (actions requiring configuration, such as an e-mail address or a list of banned words).', array('@url' => url('admin/config/system/actions'))) . '</p>';
+
+ $module = $matches[1];
+ $trigger_info = _trigger_tab_information();
+ if (!empty($trigger_info[$module])) {
+ $explanation .= '<p>' . t('There is a tab on this page for each module that defines triggers. On this tab you can assign actions to run when triggers from the <a href="@module-help">@module-name module</a> happen.', array('@module-help' => url('admin/help/' . $module), '@module-name' => $trigger_info[$module])) . '</p>';
+ }
+
+ return $explanation;
+ }
+
+ if ($path == 'admin/help#trigger') {
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The Trigger module provides the ability to cause <em>actions</em> to run when certain <em>triggers</em> take place on your site. Triggers are events, such as new content being added to your site or a user logging in, and actions are tasks, such as unpublishing content or e-mailing an administrator. For more information, see the online handbook entry for <a href="@trigger">Trigger module</a>.', array('@trigger' => 'http://drupal.org/handbook/modules/trigger/')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Configuring triggers and actions') . '</dt>';
+ $output .= '<dd>' . t('The combination of actions and triggers can perform many useful tasks, such as e-mailing an administrator if a user account is deleted, or automatically unpublishing comments that contain certain words. To set up a trigger/action combination, first visit the <a href="@actions-page">Actions configuration page</a>, where you can either verify that the action you want is already listed, or create a new <em>advanced</em> action. You will need to set up an advanced action if there are configuration options in your trigger/action combination, such as specifying an e-mail address or a list of banned words. After configuring or verifying your action, visit the <a href="@triggers-page">Triggers configuration page</a> and choose the appropriate tab (Comment, Taxonomy, etc.), where you can assign the action to run when the trigger event occurs.', array('@triggers-page' => url('admin/structure/trigger'), '@actions-page' => url('admin/config/system/actions'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function trigger_menu() {
+ $items['admin/structure/trigger'] = array(
+ 'title' => 'Triggers',
+ 'description' => 'Configure when to execute actions.',
+ 'page callback' => 'trigger_assign',
+ 'access arguments' => array('administer actions'),
+ 'file' => 'trigger.admin.inc',
+ );
+
+ $trigger_info = _trigger_tab_information();
+ foreach ($trigger_info as $module => $module_name) {
+ $items["admin/structure/trigger/$module"] = array(
+ 'title' => $module_name,
+ 'page callback' => 'trigger_assign',
+ 'page arguments' => array($module),
+ 'access arguments' => array('administer actions'),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'trigger.admin.inc',
+ );
+ }
+
+ $items['admin/structure/trigger/unassign'] = array(
+ 'title' => 'Unassign',
+ 'description' => 'Unassign an action from a trigger.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('trigger_unassign'),
+ 'access arguments' => array('administer actions'),
+ 'file' => 'trigger.admin.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_trigger_info().
+ *
+ * Defines all the triggers that this module implements triggers for.
+ */
+function trigger_trigger_info() {
+ return array(
+ 'node' => array(
+ 'node_presave' => array(
+ 'label' => t('When either saving new content or updating existing content'),
+ ),
+ 'node_insert' => array(
+ 'label' => t('After saving new content'),
+ ),
+ 'node_update' => array(
+ 'label' => t('After saving updated content'),
+ ),
+ 'node_delete' => array(
+ 'label' => t('After deleting content'),
+ ),
+ 'node_view' => array(
+ 'label' => t('When content is viewed by an authenticated user'),
+ ),
+ ),
+ 'comment' => array(
+ 'comment_presave' => array(
+ 'label' => t('When either saving a new comment or updating an existing comment'),
+ ),
+ 'comment_insert' => array(
+ 'label' => t('After saving a new comment'),
+ ),
+ 'comment_update' => array(
+ 'label' => t('After saving an updated comment'),
+ ),
+ 'comment_delete' => array(
+ 'label' => t('After deleting a comment'),
+ ),
+ 'comment_view' => array(
+ 'label' => t('When a comment is being viewed by an authenticated user'),
+ ),
+ ),
+ 'taxonomy' => array(
+ 'taxonomy_term_insert' => array(
+ 'label' => t('After saving a new term to the database'),
+ ),
+ 'taxonomy_term_update' => array(
+ 'label' => t('After saving an updated term to the database'),
+ ),
+ 'taxonomy_term_delete' => array(
+ 'label' => t('After deleting a term'),
+ ),
+ ),
+ 'system' => array(
+ 'cron' => array(
+ 'label' => t('When cron runs'),
+ ),
+ ),
+ 'user' => array(
+ 'user_insert' => array(
+ 'label' => t('After creating a new user account'),
+ ),
+ 'user_update' => array(
+ 'label' => t('After updating a user account'),
+ ),
+ 'user_delete' => array(
+ 'label' => t('After a user has been deleted'),
+ ),
+ 'user_login' => array(
+ 'label' => t('After a user has logged in'),
+ ),
+ 'user_logout' => array(
+ 'label' => t('After a user has logged out'),
+ ),
+ 'user_view' => array(
+ 'label' => t("When a user's profile is being viewed"),
+ ),
+ ),
+ );
+ }
+
+/**
+ * Gets the action IDs of actions to be executed for a hook.
+ *
+ * @param $hook
+ * The name of the hook being fired.
+ * @return
+ * An array whose keys are action IDs that the user has associated with
+ * this trigger, and whose values are arrays containing the action type and
+ * label.
+ */
+function trigger_get_assigned_actions($hook) {
+ return db_query("SELECT ta.aid, a.type, a.label FROM {trigger_assignments} ta LEFT JOIN {actions} a ON ta.aid = a.aid WHERE ta.hook = :hook ORDER BY ta.weight", array(
+ ':hook' => $hook,
+ ))->fetchAllAssoc( 'aid', PDO::FETCH_ASSOC);
+}
+
+/**
+ * Implements hook_theme().
+ */
+function trigger_theme() {
+ return array(
+ 'trigger_display' => array(
+ 'render element' => 'element',
+ 'file' => 'trigger.admin.inc',
+ ),
+ );
+}
+
+/**
+ * Implements hook_forms().
+ *
+ * We re-use code by using the same assignment form definition for each hook.
+ */
+function trigger_forms() {
+ $trigger_info = _trigger_get_all_info();
+ $forms = array();
+ foreach ($trigger_info as $module => $hooks) {
+ foreach ($hooks as $hook => $description) {
+ $forms['trigger_' . $hook . '_assign_form'] = array('callback' => 'trigger_assign_form');
+ }
+ }
+
+ return $forms;
+}
+
+/**
+ * Loads associated objects for node triggers.
+ *
+ * When an action is called in a context that does not match its type, the
+ * object that the action expects must be retrieved. For example, when an action
+ * that works on users is called during a node hook implementation, the user
+ * object is not available since the node hook call doesn't pass it. So here we
+ * load the object the action expects.
+ *
+ * @param $type
+ * The type of action that is about to be called.
+ * @param $node
+ * The node that was passed via the node hook.
+ *
+ * @return
+ * The object expected by the action that is about to be called.
+ */
+function _trigger_normalize_node_context($type, $node) {
+ // Note that comment-type actions are not supported in node contexts,
+ // because we wouldn't know which comment to choose.
+ switch ($type) {
+ // An action that works on users is being called in a node context.
+ // Load the user object of the node's author.
+ case 'user':
+ return user_load($node->uid);
+ }
+}
+
+/**
+ * Calls action functions for node triggers.
+ *
+ * @param $node
+ * Node object.
+ * @param $op
+ * Operation to trigger.
+ * @param $a3
+ * Additional argument to action function.
+ * @param $a4
+ * Additional argument to action function.
+ */
+function _trigger_node($node, $hook, $a3 = NULL, $a4 = NULL) {
+ // Keep objects for reuse so that changes actions make to objects can persist.
+ static $objects;
+ // Prevent recursion by tracking which operations have already been called.
+ static $recursion;
+
+ $aids = trigger_get_assigned_actions($hook);
+ if (!$aids) {
+ return;
+ }
+
+ if (isset($recursion[$hook])) {
+ return;
+ }
+ $recursion[$hook] = TRUE;
+
+ $context = array(
+ 'group' => 'node',
+ 'hook' => $hook,
+ );
+
+ // We need to get the expected object if the action's type is not 'node'.
+ // We keep the object in $objects so we can reuse it if we have multiple actions
+ // that make changes to an object.
+ foreach ($aids as $aid => $info) {
+ $type = $info['type'];
+ if ($type != 'node') {
+ if (!isset($objects[$type])) {
+ $objects[$type] = _trigger_normalize_node_context($type, $node);
+ }
+ // Since we know about the node, we pass that info along to the action.
+ $context['node'] = $node;
+ $result = actions_do($aid, $objects[$type], $context, $a3, $a4);
+ }
+ else {
+ actions_do($aid, $node, $context, $a3, $a4);
+ }
+ }
+
+ unset($recursion[$hook]);
+}
+
+/**
+ * Implements hook_node_view().
+ */
+function trigger_node_view($node, $view_mode) {
+ _trigger_node($node, 'node_view', $view_mode);
+}
+
+/**
+ * Implements hook_node_update().
+ */
+function trigger_node_update($node) {
+ _trigger_node($node, 'node_update');
+}
+
+/**
+ * Implements hook_node_presave().
+ */
+function trigger_node_presave($node) {
+ _trigger_node($node, 'node_presave');
+}
+
+/**
+ * Implements hook_node_insert().
+ */
+function trigger_node_insert($node) {
+ _trigger_node($node, 'node_insert');
+}
+
+/**
+ * Implements hook_node_delete().
+ */
+function trigger_node_delete($node) {
+ _trigger_node($node, 'node_delete');
+}
+
+/**
+ * Loads associated objects for comment triggers.
+ *
+ * When an action is called in a context that does not match its type, the
+ * object that the action expects must be retrieved. For example, when an action
+ * that works on nodes is called during the comment hook, the node object is not
+ * available since the comment hook doesn't pass it. So here we load the object
+ * the action expects.
+ *
+ * @param $type
+ * The type of action that is about to be called.
+ * @param $comment
+ * The comment that was passed via the comment hook.
+ *
+ * @return
+ * The object expected by the action that is about to be called.
+ */
+function _trigger_normalize_comment_context($type, $comment) {
+ switch ($type) {
+ // An action that works with nodes is being called in a comment context.
+ case 'node':
+ return node_load(is_array($comment) ? $comment['nid'] : $comment->nid);
+
+ // An action that works on users is being called in a comment context.
+ case 'user':
+ return user_load(is_array($comment) ? $comment['uid'] : $comment->uid);
+ }
+}
+
+/**
+ * Implements hook_comment_presave().
+ */
+function trigger_comment_presave($comment) {
+ _trigger_comment($comment, 'comment_presave');
+}
+
+/**
+ * Implements hook_comment_insert().
+ */
+function trigger_comment_insert($comment) {
+ _trigger_comment($comment, 'comment_insert');
+}
+
+/**
+ * Implements hook_comment_update().
+ */
+function trigger_comment_update($comment) {
+ _trigger_comment($comment, 'comment_update');
+}
+
+/**
+ * Implements hook_comment_delete().
+ */
+function trigger_comment_delete($comment) {
+ _trigger_comment($comment, 'comment_delete');
+}
+
+/**
+ * Implements hook_comment_view().
+ */
+function trigger_comment_view($comment) {
+ _trigger_comment($comment, 'comment_view');
+}
+
+/**
+ * Calls action functions for comment triggers.
+ *
+ * @param $a1
+ * Comment object or array of form values.
+ * @param $op
+ * Operation to trigger.
+ */
+function _trigger_comment($a1, $hook) {
+ // Keep objects for reuse so that changes actions make to objects can persist.
+ static $objects;
+ $aids = trigger_get_assigned_actions($hook);
+ $context = array(
+ 'group' => 'comment',
+ 'hook' => $hook,
+ );
+ // We need to get the expected object if the action's type is not 'comment'.
+ // We keep the object in $objects so we can reuse it if we have multiple
+ // actions that make changes to an object.
+ foreach ($aids as $aid => $info) {
+ $type = $info['type'];
+ if ($type != 'comment') {
+ if (!isset($objects[$type])) {
+ $objects[$type] = _trigger_normalize_comment_context($type, $a1);
+ }
+ // Since we know about the comment, we pass it along to the action
+ // in case it wants to peek at it.
+ $context['comment'] = (object) $a1;
+ actions_do($aid, $objects[$type], $context);
+ }
+ else {
+ actions_do($aid, $a1, $context);
+ }
+ }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function trigger_cron() {
+ $aids = trigger_get_assigned_actions('cron');
+ $context = array(
+ 'group' => 'cron',
+ 'hook' => 'cron',
+ );
+ // Cron does not act on any specific object.
+ $object = NULL;
+ actions_do(array_keys($aids), $object, $context);
+}
+
+/**
+ * Loads associated objects for user triggers.
+ *
+ * When an action is called in a context that does not match its type, the
+ * object that the action expects must be retrieved. For example, when an action
+ * that works on nodes is called during the user hook, the node object is not
+ * available since the user hook doesn't pass it. So here we load the object the
+ * action expects.
+ *
+ * @param $type
+ * The type of action that is about to be called.
+ * @param $account
+ * The account object that was passed via the user hook.
+ * @return
+ * The object expected by the action that is about to be called.
+ */
+function _trigger_normalize_user_context($type, $account) {
+ // Note that comment-type actions are not supported in user contexts,
+ // because we wouldn't know which comment to choose.
+ switch ($type) {
+ // An action that works with nodes is being called in a user context.
+ // If a single node is being viewed, return the node.
+ case 'node':
+ // If we are viewing an individual node, return the node.
+ if (arg(0) == 'node' && is_numeric(arg(1)) && arg(2) == NULL) {
+ return node_load(array('nid' => arg(1)));
+ }
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_login().
+ */
+function trigger_user_login(&$edit, $account, $category) {
+ _trigger_user('user_login', $edit, $account, $category);
+}
+
+/**
+ * Implements hook_user_logout().
+ */
+function trigger_user_logout($account) {
+ $edit = array();
+ _trigger_user('user_logout', $edit, $account);
+}
+
+/**
+ * Implements hook_user_insert().
+ */
+function trigger_user_insert(&$edit, $account, $category) {
+ _trigger_user('user_insert', $edit, $account, $category);
+}
+
+/**
+ * Implements hook_user_update().
+ */
+function trigger_user_update(&$edit, $account, $category) {
+ _trigger_user('user_update', $edit, $account, $category);
+}
+
+/**
+ * Implements hook_user_cancel().
+ */
+function trigger_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_reassign':
+ _trigger_user('user_delete', $edit, $account, $method);
+ break;
+ }
+}
+
+/**
+ * Implements hook_user_delete().
+ */
+function trigger_user_delete($account) {
+ $edit = array();
+ _trigger_user('user_delete', $edit, $account, NULL);
+}
+
+/**
+ * Implements hook_user_view().
+ */
+function trigger_user_view($account) {
+ $edit = NULL;
+ _trigger_user('user_view', $edit, $account, NULL);
+}
+
+/**
+ * Calls action functions for user triggers.
+ */
+function _trigger_user($hook, &$edit, $account, $category = NULL) {
+ // Keep objects for reuse so that changes actions make to objects can persist.
+ static $objects;
+ $aids = trigger_get_assigned_actions($hook);
+ $context = array(
+ 'group' => 'user',
+ 'hook' => $hook,
+ 'form_values' => &$edit,
+ );
+ foreach ($aids as $aid => $info) {
+ $type = $info['type'];
+ if ($type != 'user') {
+ if (!isset($objects[$type])) {
+ $objects[$type] = _trigger_normalize_user_context($type, $account);
+ }
+ $context['user'] = $account;
+ actions_do($aid, $objects[$type], $context);
+ }
+ else {
+ actions_do($aid, $account, $context, $category);
+ }
+ }
+}
+
+/**
+ * Calls action functions for taxonomy triggers.
+ *
+ * @param $hook
+ * Hook to trigger actions for taxonomy_term_insert(),
+ * taxonomy_term_update(), and taxonomy_term_delete().
+ * @param $array
+ * Item on which operation is being performed, either a term or
+ * form values.
+ */
+function _trigger_taxonomy($hook, $array) {
+ $aids = trigger_get_assigned_actions($hook);
+ $context = array(
+ 'group' => 'taxonomy',
+ 'hook' => $hook
+ );
+ actions_do(array_keys($aids), (object) $array, $context);
+}
+
+/**
+ * Implements hook_taxonomy_term_insert().
+ */
+function trigger_taxonomy_term_insert($term) {
+ _trigger_taxonomy('taxonomy_term_insert', (array) $term);
+}
+
+/**
+ * Implements hook_taxonomy_term_update().
+ */
+function trigger_taxonomy_term_update($term) {
+ _trigger_taxonomy('taxonomy_term_update', (array) $term);
+}
+
+/**
+ * Implements hook_taxonomy_term_delete().
+ */
+function trigger_taxonomy_term_delete($term) {
+ _trigger_taxonomy('taxonomy_term_delete', (array) $term);
+}
+
+/**
+ * Implements hook_actions_delete().
+ *
+ * Removes all trigger entries for the given action, when an action is deleted.
+ */
+function trigger_actions_delete($aid) {
+ db_delete('trigger_assignments')
+ ->condition('aid', $aid)
+ ->execute();
+}
+
+/**
+ * Retrieves and caches information from hook_trigger_info() implementations.
+ */
+function _trigger_get_all_info() {
+ $triggers = &drupal_static(__FUNCTION__);
+
+ if (!isset($triggers)) {
+ $triggers = module_invoke_all('trigger_info');
+ drupal_alter('trigger_info', $triggers);
+ }
+
+ return $triggers;
+}
+
+/**
+ * Gathers information about tabs on the triggers administration screen.
+ *
+ * @return
+ * Array of modules that have triggers, with the keys being the
+ * machine-readable name of the module, and the values being the
+ * human-readable name of the module.
+ */
+function _trigger_tab_information() {
+ // Gather information about all triggers and modules.
+ $trigger_info = _trigger_get_all_info();
+ $modules = system_get_info('module');
+ $modules = array_intersect_key($modules, $trigger_info);
+
+ $return_info = array();
+ foreach ($modules as $name => $info) {
+ $return_info[$name] = $info['name'];
+ }
+
+ return $return_info;
+}
diff --git a/core/modules/trigger/trigger.test b/core/modules/trigger/trigger.test
new file mode 100644
index 000000000000..9a9a4ba28240
--- /dev/null
+++ b/core/modules/trigger/trigger.test
@@ -0,0 +1,740 @@
+<?php
+
+/**
+ * @file
+ * Tests for trigger.module.
+ */
+
+/**
+ * Provides common helper methods.
+ */
+class TriggerWebTestCase extends DrupalWebTestCase {
+
+ /**
+ * Configure an advanced action.
+ *
+ * @param $action
+ * The name of the action callback. For example: 'user_block_user_action'
+ * @param $edit
+ * The $edit array for the form to be used to configure.
+ * Example members would be 'actions_label' (always), 'message', etc.
+ *
+ * @return
+ * the aid (action id) of the configured action, or FALSE if none.
+ */
+ protected function configureAdvancedAction($action, $edit) {
+ // Create an advanced action.
+ $hash = drupal_hash_base64($action);
+ $this->drupalPost("admin/config/system/actions/configure/$hash", $edit, t('Save'));
+ $this->assertText(t('The action has been successfully saved.'));
+
+ // Now we have to find out the action ID of what we created.
+ return db_query('SELECT aid FROM {actions} WHERE callback = :callback AND label = :label', array(':callback' => $action, ':label' => $edit['actions_label']))->fetchField();
+ }
+
+}
+
+/**
+ * Provides tests for node triggers.
+ */
+class TriggerContentTestCase extends TriggerWebTestCase {
+ var $_cleanup_roles = array();
+ var $_cleanup_users = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Trigger content (node) actions',
+ 'description' => 'Perform various tests with content actions.',
+ 'group' => 'Trigger',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('trigger', 'trigger_test');
+ }
+
+ /**
+ * Tests several content-oriented trigger issues.
+ *
+ * These are in one function to assure they happen in the right order.
+ */
+ function testActionsContent() {
+ global $user;
+ $content_actions = array('node_publish_action', 'node_unpublish_action', 'node_make_sticky_action', 'node_make_unsticky_action', 'node_promote_action', 'node_unpromote_action');
+
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $web_user = $this->drupalCreateUser(array('create page content', 'access content', 'administer nodes'));
+ foreach ($content_actions as $action) {
+ $hash = drupal_hash_base64($action);
+ $info = $this->actionInfo($action);
+
+ // Assign an action to a trigger, then pull the trigger, and make sure
+ // the actions fire.
+ $this->drupalLogin($test_user);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/node', $edit, t('Assign'), array(), array(), 'trigger-node-presave-assign-form');
+ // Create an unpublished node.
+ $this->drupalLogin($web_user);
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = '!SimpleTest test node! ' . $this->randomName(10);
+ $edit["body[$langcode][0][value]"] = '!SimpleTest test body! ' . $this->randomName(32) . ' ' . $this->randomName(32);
+ $edit[$info['property']] = !$info['expected'];
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ // Make sure the text we want appears.
+ $this->assertRaw(t('!post %title has been created.', array('!post' => 'Basic page', '%title' => $edit["title"])), t('Make sure the Basic page has actually been created'));
+ // Action should have been fired.
+ $loaded_node = $this->drupalGetNodeByTitle($edit["title"]);
+ $this->assertTrue($loaded_node->$info['property'] == $info['expected'], t('Make sure the @action action fired.', array('@action' => $info['name'])));
+ // Leave action assigned for next test
+
+ // There should be an error when the action is assigned to the trigger
+ // twice.
+ $this->drupalLogin($test_user);
+ // This action already assigned in this test.
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/node', $edit, t('Assign'), array(), array(), 'trigger-node-presave-assign-form');
+ $this->assertRaw(t('The action you chose is already assigned to that trigger.'), t('Check to make sure an error occurs when assigning an action to a trigger twice.'));
+
+ // The action should be able to be unassigned from a trigger.
+ $this->drupalPost('admin/structure/trigger/unassign/node/node_presave/' . $hash, array(), t('Unassign'));
+ $this->assertRaw(t('Action %action has been unassigned.', array('%action' => ucfirst($info['name']))), t('Check to make sure the @action action can be unassigned from the trigger.', array('@action' => $info['name'])));
+ $assigned = db_query("SELECT COUNT(*) FROM {trigger_assignments} WHERE aid IN (:keys)", array(':keys' => $content_actions))->fetchField();
+ $this->assertFalse($assigned, t('Check to make sure unassign worked properly at the database level.'));
+ }
+ }
+
+ /**
+ * Tests multiple node actions.
+ *
+ * Verifies that node actions are fired for each node individually, if acting
+ * on multiple nodes.
+ */
+ function testActionContentMultiple() {
+ // Assign an action to the node save/update trigger.
+ $test_user = $this->drupalCreateUser(array('administer actions', 'administer nodes', 'create page content', 'access administration pages', 'access content overview'));
+ $this->drupalLogin($test_user);
+
+ for ($index = 0; $index < 3; $index++) {
+ $edit = array('title' => $this->randomName());
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ }
+
+ $action_id = 'trigger_test_generic_any_action';
+ $hash = drupal_hash_base64($action_id);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/node', $edit, t('Assign'), array(), array(), 'trigger-node-update-assign-form');
+
+ $edit = array(
+ 'operation' => 'unpublish',
+ 'nodes[1]' => TRUE,
+ 'nodes[2]' => TRUE,
+ );
+ $this->drupalPost('admin/content', $edit, t('Update'));
+ $count = variable_get('trigger_test_generic_any_action', 0);
+ $this->assertTrue($count == 2, t('Action was triggered 2 times. Actual: %count', array('%count' => $count)));
+ }
+
+ /**
+ * Returns some info about each of the content actions.
+ *
+ * This is helper function for testActionsContent().
+ *
+ * @param $action
+ * The name of the action to return info about.
+ *
+ * @return
+ * An associative array of info about the action.
+ */
+ function actionInfo($action) {
+ $info = array(
+ 'node_publish_action' => array(
+ 'property' => 'status',
+ 'expected' => 1,
+ 'name' => t('publish content'),
+ ),
+ 'node_unpublish_action' => array(
+ 'property' => 'status',
+ 'expected' => 0,
+ 'name' => t('unpublish content'),
+ ),
+ 'node_make_sticky_action' => array(
+ 'property' => 'sticky',
+ 'expected' => 1,
+ 'name' => t('make content sticky'),
+ ),
+ 'node_make_unsticky_action' => array(
+ 'property' => 'sticky',
+ 'expected' => 0,
+ 'name' => t('make content unsticky'),
+ ),
+ 'node_promote_action' => array(
+ 'property' => 'promote',
+ 'expected' => 1,
+ 'name' => t('promote content to front page'),
+ ),
+ 'node_unpromote_action' => array(
+ 'property' => 'promote',
+ 'expected' => 0,
+ 'name' => t('remove content from front page'),
+ ),
+ );
+ return $info[$action];
+ }
+}
+
+/**
+ * Tests cron trigger.
+ */
+class TriggerCronTestCase extends TriggerWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Trigger cron (system) actions',
+ 'description' => 'Perform various tests with cron trigger.',
+ 'group' => 'Trigger',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('trigger', 'trigger_test');
+ }
+
+ /**
+ * Tests assigning multiple actions to the cron trigger.
+ *
+ * This test ensures that both simple and multiple complex actions
+ * succeed properly. This is done in the cron trigger test because
+ * cron allows passing multiple actions in at once.
+ */
+ function testActionsCron() {
+ // Create an administrative user.
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($test_user);
+
+ // Assign a non-configurable action to the cron run trigger.
+ $edit = array('aid' => drupal_hash_base64('trigger_test_system_cron_action'));
+ $this->drupalPost('admin/structure/trigger/system', $edit, t('Assign'), array(), array(), 'trigger-cron-assign-form');
+
+ // Assign a configurable action to the cron trigger.
+ $action_label = $this->randomName();
+ $edit = array(
+ 'actions_label' => $action_label,
+ 'subject' => $action_label,
+ );
+ $aid = $this->configureAdvancedAction('trigger_test_system_cron_conf_action', $edit);
+ // $aid is likely 3 but if we add more uses for the sequences table in
+ // core it might break, so it is easier to get the value from the database.
+ $edit = array('aid' => drupal_hash_base64($aid));
+ $this->drupalPost('admin/structure/trigger/system', $edit, t('Assign'), array(), array(), 'trigger-cron-assign-form');
+
+ // Add a second configurable action to the cron trigger.
+ $action_label = $this->randomName();
+ $edit = array(
+ 'actions_label' => $action_label,
+ 'subject' => $action_label,
+ );
+ $aid = $this->configureAdvancedAction('trigger_test_system_cron_conf_action', $edit);
+ $edit = array('aid' => drupal_hash_base64($aid));
+ $this->drupalPost('admin/structure/trigger/system', $edit, t('Assign'), array(), array(), 'trigger-cron-assign-form');
+
+ // Force a cron run.
+ $this->cronRun();
+
+ // Make sure the non-configurable action has fired.
+ $action_run = variable_get('trigger_test_system_cron_action', FALSE);
+ $this->assertTrue($action_run, t('Check that the cron run triggered the test action.'));
+
+ // Make sure that both configurable actions have fired.
+ $action_run = variable_get('trigger_test_system_cron_conf_action', 0) == 2;
+ $this->assertTrue($action_run, t('Check that the cron run triggered both complex actions.'));
+ }
+}
+
+/**
+ * Provides a base class with trigger assignments and test comparisons.
+ */
+class TriggerActionTestCase extends TriggerWebTestCase {
+
+ function setUp() {
+ parent::setUp('trigger');
+ }
+
+ /**
+ * Creates a message with tokens.
+ *
+ * @param $trigger
+ *
+ * @return
+ * A message with embedded tokens.
+ */
+ function generateMessageWithTokens($trigger) {
+ // Note that subject is limited to 254 characters in action configuration.
+ $message = t('Action was triggered by trigger @trigger user:name=[user:name] user:uid=[user:uid] user:mail=[user:mail] user:url=[user:url] user:edit-url=[user:edit-url] user:created=[user:created]',
+ array('@trigger' => $trigger));
+ return trim($message);
+ }
+
+ /**
+ * Generates a comparison message to match the pre-token-replaced message.
+ *
+ * @param $trigger
+ * Trigger, like 'user_login'.
+ * @param $account
+ * Associated user account.
+ *
+ * @return
+ * The token-replaced equivalent message. This does not use token
+ * functionality.
+ *
+ * @see generateMessageWithTokens()
+ */
+ function generateTokenExpandedComparison($trigger, $account) {
+ // Note that user:last-login was omitted because it changes and can't
+ // be properly verified.
+ $message = t('Action was triggered by trigger @trigger user:name=@username user:uid=@uid user:mail=@mail user:url=@user_url user:edit-url=@user_edit_url user:created=@user_created',
+ array(
+ '@trigger' => $trigger,
+ '@username' => $account->name,
+ '@uid' => !empty($account->uid) ? $account->uid : t('not yet assigned'),
+ '@mail' => $account->mail,
+ '@user_url' => !empty($account->uid) ? url("user/$account->uid", array('absolute' => TRUE)) : t('not yet assigned'),
+ '@user_edit_url' => !empty($account->uid) ? url("user/$account->uid/edit", array('absolute' => TRUE)) : t('not yet assigned'),
+ '@user_created' => isset($account->created) ? format_date($account->created, 'medium') : t('not yet created'),
+ )
+ );
+ return trim($message);
+ }
+
+
+ /**
+ * Assigns a simple (non-configurable) action to a trigger.
+ *
+ * @param $trigger
+ * The trigger to assign to, like 'user_login'.
+ * @param $action
+ * The simple action to be assigned, like 'comment_insert'.
+ */
+ function assignSimpleAction($trigger, $action) {
+ $form_name = "trigger_{$trigger}_assign_form";
+ $form_html_id = strtr($form_name, '_', '-');
+ $edit = array('aid' => drupal_hash_base64($action));
+ $trigger_type = preg_replace('/_.*/', '', $trigger);
+ $this->drupalPost("admin/structure/trigger/$trigger_type", $edit, t('Assign'), array(), array(), $form_html_id);
+ $actions = trigger_get_assigned_actions($trigger);
+ $this->assertTrue(!empty($actions[$action]), t('Simple action @action assigned to trigger @trigger', array('@action' => $action, '@trigger' => $trigger)));
+ }
+
+ /**
+ * Assigns a system message action to the passed-in trigger.
+ *
+ * @param $trigger
+ * For example, 'user_login'
+ */
+ function assignSystemMessageAction($trigger) {
+ $form_name = "trigger_{$trigger}_assign_form";
+ $form_html_id = strtr($form_name, '_', '-');
+ // Assign a configurable action 'System message' to the passed trigger.
+ $action_edit = array(
+ 'actions_label' => $trigger . "_system_message_action_" . $this->randomName(16),
+ 'message' => $this->generateMessageWithTokens($trigger),
+ );
+
+ // Configure an advanced action that we can assign.
+ $aid = $this->configureAdvancedAction('system_message_action', $action_edit);
+
+ $edit = array('aid' => drupal_hash_base64($aid));
+ $this->drupalPost('admin/structure/trigger/user', $edit, t('Assign'), array(), array(), $form_html_id);
+ }
+
+
+ /**
+ * Assigns a system_send_email_action to the passed-in trigger.
+ *
+ * @param $trigger
+ * For example, 'user_login'
+ */
+ function assignSystemEmailAction($trigger) {
+ $form_name = "trigger_{$trigger}_assign_form";
+ $form_html_id = strtr($form_name, '_', '-');
+
+ $message = $this->generateMessageWithTokens($trigger);
+ // Assign a configurable action 'System message' to the passed trigger.
+ $action_edit = array(
+ // 'actions_label' => $trigger . "_system_send_message_action_" . $this->randomName(16),
+ 'actions_label' => $trigger . "_system_send_email_action",
+ 'recipient' => '[user:mail]',
+ 'subject' => $message,
+ 'message' => $message,
+ );
+
+ // Configure an advanced action that we can assign.
+ $aid = $this->configureAdvancedAction('system_send_email_action', $action_edit);
+
+ $edit = array('aid' => drupal_hash_base64($aid));
+ $this->drupalPost('admin/structure/trigger/user', $edit, t('Assign'), array(), array(), $form_html_id);
+ }
+
+ /**
+ * Asserts correct token replacement in both system message and email.
+ *
+ * @param $trigger
+ * A trigger like 'user_login'.
+ * @param $account
+ * The user account which triggered the action.
+ * @param $email_depth
+ * Number of emails to scan, starting with most recent.
+ */
+ function assertSystemMessageAndEmailTokenReplacement($trigger, $account, $email_depth = 1) {
+ $this->assertSystemMessageTokenReplacement($trigger, $account);
+ $this->assertSystemEmailTokenReplacement($trigger, $account, $email_depth);
+ }
+
+ /**
+ * Asserts correct token replacement for the given trigger and account.
+ *
+ * @param $trigger
+ * A trigger like 'user_login'.
+ * @param $account
+ * The user account which triggered the action.
+ */
+ function assertSystemMessageTokenReplacement($trigger, $account) {
+ $expected = $this->generateTokenExpandedComparison($trigger, $account);
+ $this->assertText($expected,
+ t('Expected system message to contain token-replaced text "@expected" found in configured system message action', array('@expected' => $expected )) );
+ }
+
+
+ /**
+ * Asserts correct token replacement for the given trigger and account.
+ *
+ * @param $trigger
+ * A trigger like 'user_login'.
+ * @param $account
+ * The user account which triggered the action.
+ * @param $email_depth
+ * Number of emails to scan, starting with most recent.
+ */
+ function assertSystemEmailTokenReplacement($trigger, $account, $email_depth = 1) {
+ $this->verboseEmail($email_depth);
+ $expected = $this->generateTokenExpandedComparison($trigger, $account);
+ $this->assertMailString('subject', $expected, $email_depth);
+ $this->assertMailString('body', $expected, $email_depth);
+ $this->assertMail('to', $account->mail, t('Mail sent to correct destination'));
+ }
+}
+
+/**
+ * Tests token substitution in trigger actions.
+ *
+ * This tests nearly every permutation of user triggers with system actions
+ * and checks the token replacement.
+ */
+class TriggerUserTokenTestCase extends TriggerActionTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Test user triggers',
+ 'description' => 'Test user triggers and system actions with token replacement.',
+ 'group' => 'Trigger',
+ );
+ }
+
+
+ /**
+ * Tests a variety of token replacements in actions.
+ */
+ function testUserTriggerTokenReplacement() {
+ $test_user = $this->drupalCreateUser(array('administer actions', 'administer users', 'change own username', 'access user profiles'));
+ $this->drupalLogin($test_user);
+
+ $triggers = array('user_login', 'user_insert', 'user_update', 'user_delete', 'user_logout', 'user_view');
+ foreach ($triggers as $trigger) {
+ $this->assignSystemMessageAction($trigger);
+ $this->assignSystemEmailAction($trigger);
+ }
+
+ $this->drupalLogout();
+ $this->assertSystemEmailTokenReplacement('user_logout', $test_user);
+
+ $this->drupalLogin($test_user);
+ $this->assertSystemMessageAndEmailTokenReplacement('user_login', $test_user, 2);
+ $this->assertSystemMessageAndEmailTokenReplacement('user_view', $test_user, 2);
+
+ $this->drupalPost("user/{$test_user->uid}/edit", array('name' => $test_user->name . '_changed'), t('Save'));
+ $test_user->name .= '_changed'; // Since we just changed it.
+ $this->assertSystemMessageAndEmailTokenReplacement('user_update', $test_user, 2);
+
+ $this->drupalGet('user');
+ $this->assertSystemMessageAndEmailTokenReplacement('user_view', $test_user);
+
+ $new_user = $this->drupalCreateUser(array('administer actions', 'administer users', 'cancel account', 'access administration pages'));
+ $this->assertSystemEmailTokenReplacement('user_insert', $new_user);
+
+ $this->drupalLogin($new_user);
+ $user_to_delete = $this->drupalCreateUser(array('access content'));
+ variable_set('user_cancel_method', 'user_cancel_delete');
+
+ $this->drupalPost("user/{$user_to_delete->uid}/cancel", array(), t('Cancel account'));
+ $this->assertSystemMessageAndEmailTokenReplacement('user_delete', $user_to_delete);
+ }
+
+
+}
+
+/**
+ * Tests token substitution in trigger actions.
+ *
+ * This tests nearly every permutation of user triggers with system actions
+ * and checks the token replacement.
+ */
+class TriggerUserActionTestCase extends TriggerActionTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Test user actions',
+ 'description' => 'Test user actions.',
+ 'group' => 'Trigger',
+ );
+ }
+
+ /**
+ * Tests user action assignment and execution.
+ */
+ function testUserActionAssignmentExecution() {
+ $test_user = $this->drupalCreateUser(array('administer actions', 'create article content', 'access comments', 'administer comments', 'skip comment approval', 'edit own comments'));
+ $this->drupalLogin($test_user);
+
+ $triggers = array('comment_presave', 'comment_insert', 'comment_update');
+ // system_block_ip_action is difficult to test without ruining the test.
+ $actions = array('user_block_user_action');
+ foreach ($triggers as $trigger) {
+ foreach ($actions as $action) {
+ $this->assignSimpleAction($trigger, $action);
+ }
+ }
+
+ $node = $this->drupalCreateNode(array('type' => 'article'));
+ $this->drupalPost("node/{$node->nid}", array('comment_body[und][0][value]' => t("my comment"), 'subject' => t("my comment subject")), t('Save'));
+ // Posting a comment should have blocked this user.
+ $account = user_load($test_user->uid, TRUE);
+ $this->assertTrue($account->status == 0, t('Account is blocked'));
+ $comment_author_uid = $account->uid;
+ // Now rehabilitate the comment author so it can be be blocked again when
+ // the comment is updated.
+ user_save($account, array('status' => TRUE));
+
+ $test_user = $this->drupalCreateUser(array('administer actions', 'create article content', 'access comments', 'administer comments', 'skip comment approval', 'edit own comments'));
+ $this->drupalLogin($test_user);
+
+ // Our original comment will have been comment 1.
+ $this->drupalPost("comment/1/edit", array('comment_body[und][0][value]' => t("my comment, updated"), 'subject' => t("my comment subject")), t('Save'));
+ $comment_author_account = user_load($comment_author_uid, TRUE);
+ $this->assertTrue($comment_author_account->status == 0, t('Comment author account (uid=@uid) is blocked after update to comment', array('@uid' => $comment_author_uid)));
+
+ // Verify that the comment was updated.
+ $test_user = $this->drupalCreateUser(array('administer actions', 'create article content', 'access comments', 'administer comments', 'skip comment approval', 'edit own comments'));
+ $this->drupalLogin($test_user);
+
+ $this->drupalGet("node/$node->nid");
+ $this->assertText(t("my comment, updated"));
+ $this->verboseEmail();
+ }
+}
+
+/**
+ * Tests other triggers.
+ */
+class TriggerOtherTestCase extends TriggerWebTestCase {
+ var $_cleanup_roles = array();
+ var $_cleanup_users = array();
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Trigger other actions',
+ 'description' => 'Test triggering of user, comment, taxonomy actions.',
+ 'group' => 'Trigger',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('trigger', 'trigger_test', 'contact');
+ }
+
+ /**
+ * Tests triggering on user create and user login.
+ */
+ function testActionsUser() {
+ // Assign an action to the create user trigger.
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($test_user);
+ $action_id = 'trigger_test_generic_action';
+ $hash = drupal_hash_base64($action_id);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/user', $edit, t('Assign'), array(), array(), 'trigger-user-insert-assign-form');
+
+ // Set action variable to FALSE.
+ variable_set($action_id, FALSE);
+
+ // Create an unblocked user
+ $web_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($web_user);
+ $name = $this->randomName();
+ $pass = user_password();
+ $edit = array();
+ $edit['name'] = $name;
+ $edit['mail'] = $name . '@example.com';
+ $edit['pass[pass1]'] = $pass;
+ $edit['pass[pass2]'] = $pass;
+ $edit['status'] = 1;
+ $this->drupalPost('admin/people/create', $edit, t('Create new account'));
+
+ // Verify that the action variable has been set.
+ $this->assertTrue(variable_get($action_id, FALSE), t('Check that creating a user triggered the test action.'));
+
+ // Reset the action variable.
+ variable_set($action_id, FALSE);
+
+ $this->drupalLogin($test_user);
+ // Assign a configurable action 'System message' to the user_login trigger.
+ $action_edit = array(
+ 'actions_label' => $this->randomName(16),
+ 'message' => t("You have logged in:") . $this->randomName(16),
+ );
+
+ // Configure an advanced action that we can assign.
+ $aid = $this->configureAdvancedAction('system_message_action', $action_edit);
+ $edit = array('aid' => drupal_hash_base64($aid));
+ $this->drupalPost('admin/structure/trigger/user', $edit, t('Assign'), array(), array(), 'trigger-user-login-assign-form');
+
+ // Verify that the action has been assigned to the correct hook.
+ $actions = trigger_get_assigned_actions('user_login');
+ $this->assertEqual(1, count($actions), t('One Action assigned to the hook'));
+ $this->assertEqual($actions[$aid]['label'], $action_edit['actions_label'], t('Correct action label found.'));
+
+ // User should get the configured message at login.
+ $contact_user = $this->drupalCreateUser(array('access site-wide contact form'));;
+ $this->drupalLogin($contact_user);
+ $this->assertText($action_edit['message']);
+ }
+
+ /**
+ * Tests triggering on comment save.
+ */
+ function testActionsComment() {
+ // Assign an action to the comment save trigger.
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($test_user);
+ $action_id = 'trigger_test_generic_action';
+ $hash = drupal_hash_base64($action_id);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/comment', $edit, t('Assign'), array(), array(), 'trigger-comment-insert-assign-form');
+
+ // Set action variable to FALSE.
+ variable_set($action_id, FALSE);
+
+ // Create a node and add a comment to it.
+ $web_user = $this->drupalCreateUser(array('create article content', 'access content', 'skip comment approval', 'post comments'));
+ $this->drupalLogin($web_user);
+ $node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1));
+ $edit = array();
+ $edit['subject'] = $this->randomName(10);
+ $edit['comment_body[' . LANGUAGE_NONE . '][0][value]'] = $this->randomName(10) . ' ' . $this->randomName(10);
+ $this->drupalGet('comment/reply/' . $node->nid);
+ $this->drupalPost(NULL, $edit, t('Save'));
+
+ // Verify that the action variable has been set.
+ $this->assertTrue(variable_get($action_id, FALSE), t('Check that creating a comment triggered the action.'));
+ }
+
+ /**
+ * Tests triggering on taxonomy new term.
+ */
+ function testActionsTaxonomy() {
+ // Assign an action to the taxonomy term save trigger.
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($test_user);
+ $action_id = 'trigger_test_generic_action';
+ $hash = drupal_hash_base64($action_id);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/taxonomy', $edit, t('Assign'), array(), array(), 'trigger-taxonomy-term-insert-assign-form');
+
+ // Set action variable to FALSE.
+ variable_set($action_id, FALSE);
+
+ // Create a taxonomy vocabulary and add a term to it.
+
+ // Create a vocabulary.
+ $vocabulary = new stdClass();
+ $vocabulary->name = $this->randomName();
+ $vocabulary->description = $this->randomName();
+ $vocabulary->machine_name = drupal_strtolower($this->randomName());
+ $vocabulary->help = '';
+ $vocabulary->nodes = array('article' => 'article');
+ $vocabulary->weight = mt_rand(0, 10);
+ taxonomy_vocabulary_save($vocabulary);
+
+ $term = new stdClass();
+ $term->name = $this->randomName();
+ $term->vid = $vocabulary->vid;
+ taxonomy_term_save($term);
+
+ // Verify that the action variable has been set.
+ $this->assertTrue(variable_get($action_id, FALSE), t('Check that creating a taxonomy term triggered the action.'));
+ }
+
+}
+
+/**
+ * Tests that orphaned actions are properly handled.
+ */
+class TriggerOrphanedActionsTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Trigger orphaned actions',
+ 'description' => 'Test triggering an action that has since been removed.',
+ 'group' => 'Trigger',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('trigger', 'trigger_test');
+ }
+
+ /**
+ * Tests logic around orphaned actions.
+ */
+ function testActionsOrphaned() {
+ $action = 'trigger_test_generic_any_action';
+ $hash = drupal_hash_base64($action);
+
+ // Assign an action from a disable-able module to a trigger, then pull the
+ // trigger, and make sure the actions fire.
+ $test_user = $this->drupalCreateUser(array('administer actions'));
+ $this->drupalLogin($test_user);
+ $edit = array('aid' => $hash);
+ $this->drupalPost('admin/structure/trigger/node', $edit, t('Assign'), array(), array(), 'trigger-node-presave-assign-form');
+
+ // Create an unpublished node.
+ $web_user = $this->drupalCreateUser(array('create page content', 'edit own page content', 'access content', 'administer nodes'));
+ $this->drupalLogin($web_user);
+ $edit = array();
+ $langcode = LANGUAGE_NONE;
+ $edit["title"] = '!SimpleTest test node! ' . $this->randomName(10);
+ $edit["body[$langcode][0][value]"] = '!SimpleTest test body! ' . $this->randomName(32) . ' ' . $this->randomName(32);
+ $this->drupalPost('node/add/page', $edit, t('Save'));
+ $this->assertRaw(t('!post %title has been created.', array('!post' => 'Basic page', '%title' => $edit["title"])), t('Make sure the Basic page has actually been created'));
+
+ // Action should have been fired.
+ $this->assertTrue(variable_get('trigger_test_generic_any_action', FALSE), t('Trigger test action successfully fired.'));
+
+ // Disable the module that provides the action and make sure the trigger
+ // doesn't white screen.
+ module_disable(array('trigger_test'));
+ $loaded_node = $this->drupalGetNodeByTitle($edit["title"]);
+ $edit["body[$langcode][0][value]"] = '!SimpleTest test body! ' . $this->randomName(32) . ' ' . $this->randomName(32);
+ $this->drupalPost("node/$loaded_node->nid/edit", $edit, t('Save'));
+
+ // If the node body was updated successfully we have dealt with the
+ // unavailable action.
+ $this->assertRaw(t('!post %title has been updated.', array('!post' => 'Basic page', '%title' => $edit["title"])), t('Make sure the Basic page can be updated with the missing trigger function.'));
+ }
+}
diff --git a/core/modules/update/tests/aaa_update_test.1_0.xml b/core/modules/update/tests/aaa_update_test.1_0.xml
new file mode 100644
index 000000000000..5fc0d445b93d
--- /dev/null
+++ b/core/modules/update/tests/aaa_update_test.1_0.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>AAA Update test</title>
+<short_name>aaa_update_test</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>1</recommended_major>
+<supported_majors>1</supported_majors>
+<default_major>1</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/aaa_update_test</link>
+ <terms>
+ <term><name>Projects</name><value>Modules</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>aaa_update_test 8.x-1.0</name>
+ <version>8.x-1.0</version>
+ <tag>DRUPAL-7--1-0</tag>
+ <version_major>1</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/aaa_update_test-7-x-1-0-release</release_link>
+ <download_link>http://example.com/aaa_update_test-8.x-1.0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/aaa_update_test.info b/core/modules/update/tests/aaa_update_test.info
new file mode 100644
index 000000000000..60646782bc12
--- /dev/null
+++ b/core/modules/update/tests/aaa_update_test.info
@@ -0,0 +1,5 @@
+name = AAA Update test
+description = Support module for update module testing.
+package = Testing
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/update/tests/aaa_update_test.module b/core/modules/update/tests/aaa_update_test.module
new file mode 100644
index 000000000000..4d67b8e40fcb
--- /dev/null
+++ b/core/modules/update/tests/aaa_update_test.module
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Dummy module for testing Update status.
+ */
diff --git a/core/modules/update/tests/aaa_update_test.no-releases.xml b/core/modules/update/tests/aaa_update_test.no-releases.xml
new file mode 100644
index 000000000000..e266d4992a18
--- /dev/null
+++ b/core/modules/update/tests/aaa_update_test.no-releases.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<error>No release history was found for the requested project (aaa_update_test).</error>
diff --git a/core/modules/update/tests/aaa_update_test.tar.gz b/core/modules/update/tests/aaa_update_test.tar.gz
new file mode 100644
index 000000000000..34f96c87f99d
--- /dev/null
+++ b/core/modules/update/tests/aaa_update_test.tar.gz
Binary files differ
diff --git a/core/modules/update/tests/bbb_update_test.1_0.xml b/core/modules/update/tests/bbb_update_test.1_0.xml
new file mode 100644
index 000000000000..8d705b5f9697
--- /dev/null
+++ b/core/modules/update/tests/bbb_update_test.1_0.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>BBB Update test</title>
+<short_name>bbb_update_test</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>1</recommended_major>
+<supported_majors>1</supported_majors>
+<default_major>1</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/bbb_update_test</link>
+ <terms>
+ <term><name>Projects</name><value>Modules</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>bbb_update_test 8.x-1.0</name>
+ <version>8.x-1.0</version>
+ <tag>DRUPAL-7--1-0</tag>
+ <version_major>1</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/bbb_update_test-7-x-1-0-release</release_link>
+ <download_link>http://example.com/bbb_update_test-8.x-1.0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/bbb_update_test.info b/core/modules/update/tests/bbb_update_test.info
new file mode 100644
index 000000000000..95aacba18e3e
--- /dev/null
+++ b/core/modules/update/tests/bbb_update_test.info
@@ -0,0 +1,5 @@
+name = BBB Update test
+description = Support module for update module testing.
+package = Testing
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/update/tests/bbb_update_test.module b/core/modules/update/tests/bbb_update_test.module
new file mode 100644
index 000000000000..4d67b8e40fcb
--- /dev/null
+++ b/core/modules/update/tests/bbb_update_test.module
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Dummy module for testing Update status.
+ */
diff --git a/core/modules/update/tests/ccc_update_test.1_0.xml b/core/modules/update/tests/ccc_update_test.1_0.xml
new file mode 100644
index 000000000000..82764c2c33d5
--- /dev/null
+++ b/core/modules/update/tests/ccc_update_test.1_0.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>CCC Update test</title>
+<short_name>ccc_update_test</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>1</recommended_major>
+<supported_majors>1</supported_majors>
+<default_major>1</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/ccc_update_test</link>
+ <terms>
+ <term><name>Projects</name><value>Modules</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>ccc_update_test 8.x-1.0</name>
+ <version>8.x-1.0</version>
+ <tag>DRUPAL-7--1-0</tag>
+ <version_major>1</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/ccc_update_test-7-x-1-0-release</release_link>
+ <download_link>http://example.com/ccc_update_test-8.x-1.0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/ccc_update_test.info b/core/modules/update/tests/ccc_update_test.info
new file mode 100644
index 000000000000..f4df516b808d
--- /dev/null
+++ b/core/modules/update/tests/ccc_update_test.info
@@ -0,0 +1,5 @@
+name = CCC Update test
+description = Support module for update module testing.
+package = Testing
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/update/tests/ccc_update_test.module b/core/modules/update/tests/ccc_update_test.module
new file mode 100644
index 000000000000..4d67b8e40fcb
--- /dev/null
+++ b/core/modules/update/tests/ccc_update_test.module
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * Dummy module for testing Update status.
+ */
diff --git a/core/modules/update/tests/drupal.0.xml b/core/modules/update/tests/drupal.0.xml
new file mode 100644
index 000000000000..7e32072a16c7
--- /dev/null
+++ b/core/modules/update/tests/drupal.0.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>7</recommended_major>
+<supported_majors>7</supported_majors>
+<default_major>7</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+ <terms>
+ <term><name>Projects</name><value>Drupal project</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>Drupal 7.0</name>
+ <version>7.0</version>
+ <tag>DRUPAL-7-0</tag>
+ <version_major>7</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-0-release</release_link>
+ <download_link>http://example.com/drupal-7-0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/drupal.1.xml b/core/modules/update/tests/drupal.1.xml
new file mode 100644
index 000000000000..c210a5a9b188
--- /dev/null
+++ b/core/modules/update/tests/drupal.1.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>7</recommended_major>
+<supported_majors>7</supported_majors>
+<default_major>7</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+ <terms>
+ <term><name>Projects</name><value>Drupal project</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>Drupal 7.1</name>
+ <version>7.1</version>
+ <tag>DRUPAL-7-1</tag>
+ <version_major>7</version_major>
+ <version_patch>1</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-1-release</release_link>
+ <download_link>http://example.com/drupal-7-1.tar.gz</download_link>
+ <date>1250424581</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>2147483648</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+ <release>
+ <name>Drupal 7.0</name>
+ <version>7.0</version>
+ <tag>DRUPAL-7-0</tag>
+ <version_major>7</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-0-release</release_link>
+ <download_link>http://example.com/drupal-7-0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/drupal.2-sec.xml b/core/modules/update/tests/drupal.2-sec.xml
new file mode 100644
index 000000000000..8018fa2ab360
--- /dev/null
+++ b/core/modules/update/tests/drupal.2-sec.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>7</recommended_major>
+<supported_majors>7</supported_majors>
+<default_major>7</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+ <terms>
+ <term><name>Projects</name><value>Drupal project</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>Drupal 7.2</name>
+ <version>7.2</version>
+ <tag>DRUPAL-7-2</tag>
+ <version_major>7</version_major>
+ <version_patch>2</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-2-release</release_link>
+ <download_link>http://example.com/drupal-7-2.tar.gz</download_link>
+ <date>1250424641</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>4294967296</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ <term><name>Release type</name><value>Security update</value></term>
+ </terms>
+ </release>
+ <release>
+ <name>Drupal 7.1</name>
+ <version>7.1</version>
+ <tag>DRUPAL-7-1</tag>
+ <version_major>7</version_major>
+ <version_patch>1</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-1-release</release_link>
+ <download_link>http://example.com/drupal-7-1.tar.gz</download_link>
+ <date>1250424581</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>2147483648</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+ <release>
+ <name>Drupal 7.0</name>
+ <version>7.0</version>
+ <tag>DRUPAL-7-0</tag>
+ <version_major>7</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-0-release</release_link>
+ <download_link>http://example.com/drupal-7-0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/drupal.dev.xml b/core/modules/update/tests/drupal.dev.xml
new file mode 100644
index 000000000000..4f707893efad
--- /dev/null
+++ b/core/modules/update/tests/drupal.dev.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>7</recommended_major>
+<supported_majors>7</supported_majors>
+<default_major>7</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+ <terms>
+ <term><name>Projects</name><value>Drupal project</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>Drupal 7.0</name>
+ <version>7.0</version>
+ <tag>DRUPAL-7-0</tag>
+ <version_major>7</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-0-release</release_link>
+ <download_link>http://example.com/drupal-7-0.tar.gz</download_link>
+ <date>1250424521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+ <release>
+ <name>Drupal 8.x-dev</name>
+ <version>8.x-dev</version>
+ <tag>DRUPAL-7</tag>
+ <version_major>7</version_major>
+ <version_extra>dev</version_extra>
+ <status>published</status>
+ <release_link>http://example.com/drupal-7-x-dev-release</release_link>
+ <download_link>http://example.com/drupal-8.x-dev.tar.gz</download_link>
+ <date>1250424581</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>2147483648</filesize>
+ <terms>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/update_test.info b/core/modules/update/tests/update_test.info
new file mode 100644
index 000000000000..708dc9772a08
--- /dev/null
+++ b/core/modules/update/tests/update_test.info
@@ -0,0 +1,6 @@
+name = Update test
+description = Support module for update module testing.
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/update/tests/update_test.module b/core/modules/update/tests/update_test.module
new file mode 100644
index 000000000000..4acb6ef837e6
--- /dev/null
+++ b/core/modules/update/tests/update_test.module
@@ -0,0 +1,164 @@
+<?php
+
+/**
+ * Implements hook_menu().
+ */
+function update_test_menu() {
+ $items = array();
+
+ $items['update-test'] = array(
+ 'title' => t('Update test'),
+ 'page callback' => 'update_test_mock_page',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+ $items['503-error'] = array(
+ 'title' => t('503 Service unavailable'),
+ 'page callback' => 'update_callback_service_unavailable',
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ );
+
+ return $items;
+}
+
+/**
+ * Implements hook_system_info_alter().
+ *
+ * This checks the 'update_test_system_info' variable and sees if we need to
+ * alter the system info for the given $file based on the setting. The setting
+ * is expected to be a nested associative array. If the key '#all' is defined,
+ * its subarray will include .info keys and values for all modules and themes
+ * on the system. Otherwise, the settings array is keyed by the module or
+ * theme short name ($file->name) and the subarrays contain settings just for
+ * that module or theme.
+ */
+function update_test_system_info_alter(&$info, $file) {
+ $setting = variable_get('update_test_system_info', array());
+ foreach (array('#all', $file->name) as $id) {
+ if (!empty($setting[$id])) {
+ foreach ($setting[$id] as $key => $value) {
+ $info[$key] = $value;
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_update_status_alter().
+ *
+ * This checks the 'update_test_update_status' variable and sees if we need to
+ * alter the update status for the given project based on the setting. The
+ * setting is expected to be a nested associative array. If the key '#all' is
+ * defined, its subarray will include .info keys and values for all modules
+ * and themes on the system. Otherwise, the settings array is keyed by the
+ * module or theme short name and the subarrays contain settings just for that
+ * module or theme.
+ */
+function update_test_update_status_alter(&$projects) {
+ $setting = variable_get('update_test_update_status', array());
+ if (!empty($setting)) {
+ foreach ($projects as $project_name => &$project) {
+ foreach (array('#all', $project_name) as $id) {
+ if (!empty($setting[$id])) {
+ foreach ($setting[$id] as $key => $value) {
+ $project[$key] = $value;
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Page callback, prints mock XML for the update module.
+ *
+ * The specific XML file to print depends on two things: the project we're
+ * trying to fetch data for, and the desired "availability scenario" for that
+ * project which we're trying to test. Before attempting to fetch this data
+ * (by checking for updates on the available updates report), callers need to
+ * define the 'update_test_xml_map' variable as an array, keyed by project
+ * name, indicating which availability scenario to use for that project.
+ *
+ * @param $project_name
+ * The project short name update.module is trying to fetch data for (the
+ * fetch URLs are of the form: [base_url]/[project_name]/[core_version]).
+ */
+function update_test_mock_page($project_name) {
+ $xml_map = variable_get('update_test_xml_map', FALSE);
+ if (isset($xml_map[$project_name])) {
+ $availability_scenario = $xml_map[$project_name];
+ }
+ elseif (isset($xml_map['#all'])) {
+ $availability_scenario = $xml_map['#all'];
+ }
+ else {
+ // The test didn't specify (for example, the webroot has other modules and
+ // themes installed but they're disabled by the version of the site
+ // running the test. So, we default to a file we know won't exist, so at
+ // least we'll get an empty page from readfile instead of a bunch of
+ // Drupal page output.
+ $availability_scenario = '#broken#';
+ }
+
+ $path = drupal_get_path('module', 'update_test');
+ readfile("$path/$project_name.$availability_scenario.xml");
+}
+
+/**
+ * Implement hook_archiver_info().
+ */
+function update_test_archiver_info() {
+ return array(
+ 'update_test_archiver' => array(
+ // This is bogus, we only care about the extensions for now.
+ 'class' => 'ArchiverUpdateTest',
+ 'extensions' => array('update-test-extension'),
+ ),
+ );
+}
+
+/**
+ * Implements hook_filetransfer_info().
+ */
+function update_test_filetransfer_info() {
+ // Define a mock file transfer method, to ensure that there will always be
+ // at least one method available in the user interface (regardless of the
+ // environment in which the update manager tests are run).
+ return array(
+ 'system_test' => array(
+ 'title' => t('Update Test FileTransfer'),
+ // This should be in an .inc file, but for testing purposes, it is OK to
+ // leave it in the main module file.
+ 'file' => 'update_test.module',
+ 'class' => 'UpdateTestFileTransfer',
+ 'weight' => -20,
+ ),
+ );
+}
+
+/**
+ * Mock FileTransfer object to test the settings form functionality.
+ */
+class UpdateTestFileTransfer {
+ public static function factory() {
+ return new UpdateTestFileTransfer;
+ }
+
+ public function getSettingsForm() {
+ $form = array();
+ $form['udpate_test_username'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Update Test Username'),
+ );
+ return $form;
+ }
+}
+
+/**
+ * Return an Error 503 (Service unavailable) page.
+ */
+function update_callback_service_unavailable() {
+ drupal_add_http_header('Status', '503 Service unavailable');
+ print "503 Service Temporarily Unavailable";
+}
diff --git a/core/modules/update/tests/update_test_basetheme.1_1-sec.xml b/core/modules/update/tests/update_test_basetheme.1_1-sec.xml
new file mode 100644
index 000000000000..462558b595bd
--- /dev/null
+++ b/core/modules/update/tests/update_test_basetheme.1_1-sec.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Update test base theme</title>
+<short_name>update_test_basetheme</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>1</recommended_major>
+<supported_majors>1</supported_majors>
+<default_major>1</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/update_test_basetheme</link>
+ <terms>
+ <term><name>Projects</name><value>Themes</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>update_test_basetheme 8.x-1.1</name>
+ <version>8.x-1.1</version>
+ <tag>DRUPAL-7--1-1</tag>
+ <version_major>1</version_major>
+ <version_patch>1</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/update_test_basetheme-7-x-1-1-release</release_link>
+ <download_link>http://example.com/update_test_basetheme-8.x-1.1.tar.gz</download_link>
+ <date>1250624521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073763241</filesize>
+ <terms>
+ <term><name>Release type</name><value>Security update</value></term>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+ <release>
+ <name>update_test_basetheme 8.x-1.0</name>
+ <version>8.x-1.0</version>
+ <tag>DRUPAL-7--1-0</tag>
+ <version_major>1</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/update_test_basetheme-7-x-1-0-release</release_link>
+ <download_link>http://example.com/update_test_basetheme-8.x-1.0.tar.gz</download_link>
+ <date>1250524521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/tests/update_test_subtheme.1_0.xml b/core/modules/update/tests/update_test_subtheme.1_0.xml
new file mode 100644
index 000000000000..c791b7f1fbce
--- /dev/null
+++ b/core/modules/update/tests/update_test_subtheme.1_0.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Update test subtheme</title>
+<short_name>update_test_subtheme</short_name>
+<dc:creator>Drupal</dc:creator>
+<api_version>8.x</api_version>
+<recommended_major>1</recommended_major>
+<supported_majors>1</supported_majors>
+<default_major>1</default_major>
+<project_status>published</project_status>
+<link>http://example.com/project/update_test_subtheme</link>
+ <terms>
+ <term><name>Projects</name><value>Themes</value></term>
+ </terms>
+<releases>
+ <release>
+ <name>update_test_subtheme 8.x-1.0</name>
+ <version>8.x-1.0</version>
+ <tag>DRUPAL-7--1-0</tag>
+ <version_major>1</version_major>
+ <version_patch>0</version_patch>
+ <status>published</status>
+ <release_link>http://example.com/update_test_subtheme-7-x-1-0-release</release_link>
+ <download_link>http://example.com/update_test_subtheme-8.x-1.0.tar.gz</download_link>
+ <date>1250524521</date>
+ <mdhash>b966255555d9c9b86d480ca08cfaa98e</mdhash>
+ <filesize>1073741824</filesize>
+ <terms>
+ <term><name>Release type</name><value>New features</value></term>
+ <term><name>Release type</name><value>Bug fixes</value></term>
+ </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/update/update-rtl.css b/core/modules/update/update-rtl.css
new file mode 100644
index 000000000000..5fc83d1a6239
--- /dev/null
+++ b/core/modules/update/update-rtl.css
@@ -0,0 +1,31 @@
+
+.update .project {
+ padding-right: .25em;
+}
+
+.update .version-status {
+ float: left;
+ padding-left: 10px;
+}
+
+.update .version-status .icon {
+ padding-right: .5em;
+}
+
+.update table.version .version-title {
+ padding-left: 1em;
+}
+
+.update table.version .version-details {
+ padding-left: .5em;
+ direction: ltr;
+}
+
+.update table.version .version-links {
+ text-align: left;
+ padding-left: 1em;
+}
+
+.update .check-manually {
+ padding-right: 1em;
+}
diff --git a/core/modules/update/update.api.php b/core/modules/update/update.api.php
new file mode 100644
index 000000000000..83b93b28f09d
--- /dev/null
+++ b/core/modules/update/update.api.php
@@ -0,0 +1,133 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the Update Status module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Alter the list of projects before fetching data and comparing versions.
+ *
+ * Most modules will never need to implement this hook. It is for advanced
+ * interaction with the update status module: mere mortals need not apply.
+ * The primary use-case for this hook is to add projects to the list, for
+ * example, to provide update status data on disabled modules and themes. A
+ * contributed module might want to hide projects from the list, for example,
+ * if there is a site-specific module that doesn't have any official releases,
+ * that module could remove itself from this list to avoid "No available
+ * releases found" warnings on the available updates report. In rare cases, a
+ * module might want to alter the data associated with a project already in
+ * the list.
+ *
+ * @param $projects
+ * Reference to an array of the projects installed on the system. This
+ * includes all the metadata documented in the comments below for each
+ * project (either module or theme) that is currently enabled. The array is
+ * initially populated inside update_get_projects() with the help of
+ * _update_process_info_list(), so look there for examples of how to
+ * populate the array with real values.
+ *
+ * @see update_get_projects()
+ * @see _update_process_info_list()
+ */
+function hook_update_projects_alter(&$projects) {
+ // Hide a site-specific module from the list.
+ unset($projects['site_specific_module']);
+
+ // Add a disabled module to the list.
+ // The key for the array should be the machine-readable project "short name".
+ $projects['disabled_project_name'] = array(
+ // Machine-readable project short name (same as the array key above).
+ 'name' => 'disabled_project_name',
+ // Array of values from the main .info file for this project.
+ 'info' => array(
+ 'name' => 'Some disabled module',
+ 'description' => 'A module not enabled on the site that you want to see in the available updates report.',
+ 'version' => '8.x-1.0',
+ 'core' => '8.x',
+ // The maximum file change time (the "ctime" returned by the filectime()
+ // PHP method) for all of the .info files included in this project.
+ '_info_file_ctime' => 1243888165,
+ ),
+ // The date stamp when the project was released, if known. If the disabled
+ // project was an officially packaged release from drupal.org, this will
+ // be included in the .info file as the 'datestamp' field. This only
+ // really matters for development snapshot releases that are regenerated,
+ // so it can be left undefined or set to 0 in most cases.
+ 'datestamp' => 1243888185,
+ // Any modules (or themes) included in this project. Keyed by machine-
+ // readable "short name", value is the human-readable project name printed
+ // in the UI.
+ 'includes' => array(
+ 'disabled_project' => 'Disabled module',
+ 'disabled_project_helper' => 'Disabled module helper module',
+ 'disabled_project_foo' => 'Disabled module foo add-on module',
+ ),
+ // Does this project contain a 'module', 'theme', 'disabled-module', or
+ // 'disabled-theme'?
+ 'project_type' => 'disabled-module',
+ );
+}
+
+/**
+ * Alter the information about available updates for projects.
+ *
+ * @param $projects
+ * Reference to an array of information about available updates to each
+ * project installed on the system.
+ *
+ * @see update_calculate_project_data()
+ */
+function hook_update_status_alter(&$projects) {
+ $settings = variable_get('update_advanced_project_settings', array());
+ foreach ($projects as $project => $project_info) {
+ if (isset($settings[$project]) && isset($settings[$project]['check']) &&
+ ($settings[$project]['check'] == 'never' ||
+ (isset($project_info['recommended']) &&
+ $settings[$project]['check'] === $project_info['recommended']))) {
+ $projects[$project]['status'] = UPDATE_NOT_CHECKED;
+ $projects[$project]['reason'] = t('Ignored from settings');
+ if (!empty($settings[$project]['notes'])) {
+ $projects[$project]['extra'][] = array(
+ 'class' => array('admin-note'),
+ 'label' => t('Administrator note'),
+ 'data' => $settings[$project]['notes'],
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Verify an archive after it has been downloaded and extracted.
+ *
+ * @param string $project
+ * The short name of the project that has been downloaded.
+ * @param string $archive_file
+ * The filename of the unextracted archive.
+ * @param string $directory
+ * The directory that the archive was extracted into.
+ *
+ * @return
+ * If there are any problems, return an array of error messages. If there are
+ * no problems, return an empty array.
+ *
+ * @see update_manager_archive_verify()
+ */
+function hook_verify_update_archive($project, $archive_file, $directory) {
+ $errors = array();
+ if (!file_exists($directory)) {
+ $errors[] = t('The %directory does not exist.', array('%directory' => $directory));
+ }
+ // Add other checks on the archive integrity here.
+ return $errors;
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/update/update.authorize.inc b/core/modules/update/update.authorize.inc
new file mode 100644
index 000000000000..35dde0eb4fc3
--- /dev/null
+++ b/core/modules/update/update.authorize.inc
@@ -0,0 +1,314 @@
+<?php
+
+/**
+ * @file
+ * Callbacks and related functions invoked by authorize.php to update projects
+ * on the Drupal site. We use the Batch API to actually update each individual
+ * project on the site. All of the code in this file is run at a low bootstrap
+ * level (modules are not loaded), so these functions cannot assume access to
+ * the rest of the update module code.
+ */
+
+/**
+ * Callback invoked by authorize.php to update existing projects.
+ *
+ * @param $filetransfer
+ * The FileTransfer object created by authorize.php for use during this
+ * operation.
+ * @param $projects
+ * A nested array of projects to install into the live webroot, keyed by
+ * project name. Each subarray contains the following keys:
+ * - 'project': The canonical project short name.
+ * - 'updater_name': The name of the Updater class to use for this project.
+ * - 'local_url': The locally installed location of new code to update with.
+ */
+function update_authorize_run_update($filetransfer, $projects) {
+ $operations = array();
+ foreach ($projects as $project => $project_info) {
+ $operations[] = array(
+ 'update_authorize_batch_copy_project',
+ array(
+ $project_info['project'],
+ $project_info['updater_name'],
+ $project_info['local_url'],
+ $filetransfer,
+ ),
+ );
+ }
+
+ $batch = array(
+ 'title' => t('Installing updates'),
+ 'init_message' => t('Preparing to update your site'),
+ 'operations' => $operations,
+ 'finished' => 'update_authorize_update_batch_finished',
+ 'file' => drupal_get_path('module', 'update') . '/update.authorize.inc',
+ );
+
+ batch_set($batch);
+ // Invoke the batch via authorize.php.
+ system_authorized_batch_process();
+}
+
+/**
+ * Callback invoked by authorize.php to install a new project.
+ *
+ * @param FileTransfer $filetransfer
+ * The FileTransfer object created by authorize.php for use during this
+ * operation.
+ * @param string $project
+ * The canonical project short name (e.g. {system}.name).
+ * @param string $updater_name
+ * The name of the Updater class to use for installing this project.
+ * @param string $local_url
+ * The URL to the locally installed temp directory where the project has
+ * already been downloaded and extracted into.
+ */
+function update_authorize_run_install($filetransfer, $project, $updater_name, $local_url) {
+ $operations[] = array(
+ 'update_authorize_batch_copy_project',
+ array(
+ $project,
+ $updater_name,
+ $local_url,
+ $filetransfer,
+ ),
+ );
+
+ // @todo Instantiate our Updater to set the human-readable title?
+ $batch = array(
+ 'title' => t('Installing %project', array('%project' => $project)),
+ 'init_message' => t('Preparing to install'),
+ 'operations' => $operations,
+ // @todo Use a different finished callback for different messages?
+ 'finished' => 'update_authorize_install_batch_finished',
+ 'file' => drupal_get_path('module', 'update') . '/update.authorize.inc',
+ );
+ batch_set($batch);
+
+ // Invoke the batch via authorize.php.
+ system_authorized_batch_process();
+}
+
+/**
+ * Copy a project to its proper place when authorized with elevated privileges.
+ *
+ * @param string $project
+ * The canonical short name of the project being installed.
+ * @param string $updater_name
+ * The name of the Updater class to use for installing this project.
+ * @param string $local_url
+ * The URL to the locally installed temp directory where the project has
+ * already been downloaded and extracted into.
+ * @param FileTransfer $filetransfer
+ * The FileTransfer object to use for performing this operation.
+ * @param array $context
+ * Reference to an array used for BatchAPI storage.
+ */
+function update_authorize_batch_copy_project($project, $updater_name, $local_url, $filetransfer, &$context) {
+
+ // Initialize some variables in the Batch API $context array.
+ if (!isset($context['results']['log'])) {
+ $context['results']['log'] = array();
+ }
+ if (!isset($context['results']['log'][$project])) {
+ $context['results']['log'][$project] = array();
+ }
+
+ if (!isset($context['results']['tasks'])) {
+ $context['results']['tasks'] = array();
+ }
+
+ /**
+ * The batch API uses a session, and since all the arguments are serialized
+ * and unserialized between requests, although the FileTransfer object
+ * itself will be reconstructed, the connection pointer itself will be lost.
+ * However, the FileTransfer object will still have the connection variable,
+ * even though the connection itself is now gone. So, although it's ugly, we
+ * have to unset the connection variable at this point so that the
+ * FileTransfer object will re-initiate the actual connection.
+ */
+ unset($filetransfer->connection);
+
+ if (!empty($context['results']['log'][$project]['#abort'])) {
+ $context['finished'] = 1;
+ return;
+ }
+
+ $updater = new $updater_name($local_url);
+
+ try {
+ if ($updater->isInstalled()) {
+ // This is an update.
+ $tasks = $updater->update($filetransfer);
+ }
+ else {
+ $tasks = $updater->install($filetransfer);
+ }
+ }
+ catch (UpdaterException $e) {
+ _update_batch_create_message($context['results']['log'][$project], t('Error installing / updating'), FALSE);
+ _update_batch_create_message($context['results']['log'][$project], $e->getMessage(), FALSE);
+ $context['results']['log'][$project]['#abort'] = TRUE;
+ return;
+ }
+
+ _update_batch_create_message($context['results']['log'][$project], t('Installed %project_name successfully', array('%project_name' => $project)));
+ if (!empty($tasks)) {
+ $context['results']['tasks'] += $tasks;
+ }
+
+ // This particular operation is now complete, even though the batch might
+ // have other operations to perform.
+ $context['finished'] = 1;
+}
+
+/**
+ * Batch callback for when the authorized update batch is finished.
+ *
+ * This processes the results and stashes them into SESSION such that
+ * authorize.php will render a report. Also responsible for putting the site
+ * back online and clearing the update status cache after a successful update.
+ */
+function update_authorize_update_batch_finished($success, $results) {
+ foreach ($results['log'] as $project => $messages) {
+ if (!empty($messages['#abort'])) {
+ $success = FALSE;
+ }
+ }
+ $offline = variable_get('maintenance_mode', FALSE);
+ if ($success) {
+ // Now that the update completed, we need to clear the cache of available
+ // update data and recompute our status, so prevent show bogus results.
+ _update_authorize_clear_update_status();
+
+ // Take the site out of maintenance mode if it was previously that way.
+ if ($offline && isset($_SESSION['maintenance_mode']) && $_SESSION['maintenance_mode'] == FALSE) {
+ variable_set('maintenance_mode', FALSE);
+ $page_message = array(
+ 'message' => t('Update was completed successfully. Your site has been taken out of maintenance mode.'),
+ 'type' => 'status',
+ );
+ }
+ else {
+ $page_message = array(
+ 'message' => t('Update was completed successfully.'),
+ 'type' => 'status',
+ );
+ }
+ }
+ elseif (!$offline) {
+ $page_message = array(
+ 'message' => t('Update failed! See the log below for more information.'),
+ 'type' => 'error',
+ );
+ }
+ else {
+ $page_message = array(
+ 'message' => t('Update failed! See the log below for more information. Your site is still in maintenance mode.'),
+ 'type' => 'error',
+ );
+ }
+ // Since we're doing an update of existing code, always add a task for
+ // running update.php.
+ $results['tasks'][] = t('Your modules have been downloaded and updated.');
+ $results['tasks'][] = t('<a href="@update">Run database updates</a>', array('@update' => base_path() . 'core/update.php'));
+
+ // Unset the variable since it is no longer needed.
+ unset($_SESSION['maintenance_mode']);
+
+ // Set all these values into the SESSION so authorize.php can display them.
+ $_SESSION['authorize_results']['success'] = $success;
+ $_SESSION['authorize_results']['page_message'] = $page_message;
+ $_SESSION['authorize_results']['messages'] = $results['log'];
+ $_SESSION['authorize_results']['tasks'] = $results['tasks'];
+ $_SESSION['authorize_operation']['page_title'] = t('Update manager');
+}
+
+/**
+ * Batch callback for when the authorized install batch is finished.
+ *
+ * This processes the results and stashes them into SESSION such that
+ * authorize.php will render a report. Also responsible for putting the site
+ * back online after a successful install if necessary.
+ */
+function update_authorize_install_batch_finished($success, $results) {
+ foreach ($results['log'] as $project => $messages) {
+ if (!empty($messages['#abort'])) {
+ $success = FALSE;
+ }
+ }
+ $offline = variable_get('maintenance_mode', FALSE);
+ if ($success) {
+ // Take the site out of maintenance mode if it was previously that way.
+ if ($offline && isset($_SESSION['maintenance_mode']) && $_SESSION['maintenance_mode'] == FALSE) {
+ variable_set('maintenance_mode', FALSE);
+ $page_message = array(
+ 'message' => t('Installation was completed successfully. Your site has been taken out of maintenance mode.'),
+ 'type' => 'status',
+ );
+ }
+ else {
+ $page_message = array(
+ 'message' => t('Installation was completed successfully.'),
+ 'type' => 'status',
+ );
+ }
+ }
+ elseif (!$success && !$offline) {
+ $page_message = array(
+ 'message' => t('Installation failed! See the log below for more information.'),
+ 'type' => 'error',
+ );
+ }
+ else {
+ $page_message = array(
+ 'message' => t('Installation failed! See the log below for more information. Your site is still in maintenance mode.'),
+ 'type' => 'error',
+ );
+ }
+
+ // Unset the variable since it is no longer needed.
+ unset($_SESSION['maintenance_mode']);
+
+ // Set all these values into the SESSION so authorize.php can display them.
+ $_SESSION['authorize_results']['success'] = $success;
+ $_SESSION['authorize_results']['page_message'] = $page_message;
+ $_SESSION['authorize_results']['messages'] = $results['log'];
+ $_SESSION['authorize_results']['tasks'] = $results['tasks'];
+ $_SESSION['authorize_operation']['page_title'] = t('Update manager');
+}
+
+/**
+ * Helper function to create a structure of log messages.
+ *
+ * @param array $project_results
+ * @param string $message
+ * @param bool $success
+ */
+function _update_batch_create_message(&$project_results, $message, $success = TRUE) {
+ $project_results[] = array('message' => $message, 'success' => $success);
+}
+
+/**
+ * Private helper function to clear cached available update status data.
+ *
+ * Since this function is run at such a low bootstrap level, update.module is
+ * not loaded. So, we can't just call _update_cache_clear(). However, the
+ * database is bootstrapped, so we can do a query ourselves to clear out what
+ * we want to clear.
+ *
+ * Note that we do not want to just truncate the table, since that would
+ * remove items related to currently pending fetch attempts.
+ *
+ * @see update_authorize_update_batch_finished()
+ * @see _update_cache_clear()
+ */
+function _update_authorize_clear_update_status() {
+ $query = db_delete('cache_update');
+ $query->condition(
+ db_or()
+ ->condition('cid', 'update_project_%', 'LIKE')
+ ->condition('cid', 'available_releases::%', 'LIKE')
+ );
+ $query->execute();
+}
diff --git a/core/modules/update/update.compare.inc b/core/modules/update/update.compare.inc
new file mode 100644
index 000000000000..2ccd97c0eb7b
--- /dev/null
+++ b/core/modules/update/update.compare.inc
@@ -0,0 +1,789 @@
+<?php
+
+/**
+ * @file
+ * Code required only when comparing available updates to existing data.
+ */
+
+/**
+ * Fetch an array of installed and enabled projects.
+ *
+ * This is only responsible for generating an array of projects (taking into
+ * account projects that include more than one module or theme). Other
+ * information like the specific version and install type (official release,
+ * dev snapshot, etc) is handled later in update_process_project_info() since
+ * that logic is only required when preparing the status report, not for
+ * fetching the available release data.
+ *
+ * This array is fairly expensive to construct, since it involves a lot of
+ * disk I/O, so we cache the results into the {cache_update} table using the
+ * 'update_project_projects' cache ID. However, since this is not the data
+ * about available updates fetched from the network, it is ok to invalidate it
+ * somewhat quickly. If we keep this data for very long, site administrators
+ * are more likely to see incorrect results if they upgrade to a newer version
+ * of a module or theme but do not visit certain pages that automatically
+ * clear this cache.
+ *
+ * @see update_process_project_info()
+ * @see update_calculate_project_data()
+ * @see update_project_cache()
+ */
+function update_get_projects() {
+ $projects = &drupal_static(__FUNCTION__, array());
+ if (empty($projects)) {
+ // Retrieve the projects from cache, if present.
+ $projects = update_project_cache('update_project_projects');
+ if (empty($projects)) {
+ // Still empty, so we have to rebuild the cache.
+ $module_data = system_rebuild_module_data();
+ $theme_data = system_rebuild_theme_data();
+ _update_process_info_list($projects, $module_data, 'module', TRUE);
+ _update_process_info_list($projects, $theme_data, 'theme', TRUE);
+ if (variable_get('update_check_disabled', FALSE)) {
+ _update_process_info_list($projects, $module_data, 'module', FALSE);
+ _update_process_info_list($projects, $theme_data, 'theme', FALSE);
+ }
+ // Allow other modules to alter projects before fetching and comparing.
+ drupal_alter('update_projects', $projects);
+ // Cache the site's project data for at most 1 hour.
+ _update_cache_set('update_project_projects', $projects, REQUEST_TIME + 3600);
+ }
+ }
+ return $projects;
+}
+
+/**
+ * Populate an array of project data.
+ *
+ * This iterates over a list of the installed modules or themes and groups
+ * them by project and status. A few parts of this function assume that
+ * enabled modules and themes are always processed first, and if disabled
+ * modules or themes are being processed (there is a setting to control if
+ * disabled code should be included in the Available updates report or not),
+ * those are only processed after $projects has been populated with
+ * information about the enabled code. 'Hidden' modules and themes are always
+ * ignored. This function also records the latest change time on the .info
+ * files for each module or theme, which is important data which is used when
+ * deciding if the cached available update data should be invalidated.
+ *
+ * @param $projects
+ * Reference to the array of project data of what's installed on this site.
+ * @param $list
+ * Array of data to process to add the relevant info to the $projects array.
+ * @param $project_type
+ * The kind of data in the list (can be 'module' or 'theme').
+ * @param $status
+ * Boolean that controls what status (enabled or disabled) to process out of
+ * the $list and add to the $projects array.
+ *
+ * @see update_get_projects()
+ */
+function _update_process_info_list(&$projects, $list, $project_type, $status) {
+ foreach ($list as $file) {
+ // A disabled base theme of an enabled sub-theme still has all of its code
+ // run by the sub-theme, so we include it in our "enabled" projects list.
+ if ($status && !$file->status && !empty($file->sub_themes)) {
+ foreach ($file->sub_themes as $key => $name) {
+ // Build a list of enabled sub-themes.
+ if ($list[$key]->status) {
+ $file->enabled_sub_themes[$key] = $name;
+ }
+ }
+ // If there are no enabled subthemes, we should ignore this base theme
+ // for the enabled case. If the site is trying to display disabled
+ // themes, we'll catch it then.
+ if (empty($file->enabled_sub_themes)) {
+ continue;
+ }
+ }
+ // Otherwise, just add projects of the proper status to our list.
+ elseif ($file->status != $status) {
+ continue;
+ }
+
+ // Skip if the .info file is broken.
+ if (empty($file->info)) {
+ continue;
+ }
+
+ // Skip if it's a hidden module or theme.
+ if (!empty($file->info['hidden'])) {
+ continue;
+ }
+
+ // If the .info doesn't define the 'project', try to figure it out.
+ if (!isset($file->info['project'])) {
+ $file->info['project'] = update_get_project_name($file);
+ }
+
+ // If we still don't know the 'project', give up.
+ if (empty($file->info['project'])) {
+ continue;
+ }
+
+ // If we don't already know it, grab the change time on the .info file
+ // itself. Note: we need to use the ctime, not the mtime (modification
+ // time) since many (all?) tar implementations will go out of their way to
+ // set the mtime on the files it creates to the timestamps recorded in the
+ // tarball. We want to see the last time the file was changed on disk,
+ // which is left alone by tar and correctly set to the time the .info file
+ // was unpacked.
+ if (!isset($file->info['_info_file_ctime'])) {
+ $info_filename = dirname($file->uri) . '/' . $file->name . '.info';
+ $file->info['_info_file_ctime'] = filectime($info_filename);
+ }
+
+ if (!isset($file->info['datestamp'])) {
+ $file->info['datestamp'] = 0;
+ }
+
+ $project_name = $file->info['project'];
+
+ // Figure out what project type we're going to use to display this module
+ // or theme. If the project name is 'drupal', we don't want it to show up
+ // under the usual "Modules" section, we put it at a special "Drupal Core"
+ // section at the top of the report.
+ if ($project_name == 'drupal') {
+ $project_display_type = 'core';
+ }
+ else {
+ $project_display_type = $project_type;
+ }
+ if (empty($status) && empty($file->enabled_sub_themes)) {
+ // If we're processing disabled modules or themes, append a suffix.
+ // However, we don't do this to a a base theme with enabled
+ // subthemes, since we treat that case as if it is enabled.
+ $project_display_type .= '-disabled';
+ }
+ // Add a list of sub-themes that "depend on" the project and a list of base
+ // themes that are "required by" the project.
+ if ($project_name == 'drupal') {
+ // Drupal core is always required, so this extra info would be noise.
+ $sub_themes = array();
+ $base_themes = array();
+ }
+ else {
+ // Add list of enabled sub-themes.
+ $sub_themes = !empty($file->enabled_sub_themes) ? $file->enabled_sub_themes : array();
+ // Add list of base themes.
+ $base_themes = !empty($file->base_themes) ? $file->base_themes : array();
+ }
+ if (!isset($projects[$project_name])) {
+ // Only process this if we haven't done this project, since a single
+ // project can have multiple modules or themes.
+ $projects[$project_name] = array(
+ 'name' => $project_name,
+ // Only save attributes from the .info file we care about so we do not
+ // bloat our RAM usage needlessly.
+ 'info' => update_filter_project_info($file->info),
+ 'datestamp' => $file->info['datestamp'],
+ 'includes' => array($file->name => $file->info['name']),
+ 'project_type' => $project_display_type,
+ 'project_status' => $status,
+ 'sub_themes' => $sub_themes,
+ 'base_themes' => $base_themes,
+ );
+ }
+ elseif ($projects[$project_name]['project_type'] == $project_display_type) {
+ // Only add the file we're processing to the 'includes' array for this
+ // project if it is of the same type and status (which is encoded in the
+ // $project_display_type). This prevents listing all the disabled
+ // modules included with an enabled project if we happen to be checking
+ // for disabled modules, too.
+ $projects[$project_name]['includes'][$file->name] = $file->info['name'];
+ $projects[$project_name]['info']['_info_file_ctime'] = max($projects[$project_name]['info']['_info_file_ctime'], $file->info['_info_file_ctime']);
+ $projects[$project_name]['datestamp'] = max($projects[$project_name]['datestamp'], $file->info['datestamp']);
+ if (!empty($sub_themes)) {
+ $projects[$project_name]['sub_themes'] += $sub_themes;
+ }
+ if (!empty($base_themes)) {
+ $projects[$project_name]['base_themes'] += $base_themes;
+ }
+ }
+ elseif (empty($status)) {
+ // If we have a project_name that matches, but the project_display_type
+ // does not, it means we're processing a disabled module or theme that
+ // belongs to a project that has some enabled code. In this case, we add
+ // the disabled thing into a separate array for separate display.
+ $projects[$project_name]['disabled'][$file->name] = $file->info['name'];
+ }
+ }
+}
+
+/**
+ * Given a $file object (as returned by system_get_files_database()), figure
+ * out what project it belongs to.
+ *
+ * @see system_get_files_database()
+ */
+function update_get_project_name($file) {
+ $project_name = '';
+ if (isset($file->info['project'])) {
+ $project_name = $file->info['project'];
+ }
+ elseif (isset($file->info['package']) && (strpos($file->info['package'], 'Core') === 0)) {
+ $project_name = 'drupal';
+ }
+ return $project_name;
+}
+
+/**
+ * Process the list of projects on the system to figure out the currently
+ * installed versions, and other information that is required before we can
+ * compare against the available releases to produce the status report.
+ *
+ * @param $projects
+ * Array of project information from update_get_projects().
+ */
+function update_process_project_info(&$projects) {
+ foreach ($projects as $key => $project) {
+ // Assume an official release until we see otherwise.
+ $install_type = 'official';
+
+ $info = $project['info'];
+
+ if (isset($info['version'])) {
+ // Check for development snapshots
+ if (preg_match('@(dev|HEAD)@', $info['version'])) {
+ $install_type = 'dev';
+ }
+
+ // Figure out what the currently installed major version is. We need
+ // to handle both contribution (e.g. "5.x-1.3", major = 1) and core
+ // (e.g. "5.1", major = 5) version strings.
+ $matches = array();
+ if (preg_match('/^(\d+\.x-)?(\d+)\..*$/', $info['version'], $matches)) {
+ $info['major'] = $matches[2];
+ }
+ elseif (!isset($info['major'])) {
+ // This would only happen for version strings that don't follow the
+ // drupal.org convention. We let contribs define "major" in their
+ // .info in this case, and only if that's missing would we hit this.
+ $info['major'] = -1;
+ }
+ }
+ else {
+ // No version info available at all.
+ $install_type = 'unknown';
+ $info['version'] = t('Unknown');
+ $info['major'] = -1;
+ }
+
+ // Finally, save the results we care about into the $projects array.
+ $projects[$key]['existing_version'] = $info['version'];
+ $projects[$key]['existing_major'] = $info['major'];
+ $projects[$key]['install_type'] = $install_type;
+ }
+}
+
+/**
+ * Calculate the current update status of all projects on the site.
+ *
+ * The results of this function are expensive to compute, especially on sites
+ * with lots of modules or themes, since it involves a lot of comparisons and
+ * other operations. Therefore, we cache the results into the {cache_update}
+ * table using the 'update_project_data' cache ID. However, since this is not
+ * the data about available updates fetched from the network, it is ok to
+ * invalidate it somewhat quickly. If we keep this data for very long, site
+ * administrators are more likely to see incorrect results if they upgrade to
+ * a newer version of a module or theme but do not visit certain pages that
+ * automatically clear this cache.
+ *
+ * @param array $available
+ * Data about available project releases.
+ *
+ * @see update_get_available()
+ * @see update_get_projects()
+ * @see update_process_project_info()
+ * @see update_project_cache()
+ */
+function update_calculate_project_data($available) {
+ // Retrieve the projects from cache, if present.
+ $projects = update_project_cache('update_project_data');
+ // If $projects is empty, then the cache must be rebuilt.
+ // Otherwise, return the cached data and skip the rest of the function.
+ if (!empty($projects)) {
+ return $projects;
+ }
+ $projects = update_get_projects();
+ update_process_project_info($projects);
+ foreach ($projects as $project => $project_info) {
+ if (isset($available[$project])) {
+ update_calculate_project_update_status($project, $projects[$project], $available[$project]);
+ }
+ else {
+ $projects[$project]['status'] = UPDATE_UNKNOWN;
+ $projects[$project]['reason'] = t('No available releases found');
+ }
+ }
+ // Give other modules a chance to alter the status (for example, to allow a
+ // contrib module to provide fine-grained settings to ignore specific
+ // projects or releases).
+ drupal_alter('update_status', $projects);
+
+ // Cache the site's update status for at most 1 hour.
+ _update_cache_set('update_project_data', $projects, REQUEST_TIME + 3600);
+ return $projects;
+}
+
+/**
+ * Calculate the current update status of a specific project.
+ *
+ * This function is the heart of the update status feature. For each project
+ * it is invoked with, it first checks if the project has been flagged with a
+ * special status like "unsupported" or "insecure", or if the project node
+ * itself has been unpublished. In any of those cases, the project is marked
+ * with an error and the next project is considered.
+ *
+ * If the project itself is valid, the function decides what major release
+ * series to consider. The project defines what the currently supported major
+ * versions are for each version of core, so the first step is to make sure
+ * the current version is still supported. If so, that's the target version.
+ * If the current version is unsupported, the project maintainer's recommended
+ * major version is used. There's also a check to make sure that this function
+ * never recommends an earlier release than the currently installed major
+ * version.
+ *
+ * Given a target major version, it scans the available releases looking for
+ * the specific release to recommend (avoiding beta releases and development
+ * snapshots if possible). This is complicated to describe, but an example
+ * will help clarify. For the target major version, find the highest patch
+ * level. If there is a release at that patch level with no extra ("beta",
+ * etc), then we recommend the release at that patch level with the most
+ * recent release date. If every release at that patch level has extra (only
+ * betas), then recommend the latest release from the previous patch
+ * level. For example:
+ *
+ * 1.6-bugfix <-- recommended version because 1.6 already exists.
+ * 1.6
+ *
+ * or
+ *
+ * 1.6-beta
+ * 1.5 <-- recommended version because no 1.6 exists.
+ * 1.4
+ *
+ * It also looks for the latest release from the same major version, even a
+ * beta release, to display to the user as the "Latest version" option.
+ * Additionally, it finds the latest official release from any higher major
+ * versions that have been released to provide a set of "Also available"
+ * options.
+ *
+ * Finally, and most importantly, it keeps scanning the release history until
+ * it gets to the currently installed release, searching for anything marked
+ * as a security update. If any security updates have been found between the
+ * recommended release and the installed version, all of the releases that
+ * included a security fix are recorded so that the site administrator can be
+ * warned their site is insecure, and links pointing to the release notes for
+ * each security update can be included (which, in turn, will link to the
+ * official security announcements for each vulnerability).
+ *
+ * This function relies on the fact that the .xml release history data comes
+ * sorted based on major version and patch level, then finally by release date
+ * if there are multiple releases such as betas from the same major.patch
+ * version (e.g. 5.x-1.5-beta1, 5.x-1.5-beta2, and 5.x-1.5). Development
+ * snapshots for a given major version are always listed last.
+ *
+ */
+function update_calculate_project_update_status($project, &$project_data, $available) {
+ foreach (array('title', 'link') as $attribute) {
+ if (!isset($project_data[$attribute]) && isset($available[$attribute])) {
+ $project_data[$attribute] = $available[$attribute];
+ }
+ }
+
+ // If the project status is marked as something bad, there's nothing else
+ // to consider.
+ if (isset($available['project_status'])) {
+ switch ($available['project_status']) {
+ case 'insecure':
+ $project_data['status'] = UPDATE_NOT_SECURE;
+ if (empty($project_data['extra'])) {
+ $project_data['extra'] = array();
+ }
+ $project_data['extra'][] = array(
+ 'class' => array('project-not-secure'),
+ 'label' => t('Project not secure'),
+ 'data' => t('This project has been labeled insecure by the Drupal security team, and is no longer available for download. Immediately disabling everything included by this project is strongly recommended!'),
+ );
+ break;
+ case 'unpublished':
+ case 'revoked':
+ $project_data['status'] = UPDATE_REVOKED;
+ if (empty($project_data['extra'])) {
+ $project_data['extra'] = array();
+ }
+ $project_data['extra'][] = array(
+ 'class' => array('project-revoked'),
+ 'label' => t('Project revoked'),
+ 'data' => t('This project has been revoked, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
+ );
+ break;
+ case 'unsupported':
+ $project_data['status'] = UPDATE_NOT_SUPPORTED;
+ if (empty($project_data['extra'])) {
+ $project_data['extra'] = array();
+ }
+ $project_data['extra'][] = array(
+ 'class' => array('project-not-supported'),
+ 'label' => t('Project not supported'),
+ 'data' => t('This project is no longer supported, and is no longer available for download. Disabling everything included by this project is strongly recommended!'),
+ );
+ break;
+ case 'not-fetched':
+ $project_data['status'] = UPDATE_NOT_FETCHED;
+ $project_data['reason'] = t('Failed to get available update data.');
+ break;
+
+ default:
+ // Assume anything else (e.g. 'published') is valid and we should
+ // perform the rest of the logic in this function.
+ break;
+ }
+ }
+
+ if (!empty($project_data['status'])) {
+ // We already know the status for this project, so there's nothing else to
+ // compute. Record the project status into $project_data and we're done.
+ $project_data['project_status'] = $available['project_status'];
+ return;
+ }
+
+ // Figure out the target major version.
+ $existing_major = $project_data['existing_major'];
+ $supported_majors = array();
+ if (isset($available['supported_majors'])) {
+ $supported_majors = explode(',', $available['supported_majors']);
+ }
+ elseif (isset($available['default_major'])) {
+ // Older release history XML file without supported or recommended.
+ $supported_majors[] = $available['default_major'];
+ }
+
+ if (in_array($existing_major, $supported_majors)) {
+ // Still supported, stay at the current major version.
+ $target_major = $existing_major;
+ }
+ elseif (isset($available['recommended_major'])) {
+ // Since 'recommended_major' is defined, we know this is the new XML
+ // format. Therefore, we know the current release is unsupported since
+ // its major version was not in the 'supported_majors' list. We should
+ // find the best release from the recommended major version.
+ $target_major = $available['recommended_major'];
+ $project_data['status'] = UPDATE_NOT_SUPPORTED;
+ }
+ elseif (isset($available['default_major'])) {
+ // Older release history XML file without recommended, so recommend
+ // the currently defined "default_major" version.
+ $target_major = $available['default_major'];
+ }
+ else {
+ // Malformed XML file? Stick with the current version.
+ $target_major = $existing_major;
+ }
+
+ // Make sure we never tell the admin to downgrade. If we recommended an
+ // earlier version than the one they're running, they'd face an
+ // impossible data migration problem, since Drupal never supports a DB
+ // downgrade path. In the unfortunate case that what they're running is
+ // unsupported, and there's nothing newer for them to upgrade to, we
+ // can't print out a "Recommended version", but just have to tell them
+ // what they have is unsupported and let them figure it out.
+ $target_major = max($existing_major, $target_major);
+
+ $release_patch_changed = '';
+ $patch = '';
+
+ // If the project is marked as UPDATE_FETCH_PENDING, it means that the
+ // data we currently have (if any) is stale, and we've got a task queued
+ // up to (re)fetch the data. In that case, we mark it as such, merge in
+ // whatever data we have (e.g. project title and link), and move on.
+ if (!empty($available['fetch_status']) && $available['fetch_status'] == UPDATE_FETCH_PENDING) {
+ $project_data['status'] = UPDATE_FETCH_PENDING;
+ $project_data['reason'] = t('No available update data');
+ $project_data['fetch_status'] = $available['fetch_status'];
+ return;
+ }
+
+ // Defend ourselves from XML history files that contain no releases.
+ if (empty($available['releases'])) {
+ $project_data['status'] = UPDATE_UNKNOWN;
+ $project_data['reason'] = t('No available releases found');
+ return;
+ }
+ foreach ($available['releases'] as $version => $release) {
+ // First, if this is the existing release, check a few conditions.
+ if ($project_data['existing_version'] === $version) {
+ if (isset($release['terms']['Release type']) &&
+ in_array('Insecure', $release['terms']['Release type'])) {
+ $project_data['status'] = UPDATE_NOT_SECURE;
+ }
+ elseif ($release['status'] == 'unpublished') {
+ $project_data['status'] = UPDATE_REVOKED;
+ if (empty($project_data['extra'])) {
+ $project_data['extra'] = array();
+ }
+ $project_data['extra'][] = array(
+ 'class' => array('release-revoked'),
+ 'label' => t('Release revoked'),
+ 'data' => t('Your currently installed release has been revoked, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
+ );
+ }
+ elseif (isset($release['terms']['Release type']) &&
+ in_array('Unsupported', $release['terms']['Release type'])) {
+ $project_data['status'] = UPDATE_NOT_SUPPORTED;
+ if (empty($project_data['extra'])) {
+ $project_data['extra'] = array();
+ }
+ $project_data['extra'][] = array(
+ 'class' => array('release-not-supported'),
+ 'label' => t('Release not supported'),
+ 'data' => t('Your currently installed release is now unsupported, and is no longer available for download. Disabling everything included in this release or upgrading is strongly recommended!'),
+ );
+ }
+ }
+
+ // Otherwise, ignore unpublished, insecure, or unsupported releases.
+ if ($release['status'] == 'unpublished' ||
+ (isset($release['terms']['Release type']) &&
+ (in_array('Insecure', $release['terms']['Release type']) ||
+ in_array('Unsupported', $release['terms']['Release type'])))) {
+ continue;
+ }
+
+ // See if this is a higher major version than our target and yet still
+ // supported. If so, record it as an "Also available" release.
+ if ($release['version_major'] > $target_major) {
+ if (in_array($release['version_major'], $supported_majors)) {
+ if (!isset($project_data['also'])) {
+ $project_data['also'] = array();
+ }
+ if (!isset($project_data['also'][$release['version_major']])) {
+ $project_data['also'][$release['version_major']] = $version;
+ $project_data['releases'][$version] = $release;
+ }
+ }
+ // Otherwise, this release can't matter to us, since it's neither
+ // from the release series we're currently using nor the recommended
+ // release. We don't even care about security updates for this
+ // branch, since if a project maintainer puts out a security release
+ // at a higher major version and not at the lower major version,
+ // they must remove the lower version from the supported major
+ // versions at the same time, in which case we won't hit this code.
+ continue;
+ }
+
+ // Look for the 'latest version' if we haven't found it yet. Latest is
+ // defined as the most recent version for the target major version.
+ if (!isset($project_data['latest_version'])
+ && $release['version_major'] == $target_major) {
+ $project_data['latest_version'] = $version;
+ $project_data['releases'][$version] = $release;
+ }
+
+ // Look for the development snapshot release for this branch.
+ if (!isset($project_data['dev_version'])
+ && $release['version_major'] == $target_major
+ && isset($release['version_extra'])
+ && $release['version_extra'] == 'dev') {
+ $project_data['dev_version'] = $version;
+ $project_data['releases'][$version] = $release;
+ }
+
+ // Look for the 'recommended' version if we haven't found it yet (see
+ // phpdoc at the top of this function for the definition).
+ if (!isset($project_data['recommended'])
+ && $release['version_major'] == $target_major
+ && isset($release['version_patch'])) {
+ if ($patch != $release['version_patch']) {
+ $patch = $release['version_patch'];
+ $release_patch_changed = $release;
+ }
+ if (empty($release['version_extra']) && $patch == $release['version_patch']) {
+ $project_data['recommended'] = $release_patch_changed['version'];
+ $project_data['releases'][$release_patch_changed['version']] = $release_patch_changed;
+ }
+ }
+
+ // Stop searching once we hit the currently installed version.
+ if ($project_data['existing_version'] === $version) {
+ break;
+ }
+
+ // If we're running a dev snapshot and have a timestamp, stop
+ // searching for security updates once we hit an official release
+ // older than what we've got. Allow 100 seconds of leeway to handle
+ // differences between the datestamp in the .info file and the
+ // timestamp of the tarball itself (which are usually off by 1 or 2
+ // seconds) so that we don't flag that as a new release.
+ if ($project_data['install_type'] == 'dev') {
+ if (empty($project_data['datestamp'])) {
+ // We don't have current timestamp info, so we can't know.
+ continue;
+ }
+ elseif (isset($release['date']) && ($project_data['datestamp'] + 100 > $release['date'])) {
+ // We're newer than this, so we can skip it.
+ continue;
+ }
+ }
+
+ // See if this release is a security update.
+ if (isset($release['terms']['Release type'])
+ && in_array('Security update', $release['terms']['Release type'])) {
+ $project_data['security updates'][] = $release;
+ }
+ }
+
+ // If we were unable to find a recommended version, then make the latest
+ // version the recommended version if possible.
+ if (!isset($project_data['recommended']) && isset($project_data['latest_version'])) {
+ $project_data['recommended'] = $project_data['latest_version'];
+ }
+
+ //
+ // Check to see if we need an update or not.
+ //
+
+ if (!empty($project_data['security updates'])) {
+ // If we found security updates, that always trumps any other status.
+ $project_data['status'] = UPDATE_NOT_SECURE;
+ }
+
+ if (isset($project_data['status'])) {
+ // If we already know the status, we're done.
+ return;
+ }
+
+ // If we don't know what to recommend, there's nothing we can report.
+ // Bail out early.
+ if (!isset($project_data['recommended'])) {
+ $project_data['status'] = UPDATE_UNKNOWN;
+ $project_data['reason'] = t('No available releases found');
+ return;
+ }
+
+ // If we're running a dev snapshot, compare the date of the dev snapshot
+ // with the latest official version, and record the absolute latest in
+ // 'latest_dev' so we can correctly decide if there's a newer release
+ // than our current snapshot.
+ if ($project_data['install_type'] == 'dev') {
+ if (isset($project_data['dev_version']) && $available['releases'][$project_data['dev_version']]['date'] > $available['releases'][$project_data['latest_version']]['date']) {
+ $project_data['latest_dev'] = $project_data['dev_version'];
+ }
+ else {
+ $project_data['latest_dev'] = $project_data['latest_version'];
+ }
+ }
+
+ // Figure out the status, based on what we've seen and the install type.
+ switch ($project_data['install_type']) {
+ case 'official':
+ if ($project_data['existing_version'] === $project_data['recommended'] || $project_data['existing_version'] === $project_data['latest_version']) {
+ $project_data['status'] = UPDATE_CURRENT;
+ }
+ else {
+ $project_data['status'] = UPDATE_NOT_CURRENT;
+ }
+ break;
+
+ case 'dev':
+ $latest = $available['releases'][$project_data['latest_dev']];
+ if (empty($project_data['datestamp'])) {
+ $project_data['status'] = UPDATE_NOT_CHECKED;
+ $project_data['reason'] = t('Unknown release date');
+ }
+ elseif (($project_data['datestamp'] + 100 > $latest['date'])) {
+ $project_data['status'] = UPDATE_CURRENT;
+ }
+ else {
+ $project_data['status'] = UPDATE_NOT_CURRENT;
+ }
+ break;
+
+ default:
+ $project_data['status'] = UPDATE_UNKNOWN;
+ $project_data['reason'] = t('Invalid info');
+ }
+}
+
+/**
+ * Retrieve data from {cache_update} or empty the cache when necessary.
+ *
+ * Two very expensive arrays computed by this module are the list of all
+ * installed modules and themes (and .info data, project associations, etc),
+ * and the current status of the site relative to the currently available
+ * releases. These two arrays are cached in the {cache_update} table and used
+ * whenever possible. The cache is cleared whenever the administrator visits
+ * the status report, available updates report, or the module or theme
+ * administration pages, since we should always recompute the most current
+ * values on any of those pages.
+ *
+ * Note: while both of these arrays are expensive to compute (in terms of disk
+ * I/O and some fairly heavy CPU processing), neither of these is the actual
+ * data about available updates that we have to fetch over the network from
+ * updates.drupal.org. That information is stored with the
+ * 'update_available_releases' cache ID -- it needs to persist longer than 1
+ * hour and never get invalidated just by visiting a page on the site.
+ *
+ * @param $cid
+ * The cache id of data to return from the cache. Valid options are
+ * 'update_project_data' and 'update_project_projects'.
+ *
+ * @return
+ * The cached value of the $projects array generated by
+ * update_calculate_project_data() or update_get_projects(), or an empty
+ * array when the cache is cleared.
+ */
+function update_project_cache($cid) {
+ $projects = array();
+
+ // On certain paths, we should clear the cache and recompute the projects for
+ // update status of the site to avoid presenting stale information.
+ $q = $_GET['q'];
+ $paths = array(
+ 'admin/modules',
+ 'admin/modules/update',
+ 'admin/appearance',
+ 'admin/appearance/update',
+ 'admin/reports',
+ 'admin/reports/updates',
+ 'admin/reports/updates/update',
+ 'admin/reports/status',
+ 'admin/reports/updates/check',
+ );
+ if (in_array($q, $paths)) {
+ _update_cache_clear($cid);
+ }
+ else {
+ $cache = _update_cache_get($cid);
+ if (!empty($cache->data) && $cache->expire > REQUEST_TIME) {
+ $projects = $cache->data;
+ }
+ }
+ return $projects;
+}
+
+/**
+ * Filter the project .info data to only save attributes we need.
+ *
+ * @param array $info
+ * Array of .info file data as returned by drupal_parse_info_file().
+ *
+ * @return
+ * Array of .info file data we need for the Update manager.
+ *
+ * @see _update_process_info_list()
+ */
+function update_filter_project_info($info) {
+ $whitelist = array(
+ '_info_file_ctime',
+ 'datestamp',
+ 'major',
+ 'name',
+ 'package',
+ 'project',
+ 'project status url',
+ 'version',
+ );
+ return array_intersect_key($info, drupal_map_assoc($whitelist));
+}
diff --git a/core/modules/update/update.css b/core/modules/update/update.css
new file mode 100644
index 000000000000..d30dfb6e50b8
--- /dev/null
+++ b/core/modules/update/update.css
@@ -0,0 +1,131 @@
+
+.update .project {
+ font-weight: bold;
+ font-size: 110%;
+ padding-left: .25em; /* LTR */
+ height: 22px;
+}
+
+.update .version-status {
+ float: right; /* LTR */
+ padding-right: 10px; /* LTR */
+ font-size: 110%;
+ height: 20px;
+}
+
+.update .version-status .icon {
+ padding-left: .5em; /* LTR */
+}
+
+.update .version-date {
+ white-space: nowrap;
+}
+
+.update .info {
+ margin: 0;
+ padding: 1em 1em .25em 1em;
+}
+
+.update tr.even,
+.update tr.odd {
+ border: none;
+}
+
+.update tr td {
+ border-top: 1px solid #ccc;
+ border-bottom: 1px solid #ccc;
+}
+
+.update tr.error {
+ background: #fcc;
+}
+
+.update tr.error .version-recommended {
+ background: #fdd;
+}
+
+.update tr.ok {
+ background: #dfd;
+}
+
+.update tr.warning {
+ background: #ffd;
+}
+
+.update tr.warning .version-recommended {
+ background: #ffe;
+}
+
+.current-version,
+.new-version {
+ direction: ltr; /* Note: version numbers should always be LTR. */
+}
+
+.update tr.unknown {
+ background: #ddd;
+}
+
+table.update,
+.update table.version {
+ width: 100%;
+ margin-top: .5em;
+ border: none;
+}
+
+.update table.version tbody {
+ border: none;
+}
+
+.update table.version tr,
+.update table.version td {
+ line-height: .9em;
+ padding: 0;
+ margin: 0;
+ border: none;
+ background: none;
+}
+
+.update table.version .version-title {
+ padding-left: 1em; /* LTR */
+ width: 14em;
+}
+
+.update table.version .version-details {
+ padding-right: .5em; /* LTR */
+}
+
+.update table.version .version-links {
+ text-align: right; /* LTR */
+ padding-right: 1em; /* LTR */
+}
+
+.update table.version-security .version-title {
+ color: #970F00;
+}
+
+.update table.version-recommended-strong .version-title {
+ font-weight: bold;
+}
+
+.update .security-error {
+ font-weight: bold;
+ color: #970F00;
+}
+
+.update .check-manually {
+ padding-left: 1em; /* LTR */
+}
+
+.update-major-version-warning {
+ color: #ff0000;
+}
+
+table tbody tr.update-security,
+table tbody tr.update-unsupported {
+ background: #fcc;
+}
+
+th.update-project-name {
+ width: 50%;
+}
+
diff --git a/core/modules/update/update.fetch.inc b/core/modules/update/update.fetch.inc
new file mode 100644
index 000000000000..7ac0dbefbc2b
--- /dev/null
+++ b/core/modules/update/update.fetch.inc
@@ -0,0 +1,388 @@
+<?php
+
+/**
+ * @file
+ * Code required only when fetching information about available updates.
+ */
+
+/**
+ * Callback to manually check the update status without cron.
+ */
+function update_manual_status() {
+ _update_refresh();
+ $batch = array(
+ 'operations' => array(
+ array('update_fetch_data_batch', array()),
+ ),
+ 'finished' => 'update_fetch_data_finished',
+ 'title' => t('Checking available update data'),
+ 'progress_message' => t('Trying to check available update data ...'),
+ 'error_message' => t('Error checking available update data.'),
+ 'file' => drupal_get_path('module', 'update') . '/update.fetch.inc',
+ );
+ batch_set($batch);
+ batch_process('admin/reports/updates');
+}
+
+/**
+ * Process a step in the batch for fetching available update data.
+ */
+function update_fetch_data_batch(&$context) {
+ $queue = DrupalQueue::get('update_fetch_tasks');
+ if (empty($context['sandbox']['max'])) {
+ $context['finished'] = 0;
+ $context['sandbox']['max'] = $queue->numberOfItems();
+ $context['sandbox']['progress'] = 0;
+ $context['message'] = t('Checking available update data ...');
+ $context['results']['updated'] = 0;
+ $context['results']['failures'] = 0;
+ $context['results']['processed'] = 0;
+ }
+
+ // Grab another item from the fetch queue.
+ for ($i = 0; $i < 5; $i++) {
+ if ($item = $queue->claimItem()) {
+ if (_update_process_fetch_task($item->data)) {
+ $context['results']['updated']++;
+ $context['message'] = t('Checked available update data for %title.', array('%title' => $item->data['info']['name']));
+ }
+ else {
+ $context['message'] = t('Failed to check available update data for %title.', array('%title' => $item->data['info']['name']));
+ $context['results']['failures']++;
+ }
+ $context['sandbox']['progress']++;
+ $context['results']['processed']++;
+ $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
+ $queue->deleteItem($item);
+ }
+ else {
+ // If the queue is currently empty, we're done. It's possible that
+ // another thread might have added new fetch tasks while we were
+ // processing this batch. In that case, the usual 'finished' math could
+ // get confused, since we'd end up processing more tasks that we thought
+ // we had when we started and initialized 'max' with numberOfItems(). By
+ // forcing 'finished' to be exactly 1 here, we ensure that batch
+ // processing is terminated.
+ $context['finished'] = 1;
+ return;
+ }
+ }
+}
+
+/**
+ * Batch API callback when all fetch tasks have been completed.
+ *
+ * @param $success
+ * Boolean indicating the success of the batch.
+ * @param $results
+ * Associative array holding the results of the batch, including the key
+ * 'updated' which holds the total number of projects we fetched available
+ * update data for.
+ */
+function update_fetch_data_finished($success, $results) {
+ if ($success) {
+ if (!empty($results)) {
+ if (!empty($results['updated'])) {
+ drupal_set_message(format_plural($results['updated'], 'Checked available update data for one project.', 'Checked available update data for @count projects.'));
+ }
+ if (!empty($results['failures'])) {
+ drupal_set_message(format_plural($results['failures'], 'Failed to get available update data for one project.', 'Failed to get available update data for @count projects.'), 'error');
+ }
+ }
+ }
+ else {
+ drupal_set_message(t('An error occurred trying to get available update data.'), 'error');
+ }
+}
+
+/**
+ * Attempt to drain the queue of tasks for release history data to fetch.
+ */
+function _update_fetch_data() {
+ $queue = DrupalQueue::get('update_fetch_tasks');
+ $end = time() + variable_get('update_max_fetch_time', UPDATE_MAX_FETCH_TIME);
+ while (time() < $end && ($item = $queue->claimItem())) {
+ _update_process_fetch_task($item->data);
+ $queue->deleteItem($item);
+ }
+}
+
+/**
+ * Process a task to fetch available update data for a single project.
+ *
+ * Once the release history XML data is downloaded, it is parsed and saved
+ * into the {cache_update} table in an entry just for that project.
+ *
+ * @param $project
+ * Associative array of information about the project to fetch data for.
+ * @return
+ * TRUE if we fetched parsable XML, otherwise FALSE.
+ */
+function _update_process_fetch_task($project) {
+ global $base_url;
+ $fail = &drupal_static(__FUNCTION__, array());
+ // This can be in the middle of a long-running batch, so REQUEST_TIME won't
+ // necessarily be valid.
+ $now = time();
+ if (empty($fail)) {
+ // If we have valid data about release history XML servers that we have
+ // failed to fetch from on previous attempts, load that from the cache.
+ if (($cache = _update_cache_get('fetch_failures')) && ($cache->expire > $now)) {
+ $fail = $cache->data;
+ }
+ }
+
+ $max_fetch_attempts = variable_get('update_max_fetch_attempts', UPDATE_MAX_FETCH_ATTEMPTS);
+
+ $success = FALSE;
+ $available = array();
+ $site_key = drupal_hmac_base64($base_url, drupal_get_private_key());
+ $url = _update_build_fetch_url($project, $site_key);
+ $fetch_url_base = _update_get_fetch_url_base($project);
+ $project_name = $project['name'];
+
+ if (empty($fail[$fetch_url_base]) || $fail[$fetch_url_base] < $max_fetch_attempts) {
+ $xml = drupal_http_request($url);
+ if (!isset($xml->error) && isset($xml->data)) {
+ $data = $xml->data;
+ }
+ }
+
+ if (!empty($data)) {
+ $available = update_parse_xml($data);
+ // @todo: Purge release data we don't need (http://drupal.org/node/238950).
+ if (!empty($available)) {
+ // Only if we fetched and parsed something sane do we return success.
+ $success = TRUE;
+ }
+ }
+ else {
+ $available['project_status'] = 'not-fetched';
+ if (empty($fail[$fetch_url_base])) {
+ $fail[$fetch_url_base] = 1;
+ }
+ else {
+ $fail[$fetch_url_base]++;
+ }
+ }
+
+ $frequency = variable_get('update_check_frequency', 1);
+ $cid = 'available_releases::' . $project_name;
+ _update_cache_set($cid, $available, $now + (60 * 60 * 24 * $frequency));
+
+ // Stash the $fail data back in the DB for the next 5 minutes.
+ _update_cache_set('fetch_failures', $fail, $now + (60 * 5));
+
+ // Whether this worked or not, we did just (try to) check for updates.
+ variable_set('update_last_check', $now);
+
+ // Now that we processed the fetch task for this project, clear out the
+ // record in {cache_update} for this task so we're willing to fetch again.
+ _update_cache_clear('fetch_task::' . $project_name);
+
+ return $success;
+}
+
+/**
+ * Clear out all the cached available update data and initiate re-fetching.
+ */
+function _update_refresh() {
+ module_load_include('inc', 'update', 'update.compare');
+
+ // Since we're fetching new available update data, we want to clear
+ // our cache of both the projects we care about, and the current update
+ // status of the site. We do *not* want to clear the cache of available
+ // releases just yet, since that data (even if it's stale) can be useful
+ // during update_get_projects(); for example, to modules that implement
+ // hook_system_info_alter() such as cvs_deploy.
+ _update_cache_clear('update_project_projects');
+ _update_cache_clear('update_project_data');
+
+ $projects = update_get_projects();
+
+ // Now that we have the list of projects, we should also clear our cache of
+ // available release data, since even if we fail to fetch new data, we need
+ // to clear out the stale data at this point.
+ _update_cache_clear('available_releases::', TRUE);
+
+ foreach ($projects as $key => $project) {
+ update_create_fetch_task($project);
+ }
+}
+
+/**
+ * Add a task to the queue for fetching release history data for a project.
+ *
+ * We only create a new fetch task if there's no task already in the queue for
+ * this particular project (based on 'fetch_task::' entries in the
+ * {cache_update} table).
+ *
+ * @param $project
+ * Associative array of information about a project as created by
+ * update_get_projects(), including keys such as 'name' (short name),
+ * and the 'info' array with data from a .info file for the project.
+ *
+ * @see update_get_projects()
+ * @see update_get_available()
+ * @see update_refresh()
+ * @see update_fetch_data()
+ * @see _update_process_fetch_task()
+ */
+function _update_create_fetch_task($project) {
+ $fetch_tasks = &drupal_static(__FUNCTION__, array());
+ if (empty($fetch_tasks)) {
+ $fetch_tasks = _update_get_cache_multiple('fetch_task');
+ }
+ $cid = 'fetch_task::' . $project['name'];
+ if (empty($fetch_tasks[$cid])) {
+ $queue = DrupalQueue::get('update_fetch_tasks');
+ $queue->createItem($project);
+ db_insert('cache_update')
+ ->fields(array(
+ 'cid' => $cid,
+ 'created' => REQUEST_TIME,
+ ))
+ ->execute();
+ $fetch_tasks[$cid] = REQUEST_TIME;
+ }
+}
+
+/**
+ * Generates the URL to fetch information about project updates.
+ *
+ * This figures out the right URL to use, based on the project's .info file
+ * and the global defaults. Appends optional query arguments when the site is
+ * configured to report usage stats.
+ *
+ * @param $project
+ * The array of project information from update_get_projects().
+ * @param $site_key
+ * The anonymous site key hash (optional).
+ *
+ * @see update_fetch_data()
+ * @see _update_process_fetch_task()
+ * @see update_get_projects()
+ */
+function _update_build_fetch_url($project, $site_key = '') {
+ $name = $project['name'];
+ $url = _update_get_fetch_url_base($project);
+ $url .= '/' . $name . '/' . DRUPAL_CORE_COMPATIBILITY;
+ // Only append a site_key and the version information if we have a site_key
+ // in the first place, and if this is not a disabled module or theme. We do
+ // not want to record usage statistics for disabled code.
+ if (!empty($site_key) && (strpos($project['project_type'], 'disabled') === FALSE)) {
+ $url .= (strpos($url, '?') === TRUE) ? '&' : '?';
+ $url .= 'site_key=';
+ $url .= rawurlencode($site_key);
+ if (!empty($project['info']['version'])) {
+ $url .= '&version=';
+ $url .= rawurlencode($project['info']['version']);
+ }
+ }
+ return $url;
+}
+
+/**
+ * Return the base of the URL to fetch available update data for a project.
+ *
+ * @param $project
+ * The array of project information from update_get_projects().
+ * @return
+ * The base of the URL used for fetching available update data. This does
+ * not include the path elements to specify a particular project, version,
+ * site_key, etc.
+ *
+ * @see _update_build_fetch_url()
+ */
+function _update_get_fetch_url_base($project) {
+ return isset($project['info']['project status url']) ? $project['info']['project status url'] : variable_get('update_fetch_url', UPDATE_DEFAULT_URL);
+}
+
+/**
+ * Perform any notifications that should be done once cron fetches new data.
+ *
+ * This method checks the status of the site using the new data and depending
+ * on the configuration of the site, notifies administrators via email if there
+ * are new releases or missing security updates.
+ *
+ * @see update_requirements()
+ */
+function _update_cron_notify() {
+ module_load_install('update');
+ $status = update_requirements('runtime');
+ $params = array();
+ $notify_all = (variable_get('update_notification_threshold', 'all') == 'all');
+ foreach (array('core', 'contrib') as $report_type) {
+ $type = 'update_' . $report_type;
+ if (isset($status[$type]['severity'])
+ && ($status[$type]['severity'] == REQUIREMENT_ERROR || ($notify_all && $status[$type]['reason'] == UPDATE_NOT_CURRENT))) {
+ $params[$report_type] = $status[$type]['reason'];
+ }
+ }
+ if (!empty($params)) {
+ $notify_list = variable_get('update_notify_emails', '');
+ if (!empty($notify_list)) {
+ $default_language = language_default();
+ foreach ($notify_list as $target) {
+ if ($target_user = user_load_by_mail($target)) {
+ $target_language = user_preferred_language($target_user);
+ }
+ else {
+ $target_language = $default_language;
+ }
+ drupal_mail('update', 'status_notify', $target, $target_language, $params);
+ }
+ }
+ }
+}
+
+/**
+ * Parse the XML of the Drupal release history info files.
+ *
+ * @param $raw_xml
+ * A raw XML string of available release data for a given project.
+ *
+ * @return
+ * Array of parsed data about releases for a given project, or NULL if there
+ * was an error parsing the string.
+ */
+function update_parse_xml($raw_xml) {
+ try {
+ $xml = new SimpleXMLElement($raw_xml);
+ }
+ catch (Exception $e) {
+ // SimpleXMLElement::__construct produces an E_WARNING error message for
+ // each error found in the XML data and throws an exception if errors
+ // were detected. Catch any exception and return failure (NULL).
+ return;
+ }
+ // If there is no valid project data, the XML is invalid, so return failure.
+ if (!isset($xml->short_name)) {
+ return;
+ }
+ $short_name = (string) $xml->short_name;
+ $data = array();
+ foreach ($xml as $k => $v) {
+ $data[$k] = (string) $v;
+ }
+ $data['releases'] = array();
+ if (isset($xml->releases)) {
+ foreach ($xml->releases->children() as $release) {
+ $version = (string) $release->version;
+ $data['releases'][$version] = array();
+ foreach ($release->children() as $k => $v) {
+ $data['releases'][$version][$k] = (string) $v;
+ }
+ $data['releases'][$version]['terms'] = array();
+ if ($release->terms) {
+ foreach ($release->terms->children() as $term) {
+ if (!isset($data['releases'][$version]['terms'][(string) $term->name])) {
+ $data['releases'][$version]['terms'][(string) $term->name] = array();
+ }
+ $data['releases'][$version]['terms'][(string) $term->name][] = (string) $term->value;
+ }
+ }
+ }
+ }
+ return $data;
+}
diff --git a/core/modules/update/update.info b/core/modules/update/update.info
new file mode 100644
index 000000000000..83b9857d0e10
--- /dev/null
+++ b/core/modules/update/update.info
@@ -0,0 +1,7 @@
+name = Update manager
+description = Checks for available updates, and can securely install or update modules and themes via a web interface.
+version = VERSION
+package = Core
+core = 8.x
+files[] = update.test
+configure = admin/reports/updates/settings
diff --git a/core/modules/update/update.install b/core/modules/update/update.install
new file mode 100644
index 000000000000..f0d549922a0d
--- /dev/null
+++ b/core/modules/update/update.install
@@ -0,0 +1,159 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the update module.
+ */
+
+/**
+ * Implements hook_requirements().
+ *
+ * @return
+ * An array describing the status of the site regarding available updates.
+ * If there is no update data, only one record will be returned, indicating
+ * that the status of core can't be determined. If data is available, there
+ * will be two records: one for core, and another for all of contrib
+ * (assuming there are any contributed modules or themes enabled on the
+ * site). In addition to the fields expected by hook_requirements ('value',
+ * 'severity', and optionally 'description'), this array will contain a
+ * 'reason' attribute, which is an integer constant to indicate why the
+ * given status is being returned (UPDATE_NOT_SECURE, UPDATE_NOT_CURRENT, or
+ * UPDATE_UNKNOWN). This is used for generating the appropriate e-mail
+ * notification messages during update_cron(), and might be useful for other
+ * modules that invoke update_requirements() to find out if the site is up
+ * to date or not.
+ *
+ * @see _update_message_text()
+ * @see _update_cron_notify()
+ */
+function update_requirements($phase) {
+ $requirements = array();
+ if ($phase == 'runtime') {
+ if ($available = update_get_available(FALSE)) {
+ module_load_include('inc', 'update', 'update.compare');
+ $data = update_calculate_project_data($available);
+ // First, populate the requirements for core:
+ $requirements['update_core'] = _update_requirement_check($data['drupal'], 'core');
+ // We don't want to check drupal a second time.
+ unset($data['drupal']);
+ if (!empty($data)) {
+ // Now, sort our $data array based on each project's status. The
+ // status constants are numbered in the right order of precedence, so
+ // we just need to make sure the projects are sorted in ascending
+ // order of status, and we can look at the first project we find.
+ uasort($data, '_update_project_status_sort');
+ $first_project = reset($data);
+ $requirements['update_contrib'] = _update_requirement_check($first_project, 'contrib');
+ }
+ }
+ else {
+ $requirements['update_core']['title'] = t('Drupal core update status');
+ $requirements['update_core']['value'] = t('No update data available');
+ $requirements['update_core']['severity'] = REQUIREMENT_WARNING;
+ $requirements['update_core']['reason'] = UPDATE_UNKNOWN;
+ $requirements['update_core']['description'] = _update_no_data();
+ }
+ }
+ return $requirements;
+}
+
+/**
+ * Implements hook_schema().
+ */
+function update_schema() {
+ $schema['cache_update'] = drupal_get_schema_unprocessed('system', 'cache');
+ $schema['cache_update']['description'] = 'Cache table for the Update module to store information about available releases, fetched from central server.';
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function update_install() {
+ $queue = DrupalQueue::get('update_fetch_tasks', TRUE);
+ $queue->createQueue();
+}
+
+/**
+ * Implements hook_uninstall().
+ */
+function update_uninstall() {
+ // Clear any variables that might be in use
+ $variables = array(
+ 'update_check_frequency',
+ 'update_fetch_url',
+ 'update_last_check',
+ 'update_notification_threshold',
+ 'update_notify_emails',
+ 'update_max_fetch_attempts',
+ 'update_max_fetch_time',
+ );
+ foreach ($variables as $variable) {
+ variable_del($variable);
+ }
+ $queue = DrupalQueue::get('update_fetch_tasks');
+ $queue->deleteQueue();
+}
+
+/**
+ * Private helper method to fill in the requirements array.
+ *
+ * This is shared for both core and contrib to generate the right elements in
+ * the array for hook_requirements().
+ *
+ * @param $project
+ * Array of information about the project we're testing as returned by
+ * update_calculate_project_data().
+ * @param $type
+ * What kind of project is this ('core' or 'contrib').
+ *
+ * @return
+ * An array to be included in the nested $requirements array.
+ *
+ * @see hook_requirements()
+ * @see update_requirements()
+ * @see update_calculate_project_data()
+ */
+function _update_requirement_check($project, $type) {
+ $requirement = array();
+ if ($type == 'core') {
+ $requirement['title'] = t('Drupal core update status');
+ }
+ else {
+ $requirement['title'] = t('Module and theme update status');
+ }
+ $status = $project['status'];
+ if ($status != UPDATE_CURRENT) {
+ $requirement['reason'] = $status;
+ $requirement['description'] = _update_message_text($type, $status, TRUE);
+ $requirement['severity'] = REQUIREMENT_ERROR;
+ }
+ switch ($status) {
+ case UPDATE_NOT_SECURE:
+ $requirement_label = t('Not secure!');
+ break;
+ case UPDATE_REVOKED:
+ $requirement_label = t('Revoked!');
+ break;
+ case UPDATE_NOT_SUPPORTED:
+ $requirement_label = t('Unsupported release');
+ break;
+ case UPDATE_NOT_CURRENT:
+ $requirement_label = t('Out of date');
+ $requirement['severity'] = REQUIREMENT_WARNING;
+ break;
+ case UPDATE_UNKNOWN:
+ case UPDATE_NOT_CHECKED:
+ case UPDATE_NOT_FETCHED:
+ $requirement_label = isset($project['reason']) ? $project['reason'] : t('Can not determine status');
+ $requirement['severity'] = REQUIREMENT_WARNING;
+ break;
+ default:
+ $requirement_label = t('Up to date');
+ }
+ if ($status != UPDATE_CURRENT && $type == 'core' && isset($project['recommended'])) {
+ $requirement_label .= ' ' . t('(version @version available)', array('@version' => $project['recommended']));
+ }
+ $requirement['value'] = l($requirement_label, update_manager_access() ? 'admin/reports/updates/update' : 'admin/reports/updates');
+ return $requirement;
+}
diff --git a/core/modules/update/update.manager.inc b/core/modules/update/update.manager.inc
new file mode 100644
index 000000000000..59858ebe810e
--- /dev/null
+++ b/core/modules/update/update.manager.inc
@@ -0,0 +1,923 @@
+<?php
+
+/**
+ * @file
+ * Administrative screens and processing functions for the update manager.
+ * This allows site administrators with the 'administer software updates'
+ * permission to either upgrade existing projects, or download and install new
+ * ones, so long as the killswitch setting ('allow_authorize_operations') is
+ * still TRUE.
+ *
+ * To install new code, the administrator is prompted for either the URL of an
+ * archive file, or to directly upload the archive file. The archive is loaded
+ * into a temporary location, extracted, and verified. If everything is
+ * successful, the user is redirected to authorize.php to type in their file
+ * transfer credentials and authorize the installation to proceed with
+ * elevated privileges, such that the extracted files can be copied out of the
+ * temporary location and into the live web root.
+ *
+ * Updating existing code is a more elaborate process. The first step is a
+ * selection form where the user is presented with a table of installed
+ * projects that are missing newer releases. The user selects which projects
+ * they wish to upgrade, and presses the "Download updates" button to
+ * continue. This sets up a batch to fetch all the selected releases, and
+ * redirects to admin/update/download to display the batch progress bar as it
+ * runs. Each batch operation is responsible for downloading a single file,
+ * extracting the archive, and verifying the contents. If there are any
+ * errors, the user is redirected back to the first page with the error
+ * messages. If all downloads were extacted and verified, the user is instead
+ * redirected to admin/update/ready, a landing page which reminds them to
+ * backup their database and asks if they want to put the site offline during
+ * the upgrade. Once the user presses the "Install updates" button, they are
+ * redirected to authorize.php to supply their web root file access
+ * credentials. The authorized operation (which lives in update.authorize.inc)
+ * sets up a batch to copy each extracted update from the temporary location
+ * into the live web root.
+ */
+
+/**
+ * @defgroup update_manager_update Update manager: update
+ * @{
+ * Update manager for updating existing code.
+ *
+ * Provides a user interface to update existing code.
+ */
+
+/**
+ * Build the form for the update manager page to update existing projects.
+ *
+ * This presents a table with all projects that have available updates with
+ * checkboxes to select which ones to upgrade.
+ *
+ * @param $form
+ * @param $form_state
+ * @param $context
+ * String representing the context from which we're trying to update, can be:
+ * 'module', 'theme' or 'report'.
+ * @return
+ * The form array for selecting which projects to update.
+ */
+function update_manager_update_form($form, $form_state = array(), $context) {
+ if (!_update_manager_check_backends($form, 'update')) {
+ return $form;
+ }
+
+ $form['#theme'] = 'update_manager_update_form';
+
+ $available = update_get_available(TRUE);
+ if (empty($available)) {
+ $form['message'] = array(
+ '#markup' => t('There was a problem getting update information. Try again later.'),
+ );
+ return $form;
+ }
+
+ $form['#attached']['css'][] = drupal_get_path('module', 'update') . '/update.css';
+
+ // This will be a nested array. The first key is the kind of project, which
+ // can be either 'enabled', 'disabled', 'manual' (projects which require
+ // manual updates, such as core). Then, each subarray is an array of
+ // projects of that type, indexed by project short name, and containing an
+ // array of data for cells in that project's row in the appropriate table.
+ $projects = array();
+
+ // This stores the actual download link we're going to update from for each
+ // project in the form, regardless of if it's enabled or disabled.
+ $form['project_downloads'] = array('#tree' => TRUE);
+
+ module_load_include('inc', 'update', 'update.compare');
+ $project_data = update_calculate_project_data($available);
+ foreach ($project_data as $name => $project) {
+ // Filter out projects which are up to date already.
+ if ($project['status'] == UPDATE_CURRENT) {
+ continue;
+ }
+ // The project name to display can vary based on the info we have.
+ if (!empty($project['title'])) {
+ if (!empty($project['link'])) {
+ $project_name = l($project['title'], $project['link']);
+ }
+ else {
+ $project_name = check_plain($project['title']);
+ }
+ }
+ elseif (!empty($project['info']['name'])) {
+ $project_name = check_plain($project['info']['name']);
+ }
+ else {
+ $project_name = check_plain($name);
+ }
+ if ($project['project_type'] == 'theme' || $project['project_type'] == 'theme-disabled') {
+ $project_name .= ' ' . t('(Theme)');
+ }
+
+ if (empty($project['recommended'])) {
+ // If we don't know what to recommend they upgrade to, we should skip
+ // the project entirely.
+ continue;
+ }
+
+ $recommended_release = $project['releases'][$project['recommended']];
+ $recommended_version = $recommended_release['version'] . ' ' . l(t('(Release notes)'), $recommended_release['release_link'], array('attributes' => array('title' => t('Release notes for @project_title', array('@project_title' => $project['title'])))));
+ if ($recommended_release['version_major'] != $project['existing_major']) {
+ $recommended_version .= '<div title="Major upgrade warning" class="update-major-version-warning">' . t('This update is a major version update which means that it may not be backwards compatible with your currently running version. It is recommended that you read the release notes and proceed at your own risk.') . '</div>';
+ }
+
+ // Create an entry for this project.
+ $entry = array(
+ 'title' => $project_name,
+ 'installed_version' => $project['existing_version'],
+ 'recommended_version' => $recommended_version,
+ );
+
+ switch ($project['status']) {
+ case UPDATE_NOT_SECURE:
+ case UPDATE_REVOKED:
+ $entry['title'] .= ' ' . t('(Security update)');
+ $entry['#weight'] = -2;
+ $type = 'security';
+ break;
+
+ case UPDATE_NOT_SUPPORTED:
+ $type = 'unsupported';
+ $entry['title'] .= ' ' . t('(Unsupported)');
+ $entry['#weight'] = -1;
+ break;
+
+ case UPDATE_UNKNOWN:
+ case UPDATE_NOT_FETCHED:
+ case UPDATE_NOT_CHECKED:
+ case UPDATE_NOT_CURRENT:
+ $type = 'recommended';
+ break;
+
+ default:
+ // Jump out of the switch and onto the next project in foreach.
+ continue 2;
+ }
+
+ $entry['#attributes'] = array('class' => array('update-' . $type));
+
+ // Drupal core needs to be upgraded manually.
+ $needs_manual = $project['project_type'] == 'core';
+
+ if ($needs_manual) {
+ // There are no checkboxes in the 'Manual updates' table so it will be
+ // rendered by theme('table'), not theme('tableselect'). Since the data
+ // formats are incompatible, we convert now to the format expected by
+ // theme('table').
+ unset($entry['#weight']);
+ $attributes = $entry['#attributes'];
+ unset($entry['#attributes']);
+ $entry = array(
+ 'data' => $entry,
+ ) + $attributes;
+ }
+ else {
+ $form['project_downloads'][$name] = array(
+ '#type' => 'value',
+ '#value' => $recommended_release['download_link'],
+ );
+ }
+
+ // Based on what kind of project this is, save the entry into the
+ // appropriate subarray.
+ switch ($project['project_type']) {
+ case 'core':
+ // Core needs manual updates at this time.
+ $projects['manual'][$name] = $entry;
+ break;
+
+ case 'module':
+ case 'theme':
+ $projects['enabled'][$name] = $entry;
+ break;
+
+ case 'module-disabled':
+ case 'theme-disabled':
+ $projects['disabled'][$name] = $entry;
+ break;
+ }
+ }
+
+ if (empty($projects)) {
+ $form['message'] = array(
+ '#markup' => t('All of your projects are up to date.'),
+ );
+ return $form;
+ }
+
+ $headers = array(
+ 'title' => array(
+ 'data' => t('Name'),
+ 'class' => array('update-project-name'),
+ ),
+ 'installed_version' => t('Installed version'),
+ 'recommended_version' => t('Recommended version'),
+ );
+
+ if (!empty($projects['enabled'])) {
+ $form['projects'] = array(
+ '#type' => 'tableselect',
+ '#header' => $headers,
+ '#options' => $projects['enabled'],
+ );
+ if (!empty($projects['disabled'])) {
+ $form['projects']['#prefix'] = '<h2>' . t('Enabled') . '</h2>';
+ }
+ }
+
+ if (!empty($projects['disabled'])) {
+ $form['disabled_projects'] = array(
+ '#type' => 'tableselect',
+ '#header' => $headers,
+ '#options' => $projects['disabled'],
+ '#weight' => 1,
+ '#prefix' => '<h2>' . t('Disabled') . '</h2>',
+ );
+ }
+
+ // If either table has been printed yet, we need a submit button and to
+ // validate the checkboxes.
+ if (!empty($projects['enabled']) || !empty($projects['disabled'])) {
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Download these updates'),
+ );
+ $form['#validate'][] = 'update_manager_update_form_validate';
+ }
+
+ if (!empty($projects['manual'])) {
+ $prefix = '<h2>' . t('Manual updates required') . '</h2>';
+ $prefix .= '<p>' . t('Updates of Drupal core are not supported at this time.') . '</p>';
+ $form['manual_updates'] = array(
+ '#type' => 'markup',
+ '#markup' => theme('table', array('header' => $headers, 'rows' => $projects['manual'])),
+ '#prefix' => $prefix,
+ '#weight' => 120,
+ );
+ }
+
+ return $form;
+}
+
+/**
+ * Returns HTML for the first page in the update manager wizard to select projects.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_update_manager_update_form($variables) {
+ $form = $variables['form'];
+ $last = variable_get('update_last_check', 0);
+ $output = theme('update_last_check', array('last' => $last));
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+/**
+ * Validation callback to ensure that at least one project is selected.
+ */
+function update_manager_update_form_validate($form, &$form_state) {
+ if (!empty($form_state['values']['projects'])) {
+ $enabled = array_filter($form_state['values']['projects']);
+ }
+ if (!empty($form_state['values']['disabled_projects'])) {
+ $disabled = array_filter($form_state['values']['disabled_projects']);
+ }
+ if (empty($enabled) && empty($disabled)) {
+ form_set_error('projects', t('You must select at least one project to update.'));
+ }
+}
+
+/**
+ * Submit function for the main update form.
+ *
+ * This sets up a batch to download, extract and verify the selected releases
+ *
+ * @see update_manager_update_form()
+ */
+function update_manager_update_form_submit($form, &$form_state) {
+ $projects = array();
+ foreach (array('projects', 'disabled_projects') as $type) {
+ if (!empty($form_state['values'][$type])) {
+ $projects = array_merge($projects, array_keys(array_filter($form_state['values'][$type])));
+ }
+ }
+ $operations = array();
+ foreach ($projects as $project) {
+ $operations[] = array(
+ 'update_manager_batch_project_get',
+ array(
+ $project,
+ $form_state['values']['project_downloads'][$project],
+ ),
+ );
+ }
+ $batch = array(
+ 'title' => t('Downloading updates'),
+ 'init_message' => t('Preparing to download selected updates'),
+ 'operations' => $operations,
+ 'finished' => 'update_manager_download_batch_finished',
+ 'file' => drupal_get_path('module', 'update') . '/update.manager.inc',
+ );
+ batch_set($batch);
+}
+
+/**
+ * Batch callback invoked when the download batch is completed.
+ */
+function update_manager_download_batch_finished($success, $results) {
+ if (!empty($results['errors'])) {
+ $error_list = array(
+ 'title' => t('Downloading updates failed:'),
+ 'items' => $results['errors'],
+ );
+ drupal_set_message(theme('item_list', $error_list), 'error');
+ }
+ elseif ($success) {
+ drupal_set_message(t('Updates downloaded successfully.'));
+ $_SESSION['update_manager_update_projects'] = $results['projects'];
+ drupal_goto('admin/update/ready');
+ }
+ else {
+ // Ideally we're catching all Exceptions, so they should never see this,
+ // but just in case, we have to tell them something.
+ drupal_set_message(t('Fatal error trying to download.'), 'error');
+ }
+}
+
+/**
+ * Build the form when the site is ready to update (after downloading).
+ *
+ * This form is an intermediary step in the automated update workflow. It is
+ * presented to the site administrator after all the required updates have
+ * been downloaded and verified. The point of this page is to encourage the
+ * user to backup their site, gives them the opportunity to put the site
+ * offline, and then asks them to confirm that the update should continue.
+ * After this step, the user is redirected to authorize.php to enter their
+ * file transfer credentials and attempt to complete the update.
+ */
+function update_manager_update_ready_form($form, &$form_state) {
+ if (!_update_manager_check_backends($form, 'update')) {
+ return $form;
+ }
+
+ $form['backup'] = array(
+ '#prefix' => '<strong>',
+ '#markup' => t('Back up your database and site before you continue. <a href="@backup_url">Learn how</a>.', array('@backup_url' => url('http://drupal.org/node/22281'))),
+ '#suffix' => '</strong>',
+ );
+
+ $form['maintenance_mode'] = array(
+ '#title' => t('Perform updates with site in maintenance mode (strongly recommended)'),
+ '#type' => 'checkbox',
+ '#default_value' => TRUE,
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Continue'),
+ );
+
+ return $form;
+}
+
+/**
+ * Submit handler for the form to confirm that an update should continue.
+ *
+ * If the site administrator requested that the site is put offline during the
+ * update, do so now. Otherwise, pull information about all the required
+ * updates out of the SESSION, figure out what Updater class is needed for
+ * each one, generate an array of update operations to perform, and hand it
+ * all off to system_authorized_init(), then redirect to authorize.php.
+ *
+ * @see update_authorize_run_update()
+ * @see system_authorized_init()
+ * @see system_authorized_get_url()
+ */
+function update_manager_update_ready_form_submit($form, &$form_state) {
+ // Store maintenance_mode setting so we can restore it when done.
+ $_SESSION['maintenance_mode'] = variable_get('maintenance_mode', FALSE);
+ if ($form_state['values']['maintenance_mode'] == TRUE) {
+ variable_set('maintenance_mode', TRUE);
+ }
+
+ if (!empty($_SESSION['update_manager_update_projects'])) {
+ // Make sure the Updater registry is loaded.
+ drupal_get_updaters();
+
+ $updates = array();
+ $directory = _update_manager_extract_directory();
+
+ $projects = $_SESSION['update_manager_update_projects'];
+ unset($_SESSION['update_manager_update_projects']);
+
+ foreach ($projects as $project => $url) {
+ $project_location = $directory . '/' . $project;
+ $updater = Updater::factory($project_location);
+ $project_real_location = drupal_realpath($project_location);
+ $updates[] = array(
+ 'project' => $project,
+ 'updater_name' => get_class($updater),
+ 'local_url' => $project_real_location,
+ );
+ }
+
+ // If the owner of the last directory we extracted is the same as the
+ // owner of our configuration directory (e.g. sites/default) where we're
+ // trying to install the code, there's no need to prompt for FTP/SSH
+ // credentials. Instead, we instantiate a FileTransferLocal and invoke
+ // update_authorize_run_update() directly.
+ if (fileowner($project_real_location) == fileowner(conf_path())) {
+ module_load_include('inc', 'update', 'update.authorize');
+ $filetransfer = new FileTransferLocal(DRUPAL_ROOT);
+ update_authorize_run_update($filetransfer, $updates);
+ }
+ // Otherwise, go through the regular workflow to prompt for FTP/SSH
+ // credentials and invoke update_authorize_run_update() indirectly with
+ // whatever FileTransfer object authorize.php creates for us.
+ else {
+ system_authorized_init('update_authorize_run_update', drupal_get_path('module', 'update') . '/update.authorize.inc', array($updates), t('Update manager'));
+ $form_state['redirect'] = system_authorized_get_url();
+ }
+ }
+}
+
+/**
+ * @} End of "defgroup update_manager_update".
+ */
+
+/**
+ * @defgroup update_manager_install Update manager: install
+ * @{
+ * Update manager for installing new code.
+ *
+ * Provides a user interface to install new code.
+ */
+
+/**
+ * Build the form for the update manager page to install new projects.
+ *
+ * This presents a place to enter a URL or upload an archive file to use to
+ * install a new module or theme.
+ *
+ * @param $form
+ * @param $form_state
+ * @param $context
+ * String representing the context from which we're trying to install, can
+ * be: 'module', 'theme' or 'report'.
+ * @return
+ * The form array for selecting which project to install.
+ */
+function update_manager_install_form($form, &$form_state, $context) {
+ if (!_update_manager_check_backends($form, 'install')) {
+ return $form;
+ }
+
+ $form['help_text'] = array(
+ '#prefix' => '<p>',
+ '#markup' => t('You can find <a href="@module_url">modules</a> and <a href="@theme_url">themes</a> on <a href="@drupal_org_url">drupal.org</a>. The following file extensions are supported: %extensions.', array(
+ '@module_url' => 'http://drupal.org/project/modules',
+ '@theme_url' => 'http://drupal.org/project/themes',
+ '@drupal_org_url' => 'http://drupal.org',
+ '%extensions' => archiver_get_extensions(),
+ )),
+ '#suffix' => '</p>',
+ );
+
+ $form['project_url'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Install from a URL'),
+ '#description' => t('For example: %url', array('%url' => 'http://ftp.drupal.org/files/projects/name.tar.gz')),
+ );
+
+ $form['information'] = array(
+ '#prefix' => '<strong>',
+ '#markup' => t('Or'),
+ '#suffix' => '</strong>',
+ );
+
+ $form['project_upload'] = array(
+ '#type' => 'file',
+ '#title' => t('Upload a module or theme archive to install'),
+ '#description' => t('For example: %filename from your local computer', array('%filename' => 'name.tar.gz')),
+ );
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Install'),
+ );
+
+ return $form;
+}
+
+/**
+ * Checks for file transfer backends and prepares a form fragment about them.
+ *
+ * @param array $form
+ * Reference to the form array we're building.
+ * @param string $operation
+ * The Update manager operation we're in the middle of. Can be either
+ * 'update' or 'install'. Use to provide operation-specific interface text.
+ *
+ * @return
+ * TRUE if the Update manager should continue to the next step in the
+ * workflow, or FALSE if we've hit a fatal configuration and must halt the
+ * workflow.
+ */
+function _update_manager_check_backends(&$form, $operation) {
+ // If file transfers will be performed locally, we do not need to display any
+ // warnings or notices to the user and should automatically continue the
+ // workflow, since we won't be using a FileTransfer backend that requires
+ // user input or a specific server configuration.
+ if (update_manager_local_transfers_allowed()) {
+ return TRUE;
+ }
+
+ // Otherwise, show the available backends.
+ $form['available_backends'] = array(
+ '#prefix' => '<p>',
+ '#suffix' => '</p>',
+ );
+
+ $available_backends = drupal_get_filetransfer_info();
+ if (empty($available_backends)) {
+ if ($operation == 'update') {
+ $form['available_backends']['#markup'] = t('Your server does not support updating modules and themes from this interface. Instead, update modules and themes by uploading the new versions directly to the server, as described in the <a href="@handbook_url">handbook</a>.', array('@handbook_url' => 'http://drupal.org/getting-started/install-contrib'));
+ }
+ else {
+ $form['available_backends']['#markup'] = t('Your server does not support installing modules and themes from this interface. Instead, install modules and themes by uploading them directly to the server, as described in the <a href="@handbook_url">handbook</a>.', array('@handbook_url' => 'http://drupal.org/getting-started/install-contrib'));
+ }
+ return FALSE;
+ }
+
+ $backend_names = array();
+ foreach ($available_backends as $backend) {
+ $backend_names[] = $backend['title'];
+ }
+ if ($operation == 'update') {
+ $form['available_backends']['#markup'] = format_plural(
+ count($available_backends),
+ 'Updating modules and themes requires <strong>@backends access</strong> to your server. See the <a href="@handbook_url">handbook</a> for other update methods.',
+ 'Updating modules and themes requires access to your server via one of the following methods: <strong>@backends</strong>. See the <a href="@handbook_url">handbook</a> for other update methods.',
+ array(
+ '@backends' => implode(', ', $backend_names),
+ '@handbook_url' => 'http://drupal.org/getting-started/install-contrib',
+ ));
+ }
+ else {
+ $form['available_backends']['#markup'] = format_plural(
+ count($available_backends),
+ 'Installing modules and themes requires <strong>@backends access</strong> to your server. See the <a href="@handbook_url">handbook</a> for other installation methods.',
+ 'Installing modules and themes requires access to your server via one of the following methods: <strong>@backends</strong>. See the <a href="@handbook_url">handbook</a> for other installation methods.',
+ array(
+ '@backends' => implode(', ', $backend_names),
+ '@handbook_url' => 'http://drupal.org/getting-started/install-contrib',
+ ));
+ }
+ return TRUE;
+}
+
+/**
+ * Validate the form for installing a new project via the update manager.
+ */
+function update_manager_install_form_validate($form, &$form_state) {
+ if (!($form_state['values']['project_url'] XOR !empty($_FILES['files']['name']['project_upload']))) {
+ form_set_error('project_url', t('You must either provide a URL or upload an archive file to install.'));
+ }
+
+ if ($form_state['values']['project_url']) {
+ if (!valid_url($form_state['values']['project_url'], TRUE)) {
+ form_set_error('project_url', t('The provided URL is invalid.'));
+ }
+ }
+}
+
+/**
+ * Handle form submission when installing new projects via the update manager.
+ *
+ * Either downloads the file specified in the URL to a temporary cache, or
+ * uploads the file attached to the form, then attempts to extract the archive
+ * into a temporary location and verify it. Instantiate the appropriate
+ * Updater class for this project and make sure it is not already installed in
+ * the live webroot. If everything is successful, setup an operation to run
+ * via authorize.php which will copy the extracted files from the temporary
+ * location into the live site.
+ *
+ * @see update_authorize_run_install()
+ * @see system_authorized_init()
+ * @see system_authorized_get_url()
+ */
+function update_manager_install_form_submit($form, &$form_state) {
+ if ($form_state['values']['project_url']) {
+ $field = 'project_url';
+ $local_cache = update_manager_file_get($form_state['values']['project_url']);
+ if (!$local_cache) {
+ form_set_error($field, t('Unable to retrieve Drupal project from %url.', array('%url' => $form_state['values']['project_url'])));
+ return;
+ }
+ }
+ elseif ($_FILES['files']['name']['project_upload']) {
+ $validators = array('file_validate_extensions' => array(archiver_get_extensions()));
+ $field = 'project_upload';
+ if (!($finfo = file_save_upload($field, $validators, NULL, FILE_EXISTS_REPLACE))) {
+ // Failed to upload the file. file_save_upload() calls form_set_error() on
+ // failure.
+ return;
+ }
+ $local_cache = $finfo->uri;
+ }
+
+ $directory = _update_manager_extract_directory();
+ try {
+ $archive = update_manager_archive_extract($local_cache, $directory);
+ }
+ catch (Exception $e) {
+ form_set_error($field, $e->getMessage());
+ return;
+ }
+
+ $files = $archive->listContents();
+ if (!$files) {
+ form_set_error($field, t('Provided archive contains no files.'));
+ return;
+ }
+
+ // Unfortunately, we can only use the directory name to determine the project
+ // name. Some archivers list the first file as the directory (i.e., MODULE/)
+ // and others list an actual file (i.e., MODULE/README.TXT).
+ $project = strtok($files[0], '/\\');
+
+ $archive_errors = update_manager_archive_verify($project, $local_cache, $directory);
+ if (!empty($archive_errors)) {
+ form_set_error($field, array_shift($archive_errors));
+ // @todo: Fix me in D8: We need a way to set multiple errors on the same
+ // form element and have all of them appear!
+ if (!empty($archive_errors)) {
+ foreach ($archive_errors as $error) {
+ drupal_set_message($error, 'error');
+ }
+ }
+ return;
+ }
+
+ // Make sure the Updater registry is loaded.
+ drupal_get_updaters();
+
+ $project_location = $directory . '/' . $project;
+ try {
+ $updater = Updater::factory($project_location);
+ }
+ catch (Exception $e) {
+ form_set_error($field, $e->getMessage());
+ return;
+ }
+
+ try {
+ $project_title = Updater::getProjectTitle($project_location);
+ }
+ catch (Exception $e) {
+ form_set_error($field, $e->getMessage());
+ return;
+ }
+
+ if (!$project_title) {
+ form_set_error($field, t('Unable to determine %project name.', array('%project' => $project)));
+ }
+
+ if ($updater->isInstalled()) {
+ form_set_error($field, t('%project is already installed.', array('%project' => $project_title)));
+ return;
+ }
+
+ $project_real_location = drupal_realpath($project_location);
+ $arguments = array(
+ 'project' => $project,
+ 'updater_name' => get_class($updater),
+ 'local_url' => $project_real_location,
+ );
+
+ // If the owner of the directory we extracted is the same as the
+ // owner of our configuration directory (e.g. sites/default) where we're
+ // trying to install the code, there's no need to prompt for FTP/SSH
+ // credentials. Instead, we instantiate a FileTransferLocal and invoke
+ // update_authorize_run_install() directly.
+ if (fileowner($project_real_location) == fileowner(conf_path())) {
+ module_load_include('inc', 'update', 'update.authorize');
+ $filetransfer = new FileTransferLocal(DRUPAL_ROOT);
+ call_user_func_array('update_authorize_run_install', array_merge(array($filetransfer), $arguments));
+ }
+ // Otherwise, go through the regular workflow to prompt for FTP/SSH
+ // credentials and invoke update_authorize_run_install() indirectly with
+ // whatever FileTransfer object authorize.php creates for us.
+ else {
+ system_authorized_init('update_authorize_run_install', drupal_get_path('module', 'update') . '/update.authorize.inc', $arguments, t('Update manager'));
+ $form_state['redirect'] = system_authorized_get_url();
+ }
+}
+
+/**
+ * @} End of "defgroup update_manager_install".
+ */
+
+/**
+ * @defgroup update_manager_file Update manager: file management
+ * @{
+ * Update manager file management functions.
+ *
+ * These functions are used by the update manager to copy, extract
+ * and verify archive files.
+ */
+
+/**
+ * Unpack a downloaded archive file.
+ *
+ * @param string $project
+ * The short name of the project to download.
+ * @param string $file
+ * The filename of the archive you wish to extract.
+ * @param string $directory
+ * The directory you wish to extract the archive into.
+ * @return Archiver
+ * The Archiver object used to extract the archive.
+ * @throws Exception on failure.
+ */
+function update_manager_archive_extract($file, $directory) {
+ $archiver = archiver_get_archiver($file);
+ if (!$archiver) {
+ throw new Exception(t('Cannot extract %file, not a valid archive.', array ('%file' => $file)));
+ }
+
+ // Remove the directory if it exists, otherwise it might contain a mixture of
+ // old files mixed with the new files (e.g. in cases where files were removed
+ // from a later release).
+ $files = $archiver->listContents();
+
+ // Unfortunately, we can only use the directory name to determine the project
+ // name. Some archivers list the first file as the directory (i.e., MODULE/)
+ // and others list an actual file (i.e., MODULE/README.TXT).
+ $project = strtok($files[0], '/\\');
+
+ $extract_location = $directory . '/' . $project;
+ if (file_exists($extract_location)) {
+ file_unmanaged_delete_recursive($extract_location);
+ }
+
+ $archiver->extract($directory);
+ return $archiver;
+}
+
+/**
+ * Verify an archive after it has been downloaded and extracted.
+ *
+ * This function is responsible for invoking hook_verify_update_archive().
+ *
+ * @param string $project
+ * The short name of the project to download.
+ * @param string $archive_file
+ * The filename of the unextracted archive.
+ * @param string $directory
+ * The directory that the archive was extracted into.
+ *
+ * @return array
+ * An array of error messages to display if the archive was invalid. If
+ * there are no errors, it will be an empty array.
+ *
+ */
+function update_manager_archive_verify($project, $archive_file, $directory) {
+ return module_invoke_all('verify_update_archive', $project, $archive_file, $directory);
+}
+
+/**
+ * Copies a file from $url to the temporary directory for updates.
+ *
+ * If the file has already been downloaded, returns the the local path.
+ *
+ * @param $url
+ * The URL of the file on the server.
+ *
+ * @return string
+ * Path to local file.
+ */
+function update_manager_file_get($url) {
+ $parsed_url = parse_url($url);
+ $remote_schemes = array('http', 'https', 'ftp', 'ftps', 'smb', 'nfs');
+ if (!in_array($parsed_url['scheme'], $remote_schemes)) {
+ // This is a local file, just return the path.
+ return drupal_realpath($url);
+ }
+
+ // Check the cache and download the file if needed.
+ $cache_directory = _update_manager_cache_directory();
+ $local = $cache_directory . '/' . basename($parsed_url['path']);
+
+ if (!file_exists($local) || update_delete_file_if_stale($local)) {
+ return system_retrieve_file($url, $local, FALSE, FILE_EXISTS_REPLACE);
+ }
+ else {
+ return $local;
+ }
+}
+
+/**
+ * Batch operation: download, unpack, and verify a project.
+ *
+ * This function assumes that the provided URL points to a file archive of
+ * some sort. The URL can have any scheme that we have a file stream wrapper
+ * to support. The file is downloaded to a local cache.
+ *
+ * @param string $project
+ * The short name of the project to download.
+ * @param string $url
+ * The URL to download a specific project release archive file.
+ * @param array $context
+ * Reference to an array used for BatchAPI storage.
+ *
+ * @see update_manager_download_page()
+ */
+function update_manager_batch_project_get($project, $url, &$context) {
+ // This is here to show the user that we are in the process of downloading.
+ if (!isset($context['sandbox']['started'])) {
+ $context['sandbox']['started'] = TRUE;
+ $context['message'] = t('Downloading %project', array('%project' => $project));
+ $context['finished'] = 0;
+ return;
+ }
+
+ // Actually try to download the file.
+ if (!($local_cache = update_manager_file_get($url))) {
+ $context['results']['errors'][$project] = t('Failed to download %project from %url', array('%project' => $project, '%url' => $url));
+ return;
+ }
+
+ // Extract it.
+ $extract_directory = _update_manager_extract_directory();
+ try {
+ update_manager_archive_extract($local_cache, $extract_directory);
+ }
+ catch (Exception $e) {
+ $context['results']['errors'][$project] = $e->getMessage();
+ return;
+ }
+
+ // Verify it.
+ $archive_errors = update_manager_archive_verify($project, $local_cache, $extract_directory);
+ if (!empty($archive_errors)) {
+ // We just need to make sure our array keys don't collide, so use the
+ // numeric keys from the $archive_errors array.
+ foreach ($archive_errors as $key => $error) {
+ $context['results']['errors']["$project-$key"] = $error;
+ }
+ return;
+ }
+
+ // Yay, success.
+ $context['results']['projects'][$project] = $url;
+ $context['finished'] = 1;
+}
+
+/**
+ * Determines if file transfers will be performed locally.
+ *
+ * If the server is configured such that webserver-created files have the same
+ * owner as the configuration directory (e.g. sites/default) where new code
+ * will eventually be installed, the Update manager can transfer files entirely
+ * locally, without changing their ownership (in other words, without prompting
+ * the user for FTP, SSH or other credentials).
+ *
+ * This server configuration is an inherent security weakness because it allows
+ * a malicious webserver process to append arbitrary PHP code and then execute
+ * it. However, it is supported here because it is a common configuration on
+ * shared hosting, and there is nothing Drupal can do to prevent it.
+ *
+ * @return
+ * TRUE if local file transfers are allowed on this server, or FALSE if not.
+ *
+ * @see update_manager_update_ready_form_submit()
+ * @see update_manager_install_form_submit()
+ * @see install_check_requirements()
+ */
+function update_manager_local_transfers_allowed() {
+ // Compare the owner of a webserver-created temporary file to the owner of
+ // the configuration directory to determine if local transfers will be
+ // allowed.
+ $temporary_file = drupal_tempnam('temporary://', 'update_');
+ $local_transfers_allowed = fileowner($temporary_file) === fileowner(conf_path());
+
+ // Clean up. If this fails, we can ignore it (since this is just a temporary
+ // file anyway).
+ @drupal_unlink($temporary_file);
+
+ return $local_transfers_allowed;
+}
+
+/**
+ * @} End of "defgroup update_manager_file".
+ */
diff --git a/core/modules/update/update.module b/core/modules/update/update.module
new file mode 100644
index 000000000000..6da47c057062
--- /dev/null
+++ b/core/modules/update/update.module
@@ -0,0 +1,958 @@
+<?php
+
+/**
+ * @file
+ * The "Update status" module checks for available updates of Drupal core and
+ * any installed contributed modules and themes. It warns site administrators
+ * if newer releases are available via the system status report
+ * (admin/reports/status), the module and theme pages, and optionally via email.
+ */
+
+/**
+ * URL to check for updates, if a given project doesn't define its own.
+ */
+define('UPDATE_DEFAULT_URL', 'http://updates.drupal.org/release-history');
+
+// These are internally used constants for this code, do not modify.
+
+/**
+ * Project is missing security update(s).
+ */
+define('UPDATE_NOT_SECURE', 1);
+
+/**
+ * Current release has been unpublished and is no longer available.
+ */
+define('UPDATE_REVOKED', 2);
+
+/**
+ * Current release is no longer supported by the project maintainer.
+ */
+define('UPDATE_NOT_SUPPORTED', 3);
+
+/**
+ * Project has a new release available, but it is not a security release.
+ */
+define('UPDATE_NOT_CURRENT', 4);
+
+/**
+ * Project is up to date.
+ */
+define('UPDATE_CURRENT', 5);
+
+/**
+ * Project's status cannot be checked.
+ */
+define('UPDATE_NOT_CHECKED', -1);
+
+/**
+ * No available update data was found for project.
+ */
+define('UPDATE_UNKNOWN', -2);
+
+/**
+ * There was a failure fetching available update data for this project.
+ */
+define('UPDATE_NOT_FETCHED', -3);
+
+/**
+ * We need to (re)fetch available update data for this project.
+ */
+define('UPDATE_FETCH_PENDING', -4);
+
+/**
+ * Maximum number of attempts to fetch available update data from a given host.
+ */
+define('UPDATE_MAX_FETCH_ATTEMPTS', 2);
+
+/**
+ * Maximum number of seconds to try fetching available update data at a time.
+ */
+define('UPDATE_MAX_FETCH_TIME', 5);
+
+/**
+ * Implements hook_help().
+ */
+function update_help($path, $arg) {
+ switch ($path) {
+ case 'admin/reports/updates':
+ return '<p>' . t('Here you can find information about available updates for your installed modules and themes. Note that each module or theme is part of a "project", which may or may not have the same name, and might include multiple modules or themes within it.') . '</p>';
+
+ case 'admin/help#update':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t("The Update manager module periodically checks for new versions of your site's software (including contributed modules and themes), and alerts administrators to available updates. In order to provide update information, anonymous usage statistics are sent to Drupal.org. If desired, you may disable the Update manager module from the <a href='@modules'>Module administration page</a>. For more information, see the online handbook entry for <a href='@update'>Update manager module</a>.", array('@update' => 'http://drupal.org/handbook/modules/update', '@modules' => url('admin/modules'))) . '</p>';
+ // Only explain the Update manager if it has not been disabled.
+ if (update_manager_access()) {
+ $output .= '<p>' . t('The Update manager also allows administrators to update and install modules and themes through the administration interface.') . '</p>';
+ }
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Checking for available updates') . '</dt>';
+ $output .= '<dd>' . t('A report of <a href="@update-report">available updates</a> will alert you when new releases are available for download. You may configure options for the frequency for checking updates (which are performed during <a href="@cron">cron</a> runs) and e-mail notifications at the <a href="@update-settings">Update manager settings</a> page.', array('@update-report' => url('admin/reports/updates'), '@cron' => 'http://drupal.org/cron', '@update-settings' => url('admin/reports/updates/settings'))) . '</dd>';
+ // Only explain the Update manager if it has not been disabled.
+ if (update_manager_access()) {
+ $output .= '<dt>' . t('Performing updates through the user interface') . '</dt>';
+ $output .= '<dd>' . t('The Update manager module allows administrators to perform updates directly through the administration interface. At the top of the <a href="@modules_page">modules</a> and <a href="@themes_page">themes</a> pages you will see a link to update to new releases. This will direct you to the <a href="@update-page">update page</a> where you see a listing of all the missing updates and confirm which ones you want to upgrade. From there, you are prompted for your FTP/SSH password, which then transfers the files into your Drupal installation, overwriting your old files. More detailed instructions can be found in the <a href="@update">online handbook</a>.', array('@modules_page' => url('admin/modules'), '@themes_page' => url('admin/appearance'), '@update-page' => url('admin/reports/updates/update'), '@update' => 'http://drupal.org/handbook/modules/update')) . '</dd>';
+ $output .= '<dt>' . t('Installing new modules and themes through the user interface') . '</dt>';
+ $output .= '<dd>' . t('You can also install new modules and themes in the same fashion, through the <a href="@install">install page</a>, or by clicking the <em>Install new module/theme</em> link at the top of the <a href="@modules_page">modules</a> and <a href="@themes_page">themes</a> pages. In this case, you are prompted to provide either the URL to the download, or to upload a packaged release file from your local computer.', array('@modules_page' => url('admin/modules'), '@themes_page' => url('admin/appearance'), '@install' => url('admin/reports/updates/install'))) . '</dd>';
+ }
+ $output .= '</dl>';
+ return $output;
+ }
+}
+
+/**
+ * Implements hook_init().
+ */
+function update_init() {
+ if (arg(0) == 'admin' && user_access('administer site configuration')) {
+ switch ($_GET['q']) {
+ // These pages don't need additional nagging.
+ case 'admin/appearance/update':
+ case 'admin/appearance/install':
+ case 'admin/modules/update':
+ case 'admin/modules/install':
+ case 'admin/reports/updates':
+ case 'admin/reports/updates/update':
+ case 'admin/reports/updates/install':
+ case 'admin/reports/updates/settings':
+ case 'admin/reports/status':
+ case 'admin/update/ready':
+ return;
+
+ // If we are on the appearance or modules list, display a detailed report
+ // of the update status.
+ case 'admin/appearance':
+ case 'admin/modules':
+ $verbose = TRUE;
+ break;
+
+ }
+ module_load_install('update');
+ $status = update_requirements('runtime');
+ foreach (array('core', 'contrib') as $report_type) {
+ $type = 'update_' . $report_type;
+ if (!empty($verbose)) {
+ if (isset($status[$type]['severity'])) {
+ if ($status[$type]['severity'] == REQUIREMENT_ERROR) {
+ drupal_set_message($status[$type]['description'], 'error');
+ }
+ elseif ($status[$type]['severity'] == REQUIREMENT_WARNING) {
+ drupal_set_message($status[$type]['description'], 'warning');
+ }
+ }
+ }
+ // Otherwise, if we're on *any* admin page and there's a security
+ // update missing, print an error message about it.
+ else {
+ if (isset($status[$type])
+ && isset($status[$type]['reason'])
+ && $status[$type]['reason'] === UPDATE_NOT_SECURE) {
+ drupal_set_message($status[$type]['description'], 'error');
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_menu().
+ */
+function update_menu() {
+ $items = array();
+
+ $items['admin/reports/updates'] = array(
+ 'title' => 'Available updates',
+ 'description' => 'Get a status report about available updates for your installed modules and themes.',
+ 'page callback' => 'update_status',
+ 'access arguments' => array('administer site configuration'),
+ 'weight' => -50,
+ 'file' => 'update.report.inc',
+ );
+ $items['admin/reports/updates/list'] = array(
+ 'title' => 'List',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+ $items['admin/reports/updates/settings'] = array(
+ 'title' => 'Settings',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('update_settings'),
+ 'access arguments' => array('administer site configuration'),
+ 'file' => 'update.settings.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => 50,
+ );
+ $items['admin/reports/updates/check'] = array(
+ 'title' => 'Manual update check',
+ 'page callback' => 'update_manual_status',
+ 'access arguments' => array('administer site configuration'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'update.fetch.inc',
+ );
+
+ // We want action links for updating projects at a few different locations:
+ // both the module and theme administration pages, and on the available
+ // updates report itself. The menu items will be mostly identical, except the
+ // paths and titles, so we just define them in a loop. We pass in a string
+ // indicating what context we're entering the action from, so that can
+ // customize the appearance as needed.
+ $paths = array(
+ 'report' => 'admin/reports/updates',
+ 'module' => 'admin/modules',
+ 'theme' => 'admin/appearance',
+ );
+ foreach ($paths as $context => $path) {
+ $items[$path . '/install'] = array(
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('update_manager_install_form', $context),
+ 'access callback' => 'update_manager_access',
+ 'access arguments' => array(),
+ 'weight' => 25,
+ 'type' => MENU_LOCAL_ACTION,
+ 'file' => 'update.manager.inc',
+ );
+ $items[$path . '/update'] = array(
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('update_manager_update_form', $context),
+ 'access callback' => 'update_manager_access',
+ 'access arguments' => array(),
+ 'weight' => 10,
+ 'title' => 'Update',
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'update.manager.inc',
+ );
+ }
+ // Customize the titles of the action links depending on where they appear.
+ // We use += array() to let the translation extractor find these menu titles.
+ $items['admin/reports/updates/install'] += array('title' => 'Install new module or theme');
+ $items['admin/modules/install'] += array('title' => 'Install new module');
+ $items['admin/appearance/install'] += array('title' => 'Install new theme');
+
+ // Menu callback used for the confirmation page after all the releases
+ // have been downloaded, asking you to backup before installing updates.
+ $items['admin/update/ready'] = array(
+ 'title' => 'Ready to update',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('update_manager_update_ready_form'),
+ 'access callback' => 'update_manager_access',
+ 'access arguments' => array(),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'update.manager.inc',
+ );
+
+ return $items;
+}
+
+/**
+ * Determine if the current user can access the updater menu items.
+ *
+ * This is used as a menu system access callback. It both enforces the
+ * 'administer software updates' permission and the global killswitch for the
+ * authorize.php script.
+ *
+ * @see update_menu()
+ */
+function update_manager_access() {
+ return variable_get('allow_authorize_operations', TRUE) && user_access('administer software updates');
+}
+
+/**
+ * Implements hook_theme().
+ */
+function update_theme() {
+ return array(
+ 'update_manager_update_form' => array(
+ 'render element' => 'form',
+ 'file' => 'update.manager.inc',
+ ),
+ 'update_last_check' => array(
+ 'variables' => array('last' => NULL),
+ ),
+ 'update_report' => array(
+ 'variables' => array('data' => NULL),
+ ),
+ 'update_version' => array(
+ 'variables' => array('version' => NULL, 'tag' => NULL, 'class' => array()),
+ ),
+ 'update_status_label' => array(
+ 'variables' => array('status' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_cron().
+ */
+function update_cron() {
+ $frequency = variable_get('update_check_frequency', 1);
+ $interval = 60 * 60 * 24 * $frequency;
+ if ((REQUEST_TIME - variable_get('update_last_check', 0)) > $interval) {
+ // If the configured update interval has elapsed, we want to invalidate
+ // the cached data for all projects, attempt to re-fetch, and trigger any
+ // configured notifications about the new status.
+ update_refresh();
+ update_fetch_data();
+ _update_cron_notify();
+ }
+ else {
+ // Otherwise, see if any individual projects are now stale or still
+ // missing data, and if so, try to fetch the data.
+ update_get_available(TRUE);
+ }
+
+ // Clear garbage from disk.
+ update_clear_update_disk_cache();
+}
+
+/**
+ * Implements hook_themes_enabled().
+ *
+ * If themes are enabled, we invalidate the cache of available updates.
+ */
+function update_themes_enabled($themes) {
+ // Clear all update module caches.
+ _update_cache_clear();
+}
+
+/**
+ * Implements hook_themes_disabled().
+ *
+ * If themes are disabled, we invalidate the cache of available updates.
+ */
+function update_themes_disabled($themes) {
+ // Clear all update module caches.
+ _update_cache_clear();
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Adds a submit handler to the system modules form, so that if a site admin
+ * saves the form, we invalidate the cache of available updates.
+ *
+ * @see _update_cache_clear()
+ */
+function update_form_system_modules_alter(&$form, $form_state) {
+ $form['#submit'][] = 'update_cache_clear_submit';
+}
+
+/**
+ * Helper function for use as a form submit callback.
+ */
+function update_cache_clear_submit($form, &$form_state) {
+ // Clear all update module caches.
+ _update_cache_clear();
+}
+
+/**
+ * Prints a warning message when there is no data about available updates.
+ */
+function _update_no_data() {
+ $destination = drupal_get_destination();
+ return t('No update information available. <a href="@run_cron">Run cron</a> or <a href="@check_manually">check manually</a>.', array(
+ '@run_cron' => url('admin/reports/status/run-cron', array('query' => $destination)),
+ '@check_manually' => url('admin/reports/updates/check', array('query' => $destination)),
+ ));
+}
+
+/**
+ * Internal helper to try to get the update information from the cache
+ * if possible, and to refresh the cache when necessary.
+ *
+ * In addition to checking the cache lifetime, this function also ensures that
+ * there are no .info files for enabled modules or themes that have a newer
+ * modification timestamp than the last time we checked for available update
+ * data. If any .info file was modified, it almost certainly means a new
+ * version of something was installed. Without fresh available update data,
+ * the logic in update_calculate_project_data() will be wrong and produce
+ * confusing, bogus results.
+ *
+ * @param $refresh
+ * Boolean to indicate if this method should refresh the cache automatically
+ * if there's no data.
+ *
+ * @see update_refresh()
+ * @see update_get_projects()
+ */
+function update_get_available($refresh = FALSE) {
+ module_load_include('inc', 'update', 'update.compare');
+ $needs_refresh = FALSE;
+
+ // Grab whatever data we currently have cached in the DB.
+ $available = _update_get_cached_available_releases();
+ $num_avail = count($available);
+
+ $projects = update_get_projects();
+ foreach ($projects as $key => $project) {
+ // If there's no data at all, we clearly need to fetch some.
+ if (empty($available[$key])) {
+ update_create_fetch_task($project);
+ $needs_refresh = TRUE;
+ continue;
+ }
+
+ // See if the .info file is newer than the last time we checked for data,
+ // and if so, mark this project's data as needing to be re-fetched. Any
+ // time an admin upgrades their local installation, the .info file will
+ // be changed, so this is the only way we can be sure we're not showing
+ // bogus information right after they upgrade.
+ if ($project['info']['_info_file_ctime'] > $available[$key]['last_fetch']) {
+ $available[$key]['fetch_status'] = UPDATE_FETCH_PENDING;
+ }
+
+ // If we have project data but no release data, we need to fetch. This
+ // can be triggered when we fail to contact a release history server.
+ if (empty($available[$key]['releases'])) {
+ $available[$key]['fetch_status'] = UPDATE_FETCH_PENDING;
+ }
+
+ // If we think this project needs to fetch, actually create the task now
+ // and remember that we think we're missing some data.
+ if (!empty($available[$key]['fetch_status']) && $available[$key]['fetch_status'] == UPDATE_FETCH_PENDING) {
+ update_create_fetch_task($project);
+ $needs_refresh = TRUE;
+ }
+ }
+
+ if ($needs_refresh && $refresh) {
+ // Attempt to drain the queue of fetch tasks.
+ update_fetch_data();
+ // After processing the queue, we've (hopefully) got better data, so pull
+ // the latest from the cache again and use that directly.
+ $available = _update_get_cached_available_releases();
+ }
+
+ return $available;
+}
+
+/**
+ * Wrapper to load the include file and then create a new fetch task.
+ *
+ * @see _update_create_fetch_task()
+ */
+function update_create_fetch_task($project) {
+ module_load_include('inc', 'update', 'update.fetch');
+ return _update_create_fetch_task($project);
+}
+
+/**
+ * Wrapper to load the include file and then refresh the release data.
+ *
+ * @see _update_refresh()
+ */
+function update_refresh() {
+ module_load_include('inc', 'update', 'update.fetch');
+ return _update_refresh();
+}
+
+/**
+ * Wrapper to load the include file and then attempt to fetch update data.
+ */
+function update_fetch_data() {
+ module_load_include('inc', 'update', 'update.fetch');
+ return _update_fetch_data();
+}
+
+/**
+ * Return all currently cached data about available releases for all projects.
+ *
+ * @return
+ * Array of data about available releases, keyed by project shortname.
+ */
+function _update_get_cached_available_releases() {
+ $data = array();
+ $cache_items = _update_get_cache_multiple('available_releases');
+ foreach ($cache_items as $cid => $cache) {
+ $cache->data['last_fetch'] = $cache->created;
+ if ($cache->expire < REQUEST_TIME) {
+ $cache->data['fetch_status'] = UPDATE_FETCH_PENDING;
+ }
+ // The project shortname is embedded in the cache ID, even if there's no
+ // data for this project in the DB at all, so use that for the indexes in
+ // the array.
+ $parts = explode('::', $cid, 2);
+ $data[$parts[1]] = $cache->data;
+ }
+ return $data;
+}
+
+/**
+ * Implements hook_mail().
+ *
+ * Constructs the email notification message when the site is out of date.
+ *
+ * @param $key
+ * Unique key to indicate what message to build, always 'status_notify'.
+ * @param $message
+ * Reference to the message array being built.
+ * @param $params
+ * Array of parameters to indicate what kind of text to include in the
+ * message body. This is a keyed array of message type ('core' or 'contrib')
+ * as the keys, and the status reason constant (UPDATE_NOT_SECURE, etc) for
+ * the values.
+ *
+ * @see drupal_mail()
+ * @see _update_cron_notify()
+ * @see _update_message_text()
+ */
+function update_mail($key, &$message, $params) {
+ $language = $message['language'];
+ $langcode = $language->language;
+ $message['subject'] .= t('New release(s) available for !site_name', array('!site_name' => variable_get('site_name', 'Drupal')), array('langcode' => $langcode));
+ foreach ($params as $msg_type => $msg_reason) {
+ $message['body'][] = _update_message_text($msg_type, $msg_reason, FALSE, $language);
+ }
+ $message['body'][] = t('See the available updates page for more information:', array(), array('langcode' => $langcode)) . "\n" . url('admin/reports/updates', array('absolute' => TRUE, 'language' => $language));
+ if (update_manager_access()) {
+ $message['body'][] = t('You can automatically install your missing updates using the Update manager:', array(), array('langcode' => $langcode)) . "\n" . url('admin/reports/updates/update', array('absolute' => TRUE, 'language' => $language));
+ }
+ $settings_url = url('admin/reports/updates/settings', array('absolute' => TRUE));
+ if (variable_get('update_notification_threshold', 'all') == 'all') {
+ $message['body'][] = t('Your site is currently configured to send these emails when any updates are available. To get notified only for security updates, !url.', array('!url' => $settings_url));
+ }
+ else {
+ $message['body'][] = t('Your site is currently configured to send these emails only when security updates are available. To get notified for any available updates, !url.', array('!url' => $settings_url));
+ }
+}
+
+/**
+ * Helper function to return the appropriate message text when the site is out
+ * of date or missing a security update.
+ *
+ * These error messages are shared by both update_requirements() for the
+ * site-wide status report at admin/reports/status and in the body of the
+ * notification emails generated by update_cron().
+ *
+ * @param $msg_type
+ * String to indicate what kind of message to generate. Can be either
+ * 'core' or 'contrib'.
+ * @param $msg_reason
+ * Integer constant specifying why message is generated.
+ * @param $report_link
+ * Boolean that controls if a link to the updates report should be added.
+ * @param $language
+ * An optional language object to use.
+ * @return
+ * The properly translated error message for the given key.
+ */
+function _update_message_text($msg_type, $msg_reason, $report_link = FALSE, $language = NULL) {
+ $langcode = isset($language) ? $language->language : NULL;
+ $text = '';
+ switch ($msg_reason) {
+ case UPDATE_NOT_SECURE:
+ if ($msg_type == 'core') {
+ $text = t('There is a security update available for your version of Drupal. To ensure the security of your server, you should update immediately!', array(), array('langcode' => $langcode));
+ }
+ else {
+ $text = t('There are security updates available for one or more of your modules or themes. To ensure the security of your server, you should update immediately!', array(), array('langcode' => $langcode));
+ }
+ break;
+
+ case UPDATE_REVOKED:
+ if ($msg_type == 'core') {
+ $text = t('Your version of Drupal has been revoked and is no longer available for download. Upgrading is strongly recommended!', array(), array('langcode' => $langcode));
+ }
+ else {
+ $text = t('The installed version of at least one of your modules or themes has been revoked and is no longer available for download. Upgrading or disabling is strongly recommended!', array(), array('langcode' => $langcode));
+ }
+ break;
+
+ case UPDATE_NOT_SUPPORTED:
+ if ($msg_type == 'core') {
+ $text = t('Your version of Drupal is no longer supported. Upgrading is strongly recommended!', array(), array('langcode' => $langcode));
+ }
+ else {
+ $text = t('The installed version of at least one of your modules or themes is no longer supported. Upgrading or disabling is strongly recommended. See the project homepage for more details.', array(), array('langcode' => $langcode));
+ }
+ break;
+
+ case UPDATE_NOT_CURRENT:
+ if ($msg_type == 'core') {
+ $text = t('There are updates available for your version of Drupal. To ensure the proper functioning of your site, you should update as soon as possible.', array(), array('langcode' => $langcode));
+ }
+ else {
+ $text = t('There are updates available for one or more of your modules or themes. To ensure the proper functioning of your site, you should update as soon as possible.', array(), array('langcode' => $langcode));
+ }
+ break;
+
+ case UPDATE_UNKNOWN:
+ case UPDATE_NOT_CHECKED:
+ case UPDATE_NOT_FETCHED:
+ case UPDATE_FETCH_PENDING:
+ if ($msg_type == 'core') {
+ $text = t('There was a problem checking <a href="@update-report">available updates</a> for Drupal.', array('@update-report' => url('admin/reports/updates')), array('langcode' => $langcode));
+ }
+ else {
+ $text = t('There was a problem checking <a href="@update-report">available updates</a> for your modules or themes.', array('@update-report' => url('admin/reports/updates')), array('langcode' => $langcode));
+ }
+ break;
+ }
+
+ if ($report_link) {
+ if (update_manager_access()) {
+ $text .= ' ' . t('See the <a href="@available_updates">available updates</a> page for more information and to install your missing updates.', array('@available_updates' => url('admin/reports/updates/update', array('language' => $language))), array('langcode' => $langcode));
+ }
+ else {
+ $text .= ' ' . t('See the <a href="@available_updates">available updates</a> page for more information.', array('@available_updates' => url('admin/reports/updates', array('language' => $language))), array('langcode' => $langcode));
+ }
+ }
+
+ return $text;
+}
+
+/**
+ * Private sort function to order projects based on their status.
+ *
+ * @see update_requirements()
+ * @see uasort()
+ */
+function _update_project_status_sort($a, $b) {
+ // The status constants are numerically in the right order, so we can
+ // usually subtract the two to compare in the order we want. However,
+ // negative status values should be treated as if they are huge, since we
+ // always want them at the bottom of the list.
+ $a_status = $a['status'] > 0 ? $a['status'] : (-10 * $a['status']);
+ $b_status = $b['status'] > 0 ? $b['status'] : (-10 * $b['status']);
+ return $a_status - $b_status;
+}
+
+/**
+ * Returns HTML for the last time we checked for update data.
+ *
+ * In addition to properly formating the given timestamp, this function also
+ * provides a "Check manually" link that refreshes the available update and
+ * redirects back to the same page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - 'last': The timestamp when the site last checked for available updates.
+ *
+ * @see theme_update_report()
+ * @see theme_update_available_updates_form()
+ *
+ * @ingroup themeable
+ */
+function theme_update_last_check($variables) {
+ $last = $variables['last'];
+ $output = '<div class="update checked">';
+ $output .= $last ? t('Last checked: @time ago', array('@time' => format_interval(REQUEST_TIME - $last))) : t('Last checked: never');
+ $output .= ' <span class="check-manually">(' . l(t('Check manually'), 'admin/reports/updates/check', array('query' => drupal_get_destination())) . ')</span>';
+ $output .= "</div>\n";
+ return $output;
+}
+
+/**
+ * Implements hook_verify_update_archive().
+ *
+ * First, we ensure that the archive isn't a copy of Drupal core, which the
+ * Update manager does not yet support. @see http://drupal.org/node/606592
+ *
+ * Then, we make sure that at least one module included in the archive file has
+ * an .info file which claims that the code is compatible with the current
+ * version of Drupal core.
+ *
+ * @see drupal_system_listing()
+ * @see _system_rebuild_module_data()
+ */
+function update_verify_update_archive($project, $archive_file, $directory) {
+ $errors = array();
+
+ // Make sure this isn't a tarball of Drupal core.
+ if (
+ file_exists("$directory/$project/index.php")
+ && file_exists("$directory/$project/core/update.php")
+ && file_exists("$directory/$project/core/includes/bootstrap.inc")
+ && file_exists("$directory/$project/core/modules/node/node.module")
+ && file_exists("$directory/$project/core/modules/system/system.module")
+ ) {
+ return array(
+ 'no-core' => t('Automatic updating of Drupal core is not supported. See the <a href="@upgrade-guide">upgrade guide</a> for information on how to update Drupal core manually.', array('@upgrade-guide' => 'http://drupal.org/upgrade')),
+ );
+ }
+
+ // Parse all the .info files and make sure at least one is compatible with
+ // this version of Drupal core. If one is compatible, then the project as a
+ // whole is considered compatible (since, for example, the project may ship
+ // with some out-of-date modules that are not necessary for its overall
+ // functionality).
+ $compatible_project = FALSE;
+ $incompatible = array();
+ $files = file_scan_directory("$directory/$project", '/^' . DRUPAL_PHP_FUNCTION_PATTERN . '\.info$/', array('key' => 'name', 'min_depth' => 0));
+ foreach ($files as $key => $file) {
+ // Get the .info file for the module or theme this file belongs to.
+ $info = drupal_parse_info_file($file->uri);
+
+ // If the module or theme is incompatible with Drupal core, set an error.
+ if (empty($info['core']) || $info['core'] != DRUPAL_CORE_COMPATIBILITY) {
+ $incompatible[] = !empty($info['name']) ? $info['name'] : t('Unknown');
+ }
+ else {
+ $compatible_project = TRUE;
+ break;
+ }
+ }
+
+ if (empty($files)) {
+ $errors[] = t('%archive_file does not contain any .info files.', array('%archive_file' => basename($archive_file)));
+ }
+ elseif (!$compatible_project) {
+ $errors[] = format_plural(
+ count($incompatible),
+ '%archive_file contains a version of %names that is not compatible with Drupal !version.',
+ '%archive_file contains versions of modules or themes that are not compatible with Drupal !version: %names',
+ array('!version' => DRUPAL_CORE_COMPATIBILITY, '%archive_file' => basename($archive_file), '%names' => implode(', ', $incompatible))
+ );
+ }
+
+ return $errors;
+}
+
+/**
+ * @defgroup update_status_cache Private update status cache system
+ * @{
+ * Functions to manage the update status cache.
+ *
+ * We specifically do NOT use the core cache API for saving the fetched data
+ * about available updates. It is vitally important that this cache is only
+ * cleared when we're populating it after successfully fetching new available
+ * update data. Usage of the core cache API results in all sorts of potential
+ * problems that would result in attempting to fetch available update data all
+ * the time, including if a site has a "minimum cache lifetime" (which is both
+ * a minimum and a maximum) defined, or if a site uses memcache or another
+ * plug-able cache system that assumes volatile caches.
+ *
+ * Update module still uses the {cache_update} table, but instead of using
+ * cache_set(), cache_get(), and cache_clear_all(), there are private helper
+ * functions that implement these same basic tasks but ensure that the cache
+ * is not prematurely cleared, and that the data is always stored in the
+ * database, even if memcache or another cache backend is in use.
+ */
+
+/**
+ * Store data in the private update status cache table.
+ *
+ * @param $cid
+ * The cache ID to save the data with.
+ * @param $data
+ * The data to store.
+ * @param $expire
+ * One of the following values:
+ * - CACHE_PERMANENT: Indicates that the item should never be removed except
+ * by explicitly using _update_cache_clear().
+ * - A Unix timestamp: Indicates that the item should be kept at least until
+ * the given time, after which it will be invalidated.
+ */
+function _update_cache_set($cid, $data, $expire) {
+ $fields = array(
+ 'created' => REQUEST_TIME,
+ 'expire' => $expire,
+ );
+ if (!is_string($data)) {
+ $fields['data'] = serialize($data);
+ $fields['serialized'] = 1;
+ }
+ else {
+ $fields['data'] = $data;
+ $fields['serialized'] = 0;
+ }
+ db_merge('cache_update')
+ ->key(array('cid' => $cid))
+ ->fields($fields)
+ ->execute();
+}
+
+/**
+ * Retrieve data from the private update status cache table.
+ *
+ * @param $cid
+ * The cache ID to retrieve.
+ * @return
+ * The data for the given cache ID, or NULL if the ID was not found.
+ */
+function _update_cache_get($cid) {
+ $cache = db_query("SELECT data, created, expire, serialized FROM {cache_update} WHERE cid = :cid", array(':cid' => $cid))->fetchObject();
+ if (isset($cache->data)) {
+ if ($cache->serialized) {
+ $cache->data = unserialize($cache->data);
+ }
+ }
+ return $cache;
+}
+
+/**
+ * Return an array of cache items with a given cache ID prefix.
+ *
+ * @return
+ * Associative array of cache items, keyed by cache ID.
+ */
+function _update_get_cache_multiple($cid_prefix) {
+ $data = array();
+ $result = db_select('cache_update')
+ ->fields('cache_update', array('cid', 'data', 'created', 'expire', 'serialized'))
+ ->condition('cache_update.cid', $cid_prefix . '::%', 'LIKE')
+ ->execute();
+ foreach ($result as $cache) {
+ if ($cache) {
+ if ($cache->serialized) {
+ $cache->data = unserialize($cache->data);
+ }
+ $data[$cache->cid] = $cache;
+ }
+ }
+ return $data;
+}
+
+/**
+ * Invalidates cached data relating to update status.
+ *
+ * @param $cid
+ * Optional cache ID of the record to clear from the private update module
+ * cache. If empty, all records will be cleared from the table.
+ * @param $wildcard
+ * If $wildcard is TRUE, cache IDs starting with $cid are deleted in
+ * addition to the exact cache ID specified by $cid.
+ */
+function _update_cache_clear($cid = NULL, $wildcard = FALSE) {
+ if (empty($cid)) {
+ db_truncate('cache_update')->execute();
+ }
+ else {
+ $query = db_delete('cache_update');
+ if ($wildcard) {
+ $query->condition('cid', $cid . '%', 'LIKE');
+ }
+ else {
+ $query->condition('cid', $cid);
+ }
+ $query->execute();
+ }
+}
+
+/**
+ * Implements hook_flush_caches().
+ *
+ * Called from update.php (among others) to flush the caches.
+ * Since we're running update.php, we are likely to install a new version of
+ * something, in which case, we want to check for available update data again.
+ * However, because we have our own caching system, we need to directly clear
+ * the database table ourselves at this point and return nothing, for example,
+ * on sites that use memcache where cache_clear_all() won't know how to purge
+ * this data.
+ *
+ * However, we only want to do this from update.php, since otherwise, we'd
+ * lose all the available update data on every cron run. So, we specifically
+ * check if the site is in MAINTENANCE_MODE == 'update' (which indicates
+ * update.php is running, not update module... alas for overloaded names).
+ */
+function update_flush_caches() {
+ if (defined('MAINTENANCE_MODE') && MAINTENANCE_MODE == 'update') {
+ _update_cache_clear();
+ }
+ return array();
+}
+
+/**
+ * @} End of "defgroup update_status_cache".
+ */
+
+/**
+ * Return a short unique identifier for this Drupal installation.
+ *
+ * @return
+ * An eight character string uniquely identifying this Drupal installation.
+ */
+function _update_manager_unique_identifier() {
+ $id = &drupal_static(__FUNCTION__, '');
+ if (empty($id)) {
+ $id = substr(hash('sha256', drupal_get_hash_salt()), 0, 8);
+ }
+ return $id;
+}
+
+/**
+ * Return the directory where update archive files should be extracted.
+ *
+ * @param $create
+ * If TRUE, attempt to create the directory if it does not already exist.
+ *
+ * @return
+ * The full path to the temporary directory where update file archives
+ * should be extracted.
+ */
+function _update_manager_extract_directory($create = TRUE) {
+ $directory = &drupal_static(__FUNCTION__, '');
+ if (empty($directory)) {
+ $directory = 'temporary://update-extraction-' . _update_manager_unique_identifier();
+ if ($create && !file_exists($directory)) {
+ mkdir($directory);
+ }
+ }
+ return $directory;
+}
+
+/**
+ * Return the directory where update archive files should be cached.
+ *
+ * @param $create
+ * If TRUE, attempt to create the directory if it does not already exist.
+ *
+ * @return
+ * The full path to the temporary directory where update file archives
+ * should be cached.
+ */
+function _update_manager_cache_directory($create = TRUE) {
+ $directory = &drupal_static(__FUNCTION__, '');
+ if (empty($directory)) {
+ $directory = 'temporary://update-cache-' . _update_manager_unique_identifier();
+ if ($create && !file_exists($directory)) {
+ mkdir($directory);
+ }
+ }
+ return $directory;
+}
+
+/**
+ * Clear the temporary files and directories based on file age from disk.
+ */
+function update_clear_update_disk_cache() {
+ // List of update module cache directories. Do not create the directories if
+ // they do not exist.
+ $directories = array(
+ _update_manager_cache_directory(FALSE),
+ _update_manager_extract_directory(FALSE),
+ );
+
+ // Search for files and directories in base folder only without recursion.
+ foreach ($directories as $directory) {
+ file_scan_directory($directory, '/.*/', array('callback' => 'update_delete_file_if_stale', 'recurse' => FALSE));
+ }
+}
+
+/**
+ * Delete stale files and directories from the Update manager disk cache.
+ *
+ * Files and directories older than 6 hours and development snapshots older
+ * than 5 minutes are considered stale. We only cache development snapshots
+ * for 5 minutes since otherwise updated snapshots might not be downloaded as
+ * expected.
+ *
+ * When checking file ages, we need to use the ctime, not the mtime
+ * (modification time) since many (all?) tar implementations go out of their
+ * way to set the mtime on the files they create to the timestamps recorded
+ * in the tarball. We want to see the last time the file was changed on disk,
+ * which is left alone by tar and correctly set to the time the archive file
+ * was unpacked.
+ *
+ * @param $path
+ * A string containing a file path or (streamwrapper) URI.
+ */
+function update_delete_file_if_stale($path) {
+ if (file_exists($path)) {
+ $filectime = filectime($path);
+ if (REQUEST_TIME - $filectime > DRUPAL_MAXIMUM_TEMP_FILE_AGE || (preg_match('/.*-dev\.(tar\.gz|zip)/i', $path) && REQUEST_TIME - $filectime > 300)) {
+ file_unmanaged_delete_recursive($path);
+ }
+ }
+}
diff --git a/core/modules/update/update.report.inc b/core/modules/update/update.report.inc
new file mode 100644
index 000000000000..02150e9b7178
--- /dev/null
+++ b/core/modules/update/update.report.inc
@@ -0,0 +1,324 @@
+<?php
+
+/**
+ * @file
+ * Code required only when rendering the available updates report.
+ */
+
+/**
+ * Menu callback. Generate a page about the update status of projects.
+ */
+function update_status() {
+ if ($available = update_get_available(TRUE)) {
+ module_load_include('inc', 'update', 'update.compare');
+ $data = update_calculate_project_data($available);
+ return theme('update_report', array('data' => $data));
+ }
+ else {
+ return theme('update_report', array('data' => _update_no_data()));
+ }
+}
+
+/**
+ * Returns HTML for the project status report.
+ *
+ * @param array $variables
+ * An associative array containing:
+ * - data: An array of data about each project's status.
+ *
+ * @ingroup themeable
+ */
+function theme_update_report($variables) {
+ $data = $variables['data'];
+
+ $last = variable_get('update_last_check', 0);
+ $output = theme('update_last_check', array('last' => $last));
+
+ if (!is_array($data)) {
+ $output .= '<p>' . $data . '</p>';
+ return $output;
+ }
+
+ $header = array();
+ $rows = array();
+
+ $notification_level = variable_get('update_notification_threshold', 'all');
+
+ // Create an array of status values keyed by module or theme name, since
+ // we'll need this while generating the report if we have to cross reference
+ // anything (e.g. subthemes which have base themes missing an update).
+ foreach ($data as $project) {
+ foreach ($project['includes'] as $key => $name) {
+ $status[$key] = $project['status'];
+ }
+ }
+
+ foreach ($data as $project) {
+ switch ($project['status']) {
+ case UPDATE_CURRENT:
+ $class = 'ok';
+ $icon = theme('image', array('path' => 'core/misc/watchdog-ok.png', 'width' => 18, 'height' => 18, 'alt' => t('ok'), 'title' => t('ok')));
+ break;
+ case UPDATE_UNKNOWN:
+ case UPDATE_FETCH_PENDING:
+ case UPDATE_NOT_FETCHED:
+ $class = 'unknown';
+ $icon = theme('image', array('path' => 'core/misc/watchdog-warning.png', 'width' => 18, 'height' => 18, 'alt' => t('warning'), 'title' => t('warning')));
+ break;
+ case UPDATE_NOT_SECURE:
+ case UPDATE_REVOKED:
+ case UPDATE_NOT_SUPPORTED:
+ $class = 'error';
+ $icon = theme('image', array('path' => 'core/misc/watchdog-error.png', 'width' => 18, 'height' => 18, 'alt' => t('error'), 'title' => t('error')));
+ break;
+ case UPDATE_NOT_CHECKED:
+ case UPDATE_NOT_CURRENT:
+ default:
+ $class = 'warning';
+ $icon = theme('image', array('path' => 'core/misc/watchdog-warning.png', 'width' => 18, 'height' => 18, 'alt' => t('warning'), 'title' => t('warning')));
+ break;
+ }
+
+ $row = '<div class="version-status">';
+ $status_label = theme('update_status_label', array('status' => $project['status']));
+ $row .= !empty($status_label) ? $status_label : check_plain($project['reason']);
+ $row .= '<span class="icon">' . $icon . '</span>';
+ $row .= "</div>\n";
+
+ $row .= '<div class="project">';
+ if (isset($project['title'])) {
+ if (isset($project['link'])) {
+ $row .= l($project['title'], $project['link']);
+ }
+ else {
+ $row .= check_plain($project['title']);
+ }
+ }
+ else {
+ $row .= check_plain($project['name']);
+ }
+ $row .= ' ' . check_plain($project['existing_version']);
+ if ($project['install_type'] == 'dev' && !empty($project['datestamp'])) {
+ $row .= ' <span class="version-date">(' . format_date($project['datestamp'], 'custom', 'Y-M-d') . ')</span>';
+ }
+ $row .= "</div>\n";
+
+ $versions_inner = '';
+ $security_class = array();
+ $version_class = array();
+ if (isset($project['recommended'])) {
+ if ($project['status'] != UPDATE_CURRENT || $project['existing_version'] !== $project['recommended']) {
+
+ // First, figure out what to recommend.
+ // If there's only 1 security update and it has the same version we're
+ // recommending, give it the same CSS class as if it was recommended,
+ // but don't print out a separate "Recommended" line for this project.
+ if (!empty($project['security updates']) && count($project['security updates']) == 1 && $project['security updates'][0]['version'] === $project['recommended']) {
+ $security_class[] = 'version-recommended';
+ $security_class[] = 'version-recommended-strong';
+ }
+ else {
+ $version_class[] = 'version-recommended';
+ // Apply an extra class if we're displaying both a recommended
+ // version and anything else for an extra visual hint.
+ if ($project['recommended'] !== $project['latest_version']
+ || !empty($project['also'])
+ || ($project['install_type'] == 'dev'
+ && isset($project['dev_version'])
+ && $project['latest_version'] !== $project['dev_version']
+ && $project['recommended'] !== $project['dev_version'])
+ || (isset($project['security updates'][0])
+ && $project['recommended'] !== $project['security updates'][0])
+ ) {
+ $version_class[] = 'version-recommended-strong';
+ }
+ $versions_inner .= theme('update_version', array('version' => $project['releases'][$project['recommended']], 'tag' => t('Recommended version:'), 'class' => $version_class));
+ }
+
+ // Now, print any security updates.
+ if (!empty($project['security updates'])) {
+ $security_class[] = 'version-security';
+ foreach ($project['security updates'] as $security_update) {
+ $versions_inner .= theme('update_version', array('version' => $security_update, 'tag' => t('Security update:'), 'class' => $security_class));
+ }
+ }
+ }
+
+ if ($project['recommended'] !== $project['latest_version']) {
+ $versions_inner .= theme('update_version', array('version' => $project['releases'][$project['latest_version']], 'tag' => t('Latest version:'), 'class' => array('version-latest')));
+ }
+ if ($project['install_type'] == 'dev'
+ && $project['status'] != UPDATE_CURRENT
+ && isset($project['dev_version'])
+ && $project['recommended'] !== $project['dev_version']) {
+ $versions_inner .= theme('update_version', array('version' => $project['releases'][$project['dev_version']], 'tag' => t('Development version:'), 'class' => array('version-latest')));
+ }
+ }
+
+ if (isset($project['also'])) {
+ foreach ($project['also'] as $also) {
+ $versions_inner .= theme('update_version', array('version' => $project['releases'][$also], 'tag' => t('Also available:'), 'class' => array('version-also-available')));
+ }
+ }
+
+ if (!empty($versions_inner)) {
+ $row .= "<div class=\"versions\">\n" . $versions_inner . "</div>\n";
+ }
+ $row .= "<div class=\"info\">\n";
+ if (!empty($project['extra'])) {
+ $row .= '<div class="extra">' . "\n";
+ foreach ($project['extra'] as $key => $value) {
+ $row .= '<div class="' . implode(' ', $value['class']) . '">';
+ $row .= check_plain($value['label']) . ': ';
+ $row .= drupal_placeholder($value['data']);
+ $row .= "</div>\n";
+ }
+ $row .= "</div>\n"; // extra div.
+ }
+
+ $row .= '<div class="includes">';
+ sort($project['includes']);
+ if (!empty($project['disabled'])) {
+ sort($project['disabled']);
+ // Make sure we start with a clean slate for each project in the report.
+ $includes_items = array();
+ $row .= t('Includes:');
+ $includes_items[] = t('Enabled: %includes', array('%includes' => implode(', ', $project['includes'])));
+ $includes_items[] = t('Disabled: %disabled', array('%disabled' => implode(', ', $project['disabled'])));
+ $row .= theme('item_list', array('items' => $includes_items));
+ }
+ else {
+ $row .= t('Includes: %includes', array('%includes' => implode(', ', $project['includes'])));
+ }
+ $row .= "</div>\n";
+
+ if (!empty($project['base_themes'])) {
+ $row .= '<div class="basethemes">';
+ asort($project['base_themes']);
+ $base_themes = array();
+ foreach ($project['base_themes'] as $base_key => $base_theme) {
+ switch ($status[$base_key]) {
+ case UPDATE_NOT_SECURE:
+ case UPDATE_REVOKED:
+ case UPDATE_NOT_SUPPORTED:
+ $base_themes[] = t('%base_theme (!base_label)', array('%base_theme' => $base_theme, '!base_label' => theme('update_status_label', array('status' => $status[$base_key]))));
+ break;
+
+ default:
+ $base_themes[] = drupal_placeholder($base_theme);
+ }
+ }
+ $row .= t('Depends on: !basethemes', array('!basethemes' => implode(', ', $base_themes)));
+ $row .= "</div>\n";
+ }
+
+ if (!empty($project['sub_themes'])) {
+ $row .= '<div class="subthemes">';
+ sort($project['sub_themes']);
+ $row .= t('Required by: %subthemes', array('%subthemes' => implode(', ', $project['sub_themes'])));
+ $row .= "</div>\n";
+ }
+
+ $row .= "</div>\n"; // info div.
+
+ if (!isset($rows[$project['project_type']])) {
+ $rows[$project['project_type']] = array();
+ }
+ $row_key = isset($project['title']) ? drupal_strtolower($project['title']) : drupal_strtolower($project['name']);
+ $rows[$project['project_type']][$row_key] = array(
+ 'class' => array($class),
+ 'data' => array($row),
+ );
+ }
+
+ $project_types = array(
+ 'core' => t('Drupal core'),
+ 'module' => t('Modules'),
+ 'theme' => t('Themes'),
+ 'module-disabled' => t('Disabled modules'),
+ 'theme-disabled' => t('Disabled themes'),
+ );
+ foreach ($project_types as $type_name => $type_label) {
+ if (!empty($rows[$type_name])) {
+ ksort($rows[$type_name]);
+ $output .= "\n<h3>" . $type_label . "</h3>\n";
+ $output .= theme('table', array('header' => $header, 'rows' => $rows[$type_name], 'attributes' => array('class' => array('update'))));
+ }
+ }
+ drupal_add_css(drupal_get_path('module', 'update') . '/update.css');
+ return $output;
+}
+
+/**
+ * Returns HTML for a label to display for a project's update status.
+ *
+ * @param array $variables
+ * An associative array containing:
+ * - status: The integer code for a project's current update status.
+ *
+ * @see update_calculate_project_data()
+ */
+function theme_update_status_label($variables) {
+ switch ($variables['status']) {
+ case UPDATE_NOT_SECURE:
+ return '<span class="security-error">' . t('Security update required!') . '</span>';
+
+ case UPDATE_REVOKED:
+ return '<span class="revoked">' . t('Revoked!') . '</span>';
+
+ case UPDATE_NOT_SUPPORTED:
+ return '<span class="not-supported">' . t('Not supported!') . '</span>';
+
+ case UPDATE_NOT_CURRENT:
+ return '<span class="not-current">' . t('Update available') . '</span>';
+
+ case UPDATE_CURRENT:
+ return '<span class="current">' . t('Up to date') . '</span>';
+
+ }
+}
+
+/**
+ * Returns HTML for the version display of a project.
+ *
+ * @param array $variables
+ * An associative array containing:
+ * - version: An array of data about the latest released version, containing:
+ * - version: The version number.
+ * - release_link: The URL for the release notes.
+ * - date: The date of the release.
+ * - download_link: The URL for the downloadable file.
+ * - tag: The title of the project.
+ * - class: A string containing extra classes for the wrapping table.
+ *
+ * @ingroup themeable
+ */
+function theme_update_version($variables) {
+ $version = $variables['version'];
+ $tag = $variables['tag'];
+ $class = implode(' ', $variables['class']);
+
+ $output = '';
+ $output .= '<table class="version ' . $class . '">';
+ $output .= '<tr>';
+ $output .= '<td class="version-title">' . $tag . "</td>\n";
+ $output .= '<td class="version-details">';
+ $output .= l($version['version'], $version['release_link']);
+ $output .= ' <span class="version-date">(' . format_date($version['date'], 'custom', 'Y-M-d') . ')</span>';
+ $output .= "</td>\n";
+ $output .= '<td class="version-links">';
+ $links = array();
+ $links['update-download'] = array(
+ 'title' => t('Download'),
+ 'href' => $version['download_link'],
+ );
+ $links['update-release-notes'] = array(
+ 'title' => t('Release notes'),
+ 'href' => $version['release_link'],
+ );
+ $output .= theme('links__update_version', array('links' => $links));
+ $output .= '</td>';
+ $output .= '</tr>';
+ $output .= "</table>\n";
+ return $output;
+}
diff --git a/core/modules/update/update.settings.inc b/core/modules/update/update.settings.inc
new file mode 100644
index 000000000000..60ac3ca8e4e7
--- /dev/null
+++ b/core/modules/update/update.settings.inc
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @file
+ * Code required only for the update status settings form.
+ */
+
+/**
+ * Form builder for the update settings tab.
+ */
+function update_settings($form) {
+ $form['update_check_frequency'] = array(
+ '#type' => 'radios',
+ '#title' => t('Check for updates'),
+ '#default_value' => variable_get('update_check_frequency', 1),
+ '#options' => array(
+ '1' => t('Daily'),
+ '7' => t('Weekly'),
+ ),
+ '#description' => t('Select how frequently you want to automatically check for new releases of your currently installed modules and themes.'),
+ );
+
+ $form['update_check_disabled'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Check for updates of disabled modules and themes'),
+ '#default_value' => variable_get('update_check_disabled', FALSE),
+ );
+
+ $notify_emails = variable_get('update_notify_emails', array());
+ $form['update_notify_emails'] = array(
+ '#type' => 'textarea',
+ '#title' => t('E-mail addresses to notify when updates are available'),
+ '#rows' => 4,
+ '#default_value' => implode("\n", $notify_emails),
+ '#description' => t('Whenever your site checks for available updates and finds new releases, it can notify a list of users via e-mail. Put each address on a separate line. If blank, no e-mails will be sent.'),
+ );
+
+ $form['update_notification_threshold'] = array(
+ '#type' => 'radios',
+ '#title' => t('E-mail notification threshold'),
+ '#default_value' => variable_get('update_notification_threshold', 'all'),
+ '#options' => array(
+ 'all' => t('All newer versions'),
+ 'security' => t('Only security updates'),
+ ),
+ '#description' => t('You can choose to send e-mail only if a security update is available, or to be notified about all newer versions. If there are updates available of Drupal core or any of your installed modules and themes, your site will always print a message on the <a href="@status_report">status report</a> page, and will also display an error message on administration pages if there is a security update.', array('@status_report' => url('admin/reports/status')))
+ );
+
+ $form = system_settings_form($form);
+ // Custom validation callback for the email notification setting.
+ $form['#validate'][] = 'update_settings_validate';
+ // We need to call our own submit callback first, not the one from
+ // system_settings_form(), so that we can process and save the emails.
+ unset($form['#submit']);
+
+ return $form;
+}
+
+/**
+ * Validation callback for the settings form.
+ *
+ * Validates the email addresses and ensures the field is formatted correctly.
+ */
+function update_settings_validate($form, &$form_state) {
+ if (!empty($form_state['values']['update_notify_emails'])) {
+ $valid = array();
+ $invalid = array();
+ foreach (explode("\n", trim($form_state['values']['update_notify_emails'])) as $email) {
+ $email = trim($email);
+ if (!empty($email)) {
+ if (valid_email_address($email)) {
+ $valid[] = $email;
+ }
+ else {
+ $invalid[] = $email;
+ }
+ }
+ }
+ if (empty($invalid)) {
+ $form_state['notify_emails'] = $valid;
+ }
+ elseif (count($invalid) == 1) {
+ form_set_error('update_notify_emails', t('%email is not a valid e-mail address.', array('%email' => reset($invalid))));
+ }
+ else {
+ form_set_error('update_notify_emails', t('%emails are not valid e-mail addresses.', array('%emails' => implode(', ', $invalid))));
+ }
+ }
+}
+
+/**
+ * Submit handler for the settings tab.
+ *
+ * Also invalidates the cache of available updates if the "Check for updates
+ * of disabled modules and themes" setting is being changed. The available
+ * updates report need to refetch available update data after this setting
+ * changes or it would show misleading things (e.g. listing the disabled
+ * projects on the site with the "No available releases found" warning).
+ */
+function update_settings_submit($form, $form_state) {
+ $op = $form_state['values']['op'];
+
+ if (empty($form_state['notify_emails'])) {
+ variable_del('update_notify_emails');
+ }
+ else {
+ variable_set('update_notify_emails', $form_state['notify_emails']);
+ }
+ unset($form_state['notify_emails']);
+ unset($form_state['values']['update_notify_emails']);
+
+ // See if the update_check_disabled setting is being changed, and if so,
+ // invalidate all cached update status data.
+ $check_disabled = variable_get('update_check_disabled', FALSE);
+ if ($form_state['values']['update_check_disabled'] != $check_disabled) {
+ _update_cache_clear();
+ }
+
+ system_settings_form_submit($form, $form_state);
+}
diff --git a/core/modules/update/update.test b/core/modules/update/update.test
new file mode 100644
index 000000000000..a657f91de0da
--- /dev/null
+++ b/core/modules/update/update.test
@@ -0,0 +1,699 @@
+<?php
+
+/**
+ * @file
+ * Tests for update.module.
+ *
+ * This file contains tests for the update module. The overarching methodology
+ * of these tests is we need to compare a given state of installed modules and
+ * themes (e.g. version, project grouping, timestamps, etc) vs. a current
+ * state of what the release history XML files we fetch say is available. We
+ * have dummy XML files (in the 'tests' subdirectory) that describe various
+ * scenarios of what's available for different test projects, and we have
+ * dummy .info file data (specified via hook_system_info_alter() in the
+ * update_test helper module) describing what's currently installed. Each
+ * test case defines a set of projects to install, their current state (via
+ * the 'update_test_system_info' variable) and the desired available update
+ * data (via the 'update_test_xml_map' variable), and then performs a series
+ * of assertions that the report matches our expectations given the specific
+ * initial state and availability scenario.
+ */
+
+/**
+ * Base class to define some shared functions used by all update tests.
+ */
+class UpdateTestHelper extends DrupalWebTestCase {
+ /**
+ * Refresh the update status based on the desired available update scenario.
+ *
+ * @param $xml_map
+ * Array that maps project names to availability scenarios to fetch.
+ * The key '#all' is used if a project-specific mapping is not defined.
+ *
+ * @see update_test_mock_page()
+ */
+ protected function refreshUpdateStatus($xml_map, $url = 'update-test') {
+ // Tell update module to fetch from the URL provided by update_test module.
+ variable_set('update_fetch_url', url($url, array('absolute' => TRUE)));
+ // Save the map for update_test_mock_page() to use.
+ variable_set('update_test_xml_map', $xml_map);
+ // Manually check the update status.
+ $this->drupalGet('admin/reports/updates/check');
+ }
+
+ /**
+ * Run a series of assertions that are applicable for all update statuses.
+ */
+ protected function standardTests() {
+ $this->assertRaw('<h3>' . t('Drupal core') . '</h3>');
+ $this->assertRaw(l(t('Drupal'), 'http://example.com/project/drupal'), t('Link to the Drupal project appears.'));
+ $this->assertNoText(t('No available releases found'));
+ }
+
+}
+
+class UpdateCoreTestCase extends UpdateTestHelper {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update core functionality',
+ 'description' => 'Tests the update module through a series of functional tests using mock XML data.',
+ 'group' => 'Update',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('update_test', 'update');
+ $admin_user = $this->drupalCreateUser(array('administer site configuration', 'administer modules'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Tests the update module when no updates are available.
+ */
+ function testNoUpdatesAvailable() {
+ $this->setSystemInfo7_0();
+ $this->refreshUpdateStatus(array('drupal' => '0'));
+ $this->standardTests();
+ $this->assertText(t('Up to date'));
+ $this->assertNoText(t('Update available'));
+ $this->assertNoText(t('Security update required!'));
+ }
+
+ /**
+ * Tests the update module when one normal update ("7.1") is available.
+ */
+ function testNormalUpdateAvailable() {
+ $this->setSystemInfo7_0();
+ $this->refreshUpdateStatus(array('drupal' => '1'));
+ $this->standardTests();
+ $this->assertNoText(t('Up to date'));
+ $this->assertText(t('Update available'));
+ $this->assertNoText(t('Security update required!'));
+ $this->assertRaw(l('7.1', 'http://example.com/drupal-7-1-release'), t('Link to release appears.'));
+ $this->assertRaw(l(t('Download'), 'http://example.com/drupal-7-1.tar.gz'), t('Link to download appears.'));
+ $this->assertRaw(l(t('Release notes'), 'http://example.com/drupal-7-1-release'), t('Link to release notes appears.'));
+ }
+
+ /**
+ * Tests the update module when a security update ("7.2") is available.
+ */
+ function testSecurityUpdateAvailable() {
+ $this->setSystemInfo7_0();
+ $this->refreshUpdateStatus(array('drupal' => '2-sec'));
+ $this->standardTests();
+ $this->assertNoText(t('Up to date'));
+ $this->assertNoText(t('Update available'));
+ $this->assertText(t('Security update required!'));
+ $this->assertRaw(l('7.2', 'http://example.com/drupal-7-2-release'), t('Link to release appears.'));
+ $this->assertRaw(l(t('Download'), 'http://example.com/drupal-7-2.tar.gz'), t('Link to download appears.'));
+ $this->assertRaw(l(t('Release notes'), 'http://example.com/drupal-7-2-release'), t('Link to release notes appears.'));
+ }
+
+ /**
+ * Ensure proper results where there are date mismatches among modules.
+ */
+ function testDatestampMismatch() {
+ $system_info = array(
+ '#all' => array(
+ // We need to think we're running a -dev snapshot to see dates.
+ 'version' => '7.0-dev',
+ 'datestamp' => time(),
+ ),
+ 'block' => array(
+ // This is 2001-09-09 01:46:40 GMT, so test for "2001-Sep-".
+ 'datestamp' => '1000000000',
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $this->refreshUpdateStatus(array('drupal' => 'dev'));
+ $this->assertNoText(t('2001-Sep-'));
+ $this->assertText(t('Up to date'));
+ $this->assertNoText(t('Update available'));
+ $this->assertNoText(t('Security update required!'));
+ }
+
+ /**
+ * Check that running cron updates the list of available updates.
+ */
+ function testModulePageRunCron() {
+ $this->setSystemInfo7_0();
+ variable_set('update_fetch_url', url('update-test', array('absolute' => TRUE)));
+ variable_set('update_test_xml_map', array('drupal' => '0'));
+
+ $this->cronRun();
+ $this->drupalGet('admin/modules');
+ $this->assertNoText(t('No update information available.'));
+ }
+
+ /**
+ * Check the messages at admin/modules when the site is up to date.
+ */
+ function testModulePageUpToDate() {
+ $this->setSystemInfo7_0();
+ // Instead of using refreshUpdateStatus(), set these manually.
+ variable_set('update_fetch_url', url('update-test', array('absolute' => TRUE)));
+ variable_set('update_test_xml_map', array('drupal' => '0'));
+
+ $this->drupalGet('admin/reports/updates');
+ $this->clickLink(t('Check manually'));
+ $this->assertText(t('Checked available update data for one project.'));
+ $this->drupalGet('admin/modules');
+ $this->assertNoText(t('There are updates available for your version of Drupal.'));
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+ }
+
+ /**
+ * Check the messages at admin/modules when missing an update.
+ */
+ function testModulePageRegularUpdate() {
+ $this->setSystemInfo7_0();
+ // Instead of using refreshUpdateStatus(), set these manually.
+ variable_set('update_fetch_url', url('update-test', array('absolute' => TRUE)));
+ variable_set('update_test_xml_map', array('drupal' => '1'));
+
+ $this->drupalGet('admin/reports/updates');
+ $this->clickLink(t('Check manually'));
+ $this->assertText(t('Checked available update data for one project.'));
+ $this->drupalGet('admin/modules');
+ $this->assertText(t('There are updates available for your version of Drupal.'));
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+ }
+
+ /**
+ * Check the messages at admin/modules when missing a security update.
+ */
+ function testModulePageSecurityUpdate() {
+ $this->setSystemInfo7_0();
+ // Instead of using refreshUpdateStatus(), set these manually.
+ variable_set('update_fetch_url', url('update-test', array('absolute' => TRUE)));
+ variable_set('update_test_xml_map', array('drupal' => '2-sec'));
+
+ $this->drupalGet('admin/reports/updates');
+ $this->clickLink(t('Check manually'));
+ $this->assertText(t('Checked available update data for one project.'));
+ $this->drupalGet('admin/modules');
+ $this->assertNoText(t('There are updates available for your version of Drupal.'));
+ $this->assertText(t('There is a security update available for your version of Drupal.'));
+
+ // Make sure admin/appearance warns you you're missing a security update.
+ $this->drupalGet('admin/appearance');
+ $this->assertNoText(t('There are updates available for your version of Drupal.'));
+ $this->assertText(t('There is a security update available for your version of Drupal.'));
+
+ // Make sure duplicate messages don't appear on Update status pages.
+ $this->drupalGet('admin/reports/status');
+ // We're expecting "There is a security update..." inside the status report
+ // itself, but the drupal_set_message() appears as an li so we can prefix
+ // with that and search for the raw HTML.
+ $this->assertNoRaw('<li>' . t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/reports/updates');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/reports/updates/settings');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+ }
+
+ /**
+ * Tests the update module when the update server returns 503 (Service unavailable) errors.
+ */
+ function testServiceUnavailable() {
+ $this->refreshUpdateStatus(array(), '503-error');
+ // Ensure that no "Warning: SimpleXMLElement..." parse errors are found.
+ $this->assertNoText('SimpleXMLElement');
+ $this->assertUniqueText(t('Failed to get available update data for one project.'));
+ }
+
+ protected function setSystemInfo7_0() {
+ $setting = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ );
+ variable_set('update_test_system_info', $setting);
+ }
+
+}
+
+class UpdateTestContribCase extends UpdateTestHelper {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Update contrib functionality',
+ 'description' => 'Tests how the update module handles contributed modules and themes in a series of functional tests using mock XML data.',
+ 'group' => 'Update',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('update_test', 'update', 'aaa_update_test', 'bbb_update_test', 'ccc_update_test');
+ $admin_user = $this->drupalCreateUser(array('administer site configuration'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Tests when there is no available release data for a contrib module.
+ */
+ function testNoReleasesAvailable() {
+ $system_info = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ 'aaa_update_test' => array(
+ 'project' => 'aaa_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $this->refreshUpdateStatus(array('drupal' => '0', 'aaa_update_test' => 'no-releases'));
+ $this->drupalGet('admin/reports/updates');
+ // Cannot use $this->standardTests() because we need to check for the
+ // 'No available releases found' string.
+ $this->assertRaw('<h3>' . t('Drupal core') . '</h3>');
+ $this->assertRaw(l(t('Drupal'), 'http://example.com/project/drupal'));
+ $this->assertText(t('Up to date'));
+ $this->assertRaw('<h3>' . t('Modules') . '</h3>');
+ $this->assertNoText(t('Update available'));
+ $this->assertText(t('No available releases found'));
+ $this->assertNoRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'));
+ }
+
+ /**
+ * Test the basic functionality of a contrib module on the status report.
+ */
+ function testUpdateContribBasic() {
+ $system_info = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ 'aaa_update_test' => array(
+ 'project' => 'aaa_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $this->refreshUpdateStatus(
+ array(
+ 'drupal' => '0',
+ 'aaa_update_test' => '1_0',
+ )
+ );
+ $this->standardTests();
+ $this->assertText(t('Up to date'));
+ $this->assertRaw('<h3>' . t('Modules') . '</h3>');
+ $this->assertNoText(t('Update available'));
+ $this->assertRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'), t('Link to aaa_update_test project appears.'));
+ }
+
+ /**
+ * Test that contrib projects are ordered by project name.
+ *
+ * If a project contains multiple modules, we want to make sure that the
+ * available updates report is sorted by the parent project names, not by
+ * the names of the modules included in each project. In this test case, we
+ * have 2 contrib projects, "BBB Update test" and "CCC Update test".
+ * However, we have a module called "aaa_update_test" that's part of the
+ * "CCC Update test" project. We need to make sure that we see the "BBB"
+ * project before the "CCC" project, even though "CCC" includes a module
+ * that's processed first if you sort alphabetically by module name (which
+ * is the order we see things inside system_rebuild_module_data() for example).
+ */
+ function testUpdateContribOrder() {
+ // We want core to be version 7.0.
+ $system_info = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ // All the rest should be visible as contrib modules at version 8.x-1.0.
+
+ // aaa_update_test needs to be part of the "CCC Update test" project,
+ // which would throw off the report if we weren't properly sorting by
+ // the project names.
+ 'aaa_update_test' => array(
+ 'project' => 'ccc_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+
+ // This should be its own project, and listed first on the report.
+ 'bbb_update_test' => array(
+ 'project' => 'bbb_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+
+ // This will contain both aaa_update_test and ccc_update_test, and
+ // should come after the bbb_update_test project.
+ 'ccc_update_test' => array(
+ 'project' => 'ccc_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $this->refreshUpdateStatus(array('drupal' => '0', '#all' => '1_0'));
+ $this->standardTests();
+ // We're expecting the report to say all projects are up to date.
+ $this->assertText(t('Up to date'));
+ $this->assertNoText(t('Update available'));
+ // We want to see all 3 module names listed, since they'll show up either
+ // as project names or as modules under the "Includes" listing.
+ $this->assertText(t('AAA Update test'));
+ $this->assertText(t('BBB Update test'));
+ $this->assertText(t('CCC Update test'));
+ // We want aaa_update_test included in the ccc_update_test project, not as
+ // its own project on the report.
+ $this->assertNoRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'), t('Link to aaa_update_test project does not appear.'));
+ // The other two should be listed as projects.
+ $this->assertRaw(l(t('BBB Update test'), 'http://example.com/project/bbb_update_test'), t('Link to bbb_update_test project appears.'));
+ $this->assertRaw(l(t('CCC Update test'), 'http://example.com/project/ccc_update_test'), t('Link to bbb_update_test project appears.'));
+
+ // We want to make sure we see the BBB project before the CCC project.
+ // Instead of just searching for 'BBB Update test' or something, we want
+ // to use the full markup that starts the project entry itself, so that
+ // we're really testing that the project listings are in the right order.
+ $bbb_project_link = '<div class="project"><a href="http://example.com/project/bbb_update_test">BBB Update test</a>';
+ $ccc_project_link = '<div class="project"><a href="http://example.com/project/ccc_update_test">CCC Update test</a>';
+ $this->assertTrue(strpos($this->drupalGetContent(), $bbb_project_link) < strpos($this->drupalGetContent(), $ccc_project_link), "'BBB Update test' project is listed before the 'CCC Update test' project");
+ }
+
+ /**
+ * Test that subthemes are notified about security updates for base themes.
+ */
+ function testUpdateBaseThemeSecurityUpdate() {
+ // Only enable the subtheme, not the base theme.
+ db_update('system')
+ ->fields(array('status' => 1))
+ ->condition('type', 'theme')
+ ->condition('name', 'update_test_subtheme')
+ ->execute();
+
+ // Define the initial state for core and the subtheme.
+ $system_info = array(
+ // We want core to be version 7.0.
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ // Show the update_test_basetheme
+ 'update_test_basetheme' => array(
+ 'project' => 'update_test_basetheme',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ // Show the update_test_subtheme
+ 'update_test_subtheme' => array(
+ 'project' => 'update_test_subtheme',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $xml_mapping = array(
+ 'drupal' => '0',
+ 'update_test_subtheme' => '1_0',
+ 'update_test_basetheme' => '1_1-sec',
+ );
+ $this->refreshUpdateStatus($xml_mapping);
+ $this->assertText(t('Security update required!'));
+ $this->assertRaw(l(t('Update test base theme'), 'http://example.com/project/update_test_basetheme'), t('Link to the Update test base theme project appears.'));
+ }
+
+ /**
+ * Test that disabled themes are only shown when desired.
+ */
+ function testUpdateShowDisabledThemes() {
+ // Make sure all the update_test_* themes are disabled.
+ db_update('system')
+ ->fields(array('status' => 0))
+ ->condition('type', 'theme')
+ ->condition('name', 'update_test_%', 'LIKE')
+ ->execute();
+
+ // Define the initial state for core and the test contrib themes.
+ $system_info = array(
+ // We want core to be version 7.0.
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ // The update_test_basetheme should be visible and up to date.
+ 'update_test_basetheme' => array(
+ 'project' => 'update_test_basetheme',
+ 'version' => '8.x-1.1',
+ 'hidden' => FALSE,
+ ),
+ // The update_test_subtheme should be visible and up to date.
+ 'update_test_subtheme' => array(
+ 'project' => 'update_test_subtheme',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ // When there are contributed modules in the site's file system, the
+ // total number of attempts made in the test may exceed the default value
+ // of update_max_fetch_attempts. Therefore this variable is set very high
+ // to avoid test failures in those cases.
+ variable_set('update_max_fetch_attempts', 99999);
+ variable_set('update_test_system_info', $system_info);
+ $xml_mapping = array(
+ 'drupal' => '0',
+ 'update_test_subtheme' => '1_0',
+ 'update_test_basetheme' => '1_1-sec',
+ );
+ $base_theme_project_link = l(t('Update test base theme'), 'http://example.com/project/update_test_basetheme');
+ $sub_theme_project_link = l(t('Update test subtheme'), 'http://example.com/project/update_test_subtheme');
+ foreach (array(TRUE, FALSE) as $check_disabled) {
+ variable_set('update_check_disabled', $check_disabled);
+ $this->refreshUpdateStatus($xml_mapping);
+ // In neither case should we see the "Themes" heading for enabled themes.
+ $this->assertNoText(t('Themes'));
+ if ($check_disabled) {
+ $this->assertText(t('Disabled themes'));
+ $this->assertRaw($base_theme_project_link, t('Link to the Update test base theme project appears.'));
+ $this->assertRaw($sub_theme_project_link, t('Link to the Update test subtheme project appears.'));
+ }
+ else {
+ $this->assertNoText(t('Disabled themes'));
+ $this->assertNoRaw($base_theme_project_link, t('Link to the Update test base theme project does not appear.'));
+ $this->assertNoRaw($sub_theme_project_link, t('Link to the Update test subtheme project does not appear.'));
+ }
+ }
+ }
+
+ /**
+ * Make sure that if we fetch from a broken URL, sane things happen.
+ */
+ function testUpdateBrokenFetchURL() {
+ $system_info = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ 'aaa_update_test' => array(
+ 'project' => 'aaa_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ 'bbb_update_test' => array(
+ 'project' => 'bbb_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ 'ccc_update_test' => array(
+ 'project' => 'ccc_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+
+ $xml_mapping = array(
+ 'drupal' => '0',
+ 'aaa_update_test' => '1_0',
+ 'bbb_update_test' => 'does-not-exist',
+ 'ccc_update_test' => '1_0',
+ );
+ $this->refreshUpdateStatus($xml_mapping);
+
+ $this->assertText(t('Up to date'));
+ // We're expecting the report to say most projects are up to date, so we
+ // hope that 'Up to date' is not unique.
+ $this->assertNoUniqueText(t('Up to date'));
+ // It should say we failed to get data, not that we're missing an update.
+ $this->assertNoText(t('Update available'));
+
+ // We need to check that this string is found as part of a project row,
+ // not just in the "Failed to get available update data for ..." message
+ // at the top of the page.
+ $this->assertRaw('<div class="version-status">' . t('Failed to get available update data'));
+
+ // We should see the output messages from fetching manually.
+ $this->assertUniqueText(t('Checked available update data for 3 projects.'));
+ $this->assertUniqueText(t('Failed to get available update data for one project.'));
+
+ // The other two should be listed as projects.
+ $this->assertRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'), t('Link to aaa_update_test project appears.'));
+ $this->assertNoRaw(l(t('BBB Update test'), 'http://example.com/project/bbb_update_test'), t('Link to bbb_update_test project does not appear.'));
+ $this->assertRaw(l(t('CCC Update test'), 'http://example.com/project/ccc_update_test'), t('Link to bbb_update_test project appears.'));
+ }
+
+ /**
+ * Check that hook_update_status_alter() works to change a status.
+ *
+ * We provide the same external data as if aaa_update_test 8.x-1.0 were
+ * installed and that was the latest release. Then we use
+ * hook_update_status_alter() to try to mark this as missing a security
+ * update, then assert if we see the appropriate warnings on the right
+ * pages.
+ */
+ function testHookUpdateStatusAlter() {
+ variable_set('allow_authorize_operations', TRUE);
+ $update_admin_user = $this->drupalCreateUser(array('administer site configuration', 'administer software updates'));
+ $this->drupalLogin($update_admin_user);
+
+ $system_info = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ 'aaa_update_test' => array(
+ 'project' => 'aaa_update_test',
+ 'version' => '8.x-1.0',
+ 'hidden' => FALSE,
+ ),
+ );
+ variable_set('update_test_system_info', $system_info);
+ $update_status = array(
+ 'aaa_update_test' => array(
+ 'status' => UPDATE_NOT_SECURE,
+ ),
+ );
+ variable_set('update_test_update_status', $update_status);
+ $this->refreshUpdateStatus(
+ array(
+ 'drupal' => '0',
+ 'aaa_update_test' => '1_0',
+ )
+ );
+ $this->drupalGet('admin/reports/updates');
+ $this->assertRaw('<h3>' . t('Modules') . '</h3>');
+ $this->assertText(t('Security update required!'));
+ $this->assertRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'), t('Link to aaa_update_test project appears.'));
+
+ // Visit the reports page again without the altering and make sure the
+ // status is back to normal.
+ variable_set('update_test_update_status', array());
+ $this->drupalGet('admin/reports/updates');
+ $this->assertRaw('<h3>' . t('Modules') . '</h3>');
+ $this->assertNoText(t('Security update required!'));
+ $this->assertRaw(l(t('AAA Update test'), 'http://example.com/project/aaa_update_test'), t('Link to aaa_update_test project appears.'));
+
+ // Turn the altering back on and visit the Update manager UI.
+ variable_set('update_test_update_status', $update_status);
+ $this->drupalGet('admin/modules/update');
+ $this->assertText(t('Security update'));
+
+ // Turn the altering back off and visit the Update manager UI.
+ variable_set('update_test_update_status', array());
+ $this->drupalGet('admin/modules/update');
+ $this->assertNoText(t('Security update'));
+ }
+
+}
+
+class UpdateTestUploadCase extends UpdateTestHelper {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Upload and extract module functionality',
+ 'description' => 'Tests the update module\'s upload and extraction functionality.',
+ 'group' => 'Update',
+ );
+ }
+
+ public function setUp() {
+ parent::setUp('update', 'update_test');
+ variable_set('allow_authorize_operations', TRUE);
+ $admin_user = $this->drupalCreateUser(array('administer software updates', 'administer site configuration'));
+ $this->drupalLogin($admin_user);
+ }
+
+ /**
+ * Tests upload and extraction of a module.
+ */
+ public function testUploadModule() {
+ // Images are not valid archives, so get one and try to install it. We
+ // need an extra variable to store the result of drupalGetTestFiles()
+ // since reset() takes an argument by reference and passing in a constant
+ // emits a notice in strict mode.
+ $imageTestFiles = $this->drupalGetTestFiles('image');
+ $invalidArchiveFile = reset($imageTestFiles);
+ $edit = array(
+ 'files[project_upload]' => $invalidArchiveFile->uri,
+ );
+ // This also checks that the correct archive extensions are allowed.
+ $this->drupalPost('admin/modules/install', $edit, t('Install'));
+ $this->assertText(t('Only files with the following extensions are allowed: @archive_extensions.', array('@archive_extensions' => archiver_get_extensions())),'Only valid archives can be uploaded.');
+
+ // Check to ensure an existing module can't be reinstalled. Also checks that
+ // the archive was extracted since we can't know if the module is already
+ // installed until after extraction.
+ $validArchiveFile = drupal_get_path('module', 'update') . '/tests/aaa_update_test.tar.gz';
+ $edit = array(
+ 'files[project_upload]' => $validArchiveFile,
+ );
+ $this->drupalPost('admin/modules/install', $edit, t('Install'));
+ $this->assertText(t('@module_name is already installed.', array('@module_name' => 'AAA Update test')), 'Existing module was extracted and not reinstalled.');
+ }
+
+ /**
+ * Ensure that archiver extensions are properly merged in the UI.
+ */
+ function testFileNameExtensionMerging() {
+ $this->drupalGet('admin/modules/install');
+ // Make sure the bogus extension supported by update_test.module is there.
+ $this->assertPattern('/file extensions are supported:.*update-test-extension/', t("Found 'update-test-extension' extension"));
+ // Make sure it didn't clobber the first option from core.
+ $this->assertPattern('/file extensions are supported:.*tar/', t("Found 'tar' extension"));
+ }
+
+ /**
+ * Check the messages on Update manager pages when missing a security update.
+ */
+ function testUpdateManagerCoreSecurityUpdateMessages() {
+ $setting = array(
+ '#all' => array(
+ 'version' => '7.0',
+ ),
+ );
+ variable_set('update_test_system_info', $setting);
+ variable_set('update_fetch_url', url('update-test', array('absolute' => TRUE)));
+ variable_set('update_test_xml_map', array('drupal' => '2-sec'));
+ // Initialize the update status.
+ $this->drupalGet('admin/reports/updates');
+
+ // Now, make sure none of the Update manager pages have duplicate messages
+ // about core missing a security update.
+
+ $this->drupalGet('admin/modules/install');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/modules/update');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/appearance/install');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/appearance/update');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/reports/updates/install');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/reports/updates/update');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+
+ $this->drupalGet('admin/update/ready');
+ $this->assertNoText(t('There is a security update available for your version of Drupal.'));
+ }
+
+}
diff --git a/core/modules/user/tests/user_form_test.info b/core/modules/user/tests/user_form_test.info
new file mode 100644
index 000000000000..ed2f39e3a442
--- /dev/null
+++ b/core/modules/user/tests/user_form_test.info
@@ -0,0 +1,6 @@
+name = "User module form tests"
+description = "Support module for user form testing."
+package = Testing
+version = VERSION
+core = 8.x
+hidden = TRUE
diff --git a/core/modules/user/tests/user_form_test.module b/core/modules/user/tests/user_form_test.module
new file mode 100644
index 000000000000..4e907f361b3c
--- /dev/null
+++ b/core/modules/user/tests/user_form_test.module
@@ -0,0 +1,64 @@
+<?php
+
+/**
+ * @file
+ * Dummy module implementing a form to test user password validation
+ */
+
+/**
+ * Implements hook_menu().
+ *
+ * Sets up a form that allows a user to validate password.
+ */
+function user_form_test_menu() {
+ $items = array();
+ $items['user_form_test_current_password/%user'] = array(
+ 'title' => 'User form test for current password validation',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_form_test_current_password',1),
+ 'access arguments' => array('administer users'),
+ 'type' => MENU_SUGGESTED_ITEM,
+ );
+ return $items;
+}
+
+/**
+ * A test form for user_validate_current_pass().
+ */
+function user_form_test_current_password($form, &$form_state, $account) {
+ $account->user_form_test_field = '';
+ $form['#user'] = $account;
+
+ $form['user_form_test_field'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Test field'),
+ '#description' => t('A field that would require a correct password to change.'),
+ '#required' => TRUE,
+ );
+
+ $form['current_pass'] = array(
+ '#type' => 'password',
+ '#title' => t('Current password'),
+ '#size' => 25,
+ '#description' => t('Enter your current password'),
+ );
+
+ $form['current_pass_required_values'] = array(
+ '#type' => 'value',
+ '#value' => array('user_form_test_field' => t('Test field')),
+ );
+
+ $form['#validate'][] = 'user_validate_current_pass';
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Test'),
+ );
+ return $form;
+}
+
+/**
+ * Submit function for the test form for user_validate_current_pass().
+ */
+function user_form_test_current_password_submit($form, &$form_state) {
+ drupal_set_message(t('The password has been validated and the form submitted successfully.'));
+}
diff --git a/core/modules/user/user-picture.tpl.php b/core/modules/user/user-picture.tpl.php
new file mode 100644
index 000000000000..a33d2661d744
--- /dev/null
+++ b/core/modules/user/user-picture.tpl.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present a picture configured for the
+ * user's account.
+ *
+ * Available variables:
+ * - $user_picture: Image set by the user or the site's default. Will be linked
+ * depending on the viewer's permission to view the user's profile page.
+ * - $account: Array of account information. Potentially unsafe. Be sure to
+ * check_plain() before use.
+ *
+ * @see template_preprocess_user_picture()
+ */
+?>
+<?php if ($user_picture): ?>
+ <div class="user-picture">
+ <?php print $user_picture; ?>
+ </div>
+<?php endif; ?>
diff --git a/core/modules/user/user-profile-category.tpl.php b/core/modules/user/user-profile-category.tpl.php
new file mode 100644
index 000000000000..8b6cd9991a6b
--- /dev/null
+++ b/core/modules/user/user-profile-category.tpl.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present profile categories (groups of
+ * profile items).
+ *
+ * Categories are defined when configuring user profile fields for the site.
+ * It can also be defined by modules. All profile items for a category will be
+ * output through the $profile_items variable.
+ *
+ * @see user-profile-item.tpl.php
+ * where each profile item is rendered. It is implemented as a definition
+ * list by default.
+ * @see user-profile.tpl.php
+ * where all items and categories are collected and printed out.
+ *
+ * Available variables:
+ * - $title: Category title for the group of items.
+ * - $profile_items: All the items for the group rendered through
+ * user-profile-item.tpl.php.
+ * - $attributes: HTML attributes. Usually renders classes.
+ *
+ * @see template_preprocess_user_profile_category()
+ */
+?>
+<section class="<?php print $classes; ?>">
+ <?php if ($title): ?>
+ <h2><?php print $title; ?></h2>
+ <?php endif; ?>
+
+ <dl<?php print $attributes; ?>>
+ <?php print $profile_items; ?>
+ </dl>
+</section>
diff --git a/core/modules/user/user-profile-item.tpl.php b/core/modules/user/user-profile-item.tpl.php
new file mode 100644
index 000000000000..042d43a87958
--- /dev/null
+++ b/core/modules/user/user-profile-item.tpl.php
@@ -0,0 +1,26 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present profile items (values from user
+ * account profile fields or modules).
+ *
+ * This template is used to loop through and render each field configured
+ * for the user's account. It can also be the data from modules. The output is
+ * grouped by categories.
+ *
+ * @see user-profile-category.tpl.php
+ * for the parent markup. Implemented as a definition list by default.
+ * @see user-profile.tpl.php
+ * where all items and categories are collected and printed out.
+ *
+ * Available variables:
+ * - $title: Field title for the profile item.
+ * - $value: User defined value for the profile item or data from a module.
+ * - $attributes: HTML attributes. Usually renders classes.
+ *
+ * @see template_preprocess_user_profile_item()
+ */
+?>
+<dt<?php print $attributes; ?>><?php print $title; ?></dt>
+<dd<?php print $attributes; ?>><?php print $value; ?></dd>
diff --git a/core/modules/user/user-profile.tpl.php b/core/modules/user/user-profile.tpl.php
new file mode 100644
index 000000000000..a1611c835312
--- /dev/null
+++ b/core/modules/user/user-profile.tpl.php
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * @file
+ * Default theme implementation to present all user profile data.
+ *
+ * This template is used when viewing a registered member's profile page,
+ * e.g., example.com/user/123. 123 being the users ID.
+ *
+ * Use render($user_profile) to print all profile items, or print a subset
+ * such as render($user_profile['user_picture']). Always call
+ * render($user_profile) at the end in order to print all remaining items. If
+ * the item is a category, it will contain all its profile items. By default,
+ * $user_profile['summary'] is provided, which contains data on the user's
+ * history. Other data can be included by modules. $user_profile['user_picture']
+ * is available for showing the account picture.
+ *
+ * Available variables:
+ * - $user_profile: An array of profile items. Use render() to print them.
+ * - Field variables: for each field instance attached to the user a
+ * corresponding variable is defined; e.g., $account->field_example has a
+ * variable $field_example defined. When needing to access a field's raw
+ * values, developers/themers are strongly encouraged to use these
+ * variables. Otherwise they will have to explicitly specify the desired
+ * field language, e.g. $account->field_example['en'], thus overriding any
+ * language negotiation rule that was previously applied.
+ *
+ * @see user-profile-category.tpl.php
+ * Where the html is handled for the group.
+ * @see user-profile-item.tpl.php
+ * Where the html is handled for each item in the group.
+ * @see template_preprocess_user_profile()
+ */
+?>
+<div class="profile"<?php print $attributes; ?>>
+ <?php print render($user_profile); ?>
+</div>
diff --git a/core/modules/user/user-rtl.css b/core/modules/user/user-rtl.css
new file mode 100644
index 000000000000..642c9434716a
--- /dev/null
+++ b/core/modules/user/user-rtl.css
@@ -0,0 +1,34 @@
+
+#permissions td.permission {
+ padding-left: 0;
+ padding-right: 1.5em;
+}
+
+#user-admin-roles .form-item-name {
+ float: right;
+ margin-left: 1em;
+ margin-right: 0;
+}
+
+/**
+ * Password strength indicator.
+ */
+.password-strength {
+ float: left;
+}
+.password-strength-text {
+ float: left;
+}
+div.password-confirm {
+ float: left;
+}
+.confirm-parent,
+.password-parent {
+ clear: right;
+}
+
+/* Generated by user.module but used by profile.module: */
+.profile .user-picture {
+ float: left;
+ margin: 0 0 1em 1em;
+}
diff --git a/core/modules/user/user.admin.inc b/core/modules/user/user.admin.inc
new file mode 100644
index 000000000000..4789e7e73131
--- /dev/null
+++ b/core/modules/user/user.admin.inc
@@ -0,0 +1,1037 @@
+<?php
+
+/**
+ * @file
+ * Admin page callback file for the user module.
+ */
+
+function user_admin($callback_arg = '') {
+ $op = isset($_POST['op']) ? $_POST['op'] : $callback_arg;
+
+ switch ($op) {
+ case t('Create new account'):
+ case 'create':
+ $build['user_register'] = drupal_get_form('user_register_form');
+ break;
+ default:
+ if (!empty($_POST['accounts']) && isset($_POST['operation']) && ($_POST['operation'] == 'cancel')) {
+ $build['user_multiple_cancel_confirm'] = drupal_get_form('user_multiple_cancel_confirm');
+ }
+ else {
+ $build['user_filter_form'] = drupal_get_form('user_filter_form');
+ $build['user_admin_account'] = drupal_get_form('user_admin_account');
+ }
+ }
+ return $build;
+}
+
+/**
+ * Form builder; Return form for user administration filters.
+ *
+ * @ingroup forms
+ * @see user_filter_form_submit()
+ */
+function user_filter_form() {
+ $session = isset($_SESSION['user_overview_filter']) ? $_SESSION['user_overview_filter'] : array();
+ $filters = user_filters();
+
+ $i = 0;
+ $form['filters'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Show only users where'),
+ '#theme' => 'exposed_filters__user',
+ );
+ foreach ($session as $filter) {
+ list($type, $value) = $filter;
+ if ($type == 'permission') {
+ // Merge arrays of module permissions into one.
+ // Slice past the first element '[any]' whose value is not an array.
+ $options = call_user_func_array('array_merge', array_slice($filters[$type]['options'], 1));
+ $value = $options[$value];
+ }
+ else {
+ $value = $filters[$type]['options'][$value];
+ }
+ $t_args = array('%property' => $filters[$type]['title'], '%value' => $value);
+ if ($i++) {
+ $form['filters']['current'][] = array('#markup' => t('and where %property is %value', $t_args));
+ }
+ else {
+ $form['filters']['current'][] = array('#markup' => t('%property is %value', $t_args));
+ }
+ }
+
+ $form['filters']['status'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('clearfix')),
+ '#prefix' => ($i ? '<div class="additional-filters">' . t('and where') . '</div>' : ''),
+ );
+ $form['filters']['status']['filters'] = array(
+ '#type' => 'container',
+ '#attributes' => array('class' => array('filters')),
+ );
+ foreach ($filters as $key => $filter) {
+ $form['filters']['status']['filters'][$key] = array(
+ '#type' => 'select',
+ '#options' => $filter['options'],
+ '#title' => $filter['title'],
+ '#default_value' => '[any]',
+ );
+ }
+
+ $form['filters']['status']['actions'] = array(
+ '#type' => 'actions',
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $form['filters']['status']['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => (count($session) ? t('Refine') : t('Filter')),
+ );
+ if (count($session)) {
+ $form['filters']['status']['actions']['undo'] = array(
+ '#type' => 'submit',
+ '#value' => t('Undo'),
+ );
+ $form['filters']['status']['actions']['reset'] = array(
+ '#type' => 'submit',
+ '#value' => t('Reset'),
+ );
+ }
+
+ drupal_add_library('system', 'drupal.form');
+
+ return $form;
+}
+
+/**
+ * Process result from user administration filter form.
+ */
+function user_filter_form_submit($form, &$form_state) {
+ $op = $form_state['values']['op'];
+ $filters = user_filters();
+ switch ($op) {
+ case t('Filter'):
+ case t('Refine'):
+ // Apply every filter that has a choice selected other than 'any'.
+ foreach ($filters as $filter => $options) {
+ if (isset($form_state['values'][$filter]) && $form_state['values'][$filter] != '[any]') {
+ // Merge an array of arrays into one if necessary.
+ $options = ($filter == 'permission') ? form_options_flatten($filters[$filter]['options']) : $filters[$filter]['options'];
+ // Only accept valid selections offered on the dropdown, block bad input.
+ if (isset($options[$form_state['values'][$filter]])) {
+ $_SESSION['user_overview_filter'][] = array($filter, $form_state['values'][$filter]);
+ }
+ }
+ }
+ break;
+ case t('Undo'):
+ array_pop($_SESSION['user_overview_filter']);
+ break;
+ case t('Reset'):
+ $_SESSION['user_overview_filter'] = array();
+ break;
+ case t('Update'):
+ return;
+ }
+
+ $form_state['redirect'] = 'admin/people';
+ return;
+}
+
+/**
+ * Form builder; User administration page.
+ *
+ * @ingroup forms
+ * @see user_admin_account_validate()
+ * @see user_admin_account_submit()
+ */
+function user_admin_account() {
+
+ $header = array(
+ 'username' => array('data' => t('Username'), 'field' => 'u.name'),
+ 'status' => array('data' => t('Status'), 'field' => 'u.status'),
+ 'roles' => array('data' => t('Roles')),
+ 'member_for' => array('data' => t('Member for'), 'field' => 'u.created', 'sort' => 'desc'),
+ 'access' => array('data' => t('Last access'), 'field' => 'u.access'),
+ 'operations' => array('data' => t('Operations')),
+ );
+
+ $query = db_select('users', 'u');
+ $query->condition('u.uid', 0, '<>');
+ user_build_filter_query($query);
+
+ $count_query = clone $query;
+ $count_query->addExpression('COUNT(u.uid)');
+
+ $query = $query->extend('PagerDefault')->extend('TableSort');
+ $query
+ ->fields('u', array('uid', 'name', 'status', 'created', 'access'))
+ ->limit(50)
+ ->orderByHeader($header)
+ ->setCountQuery($count_query);
+ $result = $query->execute();
+
+ $form['options'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Update options'),
+ '#attributes' => array('class' => array('container-inline')),
+ );
+ $options = array();
+ foreach (module_invoke_all('user_operations') as $operation => $array) {
+ $options[$operation] = $array['label'];
+ }
+ $form['options']['operation'] = array(
+ '#type' => 'select',
+ '#title' => t('Operation'),
+ '#title_display' => 'invisible',
+ '#options' => $options,
+ '#default_value' => 'unblock',
+ );
+ $options = array();
+ $form['options']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Update'),
+ );
+
+ $destination = drupal_get_destination();
+
+ $status = array(t('blocked'), t('active'));
+ $roles = array_map('check_plain', user_roles(TRUE));
+ $accounts = array();
+ foreach ($result as $account) {
+ $users_roles = array();
+ $roles_result = db_query('SELECT rid FROM {users_roles} WHERE uid = :uid', array(':uid' => $account->uid));
+ foreach ($roles_result as $user_role) {
+ $users_roles[] = $roles[$user_role->rid];
+ }
+ asort($users_roles);
+
+ $options[$account->uid] = array(
+ 'username' => theme('username', array('account' => $account)),
+ 'status' => $status[$account->status],
+ 'roles' => theme('item_list', array('items' => $users_roles)),
+ 'member_for' => format_interval(REQUEST_TIME - $account->created),
+ 'access' => $account->access ? t('@time ago', array('@time' => format_interval(REQUEST_TIME - $account->access))) : t('never'),
+ 'operations' => array('data' => array('#type' => 'link', '#title' => t('edit'), '#href' => "user/$account->uid/edit", '#options' => array('query' => $destination))),
+ );
+ }
+
+ $form['accounts'] = array(
+ '#type' => 'tableselect',
+ '#header' => $header,
+ '#options' => $options,
+ '#empty' => t('No people available.'),
+ );
+ $form['pager'] = array('#markup' => theme('pager'));
+
+ return $form;
+}
+
+/**
+ * Submit the user administration update form.
+ */
+function user_admin_account_submit($form, &$form_state) {
+ $operations = module_invoke_all('user_operations', $form, $form_state);
+ $operation = $operations[$form_state['values']['operation']];
+ // Filter out unchecked accounts.
+ $accounts = array_filter($form_state['values']['accounts']);
+ if ($function = $operation['callback']) {
+ // Add in callback arguments if present.
+ if (isset($operation['callback arguments'])) {
+ $args = array_merge(array($accounts), $operation['callback arguments']);
+ }
+ else {
+ $args = array($accounts);
+ }
+ call_user_func_array($function, $args);
+
+ drupal_set_message(t('The update has been performed.'));
+ }
+}
+
+function user_admin_account_validate($form, &$form_state) {
+ $form_state['values']['accounts'] = array_filter($form_state['values']['accounts']);
+ if (count($form_state['values']['accounts']) == 0) {
+ form_set_error('', t('No users selected.'));
+ }
+}
+
+/**
+ * Form builder; Configure user settings for this site.
+ *
+ * @ingroup forms
+ * @see system_settings_form()
+ */
+function user_admin_settings() {
+ // Settings for anonymous users.
+ $form['anonymous_settings'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Anonymous users'),
+ );
+ $form['anonymous_settings']['anonymous'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#default_value' => variable_get('anonymous', t('Anonymous')),
+ '#description' => t('The name used to indicate anonymous users.'),
+ '#required' => TRUE,
+ );
+
+ // Administrative role option.
+ $form['admin_role'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Administrator role'),
+ );
+
+ // Do not allow users to set the anonymous or authenticated user roles as the
+ // administrator role.
+ $roles = user_roles();
+ unset($roles[DRUPAL_ANONYMOUS_RID]);
+ unset($roles[DRUPAL_AUTHENTICATED_RID]);
+ $roles[0] = t('disabled');
+
+ $form['admin_role']['user_admin_role'] = array(
+ '#type' => 'select',
+ '#title' => t('Administrator role'),
+ '#default_value' => variable_get('user_admin_role', 0),
+ '#options' => $roles,
+ '#description' => t('This role will be automatically assigned new permissions whenever a module is enabled. Changing this setting will not affect existing permissions.'),
+ );
+
+ // User registration settings.
+ $form['registration_cancellation'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Registration and cancellation'),
+ );
+ $form['registration_cancellation']['user_register'] = array(
+ '#type' => 'radios',
+ '#title' => t('Who can register accounts?'),
+ '#default_value' => variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL),
+ '#options' => array(
+ USER_REGISTER_ADMINISTRATORS_ONLY => t('Administrators only'),
+ USER_REGISTER_VISITORS => t('Visitors'),
+ USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL => t('Visitors, but administrator approval is required'),
+ )
+ );
+ $form['registration_cancellation']['user_email_verification'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Require e-mail verification when a visitor creates an account.'),
+ '#default_value' => variable_get('user_email_verification', TRUE),
+ '#description' => t('New users will be required to validate their e-mail address prior to logging into the site, and will be assigned a system-generated password. With this setting disabled, users will be logged in immediately upon registering, and may select their own passwords during registration.')
+ );
+ module_load_include('inc', 'user', 'user.pages');
+ $form['registration_cancellation']['user_cancel_method'] = array(
+ '#type' => 'item',
+ '#title' => t('When cancelling a user account'),
+ '#description' => t('Users with the %select-cancel-method or %administer-users <a href="@permissions-url">permissions</a> can override this default method.', array('%select-cancel-method' => t('Select method for cancelling account'), '%administer-users' => t('Administer users'), '@permissions-url' => url('admin/people/permissions'))),
+ );
+ $form['registration_cancellation']['user_cancel_method'] += user_cancel_methods();
+ foreach (element_children($form['registration_cancellation']['user_cancel_method']) as $element) {
+ // Remove all account cancellation methods that have #access defined, as
+ // those cannot be configured as default method.
+ if (isset($form['registration_cancellation']['user_cancel_method'][$element]['#access'])) {
+ $form['registration_cancellation']['user_cancel_method'][$element]['#access'] = FALSE;
+ }
+ // Remove the description (only displayed on the confirmation form).
+ else {
+ unset($form['registration_cancellation']['user_cancel_method'][$element]['#description']);
+ }
+ }
+
+ // Account settings.
+ $form['personalization'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Personalization'),
+ );
+ $form['personalization']['user_signatures'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable signatures.'),
+ '#default_value' => variable_get('user_signatures', 0),
+ );
+ // If picture support is enabled, check whether the picture directory exists.
+ if (variable_get('user_pictures', 0)) {
+ $picture_path = file_default_scheme() . '://' . variable_get('user_picture_path', 'pictures');
+ if (!file_prepare_directory($picture_path, FILE_CREATE_DIRECTORY)) {
+ form_set_error('user_picture_path', t('The directory %directory does not exist or is not writable.', array('%directory' => $picture_path)));
+ watchdog('file system', 'The directory %directory does not exist or is not writable.', array('%directory' => $picture_path), WATCHDOG_ERROR);
+ }
+ }
+ $picture_support = variable_get('user_pictures', 0);
+ $form['personalization']['user_pictures'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable user pictures.'),
+ '#default_value' => $picture_support,
+ );
+ drupal_add_js(drupal_get_path('module', 'user') . '/user.js');
+ $form['personalization']['pictures'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the additional picture settings when user pictures are disabled.
+ 'invisible' => array(
+ 'input[name="user_pictures"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+ $form['personalization']['pictures']['user_picture_path'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Picture directory'),
+ '#default_value' => variable_get('user_picture_path', 'pictures'),
+ '#size' => 30,
+ '#maxlength' => 255,
+ '#description' => t('Subdirectory in the file upload directory where pictures will be stored.'),
+ );
+ $form['personalization']['pictures']['user_picture_default'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Default picture'),
+ '#default_value' => variable_get('user_picture_default', ''),
+ '#size' => 30,
+ '#maxlength' => 255,
+ '#description' => t('URL of picture to display for users with no custom picture selected. Leave blank for none.'),
+ );
+ if (module_exists('image')) {
+ $form['personalization']['pictures']['settings']['user_picture_style'] = array(
+ '#type' => 'select',
+ '#title' => t('Picture display style'),
+ '#options' => image_style_options(TRUE),
+ '#default_value' => variable_get('user_picture_style', ''),
+ '#description' => t('The style selected will be used on display, while the original image is retained. Styles may be configured in the <a href="!url">Image styles</a> administration area.', array('!url' => url('admin/config/media/image-styles'))),
+ );
+ }
+ $form['personalization']['pictures']['user_picture_dimensions'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Picture upload dimensions'),
+ '#default_value' => variable_get('user_picture_dimensions', '85x85'),
+ '#size' => 10,
+ '#maxlength' => 10,
+ '#field_suffix' => ' ' . t('pixels'),
+ '#description' => t('Pictures larger than this will be scaled down to this size.'),
+ );
+ $form['personalization']['pictures']['user_picture_file_size'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Picture upload file size'),
+ '#default_value' => variable_get('user_picture_file_size', '30'),
+ '#size' => 10,
+ '#maxlength' => 10,
+ '#field_suffix' => ' ' . t('KB'),
+ '#description' => t('Maximum allowed file size for uploaded pictures. Upload size is normally limited only by the PHP maximum post and file upload settings, and images are automatically scaled down to the dimensions specified above.'),
+ );
+ $form['personalization']['pictures']['user_picture_guidelines'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Picture guidelines'),
+ '#default_value' => variable_get('user_picture_guidelines', ''),
+ '#description' => t("This text is displayed at the picture upload form in addition to the default guidelines. It's useful for helping or instructing your users."),
+ );
+
+ $form['email_title'] = array(
+ '#type' => 'item',
+ '#title' => t('E-mails'),
+ );
+ $form['email'] = array(
+ '#type' => 'vertical_tabs',
+ );
+ // These email tokens are shared for all settings, so just define
+ // the list once to help ensure they stay in sync.
+ $email_token_help = t('Available variables are: [site:name], [site:url], [user:name], [user:mail], [site:login-url], [site:url-brief], [user:edit-url], [user:one-time-login-url], [user:cancel-url].');
+
+ $form['email_admin_created'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Welcome (new user created by administrator)'),
+ '#collapsible' => TRUE,
+ '#collapsed' => (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) != USER_REGISTER_ADMINISTRATORS_ONLY),
+ '#description' => t('Edit the welcome e-mail messages sent to new member accounts created by an administrator.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_admin_created']['user_mail_register_admin_created_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('register_admin_created_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_admin_created']['user_mail_register_admin_created_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('register_admin_created_body', NULL, array(), FALSE),
+ '#rows' => 15,
+ );
+
+ $form['email_pending_approval'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Welcome (awaiting approval)'),
+ '#collapsible' => TRUE,
+ '#collapsed' => (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) != USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL),
+ '#description' => t('Edit the welcome e-mail messages sent to new members upon registering, when administrative approval is required.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_pending_approval']['user_mail_register_pending_approval_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('register_pending_approval_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_pending_approval']['user_mail_register_pending_approval_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('register_pending_approval_body', NULL, array(), FALSE),
+ '#rows' => 8,
+ );
+
+ $form['email_no_approval_required'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Welcome (no approval required)'),
+ '#collapsible' => TRUE,
+ '#collapsed' => (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) != USER_REGISTER_VISITORS),
+ '#description' => t('Edit the welcome e-mail messages sent to new members upon registering, when no administrator approval is required.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_no_approval_required']['user_mail_register_no_approval_required_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('register_no_approval_required_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_no_approval_required']['user_mail_register_no_approval_required_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('register_no_approval_required_body', NULL, array(), FALSE),
+ '#rows' => 15,
+ );
+
+ $form['email_password_reset'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Password recovery'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Edit the e-mail messages sent to users who request a new password.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ '#weight' => 10,
+ );
+ $form['email_password_reset']['user_mail_password_reset_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('password_reset_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_password_reset']['user_mail_password_reset_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('password_reset_body', NULL, array(), FALSE),
+ '#rows' => 12,
+ );
+
+ $form['email_activated'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Account activation'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Enable and edit e-mail messages sent to users upon account activation (when an administrator activates an account of a user who has already registered, on a site where administrative approval is required).') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_activated']['user_mail_status_activated_notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user when account is activated.'),
+ '#default_value' => variable_get('user_mail_status_activated_notify', TRUE),
+ );
+ $form['email_activated']['settings'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the additional settings when this email is disabled.
+ 'invisible' => array(
+ 'input[name="user_mail_status_activated_notify"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+ $form['email_activated']['settings']['user_mail_status_activated_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('status_activated_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_activated']['settings']['user_mail_status_activated_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('status_activated_body', NULL, array(), FALSE),
+ '#rows' => 15,
+ );
+
+ $form['email_blocked'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Account blocked'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Enable and edit e-mail messages sent to users when their accounts are blocked.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_blocked']['user_mail_status_blocked_notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user when account is blocked.'),
+ '#default_value' => variable_get('user_mail_status_blocked_notify', FALSE),
+ );
+ $form['email_blocked']['settings'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the additional settings when the blocked email is disabled.
+ 'invisible' => array(
+ 'input[name="user_mail_status_blocked_notify"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+ $form['email_blocked']['settings']['user_mail_status_blocked_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('status_blocked_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_blocked']['settings']['user_mail_status_blocked_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('status_blocked_body', NULL, array(), FALSE),
+ '#rows' => 3,
+ );
+
+ $form['email_cancel_confirm'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Account cancellation confirmation'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Edit the e-mail messages sent to users when they attempt to cancel their accounts.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_cancel_confirm']['user_mail_cancel_confirm_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('cancel_confirm_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_cancel_confirm']['user_mail_cancel_confirm_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('cancel_confirm_body', NULL, array(), FALSE),
+ '#rows' => 3,
+ );
+
+ $form['email_canceled'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Account canceled'),
+ '#collapsible' => TRUE,
+ '#collapsed' => TRUE,
+ '#description' => t('Enable and edit e-mail messages sent to users when their accounts are canceled.') . ' ' . $email_token_help,
+ '#group' => 'email',
+ );
+ $form['email_canceled']['user_mail_status_canceled_notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user when account is canceled.'),
+ '#default_value' => variable_get('user_mail_status_canceled_notify', FALSE),
+ );
+ $form['email_canceled']['settings'] = array(
+ '#type' => 'container',
+ '#states' => array(
+ // Hide the settings when the cancel notify checkbox is disabled.
+ 'invisible' => array(
+ 'input[name="user_mail_status_canceled_notify"]' => array('checked' => FALSE),
+ ),
+ ),
+ );
+ $form['email_canceled']['settings']['user_mail_status_canceled_subject'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Subject'),
+ '#default_value' => _user_mail_text('status_canceled_subject', NULL, array(), FALSE),
+ '#maxlength' => 180,
+ );
+ $form['email_canceled']['settings']['user_mail_status_canceled_body'] = array(
+ '#type' => 'textarea',
+ '#title' => t('Body'),
+ '#default_value' => _user_mail_text('status_canceled_body', NULL, array(), FALSE),
+ '#rows' => 3,
+ );
+
+ return system_settings_form($form);
+}
+
+/**
+ * Menu callback: administer permissions.
+ *
+ * @ingroup forms
+ * @see user_admin_permissions_submit()
+ * @see theme_user_admin_permissions()
+ */
+function user_admin_permissions($form, $form_state, $rid = NULL) {
+
+ // Retrieve role names for columns.
+ $role_names = user_roles();
+ if (is_numeric($rid)) {
+ $role_names = array($rid => $role_names[$rid]);
+ }
+ // Fetch permissions for all roles or the one selected role.
+ $role_permissions = user_role_permissions($role_names);
+
+ // Store $role_names for use when saving the data.
+ $form['role_names'] = array(
+ '#type' => 'value',
+ '#value' => $role_names,
+ );
+ // Render role/permission overview:
+ $options = array();
+ $module_info = system_get_info('module');
+ $hide_descriptions = system_admin_compact_mode();
+
+ // Get a list of all the modules implementing a hook_permission() and sort by
+ // display name.
+ $modules = array();
+ foreach (module_implements('permission') as $module) {
+ $modules[$module] = $module_info[$module]['name'];
+ }
+ asort($modules);
+
+ foreach ($modules as $module => $display_name) {
+ if ($permissions = module_invoke($module, 'permission')) {
+ $form['permission'][] = array(
+ '#markup' => $module_info[$module]['name'],
+ '#id' => $module,
+ );
+ foreach ($permissions as $perm => $perm_item) {
+ // Fill in default values for the permission.
+ $perm_item += array(
+ 'description' => '',
+ 'restrict access' => FALSE,
+ 'warning' => !empty($perm_item['restrict access']) ? t('Warning: Give to trusted roles only; this permission has security implications.') : '',
+ );
+ $options[$perm] = '';
+ $form['permission'][$perm] = array(
+ '#type' => 'item',
+ '#markup' => $perm_item['title'],
+ '#description' => theme('user_permission_description', array('permission_item' => $perm_item, 'hide' => $hide_descriptions)),
+ );
+ foreach ($role_names as $rid => $name) {
+ // Builds arrays for checked boxes for each role
+ if (isset($role_permissions[$rid][$perm])) {
+ $status[$rid][] = $perm;
+ }
+ }
+ }
+ }
+ }
+
+ // Have to build checkboxes here after checkbox arrays are built
+ foreach ($role_names as $rid => $name) {
+ $form['checkboxes'][$rid] = array(
+ '#type' => 'checkboxes',
+ '#options' => $options,
+ '#default_value' => isset($status[$rid]) ? $status[$rid] : array(),
+ '#attributes' => array('class' => array('rid-' . $rid)),
+ );
+ $form['role_names'][$rid] = array('#markup' => check_plain($name), '#tree' => TRUE);
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Save permissions'));
+
+ $form['#attached']['js'][] = drupal_get_path('module', 'user') . '/user.permissions.js';
+
+ return $form;
+}
+
+/**
+ * Save permissions selected on the administer permissions page.
+ *
+ * @see user_admin_permissions()
+ */
+function user_admin_permissions_submit($form, &$form_state) {
+ foreach ($form_state['values']['role_names'] as $rid => $name) {
+ user_role_change_permissions($rid, $form_state['values'][$rid]);
+ }
+
+ drupal_set_message(t('The changes have been saved.'));
+
+ // Clear the cached pages and blocks.
+ cache_clear_all();
+}
+
+/**
+ * Returns HTML for the administer permissions page.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_user_admin_permissions($variables) {
+ $form = $variables['form'];
+
+ $roles = user_roles();
+ foreach (element_children($form['permission']) as $key) {
+ $row = array();
+ // Module name
+ if (is_numeric($key)) {
+ $row[] = array('data' => drupal_render($form['permission'][$key]), 'class' => array('module'), 'id' => 'module-' . $form['permission'][$key]['#id'], 'colspan' => count($form['role_names']['#value']) + 1);
+ }
+ else {
+ // Permission row.
+ $row[] = array(
+ 'data' => drupal_render($form['permission'][$key]),
+ 'class' => array('permission'),
+ );
+ foreach (element_children($form['checkboxes']) as $rid) {
+ $form['checkboxes'][$rid][$key]['#title'] = $roles[$rid] . ': ' . $form['permission'][$key]['#markup'];
+ $form['checkboxes'][$rid][$key]['#title_display'] = 'invisible';
+ $row[] = array('data' => drupal_render($form['checkboxes'][$rid][$key]), 'class' => array('checkbox'));
+ }
+ }
+ $rows[] = $row;
+ }
+ $header[] = (t('Permission'));
+ foreach (element_children($form['role_names']) as $rid) {
+ $header[] = array('data' => drupal_render($form['role_names'][$rid]), 'class' => array('checkbox'));
+ }
+ $output = theme('system_compact_link');
+ $output .= theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'permissions')));
+ $output .= drupal_render_children($form);
+ return $output;
+}
+
+/**
+ * Returns HTML for an individual permission description.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - permission_item: An associative array representing the permission whose
+ * description is being themed. Useful keys include:
+ * - description: The text of the permission description.
+ * - warning: A security-related warning message about the permission (if
+ * there is one).
+ * - hide: A boolean indicating whether or not the permission description was
+ * requested to be hidden rather than shown.
+ *
+ * @ingroup themeable
+ */
+function theme_user_permission_description($variables) {
+ if (!$variables['hide']) {
+ $description = array();
+ $permission_item = $variables['permission_item'];
+ if (!empty($permission_item['description'])) {
+ $description[] = $permission_item['description'];
+ }
+ if (!empty($permission_item['warning'])) {
+ $description[] = '<em class="permission-warning">' . $permission_item['warning'] . '</em>';
+ }
+ if (!empty($description)) {
+ return implode(' ', $description);
+ }
+ }
+}
+
+/**
+ * Form to re-order roles or add a new one.
+ *
+ * @ingroup forms
+ * @see theme_user_admin_roles()
+ */
+function user_admin_roles($form, $form_state) {
+ $roles = user_roles();
+
+ $form['roles'] = array(
+ '#tree' => TRUE,
+ );
+ $order = 0;
+ foreach ($roles as $rid => $name) {
+ $form['roles'][$rid]['#role'] = (object) array(
+ 'rid' => $rid,
+ 'name' => $name,
+ 'weight' => $order,
+ );
+ $form['roles'][$rid]['#weight'] = $order;
+ $form['roles'][$rid]['weight'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Weight for @title', array('@title' => $name)),
+ '#title_display' => 'invisible',
+ '#size' => 4,
+ '#default_value' => $order,
+ '#attributes' => array('class' => array('role-weight')),
+ );
+ $order++;
+ }
+
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Name'),
+ '#title_display' => 'invisible',
+ '#size' => 32,
+ '#maxlength' => 64,
+ );
+ $form['add'] = array(
+ '#type' => 'submit',
+ '#value' => t('Add role'),
+ '#validate' => array('user_admin_role_validate'),
+ '#submit' => array('user_admin_role_submit'),
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save order'),
+ '#submit' => array('user_admin_roles_order_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form submit function. Update the role weights.
+ */
+function user_admin_roles_order_submit($form, &$form_state) {
+ foreach ($form_state['values']['roles'] as $rid => $role_values) {
+ $role = $form['roles'][$rid]['#role'];
+ $role->weight = $role_values['weight'];
+ user_role_save($role);
+ }
+ drupal_set_message(t('The role settings have been updated.'));
+}
+
+/**
+ * Returns HTML for the role order and new role form.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - form: A render element representing the form.
+ *
+ * @ingroup themeable
+ */
+function theme_user_admin_roles($variables) {
+ $form = $variables['form'];
+
+ $header = array(t('Name'), t('Weight'), array('data' => t('Operations'), 'colspan' => 2));
+ foreach (element_children($form['roles']) as $rid) {
+ $name = $form['roles'][$rid]['#role']->name;
+ $row = array();
+ if (in_array($rid, array(DRUPAL_ANONYMOUS_RID, DRUPAL_AUTHENTICATED_RID))) {
+ $row[] = t('@name <em>(locked)</em>', array('@name' => $name));
+ $row[] = drupal_render($form['roles'][$rid]['weight']);
+ $row[] = '';
+ $row[] = l(t('edit permissions'), 'admin/people/permissions/' . $rid);
+ }
+ else {
+ $row[] = check_plain($name);
+ $row[] = drupal_render($form['roles'][$rid]['weight']);
+ $row[] = l(t('edit role'), 'admin/people/permissions/roles/edit/' . $rid);
+ $row[] = l(t('edit permissions'), 'admin/people/permissions/' . $rid);
+ }
+ $rows[] = array('data' => $row, 'class' => array('draggable'));
+ }
+ $rows[] = array(array('data' => drupal_render($form['name']) . drupal_render($form['add']), 'colspan' => 4, 'class' => 'edit-name'));
+
+ drupal_add_tabledrag('user-roles', 'order', 'sibling', 'role-weight');
+
+ $output = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => array('id' => 'user-roles')));
+ $output .= drupal_render_children($form);
+
+ return $output;
+}
+
+/**
+ * Form to configure a single role.
+ *
+ * @ingroup forms
+ * @see user_admin_role_validate()
+ * @see user_admin_role_submit()
+ */
+function user_admin_role($form, $form_state, $role) {
+ if ($role->rid == DRUPAL_ANONYMOUS_RID || $role->rid == DRUPAL_AUTHENTICATED_RID) {
+ drupal_goto('admin/people/permissions/roles');
+ }
+
+ // Display the edit role form.
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Role name'),
+ '#default_value' => $role->name,
+ '#size' => 30,
+ '#required' => TRUE,
+ '#maxlength' => 64,
+ '#description' => t('The name for this role. Example: "moderator", "editorial board", "site architect".'),
+ );
+ $form['rid'] = array(
+ '#type' => 'value',
+ '#value' => $role->rid,
+ );
+ $form['weight'] = array(
+ '#type' => 'value',
+ '#value' => $role->weight,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save role'),
+ );
+ $form['actions']['delete'] = array(
+ '#type' => 'submit',
+ '#value' => t('Delete role'),
+ '#submit' => array('user_admin_role_delete_submit'),
+ );
+
+ return $form;
+}
+
+/**
+ * Form validation handler for the user_admin_role() form.
+ */
+function user_admin_role_validate($form, &$form_state) {
+ if (!empty($form_state['values']['name'])) {
+ if ($form_state['values']['op'] == t('Save role')) {
+ $role = user_role_load_by_name($form_state['values']['name']);
+ if ($role && $role->rid != $form_state['values']['rid']) {
+ form_set_error('name', t('The role name %name already exists. Choose another role name.', array('%name' => $form_state['values']['name'])));
+ }
+ }
+ elseif ($form_state['values']['op'] == t('Add role')) {
+ if (user_role_load_by_name($form_state['values']['name'])) {
+ form_set_error('name', t('The role name %name already exists. Choose another role name.', array('%name' => $form_state['values']['name'])));
+ }
+ }
+ }
+ else {
+ form_set_error('name', t('You must specify a valid role name.'));
+ }
+}
+
+/**
+ * Form submit handler for the user_admin_role() form.
+ */
+function user_admin_role_submit($form, &$form_state) {
+ $role = (object) $form_state['values'];
+ if ($form_state['values']['op'] == t('Save role')) {
+ user_role_save($role);
+ drupal_set_message(t('The role has been renamed.'));
+ }
+ elseif ($form_state['values']['op'] == t('Add role')) {
+ user_role_save($role);
+ drupal_set_message(t('The role has been added.'));
+ }
+ $form_state['redirect'] = 'admin/people/permissions/roles';
+ return;
+}
+
+/**
+ * Form submit handler for the user_admin_role() form.
+ */
+function user_admin_role_delete_submit($form, &$form_state) {
+ $form_state['redirect'] = 'admin/people/permissions/roles/delete/' . $form_state['values']['rid'];
+}
+
+/**
+ * Form to confirm role delete operation.
+ */
+function user_admin_role_delete_confirm($form, &$form_state, $role) {
+ $form['rid'] = array(
+ '#type' => 'value',
+ '#value' => $role->rid,
+ );
+ return confirm_form($form, t('Are you sure you want to delete the role %name ?', array('%name' => $role->name)), 'admin/people/permissions/roles', t('This action cannot be undone.'), t('Delete'));
+}
+
+/**
+ * Form submit handler for user_admin_role_delete_confirm().
+ */
+function user_admin_role_delete_confirm_submit($form, &$form_state) {
+ user_role_delete((int) $form_state['values']['rid']);
+ drupal_set_message(t('The role has been deleted.'));
+ $form_state['redirect'] = 'admin/people/permissions/roles';
+}
+
diff --git a/core/modules/user/user.api.php b/core/modules/user/user.api.php
new file mode 100644
index 000000000000..0b4f38f054a3
--- /dev/null
+++ b/core/modules/user/user.api.php
@@ -0,0 +1,460 @@
+<?php
+
+/**
+ * @file
+ * Hooks provided by the User module.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Act on user objects when loaded from the database.
+ *
+ * Due to the static cache in user_load_multiple() you should not use this
+ * hook to modify the user properties returned by the {users} table itself
+ * since this may result in unreliable results when loading from cache.
+ *
+ * @param $users
+ * An array of user objects, indexed by uid.
+ *
+ * @see user_load_multiple()
+ * @see profile_user_load()
+ */
+function hook_user_load($users) {
+ $result = db_query('SELECT uid, foo FROM {my_table} WHERE uid IN (:uids)', array(':uids' => array_keys($users)));
+ foreach ($result as $record) {
+ $users[$record->uid]->foo = $record->foo;
+ }
+}
+
+/**
+ * Respond to user deletion.
+ *
+ * This hook is invoked from user_delete_multiple() before field_attach_delete()
+ * is called and before users are actually removed from the database.
+ *
+ * Modules should additionally implement hook_user_cancel() to process stored
+ * user data for other account cancellation methods.
+ *
+ * @param $account
+ * The account that is being deleted.
+ *
+ * @see user_delete_multiple()
+ */
+function hook_user_delete($account) {
+ db_delete('mytable')
+ ->condition('uid', $account->uid)
+ ->execute();
+}
+
+/**
+ * Act on user account cancellations.
+ *
+ * This hook is invoked from user_cancel() before a user account is canceled.
+ * Depending on the account cancellation method, the module should either do
+ * nothing, unpublish content, or anonymize content. See user_cancel_methods()
+ * for the list of default account cancellation methods provided by User module.
+ * Modules may add further methods via hook_user_cancel_methods_alter().
+ *
+ * This hook is NOT invoked for the 'user_cancel_delete' account cancellation
+ * method. To react on this method, implement hook_user_delete() instead.
+ *
+ * Expensive operations should be added to the global account cancellation batch
+ * by using batch_set().
+ *
+ * @param $edit
+ * The array of form values submitted by the user.
+ * @param $account
+ * The user object on which the operation is being performed.
+ * @param $method
+ * The account cancellation method.
+ *
+ * @see user_cancel_methods()
+ * @see hook_user_cancel_methods_alter()
+ */
+function hook_user_cancel($edit, $account, $method) {
+ switch ($method) {
+ case 'user_cancel_block_unpublish':
+ // Unpublish nodes (current revisions).
+ module_load_include('inc', 'node', 'node.admin');
+ $nodes = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->condition('uid', $account->uid)
+ ->execute()
+ ->fetchCol();
+ node_mass_update($nodes, array('status' => 0));
+ break;
+
+ case 'user_cancel_reassign':
+ // Anonymize nodes (current revisions).
+ module_load_include('inc', 'node', 'node.admin');
+ $nodes = db_select('node', 'n')
+ ->fields('n', array('nid'))
+ ->condition('uid', $account->uid)
+ ->execute()
+ ->fetchCol();
+ node_mass_update($nodes, array('uid' => 0));
+ // Anonymize old revisions.
+ db_update('node_revision')
+ ->fields(array('uid' => 0))
+ ->condition('uid', $account->uid)
+ ->execute();
+ // Clean history.
+ db_delete('history')
+ ->condition('uid', $account->uid)
+ ->execute();
+ break;
+ }
+}
+
+/**
+ * Modify account cancellation methods.
+ *
+ * By implementing this hook, modules are able to add, customize, or remove
+ * account cancellation methods. All defined methods are turned into radio
+ * button form elements by user_cancel_methods() after this hook is invoked.
+ * The following properties can be defined for each method:
+ * - title: The radio button's title.
+ * - description: (optional) A description to display on the confirmation form
+ * if the user is not allowed to select the account cancellation method. The
+ * description is NOT used for the radio button, but instead should provide
+ * additional explanation to the user seeking to cancel their account.
+ * - access: (optional) A boolean value indicating whether the user can access
+ * a method. If #access is defined, the method cannot be configured as default
+ * method.
+ *
+ * @param $methods
+ * An array containing user account cancellation methods, keyed by method id.
+ *
+ * @see user_cancel_methods()
+ * @see user_cancel_confirm_form()
+ */
+function hook_user_cancel_methods_alter(&$methods) {
+ // Limit access to disable account and unpublish content method.
+ $methods['user_cancel_block_unpublish']['access'] = user_access('administer site configuration');
+
+ // Remove the content re-assigning method.
+ unset($methods['user_cancel_reassign']);
+
+ // Add a custom zero-out method.
+ $methods['mymodule_zero_out'] = array(
+ 'title' => t('Delete the account and remove all content.'),
+ 'description' => t('All your content will be replaced by empty strings.'),
+ // access should be used for administrative methods only.
+ 'access' => user_access('access zero-out account cancellation method'),
+ );
+}
+
+/**
+ * Add mass user operations.
+ *
+ * This hook enables modules to inject custom operations into the mass operations
+ * dropdown found at admin/people, by associating a callback function with
+ * the operation, which is called when the form is submitted. The callback function
+ * receives one initial argument, which is an array of the checked users.
+ *
+ * @return
+ * An array of operations. Each operation is an associative array that may
+ * contain the following key-value pairs:
+ * - "label": Required. The label for the operation, displayed in the dropdown menu.
+ * - "callback": Required. The function to call for the operation.
+ * - "callback arguments": Optional. An array of additional arguments to pass to
+ * the callback function.
+ *
+ */
+function hook_user_operations() {
+ $operations = array(
+ 'unblock' => array(
+ 'label' => t('Unblock the selected users'),
+ 'callback' => 'user_user_operations_unblock',
+ ),
+ 'block' => array(
+ 'label' => t('Block the selected users'),
+ 'callback' => 'user_user_operations_block',
+ ),
+ 'cancel' => array(
+ 'label' => t('Cancel the selected user accounts'),
+ ),
+ );
+ return $operations;
+}
+
+/**
+ * Retrieve a list of user setting or profile information categories.
+ *
+ * @return
+ * An array of associative arrays. Each inner array has elements:
+ * - "name": The internal name of the category.
+ * - "title": The human-readable, localized name of the category.
+ * - "weight": An integer specifying the category's sort ordering.
+ * - "access callback": Name of the access callback function to use to
+ * determine whether the user can edit the category. Defaults to using
+ * user_edit_access(). See hook_menu() for more information on access
+ * callbacks.
+ * - "access arguments": Arguments for the access callback function. Defaults
+ * to array(1).
+ */
+function hook_user_categories() {
+ return array(array(
+ 'name' => 'account',
+ 'title' => t('Account settings'),
+ 'weight' => 1,
+ ));
+}
+
+/**
+ * A user account is about to be created or updated.
+ *
+ * This hook is primarily intended for modules that want to store properties in
+ * the serialized {users}.data column, which is automatically loaded whenever a
+ * user account object is loaded, modules may add to $edit['data'] in order
+ * to have their data serialized on save.
+ *
+ * @param $edit
+ * The array of form values submitted by the user.
+ * @param $account
+ * The user object on which the operation is performed.
+ * @param $category
+ * The active category of user information being edited.
+ *
+ * @see hook_user_insert()
+ * @see hook_user_update()
+ */
+function hook_user_presave(&$edit, $account, $category) {
+ // Make sure that our form value 'mymodule_foo' is stored as 'mymodule_bar'.
+ if (isset($edit['mymodule_foo'])) {
+ $edit['data']['my_module_foo'] = $edit['my_module_foo'];
+ }
+}
+
+/**
+ * A user account was created.
+ *
+ * The module should save its custom additions to the user object into the
+ * database.
+ *
+ * @param $edit
+ * The array of form values submitted by the user.
+ * @param $account
+ * The user object on which the operation is being performed.
+ * @param $category
+ * The active category of user information being edited.
+ *
+ * @see hook_user_presave()
+ * @see hook_user_update()
+ */
+function hook_user_insert(&$edit, $account, $category) {
+ db_insert('mytable')
+ ->fields(array(
+ 'myfield' => $edit['myfield'],
+ 'uid' => $account->uid,
+ ))
+ ->execute();
+}
+
+/**
+ * A user account was updated.
+ *
+ * Modules may use this hook to update their user data in a custom storage
+ * after a user account has been updated.
+ *
+ * @param $edit
+ * The array of form values submitted by the user.
+ * @param $account
+ * The user object on which the operation is performed.
+ * @param $category
+ * The active category of user information being edited.
+ *
+ * @see hook_user_presave()
+ * @see hook_user_insert()
+ */
+function hook_user_update(&$edit, $account, $category) {
+ db_insert('user_changes')
+ ->fields(array(
+ 'uid' => $account->uid,
+ 'changed' => time(),
+ ))
+ ->execute();
+}
+
+/**
+ * The user just logged in.
+ *
+ * @param $edit
+ * The array of form values submitted by the user.
+ * @param $account
+ * The user object on which the operation was just performed.
+ */
+function hook_user_login(&$edit, $account) {
+ // If the user has a NULL time zone, notify them to set a time zone.
+ if (!$account->timezone && variable_get('configurable_timezones', 1) && variable_get('empty_timezone_message', 0)) {
+ drupal_set_message(t('Configure your <a href="@user-edit">account time zone setting</a>.', array('@user-edit' => url("user/$account->uid/edit", array('query' => drupal_get_destination(), 'fragment' => 'edit-timezone')))));
+ }
+}
+
+/**
+ * The user just logged out.
+ *
+ * @param $account
+ * The user object on which the operation was just performed.
+ */
+function hook_user_logout($account) {
+ db_insert('logouts')
+ ->fields(array(
+ 'uid' => $account->uid,
+ 'time' => time(),
+ ))
+ ->execute();
+}
+
+/**
+ * The user's account information is being displayed.
+ *
+ * The module should format its custom additions for display and add them to the
+ * $account->content array.
+ *
+ * @param $account
+ * The user object on which the operation is being performed.
+ * @param $view_mode
+ * View mode, e.g. 'full'.
+ * @param $langcode
+ * The language code used for rendering.
+ *
+ * @see hook_user_view_alter()
+ * @see hook_entity_view()
+ */
+function hook_user_view($account, $view_mode, $langcode) {
+ $account->content['user_picture'] = array(
+ '#markup' => theme('user_picture', array('account' => $account)),
+ '#weight' => -10,
+ );
+ if (!isset($account->content['summary'])) {
+ $account->content['summary'] = array();
+ }
+ $account->content['summary'] += array(
+ '#type' => 'user_profile_category',
+ '#attributes' => array('class' => array('user-member')),
+ '#weight' => 5,
+ '#title' => t('History'),
+ );
+ $account->content['summary']['member_for'] = array(
+ '#type' => 'user_profile_item',
+ '#title' => t('Member for'),
+ '#markup' => format_interval(REQUEST_TIME - $account->created),
+ );
+}
+
+/**
+ * The user was built; the module may modify the structured content.
+ *
+ * This hook is called after the content has been assembled in a structured array
+ * and may be used for doing processing which requires that the complete user
+ * content structure has been built.
+ *
+ * If the module wishes to act on the rendered HTML of the user rather than the
+ * structured content array, it may use this hook to add a #post_render callback.
+ * Alternatively, it could also implement hook_preprocess_user_profile(). See
+ * drupal_render() and theme() documentation respectively for details.
+ *
+ * @param $build
+ * A renderable array representing the user.
+ *
+ * @see user_view()
+ * @see hook_entity_view_alter()
+ */
+function hook_user_view_alter(&$build) {
+ // Check for the existence of a field added by another module.
+ if (isset($build['an_additional_field'])) {
+ // Change its weight.
+ $build['an_additional_field']['#weight'] = -10;
+ }
+
+ // Add a #post_render callback to act on the rendered HTML of the user.
+ $build['#post_render'][] = 'my_module_user_post_render';
+}
+
+/**
+ * Inform other modules that a user role is about to be saved.
+ *
+ * Modules implementing this hook can act on the user role object before
+ * it has been saved to the database.
+ *
+ * @param $role
+ * A user role object.
+ *
+ * @see hook_user_role_insert()
+ * @see hook_user_role_update()
+ */
+function hook_user_role_presave($role) {
+ // Set a UUID for the user role if it doesn't already exist
+ if (empty($role->uuid)) {
+ $role->uuid = uuid_uuid();
+ }
+}
+
+/**
+ * Inform other modules that a user role has been added.
+ *
+ * Modules implementing this hook can act on the user role object when saved to
+ * the database. It's recommended that you implement this hook if your module
+ * adds additional data to user roles object. The module should save its custom
+ * additions to the database.
+ *
+ * @param $role
+ * A user role object.
+ */
+function hook_user_role_insert($role) {
+ // Save extra fields provided by the module to user roles.
+ db_insert('my_module_table')
+ ->fields(array(
+ 'rid' => $role->rid,
+ 'role_description' => $role->description,
+ ))
+ ->execute();
+}
+
+/**
+ * Inform other modules that a user role has been updated.
+ *
+ * Modules implementing this hook can act on the user role object when updated.
+ * It's recommended that you implement this hook if your module adds additional
+ * data to user roles object. The module should save its custom additions to
+ * the database.
+ *
+ * @param $role
+ * A user role object.
+ */
+function hook_user_role_update($role) {
+ // Save extra fields provided by the module to user roles.
+ db_merge('my_module_table')
+ ->key(array('rid' => $role->rid))
+ ->fields(array(
+ 'role_description' => $role->description
+ ))
+ ->execute();
+}
+
+/**
+ * Inform other modules that a user role has been deleted.
+ *
+ * This hook allows you act when a user role has been deleted.
+ * If your module stores references to roles, it's recommended that you
+ * implement this hook and delete existing instances of the deleted role
+ * in your module database tables.
+ *
+ * @param $role
+ * The $role object being deleted.
+ */
+function hook_user_role_delete($role) {
+ // Delete existing instances of the deleted role.
+ db_delete('my_module_table')
+ ->condition('rid', $role->rid)
+ ->execute();
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/core/modules/user/user.css b/core/modules/user/user.css
new file mode 100644
index 000000000000..a3033073345e
--- /dev/null
+++ b/core/modules/user/user.css
@@ -0,0 +1,102 @@
+
+#permissions td.module {
+ font-weight: bold;
+}
+#permissions td.permission {
+ padding-left: 1.5em; /* LTR */
+}
+#permissions tr.odd .form-item,
+#permissions tr.even .form-item {
+ white-space: normal;
+}
+#user-admin-settings fieldset .fieldset-description {
+ font-size: 0.85em;
+ padding-bottom: .5em;
+}
+
+/**
+ * Override default textfield float to put the "Add role" button next to
+ * the input textfield.
+ */
+#user-admin-roles td.edit-name {
+ clear: both;
+}
+#user-admin-roles .form-item-name {
+ float: left; /* LTR */
+ margin-right: 1em; /* LTR */
+}
+
+/**
+ * Password strength indicator.
+ */
+.password-strength {
+ width: 17em;
+ float: right; /* LTR */
+ margin-top: 1.4em;
+}
+.password-strength-title {
+ display: inline;
+}
+.password-strength-text {
+ float: right; /* LTR */
+ font-weight: bold;
+}
+.password-indicator {
+ background-color: #C4C4C4;
+ height: 0.3em;
+ width: 100%;
+}
+.password-indicator div {
+ height: 100%;
+ width: 0%;
+ background-color: #47C965;
+}
+input.password-confirm,
+input.password-field {
+ width: 16em;
+ margin-bottom: 0.4em;
+}
+div.password-confirm {
+ float: right; /* LTR */
+ margin-top: 1.5em;
+ visibility: hidden;
+ width: 17em;
+}
+div.form-item div.password-suggestions {
+ padding: 0.2em 0.5em;
+ margin: 0.7em 0;
+ width: 38.5em;
+ border: 1px solid #B4B4B4;
+}
+div.password-suggestions ul {
+ margin-bottom: 0;
+}
+.confirm-parent,
+.password-parent {
+ clear: left; /* LTR */
+ margin: 0;
+ width: 36.3em;
+}
+
+/* Generated by user.module but used by profile.module: */
+.profile {
+ clear: both;
+ margin: 1em 0;
+}
+.profile .user-picture {
+ float: right; /* LTR */
+ margin: 0 1em 1em 0; /* LTR */
+}
+.profile h2 {
+ border-bottom: 1px solid #ccc;
+}
+.profile dl {
+ margin: 0 0 1.5em 0;
+}
+.profile dt {
+ margin: 0 0 0.2em 0;
+ font-weight: bold;
+}
+.profile dd {
+ margin: 0 0 1em 0;
+}
diff --git a/core/modules/user/user.entity.inc b/core/modules/user/user.entity.inc
new file mode 100644
index 000000000000..5549c7707188
--- /dev/null
+++ b/core/modules/user/user.entity.inc
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @file Controller class for users.
+ */
+
+/**
+ * Controller class for users.
+ *
+ * This extends the DrupalDefaultEntityController class, adding required
+ * special handling for user objects.
+ */
+class UserController extends DrupalDefaultEntityController {
+
+ function attachLoad(&$queried_users, $revision_id = FALSE) {
+ // Build an array of user picture IDs so that these can be fetched later.
+ $picture_fids = array();
+ foreach ($queried_users as $key => $record) {
+ $picture_fids[] = $record->picture;
+ $queried_users[$key]->data = unserialize($record->data);
+ $queried_users[$key]->roles = array();
+ if ($record->uid) {
+ $queried_users[$record->uid]->roles[DRUPAL_AUTHENTICATED_RID] = 'authenticated user';
+ }
+ else {
+ $queried_users[$record->uid]->roles[DRUPAL_ANONYMOUS_RID] = 'anonymous user';
+ }
+ }
+
+ // Add any additional roles from the database.
+ $result = db_query('SELECT r.rid, r.name, ur.uid FROM {role} r INNER JOIN {users_roles} ur ON ur.rid = r.rid WHERE ur.uid IN (:uids)', array(':uids' => array_keys($queried_users)));
+ foreach ($result as $record) {
+ $queried_users[$record->uid]->roles[$record->rid] = $record->name;
+ }
+
+ // Add the full file objects for user pictures if enabled.
+ if (!empty($picture_fids) && variable_get('user_pictures', 1) == 1) {
+ $pictures = file_load_multiple($picture_fids);
+ foreach ($queried_users as $account) {
+ if (!empty($account->picture) && isset($pictures[$account->picture])) {
+ $account->picture = $pictures[$account->picture];
+ }
+ else {
+ $account->picture = NULL;
+ }
+ }
+ }
+ // Call the default attachLoad() method. This will add fields and call
+ // hook_user_load().
+ parent::attachLoad($queried_users, $revision_id);
+ }
+}
diff --git a/core/modules/user/user.info b/core/modules/user/user.info
new file mode 100644
index 000000000000..d887352760e4
--- /dev/null
+++ b/core/modules/user/user.info
@@ -0,0 +1,10 @@
+name = User
+description = Manages the user registration and login system.
+package = Core
+version = VERSION
+core = 8.x
+files[] = user.entity.inc
+files[] = user.test
+required = TRUE
+configure = admin/config/people
+stylesheets[all][] = user.css
diff --git a/core/modules/user/user.install b/core/modules/user/user.install
new file mode 100644
index 000000000000..370427f86be6
--- /dev/null
+++ b/core/modules/user/user.install
@@ -0,0 +1,337 @@
+<?php
+
+/**
+ * @file
+ * Install, update and uninstall functions for the user module.
+ */
+
+/**
+ * Implements hook_schema().
+ */
+function user_schema() {
+ $schema['authmap'] = array(
+ 'description' => 'Stores distributed authentication mapping.',
+ 'fields' => array(
+ 'aid' => array(
+ 'description' => 'Primary Key: Unique authmap ID.',
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ ),
+ 'uid' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "User's {users}.uid.",
+ ),
+ 'authname' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Unique authentication name.',
+ ),
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Module which is controlling the authentication.',
+ ),
+ ),
+ 'unique keys' => array(
+ 'authname' => array('authname'),
+ ),
+ 'primary key' => array('aid'),
+ 'foreign keys' => array(
+ 'user' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ ),
+ );
+
+ $schema['role_permission'] = array(
+ 'description' => 'Stores the permissions assigned to user roles.',
+ 'fields' => array(
+ 'rid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Foreign Key: {role}.rid.',
+ ),
+ 'permission' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'A single permission granted to the role identified by rid.',
+ ),
+ 'module' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "The module declaring the permission.",
+ ),
+ ),
+ 'primary key' => array('rid', 'permission'),
+ 'indexes' => array(
+ 'permission' => array('permission'),
+ ),
+ 'foreign keys' => array(
+ 'role' => array(
+ 'table' => 'roles',
+ 'columns' => array('rid' => 'rid'),
+ ),
+ ),
+ );
+
+ $schema['role'] = array(
+ 'description' => 'Stores user roles.',
+ 'fields' => array(
+ 'rid' => array(
+ 'type' => 'serial',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique role ID.',
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 64,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Unique role name.',
+ 'translatable' => TRUE,
+ ),
+ 'weight' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'The weight of this role in listings and the user interface.',
+ ),
+ ),
+ 'unique keys' => array(
+ 'name' => array('name'),
+ ),
+ 'primary key' => array('rid'),
+ 'indexes' => array(
+ 'name_weight' => array('name', 'weight'),
+ ),
+ );
+
+ $schema['users'] = array(
+ 'description' => 'Stores user data.',
+ 'fields' => array(
+ 'uid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'description' => 'Primary Key: Unique user ID.',
+ 'default' => 0,
+ ),
+ 'name' => array(
+ 'type' => 'varchar',
+ 'length' => 60,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => 'Unique user name.',
+ ),
+ 'pass' => array(
+ 'type' => 'varchar',
+ 'length' => 128,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "User's password (hashed).",
+ ),
+ 'mail' => array(
+ 'type' => 'varchar',
+ 'length' => 254,
+ 'not null' => FALSE,
+ 'default' => '',
+ 'description' => "User's e-mail address.",
+ ),
+ 'theme' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "User's default theme.",
+ ),
+ 'signature' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "User's signature.",
+ ),
+ 'signature_format' => array(
+ 'type' => 'varchar',
+ 'length' => 255,
+ 'not null' => FALSE,
+ 'description' => 'The {filter_format}.format of the signature.',
+ ),
+ 'created' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Timestamp for when user was created.',
+ ),
+ 'access' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Timestamp for previous time user accessed the site.',
+ ),
+ 'login' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "Timestamp for user's last login.",
+ ),
+ 'status' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'size' => 'tiny',
+ 'description' => 'Whether the user is active(1) or blocked(0).',
+ ),
+ 'timezone' => array(
+ 'type' => 'varchar',
+ 'length' => 32,
+ 'not null' => FALSE,
+ 'description' => "User's time zone.",
+ ),
+ 'language' => array(
+ 'type' => 'varchar',
+ 'length' => 12,
+ 'not null' => TRUE,
+ 'default' => '',
+ 'description' => "User's default language.",
+ ),
+ 'picture' => array(
+ 'type' => 'int',
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => "Foreign key: {file_managed}.fid of user's picture.",
+ ),
+ 'init' => array(
+ 'type' => 'varchar',
+ 'length' => 254,
+ 'not null' => FALSE,
+ 'default' => '',
+ 'description' => 'E-mail address used for initial account creation.',
+ ),
+ 'data' => array(
+ 'type' => 'blob',
+ 'not null' => FALSE,
+ 'size' => 'big',
+ 'serialize' => TRUE,
+ 'description' => 'A serialized array of name value pairs that are related to the user. Any form values posted during user edit are stored and are loaded into the $user object during user_load(). Use of this field is discouraged and it will likely disappear in a future version of Drupal.',
+ ),
+ ),
+ 'indexes' => array(
+ 'access' => array('access'),
+ 'created' => array('created'),
+ 'mail' => array('mail'),
+ ),
+ 'unique keys' => array(
+ 'name' => array('name'),
+ ),
+ 'primary key' => array('uid'),
+ 'foreign keys' => array(
+ 'signature_format' => array(
+ 'table' => 'filter_format',
+ 'columns' => array('signature_format' => 'format'),
+ ),
+ ),
+ );
+
+ $schema['users_roles'] = array(
+ 'description' => 'Maps users to roles.',
+ 'fields' => array(
+ 'uid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Primary Key: {users}.uid for user.',
+ ),
+ 'rid' => array(
+ 'type' => 'int',
+ 'unsigned' => TRUE,
+ 'not null' => TRUE,
+ 'default' => 0,
+ 'description' => 'Primary Key: {role}.rid for role.',
+ ),
+ ),
+ 'primary key' => array('uid', 'rid'),
+ 'indexes' => array(
+ 'rid' => array('rid'),
+ ),
+ 'foreign keys' => array(
+ 'user' => array(
+ 'table' => 'users',
+ 'columns' => array('uid' => 'uid'),
+ ),
+ 'role' => array(
+ 'table' => 'roles',
+ 'columns' => array('rid' => 'rid'),
+ ),
+ ),
+ );
+
+ return $schema;
+}
+
+/**
+ * Implements hook_install().
+ */
+function user_install() {
+ // Insert a row for the anonymous user.
+ db_insert('users')
+ ->fields(array(
+ 'uid' => 0,
+ 'name' => '',
+ 'mail' => '',
+ ))
+ ->execute();
+
+ // We need some placeholders here as name and mail are uniques and data is
+ // presumed to be a serialized array. This will be changed by the settings
+ // form in the installer.
+ db_insert('users')
+ ->fields(array(
+ 'uid' => 1,
+ 'name' => 'placeholder-for-uid-1',
+ 'mail' => 'placeholder-for-uid-1',
+ 'created' => REQUEST_TIME,
+ 'status' => 1,
+ 'data' => NULL,
+ ))
+ ->execute();
+
+ // Built-in roles.
+ $rid_anonymous = db_insert('role')
+ ->fields(array('name' => 'anonymous user', 'weight' => 0))
+ ->execute();
+ $rid_authenticated = db_insert('role')
+ ->fields(array('name' => 'authenticated user', 'weight' => 1))
+ ->execute();
+
+ // Sanity check to ensure the anonymous and authenticated role IDs are the
+ // same as the drupal defined constants. In certain situations, this will
+ // not be true.
+ if ($rid_anonymous != DRUPAL_ANONYMOUS_RID) {
+ db_update('role')
+ ->fields(array('rid' => DRUPAL_ANONYMOUS_RID))
+ ->condition('rid', $rid_anonymous)
+ ->execute();
+ }
+ if ($rid_authenticated != DRUPAL_AUTHENTICATED_RID) {
+ db_update('role')
+ ->fields(array('rid' => DRUPAL_AUTHENTICATED_RID))
+ ->condition('rid', $rid_authenticated)
+ ->execute();
+ }
+}
diff --git a/core/modules/user/user.js b/core/modules/user/user.js
new file mode 100644
index 000000000000..44c00f344a03
--- /dev/null
+++ b/core/modules/user/user.js
@@ -0,0 +1,196 @@
+(function ($) {
+
+/**
+ * Attach handlers to evaluate the strength of any password fields and to check
+ * that its confirmation is correct.
+ */
+Drupal.behaviors.password = {
+ attach: function (context, settings) {
+ var translate = settings.password;
+ $('input.password-field', context).once('password', function () {
+ var passwordInput = $(this);
+ var innerWrapper = $(this).parent();
+ var outerWrapper = $(this).parent().parent();
+
+ // Add identifying class to password element parent.
+ innerWrapper.addClass('password-parent');
+
+ // Add the password confirmation layer.
+ $('input.password-confirm', outerWrapper).parent().prepend('<div class="password-confirm">' + translate['confirmTitle'] + ' <span></span></div>').addClass('confirm-parent');
+ var confirmInput = $('input.password-confirm', outerWrapper);
+ var confirmResult = $('div.password-confirm', outerWrapper);
+ var confirmChild = $('span', confirmResult);
+
+ // Add the description box.
+ var passwordMeter = '<div class="password-strength"><div class="password-strength-text" aria-live="assertive"></div><div class="password-strength-title">' + translate['strengthTitle'] + '</div><div class="password-indicator"><div class="indicator"></div></div></div>';
+ $(confirmInput).parent().after('<div class="password-suggestions description"></div>');
+ $(innerWrapper).prepend(passwordMeter);
+ var passwordDescription = $('div.password-suggestions', outerWrapper).hide();
+
+ // Check the password strength.
+ var passwordCheck = function () {
+
+ // Evaluate the password strength.
+ var result = Drupal.evaluatePasswordStrength(passwordInput.val(), settings.password);
+
+ // Update the suggestions for how to improve the password.
+ if (passwordDescription.html() != result.message) {
+ passwordDescription.html(result.message);
+ }
+
+ // Only show the description box if there is a weakness in the password.
+ if (result.strength == 100) {
+ passwordDescription.hide();
+ }
+ else {
+ passwordDescription.show();
+ }
+
+ // Adjust the length of the strength indicator.
+ $(innerWrapper).find('.indicator').css('width', result.strength + '%');
+
+ // Update the strength indication text.
+ $(innerWrapper).find('.password-strength-text').html(result.indicatorText);
+
+ passwordCheckMatch();
+ };
+
+ // Check that password and confirmation inputs match.
+ var passwordCheckMatch = function () {
+
+ if (confirmInput.val()) {
+ var success = passwordInput.val() === confirmInput.val();
+
+ // Show the confirm result.
+ confirmResult.css({ visibility: 'visible' });
+
+ // Remove the previous styling if any exists.
+ if (this.confirmClass) {
+ confirmChild.removeClass(this.confirmClass);
+ }
+
+ // Fill in the success message and set the class accordingly.
+ var confirmClass = success ? 'ok' : 'error';
+ confirmChild.html(translate['confirm' + (success ? 'Success' : 'Failure')]).addClass(confirmClass);
+ this.confirmClass = confirmClass;
+ }
+ else {
+ confirmResult.css({ visibility: 'hidden' });
+ }
+ };
+
+ // Monitor keyup and blur events.
+ // Blur must be used because a mouse paste does not trigger keyup.
+ passwordInput.keyup(passwordCheck).focus(passwordCheck).blur(passwordCheck);
+ confirmInput.keyup(passwordCheckMatch).blur(passwordCheckMatch);
+ });
+ }
+};
+
+/**
+ * Evaluate the strength of a user's password.
+ *
+ * Returns the estimated strength and the relevant output message.
+ */
+Drupal.evaluatePasswordStrength = function (password, translate) {
+ var weaknesses = 0, strength = 100, msg = [];
+
+ var hasLowercase = password.match(/[a-z]+/);
+ var hasUppercase = password.match(/[A-Z]+/);
+ var hasNumbers = password.match(/[0-9]+/);
+ var hasPunctuation = password.match(/[^a-zA-Z0-9]+/);
+
+ // If there is a username edit box on the page, compare password to that, otherwise
+ // use value from the database.
+ var usernameBox = $('input.username');
+ var username = (usernameBox.length > 0) ? usernameBox.val() : translate.username;
+
+ // Lose 5 points for every character less than 6, plus a 30 point penalty.
+ if (password.length < 6) {
+ msg.push(translate.tooShort);
+ strength -= ((6 - password.length) * 5) + 30;
+ }
+
+ // Count weaknesses.
+ if (!hasLowercase) {
+ msg.push(translate.addLowerCase);
+ weaknesses++;
+ }
+ if (!hasUppercase) {
+ msg.push(translate.addUpperCase);
+ weaknesses++;
+ }
+ if (!hasNumbers) {
+ msg.push(translate.addNumbers);
+ weaknesses++;
+ }
+ if (!hasPunctuation) {
+ msg.push(translate.addPunctuation);
+ weaknesses++;
+ }
+
+ // Apply penalty for each weakness (balanced against length penalty).
+ switch (weaknesses) {
+ case 1:
+ strength -= 12.5;
+ break;
+
+ case 2:
+ strength -= 25;
+ break;
+
+ case 3:
+ strength -= 40;
+ break;
+
+ case 4:
+ strength -= 40;
+ break;
+ }
+
+ // Check if password is the same as the username.
+ if (password !== '' && password.toLowerCase() === username.toLowerCase()) {
+ msg.push(translate.sameAsUsername);
+ // Passwords the same as username are always very weak.
+ strength = 5;
+ }
+
+ // Based on the strength, work out what text should be shown by the password strength meter.
+ if (strength < 60) {
+ indicatorText = translate.weak;
+ } else if (strength < 70) {
+ indicatorText = translate.fair;
+ } else if (strength < 80) {
+ indicatorText = translate.good;
+ } else if (strength <= 100) {
+ indicatorText = translate.strong;
+ }
+
+ // Assemble the final message.
+ msg = translate.hasWeaknesses + '<ul><li>' + msg.join('</li><li>') + '</li></ul>';
+ return { strength: strength, message: msg, indicatorText: indicatorText }
+
+};
+
+/**
+ * Field instance settings screen: force the 'Display on registration form'
+ * checkbox checked whenever 'Required' is checked.
+ */
+Drupal.behaviors.fieldUserRegistration = {
+ attach: function (context, settings) {
+ var $checkbox = $('form#field-ui-field-edit-form input#edit-instance-settings-user-register-form');
+
+ if ($checkbox.size()) {
+ $('input#edit-instance-required', context).once('user-register-form-checkbox', function () {
+ $(this).bind('change', function (e) {
+ if ($(this).attr('checked')) {
+ $checkbox.attr('checked', true);
+ }
+ });
+ });
+
+ }
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/user/user.module b/core/modules/user/user.module
new file mode 100644
index 000000000000..2b5952dc441a
--- /dev/null
+++ b/core/modules/user/user.module
@@ -0,0 +1,3897 @@
+<?php
+
+/**
+ * @file
+ * Enables the user registration and login system.
+ */
+
+/**
+ * Maximum length of username text field.
+ */
+define('USERNAME_MAX_LENGTH', 60);
+
+/**
+ * Maximum length of user e-mail text field.
+ */
+define('EMAIL_MAX_LENGTH', 254);
+
+/**
+ * Only administrators can create user accounts.
+ */
+define('USER_REGISTER_ADMINISTRATORS_ONLY', 0);
+
+/**
+ * Visitors can create their own accounts.
+ */
+define('USER_REGISTER_VISITORS', 1);
+
+/**
+ * Visitors can create accounts, but they don't become active without
+ * administrative approval.
+ */
+define('USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL', 2);
+
+/**
+ * Implement hook_help().
+ */
+function user_help($path, $arg) {
+ global $user;
+
+ switch ($path) {
+ case 'admin/help#user':
+ $output = '';
+ $output .= '<h3>' . t('About') . '</h3>';
+ $output .= '<p>' . t('The User module allows users to register, log in, and log out. It also allows users with proper permissions to manage user roles (used to classify users) and permissions associated with those roles. For more information, see the online handbook entry for <a href="@user">User module</a>.', array('@user' => 'http://drupal.org/handbook/modules/user')) . '</p>';
+ $output .= '<h3>' . t('Uses') . '</h3>';
+ $output .= '<dl>';
+ $output .= '<dt>' . t('Creating and managing users') . '</dt>';
+ $output .= '<dd>' . t('The User module allows users with the appropriate <a href="@permissions">permissions</a> to create user accounts through the <a href="@people">People administration page</a>, where they can also assign users to one or more roles, and block or delete user accounts. If allowed, users without accounts (anonymous users) can create their own accounts on the <a href="@register">Create new account</a> page.', array('@permissions' => url('admin/people/permissions', array('fragment' => 'module-user')), '@people' => url('admin/people'), '@register' => url('user/register'))) . '</dd>';
+ $output .= '<dt>' . t('User roles and permissions') . '</dt>';
+ $output .= '<dd>' . t('<em>Roles</em> are used to group and classify users; each user can be assigned one or more roles. By default there are two roles: <em>anonymous user</em> (users that are not logged in) and <em>authenticated user</em> (users that are registered and logged in). Depending on choices you made when you installed Drupal, the installation process may have defined more roles, and you can create additional custom roles on the <a href="@roles">Roles page</a>. After creating roles, you can set permissions for each role on the <a href="@permissions_user">Permissions page</a>. Granting a permission allows users who have been assigned a particular role to perform an action on the site, such as viewing a particular type of content, editing or creating content, administering settings for a particular module, or using a particular function of the site (such as search).', array('@permissions_user' => url('admin/people/permissions'), '@roles' => url('admin/people/permissions/roles'))) . '</dd>';
+ $output .= '<dt>' . t('Account settings') . '</dt>';
+ $output .= '<dd>' . t('The <a href="@accounts">Account settings page</a> allows you to manage settings for the displayed name of the anonymous user role, personal contact forms, user registration, and account cancellation. On this page you can also manage settings for account personalization (including signatures and user pictures), and adapt the text for the e-mail messages that are sent automatically during the user registration process.', array('@accounts' => url('admin/config/people/accounts'))) . '</dd>';
+ $output .= '</dl>';
+ return $output;
+ case 'admin/people/create':
+ return '<p>' . t("This web page allows administrators to register new users. Users' e-mail addresses and usernames must be unique.") . '</p>';
+ case 'admin/people/permissions':
+ return '<p>' . t('Permissions let you control what users can do and see on your site. You can define a specific set of permissions for each role. (See the <a href="@role">Roles</a> page to create a role). Two important roles to consider are Authenticated Users and Administrators. Any permissions granted to the Authenticated Users role will be given to any user who can log into your site. You can make any role the Administrator role for the site, meaning this will be granted all new permissions automatically. You can do this on the <a href="@settings">User Settings</a> page. You should be careful to ensure that only trusted users are given this access and level of control of your site.', array('@role' => url('admin/people/permissions/roles'), '@settings' => url('admin/config/people/accounts'))) . '</p>';
+ case 'admin/people/permissions/roles':
+ $output = '<p>' . t('Roles allow you to fine tune the security and administration of Drupal. A role defines a group of users that have certain privileges as defined on the <a href="@permissions">permissions page</a>. Examples of roles include: anonymous user, authenticated user, moderator, administrator and so on. In this area you will define the names and order of the roles on your site. It is recommended to order your roles from least permissive (anonymous user) to most permissive (administrator). To delete a role choose "edit role".', array('@permissions' => url('admin/people/permissions'))) . '</p>';
+ $output .= '<p>' . t('By default, Drupal comes with two user roles:') . '</p>';
+ $output .= '<ul>';
+ $output .= '<li>' . t("Anonymous user: this role is used for users that don't have a user account or that are not authenticated.") . '</li>';
+ $output .= '<li>' . t('Authenticated user: this role is automatically granted to all logged in users.') . '</li>';
+ $output .= '</ul>';
+ return $output;
+ case 'admin/config/people/accounts/fields':
+ return '<p>' . t('This form lets administrators add, edit, and arrange fields for storing user data.') . '</p>';
+ case 'admin/config/people/accounts/display':
+ return '<p>' . t('This form lets administrators configure how fields should be displayed when rendering a user profile page.') . '</p>';
+ case 'admin/people/search':
+ return '<p>' . t('Enter a simple pattern ("*" may be used as a wildcard match) to search for a username or e-mail address. For example, one may search for "br" and Drupal might return "brian", "brad", and "brenda@example.com".') . '</p>';
+ }
+}
+
+/**
+ * Invokes a user hook in every module.
+ *
+ * We cannot use module_invoke() for this, because the arguments need to
+ * be passed by reference.
+ *
+ * @param $type
+ * A text string that controls which user hook to invoke. Valid choices are:
+ * - cancel: Invokes hook_user_cancel().
+ * - insert: Invokes hook_user_insert().
+ * - login: Invokes hook_user_login().
+ * - presave: Invokes hook_user_presave().
+ * - update: Invokes hook_user_update().
+ * @param $edit
+ * An associative array variable containing form values to be passed
+ * as the first parameter of the hook function.
+ * @param $account
+ * The user account object to be passed as the second parameter of the hook
+ * function.
+ * @param $category
+ * The category of user information being acted upon.
+ */
+function user_module_invoke($type, &$edit, $account, $category = NULL) {
+ foreach (module_implements('user_' . $type) as $module) {
+ $function = $module . '_user_' . $type;
+ $function($edit, $account, $category);
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function user_theme() {
+ return array(
+ 'user_picture' => array(
+ 'variables' => array('account' => NULL),
+ 'template' => 'user-picture',
+ ),
+ 'user_profile' => array(
+ 'render element' => 'elements',
+ 'template' => 'user-profile',
+ 'file' => 'user.pages.inc',
+ ),
+ 'user_profile_category' => array(
+ 'render element' => 'element',
+ 'template' => 'user-profile-category',
+ 'file' => 'user.pages.inc',
+ ),
+ 'user_profile_item' => array(
+ 'render element' => 'element',
+ 'template' => 'user-profile-item',
+ 'file' => 'user.pages.inc',
+ ),
+ 'user_list' => array(
+ 'variables' => array('users' => NULL, 'title' => NULL),
+ ),
+ 'user_admin_permissions' => array(
+ 'render element' => 'form',
+ 'file' => 'user.admin.inc',
+ ),
+ 'user_admin_roles' => array(
+ 'render element' => 'form',
+ 'file' => 'user.admin.inc',
+ ),
+ 'user_permission_description' => array(
+ 'variables' => array('permission_item' => NULL, 'hide' => NULL),
+ 'file' => 'user.admin.inc',
+ ),
+ 'user_signature' => array(
+ 'variables' => array('signature' => NULL),
+ ),
+ );
+}
+
+/**
+ * Implements hook_entity_info().
+ */
+function user_entity_info() {
+ $return = array(
+ 'user' => array(
+ 'label' => t('User'),
+ 'controller class' => 'UserController',
+ 'base table' => 'users',
+ 'uri callback' => 'user_uri',
+ 'label callback' => 'user_label',
+ 'fieldable' => TRUE,
+ 'entity keys' => array(
+ 'id' => 'uid',
+ ),
+ 'bundles' => array(
+ 'user' => array(
+ 'label' => t('User'),
+ 'admin' => array(
+ 'path' => 'admin/config/people/accounts',
+ 'access arguments' => array('administer users'),
+ ),
+ ),
+ ),
+ 'view modes' => array(
+ 'full' => array(
+ 'label' => t('User account'),
+ 'custom settings' => FALSE,
+ ),
+ ),
+ ),
+ );
+ return $return;
+}
+
+/**
+ * Entity uri callback.
+ */
+function user_uri($user) {
+ return array(
+ 'path' => 'user/' . $user->uid,
+ );
+}
+
+/**
+ * Entity label callback.
+ *
+ * @param $entity_type
+ * The entity type.
+ * @param $entity
+ * The entity object.
+ *
+ * @return
+ * The name of the user.
+ *
+ * @see format_username
+ */
+function user_label($entity_type, $entity) {
+ return format_username($entity);
+}
+
+/**
+ * Implements hook_field_info_alter().
+ */
+function user_field_info_alter(&$info) {
+ // Add the 'user_register_form' instance setting to all field types.
+ foreach ($info as $field_type => &$field_type_info) {
+ $field_type_info += array('instance_settings' => array());
+ $field_type_info['instance_settings'] += array(
+ 'user_register_form' => FALSE,
+ );
+ }
+}
+
+/**
+ * Implements hook_field_extra_fields().
+ */
+function user_field_extra_fields() {
+ $return['user']['user'] = array(
+ 'form' => array(
+ 'account' => array(
+ 'label' => t('User name and password'),
+ 'description' => t('User module account form elements.'),
+ 'weight' => -10,
+ ),
+ 'timezone' => array(
+ 'label' => t('Timezone'),
+ 'description' => t('User module timezone form element.'),
+ 'weight' => 6,
+ ),
+ ),
+ 'display' => array(
+ 'summary' => array(
+ 'label' => t('History'),
+ 'description' => t('User module history view element.'),
+ 'weight' => 5,
+ ),
+ ),
+ );
+
+ return $return;
+}
+
+/**
+ * Fetches a user object based on an external authentication source.
+ *
+ * @param string $authname
+ * The external authentication username.
+ *
+ * @return
+ * A fully-loaded user object if the user is found or FALSE if not found.
+ */
+function user_external_load($authname) {
+ $uid = db_query("SELECT uid FROM {authmap} WHERE authname = :authname", array(':authname' => $authname))->fetchField();
+
+ if ($uid) {
+ return user_load($uid);
+ }
+ else {
+ return FALSE;
+ }
+}
+
+/**
+ * Load multiple users based on certain conditions.
+ *
+ * This function should be used whenever you need to load more than one user
+ * from the database. Users are loaded into memory and will not require
+ * database access if loaded again during the same page request.
+ *
+ * @param $uids
+ * An array of user IDs.
+ * @param $conditions
+ * (deprecated) An associative array of conditions on the {users}
+ * table, where the keys are the database fields and the values are the
+ * values those fields must have. Instead, it is preferable to use
+ * EntityFieldQuery to retrieve a list of entity IDs loadable by
+ * this function.
+ * @param $reset
+ * A boolean indicating that the internal cache should be reset. Use this if
+ * loading a user object which has been altered during the page request.
+ *
+ * @return
+ * An array of user objects, indexed by uid.
+ *
+ * @see entity_load()
+ * @see user_load()
+ * @see user_load_by_mail()
+ * @see user_load_by_name()
+ * @see EntityFieldQuery
+ *
+ * @todo Remove $conditions in Drupal 8.
+ */
+function user_load_multiple($uids = array(), $conditions = array(), $reset = FALSE) {
+ return entity_load('user', $uids, $conditions, $reset);
+}
+
+/**
+ * Loads a user object.
+ *
+ * Drupal has a global $user object, which represents the currently-logged-in
+ * user. So to avoid confusion and to avoid clobbering the global $user object,
+ * it is a good idea to assign the result of this function to a different local
+ * variable, generally $account. If you actually do want to act as the user you
+ * are loading, it is essential to call drupal_save_session(FALSE); first.
+ * See
+ * @link http://drupal.org/node/218104 Safely impersonating another user @endlink
+ * for more information.
+ *
+ * @param $uid
+ * Integer specifying the user ID to load.
+ * @param $reset
+ * TRUE to reset the internal cache and load from the database; FALSE
+ * (default) to load from the internal cache, if set.
+ *
+ * @return
+ * A fully-loaded user object upon successful user load, or FALSE if the user
+ * cannot be loaded.
+ *
+ * @see user_load_multiple()
+ */
+function user_load($uid, $reset = FALSE) {
+ $users = user_load_multiple(array($uid), array(), $reset);
+ return reset($users);
+}
+
+/**
+ * Fetch a user object by email address.
+ *
+ * @param $mail
+ * String with the account's e-mail address.
+ * @return
+ * A fully-loaded $user object upon successful user load or FALSE if user
+ * cannot be loaded.
+ *
+ * @see user_load_multiple()
+ */
+function user_load_by_mail($mail) {
+ $users = user_load_multiple(array(), array('mail' => $mail));
+ return reset($users);
+}
+
+/**
+ * Fetch a user object by account name.
+ *
+ * @param $name
+ * String with the account's user name.
+ * @return
+ * A fully-loaded $user object upon successful user load or FALSE if user
+ * cannot be loaded.
+ *
+ * @see user_load_multiple()
+ */
+function user_load_by_name($name) {
+ $users = user_load_multiple(array(), array('name' => $name));
+ return reset($users);
+}
+
+/**
+ * Save changes to a user account or add a new user.
+ *
+ * @param $account
+ * (optional) The user object to modify or add. If you want to modify
+ * an existing user account, you will need to ensure that (a) $account
+ * is an object, and (b) you have set $account->uid to the numeric
+ * user ID of the user account you wish to modify. If you
+ * want to create a new user account, you can set $account->is_new to
+ * TRUE or omit the $account->uid field.
+ * @param $edit
+ * An array of fields and values to save. For example array('name'
+ * => 'My name'). Key / value pairs added to the $edit['data'] will be
+ * serialized and saved in the {users.data} column.
+ * @param $category
+ * (optional) The category for storing profile information in.
+ *
+ * @return
+ * A fully-loaded $user object upon successful save or FALSE if the save failed.
+ *
+ * @todo D8: Drop $edit and fix user_save() to be consistent with others.
+ */
+function user_save($account, $edit = array(), $category = 'account') {
+ $transaction = db_transaction();
+ try {
+ if (!empty($edit['pass'])) {
+ // Allow alternate password hashing schemes.
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ $edit['pass'] = user_hash_password(trim($edit['pass']));
+ // Abort if the hashing failed and returned FALSE.
+ if (!$edit['pass']) {
+ return FALSE;
+ }
+ }
+ else {
+ // Avoid overwriting an existing password with a blank password.
+ unset($edit['pass']);
+ }
+ if (isset($edit['mail'])) {
+ $edit['mail'] = trim($edit['mail']);
+ }
+
+ // Load the stored entity, if any.
+ if (!empty($account->uid) && !isset($account->original)) {
+ $account->original = entity_load_unchanged('user', $account->uid);
+ }
+
+ if (empty($account)) {
+ $account = new stdClass();
+ }
+ if (!isset($account->is_new)) {
+ $account->is_new = empty($account->uid);
+ }
+ // Prepopulate $edit['data'] with the current value of $account->data.
+ // Modules can add to or remove from this array in hook_user_presave().
+ if (!empty($account->data)) {
+ $edit['data'] = !empty($edit['data']) ? array_merge($account->data, $edit['data']) : $account->data;
+ }
+
+ // Invoke hook_user_presave() for all modules.
+ user_module_invoke('presave', $edit, $account, $category);
+
+ // Invoke presave operations of Field Attach API and Entity API. Those APIs
+ // require a fully-fledged and updated entity object. Therefore, we need to
+ // copy any new property values of $edit into it.
+ foreach ($edit as $key => $value) {
+ $account->$key = $value;
+ }
+ field_attach_presave('user', $account);
+ module_invoke_all('entity_presave', $account, 'user');
+
+ if (is_object($account) && !$account->is_new) {
+ // Process picture uploads.
+ if (!empty($account->picture->fid) && (!isset($account->original->picture->fid) || $account->picture->fid != $account->original->picture->fid)) {
+ $picture = $account->picture;
+ // If the picture is a temporary file move it to its final location and
+ // make it permanent.
+ if (!$picture->status) {
+ $info = image_get_info($picture->uri);
+ $picture_directory = file_default_scheme() . '://' . variable_get('user_picture_path', 'pictures');
+
+ // Prepare the pictures directory.
+ file_prepare_directory($picture_directory, FILE_CREATE_DIRECTORY);
+ $destination = file_stream_wrapper_uri_normalize($picture_directory . '/picture-' . $account->uid . '-' . REQUEST_TIME . '.' . $info['extension']);
+
+ // Move the temporary file into the final location.
+ if ($picture = file_move($picture, $destination, FILE_EXISTS_RENAME)) {
+ $picture->status = FILE_STATUS_PERMANENT;
+ $account->picture = file_save($picture);
+ file_usage_add($picture, 'user', 'user', $account->uid);
+ }
+ }
+ // Delete the previous picture if it was deleted or replaced.
+ if (!empty($account->original->picture->fid)) {
+ file_usage_delete($account->original->picture, 'user', 'user', $account->uid);
+ file_delete($account->original->picture);
+ }
+ }
+ $account->picture = empty($account->picture->fid) ? 0 : $account->picture->fid;
+
+ // Do not allow 'uid' to be changed.
+ $account->uid = $account->original->uid;
+ // Save changes to the user table.
+ $success = drupal_write_record('users', $account, 'uid');
+ if ($success === FALSE) {
+ // The query failed - better to abort the save than risk further
+ // data loss.
+ return FALSE;
+ }
+
+ // Reload user roles if provided.
+ if ($account->roles != $account->original->roles) {
+ db_delete('users_roles')
+ ->condition('uid', $account->uid)
+ ->execute();
+
+ $query = db_insert('users_roles')->fields(array('uid', 'rid'));
+ foreach (array_keys($account->roles) as $rid) {
+ if (!in_array($rid, array(DRUPAL_ANONYMOUS_RID, DRUPAL_AUTHENTICATED_RID))) {
+ $query->values(array(
+ 'uid' => $account->uid,
+ 'rid' => $rid,
+ ));
+ }
+ }
+ $query->execute();
+ }
+
+ // Delete a blocked user's sessions to kick them if they are online.
+ if ($account->original->status != $account->status && $account->status == 0) {
+ drupal_session_destroy_uid($account->uid);
+ }
+
+ // If the password changed, delete all open sessions and recreate
+ // the current one.
+ if ($account->pass != $account->original->pass) {
+ drupal_session_destroy_uid($account->uid);
+ if ($account->uid == $GLOBALS['user']->uid) {
+ drupal_session_regenerate();
+ }
+ }
+
+ // Save Field data.
+ field_attach_update('user', $account);
+
+ // Send emails after we have the new user object.
+ if ($account->status != $account->original->status) {
+ // The user's status is changing; conditionally send notification email.
+ $op = $account->status == 1 ? 'status_activated' : 'status_blocked';
+ _user_mail_notify($op, $account);
+ }
+
+ // Update $edit with any interim changes to $account.
+ foreach ($account as $key => $value) {
+ if (!property_exists($account->original, $key) || $value !== $account->original->$key) {
+ $edit[$key] = $value;
+ }
+ }
+ user_module_invoke('update', $edit, $account, $category);
+ module_invoke_all('entity_update', $account, 'user');
+ }
+ else {
+ // Allow 'uid' to be set by the caller. There is no danger of writing an
+ // existing user as drupal_write_record will do an INSERT.
+ if (empty($account->uid)) {
+ $account->uid = db_next_id(db_query('SELECT MAX(uid) FROM {users}')->fetchField());
+ }
+ // Allow 'created' to be set by the caller.
+ if (!isset($account->created)) {
+ $account->created = REQUEST_TIME;
+ }
+ $success = drupal_write_record('users', $account);
+ if ($success === FALSE) {
+ // On a failed INSERT some other existing user's uid may be returned.
+ // We must abort to avoid overwriting their account.
+ return FALSE;
+ }
+
+ // Make sure $account is properly initialized.
+ $account->roles[DRUPAL_AUTHENTICATED_RID] = 'authenticated user';
+
+ field_attach_insert('user', $account);
+ $edit = (array) $account;
+ user_module_invoke('insert', $edit, $account, $category);
+ module_invoke_all('entity_insert', $account, 'user');
+
+ // Save user roles.
+ if (count($account->roles) > 1) {
+ $query = db_insert('users_roles')->fields(array('uid', 'rid'));
+ foreach (array_keys($account->roles) as $rid) {
+ if (!in_array($rid, array(DRUPAL_ANONYMOUS_RID, DRUPAL_AUTHENTICATED_RID))) {
+ $query->values(array(
+ 'uid' => $account->uid,
+ 'rid' => $rid,
+ ));
+ }
+ }
+ $query->execute();
+ }
+ }
+ // Clear internal properties.
+ unset($account->is_new);
+ unset($account->original);
+ // Clear the static loading cache.
+ entity_get_controller('user')->resetCache(array($account->uid));
+
+ return $account;
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('user', $e);
+ throw $e;
+ }
+}
+
+/**
+ * Verify the syntax of the given name.
+ */
+function user_validate_name($name) {
+ if (!$name) {
+ return t('You must enter a username.');
+ }
+ if (substr($name, 0, 1) == ' ') {
+ return t('The username cannot begin with a space.');
+ }
+ if (substr($name, -1) == ' ') {
+ return t('The username cannot end with a space.');
+ }
+ if (strpos($name, ' ') !== FALSE) {
+ return t('The username cannot contain multiple spaces in a row.');
+ }
+ if (preg_match('/[^\x{80}-\x{F7} a-z0-9@_.\'-]/i', $name)) {
+ return t('The username contains an illegal character.');
+ }
+ if (preg_match('/[\x{80}-\x{A0}' . // Non-printable ISO-8859-1 + NBSP
+ '\x{AD}' . // Soft-hyphen
+ '\x{2000}-\x{200F}' . // Various space characters
+ '\x{2028}-\x{202F}' . // Bidirectional text overrides
+ '\x{205F}-\x{206F}' . // Various text hinting characters
+ '\x{FEFF}' . // Byte order mark
+ '\x{FF01}-\x{FF60}' . // Full-width latin
+ '\x{FFF9}-\x{FFFD}' . // Replacement characters
+ '\x{0}-\x{1F}]/u', // NULL byte and control characters
+ $name)) {
+ return t('The username contains an illegal character.');
+ }
+ if (drupal_strlen($name) > USERNAME_MAX_LENGTH) {
+ return t('The username %name is too long: it must be %max characters or less.', array('%name' => $name, '%max' => USERNAME_MAX_LENGTH));
+ }
+}
+
+/**
+ * Validates a user's email address.
+ *
+ * Checks that a user's email address exists and follows all standard
+ * validation rules. Returns error messages when the address is invalid.
+ *
+ * @param $mail
+ * A user's email address.
+ *
+ * @return
+ * If the address is invalid, a human-readable error message is returned.
+ * If the address is valid, nothing is returned.
+ */
+function user_validate_mail($mail) {
+ if (!$mail) {
+ return t('You must enter an e-mail address.');
+ }
+ if (!valid_email_address($mail)) {
+ return t('The e-mail address %mail is not valid.', array('%mail' => $mail));
+ }
+}
+
+/**
+ * Validates an image uploaded by a user.
+ *
+ * @see user_account_form()
+ */
+function user_validate_picture(&$form, &$form_state) {
+ // If required, validate the uploaded picture.
+ $validators = array(
+ 'file_validate_is_image' => array(),
+ 'file_validate_image_resolution' => array(variable_get('user_picture_dimensions', '85x85')),
+ 'file_validate_size' => array(variable_get('user_picture_file_size', '30') * 1024),
+ );
+
+ // Save the file as a temporary file.
+ $file = file_save_upload('picture_upload', $validators);
+ if ($file === FALSE) {
+ form_set_error('picture_upload', t("Failed to upload the picture image; the %directory directory doesn't exist or is not writable.", array('%directory' => variable_get('user_picture_path', 'pictures'))));
+ }
+ elseif ($file !== NULL) {
+ $form_state['values']['picture_upload'] = $file;
+ }
+}
+
+/**
+ * Generate a random alphanumeric password.
+ */
+function user_password($length = 10) {
+ // This variable contains the list of allowable characters for the
+ // password. Note that the number 0 and the letter 'O' have been
+ // removed to avoid confusion between the two. The same is true
+ // of 'I', 1, and 'l'.
+ $allowable_characters = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+
+ // Zero-based count of characters in the allowable list:
+ $len = strlen($allowable_characters) - 1;
+
+ // Declare the password as a blank string.
+ $pass = '';
+
+ // Loop the number of times specified by $length.
+ for ($i = 0; $i < $length; $i++) {
+
+ // Each iteration, pick a random character from the
+ // allowable string and append it to the password:
+ $pass .= $allowable_characters[mt_rand(0, $len)];
+ }
+
+ return $pass;
+}
+
+/**
+ * Determine the permissions for one or more roles.
+ *
+ * @param $roles
+ * An array whose keys are the role IDs of interest, such as $user->roles.
+ *
+ * @return
+ * An array indexed by role ID. Each value is an array whose keys are the
+ * permission strings for the given role ID.
+ */
+function user_role_permissions($roles = array()) {
+ $cache = &drupal_static(__FUNCTION__, array());
+
+ $role_permissions = $fetch = array();
+
+ if ($roles) {
+ foreach ($roles as $rid => $name) {
+ if (isset($cache[$rid])) {
+ $role_permissions[$rid] = $cache[$rid];
+ }
+ else {
+ // Add this rid to the list of those needing to be fetched.
+ $fetch[] = $rid;
+ // Prepare in case no permissions are returned.
+ $cache[$rid] = array();
+ }
+ }
+
+ if ($fetch) {
+ // Get from the database permissions that were not in the static variable.
+ // Only role IDs with at least one permission assigned will return rows.
+ $result = db_query("SELECT rid, permission FROM {role_permission} WHERE rid IN (:fetch)", array(':fetch' => $fetch));
+
+ foreach ($result as $row) {
+ $cache[$row->rid][$row->permission] = TRUE;
+ }
+ foreach ($fetch as $rid) {
+ // For every rid, we know we at least assigned an empty array.
+ $role_permissions[$rid] = $cache[$rid];
+ }
+ }
+ }
+
+ return $role_permissions;
+}
+
+/**
+ * Determine whether the user has a given privilege.
+ *
+ * @param $string
+ * The permission, such as "administer nodes", being checked for.
+ * @param $account
+ * (optional) The account to check, if not given use currently logged in user.
+ *
+ * @return
+ * Boolean TRUE if the current user has the requested permission.
+ *
+ * All permission checks in Drupal should go through this function. This
+ * way, we guarantee consistent behavior, and ensure that the superuser
+ * can perform all actions.
+ */
+function user_access($string, $account = NULL) {
+ global $user;
+
+ if (!isset($account)) {
+ $account = $user;
+ }
+
+ // User #1 has all privileges:
+ if ($account->uid == 1) {
+ return TRUE;
+ }
+
+ // To reduce the number of SQL queries, we cache the user's permissions
+ // in a static variable.
+ // Use the advanced drupal_static() pattern, since this is called very often.
+ static $drupal_static_fast;
+ if (!isset($drupal_static_fast)) {
+ $drupal_static_fast['perm'] = &drupal_static(__FUNCTION__);
+ }
+ $perm = &$drupal_static_fast['perm'];
+ if (!isset($perm[$account->uid])) {
+ $role_permissions = user_role_permissions($account->roles);
+
+ $perms = array();
+ foreach ($role_permissions as $one_role) {
+ $perms += $one_role;
+ }
+ $perm[$account->uid] = $perms;
+ }
+
+ return isset($perm[$account->uid][$string]);
+}
+
+/**
+ * Checks for usernames blocked by user administration.
+ *
+ * @return boolean TRUE for blocked users, FALSE for active.
+ */
+function user_is_blocked($name) {
+ return db_select('users')
+ ->fields('users', array('name'))
+ ->condition('name', db_like($name), 'LIKE')
+ ->condition('status', 0)
+ ->execute()->fetchObject();
+}
+
+/**
+ * Implements hook_permission().
+ */
+function user_permission() {
+ return array(
+ 'administer permissions' => array(
+ 'title' => t('Administer permissions'),
+ 'restrict access' => TRUE,
+ ),
+ 'administer users' => array(
+ 'title' => t('Administer users'),
+ 'restrict access' => TRUE,
+ ),
+ 'access user profiles' => array(
+ 'title' => t('View user profiles'),
+ ),
+ 'change own username' => array(
+ 'title' => t('Change own username'),
+ ),
+ 'cancel account' => array(
+ 'title' => t('Cancel own user account'),
+ 'description' => t('Note: content may be kept, unpublished, deleted or transferred to the %anonymous-name user depending on the configured <a href="@user-settings-url">user settings</a>.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')), '@user-settings-url' => url('admin/config/people/accounts'))),
+ ),
+ 'select account cancellation method' => array(
+ 'title' => t('Select method for cancelling own account'),
+ 'restrict access' => TRUE,
+ ),
+ );
+}
+
+/**
+ * Implements hook_file_download().
+ *
+ * Ensure that user pictures (avatars) are always downloadable.
+ */
+function user_file_download($uri) {
+ if (strpos(file_uri_target($uri), variable_get('user_picture_path', 'pictures') . '/picture-') === 0) {
+ $info = image_get_info($uri);
+ return array('Content-Type' => $info['mime_type']);
+ }
+}
+
+/**
+ * Implements hook_file_move().
+ */
+function user_file_move($file, $source) {
+ // If a user's picture is replaced with a new one, update the record in
+ // the users table.
+ if (isset($file->fid) && isset($source->fid) && $file->fid != $source->fid) {
+ db_update('users')
+ ->fields(array(
+ 'picture' => $file->fid,
+ ))
+ ->condition('picture', $source->fid)
+ ->execute();
+ }
+}
+
+/**
+ * Implements hook_file_delete().
+ */
+function user_file_delete($file) {
+ // Remove any references to the file.
+ db_update('users')
+ ->fields(array('picture' => 0))
+ ->condition('picture', $file->fid)
+ ->execute();
+}
+
+/**
+ * Implements hook_search_info().
+ */
+function user_search_info() {
+ return array(
+ 'title' => 'Users',
+ );
+}
+
+/**
+ * Implements hook_search_access().
+ */
+function user_search_access() {
+ return user_access('access user profiles');
+}
+
+/**
+ * Implements hook_search_execute().
+ */
+function user_search_execute($keys = NULL, $conditions = NULL) {
+ $find = array();
+ // Replace wildcards with MySQL/PostgreSQL wildcards.
+ $keys = preg_replace('!\*+!', '%', $keys);
+ $query = db_select('users')->extend('PagerDefault');
+ $query->fields('users', array('uid'));
+ if (user_access('administer users')) {
+ // Administrators can also search in the otherwise private email field.
+ $query->fields('users', array('mail'));
+ $query->condition(db_or()->
+ condition('name', '%' . db_like($keys) . '%', 'LIKE')->
+ condition('mail', '%' . db_like($keys) . '%', 'LIKE'));
+ }
+ else {
+ $query->condition('name', '%' . db_like($keys) . '%', 'LIKE');
+ }
+ $uids = $query
+ ->limit(15)
+ ->execute()
+ ->fetchCol();
+ $accounts = user_load_multiple($uids);
+
+ $results = array();
+ foreach ($accounts as $account) {
+ $result = array(
+ 'title' => format_username($account),
+ 'link' => url('user/' . $account->uid, array('absolute' => TRUE)),
+ );
+ if (user_access('administer users')) {
+ $result['title'] .= ' (' . $account->mail . ')';
+ }
+ $results[] = $result;
+ }
+
+ return $results;
+}
+
+/**
+ * Implements hook_element_info().
+ */
+function user_element_info() {
+ $types['user_profile_category'] = array(
+ '#theme_wrappers' => array('user_profile_category'),
+ );
+ $types['user_profile_item'] = array(
+ '#theme' => 'user_profile_item',
+ );
+ return $types;
+}
+
+/**
+ * Implements hook_user_view().
+ */
+function user_user_view($account) {
+ $account->content['user_picture'] = array(
+ '#markup' => theme('user_picture', array('account' => $account)),
+ '#weight' => -10,
+ );
+ if (!isset($account->content['summary'])) {
+ $account->content['summary'] = array();
+ }
+ $account->content['summary'] += array(
+ '#type' => 'user_profile_category',
+ '#attributes' => array('class' => array('user-member')),
+ '#weight' => 5,
+ '#title' => t('History'),
+ );
+ $account->content['summary']['member_for'] = array(
+ '#type' => 'user_profile_item',
+ '#title' => t('Member for'),
+ '#markup' => format_interval(REQUEST_TIME - $account->created),
+ );
+}
+
+/**
+ * Helper function to add default user account fields to user registration and edit form.
+ *
+ * @see user_account_form_validate()
+ * @see user_validate_current_pass()
+ * @see user_validate_picture()
+ * @see user_validate_mail()
+ */
+function user_account_form(&$form, &$form_state) {
+ global $user;
+
+ $account = $form['#user'];
+ $register = ($form['#user']->uid > 0 ? FALSE : TRUE);
+
+ $admin = user_access('administer users');
+
+ $form['#validate'][] = 'user_account_form_validate';
+
+ // Account information.
+ $form['account'] = array(
+ '#type' => 'container',
+ '#weight' => -10,
+ );
+ // Only show name field on registration form or user can change own username.
+ $form['account']['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Username'),
+ '#maxlength' => USERNAME_MAX_LENGTH,
+ '#description' => t('Spaces are allowed; punctuation is not allowed except for periods, hyphens, apostrophes, and underscores.'),
+ '#required' => TRUE,
+ '#attributes' => array('class' => array('username')),
+ '#default_value' => (!$register ? $account->name : ''),
+ '#access' => ($register || ($user->uid == $account->uid && user_access('change own username')) || $admin),
+ '#weight' => -10,
+ );
+
+ $form['account']['mail'] = array(
+ '#type' => 'textfield',
+ '#title' => t('E-mail address'),
+ '#maxlength' => EMAIL_MAX_LENGTH,
+ '#description' => t('A valid e-mail address. All e-mails from the system will be sent to this address. The e-mail address is not made public and will only be used if you wish to receive a new password or wish to receive certain news or notifications by e-mail.'),
+ '#required' => TRUE,
+ '#default_value' => (!$register ? $account->mail : ''),
+ );
+
+ // Display password field only for existing users or when user is allowed to
+ // assign a password during registration.
+ if (!$register) {
+ $form['account']['pass'] = array(
+ '#type' => 'password_confirm',
+ '#size' => 25,
+ '#description' => t('To change the current user password, enter the new password in both fields.'),
+ );
+ // To skip the current password field, the user must have logged in via a
+ // one-time link and have the token in the URL.
+ $pass_reset = isset($_SESSION['pass_reset_' . $account->uid]) && isset($_GET['pass-reset-token']) && ($_GET['pass-reset-token'] == $_SESSION['pass_reset_' . $account->uid]);
+ $protected_values = array();
+ $current_pass_description = '';
+ // The user may only change their own password without their current
+ // password if they logged in via a one-time login link.
+ if (!$pass_reset) {
+ $protected_values['mail'] = $form['account']['mail']['#title'];
+ $protected_values['pass'] = t('Password');
+ $request_new = l(t('Request new password'), 'user/password', array('attributes' => array('title' => t('Request new password via e-mail.'))));
+ $current_pass_description = t('Enter your current password to change the %mail or %pass. !request_new.', array('%mail' => $protected_values['mail'], '%pass' => $protected_values['pass'], '!request_new' => $request_new));
+ }
+ // The user must enter their current password to change to a new one.
+ if ($user->uid == $account->uid) {
+ $form['account']['current_pass_required_values'] = array(
+ '#type' => 'value',
+ '#value' => $protected_values,
+ );
+ $form['account']['current_pass'] = array(
+ '#type' => 'password',
+ '#title' => t('Current password'),
+ '#size' => 25,
+ '#access' => !empty($protected_values),
+ '#description' => $current_pass_description,
+ '#weight' => -5,
+ '#attributes' => array('autocomplete' => 'off'),
+ );
+ $form['#validate'][] = 'user_validate_current_pass';
+ }
+ }
+ elseif (!variable_get('user_email_verification', TRUE) || $admin) {
+ $form['account']['pass'] = array(
+ '#type' => 'password_confirm',
+ '#size' => 25,
+ '#description' => t('Provide a password for the new account in both fields.'),
+ '#required' => TRUE,
+ );
+ }
+
+ if ($admin) {
+ $status = isset($account->status) ? $account->status : 1;
+ }
+ else {
+ $status = $register ? variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) == USER_REGISTER_VISITORS : $account->status;
+ }
+ $form['account']['status'] = array(
+ '#type' => 'radios',
+ '#title' => t('Status'),
+ '#default_value' => $status,
+ '#options' => array(t('Blocked'), t('Active')),
+ '#access' => $admin,
+ );
+
+ $roles = array_map('check_plain', user_roles(TRUE));
+ // The disabled checkbox subelement for the 'authenticated user' role
+ // must be generated separately and added to the checkboxes element,
+ // because of a limitation in Form API not supporting a single disabled
+ // checkbox within a set of checkboxes.
+ // @todo This should be solved more elegantly. See issue #119038.
+ $checkbox_authenticated = array(
+ '#type' => 'checkbox',
+ '#title' => $roles[DRUPAL_AUTHENTICATED_RID],
+ '#default_value' => TRUE,
+ '#disabled' => TRUE,
+ );
+ unset($roles[DRUPAL_AUTHENTICATED_RID]);
+ $form['account']['roles'] = array(
+ '#type' => 'checkboxes',
+ '#title' => t('Roles'),
+ '#default_value' => (!$register && isset($account->roles) ? array_keys($account->roles) : array()),
+ '#options' => $roles,
+ '#access' => $roles && user_access('administer permissions'),
+ DRUPAL_AUTHENTICATED_RID => $checkbox_authenticated,
+ );
+
+ $form['account']['notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user of new account'),
+ '#access' => $register && $admin,
+ );
+
+ // Signature.
+ $form['signature_settings'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Signature settings'),
+ '#weight' => 1,
+ '#access' => (!$register && variable_get('user_signatures', 0)),
+ );
+
+ $form['signature_settings']['signature'] = array(
+ '#type' => 'text_format',
+ '#title' => t('Signature'),
+ '#default_value' => isset($account->signature) ? $account->signature : '',
+ '#description' => t('Your signature will be publicly displayed at the end of your comments.'),
+ '#format' => isset($account->signature_format) ? $account->signature_format : NULL,
+ );
+
+ // Picture/avatar.
+ $form['picture'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Picture'),
+ '#weight' => 1,
+ '#access' => (!$register && variable_get('user_pictures', 0)),
+ );
+ $form['picture']['picture'] = array(
+ '#type' => 'value',
+ '#value' => isset($account->picture) ? $account->picture : NULL,
+ );
+ $form['picture']['picture_current'] = array(
+ '#markup' => theme('user_picture', array('account' => $account)),
+ );
+ $form['picture']['picture_delete'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Delete picture'),
+ '#access' => !empty($account->picture->fid),
+ '#description' => t('Check this box to delete your current picture.'),
+ );
+ $form['picture']['picture_upload'] = array(
+ '#type' => 'file',
+ '#title' => t('Upload picture'),
+ '#size' => 48,
+ '#description' => t('Your virtual face or picture. Pictures larger than @dimensions pixels will be scaled down.', array('@dimensions' => variable_get('user_picture_dimensions', '85x85'))) . ' ' . filter_xss_admin(variable_get('user_picture_guidelines', '')),
+ );
+ $form['#validate'][] = 'user_validate_picture';
+}
+
+/**
+ * Form validation handler for the current password on the user_account_form().
+ *
+ * @see user_account_form()
+ */
+function user_validate_current_pass(&$form, &$form_state) {
+ $account = $form['#user'];
+ foreach ($form_state['values']['current_pass_required_values'] as $key => $name) {
+ // This validation only works for required textfields (like mail) or
+ // form values like password_confirm that have their own validation
+ // that prevent them from being empty if they are changed.
+ if ((strlen(trim($form_state['values'][$key])) > 0) && ($form_state['values'][$key] != $account->$key)) {
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ $current_pass_failed = empty($form_state['values']['current_pass']) || !user_check_password($form_state['values']['current_pass'], $account);
+ if ($current_pass_failed) {
+ form_set_error('current_pass', t("Your current password is missing or incorrect; it's required to change the %name.", array('%name' => $name)));
+ form_set_error($key);
+ }
+ // We only need to check the password once.
+ break;
+ }
+ }
+}
+
+/**
+ * Form validation handler for user_account_form().
+ *
+ * @see user_account_form()
+ */
+function user_account_form_validate($form, &$form_state) {
+ if ($form['#user_category'] == 'account' || $form['#user_category'] == 'register') {
+ $account = $form['#user'];
+ // Validate new or changing username.
+ if (isset($form_state['values']['name'])) {
+ if ($error = user_validate_name($form_state['values']['name'])) {
+ form_set_error('name', $error);
+ }
+ elseif ((bool) db_select('users')->fields('users', array('uid'))->condition('uid', $account->uid, '<>')->condition('name', db_like($form_state['values']['name']), 'LIKE')->range(0, 1)->execute()->fetchField()) {
+ form_set_error('name', t('The name %name is already taken.', array('%name' => $form_state['values']['name'])));
+ }
+ }
+
+ // Trim whitespace from mail, to prevent confusing 'e-mail not valid'
+ // warnings often caused by cutting and pasting.
+ $mail = trim($form_state['values']['mail']);
+ form_set_value($form['account']['mail'], $mail, $form_state);
+
+ // Validate the e-mail address, and check if it is taken by an existing user.
+ if ($error = user_validate_mail($form_state['values']['mail'])) {
+ form_set_error('mail', $error);
+ }
+ elseif ((bool) db_select('users')->fields('users', array('uid'))->condition('uid', $account->uid, '<>')->condition('mail', db_like($form_state['values']['mail']), 'LIKE')->range(0, 1)->execute()->fetchField()) {
+ // Format error message dependent on whether the user is logged in or not.
+ if ($GLOBALS['user']->uid) {
+ form_set_error('mail', t('The e-mail address %email is already taken.', array('%email' => $form_state['values']['mail'])));
+ }
+ else {
+ form_set_error('mail', t('The e-mail address %email is already registered. <a href="@password">Have you forgotten your password?</a>', array('%email' => $form_state['values']['mail'], '@password' => url('user/password'))));
+ }
+ }
+
+ // Make sure the signature isn't longer than the size of the database field.
+ // Signatures are disabled by default, so make sure it exists first.
+ if (isset($form_state['values']['signature'])) {
+ // Move text format for user signature into 'signature_format'.
+ $form_state['values']['signature_format'] = $form_state['values']['signature']['format'];
+ // Move text value for user signature into 'signature'.
+ $form_state['values']['signature'] = $form_state['values']['signature']['value'];
+
+ $user_schema = drupal_get_schema('users');
+ if (drupal_strlen($form_state['values']['signature']) > $user_schema['fields']['signature']['length']) {
+ form_set_error('signature', t('The signature is too long: it must be %max characters or less.', array('%max' => $user_schema['fields']['signature']['length'])));
+ }
+ }
+ }
+}
+
+/**
+ * Implements hook_user_presave().
+ */
+function user_user_presave(&$edit, $account, $category) {
+ if ($category == 'account' || $category == 'register') {
+ if (!empty($edit['picture_upload'])) {
+ $edit['picture'] = $edit['picture_upload'];
+ }
+ // Delete picture if requested, and if no replacement picture was given.
+ elseif (!empty($edit['picture_delete'])) {
+ $edit['picture'] = NULL;
+ }
+ // Prepare user roles.
+ if (isset($edit['roles'])) {
+ $edit['roles'] = array_filter($edit['roles']);
+ }
+ }
+
+ // Move account cancellation information into $user->data.
+ foreach (array('user_cancel_method', 'user_cancel_notify') as $key) {
+ if (isset($edit[$key])) {
+ $edit['data'][$key] = $edit[$key];
+ }
+ }
+}
+
+/**
+ * Implements hook_user_categories().
+ */
+function user_user_categories() {
+ return array(array(
+ 'name' => 'account',
+ 'title' => t('Account settings'),
+ 'weight' => 1,
+ ));
+}
+
+function user_login_block($form) {
+ $form['#action'] = url($_GET['q'], array('query' => drupal_get_destination()));
+ $form['#id'] = 'user-login-form';
+ $form['#validate'] = user_login_default_validators();
+ $form['#submit'][] = 'user_login_submit';
+ $form['name'] = array('#type' => 'textfield',
+ '#title' => t('Username'),
+ '#maxlength' => USERNAME_MAX_LENGTH,
+ '#size' => 15,
+ '#required' => TRUE,
+ );
+ $form['pass'] = array('#type' => 'password',
+ '#title' => t('Password'),
+ '#maxlength' => 60,
+ '#size' => 15,
+ '#required' => TRUE,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit',
+ '#value' => t('Log in'),
+ );
+ $items = array();
+ if (variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL)) {
+ $items[] = l(t('Create new account'), 'user/register', array('attributes' => array('title' => t('Create a new user account.'))));
+ }
+ $items[] = l(t('Request new password'), 'user/password', array('attributes' => array('title' => t('Request new password via e-mail.'))));
+ $form['links'] = array('#markup' => theme('item_list', array('items' => $items)));
+ return $form;
+}
+
+/**
+ * Implements hook_block_info().
+ */
+function user_block_info() {
+ global $user;
+
+ $blocks['login']['info'] = t('User login');
+ // Not worth caching.
+ $blocks['login']['cache'] = DRUPAL_NO_CACHE;
+
+ $blocks['new']['info'] = t('Who\'s new');
+ $blocks['new']['properties']['administrative'] = TRUE;
+
+ // Too dynamic to cache.
+ $blocks['online']['info'] = t('Who\'s online');
+ $blocks['online']['cache'] = DRUPAL_NO_CACHE;
+ $blocks['online']['properties']['administrative'] = TRUE;
+
+ return $blocks;
+}
+
+/**
+ * Implements hook_block_configure().
+ */
+function user_block_configure($delta = '') {
+ global $user;
+
+ switch ($delta) {
+ case 'new':
+ $form['user_block_whois_new_count'] = array(
+ '#type' => 'select',
+ '#title' => t('Number of users to display'),
+ '#default_value' => variable_get('user_block_whois_new_count', 5),
+ '#options' => drupal_map_assoc(array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)),
+ );
+ return $form;
+
+ case 'online':
+ $period = drupal_map_assoc(array(30, 60, 120, 180, 300, 600, 900, 1800, 2700, 3600, 5400, 7200, 10800, 21600, 43200, 86400), 'format_interval');
+ $form['user_block_seconds_online'] = array('#type' => 'select', '#title' => t('User activity'), '#default_value' => variable_get('user_block_seconds_online', 900), '#options' => $period, '#description' => t('A user is considered online for this long after they have last viewed a page.'));
+ $form['user_block_max_list_count'] = array('#type' => 'select', '#title' => t('User list length'), '#default_value' => variable_get('user_block_max_list_count', 10), '#options' => drupal_map_assoc(array(0, 5, 10, 15, 20, 25, 30, 40, 50, 75, 100)), '#description' => t('Maximum number of currently online users to display.'));
+ return $form;
+ }
+}
+
+/**
+ * Implements hook_block_save().
+ */
+function user_block_save($delta = '', $edit = array()) {
+ global $user;
+
+ switch ($delta) {
+ case 'new':
+ variable_set('user_block_whois_new_count', $edit['user_block_whois_new_count']);
+ break;
+
+ case 'online':
+ variable_set('user_block_seconds_online', $edit['user_block_seconds_online']);
+ variable_set('user_block_max_list_count', $edit['user_block_max_list_count']);
+ break;
+ }
+}
+
+/**
+ * Implements hook_block_view().
+ */
+function user_block_view($delta = '') {
+ global $user;
+
+ $block = array();
+
+ switch ($delta) {
+ case 'login':
+ // For usability's sake, avoid showing two login forms on one page.
+ if (!$user->uid && !(arg(0) == 'user' && !is_numeric(arg(1)))) {
+
+ $block['subject'] = t('User login');
+ $block['content'] = drupal_get_form('user_login_block');
+ }
+ return $block;
+
+ case 'new':
+ if (user_access('access content')) {
+ // Retrieve a list of new users who have subsequently accessed the site successfully.
+ $items = db_query_range('SELECT uid, name FROM {users} WHERE status <> 0 AND access <> 0 ORDER BY created DESC', 0, variable_get('user_block_whois_new_count', 5))->fetchAll();
+ $output = theme('user_list', array('users' => $items));
+
+ $block['subject'] = t('Who\'s new');
+ $block['content'] = $output;
+ }
+ return $block;
+
+ case 'online':
+ if (user_access('access content')) {
+ // Count users active within the defined period.
+ $interval = REQUEST_TIME - variable_get('user_block_seconds_online', 900);
+
+ // Perform database queries to gather online user lists. We use s.timestamp
+ // rather than u.access because it is much faster.
+ $authenticated_count = db_query("SELECT COUNT(DISTINCT s.uid) FROM {sessions} s WHERE s.timestamp >= :timestamp AND s.uid > 0", array(':timestamp' => $interval))->fetchField();
+
+ $output = '<p>' . format_plural($authenticated_count, 'There is currently 1 user online.', 'There are currently @count users online.') . '</p>';
+
+ // Display a list of currently online users.
+ $max_users = variable_get('user_block_max_list_count', 10);
+ if ($authenticated_count && $max_users) {
+ $items = db_query_range('SELECT u.uid, u.name, MAX(s.timestamp) AS max_timestamp FROM {users} u INNER JOIN {sessions} s ON u.uid = s.uid WHERE s.timestamp >= :interval AND s.uid > 0 GROUP BY u.uid, u.name ORDER BY max_timestamp DESC', 0, $max_users, array(':interval' => $interval))->fetchAll();
+ $output .= theme('user_list', array('users' => $items));
+ }
+
+ $block['subject'] = t('Who\'s online');
+ $block['content'] = $output;
+ }
+ return $block;
+ }
+}
+
+/**
+ * Process variables for user-picture.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $account: A user, node or comment object with 'name', 'uid' and 'picture'
+ * fields.
+ *
+ * @see user-picture.tpl.php
+ */
+function template_preprocess_user_picture(&$variables) {
+ $variables['user_picture'] = '';
+ if (variable_get('user_pictures', 0)) {
+ $account = $variables['account'];
+ if (!empty($account->picture)) {
+ // @TODO: Ideally this function would only be passed file objects, but
+ // since there's a lot of legacy code that JOINs the {users} table to
+ // {node} or {comments} and passes the results into this function if we
+ // a numeric value in the picture field we'll assume it's a file id
+ // and load it for them. Once we've got user_load_multiple() and
+ // comment_load_multiple() functions the user module will be able to load
+ // the picture files in mass during the object's load process.
+ if (is_numeric($account->picture)) {
+ $account->picture = file_load($account->picture);
+ }
+ if (!empty($account->picture->uri)) {
+ $filepath = $account->picture->uri;
+ }
+ }
+ elseif (variable_get('user_picture_default', '')) {
+ $filepath = variable_get('user_picture_default', '');
+ }
+ if (isset($filepath)) {
+ $alt = t("@user's picture", array('@user' => format_username($account)));
+ // If the image does not have a valid Drupal scheme (for eg. HTTP),
+ // don't load image styles.
+ if (module_exists('image') && file_valid_uri($filepath) && $style = variable_get('user_picture_style', '')) {
+ $variables['user_picture'] = theme('image_style', array('style_name' => $style, 'path' => $filepath, 'alt' => $alt, 'title' => $alt));
+ }
+ else {
+ $variables['user_picture'] = theme('image', array('path' => $filepath, 'alt' => $alt, 'title' => $alt));
+ }
+ if (!empty($account->uid) && user_access('access user profiles')) {
+ $attributes = array('attributes' => array('title' => t('View user profile.')), 'html' => TRUE);
+ $variables['user_picture'] = l($variables['user_picture'], "user/$account->uid", $attributes);
+ }
+ }
+ }
+}
+
+/**
+ * Returns HTML for a list of users.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - users: An array with user objects. Should contain at least the name and
+ * uid.
+ * - title: (optional) Title to pass on to theme_item_list().
+ *
+ * @ingroup themeable
+ */
+function theme_user_list($variables) {
+ $users = $variables['users'];
+ $title = $variables['title'];
+ $items = array();
+
+ if (!empty($users)) {
+ foreach ($users as $user) {
+ $items[] = theme('username', array('account' => $user));
+ }
+ }
+ return theme('item_list', array('items' => $items, 'title' => $title));
+}
+
+function user_is_anonymous() {
+ // Menu administrators can see items for anonymous when administering.
+ return !$GLOBALS['user']->uid || !empty($GLOBALS['menu_admin']);
+}
+
+function user_is_logged_in() {
+ return (bool) $GLOBALS['user']->uid;
+}
+
+function user_register_access() {
+ return user_is_anonymous() && variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
+}
+
+/**
+ * User view access callback.
+ *
+ * @param $account
+ * Can either be a full user object or a $uid.
+ */
+function user_view_access($account) {
+ $uid = is_object($account) ? $account->uid : (int) $account;
+
+ // Never allow access to view the anonymous user account.
+ if ($uid) {
+ // Admins can view all, users can view own profiles at all times.
+ if ($GLOBALS['user']->uid == $uid || user_access('administer users')) {
+ return TRUE;
+ }
+ elseif (user_access('access user profiles')) {
+ // At this point, load the complete account object.
+ if (!is_object($account)) {
+ $account = user_load($uid);
+ }
+ return (is_object($account) && $account->status);
+ }
+ }
+ return FALSE;
+}
+
+/**
+ * Access callback for user account editing.
+ */
+function user_edit_access($account) {
+ return (($GLOBALS['user']->uid == $account->uid) || user_access('administer users')) && $account->uid > 0;
+}
+
+/**
+ * Menu access callback; limit access to account cancellation pages.
+ *
+ * Limit access to users with the 'cancel account' permission or administrative
+ * users, and prevent the anonymous user from cancelling the account.
+ */
+function user_cancel_access($account) {
+ return ((($GLOBALS['user']->uid == $account->uid) && user_access('cancel account')) || user_access('administer users')) && $account->uid > 0;
+}
+
+/**
+ * Implements hook_menu().
+ */
+function user_menu() {
+ $items['user/autocomplete'] = array(
+ 'title' => 'User autocomplete',
+ 'page callback' => 'user_autocomplete',
+ 'access callback' => 'user_access',
+ 'access arguments' => array('access user profiles'),
+ 'type' => MENU_CALLBACK,
+ 'file' => 'user.pages.inc',
+ );
+
+ // Registration and login pages.
+ $items['user'] = array(
+ 'title' => 'User account',
+ 'title callback' => 'user_menu_title',
+ 'page callback' => 'user_page',
+ 'access callback' => TRUE,
+ 'file' => 'user.pages.inc',
+ 'weight' => -10,
+ 'menu_name' => 'user-menu',
+ );
+
+ $items['user/login'] = array(
+ 'title' => 'Log in',
+ 'access callback' => 'user_is_anonymous',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ );
+
+ $items['user/register'] = array(
+ 'title' => 'Create new account',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_register_form'),
+ 'access callback' => 'user_register_access',
+ 'type' => MENU_LOCAL_TASK,
+ );
+
+ $items['user/password'] = array(
+ 'title' => 'Request new password',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_pass'),
+ 'access callback' => TRUE,
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'user.pages.inc',
+ );
+ $items['user/reset/%/%/%'] = array(
+ 'title' => 'Reset password',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_pass_reset', 2, 3, 4),
+ 'access callback' => TRUE,
+ 'type' => MENU_CALLBACK,
+ 'file' => 'user.pages.inc',
+ );
+
+ $items['user/logout'] = array(
+ 'title' => 'Log out',
+ 'access callback' => 'user_is_logged_in',
+ 'page callback' => 'user_logout',
+ 'weight' => 10,
+ 'menu_name' => 'user-menu',
+ 'file' => 'user.pages.inc',
+ );
+
+ // User listing pages.
+ $items['admin/people'] = array(
+ 'title' => 'People',
+ 'description' => 'Manage user accounts, roles, and permissions.',
+ 'page callback' => 'user_admin',
+ 'page arguments' => array('list'),
+ 'access arguments' => array('administer users'),
+ 'position' => 'left',
+ 'weight' => -4,
+ 'file' => 'user.admin.inc',
+ );
+ $items['admin/people/people'] = array(
+ 'title' => 'List',
+ 'description' => 'Find and manage people interacting with your site.',
+ 'access arguments' => array('administer users'),
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ 'file' => 'user.admin.inc',
+ );
+
+ // Permissions and role forms.
+ $items['admin/people/permissions'] = array(
+ 'title' => 'Permissions',
+ 'description' => 'Determine access to features by selecting permissions for roles.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_admin_permissions'),
+ 'access arguments' => array('administer permissions'),
+ 'file' => 'user.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ );
+ $items['admin/people/permissions/list'] = array(
+ 'title' => 'Permissions',
+ 'description' => 'Determine access to features by selecting permissions for roles.',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -8,
+ );
+ $items['admin/people/permissions/roles'] = array(
+ 'title' => 'Roles',
+ 'description' => 'List, edit, or add user roles.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_admin_roles'),
+ 'access arguments' => array('administer permissions'),
+ 'file' => 'user.admin.inc',
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => -5,
+ );
+ $items['admin/people/permissions/roles/edit/%user_role'] = array(
+ 'title' => 'Edit role',
+ 'page arguments' => array('user_admin_role', 5),
+ 'access callback' => 'user_role_edit_access',
+ 'access arguments' => array(5),
+ );
+ $items['admin/people/permissions/roles/delete/%user_role'] = array(
+ 'title' => 'Delete role',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_admin_role_delete_confirm', 5),
+ 'access callback' => 'user_role_edit_access',
+ 'access arguments' => array(5),
+ 'file' => 'user.admin.inc',
+ );
+
+ $items['admin/people/create'] = array(
+ 'title' => 'Add user',
+ 'page arguments' => array('create'),
+ 'access arguments' => array('administer users'),
+ 'type' => MENU_LOCAL_ACTION,
+ );
+
+ // Administration pages.
+ $items['admin/config/people'] = array(
+ 'title' => 'People',
+ 'description' => 'Configure user accounts.',
+ 'position' => 'left',
+ 'weight' => -20,
+ 'page callback' => 'system_admin_menu_block_page',
+ 'access arguments' => array('access administration pages'),
+ 'file' => 'system.admin.inc',
+ 'file path' => drupal_get_path('module', 'system'),
+ );
+ $items['admin/config/people/accounts'] = array(
+ 'title' => 'Account settings',
+ 'description' => 'Configure default behavior of users, including registration requirements, e-mails, fields, and user pictures.',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_admin_settings'),
+ 'access arguments' => array('administer users'),
+ 'file' => 'user.admin.inc',
+ 'weight' => -10,
+ );
+ $items['admin/config/people/accounts/settings'] = array(
+ 'title' => 'Settings',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+
+ $items['user/%user'] = array(
+ 'title' => 'My account',
+ 'title callback' => 'user_page_title',
+ 'title arguments' => array(1),
+ 'page callback' => 'user_view_page',
+ 'page arguments' => array(1),
+ 'access callback' => 'user_view_access',
+ 'access arguments' => array(1),
+ // By assigning a different menu name, this item (and all registered child
+ // paths) are no longer considered as children of 'user'. When accessing the
+ // user account pages, the preferred menu link that is used to build the
+ // active trail (breadcrumb) will be found in this menu (unless there is
+ // more specific link), so the link to 'user' will not be in the breadcrumb.
+ 'menu_name' => 'navigation',
+ );
+
+ $items['user/%user/view'] = array(
+ 'title' => 'View',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'weight' => -10,
+ );
+
+ $items['user/%user/cancel'] = array(
+ 'title' => 'Cancel account',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_cancel_confirm_form', 1),
+ 'access callback' => 'user_cancel_access',
+ 'access arguments' => array(1),
+ 'file' => 'user.pages.inc',
+ );
+
+ $items['user/%user/cancel/confirm/%/%'] = array(
+ 'title' => 'Confirm account cancellation',
+ 'page callback' => 'user_cancel_confirm',
+ 'page arguments' => array(1, 4, 5),
+ 'access callback' => 'user_cancel_access',
+ 'access arguments' => array(1),
+ 'file' => 'user.pages.inc',
+ );
+
+ $items['user/%user/edit'] = array(
+ 'title' => 'Edit',
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_profile_form', 1),
+ 'access callback' => 'user_edit_access',
+ 'access arguments' => array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'file' => 'user.pages.inc',
+ );
+
+ $items['user/%user_category/edit/account'] = array(
+ 'title' => 'Account',
+ 'type' => MENU_DEFAULT_LOCAL_TASK,
+ 'load arguments' => array('%map', '%index'),
+ );
+
+ if (($categories = _user_categories()) && (count($categories) > 1)) {
+ foreach ($categories as $key => $category) {
+ // 'account' is already handled by the MENU_DEFAULT_LOCAL_TASK.
+ if ($category['name'] != 'account') {
+ $items['user/%user_category/edit/' . $category['name']] = array(
+ 'title callback' => 'check_plain',
+ 'title arguments' => array($category['title']),
+ 'page callback' => 'drupal_get_form',
+ 'page arguments' => array('user_profile_form', 1, 3),
+ 'access callback' => isset($category['access callback']) ? $category['access callback'] : 'user_edit_access',
+ 'access arguments' => isset($category['access arguments']) ? $category['access arguments'] : array(1),
+ 'type' => MENU_LOCAL_TASK,
+ 'weight' => $category['weight'],
+ 'load arguments' => array('%map', '%index'),
+ 'tab_parent' => 'user/%/edit',
+ 'file' => 'user.pages.inc',
+ );
+ }
+ }
+ }
+ return $items;
+}
+
+/**
+ * Implements hook_menu_site_status_alter().
+ */
+function user_menu_site_status_alter(&$menu_site_status, $path) {
+ if ($menu_site_status == MENU_SITE_OFFLINE) {
+ // If the site is offline, log out unprivileged users.
+ if (user_is_logged_in() && !user_access('access site in maintenance mode')) {
+ module_load_include('pages.inc', 'user', 'user');
+ user_logout();
+ }
+
+ if (user_is_anonymous()) {
+ switch ($path) {
+ case 'user':
+ // Forward anonymous user to login page.
+ drupal_goto('user/login');
+ case 'user/login':
+ case 'user/password':
+ // Disable offline mode.
+ $menu_site_status = MENU_SITE_ONLINE;
+ break;
+ default:
+ if (strpos($path, 'user/reset/') === 0) {
+ // Disable offline mode.
+ $menu_site_status = MENU_SITE_ONLINE;
+ }
+ break;
+ }
+ }
+ }
+ if (user_is_logged_in()) {
+ if ($path == 'user/login') {
+ // If user is logged in, redirect to 'user' instead of giving 403.
+ drupal_goto('user');
+ }
+ if ($path == 'user/register') {
+ // Authenticated user should be redirected to user edit page.
+ drupal_goto('user/' . $GLOBALS['user']->uid . '/edit');
+ }
+ }
+}
+
+/**
+ * Implements hook_menu_link_alter().
+ */
+function user_menu_link_alter(&$link) {
+ // The path 'user' must be accessible for anonymous users, but only visible
+ // for authenticated users. Authenticated users should see "My account", but
+ // anonymous users should not see it at all. Therefore, invoke
+ // user_translated_menu_link_alter() to conditionally hide the link.
+ if ($link['link_path'] == 'user' && $link['module'] == 'system') {
+ $link['options']['alter'] = TRUE;
+ }
+
+ // Force the Logout link to appear on the top-level of 'user-menu' menu by
+ // default (i.e., unless it has been customized).
+ if ($link['link_path'] == 'user/logout' && $link['module'] == 'system' && empty($link['customized'])) {
+ $link['plid'] = 0;
+ }
+}
+
+/**
+ * Implements hook_translated_menu_link_alter().
+ */
+function user_translated_menu_link_alter(&$link) {
+ // Hide the "User account" link for anonymous users.
+ if ($link['link_path'] == 'user' && $link['module'] == 'system' && user_is_anonymous()) {
+ $link['hidden'] = 1;
+ }
+}
+
+/**
+ * Implements hook_admin_paths().
+ */
+function user_admin_paths() {
+ $paths = array(
+ 'user/*/cancel' => TRUE,
+ 'user/*/edit' => TRUE,
+ 'user/*/edit/*' => TRUE,
+ );
+ return $paths;
+}
+
+/**
+ * Returns $arg or the user ID of the current user if $arg is '%' or empty.
+ *
+ * Deprecated. Use %user_uid_optional instead.
+ *
+ * @todo D8: Remove.
+ */
+function user_uid_only_optional_to_arg($arg) {
+ return user_uid_optional_to_arg($arg);
+}
+
+/**
+ * Load either a specified or the current user account.
+ *
+ * @param $uid
+ * An optional user ID of the user to load. If not provided, the current
+ * user's ID will be used.
+ * @return
+ * A fully-loaded $user object upon successful user load, FALSE if user
+ * cannot be loaded.
+ *
+ * @see user_load()
+ * @todo rethink the naming of this in Drupal 8.
+ */
+function user_uid_optional_load($uid = NULL) {
+ if (!isset($uid)) {
+ $uid = $GLOBALS['user']->uid;
+ }
+ return user_load($uid);
+}
+
+/**
+ * Return a user object after checking if any profile category in the path exists.
+ */
+function user_category_load($uid, &$map, $index) {
+ static $user_categories, $accounts;
+
+ // Cache $account - this load function will get called for each profile tab.
+ if (!isset($accounts[$uid])) {
+ $accounts[$uid] = user_load($uid);
+ }
+ $valid = TRUE;
+ if ($account = $accounts[$uid]) {
+ // Since the path is like user/%/edit/category_name, the category name will
+ // be at a position 2 beyond the index corresponding to the % wildcard.
+ $category_index = $index + 2;
+ // Valid categories may contain slashes, and hence need to be imploded.
+ $category_path = implode('/', array_slice($map, $category_index));
+ if ($category_path) {
+ // Check that the requested category exists.
+ $valid = FALSE;
+ if (!isset($user_categories)) {
+ $user_categories = _user_categories();
+ }
+ foreach ($user_categories as $category) {
+ if ($category['name'] == $category_path) {
+ $valid = TRUE;
+ // Truncate the map array in case the category name had slashes.
+ $map = array_slice($map, 0, $category_index);
+ // Assign the imploded category name to the last map element.
+ $map[$category_index] = $category_path;
+ break;
+ }
+ }
+ }
+ }
+ return $valid ? $account : FALSE;
+}
+
+/**
+ * Returns $arg or the user ID of the current user if $arg is '%' or empty.
+ *
+ * @todo rethink the naming of this in Drupal 8.
+ */
+function user_uid_optional_to_arg($arg) {
+ // Give back the current user uid when called from eg. tracker, aka.
+ // with an empty arg. Also use the current user uid when called from
+ // the menu with a % for the current account link.
+ return empty($arg) || $arg == '%' ? $GLOBALS['user']->uid : $arg;
+}
+
+/**
+ * Menu item title callback for the 'user' path.
+ *
+ * Anonymous users should see "User account", but authenticated users are
+ * expected to see "My account".
+ */
+function user_menu_title() {
+ return user_is_logged_in() ? t('My account') : t('User account');
+}
+
+/**
+ * Menu item title callback - use the user name.
+ */
+function user_page_title($account) {
+ return is_object($account) ? format_username($account) : '';
+}
+
+/**
+ * Discover which external authentication module(s) authenticated a username.
+ *
+ * @param $authname
+ * A username used by an external authentication module.
+ * @return
+ * An associative array with module as key and username as value.
+ */
+function user_get_authmaps($authname = NULL) {
+ $authmaps = db_query("SELECT module, authname FROM {authmap} WHERE authname = :authname", array(':authname' => $authname))->fetchAllKeyed();
+ return count($authmaps) ? $authmaps : 0;
+}
+
+/**
+ * Save mappings of which external authentication module(s) authenticated
+ * a user. Maps external usernames to user ids in the users table.
+ *
+ * @param $account
+ * A user object.
+ * @param $authmaps
+ * An associative array with a compound key and the username as the value.
+ * The key is made up of 'authname_' plus the name of the external authentication
+ * module.
+ * @see user_external_login_register()
+ */
+function user_set_authmaps($account, $authmaps) {
+ foreach ($authmaps as $key => $value) {
+ $module = explode('_', $key, 2);
+ if ($value) {
+ db_merge('authmap')
+ ->key(array(
+ 'uid' => $account->uid,
+ 'module' => $module[1],
+ ))
+ ->fields(array('authname' => $value))
+ ->execute();
+ }
+ else {
+ db_delete('authmap')
+ ->condition('uid', $account->uid)
+ ->condition('module', $module[1])
+ ->execute();
+ }
+ }
+}
+
+/**
+ * Form builder; the main user login form.
+ *
+ * @ingroup forms
+ */
+function user_login($form, &$form_state) {
+ global $user;
+
+ // If we are already logged on, go to the user page instead.
+ if ($user->uid) {
+ drupal_goto('user/' . $user->uid);
+ }
+
+ // Display login form:
+ $form['name'] = array('#type' => 'textfield',
+ '#title' => t('Username'),
+ '#size' => 60,
+ '#maxlength' => USERNAME_MAX_LENGTH,
+ '#required' => TRUE,
+ );
+
+ $form['name']['#description'] = t('Enter your @s username.', array('@s' => variable_get('site_name', 'Drupal')));
+ $form['pass'] = array('#type' => 'password',
+ '#title' => t('Password'),
+ '#description' => t('Enter the password that accompanies your username.'),
+ '#required' => TRUE,
+ );
+ $form['#validate'] = user_login_default_validators();
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Log in'));
+
+ return $form;
+}
+
+/**
+ * Set up a series for validators which check for blocked users,
+ * then authenticate against local database, then return an error if
+ * authentication fails. Distributed authentication modules are welcome
+ * to use hook_form_alter() to change this series in order to
+ * authenticate against their user database instead of the local users
+ * table. If a distributed authentication module is successful, it
+ * should set $form_state['uid'] to a user ID.
+ *
+ * We use three validators instead of one since external authentication
+ * modules usually only need to alter the second validator.
+ *
+ * @see user_login_name_validate()
+ * @see user_login_authenticate_validate()
+ * @see user_login_final_validate()
+ * @return array
+ * A simple list of validate functions.
+ */
+function user_login_default_validators() {
+ return array('user_login_name_validate', 'user_login_authenticate_validate', 'user_login_final_validate');
+}
+
+/**
+ * A FAPI validate handler. Sets an error if supplied username has been blocked.
+ */
+function user_login_name_validate($form, &$form_state) {
+ if (isset($form_state['values']['name']) && user_is_blocked($form_state['values']['name'])) {
+ // Blocked in user administration.
+ form_set_error('name', t('The username %name has not been activated or is blocked.', array('%name' => $form_state['values']['name'])));
+ }
+}
+
+/**
+ * A validate handler on the login form. Check supplied username/password
+ * against local users table. If successful, $form_state['uid']
+ * is set to the matching user ID.
+ */
+function user_login_authenticate_validate($form, &$form_state) {
+ $password = trim($form_state['values']['pass']);
+ if (!empty($form_state['values']['name']) && !empty($password)) {
+ // Do not allow any login from the current user's IP if the limit has been
+ // reached. Default is 50 failed attempts allowed in one hour. This is
+ // independent of the per-user limit to catch attempts from one IP to log
+ // in to many different user accounts. We have a reasonably high limit
+ // since there may be only one apparent IP for all users at an institution.
+ if (!flood_is_allowed('failed_login_attempt_ip', variable_get('user_failed_login_ip_limit', 50), variable_get('user_failed_login_ip_window', 3600))) {
+ $form_state['flood_control_triggered'] = 'ip';
+ return;
+ }
+ $account = db_query("SELECT * FROM {users} WHERE name = :name AND status = 1", array(':name' => $form_state['values']['name']))->fetchObject();
+ if ($account) {
+ if (variable_get('user_failed_login_identifier_uid_only', FALSE)) {
+ // Register flood events based on the uid only, so they apply for any
+ // IP address. This is the most secure option.
+ $identifier = $account->uid;
+ }
+ else {
+ // The default identifier is a combination of uid and IP address. This
+ // is less secure but more resistant to denial-of-service attacks that
+ // could lock out all users with public user names.
+ $identifier = $account->uid . '-' . ip_address();
+ }
+ $form_state['flood_control_user_identifier'] = $identifier;
+
+ // Don't allow login if the limit for this user has been reached.
+ // Default is to allow 5 failed attempts every 6 hours.
+ if (!flood_is_allowed('failed_login_attempt_user', variable_get('user_failed_login_user_limit', 5), variable_get('user_failed_login_user_window', 21600), $identifier)) {
+ $form_state['flood_control_triggered'] = 'user';
+ return;
+ }
+ }
+ // We are not limited by flood control, so try to authenticate.
+ // Set $form_state['uid'] as a flag for user_login_final_validate().
+ $form_state['uid'] = user_authenticate($form_state['values']['name'], $password);
+ }
+}
+
+/**
+ * The final validation handler on the login form.
+ *
+ * Sets a form error if user has not been authenticated, or if too many
+ * logins have been attempted. This validation function should always
+ * be the last one.
+ */
+function user_login_final_validate($form, &$form_state) {
+ if (empty($form_state['uid'])) {
+ // Always register an IP-based failed login event.
+ flood_register_event('failed_login_attempt_ip', variable_get('user_failed_login_ip_window', 3600));
+ // Register a per-user failed login event.
+ if (isset($form_state['flood_control_user_identifier'])) {
+ flood_register_event('failed_login_attempt_user', variable_get('user_failed_login_user_window', 21600), $form_state['flood_control_user_identifier']);
+ }
+
+ if (isset($form_state['flood_control_triggered'])) {
+ if ($form_state['flood_control_triggered'] == 'user') {
+ form_set_error('name', format_plural(variable_get('user_failed_login_user_limit', 5), 'Sorry, there has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', 'Sorry, there have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
+ }
+ else {
+ // We did not find a uid, so the limit is IP-based.
+ form_set_error('name', t('Sorry, too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
+ }
+ }
+ else {
+ form_set_error('name', t('Sorry, unrecognized username or password. <a href="@password">Have you forgotten your password?</a>', array('@password' => url('user/password'))));
+ watchdog('user', 'Login attempt failed for %user.', array('%user' => $form_state['values']['name']));
+ }
+ }
+ elseif (isset($form_state['flood_control_user_identifier'])) {
+ // Clear past failures for this user so as not to block a user who might
+ // log in and out more than once in an hour.
+ flood_clear_event('failed_login_attempt_user', $form_state['flood_control_user_identifier']);
+ }
+}
+
+/**
+ * Try to validate the user's login credentials locally.
+ *
+ * @param $name
+ * User name to authenticate.
+ * @param $password
+ * A plain-text password, such as trimmed text from form values.
+ * @return
+ * The user's uid on success, or FALSE on failure to authenticate.
+ */
+function user_authenticate($name, $password) {
+ $uid = FALSE;
+ if (!empty($name) && !empty($password)) {
+ $account = user_load_by_name($name);
+ if ($account) {
+ // Allow alternate password hashing schemes.
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ if (user_check_password($password, $account)) {
+ // Successful authentication.
+ $uid = $account->uid;
+
+ // Update user to new password scheme if needed.
+ if (user_needs_new_hash($account)) {
+ user_save($account, array('pass' => $password));
+ }
+ }
+ }
+ }
+ return $uid;
+}
+
+/**
+ * Finalize the login process. Must be called when logging in a user.
+ *
+ * The function records a watchdog message about the new session, saves the
+ * login timestamp, calls hook_user op 'login' and generates a new session. *
+ */
+function user_login_finalize(&$edit = array()) {
+ global $user;
+ watchdog('user', 'Session opened for %name.', array('%name' => $user->name));
+ // Update the user table timestamp noting user has logged in.
+ // This is also used to invalidate one-time login links.
+ $user->login = REQUEST_TIME;
+ db_update('users')
+ ->fields(array('login' => $user->login))
+ ->condition('uid', $user->uid)
+ ->execute();
+
+ // Regenerate the session ID to prevent against session fixation attacks.
+ // This is called before hook_user in case one of those functions fails
+ // or incorrectly does a redirect which would leave the old session in place.
+ drupal_session_regenerate();
+
+ user_module_invoke('login', $edit, $user);
+}
+
+/**
+ * Submit handler for the login form. Load $user object and perform standard login
+ * tasks. The user is then redirected to the My Account page. Setting the
+ * destination in the query string overrides the redirect.
+ */
+function user_login_submit($form, &$form_state) {
+ global $user;
+ $user = user_load($form_state['uid']);
+ $form_state['redirect'] = 'user/' . $user->uid;
+
+ user_login_finalize($form_state);
+}
+
+/**
+ * Helper function for authentication modules. Either logs in or registers
+ * the current user, based on username. Either way, the global $user object is
+ * populated and login tasks are performed.
+ */
+function user_external_login_register($name, $module) {
+ $account = user_external_load($name);
+ if (!$account) {
+ // Register this new user.
+ $userinfo = array(
+ 'name' => $name,
+ 'pass' => user_password(),
+ 'init' => $name,
+ 'status' => 1,
+ 'access' => REQUEST_TIME
+ );
+ $account = user_save(drupal_anonymous_user(), $userinfo);
+ // Terminate if an error occurred during user_save().
+ if (!$account) {
+ drupal_set_message(t("Error saving user account."), 'error');
+ return;
+ }
+ user_set_authmaps($account, array("authname_$module" => $name));
+ }
+
+ // Log user in.
+ $form_state['uid'] = $account->uid;
+ user_login_submit(array(), $form_state);
+}
+
+/**
+ * Generates a unique URL for a user to login and reset their password.
+ *
+ * @param object $account
+ * An object containing the user account.
+ *
+ * @return
+ * A unique URL that provides a one-time log in for the user, from which
+ * they can change their password.
+ */
+function user_pass_reset_url($account) {
+ $timestamp = REQUEST_TIME;
+ return url("user/reset/$account->uid/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login), array('absolute' => TRUE));
+}
+
+/**
+ * Generates a URL to confirm an account cancellation request.
+ *
+ * @param object $account
+ * The user account object, which must contain at least the following
+ * properties:
+ * - uid: The user uid number.
+ * - pass: The hashed user password string.
+ * - login: The user login name.
+ *
+ * @return
+ * A unique URL that may be used to confirm the cancellation of the user
+ * account.
+ *
+ * @see user_mail_tokens()
+ * @see user_cancel_confirm()
+ */
+function user_cancel_url($account) {
+ $timestamp = REQUEST_TIME;
+ return url("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login), array('absolute' => TRUE));
+}
+
+/**
+ * Creates a unique hash value for use in time-dependent per-user URLs.
+ *
+ * This hash is normally used to build a unique and secure URL that is sent to
+ * the user by email for purposes such as resetting the user's password. In
+ * order to validate the URL, the same hash can be generated again, from the
+ * same information, and compared to the hash value from the URL. The URL
+ * normally contains both the time stamp and the numeric user ID. The login
+ * name and hashed password are retrieved from the database as necessary. For a
+ * usage example, see user_cancel_url() and user_cancel_confirm().
+ *
+ * @param $password
+ * The hashed user account password value.
+ * @param $timestamp
+ * A unix timestamp.
+ * @param $login
+ * The user account login name.
+ *
+ * @return
+ * A string that is safe for use in URLs and SQL statements.
+ */
+function user_pass_rehash($password, $timestamp, $login) {
+ return drupal_hmac_base64($timestamp . $login, drupal_get_hash_salt() . $password);
+}
+
+/**
+ * Cancel a user account.
+ *
+ * Since the user cancellation process needs to be run in a batch, either
+ * Form API will invoke it, or batch_process() needs to be invoked after calling
+ * this function and should define the path to redirect to.
+ *
+ * @param $edit
+ * An array of submitted form values.
+ * @param $uid
+ * The user ID of the user account to cancel.
+ * @param $method
+ * The account cancellation method to use.
+ *
+ * @see _user_cancel()
+ */
+function user_cancel($edit, $uid, $method) {
+ global $user;
+
+ $account = user_load($uid);
+
+ if (!$account) {
+ drupal_set_message(t('The user account %id does not exist.', array('%id' => $uid)), 'error');
+ watchdog('user', 'Attempted to cancel non-existing user account: %id.', array('%id' => $uid), WATCHDOG_ERROR);
+ return;
+ }
+
+ // Initialize batch (to set title).
+ $batch = array(
+ 'title' => t('Cancelling account'),
+ 'operations' => array(),
+ );
+ batch_set($batch);
+
+ // Modules use hook_user_delete() to respond to deletion.
+ if ($method != 'user_cancel_delete') {
+ // Allow modules to add further sets to this batch.
+ module_invoke_all('user_cancel', $edit, $account, $method);
+ }
+
+ // Finish the batch and actually cancel the account.
+ $batch = array(
+ 'title' => t('Cancelling user account'),
+ 'operations' => array(
+ array('_user_cancel', array($edit, $account, $method)),
+ ),
+ );
+ batch_set($batch);
+
+ // Batch processing is either handled via Form API or has to be invoked
+ // manually.
+}
+
+/**
+ * Last batch processing step for cancelling a user account.
+ *
+ * Since batch and session API require a valid user account, the actual
+ * cancellation of a user account needs to happen last.
+ *
+ * @see user_cancel()
+ */
+function _user_cancel($edit, $account, $method) {
+ global $user;
+
+ switch ($method) {
+ case 'user_cancel_block':
+ case 'user_cancel_block_unpublish':
+ default:
+ // Send account blocked notification if option was checked.
+ if (!empty($edit['user_cancel_notify'])) {
+ _user_mail_notify('status_blocked', $account);
+ }
+ user_save($account, array('status' => 0));
+ drupal_set_message(t('%name has been disabled.', array('%name' => $account->name)));
+ watchdog('user', 'Blocked user: %name %email.', array('%name' => $account->name, '%email' => '<' . $account->mail . '>'), WATCHDOG_NOTICE);
+ break;
+
+ case 'user_cancel_reassign':
+ case 'user_cancel_delete':
+ // Send account canceled notification if option was checked.
+ if (!empty($edit['user_cancel_notify'])) {
+ _user_mail_notify('status_canceled', $account);
+ }
+ user_delete($account->uid);
+ drupal_set_message(t('%name has been deleted.', array('%name' => $account->name)));
+ watchdog('user', 'Deleted user: %name %email.', array('%name' => $account->name, '%email' => '<' . $account->mail . '>'), WATCHDOG_NOTICE);
+ break;
+ }
+
+ // After cancelling account, ensure that user is logged out.
+ if ($account->uid == $user->uid) {
+ // Destroy the current session, and reset $user to the anonymous user.
+ session_destroy();
+ }
+
+ // Clear the cache for anonymous users.
+ cache_clear_all();
+}
+
+/**
+ * Delete a user.
+ *
+ * @param $uid
+ * A user ID.
+ */
+function user_delete($uid) {
+ user_delete_multiple(array($uid));
+}
+
+/**
+ * Delete multiple user accounts.
+ *
+ * @param $uids
+ * An array of user IDs.
+ */
+function user_delete_multiple(array $uids) {
+ if (!empty($uids)) {
+ $accounts = user_load_multiple($uids, array());
+
+ $transaction = db_transaction();
+ try {
+ foreach ($accounts as $uid => $account) {
+ module_invoke_all('user_delete', $account);
+ module_invoke_all('entity_delete', $account, 'user');
+ field_attach_delete('user', $account);
+ drupal_session_destroy_uid($account->uid);
+ }
+
+ db_delete('users')
+ ->condition('uid', $uids, 'IN')
+ ->execute();
+ db_delete('users_roles')
+ ->condition('uid', $uids, 'IN')
+ ->execute();
+ db_delete('authmap')
+ ->condition('uid', $uids, 'IN')
+ ->execute();
+ }
+ catch (Exception $e) {
+ $transaction->rollback();
+ watchdog_exception('user', $e);
+ throw $e;
+ }
+ entity_get_controller('user')->resetCache();
+ }
+}
+
+/**
+ * Page callback wrapper for user_view().
+ */
+function user_view_page($account) {
+ // An administrator may try to view a non-existent account,
+ // so we give them a 404 (versus a 403 for non-admins).
+ return is_object($account) ? user_view($account) : MENU_NOT_FOUND;
+}
+
+/**
+ * Generate an array for rendering the given user.
+ *
+ * When viewing a user profile, the $page array contains:
+ *
+ * - $page['content']['Profile Category']:
+ * Profile categories keyed by their human-readable names.
+ * - $page['content']['Profile Category']['profile_machine_name']:
+ * Profile fields keyed by their machine-readable names.
+ * - $page['content']['user_picture']:
+ * User's rendered picture.
+ * - $page['content']['summary']:
+ * Contains the default "History" profile data for a user.
+ * - $page['content']['#account']:
+ * The user account of the profile being viewed.
+ *
+ * To theme user profiles, copy modules/user/user-profile.tpl.php
+ * to your theme directory, and edit it as instructed in that file's comments.
+ *
+ * @param $account
+ * A user object.
+ * @param $view_mode
+ * View mode, e.g. 'full'.
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ *
+ * @return
+ * An array as expected by drupal_render().
+ */
+function user_view($account, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Retrieve all profile fields and attach to $account->content.
+ user_build_content($account, $view_mode, $langcode);
+
+ $build = $account->content;
+ // We don't need duplicate rendering info in account->content.
+ unset($account->content);
+
+ $build += array(
+ '#theme' => 'user_profile',
+ '#account' => $account,
+ '#view_mode' => $view_mode,
+ '#language' => $langcode,
+ );
+
+ // Allow modules to modify the structured user.
+ $type = 'user';
+ drupal_alter(array('user_view', 'entity_view'), $build, $type);
+
+ return $build;
+}
+
+/**
+ * Builds a structured array representing the profile content.
+ *
+ * @param $account
+ * A user object.
+ * @param $view_mode
+ * View mode, e.g. 'full'.
+ * @param $langcode
+ * (optional) A language code to use for rendering. Defaults to the global
+ * content language of the current request.
+ */
+function user_build_content($account, $view_mode = 'full', $langcode = NULL) {
+ if (!isset($langcode)) {
+ $langcode = $GLOBALS['language_content']->language;
+ }
+
+ // Remove previously built content, if exists.
+ $account->content = array();
+
+ // Build fields content.
+ field_attach_prepare_view('user', array($account->uid => $account), $view_mode, $langcode);
+ entity_prepare_view('user', array($account->uid => $account), $langcode);
+ $account->content += field_attach_view('user', $account, $view_mode, $langcode);
+
+ // Populate $account->content with a render() array.
+ module_invoke_all('user_view', $account, $view_mode, $langcode);
+ module_invoke_all('entity_view', $account, 'user', $view_mode, $langcode);
+}
+
+/**
+ * Implements hook_mail().
+ */
+function user_mail($key, &$message, $params) {
+ $language = $message['language'];
+ $variables = array('user' => $params['account']);
+ $message['subject'] .= _user_mail_text($key . '_subject', $language, $variables);
+ $message['body'][] = _user_mail_text($key . '_body', $language, $variables);
+}
+
+/**
+ * Returns a mail string for a variable name.
+ *
+ * Used by user_mail() and the settings forms to retrieve strings.
+ */
+function _user_mail_text($key, $language = NULL, $variables = array(), $replace = TRUE) {
+ $langcode = isset($language) ? $language->language : NULL;
+
+ if ($admin_setting = variable_get('user_mail_' . $key, FALSE)) {
+ // An admin setting overrides the default string.
+ $text = $admin_setting;
+ }
+ else {
+ // No override, return default string.
+ switch ($key) {
+ case 'register_no_approval_required_subject':
+ $text = t('Account details for [user:name] at [site:name]', array(), array('langcode' => $langcode));
+ break;
+ case 'register_no_approval_required_body':
+ $text = t("[user:name],
+
+Thank you for registering at [site:name]. You may now log in by clicking this link or copying and pasting it to your browser:
+
+[user:one-time-login-url]
+
+This link can only be used once to log in and will lead you to a page where you can set your password.
+
+After setting your password, you will be able to log in at [site:login-url] in the future using:
+
+username: [user:name]
+password: Your password
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'register_admin_created_subject':
+ $text = t('An administrator created an account for you at [site:name]', array(), array('langcode' => $langcode));
+ break;
+ case 'register_admin_created_body':
+ $text = t("[user:name],
+
+A site administrator at [site:name] has created an account for you. You may now log in by clicking this link or copying and pasting it to your browser:
+
+[user:one-time-login-url]
+
+This link can only be used once to log in and will lead you to a page where you can set your password.
+
+After setting your password, you will be able to log in at [site:login-url] in the future using:
+
+username: [user:name]
+password: Your password
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'register_pending_approval_subject':
+ case 'register_pending_approval_admin_subject':
+ $text = t('Account details for [user:name] at [site:name] (pending admin approval)', array(), array('langcode' => $langcode));
+ break;
+ case 'register_pending_approval_body':
+ $text = t("[user:name],
+
+Thank you for registering at [site:name]. Your application for an account is currently pending approval. Once it has been approved, you will receive another e-mail containing information about how to log in, set your password, and other details.
+
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+ case 'register_pending_approval_admin_body':
+ $text = t("[user:name] has applied for an account.
+
+[user:edit-url]", array(), array('langcode' => $langcode));
+ break;
+
+ case 'password_reset_subject':
+ $text = t('Replacement login information for [user:name] at [site:name]', array(), array('langcode' => $langcode));
+ break;
+ case 'password_reset_body':
+ $text = t("[user:name],
+
+A request to reset the password for your account has been made at [site:name].
+
+You may now log in by clicking this link or copying and pasting it to your browser:
+
+[user:one-time-login-url]
+
+This link can only be used once to log in and will lead you to a page where you can set your password. It expires after one day and nothing will happen if it's not used.
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'status_activated_subject':
+ $text = t('Account details for [user:name] at [site:name] (approved)', array(), array('langcode' => $langcode));
+ break;
+ case 'status_activated_body':
+ $text = t("[user:name],
+
+Your account at [site:name] has been activated.
+
+You may now log in by clicking this link or copying and pasting it into your browser:
+
+[user:one-time-login-url]
+
+This link can only be used once to log in and will lead you to a page where you can set your password.
+
+After setting your password, you will be able to log in at [site:login-url] in the future using:
+
+username: [user:name]
+password: Your password
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'status_blocked_subject':
+ $text = t('Account details for [user:name] at [site:name] (blocked)', array(), array('langcode' => $langcode));
+ break;
+ case 'status_blocked_body':
+ $text = t("[user:name],
+
+Your account on [site:name] has been blocked.
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'cancel_confirm_subject':
+ $text = t('Account cancellation request for [user:name] at [site:name]', array(), array('langcode' => $langcode));
+ break;
+ case 'cancel_confirm_body':
+ $text = t("[user:name],
+
+A request to cancel your account has been made at [site:name].
+
+You may now cancel your account on [site:url-brief] by clicking this link or copying and pasting it into your browser:
+
+[user:cancel-url]
+
+NOTE: The cancellation of your account is not reversible.
+
+This link expires in one day and nothing will happen if it is not used.
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+
+ case 'status_canceled_subject':
+ $text = t('Account details for [user:name] at [site:name] (canceled)', array(), array('langcode' => $langcode));
+ break;
+ case 'status_canceled_body':
+ $text = t("[user:name],
+
+Your account on [site:name] has been canceled.
+
+-- [site:name] team", array(), array('langcode' => $langcode));
+ break;
+ }
+ }
+
+ if ($replace) {
+ // We do not sanitize the token replacement, since the output of this
+ // replacement is intended for an e-mail message, not a web browser.
+ return token_replace($text, $variables, array('language' => $language, 'callback' => 'user_mail_tokens', 'sanitize' => FALSE));
+ }
+
+ return $text;
+}
+
+/**
+ * Token callback to add unsafe tokens for user mails.
+ *
+ * This function is used by the token_replace() call at the end of
+ * _user_mail_text() to set up some additional tokens that can be
+ * used in email messages generated by user_mail().
+ *
+ * @param $replacements
+ * An associative array variable containing mappings from token names to
+ * values (for use with strtr()).
+ * @param $data
+ * An associative array of token replacement values. If the 'user' element
+ * exists, it must contain a user account object with the following
+ * properties:
+ * - login: The account login name.
+ * - pass: The hashed account login password.
+ * @param $options
+ * Unused parameter required by the token_replace() function.
+ */
+function user_mail_tokens(&$replacements, $data, $options) {
+ if (isset($data['user'])) {
+ $replacements['[user:one-time-login-url]'] = user_pass_reset_url($data['user']);
+ $replacements['[user:cancel-url]'] = user_cancel_url($data['user']);
+ }
+}
+
+/*** Administrative features ***********************************************/
+
+/**
+ * Retrieve an array of roles matching specified conditions.
+ *
+ * @param $membersonly
+ * Set this to TRUE to exclude the 'anonymous' role.
+ * @param $permission
+ * A string containing a permission. If set, only roles containing that
+ * permission are returned.
+ *
+ * @return
+ * An associative array with the role id as the key and the role name as
+ * value.
+ */
+function user_roles($membersonly = FALSE, $permission = NULL) {
+ $user_roles = &drupal_static(__FUNCTION__);
+
+ // Do not cache roles for specific permissions. This data is not requested
+ // frequently enough to justify the additional memory use.
+ if (empty($permission)) {
+ $cid = $membersonly ? DRUPAL_AUTHENTICATED_RID : DRUPAL_ANONYMOUS_RID;
+ if (isset($user_roles[$cid])) {
+ return $user_roles[$cid];
+ }
+ }
+
+ $query = db_select('role', 'r');
+ $query->addTag('translatable');
+ $query->fields('r', array('rid', 'name'));
+ $query->orderBy('weight');
+ $query->orderBy('name');
+ if (!empty($permission)) {
+ $query->innerJoin('role_permission', 'p', 'r.rid = p.rid');
+ $query->condition('p.permission', $permission);
+ }
+ $result = $query->execute();
+
+ $roles = array();
+ foreach ($result as $role) {
+ switch ($role->rid) {
+ // We only translate the built in role names
+ case DRUPAL_ANONYMOUS_RID:
+ if (!$membersonly) {
+ $roles[$role->rid] = t($role->name);
+ }
+ break;
+ case DRUPAL_AUTHENTICATED_RID:
+ $roles[$role->rid] = t($role->name);
+ break;
+ default:
+ $roles[$role->rid] = $role->name;
+ }
+ }
+
+ if (empty($permission)) {
+ $user_roles[$cid] = $roles;
+ return $user_roles[$cid];
+ }
+
+ return $roles;
+}
+
+/**
+ * Fetches a user role by role ID.
+ *
+ * @param $rid
+ * An integer representing the role ID.
+ *
+ * @return
+ * A fully-loaded role object if a role with the given ID exists, or FALSE
+ * otherwise.
+ *
+ * @see user_role_load_by_name()
+ */
+function user_role_load($rid) {
+ return db_select('role', 'r')
+ ->fields('r')
+ ->condition('rid', $rid)
+ ->execute()
+ ->fetchObject();
+}
+
+/**
+ * Fetches a user role by role name.
+ *
+ * @param $role_name
+ * A string representing the role name.
+ *
+ * @return
+ * A fully-loaded role object if a role with the given name exists, or FALSE
+ * otherwise.
+ *
+ * @see user_role_load()
+ */
+function user_role_load_by_name($role_name) {
+ return db_select('role', 'r')
+ ->fields('r')
+ ->condition('name', $role_name)
+ ->execute()
+ ->fetchObject();
+}
+
+/**
+ * Save a user role to the database.
+ *
+ * @param $role
+ * A role object to modify or add. If $role->rid is not specified, a new
+ * role will be created.
+ * @return
+ * Status constant indicating if role was created or updated.
+ * Failure to write the user role record will return FALSE. Otherwise.
+ * SAVED_NEW or SAVED_UPDATED is returned depending on the operation
+ * performed.
+ */
+function user_role_save($role) {
+ if ($role->name) {
+ // Prevent leading and trailing spaces in role names.
+ $role->name = trim($role->name);
+ }
+ if (!isset($role->weight)) {
+ // Set a role weight to make this new role last.
+ $query = db_select('role');
+ $query->addExpression('MAX(weight)');
+ $role->weight = $query->execute()->fetchField() + 1;
+ }
+
+ // Let modules modify the user role before it is saved to the database.
+ module_invoke_all('user_role_presave', $role);
+
+ if (!empty($role->rid) && $role->name) {
+ $status = drupal_write_record('role', $role, 'rid');
+ module_invoke_all('user_role_update', $role);
+ }
+ else {
+ $status = drupal_write_record('role', $role);
+ module_invoke_all('user_role_insert', $role);
+ }
+
+ // Clear the user access cache.
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+
+ return $status;
+}
+
+/**
+ * Delete a user role from database.
+ *
+ * @param $role
+ * A string with the role name, or an integer with the role ID.
+ */
+function user_role_delete($role) {
+ if (is_int($role)) {
+ $role = user_role_load($role);
+ }
+ else {
+ $role = user_role_load_by_name($role);
+ }
+
+ db_delete('role')
+ ->condition('rid', $role->rid)
+ ->execute();
+ db_delete('role_permission')
+ ->condition('rid', $role->rid)
+ ->execute();
+ // Update the users who have this role set:
+ db_delete('users_roles')
+ ->condition('rid', $role->rid)
+ ->execute();
+
+ module_invoke_all('user_role_delete', $role);
+
+ // Clear the user access cache.
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+}
+
+/**
+ * Menu access callback for user role editing.
+ */
+function user_role_edit_access($role) {
+ // Prevent the system-defined roles from being altered or removed.
+ if ($role->rid == DRUPAL_ANONYMOUS_RID || $role->rid == DRUPAL_AUTHENTICATED_RID) {
+ return FALSE;
+ }
+
+ return user_access('administer permissions');
+}
+
+/**
+ * Determine the modules that permissions belong to.
+ *
+ * @return
+ * An associative array in the format $permission => $module.
+ */
+function user_permission_get_modules() {
+ $permissions = array();
+ foreach (module_implements('permission') as $module) {
+ $perms = module_invoke($module, 'permission');
+ foreach ($perms as $key => $value) {
+ $permissions[$key] = $module;
+ }
+ }
+ return $permissions;
+}
+
+/**
+ * Change permissions for a user role.
+ *
+ * This function may be used to grant and revoke multiple permissions at once.
+ * For example, when a form exposes checkboxes to configure permissions for a
+ * role, the form submit handler may directly pass the submitted values for the
+ * checkboxes form element to this function.
+ *
+ * @param $rid
+ * The ID of a user role to alter.
+ * @param $permissions
+ * An associative array, where the key holds the permission name and the value
+ * determines whether to grant or revoke that permission. Any value that
+ * evaluates to TRUE will cause the permission to be granted. Any value that
+ * evaluates to FALSE will cause the permission to be revoked.
+ * @code
+ * array(
+ * 'administer nodes' => 0, // Revoke 'administer nodes'
+ * 'administer blocks' => FALSE, // Revoke 'administer blocks'
+ * 'access user profiles' => 1, // Grant 'access user profiles'
+ * 'access content' => TRUE, // Grant 'access content'
+ * 'access comments' => 'access comments', // Grant 'access comments'
+ * )
+ * @endcode
+ * Existing permissions are not changed, unless specified in $permissions.
+ *
+ * @see user_role_grant_permissions()
+ * @see user_role_revoke_permissions()
+ */
+function user_role_change_permissions($rid, array $permissions = array()) {
+ // Grant new permissions for the role.
+ $grant = array_filter($permissions);
+ if (!empty($grant)) {
+ user_role_grant_permissions($rid, array_keys($grant));
+ }
+ // Revoke permissions for the role.
+ $revoke = array_diff_assoc($permissions, $grant);
+ if (!empty($revoke)) {
+ user_role_revoke_permissions($rid, array_keys($revoke));
+ }
+}
+
+/**
+ * Grant permissions to a user role.
+ *
+ * @param $rid
+ * The ID of a user role to alter.
+ * @param $permissions
+ * A list of permission names to grant.
+ *
+ * @see user_role_change_permissions()
+ * @see user_role_revoke_permissions()
+ */
+function user_role_grant_permissions($rid, array $permissions = array()) {
+ $modules = user_permission_get_modules();
+ // Grant new permissions for the role.
+ foreach ($permissions as $name) {
+ db_merge('role_permission')
+ ->key(array(
+ 'rid' => $rid,
+ 'permission' => $name,
+ ))
+ ->fields(array(
+ 'module' => $modules[$name],
+ ))
+ ->execute();
+ }
+
+ // Clear the user access cache.
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+}
+
+/**
+ * Revoke permissions from a user role.
+ *
+ * @param $rid
+ * The ID of a user role to alter.
+ * @param $permissions
+ * A list of permission names to revoke.
+ *
+ * @see user_role_change_permissions()
+ * @see user_role_grant_permissions()
+ */
+function user_role_revoke_permissions($rid, array $permissions = array()) {
+ // Revoke permissions for the role.
+ db_delete('role_permission')
+ ->condition('rid', $rid)
+ ->condition('permission', $permissions, 'IN')
+ ->execute();
+
+ // Clear the user access cache.
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+}
+
+/**
+ * Implements hook_user_operations().
+ */
+function user_user_operations($form = array(), $form_state = array()) {
+ $operations = array(
+ 'unblock' => array(
+ 'label' => t('Unblock the selected users'),
+ 'callback' => 'user_user_operations_unblock',
+ ),
+ 'block' => array(
+ 'label' => t('Block the selected users'),
+ 'callback' => 'user_user_operations_block',
+ ),
+ 'cancel' => array(
+ 'label' => t('Cancel the selected user accounts'),
+ ),
+ );
+
+ if (user_access('administer permissions')) {
+ $roles = user_roles(TRUE);
+ unset($roles[DRUPAL_AUTHENTICATED_RID]); // Can't edit authenticated role.
+
+ $add_roles = array();
+ foreach ($roles as $key => $value) {
+ $add_roles['add_role-' . $key] = $value;
+ }
+
+ $remove_roles = array();
+ foreach ($roles as $key => $value) {
+ $remove_roles['remove_role-' . $key] = $value;
+ }
+
+ if (count($roles)) {
+ $role_operations = array(
+ t('Add a role to the selected users') => array(
+ 'label' => $add_roles,
+ ),
+ t('Remove a role from the selected users') => array(
+ 'label' => $remove_roles,
+ ),
+ );
+
+ $operations += $role_operations;
+ }
+ }
+
+ // If the form has been posted, we need to insert the proper data for
+ // role editing if necessary.
+ if (!empty($form_state['submitted'])) {
+ $operation_rid = explode('-', $form_state['values']['operation']);
+ $operation = $operation_rid[0];
+ if ($operation == 'add_role' || $operation == 'remove_role') {
+ $rid = $operation_rid[1];
+ if (user_access('administer permissions')) {
+ $operations[$form_state['values']['operation']] = array(
+ 'callback' => 'user_multiple_role_edit',
+ 'callback arguments' => array($operation, $rid),
+ );
+ }
+ else {
+ watchdog('security', 'Detected malicious attempt to alter protected user fields.', array(), WATCHDOG_WARNING);
+ return;
+ }
+ }
+ }
+
+ return $operations;
+}
+
+/**
+ * Callback function for admin mass unblocking users.
+ */
+function user_user_operations_unblock($accounts) {
+ $accounts = user_load_multiple($accounts);
+ foreach ($accounts as $account) {
+ // Skip unblocking user if they are already unblocked.
+ if ($account !== FALSE && $account->status == 0) {
+ user_save($account, array('status' => 1));
+ }
+ }
+}
+
+/**
+ * Callback function for admin mass blocking users.
+ */
+function user_user_operations_block($accounts) {
+ $accounts = user_load_multiple($accounts);
+ foreach ($accounts as $account) {
+ // Skip blocking user if they are already blocked.
+ if ($account !== FALSE && $account->status == 1) {
+ // For efficiency manually save the original account before applying any
+ // changes.
+ $account->original = clone $account;
+ user_save($account, array('status' => 0));
+ }
+ }
+}
+
+/**
+ * Callback function for admin mass adding/deleting a user role.
+ */
+function user_multiple_role_edit($accounts, $operation, $rid) {
+ // The role name is not necessary as user_save() will reload the user
+ // object, but some modules' hook_user() may look at this first.
+ $role_name = db_query('SELECT name FROM {role} WHERE rid = :rid', array(':rid' => $rid))->fetchField();
+
+ switch ($operation) {
+ case 'add_role':
+ $accounts = user_load_multiple($accounts);
+ foreach ($accounts as $account) {
+ // Skip adding the role to the user if they already have it.
+ if ($account !== FALSE && !isset($account->roles[$rid])) {
+ $roles = $account->roles + array($rid => $role_name);
+ // For efficiency manually save the original account before applying
+ // any changes.
+ $account->original = clone $account;
+ user_save($account, array('roles' => $roles));
+ }
+ }
+ break;
+ case 'remove_role':
+ $accounts = user_load_multiple($accounts);
+ foreach ($accounts as $account) {
+ // Skip removing the role from the user if they already don't have it.
+ if ($account !== FALSE && isset($account->roles[$rid])) {
+ $roles = array_diff($account->roles, array($rid => $role_name));
+ // For efficiency manually save the original account before applying
+ // any changes.
+ $account->original = clone $account;
+ user_save($account, array('roles' => $roles));
+ }
+ }
+ break;
+ }
+}
+
+function user_multiple_cancel_confirm($form, &$form_state) {
+ $edit = $form_state['input'];
+
+ $form['accounts'] = array('#prefix' => '<ul>', '#suffix' => '</ul>', '#tree' => TRUE);
+ $accounts = user_load_multiple(array_keys(array_filter($edit['accounts'])));
+ foreach ($accounts as $uid => $account) {
+ // Prevent user 1 from being canceled.
+ if ($uid <= 1) {
+ continue;
+ }
+ $form['accounts'][$uid] = array(
+ '#type' => 'hidden',
+ '#value' => $uid,
+ '#prefix' => '<li>',
+ '#suffix' => check_plain($account->name) . "</li>\n",
+ );
+ }
+
+ // Output a notice that user 1 cannot be canceled.
+ if (isset($accounts[1])) {
+ $redirect = (count($accounts) == 1);
+ $message = t('The user account %name cannot be cancelled.', array('%name' => $accounts[1]->name));
+ drupal_set_message($message, $redirect ? 'error' : 'warning');
+ // If only user 1 was selected, redirect to the overview.
+ if ($redirect) {
+ drupal_goto('admin/people');
+ }
+ }
+
+ $form['operation'] = array('#type' => 'hidden', '#value' => 'cancel');
+
+ module_load_include('inc', 'user', 'user.pages');
+ $form['user_cancel_method'] = array(
+ '#type' => 'item',
+ '#title' => t('When cancelling these accounts'),
+ );
+ $form['user_cancel_method'] += user_cancel_methods();
+ // Remove method descriptions.
+ foreach (element_children($form['user_cancel_method']) as $element) {
+ unset($form['user_cancel_method'][$element]['#description']);
+ }
+
+ // Allow to send the account cancellation confirmation mail.
+ $form['user_cancel_confirm'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Require e-mail confirmation to cancel account.'),
+ '#default_value' => FALSE,
+ '#description' => t('When enabled, the user must confirm the account cancellation via e-mail.'),
+ );
+ // Also allow to send account canceled notification mail, if enabled.
+ $form['user_cancel_notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user when account is canceled.'),
+ '#default_value' => FALSE,
+ '#access' => variable_get('user_mail_status_canceled_notify', FALSE),
+ '#description' => t('When enabled, the user will receive an e-mail notification after the account has been cancelled.'),
+ );
+
+ return confirm_form($form,
+ t('Are you sure you want to cancel these user accounts?'),
+ 'admin/people', t('This action cannot be undone.'),
+ t('Cancel accounts'), t('Cancel'));
+}
+
+/**
+ * Submit handler for mass-account cancellation form.
+ *
+ * @see user_multiple_cancel_confirm()
+ * @see user_cancel_confirm_form_submit()
+ */
+function user_multiple_cancel_confirm_submit($form, &$form_state) {
+ global $user;
+
+ if ($form_state['values']['confirm']) {
+ foreach ($form_state['values']['accounts'] as $uid => $value) {
+ // Prevent programmatic form submissions from cancelling user 1.
+ if ($uid <= 1) {
+ continue;
+ }
+ // Prevent user administrators from deleting themselves without confirmation.
+ if ($uid == $user->uid) {
+ $admin_form_state = $form_state;
+ unset($admin_form_state['values']['user_cancel_confirm']);
+ $admin_form_state['values']['_account'] = $user;
+ user_cancel_confirm_form_submit(array(), $admin_form_state);
+ }
+ else {
+ user_cancel($form_state['values'], $uid, $form_state['values']['user_cancel_method']);
+ }
+ }
+ }
+ $form_state['redirect'] = 'admin/people';
+}
+
+/**
+ * Retrieve a list of all user setting/information categories and sort them by weight.
+ */
+function _user_categories() {
+ $categories = module_invoke_all('user_categories');
+ usort($categories, '_user_sort');
+
+ return $categories;
+}
+
+function _user_sort($a, $b) {
+ $a = (array) $a + array('weight' => 0, 'title' => '');
+ $b = (array) $b + array('weight' => 0, 'title' => '');
+ return $a['weight'] < $b['weight'] ? -1 : ($a['weight'] > $b['weight'] ? 1 : ($a['title'] < $b['title'] ? -1 : 1));
+}
+
+/**
+ * List user administration filters that can be applied.
+ */
+function user_filters() {
+ // Regular filters
+ $filters = array();
+ $roles = user_roles(TRUE);
+ unset($roles[DRUPAL_AUTHENTICATED_RID]); // Don't list authorized role.
+ if (count($roles)) {
+ $filters['role'] = array(
+ 'title' => t('role'),
+ 'field' => 'ur.rid',
+ 'options' => array(
+ '[any]' => t('any'),
+ ) + $roles,
+ );
+ }
+
+ $options = array();
+ foreach (module_implements('permission') as $module) {
+ $function = $module . '_permission';
+ if ($permissions = $function('permission')) {
+ asort($permissions);
+ foreach ($permissions as $permission => $description) {
+ $options[t('@module module', array('@module' => $module))][$permission] = t($permission);
+ }
+ }
+ }
+ ksort($options);
+ $filters['permission'] = array(
+ 'title' => t('permission'),
+ 'options' => array(
+ '[any]' => t('any'),
+ ) + $options,
+ );
+
+ $filters['status'] = array(
+ 'title' => t('status'),
+ 'field' => 'u.status',
+ 'options' => array(
+ '[any]' => t('any'),
+ 1 => t('active'),
+ 0 => t('blocked'),
+ ),
+ );
+ return $filters;
+}
+
+/**
+ * Extends a query object for user administration filters based on session.
+ *
+ * @param $query
+ * Query object that should be filtered.
+ */
+function user_build_filter_query(SelectQuery $query) {
+ $filters = user_filters();
+ // Extend Query with filter conditions.
+ foreach (isset($_SESSION['user_overview_filter']) ? $_SESSION['user_overview_filter'] : array() as $filter) {
+ list($key, $value) = $filter;
+ // This checks to see if this permission filter is an enabled permission for
+ // the authenticated role. If so, then all users would be listed, and we can
+ // skip adding it to the filter query.
+ if ($key == 'permission') {
+ $account = new stdClass();
+ $account->uid = 'user_filter';
+ $account->roles = array(DRUPAL_AUTHENTICATED_RID => 1);
+ if (user_access($value, $account)) {
+ continue;
+ }
+ $users_roles_alias = $query->join('users_roles', 'ur', '%alias.uid = u.uid');
+ $permission_alias = $query->join('role_permission', 'p', $users_roles_alias . '.rid = %alias.rid');
+ $query->condition($permission_alias . '.permission', $value);
+ }
+ elseif ($key == 'role') {
+ $users_roles_alias = $query->join('users_roles', 'ur', '%alias.uid = u.uid');
+ $query->condition($users_roles_alias . '.rid' , $value);
+ }
+ else {
+ $query->condition($filters[$key]['field'], $value);
+ }
+ }
+}
+
+/**
+ * Implements hook_forms().
+ */
+function user_forms() {
+ $forms['user_admin_access_add_form']['callback'] = 'user_admin_access_form';
+ $forms['user_admin_access_edit_form']['callback'] = 'user_admin_access_form';
+ return $forms;
+}
+
+/**
+ * Implements hook_comment_view().
+ */
+function user_comment_view($comment) {
+ if (variable_get('user_signatures', 0) && !empty($comment->signature)) {
+ // @todo This alters and replaces the original object value, so a
+ // hypothetical process of loading, viewing, and saving will hijack the
+ // stored data. Consider renaming to $comment->signature_safe or similar
+ // here and elsewhere in Drupal 8.
+ $comment->signature = check_markup($comment->signature, $comment->signature_format, '', TRUE);
+ }
+ else {
+ $comment->signature = '';
+ }
+}
+
+/**
+ * Returns HTML for a user signature.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - signature: The user's signature.
+ *
+ * @ingroup themeable
+ */
+function theme_user_signature($variables) {
+ $signature = $variables['signature'];
+ $output = '';
+
+ if ($signature) {
+ $output .= '<div class="clear">';
+ $output .= '<div>—</div>';
+ $output .= $signature;
+ $output .= '</div>';
+ }
+
+ return $output;
+}
+
+/**
+ * Get the language object preferred by the user. This user preference can
+ * be set on the user account editing page, and is only available if there
+ * are more than one languages enabled on the site. If the user did not
+ * choose a preferred language, or is the anonymous user, the $default
+ * value, or if it is not set, the site default language will be returned.
+ *
+ * @param $account
+ * User account to look up language for.
+ * @param $default
+ * Optional default language object to return if the account
+ * has no valid language.
+ */
+function user_preferred_language($account, $default = NULL) {
+ $language_list = language_list();
+ if (!empty($account->language) && isset($language_list[$account->language])) {
+ return $language_list[$account->language];
+ }
+ else {
+ return $default ? $default : language_default();
+ }
+}
+
+/**
+ * Conditionally create and send a notification email when a certain
+ * operation happens on the given user account.
+ *
+ * @see user_mail_tokens()
+ * @see drupal_mail()
+ *
+ * @param $op
+ * The operation being performed on the account. Possible values:
+ * 'register_admin_created': Welcome message for user created by the admin
+ * 'register_no_approval_required': Welcome message when user self-registers
+ * 'register_pending_approval': Welcome message, user pending admin approval
+ * 'password_reset': Password recovery request
+ * 'status_activated': Account activated
+ * 'status_blocked': Account blocked
+ * 'cancel_confirm': Account cancellation request
+ * 'status_canceled': Account canceled
+ *
+ * @param $account
+ * The user object of the account being notified. Must contain at
+ * least the fields 'uid', 'name', and 'mail'.
+ * @param $language
+ * Optional language to use for the notification, overriding account language.
+ * @return
+ * The return value from drupal_mail_system()->mail(), if ends up being called.
+ */
+function _user_mail_notify($op, $account, $language = NULL) {
+ // By default, we always notify except for canceled and blocked.
+ $default_notify = ($op != 'status_canceled' && $op != 'status_blocked');
+ $notify = variable_get('user_mail_' . $op . '_notify', $default_notify);
+ if ($notify) {
+ $params['account'] = $account;
+ $language = $language ? $language : user_preferred_language($account);
+ $mail = drupal_mail('user', $op, $account->mail, $language, $params);
+ if ($op == 'register_pending_approval') {
+ // If a user registered requiring admin approval, notify the admin, too.
+ // We use the site default language for this.
+ drupal_mail('user', 'register_pending_approval_admin', variable_get('site_mail', ini_get('sendmail_from')), language_default(), $params);
+ }
+ }
+ return empty($mail) ? NULL : $mail['result'];
+}
+
+/**
+ * Form element process handler for client-side password validation.
+ *
+ * This #process handler is automatically invoked for 'password_confirm' form
+ * elements to add the JavaScript and string translations for dynamic password
+ * validation.
+ *
+ * @see system_element_info()
+ */
+function user_form_process_password_confirm($element) {
+ global $user;
+
+ $js_settings = array(
+ 'password' => array(
+ 'strengthTitle' => t('Password strength:'),
+ 'hasWeaknesses' => t('To make your password stronger:'),
+ 'tooShort' => t('Make it at least 6 characters'),
+ 'addLowerCase' => t('Add lowercase letters'),
+ 'addUpperCase' => t('Add uppercase letters'),
+ 'addNumbers' => t('Add numbers'),
+ 'addPunctuation' => t('Add punctuation'),
+ 'sameAsUsername' => t('Make it different from your username'),
+ 'confirmSuccess' => t('yes'),
+ 'confirmFailure' => t('no'),
+ 'weak' => t('Weak'),
+ 'fair' => t('Fair'),
+ 'good' => t('Good'),
+ 'strong' => t('Strong'),
+ 'confirmTitle' => t('Passwords match:'),
+ 'username' => (isset($user->name) ? $user->name : ''),
+ ),
+ );
+
+ $element['#attached']['js'][] = drupal_get_path('module', 'user') . '/user.js';
+ // Ensure settings are only added once per page.
+ static $already_added = FALSE;
+ if (!$already_added) {
+ $already_added = TRUE;
+ $element['#attached']['js'][] = array('data' => $js_settings, 'type' => 'setting');
+ }
+
+ return $element;
+}
+
+/**
+ * Implements hook_node_load().
+ */
+function user_node_load($nodes, $types) {
+ // Build an array of all uids for node authors, keyed by nid.
+ $uids = array();
+ foreach ($nodes as $nid => $node) {
+ $uids[$nid] = $node->uid;
+ }
+
+ // Fetch name, picture, and data for these users.
+ $user_fields = db_query("SELECT uid, name, picture, data FROM {users} WHERE uid IN (:uids)", array(':uids' => $uids))->fetchAllAssoc('uid');
+
+ // Add these values back into the node objects.
+ foreach ($uids as $nid => $uid) {
+ $nodes[$nid]->name = $user_fields[$uid]->name;
+ $nodes[$nid]->picture = $user_fields[$uid]->picture;
+ $nodes[$nid]->data = $user_fields[$uid]->data;
+ }
+}
+
+/**
+ * Implements hook_image_style_delete().
+ */
+function user_image_style_delete($style) {
+ // If a style is deleted, update the variables.
+ // Administrators choose a replacement style when deleting.
+ user_image_style_save($style);
+}
+
+/**
+ * Implements hook_image_style_save().
+ */
+function user_image_style_save($style) {
+ // If a style is renamed, update the variables that use it.
+ if (isset($style['old_name']) && $style['old_name'] == variable_get('user_picture_style', '')) {
+ variable_set('user_picture_style', $style['name']);
+ }
+}
+
+/**
+ * Implements hook_action_info().
+ */
+function user_action_info() {
+ return array(
+ 'user_block_user_action' => array(
+ 'label' => t('Block current user'),
+ 'type' => 'user',
+ 'configurable' => FALSE,
+ 'triggers' => array('any'),
+ ),
+ );
+}
+
+/**
+ * Blocks the current user.
+ *
+ * @ingroup actions
+ */
+function user_block_user_action(&$entity, $context = array()) {
+ // First priority: If there is a $entity->uid, block that user.
+ // This is most likely a user object or the author if a node or comment.
+ if (isset($entity->uid)) {
+ $uid = $entity->uid;
+ }
+ elseif (isset($context['uid'])) {
+ $uid = $context['uid'];
+ }
+ // If neither of those are valid, then block the current user.
+ else {
+ $uid = $GLOBALS['user']->uid;
+ }
+ $account = user_load($uid);
+ $account = user_save($account, array('status' => 0));
+ watchdog('action', 'Blocked user %name.', array('%name' => $account->name));
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter().
+ *
+ * Add a checkbox for the 'user_register_form' instance settings on the 'Edit
+ * field instance' form.
+ */
+function user_form_field_ui_field_edit_form_alter(&$form, &$form_state, $form_id) {
+ $instance = $form['#instance'];
+
+ if ($instance['entity_type'] == 'user') {
+ $form['instance']['settings']['user_register_form'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Display on user registration form.'),
+ '#description' => t("This is compulsory for 'required' fields."),
+ // Field instances created in D7 beta releases before the setting was
+ // introduced might be set as 'required' and 'not shown on user_register
+ // form'. We make sure the checkbox comes as 'checked' for those.
+ '#default_value' => $instance['settings']['user_register_form'] || $instance['required'],
+ // Display just below the 'required' checkbox.
+ '#weight' => $form['instance']['required']['#weight'] + .1,
+ // Disabled when the 'required' checkbox is checked.
+ '#states' => array(
+ 'enabled' => array('input[name="instance[required]"]' => array('checked' => FALSE)),
+ ),
+ // Checked when the 'required' checkbox is checked. This is done through
+ // a custom behavior, since the #states system would also synchronize on
+ // uncheck.
+ '#attached' => array(
+ 'js' => array(drupal_get_path('module', 'user') . '/user.js'),
+ ),
+ );
+
+ array_unshift($form['#submit'], 'user_form_field_ui_field_edit_form_submit');
+ }
+}
+
+/**
+ * Additional submit handler for the 'Edit field instance' form.
+ *
+ * Make sure the 'user_register_form' setting is set for required fields.
+ */
+function user_form_field_ui_field_edit_form_submit($form, &$form_state) {
+ $instance = $form_state['values']['instance'];
+
+ if (!empty($instance['required'])) {
+ form_set_value($form['instance']['settings']['user_register_form'], 1, $form_state);
+ }
+}
+
+/**
+ * Form builder; the user registration form.
+ *
+ * @ingroup forms
+ * @see user_account_form()
+ * @see user_account_form_validate()
+ * @see user_register_submit()
+ */
+function user_register_form($form, &$form_state) {
+ global $user;
+
+ $admin = user_access('administer users');
+
+ // If we aren't admin but already logged on, go to the user page instead.
+ if (!$admin && $user->uid) {
+ drupal_goto('user/' . $user->uid);
+ }
+
+ $form['#user'] = drupal_anonymous_user();
+ $form['#user_category'] = 'register';
+
+ $form['#attached']['library'][] = array('system', 'jquery.cookie');
+ $form['#attributes']['class'][] = 'user-info-from-cookie';
+
+ // Start with the default user account fields.
+ user_account_form($form, $form_state);
+
+ // Attach field widgets, and hide the ones where the 'user_register_form'
+ // setting is not on.
+ field_attach_form('user', $form['#user'], $form, $form_state);
+ foreach (field_info_instances('user', 'user') as $field_name => $instance) {
+ if (empty($instance['settings']['user_register_form'])) {
+ $form[$field_name]['#access'] = FALSE;
+ }
+ }
+
+ if ($admin) {
+ // Redirect back to page which initiated the create request;
+ // usually admin/people/create.
+ $form_state['redirect'] = $_GET['q'];
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Create new account'),
+ );
+
+ $form['#validate'][] = 'user_register_validate';
+ // Add the final user registration form submit handler.
+ $form['#submit'][] = 'user_register_submit';
+
+ return $form;
+}
+
+/**
+ * Validation function for the user registration form.
+ */
+function user_register_validate($form, &$form_state) {
+ entity_form_field_validate('user', $form, $form_state);
+}
+
+/**
+ * Submit handler for the user registration form.
+ *
+ * This function is shared by the installation form and the normal registration form,
+ * which is why it can't be in the user.pages.inc file.
+ *
+ * @see user_register_form()
+ */
+function user_register_submit($form, &$form_state) {
+ $admin = user_access('administer users');
+
+ if (!variable_get('user_email_verification', TRUE) || $admin) {
+ $pass = $form_state['values']['pass'];
+ }
+ else {
+ $pass = user_password();
+ }
+ $notify = !empty($form_state['values']['notify']);
+
+ // Remove unneeded values.
+ form_state_values_clean($form_state);
+
+ $form_state['values']['pass'] = $pass;
+ $form_state['values']['init'] = $form_state['values']['mail'];
+
+ $account = $form['#user'];
+
+ entity_form_submit_build_entity('user', $account, $form, $form_state);
+
+ // Populate $edit with the properties of $account, which have been edited on
+ // this form by taking over all values, which appear in the form values too.
+ $edit = array_intersect_key((array) $account, $form_state['values']);
+ $account = user_save($account, $edit);
+
+ // Terminate if an error occurred during user_save().
+ if (!$account) {
+ drupal_set_message(t("Error saving user account."), 'error');
+ $form_state['redirect'] = '';
+ return;
+ }
+ $form_state['user'] = $account;
+ $form_state['values']['uid'] = $account->uid;
+
+ watchdog('user', 'New user: %name (%email).', array('%name' => $form_state['values']['name'], '%email' => $form_state['values']['mail']), WATCHDOG_NOTICE, l(t('edit'), 'user/' . $account->uid . '/edit'));
+
+ // Add plain text password into user account to generate mail tokens.
+ $account->password = $pass;
+
+ // New administrative account without notification.
+ $uri = entity_uri('user', $account);
+ if ($admin && !$notify) {
+ drupal_set_message(t('Created a new user account for <a href="@url">%name</a>. No e-mail has been sent.', array('@url' => url($uri['path'], $uri['options']), '%name' => $account->name)));
+ }
+ // No e-mail verification required; log in user immediately.
+ elseif (!$admin && !variable_get('user_email_verification', TRUE) && $account->status) {
+ _user_mail_notify('register_no_approval_required', $account);
+ $form_state['uid'] = $account->uid;
+ user_login_submit(array(), $form_state);
+ drupal_set_message(t('Registration successful. You are now logged in.'));
+ $form_state['redirect'] = '';
+ }
+ // No administrator approval required.
+ elseif ($account->status || $notify) {
+ $op = $notify ? 'register_admin_created' : 'register_no_approval_required';
+ _user_mail_notify($op, $account);
+ if ($notify) {
+ drupal_set_message(t('A welcome message with further instructions has been e-mailed to the new user <a href="@url">%name</a>.', array('@url' => url($uri['path'], $uri['options']), '%name' => $account->name)));
+ }
+ else {
+ drupal_set_message(t('A welcome message with further instructions has been sent to your e-mail address.'));
+ $form_state['redirect'] = '';
+ }
+ }
+ // Administrator approval required.
+ else {
+ _user_mail_notify('register_pending_approval', $account);
+ drupal_set_message(t('Thank you for applying for an account. Your account is currently pending approval by the site administrator.<br />In the meantime, a welcome message with further instructions has been sent to your e-mail address.'));
+ $form_state['redirect'] = '';
+ }
+}
+
+/**
+ * Implements hook_modules_installed().
+ */
+function user_modules_installed($modules) {
+ // Assign all available permissions to the administrator role.
+ $rid = variable_get('user_admin_role', 0);
+ if ($rid) {
+ $permissions = array();
+ foreach ($modules as $module) {
+ if ($module_permissions = module_invoke($module, 'permission')) {
+ $permissions = array_merge($permissions, array_keys($module_permissions));
+ }
+ }
+ if (!empty($permissions)) {
+ user_role_grant_permissions($rid, $permissions);
+ }
+ }
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function user_modules_uninstalled($modules) {
+ db_delete('role_permission')
+ ->condition('module', $modules, 'IN')
+ ->execute();
+}
+
+/**
+ * Helper function to rewrite the destination to avoid redirecting to login page after login.
+ *
+ * Third-party authentication modules may use this function to determine the
+ * proper destination after a user has been properly logged in.
+ */
+function user_login_destination() {
+ $destination = drupal_get_destination();
+ if ($destination['destination'] == 'user/login') {
+ $destination['destination'] = 'user';
+ }
+ return $destination;
+}
+
+/**
+ * Saves visitor information as a cookie so it can be reused.
+ *
+ * @param $values
+ * An array of key/value pairs to be saved into a cookie.
+ */
+function user_cookie_save(array $values) {
+ foreach ($values as $field => $value) {
+ // Set cookie for 365 days.
+ setrawcookie('Drupal.visitor.' . $field, rawurlencode($value), REQUEST_TIME + 31536000, '/');
+ }
+}
+
+/**
+ * Delete a visitor information cookie.
+ *
+ * @param $cookie_name
+ * A cookie name such as 'homepage'.
+ */
+function user_cookie_delete($cookie_name) {
+ setrawcookie('Drupal.visitor.' . $cookie_name, '', REQUEST_TIME - 3600, '/');
+}
+
+/**
+ * Implements hook_rdf_mapping().
+ */
+function user_rdf_mapping() {
+ return array(
+ array(
+ 'type' => 'user',
+ 'bundle' => RDF_DEFAULT_BUNDLE,
+ 'mapping' => array(
+ 'rdftype' => array('sioc:UserAccount'),
+ 'name' => array(
+ 'predicates' => array('foaf:name'),
+ ),
+ 'homepage' => array(
+ 'predicates' => array('foaf:page'),
+ 'type' => 'rel',
+ ),
+ ),
+ ),
+ );
+}
+
+/**
+ * Implements hook_file_download_access().
+ */
+function user_file_download_access($field, $entity_type, $entity) {
+ if ($entity_type == 'user') {
+ return user_view_access($entity);
+ }
+}
diff --git a/core/modules/user/user.pages.inc b/core/modules/user/user.pages.inc
new file mode 100644
index 000000000000..02870e9e65a8
--- /dev/null
+++ b/core/modules/user/user.pages.inc
@@ -0,0 +1,553 @@
+<?php
+
+/**
+ * @file
+ * User page callback file for the user module.
+ */
+
+/**
+ * Menu callback; Retrieve a JSON object containing autocomplete suggestions for existing users.
+ */
+function user_autocomplete($string = '') {
+ $matches = array();
+ if ($string) {
+ $result = db_select('users')->fields('users', array('name'))->condition('name', db_like($string) . '%', 'LIKE')->range(0, 10)->execute();
+ foreach ($result as $user) {
+ $matches[$user->name] = check_plain($user->name);
+ }
+ }
+
+ drupal_json_output($matches);
+}
+
+/**
+ * Form builder; Request a password reset.
+ *
+ * @ingroup forms
+ * @see user_pass_validate()
+ * @see user_pass_submit()
+ */
+function user_pass() {
+ global $user;
+
+ $form['name'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Username or e-mail address'),
+ '#size' => 60,
+ '#maxlength' => max(USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH),
+ '#required' => TRUE,
+ );
+ // Allow logged in users to request this also.
+ if ($user->uid > 0) {
+ $form['name']['#type'] = 'value';
+ $form['name']['#value'] = $user->mail;
+ $form['mail'] = array(
+ '#prefix' => '<p>',
+ '#markup' => t('Password reset instructions will be mailed to %email. You must log out to use the password reset link in the e-mail.', array('%email' => $user->mail)),
+ '#suffix' => '</p>',
+ );
+ }
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('E-mail new password'));
+
+ return $form;
+}
+
+function user_pass_validate($form, &$form_state) {
+ $name = trim($form_state['values']['name']);
+ // Try to load by email.
+ $users = user_load_multiple(array(), array('mail' => $name, 'status' => '1'));
+ $account = reset($users);
+ if (!$account) {
+ // No success, try to load by name.
+ $users = user_load_multiple(array(), array('name' => $name, 'status' => '1'));
+ $account = reset($users);
+ }
+ if (isset($account->uid)) {
+ form_set_value(array('#parents' => array('account')), $account, $form_state);
+ }
+ else {
+ form_set_error('name', t('Sorry, %name is not recognized as a user name or an e-mail address.', array('%name' => $name)));
+ }
+}
+
+function user_pass_submit($form, &$form_state) {
+ global $language;
+
+ $account = $form_state['values']['account'];
+ // Mail one time login URL and instructions using current language.
+ $mail = _user_mail_notify('password_reset', $account, $language);
+ if (!empty($mail)) {
+ watchdog('user', 'Password reset instructions mailed to %name at %email.', array('%name' => $account->name, '%email' => $account->mail));
+ drupal_set_message(t('Further instructions have been sent to your e-mail address.'));
+ }
+
+ $form_state['redirect'] = 'user';
+ return;
+}
+
+/**
+ * Menu callback; process one time login link and redirects to the user page on success.
+ */
+function user_pass_reset($form, &$form_state, $uid, $timestamp, $hashed_pass, $action = NULL) {
+ global $user;
+
+ // When processing the one-time login link, we have to make sure that a user
+ // isn't already logged in.
+ if ($user->uid) {
+ // The existing user is already logged in.
+ if ($user->uid == $uid) {
+ drupal_set_message(t('You are logged in as %user. <a href="!user_edit">Change your password.</a>', array('%user' => $user->name, '!user_edit' => url("user/$user->uid/edit"))));
+ }
+ // A different user is already logged in on the computer.
+ else {
+ $reset_link_account = user_load($uid);
+ if (!empty($reset_link_account)) {
+ drupal_set_message(t('Another user (%other_user) is already logged into the site on this computer, but you tried to use a one-time link for user %resetting_user. Please <a href="!logout">logout</a> and try using the link again.',
+ array('%other_user' => $user->name, '%resetting_user' => $reset_link_account->name, '!logout' => url('user/logout'))));
+ } else {
+ // Invalid one-time link specifies an unknown user.
+ drupal_set_message(t('The one-time login link you clicked is invalid.'));
+ }
+ }
+ drupal_goto();
+ }
+ else {
+ // Time out, in seconds, until login URL expires. 24 hours = 86400 seconds.
+ $timeout = 86400;
+ $current = REQUEST_TIME;
+ // Some redundant checks for extra security ?
+ $users = user_load_multiple(array($uid), array('status' => '1'));
+ if ($timestamp <= $current && $account = reset($users)) {
+ // No time out for first time login.
+ if ($account->login && $current - $timestamp > $timeout) {
+ drupal_set_message(t('You have tried to use a one-time login link that has expired. Please request a new one using the form below.'));
+ drupal_goto('user/password');
+ }
+ elseif ($account->uid && $timestamp >= $account->login && $timestamp <= $current && $hashed_pass == user_pass_rehash($account->pass, $timestamp, $account->login)) {
+ // First stage is a confirmation form, then login
+ if ($action == 'login') {
+ watchdog('user', 'User %name used one-time login link at time %timestamp.', array('%name' => $account->name, '%timestamp' => $timestamp));
+ // Set the new user.
+ $user = $account;
+ // user_login_finalize() also updates the login timestamp of the
+ // user, which invalidates further use of the one-time login link.
+ user_login_finalize();
+ drupal_set_message(t('You have just used your one-time login link. It is no longer necessary to use this link to log in. Please change your password.'));
+ // Let the user's password be changed without the current password check.
+ $token = drupal_hash_base64(drupal_random_bytes(55));
+ $_SESSION['pass_reset_' . $user->uid] = $token;
+ drupal_goto('user/' . $user->uid . '/edit', array('query' => array('pass-reset-token' => $token)));
+ }
+ else {
+ $form['message'] = array('#markup' => t('<p>This is a one-time login for %user_name and will expire on %expiration_date.</p><p>Click on this button to log in to the site and change your password.</p>', array('%user_name' => $account->name, '%expiration_date' => format_date($timestamp + $timeout))));
+ $form['help'] = array('#markup' => '<p>' . t('This login can be used only once.') . '</p>');
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array('#type' => 'submit', '#value' => t('Log in'));
+ $form['#action'] = url("user/reset/$uid/$timestamp/$hashed_pass/login");
+ return $form;
+ }
+ }
+ else {
+ drupal_set_message(t('You have tried to use a one-time login link that has either been used or is no longer valid. Please request a new one using the form below.'));
+ drupal_goto('user/password');
+ }
+ }
+ else {
+ // Deny access, no more clues.
+ // Everything will be in the watchdog's URL for the administrator to check.
+ drupal_access_denied();
+ }
+ }
+}
+
+/**
+ * Menu callback; logs the current user out, and redirects to the home page.
+ */
+function user_logout() {
+ global $user;
+
+ watchdog('user', 'Session closed for %name.', array('%name' => $user->name));
+
+ module_invoke_all('user_logout', $user);
+
+ // Destroy the current session, and reset $user to the anonymous user.
+ session_destroy();
+
+ drupal_goto();
+}
+
+/**
+ * Process variables for user-profile.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $account
+ *
+ * @see user-profile.tpl.php
+ */
+function template_preprocess_user_profile(&$variables) {
+ $account = $variables['elements']['#account'];
+
+ // Helpful $user_profile variable for templates.
+ foreach (element_children($variables['elements']) as $key) {
+ $variables['user_profile'][$key] = $variables['elements'][$key];
+ }
+
+ // Preprocess fields.
+ field_attach_preprocess('user', $account, $variables['elements'], $variables);
+}
+
+/**
+ * Process variables for user-profile-item.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $element
+ *
+ * @see user-profile-item.tpl.php
+ */
+function template_preprocess_user_profile_item(&$variables) {
+ $variables['title'] = $variables['element']['#title'];
+ $variables['value'] = $variables['element']['#markup'];
+ $variables['attributes'] = '';
+ if (isset($variables['element']['#attributes'])) {
+ $variables['attributes'] = drupal_attributes($variables['element']['#attributes']);
+ }
+}
+
+/**
+ * Process variables for user-profile-category.tpl.php.
+ *
+ * The $variables array contains the following arguments:
+ * - $element
+ *
+ * @see user-profile-category.tpl.php
+ */
+function template_preprocess_user_profile_category(&$variables) {
+ $variables['title'] = check_plain($variables['element']['#title']);
+ $variables['classes_array'][] = 'user-profile-category-' . drupal_html_class($variables['title']);
+ $variables['profile_items'] = $variables['element']['#children'];
+ $variables['attributes'] = '';
+ if (isset($variables['element']['#attributes'])) {
+ $variables['attributes'] = drupal_attributes($variables['element']['#attributes']);
+ }
+}
+
+/**
+ * Form builder; edit a user account or one of their profile categories.
+ *
+ * @ingroup forms
+ * @see user_account_form()
+ * @see user_account_form_validate()
+ * @see user_profile_form_validate()
+ * @see user_profile_form_submit()
+ * @see user_cancel_confirm_form_submit()
+ */
+function user_profile_form($form, &$form_state, $account, $category = 'account') {
+ global $user;
+
+ // During initial form build, add the entity to the form state for use during
+ // form building and processing. During a rebuild, use what is in the form
+ // state.
+ if (!isset($form_state['user'])) {
+ $form_state['user'] = $account;
+ }
+ else {
+ $account = $form_state['user'];
+ }
+
+ // @todo Legacy support. Modules are encouraged to access the entity using
+ // $form_state. Remove in Drupal 8.
+ $form['#user'] = $account;
+ $form['#user_category'] = $category;
+
+ if ($category == 'account') {
+ user_account_form($form, $form_state);
+ // Attach field widgets.
+ field_attach_form('user', $account, $form, $form_state);
+ }
+
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => t('Save'),
+ );
+ if ($category == 'account') {
+ $form['actions']['cancel'] = array(
+ '#type' => 'submit',
+ '#value' => t('Cancel account'),
+ '#submit' => array('user_edit_cancel_submit'),
+ '#access' => $account->uid > 1 && (($account->uid == $user->uid && user_access('cancel account')) || user_access('administer users')),
+ );
+ }
+
+ $form['#validate'][] = 'user_profile_form_validate';
+ // Add the final user profile form submit handler.
+ $form['#submit'][] = 'user_profile_form_submit';
+
+ return $form;
+}
+
+/**
+ * Validation function for the user account and profile editing form.
+ */
+function user_profile_form_validate($form, &$form_state) {
+ entity_form_field_validate('user', $form, $form_state);
+}
+
+/**
+ * Submit function for the user account and profile editing form.
+ */
+function user_profile_form_submit($form, &$form_state) {
+ $account = $form_state['user'];
+ $category = $form['#user_category'];
+ // Remove unneeded values.
+ form_state_values_clean($form_state);
+
+ // Before updating the account entity, keep an unchanged copy for use with
+ // user_save() later. This is necessary for modules implementing the user
+ // hooks to be able to react on changes by comparing the values of $account
+ // and $edit.
+ $account_unchanged = clone $account;
+
+ entity_form_submit_build_entity('user', $account, $form, $form_state);
+
+ // Populate $edit with the properties of $account, which have been edited on
+ // this form by taking over all values, which appear in the form values too.
+ $edit = array_intersect_key((array) $account, $form_state['values']);
+
+ user_save($account_unchanged, $edit, $category);
+ $form_state['values']['uid'] = $account->uid;
+
+ if ($category == 'account' && !empty($edit['pass'])) {
+ // Remove the password reset tag since a new password was saved.
+ unset($_SESSION['pass_reset_'. $account->uid]);
+ }
+ // Clear the page cache because pages can contain usernames and/or profile information:
+ cache_clear_all();
+
+ drupal_set_message(t('The changes have been saved.'));
+}
+
+/**
+ * Submit function for the 'Cancel account' button on the user edit form.
+ */
+function user_edit_cancel_submit($form, &$form_state) {
+ $destination = array();
+ if (isset($_GET['destination'])) {
+ $destination = drupal_get_destination();
+ unset($_GET['destination']);
+ }
+ // Note: We redirect from user/uid/edit to user/uid/cancel to make the tabs disappear.
+ $form_state['redirect'] = array("user/" . $form['#user']->uid . "/cancel", array('query' => $destination));
+}
+
+/**
+ * Form builder; confirm form for cancelling user account.
+ *
+ * @ingroup forms
+ * @see user_edit_cancel_submit()
+ */
+function user_cancel_confirm_form($form, &$form_state, $account) {
+ global $user;
+
+ $form['_account'] = array('#type' => 'value', '#value' => $account);
+
+ // Display account cancellation method selection, if allowed.
+ $default_method = variable_get('user_cancel_method', 'user_cancel_block');
+ $admin_access = user_access('administer users');
+ $can_select_method = $admin_access || user_access('select account cancellation method');
+ $form['user_cancel_method'] = array(
+ '#type' => 'item',
+ '#title' => ($account->uid == $user->uid ? t('When cancelling your account') : t('When cancelling the account')),
+ '#access' => $can_select_method,
+ );
+ $form['user_cancel_method'] += user_cancel_methods();
+
+ // Allow user administrators to skip the account cancellation confirmation
+ // mail (by default), as long as they do not attempt to cancel their own
+ // account.
+ $override_access = $admin_access && ($account->uid != $user->uid);
+ $form['user_cancel_confirm'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Require e-mail confirmation to cancel account.'),
+ '#default_value' => ($override_access ? FALSE : TRUE),
+ '#access' => $override_access,
+ '#description' => t('When enabled, the user must confirm the account cancellation via e-mail.'),
+ );
+ // Also allow to send account canceled notification mail, if enabled.
+ $default_notify = variable_get('user_mail_status_canceled_notify', FALSE);
+ $form['user_cancel_notify'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Notify user when account is canceled.'),
+ '#default_value' => ($override_access ? FALSE : $default_notify),
+ '#access' => $override_access && $default_notify,
+ '#description' => t('When enabled, the user will receive an e-mail notification after the account has been cancelled.'),
+ );
+
+ // Prepare confirmation form page title and description.
+ if ($account->uid == $user->uid) {
+ $question = t('Are you sure you want to cancel your account?');
+ }
+ else {
+ $question = t('Are you sure you want to cancel the account %name?', array('%name' => $account->name));
+ }
+ $description = '';
+ if ($can_select_method) {
+ $description = t('Select the method to cancel the account above.');
+ foreach (element_children($form['user_cancel_method']) as $element) {
+ unset($form['user_cancel_method'][$element]['#description']);
+ }
+ }
+ else {
+ // The radio button #description is used as description for the confirmation
+ // form.
+ foreach (element_children($form['user_cancel_method']) as $element) {
+ if ($form['user_cancel_method'][$element]['#default_value'] == $form['user_cancel_method'][$element]['#return_value']) {
+ $description = $form['user_cancel_method'][$element]['#description'];
+ }
+ unset($form['user_cancel_method'][$element]['#description']);
+ }
+ }
+
+ // Always provide entity id in the same form key as in the entity edit form.
+ $form['uid'] = array('#type' => 'value', '#value' => $account->uid);
+ return confirm_form($form,
+ $question,
+ 'user/' . $account->uid,
+ $description . ' ' . t('This action cannot be undone.'),
+ t('Cancel account'), t('Cancel'));
+}
+
+/**
+ * Submit handler for the account cancellation confirm form.
+ *
+ * @see user_cancel_confirm_form()
+ * @see user_multiple_cancel_confirm_submit()
+ */
+function user_cancel_confirm_form_submit($form, &$form_state) {
+ global $user;
+ $account = $form_state['values']['_account'];
+
+ // Cancel account immediately, if the current user has administrative
+ // privileges, no confirmation mail shall be sent, and the user does not
+ // attempt to cancel the own account.
+ if (user_access('administer users') && empty($form_state['values']['user_cancel_confirm']) && $account->uid != $user->uid) {
+ user_cancel($form_state['values'], $account->uid, $form_state['values']['user_cancel_method']);
+
+ $form_state['redirect'] = 'admin/people';
+ }
+ else {
+ // Store cancelling method and whether to notify the user in $account for
+ // user_cancel_confirm().
+ $edit = array(
+ 'user_cancel_method' => $form_state['values']['user_cancel_method'],
+ 'user_cancel_notify' => $form_state['values']['user_cancel_notify'],
+ );
+ $account = user_save($account, $edit);
+ _user_mail_notify('cancel_confirm', $account);
+ drupal_set_message(t('A confirmation request to cancel your account has been sent to your e-mail address.'));
+ watchdog('user', 'Sent account cancellation request to %name %email.', array('%name' => $account->name, '%email' => '<' . $account->mail . '>'), WATCHDOG_NOTICE);
+
+ $form_state['redirect'] = "user/$account->uid";
+ }
+}
+
+/**
+ * Helper function to return available account cancellation methods.
+ *
+ * See documentation of hook_user_cancel_methods_alter().
+ *
+ * @return
+ * An array containing all account cancellation methods as form elements.
+ *
+ * @see hook_user_cancel_methods_alter()
+ * @see user_admin_settings()
+ * @see user_cancel_confirm_form()
+ * @see user_multiple_cancel_confirm()
+ */
+function user_cancel_methods() {
+ $methods = array(
+ 'user_cancel_block' => array(
+ 'title' => t('Disable the account and keep its content.'),
+ 'description' => t('Your account will be blocked and you will no longer be able to log in. All of your content will remain attributed to your user name.'),
+ ),
+ 'user_cancel_block_unpublish' => array(
+ 'title' => t('Disable the account and unpublish its content.'),
+ 'description' => t('Your account will be blocked and you will no longer be able to log in. All of your content will be hidden from everyone but administrators.'),
+ ),
+ 'user_cancel_reassign' => array(
+ 'title' => t('Delete the account and make its content belong to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))),
+ 'description' => t('Your account will be removed and all account information deleted. All of your content will be assigned to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))),
+ ),
+ 'user_cancel_delete' => array(
+ 'title' => t('Delete the account and its content.'),
+ 'description' => t('Your account will be removed and all account information deleted. All of your content will also be deleted.'),
+ 'access' => user_access('administer users'),
+ ),
+ );
+ // Allow modules to customize account cancellation methods.
+ drupal_alter('user_cancel_methods', $methods);
+
+ // Turn all methods into real form elements.
+ $default_method = variable_get('user_cancel_method', 'user_cancel_block');
+ foreach ($methods as $name => $method) {
+ $form[$name] = array(
+ '#type' => 'radio',
+ '#title' => $method['title'],
+ '#description' => (isset($method['description']) ? $method['description'] : NULL),
+ '#return_value' => $name,
+ '#default_value' => $default_method,
+ '#parents' => array('user_cancel_method'),
+ );
+ }
+ return $form;
+}
+
+/**
+ * Menu callback; Cancel a user account via e-mail confirmation link.
+ *
+ * @see user_cancel_confirm_form()
+ * @see user_cancel_url()
+ */
+function user_cancel_confirm($account, $timestamp = 0, $hashed_pass = '') {
+ // Time out in seconds until cancel URL expires; 24 hours = 86400 seconds.
+ $timeout = 86400;
+ $current = REQUEST_TIME;
+
+ // Basic validation of arguments.
+ if (isset($account->data['user_cancel_method']) && !empty($timestamp) && !empty($hashed_pass)) {
+ // Validate expiration and hashed password/login.
+ if ($timestamp <= $current && $current - $timestamp < $timeout && $account->uid && $timestamp >= $account->login && $hashed_pass == user_pass_rehash($account->pass, $timestamp, $account->login)) {
+ $edit = array(
+ 'user_cancel_notify' => isset($account->data['user_cancel_notify']) ? $account->data['user_cancel_notify'] : variable_get('user_mail_status_canceled_notify', FALSE),
+ );
+ user_cancel($edit, $account->uid, $account->data['user_cancel_method']);
+ // Since user_cancel() is not invoked via Form API, batch processing needs
+ // to be invoked manually and should redirect to the front page after
+ // completion.
+ batch_process('');
+ }
+ else {
+ drupal_set_message(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.'));
+ drupal_goto("user/$account->uid/cancel");
+ }
+ }
+ drupal_access_denied();
+}
+
+/**
+ * Access callback for path /user.
+ *
+ * Displays user profile if user is logged in, or login form for anonymous
+ * users.
+ */
+function user_page() {
+ global $user;
+ if ($user->uid) {
+ menu_set_active_item('user/' . $user->uid);
+ return menu_execute_active_handler(NULL, FALSE);
+ }
+ else {
+ return drupal_get_form('user_login');
+ }
+}
diff --git a/core/modules/user/user.permissions.js b/core/modules/user/user.permissions.js
new file mode 100644
index 000000000000..988820e12db7
--- /dev/null
+++ b/core/modules/user/user.permissions.js
@@ -0,0 +1,69 @@
+(function ($) {
+
+/**
+ * Shows checked and disabled checkboxes for inherited permissions.
+ */
+Drupal.behaviors.permissions = {
+ attach: function (context) {
+ var self = this;
+ $('table#permissions').once('permissions', function () {
+ // On a site with many roles and permissions, this behavior initially has
+ // to perform thousands of DOM manipulations to inject checkboxes and hide
+ // them. By detaching the table from the DOM, all operations can be
+ // performed without triggering internal layout and re-rendering processes
+ // in the browser.
+ var $table = $(this);
+ if ($table.prev().length) {
+ var $ancestor = $table.prev(), method = 'after';
+ }
+ else {
+ var $ancestor = $table.parent(), method = 'append';
+ }
+ $table.detach();
+
+ // Create dummy checkboxes. We use dummy checkboxes instead of reusing
+ // the existing checkboxes here because new checkboxes don't alter the
+ // submitted form. If we'd automatically check existing checkboxes, the
+ // permission table would be polluted with redundant entries. This
+ // is deliberate, but desirable when we automatically check them.
+ var $dummy = $('<input type="checkbox" class="dummy-checkbox" disabled="disabled" checked="checked" />')
+ .attr('title', Drupal.t("This permission is inherited from the authenticated user role."))
+ .hide();
+
+ $('input[type=checkbox]', this).not('.rid-2, .rid-1').addClass('real-checkbox').each(function () {
+ $dummy.clone().insertAfter(this);
+ });
+
+ // Initialize the authenticated user checkbox.
+ $('input[type=checkbox].rid-2', this)
+ .bind('click.permissions', self.toggle)
+ // .triggerHandler() cannot be used here, as it only affects the first
+ // element.
+ .each(self.toggle);
+
+ // Re-insert the table into the DOM.
+ $ancestor[method]($table);
+ });
+ },
+
+ /**
+ * Toggles all dummy checkboxes based on the checkboxes' state.
+ *
+ * If the "authenticated user" checkbox is checked, the checked and disabled
+ * checkboxes are shown, the real checkboxes otherwise.
+ */
+ toggle: function () {
+ var authCheckbox = this, $row = $(this).closest('tr');
+ // jQuery performs too many layout calculations for .hide() and .show(),
+ // leading to a major page rendering lag on sites with many roles and
+ // permissions. Therefore, we toggle visibility directly.
+ $row.find('.real-checkbox').each(function () {
+ this.style.display = (authCheckbox.checked ? 'none' : '');
+ });
+ $row.find('.dummy-checkbox').each(function () {
+ this.style.display = (authCheckbox.checked ? '' : 'none');
+ });
+ }
+};
+
+})(jQuery);
diff --git a/core/modules/user/user.test b/core/modules/user/user.test
new file mode 100644
index 000000000000..15c427b6e898
--- /dev/null
+++ b/core/modules/user/user.test
@@ -0,0 +1,2211 @@
+<?php
+
+/**
+ * @file
+ * Tests for user.module.
+ */
+
+class UserRegistrationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User registration',
+ 'description' => 'Test registration of user under different configurations.',
+ 'group' => 'User'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('field_test');
+ }
+
+ function testRegistrationWithEmailVerification() {
+ // Require e-mail verification.
+ variable_set('user_email_verification', TRUE);
+
+ // Set registration to administrator only.
+ variable_set('user_register', USER_REGISTER_ADMINISTRATORS_ONLY);
+ $this->drupalGet('user/register');
+ $this->assertResponse(403, t('Registration page is inaccessible when only administrators can create accounts.'));
+
+ // Allow registration by site visitors without administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $this->assertText(t('A welcome message with further instructions has been sent to your e-mail address.'), t('User registered successfully.'));
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertTrue($new_user->status, t('New account is active after registration.'));
+
+ // Allow registration by site visitors, but require administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertFalse($new_user->status, t('New account is blocked until approved by an administrator.'));
+ }
+
+ function testRegistrationWithoutEmailVerification() {
+ // Don't require e-mail verification.
+ variable_set('user_email_verification', FALSE);
+
+ // Allow registration by site visitors without administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS);
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+
+ // Try entering a mismatching password.
+ $edit['pass[pass1]'] = '99999.0';
+ $edit['pass[pass2]'] = '99999';
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $this->assertText(t('The specified passwords do not match.'), t('Typing mismatched passwords displays an error message.'));
+
+ // Enter a correct password.
+ $edit['pass[pass1]'] = $new_pass = $this->randomName();
+ $edit['pass[pass2]'] = $new_pass;
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertText(t('Registration successful. You are now logged in.'), t('Users are logged in after registering.'));
+ $this->drupalLogout();
+
+ // Allow registration by site visitors, but require administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL);
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ $edit['pass[pass1]'] = $pass = $this->randomName();
+ $edit['pass[pass2]'] = $pass;
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $this->assertText(t('Thank you for applying for an account. Your account is currently pending approval by the site administrator.'), t('Users are notified of pending approval'));
+
+ // Try to login before administrator approval.
+ $auth = array(
+ 'name' => $name,
+ 'pass' => $pass,
+ );
+ $this->drupalPost('user/login', $auth, t('Log in'));
+ $this->assertText(t('The username @name has not been activated or is blocked.', array('@name' => $name)), t('User cannot login yet.'));
+
+ // Activate the new account.
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($admin_user);
+ $edit = array(
+ 'status' => 1,
+ );
+ $this->drupalPost('user/' . $new_user->uid . '/edit', $edit, t('Save'));
+ $this->drupalLogout();
+
+ // Login after administrator approval.
+ $this->drupalPost('user/login', $auth, t('Log in'));
+ $this->assertText(t('Member for'), t('User can log in after administrator approval.'));
+ }
+
+ function testRegistrationEmailDuplicates() {
+ // Don't require e-mail verification.
+ variable_set('user_email_verification', FALSE);
+
+ // Allow registration by site visitors without administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS);
+
+ // Set up a user to check for duplicates.
+ $duplicate_user = $this->drupalCreateUser();
+
+ $edit = array();
+ $edit['name'] = $this->randomName();
+ $edit['mail'] = $duplicate_user->mail;
+
+ // Attempt to create a new account using an existing e-mail address.
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $this->assertText(t('The e-mail address @email is already registered.', array('@email' => $duplicate_user->mail)), t('Supplying an exact duplicate email address displays an error message'));
+
+ // Attempt to bypass duplicate email registration validation by adding spaces.
+ $edit['mail'] = ' ' . $duplicate_user->mail . ' ';
+
+ $this->drupalPost('user/register', $edit, t('Create new account'));
+ $this->assertText(t('The e-mail address @email is already registered.', array('@email' => $duplicate_user->mail)), t('Supplying a duplicate email address with added whitespace displays an error message'));
+ }
+
+ function testRegistrationDefaultValues() {
+ // Allow registration by site visitors without administrator approval.
+ variable_set('user_register', USER_REGISTER_VISITORS);
+
+ // Don't require e-mail verification.
+ variable_set('user_email_verification', FALSE);
+
+ // Set the default timezone to Brussels.
+ variable_set('configurable_timezones', 1);
+ variable_set('date_default_timezone', 'Europe/Brussels');
+
+ // Check that the account information fieldset's options are not displayed
+ // is a fieldset if there is not more than one fieldset in the form.
+ $this->drupalGet('user/register');
+ $this->assertNoRaw('<fieldset id="edit-account"><legend>Account information</legend>', t('Account settings fieldset was hidden.'));
+
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ $edit['pass[pass1]'] = $new_pass = $this->randomName();
+ $edit['pass[pass2]'] = $new_pass;
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+
+ // Check user fields.
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertEqual($new_user->name, $name, t('Username matches.'));
+ $this->assertEqual($new_user->mail, $mail, t('E-mail address matches.'));
+ $this->assertEqual($new_user->theme, '', t('Correct theme field.'));
+ $this->assertEqual($new_user->signature, '', t('Correct signature field.'));
+ $this->assertTrue(($new_user->created > REQUEST_TIME - 20 ), t('Correct creation time.'));
+ $this->assertEqual($new_user->status, variable_get('user_register', USER_REGISTER_VISITORS_ADMINISTRATIVE_APPROVAL) == USER_REGISTER_VISITORS ? 1 : 0, t('Correct status field.'));
+ $this->assertEqual($new_user->timezone, variable_get('date_default_timezone'), t('Correct time zone field.'));
+ $this->assertEqual($new_user->language, '', t('Correct language field.'));
+ $this->assertEqual($new_user->picture, '', t('Correct picture field.'));
+ $this->assertEqual($new_user->init, $mail, t('Correct init field.'));
+ }
+
+ /**
+ * Tests Field API fields on user registration forms.
+ */
+ function testRegistrationWithUserFields() {
+ // Create a field, and an instance on 'user' entity type.
+ $field = array(
+ 'type' => 'test_field',
+ 'field_name' => 'test_user_field',
+ 'cardinality' => 1,
+ );
+ field_create_field($field);
+ $instance = array(
+ 'field_name' => 'test_user_field',
+ 'entity_type' => 'user',
+ 'label' => 'Some user field',
+ 'bundle' => 'user',
+ 'required' => TRUE,
+ 'settings' => array('user_register_form' => FALSE),
+ );
+ field_create_instance($instance);
+
+ // Check that the field does not appear on the registration form.
+ $this->drupalGet('user/register');
+ $this->assertNoText($instance['label'], t('The field does not appear on user registration form'));
+
+ // Have the field appear on the registration form.
+ $instance['settings']['user_register_form'] = TRUE;
+ field_update_instance($instance);
+ $this->drupalGet('user/register');
+ $this->assertText($instance['label'], t('The field appears on user registration form'));
+
+ // Check that validation errors are correctly reported.
+ $edit = array();
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ // Missing input in required field.
+ $edit['test_user_field[und][0][value]'] = '';
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ $this->assertRaw(t('@name field is required.', array('@name' => $instance['label'])), t('Field validation error was correctly reported.'));
+ // Invalid input.
+ $edit['test_user_field[und][0][value]'] = '-1';
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ $this->assertRaw(t('%name does not accept the value -1.', array('%name' => $instance['label'])), t('Field validation error was correctly reported.'));
+
+ // Submit with valid data.
+ $value = rand(1, 255);
+ $edit['test_user_field[und][0][value]'] = $value;
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ // Check user fields.
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertEqual($new_user->test_user_field[LANGUAGE_NONE][0]['value'], $value, t('The field value was correclty saved.'));
+
+ // Check that the 'add more' button works.
+ $field['cardinality'] = FIELD_CARDINALITY_UNLIMITED;
+ field_update_field($field);
+ foreach (array('js', 'nojs') as $js) {
+ $this->drupalGet('user/register');
+ // Add two inputs.
+ $value = rand(1, 255);
+ $edit = array();
+ $edit['test_user_field[und][0][value]'] = $value;
+ if ($js == 'js') {
+ $this->drupalPostAJAX(NULL, $edit, 'test_user_field_add_more');
+ $this->drupalPostAJAX(NULL, $edit, 'test_user_field_add_more');
+ }
+ else {
+ $this->drupalPost(NULL, $edit, t('Add another item'));
+ $this->drupalPost(NULL, $edit, t('Add another item'));
+ }
+ // Submit with three values.
+ $edit['test_user_field[und][1][value]'] = $value + 1;
+ $edit['test_user_field[und][2][value]'] = $value + 2;
+ $edit['name'] = $name = $this->randomName();
+ $edit['mail'] = $mail = $edit['name'] . '@example.com';
+ $this->drupalPost(NULL, $edit, t('Create new account'));
+ // Check user fields.
+ $accounts = user_load_multiple(array(), array('name' => $name, 'mail' => $mail));
+ $new_user = reset($accounts);
+ $this->assertEqual($new_user->test_user_field[LANGUAGE_NONE][0]['value'], $value, t('@js : The field value was correclty saved.', array('@js' => $js)));
+ $this->assertEqual($new_user->test_user_field[LANGUAGE_NONE][1]['value'], $value + 1, t('@js : The field value was correclty saved.', array('@js' => $js)));
+ $this->assertEqual($new_user->test_user_field[LANGUAGE_NONE][2]['value'], $value + 2, t('@js : The field value was correclty saved.', array('@js' => $js)));
+ }
+ }
+}
+
+class UserValidationTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Username/e-mail validation',
+ 'description' => 'Verify that username/email validity checks behave as designed.',
+ 'group' => 'User'
+ );
+ }
+
+ // Username validation.
+ function testUsernames() {
+ $test_cases = array( // '<username>' => array('<description>', 'assert<testName>'),
+ 'foo' => array('Valid username', 'assertNull'),
+ 'FOO' => array('Valid username', 'assertNull'),
+ 'Foo O\'Bar' => array('Valid username', 'assertNull'),
+ 'foo@bar' => array('Valid username', 'assertNull'),
+ 'foo@example.com' => array('Valid username', 'assertNull'),
+ 'foo@-example.com' => array('Valid username', 'assertNull'), // invalid domains are allowed in usernames
+ 'þòøÇߪř€' => array('Valid username', 'assertNull'),
+ 'ᚠᛇᚻ᛫ᛒᛦᚦ' => array('Valid UTF8 username', 'assertNull'), // runes
+ ' foo' => array('Invalid username that starts with a space', 'assertNotNull'),
+ 'foo ' => array('Invalid username that ends with a space', 'assertNotNull'),
+ 'foo bar' => array('Invalid username that contains 2 spaces \'&nbsp;&nbsp;\'', 'assertNotNull'),
+ '' => array('Invalid empty username', 'assertNotNull'),
+ 'foo/' => array('Invalid username containing invalid chars', 'assertNotNull'),
+ 'foo' . chr(0) . 'bar' => array('Invalid username containing chr(0)', 'assertNotNull'), // NULL
+ 'foo' . chr(13) . 'bar' => array('Invalid username containing chr(13)', 'assertNotNull'), // CR
+ str_repeat('x', USERNAME_MAX_LENGTH + 1) => array('Invalid excessively long username', 'assertNotNull'),
+ );
+ foreach ($test_cases as $name => $test_case) {
+ list($description, $test) = $test_case;
+ $result = user_validate_name($name);
+ $this->$test($result, $description . ' (' . $name . ')');
+ }
+ }
+
+ // Mail validation. More extensive tests can be found at common.test
+ function testMailAddresses() {
+ $test_cases = array( // '<username>' => array('<description>', 'assert<testName>'),
+ '' => array('Empty mail address', 'assertNotNull'),
+ 'foo' => array('Invalid mail address', 'assertNotNull'),
+ 'foo@example.com' => array('Valid mail address', 'assertNull'),
+ );
+ foreach ($test_cases as $name => $test_case) {
+ list($description, $test) = $test_case;
+ $result = user_validate_mail($name);
+ $this->$test($result, $description . ' (' . $name . ')');
+ }
+ }
+}
+
+/**
+ * Functional tests for user logins, including rate limiting of login attempts.
+ */
+class UserLoginTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User login',
+ 'description' => 'Ensure that login works as expected.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Test the global login flood control.
+ */
+ function testGlobalLoginFloodControl() {
+ // Set the global login limit.
+ variable_set('user_failed_login_ip_limit', 10);
+ // Set a high per-user limit out so that it is not relevant in the test.
+ variable_set('user_failed_login_user_limit', 4000);
+
+ $user1 = $this->drupalCreateUser(array());
+ $incorrect_user1 = clone $user1;
+ $incorrect_user1->pass_raw .= 'incorrect';
+
+ // Try 2 failed logins.
+ for ($i = 0; $i < 2; $i++) {
+ $this->assertFailedLogin($incorrect_user1);
+ }
+
+ // A successful login will not reset the IP-based flood control count.
+ $this->drupalLogin($user1);
+ $this->drupalLogout();
+
+ // Try 8 more failed logins, they should not trigger the flood control
+ // mechanism.
+ for ($i = 0; $i < 8; $i++) {
+ $this->assertFailedLogin($incorrect_user1);
+ }
+
+ // The next login trial should result in an IP-based flood error message.
+ $this->assertFailedLogin($incorrect_user1, 'ip');
+
+ // A login with the correct password should also result in a flood error
+ // message.
+ $this->assertFailedLogin($user1, 'ip');
+ }
+
+ /**
+ * Test the per-user login flood control.
+ */
+ function testPerUserLoginFloodControl() {
+ // Set a high global limit out so that it is not relevant in the test.
+ variable_set('user_failed_login_ip_limit', 4000);
+ // Set the per-user login limit.
+ variable_set('user_failed_login_user_limit', 3);
+
+ $user1 = $this->drupalCreateUser(array());
+ $incorrect_user1 = clone $user1;
+ $incorrect_user1->pass_raw .= 'incorrect';
+
+ $user2 = $this->drupalCreateUser(array());
+
+ // Try 2 failed logins.
+ for ($i = 0; $i < 2; $i++) {
+ $this->assertFailedLogin($incorrect_user1);
+ }
+
+ // A successful login will reset the per-user flood control count.
+ $this->drupalLogin($user1);
+ $this->drupalLogout();
+
+ // Try 3 failed logins for user 1, they will not trigger flood control.
+ for ($i = 0; $i < 3; $i++) {
+ $this->assertFailedLogin($incorrect_user1);
+ }
+
+ // Try one successful attempt for user 2, it should not trigger any
+ // flood control.
+ $this->drupalLogin($user2);
+ $this->drupalLogout();
+
+ // Try one more attempt for user 1, it should be rejected, even if the
+ // correct password has been used.
+ $this->assertFailedLogin($user1, 'user');
+ }
+
+ /**
+ * Test that user password is re-hashed upon login after changing $count_log2.
+ */
+ function testPasswordRehashOnLogin() {
+ // Load password hashing API.
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ // Set initial $count_log2 to the default, DRUPAL_HASH_COUNT.
+ variable_set('password_count_log2', DRUPAL_HASH_COUNT);
+ // Create a new user and authenticate.
+ $account = $this->drupalCreateUser(array());
+ $password = $account->pass_raw;
+ $this->drupalLogin($account);
+ $this->drupalLogout();
+ // Load the stored user. The password hash should reflect $count_log2.
+ $account = user_load($account->uid);
+ $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_HASH_COUNT);
+ // Change $count_log2 and log in again.
+ variable_set('password_count_log2', DRUPAL_HASH_COUNT + 1);
+ $account->pass_raw = $password;
+ $this->drupalLogin($account);
+ // Load the stored user, which should have a different password hash now.
+ $account = user_load($account->uid, TRUE);
+ $this->assertIdentical(_password_get_count_log2($account->pass), DRUPAL_HASH_COUNT + 1);
+ }
+
+ /**
+ * Make an unsuccessful login attempt.
+ *
+ * @param $account
+ * A user object with name and pass_raw attributes for the login attempt.
+ * @param $flood_trigger
+ * Whether or not to expect that the flood control mechanism will be
+ * triggered.
+ */
+ function assertFailedLogin($account, $flood_trigger = NULL) {
+ $edit = array(
+ 'name' => $account->name,
+ 'pass' => $account->pass_raw,
+ );
+ $this->drupalPost('user', $edit, t('Log in'));
+ $this->assertNoFieldByXPath("//input[@name='pass' and @value!='']", NULL, t('Password value attribute is blank.'));
+ if (isset($flood_trigger)) {
+ if ($flood_trigger == 'user') {
+ $this->assertRaw(format_plural(variable_get('user_failed_login_user_limit', 5), 'Sorry, there has been more than one failed login attempt for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', 'Sorry, there have been more than @count failed login attempts for this account. It is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
+ }
+ else {
+ // No uid, so the limit is IP-based.
+ $this->assertRaw(t('Sorry, too many failed login attempts from your IP address. This IP address is temporarily blocked. Try again later or <a href="@url">request a new password</a>.', array('@url' => url('user/password'))));
+ }
+ }
+ else {
+ $this->assertText(t('Sorry, unrecognized username or password. Have you forgotten your password?'));
+ }
+ }
+}
+
+/**
+ * Test cancelling a user.
+ */
+class UserCancelTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'Cancel account',
+ 'description' => 'Ensure that account cancellation methods work as expected.',
+ 'group' => 'User',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('comment');
+ }
+
+ /**
+ * Attempt to cancel account without permission.
+ */
+ function testUserCancelWithoutPermission() {
+ variable_set('user_cancel_method', 'user_cancel_reassign');
+
+ // Create a user.
+ $account = $this->drupalCreateUser(array());
+ $this->drupalLogin($account);
+ // Load real user object.
+ $account = user_load($account->uid, TRUE);
+
+ // Create a node.
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+
+ // Attempt to cancel account.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->assertNoRaw(t('Cancel account'), t('No cancel account button displayed.'));
+
+ // Attempt bogus account cancellation request confirmation.
+ $timestamp = $account->login;
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $this->assertResponse(403, t('Bogus cancelling request rejected.'));
+ $account = user_load($account->uid);
+ $this->assertTrue($account->status == 1, t('User account was not canceled.'));
+
+ // Confirm user's content has not been altered.
+ $test_node = node_load($node->nid, NULL, TRUE);
+ $this->assertTrue(($test_node->uid == $account->uid && $test_node->status == 1), t('Node of the user has not been altered.'));
+ }
+
+ /**
+ * Tests that user account for uid 1 cannot be cancelled.
+ *
+ * This should never be possible, or the site owner would become unable to
+ * administer the site.
+ */
+ function testUserCancelUid1() {
+ // Update uid 1's name and password to we know it.
+ $password = user_password();
+ require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'core/includes/password.inc');
+ $account = array(
+ 'name' => 'user1',
+ 'pass' => user_hash_password(trim($password)),
+ );
+ // We cannot use user_save() here or the password would be hashed again.
+ db_update('users')
+ ->fields($account)
+ ->condition('uid', 1)
+ ->execute();
+
+ // Reload and log in uid 1.
+ $user1 = user_load(1, TRUE);
+ $user1->pass_raw = $password;
+
+ // Try to cancel uid 1's account with a different user.
+ $this->admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($this->admin_user);
+ $edit = array(
+ 'operation' => 'cancel',
+ 'accounts[1]' => TRUE,
+ );
+ $this->drupalPost('admin/people', $edit, t('Update'));
+
+ // Verify that uid 1's account was not cancelled.
+ $user1 = user_load(1, TRUE);
+ $this->assertEqual($user1->status, 1, t('User #1 still exists and is not blocked.'));
+ }
+
+ /**
+ * Attempt invalid account cancellations.
+ */
+ function testUserCancelInvalid() {
+ variable_set('user_cancel_method', 'user_cancel_reassign');
+
+ // Create a user.
+ $account = $this->drupalCreateUser(array('cancel account'));
+ $this->drupalLogin($account);
+ // Load real user object.
+ $account = user_load($account->uid, TRUE);
+
+ // Create a node.
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+
+ // Attempt to cancel account.
+ $this->drupalPost('user/' . $account->uid . '/edit', NULL, t('Cancel account'));
+
+ // Confirm account cancellation.
+ $timestamp = time();
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+
+ // Attempt bogus account cancellation request confirmation.
+ $bogus_timestamp = $timestamp + 60;
+ $this->drupalGet("user/$account->uid/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account->pass, $bogus_timestamp, $account->login));
+ $this->assertText(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.'), t('Bogus cancelling request rejected.'));
+ $account = user_load($account->uid);
+ $this->assertTrue($account->status == 1, t('User account was not canceled.'));
+
+ // Attempt expired account cancellation request confirmation.
+ $bogus_timestamp = $timestamp - 86400 - 60;
+ $this->drupalGet("user/$account->uid/cancel/confirm/$bogus_timestamp/" . user_pass_rehash($account->pass, $bogus_timestamp, $account->login));
+ $this->assertText(t('You have tried to use an account cancellation link that has expired. Please request a new one using the form below.'), t('Expired cancel account request rejected.'));
+ $accounts = user_load_multiple(array($account->uid), array('status' => 1));
+ $this->assertTrue(reset($accounts), t('User account was not canceled.'));
+
+ // Confirm user's content has not been altered.
+ $test_node = node_load($node->nid, NULL, TRUE);
+ $this->assertTrue(($test_node->uid == $account->uid && $test_node->status == 1), t('Node of the user has not been altered.'));
+ }
+
+ /**
+ * Disable account and keep all content.
+ */
+ function testUserBlock() {
+ variable_set('user_cancel_method', 'user_cancel_block');
+
+ // Create a user.
+ $web_user = $this->drupalCreateUser(array('cancel account'));
+ $this->drupalLogin($web_user);
+
+ // Load real user object.
+ $account = user_load($web_user->uid, TRUE);
+
+ // Attempt to cancel account.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.'));
+ $this->assertText(t('Your account will be blocked and you will no longer be able to log in. All of your content will remain attributed to your user name.'), t('Informs that all content will be remain as is.'));
+ $this->assertNoText(t('Select the method to cancel the account above.'), t('Does not allow user to select account cancellation method.'));
+
+ // Confirm account cancellation.
+ $timestamp = time();
+
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+
+ // Confirm account cancellation request.
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $account = user_load($account->uid, TRUE);
+ $this->assertTrue($account->status == 0, t('User has been blocked.'));
+
+ // Confirm user is logged out.
+ $this->assertNoText($account->name, t('Logged out.'));
+ }
+
+ /**
+ * Disable account and unpublish all content.
+ */
+ function testUserBlockUnpublish() {
+ variable_set('user_cancel_method', 'user_cancel_block_unpublish');
+
+ // Create a user.
+ $account = $this->drupalCreateUser(array('cancel account'));
+ $this->drupalLogin($account);
+ // Load real user object.
+ $account = user_load($account->uid, TRUE);
+
+ // Create a node with two revisions.
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+ $settings = get_object_vars($node);
+ $settings['revision'] = 1;
+ $node = $this->drupalCreateNode($settings);
+
+ // Attempt to cancel account.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.'));
+ $this->assertText(t('Your account will be blocked and you will no longer be able to log in. All of your content will be hidden from everyone but administrators.'), t('Informs that all content will be unpublished.'));
+
+ // Confirm account cancellation.
+ $timestamp = time();
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+
+ // Confirm account cancellation request.
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $account = user_load($account->uid, TRUE);
+ $this->assertTrue($account->status == 0, t('User has been blocked.'));
+
+ // Confirm user's content has been unpublished.
+ $test_node = node_load($node->nid, NULL, TRUE);
+ $this->assertTrue($test_node->status == 0, t('Node of the user has been unpublished.'));
+ $test_node = node_load($node->nid, $node->vid, TRUE);
+ $this->assertTrue($test_node->status == 0, t('Node revision of the user has been unpublished.'));
+
+ // Confirm user is logged out.
+ $this->assertNoText($account->name, t('Logged out.'));
+ }
+
+ /**
+ * Delete account and anonymize all content.
+ */
+ function testUserAnonymize() {
+ variable_set('user_cancel_method', 'user_cancel_reassign');
+
+ // Create a user.
+ $account = $this->drupalCreateUser(array('cancel account'));
+ $this->drupalLogin($account);
+ // Load real user object.
+ $account = user_load($account->uid, TRUE);
+
+ // Create a simple node.
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+
+ // Create a node with two revisions, the initial one belonging to the
+ // cancelling user.
+ $revision_node = $this->drupalCreateNode(array('uid' => $account->uid));
+ $revision = $revision_node->vid;
+ $settings = get_object_vars($revision_node);
+ $settings['revision'] = 1;
+ $settings['uid'] = 1; // Set new/current revision to someone else.
+ $revision_node = $this->drupalCreateNode($settings);
+
+ // Attempt to cancel account.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.'));
+ $this->assertRaw(t('Your account will be removed and all account information deleted. All of your content will be assigned to the %anonymous-name user.', array('%anonymous-name' => variable_get('anonymous', t('Anonymous')))), t('Informs that all content will be attributed to anonymous account.'));
+
+ // Confirm account cancellation.
+ $timestamp = time();
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+
+ // Confirm account cancellation request.
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $this->assertFalse(user_load($account->uid, TRUE), t('User is not found in the database.'));
+
+ // Confirm that user's content has been attributed to anonymous user.
+ $test_node = node_load($node->nid, NULL, TRUE);
+ $this->assertTrue(($test_node->uid == 0 && $test_node->status == 1), t('Node of the user has been attributed to anonymous user.'));
+ $test_node = node_load($revision_node->nid, $revision, TRUE);
+ $this->assertTrue(($test_node->revision_uid == 0 && $test_node->status == 1), t('Node revision of the user has been attributed to anonymous user.'));
+ $test_node = node_load($revision_node->nid, NULL, TRUE);
+ $this->assertTrue(($test_node->uid != 0 && $test_node->status == 1), t("Current revision of the user's node was not attributed to anonymous user."));
+
+ // Confirm that user is logged out.
+ $this->assertNoText($account->name, t('Logged out.'));
+ }
+
+ /**
+ * Delete account and remove all content.
+ */
+ function testUserDelete() {
+ variable_set('user_cancel_method', 'user_cancel_delete');
+
+ // Create a user.
+ $account = $this->drupalCreateUser(array('cancel account', 'post comments', 'skip comment approval'));
+ $this->drupalLogin($account);
+ // Load real user object.
+ $account = user_load($account->uid, TRUE);
+
+ // Create a simple node.
+ $node = $this->drupalCreateNode(array('uid' => $account->uid));
+
+ // Create comment.
+ $langcode = LANGUAGE_NONE;
+ $edit = array();
+ $edit['subject'] = $this->randomName(8);
+ $edit['comment_body[' . $langcode . '][0][value]'] = $this->randomName(16);
+
+ $this->drupalPost('comment/reply/' . $node->nid, $edit, t('Preview'));
+ $this->drupalPost(NULL, array(), t('Save'));
+ $this->assertText(t('Your comment has been posted.'));
+ $comments = comment_load_multiple(array(), array('subject' => $edit['subject']));
+ $comment = reset($comments);
+ $this->assertTrue($comment->cid, t('Comment found.'));
+
+ // Create a node with two revisions, the initial one belonging to the
+ // cancelling user.
+ $revision_node = $this->drupalCreateNode(array('uid' => $account->uid));
+ $revision = $revision_node->vid;
+ $settings = get_object_vars($revision_node);
+ $settings['revision'] = 1;
+ $settings['uid'] = 1; // Set new/current revision to someone else.
+ $revision_node = $this->drupalCreateNode($settings);
+
+ // Attempt to cancel account.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('Are you sure you want to cancel your account?'), t('Confirmation form to cancel account displayed.'));
+ $this->assertText(t('Your account will be removed and all account information deleted. All of your content will also be deleted.'), t('Informs that all content will be deleted.'));
+
+ // Confirm account cancellation.
+ $timestamp = time();
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+
+ // Confirm account cancellation request.
+ $this->drupalGet("user/$account->uid/cancel/confirm/$timestamp/" . user_pass_rehash($account->pass, $timestamp, $account->login));
+ $this->assertFalse(user_load($account->uid, TRUE), t('User is not found in the database.'));
+
+ // Confirm that user's content has been deleted.
+ $this->assertFalse(node_load($node->nid, NULL, TRUE), t('Node of the user has been deleted.'));
+ $this->assertFalse(node_load($node->nid, $revision, TRUE), t('Node revision of the user has been deleted.'));
+ $this->assertTrue(node_load($revision_node->nid, NULL, TRUE), t("Current revision of the user's node was not deleted."));
+ $this->assertFalse(comment_load($comment->cid), t('Comment of the user has been deleted.'));
+
+ // Confirm that user is logged out.
+ $this->assertNoText($account->name, t('Logged out.'));
+ }
+
+ /**
+ * Create an administrative user and delete another user.
+ */
+ function testUserCancelByAdmin() {
+ variable_set('user_cancel_method', 'user_cancel_reassign');
+
+ // Create a regular user.
+ $account = $this->drupalCreateUser(array());
+
+ // Create administrative user.
+ $admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($admin_user);
+
+ // Delete regular user.
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertRaw(t('Are you sure you want to cancel the account %name?', array('%name' => $account->name)), t('Confirmation form to cancel account displayed.'));
+ $this->assertText(t('Select the method to cancel the account above.'), t('Allows to select account cancellation method.'));
+
+ // Confirm deletion.
+ $this->drupalPost(NULL, NULL, t('Cancel account'));
+ $this->assertRaw(t('%name has been deleted.', array('%name' => $account->name)), t('User deleted.'));
+ $this->assertFalse(user_load($account->uid), t('User is not found in the database.'));
+ }
+
+ /**
+ * Create an administrative user and mass-delete other users.
+ */
+ function testMassUserCancelByAdmin() {
+ variable_set('user_cancel_method', 'user_cancel_reassign');
+ // Enable account cancellation notification.
+ variable_set('user_mail_status_canceled_notify', TRUE);
+
+ // Create administrative user.
+ $admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($admin_user);
+
+ // Create some users.
+ $users = array();
+ for ($i = 0; $i < 3; $i++) {
+ $account = $this->drupalCreateUser(array());
+ $users[$account->uid] = $account;
+ }
+
+ // Cancel user accounts, including own one.
+ $edit = array();
+ $edit['operation'] = 'cancel';
+ foreach ($users as $uid => $account) {
+ $edit['accounts[' . $uid . ']'] = TRUE;
+ }
+ $edit['accounts[' . $admin_user->uid . ']'] = TRUE;
+ // Also try to cancel uid 1.
+ $edit['accounts[1]'] = TRUE;
+ $this->drupalPost('admin/people', $edit, t('Update'));
+ $this->assertText(t('Are you sure you want to cancel these user accounts?'), t('Confirmation form to cancel accounts displayed.'));
+ $this->assertText(t('When cancelling these accounts'), t('Allows to select account cancellation method.'));
+ $this->assertText(t('Require e-mail confirmation to cancel account.'), t('Allows to send confirmation mail.'));
+ $this->assertText(t('Notify user when account is canceled.'), t('Allows to send notification mail.'));
+
+ // Confirm deletion.
+ $this->drupalPost(NULL, NULL, t('Cancel accounts'));
+ $status = TRUE;
+ foreach ($users as $account) {
+ $status = $status && (strpos($this->content, t('%name has been deleted.', array('%name' => $account->name))) !== FALSE);
+ $status = $status && !user_load($account->uid, TRUE);
+ }
+ $this->assertTrue($status, t('Users deleted and not found in the database.'));
+
+ // Ensure that admin account was not cancelled.
+ $this->assertText(t('A confirmation request to cancel your account has been sent to your e-mail address.'), t('Account cancellation request mailed message displayed.'));
+ $admin_user = user_load($admin_user->uid);
+ $this->assertTrue($admin_user->status == 1, t('Administrative user is found in the database and enabled.'));
+
+ // Verify that uid 1's account was not cancelled.
+ $user1 = user_load(1, TRUE);
+ $this->assertEqual($user1->status, 1, t('User #1 still exists and is not blocked.'));
+ }
+}
+
+class UserPictureTestCase extends DrupalWebTestCase {
+ protected $user;
+ protected $_directory_test;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Upload user picture',
+ 'description' => 'Assure that dimension check, extension check and image scaling work as designed.',
+ 'group' => 'User'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ // Enable user pictures.
+ variable_set('user_pictures', 1);
+
+ $this->user = $this->drupalCreateUser();
+
+ // Test if directories specified in settings exist in filesystem.
+ $file_dir = 'public://';
+ $file_check = file_prepare_directory($file_dir, FILE_CREATE_DIRECTORY);
+ // TODO: Test public and private methods?
+
+ $picture_dir = variable_get('user_picture_path', 'pictures');
+ $picture_path = $file_dir . $picture_dir;
+
+ $pic_check = file_prepare_directory($picture_path, FILE_CREATE_DIRECTORY);
+ $this->_directory_test = is_writable($picture_path);
+ $this->assertTrue($this->_directory_test, "The directory $picture_path doesn't exist or is not writable. Further tests won't be made.");
+ }
+
+ function testNoPicture() {
+ $this->drupalLogin($this->user);
+
+ // Try to upload a file that is not an image for the user picture.
+ $not_an_image = current($this->drupalGetTestFiles('html'));
+ $this->saveUserPicture($not_an_image);
+ $this->assertRaw(t('Only JPEG, PNG and GIF images are allowed.'), t('Non-image files are not accepted.'));
+ }
+
+ /**
+ * Do the test:
+ * GD Toolkit is installed
+ * Picture has invalid dimension
+ *
+ * results: The image should be uploaded because ImageGDToolkit resizes the picture
+ */
+ function testWithGDinvalidDimension() {
+ if ($this->_directory_test && image_get_toolkit()) {
+ $this->drupalLogin($this->user);
+
+ $image = current($this->drupalGetTestFiles('image'));
+ $info = image_get_info($image->uri);
+
+ // Set new variables: invalid dimensions, valid filesize (0 = no limit).
+ $test_dim = ($info['width'] - 10) . 'x' . ($info['height'] - 10);
+ variable_set('user_picture_dimensions', $test_dim);
+ variable_set('user_picture_file_size', 0);
+
+ $pic_path = $this->saveUserPicture($image);
+ // Check that the image was resized and is being displayed on the
+ // user's profile page.
+ $text = t('The image was resized to fit within the maximum allowed dimensions of %dimensions pixels.', array('%dimensions' => $test_dim));
+ $this->assertRaw($text, t('Image was resized.'));
+ $alt = t("@user's picture", array('@user' => format_username($this->user)));
+ $style = variable_get('user_picture_style', '');
+ $this->assertRaw(image_style_url($style, $pic_path), t("Image is displayed in user's edit page"));
+
+ // Check if file is located in proper directory.
+ $this->assertTrue(is_file($pic_path), t("File is located in proper directory"));
+ }
+ }
+
+ /**
+ * Do the test:
+ * GD Toolkit is installed
+ * Picture has invalid size
+ *
+ * results: The image should be uploaded because ImageGDToolkit resizes the picture
+ */
+ function testWithGDinvalidSize() {
+ if ($this->_directory_test && image_get_toolkit()) {
+ $this->drupalLogin($this->user);
+
+ // Images are sorted first by size then by name. We need an image
+ // bigger than 1 KB so we'll grab the last one.
+ $files = $this->drupalGetTestFiles('image');
+ $image = end($files);
+ $info = image_get_info($image->uri);
+
+ // Set new variables: valid dimensions, invalid filesize.
+ $test_dim = ($info['width'] + 10) . 'x' . ($info['height'] + 10);
+ $test_size = 1;
+ variable_set('user_picture_dimensions', $test_dim);
+ variable_set('user_picture_file_size', $test_size);
+
+ $pic_path = $this->saveUserPicture($image);
+
+ // Test that the upload failed and that the correct reason was cited.
+ $text = t('The specified file %filename could not be uploaded.', array('%filename' => $image->filename));
+ $this->assertRaw($text, t('Upload failed.'));
+ $text = t('The file is %filesize exceeding the maximum file size of %maxsize.', array('%filesize' => format_size(filesize($image->uri)), '%maxsize' => format_size($test_size * 1024)));
+ $this->assertRaw($text, t('File size cited as reason for failure.'));
+
+ // Check if file is not uploaded.
+ $this->assertFalse(is_file($pic_path), t('File was not uploaded.'));
+ }
+ }
+
+ /**
+ * Do the test:
+ * GD Toolkit is not installed
+ * Picture has invalid size
+ *
+ * results: The image shouldn't be uploaded
+ */
+ function testWithoutGDinvalidDimension() {
+ if ($this->_directory_test && !image_get_toolkit()) {
+ $this->drupalLogin($this->user);
+
+ $image = current($this->drupalGetTestFiles('image'));
+ $info = image_get_info($image->uri);
+
+ // Set new variables: invalid dimensions, valid filesize (0 = no limit).
+ $test_dim = ($info['width'] - 10) . 'x' . ($info['height'] - 10);
+ variable_set('user_picture_dimensions', $test_dim);
+ variable_set('user_picture_file_size', 0);
+
+ $pic_path = $this->saveUserPicture($image);
+
+ // Test that the upload failed and that the correct reason was cited.
+ $text = t('The specified file %filename could not be uploaded.', array('%filename' => $image->filename));
+ $this->assertRaw($text, t('Upload failed.'));
+ $text = t('The image is too large; the maximum dimensions are %dimensions pixels.', array('%dimensions' => $test_dim));
+ $this->assertRaw($text, t('Checking response on invalid image (dimensions).'));
+
+ // Check if file is not uploaded.
+ $this->assertFalse(is_file($pic_path), t('File was not uploaded.'));
+ }
+ }
+
+ /**
+ * Do the test:
+ * GD Toolkit is not installed
+ * Picture has invalid size
+ *
+ * results: The image shouldn't be uploaded
+ */
+ function testWithoutGDinvalidSize() {
+ if ($this->_directory_test && !image_get_toolkit()) {
+ $this->drupalLogin($this->user);
+
+ $image = current($this->drupalGetTestFiles('image'));
+ $info = image_get_info($image->uri);
+
+ // Set new variables: valid dimensions, invalid filesize.
+ $test_dim = ($info['width'] + 10) . 'x' . ($info['height'] + 10);
+ $test_size = 1;
+ variable_set('user_picture_dimensions', $test_dim);
+ variable_set('user_picture_file_size', $test_size);
+
+ $pic_path = $this->saveUserPicture($image);
+
+ // Test that the upload failed and that the correct reason was cited.
+ $text = t('The specified file %filename could not be uploaded.', array('%filename' => $image->filename));
+ $this->assertRaw($text, t('Upload failed.'));
+ $text = t('The file is %filesize exceeding the maximum file size of %maxsize.', array('%filesize' => format_size(filesize($image->uri)), '%maxsize' => format_size($test_size * 1024)));
+ $this->assertRaw($text, t('File size cited as reason for failure.'));
+
+ // Check if file is not uploaded.
+ $this->assertFalse(is_file($pic_path), t('File was not uploaded.'));
+ }
+ }
+
+ /**
+ * Do the test:
+ * Picture is valid (proper size and dimension)
+ *
+ * results: The image should be uploaded
+ */
+ function testPictureIsValid() {
+ if ($this->_directory_test) {
+ $this->drupalLogin($this->user);
+
+ $image = current($this->drupalGetTestFiles('image'));
+ $info = image_get_info($image->uri);
+
+ // Set new variables: valid dimensions, valid filesize (0 = no limit).
+ $test_dim = ($info['width'] + 10) . 'x' . ($info['height'] + 10);
+ variable_set('user_picture_dimensions', $test_dim);
+ variable_set('user_picture_file_size', 0);
+
+ $pic_path = $this->saveUserPicture($image);
+
+ // Check if image is displayed in user's profile page.
+ $this->drupalGet('user');
+ $this->assertRaw(file_uri_target($pic_path), t("Image is displayed in user's profile page"));
+
+ // Check if file is located in proper directory.
+ $this->assertTrue(is_file($pic_path), t('File is located in proper directory'));
+
+ // Set new picture dimensions.
+ $test_dim = ($info['width'] + 5) . 'x' . ($info['height'] + 5);
+ variable_set('user_picture_dimensions', $test_dim);
+
+ $pic_path2 = $this->saveUserPicture($image);
+ $this->assertNotEqual($pic_path, $pic_path2, t('Filename of second picture is different.'));
+ }
+ }
+
+ /**
+ * Test HTTP schema working with user pictures.
+ */
+ function testExternalPicture() {
+ $this->drupalLogin($this->user);
+ // Set the default picture to an URI with a HTTP schema.
+ $images = $this->drupalGetTestFiles('image');
+ $image = $images[0];
+ $pic_path = file_create_url($image->uri);
+ variable_set('user_picture_default', $pic_path);
+
+ // Check if image is displayed in user's profile page.
+ $this->drupalGet('user');
+
+ // Get the user picture image via xpath.
+ $elements = $this->xpath('//div[@class="user-picture"]/img');
+ $this->assertEqual(count($elements), 1, t("There is exactly one user picture on the user's profile page"));
+ $this->assertEqual($pic_path, (string) $elements[0]['src'], t("User picture source is correct."));
+ }
+
+ function saveUserPicture($image) {
+ $edit = array('files[picture_upload]' => drupal_realpath($image->uri));
+ $this->drupalPost('user/' . $this->user->uid . '/edit', $edit, t('Save'));
+
+ // Load actual user data from database.
+ $account = user_load($this->user->uid, TRUE);
+ return isset($account->picture) ? $account->picture->uri : NULL;
+ }
+}
+
+
+class UserPermissionsTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+ protected $rid;
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'Role permissions',
+ 'description' => 'Verify that role permissions can be added and removed via the permissions page.',
+ 'group' => 'User'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ $this->admin_user = $this->drupalCreateUser(array('administer permissions', 'access user profiles', 'administer site configuration', 'administer modules', 'administer users'));
+
+ // Find the new role ID - it must be the maximum.
+ $all_rids = array_keys($this->admin_user->roles);
+ sort($all_rids);
+ $this->rid = array_pop($all_rids);
+ }
+
+ /**
+ * Change user permissions and check user_access().
+ */
+ function testUserPermissionChanges() {
+ $this->drupalLogin($this->admin_user);
+ $rid = $this->rid;
+ $account = $this->admin_user;
+
+ // Add a permission.
+ $this->assertFalse(user_access('administer nodes', $account), t('User does not have "administer nodes" permission.'));
+ $edit = array();
+ $edit[$rid . '[administer nodes]'] = TRUE;
+ $this->drupalPost('admin/people/permissions', $edit, t('Save permissions'));
+ $this->assertText(t('The changes have been saved.'), t('Successful save message displayed.'));
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+ $this->assertTrue(user_access('administer nodes', $account), t('User now has "administer nodes" permission.'));
+
+ // Remove a permission.
+ $this->assertTrue(user_access('access user profiles', $account), t('User has "access user profiles" permission.'));
+ $edit = array();
+ $edit[$rid . '[access user profiles]'] = FALSE;
+ $this->drupalPost('admin/people/permissions', $edit, t('Save permissions'));
+ $this->assertText(t('The changes have been saved.'), t('Successful save message displayed.'));
+ drupal_static_reset('user_access');
+ drupal_static_reset('user_role_permissions');
+ $this->assertFalse(user_access('access user profiles', $account), t('User no longer has "access user profiles" permission.'));
+ }
+
+ /**
+ * Test assigning of permissions for the administrator role.
+ */
+ function testAdministratorRole() {
+ $this->drupalLogin($this->admin_user);
+ $this->drupalGet('admin/config/people/accounts');
+
+ // Set the user's role to be the administrator role.
+ $edit = array();
+ $edit['user_admin_role'] = $this->rid;
+ $this->drupalPost('admin/config/people/accounts', $edit, t('Save configuration'));
+
+ // Enable aggregator module and ensure the 'administer news feeds'
+ // permission is assigned by default.
+ $edit = array();
+ $edit['modules[Core][aggregator][enable]'] = TRUE;
+ $this->drupalPost('admin/modules', $edit, t('Save configuration'));
+ $this->assertTrue(user_access('administer news feeds', $this->admin_user), t('The permission was automatically assigned to the administrator role'));
+ }
+
+ /**
+ * Verify proper permission changes by user_role_change_permissions().
+ */
+ function testUserRoleChangePermissions() {
+ $rid = $this->rid;
+ $account = $this->admin_user;
+
+ // Verify current permissions.
+ $this->assertFalse(user_access('administer nodes', $account), t('User does not have "administer nodes" permission.'));
+ $this->assertTrue(user_access('access user profiles', $account), t('User has "access user profiles" permission.'));
+ $this->assertTrue(user_access('administer site configuration', $account), t('User has "administer site configuration" permission.'));
+
+ // Change permissions.
+ $permissions = array(
+ 'administer nodes' => 1,
+ 'access user profiles' => 0,
+ );
+ user_role_change_permissions($rid, $permissions);
+
+ // Verify proper permission changes.
+ $this->assertTrue(user_access('administer nodes', $account), t('User now has "administer nodes" permission.'));
+ $this->assertFalse(user_access('access user profiles', $account), t('User no longer has "access user profiles" permission.'));
+ $this->assertTrue(user_access('administer site configuration', $account), t('User still has "administer site configuration" permission.'));
+ }
+}
+
+class UserAdminTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User administration',
+ 'description' => 'Test user administration page functionality.',
+ 'group' => 'User'
+ );
+ }
+
+ /**
+ * Registers a user and deletes it.
+ */
+ function testUserAdmin() {
+
+ $user_a = $this->drupalCreateUser(array());
+ $user_b = $this->drupalCreateUser(array('administer taxonomy'));
+ $user_c = $this->drupalCreateUser(array('administer taxonomy'));
+
+ // Create admin user to delete registered user.
+ $admin_user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($admin_user);
+ $this->drupalGet('admin/people');
+ $this->assertText($user_a->name, t('Found user A on admin users page'));
+ $this->assertText($user_b->name, t('Found user B on admin users page'));
+ $this->assertText($user_c->name, t('Found user C on admin users page'));
+ $this->assertText($admin_user->name, t('Found Admin user on admin users page'));
+
+ // Test for existence of edit link in table.
+ $link = l(t('edit'), "user/$user_a->uid/edit", array('query' => array('destination' => 'admin/people')));
+ $this->assertRaw($link, t('Found user A edit link on admin users page'));
+
+ // Filter the users by permission 'administer taxonomy'.
+ $edit = array();
+ $edit['permission'] = 'administer taxonomy';
+ $this->drupalPost('admin/people', $edit, t('Filter'));
+
+ // Check if the correct users show up.
+ $this->assertNoText($user_a->name, t('User A not on filtered by perm admin users page'));
+ $this->assertText($user_b->name, t('Found user B on filtered by perm admin users page'));
+ $this->assertText($user_c->name, t('Found user C on filtered by perm admin users page'));
+
+ // Filter the users by role. Grab the system-generated role name for User C.
+ $edit['role'] = max(array_flip($user_c->roles));
+ $this->drupalPost('admin/people', $edit, t('Refine'));
+
+ // Check if the correct users show up when filtered by role.
+ $this->assertNoText($user_a->name, t('User A not on filtered by role on admin users page'));
+ $this->assertNoText($user_b->name, t('User B not on filtered by role on admin users page'));
+ $this->assertText($user_c->name, t('User C on filtered by role on admin users page'));
+
+ // Test blocking of a user.
+ $account = user_load($user_c->uid);
+ $this->assertEqual($account->status, 1, 'User C not blocked');
+ $edit = array();
+ $edit['operation'] = 'block';
+ $edit['accounts[' . $account->uid . ']'] = TRUE;
+ $this->drupalPost('admin/people', $edit, t('Update'));
+ $account = user_load($user_c->uid, TRUE);
+ $this->assertEqual($account->status, 0, 'User C blocked');
+
+ // Test unblocking of a user from /admin/people page and sending of activation mail
+ $editunblock = array();
+ $editunblock['operation'] = 'unblock';
+ $editunblock['accounts[' . $account->uid . ']'] = TRUE;
+ $this->drupalPost('admin/people', $editunblock, t('Update'));
+ $account = user_load($user_c->uid, TRUE);
+ $this->assertEqual($account->status, 1, 'User C unblocked');
+ $this->assertMail("to", $account->mail, "Activation mail sent to user C");
+
+ // Test blocking and unblocking another user from /user/[uid]/edit form and sending of activation mail
+ $user_d = $this->drupalCreateUser(array());
+ $account1 = user_load($user_d->uid, TRUE);
+ $this->drupalPost('user/' . $account1->uid . '/edit', array('status' => 0), t('Save'));
+ $account1 = user_load($user_d->uid, TRUE);
+ $this->assertEqual($account1->status, 0, 'User D blocked');
+ $this->drupalPost('user/' . $account1->uid . '/edit', array('status' => TRUE), t('Save'));
+ $account1 = user_load($user_d->uid, TRUE);
+ $this->assertEqual($account1->status, 1, 'User D unblocked');
+ $this->assertMail("to", $account1->mail, "Activation mail sent to user D");
+ }
+}
+
+/**
+ * Tests for user-configurable time zones.
+ */
+class UserTimeZoneFunctionalTest extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User time zones',
+ 'description' => 'Set a user time zone and verify that dates are displayed in local time.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Tests the display of dates and time when user-configurable time zones are set.
+ */
+ function testUserTimeZone() {
+ // Setup date/time settings for Los Angeles time.
+ variable_set('date_default_timezone', 'America/Los_Angeles');
+ variable_set('configurable_timezones', 1);
+ variable_set('date_format_medium', 'Y-m-d H:i T');
+
+ // Create a user account and login.
+ $web_user = $this->drupalCreateUser();
+ $this->drupalLogin($web_user);
+
+ // Create some nodes with different authored-on dates.
+ // Two dates in PST (winter time):
+ $date1 = '2007-03-09 21:00:00 -0800';
+ $date2 = '2007-03-11 01:00:00 -0800';
+ // One date in PDT (summer time):
+ $date3 = '2007-03-20 21:00:00 -0700';
+ $node1 = $this->drupalCreateNode(array('created' => strtotime($date1), 'type' => 'article'));
+ $node2 = $this->drupalCreateNode(array('created' => strtotime($date2), 'type' => 'article'));
+ $node3 = $this->drupalCreateNode(array('created' => strtotime($date3), 'type' => 'article'));
+
+ // Confirm date format and time zone.
+ $this->drupalGet("node/$node1->nid");
+ $this->assertText('2007-03-09 21:00 PST', t('Date should be PST.'));
+ $this->drupalGet("node/$node2->nid");
+ $this->assertText('2007-03-11 01:00 PST', t('Date should be PST.'));
+ $this->drupalGet("node/$node3->nid");
+ $this->assertText('2007-03-20 21:00 PDT', t('Date should be PDT.'));
+
+ // Change user time zone to Santiago time.
+ $edit = array();
+ $edit['mail'] = $web_user->mail;
+ $edit['timezone'] = 'America/Santiago';
+ $this->drupalPost("user/$web_user->uid/edit", $edit, t('Save'));
+ $this->assertText(t('The changes have been saved.'), t('Time zone changed to Santiago time.'));
+
+ // Confirm date format and time zone.
+ $this->drupalGet("node/$node1->nid");
+ $this->assertText('2007-03-10 02:00 CLST', t('Date should be Chile summer time; five hours ahead of PST.'));
+ $this->drupalGet("node/$node2->nid");
+ $this->assertText('2007-03-11 05:00 CLT', t('Date should be Chile time; four hours ahead of PST'));
+ $this->drupalGet("node/$node3->nid");
+ $this->assertText('2007-03-21 00:00 CLT', t('Date should be Chile time; three hours ahead of PDT.'));
+ }
+}
+
+/**
+ * Test user autocompletion.
+ */
+class UserAutocompleteTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User autocompletion',
+ 'description' => 'Test user autocompletion functionality.',
+ 'group' => 'User'
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+
+ // Set up two users with different permissions to test access.
+ $this->unprivileged_user = $this->drupalCreateUser();
+ $this->privileged_user = $this->drupalCreateUser(array('access user profiles'));
+ }
+
+ /**
+ * Tests access to user autocompletion and verify the correct results.
+ */
+ function testUserAutocomplete() {
+ // Check access from unprivileged user, should be denied.
+ $this->drupalLogin($this->unprivileged_user);
+ $this->drupalGet('user/autocomplete/' . $this->unprivileged_user->name[0]);
+ $this->assertResponse(403, t('Autocompletion access denied to user without permission.'));
+
+ // Check access from privileged user.
+ $this->drupalLogout();
+ $this->drupalLogin($this->privileged_user);
+ $this->drupalGet('user/autocomplete/' . $this->unprivileged_user->name[0]);
+ $this->assertResponse(200, t('Autocompletion access allowed.'));
+
+ // Using first letter of the user's name, make sure the user's full name is in the results.
+ $this->assertRaw($this->unprivileged_user->name, t('User name found in autocompletion results.'));
+ }
+}
+
+
+/**
+ * Test user-links in secondary menu.
+ */
+class UserAccountLinksUnitTests extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User account links',
+ 'description' => 'Test user-account links.',
+ 'group' => 'User'
+ );
+ }
+
+ /**
+ * Test the user login block.
+ */
+ function testSecondaryMenu() {
+ // Create a regular user.
+ $user = $this->drupalCreateUser(array());
+
+ // Log in and get the homepage.
+ $this->drupalLogin($user);
+ $this->drupalGet('<front>');
+
+ // For a logged-in user, expect the secondary menu to have links for "My
+ // account" and "Log out".
+ $link = $this->xpath('//ul[@id=:menu_id]/li/a[contains(@href, :href) and text()=:text]', array(
+ ':menu_id' => 'secondary-menu-links',
+ ':href' => 'user',
+ ':text' => 'My account',
+ ));
+ $this->assertEqual(count($link), 1, 'My account link is in secondary menu.');
+
+ $link = $this->xpath('//ul[@id=:menu_id]/li/a[contains(@href, :href) and text()=:text]', array(
+ ':menu_id' => 'secondary-menu-links',
+ ':href' => 'user/logout',
+ ':text' => 'Log out',
+ ));
+ $this->assertEqual(count($link), 1, 'Log out link is in secondary menu.');
+
+ // Log out and get the homepage.
+ $this->drupalLogout();
+ $this->drupalGet('<front>');
+
+ // For a logged-out user, expect no secondary links.
+ $element = $this->xpath('//ul[@id=:menu_id]', array(':menu_id' => 'secondary-menu-links'));
+ $this->assertEqual(count($element), 0, 'No secondary-menu for logged-out users.');
+ }
+}
+
+/**
+ * Test user blocks.
+ */
+class UserBlocksUnitTests extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User blocks',
+ 'description' => 'Test user blocks.',
+ 'group' => 'User'
+ );
+ }
+
+ /**
+ * Test the user login block.
+ */
+ function testUserLoginBlock() {
+ // Create a user with some permission that anonymous users lack.
+ $user = $this->drupalCreateUser(array('administer permissions'));
+
+ // Log in using the block.
+ $edit = array();
+ $edit['name'] = $user->name;
+ $edit['pass'] = $user->pass_raw;
+ $this->drupalPost('admin/people/permissions', $edit, t('Log in'));
+ $this->assertNoText(t('User login'), t('Logged in.'));
+
+ // Check that we are still on the same page.
+ $this->assertEqual(url('admin/people/permissions', array('absolute' => TRUE)), $this->getUrl(), t('Still on the same page after login for access denied page'));
+
+ // Now, log out and repeat with a non-403 page.
+ $this->drupalLogout();
+ $this->drupalPost('filter/tips', $edit, t('Log in'));
+ $this->assertNoText(t('User login'), t('Logged in.'));
+ $this->assertPattern('!<title.*?' . t('Compose tips') . '.*?</title>!', t('Still on the same page after login for allowed page'));
+ }
+
+ /**
+ * Test the Who's Online block.
+ */
+ function testWhosOnlineBlock() {
+ // Generate users and make sure there are no current user sessions.
+ $user1 = $this->drupalCreateUser(array());
+ $user2 = $this->drupalCreateUser(array());
+ $user3 = $this->drupalCreateUser(array());
+ $this->assertEqual(db_query("SELECT COUNT(*) FROM {sessions}")->fetchField(), 0, t('Sessions table is empty.'));
+
+ // Insert a user with two sessions.
+ $this->insertSession(array('uid' => $user1->uid));
+ $this->insertSession(array('uid' => $user1->uid));
+ $this->assertEqual(db_query("SELECT COUNT(*) FROM {sessions} WHERE uid = :uid", array(':uid' => $user1->uid))->fetchField(), 2, t('Duplicate user session has been inserted.'));
+
+ // Insert a user with only one session.
+ $this->insertSession(array('uid' => $user2->uid, 'timestamp' => REQUEST_TIME + 1));
+
+ // Insert an inactive logged-in user who should not be seen in the block.
+ $this->insertSession(array('uid' => $user3->uid, 'timestamp' => (REQUEST_TIME - variable_get('user_block_seconds_online', 900) - 1)));
+
+ // Insert two anonymous user sessions.
+ $this->insertSession();
+ $this->insertSession();
+
+ // Test block output.
+ $block = user_block_view('online');
+ $this->drupalSetContent($block['content']);
+ $this->assertRaw(t('2 users'), t('Correct number of online users (2 users).'));
+ $this->assertText($user1->name, t('Active user 1 found in online list.'));
+ $this->assertText($user2->name, t('Active user 2 found in online list.'));
+ $this->assertNoText($user3->name, t("Inactive user not found in online list."));
+ $this->assertTrue(strpos($this->drupalGetContent(), $user1->name) > strpos($this->drupalGetContent(), $user2->name), t('Online users are ordered correctly.'));
+ }
+
+ /**
+ * Insert a user session into the {sessions} table. This function is used
+ * since we cannot log in more than one user at the same time in tests.
+ */
+ private function insertSession(array $fields = array()) {
+ $fields += array(
+ 'uid' => 0,
+ 'sid' => drupal_hash_base64(uniqid(mt_rand(), TRUE)),
+ 'timestamp' => REQUEST_TIME,
+ );
+ db_insert('sessions')
+ ->fields($fields)
+ ->execute();
+ $this->assertEqual(db_query("SELECT COUNT(*) FROM {sessions} WHERE uid = :uid AND sid = :sid AND timestamp = :timestamp", array(':uid' => $fields['uid'], ':sid' => $fields['sid'], ':timestamp' => $fields['timestamp']))->fetchField(), 1, t('Session record inserted.'));
+ }
+}
+
+/**
+ * Test case to test user_save() behaviour.
+ */
+class UserSaveTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User save test',
+ 'description' => 'Test user_save() for arbitrary new uid.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Test creating a user with arbitrary uid.
+ */
+ function testUserImport() {
+ // User ID must be a number that is not in the database.
+ $max_uid = db_query('SELECT MAX(uid) FROM {users}')->fetchField();
+ $test_uid = $max_uid + mt_rand(1000, 1000000);
+ $test_name = $this->randomName();
+
+ // Create the base user, based on drupalCreateUser().
+ $user = array(
+ 'name' => $test_name,
+ 'uid' => $test_uid,
+ 'mail' => $test_name . '@example.com',
+ 'is_new' => TRUE,
+ 'pass' => user_password(),
+ 'status' => 1,
+ );
+ $user_by_return = user_save(drupal_anonymous_user(), $user);
+ $this->assertTrue($user_by_return, t('Loading user by return of user_save().'));
+
+ // Test if created user exists.
+ $user_by_uid = user_load($test_uid);
+ $this->assertTrue($user_by_uid, t('Loading user by uid.'));
+
+ $user_by_name = user_load_by_name($test_name);
+ $this->assertTrue($user_by_name, t('Loading user by name.'));
+ }
+}
+
+/**
+ * Test the create user administration page.
+ */
+class UserCreateTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User create',
+ 'description' => 'Test the create user administration page.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Create a user through the administration interface and ensure that it
+ * displays in the user list.
+ */
+ protected function testUserAdd() {
+ $user = $this->drupalCreateUser(array('administer users'));
+ $this->drupalLogin($user);
+
+ foreach (array(FALSE, TRUE) as $notify) {
+ $edit = array(
+ 'name' => $this->randomName(),
+ 'mail' => $this->randomName() . '@example.com',
+ 'pass[pass1]' => $pass = $this->randomString(),
+ 'pass[pass2]' => $pass,
+ 'notify' => $notify,
+ );
+ $this->drupalPost('admin/people/create', $edit, t('Create new account'));
+
+ if ($notify) {
+ $this->assertText(t('A welcome message with further instructions has been e-mailed to the new user @name.', array('@name' => $edit['name'])), 'User created');
+ $this->assertEqual(count($this->drupalGetMails()), 1, 'Notification e-mail sent');
+ }
+ else {
+ $this->assertText(t('Created a new user account for @name. No e-mail has been sent.', array('@name' => $edit['name'])), 'User created');
+ $this->assertEqual(count($this->drupalGetMails()), 0, 'Notification e-mail not sent');
+ }
+
+ $this->drupalGet('admin/people');
+ $this->assertText($edit['name'], 'User found in list of users');
+ }
+ }
+}
+
+/**
+ * Test case to test user_save() behaviour.
+ */
+class UserEditTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User edit',
+ 'description' => 'Test user edit page.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Test user edit page.
+ */
+ function testUserEdit() {
+ // Test user edit functionality with user pictures disabled.
+ variable_set('user_pictures', 0);
+ $user1 = $this->drupalCreateUser(array('change own username'));
+ $user2 = $this->drupalCreateUser(array());
+ $this->drupalLogin($user1);
+
+ // Test that error message appears when attempting to use a non-unique user name.
+ $edit['name'] = $user2->name;
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t('The name %name is already taken.', array('%name' => $edit['name'])));
+
+ // Repeat the test with user pictures enabled, which modifies the form.
+ variable_set('user_pictures', 1);
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t('The name %name is already taken.', array('%name' => $edit['name'])));
+
+ // Check that filling out a single password field does not validate.
+ $edit = array();
+ $edit['pass[pass1]'] = '';
+ $edit['pass[pass2]'] = $this->randomName();
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertText(t("The specified passwords do not match."), t('Typing mismatched passwords displays an error message.'));
+
+ $edit['pass[pass1]'] = $this->randomName();
+ $edit['pass[pass2]'] = '';
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertText(t("The specified passwords do not match."), t('Typing mismatched passwords displays an error message.'));
+
+ // Test that the error message appears when attempting to change the mail or
+ // pass without the current password.
+ $edit = array();
+ $edit['mail'] = $this->randomName() . '@new.example.com';
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t("Your current password is missing or incorrect; it's required to change the %name.", array('%name' => t('E-mail address'))));
+
+ $edit['current_pass'] = $user1->pass_raw;
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t("The changes have been saved."));
+
+ // Test that the user must enter current password before changing passwords.
+ $edit = array();
+ $edit['pass[pass1]'] = $new_pass = $this->randomName();
+ $edit['pass[pass2]'] = $new_pass;
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t("Your current password is missing or incorrect; it's required to change the %name.", array('%name' => t('Password'))));
+
+ // Try again with the current password.
+ $edit['current_pass'] = $user1->pass_raw;
+ $this->drupalPost("user/$user1->uid/edit", $edit, t('Save'));
+ $this->assertRaw(t("The changes have been saved."));
+
+ // Make sure the user can log in with their new password.
+ $this->drupalLogout();
+ $user1->pass_raw = $new_pass;
+ $this->drupalLogin($user1);
+ $this->drupalLogout();
+ }
+}
+
+/**
+ * Test case for user signatures.
+ */
+class UserSignatureTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User signatures',
+ 'description' => 'Test user signatures.',
+ 'group' => 'User',
+ );
+ }
+
+ function setUp() {
+ parent::setUp('comment');
+
+ // Enable user signatures.
+ variable_set('user_signatures', 1);
+
+ // Prefetch text formats.
+ $this->full_html_format = filter_format_load('full_html');
+ $this->plain_text_format = filter_format_load('plain_text');
+
+ // Create regular and administrative users.
+ $this->web_user = $this->drupalCreateUser(array());
+ $admin_permissions = array('administer comments');
+ foreach (filter_formats() as $format) {
+ if ($permission = filter_permission_name($format)) {
+ $admin_permissions[] = $permission;
+ }
+ }
+ $this->admin_user = $this->drupalCreateUser($admin_permissions);
+ }
+
+ /**
+ * Test that a user can change their signature format and that it is respected
+ * upon display.
+ */
+ function testUserSignature() {
+ // Create a new node with comments on.
+ $node = $this->drupalCreateNode(array('comment' => COMMENT_NODE_OPEN));
+
+ // Verify that user signature field is not displayed on registration form.
+ $this->drupalGet('user/register');
+ $this->assertNoText(t('Signature'));
+
+ // Log in as a regular user and create a signature.
+ $this->drupalLogin($this->web_user);
+ $signature_text = "<h1>" . $this->randomName() . "</h1>";
+ $edit = array(
+ 'signature[value]' => $signature_text,
+ 'signature[format]' => $this->plain_text_format->format,
+ );
+ $this->drupalPost('user/' . $this->web_user->uid . '/edit', $edit, t('Save'));
+
+ // Verify that values were stored.
+ $this->assertFieldByName('signature[value]', $edit['signature[value]'], 'Submitted signature text found.');
+ $this->assertFieldByName('signature[format]', $edit['signature[format]'], 'Submitted signature format found.');
+
+ // Create a comment.
+ $langcode = LANGUAGE_NONE;
+ $edit = array();
+ $edit['subject'] = $this->randomName(8);
+ $edit['comment_body[' . $langcode . '][0][value]'] = $this->randomName(16);
+ $this->drupalPost('comment/reply/' . $node->nid, $edit, t('Preview'));
+ $this->drupalPost(NULL, array(), t('Save'));
+
+ // Get the comment ID. (This technique is the same one used in the Comment
+ // module's CommentHelperCase test case.)
+ preg_match('/#comment-([0-9]+)/', $this->getURL(), $match);
+ $comment_id = $match[1];
+
+ // Log in as an administrator and edit the comment to use Full HTML, so
+ // that the comment text itself is not filtered at all.
+ $this->drupalLogin($this->admin_user);
+ $edit['comment_body[' . $langcode . '][0][format]'] = $this->full_html_format->format;
+ $this->drupalPost('comment/' . $comment_id . '/edit', $edit, t('Save'));
+
+ // Assert that the signature did not make it through unfiltered.
+ $this->drupalGet('node/' . $node->nid);
+ $this->assertNoRaw($signature_text, 'Unfiltered signature text not found.');
+ $this->assertRaw(check_markup($signature_text, $this->plain_text_format->format), 'Filtered signature text found.');
+ }
+}
+
+/*
+ * Test that a user, having editing their own account, can still log in.
+ */
+class UserEditedOwnAccountTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User edited own account',
+ 'description' => 'Test user edited own account can still log in.',
+ 'group' => 'User',
+ );
+ }
+
+ function testUserEditedOwnAccount() {
+ // Change account setting 'Who can register accounts?' to Administrators
+ // only.
+ variable_set('user_register', USER_REGISTER_ADMINISTRATORS_ONLY);
+
+ // Create a new user account and log in.
+ $account = $this->drupalCreateUser(array('change own username'));
+ $this->drupalLogin($account);
+
+ // Change own username.
+ $edit = array();
+ $edit['name'] = $this->randomName();
+ $this->drupalPost('user/' . $account->uid . '/edit', $edit, t('Save'));
+
+ // Log out.
+ $this->drupalLogout();
+
+ // Set the new name on the user account and attempt to log back in.
+ $account->name = $edit['name'];
+ $this->drupalLogin($account);
+ }
+}
+
+/**
+ * Test case to test adding, editing and deleting roles.
+ */
+class UserRoleAdminTestCase extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User role administration',
+ 'description' => 'Test adding, editing and deleting user roles and changing role weights.',
+ 'group' => 'User',
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->admin_user = $this->drupalCreateUser(array('administer permissions', 'administer users'));
+ }
+
+ /**
+ * Test adding, renaming and deleting roles.
+ */
+ function testRoleAdministration() {
+ $this->drupalLogin($this->admin_user);
+
+ // Test adding a role. (In doing so, we use a role name that happens to
+ // correspond to an integer, to test that the role administration pages
+ // correctly distinguish between role names and IDs.)
+ $role_name = '123';
+ $edit = array('name' => $role_name);
+ $this->drupalPost('admin/people/permissions/roles', $edit, t('Add role'));
+ $this->assertText(t('The role has been added.'), t('The role has been added.'));
+ $role = user_role_load_by_name($role_name);
+ $this->assertTrue(is_object($role), t('The role was successfully retrieved from the database.'));
+
+ // Try adding a duplicate role.
+ $this->drupalPost(NULL, $edit, t('Add role'));
+ $this->assertRaw(t('The role name %name already exists. Choose another role name.', array('%name' => $role_name)), t('Duplicate role warning displayed.'));
+
+ // Test renaming a role.
+ $old_name = $role_name;
+ $role_name = '456';
+ $edit = array('name' => $role_name);
+ $this->drupalPost("admin/people/permissions/roles/edit/{$role->rid}", $edit, t('Save role'));
+ $this->assertText(t('The role has been renamed.'), t('The role has been renamed.'));
+ $this->assertFalse(user_role_load_by_name($old_name), t('The role can no longer be retrieved from the database using its old name.'));
+ $this->assertTrue(is_object(user_role_load_by_name($role_name)), t('The role can be retrieved from the database using its new name.'));
+
+ // Test deleting a role.
+ $this->drupalPost("admin/people/permissions/roles/edit/{$role->rid}", NULL, t('Delete role'));
+ $this->drupalPost(NULL, NULL, t('Delete'));
+ $this->assertText(t('The role has been deleted.'), t('The role has been deleted'));
+ $this->assertNoLinkByHref("admin/people/permissions/roles/edit/{$role->rid}", t('Role edit link removed.'));
+ $this->assertFalse(user_role_load_by_name($role_name), t('A deleted role can no longer be loaded.'));
+
+ // Make sure that the system-defined roles cannot be edited via the user
+ // interface.
+ $this->drupalGet('admin/people/permissions/roles/edit/' . DRUPAL_ANONYMOUS_RID);
+ $this->assertResponse(403, t('Access denied when trying to edit the built-in anonymous role.'));
+ $this->drupalGet('admin/people/permissions/roles/edit/' . DRUPAL_AUTHENTICATED_RID);
+ $this->assertResponse(403, t('Access denied when trying to edit the built-in authenticated role.'));
+ }
+
+ /**
+ * Test user role weight change operation.
+ */
+ function testRoleWeightChange() {
+ $this->drupalLogin($this->admin_user);
+
+ // Pick up a random role and get its weight.
+ $rid = array_rand(user_roles());
+ $role = user_role_load($rid);
+ $old_weight = $role->weight;
+
+ // Change the role weight and submit the form.
+ $edit = array('roles['. $rid .'][weight]' => $old_weight + 1);
+ $this->drupalPost('admin/people/permissions/roles', $edit, t('Save order'));
+ $this->assertText(t('The role settings have been updated.'), t('The role settings form submitted successfully.'));
+
+ // Retrieve the saved role and compare its weight.
+ $role = user_role_load($rid);
+ $new_weight = $role->weight;
+ $this->assertTrue(($old_weight + 1) == $new_weight, t('Role weight updated successfully.'));
+ }
+}
+
+/**
+ * Test user token replacement in strings.
+ */
+class UserTokenReplaceTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User token replacement',
+ 'description' => 'Generates text using placeholders for dummy content to check user token replacement.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * Creates a user, then tests the tokens generated from it.
+ */
+ function testUserTokenReplacement() {
+ global $language;
+ $url_options = array(
+ 'absolute' => TRUE,
+ 'language' => $language,
+ );
+
+ // Create two users and log them in one after another.
+ $user1 = $this->drupalCreateUser(array());
+ $user2 = $this->drupalCreateUser(array());
+ $this->drupalLogin($user1);
+ $this->drupalLogout();
+ $this->drupalLogin($user2);
+
+ $account = user_load($user1->uid);
+ $global_account = user_load($GLOBALS['user']->uid);
+
+ // Generate and test sanitized tokens.
+ $tests = array();
+ $tests['[user:uid]'] = $account->uid;
+ $tests['[user:name]'] = check_plain(format_username($account));
+ $tests['[user:mail]'] = check_plain($account->mail);
+ $tests['[user:url]'] = url("user/$account->uid", $url_options);
+ $tests['[user:edit-url]'] = url("user/$account->uid/edit", $url_options);
+ $tests['[user:last-login]'] = format_date($account->login, 'medium', '', NULL, $language->language);
+ $tests['[user:last-login:short]'] = format_date($account->login, 'short', '', NULL, $language->language);
+ $tests['[user:created]'] = format_date($account->created, 'medium', '', NULL, $language->language);
+ $tests['[user:created:short]'] = format_date($account->created, 'short', '', NULL, $language->language);
+ $tests['[current-user:name]'] = check_plain(format_username($global_account));
+
+ // Test to make sure that we generated something for each token.
+ $this->assertFalse(in_array(0, array_map('strlen', $tests)), t('No empty tokens generated.'));
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('user' => $account), array('language' => $language));
+ $this->assertEqual($output, $expected, t('Sanitized user token %token replaced.', array('%token' => $input)));
+ }
+
+ // Generate and test unsanitized tokens.
+ $tests['[user:name]'] = format_username($account);
+ $tests['[user:mail]'] = $account->mail;
+ $tests['[current-user:name]'] = format_username($global_account);
+
+ foreach ($tests as $input => $expected) {
+ $output = token_replace($input, array('user' => $account), array('language' => $language, 'sanitize' => FALSE));
+ $this->assertEqual($output, $expected, t('Unsanitized user token %token replaced.', array('%token' => $input)));
+ }
+ }
+}
+
+/**
+ * Test user search.
+ */
+class UserUserSearchTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User search',
+ 'description' => 'Testing that only user with the right permission can see the email address in the user search.',
+ 'group' => 'User',
+ );
+ }
+
+ function testUserSearch() {
+ $user1 = $this->drupalCreateUser(array('access user profiles', 'search content', 'use advanced search'));
+ $this->drupalLogin($user1);
+ $keys = $user1->mail;
+ $edit = array('keys' => $keys);
+ $this->drupalPost('search/user/', $edit, t('Search'));
+ $this->assertNoText($keys);
+ $this->drupalLogout();
+
+ $user2 = $this->drupalCreateUser(array('administer users', 'access user profiles', 'search content', 'use advanced search'));
+ $this->drupalLogin($user2);
+ $keys = $user2->mail;
+ $edit = array('keys' => $keys);
+ $this->drupalPost('search/user/', $edit, t('Search'));
+ $this->assertText($keys);
+ $this->drupalLogout();
+ }
+}
+
+
+/**
+ * Test role assignment.
+ */
+class UserRolesAssignmentTestCase extends DrupalWebTestCase {
+ protected $admin_user;
+
+ public static function getInfo() {
+ return array(
+ 'name' => t('Role assignment'),
+ 'description' => t('Tests that users can be assigned and unassigned roles.'),
+ 'group' => t('User')
+ );
+ }
+
+ function setUp() {
+ parent::setUp();
+ $this->admin_user = $this->drupalCreateUser(array('administer permissions', 'administer users'));
+ $this->drupalLogin($this->admin_user);
+ }
+
+ /**
+ * Tests that a user can be assigned a role and that the role can be removed
+ * again.
+ */
+ function testAssignAndRemoveRole() {
+ $rid = $this->drupalCreateRole(array('administer content types'));
+ $account = $this->drupalCreateUser();
+
+ // Assign the role to the user.
+ $this->drupalPost('user/' . $account->uid . '/edit', array("roles[$rid]" => $rid), t('Save'));
+ $this->assertText(t('The changes have been saved.'));
+ $this->assertFieldChecked('edit-roles-' . $rid, t('Role is assigned.'));
+ $this->userLoadAndCheckRoleAssigned($account, $rid);
+
+ // Remove the role from the user.
+ $this->drupalPost('user/' . $account->uid . '/edit', array("roles[$rid]" => FALSE), t('Save'));
+ $this->assertText(t('The changes have been saved.'));
+ $this->assertNoFieldChecked('edit-roles-' . $rid, t('Role is removed from user.'));
+ $this->userLoadAndCheckRoleAssigned($account, $rid, FALSE);
+ }
+
+ /**
+ * Tests that when creating a user the role can be assigned. And that it can
+ * be removed again.
+ */
+ function testCreateUserWithRole() {
+ $rid = $this->drupalCreateRole(array('administer content types'));
+ // Create a new user and add the role at the same time.
+ $edit = array(
+ 'name' => $this->randomName(),
+ 'mail' => $this->randomName() . '@example.com',
+ 'pass[pass1]' => $pass = $this->randomString(),
+ 'pass[pass2]' => $pass,
+ "roles[$rid]" => $rid,
+ );
+ $this->drupalPost('admin/people/create', $edit, t('Create new account'));
+ $this->assertText(t('Created a new user account for !name.', array('!name' => $edit['name'])));
+ // Get the newly added user.
+ $account = user_load_by_name($edit['name']);
+
+ $this->drupalGet('user/' . $account->uid . '/edit');
+ $this->assertFieldChecked('edit-roles-' . $rid, t('Role is assigned.'));
+ $this->userLoadAndCheckRoleAssigned($account, $rid);
+
+ // Remove the role again.
+ $this->drupalPost('user/' . $account->uid . '/edit', array("roles[$rid]" => FALSE), t('Save'));
+ $this->assertText(t('The changes have been saved.'));
+ $this->assertNoFieldChecked('edit-roles-' . $rid, t('Role is removed from user.'));
+ $this->userLoadAndCheckRoleAssigned($account, $rid, FALSE);
+ }
+
+ /**
+ * Check role on user object.
+ *
+ * @param object $account User.
+ * @param integer $rid Role id.
+ * @param bool $is_assigned True if the role should present on the account.
+ */
+ private function userLoadAndCheckRoleAssigned($account, $rid, $is_assigned = TRUE) {
+ $account = user_load($account->uid, TRUE);
+ if ($is_assigned) {
+ $this->assertTrue(array_key_exists($rid, $account->roles), t('The role is present in the user object.'));
+ }
+ else {
+ $this->assertFalse(array_key_exists($rid, $account->roles), t('The role is not present in the user object.'));
+ }
+ }
+}
+
+
+/**
+ * Unit test for authmap assignment.
+ */
+class UserAuthmapAssignmentTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => t('Authmap assignment'),
+ 'description' => t('Tests that users can be assigned and unassigned authmaps.'),
+ 'group' => t('User')
+ );
+ }
+
+ /**
+ * Test authmap assignment and retrieval.
+ */
+ function testAuthmapAssignment() {
+ $account = $this->drupalCreateUser();
+
+ // Assign authmaps to the user.
+ $authmaps = array(
+ 'authname_poll' => 'external username one',
+ 'authname_book' => 'external username two',
+ );
+ user_set_authmaps($account, $authmaps);
+
+ // Test for expected authmaps.
+ $expected_authmaps = array(
+ 'external username one' => array(
+ 'poll' => 'external username one',
+ ),
+ 'external username two' => array(
+ 'book' => 'external username two',
+ ),
+ );
+ foreach ($expected_authmaps as $authname => $expected_output) {
+ $this->assertIdentical(user_get_authmaps($authname), $expected_output, t('Authmap for authname %authname was set correctly.', array('%authname' => $authname)));
+ }
+
+ // Remove authmap for module poll, add authmap for module blog.
+ $authmaps = array(
+ 'authname_poll' => NULL,
+ 'authname_blog' => 'external username three',
+ );
+ user_set_authmaps($account, $authmaps);
+
+ // Assert that external username one does not have authmaps.
+ $remove_username = 'external username one';
+ unset($expected_authmaps[$remove_username]);
+ $this->assertFalse(user_get_authmaps($remove_username), t('Authmap for %authname was removed.', array('%authname' => $remove_username)));
+
+ // Assert that a new authmap was created for external username three, and
+ // existing authmaps for external username two were unchanged.
+ $expected_authmaps['external username three'] = array('blog' => 'external username three');
+ foreach ($expected_authmaps as $authname => $expected_output) {
+ $this->assertIdentical(user_get_authmaps($authname), $expected_output, t('Authmap for authname %authname was set correctly.', array('%authname' => $authname)));
+ }
+ }
+}
+
+/**
+ * Tests user_validate_current_pass on a custom form.
+ */
+class UserValidateCurrentPassCustomForm extends DrupalWebTestCase {
+
+ public static function getInfo() {
+ return array(
+ 'name' => 'User validate current pass custom form',
+ 'description' => 'Test that user_validate_current_pass is usable on a custom form.',
+ 'group' => 'User',
+ );
+ }
+
+ /**
+ * User with permission to view content.
+ */
+ protected $accessUser;
+
+ /**
+ * User permission to administer users.
+ */
+ protected $adminUser;
+
+ function setUp() {
+ parent::setUp('user_form_test');
+ // Create two users
+ $this->accessUser = $this->drupalCreateUser(array('access content'));
+ $this->adminUser = $this->drupalCreateUser(array('administer users'));
+ }
+
+ /**
+ * Tests that user_validate_current_pass can be reused on a custom form.
+ */
+ function testUserValidateCurrentPassCustomForm() {
+ $this->drupalLogin($this->adminUser);
+
+ // Submit the custom form with the admin user using the access user's password.
+ $edit = array();
+ $edit['user_form_test_field'] = $this->accessUser->name;
+ $edit['current_pass'] = $this->accessUser->pass_raw;
+ $this->drupalPost('user_form_test_current_password/' . $this->accessUser->uid, $edit, t('Test'));
+ $this->assertText(t('The password has been validated and the form submitted successfully.'));
+ }
+}
+
+/**
+ * Test user entity callbacks.
+ */
+class UserEntityCallbacksTestCase extends DrupalWebTestCase {
+ public static function getInfo() {
+ return array(
+ 'name' => 'User entity callback tests',
+ 'description' => 'Tests specific parts of the user entity like the URI callback and the label callback.',
+ 'group' => 'User'
+ );
+ }
+
+ function setUp() {
+ parent::setUp('user');
+
+ $this->account = $this->drupalCreateUser();
+ $this->anonymous = drupal_anonymous_user();
+ }
+
+ /**
+ * Test label callback.
+ */
+ function testLabelCallback() {
+ $this->assertEqual(entity_label('user', $this->account), $this->account->name, t('The username should be used as label'));
+
+ // Setup a random anonymous name to be sure the name is used.
+ $name = $this->randomName();
+ variable_set('anonymous', $name);
+ $this->assertEqual(entity_label('user', $this->anonymous), $name, t('The variable anonymous should be used for name of uid 0'));
+ }
+
+ /**
+ * Test URI callback.
+ */
+ function testUriCallback() {
+ $uri = entity_uri('user', $this->account);
+ $this->assertEqual('user/' . $this->account->uid, $uri['path'], t('Correct user URI.'));
+ }
+}
diff --git a/core/modules/user/user.tokens.inc b/core/modules/user/user.tokens.inc
new file mode 100644
index 000000000000..8dcea4b597e0
--- /dev/null
+++ b/core/modules/user/user.tokens.inc
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * @file
+ * Builds placeholder replacement tokens for user-related data.
+ */
+
+/**
+ * Implements hook_token_info().
+ */
+function user_token_info() {
+ $types['user'] = array(
+ 'name' => t('Users'),
+ 'description' => t('Tokens related to individual user accounts.'),
+ 'needs-data' => 'user',
+ );
+ $types['current-user'] = array(
+ 'name' => t('Current user'),
+ 'description' => t('Tokens related to the currently logged in user.'),
+ 'type' => 'user',
+ );
+
+ $user['uid'] = array(
+ 'name' => t('User ID'),
+ 'description' => t("The unique ID of the user account."),
+ );
+ $user['name'] = array(
+ 'name' => t("Name"),
+ 'description' => t("The login name of the user account."),
+ );
+ $user['mail'] = array(
+ 'name' => t("Email"),
+ 'description' => t("The email address of the user account."),
+ );
+ $user['url'] = array(
+ 'name' => t("URL"),
+ 'description' => t("The URL of the account profile page."),
+ );
+ $user['edit-url'] = array(
+ 'name' => t("Edit URL"),
+ 'description' => t("The URL of the account edit page."),
+ );
+
+ $user['last-login'] = array(
+ 'name' => t("Last login"),
+ 'description' => t("The date the user last logged in to the site."),
+ 'type' => 'date',
+ );
+ $user['created'] = array(
+ 'name' => t("Created"),
+ 'description' => t("The date the user account was created."),
+ 'type' => 'date',
+ );
+
+ return array(
+ 'types' => $types,
+ 'tokens' => array('user' => $user),
+ );
+}
+
+/**
+ * Implements hook_tokens().
+ */
+function user_tokens($type, $tokens, array $data = array(), array $options = array()) {
+ $url_options = array('absolute' => TRUE);
+ if (isset($options['language'])) {
+ $url_options['language'] = $options['language'];
+ $language_code = $options['language']->language;
+ }
+ else {
+ $language_code = NULL;
+ }
+ $sanitize = !empty($options['sanitize']);
+
+ $replacements = array();
+
+ if ($type == 'user' && !empty($data['user'])) {
+ $account = $data['user'];
+ foreach ($tokens as $name => $original) {
+ switch ($name) {
+ // Basic user account information.
+ case 'uid':
+ // In the case of hook user_presave uid is not set yet.
+ $replacements[$original] = !empty($account->uid) ? $account->uid : t('not yet assigned');
+ break;
+
+ case 'name':
+ $name = format_username($account);
+ $replacements[$original] = $sanitize ? check_plain($name) : $name;
+ break;
+
+ case 'mail':
+ $replacements[$original] = $sanitize ? check_plain($account->mail) : $account->mail;
+ break;
+
+ case 'url':
+ $replacements[$original] = !empty($account->uid) ? url("user/$account->uid", $url_options) : t('not yet assigned');
+ break;
+
+ case 'edit-url':
+ $replacements[$original] = !empty($account->uid) ? url("user/$account->uid/edit", $url_options) : t('not yet assigned');
+ break;
+
+ // These tokens are default variations on the chained tokens handled below.
+ case 'last-login':
+ $replacements[$original] = !empty($account->login) ? format_date($account->login, 'medium', '', NULL, $language_code) : t('never');
+ break;
+
+ case 'created':
+ // In the case of user_presave the created date may not yet be set.
+ $replacements[$original] = !empty($account->created) ? format_date($account->created, 'medium', '', NULL, $language_code) : t('not yet created');
+ break;
+ }
+ }
+
+ if ($login_tokens = token_find_with_prefix($tokens, 'last-login')) {
+ $replacements += token_generate('date', $login_tokens, array('date' => $account->login), $options);
+ }
+
+ if ($registered_tokens = token_find_with_prefix($tokens, 'created')) {
+ $replacements += token_generate('date', $registered_tokens, array('date' => $account->created), $options);
+ }
+ }
+
+ if ($type == 'current-user') {
+ $account = user_load($GLOBALS['user']->uid);
+ $replacements += token_generate('user', $tokens, array('user' => $account), $options);
+ }
+
+ return $replacements;
+}
diff --git a/core/scripts/cron-curl.sh b/core/scripts/cron-curl.sh
new file mode 100644
index 000000000000..71f06b95b3c1
--- /dev/null
+++ b/core/scripts/cron-curl.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+curl --silent --compressed http://example.com/core/cron.php
diff --git a/core/scripts/cron-lynx.sh b/core/scripts/cron-lynx.sh
new file mode 100644
index 000000000000..36880d2996d9
--- /dev/null
+++ b/core/scripts/cron-lynx.sh
@@ -0,0 +1,3 @@
+#!/bin/sh
+
+/usr/bin/lynx -source http://example.com/core/cron.php > /dev/null 2>&1
diff --git a/core/scripts/drupal.sh b/core/scripts/drupal.sh
new file mode 100755
index 000000000000..cf17e68bf03a
--- /dev/null
+++ b/core/scripts/drupal.sh
@@ -0,0 +1,144 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * Drupal shell execution script
+ *
+ * Check for your PHP interpreter - on Windows you'll probably have to
+ * replace line 1 with
+ * #!c:/program files/php/php.exe
+ *
+ * @param path Drupal's absolute root directory in local file system (optional).
+ * @param URI A URI to execute, including HTTP protocol prefix.
+ */
+$script = basename(array_shift($_SERVER['argv']));
+
+if (in_array('--help', $_SERVER['argv']) || empty($_SERVER['argv'])) {
+ echo <<<EOF
+
+Execute a Drupal page from the shell.
+
+Usage: {$script} [OPTIONS] "<URI>"
+Example: {$script} "http://mysite.org/node"
+
+All arguments are long options.
+
+ --help This page.
+
+ --root Set the working directory for the script to the specified path.
+ To execute Drupal this has to be the root directory of your
+ Drupal installation, f.e. /home/www/foo/drupal (assuming Drupal
+ running on Unix). Current directory is not required.
+ Use surrounding quotation marks on Windows.
+
+ --verbose This option displays the options as they are set, but will
+ produce errors from setting the session.
+
+ URI The URI to execute, i.e. http://default/foo/bar for executing
+ the path '/foo/bar' in your site 'default'. URI has to be
+ enclosed by quotation marks if there are ampersands in it
+ (f.e. index.php?q=node&foo=bar). Prefix 'http://' is required,
+ and the domain must exist in Drupal's sites-directory.
+
+ If the given path and file exists it will be executed directly,
+ i.e. if URI is set to http://default/bar/foo.php
+ and bar/foo.php exists, this script will be executed without
+ bootstrapping Drupal. To execute Drupal's cron.php, specify
+ http://default/core/cron.php as the URI.
+
+
+To run this script without --root argument invoke it from the root directory
+of your Drupal installation with
+
+ ./scripts/{$script}
+\n
+EOF;
+ exit;
+}
+
+// define default settings
+$cmd = 'index.php';
+$_SERVER['HTTP_HOST'] = 'default';
+$_SERVER['PHP_SELF'] = '/index.php';
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+$_SERVER['SERVER_SOFTWARE'] = NULL;
+$_SERVER['REQUEST_METHOD'] = 'GET';
+$_SERVER['QUERY_STRING'] = '';
+$_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = '/';
+$_SERVER['HTTP_USER_AGENT'] = 'console';
+
+// toggle verbose mode
+if (in_array('--verbose', $_SERVER['argv'])) {
+ $_verbose_mode = true;
+}
+else {
+ $_verbose_mode = false;
+}
+
+// parse invocation arguments
+while ($param = array_shift($_SERVER['argv'])) {
+ switch ($param) {
+ case '--root':
+ // change working directory
+ $path = array_shift($_SERVER['argv']);
+ if (is_dir($path)) {
+ chdir($path);
+ if ($_verbose_mode) {
+ echo "cwd changed to: {$path}\n";
+ }
+ }
+ else {
+ echo "\nERROR: {$path} not found.\n\n";
+ }
+ break;
+
+ default:
+ if (substr($param, 0, 2) == '--') {
+ // ignore unknown options
+ break;
+ }
+ else {
+ // parse the URI
+ $path = parse_url($param);
+
+ // set site name
+ if (isset($path['host'])) {
+ $_SERVER['HTTP_HOST'] = $path['host'];
+ }
+
+ // set query string
+ if (isset($path['query'])) {
+ $_SERVER['QUERY_STRING'] = $path['query'];
+ parse_str($path['query'], $_GET);
+ $_REQUEST = $_GET;
+ }
+
+ // set file to execute or Drupal path (clean urls enabled)
+ if (isset($path['path']) && file_exists(substr($path['path'], 1))) {
+ $_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = $path['path'];
+ $cmd = substr($path['path'], 1);
+ }
+ elseif (isset($path['path'])) {
+ if (!isset($_GET['q'])) {
+ $_REQUEST['q'] = $_GET['q'] = $path['path'];
+ }
+ }
+
+ // display setup in verbose mode
+ if ($_verbose_mode) {
+ echo "Hostname set to: {$_SERVER['HTTP_HOST']}\n";
+ echo "Script name set to: {$cmd}\n";
+ echo "Path set to: {$_GET['q']}\n";
+ }
+ }
+ break;
+ }
+}
+
+if (file_exists($cmd)) {
+ include $cmd;
+}
+else {
+ echo "\nERROR: {$cmd} not found.\n\n";
+}
+exit();
diff --git a/core/scripts/dump-database-d6.sh b/core/scripts/dump-database-d6.sh
new file mode 100644
index 000000000000..d39bb4306d83
--- /dev/null
+++ b/core/scripts/dump-database-d6.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * Dump a Drupal 6 database into a Drupal 7 PHP script to test the upgrade
+ * process.
+ *
+ * Run this script at the root of an existing Drupal 6 installation.
+ *
+ * The output of this script is a PHP script that can be ran inside Drupal 7
+ * and recreates the Drupal 6 database as dumped. Transient data from cache
+ * session and watchdog tables are not recorded.
+ */
+
+// Define default settings.
+$cmd = 'index.php';
+$_SERVER['HTTP_HOST'] = 'default';
+$_SERVER['PHP_SELF'] = '/index.php';
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+$_SERVER['SERVER_SOFTWARE'] = NULL;
+$_SERVER['REQUEST_METHOD'] = 'GET';
+$_SERVER['QUERY_STRING'] = '';
+$_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = '/';
+$_SERVER['HTTP_USER_AGENT'] = 'console';
+
+// Bootstrap Drupal.
+include_once './includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+// Include the utility drupal_var_export() function.
+include_once __DIR__ . '/../includes/utility.inc';
+
+// Output the PHP header.
+$output = <<<ENDOFHEADER
+<?php
+
+/**
+ * @file
+ * Filled installation of Drupal 6.17, for test purposes.
+ *
+ * This file was generated by the dump-database-d6.sh tool, from an
+ * installation of Drupal 6, filled with data using the generate-d6-content.sh
+ * tool. It has the following modules installed:
+
+ENDOFHEADER;
+
+foreach (module_list() as $module) {
+ $output .= " * - $module\n";
+}
+$output .= " */\n\n";
+
+// Get the current schema, order it by table name.
+$schema = drupal_get_schema();
+ksort($schema);
+
+// Export all the tables in the schema.
+foreach ($schema as $table => $data) {
+ // Remove descriptions to save time and code.
+ unset($data['description']);
+ foreach ($data['fields'] as &$field) {
+ unset($field['description']);
+ }
+
+ // Dump the table structure.
+ $output .= "db_create_table('" . $table . "', " . drupal_var_export($data) . ");\n";
+
+ // Don't output values for those tables.
+ if (substr($table, 0, 5) == 'cache' || $table == 'sessions' || $table == 'watchdog') {
+ $output .= "\n";
+ continue;
+ }
+
+ // Prepare the export of values.
+ $result = db_query('SELECT * FROM {'. $table .'}');
+ $insert = '';
+ while ($record = db_fetch_array($result)) {
+ // users.uid is a serial and inserting 0 into a serial can break MySQL.
+ // So record uid + 1 instead of uid for every uid and once all records
+ // are in place, fix them up.
+ if ($table == 'users') {
+ $record['uid']++;
+ }
+ $insert .= '->values('. drupal_var_export($record) .")\n";
+ }
+
+ // Dump the values if there are some.
+ if ($insert) {
+ $output .= "db_insert('". $table . "')->fields(". drupal_var_export(array_keys($data['fields'])) .")\n";
+ $output .= $insert;
+ $output .= "->execute();\n";
+ }
+
+ // Add the statement fixing the serial in the user table.
+ if ($table == 'users') {
+ $output .= "db_query('UPDATE {users} SET uid = uid - 1');\n";
+ }
+
+ $output .= "\n";
+}
+
+print $output;
diff --git a/core/scripts/dump-database-d7.sh b/core/scripts/dump-database-d7.sh
new file mode 100644
index 000000000000..f78f998e50c5
--- /dev/null
+++ b/core/scripts/dump-database-d7.sh
@@ -0,0 +1,90 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * Dump a Drupal 7 database into a Drupal 7 PHP script to test the upgrade
+ * process.
+ *
+ * Run this script at the root of an existing Drupal 7 installation.
+ *
+ * The output of this script is a PHP script that can be run inside Drupal 7
+ * and recreates the Drupal 7 database as dumped. Transient data from cache,
+ * session, and watchdog tables are not recorded.
+ */
+
+// Define default settings.
+define('DRUPAL_ROOT', getcwd());
+$cmd = 'index.php';
+$_SERVER['HTTP_HOST'] = 'default';
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+$_SERVER['SERVER_SOFTWARE'] = NULL;
+$_SERVER['REQUEST_METHOD'] = 'GET';
+$_SERVER['QUERY_STRING'] = '';
+$_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = '/';
+$_SERVER['HTTP_USER_AGENT'] = 'console';
+
+// Bootstrap Drupal.
+include_once './includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+// Include the utility drupal_var_export() function.
+include_once dirname(__FILE__) . '/../includes/utility.inc';
+
+// Output the PHP header.
+$output = <<<ENDOFHEADER
+<?php
+
+/**
+ * @file
+ * Filled installation of Drupal 7.0, for test purposes.
+ *
+ * This file was generated by the dump-database-d7.sh tool, from an
+ * installation of Drupal 7, filled with data using the generate-d7-content.sh
+ * tool. It has the following modules installed:
+
+ENDOFHEADER;
+
+foreach (module_list() as $module) {
+ $output .= " * - $module\n";
+}
+$output .= " */\n\n";
+
+// Get the current schema, order it by table name.
+$schema = drupal_get_schema();
+ksort($schema);
+
+// Export all the tables in the schema.
+foreach ($schema as $table => $data) {
+ // Remove descriptions to save time and code.
+ unset($data['description']);
+ foreach ($data['fields'] as &$field) {
+ unset($field['description']);
+ }
+
+ // Dump the table structure.
+ $output .= "db_create_table('" . $table . "', " . drupal_var_export($data) . ");\n";
+
+ // Don't output values for those tables.
+ if (substr($table, 0, 5) == 'cache' || $table == 'sessions' || $table == 'watchdog') {
+ $output .= "\n";
+ continue;
+ }
+
+ // Prepare the export of values.
+ $result = db_query('SELECT * FROM {'. $table .'}', array(), array('fetch' => PDO::FETCH_ASSOC));
+ $insert = '';
+ foreach ($result as $record) {
+ $insert .= '->values('. drupal_var_export($record) .")\n";
+ }
+
+ // Dump the values if there are some.
+ if ($insert) {
+ $output .= "db_insert('". $table . "')->fields(". drupal_var_export(array_keys($data['fields'])) .")\n";
+ $output .= $insert;
+ $output .= "->execute();\n";
+ }
+
+ $output .= "\n";
+}
+
+print $output;
diff --git a/core/scripts/generate-d6-content.sh b/core/scripts/generate-d6-content.sh
new file mode 100644
index 000000000000..fc4c68f96c7d
--- /dev/null
+++ b/core/scripts/generate-d6-content.sh
@@ -0,0 +1,206 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * Generate content for a Drupal 6 database to test the upgrade process.
+ *
+ * Run this script at the root of an existing Drupal 6 installation.
+ * Steps to use this generation script:
+ * - Install drupal 6.
+ * - Run this script from your Drupal ROOT directory.
+ * - Use the dump-database-d6.sh to generate the D7 file
+ * modules/simpletest/tests/upgrade/database.filled.php
+ */
+
+// Define settings.
+$cmd = 'index.php';
+$_SERVER['HTTP_HOST'] = 'default';
+$_SERVER['PHP_SELF'] = '/index.php';
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+$_SERVER['SERVER_SOFTWARE'] = NULL;
+$_SERVER['REQUEST_METHOD'] = 'GET';
+$_SERVER['QUERY_STRING'] = '';
+$_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = '/';
+$_SERVER['HTTP_USER_AGENT'] = 'console';
+$modules_to_enable = array('path', 'poll');
+
+// Bootstrap Drupal.
+include_once './includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+// Enable requested modules
+include_once './modules/system/system.admin.inc';
+$form = system_modules();
+foreach ($modules_to_enable as $module) {
+ $form_state['values']['status'][$module] = TRUE;
+}
+$form_state['values']['disabled_modules'] = $form['disabled_modules'];
+system_modules_submit(NULL, $form_state);
+unset($form_state);
+
+// Run cron after installing
+drupal_cron_run();
+
+// Create six users
+for ($i = 0; $i < 6; $i++) {
+ $name = "test user $i";
+ $pass = md5("test PassW0rd $i !(.)");
+ $mail = "test$i@example.com";
+ $now = mktime(0, 0, 0, 1, $i + 1, 2010);
+ db_query("INSERT INTO {users} (name, pass, mail, status, created, access) VALUES ('%s', '%s', '%s', %d, %d, %d)", $name, $pass, $mail, 1, $now, $now);
+}
+
+
+// Create vocabularies and terms
+
+$terms = array();
+
+// All possible combinations of these vocabulary properties.
+$hierarchy = array(0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2);
+$multiple = array(0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1);
+$required = array(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1);
+
+$voc_id = 0;
+$term_id = 0;
+for ($i = 0; $i < 24; $i++) {
+ $vocabulary = array();
+ ++$voc_id;
+ $vocabulary['name'] = "vocabulary $voc_id (i=$i)";
+ $vocabulary['description'] = "description of ". $vocabulary['name'];
+ $vocabulary['nodes'] = $i > 11 ? array('page' => TRUE) : array();
+ $vocabulary['multiple'] = $multiple[$i % 12];
+ $vocabulary['required'] = $required[$i % 12];
+ $vocabulary['relations'] = 1;
+ $vocabulary['hierarchy'] = $hierarchy[$i % 12];
+ $vocabulary['weight'] = $i;
+ taxonomy_save_vocabulary($vocabulary);
+ $parents = array();
+ // Vocabularies without hierarchy get one term, single parent vocabularies get
+ // one parent and one child term. Multiple parent vocabularies get three
+ // terms: t0, t1, t2 where t0 is a parent of both t1 and t2.
+ for ($j = 0; $j < $vocabulary['hierarchy'] + 1; $j++) {
+ $term = array();
+ $term['vid'] = $vocabulary['vid'];
+ // For multiple parent vocabularies, omit the t0-t1 relation, otherwise
+ // every parent in the vocabulary is a parent.
+ $term['parent'] = $vocabulary['hierarchy'] == 2 && i == 1 ? array() : $parents;
+ ++$term_id;
+ $term['name'] = "term $term_id of vocabulary $voc_id (j=$j)";
+ $term['description'] = 'description of ' . $term['name'];
+ $term['weight'] = $i * 3 + $j;
+ taxonomy_save_term($term);
+ $terms[] = $term['tid'];
+ $parents[] = $term['tid'];
+ }
+}
+
+$node_id = 0;
+$revision_id = 0;
+module_load_include('inc', 'node', 'node.pages');
+for ($i = 0; $i < 24; $i++) {
+ $uid = intval($i / 8) + 3;
+ $user = user_load($uid);
+ $node = new stdClass();
+ $node->uid = $uid;
+ $node->type = $i < 12 ? 'page' : 'story';
+ $node->sticky = 0;
+ ++$node_id;
+ ++$revision_id;
+ $node->title = "node title $node_id rev $revision_id (i=$i)";
+ $type = node_get_types('type', $node->type);
+ if ($type->has_body) {
+ $node->body = str_repeat("node body ($node->type) - $i", 100);
+ $node->teaser = node_teaser($node->body);
+ $node->filter = variable_get('filter_default_format', 1);
+ $node->format = FILTER_FORMAT_DEFAULT;
+ }
+ $node->status = intval($i / 4) % 2;
+ $node->language = '';
+ $node->revision = $i < 12;
+ $node->promote = $i % 2;
+ $node->created = $now + $i * 86400;
+ $node->log = "added $i node";
+ // Make every term association different a little. For nodes with revisions,
+ // make the initial revision have a different set of terms than the
+ // newest revision.
+ $node_terms = $terms;
+ unset($node_terms[$i], $node_terms[47 - $i]);
+ if ($node->revision) {
+ $node->taxonomy = array($i => $terms[$i], 47-$i => $terms[47 - $i]);
+ }
+ else {
+ $node->taxonomy = $node_terms;
+ }
+ node_save($node);
+ path_set_alias("node/$node->nid", "content/$node->created");
+ if ($node->revision) {
+ $user = user_load($uid + 3);
+ ++$revision_id;
+ $node->title .= " rev2 $revision_id";
+ $node->body = str_repeat("node revision body ($node->type) - $i", 100);
+ $node->log = "added $i revision";
+ $node->taxonomy = $node_terms;
+ node_save($node);
+ }
+}
+
+// Create poll content
+for ($i = 0; $i < 12; $i++) {
+ $uid = intval($i / 4) + 3;
+ $user = user_load($uid);
+ $node = new stdClass();
+ $node->uid = $uid;
+ $node->type = 'poll';
+ $node->sticky = 0;
+ $node->title = "poll title $i";
+ $type = node_get_types('type', $node->type);
+ if ($type->has_body) {
+ $node->body = str_repeat("node body ($node->type) - $i", 100);
+ $node->teaser = node_teaser($node->body);
+ $node->filter = variable_get('filter_default_format', 1);
+ $node->format = FILTER_FORMAT_DEFAULT;
+ }
+ $node->status = intval($i / 2) % 2;
+ $node->language = '';
+ $node->revision = 1;
+ $node->promote = $i % 2;
+ $node->created = $now + $i * 43200;
+ $node->log = "added $i poll";
+
+ $nbchoices = ($i % 4) + 2;
+ for ($c = 0; $c < $nbchoices; $c++) {
+ $node->choice[] = array('chtext' => "Choice $c for poll $i");
+ }
+ node_save($node);
+ path_set_alias("node/$node->nid", "content/poll/$i");
+ path_set_alias("node/$node->nid/results", "content/poll/$i/results");
+
+ // Add some votes
+ for ($v = 0; $v < ($i % 4) + 5; $v++) {
+ $c = $v % $nbchoices;
+ $form_state = array();
+ $form_state['values']['choice'] = $c;
+ $form_state['values']['op'] = t('Vote');
+ drupal_execute('poll_view_voting', $form_state, $node);
+ }
+}
+
+$uid = 6;
+$user = user_load($uid);
+$node = new stdClass();
+$node->uid = $uid;
+$node->type = 'broken';
+$node->sticky = 0;
+$node->title = "node title 24";
+$node->body = str_repeat("node body ($node->type) - 37", 100);
+$node->teaser = node_teaser($node->body);
+$node->filter = variable_get('filter_default_format', 1);
+$node->format = FILTER_FORMAT_DEFAULT;
+$node->status = 1;
+$node->language = '';
+$node->revision = 0;
+$node->promote = 0;
+$node->created = 1263769200;
+$node->log = "added $i node";
+node_save($node);
+path_set_alias("node/$node->nid", "content/1263769200");
diff --git a/core/scripts/generate-d7-content.sh b/core/scripts/generate-d7-content.sh
new file mode 100644
index 000000000000..e65c09902a67
--- /dev/null
+++ b/core/scripts/generate-d7-content.sh
@@ -0,0 +1,308 @@
+#!/usr/bin/env php
+<?php
+
+/**
+ * Generate content for a Drupal 7 database to test the upgrade process.
+ *
+ * Run this script at the root of an existing Drupal 6 installation.
+ * Steps to use this generation script:
+ * - Install drupal 7.
+ * - Run this script from your Drupal ROOT directory.
+ * - Use the dump-database-d7.sh to generate the D7 file
+ * modules/simpletest/tests/upgrade/database.filled.php
+ */
+
+// Define settings.
+$cmd = 'index.php';
+define('DRUPAL_ROOT', getcwd());
+$_SERVER['HTTP_HOST'] = 'default';
+$_SERVER['PHP_SELF'] = '/index.php';
+$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+$_SERVER['SERVER_SOFTWARE'] = NULL;
+$_SERVER['REQUEST_METHOD'] = 'GET';
+$_SERVER['QUERY_STRING'] = '';
+$_SERVER['PHP_SELF'] = $_SERVER['REQUEST_URI'] = '/';
+$_SERVER['HTTP_USER_AGENT'] = 'console';
+$modules_to_enable = array('path', 'poll', 'taxonomy');
+
+// Bootstrap Drupal.
+include_once './includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+// Enable requested modules
+require_once DRUPAL_ROOT . '/' . variable_get('password_inc', 'includes/password.inc');
+include_once './modules/system/system.admin.inc';
+$form = system_modules();
+foreach ($modules_to_enable as $module) {
+ $form_state['values']['status'][$module] = TRUE;
+}
+$form_state['values']['disabled_modules'] = $form['disabled_modules'];
+system_modules_submit(NULL, $form_state);
+unset($form_state);
+
+// Run cron after installing
+drupal_cron_run();
+
+// Create six users
+$query = db_insert('users')->fields(array('uid', 'name', 'pass', 'mail', 'status', 'created', 'access'));
+for ($i = 0; $i < 6; $i++) {
+ $name = "test user $i";
+ $pass = md5("test PassW0rd $i !(.)");
+ $mail = "test$i@example.com";
+ $now = mktime(0, 0, 0, 1, $i + 1, 2010);
+ $query->values(array(db_next_id(), $name, user_hash_password($pass), $mail, 1, $now, $now));
+}
+$query->execute();
+
+// Create vocabularies and terms
+
+$terms = array();
+
+// All possible combinations of these vocabulary properties.
+$hierarchy = array(0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2);
+$multiple = array(0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1);
+$required = array(0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1);
+
+$voc_id = 0;
+$term_id = 0;
+for ($i = 0; $i < 24; $i++) {
+ $vocabulary = new stdClass;
+ ++$voc_id;
+ $vocabulary->name = "vocabulary $voc_id (i=$i)";
+ $vocabulary->machine_name = 'vocabulary_' . $voc_id . '_' . $i;
+ $vocabulary->description = "description of ". $vocabulary->name;
+ $vocabulary->multiple = $multiple[$i % 12];
+ $vocabulary->required = $required[$i % 12];
+ $vocabulary->relations = 1;
+ $vocabulary->hierarchy = $hierarchy[$i % 12];
+ $vocabulary->weight = $i;
+ taxonomy_vocabulary_save($vocabulary);
+ $field = array(
+ 'field_name' => 'taxonomy_'. $vocabulary->machine_name,
+ 'module' => 'taxonomy',
+ 'type' => 'taxonomy_term_reference',
+ 'cardinality' => $vocabulary->multiple || $vocabulary->tags ? FIELD_CARDINALITY_UNLIMITED : 1,
+ 'settings' => array(
+ 'required' => $vocabulary->required ? TRUE : FALSE,
+ 'allowed_values' => array(
+ array(
+ 'vocabulary' => $vocabulary->machine_name,
+ 'parent' => 0,
+ ),
+ ),
+ ),
+ );
+ field_create_field($field);
+ $node_types = $i > 11 ? array('page') : array_keys(node_type_get_types());
+ foreach ($node_types as $bundle) {
+ $instance = array(
+ 'label' => $vocabulary->name,
+ 'field_name' => $field['field_name'],
+ 'bundle' => $bundle,
+ 'entity_type' => 'node',
+ 'settings' => array(),
+ 'description' => $vocabulary->help,
+ 'required' => $vocabulary->required,
+ 'widget' => array(),
+ 'display' => array(
+ 'default' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ 'weight' => 10,
+ ),
+ 'teaser' => array(
+ 'type' => 'taxonomy_term_reference_link',
+ 'weight' => 10,
+ ),
+ ),
+ );
+ if ($vocabulary->tags) {
+ $instance['widget'] = array(
+ 'type' => 'taxonomy_autocomplete',
+ 'module' => 'taxonomy',
+ 'settings' => array(
+ 'size' => 60,
+ 'autocomplete_path' => 'taxonomy/autocomplete',
+ ),
+ );
+ }
+ else {
+ $instance['widget'] = array(
+ 'type' => 'select',
+ 'module' => 'options',
+ 'settings' => array(),
+ );
+ }
+ field_create_instance($instance);
+ }
+ $parents = array();
+ // Vocabularies without hierarchy get one term, single parent vocabularies get
+ // one parent and one child term. Multiple parent vocabularies get three
+ // terms: t0, t1, t2 where t0 is a parent of both t1 and t2.
+ for ($j = 0; $j < $vocabulary->hierarchy + 1; $j++) {
+ $term = new stdClass;
+ $term->vocabulary_machine_name = $vocabulary->machine_name;
+ // For multiple parent vocabularies, omit the t0-t1 relation, otherwise
+ // every parent in the vocabulary is a parent.
+ $term->parent = $vocabulary->hierarchy == 2 && i == 1 ? array() : $parents;
+ ++$term_id;
+ $term->name = "term $term_id of vocabulary $voc_id (j=$j)";
+ $term->description = 'description of ' . $term->name;
+ $term->format = 'filtered_html';
+ $term->weight = $i * 3 + $j;
+ taxonomy_term_save($term);
+ $terms[] = $term->tid;
+ $term_vocabs[$term->tid] = 'taxonomy_' . $vocabulary->machine_name;
+ $parents[] = $term->tid;
+ }
+}
+$node_id = 0;
+$revision_id = 0;
+module_load_include('inc', 'node', 'node.pages');
+for ($i = 0; $i < 24; $i++) {
+ $uid = intval($i / 8) + 3;
+ $user = user_load($uid);
+ $node = new stdClass();
+ $node->uid = $uid;
+ $node->type = $i < 12 ? 'page' : 'story';
+ $node->sticky = 0;
+ ++$node_id;
+ ++$revision_id;
+ $node->title = "node title $node_id rev $revision_id (i=$i)";
+ $node->language = LANGUAGE_NONE;
+ $body_text = str_repeat("node body ($node->type) - $i", 100);
+ $node->body[$node->language][0]['value'] = $body_text;
+ $node->body[$node->language][0]['summary'] = text_summary($body_text);
+ $node->body[$node->language][0]['format'] = 'filtered_html';
+ $node->status = intval($i / 4) % 2;
+ $node->revision = $i < 12;
+ $node->promote = $i % 2;
+ $node->created = $now + $i * 86400;
+ $node->log = "added $i node";
+ // Make every term association different a little. For nodes with revisions,
+ // make the initial revision have a different set of terms than the
+ // newest revision.
+ $items = array();
+ if ($node->revision) {
+ $node_terms = array($terms[$i], $terms[47-$i]);
+ }
+ else {
+ $node_terms = $terms;
+ unset($node_terms[$i], $node_terms[47 - $i]);
+ }
+ foreach ($node_terms as $tid) {
+ $field_name = $term_vocabs[$tid];
+ $node->{$field_name}[LANGUAGE_NONE][] = array('tid' => $tid);
+ }
+ $node->path = array('alias' => "content/$node->created");
+ node_save($node);
+ if ($node->revision) {
+ $user = user_load($uid + 3);
+ ++$revision_id;
+ $node->title .= " rev2 $revision_id";
+ $body_text = str_repeat("node revision body ($node->type) - $i", 100);
+ $node->body[$node->language][0]['value'] = $body_text;
+ $node->body[$node->language][0]['summary'] = text_summary($body_text);
+ $node->body[$node->language][0]['format'] = 'filtered_html';
+ $node->log = "added $i revision";
+ $node_terms = $terms;
+ unset($node_terms[$i], $node_terms[47 - $i]);
+ foreach ($node_terms as $tid) {
+ $field_name = $term_vocabs[$tid];
+ $node->{$field_name}[LANGUAGE_NONE][] = array('tid' => $tid);
+ }
+ node_save($node);
+ }
+}
+
+// Create poll content
+for ($i = 0; $i < 12; $i++) {
+ $uid = intval($i / 4) + 3;
+ $user = user_load($uid);
+ $node = new stdClass();
+ $node->uid = $uid;
+ $node->type = 'poll';
+ $node->sticky = 0;
+ $node->title = "poll title $i";
+ $node->language = LANGUAGE_NONE;
+ $node->status = intval($i / 2) % 2;
+ $node->revision = 1;
+ $node->promote = $i % 2;
+ $node->created = REQUEST_TIME + $i * 43200;
+ $node->runtime = 0;
+ $node->active = 1;
+ $node->log = "added $i poll";
+ $node->path = array('alias' => "content/poll/$i");
+
+ $nbchoices = ($i % 4) + 2;
+ for ($c = 0; $c < $nbchoices; $c++) {
+ $node->choice[] = array('chtext' => "Choice $c for poll $i", 'chvotes' => 0, 'weight' => 0);
+ }
+ node_save($node);
+ $path = array(
+ 'alias' => "content/poll/$i/results",
+ 'source' => "node/$node->nid/results",
+ );
+ path_save($path);
+
+ // Add some votes
+ $node = node_load($node->nid);
+ $choices = array_keys($node->choice);
+ $original_user = $GLOBALS['user'];
+ for ($v = 0; $v < ($i % 4); $v++) {
+ drupal_static_reset('ip_address');
+ $_SERVER['REMOTE_ADDR'] = "127.0.$v.1";
+ $GLOBALS['user'] = drupal_anonymous_user();// We should have already allowed anon to vote.
+ $c = $v % $nbchoices;
+ $form_state = array();
+ $form_state['values']['choice'] = $choices[$c];
+ $form_state['values']['op'] = t('Vote');
+ drupal_form_submit('poll_view_voting', $form_state, $node);
+ }
+}
+
+$uid = 6;
+$node_type = 'broken';
+$user = user_load($uid);
+$node = new stdClass();
+$node->uid = $uid;
+$node->type = 'article';
+$body_text = str_repeat("node body ($node_type) - 37", 100);
+$node->sticky = 0;
+$node->title = "node title 24";
+$node->language = LANGUAGE_NONE;
+$node->body[$node->language][0]['value'] = $body_text;
+$node->body[$node->language][0]['summary'] = text_summary($body_text);
+$node->body[$node->language][0]['format'] = 'filtered_html';
+$node->status = 1;
+$node->revision = 0;
+$node->promote = 0;
+$node->created = 1263769200;
+$node->log = "added a broken node";
+$node->path = array('alias' => "content/1263769200");
+node_save($node);
+db_update('node')
+ ->fields(array(
+ 'type' => $node_type,
+ ))
+ ->condition('nid', $node->nid)
+ ->execute();
+db_update('field_data_body')
+ ->fields(array(
+ 'bundle' => $node_type,
+ ))
+ ->condition('entity_id', $node->nid)
+ ->condition('entity_type', 'node')
+ ->execute();
+db_update('field_revision_body')
+ ->fields(array(
+ 'bundle' => $node_type,
+ ))
+ ->condition('entity_id', $node->nid)
+ ->condition('entity_type', 'node')
+ ->execute();
+db_update('field_config_instance')
+ ->fields(array(
+ 'bundle' => $node_type,
+ ))
+ ->condition('bundle', 'article')
+ ->execute();
diff --git a/core/scripts/password-hash.sh b/core/scripts/password-hash.sh
new file mode 100755
index 000000000000..66fcb2655e87
--- /dev/null
+++ b/core/scripts/password-hash.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/php
+<?php
+
+/**
+ * Drupal hash script - to generate a hash from a plaintext password
+ *
+ * Check for your PHP interpreter - on Windows you'll probably have to
+ * replace line 1 with
+ * #!c:/program files/php/php.exe
+ *
+ * @param password1 [password2 [password3 ...]]
+ * Plain-text passwords in quotes (or with spaces backslash escaped).
+ */
+
+if (version_compare(PHP_VERSION, "5.2.0", "<")) {
+ $version = PHP_VERSION;
+ echo <<<EOF
+
+ERROR: This script requires at least PHP version 5.2.0. You invoked it with
+ PHP version {$version}.
+\n
+EOF;
+ exit;
+}
+
+$script = basename(array_shift($_SERVER['argv']));
+
+if (in_array('--help', $_SERVER['argv']) || empty($_SERVER['argv'])) {
+ echo <<<EOF
+
+Generate Drupal password hashes from the shell.
+
+Usage: {$script} [OPTIONS] "<plan-text password>"
+Example: {$script} "mynewpassword"
+
+All arguments are long options.
+
+ --help Print this page.
+
+ --root <path>
+
+ Set the working directory for the script to the specified path.
+ To execute this script this has to be the root directory of your
+ Drupal installation, e.g. /home/www/foo/drupal (assuming Drupal
+ running on Unix). Use surrounding quotation marks on Windows.
+
+ "<password1>" ["<password2>" ["<password3>" ...]]
+
+ One or more plan-text passwords enclosed by double quotes. The
+ output hash may be manually entered into the {users}.pass field to
+ change a password via SQL to a known value.
+
+To run this script without the --root argument invoke it from the root directory
+of your Drupal installation as
+
+ ./scripts/{$script}
+\n
+EOF;
+ exit;
+}
+
+$passwords = array();
+
+// Parse invocation arguments.
+while ($param = array_shift($_SERVER['argv'])) {
+ switch ($param) {
+ case '--root':
+ // Change the working directory.
+ $path = array_shift($_SERVER['argv']);
+ if (is_dir($path)) {
+ chdir($path);
+ }
+ break;
+ default:
+ // Add a password to the list to be processed.
+ $passwords[] = $param;
+ break;
+ }
+}
+
+chdir('../..');
+define('DRUPAL_ROOT', getcwd());
+
+include_once DRUPAL_ROOT . '/core/includes/password.inc';
+include_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+
+foreach ($passwords as $password) {
+ print("\npassword: $password \t\thash: ". user_hash_password($password) ."\n");
+}
+print("\n");
+
diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh
new file mode 100755
index 000000000000..f74766041e0c
--- /dev/null
+++ b/core/scripts/run-tests.sh
@@ -0,0 +1,672 @@
+<?php
+/**
+ * @file
+ * This script runs Drupal tests from command line.
+ */
+
+define('SIMPLETEST_SCRIPT_COLOR_PASS', 32); // Green.
+define('SIMPLETEST_SCRIPT_COLOR_FAIL', 31); // Red.
+define('SIMPLETEST_SCRIPT_COLOR_EXCEPTION', 33); // Brown.
+
+// Set defaults and get overrides.
+list($args, $count) = simpletest_script_parse_args();
+
+if ($args['help'] || $count == 0) {
+ simpletest_script_help();
+ exit;
+}
+
+if ($args['execute-test']) {
+ // Masquerade as Apache for running tests.
+ simpletest_script_init("Apache");
+ simpletest_script_run_one_test($args['test-id'], $args['execute-test']);
+}
+else {
+ // Run administrative functions as CLI.
+ simpletest_script_init(NULL);
+}
+
+// Bootstrap to perform initial validation or other operations.
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+if (!module_exists('simpletest')) {
+ simpletest_script_print_error("The simpletest module must be enabled before this script can run.");
+ exit;
+}
+
+if ($args['clean']) {
+ // Clean up left-over times and directories.
+ simpletest_clean_environment();
+ echo "\nEnvironment cleaned.\n";
+
+ // Get the status messages and print them.
+ $messages = array_pop(drupal_get_messages('status'));
+ foreach ($messages as $text) {
+ echo " - " . $text . "\n";
+ }
+ exit;
+}
+
+// Load SimpleTest files.
+$groups = simpletest_test_get_all();
+$all_tests = array();
+foreach ($groups as $group => $tests) {
+ $all_tests = array_merge($all_tests, array_keys($tests));
+}
+$test_list = array();
+
+if ($args['list']) {
+ // Display all available tests.
+ echo "\nAvailable test groups & classes\n";
+ echo "-------------------------------\n\n";
+ foreach ($groups as $group => $tests) {
+ echo $group . "\n";
+ foreach ($tests as $class => $info) {
+ echo " - " . $info['name'] . ' (' . $class . ')' . "\n";
+ }
+ }
+ exit;
+}
+
+$test_list = simpletest_script_get_test_list();
+
+// Try to allocate unlimited time to run the tests.
+drupal_set_time_limit(0);
+
+simpletest_script_reporter_init();
+
+// Setup database for test results.
+$test_id = db_insert('simpletest_test_id')->useDefaults(array('test_id'))->execute();
+
+// Execute tests.
+simpletest_script_execute_batch($test_id, simpletest_script_get_test_list());
+
+// Retrieve the last database prefix used for testing and the last test class
+// that was run from. Use the information to read the lgo file in case any
+// fatal errors caused the test to crash.
+list($last_prefix, $last_test_class) = simpletest_last_test_get($test_id);
+simpletest_log_read($test_id, $last_prefix, $last_test_class);
+
+// Stop the timer.
+simpletest_script_reporter_timer_stop();
+
+// Display results before database is cleared.
+simpletest_script_reporter_display_results();
+
+if ($args['xml']) {
+ simpletest_script_reporter_write_xml_results();
+}
+
+// Cleanup our test results.
+simpletest_clean_results_table($test_id);
+
+// Test complete, exit.
+exit;
+
+/**
+ * Print help text.
+ */
+function simpletest_script_help() {
+ global $args;
+
+ echo <<<EOF
+
+Run Drupal tests from the shell.
+
+Usage: {$args['script']} [OPTIONS] <tests>
+Example: {$args['script']} Profile
+
+All arguments are long options.
+
+ --help Print this page.
+
+ --list Display all available test groups.
+
+ --clean Cleans up database tables or directories from previous, failed,
+ tests and then exits (no tests are run).
+
+ --url Immediately precedes a URL to set the host and path. You will
+ need this parameter if Drupal is in a subdirectory on your
+ localhost and you have not set \$base_url in settings.php. Tests
+ can be run under SSL by including https:// in the URL.
+
+ --php The absolute path to the PHP executable. Usually not needed.
+
+ --concurrency [num]
+
+ Run tests in parallel, up to [num] tests at a time.
+
+ --all Run all available tests.
+
+ --class Run tests identified by specific class names, instead of group names.
+
+ --file Run tests identified by specific file names, instead of group names.
+ Specify the path and the extension
+ (i.e. 'core/modules/user/user.test').
+
+ --xml <path>
+
+ If provided, test results will be written as xml files to this path.
+
+ --color Output text format results with color highlighting.
+
+ --verbose Output detailed assertion messages in addition to summary.
+
+ <test1>[,<test2>[,<test3> ...]]
+
+ One or more tests to be run. By default, these are interpreted
+ as the names of test groups as shown at
+ ?q=admin/config/development/testing.
+ These group names typically correspond to module names like "User"
+ or "Profile" or "System", but there is also a group "XML-RPC".
+ If --class is specified then these are interpreted as the names of
+ specific test classes whose test methods will be run. Tests must
+ be separated by commas. Ignored if --all is specified.
+
+To run this script you will normally invoke it from the root directory of your
+Drupal installation as the webserver user (differs per configuration), or root:
+
+sudo -u [wwwrun|www-data|etc] php ./core/scripts/{$args['script']}
+ --url http://example.com/ --all
+sudo -u [wwwrun|www-data|etc] php ./core/scripts/{$args['script']}
+ --url http://example.com/ --class BlockTestCase
+\n
+EOF;
+}
+
+/**
+ * Parse execution argument and ensure that all are valid.
+ *
+ * @return The list of arguments.
+ */
+function simpletest_script_parse_args() {
+ // Set default values.
+ $args = array(
+ 'script' => '',
+ 'help' => FALSE,
+ 'list' => FALSE,
+ 'clean' => FALSE,
+ 'url' => '',
+ 'php' => '',
+ 'concurrency' => 1,
+ 'all' => FALSE,
+ 'class' => FALSE,
+ 'file' => FALSE,
+ 'color' => FALSE,
+ 'verbose' => FALSE,
+ 'test_names' => array(),
+ // Used internally.
+ 'test-id' => 0,
+ 'execute-test' => '',
+ 'xml' => '',
+ );
+
+ // Override with set values.
+ $args['script'] = basename(array_shift($_SERVER['argv']));
+
+ $count = 0;
+ while ($arg = array_shift($_SERVER['argv'])) {
+ if (preg_match('/--(\S+)/', $arg, $matches)) {
+ // Argument found.
+ if (array_key_exists($matches[1], $args)) {
+ // Argument found in list.
+ $previous_arg = $matches[1];
+ if (is_bool($args[$previous_arg])) {
+ $args[$matches[1]] = TRUE;
+ }
+ else {
+ $args[$matches[1]] = array_shift($_SERVER['argv']);
+ }
+ // Clear extraneous values.
+ $args['test_names'] = array();
+ $count++;
+ }
+ else {
+ // Argument not found in list.
+ simpletest_script_print_error("Unknown argument '$arg'.");
+ exit;
+ }
+ }
+ else {
+ // Values found without an argument should be test names.
+ $args['test_names'] += explode(',', $arg);
+ $count++;
+ }
+ }
+
+ // Validate the concurrency argument
+ if (!is_numeric($args['concurrency']) || $args['concurrency'] <= 0) {
+ simpletest_script_print_error("--concurrency must be a strictly positive integer.");
+ exit;
+ }
+
+ return array($args, $count);
+}
+
+/**
+ * Initialize script variables and perform general setup requirements.
+ */
+function simpletest_script_init($server_software) {
+ global $args, $php;
+
+ $host = 'localhost';
+ $path = '';
+ // Determine location of php command automatically, unless a command line argument is supplied.
+ if (!empty($args['php'])) {
+ $php = $args['php'];
+ }
+ elseif (!empty($_ENV['_'])) {
+ // '_' is an environment variable set by the shell. It contains the command that was executed.
+ $php = $_ENV['_'];
+ }
+ elseif (!empty($_ENV['SUDO_COMMAND'])) {
+ // 'SUDO_COMMAND' is an environment variable set by the sudo program.
+ // Extract only the PHP interpreter, not the rest of the command.
+ list($php, ) = explode(' ', $_ENV['SUDO_COMMAND'], 2);
+ }
+ else {
+ simpletest_script_print_error('Unable to automatically determine the path to the PHP interpreter. Supply the --php command line argument.');
+ simpletest_script_help();
+ exit();
+ }
+
+ // Get url from arguments.
+ if (!empty($args['url'])) {
+ $parsed_url = parse_url($args['url']);
+ $host = $parsed_url['host'] . (isset($parsed_url['port']) ? ':' . $parsed_url['port'] : '');
+ $path = isset($parsed_url['path']) ? $parsed_url['path'] : '';
+
+ // If the passed URL schema is 'https' then setup the $_SERVER variables
+ // properly so that testing will run under https.
+ if ($parsed_url['scheme'] == 'https') {
+ $_SERVER['HTTPS'] = 'on';
+ }
+ }
+
+ $_SERVER['HTTP_HOST'] = $host;
+ $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
+ $_SERVER['SERVER_ADDR'] = '127.0.0.1';
+ $_SERVER['SERVER_SOFTWARE'] = $server_software;
+ $_SERVER['SERVER_NAME'] = 'localhost';
+ $_SERVER['REQUEST_URI'] = $path .'/';
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+ $_SERVER['SCRIPT_NAME'] = $path .'/index.php';
+ $_SERVER['PHP_SELF'] = $path .'/index.php';
+ $_SERVER['HTTP_USER_AGENT'] = 'Drupal command line';
+
+ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') {
+ // Ensure that any and all environment variables are changed to https://.
+ foreach ($_SERVER as $key => $value) {
+ $_SERVER[$key] = str_replace('http://', 'https://', $_SERVER[$key]);
+ }
+ }
+
+ chdir(realpath(__DIR__ . '/../..'));
+ define('DRUPAL_ROOT', getcwd());
+ require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+}
+
+/**
+ * Execute a batch of tests.
+ */
+function simpletest_script_execute_batch($test_id, $test_classes) {
+ global $args;
+
+ // Multi-process execution.
+ $children = array();
+ while (!empty($test_classes) || !empty($children)) {
+ while (count($children) < $args['concurrency']) {
+ if (empty($test_classes)) {
+ break;
+ }
+
+ // Fork a child process.
+ $test_class = array_shift($test_classes);
+ $command = simpletest_script_command($test_id, $test_class);
+ $process = proc_open($command, array(), $pipes, NULL, NULL, array('bypass_shell' => TRUE));
+
+ if (!is_resource($process)) {
+ echo "Unable to fork test process. Aborting.\n";
+ exit;
+ }
+
+ // Register our new child.
+ $children[] = array(
+ 'process' => $process,
+ 'class' => $test_class,
+ 'pipes' => $pipes,
+ );
+ }
+
+ // Wait for children every 200ms.
+ usleep(200000);
+
+ // Check if some children finished.
+ foreach ($children as $cid => $child) {
+ $status = proc_get_status($child['process']);
+ if (empty($status['running'])) {
+ // The child exited, unregister it.
+ proc_close($child['process']);
+ if ($status['exitcode']) {
+ echo 'FATAL ' . $test_class . ': test runner returned a non-zero error code (' . $status['exitcode'] . ').' . "\n";
+ }
+ unset($children[$cid]);
+ }
+ }
+ }
+}
+
+/**
+ * Bootstrap Drupal and run a single test.
+ */
+function simpletest_script_run_one_test($test_id, $test_class) {
+ try {
+ // Bootstrap Drupal.
+ drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+
+ $test = new $test_class($test_id);
+ $test->run();
+ $info = $test->getInfo();
+
+ $had_fails = (isset($test->results['#fail']) && $test->results['#fail'] > 0);
+ $had_exceptions = (isset($test->results['#exception']) && $test->results['#exception'] > 0);
+ $status = ($had_fails || $had_exceptions ? 'fail' : 'pass');
+ simpletest_script_print($info['name'] . ' ' . _simpletest_format_summary_line($test->results) . "\n", simpletest_script_color_code($status));
+
+ // Finished, kill this runner.
+ exit(0);
+ }
+ catch (Exception $e) {
+ echo (string) $e;
+ exit(1);
+ }
+}
+
+/**
+ * Return a command used to run a test in a separate process.
+ *
+ * @param $test_id
+ * The current test ID.
+ * @param $test_class
+ * The name of the test class to run.
+ */
+function simpletest_script_command($test_id, $test_class) {
+ global $args, $php;
+
+ $command = escapeshellarg($php) . ' ' . escapeshellarg('./core/scripts/' . $args['script']) . ' --url ' . escapeshellarg($args['url']);
+ if ($args['color']) {
+ $command .= ' --color';
+ }
+ $command .= " --php " . escapeshellarg($php) . " --test-id $test_id --execute-test $test_class";
+ return $command;
+}
+
+/**
+ * Get list of tests based on arguments. If --all specified then
+ * returns all available tests, otherwise reads list of tests.
+ *
+ * Will print error and exit if no valid tests were found.
+ *
+ * @return List of tests.
+ */
+function simpletest_script_get_test_list() {
+ global $args, $all_tests, $groups;
+
+ $test_list = array();
+ if ($args['all']) {
+ $test_list = $all_tests;
+ }
+ else {
+ if ($args['class']) {
+ // Check for valid class names.
+ foreach ($args['test_names'] as $class_name) {
+ if (in_array($class_name, $all_tests)) {
+ $test_list[] = $class_name;
+ }
+ }
+ }
+ elseif ($args['file']) {
+ $files = array();
+ foreach ($args['test_names'] as $file) {
+ $files[drupal_realpath($file)] = 1;
+ }
+
+ // Check for valid class names.
+ foreach ($all_tests as $class_name) {
+ $refclass = new ReflectionClass($class_name);
+ $file = $refclass->getFileName();
+ if (isset($files[$file])) {
+ $test_list[] = $class_name;
+ }
+ }
+ }
+ else {
+ // Check for valid group names and get all valid classes in group.
+ foreach ($args['test_names'] as $group_name) {
+ if (isset($groups[$group_name])) {
+ foreach ($groups[$group_name] as $class_name => $info) {
+ $test_list[] = $class_name;
+ }
+ }
+ }
+ }
+ }
+
+ if (empty($test_list)) {
+ simpletest_script_print_error('No valid tests were specified.');
+ exit;
+ }
+ return $test_list;
+}
+
+/**
+ * Initialize the reporter.
+ */
+function simpletest_script_reporter_init() {
+ global $args, $all_tests, $test_list, $results_map;
+
+ $results_map = array(
+ 'pass' => 'Pass',
+ 'fail' => 'Fail',
+ 'exception' => 'Exception'
+ );
+
+ echo "\n";
+ echo "Drupal test run\n";
+ echo "---------------\n";
+ echo "\n";
+
+ // Tell the user about what tests are to be run.
+ if ($args['all']) {
+ echo "All tests will run.\n\n";
+ }
+ else {
+ echo "Tests to be run:\n";
+ foreach ($test_list as $class_name) {
+ $info = call_user_func(array($class_name, 'getInfo'));
+ echo " - " . $info['name'] . ' (' . $class_name . ')' . "\n";
+ }
+ echo "\n";
+ }
+
+ echo "Test run started:\n";
+ echo " " . format_date($_SERVER['REQUEST_TIME'], 'long') . "\n";
+ timer_start('run-tests');
+ echo "\n";
+
+ echo "Test summary\n";
+ echo "------------\n";
+ echo "\n";
+}
+
+/**
+ * Display jUnit XML test results.
+ */
+function simpletest_script_reporter_write_xml_results() {
+ global $args, $test_id, $results_map;
+
+ $results = db_query("SELECT * FROM {simpletest} WHERE test_id = :test_id ORDER BY test_class, message_id", array(':test_id' => $test_id));
+
+ $test_class = '';
+ $xml_files = array();
+
+ foreach ($results as $result) {
+ if (isset($results_map[$result->status])) {
+ if ($result->test_class != $test_class) {
+ // We've moved onto a new class, so write the last classes results to a file:
+ if (isset($xml_files[$test_class])) {
+ file_put_contents($args['xml'] . '/' . $test_class . '.xml', $xml_files[$test_class]['doc']->saveXML());
+ unset($xml_files[$test_class]);
+ }
+ $test_class = $result->test_class;
+ if (!isset($xml_files[$test_class])) {
+ $doc = new DomDocument('1.0');
+ $root = $doc->createElement('testsuite');
+ $root = $doc->appendChild($root);
+ $xml_files[$test_class] = array('doc' => $doc, 'suite' => $root);
+ }
+ }
+
+ // For convenience:
+ $dom_document = &$xml_files[$test_class]['doc'];
+
+ // Create the XML element for this test case:
+ $case = $dom_document->createElement('testcase');
+ $case->setAttribute('classname', $test_class);
+ list($class, $name) = explode('->', $result->function, 2);
+ $case->setAttribute('name', $name);
+
+ // Passes get no further attention, but failures and exceptions get to add more detail:
+ if ($result->status == 'fail') {
+ $fail = $dom_document->createElement('failure');
+ $fail->setAttribute('type', 'failure');
+ $fail->setAttribute('message', $result->message_group);
+ $text = $dom_document->createTextNode($result->message);
+ $fail->appendChild($text);
+ $case->appendChild($fail);
+ }
+ elseif ($result->status == 'exception') {
+ // In the case of an exception the $result->function may not be a class
+ // method so we record the full function name:
+ $case->setAttribute('name', $result->function);
+
+ $fail = $dom_document->createElement('error');
+ $fail->setAttribute('type', 'exception');
+ $fail->setAttribute('message', $result->message_group);
+ $full_message = $result->message . "\n\nline: " . $result->line . "\nfile: " . $result->file;
+ $text = $dom_document->createTextNode($full_message);
+ $fail->appendChild($text);
+ $case->appendChild($fail);
+ }
+ // Append the test case XML to the test suite:
+ $xml_files[$test_class]['suite']->appendChild($case);
+ }
+ }
+ // The last test case hasn't been saved to a file yet, so do that now:
+ if (isset($xml_files[$test_class])) {
+ file_put_contents($args['xml'] . '/' . $test_class . '.xml', $xml_files[$test_class]['doc']->saveXML());
+ unset($xml_files[$test_class]);
+ }
+}
+
+/**
+ * Stop the test timer.
+ */
+function simpletest_script_reporter_timer_stop() {
+ echo "\n";
+ $end = timer_stop('run-tests');
+ echo "Test run duration: " . format_interval($end['time'] / 1000);
+ echo "\n\n";
+}
+
+/**
+ * Display test results.
+ */
+function simpletest_script_reporter_display_results() {
+ global $args, $test_id, $results_map;
+
+ if ($args['verbose']) {
+ // Report results.
+ echo "Detailed test results:\n";
+ echo "----------------------\n";
+ echo "\n";
+
+ $results = db_query("SELECT * FROM {simpletest} WHERE test_id = :test_id ORDER BY test_class, message_id", array(':test_id' => $test_id));
+ $test_class = '';
+ foreach ($results as $result) {
+ if (isset($results_map[$result->status])) {
+ if ($result->test_class != $test_class) {
+ // Display test class every time results are for new test class.
+ echo "\n\n---- $result->test_class ----\n\n\n";
+ $test_class = $result->test_class;
+ }
+
+ simpletest_script_format_result($result);
+ }
+ }
+ }
+}
+
+/**
+ * Format the result so that it fits within the default 80 character
+ * terminal size.
+ *
+ * @param $result The result object to format.
+ */
+function simpletest_script_format_result($result) {
+ global $results_map, $color;
+
+ $summary = sprintf("%-10.10s %-10.10s %-30.30s %-5.5s %-20.20s\n",
+ $results_map[$result->status], $result->message_group, basename($result->file), $result->line, $result->caller);
+
+ simpletest_script_print($summary, simpletest_script_color_code($result->status));
+
+ $lines = explode("\n", wordwrap(trim(strip_tags($result->message)), 76));
+ foreach ($lines as $line) {
+ echo " $line\n";
+ }
+}
+
+/**
+ * Print error message prefixed with " ERROR: " and displayed in fail color
+ * if color output is enabled.
+ *
+ * @param $message The message to print.
+ */
+function simpletest_script_print_error($message) {
+ simpletest_script_print(" ERROR: $message\n", SIMPLETEST_SCRIPT_COLOR_FAIL);
+}
+
+/**
+ * Print a message to the console, if color is enabled then the specified
+ * color code will be used.
+ *
+ * @param $message The message to print.
+ * @param $color_code The color code to use for coloring.
+ */
+function simpletest_script_print($message, $color_code) {
+ global $args;
+ if ($args['color']) {
+ echo "\033[" . $color_code . "m" . $message . "\033[0m";
+ }
+ else {
+ echo $message;
+ }
+}
+
+/**
+ * Get the color code associated with the specified status.
+ *
+ * @param $status The status string to get code for.
+ * @return Color code.
+ */
+function simpletest_script_color_code($status) {
+ switch ($status) {
+ case 'pass':
+ return SIMPLETEST_SCRIPT_COLOR_PASS;
+ case 'fail':
+ return SIMPLETEST_SCRIPT_COLOR_FAIL;
+ case 'exception':
+ return SIMPLETEST_SCRIPT_COLOR_EXCEPTION;
+ }
+ return 0; // Default formatting.
+}
diff --git a/core/themes/README.txt b/core/themes/README.txt
new file mode 100644
index 000000000000..3fb27ed10d64
--- /dev/null
+++ b/core/themes/README.txt
@@ -0,0 +1,9 @@
+
+This directory is reserved for core theme files. Custom or contributed themes
+should be placed in their own subdirectory of the sites/all/themes directory.
+For multisite installations, they can also be placed in a subdirectory under
+/sites/{sitename}/themes/, where {sitename} is the name of your site (e.g.,
+www.example.com). This will allow you to more easily update Drupal core files.
+
+For more details, see: http://drupal.org/node/176043
+
diff --git a/core/themes/bartik/bartik.info b/core/themes/bartik/bartik.info
new file mode 100644
index 000000000000..aab37a7ac855
--- /dev/null
+++ b/core/themes/bartik/bartik.info
@@ -0,0 +1,35 @@
+
+name = Bartik
+description = A flexible, recolorable theme with many regions.
+package = Core
+version = VERSION
+core = 8.x
+
+stylesheets[all][] = css/layout.css
+stylesheets[all][] = css/style.css
+stylesheets[all][] = css/colors.css
+stylesheets[print][] = css/print.css
+
+regions[header] = Header
+regions[help] = Help
+regions[page_top] = Page top
+regions[page_bottom] = Page bottom
+regions[highlighted] = Highlighted
+
+regions[featured] = Featured
+regions[content] = Content
+regions[sidebar_first] = Sidebar first
+regions[sidebar_second] = Sidebar second
+
+regions[triptych_first] = Triptych first
+regions[triptych_middle] = Triptych middle
+regions[triptych_last] = Triptych last
+
+regions[footer_firstcolumn] = Footer first column
+regions[footer_secondcolumn] = Footer second column
+regions[footer_thirdcolumn] = Footer third column
+regions[footer_fourthcolumn] = Footer fourth column
+regions[footer] = Footer
+
+settings[shortcut_module_link] = 0
+
diff --git a/core/themes/bartik/color/base.png b/core/themes/bartik/color/base.png
new file mode 100644
index 000000000000..58cc088a49e9
--- /dev/null
+++ b/core/themes/bartik/color/base.png
Binary files differ
diff --git a/core/themes/bartik/color/color.inc b/core/themes/bartik/color/color.inc
new file mode 100644
index 000000000000..b4c813da0bd6
--- /dev/null
+++ b/core/themes/bartik/color/color.inc
@@ -0,0 +1,132 @@
+<?php
+
+// Put the logo path into JavaScript for the live preview.
+drupal_add_js(array('color' => array('logo' => theme_get_setting('logo', 'bartik'))), 'setting');
+
+$info = array(
+ // Available colors and color labels used in theme.
+ 'fields' => array(
+ 'top' => t('Header background top'),
+ 'bottom' => t('Header background bottom'),
+ 'bg' => t('Main background'),
+ 'sidebar' => t('Sidebar background'),
+ 'sidebarborders' => t('Sidebar borders'),
+ 'footer' => t('Footer background'),
+ 'titleslogan' => t('Title and slogan'),
+ 'text' => t('Text color'),
+ 'link' => t('Link color'),
+ ),
+ // Pre-defined color schemes.
+ 'schemes' => array(
+ 'default' => array(
+ 'title' => t('Blue Lagoon (default)'),
+ 'colors' => array(
+ 'top' => '#0779bf',
+ 'bottom' => '#48a9e4',
+ 'bg' => '#ffffff',
+ 'sidebar' => '#f6f6f2',
+ 'sidebarborders' => '#f9f9f9',
+ 'footer' => '#292929',
+ 'titleslogan' => '#fffeff',
+ 'text' => '#3b3b3b',
+ 'link' => '#0071B3',
+ ),
+ ),
+ 'firehouse' => array(
+ 'title' => t('Firehouse'),
+ 'colors' => array(
+ 'top' => '#cd2d2d',
+ 'bottom' => '#cf3535',
+ 'bg' => '#ffffff',
+ 'sidebar' => '#f1f4f0',
+ 'sidebarborders' => '#ededed',
+ 'footer' => '#1f1d1c',
+ 'titleslogan' => '#fffeff',
+ 'text' => '#3b3b3b',
+ 'link' => '#d6121f',
+ ),
+ ),
+ 'ice' => array(
+ 'title' => t('Ice'),
+ 'colors' => array(
+ 'top' => '#d0d0d0',
+ 'bottom' => '#c2c4c5',
+ 'bg' => '#ffffff',
+ 'sidebar' => '#ffffff',
+ 'sidebarborders' => '#cccccc',
+ 'footer' => '#24272c',
+ 'titleslogan' => '#000000',
+ 'text' => '#4a4a4a',
+ 'link' => '#019dbf',
+ ),
+ ),
+ 'plum' => array(
+ 'title' => t('Plum'),
+ 'colors' => array(
+ 'top' => '#4c1c58',
+ 'bottom' => '#593662',
+ 'bg' => '#fffdf7',
+ 'sidebar' => '#edede7',
+ 'sidebarborders' => '#e7e7e7',
+ 'footer' => '#2c2c28',
+ 'titleslogan' => '#ffffff',
+ 'text' => '#301313',
+ 'link' => '#9d408d',
+ ),
+ ),
+ 'slate' => array(
+ 'title' => t('Slate'),
+ 'colors' => array(
+ 'top' => '#4a4a4a',
+ 'bottom' => '#4e4e4e',
+ 'bg' => '#ffffff',
+ 'sidebar' => '#ffffff',
+ 'sidebarborders' => '#d0d0d0',
+ 'footer' => '#161617',
+ 'titleslogan' => '#ffffff',
+ 'text' => '#3b3b3b',
+ 'link' => '#0073b6',
+ ),
+ ),
+ ),
+
+ // CSS files (excluding @import) to rewrite with new color scheme.
+ 'css' => array(
+ 'css/colors.css',
+ ),
+
+ // Files to copy.
+ 'copy' => array(
+ 'logo.png',
+ ),
+
+ // Gradient definitions.
+ 'gradients' => array(
+ array(
+ // (x, y, width, height).
+ 'dimension' => array(0, 0, 0, 0),
+ // Direction of gradient ('vertical' or 'horizontal').
+ 'direction' => 'vertical',
+ // Keys of colors to use for the gradient.
+ 'colors' => array('top', 'bottom'),
+ ),
+ ),
+
+ // Color areas to fill (x, y, width, height).
+ 'fill' => array(),
+
+ // Coordinates of all the theme slices (x, y, width, height)
+ // with their filename as used in the stylesheet.
+ 'slices' => array(),
+
+ // Reference color used for blending. Matches the base.png's colors.
+ 'blend_target' => '#ffffff',
+
+ // Preview files.
+ 'preview_css' => 'color/preview.css',
+ 'preview_js' => 'color/preview.js',
+ 'preview_html' => 'color/preview.html',
+
+ // Base file for image generation.
+ 'base_image' => 'color/base.png',
+);
diff --git a/core/themes/bartik/color/preview.css b/core/themes/bartik/color/preview.css
new file mode 100644
index 000000000000..48a4a83346be
--- /dev/null
+++ b/core/themes/bartik/color/preview.css
@@ -0,0 +1,200 @@
+
+/* ---------- Color form ----------- */
+#color_scheme_form #palette .form-item {
+ width: 25em;
+}
+#color_scheme_form #palette .form-item label {
+ width: 15em;
+}
+
+/* ---------- Preview Styles ----------- */
+
+html.js #preview {
+ clear: both;
+ float: none !important;
+}
+#preview {
+ background-color: #fff;
+ font-family: Georgia, "Times New Roman", Times, serif;
+ font-size: 14px;
+ line-height: 1.5;
+ overflow: hidden;
+ word-wrap: break-word;
+ margin-bottom: 10px;
+}
+#preview-header {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ position: relative;
+}
+#preview-logo {
+ float: left;
+ padding: 15px 15px 15px 10px;
+}
+#preview-site-name {
+ color: #686868;
+ font-weight: normal;
+ font-size: 1.821em;
+ line-height: 1;
+ margin-bottom: 30px;
+ margin-left: 15px;
+ padding-top: 34px;
+}
+#preview-main-menu {
+ clear: both;
+ padding: 0 15px 3px;
+}
+#preview-main-menu-links a {
+ color: #d9d9d9;
+ padding: 0.6em 1em 0.4em;
+}
+#preview-main-menu-links {
+ font-size: 0.929em;
+ margin: 0;
+ padding: 0;
+}
+#preview-main-menu-links a {
+ color: #333;
+ background: #ccc;
+ background: rgba(255, 255, 255, 0.7);
+ text-shadow: 0 1px #eee;
+ -khtml-border-radius-topleft: 8px;
+ -moz-border-radius-topleft: 8px;
+ -webkit-border-top-left-radius: 8px;
+ border-top-left-radius: 8px;
+ -khtml-border-radius-topright: 8px;
+ -moz-border-radius-topright: 8px;
+ -webkit-border-top-right-radius: 8px;
+ border-top-right-radius: 8px;
+}
+#preview-main-menu-links a:hover,
+#preview-main-menu-links a:focus {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.95);
+}
+#preview-main-menu-links a:active {
+ background: #b3b3b3;
+ background: rgba(255, 255, 255, 1);
+}
+#preview-main-menu-links li a.active {
+ border-bottom: none;
+}
+#preview-main-menu-links li {
+ display: inline;
+ list-style-type: none;
+ padding: 0.6em 0 0.4em;
+}
+#preview-sidebar,
+#preview-content {
+ display: inline;
+ float: left;
+ position: relative;
+}
+#preview-sidebar {
+ margin-left: 15px;
+ width: 210px;
+}
+#preview-content {
+ margin-left: 30px;
+ width: 26.5em;
+}
+#preview-sidebar .preview-block {
+ border: 1px solid;
+ margin: 20px 0;
+ padding: 15px 20px;
+}
+#preview-sidebar h2 {
+ border-bottom: 1px solid #d6d6d6;
+ font-size: 1.071em;
+ font-weight: normal;
+ line-height: 1.2;
+ margin: 0 0 0.5em;
+ padding-bottom: 5px;
+ text-shadow: 0 1px 0 #fff;
+}
+#preview .preview-block .preview-content {
+ margin-top: 1em;
+}
+#preview .preview-block-menu .preview-content,
+#preview .preview-block-menu .preview-content ul {
+ margin-top: 0;
+}
+#preview-main {
+ margin-bottom: 40px;
+ margin-top: 20px;
+}
+#preview-page-title {
+ font-size: 2em;
+ font-weight: normal;
+ line-height: 1;
+ margin: 1em 0 0.5em;
+}
+#preview-footer-wrapper {
+ color: #c0c0c0;
+ color: rgba(255, 255, 255, 0.65);
+ display: block !important;
+ font-size: 0.857em;
+ padding: 20px 20px 25px;
+}
+#preview-footer-wrapper a {
+ color: #fcfcfc;
+ color: rgba(255, 255, 255, 0.8);
+}
+#preview-footer-wrapper a:hover,
+#preview-footer-wrapper a:focus {
+ color: #fefefe;
+ color: rgba(255, 255, 255, 0.95);
+ text-decoration: underline;
+}
+#preview-footer-wrapper .preview-footer-column {
+ display: inline;
+ float: left;
+ padding: 0 10px;
+ position: relative;
+ width: 220px;
+}
+#preview-footer-wrapper .preview-block {
+ border: 1px solid #444;
+ border-color: rgba(255, 255, 255, 0.1);
+ margin: 20px 0;
+ padding: 10px;
+}
+#preview-footer-columns .preview-block-menu {
+ border: none;
+ margin: 0;
+ padding: 0;
+}
+#preview-footer-columns h2 {
+ border-bottom: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+ font-size: 1em;
+ margin-bottom: 0;
+ padding-bottom: 3px;
+ text-transform: uppercase;
+}
+#preview-footer-columns .preview-content {
+ margin-top: 0;
+}
+#preview-footer-columns .preview-content ul {
+ margin-left: 0;
+ padding-left: 0;
+}
+#preview-footer-columns .preview-content li {
+ list-style: none;
+ list-style-image: none;
+ margin: 0;
+ padding: 0;
+}
+#preview-footer-columns .preview-content li a {
+ border-bottom: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+ display: block;
+ line-height: 1.2;
+ padding: 0.8em 2px 0.8em 20px;
+ text-indent: -15px;
+}
+#preview-footer-columns .preview-content li a:hover,
+#preview-footer-columns .preview-content li a:focus {
+ background-color: #1f1f21;
+ background-color: rgba(255, 255, 255, 0.05);
+ text-decoration: none;
+}
diff --git a/core/themes/bartik/color/preview.html b/core/themes/bartik/color/preview.html
new file mode 100644
index 000000000000..52ea5664a404
--- /dev/null
+++ b/core/themes/bartik/color/preview.html
@@ -0,0 +1,65 @@
+<div id="preview">
+
+ <div id="preview-header">
+ <div id="preview-logo"><img src="../../../themes/bartik/logo.png" alt="Site Logo" /></div>
+ <div id="preview-site-name">Bartik</div>
+ <div id="preview-main-menu">
+ <ul id="preview-main-menu-links">
+ <li><a>Home</a></li>
+ <li><a>Te Quidne</a></li>
+ <li><a>Vel Torqueo Quae Erat</a></li>
+ </ul>
+ </div>
+ </div>
+
+ <div id="preview-main" class="clearfix">
+ <div id="preview-sidebar">
+ <div id="preview-block" class="preview-block">
+ <h2>Etiam est risus</h2>
+ <div class="preview-content">
+ Maecenas id porttitor Ut enim ad minim veniam, quis nostrudfelis.
+ Laboris nisi ut aliquip ex ea.
+ </div>
+ </div>
+ </div>
+ <div id="preview-content">
+ <h1 id="preview-page-title">Lorem ipsum dolor</h1>
+ <div id="preview-node">
+ <div class="preview-content">
+ Sit amet, <a>consectetur adipisicing elit</a>, sed do eiusmod tempor
+ incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis
+ nostrud <a>exercitation ullamco</a> laboris nisi ut aliquip ex ea
+ commodo consequat. Maecenas id porttitor Ut enim ad minim veniam, quis nostr udfelis.
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="preview-footer-wrapper">
+ <div id="preview-footer-columns" class="clearfix">
+ <div class="preview-footer-column">
+ <div class="preview-block">
+ <h2>Etiam est risus</h2>
+ <div class="content">
+ Maecenas id porttitor Ut enim ad minim veniam, quis nostrudfelis.
+ Laboris nisi ut aliquip ex ea.
+ </div>
+ </div>
+ </div>
+ <div class="preview-footer-column">
+ <div class="preview-block preview-block-menu">
+ <h2>Erisus dolor</h2>
+ <div class="preview-content">
+ <ul>
+ <li><a>Donec placerat</a></li>
+ <li><a>Nullam nibh dolor</a></li>
+ <li><a>Blandit sed</a></li>
+ <li><a>Fermentum id</a></li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+</div>
diff --git a/core/themes/bartik/color/preview.js b/core/themes/bartik/color/preview.js
new file mode 100644
index 000000000000..b40bcf7dcae1
--- /dev/null
+++ b/core/themes/bartik/color/preview.js
@@ -0,0 +1,39 @@
+
+(function ($) {
+ Drupal.color = {
+ logoChanged: false,
+ callback: function(context, settings, form, farb, height, width) {
+ // Change the logo to be the real one.
+ if (!this.logoChanged) {
+ $('#preview #preview-logo img').attr('src', Drupal.settings.color.logo);
+ this.logoChanged = true;
+ }
+ // Remove the logo if the setting is toggled off.
+ if (Drupal.settings.color.logo == null) {
+ $('div').remove('#preview-logo');
+ }
+
+ // Solid background.
+ $('#preview', form).css('backgroundColor', $('#palette input[name="palette[bg]"]', form).val());
+
+ // Text preview.
+ $('#preview #preview-main h2, #preview .preview-content', form).css('color', $('#palette input[name="palette[text]"]', form).val());
+ $('#preview #preview-content a', form).css('color', $('#palette input[name="palette[link]"]', form).val());
+
+ // Sidebar block.
+ $('#preview #preview-sidebar #preview-block', form).css('background-color', $('#palette input[name="palette[sidebar]"]', form).val());
+ $('#preview #preview-sidebar #preview-block', form).css('border-color', $('#palette input[name="palette[sidebarborders]"]', form).val());
+
+ // Footer wrapper background.
+ $('#preview #preview-footer-wrapper', form).css('background-color', $('#palette input[name="palette[footer]"]', form).val());
+
+ // CSS3 Gradients.
+ var gradient_start = $('#palette input[name="palette[top]"]', form).val();
+ var gradient_end = $('#palette input[name="palette[bottom]"]', form).val();
+
+ $('#preview #preview-header', form).attr('style', "background-color: " + gradient_start + "; background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(" + gradient_start + "), to(" + gradient_end + ")); background-image: -moz-linear-gradient(-90deg, " + gradient_start + ", " + gradient_end + ");");
+
+ $('#preview #preview-site-name', form).css('color', $('#palette input[name="palette[titleslogan]"]', form).val());
+ }
+ };
+})(jQuery);
diff --git a/core/themes/bartik/color/preview.png b/core/themes/bartik/color/preview.png
new file mode 100644
index 000000000000..58cc088a49e9
--- /dev/null
+++ b/core/themes/bartik/color/preview.png
Binary files differ
diff --git a/core/themes/bartik/css/colors.css b/core/themes/bartik/css/colors.css
new file mode 100644
index 000000000000..fd83374a54b6
--- /dev/null
+++ b/core/themes/bartik/css/colors.css
@@ -0,0 +1,58 @@
+
+/* ---------- Color Module Styles ----------- */
+
+body,
+body.overlay {
+ color: #3b3b3b;
+}
+.comment .comment-arrow {
+ border-color: #ffffff;
+}
+#page,
+#main-wrapper,
+#main-menu-links li a.active,
+#main-menu-links li.active-trail a {
+ background: #ffffff;
+}
+.tabs ul.primary li a.active {
+ background-color: #ffffff;
+}
+.tabs ul.primary li.active a {
+ background-color: #ffffff;
+ border-bottom: 1px solid #ffffff;
+}
+#header {
+ background-color: #48a9e4;
+ background-image: -moz-linear-gradient(top, #0779bf 0%, #48a9e4 100%);
+ background-image: -ms-linear-gradient(top, #0779bf 0%, #48a9e4 100%);
+ background-image: -o-linear-gradient(top, #0779bf 0%, #48a9e4 100%);
+ background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #0779bf), color-stop(1, #48a9e4));
+ background-image: -webkit-linear-gradient(top, #0779bf 0%, #48a9e4 100%);
+ background-image: linear-gradient(top, #0779bf 0%, #48a9e4 100%);
+}
+a {
+ color: #0071B3;
+}
+a:hover,
+a:focus {
+ color: #018fe2;
+}
+a:active {
+ color: #23aeff;
+}
+.sidebar .block {
+ background-color: #f6f6f2;
+ border-color: #f9f9f9;
+}
+#page-wrapper,
+#footer-wrapper {
+ background: #292929;
+}
+.region-header,
+.region-header a,
+.region-header li a.active,
+#name-and-slogan,
+#name-and-slogan a,
+#secondary-menu-links li a {
+ color: #fffeff;
+}
diff --git a/core/themes/bartik/css/ie-rtl.css b/core/themes/bartik/css/ie-rtl.css
new file mode 100644
index 000000000000..6358bf376a7e
--- /dev/null
+++ b/core/themes/bartik/css/ie-rtl.css
@@ -0,0 +1,48 @@
+
+fieldset legend {
+ left: 6px;
+}
+ul.action-links li a,
+#user-login-form li.openid-link a,
+#user-login li.openid-link a {
+ zoom: 1;
+}
+.comment .attribution {
+ float: right;
+}
+.comment .comment-arrow {
+ position: absolute;
+ right: 25px;
+}
+.region-header .block,
+.region-header #block-user-login .form-item {
+ float: none;
+ display: inline;
+ vertical-align: top;
+}
+.region-header #block-user-login .item-list li {
+ float: none;
+}
+.region-header #block-user-login .item-list li.last {
+ padding-right: 0;
+}
+#user-login-form li.openid-link a,
+#user-login li.openid-link a {
+ background-position: right -3px;
+ padding-right: 20px;
+ zoom: 1;
+}
+#main-menu ul.links li {
+ margin: 0;
+}
+#main-menu ul.links li,
+#main-menu ul.links li a {
+ display: inline;
+ float: none;
+ margin: 0;
+ zoom: 1;
+}
+#footer li {
+ display: inline;
+ float: none;
+}
diff --git a/core/themes/bartik/css/ie.css b/core/themes/bartik/css/ie.css
new file mode 100644
index 000000000000..7a658336c9ca
--- /dev/null
+++ b/core/themes/bartik/css/ie.css
@@ -0,0 +1,63 @@
+
+.block {
+ zoom: 1;
+}
+#password-strength-text {
+ margin-top: 0;
+}
+fieldset legend {
+ left: -8px;
+ padding: 0;
+}
+#footer-wrapper #footer .block {
+ height: 100%;
+}
+.comment .attribution {
+ display: inline-block;
+ position: relative;
+ float: left; /* LTR */
+ overflow: hidden;
+}
+.comment .comment-text {
+ display: inline-block;
+ width: auto;
+}
+#search-block-form input.form-submit,
+#search-form input.form-submit {
+ text-transform: capitalize; /* Trigger text indent. */
+ height: 26px;
+}
+.meta {
+ margin-bottom: 10px;
+}
+.region-header .form-required {
+ color: #eee;
+}
+#search-block-form input.form-submit:hover,
+#search-block-form input.form-submit:focus,
+#search-form input.form-submit:hover,
+#search-form input.form-submit:focus {
+ background-position: center -25px;
+}
+.contact-form #edit-message {
+ width: 75%;
+}
+.contact-form .resizable-textarea .grippie {
+ width: 76.3%;
+}
+#footer li {
+ float: left; /* LTR */
+}
+#footer-wrapper {
+ color: #c0c0c0;
+}
+#footer-wrapper a {
+ color: #fcfcfc;
+}
+#footer-wrapper a:hover,
+#footer-wrapper a:focus {
+ color: #fefefe;
+}
+.node-teaser {
+ margin-top: 10px;
+}
diff --git a/core/themes/bartik/css/layout-rtl.css b/core/themes/bartik/css/layout-rtl.css
new file mode 100644
index 000000000000..fa81e4f862e8
--- /dev/null
+++ b/core/themes/bartik/css/layout-rtl.css
@@ -0,0 +1,22 @@
+
+/* ---------- Basic Layout RTL Styles ----------- */
+
+#content,
+#sidebar-first,
+#sidebar-second,
+.region-triptych-first,
+.region-triptych-middle,
+.region-triptych-last,
+.region-footer-firstcolumn,
+.region-footer-secondcolumn,
+.region-footer-thirdcolumn,
+.region-footer-fourthcolumn {
+ float: right;
+}
+.region-header {
+ float: left;
+}
+#secondary-menu {
+ left: 0;
+ right: auto;
+}
diff --git a/core/themes/bartik/css/layout.css b/core/themes/bartik/css/layout.css
new file mode 100644
index 000000000000..b561f4c021cd
--- /dev/null
+++ b/core/themes/bartik/css/layout.css
@@ -0,0 +1,100 @@
+
+/* ---------- Basic Layout Styles ----------- */
+
+html,
+body,
+#page {
+ height: 100%;
+}
+#page-wrapper {
+ min-height: 100%;
+ min-width: 960px;
+}
+#header div.section,
+#featured div.section,
+#messages div.section,
+#main,
+#triptych,
+#footer-columns,
+#footer {
+ width: 960px;
+ margin-left: auto;
+ margin-right: auto;
+}
+#header div.section {
+ position: relative;
+}
+.region-header {
+ float: right; /* LTR */
+ margin: 0 5px 10px;
+}
+.with-secondary-menu .region-header {
+ margin-top: 3em;
+}
+.without-secondary-menu .region-header {
+ margin-top: 15px;
+}
+#secondary-menu {
+ position: absolute;
+ right: 0; /* LTR */
+ top: 0;
+ width: 480px;
+}
+#content,
+#sidebar-first,
+#sidebar-second,
+.region-triptych-first,
+.region-triptych-middle,
+.region-triptych-last,
+.region-footer-firstcolumn,
+.region-footer-secondcolumn,
+.region-footer-thirdcolumn,
+.region-footer-fourthcolumn {
+ display: inline;
+ float: left; /* LTR */
+ position: relative;
+}
+.one-sidebar #content {
+ width: 720px;
+}
+.two-sidebars #content {
+ width: 480px;
+}
+.no-sidebars #content {
+ width: 960px;
+ float: none;
+}
+#sidebar-first,
+#sidebar-second {
+ width: 240px;
+}
+#main-wrapper {
+ min-height: 300px;
+}
+#content .section,
+.sidebar .section {
+ padding: 0 15px;
+}
+#breadcrumb {
+ margin: 0 15px;
+}
+.region-triptych-first,
+.region-triptych-middle,
+.region-triptych-last {
+ margin: 20px 20px 30px;
+ width: 280px;
+}
+#footer-wrapper {
+ padding: 35px 5px 30px;
+}
+.region-footer-firstcolumn,
+.region-footer-secondcolumn,
+.region-footer-thirdcolumn,
+.region-footer-fourthcolumn {
+ padding: 0 10px;
+ width: 220px;
+}
+#footer {
+ width: 940px;
+ min-width: 920px;
+}
diff --git a/core/themes/bartik/css/maintenance-page.css b/core/themes/bartik/css/maintenance-page.css
new file mode 100644
index 000000000000..c13c77b9a179
--- /dev/null
+++ b/core/themes/bartik/css/maintenance-page.css
@@ -0,0 +1,67 @@
+
+body.maintenance-page {
+ background-color: #fff;
+ color: #000;
+}
+.maintenance-page #page-wrapper {
+ background: #fff;
+ margin-left: auto;
+ margin-right: auto;
+ min-width: 0;
+ min-height: 0;
+ width: 800px;
+ border: 1px solid #ddd;
+ margin-top: 40px;
+}
+.maintenance-page #page {
+ margin: 20px 40px 40px;
+}
+.maintenance-page #main-wrapper {
+ min-height: inherit;
+}
+.maintenance-page #header,
+.maintenance-page #messages,
+.maintenance-page #main {
+ width: auto;
+}
+.maintenance-page #header div.section,
+.maintenance-page #main {
+ width: 700px;
+}
+.maintenance-page #main {
+ margin: 0;
+}
+.maintenance-page #content .section {
+ padding: 0 0 0 10px;
+}
+.maintenance-page #header {
+ background-color: #fff;
+ background-image: none;
+}
+.maintenance-page #name-and-slogan {
+ margin-bottom: 50px;
+ margin-left: 0;
+ padding-top: 20px;
+ font-size: 90%;
+}
+.maintenance-page #name-and-slogan,
+.maintenance-page #name-and-slogan a,
+.maintenance-page #name-and-slogan a:hover,
+.maintenance-page #name-and-slogan a:hover {
+ color: #777;
+}
+.maintenance-page h1#page-title {
+ line-height: 1em;
+ margin-top: 0;
+}
+.maintenance-page #messages {
+ padding: 0;
+ margin-top: 30px;
+}
+.maintenance-page #messages div.messages {
+ margin: 0;
+}
+.maintenance-page #messages div.section {
+ padding: 0;
+ width: auto;
+}
diff --git a/core/themes/bartik/css/print.css b/core/themes/bartik/css/print.css
new file mode 100644
index 000000000000..fbe386a43cc8
--- /dev/null
+++ b/core/themes/bartik/css/print.css
@@ -0,0 +1,46 @@
+
+/* ---------- General Layout ---------- */
+
+body,
+input,
+textarea,
+select {
+ color: #000;
+ background: none;
+}
+body.two-sidebars,
+body.sidebar-first,
+body.sidebar-second,
+body {
+ width: 640px;
+}
+#sidebar-first,
+#sidebar-second,
+.navigation,
+#toolbar,
+#footer-wrapper,
+.tabs,
+.add-or-remove-shortcuts {
+ display: none;
+}
+.one-sidebar #content,
+.two-sidebars #content {
+ width: 100%;
+}
+#triptych-wrapper {
+ width: 960px;
+ margin: 0;
+ padding: 0;
+ border: none;
+}
+#triptych-first, #triptych-middle, #triptych-last {
+ width: 250px;
+}
+
+/* ---------- Node Pages ---------- */
+
+#comments .title,
+#comments form,
+.comment-forbidden {
+ display: none;
+}
diff --git a/core/themes/bartik/css/style-rtl.css b/core/themes/bartik/css/style-rtl.css
new file mode 100644
index 000000000000..d25006faaac4
--- /dev/null
+++ b/core/themes/bartik/css/style-rtl.css
@@ -0,0 +1,271 @@
+
+/* ------------------ Reset Styles ------------------ */
+
+caption,
+th,
+td {
+ text-align: right;
+}
+blockquote {
+ border-left: none;
+ border-right: 4px solid #afafaf;
+}
+blockquote:before {
+ content: "\201D";
+}
+blockquote:after {
+ content: "\201C";
+}
+
+/* ------------------ List Styles ------------------ */
+
+.region-content ul,
+.region-content ol {
+ padding: 2.5em 0 0.25em 0;
+}
+.item-list ul li {
+ padding: 0.2em 0 0 0.5em;
+}
+ul.tips {
+ padding: 0 1.25em 0 0;
+}
+.block ol,
+.block ul {
+ padding: 0 1em 0.25em 0;
+}
+
+/* ------------------ Header ------------------ */
+
+#logo {
+ padding: 15px 10px 15px 15px;
+}
+#logo,
+#name-and-slogan,
+.region-header .block,
+.region-header #block-user-login .form-item,
+.region-header #block-user-login .item-list li {
+ float: right;
+}
+#name-and-slogan {
+ margin: 0 15px 30px 0;
+}
+.region-header .form-text {
+ margin-left: 2px;
+ margin-right: 0;
+}
+.region-header #block-user-login .item-list li.last {
+ padding-left: 0;
+ padding-right: 0.5em;
+}
+.region-header #block-user-login ul.openid-links li.last {
+ padding-right: 0;
+}
+.region-header #user-login-form li.openid-link a,
+.region-header #user-login li.openid-link a {
+ background-position: right -3px;
+ padding-left: 0;
+ padding-right: 20px;
+}
+
+/* --------------- Main Menu ------------ */
+
+#main-menu ul.links li,
+#main-menu ul.links li a {
+ float: right;
+}
+
+/* --------------- Secondary Menu ------------ */
+
+#secondary-menu-links {
+ float: left;
+}
+
+/* ----------------- Content ------------------ */
+
+.submitted .user-picture img {
+ float: right;
+ margin-left: 5px;
+ margin-right: 0;
+}
+.field-type-taxonomy-term-reference .field-label {
+ padding-left: 5px;
+ padding-right: 0;
+}
+.field-type-taxonomy-term-reference ul.links li {
+ padding: 0 0 0 1em;
+ float: right;
+}
+.link-wrapper {
+ margin-right: 236px;
+ margin-left: 0;
+}
+
+/* ----------------- Comments ----------------- */
+
+.comment .user-picture img {
+ margin-right: 0;
+}
+.comment .attribution {
+ float: right;
+ padding: 0 0 0 30px;
+}
+.comment .comment-arrow {
+ background-image: url(../images/comment-arrow-rtl.gif);
+ margin-left: 0;
+ margin-right: -47px;
+}
+.comment .indented {
+ margin-right: 40px;
+ margin-left: 0;
+}
+.comment ul.links li {
+ padding: 0 0 0.5em;
+}
+.comment-unpublished {
+ margin-left: 5px;
+ margin-right: 0;
+ padding: 5px 5px 5px 2px;
+}
+
+/* -------------- Password Meter ------------- */
+
+#password-strength {
+ left: auto;
+ margin-top: 2em;
+ right: 16em;
+}
+#password-strength-text {
+ margin-top: 0;
+ float: left;
+}
+.form-item-pass-pass2 label {
+ clear: right;
+}
+
+/* ----------------- Buttons ------------------ */
+
+input.form-submit,
+a.button {
+ margin-right: 0;
+ margin-left: 0.6em;
+}
+
+/* --------------- Search Form ---------------- */
+
+#search-form input#edit-keys,
+#block-search-form .form-item-search-block-form input {
+ float: right;
+ margin-left: 5px;
+ margin-right: 0;
+}
+
+/* ------------------ Footer ------------------ */
+
+#footer-columns ul {
+ padding-right: 0;
+}
+#footer-columns li a {
+ padding: 0.8em 20px 0.8em 2px;
+}
+#footer li a {
+ float: right;
+ border-left: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+ border-right: none;
+}
+#footer li.first a {
+ padding-right: 0;
+ padding-left: 12px;
+}
+#footer li.last a {
+ padding-left: 0;
+ padding-right: 12px;
+ border-left: none;
+}
+
+/* --------------- System Tabs --------------- */
+
+.tabs ul.primary li {
+ margin: 0 0 0 5px;
+ float: right;
+ zoom: 1;
+}
+.tabs ul.secondary li {
+ float: right;
+}
+.tabs ul.secondary li:first-child {
+ padding-right: 0;
+}
+.tabs ul.secondary li:last-child {
+ border-left: none;
+}
+ul.action-links li a {
+ background-position: right center;
+ padding-left: 0;
+ padding-right: 15px;
+}
+
+/* -------------- Form Elements ------------- */
+
+.fieldset-legend span.summary {
+ margin-left: 0;
+}
+#user-profile-form input#edit-submit {
+ margin-left: 0;
+}
+.password-suggestions ul li {
+ margin-right: 1.2em;
+ margin-left: 0;
+}
+
+/* Animated throbber */
+html.js input.form-autocomplete {
+ background-position: 1% 4px;
+}
+html.js input.throbbing {
+ background-position: 1% -16px;
+}
+
+/* Comment form */
+.comment-form label {
+ float: right;
+}
+.comment-form .form-type-checkbox,
+.comment-form .form-radios,
+.comment-form .form-item .description {
+ margin-left: 0;
+ margin-right: 120px;
+}
+#edit-actions input {
+ margin-left: 0.6em;
+ margin-right: 0;
+}
+
+/* -------------- Shortcut Links ------------- */
+
+.shortcut-wrapper h1#page-title {
+ float: right;
+}
+
+/* ---------- Poll ----------- */
+
+.poll .vote-form {
+ text-align: right;
+}
+.poll .total {
+ text-align: left;
+}
+
+/* ---------- Color Form ----------- */
+
+.color-form #palette {
+ margin-left: 0;
+ margin-right: 20px;
+}
+.color-form .form-item label {
+ float: right;
+}
+.color-form #palette .lock {
+ right: -20px;
+ left: 0;
+}
diff --git a/core/themes/bartik/css/style.css b/core/themes/bartik/css/style.css
new file mode 100644
index 000000000000..4fb8210255b9
--- /dev/null
+++ b/core/themes/bartik/css/style.css
@@ -0,0 +1,1650 @@
+
+/* ---------- Overall Specifications ---------- */
+
+body {
+ line-height: 1.5;
+ font-size: 87.5%;
+ word-wrap: break-word;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+}
+a:link,
+a:visited {
+ text-decoration: none;
+}
+a:hover,
+a:active,
+a:focus {
+ text-decoration: underline;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ margin: 1.0em 0 0.5em;
+ font-weight: inherit;
+}
+h1 {
+ font-size: 1.357em;
+ color: #000;
+}
+h2 {
+ font-size: 1.143em;
+}
+p {
+ margin: 0 0 1.2em;
+}
+del {
+ text-decoration: line-through;
+}
+tr.odd {
+ background-color: #dddddd;
+}
+img {
+ outline: 0;
+}
+code,
+pre,
+kbd,
+samp,
+var {
+ padding: 0 0.4em;
+ font-size: 0.77em;
+ font-family: Menlo, Consolas, "Andale Mono", "Lucida Console", "Nimbus Mono L", "DejaVu Sans Mono", monospace, "Courier New";
+}
+code {
+ background-color: #f2f2f2;
+ background-color: rgba(40, 40, 0, 0.06);
+}
+pre code,
+pre kbd,
+pre samp,
+pre var,
+kbd kbd,
+kbd samp,
+code var {
+ font-size: 100%;
+ background-color: transparent;
+}
+pre code,
+pre samp,
+pre var {
+ padding: 0;
+}
+.description code {
+ font-size: 1em;
+}
+kbd {
+ background-color: #f2f2f2;
+ border: 1px outset #575757;
+ margin: 0 3px;
+ color: #666;
+ display: inline-block;
+ padding: 0 6px;
+ -khtml-border-radius: 5px;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+}
+pre {
+ background-color: #f2f2f2;
+ background-color: rgba(40, 40, 0, 0.06);
+ margin: 10px 0;
+ overflow: hidden;
+ padding: 15px;
+ white-space: pre-wrap;
+}
+
+
+/* ------------------ Fonts ------------------ */
+
+body,
+#site-slogan,
+.ui-widget,
+.comment-form label {
+ font-family: Georgia, "Times New Roman", Times, serif;
+}
+#header,
+#footer-wrapper,
+#skip-link,
+ul.contextual-links,
+ul.links,
+ul.primary,
+.item-list .pager,
+div.field-type-taxonomy-term-reference,
+div.messages,
+div.meta,
+p.comment-time,
+table,
+.breadcrumb {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+}
+input,
+textarea,
+select,
+a.button {
+ font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, sans-serif;
+}
+
+/* ------------------ Reset Styles ------------------ */
+
+caption {
+ text-align: left; /* LTR */
+ font-weight: normal;
+}
+blockquote {
+ background: #f7f7f7;
+ border-left: 1px solid #bbb;
+ font-style: italic;
+ margin: 1.5em 10px;
+ padding: 0.5em 10px;
+}
+blockquote:before {
+ color: #bbb;
+ content: "\201C";
+ font-size: 3em;
+ line-height: 0.1em;
+ margin-right: 0.2em;
+ vertical-align: -.4em;
+}
+blockquote:after {
+ color: #bbb;
+ content: "\201D";
+ font-size: 3em;
+ line-height: 0.1em;
+ vertical-align: -.45em;
+}
+blockquote > p:first-child {
+ display: inline;
+}
+a.feed-icon {
+ display: inline-block;
+ padding: 15px 0 0 0;
+}
+
+/* ------------------ Table Styles ------------------ */
+
+table {
+ border: 0;
+ border-spacing: 0;
+ font-size: 0.857em;
+ margin: 10px 0;
+ width: 100%;
+}
+table table {
+ font-size: 1em;
+}
+#footer-wrapper table {
+ font-size: 1em;
+}
+table tr th {
+ background: #757575;
+ background: rgba(0, 0, 0, 0.51);
+ border-bottom-style: none;
+}
+table tr th,
+table tr th a,
+table tr th a:hover {
+ color: #FFF;
+ font-weight: bold;
+}
+table tbody tr th {
+ vertical-align: top;
+}
+tr td,
+tr th {
+ padding: 4px 9px;
+ border: 1px solid #fff;
+ text-align: left; /* LTR */
+}
+#footer-wrapper tr td,
+#footer-wrapper tr th {
+ border-color: #555;
+ border-color: rgba(255, 255, 255, 0.18);
+}
+tr.odd {
+ background: #e4e4e4;
+ background: rgba(0, 0, 0, 0.105);
+}
+tr,
+tr.even {
+ background: #efefef;
+ background: rgba(0, 0, 0, 0.063);
+}
+table ul.links {
+ margin: 0;
+ padding: 0;
+ font-size: 1em;
+}
+table ul.links li {
+ padding: 0 1em 0 0;
+}
+
+/* ------------------ List Styles ------------------ */
+
+.block ol,
+.block ul {
+ margin: 0;
+ padding: 0 0 0.25em 1em; /* LTR */
+}
+.contextual-links-wrapper {
+ font-size: small !important;
+}
+ul.contextual-links {
+ font-size: 0.923em;
+}
+.contextual-links-wrapper a {
+ text-shadow: 0 0 0 !important;
+}
+.item-list .pager {
+ font-size: 0.929em;
+}
+ul.menu li {
+ margin: 0;
+}
+.region-content ul,
+.region-content ol {
+ margin: 1em 0;
+ padding: 0 0 0.25em 2.5em; /* LTR */
+}
+.item-list ul li {
+ margin: 0;
+ padding: 0.2em 0.5em 0 0; /* LTR */
+}
+ul.tips {
+ padding: 0 0 0 1.25em; /* LTR */
+}
+
+/* ------------------ Header ------------------ */
+#skip-link {
+ left: 50%;
+ margin-left: -5.25em;
+ margin-top: 0;
+ position: absolute;
+ width: auto;
+ z-index: 50;
+}
+#skip-link a,
+#skip-link a:link,
+#skip-link a:visited {
+ background: #444;
+ background: rgba(0, 0, 0, 0.6);
+ color: #fff;
+ display: block;
+ font-size: 0.94em;
+ line-height: 1.7;
+ padding: 1px 10px 2px 10px;
+ text-decoration: none;
+ -khtml-border-radius: 0 0 10px 10px;
+ -moz-border-radius: 0 0 10px 10px;
+ -webkit-border-top-left-radius: 0;
+ -webkit-border-top-right-radius: 0;
+ -webkit-border-bottom-left-radius: 10px;
+ -webkit-border-bottom-right-radius: 10px;
+ border-radius: 0 0 10px 10px;
+}
+#skip-link a:hover,
+#skip-link a:active,
+#skip-link a:focus {
+ outline: 0;
+}
+#logo {
+ float: left; /* LTR */
+ padding: 15px 15px 15px 10px; /* LTR */
+}
+#name-and-slogan {
+ float: left; /* LTR */
+ padding-top: 34px;
+ margin: 0 0 30px 15px; /* LTR */
+}
+#site-name {
+ font-size: 1.821em;
+ color: #686868;
+ line-height: 1;
+}
+h1#site-name {
+ margin: 0;
+}
+#site-name a {
+ font-weight: normal;
+}
+#site-slogan {
+ font-size: 0.929em;
+ margin-top: 7px;
+ word-spacing: 0.1em;
+ font-style: italic;
+}
+/* Region header blocks. */
+.region-header .block {
+ font-size: 0.857em;
+ float: left; /* LTR */
+ margin: 0 10px;
+ padding: 0;
+}
+.region-header .block .content {
+ margin: 0;
+ padding: 0;
+}
+.region-header .block ul {
+ margin: 0;
+ padding: 0;
+}
+.region-header .block li {
+ list-style: none;
+ list-style-image: none;
+ padding: 0;
+}
+.region-header .form-text {
+ background: #fefefe;
+ background: rgba(255, 255, 255, 0.7);
+ border-color: #ccc;
+ border-color: rgba(255, 255, 255, 0.3);
+ margin-right: 2px; /* LTR */
+ width: 120px;
+}
+.region-header .form-text:hover,
+.region-header .form-text:focus,
+.region-header .form-text:active {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+}
+.region-header .form-required {
+ color: #eee;
+ color: rgba(255, 255, 255, 0.7);
+}
+/* Region header block menus. */
+.region-header .block-menu {
+ border: 1px solid;
+ border-color: #eee;
+ border-color: rgba(255, 255, 255, 0.2);
+ padding: 0;
+ width: 208px;
+}
+.region-header .block-menu li a {
+ display: block;
+ border-bottom: 1px solid;
+ border-bottom-color: #eee;
+ border-bottom-color: rgba(255, 255, 255, 0.2);
+ padding: 3px 7px;
+}
+.region-header .block-menu li a:hover,
+.region-header .block-menu li a:focus,
+.region-header .block-menu li a:active {
+ text-decoration: none;
+ background: rgba(255, 255, 255, 0.15);
+}
+.region-header .block-menu li.last a {
+ border-bottom: 0;
+}
+/* User Login block in the header region */
+.region-header #block-user-login {
+ width: auto;
+}
+.region-header #block-user-login .content {
+ margin-top: 2px;
+}
+.region-header #block-user-login .form-item {
+ float: left; /* LTR */
+ margin: 0;
+ padding: 0;
+}
+.region-header #block-user-login div.item-list,
+.region-header #block-user-login div.description {
+ font-size: 0.916em;
+ margin: 0;
+}
+.region-header #block-user-login div.item-list {
+ clear: both;
+}
+.region-header #block-user-login div.description {
+ display: inline;
+}
+.region-header #block-user-login .item-list ul {
+ padding: 0;
+ line-height: 1;
+}
+.region-header #block-user-login .item-list li {
+ list-style: none;
+ float: left; /* LTR */
+ padding: 3px 0 1px;
+}
+.region-header #block-user-login .item-list li.last {
+ padding-left: 0.5em; /* LTR */
+}
+.region-header #block-user-login ul.openid-links li.last {
+ padding-left: 0; /* LTR */
+}
+.region-header #user-login-form li.openid-link a,
+.region-header #user-login li.openid-link a {
+ padding-left: 20px; /* LTR */
+}
+.region-header #block-user-login .form-actions {
+ margin: 4px 0 0;
+ padding: 0;
+ clear: both;
+}
+.region-header #block-user-login input.form-submit {
+ border: 1px solid;
+ border-color: #ccc;
+ border-color: rgba(255, 255, 255, 0.5);
+ background: #eee;
+ background: rgba(255, 255, 255, 0.7);
+ margin: 4px 0;
+ padding: 3px 8px;
+}
+.region-header #block-user-login input.form-submit:hover,
+.region-header #block-user-login input.form-submit:focus {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.9);
+}
+/* Search block in region header. */
+.region-header #block-search-form {
+ width: 208px;
+}
+.region-header #block-search-form .form-text {
+ width: 154px;
+}
+/* Language switcher block in region header. */
+.region-header .block-locale ul li {
+ display: inline;
+ padding: 0 0.5em;
+}
+
+/* --------------- Main Menu ------------ */
+
+#main-menu {
+ clear: both;
+}
+#main-menu-links {
+ font-size: 0.929em;
+ margin: 0;
+ padding: 0 15px;
+}
+#main-menu-links li {
+ float: left; /* LTR */
+ list-style: none;
+ padding: 0 1px;
+ margin: 0 1px;
+}
+#main-menu-links a {
+ color: #333;
+ background: #ccc;
+ background: rgba(255, 255, 255, 0.7);
+ float: left; /* LTR */
+ height: 2.4em;
+ line-height: 2.4em;
+ padding: 0 0.8em;
+ text-decoration: none;
+ text-shadow: 0 1px #eee;
+ -khtml-border-radius-topleft: 8px;
+ -khtml-border-radius-topright: 8px;
+ -moz-border-radius-topleft: 8px;
+ -moz-border-radius-topright: 8px;
+ -webkit-border-top-left-radius: 8px;
+ -webkit-border-top-right-radius: 8px;
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+}
+#main-menu-links a:hover,
+#main-menu-links a:focus {
+ background: #f6f6f2;
+ background: rgba(255, 255, 255, 0.95);
+}
+#main-menu-links a:active {
+ background: #b3b3b3;
+ background: rgba(255, 255, 255, 1);
+}
+#main-menu-links li a.active {
+ border-bottom: none;
+}
+.featured #main-menu-links li a:active,
+.featured #main-menu-links li a.active {
+ background: #f0f0f0;
+ background: rgba(240, 240, 240, 1.0);
+}
+
+/* --------------- Secondary Menu ------------ */
+
+#secondary-menu-links {
+ float: right; /* LTR */
+ font-size: 0.929em;
+ margin: 10px 10px 0;
+}
+#secondary-menu-links a:hover,
+#secondary-menu-links a:focus {
+ text-decoration: underline;
+}
+
+/* ------------------- Main ------------------- */
+
+#main {
+ margin-top: 20px;
+ margin-bottom: 40px;
+}
+
+/* ----------------- Featured ----------------- */
+
+#featured {
+ text-align: center;
+ font-size: 1.643em;
+ font-weight: normal;
+ line-height: 1.4;
+ padding: 20px 0 45px;
+ margin: 0;
+ background: #f0f0f0;
+ background: rgba(30, 50, 10, 0.08);
+ border-bottom: 1px solid #e7e7e7;
+ text-shadow: 1px 1px #fff;
+}
+#featured h2 {
+ font-size: 1.174em;
+ line-height: 1;
+}
+#featured p {
+ margin: 0;
+ padding: 0;
+}
+
+/* --------------- Highlighted ---------------- */
+
+#highlighted {
+ border-bottom: 1px solid #d3d7d9;
+ font-size: 120%;
+}
+
+/* ------------------- Help ------------------- */
+
+.region-help {
+ border: 1px solid #d3d7d9;
+ padding: 0 1.5em;
+ margin-bottom: 30px;
+}
+
+/* ----------------- Content ------------------ */
+
+.content {
+ margin-top: 10px;
+}
+h1#page-title {
+ font-size: 2em;
+ line-height: 1;
+}
+#content h2 {
+ margin-bottom: 2px;
+ font-size: 1.429em;
+ line-height: 1.4;
+}
+.node .content {
+ font-size: 1.071em;
+}
+.node-teaser .content {
+ font-size: 1em;
+}
+.node-teaser h2 {
+ margin-top: 0;
+ padding-top: 0.5em;
+}
+.node-teaser h2 a {
+ color: #181818;
+}
+.node-teaser {
+ border-bottom: 1px solid #d3d7d9;
+ margin-bottom: 30px;
+ padding-bottom: 15px;
+}
+.node-sticky {
+ background: #f9f9f9;
+ background: rgba(0, 0, 0, 0.024);
+ border: 1px solid #d3d7d9;
+ padding: 0 15px 15px;
+}
+.node-full {
+ background: none;
+ border: none;
+ padding: 0;
+}
+.node-teaser .content {
+ clear: none;
+ line-height: 1.6;
+}
+.meta {
+ font-size: 0.857em;
+ color: #68696b;
+ margin-bottom: -5px;
+}
+.submitted .user-picture img {
+ float: left; /* LTR */
+ height: 20px;
+ margin: 1px 5px 0 0; /* LTR */
+}
+.field-type-taxonomy-term-reference {
+ margin: 0 0 1.2em;
+}
+.field-type-taxonomy-term-reference .field-label {
+ font-weight: normal;
+ margin: 0;
+ padding-right: 5px; /* LTR */
+}
+.field-type-taxonomy-term-reference .field-label,
+.field-type-taxonomy-term-reference ul.links {
+ font-size: 0.8em;
+}
+.node-teaser .field-type-taxonomy-term-reference .field-label,
+.node-teaser .field-type-taxonomy-term-reference ul.links {
+ font-size: 0.821em;
+}
+.field-type-taxonomy-term-reference ul.links {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+.field-type-taxonomy-term-reference ul.links li {
+ float: left; /* LTR */
+ padding: 0 1em 0 0; /* LTR */
+ white-space: nowrap;
+}
+.link-wrapper {
+ text-align: right;
+}
+.field-type-image img,
+.user-picture img {
+ margin: 0 0 1em;
+}
+ul.links {
+ color: #68696b;
+ font-size: 0.821em;
+}
+.node-unpublished {
+ margin: -20px -15px 0;
+ padding: 20px 15px 0;
+}
+.node-unpublished .comment-text .comment-arrow {
+ border-left: 1px solid #fff4f4;
+ border-right: 1px solid #fff4f4;
+}
+
+/* ----------------- Comments ----------------- */
+
+.comment h2.title {
+ margin-bottom: 1em;
+}
+.comment div.user-picture img {
+ margin-left: 0; /* LTR */
+}
+.comment {
+ margin-bottom: 20px;
+ display: table;
+ vertical-align: top;
+}
+.comment .attribution {
+ display: table-cell;
+ padding: 0 30px 0 0; /* LTR */
+ vertical-align: top;
+ overflow: hidden;
+}
+.comment .attribution img {
+ margin: 0;
+ border: 1px solid #d3d7d9;
+}
+.comment .attribution .username {
+ white-space: nowrap;
+}
+.comment .submitted p {
+ margin: 4px 0;
+ font-size: 1.071em;
+ line-height: 1.2;
+}
+.comment .submitted .comment-time {
+ font-size: 0.786em;
+ color: #68696b;
+}
+.comment .submitted .comment-permalink {
+ font-size: 0.786em;
+ text-transform: lowercase;
+}
+.comment .content {
+ font-size: 0.929em;
+ line-height: 1.6;
+}
+.comment .comment-arrow {
+ background: url(../images/comment-arrow.gif) no-repeat 0 center transparent; /* LTR */
+ border-left: 1px solid;
+ border-right: 1px solid;
+ height: 40px;
+ margin-left: -47px; /* LTR */
+ margin-top: 10px;
+ position: absolute;
+ width: 20px;
+}
+.comment .comment-text {
+ padding: 10px 25px;
+ border: 1px solid #d3d7d9;
+ display: table-cell;
+ vertical-align: top;
+ position: relative;
+ width: 100%;
+}
+.comment .indented {
+ margin-left: 40px; /* LTR */
+}
+.comment ul.links {
+ padding: 0 0 0.25em 0;
+}
+.comment ul.links li {
+ padding: 0 0.5em 0 0; /* LTR */
+}
+.comment-unpublished {
+ margin-right: 5px; /* LTR */
+ padding: 5px 2px 5px 5px; /* LTR */
+}
+.comment-unpublished .comment-text .comment-arrow {
+ border-left: 1px solid #fff4f4;
+ border-right: 1px solid #fff4f4;
+}
+
+/* ------------------ Sidebar ----------------- */
+.sidebar .section {
+ padding-top: 10px;
+}
+.sidebar .block {
+ border: 1px solid;
+ padding: 15px 20px;
+ margin: 0 0 20px;
+}
+.sidebar h2 {
+ margin: 0 0 0.5em;
+ border-bottom: 1px solid #d6d6d6;
+ padding-bottom: 5px;
+ text-shadow: 0 1px 0 #fff;
+ font-size: 1.071em;
+ line-height: 1.2;
+}
+.sidebar .block .content {
+ font-size: 0.914em;
+ line-height: 1.4;
+}
+.sidebar tbody {
+ border: none;
+}
+.sidebar tr.even,
+.sidebar tr.odd {
+ background: none;
+ border-bottom: 1px solid #d6d6d6;
+}
+
+/* ----------------- Triptych ----------------- */
+
+#triptych-wrapper {
+ background-color: #f0f0f0;
+ background: rgba(30, 50, 10, 0.08);
+ border-top: 1px solid #e7e7e7;
+}
+#triptych h2 {
+ color: #000;
+ font-size: 1.714em;
+ margin-bottom: 0.8em;
+ text-shadow: 0 1px 0 #fff;
+ text-align: center;
+ line-height: 1;
+}
+#triptych .block {
+ margin-bottom: 2em;
+ padding-bottom: 2em;
+ border-bottom: 1px solid #dfdfdf;
+ line-height: 1.3;
+}
+#triptych .block.last {
+ border-bottom: none;
+}
+#triptych .block ul li,
+#triptych .block ol li {
+ list-style: none;
+}
+#triptych .block ul,
+#triptych .block ol {
+ padding-left: 0;
+}
+#triptych #block-user-login .form-text {
+ width: 185px;
+}
+#triptych #block-user-online p {
+ margin-bottom: 0;
+}
+#triptych #block-node-syndicate h2 {
+ overflow: hidden;
+ width: 0;
+ height: 0;
+}
+#triptych-last #block-node-syndicate {
+ text-align: right;
+}
+#triptych #block-search-form .form-type-textfield input {
+ width: 185px;
+}
+#triptych-middle #block-system-powered-by {
+ text-align: center;
+}
+#triptych-last #block-system-powered-by {
+ text-align: right;
+}
+
+/* ------------------ Footer ------------------ */
+
+#footer-wrapper {
+ color: #c0c0c0;
+ color: rgba(255, 255, 255, 0.65);
+ font-size: 0.857em;
+}
+#footer-wrapper a {
+ color: #fcfcfc;
+ color: rgba(255, 255, 255, 0.8);
+}
+#footer-wrapper a:hover,
+#footer-wrapper a:focus {
+ color: #fefefe;
+ color: rgba(255, 255, 255, 0.95);
+ text-decoration: underline;
+}
+#footer-wrapper .block {
+ margin: 20px 0;
+ border: 1px solid #444;
+ border-color: rgba(255, 255, 255, 0.1);
+ padding: 10px;
+}
+#footer-columns .block-menu,
+#footer .block {
+ margin: 0;
+ padding: 0;
+ border: none;
+}
+#footer .block {
+ margin: 0.5em 0;
+}
+#footer .block .content {
+ padding: 0.5em 0;
+ margin-top: 0;
+}
+#footer .block h2 {
+ margin: 0;
+}
+#footer-columns h2 {
+ border-bottom: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+ font-size: 1em;
+ margin-bottom: 0;
+ padding-bottom: 3px;
+ text-transform: uppercase;
+}
+#footer-columns .content {
+ margin-top: 0;
+}
+#footer-columns p {
+ margin-top: 1em;
+}
+#footer-columns .content ul {
+ list-style: none;
+ padding-left: 0; /* LTR */
+ margin-left: 0;
+}
+#footer-columns .content li {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+#footer-columns .content li a {
+ display: block;
+ border-bottom: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+ line-height: 1.2;
+ padding: 0.8em 2px 0.8em 20px; /* LTR */
+ text-indent: -15px;
+}
+#footer-columns .content li a:hover,
+#footer-columns .content li a:focus {
+ background-color: #1f1f21;
+ background-color: rgba(255, 255, 255, 0.05);
+ text-decoration: none;
+}
+#footer {
+ letter-spacing: 0.2px;
+ margin-top: 30px;
+ border-top: 1px solid #555;
+ border-color: rgba(255, 255, 255, 0.15);
+}
+#footer .region {
+ margin-top: 20px;
+}
+#footer .block {
+ clear: both;
+}
+#footer ul,
+#footer li {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+#footer li a {
+ float: left; /* LTR */
+ padding: 0 12px;
+ display: block;
+ border-right: 1px solid #555; /* LTR */
+ border-color: rgba(255, 255, 255, 0.15);
+}
+#footer li.first a {
+ padding-left: 0; /* LTR */
+}
+#footer li.last a {
+ padding-right: 0; /* LTR */
+ border-right: none; /* LTR */
+}
+#footer-wrapper tr.odd {
+ background-color: transparent;
+}
+#footer-wrapper tr.even {
+ background-color: #2c2c2c;
+ background-color: rgba(0, 0, 0, 0.15);
+}
+
+/* --------------- System Tabs --------------- */
+
+.tabs {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ margin-bottom: 20px;
+}
+.tabs ul.primary {
+ padding: 0 3px;
+ margin: 0;
+ overflow: hidden;
+ border: none;
+ background: transparent url(../images/tabs-border.png) repeat-x left bottom;
+}
+.tabs ul.primary li {
+ display: block;
+ float: left; /* LTR */
+ vertical-align: bottom;
+ margin: 0 5px 0 0; /* LTR */
+}
+.tabs ul.primary li.active a {
+ border-bottom: 1px solid #ffffff;
+}
+.tabs ul.primary li a {
+ color: #000;
+ background-color: #ededed;
+ height: 1.8em;
+ line-height: 1.9;
+ display: block;
+ font-size: 0.929em;
+ float: left; /* not LTR */
+ padding: 0 10px 3px;
+ margin: 0;
+ text-shadow: 0 1px 0 #fff;
+ -khtml-border-radius-topleft: 6px;
+ -moz-border-radius-topleft: 6px;
+ -webkit-border-top-left-radius: 6px;
+ border-top-left-radius: 6px;
+ -khtml-border-radius-topright: 6px;
+ -moz-border-radius-topright: 6px;
+ -webkit-border-top-right-radius: 6px;
+ border-top-right-radius: 6px;
+}
+.tabs ul.primary li.active a {
+ background-color: #ffffff;
+}
+.tabs ul.secondary {
+ border-bottom: none;
+ padding: 0.5em 0;
+}
+.tabs ul.secondary li {
+ display: block;
+ float: left; /* LTR */
+}
+.tabs ul.secondary li:last-child {
+ border-right: none; /* LTR */
+}
+.tabs ul.secondary li:first-child {
+ padding-left: 0; /* LTR */
+}
+.tabs ul.secondary li a {
+ padding: 0.25em 0.5em;
+}
+.tabs ul.secondary li a.active {
+ background: #f2f2f2;
+ border-bottom: none;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ -khtml-border-radius: 5px;
+ border-radius: 5px;
+}
+ul.action-links {
+ list-style: none;
+ margin: 5px;
+ padding: 0.5em 1em;
+}
+ul.action-links li {
+ display: inline-block;
+ margin-left: 10px;
+}
+ul.action-links li a {
+ padding-left: 15px;
+ background: url(../images/add.png) no-repeat left center;
+ margin: 0 10px 0 0;
+}
+
+/* ---------------- Messages ----------------- */
+
+#messages {
+ padding: 20px 0 5px;
+ margin: 0 auto;
+}
+.featured #messages {
+ background: #f0f0f0;
+ background: rgba(30, 50, 10, 0.08);
+}
+div.messages {
+ margin: 8px 15px;
+}
+
+/* -------------- Breadcrumbs -------------- */
+
+.breadcrumb {
+ font-size: 0.929em;
+}
+
+/* -------------- User Profile -------------- */
+
+.profile .user-picture {
+ float: none;
+}
+
+/* -------------- Password Meter ------------- */
+
+.confirm-parent,
+.password-parent {
+ width: 34em;
+}
+.password-parent,
+div.form-item div.password-suggestions {
+ position: relative;
+}
+.password-strength-text,
+.password-strength-title,
+div.password-confirm {
+ font-size: 0.82em;
+}
+.password-strength-text {
+ margin-top: 0.2em;
+}
+div.password-confirm {
+ margin-top: 2.2em;
+ width: 20.73em;
+}
+
+/* ---------------- Buttons ---------------- */
+
+input.form-submit,
+a.button {
+ background: #fff url(../images/buttons.png) 0 0 repeat-x;
+ border: 1px solid #e4e4e4;
+ border-bottom: 1px solid #b4b4b4;
+ border-left-color: #d2d2d2;
+ border-right-color: #d2d2d2;
+ color: #3a3a3a;
+ cursor: pointer;
+ font-size: 0.929em;
+ font-weight: normal;
+ text-align: center;
+ margin-bottom: 1em;
+ margin-right: 0.6em; /* LTR */
+ padding: 4px 17px;
+ -khtml-border-radius: 15px;
+ -moz-border-radius: 20px;
+ -webkit-border-radius: 15px;
+ border-radius: 15px;
+}
+a.button:link,
+a.button:visited,
+a.button:hover,
+a.button:focus,
+a.button:active {
+ text-decoration: none;
+ color: #5a5a5a;
+}
+
+/* -------------- Form Elements ------------- */
+
+fieldset {
+ background: #ffffff;
+ border: 1px solid #cccccc;
+ margin-top: 10px;
+ margin-bottom: 32px;
+ padding: 0 0 10px;
+ position: relative;
+ top: 12px; /* Offsets the negative margin of legends */
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.fieldset-wrapper {
+ margin-top: 25px;
+}
+.node-form .vertical-tabs .fieldset-wrapper {
+ margin-top: 0;
+}
+.filter-wrapper {
+ top: 0;
+ padding: 1em 0 0.2em;
+ -khtml-border-radius-topright: 0;
+ -khtml-border-radius-topleft: 0;
+ -moz-border-radius-topright: 0;
+ -moz-border-radius-topleft: 0;
+ -webkit-border-top-left-radius: 0;
+ -webkit-border-top-right-radius: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+.filter-help a {
+ font-size: 0.857em;
+ padding: 2px 20px 0;
+}
+.filter-wrapper .form-item label {
+ margin-right: 10px;
+}
+.filter-wrapper .form-item {
+ padding: 0 0 0.5em 0.5em;
+}
+.filter-guidelines {
+ padding: 0 1.5em 0 0.5em;
+}
+fieldset.collapsed {
+ background: transparent;
+ -khtml-border-radius: 0;
+ -moz-border-radius: 0;
+ -webkit-border-radius: 0;
+ border-radius: 0;
+}
+fieldset legend {
+ background: #dbdbdb;
+ border: 1px solid #ccc;
+ border-bottom: none;
+ color: #3b3b3b;
+ display: block;
+ height: 2em;
+ left: -1px; /* LTR */
+ font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, sans-serif;
+ line-height: 2;
+ padding: 0;
+ position: absolute;
+ text-indent: 10px;
+ text-shadow: 0 1px 0 #fff;
+ top: -12px;
+ width: 100%;
+ -khtml-border-radius-topleft: 4px;
+ -moz-border-radius-topleft: 4px;
+ -webkit-border-top-left-radius: 4px;
+ border-top-left-radius: 4px;
+ -khtml-border-radius-topright: 4px;
+ -moz-border-radius-topright: 4px;
+ -webkit-border-top-right-radius: 4px;
+ border-top-right-radius: 4px;
+}
+fieldset.collapsed legend {
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+fieldset legend a {
+ color: #3b3b3b;
+}
+fieldset legend a:hover,
+fieldset legend a:focus,
+fieldset legend a:active {
+ color: #000;
+}
+fieldset .fieldset-wrapper {
+ padding: 0 10px;
+}
+fieldset .fieldset-description {
+ margin-top: 5px;
+ margin-bottom: 1em;
+ line-height: 1.4;
+ color: #3c3c3c;
+ font-style: italic;
+}
+input {
+ margin: 2px 0;
+ padding: 4px;
+}
+input,
+textarea {
+ font-size: 0.929em;
+}
+textarea {
+ line-height: 1.5;
+}
+textarea.form-textarea,
+select.form-select {
+ padding: 4px;
+}
+input.form-text,
+textarea.form-textarea,
+select.form-select {
+ border: 1px solid #ccc;
+}
+input.form-submit:hover,
+input.form-submit:focus {
+ background: #dedede;
+}
+.password-suggestions ul li {
+ margin-left: 1.2em; /* LTR */
+}
+.form-item {
+ margin-bottom: 1em;
+ margin-top: 2px;
+}
+.form-item label {
+ font-size: 0.929em;
+}
+.form-type-radio label,
+.form-type-checkbox label {
+ margin-left: 4px;
+}
+.form-type-radio .description,
+.form-type-checkbox .description {
+ margin-left: 2px;
+}
+.form-actions {
+ padding-top: 10px;
+}
+/* Contact Form */
+.contact-form #edit-name {
+ width: 75%;
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.contact-form #edit-mail {
+ width: 75%;
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.contact-form #edit-subject {
+ width: 75%;
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.contact-form #edit-message {
+ width: 76.3%;
+ -khtml-border-top-left-radius: 4px;
+ -khtml-border-top-right-radius: 4px;
+ -moz-border-radius-topleft: 4px;
+ -moz-border-radius-topright: 4px;
+ -webkit-border-top-left-radius: 4px;
+ -webkit-border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+.contact-form .resizable-textarea .grippie {
+ width: 76%;
+ -khtml-border-bottom-left-radius: 4px;
+ -khtml-border-bottom-right-radius: 4px;
+ -moz-border-radius-bottomleft: 4px;
+ -moz-border-radius-bottomright: 4px;
+ -webkit-border-bottom-left-radius: 4px;
+ -webkit-border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+/* Disabled form elements */
+input.form-button-disabled,
+input.form-button-disabled:hover,
+input.form-button-disabled:focus,
+input.form-button-disabled:active,
+.form-disabled input,
+.form-disabled select,
+.form-disabled textarea {
+ background: #ededed;
+ border-color: #bbb;
+ color: #717171;
+}
+.form-disabled .grippie {
+ background-color: #ededed;
+ border-color: #bbb;
+}
+.form-disabled label {
+ color: #717171;
+}
+
+/* Animated throbber */
+html.js input.form-autocomplete {
+ background-position: 100% 4px; /* LTR */
+}
+html.js input.throbbing {
+ background-position: 100% -16px; /* LTR */
+}
+
+/* Comment form */
+.comment-form label {
+ float: left; /* LTR */
+ font-size: 0.929em;
+ width: 120px;
+}
+.comment-form input,
+.comment-form .form-select {
+ margin: 0;
+ -khtml-border-radius: 4px;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.comment-form .form-type-textarea label {
+ float: none;
+}
+.comment-form .form-item,
+.comment-form .form-radios,
+.comment-form .form-type-checkbox,
+.comment-form .form-select {
+ margin-bottom: 10px;
+ overflow: hidden;
+}
+.comment-form .form-type-checkbox,
+.comment-form .form-radios {
+ margin-left: 120px; /* LTR */
+}
+.comment-form .form-type-checkbox label,
+.comment-form .form-radios label {
+ float: none;
+ margin-top: 0;
+}
+.comment-form input.form-file {
+ width: auto;
+}
+.no-sidebars .comment-form .form-text {
+ width: 800px;
+}
+.one-sidebar .comment-form .form-text {
+ width: 500px;
+}
+.two-sidebars .comment-form .form-text {
+ width: 320px;
+}
+.comment-form .form-item .description {
+ font-size: 0.786em;
+ line-height: 1.2;
+ margin-left: 120px; /* LTR */
+}
+#content h2.comment-form {
+ margin-bottom: 0.5em;
+}
+.comment-form .form-textarea {
+ -khtml-border-radius-topleft: 4px;
+ -khtml-border-radius-topright: 4px;
+ -moz-border-radius-topleft: 4px;
+ -moz-border-radius-topright: 4px;
+ -webkit-border-top-left-radius: 4px;
+ -webkit-border-top-right-radius: 4px;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+.comment-form fieldset.filter-wrapper .fieldset-wrapper,
+.comment-form .text-format-wrapper .form-item {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.filter-wrapper label {
+ width: auto;
+ float: none;
+}
+.filter-wrapper .form-select {
+ min-width: 120px;
+}
+.comment-form fieldset.filter-wrapper .tips {
+ font-size: 0.786em;
+}
+#comment-body-add-more-wrapper .form-type-textarea label {
+ margin-bottom: 0.4em;
+}
+#edit-actions input {
+ margin-right: 0.6em; /* LTR */
+}
+
+/* -------------- Other Overrides ------------- */
+
+div.password-suggestions {
+ border: 0;
+}
+.ui-widget-overlay {
+ background: #222222;
+ opacity: 0.7;
+}
+div.vertical-tabs .vertical-tabs-panes fieldset.vertical-tabs-pane {
+ padding: 1em;
+}
+#forum .name {
+ font-size: 1.083em;
+}
+#forum .description {
+ font-size: 1em;
+}
+
+/* --------------- Search Form ---------------- */
+
+#block-search-form {
+ padding-bottom: 7px;
+}
+#block-search-form .content {
+ margin-top: 0;
+}
+#search-form input#edit-keys,
+#block-search-form .form-item-search-block-form input {
+ float: left; /* LTR */
+ font-size: 1em;
+ height: 1.143em;
+ margin-right: 5px;
+ width: 9em;
+}
+#search-block-form input.form-submit,
+#search-form input.form-submit {
+ margin-left: 0;
+ margin-right: 0;
+ height: 25px;
+ width: 34px;
+ padding: 0;
+ cursor: pointer;
+ text-indent: -9999px;
+ border-color: #e4e4e4 #d2d2d2 #b4b4b4;
+ background: url(../images/search-button.png) no-repeat center top;
+ overflow: hidden;
+}
+#search-block-form input.form-submit:hover,
+#search-block-form input.form-submit:focus,
+#search-form input.form-submit:hover,
+#search-form input.form-submit:focus {
+ background-position: center bottom;
+}
+#search-form .form-item-keys label {
+ display: block;
+}
+
+/* --------------- Search Results ---------------- */
+ol.search-results {
+ padding-left: 0;
+}
+.search-results li {
+ border-bottom: 1px solid #d3d7d9;
+ padding-bottom: 0.4285em;
+ margin-bottom: 0.5em;
+}
+.search-results li:last-child {
+ border-bottom: none;
+ padding-bottom: none;
+ margin-bottom: 1em;
+}
+.search-results .search-snippet-info {
+ padding-left: 0;
+}
+
+/* -------------- Shortcut Links -------------- */
+
+.shortcut-wrapper {
+ margin: 2.2em 0 1.1em 0; /* Same as usual h1#page-title margin. */
+}
+.shortcut-wrapper h1#page-title {
+ float: left; /* LTR */
+ margin: 0;
+}
+div.add-or-remove-shortcuts {
+ padding-top: 0.9em;
+}
+.overlay div.add-or-remove-shortcuts {
+ padding-top: 0.8em;
+}
+
+/* ---------- Admin-specific Theming ---------- */
+
+.page-admin #content img {
+ margin-right: 15px; /* LTR */
+}
+.page-admin #content .simpletest-image img {
+ margin: 0;
+}
+.page-admin-structure-block-demo .block-region {
+ background: #ffff66;
+ border: 1px dotted #9f9e00;
+ color: #000;
+ font: 90% "Lucida Grande", "Lucida Sans Unicode", sans-serif;
+ margin: 5px;
+ padding: 5px;
+ text-align: center;
+ text-shadow: none;
+}
+.page-admin-structure-block-demo #featured .block-region {
+ font-size: 0.55em;
+}
+.page-admin-structure-block-demo #header .block-region {
+ width: 500px;
+}
+.page-admin #admin-dblog img {
+ margin: 0 5px;
+}
+/* Fix spacing when Seven is used in the overlay. */
+#system-theme-settings fieldset {
+ padding: 0;
+}
+#system-theme-settings fieldset .fieldset-legend {
+ margin-top: 0;
+}
+/* Configuration. */
+div.admin .right,
+div.admin .left {
+ width: 49%;
+ margin: 0;
+}
+div.admin-panel {
+ background: #fbfbfb;
+ border: 1px solid #ccc;
+ margin: 10px 0;
+ padding: 0 5px 5px;
+}
+div.admin-panel h3 {
+ margin: 16px 7px;
+}
+div.admin-panel dt {
+ border-top: 1px solid #ccc;
+ padding: 7px 0 0;
+}
+div.admin-panel dd {
+ margin: 0 0 10px;
+}
+div.admin-panel .description {
+ margin: 0 0 14px 7px;
+}
+
+/* ---------- Overlay layout styles ----------- */
+
+.overlay #main,
+.overlay #content {
+ width: auto;
+ float: none;
+}
+.overlay #page {
+ padding: 0 2em;
+}
+.overlay .region-page-top,
+.overlay #header,
+.overlay #page-title,
+.overlay #featured,
+.overlay #sidebar-first,
+.overlay #triptych-wrapper,
+.overlay #footer-wrapper {
+ display: none;
+}
+.overlay-processed .field-type-image {
+ display: block;
+ float: none;
+}
+.overlay #messages {
+ width: auto;
+}
+
+/* ---------- Poll ----------- */
+
+.node .poll {
+ margin: 2em 0;
+}
+.node .poll #edit-choice {
+ margin: 0 0 1.5em;
+}
+.poll .vote-form {
+ text-align: left; /* LTR */
+}
+.poll .vote-form .choices {
+ margin: 0;
+}
+.poll .percent {
+ font-size: 0.857em;
+ font-style: italic;
+ margin-bottom: 3em;
+ margin-top: -3.2em;
+ float: right;
+ text-align: right;
+}
+.poll .text {
+ clear: right;
+ margin-right: 2.25em;
+}
+.poll .total {
+ font-size: 0.929em;
+ font-style: italic;
+ text-align: right; /* LTR */
+ clear: both;
+}
+.node .poll {
+ margin: 1.8em 0 0;
+}
+.node .poll .text {
+ margin-right: 6.75em;
+}
+.node .poll #edit-choice {
+ margin: 0 0 1.2em;
+}
+.poll .bar .foreground {
+ background-color: #666;
+}
+#footer-wrapper .poll .bar {
+ background-color: #666;
+}
+#footer-wrapper .poll .bar .foreground {
+ background-color: #ddd;
+}
diff --git a/core/themes/bartik/images/add.png b/core/themes/bartik/images/add.png
new file mode 100644
index 000000000000..3e167ebd0fea
--- /dev/null
+++ b/core/themes/bartik/images/add.png
Binary files differ
diff --git a/core/themes/bartik/images/buttons.png b/core/themes/bartik/images/buttons.png
new file mode 100644
index 000000000000..c4b6df58d7ca
--- /dev/null
+++ b/core/themes/bartik/images/buttons.png
Binary files differ
diff --git a/core/themes/bartik/images/comment-arrow-rtl.gif b/core/themes/bartik/images/comment-arrow-rtl.gif
new file mode 100644
index 000000000000..b597e6587f84
--- /dev/null
+++ b/core/themes/bartik/images/comment-arrow-rtl.gif
Binary files differ
diff --git a/core/themes/bartik/images/comment-arrow.gif b/core/themes/bartik/images/comment-arrow.gif
new file mode 100644
index 000000000000..ce48d0ccd97a
--- /dev/null
+++ b/core/themes/bartik/images/comment-arrow.gif
Binary files differ
diff --git a/core/themes/bartik/images/search-button.png b/core/themes/bartik/images/search-button.png
new file mode 100644
index 000000000000..c6e820ad9f6a
--- /dev/null
+++ b/core/themes/bartik/images/search-button.png
Binary files differ
diff --git a/core/themes/bartik/images/tabs-border.png b/core/themes/bartik/images/tabs-border.png
new file mode 100644
index 000000000000..25f95356a62c
--- /dev/null
+++ b/core/themes/bartik/images/tabs-border.png
Binary files differ
diff --git a/core/themes/bartik/logo.png b/core/themes/bartik/logo.png
new file mode 100644
index 000000000000..0ada45326367
--- /dev/null
+++ b/core/themes/bartik/logo.png
Binary files differ
diff --git a/core/themes/bartik/screenshot.png b/core/themes/bartik/screenshot.png
new file mode 100644
index 000000000000..34734efa6829
--- /dev/null
+++ b/core/themes/bartik/screenshot.png
Binary files differ
diff --git a/core/themes/bartik/template.php b/core/themes/bartik/template.php
new file mode 100644
index 000000000000..8d2b8c4e316c
--- /dev/null
+++ b/core/themes/bartik/template.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * Add body classes if certain regions have content.
+ */
+function bartik_preprocess_html(&$variables) {
+ if (!empty($variables['page']['featured'])) {
+ $variables['classes_array'][] = 'featured';
+ }
+
+ if (!empty($variables['page']['triptych_first'])
+ || !empty($variables['page']['triptych_middle'])
+ || !empty($variables['page']['triptych_last'])) {
+ $variables['classes_array'][] = 'triptych';
+ }
+
+ if (!empty($variables['page']['footer_firstcolumn'])
+ || !empty($variables['page']['footer_secondcolumn'])
+ || !empty($variables['page']['footer_thirdcolumn'])
+ || !empty($variables['page']['footer_fourthcolumn'])) {
+ $variables['classes_array'][] = 'footer-columns';
+ }
+
+ // Add conditional stylesheets for IE
+ drupal_add_css(path_to_theme() . '/css/ie.css', array('group' => CSS_THEME, 'browsers' => array('IE' => 'lte IE 7', '!IE' => FALSE), 'preprocess' => FALSE));
+}
+
+/**
+ * Override or insert variables into the page template for HTML output.
+ */
+function bartik_process_html(&$variables) {
+ // Hook into color.module.
+ if (module_exists('color')) {
+ _color_html_alter($variables);
+ }
+}
+
+/**
+ * Override or insert variables into the page template.
+ */
+function bartik_process_page(&$variables) {
+ // Hook into color.module.
+ if (module_exists('color')) {
+ _color_page_alter($variables);
+ }
+ // Always print the site name and slogan, but if they are toggled off, we'll
+ // just hide them visually.
+ $variables['hide_site_name'] = theme_get_setting('toggle_name') ? FALSE : TRUE;
+ $variables['hide_site_slogan'] = theme_get_setting('toggle_slogan') ? FALSE : TRUE;
+ if ($variables['hide_site_name']) {
+ // If toggle_name is FALSE, the site_name will be empty, so we rebuild it.
+ $variables['site_name'] = filter_xss_admin(variable_get('site_name', 'Drupal'));
+ }
+ if ($variables['hide_site_slogan']) {
+ // If toggle_site_slogan is FALSE, the site_slogan will be empty, so we rebuild it.
+ $variables['site_slogan'] = filter_xss_admin(variable_get('site_slogan', ''));
+ }
+ // Since the title and the shortcut link are both block level elements,
+ // positioning them next to each other is much simpler with a wrapper div.
+ if (!empty($variables['title_suffix']['add_or_remove_shortcut']) && $variables['title']) {
+ // Add a wrapper div using the title_prefix and title_suffix render elements.
+ $variables['title_prefix']['shortcut_wrapper'] = array(
+ '#markup' => '<div class="shortcut-wrapper clearfix">',
+ '#weight' => 100,
+ );
+ $variables['title_suffix']['shortcut_wrapper'] = array(
+ '#markup' => '</div>',
+ '#weight' => -99,
+ );
+ // Make sure the shortcut link is the first item in title_suffix.
+ $variables['title_suffix']['add_or_remove_shortcut']['#weight'] = -100;
+ }
+}
+
+/**
+ * Implements hook_preprocess_maintenance_page().
+ */
+function bartik_preprocess_maintenance_page(&$variables) {
+ if (!$variables['db_is_active']) {
+ unset($variables['site_name']);
+ }
+ drupal_add_css(drupal_get_path('theme', 'bartik') . '/css/maintenance-page.css');
+}
+
+/**
+ * Override or insert variables into the maintenance page template.
+ */
+function bartik_process_maintenance_page(&$variables) {
+ // Always print the site name and slogan, but if they are toggled off, we'll
+ // just hide them visually.
+ $variables['hide_site_name'] = theme_get_setting('toggle_name') ? FALSE : TRUE;
+ $variables['hide_site_slogan'] = theme_get_setting('toggle_slogan') ? FALSE : TRUE;
+ if ($variables['hide_site_name']) {
+ // If toggle_name is FALSE, the site_name will be empty, so we rebuild it.
+ $variables['site_name'] = filter_xss_admin(variable_get('site_name', 'Drupal'));
+ }
+ if ($variables['hide_site_slogan']) {
+ // If toggle_site_slogan is FALSE, the site_slogan will be empty, so we rebuild it.
+ $variables['site_slogan'] = filter_xss_admin(variable_get('site_slogan', ''));
+ }
+}
+
+/**
+ * Override or insert variables into the node template.
+ */
+function bartik_preprocess_node(&$variables) {
+ if ($variables['view_mode'] == 'full' && node_is_page($variables['node'])) {
+ $variables['classes_array'][] = 'node-full';
+ }
+}
+
+/**
+ * Override or insert variables into the block template.
+ */
+function bartik_preprocess_block(&$variables) {
+ // In the header region visually hide block titles.
+ if ($variables['block']->region == 'header') {
+ $variables['title_attributes_array']['class'][] = 'element-invisible';
+ }
+}
+
+/**
+ * Implements theme_menu_tree().
+ */
+function bartik_menu_tree($variables) {
+ return '<ul class="menu clearfix">' . $variables['tree'] . '</ul>';
+}
+
+/**
+ * Implements theme_field__field_type().
+ */
+function bartik_field__taxonomy_term_reference($variables) {
+ $output = '';
+
+ // Render the label, if it's not hidden.
+ if (!$variables['label_hidden']) {
+ $output .= '<h3 class="field-label">' . $variables['label'] . ': </h3>';
+ }
+
+ // Render the items.
+ $output .= ($variables['element']['#label_display'] == 'inline') ? '<ul class="links inline">' : '<ul class="links">';
+ foreach ($variables['items'] as $delta => $item) {
+ $output .= '<li class="taxonomy-term-reference-' . $delta . '"' . $variables['item_attributes'][$delta] . '>' . drupal_render($item) . '</li>';
+ }
+ $output .= '</ul>';
+
+ // Render the top-level DIV.
+ $output = '<div class="' . $variables['classes'] . (!in_array('clearfix', $variables['classes_array']) ? ' clearfix' : '') . '">' . $output . '</div>';
+
+ return $output;
+}
diff --git a/core/themes/bartik/templates/comment-wrapper.tpl.php b/core/themes/bartik/templates/comment-wrapper.tpl.php
new file mode 100644
index 000000000000..864dc41c4086
--- /dev/null
+++ b/core/themes/bartik/templates/comment-wrapper.tpl.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @file
+ * Bartik's theme implementation to provide an HTML container for comments.
+ *
+ * Available variables:
+ * - $content: The array of content-related elements for the node. Use
+ * render($content) to print them all, or
+ * print a subset such as render($content['comment_form']).
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default value has the following:
+ * - comment-wrapper: The current template type, i.e., "theming hook".
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * The following variables are provided for contextual information.
+ * - $node: Node object the comments are attached to.
+ * The constants below the variables show the possible values and should be
+ * used for comparison.
+ * - $display_mode
+ * - COMMENT_MODE_FLAT
+ * - COMMENT_MODE_THREADED
+ *
+ * Other variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess_comment_wrapper()
+ * @see theme_comment_wrapper()
+ */
+?>
+<div id="comments" class="<?php print $classes; ?>"<?php print $attributes; ?>>
+ <?php if ($content['comments'] && $node->type != 'forum'): ?>
+ <?php print render($title_prefix); ?>
+ <h2 class="title"><?php print t('Comments'); ?></h2>
+ <?php print render($title_suffix); ?>
+ <?php endif; ?>
+
+ <?php print render($content['comments']); ?>
+
+ <?php if ($content['comment_form']): ?>
+ <h2 class="title comment-form"><?php print t('Add new comment'); ?></h2>
+ <?php print render($content['comment_form']); ?>
+ <?php endif; ?>
+</div>
diff --git a/core/themes/bartik/templates/comment.tpl.php b/core/themes/bartik/templates/comment.tpl.php
new file mode 100644
index 000000000000..d64487d0ea84
--- /dev/null
+++ b/core/themes/bartik/templates/comment.tpl.php
@@ -0,0 +1,105 @@
+<?php
+
+/**
+ * @file
+ * Bartik's theme implementation for comments.
+ *
+ * Available variables:
+ * - $author: Comment author. Can be link or plain text.
+ * - $content: An array of comment items. Use render($content) to print them all, or
+ * print a subset such as render($content['field_example']). Use
+ * hide($content['field_example']) to temporarily suppress the printing of a
+ * given element.
+ * - $created: Formatted date and time for when the comment was created.
+ * Preprocess functions can reformat it by calling format_date() with the
+ * desired parameters on the $comment->created variable.
+ * - $changed: Formatted date and time for when the comment was last changed.
+ * Preprocess functions can reformat it by calling format_date() with the
+ * desired parameters on the $comment->changed variable.
+ * - $new: New comment marker.
+ * - $permalink: Comment permalink.
+ * - $submitted: Submission information created from $author and $created during
+ * template_preprocess_comment().
+ * - $picture: Authors picture.
+ * - $signature: Authors signature.
+ * - $status: Comment status. Possible values are:
+ * comment-unpublished, comment-published or comment-preview.
+ * - $title: Linked title.
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the following:
+ * - comment: The current template type, i.e., "theming hook".
+ * - comment-by-anonymous: Comment by an unregistered user.
+ * - comment-by-node-author: Comment by the author of the parent node.
+ * - comment-preview: When previewing a new or edited comment.
+ * The following applies only to viewers who are registered users:
+ * - comment-unpublished: An unpublished comment visible only to administrators.
+ * - comment-by-viewer: Comment by the user currently viewing the page.
+ * - comment-new: New comment since last the visit.
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * These two variables are provided for context:
+ * - $comment: Full comment object.
+ * - $node: Node object the comments are attached to.
+ *
+ * Other variables:
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_comment()
+ * @see template_process()
+ * @see theme_comment()
+ */
+?>
+<div class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
+
+ <div class="attribution">
+
+ <?php print $picture; ?>
+
+ <div class="submitted">
+ <p class="commenter-name">
+ <?php print $author; ?>
+ </p>
+ <p class="comment-time">
+ <?php print $created; ?>
+ </p>
+ <p class="comment-permalink">
+ <?php print $permalink; ?>
+ </p>
+ </div>
+ </div>
+
+ <div class="comment-text">
+ <div class="comment-arrow"></div>
+
+ <?php if ($new): ?>
+ <span class="new"><?php print $new; ?></span>
+ <?php endif; ?>
+
+ <?php print render($title_prefix); ?>
+ <h3<?php print $title_attributes; ?>><?php print $title; ?></h3>
+ <?php print render($title_suffix); ?>
+
+ <div class="content"<?php print $content_attributes; ?>>
+ <?php
+ // We hide the comments and links now so that we can render them later.
+ hide($content['links']);
+ print render($content);
+ ?>
+ <?php if ($signature): ?>
+ <div class="user-signature clearfix">
+ <?php print $signature; ?>
+ </div>
+ <?php endif; ?>
+ </div> <!-- /.content -->
+
+ <?php print render($content['links']); ?>
+ </div> <!-- /.comment-text -->
+</div>
diff --git a/core/themes/bartik/templates/maintenance-page.tpl.php b/core/themes/bartik/templates/maintenance-page.tpl.php
new file mode 100644
index 000000000000..6deef1e3f2ae
--- /dev/null
+++ b/core/themes/bartik/templates/maintenance-page.tpl.php
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * @file
+ * Implementation to display a single Drupal page while offline.
+ *
+ * All the available variables are mirrored in page.tpl.php.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_maintenance_page()
+ * @see bartik_process_maintenance_page()
+ */
+?>
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php print $language->language; ?>" lang="<?php print $language->language; ?>" dir="<?php print $language->dir; ?>">
+<head>
+ <?php print $head; ?>
+ <title><?php print $head_title; ?></title>
+ <?php print $styles; ?>
+ <?php print $scripts; ?>
+</head>
+<body class="<?php print $classes; ?>" <?php print $attributes;?>>
+
+ <div id="skip-link">
+ <a href="#main-content" class="element-invisible element-focusable"><?php print t('Skip to main content'); ?></a>
+ </div>
+
+ <div id="page-wrapper"><div id="page">
+
+ <div id="header"><div class="section clearfix">
+ <?php if ($site_name || $site_slogan): ?>
+ <div id="name-and-slogan"<?php if ($hide_site_name && $hide_site_slogan) { print ' class="element-invisible"'; } ?>>
+ <?php if ($site_name): ?>
+ <div id="site-name"<?php if ($hide_site_name) { print ' class="element-invisible"'; } ?>>
+ <strong>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </strong>
+ </div>
+ <?php endif; ?>
+ <?php if ($site_slogan): ?>
+ <div id="site-slogan"<?php if ($hide_site_slogan) { print ' class="element-invisible"'; } ?>>
+ <?php print $site_slogan; ?>
+ </div>
+ <?php endif; ?>
+ </div> <!-- /#name-and-slogan -->
+ <?php endif; ?>
+ </div></div> <!-- /.section, /#header -->
+
+ <div id="main-wrapper"><div id="main" class="clearfix">
+ <div id="content" class="column"><div class="section">
+ <a id="main-content"></a>
+ <?php if ($title): ?><h1 class="title" id="page-title"><?php print $title; ?></h1><?php endif; ?>
+ <?php print $content; ?>
+ <?php if ($messages): ?>
+ <div id="messages"><div class="section clearfix">
+ <?php print $messages; ?>
+ </div></div> <!-- /.section, /#messages -->
+ <?php endif; ?>
+ </div></div> <!-- /.section, /#content -->
+ </div></div> <!-- /#main, /#main-wrapper -->
+
+ </div></div> <!-- /#page, /#page-wrapper -->
+
+</body>
+</html>
diff --git a/core/themes/bartik/templates/node.tpl.php b/core/themes/bartik/templates/node.tpl.php
new file mode 100644
index 000000000000..234b8994ab5e
--- /dev/null
+++ b/core/themes/bartik/templates/node.tpl.php
@@ -0,0 +1,124 @@
+<?php
+
+/**
+ * @file
+ * Bartik's theme implementation to display a node.
+ *
+ * Available variables:
+ * - $title: the (sanitized) title of the node.
+ * - $content: An array of node items. Use render($content) to print them all,
+ * or print a subset such as render($content['field_example']). Use
+ * hide($content['field_example']) to temporarily suppress the printing of a
+ * given element.
+ * - $user_picture: The node author's picture from user-picture.tpl.php.
+ * - $date: Formatted creation date. Preprocess functions can reformat it by
+ * calling format_date() with the desired parameters on the $created variable.
+ * - $name: Themed username of node author output from theme_username().
+ * - $node_url: Direct url of the current node.
+ * - $display_submitted: Whether submission information should be displayed.
+ * - $submitted: Submission information created from $name and $date during
+ * template_preprocess_node().
+ * - $classes: String of classes that can be used to style contextually through
+ * CSS. It can be manipulated through the variable $classes_array from
+ * preprocess functions. The default values can be one or more of the
+ * following:
+ * - node: The current template type, i.e., "theming hook".
+ * - node-[type]: The current node type. For example, if the node is a
+ * "Article" it would result in "node-article". Note that the machine
+ * name will often be in a short form of the human readable label.
+ * - node-teaser: Nodes in teaser form.
+ * - node-preview: Nodes in preview mode.
+ * The following are controlled through the node publishing options.
+ * - node-promoted: Nodes promoted to the front page.
+ * - node-sticky: Nodes ordered above other non-sticky nodes in teaser
+ * listings.
+ * - node-unpublished: Unpublished nodes visible only to administrators.
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ *
+ * Other variables:
+ * - $node: Full node object. Contains data that may not be safe.
+ * - $type: Node type, i.e. page, article, etc.
+ * - $comment_count: Number of comments attached to the node.
+ * - $uid: User ID of the node author.
+ * - $created: Time the node was published formatted in Unix timestamp.
+ * - $classes_array: Array of html class attribute values. It is flattened
+ * into a string within the variable $classes.
+ * - $zebra: Outputs either "even" or "odd". Useful for zebra striping in
+ * teaser listings.
+ * - $id: Position of the node. Increments each time it's output.
+ *
+ * Node status variables:
+ * - $view_mode: View mode, e.g. 'full', 'teaser'...
+ * - $teaser: Flag for the teaser state (shortcut for $view_mode == 'teaser').
+ * - $page: Flag for the full page state.
+ * - $promote: Flag for front page promotion state.
+ * - $sticky: Flags for sticky post setting.
+ * - $status: Flag for published status.
+ * - $comment: State of comment settings for the node.
+ * - $readmore: Flags true if the teaser content of the node cannot hold the
+ * main body content.
+ * - $is_front: Flags true when presented in the front page.
+ * - $logged_in: Flags true when the current user is a logged-in member.
+ * - $is_admin: Flags true when the current user is an administrator.
+ *
+ * Field variables: for each field instance attached to the node a corresponding
+ * variable is defined, e.g. $node->body becomes $body. When needing to access
+ * a field's raw values, developers/themers are strongly encouraged to use these
+ * variables. Otherwise they will have to explicitly specify the desired field
+ * language, e.g. $node->body['en'], thus overriding any language negotiation
+ * rule that was previously applied.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_node()
+ * @see template_process()
+ */
+?>
+<div id="node-<?php print $node->nid; ?>" class="<?php print $classes; ?> clearfix"<?php print $attributes; ?>>
+
+ <?php print render($title_prefix); ?>
+ <?php if (!$page): ?>
+ <h2<?php print $title_attributes; ?>>
+ <a href="<?php print $node_url; ?>"><?php print $title; ?></a>
+ </h2>
+ <?php endif; ?>
+ <?php print render($title_suffix); ?>
+
+ <?php if ($display_submitted): ?>
+ <div class="meta submitted">
+ <?php print $user_picture; ?>
+ <?php print $submitted; ?>
+ </div>
+ <?php endif; ?>
+
+ <div class="content clearfix"<?php print $content_attributes; ?>>
+ <?php
+ // We hide the comments and links now so that we can render them later.
+ hide($content['comments']);
+ hide($content['links']);
+ print render($content);
+ ?>
+ </div>
+
+ <?php
+ // Remove the "Add new comment" link on the teaser page or if the comment
+ // form is being displayed on the same page.
+ if ($teaser || !empty($content['comments']['comment_form'])) {
+ unset($content['links']['comment']['#links']['comment-add']);
+ }
+ // Only display the wrapper div if there are links.
+ $links = render($content['links']);
+ if ($links):
+ ?>
+ <div class="link-wrapper">
+ <?php print $links; ?>
+ </div>
+ <?php endif; ?>
+
+ <?php print render($content['comments']); ?>
+
+</div>
diff --git a/core/themes/bartik/templates/page.tpl.php b/core/themes/bartik/templates/page.tpl.php
new file mode 100644
index 000000000000..7b0f99097a0e
--- /dev/null
+++ b/core/themes/bartik/templates/page.tpl.php
@@ -0,0 +1,246 @@
+<?php
+
+/**
+ * @file
+ * Bartik's theme implementation to display a single Drupal page.
+ *
+ * The doctype, html, head and body tags are not in this template. Instead they
+ * can be found in the html.tpl.php template normally located in the
+ * modules/system folder.
+ *
+ * Available variables:
+ *
+ * General utility variables:
+ * - $base_path: The base URL path of the Drupal installation. At the very
+ * least, this will always default to /.
+ * - $directory: The directory the template is located in, e.g. modules/system
+ * or themes/bartik.
+ * - $is_front: TRUE if the current page is the front page.
+ * - $logged_in: TRUE if the user is registered and signed in.
+ * - $is_admin: TRUE if the user has permission to access administration pages.
+ *
+ * Site identity:
+ * - $front_page: The URL of the front page. Use this instead of $base_path,
+ * when linking to the front page. This includes the language domain or
+ * prefix.
+ * - $logo: The path to the logo image, as defined in theme configuration.
+ * - $site_name: The name of the site, empty when display has been disabled
+ * in theme settings.
+ * - $site_slogan: The slogan of the site, empty when display has been disabled
+ * in theme settings.
+ * - $hide_site_name: TRUE if the site name has been toggled off on the theme
+ * settings page. If hidden, the "element-invisible" class is added to make
+ * the site name visually hidden, but still accessible.
+ * - $hide_site_slogan: TRUE if the site slogan has been toggled off on the
+ * theme settings page. If hidden, the "element-invisible" class is added to
+ * make the site slogan visually hidden, but still accessible.
+ *
+ * Navigation:
+ * - $main_menu (array): An array containing the Main menu links for the
+ * site, if they have been configured.
+ * - $secondary_menu (array): An array containing the Secondary menu links for
+ * the site, if they have been configured.
+ * - $breadcrumb: The breadcrumb trail for the current page.
+ *
+ * Page content (in order of occurrence in the default page.tpl.php):
+ * - $title_prefix (array): An array containing additional output populated by
+ * modules, intended to be displayed in front of the main title tag that
+ * appears in the template.
+ * - $title: The page title, for use in the actual HTML content.
+ * - $title_suffix (array): An array containing additional output populated by
+ * modules, intended to be displayed after the main title tag that appears in
+ * the template.
+ * - $messages: HTML for status and error messages. Should be displayed
+ * prominently.
+ * - $tabs (array): Tabs linking to any sub-pages beneath the current page
+ * (e.g., the view and edit tabs when displaying a node).
+ * - $action_links (array): Actions local to the page, such as 'Add menu' on the
+ * menu administration interface.
+ * - $feed_icons: A string of all feed icons for the current page.
+ * - $node: The node object, if there is an automatically-loaded node
+ * associated with the page, and the node ID is the second argument
+ * in the page's path (e.g. node/12345 and node/12345/revisions, but not
+ * comment/reply/12345).
+ *
+ * Regions:
+ * - $page['header']: Items for the header region.
+ * - $page['featured']: Items for the featured region.
+ * - $page['highlighted']: Items for the highlighted content region.
+ * - $page['help']: Dynamic help text, mostly for admin pages.
+ * - $page['content']: The main content of the current page.
+ * - $page['sidebar_first']: Items for the first sidebar.
+ * - $page['triptych_first']: Items for the first triptych.
+ * - $page['triptych_middle']: Items for the middle triptych.
+ * - $page['triptych_last']: Items for the last triptych.
+ * - $page['footer_firstcolumn']: Items for the first footer column.
+ * - $page['footer_secondcolumn']: Items for the second footer column.
+ * - $page['footer_thirdcolumn']: Items for the third footer column.
+ * - $page['footer_fourthcolumn']: Items for the fourth footer column.
+ * - $page['footer']: Items for the footer region.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_page()
+ * @see template_process()
+ * @see bartik_process_page()
+ */
+?>
+<div id="page-wrapper"><div id="page">
+
+ <div id="header" class="<?php print $secondary_menu ? 'with-secondary-menu': 'without-secondary-menu'; ?>"><div class="section clearfix">
+
+ <?php if ($logo): ?>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home" id="logo">
+ <img src="<?php print $logo; ?>" alt="<?php print t('Home'); ?>" />
+ </a>
+ <?php endif; ?>
+
+ <?php if ($site_name || $site_slogan): ?>
+ <div id="name-and-slogan"<?php if ($hide_site_name && $hide_site_slogan) { print ' class="element-invisible"'; } ?>>
+
+ <?php if ($site_name): ?>
+ <?php if ($title): ?>
+ <div id="site-name"<?php if ($hide_site_name) { print ' class="element-invisible"'; } ?>>
+ <strong>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </strong>
+ </div>
+ <?php else: /* Use h1 when the content title is empty */ ?>
+ <h1 id="site-name"<?php if ($hide_site_name) { print ' class="element-invisible"'; } ?>>
+ <a href="<?php print $front_page; ?>" title="<?php print t('Home'); ?>" rel="home"><span><?php print $site_name; ?></span></a>
+ </h1>
+ <?php endif; ?>
+ <?php endif; ?>
+
+ <?php if ($site_slogan): ?>
+ <div id="site-slogan"<?php if ($hide_site_slogan) { print ' class="element-invisible"'; } ?>>
+ <?php print $site_slogan; ?>
+ </div>
+ <?php endif; ?>
+
+ </div> <!-- /#name-and-slogan -->
+ <?php endif; ?>
+
+ <?php print render($page['header']); ?>
+
+ <?php if ($main_menu): ?>
+ <div id="main-menu" class="navigation">
+ <?php print theme('links__system_main_menu', array(
+ 'links' => $main_menu,
+ 'attributes' => array(
+ 'id' => 'main-menu-links',
+ 'class' => array('links', 'clearfix'),
+ ),
+ 'heading' => array(
+ 'text' => t('Main menu'),
+ 'level' => 'h2',
+ 'class' => array('element-invisible'),
+ ),
+ )); ?>
+ </div> <!-- /#main-menu -->
+ <?php endif; ?>
+
+ <?php if ($secondary_menu): ?>
+ <div id="secondary-menu" class="navigation">
+ <?php print theme('links__system_secondary_menu', array(
+ 'links' => $secondary_menu,
+ 'attributes' => array(
+ 'id' => 'secondary-menu-links',
+ 'class' => array('links', 'inline', 'clearfix'),
+ ),
+ 'heading' => array(
+ 'text' => t('Secondary menu'),
+ 'level' => 'h2',
+ 'class' => array('element-invisible'),
+ ),
+ )); ?>
+ </div> <!-- /#secondary-menu -->
+ <?php endif; ?>
+
+ </div></div> <!-- /.section, /#header -->
+
+ <?php if ($messages): ?>
+ <div id="messages"><div class="section clearfix">
+ <?php print $messages; ?>
+ </div></div> <!-- /.section, /#messages -->
+ <?php endif; ?>
+
+ <?php if ($page['featured']): ?>
+ <div id="featured"><div class="section clearfix">
+ <?php print render($page['featured']); ?>
+ </div></div> <!-- /.section, /#featured -->
+ <?php endif; ?>
+
+ <div id="main-wrapper" class="clearfix"><div id="main" class="clearfix">
+
+ <?php if ($breadcrumb): ?>
+ <div id="breadcrumb"><?php print $breadcrumb; ?></div>
+ <?php endif; ?>
+
+ <?php if ($page['sidebar_first']): ?>
+ <div id="sidebar-first" class="column sidebar"><div class="section">
+ <?php print render($page['sidebar_first']); ?>
+ </div></div> <!-- /.section, /#sidebar-first -->
+ <?php endif; ?>
+
+ <div id="content" class="column"><div class="section">
+ <?php if ($page['highlighted']): ?><div id="highlighted"><?php print render($page['highlighted']); ?></div><?php endif; ?>
+ <a id="main-content"></a>
+ <?php print render($title_prefix); ?>
+ <?php if ($title): ?>
+ <h1 class="title" id="page-title">
+ <?php print $title; ?>
+ </h1>
+ <?php endif; ?>
+ <?php print render($title_suffix); ?>
+ <?php if ($tabs): ?>
+ <div class="tabs">
+ <?php print render($tabs); ?>
+ </div>
+ <?php endif; ?>
+ <?php print render($page['help']); ?>
+ <?php if ($action_links): ?>
+ <ul class="action-links">
+ <?php print render($action_links); ?>
+ </ul>
+ <?php endif; ?>
+ <?php print render($page['content']); ?>
+ <?php print $feed_icons; ?>
+
+ </div></div> <!-- /.section, /#content -->
+
+ <?php if ($page['sidebar_second']): ?>
+ <div id="sidebar-second" class="column sidebar"><div class="section">
+ <?php print render($page['sidebar_second']); ?>
+ </div></div> <!-- /.section, /#sidebar-second -->
+ <?php endif; ?>
+
+ </div></div> <!-- /#main, /#main-wrapper -->
+
+ <?php if ($page['triptych_first'] || $page['triptych_middle'] || $page['triptych_last']): ?>
+ <div id="triptych-wrapper"><div id="triptych" class="clearfix">
+ <?php print render($page['triptych_first']); ?>
+ <?php print render($page['triptych_middle']); ?>
+ <?php print render($page['triptych_last']); ?>
+ </div></div> <!-- /#triptych, /#triptych-wrapper -->
+ <?php endif; ?>
+
+ <div id="footer-wrapper"><div class="section">
+
+ <?php if ($page['footer_firstcolumn'] || $page['footer_secondcolumn'] || $page['footer_thirdcolumn'] || $page['footer_fourthcolumn']): ?>
+ <div id="footer-columns" class="clearfix">
+ <?php print render($page['footer_firstcolumn']); ?>
+ <?php print render($page['footer_secondcolumn']); ?>
+ <?php print render($page['footer_thirdcolumn']); ?>
+ <?php print render($page['footer_fourthcolumn']); ?>
+ </div> <!-- /#footer-columns -->
+ <?php endif; ?>
+
+ <?php if ($page['footer']): ?>
+ <div id="footer" class="clearfix">
+ <?php print render($page['footer']); ?>
+ </div> <!-- /#footer -->
+ <?php endif; ?>
+
+ </div></div> <!-- /.section, /#footer-wrapper -->
+
+</div></div> <!-- /#page, /#page-wrapper -->
diff --git a/core/themes/engines/phptemplate/phptemplate.engine b/core/themes/engines/phptemplate/phptemplate.engine
new file mode 100644
index 000000000000..d293b85c7e71
--- /dev/null
+++ b/core/themes/engines/phptemplate/phptemplate.engine
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * @file
+ * Handles integration of PHP templates with the Drupal theme system.
+ */
+
+/**
+ * Implements hook_init().
+ */
+function phptemplate_init($template) {
+ $file = dirname($template->filename) . '/template.php';
+ if (file_exists($file)) {
+ include_once DRUPAL_ROOT . '/' . $file;
+ }
+}
+
+/**
+ * Implements hook_theme().
+ */
+function phptemplate_theme($existing, $type, $theme, $path) {
+ $templates = drupal_find_theme_functions($existing, array($theme));
+ $templates += drupal_find_theme_templates($existing, '.tpl.php', $path);
+ return $templates;
+}
diff --git a/core/themes/seven/ie.css b/core/themes/seven/ie.css
new file mode 100644
index 000000000000..4b2569502f0c
--- /dev/null
+++ b/core/themes/seven/ie.css
@@ -0,0 +1,18 @@
+
+/* IE7 renders legends in nested fieldsets without a width. */
+fieldset legend {
+ height: 1%;
+}
+
+/* IE renders absolute positioned legend where fieldset content starts. */
+fieldset .fieldset-legend {
+ left: 0;
+ top: 0;
+}
+
+/* IE renders monospace font too big. */
+code,
+pre,
+kbd {
+ font-size: 1em;
+}
diff --git a/core/themes/seven/ie7.css b/core/themes/seven/ie7.css
new file mode 100644
index 000000000000..abcdb8a5e060
--- /dev/null
+++ b/core/themes/seven/ie7.css
@@ -0,0 +1,23 @@
+
+ul.tabs.primary {
+ padding: 0;
+}
+ul.primary li,
+ul.primary li a,
+ul.primary li.active a {
+ float: none !important;
+ display: inline;
+}
+ul.primary li,
+ul.primary li a,
+ul.primary li a.active,
+ul.primary li a:active,
+ul.primary li a:visited,
+ul.primary li a:hover,
+ul.primary li.active a {
+ zoom: 1;
+ position: relative;
+}
+ul.admin-list li {
+ position: static;
+}
diff --git a/core/themes/seven/images/add.png b/core/themes/seven/images/add.png
new file mode 100644
index 000000000000..1a2faf656c9f
--- /dev/null
+++ b/core/themes/seven/images/add.png
Binary files differ
diff --git a/core/themes/seven/images/arrow-asc.png b/core/themes/seven/images/arrow-asc.png
new file mode 100644
index 000000000000..d25b8dd75bef
--- /dev/null
+++ b/core/themes/seven/images/arrow-asc.png
Binary files differ
diff --git a/core/themes/seven/images/arrow-desc.png b/core/themes/seven/images/arrow-desc.png
new file mode 100644
index 000000000000..2feade962ad4
--- /dev/null
+++ b/core/themes/seven/images/arrow-desc.png
Binary files differ
diff --git a/core/themes/seven/images/arrow-next.png b/core/themes/seven/images/arrow-next.png
new file mode 100644
index 000000000000..ed551def21e8
--- /dev/null
+++ b/core/themes/seven/images/arrow-next.png
Binary files differ
diff --git a/core/themes/seven/images/arrow-prev.png b/core/themes/seven/images/arrow-prev.png
new file mode 100644
index 000000000000..64b2dac724c9
--- /dev/null
+++ b/core/themes/seven/images/arrow-prev.png
Binary files differ
diff --git a/core/themes/seven/images/buttons.png b/core/themes/seven/images/buttons.png
new file mode 100644
index 000000000000..ae833d5f8f17
--- /dev/null
+++ b/core/themes/seven/images/buttons.png
Binary files differ
diff --git a/core/themes/seven/images/fc-rtl.png b/core/themes/seven/images/fc-rtl.png
new file mode 100644
index 000000000000..e02cf9cd9e45
--- /dev/null
+++ b/core/themes/seven/images/fc-rtl.png
Binary files differ
diff --git a/core/themes/seven/images/fc.png b/core/themes/seven/images/fc.png
new file mode 100644
index 000000000000..ac44b4190f55
--- /dev/null
+++ b/core/themes/seven/images/fc.png
Binary files differ
diff --git a/core/themes/seven/images/list-item-rtl.png b/core/themes/seven/images/list-item-rtl.png
new file mode 100644
index 000000000000..aa654f74a0bf
--- /dev/null
+++ b/core/themes/seven/images/list-item-rtl.png
Binary files differ
diff --git a/core/themes/seven/images/list-item.png b/core/themes/seven/images/list-item.png
new file mode 100644
index 000000000000..d598d6366bf1
--- /dev/null
+++ b/core/themes/seven/images/list-item.png
Binary files differ
diff --git a/core/themes/seven/images/task-check.png b/core/themes/seven/images/task-check.png
new file mode 100644
index 000000000000..64fadf848a4e
--- /dev/null
+++ b/core/themes/seven/images/task-check.png
Binary files differ
diff --git a/core/themes/seven/images/task-item-rtl.png b/core/themes/seven/images/task-item-rtl.png
new file mode 100644
index 000000000000..fbf6185835fa
--- /dev/null
+++ b/core/themes/seven/images/task-item-rtl.png
Binary files differ
diff --git a/core/themes/seven/images/task-item.png b/core/themes/seven/images/task-item.png
new file mode 100644
index 000000000000..c2f9bf099543
--- /dev/null
+++ b/core/themes/seven/images/task-item.png
Binary files differ
diff --git a/core/themes/seven/images/ui-icons-222222-256x240.png b/core/themes/seven/images/ui-icons-222222-256x240.png
new file mode 100644
index 000000000000..9a9606f7614c
--- /dev/null
+++ b/core/themes/seven/images/ui-icons-222222-256x240.png
Binary files differ
diff --git a/core/themes/seven/images/ui-icons-454545-256x240.png b/core/themes/seven/images/ui-icons-454545-256x240.png
new file mode 100644
index 000000000000..80cb644a58f9
--- /dev/null
+++ b/core/themes/seven/images/ui-icons-454545-256x240.png
Binary files differ
diff --git a/core/themes/seven/images/ui-icons-800000-256x240.png b/core/themes/seven/images/ui-icons-800000-256x240.png
new file mode 100644
index 000000000000..7bf106b2b4d0
--- /dev/null
+++ b/core/themes/seven/images/ui-icons-800000-256x240.png
Binary files differ
diff --git a/core/themes/seven/images/ui-icons-888888-256x240.png b/core/themes/seven/images/ui-icons-888888-256x240.png
new file mode 100644
index 000000000000..8373712d13cf
--- /dev/null
+++ b/core/themes/seven/images/ui-icons-888888-256x240.png
Binary files differ
diff --git a/core/themes/seven/images/ui-icons-ffffff-256x240.png b/core/themes/seven/images/ui-icons-ffffff-256x240.png
new file mode 100644
index 000000000000..3086869dad62
--- /dev/null
+++ b/core/themes/seven/images/ui-icons-ffffff-256x240.png
Binary files differ
diff --git a/core/themes/seven/jquery.ui.theme.css b/core/themes/seven/jquery.ui.theme.css
new file mode 100644
index 000000000000..c26046ae129a
--- /dev/null
+++ b/core/themes/seven/jquery.ui.theme.css
@@ -0,0 +1,436 @@
+/**
+ * @file
+ * Seven styles for jQuery UI.
+ * Overrides /misc/ui/ui.theme.css.
+ */
+
+/**
+ * Component containers
+ */
+.ui-widget {
+ background: #fff;
+}
+.ui-widget-content {
+ border: solid 1px #ccc;
+}
+
+/**
+ * Interaction states
+ */
+.ui-state-default,
+.ui-state-hover,
+.ui-state-focus,
+.ui-state-active {
+ outline: 0;
+}
+.ui-state-active {
+ font-weight: bold;
+}
+
+/**
+ * Interaction cues
+ */
+.ui-state-highlight,
+.ui-widget-content .ui-state-highlight {
+ color: #840;
+ background: #fe6;
+ border: solid 1px #ed5;
+}
+.ui-state-error,
+.ui-widget-content .ui-state-error {
+ color: #fff;
+ background: #e63;
+ border-color: #d52;
+}
+.ui-state-disabled,
+.ui-widget-content .ui-state-disabled {
+ opacity: .35;
+ filter: Alpha(Opacity=35);
+}
+.ui-priority-secondary,
+.ui-widget-content .ui-priority-secondary {
+ opacity: .7;
+ filter: Alpha(Opacity=70);
+}
+
+/**
+ * Icons
+ */
+/* states and images */
+.ui-icon {
+ display: block;
+ text-indent: -99999px;
+ width: 16px;
+ height: 16px;
+ overflow: hidden;
+ background-repeat: no-repeat;
+ background-image: url(images/ui-icons-222222-256x240.png);
+}
+.ui-widget-content .ui-icon,
+.ui-widget-header .ui-icon {
+ background-image: url(images/ui-icons-222222-256x240.png);
+}
+.ui-state-default .ui-icon {
+ background-image: url(images/ui-icons-888888-256x240.png);
+}
+.ui-state-hover .ui-icon,
+.ui-state-focus .ui-icon,
+.ui-state-active .ui-icon {
+ background-image: url(images/ui-icons-454545-256x240.png);
+}
+.ui-state-highlight .ui-icon {
+ background-image: url(images/ui-icons-800000-256x240.png);
+}
+.ui-state-error .ui-icon,
+.ui-state-error-text .ui-icon {
+ background-image: url(images/ui-icons-ffffff-256x240.png);
+}
+.ui-widget p .ui-icon {
+ margin: 2px 3px 0 0;
+}
+
+/* positioning */
+.ui-icon-carat-1-ne { background-position: -16px 0; }
+.ui-icon-carat-1-e { background-position: -32px 0; }
+.ui-icon-carat-1-se { background-position: -48px 0; }
+.ui-icon-carat-1-s { background-position: -64px 0; }
+.ui-icon-carat-1-sw { background-position: -80px 0; }
+.ui-icon-carat-1-w { background-position: -96px 0; }
+.ui-icon-carat-1-nw { background-position: -112px 0; }
+.ui-icon-carat-2-n-s { background-position: -128px 0; }
+.ui-icon-carat-2-e-w { background-position: -144px 0; }
+.ui-icon-triangle-1-n { background-position: 0 -16px; }
+.ui-icon-triangle-1-ne { background-position: -16px -16px; }
+.ui-icon-triangle-1-e { background-position: -32px -16px; }
+.ui-icon-triangle-1-se { background-position: -48px -16px; }
+.ui-icon-triangle-1-s { background-position: -64px -16px; }
+.ui-icon-triangle-1-sw { background-position: -80px -16px; }
+.ui-icon-triangle-1-w { background-position: -96px -16px; }
+.ui-icon-triangle-1-nw { background-position: -112px -16px; }
+.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
+.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
+.ui-icon-arrow-1-n { background-position: 0 -32px; }
+.ui-icon-arrow-1-ne { background-position: -16px -32px; }
+.ui-icon-arrow-1-e { background-position: -32px -32px; }
+.ui-icon-arrow-1-se { background-position: -48px -32px; }
+.ui-icon-arrow-1-s { background-position: -64px -32px; }
+.ui-icon-arrow-1-sw { background-position: -80px -32px; }
+.ui-icon-arrow-1-w { background-position: -96px -32px; }
+.ui-icon-arrow-1-nw { background-position: -112px -32px; }
+.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
+.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
+.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
+.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
+.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
+.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
+.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
+.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
+.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
+.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
+.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
+.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
+.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
+.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
+.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
+.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
+.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
+.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
+.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
+.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
+.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
+.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
+.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
+.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
+.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
+.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
+.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
+.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
+.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
+.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
+.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
+.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
+.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
+.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
+.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
+.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
+.ui-icon-arrow-4 { background-position: 0 -80px; }
+.ui-icon-arrow-4-diag { background-position: -16px -80px; }
+.ui-icon-extlink { background-position: -32px -80px; }
+.ui-icon-newwin { background-position: -48px -80px; }
+.ui-icon-refresh { background-position: -64px -80px; }
+.ui-icon-shuffle { background-position: -80px -80px; }
+.ui-icon-transfer-e-w { background-position: -96px -80px; }
+.ui-icon-transferthick-e-w { background-position: -112px -80px; }
+.ui-icon-folder-collapsed { background-position: 0 -96px; }
+.ui-icon-folder-open { background-position: -16px -96px; }
+.ui-icon-document { background-position: -32px -96px; }
+.ui-icon-document-b { background-position: -48px -96px; }
+.ui-icon-note { background-position: -64px -96px; }
+.ui-icon-mail-closed { background-position: -80px -96px; }
+.ui-icon-mail-open { background-position: -96px -96px; }
+.ui-icon-suitcase { background-position: -112px -96px; }
+.ui-icon-comment { background-position: -128px -96px; }
+.ui-icon-person { background-position: -144px -96px; }
+.ui-icon-print { background-position: -160px -96px; }
+.ui-icon-trash { background-position: -176px -96px; }
+.ui-icon-locked { background-position: -192px -96px; }
+.ui-icon-unlocked { background-position: -208px -96px; }
+.ui-icon-bookmark { background-position: -224px -96px; }
+.ui-icon-tag { background-position: -240px -96px; }
+.ui-icon-home { background-position: 0 -112px; }
+.ui-icon-flag { background-position: -16px -112px; }
+.ui-icon-calendar { background-position: -32px -112px; }
+.ui-icon-cart { background-position: -48px -112px; }
+.ui-icon-pencil { background-position: -64px -112px; }
+.ui-icon-clock { background-position: -80px -112px; }
+.ui-icon-disk { background-position: -96px -112px; }
+.ui-icon-calculator { background-position: -112px -112px; }
+.ui-icon-zoomin { background-position: -128px -112px; }
+.ui-icon-zoomout { background-position: -144px -112px; }
+.ui-icon-search { background-position: -160px -112px; }
+.ui-icon-wrench { background-position: -176px -112px; }
+.ui-icon-gear { background-position: -192px -112px; }
+.ui-icon-heart { background-position: -208px -112px; }
+.ui-icon-star { background-position: -224px -112px; }
+.ui-icon-link { background-position: -240px -112px; }
+.ui-icon-cancel { background-position: 0 -128px; }
+.ui-icon-plus { background-position: -16px -128px; }
+.ui-icon-plusthick { background-position: -32px -128px; }
+.ui-icon-minus { background-position: -48px -128px; }
+.ui-icon-minusthick { background-position: -64px -128px; }
+.ui-icon-close { background-position: -80px -128px; }
+.ui-icon-closethick { background-position: -96px -128px; }
+.ui-icon-key { background-position: -112px -128px; }
+.ui-icon-lightbulb { background-position: -128px -128px; }
+.ui-icon-scissors { background-position: -144px -128px; }
+.ui-icon-clipboard { background-position: -160px -128px; }
+.ui-icon-copy { background-position: -176px -128px; }
+.ui-icon-contact { background-position: -192px -128px; }
+.ui-icon-image { background-position: -208px -128px; }
+.ui-icon-video { background-position: -224px -128px; }
+.ui-icon-script { background-position: -240px -128px; }
+.ui-icon-alert { background-position: 0 -144px; }
+.ui-icon-info { background-position: -16px -144px; }
+.ui-icon-notice { background-position: -32px -144px; }
+.ui-icon-help { background-position: -48px -144px; }
+.ui-icon-check { background-position: -64px -144px; }
+.ui-icon-bullet { background-position: -80px -144px; }
+.ui-icon-radio-off { background-position: -96px -144px; }
+.ui-icon-radio-on { background-position: -112px -144px; }
+.ui-icon-pin-w { background-position: -128px -144px; }
+.ui-icon-pin-s { background-position: -144px -144px; }
+.ui-icon-play { background-position: 0 -160px; }
+.ui-icon-pause { background-position: -16px -160px; }
+.ui-icon-seek-next { background-position: -32px -160px; }
+.ui-icon-seek-prev { background-position: -48px -160px; }
+.ui-icon-seek-end { background-position: -64px -160px; }
+.ui-icon-seek-first { background-position: -80px -160px; }
+.ui-icon-stop { background-position: -96px -160px; }
+.ui-icon-eject { background-position: -112px -160px; }
+.ui-icon-volume-off { background-position: -128px -160px; }
+.ui-icon-volume-on { background-position: -144px -160px; }
+.ui-icon-power { background-position: 0 -176px; }
+.ui-icon-signal-diag { background-position: -16px -176px; }
+.ui-icon-signal { background-position: -32px -176px; }
+.ui-icon-battery-0 { background-position: -48px -176px; }
+.ui-icon-battery-1 { background-position: -64px -176px; }
+.ui-icon-battery-2 { background-position: -80px -176px; }
+.ui-icon-battery-3 { background-position: -96px -176px; }
+.ui-icon-circle-plus { background-position: 0 -192px; }
+.ui-icon-circle-minus { background-position: -16px -192px; }
+.ui-icon-circle-close { background-position: -32px -192px; }
+.ui-icon-circle-triangle-e { background-position: -48px -192px; }
+.ui-icon-circle-triangle-s { background-position: -64px -192px; }
+.ui-icon-circle-triangle-w { background-position: -80px -192px; }
+.ui-icon-circle-triangle-n { background-position: -96px -192px; }
+.ui-icon-circle-arrow-e { background-position: -112px -192px; }
+.ui-icon-circle-arrow-s { background-position: -128px -192px; }
+.ui-icon-circle-arrow-w { background-position: -144px -192px; }
+.ui-icon-circle-arrow-n { background-position: -160px -192px; }
+.ui-icon-circle-zoomin { background-position: -176px -192px; }
+.ui-icon-circle-zoomout { background-position: -192px -192px; }
+.ui-icon-circle-check { background-position: -208px -192px; }
+.ui-icon-circlesmall-plus { background-position: 0 -208px; }
+.ui-icon-circlesmall-minus { background-position: -16px -208px; }
+.ui-icon-circlesmall-close { background-position: -32px -208px; }
+.ui-icon-squaresmall-plus { background-position: -48px -208px; }
+.ui-icon-squaresmall-minus { background-position: -64px -208px; }
+.ui-icon-squaresmall-close { background-position: -80px -208px; }
+.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
+.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
+.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
+.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
+.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
+.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
+.ui-icon-carat-1-n { background-position: 0 0; }
+
+/**
+ * Accordion
+ */
+.ui-accordion {
+ border: none;
+}
+.ui-accordion .ui-accordion-header {
+ border: solid 1px #ccc;
+ text-transform: uppercase;
+}
+.ui-accordion h3.ui-accordion-header,
+#block-system-main h3.ui-accordion-header {
+ font-size: 1.1em;
+ margin: 10px 0;
+}
+#block-system-main .ui-accordion h3.ui-state-active,
+.ui-accordion h3.ui-state-active {
+ margin-bottom: 0;
+}
+.ui-accordion .ui-accordion-header a {
+ display: block;
+}
+.ui-accordion .ui-accordion-content {
+ padding: 1em 2.2em;
+ border: solid 1px #ccc;
+ border-top: 0;
+}
+
+/**
+ * Tabs
+ */
+.ui-tabs {
+ padding: 0;
+}
+.ui-tabs .ui-tabs-nav {
+ padding: 5px 10px 4px;
+ margin: 0;
+ line-height: 20px;
+ border-bottom: solid 1px #ccc;
+ -moz-border-radius-bottomleft: 0;
+ -webkit-border-bottom-left-radius: 0;
+ border-bottom-left-radius: 0;
+ -moz-border-radius-bottomright: 0;
+ -webkit-border-bottom-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+.ui-tabs .ui-tabs-nav li {
+ padding: 0 1em 0 10px;
+ margin: 0;
+ list-style: none;
+}
+.ui-tabs .ui-tabs-nav li a {
+ float: none;
+ padding: 0 10px;
+ -moz-border-radius: 10px;
+ -webkit-border-radius: 10px;
+ border-radius: 10px;
+}
+.ui-tabs .ui-tabs-nav li.ui-tabs-selected a {
+ color: #fff;
+ background: #666;
+ font-weight: normal;
+}
+
+/**
+ * Overlays
+ */
+.ui-widget-overlay {
+ background: #000;
+ opacity: .70;
+ filter: Alpha(Opacity=70);
+}
+
+/**
+ * Dialogs
+ */
+.ui-dialog {
+ background: #fff;
+ border: solid 1px #ccc;
+ padding: 0;
+}
+.ui-dialog .ui-dialog-titlebar {
+ font-weight: bold;
+ background: #e1e2dc;
+}
+.ui-dialog .ui-dialog-buttonpane {
+ border-width: 0;
+}
+.ui-dialog .ui-dialog-buttonpane button {
+ cursor: pointer;
+ padding: 4px 17px;
+ color: #5a5a5a;
+ text-align: center;
+ font-family: "Lucida Grande", Verdana, sans-serif;
+ font-weight: normal;
+ font-size: 1em;
+ border: 1px solid #e4e4e4;
+ border-bottom: 1px solid #b4b4b4;
+ border-left-color: #D2D2D2;
+ border-right-color: #D2D2D2;
+ background: url(images/buttons.png) 0 0 repeat-x;
+ -moz-border-radius: 20px;
+ -webkit-border-radius: 20px;
+ border-radius: 20px;
+}
+.ui-dialog .ui-dialog-buttonpane button:active {
+ background: #666;
+ color: #fff;
+ border-color: #555;
+ text-shadow: #222 0px -1px 0px;
+}
+.overlay {
+ padding-right: 26px;
+}
+.overlay .ui-dialog-titlebar {
+ background: transparent;
+}
+
+/**
+ * Slider
+ */
+.ui-slider {
+ border: solid 1px #ccc;
+}
+.ui-slider .ui-slider-range {
+ background: #e4e4e4;
+}
+.ui-slider .ui-slider-handle {
+ border: 1px solid #e4e4e4;
+ border-bottom: 1px solid #b4b4b4;
+ border-left-color: #D2D2D2;
+ border-right-color: #D2D2D2;
+ background: url(images/buttons.png) 0 0 repeat-x;
+ -moz-border-radius: 4px;
+ -webkit-border-radius: 4px;
+ border-radius: 4px;
+}
+.ui-slider a.ui-state-active,
+.ui-slider .ui-slider-handle:active {
+ background: #666;
+ color: #fff;
+ border: solid 1px #555;
+}
+
+/**
+ * Progress Bar
+ */
+.ui-progressbar {
+ background: #e4e4e4;
+ height: 1.4em;
+}
+.ui-progressbar .ui-progressbar-value {
+ background: #0072b9 url(../../misc/progress.gif);
+ height: 1.5em;
+}
+
+/**
+ * Date Picker
+ */
+.ui-datepicker {
+ border: none;
+}
+.ui-datepicker td span, .ui-datepicker td a {
+ text-align: center;
+}
+.ui-datepicker .ui-state-highlight {
+ background: #E4E4E4;
+ border-color: #D2D2D2;
+ color: #000;
+}
diff --git a/core/themes/seven/logo.png b/core/themes/seven/logo.png
new file mode 100644
index 000000000000..3b49a4ce78dc
--- /dev/null
+++ b/core/themes/seven/logo.png
Binary files differ
diff --git a/core/themes/seven/maintenance-page.tpl.php b/core/themes/seven/maintenance-page.tpl.php
new file mode 100644
index 000000000000..aeef310df82b
--- /dev/null
+++ b/core/themes/seven/maintenance-page.tpl.php
@@ -0,0 +1,47 @@
+<?php
+?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="<?php print $language->language ?>" lang="<?php print $language->language ?>" dir="<?php print $language->dir ?>">
+ <head>
+ <title><?php print $head_title; ?></title>
+ <?php print $head; ?>
+ <?php print $styles; ?>
+ <?php print $scripts; ?>
+ </head>
+ <body class="<?php print $classes; ?>">
+
+ <?php print $page_top; ?>
+
+ <div id="branding">
+ <?php if ($title): ?><h1 class="page-title"><?php print $title; ?></h1><?php endif; ?>
+ </div>
+
+ <div id="page">
+
+ <?php if ($sidebar_first): ?>
+ <div id="sidebar-first" class="sidebar">
+ <?php if ($logo): ?>
+ <img id="logo" src="<?php print $logo ?>" alt="<?php print $site_name ?>" />
+ <?php endif; ?>
+ <?php print $sidebar_first ?>
+ </div>
+ <?php endif; ?>
+
+ <div id="content" class="clearfix">
+ <?php if ($messages): ?>
+ <div id="console"><?php print $messages; ?></div>
+ <?php endif; ?>
+ <?php if ($help): ?>
+ <div id="help">
+ <?php print $help; ?>
+ </div>
+ <?php endif; ?>
+ <?php print $content; ?>
+ </div>
+
+ </div>
+
+ <?php print $page_bottom; ?>
+
+ </body>
+</html>
diff --git a/core/themes/seven/page.tpl.php b/core/themes/seven/page.tpl.php
new file mode 100644
index 000000000000..b9d0ad554cd6
--- /dev/null
+++ b/core/themes/seven/page.tpl.php
@@ -0,0 +1,36 @@
+<?php
+?>
+ <div id="branding" class="clearfix">
+ <?php print $breadcrumb; ?>
+ <?php print render($title_prefix); ?>
+ <?php if ($title): ?>
+ <h1 class="page-title"><?php print $title; ?></h1>
+ <?php endif; ?>
+ <?php print render($title_suffix); ?>
+ <?php print render($primary_local_tasks); ?>
+ </div>
+
+ <div id="page">
+ <?php if ($secondary_local_tasks): ?>
+ <div class="tabs-secondary clearfix"><ul class="tabs secondary"><?php print render($secondary_local_tasks); ?></ul></div>
+ <?php endif; ?>
+
+ <div id="content" class="clearfix">
+ <div class="element-invisible"><a id="main-content"></a></div>
+ <?php if ($messages): ?>
+ <div id="console" class="clearfix"><?php print $messages; ?></div>
+ <?php endif; ?>
+ <?php if ($page['help']): ?>
+ <div id="help">
+ <?php print render($page['help']); ?>
+ </div>
+ <?php endif; ?>
+ <?php if ($action_links): ?><ul class="action-links"><?php print render($action_links); ?></ul><?php endif; ?>
+ <?php print render($page['content']); ?>
+ </div>
+
+ <div id="footer">
+ <?php print $feed_icons; ?>
+ </div>
+
+ </div>
diff --git a/core/themes/seven/reset.css b/core/themes/seven/reset.css
new file mode 100644
index 000000000000..306b1f9a3c1c
--- /dev/null
+++ b/core/themes/seven/reset.css
@@ -0,0 +1,209 @@
+
+/**
+ * Reset CSS styles.
+ *
+ * Based on Eric Meyer's "Reset CSS 1.0" tool from
+ * http://meyerweb.com/eric/tools/css/reset
+ */
+
+html,
+body,
+div,
+span,
+applet,
+object,
+iframe,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+blockquote,
+pre,
+a,
+abbr,
+acronym,
+address,
+big,
+cite,
+code,
+del,
+dfn,
+em,
+font,
+img,
+ins,
+kbd,
+q,
+s,
+samp,
+small,
+strike,
+strong,
+sub,
+sup,
+tt,
+var,
+b,
+u,
+i,
+center,
+dl,
+dt,
+dd,
+ol,
+ul,
+li,
+fieldset,
+form,
+input,
+select,
+textarea,
+label,
+legend,
+table,
+caption,
+tbody,
+tfoot,
+thead,
+tr,
+th,
+td,
+/* Drupal: system-menus.css */
+td.menu-disabled,
+ul.links,
+ul.links.inline,
+ul.links li,
+.block ul,
+/* Drupal: admin.css */
+div.admin,
+/* Drupal: system.css */
+tr.even,
+tr.odd,
+tr.drag,
+tbody,
+tbody th,
+thead th,
+.breadcrumb,
+.item-list .icon,
+.item-list .title,
+.item-list ul,
+.item-list ul li,
+ol.task-list li.active,
+.form-item,
+tr.odd .form-item,
+tr.even .form-item,
+.form-item .description,
+.form-item label,
+.form-item label.option,
+.form-checkboxes,
+.form-radios,
+.form-checkboxes .form-item,
+.form-radios .form-item,
+.marker,
+.form-required,
+.more-link,
+.more-help-link,
+.item-list .pager,
+.item-list .pager li,
+.pager-current,
+.tips,
+ul.primary,
+ul.primary li,
+ul.primary li a,
+ul.primary li.active a,
+ul.primary li a:hover,
+ul.secondary,
+ul.secondary li,
+ul.secondary a,
+ul.secondary a.active,
+.resizable-textarea {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ vertical-align: baseline;
+}
+/* Drupal: system-menus.css */
+ul.links,
+ul.links.inline,
+ul.links li,
+.block ul,
+ol,
+ul,
+.item-list ul,
+.item-list ul li {
+ list-style: none;
+}
+blockquote,
+q {
+ quotes: none;
+}
+blockquote:before,
+blockquote:after,
+q:before,
+q:after {
+ content: '';
+ content: none;
+}
+
+/* Remember to highlight inserts somehow! */
+ins {
+ text-decoration: none;
+}
+del {
+ text-decoration: line-through;
+}
+
+/* Tables still need 'cellspacing="0"' in the markup. */
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+/**
+ * Font reset.
+ *
+ * Specifically targets form elements which browsers often times give
+ * special treatment.
+ */
+input,
+select,
+textarea {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+}
+textarea {
+ font-size: 1em;
+ line-height: 1.538em;
+}
+/**
+ * Markup free clearing.
+ *
+ * Consider adding your own selectors to this instead of finding ways
+ * to sneak the clearfix class into Drupal's markup.
+ * From http://perishablepress.com/press/2009/12/06/new-clearfix-hack
+ */
+ul.links:after,
+div.admin-panel .body:after,
+.clearfix:after {
+ content: ".";
+ display: block;
+ height: 0;
+ clear: both;
+ visibility: hidden;
+}
+
+/* Exclude inline links from clearfix behavior */
+ul.inline:after {
+ content: "";
+ display: none;
+ clear: none;
+}
+/* IE7 */
+*:first-child + html .form-item,
+*:first-child + html ul.links,
+*:first-child + html div.admin-panel .body,
+*:first-child + html .clearfix {
+ min-height: 1%;
+}
diff --git a/core/themes/seven/screenshot.png b/core/themes/seven/screenshot.png
new file mode 100644
index 000000000000..3f0c36774c66
--- /dev/null
+++ b/core/themes/seven/screenshot.png
Binary files differ
diff --git a/core/themes/seven/seven.info b/core/themes/seven/seven.info
new file mode 100644
index 000000000000..969f7490bb07
--- /dev/null
+++ b/core/themes/seven/seven.info
@@ -0,0 +1,14 @@
+name = Seven
+description = A simple one-column, tableless, fluid width administration theme.
+package = Core
+version = VERSION
+core = 8.x
+stylesheets[screen][] = reset.css
+stylesheets[screen][] = style.css
+settings[shortcut_module_link] = 1
+regions[content] = Content
+regions[help] = Help
+regions[page_top] = Page top
+regions[page_bottom] = Page bottom
+regions[sidebar_first] = First sidebar
+regions_hidden[] = sidebar_first
diff --git a/core/themes/seven/style-rtl.css b/core/themes/seven/style-rtl.css
new file mode 100644
index 000000000000..a41d32547807
--- /dev/null
+++ b/core/themes/seven/style-rtl.css
@@ -0,0 +1,241 @@
+
+/**
+ * Generic elements.
+ */
+dl dd,
+dl dl {
+ margin-right: 20px;
+}
+ul,
+.block ul,
+.item-list ul {
+ margin: 0.25em 1.5em 0.25em 0;
+}
+ol {
+ margin: 0.25em 2em 0.25em 0;
+}
+
+/**
+ * Skip link.
+ */
+#skip-link {
+ right: 50%;
+ margin-right: -5.25em;
+}
+#skip-link a,
+#skip-link a:link,
+#skip-link a:visited {
+ padding: 1px 10px 2px 10px;
+}
+
+/**
+ * Branding.
+ */
+#branding {
+ padding: 20px 20px 0 20px;
+}
+
+#branding div.block {
+ float: left;
+ padding-left: 0;
+ padding-right: 10px;
+}
+#branding div.block form div.form-item {
+ float: right;
+}
+#branding div.block form input.form-text {
+ margin-left: 10px;
+ margin-right: 0;
+}
+
+/**
+ * Help.
+ */
+#help div.more-help-link {
+ text-align: left;
+}
+
+/**
+ * Page title.
+ */
+#branding h1.page-title {
+ float: right;
+}
+
+/**
+ * Tabs.
+ */
+ul.primary li,
+ul.primary li a:link,
+ul.primary li a.active {
+ float: right;
+}
+ul.primary,
+ul.secondary {
+ float: left;
+}
+ul.secondary li {
+ float: none;
+}
+ul.primary {
+ padding-top: 0;
+}
+
+/**
+ * Page layout.
+ */
+#page {
+ padding: 20px 0 40px 0;
+ margin-left: 40px;
+ margin-right: 40px;
+}
+#secondary-links ul.links li {
+ padding: 0 0 10px 10px;
+}
+ul.links li,
+ul.inline li {
+ padding-left: 1em;
+ padding-right: 0;
+}
+ul.admin-list li {
+ padding: 9px 30px 0 0;
+ margin-right: 0;
+ background: url(images/list-item-rtl.png) no-repeat right 11px;
+}
+ul.admin-list li a {
+ margin-right: -30px;
+ margin-left: 0;
+ padding: 0 30px 4px 0;
+}
+ul.admin-list.compact li a {
+ margin-right: 0;
+}
+ul.admin-list li div.description a {
+ margin-right: 0;
+}
+
+/**
+ * Tables.
+ */
+table th.active a {
+ padding: 0 0 0 25px;
+}
+table th.active img {
+ left: 3px;
+ right: auto;
+}
+/**
+ * Exception for webkit bug with the right border of the last cell
+ * in some tables, since it's webkit only, we can use :last-child
+ */
+tr td:last-child {
+ border-left: 1px solid #bebfb9;
+ border-right: none;
+}
+
+/**
+ * Fieldsets.
+ */
+fieldset {
+ padding: 2.5em 0 0 0;
+}
+fieldset .fieldset-legend {
+ padding-right: 15px;
+ right: 0;
+}
+fieldset .fieldset-wrapper {
+ padding: 0 15px 13px 13px;
+}
+
+/* Filter */
+.filter-wrapper .form-item,
+.filter-wrapper .filter-guidelines,
+.filter-wrapper .filter-help {
+ padding: 2px 0 0 0;
+}
+ul.tips li {
+ margin: 0.25em 1.5em 0.25em 0;
+}
+body div.form-type-radio div.description,
+body div.form-type-checkbox div.description {
+ margin-left: 0;
+ margin-right: 1.5em;
+}
+input.form-submit,
+a.button {
+ margin-left: 1em;
+ margin-right: 0;
+}
+ul.action-links li {
+ float: right;
+ margin: 0 0 0 1em;
+}
+ul.action-links a {
+ padding-left: 0;
+ padding-right: 15px;
+ background-position: right center;
+}
+
+/* Update options. */
+div.admin-options label,
+div.admin-options div.form-item {
+ margin-left: 10px;
+ margin-right: 0;
+ float: right;
+}
+
+/* Maintenance theming */
+body.in-maintenance #sidebar-first {
+ float: right;
+}
+body.in-maintenance #content {
+ float: left;
+ padding-left: 20px;
+ padding-right: 0;
+}
+ol.task-list {
+ margin-right: 0;
+}
+ol.task-list li {
+ padding: 0.5em 20px 0.5em 1em;
+}
+ol.task-list li.active {
+ background: transparent url(images/task-item-rtl.png) no-repeat right 50%;
+ padding: 0.5em 20px 0.5em 1em;
+}
+
+/* Overlay theming */
+.overlay #branding div.breadcrumb {
+ float: right;
+}
+.overlay ul.secondary {
+ margin: -1.4em 0 0.3em 0;
+}
+
+/* Shortcut theming */
+div.add-or-remove-shortcuts {
+ float: none;
+ padding-left: 0;
+ padding-right: 6px;
+}
+
+/* Dashboard */
+#dashboard div.block div.content {
+ padding: 10px 5px 5px 5px;
+}
+#dashboard div.block div.content ul.menu {
+ margin-right: 20px;
+}
+
+/* Recent content block */
+#block-node-recent .more-link {
+ padding: 0 0 5px 5px;
+}
+
+/* User login block */
+#user-login-form .openid-links {
+ margin-right: 0;
+}
+#user-login-form .openid-links .user-link {
+ margin-right: 1.5em;
+}
diff --git a/core/themes/seven/style.css b/core/themes/seven/style.css
new file mode 100644
index 000000000000..73410c0134b3
--- /dev/null
+++ b/core/themes/seven/style.css
@@ -0,0 +1,998 @@
+
+/**
+ * Generic elements.
+ */
+body {
+ color: #000;
+ background: #fff;
+ font: normal 81.3%/1.538em "Lucida Grande", "Lucida Sans Unicode", sans-serif;
+}
+a {
+ color: #0074BD;
+ text-decoration: none;
+}
+a:hover {
+ text-decoration: underline;
+}
+hr {
+ margin: 0;
+ padding: 0;
+ border: none;
+ height: 1px;
+ background: #cccccc;
+}
+legend {
+ font-weight: bold;
+}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-weight: bold;
+ margin: 10px 0;
+}
+h1 {
+ font-size: 1.538em;
+}
+h2 {
+ font-size: 1.385em;
+}
+h3 {
+ font-size: 1.231em;
+}
+h4 {
+ font-size: 1.154em;
+}
+h5,
+h6 {
+ font-size: 1.077em;
+}
+p {
+ margin: 1em 0;
+}
+dl {
+ margin: 0 0 20px;
+}
+dl dd,
+dl dl {
+ margin-left: 20px; /* LTR */
+ margin-bottom: 10px;
+}
+blockquote {
+ margin: 1em 40px;
+}
+address {
+ font-style: italic;
+}
+u,
+ins {
+ text-decoration: underline;
+}
+s,
+strike,
+del {
+ text-decoration: line-through;
+}
+big {
+ font-size: larger;
+}
+small {
+ font-size: smaller;
+}
+sub {
+ vertical-align: sub;
+ font-size: smaller;
+ line-height: normal;
+}
+sup {
+ vertical-align: super;
+ font-size: smaller;
+ line-height: normal;
+}
+nobr {
+ white-space: nowrap;
+}
+abbr,
+acronym {
+ border-bottom: dotted 1px;
+}
+ul,
+.block ul,
+.item-list ul {
+ list-style-type: disc;
+ list-style-image: none;
+ margin: 0.25em 0 0.25em 1.5em; /* LTR */
+}
+.item-list .pager li {
+ padding: 0.5em;
+}
+.item-list ul li,
+li.leaf,
+ul.menu li {
+ list-style-type: disc;
+ list-style-image: none;
+}
+ul.menu li {
+ margin: 0;
+}
+ol {
+ list-style-type: decimal;
+ margin: 0.25em 0 0.25em 2em; /* LTR */
+}
+.item-list ul li.collapsed,
+ul.menu li.collapsed {
+ list-style-image:url(../../misc/menu-collapsed.png);
+ list-style-type:disc;
+}
+.item-list ul li.expanded,
+ul.menu li.expanded {
+ list-style-image:url(../../misc/menu-expanded.png);
+ list-style-type:circle;
+}
+quote,
+code {
+ margin: .5em 0;
+}
+code,
+pre,
+kbd {
+ font-size: 1.231em;
+}
+pre {
+ margin: 0.5em 0;
+ white-space: pre-wrap;
+}
+
+/**
+ * Skip link.
+ */
+#skip-link {
+ margin-top: 0;
+ position: absolute;
+ left: 50%; /* LTR */
+ margin-left: -5.25em; /* LTR */
+ width: auto;
+ z-index: 50;
+}
+#skip-link a,
+#skip-link a:link,
+#skip-link a:visited {
+ display: block;
+ background: #444;
+ color: #fff;
+ font-size: 0.94em;
+ padding: 1px 10px 2px 10px; /* LTR */
+ text-decoration: none;
+ -moz-border-radius: 0 0 10px 10px;
+ -webkit-border-top-left-radius: 0;
+ -webkit-border-top-right-radius: 0;
+ -webkit-border-bottom-left-radius: 10px;
+ -webkit-border-bottom-right-radius: 10px;
+ border-radius: 0 0 10px 10px;
+}
+#skip-link a:hover,
+#skip-link a:focus,
+#skip-link a:active {
+ outline: 0;
+}
+
+/**
+ * Branding.
+ */
+#branding {
+ overflow: hidden;
+ padding: 20px 20px 0 20px; /* LTR */
+ position: relative;
+ background-color: #e0e0d8;
+}
+#branding div.breadcrumb {
+ font-size: 0.846em;
+ padding-bottom: 5px;
+}
+#branding div.block {
+ position: relative;
+ float: right; /* LTR */
+ width: 240px;
+ padding-left: 10px; /* LTR */
+ background: #333;
+}
+#branding div.block form label {
+ display: none;
+}
+#branding div.block form div.form-item {
+ float: left; /* LTR */
+ border: 0;
+ margin: 0;
+ padding: 0;
+}
+#branding div.block form input.form-text {
+ width: 140px;
+ margin-right: 10px; /* LTR */
+}
+#branding div.block form input.form-submit {
+ text-align: center;
+ width: 80px;
+}
+
+/**
+ * Help.
+ */
+#help {
+ font-size: 0.923em;
+ margin-top: 1em;
+}
+#help p {
+ margin: 0 0 10px;
+}
+#help div.more-help-link {
+ text-align: right; /* LTR */
+}
+
+/**
+ * Page title.
+ */
+#page-title {
+ background: #333;
+ padding-top: 20px;
+}
+#branding h1.page-title {
+ color: #000;
+ margin: 0;
+ padding-bottom: 10px;
+ font-size: 1.385em;
+ font-weight: normal;
+ float: left; /* LTR */
+}
+
+/**
+ * Console.
+ */
+#console {
+ margin: 9px 0 10px;
+}
+
+/**
+ * Tabs.
+ */
+ul.primary {
+ float: right; /* LTR */
+ border-bottom: none;
+ text-transform: uppercase;
+ font-size: 0.923em;
+ height: 2.60em;
+ margin: 0;
+ padding-top: 0;
+}
+ul.primary li {
+ float: left; /* LTR */
+ list-style: none;
+ margin: 0 2px;
+}
+ul.primary li a:link,
+ul.primary li a.active,
+ul.primary li a:active,
+ul.primary li a:visited,
+ul.primary li a:hover,
+ul.primary li.active a {
+ display: block;
+ float: left; /* LTR */
+ height: 2.60em;
+ line-height: 2.60em;
+ padding: 0 18px 8px;
+ background-color: #a6a7a2;
+ color: #000;
+ font-weight: bold;
+ border-width: 1px 1px 0 1px;
+ border-style: solid;
+ border-color: #a6a7a2;
+ -moz-border-radius: 8px 8px 0 0;
+ -webkit-border-top-left-radius: 8px;
+ -webkit-border-top-right-radius: 8px;
+ border-radius: 8px 8px 0 0;
+}
+ul.primary li.active a,
+ul.primary li.active a.active,
+ul.primary li.active a:active,
+ul.primary li.active a:visited {
+ background-color: #fff;
+ border-color: #c9cac4;
+}
+ul.primary li a:hover {
+ color: #fff;
+}
+ul.primary li.active a:hover {
+ color: #000;
+}
+.tabs-secondary {
+ clear: both;
+}
+ul.secondary {
+ float: right; /* LTR */
+ font-size: 0.923em;
+ padding: 0 3px 5px;
+ line-height: 1.385em;
+ overflow: hidden;
+ background-color: #fff;
+}
+ul.secondary li {
+ margin: 0 5px;
+ float: none; /* LTR */
+}
+ul.secondary li a {
+ background-color: #ddd;
+ color: #000;
+ display: inline-block;
+}
+ul.secondary li a,
+ul.secondary li a:hover,
+ul.secondary li.active a,
+ul.secondary li.active a.active {
+ padding: 2px 10px;
+ -moz-border-radius: 7px;
+ -webkit-border-radius: 7px;
+ border-radius: 7px;
+}
+ul.secondary li a:hover,
+ul.secondary li.active a,
+ul.secondary li.active a.active {
+ color: #fff;
+ background: #666;
+}
+#content {
+ clear: left;
+}
+
+/**
+ * Page layout.
+ */
+#page {
+ padding: 20px 0 40px 0; /* LTR */
+ margin-right: 40px; /* LTR */
+ margin-left: 40px; /* LTR */
+ background: #fff;
+ position: relative;
+ color: #333;
+}
+#secondary-links ul.links li {
+ padding: 0 10px 10px 0; /* LTR */
+}
+#secondary-links ul.links li a {
+ font-size: 0.923em;
+ background: #777;
+ color: #fff;
+ text-align: center;
+ padding: 5px;
+ height: 55px;
+ width: 80px;
+ overflow: hidden;
+ -moz-border-radius: 5px;
+ -webkit-border-radius: 5px;
+ border-radius: 5px;
+}
+#secondary-links ul.links li a:hover {
+ background: #999;
+}
+ul.links li,
+ul.inline li {
+ padding-right: 1em; /* LTR */
+}
+ul.inline li {
+ display: inline;
+}
+#secondary-links ul.links li.active-trail a,
+#secondary-links ul.links li a.active {
+ background: #333;
+}
+ul.admin-list li {
+ position: relative;
+ padding-left: 30px; /* LTR */
+ padding-top: 9px;
+ border-top: 1px solid #ccc;
+ margin-left: 0; /* LTR */
+ margin-bottom: 10px;
+ background: url(images/list-item.png) no-repeat 0 11px; /* LTR */
+ list-style-type: none;
+ list-style-image: none;
+}
+.admin-panel .item-list ul,
+ul.admin-list {
+ margin: 0;
+ padding: 0;
+}
+.admin-panel .item-list ul,
+ul.admin-list.compact {
+ margin: 8px 0;
+}
+.admin-panel .item-list li,
+ul.admin-list.compact li {
+ border: none;
+ background: none;
+ margin: 0 0 0.75em;
+ line-height: 1;
+ padding: 0;
+}
+ul.admin-list li:last-child {
+ border-bottom: none;
+}
+ul.admin-list li a {
+ margin-left: -30px; /* LTR */
+ padding: 0 0 4px 30px; /* LTR */
+ min-height: 0;
+}
+ul.admin-list.compact li a {
+ margin-left: 0; /* LTR */
+ padding: 0;
+}
+ul.admin-list li div.description a {
+ margin-left: 0; /* LTR */
+ padding: 0;
+ min-height: inherit;
+}
+div.submitted {
+ color: #898989;
+}
+
+/**
+ * Tables.
+ */
+table {
+ width: 100%;
+ font-size: 0.923em;
+ margin: 0 0 10px;
+ border: 1px solid #bebfb9;
+}
+table td,
+table th {
+ vertical-align: middle;
+ padding: 8px 10px;
+ border: 0;
+ color: #000;
+}
+tr.even,
+tr.odd {
+ border-width: 0 1px 0 1px;
+ border-style: solid;
+ border-color: #bebfb9;
+ background: #f3f4ee;
+}
+tr.odd {
+ background: #fff;
+}
+tr.drag {
+ background: #fe7;
+}
+tr.drag-previous {
+ background: #ffb;
+}
+table th {
+ text-transform: uppercase;
+ background: #e1e2dc;
+ font-weight: normal;
+ border-width: 1px;
+ border-style: solid;
+ border-color: #bebfb9;
+ padding: 3px 10px;
+}
+table th.active {
+ background: #bdbeb9;
+}
+table th a {
+ display: block;
+ position: relative;
+}
+table th.active a {
+ padding: 0 25px 0 0; /* LTR */
+}
+table th.active img {
+ position: absolute;
+ top: 3px;
+ right: 3px; /* LTR */
+}
+table td.active {
+ background: #e9e9dd;
+}
+table tr.odd td.active {
+ background: #f3f4ee;
+}
+table tr.selected td.active,
+table tr.selected td {
+ background: #ffc;
+ border-color: #eeb;
+}
+table.system-status-report tr {
+ border-bottom: 1px solid #ccc;
+}
+table.system-status-report tr.ok {
+ color: #255b1e;
+ background-color: #e5ffe2;
+}
+table.system-status-report tr.info {
+ color: #040f37;
+ background-color: #bdf;
+}
+table.system-status-report tr.warning {
+ color: #840;
+ background-color: #fffce5;
+}
+table.system-status-report tr.error {
+ color: #8c2e0b;
+ background-color: #fef5f1;
+}
+/**
+ * Exception for webkit bug with the right border of the last cell
+ * in some tables, since it's webkit only, we can use :last-child
+ */
+tr td:last-child {
+ border-right: 1px solid #bebfb9; /* LTR */
+}
+
+
+/**
+ * Fieldsets.
+ *
+ * Fieldset legends are displayed like containers in Seven. However, several
+ * browsers do not support styling of LEGEND elements. To achieve the desired
+ * styling:
+ * - All fieldsets use 'position: relative'.
+ * - All legend labels are wrapped in a single span.fieldset-legend that uses
+ * 'position: absolute', which means that the LEGEND element itself is not
+ * rendered by browsers.
+ * - Due to using 'position: absolute', collapsed fieldsets do not have a
+ * height; the fieldset requires a 'padding-top' to make the absolute
+ * positioned .fieldset-legend appear as though it would have a height.
+ * - Various browsers are positioning the legend differently if there is a
+ * 'padding-left'/'padding-right' applied on a fieldset and inherit the
+ * positioning even to absolute positioned elements within; we therefore have
+ * to apply all padding to the inner .fieldset-wrapper instead.
+ */
+fieldset {
+ border: 1px solid #ccc;
+ padding: 2.5em 0 0 0; /* LTR */
+ position: relative;
+ margin: 1em 0;
+}
+fieldset .fieldset-legend {
+ margin-top: 0.5em;
+ padding-left: 15px; /* LTR */
+ position: absolute;
+ text-transform: uppercase;
+}
+fieldset .fieldset-wrapper {
+ padding: 0 13px 13px 15px; /* LTR */
+}
+fieldset.collapsed {
+ background-color: transparent;
+}
+html.js fieldset.collapsed {
+ border-width: 1px;
+ height: auto;
+}
+fieldset fieldset {
+ background-color: #fff;
+}
+fieldset fieldset fieldset {
+ background-color: #f8f8f8;
+}
+
+/**
+ * Form elements.
+ */
+.form-item {
+ padding: 9px 0;
+ margin: 0 0 10px;
+}
+.filter-wrapper .form-item,
+div.teaser-checkbox .form-item,
+.form-item .form-item {
+ padding: 5px 0;
+ margin: 0;
+ border: 0;
+}
+.form-type-checkbox {
+ padding: 0;
+}
+.text-format-wrapper .form-item {
+ padding-bottom: 0;
+}
+.form-item label {
+ margin: 0;
+ padding: 0;
+}
+.form-item label.option {
+ font-size: 0.923em;
+ text-transform: none;
+}
+.form-item label.option input {
+ vertical-align: middle;
+}
+.form-disabled input.form-autocomplete,
+.form-disabled input.form-text,
+.form-disabled input.form-file,
+.form-disabled textarea.form-textarea,
+.form-disabled select.form-select {
+ background-color: #eee;
+ color: #777;
+}
+
+/* Filter */
+.filter-wrapper {
+ border-top: 0;
+ padding: 10px 2px;
+}
+.filter-wrapper .fieldset-wrapper {
+ padding: 0 6px;
+}
+.filter-wrapper .form-item,
+.filter-wrapper .filter-guidelines,
+.filter-wrapper .filter-help {
+ font-size: 0.923em;
+ padding: 2px 0 0 0; /* LTR */
+}
+ul.tips,
+div.description,
+.form-item div.description {
+ margin: 5px 0;
+ line-height: 1.231em;
+ font-size: 0.923em;
+ color: #666;
+}
+ul.tips li {
+ margin: 0.25em 0 0.25em 1.5em; /* LTR */
+}
+body div.form-type-radio div.description,
+body div.form-type-checkbox div.description {
+ margin-left: 1.5em; /* LTR */
+}
+input.form-submit,
+a.button {
+ cursor: pointer;
+ padding: 4px 17px;
+ margin-bottom: 1em;
+ margin-right: 1em; /* LTR */
+ color: #5a5a5a;
+ text-align: center;
+ font-weight: normal;
+ font-size: 1.077em;
+ font-family: "Lucida Grande", Verdana, sans-serif;
+ border: 1px solid #e4e4e4;
+ border-bottom: 1px solid #b4b4b4;
+ border-left-color: #d2d2d2;
+ border-right-color: #d2d2d2;
+ background: url(images/buttons.png) 0 0 repeat-x;
+ -moz-border-radius: 20px;
+ -webkit-border-radius: 20px;
+ border-radius: 20px;
+}
+a.button:link,
+a.button:visited,
+a.button:hover,
+a.button:active {
+ text-decoration: none;
+ color: #5a5a5a;
+}
+.node-form input#edit-submit,
+.node-form input#edit-submit-1 {
+ border: 1px solid #8eB7cd;
+ border-left-color: #8eB7cd;
+ border-right-color: #8eB7cd;
+ border-bottom-color: #7691a2;
+ background: url(images/buttons.png) 0 -40px repeat-x;
+ color: #133B54;
+}
+input.form-submit:active {
+ background: #666;
+ color: #fff;
+ border-color: #555;
+ text-shadow: #222 0 -1px 0;
+}
+input.form-button-disabled,
+input.form-button-disabled:active {
+ background: #eee none;
+ border-color: #eee;
+ text-shadow: none;
+ color: #999;
+}
+input.form-autocomplete,
+input.form-text,
+input.form-file,
+textarea.form-textarea,
+select.form-select {
+ padding: 2px;
+ border: 1px solid #ccc;
+ border-top-color: #999;
+ background: #fff;
+ color: #333;
+}
+input.form-text:focus,
+input.form-file:focus,
+textarea.form-textarea:focus,
+select.form-select:focus {
+ color: #000;
+ border-color: #ace;
+}
+html.js input.form-autocomplete {
+ background-position: 100% 4px;
+}
+html.js input.throbbing {
+ background-position: 100% -16px;
+}
+ul.action-links {
+ margin: 1em 0;
+ padding: 0 20px 0 20px; /* LTR */
+ list-style-type: none;
+ overflow: hidden;
+}
+ul.action-links li {
+ float: left; /* LTR */
+ margin: 0 1em 0 0; /* LTR */
+}
+ul.action-links a {
+ padding-left: 15px; /* LTR */
+ background: transparent url(images/add.png) no-repeat 0 center;
+ line-height: 30px;
+}
+
+/* Exceptions */
+#diff-inline-form select,
+div.filter-options select {
+ padding: 0;
+}
+
+/**
+ * System.
+ */
+div.admin .right,
+div.admin .left {
+ width: 49%;
+ margin: 0;
+}
+div.admin-panel,
+div.admin-panel .body {
+ padding: 0;
+ clear: left;
+}
+div.admin-panel {
+ margin: 0 0 20px;
+ padding: 9px;
+ background: #f8f8f8;
+ border: 1px solid #ccc;
+}
+div.admin-panel h3 {
+ font-size: 0.923em;
+ text-transform: uppercase;
+ margin: 0;
+ padding-bottom: 9px;
+}
+
+/* admin/appearance */
+#system-themes-page h2 {
+ font-weight: normal;
+ text-transform: uppercase;
+}
+.theme-selector h3 {
+ font-weight: normal;
+}
+.theme-default h3 {
+ font-weight: bold;
+}
+.system-themes-list-enabled .theme-selector h3 {
+ margin-top: 0;
+}
+
+/* Update options. */
+div.admin-options {
+ background: #f8f8f8;
+ line-height: 30px;
+ height: 30px;
+ padding: 9px;
+ border: 1px solid #ccc;
+ margin: 0 0 10px;
+}
+div.admin-options label {
+ text-transform: uppercase;
+ font: 0.846em/1.875em Lucida Grande, Lucida Sans Unicode, sans-serif;
+}
+div.admin-options label,
+div.admin-options div.form-item {
+ margin-right: 10px; /* LTR */
+ float: left; /* LTR */
+}
+div.admin-options div.form-item {
+ padding: 0;
+ border: 0;
+}
+
+/* Update status */
+.versions table.version {
+ border: none;
+}
+
+/* Maintenance theming */
+body.in-maintenance #sidebar-first {
+ float: left; /* LTR */
+ width: 200px;
+}
+body.in-maintenance #content {
+ float: right; /* LTR */
+ width: 550px;
+ padding-right: 20px; /* LTR */
+ clear: none;
+}
+body.in-maintenance #page {
+ overflow: auto;
+ width: 770px;
+ margin: 0 auto;
+ padding-top: 2em;
+}
+body.in-maintenance #branding h1 {
+ width: 770px;
+ margin: 0 auto;
+ float: none;
+}
+body.in-maintenance .form-radios .form-type-radio {
+ padding: 2px 0;
+}
+body.in-maintenance div.form-item:after {
+ content: "";
+ display: none;
+ clear: none;
+}
+body.in-maintenance .form-submit {
+ display: block;
+}
+body.in-maintenance #logo {
+ margin-bottom: 1.5em;
+}
+ol.task-list {
+ margin-left: 0; /* LTR */
+ list-style-type: none;
+ list-style-image: none;
+}
+ol.task-list li {
+ padding: 0.5em 1em 0.5em 20px; /* LTR */
+ color: #adadad;
+}
+ol.task-list li.active {
+ background: transparent url(images/task-item.png) no-repeat 3px 50%; /* LTR */
+ padding: 0.5em 1em 0.5em 20px; /* LTR */
+ color: #000;
+}
+ol.task-list li.done {
+ color: #393;
+ background: transparent url(images/task-check.png) no-repeat 0 50%;
+ color: green;
+}
+
+/* Overlay theming */
+.overlay #branding {
+ background-color: #fff;
+ padding-top: 15px;
+}
+.overlay #branding h1.page-title,
+.overlay #left,
+.overlay #footer {
+ display: none;
+}
+.overlay #page {
+ margin: 0;
+ padding: 0 20px;
+}
+.overlay #branding div.breadcrumb {
+ float: left; /* LTR */
+ position: relative;
+ z-index: 10;
+}
+#overlay-tabs {
+ bottom: -1px;
+ font-size: 1.54em;
+ line-height: 1.54em;
+ margin: 0;
+}
+#overlay-tabs li {
+ margin: 0 -2px;
+}
+.overlay ul.secondary {
+ background: transparent none;
+ margin: -1.4em 0 0.3em 0; /* LTR */
+ overflow: visible;
+}
+.overlay #content {
+ padding: 0;
+}
+h1#overlay-title {
+ font-weight: normal;
+}
+
+/* Shortcut theming */
+div.add-or-remove-shortcuts {
+ float: left; /* LTR */
+ padding-top: 6px;
+ padding-left: 6px; /* LTR */
+}
+
+/* Dashboard */
+#dashboard .dashboard-region div.block h2 {
+ background: #E0E0D8;
+}
+#dashboard div.block h2 {
+ margin: 0;
+ font-size: 1em;
+ padding: 3px 10px;
+}
+#dashboard div.block div.content {
+ padding: 10px 5px 5px 5px; /* LTR */
+}
+#dashboard div.block div.content ul.menu {
+ margin-left: 20px; /* LTR */
+}
+#dashboard .dashboard-region .block {
+ border: #ccc 1px solid;
+}
+
+/* Field UI */
+
+#field-display-overview input.field-formatter-settings-edit {
+ margin: 0;
+ padding: 1px 8px;
+}
+#field-display-overview tr.field-formatter-settings-changed {
+ background: #FFFFBB;
+}
+#field-display-overview tr.drag {
+ background: #FFEE77;
+}
+#field-display-overview tr.field-formatter-settings-editing {
+ background: #D5E9F2;
+}
+#field-display-overview .field-formatter-settings-edit-form .form-item {
+ margin: 10px 0;
+}
+#field-display-overview .field-formatter-settings-edit-form .form-submit {
+ margin-bottom: 0;
+}
+
+/* Recent content block */
+#dashboard div#block-node-recent div.content {
+ padding: 0;
+}
+#block-node-recent table,
+#block-node-recent tr {
+ border: none;
+}
+#block-node-recent .more-link {
+ padding: 0 5px 5px 0; /* LTR */
+}
+
+/* User login block */
+#user-login-form .openid-links {
+ margin-left: 0; /* LTR */
+}
+#user-login-form .openid-links .user-link {
+ margin-left: 1.5em; /* LTR */
+}
+
+/* Disable overlay message */
+#overlay-disable-message {
+ background-color: #addafc;
+}
+#overlay-disable-message a,
+#overlay-disable-message a:visited {
+ color: #000;
+}
+#overlay-disable-message a:focus,
+#overlay-disable-message a:active {
+ outline: none;
+ text-decoration: underline;
+}
+.overlay-disable-message-focused a {
+ padding: 0.4em 0.6em;
+}
+.overlay-disable-message-focused #overlay-dismiss-message {
+ background-color: #59a0d8;
+ color: #fff;
+ -moz-border-radius: 8px;
+ -webkit-border-radius: 8px;
+ border-radius: 8px;
+}
diff --git a/core/themes/seven/template.php b/core/themes/seven/template.php
new file mode 100644
index 000000000000..b1073b7f1948
--- /dev/null
+++ b/core/themes/seven/template.php
@@ -0,0 +1,113 @@
+<?php
+
+/**
+ * Override or insert variables into the maintenance page template.
+ */
+function seven_preprocess_maintenance_page(&$vars) {
+ // While markup for normal pages is split into page.tpl.php and html.tpl.php,
+ // the markup for the maintenance page is all in the single
+ // maintenance-page.tpl.php template. So, to have what's done in
+ // seven_preprocess_html() also happen on the maintenance page, it has to be
+ // called here.
+ seven_preprocess_html($vars);
+}
+
+/**
+ * Override or insert variables into the html template.
+ */
+function seven_preprocess_html(&$vars) {
+ // Add conditional CSS for IE8 and below.
+ drupal_add_css(path_to_theme() . '/ie.css', array('group' => CSS_THEME, 'browsers' => array('IE' => 'lte IE 8', '!IE' => FALSE), 'weight' => 999, 'preprocess' => FALSE));
+ // Add conditional CSS for IE7 and below.
+ drupal_add_css(path_to_theme() . '/ie7.css', array('group' => CSS_THEME, 'browsers' => array('IE' => 'lte IE 7', '!IE' => FALSE), 'weight' => 999, 'preprocess' => FALSE));
+}
+
+/**
+ * Override or insert variables into the page template.
+ */
+function seven_preprocess_page(&$vars) {
+ $vars['primary_local_tasks'] = $vars['tabs'];
+ unset($vars['primary_local_tasks']['#secondary']);
+ $vars['secondary_local_tasks'] = array(
+ '#theme' => 'menu_local_tasks',
+ '#secondary' => $vars['tabs']['#secondary'],
+ );
+}
+
+/**
+ * Display the list of available node types for node creation.
+ */
+function seven_node_add_list($variables) {
+ $content = $variables['content'];
+ $output = '';
+ if ($content) {
+ $output = '<ul class="admin-list">';
+ foreach ($content as $item) {
+ $output .= '<li class="clearfix">';
+ $output .= '<span class="label">' . l($item['title'], $item['href'], $item['localized_options']) . '</span>';
+ $output .= '<div class="description">' . filter_xss_admin($item['description']) . '</div>';
+ $output .= '</li>';
+ }
+ $output .= '</ul>';
+ }
+ else {
+ $output = '<p>' . t('You have not created any content types yet. Go to the <a href="@create-content">content type creation page</a> to add a new content type.', array('@create-content' => url('admin/structure/types/add'))) . '</p>';
+ }
+ return $output;
+}
+
+/**
+ * Overrides theme_admin_block_content().
+ *
+ * Use unordered list markup in both compact and extended mode.
+ */
+function seven_admin_block_content($variables) {
+ $content = $variables['content'];
+ $output = '';
+ if (!empty($content)) {
+ $output = system_admin_compact_mode() ? '<ul class="admin-list compact">' : '<ul class="admin-list">';
+ foreach ($content as $item) {
+ $output .= '<li class="leaf">';
+ $output .= l($item['title'], $item['href'], $item['localized_options']);
+ if (isset($item['description']) && !system_admin_compact_mode()) {
+ $output .= '<div class="description">' . filter_xss_admin($item['description']) . '</div>';
+ }
+ $output .= '</li>';
+ }
+ $output .= '</ul>';
+ }
+ return $output;
+}
+
+/**
+ * Override of theme_tablesort_indicator().
+ *
+ * Use our own image versions, so they show up as black and not gray on gray.
+ */
+function seven_tablesort_indicator($variables) {
+ $style = $variables['style'];
+ $theme_path = drupal_get_path('theme', 'seven');
+ if ($style == 'asc') {
+ return theme('image', array('path' => $theme_path . '/images/arrow-asc.png', 'alt' => t('sort ascending'), 'width' => 13, 'height' => 13, 'title' => t('sort ascending')));
+ }
+ else {
+ return theme('image', array('path' => $theme_path . '/images/arrow-desc.png', 'alt' => t('sort descending'), 'width' => 13, 'height' => 13, 'title' => t('sort descending')));
+ }
+}
+
+/**
+ * Implements hook_css_alter().
+ */
+function seven_css_alter(&$css) {
+ // Use Seven's vertical tabs style instead of the default one.
+ if (isset($css['core/misc/vertical-tabs.css'])) {
+ $css['core/misc/vertical-tabs.css']['data'] = drupal_get_path('theme', 'seven') . '/vertical-tabs.css';
+ }
+ if (isset($css['core/misc/vertical-tabs-rtl.css'])) {
+ $css['core/misc/vertical-tabs-rtl.css']['data'] = drupal_get_path('theme', 'seven') . '/vertical-tabs-rtl.css';
+ }
+ // Use Seven's jQuery UI theme style instead of the default one.
+ if (isset($css['core/misc/ui/jquery.ui.theme.css'])) {
+ $css['core/misc/ui/jquery.ui.theme.css']['data'] = drupal_get_path('theme', 'seven') . '/jquery.ui.theme.css';
+ }
+}
diff --git a/core/themes/seven/vertical-tabs-rtl.css b/core/themes/seven/vertical-tabs-rtl.css
new file mode 100644
index 000000000000..a9598c36a05a
--- /dev/null
+++ b/core/themes/seven/vertical-tabs-rtl.css
@@ -0,0 +1,21 @@
+
+/**
+ * Override of misc/vertical-tabs-rtl.css.
+ */
+div.vertical-tabs {
+ background: #fff url(images/fc-rtl.png) repeat-y right 0;
+}
+div.vertical-tabs .vertical-tabs-list {
+ float: right;
+ margin: 0 0 -1px -100%;
+}
+div.vertical-tabs ul li.selected a,
+div.vertical-tabs ul li.selected a:hover,
+div.vertical-tabs ul li.selected a:focus,
+div.vertical-tabs ul li.selected a:active {
+ border-left-color: #fff;
+}
+div.vertical-tabs .vertical-tabs-panes {
+ margin: 0 265px 0 0;
+ padding: 10px 0 10px 15px;
+}
diff --git a/core/themes/seven/vertical-tabs.css b/core/themes/seven/vertical-tabs.css
new file mode 100644
index 000000000000..f06aade8135b
--- /dev/null
+++ b/core/themes/seven/vertical-tabs.css
@@ -0,0 +1,89 @@
+
+/**
+ * Override of misc/vertical-tabs.css.
+ */
+div.vertical-tabs {
+ background: #fff url(images/fc.png) repeat-y 0 0; /* LTR */
+ border: 1px solid #ccc;
+ margin: 10px 0;
+ position: relative;
+}
+div.vertical-tabs fieldset {
+ border: 0;
+ padding: 0;
+ margin: 0;
+}
+div.vertical-tabs .vertical-tabs-list {
+ border-bottom: 1px solid #ccc;
+ float: left; /* LTR */
+ font-size: 1em;
+ line-height: 1;
+ margin: 0 -100% -1px 0; /* LTR */
+ padding: 0;
+ width: 240px;
+}
+div.vertical-tabs ul li.vertical-tab-button {
+ list-style: none;
+ list-style-image: none;
+ margin: 0;
+}
+div.vertical-tabs ul li.vertical-tab-button a {
+ border-top: 1px solid #ccc;
+ display: block;
+ padding: 10px;
+}
+div.vertical-tabs ul li.first a {
+ border-top: 0;
+}
+div.vertical-tabs ul li.vertical-tab-button strong {
+ font-size: 0.923em;
+}
+div.vertical-tabs ul li.vertical-tab-button .summary {
+ color: #666;
+ display: block;
+ font-size: 0.846em;
+ padding-top: 0.4em;
+}
+div.vertical-tabs ul li.vertical-tab-button a:hover,
+div.vertical-tabs ul li.vertical-tab-button a:focus {
+ background: #d5d5d5;
+ text-decoration: none;
+ outline: 0;
+}
+div.vertical-tabs ul li.selected a,
+div.vertical-tabs ul li.selected a:hover,
+div.vertical-tabs ul li.selected a:focus,
+div.vertical-tabs ul li.selected a:active {
+ background: #fff;
+ border-right-color: #fff; /* LTR */
+ border-top: 1px solid #ccc;
+}
+div.vertical-tabs ul li.first.selected a,
+div.vertical-tabs ul li.first.selected a:hover {
+ border-top: 0;
+}
+div.vertical-tabs ul li.selected a:focus strong {
+ text-decoration: underline;
+}
+div.vertical-tabs .vertical-tabs-panes {
+ margin: 0 0 0 265px; /* LTR */
+ padding: 10px 15px 10px 0; /* LTR */
+}
+div.vertical-tabs .vertical-tabs-panes legend {
+ display: none;
+}
+.vertical-tabs-pane .fieldset-wrapper > div:first-child {
+ padding-top: 5px;
+}
+
+/**
+ * Prevent text inputs from overflowing when container is too narrow. "width" is
+ * applied to override hardcoded cols or size attributes and used in conjunction
+ * with "box-sizing" to prevent box model issues from occurring in most browsers.
+*/
+.vertical-tabs .form-type-textfield input {
+ width: 100%;
+ -moz-box-sizing: border-box;
+ -webkit-box-sizing: border-box;
+ box-sizing: border-box;
+}
diff --git a/core/themes/stark/README.txt b/core/themes/stark/README.txt
new file mode 100644
index 000000000000..e7af281ee31a
--- /dev/null
+++ b/core/themes/stark/README.txt
@@ -0,0 +1,25 @@
+
+ABOUT STARK
+-----------
+
+The Stark theme is provided for demonstration purposes; it uses Drupal's default
+HTML markup and CSS styles. It can be used as a troubleshooting tool to
+determine whether module-related CSS and JavaScript are interfering with a more
+complex theme, and can be used by designers interested in studying Drupal's
+default markup without the interference of changes commonly made by more complex
+themes.
+
+To avoid obscuring CSS added to the page by Drupal or a contrib module, the
+Stark theme itself has no styling, except just enough CSS to arrange the page in
+a traditional "Header, sidebars, content, and footer" layout. See the layout.css
+file for more information.
+
+
+ABOUT DRUPAL THEMING
+--------------------
+
+To learn how to build your own custom theme and override Drupal's default code,
+see the Theming Guide: http://drupal.org/theme-guide
+
+See the sites/all/themes/README.txt for more information on where to place your
+custom themes to ensure easy maintenance and upgrades.
diff --git a/core/themes/stark/layout.css b/core/themes/stark/layout.css
new file mode 100644
index 000000000000..7e49d74a8f7c
--- /dev/null
+++ b/core/themes/stark/layout.css
@@ -0,0 +1,55 @@
+
+/**
+ * @file
+ * Stark layout method
+ *
+ * To avoid obscuring CSS added to the page by Drupal or a contrib module, the
+ * Stark theme itself has no styling, except just enough CSS to arrange the page
+ * in a traditional "Header, sidebars, content, and footer" layout.
+ *
+ * This layout method works reasonably well, but shouldn't be used on a
+ * production site because it can break. For example, if an over-large image
+ * (one that is wider than 20% of the viewport) is in the left sidebar, the
+ * image will overlap with the #content to the right.
+ */
+
+#content,
+#sidebar-first,
+#sidebar-second {
+ float: left;
+ display: inline;
+ position: relative;
+}
+
+#content {
+ width: 100%;
+}
+body.sidebar-first #content {
+ width: 80%;
+ left: 20%; /* LTR */
+}
+body.sidebar-second #content {
+ width: 80%;
+}
+body.two-sidebars #content {
+ width: 60%;
+ left: 20%;
+}
+
+#sidebar-first {
+ width: 20%;
+ left: -80%; /* LTR */
+}
+
+body.two-sidebars #sidebar-first {
+ left: -60%; /* LTR */
+}
+
+#sidebar-second {
+ float: right; /* LTR */
+ width: 20%;
+}
+
+.section {
+ margin: 10px;
+}
diff --git a/core/themes/stark/logo.png b/core/themes/stark/logo.png
new file mode 100644
index 000000000000..32332cf34570
--- /dev/null
+++ b/core/themes/stark/logo.png
Binary files differ
diff --git a/core/themes/stark/screenshot.png b/core/themes/stark/screenshot.png
new file mode 100644
index 000000000000..6fe457f8add0
--- /dev/null
+++ b/core/themes/stark/screenshot.png
Binary files differ
diff --git a/core/themes/stark/stark.info b/core/themes/stark/stark.info
new file mode 100644
index 000000000000..5773c5e4da15
--- /dev/null
+++ b/core/themes/stark/stark.info
@@ -0,0 +1,6 @@
+name = Stark
+description = This theme demonstrates Drupal's default HTML markup and CSS styles. To learn how to build your own theme and override Drupal's default code, see the <a href="http://drupal.org/theme-guide">Theming Guide</a>.
+package = Core
+version = VERSION
+core = 8.x
+stylesheets[all][] = layout.css
diff --git a/core/themes/tests/README.txt b/core/themes/tests/README.txt
new file mode 100644
index 000000000000..5ddaa8caf22e
--- /dev/null
+++ b/core/themes/tests/README.txt
@@ -0,0 +1,4 @@
+
+The themes in this subdirectory are all used by the Drupal core testing
+framework. They are not functioning themes that could be used on a real site
+and are hidden in the administrative user interface.
diff --git a/core/themes/tests/test_theme/template.php b/core/themes/tests/test_theme/template.php
new file mode 100644
index 000000000000..ef8118a6da94
--- /dev/null
+++ b/core/themes/tests/test_theme/template.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * Tests a theme overriding a suggestion of a base theme hook.
+ */
+function test_theme_breadcrumb__suggestion($variables) {
+ // Tests that preprocess functions for the base theme hook get called even
+ // when the suggestion has an implementation.
+ return 'test_theme_breadcrumb__suggestion: ' . $variables['theme_test_preprocess_breadcrumb'];
+}
+
+/**
+ * Tests a theme implementing an alter hook.
+ *
+ * The confusing function name here is due to this being an implementation of
+ * the alter hook invoked when the 'theme_test' module calls
+ * drupal_alter('theme_test_alter').
+ */
+function test_theme_theme_test_alter_alter(&$data) {
+ $data = 'test_theme_theme_test_alter_alter was invoked';
+}
diff --git a/core/themes/tests/test_theme/test_theme.info b/core/themes/tests/test_theme/test_theme.info
new file mode 100644
index 000000000000..c32fe57a629e
--- /dev/null
+++ b/core/themes/tests/test_theme/test_theme.info
@@ -0,0 +1,16 @@
+name = Test theme
+description = Theme for testing the theme system
+core = 8.x
+hidden = TRUE
+
+; Normally, themes may list CSS files like this, and if they exist in the theme
+; folder, then they get added to the page. If they have the same file name as a
+; module CSS file, then the theme's version overrides the module's version, so
+; that the module's version is not added to the page. Additionally, a theme may
+; have an entry like this one, without having the corresponding CSS file in the
+; theme's folder, and in this case, it just stops the module's version from
+; being loaded, and does not replace it with an alternate version. We have this
+; here in order for a test to ensure that this correctly prevents the module
+; version from being loaded, and that errors aren't caused by the lack of this
+; file within the theme folder.
+stylesheets[all][] = system.base.css
diff --git a/core/themes/tests/test_theme/theme_test.template_test.tpl.php b/core/themes/tests/test_theme/theme_test.template_test.tpl.php
new file mode 100644
index 000000000000..d4409bf16e0a
--- /dev/null
+++ b/core/themes/tests/test_theme/theme_test.template_test.tpl.php
@@ -0,0 +1,2 @@
+<!-- Output for Theme API test -->
+Success: Template overridden.
diff --git a/core/themes/tests/update_test_basetheme/update_test_basetheme.info b/core/themes/tests/update_test_basetheme/update_test_basetheme.info
new file mode 100644
index 000000000000..c8550b5bc8c8
--- /dev/null
+++ b/core/themes/tests/update_test_basetheme/update_test_basetheme.info
@@ -0,0 +1,4 @@
+name = Update test base theme
+description = Test theme which acts as a base theme for other test subthemes.
+core = 8.x
+hidden = TRUE
diff --git a/core/themes/tests/update_test_subtheme/update_test_subtheme.info b/core/themes/tests/update_test_subtheme/update_test_subtheme.info
new file mode 100644
index 000000000000..c783dc28b1e8
--- /dev/null
+++ b/core/themes/tests/update_test_subtheme/update_test_subtheme.info
@@ -0,0 +1,5 @@
+name = Update test subtheme
+description = Test theme which uses update_test_basetheme as the base theme.
+core = 8.x
+base theme = update_test_basetheme
+hidden = TRUE
diff --git a/core/update.php b/core/update.php
new file mode 100644
index 000000000000..ac3b2553d963
--- /dev/null
+++ b/core/update.php
@@ -0,0 +1,474 @@
+<?php
+
+/**
+ * @file
+ * Administrative page for handling updates from one Drupal version to another.
+ *
+ * Point your browser to "http://www.example.com/core/update.php" and follow the
+ * instructions.
+ *
+ * If you are not logged in using either the site maintenance account or an
+ * account with the "Administer software updates" permission, you will need to
+ * modify the access check statement inside your settings.php file. After
+ * finishing the upgrade, be sure to open settings.php again, and change it
+ * back to its original state!
+ */
+
+// Change the directory to the Drupal root.
+chdir('..');
+
+/**
+ * Root directory of Drupal installation.
+ */
+define('DRUPAL_ROOT', getcwd());
+
+/**
+ * Global flag indicating that update.php is being run.
+ *
+ * When this flag is set, various operations do not take place, such as invoking
+ * hook_init() and hook_exit(), css/js preprocessing, and translation.
+ */
+define('MAINTENANCE_MODE', 'update');
+
+function update_selection_page() {
+ drupal_set_title('Drupal database update');
+ $elements = drupal_get_form('update_script_selection_form');
+ $output = drupal_render($elements);
+
+ update_task_list('select');
+
+ return $output;
+}
+
+function update_script_selection_form($form, &$form_state) {
+ $count = 0;
+ $incompatible_count = 0;
+ $form['start'] = array(
+ '#tree' => TRUE,
+ '#type' => 'fieldset',
+ '#collapsed' => TRUE,
+ '#collapsible' => TRUE,
+ );
+
+ // Ensure system.module's updates appear first.
+ $form['start']['system'] = array();
+
+ $updates = update_get_update_list();
+ $starting_updates = array();
+ $incompatible_updates_exist = FALSE;
+ foreach ($updates as $module => $update) {
+ if (!isset($update['start'])) {
+ $form['start'][$module] = array(
+ '#type' => 'item',
+ '#title' => $module . ' module',
+ '#markup' => $update['warning'],
+ '#prefix' => '<div class="messages warning">',
+ '#suffix' => '</div>',
+ );
+ $incompatible_updates_exist = TRUE;
+ continue;
+ }
+ if (!empty($update['pending'])) {
+ $starting_updates[$module] = $update['start'];
+ $form['start'][$module] = array(
+ '#type' => 'hidden',
+ '#value' => $update['start'],
+ );
+ $form['start'][$module . '_updates'] = array(
+ '#theme' => 'item_list',
+ '#items' => $update['pending'],
+ '#title' => $module . ' module',
+ );
+ }
+ if (isset($update['pending'])) {
+ $count = $count + count($update['pending']);
+ }
+ }
+
+ // Find and label any incompatible updates.
+ foreach (update_resolve_dependencies($starting_updates) as $function => $data) {
+ if (!$data['allowed']) {
+ $incompatible_updates_exist = TRUE;
+ $incompatible_count++;
+ $module_update_key = $data['module'] . '_updates';
+ if (isset($form['start'][$module_update_key]['#items'][$data['number']])) {
+ $text = $data['missing_dependencies'] ? 'This update will been skipped due to the following missing dependencies: <em>' . implode(', ', $data['missing_dependencies']) . '</em>' : "This update will be skipped due to an error in the module's code.";
+ $form['start'][$module_update_key]['#items'][$data['number']] .= '<div class="warning">' . $text . '</div>';
+ }
+ // Move the module containing this update to the top of the list.
+ $form['start'] = array($module_update_key => $form['start'][$module_update_key]) + $form['start'];
+ }
+ }
+
+ // Warn the user if any updates were incompatible.
+ if ($incompatible_updates_exist) {
+ drupal_set_message('Some of the pending updates cannot be applied because their dependencies were not met.', 'warning');
+ }
+
+ if (empty($count)) {
+ drupal_set_message(t('No pending updates.'));
+ unset($form);
+ $form['links'] = array(
+ '#markup' => theme('item_list', array('items' => update_helpful_links())),
+ );
+ }
+ else {
+ $form['help'] = array(
+ '#markup' => '<p>The version of Drupal you are updating from has been automatically detected.</p>',
+ '#weight' => -5,
+ );
+ if ($incompatible_count) {
+ $form['start']['#title'] = format_plural(
+ $count,
+ '1 pending update (@number_applied to be applied, @number_incompatible skipped)',
+ '@count pending updates (@number_applied to be applied, @number_incompatible skipped)',
+ array('@number_applied' => $count - $incompatible_count, '@number_incompatible' => $incompatible_count)
+ );
+ }
+ else {
+ $form['start']['#title'] = format_plural($count, '1 pending update', '@count pending updates');
+ }
+ $form['has_js'] = array(
+ '#type' => 'hidden',
+ '#default_value' => FALSE,
+ );
+ $form['actions'] = array('#type' => 'actions');
+ $form['actions']['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Apply pending updates',
+ );
+ }
+ return $form;
+}
+
+function update_helpful_links() {
+ // NOTE: we can't use l() here because the URL would point to
+ // 'core/update.php?q=admin'.
+ $links[] = '<a href="' . base_path() . '">Front page</a>';
+ $links[] = '<a href="' . base_path() . '?q=admin">Administration pages</a>';
+ return $links;
+}
+
+function update_results_page() {
+ drupal_set_title('Drupal database update');
+ $links = update_helpful_links();
+
+ update_task_list();
+ // Report end result.
+ if (module_exists('dblog')) {
+ $log_message = ' All errors have been <a href="' . base_path() . '?q=admin/reports/dblog">logged</a>.';
+ }
+ else {
+ $log_message = ' All errors have been logged.';
+ }
+
+ if ($_SESSION['update_success']) {
+ $output = '<p>Updates were attempted. If you see no failures below, you may proceed happily to the <a href="' . base_path() . '?q=admin">administration pages</a>. Otherwise, you may need to update your database manually.' . $log_message . '</p>';
+ }
+ else {
+ list($module, $version) = array_pop(reset($_SESSION['updates_remaining']));
+ $output = '<p class="error">The update process was aborted prematurely while running <strong>update #' . $version . ' in ' . $module . '.module</strong>.' . $log_message;
+ if (module_exists('dblog')) {
+ $output .= ' You may need to check the <code>watchdog</code> database table manually.';
+ }
+ $output .= '</p>';
+ }
+
+ if (!empty($GLOBALS['update_free_access'])) {
+ $output .= "<p><strong>Reminder: don't forget to set the <code>\$update_free_access</code> value in your <code>settings.php</code> file back to <code>FALSE</code>.</strong></p>";
+ }
+
+ $output .= theme('item_list', array('items' => $links));
+
+ // Output a list of queries executed.
+ if (!empty($_SESSION['update_results'])) {
+ $all_messages = '';
+ foreach ($_SESSION['update_results'] as $module => $updates) {
+ if ($module != '#abort') {
+ $module_has_message = FALSE;
+ $query_messages = '';
+ foreach ($updates as $number => $queries) {
+ $messages = array();
+ foreach ($queries as $query) {
+ // If there is no message for this update, don't show anything.
+ if (empty($query['query'])) {
+ continue;
+ }
+
+ if ($query['success']) {
+ $messages[] = '<li class="success">' . $query['query'] . '</li>';
+ }
+ else {
+ $messages[] = '<li class="failure"><strong>Failed:</strong> ' . $query['query'] . '</li>';
+ }
+ }
+
+ if ($messages) {
+ $module_has_message = TRUE;
+ $query_messages .= '<h4>Update #' . $number . "</h4>\n";
+ $query_messages .= '<ul>' . implode("\n", $messages) . "</ul>\n";
+ }
+ }
+
+ // If there were any messages in the queries then prefix them with the
+ // module name and add it to the global message list.
+ if ($module_has_message) {
+ $all_messages .= '<h3>' . $module . " module</h3>\n" . $query_messages;
+ }
+ }
+ }
+ if ($all_messages) {
+ $output .= '<div id="update-results"><h2>The following updates returned messages</h2>';
+ $output .= $all_messages;
+ $output .= '</div>';
+ }
+ }
+ unset($_SESSION['update_results']);
+ unset($_SESSION['update_success']);
+
+ return $output;
+}
+
+function update_info_page() {
+ // Change query-strings on css/js files to enforce reload for all users.
+ _drupal_flush_css_js();
+ // Flush the cache of all data for the update status module.
+ if (db_table_exists('cache_update')) {
+ cache('update')->flush();
+ }
+
+ update_task_list('info');
+ drupal_set_title('Drupal database update');
+ $token = drupal_get_token('update');
+ $output = '<p>Use this utility to update your database whenever a new release of Drupal or a module is installed.</p><p>For more detailed information, see the <a href="http://drupal.org/upgrade">upgrading handbook</a>. If you are unsure what these terms mean you should probably contact your hosting provider.</p>';
+ $output .= "<ol>\n";
+ $output .= "<li><strong>Back up your database</strong>. This process will change your database values and in case of emergency you may need to revert to a backup.</li>\n";
+ $output .= "<li><strong>Back up your code</strong>. Hint: when backing up module code, do not leave that backup in the 'modules' or 'sites/*/modules' directories as this may confuse Drupal's auto-discovery mechanism.</li>\n";
+ $output .= '<li>Put your site into <a href="' . base_path() . '?q=admin/config/development/maintenance">maintenance mode</a>.</li>' . "\n";
+ $output .= "<li>Install your new files in the appropriate location, as described in the handbook.</li>\n";
+ $output .= "</ol>\n";
+ $output .= "<p>When you have performed the steps above, you may proceed.</p>\n";
+ $form_action = check_url(drupal_current_script_url(array('op' => 'selection', 'token' => $token)));
+ $output .= '<form method="post" action="' . $form_action . '"><p><input type="submit" value="Continue" class="form-submit" /></p></form>';
+ $output .= "\n";
+ return $output;
+}
+
+function update_access_denied_page() {
+ drupal_add_http_header('Status', '403 Forbidden');
+ watchdog('access denied', 'update.php', NULL, WATCHDOG_WARNING);
+ drupal_set_title('Access denied');
+ return '<p>Access denied. You are not authorized to access this page. Log in using either an account with the <em>administer software updates</em> permission or the site maintenance account (the account you created during installation). If you cannot log in, you will have to edit <code>settings.php</code> to bypass this access check. To do this:</p>
+<ol>
+ <li>With a text editor find the settings.php file on your system. From the main Drupal directory that you installed all the files into, go to <code>sites/your_site_name</code> if such directory exists, or else to <code>sites/default</code> which applies otherwise.</li>
+ <li>There is a line inside your settings.php file that says <code>$update_free_access = FALSE;</code>. Change it to <code>$update_free_access = TRUE;</code>.</li>
+ <li>As soon as the update.php script is done, you must change the settings.php file back to its original form with <code>$update_free_access = FALSE;</code>.</li>
+ <li>To avoid having this problem in the future, remember to log in to your website using either an account with the <em>administer software updates</em> permission or the site maintenance account (the account you created during installation) before you backup your database at the beginning of the update process.</li>
+</ol>';
+}
+
+/**
+ * Determines if the current user is allowed to run update.php.
+ *
+ * @return
+ * TRUE if the current user should be granted access, or FALSE otherwise.
+ */
+function update_access_allowed() {
+ global $update_free_access, $user;
+
+ // Allow the global variable in settings.php to override the access check.
+ if (!empty($update_free_access)) {
+ return TRUE;
+ }
+ // Calls to user_access() might fail during the Drupal 6 to 7 update process,
+ // so we fall back on requiring that the user be logged in as user #1.
+ try {
+ require_once DRUPAL_ROOT . '/' . drupal_get_path('module', 'user') . '/user.module';
+ return user_access('administer software updates');
+ }
+ catch (Exception $e) {
+ return ($user->uid == 1);
+ }
+}
+
+/**
+ * Add the update task list to the current page.
+ */
+function update_task_list($active = NULL) {
+ // Default list of tasks.
+ $tasks = array(
+ 'requirements' => 'Verify requirements',
+ 'info' => 'Overview',
+ 'select' => 'Review updates',
+ 'run' => 'Run updates',
+ 'finished' => 'Review log',
+ );
+
+ drupal_add_region_content('sidebar_first', theme('task_list', array('items' => $tasks, 'active' => $active)));
+}
+
+/**
+ * Returns (and optionally stores) extra requirements that only apply during
+ * particular parts of the update.php process.
+ */
+function update_extra_requirements($requirements = NULL) {
+ static $extra_requirements = array();
+ if (isset($requirements)) {
+ $extra_requirements += $requirements;
+ }
+ return $extra_requirements;
+}
+
+/**
+ * Check update requirements and report any errors or (optionally) warnings.
+ *
+ * @param $skip_warnings
+ * (optional) If set to TRUE, requirement warnings will be ignored, and a
+ * report will only be issued if there are requirement errors. Defaults to
+ * FALSE.
+ */
+function update_check_requirements($skip_warnings = FALSE) {
+ // Check requirements of all loaded modules.
+ $requirements = module_invoke_all('requirements', 'update');
+ $requirements += update_extra_requirements();
+ $severity = drupal_requirements_severity($requirements);
+
+ // If there are errors, always display them. If there are only warnings, skip
+ // them if the caller has indicated they should be skipped.
+ if ($severity == REQUIREMENT_ERROR || ($severity == REQUIREMENT_WARNING && !$skip_warnings)) {
+ update_task_list('requirements');
+ drupal_set_title('Requirements problem');
+ $status_report = theme('status_report', array('requirements' => $requirements));
+ $status_report .= 'Check the messages and <a href="' . check_url(drupal_requirements_url($severity)) . '">try again</a>.';
+ print theme('update_page', array('content' => $status_report));
+ exit();
+ }
+}
+
+// Some unavoidable errors happen because the database is not yet up-to-date.
+// Our custom error handler is not yet installed, so we just suppress them.
+ini_set('display_errors', FALSE);
+
+// We prepare a minimal bootstrap for the update requirements check to avoid
+// reaching the PHP memory limit.
+require_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+require_once DRUPAL_ROOT . '/core/includes/update.inc';
+require_once DRUPAL_ROOT . '/core/includes/common.inc';
+require_once DRUPAL_ROOT . '/core/includes/file.inc';
+require_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+update_prepare_d8_bootstrap();
+
+// Determine if the current user has access to run update.php.
+drupal_bootstrap(DRUPAL_BOOTSTRAP_SESSION);
+
+// Only allow the requirements check to proceed if the current user has access
+// to run updates (since it may expose sensitive information about the site's
+// configuration).
+$op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
+if (empty($op) && update_access_allowed()) {
+ require_once DRUPAL_ROOT . '/core/includes/install.inc';
+ require_once DRUPAL_ROOT . '/core/modules/system/system.install';
+
+ // Load module basics.
+ include_once DRUPAL_ROOT . '/core/includes/module.inc';
+ $module_list['system']['filename'] = 'core/modules/system/system.module';
+ module_list(TRUE, FALSE, FALSE, $module_list);
+ drupal_load('module', 'system');
+
+ // Reset the module_implements() cache so that any new hook implementations
+ // in updated code are picked up.
+ module_implements_reset();
+
+ // Set up $language, since the installer components require it.
+ drupal_language_initialize();
+
+ // Set up theme system for the maintenance page.
+ drupal_maintenance_theme();
+
+ // Check the update requirements for Drupal. Only report on errors at this
+ // stage, since the real requirements check happens further down.
+ update_check_requirements(TRUE);
+
+ // Redirect to the update information page if all requirements were met.
+ install_goto('core/update.php?op=info');
+}
+
+// update_fix_d8_requirements() needs to run before bootstrapping beyond path.
+// So bootstrap to DRUPAL_BOOTSTRAP_LANGUAGE then include unicode.inc.
+
+drupal_bootstrap(DRUPAL_BOOTSTRAP_LANGUAGE);
+include_once DRUPAL_ROOT . '/core/includes/unicode.inc';
+
+update_fix_d8_requirements();
+
+// Now proceed with a full bootstrap.
+
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+drupal_maintenance_theme();
+
+// Turn error reporting back on. From now on, only fatal errors (which are
+// not passed through the error handler) will cause a message to be printed.
+ini_set('display_errors', TRUE);
+
+// Only proceed with updates if the user is allowed to run them.
+if (update_access_allowed()) {
+
+ include_once DRUPAL_ROOT . '/core/includes/install.inc';
+ include_once DRUPAL_ROOT . '/core/includes/batch.inc';
+ drupal_load_updates();
+
+ update_fix_compatibility();
+
+ // Check the update requirements for all modules. If there are warnings, but
+ // no errors, skip reporting them if the user has provided a URL parameter
+ // acknowledging the warnings and indicating a desire to continue anyway. See
+ // drupal_requirements_url().
+ $skip_warnings = !empty($_GET['continue']);
+ update_check_requirements($skip_warnings);
+
+ $op = isset($_REQUEST['op']) ? $_REQUEST['op'] : '';
+ switch ($op) {
+ // update.php ops.
+
+ case 'selection':
+ if (isset($_GET['token']) && $_GET['token'] == drupal_get_token('update')) {
+ $output = update_selection_page();
+ break;
+ }
+
+ case 'Apply pending updates':
+ if (isset($_GET['token']) && $_GET['token'] == drupal_get_token('update')) {
+ // Generate absolute URLs for the batch processing (using $base_root),
+ // since the batch API will pass them to url() which does not handle
+ // update.php correctly by default.
+ $batch_url = $base_root . drupal_current_script_url();
+ $redirect_url = $base_root . drupal_current_script_url(array('op' => 'results'));
+ update_batch($_POST['start'], $redirect_url, $batch_url);
+ break;
+ }
+
+ case 'info':
+ $output = update_info_page();
+ break;
+
+ case 'results':
+ $output = update_results_page();
+ break;
+
+ // Regular batch ops : defer to batch processing API.
+ default:
+ update_task_list('run');
+ $output = _batch_page();
+ break;
+ }
+}
+else {
+ $output = update_access_denied_page();
+}
+if (isset($output) && $output) {
+ // Explicitly start a session so that the update.php token will be accepted.
+ drupal_session_start();
+ // We defer the display of messages until all updates are done.
+ $progress_page = ($batch = batch_get()) && isset($batch['running']);
+ print theme('update_page', array('content' => $output, 'show_messages' => !$progress_page));
+}
diff --git a/core/xmlrpc.php b/core/xmlrpc.php
new file mode 100644
index 000000000000..562aa81bedae
--- /dev/null
+++ b/core/xmlrpc.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @file
+ * PHP page for handling incoming XML-RPC requests from clients.
+ */
+
+// Change the directory to the Drupal root.
+chdir('..');
+
+/**
+ * Root directory of Drupal installation.
+ */
+define('DRUPAL_ROOT', getcwd());
+
+include_once DRUPAL_ROOT . '/core/includes/bootstrap.inc';
+drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
+include_once DRUPAL_ROOT . '/core/includes/xmlrpc.inc';
+include_once DRUPAL_ROOT . '/core/includes/xmlrpcs.inc';
+
+xmlrpc_server(module_invoke_all('xmlrpc'));